mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-22 22:35:35 -04:00
Merge branch 'develop' into mesh-pager-x2
This commit is contained in:
257 files changed
+18471
-4498
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.
|
||||
@@ -338,6 +338,7 @@ firmware/
|
||||
- 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.
|
||||
- **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(...)`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -195,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,
|
||||
@@ -273,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
|
||||
@@ -368,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
|
||||
@@ -398,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: |
|
||||
@@ -405,9 +436,31 @@ 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
|
||||
|
||||
@@ -158,6 +158,7 @@ lint:
|
||||
# 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:
|
||||
|
||||
@@ -81,6 +81,7 @@ 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.
|
||||
- **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(...)`.
|
||||
|
||||
@@ -22,3 +22,7 @@
|
||||
**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())
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+83
-6
@@ -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,6 +165,16 @@ export MESHTASTIC_TEST_STATE_SUMMARY="$STATE_SUMMARY"
|
||||
$KEEP_STATE && export MESHTASTIC_TEST_KEEP_STATE=1
|
||||
$WRITE_MANIFEST && export MESHTASTIC_TEST_KEEP_STATE=1
|
||||
|
||||
# --- 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)
|
||||
@@ -251,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
|
||||
@@ -289,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
|
||||
|
||||
@@ -426,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,6 +495,34 @@ verdict_suffix() {
|
||||
echo "$rating"
|
||||
}
|
||||
|
||||
# --- 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
|
||||
# still wins, and before the pass/fail verdict lines so the state summary always prints.
|
||||
@@ -472,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
|
||||
@@ -551,6 +613,21 @@ if ((${#DIRTY_SUITES[@]} > 0)); then
|
||||
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
|
||||
|
||||
# AMBER: a suite was still running after PlatformIO reported it. A bare UNITY_END() ends the
|
||||
# reporting, not the process - the runtime goes on calling loop() - so the suite passes, the run goes
|
||||
# green, and the binary stays resident. The wrapper has already killed it, but the consequences do
|
||||
|
||||
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")"
|
||||
@@ -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.
|
||||
+13
-20
@@ -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/6a52e33ad81e9b1d060a6db52b36c9535c742b45.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
|
||||
@@ -257,21 +262,9 @@ lib_deps =
|
||||
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}
|
||||
https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip
|
||||
# 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: 84bfb0fdb3...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
|
||||
|
||||
+43
-11
@@ -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;
|
||||
|
||||
+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
|
||||
|
||||
+46
-2
@@ -1142,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
|
||||
/*
|
||||
@@ -1446,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)
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
+166
-99
@@ -2,62 +2,65 @@
|
||||
#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.
|
||||
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;
|
||||
}
|
||||
|
||||
uint32_t air_period_tx[PERIODS_TO_LOG];
|
||||
uint32_t air_period_rx[PERIODS_TO_LOG];
|
||||
AirTime::Held::~Held()
|
||||
{
|
||||
#ifdef AIRTIME_REENTRY_CHECK
|
||||
owner->reentryFlag = false;
|
||||
#else
|
||||
(void)owner;
|
||||
#endif
|
||||
}
|
||||
|
||||
void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms)
|
||||
// --- 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();
|
||||
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()
|
||||
{
|
||||
return ((secSinceBoot / SECONDS_PER_PERIOD) % PERIODS_TO_LOG);
|
||||
}
|
||||
|
||||
uint8_t AirTime::getPeriodUtilMinute()
|
||||
uint8_t AirTime::Windows::getPeriodUtilMinute(const Held &)
|
||||
{
|
||||
return (secSinceBoot / 10) % CHANNEL_UTILIZATION_PERIODS;
|
||||
}
|
||||
|
||||
uint8_t AirTime::getPeriodUtilHour()
|
||||
uint8_t AirTime::Windows::getPeriodUtilHour(const Held &)
|
||||
{
|
||||
return (secSinceBoot / 60) % MINUTES_IN_HOUR;
|
||||
}
|
||||
|
||||
void AirTime::airtimeRotatePeriod()
|
||||
{
|
||||
// Preserve the public helper while keeping all rotation logic in one monotonic-time path.
|
||||
syncNow();
|
||||
}
|
||||
|
||||
void AirTime::syncNow()
|
||||
void AirTime::Windows::syncNow(const Held &)
|
||||
{
|
||||
// Monotonic uptime, not RTC/network time: a user, GPS, or NTP clock change must not move
|
||||
// airtime accounting. Pure read; the main loop publishes the wrap carry it derives from.
|
||||
@@ -69,13 +72,8 @@ void AirTime::syncNow()
|
||||
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));
|
||||
memset(air_period_tx, 0, sizeof(air_period_tx));
|
||||
memset(air_period_rx, 0, sizeof(air_period_rx));
|
||||
|
||||
this->secSinceBoot = nowSecs;
|
||||
this->lastUtilPeriod = this->getPeriodUtilMinute();
|
||||
this->lastUtilPeriodTX = this->getPeriodUtilHour();
|
||||
this->airtimes.lastPeriodIndex = this->currentPeriodIndex();
|
||||
firstTime = false;
|
||||
return;
|
||||
}
|
||||
@@ -94,27 +92,22 @@ void AirTime::syncNow()
|
||||
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));
|
||||
memset(air_period_tx, 0, sizeof(air_period_tx));
|
||||
memset(air_period_rx, 0, sizeof(air_period_rx));
|
||||
} else {
|
||||
while (elapsedAirtimePeriods-- > 0) {
|
||||
LOG_DEBUG("Rotate airtimes to a new period = %u", this->currentPeriodIndex());
|
||||
// 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];
|
||||
air_period_tx[i + 1] = this->airtimes.periodTX[i];
|
||||
air_period_rx[i + 1] = this->airtimes.periodRX[i];
|
||||
}
|
||||
|
||||
this->airtimes.periodTX[0] = 0;
|
||||
this->airtimes.periodRX[0] = 0;
|
||||
this->airtimes.periodRX_ALL[0] = 0;
|
||||
air_period_tx[0] = 0;
|
||||
air_period_rx[0] = 0;
|
||||
}
|
||||
}
|
||||
this->airtimes.lastPeriodIndex = this->currentPeriodIndex();
|
||||
|
||||
// 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.
|
||||
@@ -126,7 +119,6 @@ void AirTime::syncNow()
|
||||
this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0;
|
||||
}
|
||||
}
|
||||
this->lastUtilPeriod = this->getPeriodUtilMinute();
|
||||
|
||||
// TX utilization is a rolling 60-minute view used by duty-cycle checks.
|
||||
uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60);
|
||||
@@ -137,45 +129,35 @@ void AirTime::syncNow()
|
||||
this->utilizationTX[((oldSecSinceBoot / 60) + i) % MINUTES_IN_HOUR] = 0;
|
||||
}
|
||||
}
|
||||
this->lastUtilPeriodTX = this->getPeriodUtilHour();
|
||||
}
|
||||
|
||||
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();
|
||||
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()
|
||||
{
|
||||
return PERIODS_TO_LOG;
|
||||
}
|
||||
|
||||
uint32_t AirTime::getSecondsPerPeriod()
|
||||
{
|
||||
return SECONDS_PER_PERIOD;
|
||||
}
|
||||
|
||||
uint32_t AirTime::getSecondsSinceBoot()
|
||||
{
|
||||
// Keep HTTP/debug reporting aligned with the same monotonic clock used by the buckets.
|
||||
syncNow();
|
||||
return this->secSinceBoot;
|
||||
}
|
||||
|
||||
float AirTime::channelUtilizationPercent()
|
||||
float AirTime::Windows::channelUtilizationPercent(const Held &held)
|
||||
{
|
||||
// Gate decisions should see buckets that have decayed across light-sleep time.
|
||||
syncNow();
|
||||
syncNow(held);
|
||||
|
||||
uint32_t sum = 0;
|
||||
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) {
|
||||
@@ -185,10 +167,10 @@ 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();
|
||||
syncNow(held);
|
||||
|
||||
uint32_t sum = 0;
|
||||
for (uint32_t i = 0; i < MINUTES_IN_HOUR; i++) {
|
||||
@@ -198,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) {
|
||||
@@ -236,10 +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()
|
||||
{
|
||||
syncNow();
|
||||
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
-45
@@ -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,16 +86,42 @@
|
||||
|
||||
enum reportTypes { TX_LOG, RX_LOG, RX_ALL_LOG };
|
||||
|
||||
void logAirtime(reportTypes reportType, uint32_t airtime_ms);
|
||||
// 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
|
||||
|
||||
uint32_t *airtimeReport(reportTypes reportType);
|
||||
|
||||
// Not thread-safe: everything but getPeriodsToLog()/getSecondsPerPeriod() either rotates the
|
||||
// windows via syncNow() or reads the buckets. Current callers are all on the OSThread scheduler -
|
||||
// RadioLibInterface/SimRadio, RadioInterface, Router, DeviceTelemetry, ContentHandler, and the
|
||||
// screen renderers. New callers must be on that thread too, or this needs a lock.
|
||||
// TODO: airtime lock-guarding - serialise the above behind a lock so the contract is enforced
|
||||
// rather than documented. Kept out of this PR: it is a separate concern from millis() rollover.
|
||||
// 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
|
||||
{
|
||||
|
||||
@@ -55,43 +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;
|
||||
// Time::getUptimeSecs() as of the last syncNow(); the gap since is what the windows rotate by,
|
||||
// so they stay correct even if the scheduler was paused by light sleep.
|
||||
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();
|
||||
// Advance rolling airtime windows from monotonic uptime, not from runOnce() calls.
|
||||
void syncNow();
|
||||
|
||||
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.
|
||||
|
||||
+18
-2
@@ -205,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
|
||||
@@ -234,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
|
||||
@@ -253,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
|
||||
@@ -270,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
|
||||
@@ -300,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
|
||||
@@ -310,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
|
||||
|
||||
+37
-20
@@ -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,10 +28,16 @@ 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();
|
||||
|
||||
@@ -41,36 +48,27 @@ class ReClockI2C
|
||||
if (currentClock != 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_TRACE("Stored previous clock I2C clock: %uHz", this->previousClock);
|
||||
return true;
|
||||
LOG_TRACE("Previous I2C clock: %uHz", currentClock);
|
||||
return currentClock;
|
||||
}
|
||||
|
||||
LOG_TRACE("I2C clock was already %uHz. Skipping", desiredClock);
|
||||
setPreviousClock(0);
|
||||
return false;
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool restoreClock()
|
||||
void restoreClock(uint32_t previousClock)
|
||||
{
|
||||
if (this->previousClock) {
|
||||
LOG_TRACE("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_TRACE("I2C clock was unknown. Not restored");
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1040,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;
|
||||
}
|
||||
|
||||
@@ -1052,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);
|
||||
}
|
||||
@@ -1065,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)
|
||||
|
||||
+7
-5
@@ -1281,8 +1281,8 @@ 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) {
|
||||
@@ -1686,7 +1686,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);
|
||||
@@ -1713,8 +1713,10 @@ 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}};
|
||||
PROBE_FAMILY("Unicore Family", "$PDTINFO", unicore, 500);
|
||||
currentDelay = 20;
|
||||
currentStep = 2;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#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
|
||||
@@ -30,14 +31,16 @@ uint32_t gpsHardsleepThresholdMs(uint32_t predictedSearchSecs)
|
||||
// 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
|
||||
@@ -49,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);
|
||||
@@ -59,6 +63,7 @@ void GPSUpdateScheduling::informSearchFailed()
|
||||
// When re-enabling GPS with user button.
|
||||
void GPSUpdateScheduling::reset()
|
||||
{
|
||||
searching = false;
|
||||
searchStartedMs = 0;
|
||||
searchEndedMs = 0;
|
||||
searchCount = 0;
|
||||
@@ -70,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);
|
||||
@@ -105,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?
|
||||
|
||||
@@ -25,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;
|
||||
|
||||
@@ -521,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; }
|
||||
|
||||
+14
-1
@@ -140,6 +140,9 @@ RTCSetResult readFromRTC()
|
||||
RTCQuality oldQuality = currentQuality;
|
||||
timeStartMs64 = now;
|
||||
zeroOffsetSecs = tv.tv_sec;
|
||||
#if defined(ARCH_ESP32) || defined(ARCH_RP2040)
|
||||
settimeofday(&tv, NULL);
|
||||
#endif
|
||||
currentQuality = RTCQualityDevice;
|
||||
onTimeSourceQualityChanged(oldQuality, currentQuality);
|
||||
}
|
||||
@@ -186,6 +189,9 @@ RTCSetResult readFromRTC()
|
||||
RTCQuality oldQuality = currentQuality;
|
||||
timeStartMs64 = now;
|
||||
zeroOffsetSecs = tv.tv_sec;
|
||||
#if defined(ARCH_ESP32) || defined(ARCH_RP2040)
|
||||
settimeofday(&tv, NULL);
|
||||
#endif
|
||||
currentQuality = RTCQualityDevice;
|
||||
onTimeSourceQualityChanged(oldQuality, currentQuality);
|
||||
}
|
||||
@@ -222,6 +228,9 @@ RTCSetResult readFromRTC()
|
||||
RTCQuality oldQuality = currentQuality;
|
||||
timeStartMs64 = now;
|
||||
zeroOffsetSecs = tv.tv_sec;
|
||||
#if defined(ARCH_ESP32) || defined(ARCH_RP2040)
|
||||
settimeofday(&tv, NULL);
|
||||
#endif
|
||||
currentQuality = RTCQualityDevice;
|
||||
onTimeSourceQualityChanged(oldQuality, currentQuality);
|
||||
}
|
||||
@@ -389,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
|
||||
|
||||
|
||||
@@ -161,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);
|
||||
@@ -178,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);
|
||||
@@ -189,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);
|
||||
@@ -199,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);
|
||||
@@ -236,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();
|
||||
@@ -253,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);
|
||||
@@ -280,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();
|
||||
|
||||
@@ -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;
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
+183
-22
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
}
|
||||
const uint8_t lines_updated = 1;
|
||||
#endif
|
||||
|
||||
#if defined(HACKADAY_COMMUNICATOR)
|
||||
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,7 +1638,7 @@ void TFTDisplay::sendCommand(uint8_t com)
|
||||
// handle display on/off directly
|
||||
switch (com) {
|
||||
case DISPLAYON: {
|
||||
// LOG_DEBUG("Display on");
|
||||
LOG_DEBUG("Display on");
|
||||
backlightEnable->set(true);
|
||||
#if ARCH_PORTDUINO
|
||||
display(true);
|
||||
@@ -1509,7 +1666,7 @@ void TFTDisplay::sendCommand(uint8_t com)
|
||||
break;
|
||||
}
|
||||
case DISPLAYOFF: {
|
||||
// LOG_DEBUG("Display off");
|
||||
LOG_DEBUG("Display off");
|
||||
backlightEnable->set(false);
|
||||
#if ARCH_PORTDUINO
|
||||
tft->clear();
|
||||
@@ -1616,8 +1773,8 @@ bool TFTDisplay::connect()
|
||||
#endif
|
||||
}
|
||||
|
||||
backlightEnable->set(true);
|
||||
LOG_INFO("Power to TFT Backlight");
|
||||
backlightEnable->set(true);
|
||||
|
||||
#ifdef UNPHONE
|
||||
unphone.backlight(true); // using unPhone library
|
||||
@@ -1645,7 +1802,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 +1810,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,27 +148,31 @@ void menuHandler::loraMenu()
|
||||
"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 = 5
|
||||
LoraFemLna = 6
|
||||
#endif
|
||||
};
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "LoRa Actions";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
#if HAS_LORA_FEM
|
||||
bannerOptions.optionsCount = loraFEMInterface.isLnaCanControl() ? 6 : 5;
|
||||
bannerOptions.optionsCount = loraFEMInterface.isLnaCanControl() ? 7 : 6;
|
||||
#else
|
||||
bannerOptions.optionsCount = 5;
|
||||
bannerOptions.optionsCount = 6;
|
||||
#endif
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
if (selected == Back) {
|
||||
@@ -181,6 +185,8 @@ 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) {
|
||||
@@ -239,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();
|
||||
@@ -571,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"};
|
||||
@@ -2943,6 +2975,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||
case RadioPresetPicker:
|
||||
radioPresetPicker();
|
||||
break;
|
||||
case TXEnabledMenu:
|
||||
txEnabledMenu();
|
||||
break;
|
||||
case FrequencySlot:
|
||||
FrequencySlotPicker();
|
||||
break;
|
||||
|
||||
@@ -13,6 +13,7 @@ class menuHandler
|
||||
LoraPicker,
|
||||
DeviceRolePicker,
|
||||
RadioPresetPicker,
|
||||
TXEnabledMenu,
|
||||
FrequencySlot,
|
||||
NoTimeoutLoraPicker,
|
||||
TzPicker,
|
||||
@@ -73,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+18
-11
@@ -15,8 +15,6 @@
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_PORTDUINO) || !defined(HAS_FREE_RTOS)
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <thread>
|
||||
#endif
|
||||
|
||||
@@ -275,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) {
|
||||
@@ -338,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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "ExpressLRSFiveWay.h"
|
||||
#include "Throttle.h"
|
||||
#include "UptimeClock.h"
|
||||
|
||||
#ifdef INPUTBROKER_EXPRESSLRSFIVEWAY_TYPE
|
||||
|
||||
@@ -79,7 +80,7 @@ void ExpressLRSFiveWay::update(int *keyValue, bool *keyLongPressed)
|
||||
if (keyInProcess == NO_PRESS) {
|
||||
// New key down
|
||||
if (newKey != NO_PRESS) {
|
||||
keyDownStart = millis();
|
||||
keyDownStart = Time::getMillis();
|
||||
// DBGLN("down=%u", newKey);
|
||||
}
|
||||
} else {
|
||||
@@ -114,11 +115,10 @@ void ExpressLRSFiveWay::update(int *keyValue, bool *keyLongPressed)
|
||||
// Meshtastic: runs at regular intervals
|
||||
int32_t ExpressLRSFiveWay::runOnce()
|
||||
{
|
||||
uint32_t now = millis();
|
||||
|
||||
// Dismiss any alert frames after 2 seconds
|
||||
// Feedback for GPS toggle / adhoc ping
|
||||
if (alerting && now > alertingSinceMs + 2000) {
|
||||
// `alerting` is the armed flag, so alertingSinceMs never reaches the comparison unarmed.
|
||||
if (alerting && Throttle::hasElapsed(alertingSinceMs, 2000)) {
|
||||
alerting = false;
|
||||
screen->endAlert();
|
||||
}
|
||||
@@ -131,8 +131,9 @@ int32_t ExpressLRSFiveWay::runOnce()
|
||||
// Do something about this key press
|
||||
determineAction((KeyType)keyValue, longPressed ? LONG : SHORT);
|
||||
|
||||
// If there has been recent key activity, poll the joystick slightly more frequently
|
||||
if (now < keyDownStart + (20 * 1000UL)) // Within last 20 seconds
|
||||
// If there has been recent key activity, poll the joystick slightly more frequently. keyDownStart
|
||||
// is 0 until the first press of a boot, which is no activity rather than activity at time zero.
|
||||
if (keyDownStart != 0 && Throttle::isWithinTimespanMs(keyDownStart, 20 * 1000UL)) // Within last 20 seconds
|
||||
return 100;
|
||||
|
||||
// Otherwise, poll slightly less often
|
||||
@@ -203,7 +204,7 @@ void ExpressLRSFiveWay::toggleGPS()
|
||||
gps->toggleGpsMode();
|
||||
screen->startAlert("GPS Toggled");
|
||||
alerting = true;
|
||||
alertingSinceMs = millis();
|
||||
alertingSinceMs = Time::getMillis();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -226,7 +227,7 @@ void ExpressLRSFiveWay::sendAdhocPing()
|
||||
});
|
||||
|
||||
alerting = true;
|
||||
alertingSinceMs = millis();
|
||||
alertingSinceMs = Time::getMillis();
|
||||
}
|
||||
|
||||
// Shutdown the node (enter deep-sleep)
|
||||
|
||||
@@ -382,7 +382,7 @@ void InputBroker::Init()
|
||||
userConfig.singlePress = INPUT_BROKER_SEND_PING;
|
||||
userConfig.longPress = INPUT_BROKER_SHUTDOWN;
|
||||
userConfig.longPressTime = 5000;
|
||||
userConfig.doublePress = INPUT_BROKER_GPS_TOGGLE;
|
||||
userConfig.doublePress = INPUT_BROKER_PRIVACY_TOGGLE;
|
||||
UserButtonThread->initButton(userConfig);
|
||||
}
|
||||
#else
|
||||
|
||||
@@ -28,6 +28,7 @@ enum input_broker_event {
|
||||
INPUT_BROKER_FACTORY_RST = 0x9a,
|
||||
INPUT_BROKER_SHUTDOWN = 0x9b,
|
||||
INPUT_BROKER_GPS_TOGGLE = 0x9e,
|
||||
INPUT_BROKER_PRIVACY_TOGGLE = 0x9f, // GPS and buzzer off together, and back on together
|
||||
INPUT_BROKER_SEND_PING = 0xaf,
|
||||
INPUT_BROKER_FN_F1 = 0xf1,
|
||||
INPUT_BROKER_FN_F2 = 0xf2,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "STC8HKeyboard.h"
|
||||
|
||||
#if defined(ELECROW_ThinkNode_M9)
|
||||
#include "cardKbI2cImpl.h"
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// STC8H companion-MCU keypad driver (ThinkNode-M9).
|
||||
//
|
||||
// The original STC8HKeyboard.cpp was lost from the reference source tree, so
|
||||
// this was recovered from the linked reference firmware.elf (the .o was an LTO
|
||||
// object with no machine code; the final ELF had the real inlined bodies).
|
||||
//
|
||||
// How the hardware works:
|
||||
// - The STC8H raises KB_INT (rising edge, idle-low) when a key is pressed. The ISR
|
||||
// latches key_event; is_key_event() just returns that flag.
|
||||
// - The pressed key code is read over I2C from register 0x05.
|
||||
// - is_key_state() polls KB_INT directly to keep the backlight lit while a
|
||||
// key is held.
|
||||
// - Battery voltage lives in registers 0x01..0x04, little-endian.
|
||||
// - Sleep is requested by writing 0x01 to the STATE register (0x06).
|
||||
// - The keypad backlight (KB_LED) and torch (PIN_LED) are plain host GPIOs,
|
||||
// not I2C commands.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
STC8HKeyboard Stc8HKeyBoard;
|
||||
|
||||
// ISR latched on each KB_INT rising edge (a key was pressed).
|
||||
static void has_key_event()
|
||||
{
|
||||
Stc8HKeyBoard.key_event = true;
|
||||
if (cardKbI2cImpl) {
|
||||
cardKbI2cImpl->setIntervalFromNow(0);
|
||||
// runASAP = true;
|
||||
BaseType_t higherWake = 0;
|
||||
concurrency::mainDelay.interruptFromISR(&higherWake);
|
||||
}
|
||||
}
|
||||
|
||||
void STC8HKeyboard::writeRegister(uint8_t reg, uint8_t val)
|
||||
{
|
||||
_pWire->beginTransmission(_I2C_addr);
|
||||
_pWire->write(reg);
|
||||
_pWire->write(val);
|
||||
_pWire->endTransmission();
|
||||
}
|
||||
|
||||
uint8_t STC8HKeyboard::readRegister(uint8_t reg)
|
||||
{
|
||||
_pWire->beginTransmission(_I2C_addr);
|
||||
_pWire->write(reg);
|
||||
if (_pWire->endTransmission(false) != 0)
|
||||
return 0xFF;
|
||||
if (_pWire->requestFrom(_I2C_addr, (uint8_t)1) != 1)
|
||||
return 0xFF;
|
||||
return _pWire->read();
|
||||
}
|
||||
|
||||
void STC8HKeyboard::begin(uint8_t addr, TwoWire *wire)
|
||||
{
|
||||
LOG_DEBUG("STC8HKeyboard::begin() addr=0x%02x", addr);
|
||||
_I2C_addr = addr;
|
||||
_pWire = wire;
|
||||
pinMode(KB_INT, INPUT);
|
||||
#ifdef KB_LED
|
||||
pinMode(KB_LED, OUTPUT);
|
||||
#endif
|
||||
#ifdef PIN_LED
|
||||
pinMode(PIN_LED, OUTPUT);
|
||||
#endif
|
||||
attachInterrupt(KB_INT, has_key_event, RISING);
|
||||
_pWire->begin();
|
||||
Keyboard_state = true;
|
||||
#ifdef ARCH_ESP32
|
||||
// Detach/reattach the key interrupt around ESP32 light sleep
|
||||
lsObserver.observe(¬ifyLightSleep);
|
||||
lsEndObserver.observe(¬ifyLightSleepEnd);
|
||||
#endif
|
||||
}
|
||||
|
||||
bool STC8HKeyboard::is_Keyboard_begin()
|
||||
{
|
||||
return Keyboard_state;
|
||||
}
|
||||
|
||||
// A key is currently active (KB_INT held); used to wake the keypad backlight.
|
||||
bool STC8HKeyboard::is_key_state()
|
||||
{
|
||||
return digitalRead(KB_INT);
|
||||
}
|
||||
|
||||
// A key-press interrupt has been latched since the flag was last cleared.
|
||||
bool STC8HKeyboard::is_key_event()
|
||||
{
|
||||
return key_event;
|
||||
}
|
||||
|
||||
uint8_t STC8HKeyboard::bsp_get_key_value()
|
||||
{
|
||||
return readRegister(0x01);
|
||||
}
|
||||
|
||||
// Battery millivolts: registers 0x01..0x04 read little-endian, low 16 bits.
|
||||
uint16_t STC8HKeyboard::bsp_get_battery_voltage()
|
||||
{
|
||||
if (!Keyboard_state)
|
||||
return 0;
|
||||
uint32_t voltage = 0;
|
||||
for (uint8_t i = 0; i < 4; i++)
|
||||
voltage |= (uint32_t)readRegister(STC8_REG_ADDR_BATTERY + i) << (i * 8);
|
||||
return voltage > 0xFFFF ? 0xFFFF : (uint16_t)voltage;
|
||||
}
|
||||
|
||||
void STC8HKeyboard::set_keyboard_blight(bool state)
|
||||
{
|
||||
#ifdef KB_LED
|
||||
digitalWrite(KB_LED, state);
|
||||
#else
|
||||
(void)state; // KB_LED pin not defined for this board
|
||||
#endif
|
||||
}
|
||||
|
||||
void STC8HKeyboard::switch_flashlight()
|
||||
{
|
||||
#ifdef PIN_LED
|
||||
digitalWrite(PIN_LED, !digitalRead(PIN_LED));
|
||||
#endif
|
||||
// else: torch pin unresolved on this board (old board used PIN_LED 13,
|
||||
// which the current variant assigns to BATTERY_PIN) -- see variant.h.
|
||||
}
|
||||
|
||||
void STC8HKeyboard::set_sleep_status(void)
|
||||
{
|
||||
writeRegister(STC8_REG_ADDR_STATE, 0x01);
|
||||
_pWire->end();
|
||||
}
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// Detach the key interrupt before ESP32 light sleep, so it can't fire while asleep.
|
||||
int STC8HKeyboard::beforeLightSleep(void *unused)
|
||||
{
|
||||
detachInterrupt(KB_INT);
|
||||
return 0; // Indicates success
|
||||
}
|
||||
|
||||
// Reattach the key interrupt after waking from light sleep.
|
||||
int STC8HKeyboard::afterLightSleep(esp_sleep_wakeup_cause_t cause)
|
||||
{
|
||||
attachInterrupt(KB_INT, has_key_event, RISING);
|
||||
return 0; // Indicates success
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // ELECROW_ThinkNode_M9
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
#ifndef _STC8H_KEYBOARD_H_
|
||||
#define _STC8H_KEYBOARD_H_
|
||||
|
||||
#include "configuration.h"
|
||||
#include "kbI2cBase.h"
|
||||
#include <Wire.h>
|
||||
#if defined(ELECROW_ThinkNode_M9)
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
#include "sleep.h" // notifyLightSleep / notifyLightSleepEnd + esp_sleep_wakeup_cause_t
|
||||
#endif
|
||||
|
||||
// Registers exposed by the STC8H companion MCU over I2C.
|
||||
#define STC8_REG_ADDR_BATTERY 0x01
|
||||
#define STC8_REG_ADDR_MATRIX_KEY 0x05
|
||||
#define STC8_REG_ADDR_STATE 0x06
|
||||
|
||||
class STC8HKeyboard
|
||||
{
|
||||
public:
|
||||
STC8HKeyboard(){};
|
||||
|
||||
void begin(uint8_t addr, TwoWire *wire);
|
||||
|
||||
void set_sleep_status(void);
|
||||
|
||||
uint16_t bsp_get_battery_voltage();
|
||||
|
||||
bool is_key_event();
|
||||
|
||||
bool is_Keyboard_begin();
|
||||
|
||||
bool is_key_state();
|
||||
|
||||
uint8_t bsp_get_key_value();
|
||||
|
||||
void set_keyboard_blight(bool state);
|
||||
|
||||
void switch_flashlight();
|
||||
|
||||
uint8_t readRegister(uint8_t reg);
|
||||
|
||||
bool key_event;
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// Detach/reattach the KB_INT interrupt around ESP32 light sleep, so the
|
||||
// companion MCU's key interrupt can't fire spuriously while asleep.
|
||||
int beforeLightSleep(void *unused);
|
||||
int afterLightSleep(esp_sleep_wakeup_cause_t cause);
|
||||
#endif
|
||||
|
||||
private:
|
||||
void writeRegister(uint8_t reg, uint8_t val);
|
||||
|
||||
uint8_t _I2C_addr = TSTC8_KB_ADDR;
|
||||
|
||||
TwoWire *_pWire = &Wire;
|
||||
|
||||
bool Keyboard_state = false;
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// Get notified when light sleep begins and ends (mirrors TwoButton / Power)
|
||||
CallbackObserver<STC8HKeyboard, void *> lsObserver =
|
||||
CallbackObserver<STC8HKeyboard, void *>(this, &STC8HKeyboard::beforeLightSleep);
|
||||
CallbackObserver<STC8HKeyboard, esp_sleep_wakeup_cause_t> lsEndObserver =
|
||||
CallbackObserver<STC8HKeyboard, esp_sleep_wakeup_cause_t>(this, &STC8HKeyboard::afterLightSleep);
|
||||
#endif
|
||||
};
|
||||
|
||||
extern STC8HKeyboard Stc8HKeyBoard;
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -52,6 +52,9 @@ class TCA8418KeyboardBase
|
||||
virtual bool hasEvent(void) const;
|
||||
virtual char dequeueEvent(void);
|
||||
|
||||
// Public so owners (KbI2cBase's unique_ptr) can destroy through the base
|
||||
virtual ~TCA8418KeyboardBase() {}
|
||||
|
||||
protected:
|
||||
enum KeyState { Init, Idle, Held, Busy };
|
||||
|
||||
@@ -132,8 +135,6 @@ class TCA8418KeyboardBase
|
||||
|
||||
virtual void queueEvent(char);
|
||||
|
||||
virtual ~TCA8418KeyboardBase() {}
|
||||
|
||||
protected:
|
||||
// Set the size of the keypad matrix
|
||||
// All other rows and columns are set as inputs.
|
||||
|
||||
@@ -192,7 +192,7 @@ int32_t TouchScreenBase::runOnce()
|
||||
|
||||
void TouchScreenBase::hapticFeedback()
|
||||
{
|
||||
#ifdef T_WATCH_S3
|
||||
#if defined(T_WATCH_S3) || defined(T_WATCH_ULTRA)
|
||||
drv.setWaveform(0, 75);
|
||||
drv.setWaveform(1, 0); // end waveform
|
||||
drv.go();
|
||||
|
||||
@@ -51,6 +51,10 @@ void CardKbI2cImpl::init()
|
||||
// assign an arbitrary value to distinguish from other models
|
||||
kb_model = 0x84;
|
||||
break;
|
||||
case ScanI2C::DeviceType::STC8HKB:
|
||||
// assign an arbitrary value to distinguish from other models
|
||||
kb_model = 0x12;
|
||||
break;
|
||||
default:
|
||||
// use this as default since it's also just zero
|
||||
LOG_WARN("kb_info.type is unknown(0x%02x), setting kb_model=0x00", kb_info.type);
|
||||
|
||||
+129
-13
@@ -15,26 +15,34 @@
|
||||
#include "TCA8418Keyboard.h"
|
||||
#endif
|
||||
|
||||
#if defined(ELECROW_ThinkNode_M9)
|
||||
#include "STC8HKeyboard.h"
|
||||
#include "graphics/Screen.h" // for the global `screen` + FrameFocus
|
||||
#include "graphics/draw/NotificationRenderer.h" // for resetBanner()
|
||||
#endif
|
||||
|
||||
extern ScanI2C::DeviceAddress cardkb_found;
|
||||
extern uint8_t kb_model;
|
||||
|
||||
KbI2cBase::KbI2cBase(const char *name)
|
||||
: concurrency::OSThread(name),
|
||||
#if defined(T_DECK_PRO)
|
||||
TCAKeyboard(*(new TDeckProKeyboard()))
|
||||
TCAKeyboard(new TDeckProKeyboard())
|
||||
#elif defined(T_LORA_PAGER)
|
||||
TCAKeyboard(*(new TLoraPagerKeyboard()))
|
||||
TCAKeyboard(new TLoraPagerKeyboard())
|
||||
#elif defined(M5STACK_CARDPUTER_ADV)
|
||||
TCAKeyboard(*(new CardputerKeyboard()))
|
||||
TCAKeyboard(new CardputerKeyboard())
|
||||
#elif defined(HACKADAY_COMMUNICATOR)
|
||||
TCAKeyboard(*(new HackadayCommunicatorKeyboard()))
|
||||
TCAKeyboard(new HackadayCommunicatorKeyboard())
|
||||
#else
|
||||
TCAKeyboard(*(new TCA8418Keyboard()))
|
||||
TCAKeyboard(new TCA8418Keyboard())
|
||||
#endif
|
||||
{
|
||||
this->_originName = name;
|
||||
}
|
||||
|
||||
KbI2cBase::~KbI2cBase() = default;
|
||||
|
||||
uint8_t read_from_14004(TwoWire *i2cBus, uint8_t reg, uint8_t *data, uint8_t length)
|
||||
{
|
||||
uint8_t readflag = 0;
|
||||
@@ -62,6 +70,11 @@ int32_t KbI2cBase::runOnce()
|
||||
// resolved via the scanner: WIRE1 may be a bridged bus rather
|
||||
// than the local Wire1 (e.g. SenseCAP Indicator)
|
||||
i2cBus = ScanI2CTwoWire::fetchI2CBus(cardkb_found);
|
||||
#if defined(ELECROW_ThinkNode_M9)
|
||||
if (cardkb_found.address == TSTC8_KB_ADDR) {
|
||||
Stc8HKeyBoard.begin(TSTC8_KB_ADDR, &Wire1);
|
||||
}
|
||||
#endif
|
||||
if (cardkb_found.address == BBQ10_KB_ADDR) {
|
||||
Q10keyboard.begin(BBQ10_KB_ADDR, i2cBus);
|
||||
Q10keyboard.setBacklight(0);
|
||||
@@ -70,13 +83,18 @@ int32_t KbI2cBase::runOnce()
|
||||
MPRkeyboard.begin(MPR121_KB_ADDR, i2cBus);
|
||||
}
|
||||
if (cardkb_found.address == TCA8418_KB_ADDR) {
|
||||
TCAKeyboard.begin(TCA8418_KB_ADDR, i2cBus);
|
||||
TCAKeyboard->begin(TCA8418_KB_ADDR, i2cBus);
|
||||
}
|
||||
break;
|
||||
#endif
|
||||
case ScanI2C::WIRE:
|
||||
LOG_DEBUG("Use I2C Bus 0 (the first one)");
|
||||
i2cBus = &Wire;
|
||||
#if defined(ELECROW_ThinkNode_M9)
|
||||
if (cardkb_found.address == TSTC8_KB_ADDR) {
|
||||
Stc8HKeyBoard.begin(TSTC8_KB_ADDR, &Wire);
|
||||
}
|
||||
#endif
|
||||
if (cardkb_found.address == BBQ10_KB_ADDR) {
|
||||
Q10keyboard.begin(BBQ10_KB_ADDR, &Wire);
|
||||
Q10keyboard.setBacklight(0);
|
||||
@@ -85,7 +103,7 @@ int32_t KbI2cBase::runOnce()
|
||||
MPRkeyboard.begin(MPR121_KB_ADDR, &Wire);
|
||||
}
|
||||
if (cardkb_found.address == TCA8418_KB_ADDR) {
|
||||
TCAKeyboard.begin(TCA8418_KB_ADDR, &Wire);
|
||||
TCAKeyboard->begin(TCA8418_KB_ADDR, &Wire);
|
||||
}
|
||||
break;
|
||||
case ScanI2C::NO_I2C:
|
||||
@@ -259,10 +277,10 @@ int32_t KbI2cBase::runOnce()
|
||||
break;
|
||||
}
|
||||
case 0x84: { // Adafruit TCA8418
|
||||
TCAKeyboard.trigger();
|
||||
TCAKeyboard->trigger();
|
||||
InputEvent e = {};
|
||||
while (TCAKeyboard.hasEvent()) {
|
||||
char nextEvent = TCAKeyboard.dequeueEvent();
|
||||
while (TCAKeyboard->hasEvent()) {
|
||||
char nextEvent = TCAKeyboard->dequeueEvent();
|
||||
e.inputEvent = INPUT_BROKER_ANYKEY;
|
||||
e.kbchar = 0x00;
|
||||
e.source = this->_originName;
|
||||
@@ -361,9 +379,9 @@ int32_t KbI2cBase::runOnce()
|
||||
// LOG_DEBUG("TCA8418 Notifying: %i Char: %c", e.inputEvent, e.kbchar);
|
||||
this->notifyObservers(&e);
|
||||
}
|
||||
TCAKeyboard.trigger();
|
||||
TCAKeyboard->trigger();
|
||||
}
|
||||
TCAKeyboard.clearInt();
|
||||
TCAKeyboard->clearInt();
|
||||
break;
|
||||
}
|
||||
case 0x02: {
|
||||
@@ -544,6 +562,104 @@ int32_t KbI2cBase::runOnce()
|
||||
}
|
||||
break;
|
||||
}
|
||||
#if defined(ELECROW_ThinkNode_M9)
|
||||
case 0x12: { // STC8H companion-MCU keypad (ThinkNode-M9)
|
||||
Stc8HKeyBoard.key_event = false;
|
||||
InputEvent e = {};
|
||||
e.inputEvent = INPUT_BROKER_NONE;
|
||||
e.source = this->_originName;
|
||||
uint8_t c = Stc8HKeyBoard.bsp_get_key_value(); // unsigned so the 0x8x/0xbx codes match
|
||||
switch (c) {
|
||||
case 0x81: // Mute
|
||||
e.inputEvent = INPUT_BROKER_ANYKEY;
|
||||
e.kbchar = INPUT_BROKER_MSG_MUTE_TOGGLE;
|
||||
break;
|
||||
case 0x82: // Home
|
||||
e.inputEvent = INPUT_BROKER_ANYKEY;
|
||||
graphics::NotificationRenderer::resetBanner();
|
||||
// TODO(M9): also reset CannedMessage/PresetMessage state once those modules are ported
|
||||
if (screen)
|
||||
screen->setFrames(graphics::Screen::FOCUS_FAULT);
|
||||
break;
|
||||
case 0x83: // Time
|
||||
e.inputEvent = INPUT_BROKER_ANYKEY;
|
||||
graphics::NotificationRenderer::resetBanner();
|
||||
// TODO(M9): also reset CannedMessage/PresetMessage state once those modules are ported
|
||||
if (screen)
|
||||
screen->setFrames(graphics::Screen::FOCUS_CLOCK);
|
||||
break;
|
||||
case 0x84:
|
||||
e.inputEvent = INPUT_BROKER_GPS_TOGGLE;
|
||||
Stc8HKeyBoard.switch_flashlight();
|
||||
break;
|
||||
case 0x85: // FM
|
||||
e.inputEvent = INPUT_BROKER_SEND_PING;
|
||||
e.kbchar = 0;
|
||||
break;
|
||||
case 0x86: // FM (long press)
|
||||
e.inputEvent = INPUT_BROKER_CANCEL;
|
||||
e.kbchar = 0;
|
||||
break;
|
||||
case 0x87: // Preset
|
||||
graphics::NotificationRenderer::resetBanner();
|
||||
// TODO(M9): also reset CannedMessage state once that module is ported
|
||||
e.inputEvent = INPUT_BROKER_SELECT_LONG;
|
||||
e.kbchar = 0;
|
||||
break;
|
||||
case 0xb5: // Up
|
||||
e.inputEvent = INPUT_BROKER_UP;
|
||||
e.kbchar = 0;
|
||||
break;
|
||||
case 0xb4: // Left
|
||||
e.inputEvent = INPUT_BROKER_LEFT;
|
||||
e.kbchar = 0;
|
||||
break;
|
||||
case 0xb6: // Down
|
||||
e.inputEvent = INPUT_BROKER_DOWN;
|
||||
e.kbchar = 0;
|
||||
break;
|
||||
case 0xb7: // Right
|
||||
e.inputEvent = INPUT_BROKER_RIGHT;
|
||||
e.kbchar = 0;
|
||||
break;
|
||||
case 0x20: // Space
|
||||
e.inputEvent = INPUT_BROKER_ANYKEY;
|
||||
e.kbchar = 0x20;
|
||||
break;
|
||||
case 0x0d: // Enter
|
||||
e.inputEvent = INPUT_BROKER_SELECT;
|
||||
e.kbchar = 0;
|
||||
break;
|
||||
case 0x08: // Del
|
||||
e.inputEvent = INPUT_BROKER_BACK;
|
||||
e.kbchar = 0;
|
||||
break;
|
||||
case 0x89: // Del (long press)
|
||||
e.inputEvent = INPUT_BROKER_BACK;
|
||||
e.kbchar = 0;
|
||||
break;
|
||||
case 0x88: // Invalid key value
|
||||
e.inputEvent = INPUT_BROKER_ANYKEY;
|
||||
e.kbchar = 0;
|
||||
break;
|
||||
default: // all other keys (printable ASCII)
|
||||
if ((c >= 0x20) && (c <= 0x7F)) {
|
||||
e.inputEvent = INPUT_BROKER_ANYKEY;
|
||||
e.kbchar = c;
|
||||
} else {
|
||||
e.inputEvent = INPUT_BROKER_NONE;
|
||||
e.kbchar = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (e.inputEvent != INPUT_BROKER_NONE) {
|
||||
// LOG_DEBUG("STC8H companion-MCU keypad key event: 0x%02x", c);
|
||||
this->notifyObservers(&e);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
LOG_WARN("Unknown kb_model 0x%02x", kb_model);
|
||||
}
|
||||
@@ -553,6 +669,6 @@ int32_t KbI2cBase::runOnce()
|
||||
void KbI2cBase::toggleBacklight(bool on)
|
||||
{
|
||||
#if defined(T_LORA_PAGER)
|
||||
TCAKeyboard.setBacklight(on);
|
||||
TCAKeyboard->setBacklight(on);
|
||||
#endif
|
||||
}
|
||||
@@ -6,12 +6,17 @@
|
||||
#include "Wire.h"
|
||||
#include "concurrency/OSThread.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
class TCA8418KeyboardBase;
|
||||
|
||||
class KbI2cBase : public Observable<const InputEvent *>, public concurrency::OSThread
|
||||
{
|
||||
public:
|
||||
explicit KbI2cBase(const char *name);
|
||||
// Out-of-line: TCA8418KeyboardBase is only forward-declared here, so the unique_ptr
|
||||
// deleter must be instantiated in the .cpp where the type is complete
|
||||
~KbI2cBase();
|
||||
void toggleBacklight(bool on);
|
||||
|
||||
protected:
|
||||
@@ -24,6 +29,6 @@ class KbI2cBase : public Observable<const InputEvent *>, public concurrency::OST
|
||||
|
||||
BBQ10Keyboard Q10keyboard;
|
||||
MPR121Keyboard MPRkeyboard;
|
||||
TCA8418KeyboardBase &TCAKeyboard;
|
||||
std::unique_ptr<TCA8418KeyboardBase> TCAKeyboard;
|
||||
bool is_sym = false;
|
||||
};
|
||||
+15
-5
@@ -322,6 +322,8 @@ __attribute__((weak, noinline)) bool loopCanSleep()
|
||||
__attribute__((noinline)) void lateInitVariant() __attribute__((weak));
|
||||
__attribute__((noinline)) void lateInitVariant() {}
|
||||
|
||||
// earlyInitVariant() runs before consoleInit(): a LOG_* macro here CRASHES the device,
|
||||
// it is not a silent no-op. Defer any logging to lateInitVariant() or later.
|
||||
__attribute__((noinline)) void earlyInitVariant() __attribute__((weak));
|
||||
__attribute__((noinline)) void earlyInitVariant() {}
|
||||
|
||||
@@ -393,6 +395,11 @@ void setup()
|
||||
digitalWrite(LED_NOTIFICATION, HIGH ^ LED_STATE_ON);
|
||||
#endif
|
||||
|
||||
#ifdef LED_LORA
|
||||
pinMode(LED_LORA, OUTPUT);
|
||||
digitalWrite(LED_LORA, HIGH ^ LED_STATE_ON);
|
||||
#endif
|
||||
|
||||
#ifdef WIFI_LED
|
||||
pinMode(WIFI_LED, OUTPUT);
|
||||
digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON);
|
||||
@@ -756,6 +763,10 @@ void setup()
|
||||
// assign an arbitrary value to distinguish from other models
|
||||
kb_model = 0x84;
|
||||
break;
|
||||
case ScanI2C::DeviceType::STC8HKB:
|
||||
// assign an arbitrary value to distinguish from other models
|
||||
kb_model = 0x12;
|
||||
break;
|
||||
default:
|
||||
// use this as default since it's also just zero
|
||||
LOG_WARN("kb_info.type unknown(0x%02x), set kb_model=0x00", kb_info.type);
|
||||
@@ -1493,14 +1504,13 @@ void loop()
|
||||
LOG_ERROR("LoRa error detected, recovering");
|
||||
router->addInterface(nullptr);
|
||||
if (portduino_config.lora_spi_dev == "ch341") {
|
||||
if (ch341Hal != nullptr) {
|
||||
delete ch341Hal;
|
||||
ch341Hal = nullptr;
|
||||
if (ch341Hal) {
|
||||
ch341Hal.reset();
|
||||
sleep(3);
|
||||
}
|
||||
try {
|
||||
ch341Hal = new Ch341Hal(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid,
|
||||
portduino_config.lora_usb_pid);
|
||||
ch341Hal = std::make_unique<Ch341Hal>(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid,
|
||||
portduino_config.lora_usb_pid);
|
||||
} catch (std::exception &e) {
|
||||
std::cerr << e.what() << std::endl;
|
||||
std::cerr << "Could not initialize CH341 device!" << std::endl;
|
||||
|
||||
@@ -403,11 +403,20 @@ void CryptoEngine::decrypt(uint32_t fromNode, uint64_t packetId, size_t numBytes
|
||||
// Generic implementation of AES-CTR encryption.
|
||||
void CryptoEngine::encryptAESCtr(CryptoKey _key, uint8_t *_nonce, size_t numBytes, uint8_t *bytes)
|
||||
{
|
||||
std::unique_ptr<CTRCommon> ctr;
|
||||
if (_key.length == 16)
|
||||
ctr = std::unique_ptr<CTRCommon>(new CTR<AES128>());
|
||||
else
|
||||
ctr = std::unique_ptr<CTRCommon>(new CTR<AES256>());
|
||||
// Reused instead of reallocated per packet: safe because all callers hold cryptLock and setKey/setIV reset the
|
||||
// full cipher state. Lazy so overriding platforms reserve nothing; key material now lives until the next call.
|
||||
static CTR<AES128> *ctr128 = nullptr;
|
||||
static CTR<AES256> *ctr256 = nullptr;
|
||||
CTRCommon *ctr;
|
||||
if (_key.length == 16) {
|
||||
if (!ctr128)
|
||||
ctr128 = new CTR<AES128>();
|
||||
ctr = ctr128;
|
||||
} else {
|
||||
if (!ctr256)
|
||||
ctr256 = new CTR<AES256>();
|
||||
ctr = ctr256;
|
||||
}
|
||||
ctr->setKey(_key.bytes, _key.length);
|
||||
static uint8_t scratch[MAX_BLOCKSIZE];
|
||||
memcpy(scratch, bytes, numBytes);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "MeshPacketQueue.h"
|
||||
#include "NodeDB.h"
|
||||
#include "Throttle.h"
|
||||
#include "UptimeClock.h"
|
||||
#include "configuration.h"
|
||||
#include <assert.h>
|
||||
|
||||
@@ -186,9 +188,14 @@ bool MeshPacketQueue::replaceLowerPriorityPacket(meshtastic_MeshPacket *p)
|
||||
|
||||
if (backPacket->tx_after) {
|
||||
// Check if there's a late packet at the queue end
|
||||
auto now = millis();
|
||||
if (backPacket->tx_after < now && (!p->tx_after || backPacket->tx_after > p->tx_after)) {
|
||||
int32_t dt = (int32_t)(backPacket->tx_after - now);
|
||||
const uint32_t now = Time::getMillis();
|
||||
// Elapsed times only order two deadlines that have both passed: a future one subtracts to a
|
||||
// near-2^32 elapsed and would read as the most overdue packet in the queue.
|
||||
const uint32_t backElapsed = now - backPacket->tx_after;
|
||||
const bool newGoesFirst =
|
||||
!p->tx_after || (Throttle::deadlinePassedAt(now, p->tx_after) && backElapsed < (uint32_t)(now - p->tx_after));
|
||||
if (Throttle::deadlinePassedAt(now, backPacket->tx_after) && newGoesFirst) {
|
||||
int32_t dt = -(int32_t)backElapsed;
|
||||
if (p->tx_after) {
|
||||
LOG_WARN("Dropping late packet 0x%08x with TX delay %dms to make room in the TX queue for packet 0x%08x with "
|
||||
"TX delay %ums",
|
||||
|
||||
@@ -39,6 +39,11 @@ struct RegionProfile {
|
||||
*/
|
||||
extern float getEffectiveDutyCycle();
|
||||
|
||||
// True if `preset` appears in at least one region's preset list, i.e. it is a real preset
|
||||
// some region offers rather than a fabricated or long-retired enum value. Defined in
|
||||
// RadioInterface.cpp, where the region table lives.
|
||||
extern bool isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset preset);
|
||||
|
||||
extern const RegionProfile PROFILE_STD;
|
||||
extern const RegionProfile PROFILE_EU868;
|
||||
extern const RegionProfile PROFILE_UNDEF;
|
||||
@@ -71,6 +76,14 @@ struct RegionInfo {
|
||||
if (profile->presets[i] == preset)
|
||||
return true;
|
||||
}
|
||||
// UNSET is "no region chosen yet", not a regulatory domain: the radio is held silent
|
||||
// either way (see the region==UNSET gates in RadioLibInterface::send/handleReceive),
|
||||
// so there is nothing here to enforce. Rejecting would instead destroy a preset the
|
||||
// user already picked - the clamp rewrites it to LONG_FAST, and that clamp runs on
|
||||
// every boot and on every set_config while the region is unset. Accept any preset a
|
||||
// real region offers; fabricated values still fail and are clamped as before.
|
||||
if (code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
|
||||
return isKnownModemPreset(preset);
|
||||
return false;
|
||||
}
|
||||
size_t getNumPresets() const
|
||||
|
||||
+24
-21
@@ -114,6 +114,14 @@ int MeshService::handleFromRadio(const meshtastic_MeshPacket *mp)
|
||||
}
|
||||
}
|
||||
|
||||
// Our own packet heard back off the mesh, which the duplicate cache only suppresses best-effort.
|
||||
// Clients can't tell an echo from genuine ingress, so it surfaces as an incoming message. Packets
|
||||
// addressed to us are locally-generated feedback (implicit ACK, NAK, routing error), not an echo.
|
||||
if (isFromUs(mp) && !isToUs(mp)) {
|
||||
LOG_DEBUG("Skip phone echo of our own packet 0x%08x", mp->id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
printPacket("Forwarding to phone", mp);
|
||||
if (auto *toPhone = packetPool.allocCopy(*mp))
|
||||
sendToPhone(toPhone);
|
||||
@@ -353,6 +361,8 @@ ErrorCode MeshService::sendQueueStatusToPhone(const meshtastic_QueueStatus &qs,
|
||||
lastQueueStatus = *copied;
|
||||
|
||||
res = toPhoneQueueStatusQueue.enqueue(copied, 0);
|
||||
if (!res)
|
||||
releaseQueueStatusToPool(copied);
|
||||
fromNum++;
|
||||
|
||||
return res ? ERRNO_OK : ERRNO_UNKNOWN;
|
||||
@@ -409,27 +419,17 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies)
|
||||
LOG_DEBUG("Skip position ping; no fresh position since boot");
|
||||
return false;
|
||||
}
|
||||
// Prefer the node's current channel, but fall back to the first channel with
|
||||
// position enabled (matching PositionModule::sendOurPosition() behavior).
|
||||
// Prefer the node's current channel, but fall back to the position channel
|
||||
// (matching PositionModule::sendOurPosition() behavior).
|
||||
uint8_t sendChan = node->channel;
|
||||
if (getPositionPrecisionForChannel(sendChan) == 0) {
|
||||
bool found = false;
|
||||
for (uint8_t ch = 0; ch < 8; ++ch) {
|
||||
if (getPositionPrecisionForChannel(ch) != 0) {
|
||||
sendChan = ch;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// No channel with position enabled: fall back to sending nodeinfo, as before.
|
||||
if (nodeInfoModule) {
|
||||
LOG_INFO("No position-enabled channel; send nodeinfo instead to 0x%08x, wantReplies=%d, channel=%d", dest,
|
||||
wantReplies, node->channel);
|
||||
nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel);
|
||||
}
|
||||
return false;
|
||||
if (getPositionPrecisionForChannel(sendChan) == 0 && !findPositionChannel(sendChan)) {
|
||||
// No channel with position enabled: fall back to sending nodeinfo, as before.
|
||||
if (nodeInfoModule) {
|
||||
LOG_INFO("No position-enabled channel; send nodeinfo instead to 0x%08x, wantReplies=%d, channel=%d", dest,
|
||||
wantReplies, node->channel);
|
||||
nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, sendChan);
|
||||
positionModule->sendOurPosition(dest, wantReplies, sendChan);
|
||||
@@ -490,8 +490,11 @@ void MeshService::sendToPhone(meshtastic_MeshPacket *p)
|
||||
#endif
|
||||
|
||||
if (toPhoneQueue.numFree() == 0) {
|
||||
if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP ||
|
||||
p->decoded.portnum == meshtastic_PortNum_RANGE_TEST_APP) {
|
||||
// ROUTING_APP is the phone's only delivery confirmation, so it displaces the oldest like
|
||||
// text does. Gate the variant: decoded.portnum aliases encrypted.size in the union.
|
||||
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
(p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP ||
|
||||
p->decoded.portnum == meshtastic_PortNum_RANGE_TEST_APP || p->decoded.portnum == meshtastic_PortNum_ROUTING_APP)) {
|
||||
LOG_WARN("ToPhone queue full, discard oldest");
|
||||
meshtastic_MeshPacket *d = toPhoneQueue.dequeuePtr(0);
|
||||
if (d)
|
||||
|
||||
@@ -222,6 +222,9 @@ class MeshService
|
||||
/// needs to keep the packet around it makes a copy
|
||||
int handleFromRadio(const meshtastic_MeshPacket *p);
|
||||
friend class RoutingModule;
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
friend class MeshServicePhoneDeliveryTest;
|
||||
#endif
|
||||
};
|
||||
|
||||
extern MeshService *service;
|
||||
@@ -37,6 +37,18 @@ bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p)
|
||||
(p->next_hop != NO_NEXT_HOP_PREFERENCE && p->next_hop != nodeDB->getLastByteOfNodeNum(getNodeNum())))
|
||||
return false;
|
||||
|
||||
// Dedup opaque relays. Opaque frames deliberately never enter PacketHistory (so unauthenticated
|
||||
// traffic can't influence routing/ACK/next-hop) - but with NO dedup at all, a dense mesh re-relays
|
||||
// every copy of every frame, multiplying at each hop into an unbounded broadcast storm ("let hop
|
||||
// exhaustion bound it" caps depth, not count). Suppress duplicate opaque rebroadcasts with a small,
|
||||
// routing-isolated seen-set. Genuine originator (re)transmissions (hop_start == hop_limit) are
|
||||
// always relayed so reliable opaque unicast still propagates (mirrors FloodingRouter's isRepeated).
|
||||
const bool isOriginatorTx = p->hop_start > 0 && p->hop_start == p->hop_limit;
|
||||
if (opaqueWasSeenRecently(getFrom(p), p->id) && !isOriginatorTx) {
|
||||
LOG_TRACE("Drop duplicate opaque relay from 0x%08x id 0x%08x", getFrom(p), p->id);
|
||||
return false;
|
||||
}
|
||||
|
||||
meshtastic_MeshPacket *relay = packetPool.allocCopy(*p);
|
||||
if (!relay)
|
||||
return false;
|
||||
@@ -53,16 +65,40 @@ bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p)
|
||||
return res == ERRNO_OK;
|
||||
}
|
||||
|
||||
// Isolated dedup for opaque relays (see relayOpaquePacket). Returns true if (from,id) is already in the
|
||||
// ring; otherwise records it (round-robin eviction) and returns false. A separate table from
|
||||
// PacketHistory on purpose: opaque frames must never influence routing/ACK/next-hop. No timestamps -
|
||||
// a stale (from,id) can't false-match a later packet because ids are effectively random.
|
||||
bool NextHopRouter::opaqueWasSeenRecently(NodeNum from, PacketId id)
|
||||
{
|
||||
for (uint8_t i = 0; i < OPAQUE_SEEN_MAX; i++) {
|
||||
if (opaqueSeen[i].sender == from && opaqueSeen[i].id == id)
|
||||
return true;
|
||||
}
|
||||
// Not seen: record it, overwriting the oldest-written slot (FIFO). Empty slots hold id 0, which a
|
||||
// real entry never has (relayOpaquePacket drops id 0), so they simply never match above.
|
||||
opaqueSeen[opaqueSeenNext].sender = from;
|
||||
opaqueSeen[opaqueSeenNext].id = id;
|
||||
opaqueSeenNext = (uint8_t)((opaqueSeenNext + 1) % OPAQUE_SEEN_MAX);
|
||||
return false;
|
||||
}
|
||||
|
||||
PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions)
|
||||
{
|
||||
packet = p;
|
||||
this->numRetransmissions = numRetransmissions - 1; // We subtract one, because we assume the user just did the first send
|
||||
this->initialNumRetransmissions = this->numRetransmissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a packet
|
||||
*/
|
||||
ErrorCode NextHopRouter::send(meshtastic_MeshPacket *p)
|
||||
{
|
||||
return sendWithNextHop(p, true);
|
||||
}
|
||||
|
||||
ErrorCode NextHopRouter::sendWithNextHop(meshtastic_MeshPacket *p, bool trackRetransmission)
|
||||
{
|
||||
// Add any messages _we_ send to the seen message list (so we will ignore all retransmissions we see)
|
||||
p->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); // First set the relayer to us
|
||||
@@ -73,7 +109,8 @@ ErrorCode NextHopRouter::send(meshtastic_MeshPacket *p)
|
||||
|
||||
// If it's from us, ReliableRouter already handles retransmissions if want_ack is set. If a next hop is set and hop limit is
|
||||
// not 0 or want_ack is set, start retransmissions
|
||||
if ((!isFromUs(p) || !p->want_ack) && p->next_hop != NO_NEXT_HOP_PREFERENCE && (p->hop_limit > 0 || p->want_ack)) {
|
||||
if (trackRetransmission && (!isFromUs(p) || !p->want_ack) && p->next_hop != NO_NEXT_HOP_PREFERENCE &&
|
||||
(p->hop_limit > 0 || p->want_ack)) {
|
||||
if (auto *copy = packetPool.allocCopy(*p))
|
||||
startRetransmission(copy); // start retransmission for relayed packet
|
||||
}
|
||||
@@ -362,7 +399,7 @@ bool NextHopRouter::stopRetransmission(GlobalPacketId key)
|
||||
auto p = old->packet;
|
||||
/* Only when we already transmitted a packet via LoRa, we will cancel the packet in the Tx queue
|
||||
to avoid canceling a transmission if it was ACKed super fast via MQTT */
|
||||
if (old->numRetransmissions < NUM_RELIABLE_RETX - 1) {
|
||||
if (old->numRetransmissions < old->initialNumRetransmissions) {
|
||||
// We only cancel it if we are the original sender or if we're not a router(_late)
|
||||
if (isFromUs(p) || roleAllowsCancelingFromTxQueue(p)) {
|
||||
// remove the 'original' (identified by originator and packet->id) from the txqueue and free it
|
||||
@@ -475,13 +512,13 @@ int32_t NextHopRouter::doRetransmissions()
|
||||
}
|
||||
} else {
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet)) {
|
||||
if (NextHopRouter::send(copy) == ERRNO_SHOULD_RELEASE)
|
||||
if (sendWithNextHop(copy, false) == ERRNO_SHOULD_RELEASE)
|
||||
packetPool.release(copy);
|
||||
}
|
||||
}
|
||||
#else
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet)) {
|
||||
if (NextHopRouter::send(copy) == ERRNO_SHOULD_RELEASE)
|
||||
if (sendWithNextHop(copy, false) == ERRNO_SHOULD_RELEASE)
|
||||
packetPool.release(copy);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -39,6 +39,9 @@ struct PendingPacket {
|
||||
/** Starts at NUM_RETRANSMISSIONS -1 and counts down. Once zero it will be removed from the list */
|
||||
uint8_t numRetransmissions = 0;
|
||||
|
||||
/** Initial remaining retry count, used to detect whether a retry has fired. */
|
||||
uint8_t initialNumRetransmissions = 0;
|
||||
|
||||
PendingPacket() {}
|
||||
explicit PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions);
|
||||
};
|
||||
@@ -77,8 +80,8 @@ class GlobalPacketIdHashFunction
|
||||
Namely, in the PacketHistory, we keep track of (up to 3) relayers of a packet. When the ACK is delivered back to us via a node
|
||||
that also relayed the original packet, we use that node as next hop for the destination from then on. This makes sure that only
|
||||
when there’s a two-way connection, we assign a next hop. Both the ReliableRouter and NextHopRouter will do retransmissions (the
|
||||
NextHopRouter only 1 time). For the final retry, if no one actually relayed the packet, it will reset the next hop in order to
|
||||
fall back to the FloodingRouter again. Note that thus also intermediate hops will do a single retransmission if the intended
|
||||
NextHopRouter only a small number of times). For the final retry, if no one actually relayed the packet, it will reset the next
|
||||
hop in order to fall back to the FloodingRouter again. Intermediate hops also do bounded retransmissions if the intended
|
||||
next-hop didn’t relay, in order to fix changes in the middle of the route.
|
||||
*/
|
||||
class NextHopRouter : public FloodingRouter
|
||||
@@ -109,16 +112,20 @@ class NextHopRouter : public FloodingRouter
|
||||
return min(d, r);
|
||||
}
|
||||
|
||||
// The number of retransmissions intermediate nodes will do (actually 1 less than this)
|
||||
constexpr static uint8_t NUM_INTERMEDIATE_RETX = 2;
|
||||
// The number of retransmissions the original sender will do
|
||||
// Total attempts for directed hop-level delivery, including the initial send.
|
||||
constexpr static uint8_t NUM_INTERMEDIATE_RETX = 3;
|
||||
// Existing reliable broadcast budget, including the initial send.
|
||||
constexpr static uint8_t NUM_RELIABLE_RETX = 3;
|
||||
// Total attempts for acknowledged unicast from the originating node.
|
||||
constexpr static uint8_t NUM_RELIABLE_UNICAST_ATTEMPTS = 5;
|
||||
|
||||
// M3: bounded RAM route-health table (reuse-oldest eviction, like PacketHistory)
|
||||
constexpr static uint8_t ROUTE_HEALTH_MAX = 32; // ~12B/slot -> ~384B
|
||||
constexpr static uint32_t ROUTE_TTL_MSEC = 30UL * 60 * 1000; // re-discover a route unconfirmed for 30 min
|
||||
constexpr static uint8_t ROUTE_FAILURE_THRESHOLD = 3; // consecutive un-ACKed directed deliveries -> dead
|
||||
|
||||
constexpr static uint8_t OPAQUE_SEEN_MAX = 32; // opaque-relay dedup slots (see relayOpaquePacket); ~8B/slot -> ~256B
|
||||
|
||||
protected:
|
||||
/**
|
||||
* Pending retransmissions
|
||||
@@ -130,6 +137,21 @@ class NextHopRouter : public FloodingRouter
|
||||
*/
|
||||
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
|
||||
|
||||
/**
|
||||
* Recently-seen opaque (undecryptable) frames, keyed on the outer (from,id) header. A second,
|
||||
* isolated PacketHistory-style dedup: it bounds broadcast amplification of frames we can't decrypt
|
||||
* WITHOUT admitting them to the real PacketHistory/NodeDB, so unauthenticated traffic can never
|
||||
* influence routing / ACK / next-hop decisions. Fixed-size ring, round-robin (FIFO) eviction, no
|
||||
* timestamps (a stale (from,id) can't false-match: packet ids are effectively random, and a real
|
||||
* entry never has id 0 - relayOpaquePacket drops id 0 before this). RAM-only.
|
||||
*/
|
||||
struct OpaqueSeen {
|
||||
NodeNum sender = 0;
|
||||
PacketId id = 0; // 0 == empty/unused slot
|
||||
};
|
||||
OpaqueSeen opaqueSeen[OPAQUE_SEEN_MAX] = {};
|
||||
uint8_t opaqueSeenNext = 0; // ring write cursor (round-robin eviction)
|
||||
|
||||
/**
|
||||
* Should this incoming filter be dropped?
|
||||
*
|
||||
@@ -138,6 +160,9 @@ class NextHopRouter : public FloodingRouter
|
||||
*/
|
||||
virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override;
|
||||
bool relayOpaquePacket(const meshtastic_MeshPacket *p) override;
|
||||
// Dedup helper for relayOpaquePacket: true if (from,id) is already recorded; otherwise records it
|
||||
// (round-robin eviction) and returns false. Pure function of the table - no clock.
|
||||
bool opaqueWasSeenRecently(NodeNum from, PacketId id);
|
||||
|
||||
/**
|
||||
* Look for packets we need to relay
|
||||
@@ -155,6 +180,8 @@ class NextHopRouter : public FloodingRouter
|
||||
*/
|
||||
PendingPacket *startRetransmission(meshtastic_MeshPacket *p, uint8_t numReTx = NUM_INTERMEDIATE_RETX);
|
||||
|
||||
ErrorCode sendWithNextHop(meshtastic_MeshPacket *p, bool trackRetransmission);
|
||||
|
||||
// Return true if we're allowed to cancel a packet in the txQueue (so we may never transmit it even once)
|
||||
bool roleAllowsCancelingFromTxQueue(const meshtastic_MeshPacket *p);
|
||||
|
||||
|
||||
+41
-19
@@ -430,6 +430,14 @@ NodeDB::NodeDB()
|
||||
|
||||
// likewise - we always want the app requirements to come from the running appload
|
||||
myNodeInfo.min_app_version = 30200; // format is Mmmss (where M is 1+the numeric major number. i.e. 30200 means 2.2.00
|
||||
|
||||
// likewise the edition: it lives in persisted devicestate, so a vanilla install must
|
||||
// overwrite the previous event build's value. Before the CRC compare, so the change persists.
|
||||
#ifdef USERPREFS_FIRMWARE_EDITION
|
||||
myNodeInfo.firmware_edition = USERPREFS_FIRMWARE_EDITION;
|
||||
#else
|
||||
myNodeInfo.firmware_edition = meshtastic_FirmwareEdition_VANILLA;
|
||||
#endif
|
||||
pickNewNodeNum();
|
||||
|
||||
// Set our board type so we can share it with others
|
||||
@@ -615,9 +623,6 @@ NodeDB::NodeDB()
|
||||
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED;
|
||||
config.position.gps_enabled = 0;
|
||||
}
|
||||
#ifdef USERPREFS_FIRMWARE_EDITION
|
||||
myNodeInfo.firmware_edition = USERPREFS_FIRMWARE_EDITION;
|
||||
#endif
|
||||
#ifdef USERPREFS_FIXED_GPS
|
||||
if (myNodeInfo.reboot_count == 1) { // Check if First boot ever or after Factory Reset.
|
||||
meshtastic_Position fixedGPS = meshtastic_Position_init_default;
|
||||
@@ -1028,7 +1033,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
|
||||
|
||||
#if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \
|
||||
defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2) || \
|
||||
defined(SEEED_MESHPAGER_X2)) && \
|
||||
defined(ELECROW_ThinkNode_M9) || defined(T_WATCH_ULTRA) || defined(SEEED_MESHPAGER_X2)) && \
|
||||
HAS_TFT
|
||||
// switch BT off by default; use TFT programming mode or hotkey to enable
|
||||
config.bluetooth.enabled = false;
|
||||
@@ -1112,7 +1117,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
|
||||
config.display.wake_on_tap_or_motion = true;
|
||||
#endif
|
||||
|
||||
#if defined(T_WATCH_S3) || defined(SENSECAP_INDICATOR)
|
||||
#if defined(T_WATCH_S3) || defined(SENSECAP_INDICATOR) || defined(T_WATCH_ULTRA)
|
||||
config.display.screen_on_secs = 30;
|
||||
config.display.wake_on_tap_or_motion = true;
|
||||
#endif
|
||||
@@ -1260,7 +1265,10 @@ void NodeDB::installDefaultModuleConfig()
|
||||
moduleConfig.external_notification.output_ms = 1000;
|
||||
#endif
|
||||
|
||||
#if defined(PIN_VIBRATION)
|
||||
#if HAS_TFT
|
||||
if (moduleConfig.external_notification.nag_timeout == default_ringtone_nag_secs)
|
||||
moduleConfig.external_notification.nag_timeout = 0;
|
||||
#elif defined(PIN_VIBRATION)
|
||||
moduleConfig.external_notification.nag_timeout = 2;
|
||||
#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) || \
|
||||
defined(HAS_I2S_SPEAKER_NRF52)
|
||||
@@ -1272,12 +1280,6 @@ void NodeDB::installDefaultModuleConfig()
|
||||
moduleConfig.external_notification.enabled = true;
|
||||
moduleConfig.external_notification.use_i2s_as_buzzer = true;
|
||||
moduleConfig.external_notification.alert_message_buzzer = true;
|
||||
#if HAS_TFT
|
||||
if (moduleConfig.external_notification.nag_timeout == default_ringtone_nag_secs)
|
||||
moduleConfig.external_notification.nag_timeout = 0;
|
||||
#else
|
||||
moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs;
|
||||
#endif // HAS_TFT
|
||||
#endif // HAS_I2S
|
||||
|
||||
#ifdef NANO_G2_ULTRA
|
||||
@@ -2146,9 +2148,9 @@ void NodeDB::demoteOldestHotNodesToWarm()
|
||||
const meshtastic_NodeInfoLite &n = (*meshNodes)[i];
|
||||
if (n.num == 0)
|
||||
continue;
|
||||
// Keep the public key if we have one (40 B warm record); keyless nodes
|
||||
// still get a placeholder so re-admission restores last_heard.
|
||||
warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr, n.role,
|
||||
// Warm entries carry no key length, so a partial key would be indistinguishable
|
||||
// from a full one. nullptr keeps the keyless placeholder that restores last_heard.
|
||||
warmStore.absorb(n.num, n.last_heard, n.public_key.size == 32 ? n.public_key.bytes : nullptr, n.role,
|
||||
warmProtectedCategory(n), nodeInfoLiteHasXeddsaSigned(&n));
|
||||
// Demotion drops the node from the header table, so drop its satellites
|
||||
// too (the eviction chokepoint) - they'd otherwise orphan until the next
|
||||
@@ -3526,8 +3528,10 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
|
||||
// last_heard will remain as-is (or remain 0 if this entry wasn't in the nodeDB).
|
||||
// If the protected cap refuses the favorite, fall back to a heard-now stamp so the
|
||||
// contact still isn't the first eviction victim.
|
||||
if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true))
|
||||
if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) {
|
||||
LOG_WARN(PROTECTED_CAP_WARN_FMT, "favorite", contact.node_num, MAX_NUM_NODES - 2);
|
||||
stampContactHeardNow(info);
|
||||
}
|
||||
}
|
||||
|
||||
// As the clients will begin sending the contact with DMs, we want to strictly check if the node is manually verified
|
||||
@@ -4442,14 +4446,32 @@ bool NodeDB::createNewIdentity()
|
||||
|
||||
myNodeInfo.my_node_num = newNodeNum;
|
||||
|
||||
// The number has moved, so the caller must persist it whatever happens next. Returning false here
|
||||
// would leave the new key saved against the old number, which is the break this exists to prevent.
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum());
|
||||
if (!info)
|
||||
return false;
|
||||
TypeConversions::CopyUserToNodeInfoLite(info, owner);
|
||||
if (info)
|
||||
TypeConversions::CopyUserToNodeInfoLite(info, owner);
|
||||
else
|
||||
LOG_ERROR("No room for our own node 0x%08x, identity moved without a self record", newNodeNum);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NodeDB::ensurePkiIdentity()
|
||||
{
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
// A failed or declined keygen leaves the existing key, and so the existing node num, untouched.
|
||||
if (!crypto || !crypto->ensurePkiKeys(config.security, owner))
|
||||
return false;
|
||||
|
||||
// ensurePkiKeys() writes key material only, so my_node_num is still the stale MAC-derived value.
|
||||
// createNewIdentity() early-returns when the key, and so the node num, did not actually change.
|
||||
return createNewIdentity();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool NodeDB::backupPreferences(meshtastic_AdminMessage_BackupLocation location)
|
||||
{
|
||||
bool success = false;
|
||||
|
||||
@@ -223,6 +223,18 @@ inline bool shouldDropPacketForPreHop(const meshtastic_MeshPacket &p)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Post-decode, the encrypted bitfield makes MISSING_OR_UNKNOWN decidable.
|
||||
/// Local packets are exempt; Router::dispatchReceived uses this predicate to set skipHandle.
|
||||
inline bool shouldSkipHandleForPostDecodeHop(const meshtastic_MeshPacket &p)
|
||||
{
|
||||
#if !MESHTASTIC_PREHOP_DROP
|
||||
(void)p;
|
||||
return false;
|
||||
#else
|
||||
return !isFromUs(&p) && classifyHopStart(p) != HopStartStatus::VALID;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Rate-limited debug log when hop_start is invalid/missing and packet is dropped.
|
||||
void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context);
|
||||
|
||||
@@ -584,6 +596,10 @@ class NodeDB
|
||||
|
||||
bool createNewIdentity();
|
||||
|
||||
/// Mint the identity keypair outside the boot path and re-seat my_node_num == crc32(public_key).
|
||||
/// @return true if my_node_num moved; the caller must then also persist SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE.
|
||||
bool ensurePkiIdentity();
|
||||
|
||||
bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
|
||||
bool restorePreferences(meshtastic_AdminMessage_BackupLocation location,
|
||||
int restoreWhat = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
|
||||
|
||||
+11
-5
@@ -325,10 +325,10 @@ void PhoneAPI::handleStartConfig()
|
||||
filesManifest = getFiles("/", FILES_MANIFEST_LEVELS, FILES_MANIFEST_MAX_COUNT, &filesManifestLimited);
|
||||
}
|
||||
if (filesManifestLimited) {
|
||||
LOG_WARN("Got %zu files in manifest (limited to %zu entries/depth %u)", filesManifest.size(),
|
||||
FILES_MANIFEST_MAX_COUNT, static_cast<unsigned>(FILES_MANIFEST_LEVELS));
|
||||
LOG_WARN("Got %u files in manifest (limited to %u entries/depth %u)", (unsigned)filesManifest.size(),
|
||||
(unsigned)FILES_MANIFEST_MAX_COUNT, static_cast<unsigned>(FILES_MANIFEST_LEVELS));
|
||||
} else {
|
||||
LOG_DEBUG("Got %zu files in manifest", filesManifest.size());
|
||||
LOG_DEBUG("Got %u files in manifest", (unsigned)filesManifest.size());
|
||||
}
|
||||
} else {
|
||||
releaseFilesManifest(filesManifest);
|
||||
@@ -579,6 +579,9 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
// app not to send locations on our behalf.
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_my_info_tag;
|
||||
strncpy(myNodeInfo.pio_env, optstr(APP_ENV), sizeof(myNodeInfo.pio_env));
|
||||
// strncpy does not terminate when the source fills the buffer; a 40+ char
|
||||
// APP_ENV would make nanopb reject the MyInfo encode ("unterminated string").
|
||||
myNodeInfo.pio_env[sizeof(myNodeInfo.pio_env) - 1] = '\0';
|
||||
myNodeInfo.nodedb_count = static_cast<uint16_t>(nodeDB->getNumMeshNodes());
|
||||
fromRadioScratch.my_info = myNodeInfo;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
@@ -1823,8 +1826,11 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Reject before recording duplicate or per-port cooldown state, so a blocked
|
||||
// attempt cannot throttle a valid private-channel position retry.
|
||||
// Coordinates aimed at the event channel go out on the position channel instead (the phone picks the
|
||||
// channel it last heard the node on, which is the event channel for everyone). Only when there is no
|
||||
// channel to move them to is the send rejected. Reject before recording duplicate or per-port cooldown
|
||||
// state, so a blocked attempt cannot throttle a valid private-channel position retry.
|
||||
coerceCoordinatePacketToPositionChannel(&p);
|
||||
if (isBlockedEventCoordinatePacket(&p)) {
|
||||
LOG_DEBUG("Suppress phone coordinate send on event (everyone) channel");
|
||||
meshtastic_QueueStatus qs = router->getQueueStatus();
|
||||
|
||||
@@ -32,6 +32,17 @@ uint32_t getPositionPrecisionForChannel(uint8_t channelIndex)
|
||||
return precision;
|
||||
}
|
||||
|
||||
bool findPositionChannel(uint8_t &channelIndex)
|
||||
{
|
||||
for (uint8_t i = 0; i < channels.getNumChannels(); i++) {
|
||||
if (getPositionPrecisionForChannel(i) != 0) {
|
||||
channelIndex = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
|
||||
{
|
||||
if (precision == 0 || precision >= 32)
|
||||
|
||||
@@ -16,6 +16,10 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel);
|
||||
// Configured precision, clamped to MAX_POSITION_PRECISION_PUBLIC_KEY when the channel's effective key is publicly decryptable.
|
||||
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex);
|
||||
|
||||
// The channel our position goes out on: the lowest index with a non-zero on-wire precision (disabled and event
|
||||
// channels never qualify). Returns false when position sharing is off on every channel.
|
||||
bool findPositionChannel(uint8_t &channelIndex);
|
||||
|
||||
// Truncate a single latitude_i/longitude_i to `precision` significant bits, centered in the
|
||||
// resulting grid cell (stable under GPS jitter). precision 0 or >=32 returns the value unchanged.
|
||||
// The return is the coordinate (int32_t); the uint8_t overload only narrows the precision arg.
|
||||
|
||||
@@ -129,7 +129,8 @@ bool RF95Interface::init()
|
||||
|
||||
limitPower(RF95_MAX_POWER);
|
||||
|
||||
iface = lora = new RadioLibRF95(&module);
|
||||
lora.reset(new RadioLibRF95(&module));
|
||||
iface = lora.get();
|
||||
|
||||
#ifdef RF95_TCXO
|
||||
pinMode(RF95_TCXO, OUTPUT);
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
#include "RadioLibInterface.h"
|
||||
#include "RadioLibRF95.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
/**
|
||||
* Our new not radiohead adapter for RF95 style radios
|
||||
*/
|
||||
class RF95Interface : public RadioLibInterface
|
||||
{
|
||||
RadioLibRF95 *lora = NULL; // Either a RFM95 or RFM96 depending on what was stuffed on this board
|
||||
// Either a RFM95 or RFM96 depending on what was stuffed on this board.
|
||||
// Owned here; every other radio interface holds its driver by value, but this one is
|
||||
// constructed in init(), so unique_ptr keeps it from leaking when init() fails and the
|
||||
// interface is destroyed.
|
||||
std::unique_ptr<RadioLibRF95> lora;
|
||||
|
||||
public:
|
||||
RF95Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst,
|
||||
|
||||
@@ -414,7 +414,7 @@ std::unique_ptr<RadioInterface> initLoRa()
|
||||
LOG_DEBUG("Activate %s radio on SPI port %s", portduino_config.loraModules[portduino_config.lora_module].c_str(),
|
||||
portduino_config.lora_spi_dev.c_str());
|
||||
if (portduino_config.lora_spi_dev == "ch341") {
|
||||
RadioLibHAL = ch341Hal;
|
||||
RadioLibHAL = ch341Hal.get(); // non-owning: the ch341 HAL stays owned by the global unique_ptr
|
||||
} else {
|
||||
if (RadioLibHAL != nullptr) {
|
||||
delete RadioLibHAL;
|
||||
@@ -672,6 +672,19 @@ const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code)
|
||||
return r;
|
||||
}
|
||||
|
||||
bool isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset preset)
|
||||
{
|
||||
// Walks profile->presets directly rather than RegionInfo::supportsPreset(), which calls
|
||||
// back here for the UNSET entry. UNSET terminates the table, so it is checked last.
|
||||
for (const RegionInfo *r = regions;; r++) {
|
||||
for (size_t i = 0; r->profile->presets[i] != MODEM_PRESET_END; i++)
|
||||
if (r->profile->presets[i] == preset)
|
||||
return true;
|
||||
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map)
|
||||
{
|
||||
map = meshtastic_LoRaRegionPresetMap_init_zero;
|
||||
|
||||
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user