Merge branch 'upstream/develop' into develop
Daily Packaging / docker-multiarch (push) Skipped
Daily Packaging / package-ppa (jammy) (push) Skipped
Daily Packaging / package-ppa (noble) (push) Skipped
Daily Packaging / package-ppa (resolute) (push) Skipped
Daily Packaging / package-ppa (stonking) (push) Skipped
Daily Packaging / package-obs (push) Skipped
Daily Packaging / hook-copr (push) Skipped
CI / build-debian-src (push) Skipped
CI / package-pio-deps-native-tft (push) Skipped
CI / test-native (push) Skipped
CI / setup (push) Canceled after 0s
CI / version (push) Canceled after 0s
Build Windows Binary / build-Windows (push) Canceled after 0s
Build PortDuino WASM / Build PortDuino WASM (push) Canceled after 0s
CI / check (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / ci-gate (push) Canceled after 0s
CI / gather-artifacts (esp32) (push) Canceled after 0s
CI / gather-artifacts (esp32c3) (push) Canceled after 0s
CI / gather-artifacts (esp32c6) (push) Canceled after 0s
CI / gather-artifacts (esp32s3) (push) Canceled after 0s
CI / gather-artifacts (nrf52840) (push) Canceled after 0s
CI / gather-artifacts (rp2040) (push) Canceled after 0s
CI / gather-artifacts (rp2350) (push) Canceled after 0s
CI / gather-artifacts (stm32) (push) Canceled after 0s
CI / firmware-size-report (push) Canceled after 0s
CI / size-budget-gate (push) Canceled after 0s
CI / release-artifacts (push) Canceled after 0s
CI / release-firmware (esp32) (push) Canceled after 0s
CI / release-firmware (esp32c3) (push) Canceled after 0s
CI / release-firmware (esp32c6) (push) Canceled after 0s
CI / release-firmware (esp32s3) (push) Canceled after 0s
CI / release-firmware (nrf52840) (push) Canceled after 0s
CI / release-firmware (rp2040) (push) Canceled after 0s
CI / release-firmware (rp2350) (push) Canceled after 0s
CI / release-firmware (stm32) (push) Canceled after 0s
CI / publish-firmware (push) Canceled after 0s
CI / publish-nightly (push) Canceled after 0s
CI / MacOS (15) (push) Canceled after 0s
CI / MacOS (26) (push) Canceled after 0s
Build MacOS Binary / build-MacOS (push) Canceled after 0s
CI / Windows (2025) (push) Canceled after 0s
CI / build-wasm (push) Canceled after 0s
CI / docker (alpine, native-tft, linux/arm64) (push) Canceled after 0s
CI / docker (debian, native-tft, linux/arm64) (push) Canceled after 0s
Build Docker / docker-build (push) Canceled after 0s
Daily Packaging / docker-multiarch (push) Skipped
Daily Packaging / package-ppa (jammy) (push) Skipped
Daily Packaging / package-ppa (noble) (push) Skipped
Daily Packaging / package-ppa (resolute) (push) Skipped
Daily Packaging / package-ppa (stonking) (push) Skipped
Daily Packaging / package-obs (push) Skipped
Daily Packaging / hook-copr (push) Skipped
CI / build-debian-src (push) Skipped
CI / package-pio-deps-native-tft (push) Skipped
CI / test-native (push) Skipped
CI / setup (push) Canceled after 0s
CI / version (push) Canceled after 0s
Build Windows Binary / build-Windows (push) Canceled after 0s
Build PortDuino WASM / Build PortDuino WASM (push) Canceled after 0s
CI / check (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / ci-gate (push) Canceled after 0s
CI / gather-artifacts (esp32) (push) Canceled after 0s
CI / gather-artifacts (esp32c3) (push) Canceled after 0s
CI / gather-artifacts (esp32c6) (push) Canceled after 0s
CI / gather-artifacts (esp32s3) (push) Canceled after 0s
CI / gather-artifacts (nrf52840) (push) Canceled after 0s
CI / gather-artifacts (rp2040) (push) Canceled after 0s
CI / gather-artifacts (rp2350) (push) Canceled after 0s
CI / gather-artifacts (stm32) (push) Canceled after 0s
CI / firmware-size-report (push) Canceled after 0s
CI / size-budget-gate (push) Canceled after 0s
CI / release-artifacts (push) Canceled after 0s
CI / release-firmware (esp32) (push) Canceled after 0s
CI / release-firmware (esp32c3) (push) Canceled after 0s
CI / release-firmware (esp32c6) (push) Canceled after 0s
CI / release-firmware (esp32s3) (push) Canceled after 0s
CI / release-firmware (nrf52840) (push) Canceled after 0s
CI / release-firmware (rp2040) (push) Canceled after 0s
CI / release-firmware (rp2350) (push) Canceled after 0s
CI / release-firmware (stm32) (push) Canceled after 0s
CI / publish-firmware (push) Canceled after 0s
CI / publish-nightly (push) Canceled after 0s
CI / MacOS (15) (push) Canceled after 0s
CI / MacOS (26) (push) Canceled after 0s
Build MacOS Binary / build-MacOS (push) Canceled after 0s
CI / Windows (2025) (push) Canceled after 0s
CI / build-wasm (push) Canceled after 0s
CI / docker (alpine, native-tft, linux/arm64) (push) Canceled after 0s
CI / docker (debian, native-tft, linux/arm64) (push) Canceled after 0s
Build Docker / docker-build (push) Canceled after 0s
Merge upstream meshtastic/firmware develop (109 commits). Resolved conflicts: - protobufs submodule: take upstream pointer (aca181b9) - src/mesh/RadioInterface.cpp: retain local CN region max power 35 (power unlock)
This commit is contained in:
382 files changed
+24825
-5445
No files matched your search
@@ -31,3 +31,16 @@ reviews:
|
||||
instructions: >
|
||||
meshtasticd configuration files. Bundled with meshtasticd Linux/MacOS packaging.
|
||||
Ensure configurations include metadata found in other configs.
|
||||
- path: "**/*.md"
|
||||
instructions: >
|
||||
Documentation does not live in this repo; it lives in
|
||||
https://github.com/meshtastic/meshtastic. Flag any NEW .md file that documents a
|
||||
feature, configuration surface, API, wire format, or design, and ask for it to be
|
||||
opened against the docs repo instead. Flag any attempt to recreate a docs/
|
||||
directory: it was deleted in #11488 and must not come back. Flag write-ups left in
|
||||
the tree - investigation notes, mitigation plans, migration checklists, "how we got
|
||||
here" narrative, summaries of what a change did - that content belongs in the PR
|
||||
description and commit message. Documentation that does belong upstream must read
|
||||
as a technical manual, not a novel: what it does, the settings in user terms, the
|
||||
API or protocol a client speaks. No debugging journey, no rationale essays, no
|
||||
changelog prose.
|
||||
@@ -313,7 +313,7 @@ firmware/
|
||||
│ └── native/ # Linux/Portduino variants
|
||||
├── protobufs/ # Protocol buffer definitions
|
||||
├── boards/ # Custom PlatformIO board definitions
|
||||
├── test/ # Native unit-test suites (count: test/native-suite-count)
|
||||
├── test/ # Native unit-test suites (count = the test_* dirs, detected on the fly)
|
||||
└── bin/ # Build and utility scripts
|
||||
```
|
||||
|
||||
@@ -332,12 +332,25 @@ firmware/
|
||||
|
||||
- Follow existing code style - run `trunk fmt` before commits
|
||||
- Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging
|
||||
- **Three logging tiers for diagnostics.** `LOG_TRACE` is the per-packet/per-poll firehose - compiled out by default (`MESHTASTIC_TRACE_LOGGING=1` enables; always on for portduino). Subsystem bring-up detail routes through a per-subsystem gate macro instead, e.g. `LOG_DEBUG_GPS(...)` in `src/gps/GPSLog.h` (`GPS_DEBUG=1` enables; costs no flash when off) - model new subsystem gates on it or on `LOG_MIGRATION` (`src/mesh/WarmNodeStore.h`): `#ifndef` value-default, `#if SYM` value test, `((void)0)` off-branch. Genuine anomalies stay unconditional `LOG_WARN`/`LOG_ERROR`.
|
||||
- **Format node IDs and packet IDs as `0x%08x` in logs.** This covers `NodeNum`/`PacketId` and the `uint32_t` packet fields `from`, `to`, `id`, `dest`, `source`, `request_id`, and `node_id`. They are 32-bit, so 8 hex digits is exact - `%08x` never truncates or leaves a value ragged. Do **not** use `%x` (variable width) or `%0x` (a no-op typo for `%08x` - the `0` flag does nothing without a width). User-facing display uses `!%08x` (the `!xxxxxxxx` convention), e.g. `Applet::hexifyNodeNum`.
|
||||
- **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.
|
||||
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check - raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||
- **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.
|
||||
- `Throttle::hasElapsed(lastMs, intervalMs)` - its complement, true once the interval has passed (inclusive `>=`). Prefer this to spelling `!isWithinTimespanMs(...)`.
|
||||
- `Throttle::execute(&lastMs, intervalMs, func)` - function-pointer form that updates the timestamp on fire.
|
||||
- `Throttle::deadlinePassed(deadlineMs)` - for a stored absolute deadline that cannot be re-expressed as "interval since an event". Uses an unsigned half-range compare; reads deadlines more than ~24.8 days out as already passed, which no interval in this firmware approaches (the longest is 24 h).
|
||||
- `Throttle::deadlinePassedAt(nowMs, deadlineMs)` - the same test against a caller-supplied `now`, for a loop that snapshots the clock once and then tests many deadlines (`NextHopRouter::doRetransmissions()`). Take the snapshot from `Time::getMillis()`, not `millis()`.
|
||||
|
||||
Raw `millis() > deadline` or `deadline < millis()` is rollover-unsafe: the comparison inverts while the deadline sits on the far side of the 32-bit wrap, so the action fires immediately (losing its whole wait) or blocks for roughly the interval it should have waited - days, for the nRF52 flash-corruption backoff. All five helpers subtract first, so unsigned wraparound cancels out. `Throttle` reads the clock through `Time::getMillis()` (`src/UptimeClock.h`), which means every one of its ~94 call sites is time-injectable - a native test can drive `Time::setTestMillis(0xFFFFFF00)` across the wrap. For _timestamps_ (not deadlines) there is `Time::getMillisMonotonic()` / `Time::getUptimeSecs()` - a 64-bit monotonic uptime read. Readers are pure: they add their own wrap-immune elapsed time to a snapshot published by `Time::serviceMonotonic()`, which the main loop calls every iteration and which is **the only writer**. Never call `serviceMonotonic()` from anywhere else - two writers can count one wrap twice, putting every uptime and wall-clock reading ~49.7 days into the future for the rest of the boot. Not ISR-safe (the snapshot is read under a seqlock); see the contract in `UptimeClock.h`. Deadline and interval checks should still use `Throttle`, which needs no carry state at all.
|
||||
|
||||
**Sentinel hazard.** If a deadline variable also encodes "inactive" - `0` for `rebootAtMsec`, `shutdownAtMsec`, `alertBannerUntil`, `fixHoldEnds`; `UINT32_MAX` for `nagCycleCutoff` - test that sentinel _before_ the elapsed comparison, and match the test to the sentinel actually in use. `if (deadline && Throttle::deadlinePassed(deadline))` covers the `0` family only; `nagCycleCutoff` needs `deadline != UINT32_MAX`, or a separate armed flag as `ExternalNotificationModule` does with `isNagging`. Every sentinel value is arithmetically far in the past, so a correct comparison reads it as "expired" and fires immediately: `rebootAtMsec = -1` meaning "never" is what would have become a reboot loop. Never fold the sentinel into the helper.
|
||||
|
||||
**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.
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
@@ -663,7 +676,7 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing.
|
||||
|
||||
### Native unit tests (C++)
|
||||
|
||||
Unit tests in `test/` directory. The canonical suite count is in `test/native-suite-count`, cross-checked against `test/test_*` on every full run and by the `suite-count-check` CI job. **Never state the count as a literal anywhere else** - point at that file. The list below is a partial description of what suites cover, not an inventory:
|
||||
Unit tests in `test/` directory. The canonical suite count is detected on the fly: the `test_*` directories under `test/` are the register, and `bin/run-tests.sh` cross-checks the suites that actually ran against them on every full run. **Never state the count as a literal anywhere** - it is whatever `test/test_*` contains right now. In CI, the `suite-shrinkage-check` job (`test_native.yml`) fails a PR that loses a `test_*` directory relative to its merge base unless the suite is named in the PR title, body, or a commit message - deleting a suite therefore requires saying so. The list below is a partial description of what suites cover, not an inventory:
|
||||
|
||||
- `test_admin_radio/` - LoRa region/config validation, AdminModule dispatch, node-DB metadata saves
|
||||
- `test_fscommon_getfiles/` - bounded file-manifest walk (cap, depth, truncation reporting)
|
||||
@@ -693,7 +706,7 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su
|
||||
- `test_utf8/` - UTF-8 utilities
|
||||
- `test_warm_store/` - Warm-tier node store
|
||||
|
||||
**Preferred run command - `bin/run-tests.sh`** (defaults to the `coverage` env; emits a machine-readable verdict on the final line; update `test/native-suite-count` when adding or removing suites):
|
||||
**Preferred run command - `bin/run-tests.sh`** (defaults to the `coverage` env; emits a machine-readable verdict on the final line; new `test_*` directories are picked up automatically):
|
||||
|
||||
```bash
|
||||
./bin/run-tests.sh # all suites
|
||||
@@ -712,18 +725,18 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su
|
||||
|
||||
Exit codes and verdicts (exact counts will vary; examples below are illustrative):
|
||||
|
||||
| Exit | Verdict | Meaning |
|
||||
| ---- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 0 | `GREEN` | All canonical suites ran, all passed, no ignored test cases |
|
||||
| 1 | `RED` | At least one failure, build error, or sanitizer fault |
|
||||
| 2 | `AMBER` | All that ran passed, but something was lost or unexplained: a suite silently went missing on a full run, individual test cases were skipped (`TEST_IGNORE`), `test/native-suite-count` disagrees with the `test/` directory count, or a suite left behind shared state it does not declare |
|
||||
| 3 | `FILTERED` | A `-f` run completed cleanly; suites outside the filter were intentionally not run |
|
||||
| Exit | Verdict | Meaning |
|
||||
| ---- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 | `GREEN` | All canonical suites ran, all passed, no ignored test cases |
|
||||
| 1 | `RED` | At least one failure, build error, or sanitizer fault |
|
||||
| 2 | `AMBER` | All that ran passed, but something was lost or unexplained: a suite silently went missing on a full run, individual test cases were skipped (`TEST_IGNORE`), or a suite left behind shared state it does not declare |
|
||||
| 3 | `FILTERED` | A `-f` run completed cleanly; suites outside the filter were intentionally not run |
|
||||
|
||||
Examples - exact counts will vary by suite count and env:
|
||||
|
||||
```text
|
||||
# GREEN: all suites ran and passed
|
||||
RESULT: GREEN N/N suites passed [canonical: N/N]
|
||||
RESULT: GREEN N/N suites passed, all CLEAN
|
||||
|
||||
# RED: real test failure
|
||||
RESULT: RED 1 failed
|
||||
@@ -731,14 +744,11 @@ RESULT: RED 1 failed
|
||||
# RED: sanitizer exit-time abort (all tests passed but process aborted at exit)
|
||||
RESULT: RED exit-time abort (tests passed; likely sanitizer - see hint above)
|
||||
|
||||
# AMBER: native-suite-count disagrees with test/ directory count (too low)
|
||||
RESULT: AMBER test/ has 24 suite directories but native-suite-count says 5 - update test/native-suite-count after registering new suites
|
||||
|
||||
# AMBER: native-suite-count disagrees with test/ directory count (too high)
|
||||
RESULT: AMBER test/ has 24 suite directories but native-suite-count says 99 - update test/native-suite-count after removing suites
|
||||
# AMBER: a suite silently went missing on a full run
|
||||
RESULT: AMBER 23/24 suites ran (missing: test_radio) - all that ran passed
|
||||
|
||||
# FILTERED: single suite run completed cleanly
|
||||
RESULT: FILTERED 1/24 suites ran (not run: test_admin_radio test_atak …) - filtered: test_serial [canonical: 1/24]
|
||||
RESULT: FILTERED 1/24 suites ran (not run: test_admin_radio test_atak …) - filtered: test_serial
|
||||
```
|
||||
|
||||
> **Copilot interface note:** When running tests via the Copilot chat interface, edits made through the chat may not be reflected in the on-disk files that the test binary reads. If tests pass in chat but fail locally (or vice versa), verify the files on disk match what you expect before trusting the result. Always confirm with a local terminal run.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Allowlist for the millis-deadline-check guard in .github/workflows/test_native.yml.
|
||||
#
|
||||
# That guard rejects comparisons made directly against millis(), because they invert while the
|
||||
# deadline sits on the far side of the 32-bit wrap. Use Throttle::deadlinePassed(deadline) or
|
||||
# Throttle::hasElapsed(lastEvent, intervalMs) instead - see .github/copilot-instructions.md.
|
||||
#
|
||||
# Only add a line here when the comparison genuinely is not a deadline test. The usual valid case is
|
||||
# an *uptime threshold*: "has the device been up for at least N ms", where there is no stored
|
||||
# deadline and no event to measure from. Those still misbehave briefly after a wrap - the threshold
|
||||
# is simply re-crossed - which is harmless for boot-holdoff logic and not worth new state.
|
||||
#
|
||||
# Format: <path><TAB><exact trimmed source line, comments stripped>
|
||||
# Line numbers are deliberately absent so edits above an entry do not invalidate it. A `#` comment
|
||||
# on the code line is stripped before matching, so do not include one here.
|
||||
|
||||
# Boot holdoff, not a deadline: suppresses a phantom shutdown from floating pins during the first
|
||||
# 30s of uptime. Pairs with the buttonPressStartTime > 30000 test on the same line.
|
||||
src/input/ButtonThread.cpp if (millis() > 30000 && buttonPressStartTime > 30000 && _longLongPress != INPUT_BROKER_NONE &&
|
||||
|
||||
# Boot-window check, not a deadline: draws the custom OEM logo only during the first 10s of uptime,
|
||||
# so the ordinary Meshtastic logo is used at shutdown.
|
||||
src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.cpp if (millis() < 10 * 1000UL) {
|
||||
@@ -21,6 +21,10 @@ permissions:
|
||||
jobs:
|
||||
build-debian-src:
|
||||
runs-on: ubuntu-24.04
|
||||
# Only pushes to the default branch (develop) populate the cache; PR / merge_group runs
|
||||
# restore it but never save, so they stop filling up the repo's Actions cache storage.
|
||||
env:
|
||||
SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
@@ -58,6 +62,14 @@ jobs:
|
||||
BUILD_LOCATION: ${{ inputs.build_location }}
|
||||
id: version
|
||||
|
||||
- name: Restore PlatformIO cache
|
||||
id: pio-cache
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: meshtasticd/pio/core/.cache
|
||||
key: |
|
||||
pio-deb-src-${{ hashFiles('meshtasticd/platformio.ini', 'meshtasticd/variants/native/portduino.ini', 'meshtasticd/variants/native/portduino/platformio.ini') }}
|
||||
|
||||
- name: Fetch libdeps, package debian source
|
||||
working-directory: meshtasticd
|
||||
run: debian/ci_pack_sdeb.sh
|
||||
@@ -66,6 +78,18 @@ jobs:
|
||||
GPG_KEY_ID: ${{ steps.gpg.outputs.keyid || '' }}
|
||||
PKG_VERSION: ${{ steps.version.outputs.deb }}
|
||||
|
||||
- name: Extract cache from pio.tar
|
||||
if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true'
|
||||
run: tar -C meshtasticd -xf meshtasticd/pio.tar pio/core/.cache
|
||||
|
||||
- name: Save PlatformIO cache
|
||||
if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: meshtasticd/pio/core/.cache
|
||||
key: |
|
||||
pio-deb-src-${{ hashFiles('meshtasticd/platformio.ini', 'meshtasticd/variants/native/portduino.ini', 'meshtasticd/variants/native/portduino/platformio.ini') }}
|
||||
|
||||
- name: Store binaries as an artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
@@ -82,13 +82,20 @@ jobs:
|
||||
plat: ${{ inputs.platform }}
|
||||
run: echo "cleaned_platform=${plat}" | sed 's/\//_/g' >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker login
|
||||
- name: DockerHub login
|
||||
if: ${{ inputs.push }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: meshtastic
|
||||
password: ${{ secrets.DOCKER_FIRMWARE_TOKEN }}
|
||||
|
||||
- name: GHCR login
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker tag
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
@@ -98,6 +105,19 @@ jobs:
|
||||
GHA-${{ steps.version.outputs.long }}-${{ inputs.distro }}-${{ steps.sanitize_platform.outputs.cleaned_platform }}
|
||||
flavor: latest=false
|
||||
|
||||
- name: Docker setup caching
|
||||
id: docker-cache
|
||||
env:
|
||||
BASE_REF: ${{ github.event.merge_group.base_ref || github.event.pull_request.base.ref || github.ref_name }}
|
||||
run: |
|
||||
base=$(echo "${BASE_REF#refs/heads/}" | sed 's/\//_/g')
|
||||
ref=ghcr.io/${{ github.repository }}-cache:${base}-${{ inputs.distro }}-${{ steps.sanitize_platform.outputs.cleaned_platform }}
|
||||
echo "cache_from=type=registry,ref=${ref}" >> $GITHUB_OUTPUT
|
||||
case "${GITHUB_EVENT_NAME}" in
|
||||
merge_group|pull_request) ;;
|
||||
*) echo "cache_to=type=registry,ref=${ref},mode=max,ignore-error=true" >> $GITHUB_OUTPUT ;;
|
||||
esac
|
||||
|
||||
- name: Docker build and push
|
||||
uses: docker/build-push-action@v7
|
||||
id: docker_variant
|
||||
@@ -110,6 +130,6 @@ jobs:
|
||||
platforms: ${{ inputs.platform }}
|
||||
build-args: |
|
||||
PIO_ENV=${{ inputs.pio_env }}
|
||||
# Disabled for now: Cache image layers in GitHub Actions cache to speed up subsequent builds.
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
# Cache image layers in GitHub Container Registry to speed up subsequent builds.
|
||||
cache-from: ${{ steps.docker-cache.outputs.cache_from }}
|
||||
cache-to: ${{ steps.docker-cache.outputs.cache_to || '' }}
|
||||
@@ -55,6 +55,9 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Needed to diff against the base branch for newly added variants.
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
@@ -62,11 +65,33 @@ jobs:
|
||||
- run: pip install -U platformio
|
||||
- name: Generate matrix
|
||||
id: jsonStep
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
|
||||
run: |
|
||||
# A new board is 'release' and gets no CI until after merge, so force-build the
|
||||
# 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)
|
||||
elif [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
|
||||
DIFF_BASE="$MERGE_GROUP_BASE_SHA"
|
||||
fi
|
||||
ADDED_ARGS=()
|
||||
if [[ -n "$DIFF_BASE" ]]; then
|
||||
# Assign rather than pipe: a failing diff must abort the step under 'set -e',
|
||||
# not silently yield an empty list and drop the new board from the matrix.
|
||||
ADDED_CONFIGS=$(git diff --name-only --diff-filter=A \
|
||||
"$DIFF_BASE" HEAD -- 'variants/**/platformio.ini')
|
||||
while IFS= read -r cfg; do
|
||||
[[ -n "$cfg" ]] && ADDED_ARGS+=(--added-config "$cfg")
|
||||
done <<<"$ADDED_CONFIGS"
|
||||
fi
|
||||
# PRs and (for now) merge_group builds use the narrowed --level pr board
|
||||
# subset. Full-matrix builds run on push / schedule / workflow_dispatch.
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
|
||||
TARGETS=$(./bin/generate_ci_matrix.py all --level pr)
|
||||
TARGETS=$(./bin/generate_ci_matrix.py all --level pr "${ADDED_ARGS[@]}")
|
||||
else
|
||||
TARGETS=$(./bin/generate_ci_matrix.py all)
|
||||
fi
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Trunk Check
|
||||
uses: trunk-io/trunk-action@v1
|
||||
uses: trunk-io/trunk-action@v2.0.0
|
||||
with:
|
||||
trunk-token: ${{ secrets.TRUNK_TOKEN }}
|
||||
|
||||
@@ -34,6 +34,6 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Trunk Upgrade
|
||||
uses: trunk-io/trunk-action/upgrade@v1
|
||||
uses: trunk-io/trunk-action/upgrade@v2.0.0
|
||||
with:
|
||||
base: develop
|
||||
@@ -23,13 +23,19 @@ env:
|
||||
LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --directory .pio/build/coverage/src --base-directory "${PWD}"
|
||||
|
||||
jobs:
|
||||
# Guard the registered native-suite total. `platformio test` discovers and runs whatever
|
||||
# test_* directories exist, so it never notices when test/native-suite-count drifts from the
|
||||
# actual directory count (a suite added without registering it, or the file left stale). That
|
||||
# reconciliation only lives in bin/run-tests.sh, which CI does not invoke - so mirror the exact
|
||||
# check here and fail the PR on a mismatch, keeping the manual count honest.
|
||||
suite-count-check:
|
||||
name: Native Suite Count
|
||||
# Tripwire against the native suite set shrinking by accident. `platformio test` discovers and
|
||||
# runs whatever test_* directories exist, and bin/run-tests.sh derives its expected count from
|
||||
# the same walk - so a suite directory lost in a bad rebase or an overzealous cleanup just means
|
||||
# fewer suites run, and every remaining check stays green. Compare the test_* directory list
|
||||
# against the PR's merge base and fail when a suite vanished without the PR saying so: a removed
|
||||
# suite's name must appear in the PR title, the PR body, or a commit message in the PR's range.
|
||||
# A deliberate removal satisfies that by stating what it removes; an accidental loss cannot.
|
||||
# Only pull_request runs have a base to compare against (and PRs are where accidents arrive);
|
||||
# every other event skips. No job depends on this one: a skipped job would skip its dependents,
|
||||
# and the expensive jobs should not wait on a full-history clone.
|
||||
suite-shrinkage-check:
|
||||
name: Native Suite Shrinkage
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -37,40 +43,111 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Full history: the merge base must be computed, not guessed from a possibly stale
|
||||
# event payload, and the acknowledgment scan reads every commit message in the range.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Reconcile native-suite-count with test/ directories
|
||||
- name: Fail if a test_* suite vanished unacknowledged
|
||||
shell: bash
|
||||
# PR title/body are attacker-controlled text; they reach the script through env: only,
|
||||
# never spliced into the shell source (same rule as the suite-order seed below).
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --quiet origin "$BASE_REF"
|
||||
base=$(git merge-base FETCH_HEAD HEAD)
|
||||
# Same canonical set every other consumer derives: directories named test_* directly
|
||||
# under test/, read from the git trees so the comparison is exact at both endpoints.
|
||||
list_suites() { git ls-tree -d --name-only "$1" test/ | sed 's#^test/##' | grep '^test_' | sort; }
|
||||
removed=$(comm -23 <(list_suites "$base") <(list_suites HEAD))
|
||||
if [[ -z $removed ]]; then
|
||||
echo "No suite removed: $(list_suites HEAD | wc -l) test_* directories, none lost since merge base ${base:0:8}."
|
||||
exit 0
|
||||
fi
|
||||
messages=$(git log --format=%B "$base..HEAD")
|
||||
fail=0
|
||||
while IFS= read -r suite; do
|
||||
if printf '%s\n%s\n%s\n' "$PR_TITLE" "$PR_BODY" "$messages" | grep -qF "$suite"; then
|
||||
echo "Removed suite $suite is named in the PR title/body or a commit message - acknowledged."
|
||||
else
|
||||
echo "::error title=Native suite vanished::test/$suite exists on the merge base but is gone from this PR, and nothing in the PR title, body, or commit messages mentions it. If the removal is deliberate, name $suite in the PR description or a commit message; if not, restore the directory - platformio test would silently run without it."
|
||||
fail=1
|
||||
fi
|
||||
done <<<"$removed"
|
||||
exit $fail
|
||||
|
||||
# Reject naive deadline comparisons against the 32-bit uptime clocks. `millis() > deadline` and
|
||||
# `deadline < millis()` invert while the deadline sits on the far side of the 32-bit wrap: the
|
||||
# action fires immediately, or blocks for about the interval it should have waited. The correct
|
||||
# forms are
|
||||
# Throttle::isWithinTimespanMs / hasElapsed (elapsed since a stored event) and
|
||||
# Throttle::deadlinePassed (an absolute deadline). See .github/copilot-instructions.md.
|
||||
millis-deadline-check:
|
||||
# Name is load-bearing: upstream branch protection matches the check by name. Widen the guard,
|
||||
# not this string.
|
||||
name: Naive millis() Deadline Compare
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Reject 32-bit uptime clocks used directly in a deadline comparison
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
count_file="test/native-suite-count"
|
||||
if [[ ! -f $count_file ]]; then
|
||||
echo "::error title=Missing native-suite-count::$count_file not found - it must record the number of test_* suite directories."
|
||||
exit 1
|
||||
allowlist=".github/millis-deadline-allowlist.txt"
|
||||
|
||||
# Flag millis() or its Time::getMillis() wrapper directly adjacent to a comparison
|
||||
# operator, in either order. The correct idioms subtract first, so they are not matched.
|
||||
#
|
||||
# Line comments are stripped before matching, so prose may name the broken idiom (this
|
||||
# guard's own documentation does). Block comments are not stripped; keep `millis() >` out
|
||||
# of /* */ blocks. mawk-compatible - ubuntu-latest has no gawk.
|
||||
find src -type f \( -name '*.cpp' -o -name '*.h' -o -name '*.hpp' -o -name '*.ino' \) \
|
||||
! -path 'src/mesh/generated/*' -print0 |
|
||||
xargs -0 awk '
|
||||
{
|
||||
line = $0
|
||||
sub(/\/\/.*/, "", line)
|
||||
if (line ~ /((millis|getMillis)\(\)[ \t]*[<>]=?)|([<>]=?[ \t]*(millis|getMillis)\(\))/) {
|
||||
code = line
|
||||
sub(/^[ \t]+/, "", code); sub(/[ \t]+$/, "", code)
|
||||
printf "%s\t%s\t%s\n", FILENAME, FNR, code
|
||||
}
|
||||
}' > /tmp/millis-hits.tsv
|
||||
|
||||
# Allowlisted entries are keyed on file + exact source text, deliberately without a line
|
||||
# number, so unrelated edits above them do not invalidate the entry.
|
||||
: > /tmp/millis-allowed.tsv
|
||||
if [[ -f $allowlist ]]; then
|
||||
grep -vE '^[[:space:]]*(#|$)' "$allowlist" > /tmp/millis-allowed.tsv || true
|
||||
fi
|
||||
# Same canonical set as bin/run-tests.sh: directories named test_* directly under test/.
|
||||
expected_count=$(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | wc -l)
|
||||
canonical_count=$(tr -d '[:space:]' <"$count_file")
|
||||
if ! [[ $canonical_count =~ ^[0-9]+$ ]]; then
|
||||
echo "::error title=Invalid native-suite-count::$count_file must contain a single integer, got '$canonical_count'."
|
||||
exit 1
|
||||
fi
|
||||
echo "test/ directories: $expected_count"
|
||||
echo "native-suite-count: $canonical_count"
|
||||
if [[ $expected_count -ne $canonical_count ]]; then
|
||||
if [[ $expected_count -gt $canonical_count ]]; then
|
||||
hint="a suite was added - bump $count_file to $expected_count"
|
||||
else
|
||||
hint="a suite was removed - lower $count_file to $expected_count"
|
||||
|
||||
violations=0
|
||||
while IFS=$'\t' read -r file line code; do
|
||||
[[ -n ${file:-} ]] || continue
|
||||
if grep -qxF "$(printf '%s\t%s' "$file" "$code")" /tmp/millis-allowed.tsv; then
|
||||
continue
|
||||
fi
|
||||
echo "::error title=native-suite-count mismatch::test/ has $expected_count suite directories but $count_file says $canonical_count ($hint)."
|
||||
echo "$file:$line: $code"
|
||||
violations=$((violations + 1))
|
||||
done < /tmp/millis-hits.tsv
|
||||
|
||||
if [[ $violations -gt 0 ]]; then
|
||||
echo "::error title=Naive uptime deadline compare::$violations line(s) compare a 32-bit uptime clock directly, which inverts while the deadline is on the far side of the 32-bit wrap - the action fires immediately, or blocks for about the interval it should have waited. Use Throttle::deadlinePassed(deadline) for a stored absolute deadline, or Throttle::hasElapsed(lastEvent, intervalMs) for an interval. If a match genuinely is not a deadline test (an uptime threshold, say), add it to $allowlist with a reason."
|
||||
exit 1
|
||||
fi
|
||||
echo "native-suite-count matches the $expected_count suite directories."
|
||||
echo "No naive 32-bit uptime deadline comparisons in src/ (allowlist: $(wc -l < /tmp/millis-allowed.tsv) entr(y/ies))."
|
||||
|
||||
simulator-tests:
|
||||
name: Native Simulator Tests
|
||||
runs-on: ubuntu-24.04-arm
|
||||
needs: suite-count-check
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -118,6 +195,13 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
run: ./bin/test-config-check.sh .pio/build/coverage/meshtasticd
|
||||
|
||||
- name: Shared-state checker self-test
|
||||
# Fixtures that write nothing / exactly what they declare / something undeclared /
|
||||
# a declared write they never make, asserting CLEAN / CLEAN / DIRTY / MISSING. A
|
||||
# checker that has silently stopped matching looks identical to a clean codebase.
|
||||
timeout-minutes: 5
|
||||
run: ./bin/test-state-check.sh
|
||||
|
||||
- name: Integration test
|
||||
# Cap the whole step: if the simulator ever fails to exit (e.g. the
|
||||
# exit_simulator admin path regresses again) the job must fail fast,
|
||||
@@ -169,7 +253,6 @@ jobs:
|
||||
platformio-tests:
|
||||
name: Native PlatformIO Tests
|
||||
runs-on: ubuntu-24.04-arm
|
||||
needs: suite-count-check
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -197,9 +280,12 @@ jobs:
|
||||
restore-keys: |
|
||||
pio-coverage-tests-
|
||||
|
||||
- name: Build test programs once
|
||||
# One shared build of src + every test program. This is the single source build; gcov then
|
||||
# accumulates coverage counts into this shared .pio/build/coverage/src as the chunks run.
|
||||
- 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
|
||||
|
||||
- name: Save PlatformIO cache
|
||||
@@ -292,12 +378,21 @@ jobs:
|
||||
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 --without-building -v ${group[$a]# } \
|
||||
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
|
||||
@@ -322,6 +417,18 @@ jobs:
|
||||
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: |
|
||||
@@ -329,13 +436,38 @@ jobs:
|
||||
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 }}
|
||||
overwrite: true
|
||||
path: ./testreport.xml
|
||||
path: ./*testreport.xml
|
||||
|
||||
- name: Save coverage information
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Annotate PR with trunk issues
|
||||
# See: https://github.com/trunk-io/trunk-action/blob/v1/readme.md#getting-inline-annotations-for-fork-prs
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Pull Request] # Name from `trunk_check.yml`
|
||||
types: [completed]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
trunk_check:
|
||||
name: Trunk Code Quality Annotate
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
checks: write # For trunk to post annotations
|
||||
contents: read # For repo checkout
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Trunk Check
|
||||
uses: trunk-io/trunk-action@v1
|
||||
with:
|
||||
post-annotations: true
|
||||
cache: false
|
||||
@@ -11,7 +11,6 @@ jobs:
|
||||
name: Trunk Check Runner
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
checks: write # For trunk to post annotations
|
||||
contents: read # For repo checkout
|
||||
|
||||
steps:
|
||||
@@ -19,7 +18,6 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Trunk Check
|
||||
uses: trunk-io/trunk-action@v1
|
||||
uses: trunk-io/trunk-action@v2.0.0
|
||||
with:
|
||||
save-annotations: true
|
||||
cache: false
|
||||
@@ -1,12 +1,23 @@
|
||||
name: Update protobufs and regenerate classes
|
||||
on: workflow_dispatch
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
protobufs_branch:
|
||||
description: Branch of meshtastic/protobufs to generate from
|
||||
required: true
|
||||
type: choice
|
||||
default: same-as-this-branch
|
||||
options:
|
||||
- same-as-this-branch
|
||||
- master
|
||||
- develop
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
update-protobufs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: # Needed for peter-evans/create-pull-request.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -14,22 +25,50 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve protobufs branch
|
||||
id: resolve
|
||||
env:
|
||||
INPUT_BRANCH: ${{ inputs.protobufs_branch }}
|
||||
TRIGGER_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$INPUT_BRANCH" = "same-as-this-branch" ]; then
|
||||
BRANCH="$TRIGGER_BRANCH"
|
||||
else
|
||||
BRANCH="$INPUT_BRANCH"
|
||||
fi
|
||||
case "$BRANCH" in
|
||||
master | develop) ;;
|
||||
*)
|
||||
echo "::error::Refusing to generate from branch '$BRANCH'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "branch=$BRANCH" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update submodule
|
||||
if: ${{ github.ref_name == 'master' || github.ref_name == 'develop' }}
|
||||
working-directory: protobufs
|
||||
env:
|
||||
# Use the branch that triggered the workflow as the protobuf branch.
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
GIT_BRANCH: ${{ steps.resolve.outputs.branch }}
|
||||
run: |
|
||||
git fetch --prune origin $GIT_BRANCH
|
||||
git checkout FETCH_HEAD
|
||||
set -euo pipefail
|
||||
git fetch --prune origin "+refs/heads/${GIT_BRANCH}:refs/remotes/origin/${GIT_BRANCH}"
|
||||
git checkout --detach "refs/remotes/origin/${GIT_BRANCH}"
|
||||
git rev-parse HEAD
|
||||
|
||||
- name: Download nanopb
|
||||
env:
|
||||
NANOPB_VERSION: 0.4.9.1
|
||||
NANOPB_SHA256: 951a9ab2385424a4cdf245d0c84f4c88c6ccbc65a0dade4b246d50c068f24128
|
||||
run: |
|
||||
wget https://github.com/nanopb/nanopb/releases/download/nanopb-0.4.9.1/nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
tar xvzf nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
mv nanopb-0.4.9.1-linux-x86 nanopb-0.4.9
|
||||
set -euo pipefail
|
||||
TARBALL="nanopb-${NANOPB_VERSION}-linux-x86.tar.gz"
|
||||
wget -q "https://github.com/nanopb/nanopb/releases/download/nanopb-${NANOPB_VERSION}/${TARBALL}"
|
||||
echo "${NANOPB_SHA256} ${TARBALL}" | sha256sum -c -
|
||||
tar xzf "${TARBALL}"
|
||||
mv "nanopb-${NANOPB_VERSION}-linux-x86" nanopb-0.4.9
|
||||
|
||||
- name: Re-generate protocol buffers
|
||||
run: |
|
||||
@@ -38,10 +77,12 @@ jobs:
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
branch: create-pull-request/update-protobufs-${{ github.ref_name }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: create-pull-request/update-protobufs-${{ github.ref_name }}-from-${{ steps.resolve.outputs.branch }}
|
||||
labels: submodules
|
||||
title: Update protobufs and classes
|
||||
commit-message: Update protobufs
|
||||
add-paths: |
|
||||
protobufs
|
||||
src/mesh
|
||||
|
||||
@@ -151,6 +151,16 @@ lint:
|
||||
- linters: [ascii-dash]
|
||||
paths:
|
||||
- src/graphics/fonts/**
|
||||
# millis()-wraparound tests pin dense clusters of hex boundary constants
|
||||
# (0xFFFFFF00u and neighbors). trufflehog's Lob detector stitches nearby
|
||||
# hex literals into one candidate string and the result happens to match
|
||||
# a Lob API key shape. Not secrets - deterministic test fixtures for the
|
||||
# 32-bit rollover.
|
||||
- linters: [trufflehog]
|
||||
paths:
|
||||
- test/test_airtime/test_main.cpp
|
||||
- test/test_throttle/test_main.cpp
|
||||
- test/test_uptime_clock/test_main.cpp
|
||||
runtimes:
|
||||
enabled:
|
||||
- python@3.14.4
|
||||
|
||||
@@ -81,7 +81,19 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
|
||||
- **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.
|
||||
- **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check - raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||
- **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.
|
||||
- `Throttle::hasElapsed(lastMs, intervalMs)` - its complement, true once the interval has passed (inclusive `>=`). Prefer this to spelling `!isWithinTimespanMs(...)`.
|
||||
- `Throttle::execute(&lastMs, intervalMs, func)` - function-pointer form that updates the timestamp on fire.
|
||||
- `Throttle::deadlinePassed(deadlineMs)` - for a stored absolute deadline that cannot be re-expressed as "interval since an event".
|
||||
- `Throttle::deadlinePassedAt(nowMs, deadlineMs)` - the same test against a caller-supplied `now`, for a loop that snapshots the clock once and tests many deadlines. Snapshot from `Time::getMillis()`.
|
||||
|
||||
Raw `millis() > deadline` or `deadline < millis()` is rollover-unsafe: the comparison inverts while the deadline sits on the far side of the 32-bit wrap, so the action fires immediately or blocks for roughly the interval it should have waited. All five helpers subtract first, so unsigned wraparound cancels out. `Throttle` reads the clock through `Time::getMillis()` (`src/UptimeClock.h`), so all ~94 of its call sites are time-injectable and a native test can drive the wrap with `Time::setTestMillis()`.
|
||||
|
||||
**Sentinel hazard.** If a deadline variable also encodes "inactive" (`0` for `rebootAtMsec`, `shutdownAtMsec`, `alertBannerUntil`, `fixHoldEnds`; `UINT32_MAX` for `nagCycleCutoff`), test that sentinel _before_ the elapsed comparison - every such value is arithmetically far in the past, so a correct comparison fires on it immediately. Match the test to the sentinel in use: `if (deadline && Throttle::deadlinePassed(deadline))` covers the `0` family, `nagCycleCutoff` needs `deadline != UINT32_MAX` or a separate armed flag (`isNagging`).
|
||||
|
||||
Then 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 one must be armed; guarding it with `fixHoldEnds != 0 &&` looks like this rule and inverts the site. See `fixHoldInForce()` in `src/gps/GPS.cpp` and `test/test_gps_fix_hold/`.
|
||||
|
||||
## Typical agent workflows
|
||||
|
||||
@@ -131,7 +143,7 @@ Sequence these; don't parallelize on the same port.
|
||||
| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers |
|
||||
| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) |
|
||||
| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` |
|
||||
| `test/` | Firmware unit tests (count: `test/native-suite-count`; `./bin/run-tests.sh` preferred, falls back to `pio test -e native`) |
|
||||
| `test/` | Firmware unit tests (count = the `test_*` dirs, detected on the fly; `./bin/run-tests.sh` preferred, falls back to `pio test -e native`) |
|
||||
| [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) | Standalone MCP server + tiered pytest hardware harness (`unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/`) - registered here via `.mcp.json` |
|
||||
| `.github/prompts/` | Copilot prompt bodies (firmware scaffolding: new module / sensor / variant) |
|
||||
| `.github/copilot-instructions.md` | **Primary agent instructions - read this** |
|
||||
|
||||
@@ -11,13 +11,18 @@
|
||||
>
|
||||
> **Need this? It's here.**
|
||||
>
|
||||
> | | |
|
||||
> | ------------------------------------------- | ---------------------------------------------------------- |
|
||||
> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` |
|
||||
> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.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` |
|
||||
|
||||
**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.
|
||||
|
||||
This file (`CLAUDE.md`) is a short pointer for Claude Code sessions. Slash commands live in `.claude/commands/`.
|
||||
|
||||
## House rule: documentation does not live in this repo
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,100 @@
|
||||
// Replays a captured BME680 CSV trace (gas_ohms,rh[,bsec_iaq]) through
|
||||
// BME680IaqEstimator for offline tuning. See docs/bme680_iaq_replay.md.
|
||||
|
||||
#include "modules/Telemetry/Sensor/BME680IaqEstimator.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
namespace
|
||||
{
|
||||
// Same buckets the device UI uses (EnvironmentTelemetry drawFrame)
|
||||
int band(int iaq)
|
||||
{
|
||||
if (iaq <= 25)
|
||||
return 0; // Excellent
|
||||
if (iaq <= 50)
|
||||
return 1; // Good
|
||||
if (iaq <= 100)
|
||||
return 2; // Moderate
|
||||
if (iaq <= 150)
|
||||
return 3; // Poor
|
||||
if (iaq <= 200)
|
||||
return 4; // Unhealthy
|
||||
if (iaq <= 300)
|
||||
return 5; // Very Unhealthy
|
||||
return 6; // Hazardous
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
FILE *in = stdin;
|
||||
if (argc > 1) {
|
||||
in = fopen(argv[1], "r");
|
||||
if (!in) {
|
||||
fprintf(stderr, "cannot open %s\n", argv[1]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
BME680IaqEstimator est;
|
||||
char line[256];
|
||||
long lineNo = 0, n = 0, skipped = 0, produced = 0, compared = 0, bandHits = 0;
|
||||
double absErrSum = 0;
|
||||
|
||||
printf("n,gas_ohms,rh,est_iaq,bsec_iaq\n");
|
||||
while (fgets(line, sizeof(line), in)) {
|
||||
lineNo++;
|
||||
if (line[0] == '#' || line[0] == '\n')
|
||||
continue;
|
||||
float gas, rh, bsec = NAN;
|
||||
int fields = sscanf(line, "%f,%f,%f", &gas, &rh, &bsec);
|
||||
if (fields < 2) {
|
||||
// Tolerate one header row silently; anything else malformed is
|
||||
// reported so a damaged trace can't produce a quiet, biased summary
|
||||
if (lineNo > 1) {
|
||||
skipped++;
|
||||
fprintf(stderr, "skipping malformed line %ld: %s", lineNo, line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
n++;
|
||||
uint16_t iaq;
|
||||
bool got = est.update(gas, rh, &iaq);
|
||||
bool haveBsec = fields >= 3 && std::isfinite(bsec);
|
||||
|
||||
printf("%ld,%.0f,%.2f,", n, gas, rh);
|
||||
if (got)
|
||||
printf("%u", (unsigned)iaq);
|
||||
if (haveBsec)
|
||||
printf(",%.0f\n", bsec);
|
||||
else
|
||||
printf(",\n");
|
||||
|
||||
if (got) {
|
||||
produced++;
|
||||
if (haveBsec) {
|
||||
compared++;
|
||||
absErrSum += std::fabs((double)iaq - (double)bsec);
|
||||
if (band(iaq) == band((int)std::lround(bsec)))
|
||||
bandHits++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ferror(in)) {
|
||||
fprintf(stderr, "input read error at line %ld\n", lineNo);
|
||||
if (in != stdin)
|
||||
fclose(in);
|
||||
return 1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "samples: %ld, estimator outputs: %ld, malformed lines skipped: %ld\n", n, produced, skipped);
|
||||
if (compared) {
|
||||
fprintf(stderr, "vs BSEC (%ld comparable): mean abs error %.1f IAQ points, band agreement %.1f%%\n", compared,
|
||||
absErrSum / compared, 100.0 * bandHits / compared);
|
||||
}
|
||||
if (in != stdin)
|
||||
fclose(in);
|
||||
return 0;
|
||||
}
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify each PlatformIO JUnit report ran the suite it claims to have run.
|
||||
|
||||
PlatformIO links every native test program to one path ($BUILD_DIR/$PROGNAME) and parses
|
||||
Unity output textually, without checking that the reported source file belongs to the suite
|
||||
it is running. Split a run into `--without-testing` then `--without-building` and every suite
|
||||
executes whichever binary was linked last, all reporting PASSED. This reads the JUnit reports
|
||||
that run already produces and fails on the two shapes that hides:
|
||||
|
||||
MISATTRIBUTED - a test case whose source file lives outside the suite that reported it
|
||||
EMPTY - a suite that was asked to run and produced no test cases at all
|
||||
|
||||
Usage:
|
||||
check-test-attribution.py [--expect "s1 s2"]... [--label TEXT] REPORT.xml...
|
||||
|
||||
--expect names the suites the run was asked for (repeatable, whitespace- or `-f`-separated,
|
||||
so a CI area string can be passed through verbatim). Omit it to check attribution only.
|
||||
Exit: 0 clean, 1 findings, 2 bad usage / unreadable report.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def parse_expect(values):
|
||||
"""Flatten repeated --expect values into a suite list, tolerating `-f suite` tokens."""
|
||||
suites = []
|
||||
for value in values or []:
|
||||
for token in value.split():
|
||||
if token == "-f":
|
||||
continue
|
||||
suites.append(token.removeprefix("-f"))
|
||||
return [s for s in suites if s]
|
||||
|
||||
|
||||
def suite_of(testsuite_name):
|
||||
"""`coverage:test_foo` -> `test_foo`; a bare name is returned unchanged."""
|
||||
return testsuite_name.split(":", 1)[1] if ":" in testsuite_name else testsuite_name
|
||||
|
||||
|
||||
def owns(suite, source_file):
|
||||
"""Report whether source_file sits inside the suite's own directory.
|
||||
|
||||
Matched on a whole path segment so `test_mesh` does not claim `test_mesh_module`, and
|
||||
with a leading separator so absolute and relative paths behave the same.
|
||||
"""
|
||||
normalized = "/" + source_file.replace("\\", "/").lstrip("/")
|
||||
return f"/{suite}/" in normalized
|
||||
|
||||
|
||||
def collect(paths):
|
||||
"""Map suite -> list of (case name, source file or None), merged across reports."""
|
||||
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.
|
||||
# nosemgrep: python.lang.security.use-defused-xml-parse.use-defused-xml-parse
|
||||
root = ET.parse(path).getroot()
|
||||
except (ET.ParseError, OSError) as exc:
|
||||
sys.stderr.write(f"check-test-attribution: cannot read {path}: {exc}\n")
|
||||
sys.exit(2)
|
||||
# PlatformIO nests <testsuite> under <testsuites>; accept a bare <testsuite> too.
|
||||
nodes = [root] if root.tag == "testsuite" else root.iter("testsuite")
|
||||
for node in nodes:
|
||||
suite = suite_of(node.get("name", ""))
|
||||
if not suite:
|
||||
continue
|
||||
entries = cases.setdefault(suite, [])
|
||||
for case in node.iter("testcase"):
|
||||
entries.append((case.get("name", "?"), case.get("file")))
|
||||
return cases
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(add_help=True)
|
||||
parser.add_argument("--expect", action="append", default=[])
|
||||
parser.add_argument("--label", default="")
|
||||
parser.add_argument("reports", nargs="+")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Expand globs ourselves: CI passes a pattern that may match nothing if a step was skipped,
|
||||
# and a silent pass over zero reports is exactly the false green this script exists to stop.
|
||||
paths = sorted({p for pattern in args.reports for p in glob.glob(pattern)})
|
||||
if not paths:
|
||||
sys.stderr.write(
|
||||
"check-test-attribution: no JUnit reports matched %s\n"
|
||||
% " ".join(args.reports)
|
||||
)
|
||||
return 2
|
||||
|
||||
cases = collect(paths)
|
||||
expected = parse_expect(args.expect)
|
||||
|
||||
misattributed = [] # (suite, case name, source file)
|
||||
unsourced = [] # (suite, case name)
|
||||
for suite, entries in sorted(cases.items()):
|
||||
for name, source in entries:
|
||||
if source is None:
|
||||
unsourced.append((suite, name))
|
||||
elif not owns(suite, source):
|
||||
misattributed.append((suite, name, source))
|
||||
|
||||
empty = [s for s in expected if not cases.get(s)]
|
||||
|
||||
label = f" [{args.label}]" if args.label else ""
|
||||
total = sum(len(v) for v in cases.values())
|
||||
print(
|
||||
f"test attribution{label}: {len(paths)} report(s), "
|
||||
f"{len([s for s, v in cases.items() if v])} suite(s) with cases, {total} case(s)"
|
||||
)
|
||||
if unsourced:
|
||||
print("")
|
||||
print("UNSOURCED - these cases carry no source file, so ownership cannot be proved:")
|
||||
for suite, name in unsourced[:20]:
|
||||
print(f" {suite}: case '{name}'")
|
||||
if len(unsourced) > 20:
|
||||
print(f" ... +{len(unsourced) - 20} more")
|
||||
print(
|
||||
"A report without file attributes is not evidence that the suites ran their own"
|
||||
)
|
||||
print(
|
||||
"tests. Treat it as a finding rather than a pass: the JUnit format has changed, or"
|
||||
)
|
||||
print("the runner emitted cases it could not attribute.")
|
||||
|
||||
if misattributed:
|
||||
print("")
|
||||
print(
|
||||
"MISATTRIBUTED - these suites reported test cases belonging to another suite."
|
||||
)
|
||||
print(
|
||||
"The run executed one suite's binary under another suite's name; the named"
|
||||
)
|
||||
print(
|
||||
"suites did NOT run. Check for --without-building in the test invocation."
|
||||
)
|
||||
for suite, name, source in misattributed[:20]:
|
||||
print(f" {suite}: case '{name}' came from {source}")
|
||||
if len(misattributed) > 20:
|
||||
print(f" ... +{len(misattributed) - 20} more")
|
||||
|
||||
if empty:
|
||||
print("")
|
||||
print("EMPTY - these suites were asked to run and produced no test cases:")
|
||||
for suite in empty:
|
||||
print(f" {suite}")
|
||||
|
||||
if misattributed or empty or unsourced:
|
||||
print("")
|
||||
print(
|
||||
"RESULT: test attribution FAILED"
|
||||
f"{label} ({len(misattributed)} misattributed, {len(empty)} empty,"
|
||||
f" {len(unsourced)} unsourced)"
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"RESULT: test attribution OK{label}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+30
-11
@@ -70,7 +70,7 @@ IF "__!FILENAME!__"=="____" (
|
||||
CALL :LOG_MESSAGE ERROR "Filename containing spaces are not supported."
|
||||
GOTO help
|
||||
)
|
||||
IF NOT "__!FILENAME:.factory.bin=!__"=="__!FILENAME!__" (
|
||||
IF /I NOT "!FILENAME:~-12!"==".factory.bin" (
|
||||
CALL :LOG_MESSAGE ERROR "Filename must be a firmware-*.factory.bin file."
|
||||
GOTO help
|
||||
)
|
||||
@@ -111,7 +111,7 @@ IF EXIST !METAFILE! (
|
||||
|
||||
CALL :LOG_MESSAGE DEBUG "Determine the correct esptool command to use..."
|
||||
IF NOT "__%PYTHON%__"=="____" (
|
||||
SET "ESPTOOL_CMD=!PYTHON! -m esptool"
|
||||
SET "ESPTOOL_CMD="!PYTHON!" -m esptool"
|
||||
CALL :LOG_MESSAGE DEBUG "Python interpreter supplied."
|
||||
) ELSE (
|
||||
CALL :LOG_MESSAGE DEBUG "Python interpreter NOT supplied. Looking for esptool..."
|
||||
@@ -126,12 +126,31 @@ IF NOT "__%PYTHON%__"=="____" (
|
||||
)
|
||||
|
||||
CALL :LOG_MESSAGE DEBUG "Checking esptool command !ESPTOOL_CMD!..."
|
||||
!ESPTOOL_CMD! >nul 2>&1
|
||||
IF %ERRORLEVEL% EQU 9009 (
|
||||
@REM 9009 = command not found on Windows
|
||||
@REM %VAR% not !VAR!: cmd will not split a delayed-expanded command token that
|
||||
@REM carries a path, so the "python -m esptool" form never starts.
|
||||
%ESPTOOL_CMD% >nul 2>&1
|
||||
SET "ESPTOOL_EXIT=!ERRORLEVEL!"
|
||||
@REM 9009 = command not found, 3 = bad path from -P. Both mean unusable.
|
||||
IF !ESPTOOL_EXIT! EQU 3 SET "ESPTOOL_EXIT=9009"
|
||||
IF !ESPTOOL_EXIT! EQU 9009 (
|
||||
CALL :LOG_MESSAGE ERROR "esptool not found: !ESPTOOL_CMD!"
|
||||
EXIT /B 1
|
||||
)
|
||||
|
||||
@REM esptool v5 renamed subcommands to dashes; older versions only take underscores.
|
||||
@REM Probe here: the --debug and --port rewrites below leave ESPTOOL_CMD unusable.
|
||||
SET "ESPTOOL_WRITE_FLASH=write_flash"
|
||||
SET "ESPTOOL_ERASE_FLASH=erase_flash"
|
||||
SET "ESPTOOL_READ_FLASH_STATUS=read_flash_status"
|
||||
%ESPTOOL_CMD% 2>&1 | findstr /C:"write-flash" >nul
|
||||
IF !ERRORLEVEL! EQU 0 (
|
||||
SET "ESPTOOL_WRITE_FLASH=write-flash"
|
||||
SET "ESPTOOL_ERASE_FLASH=erase-flash"
|
||||
SET "ESPTOOL_READ_FLASH_STATUS=read-flash-status"
|
||||
)
|
||||
CALL :RESET_ERROR
|
||||
CALL :LOG_MESSAGE DEBUG "Using esptool write command: !ESPTOOL_WRITE_FLASH!"
|
||||
|
||||
IF %DEBUG% EQU 1 (
|
||||
CALL :LOG_MESSAGE DEBUG "Skipping ESPTOOL_CMD steps."
|
||||
SET "ESPTOOL_CMD=REM !ESPTOOL_CMD!"
|
||||
@@ -148,7 +167,7 @@ CALL :LOG_MESSAGE INFO "Using esptool baud: !ESPTOOL_BAUD!."
|
||||
|
||||
IF %BPS_RESET% EQU 1 (
|
||||
@REM Attempt to change mode via 1200bps Reset.
|
||||
CALL :RUN_ESPTOOL 1200 --after no_reset read_flash_status
|
||||
CALL :RUN_ESPTOOL 1200 --after no_reset !ESPTOOL_READ_FLASH_STATUS!
|
||||
GOTO eof
|
||||
)
|
||||
|
||||
@@ -174,14 +193,14 @@ IF NOT EXIST !SPIFFS_FILENAME! CALL :LOG_MESSAGE ERROR "File does not exist: "!S
|
||||
|
||||
@REM Flashing operations.
|
||||
CALL :LOG_MESSAGE INFO "Trying to flash "!FILENAME!", but first erasing and writing system information..."
|
||||
CALL :RUN_ESPTOOL !ESPTOOL_BAUD! erase_flash || GOTO eof
|
||||
CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write_flash 0x00 "!FILENAME!" || GOTO eof
|
||||
CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_ERASE_FLASH! || GOTO eof
|
||||
CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! 0x00 "!FILENAME!" || GOTO eof
|
||||
|
||||
CALL :LOG_MESSAGE INFO "Trying to flash BLEOTA "!OTA_FILENAME!" at OTA_OFFSET !OTA_OFFSET!..."
|
||||
CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write_flash !OTA_OFFSET! "!OTA_FILENAME!" || GOTO eof
|
||||
CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! !OTA_OFFSET! "!OTA_FILENAME!" || GOTO eof
|
||||
|
||||
CALL :LOG_MESSAGE INFO "Trying to flash SPIFFS "!SPIFFS_FILENAME!" at SPIFFS_OFFSET !SPIFFS_OFFSET!..."
|
||||
CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write_flash !SPIFFS_OFFSET! "!SPIFFS_FILENAME!" || GOTO eof
|
||||
CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! !SPIFFS_OFFSET! "!SPIFFS_FILENAME!" || GOTO eof
|
||||
|
||||
CALL :LOG_MESSAGE INFO "Script complete!."
|
||||
|
||||
@@ -198,7 +217,7 @@ EXIT /B %ERRORLEVEL%
|
||||
@REM Example:: CALL :RUN_ESPTOOL 115200 write_flash 0x10000 "firmwarefile.bin"
|
||||
IF %DEBUG% EQU 1 CALL :LOG_MESSAGE DEBUG "About to run command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4"
|
||||
CALL :RESET_ERROR
|
||||
!ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4
|
||||
%ESPTOOL_CMD% --baud %~1 %~2 %~3 %~4
|
||||
IF %BPS_RESET% EQU 1 GOTO :eof
|
||||
IF %ERRORLEVEL% NEQ 0 (
|
||||
CALL :LOG_MESSAGE ERROR "Error running command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4"
|
||||
|
||||
+27
-8
@@ -90,7 +90,7 @@ IF NOT "__!FILENAME:.factory.bin=!__"=="__!FILENAME!__" (
|
||||
|
||||
CALL :LOG_MESSAGE DEBUG "Determine the correct esptool command to use..."
|
||||
IF NOT "__%PYTHON%__"=="____" (
|
||||
SET "ESPTOOL_CMD=""!PYTHON!"" -m esptool"
|
||||
SET "ESPTOOL_CMD="!PYTHON!" -m esptool"
|
||||
CALL :LOG_MESSAGE DEBUG "Python interpreter supplied."
|
||||
) ELSE (
|
||||
CALL :LOG_MESSAGE DEBUG "Python interpreter NOT supplied. Looking for esptool..."
|
||||
@@ -105,13 +105,32 @@ IF NOT "__%PYTHON%__"=="____" (
|
||||
)
|
||||
|
||||
CALL :LOG_MESSAGE DEBUG "Checking esptool command !ESPTOOL_CMD!..."
|
||||
!ESPTOOL_CMD! >nul 2>&1
|
||||
CALL :LOG_MESSAGE DEBUG "esptool exit code: %ERRORLEVEL%"
|
||||
IF %ERRORLEVEL% EQU 9009 (
|
||||
@REM 9009 = command not found on Windows
|
||||
@REM %VAR% not !VAR!: cmd will not split a delayed-expanded command token that
|
||||
@REM carries a path, so the "python -m esptool" form never starts.
|
||||
%ESPTOOL_CMD% >nul 2>&1
|
||||
SET "ESPTOOL_EXIT=!ERRORLEVEL!"
|
||||
CALL :LOG_MESSAGE DEBUG "esptool exit code: !ESPTOOL_EXIT!"
|
||||
@REM 9009 = command not found, 3 = bad path from -P. Both mean unusable.
|
||||
IF !ESPTOOL_EXIT! EQU 3 SET "ESPTOOL_EXIT=9009"
|
||||
IF !ESPTOOL_EXIT! EQU 9009 (
|
||||
CALL :LOG_MESSAGE ERROR "esptool not found: !ESPTOOL_CMD!"
|
||||
EXIT /B 1
|
||||
)
|
||||
|
||||
@REM esptool v5 renamed subcommands to dashes; older versions only take underscores.
|
||||
@REM Probe here: the --debug and --port rewrites below leave ESPTOOL_CMD unusable.
|
||||
SET "ESPTOOL_WRITE_FLASH=write_flash"
|
||||
SET "ESPTOOL_ERASE_FLASH=erase_flash"
|
||||
SET "ESPTOOL_READ_FLASH_STATUS=read_flash_status"
|
||||
%ESPTOOL_CMD% 2>&1 | findstr /C:"write-flash" >nul
|
||||
IF !ERRORLEVEL! EQU 0 (
|
||||
SET "ESPTOOL_WRITE_FLASH=write-flash"
|
||||
SET "ESPTOOL_ERASE_FLASH=erase-flash"
|
||||
SET "ESPTOOL_READ_FLASH_STATUS=read-flash-status"
|
||||
)
|
||||
CALL :RESET_ERROR
|
||||
CALL :LOG_MESSAGE DEBUG "Using esptool write command: !ESPTOOL_WRITE_FLASH!"
|
||||
|
||||
IF %DEBUG% EQU 1 (
|
||||
CALL :LOG_MESSAGE DEBUG "Skipping ESPTOOL_CMD steps."
|
||||
SET "ESPTOOL_CMD=REM !ESPTOOL_CMD!"
|
||||
@@ -128,13 +147,13 @@ CALL :LOG_MESSAGE INFO "Using esptool baud: !ESPTOOL_BAUD!."
|
||||
|
||||
IF %CHANGE_MODE% EQU 1 (
|
||||
@REM Attempt to change mode via 1200bps Reset.
|
||||
CALL :RUN_ESPTOOL !RESET_BAUD! --after no_reset read_flash_status
|
||||
CALL :RUN_ESPTOOL !RESET_BAUD! --after no_reset !ESPTOOL_READ_FLASH_STATUS!
|
||||
GOTO eof
|
||||
)
|
||||
|
||||
@REM Flashing operations.
|
||||
CALL :LOG_MESSAGE INFO "Trying to flash update "!FILENAME!" at OFFSET !UPDATE_OFFSET!..."
|
||||
CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write-flash !UPDATE_OFFSET! "!FILENAME!" || GOTO eof
|
||||
CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! !UPDATE_OFFSET! "!FILENAME!" || GOTO eof
|
||||
|
||||
CALL :LOG_MESSAGE INFO "Script complete!."
|
||||
|
||||
@@ -151,7 +170,7 @@ EXIT /B %ERRORLEVEL%
|
||||
@REM Example:: CALL :RUN_ESPTOOL 115200 write-flash 0x10000 "firmwarefile.bin"
|
||||
IF %DEBUG% EQU 1 CALL :LOG_MESSAGE DEBUG "About to run command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4"
|
||||
CALL :RESET_ERROR
|
||||
!ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4
|
||||
%ESPTOOL_CMD% --baud %~1 %~2 %~3 %~4
|
||||
IF %CHANGE_MODE% EQU 1 GOTO :eof
|
||||
IF %ERRORLEVEL% NEQ 0 (
|
||||
CALL :LOG_MESSAGE ERROR "Error running command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4"
|
||||
|
||||
@@ -23,10 +23,29 @@ parser.add_argument(
|
||||
default=[],
|
||||
help="Board level to build for (omit for the 'pr' + 'release' matrix)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--added-config",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="PATH",
|
||||
help="platformio.ini added by this PR; its first env is built regardless of board_level",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
outlist = []
|
||||
|
||||
# A brand-new board is normally 'release', so it would get no CI until after merge.
|
||||
# Build the first env of each newly added config so it is compiled at least once.
|
||||
forced_envs = set()
|
||||
for added_path in args.added_config:
|
||||
try:
|
||||
with open(added_path, encoding="utf-8") as added_file:
|
||||
first_env = re.search(r"^[ \t]*\[env:([^\]]+)\]", added_file.read(), re.MULTILINE)
|
||||
except OSError:
|
||||
continue
|
||||
if first_env:
|
||||
forced_envs.add(first_env.group(1).strip())
|
||||
|
||||
cfg = ProjectConfig.get_instance()
|
||||
pio_envs = cfg.envs()
|
||||
|
||||
@@ -69,6 +88,9 @@ for env in all_envs:
|
||||
# Always include board_level = 'pr'
|
||||
if env["board_level"] == "pr":
|
||||
outlist.append(env["ci"])
|
||||
# Include the first env of a platformio.ini added by this PR
|
||||
elif env["ci"]["board"] in forced_envs:
|
||||
outlist.append(env["ci"])
|
||||
# Include board_level = 'extra' when requested
|
||||
elif "extra" in args.level and env["board_level"] == "extra":
|
||||
outlist.append(env["ci"])
|
||||
|
||||
@@ -167,3 +167,53 @@ state_classify() {
|
||||
printf 'CLEAN\t\n'
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Error-line budget -------------------------------------------------------------------------
|
||||
#
|
||||
# A second orthogonal axis, like CLEAN/DIRTY above: a suite can pass while emitting six figures of
|
||||
# LOG_ERROR, which buries a real failure and trains everyone to skim. The budget is declared in the
|
||||
# same manifest, as an `errors=` flag, and it is a RANGE rather than a ceiling - for a fuzz suite the
|
||||
# floor is the load-bearing half. test_fuzz_decode logging ~100k rejections is it working; the same
|
||||
# suite logging none means it stopped feeding malformed input, and every case would still pass.
|
||||
#
|
||||
# Undeclared suites get ERROR_BUDGET_DEFAULT. Declared forms: "N" (max), "MIN..MAX", "MIN.." (floor
|
||||
# only). Everything is inclusive.
|
||||
ERROR_BUDGET_DEFAULT=100
|
||||
|
||||
# Count LOG_ERROR lines in a suite's captured output.
|
||||
state_count_errors() {
|
||||
local log="$1"
|
||||
[[ -f $log ]] || {
|
||||
printf '0'
|
||||
return 0
|
||||
}
|
||||
# `|| true`, not `|| printf 0`: grep -c already prints 0 before exiting 1 on no match, so a
|
||||
# fallback that prints appends a second line and the caller gets "0\n0" to do arithmetic on.
|
||||
grep -cE '^ERROR +\|' "$log" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# VERDICT<TAB>DETAIL. WITHIN / OVER / UNDER, mirroring state_classify()'s shape.
|
||||
state_classify_errors() {
|
||||
local count="$1" declared="$2" min=0 max="$ERROR_BUDGET_DEFAULT"
|
||||
|
||||
if [[ -n $declared ]]; then
|
||||
if [[ $declared == *".."* ]]; then
|
||||
min="${declared%%..*}"
|
||||
max="${declared##*..}"
|
||||
[[ -z $max ]] && max=""
|
||||
else
|
||||
max="$declared"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n $max ]] && ((count > max)); then
|
||||
printf 'OVER\t%d error line(s), budget %s' "$count" "${declared:-$ERROR_BUDGET_DEFAULT}"
|
||||
return 0
|
||||
fi
|
||||
if ((count < min)); then
|
||||
printf 'UNDER\t%d error line(s), expected at least %d - is it still exercising the path?' \
|
||||
"$count" "$min"
|
||||
return 0
|
||||
fi
|
||||
printf 'WITHIN\t%d' "$count"
|
||||
}
|
||||
@@ -92,6 +92,11 @@ GRANULARITY="$(state_flag_value state "$FLAGS")"
|
||||
|
||||
IFS=$'\t' read -r VERDICT DETAIL <<<"$(state_classify "$CHANGED" "$DECLARED")"
|
||||
|
||||
# Error-line budget: same manifest, same declare-and-justify shape as the writes above. Counted from
|
||||
# the captured log, so it costs nothing extra.
|
||||
ERROR_COUNT="$(state_count_errors "$LOG")"
|
||||
IFS=$'\t' read -r ERROR_VERDICT ERROR_DETAIL <<<"$(state_classify_errors "$ERROR_COUNT" "$(state_flag_value errors "$FLAGS")")"
|
||||
|
||||
# Per-test attribution, when the suite has not declared that it carries state across its own test
|
||||
# cases. For a state=per-suite suite every test after the first would be flagged by design - that
|
||||
# carry *is* the declared behaviour - so only the suite boundary is meaningful there.
|
||||
@@ -114,14 +119,14 @@ fi
|
||||
|
||||
STATUS=$([[ $RC -eq 0 ]] && echo PASS || echo FAIL)
|
||||
mkdir -p "$(dirname "$SUMMARY")" 2>/dev/null
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$SUITE" "$STATUS" "$VERDICT" "${DETAIL-}" "${PER_TEST_DETAIL-}" \
|
||||
"${SURVIVORS-}" >>"$SUMMARY"
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$SUITE" "$STATUS" "$VERDICT" "${DETAIL-}" "${PER_TEST_DETAIL-}" \
|
||||
"${SURVIVORS-}" "${ERROR_VERDICT-}" "${ERROR_DETAIL-}" >>"$SUMMARY"
|
||||
|
||||
# Keep the sandbox when there is something to look at: on a failure it plus the built binary is a
|
||||
# complete, replayable reproduction, and on a DIRTY verdict the leftovers *are* the bug report. A
|
||||
# clean pass leaves nothing behind.
|
||||
KEEP="${MESHTASTIC_TEST_KEEP_STATE:-0}"
|
||||
if [[ $RC -ne 0 || $VERDICT != CLEAN || -n ${SURVIVORS-} || $KEEP == 1 ]]; then
|
||||
if [[ $RC -ne 0 || $VERDICT != CLEAN || $ERROR_VERDICT != WITHIN || -n ${SURVIVORS-} || $KEEP == 1 ]]; then
|
||||
DEST="$STATE_ROOT/$SUITE"
|
||||
rm -rf "$DEST" 2>/dev/null
|
||||
mv "$SCRATCH" "$DEST" 2>/dev/null || DEST="$SCRATCH"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"description."
|
||||
],
|
||||
"rak4631": {
|
||||
"ram_bytes": 113000,
|
||||
"flash_bytes": 786000
|
||||
"ram_bytes": 108000,
|
||||
"flash_bytes": 746000
|
||||
}
|
||||
}
|
||||
+94
-42
@@ -38,7 +38,8 @@
|
||||
# test/state-manifest.tsv.
|
||||
# FILTERED - a -f run completed cleanly; suites not in the filter were intentionally skipped.
|
||||
# Use this when iterating on a single suite; it is not a quality signal.
|
||||
# RED - at least one failure, build error, or sanitizer fault.
|
||||
# RED - at least one failure, build error, sanitizer fault, or a suite that reported
|
||||
# another suite's test cases (bin/check-test-attribution.py).
|
||||
#
|
||||
# Two orthogonal axes: PASS/FAIL × CLEAN/DIRTY. Each suite runs in its own scratch $HOME
|
||||
# (bin/pio-test-isolate.sh), so leftovers are harmless; DIRTY means "undeclared", not "dangerous".
|
||||
@@ -59,6 +60,7 @@
|
||||
# RESULT: AMBER N/M suites ran (missing: test_radio test_serial) - all that ran passed
|
||||
# RESULT: AMBER 3 test case(s) ignored
|
||||
# RESULT: FILTERED 1/N suites ran (not run: …) - filtered: test_utf8
|
||||
# RESULT: RED test attribution failed - suites did not run their own tests
|
||||
# RESULT: RED test_traffic_management: 1 failed (or: build/crash error)
|
||||
# RESULT: RED sanitizer fault - SUMMARY: AddressSanitizer: 1272 byte(s) leaked (tests may have
|
||||
# all passed; the coverage build aborts at exit on an ASan/LSan fault - often shown only
|
||||
@@ -163,20 +165,21 @@ export MESHTASTIC_TEST_STATE_SUMMARY="$STATE_SUMMARY"
|
||||
$KEEP_STATE && export MESHTASTIC_TEST_KEEP_STATE=1
|
||||
$WRITE_MANIFEST && export MESHTASTIC_TEST_KEEP_STATE=1
|
||||
|
||||
# Canonical suite set = the directories in test/. This is the source of truth for
|
||||
# "what should run"; a filtered run only expects its filtered suite.
|
||||
# --- Test attribution --------------------------------------------------------
|
||||
# PlatformIO parses Unity output textually and never checks that the source file a case came from
|
||||
# belongs to the suite it thinks it ran, so one suite's binary running under another's name reads
|
||||
# as a pass. The JUnit reports carry both halves (testsuite@name vs testcase@file), so collect them
|
||||
# here and grade with bin/check-test-attribution.py below. Cleared first: a stale report from an
|
||||
# earlier run would otherwise satisfy this run's expectations.
|
||||
ATTRIB_DIR="$ROOT_DIR/.pio/test-attribution"
|
||||
rm -rf "$ATTRIB_DIR"
|
||||
mkdir -p "$ATTRIB_DIR"
|
||||
|
||||
# Canonical suite set = the directories in test/, detected on the fly. This is the sole source
|
||||
# of truth for "what should run"; a filtered run only expects its filtered suite.
|
||||
mapfile -t ALL_SUITES < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
|
||||
EXPECTED_COUNT=${#ALL_SUITES[@]}
|
||||
|
||||
# Canonical suite count - the registered total, maintained in test/native-suite-count.
|
||||
# Update that file whenever a test suite is added or removed.
|
||||
CANONICAL_COUNT_FILE="test/native-suite-count"
|
||||
if [[ -f $CANONICAL_COUNT_FILE ]]; then
|
||||
CANONICAL_COUNT=$(tr -d '[:space:]' <"$CANONICAL_COUNT_FILE")
|
||||
else
|
||||
CANONICAL_COUNT=""
|
||||
fi
|
||||
|
||||
# Cached object-count for this env, written after each completed build (in the gitignored build
|
||||
# dir). Used as the progress denominator: accurate for a full rebuild (every object recompiles),
|
||||
# only a rough upper bound for an incremental run.
|
||||
@@ -260,10 +263,15 @@ if $SHUFFLE; then
|
||||
echo "suite order: shuffled with --seed $SEED (${#RUN_ORDER[@]} suites)"
|
||||
fi
|
||||
|
||||
# Build every test program before running any of them, the way .github/workflows/test_native.yml
|
||||
# Warm the shared src objects before running any suite, the way .github/workflows/test_native.yml
|
||||
# does. Fused build+run makes whichever suite PlatformIO's directory walk reaches first absorb the
|
||||
# whole src compile and report it as its own duration - that is how a 35s suite once reported 13
|
||||
# minutes, and it hides the build cost from every timing the summary prints.
|
||||
#
|
||||
# This is a WARM-UP ONLY: the run below must still build. PlatformIO links every test program to
|
||||
# the one $BUILD_DIR/$PROGNAME path, so a `--without-building` run executes whichever suite was
|
||||
# linked last - every suite, under its own name, all PASSED. The warm-up keeps the src compile out
|
||||
# of the suite timings; the per-suite step is then just one test_main.cpp plus a link.
|
||||
BUILD_SECS=0
|
||||
build_started=$SECONDS
|
||||
if $QUIET; then
|
||||
@@ -298,19 +306,23 @@ if $SHUFFLE; then
|
||||
: >"$LOG"
|
||||
for suite in "${RUN_ORDER[@]}"; do
|
||||
if $QUIET; then
|
||||
"$PIO" test -e "$ENV" -f "$suite" "${EXTRA_ARGS[@]}" --without-building >>"$LOG" 2>&1
|
||||
"$PIO" test -e "$ENV" -f "$suite" "${EXTRA_ARGS[@]}" \
|
||||
--junit-output-path "$ATTRIB_DIR/$suite.xml" >>"$LOG" 2>&1
|
||||
rc=$?
|
||||
else
|
||||
"$PIO" test -e "$ENV" -f "$suite" "${EXTRA_ARGS[@]}" --without-building 2>&1 | tee -a "$LOG"
|
||||
"$PIO" test -e "$ENV" -f "$suite" "${EXTRA_ARGS[@]}" \
|
||||
--junit-output-path "$ATTRIB_DIR/$suite.xml" 2>&1 | tee -a "$LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
fi
|
||||
((rc != 0)) && PIO_RC=$rc
|
||||
done
|
||||
elif $QUIET; then
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" --without-building >"$LOG" 2>&1
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" \
|
||||
--junit-output-path "$ATTRIB_DIR/all.xml" >"$LOG" 2>&1
|
||||
PIO_RC=$?
|
||||
else
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" --without-building 2>&1 | tee "$LOG"
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" \
|
||||
--junit-output-path "$ATTRIB_DIR/all.xml" 2>&1 | tee "$LOG"
|
||||
PIO_RC=${PIPESTATUS[0]}
|
||||
fi
|
||||
|
||||
@@ -435,6 +447,18 @@ verdict_red() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A guard in test/TestUtil.cpp aborting on purpose - a listening socket, or force_simradio put
|
||||
# back. It prints FATAL on stdout precisely so this can be told apart from a fault: otherwise its
|
||||
# exit(EXIT_FAILURE) lands in the heuristic below and is reported as a sanitizer abort that never
|
||||
# happened, which is the same wrong-cause-in-the-verdict trap as the phantom signal above.
|
||||
if grep -qE '^FATAL: ' "$LOG"; then
|
||||
grep -E '^FATAL: ' "$LOG" | head -3 | sed 's/^/ /'
|
||||
echo " -> a harness guard aborted the suite deliberately. Not a crash and not a sanitizer"
|
||||
echo " fault; the reason is the FATAL line above, and the suite's sandbox has the full log."
|
||||
echo "RESULT: RED harness guard - $(grep -m1 -oE '^FATAL: .*' "$LOG")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# All tests passed but the process still aborted at EXIT (ERRORED/SIGHUP/SIGABRT) and the
|
||||
# sanitizer report was swallowed by the runner (often surfaced only as SIGHUP). Almost always a
|
||||
# sanitizer fault - point at how to surface it rather than calling it a generic crash.
|
||||
@@ -462,30 +486,42 @@ if ! grep -qE "$PASS_RE" "$LOG"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Canonical-count rating suffix - appended to every verdict line so the result is always
|
||||
# rated against the registered total, not just the directory count.
|
||||
# If the two counts diverge (suite added/removed without updating native-suite-count), that
|
||||
# is itself surfaced as AMBER before we reach any verdict.
|
||||
canonical_rating() {
|
||||
# Verdict-line suffix. The suite count itself is derived from the test_* directories on the fly
|
||||
# (EXPECTED_COUNT above), so the only extra context a verdict needs is the shuffle seed - carried
|
||||
# into the machine-readable line so a verdict is always replayable from it alone.
|
||||
verdict_suffix() {
|
||||
local rating=""
|
||||
if [[ -n $CANONICAL_COUNT ]]; then
|
||||
rating="[canonical: ${RAN_COUNT}/${CANONICAL_COUNT}]"
|
||||
fi
|
||||
# Carry the seed into the machine-readable line so a verdict is always replayable from it alone.
|
||||
$SHUFFLE && rating="$rating [seed: $SEED]"
|
||||
$SHUFFLE && rating="[seed: $SEED]"
|
||||
echo "$rating"
|
||||
}
|
||||
|
||||
# AMBER: directory count disagrees with native-suite-count - file needs updating.
|
||||
if [[ -n $CANONICAL_COUNT && $EXPECTED_COUNT -ne $CANONICAL_COUNT ]]; then
|
||||
echo ""
|
||||
if [[ $EXPECTED_COUNT -gt $CANONICAL_COUNT ]]; then
|
||||
echo "RESULT: AMBER test/ has $EXPECTED_COUNT suite directories but native-suite-count says $CANONICAL_COUNT - update test/native-suite-count after registering new suites"
|
||||
else
|
||||
echo "RESULT: AMBER test/ has $EXPECTED_COUNT suite directories but native-suite-count says $CANONICAL_COUNT - update test/native-suite-count after removing suites"
|
||||
fi
|
||||
exit 2
|
||||
# --- Attribution axis ---------------------------------------------------------
|
||||
# RED, and checked before every softer verdict: a suite that reported another suite's test cases
|
||||
# did not run at all, so every count and state verdict below it is measuring the wrong thing. A
|
||||
# filtered run expects only its own suite; a full run expects the canonical set.
|
||||
# -f takes an fnmatch pattern, not necessarily a suite name, so resolve it against the canonical
|
||||
# set rather than expecting a suite literally called "test_nodedb*". An unmatched pattern leaves
|
||||
# the list empty, which checks attribution only - a filter that selects nothing is already RED
|
||||
# above, for want of a pass summary.
|
||||
ATTRIB_EXPECT="${ALL_SUITES[*]}"
|
||||
if [[ -n $FILTER ]]; then
|
||||
ATTRIB_EXPECT=""
|
||||
for attrib_suite in "${ALL_SUITES[@]}"; do
|
||||
# shellcheck disable=SC2053 # deliberate glob match: FILTER is a pattern, not a literal
|
||||
[[ $attrib_suite == $FILTER ]] && ATTRIB_EXPECT+="$attrib_suite "
|
||||
done
|
||||
fi
|
||||
ATTRIB_OUT="$("$SCRIPT_DIR/check-test-attribution.py" --expect "$ATTRIB_EXPECT" \
|
||||
--label "$ENV" "$ATTRIB_DIR"/*.xml 2>&1)"
|
||||
ATTRIB_RC=$?
|
||||
if ((ATTRIB_RC != 0)); then
|
||||
echo ""
|
||||
echo "$ATTRIB_OUT" | sed 's/^/ /'
|
||||
preserve_run_log
|
||||
echo "RESULT: RED test attribution failed - suites did not run their own tests $(verdict_suffix)"
|
||||
exit 1
|
||||
fi
|
||||
$QUIET || echo "$ATTRIB_OUT" | tail -1
|
||||
|
||||
# --- Shared-state axis --------------------------------------------------------
|
||||
# Read what the per-suite wrapper recorded. Reported after the count checks so a structural problem
|
||||
@@ -497,6 +533,7 @@ if [[ -f $STATE_SUMMARY ]]; then
|
||||
mapfile -t DIRTY_SUITES < <(awk -F'\t' '$3 == "DIRTY" { print $1 " (" $4 ")" }' "$STATE_SUMMARY")
|
||||
mapfile -t MISSING_SUITES < <(awk -F'\t' '$3 == "MISSING" { print $1 " (" $4 ")" }' "$STATE_SUMMARY")
|
||||
mapfile -t SURVIVOR_SUITES < <(awk -F'\t' '$6 != "" { print $1 " (pid " $6 ")" }' "$STATE_SUMMARY")
|
||||
mapfile -t ERROR_BUDGET_SUITES < <(awk -F'\t' '$7 == "OVER" || $7 == "UNDER" { print $1 " " tolower($7) " budget: " $8 }' "$STATE_SUMMARY")
|
||||
fi
|
||||
|
||||
# Print the opt-out count on every run, so the number creeping upward is visible without anyone
|
||||
@@ -546,7 +583,7 @@ if [[ $IGNORED_COUNT -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "$IGNORE_DETAIL"
|
||||
echo ""
|
||||
echo "RESULT: AMBER ${IGNORED_COUNT} test case(s) ignored $(canonical_rating)"
|
||||
echo "RESULT: AMBER ${IGNORED_COUNT} test case(s) ignored $(verdict_suffix)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
@@ -558,7 +595,7 @@ if [[ -z $FILTER && $ACCOUNTED_COUNT -lt $EXPECTED_COUNT ]]; then
|
||||
printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || missing+=("$s")
|
||||
done
|
||||
echo ""
|
||||
echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) - all that ran passed $(canonical_rating)"
|
||||
echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) - all that ran passed $(verdict_suffix)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
@@ -572,7 +609,22 @@ if ((${#DIRTY_SUITES[@]} > 0)); then
|
||||
echo ""
|
||||
echo " -> declare these in test/state-manifest.tsv with a reason, or stop the write."
|
||||
echo " -> ./bin/run-tests.sh --write-manifest prints the entries to paste."
|
||||
echo "RESULT: AMBER ${#DIRTY_SUITES[@]} suite(s) left undeclared shared state $(canonical_rating)"
|
||||
echo "RESULT: AMBER ${#DIRTY_SUITES[@]} suite(s) left undeclared shared state $(verdict_suffix)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# AMBER: a suite spent its LOG_ERROR budget, or came in under a declared floor. Over budget buries a
|
||||
# real failure in noise - three log sites account for nearly all of today's volume, and until those
|
||||
# are demoted this stays AMBER rather than RED so it does not land red on day one and get switched
|
||||
# off. Under a floor is the more interesting half: a fuzz suite that stops logging rejections has
|
||||
# stopped feeding malformed input, and every one of its cases still passes.
|
||||
if ((${#ERROR_BUDGET_SUITES[@]} > 0)); then
|
||||
echo ""
|
||||
printf ' %s\n' "${ERROR_BUDGET_SUITES[@]}"
|
||||
echo ""
|
||||
echo " -> over: demote the log line if the condition is expected, or declare errors=<max> in"
|
||||
echo " test/state-manifest.tsv with a reason. Under: check the suite still exercises the path."
|
||||
echo "RESULT: AMBER ${#ERROR_BUDGET_SUITES[@]} suite(s) outside their error budget $(verdict_suffix)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
@@ -588,7 +640,7 @@ if ((${#SURVIVOR_SUITES[@]} > 0)); then
|
||||
echo ""
|
||||
echo " -> end every setup() branch with exit(UNITY_END()), not a bare UNITY_END()."
|
||||
echo " -> ./bin/lint-unity-exit.sh test/**/*.cpp finds the sites; see test/README.md."
|
||||
echo "RESULT: AMBER ${#SURVIVOR_SUITES[@]} suite(s) still running after the suite finished $(canonical_rating)"
|
||||
echo "RESULT: AMBER ${#SURVIVOR_SUITES[@]} suite(s) still running after the suite finished $(verdict_suffix)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
@@ -599,10 +651,10 @@ if [[ -n $FILTER ]]; then
|
||||
for s in "${ALL_SUITES[@]}"; do
|
||||
printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || not_run+=("$s")
|
||||
done
|
||||
echo "RESULT: FILTERED ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (not run: ${not_run[*]}) - filtered: $FILTER $(canonical_rating)"
|
||||
echo "RESULT: FILTERED ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (not run: ${not_run[*]}) - filtered: $FILTER $(verdict_suffix)"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# GREEN: all canonical suites ran, all passed, no ignored test cases, nothing undeclared left behind.
|
||||
echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed, all CLEAN $(canonical_rating)"
|
||||
echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed, all CLEAN $(verdict_suffix)"
|
||||
exit 0
|
||||
Executable
+202
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run one native test suite repeatedly and report how often it fails.
|
||||
#
|
||||
# For order-independent flakes - a real-time race, a slow-host margin, an uninitialised read - a
|
||||
# single green run proves nothing. This runs the same built binary N times and prints a flake rate,
|
||||
# so "passes here" becomes a measurement instead of an anecdote.
|
||||
#
|
||||
# ./bin/stress-suite.sh test_pki_admin_fallback # 20 runs, coverage, as CI invokes it
|
||||
# ./bin/stress-suite.sh -n 200 test_packet_signing # 200 runs
|
||||
# ./bin/stress-suite.sh -e native -n 50 test_admin_radio # the other env's invocation
|
||||
# ./bin/stress-suite.sh -l 8 -n 50 test_pki_admin_fallback # 8 spinners of CPU contention
|
||||
# ./bin/stress-suite.sh --no-simradio -n 50 test_packet_signing
|
||||
# ./bin/stress-suite.sh --shuffle -n 5 # whole suite set, a new order each time
|
||||
#
|
||||
# --shuffle is the other axis and takes no suite name: it drives bin/run-tests.sh --seed with a fresh
|
||||
# seed per iteration, so suite ORDER varies. Use it for state that leaks suite -> suite; use the
|
||||
# single-suite mode above for races and slow-host margins, which order cannot expose. Every seed is
|
||||
# printed, and a red one is replayable with ./bin/run-tests.sh --seed <n>.
|
||||
#
|
||||
# Each run gets a fresh scratch $HOME, so no run inherits another's prefs. Failing runs keep their
|
||||
# log and their $HOME; passing runs leave nothing behind.
|
||||
#
|
||||
# Exit: 0 = every run passed, 1 = at least one failed, 2 = usage/build error.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_NAME=coverage
|
||||
RUNS=20
|
||||
LOAD=0
|
||||
SIMRADIO=auto
|
||||
SHUFFLE=false
|
||||
SUITE=""
|
||||
|
||||
usage() {
|
||||
sed -n '3,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit 2
|
||||
}
|
||||
|
||||
# A missing or non-numeric value used to sail through and produce a loop that never ran, reporting
|
||||
# "0/0 failed" as a pass. Reject it at parse time instead.
|
||||
need_value() {
|
||||
[[ -n ${2:-} && $2 != -* ]] || {
|
||||
echo "$1 needs a value" >&2
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
need_number() {
|
||||
[[ $2 =~ ^[0-9]+$ ]] || {
|
||||
echo "$1 needs a number, got '$2'" >&2
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-e | --environment)
|
||||
need_value "$1" "${2:-}"
|
||||
ENV_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
-n | --runs)
|
||||
need_value "$1" "${2:-}"
|
||||
need_number "$1" "$2"
|
||||
RUNS="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l | --load)
|
||||
need_value "$1" "${2:-}"
|
||||
need_number "$1" "$2"
|
||||
LOAD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--shuffle)
|
||||
SHUFFLE=true
|
||||
shift
|
||||
;;
|
||||
--simradio)
|
||||
SIMRADIO=yes
|
||||
shift
|
||||
;;
|
||||
--no-simradio)
|
||||
SIMRADIO=no
|
||||
shift
|
||||
;;
|
||||
-h | --help) usage ;;
|
||||
-*)
|
||||
echo "unknown option: $1" >&2
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
SUITE="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if $SHUFFLE; then
|
||||
[[ -z $SUITE ]] || {
|
||||
echo "--shuffle varies suite order across the whole set; drop the suite name" >&2
|
||||
exit 2
|
||||
}
|
||||
fails=0
|
||||
reds=()
|
||||
echo "running the full suite set x$RUNS on $ENV_NAME, reshuffled each time"
|
||||
for ((run = 1; run <= RUNS; run++)); do
|
||||
# Seeds from /dev/urandom, printed and recorded: an order you cannot replay is not evidence.
|
||||
seed=$((RANDOM * 32768 + RANDOM))
|
||||
log="$REPO/.pio/build/$ENV_NAME/stress-shuffle.$seed.log"
|
||||
mkdir -p "$(dirname "$log")"
|
||||
printf 'run %d/%d seed %s ... ' "$run" "$RUNS" "$seed"
|
||||
if "$REPO/bin/run-tests.sh" -e "$ENV_NAME" --seed "$seed" >"$log" 2>&1; then
|
||||
echo "GREEN"
|
||||
rm -f "$log"
|
||||
else
|
||||
rc=$?
|
||||
fails=$((fails + 1))
|
||||
reds+=("$seed")
|
||||
echo "$(grep -m1 '^RESULT:' "$log" || echo "exit $rc") - log $log"
|
||||
fi
|
||||
done
|
||||
echo "RESULT: $fails/$RUNS runs not green"
|
||||
[[ ${#reds[@]} -gt 0 ]] && echo "replay: ./bin/run-tests.sh --seed ${reds[0]}"
|
||||
[[ $fails -eq 0 ]] || exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -n $SUITE ]] || usage
|
||||
|
||||
# Mirror what the env's test_testing_command passes, so a stress run reproduces the real invocation
|
||||
# rather than a third one of its own. [env:coverage] adds -s (simradio); [env:native] does not.
|
||||
if [[ $SIMRADIO == auto ]]; then
|
||||
# Read to the next [section] header, not a fixed window: -s is the last line of the command block.
|
||||
if awk "/^\\[env:$ENV_NAME\\]/{f=1;next} /^\\[/{f=0} f" \
|
||||
"$REPO/variants/native/portduino/platformio.ini" | grep -qE '^[[:space:]]+-s[[:space:]]*$'; then
|
||||
SIMRADIO=yes
|
||||
else
|
||||
SIMRADIO=no
|
||||
fi
|
||||
fi
|
||||
ARGS=()
|
||||
[[ $SIMRADIO == yes ]] && ARGS+=(-s)
|
||||
|
||||
PIO="$REPO/.pio_env/bin/pio"
|
||||
[[ -x $PIO ]] || PIO="$(command -v pio)" || {
|
||||
echo "pio not found" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
BIN="$REPO/.pio/build/$ENV_NAME/meshtasticd"
|
||||
echo "building $SUITE for $ENV_NAME ..."
|
||||
"$PIO" test -e "$ENV_NAME" -f "$SUITE" --without-testing >/dev/null 2>&1 || {
|
||||
echo "build failed - rerun without --without-testing to see why" >&2
|
||||
exit 2
|
||||
}
|
||||
[[ -x $BIN ]] || {
|
||||
echo "no binary at $BIN" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
LOADPIDS=()
|
||||
cleanup() {
|
||||
[[ ${#LOADPIDS[@]} -gt 0 ]] && kill "${LOADPIDS[@]}" 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
# EXIT cleans up; INT/TERM must also stop, or the loop keeps launching runs after a ^C.
|
||||
trap cleanup EXIT
|
||||
trap 'cleanup; exit 130' INT
|
||||
trap 'cleanup; exit 143' TERM
|
||||
|
||||
if [[ $LOAD -gt 0 ]]; then
|
||||
echo "starting $LOAD spinner(s) against $(nproc) cpu(s)"
|
||||
for ((i = 0; i < LOAD; i++)); do
|
||||
(while :; do :; done) &
|
||||
LOADPIDS+=($!)
|
||||
done
|
||||
fi
|
||||
|
||||
OUT="$REPO/.pio/build/$ENV_NAME/stress"
|
||||
mkdir -p "$OUT"
|
||||
fails=0
|
||||
echo "running $SUITE x$RUNS on $ENV_NAME (simradio=$SIMRADIO)"
|
||||
for ((run = 1; run <= RUNS; run++)); do
|
||||
scratch=$(mktemp -d)
|
||||
log="$OUT/$SUITE.$run.log"
|
||||
# Through pio-test-isolate.sh, not the bare binary: that is what test_testing_command runs, so
|
||||
# a repetition here exercises the sandboxing, survivor reaping and state verdict too.
|
||||
if MESHTASTIC_TEST_STATE_DIR="$scratch/state" "$REPO/bin/pio-test-isolate.sh" "$BIN" "${ARGS[@]}" >"$log" 2>&1; then
|
||||
rm -rf "$scratch" "$log"
|
||||
printf '.'
|
||||
else
|
||||
fails=$((fails + 1))
|
||||
printf '\nRUN %d FAILED - log %s - state %s\n' "$run" "$log" "$scratch"
|
||||
grep -E ':(FAIL|IGNORE)' "$log" | head -5
|
||||
fi
|
||||
done
|
||||
printf '\n'
|
||||
|
||||
pct=$((fails * 100 / RUNS))
|
||||
echo "RESULT: $fails/$RUNS failed (${pct}%)"
|
||||
[[ $fails -eq 0 ]] || exit 1
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canary for bin/check-test-attribution.py: reproduce the false green on purpose and require the
|
||||
# checker to catch it.
|
||||
#
|
||||
# The attribution check exists because both harnesses once ran every suite against whichever binary
|
||||
# was linked last, so all 57 reported a pass while five test programs actually executed. A checker
|
||||
# for that is only worth having if it still fires, and a checker that has quietly stopped firing
|
||||
# looks exactly like a codebase with no problem. So: build two suites, run them the broken way
|
||||
# (--without-building, which is what stops PlatformIO relinking on a non-embedded platform), and
|
||||
# assert the checker reports a mismatch.
|
||||
#
|
||||
# It also fails if the reproduction stops reproducing - if PlatformIO ever relinks per suite under
|
||||
# --without-building, the premise behind dropping that flag no longer holds and the harness should
|
||||
# be revisited rather than left resting on a stale assumption.
|
||||
#
|
||||
# Not a Unity suite and not a test_* directory, so it stays outside the suite count run-tests.sh
|
||||
# derives from test/ - same arrangement as bin/test-state-check.sh and bin/test-config-check.sh.
|
||||
#
|
||||
# Usage: ./bin/test-attribution-canary.sh [-e <env>] (default: coverage, as CI runs)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO" || exit 2
|
||||
|
||||
ENV_NAME=coverage
|
||||
[[ ${1-} == "-e" ]] && ENV_NAME="$2"
|
||||
|
||||
PIO="$REPO/.pio_env/bin/pio"
|
||||
[[ -x $PIO ]] || PIO="$(command -v pio)" || {
|
||||
echo "canary: pio not found" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Two suites whose cases cannot be confused: different source files, different counts. Both are
|
||||
# small and neither touches shared state, so the canary costs a link rather than a rebuild.
|
||||
A=test_utf8
|
||||
B=test_breakout
|
||||
REPORT="$(mktemp -d)/canary.xml"
|
||||
|
||||
echo "canary: building $A and $B for $ENV_NAME"
|
||||
"$PIO" test -e "$ENV_NAME" -f "$A" -f "$B" --without-testing >/dev/null 2>&1 || {
|
||||
echo "canary: build failed" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
echo "canary: running them the broken way (--without-building)"
|
||||
"$PIO" test -e "$ENV_NAME" -f "$A" -f "$B" --without-building --junit-output-path "$REPORT" >/dev/null 2>&1
|
||||
|
||||
[[ -s $REPORT ]] || {
|
||||
echo "canary: no JUnit report at $REPORT - cannot judge the checker" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# The checker must FAIL here, and fail for the RIGHT reason. Exit 1 is a finding; exit 2 is bad
|
||||
# usage or an unreadable report, which would let a broken canary read as a caught mismatch.
|
||||
OUT="$(./bin/check-test-attribution.py --label "canary" "$REPORT" 2>&1)"
|
||||
RC=$?
|
||||
if [[ $RC -eq 2 ]]; then
|
||||
echo ""
|
||||
echo "CANARY INCONCLUSIVE: the checker could not read the report it was given (exit 2)."
|
||||
echo "$OUT"
|
||||
echo "Report kept at: $REPORT"
|
||||
exit 2
|
||||
fi
|
||||
if [[ $RC -eq 0 ]] || ! grep -q 'MISATTRIBUTED' <<<"$OUT"; then
|
||||
echo ""
|
||||
echo "CANARY FAILED: the attribution check passed a run that mis-attributes its cases."
|
||||
echo ""
|
||||
echo "Two suites were run with --without-building, so PlatformIO did not relink and both"
|
||||
echo "executed the same leftover binary. check-test-attribution.py is supposed to catch exactly"
|
||||
echo "that and it did not, which means the guard against the whole false-green class is dead."
|
||||
echo ""
|
||||
echo "Either the checker regressed, or PlatformIO now relinks per suite under --without-building"
|
||||
echo "- in which case the reason bin/run-tests.sh and CI stopped passing that flag has changed,"
|
||||
echo "and the harness should be revisited rather than left on a stale assumption."
|
||||
echo "Report kept at: $REPORT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "canary: OK - the attribution check caught the deliberate mis-attribution"
|
||||
rm -rf "$(dirname "$REPORT")"
|
||||
@@ -12,8 +12,9 @@
|
||||
# anything" is what catches a rule that reports the right number of findings in the wrong places, or
|
||||
# that collapses two findings on one line into one.
|
||||
#
|
||||
# Not a Unity suite and not counted in test/native-suite-count - same arrangement as
|
||||
# bin/test-state-check.sh, and for the same reason: it asserts the behaviour of a process.
|
||||
# Not a Unity suite and not a test_* directory, so outside the suite count run-tests.sh derives
|
||||
# from test/ - same arrangement as bin/test-state-check.sh, and for the same reason: it asserts
|
||||
# the behaviour of a process.
|
||||
#
|
||||
# Usage: ./bin/test-lint-unity-exit.sh (exit 0 = all fixtures behaved)
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
# before-empty assertion fires, because an after-diff measured against a dirty baseline reports
|
||||
# green while meaning nothing.
|
||||
#
|
||||
# Not a Unity suite and not counted in test/native-suite-count - the same arrangement as
|
||||
# bin/test-config-check.sh, and for the same reason: what it asserts is the behaviour of a process,
|
||||
# not of a linkable function.
|
||||
# Not a Unity suite and not a test_* directory, so outside the suite count run-tests.sh derives
|
||||
# from test/ - the same arrangement as bin/test-config-check.sh, and for the same reason: what it
|
||||
# asserts is the behaviour of a process, not of a linkable function.
|
||||
#
|
||||
# Usage: ./bin/test-state-check.sh (exit 0 = all fixtures behaved)
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"partitions": "default_16MB.csv",
|
||||
"memory_type": "qio_opi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "opi",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "heltec_rc32"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "Heltec RC32 (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://heltec.org/",
|
||||
"vendor": "Heltec"
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52840_s140_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DNRF52840_XXAA",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x239A", "0x4405"],
|
||||
["0x239A", "0x0029"],
|
||||
["0x239A", "0x002A"],
|
||||
["0x2886", "0x1667"]
|
||||
],
|
||||
"usb_product": "HT-n5262",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "heltec_rc52",
|
||||
"variants_dir": "variants",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS140",
|
||||
"sd_name": "s140",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B6"
|
||||
},
|
||||
"bootloader": {
|
||||
"settings_addr": "0xFF000"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"onboard_tools": ["jlink"],
|
||||
"svd_path": "nrf52840.svd",
|
||||
"openocd_target": "nrf52840-mdk-rs"
|
||||
},
|
||||
"frameworks": ["arduino"],
|
||||
"name": "Heltec RC52",
|
||||
"upload": {
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_size": 815104,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"],
|
||||
"use_1200bps_touch": true,
|
||||
"require_upload_port": true,
|
||||
"wait_for_upload_port": true
|
||||
},
|
||||
"url": "https://heltec.org/",
|
||||
"vendor": "Heltec"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"partitions": "default_16MB.csv"
|
||||
},
|
||||
"core": "esp32",
|
||||
"f_cpu": "160000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32c6",
|
||||
"variant": "heltec_rcc6"
|
||||
},
|
||||
"connectivity": ["bluetooth", "wifi", "lora"],
|
||||
"debug": {
|
||||
"openocd_target": "esp32c6.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "Heltec RCC6",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://heltec.org/",
|
||||
"vendor": "Heltec"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52840_s140_v7.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DARDUINO_MDBT50Q_RX -DNRF52840_XXAA",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x2886", "0x1668"],
|
||||
["0x2886", "0x1667"]
|
||||
],
|
||||
"usb_product": "TRACKER L1 Pro 1W",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "seeed_wio_tracker_L1_Pro_1W",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS140",
|
||||
"sd_name": "s140",
|
||||
"sd_version": "7.3.0",
|
||||
"sd_fwid": "0x0123"
|
||||
},
|
||||
"bootloader": {
|
||||
"settings_addr": "0xFF000"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"svd_path": "nrf52840.svd",
|
||||
"openocd_target": "nrf52840-mdk-rs"
|
||||
},
|
||||
"frameworks": ["arduino"],
|
||||
"name": "seeed_wio_tracker_L1_Pro_1W",
|
||||
"upload": {
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_size": 815104,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": [
|
||||
"jlink",
|
||||
"nrfjprog",
|
||||
"nrfutil",
|
||||
"stlink",
|
||||
"cmsis-dap",
|
||||
"blackmagic"
|
||||
],
|
||||
"use_1200bps_touch": true,
|
||||
"require_upload_port": true,
|
||||
"wait_for_upload_port": true
|
||||
},
|
||||
"url": "https://www.seeedstudio.com/Wio-Tracker-L1-Pro-p-6454.html",
|
||||
"vendor": "Seeed Studio"
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DARDUINO_NRF52840_T_IMPULSE_PLUS -DNRF52840_XXAA",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [["0x239A", "0x8029"]],
|
||||
"hwids": [
|
||||
["0x239A", "0x8029"],
|
||||
["0x239A", "0x00DA"]
|
||||
],
|
||||
"usb_product": "T-Impulse-Plus-nRF52840",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "t-impulse-plus",
|
||||
@@ -37,6 +40,8 @@
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_size": 815104,
|
||||
"require_upload_port": true,
|
||||
"wait_for_upload_port": true,
|
||||
"use_1200bps_touch": true,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": [
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"memory_type": "qio_qspi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "qio",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "t-watch-ultra"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino"],
|
||||
"name": "LilyGo T-Watch Ultra",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"require_upload_port": true,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://www.lilygo.cc/en-pl/products/t-watch-ultra",
|
||||
"vendor": "LilyGo"
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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.
|
||||
@@ -1,293 +0,0 @@
|
||||
# LoRa Region → Preset Compatibility - Client Implementation Spec
|
||||
|
||||
**Status:** Draft for 2.8 · **Audience:** Meshtastic client app developers (Android first,
|
||||
Apple second, then web/python) · **Firmware side:** implemented in `firmware`
|
||||
(`FromRadio.region_presets`, see below).
|
||||
|
||||
> This document lives in the firmware repo while the feature is developed. It is meant to
|
||||
> graduate to `meshtastic/protobufs` (and/or the docs site) alongside the upstream protobuf
|
||||
> PR that reserves `FromRadio` field **19**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
For 2.8 the LoRa regions and modem presets were reworked. **Not every modem preset is legal
|
||||
in every region** - narrow EU SRD bands, the EU 868 "narrow" band, amateur/ham bands, and
|
||||
the 2.4 GHz band each accept only a specific subset of presets. The firmware already
|
||||
enforces this internally (it clamps or rejects illegal combinations), but until now a client
|
||||
had no way to _know_ the rules, so a user could pick an illegal region+preset pair in the UI
|
||||
and only discover the problem after the device silently corrected it.
|
||||
|
||||
This feature has the firmware **declare the legal region→preset combinations** to the client
|
||||
during the `want_config` handshake, so the client UI can constrain the preset picker to the
|
||||
valid set for the currently selected region (and warn about licensed-only bands). It is
|
||||
purely advisory metadata - the firmware remains the source of truth and still
|
||||
validates/clamps on its own.
|
||||
|
||||
---
|
||||
|
||||
## 2. Protocol additions
|
||||
|
||||
Three new messages in `meshtastic/mesh.proto`, plus one new `FromRadio` oneof variant.
|
||||
|
||||
### 2.1 `FromRadio.region_presets` (field 19)
|
||||
|
||||
```proto
|
||||
message FromRadio {
|
||||
uint32 id = 1;
|
||||
oneof payload_variant {
|
||||
// ... fields 2..18 unchanged ...
|
||||
LoRaRegionPresetMap region_presets = 19;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Messages
|
||||
|
||||
```proto
|
||||
// A distinct set of legal modem presets shared by one or more LoRa regions.
|
||||
message LoRaPresetGroup {
|
||||
repeated Config.LoRaConfig.ModemPreset presets = 1; // legal presets for this group
|
||||
Config.LoRaConfig.ModemPreset default_preset = 2; // always one of `presets`
|
||||
bool licensed_only = 3; // ham/amateur band → warn/gate
|
||||
}
|
||||
|
||||
// Associates a single LoRa region with its preset group (by index).
|
||||
message LoRaRegionPresets {
|
||||
Config.LoRaConfig.RegionCode region = 1;
|
||||
uint32 group_index = 2; // index into LoRaRegionPresetMap.groups
|
||||
}
|
||||
|
||||
// The full map, delivered grouped to fit one FromRadio packet.
|
||||
message LoRaRegionPresetMap {
|
||||
repeated LoRaPresetGroup groups = 1; // each distinct preset list
|
||||
repeated LoRaRegionPresets region_groups = 2; // every known region → a group index
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Why grouped (and the size envelope clients should respect)
|
||||
|
||||
A `FromRadio` packet is capped at **512 bytes** (`MAX_TO_FROM_RADIO_SIZE`). Most regions
|
||||
share one identical preset list (the "standard" 10-preset list), so the map is delivered
|
||||
**grouped**: `groups` holds each _distinct_ preset list once, and `region_groups` maps every
|
||||
known region to one of those groups by index. This keeps the encoded size additive
|
||||
(`groups` + `region_groups`) rather than multiplicative, well under the cap.
|
||||
|
||||
nanopb (firmware) array bounds - clients do **not** need to enforce these, but they bound
|
||||
what you can receive:
|
||||
|
||||
| field | max_count |
|
||||
| ----------------------------------- | ------------------------------------ |
|
||||
| `LoRaRegionPresetMap.groups` | 8 |
|
||||
| `LoRaRegionPresetMap.region_groups` | 38 (= number of `RegionCode` values) |
|
||||
| `LoRaPresetGroup.presets` | 11 |
|
||||
|
||||
---
|
||||
|
||||
## 3. When it is delivered
|
||||
|
||||
`region_presets` is sent **once** during the `want_config` handshake, as a single
|
||||
`FromRadio` message, in this position:
|
||||
|
||||
```text
|
||||
my_info → (deviceuiConfig) → node_info(self) → metadata → region_presets → channel… → config… → moduleConfig… → node_info(others)… → fileInfo… → config_complete_id → (live packets)
|
||||
```
|
||||
|
||||
i.e. **immediately after `metadata` and before the first `channel`**.
|
||||
|
||||
- It is included for a normal full `want_config` and for the **config-only** nonce.
|
||||
- It is **omitted** for the **nodes-only** nonce (that path skips metadata/config entirely).
|
||||
- A client must **not** assume it always arrives (see §5).
|
||||
|
||||
---
|
||||
|
||||
## 4. Decoding into a usable lookup
|
||||
|
||||
Flatten the grouped wire form into `Map<RegionCode, RegionPresetInfo>`:
|
||||
|
||||
```text
|
||||
struct RegionPresetInfo { Set<ModemPreset> presets; ModemPreset default; bool licensedOnly }
|
||||
|
||||
fun decode(map: LoRaRegionPresetMap): Map<RegionCode, RegionPresetInfo> {
|
||||
result = {}
|
||||
for (rg in map.region_groups) {
|
||||
if (rg.group_index >= map.groups.size) continue // defensive: malformed/forward data
|
||||
g = map.groups[rg.group_index]
|
||||
result[rg.region] = RegionPresetInfo(
|
||||
presets = g.presets.toSet(),
|
||||
default = g.default_preset,
|
||||
licensedOnly = g.licensed_only)
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Persist this map alongside the rest of the downloaded config so the LoRa config screen can
|
||||
read it synchronously.
|
||||
|
||||
---
|
||||
|
||||
## 5. Semantics & rules (the load-bearing part)
|
||||
|
||||
These rules are what keep the UX correct across firmware versions. Implement all of them.
|
||||
|
||||
1. **Absent region ⇒ no constraint.** If a `RegionCode` does not appear in `region_groups`,
|
||||
the client has _no_ compatibility info for it and **must not restrict** its preset
|
||||
choices (fall back to allowing the full `ModemPreset` list). This happens for a handful
|
||||
of `RegionCode` enum values that have no firmware band table entry (today: `EU_874`,
|
||||
`EU_917`, `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`).
|
||||
|
||||
2. **Absent message ⇒ no constraint.** Firmware older than 2.8 never sends `region_presets`.
|
||||
New clients **must** tolerate the message being absent entirely and keep their existing
|
||||
(unconstrained) behavior. Do not block the config screen waiting for it.
|
||||
|
||||
3. **`default_preset`** is always a member of that group's `presets`. Use it to pre-select a
|
||||
preset when the user switches to a region whose valid set does not include the currently
|
||||
selected preset (instead of leaving an illegal selection or guessing).
|
||||
|
||||
4. **`licensed_only`** marks ham/amateur bands. Surface a warning or gate (the firmware also
|
||||
requires the operator's `is_licensed` flag for these regions; coordinate the two so the
|
||||
user isn't allowed to pick a licensed band without acknowledging licensing).
|
||||
|
||||
5. **EU region auto-swap caveat.** The firmware treats the EU sibling regions
|
||||
(`EU_868` / `EU_866` / `EU_N_868`) specially: if the user is in one of them and selects a
|
||||
preset that belongs to a sibling's list, the firmware **swaps the region** rather than
|
||||
rejecting the preset. To make this visible in the picker, the firmware advertises the
|
||||
**same superset** (the union of the trio's presets) for all three sibling regions, so a
|
||||
client filtering per §6 will offer every EU 86x preset regardless of which sibling is
|
||||
currently selected. Consequence for clients: **do not assume the region is immutable
|
||||
across a preset change** - after an admin config write, re-read the resulting
|
||||
`LoRaConfig` and reflect the (possibly changed) region back into the UI.
|
||||
|
||||
6. **Use it as a UI guard, not a validator of truth.** The firmware still validates/clamps
|
||||
on its own. The map exists to prevent the user from _selecting_ an illegal combo; it is
|
||||
not a security or correctness boundary.
|
||||
|
||||
---
|
||||
|
||||
## 6. UI/UX recommendations
|
||||
|
||||
- In the LoRa config screen, when a region is selected, **filter/enable the modem-preset
|
||||
picker to that region's `presets`** (when `use_preset`/`use_modem_preset` is on).
|
||||
- If the current preset is not in the newly selected region's set, switch the selection to
|
||||
that region's `default_preset`.
|
||||
- Show a **licensed badge / confirmation** for regions where `licensed_only == true`.
|
||||
- If a region is absent from the map (rule §5.1) or the whole message is absent (§5.2),
|
||||
render the full preset list as before - never show an empty picker.
|
||||
|
||||
---
|
||||
|
||||
## 7. Forward / backward compatibility
|
||||
|
||||
- **Old clients, new firmware:** an unknown `FromRadio` oneof variant (field 19) is ignored
|
||||
by protobuf/nanopb decoders; the relative ordering of the known messages is unchanged, so
|
||||
existing apps are unaffected.
|
||||
- **New clients, old firmware:** message simply never arrives → treat as "no constraints"
|
||||
(§5.2).
|
||||
- **Enum growth:** new `RegionCode`/`ModemPreset` values may appear over time. Decoders
|
||||
should pass through unknown enum values rather than crashing; an unknown region in
|
||||
`region_groups` is harmless (the client just won't have a localized name for it).
|
||||
|
||||
---
|
||||
|
||||
## 8. Platform notes
|
||||
|
||||
> Verified against the `main` branch of each repo. Both have been refactored away from
|
||||
> older layouts; re-pin file paths against a specific commit if you need them durable.
|
||||
|
||||
### 8.1 Android - `meshtastic/Meshtastic-Android` (Kotlin / Compose, KMP)
|
||||
|
||||
- **Protobufs are a published Maven artifact, _not_ a submodule.** Declared in
|
||||
`gradle/libs.versions.toml` (`org.meshtastic:protobufs`, currently `2.7.25`); generated
|
||||
package is **`org.meshtastic.proto`**. **A `region_presets`-aware build requires a new
|
||||
published `org.meshtastic:protobufs` release**, then bumping that one version string.
|
||||
- **The protobufs are Wire-generated**, so the `FromRadio` oneof is **not** a
|
||||
`payloadVariantCase` enum - each arm is a **nullable field**. Handle the new variant in
|
||||
`FromRadioPacketHandlerImpl.handleFromRadio(...)`
|
||||
(`core/data/.../manager/FromRadioPacketHandlerImpl.kt`) by adding a
|
||||
`regionPresets != null -> …` arm to the existing `when { … }`, delegating to a handler
|
||||
(mirror `handleLocalMetadata` / `handleConfigComplete`).
|
||||
- **State holder:** expose the decoded map from `RadioConfigRepository` /
|
||||
`RadioConfigRepositoryImpl` as a `Flow` (mirroring `localConfigFlow`/`channelSetFlow`),
|
||||
consumed by `feature/settings/.../radio/RadioConfigViewModel.kt`.
|
||||
- **UI:** the region & preset dropdowns are `DropDownPreference`s in
|
||||
`feature/settings/.../radio/component/LoRaConfigItemList.kt` (public composable
|
||||
`LoRaConfigScreen`). Gate/filter the `ChannelOption` (preset) dropdown by the selected
|
||||
`RegionInfo`'s entry in the map.
|
||||
|
||||
### 8.2 Apple - `meshtastic/Meshtastic-Apple` (Swift / SwiftUI)
|
||||
|
||||
- **Protobufs are vendored** into a local Swift package `MeshtasticProtobufs`
|
||||
(`MeshtasticProtobufs/Sources/meshtastic/*.pb.swift`), generated from the `protobufs` git
|
||||
submodule via `scripts/gen_protos.sh`. **To get field 19:** advance the `protobufs`
|
||||
submodule, run `scripts/gen_protos.sh`, commit the regenerated `.pb.swift` + submodule
|
||||
pointer. (No published-artifact dependency - Apple can regenerate from any commit.)
|
||||
- **Dispatch:** `AccessoryManager.processFromRadio(_:)`
|
||||
(`Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift`) is a real
|
||||
`switch decodedInfo.payloadVariant { … }` - add a `.regionPresets` case, with the handler
|
||||
in `AccessoryManager+FromRadio.swift` (mirror `handleConfig` / `handleMetadata`).
|
||||
- **Persistence:** config is **SwiftData** (`@Model` entities), upserted via
|
||||
`MeshPackets`/`UpdateSwiftData.swift`. Store the decoded map (e.g. on a settings/connection
|
||||
model) so the LoRa view can read it.
|
||||
- **UI:** `Meshtastic/Views/Settings/Config/LoRaConfig.swift` (`struct LoRaConfig: View`)
|
||||
has the `Picker("Region", …)` (`RegionCodes.userSelectable`) and `Picker("Presets", …)`
|
||||
(`ModemPresets.userSelectable`, gated on `usePreset`). Filter the presets picker by the
|
||||
selected region's entry. Enums live in `Meshtastic/Enums/LoraConfigEnums.swift`.
|
||||
|
||||
### 8.3 Other clients
|
||||
|
||||
- **python (`meshtastic` / Meshtastic-python)** and **web** consume the published protobufs;
|
||||
they will see `region_presets` once their protobuf dependency includes field 19, and can
|
||||
ignore it until then (it decodes as an unknown field).
|
||||
|
||||
---
|
||||
|
||||
## 9. Reference payload (current firmware table)
|
||||
|
||||
For decoder unit tests. With the 2.8 region table, the firmware emits **6 groups**. Group
|
||||
indices are assigned in region-table order (first region to use a profile creates its group),
|
||||
so they are stable as listed here:
|
||||
|
||||
| group_index | default_preset | licensed_only | presets |
|
||||
| ----------------------- | -------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO, MEDIUM_TURBO |
|
||||
| 1 (EU 868) | `LONG_FAST` | false | _EU 86x superset_ (see below) |
|
||||
| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | _EU 86x superset_ (see below) |
|
||||
| 3 (EU 868 narrow) | `NARROW_SLOW` | false | _EU 86x superset_ (see below) |
|
||||
| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW |
|
||||
| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW |
|
||||
|
||||
The **EU 86x superset** advertised by groups 1, 2 and 3 is the union of the trio's own
|
||||
band presets, because the firmware auto-swaps region within the trio on preset selection
|
||||
(§5), so any of these is a legal pick from any of the three regions:
|
||||
|
||||
```text
|
||||
LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, LITE_FAST, LITE_SLOW, NARROW_FAST, NARROW_SLOW
|
||||
```
|
||||
|
||||
The three groups still differ by `default_preset` (`LONG_FAST` / `LITE_FAST` / `NARROW_SLOW`),
|
||||
which is why they remain distinct groups despite sharing this preset list.
|
||||
|
||||
`region_groups` (region → group_index):
|
||||
|
||||
| group | regions |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 | US, EU_433, CN, JP, ANZ, ANZ_433, RU, KR, TW, IN, NZ_865, TH, UA_433, MY_433, MY_919, SG_923, PH_433, PH_868, PH_915, KZ_433, KZ_863, NP_865, BR_902, LORA_24 |
|
||||
| 1 | EU_868 |
|
||||
| 2 | EU_866 |
|
||||
| 3 | EU_N_868 |
|
||||
| 4 | ITU1_2M, ITU2_2M, ITU3_2M |
|
||||
| 5 | ITU2_125CM |
|
||||
|
||||
> Note that several groups can carry overlapping preset lists but remain distinct: groups 1,
|
||||
> 2 and 3 share the EU 86x superset yet differ in `default_preset`, and group **5** (ham
|
||||
> 100 kHz) shares the `NARROW_*` presets with group 3 but differs in `licensed_only`.
|
||||
> Decoders must key on the group, not on the preset list, to preserve `default_preset` and
|
||||
> the licensing flag.
|
||||
>
|
||||
> Regions **absent** from the table (no constraint info; see §5.1): `EU_874`, `EU_917`,
|
||||
> `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`.
|
||||
|
||||
This table is generated from the firmware's region table at runtime; treat the firmware as
|
||||
authoritative and these values as the expected snapshot for the 2.8 table.
|
||||
@@ -1,454 +0,0 @@
|
||||
# Mesh Beacon Module - Function, Settings, and Client Interface Spec
|
||||
|
||||
Status: draft, tracks firmware branch `feat/mesh-beacon`.
|
||||
Audience: firmware reviewers (Part 1) and client-app developers - Android / Apple / Web / Python (Part 2).
|
||||
|
||||
The Mesh Beacon module lets a node periodically **advertise the existence of a mesh** to
|
||||
nodes that are not yet on it - broadcasting a short human-readable message plus an optional
|
||||
"join offer" (a channel, region, and modem preset). It is the mechanism behind invitations
|
||||
like _"Join us on NarrowSlow"_: a node sitting on one preset/region can shout an invitation
|
||||
that listeners on other presets/regions can hear and surface to their user.
|
||||
|
||||
The module is deliberately **advisory**. The firmware never auto-joins an advertised
|
||||
channel or auto-switches preset/region in response to a received beacon - it delivers the
|
||||
information to the client app and stops there. All "should I act on this?" decisions belong
|
||||
to the client and, ultimately, the user.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 - Function and settings choices
|
||||
|
||||
### 1.1 Two roles in one module
|
||||
|
||||
| Role | Class | Active when | What it does |
|
||||
| --------------- | --------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Broadcaster** | `MeshBeaconBroadcastModule` | `FLAG_BROADCAST_ENABLED` set | Periodically transmits `MESH_BEACON_APP` packets on the configured radio settings. |
|
||||
| **Listener** | `MeshBeaconListenerModule` | `FLAG_LISTEN_ENABLED` set | Receives `MESH_BEACON_APP` packets and caches the offer for the client (the packet itself flows to the client unchanged). |
|
||||
|
||||
The boolean toggles live in a single `flags` bitfield (see [§1.8](#18-settings-reference-moduleconfigmeshbeaconconfig-tag-17)) - broadcasting and
|
||||
listening can be enabled independently on the same node. The whole module compiles out under the
|
||||
`MESHTASTIC_EXCLUDE_BEACON` build flag.
|
||||
|
||||
### 1.2 Wire message
|
||||
|
||||
Beacons travel on a dedicated port number:
|
||||
|
||||
```protobuf
|
||||
MESH_BEACON_APP = 37 // meshtastic/portnums.proto
|
||||
ENCODING: protobuf (meshtastic.MeshBeacon)
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message MeshBeacon {
|
||||
string message = 1; // human-readable text, max 100 bytes (buffer 101)
|
||||
ChannelSettings offer_channel = 2; // optional advertised channel (name + PSK + slot)
|
||||
Config.LoRaConfig.RegionCode offer_region = 3; // optional advertised region (UNSET = none)
|
||||
optional Config.LoRaConfig.ModemPreset offer_preset = 4; // optional advertised preset
|
||||
}
|
||||
```
|
||||
|
||||
`.options` size caps (enforced at generation and on send):
|
||||
`message ≤ 100`, `offer_channel.name ≤ 12`, `offer_channel.psk ≤ 32`.
|
||||
|
||||
The three `offer_*` fields together describe _"there is a reachable mesh on this
|
||||
region+preset, here is the channel to use."_ Any subset may be present; an empty message with
|
||||
a populated offer (or vice-versa) is valid.
|
||||
|
||||
### 1.3 Transmission behaviour
|
||||
|
||||
Every outgoing beacon packet is stamped uniformly (`sendBeacon` → `stampPacket`):
|
||||
|
||||
- `to = NODENUM_BROADCAST`
|
||||
- `from = local node` (see [§1.6](#16-broadcast_send_as_node-currently-disabled) for the disabled spoof path)
|
||||
- **`hop_limit = 0`** - beacons are **zero-hop**. They are never rebroadcast by the mesh; only
|
||||
direct RF neighbours hear them. This is the primary spam-control mechanism. (`hop_start` is
|
||||
normally `0` too, but `FLAG_LEGACY_SPLIT` raises it to `1` for old-firmware compatibility - see
|
||||
[§1.5](#15-legacy-split-flag_legacy_split).)
|
||||
- `priority = BACKGROUND`, `want_ack = false`.
|
||||
|
||||
Broadcasting is additionally gated at runtime by:
|
||||
|
||||
- airtime utilisation (`isTxAllowedAirUtil()`), and
|
||||
- device role - **`CLIENT_HIDDEN` never broadcasts**.
|
||||
|
||||
#### Interval
|
||||
|
||||
`broadcast_interval_secs` controls cadence. The floor is **3600 s (1 hour)**
|
||||
(`default_mesh_beacon_min_broadcast_interval_secs`); `0` means "use default". Values below the
|
||||
floor are silently raised, both at config-set time (AdminModule) and at runtime.
|
||||
|
||||
The cadence is **reboot-safe**. Each broadcast's time is persisted to flash via `TransmitHistory`
|
||||
(keyed by `MESH_BEACON_APP`), and the broadcaster reads it back on boot - so a node that reboots
|
||||
(or crash-loops) won't re-broadcast until a full interval has elapsed since its last real send,
|
||||
rather than firing ~30 s after every boot. The timestamp is written **before** the transmit, so a
|
||||
brown-out during the high-current LoRa TX still counts as "sent." This mirrors `NodeInfoModule` /
|
||||
`PositionModule`.
|
||||
|
||||
#### Radio switching for TX
|
||||
|
||||
A beacon's whole point is often to reach a mesh on a _different_ preset/region/channel than the
|
||||
broadcaster currently runs. Before transmitting a beacon tagged with target radio settings, the
|
||||
module temporarily reconfigures the radio (`reconfigureForBeaconTX`), sends, then restores the
|
||||
prior config. Per-packet target settings are held in an 8-entry **sidecar table** keyed by packet
|
||||
ID - chosen so the `MeshPacket` proto carries no extra per-packet radio fields, and normal
|
||||
(non-beacon) traffic is never touched.
|
||||
|
||||
Two safety guards run before any radio switch (`beaconTxConfigInvalid`):
|
||||
|
||||
1. **An unlicensed node never keys up on a licensed-only (ham) region.** (The reverse - a licensed
|
||||
node operating in a non-ham region - is allowed. The switch only touches preset/region/channel,
|
||||
never `owner.is_licensed`.)
|
||||
2. **The preset must be valid for the target region** (`validateConfigLora`).
|
||||
|
||||
If either fails, the radio is **not** switched and the radio driver **drops** the packet rather
|
||||
than letting it fall through onto the current config.
|
||||
|
||||
#### Channel encryption on an override channel
|
||||
|
||||
Encryption keys off the **primary** channel slot, and the radio-thread channel switch happens
|
||||
_after_ encryption. So when a beacon goes out on an override channel (different name/PSK), the
|
||||
module installs the beacon channel into the primary slot for the synchronous duration of
|
||||
`send()`, then restores it (`sendBeaconPacket`). This guarantees the packet is encrypted with the
|
||||
beacon channel's key and stamped with its hash - not the primary's. Meshtastic threading is
|
||||
cooperative, so there is no preemption between swap and restore.
|
||||
|
||||
### 1.4 Where beacons are sent: single-target and multi-target
|
||||
|
||||
The broadcaster can send to one set of radio settings or to several. **Single- and multi-target
|
||||
are equal options - neither is preferred and neither is legacy.** Pick whichever matches the
|
||||
deployment.
|
||||
|
||||
- **Single-target:** the scalar `broadcast_on_preset` / `broadcast_on_region` /
|
||||
`broadcast_on_channel` fields describe one destination. Used when `broadcast_targets` is empty.
|
||||
- **Multi-target:** `broadcast_targets` (repeated `BroadcastTarget`) describes several. When
|
||||
non-empty it takes over from the scalar `broadcast_on_*` fields, and the broadcaster sends **one
|
||||
beacon copy per entry**. Each `BroadcastTarget` is `{ optional preset, region, optional channel_index }`,
|
||||
where `channel_index` references a slot in the node's own channel table (the channel must already be
|
||||
configured locally - its key is needed to encrypt the beacon). Within one cycle, targets that
|
||||
resolve to the **same** effective preset/region/channel are de-duplicated - only the first is
|
||||
transmitted - so an accidentally repeated entry costs no extra airtime.
|
||||
|
||||
#### Same-settings vs. other-settings
|
||||
|
||||
Independent of single/multi, each destination can either reuse the node's **own current radio
|
||||
settings** or specify **different** ones:
|
||||
|
||||
- **Same-settings ("message of the day"):** leave the preset / region / channel unset. They fall
|
||||
back to the running config, so the beacon goes out on the node's current mesh with **no radio
|
||||
switch** - a plain periodic broadcast to whoever is already on this preset/region.
|
||||
- **Other-settings (cross-mesh invite):** set a preset / region / channel that differs from the
|
||||
running config. The radio is temporarily switched for that copy's TX, then restored (see
|
||||
[§1.3](#radio-switching-for-tx)).
|
||||
|
||||
Both modes support both styles: a single-target beacon with no `broadcast_on_*` overrides is a
|
||||
message-of-the-day on the current mesh; a multi-target list can mix one entry on the current
|
||||
settings with others on different presets/regions.
|
||||
|
||||
### 1.5 Legacy split (`FLAG_LEGACY_SPLIT`)
|
||||
|
||||
This one flag controls **two** independent legacy-compatibility behaviours. Both are about making
|
||||
beacons usable by firmware that predates this module.
|
||||
|
||||
**(a) Text/offer packet split.** A combined `MESH_BEACON_APP` packet carries both the text and the
|
||||
offer, but old firmware only decodes `TEXT_MESSAGE_APP` and would never show the text. When
|
||||
`FLAG_LEGACY_SPLIT` is set **and both text and offer content are present**, the broadcaster
|
||||
emits **two** packets on the same beacon radio settings instead of one:
|
||||
|
||||
- **Packet A** - `MESH_BEACON_APP` carrying the **offer only** (no text).
|
||||
- **Packet B** - `TEXT_MESSAGE_APP` carrying the **text only**.
|
||||
|
||||
This is an independent two-packet decision, not an either/or: offer-only and text-only payloads
|
||||
still go out as a single packet in their respective cases; only the both-present case splits.
|
||||
|
||||
**(b) `hop_start = 1` override.** When `FLAG_LEGACY_SPLIT` is set, **every** beacon packet it sends
|
||||
(combined, split-A, or split-B; even same-settings ones) is stamped with `hop_start = 1` while
|
||||
`hop_limit` stays `0`. Pre-2.7.20 firmware drops `hop_start == 0` packets in a pre-decryption check
|
||||
before it can read the bitfield, so `hop_start = 1` lets those nodes accept the beacon - and it
|
||||
remains genuinely zero-hop (`hop_limit = 0` still prevents any rebroadcast).
|
||||
|
||||
> **Side effect for clients:** with `hop_start = 1, hop_limit = 0`, receivers compute
|
||||
> `hops_away = hop_start − hop_limit = 1`, so a legacy-split beacon reads as **1 hop away** even
|
||||
> though it arrived over direct RF. Without legacy-split it reads as direct (0). Don't treat a
|
||||
> beacon's `hops_away` as a reliable distance signal.
|
||||
|
||||
### 1.6 `broadcast_send_as_node` (currently disabled)
|
||||
|
||||
The schema reserves `broadcast_send_as_node` (field 3) to send beacons _as_ another node ID. **The
|
||||
firmware application of this field is currently commented out pending review**, so beacons always
|
||||
go out as the local node today. The access-control rule is, however, already enforced in
|
||||
AdminModule and should be treated as canonical:
|
||||
|
||||
> A remote admin may only set `broadcast_send_as_node` to **their own** node ID
|
||||
> (`mp.from`). Any other value is rejected and reset to the stored value.
|
||||
|
||||
Design note for when it is re-enabled: it is a _node-ID_ spoof only - it rewrites `from` but forges
|
||||
no signature. Once `from` is not us, the packet is no longer `isFromUs()`, so the router skips
|
||||
XEdDSA signing and receivers get an unsigned packet attributed to another node.
|
||||
|
||||
### 1.7 Reception behaviour (listener)
|
||||
|
||||
When `FLAG_LISTEN_ENABLED` is **off**, the router drops incoming `MESH_BEACON_APP` packets up front
|
||||
(`Router::handleReceived`, same pattern as a disabled NeighborInfo module) - so they reach neither
|
||||
the modules nor the phone. When it is **on**, the packet flows normally and the listener's
|
||||
`wantPacket` accepts it (`has_mesh_beacon` + `FLAG_LISTEN_ENABLED` + `portnum == MESH_BEACON_APP`).
|
||||
On a valid beacon (`handleReceivedProtobuf`):
|
||||
|
||||
1. **Offer → cache.** Any offer (`offer_channel` / `offer_region` / `offer_preset`) is stored in
|
||||
the static `lastReceivedOffer` (sender, channel, region, preset, `received_at`). `received_at`
|
||||
is `0` if the node has no RTC fix yet - **consumers must not treat `0` as a valid timestamp.**
|
||||
2. **Never auto-applied.** The firmware does not switch channel/preset/region from a received
|
||||
offer. Acting on it is the client app's job.
|
||||
3. The handler returns `CONTINUE` (not `STOP`), so the original `MESH_BEACON_APP` packet **flows to
|
||||
the client unchanged** through the normal FromRadio path (see Part 2). The client reads the
|
||||
`message` field directly from that packet - there is no separate copy.
|
||||
|
||||
The firmware deliberately does **not** unwrap a combined beacon's text into a synthesized
|
||||
`TEXT_MESSAGE_APP`, and does **not** fire `EVENT_RECEIVED_MSG`: a beacon is an advisory broadcast,
|
||||
not a personal message, so it must not duplicate the text or wake the device from sleep. If a
|
||||
broadcaster needs non-beacon-aware clients to see the text, it uses `FLAG_LEGACY_SPLIT`, which sends
|
||||
a real `TEXT_MESSAGE_APP` over RF (see [§1.5](#15-legacy-split-flag_legacy_split)).
|
||||
|
||||
### 1.8 Settings reference (`ModuleConfig.MeshBeaconConfig`, tag 17)
|
||||
|
||||
| # | Field | Type | Meaning / constraints |
|
||||
| --- | ------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------ |
|
||||
| 1 | `flags` | uint32 (bitfield) | Bitwise-OR of `Flags` values (listen / broadcast / legacy-split toggles). See enum below. |
|
||||
| 3 | `broadcast_send_as_node` | uint32 | Send-as node ID. **Application disabled in firmware.** Remote admin may only set to own node ID. |
|
||||
| 4 | `broadcast_message` | string | Text in each broadcast. **Hard-capped at 100 bytes.** |
|
||||
| 5 | `broadcast_offer_channel` | ChannelSettings | Channel advertised in `offer_channel`. |
|
||||
| 6 | `broadcast_offer_region` | RegionCode | Region advertised in `offer_region`. Must be a known region or it is cleared. |
|
||||
| 7 | `broadcast_offer_preset` | optional ModemPreset | Preset advertised in `offer_preset`. Validated against offer region (else cleared). |
|
||||
| 8 | `broadcast_on_channel` | ChannelSettings | Channel to transmit on (single-target). Empty name → preset display name. |
|
||||
| 9 | `broadcast_on_region` | RegionCode | Region to transmit on (single-target). |
|
||||
| 10 | `broadcast_on_preset` | optional ModemPreset | Preset to transmit on (single-target). Validated against on-region (else this + `on_channel` cleared). |
|
||||
| 11 | `broadcast_interval_secs` | uint32 | Cadence. **Min 3600**, default 3600; `0` = default. |
|
||||
| 13 | `broadcast_targets` | repeated BroadcastTarget | Multi-target list; when non-empty overrides the single-target `broadcast_on_*` fields. |
|
||||
|
||||
> The three boolean toggles were folded into the `flags` bitfield; field tags 2 and 12 are now
|
||||
> unused (the branch is unreleased, so the old tags are left as gaps rather than reserved).
|
||||
|
||||
**`Flags` enum** (nested in `MeshBeaconConfig`; OR the values into `flags`):
|
||||
|
||||
| Bit value | Name | Meaning |
|
||||
| --------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 0 | `FLAG_NONE` | No options enabled. |
|
||||
| 1 | `FLAG_LISTEN_ENABLED` | Receive beacons; cache the offer. The packet flows to the client, which reads `message` directly. |
|
||||
| 2 | `FLAG_BROADCAST_ENABLED` | Periodically broadcast beacons from this node. |
|
||||
| 4 | `FLAG_LEGACY_SPLIT` | Legacy compatibility: (a) split text+offer into separate `TEXT_MESSAGE_APP` + `MESH_BEACON_APP` packets, and (b) stamp `hop_start = 1` on every beacon so pre-2.7.20 firmware accepts it (see [§1.5](#15-legacy-split-flag_legacy_split)). |
|
||||
|
||||
`BroadcastTarget`: `1 preset` (optional, falls back to running config), `2 region` (`UNSET` = running config), `4 channel_index` (optional `uint32`, index into the node's channel table; if unset, the default channel for the preset is used). Tag `3` is an unused gap - it previously held an embedded `ChannelSettings`, dropped to keep `ModuleConfig` within the BLE `FromRadio` size budget.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 - Client interface specification
|
||||
|
||||
This section is what a client app needs to integrate with the beacon module. Everything goes
|
||||
through the **standard admin / ToRadio / FromRadio protocol** - there is no bespoke transport.
|
||||
|
||||
### 2.1 Capability detection
|
||||
|
||||
The module is build-flag optional. Treat it as present when the node's `LocalModuleConfig`
|
||||
contains a `mesh_beacon` sub-message (`LocalModuleConfig.mesh_beacon`, tag 18). If absent, the
|
||||
firmware was built with `MESHTASTIC_EXCLUDE_BEACON` - hide the beacon UI.
|
||||
|
||||
### 2.2 Reading and writing configuration
|
||||
|
||||
Standard module-config flow - no new admin messages:
|
||||
|
||||
- **Read:** `AdminMessage.get_module_config_request = ModuleConfig.MeshBeaconConfig` (variant 17).
|
||||
Reply is `get_module_config_response` with the `mesh_beacon` payload.
|
||||
- **Write:** `AdminMessage.set_module_config { mesh_beacon = … }`.
|
||||
|
||||
The on/off toggles (listen, broadcast, legacy-split) are bits in the `flags` field, not separate
|
||||
booleans - read/write them with the `MeshBeaconConfig.Flags` values
|
||||
(`FLAG_LISTEN_ENABLED = 1`, `FLAG_BROADCAST_ENABLED = 2`, `FLAG_LEGACY_SPLIT = 4`). To toggle one
|
||||
bit, read the current `flags`, set/clear the bit, and write the whole config back.
|
||||
|
||||
The firmware **sanitises on write** - your value may be silently adjusted. Mirror these rules
|
||||
client-side so the UI doesn't disagree with the device:
|
||||
|
||||
| Rule | Firmware behaviour |
|
||||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| `broadcast_message` length | Truncated to 100 bytes. |
|
||||
| `broadcast_interval_secs` | If non-zero and `< 3600`, raised to 3600. |
|
||||
| `broadcast_on_preset` invalid for `broadcast_on_region` (or current region) | Cleared, **and `broadcast_on_channel` cleared too.** |
|
||||
| `broadcast_offer_preset` invalid for offer/current region | Cleared. |
|
||||
| `broadcast_offer_region` not a known region | Cleared to `UNSET`. |
|
||||
| `broadcast_targets[i].region` not a known region | That entry's region cleared to `UNSET` (TX falls back to running config). |
|
||||
| `broadcast_targets[i].preset` invalid for that entry's region | That entry's `preset` and `channel_index` cleared. |
|
||||
| `broadcast_targets[i].channel_index` ≥ `MAX_NUM_CHANNELS` (8) | That entry's `channel_index` cleared (existence is **not** checked - see §2.5). |
|
||||
| `broadcast_send_as_node` ≠ sender's node ID (remote admin) | Rejected, reset to stored value. |
|
||||
|
||||
Setting beacon config does **not** trigger a reboot (`shouldReboot = false`); changes take effect
|
||||
on the next broadcast cycle. After a successful write, **re-read** the config to display the
|
||||
effective (sanitised) values.
|
||||
|
||||
### 2.3 Receiving beacons
|
||||
|
||||
A received beacon reaches the client as a normal `FromRadio.packet` (`MeshPacket`) - the listener
|
||||
returns `CONTINUE`, so the packet is **not** consumed on-device. The client must:
|
||||
|
||||
1. Subscribe to the FromRadio packet stream as usual.
|
||||
2. For packets with `decoded.portnum == MESH_BEACON_APP (37)`, decode `decoded.payload` as a
|
||||
`meshtastic.MeshBeacon`.
|
||||
3. Read `message`, `offer_channel`, `offer_region`, `offer_preset` (presence-checked).
|
||||
4. `packet.from` is the **originating beaconer** (the firmware preserves it).
|
||||
|
||||
> **Requires `FLAG_LISTEN_ENABLED` set in `flags`.** With listening disabled the firmware drops
|
||||
> received `MESH_BEACON_APP` packets in the router - before they reach the phone or any on-device
|
||||
> handler - the same way it drops a disabled module's packets (e.g. NeighborInfo). The node still
|
||||
> physically receives the RF, but the client will not see beacons over the FromRadio stream until
|
||||
> listening is enabled.
|
||||
|
||||
#### Reading the text - no duplication
|
||||
|
||||
For a beacon-aware client the text is **simply the `message` field of the `MESH_BEACON_APP`
|
||||
packet** you already decode for the offer (step 3 above). One packet, one field - the firmware does
|
||||
**not** inject a separate `TEXT_MESSAGE_APP` copy, so there is nothing to deduplicate.
|
||||
|
||||
The only time a beacon's text arrives as a separate `TEXT_MESSAGE_APP` is when the broadcaster set
|
||||
`FLAG_LEGACY_SPLIT`: in that mode the `MESH_BEACON_APP` carries the **offer only** (empty `message`)
|
||||
and the text is sent as a normal `TEXT_MESSAGE_APP` over RF, so legacy/non-beacon-aware clients can
|
||||
display it. These two cases are mutually exclusive - a given beacon's text appears exactly once,
|
||||
either in `MESH_BEACON_APP.message` (combined) or as a `TEXT_MESSAGE_APP` (legacy-split) - so a
|
||||
client never needs to dedup. Render whichever it receives.
|
||||
|
||||
### 2.4 Acting on an offer (the core client responsibility)
|
||||
|
||||
When a `MESH_BEACON_APP` carries offer content, present it to the user as an **invitation** -
|
||||
e.g. _"Node ⟨from⟩ invites you to join '⟨offer_channel.name⟩' on ⟨preset⟩/⟨region⟩."_ Then, only on
|
||||
explicit user confirmation, apply it by writing normal config:
|
||||
|
||||
- `offer_channel` → add/replace a `Channel` (`set_channel`), typically as a secondary channel.
|
||||
- `offer_region` / `offer_preset` → `set_config { lora = … }` (`use_preset = true`, set
|
||||
`modem_preset` and `region`). **Note this changes the node's own radio and will drop it off its
|
||||
current mesh** - make that consequence explicit in the UI.
|
||||
|
||||
**The firmware will never do any of this for the user. No silent auto-apply.** The on-device
|
||||
`lastReceivedOffer` cache is a firmware-internal convenience and is **not** currently exposed via
|
||||
an admin message - clients should source offers from the live `MESH_BEACON_APP` packet stream
|
||||
(§2.3), not expect a "get last offer" RPC.
|
||||
|
||||
#### Offer trust model - read before applying
|
||||
|
||||
- **The advertised PSK is not a secret.** `offer_channel.psk` is a public join token sent in the
|
||||
clear inside a broadcast; it is a convenience, not a security boundary. An operator who wants a
|
||||
genuinely private channel must distribute the PSK out-of-band and leave `offer_channel` unset.
|
||||
Surface offered channels as **public/open** to the user.
|
||||
- **Validate before applying.** Reject or warn if `offer_preset` is not valid for `offer_region`,
|
||||
and **never** apply a licensed-only (ham) region for a user who is not a licensed operator -
|
||||
mirror the firmware's own guard.
|
||||
- Beacons are **unsigned** when sent as another node (the disabled send-as path), and even normal
|
||||
beacons assert nothing about the sender's authority. Treat `from` as informational.
|
||||
|
||||
### 2.5 Configuring this node as a broadcaster
|
||||
|
||||
To make a node advertise a mesh, write `MeshBeaconConfig` with `FLAG_BROADCAST_ENABLED` set in
|
||||
`flags` and at least one of: a non-empty `broadcast_message`, or offer content
|
||||
(`broadcast_offer_*`). With neither, the broadcaster has nothing to send and stays silent.
|
||||
|
||||
Typical multi-region invite beacon:
|
||||
|
||||
```text
|
||||
flags = FLAG_BROADCAST_ENABLED | FLAG_LEGACY_SPLIT // broadcast on; split so legacy nodes still see the text
|
||||
broadcast_message = "Join us on NarrowSlow!"
|
||||
broadcast_offer_preset = NARROW_SLOW
|
||||
broadcast_offer_region = EU_N_868
|
||||
broadcast_offer_channel = { name: "MyChannel", psk: <32-byte key> }
|
||||
broadcast_interval_secs = 3600
|
||||
// channel_index points at slots in THIS node's channel table - configure those channels first.
|
||||
broadcast_targets = [
|
||||
{ preset: LONG_FAST, region: EU_868, channel_index: 0 },
|
||||
{ preset: NARROW_SLOW, region: EU_N_868, channel_index: 1 },
|
||||
]
|
||||
```
|
||||
|
||||
The same fields can be baked in at build time via `userPrefs.jsonc`
|
||||
(`USERPREFS_MESH_BEACON_*`) - see that file for the full list, including
|
||||
`USERPREFS_MESH_BEACON_TARGET_<n>_*` for multi-target entries.
|
||||
|
||||
#### Single-target vs. multi-target - equal options, different channel representation
|
||||
|
||||
Single-target and multi-target are **equal, first-class options**. Neither is preferred,
|
||||
deprecated, or a "legacy" fallback - pick whichever matches the deployment (a single-target
|
||||
beacon with no overrides is a plain message-of-the-day; a multi-target list reaches several
|
||||
preset/region/channel combinations). The broadcaster uses `broadcast_targets` when it is
|
||||
non-empty and the scalar `broadcast_on_*` fields when it is empty.
|
||||
|
||||
The one **subtle implementation difference** is how each names its TX channel:
|
||||
|
||||
| Path | TX channel is specified by | Channel name/PSK live… |
|
||||
| ------------- | ------------------------------------------------------- | ----------------------------------------- |
|
||||
| Single-target | `broadcast_on_channel` - an embedded `ChannelSettings` | …inline in the beacon config |
|
||||
| Multi-target | `broadcast_targets[i].channel_index` - a `uint32` index | …in the node's channel table (referenced) |
|
||||
|
||||
This asymmetry is deliberate: embedding a full `ChannelSettings` in every one of the (up to
|
||||
four) targets would push `ModuleConfig` past the BLE `FromRadio` size limit, so a target
|
||||
references an already-configured channel-table slot instead. `broadcast_offer_channel` (the
|
||||
advertised join token) is **always** inline regardless of path - it is the advertisement payload
|
||||
and must carry the actual name/PSK.
|
||||
|
||||
#### Configuring a multi-target broadcaster (two-step)
|
||||
|
||||
Because a target's channel is a reference, configuring a multi-target broadcaster takes **two
|
||||
admin writes**, in order:
|
||||
|
||||
1. **Create/define each channel in the node's channel table** with the normal channel admin flow
|
||||
(the same `set_channel` your app already uses for adding channels):
|
||||
|
||||
```text
|
||||
AdminMessage.set_channel { index: 1, role: SECONDARY,
|
||||
settings: { name: "NarrowSlow", psk: <key>, channel_num: 0 } }
|
||||
```
|
||||
|
||||
2. **Write the beacon config**, pointing each target at the slot index from step 1:
|
||||
|
||||
```text
|
||||
AdminMessage.set_module_config { mesh_beacon: {
|
||||
flags = FLAG_BROADCAST_ENABLED
|
||||
broadcast_targets = [ { preset: NARROW_SLOW, region: EU_N_868, channel_index: 1 } ]
|
||||
} }
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- A target may **only** reference a channel that already exists locally - the node needs that
|
||||
channel's key to encrypt the beacon. A `channel_index` that is out of range, or points at a
|
||||
blank/unconfigured slot, is not an error: the beacon falls back to the node's **current/primary
|
||||
channel** (its name, PSK, and slot) on the target preset/region. The channel name only defaults
|
||||
to the preset's display name (e.g. `LongFast`) when the primary channel itself is unnamed - so
|
||||
the fallback is "broadcast on my home channel," **not** a freshly-synthesised default-PSK channel
|
||||
for that preset.
|
||||
- `channel_index` must be `< MAX_NUM_CHANNELS` (8); the firmware clears it on write otherwise (see
|
||||
§2.2 sanitise rules). This is the **only** check on write - the firmware does **not** verify that
|
||||
the referenced slot is actually populated, because you may legitimately write the beacon config
|
||||
before creating the channel. **Validating that a referenced channel exists is the client app's
|
||||
responsibility.** A dangling reference doesn't error; it silently falls back to the preset's
|
||||
default channel - so without a client-side check, the user can believe they're advertising
|
||||
channel _X_ while the node is really transmitting on the preset default. Before writing, confirm
|
||||
each `channel_index` maps to a configured `Channel`, and warn the user otherwise.
|
||||
- **No automatic deduplication of channels.** Neither the beacon config nor the channel table
|
||||
dedups by content: two `broadcast_targets` may carry the same `channel_index`, or different
|
||||
indices whose slots hold identical settings, and `set_channel` will happily store two slots with
|
||||
the same name/PSK. The broadcaster _does_ skip transmitting a target whose effective
|
||||
preset/region/channel duplicates an earlier one in the same cycle (so a duplicated entry wastes
|
||||
no airtime), but it does not rewrite or reject your config - keeping the target list free of
|
||||
redundant entries is up to the client.
|
||||
- The single-target path needs no separate `set_channel` step - its `broadcast_on_channel` is
|
||||
written inline in the same beacon-config message.
|
||||
|
||||
### 2.6 Quick reference
|
||||
|
||||
| Concern | Value |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Port number | `MESH_BEACON_APP = 37` |
|
||||
| Wire message | `meshtastic.MeshBeacon` |
|
||||
| Config message | `ModuleConfig.MeshBeaconConfig` (variant tag 17) |
|
||||
| On/off toggles | `flags` bitfield (`MeshBeaconConfig.Flags`) |
|
||||
| Local config presence | `LocalModuleConfig.mesh_beacon` (tag 18) |
|
||||
| Min broadcast interval | 3600 s (1 h) |
|
||||
| Message max length | 100 bytes |
|
||||
| Hop behaviour | Zero-hop (`hop_limit = 0`), never rebroadcast; `hop_start = 1` under `FLAG_LEGACY_SPLIT` |
|
||||
| Auto-apply offers? | **Never** - client + user decide |
|
||||
| Offer PSK | Public join token, not a secret |
|
||||
| Disabled today | `broadcast_send_as_node` application |
|
||||
@@ -1,456 +0,0 @@
|
||||
# NextHop direct-message reliability on dense meshes - findings & plan
|
||||
|
||||
**Status:** Implemented - mitigations and tests in `PR3-tmm-nexthop`
|
||||
**Date:** 2026-06-13
|
||||
**Area:** `src/mesh` router stack (`NextHopRouter`, `ReliableRouter`, `FloodingRouter`, `Router`, `NodeDB`, `PacketHistory`)
|
||||
**Constraint:** No over-the-air / wire-format changes - `next_hop` and `relay_node` stay 1 byte, no `PacketHeader` changes, no breaking protobuf changes. All new state is RAM-only.
|
||||
|
||||
This document captures the analysis and the proposed mitigations so the work can be
|
||||
continued on this branch by anyone. It is intentionally code-grounded (file:line
|
||||
references throughout) and standalone - you should not need the original investigation
|
||||
context to pick it up.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
NextHop routing for direct messages (DMs) is unreliable on dense meshes. The headline
|
||||
cause is the **birthday problem**: `next_hop` and `relay_node` are each a single byte
|
||||
(the last byte of a 32-bit node number), so on a mesh of N nodes the probability that
|
||||
two share the same byte hits ~50% at **~19 nodes** and is near-certain by 50-100. But
|
||||
there are **other, equally important issues**: that single byte is trusted blindly at
|
||||
five different code sites, learned routes **never decay**, routes are learned from the
|
||||
**reverse (ACK) path** (asymmetric-link hazard), and collision-driven spurious
|
||||
rebroadcasts **amplify congestion** exactly when the mesh is busy.
|
||||
|
||||
Because we can't widen the on-wire field, the fix is **interpretation-side** ("don't
|
||||
trust a byte that doesn't map to a unique reachable neighbor - flood instead") plus
|
||||
**recovery-side** ("decay stale/failing routes so they get re-discovered"). Four
|
||||
mitigations, M1-M4, all RAM-only. The net behavioral change: on dense/mobile meshes a
|
||||
DM that today silently misroutes or black-holes instead falls back to managed flooding
|
||||
(which still delivers) and re-learns a fresh route quickly. Sparse-mesh happy paths are
|
||||
unchanged.
|
||||
|
||||
---
|
||||
|
||||
## How NextHop routing works today (mechanics)
|
||||
|
||||
Inheritance chain: `Router` → `FloodingRouter` → `NextHopRouter` → `ReliableRouter`.
|
||||
|
||||
**The single-byte identifiers.** Both routing bytes come from one helper:
|
||||
|
||||
```cpp
|
||||
// src/mesh/NodeDB.h:255
|
||||
uint8_t getLastByteOfNodeNum(NodeNum num) { return (uint8_t)((num & 0xFF) ? (num & 0xFF) : 0xFF); }
|
||||
```
|
||||
|
||||
It projects a 32-bit node number onto 255 values (`0x00` is remapped to `0xFF` so it
|
||||
never collides with the `0`-valued sentinels `NO_NEXT_HOP_PREFERENCE` / `NO_RELAY_NODE`,
|
||||
`src/mesh/MeshTypes.h:44-46`). `next_hop` and `relay_node` in the packet header are
|
||||
`uint8_t` (`src/mesh/mesh.pb.h`, comments "Last byte of the node number…"). The learned
|
||||
route stored per destination, `meshtastic_NodeInfoLite::next_hop`, is also a single byte
|
||||
(`src/mesh/generated/meshtastic/deviceonly.pb.h:83`).
|
||||
|
||||
**Sending a DM** - `NextHopRouter::send` (`src/mesh/NextHopRouter.cpp:23`):
|
||||
|
||||
1. `p->relay_node = getLastByteOfNodeNum(getNodeNum())` (mark ourselves as relayer).
|
||||
2. `p->next_hop = getNextHop(p->to, p->relay_node)` (`src/mesh/NextHopRouter.cpp:192`):
|
||||
look up `nodeDB->getMeshNode(to)->next_hop`; return it unless it equals the relayer
|
||||
byte; otherwise `NO_NEXT_HOP_PREFERENCE` (→ flood).
|
||||
|
||||
**Relaying** - `NextHopRouter::perhapsRebroadcast` (`src/mesh/NextHopRouter.cpp:133`):
|
||||
rebroadcast iff `next_hop == NO_NEXT_HOP_PREFERENCE` (flood) **or**
|
||||
`next_hop == getLastByteOfNodeNum(getNodeNum())` (we are the addressed next hop)
|
||||
(`:147`). Each node only ever compares against **its own** byte.
|
||||
|
||||
**Learning** - `NextHopRouter::sniffReceived` (`src/mesh/NextHopRouter.cpp:89`): on an
|
||||
ACK/reply (`request_id`/`reply_id` set), if the relayer of the ACK was also a relayer of
|
||||
the original packet (validated via `PacketHistory::checkRelayers`), set
|
||||
`origTx->next_hop = p->relay_node` (`:114`). I.e. the **forward** next-hop is learned
|
||||
from the **reverse** path's relayer.
|
||||
|
||||
**Retransmission / fallback** - `NextHopRouter::doRetransmissions`
|
||||
(`src/mesh/NextHopRouter.cpp:284`). Budgets: `NUM_RELIABLE_RETX=3` (originator: initial
|
||||
|
||||
- 2 retries), `NUM_INTERMEDIATE_RETX=2` (relayer: 1 retry). On the **last** retry
|
||||
(`numRetransmissions==1`) it resets `next_hop` to `NO_NEXT_HOP_PREFERENCE` on the packet
|
||||
**and** clears `sentTo->next_hop` in NodeDB, then floods (`:313-321`). Retransmit timing
|
||||
comes from `iface->getRetransmissionMsec`, whose contention window **grows with channel
|
||||
utilization** (`src/mesh/RadioInterface.cpp` `getTxDelayMsec`/`getTxDelayMsecWeighted`).
|
||||
|
||||
**Dedup / relayer history** - `PacketHistory` (`src/mesh/PacketHistory.cpp`): a bounded
|
||||
ring (`PACKETHISTORY_MAX = max(MAX_NUM_NODES*2, 100)`, 20 B/record) keyed by
|
||||
`(sender,id)`, tracking up to `NUM_RELAYERS=6` relayer **bytes** per packet in
|
||||
`relayed_by[]`. `wasRelayer` (`:490`) and `checkRelayers` (`:517`) match bytes against
|
||||
that array.
|
||||
|
||||
---
|
||||
|
||||
## Root-cause analysis
|
||||
|
||||
### 1. The single byte is trusted blindly at five sites (the birthday problem)
|
||||
|
||||
| # | Site | File:line | Failure on collision |
|
||||
| --- | -------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Rebroadcast self-check | `NextHopRouter.cpp:147` | A remote "impostor" node sharing the intended next-hop's byte also rebroadcasts → wasted airtime / congestion. |
|
||||
| 2 | Route learning | `NextHopRouter.cpp:111-114` | Stores an ambiguous byte as the route; later resolves to the wrong physical node. |
|
||||
| 3 | Relayer validation | `PacketHistory.cpp:490-538` | `wasRelayer(byte)` returns true for the wrong node → mis-validated ACK / mis-learn. |
|
||||
| 4 | Favorite-router hop preservation | `Router.cpp:120-145` | **First** NodeDB node whose last byte matches wins - non-deterministic; can preserve hops for the wrong relay (hop leak). |
|
||||
| 5 | Send-path lookup | `NextHopRouter.cpp:192-207` | Emits a byte that may address the wrong node; no check it still maps to a reachable neighbor. |
|
||||
|
||||
Collision math (uniform last byte over 255 buckets): P(collision) ≈ 50% at ~19 nodes,
|
||||
|
||||
> 99% by ~75 nodes. Dense meshes are squarely in the "always colliding" regime.
|
||||
|
||||
### 2. Stale routes never decay
|
||||
|
||||
The learned `next_hop` byte is cleared only on the **current DM's** last retry
|
||||
(`NextHopRouter.cpp:313-321`). A route learned hours ago that has since gone dead is
|
||||
still trusted on the **next** DM's first attempt - which on a congested mesh is also the
|
||||
slowest attempt. Result: silent black-hole at a dead hop until the retransmission budget
|
||||
drains, then a late flood. Intermediate nodes hold stale routes indefinitely.
|
||||
|
||||
### 3. Reverse-path (asymmetric-link) learning
|
||||
|
||||
`origTx->next_hop` is learned from the ACK's relayer (`NextHopRouter.cpp:110-114`) - the
|
||||
**reverse** direction. RF links are frequently asymmetric, so the best reverse relay can
|
||||
be a poor forward relay. Worse, the next reverse ACK immediately re-learns the same bad
|
||||
hop, so the route **flaps** back to the bad value even after a failure reset.
|
||||
|
||||
### 4. Congestion amplification
|
||||
|
||||
Collision-driven impostor rebroadcasts (issue 1) add airtime; the contention window
|
||||
grows with channel utilization, so retransmit intervals **lengthen** exactly when the
|
||||
mesh is busy. The 3-try reliable budget can then expire before delivery. On dense
|
||||
meshes, efficiency _is_ reliability.
|
||||
|
||||
### Note: pubkey-derived node numbers (develop / 2.8) - does not change the plan
|
||||
|
||||
develop derives the node number from the public key:
|
||||
`my_node_num = crc32Buffer(public_key)` (`src/mesh/NodeDB.cpp:481`), re-derived on key
|
||||
change in `createNewIdentity()` (`src/mesh/NodeDB.cpp:3113`). This **reinforces** the
|
||||
plan rather than changing it:
|
||||
|
||||
- **Birthday problem unchanged and now textbook-exact.** CRC32 mixes well → the last
|
||||
byte is uniformly distributed over 256 values. Derivation adds no wire bits.
|
||||
- **Node numbers are now immutable / identity-bound.** Pre-2.8 `pickNewNodeNum()` could
|
||||
renumber a node to dodge a conflict; now the number is fixed by the key, so a last-byte
|
||||
collision **cannot be resolved operationally by renumbering** → M1/M2/M3 become _more_
|
||||
necessary.
|
||||
- **Resolver gets cleaner inputs.** Stable node numbers keep a learned byte bound to one
|
||||
identity (good for M3 freshness). `createNewIdentity()` retires the old entry by marking
|
||||
it **ignored** and clearing its pubkey (`src/mesh/NodeDB.cpp:3123-3125`), which M1's
|
||||
candidate gate already skips - so key rotation can't pollute resolution.
|
||||
- **No wire-free disambiguation unlocked.** A receiver still gets only 1 byte and cannot
|
||||
recover which full node number a colliding value meant - so "detect ambiguity → flood"
|
||||
remains the correct strategy.
|
||||
|
||||
---
|
||||
|
||||
## Proposed mitigations
|
||||
|
||||
Key insight for all of M1/M2: **a 1-byte ID only needs to be unique among a node's
|
||||
direct neighbors / plausible relays, not the whole mesh.** That candidate set is small
|
||||
(typically 5-15), so a byte usually resolves unambiguously there; when it doesn't, fall
|
||||
back to the _safe_ behavior (flood / decrement / don't-learn).
|
||||
|
||||
### M1 - Ambiguity-aware last-byte resolution (new NodeDB primitive)
|
||||
|
||||
New types + methods in `src/mesh/NodeDB.h` (near line 255) / `src/mesh/NodeDB.cpp`
|
||||
(near `getMeshNode`, ~2936):
|
||||
|
||||
```cpp
|
||||
enum class LastByteResolution : uint8_t { None, Unique, Ambiguous };
|
||||
struct ResolvedNode { LastByteResolution status = LastByteResolution::None; NodeNum num = 0; };
|
||||
|
||||
// Resolve a single on-wire last-byte to a unique full NodeNum among relevant candidates.
|
||||
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
|
||||
// Convenience: true iff exactly one relevant candidate (Ambiguous and None both -> false = SAFE).
|
||||
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
|
||||
```
|
||||
|
||||
- **One linear pass** over `meshNodes`, reusing `getNumMeshNodes()`/`getMeshNodeByIndex()`,
|
||||
the bitfield helpers (`nodeInfoLiteIsFavorite/HasUser/IsIgnored`), `sinceLastSeen()`,
|
||||
and `getLastByteOfNodeNum()`. **Early-exit** on the 2nd match (return `Ambiguous`).
|
||||
- **Guard:** `if (lastByte == 0) return {None, 0};` (covers `NO_RELAY_NODE` / MQTT-invalid).
|
||||
- **Candidate gate** (skip): `num == getNodeNum()` (never resolve to ourselves), `num == 0`,
|
||||
`num == NODENUM_BROADCAST`, `nodeInfoLiteIsIgnored`. Then match
|
||||
`getLastByteOfNodeNum(node->num) == lastByte` (cheapest test last, mirroring `Router.cpp:119`).
|
||||
- **Relevance gate:**
|
||||
- `requireDirectNeighbor == true` (strict, for SEND): `has_hops_away && hops_away == 0`
|
||||
**and** `sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS`.
|
||||
- `requireDirectNeighbor == false` (lenient, for learn / hop-preserve): accept if direct
|
||||
neighbor **or** `nodeInfoLiteIsFavorite` **or** role ∈ {ROUTER, ROUTER_LATE, CLIENT_BASE}.
|
||||
- **No tie-break.** A collision must return `Ambiguous` - picking "best SNR" would
|
||||
resurrect the silent-misroute bug. (Deliberate non-goal; document in code.)
|
||||
|
||||
New constant in `src/mesh/MeshTypes.h` (near line 44):
|
||||
`#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2)` (mirrors `NUM_ONLINE_SECS`).
|
||||
|
||||
### M2 - Only route on bytes that resolve to a unique, reachable neighbor
|
||||
|
||||
In `getNextHop` (`src/mesh/NextHopRouter.cpp:192-207`), after the existing split-horizon
|
||||
check (`node->next_hop != relay_node`), require the stored byte to resolve to a **unique,
|
||||
currently-fresh direct neighbor**; else flood:
|
||||
|
||||
```cpp
|
||||
if (node->next_hop != relay_node) {
|
||||
ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
|
||||
if (r.status == LastByteResolution::Unique) return node->next_hop;
|
||||
LOG_WARN("Next hop 0x%x for 0x%x %s -> flood", node->next_hop, to,
|
||||
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "no longer a neighbor");
|
||||
return std::nullopt;
|
||||
}
|
||||
```
|
||||
|
||||
This self-heals when a neighbor goes away (unicast-into-a-void becomes a flood). It
|
||||
applies to originating, relaying, and retrying, since all route through `getNextHop`.
|
||||
|
||||
Apply M1's safe fallback at the other sites:
|
||||
|
||||
- **Learning** (`NextHopRouter.cpp:111-114`): gate `origTx->next_hop = p->relay_node` on
|
||||
`resolveUniqueLastByte(p->relay_node, /*direct=*/false)`. Ambiguous/unknown → don't
|
||||
learn (leave route unset → flood).
|
||||
- **Favorite-router preservation** (`Router.cpp:120-145`): replace the "first match wins"
|
||||
loop with `resolveUniqueLastByte(p->relay_node, /*direct=*/false)` + a re-check that the
|
||||
resolved node is favorite/has_user/router. Ambiguous/none/not-favorite → **decrement**
|
||||
(safe). Net: removes one full DB scan, adds one resolver scan (wash).
|
||||
|
||||
**Left unchanged, by design (document why in code):**
|
||||
|
||||
- **Site 1** rebroadcast self-check (`NextHopRouter.cpp:147`) and self-identity checks
|
||||
(`ReliableRouter.cpp:127`): a node matches its **own** byte - no DB resolution helps. A
|
||||
remote impostor sharing the intended next-hop's byte will still rebroadcast. M1/M2
|
||||
shrink the blast radius by reducing how often an ambiguous byte is ever stored or
|
||||
originated; a true fix needs a wider field (out of scope). **This is the one residual
|
||||
the plan cannot fully close.**
|
||||
- **Site 3** `wasRelayer`/`checkRelayers` (`PacketHistory.cpp:490-538`): intentionally
|
||||
byte-domain (both sides are on-wire bytes); the consumer (learning) is now hardened.
|
||||
Add a one-line comment; do not change.
|
||||
|
||||
### M3 - Route freshness / failure memory (RAM table on NextHopRouter)
|
||||
|
||||
A bounded, LRU-evicted table keyed by destination, mirroring `PacketHistory`'s
|
||||
reuse-oldest discipline (not an unbounded map) to cap RAM.
|
||||
|
||||
`src/mesh/NextHopRouter.h` (near `pending`, line 99):
|
||||
|
||||
```cpp
|
||||
struct RouteHealth {
|
||||
NodeNum dest = 0; // 0 == empty slot
|
||||
uint32_t learnedAtMsec = 0; // millis() at last (re)learn; rollover-aware
|
||||
uint8_t consecutiveFailures = 0;
|
||||
uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; // byte this health refers to
|
||||
};
|
||||
static constexpr uint8_t ROUTE_HEALTH_MAX = 32; // ~384B; drop to 16 if RAM-tight
|
||||
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
|
||||
// Helpers take `now` (pure/testable): findRouteHealth, getOrAllocRouteHealth,
|
||||
// noteRouteLearned, noteRouteSuccess, noteRouteFailure, isRouteStale, clearRouteHealth
|
||||
```
|
||||
|
||||
Policy:
|
||||
|
||||
| Constant | Value | Rationale |
|
||||
| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ROUTE_TTL_MSEC` | 30 min | Survives a normal conversation; re-discovers a moved node within a telemetry interval. |
|
||||
| `ROUTE_FAILURE_THRESHOLD` | 3 | 1-2 consecutive failures are transient LoRa collisions; 3 to the same hop = dead. Accumulates **across** DMs (independent of the per-DM 3-try budget). |
|
||||
|
||||
`isRouteStale(h, now)` = `(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC || h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD`.
|
||||
All age math uses **unsigned subtraction** (rollover-safe, matching
|
||||
`PacketHistory.cpp:364`); treat `learnedAtMsec == 0` as "set now".
|
||||
|
||||
Wiring (as built - `src/mesh/NextHopRouter.cpp`, `src/mesh/ReliableRouter.cpp`):
|
||||
|
||||
- `getNextHop`: if a health record matches the stored byte and `isRouteStale`, clear
|
||||
`node->next_hop` (NodeDB) **and** `clearRouteHealth`, return `nullopt` (flood). No
|
||||
record yet (cold path, first DM after boot) → trust NodeDB, but the M2 strict-neighbor
|
||||
gate still applies.
|
||||
- `sniffReceived` learn: gate the write through `resolveUniqueLastByte` (M2), then
|
||||
`noteRouteLearned(p->from, p->relay_node, millis())` - resets `consecutiveFailures`
|
||||
**only if the hop changed** (anti-flap for asymmetric re-learn); otherwise just refreshes
|
||||
`learnedAtMsec`. (No success signal is taken on the intermediate reverse-pass: an ACK
|
||||
merely passing through us is not proof that _we_ delivered, and resetting failures there
|
||||
would reintroduce the asymmetric flap.)
|
||||
- `doRetransmissions`: on the last-retransmission branch (`numRetransmissions == 1`, the
|
||||
point a directed delivery has gone un-ACKed for both originator and intermediate) →
|
||||
`noteRouteFailure(to)`, then the existing NodeDB `next_hop` reset + flood. We deliberately
|
||||
do **not** `clearRouteHealth` here: keeping the record is what lets the failure count
|
||||
accumulate across DMs so a flapping reverse-path-relearned dead hop eventually ages out.
|
||||
- `ReliableRouter::sniffReceived` ACK path → `noteRouteSuccess(getFrom(p), millis())`
|
||||
(an end-to-end ACK addressed to us is genuine forward-delivery proof; clears failures and
|
||||
refreshes freshness). `noteRouteSuccess`/`noteRouteFailure` are no-ops when no record
|
||||
exists, so flood-only destinations never pollute the table.
|
||||
|
||||
**Reconciliation (no double-handling):** `doRetransmissions` owns _in-flight_ failure of
|
||||
the current DM (reset NodeDB `next_hop` + flood, and bump the cross-DM failure counter);
|
||||
`getNextHop` owns _between-DM_ staleness (TTL or failure-threshold → flood + clear). The
|
||||
only place that erases a health record is the `getNextHop` decay path; the retransmission
|
||||
path leaves it intact so the counter survives a reverse-path re-learn.
|
||||
|
||||
### M4 - Earlier flood for unverified routes (gated, off by default)
|
||||
|
||||
Compile-gated so healthy sparse meshes are untouched. **Default is off** - the define
|
||||
lives in `NextHopRouter.h` and must be flipped to measure:
|
||||
`#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 1`.
|
||||
|
||||
In `doRetransmissions`, the directed-retry `else` branch: if the route is **not verified**
|
||||
(`!findRouteHealth(to) || consecutiveFailures > 0 || isRouteStale`), reset `next_hop` and
|
||||
flood on this attempt instead of spending another directed try. A **verified** route
|
||||
(record present, `consecutiveFailures == 0`, within TTL - i.e. recently ACKed) takes the
|
||||
unchanged directed-retry path, so the sparse-mesh happy path is untouched. Trade-off:
|
||||
airtime ↔ latency; the gate ensures we never pay the flood cost on a proven route, only on
|
||||
one we already distrust. Off by default precisely so it can be A/B-measured on the
|
||||
simulator before broad enable.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/mesh/MeshTypes.h` | `NEXTHOP_NEIGHBOR_FRESH_SECS`, `ROUTE_TTL_MSEC`, `ROUTE_FAILURE_THRESHOLD`, `NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED` |
|
||||
| `src/mesh/NodeDB.h` / `src/mesh/NodeDB.cpp` | `LastByteResolution`, `ResolvedNode`, `resolveLastByte`, `resolveUniqueLastByte` |
|
||||
| `src/mesh/NextHopRouter.h` | `RouteHealth` + array + helpers; `#ifdef PIO_UNIT_TESTING public:` for helpers and `getNextHop` |
|
||||
| `src/mesh/NextHopRouter.cpp` | `getNextHop` (M2 gate + M3 decay); `sniffReceived` (learn gate + health seed + success); `doRetransmissions` (failure counting + M4); comment site 1 |
|
||||
| `src/mesh/Router.cpp` | `shouldDecrementHopLimit` → resolver + favorite/router re-check |
|
||||
| `src/mesh/ReliableRouter.cpp` | ACK path → `noteRouteSuccess` |
|
||||
| `test/test_nexthop_routing/test_main.cpp` | **new** unit suite (auto-built under `[env:native]`) |
|
||||
|
||||
**Reuse, don't reinvent:** `getLastByteOfNodeNum`, `sinceLastSeen`, the bitfield helpers,
|
||||
`getMeshNodeByIndex`/`getNumMeshNodes`, PacketHistory's reuse-oldest eviction shape, and
|
||||
`MockNodeDB::addTestNode` (from `test/test_hop_scaling/test_main.cpp`).
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **`0x00`↔`0xFF` projection:** the resolver compares via `getLastByteOfNodeNum` on both
|
||||
sides, so a `…00` node and a `…FF` node correctly collide on `0xFF` → `Ambiguous`. Test
|
||||
explicitly.
|
||||
- **MQTT packets:** `relay_node`/`next_hop` are forced invalid when `hop_start == 0`
|
||||
(`src/mesh/RadioLibInterface.cpp:603-605`) → byte 0 → resolver `None` → don't learn
|
||||
(correct).
|
||||
- **`has_hops_away == false`** nodes are excluded from the strict gate (never fabricate a
|
||||
Unique neighbor for M2); admitted to the lenient gate only via favorite/router role.
|
||||
Safe; self-corrects once `hops_away` is learned.
|
||||
- **Self / broadcast:** the resolver skips `getNodeNum()` and `NODENUM_BROADCAST`;
|
||||
`getNextHop` already early-returns for broadcast.
|
||||
- **Perf:** M2 adds one O(N) resolver scan per directed send/relay (early-exit on the 2nd
|
||||
match), cheaper than the crypto already on that path; site-4 is a wash. If ever hot, a
|
||||
future 256-entry last-byte index is the optimization (not now - RAM).
|
||||
|
||||
---
|
||||
|
||||
## Verification (all tiers)
|
||||
|
||||
### 1. Native unit tests - new `test/test_nexthop_routing/test_main.cpp`
|
||||
|
||||
`pio test -e native -f test_nexthop_routing`; on macOS `./bin/test-native-docker.sh -f test_nexthop_routing`.
|
||||
Design the RouteHealth helpers to take `now` as a parameter so the 30-min TTL logic is
|
||||
testable without a clock mock.
|
||||
|
||||
- **Resolver:** None / Unique / **Ambiguous (birthday collision)** / strict-excludes-stale /
|
||||
strict-excludes-far / lenient-includes-favorite-router / lenient-collision / skips-self /
|
||||
skips-ignored / **`0x00`↔`0xFF` collision** / early-exit.
|
||||
- **`getNextHop`:** unique→byte, **ambiguous→nullopt**, stale-neighbor→nullopt,
|
||||
split-horizon (relay==next_hop)→nullopt, broadcast→nullopt.
|
||||
- **RouteHealth:** TTL boundary, **rollover** (learn near `0xFFFFFFFF`, check after wrap),
|
||||
failure threshold, success-resets, **re-learn-same-hop keeps fails (anti-flap)**,
|
||||
re-learn-new-hop resets, LRU eviction bound, clear.
|
||||
- **Site-4:** preserve on unique favorite router; **decrement on two colliding favorites**;
|
||||
decrement when the resolved node is not a favorite.
|
||||
- **Sparse-mesh regression:** all-distinct last bytes → every resolve Unique, `getNextHop`
|
||||
returns the stored byte unchanged (proves no happy-path change).
|
||||
- Re-run `test_packet_history` and `test_hop_scaling` for no regression.
|
||||
|
||||
### 2. portduino SimRadio simulator
|
||||
|
||||
`pio run -e native && ./bin/test-simulator.sh`. Best vehicle for the **intermediate-node**
|
||||
path the 2-device bench can't reach. Line topology A - B - C: establish A→C (B learns a
|
||||
directed route), stop B relaying that dest, confirm A re-discovers via flood within
|
||||
`ROUTE_FAILURE_THRESHOLD` and that B's `noteRouteFailure`/`clearRouteHealth` fires (visible
|
||||
via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this to A/B M4
|
||||
(attempts-to-delivery, total airtime).
|
||||
|
||||
### 3. Hardware via meshtastic MCP (auto-detect; 3+ devices for a real hop)
|
||||
|
||||
- `meshtastic-mcp/tests/mesh/test_nexthop_multihop_recovery.py` - **the multi-hop validator
|
||||
for this work** (added on this branch). Self-discovers an A - relay - C line, asserts a
|
||||
directed DM is delivered across the relay (next_hop + M1/M2/M3 engaged), and asserts
|
||||
delivery recovers after the relay is power-cycled (M3). Skips unless the bench is a true
|
||||
multi-hop line (≥3 roles via `--hub-profile`, endpoints out of direct RF range).
|
||||
- `meshtastic-mcp/tests/mesh/test_direct_with_ack.py` - happy-path regression: a fresh/unique
|
||||
route still delivers a want_ack DM on the first/second try (M4's gate must keep this
|
||||
green).
|
||||
- `meshtastic-mcp/tests/mesh/test_peer_offline_recovery.py` - 2-device recovery validator: peer
|
||||
off mid-conversation then back. Must stay green and ideally recover in fewer attempts.
|
||||
|
||||
### 4. Build / format sanity
|
||||
|
||||
native-macos **and** Docker both ways; trunk clang-format@16.0.3; a release `pio run` to
|
||||
confirm the `#ifdef PIO_UNIT_TESTING` visibility widening does **not** leak into
|
||||
production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B table.
|
||||
|
||||
---
|
||||
|
||||
## Verification status (as built on `nexthop-redux`)
|
||||
|
||||
| Tier | What ran | Result |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------- | ------------------- |
|
||||
| Unit (native-macos) | `test_nexthop_routing` (31 cases) | ✅ 31/31 |
|
||||
| Unit (Docker / Linux, CI parity) | `test_nexthop_routing` | ✅ 31/31 |
|
||||
| Regression | `test_packet_history`, `test_hop_scaling`, `test_mqtt`, `test_traffic_management` | ✅ 105/105 |
|
||||
| Build | `pio run -e native-macos` (M4 off) and with `-DNEXTHOP_EARLY_FLOOD_ON_UNVERIFIED=1` | ✅ both link |
|
||||
| Format | trunk `clang-format@16.0.3` | ✅ no issues |
|
||||
| Simulator (CI `simulator-tests`) | `meshtasticd -s` + `meshtastic.test.testSimulator()` on native-macos | ✅ exit 0, no crash |
|
||||
|
||||
**Pending (environment-blocked, not yet run):**
|
||||
|
||||
- **Multi-hop A-B-C recovery sim** - the `simulator/` broker hub is **not git-tracked**
|
||||
(only stale local `.pyc`), and two `meshtasticd -s` instances can't hear each other
|
||||
without it. The intermediate-node failure-count path and the M4 A/B therefore have unit
|
||||
coverage of their logic but no end-to-end multi-node run yet.
|
||||
- **Hardware / multi-hop tier** - a committable bench test now exists:
|
||||
`meshtastic-mcp/tests/mesh/test_nexthop_multihop_recovery.py`. It self-discovers a real
|
||||
multi-hop pair (A - relay - C), asserts a directed DM is delivered across the relay, and
|
||||
asserts delivery recovers after the relay is power-cycled (the M3 path). It
|
||||
`pytest.skip`s cleanly unless the bench is a true line with endpoints out of direct RF
|
||||
range (≥3 roles via `--hub-profile`), so it's safe to commit and only asserts when the
|
||||
NextHop path is genuinely exercised. Collected + verified to skip without hardware;
|
||||
not yet run on a bench. `test_direct_with_ack.py` / `test_peer_offline_recovery.py`
|
||||
remain the 2-device happy-path/recovery regressions.
|
||||
|
||||
---
|
||||
|
||||
## Risks & limitations
|
||||
|
||||
- **Site-1 impostor rebroadcast** is unfixable without a wider field - documented; M1/M2
|
||||
only shrink its frequency.
|
||||
- **Dense meshes flood DMs more often** - intended (a flooded DM arrives; a mis-unicast one
|
||||
black-holes). Call out in the PR so reviewers expect a slightly higher DM flood rate on
|
||||
very dense meshes.
|
||||
- **M4 airtime** if the gate is too loose → default conservative + compile-gated +
|
||||
simulator A/B before broad enable.
|
||||
- **RAM** ~384 B (32 slots); 16 slots (~192 B) with graceful LRU degradation if tight.
|
||||
- **Asymmetric flap** not fully closed (a _new_ bad hop resets the counter); the TTL
|
||||
backstop bounds it. Per-hop failure history is future work (more RAM).
|
||||
|
||||
---
|
||||
|
||||
## How to continue this work (commit sequencing)
|
||||
|
||||
Each step is independently testable; land them as separate commits.
|
||||
|
||||
1. **M1 resolver + unit tests** - `NodeDB` only; no behavior change until wired. Lands the
|
||||
`resolveLastByte`/`resolveUniqueLastByte` primitive and its full unit-test matrix.
|
||||
2. **M2 + wiring + tests** - `getNextHop` strict gate, learning gate, favorite-router
|
||||
preservation rewrite. Adds the `getNextHop` and site-4 tests.
|
||||
3. **M3 health table + decay + tests** - RAM `RouteHealth` table, decay-on-read, failure/
|
||||
success accounting, reconciliation with the existing last-retry reset. Adds the
|
||||
route-health unit tests and the simulator recovery check.
|
||||
4. **M4 gated tuning** - early-flood-on-unverified behind the compile flag; simulator A/B
|
||||
and hardware regression.
|
||||
|
||||
Reference plan (with the same content) was developed at
|
||||
`~/.claude/plans/nexthop-routing-for-direct-lexical-shell.md` on the author's machine; this
|
||||
in-repo doc is the canonical handoff copy.
|
||||
@@ -1,321 +0,0 @@
|
||||
# NodeInfo stores: the base and extended databases
|
||||
|
||||
This document is an overview of the node-identity and traffic-state databases that the
|
||||
TrafficManagementModule (TMM) either owns or leans on. There are four stores in play, but
|
||||
only three form the identity lookup chain:
|
||||
|
||||
1. **NodeDB hot store** - the authoritative `NodeInfoLite` array (identity tier 1).
|
||||
2. **Warm tier** (`WarmNodeStore`) - minimal persisted records for hot-store evictees
|
||||
(identity tier 2).
|
||||
3. **TMM NodeInfo payload cache** (extended) - the ephemeral **third identity tier**: full
|
||||
`User` payloads plus direct-response metadata; PSRAM-backed on hardware, plain heap in
|
||||
native tests.
|
||||
|
||||
The fourth store, the **TMM unified cache** (base - flat 10-byte-per-node traffic-shaping
|
||||
state), is not part of that chain: it sits beside it, keyed by the same NodeNum, and only
|
||||
its 4-bit cached role acts as a final fallback when all three identity tiers miss.
|
||||
|
||||
Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
|
||||
`src/modules/TrafficManagementModule.{h,cpp}`, sizing in `src/mesh/mesh-pb-constants.h`.
|
||||
|
||||
**Memory classes.** The warm tier (§2) and unified cache (§3) size themselves from
|
||||
`MESHTASTIC_MEM_CLASS` (`src/memory/MemClass.h`), which ranks a build by _usable app heap after
|
||||
platform overheads_ (SoftDevice, WiFi+BLE stacks) rather than by raw RAM or chip family. The hot
|
||||
store (§1) is flash-shaped and the NodeInfo cache (§4) is present-or-absent, so neither is classed:
|
||||
|
||||
| Class | Heap | Parts |
|
||||
| ------ | --------------------- | -------------------------------------------- |
|
||||
| LARGE | PSRAM or host | ESP32-S3 with PSRAM, portduino/native |
|
||||
| MEDIUM | ~250-500 KB, no PSRAM | ESP32-S3/C6/P4 without PSRAM |
|
||||
| SMALL | ~100-250 KB | classic ESP32/S2/C3, nRF52840, RP2040/RP2350 |
|
||||
| TINY | <32 KB | STM32WL |
|
||||
|
||||
An unclassified chip lands in SMALL on purpose: small caches are a recoverable default, an
|
||||
exhausted heap is not. Where a capacity table names a specific part beside these classes, that
|
||||
part is deliberately class-deviant and the reason is given under the table.
|
||||
|
||||
---
|
||||
|
||||
## 1. NodeDB hot store (authoritative)
|
||||
|
||||
- **What:** the classic `meshNodes` array of `meshtastic_NodeInfoLite` - full identity as
|
||||
flattened fields (names, role, public key, bitfield flags such as `HAS_XEDDSA_SIGNED`;
|
||||
position/telemetry live in satellite stores reached via copy-out accessors, not nested
|
||||
members). Everything else in this document is a cache or a fallback for it.
|
||||
- **Eviction:** oldest non-protected node when full (`getOrCreateMeshNode`). On eviction
|
||||
the node's essentials are **absorbed into the warm tier** (see §2); on re-admission the
|
||||
warm record is rehydrated back (`take()`), including the XEdDSA-signed bit.
|
||||
- **Persistence:** the node database file in LittleFS, saved on the usual NodeDB cadence.
|
||||
- **Authority:** key pinning (`updateUser`'s "Public Key mismatch" drop), signer
|
||||
provenance, and identity content all originate here. The lookup helpers that other
|
||||
stores mirror:
|
||||
- `copyPublicKeyAuthoritative(n, out)` - hot store, then warm tier. The pin reference
|
||||
for caches; never consults opportunistic caches.
|
||||
- `copyPublicKey(n, out)` - the above, then **TMM's NodeInfo cache as last resort**
|
||||
(extends the encrypt-to pool for nodes both tiers have forgotten).
|
||||
- `isVerifiedSignerForKey(n, key32)` - key-matched signer verdict across hot + warm.
|
||||
- `isKnownXeddsaSigner(n)` - key-agnostic "should this node's signable traffic arrive
|
||||
signed", across hot + warm. Gates that check only the hot store would let a
|
||||
warm-evicted signer be impersonated with unsigned frames.
|
||||
- `getNodeRole(n)` - hot store, then the role cached in the warm tier, else `CLIENT`.
|
||||
|
||||
**Capacity** - `MAX_NUM_NODES`:
|
||||
|
||||
| ESP32-S3 | Native (portduino) | nRF52840, generic ESP32 | STM32WL |
|
||||
| --------------- | ------------------ | ----------------------- | ------- |
|
||||
| 250 / 200 / 100 | 200, configurable | 120 | 10 |
|
||||
|
||||
This one is flash-shaped rather than heap-shaped, so it is unclassed: `nodes.proto` has to fit the
|
||||
filesystem. The fixed-cap platforms get their value from `mesh-pb-constants.h`; the 120 covers
|
||||
nRF52840 plus generic ESP32 including C3, and is what keeps `nodes.proto` inside the stock 28 KB
|
||||
LittleFS.
|
||||
|
||||
**Two platforms do not take their cap from that header, and neither is a compile-time constant:**
|
||||
|
||||
- **ESP32-S3** picks a tier at boot from the flash chip size (>=15 MB / >=7 MB / smaller).
|
||||
- **Native/portduino** resolves it from _runtime_ config:
|
||||
`variants/native/portduino{,-buildroot}/variant.h` define `MAX_NUM_NODES portduino_config.MaxNodes`,
|
||||
default **200** (`PortduinoGlue.h`), overridable per-host with `General: MaxNodes` in the YAML.
|
||||
Because `variant.h` is reached first, the `ARCH_PORTDUINO` branch of `mesh-pb-constants.h` never
|
||||
fires - it is `#error`-guarded so it can no longer be misread as the native cap.
|
||||
|
||||
Do not grep `mesh-pb-constants.h` for the native number: the protected-node cap derives from
|
||||
`MAX_NUM_NODES` (`numProtectedNodes() < MAX_NUM_NODES - 2`), so a wrong reading gives a wrong cap
|
||||
(248 instead of 198) and makes a genuinely saturated database look impossible.
|
||||
|
||||
The separate `250` in `NodeDB::getMaxNodesAllocatedSize()` is `NODEDB_MIGRATION_LOAD_CEILING`, a
|
||||
decode allowance for files written by larger-cap firmware. It is not a cap on this build.
|
||||
|
||||
## 2. Warm tier - `WarmNodeStore` (NodeDB-owned)
|
||||
|
||||
- **What:** the "long-tail" second tier. When a node ages out of the hot store, a minimal
|
||||
record survives so DMs keep encrypting: the key is expensive to re-learn; everything
|
||||
else rebuilds from traffic in seconds.
|
||||
- **Entry:** exactly 40 bytes - `num(4) | last_heard(4) | public_key(32)`. The low 7 bits
|
||||
of `last_heard` are omitted, and replaced with metadata (role: 4 bits, protected
|
||||
category: 2, XEdDSA-signed bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking.
|
||||
- **Capacity:** `WARM_NODE_COUNT` (100 on constrained parts; platform-tiered).
|
||||
- **Eviction:** LRU by `last_heard`, with keyed entries outranking keyless; keyless
|
||||
candidates never displace keyed entries.
|
||||
- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring below LittleFS
|
||||
(append/replay/compact); everywhere else `/prefs/warm.dat` (LittleFS).
|
||||
- **Membership invariant:** a node lives in the hot **XOR** warm tier. `take()` removes
|
||||
the warm record when the node is re-admitted hot, restoring role/protected/XEdDSA-signed bits.
|
||||
|
||||
**Capacity** - `WARM_NODE_COUNT` (`mesh-pb-constants.h`):
|
||||
|
||||
| LARGE | MEDIUM | RP2040 / RP2350 | nRF52840 | SMALL | TINY |
|
||||
| ----- | ------ | --------------- | -------- | ----- | ---- |
|
||||
| 2000 | 150 | 150 | 100 | 100 | 0 |
|
||||
|
||||
TINY's 0 disables the tier outright. At 40 B/entry, LARGE costs ~80 KB and lives in PSRAM, MEDIUM
|
||||
~6 KB of heap. Both named parts are class-deviant on purpose: RP2040/RP2350 is bounded so the
|
||||
`warm.dat` write fits the 8 s watchdog (#10746) rather than by RAM, and nRF52840 dropped from 200 to
|
||||
100 because its RAM cache is calloc'd from the ~115 KB heap arena shared with SoftDevice, which
|
||||
2.8.0 field reports showed at 99% use.
|
||||
|
||||
## 3. TMM unified cache (base, traffic state)
|
||||
|
||||
- **What:** TMM's own flat array of packed 10-byte `UnifiedCacheEntry` records - the
|
||||
per-node state behind position dedup, rate limiting, unknown-packet filtering, plus two
|
||||
piggybacked caches:
|
||||
- `next_hop` - last-byte relay hint, written only from ACK-confirmed NextHopRouter
|
||||
decisions (no TTL; keeps the slot alive across sweeps).
|
||||
- a **4-bit device role** (split across the top bits of two count bytes) - the _third_
|
||||
fallback for role-aware policy after the hot store and warm tier, surviving even total
|
||||
NodeDB eviction. Read through `resolveSenderRole()`, refreshed by
|
||||
`updateCachedRoleFromNodeInfo()` on observed NodeInfo.
|
||||
- **Entry layout:**
|
||||
`node(4) | pos_fingerprint(1) | rate_count(1) | unknown_count(1) | pos_time(1) | rate_unknown_time(1) | next_hop(1)`
|
||||
= 10 bytes, all platforms. Timestamps are free-running modular ticks (uint8 / nibbles)
|
||||
with presence carried by non-zero sentinels - no epochs, no absolute time.
|
||||
- **Eviction:** linear scan; insertion on a full cache evicts the stalest entry,
|
||||
preferring to keep entries with a `next_hop` hint **or** a cached special (non-`CLIENT`)
|
||||
role - the long-tail state this cache exists to retain (`findOrCreateEntry`'s `preferred`
|
||||
test covers both, not just `next_hop`).
|
||||
- **Persistence:** none - PSRAM (or heap) only, rebuilt from traffic.
|
||||
|
||||
**Capacity** - `TRAFFIC_MANAGEMENT_CACHE_SIZE` (`mesh-pb-constants.h`), variant-overridable:
|
||||
|
||||
| LARGE | MEDIUM | SMALL | nRF52840 | `HAS_TRAFFIC_MANAGEMENT=0` |
|
||||
| ----- | ------ | ----- | -------- | -------------------------- |
|
||||
| 2048 | 500 | 400 | 250 | 0 |
|
||||
|
||||
At 10 B/entry that is ~5 KB on MEDIUM and ~2.5 KB on nRF52840, which is class-deviant for the same
|
||||
heap reason as the warm tier (its class would give 400); 250 entries still tracks over 2x the
|
||||
120-node hot store, and LRU victim recycling absorbs busier meshes.
|
||||
|
||||
## 4. TMM NodeInfo payload cache (extended, the ephemeral third tier)
|
||||
|
||||
- **What:** a flat array of `NodeInfoPayloadEntry` (PSRAM-backed on hardware; see
|
||||
Availability) - the full cached `User` payload (names, role, key) plus the metadata that
|
||||
backs TMM's **spoofed direct NodeInfo replies** on a target's behalf, independent of
|
||||
NodeDB (the serve/throttle behaviour is documented in
|
||||
[traffic_management_module.md](traffic_management_module.md)). Also the last-resort key
|
||||
source for `NodeDB::copyPublicKey()`.
|
||||
- **Availability:** `TMM_HAS_NODEINFO_CACHE` - ESP32 with PSRAM (production home; 2000
|
||||
entries is too large for MCU internal RAM), plus native unit-test builds on the plain
|
||||
heap so the trust/retention paths run in CI.
|
||||
- **Entry:** `node`, `user` (full nanopb `User`), the `obsTick` recency stamp (3 min/tick),
|
||||
`sourceChannel`, `decodedBitfield`, and packed 1-bit flags: `hasDecodedBitfield`,
|
||||
`keyXeddsaSigned`, `keyManuallyVerified`, `hasObserved`, `hasFullUser`, `isMember`. (The direct-response throttle
|
||||
no longer keeps per-entry state here - it is a pair of separate RAM tables; see the module
|
||||
doc.)
|
||||
- **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from NodeDB
|
||||
seeding plus observed traffic after every boot.
|
||||
|
||||
**Capacity** - `kNodeInfoCacheEntries` (`TrafficManagementModule.h`), gated by
|
||||
`TMM_HAS_NODEINFO_CACHE`:
|
||||
|
||||
| ESP32 + PSRAM | Native unit-test builds | Everything else |
|
||||
| ------------- | ----------------------- | --------------- |
|
||||
| 2000 | 2000 | not compiled |
|
||||
|
||||
Not class-tiered: the array is either compiled or it isn't. ESP32+PSRAM is the production home (in
|
||||
PSRAM); native test builds put the same 2000 entries on the plain heap so the trust and retention
|
||||
paths run in CI. Linear scan in every build - NodeInfo traffic is low-rate.
|
||||
|
||||
### Trust & provenance model
|
||||
|
||||
- **Key pin, three layers deep:** an incoming NodeInfo key is checked against
|
||||
`copyPublicKeyAuthoritative()` (hot then warm - the same coverage as `updateUser`'s own
|
||||
pin), and, failing NodeDB knowledge, against the cache's **own previously cached key**
|
||||
(TOFU pin). Mismatches are dropped, never overwritten. A frame advertising _our own_ key
|
||||
is dropped outright (impersonation).
|
||||
- **Key provenance (`keyXeddsaSigned` + `keyManuallyVerified`, combined via `keyProven()`):**
|
||||
`keyXeddsaSigned` is set when a frame's XEdDSA signature was router-verified
|
||||
(`mp.xeddsa_signed`) or when NodeDB already knew the node as a signer **for the same key**
|
||||
(`isVerifiedSignerForKey`). `keyManuallyVerified` is set when the user confirmed possession
|
||||
out-of-band (QR / fingerprint), routed via `onNodeKeyCommitted(proven)` and re-seeded from the
|
||||
hot store's `is_key_manually_verified` bit at reconcile. Either bit makes `keyProven()` true -
|
||||
the predicate the replay gate, eviction tiering, and pubkey-pool callers use. Both are monotonic
|
||||
per slot; a changed key resets both.
|
||||
- **Unsigned-identity gate:** a NodeInfo arriving _unsigned_ from a node we have ever
|
||||
verified as a signer - per `NodeDB::isKnownXeddsaSigner()`, which covers hot **and
|
||||
warm** tiers - drives no cache, role, or `updateUser()` write. (Warm coverage matters: a
|
||||
signer evicted to the warm tier would otherwise be forgeable with its own public key
|
||||
until re-heard. The same rule guards `Router::checkXeddsaReceivePolicy`'s
|
||||
unsigned-broadcast drop.)
|
||||
- **Serve gate honesty:** only a genuinely _heard_ NODEINFO frame stamps
|
||||
`obsTick`/`hasObserved` - seeding and write-through don't, so a silent node never looks alive
|
||||
to the replay path. The sweep clears `hasObserved` to enforce the 6 h serve window. The
|
||||
spoofed-reply throttle this gate feeds lives in the module (see
|
||||
[traffic_management_module.md](traffic_management_module.md)).
|
||||
|
||||
### Consistency with NodeDB (anti-entropy)
|
||||
|
||||
Four mechanisms keep this tier a superset of NodeDB's identities. All **merge rather than
|
||||
overwrite**, so a keyless commit never costs the cache a learned TOFU key.
|
||||
|
||||
| Mechanism | When | Role |
|
||||
| --------------------------------------------------------------------- | --------------------------- | -------------------------------- |
|
||||
| Write-through hooks (`onNodeIdentityCommitted`, `onNodeKeyCommitted`) | every identity/key commit | immediate upsert |
|
||||
| Reconcile sweep (`reconcileNodeInfoFromNodeDBLocked`) | boot seed, then hourly | re-seed from hot + warm tiers |
|
||||
| Membership refresh | inside the hourly reconcile | re-mark which nodes NodeDB holds |
|
||||
| Purge hooks (`purgeNode`, `purgeAll`) | node removal / reset | drop the node from both caches |
|
||||
|
||||
Two details that bite: the reconcile sweep transfers signer verdicts only when **key-matched**;
|
||||
and membership refresh clears-then-re-marks from both tiers rather than a per-entry NodeDB lookup
|
||||
each sweep (which would be O(entries x members) under the lock). A keyless warm-tier record still
|
||||
marks membership (`isMember`) even though it has no `User` to seed - `isMember` is a keep-alive,
|
||||
independent of `hasFullUser`. Because the re-mark is only hourly, hook-driven additions and
|
||||
`purgeNode()` removals are immediate, but a **passive** NodeDB eviction may lag membership by up to
|
||||
an hour.
|
||||
|
||||
**Retention:** no timed eviction. Slots die only by LRU displacement on insert, ranked by
|
||||
trust tiers - members and key-proven keys are stickiest; the seeding pass additionally
|
||||
refuses to churn one member out for another (`spareMembers`).
|
||||
|
||||
**Key-commit funnel:** every path that writes a remote key into the hot store must route
|
||||
the write-through. Full-identity commits funnel through `NodeDB::updateUser()`; bare-key
|
||||
commits (admin-channel learn in `Router::perhapsDecode`, manual verification in
|
||||
`KeyVerificationModule`) funnel through `NodeDB::commitRemoteKey()`, which carries an
|
||||
explicit `KeyCommitTrust` provenance (`ManuallyVerified` sets the `keyManuallyVerified` bit in this
|
||||
cache). Never assign `info->public_key` directly when **learning or rotating a remote
|
||||
key** - the cache would silently diverge until the next reconcile. (The lone direct write
|
||||
in `getOrCreateMeshNode()`'s warm-tier re-admission is exempt: it restores a key the warm
|
||||
tier already holds, which this cache already tracks as a member, so nothing new is learned
|
||||
and the hourly reconcile re-seeds it even if the packet path had LRU-evicted that slot.)
|
||||
|
||||
**Enable gate:** the write-through hooks, the sweep, the packet path, **and the
|
||||
`copyPublicKey()`/`copyUser()` accessors** all no-op while `moduleConfig.has_traffic_management`
|
||||
is off, so cache content, maintenance, and reads are keyed to the same condition. This enforces
|
||||
(not just documents) the corollary that the pubkey-pool superset property holds only while the
|
||||
module is enabled: a disabled module's frozen cache never feeds PKI resolution or name
|
||||
rehydration.
|
||||
|
||||
### Tick clocks and wrap safety
|
||||
|
||||
This cache's `obsTick` recency stamp, like the unified cache's pos/rate/unknown stamps, is a
|
||||
free-running modular tick rather than an absolute time, and depends on the maintenance sweep to
|
||||
clear expired state before it aliases. The per-clock periods, windows, and what keeps each honest
|
||||
are documented with the module in
|
||||
[traffic_management_module.md](traffic_management_module.md#tick-clocks-and-wrap-safety). The sharp
|
||||
case for this tier is `obsTick`: the sweep clearing `hasObserved` is the _sole_ guarantee the 6 h
|
||||
serve gate never reads an aliased stamp, which is why it is a compile-time invariant guarded by
|
||||
`TMM_HAS_NODEINFO_CACHE` alone.
|
||||
|
||||
The warm tier is different by design: `WarmNodeStore.last_heard` is an **absolute** unix-seconds
|
||||
timestamp (128 s quantised), so it cannot wrap until 2106 and needs no sweep - the TMM caches
|
||||
chose 1-byte ticks instead to stay at 10 B/entry across up to 2048 entries.
|
||||
|
||||
### Direct-response behavior
|
||||
|
||||
How this cache's identities are served as spoofed direct NodeInfo replies - the serve gates,
|
||||
the per-requester/per-target/global throttle, and the "throttled forwards, not dropped"
|
||||
behaviour - is documented with the module in
|
||||
[traffic_management_module.md](traffic_management_module.md).
|
||||
|
||||
---
|
||||
|
||||
## Property matrix
|
||||
|
||||
Side-by-side view of what each store actually holds ("-" = not held). Details and
|
||||
rationale live in the per-store sections above.
|
||||
|
||||
| Property | 1. Hot store | 2. Warm tier | 3. NodeInfo cache | 4. Unified cache |
|
||||
| -------------------------- | ---------------------------------- | ------------------------------ | ---------------------------------- | ------------------------------- |
|
||||
| Struct | `NodeInfoLite` | `WarmNodeEntry` | `NodeInfoPayloadEntry` | `UnifiedCacheEntry` |
|
||||
| Node number | yes | yes | yes (0 = free) | yes (0 = free) |
|
||||
| Names + user id | yes (flattened) | - | yes (full `User`) | - |
|
||||
| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU/proven; pinned) | - |
|
||||
| Key source - XEdDSA signed | `HAS_XEDDSA_SIGNED` bit | 1 bit (in `last_heard`) | `keyXeddsaSigned` | - |
|
||||
| Key source - manual scan | `IS_KEY_MANUALLY_VERIFIED` bit | - (not carried) | `keyManuallyVerified` | - |
|
||||
| Device role | `role` field | 4-bit role (metadata steal) | in cached `User` | 4-bit role (final fallback) |
|
||||
| Recency | `last_heard` (unix s) | `last_heard` (128 s quant.) | `obsTick` (3 min) + `hasObserved` | modular ticks |
|
||||
| Position / telemetry | satellite accessors | - | - | 8-bit pos fingerprint (dedup) |
|
||||
| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` instead) | - |
|
||||
| Routing hint (`next_hop`) | yes (persisted) | - | - | ACK-confirmed relay byte |
|
||||
| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` | - |
|
||||
| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fp |
|
||||
| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8 (padded) | 10 B exact |
|
||||
| Capacity (symbol) | `MAX_NUM_NODES` | `WARM_NODE_COUNT` | `kNodeInfoCacheEntries` | `TRAFFIC_MANAGEMENT_CACHE_SIZE` |
|
||||
| Capacity (entries) | 250/200/120/100/10 (native: 200\*) | ~100 | 2000 | 2048/500/400/250/0 |
|
||||
| Persistence (durable) | LittleFS (node DB) | flash ring (nRF52840)/LittleFS | none (rebuilt) | none |
|
||||
| Storage (runtime) | heap | heap / PSRAM (ESP32) | PSRAM (hw) / heap (test) | PSRAM / heap |
|
||||
|
||||
\* Native/portduino is not a compile-time value: it is `portduino_config.MaxNodes`; the host default
|
||||
is 200, settable per-host via `General: MaxNodes`, and the WASM build overrides it to 80
|
||||
(`wasm_config_apply()`). See the hot-store capacity section above.
|
||||
|
||||
## How a lookup falls through the tiers
|
||||
|
||||
```text
|
||||
identity/role/key consumer
|
||||
│
|
||||
▼
|
||||
1. hot store (NodeInfoLite) full identity, authoritative
|
||||
│ miss
|
||||
▼
|
||||
2. warm tier (WarmNodeStore) key + role/protected/XEdDSA-signed bits, persisted
|
||||
│ miss
|
||||
▼
|
||||
3. TMM NodeInfo cache (extended) full User payloads + TOFU/proven keys, ephemeral
|
||||
│ miss (role-only: 4-bit role in the unified cache)
|
||||
▼
|
||||
defaults (no key; role = CLIENT)
|
||||
```
|
||||
|
||||
The unified cache (§3) sits beside this chain rather than in it: it is traffic-shaping
|
||||
state keyed by the same NodeNum, whose role bits act as the final role fallback when all
|
||||
three identity tiers miss.
|
||||
@@ -1,222 +0,0 @@
|
||||
# The Traffic Management Module (TMM)
|
||||
|
||||
TMM is an optional module that shapes **transit** traffic on busy meshes. Large networks get
|
||||
noisy fast - repeated position packets, bursty senders, and unknown/undecryptable frames all
|
||||
burn limited airtime and power - and TMM filters or answers that traffic before it is
|
||||
rebroadcast. On supported targets it **ships enabled** (`has_traffic_management` defaults to
|
||||
true) with position dedup running at its 11 h default; the other features each default off, so
|
||||
the module is on out of the box but opt-in per feature. It was introduced in
|
||||
[meshtastic/firmware#9358](https://github.com/meshtastic/firmware/pull/9358).
|
||||
|
||||
This document covers the module's behaviour, with a deep dive on the two TMM-specific
|
||||
NodeInfo features - **direct-serve** (answering NodeInfo requests on another node's behalf)
|
||||
and the **throttling** that bounds it. The identity/traffic-state stores those features read
|
||||
from are documented separately in [node_info_stores.md](node_info_stores.md); this file owns
|
||||
the direct-serve and throttle behaviour, that file owns the stores.
|
||||
|
||||
Sources of truth: `src/modules/TrafficManagementModule.{h,cpp}`, defaults in
|
||||
`src/mesh/Default.h`.
|
||||
|
||||
---
|
||||
|
||||
## How it runs
|
||||
|
||||
- **Enablement is three-gated.** Compile-time `HAS_TRAFFIC_MANAGEMENT` (with the
|
||||
`MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT` build exclusion), then the runtime
|
||||
`moduleConfig.has_traffic_management` presence flag. While the runtime gate is off, the
|
||||
packet path, the maintenance sweep, the NodeDB write-through hooks, and the cache accessors
|
||||
all no-op - content, maintenance, and reads are keyed to the same condition.
|
||||
- **It runs before `RoutingModule`** in `callModules()`. Returning `STOP` from
|
||||
`handleReceived()` fully consumes a packet, so it is never rebroadcast; `CONTINUE` lets it
|
||||
proceed through normal relay handling.
|
||||
- **State is cheap.** Per-node traffic-shaping counters live in a flat 10-byte
|
||||
`UnifiedCacheEntry` array (position fingerprint, rate/unknown counters, modular tick
|
||||
stamps, a next-hop hint, and a 4-bit role fallback) - see
|
||||
[node_info_stores.md §3](node_info_stores.md). Direct-serve additionally reads the PSRAM
|
||||
NodeInfo payload cache (or the NodeDB fallback when that cache is absent).
|
||||
|
||||
## What it does
|
||||
|
||||
| Feature | Default | In one line |
|
||||
| ------------------------ | -------------- | -------------------------------------------------------------- |
|
||||
| Position dedup | on, 11 h | Suppresses a stationary sender's repeated position broadcasts. |
|
||||
| Per-sender rate limit | off | Caps how many transit packets one sender may spend per window. |
|
||||
| Unknown-packet filter | off | Drops a sender's undecryptable traffic past a threshold. |
|
||||
| NodeInfo direct response | off | Answers a NodeInfo request on the target's behalf (see below). |
|
||||
| Position precision clamp | channel-driven | Truncates relayed position to the channel's precision. |
|
||||
|
||||
Config lives under `moduleConfig.traffic_management`; the per-feature sections below give the
|
||||
exact fields, defaults, and behaviour. NodeInfo direct response has its own deep-dive sections
|
||||
after these.
|
||||
|
||||
### Position dedup
|
||||
|
||||
`position_min_interval_secs` (default 11 h; `0` disables). Drops a duplicate position from the
|
||||
same sender inside the interval, where "duplicate" means the same fingerprint on the channel's
|
||||
`position_precision` grid (firmware default 19-bit, ~90 m cells). Role caps only ever _shorten_
|
||||
the interval: **tracker / TAK tracker → 1 h**, **lost-and-found → 15 min**.
|
||||
|
||||
### Per-sender rate limit
|
||||
|
||||
`rate_limit_window_secs` + `rate_limit_max_packets` (default off; either `0` disables). Drops a
|
||||
sender's transit packets once it exceeds the budget within the window.
|
||||
|
||||
### Unknown-packet filter
|
||||
|
||||
`unknown_packet_threshold` (default `0` = off). Drops undecryptable traffic from a sender once it
|
||||
passes the threshold within a ~5 min window.
|
||||
|
||||
### NodeInfo direct response
|
||||
|
||||
`nodeinfo_direct_response_max_hops` (default `0` = off). When set, a neighbour that already
|
||||
holds the target's identity answers a unicast NodeInfo request on its behalf, saving the full
|
||||
round trip. This is TMM's most security-sensitive feature; the serve gates and the throttle
|
||||
that bounds it are covered in the two dedicated sections below.
|
||||
|
||||
### Position precision clamp
|
||||
|
||||
Driven by the channel's `position_precision` ceiling (else the 19-bit firmware default).
|
||||
`alterReceived()` truncates relayed position coordinates to that precision.
|
||||
|
||||
### Shelved
|
||||
|
||||
Present in the config surface but currently no-ops in the module, deferred until the right
|
||||
heuristics are settled: hop exhaustion for position/telemetry (`exhaust_hop_position` /
|
||||
`exhaust_hop_telemetry`) and `router_preserve_hops`. `alterReceived()` leaves rebroadcast hop
|
||||
handling untouched.
|
||||
|
||||
---
|
||||
|
||||
## NodeInfo direct response (direct-serve)
|
||||
|
||||
Normally a unicast NodeInfo request travels all the way to the target and the reply travels
|
||||
all the way back. On a large mesh that is several hops of airtime per lookup. When
|
||||
`nodeinfo_direct_response_max_hops > 0`, a neighbour that already holds the target's identity
|
||||
answers **on the target's behalf** with a spoofed reply, cutting the round trip to one hop.
|
||||
|
||||
**Data source.** The reply payload comes from the TMM NodeInfo payload cache (PSRAM-backed;
|
||||
full cached `User` plus provenance metadata) or, on builds without that cache, from the
|
||||
NodeDB fallback. Both are described in [node_info_stores.md §4](node_info_stores.md); this
|
||||
feature is a _consumer_ of them.
|
||||
|
||||
**Decision pipeline** (`shouldRespondToNodeInfo()`), in order - any failure returns `false`
|
||||
and the request is left to propagate normally:
|
||||
|
||||
1. **Eligibility** (checked by the caller): `nodeinfo_direct_response_max_hops > 0`,
|
||||
`NODEINFO_APP` portnum, `want_response`, and the packet is unicast, not to us, not from us.
|
||||
2. **Hop clamp** (`isMinHopsFromRequestor()`): respond only when the requester is within the
|
||||
role-clamped hop ceiling - **routers up to 3 hops** (`kRouterDefaultMaxHops`, may be
|
||||
lowered by config), **clients direct-only, 0 hops** (`kClientDefaultMaxHops`).
|
||||
3. **Identity lookup**: NodeInfo cache hit (cache path) or NodeDB fallback (fallback path).
|
||||
4. **Staleness gate (6 h)**: never vouch for a node not genuinely _heard_ within the serve
|
||||
window. Only a real observed frame stamps the recency bit - seeding and write-through are
|
||||
knowledge, not observation, so a silent node can never look alive to this path.
|
||||
5. **Key-provenance gate** (`TMM_NODEINFO_REPLAY_SIGNED_GATE`, default on): vouch only for
|
||||
an identity whose key is proven - XEdDSA-verified (directly or inherited from NodeDB) **or**
|
||||
manually verified out-of-band. Both paths honour both channels: the cache path via
|
||||
`keyProven()`, the NodeDB fallback path via `HAS_XEDDSA_SIGNED | IS_KEY_MANUALLY_VERIFIED`. A
|
||||
trust-on-first-use identity is left for the genuine node - or another cache-holder that _has_
|
||||
proof - to answer. Bypassed when PKI is compiled out.
|
||||
6. **Throttle** (`directResponseAllowed()`): see the next section.
|
||||
|
||||
**The spoofed reply.** On success TMM emits a NodeInfo reply with `from` set to the _target_
|
||||
(so the requester sees a valid answer), `to` the requester, `hop_limit = 0` (one hop only),
|
||||
`request_id` the original packet id, and the OK_TO_MQTT bit set from local
|
||||
`config.lora.config_ok_to_mqtt` policy. The requester's own identity claim in the request is
|
||||
**not** written back to NodeDB - a unicast NodeInfo is unsigned, so treating it as an
|
||||
identity update would be unauthenticated. `nodeinfo_cache_hits` counts only replies actually
|
||||
sent.
|
||||
|
||||
---
|
||||
|
||||
## Throttling direct responses
|
||||
|
||||
A direct reply is addressed to the requesting packet's `from` and spoofs the requested
|
||||
target - and **both fields are unauthenticated header data**. Without a bound, an attacker
|
||||
crafts requests carrying a victim's address as `from`, and every neighbour holding the target
|
||||
transmits at the victim: a reflector-amplification primitive. The throttle is the security
|
||||
core of this feature, checked immediately before a reply would go out so requests declined for
|
||||
other reasons never consume the budget.
|
||||
|
||||
**Three bounds**, all keyed off `clockMs()` and evaluated under `cacheLock`:
|
||||
|
||||
| Bound | Window | Bounds |
|
||||
| ------------------------------------------------ | ------ | ------------------------------------------------ |
|
||||
| Per requester (`kDirectResponsePerRequesterMs`) | 60 s | how much any single node can be made to receive |
|
||||
| Per target (`kDirectResponsePerTargetMs`) | 60 s | how often we vouch for the same identity |
|
||||
| Global airtime floor (`kDirectResponseGlobalMs`) | 1 s | total spoofed TX, regardless of key distribution |
|
||||
|
||||
**Mechanism.** The two per-key bounds are fixed **8-slot LRU tables in internal RAM**
|
||||
(`directRequesterSeen`, `directTargetSeen`) - _not_ the PSRAM NodeInfo cache - so they behave
|
||||
identically with and without PSRAM, on the cache path and the NodeDB-fallback path alike.
|
||||
Timestamps are full `uint32` milliseconds compared by wrap-safe subtraction, so there is no
|
||||
tick clock and no maintenance sweep to keep them honest. `directResponseAllowed(requester,
|
||||
target, now)` resolves a slot in _both_ tables before stamping either - so a reply one axis
|
||||
throttles never consumes the other axis's budget - then records the send on all three bounds.
|
||||
The global floor is a single stamp, checked first as the cheap common case.
|
||||
|
||||
**When a table fills.** For an unseen key with no free slot, `directResponseSlot()` evicts the
|
||||
**least-recently-used** entry (smallest last-reply time) and admits the new key. The LRU
|
||||
victim is by construction the entry closest to expiring anyway, so eviction is the
|
||||
lowest-cost choice. An attacker who cycles more than 8 distinct requesters or targets - easy,
|
||||
since both are unauthenticated - evicts entries and defeats _per-key_ throttling for the
|
||||
cycled keys; that is expected, and why the **global 1 s floor is the hard backstop**. It is a
|
||||
single stamp, cannot fill, and caps total spoofed replies at ~1/s no matter what. Per-key
|
||||
throttling degrades gracefully to the floor under pressure.
|
||||
|
||||
**Throttled is not dropped.** A throttled request returns `false`, which lets
|
||||
`handleReceived()` `CONTINUE`: the request forwards toward the genuine target (which can
|
||||
answer itself) rather than being black-holed. A requester whose first reply was lost on a
|
||||
noisy link would otherwise get silence for the whole window; repeats of the same packet id
|
||||
are already absorbed by the router's duplicate detection.
|
||||
|
||||
**Evolution.** The original design split throttling by path: a per-entry `respTick` stamp in
|
||||
each NodeInfo cache slot (cache path, 30 s, swept for wrap-safety) plus a single module-global
|
||||
stamp for the NodeDB fallback (30 s, neither per-requester nor per-target). Those two routes
|
||||
were unified into the symmetric per-requester + per-target RAM tables above, aligned to a
|
||||
single 60 s window, so both axes hold with and without PSRAM and the cache entry no longer
|
||||
carries throttle state.
|
||||
|
||||
---
|
||||
|
||||
## Tick clocks and wrap safety
|
||||
|
||||
Every per-node timestamp in TMM's caches is a free-running modular tick (uint8 or nibble) taken
|
||||
from `clockMs()` - never an absolute time. That is what keeps `UnifiedCacheEntry` at 10 bytes
|
||||
across up to 2048 entries. The cost is that modular subtraction is only correct while the true age
|
||||
stays below the counter's period, so every clock needs something to clear expired state before it
|
||||
aliases. (The direct-serve throttle above is the deliberate exception: full `uint32` milliseconds
|
||||
compared by wrap-safe subtraction, hence no tick and no sweep.)
|
||||
|
||||
| Clock | Tick / period | Window | Kept honest by |
|
||||
| ------------------ | -------------- | --------------- | -------------------------------------------------- |
|
||||
| pos | 6 min / 25.6 h | <=255 ticks | 60 s sweep (margin as low as 1 tick at the clamp) |
|
||||
| rate | 5 min / 80 min | <=15 ticks | sweep + read-time window reset (`isRateLimited()`) |
|
||||
| unknown | 1 min / 16 min | 12 ticks | sweep + read-time window reset |
|
||||
| NodeInfo `obsTick` | 3 min / 12.8 h | 120 ticks (6 h) | sweep only |
|
||||
|
||||
`obsTick` is the sharp case: `maintainNodeInfoCacheLocked()` clearing `hasObserved` is the
|
||||
_sole_ guarantee the 6 h serve gate never reads an aliased stamp. That makes the sweep a
|
||||
compile-time invariant - guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never
|
||||
`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring `purgeAll()`:
|
||||
a build that has the cache always has its sweep.
|
||||
|
||||
The stores these clocks stamp, and the warm tier's contrasting absolute timestamps, are described
|
||||
in [node_info_stores.md](node_info_stores.md).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All tunables live under `moduleConfig.traffic_management`; the whole module is gated by the
|
||||
`has_traffic_management` presence flag, and each per-feature section above lists its own
|
||||
field(s) and default. Two related sets of knobs are **firmware constants, not config**: the
|
||||
role-based position caps `default_traffic_mgmt_tracker_position_min_interval_secs` (1 h) and
|
||||
`default_traffic_mgmt_lost_and_found_position_min_interval_secs` (15 min), and the direct-serve
|
||||
throttle windows (the `kDirectResponse*Ms` constants).
|
||||
|
||||
## See also
|
||||
|
||||
- [node_info_stores.md](node_info_stores.md) - the NodeDB hot store, warm tier, TMM NodeInfo
|
||||
payload cache, and unified cache that the direct-serve path reads from, plus their trust,
|
||||
provenance, and anti-entropy model.
|
||||
@@ -233,9 +233,26 @@ def _assert_isr_handlers_survived(source, target, env):
|
||||
# and the build stays green. Turn that into a red build:
|
||||
# 1. the linked variant.cpp.o must not be an LTO object (proves the -fno-lto recompile fired);
|
||||
# 2. any override the object defines strong must resolve strong in the ELF.
|
||||
#
|
||||
# The list must also cover Meshtastic's OWN weak variant hooks, not just the core's. Those have
|
||||
# a second, independent way to vanish: their weak default AND their call site sit in the same
|
||||
# LTO'd translation unit (src/main.cpp, src/platform/nrf52/main-nrf52.cpp), so GCC inlines the
|
||||
# empty body at the call site and never reaches for the variant's strong override -- the
|
||||
# -fno-lto middleware above cannot help, the caller is the problem. The definitions carry
|
||||
# __attribute__((noinline)) to prevent it; this guard is what catches a future one that forgets.
|
||||
# Regression that motivated the extension: 2.8 dropped earlyInitVariant() on the muzi R1 Neo, so
|
||||
# DCDC_EN_HOLD/NRF_ON were never driven and the IO controller read the nRF as stuck in DFU
|
||||
# (purple LED). The build stayed green because only _Z11initVariantv was listed here.
|
||||
_VARIANT_OVERRIDES = (
|
||||
"_Z11initVariantv",
|
||||
) # extend if the core grows more weak variant hooks
|
||||
"_Z11initVariantv", # core hook (cores/nRF5/main.cpp)
|
||||
"_Z16earlyInitVariantv", # src/main.cpp -- pre-peripheral board bring-up
|
||||
"_Z15lateInitVariantv", # src/main.cpp -- post-radio board bring-up
|
||||
"_Z16variant_shutdownv", # main-nrf52.cpp -- pin parking before System OFF
|
||||
"_Z21variant_nrf52LoopHookv", # main-nrf52.cpp -- per-loop variant hook
|
||||
"_Z31variant_enableBatteryLpcompWakev", # main-nrf52.cpp -- LPCOMP wake opt-out
|
||||
"_Z20variantDefaultConfigv", # NodeDB.cpp -- per-board config defaults
|
||||
"_Z26variantDefaultModuleConfigv", # NodeDB.cpp -- per-board module defaults
|
||||
) # extend if the core (or Meshtastic) grows more weak variant hooks
|
||||
|
||||
|
||||
def _assert_variant_survived(source, target, env):
|
||||
@@ -289,15 +306,19 @@ def _assert_variant_survived(source, target, env):
|
||||
):
|
||||
problems.append(
|
||||
"%s is strong in variant.cpp.o but weak/absent in the ELF "
|
||||
"(LTO resolved the core's call to the empty weak stub)" % sym
|
||||
"(LTO resolved the call to the empty weak stub)" % sym
|
||||
)
|
||||
if problems:
|
||||
sys.stderr.write(
|
||||
"\n*** nrf52 LTO guard: board variant DROPPED from the image ***\n%s\n"
|
||||
"The variant's early hardware setup (initVariant) will never run on this board.\n"
|
||||
"Check _is_board_variant() in extra_scripts/nrf52_lto.py -- middleware nodes are\n"
|
||||
"$BUILD_DIR-mirrored; match srcnode() paths, not node.get_abspath().\n\n"
|
||||
% "\n".join(" - " + p for p in problems)
|
||||
"\n*** nrf52 LTO guard: board variant override DROPPED from the image ***\n%s\n"
|
||||
"That board hardware setup silently will not run. Two possible causes:\n"
|
||||
" 1. The weak default and its CALL SITE share one LTO'd translation unit\n"
|
||||
" (src/main.cpp, src/platform/nrf52/main-nrf52.cpp, src/mesh/NodeDB.cpp), so GCC\n"
|
||||
" inlined the empty body and never reached the override. Fix: mark BOTH the weak\n"
|
||||
" declaration and definition __attribute__((noinline)) -- see earlyInitVariant().\n"
|
||||
" 2. The -fno-lto middleware stopped matching the variant. Check _is_board_variant()\n"
|
||||
" below -- middleware nodes are $BUILD_DIR-mirrored, so match srcnode() paths,\n"
|
||||
" not node.get_abspath().\n\n" % "\n".join(" - " + p for p in problems)
|
||||
)
|
||||
from SCons.Script import Exit
|
||||
|
||||
|
||||
+22
-19
@@ -132,12 +132,12 @@ lib_deps =
|
||||
[radiolib_base]
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
||||
https://github.com/jgromes/RadioLib/archive/6d8934836678d8894e3d556550475b37dce3e2b6.zip
|
||||
https://github.com/jgromes/RadioLib/archive/510e00cfb05bbc3c2b7b524262785454944adb6e.zip
|
||||
|
||||
[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/4f150e5b622fc2f5c8ac26b81bf843e14c33ba33.zip
|
||||
https://github.com/meshtastic/device-ui/archive/44b86e1b6842e9c67b1ed935753304b0313605da.zip
|
||||
custom_sdkconfig =
|
||||
# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set
|
||||
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
|
||||
@@ -164,7 +164,7 @@ lib_deps =
|
||||
# renovate: datasource=github-tags depName=Adafruit DPS310 packageName=adafruit/Adafruit_DPS310
|
||||
https://github.com/adafruit/Adafruit_DPS310/archive/refs/tags/1.1.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SH110x packageName=adafruit/Adafruit_SH110x
|
||||
https://github.com/adafruit/Adafruit_SH110x/archive/refs/tags/2.1.14.zip
|
||||
https://github.com/adafruit/Adafruit_SH110x/archive/2.1.15.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MCP9808 packageName=adafruit/Adafruit_MCP9808_Library
|
||||
https://github.com/adafruit/Adafruit_MCP9808_Library/archive/refs/tags/2.0.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit INA260 packageName=adafruit/Adafruit_INA260
|
||||
@@ -193,6 +193,8 @@ lib_deps =
|
||||
https://github.com/DFRobot/DFRobot_RTU/archive/refs/tags/V1.0.6.zip
|
||||
# renovate: datasource=git-refs depName=DFRobot_RainfallSensor packageName=https://github.com/DFRobot/DFRobot_RainfallSensor gitBranch=master
|
||||
https://github.com/DFRobot/DFRobot_RainfallSensor/archive/38fea5e02b40a5430be6dab39a99a6f6347d667e.zip
|
||||
# renovate: datasource=github-tags depName=SparkFun AS3935 packageName=sparkfun/SparkFun_AS3935_Lightning_Detector_Arduino_Library
|
||||
https://github.com/sparkfun/SparkFun_AS3935_Lightning_Detector_Arduino_Library/archive/refs/tags/v1.4.9.zip
|
||||
# renovate: datasource=github-tags depName=INA226 packageName=robtillaart/INA226
|
||||
https://github.com/RobTillaart/INA226/archive/refs/tags/0.6.6.zip
|
||||
# renovate: datasource=github-tags depName=SparkFun MAX3010x packageName=sparkfun/SparkFun_MAX3010x_Sensor_Library
|
||||
@@ -230,8 +232,11 @@ lib_deps =
|
||||
# renovate: datasource=github-tags depName=Seeed_PM2_5_sensor_HM3301 packageName=meshtastic/Seeed_PM2_5_sensor_HM3301
|
||||
https://github.com/meshtastic/Seeed_PM2_5_sensor_HM3301/archive/2704ca254c7e2136c52ac23198dd05f5ba1e2f04.zip
|
||||
|
||||
; Common environmental sensor libraries (not included in native / portduino)
|
||||
[environmental_extra_common]
|
||||
; Extra environmental sensor libraries (not included in native / portduino).
|
||||
; BME680/BME688 IAQ comes from the in-tree open estimator (BME680IaqEstimator);
|
||||
; the proprietary Bosch BSEC blob (measured ~37-39 KB flash + ~4-5 KB static
|
||||
; RAM per image) is intentionally not linked anywhere.
|
||||
[environmental_extra]
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=Adafruit BMP3XX packageName=adafruit/Adafruit_BMP3XX
|
||||
https://github.com/adafruit/Adafruit_BMP3XX/archive/refs/tags/2.1.6.zip
|
||||
@@ -247,21 +252,19 @@ lib_deps =
|
||||
closedcube/ClosedCube OPT3001@1.1.2
|
||||
# renovate: datasource=git-refs depName=meshtastic-DFRobot_LarkWeatherStation packageName=https://github.com/meshtastic/DFRobot_LarkWeatherStation gitBranch=master
|
||||
https://github.com/meshtastic/DFRobot_LarkWeatherStation/archive/4de3a9cadef0f6a5220a8a906cf9775b02b0040d.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
|
||||
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x
|
||||
https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
|
||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/1.1.1.zip
|
||||
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
|
||||
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
|
||||
# renovate: datasource=custom.pio depName=Adafruit ADS1X15 packageName=adafruit/library/Adafruit ADS1X15 Library
|
||||
https://github.com/adafruit/Adafruit_ADS1X15/archive/refs/tags/2.6.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit DS248x packageName=adafruit/Adafruit_DS248x
|
||||
https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip
|
||||
|
||||
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
|
||||
[environmental_extra]
|
||||
lib_deps =
|
||||
${environmental_extra_common.lib_deps}
|
||||
# renovate: datasource=github-tags depName=Bosch BSEC2 packageName=boschsensortec/Bosch-BSEC2-Library
|
||||
https://github.com/boschsensortec/Bosch-BSEC2-Library/archive/refs/tags/1.10.2610.zip
|
||||
# renovate: datasource=github-tags depName=Bosch BME68x packageName=boschsensortec/Bosch-BME68x-Library
|
||||
https://github.com/boschsensortec/Bosch-BME68x-Library/archive/refs/tags/v1.3.40408.zip
|
||||
|
||||
; Environmental sensors without BSEC (saves ~3.5KB DRAM for original ESP32 targets)
|
||||
[environmental_extra_no_bsec]
|
||||
lib_deps =
|
||||
${environmental_extra_common.lib_deps}
|
||||
# renovate: datasource=github-tags depName=Adafruit_BME680 packageName=adafruit/Adafruit_BME680
|
||||
https://github.com/adafruit/Adafruit_BME680/archive/refs/tags/2.0.6.zip
|
||||
+1
-1
Submodule protobufs updated: 6ceceae4b1...aca181b97b.
@@ -79,6 +79,7 @@ class AudioThread : public concurrency::OSThread
|
||||
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
|
||||
|
||||
@@ -48,6 +48,16 @@ extern MemGet memGet;
|
||||
|
||||
#define DEBUG_PORT (*console) // Serial debug port
|
||||
|
||||
// LOG_TRACE costs no flash unless enabled: -DMESHTASTIC_TRACE_LOGGING(=1) turns it on, =0 forces it off.
|
||||
// Default is on only for portduino (traceFilename packet traces, logoutputlevel=trace), off elsewhere.
|
||||
#ifndef MESHTASTIC_TRACE_LOGGING
|
||||
#ifdef ARCH_PORTDUINO
|
||||
#define MESHTASTIC_TRACE_LOGGING 1
|
||||
#else
|
||||
#define MESHTASTIC_TRACE_LOGGING 0
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef USE_SEGGER
|
||||
// #undef DEBUG_PORT
|
||||
#define LOG_DEBUG(...) SEGGER_RTT_printf(0, __VA_ARGS__)
|
||||
@@ -55,16 +65,24 @@ extern MemGet memGet;
|
||||
#define LOG_WARN(...) SEGGER_RTT_printf(0, __VA_ARGS__)
|
||||
#define LOG_ERROR(...) SEGGER_RTT_printf(0, __VA_ARGS__)
|
||||
#define LOG_CRIT(...) SEGGER_RTT_printf(0, __VA_ARGS__)
|
||||
#if MESHTASTIC_TRACE_LOGGING
|
||||
#define LOG_TRACE(...) SEGGER_RTT_printf(0, __VA_ARGS__)
|
||||
#else
|
||||
#define LOG_TRACE(...)
|
||||
#endif
|
||||
#else
|
||||
#if defined(DEBUG_PORT) && !defined(DEBUG_MUTE)
|
||||
#define LOG_DEBUG(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_DEBUG, __VA_ARGS__)
|
||||
#define LOG_INFO(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_INFO, __VA_ARGS__)
|
||||
#define LOG_WARN(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_WARN, __VA_ARGS__)
|
||||
#define LOG_ERROR(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_ERROR, __VA_ARGS__)
|
||||
#define LOG_CRIT(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_CRIT, __VA_ARGS__)
|
||||
#if MESHTASTIC_TRACE_LOGGING
|
||||
#define LOG_TRACE(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_TRACE, __VA_ARGS__)
|
||||
#else
|
||||
#define LOG_TRACE(...)
|
||||
#endif
|
||||
#else
|
||||
#define LOG_DEBUG(...)
|
||||
#define LOG_INFO(...)
|
||||
#define LOG_WARN(...)
|
||||
|
||||
+45
-13
@@ -129,10 +129,13 @@ bool renameFile(const char *pathFrom, const char *pathTo)
|
||||
#endif
|
||||
}
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
#ifdef ARCH_ESP32
|
||||
#include <esp_heap_caps.h>
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Platform-agnostic filesystem format / wipe.
|
||||
@@ -250,6 +253,12 @@ 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.
|
||||
*
|
||||
@@ -268,18 +277,41 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, s
|
||||
if (wasLimited)
|
||||
*wasLimited = false;
|
||||
#ifdef FSCom
|
||||
#if defined(__cpp_exceptions) || defined(__EXCEPTIONS)
|
||||
size_t reservedCount = maxCount;
|
||||
// Size the vector once, up front, to what the heap can actually hand out, and cap the walk at that
|
||||
// count so push_back() never has to grow it. Any allocation that fails here goes through operator
|
||||
// new and raises std::bad_alloc; the ESP32 framework is built with CONFIG_COMPILER_CXX_EXCEPTIONS=n,
|
||||
// so there is no unwinder and a throw is std::terminate() -> abort() -> reboot. That fires on the
|
||||
// very first client handshake whenever the heap is fragmented (WiFi + TLS up, no PSRAM), which is
|
||||
// exactly when this runs. So: never let reserve() be the thing that discovers there is no room.
|
||||
// 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.
|
||||
while (reservedCount > 0) {
|
||||
try {
|
||||
filenames.reserve(reservedCount);
|
||||
void *probe = malloc(reservedCount * sizeof(meshtastic_FileInfo));
|
||||
if (probe) {
|
||||
free(probe);
|
||||
break;
|
||||
} catch (const std::bad_alloc &) {
|
||||
reservedCount /= 2;
|
||||
} catch (const std::length_error &) {
|
||||
reservedCount /= 2;
|
||||
}
|
||||
reservedCount /= 2;
|
||||
}
|
||||
#endif
|
||||
if (reservedCount == 0) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
@@ -290,7 +322,7 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, s
|
||||
*wasLimited = true;
|
||||
maxCount = reservedCount;
|
||||
}
|
||||
#endif
|
||||
filenames.reserve(reservedCount);
|
||||
collectFiles(dirname, levels, maxCount, filenames, wasLimited);
|
||||
#endif
|
||||
return filenames;
|
||||
@@ -340,7 +372,7 @@ void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", filepath, file.size());
|
||||
LOG_TRACE(" %s (%i Bytes)", filepath, file.size());
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
@@ -394,7 +426,7 @@ void fsInit()
|
||||
#if defined(ARCH_ESP32)
|
||||
LOG_DEBUG("Filesystem files (%d/%d Bytes):", FSCom.usedBytes(), FSCom.totalBytes());
|
||||
#else
|
||||
LOG_DEBUG("Filesystem files:");
|
||||
LOG_TRACE("Filesystem files:");
|
||||
#endif
|
||||
listDir("/", 10);
|
||||
#endif
|
||||
|
||||
+2
-3
@@ -2,6 +2,7 @@
|
||||
#include "NodeDB.h"
|
||||
#include "Status.h"
|
||||
#include "configuration.h"
|
||||
#include "gps/GPSLog.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
namespace meshtastic
|
||||
@@ -92,9 +93,7 @@ class GPSStatus : public Status
|
||||
|
||||
bool matches(const GPSStatus *newStatus) const
|
||||
{
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("GPSStatus.match() new pos@%x to old pos@%x", newStatus->p.timestamp, p.timestamp);
|
||||
#endif
|
||||
LOG_DEBUG_GPS("GPSStatus.match() new pos@%x to old pos@%x", newStatus->p.timestamp, p.timestamp);
|
||||
return (newStatus->hasLock != hasLock || newStatus->isConnected != isConnected || newStatus->hasTime != hasTime ||
|
||||
newStatus->isPowerSaving != isPowerSaving || newStatus->p.latitude_i != p.latitude_i ||
|
||||
newStatus->p.longitude_i != p.longitude_i || newStatus->p.altitude != p.altitude ||
|
||||
|
||||
+16
-13
@@ -5,6 +5,8 @@
|
||||
#include "NodeDB.h"
|
||||
#include "SPILock.h"
|
||||
#include "SafeFile.h"
|
||||
#include "Throttle.h"
|
||||
#include "UptimeClock.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "memory/MemAudit.h"
|
||||
#include <cstring> // memcpy
|
||||
@@ -42,6 +44,10 @@ static inline void resetMessagePool()
|
||||
// If not enough space remains, wrap around (ring buffer style)
|
||||
static inline uint16_t storeTextInPool(const char *src, size_t len)
|
||||
{
|
||||
// Pool allocation can fail at boot; getTextFromPool() already maps offset 0 to "" in that case
|
||||
if (!g_messagePool)
|
||||
return 0;
|
||||
|
||||
if (len >= MAX_MESSAGE_SIZE)
|
||||
len = MAX_MESSAGE_SIZE - 1;
|
||||
|
||||
@@ -82,7 +88,9 @@ static inline void assignTimestamp(StoredMessage &sm)
|
||||
sm.timestamp = nowSecs;
|
||||
sm.isBootRelative = false;
|
||||
} else {
|
||||
sm.timestamp = millis() / 1000;
|
||||
// Uptime seconds, not millis()/1000: a stamp taken before the 32-bit wrap otherwise reads as
|
||||
// newer than "now" afterwards, and upgradeBootRelativeTimestamps() then declines to heal it.
|
||||
sm.timestamp = Time::getUptimeSecs();
|
||||
sm.isBootRelative = true;
|
||||
}
|
||||
}
|
||||
@@ -130,18 +138,13 @@ static inline uint32_t autosaveIntervalMs()
|
||||
return sec * 1000UL;
|
||||
}
|
||||
|
||||
static inline bool reachedMs(uint32_t now, uint32_t target)
|
||||
{
|
||||
return (int32_t)(now - target) >= 0;
|
||||
}
|
||||
|
||||
// Mark new messages in RAM that need to be saved later
|
||||
static inline void markMessageStoreUnsaved()
|
||||
{
|
||||
g_messageStoreHasUnsavedChanges = true;
|
||||
|
||||
if (g_lastAutoSaveMs == 0) {
|
||||
g_lastAutoSaveMs = millis();
|
||||
g_lastAutoSaveMs = Time::getMillis();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,14 +154,14 @@ static inline void autosaveTick(MessageStore *store)
|
||||
if (!store)
|
||||
return;
|
||||
|
||||
uint32_t now = millis();
|
||||
uint32_t now = Time::getMillis();
|
||||
|
||||
if (g_lastAutoSaveMs == 0) {
|
||||
g_lastAutoSaveMs = now;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reachedMs(now, g_lastAutoSaveMs + autosaveIntervalMs()))
|
||||
if (Throttle::isWithinTimespanMs(g_lastAutoSaveMs, autosaveIntervalMs()))
|
||||
return;
|
||||
|
||||
// Autosave interval reached, only save if there are unsaved messages.
|
||||
@@ -336,7 +339,7 @@ void MessageStore::saveToFlash()
|
||||
|
||||
// Reset autosave state after any save
|
||||
g_messageStoreHasUnsavedChanges = false;
|
||||
g_lastAutoSaveMs = millis();
|
||||
g_lastAutoSaveMs = Time::getMillis();
|
||||
}
|
||||
|
||||
void MessageStore::loadFromFlash()
|
||||
@@ -375,7 +378,7 @@ void MessageStore::loadFromFlash()
|
||||
#endif
|
||||
// Loading messages does not trigger an autosave
|
||||
g_messageStoreHasUnsavedChanges = false;
|
||||
g_lastAutoSaveMs = millis();
|
||||
g_lastAutoSaveMs = Time::getMillis();
|
||||
}
|
||||
|
||||
#else
|
||||
@@ -406,7 +409,7 @@ void MessageStore::clearAllMessages()
|
||||
|
||||
#if ENABLE_MESSAGE_PERSISTENCE
|
||||
g_messageStoreHasUnsavedChanges = false;
|
||||
g_lastAutoSaveMs = millis();
|
||||
g_lastAutoSaveMs = Time::getMillis();
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -544,7 +547,7 @@ void MessageStore::upgradeBootRelativeTimestamps()
|
||||
if (nowSecs == 0)
|
||||
return; // Still no valid RTC
|
||||
|
||||
uint32_t bootNow = millis() / 1000;
|
||||
uint32_t bootNow = Time::getUptimeSecs();
|
||||
|
||||
auto fix = [&](std::deque<StoredMessage> &dq) {
|
||||
for (auto &m : dq) {
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ struct StoredMessage {
|
||||
uint8_t channelIndex; // Channel index used
|
||||
uint32_t dest; // Destination node (broadcast or direct)
|
||||
MessageType type; // Derived from dest (explicit classification)
|
||||
bool isBootRelative; // true = millis()/1000 fallback; false = epoch/RTC absolute
|
||||
bool isBootRelative; // true = Time::getUptimeSecs() fallback; false = epoch/RTC absolute
|
||||
AckStatus ackStatus; // Delivery status (only meaningful for our own sent messages)
|
||||
|
||||
// Text storage metadata - rebuilt from flash at boot
|
||||
|
||||
+128
-30
@@ -606,7 +606,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
// get current flow from INA sensor - negative value means power flowing
|
||||
// into the battery default assuming BATTERY+ <--> INA_VIN+ <--> SHUNT
|
||||
// RESISTOR <--> INA_VIN- <--> LOAD
|
||||
LOG_DEBUG("Using INA on I2C addr 0x%x for charging detection", config.power.device_battery_ina_address);
|
||||
LOG_TRACE("Using INA on I2C addr 0x%x for charging detection", config.power.device_battery_ina_address);
|
||||
#if defined(INA_CHARGING_DETECTION_INVERT)
|
||||
return getINACurrent() > 0;
|
||||
#else
|
||||
@@ -837,12 +837,13 @@ bool Power::setup()
|
||||
|
||||
void Power::powerCommandsCheck()
|
||||
{
|
||||
if (rebootAtMsec && millis() > rebootAtMsec) {
|
||||
// 0 means "not scheduled" for both, and reads as long expired - test it first.
|
||||
if (rebootAtMsec && Throttle::deadlinePassed(rebootAtMsec)) {
|
||||
LOG_INFO("Rebooting");
|
||||
reboot();
|
||||
}
|
||||
|
||||
if (shutdownAtMsec && millis() > shutdownAtMsec) {
|
||||
if (shutdownAtMsec && Throttle::deadlinePassed(shutdownAtMsec)) {
|
||||
shutdownAtMsec = 0;
|
||||
shutdown();
|
||||
}
|
||||
@@ -884,9 +885,10 @@ void Power::reboot()
|
||||
#elif defined(ARCH_STM32)
|
||||
HAL_NVIC_SystemReset();
|
||||
#else
|
||||
rebootAtMsec = -1;
|
||||
LOG_WARN("FIXME implement reboot for this platform; some settings "
|
||||
"need restart to apply");
|
||||
// 0 disarms; UINT32_MAX would read as long expired and reboot-loop.
|
||||
rebootAtMsec = 0;
|
||||
LOG_WARN("FIXME implement reboot for this platform. Note that some settings "
|
||||
"require a restart to be applied");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1117,6 +1119,7 @@ int32_t Power::runOnce()
|
||||
{
|
||||
readPowerStatus();
|
||||
logHeapUsage();
|
||||
lipoChargerRetry();
|
||||
|
||||
#ifdef HAS_PMU
|
||||
// WE no longer use the IRQ line to wake the CPU (due to false wakes from
|
||||
@@ -1139,8 +1142,10 @@ int32_t Power::runOnce()
|
||||
// cancel action also turns the screen on and off.
|
||||
if (PMU->isPekeyShortPressIrq()) {
|
||||
LOG_INFO("Input: Corona Button Click");
|
||||
InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_CANCEL, .kbchar = 0, .touchX = 0, .touchY = 0};
|
||||
inputBroker->injectInputEvent(&event);
|
||||
if (inputBroker) {
|
||||
InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_CANCEL, .kbchar = 0, .touchX = 0, .touchY = 0};
|
||||
inputBroker->injectInputEvent(&event);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
/*
|
||||
@@ -1443,6 +1448,48 @@ bool Power::axpChipInit()
|
||||
PMU->disablePowerOutput(XPOWERS_DLDO1); // Invalid power channel, it does not exist
|
||||
PMU->disablePowerOutput(XPOWERS_DLDO2); // Invalid power channel, it does not exist
|
||||
PMU->disablePowerOutput(XPOWERS_VBACKUP);
|
||||
} else if (HW_VENDOR == meshtastic_HardwareModel_T_WATCH_ULTRA) {
|
||||
PMU->clearIrqStatus();
|
||||
|
||||
// Turn off the PMU charging indicator light, no physical connection
|
||||
PMU->setChargingLedMode(XPOWERS_CHG_LED_OFF); // NO LED
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO1, 3300); // SD Card
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO1);
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300); // Display
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO2);
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO3, 3300); // LoRa
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO3);
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO4, 1800); // Sensor
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO4);
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); // GPS
|
||||
PMU->enablePowerOutput(XPOWERS_BLDO1);
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_BLDO2, 3300); // Speaker
|
||||
PMU->enablePowerOutput(XPOWERS_BLDO2);
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_VBACKUP, 3300); // RTC Button battery
|
||||
PMU->enablePowerOutput(XPOWERS_VBACKUP);
|
||||
|
||||
// PMU->enablePowerOutput(XPOWERS_DLDO1); // NFC
|
||||
|
||||
// UNUSED POWER CHANNEL
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC2);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC3);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC4);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC5);
|
||||
PMU->disablePowerOutput(XPOWERS_CPULDO);
|
||||
|
||||
// Enable Measure
|
||||
PMU->enableBattDetection();
|
||||
PMU->enableVbusVoltageMeasure();
|
||||
PMU->enableBattVoltageMeasure();
|
||||
PMU->enableSystemVoltageMeasure();
|
||||
PMU->enableTemperatureMeasure();
|
||||
} else if (HW_VENDOR == meshtastic_HardwareModel_TBEAM_BPF) {
|
||||
// T-Beam BPF rail map (per schematic LilyGo_TBeam_BPF r2025-05-08):
|
||||
// DCDC1 -> ESP32 + OLED 3V3 (always on, protected)
|
||||
@@ -1733,13 +1780,32 @@ bool Power::cw2015Init()
|
||||
|
||||
#if defined(HAS_PPM) && HAS_PPM
|
||||
|
||||
// The gauge is soldered on, so a failed init means wedged rather than absent - retry from
|
||||
// the power thread before writing it off.
|
||||
#define BQ27220_INIT_ATTEMPTS 3
|
||||
#define BQ27220_RETRY_INTERVAL_MS (60 * 1000)
|
||||
|
||||
/**
|
||||
* Adapter class for BQ25896/BQ27220 Lipo battery charger.
|
||||
*
|
||||
* The gauge only adds time-to-full/empty, so its failure must not take the charger down.
|
||||
*/
|
||||
class LipoCharger : public HasBatteryLevel
|
||||
{
|
||||
private:
|
||||
BQ27220 *bq = nullptr;
|
||||
uint8_t gaugeAttemptsLeft = BQ27220_INIT_ATTEMPTS;
|
||||
uint32_t lastGaugeAttemptMs = 0;
|
||||
|
||||
// An aborted transfer leaves the i2c_master driver holding a stale transaction, which
|
||||
// the next transfer trips over. Deleting the bus frees it along with the interrupt.
|
||||
void recoverI2CBus()
|
||||
{
|
||||
#ifdef ARCH_ESP32
|
||||
Wire.end();
|
||||
Wire.begin(I2C_SDA, I2C_SCL);
|
||||
#endif
|
||||
}
|
||||
|
||||
public:
|
||||
/**
|
||||
@@ -1786,24 +1852,46 @@ class LipoCharger : public HasBatteryLevel
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (bq == nullptr) {
|
||||
bq = new BQ27220;
|
||||
bq->setDefaultCapacity(BQ27220_DESIGN_CAPACITY);
|
||||
gaugeRunOnce();
|
||||
// Ready on the charger alone, so Power stays enabled and can retry the gauge later.
|
||||
return true;
|
||||
}
|
||||
|
||||
bool result = bq->init();
|
||||
if (result) {
|
||||
LOG_DEBUG("BQ27220 design capacity: %d", bq->getDesignCapacity());
|
||||
LOG_DEBUG("BQ27220 fullCharge capacity: %d", bq->getFullChargeCapacity());
|
||||
LOG_DEBUG("BQ27220 remaining capacity: %d", bq->getRemainingCapacity());
|
||||
return true;
|
||||
} else {
|
||||
LOG_WARN("BQ27220 init failed");
|
||||
delete bq;
|
||||
bq = nullptr;
|
||||
return false;
|
||||
}
|
||||
/// Bring up the BQ27220 fuel gauge, unless it is already up or out of attempts
|
||||
void gaugeRunOnce()
|
||||
{
|
||||
if (bq != nullptr || gaugeAttemptsLeft == 0)
|
||||
return;
|
||||
if (gaugeAttemptsLeft < BQ27220_INIT_ATTEMPTS &&
|
||||
Throttle::isWithinTimespanMs(lastGaugeAttemptMs, BQ27220_RETRY_INTERVAL_MS))
|
||||
return;
|
||||
|
||||
lastGaugeAttemptMs = millis();
|
||||
gaugeAttemptsLeft--;
|
||||
|
||||
// Cheap probe first: a silent gauge costs one transaction instead of the
|
||||
// multi-second unseal/reset/provision sequence inside init().
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDRESS);
|
||||
if (Wire.endTransmission() != 0) {
|
||||
LOG_WARN("BQ27220 not responding at 0x%x", BQ27220_I2C_ADDRESS);
|
||||
return;
|
||||
}
|
||||
return false;
|
||||
|
||||
bq = new BQ27220;
|
||||
bq->setDefaultCapacity(BQ27220_DESIGN_CAPACITY);
|
||||
|
||||
if (bq->init()) {
|
||||
LOG_DEBUG("BQ27220 design capacity: %d", bq->getDesignCapacity());
|
||||
LOG_DEBUG("BQ27220 fullCharge capacity: %d", bq->getFullChargeCapacity());
|
||||
LOG_DEBUG("BQ27220 remaining capacity: %d", bq->getRemainingCapacity());
|
||||
return;
|
||||
}
|
||||
|
||||
delete bq;
|
||||
bq = nullptr;
|
||||
// init() bails out mid-sequence, so hand the next bus user a sane driver state.
|
||||
recoverI2CBus();
|
||||
LOG_WARN("BQ27220 init failed (%d retries left), use BQ25896 for battery state", (int)gaugeAttemptsLeft);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1819,7 +1907,7 @@ class LipoCharger : public HasBatteryLevel
|
||||
/**
|
||||
* The raw voltage of the battery in millivolts, or NAN if unknown
|
||||
*/
|
||||
virtual uint16_t getBattVoltage() override { return bq->getVoltage(); }
|
||||
virtual uint16_t getBattVoltage() override { return bq ? bq->getVoltage() : PPM->getBattVoltage(); }
|
||||
|
||||
/**
|
||||
* return true if there is a battery installed in this unit
|
||||
@@ -1837,11 +1925,13 @@ class LipoCharger : public HasBatteryLevel
|
||||
virtual bool isCharging() override
|
||||
{
|
||||
bool isCharging = PPM->isCharging();
|
||||
if (isCharging) {
|
||||
LOG_DEBUG("BQ27220 time to full charge: %d min", bq->getTimeToFull());
|
||||
} else {
|
||||
if (!PPM->isVbusIn()) {
|
||||
LOG_DEBUG("BQ27220 time to empty: %d min (%d mAh)", bq->getTimeToEmpty(), bq->getRemainingCapacity());
|
||||
if (bq) {
|
||||
if (isCharging) {
|
||||
LOG_TRACE("BQ27220 time to full charge: %d min", bq->getTimeToFull());
|
||||
} else {
|
||||
if (!PPM->isVbusIn()) {
|
||||
LOG_TRACE("BQ27220 time to empty: %d min (%d mAh)", bq->getTimeToEmpty(), bq->getRemainingCapacity());
|
||||
}
|
||||
}
|
||||
}
|
||||
return isCharging;
|
||||
@@ -1863,6 +1953,12 @@ bool Power::lipoChargerInit()
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Retry a fuel gauge that did not come up during setup
|
||||
void Power::lipoChargerRetry()
|
||||
{
|
||||
lipoCharger.gaugeRunOnce();
|
||||
}
|
||||
|
||||
#else
|
||||
/**
|
||||
* The Lipo battery level sensor is unavailable - default to AnalogBatteryLevel
|
||||
@@ -1871,6 +1967,8 @@ bool Power::lipoChargerInit()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
void Power::lipoChargerRetry() {}
|
||||
#endif
|
||||
|
||||
#ifdef HELTEC_MESH_SOLAR
|
||||
|
||||
@@ -121,6 +121,8 @@ class Power : public concurrency::OSThread
|
||||
bool max17048Init();
|
||||
/// Setup a Lipo charger
|
||||
bool lipoChargerInit();
|
||||
/// Retry a fuel gauge that did not come up during setup
|
||||
void lipoChargerRetry();
|
||||
/// Setup a meshSolar battery sensor
|
||||
bool meshSolarInit();
|
||||
/// Setup a serial battery sensor
|
||||
|
||||
+13
-11
@@ -173,23 +173,25 @@ static void lsIdle()
|
||||
powerFSM.trigger(EVENT_SERIAL_CONNECTED);
|
||||
break;
|
||||
|
||||
default:
|
||||
// We woke for some other reason (button press, device IRQ interrupt)
|
||||
|
||||
#ifdef BUTTON_PIN
|
||||
bool pressed = !digitalRead(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN);
|
||||
#else
|
||||
case ESP_SLEEP_WAKEUP_GPIO: {
|
||||
bool pressed = false;
|
||||
#if defined(BUTTON_PIN)
|
||||
pressed = !digitalRead(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN);
|
||||
#elif defined(KB_INT)
|
||||
// keyboard press (probably) triggered GPIO interrupt
|
||||
pressed = true;
|
||||
#endif
|
||||
if (pressed) { // If we woke because of press, instead generate a PRESS event.
|
||||
if (pressed) {
|
||||
powerFSM.trigger(EVENT_PRESS);
|
||||
} else {
|
||||
// Otherwise let the NB state handle the IRQ (and that state will handle stuff like IRQs etc)
|
||||
// we lie and say "wake timer" because the interrupt will be handled by the regular IRQ code
|
||||
powerFSM.trigger(EVENT_WAKE_TIMER);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Otherwise let the NB state handle the IRQ (and that state will handle stuff like IRQs etc)
|
||||
// we lie and say "wake timer" because the interrupt will be handled by the regular IRQ code
|
||||
powerFSM.trigger(EVENT_WAKE_TIMER);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Someone says we can't sleep now, so just save some power by sleeping the CPU for 100ms or so
|
||||
delay(100);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "concurrency/OSThread.h"
|
||||
#include "configuration.h"
|
||||
#include "main.h"
|
||||
#include "mesh/Throttle.h"
|
||||
|
||||
namespace concurrency
|
||||
{
|
||||
@@ -29,9 +30,9 @@ class PowerFSMThread : public OSThread
|
||||
if (powerStatus->getHasUSB()) {
|
||||
timeLastPowered = millis();
|
||||
} else if (config.power.on_battery_shutdown_after_secs > 0 && config.power.on_battery_shutdown_after_secs != UINT32_MAX &&
|
||||
millis() > (timeLastPowered +
|
||||
Default::getConfiguredOrDefaultMs(
|
||||
config.power.on_battery_shutdown_after_secs))) { // shutdown after 30 minutes unpowered
|
||||
Throttle::hasElapsed(
|
||||
timeLastPowered,
|
||||
Default::getConfiguredOrDefaultMs(config.power.on_battery_shutdown_after_secs))) { // unpowered too long
|
||||
powerFSM.trigger(EVENT_SHUTDOWN);
|
||||
}
|
||||
|
||||
|
||||
@@ -302,13 +302,18 @@ void RedirectablePrint::log(const char *logLevel, const char *format, ...)
|
||||
// level trace is special, two possible ways to handle it.
|
||||
if (strcmp(logLevel, MESHTASTIC_LOG_LEVEL_TRACE) == 0) {
|
||||
if (portduino_config.traceFilename != "") {
|
||||
// Format the message rather than assuming the first vararg is a string: not every
|
||||
// LOG_TRACE call passes one, and reading a char* that isn't there segfaults. Sized for
|
||||
// the worst-case packet JSON (233-byte payload escaped 6x, plus metadata ~= 1.7 KB).
|
||||
char traceBuf[2048];
|
||||
va_list arg;
|
||||
va_start(arg, format);
|
||||
vsnprintf(traceBuf, sizeof(traceBuf), format, arg);
|
||||
va_end(arg);
|
||||
try {
|
||||
traceFile << va_arg(arg, char *) << std::endl;
|
||||
traceFile << traceBuf << std::endl;
|
||||
} catch (const std::ios_base::failure &e) {
|
||||
}
|
||||
va_end(arg);
|
||||
}
|
||||
if (portduino_config.logoutputlevel < level_trace && strcmp(logLevel, MESHTASTIC_LOG_LEVEL_TRACE) == 0) {
|
||||
return;
|
||||
|
||||
@@ -125,6 +125,10 @@ int32_t SerialConsole::runOnce()
|
||||
|
||||
int32_t delay = runOncePart();
|
||||
#if defined(SERIAL_HAS_ON_RECEIVE) || defined(CONFIG_IDF_TARGET_ESP32S2)
|
||||
// Nothing wakes the idle sleep for "TX space freed" or a bounded-drain remainder
|
||||
// (#11164), so keep polling while the API holds undelivered output.
|
||||
if (hasPendingOutput())
|
||||
return delay < 25 ? delay : 25; // 0 continues a budget slice; else short-poll TX drain
|
||||
return Port.available() ? delay : INT32_MAX;
|
||||
#elif defined(IS_USB_SERIAL)
|
||||
return HWCDC::isPlugged() ? delay : (1000 * 20);
|
||||
@@ -212,6 +216,17 @@ bool SerialConsole::finishPendingFrame()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Report a retained USB CDC frame awaiting TX space.
|
||||
bool SerialConsole::hasRetainedFrame()
|
||||
{
|
||||
#ifdef IS_USB_SERIAL
|
||||
concurrency::LockGuard guard(&streamLock);
|
||||
return !frameWriter.isIdle();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Protect the retained log buffer from being overwritten.
|
||||
bool SerialConsole::canEncodeLogRecord()
|
||||
{
|
||||
|
||||
@@ -51,6 +51,8 @@ class SerialConsole : public StreamAPI, public RedirectablePrint, private concur
|
||||
|
||||
/// Continue retained USB CDC output before PhoneAPI advances.
|
||||
virtual bool finishPendingFrame() override;
|
||||
/// Report a retained USB CDC frame awaiting TX space.
|
||||
virtual bool hasRetainedFrame() override;
|
||||
/// Return whether the dedicated log buffer can be safely overwritten.
|
||||
virtual bool canEncodeLogRecord() override;
|
||||
/// Write or retain one framed USB CDC message.
|
||||
|
||||
+82
-17
@@ -1,33 +1,98 @@
|
||||
// See UptimeClock.h for the full contract.
|
||||
#include "UptimeClock.h"
|
||||
#include <Arduino.h>
|
||||
#include <atomic>
|
||||
|
||||
uint32_t Time::getMillis()
|
||||
{
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
if (Time::useTestClock)
|
||||
return Time::testNowMs;
|
||||
if (Time::useTestClock.load(std::memory_order_relaxed))
|
||||
return Time::testNowMs.load(std::memory_order_relaxed);
|
||||
#endif
|
||||
return millis();
|
||||
}
|
||||
|
||||
uint64_t Time::getMillis64()
|
||||
namespace
|
||||
{
|
||||
static uint32_t lastLow = 0; // last 32-bit sample
|
||||
static uint32_t highWord = 0; // number of observed wraps
|
||||
struct PublishedSnapshot {
|
||||
std::atomic<uint32_t> high{0};
|
||||
std::atomic<uint32_t> low{0};
|
||||
};
|
||||
|
||||
uint32_t now = Time::getMillis();
|
||||
// The constexpr atomic initializers make both snapshots available before firmware startup.
|
||||
PublishedSnapshot published[2];
|
||||
std::atomic<uint32_t> publishedGeneration{0};
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
// A test swapping clock sources (real <-> injected) can make `now` jump backward for
|
||||
// reasons other than a genuine wrap - rebase rather than miscount it as one.
|
||||
if (Time::clockSourceChanged) {
|
||||
lastLow = now;
|
||||
highWord = 0;
|
||||
Time::clockSourceChanged = false;
|
||||
}
|
||||
std::atomic<Time::MonotonicPublishHook> monotonicPublishHook{nullptr};
|
||||
#endif
|
||||
if (now < lastLow)
|
||||
highWord++; // low word wrapped since last call
|
||||
lastLow = now;
|
||||
return (static_cast<uint64_t>(highWord) << 32) | now;
|
||||
|
||||
// Extend a published (high, low) snapshot to `now`; unsigned subtraction is exact across the wrap
|
||||
// for any gap under 49.7 days. One copy, because reader and writer must agree on it exactly.
|
||||
uint64_t extendPublished(uint32_t high, uint32_t low, uint32_t now)
|
||||
{
|
||||
return ((((uint64_t)high << 32) | low) + (uint32_t)(now - low));
|
||||
}
|
||||
|
||||
// A generation change means the writer completed a publish while this copy was being read. A
|
||||
// paused publish leaves the generation unchanged and writes only the inactive snapshot.
|
||||
void readPublished(uint32_t &high, uint32_t &low)
|
||||
{
|
||||
for (;;) {
|
||||
const uint32_t before = publishedGeneration.load(std::memory_order_acquire);
|
||||
PublishedSnapshot &snapshot = published[before & 1u];
|
||||
high = snapshot.high.load(std::memory_order_relaxed);
|
||||
low = snapshot.low.load(std::memory_order_relaxed);
|
||||
std::atomic_thread_fence(std::memory_order_acquire);
|
||||
if (publishedGeneration.load(std::memory_order_relaxed) == before)
|
||||
return;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
uint64_t Time::getMillisMonotonic()
|
||||
{
|
||||
uint32_t high, low;
|
||||
readPublished(high, low);
|
||||
// The reader writes nothing back; it just extends the last published carry to now.
|
||||
return extendPublished(high, low, getMillis());
|
||||
}
|
||||
|
||||
uint32_t Time::getUptimeSecs()
|
||||
{
|
||||
return (uint32_t)(getMillisMonotonic() / 1000);
|
||||
}
|
||||
|
||||
void Time::serviceMonotonic()
|
||||
{
|
||||
const uint32_t generation = publishedGeneration.load(std::memory_order_relaxed);
|
||||
PublishedSnapshot &active = published[generation & 1u];
|
||||
const uint32_t low = active.low.load(std::memory_order_relaxed);
|
||||
const uint32_t high = active.high.load(std::memory_order_relaxed);
|
||||
const uint64_t next = extendPublished(high, low, getMillis());
|
||||
|
||||
PublishedSnapshot &inactive = published[(generation + 1u) & 1u];
|
||||
inactive.high.store((uint32_t)(next >> 32), std::memory_order_relaxed);
|
||||
inactive.low.store((uint32_t)next, std::memory_order_relaxed);
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
if (const auto hook = monotonicPublishHook.load(std::memory_order_relaxed))
|
||||
hook();
|
||||
#endif
|
||||
publishedGeneration.store(generation + 1u, std::memory_order_release);
|
||||
}
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
void Time::resetMonotonicForTests()
|
||||
{
|
||||
publishedGeneration.store(0, std::memory_order_relaxed);
|
||||
for (auto &snapshot : published) {
|
||||
snapshot.high.store(0, std::memory_order_relaxed);
|
||||
snapshot.low.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
monotonicPublishHook.store(nullptr, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void Time::setMonotonicPublishHookForTests(MonotonicPublishHook hook)
|
||||
{
|
||||
monotonicPublishHook.store(hook, std::memory_order_relaxed);
|
||||
}
|
||||
#endif
|
||||
+43
-20
@@ -1,46 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
#include <atomic>
|
||||
#endif
|
||||
|
||||
// Monotonic uptime clock, injectable so tests can drive a virtual timebase instead of sleeping.
|
||||
// Uptime only; see gps/RTC.h for wall-clock. Not named Time.h: -Isrc would shadow C's <time.h>.
|
||||
namespace Time
|
||||
{
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
// Test-only virtual clock; OFF by default so suites relying on real time are unaffected.
|
||||
inline uint32_t testNowMs = 0;
|
||||
inline bool useTestClock = false;
|
||||
inline bool clockSourceChanged = true; // forces getMillis64() to rebase its wrap accumulator
|
||||
// Test-only virtual clock; OFF by default so suites relying on real time are unaffected. Atomic so
|
||||
// a suite can step the clock from one thread while others read it - the concurrent-reader cases in
|
||||
// test_uptime_clock/ do exactly that.
|
||||
inline std::atomic<uint32_t> testNowMs{0};
|
||||
inline std::atomic<bool> useTestClock{false};
|
||||
using MonotonicPublishHook = void (*)();
|
||||
|
||||
inline void setTestMillis(uint32_t ms)
|
||||
{
|
||||
testNowMs = ms;
|
||||
useTestClock = true;
|
||||
clockSourceChanged = true;
|
||||
testNowMs.store(ms, std::memory_order_relaxed);
|
||||
useTestClock.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
inline void advanceTestMillis(uint32_t deltaMs)
|
||||
{
|
||||
// Advancing from 0 after getMillis64() sampled the real clock steps backward, which would
|
||||
// otherwise be miscounted as a wrap.
|
||||
if (!useTestClock)
|
||||
clockSourceChanged = true;
|
||||
testNowMs += deltaMs;
|
||||
useTestClock = true;
|
||||
testNowMs.fetch_add(deltaMs, std::memory_order_relaxed);
|
||||
useTestClock.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
// Restore real-clock behaviour (call in test tearDown if a suite mixes real and fake time).
|
||||
inline void useRealClock()
|
||||
{
|
||||
useTestClock = false;
|
||||
testNowMs = 0;
|
||||
clockSourceChanged = true;
|
||||
useTestClock.store(false, std::memory_order_relaxed);
|
||||
testNowMs.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
// Zero the published wrap carry. Suites that assert absolute uptime values call this in setUp():
|
||||
// a previous case that moved the test clock backwards left a counted wrap behind.
|
||||
void resetMonotonicForTests();
|
||||
void setMonotonicPublishHookForTests(MonotonicPublishHook hook);
|
||||
#endif
|
||||
|
||||
/// Milliseconds since boot, 32-bit (wraps ~49.7 days). Drop-in for millis().
|
||||
/// Milliseconds since boot, 32-bit (wraps ~49.7 days). Drop-in for millis(). For "has this interval
|
||||
/// elapsed / deadline arrived" use Throttle (isWithinTimespanMs / hasElapsed / deadlinePassed),
|
||||
/// which is wrap-correct with no carry state at all.
|
||||
uint32_t getMillis();
|
||||
|
||||
/// Milliseconds since boot, 64-bit, rollover-immune. Must be polled at least once per ~49.7-day
|
||||
/// wrap window to catch every wrap, and keeps mutable static carry state, so it is NOT ISR-safe.
|
||||
uint64_t getMillis64();
|
||||
/// Milliseconds since boot as a monotonic 64-bit count.
|
||||
///
|
||||
/// A pure read: it derives its answer from a complete snapshot published by serviceMonotonic()
|
||||
/// plus the unsigned elapsed time since that snapshot, which is exact across the wrap. A reader
|
||||
/// that preempts publication uses the previous snapshot. If publication completes during a copy,
|
||||
/// the reader retries; it never waits for a publish in progress.
|
||||
///
|
||||
/// Not intended for ISR call sites because lock-free std::atomic operations are not guaranteed by
|
||||
/// every supported toolchain. ISRs use getMillis(); the publication protocol itself never waits.
|
||||
uint64_t getMillisMonotonic();
|
||||
|
||||
/// Whole seconds since boot, derived from getMillisMonotonic() (~136 years of range). This is
|
||||
/// the unit to store when an instant must be dated before the wall clock is trustworthy.
|
||||
uint32_t getUptimeSecs();
|
||||
|
||||
/// Advances the published wrap carry. THE ONLY WRITER - call it from the main loop and nowhere
|
||||
/// else. Two concurrent callers could count one wrap twice, jumping every uptime and wall-clock
|
||||
/// reading ~49.7 days forward for the rest of the boot.
|
||||
///
|
||||
/// Must run at least once per ~49.7-day wrap window; the main loop calls it every iteration.
|
||||
void serviceMonotonic();
|
||||
|
||||
} // namespace Time
|
||||
+232
-131
@@ -1,107 +1,164 @@
|
||||
#include "airtime.h"
|
||||
#include "NodeDB.h"
|
||||
#include "UptimeClock.h"
|
||||
#include "configuration.h"
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
|
||||
AirTime *airTime = NULL;
|
||||
|
||||
// Don't read out of this directly. Use the helper functions.
|
||||
|
||||
uint32_t air_period_tx[PERIODS_TO_LOG];
|
||||
uint32_t air_period_rx[PERIODS_TO_LOG];
|
||||
|
||||
void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms)
|
||||
AirTime *AirTime::Held::armReentryCheck(AirTime *a)
|
||||
{
|
||||
#ifdef AIRTIME_REENTRY_CHECK
|
||||
// Before the lock: a nested take blocks forever, so a later check would never run.
|
||||
assert(!a->reentryFlag);
|
||||
a->reentryFlag = true;
|
||||
#endif
|
||||
return a;
|
||||
}
|
||||
|
||||
AirTime::Held::~Held()
|
||||
{
|
||||
#ifdef AIRTIME_REENTRY_CHECK
|
||||
owner->reentryFlag = false;
|
||||
#else
|
||||
(void)owner;
|
||||
#endif
|
||||
}
|
||||
|
||||
// --- the lock-free core -------------------------------------------------------------------------
|
||||
// Every method here requires the lock, and says so in its signature. None can take it: Windows has
|
||||
// no lock to reach.
|
||||
|
||||
void AirTime::Windows::logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &held)
|
||||
{
|
||||
// A packet may be logged immediately after waking from light sleep. Sync first so
|
||||
// the packet is counted in the current wall-time bucket, not a stale awake-time bucket.
|
||||
syncNow(held);
|
||||
|
||||
// The caller logs, once the lock is released.
|
||||
if (reportType == TX_LOG) {
|
||||
LOG_DEBUG("Packet TX: %ums", airtime_ms);
|
||||
this->airtimes.periodTX[0] = this->airtimes.periodTX[0] + airtime_ms;
|
||||
air_period_tx[0] = air_period_tx[0] + airtime_ms;
|
||||
|
||||
this->utilizationTX[this->getPeriodUtilHour()] = this->utilizationTX[this->getPeriodUtilHour()] + airtime_ms;
|
||||
this->utilizationTX[this->getPeriodUtilHour(held)] += airtime_ms;
|
||||
} else if (reportType == RX_LOG) {
|
||||
LOG_DEBUG("Packet RX: %ums", airtime_ms);
|
||||
this->airtimes.periodRX[0] = this->airtimes.periodRX[0] + airtime_ms;
|
||||
air_period_rx[0] = air_period_rx[0] + airtime_ms;
|
||||
} else if (reportType == RX_ALL_LOG) {
|
||||
LOG_DEBUG("Packet RX (noise?) : %ums", airtime_ms);
|
||||
this->airtimes.periodRX_ALL[0] = this->airtimes.periodRX_ALL[0] + airtime_ms;
|
||||
}
|
||||
|
||||
// Log all airtime type for channel utilization
|
||||
this->channelUtilization[this->getPeriodUtilMinute()] = channelUtilization[this->getPeriodUtilMinute()] + airtime_ms;
|
||||
this->channelUtilization[this->getPeriodUtilMinute(held)] += airtime_ms;
|
||||
}
|
||||
|
||||
uint8_t AirTime::currentPeriodIndex()
|
||||
uint8_t AirTime::Windows::getPeriodUtilMinute(const Held &)
|
||||
{
|
||||
return ((getSecondsSinceBoot() / SECONDS_PER_PERIOD) % PERIODS_TO_LOG);
|
||||
return (secSinceBoot / 10) % CHANNEL_UTILIZATION_PERIODS;
|
||||
}
|
||||
|
||||
uint8_t AirTime::getPeriodUtilMinute()
|
||||
uint8_t AirTime::Windows::getPeriodUtilHour(const Held &)
|
||||
{
|
||||
return (getSecondsSinceBoot() / 10) % CHANNEL_UTILIZATION_PERIODS;
|
||||
return (secSinceBoot / 60) % MINUTES_IN_HOUR;
|
||||
}
|
||||
|
||||
uint8_t AirTime::getPeriodUtilHour()
|
||||
void AirTime::Windows::syncNow(const Held &)
|
||||
{
|
||||
return (getSecondsSinceBoot() / 60) % MINUTES_IN_HOUR;
|
||||
}
|
||||
// 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.
|
||||
uint32_t nowSecs = Time::getUptimeSecs();
|
||||
|
||||
void AirTime::airtimeRotatePeriod()
|
||||
{
|
||||
if (firstTime) {
|
||||
memset(this->utilizationTX, 0, sizeof(this->utilizationTX));
|
||||
memset(this->channelUtilization, 0, sizeof(this->channelUtilization));
|
||||
memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX));
|
||||
memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX));
|
||||
memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL));
|
||||
|
||||
if (this->airtimes.lastPeriodIndex != this->currentPeriodIndex()) {
|
||||
LOG_DEBUG("Rotate airtimes to a new period = %u", this->currentPeriodIndex());
|
||||
this->secSinceBoot = nowSecs;
|
||||
firstTime = false;
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = PERIODS_TO_LOG - 2; i >= 0; --i) {
|
||||
this->airtimes.periodTX[i + 1] = this->airtimes.periodTX[i];
|
||||
this->airtimes.periodRX[i + 1] = this->airtimes.periodRX[i];
|
||||
this->airtimes.periodRX_ALL[i + 1] = this->airtimes.periodRX_ALL[i];
|
||||
if (nowSecs == this->secSinceBoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
air_period_tx[i + 1] = this->airtimes.periodTX[i];
|
||||
air_period_rx[i + 1] = this->airtimes.periodRX[i];
|
||||
uint32_t oldSecSinceBoot = this->secSinceBoot;
|
||||
this->secSinceBoot = nowSecs;
|
||||
|
||||
// Historical airtime reports use 1-hour buckets. If multiple hours elapsed while
|
||||
// asleep, rotate each crossed bucket or clear the whole report window.
|
||||
uint32_t elapsedAirtimePeriods = (this->secSinceBoot / SECONDS_PER_PERIOD) - (oldSecSinceBoot / SECONDS_PER_PERIOD);
|
||||
if (elapsedAirtimePeriods >= PERIODS_TO_LOG) {
|
||||
memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX));
|
||||
memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX));
|
||||
memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL));
|
||||
} else {
|
||||
// Hand the count to runOnce() rather than tracing each crossing here: this runs under
|
||||
// the lock, and a UART write would stall every other caller waiting on it.
|
||||
this->rotationsPendingLog += elapsedAirtimePeriods;
|
||||
for (uint32_t h = 0; h < elapsedAirtimePeriods; h++) {
|
||||
for (int i = PERIODS_TO_LOG - 2; i >= 0; --i) {
|
||||
this->airtimes.periodTX[i + 1] = this->airtimes.periodTX[i];
|
||||
this->airtimes.periodRX[i + 1] = this->airtimes.periodRX[i];
|
||||
this->airtimes.periodRX_ALL[i + 1] = this->airtimes.periodRX_ALL[i];
|
||||
}
|
||||
|
||||
this->airtimes.periodTX[0] = 0;
|
||||
this->airtimes.periodRX[0] = 0;
|
||||
this->airtimes.periodRX_ALL[0] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this->airtimes.periodTX[0] = 0;
|
||||
this->airtimes.periodRX[0] = 0;
|
||||
this->airtimes.periodRX_ALL[0] = 0;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
air_period_tx[0] = 0;
|
||||
air_period_rx[0] = 0;
|
||||
|
||||
this->airtimes.lastPeriodIndex = this->currentPeriodIndex();
|
||||
// TX utilization is a rolling 60-minute view used by duty-cycle checks.
|
||||
uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60);
|
||||
if (elapsedUtilTXPeriods >= MINUTES_IN_HOUR) {
|
||||
memset(this->utilizationTX, 0, sizeof(this->utilizationTX));
|
||||
} else {
|
||||
for (uint32_t i = 1; i <= elapsedUtilTXPeriods; i++) {
|
||||
this->utilizationTX[((oldSecSinceBoot / 60) + i) % MINUTES_IN_HOUR] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t *AirTime::airtimeReport(reportTypes reportType)
|
||||
bool AirTime::Windows::airtimeReport(reportTypes reportType, uint32_t *out, size_t count, const Held &held)
|
||||
{
|
||||
if (!out || count > PERIODS_TO_LOG)
|
||||
return false;
|
||||
|
||||
// Reports may be requested before runOnce() executes after wake.
|
||||
syncNow(held);
|
||||
|
||||
const uint32_t *src = nullptr;
|
||||
if (reportType == TX_LOG) {
|
||||
return this->airtimes.periodTX;
|
||||
src = this->airtimes.periodTX;
|
||||
} else if (reportType == RX_LOG) {
|
||||
return this->airtimes.periodRX;
|
||||
src = this->airtimes.periodRX;
|
||||
} else if (reportType == RX_ALL_LOG) {
|
||||
return this->airtimes.periodRX_ALL;
|
||||
src = this->airtimes.periodRX_ALL;
|
||||
}
|
||||
return 0;
|
||||
if (!src)
|
||||
return false;
|
||||
|
||||
memcpy(out, src, count * sizeof(*out));
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t AirTime::getPeriodsToLog()
|
||||
float AirTime::Windows::channelUtilizationPercent(const Held &held)
|
||||
{
|
||||
return PERIODS_TO_LOG;
|
||||
}
|
||||
// Gate decisions should see buckets that have decayed across light-sleep time.
|
||||
syncNow(held);
|
||||
|
||||
uint32_t AirTime::getSecondsPerPeriod()
|
||||
{
|
||||
return SECONDS_PER_PERIOD;
|
||||
}
|
||||
|
||||
uint32_t AirTime::getSecondsSinceBoot()
|
||||
{
|
||||
return this->secSinceBoot;
|
||||
}
|
||||
|
||||
float AirTime::channelUtilizationPercent()
|
||||
{
|
||||
uint32_t sum = 0;
|
||||
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) {
|
||||
sum += this->channelUtilization[i];
|
||||
@@ -110,8 +167,11 @@ float AirTime::channelUtilizationPercent()
|
||||
return (float(sum) / float(CHANNEL_UTILIZATION_PERIODS * 10 * 1000)) * 100;
|
||||
}
|
||||
|
||||
float AirTime::utilizationTXPercent()
|
||||
float AirTime::Windows::utilizationTXPercent(const Held &held)
|
||||
{
|
||||
// Duty-cycle checks use this value, so keep it current even outside the periodic thread.
|
||||
syncNow(held);
|
||||
|
||||
uint32_t sum = 0;
|
||||
for (uint32_t i = 0; i < MINUTES_IN_HOUR; i++) {
|
||||
sum += this->utilizationTX[i];
|
||||
@@ -120,33 +180,9 @@ float AirTime::utilizationTXPercent()
|
||||
return (float(sum) / float(MS_IN_HOUR)) * 100;
|
||||
}
|
||||
|
||||
bool AirTime::isTxAllowedChannelUtil(bool polite)
|
||||
{
|
||||
uint8_t percentage = (polite ? polite_channel_util_percent : max_channel_util_percent);
|
||||
if (channelUtilizationPercent() < percentage) {
|
||||
return true;
|
||||
} else {
|
||||
LOG_WARN("Ch. util >%d%%. Skip send", percentage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool AirTime::isTxAllowedAirUtil()
|
||||
{
|
||||
float effectiveDutyCycle = getEffectiveDutyCycle();
|
||||
if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) {
|
||||
if (utilizationTXPercent() < effectiveDutyCycle * polite_duty_cycle_percent / 100) {
|
||||
return true;
|
||||
} else {
|
||||
LOG_WARN("TX air util. >%f%%. Skip send", effectiveDutyCycle * polite_duty_cycle_percent / 100);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the amount of minutes we have to be silent before we can send again
|
||||
uint8_t AirTime::getSilentMinutes(float txPercent, float dutyCycle)
|
||||
// Minutes we must be silent before sending again. Does not sync, and walks the ring as if the index
|
||||
// were an age; both are wrong and both are pinned by characterisation tests. See airtime.h's TODO.
|
||||
uint8_t AirTime::Windows::getSilentMinutes(float txPercent, float dutyCycle, const Held &)
|
||||
{
|
||||
float newTxPercent = txPercent;
|
||||
for (int8_t i = MINUTES_IN_HOUR - 1; i >= 0; --i) {
|
||||
@@ -158,54 +194,119 @@ uint8_t AirTime::getSilentMinutes(float txPercent, float dutyCycle)
|
||||
return MINUTES_IN_HOUR;
|
||||
}
|
||||
|
||||
AirTime::AirTime() : concurrency::OSThread("AirTime"), airtimes({}) {}
|
||||
// --- the locking shell --------------------------------------------------------------------------
|
||||
// Each takes the lock exactly once and delegates. Nothing below calls another method on `this`.
|
||||
|
||||
void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms)
|
||||
{
|
||||
{
|
||||
Held held(this);
|
||||
w.logAirtime(reportType, airtime_ms, held);
|
||||
}
|
||||
|
||||
// Outside the lock: DEBUG_PORT.log() blocks on a UART write, and `lock` is a plain binary
|
||||
// semaphore with no priority inheritance, so holding it here would stall the radio thread.
|
||||
if (reportType == TX_LOG) {
|
||||
LOG_DEBUG("Packet TX: %ums", airtime_ms);
|
||||
} else if (reportType == RX_LOG) {
|
||||
LOG_DEBUG("Packet RX: %ums", airtime_ms);
|
||||
} else if (reportType == RX_ALL_LOG) {
|
||||
LOG_DEBUG("Packet RX (noise?) : %ums", airtime_ms);
|
||||
}
|
||||
}
|
||||
|
||||
void AirTime::airtimeRotatePeriod()
|
||||
{
|
||||
// Preserve the public helper while keeping all rotation logic in one monotonic-time path.
|
||||
Held held(this);
|
||||
w.syncNow(held);
|
||||
}
|
||||
|
||||
bool AirTime::airtimeReport(reportTypes reportType, uint32_t *out, size_t count)
|
||||
{
|
||||
Held held(this);
|
||||
return w.airtimeReport(reportType, out, count, held);
|
||||
}
|
||||
|
||||
uint32_t AirTime::getSecondsSinceBoot()
|
||||
{
|
||||
// Keep HTTP/debug reporting aligned with the same monotonic clock used by the buckets.
|
||||
Held held(this);
|
||||
w.syncNow(held);
|
||||
return w.secSinceBoot;
|
||||
}
|
||||
|
||||
float AirTime::channelUtilizationPercent()
|
||||
{
|
||||
Held held(this);
|
||||
return w.channelUtilizationPercent(held);
|
||||
}
|
||||
|
||||
float AirTime::utilizationTXPercent()
|
||||
{
|
||||
Held held(this);
|
||||
return w.utilizationTXPercent(held);
|
||||
}
|
||||
|
||||
// These lock like everything else, because they call the core rather than the public accessors.
|
||||
// Both read under the lock and warn after it, for the reason logAirtime() does.
|
||||
bool AirTime::isTxAllowedChannelUtil(bool polite)
|
||||
{
|
||||
uint8_t percentage = (polite ? polite_channel_util_percent : max_channel_util_percent);
|
||||
float utilization;
|
||||
{
|
||||
Held held(this);
|
||||
utilization = w.channelUtilizationPercent(held);
|
||||
}
|
||||
|
||||
if (utilization < percentage)
|
||||
return true;
|
||||
LOG_WARN("Ch. util >%d%%. Skip send", percentage);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool AirTime::isTxAllowedAirUtil()
|
||||
{
|
||||
float effectiveDutyCycle = getEffectiveDutyCycle();
|
||||
if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) {
|
||||
float limit = effectiveDutyCycle * polite_duty_cycle_percent / 100;
|
||||
float utilization;
|
||||
{
|
||||
Held held(this);
|
||||
utilization = w.utilizationTXPercent(held);
|
||||
}
|
||||
|
||||
if (utilization < limit)
|
||||
return true;
|
||||
LOG_WARN("TX air util. >%f%%. Skip send", limit);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t AirTime::getSilentMinutes(float txPercent, float dutyCycle)
|
||||
{
|
||||
Held held(this);
|
||||
return w.getSilentMinutes(txPercent, dutyCycle, held);
|
||||
}
|
||||
|
||||
AirTime::AirTime() : concurrency::OSThread("AirTime") {}
|
||||
|
||||
int32_t AirTime::runOnce()
|
||||
{
|
||||
secSinceBoot++;
|
||||
|
||||
uint8_t utilPeriod = this->getPeriodUtilMinute();
|
||||
uint8_t utilPeriodTX = this->getPeriodUtilHour();
|
||||
|
||||
if (firstTime) {
|
||||
|
||||
// Init utilizationTX window to all 0
|
||||
for (uint32_t i = 0; i < MINUTES_IN_HOUR; i++) {
|
||||
this->utilizationTX[i] = 0;
|
||||
}
|
||||
|
||||
// Init channelUtilization window to all 0
|
||||
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) {
|
||||
this->channelUtilization[i] = 0;
|
||||
}
|
||||
|
||||
// Init airtime windows to all 0
|
||||
for (int i = 0; i < PERIODS_TO_LOG; i++) {
|
||||
this->airtimes.periodTX[i] = 0;
|
||||
this->airtimes.periodRX[i] = 0;
|
||||
this->airtimes.periodRX_ALL[i] = 0;
|
||||
|
||||
// air_period_tx[i] = 0;
|
||||
// air_period_rx[i] = 0;
|
||||
}
|
||||
|
||||
firstTime = false;
|
||||
lastUtilPeriod = utilPeriod;
|
||||
} else {
|
||||
this->airtimeRotatePeriod();
|
||||
|
||||
// Reset the channelUtilization window when we roll over
|
||||
if (lastUtilPeriod != utilPeriod) {
|
||||
lastUtilPeriod = utilPeriod;
|
||||
|
||||
this->channelUtilization[utilPeriod] = 0;
|
||||
}
|
||||
|
||||
if (lastUtilPeriodTX != utilPeriodTX) {
|
||||
lastUtilPeriodTX = utilPeriodTX;
|
||||
|
||||
this->utilizationTX[utilPeriodTX] = 0;
|
||||
}
|
||||
uint32_t rotations;
|
||||
{
|
||||
Held held(this);
|
||||
w.syncNow(held);
|
||||
rotations = w.rotationsPendingLog;
|
||||
w.rotationsPendingLog = 0;
|
||||
}
|
||||
|
||||
// Outside the lock, for the reason logAirtime() gives. Any caller can cross an hour, but only
|
||||
// this thread reports it, so a crossing raised elsewhere is traced at most one tick late.
|
||||
if (rotations > 0) {
|
||||
LOG_DEBUG("Rotate airtimes, crossed %u hour(s)", rotations);
|
||||
}
|
||||
|
||||
return (1000 * 1);
|
||||
}
|
||||
+164
-35
@@ -1,28 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
#include "MeshRadio.h"
|
||||
#include "concurrency/Lock.h"
|
||||
#include "concurrency/LockGuard.h"
|
||||
#include "concurrency/OSThread.h"
|
||||
#include "configuration.h"
|
||||
#include <Arduino.h>
|
||||
#include <functional>
|
||||
|
||||
/*
|
||||
TX_LOG - Time on air this device has transmitted
|
||||
AirTime records how long the radio was busy and turns that into the two
|
||||
percentages the transmit gates and DeviceMetrics use.
|
||||
|
||||
RX_LOG - Time on air used by valid and routable mesh packets, does not include
|
||||
TX air time
|
||||
INPUTS - four events change this class's state:
|
||||
|
||||
RX_ALL_LOG - Time of all received lora packets. This includes packets that are not
|
||||
for meshtastic devices. Does not include TX air time.
|
||||
logAirtime(TX_LOG, ms) one per completed transmission, ours and relayed
|
||||
logAirtime(RX_LOG, ms) one per well-formed reception. The interface is
|
||||
promiscuous: this counts packets not addressed
|
||||
to us, and every duplicate relay copy.
|
||||
logAirtime(RX_ALL_LOG, ms) one per reception that could NOT be parsed -
|
||||
failed CRC, truncated, region unset, collision
|
||||
elapsed time Time::getUptimeSecs(), read by syncNow() on
|
||||
every public entry point. The only input that
|
||||
removes airtime.
|
||||
|
||||
Example analytics:
|
||||
RX_LOG and RX_ALL_LOG are DISJOINT, and a reception logs AT MOST one of them.
|
||||
RX_ALL_LOG is unparseable airtime, not a superset of RX_LOG, so the total is
|
||||
TX + RX + RX_ALL - but it under-counts: five drop paths log neither. A packet
|
||||
with from == 0 returns unlogged from handleReceiveInterrupt(), unlike every
|
||||
neighbouring drop, and SimRadio drops a collision during transmission plus
|
||||
three allocation failures. Pre-existing; see the TODO below.
|
||||
|
||||
TX_LOG + RX_LOG = Total air time for a particular meshtastic channel.
|
||||
OUTPUTS:
|
||||
|
||||
TX_LOG + RX_ALL_LOG = Total air time for a particular meshtastic channel, including
|
||||
other lora radios.
|
||||
channelUtilizationPercent() % of the last 60s busy, all three types
|
||||
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
|
||||
getSilentMinutes() minutes until the TX figure clears a limit.
|
||||
Feeds a log line and a client notification; it
|
||||
gates nothing.
|
||||
airtimeReport() 8 x 1h of raw ms per type, for the HTTP report
|
||||
getSecondsSinceBoot() the clock the buckets are keyed to
|
||||
|
||||
RX_ALL_LOG - RX_LOG = Other lora radios on our frequency channel.
|
||||
The three thresholds are hard-coded members with no config binding.
|
||||
|
||||
STORAGE - two orderings, easily confused:
|
||||
|
||||
channelUtilization[], utilizationTX[]
|
||||
Modular rings indexed by absolute uptime phase, (secs / p) % N. The
|
||||
index is NOT an age; the oldest bucket is (current + 1) % N. Crossing
|
||||
into a bucket zeroes it.
|
||||
|
||||
airtimes.period{TX,RX,RX_ALL}[]
|
||||
Shift-ordered, slot 0 newest, index IS age in hours. Slot 0 is a partial
|
||||
hour; normalise it by getSecondsSinceBoot() % getSecondsPerPeriod().
|
||||
|
||||
The percentages measure wall time, not time awake. A light-sleeping node still
|
||||
hears traffic, and reporting over observed time would make two nodes'
|
||||
broadcast readings incomparable.
|
||||
|
||||
channelUtilization spans 60s but reaches the mesh at >= 1h cadence, so remote
|
||||
readings are a snapshot rather than an average. Its contention-window consumer
|
||||
moves in 20-percentage-point steps, map(chanutil, 0, 100, CWmin, CWmax), so
|
||||
small errors never reach the backoff.
|
||||
|
||||
Rotation happens on access, not on the scheduler tick: every public method
|
||||
calls syncNow() first and runOnce() only guarantees once a second. A
|
||||
scheduler-driven window stops advancing during light sleep. Enforced by
|
||||
test_channel_utilization_is_independent_of_scheduler_rate.
|
||||
|
||||
TODO: airtime accuracy. Four known defects remain - the quantised denominator,
|
||||
its sawtooth, whole-packet attribution to the completing bucket, and
|
||||
getSilentMinutes() reading a modular ring as if the index were an age. Each is
|
||||
pinned by a test tagged CHARACTERISATION in test/test_airtime.
|
||||
*/
|
||||
|
||||
#define CHANNEL_UTILIZATION_PERIODS 6
|
||||
@@ -35,10 +86,42 @@
|
||||
|
||||
enum reportTypes { TX_LOG, RX_LOG, RX_ALL_LOG };
|
||||
|
||||
void logAirtime(reportTypes reportType, uint32_t airtime_ms);
|
||||
|
||||
uint32_t *airtimeReport(reportTypes reportType);
|
||||
// Arms AirTime's nested-take check. Sound only where the lock is not a real lock: the check runs
|
||||
// before the take, because a nested take blocks forever and a later check would never run - so
|
||||
// under preemption it would false-positive on legitimate contention and race on its own write.
|
||||
// Portduino is where it earns its keep anyway; there Lock::lock() is empty, so a nested take
|
||||
// succeeds silently and nothing else would notice. On an on-target test build the nesting it
|
||||
// catches shows up as a hang instead. Test builds only: nothing in this tree defines DEBUG or
|
||||
// NDEBUG, so either spelling would ship an abort() to every board, and nrf52_promicro_diy_tcxo
|
||||
// has no flash for it.
|
||||
#if defined(PIO_UNIT_TESTING) && !defined(HAS_FREE_RTOS)
|
||||
#define AIRTIME_REENTRY_CHECK
|
||||
#endif
|
||||
|
||||
// Serialised behind `lock` because two FreeRTOS tasks genuinely reach this class at once on nRF52.
|
||||
// NRF52Bluetooth registers its ToRadio write callback with defer == false, so a phone's packet runs
|
||||
// PhoneAPI::handleToRadio -> MeshService::sendToMesh -> Router::send on the Bluefruit BLE task,
|
||||
// which reads utilizationTXPercent() and getSilentMinutes() while loopTask may be inside
|
||||
// logAirtime() from a reception. That is an unsynchronised read-modify-write of utilizationTX[] and
|
||||
// secSinceBoot against a summing read. ESP32 hands BLE work to the main task and does not have it.
|
||||
//
|
||||
// Two mechanisms keep it serialised:
|
||||
//
|
||||
// - a lock-free inner core (Windows) holds all state and all logic. It has no lock member, and
|
||||
// must never reach one through the global `airTime` - `airTime->anyPublicMethod()` from inside
|
||||
// a Windows method would take a second Held and hang, because concurrency::Lock is a
|
||||
// non-recursive binary semaphore taken with portMAX_DELAY. Nothing does this today; the
|
||||
// AIRTIME_REENTRY_CHECK assert is the backstop, and it only builds on host test builds.
|
||||
// - a private Held token takes the lock in its constructor and is the only thing that satisfies a
|
||||
// core method's `const Held &`, so the lock cannot be forgotten.
|
||||
//
|
||||
// Every public method takes the lock exactly once and delegates, with two exceptions: the two
|
||||
// constexpr accessors below touch no state and take none, and isTxAllowedAirUtil() takes it zero or
|
||||
// one times, depending on whether the duty-cycle branch is entered at all. Nothing inside locks -
|
||||
// that includes isTxAllowed*(), which call the core rather than the public accessors.
|
||||
//
|
||||
// A new write-path helper belongs to Windows or is a free function, never a method on AirTime: an
|
||||
// AirTime method locks, and logAirtime() would call it while already holding the lock.
|
||||
class AirTime : private concurrency::OSThread
|
||||
{
|
||||
|
||||
@@ -49,39 +132,85 @@ class AirTime : private concurrency::OSThread
|
||||
float channelUtilizationPercent();
|
||||
float utilizationTXPercent();
|
||||
|
||||
float UtilizationPercentTX();
|
||||
uint32_t channelUtilization[CHANNEL_UTILIZATION_PERIODS] = {0};
|
||||
uint32_t utilizationTX[MINUTES_IN_HOUR] = {0};
|
||||
|
||||
/// Compatibility shim: no caller in the tree, kept for out-of-tree ones.
|
||||
void airtimeRotatePeriod();
|
||||
uint8_t getPeriodsToLog();
|
||||
uint32_t getSecondsPerPeriod();
|
||||
/// Constants, not state: no lock, and usable where a constant expression is required so a
|
||||
/// caller's buffer and the count it passes to airtimeReport() cannot drift apart.
|
||||
static constexpr uint8_t getPeriodsToLog() { return PERIODS_TO_LOG; }
|
||||
static constexpr uint32_t getSecondsPerPeriod() { return SECONDS_PER_PERIOD; }
|
||||
uint32_t getSecondsSinceBoot();
|
||||
uint32_t *airtimeReport(reportTypes reportType);
|
||||
/// Copies `count` buckets into `out`, newest first. Copies rather than returning the array so a
|
||||
/// caller cannot hold a handle to buckets that every other entry point rotates underneath it.
|
||||
/// False if `out` is null, `count` exceeds the log depth, or the report type is unknown.
|
||||
bool airtimeReport(reportTypes reportType, uint32_t *out, size_t count);
|
||||
uint8_t getSilentMinutes(float txPercent, float dutyCycle);
|
||||
bool isTxAllowedChannelUtil(bool polite = false);
|
||||
bool isTxAllowedAirUtil();
|
||||
|
||||
private:
|
||||
bool firstTime = true;
|
||||
uint8_t lastUtilPeriod = 0;
|
||||
uint8_t lastUtilPeriodTX = 0;
|
||||
uint32_t secSinceBoot = 0;
|
||||
concurrency::Lock lock;
|
||||
|
||||
#ifdef AIRTIME_REENTRY_CHECK
|
||||
// Set for the lifetime of a Held and checked before the lock is taken, so a nested take is
|
||||
// reported rather than hung at. See the macro's definition for why it is host-only.
|
||||
bool reentryFlag = false;
|
||||
#endif
|
||||
|
||||
/// Takes `lock` for its lifetime and doubles as proof that it is held. Only AirTime can
|
||||
/// construct one, so a core method taking `const Held &` cannot be called without the lock.
|
||||
/// A bare LockGuard would not do: it proves only that *some* lock is held.
|
||||
class Held
|
||||
{
|
||||
public:
|
||||
explicit Held(AirTime *a) : owner(armReentryCheck(a)), guard(&a->lock) {}
|
||||
~Held();
|
||||
Held(const Held &) = delete;
|
||||
Held &operator=(const Held &) = delete;
|
||||
|
||||
private:
|
||||
static AirTime *armReentryCheck(AirTime *a);
|
||||
AirTime *owner; // declared first, so its initialiser runs before the lock is taken
|
||||
concurrency::LockGuard guard;
|
||||
};
|
||||
|
||||
/// All state, all logic, no lock. Cannot take one, so cannot nest.
|
||||
struct Windows {
|
||||
bool firstTime = true;
|
||||
// Time::getUptimeSecs() as of the last syncNow(). The windows rotate by the gap since, so
|
||||
// they stay correct across a paused scheduler.
|
||||
uint32_t secSinceBoot = 0;
|
||||
|
||||
// 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
|
||||
|
||||
// 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()
|
||||
// drains this and logs after releasing, so the trace costs the lock nothing.
|
||||
uint32_t rotationsPendingLog = 0;
|
||||
|
||||
// Shift-ordered, unlike the rings above: slot 0 is the newest hour and the index is age.
|
||||
struct airtimeStruct {
|
||||
uint32_t periodTX[PERIODS_TO_LOG] = {0}; // AirTime transmitted
|
||||
uint32_t periodRX[PERIODS_TO_LOG] = {0}; // AirTime received and repeated (valid mesh packets)
|
||||
uint32_t periodRX_ALL[PERIODS_TO_LOG] = {0}; // AirTime received regardless of validity. May be noise.
|
||||
} airtimes;
|
||||
|
||||
void logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &);
|
||||
float channelUtilizationPercent(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 &);
|
||||
uint8_t getPeriodUtilMinute(const Held &);
|
||||
uint8_t getPeriodUtilHour(const Held &);
|
||||
// Advance rolling airtime windows from monotonic uptime, not from runOnce() calls.
|
||||
void syncNow(const Held &);
|
||||
} w;
|
||||
|
||||
uint8_t max_channel_util_percent = 40;
|
||||
uint8_t polite_channel_util_percent = 25;
|
||||
uint8_t polite_duty_cycle_percent = 50; // half of Duty Cycle allowance is ok for metadata
|
||||
|
||||
struct airtimeStruct {
|
||||
uint32_t periodTX[PERIODS_TO_LOG]; // AirTime transmitted
|
||||
uint32_t periodRX[PERIODS_TO_LOG]; // AirTime received and repeated (Only valid mesh packets)
|
||||
uint32_t periodRX_ALL[PERIODS_TO_LOG]; // AirTime received regardless of valid mesh packet. Could include noise.
|
||||
uint8_t lastPeriodIndex;
|
||||
} airtimes;
|
||||
|
||||
uint8_t getPeriodUtilMinute();
|
||||
uint8_t getPeriodUtilHour();
|
||||
uint8_t currentPeriodIndex();
|
||||
|
||||
protected:
|
||||
virtual int32_t runOnce() override;
|
||||
};
|
||||
|
||||
+22
-19
@@ -62,20 +62,17 @@ const int DURATION_1_1 = 1000; // 1/1 note
|
||||
#ifdef HAS_I2S
|
||||
void playTonesRTTTL(const ToneDuration *tone_durations, int size)
|
||||
{
|
||||
// translate ToneDuration[] to RTTTL string and play using audioThread
|
||||
static std::unordered_map<int, std::string> freqToNote = {
|
||||
{NOTE_C3, "c4"}, {NOTE_CS3, "c#4"}, {NOTE_D3, "d4"}, {NOTE_DS3, "d#4"}, {NOTE_E3, "e4"}, {NOTE_F3, "f4"},
|
||||
{NOTE_FS3, "f#4"}, {NOTE_G3, "g4"}, {NOTE_GS3, "g#4"}, {NOTE_A3, "a4"}, {NOTE_AS3, "a#4"}, {NOTE_B3, "b4"},
|
||||
{NOTE_C4, "c5"}, {NOTE_E4, "e5"}, {NOTE_G4, "g5"}, {NOTE_A4, "a5"}, {NOTE_C5, "c6"}, {NOTE_E5, "e6"},
|
||||
{NOTE_G5, "g6"}, {NOTE_F5, "f6"}, {NOTE_G6, "g7"}, {NOTE_E7, "e8"}};
|
||||
// translate ToneDuration[] to a single RTTTL string and play it via audioThread
|
||||
static std::unordered_map<int, const char *> freqToNote = {
|
||||
{NOTE_SILENT, "p"}, // rest
|
||||
{NOTE_C3, "c4"}, {NOTE_CS3, "c#4"}, {NOTE_D3, "d4"}, {NOTE_DS3, "d#4"}, {NOTE_E3, "e4"}, {NOTE_F3, "f4"},
|
||||
{NOTE_FS3, "f#4"}, {NOTE_G3, "g4"}, {NOTE_GS3, "g#4"}, {NOTE_A3, "a4"}, {NOTE_AS3, "a#4"}, {NOTE_B3, "b4"},
|
||||
{NOTE_C4, "c5"}, {NOTE_CS4, "c#5"}, {NOTE_E4, "e5"}, {NOTE_G4, "g5"}, {NOTE_A4, "a5"}, {NOTE_B4, "b5"},
|
||||
{NOTE_C5, "c6"}, {NOTE_E5, "e6"}, {NOTE_G5, "g6"}, {NOTE_F5, "f6"}, {NOTE_G6, "g7"}, {NOTE_E7, "e8"}};
|
||||
|
||||
char rtttl[128] = "tone:d=32,o=4,b=200:"; // default duration and octave
|
||||
char rtttl[128] = "tone:d=32,o=4,b=240:"; // b=240 makes 240000/(bpm*d) match the ms durations above
|
||||
for (int i = 0; i < size; i++) {
|
||||
const auto &td = tone_durations[i];
|
||||
std::string note = "b4";
|
||||
if (freqToNote.find(td.frequency_khz) != freqToNote.end()) {
|
||||
note = freqToNote[td.frequency_khz];
|
||||
}
|
||||
int dur = 32; // default duration
|
||||
if (td.duration_ms >= 1000)
|
||||
dur = 1;
|
||||
@@ -90,16 +87,22 @@ void playTonesRTTTL(const ToneDuration *tone_durations, int size)
|
||||
else
|
||||
dur = 32;
|
||||
|
||||
char noteStr[64];
|
||||
snprintf(noteStr, sizeof(noteStr), "%s,%d", note.c_str(), dur);
|
||||
strncat(rtttl, noteStr, sizeof(rtttl) - strlen(rtttl) - 1);
|
||||
auto it = freqToNote.find(td.frequency_khz);
|
||||
const char *note = (it != freqToNote.end()) ? it->second : "p"; // unknown freq -> rest
|
||||
|
||||
audioThread->beginRttl(rtttl, strlen(rtttl));
|
||||
while (audioThread->isPlaying()) {
|
||||
delay(10);
|
||||
}
|
||||
return;
|
||||
// RTTTL grammar puts duration before the note; notes are comma-separated
|
||||
char noteStr[64];
|
||||
snprintf(noteStr, sizeof(noteStr), "%s%d%s", i ? "," : "", dur, note);
|
||||
strncat(rtttl, noteStr, sizeof(rtttl) - strlen(rtttl) - 1);
|
||||
}
|
||||
// trailing rest flushes the last note out of the I2S DMA buffer before teardown
|
||||
strncat(rtttl, ",32p", sizeof(rtttl) - strlen(rtttl) - 1);
|
||||
|
||||
audioThread->beginRttl(rtttl, strlen(rtttl));
|
||||
while (audioThread->isPlaying()) {
|
||||
delay(10);
|
||||
}
|
||||
audioThread->stop(); // release I2S so the amp goes silent instead of looping the last buffer
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ void Lock::lock()
|
||||
}
|
||||
}
|
||||
|
||||
bool Lock::lock(uint32_t timeout)
|
||||
{
|
||||
return xSemaphoreTake(handle, pdMS_TO_TICKS(timeout)) == pdTRUE;
|
||||
}
|
||||
|
||||
void Lock::unlock()
|
||||
{
|
||||
if (xSemaphoreGive(handle) == false) {
|
||||
@@ -39,6 +44,11 @@ Lock::~Lock() {}
|
||||
|
||||
void Lock::lock() {}
|
||||
|
||||
bool Lock::lock(uint32_t)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void Lock::unlock() {}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ class Lock
|
||||
// Must not be called from an ISR.
|
||||
void lock();
|
||||
|
||||
/// Locks the lock with timeout.
|
||||
//
|
||||
// Must not be called from an ISR.
|
||||
bool lock(uint32_t timeout);
|
||||
|
||||
// Unlocks the lock.
|
||||
//
|
||||
// Must not be called from an ISR.
|
||||
|
||||
+24
-2
@@ -88,6 +88,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define MESHTASTIC_PREHOP_DROP 1
|
||||
#endif
|
||||
|
||||
// Use polynomial approximations for trigonometric functions to save flash.
|
||||
// Override with -D MESHTASTIC_TRIG_APPROX=0 for exact trig for special use cases e.g. close to Earth's poles.
|
||||
#ifndef MESHTASTIC_TRIG_APPROX
|
||||
#define MESHTASTIC_TRIG_APPROX 1
|
||||
#endif
|
||||
|
||||
// Debug/test only: let a wired client (serial/TCP) inject frames into the RX pipeline as if they had
|
||||
// arrived over LoRa - a SIMULATOR_APP ToRadio packet is delivered through the real receive path on real
|
||||
// hardware (see MeshService::injectAsReceived). This forges over-the-air traffic, so it MUST stay 0 in
|
||||
@@ -199,6 +205,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define TX_GAIN_LORA 7, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 8
|
||||
#endif
|
||||
|
||||
#ifdef SEEED_WIO_TRACKER_L1_PRO_1W
|
||||
// Indexed by SX1262 output power in dBm, matching RadioInterface::limitPower().
|
||||
// TODO: verify against measured output.
|
||||
#define NUM_PA_POINTS 22
|
||||
#define TX_GAIN_LORA 10, 10, 10, 10, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 10
|
||||
#endif
|
||||
|
||||
// Default system gain to 0 if not defined
|
||||
#ifndef NUM_PA_POINTS
|
||||
#define NUM_PA_POINTS 1
|
||||
@@ -228,7 +241,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define SSD1306_ADDRESS_L 0x3C // Addr = 0
|
||||
#define SSD1306_ADDRESS_H 0x3D // Addr = 1
|
||||
|
||||
#if defined(SEEED_WIO_TRACKER_L1) && !defined(SEEED_WIO_TRACKER_L1_EINK)
|
||||
#if (defined(SEEED_WIO_TRACKER_L1) || defined(SEEED_WIO_TRACKER_L1_PRO_1W)) && !defined(SEEED_WIO_TRACKER_L1_EINK)
|
||||
#define SSD1306_ADDRESS SSD1306_ADDRESS_H
|
||||
#define USE_SH1106
|
||||
#endif
|
||||
@@ -247,6 +260,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define BBQ10_KB_ADDR 0x1F
|
||||
#define MPR121_KB_ADDR 0x5A
|
||||
#define TCA8418_KB_ADDR 0x34
|
||||
#define TSTC8_KB_ADDR 0x6C // STC8H companion-MCU keypad on the ThinkNode-M9
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// SENSOR
|
||||
@@ -264,6 +278,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define QMC5883L_ADDR 0x0D
|
||||
#define HMC5883L_ADDR 0x1E
|
||||
#define MMC5983MA_ADDR 0x30
|
||||
#define QMC6309_ADDR 0x7C
|
||||
#define SHTC3_ADDR 0x70
|
||||
#define LPS22HB_ADDR 0x5C
|
||||
#define LPS22HB_ADDR_ALT 0x5D
|
||||
@@ -294,7 +309,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define BQ25896_ADDR 0x6B
|
||||
#define LTR553ALS_ADDR 0x23
|
||||
#define SEN5X_ADDR 0x69
|
||||
#define SEN6X_ADDR 0x6B // same as QMI8658_ADDR and BQ25896_ADDR
|
||||
#define SCD30_ADDR 0x61
|
||||
#define ADS1X15_ADDR 0x48
|
||||
#define ADS1X15_ADDR_ALT1 0x49
|
||||
#define ADS1X15_ADDR_ALT2 0x4A
|
||||
#define ADS1X15_ADDR_ALT3 0x4B
|
||||
#define DS248X_ADDR 0x18 // same as MCP9808_ADDR, STK8BXX_ADDR and LIS3DH_ADDR
|
||||
#define DS248X_ADDR_ALT1 0x19 // same as LIS3DH_ADDR_ALT and BMA423_ADDR
|
||||
#define DS248X_ADDR_ALT2 0x1A // same as CST328_ADDR
|
||||
@@ -304,7 +324,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define DS248X_ADDR_ALT6 0x1E // same as HMC5883L_ADDR
|
||||
#define DS248X_ADDR_ALT7 0x1F // same as BBQ10_KB_ADDR
|
||||
#define HM330X_ADDR 0x40
|
||||
|
||||
#define AS3935_ADDR 0x03 // both address pins tied high, the common breakout-board default
|
||||
#define AS3935_ADDR_ALT 0x01
|
||||
#define AS3935_ADDR_ALT2 0x02
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ACCELEROMETER
|
||||
|
||||
+41
-24
@@ -13,7 +13,8 @@
|
||||
https://github.com/sandeepmistry/arduino-nRF5/blob/master/libraries/Wire/Wire.h#L50
|
||||
https://github.com/earlephilhower/arduino-pico/blob/master/libraries/Wire/src/Wire.h#L60
|
||||
https://github.com/stm32duino/Arduino_Core_STM32/blob/main/libraries/Wire/src/Wire.h#L103
|
||||
For cases when I2C speed is different to the ones defined by sensors (see defines in sensor classes)
|
||||
For cases when I2C speed is different to the ones defined by sensors
|
||||
(see defines in sensor classes)
|
||||
we need to reclock I2C and set it back to the previous established speed.
|
||||
Only for cases where we can know it (ESP32 or known screen) we can do this.
|
||||
*/
|
||||
@@ -27,50 +28,47 @@ class ReClockI2C
|
||||
{
|
||||
this->i2cBus = i2cBus;
|
||||
this->port = port;
|
||||
this->previousClock = 0;
|
||||
}
|
||||
|
||||
bool setClock(uint32_t desiredClock)
|
||||
// Sets the I2C clock to desiredClock and returns whatever clock was active
|
||||
// beforehand, so the caller can hand it back to restoreClock() later. The
|
||||
// previous clock is returned rather than stored on this object, so callers
|
||||
// that nest calls (see ReClockI2CGuard) each keep their own restoration
|
||||
// value instead of clobbering a single shared one.
|
||||
// Returns 0 if the clock was already at desiredClock, or if the previous
|
||||
// clock couldn't be determined - in both cases there's nothing to restore.
|
||||
uint32_t setClock(uint32_t desiredClock)
|
||||
{
|
||||
uint32_t currentClock = this->getClock();
|
||||
|
||||
if (currentClock) {
|
||||
LOG_DEBUG("Current I2C frequency: %uHz", currentClock);
|
||||
LOG_TRACE("Current I2C frequency: %uHz", currentClock);
|
||||
}
|
||||
|
||||
if (currentClock != desiredClock) {
|
||||
LOG_DEBUG("Changing I2C clock to %uHz", desiredClock);
|
||||
LOG_TRACE("Changing I2C clock to %uHz", desiredClock);
|
||||
this->i2cBus->setClock(desiredClock);
|
||||
// If the clock is 0Hz, we still store it
|
||||
// We'll check in restoreClock function
|
||||
setPreviousClock(currentClock);
|
||||
LOG_DEBUG("Stored previous clock I2C clock: %uHz", this->previousClock);
|
||||
return true;
|
||||
LOG_TRACE("Previous I2C clock: %uHz", currentClock);
|
||||
return currentClock;
|
||||
}
|
||||
|
||||
LOG_DEBUG("I2C clock was already %uHz. Skipping", desiredClock);
|
||||
setPreviousClock(0);
|
||||
return false;
|
||||
LOG_TRACE("I2C clock was already %uHz. Skipping", desiredClock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool restoreClock()
|
||||
void restoreClock(uint32_t previousClock)
|
||||
{
|
||||
if (this->previousClock) {
|
||||
LOG_DEBUG("Restoring I2C clock to %uHz", this->previousClock);
|
||||
i2cBus->setClock(this->previousClock);
|
||||
setPreviousClock(0);
|
||||
return true;
|
||||
if (previousClock) {
|
||||
LOG_TRACE("Restoring I2C clock to %uHz", previousClock);
|
||||
i2cBus->setClock(previousClock);
|
||||
return;
|
||||
}
|
||||
LOG_DEBUG("I2C clock was unknown. Not restored");
|
||||
return false;
|
||||
LOG_TRACE("I2C clock was unknown. Not restored");
|
||||
}
|
||||
|
||||
private:
|
||||
TwoWire *i2cBus{};
|
||||
ScanI2C::I2CPort port{};
|
||||
uint32_t previousClock = 0;
|
||||
|
||||
void setPreviousClock(uint32_t clock) { this->previousClock = clock; }
|
||||
|
||||
uint32_t getClock()
|
||||
{
|
||||
@@ -95,4 +93,23 @@ class ReClockI2C
|
||||
}
|
||||
};
|
||||
|
||||
/* Helper for ReClockI2C: sets the clock on construction and restores it on
|
||||
destruction, so a caller with multiple early-return paths doesn't need to
|
||||
remember to call restoreClock() on each one.
|
||||
*/
|
||||
class ReClockI2CGuard
|
||||
{
|
||||
public:
|
||||
ReClockI2CGuard(ReClockI2C &reClock, uint32_t desiredClock) : reClock(reClock), previousClock(reClock.setClock(desiredClock))
|
||||
{
|
||||
}
|
||||
~ReClockI2CGuard() { reClock.restoreClock(previousClock); }
|
||||
ReClockI2CGuard(const ReClockI2CGuard &) = delete;
|
||||
ReClockI2CGuard &operator=(const ReClockI2CGuard &) = delete;
|
||||
|
||||
private:
|
||||
ReClockI2C &reClock;
|
||||
uint32_t previousClock;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -31,27 +31,27 @@ ScanI2C::FoundDevice ScanI2C::firstRTC() const
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
|
||||
{
|
||||
ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MPR121KB, TCA8418KB};
|
||||
return firstOfOrNONE(6, types);
|
||||
ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MPR121KB, TCA8418KB, STC8HKB};
|
||||
return firstOfOrNONE(7, types);
|
||||
}
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
|
||||
{
|
||||
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, SC7A20, BMA423, LSM6DS3, BMX160, STK8BAXX,
|
||||
ICM20948, BMM150, BMI270, ICM42607P, ISM330DHCX, QMA6100P};
|
||||
return firstOfOrNONE(13, types);
|
||||
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, SC7A20, BMA423, LSM6DS3, BMX160, STK8BAXX, ICM20948,
|
||||
BMM150, BMI270, BHI260AP, ICM42607P, ISM330DHCX, QMA6100P, QMI8658};
|
||||
return firstOfOrNONE(15, types);
|
||||
}
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
|
||||
{
|
||||
ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR};
|
||||
return firstOfOrNONE(2, types);
|
||||
ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR, QMC6309};
|
||||
return firstOfOrNONE(3, types);
|
||||
}
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstAQI() const
|
||||
{
|
||||
ScanI2C::DeviceType types[] = {PMSA003I, SEN5X, SCD4X, SFA30};
|
||||
return firstOfOrNONE(4, types);
|
||||
ScanI2C::DeviceType types[] = {PMSA003I, SEN5X, SEN6X, SCD4X, SFA30};
|
||||
return firstOfOrNONE(5, types);
|
||||
}
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstRGBLED() const
|
||||
|
||||
@@ -42,6 +42,7 @@ class ScanI2C
|
||||
QMC5883L,
|
||||
HMC5883L,
|
||||
MMC5983MA,
|
||||
QMC6309,
|
||||
PMSA003I,
|
||||
QMA6100P,
|
||||
MPU6050,
|
||||
@@ -96,16 +97,20 @@ class ScanI2C
|
||||
CST3530,
|
||||
BMI270,
|
||||
SEN5X,
|
||||
SEN6X,
|
||||
SFA30,
|
||||
CW2015,
|
||||
SCD30,
|
||||
ADS1115,
|
||||
ADS1X15,
|
||||
ADS1X15_ALT,
|
||||
IIS2MDCTR,
|
||||
ISM330DHCX,
|
||||
SPA06,
|
||||
STC8HKB, // STC8H companion-MCU keypad (ThinkNode-M9)
|
||||
DS248X,
|
||||
HM330X
|
||||
} DeviceType;
|
||||
HM330X,
|
||||
AS3935
|
||||
} DeviceType;
|
||||
|
||||
// typedef uint8_t DeviceAddress;
|
||||
typedef enum I2CPort {
|
||||
|
||||
@@ -160,12 +160,19 @@ bool ScanI2CTwoWire::i2cCommandResponseLength(ScanI2C::DeviceAddress addr, uint1
|
||||
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
||||
#include "../modules/Telemetry/Sensor/SEN5XSensor.h"
|
||||
#include "../modules/Telemetry/Sensor/SEN6XSensor.h"
|
||||
bool probeSEN5X(TwoWire *i2cBus, uint8_t address, ScanI2C::I2CPort port)
|
||||
{
|
||||
SEN5XSensor sen5xsensor;
|
||||
return sen5xsensor.probe(i2cBus, address, port);
|
||||
}
|
||||
|
||||
bool probeSEN6X(TwoWire *i2cBus, uint8_t address, ScanI2C::I2CPort port)
|
||||
{
|
||||
SEN6XSensor sen6xsensor;
|
||||
return sen6xsensor.probe(i2cBus, address, port);
|
||||
}
|
||||
|
||||
bool probeHM330x(TwoWire *i2cBus, uint8_t address)
|
||||
{
|
||||
|
||||
@@ -437,6 +444,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
type = BBQ10KB;
|
||||
logFoundDevice("BB Q10", (uint8_t)addr.address);
|
||||
break;
|
||||
SCAN_SIMPLE_CASE(TSTC8_KB_ADDR, STC8HKB, "STC8H KB", (uint8_t)addr.address);
|
||||
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);
|
||||
@@ -700,7 +708,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("QMC6310U", (uint8_t)addr.address);
|
||||
break;
|
||||
|
||||
case QMI8658_ADDR:
|
||||
case QMI8658_ADDR: // same as BQ25896_ADDR and SEN6X_ADDR
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x0A), 1); // get ID
|
||||
if (registerValue == 0xC0) {
|
||||
type = BQ24295;
|
||||
@@ -721,6 +729,13 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
type = ISM330DHCX;
|
||||
logFoundDevice("ISM330DHCX", (uint8_t)addr.address);
|
||||
} else {
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR
|
||||
if (probeSEN6X(i2cBus, addr.address, port)) {
|
||||
type = SEN6X;
|
||||
logFoundDevice("SEN6X", addr.address);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
type = QMI8658;
|
||||
logFoundDevice("QMI8658", (uint8_t)addr.address);
|
||||
}
|
||||
@@ -743,7 +758,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
logFoundDevice("DS2482-800", (uint8_t)addr.address);
|
||||
break;
|
||||
}
|
||||
|
||||
type = HMC5883L;
|
||||
logFoundDevice("HMC5883L", (uint8_t)addr.address);
|
||||
break;
|
||||
@@ -1041,10 +1055,11 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
break;
|
||||
}
|
||||
|
||||
// ADS1X15 default config register is 8583h
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 2);
|
||||
if (registerValue == 0x8583 || registerValue == 0x8580) {
|
||||
type = ADS1115;
|
||||
logFoundDevice("ADS1115 ADC", (uint8_t)addr.address);
|
||||
if (registerValue == 0x8583 || registerValue == 0x8580 || registerValue == 0xf700) {
|
||||
type = ADS1X15;
|
||||
logFoundDevice("ADS1X15 ADC", (uint8_t)addr.address);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1053,6 +1068,19 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
break;
|
||||
}
|
||||
|
||||
case ADS1X15_ADDR_ALT1:
|
||||
case ADS1X15_ADDR_ALT2:
|
||||
case ADS1X15_ADDR_ALT3: {
|
||||
// ADS1X15 default config register is 8583h
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 2);
|
||||
if (registerValue == 0x8583 || registerValue == 0x8580 || registerValue == 0xf700) {
|
||||
type = ADS1X15_ALT;
|
||||
logFoundDevice("ADS1X15_ALT", (uint8_t)addr.address);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
LOG_INFO("Device found at address 0x%x was not able to be enumerated", (uint8_t)addr.address);
|
||||
}
|
||||
@@ -1066,6 +1094,54 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
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;
|
||||
|
||||
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]);
|
||||
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.
|
||||
#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
|
||||
}
|
||||
|
||||
void ScanI2CTwoWire::scanPort(I2CPort port)
|
||||
|
||||
+85
-96
@@ -5,10 +5,12 @@
|
||||
#if !MESHTASTIC_EXCLUDE_GPS
|
||||
#include "Default.h"
|
||||
#include "GPS.h"
|
||||
#include "GPSLog.h"
|
||||
#include "GpioLogic.h"
|
||||
#include "NodeDB.h"
|
||||
#include "PowerMon.h"
|
||||
#include "Throttle.h"
|
||||
#include "UptimeClock.h"
|
||||
#include "buzz.h"
|
||||
#include "concurrency/Periodic.h"
|
||||
#include "gps/RTC.h"
|
||||
@@ -337,7 +339,7 @@ uint8_t GPS::makeCASPacket(uint8_t class_id, uint8_t msg_id, uint8_t payload_siz
|
||||
}
|
||||
CASChecksum(UBXscratch, (payload_size + 10));
|
||||
|
||||
#if defined(GPS_DEBUG) && defined(DEBUG_PORT)
|
||||
#if GPS_DEBUG && defined(DEBUG_PORT)
|
||||
LOG_DEBUG("CAS packet: ");
|
||||
DEBUG_PORT.hexDump(MESHTASTIC_LOG_LEVEL_DEBUG, UBXscratch, payload_size + 10);
|
||||
#endif
|
||||
@@ -349,27 +351,25 @@ GPS_RESPONSE GPS::getACK(const char *message, uint32_t waitMillis)
|
||||
uint8_t buffer[768] = {0};
|
||||
uint8_t b;
|
||||
int bytesRead = 0;
|
||||
uint32_t startTimeout = millis() + waitMillis;
|
||||
#ifdef GPS_DEBUG
|
||||
// Start stamp + interval rather than a stored deadline: same wrap-safety, but the full 49.7-day
|
||||
// range instead of 24.8 days ahead, and Time::getMillis() makes the wait injectable.
|
||||
const uint32_t waitStartMs = Time::getMillis();
|
||||
#if GPS_DEBUG
|
||||
std::string debugmsg = "";
|
||||
#endif
|
||||
while (millis() < startTimeout) {
|
||||
while (Throttle::isWithinTimespanMs(waitStartMs, waitMillis)) {
|
||||
if (_serial_gps->available()) {
|
||||
b = _serial_gps->read();
|
||||
|
||||
#ifdef GPS_DEBUG
|
||||
#if GPS_DEBUG
|
||||
debugmsg += vformat("%c", (b >= 32 && b <= 126) ? b : '.');
|
||||
#endif
|
||||
buffer[bytesRead] = b;
|
||||
bytesRead++;
|
||||
if ((bytesRead == 767) || (b == '\r')) {
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG(debugmsg.c_str());
|
||||
#endif
|
||||
LOG_DEBUG_GPS("%s", debugmsg.c_str());
|
||||
if (strnstr((char *)buffer, message, bytesRead) != nullptr) {
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("Found: %s", message); // Log the found message
|
||||
#endif
|
||||
LOG_DEBUG_GPS("Found: %s", message); // Log the found message
|
||||
return GNSS_RESPONSE_OK;
|
||||
} else {
|
||||
bytesRead = 0;
|
||||
@@ -418,17 +418,13 @@ GPS_RESPONSE GPS::getACKCas(uint8_t class_id, uint8_t msg_id, uint32_t waitMilli
|
||||
|
||||
// Check for an ACK-ACK for the specified class and message id
|
||||
if ((msg_cls == 0x05) && (msg_msg_id == 0x01) && payload_cls == class_id && payload_msg == msg_id) {
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_INFO("Got ACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime);
|
||||
#endif
|
||||
LOG_DEBUG_GPS("Got ACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime);
|
||||
return GNSS_RESPONSE_OK;
|
||||
}
|
||||
|
||||
// Check for an ACK-NACK for the specified class and message id
|
||||
if ((msg_cls == 0x05) && (msg_msg_id == 0x00) && payload_cls == class_id && payload_msg == msg_id) {
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_WARN("Got NACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime);
|
||||
#endif
|
||||
LOG_DEBUG_GPS("Got NACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime);
|
||||
return GNSS_RESPONSE_NAK;
|
||||
}
|
||||
|
||||
@@ -450,7 +446,7 @@ GPS_RESPONSE GPS::getACK(uint8_t class_id, uint8_t msg_id, uint32_t waitMillis)
|
||||
uint32_t startTime = millis();
|
||||
const char frame_errors[] = "More than 100 frame errors";
|
||||
int sCounter = 0;
|
||||
#ifdef GPS_DEBUG
|
||||
#if GPS_DEBUG
|
||||
std::string debugmsg = "";
|
||||
#endif
|
||||
|
||||
@@ -467,9 +463,7 @@ GPS_RESPONSE GPS::getACK(uint8_t class_id, uint8_t msg_id, uint32_t waitMillis)
|
||||
|
||||
while (Throttle::isWithinTimespanMs(startTime, waitMillis)) {
|
||||
if (ack > 9) {
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_INFO("Got ACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime);
|
||||
#endif
|
||||
LOG_DEBUG_GPS("Got ACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime);
|
||||
return GNSS_RESPONSE_OK; // ACK received
|
||||
}
|
||||
if (_serial_gps->available()) {
|
||||
@@ -477,25 +471,20 @@ GPS_RESPONSE GPS::getACK(uint8_t class_id, uint8_t msg_id, uint32_t waitMillis)
|
||||
if (b == frame_errors[sCounter]) {
|
||||
sCounter++;
|
||||
if (sCounter == 26) {
|
||||
#ifdef GPS_DEBUG
|
||||
|
||||
LOG_DEBUG(debugmsg.c_str());
|
||||
#endif
|
||||
LOG_DEBUG_GPS("%s", debugmsg.c_str());
|
||||
return GNSS_RESPONSE_FRAME_ERRORS;
|
||||
}
|
||||
} else {
|
||||
sCounter = 0;
|
||||
}
|
||||
#ifdef GPS_DEBUG
|
||||
#if GPS_DEBUG
|
||||
debugmsg += vformat("%02X", b);
|
||||
#endif
|
||||
if (b == buf[ack]) {
|
||||
ack++;
|
||||
} else {
|
||||
if (ack == 3 && b == 0x00) { // UBX-ACK-NAK message
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG(debugmsg.c_str());
|
||||
#endif
|
||||
LOG_DEBUG_GPS("%s", debugmsg.c_str());
|
||||
LOG_WARN("Got NAK for class %02X msg %02X", class_id, msg_id);
|
||||
return GNSS_RESPONSE_NAK; // NAK received
|
||||
}
|
||||
@@ -503,10 +492,8 @@ GPS_RESPONSE GPS::getACK(uint8_t class_id, uint8_t msg_id, uint32_t waitMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG(debugmsg.c_str());
|
||||
LOG_WARN("No response for class %02X msg %02X", class_id, msg_id);
|
||||
#endif
|
||||
LOG_DEBUG_GPS("%s", debugmsg.c_str());
|
||||
LOG_DEBUG_GPS("No response for class %02X msg %02X", class_id, msg_id);
|
||||
return GNSS_RESPONSE_NONE; // No response received within timeout
|
||||
}
|
||||
|
||||
@@ -577,9 +564,7 @@ int GPS::getACK(uint8_t *buffer, uint16_t size, uint8_t requestedClass, uint8_t
|
||||
ubxFrameCounter = 0;
|
||||
} else {
|
||||
// return payload length
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_INFO("Got ACK for class %02X msg %02X in %dms", requestedClass, requestedID, millis() - startTime);
|
||||
#endif
|
||||
LOG_DEBUG_GPS("Got ACK for class %02X msg %02X in %dms", requestedClass, requestedID, millis() - startTime);
|
||||
return needRead;
|
||||
}
|
||||
break;
|
||||
@@ -757,6 +742,11 @@ bool GPS::verifyCachedProbePresence()
|
||||
_serial_gps->write("$PDTINFO\r\n");
|
||||
present = (getACK("CM121", 900) == GNSS_RESPONSE_OK);
|
||||
break;
|
||||
case GNSS_MODEL_LC760CA:
|
||||
cachedProbeModelName = "LC760CA";
|
||||
_serial_gps->write("$PDTINFO\r\n");
|
||||
present = (getACK("CC1161W", 900) == GNSS_RESPONSE_OK);
|
||||
break;
|
||||
case GNSS_MODEL_UBLOX6:
|
||||
case GNSS_MODEL_UBLOX7:
|
||||
case GNSS_MODEL_UBLOX8:
|
||||
@@ -1130,7 +1120,7 @@ bool GPS::setup()
|
||||
} else {
|
||||
LOG_INFO("GNSS module config saved");
|
||||
}
|
||||
} else if (gnssModel == GNSS_MODEL_CM121) {
|
||||
} else if (IS_ONE_OF(gnssModel, GNSS_MODEL_CM121, GNSS_MODEL_LC760CA)) {
|
||||
// only ask for RMC and GGA
|
||||
// enable GGA
|
||||
_serial_gps->write("$CFGMSG,0,0,1,1*1B\r\n");
|
||||
@@ -1234,9 +1224,7 @@ void GPS::writePinEN(bool on)
|
||||
|
||||
// Write and log
|
||||
enablePin->set(on);
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("Pin EN %s", on == HIGH ? "HI" : "LOW");
|
||||
#endif
|
||||
LOG_DEBUG_GPS("Pin EN %s", on == HIGH ? "HI" : "LOW");
|
||||
}
|
||||
|
||||
// Set the value of the STANDBY pin, if relevant
|
||||
@@ -1259,9 +1247,7 @@ void GPS::writePinStandby(bool standby)
|
||||
_serial_gps->write("$PMTK225,4*2F\r\n");
|
||||
}
|
||||
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("Pin STANDBY %s", val == HIGH ? "HI" : "LOW");
|
||||
#endif
|
||||
LOG_DEBUG_GPS("Pin STANDBY %s", val == HIGH ? "HI" : "LOW");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1272,9 +1258,7 @@ void GPS::writePinRFEN(bool on)
|
||||
bool val = on ? GPS_RF_EN_ACTIVE : !GPS_RF_EN_ACTIVE;
|
||||
pinMode(PIN_GPS_RF_EN, OUTPUT);
|
||||
digitalWrite(PIN_GPS_RF_EN, val);
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("Pin RF EN %s", val == HIGH ? "HI" : "LOW");
|
||||
#endif
|
||||
LOG_DEBUG_GPS("Pin RF EN %s", val == HIGH ? "HI" : "LOW");
|
||||
#else
|
||||
(void)on;
|
||||
#endif
|
||||
@@ -1302,17 +1286,15 @@ void GPS::setPowerPMU(bool on)
|
||||
} else if (HW_VENDOR == meshtastic_HardwareModel_LILYGO_TBEAM_S3_CORE) {
|
||||
// t-beam-s3-core GNSS power channel
|
||||
on ? PMU->enablePowerOutput(XPOWERS_ALDO4) : PMU->disablePowerOutput(XPOWERS_ALDO4);
|
||||
} else if (HW_VENDOR == meshtastic_HardwareModel_T_WATCH_S3) {
|
||||
// t-watch-s3-plus GNSS power channel
|
||||
} else if (HW_VENDOR == meshtastic_HardwareModel_T_WATCH_ULTRA || HW_VENDOR == meshtastic_HardwareModel_T_WATCH_S3) {
|
||||
// t-watch-ultra / t-watch-s3-plus GNSS power channel
|
||||
on ? PMU->enablePowerOutput(XPOWERS_BLDO1) : PMU->disablePowerOutput(XPOWERS_BLDO1);
|
||||
}
|
||||
} else if (model == XPOWERS_AXP192) {
|
||||
// t-beam v1.1 GNSS power channel
|
||||
on ? PMU->enablePowerOutput(XPOWERS_LDO3) : PMU->disablePowerOutput(XPOWERS_LDO3);
|
||||
}
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("PMU %s", on ? "on" : "off");
|
||||
#endif
|
||||
LOG_DEBUG_GPS("PMU %s", on ? "on" : "off");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1358,9 +1340,7 @@ void GPS::setPowerUBLOX(bool on, uint32_t sleepMs)
|
||||
|
||||
// Send the UBX packet
|
||||
gps->_serial_gps->write(gps->UBXscratch, msglen);
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("UBLOX: sleep for %dmS", sleepMs);
|
||||
#endif
|
||||
LOG_DEBUG_GPS("UBLOX: sleep for %dmS", sleepMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,11 +1397,7 @@ void GPS::down()
|
||||
#endif
|
||||
|
||||
if (softsleepSupported) {
|
||||
// How long does gps_update_interval need to be, for GPS_HARDSLEEP to become more efficient than
|
||||
// GPS_SOFTSLEEP? Heuristic equation. A compromise manually fitted to power observations from U-blox NEO-6M
|
||||
// and M10050 https://www.desmos.com/calculator/6gvjghoumr This is not particularly accurate, but probably an
|
||||
// improvement over a single, fixed threshold
|
||||
uint32_t hardsleepThreshold = (2750 * pow(predictedSearchDuration / 1000, 1.22));
|
||||
uint32_t hardsleepThreshold = gpsHardsleepThresholdMs(predictedSearchDuration / 1000);
|
||||
LOG_DEBUG("gps_update_interval >= %us needed for hardsleep", hardsleepThreshold / 1000);
|
||||
|
||||
// If update interval too short: softsleep (if supported by hardware)
|
||||
@@ -1454,6 +1430,29 @@ void GPS::publishUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a post-lock ephemeris hold currently in force? The `!= 0` is the "never armed" sentinel, which
|
||||
/// deadlinePassed() reads as passed for the first half of each wrap cycle and as ~24.8 days in the
|
||||
/// future for the second. No header: test_gps_fix_hold declares the prototypes itself.
|
||||
bool fixHoldInForce(uint32_t fixHoldEnds, uint32_t threadIntervalMs)
|
||||
{
|
||||
return fixHoldEnds != 0 && !Throttle::deadlinePassed(fixHoldEnds + threadIntervalMs);
|
||||
}
|
||||
|
||||
/// Did an armed hold just expire? `!= 0` guards against negating fixHoldInForce() alone, which would
|
||||
/// call an unarmed hold "expired" every cycle. No grace interval: the deadline itself is go-down time.
|
||||
bool holdJustExpired(uint32_t fixHoldEnds)
|
||||
{
|
||||
return fixHoldEnds != 0 && !fixHoldInForce(fixHoldEnds, 0);
|
||||
}
|
||||
|
||||
/// Should a post-lock ephemeris hold be (re-)armed this cycle? "No hold in force" fires often, since
|
||||
/// every publish clears the hold, including ones that don't put the receiver back to sleep.
|
||||
bool shouldArmFixHold(bool hasValidLocation, uint8_t prevFixQual, uint32_t fixHoldEnds, uint32_t threadIntervalMs)
|
||||
{
|
||||
// First lock of a cycle, first lock after the receiver was off, or nothing holding right now.
|
||||
return !hasValidLocation || prevFixQual == 0 || !fixHoldInForce(fixHoldEnds, threadIntervalMs);
|
||||
}
|
||||
|
||||
int32_t GPS::runOnce()
|
||||
{
|
||||
#if defined(SENSECAP_INDICATOR)
|
||||
@@ -1546,7 +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) {
|
||||
#ifdef GPS_DEBUG
|
||||
#if GPS_DEBUG
|
||||
if (!hasValidLocation) { // declare that we have location ASAP
|
||||
LOG_DEBUG("hasValidLocation RISING EDGE");
|
||||
}
|
||||
@@ -1554,16 +1553,16 @@ int32_t GPS::runOnce()
|
||||
if (updateInterval <= GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS) {
|
||||
hasValidLocation = true;
|
||||
shouldPublish = true;
|
||||
} else if (!hasValidLocation || prev_fixQual == 0 || (fixHoldEnds + GPS_THREAD_INTERVAL) < millis()) {
|
||||
} else if (shouldArmFixHold(hasValidLocation, prev_fixQual, fixHoldEnds, GPS_THREAD_INTERVAL)) {
|
||||
hasValidLocation = true;
|
||||
// Hold for up to 20secs after getting a lock to download ephemeris etc
|
||||
uint32_t holdTime = updateInterval - GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS;
|
||||
if (holdTime > GPS_FIX_HOLD_MAX_MS)
|
||||
holdTime = GPS_FIX_HOLD_MAX_MS;
|
||||
fixHoldEnds = millis() + holdTime;
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("Holding for %ums after lock", holdTime);
|
||||
#endif
|
||||
// Same clock the Throttle evaluation reads, and never the "no hold" sentinel.
|
||||
const uint32_t holdEnds = Time::getMillis() + holdTime;
|
||||
fixHoldEnds = holdEnds == 0 ? 1 : holdEnds;
|
||||
LOG_DEBUG_GPS("Holding for %ums after lock", holdTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1575,14 +1574,12 @@ int32_t GPS::runOnce()
|
||||
p = meshtastic_Position_init_default;
|
||||
hasValidLocation = false;
|
||||
shouldPublish = true;
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("hasValidLocation FALLING EDGE");
|
||||
#endif
|
||||
LOG_DEBUG_GPS("hasValidLocation FALLING EDGE");
|
||||
}
|
||||
}
|
||||
|
||||
// Hold has expired , Search time has expired, we got a time only, or we never needed to hold.
|
||||
bool holdExpired = (fixHoldEnds != 0 && millis() > fixHoldEnds);
|
||||
bool holdExpired = holdJustExpired(fixHoldEnds);
|
||||
if (shouldPublish || tooLong || holdExpired) {
|
||||
if (gotTime && hasValidLocation) {
|
||||
shouldPublish = true;
|
||||
@@ -1597,9 +1594,9 @@ int32_t GPS::runOnce()
|
||||
down();
|
||||
}
|
||||
|
||||
#ifdef GPS_DEBUG
|
||||
#if GPS_DEBUG
|
||||
} else if (fixHoldEnds != 0) {
|
||||
LOG_DEBUG("Holding for GPS data download: %d ms (numSats=%d)", fixHoldEnds - millis(), p.sats_in_view);
|
||||
LOG_DEBUG("Holding for GPS data download: %d ms (numSats=%d)", fixHoldEnds - Time::getMillis(), p.sats_in_view);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1694,7 +1691,7 @@ GnssModel_t GPS::probe(int serialSpeed)
|
||||
{"AG3335", "$PAIR021,AG3335", GNSS_MODEL_AG3335},
|
||||
{"AG3352", "$PAIR021,AG3352", GNSS_MODEL_AG3352},
|
||||
{"RYS3520", "$PAIR021,REYAX_RYS3520_V2", GNSS_MODEL_AG3352},
|
||||
{"UC6580", "UC6580", GNSS_MODEL_UC6580},
|
||||
{"UC6580", "UC6580", GNSS_MODEL_UC6580}
|
||||
// as L76K is sort of a last ditch effort, we won't attempt to detect it by startup messages for now.
|
||||
/*{"L76K", "SW=URANUS", GNSS_MODEL_MTK}*/};
|
||||
GnssModel_t detectedDriver = getProbeResponse(500, passive_detect, serialSpeed);
|
||||
@@ -1721,8 +1718,11 @@ GnssModel_t GPS::probe(int serialSpeed)
|
||||
case 1: {
|
||||
|
||||
// Unicore UFirebirdII Series: UC6580, UM620, UM621, UM670A, UM680A, or UM681A,or CM121
|
||||
std::vector<ChipInfo> unicore = {
|
||||
{"UC6580", "UC6580", GNSS_MODEL_UC6580}, {"UM600", "UM600", GNSS_MODEL_UC6580}, {"CM121", "CM121", GNSS_MODEL_CM121}};
|
||||
std::vector<ChipInfo> unicore = {{"UC6580", "UC6580", GNSS_MODEL_UC6580},
|
||||
{"UM600", "UM600", GNSS_MODEL_UC6580},
|
||||
{"CM121", "CM121", GNSS_MODEL_CM121},
|
||||
{"CC1167Q", "CC1167Q", GNSS_MODEL_CM121},
|
||||
{"LC760CA", "CC1161W", GNSS_MODEL_LC760CA}};
|
||||
PROBE_FAMILY("Unicore Family", "$PDTINFO", unicore, 500);
|
||||
currentDelay = 20;
|
||||
currentStep = 2;
|
||||
@@ -1824,7 +1824,6 @@ GnssModel_t GPS::probe(int serialSpeed)
|
||||
break;
|
||||
}
|
||||
|
||||
LOG_DEBUG("Module Info : ");
|
||||
LOG_DEBUG("Soft version: %s", ublox_info.swVersion);
|
||||
LOG_DEBUG("Hard version: %s", ublox_info.hwVersion);
|
||||
LOG_DEBUG("Extensions:%d", ublox_info.extensionNo);
|
||||
@@ -1904,27 +1903,21 @@ GnssModel_t GPS::getProbeResponse(unsigned long timeout, const std::vector<ChipI
|
||||
// check if we can see our chips
|
||||
for (const auto &chipInfo : responseMap) {
|
||||
if (strstr(response.get(), chipInfo.detectionString.c_str()) != nullptr) {
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG(response.get());
|
||||
#endif
|
||||
LOG_DEBUG_GPS("%s", response.get());
|
||||
LOG_INFO("%s detected", chipInfo.chipName.c_str());
|
||||
return chipInfo.driver;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (responseLen >= 2 && response[responseLen - 2] == '\r' && response[responseLen - 1] == '\n') {
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG(response.get());
|
||||
#endif
|
||||
LOG_DEBUG_GPS("%s", response.get());
|
||||
// Reset the response buffer for the next potential message
|
||||
responseLen = 0;
|
||||
response[0] = '\0';
|
||||
}
|
||||
}
|
||||
}
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG(response.get());
|
||||
#endif
|
||||
LOG_DEBUG_GPS("%s", response.get());
|
||||
return GNSS_MODEL_UNKNOWN; // Return unknown on timeout
|
||||
}
|
||||
|
||||
@@ -2125,7 +2118,7 @@ bool GPS::lookForLocation()
|
||||
#ifndef TINYGPS_OPTION_NO_STATISTICS
|
||||
if (reader.failedChecksum() > lastChecksumFailCount) {
|
||||
// In a GPS_DEBUG build we want to log all of these. In production, we only care if there are many of them.
|
||||
#ifndef GPS_DEBUG
|
||||
#if !GPS_DEBUG
|
||||
if (reader.failedChecksum() > 4)
|
||||
#endif
|
||||
LOG_WARN("%u new GPS checksum failures, total %u", reader.failedChecksum() - lastChecksumFailCount,
|
||||
@@ -2142,7 +2135,7 @@ bool GPS::lookForLocation()
|
||||
if (!hasLock())
|
||||
return false;
|
||||
|
||||
#ifdef GPS_DEBUG
|
||||
#if GPS_DEBUG
|
||||
LOG_DEBUG("AGE: LOC=%d FIX=%d DATE=%d TIME=%d", reader.location.age(),
|
||||
#ifndef TINYGPS_OPTION_NO_CUSTOM_FIELDS
|
||||
gsafixtype.age(),
|
||||
@@ -2173,15 +2166,11 @@ bool GPS::lookForLocation()
|
||||
|
||||
// Bail out EARLY to avoid overwriting previous good data (like #857)
|
||||
if (toDegInt(loc.lat) > 900000000) {
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("Bail out EARLY on LAT %i", toDegInt(loc.lat));
|
||||
#endif
|
||||
LOG_DEBUG_GPS("Bail out EARLY on LAT %i", toDegInt(loc.lat));
|
||||
return false;
|
||||
}
|
||||
if (toDegInt(loc.lng) > 1800000000) {
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("Bail out EARLY on LNG %i", toDegInt(loc.lng));
|
||||
#endif
|
||||
LOG_DEBUG_GPS("Bail out EARLY on LNG %i", toDegInt(loc.lng));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2266,7 +2255,7 @@ bool GPS::whileActive()
|
||||
{
|
||||
unsigned int charsInBuf = 0;
|
||||
bool isValid = false;
|
||||
#ifdef GPS_DEBUG
|
||||
#if GPS_DEBUG
|
||||
std::string debugmsg = "";
|
||||
#endif
|
||||
if (powerState != GPS_ACTIVE) {
|
||||
@@ -2283,7 +2272,7 @@ bool GPS::whileActive()
|
||||
while (_serial_gps->available() > 0) {
|
||||
int c = _serial_gps->read();
|
||||
UBXscratch[charsInBuf] = c;
|
||||
#ifdef GPS_DEBUG
|
||||
#if GPS_DEBUG
|
||||
debugmsg += vformat("%c", (c >= 32 && c <= 126) ? c : '.');
|
||||
#endif
|
||||
isValid |= reader.encode(c);
|
||||
@@ -2296,9 +2285,9 @@ bool GPS::whileActive()
|
||||
charsInBuf++;
|
||||
}
|
||||
}
|
||||
#ifdef GPS_DEBUG
|
||||
#if GPS_DEBUG
|
||||
if (debugmsg != "") {
|
||||
LOG_DEBUG(debugmsg.c_str());
|
||||
LOG_DEBUG("%s", debugmsg.c_str());
|
||||
}
|
||||
#endif
|
||||
return isValid;
|
||||
|
||||
@@ -52,6 +52,9 @@ typedef enum {
|
||||
GNSS_MODEL_AG3352,
|
||||
GNSS_MODEL_LS20031,
|
||||
GNSS_MODEL_CM121,
|
||||
GNSS_MODEL_LC760CA,
|
||||
// Keep GNSS_MODEL_GENERIC_NMEA last: isValidGnssModel() uses it as the exclusive upper bound
|
||||
// for values the probe cache is allowed to hold.
|
||||
GNSS_MODEL_GENERIC_NMEA // generic NMEA source (e.g. gpsd); skips chip-specific probe and init
|
||||
} GnssModel_t;
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include "DebugConfiguration.h"
|
||||
|
||||
// GPS_DEBUG=1 enables verbose GNSS diagnostics (probe/ACK byte dumps, pin states, NMEA ages).
|
||||
// Costs no flash when off. Genuine LOG_WARN anomalies stay unconditional.
|
||||
#ifndef GPS_DEBUG
|
||||
#define GPS_DEBUG 0
|
||||
#endif
|
||||
#if GPS_DEBUG
|
||||
#define LOG_DEBUG_GPS(...) LOG_DEBUG(__VA_ARGS__)
|
||||
#else
|
||||
#define LOG_DEBUG_GPS(...) ((void)0)
|
||||
#endif
|
||||
@@ -1,18 +1,46 @@
|
||||
#include "GPSUpdateScheduling.h"
|
||||
|
||||
#include "Default.h"
|
||||
#include "UptimeClock.h"
|
||||
|
||||
// Sampled from the original `2750 * seconds^1.22` curve. Interpolation tracks it within 0.6% for
|
||||
// inputs >=10s and 1.7% below that; the 1s/2s/3s points keep the convex first segment from
|
||||
// overshooting (a 0s-to-5s chord reads 42% high at 1s).
|
||||
static constexpr uint32_t kThresholdCurveSecs[] = {0, 1, 2, 3, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 300, 450, 600, 900};
|
||||
static constexpr uint32_t kThresholdCurveMs[] = {0, 2750, 6406, 10506, 19592, 45639, 74845,
|
||||
106314, 174350, 285925, 406141, 666053, 946093, 1551548,
|
||||
2203893, 2893481, 4745172, 6740269, 11053722};
|
||||
static constexpr size_t kThresholdCurvePoints = sizeof(kThresholdCurveSecs) / sizeof(kThresholdCurveSecs[0]);
|
||||
|
||||
// How long does gps_update_interval need to be, for GPS_HARDSLEEP to become more efficient than
|
||||
// GPS_SOFTSLEEP? Avoids pow() so this heuristic doesn't pull double-precision libm into the image.
|
||||
uint32_t gpsHardsleepThresholdMs(uint32_t predictedSearchSecs)
|
||||
{
|
||||
if (predictedSearchSecs >= kThresholdCurveSecs[kThresholdCurvePoints - 1])
|
||||
return kThresholdCurveMs[kThresholdCurvePoints - 1];
|
||||
|
||||
size_t i = 1;
|
||||
while (kThresholdCurveSecs[i] < predictedSearchSecs)
|
||||
i++;
|
||||
|
||||
uint32_t x0 = kThresholdCurveSecs[i - 1], x1 = kThresholdCurveSecs[i];
|
||||
uint32_t y0 = kThresholdCurveMs[i - 1], y1 = kThresholdCurveMs[i];
|
||||
return y0 + (uint32_t)((uint64_t)(y1 - y0) * (predictedSearchSecs - x0) / (x1 - x0));
|
||||
}
|
||||
|
||||
// Mark the time when searching for GPS position begins
|
||||
void GPSUpdateScheduling::informSearching()
|
||||
{
|
||||
searchStartedMs = millis();
|
||||
searching = true;
|
||||
searchStartedMs = Time::getMillis();
|
||||
}
|
||||
|
||||
// Mark the time when searching for GPS is complete,
|
||||
// then update the predicted lock-time
|
||||
void GPSUpdateScheduling::informGotLock()
|
||||
{
|
||||
searchEndedMs = millis();
|
||||
searching = false;
|
||||
searchEndedMs = Time::getMillis();
|
||||
LOG_DEBUG("Took %us to get lock", (searchEndedMs - searchStartedMs) / 1000);
|
||||
updateLockTimePrediction();
|
||||
consecutiveFailures = 0; // Drop back to fast cadence as soon as we acquire any fix
|
||||
@@ -24,7 +52,8 @@ void GPSUpdateScheduling::informGotLock()
|
||||
// down() to fall into GPS_IDLE, leaving the chip awake on subsequent indoor cycles.
|
||||
void GPSUpdateScheduling::informSearchFailed()
|
||||
{
|
||||
searchEndedMs = millis();
|
||||
searching = false;
|
||||
searchEndedMs = Time::getMillis();
|
||||
consecutiveFailures++;
|
||||
LOG_DEBUG("GPS search ended without fix after %us (consecutive failures: %u)", (searchEndedMs - searchStartedMs) / 1000,
|
||||
consecutiveFailures);
|
||||
@@ -34,6 +63,7 @@ void GPSUpdateScheduling::informSearchFailed()
|
||||
// When re-enabling GPS with user button.
|
||||
void GPSUpdateScheduling::reset()
|
||||
{
|
||||
searching = false;
|
||||
searchStartedMs = 0;
|
||||
searchEndedMs = 0;
|
||||
searchCount = 0;
|
||||
@@ -45,7 +75,7 @@ void GPSUpdateScheduling::reset()
|
||||
// Used by GPS hardware directly, to enter timed hardware sleep
|
||||
uint32_t GPSUpdateScheduling::msUntilNextSearch()
|
||||
{
|
||||
uint32_t now = millis();
|
||||
uint32_t now = Time::getMillis();
|
||||
|
||||
// Target interval (seconds), between GPS updates
|
||||
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval, default_gps_update_interval);
|
||||
@@ -80,13 +110,12 @@ uint32_t GPSUpdateScheduling::msUntilNextSearch()
|
||||
// Used to abort a search in progress, if it runs unacceptably long
|
||||
uint32_t GPSUpdateScheduling::elapsedSearchMs()
|
||||
{
|
||||
// If searching
|
||||
if (searchStartedMs > searchEndedMs)
|
||||
return millis() - searchStartedMs;
|
||||
// Recorded, not inferred from searchStartedMs > searchEndedMs: ordering two stamps inverts
|
||||
// across the 32-bit wrap, and the inform*() calls already know which state we are in.
|
||||
if (!searching)
|
||||
return 0; // Not searching. We shouldn't really consume this value
|
||||
|
||||
// If not searching - 0ms. We shouldn't really consume this value
|
||||
else
|
||||
return 0;
|
||||
return Time::getMillis() - searchStartedMs;
|
||||
}
|
||||
|
||||
// Is it now time to begin searching for a GPS position?
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
// Approximates the GPS_HARDSLEEP/GPS_SOFTSLEEP crossover curve without pow(); see .cpp for the
|
||||
// sampled reference values it interpolates between.
|
||||
uint32_t gpsHardsleepThresholdMs(uint32_t predictedSearchSecs);
|
||||
|
||||
// Encapsulates code responsible for the timing of GPS updates
|
||||
class GPSUpdateScheduling
|
||||
{
|
||||
@@ -21,6 +25,7 @@ class GPSUpdateScheduling
|
||||
|
||||
private:
|
||||
void updateLockTimePrediction(); // Called from informGotLock
|
||||
bool searching = false; // Set by the inform*() calls; never inferred from stamp ordering
|
||||
uint32_t searchStartedMs = 0;
|
||||
uint32_t searchEndedMs = 0;
|
||||
uint32_t searchCount = 0;
|
||||
|
||||
+39
-35
@@ -1,4 +1,5 @@
|
||||
#include "GeoCoord.h"
|
||||
#include "configuration.h"
|
||||
#include <cmath>
|
||||
|
||||
// Narrow a UTM meter value to its unsigned field, clamping non-finite/negative/oversized inputs: an
|
||||
@@ -433,6 +434,43 @@ void GeoCoord::convertWGS84ToOSGB36(const double lat, const double lon, double &
|
||||
//(airyA*airyA/(airyA / sqrt(1 - airyEcc*sin(osgb.latitude)*sin(osgb.latitude)))); // Not used, no OSTN data
|
||||
}
|
||||
|
||||
#if MESHTASTIC_TRIG_APPROX
|
||||
// cos(x) minimax approx for x in [-pi/2, pi/2] ("cos_52"): https://www.ganssle.com/approx.htm
|
||||
static double cosLatitudeApprox(double latRad)
|
||||
{
|
||||
constexpr double c1 = 0.9999932946, c2 = -0.4999124376, c3 = 0.0414877472, c4 = -0.0012712095;
|
||||
double x2 = latRad * latRad;
|
||||
return c1 + x2 * (c2 + x2 * (c3 + c4 * x2));
|
||||
}
|
||||
|
||||
/// Approximate distance in meters via equirectangular projection (not exact spherical trig).
|
||||
/// <1% error to ~500km, degrading near the poles at long range (see test_geocoord_distance).
|
||||
float GeoCoord::latLongToMeter(double lat_a, double lng_a, double lat_b, double lng_b)
|
||||
{
|
||||
// Don't do math if the points are the same
|
||||
if (lat_a == lat_b && lng_a == lng_b)
|
||||
return 0.0;
|
||||
|
||||
double a1 = lat_a / DEG_CONVERT;
|
||||
double a2 = lng_a / DEG_CONVERT;
|
||||
double b1 = lat_b / DEG_CONVERT;
|
||||
double b2 = lng_b / DEG_CONVERT;
|
||||
|
||||
double meanLat = (a1 + b1) / 2;
|
||||
double dLng = b2 - a2;
|
||||
// Wrap to [-PI, PI]: unlike cos()/sin(), a raw longitude difference doesn't handle points that
|
||||
// straddle the antimeridian (e.g. 179.9 and -179.9 are ~0.2 degrees apart, not ~360).
|
||||
if (dLng > PI)
|
||||
dLng -= 2 * PI;
|
||||
else if (dLng < -PI)
|
||||
dLng += 2 * PI;
|
||||
double x = dLng * cosLatitudeApprox(meanLat);
|
||||
double y = b1 - a1;
|
||||
double tt = sqrt(x * x + y * y);
|
||||
|
||||
return (float)(6366000 * tt);
|
||||
}
|
||||
#else
|
||||
/// Ported from my old java code, returns distance in meters along the globe
|
||||
/// surface (by Haversine formula)
|
||||
float GeoCoord::latLongToMeter(double lat_a, double lng_a, double lat_b, double lng_b)
|
||||
@@ -456,6 +494,7 @@ float GeoCoord::latLongToMeter(double lat_a, double lng_a, double lat_b, double
|
||||
|
||||
return (float)(6366000 * tt);
|
||||
}
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Computes the bearing in degrees between two points on Earth. Ported from my
|
||||
@@ -482,41 +521,6 @@ float GeoCoord::bearing(double lat1, double lon1, double lat2, double lon2)
|
||||
return atan2(y, x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ported from http://www.edwilliams.org/avform147.htm#Intro
|
||||
* @brief Convert from meters to range in radians on a great circle
|
||||
* @param range_meters
|
||||
* The range in meters
|
||||
* @return range in radians on a great circle
|
||||
*/
|
||||
float GeoCoord::rangeMetersToRadians(double range_meters)
|
||||
{
|
||||
// 1 nm is 1852 meters
|
||||
double distance_nm = range_meters * 1852;
|
||||
return (PI / (180 * 60)) * distance_nm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new point based on the passed-in point
|
||||
* Ported from http://www.edwilliams.org/avform147.htm#LL
|
||||
* @param bearing
|
||||
* The bearing in radians
|
||||
* @param range_meters
|
||||
* range in meters
|
||||
* @return GeoCoord object of point at bearing and range from initial point
|
||||
*/
|
||||
std::shared_ptr<GeoCoord> GeoCoord::pointAtDistance(double bearing, double range_meters)
|
||||
{
|
||||
double range_radians = rangeMetersToRadians(range_meters);
|
||||
double lat1 = this->getLatitude() * 1e-7;
|
||||
double lon1 = this->getLongitude() * 1e-7;
|
||||
double lat = asin(sin(lat1) * cos(range_radians) + cos(lat1) * sin(range_radians) * cos(bearing));
|
||||
double dlon = atan2(sin(bearing) * sin(range_radians) * cos(lat1), cos(range_radians) - sin(lat1) * sin(lat));
|
||||
double lon = fmod(lon1 - dlon + PI, 2 * PI) - PI;
|
||||
|
||||
return std::make_shared<GeoCoord>(double(lat), double(lon), this->getAltitude());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bearing to degrees
|
||||
* @param bearing
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <math.h>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <stdint.h>
|
||||
#include <string>
|
||||
@@ -103,7 +102,6 @@ class GeoCoord
|
||||
static void convertWGS84ToOSGB36(const double lat, const double lon, double &osgb_Latitude, double &osgb_Longitude);
|
||||
static float latLongToMeter(double lat_a, double lng_a, double lat_b, double lng_b);
|
||||
static float bearing(double lat1, double lon1, double lat2, double lon2);
|
||||
static float rangeMetersToRadians(double range_meters);
|
||||
static unsigned int bearingToDegrees(const char *bearing);
|
||||
static const char *degreesToBearing(unsigned int degrees);
|
||||
|
||||
@@ -112,9 +110,6 @@ class GeoCoord
|
||||
static double toRadians(double deg);
|
||||
static double toDegrees(double r);
|
||||
|
||||
// Point to point conversions
|
||||
std::shared_ptr<GeoCoord> pointAtDistance(double bearing, double range);
|
||||
|
||||
// Lat lon alt getters
|
||||
int32_t getLatitude() const { return _latitude; }
|
||||
int32_t getLongitude() const { return _longitude; }
|
||||
|
||||
+62
-39
@@ -1,9 +1,12 @@
|
||||
#include "gps/RTC.h"
|
||||
#include "UptimeClock.h"
|
||||
#include "configuration.h"
|
||||
#include "detect/ScanI2C.h"
|
||||
#include "detect/ScanI2CTwoWire.h"
|
||||
#include "gps/GPSLog.h"
|
||||
#include "main.h"
|
||||
#include "mesh/MeshService.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "modules/NodeInfoModule.h"
|
||||
#include <Throttle.h>
|
||||
#include <sys/time.h>
|
||||
@@ -25,9 +28,12 @@ static void onTimeSourceQualityChanged(RTCQuality oldQuality, RTCQuality newQual
|
||||
LOG_DEBUG("Time source acquired (%s -> %s), recheck NodeInfo", RtcName(oldQuality), RtcName(newQuality));
|
||||
nodeInfoModule->triggerImmediateNodeInfoCheck();
|
||||
}
|
||||
if (oldQuality < RTCQualityFromNet && newQuality >= RTCQualityFromNet && service) {
|
||||
if (oldQuality < RTCQualityFromNet && newQuality >= RTCQualityFromNet) {
|
||||
LOG_DEBUG("RTC net quality reached (%s -> %s), reconciling rx_time", RtcName(oldQuality), RtcName(newQuality));
|
||||
service->reconcilePendingRxTimes();
|
||||
if (service)
|
||||
service->reconcilePendingRxTimes();
|
||||
if (nodeDB)
|
||||
nodeDB->backfillHeardAt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +43,9 @@ RTCQuality getRTCQuality()
|
||||
}
|
||||
|
||||
// stuff that really should be in in the instance instead...
|
||||
static uint32_t
|
||||
timeStartMsec; // Once we have a GPS lock, this is where we hold the initial msec clock that corresponds to that time
|
||||
// The Time::getMillisMonotonic() instant corresponding to zeroOffsetSecs. 64-bit so getTime()'s
|
||||
// elapsed term cannot wrap: a 32-bit anchor walks the wall clock back 49.7 days per millis() cycle.
|
||||
static uint64_t timeStartMs64;
|
||||
static uint64_t zeroOffsetSecs; // GPS based time in secs since 1970 - only updated once on initial lock
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
@@ -70,11 +77,11 @@ static struct timeval mockSystemTime = {};
|
||||
{
|
||||
struct timeval tv;
|
||||
if (readSystemTime(&tv)) {
|
||||
uint32_t now = millis();
|
||||
const uint64_t now = Time::getMillisMonotonic();
|
||||
uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
|
||||
if (currentQuality == RTCQualityNone) {
|
||||
LOG_DEBUG("Seed time from system clock: %lu", (unsigned long)printableEpoch);
|
||||
timeStartMsec = now;
|
||||
timeStartMs64 = now;
|
||||
zeroOffsetSecs = tv.tv_sec;
|
||||
} else {
|
||||
LOG_DEBUG("Ignore system clock fallback (%lu); RTC quality is %s", (unsigned long)printableEpoch,
|
||||
@@ -100,7 +107,7 @@ RTCSetResult readFromRTC()
|
||||
[[maybe_unused]] struct timeval tv; /* btw settimeofday() is helpful here too*/
|
||||
#ifdef RV3028_RTC
|
||||
if (rtc_found.address == RV3028_RTC) {
|
||||
uint32_t now = millis();
|
||||
const uint64_t now = Time::getMillisMonotonic();
|
||||
Melopero_RV3028 rtc;
|
||||
#if WIRE_INTERFACES_COUNT == 2
|
||||
rtc.initI2C(*ScanI2CTwoWire::fetchI2CBus(rtc_found));
|
||||
@@ -127,12 +134,15 @@ RTCSetResult readFromRTC()
|
||||
}
|
||||
#endif
|
||||
|
||||
LOG_DEBUG("RTC time from RV3028 getTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday,
|
||||
t.tm_hour, t.tm_min, t.tm_sec, printableEpoch);
|
||||
LOG_DEBUG_GPS("RTC time from RV3028 getTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", 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;
|
||||
timeStartMsec = now;
|
||||
timeStartMs64 = now;
|
||||
zeroOffsetSecs = tv.tv_sec;
|
||||
#if defined(ARCH_ESP32) || defined(ARCH_RP2040)
|
||||
settimeofday(&tv, NULL);
|
||||
#endif
|
||||
currentQuality = RTCQualityDevice;
|
||||
onTimeSourceQualityChanged(oldQuality, currentQuality);
|
||||
}
|
||||
@@ -149,7 +159,7 @@ RTCSetResult readFromRTC()
|
||||
SensorPCF85063 rtc;
|
||||
|
||||
#endif
|
||||
uint32_t now = millis();
|
||||
const uint64_t now = Time::getMillisMonotonic();
|
||||
|
||||
#if WIRE_INTERFACES_COUNT == 2
|
||||
rtc.begin(*ScanI2CTwoWire::fetchI2CBus(rtc_found));
|
||||
@@ -173,12 +183,15 @@ RTCSetResult readFromRTC()
|
||||
}
|
||||
#endif
|
||||
|
||||
LOG_DEBUG("RTC time from %s getDateTime: %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);
|
||||
LOG_DEBUG_GPS("RTC time from %s getDateTime: %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 (currentQuality == RTCQualityNone) {
|
||||
RTCQuality oldQuality = currentQuality;
|
||||
timeStartMsec = now;
|
||||
timeStartMs64 = now;
|
||||
zeroOffsetSecs = tv.tv_sec;
|
||||
#if defined(ARCH_ESP32) || defined(ARCH_RP2040)
|
||||
settimeofday(&tv, NULL);
|
||||
#endif
|
||||
currentQuality = RTCQualityDevice;
|
||||
onTimeSourceQualityChanged(oldQuality, currentQuality);
|
||||
}
|
||||
@@ -188,7 +201,7 @@ RTCSetResult readFromRTC()
|
||||
}
|
||||
#elif defined(RX8130CE_RTC)
|
||||
if (rtc_found.address == RX8130CE_RTC) {
|
||||
uint32_t now = millis();
|
||||
const uint64_t now = Time::getMillisMonotonic();
|
||||
#ifdef MUZI_BASE
|
||||
ArtronShop_RX8130CE rtc(&Wire1);
|
||||
#else
|
||||
@@ -200,8 +213,8 @@ RTCSetResult readFromRTC()
|
||||
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
|
||||
LOG_DEBUG("RTC time from RX8130CE getDateTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, t.tm_mon + 1,
|
||||
t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch);
|
||||
LOG_DEBUG_GPS("RTC time from RX8130CE getDateTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900,
|
||||
t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch);
|
||||
#ifdef BUILD_EPOCH
|
||||
if (tv.tv_sec < BUILD_EPOCH) {
|
||||
if (Throttle::isWithinTimespanMs(lastTimeValidationWarning, TIME_VALIDATION_WARNING_INTERVAL_MS) == false) {
|
||||
@@ -213,8 +226,11 @@ RTCSetResult readFromRTC()
|
||||
#endif
|
||||
if (currentQuality == RTCQualityNone) {
|
||||
RTCQuality oldQuality = currentQuality;
|
||||
timeStartMsec = now;
|
||||
timeStartMs64 = now;
|
||||
zeroOffsetSecs = tv.tv_sec;
|
||||
#if defined(ARCH_ESP32) || defined(ARCH_RP2040)
|
||||
settimeofday(&tv, NULL);
|
||||
#endif
|
||||
currentQuality = RTCQualityDevice;
|
||||
onTimeSourceQualityChanged(oldQuality, currentQuality);
|
||||
}
|
||||
@@ -223,7 +239,7 @@ RTCSetResult readFromRTC()
|
||||
}
|
||||
#elif HAS_LSE
|
||||
if (stm32wlRtcAvailable()) {
|
||||
uint32_t now = millis();
|
||||
const uint64_t now = Time::getMillisMonotonic();
|
||||
tv.tv_sec = STM32RTC::getInstance().getEpoch();
|
||||
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
|
||||
@@ -238,7 +254,7 @@ RTCSetResult readFromRTC()
|
||||
#endif
|
||||
if (currentQuality == RTCQualityNone) {
|
||||
RTCQuality oldQuality = currentQuality;
|
||||
timeStartMsec = now;
|
||||
timeStartMs64 = now;
|
||||
zeroOffsetSecs = tv.tv_sec;
|
||||
currentQuality = RTCQualityDevice;
|
||||
onTimeSourceQualityChanged(oldQuality, currentQuality);
|
||||
@@ -263,7 +279,8 @@ RTCSetResult readFromRTC()
|
||||
RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpdate)
|
||||
{
|
||||
static uint32_t lastSetMsec = 0;
|
||||
uint32_t now = millis();
|
||||
const uint64_t now64 = Time::getMillisMonotonic();
|
||||
const uint32_t now = (uint32_t)now64; // low word == getMillis(); fine for the Throttle-checked stamps below
|
||||
uint32_t printableEpoch = tv->tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
|
||||
#ifdef BUILD_EPOCH
|
||||
if (tv->tv_sec < BUILD_EPOCH) {
|
||||
@@ -294,14 +311,14 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
|
||||
LOG_DEBUG("Upgrade time to quality %s", RtcName(q));
|
||||
} else if (q == RTCQualityGPS) {
|
||||
shouldSet = true;
|
||||
LOG_DEBUG("Reapply GPS time: %ld secs", printableEpoch);
|
||||
LOG_DEBUG_GPS("Reapply GPS time: %ld secs", printableEpoch);
|
||||
} else if (q == RTCQualityNTP && !Throttle::isWithinTimespanMs(lastSetMsec, (30 * 60 * 1000UL))) {
|
||||
// Every 30 minutes we will slam in a new NTP or Phone GPS / NTP time, to correct for local RTC clock drift
|
||||
shouldSet = true;
|
||||
LOG_DEBUG("Reapply external time to fix clock drift %ld secs", printableEpoch);
|
||||
LOG_DEBUG_GPS("Reapply external time to fix clock drift %ld secs", printableEpoch);
|
||||
} else {
|
||||
shouldSet = false;
|
||||
LOG_DEBUG("RTC quality: %s. Ignore time of quality %s", RtcName(currentQuality), RtcName(q));
|
||||
LOG_DEBUG_GPS("RTC quality: %s. Ignore time of quality %s", RtcName(currentQuality), RtcName(q));
|
||||
}
|
||||
|
||||
if (shouldSet) {
|
||||
@@ -313,7 +330,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
|
||||
}
|
||||
|
||||
// This delta value works on all platforms
|
||||
timeStartMsec = now;
|
||||
timeStartMs64 = now64;
|
||||
zeroOffsetSecs = tv->tv_sec;
|
||||
// If this platform has a settable RTC, set it
|
||||
#ifdef RV3028_RTC
|
||||
@@ -327,10 +344,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
|
||||
// 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;
|
||||
tm *t = gmtime(&setSecs);
|
||||
const tm *t = gmtime(&setSecs);
|
||||
rtc.setTime(t->tm_year + 1900, t->tm_mon + 1, t->tm_wday, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec);
|
||||
LOG_DEBUG("RV3028_RTC setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday,
|
||||
t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
|
||||
LOG_DEBUG_GPS("RV3028_RTC setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday,
|
||||
t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
|
||||
} else {
|
||||
LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address);
|
||||
}
|
||||
@@ -352,10 +369,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
|
||||
// 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;
|
||||
tm *t = gmtime(&setSecs);
|
||||
const tm *t = gmtime(&setSecs);
|
||||
rtc.setDateTime(*t);
|
||||
LOG_DEBUG("%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);
|
||||
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);
|
||||
} else {
|
||||
LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address);
|
||||
}
|
||||
@@ -369,10 +386,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
|
||||
// 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;
|
||||
tm *t = gmtime(&setSecs);
|
||||
const tm *t = gmtime(&setSecs);
|
||||
if (rtc.setTime(*t)) {
|
||||
LOG_DEBUG("RX8130CE setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1,
|
||||
t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
|
||||
LOG_DEBUG_GPS("RX8130CE setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1,
|
||||
t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
|
||||
} else {
|
||||
LOG_WARN("RX8130CE set time failed");
|
||||
}
|
||||
@@ -381,7 +398,11 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
|
||||
if (stm32wlRtcAvailable()) {
|
||||
STM32RTC::getInstance().setEpoch(tv->tv_sec);
|
||||
}
|
||||
#elif defined(ARCH_ESP32) || defined(ARCH_RP2040)
|
||||
#endif
|
||||
// Keep the POSIX system clock in sync on platforms that support it so that
|
||||
// any code using time() (e.g. the device-ui thread) sees the correct wall time
|
||||
// even when a hardware RTC chip is also present and handled above.
|
||||
#if defined(ARCH_ESP32) || defined(ARCH_RP2040)
|
||||
settimeofday(tv, NULL);
|
||||
#endif
|
||||
|
||||
@@ -485,10 +506,12 @@ int32_t getTZOffset()
|
||||
*/
|
||||
uint32_t getTime(bool local)
|
||||
{
|
||||
// Both terms are 64-bit monotonic, so the elapsed time cannot wrap - see timeStartMs64.
|
||||
const uint64_t elapsedSecs = (Time::getMillisMonotonic() - timeStartMs64) / 1000;
|
||||
if (local) {
|
||||
return (((uint32_t)millis() - timeStartMsec) / 1000) + zeroOffsetSecs + getTZOffset();
|
||||
return elapsedSecs + zeroOffsetSecs + getTZOffset();
|
||||
} else {
|
||||
return (((uint32_t)millis() - timeStartMsec) / 1000) + zeroOffsetSecs;
|
||||
return elapsedSecs + zeroOffsetSecs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +531,7 @@ void setBootRelativeTimeForUnitTest(uint32_t secondsSinceBoot)
|
||||
{
|
||||
currentQuality = RTCQualityNone;
|
||||
zeroOffsetSecs = 0;
|
||||
timeStartMsec = millis() - (secondsSinceBoot * 1000);
|
||||
timeStartMs64 = Time::getMillisMonotonic() - ((uint64_t)secondsSinceBoot * 1000);
|
||||
lastSetFromPhoneNtpOrGps = 0;
|
||||
lastTimeValidationWarning = 0;
|
||||
}
|
||||
@@ -537,7 +560,7 @@ void setReadFromRTCUseSystemTimeForTests(bool enabled)
|
||||
void resetRTCStateForTests()
|
||||
{
|
||||
currentQuality = RTCQualityNone;
|
||||
timeStartMsec = 0;
|
||||
timeStartMs64 = 0;
|
||||
zeroOffsetSecs = 0;
|
||||
lastSetFromPhoneNtpOrGps = 0;
|
||||
lastTimeValidationWarning = 0;
|
||||
|
||||
@@ -99,7 +99,6 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit)
|
||||
// End the update process
|
||||
endUpdate();
|
||||
|
||||
LOG_DEBUG("done");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -162,9 +161,9 @@ bool EInkDisplay::connect()
|
||||
#if defined(TTGO_T_ECHO) || defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(TTGO_T_ECHO_PLUS) || \
|
||||
defined(ELECROW_ThinkNode_M8)
|
||||
{
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1);
|
||||
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
// GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(
|
||||
EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1));
|
||||
adafruitDisplay->init();
|
||||
#if defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(ELECROW_ThinkNode_M8)
|
||||
adafruitDisplay->setRotation(4);
|
||||
@@ -179,9 +178,9 @@ bool EInkDisplay::connect()
|
||||
hspi = new SPIClass(HSPI);
|
||||
hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS
|
||||
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi);
|
||||
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
// GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(
|
||||
EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi));
|
||||
adafruitDisplay->init();
|
||||
|
||||
adafruitDisplay->setRotation(4);
|
||||
@@ -190,9 +189,9 @@ bool EInkDisplay::connect()
|
||||
}
|
||||
#elif defined(MESHLINK)
|
||||
{
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1);
|
||||
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
// GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(
|
||||
EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1));
|
||||
adafruitDisplay->init();
|
||||
adafruitDisplay->setRotation(3);
|
||||
adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight);
|
||||
@@ -200,8 +199,9 @@ bool EInkDisplay::connect()
|
||||
#elif defined(RAK4630) || defined(MAKERPYTHON)
|
||||
{
|
||||
if (eink_found) {
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
// GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(
|
||||
EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY));
|
||||
adafruitDisplay->init(115200, true, 10, false, SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
// RAK14000 2.13 inch b/w 250x122 does actually now support fast refresh
|
||||
adafruitDisplay->setRotation(3);
|
||||
@@ -237,9 +237,9 @@ bool EInkDisplay::connect()
|
||||
// VExt already enabled in setup()
|
||||
// RTC GPIO hold disabled in setup()
|
||||
|
||||
// Create GxEPD2 objects
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
// Create GxEPD2 objects (GxEPD2_BW stores a copy of the driver, so pass a temporary)
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(
|
||||
EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi));
|
||||
|
||||
// Init GxEPD2
|
||||
adafruitDisplay->init();
|
||||
@@ -254,22 +254,25 @@ bool EInkDisplay::connect()
|
||||
}
|
||||
#elif defined(PCA10059) || defined(ME25LS01)
|
||||
{
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
// GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(
|
||||
EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY));
|
||||
adafruitDisplay->init(115200, true, 40, false, SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
adafruitDisplay->setRotation(0);
|
||||
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
|
||||
}
|
||||
#elif defined(M5_COREINK) || defined(T_DECK_PRO)
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
// GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(
|
||||
EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY));
|
||||
adafruitDisplay->init(115200, true, 40, false, SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
adafruitDisplay->setRotation(0);
|
||||
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
|
||||
#elif defined(my) || defined(ESP32_S3_PICO)
|
||||
{
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
// GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(
|
||||
EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY));
|
||||
adafruitDisplay->init(115200, true, 40, false, SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
adafruitDisplay->setRotation(1);
|
||||
adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT);
|
||||
@@ -281,9 +284,9 @@ bool EInkDisplay::connect()
|
||||
// VExt already enabled in setup()
|
||||
// RTC GPIO hold disabled in setup()
|
||||
|
||||
// Create GxEPD2 objects
|
||||
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *spi1);
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
|
||||
// Create GxEPD2 objects (GxEPD2_BW stores a copy of the driver, so pass a temporary)
|
||||
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(
|
||||
EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *spi1));
|
||||
|
||||
// Init GxEPD2
|
||||
adafruitDisplay->init();
|
||||
|
||||
@@ -157,7 +157,7 @@ bool EInkDynamicDisplay::determineMode()
|
||||
|
||||
resetRateLimiting(); // Once determineMode() ends, will have to wait again
|
||||
hashImage(); // Generate here, so we can still copy it to previousImageHash, even if we skip the comparison check
|
||||
LOG_DEBUG("determineMode(): "); // Begin log entry
|
||||
LOG_TRACE("determineMode(): "); // Begin log entry
|
||||
|
||||
// Once mode determined, any remaining checks will bypass
|
||||
checkCosmetic();
|
||||
@@ -232,9 +232,7 @@ void EInkDynamicDisplay::checkForPromotion()
|
||||
// Is it too soon for another frame of this type?
|
||||
void EInkDynamicDisplay::checkRateLimiting()
|
||||
{
|
||||
// Sanity check: millis() overflow - just let the update run..
|
||||
if (previousRunMs > millis())
|
||||
return;
|
||||
// No millis()-overflow guard needed: the Throttle checks below are wrap-correct already.
|
||||
|
||||
// Skip update: too soon for BACKGROUND
|
||||
if (frameFlags == BACKGROUND) {
|
||||
@@ -254,7 +252,7 @@ void EInkDynamicDisplay::checkRateLimiting()
|
||||
if (Throttle::isWithinTimespanMs(previousRunMs, 1000)) {
|
||||
refresh = SKIPPED;
|
||||
reason = EXCEEDED_RATELIMIT_FAST;
|
||||
LOG_DEBUG("refresh=SKIPPED, reason=EXCEEDED_RATELIMIT_FAST, frameFlags=0x%x", frameFlags);
|
||||
LOG_TRACE("refresh=SKIPPED, reason=EXCEEDED_RATELIMIT_FAST, frameFlags=0x%x", frameFlags);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -271,7 +269,7 @@ void EInkDynamicDisplay::checkCosmetic()
|
||||
if (frameFlags & COSMETIC) {
|
||||
refresh = FULL;
|
||||
reason = FLAGGED_COSMETIC;
|
||||
LOG_DEBUG("refresh=FULL, reason=FLAGGED_COSMETIC, frameFlags=0x%x", frameFlags);
|
||||
LOG_TRACE("refresh=FULL, reason=FLAGGED_COSMETIC, frameFlags=0x%x", frameFlags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +284,7 @@ void EInkDynamicDisplay::checkDemandingFast()
|
||||
if (frameFlags & DEMAND_FAST) {
|
||||
refresh = FAST;
|
||||
reason = FLAGGED_DEMAND_FAST;
|
||||
LOG_DEBUG("refresh=FAST, reason=FLAGGED_DEMAND_FAST, frameFlags=0x%x", frameFlags);
|
||||
LOG_TRACE("refresh=FAST, reason=FLAGGED_DEMAND_FAST, frameFlags=0x%x", frameFlags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +304,7 @@ void EInkDynamicDisplay::checkFrameMatchesPrevious()
|
||||
if (frameFlags == BACKGROUND && fastRefreshCount > 0) {
|
||||
refresh = FULL;
|
||||
reason = REDRAW_WITH_FULL;
|
||||
LOG_DEBUG("refresh=FULL, reason=REDRAW_WITH_FULL, frameFlags=0x%x", frameFlags);
|
||||
LOG_TRACE("refresh=FULL, reason=REDRAW_WITH_FULL, frameFlags=0x%x", frameFlags);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
@@ -314,7 +312,7 @@ void EInkDynamicDisplay::checkFrameMatchesPrevious()
|
||||
// Not redrawn, not COSMETIC, not DEMAND_FAST
|
||||
refresh = SKIPPED;
|
||||
reason = FRAME_MATCHED_PREVIOUS;
|
||||
LOG_DEBUG("refresh=SKIPPED, reason=FRAME_MATCHED_PREVIOUS, frameFlags=0x%x", frameFlags);
|
||||
LOG_TRACE("refresh=SKIPPED, reason=FRAME_MATCHED_PREVIOUS, frameFlags=0x%x", frameFlags);
|
||||
}
|
||||
|
||||
// Have too many fast-refreshes occurred consecutively, since last full refresh?
|
||||
@@ -328,7 +326,7 @@ void EInkDynamicDisplay::checkConsecutiveFastRefreshes()
|
||||
if (frameFlags & UNLIMITED_FAST) {
|
||||
refresh = FAST;
|
||||
reason = NO_OBJECTIONS;
|
||||
LOG_DEBUG("refresh=FAST, reason=UNLIMITED_FAST_MODE_ACTIVE, frameFlags=0x%x", frameFlags);
|
||||
LOG_TRACE("refresh=FAST, reason=UNLIMITED_FAST_MODE_ACTIVE, frameFlags=0x%x", frameFlags);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -336,7 +334,7 @@ void EInkDynamicDisplay::checkConsecutiveFastRefreshes()
|
||||
if (fastRefreshCount >= EINK_LIMIT_FASTREFRESH) {
|
||||
refresh = FULL;
|
||||
reason = EXCEEDED_LIMIT_FASTREFRESH;
|
||||
LOG_DEBUG("refresh=FULL, reason=EXCEEDED_LIMIT_FASTREFRESH, frameFlags=0x%x", frameFlags);
|
||||
LOG_TRACE("refresh=FULL, reason=EXCEEDED_LIMIT_FASTREFRESH, frameFlags=0x%x", frameFlags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,13 +349,13 @@ void EInkDynamicDisplay::checkFastRequested()
|
||||
// If we want BACKGROUND to use fast. (FULL only when a limit is hit)
|
||||
refresh = FAST;
|
||||
reason = BACKGROUND_USES_FAST;
|
||||
LOG_DEBUG("refresh=FAST, reason=BACKGROUND_USES_FAST, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount,
|
||||
LOG_TRACE("refresh=FAST, reason=BACKGROUND_USES_FAST, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount,
|
||||
frameFlags);
|
||||
#else
|
||||
// If we do want to use FULL for BACKGROUND updates
|
||||
refresh = FULL;
|
||||
reason = FLAGGED_BACKGROUND;
|
||||
LOG_DEBUG("refresh=FULL, reason=FLAGGED_BACKGROUND");
|
||||
LOG_TRACE("refresh=FULL, reason=FLAGGED_BACKGROUND");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -365,7 +363,7 @@ void EInkDynamicDisplay::checkFastRequested()
|
||||
if (frameFlags & RESPONSIVE) {
|
||||
refresh = FAST;
|
||||
reason = NO_OBJECTIONS;
|
||||
LOG_DEBUG("refresh=FAST, reason=NO_OBJECTIONS, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, frameFlags);
|
||||
LOG_TRACE("refresh=FAST, reason=NO_OBJECTIONS, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, frameFlags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +428,7 @@ void EInkDynamicDisplay::countGhostPixels()
|
||||
}
|
||||
}
|
||||
|
||||
LOG_DEBUG("ghostPixels=%hu, ", ghostPixelCount);
|
||||
LOG_TRACE("ghostPixels=%hu, ", ghostPixelCount);
|
||||
}
|
||||
|
||||
// Check if ghost pixel count exceeds the defined limit
|
||||
@@ -446,7 +444,7 @@ void EInkDynamicDisplay::checkExcessiveGhosting()
|
||||
if (ghostPixelCount > EINK_LIMIT_GHOSTING_PX) {
|
||||
refresh = FULL;
|
||||
reason = EXCEEDED_GHOSTINGLIMIT;
|
||||
LOG_DEBUG("refresh=FULL, reason=EXCEEDED_GHOSTINGLIMIT, frameFlags=0x%x", frameFlags);
|
||||
LOG_TRACE("refresh=FULL, reason=EXCEEDED_GHOSTINGLIMIT, frameFlags=0x%x", frameFlags);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,8 +183,10 @@ void EInkParallelDisplay::asyncFullUpdateTask(void *pvParameters)
|
||||
self->resetGhostPixelTracking();
|
||||
#endif
|
||||
|
||||
self->asyncFullRunning.store(false);
|
||||
// Handle first: once asyncFullRunning reads false, the destructor may act on the handle, so
|
||||
// it must already be null by then (same ordering fix as eink/Drivers/EInkParallel.cpp).
|
||||
self->asyncTaskHandle = nullptr;
|
||||
self->asyncFullRunning.store(false);
|
||||
|
||||
// delete this task
|
||||
vTaskDelete(nullptr);
|
||||
|
||||
@@ -360,7 +360,10 @@ Panel_sdl::Panel_sdl(void) : Panel_FrameBufferBase()
|
||||
|
||||
bool Panel_sdl::init(bool use_reset)
|
||||
{
|
||||
initFrameBuffer(_cfg.panel_width * 4, _cfg.panel_height);
|
||||
// Bail before registering the monitor: continuing with a failed framebuffer allocation
|
||||
// would leave sdl_update() reading garbage line pointers.
|
||||
if (!initFrameBuffer(_cfg.panel_width * 4, _cfg.panel_height))
|
||||
return false;
|
||||
bool res = Panel_FrameBufferBase::init(use_reset);
|
||||
|
||||
_list_monitor.push_back(&monitor);
|
||||
@@ -647,6 +650,10 @@ bool Panel_sdl::initFrameBuffer(size_t width, size_t height)
|
||||
}
|
||||
|
||||
_texturebuf = (rgb888_t *)heap_alloc_dma(width * height * sizeof(rgb888_t));
|
||||
if (nullptr == _texturebuf) {
|
||||
heap_free(lineArray);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 8byte alignment;
|
||||
width = (width + 7) & ~7u;
|
||||
@@ -655,6 +662,15 @@ bool Panel_sdl::initFrameBuffer(size_t width, size_t height)
|
||||
memset(lineArray, 0, height * sizeof(uint8_t *));
|
||||
|
||||
uint8_t *framebuffer = (uint8_t *)heap_alloc_dma(width * height + 16);
|
||||
if (nullptr == framebuffer) {
|
||||
// Returning true here would leave _lines_buffer full of null+offset garbage pointers
|
||||
// and turn the failure into a wild write on the next redraw.
|
||||
heap_free(_texturebuf);
|
||||
_texturebuf = nullptr;
|
||||
heap_free(lineArray);
|
||||
_lines_buffer = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
auto fb = framebuffer;
|
||||
{
|
||||
|
||||
+12
-7
@@ -652,6 +652,10 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
||||
Screen::~Screen()
|
||||
{
|
||||
delete[] graphics::normalFrames;
|
||||
// Owned by the constructor; Screen is genuinely destroyed on the portduino reboot path
|
||||
// (screen = nullptr in Power.cpp), which previously leaked the display and UI objects.
|
||||
delete ui;
|
||||
delete dispdev;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -677,12 +681,13 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
if (on) {
|
||||
LOG_INFO("Turn on screen");
|
||||
powerMon->setState(meshtastic_PowerMon_State_Screen_On);
|
||||
#ifdef T_WATCH_S3
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO2);
|
||||
#if defined(T_WATCH_S3) || defined(T_WATCH_ULTRA)
|
||||
if (PMU) // cleared when both AXP init attempts failed
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO2);
|
||||
#endif
|
||||
|
||||
// some screens seem to need a kick in the pants to turn back on
|
||||
#if defined(MUZI_BASE) || defined(M5STACK_CARDPUTER_ADV)
|
||||
#if defined(MUZI_BASE) || defined(M5STACK_CARDPUTER_ADV) || defined(TFT_RESET_AFTER_SLEEP)
|
||||
dispdev->init();
|
||||
dispdev->setBrightness(brightness);
|
||||
dispdev->flipScreenVertically();
|
||||
@@ -815,7 +820,7 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef T_WATCH_S3
|
||||
#if defined(T_WATCH_S3) // on T_WATCH_ULTRA, powering down this pin seems to goober the i2c bus.
|
||||
PMU->disablePowerOutput(XPOWERS_ALDO2);
|
||||
#endif
|
||||
enabled = false;
|
||||
@@ -1112,7 +1117,7 @@ int32_t Screen::runOnce()
|
||||
// Show boot screen for first logo_timeout seconds, then switch to normal operation.
|
||||
// serialSinceMsec adjusts for additional serial wait time during nRF52 bootup
|
||||
static bool showingBootScreen = true;
|
||||
if (showingBootScreen && (millis() > (logo_timeout + serialSinceMsec))) {
|
||||
if (showingBootScreen && Throttle::hasElapsed(serialSinceMsec, logo_timeout)) {
|
||||
LOG_INFO("Done with boot screen");
|
||||
stopBootScreen();
|
||||
showingBootScreen = false;
|
||||
@@ -1120,8 +1125,8 @@ int32_t Screen::runOnce()
|
||||
|
||||
#ifdef USERPREFS_OEM_TEXT
|
||||
static bool showingOEMBootScreen = true;
|
||||
if (showingOEMBootScreen && (millis() > ((logo_timeout / 2) + serialSinceMsec))) {
|
||||
LOG_INFO("Switch to OEM screen");
|
||||
if (showingOEMBootScreen && Throttle::hasElapsed(serialSinceMsec, logo_timeout / 2)) {
|
||||
LOG_INFO("Switch to OEM screen...");
|
||||
// Change frames.
|
||||
static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen};
|
||||
static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]);
|
||||
|
||||
@@ -287,6 +287,9 @@ class Screen : public concurrency::OSThread
|
||||
// FIXME: Needs refactoring and getMacAddr needs to be moved to a utility class
|
||||
char ourId[5];
|
||||
|
||||
// if we have a step counter, this stores the number of steps.
|
||||
uint32_t steps = 0;
|
||||
|
||||
/// Initializes the UI, turns on the display, starts showing boot screen.
|
||||
//
|
||||
// Not thread safe - must be called before any other methods are called.
|
||||
|
||||
@@ -104,14 +104,14 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w,
|
||||
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date,
|
||||
bool transparent_background, bool use_title_color_override, uint16_t title_color_override)
|
||||
{
|
||||
constexpr int HEADER_OFFSET_Y = 1;
|
||||
constexpr int HEADER_OFFSET_Y = 1 + BASEUI_HEADER_MARGIN;
|
||||
y += HEADER_OFFSET_Y;
|
||||
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
|
||||
const int xOffset = 4;
|
||||
const int highlightHeight = FONT_HEIGHT_SMALL - 1;
|
||||
const int xOffset = 4 + BASEUI_HEADER_LR_MARGIN;
|
||||
const int highlightHeight = FONT_HEIGHT_SMALL - 1 + BASEUI_HEADER_MARGIN;
|
||||
const bool isInverted = (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED);
|
||||
const bool isBold = config.display.heading_bold;
|
||||
|
||||
@@ -250,8 +250,8 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
}
|
||||
#endif
|
||||
|
||||
int batteryX = 1;
|
||||
int batteryY = HEADER_OFFSET_Y + 1;
|
||||
int batteryX = x + 1 + BASEUI_HEADER_LR_MARGIN;
|
||||
int batteryY = HEADER_OFFSET_Y + 1 + BASEUI_HEADER_MARGIN / 2;
|
||||
#if !defined(OLED_TINY)
|
||||
// === Battery Icons ===
|
||||
if (usbPowered && !isCharging) { // This is a basic check to determine USB Powered is flagged but not charging
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace graphics
|
||||
#define textSixthLine (textFifthLine + (FONT_HEIGHT_SMALL - 5))
|
||||
|
||||
// Consistent Line Spacing for devices like T114 and TEcho/ThinkNode M1 of devices
|
||||
#define textFirstLine_medium (FONT_HEIGHT_SMALL + 1)
|
||||
#define textFirstLine_medium (FONT_HEIGHT_SMALL + 1 + BASEUI_HEADER_MARGIN)
|
||||
#define textSecondLine_medium (textFirstLine_medium + FONT_HEIGHT_SMALL)
|
||||
#define textThirdLine_medium (textSecondLine_medium + FONT_HEIGHT_SMALL)
|
||||
#define textFourthLine_medium (textThirdLine_medium + FONT_HEIGHT_SMALL)
|
||||
@@ -36,6 +36,22 @@ namespace graphics
|
||||
#define textFifthLine_large (textFourthLine_large + (FONT_HEIGHT_SMALL + 5))
|
||||
#define textSixthLine_large (textFifthLine_large + (FONT_HEIGHT_SMALL + 5))
|
||||
|
||||
#ifndef BASEUI_HEADER_MARGIN
|
||||
#define BASEUI_HEADER_MARGIN 0
|
||||
#endif
|
||||
#ifndef BASEUI_HEADER_LR_MARGIN
|
||||
#define BASEUI_HEADER_LR_MARGIN 0
|
||||
#endif
|
||||
#ifndef BASEUI_BODY_LR_MARGIN
|
||||
#define BASEUI_BODY_LR_MARGIN 0
|
||||
#endif
|
||||
#ifndef BASEUI_BELOW_HEADER_MARGIN
|
||||
#define BASEUI_BELOW_HEADER_MARGIN 0
|
||||
#endif
|
||||
#ifndef ROUNDED_SCREEN
|
||||
#define ROUNDED_SCREEN false
|
||||
#endif
|
||||
|
||||
// Quick screen access
|
||||
#define SCREEN_WIDTH display->getWidth()
|
||||
#define SCREEN_HEIGHT display->getHeight()
|
||||
|
||||
+233
-34
@@ -17,6 +17,92 @@
|
||||
extern SX1509 gpioExtender;
|
||||
#endif
|
||||
|
||||
#ifdef TFT_MESH_OVERRIDE
|
||||
uint16_t TFT_MESH = TFT_MESH_OVERRIDE;
|
||||
#else
|
||||
uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94);
|
||||
#endif
|
||||
|
||||
#if defined(CO5300_CS)
|
||||
#include <LovyanGFX.hpp> // Graphics and font library for AMOLED driver chip
|
||||
class LGFX : public lgfx::LGFX_Device
|
||||
{
|
||||
lgfx::Panel_CO5300 _panel_instance;
|
||||
lgfx::Bus_SPI _bus_instance;
|
||||
|
||||
public:
|
||||
LGFX(void)
|
||||
{
|
||||
{
|
||||
auto cfg = _bus_instance.config();
|
||||
|
||||
// configure SPI
|
||||
cfg.spi_host = CO5300_SPI_HOST; // ESP32-S2,S3,C3 : SPI2_HOST or SPI3_HOST / ESP32 : VSPI_HOST or HSPI_HOST
|
||||
cfg.spi_mode = SPI_MODE0;
|
||||
cfg.freq_write = SPI_FREQUENCY; // SPI clock for transmission (up to 80MHz, rounded to the value obtained by dividing
|
||||
// 80MHz by an integer)
|
||||
cfg.freq_read = SPI_READ_FREQUENCY; // SPI clock when receiving
|
||||
cfg.spi_3wire = false; // Set to true if reception is done on the MOSI pin
|
||||
cfg.use_lock = true; // Set to true to use transaction locking
|
||||
cfg.dma_channel = SPI_DMA_CH_AUTO; // SPI_DMA_CH_AUTO; // Set DMA channel to use (0=not use DMA / 1=1ch / 2=ch /
|
||||
// SPI_DMA_CH_AUTO=auto setting)
|
||||
cfg.pin_sclk = CO5300_SCK; // Set SPI SCLK pin number
|
||||
cfg.pin_io0 = CO5300_IO0;
|
||||
cfg.pin_io1 = CO5300_IO1;
|
||||
cfg.pin_io2 = CO5300_IO2;
|
||||
cfg.pin_io3 = CO5300_IO3;
|
||||
|
||||
_bus_instance.config(cfg); // applies the set value to the bus.
|
||||
_panel_instance.setBus(&_bus_instance); // set the bus on the panel.
|
||||
}
|
||||
|
||||
{ // Set the display panel control.
|
||||
auto cfg = _panel_instance.config(); // Gets a structure for display panel settings.
|
||||
|
||||
cfg.pin_cs = CO5300_CS; // Pin number where CS is connected (-1 = disable)
|
||||
cfg.pin_rst = CO5300_RESET; // Pin number where RST is connected (-1 = disable)
|
||||
cfg.panel_width = TFT_WIDTH; // actual displayable width
|
||||
cfg.panel_height = TFT_HEIGHT; // actual displayable height
|
||||
cfg.offset_rotation = TFT_OFFSET_ROTATION; // Rotation direction value offset 0~7 (4~7 is upside down)
|
||||
cfg.offset_x = TFT_OFFSET_X;
|
||||
cfg.offset_y = TFT_OFFSET_Y;
|
||||
cfg.dummy_read_pixel = 8; // Number of bits for dummy read before pixel readout
|
||||
cfg.dummy_read_bits = 1; // Number of bits for dummy read before non-pixel data read
|
||||
cfg.readable = true; // Set to true if data can be read
|
||||
cfg.invert = false; // Set to true if the light/darkness of the panel is reversed
|
||||
cfg.rgb_order = false; // Set to true if the panel's red and blue are swapped
|
||||
cfg.dlen_16bit = false; // Set to true for panels that transmit data length in 16-bit units
|
||||
cfg.bus_shared = true; // If the bus is shared with the SD card, set to true (bus control with drawJpgFile etc.)
|
||||
|
||||
// Set the following only when the display is shifted with a driver with a variable number of pixels
|
||||
cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC
|
||||
cfg.memory_height = TFT_HEIGHT; // Maximum height supported by the driver IC
|
||||
_panel_instance.config(cfg);
|
||||
}
|
||||
|
||||
setPanel(&_panel_instance);
|
||||
}
|
||||
|
||||
bool init()
|
||||
{
|
||||
#ifdef CO5300_RESET
|
||||
LOG_DEBUG("LGFX_Panel_CO5300::init()");
|
||||
lgfx::pinMode(CO5300_RESET, lgfx::pin_mode_t::output);
|
||||
lgfx::gpio_hi(CO5300_RESET);
|
||||
delay(20);
|
||||
lgfx::gpio_lo(CO5300_RESET);
|
||||
delay(30);
|
||||
lgfx::gpio_hi(CO5300_RESET);
|
||||
delay(20);
|
||||
#endif
|
||||
return lgfx::LGFX_Device::init();
|
||||
}
|
||||
};
|
||||
|
||||
static LGFX *tft = nullptr;
|
||||
|
||||
#endif
|
||||
|
||||
#if defined(ST7735S)
|
||||
#include <LovyanGFX.hpp> // Graphics and font library for ST7735 driver chip
|
||||
|
||||
@@ -119,7 +205,7 @@ static void rak14014_tpIntHandle(void)
|
||||
_rak14014_touch_int = true;
|
||||
}
|
||||
|
||||
#elif defined(HACKADAY_COMMUNICATOR)
|
||||
#elif defined(USE_ARDUINO_GFX)
|
||||
#include <Arduino_GFX_Library.h>
|
||||
Arduino_GFX *tft = nullptr;
|
||||
|
||||
@@ -821,7 +907,7 @@ class LGFX : public lgfx::LGFX_Device
|
||||
{
|
||||
lgfx::Bus_SPI _bus_instance;
|
||||
|
||||
lgfx::ITouch *_touch_instance;
|
||||
lgfx::ITouch *_touch_instance = nullptr;
|
||||
|
||||
public:
|
||||
lgfx::Panel_Device *_panel_instance;
|
||||
@@ -891,24 +977,28 @@ class LGFX : public lgfx::LGFX_Device
|
||||
} else if (portduino_config.touchscreenModule == ft5x06) {
|
||||
_touch_instance = new lgfx::Touch_FT5x06;
|
||||
}
|
||||
auto touch_cfg = _touch_instance->config();
|
||||
// Not every module in the config enum has a branch above (gt911 is handled by the
|
||||
// color-UI path in tftSetup.cpp), so the pointer can legitimately still be null here.
|
||||
if (_touch_instance) {
|
||||
auto touch_cfg = _touch_instance->config();
|
||||
|
||||
touch_cfg.pin_cs = portduino_config.touchscreenCS.pin;
|
||||
touch_cfg.x_min = 0;
|
||||
touch_cfg.x_max = portduino_config.displayHeight - 1;
|
||||
touch_cfg.y_min = 0;
|
||||
touch_cfg.y_max = portduino_config.displayWidth - 1;
|
||||
touch_cfg.pin_int = portduino_config.touchscreenIRQ.pin;
|
||||
touch_cfg.bus_shared = true;
|
||||
touch_cfg.offset_rotation = portduino_config.touchscreenRotate;
|
||||
if (portduino_config.touchscreenI2CAddr != -1) {
|
||||
touch_cfg.i2c_addr = portduino_config.touchscreenI2CAddr;
|
||||
} else {
|
||||
touch_cfg.spi_host = portduino_config.touchscreen_spi_dev_int;
|
||||
touch_cfg.pin_cs = portduino_config.touchscreenCS.pin;
|
||||
touch_cfg.x_min = 0;
|
||||
touch_cfg.x_max = portduino_config.displayHeight - 1;
|
||||
touch_cfg.y_min = 0;
|
||||
touch_cfg.y_max = portduino_config.displayWidth - 1;
|
||||
touch_cfg.pin_int = portduino_config.touchscreenIRQ.pin;
|
||||
touch_cfg.bus_shared = true;
|
||||
touch_cfg.offset_rotation = portduino_config.touchscreenRotate;
|
||||
if (portduino_config.touchscreenI2CAddr != -1) {
|
||||
touch_cfg.i2c_addr = portduino_config.touchscreenI2CAddr;
|
||||
} else {
|
||||
touch_cfg.spi_host = portduino_config.touchscreen_spi_dev_int;
|
||||
}
|
||||
|
||||
_touch_instance->config(touch_cfg);
|
||||
_panel_instance->setTouch(_touch_instance);
|
||||
}
|
||||
|
||||
_touch_instance->config(touch_cfg);
|
||||
_panel_instance->setTouch(_touch_instance);
|
||||
}
|
||||
#if defined(SDL_h_)
|
||||
if (portduino_config.displayPanel == x11) {
|
||||
@@ -1307,7 +1397,7 @@ void TFTDisplay::display(bool fromBlank)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if defined(HACKADAY_COMMUNICATOR)
|
||||
#if defined(USE_ARDUINO_GFX)
|
||||
tft->draw16bitBeRGBBitmap(0, yStart, repaintChunkBuffer, displayWidth, rowsThisChunk);
|
||||
#else
|
||||
tft->pushImage(0, yStart, displayWidth, rowsThisChunk, repaintChunkBuffer);
|
||||
@@ -1390,6 +1480,70 @@ void TFTDisplay::display(bool fromBlank)
|
||||
}
|
||||
|
||||
// Step 3: Copy only the changed span into the pixel line buffer.
|
||||
#if defined(CO5300_CS)
|
||||
constexpr uint32_t kCO5300MinTransferBytes = 80;
|
||||
constexpr uint32_t kCO5300BytesPerColumn = sizeof(uint16_t) * 2; // two rows, RGB565
|
||||
constexpr uint32_t kCO5300MinColumns = (kCO5300MinTransferBytes + kCO5300BytesPerColumn - 1) / kCO5300BytesPerColumn;
|
||||
|
||||
// CO5300 workaround: widen very small updates so LovyanGFX avoids tiny SPI writes.
|
||||
uint32_t span = x_LastPixelUpdate - x_FirstPixelUpdate + 1;
|
||||
if (span < kCO5300MinColumns) {
|
||||
uint32_t needed = kCO5300MinColumns - span;
|
||||
uint32_t growLeft = needed / 2;
|
||||
uint32_t growRight = needed - growLeft;
|
||||
|
||||
const uint32_t availableLeft = x_FirstPixelUpdate;
|
||||
if (growLeft > availableLeft)
|
||||
growLeft = availableLeft;
|
||||
x_FirstPixelUpdate -= growLeft;
|
||||
needed -= growLeft;
|
||||
|
||||
const uint32_t availableRight = (displayWidth - 1) - x_LastPixelUpdate;
|
||||
const uint32_t extendRight = (needed < availableRight) ? needed : availableRight;
|
||||
x_LastPixelUpdate += extendRight;
|
||||
needed -= extendRight;
|
||||
|
||||
const uint32_t extendLeft = (needed < x_FirstPixelUpdate) ? needed : x_FirstPixelUpdate;
|
||||
x_FirstPixelUpdate -= extendLeft;
|
||||
}
|
||||
|
||||
// Keep transfer edges aligned as before for DMA-friendly boundaries.
|
||||
x_FirstPixelUpdate &= ~1U;
|
||||
x_LastPixelUpdate = (x_LastPixelUpdate | 1U);
|
||||
if (x_LastPixelUpdate >= displayWidth) {
|
||||
x_LastPixelUpdate = displayWidth - 1;
|
||||
}
|
||||
|
||||
// snap y down to the even-row pair (AMOLED requires 2-row aligned writes)
|
||||
const uint32_t y_draw = y & ~1U;
|
||||
span = x_LastPixelUpdate - x_FirstPixelUpdate + 1;
|
||||
const int y_offset = (int)y_draw - (int)y;
|
||||
for (x = x_FirstPixelUpdate; x <= x_LastPixelUpdate; x++) {
|
||||
const uint32_t col = x - x_FirstPixelUpdate;
|
||||
uint32_t bi = (y_draw / 8) * displayWidth;
|
||||
isset = buffer[x + bi] & (1 << (y_draw & 7));
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
linePixelBuffer[x_FirstPixelUpdate + col] =
|
||||
hasColorRegions ? graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y_draw), isset,
|
||||
colorTftWhite, colorTftBlack)
|
||||
: (isset ? colorTftWhite : colorTftBlack);
|
||||
#else
|
||||
linePixelBuffer[x_FirstPixelUpdate + col] = isset ? colorTftWhite : colorTftBlack;
|
||||
#endif
|
||||
bi = ((y_draw + 1) / 8) * displayWidth;
|
||||
isset = buffer[x + bi] & (1 << ((y_draw + 1) & 7));
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
linePixelBuffer[x_FirstPixelUpdate + span + col] =
|
||||
hasColorRegions ? graphics::resolveTFTColorPixel(static_cast<int16_t>(x), static_cast<int16_t>(y_draw + 1),
|
||||
isset, colorTftWhite, colorTftBlack)
|
||||
: (isset ? colorTftWhite : colorTftBlack);
|
||||
#else
|
||||
linePixelBuffer[x_FirstPixelUpdate + span + col] = isset ? colorTftWhite : colorTftBlack;
|
||||
#endif
|
||||
}
|
||||
const uint8_t lines_updated = 2;
|
||||
#else
|
||||
int y_offset = 0;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
if (hasColorRegions)
|
||||
graphics::beginTFTColorRow(static_cast<int16_t>(y));
|
||||
@@ -1407,13 +1561,16 @@ void TFTDisplay::display(bool fromBlank)
|
||||
linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
|
||||
#endif
|
||||
}
|
||||
#if defined(HACKADAY_COMMUNICATOR)
|
||||
const uint8_t lines_updated = 1;
|
||||
#endif
|
||||
|
||||
#if defined(USE_ARDUINO_GFX)
|
||||
tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate],
|
||||
(x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1);
|
||||
#else
|
||||
// Step 4: Send the changed pixels on this line to the screen as a single block transfer.
|
||||
// This function accepts pixel data MSB first so it can dump the memory straight out the SPI port.
|
||||
tft->pushImage(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
|
||||
tft->pushImage(x_FirstPixelUpdate, y + y_offset, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), lines_updated,
|
||||
&linePixelBuffer[x_FirstPixelUpdate]);
|
||||
#endif
|
||||
somethingChanged = true;
|
||||
@@ -1481,13 +1638,25 @@ void TFTDisplay::sendCommand(uint8_t com)
|
||||
// handle display on/off directly
|
||||
switch (com) {
|
||||
case DISPLAYON: {
|
||||
// LOG_DEBUG("Display on");
|
||||
LOG_DEBUG("Display on");
|
||||
#if defined(TFT_NV3001B)
|
||||
// DISPLAYOFF cuts the panel rail, so the controller loses its configuration and sleep-out
|
||||
// alone cannot bring it back. Restore the rail, let it settle, then re-run the init sequence.
|
||||
digitalWrite(VTFT_CTRL, TFT_EN_ON);
|
||||
delay(10);
|
||||
if (!tft->begin(SPI_FREQUENCY)) {
|
||||
// Nothing below this point can reach the panel, so skip the wake instead of lighting
|
||||
// the backlight and repainting over a bus that did not come up.
|
||||
LOG_ERROR("NV3001B re-init failed on wake");
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
backlightEnable->set(true);
|
||||
#if ARCH_PORTDUINO
|
||||
display(true);
|
||||
if (portduino_config.displayBacklight.pin > 0)
|
||||
digitalWrite(portduino_config.displayBacklight.pin, TFT_BACKLIGHT_ON);
|
||||
#elif defined(HACKADAY_COMMUNICATOR)
|
||||
#elif defined(USE_ARDUINO_GFX)
|
||||
tft->displayOn();
|
||||
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096) && \
|
||||
!defined(HELTEC_MESH_NODE_T1)
|
||||
@@ -1495,7 +1664,13 @@ void TFTDisplay::sendCommand(uint8_t com)
|
||||
tft->powerSaveOff();
|
||||
#endif
|
||||
|
||||
#ifdef VTFT_CTRL
|
||||
#if defined(TFT_NV3001B)
|
||||
// Re-init left display RAM undefined, so repaint in full rather than diff against a
|
||||
// buffer that no longer describes the panel.
|
||||
display(true);
|
||||
#endif
|
||||
|
||||
#if defined(VTFT_CTRL) && !defined(TFT_NV3001B) // NV3001B panels already powered the rail above
|
||||
digitalWrite(VTFT_CTRL, LOW);
|
||||
#endif
|
||||
#ifdef UNPHONE
|
||||
@@ -1503,19 +1678,19 @@ void TFTDisplay::sendCommand(uint8_t com)
|
||||
#endif
|
||||
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1)
|
||||
#elif !defined(M5STACK) && !defined(ST7789_CS) && \
|
||||
!defined(HACKADAY_COMMUNICATOR) // T-Deck gets brightness set in Screen.cpp in the handleSetOn function
|
||||
!defined(USE_ARDUINO_GFX) // T-Deck gets brightness set in Screen.cpp in the handleSetOn function
|
||||
tft->setBrightness(172);
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
case DISPLAYOFF: {
|
||||
// LOG_DEBUG("Display off");
|
||||
LOG_DEBUG("Display off");
|
||||
backlightEnable->set(false);
|
||||
#if ARCH_PORTDUINO
|
||||
tft->clear();
|
||||
if (portduino_config.displayBacklight.pin > 0)
|
||||
digitalWrite(portduino_config.displayBacklight.pin, !TFT_BACKLIGHT_ON);
|
||||
#elif defined(HACKADAY_COMMUNICATOR)
|
||||
#elif defined(USE_ARDUINO_GFX)
|
||||
tft->displayOff();
|
||||
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096) && \
|
||||
!defined(HELTEC_MESH_NODE_T1)
|
||||
@@ -1530,7 +1705,7 @@ void TFTDisplay::sendCommand(uint8_t com)
|
||||
unphone.backlight(false); // using unPhone library
|
||||
#endif
|
||||
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1)
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR)
|
||||
#elif !defined(M5STACK) && !defined(USE_ARDUINO_GFX)
|
||||
tft->setBrightness(0);
|
||||
#endif
|
||||
break;
|
||||
@@ -1546,7 +1721,7 @@ void TFTDisplay::setDisplayBrightness(uint8_t _brightness)
|
||||
{
|
||||
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1)
|
||||
// todo
|
||||
#elif !defined(HACKADAY_COMMUNICATOR)
|
||||
#elif !defined(USE_ARDUINO_GFX)
|
||||
tft->setBrightness(_brightness);
|
||||
LOG_DEBUG("Brightness is set to value: %i ", _brightness);
|
||||
#endif
|
||||
@@ -1564,7 +1739,7 @@ bool TFTDisplay::hasTouch(void)
|
||||
{
|
||||
#ifdef RAK14014
|
||||
return true;
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
|
||||
#elif !defined(M5STACK) && !defined(USE_ARDUINO_GFX) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
|
||||
return tft->touch() != nullptr;
|
||||
#else
|
||||
return false;
|
||||
@@ -1583,7 +1758,7 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y)
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
|
||||
#elif !defined(M5STACK) && !defined(USE_ARDUINO_GFX) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
|
||||
return tft->getTouch(x, y);
|
||||
#else
|
||||
return false;
|
||||
@@ -1611,19 +1786,39 @@ bool TFTDisplay::connect()
|
||||
tft = new Arduino_NV3007(bus, 40, 0 /* rotation */, false /* IPS */, 142 /* width */, 428 /* height */,
|
||||
12 /* col offset 1 */, 0 /* row offset 1 */, 14 /* col offset 2 */, 0 /* row offset 2 */,
|
||||
nv3007_279_init_operations, sizeof(nv3007_279_init_operations));
|
||||
#elif defined(TFT_NV3001B)
|
||||
// The Heltec RC panels all use the same controller and differ only in how the bus is wired.
|
||||
#if defined(HELTEC_RC52)
|
||||
// nRF52840: the panel sits on SPI1, clear of the LoRa radio on SPI0.
|
||||
Arduino_DataBus *bus = new Arduino_HWSPI(TFT_RS, TFT_CS, &SPI1, true /* is_shared_interface */);
|
||||
#elif defined(HELTEC_RCC6)
|
||||
// ESP32-C6: the panel shares pins with the LoRa host, so bit-bang it rather than claim the peripheral.
|
||||
Arduino_DataBus *bus = new Arduino_SWSPI(TFT_RS, TFT_CS, TFT_SCL, TFT_SDA, GFX_NOT_DEFINED /* MISO */);
|
||||
#else
|
||||
// ESP32-S3: keep the panel off the LoRa FSPI host, since Arduino_GFX reconfigures whichever bus it is handed.
|
||||
Arduino_DataBus *bus =
|
||||
new Arduino_ESP32SPI(TFT_RS, TFT_CS, TFT_SCL, TFT_SDA, GFX_NOT_DEFINED /* MISO */, HSPI /* spi_num */);
|
||||
#endif
|
||||
tft = new Arduino_NV3001B(bus, TFT_RST, 3 /* rotation */, true /* IPS */, TFT_WIDTH, TFT_HEIGHT, 0 /* col offset 1 */,
|
||||
0 /* row offset 1 */, 0 /* col offset 2 */, 0 /* row offset 2 */);
|
||||
#else
|
||||
tft = new LGFX;
|
||||
#endif
|
||||
}
|
||||
|
||||
backlightEnable->set(true);
|
||||
LOG_INFO("Power to TFT Backlight");
|
||||
backlightEnable->set(true);
|
||||
|
||||
#ifdef UNPHONE
|
||||
unphone.backlight(true); // using unPhone library
|
||||
#endif
|
||||
#ifdef HACKADAY_COMMUNICATOR
|
||||
#ifdef USE_ARDUINO_GFX
|
||||
#if defined(TFT_NV3001B)
|
||||
// Arduino_SWSPI ignores the clock argument, so this only bites on the hardware-SPI variants.
|
||||
bool beginStatus = tft->begin(SPI_FREQUENCY);
|
||||
#else
|
||||
bool beginStatus = tft->begin();
|
||||
#endif
|
||||
if (beginStatus)
|
||||
LOG_DEBUG("TFT Success");
|
||||
else
|
||||
@@ -1645,7 +1840,7 @@ 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)
|
||||
#elif ARCH_PORTDUINO || defined(SENSECAP_INDICATOR) || defined(T_LORA_PAGER) || defined(T_WATCH_ULTRA)
|
||||
tft->setRotation(0); // use config.yaml to set rotation
|
||||
#else
|
||||
tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label
|
||||
@@ -1653,7 +1848,11 @@ bool TFTDisplay::connect()
|
||||
tft->fillScreen(getThemeDefaultOffColor());
|
||||
|
||||
if (this->linePixelBuffer == NULL) {
|
||||
#if defined(CO5300_CS)
|
||||
this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth * 2);
|
||||
#else
|
||||
this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth);
|
||||
#endif
|
||||
|
||||
if (!this->linePixelBuffer) {
|
||||
LOG_ERROR("Not enough memory to create TFT line buffer");
|
||||
|
||||
@@ -666,7 +666,13 @@ void VirtualKeyboard::handleLongPress()
|
||||
break;
|
||||
case VK_ESC:
|
||||
if (onTextEntered) {
|
||||
onTextEntered("");
|
||||
// Copy-and-clear before invoking, like handlePress/submitText: the callback can
|
||||
// destroy this keyboard (OnScreenKeyboardModule::stop), so the member must not be
|
||||
// the std::function still executing on the stack.
|
||||
std::function<void(const std::string &)> callback = onTextEntered;
|
||||
onTextEntered = nullptr;
|
||||
inputText = "";
|
||||
callback("");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -59,17 +59,18 @@ void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, i
|
||||
|
||||
// === Header ===
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
y += BASEUI_BELOW_HEADER_MARGIN;
|
||||
|
||||
const char *wifiName = config.network.wifi_ssid;
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
display->drawString(x, getTextPositions(display)[line++], "WiFi: Not Connected");
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "WiFi: Not Connected");
|
||||
} else {
|
||||
display->drawString(x, getTextPositions(display)[line++], "WiFi: Connected");
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "WiFi: Connected");
|
||||
|
||||
char rssiStr[32];
|
||||
snprintf(rssiStr, sizeof(rssiStr), "RSSI: %d", WiFi.RSSI());
|
||||
display->drawString(x, getTextPositions(display)[line++], rssiStr);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, rssiStr);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -87,36 +88,36 @@ void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, i
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
char ipStr[64];
|
||||
snprintf(ipStr, sizeof(ipStr), "IP: %s", WiFi.localIP().toString().c_str());
|
||||
display->drawString(x, getTextPositions(display)[line++], ipStr);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, ipStr);
|
||||
} else if (WiFi.status() == WL_NO_SSID_AVAIL) {
|
||||
display->drawString(x, getTextPositions(display)[line++], "SSID Not Found");
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "SSID Not Found");
|
||||
} else if (WiFi.status() == WL_CONNECTION_LOST) {
|
||||
display->drawString(x, getTextPositions(display)[line++], "Connection Lost");
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "Connection Lost");
|
||||
} else if (WiFi.status() == WL_IDLE_STATUS) {
|
||||
display->drawString(x, getTextPositions(display)[line++], "Idle ... Reconnecting");
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "Idle ... Reconnecting");
|
||||
} else if (WiFi.status() == WL_CONNECT_FAILED) {
|
||||
display->drawString(x, getTextPositions(display)[line++], "Connection Failed");
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "Connection Failed");
|
||||
}
|
||||
#ifdef ARCH_ESP32
|
||||
else {
|
||||
// Codes:
|
||||
// https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/wifi.html#wi-fi-reason-code
|
||||
display->drawString(x, getTextPositions(display)[line++],
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y,
|
||||
WiFi.disconnectReasonName(static_cast<wifi_err_reason_t>(getWifiDisconnectReason())));
|
||||
}
|
||||
#else
|
||||
else {
|
||||
char statusStr[32];
|
||||
snprintf(statusStr, sizeof(statusStr), "Unknown status: %d", WiFi.status());
|
||||
display->drawString(x, getTextPositions(display)[line++], statusStr);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, statusStr);
|
||||
}
|
||||
#endif
|
||||
|
||||
char ssidStr[64];
|
||||
snprintf(ssidStr, sizeof(ssidStr), "SSID: %s", wifiName);
|
||||
display->drawString(x, getTextPositions(display)[line++], ssidStr);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, ssidStr);
|
||||
|
||||
display->drawString(x, getTextPositions(display)[line++], "URL: http://meshtastic.local");
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "URL: http://meshtastic.local");
|
||||
|
||||
graphics::drawCommonFooter(display, x, y);
|
||||
|
||||
@@ -144,9 +145,11 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
|
||||
// === Header ===
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
y += BASEUI_BELOW_HEADER_MARGIN;
|
||||
|
||||
// === First Row: Region / BLE Name ===
|
||||
graphics::UIRenderer::drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, 0, true, "");
|
||||
graphics::UIRenderer::drawNodes(display, x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line] + 2 + y, nodeStatus, 0,
|
||||
true, "");
|
||||
|
||||
uint8_t dmac[6];
|
||||
char shortnameble[35];
|
||||
@@ -158,8 +161,8 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
snprintf(shortnameble, sizeof(shortnameble), "BLE: %s", screen->ourId);
|
||||
}
|
||||
int textWidth = display->getStringWidth(shortnameble);
|
||||
int nameX = (SCREEN_WIDTH - textWidth);
|
||||
display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
|
||||
int nameX = (SCREEN_WIDTH - textWidth - BASEUI_BODY_LR_MARGIN);
|
||||
display->drawString(nameX, getTextPositions(display)[line++] + y, shortnameble);
|
||||
|
||||
if (!graphics::isCompactPanel(display)) {
|
||||
// === Second Row: Role ===
|
||||
@@ -168,7 +171,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
snprintf(device_role, sizeof(device_role), "Role: %s", role);
|
||||
textWidth = display->getStringWidth(device_role);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
display->drawString(nameX, getTextPositions(display)[line++], device_role);
|
||||
display->drawString(nameX, getTextPositions(display)[line++] + y, device_role);
|
||||
}
|
||||
|
||||
// === Third Row: Radio Preset ===
|
||||
@@ -194,7 +197,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
}
|
||||
textWidth = display->getStringWidth(regionradiopreset);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
display->drawString(nameX, getTextPositions(display)[line++], regionradiopreset);
|
||||
display->drawString(nameX, getTextPositions(display)[line++] + y, regionradiopreset);
|
||||
|
||||
// === Fourth Row: Frequency / ChanNum ===
|
||||
char frequencyslot[35];
|
||||
@@ -220,78 +223,86 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
|
||||
}
|
||||
textWidth = display->getStringWidth(frequencyslot);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
display->drawString(nameX, getTextPositions(display)[line++], frequencyslot);
|
||||
display->drawString(nameX, getTextPositions(display)[line++] + y, frequencyslot);
|
||||
|
||||
#if !defined(OLED_TINY)
|
||||
// === Fifth Row: Channel Utilization ===
|
||||
const char *chUtil = "ChUtil:";
|
||||
char chUtilPercentage[10];
|
||||
snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent());
|
||||
|
||||
int chUtil_x = (currentResolution == ScreenResolution::High) ? display->getStringWidth(chUtil) + 10
|
||||
: display->getStringWidth(chUtil) + 5;
|
||||
int chUtil_y = getTextPositions(display)[line] + 3;
|
||||
|
||||
int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50;
|
||||
int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border
|
||||
int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7;
|
||||
int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3;
|
||||
int chutil_percent = airTime->channelUtilizationPercent();
|
||||
const int raw_chutil_percent = chutil_percent;
|
||||
|
||||
int centerofscreen = SCREEN_WIDTH / 2;
|
||||
int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2;
|
||||
int starting_position = centerofscreen - total_line_content_width;
|
||||
|
||||
display->drawString(starting_position, getTextPositions(display)[line], chUtil);
|
||||
|
||||
// Force 61% or higher to show a full 100% bar, text would still show related percent.
|
||||
if (chutil_percent >= 61) {
|
||||
chutil_percent = 100;
|
||||
}
|
||||
|
||||
// Weighting for nonlinear segments
|
||||
float milestone1 = 25;
|
||||
float milestone2 = 40;
|
||||
float weight1 = 0.45; // Weight for 0-25%
|
||||
float weight2 = 0.35; // Weight for 25-40%
|
||||
float weight3 = 0.20; // Weight for 40-100%
|
||||
float totalWeight = weight1 + weight2 + weight3;
|
||||
|
||||
int seg1 = chutil_bar_max_fill * (weight1 / totalWeight);
|
||||
int seg2 = chutil_bar_max_fill * (weight2 / totalWeight);
|
||||
int seg3 = chutil_bar_max_fill - seg1 - seg2; // Remainder absorbs rounding errors
|
||||
|
||||
int fillRight = 0;
|
||||
|
||||
if (chutil_percent <= milestone1) {
|
||||
fillRight = (seg1 * (chutil_percent / milestone1));
|
||||
} else if (chutil_percent <= milestone2) {
|
||||
fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1)));
|
||||
if (!config.lora.tx_enabled) {
|
||||
const char *txdisabled = "Transmit Disabled";
|
||||
textWidth = display->getStringWidth(txdisabled);
|
||||
display->drawString((SCREEN_WIDTH - textWidth) / 2, getTextPositions(display)[line] + y, txdisabled);
|
||||
} else {
|
||||
fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2)));
|
||||
}
|
||||
|
||||
// Draw outline
|
||||
display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height);
|
||||
const char *chUtil = "ChUtil:";
|
||||
char chUtilPercentage[10];
|
||||
snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent());
|
||||
|
||||
// Fill progress
|
||||
if (fillRight > 0) {
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
uint16_t UtilizationFillColor = TFTPalette::Good;
|
||||
if (raw_chutil_percent >= 60) {
|
||||
UtilizationFillColor = TFTPalette::Bad;
|
||||
} else if (raw_chutil_percent >= 35) {
|
||||
UtilizationFillColor = TFTPalette::Medium;
|
||||
int chUtil_x = (currentResolution == ScreenResolution::High) ? display->getStringWidth(chUtil) + 10
|
||||
: display->getStringWidth(chUtil) + 5;
|
||||
int chUtil_y = getTextPositions(display)[line] + 3 + y;
|
||||
|
||||
int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50;
|
||||
int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border
|
||||
int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7;
|
||||
int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3;
|
||||
int chutil_percent = airTime->channelUtilizationPercent();
|
||||
const int raw_chutil_percent = chutil_percent;
|
||||
|
||||
int centerofscreen = SCREEN_WIDTH / 2;
|
||||
int total_line_content_width =
|
||||
(chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2;
|
||||
int starting_position = centerofscreen - total_line_content_width;
|
||||
|
||||
display->drawString(starting_position, getTextPositions(display)[line] + y, chUtil);
|
||||
|
||||
// Force 61% or higher to show a full 100% bar, text would still show related percent.
|
||||
if (chutil_percent >= 61) {
|
||||
chutil_percent = 100;
|
||||
}
|
||||
setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black,
|
||||
starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
||||
#endif
|
||||
display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
||||
}
|
||||
|
||||
display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++],
|
||||
chUtilPercentage);
|
||||
// Weighting for nonlinear segments
|
||||
float milestone1 = 25;
|
||||
float milestone2 = 40;
|
||||
float weight1 = 0.45; // Weight for 0-25%
|
||||
float weight2 = 0.35; // Weight for 25-40%
|
||||
float weight3 = 0.20; // Weight for 40-100%
|
||||
float totalWeight = weight1 + weight2 + weight3;
|
||||
|
||||
int seg1 = chutil_bar_max_fill * (weight1 / totalWeight);
|
||||
int seg2 = chutil_bar_max_fill * (weight2 / totalWeight);
|
||||
int seg3 = chutil_bar_max_fill - seg1 - seg2; // Remainder absorbs rounding errors
|
||||
|
||||
int fillRight = 0;
|
||||
|
||||
if (chutil_percent <= milestone1) {
|
||||
fillRight = (seg1 * (chutil_percent / milestone1));
|
||||
} else if (chutil_percent <= milestone2) {
|
||||
fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1)));
|
||||
} else {
|
||||
fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2)));
|
||||
}
|
||||
|
||||
// Draw outline
|
||||
display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height);
|
||||
|
||||
// Fill progress
|
||||
if (fillRight > 0) {
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
uint16_t UtilizationFillColor = TFTPalette::Good;
|
||||
if (raw_chutil_percent >= 60) {
|
||||
UtilizationFillColor = TFTPalette::Bad;
|
||||
} else if (raw_chutil_percent >= 35) {
|
||||
UtilizationFillColor = TFTPalette::Medium;
|
||||
}
|
||||
setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black,
|
||||
starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
||||
#endif
|
||||
display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
||||
}
|
||||
|
||||
display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++] + y,
|
||||
chUtilPercentage);
|
||||
}
|
||||
#endif
|
||||
graphics::drawCommonFooter(display, x, y);
|
||||
}
|
||||
@@ -310,11 +321,12 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
|
||||
|
||||
// === Header ===
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
y += BASEUI_BELOW_HEADER_MARGIN;
|
||||
|
||||
// === Layout ===
|
||||
int line = 1;
|
||||
const int barHeight = 6;
|
||||
const int labelX = x;
|
||||
const int labelX = x + BASEUI_BODY_LR_MARGIN;
|
||||
int barsOffset = (currentResolution == ScreenResolution::High) ? 24 : 0;
|
||||
#ifdef USE_EINK
|
||||
#ifndef T_DECK_PRO
|
||||
@@ -345,7 +357,11 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
|
||||
}
|
||||
|
||||
int textWidth = display->getStringWidth(combinedStr);
|
||||
int adjustedBarWidth = SCREEN_WIDTH - barX - textWidth - 6;
|
||||
int labelWidth = display->getStringWidth(label);
|
||||
if (barX < BASEUI_BODY_LR_MARGIN + labelWidth) {
|
||||
barX = BASEUI_BODY_LR_MARGIN + labelWidth;
|
||||
}
|
||||
int adjustedBarWidth = SCREEN_WIDTH - barX - textWidth - 6 - BASEUI_BODY_LR_MARGIN;
|
||||
if (adjustedBarWidth < 10)
|
||||
adjustedBarWidth = 10;
|
||||
|
||||
@@ -353,10 +369,10 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
|
||||
|
||||
// Label
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->drawString(labelX, getTextPositions(display)[line], label);
|
||||
display->drawString(labelX, getTextPositions(display)[line] + y, label);
|
||||
#if !defined(OLED_TINY)
|
||||
// Bar
|
||||
int barY = getTextPositions(display)[line] + (FONT_HEIGHT_SMALL - barHeight) / 2;
|
||||
int barY = getTextPositions(display)[line] + y + (FONT_HEIGHT_SMALL - barHeight) / 2;
|
||||
display->setColor(WHITE);
|
||||
display->drawRect(barX, barY, adjustedBarWidth, barHeight);
|
||||
|
||||
@@ -376,7 +392,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
|
||||
#endif
|
||||
// Value string
|
||||
display->setTextAlignment(TEXT_ALIGN_RIGHT);
|
||||
display->drawString(SCREEN_WIDTH, getTextPositions(display)[line], combinedStr);
|
||||
display->drawString(SCREEN_WIDTH - BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line] + y, combinedStr);
|
||||
};
|
||||
|
||||
// === Memory values ===
|
||||
@@ -465,7 +481,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
|
||||
int textWidth = display->getStringWidth(appversionstr);
|
||||
int nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
|
||||
display->drawString(nameX, getTextPositions(display)[line++], appversionstr);
|
||||
display->drawString(nameX, getTextPositions(display)[line++] + y, appversionstr);
|
||||
|
||||
if (!graphics::isCompactPanel(display) &&
|
||||
(SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line <= 5))) { // Only show uptime if the screen can show it
|
||||
@@ -473,7 +489,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
|
||||
getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr));
|
||||
textWidth = display->getStringWidth(uptimeStr);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
display->drawString(nameX, getTextPositions(display)[line++], uptimeStr);
|
||||
display->drawString(nameX, getTextPositions(display)[line++] + y, uptimeStr);
|
||||
}
|
||||
|
||||
if (SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line <= 5)) { // Only show API state if the screen can show it
|
||||
@@ -520,7 +536,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
|
||||
}
|
||||
#endif
|
||||
if (api_state[0] != '\0') {
|
||||
display->drawString((SCREEN_WIDTH - display->getStringWidth(api_state)) / 2, getTextPositions(display)[line++],
|
||||
display->drawString((SCREEN_WIDTH - display->getStringWidth(api_state)) / 2, getTextPositions(display)[line++] + y,
|
||||
api_state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
#include "input/UpDownInterruptImpl1.h"
|
||||
#include "main.h"
|
||||
#include "mesh/Default.h"
|
||||
#if HAS_LORA_FEM
|
||||
#include "mesh/LoRaFEMInterface.h"
|
||||
#endif
|
||||
#include "mesh/MeshTypes.h"
|
||||
#include "mesh/RadioLibInterface.h"
|
||||
#include "modules/AdminModule.h"
|
||||
@@ -139,12 +142,38 @@ uint8_t test_count = 0;
|
||||
|
||||
void menuHandler::loraMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "Device Role", "Radio Preset", "Frequency Slot", "LoRa Region"};
|
||||
enum optionsNumbers { Back = 0, DeviceRolePicker = 1, RadioPresetPicker = 2, FrequencySlot = 3, LoraPicker = 4 };
|
||||
static const char *optionsArray[] = {
|
||||
"Back",
|
||||
"Device Role",
|
||||
"Radio Preset",
|
||||
"Frequency Slot",
|
||||
"LoRa Region",
|
||||
"Transmit Enabled",
|
||||
#if HAS_LORA_FEM
|
||||
"FEM LNA",
|
||||
#endif
|
||||
};
|
||||
// NOTE: "FEM LNA" must stay last; it is the only entry that can be hidden at runtime by
|
||||
// trimming optionsCount, which only works for a trailing option.
|
||||
enum optionsNumbers {
|
||||
Back = 0,
|
||||
DeviceRolePicker = 1,
|
||||
RadioPresetPicker = 2,
|
||||
FrequencySlot = 3,
|
||||
LoraPicker = 4,
|
||||
TxEnabled = 5,
|
||||
#if HAS_LORA_FEM
|
||||
LoraFemLna = 6
|
||||
#endif
|
||||
};
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "LoRa Actions";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = 5;
|
||||
#if HAS_LORA_FEM
|
||||
bannerOptions.optionsCount = loraFEMInterface.isLnaCanControl() ? 7 : 6;
|
||||
#else
|
||||
bannerOptions.optionsCount = 6;
|
||||
#endif
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
if (selected == Back) {
|
||||
// No action
|
||||
@@ -156,7 +185,14 @@ void menuHandler::loraMenu()
|
||||
menuHandler::menuQueue = menuHandler::FrequencySlot;
|
||||
} else if (selected == LoraPicker) {
|
||||
menuHandler::menuQueue = menuHandler::LoraPicker;
|
||||
} else if (selected == TxEnabled) {
|
||||
menuHandler::menuQueue = menuHandler::TXEnabledMenu;
|
||||
}
|
||||
#if HAS_LORA_FEM
|
||||
else if (selected == LoraFemLna) {
|
||||
menuHandler::menuQueue = menuHandler::LoraFemLnaToggleMenu;
|
||||
}
|
||||
#endif
|
||||
};
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
}
|
||||
@@ -209,8 +245,9 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool
|
||||
}
|
||||
auto changes = SEGMENT_CONFIG;
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (crypto) {
|
||||
crypto->ensurePkiKeys(config.security, owner);
|
||||
// Minting the key moves our node num with it, and nothing reboots on this path to repair it later.
|
||||
if (nodeDB->ensurePkiIdentity()) {
|
||||
changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
|
||||
}
|
||||
#endif
|
||||
initRegion();
|
||||
@@ -541,6 +578,31 @@ void menuHandler::radioPresetPicker()
|
||||
screen->showOverlayBanner(buildRegionPresetBanner());
|
||||
}
|
||||
|
||||
void menuHandler::txEnabledMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "Enabled", "Disabled"};
|
||||
enum optionsNumbers { Back = 0, Enabled = 1, Disabled = 2 };
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Transmit Enabled";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = 3;
|
||||
bannerOptions.InitialSelected = config.lora.tx_enabled ? Enabled : Disabled;
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
// -1 is the timeout/dismiss case; treat it like Back so we never write config.
|
||||
if (selected <= Back) {
|
||||
menuHandler::menuQueue = menuHandler::LoraMenu;
|
||||
screen->runNow();
|
||||
return;
|
||||
}
|
||||
bool wanted = (selected == Enabled);
|
||||
if (config.lora.tx_enabled == wanted)
|
||||
return;
|
||||
config.lora.tx_enabled = wanted;
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
};
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
}
|
||||
|
||||
void menuHandler::twelveHourPicker()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "12-hour", "24-hour"};
|
||||
@@ -2804,6 +2866,49 @@ void menuHandler::messageBubblesMenu()
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
}
|
||||
|
||||
#if HAS_LORA_FEM
|
||||
void menuHandler::LoRaFEMLNAToggleMenu()
|
||||
{
|
||||
static const LoRaFEMLNAToggleOption femToggleOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{"Enabled", OptionsAction::Select, meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED},
|
||||
{"Disabled", OptionsAction::Select, meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED},
|
||||
};
|
||||
constexpr size_t toggleCount = sizeof(femToggleOptions) / sizeof(femToggleOptions[0]);
|
||||
static std::array<const char *, toggleCount> toggleLabels{};
|
||||
|
||||
auto bannerOptions = createStaticBannerOptions(
|
||||
"FEM LNA", femToggleOptions, toggleLabels, [](const LoRaFEMLNAToggleOption &option, int) -> void {
|
||||
if (option.action == OptionsAction::Back) {
|
||||
menuQueue = LoraMenu;
|
||||
screen->runNow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!option.hasValue || config.lora.fem_lna_mode == option.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool enabled = option.value != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED;
|
||||
config.lora.fem_lna_mode = option.value;
|
||||
loraFEMInterface.setLNAEnable(enabled);
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
LOG_INFO("FEM LNA %s", enabled ? "enabled" : "disabled");
|
||||
});
|
||||
|
||||
int initialSelection = 0;
|
||||
for (size_t i = 0; i < toggleCount; ++i) {
|
||||
if (femToggleOptions[i].hasValue && config.lora.fem_lna_mode == femToggleOptions[i].value) {
|
||||
initialSelection = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
bannerOptions.InitialSelected = initialSelection;
|
||||
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
}
|
||||
#endif
|
||||
|
||||
void menuHandler::themeMenu()
|
||||
{
|
||||
// Build menu dynamically from the theme table.
|
||||
@@ -2870,6 +2975,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||
case RadioPresetPicker:
|
||||
radioPresetPicker();
|
||||
break;
|
||||
case TXEnabledMenu:
|
||||
txEnabledMenu();
|
||||
break;
|
||||
case FrequencySlot:
|
||||
FrequencySlotPicker();
|
||||
break;
|
||||
@@ -3013,6 +3121,11 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||
case LicensedToNormalConfirm:
|
||||
licensedToNormalConfirmMenu();
|
||||
break;
|
||||
#if HAS_LORA_FEM
|
||||
case LoraFemLnaToggleMenu:
|
||||
LoRaFEMLNAToggleMenu();
|
||||
break;
|
||||
#endif
|
||||
}
|
||||
menuQueue = MenuNone;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ class menuHandler
|
||||
LoraPicker,
|
||||
DeviceRolePicker,
|
||||
RadioPresetPicker,
|
||||
TXEnabledMenu,
|
||||
FrequencySlot,
|
||||
NoTimeoutLoraPicker,
|
||||
TzPicker,
|
||||
@@ -59,7 +60,10 @@ class menuHandler
|
||||
MessageBubblesMenu,
|
||||
ThemeMenu,
|
||||
HamModeConfirm,
|
||||
LicensedToNormalConfirm
|
||||
LicensedToNormalConfirm,
|
||||
#if HAS_LORA_FEM
|
||||
LoraFemLnaToggleMenu
|
||||
#endif
|
||||
};
|
||||
static screenMenus menuQueue;
|
||||
static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu
|
||||
@@ -70,6 +74,7 @@ class menuHandler
|
||||
static void loraMenu();
|
||||
static void deviceRolePicker();
|
||||
static void radioPresetPicker();
|
||||
static void txEnabledMenu();
|
||||
static void FrequencySlotPicker();
|
||||
static void handleMenuSwitch(OLEDDisplay *display);
|
||||
static void showConfirmationBanner(const char *message, std::function<void()> onConfirm);
|
||||
@@ -120,6 +125,9 @@ class menuHandler
|
||||
static void textMessageMenu();
|
||||
static void hamModeConfirmMenu();
|
||||
static void licensedToNormalConfirmMenu();
|
||||
#if HAS_LORA_FEM
|
||||
static void LoRaFEMLNAToggleMenu();
|
||||
#endif
|
||||
|
||||
// Lifted out of its banner-callback lambda so it is reachable without a Screen. The lambda only
|
||||
// ever runs via screen->showOverlayBanner(), which is why nothing here was unit-testable.
|
||||
@@ -159,6 +167,9 @@ using NodeNameOption = MenuOption<bool>;
|
||||
using PositionMenuOption = MenuOption<int>;
|
||||
using ManageNodeOption = MenuOption<int>;
|
||||
using ClockFaceOption = MenuOption<bool>;
|
||||
#if HAS_LORA_FEM
|
||||
using LoRaFEMLNAToggleOption = MenuOption<meshtastic_Config_LoRaConfig_FEM_LNA_Mode>;
|
||||
#endif
|
||||
|
||||
} // namespace graphics
|
||||
#endif
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "MessageStore.h"
|
||||
#include "NodeDB.h"
|
||||
#include "UIRenderer.h"
|
||||
#include "UptimeClock.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/EmoteRenderer.h"
|
||||
#include "graphics/Screen.h"
|
||||
@@ -436,12 +437,13 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
display->setFont(FONT_SMALL);
|
||||
const bool compactPanel = graphics::isCompactPanel(display);
|
||||
// Compact panels: no bottom nav row anymore (see UIRenderer::drawNavigationBar), full height available.
|
||||
const int navHeight = compactPanel ? 0 : FONT_HEIGHT_SMALL;
|
||||
const int navHeight = compactPanel ? 0 : FONT_HEIGHT_SMALL + BASEUI_BELOW_HEADER_MARGIN + BASEUI_HEADER_MARGIN;
|
||||
const int scrollBottom = SCREEN_HEIGHT - navHeight;
|
||||
const int contentTop = compactPanel ? 0 : getTextPositions(display)[1];
|
||||
// Rounded screens start the body below the header margin; getTextPositions(display)[1] + BASEUI_BELOW_HEADER_MARGIN
|
||||
const int contentTop = compactPanel ? 0 : navHeight;
|
||||
const int usableHeight = compactPanel ? scrollBottom - contentTop : scrollBottom;
|
||||
constexpr int LEFT_MARGIN = 2;
|
||||
constexpr int RIGHT_MARGIN = 2;
|
||||
constexpr int LEFT_MARGIN = 2 + BASEUI_BODY_LR_MARGIN;
|
||||
constexpr int RIGHT_MARGIN = 2 + BASEUI_BODY_LR_MARGIN;
|
||||
constexpr int SCROLLBAR_WIDTH = 3;
|
||||
constexpr int BUBBLE_PAD_X = 3;
|
||||
constexpr int BUBBLE_PAD_Y = 4;
|
||||
@@ -452,6 +454,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
// Check if bubbles are enabled
|
||||
const bool showBubbles = config.display.enable_message_bubbles && !compactPanel;
|
||||
const int textIndent = showBubbles ? (BUBBLE_PAD_X + BUBBLE_TEXT_INDENT) : LEFT_MARGIN;
|
||||
// Bubbles carry their own padding, so the rounded-screen inset has to come from here
|
||||
const int contentLeft = x + (showBubbles ? BASEUI_BODY_LR_MARGIN : 0);
|
||||
|
||||
// Derived widths
|
||||
const int leftTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - (showBubbles ? (BUBBLE_PAD_X * 2) : 0);
|
||||
@@ -571,7 +575,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
}
|
||||
} else if (m.timestamp > 0 && nowSecs == 0) {
|
||||
// RTC not valid: only trust boot-relative if same boot
|
||||
uint32_t bootNow = millis() / 1000;
|
||||
uint32_t bootNow = Time::getUptimeSecs();
|
||||
if (m.isBootRelative && m.timestamp <= bootNow) {
|
||||
seconds = bootNow - m.timestamp;
|
||||
invalidTime = false;
|
||||
@@ -872,10 +876,10 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
if (b.mine) {
|
||||
bubbleX = rightEdge - bubbleW;
|
||||
} else {
|
||||
bubbleX = x;
|
||||
bubbleX = contentLeft;
|
||||
}
|
||||
if (bubbleX < x)
|
||||
bubbleX = x;
|
||||
if (bubbleX < contentLeft)
|
||||
bubbleX = contentLeft;
|
||||
if (bubbleX + bubbleW > rightEdge)
|
||||
bubbleW = std::max(1, rightEdge - bubbleX);
|
||||
|
||||
@@ -952,7 +956,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
if (headerX < LEFT_MARGIN)
|
||||
headerX = LEFT_MARGIN;
|
||||
} else {
|
||||
headerX = x + textIndent;
|
||||
headerX = contentLeft + textIndent;
|
||||
}
|
||||
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
|
||||
true);
|
||||
@@ -1001,7 +1005,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
|
||||
drawStringWithEmotes(display, rightX, lineY, cachedLines[i], emotes, numEmotes);
|
||||
} else {
|
||||
drawStringWithEmotes(display, x + textIndent, lineY, cachedLines[i], emotes, numEmotes);
|
||||
drawStringWithEmotes(display, contentLeft + textIndent, lineY, cachedLines[i], emotes, numEmotes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,20 @@ void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *
|
||||
}
|
||||
}
|
||||
|
||||
void drawScaledXBitmap3x(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display)
|
||||
{
|
||||
for (int row = 0; row < height; row++) {
|
||||
uint8_t rowMask = (1 << row);
|
||||
for (int col = 0; col < width; col++) {
|
||||
uint8_t colData = pgm_read_byte(&bitmapXBM[col]);
|
||||
if (colData & rowMask) {
|
||||
// Note: rows become X, columns become Y after transpose
|
||||
display->fillRect(x + row * 3, y + col * 3, 3, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Static variables for dynamic cycling
|
||||
static ListMode_Node currentMode_Nodes = MODE_LAST_HEARD;
|
||||
static ListMode_Location currentMode_Location = MODE_DISTANCE;
|
||||
@@ -606,7 +620,7 @@ void drawCompassUnknown(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
||||
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
||||
EntryRenderer renderer, NodeExtrasRenderer extras, float headingRadian, double lat, double lon)
|
||||
{
|
||||
const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1;
|
||||
const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1 + BASEUI_HEADER_MARGIN;
|
||||
// Compact panels: 4 rows fit (0,9,18,27), a 5th pages instead of cramming in.
|
||||
const int rowYOffset = graphics::isCompactPanel(display) ? (FONT_HEIGHT_SMALL - 4) : (FONT_HEIGHT_SMALL - 3);
|
||||
bool locationScreen = false;
|
||||
@@ -622,7 +636,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
||||
|
||||
// Compact panels have no header (see drawCommonHeader) - don't reserve space for one.
|
||||
if (!graphics::isCompactPanel(display))
|
||||
y += COMMON_HEADER_HEIGHT;
|
||||
y += COMMON_HEADER_HEIGHT + BASEUI_BELOW_HEADER_MARGIN;
|
||||
firstRowY = y;
|
||||
|
||||
int totalColumns = 1; // Default to 1 column
|
||||
@@ -638,7 +652,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
||||
} else {
|
||||
if (SCREEN_WIDTH <= 64) {
|
||||
totalColumns = 1;
|
||||
} else if (SCREEN_WIDTH > 64 && SCREEN_WIDTH <= 240) {
|
||||
} else if ((SCREEN_WIDTH > 64 && SCREEN_WIDTH <= 240) || ROUNDED_SCREEN) {
|
||||
totalColumns = 2;
|
||||
} else {
|
||||
totalColumns = 3;
|
||||
@@ -691,11 +705,20 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
|
||||
auto *node = nodeDB->getMeshNode(nodeNum);
|
||||
int xPos = x + (col * columnWidth);
|
||||
int yPos = y + yOffset;
|
||||
int effectiveColumnWidth = columnWidth;
|
||||
if (BASEUI_BODY_LR_MARGIN) {
|
||||
if (col == 0) {
|
||||
xPos += BASEUI_BODY_LR_MARGIN;
|
||||
effectiveColumnWidth -= BASEUI_BODY_LR_MARGIN;
|
||||
} else if (col == (totalColumns - 1)) {
|
||||
effectiveColumnWidth -= BASEUI_BODY_LR_MARGIN;
|
||||
}
|
||||
}
|
||||
|
||||
renderer(display, node, xPos, yPos, columnWidth);
|
||||
renderer(display, node, xPos, yPos, effectiveColumnWidth);
|
||||
|
||||
if (extras)
|
||||
extras(display, node, xPos, yPos, columnWidth, headingRadian, lat, lon);
|
||||
extras(display, node, xPos, yPos, effectiveColumnWidth, headingRadian, lat, lon);
|
||||
|
||||
lastNodeY = max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
|
||||
yOffset += rowYOffset;
|
||||
|
||||
@@ -65,6 +65,7 @@ void scrollDown();
|
||||
|
||||
// Bitmap drawing function
|
||||
void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display);
|
||||
void drawScaledXBitmap3x(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display);
|
||||
|
||||
} // namespace NodeListRenderer
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "graphics/images.h"
|
||||
#include "input/RotaryEncoderInterruptImpl1.h"
|
||||
#include "input/UpDownInterruptImpl1.h"
|
||||
#include "mesh/Throttle.h"
|
||||
#if HAS_BUTTON
|
||||
#include "input/ButtonThread.h"
|
||||
#endif
|
||||
@@ -84,7 +85,7 @@ static inline graphics::NotificationRenderer::BannerFont parseFontTagPrefix(cons
|
||||
{
|
||||
// Tags must be at the start of the line:
|
||||
// [S] small, [M] medium, [L] large
|
||||
if (p && p[0] == '[' && p[2] == ']' && p[1] != '\0') {
|
||||
if (p && p[0] == '[' && p[1] != '\0' && p[2] == ']') {
|
||||
char t = p[1];
|
||||
if (t == 'S') {
|
||||
p += 3;
|
||||
@@ -136,6 +137,26 @@ static inline uint8_t effectiveLineHeightForBannerLine(graphics::NotificationRen
|
||||
return (height > 3) ? (height - 3) : height;
|
||||
}
|
||||
|
||||
const char *graphics::NotificationRenderer::resolveBannerLine(uint16_t lineIndex, const char *rawLine, BannerFont &lineFont)
|
||||
{
|
||||
lineFont = BANNER_FONT_DEFAULT;
|
||||
bool tagAware = (current_notification_type == notificationTypeEnum::text_banner ||
|
||||
current_notification_type == notificationTypeEnum::pairing_pin) &&
|
||||
alertBannerOptions == 0;
|
||||
if (!tagAware)
|
||||
return rawLine;
|
||||
if (lineIndex < alertBannerLineCount) {
|
||||
lineFont = alertBannerLineFonts[lineIndex];
|
||||
return alertBannerLines[lineIndex];
|
||||
}
|
||||
// The parsed-line cache doesn't cover this line (the banner text was stored without a
|
||||
// re-parse, or a draw raced the parse from another task): strip the tag here too, so it
|
||||
// acts as a font change and never renders as literal text - the BLE pair PIN banner
|
||||
// prefixes its PIN line with [M].
|
||||
lineFont = parseFontTagPrefix(rawLine);
|
||||
return rawLine;
|
||||
}
|
||||
|
||||
void graphics::NotificationRenderer::parseBannerMessageWithFonts(const char *message)
|
||||
{
|
||||
alertBannerLineCount = 0;
|
||||
@@ -233,7 +254,7 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
|
||||
// Handle text_input notifications first - they have their own timeout/banner logic
|
||||
if (current_notification_type == notificationTypeEnum::text_input) {
|
||||
// Check for timeout and reset if needed for text input
|
||||
if (millis() > alertBannerUntil && alertBannerUntil > 0) {
|
||||
if (alertBannerUntil > 0 && Throttle::deadlinePassed(alertBannerUntil)) {
|
||||
resetBanner();
|
||||
return;
|
||||
}
|
||||
@@ -241,7 +262,8 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
|
||||
return;
|
||||
}
|
||||
|
||||
if (millis() > alertBannerUntil && alertBannerUntil > 0) {
|
||||
// 0 means "no deadline set", and reads as long expired - test it first.
|
||||
if (alertBannerUntil > 0 && Throttle::deadlinePassed(alertBannerUntil)) {
|
||||
resetBanner();
|
||||
}
|
||||
|
||||
@@ -845,9 +867,6 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
BannerFont lineFonts[totalLines] = {};
|
||||
uint8_t lineEffectiveHeights[totalLines] = {0};
|
||||
const char *renderLines[totalLines] = {0};
|
||||
bool useTaggedBannerFonts = (current_notification_type == notificationTypeEnum::text_banner ||
|
||||
current_notification_type == notificationTypeEnum::pairing_pin) &&
|
||||
alertBannerOptions == 0;
|
||||
|
||||
if (maxWidth != 0)
|
||||
is_picker = true;
|
||||
@@ -860,12 +879,8 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
uint16_t widestLineWithBars = 0;
|
||||
|
||||
while (lines[lineCount] != nullptr) {
|
||||
const char *renderText = lines[lineCount];
|
||||
BannerFont lineFont = BANNER_FONT_DEFAULT;
|
||||
if (useTaggedBannerFonts && lineCount < alertBannerLineCount) {
|
||||
renderText = alertBannerLines[lineCount];
|
||||
lineFont = alertBannerLineFonts[lineCount];
|
||||
}
|
||||
const char *renderText = resolveBannerLine(lineCount, lines[lineCount], lineFont);
|
||||
renderLines[lineCount] = renderText;
|
||||
lineFonts[lineCount] = lineFont;
|
||||
lineEffectiveHeights[lineCount] = effectiveLineHeightForBannerLine(lineFont);
|
||||
@@ -879,10 +894,10 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
char measureBuffer[64] = {0};
|
||||
strncpy(measureBuffer, lines[lineCount], std::min<size_t>(lineLengths[lineCount], sizeof(measureBuffer) - 1));
|
||||
strncpy(measureBuffer, renderText, std::min<size_t>(lineLengths[lineCount], sizeof(measureBuffer) - 1));
|
||||
lineWidths[lineCount] = UIRenderer::measureStringWithEmotes(display, measureBuffer);
|
||||
} else {
|
||||
lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true);
|
||||
lineWidths[lineCount] = display->getStringWidth(renderText, lineLengths[lineCount], true);
|
||||
}
|
||||
|
||||
// Consider extra width for signal bars on lines that contain "Signal:"
|
||||
@@ -1213,7 +1228,8 @@ void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiStat
|
||||
|
||||
bool NotificationRenderer::isOverlayBannerShowing()
|
||||
{
|
||||
return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil);
|
||||
// Here 0 means "show indefinitely", so it must short-circuit the comparison.
|
||||
return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || !Throttle::deadlinePassed(alertBannerUntil));
|
||||
}
|
||||
|
||||
bool NotificationRenderer::isMenuShowing()
|
||||
|
||||
@@ -38,6 +38,10 @@ class NotificationRenderer
|
||||
static uint8_t alertBannerLineCount;
|
||||
static BannerFont alertBannerLineFonts[MAX_LINES + 1];
|
||||
static void parseBannerMessageWithFonts(const char *message);
|
||||
// Decide what text and font a banner line actually renders with: parsed (tag-stripped)
|
||||
// line if the cache covers it, otherwise the raw line with any leading font tag stripped
|
||||
// on the fly. Exposed for unit tests.
|
||||
static const char *resolveBannerLine(uint16_t lineIndex, const char *rawLine, BannerFont &lineFont);
|
||||
static void resetBanner();
|
||||
static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
#include "graphics/images.h"
|
||||
#include "main.h"
|
||||
#include "target_specific.h"
|
||||
#ifdef COMPASS_SENSOR_DEBUG
|
||||
#include "motion/MotionSensor.h"
|
||||
#endif
|
||||
#include <OLEDDisplay.h>
|
||||
#include <cstring>
|
||||
#include <gps/RTC.h>
|
||||
@@ -448,7 +451,8 @@ static bool computeBottomCompassPlacement(OLEDDisplay *display, int16_t xOffset,
|
||||
int16_t margin, int16_t *compassX, int16_t *compassY, int16_t *compassRadius)
|
||||
{
|
||||
// Return false when content leaves no room for a readable compass.
|
||||
int availableHeight = SCREEN_HEIGHT - yBelowContent - bottomReserved - margin;
|
||||
int availableHeight =
|
||||
SCREEN_HEIGHT - yBelowContent - bottomReserved - margin - BASEUI_HEADER_MARGIN - BASEUI_BELOW_HEADER_MARGIN;
|
||||
if (availableHeight < FONT_HEIGHT_SMALL * 2) {
|
||||
return false;
|
||||
}
|
||||
@@ -543,7 +547,7 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display);
|
||||
} else {
|
||||
display->drawXbm(x + 1, y + 1, imgGPS_width, imgGPS_height, imgGPS);
|
||||
display->drawXbm(x + 1, y + 3, imgGPS_width, imgGPS_height, imgGPS);
|
||||
}
|
||||
|
||||
display->drawString(x + textOffset, y, textString);
|
||||
@@ -578,12 +582,12 @@ void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y,
|
||||
if (!gps->getIsConnected() && !config.position.fixed_position) {
|
||||
if (strcmp(mode, "line1") == 0) {
|
||||
strcpy(displayLine, "No GPS present");
|
||||
display->drawString(x, y, displayLine);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, y, displayLine);
|
||||
}
|
||||
} else if (!gps->getHasLock() && !config.position.fixed_position) {
|
||||
if (strcmp(mode, "line1") == 0) {
|
||||
strcpy(displayLine, gps->getHasTime() ? "GPS Time Only" : "No GPS Lock");
|
||||
display->drawString(x, y, displayLine);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, y, displayLine);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -662,13 +666,14 @@ void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y,
|
||||
}
|
||||
|
||||
if (strcmp(mode, "line1") == 0) {
|
||||
display->drawString(x, y, coordinateLine_1);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, y, coordinateLine_1);
|
||||
} else if (strcmp(mode, "line2") == 0) {
|
||||
display->drawString(x, y, coordinateLine_2);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, y, coordinateLine_2);
|
||||
} else if (strcmp(mode, "combined") == 0) {
|
||||
display->drawString(x, y, coordinateLine_1);
|
||||
if (coordinateLine_2[0] != '\0') {
|
||||
display->drawString(x + display->getStringWidth(coordinateLine_1), y, coordinateLine_2);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN + display->getStringWidth(coordinateLine_1), y,
|
||||
coordinateLine_2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,12 +685,12 @@ void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y,
|
||||
snprintf(coordinateLine_2, sizeof(coordinateLine_2), "Lon: %3i° %2i' %2u\" %1c", geoCoord.getDMSLonDeg(),
|
||||
geoCoord.getDMSLonMin(), geoCoord.getDMSLonSec(), geoCoord.getDMSLonCP());
|
||||
if (strcmp(mode, "line1") == 0) {
|
||||
display->drawString(x, y, coordinateLine_1);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, y, coordinateLine_1);
|
||||
} else if (strcmp(mode, "line2") == 0) {
|
||||
display->drawString(x, y, coordinateLine_2);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, y, coordinateLine_2);
|
||||
} else { // both
|
||||
display->drawString(x, y, coordinateLine_1);
|
||||
display->drawString(x, y + 10, coordinateLine_2);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, y, coordinateLine_1);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, y + 10, coordinateLine_2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -930,6 +935,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
||||
}
|
||||
#endif
|
||||
|
||||
y += BASEUI_BELOW_HEADER_MARGIN;
|
||||
// ===== DYNAMIC ROW STACKING WITH YOUR MACROS =====
|
||||
// 1. Each potential info row has a macro-defined Y position (not regular increments!).
|
||||
// 2. Each row is only shown if it has valid data.
|
||||
@@ -1301,7 +1307,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
||||
}
|
||||
|
||||
// ****************************
|
||||
// * Device Focused Screen *
|
||||
// * Home Frame *
|
||||
// ****************************
|
||||
void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
@@ -1310,6 +1316,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
display->setFont(FONT_SMALL);
|
||||
int line = 1;
|
||||
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
bool origBold = config.display.heading_bold;
|
||||
|
||||
// === Header ===
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
@@ -1317,11 +1324,11 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
} else {
|
||||
graphics::drawCommonHeader(display, x, y, "");
|
||||
}
|
||||
y += BASEUI_BELOW_HEADER_MARGIN;
|
||||
|
||||
// === Content below header ===
|
||||
|
||||
// === First Row: Region / Channel Utilization and Uptime ===
|
||||
bool origBold = config.display.heading_bold;
|
||||
config.display.heading_bold = false;
|
||||
|
||||
const bool compactPanel = graphics::isCompactPanel(display);
|
||||
@@ -1330,19 +1337,20 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
const char *txdisabled = "Transmit Disabled";
|
||||
if (compactPanel) {
|
||||
int textWidth = display->getStringWidth(txdisabled);
|
||||
display->drawString((SCREEN_WIDTH - textWidth) / 2, getTextPositions(display)[line], txdisabled);
|
||||
display->drawString((SCREEN_WIDTH - textWidth) / 2, getTextPositions(display)[line] + y, txdisabled);
|
||||
} else {
|
||||
display->drawString(x, getTextPositions(display)[line], txdisabled);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line] + y, txdisabled);
|
||||
}
|
||||
} else if (compactPanel) {
|
||||
// No room for a separate left/right column layout - center it instead.
|
||||
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online", true);
|
||||
drawNodes(display, x, getTextPositions(display)[line] + y + 2, nodeStatus, -1, false, "online", true);
|
||||
} else {
|
||||
// Display Region and Channel Utilization
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
||||
drawNodes(display, x, getTextPositions(display)[line] + y + 2, nodeStatus, -1, false, "online");
|
||||
} else {
|
||||
drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
||||
drawNodes(display, x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line] + y + 2, nodeStatus, -1, false,
|
||||
"online");
|
||||
}
|
||||
}
|
||||
char uptimeStr[32] = "";
|
||||
@@ -1350,7 +1358,8 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr));
|
||||
}
|
||||
if (!compactPanel) {
|
||||
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);
|
||||
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr) - BASEUI_BODY_LR_MARGIN,
|
||||
getTextPositions(display)[line++] + y, uptimeStr);
|
||||
} else {
|
||||
line++;
|
||||
}
|
||||
@@ -1359,7 +1368,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
config.display.heading_bold = false;
|
||||
|
||||
#if HAS_GPS
|
||||
UIRenderer::drawGps(display, x, getTextPositions(display)[line], gpsStatus, compactPanel);
|
||||
UIRenderer::drawGps(display, x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line] + y, gpsStatus, compactPanel);
|
||||
#endif
|
||||
|
||||
#if defined(OLED_TINY)
|
||||
@@ -1371,7 +1380,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
char chUtilStr[16];
|
||||
snprintf(chUtilStr, sizeof(chUtilStr), "ChUtil %d%%", chutil_percent);
|
||||
int chUtilWidth = display->getStringWidth(chUtilStr);
|
||||
display->drawString((SCREEN_WIDTH - chUtilWidth) / 2, getTextPositions(display)[line++], chUtilStr);
|
||||
display->drawString((SCREEN_WIDTH - chUtilWidth) / 2, getTextPositions(display)[line++] + y, chUtilStr);
|
||||
|
||||
// === Node Identity: long name (falls back to short), truncated with "..." if too wide ===
|
||||
const char *longName = (nodeInfoLiteHasUser(ourNode) && ourNode->long_name[0]) ? ourNode->long_name : "";
|
||||
@@ -1381,14 +1390,14 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
UIRenderer::truncateStringWithEmotes(display, rawName, nodeName, sizeof(nodeName), SCREEN_WIDTH - 4);
|
||||
int textWidth = UIRenderer::measureStringWithEmotes(display, nodeName);
|
||||
int nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], nodeName, FONT_HEIGHT_SMALL, 1,
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + y, nodeName, FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
} else {
|
||||
// === Node Identity ===
|
||||
const char *shortName = owner.short_name[0] ? owner.short_name : "";
|
||||
int textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
|
||||
int nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1,
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + y, shortName, FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
}
|
||||
#else
|
||||
@@ -1397,9 +1406,11 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
int batV = powerStatus->getBatteryVoltageMv() / 1000;
|
||||
int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10;
|
||||
snprintf(batStr, sizeof(batStr), "%01d.%02dV", batV, batCv);
|
||||
display->drawString(x + SCREEN_WIDTH - display->getStringWidth(batStr), getTextPositions(display)[line++], batStr);
|
||||
display->drawString(x + SCREEN_WIDTH - BASEUI_BODY_LR_MARGIN - display->getStringWidth(batStr),
|
||||
getTextPositions(display)[line++] + y, batStr);
|
||||
} else {
|
||||
display->drawString(x + SCREEN_WIDTH - display->getStringWidth("USB"), getTextPositions(display)[line++], "USB");
|
||||
display->drawString(x + SCREEN_WIDTH - BASEUI_BODY_LR_MARGIN - display->getStringWidth("USB"),
|
||||
getTextPositions(display)[line++] + y, "USB");
|
||||
}
|
||||
|
||||
config.display.heading_bold = origBold;
|
||||
@@ -1410,9 +1421,8 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
int chutil_percent = static_cast<int>(airTime->channelUtilizationPercent() + 0.5f);
|
||||
snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%d%%", chutil_percent);
|
||||
|
||||
int chUtil_x = (currentResolution == ScreenResolution::High) ? display->getStringWidth(chUtil) + 10
|
||||
: display->getStringWidth(chUtil) + 5;
|
||||
int chUtil_y = getTextPositions(display)[line] + 3;
|
||||
int chUtil_width = display->getStringWidth(chUtil);
|
||||
int chUtil_y = getTextPositions(display)[line] + 3 + y;
|
||||
|
||||
int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50;
|
||||
int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border
|
||||
@@ -1430,10 +1440,15 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
}
|
||||
const int raw_chutil_percent = chutil_percent;
|
||||
|
||||
// With BT disabled we pin this row left to make room for the extra "BT off" indicator.
|
||||
const int starting_position = config.bluetooth.enabled ? x : 0;
|
||||
// Center the row; with BT disabled reserve the width of the extra "BT off" indicator.
|
||||
int starting_position =
|
||||
(SCREEN_WIDTH - chUtil_width - chutil_bar_width - extraoffset - display->getStringWidth(chUtilPercentage));
|
||||
if (!config.bluetooth.enabled) {
|
||||
starting_position -= (display->getStringWidth("BT off") + extraoffset);
|
||||
}
|
||||
starting_position /= 2;
|
||||
|
||||
display->drawString(starting_position, getTextPositions(display)[line], chUtil);
|
||||
display->drawString(starting_position, getTextPositions(display)[line] + y, chUtil);
|
||||
|
||||
// Force 61% or higher to show a full 100% bar, text would still show related percent.
|
||||
if (chutil_percent >= 61) {
|
||||
@@ -1443,7 +1458,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
int fillRight = computeChannelUtilizationFill(chutil_percent, chutil_bar_max_fill);
|
||||
|
||||
// Draw outline
|
||||
display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height);
|
||||
display->drawRect(starting_position + chUtil_width, chUtil_y, chutil_bar_width, chutil_bar_height);
|
||||
|
||||
// Fill progress
|
||||
if (fillRight > 0) {
|
||||
@@ -1455,16 +1470,18 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
UtilizationFillColor = TFTPalette::Medium;
|
||||
}
|
||||
setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black,
|
||||
starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
||||
starting_position + chUtil_width + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
||||
#endif
|
||||
display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
||||
display->fillRect(starting_position + chUtil_width + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
||||
}
|
||||
|
||||
display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line],
|
||||
display->drawString(starting_position + chUtil_width + chutil_bar_width + extraoffset, getTextPositions(display)[line] + y,
|
||||
chUtilPercentage);
|
||||
|
||||
if (!config.bluetooth.enabled) {
|
||||
display->drawString(SCREEN_WIDTH - display->getStringWidth("BT off"), getTextPositions(display)[line], "BT off");
|
||||
display->drawString(starting_position + chUtil_width + chutil_bar_width + extraoffset +
|
||||
display->getStringWidth(chUtilPercentage) + extraoffset,
|
||||
getTextPositions(display)[line] + y, "BT off");
|
||||
}
|
||||
|
||||
line += 1;
|
||||
@@ -1488,21 +1505,28 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) {
|
||||
textWidth = UIRenderer::measureStringWithEmotes(display, combinedName);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + yOffset, combinedName,
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + yOffset + y, combinedName,
|
||||
FONT_HEIGHT_SMALL, 1, false);
|
||||
} else {
|
||||
// === LongName Centered ===
|
||||
textWidth = UIRenderer::measureStringWithEmotes(display, longName);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], longName, FONT_HEIGHT_SMALL, 1,
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + y, longName, FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
|
||||
// === ShortName Centered ===
|
||||
textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1,
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + y, shortName, FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
}
|
||||
#ifdef SHOW_STEP_COUNTER
|
||||
std::string stepsLine = "Steps: " + std::to_string(screen->steps);
|
||||
textWidth = UIRenderer::measureStringWithEmotes(display, stepsLine.c_str());
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + y, stepsLine.c_str(), FONT_HEIGHT_SMALL,
|
||||
1, false);
|
||||
#endif
|
||||
#endif
|
||||
graphics::drawCommonFooter(display, x, y);
|
||||
}
|
||||
@@ -1773,15 +1797,36 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
|
||||
// === Header ===
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
y += BASEUI_BELOW_HEADER_MARGIN;
|
||||
const int *textPos = getTextPositions(display);
|
||||
const bool compactPanel = graphics::isCompactPanel(display);
|
||||
|
||||
#ifdef COMPASS_SENSOR_DEBUG
|
||||
// Optional raw IMU accel + magnetometer x/y/z readout for on-device axis/sign tuning.
|
||||
{
|
||||
char dbg[40];
|
||||
float sx = 0, sy = 0, sz = 0;
|
||||
uint32_t age = 0;
|
||||
if (MotionSensor::getLatestCompassAccelSample(sx, sy, sz, age))
|
||||
snprintf(dbg, sizeof(dbg), "A %.2f %.2f %.2f", sx, sy, sz);
|
||||
else
|
||||
snprintf(dbg, sizeof(dbg), "A ---");
|
||||
display->drawString(x, textPos[line++], dbg);
|
||||
|
||||
if (MotionSensor::getLatestCompassMagSample(sx, sy, sz, age))
|
||||
snprintf(dbg, sizeof(dbg), "M %.2f %.2f %.2f", sx, sy, sz);
|
||||
else
|
||||
snprintf(dbg, sizeof(dbg), "M ---");
|
||||
display->drawString(x, textPos[line++], dbg);
|
||||
}
|
||||
#endif
|
||||
|
||||
// === First Row: My Location ===
|
||||
#if HAS_GPS
|
||||
bool origBold = config.display.heading_bold;
|
||||
config.display.heading_bold = false;
|
||||
|
||||
UIRenderer::drawGps(display, x, textPos[line++], gpsStatus, compactPanel);
|
||||
UIRenderer::drawGps(display, x + BASEUI_BODY_LR_MARGIN, textPos[line++] + y, gpsStatus, compactPanel);
|
||||
|
||||
config.display.heading_bold = origBold;
|
||||
|
||||
@@ -1891,18 +1936,18 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), true);
|
||||
#endif
|
||||
|
||||
display->drawString(0, textPos[line++], uptimeStr);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, textPos[line++] + y, uptimeStr);
|
||||
} else {
|
||||
display->drawString(0, textPos[line++], "Last: ?");
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, textPos[line++] + y, "Last: ?");
|
||||
}
|
||||
|
||||
// === Third Row: Line 1 GPS Info ===
|
||||
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line1");
|
||||
UIRenderer::drawGpsCoordinates(display, x, textPos[line++] + y, gpsStatus, "line1");
|
||||
|
||||
if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC &&
|
||||
uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS) {
|
||||
// === Fourth Row: Line 2 GPS Info ===
|
||||
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line2");
|
||||
UIRenderer::drawGpsCoordinates(display, x, textPos[line++] + y, gpsStatus, "line2");
|
||||
}
|
||||
|
||||
// === Final Row: Altitude ===
|
||||
@@ -1913,21 +1958,21 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
} else {
|
||||
snprintf(altitudeLine, sizeof(altitudeLine), "Alt: %.0im", alt);
|
||||
}
|
||||
display->drawString(x, textPos[line++], altitudeLine);
|
||||
display->drawString(x + BASEUI_BODY_LR_MARGIN, textPos[line++] + y, altitudeLine);
|
||||
}
|
||||
#if !defined(OLED_TINY)
|
||||
// === Draw Compass ===
|
||||
if (validHeading || statusLine1) {
|
||||
// --- Compass Rendering: landscape (wide) screens use original side-aligned logic ---
|
||||
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
|
||||
const int16_t topY = textPos[1];
|
||||
const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); // nav row height
|
||||
const int16_t topY = textPos[1] + y;
|
||||
const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1) - y; // nav row height
|
||||
const int16_t usableHeight = bottomY - topY - 5;
|
||||
|
||||
int16_t compassRadius = usableHeight / 2;
|
||||
if (compassRadius < 8)
|
||||
compassRadius = 8;
|
||||
const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8;
|
||||
const int16_t compassX = x + BASEUI_BODY_LR_MARGIN + SCREEN_WIDTH - compassRadius - 8;
|
||||
|
||||
// Center vertically and nudge down slightly to keep "N" clear of header
|
||||
const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
|
||||
@@ -2062,7 +2107,11 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
lastFrameChangeTime = millis();
|
||||
}
|
||||
|
||||
#ifdef OLED_HUGE
|
||||
const int iconSize = 24;
|
||||
#else
|
||||
const int iconSize = (currentResolution == ScreenResolution::High) ? 16 : 8;
|
||||
#endif
|
||||
const int spacing = (currentResolution == ScreenResolution::High) ? 8 : 4;
|
||||
const int bigOffset = (currentResolution == ScreenResolution::High) ? 1 : 0;
|
||||
const bool compactPanel = graphics::isCompactPanel(display);
|
||||
@@ -2130,7 +2179,11 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
}
|
||||
#endif
|
||||
|
||||
#if BASEUI_HEADER_LR_MARGIN
|
||||
const int navPadding = BASEUI_HEADER_LR_MARGIN;
|
||||
#else
|
||||
const int navPadding = compactPanel ? 8 : ((currentResolution == ScreenResolution::High) ? 24 : 12);
|
||||
#endif
|
||||
|
||||
int usableWidth = SCREEN_WIDTH - (navPadding * 2);
|
||||
if (usableWidth < iconSize)
|
||||
@@ -2230,12 +2283,15 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
display->setColor(BLACK);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef OLED_HUGE
|
||||
NodeListRenderer::drawScaledXBitmap3x(x, y, 8, 8, icon, display);
|
||||
#else
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y, 8, 8, icon, display);
|
||||
} else {
|
||||
display->drawXbm(x, y, iconSize, iconSize, icon);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (isActive) {
|
||||
display->setColor(WHITE);
|
||||
|
||||
@@ -52,6 +52,10 @@ class UIRenderer
|
||||
// though drawNavigationBar itself never ran while the screen (and its OSThread) was off.
|
||||
static void notifyScreenWoke();
|
||||
|
||||
// screen frames
|
||||
// First two pointers are self explanatory
|
||||
// x and y are the offset everything should be drawn at, to support sliding transitions between frames.
|
||||
|
||||
static void drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
// Compact panels: toggle between compass+distance view and status/telemetry view
|
||||
static void scrollFavoriteDown();
|
||||
|
||||
@@ -324,8 +324,9 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
||||
auto changes = SEGMENT_CONFIG;
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
if (crypto) {
|
||||
crypto->ensurePkiKeys(config.security, owner);
|
||||
// Minting the key moves our node num with it, and the reboot below only re-derives after the save.
|
||||
if (nodeDB->ensurePkiIdentity()) {
|
||||
changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -24,4 +24,4 @@ build_flags =
|
||||
-D HAS_BUTTON=0 ; Suppress default ButtonThread
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=GFX_Root packageName=ZinggJM/GFX_Root
|
||||
https://github.com/ZinggJM/GFX_Root/archive/3195764e352a0d2567c8d277ac408ca7293a99b0.zip ; Used by InkHUD as a "slimmer" version of AdafruitGFX
|
||||
https://github.com/ZinggJM/GFX_Root.git#3195764e352a0d2567c8d277ac408ca7293a99b0 ; Used by InkHUD as a "slimmer" version of AdafruitGFX
|
||||
@@ -96,7 +96,7 @@ template <typename T> class FlashData
|
||||
|
||||
f.close();
|
||||
} else {
|
||||
LOG_ERROR("Can't open / read %s", filename.c_str());
|
||||
LOG_ERROR("Can't open/read %s", filename.c_str());
|
||||
okay = false;
|
||||
}
|
||||
#else
|
||||
|
||||
+28
-17
@@ -15,8 +15,6 @@
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_PORTDUINO) || !defined(HAS_FREE_RTOS)
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <thread>
|
||||
#endif
|
||||
|
||||
@@ -235,10 +233,8 @@ DeviceScreen *deviceScreen = nullptr;
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// Get notified when the system is entering light sleep
|
||||
CallbackObserver<DeviceScreen, void *> tftSleepObserver =
|
||||
CallbackObserver<DeviceScreen, void *>(deviceScreen, &DeviceScreen::prepareSleep);
|
||||
CallbackObserver<DeviceScreen, esp_sleep_wakeup_cause_t> endSleepObserver =
|
||||
CallbackObserver<DeviceScreen, esp_sleep_wakeup_cause_t>(deviceScreen, &DeviceScreen::wakeUp);
|
||||
static CallbackObserver<DeviceScreen, void *> *tftSleepObserver = nullptr;
|
||||
static CallbackObserver<DeviceScreen, esp_sleep_wakeup_cause_t> *endSleepObserver = nullptr;
|
||||
#endif
|
||||
|
||||
/**
|
||||
@@ -277,6 +273,19 @@ class ReentrantSpiLock : public ISpiLock
|
||||
depth = 1;
|
||||
}
|
||||
|
||||
bool lock(uint32_t timeout) override
|
||||
{
|
||||
ThreadId self = currentThread();
|
||||
if (depth && owner == self) {
|
||||
depth++;
|
||||
return true;
|
||||
}
|
||||
bool result = spiLock->lock(timeout);
|
||||
owner = self;
|
||||
depth = 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
void unlock(void) override
|
||||
{
|
||||
if (--depth == 0) {
|
||||
@@ -340,15 +349,11 @@ void tftSetup(void)
|
||||
#elif defined(USE_FRAMEBUFFER)
|
||||
if (portduino_config.displayPanel == fb) {
|
||||
// Rotation from yaml Display.OffsetRotate: 1=90, 2=180, 3=270 deg
|
||||
char rbuf[4];
|
||||
snprintf(rbuf, sizeof(rbuf), "%d", portduino_config.displayRotate ? (portduino_config.displayOffsetRotate & 3) : 0);
|
||||
if (setenv("MESHTASTIC_FB_ROTATION", rbuf, 1) != 0)
|
||||
LOG_ERROR("Failed to set MESHTASTIC_FB_ROTATION, framebuffer will use its default rotation");
|
||||
if (portduino_config.displayWidth && portduino_config.displayHeight)
|
||||
displayConfig = DisplayDriverConfig(DisplayDriverConfig::device_t::FB, (uint16_t)portduino_config.displayWidth,
|
||||
(uint16_t)portduino_config.displayHeight);
|
||||
else
|
||||
displayConfig.device(DisplayDriverConfig::device_t::FB);
|
||||
displayConfig.device(DisplayDriverConfig::device_t::FB)
|
||||
.panel(DisplayDriverConfig::panel_config_t{.type = panels[portduino_config.displayPanel],
|
||||
.panel_width = (uint16_t)portduino_config.displayWidth,
|
||||
.panel_height = (uint16_t)portduino_config.displayHeight,
|
||||
.offset_rotation = (uint8_t)portduino_config.displayOffsetRotate});
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
@@ -413,8 +418,14 @@ void tftSetup(void)
|
||||
|
||||
if (deviceScreen) {
|
||||
#ifdef ARCH_ESP32
|
||||
tftSleepObserver.observe(¬ifyLightSleep);
|
||||
endSleepObserver.observe(¬ifyLightSleepEnd);
|
||||
if (!tftSleepObserver) {
|
||||
tftSleepObserver = new CallbackObserver<DeviceScreen, void *>(deviceScreen, &DeviceScreen::prepareSleep);
|
||||
}
|
||||
if (!endSleepObserver) {
|
||||
endSleepObserver = new CallbackObserver<DeviceScreen, esp_sleep_wakeup_cause_t>(deviceScreen, &DeviceScreen::wakeUp);
|
||||
}
|
||||
tftSleepObserver->observe(¬ifyLightSleep);
|
||||
endSleepObserver->observe(¬ifyLightSleepEnd);
|
||||
xTaskCreatePinnedToCore(tft_task_handler, "tft", TFT_TASK_STACK_SIZE, NULL, 1, NULL, 0);
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
std::thread *tft_task = new std::thread([] { tft_task_handler(); });
|
||||
|
||||
@@ -102,7 +102,9 @@ bool ButtonThread::initButton(const ButtonConfig &config)
|
||||
#endif
|
||||
userButton.setPressMs(_longPressTime);
|
||||
|
||||
if (screen) {
|
||||
// The 20ms window a screen normally gets closes before a second click can land, so boards
|
||||
// binding double or multi click need the full one.
|
||||
if (screen && _doublePress == INPUT_BROKER_NONE && _triplePress == INPUT_BROKER_NONE) {
|
||||
userButton.setClickMs(20);
|
||||
} else {
|
||||
userButton.setClickMs(BUTTON_CLICK_MS);
|
||||
@@ -225,15 +227,8 @@ int32_t ButtonThread::runOnce()
|
||||
break;
|
||||
}
|
||||
|
||||
case BUTTON_EVENT_DOUBLE_PRESSED: { // not wired in if screen detected
|
||||
case BUTTON_EVENT_DOUBLE_PRESSED: { // only on boards binding ButtonConfig::doublePress
|
||||
LOG_INFO("Double press");
|
||||
#if defined(ELECROW_ThinkNode_M8)
|
||||
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED)
|
||||
config.device.buzzer_mode = meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED;
|
||||
else if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED)
|
||||
config.device.buzzer_mode = meshtastic_Config_DeviceConfig_BuzzerMode_ALL_ENABLED;
|
||||
service->reloadConfig(SEGMENT_CONFIG);
|
||||
#endif
|
||||
// Reset combination tracking
|
||||
waitingForLongPress = false;
|
||||
|
||||
|
||||
Loaded 100 of 382 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in New Issue
Block a user