diff --git a/.coderabbit.yaml b/.coderabbit.yaml index a193662cbe..cdcd43f3ae 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -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. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d8c5fed325..3d20ca974f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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(...)`. diff --git a/.github/workflows/build_debian_src.yml b/.github/workflows/build_debian_src.yml index 066727cff7..be3050cbad 100644 --- a/.github/workflows/build_debian_src.yml +++ b/.github/workflows/build_debian_src.yml @@ -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: diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index 8c16e75aad..aeb621f68c 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -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 || '' }} diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 91eb2690e9..59cc44c8c8 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -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 diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 04e6b3a237..2167e29564 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -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 diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 099a6a4919..aec3fc6f87 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index 5c67d124d2..66a8ca6847 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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(...)`. diff --git a/CLAUDE.md b/CLAUDE.md index 325fb7100e..a7dbf6991e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/bin/bme680_iaq_replay.cpp b/bin/bme680_iaq_replay.cpp new file mode 100644 index 0000000000..62d0a5286e --- /dev/null +++ b/bin/bme680_iaq_replay.cpp @@ -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 +#include + +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; +} diff --git a/bin/check-test-attribution.py b/bin/check-test-attribution.py new file mode 100755 index 0000000000..2d3d258e46 --- /dev/null +++ b/bin/check-test-attribution.py @@ -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 under ; accept a bare 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()) diff --git a/bin/generate_ci_matrix.py b/bin/generate_ci_matrix.py index c3235c2795..02155b59a7 100755 --- a/bin/generate_ci_matrix.py +++ b/bin/generate_ci_matrix.py @@ -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"]) diff --git a/bin/lib/test-state.sh b/bin/lib/test-state.sh index a0c6244555..ce77fdd2e2 100644 --- a/bin/lib/test-state.sh +++ b/bin/lib/test-state.sh @@ -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 +} + +# VERDICTDETAIL. 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" +} diff --git a/bin/pio-test-isolate.sh b/bin/pio-test-isolate.sh index bd58c73eb2..bfc51cffdf 100755 --- a/bin/pio-test-isolate.sh +++ b/bin/pio-test-isolate.sh @@ -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" diff --git a/bin/ram_budgets.json b/bin/ram_budgets.json index b48903a0b8..a7e10125e6 100644 --- a/bin/ram_budgets.json +++ b/bin/ram_budgets.json @@ -18,7 +18,7 @@ "description." ], "rak4631": { - "ram_bytes": 113000, - "flash_bytes": 786000 + "ram_bytes": 108000, + "flash_bytes": 746000 } } diff --git a/bin/run-tests.sh b/bin/run-tests.sh index dcc3a705f4..f454d5c8fd 100755 --- a/bin/run-tests.sh +++ b/bin/run-tests.sh @@ -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= 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 diff --git a/bin/stress-suite.sh b/bin/stress-suite.sh new file mode 100755 index 0000000000..ec63612b17 --- /dev/null +++ b/bin/stress-suite.sh @@ -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 . +# +# 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 diff --git a/bin/test-attribution-canary.sh b/bin/test-attribution-canary.sh new file mode 100755 index 0000000000..cb873e6a8a --- /dev/null +++ b/bin/test-attribution-canary.sh @@ -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 ] (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")" diff --git a/boards/seeed_wio_tracker_L1_Pro_1W.json b/boards/seeed_wio_tracker_L1_Pro_1W.json new file mode 100644 index 0000000000..f87074a01e --- /dev/null +++ b/boards/seeed_wio_tracker_L1_Pro_1W.json @@ -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" +} diff --git a/boards/t-impulse-plus.json b/boards/t-impulse-plus.json index 83b289b422..511e308d2c 100644 --- a/boards/t-impulse-plus.json +++ b/boards/t-impulse-plus.json @@ -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": [ diff --git a/boards/t-watch-ultra.json b/boards/t-watch-ultra.json new file mode 100644 index 0000000000..3ac379df5f --- /dev/null +++ b/boards/t-watch-ultra.json @@ -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" +} diff --git a/docs/bme680_iaq_replay.md b/docs/bme680_iaq_replay.md new file mode 100644 index 0000000000..5fc8d96df3 --- /dev/null +++ b/docs/bme680_iaq_replay.md @@ -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. diff --git a/docs/lora_region_preset_compatibility_client_spec.md b/docs/lora_region_preset_compatibility_client_spec.md deleted file mode 100644 index bb1749672f..0000000000 --- a/docs/lora_region_preset_compatibility_client_spec.md +++ /dev/null @@ -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`: - -```text -struct RegionPresetInfo { Set presets; ModemPreset default; bool licensedOnly } - -fun decode(map: LoRaRegionPresetMap): Map { - 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. diff --git a/docs/mesh_beacon_module.md b/docs/mesh_beacon_module.md deleted file mode 100644 index 67a391fe0e..0000000000 --- a/docs/mesh_beacon_module.md +++ /dev/null @@ -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__*` 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: , 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 | diff --git a/docs/nexthop-routing-reliability.md b/docs/nexthop-routing-reliability.md deleted file mode 100644 index 42a08d0776..0000000000 --- a/docs/nexthop-routing-reliability.md +++ /dev/null @@ -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. diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md deleted file mode 100644 index 7908f9f905..0000000000 --- a/docs/node_info_stores.md +++ /dev/null @@ -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. diff --git a/docs/traffic_management_module.md b/docs/traffic_management_module.md deleted file mode 100644 index cf4f9538e0..0000000000 --- a/docs/traffic_management_module.md +++ /dev/null @@ -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. diff --git a/platformio.ini b/platformio.ini index a5ce89383a..5f43d64d6b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/protobufs b/protobufs index 84bfb0fdb3..aca181b97b 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 84bfb0fdb3b853ea18abc4535497fa41a1b09546 +Subproject commit aca181b97b7db047d76e9f000220a11a234cd389 diff --git a/src/AudioThread.h b/src/AudioThread.h index 3a44bf8239..f4f5781fcf 100644 --- a/src/AudioThread.h +++ b/src/AudioThread.h @@ -79,6 +79,7 @@ class AudioThread : public concurrency::OSThread auto sam = std::unique_ptr(new ESP8266SAM); sam->Say(audioOut.get(), text); setCPUFast(false); + audioOut->stop(); #ifdef AUDIO_AMP_ENABLE AUDIO_AMP_ENABLE(false); #endif diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index c00b07684b..ef0d5841ad 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -129,10 +129,13 @@ bool renameFile(const char *pathFrom, const char *pathTo) #endif } +#include +#include #include -#include -#include #include +#ifdef ARCH_ESP32 +#include +#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 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 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; diff --git a/src/MessageStore.cpp b/src/MessageStore.cpp index 4030bdd28f..cca57acb08 100644 --- a/src/MessageStore.cpp +++ b/src/MessageStore.cpp @@ -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 // 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 &dq) { for (auto &m : dq) { diff --git a/src/MessageStore.h b/src/MessageStore.h index 366c1a37d8..dfc8673e7f 100644 --- a/src/MessageStore.h +++ b/src/MessageStore.h @@ -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 diff --git a/src/Power.cpp b/src/Power.cpp index aa752cf633..2cb73296b7 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -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) diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp index 268cb8211d..2ef5b2ea1c 100644 --- a/src/PowerFSM.cpp +++ b/src/PowerFSM.cpp @@ -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); diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index f0ebbcc208..66a266d960 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -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; diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index a406fcd0dd..24141be28e 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -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() { diff --git a/src/SerialConsole.h b/src/SerialConsole.h index eeed25644d..466d6afa96 100644 --- a/src/SerialConsole.h +++ b/src/SerialConsole.h @@ -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. diff --git a/src/airtime.cpp b/src/airtime.cpp index a9b4c7dc58..aaacefb092 100644 --- a/src/airtime.cpp +++ b/src/airtime.cpp @@ -2,62 +2,65 @@ #include "NodeDB.h" #include "UptimeClock.h" #include "configuration.h" +#include #include 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); } diff --git a/src/airtime.h b/src/airtime.h index 39c1d3e03d..b1e1172a76 100644 --- a/src/airtime.h +++ b/src/airtime.h @@ -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 #include /* - 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; }; diff --git a/src/buzz/buzz.cpp b/src/buzz/buzz.cpp index 897f74d69b..63f4e9b13b 100644 --- a/src/buzz/buzz.cpp +++ b/src/buzz/buzz.cpp @@ -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 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 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 diff --git a/src/concurrency/Lock.cpp b/src/concurrency/Lock.cpp index 4596e0edfd..9068cee433 100644 --- a/src/concurrency/Lock.cpp +++ b/src/concurrency/Lock.cpp @@ -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 diff --git a/src/concurrency/Lock.h b/src/concurrency/Lock.h index a512481178..342747ae7f 100644 --- a/src/concurrency/Lock.h +++ b/src/concurrency/Lock.h @@ -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. diff --git a/src/configuration.h b/src/configuration.h index 0ed4dd893d..b55ce262f7 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -205,6 +205,13 @@ along with this program. If not, see . #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 . #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 . #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 . #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 . #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 . #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 diff --git a/src/detect/ReClockI2C.h b/src/detect/ReClockI2C.h index 24a166d53f..2501224a0c 100644 --- a/src/detect/ReClockI2C.h +++ b/src/detect/ReClockI2C.h @@ -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 diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp index a919bd1058..580cba7fd1 100644 --- a/src/detect/ScanI2C.cpp +++ b/src/detect/ScanI2C.cpp @@ -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 diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index 70f848b33a..4bb141722a 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -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 { diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 21a74b7599..d9c9d77179 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -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() + addr.address = QMC6309_ADDR; + i2cBus->beginTransmission(addr.address); + if (i2cBus->endTransmission() == 0 && + getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1) == 0x90 /* QMC6309 chip id */) { + deviceAddresses[QMC6309] = addr; + foundDevices[addr] = QMC6309; + logFoundDevice("QMC6309", (uint8_t)addr.address); + } +#endif } void ScanI2CTwoWire::scanPort(I2CPort port) diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 69000f2fef..0bd0f3212a 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -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 unicore = { - {"UC6580", "UC6580", GNSS_MODEL_UC6580}, {"UM600", "UM600", GNSS_MODEL_UC6580}, {"CM121", "CM121", GNSS_MODEL_CM121}}; + std::vector 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; diff --git a/src/gps/GPSUpdateScheduling.cpp b/src/gps/GPSUpdateScheduling.cpp index fe2c3ae78a..7f37e100c9 100644 --- a/src/gps/GPSUpdateScheduling.cpp +++ b/src/gps/GPSUpdateScheduling.cpp @@ -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? diff --git a/src/gps/GPSUpdateScheduling.h b/src/gps/GPSUpdateScheduling.h index d7609d704e..d7e11ad1ab 100644 --- a/src/gps/GPSUpdateScheduling.h +++ b/src/gps/GPSUpdateScheduling.h @@ -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; diff --git a/src/gps/GeoCoord.cpp b/src/gps/GeoCoord.cpp index 1fc60c3049..4f34966817 100644 --- a/src/gps/GeoCoord.cpp +++ b/src/gps/GeoCoord.cpp @@ -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::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(double(lat), double(lon), this->getAltitude()); -} - /** * Convert bearing to degrees * @param bearing diff --git a/src/gps/GeoCoord.h b/src/gps/GeoCoord.h index 5afa784307..027f39f147 100644 --- a/src/gps/GeoCoord.h +++ b/src/gps/GeoCoord.h @@ -4,7 +4,6 @@ #include #include #include -#include #include #include #include @@ -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 pointAtDistance(double bearing, double range); - // Lat lon alt getters int32_t getLatitude() const { return _latitude; } int32_t getLongitude() const { return _longitude; } diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 5c18cca623..93e59e31e6 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -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 diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index dca31be605..d18cc680e1 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -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(*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(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(*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(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(*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(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(*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(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(*lowLevel); + // Create GxEPD2 objects (GxEPD2_BW stores a copy of the driver, so pass a temporary) + adafruitDisplay = new GxEPD2_BW( + 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(*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(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(*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(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(*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(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(*lowLevel); + // Create GxEPD2 objects (GxEPD2_BW stores a copy of the driver, so pass a temporary) + adafruitDisplay = new GxEPD2_BW( + EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *spi1)); // Init GxEPD2 adafruitDisplay->init(); diff --git a/src/graphics/EInkParallelDisplay.cpp b/src/graphics/EInkParallelDisplay.cpp index 552b117470..a61b1bae97 100644 --- a/src/graphics/EInkParallelDisplay.cpp +++ b/src/graphics/EInkParallelDisplay.cpp @@ -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); diff --git a/src/graphics/Panel_sdl.cpp b/src/graphics/Panel_sdl.cpp index bad6072f9e..f01d7dc68b 100644 --- a/src/graphics/Panel_sdl.cpp +++ b/src/graphics/Panel_sdl.cpp @@ -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; { diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index c8271ddf13..f36154e8cd 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -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; diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index cf694f51a7..e7a77942f5 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -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. diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index b66a2a02af..5e6567206c 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -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 diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h index 3ed91d86b1..3b99fb77f6 100644 --- a/src/graphics/SharedUIDisplay.h +++ b/src/graphics/SharedUIDisplay.h @@ -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() diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index ba2d50320c..a3af9e7ff1 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -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 // 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 // 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(x), static_cast(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(x), static_cast(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(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"); diff --git a/src/graphics/VirtualKeyboard.cpp b/src/graphics/VirtualKeyboard.cpp index fd06e0def3..bdc827a0a1 100644 --- a/src/graphics/VirtualKeyboard.cpp +++ b/src/graphics/VirtualKeyboard.cpp @@ -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 callback = onTextEntered; + onTextEntered = nullptr; + inputText = ""; + callback(""); } break; default: diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index b50c7081cf..bddc6e8d18 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -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(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); } } diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index f5cd21e1a5..446f29a21d 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -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; diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 311205e0aa..9650932232 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -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 onConfirm); diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 05a283b8ff..acfc4d11b6 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -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); } } } diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 7d7bf5a6e8..24a5eeaf63 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -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; diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h index 69c4bc0671..d1e8bac1dd 100644 --- a/src/graphics/draw/NodeListRenderer.h +++ b/src/graphics/draw/NodeListRenderer.h @@ -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 diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index b82b850f9d..804f949ffb 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -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 #include #include @@ -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(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); diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h index d66406abf3..528b8fdffd 100644 --- a/src/graphics/draw/UIRenderer.h +++ b/src/graphics/draw/UIRenderer.h @@ -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(); diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 5e8a08e751..863c1e85d4 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -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 diff --git a/src/graphics/tftSetup.cpp b/src/graphics/tftSetup.cpp index 40e533177e..8e971cb60b 100644 --- a/src/graphics/tftSetup.cpp +++ b/src/graphics/tftSetup.cpp @@ -15,8 +15,6 @@ #endif #if defined(ARCH_PORTDUINO) || !defined(HAS_FREE_RTOS) -#include -#include #include #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 { diff --git a/src/input/ButtonThread.cpp b/src/input/ButtonThread.cpp index 29f56dba5f..251311df75 100644 --- a/src/input/ButtonThread.cpp +++ b/src/input/ButtonThread.cpp @@ -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; diff --git a/src/input/ExpressLRSFiveWay.cpp b/src/input/ExpressLRSFiveWay.cpp index 01712ad2af..e9efeda52e 100644 --- a/src/input/ExpressLRSFiveWay.cpp +++ b/src/input/ExpressLRSFiveWay.cpp @@ -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) diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index 429ecd7eaa..7b0f830c59 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -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 diff --git a/src/input/InputBroker.h b/src/input/InputBroker.h index 975c9d9f4a..e30e84ff7b 100644 --- a/src/input/InputBroker.h +++ b/src/input/InputBroker.h @@ -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, diff --git a/src/input/STC8HKeyboard.cpp b/src/input/STC8HKeyboard.cpp new file mode 100644 index 0000000000..134153593a --- /dev/null +++ b/src/input/STC8HKeyboard.cpp @@ -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 diff --git a/src/input/STC8HKeyboard.h b/src/input/STC8HKeyboard.h new file mode 100644 index 0000000000..7de3b3d281 --- /dev/null +++ b/src/input/STC8HKeyboard.h @@ -0,0 +1,74 @@ +#pragma once +#ifndef _STC8H_KEYBOARD_H_ +#define _STC8H_KEYBOARD_H_ + +#include "configuration.h" +#include "kbI2cBase.h" +#include +#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 lsObserver = + CallbackObserver(this, &STC8HKeyboard::beforeLightSleep); + CallbackObserver lsEndObserver = + CallbackObserver(this, &STC8HKeyboard::afterLightSleep); +#endif +}; + +extern STC8HKeyboard Stc8HKeyBoard; + +#endif +#endif diff --git a/src/input/TCA8418KeyboardBase.h b/src/input/TCA8418KeyboardBase.h index e608c6da54..caa9a40449 100644 --- a/src/input/TCA8418KeyboardBase.h +++ b/src/input/TCA8418KeyboardBase.h @@ -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. diff --git a/src/input/TouchScreenBase.cpp b/src/input/TouchScreenBase.cpp index 2e39e52d69..8512300f71 100644 --- a/src/input/TouchScreenBase.cpp +++ b/src/input/TouchScreenBase.cpp @@ -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(); diff --git a/src/input/cardKbI2cImpl.cpp b/src/input/cardKbI2cImpl.cpp index cb03eb4ff6..dd86607c05 100644 --- a/src/input/cardKbI2cImpl.cpp +++ b/src/input/cardKbI2cImpl.cpp @@ -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); diff --git a/src/input/kbI2cBase.cpp b/src/input/kbI2cBase.cpp index 88386c5c9d..d4851c7f65 100644 --- a/src/input/kbI2cBase.cpp +++ b/src/input/kbI2cBase.cpp @@ -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 } diff --git a/src/input/kbI2cBase.h b/src/input/kbI2cBase.h index ae769dff85..a2f2e3cc01 100644 --- a/src/input/kbI2cBase.h +++ b/src/input/kbI2cBase.h @@ -6,12 +6,17 @@ #include "Wire.h" #include "concurrency/OSThread.h" +#include + class TCA8418KeyboardBase; class KbI2cBase : public Observable, 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, public concurrency::OST BBQ10Keyboard Q10keyboard; MPR121Keyboard MPRkeyboard; - TCA8418KeyboardBase &TCAKeyboard; + std::unique_ptr TCAKeyboard; bool is_sym = false; }; \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 3a4eb5f5fa..3f606706f9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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(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; diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index f2c966cc7c..bd199e8fd9 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -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 ctr; - if (_key.length == 16) - ctr = std::unique_ptr(new CTR()); - else - ctr = std::unique_ptr(new CTR()); + // 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 *ctr128 = nullptr; + static CTR *ctr256 = nullptr; + CTRCommon *ctr; + if (_key.length == 16) { + if (!ctr128) + ctr128 = new CTR(); + ctr = ctr128; + } else { + if (!ctr256) + ctr256 = new CTR(); + ctr = ctr256; + } ctr->setKey(_key.bytes, _key.length); static uint8_t scratch[MAX_BLOCKSIZE]; memcpy(scratch, bytes, numBytes); diff --git a/src/mesh/MeshPacketQueue.cpp b/src/mesh/MeshPacketQueue.cpp index 4aad40c69d..58e9cf0bf6 100644 --- a/src/mesh/MeshPacketQueue.cpp +++ b/src/mesh/MeshPacketQueue.cpp @@ -1,5 +1,7 @@ #include "MeshPacketQueue.h" #include "NodeDB.h" +#include "Throttle.h" +#include "UptimeClock.h" #include "configuration.h" #include @@ -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", diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index e5b54d6a21..624d966231 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -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 diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 162d15353d..cfe213ff9b 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -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) diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index 8ddc6e4349..7adcdb7c6d 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -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; diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 3be7a1ba6b..c86f35ec76 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -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 diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index 3a19191fe3..0d6d971fc8 100644 --- a/src/mesh/NextHopRouter.h +++ b/src/mesh/NextHopRouter.h @@ -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); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index abccda70ff..4dcd1781a7 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -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; diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index e5b5a67acc..0e669cca54 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -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); diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 45f9b2477e..fdffd0c260 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -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(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(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(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(); diff --git a/src/mesh/PositionPrecision.cpp b/src/mesh/PositionPrecision.cpp index d34c660861..df846c01c3 100644 --- a/src/mesh/PositionPrecision.cpp +++ b/src/mesh/PositionPrecision.cpp @@ -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) diff --git a/src/mesh/PositionPrecision.h b/src/mesh/PositionPrecision.h index 0a2dc8ef07..a3565aa564 100644 --- a/src/mesh/PositionPrecision.h +++ b/src/mesh/PositionPrecision.h @@ -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. diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 6968b5654e..909d47e23e 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -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); diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index e01dfe3768..2cd4835720 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -4,12 +4,18 @@ #include "RadioLibInterface.h" #include "RadioLibRF95.h" +#include + /** * 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 lora; public: RF95Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 5c757cd849..58bd498c58 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -414,7 +414,7 @@ std::unique_ptr 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; diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index a826a51318..195a5738a0 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -4,6 +4,7 @@ #include "PowerMon.h" #include "SPILock.h" #include "Throttle.h" +#include "UptimeClock.h" #include "configuration.h" #include "error.h" #include "main.h" @@ -436,10 +437,12 @@ void RadioLibInterface::onNotify(uint32_t notification) } else { meshtastic_MeshPacket *txp = txQueue.getFront(); assert(txp); - long delay_remaining = txp->tx_after ? txp->tx_after - millis() : 0; - if (delay_remaining > 0) { + const uint32_t now = Time::getMillis(); + // Not `long remaining = tx_after - millis()`: that uint32_t subtraction widens to + // ~4.29e9 where long is 64-bit (portduino), rescheduling a due packet ~49.7 days out. + if (txp->tx_after && !Throttle::deadlinePassedAt(now, txp->tx_after)) { // There's still some delay pending on this packet, so resume waiting for it to elapse - notifyLater(delay_remaining, TRANSMIT_DELAY_COMPLETED, txTimerOverwrite); + notifyLater(txp->tx_after - now, TRANSMIT_DELAY_COMPLETED, txTimerOverwrite); #if !MESHTASTIC_EXCLUDE_BEACON } else if (MeshBeaconModule::beaconTxConfigInvalid(txp)) { // The beacon's target radio config is invalid (bad preset/region, or an diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index 72ef73eab8..4e8d2c2e90 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -30,8 +30,10 @@ ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p) auto copy = packetPool.allocCopy(*p); DEBUG_HEAP_AFTER("ReliableRouter::send", copy); - if (copy) - startRetransmission(copy, NUM_RELIABLE_RETX); + if (copy) { + const uint8_t totalAttempts = isBroadcast(p->to) ? NUM_RELIABLE_RETX : NUM_RELIABLE_UNICAST_ATTEMPTS; + startRetransmission(copy, totalAttempts); + } } /* If we have pending retransmissions, add the airtime of this packet to it, because during that time we cannot receive an @@ -51,34 +53,41 @@ ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p) return result; } -bool ReliableRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) +void ReliableRouter::perhapsGenerateImplicitAckForOwnOverheard(const meshtastic_MeshPacket *p) { // Note: do not use getFrom() here, because we want to ignore messages sent from phone - if (p->from == getNodeNum()) { - printPacket("Rx someone rebroadcasting for us", p); + if (p->from != getNodeNum()) + return; - // We are seeing someone rebroadcast one of our broadcast attempts. - // If this is the first time we saw this, cancel any retransmissions we have queued up and generate an internal ack for - // the original sending process. + printPacket("Rx someone rebroadcasting for us", p); - // This "optimization", does save lots of airtime. For DMs, you also get a real ACK back - // from the intended recipient. - auto key = GlobalPacketId(getFrom(p), p->id); - auto old = findPendingPacket(key); - if (old) { - LOG_DEBUG("Generate implicit ack"); - // NOTE: we do NOT check p->wantAck here because p is the INCOMING rebroadcast and that packet is not expected to be - // marked as wantAck - sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, old->packet->channel); + // We are seeing someone rebroadcast one of our transmissions. If this is the first time we saw + // this, cancel any retransmissions we have queued up and generate an internal ack for the + // original sending process. Header-only (from/id), so it works even for a packet we cannot + // decrypt - notably a PKI DM we originated, which is opaque to us when overheard. - // Only stop retransmissions if the rebroadcast came via LoRa - if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA) { - stopRetransmission(key); - } - } else { - LOG_DEBUG("Didn't find pending packet"); + // This "optimization", does save lots of airtime. For DMs, you also get a real ACK back + // from the intended recipient. + auto key = GlobalPacketId(getFrom(p), p->id); + auto old = findPendingPacket(key); + if (old) { + LOG_DEBUG("Generate implicit ack"); + // NOTE: we do NOT check p->wantAck here because p is the INCOMING rebroadcast and that packet is not expected to be + // marked as wantAck + sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, old->packet->channel); + + // Only stop retransmissions if the rebroadcast came via LoRa + if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA) { + stopRetransmission(key); } + } else { + LOG_DEBUG("Didn't find pending packet"); } +} + +bool ReliableRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) +{ + perhapsGenerateImplicitAckForOwnOverheard(p); /* At this point we have already deleted the pending retransmission if this packet was an (implicit) ACK to it. Now for all other pending retransmissions, we have to add the airtime of this received packet to the retransmission timer, diff --git a/src/mesh/ReliableRouter.h b/src/mesh/ReliableRouter.h index 33121de6be..1dafaca801 100644 --- a/src/mesh/ReliableRouter.h +++ b/src/mesh/ReliableRouter.h @@ -32,6 +32,11 @@ class ReliableRouter : public NextHopRouter */ virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override; + /** + * Header-only implicit ACK for our own overheard rebroadcast (also usable before decode). + */ + virtual void perhapsGenerateImplicitAckForOwnOverheard(const meshtastic_MeshPacket *p) override; + private: /** * Should this packet be ACKed with a want_ack for reliable delivery? diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 2aa6a6c639..34f477438c 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -16,6 +16,9 @@ #include #include #include +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && !MESHTASTIC_EXCLUDE_GPS +#include "modules/PositionModule.h" +#endif #if HAS_TRAFFIC_MANAGEMENT #endif #if HAS_VARIABLE_HOPS @@ -86,6 +89,11 @@ bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p) if (p->pki_encrypted || willUsePki(p)) { return false; } + // From us, to us: never leaves the device (sendLocal delivers it locally). This is how the phone + // hands a GPS-less node its fix and time, so it shares nothing and must not be blocked. + if (isFromUs(p) && isToUs(p)) { + return false; + } if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { return isCoordinatePortnum(p->decoded.portnum) && channels.isEventChannel(getEffectiveChannelIndex(p)); } @@ -96,6 +104,33 @@ bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p) #endif } +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && !MESHTASTIC_EXCLUDE_GPS +// A remote node's unicast position request to us. Only the reply is generated for these; the packet +// itself is still dropped by the caller. +static bool isEventChannelPositionRequestForUs(const meshtastic_MeshPacket *p) +{ + return p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && + p->decoded.portnum == meshtastic_PortNum_POSITION_APP && p->decoded.want_response && isToUs(p) && !isFromUs(p); +} +#endif + +bool coerceCoordinatePacketToPositionChannel(meshtastic_MeshPacket *p) +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + if (!isBlockedEventCoordinatePacket(p)) + return false; + uint8_t positionChannel; + if (!findPositionChannel(positionChannel)) + return false; + LOG_DEBUG("Coerce coordinate packet 0x%08x from event channel to position channel %u", p->id, positionChannel); + p->channel = positionChannel; + return true; +#else + (void)p; + return false; +#endif +} + bool willUsePki(const meshtastic_MeshPacket *p) { #if !(MESHTASTIC_EXCLUDE_PKI) @@ -396,6 +431,11 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) return ERRNO_NO_INTERFACES; } else { + // Coordinates never go out on the event channel: any local originator (phone, module, UI) that aimed + // one there is moved onto the position channel instead. Before the loopback below so the local copy + // carries the channel it will actually be sent on. + coerceCoordinatePacketToPositionChannel(p); + // If we are sending a broadcast, we also treat it as if we just received it ourself // this allows local apps (and PCs) to see broadcasts sourced locally. Only the loopback // handleReceived is deferred when nested; send(p) below still transmits immediately. @@ -789,6 +829,12 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p) return RoutingAuthVerdict::REJECT; } if (state == DecodeState::DECODE_FAILURE) { + // One-byte hash collisions are indistinguishable from tampering, so relay opaquely + // instead of blackholing; isFromUs stays REJECT to keep forged senders off the ACK path. + if (!isToUs(p) && !isFromUs(p)) { + LOG_WARN("Decryptable packet failed decoding, relay opaquely"); + return RoutingAuthVerdict::OPAQUE_RELAY_ONLY; + } LOG_WARN("Decryptable packet failed decoding, drop"); return RoutingAuthVerdict::REJECT; } @@ -809,6 +855,18 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p) static uint32_t adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST; static uint32_t adminKeyFallbackRefillMs = 0; +#ifdef PIO_UNIT_TESTING +// The refill stamp is a timestamp, so it is only meaningful against the clock that produced it. +// A suite that swaps between the real and the virtual clock leaves a stamp from the other +// timebase, and the next unsigned subtraction reads as a near-infinite gap: the bucket silently +// refills to full. Re-stamp when the clock changes. +void resetAdminKeyFallbackBudget() +{ + adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST; + adminKeyFallbackRefillMs = Time::getMillis(); +} +#endif + static bool adminKeyFallbackAllowed() { bool haveAdminKey = false; @@ -821,7 +879,8 @@ static bool adminKeyFallbackAllowed() if (!haveAdminKey) return false; // nothing to try, so do not spend a token - uint32_t now = millis(); + // Injectable clock so the budget can be tested without sleeping, and without racing a slow host. + uint32_t now = Time::getMillis(); if (adminKeyFallbackRefillMs == 0) adminKeyFallbackRefillMs = now; uint32_t elapsed = now - adminKeyFallbackRefillMs; @@ -1010,14 +1069,16 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) return DecodeState::DECODE_POLICY_REJECT; #endif + if (p->decoded.has_bitfield) + p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK; + if (isBlockedEventCoordinatePacket(p)) { + // want_response is already merged above: a position request on the event channel is still + // answered (on the position channel) even though its coordinates are dropped. LOG_DEBUG("Decoded coordinate packet on event channel; suppress payload logging"); return DecodeState::DECODE_SUCCESS; } - if (p->decoded.has_bitfield) - p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK; - /* Not actually ever used. // Decompress if needed. jm if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) { @@ -1443,11 +1504,10 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) printPacket("handleReceived(REMOTE)", p); #if MESHTASTIC_PREHOP_DROP - // Pre-hop firmware drop, post-decode half: the bitfield that proves the origin populated hop_start is - // encrypted under the channel key, so it can only be evaluated now that the packet is decoded. A packet - // whose hop_start is still missing/unknown comes from pre-hop firmware - keep it out of module - // processing, admin handling, phone delivery, MQTT and rebroadcast. Local-origin packets are exempt. - if (!isFromUs(p) && classifyHopStart(*p) != HopStartStatus::VALID) { + // Pre-hop firmware drop, post-decode half: a packet whose hop_start is still missing/unknown comes + // from pre-hop firmware - keep it out of module processing, admin handling, phone delivery, MQTT + // and rebroadcast. + if (shouldSkipHandleForPostDecodeHop(*p)) { logHopStartDrop(*p, "post-decode pre-hop drop"); cancelSending(p->from, p->id); skipHandle = true; @@ -1497,6 +1557,16 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) // Discard coordinate-bearing packets that arrive on the event ("everyone") // channel: don't process, store in NodeDB, or rebroadcast them. if (!skipHandle && isBlockedEventCoordinatePacket(p)) { + // A position request addressed to us is still answered, on our position channel at that + // channel's precision, so "request position" from a node that only shares the event channel + // with us resolves where positions actually live. The requester's own coordinates are + // still dropped: not stored, not forwarded to the phone, not relayed, not published. + // Builds without the position module (MESHTASTIC_EXCLUDE_GPS, e.g. repeaters) have nothing + // to answer with, and neither the symbol nor the global exists to link against. +#if !MESHTASTIC_EXCLUDE_GPS + if (isEventChannelPositionRequestForUs(p) && positionModule) + positionModule->replyOnPositionChannel(*p); +#endif LOG_DEBUG("Drop coordinate packet on event (everyone) channel"); cancelSending(p->from, p->id); skipHandle = true; @@ -1611,6 +1681,12 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) return; } if (authVerdict == RoutingAuthVerdict::OPAQUE_RELAY_ONLY) { + // A packet we originated but cannot decrypt (a PKI DM we sent, overheard being rebroadcast) + // is opaque to us and would otherwise skip shouldFilterReceived entirely, so the implicit + // ACK that marks a DM "Delivered to mesh" never fires. The ACK is header-only (from/id), so + // generate it here from the still-encrypted packet before opaque relay. + if (isFromUs(p)) + perhapsGenerateImplicitAckForOwnOverheard(p); relayOpaquePacket(p); packetPool.release(p); return; diff --git a/src/mesh/Router.h b/src/mesh/Router.h index d5ea73cfe9..069b4ede0a 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -18,6 +18,10 @@ inline bool isCoordinatePortnum(meshtastic_PortNum portnum) } bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p); +/// Retarget a locally-originated coordinate packet that would be blocked on the event channel onto the +/// position channel (see findPositionChannel). Returns true if p->channel was changed; false when the +/// packet is not a blocked event coordinate packet or no channel carries positions. +bool coerceCoordinatePacketToPositionChannel(meshtastic_MeshPacket *p); bool willUsePki(const meshtastic_MeshPacket *p); /// rx_time/has_rx_time for "now": a real epoch when the clock is trustworthy, else a @@ -137,6 +141,14 @@ class Router : protected concurrency::OSThread, protected PacketHistory /** Relay an opaque packet without admitting it to local routing/history state. */ virtual bool relayOpaquePacket(const meshtastic_MeshPacket *) { return false; } + /** + * Generate the implicit ACK for our own transmission overheard being rebroadcast, using header + * fields only (from/id). Split out of shouldFilterReceived() so it can also run when the auth + * gate short-circuits a packet we cannot decrypt (a PKI DM we originated is opaque to us, so + * without this the client never sees "Delivered to mesh" for DMs). + */ + virtual void perhapsGenerateImplicitAckForOwnOverheard(const meshtastic_MeshPacket *) {} + /** * Determine if hop_limit should be decremented for a relay operation. * Returns false (preserve hop_limit) only if all conditions are met: @@ -257,6 +269,8 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p); #ifdef PIO_UNIT_TESTING uint32_t routingAuthEvaluationCount(); void resetRoutingAuthEvaluationCount(); +/** Refill the admin-key fallback budget and re-stamp it against the clock in use right now. */ +void resetAdminKeyFallbackBudget(); #endif /** Return 0 for success or a Routing_Error code for failure diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 750ebbbefb..2400a8e03f 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -176,6 +176,12 @@ template bool SX126xInterface::init() if (res == RADIOLIB_ERR_NONE) res = lora.setCRC(RADIOLIB_SX126X_LORA_CRC_ON); +#ifdef SX126X_NO_POWER_OPTIMIZATION_TABLE + // begin() applied the optimization table; re-apply the fixed PA config. + if (res == RADIOLIB_ERR_NONE) + res = lora.setOutputPower(power, false); +#endif + if (res == RADIOLIB_ERR_NONE) startReceive(); // start receiving @@ -226,7 +232,11 @@ template bool SX126xInterface::reconfigure() if (power < -9) power = -9; +#ifdef SX126X_NO_POWER_OPTIMIZATION_TABLE + err = lora.setOutputPower(power, false); // external PA: fixed PA config +#else err = lora.setOutputPower(power); +#endif if (err != RADIOLIB_ERR_NONE) { // Don't abort: this power is operator config (tx_power/SX126X_MAX_POWER); a value above the // driver's max would crash the daemon before reloadConfig() persists. Flag it and keep prior power. @@ -525,4 +535,4 @@ template void SX126xInterface::setTransmitEnable(bool txon) #endif } -#endif \ No newline at end of file +#endif diff --git a/src/mesh/StreamAPI.cpp b/src/mesh/StreamAPI.cpp index e20434042c..7d3ca39532 100644 --- a/src/mesh/StreamAPI.cpp +++ b/src/mesh/StreamAPI.cpp @@ -33,6 +33,12 @@ int32_t StreamAPI::runOncePart(char *buf, uint16_t bufLen) return result; } +/// Report undelivered output so idle-sleep decisions keep the drain alive. +bool StreamAPI::hasPendingOutput() +{ + return canWrite && (hasRetainedFrame() || available()); +} + /** * Read any rx chars from the link and call handleRecStream */ @@ -80,12 +86,9 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen) { uint16_t index = 0; while (bufLen > index) { // Currently we never want to block - int cInt = buf[index++]; - if (cInt < 0) - break; // We ran out of characters (even though available said otherwise) - this can happen on rf52 adafruit - // arduino - - uint8_t c = (uint8_t)cInt; + // Unlike stream->read(), a buffer byte has no EOF sentinel: bufLen already bounds the loop, + // and a signed-char comparison would treat any byte >= 0x80 (START1 included) as EOF. + uint8_t c = (uint8_t)buf[index++]; // Use the read pointer for a little state machine, first look for framing, then length bytes, then payload size_t ptr = rxPtr; @@ -99,8 +102,10 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen) if (c != START1) rxPtr = 0; // failed to find framing } else if (ptr == 1) { // looking for START2 + // A byte that fails START2 can itself be the START1 of the real frame (0x94 0x94 0xc3 + // ...), so re-test it here: discarding it drops the frame behind a single stray marker. if (c != START2) - rxPtr = 0; // failed to find framing + rxPtr = (c == START1) ? 1 : 0; } else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing @@ -155,8 +160,10 @@ int32_t StreamAPI::readStream() if (c != START1) rxPtr = 0; // failed to find framing } else if (ptr == 1) { // looking for START2 + // A byte that fails START2 can itself be the START1 of the real frame (0x94 0x94 + // 0xc3 ...): discarding it drops the frame behind a single stray marker. if (c != START2) - rxPtr = 0; // failed to find framing + rxPtr = (c == START1) ? 1 : 0; } else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing diff --git a/src/mesh/StreamAPI.h b/src/mesh/StreamAPI.h index c91da4d02f..7968972e1f 100644 --- a/src/mesh/StreamAPI.h +++ b/src/mesh/StreamAPI.h @@ -57,6 +57,10 @@ class StreamAPI : public PhoneAPI virtual int32_t runOncePart(); virtual int32_t runOncePart(char *buf, uint16_t bufLen); + /// True while undelivered output remains (retained frame or queued PhoneAPI data); callers + /// woken only by RX activity must keep polling while set, as drains stop mid-dump (#11164). + bool hasPendingOutput(); + /// Check the current underlying physical link to see if the client is currently connected virtual bool checkIsConnected() override = 0; @@ -104,6 +108,8 @@ class StreamAPI : public PhoneAPI /// Complete retained transport output before dequeuing another PhoneAPI packet. virtual bool finishPendingFrame() { return true; } + /// Return whether the transport retains an incomplete frame awaiting TX space. + virtual bool hasRetainedFrame() { return false; } /// Return whether the dedicated log buffer is available for encoding. virtual bool canEncodeLogRecord() { return true; } /// Frame and write a payload, optionally using best-effort admission. diff --git a/src/mesh/api/ServerAPI.cpp b/src/mesh/api/ServerAPI.cpp index 20ff8af999..7303ae3044 100644 --- a/src/mesh/api/ServerAPI.cpp +++ b/src/mesh/api/ServerAPI.cpp @@ -5,6 +5,8 @@ #include "ServerAPI.h" #include "Throttle.h" #include +#include +#include static constexpr uint32_t TCP_IDLE_TIMEOUT_MS = 15 * 60 * 1000UL; @@ -117,7 +119,22 @@ template int32_t APIServerPort::runOnce() openAPI.reset(); } - openAPI.reset(new T(client)); + // A ServerAPI carries the stream rx/tx buffers plus the FromRadio/ToRadio scratch, several + // KB in one block. On ESP32 a new that cannot get that block is a reboot (see the note on + // openAPI in the header), and std::nothrow does not help there because libstdc++ builds it + // on the throwing form. malloc() does return nullptr, so take the block from malloc() and + // construct in place; if there is no room drop this connection instead of the node - the + // client retries and the next accept gets a fresh look at the heap. The T constructors do + // not allocate (default-constructed containers, fixed-size thread table), so nothing inside + // the placement new can throw either. + void *block = malloc(sizeof(T)); + if (!block) { + LOG_ERROR("No heap for API connection (%u bytes), dropping client", (unsigned)sizeof(T)); + client.stop(); + } else { + openAPI.reset(new (block) T(client)); + } + // cppcheck-suppress memleak ; block is owned by openAPI via placement new, freed by MallocDeleter } #if RAK_4631 diff --git a/src/mesh/api/ServerAPI.h b/src/mesh/api/ServerAPI.h index ece8e0ba23..05c3bb56f5 100644 --- a/src/mesh/api/ServerAPI.h +++ b/src/mesh/api/ServerAPI.h @@ -1,6 +1,7 @@ #pragma once #include "StreamAPI.h" +#include #include #define SERVER_API_DEFAULT_PORT 4403 @@ -44,8 +45,23 @@ template class APIServerPort : public U, private concurrency: * * FIXME: We currently only allow one open TCP connection at a time, because we depend on the loop() call in this class to * delegate to the worker. Once coroutines are implemented we can relax this restriction. + * + * The ServerAPI is built in a malloc()'d block with placement new rather than operator new: on ESP32 the framework + * is compiled with CONFIG_COMPILER_CXX_EXCEPTIONS=n and every throw is wrapped to abort(), which makes a failed + * operator new - the plain form and, because libstdc++ implements it as a try/catch around the plain form, the + * std::nothrow form too - a reboot. malloc() is the one allocation on that platform that hands back nullptr, so + * a fragmented heap drops the incoming client instead of the node. The deleter runs the destructor and free()s. */ - std::unique_ptr openAPI; + struct MallocDeleter { + void operator()(T *p) const + { + if (p) { + p->~T(); + free(p); + } + } + }; + std::unique_ptr openAPI; #if defined(RAK_4631) || defined(RAK11310) // Track wait time for RAK13800 Ethernet requests int32_t waitTime = 100; diff --git a/src/mesh/api/WiFiServerAPI.cpp b/src/mesh/api/WiFiServerAPI.cpp index 4d729f5c71..8b46a5725f 100644 --- a/src/mesh/api/WiFiServerAPI.cpp +++ b/src/mesh/api/WiFiServerAPI.cpp @@ -4,23 +4,20 @@ #if HAS_WIFI #include "WiFiServerAPI.h" -static WiFiServerPort *apiPort; +static std::unique_ptr apiPort; void initApiServer(int port) { // Start API server on port 4403 if (!apiPort) { - apiPort = new WiFiServerPort(port); + apiPort = std::make_unique(port); LOG_INFO("API server listen on TCP port %d", port); apiPort->init(); } } void deInitApiServer() { - if (apiPort) { - delete apiPort; - apiPort = nullptr; - } + apiPort.reset(); } WiFiServerAPI::WiFiServerAPI(WiFiClient &_client) : ServerAPI(_client) diff --git a/src/mesh/api/ethServerAPI.cpp b/src/mesh/api/ethServerAPI.cpp index c75d53ff7c..953c9921f0 100644 --- a/src/mesh/api/ethServerAPI.cpp +++ b/src/mesh/api/ethServerAPI.cpp @@ -5,13 +5,13 @@ #include "ethServerAPI.h" -static ethServerPort *apiPort; +static std::unique_ptr apiPort; void initApiServer(int port) { // Start API server on port 4403 if (!apiPort) { - apiPort = new ethServerPort(port); + apiPort = std::make_unique(port); LOG_INFO("API server listening on TCP port %d", port); apiPort->init(); } @@ -21,8 +21,7 @@ void deInitApiServer() { if (apiPort) { LOG_INFO("Deinit API server"); - delete apiPort; - apiPort = nullptr; + apiPort.reset(); } } diff --git a/src/mesh/eth/ethApiServer.cpp b/src/mesh/eth/ethApiServer.cpp index c7f1df6105..c27d97fe37 100644 --- a/src/mesh/eth/ethApiServer.cpp +++ b/src/mesh/eth/ethApiServer.cpp @@ -6,6 +6,7 @@ #include "ethApiHandlers.h" #include "ethApiServer.h" #include +#include #ifdef USE_ARDUINO_ETHERNET #include @@ -20,7 +21,7 @@ static constexpr int32_t ACTIVE_INTERVAL_MS = 20; static constexpr int32_t MEDIUM_INTERVAL_MS = 100; static constexpr int32_t IDLE_INTERVAL_MS = 500; -static EthernetServer *apiServer = nullptr; +static std::unique_ptr apiServer; // Adapter that exposes an EthernetClient through the transport-agnostic // IStreamReadWrite interface so the handlers in ethApiHandlers.cpp can drive @@ -86,7 +87,7 @@ void initEthApiServer() // Bind the listener (idempotent - deInitEthApiServer() drops apiServer on a // W5500 reset, and this rebinds it on the restart path). if (!apiServer) { - apiServer = new EthernetServer(ETH_API_PORT); + apiServer = std::make_unique(ETH_API_PORT); apiServer->begin(); LOG_INFO("ETH API: server listening on TCP port %d (phase 2.0, OSThread @ 20ms)", ETH_API_PORT); } @@ -103,10 +104,7 @@ void deInitEthApiServer() // A W5500 chip reset wipes the hardware socket table, so the listener is now // bound to a dead socket. Drop it (the worker stays alive and idles) so the // next initEthApiServer() from reconnectETH's restart path rebinds TCP/80. - if (apiServer) { - delete apiServer; - apiServer = nullptr; - } + apiServer.reset(); } #endif // HAS_ETHERNET && HAS_ETHERNET_API diff --git a/src/mesh/eth/ethTlsApiServer.cpp b/src/mesh/eth/ethTlsApiServer.cpp index b73cafce06..d658f0a2ae 100644 --- a/src/mesh/eth/ethTlsApiServer.cpp +++ b/src/mesh/eth/ethTlsApiServer.cpp @@ -61,6 +61,16 @@ static mbedtls_ssl_config sslConf; static mbedtls_ssl_context ssl; static bool tlsReady = false; +// Free all TLS contexts, including partially initialized ones - initTlsContext's failure +// paths must use this, because deInit's cleanup only runs once tlsReady is set. +static void freeTlsContexts() +{ + mbedtls_ssl_free(&ssl); + mbedtls_ssl_config_free(&sslConf); + mbedtls_pk_free(&pkKey); + mbedtls_x509_crt_free(&certChain); +} + // Adapter: route mbedtls_ssl_set_bio() through the EthernetClient instance // that runOnce() is currently servicing. The void* ctx we hand mbedtls is a // pointer to the EthernetClient. @@ -243,12 +253,14 @@ class EthTlsApiServerThread : public concurrency::OSThread ret = mbedtls_x509_crt_parse_der(&certChain, cert.certDer.data(), cert.certDer.size()); if (ret != 0) { LOG_ERROR("ETH TLS: x509_crt_parse_der failed -0x%04x", -ret); + freeTlsContexts(); return false; } ret = mbedtls_pk_parse_key(&pkKey, cert.keyDer.data(), cert.keyDer.size(), nullptr, 0, picoRand, nullptr); if (ret != 0) { LOG_ERROR("ETH TLS: pk_parse_key failed -0x%04x", -ret); + freeTlsContexts(); return false; } @@ -256,6 +268,7 @@ class EthTlsApiServerThread : public concurrency::OSThread MBEDTLS_SSL_PRESET_DEFAULT); if (ret != 0) { LOG_ERROR("ETH TLS: ssl_config_defaults failed -0x%04x", -ret); + freeTlsContexts(); return false; } @@ -272,12 +285,14 @@ class EthTlsApiServerThread : public concurrency::OSThread ret = mbedtls_ssl_conf_own_cert(&sslConf, &certChain, &pkKey); if (ret != 0) { LOG_ERROR("ETH TLS: conf_own_cert failed -0x%04x", -ret); + freeTlsContexts(); return false; } ret = mbedtls_ssl_setup(&ssl, &sslConf); if (ret != 0) { LOG_ERROR("ETH TLS: ssl_setup failed -0x%04x", -ret); + freeTlsContexts(); return false; } @@ -340,10 +355,7 @@ void deInitEthTlsApiServer() tlsServer = nullptr; } if (tlsReady) { - mbedtls_ssl_free(&ssl); - mbedtls_ssl_config_free(&sslConf); - mbedtls_pk_free(&pkKey); - mbedtls_x509_crt_free(&certChain); + freeTlsContexts(); tlsReady = false; } } diff --git a/src/mesh/generated/meshtastic/admin.pb.cpp b/src/mesh/generated/meshtastic/admin.pb.cpp index d029daf314..42d7fa9a23 100644 --- a/src/mesh/generated/meshtastic/admin.pb.cpp +++ b/src/mesh/generated/meshtastic/admin.pb.cpp @@ -51,6 +51,9 @@ PB_BIND(meshtastic_SHTXX_config, meshtastic_SHTXX_config, AUTO) PB_BIND(meshtastic_DS248X_config, meshtastic_DS248X_config, AUTO) +PB_BIND(meshtastic_AS3935_config, meshtastic_AS3935_config, AUTO) + + diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index 9d73b85088..ccf6f54c8b 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -368,6 +368,13 @@ typedef struct _meshtastic_DS248X_config { uint32_t main_temperature_channel; } meshtastic_DS248X_config; +typedef struct _meshtastic_AS3935_config { + /* Antenna tuning capacitance in pF, 0 to 120 in steps of 8. The antenna tank must + resonate within 3.5% of 500kHz; the correct trim is specific to the sensor board. */ + bool has_set_tuning_cap_pf; + uint32_t set_tuning_cap_pf; +} meshtastic_AS3935_config; + typedef struct _meshtastic_SensorConfig { /* SCD4X CO2 Sensor configuration */ bool has_scd4x_config; @@ -387,6 +394,9 @@ typedef struct _meshtastic_SensorConfig { /* SEN6X PM/RHT/VOC/NOx/CO2/HCHO Sensor configuration */ bool has_sen6x_config; meshtastic_SEN6X_config sen6x_config; + /* AS3935 lightning sensor configuration */ + bool has_as3935_config; + meshtastic_AS3935_config as3935_config; } meshtastic_SensorConfig; typedef PB_BYTES_ARRAY_T(8) meshtastic_AdminMessage_session_passkey_t; @@ -588,6 +598,7 @@ extern "C" { + /* Initializer values for message structs */ #define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0} @@ -597,13 +608,14 @@ extern "C" { #define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} #define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0} #define meshtastic_KeyVerificationAdmin_init_default {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0} -#define meshtastic_SensorConfig_init_default {false, meshtastic_SCD4X_config_init_default, false, meshtastic_SEN5X_config_init_default, false, meshtastic_SCD30_config_init_default, false, meshtastic_SHTXX_config_init_default, false, meshtastic_DS248X_config_init_default, false, meshtastic_SEN6X_config_init_default} +#define meshtastic_SensorConfig_init_default {false, meshtastic_SCD4X_config_init_default, false, meshtastic_SEN5X_config_init_default, false, meshtastic_SCD30_config_init_default, false, meshtastic_SHTXX_config_init_default, false, meshtastic_DS248X_config_init_default, false, meshtastic_SEN6X_config_init_default, false, meshtastic_AS3935_config_init_default} #define meshtastic_SCD4X_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SEN5X_config_init_default {false, 0, false, 0, false, 0} #define meshtastic_SEN6X_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SCD30_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SHTXX_config_init_default {false, 0} #define meshtastic_DS248X_config_init_default {false, 0} +#define meshtastic_AS3935_config_init_default {false, 0} #define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}} @@ -612,13 +624,14 @@ extern "C" { #define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} #define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0} #define meshtastic_KeyVerificationAdmin_init_zero {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0} -#define meshtastic_SensorConfig_init_zero {false, meshtastic_SCD4X_config_init_zero, false, meshtastic_SEN5X_config_init_zero, false, meshtastic_SCD30_config_init_zero, false, meshtastic_SHTXX_config_init_zero, false, meshtastic_DS248X_config_init_zero, false, meshtastic_SEN6X_config_init_zero} +#define meshtastic_SensorConfig_init_zero {false, meshtastic_SCD4X_config_init_zero, false, meshtastic_SEN5X_config_init_zero, false, meshtastic_SCD30_config_init_zero, false, meshtastic_SHTXX_config_init_zero, false, meshtastic_DS248X_config_init_zero, false, meshtastic_SEN6X_config_init_zero, false, meshtastic_AS3935_config_init_zero} #define meshtastic_SCD4X_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SEN5X_config_init_zero {false, 0, false, 0, false, 0} #define meshtastic_SEN6X_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SCD30_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SHTXX_config_init_zero {false, 0} #define meshtastic_DS248X_config_init_zero {false, 0} +#define meshtastic_AS3935_config_init_zero {false, 0} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_AdminMessage_InputEvent_event_code_tag 1 @@ -673,12 +686,14 @@ extern "C" { #define meshtastic_SCD30_config_soft_reset_tag 6 #define meshtastic_SHTXX_config_set_accuracy_tag 1 #define meshtastic_DS248X_config_main_temperature_channel_tag 1 +#define meshtastic_AS3935_config_set_tuning_cap_pf_tag 1 #define meshtastic_SensorConfig_scd4x_config_tag 1 #define meshtastic_SensorConfig_sen5x_config_tag 2 #define meshtastic_SensorConfig_scd30_config_tag 3 #define meshtastic_SensorConfig_shtxx_config_tag 4 #define meshtastic_SensorConfig_ds248x_config_tag 5 #define meshtastic_SensorConfig_sen6x_config_tag 6 +#define meshtastic_SensorConfig_as3935_config_tag 7 #define meshtastic_AdminMessage_get_channel_request_tag 1 #define meshtastic_AdminMessage_get_channel_response_tag 2 #define meshtastic_AdminMessage_get_owner_request_tag 3 @@ -886,7 +901,8 @@ X(a, STATIC, OPTIONAL, MESSAGE, sen5x_config, 2) \ X(a, STATIC, OPTIONAL, MESSAGE, scd30_config, 3) \ X(a, STATIC, OPTIONAL, MESSAGE, shtxx_config, 4) \ X(a, STATIC, OPTIONAL, MESSAGE, ds248x_config, 5) \ -X(a, STATIC, OPTIONAL, MESSAGE, sen6x_config, 6) +X(a, STATIC, OPTIONAL, MESSAGE, sen6x_config, 6) \ +X(a, STATIC, OPTIONAL, MESSAGE, as3935_config, 7) #define meshtastic_SensorConfig_CALLBACK NULL #define meshtastic_SensorConfig_DEFAULT NULL #define meshtastic_SensorConfig_scd4x_config_MSGTYPE meshtastic_SCD4X_config @@ -895,6 +911,7 @@ X(a, STATIC, OPTIONAL, MESSAGE, sen6x_config, 6) #define meshtastic_SensorConfig_shtxx_config_MSGTYPE meshtastic_SHTXX_config #define meshtastic_SensorConfig_ds248x_config_MSGTYPE meshtastic_DS248X_config #define meshtastic_SensorConfig_sen6x_config_MSGTYPE meshtastic_SEN6X_config +#define meshtastic_SensorConfig_as3935_config_MSGTYPE meshtastic_AS3935_config #define meshtastic_SCD4X_config_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, BOOL, set_asc, 1) \ @@ -946,6 +963,11 @@ X(a, STATIC, OPTIONAL, UINT32, main_temperature_channel, 1) #define meshtastic_DS248X_config_CALLBACK NULL #define meshtastic_DS248X_config_DEFAULT NULL +#define meshtastic_AS3935_config_FIELDLIST(X, a) \ +X(a, STATIC, OPTIONAL, UINT32, set_tuning_cap_pf, 1) +#define meshtastic_AS3935_config_CALLBACK NULL +#define meshtastic_AS3935_config_DEFAULT NULL + extern const pb_msgdesc_t meshtastic_AdminMessage_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_InputEvent_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_OTAEvent_msg; @@ -961,6 +983,7 @@ extern const pb_msgdesc_t meshtastic_SEN6X_config_msg; extern const pb_msgdesc_t meshtastic_SCD30_config_msg; extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; extern const pb_msgdesc_t meshtastic_DS248X_config_msg; +extern const pb_msgdesc_t meshtastic_AS3935_config_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ #define meshtastic_AdminMessage_fields &meshtastic_AdminMessage_msg @@ -978,9 +1001,11 @@ extern const pb_msgdesc_t meshtastic_DS248X_config_msg; #define meshtastic_SCD30_config_fields &meshtastic_SCD30_config_msg #define meshtastic_SHTXX_config_fields &meshtastic_SHTXX_config_msg #define meshtastic_DS248X_config_fields &meshtastic_DS248X_config_msg +#define meshtastic_AS3935_config_fields &meshtastic_AS3935_config_msg /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_ADMIN_PB_H_MAX_SIZE meshtastic_AdminMessage_size +#define meshtastic_AS3935_config_size 6 #define meshtastic_AdminMessage_InputEvent_size 14 #define meshtastic_AdminMessage_OTAEvent_size 36 #define meshtastic_AdminMessage_size 511 @@ -994,7 +1019,7 @@ extern const pb_msgdesc_t meshtastic_DS248X_config_msg; #define meshtastic_SEN5X_config_size 9 #define meshtastic_SEN6X_config_size 31 #define meshtastic_SHTXX_config_size 6 -#define meshtastic_SensorConfig_size 120 +#define meshtastic_SensorConfig_size 128 #define meshtastic_SharedContact_size 127 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index a4757b5ca2..51e43526e0 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -458,7 +458,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_BackupPreferences_size 2740 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 -#define meshtastic_NodeEnvironmentEntry_size 218 +#define meshtastic_NodeEnvironmentEntry_size 231 #define meshtastic_NodeInfoLite_size 112 #define meshtastic_NodePositionEntry_size 42 #define meshtastic_NodeStatusEntry_size 89 diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 7330143592..c59001f105 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -339,6 +339,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_HELTEC_RC52 = 142, /* Heltec ESP32C6 + SX1262 */ meshtastic_HardwareModel_HELTEC_RCC6 = 143, + /* Seeed Wio Tracker L1 Pro 1W, nRF52840 + SX1262 with 1 W external PA */ + meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1_PRO_1W = 144, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ @@ -414,6 +416,10 @@ typedef enum _meshtastic_FirmwareEdition { meshtastic_FirmwareEdition_HAMVENTION = 19, /* FAB, the international Fab Lab digital fabrication conference */ meshtastic_FirmwareEdition_FAB = 20, + /* Dragon Con, the yearly pop culture convention in Atlanta, GA */ + meshtastic_FirmwareEdition_DRAGON_CON = 21, + /* Chaos Communication Congress, the hacker conference held yearly in Germany */ + meshtastic_FirmwareEdition_CCC = 22, /* Placeholder for DIY and unofficial events */ meshtastic_FirmwareEdition_DIY_EDITION = 127 } meshtastic_FirmwareEdition; diff --git a/src/mesh/generated/meshtastic/telemetry.pb.cpp b/src/mesh/generated/meshtastic/telemetry.pb.cpp index 64cc0422f0..aa095b1a2a 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.cpp +++ b/src/mesh/generated/meshtastic/telemetry.pb.cpp @@ -36,6 +36,9 @@ PB_BIND(meshtastic_Telemetry, meshtastic_Telemetry, 2) PB_BIND(meshtastic_Nau7802Config, meshtastic_Nau7802Config, AUTO) +PB_BIND(meshtastic_AS3935Config, meshtastic_AS3935Config, AUTO) + + PB_BIND(meshtastic_SEN5XState, meshtastic_SEN5XState, AUTO) diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index 8c01688433..bfac3b038a 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -125,7 +125,9 @@ typedef enum _meshtastic_TelemetrySensorType { /* HM330X PM SENSOR */ meshtastic_TelemetrySensorType_HM330X = 55, /* Sensirion SEN6X PM/RHT/VOC/NOx/CO2/HCHO sensor family (SEN62, SEN63C, SEN65, SEN66, SEN68, SEN69C) */ - meshtastic_TelemetrySensorType_SEN6X = 56 + meshtastic_TelemetrySensorType_SEN6X = 56, + /* AS3935 Franklin lightning sensor */ + meshtastic_TelemetrySensorType_AS3935 = 57 } meshtastic_TelemetrySensorType; /* Struct definitions */ @@ -266,6 +268,12 @@ typedef struct _meshtastic_EnvironmentMetrics { /* Multi-channel One-Wire Temperature Channel 7 (*C) */ bool has_one_wire_temperature_ch7; float one_wire_temperature_ch7; + /* Lightning strikes detected in the last hour */ + bool has_lightning_strike_count_1h; + uint32_t lightning_strike_count_1h; + /* Estimated distance to the leading edge of the storm, in km */ + bool has_lightning_distance_km; + float lightning_distance_km; } meshtastic_EnvironmentMetrics; /* Power Metrics (voltage / current / etc) */ @@ -531,6 +539,13 @@ typedef struct _meshtastic_Nau7802Config { float calibrationFactor; } meshtastic_Nau7802Config; +/* AS3935 lightning sensor configuration, for saving to flash */ +typedef struct _meshtastic_AS3935Config { + /* Antenna tuning capacitance in pF, 0 to 120 in steps of 8. The chip does not retain + this across power loss, so it is stored here and re-applied on every boot. */ + uint32_t tuning_cap_pf; +} meshtastic_AS3935Config; + /* SEN5X State, for saving to flash (to be merged with SEN6XState) */ typedef struct _meshtastic_SEN5XState { /* Last cleaning time for SEN5X */ @@ -576,8 +591,9 @@ extern "C" { /* Helper constants for enums */ #define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET -#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_SEN6X -#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_SEN6X+1)) +#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_AS3935 +#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_AS3935+1)) + @@ -594,7 +610,7 @@ extern "C" { /* Initializer values for message structs */ #define meshtastic_DeviceMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -603,10 +619,11 @@ extern "C" { #define meshtastic_HostMetrics_init_default {0, 0, 0, false, 0, false, 0, 0, 0, 0, false, ""} #define meshtastic_Telemetry_init_default {0, 0, {meshtastic_DeviceMetrics_init_default}} #define meshtastic_Nau7802Config_init_default {0, 0} +#define meshtastic_AS3935Config_init_default {0} #define meshtastic_SEN5XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_SEN6XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_DeviceMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -615,6 +632,7 @@ extern "C" { #define meshtastic_HostMetrics_init_zero {0, 0, 0, false, 0, false, 0, 0, 0, 0, false, ""} #define meshtastic_Telemetry_init_zero {0, 0, {meshtastic_DeviceMetrics_init_zero}} #define meshtastic_Nau7802Config_init_zero {0, 0} +#define meshtastic_AS3935Config_init_zero {0} #define meshtastic_SEN5XState_init_zero {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_SEN6XState_init_zero {0, 0, 0, false, 0, false, 0, false, 0} @@ -662,6 +680,8 @@ extern "C" { #define meshtastic_EnvironmentMetrics_one_wire_temperature_ch5_tag 37 #define meshtastic_EnvironmentMetrics_one_wire_temperature_ch6_tag 38 #define meshtastic_EnvironmentMetrics_one_wire_temperature_ch7_tag 39 +#define meshtastic_EnvironmentMetrics_lightning_strike_count_1h_tag 40 +#define meshtastic_EnvironmentMetrics_lightning_distance_km_tag 41 #define meshtastic_PowerMetrics_ch1_voltage_tag 1 #define meshtastic_PowerMetrics_ch1_current_tag 2 #define meshtastic_PowerMetrics_ch2_voltage_tag 3 @@ -749,6 +769,7 @@ extern "C" { #define meshtastic_Telemetry_traffic_management_stats_tag 9 #define meshtastic_Nau7802Config_zeroOffset_tag 1 #define meshtastic_Nau7802Config_calibrationFactor_tag 2 +#define meshtastic_AS3935Config_tuning_cap_pf_tag 1 #define meshtastic_SEN5XState_last_cleaning_time_tag 1 #define meshtastic_SEN5XState_last_cleaning_valid_tag 2 #define meshtastic_SEN5XState_one_shot_mode_tag 3 @@ -810,7 +831,9 @@ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch3, 35) \ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch4, 36) \ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch5, 37) \ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch6, 38) \ -X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch7, 39) +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch7, 39) \ +X(a, STATIC, OPTIONAL, UINT32, lightning_strike_count_1h, 40) \ +X(a, STATIC, OPTIONAL, FLOAT, lightning_distance_km, 41) #define meshtastic_EnvironmentMetrics_CALLBACK NULL #define meshtastic_EnvironmentMetrics_DEFAULT NULL @@ -941,6 +964,11 @@ X(a, STATIC, SINGULAR, FLOAT, calibrationFactor, 2) #define meshtastic_Nau7802Config_CALLBACK NULL #define meshtastic_Nau7802Config_DEFAULT NULL +#define meshtastic_AS3935Config_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, tuning_cap_pf, 1) +#define meshtastic_AS3935Config_CALLBACK NULL +#define meshtastic_AS3935Config_DEFAULT NULL + #define meshtastic_SEN5XState_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, last_cleaning_time, 1) \ X(a, STATIC, SINGULAR, BOOL, last_cleaning_valid, 2) \ @@ -971,6 +999,7 @@ extern const pb_msgdesc_t meshtastic_HealthMetrics_msg; extern const pb_msgdesc_t meshtastic_HostMetrics_msg; extern const pb_msgdesc_t meshtastic_Telemetry_msg; extern const pb_msgdesc_t meshtastic_Nau7802Config_msg; +extern const pb_msgdesc_t meshtastic_AS3935Config_msg; extern const pb_msgdesc_t meshtastic_SEN5XState_msg; extern const pb_msgdesc_t meshtastic_SEN6XState_msg; @@ -985,14 +1014,16 @@ extern const pb_msgdesc_t meshtastic_SEN6XState_msg; #define meshtastic_HostMetrics_fields &meshtastic_HostMetrics_msg #define meshtastic_Telemetry_fields &meshtastic_Telemetry_msg #define meshtastic_Nau7802Config_fields &meshtastic_Nau7802Config_msg +#define meshtastic_AS3935Config_fields &meshtastic_AS3935Config_msg #define meshtastic_SEN5XState_fields &meshtastic_SEN5XState_msg #define meshtastic_SEN6XState_fields &meshtastic_SEN6XState_msg /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_TELEMETRY_PB_H_MAX_SIZE meshtastic_Telemetry_size +#define meshtastic_AS3935Config_size 6 #define meshtastic_AirQualityMetrics_size 157 #define meshtastic_DeviceMetrics_size 27 -#define meshtastic_EnvironmentMetrics_size 209 +#define meshtastic_EnvironmentMetrics_size 222 #define meshtastic_HealthMetrics_size 11 #define meshtastic_HostMetrics_size 264 #define meshtastic_LocalStats_size 87 diff --git a/src/mesh/http/ContentHandler.cpp b/src/mesh/http/ContentHandler.cpp index 95712403ec..ad0b9a84c3 100644 --- a/src/mesh/http/ContentHandler.cpp +++ b/src/mesh/http/ContentHandler.cpp @@ -6,6 +6,7 @@ #include "main.h" #include "mesh/http/ContentHelper.h" #include "mesh/http/WebServer.h" +#include #if HAS_WIFI #include "mesh/wifi/WiFiAPClient.h" #endif @@ -484,7 +485,7 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) // Actually we do this only for documentary purposes, we know the form is going // to be multipart/form-data. LOG_DEBUG("Form Upload - Creating body parser reference"); - HTTPBodyParser *parser; + std::unique_ptr parser; std::string contentType = req->getHeader("Content-Type"); // The content type may have additional properties after a semicolon, for example: @@ -500,7 +501,7 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) // Now, we can decide based on the content type: if (contentType == "multipart/form-data") { LOG_DEBUG("Form Upload - multipart/form-data"); - parser = new HTTPMultipartBodyParser(req); + parser.reset(new HTTPMultipartBodyParser(req)); } else { LOG_DEBUG("Unknown POST Content-Type: %s", contentType.c_str()); return; @@ -536,7 +537,6 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) if (name != "file") { LOG_DEBUG("Skip unexpected field"); res->println("

No file found.

"); - delete parser; return; } @@ -544,7 +544,6 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) if (filename == "") { LOG_DEBUG("Skip unexpected field"); res->println("

No file found.

"); - delete parser; return; } @@ -575,7 +574,6 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) // enableLoopWDT(); - delete parser; return; } @@ -596,7 +594,6 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) res->println("

Did not write any file

"); } res->println(""); - delete parser; } void handleReport(HTTPRequest *req, HTTPResponse *res) @@ -628,13 +625,18 @@ void handleReport(HTTPRequest *req, HTTPResponse *res) return s; }; - uint32_t *logArray; - logArray = airTime->airtimeReport(TX_LOG); - std::string txLog = arrayFromLog(logArray, airTime->getPeriodsToLog()); - logArray = airTime->airtimeReport(RX_LOG); - std::string rxLog = arrayFromLog(logArray, airTime->getPeriodsToLog()); - logArray = airTime->airtimeReport(RX_ALL_LOG); - std::string rxAllLog = arrayFromLog(logArray, airTime->getPeriodsToLog()); + // One constant sizes the buffer and the count, so they cannot drift. Buffer is per call, so a + // report that fails emits zeros rather than the previous type's data. + constexpr size_t periods = AirTime::getPeriodsToLog(); + auto reportFor = [&](reportTypes reportType) { + uint32_t logArray[periods] = {0}; + (void)airTime->airtimeReport(reportType, logArray, periods); + return arrayFromLog(logArray, (int)periods); + }; + + std::string txLog = reportFor(TX_LOG); + std::string rxLog = reportFor(RX_LOG); + std::string rxAllLog = reportFor(RX_ALL_LOG); String wifiIPString = WiFi.localIP().toString(); std::string wifiIP = wifiIPString.c_str(); diff --git a/src/mesh/http/WebServer.cpp b/src/mesh/http/WebServer.cpp index 84ea8fea48..8a44895241 100644 --- a/src/mesh/http/WebServer.cpp +++ b/src/mesh/http/WebServer.cpp @@ -1,6 +1,7 @@ #include "configuration.h" #if !MESHTASTIC_EXCLUDE_WEBSERVER #include "NodeDB.h" +#include "UptimeClock.h" #include "graphics/Screen.h" #include "main.h" #include "mesh/http/WebServer.h" @@ -61,8 +62,33 @@ static const uint8_t MAX_HTTPS_CONNECTIONS = 2; // Minimum free heap required for SSL handshake (~40KB for mbedTLS contexts) static const uint32_t MIN_HEAP_FOR_SSL = 40000; +// HTTPSServer that can service and reap the connections it already holds without accepting new ones, +// so a low-heap pause doesn't freeze open TLS sessions (and their heap) in place. Needs the protected table. +class MeshHTTPSServer : public HTTPSServer +{ + public: + using HTTPSServer::HTTPSServer; + + /// The first half of HTTPServer::loop(): drive and reap existing connections, accept nothing. + void serviceExistingConnections() + { + if (!_running) + return; + for (uint8_t i = 0; i < _maxConnections; i++) { + if (!_connections[i]) + continue; + if (_connections[i]->isClosed()) { + delete _connections[i]; + _connections[i] = nullptr; + } else { + _connections[i]->loop(); + } + } + } +}; + static SSLCert *cert; -static HTTPSServer *secureServer; +static MeshHTTPSServer *secureServer; static HTTPServer *insecureServer; volatile bool isWebServerReady; @@ -79,10 +105,12 @@ static void handleWebResponse() if (freeHeap >= MIN_HEAP_FOR_SSL) { secureServer->loop(); } else { - // Skip HTTPS when memory is low to prevent SSL setup failures + // Low heap: accept nothing new, but keep servicing open connections so they can time out + // and free their contexts - skipping them pins the heap below the threshold for good. + secureServer->serviceExistingConnections(); static uint32_t lastHeapWarning = 0; if (lastHeapWarning == 0 || !Throttle::isWithinTimespanMs(lastHeapWarning, 30000)) { - LOG_WARN("Low heap (%u bytes), skipping HTTPS processing", freeHeap); + LOG_WARN("Low heap (%u bytes), not accepting HTTPS connections", freeHeap); lastHeapWarning = millis(); } } @@ -191,28 +219,19 @@ WebServerThread::WebServerThread() : concurrency::OSThread("WebServer") if (!config.network.wifi_enabled && !config.network.eth_enabled) { disable(); } - lastActivityTime = millis(); + lastActivityTime = Time::getMillis(); } void WebServerThread::markActivity() { - lastActivityTime = millis(); + lastActivityTime = Time::getMillis(); } int32_t WebServerThread::getAdaptiveInterval() { - uint32_t currentTime = millis(); - uint32_t timeSinceActivity; - - if (currentTime >= lastActivityTime) { - timeSinceActivity = currentTime - lastActivityTime; - } else { - timeSinceActivity = (UINT32_MAX - lastActivityTime) + currentTime + 1; - } - - if (timeSinceActivity < ACTIVE_THRESHOLD_MS) { + if (Throttle::isWithinTimespanMs(lastActivityTime, ACTIVE_THRESHOLD_MS)) { return ACTIVE_INTERVAL_MS; - } else if (timeSinceActivity < MEDIUM_THRESHOLD_MS) { + } else if (Throttle::isWithinTimespanMs(lastActivityTime, MEDIUM_THRESHOLD_MS)) { return MEDIUM_INTERVAL_MS; } else { return IDLE_INTERVAL_MS; @@ -239,7 +258,7 @@ void initWebServer() LOG_DEBUG("Init Web Server"); // We can now use the new certificate to setup our server as usual. - secureServer = new HTTPSServer(cert, 443, MAX_HTTPS_CONNECTIONS); + secureServer = new MeshHTTPSServer(cert, 443, MAX_HTTPS_CONNECTIONS); insecureServer = new HTTPServer(); registerHandlers(insecureServer, secureServer); diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp index 9158a30611..5dae68a736 100644 --- a/src/mesh/raspihttp/PiWebServer.cpp +++ b/src/mesh/raspihttp/PiWebServer.cpp @@ -206,6 +206,10 @@ int callback_static_file(const struct _u_request *request, struct _u_response *r if (ulfius_set_stream_response(response, 200, callback_static_file_stream, callback_static_file_stream_free, length, STATIC_FILE_CHUNK, f) != U_OK) { LOG_DEBUG("callback_static_file - Error ulfius_set_stream_response"); + // The stream-free callback only runs when the stream was accepted, so the + // file must be closed here or the FILE and its fd leak on every failure. + fclose(f); + ulfius_set_string_body_response(response, 500, "Internal server error"); } } } else { @@ -256,9 +260,13 @@ int handleAPIv1ToRadio(const struct _u_request *req, struct _u_response *res, vo } byte buffer[MAX_TO_FROM_RADIO_SIZE]; - size_t s = req->binary_body_length; - - memcpy(buffer, req->binary_body, MAX_TO_FROM_RADIO_SIZE); + // ulfius allocates binary_body at exactly binary_body_length bytes (NULL for a body-less + // PUT), and the framework accepts bodies larger than our buffer, so clamp both directions. + size_t s = req->binary_body ? req->binary_body_length : 0; + if (s > sizeof(buffer)) + s = sizeof(buffer); + if (s > 0) + memcpy(buffer, req->binary_body, s); // FIXME* Problem with portdunio loosing mountpoint maybe because of running in a real sep. thread @@ -327,12 +335,11 @@ int generate_rsa_key(EVP_PKEY **pkey) EVP_PKEY_CTX *pkey_ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, NULL); if (!pkey_ctx) return -1; - if (EVP_PKEY_keygen_init(pkey_ctx) <= 0) - return -1; - if (EVP_PKEY_CTX_set_rsa_keygen_bits(pkey_ctx, 2048) <= 0) - return -1; - if (EVP_PKEY_keygen(pkey_ctx, pkey) <= 0) + if (EVP_PKEY_keygen_init(pkey_ctx) <= 0 || EVP_PKEY_CTX_set_rsa_keygen_bits(pkey_ctx, 2048) <= 0 || + EVP_PKEY_keygen(pkey_ctx, pkey) <= 0) { + EVP_PKEY_CTX_free(pkey_ctx); return -1; + } EVP_PKEY_CTX_free(pkey_ctx); return 0; // SUCCESS } @@ -413,6 +420,10 @@ int PiWebServerThread::CheckSSLandLoad() key_pem = read_file_into_string(KEY_PATH); if (key_pem == NULL) { LOG_ERROR("File private_key can't be loaded or missing"); + // The constructor retries CheckSSLandLoad() after regenerating, which would overwrite + // (and leak) the cert buffer loaded above. + free(cert_pem); + cert_pem = NULL; return 2; } @@ -432,6 +443,9 @@ int PiWebServerThread::CreateSSLCertificate() if (generate_self_signed_x509(pkey, &x509) != 0) { LOG_ERROR("Error generating X509-Cert"); + // generate_self_signed_x509 can fail after allocating *x509; X509_free(NULL) is a no-op + X509_free(x509); + EVP_PKEY_free(pkey); return 2; } @@ -439,6 +453,8 @@ int PiWebServerThread::CreateSSLCertificate() FILE *pkey_file = fopen(KEY_PATH, "wb"); if (!pkey_file) { LOG_ERROR("Error opening private key file"); + X509_free(x509); + EVP_PKEY_free(pkey); return 3; } // write private key file @@ -449,6 +465,8 @@ int PiWebServerThread::CreateSSLCertificate() FILE *x509_file = fopen(CERT_PATH, "wb"); if (!x509_file) { LOG_ERROR("Error opening cert"); + X509_free(x509); + EVP_PKEY_free(pkey); return 4; } // write certificate diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index c7fc1b25fb..8bb80cd96a 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -294,7 +294,7 @@ static int32_t reconnectWiFi() #ifndef DISABLE_NTP if (WiFi.isConnected() && (!Throttle::isWithinTimespanMs(lastrun_ntp, 43200000) || (lastrun_ntp == 0))) { // every 12 hours LOG_DEBUG("Update NTP time from %s", config.network.ntp_server); - if (timeClient.update()) { + if (timeClient.forceUpdate()) { LOG_DEBUG("NTP success - set RTCQualityNTP if needed"); struct timeval tv; @@ -316,7 +316,11 @@ static int32_t reconnectWiFi() return 1000; // check once per second } else { onNetworkConnected(); // will only do anything once (guarded by APStartupComplete) - return 300000; // every 5 minutes +#ifndef DISABLE_NTP + if (lastrun_ntp == 0) + return 5000; // NTP not yet synced, retry sooner +#endif + return 300000; // every 5 minutes } } diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 80bb799036..55b029f031 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1034,8 +1034,10 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // If we're setting region for the first time, init the region and regenerate the keys if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (crypto && !owner.is_licensed) { - crypto->ensurePkiKeys(config.security, owner); + // Minting the key moves our node num with it (my_node_num == crc32(public_key)), so + // persist devicestate + the node DB too - exactly as the licensed branch below does. + if (!owner.is_licensed && nodeDB->ensurePkiIdentity()) { + changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; } #endif // new region is valid and we're coming from an unset region, so enable tx diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 0013012751..7551ac7bbe 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -211,10 +211,13 @@ void CannedMessageModule::drawHeader(OLEDDisplay *display, int16_t x, int16_t y, snprintf(header, sizeof(header), "To: @%s", getNodeName(this->dest)); } - const int maxWidth = std::max(0, display->getWidth() - x); + // First row of text: inset horizontally by the header L/R margin and pushed down by the header margin + const int headerX = x + BASEUI_HEADER_LR_MARGIN; + const int headerY = y + BASEUI_HEADER_MARGIN; + const int maxWidth = std::max(0, display->getWidth() - headerX - BASEUI_HEADER_LR_MARGIN); char truncatedHeader[96]; graphics::UIRenderer::truncateStringWithEmotes(display, header, truncatedHeader, sizeof(truncatedHeader), maxWidth); - graphics::UIRenderer::drawStringWithEmotes(display, x, y, truncatedHeader, FONT_HEIGHT_SMALL, 1, false); + graphics::UIRenderer::drawStringWithEmotes(display, headerX, headerY, truncatedHeader, FONT_HEIGHT_SMALL, 1, false); } void CannedMessageModule::resetSearch() @@ -1475,7 +1478,9 @@ void CannedMessageModule::drawKeyboard(OLEDDisplay *display, OLEDDisplayUiState { int outerSize = *(&this->keyboard[this->charSet] + 1) - this->keyboard[this->charSet]; - int xOffset = 0; + // Inset the key grid horizontally by the body L/R margin (keeps touch aligned since + // keyForCoordinates() reads the same per-key rects stored below) + int xOffset = BASEUI_BODY_LR_MARGIN; int yOffset = 56; @@ -1485,7 +1490,8 @@ void CannedMessageModule::drawKeyboard(OLEDDisplay *display, OLEDDisplayUiState display->setColor(OLEDDISPLAY_COLOR::WHITE); - display->drawStringMaxWidth(0, 0, display->getWidth(), + // Free text being typed is the first row of text: inset by header margins + display->drawStringMaxWidth(BASEUI_HEADER_LR_MARGIN, BASEUI_HEADER_MARGIN, display->getWidth() - 2 * BASEUI_HEADER_LR_MARGIN, cannedMessageModule->drawWithCursor(cannedMessageModule->freetext, cannedMessageModule->cursor)); display->setFont(FONT_MEDIUM); @@ -1507,7 +1513,7 @@ void CannedMessageModule::drawKeyboard(OLEDDisplay *display, OLEDDisplayUiState } } - int cellWidth = display->width() / innerSize; + int cellWidth = (display->width() - 2 * BASEUI_BODY_LR_MARGIN) / innerSize; for (int8_t innerIndex = 0; innerIndex < innerSize; innerIndex++) { xOffset += innerIndex > 0 ? cellWidth : 0; @@ -1580,7 +1586,7 @@ void CannedMessageModule::drawKeyboard(OLEDDisplay *display, OLEDDisplayUiState } } - xOffset = 0; + xOffset = BASEUI_BODY_LR_MARGIN; } this->highlight = 0x00; @@ -1670,8 +1676,8 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - // Header - int titleY = 2; + // Header (first row): pushed down by the header margin; centered, so no L/R inset needed + int titleY = 2 + BASEUI_HEADER_MARGIN; String titleText = "Select Destination"; titleText += searchQuery.length() > 0 ? " [" + searchQuery + "]" : " [ ]"; display->setTextAlignment(TEXT_ALIGN_CENTER); @@ -1723,7 +1729,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O } } - int availWidth = display->getWidth() - + int availWidth = display->getWidth() - 2 * BASEUI_BODY_LR_MARGIN - ((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) - ((nodeInfoLiteIsFavorite(node)) ? 10 : 0); if (availWidth < 0) @@ -1749,12 +1755,14 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O // Highlight background (if selected) if (itemIndex == destIndex) { int scrollPadding = 8; // Reserve space for scrollbar - display->fillRect(0, yOffset + 2, display->getWidth() - scrollPadding, FONT_HEIGHT_SMALL - 5); + display->fillRect(BASEUI_BODY_LR_MARGIN, yOffset + 2, display->getWidth() - scrollPadding - 2 * BASEUI_BODY_LR_MARGIN, + FONT_HEIGHT_SMALL - 5); display->setColor(BLACK); } // Draw entry text - graphics::UIRenderer::drawStringWithEmotes(display, xOffset + 2, yOffset, entryText.c_str(), FONT_HEIGHT_SMALL, 1, false); + graphics::UIRenderer::drawStringWithEmotes(display, xOffset + 2 + BASEUI_BODY_LR_MARGIN, yOffset, entryText.c_str(), + FONT_HEIGHT_SMALL, 1, false); display->setColor(WHITE); // Draw key icon (after highlight) @@ -1783,7 +1791,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O if (totalEntries > visibleRows) { int scrollbarHeight = visibleRows * (FONT_HEIGHT_SMALL - 4); int totalScrollable = totalEntries; - int scrollTrackX = display->getWidth() - 6; + int scrollTrackX = display->getWidth() - 6 - BASEUI_BODY_LR_MARGIN; display->drawRect(scrollTrackX, rowYOffset, 4, scrollbarHeight); int scrollHeight = (scrollbarHeight * visibleRows) / totalScrollable; int scrollPos = rowYOffset + (scrollbarHeight * scrollIndex) / totalScrollable; @@ -1801,8 +1809,8 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla const int maxEmoteHeight = graphics::EmoteRenderer::maxEmoteHeight(); const int rowHeight = maxEmoteHeight + 2; - // Place header at top, then compute start of emote list - int headerY = y; + // Place header at top (pushed down by the header margin), then compute start of emote list + int headerY = y + BASEUI_HEADER_MARGIN; int listTop = headerY + headerFontHeight + headerMargin; int _visibleRows = (display->getHeight() - listTop - 2) / rowHeight; @@ -1841,12 +1849,13 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla // Draw highlight box 2px taller than emote (1px margin above and below) if (emoteIdx == emotePickerIndex) { - display->fillRect(x, rowY, display->getWidth() - 8, emote.height + 2); + display->fillRect(x + BASEUI_BODY_LR_MARGIN, rowY, display->getWidth() - 8 - 2 * BASEUI_BODY_LR_MARGIN, + emote.height + 2); display->setColor(BLACK); } // Emote bitmap (left), centered inside the row - int labelStartX = x + bitmapGapX; + int labelStartX = x + BASEUI_BODY_LR_MARGIN + bitmapGapX; const int emoteY = rowY + ((rowHeight - emote.height) / 2); display->drawXbm(labelStartX, emoteY, emote.width, emote.height, emote.bitmap); labelStartX += emote.width; @@ -1863,7 +1872,7 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla // Draw scrollbar if needed if (numEmotes > _visibleRows) { int scrollbarHeight = _visibleRows * rowHeight; - int scrollTrackX = display->getWidth() - 6; + int scrollTrackX = display->getWidth() - 6 - BASEUI_BODY_LR_MARGIN; display->drawRect(scrollTrackX, listTop, 4, scrollbarHeight); int scrollBarLen = std::max(6, (scrollbarHeight * _visibleRows) / numEmotes); int scrollBarPos = listTop + (scrollbarHeight * topIndex) / numEmotes; @@ -1900,7 +1909,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st if (this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) { display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - display->drawString(10 + x, 0 + y + FONT_HEIGHT_SMALL, "Canned Message\nModule disabled."); + display->drawString(10 + x + BASEUI_BODY_LR_MARGIN, y + FONT_HEIGHT_SMALL + BASEUI_HEADER_MARGIN, + "Canned Message\nModule disabled."); return; } @@ -1927,7 +1937,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st uint16_t charsLeft = meshtastic_Constants_DATA_PAYLOAD_LEN - this->freetext.length() - (moduleConfig.canned_message.send_bell ? 1 : 0); snprintf(buffer, sizeof(buffer), "%d left", charsLeft); - display->drawString(x + display->getWidth() - display->getStringWidth(buffer), y + 0, buffer); + display->drawString(x + display->getWidth() - display->getStringWidth(buffer) - BASEUI_HEADER_LR_MARGIN, + y + BASEUI_HEADER_MARGIN, buffer); } #if INPUTBROKER_SERIAL_TYPE == 1 @@ -2014,9 +2025,11 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st // Draw Free Text input with multi-emote support and proper line wrapping display->setColor(WHITE); { - int inputY = 0 + y + FONT_HEIGHT_SMALL; + int inputY = y + FONT_HEIGHT_SMALL + BASEUI_HEADER_MARGIN; + int inputX = x + BASEUI_BODY_LR_MARGIN; String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor); - drawWrappedEmoteText(display, x, inputY, msgWithCursor.c_str(), display->getWidth() - x, FONT_HEIGHT_SMALL); + drawWrappedEmoteText(display, inputX, inputY, msgWithCursor.c_str(), + display->getWidth() - inputX - BASEUI_BODY_LR_MARGIN, FONT_HEIGHT_SMALL); } #endif return; @@ -2037,7 +2050,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st drawHeader(display, x, y, buffer); // Shift message list upward by 3 pixels to reduce spacing between header and first message - const int listYOffset = y + FONT_HEIGHT_SMALL - 3; + // Push the list below the header margin so the body starts clear of the reserved top area + const int listYOffset = y + FONT_HEIGHT_SMALL - 3 + BASEUI_HEADER_MARGIN; _visibleRows = (display->getHeight() - listYOffset) / baseRowSpacing; // Figure out which messages are visible and their needed heights @@ -2059,16 +2073,17 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st int textYOffset = (rowHeight - FONT_HEIGHT_SMALL) / 2; #ifdef USE_EINK - int nextX = x + (_highlight ? 12 : 0); + int nextX = x + BASEUI_BODY_LR_MARGIN + (_highlight ? 12 : 0); if (_highlight) - display->drawString(x + 0, lineY + textYOffset, ">"); + display->drawString(x + BASEUI_BODY_LR_MARGIN, lineY + textYOffset, ">"); #else int scrollPadding = 8; if (_highlight) { - display->fillRect(x + 0, lineY, display->getWidth() - scrollPadding, rowHeight); + display->fillRect(x + BASEUI_BODY_LR_MARGIN, lineY, + display->getWidth() - scrollPadding - 2 * BASEUI_BODY_LR_MARGIN, rowHeight); display->setColor(BLACK); } - int nextX = x + (_highlight ? 2 : 0); + int nextX = x + BASEUI_BODY_LR_MARGIN + (_highlight ? 2 : 0); #endif if (msg && *msg) @@ -2084,7 +2099,7 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st // Scrollbar if (messagesCount > _visibleRows) { int scrollHeight = display->getHeight() - listYOffset; - int scrollTrackX = display->getWidth() - 6; + int scrollTrackX = display->getWidth() - 6 - BASEUI_BODY_LR_MARGIN; display->drawRect(scrollTrackX, listYOffset, 4, scrollHeight); int barHeight = (scrollHeight * _visibleRows) / messagesCount; int scrollPos = listYOffset + (scrollHeight * topMsg) / messagesCount; diff --git a/src/modules/DetectionSensorModule.cpp b/src/modules/DetectionSensorModule.cpp index 1de1bc184c..927fe7b1a5 100644 --- a/src/modules/DetectionSensorModule.cpp +++ b/src/modules/DetectionSensorModule.cpp @@ -128,11 +128,10 @@ int32_t DetectionSensorModule::runOnce() void DetectionSensorModule::sendDetectionMessage() { LOG_DEBUG("Detected event observed. Send message"); - char *message = new char[40]; - sprintf(message, "%s detected", moduleConfig.detection_sensor.name); + char message[40]; + snprintf(message, sizeof(message), "%s detected", moduleConfig.detection_sensor.name); meshtastic_MeshPacket *p = allocDataPacket(); if (!p) { - delete[] message; return; } p->want_ack = false; @@ -147,18 +146,18 @@ void DetectionSensorModule::sendDetectionMessage() if (!channels.isDefaultChannel(0)) { LOG_INFO("Send message id=%d, dest=%x, msg=%.*s", p->id, p->to, p->decoded.payload.size, p->decoded.payload.bytes); service->sendToMesh(p); - } else + } else { LOG_ERROR("Message not allow on Public channel"); - delete[] message; + packetPool.release(p); + } } void DetectionSensorModule::sendCurrentStateMessage(bool state) { - char *message = new char[40]; - sprintf(message, "%s state: %i", moduleConfig.detection_sensor.name, state); + char message[40]; + snprintf(message, sizeof(message), "%s state: %i", moduleConfig.detection_sensor.name, state); meshtastic_MeshPacket *p = allocDataPacket(); if (!p) { - delete[] message; return; } p->want_ack = false; @@ -168,9 +167,10 @@ void DetectionSensorModule::sendCurrentStateMessage(bool state) if (!channels.isDefaultChannel(0)) { LOG_INFO("Send message id=%d, dest=%x, msg=%.*s", p->id, p->to, p->decoded.payload.size, p->decoded.payload.bytes); service->sendToMesh(p); - } else + } else { LOG_ERROR("Message not allow on Public channel"); - delete[] message; + packetPool.release(p); + } } bool DetectionSensorModule::hasDetectionEvent() diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 546651c5bc..a2de555e90 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -223,7 +223,7 @@ void setupModules() #if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR if (moduleConfig.has_telemetry && (moduleConfig.telemetry.environment_measurement_enabled || moduleConfig.telemetry.environment_screen_enabled)) { - new EnvironmentTelemetryModule(); + environmentTelemetryModule = new EnvironmentTelemetryModule(); } #if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR if (moduleConfig.has_telemetry && diff --git a/src/modules/OnScreenKeyboardModule.cpp b/src/modules/OnScreenKeyboardModule.cpp index ae2707cfe9..3a9d498ed3 100644 --- a/src/modules/OnScreenKeyboardModule.cpp +++ b/src/modules/OnScreenKeyboardModule.cpp @@ -18,22 +18,12 @@ OnScreenKeyboardModule &OnScreenKeyboardModule::instance() return inst; } -OnScreenKeyboardModule::~OnScreenKeyboardModule() -{ - if (keyboard) { - delete keyboard; - keyboard = nullptr; - } -} +OnScreenKeyboardModule::~OnScreenKeyboardModule() = default; void OnScreenKeyboardModule::start(const char *header, const char *initialText, uint32_t durationMs, std::function cb) { - if (keyboard) { - delete keyboard; - keyboard = nullptr; - } - keyboard = new VirtualKeyboard(); + keyboard = std::make_unique(); callback = cb; if (header) keyboard->setHeader(header); @@ -50,7 +40,7 @@ void OnScreenKeyboardModule::start(const char *header, const char *initialText, }); // Maintain legacy compatibility hooks - NotificationRenderer::virtualKeyboard = keyboard; + NotificationRenderer::virtualKeyboard = keyboard.get(); NotificationRenderer::textInputCallback = callback; } @@ -58,10 +48,7 @@ void OnScreenKeyboardModule::stop(bool callEmptyCallback) { auto cb = callback; callback = nullptr; - if (keyboard) { - delete keyboard; - keyboard = nullptr; - } + keyboard.reset(); // Keep NotificationRenderer legacy pointers in sync NotificationRenderer::virtualKeyboard = nullptr; NotificationRenderer::textInputCallback = nullptr; @@ -74,7 +61,7 @@ void OnScreenKeyboardModule::handleInput(const InputEvent &event) if (!keyboard) return; - if (processVirtualKeyboardInput(event, keyboard)) + if (processVirtualKeyboardInput(event, keyboard.get())) return; if (event.inputEvent == INPUT_BROKER_CANCEL) diff --git a/src/modules/OnScreenKeyboardModule.h b/src/modules/OnScreenKeyboardModule.h index 40dc23fae1..555da432f7 100644 --- a/src/modules/OnScreenKeyboardModule.h +++ b/src/modules/OnScreenKeyboardModule.h @@ -7,6 +7,7 @@ #include "graphics/VirtualKeyboard.h" #include #include +#include #include namespace graphics @@ -34,7 +35,7 @@ class OnScreenKeyboardModule void onSubmit(const std::string &text); void onCancel(); - VirtualKeyboard *keyboard = nullptr; + std::unique_ptr keyboard; std::function callback; }; diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 9ee985b156..f11839bd75 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -71,7 +71,7 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes if (config.position.fixed_position) { LOG_DEBUG("Ignore own position update except time: position.fixed_position true"); -#ifdef T_WATCH_S3 +#if defined(T_WATCH_S3) || defined(T_WATCH_ULTRA) // Since we return early if position.fixed_position is true, set the T-Watch's RTC to the time received from the // client device here if (p.time && channels.getByIndex(mp.channel).role == meshtastic_Channel_Role_PRIMARY) { @@ -289,6 +289,27 @@ meshtastic_MeshPacket *PositionModule::allocReply() return reply; } +void PositionModule::replyOnPositionChannel(const meshtastic_MeshPacket &req) +{ + uint8_t positionChannel; + if (!findPositionChannel(positionChannel)) { + LOG_DEBUG("Skip position reply to 0x%08x: position sharing disabled on all channels", getFrom(&req)); + return; + } + if (!service) + return; + + precision = getPositionPrecisionForChannel(positionChannel); + meshtastic_MeshPacket *reply = allocReply(); // reply throttle + precision-0/no-fix guards live here + if (!reply) + return; + + setReplyTo(reply, req); + reply->channel = positionChannel; // not the channel the request came in on + LOG_INFO("Reply to position request from 0x%08x on position channel %u", getFrom(&req), positionChannel); + service->sendToMesh(reply); +} + meshtastic_MeshPacket *PositionModule::allocAtakPli() { LOG_INFO("Send TAK V2 PLI packet"); @@ -374,12 +395,11 @@ void PositionModule::sendOurPosition() currentGeneration = radioGeneration; // If we changed channels, ask everyone else for their latest info - for (uint8_t channelNum = 0; channelNum < 8; channelNum++) { - if (getPositionPrecisionForChannel(channelNum) != 0) { - LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies); - sendOurPosition(NODENUM_BROADCAST, requestReplies, channelNum); - return; - } + uint8_t positionChannel; + if (findPositionChannel(positionChannel)) { + LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies); + sendOurPosition(NODENUM_BROADCAST, requestReplies, positionChannel); + return; } LOG_INFO("Skip pos@%x:6 broadcast; position sharing disabled on all channels", localPosition.timestamp); } @@ -467,12 +487,10 @@ bool PositionModule::positionUnchangedSinceLastSend(const meshtastic_PositionLit // precision). Default nodes gauge movement at that on-wire (public-clamped) resolution; // trackers use their own configured (unclamped) precision so finer moves still count. uint32_t precisionBits = 0; - for (uint8_t ch = 0; ch < 8; ch++) { - if (getPositionPrecisionForChannel(ch) == 0) - continue; + uint8_t ch; + if (findPositionChannel(ch)) { precisionBits = useConfiguredPrecision ? getPositionPrecisionForChannel(channels.getByIndex(ch)) : getPositionPrecisionForChannel(ch); - break; } return positionWithinPrecisionCell(selfPos.latitude_i, selfPos.longitude_i, lastGpsLatitude, lastGpsLongitude, precisionBits); diff --git a/src/modules/PositionModule.h b/src/modules/PositionModule.h index 03754c22b2..c5a3d47add 100644 --- a/src/modules/PositionModule.h +++ b/src/modules/PositionModule.h @@ -36,6 +36,13 @@ class PositionModule : public ProtobufModule, private concu void sendOurPosition(NodeNum dest, bool wantReplies = false, uint8_t channel = 0); void sendOurPosition(); + /** + * Answer a position request that arrived on a channel we never share position on (the event channel): + * the reply goes out on the position channel at that channel's precision, tagged as a reply to req. + * Subject to the same reply throttle as allocReply(). No-op when no channel carries positions. + */ + void replyOnPositionChannel(const meshtastic_MeshPacket &req); + void handleNewPosition(); // Pure broadcast-policy helpers, split out so they're unit-testable without the module. diff --git a/src/modules/SystemCommandsModule.cpp b/src/modules/SystemCommandsModule.cpp index 5c4babb19a..1b31942903 100644 --- a/src/modules/SystemCommandsModule.cpp +++ b/src/modules/SystemCommandsModule.cpp @@ -86,18 +86,30 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) } switch (event->inputEvent) { - // GPS + // GPS, on its own or together with the buzzer case INPUT_BROKER_GPS_TOGGLE: + case INPUT_BROKER_PRIVACY_TOGGLE: #if !MESHTASTIC_EXCLUDE_GPS if (gps) { - if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED && - config.position.fixed_position == false) { + const bool wasEnabled = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED; + // toggleGpsMode() only moves between ENABLED and DISABLED, so leave the buzzer alone otherwise. + const bool withBuzzer = event->inputEvent == INPUT_BROKER_PRIVACY_TOGGLE && + (wasEnabled || config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED); + if (wasEnabled && config.position.fixed_position == false) { nodeDB->clearLocalPosition(); nodeDB->saveToDisk(); } + if (withBuzzer) // unmute first, so the confirmation beep is audible in both directions + config.device.buzzer_mode = meshtastic_Config_DeviceConfig_BuzzerMode_ALL_ENABLED; gps->toggleGpsMode(); - const char *msg = - (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) ? "GPS Enabled" : "GPS Disabled"; + const bool nowEnabled = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED; + if (withBuzzer) { + config.device.buzzer_mode = nowEnabled ? meshtastic_Config_DeviceConfig_BuzzerMode_ALL_ENABLED + : meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED; + nodeDB->saveToDisk(SEGMENT_CONFIG); + } + const char *msg = withBuzzer ? (nowEnabled ? "GPS + Buzzer\nEnabled" : "GPS + Buzzer\nDisabled") + : (nowEnabled ? "GPS Enabled" : "GPS Disabled"); IF_SCREEN(screen->forceDisplay(); screen->showSimpleBanner(msg, 3000);) } #endif diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index b67a18327f..2eb596bd96 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -27,6 +27,7 @@ static constexpr uint16_t TX_HISTORY_KEY_AIR_QUALITY_TELEMETRY = 0x8004; #include "Sensor/AddI2CSensorTemplate.h" #include "Sensor/PMSA003ISensor.h" #include "Sensor/SEN5XSensor.h" +#include "Sensor/SEN6XSensor.h" #if __has_include() #include "Sensor/SCD4XSensor.h" #endif @@ -66,6 +67,8 @@ void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) supportedSensors[PMSA003I_ADDR] = ScanI2C::DeviceType::PMSA003I; if (!supportedSensors.count(SEN5X_ADDR)) supportedSensors[SEN5X_ADDR] = ScanI2C::DeviceType::SEN5X; + if (!supportedSensors.count(SEN6X_ADDR)) + supportedSensors[SEN6X_ADDR] = ScanI2C::DeviceType::SEN6X; #if __has_include() if (!supportedSensors.count(SCD4X_ADDR)) supportedSensors[SCD4X_ADDR] = ScanI2C::DeviceType::SCD4X; @@ -108,6 +111,7 @@ void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) // order by priority of metrics/values (low top, high bottom) addSensor(i2cScanner, ScanI2C::DeviceType::PMSA003I); addSensor(i2cScanner, ScanI2C::DeviceType::SEN5X); + addSensor(i2cScanner, ScanI2C::DeviceType::SEN6X); #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::SCD4X); #endif diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 9a6076b281..a4143de299 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -54,7 +54,7 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/LTR390UVSensor.h" #endif -#if __has_include() || __has_include() +#if __has_include() #include "Sensor/BME680Sensor.h" #endif @@ -102,6 +102,10 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/DFRobotGravitySensor.h" #endif +#if __has_include() +#include "Sensor/AS3935Sensor.h" +#endif + #if __has_include() #include "Sensor/NAU7802Sensor.h" #endif @@ -131,6 +135,10 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/BH1750Sensor.h" #endif +#if __has_include() +#include "Sensor/ADS1X15Sensor.h" +#endif + #if __has_include() #include "Sensor/DS248XSensor.h" #endif @@ -150,6 +158,7 @@ EnvironmentTelemetryModule::DisplaySource gDisplaySource = EnvironmentTelemetryM } // namespace static constexpr uint16_t TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY = 0x8002; +static constexpr uint32_t IMMEDIATE_SEND_MAX_STALENESS_MS = 5UL * 60UL * 1000; // 5 minutes static constexpr uint32_t LOCAL_DISPLAY_REFRESH_INTERVAL_MS = 1000; EnvironmentTelemetryModule::DisplaySource EnvironmentTelemetryModule::getDisplaySource() @@ -290,6 +299,9 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::DFROBOT_RAIN); #endif +#if __has_include() + addSensor(i2cScanner, ScanI2C::DeviceType::AS3935); +#endif #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::AHT10); #endif @@ -302,7 +314,7 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::LTR390UV); #endif -#if __has_include() || __has_include() +#if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::BME_680); #endif #if __has_include() @@ -347,6 +359,10 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::BH1750); #endif +#if __has_include() + addSensor(i2cScanner, ScanI2C::DeviceType::ADS1X15); + addSensor(i2cScanner, ScanI2C::DeviceType::ADS1X15_ALT); +#endif #if __has_include() // TODO Can we scan for multiple sensors connected on the same bus? addSensor(i2cScanner, ScanI2C::DeviceType::SHTXX); @@ -426,9 +442,15 @@ int32_t EnvironmentTelemetryModule::runOnce() } refreshDisplayedMeasurement(); + // Give up on a stale immediate-send request rather than fire an arbitrarily late broadcast. + if (immediateSendRequested && + !Throttle::isWithinTimespanMs(immediateSendRequestedAtMs, IMMEDIATE_SEND_MAX_STALENESS_MS)) { + immediateSendRequested = false; + } + uint32_t lastTelemetry = transmitHistory ? transmitHistory->getLastSentToMeshMillis(TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY) : 0; - if (((lastTelemetry == 0) || + if (((lastTelemetry == 0) || immediateSendRequested || !Throttle::isWithinTimespanMs( lastTelemetry, Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.environment_update_interval, default_telemetry_broadcast_interval_secs, numOnlineNodes, @@ -436,6 +458,7 @@ int32_t EnvironmentTelemetryModule::runOnce() airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { sendTelemetry(); + immediateSendRequested = false; if (transmitHistory) transmitHistory->setLastSentToMesh(TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY); } else if (((lastSentToPhone == 0) || !Throttle::isWithinTimespanMs(lastSentToPhone, sendToPhoneIntervalMs)) && @@ -449,7 +472,8 @@ int32_t EnvironmentTelemetryModule::runOnce() if (sleepOnNextExecution) { // Honor the pre-sleep grace period armed in sendTelemetry(): OSThread reschedules with // this return value, which would otherwise override setIntervalFromNow() with the sensor - // polling interval (35 ms for BSEC2) and trigger deep sleep while the TX is still on air + // polling interval (sub-second while a BME680 reading is in flight) and trigger deep sleep + // while the TX is still on air return FIVE_SECONDS_MS; } return min(sendToPhoneIntervalMs, result); @@ -512,7 +536,7 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt const auto &m = telemetry.variant.environment_metrics; // Check if any telemetry field has valid data - bool hasAny = m.has_temperature || m.has_relative_humidity || m.barometric_pressure != 0 || m.iaq != 0 || m.voltage != 0 || + bool hasAny = m.has_temperature || m.has_relative_humidity || m.barometric_pressure != 0 || m.has_iaq || m.voltage != 0 || m.current != 0 || m.lux != 0 || m.white_lux != 0 || m.weight != 0 || m.distance != 0 || m.radiation != 0; if (!hasAny) { @@ -547,7 +571,7 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt entries.push_back("Hum: " + String(m.relative_humidity, 0) + "%"); if (m.barometric_pressure != 0) entries.push_back("Prss: " + String(m.barometric_pressure, 0) + " hPa"); - if (m.iaq != 0) { + if (m.has_iaq) { String aqi = "IAQ: " + String(m.iaq); const char *bannerMsg = nullptr; // Default: no banner @@ -759,21 +783,52 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) m.time = getTime(); bool validTelemetry = getEnvironmentTelemetry(&m); + if (validTelemetry) { - LOG_INFO("Send: barometric_pressure=%f, current=%f, gas_resistance=%f, relative_humidity=%f, temperature=%f", - m.variant.environment_metrics.barometric_pressure, m.variant.environment_metrics.current, - m.variant.environment_metrics.gas_resistance, m.variant.environment_metrics.relative_humidity, - m.variant.environment_metrics.temperature); - LOG_INFO("Send: voltage=%f, IAQ=%d, distance=%f, lux=%f", m.variant.environment_metrics.voltage, - m.variant.environment_metrics.iaq, m.variant.environment_metrics.distance, m.variant.environment_metrics.lux); + if (m.variant.environment_metrics.has_temperature || m.variant.environment_metrics.has_relative_humidity || + m.variant.environment_metrics.has_barometric_pressure) + LOG_INFO("Send: barometric_pressure=%fkPa, relative_humidity=%f%RH, temperature=%fdegC", + m.variant.environment_metrics.barometric_pressure, m.variant.environment_metrics.relative_humidity, + m.variant.environment_metrics.temperature); - LOG_INFO("Send: wind speed=%fm/s, direction=%d degrees, weight=%fkg", m.variant.environment_metrics.wind_speed, - m.variant.environment_metrics.wind_direction, m.variant.environment_metrics.weight); + if (m.variant.environment_metrics.has_voltage || m.variant.environment_metrics.has_current || + m.variant.environment_metrics.has_iaq || m.variant.environment_metrics.has_gas_resistance) + LOG_INFO("Send: voltage=%f, current=%f, IAQ=%d, gas_resistance=%f", m.variant.environment_metrics.voltage, + m.variant.environment_metrics.current, m.variant.environment_metrics.iaq, + m.variant.environment_metrics.gas_resistance); - LOG_INFO("Send: radiation=%fµR/h", m.variant.environment_metrics.radiation); + if (m.variant.environment_metrics.has_distance || m.variant.environment_metrics.has_lux) + LOG_INFO("Send: distance=%f, lux=%f", m.variant.environment_metrics.distance, m.variant.environment_metrics.lux); - LOG_INFO("Send: soil_temperature=%f, soil_moisture=%u", m.variant.environment_metrics.soil_temperature, - m.variant.environment_metrics.soil_moisture); + if (m.variant.environment_metrics.has_wind_speed || m.variant.environment_metrics.has_wind_direction) + LOG_INFO("Send: wind speed=%fm/s, direction=%d degrees", m.variant.environment_metrics.wind_speed, + m.variant.environment_metrics.wind_direction); + + if (m.variant.environment_metrics.has_weight) + LOG_INFO("Send: weight=%fkg", m.variant.environment_metrics.weight); + + if (m.variant.environment_metrics.has_radiation) + LOG_INFO("Send: radiation=%fµR/h", m.variant.environment_metrics.radiation); + + if (m.variant.environment_metrics.has_soil_temperature || m.variant.environment_metrics.has_soil_moisture) + LOG_INFO("Send: soil_temperature=%f, soil_moisture=%u", m.variant.environment_metrics.soil_temperature, + m.variant.environment_metrics.soil_moisture); + + if (m.variant.environment_metrics.has_adc_voltage_ch0 || m.variant.environment_metrics.has_adc_voltage_ch1 || + m.variant.environment_metrics.has_adc_voltage_ch2 || m.variant.environment_metrics.has_adc_voltage_ch3) + LOG_INFO("Send: adc_ch0=%f, adc_ch1=%f, adc_ch2=%f, adc_ch3=%f", m.variant.environment_metrics.adc_voltage_ch0, + m.variant.environment_metrics.adc_voltage_ch1, m.variant.environment_metrics.adc_voltage_ch2, + m.variant.environment_metrics.adc_voltage_ch3); + + if (m.variant.environment_metrics.has_adc_voltage_ch4 || m.variant.environment_metrics.has_adc_voltage_ch5 || + m.variant.environment_metrics.has_adc_voltage_ch6 || m.variant.environment_metrics.has_adc_voltage_ch7) + LOG_INFO("Send: adc_ch4=%f, adc_ch5=%f, adc_ch6=%f, adc_ch7=%f", m.variant.environment_metrics.adc_voltage_ch4, + m.variant.environment_metrics.adc_voltage_ch5, m.variant.environment_metrics.adc_voltage_ch6, + m.variant.environment_metrics.adc_voltage_ch7); + + if (m.variant.environment_metrics.has_lightning_strike_count_1h) + LOG_INFO("Send: lightning=%u, distance=%fkm", m.variant.environment_metrics.lightning_strike_count_1h, + m.variant.environment_metrics.lightning_distance_km); meshtastic_MeshPacket *p = allocDataProtobuf(m); if (!p) { @@ -809,7 +864,7 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) } // Arm the pre-sleep sequence even when no valid reading was available this cycle (e.g. a - // BSEC2 call timing violation): a power-saving SENSOR node must still return to deep sleep, + // failed sensor read): a power-saving SENSOR node must still return to deep sleep, // otherwise it stays awake until the next telemetry interval and drains its battery if (!phoneOnly && isPowerSavingSensor()) { if (!validTelemetry) diff --git a/src/modules/Telemetry/EnvironmentTelemetry.h b/src/modules/Telemetry/EnvironmentTelemetry.h index 6d5678b35a..0aabe86479 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.h +++ b/src/modules/Telemetry/EnvironmentTelemetry.h @@ -56,6 +56,14 @@ class EnvironmentTelemetryModule : private concurrency::OSThread, virtual void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) override; #endif + /** Bypass the normal broadcast throttle once, for a sensor with a noteworthy event to + * report sooner than the next scheduled send (airtime limits still apply). */ + void requestImmediateSend() + { + immediateSendRequested = true; + immediateSendRequestedAtMs = millis(); + } + protected: /** Called to handle a particular incoming message @return true if you've guaranteed you've handled this message and no other handlers should be considered for it @@ -87,6 +95,8 @@ class EnvironmentTelemetryModule : private concurrency::OSThread, bool shouldDisplayRemoteNode(NodeNum nodeNum) const; bool firstTime = 1; + bool immediateSendRequested = false; + uint32_t immediateSendRequestedAtMs = 0; meshtastic_MeshPacket *lastMeasurementPacket; uint32_t lastLocalDisplayRefreshMs = 0; uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index b00672d2d6..60fe00c381 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -272,10 +272,15 @@ bool PowerTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) m.time = getTime(); bool validTelemetry = getPowerTelemetry(&m); if (validTelemetry) { - LOG_INFO("Send: ch1_voltage=%f, ch1_current=%f, ch2_voltage=%f, ch2_current=%f, " - "ch3_voltage=%f, ch3_current=%f", - m.variant.power_metrics.ch1_voltage, m.variant.power_metrics.ch1_current, m.variant.power_metrics.ch2_voltage, - m.variant.power_metrics.ch2_current, m.variant.power_metrics.ch3_voltage, m.variant.power_metrics.ch3_current); + LOG_INFO("Send: ch1_voltage=%f, ch2_voltage=%f, ch3_voltage=%f", m.variant.power_metrics.ch1_voltage, + m.variant.power_metrics.ch2_voltage, m.variant.power_metrics.ch3_voltage); + + bool hasAnyCurrent = m.variant.power_metrics.has_ch1_current || m.variant.power_metrics.has_ch2_current || + m.variant.power_metrics.has_ch3_current; + if (hasAnyCurrent) { + LOG_INFO("Send: ch1_current=%f, ch2_current=%f, ch3_current=%f", m.variant.power_metrics.ch1_current, + m.variant.power_metrics.ch2_current, m.variant.power_metrics.ch3_current); + } sensor_read_error_count = 0; diff --git a/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp b/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp new file mode 100644 index 0000000000..1a8e923f1b --- /dev/null +++ b/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp @@ -0,0 +1,168 @@ +#include "configuration.h" + +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "ADS1X15Sensor.h" +#include "TelemetrySensor.h" +#include + +ADS1X15Sensor::ADS1X15Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_ADS1X15, "ADS1X15") {} + +bool ADS1X15Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + LOG_INFO("Init sensor: %s (address: 0x%x)", sensorName, dev->address.address); + + _bus = bus; + + _address = dev->address.address; + _deviceType = dev->type; + +#ifdef ADS1X15_I2C_CLOCK_SPEED + _port = dev->address.port; + reClockI2C.setup(_bus, _port); + ReClockI2CGuard clockGuard(reClockI2C, ADS1X15_I2C_CLOCK_SPEED); +#endif /* ADS1X15_I2C_CLOCK_SPEED */ + + status = ads1x15.begin(_address, _bus); + + initI2CSensor(); + + return status; +} + +struct _ADS1X15Measurement ADS1X15Sensor::getMeasurement(uint8_t ch) +{ + struct _ADS1X15Measurement measurement; + + // Reset gain + ads1x15.setGain(GAIN_TWOTHIRDS); + double voltage_range = 6.144; + + // Get value with full range + uint16_t value = ads1x15.readADC_SingleEnded(ch); + + // Dynamic gain, to increase resolution of low voltage values + // If value is under 4.096v increase the gain depending on voltage + if (value < 21845) { + if (value > 10922) { + + // 1x gain, 4.096V + ads1x15.setGain(GAIN_ONE); + voltage_range = 4.096; + + } else if (value > 5461) { + + // 2x gain, 2.048V + ads1x15.setGain(GAIN_TWO); + voltage_range = 2.048; + + } else if (value > 2730) { + + // 4x gain, 1.024V + ads1x15.setGain(GAIN_FOUR); + voltage_range = 1.024; + + } else if (value > 1365) { + + // 8x gain, 0.25V + ads1x15.setGain(GAIN_EIGHT); + voltage_range = 0.512; + + } else { + + // 16x gain, 0.125V + ads1x15.setGain(GAIN_SIXTEEN); + voltage_range = 0.256; + } + + // Get the value again + value = ads1x15.readADC_SingleEnded(ch); + } + + measurement.voltage = (float)value / 32768 * voltage_range; + + return measurement; +} + +struct _ADS1X15Measurements ADS1X15Sensor::getMeasurements() +{ + struct _ADS1X15Measurements measurements; + + // ADS1X15 has 4 channels starting from 0 + for (int i = 0; i < 4; i++) { + measurements.measurements[i] = getMeasurement(i); + } + + return measurements; +} + +bool ADS1X15Sensor::getMetrics(meshtastic_Telemetry *measurement) +{ + // Done here and not in getMeasurements to avoid the back-and-forth 4-8 times one after the other +#ifdef ADS1X15_I2C_CLOCK_SPEED + ReClockI2CGuard clockGuard(reClockI2C, ADS1X15_I2C_CLOCK_SPEED); +#endif /* ADS1X15_I2C_CLOCK_SPEED */ + + struct _ADS1X15Measurements m = getMeasurements(); + + switch (_deviceType) { + case ScanI2C::DeviceType::ADS1X15: { + measurement->variant.environment_metrics.has_adc_voltage_ch0 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch1 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch2 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch3 = true; + + measurement->variant.environment_metrics.adc_voltage_ch0 = m.measurements[0].voltage; + measurement->variant.environment_metrics.adc_voltage_ch1 = m.measurements[1].voltage; + measurement->variant.environment_metrics.adc_voltage_ch2 = m.measurements[2].voltage; + measurement->variant.environment_metrics.adc_voltage_ch3 = m.measurements[3].voltage; + + LOG_DEBUG( + "Got %s readings: adc_voltage_ch0=%f, adc_voltage_ch1=%f, adc_voltage_ch2=%f, adc_voltage_ch3=%f", sensorName, + measurement->variant.environment_metrics.adc_voltage_ch0, measurement->variant.environment_metrics.adc_voltage_ch1, + measurement->variant.environment_metrics.adc_voltage_ch2, measurement->variant.environment_metrics.adc_voltage_ch3); + + break; + } + case ScanI2C::DeviceType::ADS1X15_ALT: { + measurement->variant.environment_metrics.has_adc_voltage_ch4 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch5 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch6 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch7 = true; + + measurement->variant.environment_metrics.adc_voltage_ch4 = m.measurements[0].voltage; + measurement->variant.environment_metrics.adc_voltage_ch5 = m.measurements[1].voltage; + measurement->variant.environment_metrics.adc_voltage_ch6 = m.measurements[2].voltage; + measurement->variant.environment_metrics.adc_voltage_ch7 = m.measurements[3].voltage; + + LOG_DEBUG( + "Got %s readings: adc_voltage_ch4=%f, adc_voltage_ch5=%f, adc_voltage_ch6=%f, adc_voltage_ch7=%f", sensorName, + measurement->variant.environment_metrics.adc_voltage_ch4, measurement->variant.environment_metrics.adc_voltage_ch5, + measurement->variant.environment_metrics.adc_voltage_ch6, measurement->variant.environment_metrics.adc_voltage_ch7); + + break; + } + default: { + measurement->variant.environment_metrics.has_adc_voltage_ch0 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch1 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch2 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch3 = true; + + measurement->variant.environment_metrics.adc_voltage_ch0 = m.measurements[0].voltage; + measurement->variant.environment_metrics.adc_voltage_ch1 = m.measurements[1].voltage; + measurement->variant.environment_metrics.adc_voltage_ch2 = m.measurements[2].voltage; + measurement->variant.environment_metrics.adc_voltage_ch3 = m.measurements[3].voltage; + + LOG_DEBUG( + "Got %s readings: adc_voltage_ch0=%f, adc_voltage_ch1=%f, adc_voltage_ch2=%f, adc_voltage_ch3=%f", sensorName, + measurement->variant.environment_metrics.adc_voltage_ch0, measurement->variant.environment_metrics.adc_voltage_ch1, + measurement->variant.environment_metrics.adc_voltage_ch2, measurement->variant.environment_metrics.adc_voltage_ch3); + + break; + } + } + return true; +} + +#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/ADS1X15Sensor.h b/src/modules/Telemetry/Sensor/ADS1X15Sensor.h new file mode 100644 index 0000000000..4d56e2db71 --- /dev/null +++ b/src/modules/Telemetry/Sensor/ADS1X15Sensor.h @@ -0,0 +1,52 @@ +#include "configuration.h" + +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../detect/ReClockI2C.h" +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "TelemetrySensor.h" +#include + +#define ADS1X15_I2C_CLOCK_SPEED 100000 +// ADS1X15 has no practical way to be detected. Use this to toggle +// between ADS1015 (0) or ADS1115 (1) +#ifndef MESHTASTIC_ADC_ADS1115 +#define MESHTASTIC_ADC_ADS1115 1 +#endif + +class ADS1X15Sensor : public TelemetrySensor +{ + private: +#if MESHTASTIC_ADC_ADS1115 + Adafruit_ADS1115 ads1x15{}; +#else + Adafruit_ADS1015 ads1x15{}; +#endif + +#ifdef ADS1X15_I2C_CLOCK_SPEED + ReClockI2C reClockI2C; +#endif + ScanI2C::DeviceType _deviceType{}; + + // get a single measurement for a channel + struct _ADS1X15Measurement getMeasurement(uint8_t ch); + + // get all measurements for all channels + struct _ADS1X15Measurements getMeasurements(); + + public: + ADS1X15Sensor(); + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; +}; + +struct _ADS1X15Measurement { + float voltage; +}; + +struct _ADS1X15Measurements { + // ADS1X15 has 4 channels + struct _ADS1X15Measurement measurements[4]; +}; + +#endif diff --git a/src/modules/Telemetry/Sensor/AS3935Sensor.cpp b/src/modules/Telemetry/Sensor/AS3935Sensor.cpp new file mode 100644 index 0000000000..e8790fd929 --- /dev/null +++ b/src/modules/Telemetry/Sensor/AS3935Sensor.cpp @@ -0,0 +1,225 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "AS3935Sensor.h" +#include "FSCommon.h" +#include "SPILock.h" +#include "SafeFile.h" +#include "TelemetrySensor.h" +#include "modules/Telemetry/EnvironmentTelemetry.h" +#include +#include +#include + +namespace +{ +// No attachInterrupt(): the interrupt latches until read, so polling can't miss it, and the +// I2C read itself isn't ISR-safe anyway. AS3935_IRQ is optional - see runOnce(). +constexpr int32_t AS3935_CHECK_INTERVAL_MS = DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; +constexpr uint8_t AS3935_DISTANCE_OUT_OF_RANGE = 0x3F; +} // namespace + +// Fallback until an admin message sets one; 96pF is DFRobot's value for the SEN0290. +#ifndef AS3935_TUNING_CAP_PF +#define AS3935_TUNING_CAP_PF 96 +#endif +static_assert(AS3935_TUNING_CAP_PF % 8 == 0 && AS3935_TUNING_CAP_PF <= 120, + "AS3935_TUNING_CAP_PF must be a multiple of 8, at most 120 - tuneCap() silently ignores other values"); + +AS3935Sensor::AS3935Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_AS3935, "AS3935") {} + +AS3935Sensor::~AS3935Sensor() +{ + if (lightning) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdelete-non-virtual-dtor" + delete lightning; +#pragma GCC diagnostic pop + lightning = nullptr; + } +} + +bool AS3935Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + LOG_INFO("Init sensor: %s", sensorName); + + lightning = new SparkFun_AS3935(dev->address.address); + status = lightning->begin(*bus); + if (!status) { + initI2CSensor(); + return status; + } + + // Oscillators are tuned to the antenna resonance; calibration affects strike detection thresholds. + if (!lightning->calibrateOsc()) { + LOG_WARN("%s: oscillator calibration failed", sensorName); + } + + // Defaults match the library's own example, except outdoor mode. Disturbers are masked in + // the chip - runOnce() polls every second, so an unmasked noisy site never goes quiet. + lightning->setIndoorOutdoor(OUTDOOR); + lightning->setNoiseLevel(2); + lightning->watchdogThreshold(2); + lightning->spikeRejection(2); + lightning->maskDisturber(true); + lightning->lightningThreshold(1); + + // Applied last: the RCO calibration above uses the antenna oscillator as its reference. + if (!loadCalibrationData()) + as3935config.tuning_cap_pf = AS3935_TUNING_CAP_PF; + if (!setTuningCap(as3935config.tuning_cap_pf)) { + LOG_WARN("%s: bad stored cap %upF", sensorName, as3935config.tuning_cap_pf); + setTuningCap(AS3935_TUNING_CAP_PF); + } + +#ifdef AS3935_IRQ + pinMode(AS3935_IRQ, INPUT); +#endif + // Drain anything already latched, so we don't report a strike that predates us. + lightning->readInterruptReg(); + + initI2CSensor(); + return status; +} + +int32_t AS3935Sensor::runOnce() +{ +#ifdef AS3935_IRQ + // IRQ wired: only spend an I2C transaction once the pin says something is latched. + if (digitalRead(AS3935_IRQ) == HIGH) { + classifyPendingIrq(); + } +#else + // I2C-only breakout: poll the register instead, it reads back 0 when nothing is pending. + classifyPendingIrq(); +#endif + return AS3935_CHECK_INTERVAL_MS; +} + +void AS3935Sensor::classifyPendingIrq() +{ + uint8_t interruptReason = lightning->readInterruptReg(); + switch (interruptReason) { + case LIGHTNING: { + strikes.add(); + uint8_t distance = lightning->distanceToStorm(); + if (distance != AS3935_DISTANCE_OUT_OF_RANGE) { + lastDistanceKm = distance; + LOG_INFO("%s: strike %dkm", sensorName, distance); + } else { + LOG_INFO("%s: strike, distance unknown", sensorName); + } + // No debounce here - EnvironmentTelemetryModule's airtime gate already paces every send. + if (environmentTelemetryModule) { + environmentTelemetryModule->requestImmediateSend(); + } + break; + } + case NOISE_TO_HIGH: + LOG_DEBUG("%s: noise floor high", sensorName); + break; + default: + break; + } +} + +bool AS3935Sensor::setTuningCap(uint32_t pf) +{ + if (pf > 120 || pf % 8 != 0) + return false; + + lightning->tuneCap(pf); + as3935config.tuning_cap_pf = pf; + // Readback, not pf: the only evidence the register write actually landed. + LOG_INFO("%s: tuning cap %upF", sensorName, lightning->readTuneCap()); + return true; +} + +AdminMessageHandleResult AS3935Sensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) +{ + AdminMessageHandleResult result; + result = AdminMessageHandleResult::NOT_HANDLED; + + switch (request->which_payload_variant) { + case meshtastic_AdminMessage_sensor_config_tag: + if (!request->sensor_config.has_as3935_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + + if (request->sensor_config.as3935_config.has_set_tuning_cap_pf) { + uint32_t pf = request->sensor_config.as3935_config.set_tuning_cap_pf; + if (!setTuningCap(pf)) { + LOG_ERROR("%s: bad cap %upF", sensorName, pf); + } else if (!saveCalibrationData()) { + LOG_WARN("%s: save failed", sensorName); + } + } + + result = AdminMessageHandleResult::HANDLED; + break; + + default: + result = AdminMessageHandleResult::NOT_HANDLED; + } + + return result; +} + +bool AS3935Sensor::saveCalibrationData() +{ + auto file = SafeFile(as3935ConfigFileName); + bool okay = false; + + LOG_INFO("%s state write to %s", sensorName, as3935ConfigFileName); + pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_AS3935Config_size}; + + if (!pb_encode(&stream, &meshtastic_AS3935Config_msg, &as3935config)) { + LOG_ERROR("Can't encode protobuf %s", PB_GET_ERROR(&stream)); + } else { + okay = true; + } + // Note: SafeFile::close() already acquires the lock and releases it internally + okay &= file.close(); + + return okay; +} + +bool AS3935Sensor::loadCalibrationData() +{ + spiLock->lock(); + auto file = FSCom.open(as3935ConfigFileName, FILE_O_READ); + bool okay = false; + if (file) { + LOG_INFO("%s state read from %s", sensorName, as3935ConfigFileName); + pb_istream_t stream = {&readcb, &file, meshtastic_AS3935Config_size}; + if (!pb_decode(&stream, &meshtastic_AS3935Config_msg, &as3935config)) { + LOG_ERROR("Can't decode protobuf %s", PB_GET_ERROR(&stream)); + } else { + okay = true; + } + file.close(); + } else { + LOG_INFO("No %s state found (File: %s)", sensorName, as3935ConfigFileName); + } + spiLock->unlock(); + return okay; +} + +bool AS3935Sensor::getMetrics(meshtastic_Telemetry *measurement) +{ + uint32_t count = strikes.sum(); + measurement->variant.environment_metrics.has_lightning_strike_count_1h = true; + measurement->variant.environment_metrics.lightning_strike_count_1h = count; + // The distance belongs to the newest strike, so it expires when that strike leaves the window. + if (count && lastDistanceKm >= 0) { + measurement->variant.environment_metrics.has_lightning_distance_km = true; + measurement->variant.environment_metrics.lightning_distance_km = lastDistanceKm; + } + return true; +} + +#endif diff --git a/src/modules/Telemetry/Sensor/AS3935Sensor.h b/src/modules/Telemetry/Sensor/AS3935Sensor.h new file mode 100644 index 0000000000..37e25ab403 --- /dev/null +++ b/src/modules/Telemetry/Sensor/AS3935Sensor.h @@ -0,0 +1,44 @@ +#pragma once + +#ifndef _MT_AS3935SENSOR_H +#define _MT_AS3935SENSOR_H +#include "MeshModule.h" +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "RollingCounter.h" +#include "TelemetrySensor.h" +#include + +class AS3935Sensor : public TelemetrySensor +{ + private: + SparkFun_AS3935 *lightning = nullptr; + RollingCounter<60UL * 60 * 1000, 5UL * 60 * 1000> strikes; + float lastDistanceKm = -1; // sentinel: no valid distance captured yet + + void classifyPendingIrq(); + + protected: + const char *as3935ConfigFileName = "/prefs/as3935.dat"; + meshtastic_AS3935Config as3935config = meshtastic_AS3935Config_init_zero; + bool saveCalibrationData(); + bool loadCalibrationData(); + + public: + AS3935Sensor(); + ~AS3935Sensor(); + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + virtual int32_t runOnce() override; + // Antenna trim in pF. Rejects anything but a multiple of 8 up to 120, which + // tuneCap() would silently ignore. + bool setTuningCap(uint32_t pf); + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; +}; + +#endif +#endif diff --git a/src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp b/src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp new file mode 100644 index 0000000000..89a792df6d --- /dev/null +++ b/src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp @@ -0,0 +1,94 @@ +#include "BME680IaqEstimator.h" + +// std::clamp rather than meshUtils.h's clamp: that header drags in Arduino.h, +// and this file must stay compilable standalone on a dev host (see the replay +// harness in bin/bme680_iaq_replay.cpp) +#include +#include +#include + +bool BME680IaqEstimator::update(float gasOhms, float relativeHumidity, uint16_t *iaqOut) +{ + if (!(isfinite(gasOhms) && gasOhms > 0.0f)) + return false; + + // A failed humidity read must not poison the baseline: fall back to the + // reference, which makes both compensation terms no-ops + float rh = isfinite(relativeHumidity) ? std::clamp(relativeHumidity, 0.0f, 100.0f) : RH_REF; + + if (warmupRemaining > 0) { + warmupRemaining--; + return false; + } + + float x = logf(gasOhms) + KH * (rh - RH_REF); + x = std::clamp(x, LN_FLOOR - LN_RANGE, LN_CEIL_MAX); + + if (!seeded) { + lnCeiling = std::clamp(x, LN_FLOOR, LN_CEIL_MAX); + seeded = true; + } else { + float alpha = (x > lnCeiling) ? ALPHA_UP : ALPHA_DOWN; + lnCeiling = std::clamp(lnCeiling + alpha * (x - lnCeiling), LN_FLOOR, LN_CEIL_MAX); + } + + if (sampleCount < UINT32_MAX) + sampleCount++; + if (sampleCount < BURN_IN_SAMPLES) + return false; + + float below = lnCeiling - x; + if (below < 0.0f) + below = 0.0f; + float gasScore = std::clamp(below / LN_RANGE, 0.0f, 1.0f) * 500.0f; + + // Comfort-band penalty: only outside the band, so ordinary indoor humidity + // can't keep IAQ away from the "Excellent" band + float humDeviation = rh < RH_COMFORT_MIN ? RH_COMFORT_MIN - rh : (rh > RH_COMFORT_MAX ? rh - RH_COMFORT_MAX : 0.0f); + float humScore = std::clamp(humDeviation / RH_DEV_NORM, 0.0f, 1.0f) * 500.0f; + + *iaqOut = (uint16_t)lroundf(std::clamp(gasScore + HUM_WEIGHT * humScore, 0.0f, 500.0f)); + return true; +} + +uint32_t BME680IaqEstimator::computeHash(const BME680IaqState &s) +{ + uint32_t words[5]; + memcpy(words, &s, sizeof(words)); + return words[0] ^ words[1] ^ words[2] ^ words[3] ^ words[4]; +} + +void BME680IaqEstimator::serialize(BME680IaqState *out, uint32_t nowSecs) const +{ + memset(out, 0, sizeof(*out)); + out->magic = MAGIC; + out->version = VERSION; + out->warmupRemaining = (uint8_t)warmupRemaining; + out->lnCeiling = lnCeiling; + out->savedAtSecs = nowSecs; + out->sampleCount = sampleCount; + out->xorHash = computeHash(*out); +} + +bool BME680IaqEstimator::restore(const BME680IaqState &in, uint32_t nowSecs) +{ + if (in.magic != MAGIC || in.version != VERSION) + return false; + if (in.xorHash != computeHash(in)) + return false; + // The ceiling only exists once a sample has been accepted (sampleCount > 0); + // pure warm-up progress is persisted with lnCeiling still at 0 + bool hasBaseline = in.sampleCount > 0; + if (hasBaseline && !(isfinite(in.lnCeiling) && in.lnCeiling >= LN_FLOOR && in.lnCeiling <= LN_CEIL_MAX)) + return false; + // Staleness is only judgeable when the state was stamped with a valid RTC + // and we have one now; a week-old baseline says nothing about today's air + if (in.savedAtSecs != 0 && nowSecs != 0 && nowSecs >= in.savedAtSecs && (nowSecs - in.savedAtSecs) > STATE_MAX_AGE_SECS) + return false; + + lnCeiling = in.lnCeiling; + sampleCount = in.sampleCount; + warmupRemaining = in.warmupRemaining <= WARMUP_DISCARD ? in.warmupRemaining : WARMUP_DISCARD; + seeded = hasBaseline; + return true; +} diff --git a/src/modules/Telemetry/Sensor/BME680IaqEstimator.h b/src/modules/Telemetry/Sensor/BME680IaqEstimator.h new file mode 100644 index 0000000000..87f19243c9 --- /dev/null +++ b/src/modules/Telemetry/Sensor/BME680IaqEstimator.h @@ -0,0 +1,104 @@ +#pragma once + +#include + +/** + * Persisted estimator state, written to /prefs/bme680.dat via SafeFile. + * Fixed 24-byte little-endian layout; xorHash covers the five preceding words + * as a semantic guard on top of SafeFile's write-path hash. + */ +struct BME680IaqState { + uint32_t magic; + uint8_t version; + uint8_t warmupRemaining; + uint8_t reserved[2]; + float lnCeiling; + uint32_t savedAtSecs; // RTC epoch at save; 0 if no valid RTC + uint32_t sampleCount; + uint32_t xorHash; +}; + +static_assert(sizeof(BME680IaqState) == 24, "BME680IaqState layout must stay fixed for on-disk compatibility"); + +/** + * Clean-room IAQ estimator for the BME680/BME688 gas sensor (replaces the + * proprietary Bosch BSEC library). + * + * VOC exposure lowers the sensor's gas resistance. We track a rolling ceiling + * of humidity-compensated log-resistance ("cleanest air seen recently") and + * score each sample by its log-distance below that ceiling, mapped onto the + * 0-500 scale the UI already bands (<=25 Excellent ... >300 Hazardous). + * + * Warm-up and burn-in progress are part of the persisted state: a deep-sleep + * SENSOR node that takes one sample per wake (RAM wiped in between) still + * converges by restoring and re-serializing across reboots. + * + * Pure math on purpose: no Arduino, filesystem, or clock dependencies, so the + * whole thing is unit-testable on the native host (test_bme680_iaq). + */ +class BME680IaqEstimator +{ + public: + static constexpr uint32_t MAGIC = 0x42494151; // 'BIAQ' + static constexpr uint8_t VERSION = 1; + + // Tunables, centralized for the hardware-soak stage. Physical rationale: + // KH: gas resistance falls roughly exp(-0.035 * %RH); compensate to a 40 %RH reference + // ALPHA_UP/DOWN: ceiling rises fast toward cleaner air, decays with a ~12 h time + // constant at one sample per minute so pollution episodes don't become "normal" + // LN_FLOOR: baseline can't sit below ln(5 kOhm), the heavily-polluted end of the range + // LN_CEIL_MAX: sanity bound only -- fresh/very clean sensors legitimately read + // 1-13 MOhm (Bosch specs to 50 MOhm), so this sits far above at ln(~100 MOhm) + // LN_RANGE: gas at 1/15 of the baseline maps to IAQ 500 + static constexpr float KH = 0.035f; + static constexpr float ALPHA_UP = 0.25f; + static constexpr float ALPHA_DOWN = 1.0f / 720.0f; + static constexpr float LN_FLOOR = 8.517193f; // ln(5000) + static constexpr float LN_CEIL_MAX = 18.4f; // ln(~1e8) + static constexpr float LN_RANGE = 2.7080502f; // ln(15) + static constexpr float HUM_WEIGHT = 0.15f; + // RH_REF: the KH compensation reference, and the fallback for failed humidity reads + // RH_COMFORT_MIN/MAX: no humidity penalty inside this band + // RH_DEV_NORM: deviation that earns the full penalty (== 100 - RH_COMFORT_MAX; the dry + // side's maximum deviation is only RH_COMFORT_MIN, so it intentionally caps at 75%) + static constexpr float RH_REF = 40.0f; + static constexpr float RH_COMFORT_MIN = 30.0f; + static constexpr float RH_COMFORT_MAX = 60.0f; + static constexpr float RH_DEV_NORM = 40.0f; + static constexpr uint32_t WARMUP_DISCARD = 3; // first-ever samples, while the heater element settles + static constexpr uint32_t BURN_IN_SAMPLES = 30; // no output until the baseline has this much history + static constexpr uint32_t STATE_MAX_AGE_SECS = 7 * 24 * 60 * 60; // a week-old baseline says nothing about today's air + + /** + * Feed one sample. Returns true and writes *iaqOut (0-500) once the + * estimator has enough history; returns false during warm-up/burn-in or + * for invalid readings. + */ + bool update(float gasOhms, float relativeHumidity, uint16_t *iaqOut); + + /// Burn-in complete: output is available + bool ready() const { return sampleCount >= BURN_IN_SAMPLES; } + + // Progress accessors, used by the sensor to decide when persisting is worthwhile + uint32_t samplesFed() const { return sampleCount; } + uint32_t warmupLeft() const { return warmupRemaining; } + + void serialize(BME680IaqState *out, uint32_t nowSecs) const; + + /** + * Adopt persisted state, including warm-up/burn-in progress (warm-up is + * NOT re-armed: the persisted counters are the source of truth). Returns + * false and leaves the estimator untouched on magic, version, hash, or + * range mismatch, or if the state is older than STATE_MAX_AGE_SECS (only + * checkable when both timestamps are valid). + */ + bool restore(const BME680IaqState &in, uint32_t nowSecs); + + private: + static uint32_t computeHash(const BME680IaqState &s); + + float lnCeiling = 0.0f; + uint32_t sampleCount = 0; // samples fed to the baseline (excludes warm-up discards) + uint32_t warmupRemaining = WARMUP_DISCARD; + bool seeded = false; +}; diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.cpp b/src/modules/Telemetry/Sensor/BME680Sensor.cpp index 107601267e..9162a93212 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.cpp +++ b/src/modules/Telemetry/Sensor/BME680Sensor.cpp @@ -1,58 +1,25 @@ #include "configuration.h" -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && (__has_include() || __has_include()) +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() #include "../mesh/generated/meshtastic/telemetry.pb.h" #include "BME680Sensor.h" #include "FSCommon.h" #include "SPILock.h" +#include "SafeFile.h" #include "TelemetrySensor.h" #include "UptimeClock.h" +#include "gps/RTC.h" #include "mesh/Throttle.h" -#if __has_include() -#include -#endif +#include BME680Sensor::BME680Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_BME680, "BME680") {} -#if __has_include() -int32_t BME680Sensor::runOnce() -{ - if (!bme680.run()) { - checkStatus("runTrigger"); - } - return 35; -} -#endif - bool BME680Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) { status = 0; -#if __has_include() - if (!bme680.begin(dev->address.address, *bus)) - checkStatus("begin"); - - if (bme680.status == BSEC_OK) { - status = 1; - if (!bme680.setConfig(bsec_config)) { - checkStatus("setConfig"); - status = 0; - } - loadState(); - if (!bme680.updateSubscription(sensorList, ARRAY_LEN(sensorList), BSEC_SAMPLE_RATE_LP)) { - checkStatus("updateSubscription"); - status = 0; - } - LOG_INFO("Init sensor: %s with the BSEC Library version %d.%d.%d.%d ", sensorName, bme680.version.major, - bme680.version.minor, bme680.version.major_bugfix, bme680.version.minor_bugfix); - } - - if (status == 0) - LOG_DEBUG("BME680Sensor::runOnce: bme680.status %d", bme680.status); - -#else bme680 = makeBME680(bus); if (!bme680->begin(dev->address.address)) { @@ -60,154 +27,204 @@ bool BME680Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) return status; } - status = 1; + // Acquisition profile, stated explicitly (these match the library defaults): + // the heater setting determines power draw, ~0.25% duty at one sample/min + bme680->setTemperatureOversampling(BME680_OS_8X); + bme680->setHumidityOversampling(BME680_OS_2X); + bme680->setPressureOversampling(BME680_OS_4X); + bme680->setIIRFilterSize(BME680_FILTER_SIZE_3); + bme680->setGasHeater(320, 150); // 320 degC for 150 ms -#endif + status = 1; + loadState(); + LOG_INFO("Init sensor: %s (open IAQ estimator)", sensorName); initI2CSensor(); return status; } +int32_t BME680Sensor::runOnce() +{ + uint32_t now = Time::getMillis(); + + if (readingInFlight) { + if (!Throttle::deadlinePassedAt(now, readingDoneAtMs)) + return readingDoneAtMs - now; + captureSample(); + return SAMPLE_INTERVAL_MS; + } + + if (haveSample && Throttle::isWithinTimespanMs(lastSampleMs, SAMPLE_INTERVAL_MS)) + return SAMPLE_INTERVAL_MS - (now - lastSampleMs); + + uint32_t doneAt = bme680->beginReading(); + if (doneAt == 0) { + LOG_WARN("%s beginReading() failed", sensorName); + return SAMPLE_INTERVAL_MS; + } + readingInFlight = true; + readingDoneAtMs = doneAt; + return Throttle::deadlinePassedAt(now, doneAt) ? 1 : (int32_t)(doneAt - now); +} + +/// Complete the reading (in flight or synchronous), feed the estimator, refresh the cache +void BME680Sensor::captureSample() +{ + readingInFlight = false; + // endReading() completes the in-flight conversion, or starts and finishes + // a fresh one when none is pending (performReading() is an alias for it in + // Adafruit_BME680; a failed first call resets the conversion, so the second + // call is a genuine one-shot retry). Worst case each call waits ~2x the + // remaining TPHG cycle, so a synchronous read costs a few hundred ms. + if (!bme680->endReading() && !bme680->performReading()) { + LOG_WARN("%s reading failed", sensorName); + return; + } + + lastTemperature = bme680->temperature; + lastHumidity = bme680->humidity; + lastPressureHPa = bme680->pressure / 100.0F; + lastGasOhms = (float)bme680->gas_resistance; + haveSample = true; + lastSampleMs = Time::getMillis(); + + uint16_t iaq; + if (iaqEstimator.update(lastGasOhms, lastHumidity, &iaq)) { + lastIaq = iaq; + lastIaqValid = true; + lastIaqMs = lastSampleMs; + } else if (isfinite(lastGasOhms) && lastGasOhms > 0.0f) { + // Valid gas sample but the estimator has no output yet (warm-up/burn-in) + lastIaqValid = false; + } else if (lastIaqValid && !Throttle::isWithinTimespanMs(lastIaqMs, IAQ_CARRY_MS)) { + // Heater-unstable cycles (gas reported as 0) may ride on the previous + // IAQ briefly, but a persistently gasless sensor stops reporting IAQ + lastIaqValid = false; + } + + maybeSaveState(); +} + bool BME680Sensor::getMetrics(meshtastic_Telemetry *measurement) { -#if __has_include() - if (bme680.getData(BSEC_OUTPUT_RAW_PRESSURE).signal == 0) + if (!haveSample || !Throttle::isWithinTimespanMs(lastSampleMs, SAMPLE_FRESH_MS)) + captureSample(); + // A failed refresh must not freeze the last reading on the wire: publish + // only while the cache is genuinely fresh + if (!haveSample || !Throttle::isWithinTimespanMs(lastSampleMs, SAMPLE_FRESH_MS)) return false; measurement->variant.environment_metrics.has_temperature = true; measurement->variant.environment_metrics.has_relative_humidity = true; measurement->variant.environment_metrics.has_barometric_pressure = true; - measurement->variant.environment_metrics.has_gas_resistance = true; - measurement->variant.environment_metrics.has_iaq = true; - measurement->variant.environment_metrics.temperature = bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE).signal; - measurement->variant.environment_metrics.relative_humidity = - bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY).signal; - measurement->variant.environment_metrics.barometric_pressure = bme680.getData(BSEC_OUTPUT_RAW_PRESSURE).signal; - measurement->variant.environment_metrics.gas_resistance = bme680.getData(BSEC_OUTPUT_RAW_GAS).signal / 1000.0; - // Check if we need to save state to filesystem (every STATE_SAVE_PERIOD ms) - measurement->variant.environment_metrics.iaq = bme680.getData(BSEC_OUTPUT_IAQ).signal; - updateState(); -#else - if (!bme680->performReading()) { - LOG_ERROR("BME680Sensor::getMetrics: performReading failed"); - return false; + measurement->variant.environment_metrics.temperature = lastTemperature; + measurement->variant.environment_metrics.relative_humidity = lastHumidity; + measurement->variant.environment_metrics.barometric_pressure = lastPressureHPa; + + // A heater-unstable cycle reports gas_resistance 0; suppress the field + // rather than broadcasting a bogus 0 kOhm point + if (isfinite(lastGasOhms) && lastGasOhms > 0.0f) { + measurement->variant.environment_metrics.has_gas_resistance = true; + // Fleet convention is kOhm on the wire (despite the proto comment saying MOhm) + measurement->variant.environment_metrics.gas_resistance = lastGasOhms / 1000.0f; } - measurement->variant.environment_metrics.has_temperature = true; - measurement->variant.environment_metrics.has_relative_humidity = true; - measurement->variant.environment_metrics.has_barometric_pressure = true; - measurement->variant.environment_metrics.has_gas_resistance = true; - - measurement->variant.environment_metrics.temperature = bme680->readTemperature(); - measurement->variant.environment_metrics.relative_humidity = bme680->readHumidity(); - measurement->variant.environment_metrics.barometric_pressure = bme680->readPressure() / 100.0F; - - float gasRaw = bme680->readGas(); - measurement->variant.environment_metrics.gas_resistance = gasRaw / 1000.0; - - // IAQ approximation: humidity-compensated logarithmic mapping of gas resistance - // Gas sensor resistance drops with humidity; compensate to a 40% RH reference baseline - // Map compensated gas resistance (Ohms) to IAQ 0-500 using log-linear interpolation - // Clean air reference ~400 kOhm, polluted reference ~5 kOhm - if (gasRaw > 0.0f && !isfinite(gasRaw)) { - - static constexpr float LOG_UPPER = 12.899219f; // log(400k) - static constexpr float LOG_RANGE_INV = 1.0f / (12.899219f - 8.517193f); // 1 / (log(400k) - log(5k)) + if (lastIaqValid) { measurement->variant.environment_metrics.has_iaq = true; - measurement->variant.environment_metrics.iaq = (uint16_t)(fminf( - fmaxf(((LOG_UPPER - - logf(fmaxf(gasRaw * expf(0.035f * (measurement->variant.environment_metrics.relative_humidity - 40.0f)), - 1.0f))) * - LOG_RANGE_INV) * - 500.0f, - 0.0f), - 500.0f)); + measurement->variant.environment_metrics.iaq = lastIaq; } -#endif return true; } -#if __has_include() void BME680Sensor::loadState() { #ifdef FSCom + BME680IaqState state; + bool haveBlob = false; + spiLock->lock(); - auto file = FSCom.open(bsecConfigFileName, FILE_O_READ); + auto file = FSCom.open(stateFileName, FILE_O_READ); if (file) { - file.read((uint8_t *)&bsecState, BSEC_MAX_STATE_BLOB_SIZE); + haveBlob = file.read((uint8_t *)&state, sizeof(state)) == sizeof(state); file.close(); - bme680.setState(bsecState); - LOG_INFO("%s: state read from %s", sensorName, bsecConfigFileName); - } else { - LOG_INFO("No %s state found (File: %s)", sensorName, bsecConfigFileName); } + // One-time cleanup of the proprietary-BSEC calibration blob from older firmware + if (FSCom.exists(legacyBsecStateFileName) && FSCom.remove(legacyBsecStateFileName)) + LOG_INFO("%s removed legacy state file %s", sensorName, legacyBsecStateFileName); spiLock->unlock(); + + if (!haveBlob) { + LOG_INFO("No %s state found (File: %s)", sensorName, stateFileName); + return; + } + if (iaqEstimator.restore(state, getValidTime(RTCQuality::RTCQualityDevice))) { + lastPersistedSampleCount = iaqEstimator.samplesFed(); + lastPersistedWarmup = iaqEstimator.warmupLeft(); + lastSaveEpochSecs = state.savedAtSecs; + LOG_INFO("%s IAQ state restored from %s (%u samples)", sensorName, stateFileName, iaqEstimator.samplesFed()); + } else { + LOG_INFO("%s IAQ state in %s rejected (stale or invalid), starting fresh", sensorName, stateFileName); + } #else LOG_ERROR("Filesystem not implemented"); #endif } -void BME680Sensor::updateState() +void BME680Sensor::maybeSaveState() +{ + if (!iaqEstimator.ready()) { + // Persist warm-up/burn-in progress whenever it advances, so a + // deep-sleeping SENSOR node (one sample per wake, RAM wiped between) + // still converges. Bounded to ~33 writes over the sensor's lifetime. + if (iaqEstimator.samplesFed() != lastPersistedSampleCount || iaqEstimator.warmupLeft() != lastPersistedWarmup) + saveState(); + return; + } + + uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice); + if (nowSecs != 0 && lastSaveEpochSecs != 0) { + // RTC available: gate on wall-clock age so short deep-sleep wakes don't + // rewrite flash every time + if (nowSecs >= lastSaveEpochSecs && (nowSecs - lastSaveEpochSecs) < STATE_SAVE_PERIOD_SECS) + return; + } else { + // No RTC: gate on the persisted sample count (it survives reboots, so + // deep-sleeping RTC-less nodes still refresh their baseline every + // ~STATE_SAVE_PERIOD_MS worth of samples) with an uptime cadence as a + // secondary trigger for always-on nodes + if (iaqEstimator.samplesFed() - lastPersistedSampleCount < STATE_SAVE_PERIOD_MS / SAMPLE_INTERVAL_MS && + !Throttle::hasElapsed(lastStateSaveMs, STATE_SAVE_PERIOD_MS)) + return; + } + saveState(); +} + +void BME680Sensor::saveState() { #ifdef FSCom - spiLock->lock(); - bool update = false; - if (stateUpdateCounter == 0) { - /* First state update when IAQ accuracy is >= 3 */ - accuracy = bme680.getData(BSEC_OUTPUT_IAQ).accuracy; - if (accuracy >= 2) { - LOG_DEBUG("%s state update IAQ accuracy %u >= 2", sensorName, accuracy); - update = true; - stateUpdateCounter++; - } else { - LOG_DEBUG("%s not updated, IAQ accuracy is %u < 2", sensorName, accuracy); - } - } else { - /* Update every STATE_SAVE_PERIOD minutes */ - // Interval since the last save; counter * period overflows uint32 past ~198 saves. - if (Throttle::hasElapsed(lastStateSaveMs, STATE_SAVE_PERIOD)) { - LOG_DEBUG("%s state update every %d minutes", sensorName, STATE_SAVE_PERIOD / 60000); - update = true; - stateUpdateCounter++; - } - } + BME680IaqState state; + uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice); + iaqEstimator.serialize(&state, nowSecs); - if (update) { - bme680.getState(bsecState); - if (FSCom.exists(bsecConfigFileName) && !FSCom.remove(bsecConfigFileName)) { - LOG_WARN("Can't remove old state file"); - } - auto file = FSCom.open(bsecConfigFileName, FILE_O_WRITE); - if (file) { - LOG_INFO("%s: state write to %s", sensorName, bsecConfigFileName); - file.write((uint8_t *)&bsecState, BSEC_MAX_STATE_BLOB_SIZE); - file.flush(); - file.close(); - // Checkpoint on success only, so a failed write is retried at the next interval. - lastStateSaveMs = Time::getMillis(); - } else { - LOG_INFO("Can't write %s state (File: %s)", sensorName, bsecConfigFileName); - } + // SafeFile takes the SPI lock itself; fullAtomic keeps the old state file + // in place until the verified replacement is renamed over it, so a power + // loss mid-save can't lose the banked burn-in progress (the blob is 24 + // bytes, so the atomic path costs nothing) + auto file = SafeFile(stateFileName, true); + file.write((uint8_t *)&state, sizeof(state)); + if (file.close()) { + lastPersistedSampleCount = iaqEstimator.samplesFed(); + lastPersistedWarmup = iaqEstimator.warmupLeft(); + lastSaveEpochSecs = nowSecs; + lastStateSaveMs = Time::getMillis(); + LOG_DEBUG("%s state write to %s", sensorName, stateFileName); + } else { + LOG_WARN("Can't write %s state (File: %s)", sensorName, stateFileName); } - spiLock->unlock(); #else LOG_ERROR("Filesystem not implemented"); #endif } -void BME680Sensor::checkStatus(const char *functionName) -{ - if (bme680.status < BSEC_OK) - LOG_ERROR("%s BSEC2 code: %d", functionName, bme680.status); - else if (bme680.status > BSEC_OK) - LOG_WARN("%s BSEC2 code: %d", functionName, bme680.status); - - if (bme680.sensor.status < BME68X_OK) - LOG_ERROR("%s BME68X code: %d", functionName, bme680.sensor.status); - else if (bme680.sensor.status > BME68X_OK) - LOG_WARN("%s BME68X code: %d", functionName, bme680.sensor.status); -} -#endif - #endif diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.h b/src/modules/Telemetry/Sensor/BME680Sensor.h index b8c0bd8109..a10ea1fefc 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.h +++ b/src/modules/Telemetry/Sensor/BME680Sensor.h @@ -1,66 +1,71 @@ #include "configuration.h" -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && (__has_include() || __has_include()) +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() #include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "BME680IaqEstimator.h" #include "TelemetrySensor.h" -#if __has_include() -#include -#include -#else #include #include -#endif -#define STATE_SAVE_PERIOD UINT32_C(360 * 60 * 1000) // That's 6 hours worth of millis() - -#if __has_include() -const uint8_t bsec_config[] = { -#include "config/bme680/bme680_iaq_33v_3s_4d/bsec_iaq.txt" -}; -#endif class BME680Sensor : public TelemetrySensor { private: -#if __has_include() - Bsec2 bme680; -#else using BME680Ptr = std::unique_ptr; static BME680Ptr makeBME680(TwoWire *bus) { return BME680Ptr(new Adafruit_BME680(bus)); } BME680Ptr bme680; -#endif + BME680IaqEstimator iaqEstimator; - protected: -#if __has_include() - const char *bsecConfigFileName = "/prefs/bsec.dat"; - uint8_t bsecState[BSEC_MAX_STATE_BLOB_SIZE] = {0}; - uint8_t accuracy = 0; - uint16_t stateUpdateCounter = 0; - uint32_t lastStateSaveMs = 0; // when the state blob was last written, for the save interval - bsecSensor sensorList[9] = {BSEC_OUTPUT_IAQ, - BSEC_OUTPUT_RAW_TEMPERATURE, - BSEC_OUTPUT_RAW_PRESSURE, - BSEC_OUTPUT_RAW_HUMIDITY, - BSEC_OUTPUT_RAW_GAS, - BSEC_OUTPUT_STABILIZATION_STATUS, - BSEC_OUTPUT_RUN_IN_STATUS, - BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, - BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY}; + static constexpr uint32_t SAMPLE_INTERVAL_MS = 60 * 1000; + // getMetrics() publishes the cached async sample only while it is this + // fresh; a failed refresh past this age drops the BME680 fields from the + // packet rather than freezing the last reading on the wire + static constexpr uint32_t SAMPLE_FRESH_MS = 2 * 60 * 1000; + // A heater-unstable cycle reports gas_resistance 0; carry the previous IAQ + // through such blips, but not forever + static constexpr uint32_t IAQ_CARRY_MS = 10 * 60 * 1000; + static constexpr uint32_t STATE_SAVE_PERIOD_MS = 6 * 60 * 60 * 1000; + static constexpr uint32_t STATE_SAVE_PERIOD_SECS = STATE_SAVE_PERIOD_MS / 1000; + + static constexpr const char *stateFileName = "/prefs/bme680.dat"; + static constexpr const char *legacyBsecStateFileName = "/prefs/bsec.dat"; // left behind by pre-open-IAQ firmware + + // Async sampling state (driven from runOnce) + bool readingInFlight = false; + uint32_t readingDoneAtMs = 0; + + // Cached last sample + bool haveSample = false; + uint32_t lastSampleMs = 0; + float lastTemperature = 0; + float lastHumidity = 0; + float lastPressureHPa = 0; + float lastGasOhms = 0; + uint16_t lastIaq = 0; + bool lastIaqValid = false; + uint32_t lastIaqMs = 0; + + // Persistence bookkeeping: burn-in progress is saved whenever it advances + // (bounded to ~33 writes lifetime), steady-state saves are RTC-gated so a + // deep-sleeping node doesn't rewrite flash on every wake + uint32_t lastPersistedSampleCount = UINT32_MAX; + uint32_t lastPersistedWarmup = UINT32_MAX; + uint32_t lastSaveEpochSecs = 0; + uint32_t lastStateSaveMs = 0; + + void captureSample(); void loadState(); - void updateState(); - void checkStatus(const char *functionName); -#endif + void maybeSaveState(); + void saveState(); public: BME680Sensor(); -#if __has_include() virtual int32_t runOnce() override; -#endif virtual bool getMetrics(meshtastic_Telemetry *measurement) override; virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; }; -#endif \ No newline at end of file +#endif diff --git a/src/modules/Telemetry/Sensor/CO2Sensor.h b/src/modules/Telemetry/Sensor/CO2Sensor.h new file mode 100644 index 0000000000..57e747a6f3 --- /dev/null +++ b/src/modules/Telemetry/Sensor/CO2Sensor.h @@ -0,0 +1,111 @@ +#pragma once + +#include "MeshModule.h" + +/* +Shared CO2 calibration interface + admin-message dispatch for any sensor that +exposes Sensirion-style CO2 auto/forced calibration: automatic self-calibration +(ASC), forced recalibration (FRC), altitude/ambient-pressure compensation, and +a calibration-history factory reset. SCD4XSensor, SCD30Sensor and the +CO2-capable SEN6X variants (SEN63C/SEN66/SEN69C, via SENXXSensor) all implement +this instead of duplicating the same admin-message branching logic. + +Concrete classes only need to implement the low-level co2* operations against +their own I2C command set; handleCo2AdminRequest() below is the one shared +place that decides *when* to call FRC vs ASC, validates that a target CO2 was +supplied for FRC, and reverts ASC on a failed FRC attempt. +*/ +class CO2CalibrationSensor +{ + protected: + virtual ~CO2CalibrationSensor() {} + + // Forced recalibration against a known reference CO2 concentration (ppm). + virtual bool co2PerformFRC(uint32_t targetCO2ppm) = 0; + + // Automatic self-calibration on/off. + virtual bool co2GetASC(bool &ascEnabled) = 0; + virtual bool co2SetASC(bool ascEnabled) = 0; + // Optional: not every sensor exposes a settable ASC baseline (e.g. SCD30/SEN6X don't). + virtual bool co2SetASCBaseline(uint32_t targetCO2ppm) { return true; } + + // Altitude/pressure compensation. altitude in meters above sea level, + // ambientPressure in Pa (implementations convert to whatever unit their + // own command set expects). + virtual bool co2SetAltitude(uint32_t altitude) = 0; + virtual bool co2SetAmbientPressure(uint32_t ambientPressurePa) { return false; } + + // Erases the sensor's FRC/ASC calibration history. Optional. + virtual bool co2FactoryReset() { return false; } + + // Snapshot of whichever *_config admin message fields were populated, + // translated once by the caller into this sensor-agnostic shape. + struct Co2AdminRequest { + bool hasFactoryReset = false; + bool hasSetAsc = false; + bool setAsc = false; + bool hasTargetCo2 = false; + uint32_t targetCo2 = 0; + bool hasSetAltitude = false; + uint32_t setAltitude = 0; + bool hasSetAmbientPressure = false; + uint32_t setAmbientPressure = 0; + }; + + // Returns false if a requested operation failed - callers should map + // that to AdminMessageHandleResult::NOT_HANDLED like they already do for + // their sensor-specific fields (e.g. temperature offset, power mode). + bool handleCo2AdminRequest(const Co2AdminRequest &cfg, const char *sensorName) + { + if (cfg.hasFactoryReset) { + LOG_DEBUG("%s: Requested CO2 calibration factory reset", sensorName); + return co2FactoryReset(); + } + + if (cfg.hasSetAsc) { + if (!cfg.setAsc) { + bool currentASC = false; + if (!co2GetASC(currentASC)) { + return false; + } + // Disabling ASC is how you request a forced recalibration (FRC). + if (!cfg.hasTargetCo2) { + LOG_ERROR("%s: target CO2 not provided for FRC", sensorName); + return false; + } + LOG_DEBUG("%s: Request for FRC", sensorName); + if (!co2SetASC(false)) { + return false; + } + if (!co2PerformFRC(cfg.targetCo2)) { + // Restore previous ASC state since the FRC attempt failed. + co2SetASC(currentASC); + return false; + } + } else { + LOG_DEBUG("%s: Request for ASC", sensorName); + if (!co2SetASC(true)) { + return false; + } + // ASC with target CO2 is only available in SCD4X + if (cfg.hasTargetCo2) { + if (!co2SetASCBaseline(cfg.targetCo2)) { + return false; + } + } + } + } + + if (cfg.hasSetAltitude) { + if (!co2SetAltitude(cfg.setAltitude)) { + return false; + } + } else if (cfg.hasSetAmbientPressure) { + if (!co2SetAmbientPressure(cfg.setAmbientPressure)) { + return false; + } + } + + return true; + } +}; diff --git a/src/modules/Telemetry/Sensor/DS248XSensor.cpp b/src/modules/Telemetry/Sensor/DS248XSensor.cpp index d0e1385528..fe0877e6d4 100644 --- a/src/modules/Telemetry/Sensor/DS248XSensor.cpp +++ b/src/modules/Telemetry/Sensor/DS248XSensor.cpp @@ -63,13 +63,11 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef DS248X_I2C_CLOCK_SPEED reClockI2C.setup(_bus, _port); - reClockI2C.setClock(DS248X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, DS248X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, DS248X_I2C_CLOCK_SPEED); #endif /* DS248X_I2C_CLOCK_SPEED */ if (!ds248x.begin(bus, _address)) { -#ifdef DS248X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* DS248X_I2C_CLOCK_SPEED */ return false; } @@ -151,9 +149,6 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } if (initError && retry == numRetries) { -#ifdef DS248X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* DS248X_I2C_CLOCK_SPEED */ LOG_ERROR("%s: Max retries for one-wire init (%u/%u). Aborting", sensorName, retry, numRetries); return false; } @@ -173,10 +168,6 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) delay(500); } -#ifdef DS248X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* DS248X_I2C_CLOCK_SPEED */ - initI2CSensor(); return status; } @@ -190,7 +181,8 @@ bool DS248XSensor::isValidROM(const uint8_t *rom) float DS248XSensor::readTemperatureROM(const uint8_t *rom) { #ifdef DS248X_I2C_CLOCK_SPEED - reClockI2C.setClock(DS248X_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, DS248X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, DS248X_I2C_CLOCK_SPEED); #endif /* DS248X_I2C_CLOCK_SPEED */ uint8_t data[9]{}; @@ -219,10 +211,6 @@ float DS248XSensor::readTemperatureROM(const uint8_t *rom) } } -#ifdef DS248X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* DS248X_I2C_CLOCK_SPEED */ - if (!ok) { LOG_WARN("%s: One-wire transaction failed", sensorName); return DS248X_INVALID_TEMPERATURE; diff --git a/src/modules/Telemetry/Sensor/HM330XSensor.cpp b/src/modules/Telemetry/Sensor/HM330XSensor.cpp index 20b2a5e660..206b89d1d1 100644 --- a/src/modules/Telemetry/Sensor/HM330XSensor.cpp +++ b/src/modules/Telemetry/Sensor/HM330XSensor.cpp @@ -17,21 +17,16 @@ bool HM330XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef HM330X_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - reClockI2C.setClock(HM330X_I2C_CLOCK_SPEED); + + LOG_INFO("%s: reclock speed %uHz", sensorName, HM330X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, HM330X_I2C_CLOCK_SPEED); #endif /* HM330X_I2C_CLOCK_SPEED */ if (hm330x.init(_bus) != HM330XErrorCode::NO_ERROR) { -#ifdef HM330X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* HM330X_I2C_CLOCK_SPEED */ LOG_WARN("%s error in sensor init", sensorName); return false; } -#ifdef HM330X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* HM330X_I2C_CLOCK_SPEED */ - status = 1; LOG_INFO("%s Enabled", sensorName); @@ -42,7 +37,7 @@ bool HM330XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) uint32_t HM330XSensor::wakeUp() { state = State::ACTIVE; - measureStarted = getTime(); + measureStarted = millis(); return HM330X_WARMUP_MS; } @@ -64,9 +59,7 @@ bool HM330XSensor::isActive() int32_t HM330XSensor::pendingForReadyMs() { - uint32_t now; - now = getTime(); - uint32_t sincePMMeasureStarted = (now - measureStarted) * 1000; + uint32_t sincePMMeasureStarted = millis() - measureStarted; LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePMMeasureStarted); if (sincePMMeasureStarted < HM330X_WARMUP_MS) { @@ -79,21 +72,15 @@ int32_t HM330XSensor::pendingForReadyMs() bool HM330XSensor::getMetrics(meshtastic_Telemetry *measurement) { #ifdef HM330X_I2C_CLOCK_SPEED - reClockI2C.setClock(HM330X_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, HM330X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, HM330X_I2C_CLOCK_SPEED); #endif /* HM330X_I2C_CLOCK_SPEED */ if (hm330x.read_sensor_value(buffer, 29)) { LOG_WARN("%s: read result failed", sensorName); -#ifdef HM330X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* HM330X_I2C_CLOCK_SPEED */ return false; } -#ifdef HM330X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* HM330X_I2C_CLOCK_SPEED */ - if (hm330x.checksum_calc(buffer) != HM330XErrorCode::NO_ERROR) { LOG_ERROR("%s: Checksum error", sensorName); return false; diff --git a/src/modules/Telemetry/Sensor/HM330XSensor.h b/src/modules/Telemetry/Sensor/HM330XSensor.h index 76312b04c8..f8edb0a47b 100644 --- a/src/modules/Telemetry/Sensor/HM330XSensor.h +++ b/src/modules/Telemetry/Sensor/HM330XSensor.h @@ -18,6 +18,8 @@ class HM330XSensor : public TelemetrySensor private: enum class State { IDLE, ACTIVE }; State state = State::IDLE; + // millis()-based, not wall-clock: this only measures in-session warmup elapsed time, + // and getTime() can jump discontinuously when RTC quality improves mid-session. uint32_t measureStarted = 0; uint8_t buffer[HM330X_FRAME_LENGTH]{}; TwoWire *_bus{}; diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp index c366057735..16aa5e7d10 100644 --- a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp +++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp @@ -24,23 +24,18 @@ bool PMSA003ISensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef PMSA003I_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - reClockI2C.setClock(PMSA003I_I2C_CLOCK_SPEED); + + LOG_INFO("%s: reclock speed %uHz", sensorName, PMSA003I_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, PMSA003I_I2C_CLOCK_SPEED); #endif /* PMSA003I_I2C_CLOCK_SPEED */ _bus->beginTransmission(_address); if (_bus->endTransmission() != 0) { LOG_WARN("%s not found on I2C at 0x12", sensorName); -#ifdef PMSA003I_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* PMSA003I_I2C_CLOCK_SPEED */ sleep(); return false; } -#ifdef PMSA003I_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* PMSA003I_I2C_CLOCK_SPEED */ - status = 1; LOG_INFO("%s: Enabled", sensorName); sleep(); @@ -57,15 +52,13 @@ bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) } #ifdef PMSA003I_I2C_CLOCK_SPEED - reClockI2C.setClock(PMSA003I_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, PMSA003I_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, PMSA003I_I2C_CLOCK_SPEED); #endif /* PMSA003I_I2C_CLOCK_SPEED */ _bus->requestFrom(_address, (uint8_t)PMSA003I_FRAME_LENGTH); if (_bus->available() < PMSA003I_FRAME_LENGTH) { LOG_WARN("%s: read failed: incomplete data (%d bytes)", sensorName, _bus->available()); -#ifdef PMSA003I_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* PMSA003I_I2C_CLOCK_SPEED */ return false; } @@ -73,10 +66,6 @@ bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) buffer[i] = _bus->read(); } -#ifdef PMSA003I_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* PMSA003I_I2C_CLOCK_SPEED */ - if (buffer[0] != 0x42 || buffer[1] != 0x4D) { LOG_WARN("%s: frame header invalid: 0x%02X 0x%02X", sensorName, buffer[0], buffer[1]); return false; @@ -157,9 +146,7 @@ int32_t PMSA003ISensor::wakeUpTimeMs() int32_t PMSA003ISensor::pendingForReadyMs() { #ifdef PMSA003I_ENABLE_PIN - uint32_t now; - now = getTime(); - uint32_t sincePmMeasureStarted = (now - pmMeasureStarted) * 1000; + uint32_t sincePmMeasureStarted = millis() - pmMeasureStarted; LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted); if (sincePmMeasureStarted < PMSA003I_WARMUP_MS) { @@ -195,7 +182,7 @@ uint32_t PMSA003ISensor::wakeUp() LOG_INFO("%s Waking", sensorName); digitalWrite(PMSA003I_ENABLE_PIN, HIGH); state = PMSA003I_ACTIVE; - pmMeasureStarted = getTime(); + pmMeasureStarted = millis(); return PMSA003I_WARMUP_MS; #endif diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.h b/src/modules/Telemetry/Sensor/PMSA003ISensor.h index b65ef99a9b..7243a0ddfa 100644 --- a/src/modules/Telemetry/Sensor/PMSA003ISensor.h +++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.h @@ -39,6 +39,8 @@ class PMSA003ISensor : public TelemetrySensor uint16_t computedChecksum = 0; uint16_t receivedChecksum = 0; + // millis()-based, not wall-clock: this only measures in-session warmup elapsed time, + // and getTime() can jump discontinuously when RTC quality improves mid-session. uint32_t pmMeasureStarted = 0; uint8_t buffer[PMSA003I_FRAME_LENGTH]{}; diff --git a/src/modules/Telemetry/Sensor/RollingCounter.h b/src/modules/Telemetry/Sensor/RollingCounter.h new file mode 100644 index 0000000000..1d2d8bae09 --- /dev/null +++ b/src/modules/Telemetry/Sensor/RollingCounter.h @@ -0,0 +1,85 @@ +#pragma once + +#include "UptimeClock.h" +#include "mesh/Throttle.h" +#include + +/** + * Sliding-window event counter in fixed memory, one counter per bucket. The spare bucket and the + * weighted oldest bucket are what hold sum() at exactly WindowMs rather than a bucket either way. + * RollingCounter<60UL * 60 * 1000, 5UL * 60 * 1000> strikes; // last hour in 5min steps + */ +template class RollingCounter +{ + static_assert(BucketMs > 0, "BucketMs must be non-zero"); + static_assert(WindowMs % BucketMs == 0, "WindowMs must be a whole number of buckets"); + static_assert(WindowMs / BucketMs + 1 <= UINT8_MAX, "too many buckets"); + + // One more than WindowMs needs, so the oldest is never recycled while still in the window. + static constexpr uint8_t BUCKETS = WindowMs / BucketMs + 1; + + public: + /// Record events happening now. + void add(uint32_t events = 1) + { + advance(); + counts[head] += events; + } + + /// Events within the last WindowMs. + uint32_t sum() + { + advance(); + + // The current bucket plus every fully enclosed one: WindowMs - BucketMs, plus however + // far the current bucket has filled. + uint32_t total = 0; + for (uint8_t age = 0; age <= BUCKETS - 2; age++) + total += counts[(head + BUCKETS - age) % BUCKETS]; + + // The oldest bucket covers the remainder. Counting only the part still inside is what + // holds the total at exactly WindowMs as the current bucket fills. + uint32_t elapsed = Time::getMillis() - bucketStartMs; + uint32_t inWindow = elapsed < BucketMs ? BucketMs - elapsed : 0; + // 64-bit: the product overflows 32 bits once a bucket holds more than 2^32 / BucketMs + // events, which is only ~14k at a 5 minute width. + total += (uint32_t)(((uint64_t)counts[(head + 1) % BUCKETS] * inWindow + BucketMs / 2) / BucketMs); + return total; + } + + void reset() + { + memset(counts, 0, sizeof(counts)); + head = 0; + bucketStartMs = Time::getMillis(); + started = true; + } + + private: + void advance() + { + if (!started) { + reset(); + return; + } + if (!Throttle::hasElapsed(bucketStartMs, BucketMs)) + return; + + uint32_t steps = (Time::getMillis() - bucketStartMs) / BucketMs; + if (steps >= BUCKETS) { // idle longer than the ring, nothing survives + reset(); + return; + } + bucketStartMs += steps * BucketMs; + while (steps--) { + head = (head + 1) % BUCKETS; + counts[head] = 0; + } + } + + uint32_t counts[BUCKETS] = {}; + uint32_t bucketStartMs = 0; + uint8_t head = 0; + // Explicit rather than bucketStartMs == 0, which is a real time value after a rollover. + bool started = false; +}; diff --git a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp index c380f0f42e..d203837bbd 100644 --- a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp @@ -18,16 +18,15 @@ bool SCD30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SCD30_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); + + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ scd30.begin(*_bus, _address); if (!startMeasurement()) { - LOG_ERROR("%s: Periodic measurement start failed", sensorName); -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Failed to start periodic measurement", sensorName); return false; } @@ -35,10 +34,6 @@ bool SCD30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) LOG_WARN("%s: Can't determine ASC state", sensorName); } -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ - if (state == SCD30_MEASUREMENT) { status = 1; } else { @@ -55,21 +50,15 @@ bool SCD30Sensor::getMetrics(meshtastic_Telemetry *measurement) float co2, temperature, humidity; #ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ if (scd30.readMeasurementData(co2, temperature, humidity) != SCD30_NO_ERROR) { - LOG_ERROR("%s: Measurement read failed", sensorName); -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Failed to read measurement data", sensorName); return false; } -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ - if (co2 == 0) { LOG_ERROR("%s: Invalid CO₂ reading", sensorName); return false; @@ -359,15 +348,12 @@ bool SCD30Sensor::isActive() uint32_t SCD30Sensor::wakeUp() { #ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ startMeasurement(); -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ - return 0; } @@ -378,14 +364,11 @@ uint32_t SCD30Sensor::wakeUp() void SCD30Sensor::sleep() { #ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ stopMeasurement(); - -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ } bool SCD30Sensor::canSleep() @@ -409,7 +392,8 @@ AdminMessageHandleResult SCD30Sensor::handleAdminMessage(const meshtastic_MeshPa AdminMessageHandleResult result; #ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ switch (request->which_payload_variant) { @@ -424,37 +408,34 @@ AdminMessageHandleResult SCD30Sensor::handleAdminMessage(const meshtastic_MeshPa LOG_DEBUG("%s: Requested soft reset", sensorName); this->softReset(); } else { + const auto &cfg = request->sensor_config.scd30_config; - if (request->sensor_config.scd30_config.has_set_asc) { - this->setASC(request->sensor_config.scd30_config.set_asc); - if (request->sensor_config.scd30_config.set_asc == false) { - LOG_DEBUG("%s: Request for FRC", sensorName); - if (request->sensor_config.scd30_config.has_set_target_co2_conc) { - this->performFRC(request->sensor_config.scd30_config.set_target_co2_conc); - } else { - // FRC requested but no target CO2 provided - LOG_ERROR("%s: target CO2 not provided", sensorName); - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } + // ASC/FRC/altitude calibration branching is shared with SCD4XSensor and the + // CO2-capable SEN6X variants via CO2CalibrationSensor. + if (cfg.has_set_asc || cfg.has_set_altitude) { + Co2AdminRequest co2req; + co2req.hasSetAsc = cfg.has_set_asc; + co2req.setAsc = cfg.set_asc; + co2req.hasTargetCo2 = cfg.has_set_target_co2_conc; + co2req.targetCo2 = cfg.set_target_co2_conc; + co2req.hasSetAltitude = cfg.has_set_altitude; + co2req.setAltitude = cfg.set_altitude; + if (!this->handleCo2AdminRequest(co2req, sensorName)) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; } } // Check for temperature offset // NOTE: this requires to have a sensor working on stable environment // And to make it between readings - if (request->sensor_config.scd30_config.has_set_temperature) { - this->setTemperature(request->sensor_config.scd30_config.set_temperature); - } - - // Check for altitude - if (request->sensor_config.scd30_config.has_set_altitude) { - this->setAltitude(request->sensor_config.scd30_config.set_altitude); + if (cfg.has_set_temperature) { + this->setTemperature(cfg.set_temperature); } // Check for set measuremen interval - if (request->sensor_config.scd30_config.has_set_measurement_interval) { - this->setMeasurementInterval(request->sensor_config.scd30_config.set_measurement_interval); + if (cfg.has_set_measurement_interval) { + this->setMeasurementInterval(cfg.set_measurement_interval); } } @@ -465,10 +446,6 @@ AdminMessageHandleResult SCD30Sensor::handleAdminMessage(const meshtastic_MeshPa result = AdminMessageHandleResult::NOT_HANDLED; } -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ - return result; } diff --git a/src/modules/Telemetry/Sensor/SCD30Sensor.h b/src/modules/Telemetry/Sensor/SCD30Sensor.h index 82c9a5532a..51bc872bad 100644 --- a/src/modules/Telemetry/Sensor/SCD30Sensor.h +++ b/src/modules/Telemetry/Sensor/SCD30Sensor.h @@ -4,12 +4,13 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "CO2Sensor.h" #include "TelemetrySensor.h" #include #define SCD30_I2C_CLOCK_SPEED 100000 -class SCD30Sensor : public TelemetrySensor +class SCD30Sensor : public TelemetrySensor, public CO2CalibrationSensor { private: SensirionI2cScd30 scd30; @@ -29,6 +30,27 @@ class SCD30Sensor : public TelemetrySensor bool startMeasurement(); bool stopMeasurement(); + // CO2CalibrationSensor overrides - thin wrappers, shared with SCD4XSensor and + // the CO2-capable SEN6X variants via CO2CalibrationSensor::handleCo2AdminRequest(). + // SCD30 has no ambient-pressure command or calibration-history factory reset, so + // those two are left at CO2CalibrationSensor's default (unsupported) implementation. + bool co2PerformFRC(uint32_t targetCO2ppm) override + { + return targetCO2ppm <= UINT16_MAX && performFRC(static_cast(targetCO2ppm)); + } + bool co2GetASC(bool &ascEnabled) override + { + uint16_t v = 0; + bool ok = getASC(v); + ascEnabled = v != 0; + return ok; + } + bool co2SetASC(bool ascEnabled) override { return setASC(ascEnabled); } + bool co2SetAltitude(uint32_t altitude) override + { + return altitude <= UINT16_MAX && setAltitude(static_cast(altitude)); + } + // Parameters uint16_t ascActive = 1; uint16_t measurementInterval = 2; diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp index 7c6bc3ecf7..92cb696e6f 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp @@ -19,7 +19,8 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SCD4X_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ scd4x.begin(*_bus, _address); @@ -29,9 +30,6 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) // Stop periodic measurement if (!stopMeasurement()) { -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ return false; } @@ -41,35 +39,22 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) if (sensorVariant == SCD4X_SENSOR_VARIANT_SCD41) { LOG_INFO("%s: Found SCD41", sensorName); if (!powerUp()) { - LOG_ERROR("%s: powerUp() failed", sensorName); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Error trying to execute powerUp()", sensorName); return false; } } if (!getASC(ascActive)) { - LOG_ERROR("%s: Can't check if ASC enabled", sensorName); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Unable to check if ASC is enabled", sensorName); return false; } // Start measurement in selected power mode (low power by default) if (!startMeasurement()) { - LOG_ERROR("%s: Can't start measurement", sensorName); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Couldn't start measurement", sensorName); return false; } -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ - if (state == SCD4X_MEASUREMENT) { status = 1; } else { @@ -93,7 +78,8 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) float temperature, humidity; #ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ bool dataReady = false; @@ -109,19 +95,12 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) } if (error != SCD4X_NO_ERROR || !dataReady) { -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ LOG_ERROR("SCD4X: Data is not ready"); return false; } error = scd4x.readMeasurement(co2, temperature, humidity); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ - LOG_DEBUG("Got %s readings: co2=%u, co2_temp=%.2f, co2_hum%.2f", sensorName, co2, temperature, humidity); if (error != SCD4X_NO_ERROR) { LOG_DEBUG("%s: Error getting measurements: %u", sensorName, error); @@ -634,28 +613,19 @@ bool SCD4XSensor::powerDown() } #ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ if (!stopMeasurement()) { -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ return false; } if (scd4x.powerDown() != SCD4X_NO_ERROR) { - LOG_ERROR("%s: sleep() failed", sensorName); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Error trying to execute sleep()", sensorName); return false; } -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ - state = SCD4X_OFF; return true; } @@ -701,21 +671,15 @@ uint32_t SCD4XSensor::wakeUp() { #ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ if (startMeasurement()) { - co2MeasureStarted = getTime(); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ + co2MeasureStarted = millis(); return SCD4X_WARMUP_MS; } -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ - return 0; } @@ -726,14 +690,11 @@ uint32_t SCD4XSensor::wakeUp() void SCD4XSensor::sleep() { #ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ stopMeasurement(); - -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ } /** @@ -755,9 +716,7 @@ int32_t SCD4XSensor::wakeUpTimeMs() int32_t SCD4XSensor::pendingForReadyMs() { - uint32_t now; - now = getTime(); - uint32_t sinceCO2MeasureStarted = (now - co2MeasureStarted) * 1000; + uint32_t sinceCO2MeasureStarted = millis() - co2MeasureStarted; LOG_DEBUG("%s: Since measure started: %ums", sensorName, sinceCO2MeasureStarted); if (sinceCO2MeasureStarted < SCD4X_WARMUP_MS) { @@ -773,7 +732,8 @@ AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPa AdminMessageHandleResult result; #ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ // TODO: potentially add selftest command? @@ -785,85 +745,48 @@ AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPa break; } - if (request->sensor_config.scd4x_config.has_factory_reset) { - LOG_DEBUG("%s: Requested factory reset", sensorName); - if (!this->factoryReset()) { + { + const auto &cfg = request->sensor_config.scd4x_config; + bool ok = true; + + // FRC/ASC/altitude/pressure/factory-reset calibration branching is shared with + // SCD30Sensor and the CO2-capable SEN6X variants via CO2CalibrationSensor. + if (cfg.has_factory_reset || cfg.has_set_asc || cfg.has_set_altitude || cfg.has_set_ambient_pressure) { + Co2AdminRequest co2req; + co2req.hasFactoryReset = cfg.has_factory_reset; + co2req.hasSetAsc = cfg.has_set_asc; + co2req.setAsc = cfg.set_asc; + co2req.hasTargetCo2 = cfg.has_set_target_co2_conc; + co2req.targetCo2 = cfg.set_target_co2_conc; + co2req.hasSetAltitude = cfg.has_set_altitude; + co2req.setAltitude = cfg.set_altitude; + co2req.hasSetAmbientPressure = cfg.has_set_ambient_pressure; + co2req.setAmbientPressure = cfg.set_ambient_pressure; + ok &= this->handleCo2AdminRequest(co2req, sensorName); + } + + // A factory reset erases calibration history outright - matches the original + // behavior of skipping every other field when it's requested. + if (ok && !cfg.has_factory_reset) { + // Check for temperature offset + // NOTE: this requires to have a sensor working on stable environment + // And to make it between readings + if (cfg.has_set_temperature) { + ok &= this->setTemperature(cfg.set_temperature); + } + + // Check for low power mode + // NOTE: to switch from one mode to another do: + // setPowerMode -> startMeasurement + if (cfg.has_set_power_mode) { + ok &= this->setPowerMode(cfg.set_power_mode); + } + } + + if (!ok) { result = AdminMessageHandleResult::NOT_HANDLED; break; } - } else { - if (request->sensor_config.scd4x_config.has_set_asc) { - getASC(ascActive); - bool currentASC = ascActive; - if (request->sensor_config.scd4x_config.set_asc == false) { - LOG_DEBUG("%s: Request for FRC", sensorName); - if (request->sensor_config.scd4x_config.has_set_target_co2_conc) { - if (this->setASC(request->sensor_config.scd4x_config.set_asc)) { - if (!this->performFRC(request->sensor_config.scd4x_config.set_target_co2_conc)) { - result = AdminMessageHandleResult::NOT_HANDLED; - // Set it back to ASC if failed - setASC(currentASC); - break; - }; - } else { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } else { - // FRC requested but no target CO2 provided - LOG_ERROR("%s: target CO2 not provided", sensorName); - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } else { - LOG_DEBUG("%s: Request for ASC", sensorName); - if (this->setASC(request->sensor_config.scd4x_config.set_asc)) { - if (request->sensor_config.scd4x_config.has_set_target_co2_conc) { - LOG_DEBUG("%s: Request has target CO2", sensorName); - this->setASCBaseline(request->sensor_config.scd4x_config.set_target_co2_conc); - // NOTE - in this situation, if we set ASC, but baseline set fails, we stay on ASC - } else { - LOG_DEBUG("%s: Request doesn't have target CO2", sensorName); - } - } else { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } - } - - // Check for temperature offset - // NOTE: this requires to have a sensor working on stable environment - // And to make it between readings - if (request->sensor_config.scd4x_config.has_set_temperature) { - if (!this->setTemperature(request->sensor_config.scd4x_config.set_temperature)) { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } - - // Check for altitude or pressure offset - if (request->sensor_config.scd4x_config.has_set_altitude) { - if (!this->setAltitude(request->sensor_config.scd4x_config.set_altitude)) { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } else if (request->sensor_config.scd4x_config.has_set_ambient_pressure) { - if (!this->setAmbientPressure(request->sensor_config.scd4x_config.set_ambient_pressure)) { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } - - // Check for low power mode - // NOTE: to switch from one mode to another do: - // setPowerMode -> startMeasurement - if (request->sensor_config.scd4x_config.has_set_power_mode) { - if (!this->setPowerMode(request->sensor_config.scd4x_config.set_power_mode)) { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } } result = AdminMessageHandleResult::HANDLED; @@ -876,10 +799,6 @@ AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPa // Start measurement mode this->startMeasurement(); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ - return result; } diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.h b/src/modules/Telemetry/Sensor/SCD4XSensor.h index f9161942e3..af7703151d 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.h +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.h @@ -4,6 +4,7 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "CO2Sensor.h" #include "TelemetrySensor.h" #include "gps/RTC.h" #include @@ -13,7 +14,7 @@ #define SCD4X_WARMUP_MS 5000 #define SCD4X_MAX_RETRIES 3 -class SCD4XSensor : public TelemetrySensor +class SCD4XSensor : public TelemetrySensor, public CO2CalibrationSensor { private: SensirionI2cScd4x scd4x; @@ -35,10 +36,45 @@ class SCD4XSensor : public TelemetrySensor bool startMeasurement(); bool stopMeasurement(); + // CO2CalibrationSensor overrides - thin wrappers around the methods above, + // shared with SCD30Sensor and the CO2-capable SEN6X variants via + // CO2CalibrationSensor::handleCo2AdminRequest(). + bool co2PerformFRC(uint32_t targetCO2ppm) override + { + return targetCO2ppm <= UINT16_MAX && performFRC(static_cast(targetCO2ppm)); + } + bool co2GetASC(bool &ascEnabled) override + { + uint16_t v = 0; + bool ok = getASC(v); + ascEnabled = v != 0; + return ok; + } + bool co2SetASC(bool ascEnabled) override { return setASC(ascEnabled); } + bool co2SetASCBaseline(uint32_t targetCO2ppm) override + { + return targetCO2ppm <= UINT16_MAX && setASCBaseline(static_cast(targetCO2ppm)); + } + bool co2SetAltitude(uint32_t altitude) override + { + if (altitude > 3000) + return false; + return altitude <= UINT16_MAX && setAltitude(static_cast(altitude)); + } + bool co2SetAmbientPressure(uint32_t ambientPressurePa) override + { + if (ambientPressurePa < 70000 || ambientPressurePa > 120000) + return false; + return setAmbientPressure(ambientPressurePa); + } + bool co2FactoryReset() override { return factoryReset(); } + uint16_t ascActive = 1; // low power measurement mode (on sensirion side). Disables sleep mode // Improvement and testing needed for timings bool lowPower = true; + // millis()-based, not wall-clock: this only measures in-session warmup elapsed time, + // and getTime() can jump discontinuously when RTC quality improves mid-session. uint32_t co2MeasureStarted = 0; public: diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp deleted file mode 100644 index 37df1204be..0000000000 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp +++ /dev/null @@ -1,1003 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "FSCommon.h" -#include "SEN5XSensor.h" -#include "SPILock.h" -#include "SafeFile.h" -#include "TelemetrySensor.h" -#include // FLT_MAX -#include -#include - -SEN5XSensor::SEN5XSensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SEN5X, "SEN5X") {} - -bool SEN5XSensor::getVersion() -{ - if (!sendCommand(SEN5X_GET_FIRMWARE_VERSION)) { - LOG_ERROR("%s: Error sending version command", sensorName); - return false; - } - delay(20); // From Sensirion Datasheet - - // Version reply layout: fw major/minor, fw debug, hw major/minor, - // protocol major/minor, padding - uint8_t versionBuffer[SEN5X_VERSION_BUFFER_SIZE]{}; - size_t charNumber = readBuffer(&versionBuffer[0], SEN5X_VERSION_BUFFER_SIZE + (SEN5X_VERSION_BUFFER_SIZE / 2)); - if (charNumber < SEN5X_VERSION_BUFFER_SIZE) { - LOG_ERROR("%s: Error getting device version value", sensorName); - return false; - } - - firmwareVer = versionBuffer[0] + (versionBuffer[1] / 10.0f); - hardwareVer = versionBuffer[3] + (versionBuffer[4] / 10.0f); - protocolVer = versionBuffer[5] + (versionBuffer[6] / 10.0f); - - LOG_INFO("%s: Firmware Version: %0.2f", sensorName, firmwareVer); - LOG_INFO("%s: Hardware Version: %0.2f", sensorName, hardwareVer); - LOG_INFO("%s: Protocol Version: %0.2f", sensorName, protocolVer); - - return true; -} - -bool SEN5XSensor::findModel() -{ - if (!sendCommand(SEN5X_GET_PRODUCT_NAME)) { - LOG_ERROR("%s: Error asking for product name", sensorName); - return false; - } - delay(50); // From Sensirion Datasheet - - uint8_t name[SEN5X_PRODUCT_NAME_BUFFER_SIZE]{}; - size_t charNumber = readBuffer(&name[0], SEN5X_PRODUCT_NAME_BUFFER_SIZE + (SEN5X_PRODUCT_NAME_BUFFER_SIZE / 2)); - bool foundModel = false; - - if (charNumber < SEN5X_PRODUCT_NAME_BUFFER_SIZE) { - LOG_ERROR("%s: Error getting device name", sensorName); - return foundModel; - } - - // We only check the last character that defines the model SEN5X - switch (name[4]) { - case 48: - model = SEN50; - LOG_INFO("%s: found sensor model SEN50", sensorName); - foundModel = true; - break; - case 52: - model = SEN54; - LOG_INFO("%s: found sensor model SEN54", sensorName); - foundModel = true; - break; - case 53: - model = SEN55; - LOG_INFO("%s: found sensor model SEN55", sensorName); - foundModel = true; - break; - } - - return foundModel; -} - -bool SEN5XSensor::probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port) -{ - LOG_INFO("SEN5X: probing sensor"); - - _bus = bus; - _address = address; - -#ifdef SEN5X_I2C_CLOCK_SPEED - _port = port; - reClockI2C.setup(_bus, _port); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - if (!findModel()) { - LOG_DEBUG("SEN5X: can't find SEN5X model"); - return false; - } - - return true; -} - -bool SEN5XSensor::sendCommand(uint16_t command) -{ - uint8_t nothing; - return sendCommand(command, ¬hing, 0); -} - -bool SEN5XSensor::sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber) -{ - // At least we need two bytes for the command - uint8_t bufferSize = 2; - - // Add space for CRC bytes (one every two bytes) - if (byteNumber > 0) - bufferSize += byteNumber + (byteNumber / 2); - - uint8_t toSend[bufferSize]; - uint8_t i = 0; - toSend[i++] = static_cast((command & 0xFF00) >> 8); - toSend[i++] = static_cast((command & 0x00FF) >> 0); - - // Prepare buffer with CRC every third byte - uint8_t bi = 0; - if (byteNumber > 0) { - while (bi < byteNumber) { - toSend[i++] = buffer[bi++]; - toSend[i++] = buffer[bi++]; - uint8_t calcCRC = sen5xCRC(&buffer[bi - 2]); - toSend[i++] = calcCRC; - } - } - -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.setClock(SEN5X_I2C_CLOCK_SPEED); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - // Transmit the data - // LOG_DEBUG("Beginning connection to SEN5X: 0x%x. Size: %u", address, bufferSize); - // Note: this delay is necessary to allow for long-buffers - delay(20); - _bus->beginTransmission(_address); - size_t writtenBytes = _bus->write(toSend, bufferSize); - uint8_t i2c_error = _bus->endTransmission(); - -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - if (writtenBytes != bufferSize) { - LOG_ERROR("%s: Error writing on I2C bus", sensorName); - return false; - } - - if (i2c_error != 0) { - LOG_ERROR("%s: Error on I2C communication: %x", sensorName, i2c_error); - return false; - } - return true; -} - -uint8_t SEN5XSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber) -{ -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.setClock(SEN5X_I2C_CLOCK_SPEED); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - size_t readBytes = _bus->requestFrom(_address, byteNumber); - if (readBytes != byteNumber) { - LOG_ERROR("%s: Error reading I2C bus", sensorName); -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - return 0; - } - - uint8_t i = 0; - uint8_t receivedBytes = 0; - while (readBytes > 0) { - buffer[i++] = _bus->read(); // Just as a reminder: i++ returns i and after that increments. - buffer[i++] = _bus->read(); - uint8_t recvCRC = _bus->read(); - uint8_t calcCRC = sen5xCRC(&buffer[i - 2]); - if (recvCRC != calcCRC) { - LOG_ERROR("%s: Checksum error receiving msg", sensorName); -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - return 0; - } - readBytes -= 3; - receivedBytes += 2; - } - -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - return receivedBytes; -} - -uint8_t SEN5XSensor::sen5xCRC(const uint8_t *buffer) -{ - // This code is based on Sensirion's own implementation - // https://github.com/Sensirion/arduino-core/blob/41fd02cacf307ec4945955c58ae495e56809b96c/src/SensirionCrc.cpp - uint8_t crc = 0xff; - - for (uint8_t i = 0; i < 2; i++) { - - crc ^= buffer[i]; - - for (uint8_t bit = 8; bit > 0; bit--) { - if (crc & 0x80) - crc = (crc << 1) ^ 0x31; - else - crc = (crc << 1); - } - } - - return crc; -} - -void SEN5XSensor::sleep() -{ - idle(true); -} - -bool SEN5XSensor::idle(bool checkState) -{ - // From the datasheet: - // By default, the VOC algorithm resets its state to initial - // values each time a measurement is started, - // even if the measurement was stopped only for a short - // time. So, the VOC index output value needs a long time - // until it is stable again. This can be avoided by - // restoring the previously memorized algorithm state before - // starting the measure mode - - if (checkState) { - // If the stabilisation period is not passed for SEN54 or SEN55, don't go to idle - if (model != SEN50) { - // Get VOC state before going to idle mode - vocValid = false; - if (vocStateFromSensor()) { - vocValid = vocStateValid(); - // Check if we have time, and store it - uint32_t now; // If time is RTCQualityNone, it will return zero - now = getValidTime(RTCQuality::RTCQualityDevice); - // Check if state is valid (non-zero) - if (now) { - vocTime = now; - } - } - - if (!(vocStateStable() && vocValid)) { - LOG_INFO("%s: Not stopping measurement, vocState not stable yet", sensorName); - return true; - } - } - // Save state and prefs (on all models) - saveState(); - } - - if (!oneShotMode) { - LOG_INFO("%s: Not stopping measurement, continuous mode", sensorName); - return true; - } else { - LOG_INFO("%s: One shot mode enabled", sensorName); - } - - // Switch to low-power based on the model - if (model == SEN50) { - if (!sendCommand(SEN5X_STOP_MEASUREMENT)) { - LOG_ERROR("%s: Error stopping measurement", sensorName); - return false; - } - state = SEN5X_IDLE; - LOG_INFO("%s: Stop measurement mode", sensorName); - } else { - if (!sendCommand(SEN5X_START_MEASUREMENT_RHT_GAS)) { - LOG_ERROR("%s: Error switching to RHT/Gas measurement", sensorName); - return false; - } - state = SEN5X_RHTGAS_ONLY; - LOG_INFO("%s: Switch to RHT/Gas only measurement mode", sensorName); - } - - delay(200); // From Sensirion Datasheet - pmMeasureStarted = 0; - return true; -} - -bool SEN5XSensor::vocStateRecent(uint32_t now) -{ - if (now) { - uint32_t passed = now - vocTime; // in seconds - - // Check if state is recent, less than 10 minutes (600 seconds) - if (passed < SEN5X_VOC_VALID_TIME && (now > SEN5X_VOC_VALID_DATE)) { - return true; - } - } - return false; -} - -bool SEN5XSensor::vocStateValid() -{ - if (!vocState[0] && !vocState[1] && !vocState[2] && !vocState[3] && !vocState[4] && !vocState[5] && !vocState[6] && - !vocState[7]) { - LOG_DEBUG("%s: VOC state is all 0, invalid", sensorName); - return false; - } else { - LOG_DEBUG("%s: VOC state is valid", sensorName); - return true; - } -} - -bool SEN5XSensor::vocStateToSensor() -{ - if (model == SEN50) { - return true; - } - - if (!vocStateValid()) { - LOG_INFO("%s: VOC state is invalid, not sending", sensorName); - return true; - } - - if (!sendCommand(SEN5X_STOP_MEASUREMENT)) { - LOG_ERROR("%s: Error stopping measurement", sensorName); - return false; - } - delay(200); // From Sensirion Datasheet - - LOG_DEBUG("%s: Sending VOC state to sensor", sensorName); - LOG_DEBUG("[%u, %u, %u, %u, %u, %u, %u, %u]", vocState[0], vocState[1], vocState[2], vocState[3], vocState[4], vocState[5], - vocState[6], vocState[7]); - - // Note: send command already takes into account the CRC - // buffer size increment needed - if (!sendCommand(SEN5X_RW_VOCS_STATE, vocState, SEN5X_VOC_STATE_BUFFER_SIZE)) { - LOG_ERROR("%s: Error sending VOC's state command", sensorName); - return false; - } - - return true; -} - -bool SEN5XSensor::vocStateFromSensor() -{ - if (model == SEN50) { - return true; - } - - LOG_INFO("%s: Getting VOC state from sensor", sensorName); - // Ask VOCs state from the sensor - if (!sendCommand(SEN5X_RW_VOCS_STATE)) { - LOG_ERROR("%s: Error sending VOC's state command", sensorName); - return false; - } - - delay(20); // From Sensirion Datasheet - - // Retrieve the data into a staging buffer so a partial read (e.g. a CRC - // failure halfway through) cannot corrupt the current vocState. - // The requested size accounts for the CRC bytes - uint8_t stateBuffer[SEN5X_VOC_STATE_BUFFER_SIZE]{}; - size_t receivedNumber = readBuffer(&stateBuffer[0], SEN5X_VOC_STATE_BUFFER_SIZE + (SEN5X_VOC_STATE_BUFFER_SIZE / 2)); - delay(20); // From Sensirion Datasheet - - if (receivedNumber < SEN5X_VOC_STATE_BUFFER_SIZE) { - LOG_DEBUG("%s: Error getting VOC's state", sensorName); - return false; - } - memcpy(vocState, stateBuffer, SEN5X_VOC_STATE_BUFFER_SIZE); - - // Print the state (if debug is on) - LOG_DEBUG("%s: VOC state from sensor: [%u, %u, %u, %u, %u, %u, %u, %u]", sensorName, vocState[0], vocState[1], vocState[2], - vocState[3], vocState[4], vocState[5], vocState[6], vocState[7]); - - return true; -} - -bool SEN5XSensor::loadState() -{ -#ifdef FSCom - spiLock->lock(); - auto file = FSCom.open(sen5XStateFileName, FILE_O_READ); - bool okay = false; - if (file) { - LOG_INFO("%s: state read from %s", sensorName, sen5XStateFileName); - pb_istream_t stream = {&readcb, &file, meshtastic_SEN5XState_size}; - - if (!pb_decode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate)) { - LOG_ERROR("%s: can't decode protobuf %s", sensorName, PB_GET_ERROR(&stream)); - } else { - lastCleaning = sen5xstate.last_cleaning_time; - lastCleaningValid = sen5xstate.last_cleaning_valid; - oneShotMode = sen5xstate.one_shot_mode; - - if (model != SEN50) { - vocTime = sen5xstate.voc_state_time; - vocValid = sen5xstate.voc_state_valid; - // Unpack state - vocState[7] = (uint8_t)(sen5xstate.voc_state_array >> 56); - vocState[6] = (uint8_t)(sen5xstate.voc_state_array >> 48); - vocState[5] = (uint8_t)(sen5xstate.voc_state_array >> 40); - vocState[4] = (uint8_t)(sen5xstate.voc_state_array >> 32); - vocState[3] = (uint8_t)(sen5xstate.voc_state_array >> 24); - vocState[2] = (uint8_t)(sen5xstate.voc_state_array >> 16); - vocState[1] = (uint8_t)(sen5xstate.voc_state_array >> 8); - vocState[0] = (uint8_t)sen5xstate.voc_state_array; - } - - // LOG_DEBUG("Loaded lastCleaning %u", lastCleaning); - // LOG_DEBUG("Loaded lastCleaningValid %u", lastCleaningValid); - // LOG_DEBUG("Loaded oneShotMode %s", oneShotMode ? "true" : "false"); - // LOG_DEBUG("Loaded vocTime %u", vocTime); - // LOG_DEBUG("Loaded [%u, %u, %u, %u, %u, %u, %u, %u]", - // vocState[7], vocState[6], vocState[5], vocState[4], vocState[3], vocState[2], vocState[1], vocState[0]); - // LOG_DEBUG("Loaded %svalid VOC state", vocValid ? "" : "in"); - - okay = true; - } - file.close(); - } else { - LOG_INFO("%s: No state found (File: %s)", sensorName, sen5XStateFileName); - } - spiLock->unlock(); - return okay; -#else - LOG_ERROR("%s: Filesystem not implemented", sensorName); - return false; -#endif -} - -bool SEN5XSensor::saveState() -{ -#ifdef FSCom - auto file = SafeFile(sen5XStateFileName); - - sen5xstate.last_cleaning_time = lastCleaning; - sen5xstate.last_cleaning_valid = lastCleaningValid; - sen5xstate.one_shot_mode = oneShotMode; - - if (model != SEN50) { - sen5xstate.has_voc_state_time = true; - sen5xstate.has_voc_state_valid = true; - sen5xstate.has_voc_state_array = true; - - sen5xstate.voc_state_time = vocTime; - sen5xstate.voc_state_valid = vocValid; - // Unpack state (8 bytes) - sen5xstate.voc_state_array = (((uint64_t)vocState[7]) << 56) | ((uint64_t)vocState[6] << 48) | - ((uint64_t)vocState[5] << 40) | ((uint64_t)vocState[4] << 32) | - ((uint64_t)vocState[3] << 24) | ((uint64_t)vocState[2] << 16) | - ((uint64_t)vocState[1] << 8) | ((uint64_t)vocState[0]); - } - - bool okay = false; - - LOG_INFO("%s: state write to %s", sensorName, sen5XStateFileName); - pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_SEN5XState_size}; - - if (!pb_encode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate)) { - LOG_ERROR("%s: can't encode protobuf %s", sensorName, PB_GET_ERROR(&stream)); - } else { - okay = true; - } - - okay &= file.close(); - - if (okay) - LOG_INFO("%s: state write to %s OK", sensorName, sen5XStateFileName); - - return okay; -#else - LOG_ERROR("%s: Filesystem not implemented", sensorName); - return false; -#endif -} - -bool SEN5XSensor::isActive() -{ - return state == SEN5X_MEASUREMENT || state == SEN5X_MEASUREMENT_2; -} - -uint32_t SEN5XSensor::wakeUp() -{ - - LOG_TRACE("%s Waking", sensorName); - - if (!sendCommand(SEN5X_START_MEASUREMENT)) { - LOG_ERROR("%s: Error starting measurement", sensorName); - // TODO - what should this return?? Something actually on the default interval? - return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; - } - delay(50); // From Sensirion Datasheet - - // TODO - This is currently "problematic" - // If time is updated in between reads, there is no way to - // keep track of how long it has passed - pmMeasureStarted = getTime(); - state = SEN5X_MEASUREMENT; - LOG_INFO("%s: Started measurement mode", sensorName); - return SEN5X_WARMUP_MS_1; -} - -bool SEN5XSensor::vocStateStable() -{ - uint32_t now; - now = getTime(); - uint32_t sinceFirstMeasureStarted = (now - rhtGasMeasureStarted); - LOG_TRACE("%s: sinceFirstMeasureStarted: %us", sensorName, sinceFirstMeasureStarted); - return sinceFirstMeasureStarted > SEN5X_VOC_STATE_WARMUP_S; -} - -bool SEN5XSensor::startCleaning() -{ - // Note: we only should enter here if we have a valid RTC with at least - // RTCQuality::RTCQualityDevice - state = SEN5X_CLEANING; - - // Note that cleaning command can only be run when the sensor is in measurement mode - if (!sendCommand(SEN5X_START_MEASUREMENT)) { - LOG_ERROR("%s: Error starting measurement mode", sensorName); - return false; - } - delay(50); // From Sensirion Datasheet - - if (!sendCommand(SEN5X_START_FAN_CLEANING)) { - LOG_ERROR("%s: Error starting fan cleaning", sensorName); - return false; - } - delay(20); // From Sensirion Datasheet - - // This message will be always printed so the user knows the device it's not hung - LOG_INFO("%s: Started fan cleaning (10 sec)", sensorName); - - uint32_t started = millis(); - while (millis() - started < 10500) { - delay(500); - } - LOG_INFO("%s: Cleaning done", sensorName); - - // Save timestamp in flash so we know when a week has passed - uint32_t now; - now = getValidTime(RTCQuality::RTCQualityDevice); - // If time is not RTCQualityNone, it will return non-zero - lastCleaning = now; - lastCleaningValid = true; - saveState(); - - idle(); - return true; -} - -bool SEN5XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) -{ - state = SEN5X_NOT_DETECTED; - LOG_INFO("%s: Init sensor", sensorName); - - _bus = bus; - _address = dev->address.address; -#ifdef SEN5X_I2C_CLOCK_SPEED - _port = dev->address.port; - reClockI2C.setup(_bus, _port); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - delay(50); // without this there is an error on the deviceReset function - - if (!sendCommand(SEN5X_RESET)) { - LOG_ERROR("%s: error resetting device", sensorName); - return false; - } - delay(200); // From Sensirion Datasheet - - if (!findModel()) { - LOG_ERROR("%s: error finding sensor model", sensorName); - return false; - } - - // Check the firmware version - if (!getVersion()) - return false; - if (firmwareVer < 2) { - LOG_ERROR("%s: firmware too old, unsupported", sensorName); - return false; - } - delay(200); // From Sensirion Datasheet - - // Detection succeeded - state = SEN5X_IDLE; - status = 1; - - // Load state - loadState(); - - // Check if it is time to do a cleaning - uint32_t now; - int32_t passed = 0; - now = getValidTime(RTCQuality::RTCQualityDevice); - - // If time is not RTCQualityNone, it will return non-zero - if (now) { - if (lastCleaningValid) { - - passed = now - lastCleaning; // in seconds - - if (passed > ONE_WEEK_IN_SECONDS && (now > SEN5X_VOC_VALID_DATE)) { - // If current date greater than 01/01/2018 (validity check) - LOG_INFO("%s: Over a week (%us) since last cleaning (%us), trigger cleaning", sensorName, passed, lastCleaning); - startCleaning(); - } else { - LOG_INFO("%s: Cleaning not needed (%ds passed), last cleaning: %us", sensorName, passed, lastCleaning); - } - } else { - // We assume the device has just been updated or it is new, - // so no need to trigger a cleaning. - // Just save the timestamp to do a cleaning one week from now. - // Otherwise, we will never trigger cleaning in some cases - lastCleaning = now; - lastCleaningValid = true; - LOG_INFO("%s: No valid last cleaning date, saving now: %us", sensorName, lastCleaning); - saveState(); - } - - if (model != SEN50) { - if (!vocValid) { - LOG_INFO("%s: No valid VOC's state found", sensorName); - } else { - // Check if state is recent - if (vocStateRecent(now)) { - // If current date greater than 01/01/2018 (validity check) - // Send it to the sensor - LOG_INFO("%s: VOC state is valid and recent", sensorName); - vocStateToSensor(); - } else { - LOG_INFO("%s: VOC state too old or date invalid", sensorName); - LOG_DEBUG("%s: vocTime %u, Passed %u, and now %u", sensorName, vocTime, passed, now); - } - } - } - } else { - // TODO - Should this actually ignore? We could end up never cleaning... - LOG_INFO("%s: Not enough RTCQuality, ignoring saved cleaning and VOC state", sensorName); - } - - idle(false); - rhtGasMeasureStarted = now; - - initI2CSensor(); - return true; -} - -bool SEN5XSensor::readValues() -{ - if (!sendCommand(SEN5X_READ_VALUES)) { - LOG_ERROR("%s: Error sending read command", sensorName); - return false; - } - LOG_TRACE("%s: Reading PM Values", sensorName); - delay(20); // From Sensirion Datasheet - - uint8_t dataBuffer[SEN5X_READ_VALUES_BUFFER_SIZE]{}; - size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_VALUES_BUFFER_SIZE + (SEN5X_READ_VALUES_BUFFER_SIZE / 2)); - if (receivedNumber < SEN5X_READ_VALUES_BUFFER_SIZE) { - LOG_ERROR("%s: Error getting values", sensorName); - return false; - } - - // Get the integers - uint16_t uint_pM1p0 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]); - uint16_t uint_pM2p5 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]); - uint16_t uint_pM4p0 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]); - uint16_t uint_pM10p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]); - - int16_t int_humidity = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); - int16_t int_temperature = static_cast((dataBuffer[10] << 8) | dataBuffer[11]); - int16_t int_vocIndex = static_cast((dataBuffer[12] << 8) | dataBuffer[13]); - int16_t int_noxIndex = static_cast((dataBuffer[14] << 8) | dataBuffer[15]); - - // Convert values based on Sensirion Arduino lib. - // Map values the sensor reports as unavailable (SEN5X_UINT_INVALID / - // SEN5X_INT_INVALID) to the sentinels getMetrics() checks for - sen5xmeasurement.pM1p0 = (uint_pM1p0 != SEN5X_UINT_INVALID) ? (uint_pM1p0 / 10) : UINT16_MAX; - sen5xmeasurement.pM2p5 = (uint_pM2p5 != SEN5X_UINT_INVALID) ? (uint_pM2p5 / 10) : UINT16_MAX; - sen5xmeasurement.pM4p0 = (uint_pM4p0 != SEN5X_UINT_INVALID) ? (uint_pM4p0 / 10) : UINT16_MAX; - sen5xmeasurement.pM10p0 = (uint_pM10p0 != SEN5X_UINT_INVALID) ? (uint_pM10p0 / 10) : UINT16_MAX; - sen5xmeasurement.humidity = (int_humidity != SEN5X_INT_INVALID) ? (int_humidity / 100.0f) : FLT_MAX; - sen5xmeasurement.temperature = (int_temperature != SEN5X_INT_INVALID) ? (int_temperature / 200.0f) : FLT_MAX; - sen5xmeasurement.vocIndex = (int_vocIndex != SEN5X_INT_INVALID) ? (int_vocIndex / 10.0f) : FLT_MAX; - sen5xmeasurement.noxIndex = (int_noxIndex != SEN5X_INT_INVALID) ? (int_noxIndex / 10.0f) : FLT_MAX; - - LOG_TRACE("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, sen5xmeasurement.pM1p0, - sen5xmeasurement.pM2p5, sen5xmeasurement.pM4p0, sen5xmeasurement.pM10p0); - - if (model != SEN50) { - LOG_TRACE("%s: Got readings: humidity=%.2f, temperature=%.2f, vocIndex=%.2f", sensorName, sen5xmeasurement.humidity, - sen5xmeasurement.temperature, sen5xmeasurement.vocIndex); - } - - if (model == SEN55) { - LOG_TRACE("%s: Got readings: noxIndex=%.2f", sensorName, sen5xmeasurement.noxIndex); - } - - return true; -} - -bool SEN5XSensor::readPNValues(bool cumulative) -{ - if (!sendCommand(SEN5X_READ_PM_VALUES)) { - LOG_ERROR("%s: Error sending read command", sensorName); - return false; - } - - LOG_TRACE("%s: Reading PN Values", sensorName); - delay(20); // From Sensirion Datasheet - - uint8_t dataBuffer[SEN5X_READ_PM_BUFFER_SIZE]{}; - size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_PM_BUFFER_SIZE + (SEN5X_READ_PM_BUFFER_SIZE / 2)); - if (receivedNumber < SEN5X_READ_PM_BUFFER_SIZE) { - LOG_ERROR("%s: Error getting PN values", sensorName); - return false; - } - - // Get the integers - // uint16_t uint_pM1p0 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]); - // uint16_t uint_pM2p5 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]); - // uint16_t uint_pM4p0 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]); - // uint16_t uint_pM10p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]); - uint16_t uint_pN0p5 = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); - uint16_t uint_pN1p0 = static_cast((dataBuffer[10] << 8) | dataBuffer[11]); - uint16_t uint_pN2p5 = static_cast((dataBuffer[12] << 8) | dataBuffer[13]); - uint16_t uint_pN4p0 = static_cast((dataBuffer[14] << 8) | dataBuffer[15]); - uint16_t uint_pN10p0 = static_cast((dataBuffer[16] << 8) | dataBuffer[17]); - uint16_t uint_tSize = static_cast((dataBuffer[18] << 8) | dataBuffer[19]); - - // Convert values based on Sensirion Arduino lib. - // Raw PN values are #/cm3 with 0.1 resolution; multiplying by 10 - // converts to #/0.1l without the truncation of dividing first. - // Map values the sensor reports as unavailable (SEN5X_UINT_INVALID) to the - // sentinels getMetrics() checks for - sen5xmeasurement.pN0p5 = (uint_pN0p5 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN0p5 * 10) : UINT32_MAX; - sen5xmeasurement.pN1p0 = (uint_pN1p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN1p0 * 10) : UINT32_MAX; - sen5xmeasurement.pN2p5 = (uint_pN2p5 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN2p5 * 10) : UINT32_MAX; - sen5xmeasurement.pN4p0 = (uint_pN4p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN4p0 * 10) : UINT32_MAX; - sen5xmeasurement.pN10p0 = (uint_pN10p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN10p0 * 10) : UINT32_MAX; - sen5xmeasurement.tSize = (uint_tSize != SEN5X_UINT_INVALID) ? (uint_tSize / 1000.0f) : FLT_MAX; - - // Remove accumuluative values: - // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85 - if (!cumulative) { - if (sen5xmeasurement.pN10p0 != UINT32_MAX && sen5xmeasurement.pN4p0 != UINT32_MAX) - sen5xmeasurement.pN10p0 -= sen5xmeasurement.pN4p0; - if (sen5xmeasurement.pN4p0 != UINT32_MAX && sen5xmeasurement.pN2p5 != UINT32_MAX) - sen5xmeasurement.pN4p0 -= sen5xmeasurement.pN2p5; - if (sen5xmeasurement.pN2p5 != UINT32_MAX && sen5xmeasurement.pN1p0 != UINT32_MAX) - sen5xmeasurement.pN2p5 -= sen5xmeasurement.pN1p0; - if (sen5xmeasurement.pN1p0 != UINT32_MAX && sen5xmeasurement.pN0p5 != UINT32_MAX) - sen5xmeasurement.pN1p0 -= sen5xmeasurement.pN0p5; - } - - LOG_TRACE("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u, tSize=%.2f", sensorName, - sen5xmeasurement.pN0p5, sen5xmeasurement.pN1p0, sen5xmeasurement.pN2p5, sen5xmeasurement.pN4p0, - sen5xmeasurement.pN10p0, sen5xmeasurement.tSize); - - return true; -} - -uint8_t SEN5XSensor::getMeasurements() -{ - uint32_t now; - now = getTime(); - - // Try to get new data - if (!sendCommand(SEN5X_READ_DATA_READY)) { - LOG_ERROR("%s: Error sending command data ready flag", sensorName); - return 2; - } - delay(20); // From Sensirion Datasheet - - uint8_t dataReadyBuffer[SEN5X_DATA_READY_BUFFER_SIZE]{}; - size_t charNumber = readBuffer(&dataReadyBuffer[0], SEN5X_DATA_READY_BUFFER_SIZE + (SEN5X_DATA_READY_BUFFER_SIZE / 2)); - if (charNumber < SEN5X_DATA_READY_BUFFER_SIZE) { - LOG_ERROR("%s: Error getting data ready flag value", sensorName); - return 2; - } - - bool dataReady = dataReadyBuffer[1]; - uint32_t sinceLastDataPollMs = (now - lastDataPoll) * 1000; - // Check if data is ready, and if since last time we requested is less than SEN5X_POLL_INTERVAL - if (!dataReady && (sinceLastDataPollMs > SEN5X_POLL_INTERVAL)) { - LOG_INFO("%s: Data is not ready", sensorName); - return 1; - } - - if (!readValues()) { - LOG_ERROR("%s: Error getting readings", sensorName); - return 2; - } - - if (!readPNValues(false)) { - LOG_ERROR("%s: Error getting PN readings", sensorName); - return 2; - } - - lastDataPoll = now; - - return 0; -} - -int32_t SEN5XSensor::wakeUpTimeMs() -{ - return SEN5X_WARMUP_MS_2; -} - -int32_t SEN5XSensor::pendingForReadyMs() -{ - uint32_t now; - now = getTime(); - uint32_t sincePmMeasureStarted = (now - pmMeasureStarted) * 1000; - LOG_TRACE("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted); - - switch (state) { - case SEN5X_MEASUREMENT: { - - if (sincePmMeasureStarted < SEN5X_WARMUP_MS_1) { - LOG_INFO("%s: not enough time since measurement start", sensorName); - return SEN5X_WARMUP_MS_1 - sincePmMeasureStarted; - } - - if (!pmMeasureStarted) { - pmMeasureStarted = now; - } - - // Get PN values to check if we are above or below threshold - readPNValues(true); - lastDataPoll = now; - - // If the reading is low (the tyhreshold is in #/cm3) and second warmUp hasn't passed we return to come back later - if ((sen5xmeasurement.pN4p0 / 100) < SEN5X_PN4P0_CONC_THD && sincePmMeasureStarted < SEN5X_WARMUP_MS_2) { - LOG_INFO("%s: Concentration low, will ask again in second warm up period", sensorName); - state = SEN5X_MEASUREMENT_2; - // Report how many seconds are pending to cover the first warm up period - return SEN5X_WARMUP_MS_2 - sincePmMeasureStarted; - } - return 0; - } - case SEN5X_MEASUREMENT_2: { - if (sincePmMeasureStarted < SEN5X_WARMUP_MS_2) { - // Report how many seconds are pending to cover the first warm up period - return SEN5X_WARMUP_MS_2 - sincePmMeasureStarted; - } - return 0; - } - default: { - return -1; - } - } -} - -bool SEN5XSensor::getMetrics(meshtastic_Telemetry *measurement) -{ - LOG_INFO("%s: Get metrics", sensorName); - if (!isActive()) { - LOG_INFO("%s: Not in measurement mode", sensorName); - return false; - } - - uint8_t response; - response = getMeasurements(); - - if (response == 0) { - if (sen5xmeasurement.pM1p0 != UINT16_MAX) { - measurement->variant.air_quality_metrics.has_pm10_standard = true; - measurement->variant.air_quality_metrics.pm10_standard = sen5xmeasurement.pM1p0; - } - if (sen5xmeasurement.pM2p5 != UINT16_MAX) { - measurement->variant.air_quality_metrics.has_pm25_standard = true; - measurement->variant.air_quality_metrics.pm25_standard = sen5xmeasurement.pM2p5; - } - if (sen5xmeasurement.pM4p0 != UINT16_MAX) { - measurement->variant.air_quality_metrics.has_pm40_standard = true; - measurement->variant.air_quality_metrics.pm40_standard = sen5xmeasurement.pM4p0; - } - if (sen5xmeasurement.pM10p0 != UINT16_MAX) { - measurement->variant.air_quality_metrics.has_pm100_standard = true; - measurement->variant.air_quality_metrics.pm100_standard = sen5xmeasurement.pM10p0; - } - if (sen5xmeasurement.pN0p5 != UINT32_MAX) { - measurement->variant.air_quality_metrics.has_particles_05um = true; - measurement->variant.air_quality_metrics.particles_05um = sen5xmeasurement.pN0p5; - } - if (sen5xmeasurement.pN1p0 != UINT32_MAX) { - measurement->variant.air_quality_metrics.has_particles_10um = true; - measurement->variant.air_quality_metrics.particles_10um = sen5xmeasurement.pN1p0; - } - if (sen5xmeasurement.pN2p5 != UINT32_MAX) { - measurement->variant.air_quality_metrics.has_particles_25um = true; - measurement->variant.air_quality_metrics.particles_25um = sen5xmeasurement.pN2p5; - } - if (sen5xmeasurement.pN4p0 != UINT32_MAX) { - measurement->variant.air_quality_metrics.has_particles_40um = true; - measurement->variant.air_quality_metrics.particles_40um = sen5xmeasurement.pN4p0; - } - if (sen5xmeasurement.pN10p0 != UINT32_MAX) { - measurement->variant.air_quality_metrics.has_particles_100um = true; - measurement->variant.air_quality_metrics.particles_100um = sen5xmeasurement.pN10p0; - } - if (sen5xmeasurement.tSize != FLT_MAX) { - measurement->variant.air_quality_metrics.has_particles_tps = true; - measurement->variant.air_quality_metrics.particles_tps = sen5xmeasurement.tSize; - } - - if (model != SEN50) { - if (sen5xmeasurement.humidity != FLT_MAX) { - measurement->variant.air_quality_metrics.has_pm_humidity = true; - measurement->variant.air_quality_metrics.pm_humidity = sen5xmeasurement.humidity; - } - if (sen5xmeasurement.temperature != FLT_MAX) { - measurement->variant.air_quality_metrics.has_pm_temperature = true; - measurement->variant.air_quality_metrics.pm_temperature = sen5xmeasurement.temperature; - } - if (sen5xmeasurement.vocIndex != FLT_MAX) { - measurement->variant.air_quality_metrics.has_pm_voc_idx = true; - measurement->variant.air_quality_metrics.pm_voc_idx = sen5xmeasurement.vocIndex; - } - } - - if (model == SEN55) { - if (sen5xmeasurement.noxIndex != FLT_MAX) { - measurement->variant.air_quality_metrics.has_pm_nox_idx = true; - measurement->variant.air_quality_metrics.pm_nox_idx = sen5xmeasurement.noxIndex; - } - } - - return true; - } else if (response == 1) { - // TODO return because data was not ready yet - // Should this return false? - idle(); - return false; - } else if (response == 2) { - // Return with error for non-existing data - idle(); - return false; - } - - return true; -} - -void SEN5XSensor::setMode(bool setOneShot) -{ - oneShotMode = setOneShot; - if (oneShotMode) { - LOG_INFO("%s: set one shot mode", sensorName); - } else { - LOG_INFO("%s: set continuous mode", sensorName); - } -} - -AdminMessageHandleResult SEN5XSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, - meshtastic_AdminMessage *response) -{ - AdminMessageHandleResult result; - result = AdminMessageHandleResult::NOT_HANDLED; - - switch (request->which_payload_variant) { - case meshtastic_AdminMessage_sensor_config_tag: - if (!request->sensor_config.has_sen5x_config) { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - - // Check for one-shot/continuous mode request - if (request->sensor_config.sen5x_config.has_set_one_shot_mode) { - this->setMode(request->sensor_config.sen5x_config.set_one_shot_mode); - } - - // TODO - Add admin command to set temperature offset? - // Check for temperature offset - // if (request->sensor_config.sen5x_config.has_set_temperature) { - // this->setTemperature(request->sensor_config.sen5x_config.set_temperature); - // } - - // TODO - Add admin command to trigger fan cleaning? - // Check for one-shot/continuous mode request - // if (request->sensor_config.sen5x_config.has_fan_cleaning && request->sensor_config.sen5x_config.fan_cleaning) { - // this->startCleaning(); - // } - - result = AdminMessageHandleResult::HANDLED; - break; - - default: - result = AdminMessageHandleResult::NOT_HANDLED; - } - - return result; -} -#endif diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.h b/src/modules/Telemetry/Sensor/SEN5XSensor.h index eeebbd3735..2c8aaf524b 100644 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.h +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.h @@ -1,200 +1,17 @@ +#pragma once #include "configuration.h" #if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR -#include "../detect/ReClockI2C.h" -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "TelemetrySensor.h" -#include "Wire.h" -#include "gps/RTC.h" +#include "SENXXSensor.h" -// Warm up times for SEN5X from the datasheet -#ifndef SEN5X_WARMUP_MS_1 -#define SEN5X_WARMUP_MS_1 15000 -#endif - -#ifndef SEN5X_WARMUP_MS_2 -#define SEN5X_WARMUP_MS_2 30000 -#endif - -#ifndef SEN5X_POLL_INTERVAL -#define SEN5X_POLL_INTERVAL 1000 -#endif - -#ifndef SEN5X_I2C_CLOCK_SPEED -#define SEN5X_I2C_CLOCK_SPEED 100000 -#endif - -/* -Time after which the sensor can go to sleep, as the warmup period has passed -and the VOCs sensor will is allowed to stop (although needs to recover the state -each time) -*/ -#ifndef SEN5X_VOC_STATE_WARMUP_S -/* Note for Testing 5' is enough -Sensirion recommends 1h -This can be bypassed completely if switching to low-power RHT/Gas mode and setting -SEN5X_VOC_STATE_WARMUP_S 0 -*/ -#define SEN5X_VOC_STATE_WARMUP_S 3600 -#endif - -#define ONE_WEEK_IN_SECONDS 604800 - -struct _SEN5XMeasurements { - uint16_t pM1p0; - uint16_t pM2p5; - uint16_t pM4p0; - uint16_t pM10p0; - uint32_t pN0p5; - uint32_t pN1p0; - uint32_t pN2p5; - uint32_t pN4p0; - uint32_t pN10p0; - float tSize; - float humidity; - float temperature; - float vocIndex; - float noxIndex; -}; - -class SEN5XSensor : public TelemetrySensor +// Thin identity wrapper around SENXXSensor for the SEN5X family (SEN50/54/55, +// I2C address SEN5X_ADDR / 0x69). All protocol/state-machine logic lives in +// SENXXSensor; the exact model is auto-detected in probe()/initDevice(). +class SEN5XSensor : public SENXXSensor { - private: -#ifdef SEN5X_I2C_CLOCK_SPEED - ReClockI2C reClockI2C; -#endif - - bool getVersion(); - float firmwareVer = -1; - float hardwareVer = -1; - float protocolVer = -1; - bool findModel(); - -// Commands -#define SEN5X_RESET 0xD304 -#define SEN5X_GET_PRODUCT_NAME 0xD014 -#define SEN5X_GET_FIRMWARE_VERSION 0xD100 -#define SEN5X_START_MEASUREMENT 0x0021 -#define SEN5X_START_MEASUREMENT_RHT_GAS 0x0037 -#define SEN5X_STOP_MEASUREMENT 0x0104 -#define SEN5X_READ_DATA_READY 0x0202 -#define SEN5X_START_FAN_CLEANING 0x5607 -#define SEN5X_RW_VOCS_STATE 0x6181 - -#define SEN5X_READ_VALUES 0x03C4 -#define SEN5X_READ_RAW_VALUES 0x03D2 -#define SEN5X_READ_PM_VALUES 0x0413 - -// Values the sensor reports when a reading is unavailable -#define SEN5X_UINT_INVALID 0xFFFF -#define SEN5X_INT_INVALID 0x7FFF - -// Reply payload sizes in data bytes; the raw I2C transfer adds one CRC byte -// per 2-byte group, so requests are + / 2 raw bytes -#define SEN5X_VERSION_BUFFER_SIZE 8 -#define SEN5X_PRODUCT_NAME_BUFFER_SIZE 32 -#define SEN5X_DATA_READY_BUFFER_SIZE 2 -#define SEN5X_READ_VALUES_BUFFER_SIZE 16 -#define SEN5X_READ_PM_BUFFER_SIZE 20 - -#define SEN5X_VOC_VALID_TIME 600 -#define SEN5X_VOC_VALID_DATE 1514764800 - - enum SEN5Xmodel { SEN5X_UNKNOWN = 0, SEN50 = 0b001, SEN54 = 0b010, SEN55 = 0b100 }; - SEN5Xmodel model = SEN5X_UNKNOWN; - - enum SEN5XState { - SEN5X_OFF, - SEN5X_IDLE, - SEN5X_RHTGAS_ONLY, - SEN5X_MEASUREMENT, - SEN5X_MEASUREMENT_2, - SEN5X_CLEANING, - SEN5X_NOT_DETECTED - }; - SEN5XState state = SEN5X_OFF; - // Flag to work on one-shot (read and sleep), or continuous mode - bool oneShotMode = true; - void setMode(bool setOneShot); - bool vocStateValid(); -/* Sensirion recommends taking a reading after 15 seconds, -if the Particle number reading is over 100#/cm3 the reading is OK, -but if it is lower wait until 30 seconds and take it again. -See: https://sensirion.com/resource/application_note/low_power_mode/sen5x -*/ -#define SEN5X_PN4P0_CONC_THD 100 - - bool sendCommand(uint16_t command); - /** - * @brief Send a command word followed by a data payload; a CRC byte is - * computed and inserted on the wire after every 2-byte pair. - * @param command 16-bit command code, sent big-endian - * @param buffer payload data bytes, without CRCs - * @param byteNumber payload size in data bytes; must be even - * @return true when the full transfer is written and acknowledged - */ - bool sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber = 0); - /** - * @brief Read a reply, verifying and stripping the interleaved CRC bytes. - * @param buffer destination for the data bytes (byteNumber * 2 / 3 of them) - * @param byteNumber raw transfer size including CRCs; must be a multiple - * of 3 (2 data bytes + 1 CRC per group) - * @return the number of data bytes written to buffer, or 0 on any error - */ - uint8_t readBuffer(uint8_t *buffer, uint8_t byteNumber); - uint8_t sen5xCRC(const uint8_t *buffer); - bool startCleaning(); - uint8_t getMeasurements(); - // bool readRawValues(); - bool readPNValues(bool cumulative); - bool readValues(); - - uint32_t pmMeasureStarted = 0; - uint32_t rhtGasMeasureStarted = 0; - uint32_t lastDataPoll = 0; - _SEN5XMeasurements sen5xmeasurement{}; - - bool idle(bool checkState = true); - - protected: - // Store status of the sensor in this file - const char *sen5XStateFileName = "/prefs/sen5X.dat"; - meshtastic_SEN5XState sen5xstate = meshtastic_SEN5XState_init_zero; - - bool loadState(); - bool saveState(); - - // Cleaning State - uint32_t lastCleaning = 0; - bool lastCleaningValid = false; - -// VOC State -#define SEN5X_VOC_STATE_BUFFER_SIZE 8 - uint8_t vocState[SEN5X_VOC_STATE_BUFFER_SIZE]{}; - uint32_t vocTime = 0; - bool vocValid = false; - - bool vocStateFromSensor(); - bool vocStateToSensor(); - bool vocStateStable(); - bool vocStateRecent(uint32_t now); - public: - SEN5XSensor(); - bool probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port); - virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; - virtual bool getMetrics(meshtastic_Telemetry *measurement) override; - - virtual bool isActive() override; - virtual void sleep() override; - virtual uint32_t wakeUp() override; - virtual bool canSleep() override { return true; } - virtual int32_t wakeUpTimeMs() override; - virtual int32_t pendingForReadyMs() override; - - AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, - meshtastic_AdminMessage *response) override; + SEN5XSensor() : SENXXSensor(meshtastic_TelemetrySensorType_SEN5X, "SEN5X") { senXXStateFileName = "/prefs/sen5X.dat"; } }; #endif diff --git a/src/modules/Telemetry/Sensor/SEN6XSensor.h b/src/modules/Telemetry/Sensor/SEN6XSensor.h new file mode 100644 index 0000000000..ea624960e4 --- /dev/null +++ b/src/modules/Telemetry/Sensor/SEN6XSensor.h @@ -0,0 +1,18 @@ +#pragma once +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR + +#include "SENXXSensor.h" + +// Thin identity wrapper around SENXXSensor for the SEN6X family (SEN62, SEN63C, +// SEN65, SEN66, SEN68, SEN69C - I2C address SEN6X_ADDR / 0x6B). All +// protocol/state-machine logic lives in SENXXSensor; the exact model is +// auto-detected in probe()/initDevice(). +class SEN6XSensor : public SENXXSensor +{ + public: + SEN6XSensor() : SENXXSensor(meshtastic_TelemetrySensorType_SEN6X, "SEN6X") { senXXStateFileName = "/prefs/sen6X.dat"; } +}; + +#endif diff --git a/src/modules/Telemetry/Sensor/SENXXSensor.cpp b/src/modules/Telemetry/Sensor/SENXXSensor.cpp new file mode 100644 index 0000000000..d63d448502 --- /dev/null +++ b/src/modules/Telemetry/Sensor/SENXXSensor.cpp @@ -0,0 +1,1613 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "FSCommon.h" +#include "SENXXSensor.h" +#include "SPILock.h" +#include "SafeFile.h" +#include "TelemetrySensor.h" +#include // FLT_MAX +#include +#include +#include // memcpy + +bool SENXXSensor::getVersion() +{ + if (!sendCommand(SENXX_GET_FIRMWARE_VERSION)) { + LOG_ERROR("%s: Error sending version command", sensorName); + return false; + } + delay(20); // From Sensirion Datasheet + + // Version reply layout: fw major/minor, fw debug, hw major/minor, + // protocol major/minor, padding + uint8_t versionBuffer[SENXX_VERSION_BUFFER_SIZE]{}; + size_t charNumber = readBuffer(&versionBuffer[0], SENXX_VERSION_BUFFER_SIZE + (SENXX_VERSION_BUFFER_SIZE / 2)); + if (charNumber < SENXX_VERSION_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting device version value", sensorName); + return false; + } + + firmwareVer = versionBuffer[0] + (versionBuffer[1] / 10.0f); + hardwareVer = versionBuffer[3] + (versionBuffer[4] / 10.0f); + protocolVer = versionBuffer[5] + (versionBuffer[6] / 10.0f); + + LOG_INFO("%s: Firmware Version: %0.2f", sensorName, firmwareVer); + LOG_INFO("%s: Hardware Version: %0.2f", sensorName, hardwareVer); + LOG_INFO("%s: Protocol Version: %0.2f", sensorName, protocolVer); + + return true; +} + +void SENXXSensor::updateCapabilities() +{ + hasRHT = hasVOC = hasNOx = hasCO2 = hasHCHO = false; + readMeasuredValuesCmd = 0; + + switch (model) { + case SEN50: + break; + case SEN54: + hasRHT = true; + hasVOC = true; + break; + case SEN55: + hasRHT = true; + hasVOC = true; + hasNOx = true; + break; + case SEN62: + hasRHT = true; + readMeasuredValuesCmd = 0x04A3; + break; + case SEN63C: + hasRHT = true; + hasCO2 = true; + readMeasuredValuesCmd = 0x0471; + break; + case SEN65: + hasRHT = true; + hasVOC = true; + hasNOx = true; + readMeasuredValuesCmd = 0x0446; + break; + case SEN66: + hasRHT = true; + hasVOC = true; + hasNOx = true; + hasCO2 = true; + readMeasuredValuesCmd = 0x0300; + break; + case SEN68: + hasRHT = true; + hasVOC = true; + hasNOx = true; + hasHCHO = true; + readMeasuredValuesCmd = 0x0467; + break; + case SEN69C: + hasRHT = true; + hasVOC = true; + hasNOx = true; + hasHCHO = true; + hasCO2 = true; + readMeasuredValuesCmd = 0x04B5; + break; + default: + break; + } +} + +bool SENXXSensor::findModel() +{ + if (!sendCommand(SENXX_GET_PRODUCT_NAME)) { + LOG_ERROR("%s: Error asking for product name", sensorName); + return false; + } + delay(50); // From Sensirion Datasheet + + uint8_t name[SENXX_PRODUCT_NAME_BUFFER_SIZE]{}; + size_t charNumber = readBuffer(&name[0], SENXX_PRODUCT_NAME_BUFFER_SIZE + (SENXX_PRODUCT_NAME_BUFFER_SIZE / 2)); + + if (charNumber < SENXX_PRODUCT_NAME_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting device name", sensorName); + return false; + } + + // Every model's product name follows "SEN[C]", + // e.g. "SEN50", "SEN55", "SEN63C", "SEN69C" - so name[3] picks the family + // (SEN5X vs SEN6X) and name[4] picks the exact variant within it. + model = SENXX_UNKNOWN; + if (name[3] == '5') { + switch (name[4]) { + case '0': + model = SEN50; + break; + case '4': + model = SEN54; + break; + case '5': + model = SEN55; + break; + } + } else if (name[3] == '6') { + switch (name[4]) { + case '2': + model = SEN62; + break; + case '3': + model = SEN63C; + break; + case '5': + model = SEN65; + break; + case '6': + model = SEN66; + break; + case '8': + model = SEN68; + break; + case '9': + model = SEN69C; + break; + } + } + + if (model == SENXX_UNKNOWN) { + return false; + } + + updateCapabilities(); + LOG_INFO("%s: found sensor model %s", sensorName, (const char *)name); + return true; +} + +bool SENXXSensor::probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port) +{ + LOG_INFO("%s: probing sensor", sensorName); + + _bus = bus; + _address = address; + +#ifdef SENXX_I2C_CLOCK_SPEED + _port = port; + reClockI2C.setup(_bus, _port); + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + if (!findModel()) { + LOG_DEBUG("%s: can't find sensor model", sensorName); + return false; + } + + return true; +} + +bool SENXXSensor::sendCommand(uint16_t command) +{ + uint8_t nothing; + return sendCommand(command, ¬hing, 0); +} + +bool SENXXSensor::sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber) +{ + // At least we need two bytes for the command + uint8_t bufferSize = 2; + + // Add space for CRC bytes (one every two bytes) + if (byteNumber > 0) + bufferSize += byteNumber + (byteNumber / 2); + + uint8_t toSend[bufferSize]; + uint8_t i = 0; + toSend[i++] = static_cast((command & 0xFF00) >> 8); + toSend[i++] = static_cast((command & 0x00FF) >> 0); + + // Prepare buffer with CRC every third byte + uint8_t bi = 0; + if (byteNumber > 0) { + while (bi < byteNumber) { + toSend[i++] = buffer[bi++]; + toSend[i++] = buffer[bi++]; + uint8_t calcCRC = senxxCRC(&buffer[bi - 2]); + toSend[i++] = calcCRC; + } + } + + // Note: this delay is necessary to allow for long-buffers + delay(20); + _bus->beginTransmission(_address); + size_t writtenBytes = _bus->write(toSend, bufferSize); + uint8_t i2c_error = _bus->endTransmission(); + + if (writtenBytes != bufferSize) { + LOG_ERROR("%s: Error writing on I2C bus", sensorName); + return false; + } + + if (i2c_error != 0) { + LOG_ERROR("%s: Error on I2C communication: %x", sensorName, i2c_error); + return false; + } + return true; +} + +uint8_t SENXXSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber) +{ + size_t readBytes = _bus->requestFrom(_address, byteNumber); + if (readBytes != byteNumber) { + LOG_ERROR("%s: Error reading I2C bus", sensorName); + return 0; + } + + uint8_t i = 0; + uint8_t receivedBytes = 0; + while (readBytes > 0) { + buffer[i++] = _bus->read(); // Just as a reminder: i++ returns i and after that increments. + buffer[i++] = _bus->read(); + uint8_t recvCRC = _bus->read(); + uint8_t calcCRC = senxxCRC(&buffer[i - 2]); + if (recvCRC != calcCRC) { + LOG_ERROR("%s: Checksum error while receiving msg", sensorName); + return 0; + } + readBytes -= 3; + receivedBytes += 2; + } + + return receivedBytes; +} + +uint8_t SENXXSensor::senxxCRC(const uint8_t *buffer) +{ + // This code is based on Sensirion's own implementation + // https://github.com/Sensirion/arduino-core/blob/41fd02cacf307ec4945955c58ae495e56809b96c/src/SensirionCrc.cpp + // Identical CRC8 (poly 0x31, init 0xFF) is used by the whole SEN5X/SEN6X family. + uint8_t crc = 0xff; + + for (uint8_t i = 0; i < 2; i++) { + + crc ^= buffer[i]; + + for (uint8_t bit = 8; bit > 0; bit--) { + if (crc & 0x80) + crc = (crc << 1) ^ 0x31; + else + crc = (crc << 1); + } + } + + return crc; +} + +void SENXXSensor::sleep() +{ + if (state == SENXX_CLEANING) { + // The scheduler's periodic "put idle-able sensors to sleep" housekeeping can reach + // here while a cleaning cycle is still running (isActive() reports SENXX_CLEANING as + // active). Don't let it interrupt the cycle - pendingForReadyMs()/finishCleaning() + // owns the transition out of SENXX_CLEANING. + LOG_INFO("%s: Not going to sleep, fan cleaning is in progress", sensorName); + return; + } +#ifdef SENXX_I2C_CLOCK_SPEED + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + idle(true); +} + +bool SENXXSensor::idle(bool checkState) +{ + // From the datasheet: + // By default, the VOC algorithm resets its state to initial + // values each time a measurement is started, + // even if the measurement was stopped only for a short + // time. So, the VOC index output value needs a long time + // until it is stable again. This can be avoided by + // restoring the previously memorized algorithm state before + // starting the measure mode + + if (checkState) { + // If the stabilisation period is not passed for a model with a VOC sensor, don't go to idle + if (hasVOC) { + // Get VOC state before going to idle mode + vocValid = false; + if (vocStateFromSensor()) { + vocValid = vocStateValid(); + // Check if we have time, and store it + uint32_t now; // If time is RTCQualityNone, it will return zero + now = getValidTime(RTCQuality::RTCQualityDevice); + // Check if state is valid (non-zero) + if (now) { + vocTime = now; + } + } + + if (!(vocStateStable() && vocValid)) { + LOG_INFO("%s: Not stopping measurement, vocState is not stable yet!", sensorName); + return true; + } + } + // Save state and prefs (on all models) + saveState(); + } + + if (!oneShotMode) { + LOG_INFO("%s: Not stopping measurement, continuous mode!", sensorName); + return true; + } else { + LOG_INFO("%s: One shot mode enabled", sensorName); + } + + // SEN6X has no low-power "RHT/Gas only" mode - it must always fully stop. + // Within SEN5X, models without gas sensing (SEN50) also fully stop; SEN54/SEN55 + // instead switch to the RHT/Gas-only mode to keep the VOC engine warm. + // TODO - Decide if for variants with VOC/NOx sensor, the device will be kept on to avoid messing + // up with the engine. In principle, since we are giving the VOC state, the algorithm should work fine, + // however, from tests, we don't see the same. + // Recommendation: if it has VOC / NOx, suggest NOT to use oneShot mode + if (isSen6xFamily() || !hasVOC) { + if (!sendCommand(SENXX_STOP_MEASUREMENT)) { + LOG_ERROR("%s: Error stopping measurement", sensorName); + return false; + } + state = SENXX_IDLE; + LOG_INFO("%s: Stop measurement mode", sensorName); + } else { + if (!sendCommand(SEN5X_START_MEASUREMENT_RHT_GAS)) { + LOG_ERROR("%s: Error switching to RHT/Gas measurement", sensorName); + return false; + } + state = SENXX_RHTGAS_ONLY; + LOG_INFO("%s: Switch to RHT/Gas only measurement mode", sensorName); + } + + delay(200); // From Sensirion Datasheet + pmMeasureStarted = 0; + return true; +} + +bool SENXXSensor::vocStateRecent(uint32_t now) +{ + if (now) { + uint32_t passed = now - vocTime; // in seconds + + // Check if state is recent, less than 10 minutes (600 seconds) + if (passed < SENXX_VOC_VALID_TIME && (now > SENXX_VOC_VALID_DATE)) { + return true; + } + } + return false; +} + +bool SENXXSensor::vocStateValid() +{ + if (!vocState[0] && !vocState[1] && !vocState[2] && !vocState[3] && !vocState[4] && !vocState[5] && !vocState[6] && + !vocState[7]) { + LOG_DEBUG("%s: VOC state is all 0, invalid", sensorName); + return false; + } else { + LOG_DEBUG("%s: VOC state is valid", sensorName); + return true; + } +} + +bool SENXXSensor::vocStateToSensor() +{ + if (!hasVOC) { + return true; + } + + if (!vocStateValid()) { + LOG_INFO("%s: VOC state is invalid, not sending", sensorName); + return true; + } + + if (!sendCommand(SENXX_STOP_MEASUREMENT)) { + LOG_ERROR("%s: Error stopping measurement", sensorName); + return false; + } + delay(200); // From Sensirion Datasheet + + LOG_DEBUG("%s: Sending VOC state to sensor", sensorName); + LOG_DEBUG("[%u, %u, %u, %u, %u, %u, %u, %u]", vocState[0], vocState[1], vocState[2], vocState[3], vocState[4], vocState[5], + vocState[6], vocState[7]); + + // Note: send command already takes into account the CRC + // buffer size increment needed + if (!sendCommand(SENXX_RW_VOCS_STATE, vocState, SENXX_VOC_STATE_BUFFER_SIZE)) { + LOG_ERROR("%s: Error sending VOC's state command", sensorName); + return false; + } + + return true; +} + +bool SENXXSensor::vocStateFromSensor() +{ + if (!hasVOC) { + return true; + } + + LOG_INFO("%s: Getting VOC state from sensor", sensorName); + // Ask VOCs state from the sensor + if (!sendCommand(SENXX_RW_VOCS_STATE)) { + LOG_ERROR("%s: Error sending VOC's state command", sensorName); + return false; + } + + delay(20); // From Sensirion Datasheet + + // Retrieve the data into a staging buffer so a partial read (e.g. a CRC + // failure halfway through) cannot corrupt the current vocState. + // The requested size accounts for the CRC bytes + uint8_t stateBuffer[SENXX_VOC_STATE_BUFFER_SIZE]{}; + size_t receivedNumber = readBuffer(&stateBuffer[0], SENXX_VOC_STATE_BUFFER_SIZE + (SENXX_VOC_STATE_BUFFER_SIZE / 2)); + delay(20); // From Sensirion Datasheet + + if (receivedNumber < SENXX_VOC_STATE_BUFFER_SIZE) { + LOG_DEBUG("%s: Error getting VOC's state", sensorName); + return false; + } + memcpy(vocState, stateBuffer, SENXX_VOC_STATE_BUFFER_SIZE); + + // Print the state (if debug is on) + LOG_DEBUG("%s: VOC state retrieved from sensor: [%u, %u, %u, %u, %u, %u, %u, %u]", sensorName, vocState[0], vocState[1], + vocState[2], vocState[3], vocState[4], vocState[5], vocState[6], vocState[7]); + + return true; +} + +bool SENXXSensor::loadState() +{ +#ifdef FSCom + spiLock->lock(); + auto file = FSCom.open(senXXStateFileName, FILE_O_READ); + bool okay = false; + if (file) { + LOG_INFO("%s: state read from %s", sensorName, senXXStateFileName); + + bool decoded; + uint32_t lastCleaningTime = 0; + bool lastCleaningValidFlag = false; + bool oneShot = true; + uint32_t vocStateTime = 0; + bool vocStateValidFlag = false; + uint64_t vocStateArray = 0; + + if (isSen6xFamily()) { + pb_istream_t stream = {&readcb, &file, meshtastic_SEN6XState_size}; + decoded = pb_decode(&stream, &meshtastic_SEN6XState_msg, &sen6xstate); + if (decoded) { + lastCleaningTime = sen6xstate.last_cleaning_time; + lastCleaningValidFlag = sen6xstate.last_cleaning_valid; + oneShot = sen6xstate.one_shot_mode; + vocStateTime = sen6xstate.voc_state_time; + vocStateValidFlag = sen6xstate.voc_state_valid; + vocStateArray = sen6xstate.voc_state_array; + } else { + LOG_ERROR("%s: can't decode protobuf %s", sensorName, PB_GET_ERROR(&stream)); + } + } else { + pb_istream_t stream = {&readcb, &file, meshtastic_SEN5XState_size}; + decoded = pb_decode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate); + if (decoded) { + lastCleaningTime = sen5xstate.last_cleaning_time; + lastCleaningValidFlag = sen5xstate.last_cleaning_valid; + oneShot = sen5xstate.one_shot_mode; + vocStateTime = sen5xstate.voc_state_time; + vocStateValidFlag = sen5xstate.voc_state_valid; + vocStateArray = sen5xstate.voc_state_array; + } else { + LOG_ERROR("%s: can't decode protobuf %s", sensorName, PB_GET_ERROR(&stream)); + } + } + + if (decoded) { + lastCleaning = lastCleaningTime; + lastCleaningValid = lastCleaningValidFlag; + oneShotMode = oneShot; + + if (hasVOC) { + vocTime = vocStateTime; + vocValid = vocStateValidFlag; + // Unpack state + vocState[7] = (uint8_t)(vocStateArray >> 56); + vocState[6] = (uint8_t)(vocStateArray >> 48); + vocState[5] = (uint8_t)(vocStateArray >> 40); + vocState[4] = (uint8_t)(vocStateArray >> 32); + vocState[3] = (uint8_t)(vocStateArray >> 24); + vocState[2] = (uint8_t)(vocStateArray >> 16); + vocState[1] = (uint8_t)(vocStateArray >> 8); + vocState[0] = (uint8_t)vocStateArray; + } + + okay = true; + } + file.close(); + } else { + LOG_INFO("%s: No state found (File: %s)", sensorName, senXXStateFileName); + } + spiLock->unlock(); + return okay; +#else + LOG_ERROR("%s: Filesystem not implemented", sensorName); + return false; +#endif +} + +bool SENXXSensor::saveState() +{ +#ifdef FSCom + auto file = SafeFile(senXXStateFileName); + + // Pack VOC state (8 bytes) + uint64_t vocStateArray = (((uint64_t)vocState[7]) << 56) | ((uint64_t)vocState[6] << 48) | ((uint64_t)vocState[5] << 40) | + ((uint64_t)vocState[4] << 32) | ((uint64_t)vocState[3] << 24) | ((uint64_t)vocState[2] << 16) | + ((uint64_t)vocState[1] << 8) | ((uint64_t)vocState[0]); + + bool encoded; + LOG_INFO("%s: state write to %s", sensorName, senXXStateFileName); + + if (isSen6xFamily()) { + sen6xstate.last_cleaning_time = lastCleaning; + sen6xstate.last_cleaning_valid = lastCleaningValid; + sen6xstate.one_shot_mode = oneShotMode; + + if (hasVOC) { + sen6xstate.has_voc_state_time = true; + sen6xstate.has_voc_state_valid = true; + sen6xstate.has_voc_state_array = true; + sen6xstate.voc_state_time = vocTime; + sen6xstate.voc_state_valid = vocValid; + sen6xstate.voc_state_array = vocStateArray; + } + + pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_SEN6XState_size}; + encoded = pb_encode(&stream, &meshtastic_SEN6XState_msg, &sen6xstate); + if (!encoded) + LOG_ERROR("%s: can't encode protobuf %s", sensorName, PB_GET_ERROR(&stream)); + } else { + sen5xstate.last_cleaning_time = lastCleaning; + sen5xstate.last_cleaning_valid = lastCleaningValid; + sen5xstate.one_shot_mode = oneShotMode; + + if (hasVOC) { + sen5xstate.has_voc_state_time = true; + sen5xstate.has_voc_state_valid = true; + sen5xstate.has_voc_state_array = true; + sen5xstate.voc_state_time = vocTime; + sen5xstate.voc_state_valid = vocValid; + sen5xstate.voc_state_array = vocStateArray; + } + + pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_SEN5XState_size}; + encoded = pb_encode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate); + if (!encoded) + LOG_ERROR("%s: can't encode protobuf %s", sensorName, PB_GET_ERROR(&stream)); + } + + bool okay = encoded; + okay &= file.close(); + + if (okay) + LOG_INFO("%s: state write to %s successful", sensorName, senXXStateFileName); + + return okay; +#else + LOG_ERROR("%s: Filesystem not implemented", sensorName); + return false; +#endif +} + +bool SENXXSensor::isActive() +{ + // SENXX_CLEANING counts as active so the scheduler polls pendingForReadyMs() + // (which drives the cleaning cycle to completion) instead of calling wakeUp() again. + return state == SENXX_MEASUREMENT || state == SENXX_MEASUREMENT_2 || state == SENXX_CLEANING; +} + +bool SENXXSensor::checkRTCQualityImproved() +{ + RTCQuality currentQuality = getRTCQuality(); + if (currentQuality == lastRTCQuality) { + return false; + } + LOG_DEBUG("%s: RTC quality changed: %s -> %s", sensorName, RtcName(lastRTCQuality), RtcName(currentQuality)); + bool gainedUsableClock = lastRTCQuality < RTCQuality::RTCQualityDevice && currentQuality >= RTCQuality::RTCQualityDevice; + lastRTCQuality = currentQuality; + return gainedUsableClock; +} + +void SENXXSensor::reconcileTimeDependentState(uint32_t now) +{ + if (lastCleaningValid) { + int32_t passed = now - lastCleaning; // in seconds + + if (passed > ONE_WEEK_IN_SECONDS && (now > SENXX_VOC_VALID_DATE)) { + // If current date greater than 01/01/2018 (validity check) + LOG_INFO("%s: More than a week (%us) since last cleaning in epoch (%us). Trigger, cleaning...", sensorName, passed, + lastCleaning); + startCleaning(); + } else { + LOG_INFO("%s: Cleaning not needed (%ds passed). Last cleaning date (in epoch): %us", sensorName, passed, + lastCleaning); + } + } else { + // We assume the device has just been updated or it is new, + // so no need to trigger a cleaning. + // Just save the timestamp to do a cleaning one week from now. + // Otherwise, we will never trigger cleaning in some cases + lastCleaning = now; + lastCleaningValid = true; + LOG_INFO("%s: No valid last cleaning date found, saving it now: %us", sensorName, lastCleaning); + saveState(); + } + + if (hasVOC) { + if (!vocValid) { + LOG_INFO("%s: No valid VOC's state found", sensorName); + } else { + // Check if state is recent + if (vocStateRecent(now)) { + // If current date greater than 01/01/2018 (validity check) + // Send it to the sensor + LOG_INFO("%s: VOC state is valid and recent", sensorName); + vocStateToSensor(); + } else { + LOG_INFO("%s: VOC state is too old or date is invalid", sensorName); + LOG_DEBUG("%s: vocTime %u, and now %u", sensorName, vocTime, now); + } + } + } +} + +uint32_t SENXXSensor::wakeUp() +{ +#ifdef SENXX_I2C_CLOCK_SPEED + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + return wakeUpInternal(); +} + +uint32_t SENXXSensor::wakeUpInternal() +{ + + LOG_DEBUG("%s: Waking up sensor", sensorName); + + // The RTC may not have had a valid time when we last checked (e.g. right after boot, + // before a WiFi/GPS/phone time source connected). Each wake is a natural, frequent point + // to notice that it has since become valid and reconcile the saved cleaning/VOC state + // against real elapsed time, instead of only ever checking once in initDevice(). + if (checkRTCQualityImproved()) { + uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + if (now) { + LOG_INFO("%s: RTC became available (%s), reconciling saved cleaning/VOC state", sensorName, RtcName(lastRTCQuality)); + reconcileTimeDependentState(now); + if (state == SENXX_CLEANING) { + // A cleaning cycle was just started; let it run its course via + // pendingForReadyMs() instead of overwriting state with the + // measurement-start logic below. + return SENXX_CLEANING_DURATION_MS; + } + } + } + + if (!sendCommand(SENXX_START_MEASUREMENT)) { + LOG_ERROR("%s: Error starting measurement", sensorName); + // TODO - what should this return?? Something actually on the default interval? + return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; + } + delay(50); // From Sensirion Datasheet + + pmMeasureStarted = millis(); + state = SENXX_MEASUREMENT; + LOG_INFO("%s: Started measurement mode", sensorName); + return SENXX_PM_WARMUP_MS_1; +} + +bool SENXXSensor::vocStateStable() +{ + uint32_t sinceFirstMeasureStarted = (millis() - rhtGasMeasureStarted) / 1000; + LOG_DEBUG("%s: sinceFirstMeasureStarted: %us", sensorName, sinceFirstMeasureStarted); + return sinceFirstMeasureStarted > SENXX_VOC_STATE_WARMUP_S; +} + +bool SENXXSensor::startCleaning() +{ + // Note: we only should enter here if we have a valid RTC with at least + // RTCQuality::RTCQualityDevice + SENXXState previousState = state; + state = SENXX_CLEANING; + + // Note that cleaning command can only be run when the sensor is in measurement mode + if (!sendCommand(SENXX_START_MEASUREMENT)) { + LOG_ERROR("%s: Error starting measurement mode", sensorName); + state = previousState; + return false; + } + delay(50); // From Sensirion Datasheet + + if (!sendCommand(SENXX_START_FAN_CLEANING)) { + LOG_ERROR("%s: Error starting fan cleaning", sensorName); + state = previousState; + return false; + } + delay(20); // From Sensirion Datasheet + + // This message will be always printed so the user knows the device it's not hung + LOG_INFO("%s: Started fan cleaning it will take 10 seconds...", sensorName); + + // Don't block the caller for the ~10.5s the cycle takes - pendingForReadyMs() + // polls SENXX_CLEANING and calls finishCleaning() once it's done. + cleaningStarted = millis(); + return true; +} + +void SENXXSensor::finishCleaning() +{ + LOG_INFO("%s: Cleaning done", sensorName); + + // Save timestamp in flash so we know when a week has passed + uint32_t now; + now = getValidTime(RTCQuality::RTCQualityDevice); + if (now) { + lastCleaning = now; + lastCleaningValid = true; + saveState(); + } + + idle(); +} + +bool SENXXSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + state = SENXX_NOT_DETECTED; + LOG_INFO("%s: Init sensor", sensorName); + + _bus = bus; + _address = dev->address.address; +#ifdef SENXX_I2C_CLOCK_SPEED + _port = dev->address.port; + reClockI2C.setup(_bus, _port); + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + delay(50); // without this there is an error on the deviceReset function + + if (!sendCommand(SENXX_RESET)) { + LOG_ERROR("%s: error resetting device", sensorName); + return false; + } + delay(200); // From Sensirion Datasheet + + if (!findModel()) { + LOG_ERROR("%s: error finding sensor model", sensorName); + return false; + } + + // Check the firmware version + if (!getVersion()) + return false; + if (firmwareVer < 2) { + LOG_ERROR("%s: firmware is too old and will not work with this implementation", sensorName); + return false; + } + delay(200); // From Sensirion Datasheet + + // Detection succeeded + state = SENXX_IDLE; + status = 1; + + // Load state + loadState(); + + // Check if it is time to do a cleaning / whether the saved VOC state is still usable. + // This needs a real clock; if we don't have one yet (typical right after boot, before + // any time source has connected), don't lose the saved state - just defer the check. + // wakeUp() re-checks getRTCQuality() on every wake via checkRTCQualityImproved() and + // will run this same reconciliation the moment a valid time becomes available. + lastRTCQuality = getRTCQuality(); + uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + if (now) { + reconcileTimeDependentState(now); + } else { + LOG_INFO("%s: Not enough RTCQuality yet, deferring saved cleaning/VOC state check until it improves", sensorName); + } + + // If reconcileTimeDependentState() just started a cleaning cycle, leave state as + // SENXX_CLEANING - idle(false) would send SENXX_STOP_MEASUREMENT and clobber it + // mid-cycle. pendingForReadyMs() will poll it to completion once the scheduler starts. + rhtGasMeasureStarted = millis(); + if (state != SENXX_CLEANING) { + idle(false); + } + + initI2CSensor(); + return true; +} + +bool SENXXSensor::readValues() +{ + if (isSen6xFamily()) { + if (!sendCommand(readMeasuredValuesCmd)) { + LOG_ERROR("%s: Error sending read command", sensorName); + return false; + } + LOG_DEBUG("%s: Reading measured values", sensorName); + delay(20); // From Sensirion Datasheet + + // Fixed field order per the SEN6x datasheet: PM1.0, PM2.5, PM4.0, PM10.0, + // [Humidity, Temperature], [VOC], [NOx], [HCHO], [CO2] - each block only + // present if the model supports it. + uint8_t wordCount = 4 + (hasRHT ? 2 : 0) + (hasVOC ? 1 : 0) + (hasNOx ? 1 : 0) + (hasHCHO ? 1 : 0) + (hasCO2 ? 1 : 0); + uint8_t dataBuffer[20]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], wordCount * 3); + if (receivedNumber < (size_t)(wordCount * 2)) { + LOG_ERROR("%s: Error getting values", sensorName); + return false; + } + + uint8_t idx = 0; + auto nextWord = [&dataBuffer, &idx]() -> int16_t { + int16_t v = static_cast((dataBuffer[idx] << 8) | dataBuffer[idx + 1]); + idx += 2; + return v; + }; + + uint16_t uint_pM1p0 = static_cast(nextWord()); + uint16_t uint_pM2p5 = static_cast(nextWord()); + uint16_t uint_pM4p0 = static_cast(nextWord()); + uint16_t uint_pM10p0 = static_cast(nextWord()); + + // Map values the sensor reports as unavailable (SENXX_UINT_INVALID / + // SENXX_INT_INVALID) to the sentinels getMetrics() checks for + senxxmeasurement.pM1p0 = (uint_pM1p0 != SENXX_UINT_INVALID) ? (uint_pM1p0 / 10) : UINT16_MAX; + senxxmeasurement.pM2p5 = (uint_pM2p5 != SENXX_UINT_INVALID) ? (uint_pM2p5 / 10) : UINT16_MAX; + senxxmeasurement.pM4p0 = (uint_pM4p0 != SENXX_UINT_INVALID) ? (uint_pM4p0 / 10) : UINT16_MAX; + senxxmeasurement.pM10p0 = (uint_pM10p0 != SENXX_UINT_INVALID) ? (uint_pM10p0 / 10) : UINT16_MAX; + + senxxmeasurement.humidity = FLT_MAX; + senxxmeasurement.temperature = FLT_MAX; + senxxmeasurement.vocIndex = FLT_MAX; + senxxmeasurement.noxIndex = FLT_MAX; + senxxmeasurement.hcho = FLT_MAX; + senxxmeasurement.co2 = FLT_MAX; + + LOG_DEBUG("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, senxxmeasurement.pM1p0, + senxxmeasurement.pM2p5, senxxmeasurement.pM4p0, senxxmeasurement.pM10p0); + + if (hasRHT) { + int16_t int_humidity = nextWord(); + int16_t int_temperature = nextWord(); + senxxmeasurement.humidity = (int_humidity != SENXX_INT_INVALID) ? (int_humidity / 100.0f) : FLT_MAX; + senxxmeasurement.temperature = (int_temperature != SENXX_INT_INVALID) ? (int_temperature / 200.0f) : FLT_MAX; + LOG_DEBUG("%s: Got readings: humidity=%.2f, temperature=%.2f", sensorName, senxxmeasurement.humidity, + senxxmeasurement.temperature); + } + if (hasVOC) { + int16_t int_vocIndex = nextWord(); + senxxmeasurement.vocIndex = (int_vocIndex != SENXX_INT_INVALID) ? (int_vocIndex / 10.0f) : FLT_MAX; + LOG_DEBUG("%s: Got readings: vocIndex=%.2f", sensorName, senxxmeasurement.vocIndex); + } + if (hasNOx) { + int16_t int_noxIndex = nextWord(); + senxxmeasurement.noxIndex = (int_noxIndex != SENXX_INT_INVALID) ? (int_noxIndex / 10.0f) : FLT_MAX; + LOG_DEBUG("%s: Got readings: noxIndex=%.2f", sensorName, senxxmeasurement.noxIndex); + } + if (hasHCHO) { + uint16_t uint_hcho = static_cast(nextWord()); + senxxmeasurement.hcho = (uint_hcho != SENXX_UINT_INVALID) ? (uint_hcho / 10.0f) : FLT_MAX; + LOG_DEBUG("%s: Got readings: HCHO=%.2f", sensorName, senxxmeasurement.hcho); + } + if (hasCO2) { + uint16_t uint_co2 = static_cast(nextWord()); + senxxmeasurement.co2 = (uint_co2 != SENXX_UINT_INVALID) ? uint_co2 : FLT_MAX; + LOG_DEBUG("%s: Got readings: CO2=%.2f", sensorName, senxxmeasurement.co2); + } + + return true; + } + + // SEN5X always answers with the same fixed 8-word layout (PM1/2.5/4/10, humidity, + // temperature, VOC, NOx) regardless of model; unsupported fields simply come + // back as Sensirion's "value unknown" placeholders. + if (!sendCommand(SEN5X_READ_VALUES)) { + LOG_ERROR("%s: Error sending read command", sensorName); + return false; + } + LOG_DEBUG("%s: Reading PM Values", sensorName); + delay(20); // From Sensirion Datasheet + + uint8_t dataBuffer[SEN5X_READ_VALUES_BUFFER_SIZE]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_VALUES_BUFFER_SIZE + (SEN5X_READ_VALUES_BUFFER_SIZE / 2)); + if (receivedNumber < SEN5X_READ_VALUES_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting values", sensorName); + return false; + } + + // Get the integers + uint16_t uint_pM1p0 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]); + uint16_t uint_pM2p5 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]); + uint16_t uint_pM4p0 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]); + uint16_t uint_pM10p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]); + + int16_t int_humidity = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); + int16_t int_temperature = static_cast((dataBuffer[10] << 8) | dataBuffer[11]); + int16_t int_vocIndex = static_cast((dataBuffer[12] << 8) | dataBuffer[13]); + int16_t int_noxIndex = static_cast((dataBuffer[14] << 8) | dataBuffer[15]); + + // Convert values based on Sensirion Arduino lib. Map values the sensor + // reports as unavailable (SENXX_UINT_INVALID / SENXX_INT_INVALID) to the + // sentinels getMetrics() checks for + senxxmeasurement.pM1p0 = (uint_pM1p0 != SENXX_UINT_INVALID) ? (uint_pM1p0 / 10) : UINT16_MAX; + senxxmeasurement.pM2p5 = (uint_pM2p5 != SENXX_UINT_INVALID) ? (uint_pM2p5 / 10) : UINT16_MAX; + senxxmeasurement.pM4p0 = (uint_pM4p0 != SENXX_UINT_INVALID) ? (uint_pM4p0 / 10) : UINT16_MAX; + senxxmeasurement.pM10p0 = (uint_pM10p0 != SENXX_UINT_INVALID) ? (uint_pM10p0 / 10) : UINT16_MAX; + senxxmeasurement.humidity = (int_humidity != SENXX_INT_INVALID) ? (int_humidity / 100.0f) : FLT_MAX; + senxxmeasurement.temperature = (int_temperature != SENXX_INT_INVALID) ? (int_temperature / 200.0f) : FLT_MAX; + senxxmeasurement.vocIndex = (int_vocIndex != SENXX_INT_INVALID) ? (int_vocIndex / 10.0f) : FLT_MAX; + senxxmeasurement.noxIndex = (int_noxIndex != SENXX_INT_INVALID) ? (int_noxIndex / 10.0f) : FLT_MAX; + senxxmeasurement.co2 = FLT_MAX; + senxxmeasurement.hcho = FLT_MAX; + + LOG_DEBUG("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, senxxmeasurement.pM1p0, + senxxmeasurement.pM2p5, senxxmeasurement.pM4p0, senxxmeasurement.pM10p0); + + if (hasRHT) { + LOG_DEBUG("%s: Got readings: humidity=%.2f, temperature=%.2f, vocIndex=%.2f", sensorName, senxxmeasurement.humidity, + senxxmeasurement.temperature, senxxmeasurement.vocIndex); + } + + if (hasNOx) { + LOG_DEBUG("%s: Got readings: noxIndex=%.2f", sensorName, senxxmeasurement.noxIndex); + } + + return true; +} + +bool SENXXSensor::readPNValues(bool cumulative) +{ + if (isSen6xFamily()) { + if (!sendCommand(SEN6X_READ_NUMBER_CONCENTRATION_VALUES)) { + LOG_ERROR("%s: Error sending read command", sensorName); + return false; + } + + LOG_DEBUG("%s: Reading PN Values", sensorName); + delay(20); // From Sensirion Datasheet + + uint8_t dataBuffer[10]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], 15); + if (receivedNumber < 10) { + LOG_ERROR("%s: Error getting PN values", sensorName); + return false; + } + + uint16_t uint_pN0p5 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]); + uint16_t uint_pN1p0 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]); + uint16_t uint_pN2p5 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]); + uint16_t uint_pN4p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]); + uint16_t uint_pN10p0 = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); + + // Raw PN values are #/cm3 with 0.1 resolution; multiplying by 10 converts + // to #/0.1l without the truncation of dividing first. Map values the + // sensor reports as unavailable (SENXX_UINT_INVALID) to the sentinel. + senxxmeasurement.pN0p5 = (uint_pN0p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN0p5 * 10) : UINT32_MAX; + senxxmeasurement.pN1p0 = (uint_pN1p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN1p0 * 10) : UINT32_MAX; + senxxmeasurement.pN2p5 = (uint_pN2p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN2p5 * 10) : UINT32_MAX; + senxxmeasurement.pN4p0 = (uint_pN4p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN4p0 * 10) : UINT32_MAX; + senxxmeasurement.pN10p0 = (uint_pN10p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN10p0 * 10) : UINT32_MAX; + // Unlike SEN5X's number-concentration command, SEN6X's doesn't return a + // "typical particle size" word. + senxxmeasurement.tSize = FLT_MAX; + + // Remove accumulative values: + // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85 + if (!cumulative) { + if (senxxmeasurement.pN10p0 != UINT32_MAX && senxxmeasurement.pN4p0 != UINT32_MAX) + senxxmeasurement.pN10p0 -= senxxmeasurement.pN4p0; + if (senxxmeasurement.pN4p0 != UINT32_MAX && senxxmeasurement.pN2p5 != UINT32_MAX) + senxxmeasurement.pN4p0 -= senxxmeasurement.pN2p5; + if (senxxmeasurement.pN2p5 != UINT32_MAX && senxxmeasurement.pN1p0 != UINT32_MAX) + senxxmeasurement.pN2p5 -= senxxmeasurement.pN1p0; + if (senxxmeasurement.pN1p0 != UINT32_MAX && senxxmeasurement.pN0p5 != UINT32_MAX) + senxxmeasurement.pN1p0 -= senxxmeasurement.pN0p5; + } + + LOG_DEBUG("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u", sensorName, senxxmeasurement.pN0p5, + senxxmeasurement.pN1p0, senxxmeasurement.pN2p5, senxxmeasurement.pN4p0, senxxmeasurement.pN10p0); + + return true; + } + + if (!sendCommand(SEN5X_READ_PM_VALUES)) { + LOG_ERROR("%s: Error sending read command", sensorName); + return false; + } + + LOG_DEBUG("%s: Reading PN Values", sensorName); + delay(20); // From Sensirion Datasheet + + uint8_t dataBuffer[SEN5X_READ_PM_BUFFER_SIZE]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_PM_BUFFER_SIZE + (SEN5X_READ_PM_BUFFER_SIZE / 2)); + if (receivedNumber < SEN5X_READ_PM_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting PN values", sensorName); + return false; + } + + // Get the integers + uint16_t uint_pN0p5 = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); + uint16_t uint_pN1p0 = static_cast((dataBuffer[10] << 8) | dataBuffer[11]); + uint16_t uint_pN2p5 = static_cast((dataBuffer[12] << 8) | dataBuffer[13]); + uint16_t uint_pN4p0 = static_cast((dataBuffer[14] << 8) | dataBuffer[15]); + uint16_t uint_pN10p0 = static_cast((dataBuffer[16] << 8) | dataBuffer[17]); + uint16_t uint_tSize = static_cast((dataBuffer[18] << 8) | dataBuffer[19]); + + // Convert values based on Sensirion Arduino lib. Raw PN values are #/cm3 + // with 0.1 resolution; multiplying by 10 converts to #/0.1l without the + // truncation of dividing first. Map values the sensor reports as + // unavailable (SENXX_UINT_INVALID) to the sentinel getMetrics() checks for. + senxxmeasurement.pN0p5 = (uint_pN0p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN0p5 * 10) : UINT32_MAX; + senxxmeasurement.pN1p0 = (uint_pN1p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN1p0 * 10) : UINT32_MAX; + senxxmeasurement.pN2p5 = (uint_pN2p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN2p5 * 10) : UINT32_MAX; + senxxmeasurement.pN4p0 = (uint_pN4p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN4p0 * 10) : UINT32_MAX; + senxxmeasurement.pN10p0 = (uint_pN10p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN10p0 * 10) : UINT32_MAX; + senxxmeasurement.tSize = (uint_tSize != SENXX_UINT_INVALID) ? (uint_tSize / 1000.0f) : FLT_MAX; + + // Remove accumuluative values: + // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85 + if (!cumulative) { + if (senxxmeasurement.pN10p0 != UINT32_MAX && senxxmeasurement.pN4p0 != UINT32_MAX) + senxxmeasurement.pN10p0 -= senxxmeasurement.pN4p0; + if (senxxmeasurement.pN4p0 != UINT32_MAX && senxxmeasurement.pN2p5 != UINT32_MAX) + senxxmeasurement.pN4p0 -= senxxmeasurement.pN2p5; + if (senxxmeasurement.pN2p5 != UINT32_MAX && senxxmeasurement.pN1p0 != UINT32_MAX) + senxxmeasurement.pN2p5 -= senxxmeasurement.pN1p0; + if (senxxmeasurement.pN1p0 != UINT32_MAX && senxxmeasurement.pN0p5 != UINT32_MAX) + senxxmeasurement.pN1p0 -= senxxmeasurement.pN0p5; + } + + LOG_DEBUG("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u, tSize=%.2f", sensorName, + senxxmeasurement.pN0p5, senxxmeasurement.pN1p0, senxxmeasurement.pN2p5, senxxmeasurement.pN4p0, + senxxmeasurement.pN10p0, senxxmeasurement.tSize); + + return true; +} + +uint8_t SENXXSensor::getMeasurements() +{ + uint32_t now = millis(); + + // Try to get new data + if (!sendCommand(SENXX_READ_DATA_READY)) { + LOG_ERROR("%s: Error sending command data ready flag", sensorName); + return 2; + } + delay(20); // From Sensirion Datasheet + + uint8_t dataReadyBuffer[SENXX_DATA_READY_BUFFER_SIZE]{}; + size_t charNumber = readBuffer(&dataReadyBuffer[0], SENXX_DATA_READY_BUFFER_SIZE + (SENXX_DATA_READY_BUFFER_SIZE / 2)); + if (charNumber < SENXX_DATA_READY_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting device version value", sensorName); + return 2; + } + + bool dataReady = dataReadyBuffer[1]; + uint32_t sinceLastDataPollMs = now - lastDataPoll; + // Check if data is ready, and if since last time we requested is less than SENXX_POLL_INTERVAL + if (!dataReady || (sinceLastDataPollMs < SENXX_POLL_INTERVAL)) { + LOG_INFO("%s: Data is not ready", sensorName); + return 1; + } + + if (!readValues()) { + LOG_ERROR("%s: Error getting readings", sensorName); + return 2; + } + + if (!readPNValues(false)) { + LOG_ERROR("%s: Error getting PN readings", sensorName); + return 2; + } + + lastDataPoll = now; + + return 0; +} + +int32_t SENXXSensor::wakeUpTimeMs() +{ + return SENXX_PM_WARMUP_MS_2; +} + +int32_t SENXXSensor::pendingForReadyMs() +{ +#ifdef SENXX_I2C_CLOCK_SPEED + // Only the SENXX_MEASUREMENT/SENXX_CLEANING branches below touch I2C, but this is only + // ever called while isActive() (i.e. one of those, or SENXX_MEASUREMENT_2, which doesn't), + // so bracketing unconditionally here is simpler than guarding each branch separately. + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + uint32_t now = millis(); + uint32_t sincePmMeasureStarted = now - pmMeasureStarted; + LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted); + + switch (state) { + case SENXX_MEASUREMENT: { + + if (!pmMeasureStarted) { + pmMeasureStarted = now; + } + + if (sincePmMeasureStarted < SENXX_PM_WARMUP_MS_1) { + LOG_INFO("%s: not enough time passed since starting measurement", sensorName); + return SENXX_PM_WARMUP_MS_1 - sincePmMeasureStarted; + } + + // Get PN values to check if we are above or below threshold + readPNValues(true); + lastDataPoll = now; + + // If the reading is low (the threshold is in #/cm3) and second warmUp hasn't passed we return to come back later + if ((senxxmeasurement.pN4p0 / 100) < SENXX_PN4P0_CONC_THD && sincePmMeasureStarted < SENXX_PM_WARMUP_MS_2) { + LOG_INFO("%s: Concentration is low, we will ask again in the second warm up period", sensorName); + state = SENXX_MEASUREMENT_2; + // Report how many seconds are pending to cover the first warm up period + return SENXX_PM_WARMUP_MS_2 - sincePmMeasureStarted; + } + // CO2 sensor has an additional warmup time + if (hasCO2 && sincePmMeasureStarted < SEN6X_CO2_WARMUP_MS) { + return SEN6X_CO2_WARMUP_MS - sincePmMeasureStarted; + } + return 0; + } + case SENXX_MEASUREMENT_2: { + if (sincePmMeasureStarted < SENXX_PM_WARMUP_MS_2) { + // Report how many seconds are pending to cover the first warm up period + return SENXX_PM_WARMUP_MS_2 - sincePmMeasureStarted; + } + return 0; + } + case SENXX_CLEANING: { + uint32_t sinceCleaningStarted = now - cleaningStarted; + if (sinceCleaningStarted < SENXX_CLEANING_DURATION_MS) { + return SENXX_CLEANING_DURATION_MS - sinceCleaningStarted; + } + finishCleaning(); + return 0; + } + default: { + return -1; + } + } +} + +bool SENXXSensor::getMetrics(meshtastic_Telemetry *measurement) +{ + LOG_INFO("%s: Attempting to get metrics", sensorName); + if (!isActive()) { + LOG_INFO("%s: not in measurement mode", sensorName); + return false; + } + +#ifdef SENXX_I2C_CLOCK_SPEED + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + uint8_t response; + response = getMeasurements(); + + if (response == 0) { + if (senxxmeasurement.pM1p0 != UINT16_MAX) { + measurement->variant.air_quality_metrics.has_pm10_standard = true; + measurement->variant.air_quality_metrics.pm10_standard = senxxmeasurement.pM1p0; + } + if (senxxmeasurement.pM2p5 != UINT16_MAX) { + measurement->variant.air_quality_metrics.has_pm25_standard = true; + measurement->variant.air_quality_metrics.pm25_standard = senxxmeasurement.pM2p5; + } + if (senxxmeasurement.pM4p0 != UINT16_MAX) { + measurement->variant.air_quality_metrics.has_pm40_standard = true; + measurement->variant.air_quality_metrics.pm40_standard = senxxmeasurement.pM4p0; + } + if (senxxmeasurement.pM10p0 != UINT16_MAX) { + measurement->variant.air_quality_metrics.has_pm100_standard = true; + measurement->variant.air_quality_metrics.pm100_standard = senxxmeasurement.pM10p0; + } + if (senxxmeasurement.pN0p5 != UINT32_MAX) { + measurement->variant.air_quality_metrics.has_particles_05um = true; + measurement->variant.air_quality_metrics.particles_05um = senxxmeasurement.pN0p5; + } + if (senxxmeasurement.pN1p0 != UINT32_MAX) { + measurement->variant.air_quality_metrics.has_particles_10um = true; + measurement->variant.air_quality_metrics.particles_10um = senxxmeasurement.pN1p0; + } + if (senxxmeasurement.pN2p5 != UINT32_MAX) { + measurement->variant.air_quality_metrics.has_particles_25um = true; + measurement->variant.air_quality_metrics.particles_25um = senxxmeasurement.pN2p5; + } + if (senxxmeasurement.pN4p0 != UINT32_MAX) { + measurement->variant.air_quality_metrics.has_particles_40um = true; + measurement->variant.air_quality_metrics.particles_40um = senxxmeasurement.pN4p0; + } + if (senxxmeasurement.pN10p0 != UINT32_MAX) { + measurement->variant.air_quality_metrics.has_particles_100um = true; + measurement->variant.air_quality_metrics.particles_100um = senxxmeasurement.pN10p0; + } + if (senxxmeasurement.tSize != FLT_MAX) { + measurement->variant.air_quality_metrics.has_particles_tps = true; + measurement->variant.air_quality_metrics.particles_tps = senxxmeasurement.tSize; + } + + if (hasRHT) { + if (senxxmeasurement.humidity != FLT_MAX) { + measurement->variant.air_quality_metrics.has_pm_humidity = true; + measurement->variant.air_quality_metrics.pm_humidity = senxxmeasurement.humidity; + } + if (senxxmeasurement.temperature != FLT_MAX) { + measurement->variant.air_quality_metrics.has_pm_temperature = true; + measurement->variant.air_quality_metrics.pm_temperature = senxxmeasurement.temperature; + } + } + + if (hasVOC && senxxmeasurement.vocIndex != FLT_MAX) { + measurement->variant.air_quality_metrics.has_pm_voc_idx = true; + measurement->variant.air_quality_metrics.pm_voc_idx = senxxmeasurement.vocIndex; + } + + if (hasNOx && senxxmeasurement.noxIndex != FLT_MAX) { + measurement->variant.air_quality_metrics.has_pm_nox_idx = true; + measurement->variant.air_quality_metrics.pm_nox_idx = senxxmeasurement.noxIndex; + } + + if (hasCO2 && senxxmeasurement.co2 != FLT_MAX) { + measurement->variant.air_quality_metrics.has_co2 = true; + measurement->variant.air_quality_metrics.co2 = (uint32_t)senxxmeasurement.co2; + } + + if (hasHCHO && senxxmeasurement.hcho != FLT_MAX) { + measurement->variant.air_quality_metrics.has_form_formaldehyde = true; + measurement->variant.air_quality_metrics.form_formaldehyde = senxxmeasurement.hcho; + } + + if (isSen6xFamily()) { + uint32_t statusFlags = 0; + if (readDeviceStatus(statusFlags)) { + measurement->variant.air_quality_metrics.has_pm_status_flags = true; + measurement->variant.air_quality_metrics.pm_status_flags = statusFlags; + logDeviceStatus(statusFlags); + } + } + + return true; + } else if (response == 1) { + // TODO return because data was not ready yet + // Should this return false? + idle(); + return false; + } else if (response == 2) { + // Return with error for non-existing data + idle(); + return false; + } + + return true; +} + +bool SENXXSensor::readDeviceStatus(uint32_t &statusFlags) +{ + if (!isSen6xFamily()) { + return false; + } + + if (!sendCommand(SEN6X_READ_DEVICE_STATUS)) { + LOG_ERROR("%s: Error sending read device status command", sensorName); + return false; + } + delay(20); // From Sensirion Datasheet + + uint8_t dataBuffer[4]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], 6); + if (receivedNumber == 0) { + LOG_ERROR("%s: Error getting device status", sensorName); + return false; + } + + statusFlags = (static_cast(dataBuffer[0]) << 24) | (static_cast(dataBuffer[1]) << 16) | + (static_cast(dataBuffer[2]) << 8) | static_cast(dataBuffer[3]); + return true; +} + +void SENXXSensor::logDeviceStatus(uint32_t statusFlags) +{ + if (statusFlags & SEN6X_STATUS_FAN_ERROR) + LOG_ERROR("%s: Fan error", sensorName); + if (statusFlags & SEN6X_STATUS_RHT_ERROR) + LOG_ERROR("%s: RH&T sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_GAS_ERROR) + LOG_ERROR("%s: Gas (VOC/NOx) sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_CO2_2_ERROR) + LOG_ERROR("%s: CO2 sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_HCHO_ERROR) + LOG_ERROR("%s: Formaldehyde sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_PM_ERROR) + LOG_ERROR("%s: PM sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_CO2_1_ERROR) + LOG_ERROR("%s: CO2 sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_FAN_SPEED_WARNING) + LOG_WARN("%s: Fan speed warning", sensorName); +} + +bool SENXXSensor::setTemperatureOffset(float tempReference) +{ + if (!isSen6xFamily()) { + // No verified opcode for SEN5X's temperature offset command yet. + LOG_WARN("%s: Temperature offset not implemented for this model", sensorName); + return false; + } + + if (senxxmeasurement.temperature == FLT_MAX) { + LOG_ERROR("%s: No recent temperature reading to calibrate against", sensorName); + return false; + } + + float tempOffset = senxxmeasurement.temperature - tempReference; + LOG_INFO("%s: Setting temperature offset: %.2f (current=%.2f, reference=%.2f)", sensorName, tempOffset, + senxxmeasurement.temperature, tempReference); + + // Payload: offset (int16, *200), slope (int16, *10000, 0=no change over time), + // time constant (uint16 seconds, 0=apply immediately), slot (uint16, 0=base self-heating). + int16_t offsetWord = static_cast(tempOffset * 200.0f); + uint8_t buffer[8]{ + static_cast((offsetWord >> 8) & 0xFF), + static_cast(offsetWord & 0xFF), + 0, + 0, // slope = 0 + 0, + 0, // time constant = 0 (apply immediately) + 0, + 0, // slot = 0 + }; + + if (!sendCommand(SEN6X_GET_SET_TEMP_OFFSET, buffer, 8)) { + LOG_ERROR("%s: Error setting temperature offset", sensorName); + return false; + } + + return true; +} + +bool SENXXSensor::co2PerformFRC(uint32_t targetCO2ppm) +{ + if (!hasCO2) { + return false; + } + + LOG_INFO("%s: Issuing FRC. Ensure device has been working at least 3 minutes in stable target environment", sensorName); + LOG_INFO("%s: Target CO2: %u ppm", sensorName, targetCO2ppm); + + uint8_t buffer[2]{static_cast((targetCO2ppm >> 8) & 0xFF), static_cast(targetCO2ppm & 0xFF)}; + if (!sendCommand(SEN6X_PERFORM_FORCED_CO2_RECAL, buffer, 2)) { + LOG_ERROR("%s: Error sending forced recalibration command", sensorName); + return false; + } + delay(500); // From Sensirion Datasheet + + uint8_t resultBuffer[2]{}; + if (readBuffer(&resultBuffer[0], 3) == 0) { + LOG_ERROR("%s: Error reading forced recalibration result", sensorName); + return false; + } + + uint16_t correction = static_cast((resultBuffer[0] << 8) | resultBuffer[1]); + if (correction == 0xFFFF) { + LOG_ERROR("%s: Forced recalibration failed", sensorName); + return false; + } + + LOG_INFO("%s: FRC correction successful. Correction output: %d ppm", sensorName, (int32_t)correction - 0x8000); + return true; +} + +bool SENXXSensor::co2GetASC(bool &ascEnabled) +{ + if (!hasCO2) { + return false; + } + + if (!sendCommand(SEN6X_GET_SET_CO2_ASC)) { + LOG_ERROR("%s: Error sending get ASC command", sensorName); + return false; + } + delay(20); // From Sensirion Datasheet + + uint8_t buffer[2]{}; + if (readBuffer(&buffer[0], 3) == 0) { + LOG_ERROR("%s: Error reading ASC status", sensorName); + return false; + } + + ascEnabled = buffer[1] != 0; + LOG_INFO("%s: ASC is %s", sensorName, ascEnabled ? "enabled" : "disabled"); + return true; +} + +bool SENXXSensor::co2SetASC(bool ascEnabled) +{ + if (!hasCO2) { + return false; + } + + LOG_INFO("%s: %s ASC", sensorName, ascEnabled ? "Enabling" : "Disabling"); + + uint8_t buffer[2]{0, static_cast(ascEnabled ? 1 : 0)}; + if (!sendCommand(SEN6X_GET_SET_CO2_ASC, buffer, 2)) { + LOG_ERROR("%s: Error setting ASC", sensorName); + return false; + } + return true; +} + +bool SENXXSensor::co2SetAltitude(uint32_t altitude) +{ + if (!hasCO2) { + return false; + } + + LOG_INFO("%s: Setting altitude at %um (volatile - reverts on device reset)", sensorName, altitude); + + uint16_t altitudeWord = static_cast(altitude); + uint8_t buffer[2]{static_cast((altitudeWord >> 8) & 0xFF), static_cast(altitudeWord & 0xFF)}; + if (!sendCommand(SEN6X_GET_SET_ALTITUDE, buffer, 2)) { + LOG_ERROR("%s: Error setting altitude", sensorName); + return false; + } + return true; +} + +bool SENXXSensor::co2SetAmbientPressure(uint32_t ambientPressurePa) +{ + if (!hasCO2) { + return false; + } + + // The SEN6X command expects hPa (700-1200), while the admin config field + // matches SCD4X's Pa convention (70000-120000) for consistency across sensors. + uint16_t pressureHpa = static_cast(ambientPressurePa / 100); + LOG_INFO("%s: Setting ambient pressure at %u hPa (volatile - reverts on device reset)", sensorName, pressureHpa); + + uint8_t buffer[2]{static_cast((pressureHpa >> 8) & 0xFF), static_cast(pressureHpa & 0xFF)}; + if (!sendCommand(SEN6X_GET_SET_AMBIENT_PRESSURE, buffer, 2)) { + LOG_ERROR("%s: Error setting ambient pressure", sensorName); + return false; + } + return true; +} + +bool SENXXSensor::co2FactoryReset() +{ + if (!hasCO2) { + return false; + } + + LOG_INFO("%s: Requesting CO2 sensor factory reset", sensorName); + if (!sendCommand(SEN6X_CO2_FACTORY_RESET)) { + LOG_ERROR("%s: Error requesting CO2 factory reset", sensorName); + return false; + } + return true; +} + +void SENXXSensor::setMode(bool setOneShot) +{ + oneShotMode = setOneShot; + if (oneShotMode) { + LOG_INFO("%s: setting mode to one shot mode", sensorName); + } else { + LOG_INFO("%s: setting mode to continuous mode", sensorName); + } +} + +AdminMessageHandleResult SENXXSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) +{ + AdminMessageHandleResult result; + result = AdminMessageHandleResult::NOT_HANDLED; + + switch (request->which_payload_variant) { + case meshtastic_AdminMessage_sensor_config_tag: { +#ifdef SENXX_I2C_CLOCK_SPEED + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + bool ok = true; + bool wasActive = isActive(); + + if (isSen6xFamily()) { + if (!request->sensor_config.has_sen6x_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + const auto &cfg = request->sensor_config.sen6x_config; + + if (cfg.has_set_one_shot_mode) { + this->setMode(cfg.set_one_shot_mode); + } + + if (cfg.has_start_fan_cleaning && cfg.start_fan_cleaning) { + ok &= this->startCleaning(); + } + + // FRC/ASC/altitude are only valid in idle mode (see SEN6X datasheet), and the + // temperature offset command doesn't need measurement running either - stop + // once, run every requested calibration step, then resume if we were active. + bool needsCalibration = cfg.has_set_temperature || cfg.has_set_asc || cfg.has_set_altitude || + cfg.has_set_ambient_pressure || cfg.has_factory_reset; + if (needsCalibration && state == SENXX_CLEANING) { + // A fan cleaning was just started above (non-blocking) - stopping measurement + // now would interrupt it. Calibration and cleaning can't be requested together; + // ask the caller to retry once the cleaning cycle completes. + LOG_WARN("%s: Skipping calibration request - fan cleaning in progress, retry once it completes", sensorName); + ok = false; + } else if (needsCalibration) { + if (wasActive) { + sendCommand(SENXX_STOP_MEASUREMENT); + delay(1400); // From Sensirion Datasheet + } + + if (cfg.has_set_temperature) { + ok &= this->setTemperatureOffset(cfg.set_temperature); + } + + if (hasCO2 && + (cfg.has_set_asc || cfg.has_set_altitude || cfg.has_set_ambient_pressure || cfg.has_factory_reset)) { + Co2AdminRequest co2req; + // Matches SCD4X_config's own convention: presence of the field (not its + // value) is what requests a factory reset. + co2req.hasFactoryReset = cfg.has_factory_reset; + co2req.hasSetAsc = cfg.has_set_asc; + co2req.setAsc = cfg.set_asc; + co2req.hasTargetCo2 = cfg.has_set_target_co2_conc; + co2req.targetCo2 = cfg.set_target_co2_conc; + co2req.hasSetAltitude = cfg.has_set_altitude; + co2req.setAltitude = cfg.set_altitude; + co2req.hasSetAmbientPressure = cfg.has_set_ambient_pressure; + co2req.setAmbientPressure = cfg.set_ambient_pressure; + ok &= this->handleCo2AdminRequest(co2req, sensorName); + } + + if (wasActive) { + // Not this->wakeUp() - we're already inside this function's own + // ReClockI2CGuard, and that guard isn't reentrant (see its comment). + this->wakeUpInternal(); + } + } + } else { + if (!request->sensor_config.has_sen5x_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + const auto &cfg = request->sensor_config.sen5x_config; + + if (cfg.has_set_one_shot_mode) { + this->setMode(cfg.set_one_shot_mode); + } + + if (cfg.has_start_fan_cleaning && cfg.start_fan_cleaning) { + ok &= this->startCleaning(); + } + } + + result = ok ? AdminMessageHandleResult::HANDLED : AdminMessageHandleResult::NOT_HANDLED; + break; + } + + default: + result = AdminMessageHandleResult::NOT_HANDLED; + } + + return result; +} +#endif diff --git a/src/modules/Telemetry/Sensor/SENXXSensor.h b/src/modules/Telemetry/Sensor/SENXXSensor.h new file mode 100644 index 0000000000..94c1930b31 --- /dev/null +++ b/src/modules/Telemetry/Sensor/SENXXSensor.h @@ -0,0 +1,315 @@ +#pragma once +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR + +#include "../detect/ReClockI2C.h" +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "CO2Sensor.h" +#include "TelemetrySensor.h" +#include "Wire.h" +#include "gps/RTC.h" + +/* +Shared driver for Sensirion's SEN5X and SEN6X particulate-matter sensor families +(SEN50/54/55 and SEN62/63C/65/66/68/69C). All of these sensors speak the same +16-bit-command + CRC8-framed word I2C protocol (reset, product name, start/stop +measurement, data-ready, fan cleaning, VOC algorithm state, ...). The families +differ only in: + - I2C address (SEN5X_ADDR 0x69 vs SEN6X_ADDR 0x6B) + - which physical quantities a given model exposes (PM is universal; RHT, VOC, + NOx, CO2 and HCHO are present on some models and not others) + - the opcode used to read measured values (SEN5X always uses one fixed-format + command; each SEN6X model has its own opcode returning only the words that + model supports) +This class implements the shared protocol, state machine and admin handling +once. SEN5XSensor / SEN6XSensor (see SEN5XSensor.h / SEN6XSensor.h) are thin +subclasses that only supply the sensorType/sensorName identity. +*/ +#define SENXX_PM_WARMUP_MS_1 15000 +#define SENXX_PM_WARMUP_MS_2 30000 +#define SENXX_POLL_INTERVAL 1000 +#define SENXX_I2C_CLOCK_SPEED 100000 +// How long a fan-cleaning cycle takes once started; polled via pendingForReadyMs() +// rather than blocked on, see SENXX_CLEANING in SENXXState. +#define SENXX_CLEANING_DURATION_MS 10500 + +/* +Time after which the co2 sensor in some SEN6X variants give stable data +*/ +#define SEN6X_CO2_WARMUP_MS 24000 +#define SENXX_VOC_VALID_TIME 600 +#define SENXX_VOC_VALID_DATE 1514764800 + +/* +Time after which the sensor can go to sleep, as the warmup period has passed +and the VOCs sensor will is allowed to stop (although needs to recover the state +each time) +Note: for Testing 5' is enough. Sensirion recommends 1h +This can be bypassed completely if switching to low-power RHT/Gas mode and setting +SENXX_VOC_STATE_WARMUP_S 0 +*/ +#define SENXX_VOC_STATE_WARMUP_S 3600 +#define SENXX_VOC_STATE_BUFFER_SIZE 8 + +/* Sensirion recommends taking a reading after 15 seconds, +if the Particle number reading is over 100#/cm3 the reading is OK, +but if it is lower wait until 30 seconds and take it again. +See: https://sensirion.com/resource/application_note/low_power_mode/sen5x +*/ +#define SENXX_PN4P0_CONC_THD 100 +#ifndef ONE_WEEK_IN_SECONDS +#define ONE_WEEK_IN_SECONDS 604800 +#endif + +// Commands shared identically by every SEN5X/SEN6X model +#define SENXX_RESET 0xD304 +#define SENXX_GET_PRODUCT_NAME 0xD014 +#define SENXX_GET_FIRMWARE_VERSION 0xD100 +#define SENXX_START_MEASUREMENT 0x0021 +#define SENXX_STOP_MEASUREMENT 0x0104 +#define SENXX_READ_DATA_READY 0x0202 +#define SENXX_START_FAN_CLEANING 0x5607 +#define SENXX_RW_VOCS_STATE 0x6181 + +// SEN5X-only: low-power "RHT/Gas only" measurement mode and fixed-format read commands +#define SEN5X_START_MEASUREMENT_RHT_GAS 0x0037 +#define SEN5X_READ_VALUES 0x03C4 +#define SEN5X_READ_PM_VALUES 0x0413 + +// SEN6X-only: shared number-concentration read command (per-model measured-values +// opcode lives in readMeasuredValuesCmd, set once the model is known) +#define SEN6X_READ_NUMBER_CONCENTRATION_VALUES 0x0316 + +// Values the sensor reports when a reading is unavailable (same sentinels across +// the whole SEN5X/SEN6X family per Sensirion's datasheets) +#define SENXX_UINT_INVALID 0xFFFF +#define SENXX_INT_INVALID 0x7FFF + +// Reply payload sizes in data bytes; the raw I2C transfer adds one CRC byte per +// 2-byte group, so requests are + / 2 raw bytes +#define SENXX_VERSION_BUFFER_SIZE 8 +#define SENXX_PRODUCT_NAME_BUFFER_SIZE 32 +#define SENXX_DATA_READY_BUFFER_SIZE 2 +#define SEN5X_READ_VALUES_BUFFER_SIZE 16 +#define SEN5X_READ_PM_BUFFER_SIZE 20 + +// SEN6X-only commands (all models: SEN62/63C/65/66/68/69C) +#define SEN6X_GET_SET_TEMP_OFFSET 0x60B2 +#define SEN6X_READ_DEVICE_STATUS 0xD206 +// SEN6X-only, CO2-capable models only (SEN63C/66/69C) +#define SEN6X_PERFORM_FORCED_CO2_RECAL 0x6707 +#define SEN6X_CO2_FACTORY_RESET 0x6754 +#define SEN6X_GET_SET_CO2_ASC 0x6711 +#define SEN6X_GET_SET_AMBIENT_PRESSURE 0x6720 +#define SEN6X_GET_SET_ALTITUDE 0x6736 + +struct _SENXXMeasurements { + uint16_t pM1p0; + uint16_t pM2p5; + uint16_t pM4p0; + uint16_t pM10p0; + uint32_t pN0p5; + uint32_t pN1p0; + uint32_t pN2p5; + uint32_t pN4p0; + uint32_t pN10p0; + float tSize; + float humidity; + float temperature; + float vocIndex; + float noxIndex; + float co2; + float hcho; +}; + +class SENXXSensor : public TelemetrySensor, public CO2CalibrationSensor +{ + protected: + // Only subclasses (SEN5XSensor / SEN6XSensor) construct this; they supply the + // proto sensorType/sensorName identity, everything else is auto-detected via + // findModel() at probe/init time. + SENXXSensor(meshtastic_TelemetrySensorType sensorType, const char *sensorName) : TelemetrySensor(sensorType, sensorName) {} + + private: +#ifdef SENXX_I2C_CLOCK_SPEED + ReClockI2C reClockI2C; +#endif + + bool getVersion(); + float firmwareVer = -1; + float hardwareVer = -1; + float protocolVer = -1; + bool findModel(); + + enum SENXXmodel { + SENXX_UNKNOWN = 0, + // SEN5X family - I2C address SEN5X_ADDR (0x69) + SEN50, + SEN54, + SEN55, + // SEN6X family - I2C address SEN6X_ADDR (0x6B) + SEN62, + SEN63C, + SEN65, + SEN66, + SEN68, + SEN69C, + }; + SENXXmodel model = SENXX_UNKNOWN; + + // True for any SEN6X-family model (SEN62/63C/65/66/68/69C) + bool isSen6xFamily() { return model >= SEN62; } + + // Per-model capabilities, derived once in updateCapabilities() right after + // findModel() succeeds. Every read/state routine below is written against + // these flags rather than against individual model checks, so adding a new + // family member only means extending findModel()/updateCapabilities(). + bool hasRHT = false; + bool hasVOC = false; + bool hasNOx = false; + bool hasCO2 = false; + bool hasHCHO = false; + void updateCapabilities(); + + // SEN6X: opcode for "Read Measured Values" - differs per model, see updateCapabilities() + uint16_t readMeasuredValuesCmd = 0; + + // Device Status Register bit positions (SEN6X only - see datasheet Figure 7) + static constexpr uint32_t SEN6X_STATUS_FAN_ERROR = 1u << 4; + static constexpr uint32_t SEN6X_STATUS_RHT_ERROR = 1u << 6; + static constexpr uint32_t SEN6X_STATUS_GAS_ERROR = 1u << 7; + static constexpr uint32_t SEN6X_STATUS_CO2_2_ERROR = 1u << 9; // SEN66 only + static constexpr uint32_t SEN6X_STATUS_HCHO_ERROR = 1u << 10; + static constexpr uint32_t SEN6X_STATUS_PM_ERROR = 1u << 11; + static constexpr uint32_t SEN6X_STATUS_CO2_1_ERROR = 1u << 12; // SEN63C/SEN69C only + static constexpr uint32_t SEN6X_STATUS_FAN_SPEED_WARNING = 1u << 21; + + bool readDeviceStatus(uint32_t &statusFlags); + void logDeviceStatus(uint32_t statusFlags); + + // Sets the SEN6X RHT temperature-offset compensation (slot 0, applied immediately) + // from the most recently measured temperature vs. a known-good reference. Unlike + // SCD4X/SCD30 there is no "get current offset" command to accumulate against, so + // this simply computes offset = lastMeasuredTemperature - tempReference. + bool setTemperatureOffset(float tempReference); + + // CO2CalibrationSensor overrides - only meaningful when hasCO2 (SEN63C/66/69C); + // return false/no-op otherwise. + bool co2PerformFRC(uint32_t targetCO2ppm) override; + bool co2GetASC(bool &ascEnabled) override; + bool co2SetASC(bool ascEnabled) override; + bool co2SetAltitude(uint32_t altitude) override; + bool co2SetAmbientPressure(uint32_t ambientPressurePa) override; + bool co2FactoryReset() override; + + enum SENXXState { + SENXX_OFF, + SENXX_IDLE, + SENXX_RHTGAS_ONLY, // SEN5X Only + SENXX_MEASUREMENT, + SENXX_MEASUREMENT_2, + SENXX_CLEANING, + SENXX_NOT_DETECTED + }; + SENXXState state = SENXX_OFF; + // Flag to work on one-shot (read and sleep), or continuous mode + // Recommendation: if it has VOC / NOx, suggest NOT to use oneShot mode + bool oneShotMode = true; + void setMode(bool setOneShot); + bool vocStateValid(); + + // Tracks getRTCQuality() across calls so we can notice the moment a real clock + // becomes available (e.g. the phone/WiFi/GPS sets it well after boot), rather than + // only checking once in initDevice(). See checkRTCQualityImproved()/ + // reconcileTimeDependentState() for how this is used. + RTCQuality lastRTCQuality = RTCQualityNone; + bool checkRTCQualityImproved(); + void reconcileTimeDependentState(uint32_t now); + + bool sendCommand(uint16_t command); + /** + * @brief Send a command word followed by a data payload; a CRC byte is + * computed and inserted on the wire after every 2-byte pair. + * @param command 16-bit command code, sent big-endian + * @param buffer payload data bytes, without CRCs + * @param byteNumber payload size in data bytes; must be even + * @return true when the full transfer is written and acknowledged + */ + bool sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber = 0); + /** + * @brief Read a reply, verifying and stripping the interleaved CRC bytes. + * @param buffer destination for the data bytes (byteNumber * 2 / 3 of them) + * @param byteNumber raw transfer size including CRCs; must be a multiple + * of 3 (2 data bytes + 1 CRC per group) + * @return the number of data bytes written to buffer, or 0 on any error + */ + uint8_t readBuffer(uint8_t *buffer, uint8_t byteNumber); + uint8_t senxxCRC(const uint8_t *buffer); + // Starts a fan-cleaning cycle and returns immediately (does not block for the + // ~10.5s the cycle takes); pendingForReadyMs() polls SENXX_CLEANING to completion + // and calls finishCleaning() once done. + bool startCleaning(); + void finishCleaning(); + uint8_t getMeasurements(); + bool readPNValues(bool cumulative); + bool readValues(); + + // Actual wakeUp() logic, factored out so handleAdminMessage() can resume + // measurement after a calibration pause without nesting a second I2C-clock + // guard inside its own (see ReClockI2CGuard's reentrancy note). + uint32_t wakeUpInternal(); + + // Monotonic (millis()) timers for warmup/poll intervals. Deliberately not + // wall-clock (getTime()) based: getTime() can jump discontinuously the moment the RTC + // quality improves mid-session (see checkRTCQualityImproved()), which would corrupt + // these short elapsed-time computations. millis() is immune to that and wraps only every ~49 days. + uint32_t pmMeasureStarted = 0; + uint32_t rhtGasMeasureStarted = 0; + uint32_t lastDataPoll = 0; + uint32_t cleaningStarted = 0; + _SENXXMeasurements senxxmeasurement{}; + + bool idle(bool checkState = true); + + protected: + // Store status of the sensor in this file. SEN5X and SEN6X keep separate prefs + // files/proto messages so existing SEN5X saved state is unaffected. + const char *senXXStateFileName = nullptr; + meshtastic_SEN5XState sen5xstate = meshtastic_SEN5XState_init_zero; + meshtastic_SEN6XState sen6xstate = meshtastic_SEN6XState_init_zero; + + bool loadState(); + bool saveState(); + + // Cleaning State + uint32_t lastCleaning = 0; + bool lastCleaningValid = false; + + // VOC State + uint8_t vocState[SENXX_VOC_STATE_BUFFER_SIZE]{}; + uint32_t vocTime = 0; + bool vocValid = false; + + bool vocStateFromSensor(); + bool vocStateToSensor(); + bool vocStateStable(); + bool vocStateRecent(uint32_t now); + + public: + bool probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port); + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + + virtual bool isActive() override; + virtual void sleep() override; + virtual uint32_t wakeUp() override; + virtual bool canSleep() override { return true; } + virtual int32_t wakeUpTimeMs() override; + virtual int32_t pendingForReadyMs() override; + + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; +}; + +#endif diff --git a/src/modules/Telemetry/Sensor/SFA30Sensor.cpp b/src/modules/Telemetry/Sensor/SFA30Sensor.cpp index aa19baf0fa..743130d489 100644 --- a/src/modules/Telemetry/Sensor/SFA30Sensor.cpp +++ b/src/modules/Telemetry/Sensor/SFA30Sensor.cpp @@ -17,37 +17,28 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SFA30_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ sfa30.begin(*_bus, _address); delay(20); if (this->isError(sfa30.deviceReset())) { -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ return false; } state = State::IDLE; if (this->isError(sfa30.startContinuousMeasurement())) { -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ return false; } LOG_INFO("%s starting measurement", sensorName); -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ - status = 1; state = State::ACTIVE; - measureStarted = getTime(); - LOG_INFO("%s: Enabled", sensorName); + measureStarted = millis(); + LOG_INFO("%s Enabled", sensorName); initI2CSensor(); return true; @@ -66,7 +57,8 @@ bool SFA30Sensor::isError(uint16_t response) void SFA30Sensor::sleep() { #ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ // Note - not recommended for this sensor on a periodic basis @@ -74,10 +66,6 @@ void SFA30Sensor::sleep() LOG_ERROR("%s: Can't stop measurement", sensorName); }; -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ - LOG_DEBUG("%s: stop measurement", sensorName); state = State::IDLE; measureStarted = 0; @@ -86,23 +74,17 @@ void SFA30Sensor::sleep() uint32_t SFA30Sensor::wakeUp() { #ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ LOG_DEBUG("Waking %s", sensorName); if (this->isError(sfa30.startContinuousMeasurement())) { -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ return 0; } -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ - state = State::ACTIVE; - measureStarted = getTime(); + measureStarted = millis(); return SFA30_WARMUP_MS; } @@ -125,9 +107,7 @@ bool SFA30Sensor::isActive() int32_t SFA30Sensor::pendingForReadyMs() { - uint32_t now; - now = getTime(); - uint32_t sinceHchoMeasureStarted = (now - measureStarted) * 1000; + uint32_t sinceHchoMeasureStarted = millis() - measureStarted; LOG_DEBUG("%s: Since measure started: %ums", sensorName, sinceHchoMeasureStarted); if (sinceHchoMeasureStarted < SFA30_WARMUP_MS) { @@ -144,21 +124,15 @@ bool SFA30Sensor::getMetrics(meshtastic_Telemetry *measurement) float temperature = 0.0; #ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ if (this->isError(sfa30.readMeasuredValues(hcho, humidity, temperature))) { LOG_WARN("%s: No values", sensorName); -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ return false; } -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ - measurement->variant.air_quality_metrics.has_form_temperature = true; measurement->variant.air_quality_metrics.has_form_humidity = true; measurement->variant.air_quality_metrics.has_form_formaldehyde = true; diff --git a/src/modules/Telemetry/Sensor/SFA30Sensor.h b/src/modules/Telemetry/Sensor/SFA30Sensor.h index a72bef252c..8894986a50 100644 --- a/src/modules/Telemetry/Sensor/SFA30Sensor.h +++ b/src/modules/Telemetry/Sensor/SFA30Sensor.h @@ -17,6 +17,8 @@ class SFA30Sensor : public TelemetrySensor private: enum class State { IDLE, ACTIVE }; State state = State::IDLE; + // millis()-based, not wall-clock: this only measures in-session warmup elapsed time, + // and getTime() can jump discontinuously when RTC quality improves mid-session. uint32_t measureStarted = 0; SensirionI2cSfa3x sfa30; diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index b4c5fae98e..0fdd8c7222 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -586,7 +586,8 @@ void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() // Membership refresh (this hourly pass owns it): clear every isMember bit, then re-mark from // both NodeDB tiers. Runs AFTER seeding so the upsert still sees last pass's bits (spareMembers). - // Cost/lag rationale in docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)". + // Cost/lag rationale in https://meshtastic.org/docs/development/reference/node-info-stores "Consistency with NodeDB + // (anti-entropy)". for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) nodeInfoPayload[i].isMember = false; for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { @@ -729,7 +730,8 @@ bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool { // Same enable gate as the write-through hooks and maintenance: a disabled module stops // updating and sweeping the cache, so its frozen contents must not keep feeding PKI key - // resolution either. Enforces the "superset only while enabled" corollary (node_info_stores.md). + // resolution either. Enforces the "superset only while enabled" corollary + // (https://meshtastic.org/docs/development/reference/node-info-stores). if (!moduleConfig.has_traffic_management) return false; if (!nodeInfoPayload || node == 0 || !out) @@ -1514,7 +1516,8 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // Throttle the spoofed reply (per requester + per target + 1 s global floor; checked here so a // request declined above never spends the budget). false forwards the request instead of consuming - // it. Rationale in docs/traffic_management_module.md "Throttling direct responses". + // it. Rationale in https://meshtastic.org/docs/development/reference/traffic-management-internals "Throttling direct + // responses". if (!directResponseAllowed(getFrom(p), p->to, clockMs())) { TM_LOG_DEBUG("NodeInfo direct response throttled for 0x%08x; forwarding request", getFrom(p)); return false; diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index e01cdefdb9..631673d757 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -33,7 +33,8 @@ /// Packet inspection and traffic shaping: position dedup, per-node rate limiting, unknown-packet /// filtering, NodeInfo direct response, and the next-hop/role overflow caches. One flat 10-byte -/// unified cache backs all per-node features; see docs/node_info_stores.md for the store overview. +/// unified cache backs all per-node features; see https://meshtastic.org/docs/development/reference/node-info-stores for the +/// store overview. class TrafficManagementModule : public MeshModule, private concurrency::OSThread { public: @@ -144,7 +145,8 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread private: // 10-byte packed entry, all platforms. Tick stamps are free-running modular counters with // non-zero presence sentinels; the 4-bit cached role rides the top bits of the two count - // bytes (tier-3 role fallback). Full layout and rationale: docs/node_info_stores.md. + // bytes (tier-3 role fallback). Full layout and rationale: + // https://meshtastic.org/docs/development/reference/node-info-stores. #if _meshtastic_Config_DeviceConfig_Role_MAX > 15 #warning "Device role enum max exceeds 15 - TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values" #endif @@ -347,12 +349,14 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread /// 60 s NodeInfo-cache maintenance under cacheLock: saturate the expired obsTick stamp (wrap-safety /// for the modular clock) and run the boot/hourly reconcile. Guarded by TMM_HAS_NODEINFO_CACHE alone - /// (never the unified cache size); see docs/node_info_stores.md "Tick clocks and wrap safety". + /// (never the unified cache size); see https://meshtastic.org/docs/development/reference/node-info-stores "Tick clocks and + /// wrap safety". void maintainNodeInfoCacheLocked(); /// Anti-entropy under cacheLock: upsert hot-store + warm-tier records this cache lacks (never sets /// hasObserved - seeding is knowledge, not observation), and refresh isMember from both NodeDB - /// tiers. Cost/lag: docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)". + /// tiers. Cost/lag: https://meshtastic.org/docs/development/reference/node-info-stores "Consistency with NodeDB + /// (anti-entropy)". void reconcileNodeInfoFromNodeDBLocked(); /// Learn an observed NODEINFO frame into the cache (key hygiene + provenance rules apply). void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp); @@ -368,7 +372,8 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // Direct-response throttles bounding the reflector risk of spoofed replies: three fixed bounds // (per requester, per target, 1 s global airtime floor) via 8-slot LRU RAM tables, wrap-safe and - // PSRAM-agnostic. Design & rationale: docs/traffic_management_module.md "Throttling direct responses". + // PSRAM-agnostic. Design & rationale: https://meshtastic.org/docs/development/reference/traffic-management-internals + // "Throttling direct responses". static constexpr uint32_t kDirectResponsePerRequesterMs = 60'000UL; static constexpr uint32_t kDirectResponsePerTargetMs = 60'000UL; static constexpr uint32_t kDirectResponseGlobalMs = 1'000UL; diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h index 571767715b..8657a13e8f 100755 --- a/src/motion/AccelerometerThread.h +++ b/src/motion/AccelerometerThread.h @@ -21,19 +21,25 @@ #include "LSM6DS3Sensor.h" #include "MPU6050Sensor.h" #include "MotionSensor.h" +#include "QMI8658Sensor.h" + +#include #ifdef HAS_QMA6100P #include "QMA6100PSensor.h" #endif #ifdef HAS_STK8XXX #include "STK8XXXSensor.h" #endif +#ifdef HAS_BHI260AP +#include "BHI260APSensor.h" +#endif extern ScanI2C::DeviceAddress accelerometer_found; class AccelerometerThread : public concurrency::OSThread { private: - MotionSensor *sensor = nullptr; + std::unique_ptr sensor; bool isInitialised = false; public: @@ -93,56 +99,66 @@ class AccelerometerThread : public concurrency::OSThread switch (device.type) { #ifdef HAS_BMA423 case ScanI2C::DeviceType::BMA423: - sensor = new BMA423Sensor(device); + sensor.reset(new BMA423Sensor(device)); break; #endif #if __has_include() case ScanI2C::DeviceType::MPU6050: - sensor = new MPU6050Sensor(device); + sensor.reset(new MPU6050Sensor(device)); break; #endif case ScanI2C::DeviceType::BMX160: - sensor = new BMX160Sensor(device); + sensor.reset(new BMX160Sensor(device)); break; #if __has_include() case ScanI2C::DeviceType::LIS3DH: case ScanI2C::DeviceType::SC7A20: - sensor = new LIS3DHSensor(device); + sensor.reset(new LIS3DHSensor(device)); break; #endif #if __has_include() case ScanI2C::DeviceType::LSM6DS3: - sensor = new LSM6DS3Sensor(device); + sensor.reset(new LSM6DS3Sensor(device)); break; #endif #ifdef HAS_STK8XXX case ScanI2C::DeviceType::STK8BAXX: - sensor = new STK8XXXSensor(device); + sensor.reset(new STK8XXXSensor(device)); break; #endif #if __has_include() case ScanI2C::DeviceType::ICM20948: - sensor = new ICM20948Sensor(device); + sensor.reset(new ICM20948Sensor(device)); break; #endif #if __has_include() case ScanI2C::DeviceType::ICM42607P: - sensor = new ICM42607PSensor(device); + sensor.reset(new ICM42607PSensor(device)); break; #endif #if __has_include() case ScanI2C::DeviceType::BMM150: - sensor = new BMM150Sensor(device); + sensor.reset(new BMM150Sensor(device)); break; #endif #ifdef HAS_BMI270 case ScanI2C::DeviceType::BMI270: - sensor = new BMI270Sensor(device); + sensor.reset(new BMI270Sensor(device)); break; #endif #ifdef HAS_QMA6100P case ScanI2C::DeviceType::QMA6100P: - sensor = new QMA6100PSensor(device); + sensor.reset(new QMA6100PSensor(device)); + break; +#endif +#if __has_include() + case ScanI2C::DeviceType::QMI8658: + sensor.reset(new QMI8658Sensor(device)); + break; +#endif +#ifdef HAS_BHI260AP + case ScanI2C::DeviceType::BHI260AP: + sensor.reset(new BHI260APSensor(device)); break; #endif default: @@ -185,8 +201,7 @@ class AccelerometerThread : public concurrency::OSThread void clean() { isInitialised = false; - delete sensor; - sensor = nullptr; + sensor.reset(); } }; diff --git a/src/motion/BHI260APSensor.cpp b/src/motion/BHI260APSensor.cpp new file mode 100644 index 0000000000..90c6e1f82a --- /dev/null +++ b/src/motion/BHI260APSensor.cpp @@ -0,0 +1,80 @@ +#include "BHI260APSensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && defined(HAS_BHI260AP) && __has_include() +#define BOSCH_BHI260_KLIO +#define USING_DATA_HELPER + +#include +BHI260APSensor::BHI260APSensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} +// https://github.com/lewisxhe/SensorLib/blob/master/examples/Sensors/IMU/BHI260AP_InterruptSettings/BHI260AP_InterruptSettings.ino + +bool BHI260APSensor::init() +{ + LOG_WARN("Initializing BHI260AP sensor %u", deviceAddress()); + sensor.setFirmware(bosch_firmware_image, bosch_firmware_size, bosch_firmware_type); + sensor.setBootFromFlash(bosch_firmware_type); + if (sensor.begin(Wire, deviceAddress())) { + sensor.setRemapAxes(SensorBHI260AP::TOP_LAYER_BOTTOM_RIGHT_CORNER); + BoschSensorInfo info = sensor.getSensorInfo(); + + LOG_INFO("Product ID : %02x\n", info.product_id); + LOG_INFO("Kernel version : %04u\n", info.kernel_version); + LOG_INFO("User version : %04u\n", info.user_version); + LOG_INFO("ROM version : %04u\n", info.rom_version); + LOG_INFO("Power state : %s\n", (info.host_status & BHY2_HST_POWER_STATE) ? "sleeping" : "active"); + LOG_INFO("Host interface : %s\n", (info.host_status & BHY2_HST_HOST_PROTOCOL) ? "SPI" : "I2C"); + LOG_INFO("Feature status : 0x%02x\n", info.feat_status); + + stepCounter = new SensorStepCounter(sensor); + // stepDetector = new SensorStepDetector(sensor); + + // sensor.configAccelerometer(sensor.RANGE_2G, sensor.ODR_100HZ, sensor.BW_NORMAL_AVG4, sensor.PERF_CONTINUOUS_MODE); + // sensor.enableAccelerometer(); + // sensor.configInterrupt(); + +#ifdef BHI260AP_INT + pinMode(BHI260AP_INT, INPUT); + attachInterrupt( + BHI260AP_INT, + [] { + // Set interrupt to set irq value to true + }, + RISING); // Select the interrupt mode according to the actual circuit +#endif + +#ifdef T_WATCH_S3 + // Need to raise the wrist function, need to set the correct axis + sensor.setRemapAxes(sensor.REMAP_TOP_LAYER_RIGHT_CORNER); +#else + // sensor.setRemapAxes(sensor.REMAP_BOTTOM_LAYER_BOTTOM_LEFT_CORNER); +#endif + + // stepDetector->enable(1.0, 0); + stepCounter->enable(1.0, 0); + LOG_DEBUG("BHI260AP init ok"); + return true; + } + LOG_DEBUG("BHI260AP init failed"); + return false; +} + +int32_t BHI260APSensor::runOnce() +{ + sensor.update(); + if (stepCounter->hasUpdated()) { + steps = stepCounter->getStepCount(); + LOG_WARN("Step count updated: %u", steps); + if (screen) + screen->steps = steps; + } + // LOG_WARN("Step count: %u", stepCounter->getStepCount()); + // if (sensor.readIrqStatus()) { + // if (sensor.isTilt() || sensor.isDoubleTap()) { + // wakeScreen(); + // return 500; + // } + //} + return 1000; +} + +#endif \ No newline at end of file diff --git a/src/motion/BHI260APSensor.h b/src/motion/BHI260APSensor.h new file mode 100644 index 0000000000..b0a4064872 --- /dev/null +++ b/src/motion/BHI260APSensor.h @@ -0,0 +1,31 @@ +#pragma once +#ifndef _BHI260AP_SENSOR_H_ +#define _BHI260AP_SENSOR_H_ + +#include "MotionSensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && defined(HAS_BHI260AP) && __has_include() + +// Sensor lib +#include +#include +#include + +class BHI260APSensor : public MotionSensor +{ + private: + SensorBHI260AP sensor; + volatile bool BHI_IRQ = false; + SensorStepCounter *stepCounter; + SensorStepDetector *stepDetector; + uint32_t steps = 0; + + public: + explicit BHI260APSensor(ScanI2C::FoundDevice foundDevice); + virtual bool init() override; + virtual int32_t runOnce() override; +}; + +#endif + +#endif \ No newline at end of file diff --git a/src/motion/MagnetometerThread.h b/src/motion/MagnetometerThread.h index cf632867da..4a3d7d69a4 100644 --- a/src/motion/MagnetometerThread.h +++ b/src/motion/MagnetometerThread.h @@ -9,13 +9,16 @@ #include "../concurrency/OSThread.h" #include "MMC5983MASensor.h" #include "MotionSensor.h" +#include "QMC6309Sensor.h" + +#include extern ScanI2C::DeviceAddress magnetometer_found; class MagnetometerThread : public concurrency::OSThread { private: - MotionSensor *sensor = nullptr; + std::unique_ptr sensor; ScanI2C::FoundDevice device; bool isInitialised = false; @@ -69,7 +72,12 @@ class MagnetometerThread : public concurrency::OSThread switch (device.type) { #if __has_include() case ScanI2C::DeviceType::MMC5983MA: - sensor = new MMC5983MASensor(device); + sensor.reset(new MMC5983MASensor(device)); + break; +#endif +#if __has_include() + case ScanI2C::DeviceType::QMC6309: + sensor.reset(new QMC6309Sensor(device)); break; #endif default: @@ -106,8 +114,7 @@ class MagnetometerThread : public concurrency::OSThread void clean() { isInitialised = false; - delete sensor; - sensor = nullptr; + sensor.reset(); } }; diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp index 6cbe8e21db..e6331ea857 100755 --- a/src/motion/MotionSensor.cpp +++ b/src/motion/MotionSensor.cpp @@ -42,6 +42,9 @@ struct CompassAccelSample { concurrency::Lock latestCompassAccelLock; CompassAccelSample latestCompassAccelSample; + +concurrency::Lock latestCompassMagLock; +CompassAccelSample latestCompassMagSample; } // namespace // screen is defined in main.cpp @@ -245,6 +248,35 @@ bool MotionSensor::getLatestCompassAccelSample(float &x, float &y, float &z, uin return true; } +void MotionSensor::publishCompassMagSample(float x, float y, float z) +{ + concurrency::LockGuard guard(&latestCompassMagLock); + latestCompassMagSample.x = x; + latestCompassMagSample.y = y; + latestCompassMagSample.z = z; + latestCompassMagSample.sampledAtMs = millis(); + latestCompassMagSample.valid = true; +} + +bool MotionSensor::getLatestCompassMagSample(float &x, float &y, float &z, uint32_t &ageMs) +{ + uint32_t sampledAtMs = 0; + { + concurrency::LockGuard guard(&latestCompassMagLock); + if (!latestCompassMagSample.valid) { + return false; + } + + x = latestCompassMagSample.x; + y = latestCompassMagSample.y; + z = latestCompassMagSample.z; + sampledAtMs = latestCompassMagSample.sampledAtMs; + } + + ageMs = millis() - sampledAtMs; + return true; +} + #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { diff --git a/src/motion/MotionSensor.h b/src/motion/MotionSensor.h index 38876fb776..ef84e1b19b 100755 --- a/src/motion/MotionSensor.h +++ b/src/motion/MotionSensor.h @@ -42,6 +42,11 @@ class MotionSensor virtual void calibrate(uint16_t forSeconds){}; + // Latest samples published by the compass-fusion drivers (accel from the IMU, mag from the magnetometer). + // Public so an optional on-screen sensor debug readout can read them. Return false if nothing published yet. + static bool getLatestCompassAccelSample(float &x, float &y, float &z, uint32_t &ageMs); + static bool getLatestCompassMagSample(float &x, float &y, float &z, uint32_t &ageMs); + // True if this sensor produces the compass heading (screen->setHeading()) in runOnce(). // Combined accel+magnetometer parts (e.g. BMX160, ICM20948) and standalone magnetometers // handled by the accelerometer thread (e.g. BMM150) override this. Used to avoid halting @@ -74,7 +79,7 @@ class MotionSensor float &lowestY, float &highestZ, float &lowestZ); static float applyCompassOrientation(float heading); static void publishCompassAccelSample(float x, float y, float z); - static bool getLatestCompassAccelSample(float &x, float &y, float &z, uint32_t &ageMs); + static void publishCompassMagSample(float x, float y, float z); ScanI2C::FoundDevice device; diff --git a/src/motion/QMC6309Sensor.cpp b/src/motion/QMC6309Sensor.cpp new file mode 100644 index 0000000000..bdf82878b2 --- /dev/null +++ b/src/motion/QMC6309Sensor.cpp @@ -0,0 +1,152 @@ +#include "QMC6309Sensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() + +#include "Fusion/Fusion.h" +#include "detect/ScanI2CTwoWire.h" +#include + +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) +extern std::unique_ptr screen; +#endif + +static constexpr int32_t QMC6309_UPDATE_INTERVAL_MS = 20; +// Heading offset/flip below is a starting point copied from the MMC5983MA path; it is orientation-specific +// and must be verified/tuned against known North on real M9 hardware (see plan). +static constexpr float QMC6309_HEADING_OFFSET_DEG = 180.0f; +static constexpr uint32_t QMC6309_ACCEL_STALE_MS = 300; +static constexpr float QMC6309_MIN_AXIS_RADIUS = 1e-4f; + +QMC6309Sensor::QMC6309Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} + +bool QMC6309Sensor::init() +{ + LOG_DEBUG("QMC6309 begin on addr 0x%02X (port=%d)", device.address.address, device.address.port); + TwoWire *wire = ScanI2CTwoWire::fetchI2CBus(device.address); + + if (!sensor.begin(*wire, deviceAddress())) { + LOG_DEBUG("QMC6309 init error"); + return false; + } + + sensor.reset(); + + // 8 Gauss full-scale easily covers Earth's ~0.5 G field; OSR_8 for low noise. Tunable. + if (!sensor.configMagnetometer(OperationMode::CONTINUOUS_MEASUREMENT, MagFullScaleRange::FS_8G, 100.0f, + MagOverSampleRatio::OSR_8)) { + LOG_DEBUG("QMC6309 config failed"); + return false; + } + + loadMagnetometerCalibration(compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + LOG_DEBUG("QMC6309 init ok"); + LOG_DEBUG("QMC6309 calibration extrema: X=(%.3f, %.3f), Y=(%.3f, %.3f), Z=(%.3f, %.3f)", lowestX, highestX, lowestY, highestY, + lowestZ, highestZ); + return true; +} + +bool QMC6309Sensor::readMagnetometer(float &xGauss, float &yGauss, float &zGauss) +{ + MagnetometerData data; + if (!sensor.readData(data)) { + return false; + } + + // magnetic_field is already scaled to Gauss by the driver. + xGauss = data.magnetic_field.x; + yGauss = data.magnetic_field.y; + zGauss = data.magnetic_field.z; + return true; +} + +int32_t QMC6309Sensor::runOnce() +{ + float magX = 0, magY = 0, magZ = 0; + if (!readMagnetometer(magX, magY, magZ)) { + return QMC6309_UPDATE_INTERVAL_MS; + } + +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) + if (doCalibration) { + beginCalibrationDisplay(showingScreen); + updateCalibrationExtrema(magX, magY, magZ, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + finishCalibrationIfExpired(showingScreen, compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ, + lowestZ); + } +#endif + + // Hard-iron bias removal. + magX -= (highestX + lowestX) * 0.5f; + magY -= (highestY + lowestY) * 0.5f; + magZ -= (highestZ + lowestZ) * 0.5f; + // LOG_WARN("QMC6309 extrema=(%.3f, %.3f, %.3f) to (%.3f, %.3f, %.3f)", + // lowestX, lowestY, lowestZ, highestX, highestY, highestZ); + + // Soft-iron diagonal scaling from calibration extrema. + const float radiusX = (highestX - lowestX) * 0.5f; + const float radiusY = (highestY - lowestY) * 0.5f; + const float radiusZ = (highestZ - lowestZ) * 0.5f; + const float avgRadius = (radiusX + radiusY + radiusZ) / 3.0f; + // magX *= (radiusX > QMC6309_MIN_AXIS_RADIUS) ? (avgRadius / radiusX) : 1.0f; + // magY *= (radiusY > QMC6309_MIN_AXIS_RADIUS) ? (avgRadius / radiusY) : 1.0f; + // magZ *= (radiusZ > QMC6309_MIN_AXIS_RADIUS) ? (avgRadius / radiusZ) : 1.0f; + + // Publish the calibrated magnetometer values (hard/soft-iron applied) for the optional on-screen debug readout. + publishCompassMagSample(magX, magY, magZ); + +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN + float heading; + float accelX = 0.0f; + float accelY = 0.0f; + float accelZ = 0.0f; + uint32_t accelAgeMs = 0; + + // Fuse with the latest accelerometer sample (published by the QMI8658 driver) for tilt compensation. + if (getLatestCompassAccelSample(accelX, accelY, accelZ, accelAgeMs) && accelAgeMs <= QMC6309_ACCEL_STALE_MS) { + FusionVector ga = {.axis = {accelX, accelY, accelZ}}; + FusionVector ma = {.axis = {magX, magY, magZ}}; + // if (config.display.compass_orientation > meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270) { + // ma = FusionAxesSwap(ma, FusionAxesAlignmentNXNYPZ); + // ga = FusionAxesSwap(ga, FusionAxesAlignmentNXNYPZ); + //} + // LOG_WARN("QMC6309 accel age %ums, ga=(%.3f, %.3f, %.3f), ma=(%.3f, %.3f, %.3f)", accelAgeMs, ga.axis.x, ga.axis.y, + // ga.axis.z, ma.axis.x, ma.axis.y, ma.axis.z); + heading = FusionCompass(ga, ma, FusionConventionNed); + if (ga.axis.z > 0.0f) + heading = 360.0f - heading; + + } else { + heading = atan2f(-magY, magX) * RAD_TO_DEG; + } + + if (heading >= 360.0f) + heading -= 360.0f; + else if (heading < 0.0f) + heading += 360.0f; + + heading = applyCompassOrientation(heading); + if (screen) + screen->setHeading(heading); +#endif + + return QMC6309_UPDATE_INTERVAL_MS; +} + +void QMC6309Sensor::calibrate(uint16_t forSeconds) +{ +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) + float xGauss = 0.0f; + float yGauss = 0.0f; + float zGauss = 0.0f; + + LOG_DEBUG("QMC6309 calibration started for %is", forSeconds); + if (readMagnetometer(xGauss, yGauss, zGauss)) { + seedCalibrationExtrema(xGauss, yGauss, zGauss, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + } else { + seedCalibrationExtrema(0.0f, 0.0f, 0.0f, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + } + startCalibrationWindow(forSeconds); +#endif +} + +#endif diff --git a/src/motion/QMC6309Sensor.h b/src/motion/QMC6309Sensor.h new file mode 100644 index 0000000000..c457f35269 --- /dev/null +++ b/src/motion/QMC6309Sensor.h @@ -0,0 +1,40 @@ +#pragma once +#ifndef _QMC6309_SENSOR_H_ +#define _QMC6309_SENSOR_H_ + +#include "MotionSensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() + +// SensorQMC6309.hpp (SensorLib 0.4.1) references the isBitSet() macro in an inline method but never includes +// SensorLib.h where it is defined. Define it here (guarded) so the header compiles regardless of include order +// (pulling in SensorLib.h is unreliable - its #pragma once can already be tripped by an in-progress include). +#ifndef isBitSet +#define isBitSet(value, bit) (((value) & (1UL << (bit))) == (1UL << (bit))) +#endif +#include + +class QMC6309Sensor : public MotionSensor +{ + private: + SensorQMC6309 sensor; + bool showingScreen = false; + static constexpr const char *compassCalibrationFileName = "/prefs/compass_qmc6309.dat"; +#ifdef ELECROW_ThinkNode_M9 + float highestX = -5.548, lowestX = -6.530, highestY = -6.638, lowestY = -7.637, highestZ = -6.676, lowestZ = -7.633; +#else + float highestX = 0, lowestX = 0, highestY = 0, lowestY = 0, highestZ = 0, lowestZ = 0; +#endif + + bool readMagnetometer(float &xGauss, float &yGauss, float &zGauss); + + public: + explicit QMC6309Sensor(ScanI2C::FoundDevice foundDevice); + virtual bool init() override; + virtual int32_t runOnce() override; + virtual void calibrate(uint16_t forSeconds) override; +}; + +#endif + +#endif diff --git a/src/motion/QMI8658Sensor.cpp b/src/motion/QMI8658Sensor.cpp new file mode 100644 index 0000000000..e55ffc26f7 --- /dev/null +++ b/src/motion/QMI8658Sensor.cpp @@ -0,0 +1,88 @@ +#include "QMI8658Sensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() + +#include "NodeDB.h" +#include "detect/ScanI2CTwoWire.h" +#include + +// Accelerometer configuration. 2G full-scale gives the best gravity resolution for the tilt +// compensation that the (future) separate compass module will apply to these samples. +static constexpr SensorQMI8658::AccelRange QMI8658_ACCEL_RANGE = SensorQMI8658::ACC_RANGE_2G; +static constexpr SensorQMI8658::AccelODR QMI8658_ACCEL_ODR = SensorQMI8658::ACC_ODR_125Hz; + +// Any-motion slope threshold (in mg) used to wake the screen. Tunable: raise to reduce false wakes, +// lower to make it more sensitive. 200mg (~0.2g) requires a deliberate movement. +static constexpr float QMI8658_ANY_MOTION_THRESHOLD_MG = 200.0f; +static constexpr uint8_t QMI8658_ANY_MOTION_WINDOW = 1; + +// Optional board-defined rotation (degrees) applied to the accel X/Y before publishing to the compass +// fusion path, mirroring the ICM42607P driver. Defaults to no rotation. +static constexpr float QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE = +#ifdef QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG + QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG; +#else + 0.0f; +#endif + +QMI8658Sensor::QMI8658Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} + +bool QMI8658Sensor::init() +{ + LOG_DEBUG("QMI8658 begin on addr 0x%02X (port=%d)", deviceAddress(), devicePort()); + TwoWire *wire = ScanI2CTwoWire::fetchI2CBus(device.address); + + if (!sensor.begin(*wire, deviceAddress())) { + LOG_DEBUG("QMI8658 init failed"); + return false; + } + + sensor.configAccelerometer(QMI8658_ACCEL_RANGE, QMI8658_ACCEL_ODR, SensorQMI8658::LPF_MODE_0); + sensor.enableAccelerometer(); + + // Configure the on-chip any-motion engine so we can wake the screen without a dedicated interrupt pin. + // configMotion() runs alongside normal accel data output (unlike Wake-on-Motion, which halts data), so + // we keep publishing samples for compass fusion while still detecting motion. + wakeOnMotion = config.display.wake_on_tap_or_motion; + if (wakeOnMotion) { + const uint8_t modeCtrl = SensorQMI8658::ANY_MOTION_EN_X | SensorQMI8658::ANY_MOTION_EN_Y | SensorQMI8658::ANY_MOTION_EN_Z; + // No-motion detection is left disabled (unreliable per the SensorLib example); its thresholds/windows + // are still required arguments but are ignored when the mode bits above are clear. + sensor.configMotion(modeCtrl, QMI8658_ANY_MOTION_THRESHOLD_MG, QMI8658_ANY_MOTION_THRESHOLD_MG, + QMI8658_ANY_MOTION_THRESHOLD_MG, QMI8658_ANY_MOTION_WINDOW, /*NoMotion X/Y/Z*/ 0.1f, 0.1f, 0.1f, + /*NoMotionWindow*/ 1, /*SigMotionWaitWindow*/ 1, /*SigMotionConfirmWindow*/ 1); + sensor.enableMotionDetect(); + } + + LOG_DEBUG("QMI8658 init ok"); + return true; +} + +int32_t QMI8658Sensor::runOnce() +{ + float ax, ay, az; + if (sensor.getAccelerometer(ax, ay, az)) { + if (QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE != 0.0f) { + static const float rotRad = QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE * DEG_TO_RAD; + static const float cosTheta = cosf(rotRad); + static const float sinTheta = sinf(rotRad); + const float rotatedX = (ax * cosTheta) - (ay * sinTheta); + const float rotatedY = (ax * sinTheta) + (ay * cosTheta); + ax = rotatedX; + ay = rotatedY; + } + + // Match the accel sign convention used by the other FusionCompass sensor paths (e.g. ICM42607P). + // The final handedness must be verified against the QMI8658 datasheet and real calibration once the + // separate compass module is wired up; do not hand-tune the signs before then. + publishCompassAccelSample(ax, ay, az); + } + + if (wakeOnMotion && (sensor.getStatusRegister() & SensorQMI8658::EVENT_ANY_MOTION)) { + wakeScreen(); + } + + return MOTION_SENSOR_CHECK_INTERVAL_MS; +} + +#endif diff --git a/src/motion/QMI8658Sensor.h b/src/motion/QMI8658Sensor.h new file mode 100644 index 0000000000..6aa310132f --- /dev/null +++ b/src/motion/QMI8658Sensor.h @@ -0,0 +1,25 @@ +#pragma once +#ifndef _QMI8658_SENSOR_H_ +#define _QMI8658_SENSOR_H_ + +#include "MotionSensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() + +#include + +class QMI8658Sensor : public MotionSensor +{ + private: + SensorQMI8658 sensor; + bool wakeOnMotion = false; + + public: + explicit QMI8658Sensor(ScanI2C::FoundDevice foundDevice); + virtual bool init() override; + virtual int32_t runOnce() override; +}; + +#endif + +#endif diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 36f5f9d0e5..e01c32c72d 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -1025,13 +1025,16 @@ void NimbleBluetooth::setupService() // Setup the battery service BLEService *batteryService = bleServer->createService(BLEUUID((uint16_t)0x180f)); // 0x180F is the Battery Service - BLE2904 *batteryLevelDescriptor = new BLE2904(); - batteryLevelDescriptor->setFormat(BLE2904::FORMAT_UINT8); - batteryLevelDescriptor->setNamespace(1); - batteryLevelDescriptor->setUnit(0x27ad); + // Static like the callback objects above: setupService() re-runs on every BLE re-enable, and + // the framework never frees descriptors (~BLECharacteristic is empty), so a heap allocation + // here leaks one BLE2904 per cycle. + static BLE2904 batteryLevelDescriptor; + batteryLevelDescriptor.setFormat(BLE2904::FORMAT_UINT8); + batteryLevelDescriptor.setNamespace(1); + batteryLevelDescriptor.setUnit(0x27ad); BatteryCharacteristic = batteryService->createCharacteristic( // 0x2A19 is the Battery Level characteristic) (uint16_t)0x2a19, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); - BatteryCharacteristic->addDescriptor(batteryLevelDescriptor); + BatteryCharacteristic->addDescriptor(&batteryLevelDescriptor); // Seed an initial 0-100 level so an early read of 0x2A19 returns a valid value. uint8_t initialLevel = (powerStatus && powerStatus->getHasBattery()) ? powerStatus->getBatteryChargePercent() : 0; if (initialLevel > 100) diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 4cc4461cf4..3f6a05d91f 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -208,6 +208,8 @@ #define HW_VENDOR meshtastic_HardwareModel_M5STACK_C6L #elif defined(HELTEC_WIRELESS_TRACKER_V2) #define HW_VENDOR meshtastic_HardwareModel_HELTEC_WIRELESS_TRACKER_V2 +#elif defined(T_WATCH_ULTRA) +#define HW_VENDOR meshtastic_HardwareModel_T_WATCH_ULTRA #elif defined(M5STACK_CARDPUTER_ADV) #define HW_VENDOR meshtastic_HardwareModel_M5STACK_CARDPUTER_ADV #elif defined(MESHNOLOGY_W10) diff --git a/src/platform/esp32/esp_partition_read_mmap_wrap.c b/src/platform/esp32/esp_partition_read_mmap_wrap.c new file mode 100644 index 0000000000..b8bf96d603 --- /dev/null +++ b/src/platform/esp32/esp_partition_read_mmap_wrap.c @@ -0,0 +1,42 @@ +// Workaround for the IDF 5.5 manual esp_flash read regression on t-watch-ultra. +// +// On this board (Winbond W25Q128JW, ef:8018), the IDF 5.5 *direct* flash read path +// (esp_flash_read / esp_partition_read) returns 0x00 for data that is physically +// correct on flash. We proved the *memory-mapped* (cache) read returns the right +// data, writes work, and it's not read-mode/HPM/timing-tuning/PSRAM. So route every +// esp_partition_read through esp_partition_mmap + memcpy, which uses the working +// cache path. Activated by `-Wl,--wrap=esp_partition_read` (t-watch-ultra only). +#if defined(T_WATCH_ULTRA) + +#include "esp_partition.h" +#include + +extern esp_err_t __real_esp_partition_read(const esp_partition_t *partition, size_t src_offset, void *dst, size_t size); + +esp_err_t __wrap_esp_partition_read(const esp_partition_t *partition, size_t src_offset, void *dst, size_t size) +{ + if (partition == NULL || dst == NULL) + return ESP_ERR_INVALID_ARG; + if (size == 0) + return ESP_OK; + + // mmap requires a 64KB-aligned start; map the containing page span and copy + // out from the requested offset. + const size_t PAGE = 0x10000; + size_t aligned = src_offset & ~(PAGE - 1); + size_t delta = src_offset - aligned; + + const void *ptr = NULL; + esp_partition_mmap_handle_t handle; + esp_err_t err = esp_partition_mmap(partition, aligned, delta + size, ESP_PARTITION_MMAP_DATA, &ptr, &handle); + if (err != ESP_OK) { + // Encrypted partitions / regions mmap can't serve: fall back to the real + // read (may be wrong on this board, but better than failing the call). + return __real_esp_partition_read(partition, src_offset, dst, size); + } + memcpy(dst, (const uint8_t *)ptr + delta, size); + esp_partition_munmap(handle); + return ESP_OK; +} + +#endif // T_WATCH_ULTRA diff --git a/src/platform/extra_variants/README.md b/src/platform/extra_variants/README.md index 838014c4f6..3fd66a9a70 100644 --- a/src/platform/extra_variants/README.md +++ b/src/platform/extra_variants/README.md @@ -7,6 +7,8 @@ This directory tree is designed to solve two problems. So we are borrowing the initVariant() ideas here (by using weak gcc references). You can now define earlyInitVariant() and lateInitVariant() if your board needs them. earlyInitVariant() runs at the beginning of setup() directly after waitUntilPowerLevelSafe(); while lateInitVariant() runs after the LoRa radio is initialized. +**Important:** earlyInitVariant() runs _before_ consoleInit(), so the logging subsystem isn't set up yet. Calling a `LOG_*` macro there **crashes the device** - it is not a silent no-op. Never use `LOG_*` in earlyInitVariant(); defer any logging to lateInitVariant() or later. + If you'd like a board specific variant to be run, add the variant.cpp file to an appropriately named subdirectory and check for \_VARIANT_boardname in the cpp file (so that your code is only built for your board). You'll need to define \_VARIANT_boardname in your corresponding variant.h file. diff --git a/src/platform/extra_variants/t-watch-ultra/variant.cpp b/src/platform/extra_variants/t-watch-ultra/variant.cpp new file mode 100644 index 0000000000..f77c1e97b5 --- /dev/null +++ b/src/platform/extra_variants/t-watch-ultra/variant.cpp @@ -0,0 +1,83 @@ +#include "configuration.h" + +#ifdef T_WATCH_ULTRA + +// Board-specific init lives here (rather than in variants/esp32s3/t-watch-ultra/variant.cpp) +// so that PlatformIO's library dependency finder can resolve headers such as +// input/TouchScreenImpl1.h (which transitively pulls in the ArduinoThread "Thread.h"), +// ExtensionIOXL9555.hpp and TouchDrvCSTXXX.hpp. Files compiled from outside src/ only get +// include paths for libraries they reference directly, so the transitive Thread.h include +// is not found there. See src/platform/extra_variants/README.md. + +#include "TouchDrvCSTXXX.hpp" +#include "input/TouchScreenImpl1.h" +#include +#include + +static ExtensionIOXL9555 io; +static TouchDrvCST92xx touchDrv; + +void earlyInitVariant() +{ + pinMode(LORA_CS, OUTPUT); + digitalWrite(LORA_CS, HIGH); + pinMode(DISP_CS, OUTPUT); + digitalWrite(DISP_CS, HIGH); + pinMode(SDCARD_CS, OUTPUT); + digitalWrite(SDCARD_CS, HIGH); + pinMode(NFC_CS, OUTPUT); + digitalWrite(NFC_CS, HIGH); + pinMode(I2C_SDA, INPUT_PULLUP); + pinMode(I2C_SCL, INPUT_PULLUP); + + if (io.begin(Wire, XL9555_SLAVE_ADDRESS0)) { + io.pinMode(EXPANDS_DRV_EN, OUTPUT); + io.digitalWrite(EXPANDS_DRV_EN, HIGH); + delay(1); + io.pinMode(EXPANDS_DISP_EN, OUTPUT); + io.digitalWrite(EXPANDS_DISP_EN, HIGH); + delay(1); + io.pinMode(EXPANDS_TOUCH_RST, OUTPUT); + io.digitalWrite(EXPANDS_TOUCH_RST, LOW); + delay(20); + io.digitalWrite(EXPANDS_TOUCH_RST, HIGH); + delay(60); + io.pinMode(EXPANDS_LORA_RF_SW, OUTPUT); + io.digitalWrite(EXPANDS_LORA_RF_SW, HIGH); // set RF switch to built-in LoRa antenna + // io.pinMode(EXPANDS_SD_DET, INPUT); + } + // NOTE: deliberately no LOG_* on the io.begin() failure path. earlyInitVariant() runs + // before consoleInit(), where calling a LOG_* macro crashes the device (see + // extra_variants/README.md). On failure the EXPANDS_* pins stay on their defaults. +} + +static bool readTouch(int16_t *x, int16_t *y) +{ + int16_t x_array[1], y_array[1]; + uint8_t touched = touchDrv.getPoint(x_array, y_array, 1); + if (touched > 0) { + *x = (x_array[0]); + *y = (y_array[0]); + // Check bounds + if (*x < 0 || *x >= TFT_WIDTH || *y < 0 || *y >= TFT_HEIGHT) { + return false; + } + return true; // Valid touch detected + } + return false; // No valid touch data +} + +void lateInitVariant() +{ + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + pinMode(SCREEN_TOUCH_INT, INPUT_PULLUP); + touchDrv.setPins(-1, SCREEN_TOUCH_INT); + if (touchDrv.begin(Wire, TOUCH_SLAVE_ADDRESS, -1, -1)) { + touchScreenImpl1 = new TouchScreenImpl1(TFT_WIDTH, TFT_HEIGHT, readTouch); + touchScreenImpl1->init(); + } else { + LOG_ERROR("failed to initialize CST92xx"); + } + } +} +#endif diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h index 0193eeb5c8..e9abbbc293 100644 --- a/src/platform/nrf52/architecture.h +++ b/src/platform/nrf52/architecture.h @@ -137,6 +137,8 @@ #define HW_VENDOR meshtastic_HardwareModel_HELTEC_MESH_POCKET #elif defined(SEEED_WIO_TRACKER_L1_EINK) #define HW_VENDOR meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1_EINK +#elif defined(SEEED_WIO_TRACKER_L1_PRO_1W) +#define HW_VENDOR meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1_PRO_1W #elif defined(SEEED_WIO_TRACKER_L1) #define HW_VENDOR meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1 #elif defined(HELTEC_MESH_SOLAR) diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index eb20844033..865e1c3633 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #include "mesh/Throttle.h" #include @@ -296,7 +297,7 @@ void preFSBegin() if (!(NRF_POWER->RESETREAS == 0 && NRF_POWER->GPREGRET == NRF52_MAGIC_LFS_IS_CORRUPT)) return; NRF_POWER->GPREGRET = 0; - last_format_ms = millis(); + last_format_ms = Time::getMillis(); formatted_this_boot = true; InternalFS.format(); LOG_INFO("LittleFS format complete; restoring default settings"); @@ -309,8 +310,12 @@ extern "C" void lfs_assert(const char *reason) // minutes after each wrap. if (formatted_this_boot && Throttle::isWithinTimespanMs(last_format_ms, MULTIPLE_CORRUPTION_DELAY_MILLIS)) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE); - const long millis_remain = MULTIPLE_CORRUPTION_DELAY_MILLIS - (millis() - last_format_ms); - LOG_WARN("Pausing %d seconds to avoid wear on flash storage", millis_remain / 1000); + // Same clock Throttle just read, and clamped: the check above and a second, later read + // can straddle the backoff, which would wrap the remainder into a ~50-day delay(). + const uint32_t elapsed = Time::getMillis() - last_format_ms; + const uint32_t millis_remain = + elapsed < MULTIPLE_CORRUPTION_DELAY_MILLIS ? MULTIPLE_CORRUPTION_DELAY_MILLIS - elapsed : 0; + LOG_WARN("Pausing %u seconds to avoid wear on flash storage", millis_remain / 1000); delay(millis_remain); } LOG_INFO("Rebooting to format LittleFS"); diff --git a/src/platform/nrf54l15/Arduino.h b/src/platform/nrf54l15/Arduino.h index c67628afa1..0d7449e891 100644 --- a/src/platform/nrf54l15/Arduino.h +++ b/src/platform/nrf54l15/Arduino.h @@ -599,8 +599,12 @@ class String void assign(const char *s, unsigned int n) { - if (n >= _cap) - reserve(n + 1); + // reserve() keeps the old (smaller) buffer on OOM, so a failed grow must abort the + // write: memcpy'ing n >= _cap bytes would overflow into adjacent heap. + if (n + 1 == 0) + return; // n + 1 would wrap + if (n >= _cap && !reserve(n + 1)) + return; if (_buf) { memcpy(_buf, s, n); _buf[n] = 0; @@ -612,21 +616,27 @@ class String if (!s || n == 0) return; unsigned newlen = _len + n; - if (newlen >= _cap) - reserve(newlen + 1); + if (newlen < _len || newlen + 1 == 0) + return; // length arithmetic wrapped + if (newlen >= _cap && !reserve(newlen + 1)) + return; // OOM: keep the existing content intact instead of writing past the buffer if (_buf) { memcpy(_buf + _len, s, n); _len = newlen; _buf[_len] = 0; } } - void reserve(unsigned int n) + bool reserve(unsigned int n) { + if (n == 0) + return false; char *b = (char *)realloc(_buf, n); if (b) { _buf = b; _cap = n; + return true; } + return false; } }; diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 3076be7641..7ac778e3c4 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -62,7 +62,7 @@ portduino_config_struct portduino_config; portduino_status_struct portduino_status; std::ofstream traceFile; std::ofstream JSONFile; -Ch341Hal *ch341Hal = nullptr; +std::unique_ptr ch341Hal; char *configPath = nullptr; char *optionMac = nullptr; bool verboseEnabled = false; @@ -325,8 +325,8 @@ void portduinoSetup() { extern void wasm_config_apply(); wasm_config_apply(); - ch341Hal = - new Ch341Hal(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid, portduino_config.lora_usb_pid); + ch341Hal = std::make_unique(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid, + portduino_config.lora_usb_pid); } return; #endif @@ -650,8 +650,8 @@ void portduinoSetup() uint8_t dmac[6] = {0}; if (portduino_config.lora_spi_dev == "ch341") { 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(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; @@ -845,10 +845,10 @@ int initGPIOPin(int pinNum, const std::string &gpioChipName, int line) std::string gpio_name = "GPIO" + std::to_string(pinNum); std::cout << "Initializing " << gpio_name << " on chip " << gpioChipName << std::endl; try { - GPIOPin *csPin; - csPin = new LinuxGPIOPin(pinNum, gpioChipName.c_str(), line, gpio_name.c_str()); + auto csPin = std::make_unique(pinNum, gpioChipName.c_str(), line, gpio_name.c_str()); csPin->setSilent(); - gpioBind(csPin); + gpioBind(csPin.get()); + csPin.release(); // owned by the gpio table from here on return ERRNO_OK; } catch (...) { const std::type_info *t = abi::__cxa_current_exception_type(); diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index a6797c2923..2072455671 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include #include #include #include @@ -64,7 +65,7 @@ struct pinMapping { extern std::ofstream traceFile; extern std::ofstream JSONFile; -extern Ch341Hal *ch341Hal; +extern std::unique_ptr ch341Hal; int initGPIOPin(int pinNum, const std::string &gpioChipname, int line); bool loadConfig(const char *configPath); static bool ends_with(std::string_view str, std::string_view suffix); diff --git a/src/platform/portduino/wasm/portduino_glue_wasm.cpp b/src/platform/portduino/wasm/portduino_glue_wasm.cpp index a4b4a31e9d..65d6320849 100644 --- a/src/platform/portduino/wasm/portduino_glue_wasm.cpp +++ b/src/platform/portduino/wasm/portduino_glue_wasm.cpp @@ -12,10 +12,9 @@ // - exec() short-circuits to "" (no popen/shell in the browser). // Downstream is unchanged: Ch341Hal -> libpinedio_webusb.c -> WebUSB. -#include "CryptoEngine.h" // crypto->ensurePkiKeys() #include "MeshRadio.h" // initRegion() #include "MeshService.h" // service->reloadConfig() -#include "NodeDB.h" // config, owner globals + SEGMENT_CONFIG +#include "NodeDB.h" // config globals, SEGMENT_*, nodeDB->ensurePkiIdentity() #include "PhoneAPI.h" // the transport-agnostic client API seam #include "PortduinoFS.h" // portduinoVFS #include "PortduinoGlue.h" // declares `portduino_config` + Ch341Hal @@ -260,11 +259,13 @@ extern "C" EMSCRIPTEN_KEEPALIVE int wasm_set_region(int region) if (!(RadioInterface::validateConfigRegion(validated) && RadioInterface::validateConfigLora(validated))) return -1; + int changes = SEGMENT_CONFIG; bool wasUnset = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET); if (wasUnset && newRegion > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (crypto) - crypto->ensurePkiKeys(config.security, owner); // first real region -> generate keys + // Minting the key moves our node num with it, so persist devicestate + the node DB too. + if (nodeDB && nodeDB->ensurePkiIdentity()) + changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; #endif validated.tx_enabled = true; } @@ -274,8 +275,8 @@ extern "C" EMSCRIPTEN_KEEPALIVE int wasm_set_region(int region) config.lora = validated; initRegion(); // repoint myRegion at the new region table if (service) - service->reloadConfig(SEGMENT_CONFIG); // reconfigure radio (new freq) + persist - wasm_fs_sync(); // browser: flush config.proto to IndexedDB + service->reloadConfig(changes); // reconfigure radio (new freq) + persist + wasm_fs_sync(); // browser: flush config.proto to IndexedDB return 0; } diff --git a/src/platform/stm32wl/STM32_LittleFS_File.cpp b/src/platform/stm32wl/STM32_LittleFS_File.cpp index 1f8ae1dea4..dfe04aaf1d 100644 --- a/src/platform/stm32wl/STM32_LittleFS_File.cpp +++ b/src/platform/stm32wl/STM32_LittleFS_File.cpp @@ -100,11 +100,18 @@ bool File::_open_dir(char const *filepath) return false; } - _is_dir = true; - _dir_path = (char *)rtos_malloc(strlen(filepath) + 1); + if (!_dir_path) { + // match the _dir failure path above: don't leave a half-open dir behind + lfs_dir_close(_fs->_getFS(), _dir); + rtos_free(_dir); + _dir = NULL; + return false; + } strcpy(_dir_path, filepath); + _is_dir = true; + return true; } diff --git a/src/sleep.cpp b/src/sleep.cpp index c5d469b42a..6ed3084e12 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -370,7 +370,7 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN // t-beam v1.2 radio power channel PMU->disablePowerOutput(XPOWERS_ALDO2); // lora radio power channel } else if (HW_VENDOR == meshtastic_HardwareModel_LILYGO_TBEAM_S3_CORE || - HW_VENDOR == meshtastic_HardwareModel_T_WATCH_S3) { + HW_VENDOR == meshtastic_HardwareModel_T_WATCH_S3 || HW_VENDOR == meshtastic_HardwareModel_T_WATCH_ULTRA) { PMU->disablePowerOutput(XPOWERS_ALDO3); // lora radio power channel } } else if (model == XPOWERS_AXP192) { @@ -453,8 +453,12 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r gpio_wakeup_enable((gpio_num_t)ROTARY_PRESS, GPIO_INTR_LOW_LEVEL); #endif #ifdef KB_INT +#if KB_INT_WAKE_ON_HIGH + gpio_wakeup_enable((gpio_num_t)KB_INT, GPIO_INTR_HIGH_LEVEL); +#else gpio_wakeup_enable((gpio_num_t)KB_INT, GPIO_INTR_LOW_LEVEL); -#endif +#endif // KB_INT_WAKE_ON_HIGH +#endif // KB_INT #ifdef BOARD_PCA9535_INT // Side-key interrupt line from PCA9535 expander (active low). gpio_wakeup_enable((gpio_num_t)BOARD_PCA9535_INT, GPIO_INTR_LOW_LEVEL); diff --git a/test/README.md b/test/README.md index d1dbd804c4..ceb8192061 100644 --- a/test/README.md +++ b/test/README.md @@ -33,6 +33,8 @@ Randomisation costs one `pio` invocation per suite (about 4.7s each), because Pl > **Copilot interface note:** When running tests via the Copilot chat interface, edits made through the chat may not be reflected in the on-disk files that the test binary reads. If tests pass in chat but fail locally (or vice versa), verify the files on disk match what you expect before trusting the result. Always confirm with a local terminal run. +**Never add `--without-building` to a test run.** PlatformIO links every native test program to the single `$BUILD_DIR/$PROGNAME` path and attributes Unity output by text alone, so a run that only builds beforehand executes whichever suite was linked last under _every_ suite's name - all reporting PASSED. Build once with `--without-testing` to warm the shared src objects if you like; the run itself must still build. `bin/check-test-attribution.py` grades the JUnit reports for exactly this and is wired into both `bin/run-tests.sh` (RED) and CI. + **Raw `pio test` (no sanitizers, no verdict logic)** - use when you need to override the env or inspect verbose Unity output: ```bash diff --git a/test/TestUtil.cpp b/test/TestUtil.cpp index f9e14373d9..2855cc9c77 100644 --- a/test/TestUtil.cpp +++ b/test/TestUtil.cpp @@ -18,21 +18,155 @@ // The state checkpoint needs a POSIX directory walk, and only the host builds run these suites. // Note ARDUINO *is* defined on portduino, so it is not the right guard here. #if ARCH_PORTDUINO +#include "platform/portduino/PortduinoGlue.h" #include #include #include #include #include #include +#include #include #include +#include #endif +#if ARCH_PORTDUINO +// A test binary must not be reachable from the network. main.cpp's setup()/loop() are compiled out +// under PIO_UNIT_TESTING, so the phone API, MQTT and the web server are never started - but that is +// a property of today's guards, not something anything checks. A suite that pulled in a service +// which binds a port would otherwise open one on the developer's machine, silently, for the length +// of the run. Assert the absence instead of trusting it. +// +// Listening sockets only: an outbound connection is a different (and louder) problem, and gethostby* +// opens transient sockets that would make an any-socket check flap. +// Linux-only: this check reads /proc; MinGW-w64 has no readlink() for fd links. +// Linux CI covers the check; native Windows uses a no-op. +#ifdef _WIN32 +static void assertNoListeningSockets() {} +#else +static void assertNoListeningSockets() +{ + // Socket fds appear as "socket:[inode]"; a listening TCP row in /proc/self/net carries st 0A. + std::set ours; + if (DIR *fds = opendir("/proc/self/fd")) { + while (struct dirent *e = readdir(fds)) { + char path[64], target[128]; + snprintf(path, sizeof(path), "/proc/self/fd/%s", e->d_name); + ssize_t n = readlink(path, target, sizeof(target) - 1); + if (n <= 0) + continue; + target[n] = '\0'; + unsigned long inode = 0; + if (sscanf(target, "socket:[%lu]", &inode) == 1) + ours.insert(std::to_string(inode)); + } + closedir(fds); + } + if (ours.empty()) + return; + + std::string offenders; + for (const char *table : {"/proc/self/net/tcp", "/proc/self/net/tcp6"}) { + FILE *f = fopen(table, "r"); + if (!f) + continue; + char line[512]; + bool header = true; + while (fgets(line, sizeof(line), f)) { + if (header) { + header = false; + continue; + } + // sl local_address rem_address st tx:rx tr:when retrnsmt uid timeout inode + char local[128] = {0}; + unsigned st = 0, uid = 0; + unsigned long inode = 0; + if (sscanf(line, "%*d: %127s %*s %x %*s %*s %*s %u %*d %lu", local, &st, &uid, &inode) != 4) + continue; + if (st != 0x0A) // TCP_LISTEN + continue; + if (ours.count(std::to_string(inode)) == 0) + continue; + offenders += " "; + offenders += local; + } + fclose(f); + } + if (offenders.empty()) + return; + + // Before UNITY_BEGIN(), so there is no Unity failure to record - and a test binary that has + // opened a port is not a result worth collecting. Fail the suite outright and say why. + fprintf(stderr, + "FATAL: test binary is listening on%s\n" + "A unit-test run must not be reachable. Something started a network service - check what\n" + "the suite constructs, and whether it belongs behind main.cpp's PIO_UNIT_TESTING guard.\n", + offenders.c_str()); + fflush(stderr); + exit(EXIT_FAILURE); +} +#endif +#endif + +#if ARCH_PORTDUINO +static bool environmentBaselined = false; + +// -s is how the harness keeps a test run off the host's radio: it makes portduinoSetup() skip the +// /etc/meshtasticd/config.yaml search and return before GPIO/SPI init. That job is done by the time +// any of this runs, and the flag's only remaining readers are behaviour we do want under test - +// wouldEncryptWithPKC() disables PKC while it is set. Clear it so suites exercise the production +// encode path; the radio choice is already made and is not revisited. +static void baselineEnvironment() +{ + portduino_config.force_simradio = false; + assertNoListeningSockets(); + environmentBaselined = true; +} +#endif + +void testAssertEnvironmentIntact(const char *testName) +{ +#if ARCH_PORTDUINO + // Not every suite calls initializeTestEnvironment() - test_atak does not - so the baseline + // cannot live only there, or those suites run with PKC off and skip the socket check. Establish + // it at the first RUN_TEST for whoever has not, and hold it from then on. + if (!environmentBaselined) { + baselineEnvironment(); + return; + } + + // Per test, not once per suite: a service that binds a port is opened by the code under test, + // not by the harness, so checking only at startup would miss every case that starts one. + assertNoListeningSockets(); + + if (!portduino_config.force_simradio) + return; + + // Hard exit rather than TEST_FAIL: this runs between tests, outside any Unity test frame, so + // there is no failure to longjmp into. Repairing the flag silently would be worse - it would + // leave the suite that broke it passing. + for (FILE *out : {stdout, stderr}) + fprintf(out, + "FATAL: force_simradio was set back on before %s\n" + "PKC is disabled while it is set, so the encode path under test falls back to channel\n" + "crypto and every later case asserts the wrong thing. A test that needs simradio must\n" + "restore the flag before it returns.\n", + testName ? testName : "(unknown test)"); + fflush(stderr); + exit(EXIT_FAILURE); +#else + (void)testName; +#endif +} + void initializeTestEnvironment() { concurrency::hasBeenSetup = true; consoleInit(); #if ARCH_PORTDUINO + baselineEnvironment(); + struct timeval tv; tv.tv_sec = time(NULL); tv.tv_usec = 0; @@ -63,6 +197,20 @@ void testStateCheckpoint(const char *, const char *) {} namespace { +/// MinGW-w64 has no lstat(): Windows has no POSIX symlink stat, and nothing in a test sandbox +/// creates a symlink, so stat() sees the same thing for every entry walk() can reach. +#ifdef _WIN32 +inline int lstatCompat(const char *path, struct stat *st) +{ + return stat(path, st); +} +#else +inline int lstatCompat(const char *path, struct stat *st) +{ + return lstat(path, st); +} +#endif + /// Content fingerprint, used only to answer "did this file change?". FNV-1a rather than a real /// digest because the answer is a boolean and the files are a few KB of protobuf; nothing here /// records a hash as an expected value, which is what would make this a snapshot test. @@ -96,7 +244,7 @@ void walk(const std::string &root, const std::string &rel, std::mapd_name) : rel + "/" + e->d_name; const std::string childPath = root + "/" + childRel; struct stat st; - if (lstat(childPath.c_str(), &st) != 0) + if (lstatCompat(childPath.c_str(), &st) != 0) continue; if (S_ISDIR(st.st_mode)) walk(root, childRel, out); diff --git a/test/TestUtil.h b/test/TestUtil.h index bb56d1096d..d2a145e527 100644 --- a/test/TestUtil.h +++ b/test/TestUtil.h @@ -17,6 +17,14 @@ void testDelay(unsigned long ms); // place instead of being spread across 40-odd suites. void testStateCheckpoint(const char *testName, const char *sourceFile); +// Checked before every test, because the environment a suite starts in is not the one it keeps. +// initializeTestEnvironment() clears force_simradio once, and a test that sets it - directly, or by +// restoring a struct it saved before the clear - silently disables PKC for every test after it. +// wouldEncryptWithPKC() would then return false and the encode path would quietly fall back to +// channel crypto, which is a passing test asserting the wrong thing. Named per test so the culprit +// is the test that follows the one that broke it. +void testAssertEnvironmentIntact(const char *testName); + // Every RUN_TEST becomes a checkpoint. An unintended write has no matching assertion *by // definition* - nobody wrote a TEST_ASSERT for the nodes.proto write that broke test_admin_radio, // because nobody knew it happened - so attribution has to come from outside the test body. @@ -27,6 +35,7 @@ void testStateCheckpoint(const char *testName, const char *sourceFile); #undef RUN_TEST #define RUN_TEST(func, ...) \ do { \ + testAssertEnvironmentIntact(#func); \ UnityDefaultTestRun(func, #func, __LINE__); \ testStateCheckpoint(#func, __FILE__); \ } while (0) diff --git a/test/state-manifest.tsv b/test/state-manifest.tsv index 45a8fa9621..4c8f56bbb1 100644 --- a/test/state-manifest.tsv +++ b/test/state-manifest.tsv @@ -39,20 +39,37 @@ # add, for a human to paste and justify. It never applies them itself, and CI never applies them at # all - an auto-accepted baseline is the same rot as an auto-updated snapshot. # +# errors= | .. | .. caps a suite's LOG_ERROR lines, default 100. A range, not a +# ceiling: for a fuzz suite the floor is the half that matters. test_fuzz_decode logging ~100k +# rejections is the suite working; the same suite logging none means it stopped feeding malformed +# input, and every case would still pass. Bounds are wide on purpose - they catch a path that has +# stopped running, not a drift of a few hundred lines. +# # suite flags reason -test_admin_radio writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs per-test NodeDB fixture, and the admin handlers under test persist config, channels and node metadata +test_admin_radio writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs errors=400 per-test NodeDB fixture, and the admin handlers under test persist config, channels and node metadata test_admin_session_repro writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB, whose constructor persists a default set when the prefs directory is empty -test_fuzz_packets writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs drives decode of fuzzed packets through the real NodeDB and message store +test_event_channel_phone_api writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB, whose constructor persists a default set when the prefs directory is empty +test_event_channel_router writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto errors=200 subclasses NodeDB for the event-channel fixtures; the base constructor persists a default set when the prefs directory is empty +test_firmware_edition writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto persists an event firmware_edition in devicestate, then reboots a NodeDB to prove a vanilla build resets it +test_fuzz_decode errors=20000..250000 fuzzes protobuf decode; every rejection logs. A collapse to near zero means the corpus stopped reaching the decoder +test_fuzz_packets writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs errors=5000..60000 drives decode of fuzzed packets through the real NodeDB and message store test_hop_scaling writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB to hold the hop-distance fixtures +test_hop_start_policy writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB (isFromUs needs nodeDB->getNodeNum()), whose constructor persists a default set when the prefs directory is empty test_mesh_beacon writes=module.proto exercises the beacon's module-config save path test_mesh_module writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat module framework tests construct a NodeDB -test_mqtt writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB for node lookups in the MQTT paths +test_mqtt writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto errors=1000..12000 constructs a NodeDB for node lookups in the MQTT paths test_nexthop_routing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto next-hop selection reads and updates the node DB test_nodedb_blocked state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat saturates the DB with MAX_NUM_NODES-2 favourited nodes to test the protected cap; a later test's removeNodeByNum() persists that state, and the cap test depends on the fill from the test before it -test_packet_signing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat needs a NodeDB holding both peers' keys for the PKI encode/decode paths +test_nodedb_boot_recovery state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto deliberate boot-recovery ladder: corrupts/deletes/restores the pref files and reboots a NodeDB per test to pin the DECODE_FAILED identity freeze, so each test observes the previous test's on-disk state +test_nodedb_identity_hygiene writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs constructs a NodeDB; addFromContact persists the node DB after every merge, the reboot test proves the key-erasure guard survives a reload, and the should_ignore path rewrites the message store +test_nodedb_legacy_migration writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat each test hand-writes a v24-format nodes.proto fixture and cold-boots a NodeDB, whose constructor persists the migrated v25 database (warm.dat via the over-cap eviction absorb) +test_nodedb_v25_roundtrip writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat v25 persistence round-trips: every test saves nodes.proto and cold-boots a NodeDB whose constructor persists the default segments; warm.dat on the node-DB save cadence +test_packet_signing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat errors=300 needs a NodeDB holding both peers' keys for the PKI encode/decode paths +test_phone_api_config_dump writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto per-test NodeDB fixture backing full PhoneAPI want_config dumps; the constructor persists a default config/channel/node set in a fresh sandbox test_pki_admin_fallback writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto needs a NodeDB holding admin keys for the fallback paths +test_reliable_ack_matrix writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB (whose constructor persists a default set when the prefs directory is empty) for the sender-key lookups in the ACK/NAK matrix test_stream_api writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto drives real PhoneAPI handshakes, which read and persist config and the node DB test_traceroute_nexthop writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto traceroute route selection reads the node DB -test_traffic_management writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat constructs a NodeDB for the per-node rate-limit and dedup state +test_traffic_management writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat errors=3000..12000 constructs a NodeDB for the per-node rate-limit and dedup state; test_tm_fuzz_nodenum_blitz feeds malformed payloads, and each rejection logs (measured 7985) test_transmit_history writes=transmit_history.dat persistence round-trip: what it asserts is that retransmission state survives a save/load test_warm_store writes=warm.dat persistence round-trip of the warm-tier snapshot, which is the tier's whole contract diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index f5237b0555..004037c9ad 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -23,6 +23,7 @@ #include "mesh/Channels.h" #include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" +#include // crc32Buffer(), for the my_node_num == crc32(public_key) invariant #include #include #include @@ -607,21 +608,43 @@ static void test_validateConfigLora_bogusPresetRejected() TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg)); } -static void test_validateConfigLora_unsetRegionOnlyAcceptsLongFast() +static void test_validateConfigLora_unsetRegionAcceptsAnyRealPreset() { - // UNSET uses PROFILE_UNDEF which has only LONG_FAST + // UNSET is "no region chosen yet", not a regulatory domain, so it must not invalidate + // a preset the user already picked - whichever region that preset belongs to. meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; cfg.use_preset = true; - cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; - TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_FAST should be valid for UNSET"); + const meshtastic_Config_LoRaConfig_ModemPreset realPresets[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST, meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_TINY_FAST, + }; + for (auto preset : realPresets) { + cfg.modem_preset = preset; + char msg[64]; + snprintf(msg, sizeof(msg), "preset %d should be valid for UNSET", (int)preset); + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), msg); + } - cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; - TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_FAST should be invalid for UNSET"); + // A value no region offers is still invalid, so the clamp can repair it. + cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "bogus preset should be invalid for UNSET"); +} - cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; - TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for UNSET"); +static void test_isKnownModemPreset_matchesRegionTable() +{ + // Every preset some region offers is "known"... + TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST)); + TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO)); + TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW)); + TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW)); + + // ...and nothing else is, including the retired VERY_LONG_SLOW enum value. + TEST_ASSERT_FALSE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_VERY_LONG_SLOW)); + TEST_ASSERT_FALSE(isKnownModemPreset((meshtastic_Config_LoRaConfig_ModemPreset)99)); } static void test_validateConfigLora_allPresetsValidForLORA24() @@ -706,7 +729,7 @@ static void test_clampConfigLora_customBwValidLeftUnchanged() static void test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast() { - // UNSET uses PROFILE_UNDEF with only LONG_FAST; any other preset should clamp to it + // UNSET's default preset is LONG_FAST; a value no region offers clamps to it meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; cfg.use_preset = true; @@ -717,6 +740,21 @@ static void test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast() TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); } +static void test_clampConfigLora_unsetRegionKeepsRealPreset() +{ + // The boot-time clamp (NodeDB::loadFromDisk) runs on every boot. While the region is + // unset it must leave a real preset alone rather than rewriting it to LONG_FAST. + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + cfg.use_preset = true; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, cfg.modem_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, cfg.region); +} + static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault() { // LORA_24 uses PROFILE_STD; a bogus preset should clamp to LONG_FAST (first in PRESETS_STD) @@ -1116,6 +1154,32 @@ static void test_handleSetConfig_persistsLicensedFirstRegionIdentity() TEST_ASSERT_EQUAL(32, owner.public_key.size); } +// Unlicensed twin of the test above. Without the re-derivation the node signs broadcasts every receiver +// drops (verifyFirstContactNodeInfo: crc32(user.public_key) != from). +static void test_handleSetConfig_persistsUnlicensedFirstRegionIdentity() +{ + owner = meshtastic_User_init_zero; + owner.is_licensed = false; + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + initRegion(); + + testAdmin->deferSaves(); + const meshtastic_Config c = + makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + testAdmin->handleSetConfig(c, false); + + const int expectedSegments = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + TEST_ASSERT_EQUAL_INT(expectedSegments, testAdmin->savedSegments()); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL(32, config.security.public_key.size); + TEST_ASSERT_EQUAL(32, owner.public_key.size); + // The invariant: a node's mesh address is derived from its identity key. + TEST_ASSERT_EQUAL_UINT32(crc32Buffer(config.security.public_key.bytes, config.security.public_key.size), + nodeDB->getNodeNum()); +} + static void test_handleSetConfig_fromOthers_invalidPresetRejected() { // Set up a known-good baseline in the global config @@ -1436,6 +1500,14 @@ static void test_regionInfo_supportsPreset() const RegionInfo *eu866 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866); TEST_ASSERT_TRUE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW)); TEST_ASSERT_FALSE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST)); + + // UNSET enforces nothing (the radio is silent regardless), so it supports every real + // preset - not just the LONG_FAST its own profile advertises as the default. + const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST)); + TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO)); + TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST)); + TEST_ASSERT_FALSE(unset->supportsPreset((meshtastic_Config_LoRaConfig_ModemPreset)99)); } static void test_checkConfigRegion_quietCheckReportsReason() @@ -1501,6 +1573,50 @@ static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejecte TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); } +static void test_handleSetConfig_presetChosenBeforeRegionSurvives() +{ + // A fresh device: the user picks a preset in the app before choosing a region. The + // unset region must not clamp that choice back to LONG_FAST. + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_UNSET, true, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); +} + +static void test_handleSetConfig_unsettingRegionKeepsPreset() +{ + // Clearing the region is a valid request in its own right. It must take effect (and + // disable tx) without discarding the config because the preset outlives the region. + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + config.lora.tx_enabled = true; + initRegion(); + + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_UNSET, true, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); + c.payload_variant.lora.tx_enabled = true; + + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, config.lora.modem_preset); + TEST_ASSERT_FALSE_MESSAGE(config.lora.tx_enabled, "unsetting the region must disable tx"); + + // Restore the region table pointer for subsequent tests + initRegion(); +} + // ----------------------------------------------------------------------- // Channel-configuration warning + coalescing tests // @@ -1886,6 +2002,7 @@ void setup() // getRegion() RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation); RUN_TEST(test_handleSetConfig_persistsLicensedFirstRegionIdentity); + RUN_TEST(test_handleSetConfig_persistsUnlicensedFirstRegionIdentity); RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce); RUN_TEST(test_restorePreferences_sanitizesLicensedBackupBeforeReturn); RUN_TEST(test_getRegion_returnsCorrectRegion_US); @@ -1919,7 +2036,8 @@ void setup() RUN_TEST(test_validateConfigLora_customBandwidthFitsUS); RUN_TEST(test_validateConfigLora_customBandwidthFitsEU868); RUN_TEST(test_validateConfigLora_bogusPresetRejected); - RUN_TEST(test_validateConfigLora_unsetRegionOnlyAcceptsLongFast); + RUN_TEST(test_validateConfigLora_unsetRegionAcceptsAnyRealPreset); + RUN_TEST(test_isKnownModemPreset_matchesRegionTable); RUN_TEST(test_validateConfigLora_allPresetsValidForLORA24); // clampConfigLora() @@ -1928,6 +2046,7 @@ void setup() RUN_TEST(test_clampConfigLora_customBwTooWideClampedToDefaultBw); RUN_TEST(test_clampConfigLora_customBwValidLeftUnchanged); RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast); + RUN_TEST(test_clampConfigLora_unsetRegionKeepsRealPreset); RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault); // Region-locked preset swap @@ -1977,6 +2096,8 @@ void setup() RUN_TEST(test_checkConfigRegion_allowsProspectiveLicensedOwner); RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion); RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected); + RUN_TEST(test_handleSetConfig_presetChosenBeforeRegionSurvives); + RUN_TEST(test_handleSetConfig_unsettingRegionKeepsPreset); // Channel-configuration warning + coalescing RUN_TEST(test_warn_singleChannel_variantName_oneSpecificMessage); diff --git a/test/test_admin_session_repro/test_main.cpp b/test/test_admin_session_repro/test_main.cpp index c93c1fd947..36efc9a41e 100644 --- a/test/test_admin_session_repro/test_main.cpp +++ b/test/test_admin_session_repro/test_main.cpp @@ -21,10 +21,6 @@ #include "support/MockMeshService.h" #include -#ifdef ARCH_PORTDUINO -#include "platform/portduino/PortduinoGlue.h" -#endif - static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us static constexpr NodeNum QUERIED_NODE = 0x0C0C0C0C; // a remote we send admin requests to @@ -161,14 +157,6 @@ void setUp(void) nodeDB = mockNodeDB; myNodeInfo.my_node_num = LOCAL_NODE; -#ifdef ARCH_PORTDUINO - // The native test harness boots Portduino in simulated mode, and wouldEncryptWithPKC() - // hard-disables PKC whenever force_simradio is set. Left true, no outgoing admin request is - // ever key-pinned, so the pinning tests below cannot exercise what they are asserting. - // Model a real (non-sim) device instead. - portduino_config.force_simradio = false; -#endif - config = meshtastic_LocalConfig_init_zero; // A real device always holds a private key; without one perhapsEncode never picks PKC. config.security.private_key.size = 32; diff --git a/test/test_airtime/test_main.cpp b/test/test_airtime/test_main.cpp index 97adeac0cb..6edab96fb3 100644 --- a/test/test_airtime/test_main.cpp +++ b/test/test_airtime/test_main.cpp @@ -6,21 +6,38 @@ // the rotation/decay math on top of that, including across the 32-bit millis() wrap. The wrap cases // therefore step the clock the way the main loop does - advance, then publish. #include "Arduino.h" +#include "MeshRadio.h" +#include "NodeDB.h" #include "TestUtil.h" #include "UptimeClock.h" #include "airtime.h" #include +#include #include +static meshtastic_Config_LoRaConfig_RegionCode savedRegion; +static meshtastic_Config_DeviceConfig_Role savedRole; +static bool savedOverrideDutyCycle; + void setUp(void) { // Absolute uptime assertions (e.g. getSecondsSinceBoot()) must not inherit wraps counted by // an earlier case that moved the test clock backwards via setTestMillis(). Time::resetMonotonicForTests(); + savedRegion = config.lora.region; + savedRole = config.device.role; + savedOverrideDutyCycle = config.lora.override_duty_cycle; } void tearDown(void) { Time::useRealClock(); // don't leak the fake clock into other suites + // Restore the duty-cycle globals here, not at the end of a test body: an assertion aborts the + // body via longjmp and would leak the region into every later case. initRegion() on the way + // out, because getEffectiveDutyCycle() dereferences myRegion. + config.lora.region = savedRegion; + config.device.role = savedRole; + config.lora.override_duty_cycle = savedOverrideDutyCycle; + initRegion(); } // --- first sync / immediate writes --- @@ -32,7 +49,9 @@ void test_logAirtime_writes_into_current_bucket_immediately() a.logAirtime(TX_LOG, 100); - TEST_ASSERT_EQUAL_UINT32(100, a.airtimeReport(TX_LOG)[0]); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(100, report[0]); } void test_getSecondsSinceBoot_tracks_elapsed_time() @@ -55,7 +74,8 @@ void test_period_rotates_after_one_hour() Time::advanceTestMillis(3600u * 1000u); // exactly one SECONDS_PER_PERIOD - uint32_t *report = a.airtimeReport(TX_LOG); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); TEST_ASSERT_EQUAL_UINT32(0, report[0]); // new period starts empty TEST_ASSERT_EQUAL_UINT32(500, report[1]); // old period shifted back one slot } @@ -70,7 +90,8 @@ void test_period_rotates_once_per_hour_crossed_while_asleep() Time::advanceTestMillis(3u * 3600u * 1000u); // 3 hours in one jump - uint32_t *report = a.airtimeReport(TX_LOG); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); TEST_ASSERT_EQUAL_UINT32(200, report[3]); TEST_ASSERT_EQUAL_UINT32(0, report[0]); TEST_ASSERT_EQUAL_UINT32(0, report[1]); @@ -87,7 +108,8 @@ void test_period_history_clears_when_asleep_longer_than_the_whole_log() Time::advanceTestMillis(9u * 3600u * 1000u); // 9 hours > PERIODS_TO_LOG (8) - uint32_t *report = a.airtimeReport(TX_LOG); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); for (uint8_t i = 0; i < a.getPeriodsToLog(); i++) { TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, report[i], "stale history must be cleared, not rotated in"); } @@ -170,11 +192,1014 @@ void test_period_rotation_survives_millis_wrap() Time::advanceTestMillis(3600u * 1000u); // wraps partway through Time::serviceMonotonic(); - uint32_t *report = a.airtimeReport(TX_LOG); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); TEST_ASSERT_EQUAL_UINT32(0, report[0]); TEST_ASSERT_EQUAL_UINT32(777, report[1]); } +// --- report routing: which array each type feeds --- +// +// Asserted through the public API, not the bucket arrays: those are private. + +void test_tx_log_feeds_tx_report_and_tx_utilization() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(TX_LOG, 6000); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(6000, report[0]); + // TX is the only type that reaches all three stores. + TEST_ASSERT_TRUE(a.utilizationTXPercent() > 0.0f); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 10.0f, a.channelUtilizationPercent()); +} + +// Duty cycle is about our own transmissions. Counting received airtime here would throttle a node +// for other people's traffic. +void test_rx_log_feeds_rx_report_but_not_tx_utilization() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(RX_LOG, 6000); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(RX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(6000, report[0]); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.utilizationTXPercent()); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 10.0f, a.channelUtilizationPercent()); +} + +void test_rx_all_log_feeds_only_the_noise_report() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(RX_ALL_LOG, 6000); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(RX_ALL_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(6000, report[0]); + + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(0, report[0]); + TEST_ASSERT_TRUE(a.airtimeReport(RX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(0, report[0]); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.utilizationTXPercent()); +} + +// The shared property: channel utilisation counts all airtime, ours and other people's. +void test_every_report_type_feeds_channel_utilization() +{ + const reportTypes types[] = {TX_LOG, RX_LOG, RX_ALL_LOG}; + for (uint8_t i = 0; i < 3; i++) { + Time::resetMonotonicForTests(); + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(types[i], 6000); + + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 10.0f, a.channelUtilizationPercent(), + "every report type must reach channelUtilization"); + } +} + +void test_report_types_do_not_cross_contaminate() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(TX_LOG, 111); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(RX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(0, report[0]); + TEST_ASSERT_TRUE(a.airtimeReport(RX_ALL_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(0, report[0]); +} + +// --- airtimeReport() contract --- + +void test_airtimeReport_rejects_a_null_buffer() +{ + Time::setTestMillis(0); + AirTime a; + + TEST_ASSERT_FALSE(a.airtimeReport(TX_LOG, nullptr, PERIODS_TO_LOG)); +} + +void test_airtimeReport_rejects_a_count_above_the_log_depth() +{ + Time::setTestMillis(0); + AirTime a; + + uint32_t report[PERIODS_TO_LOG + 1] = {0}; + TEST_ASSERT_FALSE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG + 1)); +} + +void test_airtimeReport_accepts_a_partial_count() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 42); + + const uint32_t sentinel = 0xDEADBEEFu; + uint32_t report[PERIODS_TO_LOG]; + for (uint8_t i = 0; i < PERIODS_TO_LOG; i++) + report[i] = sentinel; + + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, 2)); + + TEST_ASSERT_EQUAL_UINT32(42, report[0]); + TEST_ASSERT_EQUAL_UINT32(0, report[1]); + for (uint8_t i = 2; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32_MESSAGE(sentinel, report[i], "a partial count must not write past it"); +} + +void test_airtimeReport_rejects_an_unknown_report_type() +{ + Time::setTestMillis(0); + AirTime a; + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_FALSE(a.airtimeReport(static_cast(99), report, PERIODS_TO_LOG)); +} + +// The regression guard for the copy-out: if anyone reintroduces the array-returning form, the +// caller's buffer starts tracking the live buckets and this fails. +void test_airtimeReport_returns_a_snapshot_not_an_alias() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 100); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(100, report[0]); + + a.logAirtime(TX_LOG, 900); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(100, report[0], "the copy must not follow the live bucket"); +} + +// --- storage conventions --- +// +// Two orderings: the report arrays are shift-ordered (slot 0 newest); channelUtilization and +// utilizationTX are modular rings indexed by uptime phase. Reading one as the other is a defect. + +void test_report_arrays_are_shift_ordered_slot_zero_newest() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(TX_LOG, 100); // oldest + Time::advanceTestMillis(3600u * 1000u); + a.logAirtime(TX_LOG, 200); + Time::advanceTestMillis(3600u * 1000u); + a.logAirtime(TX_LOG, 300); // newest + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(300, report[0], "slot 0 is the newest hour"); + TEST_ASSERT_EQUAL_UINT32(200, report[1]); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(100, report[2], "index is age in hours, not ring phase"); +} + +// Slot 0 covers only the time since the last rotation; treating it as a whole hour under-reports. +// getSecondsSinceBoot() % getSecondsPerPeriod() recovers the elapsed part. +void test_report_slot_zero_is_a_partial_hour() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 100); + + Time::advanceTestMillis(3600u * 1000u); // rotate; slot 0 is now brand new + Time::advanceTestMillis(120u * 1000u); // and 120s into its hour + a.logAirtime(TX_LOG, 250); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(250, report[0], "slot 0 holds only airtime since the boundary"); + TEST_ASSERT_EQUAL_UINT32(100, report[1]); + + const uint32_t elapsedInSlotZero = a.getSecondsSinceBoot() % a.getSecondsPerPeriod(); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(120, elapsedInSlotZero, "the partial-hour phase must be recoverable"); +} + +// --- first sync and seeding --- + +// The firstTime branch seeds secSinceBoot from the clock; seeding 0 would rotate 500s of empty +// windows through on first access. +void test_first_sync_seeds_from_current_uptime_not_zero() +{ + Time::setTestMillis(500u * 1000u); + AirTime a; + + TEST_ASSERT_EQUAL_UINT32(500, a.getSecondsSinceBoot()); + + a.logAirtime(RX_LOG, 6000); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 10.0f, a.channelUtilizationPercent(), + "no phantom decay from the pre-construction uptime"); +} + +void test_first_sync_zeroes_every_window() +{ + Time::setTestMillis(1234u * 1000u); + AirTime a; + + uint32_t report[PERIODS_TO_LOG] = {0}; + const reportTypes types[] = {TX_LOG, RX_LOG, RX_ALL_LOG}; + for (uint8_t t = 0; t < 3; t++) { + TEST_ASSERT_TRUE(a.airtimeReport(types[t], report, PERIODS_TO_LOG)); + for (uint8_t i = 0; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32(0, report[i]); + } + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.channelUtilizationPercent()); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.utilizationTXPercent()); +} + +void test_late_construction_does_not_backdate_airtime() +{ + Time::setTestMillis(7200u * 1000u); // two hours of uptime before AirTime exists + AirTime a; + + a.logAirtime(TX_LOG, 400); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(400, report[0], "airtime belongs to the current bucket, not a backdated one"); + for (uint8_t i = 1; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32(0, report[i]); +} + +// --- sync idempotency --- + +void test_repeated_sync_within_one_second_does_not_rotate() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 6000); + + Time::advanceTestMillis(500); // sub-second: the nowSecs == secSinceBoot early return + for (uint8_t i = 0; i < 5; i++) { + (void)a.channelUtilizationPercent(); + (void)a.getSecondsSinceBoot(); + } + + TEST_ASSERT_FLOAT_WITHIN(0.01f, 10.0f, a.channelUtilizationPercent()); +} + +// Every public entry point syncs. Calling several in the same interval must not compound the +// rotation: two instances see identical wall time and airtime, differing only in how many entry +// points were called. +void test_rotation_is_once_per_second_regardless_of_entry_point() +{ + Time::setTestMillis(0); + AirTime oneEntryPoint; + AirTime everyEntryPoint; + + oneEntryPoint.logAirtime(RX_LOG, 6000); + everyEntryPoint.logAirtime(RX_LOG, 6000); + + Time::advanceTestMillis(20u * 1000u); // two 10s buckets crossed + + uint32_t scratch[PERIODS_TO_LOG] = {0}; + (void)everyEntryPoint.getSecondsSinceBoot(); + (void)everyEntryPoint.utilizationTXPercent(); + everyEntryPoint.airtimeRotatePeriod(); + (void)everyEntryPoint.airtimeReport(TX_LOG, scratch, PERIODS_TO_LOG); + (void)everyEntryPoint.isTxAllowedChannelUtil(); + + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, oneEntryPoint.channelUtilizationPercent(), + everyEntryPoint.channelUtilizationPercent(), + "rotation must be driven by the clock, not by the call count"); +} + +void test_period_constants_are_stable() +{ + Time::setTestMillis(0); + AirTime a; + + // Public API: ContentHandler sizes its buffer from getPeriodsToLog(). + TEST_ASSERT_EQUAL_UINT8(8, a.getPeriodsToLog()); + TEST_ASSERT_EQUAL_UINT32(3600, a.getSecondsPerPeriod()); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(PERIODS_TO_LOG, a.getPeriodsToLog(), "the accessor and the macro must agree"); +} + +// ============================================================================ +// Window decay, gates, and sleep behaviour. Three kinds of test: +// +// invariant - must hold now and forever; any failure is a bug +// boundary - pins an off-by-one a refactor would silently move +// CHARACTERISATION - encodes today's wrong number. Replace it when the defect +// it describes is fixed; the tag is greppable. +// ============================================================================ + +// --- the oracle ------------------------------------------------------------- +// +// The definition the buckets approximate: airtime physically on air inside +// (now - window, now]. Assert against this rather than hand-worked constants. +// A packet is stamped with its END time, as completeSending() has it; the +// start is end - airtime. + +struct AirtimeEvent { + uint64_t endMs; + uint32_t airtimeMs; +}; + +static float expectedUtilisation(const AirtimeEvent *ev, size_t n, uint64_t nowMs, uint32_t windowMs) +{ + const uint64_t lo = (nowMs > windowMs) ? (nowMs - windowMs) : 0; + uint64_t busy = 0; + for (size_t i = 0; i < n; i++) { + const uint64_t start = (ev[i].airtimeMs < ev[i].endMs) ? (ev[i].endMs - ev[i].airtimeMs) : 0; + const uint64_t from = start > lo ? start : lo; + const uint64_t to = ev[i].endMs < nowMs ? ev[i].endMs : nowMs; + if (to > from) + busy += (to - from); + } + return (float)busy / (float)windowMs * 100.0f; +} + +// Steady load helper: logs `msPerSecond` of airtime once a second for `seconds`, +// leaving the clock exactly `seconds` later than it started. +static void logEverySecond(AirTime &a, uint32_t seconds, uint32_t msPerSecond, reportTypes type = RX_LOG) +{ + for (uint32_t i = 0; i < seconds; i++) { + a.logAirtime(type, msPerSecond); + Time::advanceTestMillis(1000); + } +} + +static char g_msg[160]; // Unity messages must outlive the assert + +// --- hourly period rotation: boundaries the first three tests miss ----------- + +// The shift loop runs PERIODS_TO_LOG-2 -> 0; an off-by-one resurrects hour-old +// data into slot 0 instead of dropping it. +void test_oldest_period_falls_off_the_end() +{ + Time::setTestMillis(0); + AirTime a; + + for (uint32_t h = 0; h < PERIODS_TO_LOG; h++) { + a.logAirtime(TX_LOG, (h + 1) * 100); + Time::advanceTestMillis(3600u * 1000u); + } + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + // Slot 0 is the (empty) current hour; 800 was the newest logged, 100 the oldest. + TEST_ASSERT_EQUAL_UINT32(0, report[0]); + TEST_ASSERT_EQUAL_UINT32(800, report[1]); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(200, report[7], "the oldest survivor sits in the last slot"); + + Time::advanceTestMillis(3600u * 1000u); + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(300, report[7], "one more hour drops 200 off the end"); + for (uint8_t i = 0; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_NOT_EQUAL_UINT32_MESSAGE(200, report[i], "dropped data must not wrap back in"); +} + +void test_period_boundary_is_exact_at_one_hour() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 500); + + Time::advanceTestMillis(3599u * 1000u); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(500, report[0], "3599s must not rotate"); + + Time::advanceTestMillis(1000); + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, report[0], "3600s rotates exactly once"); + TEST_ASSERT_EQUAL_UINT32(500, report[1]); +} + +// The >= is the seam between "rotate N times" and "wipe the lot". +void test_period_clear_boundary_is_exactly_the_log_depth() +{ + { + Time::setTestMillis(0); + AirTime shift; + shift.logAirtime(TX_LOG, 500); + Time::advanceTestMillis(7u * 3600u * 1000u); // 7 h: shift branch + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(shift.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(500, report[7], "7h shifts to the last slot"); + } + { + Time::resetMonotonicForTests(); + Time::setTestMillis(0); + AirTime wipe; + wipe.logAirtime(TX_LOG, 500); + Time::advanceTestMillis(8u * 3600u * 1000u); // 8 h: memset branch + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(wipe.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + for (uint8_t i = 0; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, report[i], "8h wipes rather than rotating"); + } +} + +// --- channelUtilization: the 6 x 10 s modular ring -------------------------- + +// Airtime ages out oldest-first. The ring's index is absolute uptime phase, so +// the oldest bucket is (current + 1) % N, never index N-1 - the assumption +// getSilentMinutes() wrongly makes about the other ring. Stated as a property +// so it holds at any geometry. +void test_channel_utilization_ages_out_oldest_first() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(RX_LOG, 6000); // A: 10% of the window + Time::advanceTestMillis(15u * 1000u); + a.logAirtime(RX_LOG, 3000); // B: 5%, logged later, must outlive A + + bool sawBOnly = false; + for (uint32_t t = 16; t <= 120; t++) { + Time::advanceTestMillis(1000); + const float pct = a.channelUtilizationPercent(); + // "A alone" would be 10% with B already gone: that is out-of-order ageing. + TEST_ASSERT_FALSE_MESSAGE(pct > 9.0f && pct < 11.0f && sawBOnly, "A must not outlive B"); + if (pct > 4.0f && pct < 6.0f) + sawBOnly = true; + } + TEST_ASSERT_TRUE_MESSAGE(sawBOnly, "there must be a window where only the newer airtime remains"); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.0f, a.channelUtilizationPercent()); +} + +void test_channel_utilization_clears_only_the_buckets_crossed() +{ + Time::setTestMillis(0); + AirTime a; + + // One distinct value per 10 s bucket: 1000, 2000, ... 6000 ms. + for (uint32_t b = 0; b < 6; b++) { + a.logAirtime(RX_LOG, (b + 1) * 1000); + Time::advanceTestMillis(10u * 1000u); + } + // t = 60 s: bucket 0 has just been cleared, so 1000 is already gone. + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, (2000 + 3000 + 4000 + 5000 + 6000) / 600.0f, a.channelUtilizationPercent(), + "entering a bucket clears exactly that bucket"); + + Time::advanceTestMillis(20u * 1000u); // crosses two more + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, (4000 + 5000 + 6000) / 600.0f, a.channelUtilizationPercent(), + "20s must clear exactly two buckets, oldest first"); +} + +void test_channel_utilization_clear_boundary_is_exactly_six_periods() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 6000); + + Time::advanceTestMillis(59u * 1000u); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 10.0f, a.channelUtilizationPercent(), "59s: still inside the window"); + + Time::advanceTestMillis(1000); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 0.0f, a.channelUtilizationPercent(), "60s: the bucket is reused"); +} + +void test_channel_utilization_is_zero_when_nothing_logged() +{ + Time::setTestMillis(0); + AirTime a; + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.channelUtilizationPercent()); + Time::advanceTestMillis(3600u * 1000u); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.channelUtilizationPercent()); +} + +void test_channel_utilization_decays_proportionally_across_light_sleep() +{ + Time::setTestMillis(0); + AirTime a; + AirtimeEvent ev[6]; + for (uint32_t b = 0; b < 6; b++) { + a.logAirtime(RX_LOG, 1000); + ev[b].endMs = (uint64_t)b * 10000u; + ev[b].airtimeMs = 1000; + Time::advanceTestMillis(10u * 1000u); + } + const float full = a.channelUtilizationPercent(); + TEST_ASSERT_TRUE(full > 0.0f); + + Time::advanceTestMillis(30u * 1000u); // asleep: not one call for half the window + + const float after = a.channelUtilizationPercent(); + const float truth = expectedUtilisation(ev, 6, 90000, 60000); + snprintf(g_msg, sizeof(g_msg), "before %.4f%%, after a 30s gap %.4f%%, oracle %.4f%%", full, after, truth); + TEST_ASSERT_TRUE_MESSAGE(after < full, g_msg); + // Whole buckets shed, so the survivors are exactly what was still on air in + // the last 60s. + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, truth, after, g_msg); +} + +// Hold wall time and airtime fixed, vary only how often the class is polled, +// and assert the answer does not move. Fails if rotation moves back into +// runOnce() only. +void test_channel_utilization_is_independent_of_scheduler_rate() +{ + Time::setTestMillis(0); + AirTime polledOften; + AirTime polledOnce; + + for (uint32_t s = 0; s < 45; s++) { + polledOften.logAirtime(RX_LOG, 200); + polledOnce.logAirtime(RX_LOG, 200); + Time::advanceTestMillis(1000); + (void)polledOften.channelUtilizationPercent(); // once a second + } + + snprintf(g_msg, sizeof(g_msg), "polled 45x: %.4f%%, polled once: %.4f%%", polledOften.channelUtilizationPercent(), + polledOnce.channelUtilizationPercent()); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, polledOnce.channelUtilizationPercent(), polledOften.channelUtilizationPercent(), + g_msg); +} + +// A percentage of a fixed window cannot exceed 100. Holds for every preset +// whose packets fit inside a bucket; LONG_SLOW is characterised below. +void test_channel_utilization_never_exceeds_100_percent() +{ + Time::setTestMillis(0); + AirTime a; + + float peak = 0.0f; + for (uint32_t s = 0; s < 200; s++) { + a.logAirtime(RX_LOG, 1000); // a fully saturated channel: 1000ms of airtime per second + Time::advanceTestMillis(1000); + const float pct = a.channelUtilizationPercent(); + if (pct > peak) + peak = pct; + } + snprintf(g_msg, sizeof(g_msg), "peak reading was %.4f%%", peak); + TEST_ASSERT_TRUE_MESSAGE(peak <= 100.01f, g_msg); +} + +void test_channel_utilization_counts_each_packet_once() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(TX_LOG, 1000); + a.logAirtime(RX_LOG, 2000); + a.logAirtime(RX_ALL_LOG, 3000); + + // 6000ms of the 60s window, counted once each. + TEST_ASSERT_FLOAT_WITHIN(0.01f, 10.0f, a.channelUtilizationPercent()); +} + +// CHARACTERISATION. The current bucket is zeroed on entry and fills across its +// period, so the window covers (N-1)p + phase against a denominator of Np - +// right after a boundary, 50s of coverage divided by 60s. +void test_channel_utilization_covers_less_than_its_denominator() +{ + Time::setTestMillis(0); + AirTime a; + + AirtimeEvent ev[61]; + size_t n = 0; + for (uint32_t s = 0; s < 60; s++) { + a.logAirtime(RX_LOG, 100); + ev[n].endMs = (uint64_t)s * 1000; + ev[n].airtimeMs = 100; + n++; + Time::advanceTestMillis(1000); + } + // t = 60 000 ms, phase 0: the bucket holding t=0..9 has just been reused. + const float truth = expectedUtilisation(ev, n, 60000, 60000); + const float reported = a.channelUtilizationPercent(); + + snprintf(g_msg, sizeof(g_msg), "oracle %.4f%%, reported %.4f%% (deficit %.4f pp)", truth, reported, truth - reported); + TEST_ASSERT_TRUE_MESSAGE(truth > 9.5f, g_msg); // a steady 10% load, less the event on the window edge + TEST_ASSERT_TRUE_MESSAGE(reported < truth - 1.0f, g_msg); +} + +// CHARACTERISATION. The same defect numerically: under a steady load the +// reading sweeps with position inside the current bucket instead of holding. +void test_channel_utilization_quantisation_error_by_phase() +{ + Time::setTestMillis(0); + AirTime a; + for (uint32_t s = 0; s < 60; s++) { + a.logAirtime(RX_LOG, 100); + Time::advanceTestMillis(1000); + } + + float lo = 1000.0f, hi = 0.0f; + for (uint32_t s = 0; s < 10; s++) { // one full bucket period of phases + const float pct = a.channelUtilizationPercent(); + if (pct < lo) + lo = pct; + if (pct > hi) + hi = pct; + a.logAirtime(RX_LOG, 100); + Time::advanceTestMillis(1000); + } + + snprintf(g_msg, sizeof(g_msg), "steady 10%% load reads %.4f%%..%.4f%% across bucket phase", lo, hi); + TEST_ASSERT_TRUE_MESSAGE(lo < 9.0f, g_msg); // under-reports at the start of a bucket + TEST_ASSERT_TRUE_MESSAGE(hi > 9.5f, g_msg); // recovers by the end of it + TEST_ASSERT_TRUE_MESSAGE(hi - lo > 1.0f, g_msg); // and the sawtooth is the jitter defect +} + +// CHARACTERISATION. A packet's whole airtime is credited to the bucket it +// completed in, so a bucket can hold more than its own period. LONG_SLOW at max +// payload is 14 164 ms against a 10 s bucket. +void test_channel_utilization_exceeds_100_percent_on_long_slow() +{ + Time::setTestMillis(0); + AirTime a; + + const uint32_t LONG_SLOW_MAX_MS = 14164; + float peak = 0.0f; + for (uint32_t i = 0; i < 40; i++) { + Time::advanceTestMillis(LONG_SLOW_MAX_MS); // back-to-back: the channel is 100% busy + a.logAirtime(RX_LOG, LONG_SLOW_MAX_MS); + const float pct = a.channelUtilizationPercent(); + if (pct > peak) + peak = pct; + } + + snprintf(g_msg, sizeof(g_msg), "true occupancy 100%%, peak reading %.4f%%", peak); + TEST_ASSERT_TRUE_MESSAGE(peak > 100.0f, g_msg); +} + +// --- utilizationTX: the 60 x 60 s modular ring ------------------------------ + +void test_tx_utilization_ages_out_oldest_first() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(TX_LOG, 60000); // A + Time::advanceTestMillis(15u * 60u * 1000u); + a.logAirtime(TX_LOG, 30000); // B, newer and smaller + + bool sawBOnly = false; + for (uint32_t m = 16; m <= 120; m++) { + Time::advanceTestMillis(60u * 1000u); + const float pct = a.utilizationTXPercent(); + const float bOnly = 30000.0f / (60.0f * 60.0f * 1000.0f) * 100.0f; + TEST_ASSERT_FALSE_MESSAGE(sawBOnly && pct > bOnly * 1.5f, "A must not outlive B"); + if (pct > bOnly * 0.9f && pct < bOnly * 1.1f) + sawBOnly = true; + } + TEST_ASSERT_TRUE_MESSAGE(sawBOnly, "there must be a window where only the newer airtime remains"); +} + +void test_tx_utilization_clears_only_the_minutes_crossed() +{ + Time::setTestMillis(0); + AirTime a; + for (uint32_t m = 0; m < 4; m++) { + a.logAirtime(TX_LOG, (m + 1) * 1000); + Time::advanceTestMillis(60u * 1000u); + } + const float all = (1000 + 2000 + 3000 + 4000) / (float)MS_IN_HOUR * 100.0f; + TEST_ASSERT_FLOAT_WITHIN(0.001f, all, a.utilizationTXPercent()); + + Time::advanceTestMillis(56u * 60u * 1000u); // t = 60 min: the first minute-bucket is reused + const float withoutFirst = (2000 + 3000 + 4000) / (float)MS_IN_HOUR * 100.0f; + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.001f, withoutFirst, a.utilizationTXPercent(), + "only the crossed minute buckets are cleared"); +} + +void test_tx_utilization_clear_boundary_is_exactly_sixty_minutes() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 36000); + + Time::advanceTestMillis(59u * 60u * 1000u); + TEST_ASSERT_TRUE_MESSAGE(a.utilizationTXPercent() > 0.0f, "59 min: still inside the hour"); + + Time::advanceTestMillis(60u * 1000u); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.0001f, 0.0f, a.utilizationTXPercent(), "60 min: the bucket is reused"); +} + +void test_tx_utilization_counts_only_transmissions() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(RX_LOG, MS_IN_HOUR / 2); + a.logAirtime(RX_ALL_LOG, MS_IN_HOUR / 2); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.0001f, 0.0f, a.utilizationTXPercent(), + "received airtime must never reach the duty-cycle figure"); + + a.logAirtime(TX_LOG, 36000); + TEST_ASSERT_TRUE(a.utilizationTXPercent() > 0.0f); +} + +// CHARACTERISATION. The same quantisation defect on the hour window: 10x +// smaller because N is 60 rather than 6, but not zero. +void test_tx_utilization_quantisation_error() +{ + Time::setTestMillis(0); + AirTime a; + for (uint32_t m = 0; m < 60; m++) { + a.logAirtime(TX_LOG, 1000); + Time::advanceTestMillis(60u * 1000u); + } + // 60 000 ms of TX in the hour just elapsed = 1.6667% true. + const float truth = 60000.0f / (float)MS_IN_HOUR * 100.0f; + const float reported = a.utilizationTXPercent(); + + snprintf(g_msg, sizeof(g_msg), "true %.4f%%, reported %.4f%%", truth, reported); + TEST_ASSERT_TRUE_MESSAGE(reported < truth, g_msg); + TEST_ASSERT_TRUE_MESSAGE(reported > truth * 0.95f, g_msg); // ~1/60, not gross +} + +// --- TX gates ---------------------------------------------------------------- + +void test_isTxAllowedChannelUtil_polite_threshold_is_lower() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 18000); // 30% of the 60s window + + TEST_ASSERT_TRUE_MESSAGE(a.isTxAllowedChannelUtil(false), "30% is under the 40% default"); + TEST_ASSERT_FALSE_MESSAGE(a.isTxAllowedChannelUtil(true), "30% is over the 25% polite limit"); +} + +// The compare is `< percentage`, so exactly the threshold must block. +void test_isTxAllowedChannelUtil_boundary_is_exclusive() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 24000); // exactly 40.0% + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 40.0f, a.channelUtilizationPercent()); + TEST_ASSERT_FALSE_MESSAGE(a.isTxAllowedChannelUtil(false), "exactly 40.0% must block, not allow"); +} + +void test_isTxAllowedAirUtil_allows_when_override_is_set() +{ + Time::setTestMillis(0); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866; + config.lora.override_duty_cycle = true; + initRegion(); + AirTime a; + a.logAirtime(TX_LOG, MS_IN_HOUR); // 100% TX utilisation + + TEST_ASSERT_TRUE(a.isTxAllowedAirUtil()); + config.lora.override_duty_cycle = false; +} + +void test_isTxAllowedAirUtil_allows_when_the_region_is_unlimited() +{ + Time::setTestMillis(0); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.override_duty_cycle = false; + initRegion(); + AirTime a; + a.logAirtime(TX_LOG, MS_IN_HOUR); + + TEST_ASSERT_TRUE_MESSAGE(getEffectiveDutyCycle() >= 100.0f, "US has no duty cycle limit"); + TEST_ASSERT_TRUE(a.isTxAllowedAirUtil()); +} + +// The polite gate is half the allowance, not the whole of it. +void test_isTxAllowedAirUtil_blocks_at_half_the_duty_cycle() +{ + Time::setTestMillis(0); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866; + config.lora.override_duty_cycle = false; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + initRegion(); + const float duty = getEffectiveDutyCycle(); // 2.5% for a non-router on EU_866 + TEST_ASSERT_FLOAT_WITHIN(0.01f, 2.5f, duty); + + AirTime a; + // 40% of the allowance: under half, so still allowed. + a.logAirtime(TX_LOG, (uint32_t)(MS_IN_HOUR * duty / 100.0f * 0.40f)); + TEST_ASSERT_TRUE_MESSAGE(a.isTxAllowedAirUtil(), "40% of the allowance is under the polite half"); + + // Push past half. + a.logAirtime(TX_LOG, (uint32_t)(MS_IN_HOUR * duty / 100.0f * 0.30f)); + TEST_ASSERT_FALSE_MESSAGE(a.isTxAllowedAirUtil(), "70% of the allowance is over the polite half"); +} + +// Two thresholds ride on one figure: isTxAllowedAirUtil() is polite at half the +// duty cycle, while Router::send() aborts only at the whole of it. There is a +// band where the polite gate blocks and the hard gate would not - pinning it +// here means an accuracy change has to be evaluated against both. +void test_router_send_gate_uses_the_whole_duty_cycle() +{ + Time::setTestMillis(0); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866; + config.lora.override_duty_cycle = false; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + initRegion(); + const float duty = getEffectiveDutyCycle(); + + AirTime a; + a.logAirtime(TX_LOG, (uint32_t)(MS_IN_HOUR * duty / 100.0f * 0.70f)); // 70% of the allowance + + TEST_ASSERT_FALSE_MESSAGE(a.isTxAllowedAirUtil(), "the polite gate blocks at 70% of the allowance"); + TEST_ASSERT_TRUE_MESSAGE(a.utilizationTXPercent() < duty, + "...while the figure is still under the whole duty cycle Router::send() uses"); +} + +// getEffectiveDutyCycle() special-cases EU_866 by role. Every other region - +// including EU_868, one digit away - takes the generic myRegion->dutyCycle path. +void test_effective_duty_cycle_special_case_is_eu_866_only() +{ + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866; + initRegion(); + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + const float eu866Client = getEffectiveDutyCycle(); + config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; + const float eu866Router = getEffectiveDutyCycle(); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 2.5f, eu866Client); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 10.0f, eu866Router, "EU_866 is role-dependent"); + + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + initRegion(); + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + const float eu868Client = getEffectiveDutyCycle(); + config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; + const float eu868Router = getEffectiveDutyCycle(); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, eu868Client, eu868Router, "EU_868 must NOT be role-dependent"); + + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; +} + +// --- getSilentMinutes() ------------------------------------------------------ + +void test_getSilentMinutes_returns_zero_when_already_under_the_limit() +{ + Time::setTestMillis(0); + AirTime a; + TEST_ASSERT_EQUAL_UINT8(0, a.getSilentMinutes(1.0f, 2.5f)); +} + +void test_getSilentMinutes_returns_a_full_hour_when_nothing_ages_out() +{ + Time::setTestMillis(0); + AirTime a; // empty ring, but told we are over the limit + TEST_ASSERT_EQUAL_UINT8_MESSAGE(60, a.getSilentMinutes(10.0f, 2.5f), "nothing to age out means the full hour"); +} + +void test_getSilentMinutes_counts_minutes_until_enough_ages_out() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 120000); // two minutes of TX, all of it in minute-bucket 0 + const float pct = a.utilizationTXPercent(); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 3.3333f, pct); + + // Fully determined: the walk subtracts nothing for i in 59..1, then the whole 3.3333% at i == 0, + // returning MINUTES_IN_HOUR - 1 - 0. That answer is one minute short of the truth - syncNow() + // clears bucket 0 at minute 60, not 59 - which test_getSilentMinutes_depends_on_ring_phase pins. + const uint8_t mins = a.getSilentMinutes(pct, 2.5f); + TEST_ASSERT_EQUAL_UINT8(59, mins); +} + +// CHARACTERISATION. getSilentMinutes() walks utilizationTX from index 59 down +// to 0 and returns 59 - i, treating the index as an age. That is the report +// array's convention; utilizationTX is a modular ring indexed by minute phase, +// so identical airtime gives different answers at different phases. +void test_getSilentMinutes_depends_on_ring_phase() +{ + uint8_t answers[6] = {0}; + float pcts[6] = {0}; + for (uint8_t i = 0; i < 6; i++) { + Time::resetMonotonicForTests(); + Time::setTestMillis((uint32_t)i * 10u * 60u * 1000u); // 0, 10, 20... minutes of uptime + AirTime a; + a.logAirtime(TX_LOG, 120000); + pcts[i] = a.utilizationTXPercent(); + answers[i] = a.getSilentMinutes(pcts[i], 2.5f); + } + + for (uint8_t i = 1; i < 6; i++) + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.0001f, pcts[0], pcts[i], "the inputs must be identical"); + + bool varies = false; + for (uint8_t i = 1; i < 6; i++) + if (answers[i] != answers[0]) + varies = true; + + snprintf(g_msg, sizeof(g_msg), "same airtime, answers by phase: %u %u %u %u %u %u", answers[0], answers[1], answers[2], + answers[3], answers[4], answers[5]); + TEST_ASSERT_TRUE_MESSAGE(varies, g_msg); +} + +// --- clock robustness --------------------------------------------------------- + +// A gap longer than the window that also crosses the 49.7-day millis() wrap. +void test_survives_heavy_sleep_across_the_wrap() +{ + const uint32_t beforeWrap = 0xFFFFFFFFu - (30u * 1000u); + Time::setTestMillis(beforeWrap); + Time::serviceMonotonic(); + AirTime a; + a.logAirtime(RX_LOG, 6000); + TEST_ASSERT_TRUE(a.channelUtilizationPercent() > 0.0f); + + Time::advanceTestMillis(120u * 1000u); // wraps, and outlasts the 60s window + Time::serviceMonotonic(); + + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 0.0f, a.channelUtilizationPercent(), + "a window that outlasts its span must be empty, wrap or not"); +} + +void test_multi_day_sleep_clears_every_window() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 6000); + a.logAirtime(RX_LOG, 6000); + a.logAirtime(RX_ALL_LOG, 6000); + + Time::advanceTestMillis(3u * 24u * 3600u * 1000u); // three days + Time::serviceMonotonic(); + + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.channelUtilizationPercent()); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.utilizationTXPercent()); + uint32_t report[PERIODS_TO_LOG] = {0}; + const reportTypes types[] = {TX_LOG, RX_LOG, RX_ALL_LOG}; + for (uint8_t t = 0; t < 3; t++) { + TEST_ASSERT_TRUE(a.airtimeReport(types[t], report, PERIODS_TO_LOG)); + for (uint8_t i = 0; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32(0, report[i]); + } +} + +// getUptimeSecs() is monotonic by construction. If it ever stops being, the +// elapsed calculation underflows to a huge value, which trips every >= branch +// and clears the windows. Benign, and pinned so a swap back to bare millis() +// fails loudly rather than corrupting buckets. +void test_backwards_uptime_degrades_safely() +{ + // Step by the wrap, which is the size the regression would actually produce: uptime falls from + // 4294967s to 0. A smaller backwards step leaves elapsedAirtimePeriods at 0, so the hourly + // report below is never reached - which is what this case used to miss. + Time::setTestMillis(UINT32_MAX); + AirTime a; + a.logAirtime(TX_LOG, 6000); + TEST_ASSERT_TRUE(a.channelUtilizationPercent() > 0.0f); + + Time::setTestMillis(0); // the wrap, as a naive millis() clock would present it + + const float pct = a.channelUtilizationPercent(); + snprintf(g_msg, sizeof(g_msg), "channel utilisation after the wrap: %.4f%%", pct); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.0001f, 0.0f, pct, g_msg); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + for (uint32_t i = 0; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, report[i], "every hourly bucket clears across the wrap"); +} + +// --- the lock ---------------------------------------------------------------------------------- + +// No single public method may take the lock twice: a second Held on the same instance trips the +// re-entry assert. The calls below are sequential and each Held is destroyed before the next, so +// this catches a method re-entering itself, not two methods nesting. That is the regression guard +// for isTxAllowedChannelUtil() regaining its pre-split shape. Two of the methods called take no +// lock at all. Portduino compiles Lock::lock() to an empty body, so the assert is the only check +// that works natively; on hardware the same bug is a deadlock. +void test_no_public_method_takes_the_lock_twice() +{ + Time::setTestMillis(0); + // EU_868 explicitly, not inherited: isTxAllowedAirUtil() constructs a Held only inside its + // duty-cycle branch, so under the default US region (100%) it would return before locking and + // this test would not cover it at all. + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.override_duty_cycle = false; + initRegion(); + + AirTime a; + uint32_t report[PERIODS_TO_LOG] = {0}; + + a.logAirtime(TX_LOG, 100); + a.logAirtime(RX_LOG, 100); + a.logAirtime(RX_ALL_LOG, 100); + (void)a.channelUtilizationPercent(); + (void)a.utilizationTXPercent(); + a.airtimeRotatePeriod(); + (void)a.getPeriodsToLog(); + (void)a.getSecondsPerPeriod(); + (void)a.getSecondsSinceBoot(); + (void)a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG); + (void)a.getSilentMinutes(10.0f, 2.5f); + (void)a.isTxAllowedChannelUtil(false); + (void)a.isTxAllowedChannelUtil(true); + (void)a.isTxAllowedAirUtil(); + + // Reaching here without the assert firing IS the assertion; check the object still works. + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(100, report[0]); +} + void setup() { initializeTestEnvironment(); @@ -190,6 +1215,66 @@ void setup() RUN_TEST(test_tx_utilization_decays_once_the_60_minute_window_passes); RUN_TEST(test_syncNow_survives_millis_wrap); RUN_TEST(test_period_rotation_survives_millis_wrap); + + // report routing + RUN_TEST(test_tx_log_feeds_tx_report_and_tx_utilization); + RUN_TEST(test_rx_log_feeds_rx_report_but_not_tx_utilization); + RUN_TEST(test_rx_all_log_feeds_only_the_noise_report); + RUN_TEST(test_every_report_type_feeds_channel_utilization); + RUN_TEST(test_report_types_do_not_cross_contaminate); + // airtimeReport() contract + RUN_TEST(test_airtimeReport_rejects_a_null_buffer); + RUN_TEST(test_airtimeReport_rejects_a_count_above_the_log_depth); + RUN_TEST(test_airtimeReport_accepts_a_partial_count); + RUN_TEST(test_airtimeReport_rejects_an_unknown_report_type); + RUN_TEST(test_airtimeReport_returns_a_snapshot_not_an_alias); + // storage conventions + RUN_TEST(test_report_arrays_are_shift_ordered_slot_zero_newest); + RUN_TEST(test_report_slot_zero_is_a_partial_hour); + // first sync and seeding + RUN_TEST(test_first_sync_seeds_from_current_uptime_not_zero); + RUN_TEST(test_first_sync_zeroes_every_window); + RUN_TEST(test_late_construction_does_not_backdate_airtime); + // sync idempotency + RUN_TEST(test_repeated_sync_within_one_second_does_not_rotate); + RUN_TEST(test_rotation_is_once_per_second_regardless_of_entry_point); + RUN_TEST(test_period_constants_are_stable); + + // --- phase 3: windows, gates, sleep --- + RUN_TEST(test_oldest_period_falls_off_the_end); + RUN_TEST(test_period_boundary_is_exact_at_one_hour); + RUN_TEST(test_period_clear_boundary_is_exactly_the_log_depth); + RUN_TEST(test_channel_utilization_ages_out_oldest_first); + RUN_TEST(test_channel_utilization_clears_only_the_buckets_crossed); + RUN_TEST(test_channel_utilization_clear_boundary_is_exactly_six_periods); + RUN_TEST(test_channel_utilization_is_zero_when_nothing_logged); + RUN_TEST(test_channel_utilization_decays_proportionally_across_light_sleep); + RUN_TEST(test_channel_utilization_is_independent_of_scheduler_rate); + RUN_TEST(test_channel_utilization_never_exceeds_100_percent); + RUN_TEST(test_channel_utilization_counts_each_packet_once); + RUN_TEST(test_channel_utilization_covers_less_than_its_denominator); + RUN_TEST(test_channel_utilization_quantisation_error_by_phase); + RUN_TEST(test_channel_utilization_exceeds_100_percent_on_long_slow); + RUN_TEST(test_tx_utilization_ages_out_oldest_first); + RUN_TEST(test_tx_utilization_clears_only_the_minutes_crossed); + RUN_TEST(test_tx_utilization_clear_boundary_is_exactly_sixty_minutes); + RUN_TEST(test_tx_utilization_counts_only_transmissions); + RUN_TEST(test_tx_utilization_quantisation_error); + RUN_TEST(test_isTxAllowedChannelUtil_polite_threshold_is_lower); + RUN_TEST(test_isTxAllowedChannelUtil_boundary_is_exclusive); + RUN_TEST(test_isTxAllowedAirUtil_allows_when_override_is_set); + RUN_TEST(test_isTxAllowedAirUtil_allows_when_the_region_is_unlimited); + RUN_TEST(test_isTxAllowedAirUtil_blocks_at_half_the_duty_cycle); + RUN_TEST(test_router_send_gate_uses_the_whole_duty_cycle); + RUN_TEST(test_effective_duty_cycle_special_case_is_eu_866_only); + RUN_TEST(test_getSilentMinutes_returns_zero_when_already_under_the_limit); + RUN_TEST(test_getSilentMinutes_returns_a_full_hour_when_nothing_ages_out); + RUN_TEST(test_getSilentMinutes_counts_minutes_until_enough_ages_out); + RUN_TEST(test_getSilentMinutes_depends_on_ring_phase); + RUN_TEST(test_survives_heavy_sleep_across_the_wrap); + RUN_TEST(test_multi_day_sleep_clears_every_window); + RUN_TEST(test_backwards_uptime_degrades_safely); + RUN_TEST(test_no_public_method_takes_the_lock_twice); exit(UNITY_END()); } diff --git a/test/test_bme680_iaq/test_main.cpp b/test/test_bme680_iaq/test_main.cpp new file mode 100644 index 0000000000..d4844e872e --- /dev/null +++ b/test/test_bme680_iaq/test_main.cpp @@ -0,0 +1,352 @@ +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#include "modules/Telemetry/Sensor/BME680IaqEstimator.h" +#include +#include +#include + +// The estimator is pure math with no platform dependencies, so this suite has +// no feature guard: it runs everywhere the native tests run. + +namespace +{ +constexpr float CLEAN_GAS = 400000.0f; // ~clean-air gas resistance in Ohms +constexpr float REF_RH = 40.0f; + +// Total update() calls before the first IAQ value can appear: the warm-up +// discards plus the burn-in history requirement +constexpr uint32_t CALLS_TO_READY = BME680IaqEstimator::WARMUP_DISCARD + BME680IaqEstimator::BURN_IN_SAMPLES; + +/// Feed constant clean air until the estimator reports; returns the first IAQ +uint16_t makeReady(BME680IaqEstimator &est, float gasOhms = CLEAN_GAS, float rh = REF_RH) +{ + uint16_t iaq = 0xFFFF; + for (uint32_t i = 0; i < CALLS_TO_READY; i++) { + bool got = est.update(gasOhms, rh, &iaq); + TEST_ASSERT_EQUAL_MESSAGE(i == CALLS_TO_READY - 1, got, "IAQ must appear exactly when burn-in completes"); + } + return iaq; +} + +/// On-disk hash contract (xor of the five words preceding xorHash), replicated +/// so corruption tests can forge otherwise-consistent state +uint32_t stateHash(const BME680IaqState &s) +{ + uint32_t words[5]; + memcpy(words, &s, sizeof(words)); + return words[0] ^ words[1] ^ words[2] ^ words[3] ^ words[4]; +} +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +// --- Input validation --- + +void test_rejects_invalid_gas() +{ + BME680IaqEstimator est; + uint16_t iaq; + TEST_ASSERT_FALSE(est.update(0.0f, REF_RH, &iaq)); + TEST_ASSERT_FALSE(est.update(-5000.0f, REF_RH, &iaq)); + TEST_ASSERT_FALSE(est.update(NAN, REF_RH, &iaq)); + TEST_ASSERT_FALSE(est.update(INFINITY, REF_RH, &iaq)); + // Invalid samples must not consume warm-up or burn-in progress + makeReady(est); +} + +void test_invalid_humidity_is_neutral() +{ + BME680IaqEstimator est; + makeReady(est); + uint16_t iaq = 0xFFFF; + TEST_ASSERT_TRUE(est.update(CLEAN_GAS, NAN, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); + // The fallback must not have moved the ceiling: a subsequent valid sample + // at the reference RH must still score 0 (catches a wrong fallback value, + // which would poison the baseline upward via ALPHA_UP) + TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); +} + +// --- Warm-up / burn-in gating --- + +void test_no_output_until_burn_in() +{ + BME680IaqEstimator est; + uint16_t iaq = 0xFFFF; + for (uint32_t i = 0; i < CALLS_TO_READY - 1; i++) + TEST_ASSERT_FALSE(est.update(CLEAN_GAS, REF_RH, &iaq)); + TEST_ASSERT_FALSE(est.ready()); + TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq)); + TEST_ASSERT_TRUE(est.ready()); + TEST_ASSERT_EQUAL_UINT16(0, iaq); +} + +// --- Scoring --- + +void test_clean_air_scores_zero() +{ + BME680IaqEstimator est; + TEST_ASSERT_EQUAL_UINT16(0, makeReady(est)); +} + +void test_band_mapping_from_baseline_ratio() +{ + // Gas dropping to 1/N of the clean baseline should land in the UI band + // the design targets: 1.31x ~Good, 1.7x ~Moderate/Poor edge, 3x ~beep + // threshold, 15x+ pegged at 500 + struct { + float ratio; + uint16_t expected; + uint16_t tolerance; + } cases[] = { + {1.31f, 50, 6}, {1.7f, 98, 7}, {3.0f, 203, 8}, {15.0f, 499, 2}, {100.0f, 500, 1}, + }; + for (auto &c : cases) { + BME680IaqEstimator est; + makeReady(est); + uint16_t iaq = 0; + TEST_ASSERT_TRUE(est.update(CLEAN_GAS / c.ratio, REF_RH, &iaq)); + char msg[64]; + snprintf(msg, sizeof(msg), "ratio %.2f -> iaq %u", (double)c.ratio, iaq); + TEST_ASSERT_UINT_WITHIN_MESSAGE(c.tolerance, c.expected, iaq, msg); + } +} + +void test_band_mapping_holds_for_high_resistance_sensors() +{ + // Fresh/very clean sensors legitimately read in the MOhm range; the + // sanity clamp must not compress events there (regression: LN_CEIL_MAX + // was once ln(~730k), blinding the estimator above that) + BME680IaqEstimator est; + TEST_ASSERT_EQUAL_UINT16(0, makeReady(est, 5000000.0f)); + uint16_t iaq = 0; + TEST_ASSERT_TRUE(est.update(5000000.0f / 3.0f, REF_RH, &iaq)); + TEST_ASSERT_UINT_WITHIN(8, 203, iaq); +} + +void test_floor_clamps_bound_extreme_pollution() +{ + // Baseline seeded from heavily polluted air is clamped up to LN_FLOOR... + BME680IaqEstimator est; + uint16_t iaq = 0xFFFF; + for (uint32_t i = 0; i < CALLS_TO_READY; i++) + est.update(1000.0f, REF_RH, &iaq); + // ...so 1 kOhm scores as polluted relative to that floor, not as "normal" + TEST_ASSERT_TRUE(est.update(1000.0f, REF_RH, &iaq)); + TEST_ASSERT_UINT_WITHIN(10, 297, iaq); // (ln(5000) - ln(1000)) / ln(15) * 500 = (8.517 - 6.908) / 2.708 * 500 + // gas at the floor itself reads clean + TEST_ASSERT_TRUE(est.update(5000.0f, REF_RH, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); + // absurdly low readings rail at exactly 500 via the sample clamp + TEST_ASSERT_TRUE(est.update(1.0f, REF_RH, &iaq)); + TEST_ASSERT_EQUAL_UINT16(500, iaq); +} + +void test_humidity_comfort_penalty() +{ + // Present the same compensated log-resistance at 80 %RH: gas score stays + // ~0, and only the outside-the-30-60-deadband humidity penalty remains + BME680IaqEstimator est; + makeReady(est); + float gasAt80 = CLEAN_GAS * expf(-BME680IaqEstimator::KH * (80.0f - REF_RH)); + uint16_t iaq = 0xFFFF; + TEST_ASSERT_TRUE(est.update(gasAt80, 80.0f, &iaq)); + TEST_ASSERT_UINT_WITHIN(8, 38, iaq); // 0.15 * (20/40 * 500) = 37.5 + + // The dry side of the deadband penalizes symmetrically + BME680IaqEstimator estDry; + makeReady(estDry); + float gasAt10 = CLEAN_GAS * expf(-BME680IaqEstimator::KH * (10.0f - REF_RH)); + TEST_ASSERT_TRUE(estDry.update(gasAt10, 10.0f, &iaq)); + TEST_ASSERT_UINT_WITHIN(8, 38, iaq); + + // Inside the deadband there is no penalty at all + BME680IaqEstimator est2; + makeReady(est2); + float gasAt55 = CLEAN_GAS * expf(-BME680IaqEstimator::KH * (55.0f - REF_RH)); + TEST_ASSERT_TRUE(est2.update(gasAt55, 55.0f, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); +} + +// --- Baseline dynamics --- + +void test_baseline_resists_sustained_pollution() +{ + BME680IaqEstimator est; + makeReady(est); + uint16_t iaq = 0; + for (int i = 0; i < 10; i++) { + TEST_ASSERT_TRUE(est.update(100000.0f, REF_RH, &iaq)); + TEST_ASSERT_GREATER_THAN_UINT(200, iaq); // ln(4) -> ~256, must stay "bad" + } + // Back to clean air: the ceiling barely decayed, so the score snaps to 0 + TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); +} + +void test_baseline_rises_fast_toward_cleaner_air() +{ + BME680IaqEstimator est; + makeReady(est, 300000.0f); + uint16_t iaq = 0xFFFF; + // Cleaner air scores 0 immediately and re-baselines within ~20 samples + for (int i = 0; i < 20; i++) { + TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); + } + // The old air now reads as polluted relative to the new baseline + TEST_ASSERT_TRUE(est.update(300000.0f, REF_RH, &iaq)); + TEST_ASSERT_UINT_WITHIN(8, 53, iaq); // ln(400/300)/ln(15) * 500 +} + +// --- Persistence --- + +void test_serialize_restore_roundtrip() +{ + BME680IaqEstimator est; + makeReady(est); + BME680IaqState state; + est.serialize(&state, 1000000); + TEST_ASSERT_EQUAL_UINT32(BME680IaqEstimator::MAGIC, state.magic); + TEST_ASSERT_EQUAL_UINT32(stateHash(state), state.xorHash); + TEST_ASSERT_EQUAL_UINT8(0, state.warmupRemaining); + + // Warm-up progress travels with the state: a restored estimator reports + // on its very first sample (essential for one-sample-per-wake nodes) + BME680IaqEstimator restored; + TEST_ASSERT_TRUE(restored.restore(state, 1000000 + 3600)); + uint16_t iaq = 0; + TEST_ASSERT_TRUE(restored.update(CLEAN_GAS / 3.0f, REF_RH, &iaq)); + TEST_ASSERT_UINT_WITHIN(8, 203, iaq); +} + +void test_restore_mid_burn_in_continues_progress() +{ + BME680IaqEstimator est; + uint16_t iaq; + for (uint32_t i = 0; i < BME680IaqEstimator::WARMUP_DISCARD + 5; i++) + est.update(CLEAN_GAS, REF_RH, &iaq); + BME680IaqState state; + est.serialize(&state, 0); + + BME680IaqEstimator restored; + TEST_ASSERT_TRUE(restored.restore(state, 0)); + int producedAt = -1; + for (int i = 1; i <= 40; i++) { + if (restored.update(CLEAN_GAS, REF_RH, &iaq)) { + producedAt = i; + break; + } + } + // 5 of 30 burn-in samples were banked before the "reboot" + TEST_ASSERT_EQUAL_INT(BME680IaqEstimator::BURN_IN_SAMPLES - 5, producedAt); +} + +void test_deep_sleep_node_converges_across_reboots() +{ + // Simulate a power-saving SENSOR role: one sample per wake, RAM wiped + // between wakes, state restored+persisted each cycle. Must produce IAQ + // after exactly warm-up + burn-in wakes, not never. + BME680IaqState state; + bool haveState = false; + uint16_t iaq = 0xFFFF; + int producedAt = -1; + for (int wake = 1; wake <= 50; wake++) { + BME680IaqEstimator est; + if (haveState) + TEST_ASSERT_TRUE_MESSAGE(est.restore(state, 0), "persisted progress must restore on every wake"); + if (est.update(CLEAN_GAS, REF_RH, &iaq)) { + producedAt = wake; + break; + } + est.serialize(&state, 0); + haveState = true; + } + TEST_ASSERT_EQUAL_INT((int)CALLS_TO_READY, producedAt); + TEST_ASSERT_EQUAL_UINT16(0, iaq); +} + +void test_restore_rejects_corruption() +{ + BME680IaqEstimator est; + makeReady(est); + BME680IaqState good; + est.serialize(&good, 1000000); + BME680IaqEstimator target; + + BME680IaqState bad = good; + bad.magic ^= 1; + TEST_ASSERT_FALSE(target.restore(bad, 1000000)); + + bad = good; + bad.version = BME680IaqEstimator::VERSION + 1; + bad.xorHash = stateHash(bad); + TEST_ASSERT_FALSE(target.restore(bad, 1000000)); + + bad = good; + bad.xorHash ^= 0xDEADBEEF; + TEST_ASSERT_FALSE(target.restore(bad, 1000000)); + + // Consistent hash but implausible ceiling (the ceiling check only applies + // once samples have been accepted) + bad = good; + bad.lnCeiling = 20.0f; + bad.xorHash = stateHash(bad); + TEST_ASSERT_FALSE(target.restore(bad, 1000000)); + + bad = good; + bad.lnCeiling = NAN; + bad.xorHash = stateHash(bad); + TEST_ASSERT_FALSE(target.restore(bad, 1000000)); +} + +void test_restore_staleness() +{ + BME680IaqEstimator est; + makeReady(est); + BME680IaqState state; + est.serialize(&state, 1000000); + + BME680IaqEstimator target; + TEST_ASSERT_FALSE(target.restore(state, 1000000 + BME680IaqEstimator::STATE_MAX_AGE_SECS + 1)); + TEST_ASSERT_TRUE(target.restore(state, 1000000 + BME680IaqEstimator::STATE_MAX_AGE_SECS - 1)); + + // Unknown age (no RTC at save time or now) is accepted rather than discarded + est.serialize(&state, 0); + BME680IaqEstimator target2; + TEST_ASSERT_TRUE(target2.restore(state, 2000000)); + est.serialize(&state, 1000000); + BME680IaqEstimator target3; + TEST_ASSERT_TRUE(target3.restore(state, 0)); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== BME680 IAQ estimator ===\n"); + RUN_TEST(test_rejects_invalid_gas); + RUN_TEST(test_invalid_humidity_is_neutral); + RUN_TEST(test_no_output_until_burn_in); + RUN_TEST(test_clean_air_scores_zero); + RUN_TEST(test_band_mapping_from_baseline_ratio); + RUN_TEST(test_band_mapping_holds_for_high_resistance_sensors); + RUN_TEST(test_floor_clamps_bound_extreme_pollution); + RUN_TEST(test_humidity_comfort_penalty); + RUN_TEST(test_baseline_resists_sustained_pollution); + RUN_TEST(test_baseline_rises_fast_toward_cleaner_air); + RUN_TEST(test_serialize_restore_roundtrip); + RUN_TEST(test_restore_mid_burn_in_continues_progress); + RUN_TEST(test_deep_sleep_node_converges_across_reboots); + RUN_TEST(test_restore_staleness); + RUN_TEST(test_restore_rejects_corruption); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_channel_keys/test_main.cpp b/test/test_channel_keys/test_main.cpp new file mode 100644 index 0000000000..34c42b3328 --- /dev/null +++ b/test/test_channel_keys/test_main.cpp @@ -0,0 +1,586 @@ +// Channel key derivation and hash layer: getKey() PSK expansion, generateHash() golden values, +// onConfigChanged() primary restore, setChannel() demotion, and perhapsDecode()'s hash fall-through. + +#include "Channels.h" +#include "CryptoEngine.h" +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isBroadcast, etc.) +#include "NodeDB.h" +#include "Router.h" +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include // printf() group separators +#include +#include + +#if defined(ARCH_PORTDUINO) +#define CK_TEST_ENTRY extern "C" +#else +#define CK_TEST_ENTRY +#endif + +// --- Test output helpers --- +#define MSG_BUF_LEN 200 +#define TEST_MSG_FMT(fmt, ...) \ + do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + +// --- Reference hash implementation --- +// Independent re-statement of the algorithm in Channels.cpp (xorHash of the channel name, +// XORed with xorHash of the *expanded* key bytes), used to derive expected values from +// first principles. The golden constants below were computed by hand from this same rule. +static uint8_t refXorHash(const uint8_t *p, size_t len) +{ + uint8_t code = 0; + for (size_t i = 0; i < len; i++) + code ^= p[i]; + return code; +} + +static uint8_t refHash(const char *name, const uint8_t *keyBytes, size_t keyLen) +{ + return refXorHash((const uint8_t *)name, strlen(name)) ^ refXorHash(keyBytes, keyLen); +} + +// Golden values, derived by hand from the algorithm above (pinned so a helper bug cannot +// silently re-derive a wrong expectation): +// xorHash("LongFast") = 'L'^'o'^'n'^'g'^'F'^'a'^'s'^'t' = 0x0A +// xorHash(defaultpsk) = d4^f1^bb^3a^20^29^07^59^f0^bc^ff^ab^cf^4e^69^01 = 0x02 +// hash(default LongFast channel) = 0x0A ^ 0x02 = 0x08 +static const int16_t GOLDEN_LONGFAST_HASH = 0x08; +static const uint8_t GOLDEN_LONGFAST_NAME_XOR = 0x0A; +static const uint8_t GOLDEN_DEFAULTPSK_XOR = 0x02; + +// --- Fixture helpers --- + +// A 16-byte-of-0xEE sentinel armed before each test so "crypto key unchanged" is a real +// assertion instead of an accident of whatever the previous test left behind. +static const uint8_t kSentinelByte = 0xEE; + +static void armCryptoSentinel() +{ + CryptoKey s; + memset(s.bytes, kSentinelByte, sizeof(s.bytes)); + s.length = 16; + crypto->setKey(s); +} + +static bool cryptoKeyIsSentinel() +{ + if (crypto->key.length != 16) + return false; + for (int i = 0; i < 16; i++) + if (crypto->key.bytes[i] != kSentinelByte) + return false; + return true; +} + +static void expectCryptoKey(const uint8_t *expected, int len) +{ + TEST_ASSERT_EQUAL_INT(len, crypto->key.length); + if (len > 0) + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, crypto->key.bytes, (uint32_t)len); +} + +// Write a slot directly and re-run fixupChannel() so the hash cache tracks the edit, +// mirroring how the admin/config paths mutate channelFile. +static meshtastic_Channel &setSlot(uint8_t idx, meshtastic_Channel_Role role, const char *name, const uint8_t *psk, size_t pskLen) +{ + meshtastic_Channel &ch = channels.getByIndex(idx); + ch.index = idx; + ch.has_settings = true; + ch.role = role; + memset(&ch.settings, 0, sizeof(ch.settings)); + if (name) + strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1); + if (psk && pskLen) + memcpy(ch.settings.psk.bytes, psk, pskLen); + ch.settings.psk.size = (pb_size_t)pskLen; + channels.fixupChannel(idx); + return ch; +} + +// Slot 0 as the canonical stock channel (1-byte PSK index 1, empty name -> preset name), +// independent of any USERPREFS_CHANNEL_0_* a build variant may bake into initDefaults(). +static void forceCanonicalDefaultSlot0() +{ + static const uint8_t defaultIndexPsk[1] = {0x01}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", defaultIndexPsk, 1); +} + +// ===================================================================================== +// Group 1: generateHash golden values and sensitivity +// ===================================================================================== + +void test_default_longfast_hash_is_golden() +{ + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); + // Cross-check the hand-derived constant against the reference algorithm on the + // expanded key (a 1-byte index-1 PSK expands to exactly defaultpsk). + TEST_ASSERT_EQUAL_UINT8((uint8_t)GOLDEN_LONGFAST_HASH, refHash("LongFast", defaultpsk, sizeof(defaultpsk))); + TEST_ASSERT_EQUAL_UINT8(GOLDEN_LONGFAST_NAME_XOR ^ GOLDEN_DEFAULTPSK_XOR, (uint8_t)GOLDEN_LONGFAST_HASH); +} + +void test_explicit_longfast_name_hashes_like_empty_name() +{ + // getName() substitutes the modem-preset display name for "" - so an explicit + // "LongFast" and the stock empty name MUST be wire-identical or the two devices + // silently stop decoding each other. + static const uint8_t defaultIndexPsk[1] = {0x01}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "LongFast", defaultIndexPsk, 1); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); +} + +void test_default_string_name_is_normalized() +{ + // fixupChannel() converts the legacy "Default" name to the "" short form. + static const uint8_t defaultIndexPsk[1] = {0x01}; + meshtastic_Channel &ch = setSlot(0, meshtastic_Channel_Role_PRIMARY, "Default", defaultIndexPsk, 1); + TEST_ASSERT_EQUAL_STRING("", ch.settings.name); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); +} + +void test_hash_differs_on_psk_only() +{ + // Same name, PSKs that differ in bytes AND xor -> different hashes. + static const uint8_t pskA[16] = {0x01}; + static const uint8_t pskB[16] = {0x02}; + setSlot(1, meshtastic_Channel_Role_SECONDARY, "alpha", pskA, sizeof(pskA)); + setSlot(2, meshtastic_Channel_Role_SECONDARY, "alpha", pskB, sizeof(pskB)); + TEST_ASSERT_TRUE(channels.getHash(1) >= 0); + TEST_ASSERT_TRUE(channels.getHash(2) >= 0); + TEST_ASSERT_NOT_EQUAL(channels.getHash(1), channels.getHash(2)); + TEST_ASSERT_EQUAL_UINT8(refHash("alpha", pskA, sizeof(pskA)), (uint8_t)channels.getHash(1)); + TEST_ASSERT_EQUAL_UINT8(refHash("alpha", pskB, sizeof(pskB)), (uint8_t)channels.getHash(2)); +} + +void test_hash_differs_on_name_only() +{ + static const uint8_t psk[16] = {0x01}; + setSlot(1, meshtastic_Channel_Role_SECONDARY, "alpha", psk, sizeof(psk)); + setSlot(2, meshtastic_Channel_Role_SECONDARY, "beta", psk, sizeof(psk)); + TEST_ASSERT_TRUE(channels.getHash(1) >= 0); + TEST_ASSERT_TRUE(channels.getHash(2) >= 0); + TEST_ASSERT_NOT_EQUAL(channels.getHash(1), channels.getHash(2)); +} + +void test_disabled_channel_has_invalid_hash() +{ + // Slot 3 was never configured: fixupChannel() in setUp left it DISABLED. + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(3).role); + TEST_ASSERT_EQUAL_INT16(-1, channels.getHash(3)); + // setActiveByIndex on it must refuse and must not touch the crypto key. + TEST_ASSERT_EQUAL_INT16(-1, channels.setActiveByIndex(3)); + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +// ===================================================================================== +// Group 2: getKey() PSK expansion and padding (observed via setActiveByIndex -> crypto->key, +// which is public under PIO_UNIT_TESTING) +// ===================================================================================== + +void test_psk_index_1_expands_to_defaultpsk() +{ + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.setActiveByIndex(0)); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +} + +void test_psk_index_2_bumps_last_byte() +{ + static const uint8_t psk[1] = {0x02}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, 1); + uint8_t expected[sizeof(defaultpsk)]; + memcpy(expected, defaultpsk, sizeof(defaultpsk)); + expected[sizeof(defaultpsk) - 1] = (uint8_t)(expected[sizeof(defaultpsk) - 1] + 1); // index 2 -> last byte +1 + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); + TEST_ASSERT_EQUAL_UINT8(refHash("LongFast", expected, sizeof(expected)), (uint8_t)channels.getHash(0)); +} + +void test_psk_index_0_disables_encryption() +{ + static const uint8_t psk[1] = {0x00}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, 1); + // Key length 0 = plaintext; the hash then covers the name alone. + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_NAME_XOR, channels.setActiveByIndex(0)); + TEST_ASSERT_EQUAL_INT8(0, crypto->key.length); +} + +void test_psk_index_255_boundary() +{ + static const uint8_t psk[1] = {0xFF}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, 1); + uint8_t expected[sizeof(defaultpsk)]; + memcpy(expected, defaultpsk, sizeof(defaultpsk)); + // last byte 0x01 + 0xFF - 1 = 0xFF: the full index range stays inside one uint8_t + expected[sizeof(defaultpsk) - 1] = 0xFF; + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); +} + +void test_short_key_pads_to_aes128() +{ + static const uint8_t psk[5] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, sizeof(psk)); + uint8_t expected[16] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5}; // bytes 5..15 zero-padded + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); +} + +void test_midsize_key_pads_to_aes256() +{ + uint8_t psk[24]; + for (size_t i = 0; i < sizeof(psk); i++) + psk[i] = (uint8_t)(0x40 + i); + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, sizeof(psk)); + uint8_t expected[32] = {}; + memcpy(expected, psk, sizeof(psk)); // bytes 24..31 zero-padded + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); +} + +void test_exact_16_and_32_byte_keys_pass_through() +{ + uint8_t psk16[16]; + for (size_t i = 0; i < sizeof(psk16); i++) + psk16[i] = (uint8_t)(0x10 + i); + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk16, sizeof(psk16)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(psk16, sizeof(psk16)); + + uint8_t psk32[32]; + for (size_t i = 0; i < sizeof(psk32); i++) + psk32[i] = (uint8_t)(0x20 + i); + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk32, sizeof(psk32)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(psk32, sizeof(psk32)); +} + +// ===================================================================================== +// Group 3: secondary key inheritance and the recursion guard +// ===================================================================================== + +void test_secondary_empty_psk_inherits_primary_key() +{ + setSlot(1, meshtastic_Channel_Role_SECONDARY, "second", nullptr, 0); + // Effective key is the primary's expanded key (defaultpsk); the hash mixes the + // secondary's OWN name with that inherited key: + // xorHash("second") = 's'^'e'^'c'^'o'^'n'^'d' = 0x10; 0x10 ^ 0x02 = 0x12 + TEST_ASSERT_EQUAL_INT16(0x12, channels.getHash(1)); + TEST_ASSERT_EQUAL_UINT8(refHash("second", defaultpsk, sizeof(defaultpsk)), (uint8_t)channels.getHash(1)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(1) >= 0); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +} + +void test_recursion_guard_primary_slot_marked_secondary() +{ + // Malformed config: the slot primaryIndex points at (0) is itself SECONDARY with no + // PSK. Without the chIndex != primaryIndex guard, getKey(0) would recurse into + // getKey(0) forever; the guarded path treats it as encryption-off instead. + setSlot(0, meshtastic_Channel_Role_SECONDARY, "", nullptr, 0); + TEST_ASSERT_EQUAL_UINT8(0, channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_NAME_XOR, channels.getHash(0)); // name-only hash + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_NAME_XOR, channels.setActiveByIndex(0)); + TEST_ASSERT_EQUAL_INT8(0, crypto->key.length); +} + +// ===================================================================================== +// Group 4: onConfigChanged() no-primary restore and setChannel() demotion +// ===================================================================================== + +void test_onconfigchanged_promotes_demoted_primary_slot_keeping_key() +{ + // Phone demotes every slot: the slot primaryIndex references is SECONDARY with real + // key material -> it must be promoted in place, NOT replaced with a default key. + static const uint8_t privatePsk[16] = {0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, + 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB}; + setSlot(0, meshtastic_Channel_Role_SECONDARY, "keep", privatePsk, sizeof(privatePsk)); + channels.onConfigChanged(); + + meshtastic_Channel &ch = channels.getByIndex(0); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, ch.role); + TEST_ASSERT_EQUAL_UINT8(0, channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL_UINT16(sizeof(privatePsk), ch.settings.psk.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privatePsk, ch.settings.psk.bytes, sizeof(privatePsk)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(privatePsk, sizeof(privatePsk)); +} + +void test_onconfigchanged_restores_default_when_all_disabled() +{ + // Every slot DISABLED (zeroed): promoting a zeroed slot would create a plaintext + // primary, so the restore must install the stock default channel instead. + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = MAX_NUM_CHANNELS; + channels.onConfigChanged(); + + meshtastic_Channel &ch = channels.getByIndex(channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, ch.role); + TEST_ASSERT_TRUE(ch.settings.psk.size >= 1); + TEST_ASSERT_TRUE(channels.setActiveByIndex(channels.getPrimaryIndex()) >= 0); + // The restored primary must never come up plaintext. + TEST_ASSERT_TRUE(crypto->key.length > 0); +#if !defined(USERPREFS_CHANNEL_0_PSK) && !defined(USERPREFS_CHANNEL_0_NAME) + // Stock build: the restored channel is exactly the default LongFast channel. + TEST_ASSERT_EQUAL_UINT8(0, channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL_UINT16(1, ch.settings.psk.size); + TEST_ASSERT_EQUAL_UINT8(0x01, ch.settings.psk.bytes[0]); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +#endif +} + +void test_setchannel_demotes_old_primary() +{ + static const uint8_t psk[1] = {0x02}; + meshtastic_Channel c = meshtastic_Channel_init_zero; + c.index = 1; + c.role = meshtastic_Channel_Role_PRIMARY; + c.has_settings = true; + strncpy(c.settings.name, "boss", sizeof(c.settings.name) - 1); + memcpy(c.settings.psk.bytes, psk, sizeof(psk)); + c.settings.psk.size = sizeof(psk); + + channels.setChannel(c); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_SECONDARY, channels.getByIndex(0).role); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, channels.getByIndex(1).role); + + // primaryIndex tracks the change only once onConfigChanged() re-scans. + channels.onConfigChanged(); + TEST_ASSERT_EQUAL_UINT8(1, channels.getPrimaryIndex()); +} + +// ===================================================================================== +// Group 5: decryptForHash() bounds - regression pin for #11046 (cfecef537). Pre-fix the +// bound was `>`, so chIndex == getNumChannels() read one past hashes[] on the hot decode +// path for every received packet. +// ===================================================================================== + +void test_decryptforhash_rejects_out_of_range_index() +{ + const ChannelIndex n = channels.getNumChannels(); + TEST_ASSERT_EQUAL_UINT8(MAX_NUM_CHANNELS, n); + TEST_ASSERT_FALSE(channels.decryptForHash(n, (ChannelHash)channels.getHash(0))); + TEST_ASSERT_FALSE(channels.decryptForHash((ChannelIndex)(n + 1), (ChannelHash)channels.getHash(0))); + TEST_ASSERT_FALSE(channels.decryptForHash((ChannelIndex)MAX_NUM_CHANNELS, 0x08)); + TEST_ASSERT_FALSE(channels.decryptForHash((ChannelIndex)255, 0x08)); + // A rejected index must not have touched the crypto key. + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +void test_decryptforhash_accepts_valid_index_and_hash() +{ + TEST_ASSERT_TRUE(channels.decryptForHash(0, (ChannelHash)GOLDEN_LONGFAST_HASH)); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +} + +void test_decryptforhash_rejects_wrong_hash() +{ + TEST_ASSERT_FALSE(channels.decryptForHash(0, (ChannelHash)(GOLDEN_LONGFAST_HASH + 1))); + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +void test_decryptforhash_disabled_slot_matches_no_hash() +{ + // A DISABLED slot's cached hash is -1 (int16), which no 0-255 wire hash can equal. + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(3).role); + for (int h = 0; h <= 255; h++) + TEST_ASSERT_FALSE(channels.decryptForHash(3, (ChannelHash)h)); + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +// ===================================================================================== +// Group 6: Router perhapsDecode() same-hash fall-through. Two enabled channels can share +// a hash (it is one xor byte); the decoder must try each candidate and commit the one +// whose key authenticates a well-formed Data, rewriting p->channel from hash to INDEX - +// the value admin-channel authorization consumes downstream. +// +// Skipped on event builds: their decode path runs isBlockedEventCoordinatePacket() -> +// willUsePki(), which dereferences the nodeDB this suite deliberately never constructs +// (keeping it free of disk writes). +// ===================================================================================== + +#if !USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + +// Same name + PSKs with equal xor but different bytes -> identical hash, different keys. +static const uint8_t kClashPskA[16] = {0x01}; +static const uint8_t kClashPskB[16] = {0x00, 0x01}; + +static uint8_t configureCollisionChannels() +{ + setSlot(1, meshtastic_Channel_Role_SECONDARY, "clash", kClashPskA, sizeof(kClashPskA)); + setSlot(2, meshtastic_Channel_Role_SECONDARY, "clash", kClashPskB, sizeof(kClashPskB)); + TEST_ASSERT_TRUE(channels.getHash(1) >= 0); + TEST_ASSERT_EQUAL_INT16(channels.getHash(1), channels.getHash(2)); + // Nonzero hash keeps perhapsDecode() off the PKI-candidate branch (p->channel == 0), + // which would dereference the nodeDB this suite deliberately never constructs. + TEST_ASSERT_TRUE(channels.getHash(1) != 0); + return (uint8_t)channels.getHash(1); +} + +static meshtastic_Data makeProbeData() +{ + meshtastic_Data d = meshtastic_Data_init_zero; + d.portnum = meshtastic_PortNum_POSITION_APP; + static const char probe[] = "collision-probe"; + memcpy(d.payload.bytes, probe, sizeof(probe)); + d.payload.size = sizeof(probe); + return d; +} + +// Encrypts with whatever key is currently loaded into the crypto engine. +static meshtastic_MeshPacket makeEncryptedPacket(uint8_t channelHash, const meshtastic_Data &d) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = 0x11223344; + p.to = NODENUM_BROADCAST; // broadcast: no unicast-only branches + p.id = 0xA5A5A5A5; + p.channel = channelHash; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = (pb_size_t)pb_encode_to_bytes(p.encrypted.bytes, sizeof(p.encrypted.bytes), &meshtastic_Data_msg, &d); + TEST_ASSERT_TRUE(p.encrypted.size > 0); + crypto->encryptPacket(p.from, p.id, p.encrypted.size, p.encrypted.bytes); + return p; +} + +void test_perhapsdecode_collision_selects_matching_psk() +{ + // is_licensed short-circuits the legacy-DM isToUs() check inside perhapsDecode(), + // which would otherwise dereference the absent nodeDB (restored in tearDown). + owner.is_licensed = true; + const uint8_t h = configureCollisionChannels(); + const meshtastic_Data d = makeProbeData(); + + TEST_ASSERT_TRUE(channels.setActiveByIndex(2) >= 0); // encrypt with slot 2's key + meshtastic_MeshPacket p = makeEncryptedPacket(h, d); + + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_SUCCESS, perhapsDecode(&p)); + // Hash slot 1 was tried first and rejected; the committed channel is the INDEX 2. + TEST_ASSERT_EQUAL_UINT8(2, p.channel); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant); + TEST_ASSERT_EQUAL_INT(meshtastic_PortNum_POSITION_APP, p.decoded.portnum); + TEST_ASSERT_EQUAL_UINT16(d.payload.size, p.decoded.payload.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(d.payload.bytes, p.decoded.payload.bytes, d.payload.size); +} + +void test_perhapsdecode_wrong_key_is_decode_failure() +{ + owner.is_licensed = true; + const uint8_t h = configureCollisionChannels(); + + // Encrypt with a key belonging to NO configured channel; the hash still matches + // slots 1 and 2, so a channel was tried -> DECODE_FAILURE, not DECODE_OPAQUE. + CryptoKey stranger; + memset(stranger.bytes, 0x5A, sizeof(stranger.bytes)); + stranger.length = 16; + crypto->setKey(stranger); + meshtastic_MeshPacket p = makeEncryptedPacket(h, makeProbeData()); + + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_FAILURE, perhapsDecode(&p)); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, p.which_payload_variant); +} + +void test_perhapsdecode_unknown_hash_is_opaque() +{ + owner.is_licensed = true; + configureCollisionChannels(); + + // Find a nonzero wire hash no enabled channel produces. + int candidate = -1; + for (int c = 1; c < 256 && candidate < 0; c++) { + bool used = false; + for (ChannelIndex i = 0; i < channels.getNumChannels(); i++) + if (channels.getHash(i) == c) + used = true; + if (!used) + candidate = c; + } + TEST_ASSERT_TRUE(candidate > 0); + TEST_MSG_FMT("unknown-hash probe uses 0x%02x", (unsigned)candidate); + + TEST_ASSERT_TRUE(channels.setActiveByIndex(2) >= 0); + meshtastic_MeshPacket p = makeEncryptedPacket((uint8_t)candidate, makeProbeData()); + + // No channel matched at all: the packet stays opaque (relayable ciphertext). + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_OPAQUE, perhapsDecode(&p)); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, p.which_payload_variant); +} + +#endif // !USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + +// --- Unity lifecycle --- + +void setUp(void) +{ + memset(&channelFile, 0, sizeof(channelFile)); + memset(&config, 0, sizeof(config)); + owner.is_licensed = false; + channels.initDefaults(); // 8 slots + default lora config; only slot 0 populated + // Pin the preset the golden hashes assume ("" -> "LongFast"), in case a variant + // build's USERPREFS_LORACONFIG_MODEM_PRESET overrode it inside initDefaults(). + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + channels.onConfigChanged(); // computes the hash cache and primaryIndex + forceCanonicalDefaultSlot0(); + armCryptoSentinel(); +} + +void tearDown(void) +{ + owner.is_licensed = false; +} + +CK_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + // perhapsDecode() takes cryptLock; normally Router's ctor allocates it, but this + // suite never constructs a Router (nor a NodeDB - it must stay disk-write free). + if (!cryptLock) + cryptLock = new concurrency::Lock(); + UNITY_BEGIN(); + + printf("\n=== generateHash golden values ===\n"); + RUN_TEST(test_default_longfast_hash_is_golden); + RUN_TEST(test_explicit_longfast_name_hashes_like_empty_name); + RUN_TEST(test_default_string_name_is_normalized); + RUN_TEST(test_hash_differs_on_psk_only); + RUN_TEST(test_hash_differs_on_name_only); + RUN_TEST(test_disabled_channel_has_invalid_hash); + + printf("\n=== getKey expansion and padding ===\n"); + RUN_TEST(test_psk_index_1_expands_to_defaultpsk); + RUN_TEST(test_psk_index_2_bumps_last_byte); + RUN_TEST(test_psk_index_0_disables_encryption); + RUN_TEST(test_psk_index_255_boundary); + RUN_TEST(test_short_key_pads_to_aes128); + RUN_TEST(test_midsize_key_pads_to_aes256); + RUN_TEST(test_exact_16_and_32_byte_keys_pass_through); + + printf("\n=== secondary inheritance and recursion guard ===\n"); + RUN_TEST(test_secondary_empty_psk_inherits_primary_key); + RUN_TEST(test_recursion_guard_primary_slot_marked_secondary); + + printf("\n=== onConfigChanged restore and setChannel ===\n"); + RUN_TEST(test_onconfigchanged_promotes_demoted_primary_slot_keeping_key); + RUN_TEST(test_onconfigchanged_restores_default_when_all_disabled); + RUN_TEST(test_setchannel_demotes_old_primary); + + printf("\n=== decryptForHash bounds (#11046) ===\n"); + RUN_TEST(test_decryptforhash_rejects_out_of_range_index); + RUN_TEST(test_decryptforhash_accepts_valid_index_and_hash); + RUN_TEST(test_decryptforhash_rejects_wrong_hash); + RUN_TEST(test_decryptforhash_disabled_slot_matches_no_hash); + +#if !USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + printf("\n=== perhapsDecode same-hash fall-through ===\n"); + RUN_TEST(test_perhapsdecode_collision_selects_matching_psk); + RUN_TEST(test_perhapsdecode_wrong_key_is_decode_failure); + RUN_TEST(test_perhapsdecode_unknown_hash_is_opaque); +#endif + + exit(UNITY_END()); +} + +CK_TEST_ENTRY void loop() {} diff --git a/test/test_default/test_main.cpp b/test/test_default/test_main.cpp index ee4fc16279..36c06e9773 100644 --- a/test/test_default/test_main.cpp +++ b/test/test_default/test_main.cpp @@ -277,6 +277,10 @@ void test_trafficType_overflowSaturates() TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), res); } +// Required by Unity: PlatformIO's weak defaults do not link on MinGW (PE-COFF weak externals). +void setUp(void) {} +void tearDown(void) {} + void setup() { // Small delay to match other test mains diff --git a/test/test_event_channel_phone_api/test_main.cpp b/test/test_event_channel_phone_api/test_main.cpp index f7932b9c3f..57a65915aa 100644 --- a/test/test_event_channel_phone_api/test_main.cpp +++ b/test/test_event_channel_phone_api/test_main.cpp @@ -1,4 +1,5 @@ #include "Channels.h" +#include "MeshModule.h" #include "MeshService.h" #include "NodeDB.h" #include "RadioInterface.h" @@ -15,10 +16,28 @@ namespace { constexpr PacketId BLOCKED_PACKET_ID = 0x10203040; constexpr PacketId FOLLOWUP_PACKET_ID = 0x50607080; +constexpr PacketId WAYPOINT_PACKET_ID = 0x0a0b0c0d; constexpr ChannelIndex EVENT_CHANNEL = 0; constexpr ChannelIndex PRIVATE_CHANNEL = 1; +constexpr NodeNum LOCAL_NODE = 0x87654321; constexpr NodeNum REMOTE_NODE = 0x12345678; +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) +// Where a coordinate packet the phone aimed at the event channel actually goes once a channel carries positions. +constexpr ChannelIndex COERCED_CHANNEL = PRIVATE_CHANNEL; +#else +constexpr ChannelIndex COERCED_CHANNEL = EVENT_CHANNEL; +#endif + +// Router::sendLocal() loops a to-self packet through MeshModule::callModules(), which walks the module +// list; construct one so the list exists in this otherwise module-free binary. +class NoopModule : public MeshModule +{ + public: + NoopModule() : MeshModule("event-phone-api-noop") {} + bool wantPacket(const meshtastic_MeshPacket *) override { return false; } +}; + class MockRadioInterface : public RadioInterface { public: @@ -106,6 +125,7 @@ MockMeshService *mockService; MockRouter *mockRouter; NodeDB *mockNodeDB; TestStreamAPI *streamAPI; +NoopModule *noopModule; void configureChannels() { @@ -135,20 +155,35 @@ void configureChannels() channels.onConfigChanged(); } -meshtastic_ToRadio makePositionToRadio(PacketId id, ChannelIndex channel) +// configureChannels() leaves both channels without module_settings, i.e. position sharing off everywhere +// (getPositionPrecisionForChannel fails closed). Opt the private channel in so it becomes the position channel. +void enablePositionOnPrivateChannel() +{ + auto &privateChannel = channelFile.channels[PRIVATE_CHANNEL]; + privateChannel.settings.has_module_settings = true; + privateChannel.settings.module_settings.position_precision = 32; + channels.onConfigChanged(); +} + +meshtastic_ToRadio makeCoordinateToRadio(PacketId id, ChannelIndex channel, meshtastic_PortNum portnum, NodeNum to) { meshtastic_ToRadio message = meshtastic_ToRadio_init_default; const meshtastic_MeshPacket defaultPacket = meshtastic_MeshPacket_init_default; message.which_payload_variant = meshtastic_ToRadio_packet_tag; message.packet = defaultPacket; - message.packet.to = REMOTE_NODE; + message.packet.to = to; message.packet.id = id; message.packet.channel = channel; message.packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; - message.packet.decoded.portnum = meshtastic_PortNum_POSITION_APP; + message.packet.decoded.portnum = portnum; return message; } +meshtastic_ToRadio makePositionToRadio(PacketId id, ChannelIndex channel) +{ + return makeCoordinateToRadio(id, channel, meshtastic_PortNum_POSITION_APP, REMOTE_NODE); +} + bool sendToRadio(const meshtastic_ToRadio &message) { uint8_t encoded[meshtastic_ToRadio_size] = {}; @@ -160,13 +195,14 @@ bool sendToRadio(const meshtastic_ToRadio &message) return streamAPI->handleToRadio(encoded, encodedSize); } -void assertSentPacket(size_t index, PacketId id, ChannelIndex channel) +void assertSentPacket(size_t index, PacketId id, ChannelIndex channel, + meshtastic_PortNum portnum = meshtastic_PortNum_POSITION_APP) { TEST_ASSERT_GREATER_THAN(index, mockRouter->sentPackets.size()); const auto &packet = mockRouter->sentPackets[index]; TEST_ASSERT_EQUAL_UINT32(id, packet.id); TEST_ASSERT_EQUAL_UINT8(channel, packet.channel); - TEST_ASSERT_EQUAL(meshtastic_PortNum_POSITION_APP, packet.decoded.portnum); + TEST_ASSERT_EQUAL(portnum, packet.decoded.portnum); } } // namespace @@ -177,16 +213,19 @@ void setUp(void) service = mockService = new MockMeshService(); nodeDB = mockNodeDB = new NodeDB(); - myNodeInfo.my_node_num = 0x87654321; + myNodeInfo.my_node_num = LOCAL_NODE; configureChannels(); cryptLock = nullptr; // Router's ctor asserts this is unset before allocating its own. router = mockRouter = new MockRouter(); streamAPI = new TestStreamAPI(); + noopModule = new NoopModule(); testDelay(1); } void tearDown(void) { + delete noopModule; + noopModule = nullptr; delete streamAPI; streamAPI = nullptr; delete mockRouter; @@ -250,12 +289,62 @@ static void test_event_position_ingress_does_not_poison_retry_state() #endif } +// The apps feed the node its phone GPS fix as a POSITION packet addressed to the node itself on channel 0. +// That packet never leaves the device, so it must pass regardless of the event policy and without a +// notification, on any channel configuration (here: no channel carries positions at all). +static void test_phone_position_to_self_is_never_blocked() +{ + const auto toSelf = makeCoordinateToRadio(BLOCKED_PACKET_ID, EVENT_CHANNEL, meshtastic_PortNum_POSITION_APP, LOCAL_NODE); + + TEST_ASSERT_TRUE(sendToRadio(toSelf)); + TEST_ASSERT_EQUAL(0, mockRouter->sentPackets.size()); // delivered locally, never on the air + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); +} + +// A coordinate the phone aims at the event channel is moved onto the position channel (the first channel +// with position sharing enabled) instead of being rejected, and the phone is not told anything went wrong. +// Without the event policy the packet stays on the channel the phone chose. +static void test_phone_coordinates_on_event_channel_move_to_position_channel() +{ + enablePositionOnPrivateChannel(); + const auto positionRequest = makePositionToRadio(BLOCKED_PACKET_ID, EVENT_CHANNEL); // DM (e.g. "request position") + const auto waypointBroadcast = + makeCoordinateToRadio(WAYPOINT_PACKET_ID, EVENT_CHANNEL, meshtastic_PortNum_WAYPOINT_APP, NODENUM_BROADCAST); + + TEST_ASSERT_TRUE(sendToRadio(positionRequest)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + assertSentPacket(0, BLOCKED_PACKET_ID, COERCED_CHANNEL); + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); + + TEST_ASSERT_TRUE(sendToRadio(waypointBroadcast)); + TEST_ASSERT_EQUAL(2, mockRouter->sentPackets.size()); + assertSentPacket(1, WAYPOINT_PACKET_ID, COERCED_CHANNEL, meshtastic_PortNum_WAYPOINT_APP); + mockService->assertQueueStatus(WAYPOINT_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); +} + +// A coordinate already on the position channel is left alone. +static void test_phone_coordinates_on_position_channel_are_untouched() +{ + enablePositionOnPrivateChannel(); + + TEST_ASSERT_TRUE(sendToRadio(makePositionToRadio(FOLLOWUP_PACKET_ID, PRIVATE_CHANNEL))); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + assertSentPacket(0, FOLLOWUP_PACKET_ID, PRIVATE_CHANNEL); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); +} + extern "C" { void setup() { initializeTestEnvironment(); UNITY_BEGIN(); RUN_TEST(test_event_position_ingress_does_not_poison_retry_state); + RUN_TEST(test_phone_position_to_self_is_never_blocked); + RUN_TEST(test_phone_coordinates_on_event_channel_move_to_position_channel); + RUN_TEST(test_phone_coordinates_on_position_channel_are_untouched); exit(UNITY_END()); } diff --git a/test/test_event_channel_router/test_main.cpp b/test/test_event_channel_router/test_main.cpp index c1f5e8becc..479a0275c3 100644 --- a/test/test_event_channel_router/test_main.cpp +++ b/test/test_event_channel_router/test_main.cpp @@ -9,10 +9,11 @@ #include "mesh/MeshRadio.h" #include "mesh/MeshService.h" #include "mesh/NodeDB.h" +#include "mesh/PositionPrecision.h" #include "mesh/Router.h" -#if ARCH_PORTDUINO -#include "platform/portduino/PortduinoGlue.h" -#endif +#include "modules/PositionModule.h" +#include "modules/RoutingModule.h" +#include "support/MockMeshService.h" #include #include #include @@ -121,9 +122,6 @@ struct SavedGlobals { MeshService *service; AirTime *airTime; concurrency::Lock *cryptLock; -#if ARCH_PORTDUINO - bool forceSimRadio; -#endif }; SavedGlobals saved; @@ -283,6 +281,151 @@ static void test_opaque_tx_is_not_misclassified_as_coordinates() TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); } +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL +static void enablePositionOnPrivateChannel() +{ + meshtastic_Channel &privateChannel = channelFile.channels[kPrivateChannel]; + privateChannel.settings.has_module_settings = true; + privateChannel.settings.module_settings.position_precision = 32; + channels.onConfigChanged(); + uint8_t positionChannel = 0xff; + TEST_ASSERT_TRUE(findPositionChannel(positionChannel)); + TEST_ASSERT_EQUAL_UINT8(kPrivateChannel, positionChannel); +} + +// The reply path needs the module, a service to send through, a routing module for the response hop +// limit, and a fix of our own. Scoped to the one test so the rest of the suite stays module-free. +struct ReplyHarness { + MeshService *savedService = service; + RoutingModule *savedRouting = routingModule; + PositionModule *savedPosition = positionModule; + MockMeshService localService; + RoutingModule localRouting; + PositionModule localPosition; + + ReplyHarness() + { + service = &localService; + routingModule = &localRouting; + positionModule = &localPosition; + testNodeDB->addNode(kLocalNode, kEventChannel); // refreshLocalMeshNode() asserts our own entry exists + meshtastic_Position fix = meshtastic_Position_init_zero; + fix.has_latitude_i = true; + fix.latitude_i = 407825770; + fix.has_longitude_i = true; + fix.longitude_i = -1192084390; + testNodeDB->setLocalPosition(fix); + } + + ~ReplyHarness() + { + // Drain what sendToMesh() queued for the (absent) phone so the pools are clean at exit. + while (auto *status = localService.getQueueStatusForPhone()) + localService.releaseQueueStatusToPool(status); + while (auto *packet = localService.getForPhone()) + localService.releaseToPool(packet); + positionModule = savedPosition; + routingModule = savedRouting; + service = savedService; + } +}; + +// A position request DM'd to us on the event channel is not processed (no module sees it, so nothing is +// stored or forwarded), but it is answered: our position goes out as a reply, on the position channel. +static void test_rx_event_channel_position_request_to_us_is_answered_on_position_channel() +{ + enablePositionOnPrivateChannel(); + ReplyHarness harness; + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kRemoteNode, kLocalNode, kEventChannel); + request.decoded.want_response = true; + receivePacket(request); + + TEST_ASSERT_EQUAL_UINT32(0, captureModule->packets.size()); + TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); + + meshtastic_MeshPacket reply = captureRadio->packets.front(); + TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); + TEST_ASSERT_EQUAL_UINT32(kLocalNode, reply.from); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, reply.which_payload_variant); // went out under a channel key + TEST_ASSERT_EQUAL(DecodeState::DECODE_SUCCESS, perhapsDecode(&reply)); + TEST_ASSERT_EQUAL_UINT8(kPrivateChannel, reply.channel); // ...the position channel's, not the event channel's + TEST_ASSERT_EQUAL(meshtastic_PortNum_POSITION_APP, reply.decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); +} + +// Without a position channel there is nothing to answer on: the request is simply dropped. +static void test_rx_event_channel_position_request_without_position_channel_is_dropped() +{ + ReplyHarness harness; + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kRemoteNode, kLocalNode, kEventChannel); + request.decoded.want_response = true; + receivePacket(request); + + TEST_ASSERT_EQUAL_UINT32(0, captureModule->packets.size()); + TEST_ASSERT_EQUAL_UINT32(0, captureRadio->packets.size()); +} + +// A broadcast position on the event channel is dropped outright, want_response or not: only unicast +// requests to us are answered. +static void test_rx_event_channel_position_broadcast_with_want_response_is_not_answered() +{ + enablePositionOnPrivateChannel(); + ReplyHarness harness; + + meshtastic_MeshPacket broadcast = + makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kRemoteNode, NODENUM_BROADCAST, kEventChannel); + broadcast.decoded.want_response = true; + receivePacket(broadcast); + + TEST_ASSERT_EQUAL_UINT32(0, captureModule->packets.size()); + TEST_ASSERT_EQUAL_UINT32(0, captureRadio->packets.size()); +} + +// The phone hands a GPS-less node its fix as a POSITION packet from us to us on channel 0. It never goes on +// the air, so the event policy must let it through to the modules (where PositionModule records it). +static void test_loopback_position_from_us_to_us_on_event_channel_is_not_blocked() +{ + meshtastic_MeshPacket loopback = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kLocalNode, kLocalNode, kEventChannel); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&loopback)); + + meshtastic_MeshPacket *packet = packetPool.allocCopy(loopback); + TEST_ASSERT_NOT_NULL(packet); + TEST_ASSERT_EQUAL_INT(ERRNO_SHOULD_RELEASE, testRouter->sendLocal(packet, RX_SRC_USER)); + packetPool.release(packet); + + TEST_ASSERT_EQUAL_UINT32(1, captureModule->packets.size()); + TEST_ASSERT_EQUAL_UINT32(0, captureRadio->packets.size()); +} + +// A local originator (module, UI) that aims a coordinate at the event channel is moved onto the position +// channel by sendLocal(); with no position channel the send is still refused. +static void test_tx_local_coordinate_on_event_channel_is_moved_to_position_channel() +{ + meshtastic_MeshPacket *packet = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(packet); + packet->to = NODENUM_BROADCAST; + packet->channel = kEventChannel; + packet->decoded = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kLocalNode, NODENUM_BROADCAST, kEventChannel).decoded; + TEST_ASSERT_EQUAL_INT(meshtastic_Routing_Error_NOT_AUTHORIZED, testRouter->sendLocal(packet, RX_SRC_LOCAL)); + TEST_ASSERT_EQUAL_UINT32(0, captureRadio->packets.size()); + + enablePositionOnPrivateChannel(); + packet = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(packet); + packet->to = NODENUM_BROADCAST; + packet->channel = kEventChannel; + packet->decoded = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kLocalNode, NODENUM_BROADCAST, kEventChannel).decoded; + TEST_ASSERT_EQUAL_INT(ERRNO_OK, testRouter->sendLocal(packet, RX_SRC_LOCAL)); + TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); + + meshtastic_MeshPacket sent = captureRadio->packets.front(); + TEST_ASSERT_EQUAL(DecodeState::DECODE_SUCCESS, perhapsDecode(&sent)); + TEST_ASSERT_EQUAL_UINT8(kPrivateChannel, sent.channel); +} +#endif + static void test_capture_endpoints_release_packet_pool_ownership() { constexpr size_t iterations = 64; @@ -319,9 +462,6 @@ void setUp(void) saved.service = service; saved.airTime = airTime; saved.cryptLock = cryptLock; -#if ARCH_PORTDUINO - saved.forceSimRadio = portduino_config.force_simradio; -#endif testNodeDB = new TestNodeDB(); testNodeDB->clearTestNodes(); @@ -335,9 +475,6 @@ void setUp(void) memset(&myNodeInfo, 0, sizeof(myNodeInfo)); myNodeInfo.my_node_num = kLocalNode; service = nullptr; -#if ARCH_PORTDUINO - portduino_config.force_simradio = false; -#endif installChannels(); testAirTime = new AirTime(); @@ -379,9 +516,6 @@ void tearDown(void) router = saved.router; service = saved.service; airTime = saved.airTime; -#if ARCH_PORTDUINO - portduino_config.force_simradio = saved.forceSimRadio; -#endif } EVENT_ROUTER_TEST_ENTRY void setup() @@ -397,6 +531,13 @@ EVENT_ROUTER_TEST_ENTRY void setup() RUN_TEST(test_tx_event_coordinate_that_uses_pki_reaches_radio); #endif RUN_TEST(test_opaque_tx_is_not_misclassified_as_coordinates); +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + RUN_TEST(test_rx_event_channel_position_request_to_us_is_answered_on_position_channel); + RUN_TEST(test_rx_event_channel_position_request_without_position_channel_is_dropped); + RUN_TEST(test_rx_event_channel_position_broadcast_with_want_response_is_not_answered); + RUN_TEST(test_loopback_position_from_us_to_us_on_event_channel_is_not_blocked); + RUN_TEST(test_tx_local_coordinate_on_event_channel_is_moved_to_position_channel); +#endif RUN_TEST(test_capture_endpoints_release_packet_pool_ownership); exit(UNITY_END()); diff --git a/test/test_firmware_edition/test_main.cpp b/test/test_firmware_edition/test_main.cpp new file mode 100644 index 0000000000..949dd0335f --- /dev/null +++ b/test/test_firmware_edition/test_main.cpp @@ -0,0 +1,49 @@ +// devicestate.my_node survives a firmware reinstall, so a vanilla build (no +// USERPREFS_FIRMWARE_EDITION) must reset a persisted event edition at boot. +#include "MeshTypes.h" // Include BEFORE TestUtil.h +#include "TestUtil.h" +#include "mesh/NodeDB.h" +#include + +#if defined(ARCH_PORTDUINO) +#define FE_TEST_ENTRY extern "C" +#else +#define FE_TEST_ENTRY +#endif + +void setUp(void) {} +void tearDown(void) {} + +static meshtastic_FirmwareEdition persistedEdition() +{ + meshtastic_DeviceState saved = meshtastic_DeviceState_init_zero; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(deviceStateFileName, meshtastic_DeviceState_size, + sizeof(saved), &meshtastic_DeviceState_msg, &saved)); + return saved.my_node.firmware_edition; +} + +static void test_vanillaBoot_resetsPersistedEventEdition(void) +{ + devicestate.my_node.firmware_edition = meshtastic_FirmwareEdition_DEFCON; + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_DEVICESTATE)); + TEST_ASSERT_EQUAL(meshtastic_FirmwareEdition_DEFCON, persistedEdition()); + + NodeDB *rebooted = new NodeDB(); + delete nodeDB; + nodeDB = rebooted; + + TEST_ASSERT_EQUAL(meshtastic_FirmwareEdition_VANILLA, devicestate.my_node.firmware_edition); + // On disk too, not just in RAM: the stamp must land before the boot save decision. + TEST_ASSERT_EQUAL(meshtastic_FirmwareEdition_VANILLA, persistedEdition()); +} + +FE_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + nodeDB = new NodeDB(); + + UNITY_BEGIN(); + RUN_TEST(test_vanillaBoot_resetsPersistedEventEdition); + exit(UNITY_END()); +} +FE_TEST_ENTRY void loop() {} diff --git a/test/test_fscommon_getfiles/test_main.cpp b/test/test_fscommon_getfiles/test_main.cpp index 943bc43a7f..eaa776d1be 100644 --- a/test/test_fscommon_getfiles/test_main.cpp +++ b/test/test_fscommon_getfiles/test_main.cpp @@ -112,6 +112,9 @@ void test_getfiles_depth_limit(void) // 4. A path that will not fit meshtastic_FileInfo::file_name is dropped, not truncated into the // manifest, and the drop is reported. +// Not built on Windows: any path long enough to overrun the 228-byte file_name also exceeds the +// 260-byte MAX_PATH, so the tree is never created and there is nothing to drop. +#ifndef _WIN32 void test_getfiles_rejects_overlong_path(void) { // file_name is 228 bytes; build a nested path that overruns it while each component stays @@ -148,6 +151,7 @@ void test_getfiles_rejects_overlong_path(void) *strrchr(dir, '/') = '\0'; } } +#endif // 5. pathEndsWithDot() - no entry in the manifest may end in '.', which is how the walk filters the // "." and ".." pseudo-entries some backends return. @@ -231,7 +235,9 @@ void setup() RUN_TEST(test_getfiles_respects_max_count); RUN_TEST(test_getfiles_unlimited_when_under_cap); RUN_TEST(test_getfiles_depth_limit); +#ifndef _WIN32 RUN_TEST(test_getfiles_rejects_overlong_path); +#endif RUN_TEST(test_getfiles_skips_dot_entries); RUN_TEST(test_getfiles_reports_sizes); RUN_TEST(test_getfiles_missing_dir_is_empty); diff --git a/test/test_geocoord_distance/test_main.cpp b/test/test_geocoord_distance/test_main.cpp index de3430f1c3..e54498a155 100644 --- a/test/test_geocoord_distance/test_main.cpp +++ b/test/test_geocoord_distance/test_main.cpp @@ -1,3 +1,8 @@ +// Deliberately does NOT include TestUtil.h. This suite is pure-function - no NodeDB, no router, no +// sockets, no PKC - so the harness-wide guards there (no listening sockets, force_simradio clear) +// would assert conditions it cannot reach, and initializeTestEnvironment()'s RTC and OSThread setup +// would add portduino globals it otherwise never touches. Suite-level state cleanliness is still +// checked from outside by bin/pio-test-isolate.sh, which wraps every suite regardless. #include "configuration.h" #include "gps/GeoCoord.h" #include diff --git a/test/test_gps_update_scheduling/test_main.cpp b/test/test_gps_update_scheduling/test_main.cpp index 00c01c0f60..72efe89043 100644 --- a/test/test_gps_update_scheduling/test_main.cpp +++ b/test/test_gps_update_scheduling/test_main.cpp @@ -1,12 +1,19 @@ #include "Arduino.h" #include "TestUtil.h" +#include "UptimeClock.h" #include "gps/GPSUpdateScheduling.h" #include #include #include -void setUp(void) {} -void tearDown(void) {} +void setUp(void) +{ + Time::setTestMillis(0); +} +void tearDown(void) +{ + Time::useRealClock(); +} // Confirms gpsHardsleepThresholdMs()'s pow()-free lookup table tracks the original // `2750 * pow(seconds, 1.22)` curve closely. @@ -74,6 +81,92 @@ static void test_clamp_boundary(void) TEST_ASSERT_EQUAL_UINT32(gpsHardsleepThresholdMs(900), gpsHardsleepThresholdMs(901)); } +// elapsedSearchMs() across the 32-bit millis() wrap. Ordering the two raw stamps, as it used to, +// reports an idle receiver as searching or a searching one as idle, and searchedTooLong() acts on it. + +// A search that has not started yet reads as idle, not as a search of length millis(). +static void test_elapsed_is_zero_before_any_search(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(90 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, s.elapsedSearchMs()); +} + +static void test_elapsed_tracks_the_clock_while_searching(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(10 * 1000); + s.informSearching(); + Time::advanceTestMillis(7 * 1000); + TEST_ASSERT_EQUAL_UINT32(7 * 1000, s.elapsedSearchMs()); +} + +static void test_elapsed_is_zero_once_the_search_ends(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(10 * 1000); + s.informSearching(); + Time::advanceTestMillis(7 * 1000); + s.informGotLock(); + Time::advanceTestMillis(60 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, s.elapsedSearchMs()); + + s.informSearching(); + Time::advanceTestMillis(3 * 1000); + s.informSearchFailed(); + TEST_ASSERT_EQUAL_UINT32(0, s.elapsedSearchMs()); +} + +// Start before the wrap, still searching after it: elapsed must be the real 10s, not ~49.7 days. +static void test_elapsed_is_exact_across_the_wrap(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(0xFFFFF000u); + s.informSearching(); + Time::advanceTestMillis(0x1000u + 6 * 1000); // 4.096s to the wrap, then 6s past it + TEST_ASSERT_EQUAL_UINT32(0x1000u + 6 * 1000, s.elapsedSearchMs()); +} + +// The regression: started before the wrap, ended after it, so searchStartedMs > searchEndedMs. +// The receiver is idle and elapsed must say so. +static void test_search_ending_after_the_wrap_reads_as_idle(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(0xFFFFF000u); + s.informSearching(); + Time::advanceTestMillis(0x1000u + 2 * 1000); + s.informGotLock(); + // The stamps really are inverted: the search ended at a smaller millis() than it started at. + TEST_ASSERT_LESS_THAN_UINT32(0xFFFFF000u, Time::getMillis()); + Time::advanceTestMillis(30 * 60 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, s.elapsedSearchMs()); +} + +// The mirror image: the previous search ended before the wrap, this one started after it, so +// searchStartedMs < searchEndedMs while a search is genuinely in progress. +static void test_search_starting_after_the_wrap_reads_as_searching(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(0xFFFFF000u); + s.informSearching(); + Time::advanceTestMillis(1000); + s.informGotLock(); + Time::advanceTestMillis(0x1000u); // over the wrap + s.informSearching(); + Time::advanceTestMillis(12 * 1000); + TEST_ASSERT_EQUAL_UINT32(12 * 1000, s.elapsedSearchMs()); +} + +static void test_reset_clears_the_search_state(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(10 * 1000); + s.informSearching(); + Time::advanceTestMillis(5 * 1000); + s.reset(); + TEST_ASSERT_EQUAL_UINT32(0, s.elapsedSearchMs()); +} + void setup() { delay(10); @@ -85,6 +178,13 @@ void setup() RUN_TEST(test_exact_at_table_breakpoints); RUN_TEST(test_clamps_above_table_range); RUN_TEST(test_clamp_boundary); + RUN_TEST(test_elapsed_is_zero_before_any_search); + RUN_TEST(test_elapsed_tracks_the_clock_while_searching); + RUN_TEST(test_elapsed_is_zero_once_the_search_ends); + RUN_TEST(test_elapsed_is_exact_across_the_wrap); + RUN_TEST(test_search_ending_after_the_wrap_reads_as_idle); + RUN_TEST(test_search_starting_after_the_wrap_reads_as_searching); + RUN_TEST(test_reset_clears_the_search_state); exit(UNITY_END()); } diff --git a/test/test_hop_start_policy/test_main.cpp b/test/test_hop_start_policy/test_main.cpp new file mode 100644 index 0000000000..84cc7a9c8e --- /dev/null +++ b/test/test_hop_start_policy/test_main.cpp @@ -0,0 +1,352 @@ +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isFromUs) +#include "TestUtil.h" +#include + +#include "configuration.h" // MESHTASTIC_PREHOP_DROP +#include "mesh/NodeDB.h" // classifyHopStart, shouldDropPacketForPreHop, HopStartStatus +#include +#include + +// TEST_MESSAGE emits file:line:INFO lines visible at -vv; printf lines appear un-prefixed. +// TEST_MSG_FMT wraps TEST_MESSAGE for formatted per-case diagnostics. +#define MSG_BUF_LEN 200 +#define TEST_MSG_FMT(fmt, ...) \ + do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + +static constexpr NodeNum kLocalNode = 0x11111111; +static constexpr NodeNum kRemoteNode = 0x22222222; + +// shouldDropPacketForPreHop -> isFromUs -> nodeDB->getNodeNum(), so a real NodeDB must be live. +static NodeDB *testNodeDB = nullptr; + +// --------------------------------------------------------------------------- +// Packet builders +// --------------------------------------------------------------------------- + +// A still-encrypted packet as Router::perhapsHandleReceived sees it (Router.cpp:1598): the +// channel-encrypted bitfield is unreadable, so the union's decoded half is untouched garbage. +static meshtastic_MeshPacket makeEncrypted(NodeNum from, uint8_t hopStart, uint8_t hopLimit) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = NODENUM_BROADCAST; + p.id = 0x1000u + (uint32_t)hopStart * 16u + hopLimit; + p.hop_start = hopStart; + p.hop_limit = hopLimit; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 16; // opaque ciphertext; contents irrelevant to hop classification + return p; +} + +// A decoded packet as Router::handleReceived sees it post-decrypt (Router.cpp:1450). +static meshtastic_MeshPacket makeDecoded(NodeNum from, uint8_t hopStart, uint8_t hopLimit, bool hasBitfield) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = NODENUM_BROADCAST; + p.id = 0x2000u + (uint32_t)hopStart * 16u + hopLimit; + p.hop_start = hopStart; + p.hop_limit = hopLimit; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.has_bitfield = hasBitfield; + p.decoded.bitfield = hasBitfield ? 1 : 0; + return p; +} + +static void assertClassify(const meshtastic_MeshPacket &p, HopStartStatus expected, const char *label) +{ + HopStartStatus got = classifyHopStart(p); + TEST_MSG_FMT("%-44s hop_start=%u hop_limit=%u -> %d (expect %d)", label, (unsigned)p.hop_start, (unsigned)p.hop_limit, + (int)got, (int)expected); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)expected, (int)got, label); +} + +// The shared predicate Router::dispatchReceived uses to set skipHandle, so gate drift fails here. +// (The cancelSending side effect stays uncovered.) +static bool routerPostDecodeWouldSkip(const meshtastic_MeshPacket &p) +{ + return shouldSkipHandleForPostDecodeHop(p); +} + +// --------------------------------------------------------------------------- +// classifyHopStart truth table +// --------------------------------------------------------------------------- + +void test_classify_invalid_when_hop_start_below_hop_limit() +{ + TEST_MESSAGE("=== hop_start < hop_limit is provably corrupt on any payload variant ==="); + + assertClassify(makeEncrypted(kRemoteNode, 2, 5), HopStartStatus::INVALID, "encrypted 2/5"); + assertClassify(makeEncrypted(kRemoteNode, 0, 1), HopStartStatus::INVALID, "encrypted 0/1"); + assertClassify(makeEncrypted(kRemoteNode, 0, 3), HopStartStatus::INVALID, "encrypted 0/3 (not UNKNOWN: limit > 0)"); + // The bitfield cannot rescue an inconsistent pair - the guard runs before the zero-hop probe. + assertClassify(makeDecoded(kRemoteNode, 2, 5, true), HopStartStatus::INVALID, "decoded+bitfield 2/5"); + assertClassify(makeDecoded(kRemoteNode, 0, 3, true), HopStartStatus::INVALID, "decoded+bitfield 0/3"); + assertClassify(makeDecoded(kRemoteNode, 0, 3, false), HopStartStatus::INVALID, "decoded no-bitfield 0/3"); +} + +void test_classify_valid_when_hop_start_covers_hop_limit() +{ + TEST_MESSAGE("=== hop_start > 0 and >= hop_limit is VALID regardless of variant or bitfield ==="); + + assertClassify(makeEncrypted(kRemoteNode, 3, 3), HopStartStatus::VALID, "encrypted 3/3 (fresh broadcast)"); + assertClassify(makeEncrypted(kRemoteNode, 3, 0), HopStartStatus::VALID, "encrypted 3/0 (fully relayed)"); + assertClassify(makeEncrypted(kRemoteNode, 5, 2), HopStartStatus::VALID, "encrypted 5/2 (mid-relay)"); + assertClassify(makeDecoded(kRemoteNode, 3, 3, false), HopStartStatus::VALID, "decoded no-bitfield 3/3"); + assertClassify(makeDecoded(kRemoteNode, 1, 0, false), HopStartStatus::VALID, "decoded no-bitfield 1/0"); +} + +void test_classify_zero_hop_modern_beacon_valid() +{ + TEST_MESSAGE("=== 0/0 decoded with bitfield = modern zero-hop broadcast, VALID ==="); + + assertClassify(makeDecoded(kRemoteNode, 0, 0, true), HopStartStatus::VALID, "decoded+bitfield 0/0 (beacon)"); +} + +void test_classify_zero_hop_decoded_without_bitfield_unknown() +{ + TEST_MESSAGE("=== 0/0 decoded without bitfield = pre-2.3.0 origin, MISSING_OR_UNKNOWN ==="); + + assertClassify(makeDecoded(kRemoteNode, 0, 0, false), HopStartStatus::MISSING_OR_UNKNOWN, "decoded no-bitfield 0/0"); +} + +void test_classify_zero_hop_encrypted_is_unknown() +{ + TEST_MESSAGE("=== 0/0 encrypted: bitfield unreadable pre-decode, MISSING_OR_UNKNOWN ==="); + + assertClassify(makeEncrypted(kRemoteNode, 0, 0), HopStartStatus::MISSING_OR_UNKNOWN, "encrypted 0/0"); +} + +void test_classify_encrypted_variant_ignores_stale_union_bitfield() +{ + TEST_MESSAGE("=== stale decoded-union bytes must not leak through the variant check ==="); + + // Adversarial struct state: payload variant says encrypted, but the union's decoded half + // still claims has_bitfield (e.g. a reused pool packet). The variant tag must gate the read. + meshtastic_MeshPacket p = makeEncrypted(kRemoteNode, 0, 0); + p.decoded.has_bitfield = true; + p.decoded.bitfield = 1; + assertClassify(p, HopStartStatus::MISSING_OR_UNKNOWN, "encrypted 0/0 w/ stale union bitfield"); +} + +void test_classify_hop_cap_boundaries() +{ + TEST_MESSAGE("=== boundaries at the 3-bit wire cap (HOP_MAX=7) and uint8 extremes ==="); + + assertClassify(makeEncrypted(kRemoteNode, 7, 7), HopStartStatus::VALID, "encrypted 7/7 (max fresh)"); + assertClassify(makeEncrypted(kRemoteNode, 7, 0), HopStartStatus::VALID, "encrypted 7/0 (max relayed out)"); + assertClassify(makeEncrypted(kRemoteNode, 6, 7), HopStartStatus::INVALID, "encrypted 6/7 (one below limit)"); + // Above the wire cap: unreachable from radio (3-bit fields) but reachable via phone input, + // where hop fields are plain uint8 in the struct. + assertClassify(makeEncrypted(kRemoteNode, 7, 8), HopStartStatus::INVALID, "encrypted 7/8 (limit past cap)"); + assertClassify(makeEncrypted(kRemoteNode, 255, 255), HopStartStatus::VALID, "encrypted 255/255"); + assertClassify(makeEncrypted(kRemoteNode, 254, 255), HopStartStatus::INVALID, "encrypted 254/255"); +} + +// --------------------------------------------------------------------------- +// Pre-decode drop policy (Router.cpp:1598 gate) and post-decode re-check +// --------------------------------------------------------------------------- + +#if MESHTASTIC_PREHOP_DROP + +void test_predecode_drops_provably_corrupt_only() +{ + TEST_MESSAGE("=== pre-decode gate drops only INVALID; VALID passes ==="); + + TEST_ASSERT_TRUE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 2, 5)), "corrupt 2/5 must drop"); + TEST_ASSERT_TRUE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 0, 3)), "corrupt 0/3 must drop"); + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 3, 3)), "valid 3/3 must pass"); + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 5, 2)), "valid 5/2 must pass"); +} + +void test_predecode_keeps_unknown_encrypted() +{ + TEST_MESSAGE("=== REGRESSION (#10758): MISSING_OR_UNKNOWN must survive the pre-decode gate ==="); + TEST_MESSAGE("Pre-fix, every non-VALID verdict dropped here - silently discarding all encrypted"); + TEST_MESSAGE("traffic whose proving bitfield was still under the channel key."); + + meshtastic_MeshPacket p = makeEncrypted(kRemoteNode, 0, 0); + TEST_ASSERT_EQUAL_INT((int)HopStartStatus::MISSING_OR_UNKNOWN, (int)classifyHopStart(p)); + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(p), "unknown-yet packet dropped before decryption"); +} + +void test_predecode_from_us_exempt() +{ + TEST_MESSAGE("=== local-origin packets are never pre-hop dropped, even when corrupt ==="); + + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kLocalNode, 2, 5)), "own node num exempt"); + // from == 0 also counts as us (isFromUs), e.g. phone-injected packets pre-numbering. + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(0, 2, 5)), "from==0 exempt"); +} + +void test_postdecode_recheck_catches_unknown() +{ + TEST_MESSAGE("=== the pre/post-decode asymmetry: UNKNOWN passes the gate, then skipHandle ==="); + + // Pre-decode the packet is opaque 0/0 -> kept; post-decode the absent bitfield proves a + // pre-hop-firmware origin -> Router.cpp:1450 sets skipHandle. This split IS the fix; a + // cleanup that collapses the two checks into one re-creates the mesh-wide drop. + meshtastic_MeshPacket preHopOrigin = makeDecoded(kRemoteNode, 0, 0, false); + TEST_ASSERT_FALSE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 0, 0))); + TEST_ASSERT_TRUE_MESSAGE(routerPostDecodeWouldSkip(preHopOrigin), "post-decode must exclude pre-hop origin"); + + meshtastic_MeshPacket modernBeacon = makeDecoded(kRemoteNode, 0, 0, true); + TEST_ASSERT_FALSE_MESSAGE(routerPostDecodeWouldSkip(modernBeacon), "modern zero-hop beacon must be handled"); + + meshtastic_MeshPacket ourOwn = makeDecoded(kLocalNode, 0, 0, false); + TEST_ASSERT_FALSE_MESSAGE(routerPostDecodeWouldSkip(ourOwn), "local-origin exempt post-decode too"); + + meshtastic_MeshPacket corrupt = makeDecoded(kRemoteNode, 2, 5, true); + TEST_ASSERT_TRUE_MESSAGE(routerPostDecodeWouldSkip(corrupt), "corrupt still excluded post-decode"); +} + +#else // !MESHTASTIC_PREHOP_DROP + +void test_prehop_disabled_never_drops() +{ + TEST_MESSAGE("=== MESHTASTIC_PREHOP_DROP=0: the gate is compiled out entirely ==="); + + TEST_ASSERT_FALSE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 2, 5))); + TEST_ASSERT_FALSE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 0, 0))); +} + +#endif // MESHTASTIC_PREHOP_DROP + +// --------------------------------------------------------------------------- +// Cross-check against getHopsAway +// --------------------------------------------------------------------------- + +void test_gethopsaway_agrees_with_classification() +{ + TEST_MESSAGE("=== getHopsAway yields a hop count iff classifyHopStart says VALID ==="); + + struct Case { + meshtastic_MeshPacket p; + const char *label; + }; + const Case cases[] = { + {makeEncrypted(kRemoteNode, 2, 5), "encrypted 2/5"}, + {makeEncrypted(kRemoteNode, 0, 3), "encrypted 0/3"}, + {makeEncrypted(kRemoteNode, 0, 0), "encrypted 0/0"}, + {makeEncrypted(kRemoteNode, 3, 3), "encrypted 3/3"}, + {makeEncrypted(kRemoteNode, 5, 2), "encrypted 5/2"}, + {makeEncrypted(kRemoteNode, 7, 0), "encrypted 7/0"}, + {makeDecoded(kRemoteNode, 0, 0, true), "decoded+bitfield 0/0"}, + {makeDecoded(kRemoteNode, 0, 0, false), "decoded no-bitfield 0/0"}, + {makeDecoded(kRemoteNode, 0, 3, true), "decoded+bitfield 0/3"}, + {makeDecoded(kRemoteNode, 4, 1, false), "decoded no-bitfield 4/1"}, + }; + + for (const Case &c : cases) { + const bool valid = classifyHopStart(c.p) == HopStartStatus::VALID; + const int8_t hops = getHopsAway(c.p, -1); + TEST_MSG_FMT("%-28s valid=%d hopsAway=%d", c.label, (int)valid, (int)hops); + if (valid) { + TEST_ASSERT_EQUAL_INT8_MESSAGE((int8_t)(c.p.hop_start - c.p.hop_limit), hops, c.label); + } else { + TEST_ASSERT_EQUAL_INT8_MESSAGE(-1, hops, c.label); + } + } +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +// Printed row and checked expectation come from one struct, so the summary cannot narrate a table +// the predicates no longer implement. Was TEST_MESSAGE-only, i.e. a case that could not fail. +void test_truth_table_summary() +{ +#if MESHTASTIC_PREHOP_DROP + constexpr bool kGate = true; +#else + constexpr bool kGate = false; +#endif + + struct Row { + meshtastic_MeshPacket p; + HopStartStatus expected; + bool preDrop; // shouldDropPacketForPreHop, gate compiled in + bool postSkip; // shouldSkipHandleForPostDecodeHop, ditto + const char *label; + }; + const Row rows[] = { + {makeDecoded(kRemoteNode, 2, 5, true), HopStartStatus::INVALID, true, true, + "hop_start0, >=limit | any variant | VALID | handled normally"}, + {makeDecoded(kRemoteNode, 0, 0, true), HopStartStatus::VALID, false, false, + "0/0 | decoded + bitfield | VALID | modern zero-hop beacon"}, + {makeDecoded(kRemoteNode, 0, 0, false), HopStartStatus::MISSING_OR_UNKNOWN, false, true, + "0/0 | decoded, no bitfield | UNKNOWN | kept pre-decode, skipHandle post-decode"}, + {makeEncrypted(kRemoteNode, 0, 0), HopStartStatus::MISSING_OR_UNKNOWN, false, true, + "0/0 | encrypted | UNKNOWN | kept pre-decode (bitfield unreadable)"}, + {makeDecoded(kLocalNode, 2, 5, true), HopStartStatus::INVALID, false, false, + "isFromUs | any | any | never dropped by pre-hop policy"}, + }; + + TEST_MESSAGE("=== classifyHopStart truth table ==="); + for (const Row &r : rows) { + TEST_MESSAGE(r.label); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)r.expected, (int)classifyHopStart(r.p), r.label); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)(kGate && r.preDrop), (int)shouldDropPacketForPreHop(r.p), r.label); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)(kGate && r.postSkip), (int)routerPostDecodeWouldSkip(r.p), r.label); + } +} + +// --------------------------------------------------------------------------- +// Unity lifecycle +// --------------------------------------------------------------------------- + +void setUp(void) +{ + if (!testNodeDB) + testNodeDB = new NodeDB(); + + config = meshtastic_LocalConfig_init_zero; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + myNodeInfo.my_node_num = kLocalNode; + nodeDB = testNodeDB; +} + +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + + UNITY_BEGIN(); + + printf("\n=== classifyHopStart truth table ===\n"); + RUN_TEST(test_classify_invalid_when_hop_start_below_hop_limit); + RUN_TEST(test_classify_valid_when_hop_start_covers_hop_limit); + RUN_TEST(test_classify_zero_hop_modern_beacon_valid); + RUN_TEST(test_classify_zero_hop_decoded_without_bitfield_unknown); + RUN_TEST(test_classify_zero_hop_encrypted_is_unknown); + RUN_TEST(test_classify_encrypted_variant_ignores_stale_union_bitfield); + RUN_TEST(test_classify_hop_cap_boundaries); + + printf("\n=== Pre-hop drop policy ===\n"); +#if MESHTASTIC_PREHOP_DROP + RUN_TEST(test_predecode_drops_provably_corrupt_only); + RUN_TEST(test_predecode_keeps_unknown_encrypted); + RUN_TEST(test_predecode_from_us_exempt); + RUN_TEST(test_postdecode_recheck_catches_unknown); +#else + RUN_TEST(test_prehop_disabled_never_drops); +#endif + + printf("\n=== Cross-checks ===\n"); + RUN_TEST(test_gethopsaway_agrees_with_classification); + + printf("\n=== Summary ===\n"); + RUN_TEST(test_truth_table_summary); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_http_content_handler/test_main.cpp b/test/test_http_content_handler/test_main.cpp index 3b628a2b21..c5b5d32a17 100644 --- a/test/test_http_content_handler/test_main.cpp +++ b/test/test_http_content_handler/test_main.cpp @@ -8,6 +8,10 @@ static void test_placeholder() } extern "C" { +// Required by Unity: PlatformIO's weak defaults do not link on MinGW (PE-COFF weak externals). +void setUp(void) {} +void tearDown(void) {} + void setup() { initializeTestEnvironment(); diff --git a/test/test_mesh_module/test_main.cpp b/test/test_mesh_module/test_main.cpp index a399880644..9cc0d18116 100644 --- a/test/test_mesh_module/test_main.cpp +++ b/test/test_mesh_module/test_main.cpp @@ -265,6 +265,7 @@ static MockMeshService *mockService; static MockRouter *mockRouter; static MockRoutingModule *mockRoutingModule; static NeighborInfoModule *realNeighborInfoModule; +static RoutingModule *realRoutingModule; static std::vector dispatchModules; template static T *registerDispatchModule(T *module) @@ -273,6 +274,14 @@ template static T *registerDispatchModule(T *module) return module; } +// Swap the mocked RoutingModule for a real one. tearDown() owns the cleanup because a failed +// assertion longjmps out of the test, which would otherwise leave it registered in MeshModule::modules. +static void installRealRoutingModule() +{ + realRoutingModule = new RoutingModule(); + routingModule = realRoutingModule; +} + static meshtastic_MeshPacket makeRequest(meshtastic_PortNum port) { meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; @@ -335,6 +344,7 @@ void setUp(void) mockRoutingModule = new MockRoutingModule(); routingModule = mockRoutingModule; + realRoutingModule = nullptr; testModule = new TestModule(); memset(&testPacket, 0, sizeof(testPacket)); @@ -355,6 +365,9 @@ void tearDown(void) delete testModule; testModule = nullptr; + delete realRoutingModule; + realRoutingModule = nullptr; + delete mockRoutingModule; mockRoutingModule = nullptr; routingModule = nullptr; @@ -606,6 +619,108 @@ static void test_localReplyToSelf_isDeliveredToPhone() TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); // nothing went toward the radio } +// handleFromRadio() is private to MeshService, which befriends RoutingModule and, under +// PIO_UNIT_TESTING, this seam. +class MeshServicePhoneDeliveryTest +{ + public: + static void deliver(const meshtastic_MeshPacket &p) { service->handleFromRadio(&p); } +}; + +static void test_handleFromRadio_remotePacketReachesPhone() +{ + meshtastic_MeshPacket rx = meshtastic_MeshPacket_init_zero; + rx.from = REMOTE_NODE; + rx.to = NODENUM_BROADCAST; + rx.id = 0x0BADF00D; + rx.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + rx.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + + MeshServicePhoneDeliveryTest::deliver(rx); + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL_UINT32(0x0BADF00D, toPhone->id); + mockService->releaseToPool(toPhone); + TEST_ASSERT_NULL(mockService->getForPhone()); +} + +// A packet we originated, coming back around, must not be echoed to the client that sent it. +static void test_handleFromRadio_ownPacketIsNotEchoedToPhone() +{ + meshtastic_MeshPacket ours = meshtastic_MeshPacket_init_zero; + ours.from = LOCAL_NODE; + ours.to = NODENUM_BROADCAST; + ours.id = 0x5E1F0001; + ours.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + ours.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + + MeshServicePhoneDeliveryTest::deliver(ours); + TEST_ASSERT_NULL(mockService->getForPhone()); + + // Same for the from==0 spelling handleToRadio stamps on phone-originated packets. + ours.from = 0; + ours.id = 0x5E1F0002; + MeshServicePhoneDeliveryTest::deliver(ours); + TEST_ASSERT_NULL(mockService->getForPhone()); +} + +// A packet from us *addressed to us* is locally-generated feedback, not an echo, and must still be +// delivered - suppressing it would silently drop every ACK/NAK the client relies on. +static void test_handleFromRadio_ownPacketAddressedToUsReachesPhone() +{ + meshtastic_MeshPacket ack = meshtastic_MeshPacket_init_zero; + ack.from = LOCAL_NODE; + ack.to = LOCAL_NODE; + ack.id = 0x5E1F0003; + ack.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + ack.decoded.portnum = meshtastic_PortNum_ROUTING_APP; + ack.decoded.request_id = 0x0C0FFEE0; + + MeshServicePhoneDeliveryTest::deliver(ack); + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL_UINT32(0x0C0FFEE0, toPhone->decoded.request_id); + mockService->releaseToPool(toPhone); + TEST_ASSERT_NULL(mockService->getForPhone()); +} + +// sendAckNak stamps from == our nodenum and to == us, and sendLocal defaults to RX_SRC_RADIO, so the +// loopback gate never applies and only handleFromRadio's filter gates the implicit ACK / NAK path. +static void test_localAckNak_reachesPhoneViaRealRoutingModule() +{ + installRealRoutingModule(); + + realRoutingModule->sendAckNak(meshtastic_Routing_Error_NONE, LOCAL_NODE, 0xFEEDBEEF, 0); + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, toPhone->decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(0xFEEDBEEF, toPhone->decoded.request_id); + TEST_ASSERT_EQUAL_UINT32(LOCAL_NODE, toPhone->to); + TEST_ASSERT_EQUAL_UINT32(LOCAL_NODE, toPhone->from); + mockService->releaseToPool(toPhone); +} + +// The mirror of the above: a broadcast we originated, heard back off the mesh, must not reach the +// phone even though it travels the same RoutingModule path. +static void test_ownBroadcastEcho_isDroppedByRealRoutingModule() +{ + installRealRoutingModule(); + + meshtastic_MeshPacket echo = meshtastic_MeshPacket_init_zero; + echo.from = LOCAL_NODE; + echo.to = NODENUM_BROADCAST; + echo.id = 0x5E1F0004; + echo.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + echo.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + + MeshModule::callModules(echo, RX_SRC_RADIO); + + TEST_ASSERT_NULL(mockService->getForPhone()); +} + // Full loop: a phone-originated want_response request (from == 0, RX_SRC_USER) dispatched // through the real router must produce a module reply that reaches the phone queue. static void test_phoneRequest_replyReachesPhone() @@ -736,6 +851,11 @@ void setup() RUN_TEST(test_dispatch_ignoreRequestIsClearedPerPacket); RUN_TEST(test_dispatch_realNeighborInfoCannotShadowTelemetryOwner); RUN_TEST(test_localReplyToSelf_isDeliveredToPhone); + RUN_TEST(test_handleFromRadio_remotePacketReachesPhone); + RUN_TEST(test_handleFromRadio_ownPacketIsNotEchoedToPhone); + RUN_TEST(test_handleFromRadio_ownPacketAddressedToUsReachesPhone); + RUN_TEST(test_localAckNak_reachesPhoneViaRealRoutingModule); + RUN_TEST(test_ownBroadcastEcho_isDroppedByRealRoutingModule); RUN_TEST(test_phoneRequest_replyReachesPhone); RUN_TEST(test_nestedLocalSend_isDeferred_notReentrant); RUN_TEST(test_deferredChain_drainsBreadthFirst); diff --git a/test/test_meshpacket_queue/test_main.cpp b/test/test_meshpacket_queue/test_main.cpp new file mode 100644 index 0000000000..37709c0046 --- /dev/null +++ b/test/test_meshpacket_queue/test_main.cpp @@ -0,0 +1,164 @@ +// Unit tests for MeshPacketQueue::replaceLowerPriorityPacket()'s late-packet branch - the one that +// evicts an overdue packet from a full queue to make room for a new arrival. +// +// tx_after is an absolute millis() deadline, so every decision here has to subtract before comparing +// or it inverts across the 32-bit wrap. The subtlety the cases below pin is that an *elapsed* time +// only orders two deadlines that have both passed: a deadline still in the future subtracts to a +// near-2^32 elapsed, which reads as the most overdue packet in the queue rather than the least. +// +// maxLen is 1 throughout. That is enough to reach the branch (any enqueue into a full queue goes +// through it) and it keeps CompareMeshPacketFunc out of the picture - std::upper_bound over an +// empty range never invokes the comparator, so the suite needs no NodeDB. + +#include "Arduino.h" +#include "TestUtil.h" +#include "UptimeClock.h" +#include "configuration.h" +#include "mesh/MeshPacketQueue.h" +#include "mesh/MeshTypes.h" +#include +#include + +namespace +{ + +// A packet that is only ever a queue occupant: id and tx_after are all the branch reads. +meshtastic_MeshPacket *makePacket(uint32_t id, uint32_t txAfter) +{ + meshtastic_MeshPacket *p = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(p); + p->id = id; + p->tx_after = txAfter; + p->priority = meshtastic_MeshPacket_Priority_DEFAULT; + return p; +} + +// Drains whatever is still queued back to the pool, so a failing case cannot starve a later one. +void drain(MeshPacketQueue &q) +{ + while (meshtastic_MeshPacket *p = q.dequeue()) + packetPool.release(p); +} + +} // namespace + +void setUp(void) +{ + Time::setTestMillis(0); +} +void tearDown(void) +{ + Time::useRealClock(); +} + +// The regression: the incoming packet is not due yet, so it must not displace an overdue one. +// `now - p->tx_after` underflows to ~49.7 days of "elapsed", which an unguarded comparison reads as +// the more urgent packet. +static void test_future_incoming_deadline_does_not_evict_an_overdue_packet(void) +{ + Time::setTestMillis(1000); + MeshPacketQueue q(1); + + meshtastic_MeshPacket *back = makePacket(0x1001, 900); // 100ms overdue + meshtastic_MeshPacket *fresh = makePacket(0x1002, 1100); // 100ms in the future + TEST_ASSERT_TRUE(q.enqueue(back)); + + TEST_ASSERT_FALSE(q.enqueue(fresh)); + TEST_ASSERT_EQUAL_HEX32(0x1001, q.getFront()->id); + + packetPool.release(fresh); + drain(q); +} + +// The ordering the branch does want: both deadlines have passed and the arrival is the more overdue +// of the two, so the queued packet gives up its slot. +static void test_more_overdue_incoming_packet_evicts_the_late_back_packet(void) +{ + Time::setTestMillis(1000); + MeshPacketQueue q(1); + + meshtastic_MeshPacket *back = makePacket(0x2001, 900); // 100ms overdue + meshtastic_MeshPacket *fresh = makePacket(0x2002, 800); // 200ms overdue + TEST_ASSERT_TRUE(q.enqueue(back)); + + TEST_ASSERT_TRUE(q.enqueue(fresh)); // back is released by the queue + TEST_ASSERT_EQUAL_HEX32(0x2002, q.getFront()->id); + + drain(q); +} + +// The other half of that ordering: a less overdue arrival leaves the queue alone. +static void test_less_overdue_incoming_packet_is_rejected(void) +{ + Time::setTestMillis(1000); + MeshPacketQueue q(1); + + meshtastic_MeshPacket *back = makePacket(0x3001, 800); // 200ms overdue + meshtastic_MeshPacket *fresh = makePacket(0x3002, 900); // 100ms overdue + TEST_ASSERT_TRUE(q.enqueue(back)); + + TEST_ASSERT_FALSE(q.enqueue(fresh)); + TEST_ASSERT_EQUAL_HEX32(0x3001, q.getFront()->id); + + packetPool.release(fresh); + drain(q); +} + +// An arrival with no TX delay at all always wins the slot from an overdue packet. +static void test_undelayed_incoming_packet_evicts_the_late_back_packet(void) +{ + Time::setTestMillis(1000); + MeshPacketQueue q(1); + + meshtastic_MeshPacket *back = makePacket(0x4001, 900); + meshtastic_MeshPacket *fresh = makePacket(0x4002, 0); // no tx_after + TEST_ASSERT_TRUE(q.enqueue(back)); + + TEST_ASSERT_TRUE(q.enqueue(fresh)); + TEST_ASSERT_EQUAL_HEX32(0x4002, q.getFront()->id); + + drain(q); +} + +// Both deadlines were set before the wrap and `now` is after it, so every raw comparison in the +// branch inverts. The decisions must come out the same as they do away from the boundary. +static void test_decisions_survive_the_millis_wrap(void) +{ + // 0xFFFFFF00 and 0xFFFFFE00 are 256ms and 512ms before the wrap; now is 256ms after it. + Time::setTestMillis(0x00000100); + MeshPacketQueue q(1); + + meshtastic_MeshPacket *back = makePacket(0x5001, 0xFFFFFF00); // 512ms overdue + meshtastic_MeshPacket *older = makePacket(0x5002, 0xFFFFFE00); // 768ms overdue + TEST_ASSERT_TRUE(q.enqueue(back)); + TEST_ASSERT_TRUE(q.enqueue(older)); + TEST_ASSERT_EQUAL_HEX32(0x5002, q.getFront()->id); + drain(q); + + // ...and a not-yet-due arrival still loses, with the deadline on the far side of the wrap. + MeshPacketQueue q2(1); + meshtastic_MeshPacket *back2 = makePacket(0x5003, 0xFFFFFF00); // 512ms overdue + meshtastic_MeshPacket *fresh = makePacket(0x5004, 0x00000300); // 512ms in the future + TEST_ASSERT_TRUE(q2.enqueue(back2)); + + TEST_ASSERT_FALSE(q2.enqueue(fresh)); + TEST_ASSERT_EQUAL_HEX32(0x5003, q2.getFront()->id); + + packetPool.release(fresh); + drain(q2); +} + +void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_future_incoming_deadline_does_not_evict_an_overdue_packet); + RUN_TEST(test_more_overdue_incoming_packet_evicts_the_late_back_packet); + RUN_TEST(test_less_overdue_incoming_packet_is_rejected); + RUN_TEST(test_undelayed_incoming_packet_evicts_the_late_back_packet); + RUN_TEST(test_decisions_survive_the_millis_wrap); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_meshpacket_serializer/test_serializer.cpp b/test/test_meshpacket_serializer/test_serializer.cpp index 82e79f8e1a..0ddb4ca0bc 100644 --- a/test/test_meshpacket_serializer/test_serializer.cpp +++ b/test/test_meshpacket_serializer/test_serializer.cpp @@ -1,3 +1,8 @@ +// Deliberately does NOT include TestUtil.h. This suite is pure-function - no NodeDB, no router, no +// sockets, no PKC - so the harness-wide guards there (no listening sockets, force_simradio clear) +// would assert conditions it cannot reach, and initializeTestEnvironment()'s RTC and OSThread setup +// would add portduino globals it otherwise never touches. Suite-level state cleanliness is still +// checked from outside by bin/pio-test-isolate.sh, which wraps every suite regardless. #include "test_helpers.h" #include #include @@ -23,6 +28,10 @@ void test_timestamp_present_when_has_rx_time(); void test_timestamp_zeroed_when_rx_time_absent(); void test_encrypted_timestamp_zeroed_when_rx_time_absent(); +// Required by Unity: PlatformIO's weak defaults do not link on MinGW (PE-COFF weak externals). +void setUp(void) {} +void tearDown(void) {} + void setup() { UNITY_BEGIN(); diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 3c4f1ab6af..64ea0cd4dd 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -17,10 +17,17 @@ #include #include +// htonl() for remoteIP() below. MinGW has no ; the byte-order helpers live in +// winsock2.h, which must precede any the Arduino shims pull in. +#ifdef _WIN32 +#include +#else #include +#endif #include #include +#include #include #include #include @@ -80,8 +87,15 @@ class MockMeshService : public MeshService class MockNodeDB : public NodeDB { public: - meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override { return &emptyNode; } + // Per-NodeNum overlay on top of the shared node, so a test can make one endpoint known + // while another stays unknown; everything else keeps the shared-node semantics. + meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override + { + auto it = nodes_.find(n); + return it != nodes_.end() ? &it->second : &emptyNode; + } meshtastic_NodeInfoLite emptyNode = {}; + std::map nodes_; }; // Minimal RoutingModule needed to return values from sendAckNak. @@ -411,8 +425,10 @@ void setUp(void) // The shared MockNodeDB node is mutated by the XEdDSA policy tests (signer bit, public // key); reset it so state can't leak between tests. - if (mockNodeDB) + if (mockNodeDB) { mockNodeDB->emptyNode = meshtastic_NodeInfoLite(); + mockNodeDB->nodes_.clear(); + } router = mockRouter = new MockRouter(); service = mockMeshService = new MockMeshService(); @@ -752,7 +768,9 @@ void test_receiveIgnoresOwnPublishedMessages(void) TEST_ASSERT_TRUE(mockRoutingModule->ackNacks_.empty()); } -// Considers receiving one of our packets an acknowledgement of it being sent. +// Considers receiving one of our packets an acknowledgement of it being sent: hearing our own +// packet back on our own gateway topic synthesizes an implicit ACK, delivered locally through +// sendLocal() -> handleReceived() -> the phone queue, marked as arriving via MQTT transport. void test_receiveAcksOwnSentMessages(void) { meshtastic_MeshPacket p = decoded; @@ -760,13 +778,26 @@ void test_receiveAcksOwnSentMessages(void) unitTest->publish(&p, nodeDB->getNodeId().c_str()); - // FIXME: Better assertion for this test - // TEST_ASSERT_TRUE(mockRouter->packets_.empty()); - // TEST_ASSERT_EQUAL(1, mockRoutingModule->ackNacks_.size()); - // const auto &[err, to, idFrom, chIndex, hopLimit] = mockRoutingModule->ackNacks_.front(); - // TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, err); - // TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, to); - // TEST_ASSERT_EQUAL(p.id, idFrom); + // The implicit ACK is delivered locally, never enqueued as MQTT downlink ingress. + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); + + meshtastic_MeshPacket *ack = mockMeshService->getForPhone(); + TEST_ASSERT_NOT_NULL(ack); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, ack->which_payload_variant); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, ack->decoded.portnum); + TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, ack->to); + TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, ack->from); + TEST_ASSERT_EQUAL(p.id, ack->decoded.request_id); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, ack->transport_mechanism); + + meshtastic_Routing routing = meshtastic_Routing_init_default; + TEST_ASSERT_TRUE( + pb_decode_from_bytes(ack->decoded.payload.bytes, ack->decoded.payload.size, &meshtastic_Routing_msg, &routing)); + TEST_ASSERT_EQUAL(meshtastic_Routing_error_reason_tag, routing.which_variant); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, routing.error_reason); + + mockMeshService->releaseToPool(ack); + TEST_ASSERT_NULL(mockMeshService->getForPhone()); // exactly one ACK } // Should ignore our own messages from MQTT that were heard by other nodes. @@ -961,6 +992,208 @@ void test_receiveIgnoresInvalidHopLimit(void) TEST_ASSERT_TRUE(mockRouter->packets_.empty()); } +// =========================================================================== +// Downlink acceptance gates - shouldDropMqttDownlink + onReceiveProto policy +// =========================================================================== + +// hop_start above HOP_MAX is rejected even when hop_limit is valid. +void test_receiveIgnoresInvalidHopStart(void) +{ + meshtastic_MeshPacket p = decoded; + p.hop_start = 10; + p.hop_limit = 3; + + unitTest->publish(&p); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// The ignore_mqtt kill-switch drops every MQTT downlink. +void test_receiveDropsWhenIgnoreMqttSet(void) +{ + config.lora.ignore_mqtt = true; + + unitTest->publish(&decoded); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A sender listed in config.lora.ignore_incoming is dropped. +void test_receiveDropsSenderInIgnoreIncomingList(void) +{ + config.lora.ignore_incoming_count = 1; + config.lora.ignore_incoming[0] = decoded.from; + + unitTest->publish(&decoded); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A non-empty ignore list only drops matching senders - presence of the list alone must not drop. +void test_receiveAcceptsSenderNotInIgnoreIncomingList(void) +{ + config.lora.ignore_incoming_count = 2; + config.lora.ignore_incoming[0] = 99; + config.lora.ignore_incoming[1] = 100; + + unitTest->publish(&decoded); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); +} + +// A sender whose NodeDB entry carries the is_ignored bit is dropped (resurrect-ignored-node guard). +void test_receiveDropsNodeDbIgnoredSender(void) +{ + mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_IS_IGNORED_MASK; + + unitTest->publish(&decoded); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A packet claiming the broadcast address as its source is dropped. +void test_receiveDropsBroadcastSource(void) +{ + meshtastic_MeshPacket p = decoded; + p.from = NODENUM_BROADCAST; + + unitTest->publish(&p); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); + TEST_ASSERT_TRUE(mockRoutingModule->ackNacks_.empty()); +} + +// A broker cannot assert PKI authentication or a transport: every accepted downlink is laundered +// to pki_encrypted=false + TRANSPORT_MQTT + via_mqtt=true. pki_encrypted grants admin-level trust +// downstream, so a regression here is remote privilege escalation. +void test_receiveLaundersPkiAndTransportFields(void) +{ + meshtastic_MeshPacket p = decoded; + p.pki_encrypted = true; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + + unitTest->publish(&p); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); + const meshtastic_MeshPacket &r = mockRouter->packets_.front(); + TEST_ASSERT_FALSE(r.pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, r.transport_mechanism); + TEST_ASSERT_TRUE(r.via_mqtt); +} + +// PKI-topic envelopes are dropped when no channel has downlink enabled, even when addressed to us. +void test_receiveDropsPkiTopicWhenNoChannelHasDownlink(void) +{ + channelFile.channels[0].settings.downlink_enabled = false; + meshtastic_MeshPacket e = encrypted; + e.to = myNodeInfo.my_node_num; + + unitTest->publish(&e, "!87654321", "PKI"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// Any single downlink-enabled channel (here only a secondary) is enough to admit PKI envelopes. +void test_receiveAcceptsPkiTopicWithOnlySecondaryDownlink(void) +{ + channelFile.channels[0].settings.downlink_enabled = false; + channelFile.channels[1] = meshtastic_Channel{ + .index = 1, + .has_settings = true, + .settings = {.name = "second", .downlink_enabled = true}, + .role = meshtastic_Channel_Role_SECONDARY, + }; + channelFile.channels_count = 2; + channels.onConfigChanged(); + meshtastic_MeshPacket e = encrypted; + e.to = myNodeInfo.my_node_num; + + unitTest->publish(&e, "!87654321", "PKI"); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); +} + +// An encrypted PKI envelope not addressed to us needs both endpoints known with user info. +void test_receiveDropsPkiNotToUsWithUnknownEndpoints(void) +{ + unitTest->publish(&encrypted, "!87654321", "PKI"); // to=2; neither endpoint has user info + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +void test_receiveAcceptsPkiNotToUsWithKnownEndpoints(void) +{ + // MockNodeDB serves the same node for every NodeNum, so this marks both endpoints known. + mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; + + unitTest->publish(&encrypted, "!87654321", "PKI"); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); + const meshtastic_MeshPacket &r = mockRouter->packets_.front(); + TEST_ASSERT_TRUE(r.via_mqtt); + TEST_ASSERT_FALSE(r.pki_encrypted); // laundered even on the PKI topic + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, r.transport_mechanism); +} + +// The endpoint gate is an AND: knowing only the sender (from=1) while the receiver (to=2) is +// unknown must still drop. Distinguishes && from || in the MQTT.cpp acceptance rule. +void test_receiveDropsPkiNotToUsWithOnlySenderKnown(void) +{ + mockNodeDB->nodes_[1].bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; // only from=1 known; to=2 stays unknown + + unitTest->publish(&encrypted, "!87654321", "PKI"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// An envelope naming a channel we do not have is dropped, even though getByName falls back to +// the primary channel - the case-sensitive global-id recheck must refuse the substitution. +void test_receiveDropsUnknownChannelName(void) +{ + unitTest->publish(&decoded, "!87654321", "nope"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// getByName matches case-insensitively, but the downlink gate compares case-sensitively; a +// mixed-case channel_id must not ride the primary channel's downlink permission. +void test_receiveDropsCaseMismatchedChannelName(void) +{ + unitTest->publish(&decoded, "!87654321", "TEST"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A validly-decoding envelope missing channel_id is rejected before any gate runs. +void test_receiveRejectsEnvelopeWithoutChannelId(void) +{ + const meshtastic_ServiceEnvelope env = {.packet = const_cast(&decoded), + .channel_id = NULL, + .gateway_id = const_cast("!87654321")}; + uint8_t bytes[256]; + const size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env); + unitTest->deliverRaw("msh/2/e/test/!87654321", bytes, numBytes); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// Every strict prefix of a valid envelope must be rejected: either the truncated decode fails, or +// it succeeds with gateway_id (the last-encoded field) missing and the NULL check refuses it. +void test_receiveRejectsTruncatedEnvelope(void) +{ + const meshtastic_ServiceEnvelope env = {.packet = const_cast(&decoded), + .channel_id = const_cast("test"), + .gateway_id = const_cast("!87654321")}; + uint8_t bytes[256]; + const size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env); + TEST_ASSERT_TRUE(numBytes > 0); + + for (size_t n = 1; n < numBytes; n++) + unitTest->deliverRaw("msh/2/e/test/!87654321", bytes, n); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + // Publishing to a text channel. void test_publishTextMessageDirect(void) { @@ -1289,6 +1522,22 @@ void setup() #endif RUN_TEST(test_receiveIgnoresUnexpectedFields); RUN_TEST(test_receiveIgnoresInvalidHopLimit); + RUN_TEST(test_receiveIgnoresInvalidHopStart); + RUN_TEST(test_receiveDropsWhenIgnoreMqttSet); + RUN_TEST(test_receiveDropsSenderInIgnoreIncomingList); + RUN_TEST(test_receiveAcceptsSenderNotInIgnoreIncomingList); + RUN_TEST(test_receiveDropsNodeDbIgnoredSender); + RUN_TEST(test_receiveDropsBroadcastSource); + RUN_TEST(test_receiveLaundersPkiAndTransportFields); + RUN_TEST(test_receiveDropsPkiTopicWhenNoChannelHasDownlink); + RUN_TEST(test_receiveAcceptsPkiTopicWithOnlySecondaryDownlink); + RUN_TEST(test_receiveDropsPkiNotToUsWithUnknownEndpoints); + RUN_TEST(test_receiveAcceptsPkiNotToUsWithKnownEndpoints); + RUN_TEST(test_receiveDropsPkiNotToUsWithOnlySenderKnown); + RUN_TEST(test_receiveDropsUnknownChannelName); + RUN_TEST(test_receiveDropsCaseMismatchedChannelName); + RUN_TEST(test_receiveRejectsEnvelopeWithoutChannelId); + RUN_TEST(test_receiveRejectsTruncatedEnvelope); RUN_TEST(test_receiveFuzzServiceEnvelope); RUN_TEST(test_publishTextMessageDirect); RUN_TEST(test_publishTextMessageWithProxy); diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index 45dfa8c4ac..c4891056cd 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -1,4 +1,4 @@ -// Unit tests for NextHop direct-message reliability mitigations (see docs/nexthop-routing-reliability.md): +// Unit tests for NextHop direct-message reliability mitigations (landed in meshtastic/firmware#10745): // M1 - NodeDB::resolveLastByte / resolveUniqueLastByte (ambiguity-aware last-byte resolution) // M2 - NextHopRouter::getNextHop strict-neighbor gate + Router::shouldDecrementHopLimit favorite check // M3 - NextHopRouter route-health freshness / failure decay @@ -106,6 +106,44 @@ class NextHopRouterTestShim : public NextHopRouter using NextHopRouter::relayOpaquePacket; using Router::shouldDecrementHopLimit; // protected in Router + PendingPacket *trackForTest(const meshtastic_MeshPacket &packet, uint8_t totalAttempts) + { + auto *copy = packetPool.allocCopy(packet); + TEST_ASSERT_NOT_NULL(copy); + return startRetransmission(copy, totalAttempts); + } + + PendingPacket *trackWithDefaultBudgetForTest(const meshtastic_MeshPacket &packet) + { + auto *copy = packetPool.allocCopy(packet); + TEST_ASSERT_NOT_NULL(copy); + return startRetransmission(copy); + } + + bool stopForTest(NodeNum from, PacketId id) { return stopRetransmission(from, id); } + + meshtastic_MeshPacket *pendingPacketForTest(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + return entry ? entry->packet : nullptr; + } + + void fireNextRetryForTest(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(entry); + entry->nextTxMsec = 0; + doRetransmissions(); + } + + void markOneRetryFiredForTest(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(entry); + TEST_ASSERT_GREATER_THAN_UINT8(0, entry->numRetransmissions); + --entry->numRetransmissions; + } + bool filterViaFlooding(const meshtastic_MeshPacket *p) { return FloodingRouter::shouldFilterReceived(p); } bool filterViaNextHop(const meshtastic_MeshPacket *p) { return NextHopRouter::shouldFilterReceived(p); } @@ -132,6 +170,7 @@ class MockRadioInterface : public RadioInterface sendCount++; lastHopLimit = p->hop_limit; lastHopStart = p->hop_start; + sentNextHops.push_back(p->next_hop); if (declineAll || p->to == NODENUM_BROADCAST_NO_LORA) return ERRNO_SHOULD_RELEASE; @@ -146,10 +185,18 @@ class MockRadioInterface : public RadioInterface return 0; } + bool cancelSending(NodeNum, PacketId) override + { + cancelCount++; + return true; + } + int sendCount = 0; + uint32_t cancelCount = 0; bool declineAll = false; uint8_t lastHopLimit = 0; uint8_t lastHopStart = 0; + std::vector sentNextHops; }; class CaptureRadioInterface : public RadioInterface @@ -221,6 +268,8 @@ class ReliableRouterTestShim : public ReliableRouter ReliableRouter::sniffReceived(p, routing); } + void implicitAckForTest(const meshtastic_MeshPacket *p) { perhapsGenerateImplicitAckForOwnOverheard(p); } + void clearPendingForTest() { while (!pending.empty()) @@ -773,6 +822,143 @@ void test_reliableAckStopsNormalPendingTransmission(void) TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); } +// A PKI DM we originated is encrypted to the recipient, so when we overhear it being rebroadcast we +// cannot decode it. The routing auth gate classifies it opaque and returns before +// shouldFilterReceived() runs, so the implicit ACK has to be reachable from the header alone - +// otherwise the client never sees "Delivered to mesh" for a DM. +void test_implicit_ack_for_opaque_own_packet(void) +{ + auto original = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 0, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + mockRoutingModule->ackNaks.clear(); + + // The overheard copy as it actually arrives: still encrypted, nothing decoded. + meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero; + overheard.from = kLocalNode; + overheard.to = kRemoteNode; + overheard.id = original.id; + overheard.channel = 0; + overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + overheard.encrypted.size = 32; + + reliableShim->implicitAckForTest(&overheard); + + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + const auto &ack = mockRoutingModule->ackNaks.front(); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, std::get<0>(ack)); + TEST_ASSERT_EQUAL_UINT32(kLocalNode, std::get<1>(ack)); // addressed to us -> reaches the phone + TEST_ASSERT_EQUAL_UINT32(original.id, std::get<2>(ack)); + + reliableShim->clearPendingForTest(); +} + +// Someone else's traffic must never mint an ACK, even with a colliding id. +void test_implicit_ack_ignores_foreign_pkt(void) +{ + auto original = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 0, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + mockRoutingModule->ackNaks.clear(); + + meshtastic_MeshPacket foreign = meshtastic_MeshPacket_init_zero; + foreign.from = kRemoteNode; + foreign.to = kLocalNode; + foreign.id = original.id; + foreign.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + foreign.encrypted.size = 32; + + reliableShim->implicitAckForTest(&foreign); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + reliableShim->clearPendingForTest(); +} + +void test_pending_does_not_cancel_radio_queue_before_first_retry(void) +{ + MockRadioInterface *mockIface = installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(0x33333333); + p.from = kLocalNode; + p.id = 0x51000001; + shim->trackForTest(p, 5); + + TEST_ASSERT_TRUE(shim->stopForTest(kLocalNode, p.id)); + TEST_ASSERT_EQUAL_UINT32(0, mockIface->cancelCount); +} + +void test_pending_cancels_radio_queue_after_first_retry_for_any_budget(void) +{ + MockRadioInterface *mockIface = installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(0x33333333); + p.from = kLocalNode; + p.id = 0x51000002; + shim->trackForTest(p, 5); + shim->markOneRetryFiredForTest(kLocalNode, p.id); + + TEST_ASSERT_TRUE(shim->stopForTest(kLocalNode, p.id)); + TEST_ASSERT_EQUAL_UINT32(1, mockIface->cancelCount); +} + +void test_directed_hop_tracks_three_total_attempts(void) +{ + installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(0x33333333); + p.id = 0x51530003; + + PendingPacket *entry = shim->trackWithDefaultBudgetForTest(p); + TEST_ASSERT_NOT_NULL(entry); + TEST_ASSERT_EQUAL_UINT8(3, entry->initialNumRetransmissions + 1); + TEST_ASSERT_TRUE(shim->stopForTest(p.from, p.id)); +} + +void test_intermediate_three_attempts_preserve_record_and_flood_last(void) +{ + MockRadioInterface *mockIface = installMockIface(); + constexpr NodeNum dest = 0x33333333; + mockNodeDB->addNode(dest, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, 0xAB); + mockNodeDB->addNode(0x000007AB, 0, true, 60); + + meshtastic_MeshPacket p = makeRebroadcastCandidate(dest); + p.id = 0x51530004; + p.next_hop = 0xAB; + PendingPacket *entry = shim->trackWithDefaultBudgetForTest(p); + TEST_ASSERT_NOT_NULL(entry); + meshtastic_MeshPacket *trackedPacket = entry->packet; + + shim->fireNextRetryForTest(p.from, p.id); + TEST_ASSERT_EQUAL_UINT32(1, mockIface->sentNextHops.size()); +#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED + TEST_ASSERT_EQUAL_HEX8(NO_NEXT_HOP_PREFERENCE, mockIface->sentNextHops[0]); +#else + TEST_ASSERT_EQUAL_HEX8(0xAB, mockIface->sentNextHops[0]); +#endif + TEST_ASSERT_EQUAL_PTR(trackedPacket, shim->pendingPacketForTest(p.from, p.id)); + + shim->fireNextRetryForTest(p.from, p.id); + TEST_ASSERT_EQUAL_UINT32(2, mockIface->sentNextHops.size()); + TEST_ASSERT_EQUAL_HEX8(NO_NEXT_HOP_PREFERENCE, mockIface->sentNextHops[1]); + TEST_ASSERT_TRUE(shim->stopForTest(p.from, p.id)); +} + +void test_early_flood_preserves_fresh_verified_route(void) +{ + MockRadioInterface *mockIface = installMockIface(); + constexpr NodeNum dest = 0x33333333; + mockNodeDB->addNode(dest, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, 0xAB); + mockNodeDB->addNode(0x000007AB, 0, true, 60); + shim->noteRouteLearned(dest, 0xAB, millis()); + + meshtastic_MeshPacket p = makeRebroadcastCandidate(dest); + p.id = 0x51530005; + p.next_hop = 0xAB; + TEST_ASSERT_NOT_NULL(shim->trackWithDefaultBudgetForTest(p)); + + shim->fireNextRetryForTest(p.from, p.id); + TEST_ASSERT_EQUAL_UINT32(1, mockIface->sentNextHops.size()); + TEST_ASSERT_EQUAL_HEX8(0xAB, mockIface->sentNextHops[0]); + TEST_ASSERT_TRUE(shim->stopForTest(p.from, p.id)); +} + +// Control: proves the NO_LORA case below turns on the `to` field alone. void test_rebroadcast_normal_broadcast_is_relayed(void) { MockRadioInterface *mockIface = installMockIface(); @@ -846,6 +1032,8 @@ void test_event_mode_hop_behavior(void) void setup() { initializeTestEnvironment(); + AirTime testAirTime; + airTime = &testAirTime; UNITY_BEGIN(); airTimeFixture = std::make_unique(); @@ -913,6 +1101,15 @@ void setup() RUN_TEST(test_eventPolicy_seededRetrySuppressesTxUntilGateOff); RUN_TEST(test_reliableAckStopsNormalPendingTransmission); + printf("\n=== pending retransmission bookkeeping ===\n"); + RUN_TEST(test_implicit_ack_for_opaque_own_packet); + RUN_TEST(test_implicit_ack_ignores_foreign_pkt); + RUN_TEST(test_pending_does_not_cancel_radio_queue_before_first_retry); + RUN_TEST(test_pending_cancels_radio_queue_after_first_retry_for_any_budget); + RUN_TEST(test_directed_hop_tracks_three_total_attempts); + RUN_TEST(test_intermediate_three_attempts_preserve_record_and_flood_last); + RUN_TEST(test_early_flood_preserves_fresh_verified_route); + printf("\n=== rebroadcast of NODENUM_BROADCAST_NO_LORA ===\n"); RUN_TEST(test_rebroadcast_normal_broadcast_is_relayed); RUN_TEST(test_rebroadcast_no_lora_broadcast_is_not_relayed); diff --git a/test/test_nodedb_blocked/test_main.cpp b/test/test_nodedb_blocked/test_main.cpp index 8b35d65340..3825ed5eed 100644 --- a/test/test_nodedb_blocked/test_main.cpp +++ b/test/test_nodedb_blocked/test_main.cpp @@ -29,6 +29,7 @@ class NodeDBTestShim : public NodeDB // Read back the role + protected category the warm tier cached for a node. bool warmMeta(NodeNum n, uint8_t &role, uint8_t &prot) { return warmStore.lookupMeta(n, role, prot); } + bool warmTake(NodeNum n, WarmNodeEntry &out) { return warmStore.take(n, out); } void clearHot() { @@ -36,8 +37,13 @@ class NodeDBTestShim : public NodeDB numMeshNodes = 0; } + // The warm tier outlives setUp() (and a prior run's warm.dat), so a test that + // asserts on a warm row has to start from an empty one. + void clearWarm() { warmStore.clear(); } + + // keySize < 32 seeds a partial key, as a truncated/short NodeInfo would leave behind. void push(NodeNum num, uint32_t lastHeard, bool favorite, bool ignored, bool withUser, bool withKey, - meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT) + meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT, pb_size_t keySize = 32) { meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero; n.num = num; @@ -50,8 +56,8 @@ class NodeDBTestShim : public NodeDB if (withUser) nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_USER_MASK, true); if (withKey) { - n.public_key.size = 32; - memset(n.public_key.bytes, static_cast(num & 0xff), 32); + n.public_key.size = keySize; + memset(n.public_key.bytes, static_cast(num & 0xff), keySize); n.public_key.bytes[0] = 0x01; // ensure non-zero (all-zero == "no key") } meshNodes->push_back(n); @@ -161,6 +167,38 @@ static void test_migration_carriesSignerBitThroughWarm(void) TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasXeddsaSigned(plainBack), "re-admission must not invent the signer bit"); } +// A warm record stores 32 raw key bytes with no length, so a partial hot-store key would be +// indistinguishable from a real one once demoted. It must land as a keyless placeholder instead. +static void test_migration_dropsShortKeyOnDemotion(void) +{ + db->clearWarm(); + db->seedSelf(); + const NodeNum shortKeyNum = 2000 + 3; + const NodeNum fullKeyNum = 2000 + 4; + const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted + // Warm entries steal the low 7 bits of last_heard for role and protected-category metadata + // (WARM_TIME_MASK), so seed multiples of 128 to keep the values representable once demoted. + for (int i = 1; i <= extra; i++) + db->push(2000 + i, /*last_heard=*/(uint32_t)i * 128, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true, + /*withKey=*/true, meshtastic_Config_DeviceConfig_Role_CLIENT, + /*keySize=*/(NodeNum)(2000 + i) == shortKeyNum ? 31 : 32); + + db->runDemote(); + + // Both left the hot store; only the full key is allowed through to the warm tier. + TEST_ASSERT_NULL(db->getMeshNode(shortKeyNum)); + TEST_ASSERT_NULL(db->getMeshNode(fullKeyNum)); + TEST_ASSERT_FALSE_MESSAGE(warmHasKey(shortKeyNum), "a 31-byte key must not be demoted as if it were a full key"); + TEST_ASSERT_TRUE_MESSAGE(warmHasKey(fullKeyNum), "a full 32-byte key still survives demotion"); + + // The short-key node is still held, just keyless, so re-admission restores its last_heard. + uint8_t role = 0xFF, prot = 0xFF; + TEST_ASSERT_TRUE_MESSAGE(db->warmMeta(shortKeyNum, role, prot), "keyless placeholder row must still be present"); + WarmNodeEntry placeholder = {}; + TEST_ASSERT_TRUE_MESSAGE(db->warmTake(shortKeyNum, placeholder), "placeholder must be readable from the warm tier"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(3u * 128, warmTimeOf(placeholder), "the keyless placeholder must carry last_heard"); +} + // Favourite handling: a favourite is never the eviction victim, even when it is // the oldest node in a full hot store. static void test_eviction_preservesFavorite(void) @@ -181,7 +219,7 @@ static void test_eviction_preservesFavorite(void) // A node heard during this boot is newer than every persisted epoch, including valid epochs after // 2038. Ranking both domains in one uint32_t incorrectly evicts the current-boot node first. -static void test_eviction_prefers_current_boot_stamp_over_post2038_epoch(void) +static void test_eviction_prefersCurrentBootStampOverPost2038Epoch(void) { constexpr NodeNum futureDated = 0x70000001; constexpr NodeNum heardThisBoot = 0x70000002; @@ -290,8 +328,9 @@ NDB_TEST_ENTRY void setup() RUN_TEST(test_migration_demotesOldestKeepsKeepersAndSelf); RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm); RUN_TEST(test_migration_carriesSignerBitThroughWarm); + RUN_TEST(test_migration_dropsShortKeyOnDemotion); RUN_TEST(test_eviction_preservesFavorite); - RUN_TEST(test_eviction_prefers_current_boot_stamp_over_post2038_epoch); + RUN_TEST(test_eviction_prefersCurrentBootStampOverPost2038Epoch); RUN_TEST(test_ignored_survivesEvictionAndCleanup); RUN_TEST(test_protectedCap_refusesBeyondLimit); RUN_TEST(test_removeNodeByNum_absentNodeOnFullDb); diff --git a/test/test_nodedb_boot_recovery/test_main.cpp b/test/test_nodedb_boot_recovery/test_main.cpp new file mode 100644 index 0000000000..0c2f537df5 --- /dev/null +++ b/test/test_nodedb_boot_recovery/test_main.cpp @@ -0,0 +1,396 @@ +// NodeDB boot-recovery contract: an undecodable config.proto must freeze identity (no keygen, no +// overwrite), an absent one takes the fresh-install path, and a corrupt nodes.proto does neither. +// The tests are a ladder (state=per-suite): arrange /prefs, then "reboot" a fresh NodeDB. +#include "MeshTypes.h" // Include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define NBR_TEST_ENTRY extern "C" +#else +#define NBR_TEST_ENTRY +#endif + +#include "FSCommon.h" // defines FSCom; must precede the feature guard below + +// The identity-freeze contract only exists where there is a filesystem and boot keygen. +#if defined(FSCom) && !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + +#include "mesh/NodeDB.h" +#include "mesh/TypeConversions.h" +#include +#include +#include +#include + +// Friend seam declared in NodeDB.h (PIO_UNIT_TESTING): read the private degraded-boot flag. +// Never instantiated - constructing one would run the real boot sequence. +class NodeDBTestShim : public NodeDB +{ + public: + static bool decodeFailed(const NodeDB *db) { return db->configDecodeFailed; } +}; + +namespace +{ + +// --- Identity baseline captured after a healthy keyed boot --- +uint32_t baseNodeNum = 0; +uint8_t basePublicKey[32]; +uint8_t basePrivateKey[32]; +char baseLongName[sizeof(meshtastic_User::long_name)]; +std::vector goodConfigBytes; // byte-exact healthy config.proto for restore tests + +// --- File helpers (through FSCom so the tests stay agnostic about the mountpoint) --- + +bool readFileBytes(const char *path, std::vector &out) +{ + out.clear(); + File f = FSCom.open(path, FILE_O_READ); + if (!f) + return false; + uint8_t buf[512]; + size_t n; + while ((n = f.read(buf, sizeof(buf))) > 0) + out.insert(out.end(), buf, buf + n); + f.close(); + return true; +} + +void writeFileBytes(const char *path, const uint8_t *data, size_t len) +{ + FSCom.remove(path); // FILE_O_WRITE is append on some backends; start clean + File f = FSCom.open(path, FILE_O_WRITE); + TEST_ASSERT_TRUE_MESSAGE(f, path); + TEST_ASSERT_EQUAL_size_t(len, f.write(data, len)); + f.close(); +} + +// FNV-1a content fingerprint; answers only "did this file change?". 0 == missing file. +uint64_t fileFingerprint(const char *path) +{ + std::vector bytes; + if (!readFileBytes(path, bytes)) + return 0; + uint64_t h = 1469598103934665603ULL; + for (uint8_t b : bytes) { + h ^= b; + h *= 1099511628211ULL; + } + return h; +} + +// A varint tag of five 0xFF bytes overflows 32 bits, so nanopb fails deterministically. +const uint8_t kGarbage[32] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// --- Reboot helper --- + +// A real boot starts with a zeroed nodeDatabase; in-process the global retains the previous +// boot's vector (the decode callback appends, it does not clear), so reset it first. +void rebootNodeDB() +{ + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + NodeDB *rebooted = new NodeDB(); + delete nodeDB; + nodeDB = rebooted; +} + +void captureIdentityBaseline() +{ + TEST_ASSERT_EQUAL(32, config.security.public_key.size); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL(32, owner.public_key.size); + baseNodeNum = myNodeInfo.my_node_num; + memcpy(basePublicKey, config.security.public_key.bytes, 32); + memcpy(basePrivateKey, config.security.private_key.bytes, 32); + strncpy(baseLongName, owner.long_name, sizeof(baseLongName)); + baseLongName[sizeof(baseLongName) - 1] = '\0'; + TEST_ASSERT_TRUE(readFileBytes(configFileName, goodConfigBytes)); + TEST_ASSERT_GREATER_THAN(1, goodConfigBytes.size()); +} + +// Persist a set region so boot keygen is unconditionally armed (generateCryptoKeyPair skips +// while region == UNSET unless the portduino sim-radio bypass applies), then reboot into the +// healthy keyed state every later test measures against. +void establishHealthyBaseline() +{ + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_CONFIG)); + rebootNodeDB(); + // Reboot once more so any boot-time coercion of the freshly saved config (preset clamp) + // has reached its fixpoint on disk before we fingerprint it as the "good" file. + rebootNodeDB(); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + captureIdentityBaseline(); +} + +void assertIdentityMatchesBaseline() +{ + TEST_ASSERT_EQUAL_UINT32(baseNodeNum, myNodeInfo.my_node_num); + TEST_ASSERT_EQUAL(32, config.security.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, config.security.public_key.bytes, 32); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePrivateKey, config.security.private_key.bytes, 32); + TEST_ASSERT_EQUAL(32, owner.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, owner.public_key.bytes, 32); +} + +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +// --- Healthy-boot identity --- + +// The #11001 renumber family: a keyed boot must mint NodeNum == crc32(public_key) once, and +// every subsequent reboot must reproduce the same NodeNum, keypair and owner identity. +static void test_firstBoot_establishesKeyedIdentity(void) +{ + TEST_MESSAGE("=== First keyed boot mints crc32(pubkey) identity ==="); + establishHealthyBaseline(); + + TEST_ASSERT_EQUAL_UINT32(crc32Buffer(config.security.public_key.bytes, 32), myNodeInfo.my_node_num); + // The minted identity is in the store of record: self entry present, carrying our key. + const meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum()); + TEST_ASSERT_NOT_NULL(self); + TEST_ASSERT_TRUE(nodeInfoLiteHasUser(self)); + TEST_ASSERT_EQUAL(32, self->public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, self->public_key.bytes, 32); + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); +} + +static void test_healthyReboot_preservesIdentity(void) +{ + TEST_MESSAGE("=== Plain reboot: identity byte-identical, config.proto not rewritten ==="); + const uint64_t fpBefore = fileFingerprint(configFileName); + TEST_ASSERT_NOT_EQUAL(0, fpBefore); + + rebootNodeDB(); + + assertIdentityMatchesBaseline(); + TEST_ASSERT_EQUAL_STRING(baseLongName, owner.long_name); + // A healthy boot has nothing to persist for config: the on-disk file is already the fixpoint. + TEST_ASSERT_EQUAL_UINT64(fpBefore, fileFingerprint(configFileName)); +} + +// --- Degraded boot: present-but-undecodable config --- + +static void test_corruptConfig_freezesIdentity_leavesFileUntouched(void) +{ + TEST_MESSAGE("=== Corrupt config.proto: frozen identity, radio silent, file untouched ==="); + writeFileBytes(configFileName, kGarbage, sizeof(kGarbage)); + const uint64_t fpGarbage = fileFingerprint(configFileName); + TEST_ASSERT_NOT_EQUAL(0, fpGarbage); + + rebootNodeDB(); + + TEST_ASSERT_TRUE(NodeDBTestShim::decodeFailed(nodeDB)); + // Radio silent until the operator restores a config. + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + TEST_ASSERT_FALSE(config.lora.tx_enabled); + // Keygen skipped: no replacement keypair minted into RAM... + TEST_ASSERT_EQUAL(0, config.security.private_key.size); + // ...and the identity carried by devicestate is untouched, so the NodeNum cannot move. + TEST_ASSERT_EQUAL_UINT32(baseNodeNum, myNodeInfo.my_node_num); + TEST_ASSERT_EQUAL(32, owner.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, owner.public_key.bytes, 32); + // The boot must not have overwritten the (maybe transiently) corrupt file with defaults. + TEST_ASSERT_EQUAL_UINT64(fpGarbage, fileFingerprint(configFileName)); +} + +// Runs against the still-degraded NodeDB from the previous test: runtime reconfiguration +// (admin set_config -> saveToDisk) must not be permanently blocked by the boot freeze. +static void test_degradedBoot_runtimeConfigSaveStillPersists(void) +{ + TEST_MESSAGE("=== Degraded boot: an explicit runtime config save still lands ==="); + TEST_ASSERT_TRUE(NodeDBTestShim::decodeFailed(nodeDB)); + const uint64_t fpGarbage = fileFingerprint(configFileName); + + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_CONFIG)); + + TEST_ASSERT_NOT_EQUAL(fpGarbage, fileFingerprint(configFileName)); + // What landed is a decodable config again (the degraded-boot defaults). + static meshtastic_LocalConfig scratch; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(configFileName, meshtastic_LocalConfig_size, + sizeof(scratch), &meshtastic_LocalConfig_msg, &scratch)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, scratch.lora.region); +} + +static void test_restoredConfig_recoversOriginalIdentity(void) +{ + TEST_MESSAGE("=== Good config bytes restored: next boot is normal with the ORIGINAL identity ==="); + writeFileBytes(configFileName, goodConfigBytes.data(), goodConfigBytes.size()); + + rebootNodeDB(); + + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + TEST_ASSERT_TRUE(config.lora.tx_enabled); + assertIdentityMatchesBaseline(); +} + +// --- Absent config: fresh install, not a freeze --- + +static void test_absentConfig_takesFreshInstallPath(void) +{ + TEST_MESSAGE("=== Absent config.proto: OTHER_FAILURE -> defaults + fresh keypair ==="); + uint8_t previousPublicKey[32]; + memcpy(previousPublicKey, basePublicKey, 32); + TEST_ASSERT_TRUE(FSCom.remove(configFileName)); + + rebootNodeDB(); + + // No usable contents to protect, so this is NOT the frozen path. + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + + // Re-arm keygen (region gate) and reboot into the replacement identity. + establishHealthyBaseline(); // re-captures the baseline for the remaining tests + + // A fresh install mints a new keypair - and with it a new NodeNum, still crc32-derived. + // (This is the flip side of the DECODE_FAILED freeze: with the file genuinely gone there + // is no identity left to preserve.) + TEST_ASSERT_EQUAL(32, config.security.public_key.size); + TEST_ASSERT_TRUE(memcmp(previousPublicKey, config.security.public_key.bytes, 32) != 0); + TEST_ASSERT_EQUAL_UINT32(crc32Buffer(config.security.public_key.bytes, 32), myNodeInfo.my_node_num); + TEST_ASSERT_TRUE(FSCom.exists(configFileName)); +} + +// --- Freeze is config-scoped --- + +static void test_corruptNodesDb_doesNotFreezeIdentity(void) +{ + TEST_MESSAGE("=== Corrupt nodes.proto alone: config loads, keygen runs, NodeNum kept ==="); + const uint64_t fpConfig = fileFingerprint(configFileName); + writeFileBytes(nodeDatabaseFileName, kGarbage, sizeof(kGarbage)); + + rebootNodeDB(); + + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + assertIdentityMatchesBaseline(); + // The store rebuilt from defaults still contains us. + const meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum()); + TEST_ASSERT_NOT_NULL(self); + TEST_ASSERT_TRUE(nodeInfoLiteHasUser(self)); + TEST_ASSERT_EQUAL_UINT64(fpConfig, fileFingerprint(configFileName)); +} + +// --- Devicestate-loss owner recovery --- + +// The recovery block in loadFromDisk() fires when device.proto decodes but is below +// DEVICESTATE_MIN_VER: identity fields survive (my_node_num is in the decoded struct), the +// defaults overwrite the owner names, and the own-node entry in nodes.proto restores them. +static void test_oldDevicestate_recoversOwnerFromNodeDb(void) +{ + TEST_MESSAGE("=== Old-version devicestate: owner names recovered from own NodeDB entry ==="); + // Put the recoverable names into the store of record... + strncpy(owner.long_name, "Recovered Owner", sizeof(owner.long_name)); + strncpy(owner.short_name, "RCVR", sizeof(owner.short_name)); + meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum()); + TEST_ASSERT_NOT_NULL(self); + TypeConversions::CopyUserToNodeInfoLite(self, owner); + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_NODEDATABASE)); + + // ...then persist a devicestate that is valid but too old, carrying DIFFERENT names, so a + // recovered name can only have come from the nodes.proto entry. + strncpy(owner.long_name, "Stale Devicestate", sizeof(owner.long_name)); + strncpy(owner.short_name, "STAL", sizeof(owner.short_name)); + devicestate.version = DEVICESTATE_MIN_VER - 1; + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_DEVICESTATE)); + + rebootNodeDB(); + + TEST_ASSERT_EQUAL_STRING("Recovered Owner", owner.long_name); + TEST_ASSERT_EQUAL_STRING("RCVR", owner.short_name); + // Identity survives the devicestate discard: the NodeNum in the old file is carried over + // and keygen re-derives the same crc32(public_key) value. + assertIdentityMatchesBaseline(); + + // The recovery is re-persisted: the on-disk devicestate is current-version with the + // recovered names, not the stale ones. + static meshtastic_DeviceState saved; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(deviceStateFileName, meshtastic_DeviceState_size, + sizeof(saved), &meshtastic_DeviceState_msg, &saved)); + TEST_ASSERT_EQUAL(DEVICESTATE_CUR_VER, saved.version); + TEST_ASSERT_EQUAL_STRING("Recovered Owner", saved.owner.long_name); +} + +// --- loadProto classification --- + +// The wipe cascade lived in the difference between these verdicts: DECODE_FAILED is the only +// protected path, and loadProto never returns NOT_FOUND (an unopenable file is OTHER_FAILURE). +static void test_loadProto_classifiesFailuresDistinctly(void) +{ + TEST_MESSAGE("=== loadProto: absent=OTHER_FAILURE, garbage/truncated=DECODE_FAILED ==="); + const char *scratchPath = "/prefs/nbr_scratch.proto"; + static meshtastic_LocalConfig scratch; + + FSCom.remove(scratchPath); + TEST_ASSERT_EQUAL(LoadFileResult::OTHER_FAILURE, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + writeFileBytes(scratchPath, kGarbage, sizeof(kGarbage)); + TEST_ASSERT_EQUAL(LoadFileResult::DECODE_FAILED, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + // A torn write: a valid encoding minus its final byte always cuts the last field short. + TEST_ASSERT_GREATER_THAN(1, goodConfigBytes.size()); + writeFileBytes(scratchPath, goodConfigBytes.data(), goodConfigBytes.size() - 1); + TEST_ASSERT_EQUAL(LoadFileResult::DECODE_FAILED, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + // The unmodified bytes still decode - the failure above was the truncation, nothing else. + writeFileBytes(scratchPath, goodConfigBytes.data(), goodConfigBytes.size()); + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + FSCom.remove(scratchPath); // leave nothing behind +} + +NBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + nodeDB = new NodeDB(); // first boot on the pristine per-suite sandbox + + UNITY_BEGIN(); + + printf("\n=== Healthy-boot identity ===\n"); + RUN_TEST(test_firstBoot_establishesKeyedIdentity); + RUN_TEST(test_healthyReboot_preservesIdentity); + + printf("\n=== Degraded boot (corrupt config) ===\n"); + RUN_TEST(test_corruptConfig_freezesIdentity_leavesFileUntouched); + RUN_TEST(test_degradedBoot_runtimeConfigSaveStillPersists); + RUN_TEST(test_restoredConfig_recoversOriginalIdentity); + + printf("\n=== Fresh install vs freeze scoping ===\n"); + RUN_TEST(test_absentConfig_takesFreshInstallPath); + RUN_TEST(test_corruptNodesDb_doesNotFreezeIdentity); + + printf("\n=== Devicestate recovery + loadProto classification ===\n"); + RUN_TEST(test_oldDevicestate_recoversOwnerFromNodeDb); + RUN_TEST(test_loadProto_classifiesFailuresDistinctly); + + exit(UNITY_END()); +} + +NBR_TEST_ENTRY void loop() {} + +#else // !FSCom || PKI excluded + +void setUp(void) {} +void tearDown(void) {} + +NBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +NBR_TEST_ENTRY void loop() {} + +#endif diff --git a/test/test_nodedb_identity_hygiene/test_main.cpp b/test/test_nodedb_identity_hygiene/test_main.cpp new file mode 100644 index 0000000000..6a2f7eaf93 --- /dev/null +++ b/test/test_nodedb_identity_hygiene/test_main.cpp @@ -0,0 +1,512 @@ +// Identity hygiene for the remote-identity commit paths in NodeDB: updateUser() key pinning and +// addFromContact() guards (a keyless contact must never erase a stored key - the #11432 regression). +#include "MeshTypes.h" // Include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define IH_TEST_ENTRY extern "C" +#else +#define IH_TEST_ENTRY +#endif + +#include "FSCommon.h" +#include "SPILock.h" +#include "mesh/NodeDB.h" +#include "support/MockMeshService.h" +#include +#include + +// Subclass shim: the friend declaration in NodeDB.h grants access to the +// private state these tests must seed/reset (duplicateWarned latch, warm-tier +// demotion). Declared at global scope so it matches `friend class NodeDBTestShim`. +class NodeDBTestShim : public NodeDB +{ + public: + void clearHot() + { + meshNodes->clear(); + numMeshNodes = 0; + } + + // keySeed == 0 means "no stored key"; otherwise a deterministic 32-byte pattern. + void push(NodeNum num, uint32_t lastHeard, uint8_t keySeed = 0, bool xeddsaSigned = false) + { + meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero; + n.num = num; + n.last_heard = lastHeard; + nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_USER_MASK, true); + if (keySeed) { + n.public_key.size = 32; + memset(n.public_key.bytes, keySeed, 32); + n.public_key.bytes[0] = 0x01; // never all-zero (all-zero == "no key") + } + if (xeddsaSigned) + nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + meshNodes->push_back(n); + numMeshNodes = meshNodes->size(); + } + + // Index 0 is our own node; eviction scans treat it as self. + void seedSelf() { push(0x0BADF00D, 0xFFFFFFFFu); } + + void resetDuplicateWarned() { duplicateWarned = false; } + +#if WARM_NODE_COUNT > 0 + void runDemote() { demoteOldestHotNodesToWarm(); } +#endif +}; + +namespace +{ + +NodeDBTestShim *db = nullptr; +MockMeshService *mockService = nullptr; + +meshtastic_User savedOwner; +meshtastic_LocalConfig savedConfig; + +constexpr NodeNum kPeer = 0xE1000001; + +// Same pattern as NodeDBTestShim::push so a "matching" user key really matches. +template void fillKey(KeyT &k, uint8_t seed) +{ + k.size = 32; + memset(k.bytes, seed, 32); + k.bytes[0] = 0x01; +} + +meshtastic_User makeUser(const char *longName, const char *shortName, uint8_t keySeed = 0) +{ + meshtastic_User u = meshtastic_User_init_zero; + strncpy(u.long_name, longName, sizeof(u.long_name) - 1); + strncpy(u.short_name, shortName, sizeof(u.short_name) - 1); + if (keySeed) + fillKey(u.public_key, keySeed); + return u; +} + +meshtastic_SharedContact makeContact(NodeNum num, const char *longName, const char *shortName, uint8_t keySeed = 0) +{ + meshtastic_SharedContact c = meshtastic_SharedContact_init_zero; + c.node_num = num; + c.has_user = true; + c.user = makeUser(longName, shortName, keySeed); + return c; +} + +void assertStoredKeyEquals(NodeNum num, uint8_t seed) +{ + const meshtastic_NodeInfoLite *info = db->getMeshNode(num); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_EQUAL(32, info->public_key.size); + uint8_t expected[32]; + memset(expected, seed, 32); + expected[0] = 0x01; + TEST_ASSERT_EQUAL_MEMORY(expected, info->public_key.bytes, 32); +} + +} // namespace + +// --- addFromContact --- + +// The #11432 regression: a stored 32-byte key plus a contact with has_user=true +// but no key must keep the stored key bit-for-bit while still merging the user +// fields (clients send add_contact before every DM, usually keyless). +static void test_contact_keyless_preserves_stored_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + db->addFromContact(makeContact(kPeer, "Alice", "AL")); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice", info->long_name); // merge still applied + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(info)); // anti-eviction stamp for normal roles +} + +// The guard blocks erasure, not update: a contact carrying a different valid +// 32-byte key replaces the stored one (the QR contact-sharing flow). +static void test_contact_new_key_updates_stored_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + db->addFromContact(makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x77)); + + assertStoredKeyEquals(kPeer, 0x77); +} + +// A manually-verified pin refuses the ENTIRE update from a non-verified contact +// whose key mismatches - name and key both stay untouched. +static void test_contact_verified_pin_blocks_mismatched_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); + + db->addFromContact(makeContact(kPeer, "Mallory", "MA", /*keySeed=*/0x77)); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("", info->long_name); // refused wholesale, not just the key + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info)); // returned before the favorite stamp + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); +} + +// The verified pin also refuses a KEYLESS non-verified contact wholesale (a +// size mismatch is a key mismatch) - unlike the plain erasure guard below, +// which merges the user fields and only restores the key. +static void test_contact_verified_pin_blocks_keyless_unverified(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); + + db->addFromContact(makeContact(kPeer, "Alice", "AL")); // keyless, not verified + + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); +} + +// A non-verified contact whose key MATCHES the verified pin may still update +// the user fields; the verified bit survives the merge. +static void test_contact_verified_pin_allows_matching_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); + + db->addFromContact(makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42)); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice", info->long_name); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); +} + +// contact.manually_verified sets the bit, and a later plain update (here via +// updateUser with the pinned key) must not clear it - CopyUserToNodeInfoLite +// only touches the user-derived bits. +static void test_contact_manually_verified_bit_survives_updates(void) +{ + meshtastic_SharedContact c = makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42); + c.manually_verified = true; + db->addFromContact(c); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(db->getMeshNode(kPeer))); + + meshtastic_User u = makeUser("Alice2", "A2", /*keySeed=*/0x42); + TEST_ASSERT_TRUE(db->updateUser(kPeer, u)); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice2", info->long_name); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); + assertStoredKeyEquals(kPeer, 0x42); +} + +// should_ignore blocks the contact and drops its satellite data but keeps the +// stored public key: an ignored peer stays a verifiable identity. +static void test_contact_should_ignore_blocks_but_keeps_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_PositionLite pos = meshtastic_PositionLite_init_zero; + pos.latitude_i = 123456789; + db->nodePositions[kPeer] = pos; + TEST_ASSERT_TRUE(db->hasNodePosition(kPeer)); +#endif + + meshtastic_SharedContact c = makeContact(kPeer, "Blocked", "BL"); // keyless on purpose + c.should_ignore = true; + db->addFromContact(c); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_TRUE(nodeInfoLiteIsIgnored(info)); + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + TEST_ASSERT_FALSE(db->hasNodePosition(kPeer)); +#endif + assertStoredKeyEquals(kPeer, 0x42); // key retained through the keyless ignore contact +} + +// CLIENT_BASE must not auto-favorite (is_favorite has special meaning there); +// the anti-eviction protection is a heard-now stamp instead. +static void test_contact_client_base_stamps_heard_not_favorite(void) +{ + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT_BASE; + + db->addFromContact(makeContact(kPeer, "Alice", "AL")); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info)); + // initializeTestEnvironment() set an NTP-quality RTC, so the stamp lands in last_heard. + TEST_ASSERT_NOT_EQUAL(0, info->last_heard); +} + +// A contact without a user payload must not merge fields or apply should_ignore to an +// existing node. (getOrCreateMeshNode still runs first, so an unknown num would be +// admitted as a blank row - that path is not covered here.) +static void test_contact_without_user_is_noop(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + const size_t countBefore = db->getNumMeshNodes(); + + meshtastic_SharedContact c = meshtastic_SharedContact_init_zero; + c.node_num = kPeer; + c.has_user = false; + c.should_ignore = true; + db->addFromContact(c); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(info)); + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_UINT(countBefore, db->getNumMeshNodes()); // existing node: no new row admitted +} + +// --- updateUser --- + +#if !(MESHTASTIC_EXCLUDE_PKI) + +// A pinned 32-byte key is immutable against a NodeInfo carrying a different key. +static void test_updateuser_pinned_key_blocks_mismatch(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + meshtastic_User u = makeUser("Mallory", "MA", /*keySeed=*/0x77); + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); // dropped wholesale +} + +// ...and against a NodeInfo carrying NO key: unlike addFromContact, updateUser +// drops a keyless update for a pinned node entirely. +static void test_updateuser_keyless_nodeinfo_dropped_wholesale(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + meshtastic_User u = makeUser("Alice", "AL"); + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); +} + +// First key for a node is accepted (TOFU) and the reach-channel is stamped. +static void test_updateuser_first_key_accepted(void) +{ + db->push(kPeer, 1000); + + meshtastic_User u = makeUser("Alice", "AL", /*keySeed=*/0x42); + TEST_ASSERT_TRUE(db->updateUser(kPeer, u, /*channelIndex=*/3)); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice", info->long_name); + TEST_ASSERT_EQUAL(3, info->channel); +} + +// A remote node advertising OUR public key is refused with exactly one +// ClientNotification; the duplicateWarned latch silences the second attempt. +static void test_updateuser_own_key_advert_notifies_once(void) +{ + fillKey(owner.public_key, 0x5A); + meshtastic_User u = makeUser("Evil twin", "ET", /*keySeed=*/0x5A); + + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + TEST_ASSERT_EQUAL(1, mockService->notificationCount); + + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + TEST_ASSERT_EQUAL(1, mockService->notificationCount); // latched +} + +// user.id is always re-derived from the node number, whatever the payload claims. +static void test_updateuser_id_derived_from_nodenum(void) +{ + meshtastic_User u = makeUser("Alice", "AL", /*keySeed=*/0x42); + strncpy(u.id, "!deadbeef", sizeof(u.id) - 1); + + TEST_ASSERT_TRUE(db->updateUser(kPeer, u)); + + char expected[16]; + snprintf(expected, sizeof(expected), "!%08x", (unsigned)kPeer); + TEST_ASSERT_EQUAL_STRING(expected, u.id); +} + +// A known XEdDSA signer's identity only changes via a signed update - even a +// same-key name change arriving unsigned is refused. +static void test_updateuser_unsigned_update_refused_for_hot_signer(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true); + meshtastic_User u = makeUser("New name", "NN", /*keySeed=*/0x42); + + TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/false)); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); + + TEST_ASSERT_TRUE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/true)); // signed control + TEST_ASSERT_EQUAL_STRING("New name", db->getMeshNode(kPeer)->long_name); +} + +// The key pin outranks the signature: a signed update still cannot rotate a +// pinned key (rotation goes through commitRemoteKey's proven paths instead). +static void test_updateuser_signed_update_cannot_rotate_pinned_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true); + + meshtastic_User u = makeUser("Rotated", "RO", /*keySeed=*/0x77); + TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/true)); + + assertStoredKeyEquals(kPeer, 0x42); +} + +#if WARM_NODE_COUNT > 0 +// The signer gate runs BEFORE getOrCreateMeshNode, so refusing an unsigned +// update for a warm-tier signer must not evict a hot node, must not re-admit +// the signer, and must not consume its warm record. +static void test_updateuser_warm_signer_refusal_does_not_evict(void) +{ + const NodeNum signerNum = 0xE2000000 + 3; + const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected demote to warm + for (int i = 1; i <= extra; i++) + db->push(0xE2000000 + i, /*lastHeard=*/i, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(signerNum), NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + + db->runDemote(); + + TEST_ASSERT_NULL(db->getMeshNode(signerNum)); // demoted out of hot + TEST_ASSERT_TRUE(db->isKnownXeddsaSigner(signerNum)); + TEST_ASSERT_TRUE(db->isFull()); + const int hotBefore = (int)db->getNumMeshNodes(); + + meshtastic_User u = makeUser("New name", "NN", /*keySeed=*/0x42); + TEST_ASSERT_FALSE(db->updateUser(signerNum, u, 0, /*xeddsaSigned=*/false)); + + TEST_ASSERT_EQUAL_INT(hotBefore, (int)db->getNumMeshNodes()); + TEST_ASSERT_NULL(db->getMeshNode(signerNum)); // not re-admitted + TEST_ASSERT_TRUE(db->isKnownXeddsaSigner(signerNum)); // warm record intact (take() never ran) + + // Signed control: the same update signed is accepted and re-admits the + // signer from warm with its key and signer bit restored. + TEST_ASSERT_TRUE(db->updateUser(signerNum, u, 0, /*xeddsaSigned=*/true)); + const meshtastic_NodeInfoLite *back = db->getMeshNode(signerNum); + TEST_ASSERT_NOT_NULL(back); + TEST_ASSERT_TRUE(nodeInfoLiteHasXeddsaSigned(back)); + TEST_ASSERT_EQUAL_STRING("New name", back->long_name); + assertStoredKeyEquals(signerNum, 0x42); +} +#endif // WARM_NODE_COUNT > 0 + +#endif // !(MESHTASTIC_EXCLUDE_PKI) + +// --- persistence --- + +// The erasure guard's outcome must survive the disk round trip: after a keyless +// add_contact against a pinned key, a rebooted NodeDB still holds the full key +// (pre-#11432 the zeroed key was persisted, breaking DMs until re-exchange). +static void test_contact_key_guard_survives_reboot(void) +{ + // saveNodeDatabaseToDisk() skips keyless devices, so give ourselves a key. + fillKey(owner.public_key, 0x5A); + + meshtastic_SharedContact keyed = makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42); + keyed.manually_verified = true; + db->addFromContact(keyed); // persists + // The keyless pre-DM contact for a verified node also carries manually_verified + // (a non-verified keyless contact would be refused by the verified pin instead). + meshtastic_SharedContact keyless = makeContact(kPeer, "Al2", "A2"); + keyless.manually_verified = true; + db->addFromContact(keyless); // keyless merge; persists the guard result + assertStoredKeyEquals(kPeer, 0x42); + + // A real cold boot starts with a zeroed nodeDatabase global; in-process the decode + // callback appends on top of the previous boot's rows, so without this reset the + // lookups below would find the pre-reboot RAM row and the persistence claim is vacuous. + delete db; + db = nullptr; + nodeDB = nullptr; + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDatabase.positions.clear(); + nodeDatabase.telemetry.clear(); + nodeDatabase.environment.clear(); + nodeDatabase.status.clear(); + db = new NodeDBTestShim(); + nodeDB = db; + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_NOT_NULL_MESSAGE(info, "contact must survive the reload"); + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("Al2", info->long_name); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); // pin survives the reboot too +} + +// --- Unity lifecycle --- + +void setUp(void) +{ + savedOwner = owner; + savedConfig = config; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + owner.public_key.size = 0; + + mockService = new MockMeshService(); + service = mockService; + + db->clearHot(); + db->seedSelf(); + db->resetDuplicateWarned(); +} + +void tearDown(void) +{ + owner = savedOwner; + config = savedConfig; + service = nullptr; + delete mockService; + mockService = nullptr; +} + +IH_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); +#ifdef FSCom + // NodeDB and MessageStore bracket their FS writes with spiLock; nothing in the + // test environment creates it, so do it here (initSPI asserts it only runs once). + if (!spiLock) + initSPI(); +#endif + db = new NodeDBTestShim(); + nodeDB = db; + + UNITY_BEGIN(); + + printf("\n=== addFromContact guards ===\n"); + RUN_TEST(test_contact_keyless_preserves_stored_key); + RUN_TEST(test_contact_new_key_updates_stored_key); + RUN_TEST(test_contact_verified_pin_blocks_mismatched_key); + RUN_TEST(test_contact_verified_pin_blocks_keyless_unverified); + RUN_TEST(test_contact_verified_pin_allows_matching_key); + RUN_TEST(test_contact_manually_verified_bit_survives_updates); + RUN_TEST(test_contact_should_ignore_blocks_but_keeps_key); + RUN_TEST(test_contact_client_base_stamps_heard_not_favorite); + RUN_TEST(test_contact_without_user_is_noop); + +#if !(MESHTASTIC_EXCLUDE_PKI) + printf("\n=== updateUser key pinning ===\n"); + RUN_TEST(test_updateuser_pinned_key_blocks_mismatch); + RUN_TEST(test_updateuser_keyless_nodeinfo_dropped_wholesale); + RUN_TEST(test_updateuser_first_key_accepted); + RUN_TEST(test_updateuser_own_key_advert_notifies_once); + RUN_TEST(test_updateuser_id_derived_from_nodenum); + RUN_TEST(test_updateuser_unsigned_update_refused_for_hot_signer); + RUN_TEST(test_updateuser_signed_update_cannot_rotate_pinned_key); +#if WARM_NODE_COUNT > 0 + RUN_TEST(test_updateuser_warm_signer_refusal_does_not_evict); +#endif +#endif + + printf("\n=== persistence ===\n"); + RUN_TEST(test_contact_key_guard_survives_reboot); + + exit(UNITY_END()); +} +IH_TEST_ENTRY void loop() {} diff --git a/test/test_nodedb_legacy_migration/test_main.cpp b/test/test_nodedb_legacy_migration/test_main.cpp new file mode 100644 index 0000000000..3f6a645f36 --- /dev/null +++ b/test/test_nodedb_legacy_migration/test_main.cpp @@ -0,0 +1,570 @@ +// The one-shot v24 -> v25 NodeDatabase migration every 2.7 -> 2.8 upgrader runs: each test +// hand-encodes a legacy /prefs/nodes.proto, cold-boots a real NodeDB, and asserts the migrated +// state (including sanitizeUtf8 of legacy names, which the later encode depends on). +#include "MeshTypes.h" // BEFORE TestUtil.h - provides MAX_NUM_NODES via mesh-pb-constants.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define NDBM_TEST_ENTRY extern "C" +#else +#define NDBM_TEST_ENTRY +#endif + +#include "FSCommon.h" + +// The migration is a file-load path; without a filesystem there is nothing to drive. +#if defined(FSCom) + +#include "mesh/NodeDB.h" +#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h" +#include "meshUtils.h" +#include +#include +#include +#include +#include +#include +#include + +// Exposes the private save path via the friend declaration in NodeDB.h, so the +// hostile-name test can prove the migrated store re-encodes cleanly. +class NodeDBTestShim : public NodeDB +{ + public: + bool saveDatabase() { return saveNodeDatabaseToDisk(); } +}; + +namespace +{ + +NodeDBTestShim *db = nullptr; + +void fillKey(meshtastic_UserLite_public_key_t &key, uint8_t seed) +{ + key.size = 32; + for (int i = 0; i < 32; i++) + key.bytes[i] = (uint8_t)(i ^ seed); + key.bytes[0] = seed; // distinctive, never all-zero +} + +meshtastic_NodeInfoLite_Legacy makeLegacyNode(uint32_t num, uint32_t lastHeard) +{ + meshtastic_NodeInfoLite_Legacy n = meshtastic_NodeInfoLite_Legacy_init_zero; + n.num = num; + n.last_heard = lastHeard; + return n; +} + +void giveLegacyUser(meshtastic_NodeInfoLite_Legacy &n, const char *longName, const char *shortName) +{ + n.has_user = true; + strncpy(n.user.long_name, longName, sizeof(n.user.long_name)); + n.user.long_name[sizeof(n.user.long_name) - 1] = '\0'; + strncpy(n.user.short_name, shortName, sizeof(n.user.short_name)); + n.user.short_name[sizeof(n.user.short_name) - 1] = '\0'; +} + +/// Encode a legacy-shape NodeDatabase - exactly what a 2.7 device leaves +/// behind for the 2.8 boot to find. +std::vector encodeLegacyNodes(uint32_t version, const std::vector &nodes) +{ + // _init_zero brace-inits the embedded std::vector via its explicit + // (size_type, allocator) ctor, so default-construct instead (see + // NodeDBLegacyMigration.cpp). + meshtastic_NodeDatabase_Legacy legacyDb{}; + legacyDb.version = version; + legacyDb.nodes = nodes; + + size_t encodedSize = 0; + TEST_ASSERT_TRUE_MESSAGE(pb_get_encoded_size(&encodedSize, meshtastic_NodeDatabase_Legacy_fields, &legacyDb), + "sizing the legacy fixture must succeed"); + std::vector buf(encodedSize); + pb_ostream_t stream = pb_ostream_from_buffer(buf.data(), buf.size()); + TEST_ASSERT_TRUE_MESSAGE(pb_encode(&stream, meshtastic_NodeDatabase_Legacy_fields, &legacyDb), + "encoding the legacy fixture must succeed"); + buf.resize(stream.bytes_written); + return buf; +} + +void writeNodesBytes(const uint8_t *bytes, size_t len) +{ + FSCom.mkdir("/prefs"); + FSCom.remove(nodeDatabaseFileName); + auto f = FSCom.open(nodeDatabaseFileName, FILE_O_WRITE); + TEST_ASSERT_TRUE((bool)f); + const size_t wrote = f.write(bytes, len); + f.close(); + TEST_ASSERT_EQUAL_MESSAGE(len, wrote, "short write laying down the nodes.proto fixture"); +} + +void writeLegacyNodesFile(uint32_t version, const std::vector &nodes) +{ + const std::vector buf = encodeLegacyNodes(version, nodes); + writeNodesBytes(buf.data(), buf.size()); +} + +/// Overwrite a unique same-length placeholder inside an encoded fixture with +/// raw bytes. PB_VALIDATE_UTF8 makes pb_encode refuse invalid UTF-8, so a +/// hostile v24 name (written by pre-validation firmware) can only be produced +/// by patching the encoded bytes - the protobuf framing stays intact because +/// the length does not change. +void patchBytes(std::vector &buf, const char *placeholder, const char *raw, size_t n) +{ + TEST_ASSERT_EQUAL(strlen(placeholder), n); + auto it = std::search(buf.begin(), buf.end(), reinterpret_cast(placeholder), + reinterpret_cast(placeholder) + n); + TEST_ASSERT_TRUE_MESSAGE(it != buf.end(), "placeholder not found in encoded fixture"); + memcpy(&*it, raw, n); +} + +/// Simulate a process restart. A real cold boot starts with a zeroed +/// nodeDatabase global; in-process it still holds the previous boot's version +/// stamp and nodes, which would short-circuit the version-gate ladder. +void coldBoot() +{ + if (db) { + delete db; + db = nullptr; + nodeDB = nullptr; + } + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDatabase.positions.clear(); + nodeDatabase.telemetry.clear(); + nodeDatabase.environment.clear(); + nodeDatabase.status.clear(); + + db = new NodeDBTestShim(); + nodeDB = db; +} + +/// The migrated-store re-save (migrationSavePending) is skipped for keyless +/// devices, so every persistence assertion depends on boot keygen having run. +void assertBootKeygenRan() +{ + TEST_ASSERT_EQUAL_MESSAGE(32, owner.public_key.size, + "boot keygen did not run - persistence legs of this suite need an owner key"); +} + +/// True UTF-8 cleanliness check via the production validator: a second +/// sanitize pass over already-sanitized bytes must find nothing to replace. +void assertValidUtf8(const char *s, size_t width) +{ + char copy[64]; + TEST_ASSERT_TRUE(width < sizeof(copy)); + memcpy(copy, s, width); + TEST_ASSERT_FALSE_MESSAGE(sanitizeUtf8(copy, width), "migrated name still contains invalid UTF-8"); +} + +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +// --- Version-gate ladder (NodeDB.cpp loadFromDisk) --- + +// v24 with no nodes is still a migration: the version stamp must advance and +// the boot must complete with just ourself in the store. +static void test_emptyV24File_migratesToEmptyV25(void) +{ + writeLegacyNodesFile(24, {}); + coldBoot(); + + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(1, (int)db->getNumMeshNodes()); // self only, added by nodeDBSelfCare +} + +// version < DEVICESTATE_MIN_VER: discarded, never migrated. +static void test_versionBelowMin_discardsToDefaults(void) +{ + auto old = makeLegacyNode(0xF6000001, 1000); + giveLegacyUser(old, "Ancient", "OLD"); + writeLegacyNodesFile(DEVICESTATE_MIN_VER - 1, {old}); + coldBoot(); + + TEST_ASSERT_NULL(db->getMeshNode(0xF6000001)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(1, (int)db->getNumMeshNodes()); +} + +// Garbage bytes: the v25 decode fails, the version stays below MIN, and the +// boot lands on installDefaultNodeDatabase instead of crashing or migrating. +static void test_garbageNodesProto_installsDefaults(void) +{ + static const uint8_t garbage[] = {0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x13, 0x37, 0xC0, 0xFF, 0xEE}; + writeNodesBytes(garbage, sizeof(garbage)); + coldBoot(); + + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(1, (int)db->getNumMeshNodes()); +} + +// --- Field-by-field migration fidelity --- + +static void test_v24RoundTrip_migratesFieldsBitfieldAndSatellites(void) +{ + std::vector nodes; + + // Node A: every scalar populated, plus position + device_metrics. + auto a = makeLegacyNode(0xA1000001, 111111); + giveLegacyUser(a, "Alice Node", "AL"); + a.user.hw_model = meshtastic_HardwareModel_TBEAM; + a.user.role = meshtastic_Config_DeviceConfig_Role_TRACKER; + fillKey(a.user.public_key, 0x42); + a.snr = 7.25f; + a.channel = 2; + a.has_hops_away = true; + a.hops_away = 3; + a.next_hop = 0xAB; + a.has_position = true; + a.position.latitude_i = 375000000; + a.position.longitude_i = -1219876543; + a.position.altitude = 123; + a.position.time = 1700000000; + a.position.location_source = meshtastic_Position_LocSource_LOC_INTERNAL; + a.position.precision_bits = 32; + a.has_device_metrics = true; + a.device_metrics.has_battery_level = true; + a.device_metrics.battery_level = 87; + a.device_metrics.has_voltage = true; + a.device_metrics.voltage = 3.7f; + nodes.push_back(a); + + // Node B: the legacy compatibility bools that must pack into the bitfield. + auto b = makeLegacyNode(0xA1000002, 222222); + giveLegacyUser(b, "Bob", "BB"); + b.via_mqtt = true; + b.is_favorite = true; + nodes.push_back(b); + + // Node C: blocked + licensed. + auto c = makeLegacyNode(0xA1000003, 333333); + giveLegacyUser(c, "Carol", "CC"); + c.is_ignored = true; + c.user.is_licensed = true; + nodes.push_back(c); + + // Node D: tri-state unmessagable present-and-set. + auto d = makeLegacyNode(0xA1000004, 444444); + giveLegacyUser(d, "Dave", "DD"); + d.user.has_is_unmessagable = true; + d.user.is_unmessagable = true; + nodes.push_back(d); + + // Node E: control - no key, no bools, no unmessagable tri-state. + auto e = makeLegacyNode(0xA1000005, 555555); + giveLegacyUser(e, "Erin", "EE"); + nodes.push_back(e); + + writeLegacyNodesFile(24, nodes); + coldBoot(); + + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(6, (int)db->getNumMeshNodes()); // 5 migrated + self + + const meshtastic_NodeInfoLite *na = db->getMeshNode(0xA1000001); + TEST_ASSERT_NOT_NULL(na); + TEST_ASSERT_EQUAL_STRING("Alice Node", na->long_name); + TEST_ASSERT_EQUAL_STRING("AL", na->short_name); + TEST_ASSERT_EQUAL(meshtastic_HardwareModel_TBEAM, na->hw_model); + TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_TRACKER, na->role); + TEST_ASSERT_EQUAL_FLOAT(7.25f, na->snr); + TEST_ASSERT_EQUAL_UINT32(111111, na->last_heard); + TEST_ASSERT_EQUAL_UINT8(2, na->channel); + TEST_ASSERT_TRUE(na->has_hops_away); + TEST_ASSERT_EQUAL_UINT8(3, na->hops_away); + TEST_ASSERT_EQUAL_UINT8(0xAB, na->next_hop); + TEST_ASSERT_TRUE(nodeInfoLiteHasUser(na)); + TEST_ASSERT_FALSE(nodeInfoLiteViaMqtt(na)); + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(na)); + TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(na)); + TEST_ASSERT_FALSE(nodeInfoLiteIsLicensed(na)); + + // Satellite routing: position and device_metrics land in the maps, not the header. +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_PositionLite pos; + TEST_ASSERT_TRUE(db->copyNodePosition(0xA1000001, pos)); + TEST_ASSERT_EQUAL_INT32(375000000, pos.latitude_i); + TEST_ASSERT_EQUAL_INT32(-1219876543, pos.longitude_i); + TEST_ASSERT_EQUAL_INT32(123, pos.altitude); + TEST_ASSERT_EQUAL_UINT32(1700000000, pos.time); + TEST_ASSERT_EQUAL(meshtastic_Position_LocSource_LOC_INTERNAL, pos.location_source); + TEST_ASSERT_EQUAL_UINT32(32, pos.precision_bits); +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + meshtastic_DeviceMetrics dm; + TEST_ASSERT_TRUE(db->copyNodeTelemetry(0xA1000001, dm)); + TEST_ASSERT_TRUE(dm.has_battery_level); + TEST_ASSERT_EQUAL_UINT32(87, dm.battery_level); + TEST_ASSERT_TRUE(dm.has_voltage); + TEST_ASSERT_EQUAL_FLOAT(3.7f, dm.voltage); +#endif + + const meshtastic_NodeInfoLite *nb = db->getMeshNode(0xA1000002); + TEST_ASSERT_NOT_NULL(nb); + TEST_ASSERT_TRUE(nodeInfoLiteViaMqtt(nb)); + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(nb)); + TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(nb)); + + const meshtastic_NodeInfoLite *nc = db->getMeshNode(0xA1000003); + TEST_ASSERT_NOT_NULL(nc); + TEST_ASSERT_TRUE(nodeInfoLiteIsIgnored(nc)); + TEST_ASSERT_TRUE(nodeInfoLiteIsLicensed(nc)); + TEST_ASSERT_FALSE(nodeInfoLiteViaMqtt(nc)); + + const meshtastic_NodeInfoLite *nd = db->getMeshNode(0xA1000004); + TEST_ASSERT_NOT_NULL(nd); + TEST_ASSERT_TRUE(nodeInfoLiteHasIsUnmessagable(nd)); + TEST_ASSERT_TRUE(nodeInfoLiteIsUnmessagable(nd)); + + const meshtastic_NodeInfoLite *ne = db->getMeshNode(0xA1000005); + TEST_ASSERT_NOT_NULL(ne); + TEST_ASSERT_FALSE(nodeInfoLiteHasIsUnmessagable(ne)); + TEST_ASSERT_FALSE(nodeInfoLiteIsUnmessagable(ne)); + TEST_ASSERT_EQUAL(0, ne->public_key.size); + + // public_key survives byte-identical, and the public lookup API finds it. + TEST_ASSERT_EQUAL(32, na->public_key.size); + meshtastic_UserLite_public_key_t expected; + fillKey(expected, 0x42); + TEST_ASSERT_EQUAL_MEMORY(expected.bytes, na->public_key.bytes, 32); + meshtastic_NodeInfoLite_public_key_t got = {0, {0}}; + TEST_ASSERT_TRUE(db->copyPublicKey(0xA1000001, got)); + TEST_ASSERT_EQUAL(32, got.size); + TEST_ASSERT_EQUAL_MEMORY(expected.bytes, got.bytes, 32); +} + +// has_position=false / has_device_metrics=false entries must not seed +// zero-position ghosts in the satellite maps. +static void test_absentSubmessages_noSatelliteGhostRows(void) +{ + auto a = makeLegacyNode(0xC3000001, 1000); + giveLegacyUser(a, "NoPos", "NP"); + auto b = makeLegacyNode(0xC3000002, 2000); + giveLegacyUser(b, "NoTel", "NT"); + writeLegacyNodesFile(24, {a, b}); + coldBoot(); + + TEST_ASSERT_NOT_NULL(db->getMeshNode(0xC3000001)); + TEST_ASSERT_NOT_NULL(db->getMeshNode(0xC3000002)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + TEST_ASSERT_FALSE(db->hasNodePosition(0xC3000001)); + TEST_ASSERT_FALSE(db->hasNodePosition(0xC3000002)); + TEST_ASSERT_TRUE(db->snapshotPositionNodeNums(0).empty()); +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + TEST_ASSERT_FALSE(db->hasNodeTelemetry(0xC3000001)); + TEST_ASSERT_TRUE(db->snapshotTelemetryNodeNums(0).empty()); +#endif +} + +// --- sanitizeUtf8 firewall (hostile v24 names) --- + +// The truncation firewall: a wide-but-VALID v24 long_name (UserLite allows 40 +// bytes) whose 25-byte slim copy cuts a multi-byte sequence in half. Without +// migration's sanitizeUtf8, the orphaned lead byte makes the next +// saveNodeDatabaseToDisk() fail its PB_VALIDATE_UTF8 encode - and a failed +// save is what triggers saveToDisk()'s fsFormat() wipe on device. +static void test_truncatedWideName_sanitizedAndReencodable(void) +{ + // 23 ASCII bytes then Euro signs straddling the 24-byte truncation boundary. + std::string straddle(23, 'a'); + straddle += "\xE2\x82\xAC\xE2\x82\xAC"; // two Euro signs, 29 bytes total - valid UTF-8 in v24 + auto s = makeLegacyNode(0xB2000002, 2000); + giveLegacyUser(s, straddle.c_str(), "OK"); + + writeLegacyNodesFile(24, {s}); + coldBoot(); + + const meshtastic_NodeInfoLite *ns = db->getMeshNode(0xB2000002); + TEST_ASSERT_NOT_NULL(ns); + std::string expected(23, 'a'); + expected += '?'; // orphaned 0xE2 lead byte after the cut, replaced by sanitizeUtf8 + TEST_ASSERT_EQUAL_STRING(expected.c_str(), ns->long_name); + assertValidUtf8(ns->long_name, sizeof(ns->long_name)); + + // The firewall itself: the migrated store must encode and re-decode. + assertBootKeygenRan(); + TEST_ASSERT_TRUE_MESSAGE(db->saveDatabase(), "sanitized store must re-encode without a nanopb failure"); + meshtastic_NodeDatabase reloaded{}; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &reloaded)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, reloaded.version); +} + +// Raw invalid UTF-8 inside a v24 name (written by pre-PB_VALIDATE_UTF8 +// firmware): nanopb refuses to decode that node and the legacy callback drops +// it, but the rest of the file must still migrate and the boot must still +// complete and re-save. One poisoned node must never cost the whole database. +static void test_rawInvalidUtf8Node_droppedWithoutBreakingMigration(void) +{ + static const char kPlaceholderLong[] = "Bad0(nameXXzzYY"; // 15 ASCII bytes, patched below + static const char kHostileLong[] = "Bad\xC3" + "(name\xFF\xFE" + "zz\xE2\x82"; // invalid leads + truncated tail, same 15 bytes + + auto h = makeLegacyNode(0xB2000001, 1000); + giveLegacyUser(h, kPlaceholderLong, "HN"); + + auto good = makeLegacyNode(0xB2000003, 3000); + giveLegacyUser(good, "Good Node", "GN"); + + std::vector buf = encodeLegacyNodes(24, {h, good}); + patchBytes(buf, kPlaceholderLong, kHostileLong, 15); + writeNodesBytes(buf.data(), buf.size()); + coldBoot(); + + // The poisoned node is gone (its num was consumed before the failing name, + // so no partial-decode fragment can carry it either)... + TEST_ASSERT_NULL(db->getMeshNode(0xB2000001)); + // ...while its well-formed sibling in the same file migrated intact. + const meshtastic_NodeInfoLite *ng = db->getMeshNode(0xB2000003); + TEST_ASSERT_NOT_NULL(ng); + TEST_ASSERT_EQUAL_STRING("Good Node", ng->long_name); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + + // And the migrated store still persists cleanly. + assertBootKeygenRan(); + TEST_ASSERT_TRUE(db->saveDatabase()); + meshtastic_NodeDatabase reloaded{}; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &reloaded)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, reloaded.version); +} + +// --- Capacity --- + +// A legacy file from a larger-cap build migrates at most MAX_NUM_NODES entries +// in file order; no OOB under ASan (the getOrCreate boot-loop family guard). +static void test_overCapLegacyFile_truncatesToMaxNumNodes(void) +{ + const int maxNodes = MAX_NUM_NODES; + const int extra = 20; + std::vector nodes; + nodes.reserve(maxNodes + extra); + for (int i = 0; i < maxNodes + extra; i++) { + auto n = makeLegacyNode(0xE5000000u + i, (uint32_t)(i + 1)); // ascending: index 0 is oldest + char ln[16], sn[5]; + snprintf(ln, sizeof(ln), "n%d", i); + snprintf(sn, sizeof(sn), "%02d", i % 100); + giveLegacyUser(n, ln, sn); // users required: keyless/userless entries are purged by cleanupMeshDB + nodes.push_back(n); + } + writeLegacyNodesFile(24, nodes); + coldBoot(); + + // Exactly the hot cap: file entries 0..max-1 migrated, the tail dropped, + // then nodeDBSelfCare evicted one old migrated node to admit self. Which + // of the oldest is the victim is an eviction-policy detail; only the + // counts and the cap boundary are contract here. + TEST_ASSERT_EQUAL_INT(maxNodes, (int)db->getNumMeshNodes()); + TEST_ASSERT_NULL(db->getMeshNode(0xE5000000u + maxNodes)); // first beyond the cap: dropped + TEST_ASSERT_NULL(db->getMeshNode(0xE5000000u + maxNodes + extra - 1)); // last beyond the cap: dropped + TEST_ASSERT_NOT_NULL(db->getMeshNode(0xE5000000u + maxNodes - 1)); // last within the cap: kept + TEST_ASSERT_NOT_NULL(db->getMeshNode(db->getNodeNum())); // self admitted + int survivors = 0; + for (int i = 0; i < maxNodes; i++) { + if (db->getMeshNode(0xE5000000u + i)) + survivors++; + } + TEST_ASSERT_EQUAL_INT_MESSAGE(maxNodes - 1, survivors, "exactly one within-cap node should have been evicted for self"); +} + +// --- Full boot ladder persistence --- + +// The deferred migrationSavePending re-save must land: after the boot, +// the on-disk nodes.proto is v25 with the migrated node, key, and satellite. +static void test_fullBootLadder_persistsMigratedV25(void) +{ + auto a = makeLegacyNode(0xD4000001, 4000); + giveLegacyUser(a, "Persist Me", "PM"); + fillKey(a.user.public_key, 0x77); + a.has_position = true; + a.position.latitude_i = 101010101; + a.position.longitude_i = -202020202; + writeLegacyNodesFile(24, {a}); + coldBoot(); + + assertBootKeygenRan(); + + meshtastic_NodeDatabase reloaded{}; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &reloaded)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, reloaded.version); + + const meshtastic_NodeInfoLite *persisted = nullptr; + for (const auto &n : reloaded.nodes) { + if (n.num == 0xD4000001) + persisted = &n; + } + TEST_ASSERT_NOT_NULL_MESSAGE(persisted, "migrated node must survive the v25 re-save"); + TEST_ASSERT_EQUAL_STRING("Persist Me", persisted->long_name); + TEST_ASSERT_TRUE(persisted->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK); + TEST_ASSERT_EQUAL(32, persisted->public_key.size); + meshtastic_UserLite_public_key_t expected; + fillKey(expected, 0x77); + TEST_ASSERT_EQUAL_MEMORY(expected.bytes, persisted->public_key.bytes, 32); + +#if !MESHTASTIC_EXCLUDE_POSITIONDB + // With the decode targets disarmed (steady state), satellite entries land in + // the struct's own vectors - so this asserts the on-disk projection directly. + bool posFound = false; + for (const auto &e : reloaded.positions) { + if (e.num == 0xD4000001 && e.has_position) { + posFound = true; + TEST_ASSERT_EQUAL_INT32(101010101, e.position.latitude_i); + TEST_ASSERT_EQUAL_INT32(-202020202, e.position.longitude_i); + } + } + TEST_ASSERT_TRUE_MESSAGE(posFound, "satellite position must survive the v25 re-save"); +#endif +} + +NDBM_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + // First boot on the empty sandbox: installs defaults, runs keygen, and + // persists the base config files every later cold boot reloads. + coldBoot(); + + UNITY_BEGIN(); + + printf("\n=== Version-gate ladder ===\n"); + RUN_TEST(test_emptyV24File_migratesToEmptyV25); + RUN_TEST(test_versionBelowMin_discardsToDefaults); + RUN_TEST(test_garbageNodesProto_installsDefaults); + + printf("\n=== Migration fidelity ===\n"); + RUN_TEST(test_v24RoundTrip_migratesFieldsBitfieldAndSatellites); + RUN_TEST(test_absentSubmessages_noSatelliteGhostRows); + + printf("\n=== sanitizeUtf8 firewall ===\n"); + RUN_TEST(test_truncatedWideName_sanitizedAndReencodable); + RUN_TEST(test_rawInvalidUtf8Node_droppedWithoutBreakingMigration); + + printf("\n=== Capacity and persistence ===\n"); + RUN_TEST(test_overCapLegacyFile_truncatesToMaxNumNodes); + RUN_TEST(test_fullBootLadder_persistsMigratedV25); + + exit(UNITY_END()); +} +NDBM_TEST_ENTRY void loop() {} + +#else // !FSCom - no filesystem, nothing to migrate + +void setUp(void) {} +void tearDown(void) {} + +NDBM_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} +NDBM_TEST_ENTRY void loop() {} + +#endif diff --git a/test/test_nodedb_v25_roundtrip/test_main.cpp b/test/test_nodedb_v25_roundtrip/test_main.cpp new file mode 100644 index 0000000000..93b15a9551 --- /dev/null +++ b/test/test_nodedb_v25_roundtrip/test_main.cpp @@ -0,0 +1,691 @@ +// Round-trip fidelity of the v25 slim NodeDB persistence cycle: snr_q4 quantization and its +// HAS_SNR sentinel, satellite-map projection/rehydration and eviction, the keyless-device write +// skip, and resetNodes() compaction. Each test saves, cold-boots a real NodeDB, and reads back. +#include "MeshTypes.h" // BEFORE TestUtil.h - provides MAX_SATELLITE_NODES via mesh-pb-constants.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define NDBR_TEST_ENTRY extern "C" +#else +#define NDBR_TEST_ENTRY +#endif + +#include "FSCommon.h" + +// This is a disk round-trip suite; without a filesystem there is nothing to pin. +#if defined(FSCom) + +#include "mesh/NodeDB.h" +#include +#include +#include +#include + +// Friend declared in NodeDB.h (PIO_UNIT_TESTING): exposes the private save path so +// the tests drive exactly the gate under test, without saveToDisk()'s format-retry. +class NodeDBTestShim : public NodeDB +{ + public: + bool saveDatabase() { return saveNodeDatabaseToDisk(); } +}; + +namespace +{ + +NodeDBTestShim *db = nullptr; + +/// Simulate a process restart. A real cold boot starts with a zeroed nodeDatabase +/// global; in-process the decode callback would append on top of the previous +/// boot's rows, duplicating every node. +void coldBoot() +{ + if (db) { + delete db; + db = nullptr; + nodeDB = nullptr; + } + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDatabase.positions.clear(); + nodeDatabase.telemetry.clear(); + nodeDatabase.environment.clear(); + nodeDatabase.status.clear(); + + db = new NodeDBTestShim(); + nodeDB = db; +} + +meshtastic_User makeUser(uint32_t num, uint8_t seed) +{ + meshtastic_User u = meshtastic_User_init_zero; + snprintf(u.id, sizeof(u.id), "!%08x", num); + snprintf(u.long_name, sizeof(u.long_name), "Node %02X", seed); + snprintf(u.short_name, sizeof(u.short_name), "N%02X", seed); + u.hw_model = meshtastic_HardwareModel_TBEAM; + u.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + u.public_key.size = 32; + for (int i = 0; i < 32; i++) + u.public_key.bytes[i] = (uint8_t)(i ^ seed ^ 0x5A); + return u; +} + +/// Give the node a user so it survives the next boot's cleanupMeshDB() purge - +/// userless, non-ignored rows are dropped on load, which is itself part of the cycle. +meshtastic_NodeInfoLite *addUserNode(uint32_t num, uint8_t seed, uint8_t channelIndex = 0) +{ + meshtastic_User u = makeUser(num, seed); + nodeDB->updateUser(num, u, channelIndex); + meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(num); + TEST_ASSERT_NOT_NULL_MESSAGE(info, "updateUser must admit the node"); + return info; +} + +/// A packet as the real over-the-air RX path shapes it: decoded, TRANSPORT_LORA, +/// modern-sender bitfield, rx_time and rx_rssi present. +meshtastic_MeshPacket makeRxPacket(uint32_t from) +{ + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = from; + mp.to = nodeDB->getNodeNum(); + mp.id = 0x1000u + (from & 0xFFFu); + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.has_bitfield = true; // modern sender: hop_start is trustworthy + mp.has_rx_time = true; + mp.rx_time = 1700000000; + mp.hop_start = 3; + mp.hop_limit = 3; + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + mp.has_rx_rssi = true; + mp.rx_rssi = -80; + return mp; +} + +void heardOverLoRa(uint32_t from, float snr) +{ + meshtastic_MeshPacket mp = makeRxPacket(from); + mp.rx_snr = snr; + nodeDB->updateFrom(mp); +} + +meshtastic_StatusMessage makeStatus(const char *text) +{ + meshtastic_StatusMessage st = meshtastic_StatusMessage_init_zero; + snprintf(st.status, sizeof(st.status), "%s", text); + return st; +} + +bool readFileBytes(const char *path, std::vector &out) +{ + auto f = FSCom.open(path, FILE_O_READ); + if (!f) + return false; + out.resize(f.size()); + if (!out.empty() && f.read(out.data(), out.size()) != out.size()) { + f.close(); + return false; + } + f.close(); + return true; +} + +void decodeNodesFile(meshtastic_NodeDatabase &out) +{ + // _init_zero brace-inits the embedded std::vector via its (size_type) ctor, + // so callers pass a default-constructed struct; decode targets are disarmed in + // steady state, so satellite entries land in the struct's own vectors - this + // reads the on-disk projection directly. + TEST_ASSERT_EQUAL_MESSAGE(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &out), + "nodes.proto must decode"); +} + +void assertTempVectorsEmpty(const char *when) +{ + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.positions.empty(), when); + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.telemetry.empty(), when); + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.environment.empty(), when); + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.status.empty(), when); +} + +void clearAllSatellites() +{ + auto wipe = [](const std::vector &nums) { + for (NodeNum n : nums) + nodeDB->eraseNodeSatellites(n); + }; + wipe(nodeDB->snapshotPositionNodeNums(0)); + wipe(nodeDB->snapshotTelemetryNodeNums(0)); + wipe(nodeDB->snapshotEnvironmentNodeNums(0)); + wipe(nodeDB->snapshotStatusNodeNums(0)); +} + +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +// --- Environment preconditions --- + +// Every persistence leg depends on boot keygen having produced an owner key +// (keyless devices deliberately skip the nodes.proto write - tested below). +static void test_identityReady_saveUnlocked(void) +{ + TEST_ASSERT_EQUAL_MESSAGE(32, owner.public_key.size, "boot keygen did not run - this suite needs an owner key"); + TEST_ASSERT_NOT_NULL(db->getMeshNode(db->getNodeNum())); +} + +// --- updateFrom SNR admission gates (in-RAM policy feeding the persisted bit) --- + +static void test_updateFrom_snrTransportGates(void) +{ + const uint32_t A = 0x52000001, B = 0x52000002, C = 0x52000003; + + // Genuine RF reception of a 0 dB packet: stored, and HAS_SNR says so. + heardOverLoRa(A, 0.0f); + const meshtastic_NodeInfoLite *na = db->getMeshNode(A); + TEST_ASSERT_NOT_NULL(na); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(na), "a measured 0 dB must be recorded as known"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, na->snr); + + // Broker-delivered MQTT packet: rx_snr is not our measurement, never recorded. + meshtastic_MeshPacket mp = makeRxPacket(B); + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + mp.via_mqtt = true; + mp.rx_snr = 7.5f; + nodeDB->updateFrom(mp); + const meshtastic_NodeInfoLite *nb = db->getMeshNode(B); + TEST_ASSERT_NOT_NULL(nb); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(nb), "MQTT-transport SNR must not be recorded"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, nb->snr); + TEST_ASSERT_TRUE(nodeInfoLiteViaMqtt(nb)); + + // TRANSPORT_LORA without has_rx_rssi (the PhoneAPI-replay shape): not recorded. + mp = makeRxPacket(C); + mp.has_rx_rssi = false; + mp.rx_rssi = 0; + mp.rx_snr = 6.0f; + nodeDB->updateFrom(mp); + const meshtastic_NodeInfoLite *nc = db->getMeshNode(C); + TEST_ASSERT_NOT_NULL(nc); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(nc), "replay-shaped packets must not mint a measurement"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, nc->snr); + + // An MQTT-origin packet a gateway rebroadcast onto LoRa: we measured that one. + mp = makeRxPacket(B); + mp.via_mqtt = true; + mp.rx_snr = -3.5f; + nodeDB->updateFrom(mp); + nb = db->getMeshNode(B); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(nb)); + TEST_ASSERT_EQUAL_FLOAT(-3.5f, nb->snr); +} + +// --- snr_q4 quantization + HAS_SNR sentinel through a real save/boot cycle --- + +static void test_snrQuantization_roundTripsThroughDisk(void) +{ + const uint32_t N1 = 0x53000001; // |SNR| < 0.25 dB: rounds to -1, not truncated to the sentinel + const uint32_t N2 = 0x53000002; // measured 0.0 dB: the #11271 sentinel collision + const uint32_t N3 = 0x53000003; // rounds TO 0 yet stays a known measurement + const uint32_t N4 = 0x53000004; // legacy record: snr set, HAS_SNR clear (compat branch) + const uint32_t N5 = 0x53000005; // never measured + const uint32_t N6 = 0x53000006; // plain quantization: 7.9 -> 32/4 = 8.0 + + addUserNode(N1, 0x01); + heardOverLoRa(N1, -0.2f); + addUserNode(N2, 0x02); + heardOverLoRa(N2, 0.0f); + addUserNode(N3, 0x03); + heardOverLoRa(N3, 0.1f); + meshtastic_NodeInfoLite *legacy = addUserNode(N4, 0x04); + legacy->snr = 3.0f; // pre-HAS_SNR store shape: value present, bit clear + addUserNode(N5, 0x05); + addUserNode(N6, 0x06); + heardOverLoRa(N6, 7.9f); + + TEST_ASSERT_TRUE(db->saveDatabase()); + coldBoot(); + + const meshtastic_NodeInfoLite *n = db->getMeshNode(N1); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(n)); + TEST_ASSERT_EQUAL_FLOAT_MESSAGE(-0.25f, n->snr, "lroundf(-0.8) = -1 -> -0.25 dB (rounding, not truncation)"); + + n = db->getMeshNode(N2); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(n), "a genuine 0 dB reading must come back as known, not unknown"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); + + n = db->getMeshNode(N3); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(n), "a measurement that quantizes to 0 is still a measurement"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); + + n = db->getMeshNode(N4); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_FALSE(nodeInfoLiteHasSnr(n)); + TEST_ASSERT_EQUAL_FLOAT_MESSAGE(3.0f, n->snr, "legacy snr_q4 without the bit must decode via the compat branch"); + + n = db->getMeshNode(N5); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(n), "snr_q4 = 0 with the bit clear is unambiguously unknown"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); + + n = db->getMeshNode(N6); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(n)); + TEST_ASSERT_EQUAL_FLOAT(8.0f, n->snr); +} + +// --- Full header + satellite-map projection/rehydration cycle --- + +static void test_fullRoundTrip_headerAndSatelliteFidelity(void) +{ + const uint32_t P = 0x54000001; // position + const uint32_t T = 0x54000002; // device telemetry + const uint32_t E = 0x54000003; // environment + status + const uint32_t M = 0x54000004; // bitfield bools + hops + + addUserNode(P, 0x11, /*channelIndex=*/2); + heardOverLoRa(P, 5.5f); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.latitude_i = 375000000; + pos.longitude_i = -1219876543; + pos.altitude = 123; + pos.time = 1700000200; + pos.location_source = meshtastic_Position_LocSource_LOC_INTERNAL; + pos.precision_bits = 32; + nodeDB->updatePosition(P, pos); +#endif + + addUserNode(T, 0x12); +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + meshtastic_Telemetry tel = meshtastic_Telemetry_init_zero; + tel.which_variant = meshtastic_Telemetry_device_metrics_tag; + tel.variant.device_metrics.has_battery_level = true; + tel.variant.device_metrics.battery_level = 87; + tel.variant.device_metrics.has_voltage = true; + tel.variant.device_metrics.voltage = 3.7f; + tel.variant.device_metrics.has_channel_utilization = true; + tel.variant.device_metrics.channel_utilization = 12.5f; + tel.variant.device_metrics.has_air_util_tx = true; + tel.variant.device_metrics.air_util_tx = 1.5f; + tel.variant.device_metrics.has_uptime_seconds = true; + tel.variant.device_metrics.uptime_seconds = 3600; + nodeDB->updateTelemetry(T, tel); +#endif + + addUserNode(E, 0x13); +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + meshtastic_Telemetry env = meshtastic_Telemetry_init_zero; + env.which_variant = meshtastic_Telemetry_environment_metrics_tag; + env.variant.environment_metrics.has_temperature = true; + env.variant.environment_metrics.temperature = 21.5f; + env.variant.environment_metrics.has_relative_humidity = true; + env.variant.environment_metrics.relative_humidity = 40.5f; + env.variant.environment_metrics.has_barometric_pressure = true; + env.variant.environment_metrics.barometric_pressure = 1013.25f; + nodeDB->updateTelemetry(E, env); +#endif +#if !MESHTASTIC_EXCLUDE_STATUSDB + nodeDB->setNodeStatus(E, makeStatus("on the tower")); +#endif + + meshtastic_NodeInfoLite *m = addUserNode(M, 0x14); + meshtastic_MeshPacket mp = makeRxPacket(M); + mp.via_mqtt = true; // gateway rebroadcast: bit stored, SNR still ours + mp.hop_start = 5; + mp.hop_limit = 2; // hops_away = 3 + mp.rx_snr = 2.0f; + nodeDB->updateFrom(mp); + m = db->getMeshNode(M); + nodeInfoLiteSetBit(m, NODEINFO_BITFIELD_IS_MUTED_MASK, true); + + TEST_ASSERT_TRUE(db->saveDatabase()); + assertTempVectorsEmpty("temp vectors must be cleared after the save projection"); + + coldBoot(); + assertTempVectorsEmpty("armed decode must route entries into the maps, not the temp vectors"); + + // Header fidelity + const meshtastic_NodeInfoLite *np = db->getMeshNode(P); + TEST_ASSERT_NOT_NULL(np); + TEST_ASSERT_EQUAL_STRING("Node 11", np->long_name); + TEST_ASSERT_EQUAL_STRING("N11", np->short_name); + TEST_ASSERT_EQUAL(meshtastic_HardwareModel_TBEAM, np->hw_model); + TEST_ASSERT_EQUAL_UINT8(2, np->channel); + TEST_ASSERT_EQUAL_UINT32(1700000000, np->last_heard); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(np)); + TEST_ASSERT_EQUAL_FLOAT(5.5f, np->snr); + meshtastic_User expected = makeUser(P, 0x11); + TEST_ASSERT_EQUAL(32, np->public_key.size); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(expected.public_key.bytes, np->public_key.bytes, 32, + "public key must survive byte-identical"); + + const meshtastic_NodeInfoLite *nm = db->getMeshNode(M); + TEST_ASSERT_NOT_NULL(nm); + TEST_ASSERT_TRUE(nodeInfoLiteViaMqtt(nm)); + TEST_ASSERT_TRUE(nodeInfoLiteIsMuted(nm)); + TEST_ASSERT_TRUE(nm->has_hops_away); + TEST_ASSERT_EQUAL_UINT8(3, nm->hops_away); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(nm)); + TEST_ASSERT_EQUAL_FLOAT(2.0f, nm->snr); + + // Satellite rehydration - identical values, and only where they were written. +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_PositionLite gotPos; + TEST_ASSERT_TRUE(db->copyNodePosition(P, gotPos)); + TEST_ASSERT_EQUAL_INT32(375000000, gotPos.latitude_i); + TEST_ASSERT_EQUAL_INT32(-1219876543, gotPos.longitude_i); + TEST_ASSERT_EQUAL_INT32(123, gotPos.altitude); + TEST_ASSERT_EQUAL_UINT32(1700000200, gotPos.time); + TEST_ASSERT_EQUAL(meshtastic_Position_LocSource_LOC_INTERNAL, gotPos.location_source); + TEST_ASSERT_EQUAL_UINT32(32, gotPos.precision_bits); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(T), "no position was ever written for T"); +#endif + +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + meshtastic_DeviceMetrics gotDm; + TEST_ASSERT_TRUE(db->copyNodeTelemetry(T, gotDm)); + TEST_ASSERT_TRUE(gotDm.has_battery_level); + TEST_ASSERT_EQUAL_UINT32(87, gotDm.battery_level); + TEST_ASSERT_TRUE(gotDm.has_voltage); + TEST_ASSERT_EQUAL_FLOAT(3.7f, gotDm.voltage); + TEST_ASSERT_TRUE(gotDm.has_channel_utilization); + TEST_ASSERT_EQUAL_FLOAT(12.5f, gotDm.channel_utilization); + TEST_ASSERT_TRUE(gotDm.has_air_util_tx); + TEST_ASSERT_EQUAL_FLOAT(1.5f, gotDm.air_util_tx); + TEST_ASSERT_TRUE(gotDm.has_uptime_seconds); + TEST_ASSERT_EQUAL_UINT32(3600, gotDm.uptime_seconds); + TEST_ASSERT_FALSE(db->hasNodeTelemetry(P)); +#endif + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + meshtastic_EnvironmentMetrics gotEnv; + TEST_ASSERT_TRUE(db->copyNodeEnvironment(E, gotEnv)); + TEST_ASSERT_TRUE(gotEnv.has_temperature); + TEST_ASSERT_EQUAL_FLOAT(21.5f, gotEnv.temperature); + TEST_ASSERT_TRUE(gotEnv.has_relative_humidity); + TEST_ASSERT_EQUAL_FLOAT(40.5f, gotEnv.relative_humidity); + TEST_ASSERT_TRUE(gotEnv.has_barometric_pressure); + TEST_ASSERT_EQUAL_FLOAT(1013.25f, gotEnv.barometric_pressure); +#endif + +#if !MESHTASTIC_EXCLUDE_STATUSDB + meshtastic_StatusMessage gotSt; + TEST_ASSERT_TRUE(db->copyNodeStatus(E, gotSt)); + TEST_ASSERT_EQUAL_STRING("on the tower", gotSt.status); + TEST_ASSERT_FALSE(db->hasNodeStatus(P)); +#endif +} + +// --- Keyless-save skip (part of the PKI-DM key-amnesia diagnosis) --- + +static void test_keylessDevice_skipsNodesProtoWrite(void) +{ +#if MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI + TEST_IGNORE_MESSAGE("keyless-save gate compiled out on this build"); +#else + std::vector before; + TEST_ASSERT_TRUE_MESSAGE(readFileBytes(nodeDatabaseFileName, before), "nodes.proto must exist before the gate check"); + + const meshtastic_User_public_key_t savedKey = owner.public_key; + const bool savedLicensed = owner.is_licensed; + owner.public_key.size = 0; + owner.is_licensed = false; + + // Returning success on the skip matters: a false here would propagate into + // saveToDisk()'s fsFormat() whole-FS wipe. + TEST_ASSERT_TRUE_MESSAGE(db->saveDatabase(), "keyless save must report success"); + + std::vector after; + TEST_ASSERT_TRUE(readFileBytes(nodeDatabaseFileName, after)); + TEST_ASSERT_TRUE_MESSAGE(before == after, "keyless save must leave nodes.proto byte-identical"); + + owner.public_key = savedKey; + owner.is_licensed = savedLicensed; + + // Control: with the key restored, the same call writes. + addUserNode(0x55000001, 0x55); + TEST_ASSERT_TRUE(db->saveDatabase()); + TEST_ASSERT_TRUE(readFileBytes(nodeDatabaseFileName, after)); + TEST_ASSERT_FALSE_MESSAGE(before == after, "keyed save must rewrite nodes.proto"); +#endif +} + +// --- Live satellite-cap eviction policy --- + +#if !MESHTASTIC_EXCLUDE_STATUSDB +static void test_satelliteCap_evictionPolicy(void) +{ + if ((size_t)MAX_NUM_NODES < (size_t)MAX_SATELLITE_NODES + 8) + TEST_IGNORE_MESSAGE("hot cap too small to own a full satellite map on this build"); + + clearAllSatellites(); + TEST_ASSERT_EQUAL_UINT(0, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + const NodeNum self = nodeDB->getNodeNum(); + meshtastic_NodeInfoLite *selfRow = nodeDB->getOrCreateMeshNode(self); + TEST_ASSERT_NOT_NULL(selfRow); + selfRow->last_heard = 0; // stalest possible: only the identity exemption can protect it + nodeDB->setNodeStatus(self, makeStatus("self")); + + // Fill to exactly the cap with hot-owned entries; owner i heard at 1000+i. + const size_t owners = (size_t)MAX_SATELLITE_NODES - 1; + const NodeNum ownerBase = 0x60000000u; + for (size_t i = 0; i < owners; i++) { + meshtastic_NodeInfoLite *info = nodeDB->getOrCreateMeshNode(ownerBase + i); + TEST_ASSERT_NOT_NULL(info); + info->last_heard = 1000 + (uint32_t)i; + nodeDB->setNodeStatus(ownerBase + i, makeStatus("owned")); + } + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + // (a) At cap, a new entry evicts the stalest-by-owner victim - never self, + // even though self ranks stalest of all. + const NodeNum orphan1 = 0x60FFFF01u; + nodeDB->setNodeStatus(orphan1, makeStatus("new")); + TEST_ASSERT_TRUE_MESSAGE(db->hasNodeStatus(self), "self must never be evicted"); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodeStatus(ownerBase + 0), "stalest owner must be the victim"); + TEST_ASSERT_TRUE(db->hasNodeStatus(ownerBase + 1)); + TEST_ASSERT_TRUE(db->hasNodeStatus(orphan1)); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + // (b) Orphans (owner absent from the hot store) are evicted before any owner, + // however stale the owner: orphan1 (recency 0) loses to owner1 (1001). + const NodeNum orphan2 = 0x60FFFF02u; + nodeDB->setNodeStatus(orphan2, makeStatus("new2")); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodeStatus(orphan1), "orphan must be evicted before any owned entry"); + TEST_ASSERT_TRUE(db->hasNodeStatus(ownerBase + 1)); + TEST_ASSERT_TRUE(db->hasNodeStatus(orphan2)); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + // (c) Updating an existing key at cap must not evict anything. + nodeDB->setNodeStatus(ownerBase + 1, makeStatus("updated")); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + TEST_ASSERT_TRUE_MESSAGE(db->hasNodeStatus(orphan2), "update-in-place must not trigger eviction"); + meshtastic_StatusMessage got; + TEST_ASSERT_TRUE(db->copyNodeStatus(ownerBase + 1, got)); + TEST_ASSERT_EQUAL_STRING("updated", got.status); +} +#endif // !MESHTASTIC_EXCLUDE_STATUSDB + +// --- Boot-time trim of an over-cap nodes.proto (capacity downgrade / foreign file) --- + +#if !MESHTASTIC_EXCLUDE_POSITIONDB +static void test_bootTrim_overCapSatellitesHealedOnDisk(void) +{ + const size_t overBy = 10; + const NodeNum base = 0x70000000u; + + // Craft a v25 nodes.proto whose position store exceeds this build's cap, as a + // larger-cap build (or a peer backup) would leave behind. + meshtastic_NodeDatabase crafted{}; + crafted.version = DEVICESTATE_CUR_VER; + for (size_t i = 0; i < (size_t)MAX_SATELLITE_NODES + overBy; i++) { + meshtastic_NodePositionEntry e = meshtastic_NodePositionEntry_init_zero; + e.num = base + (uint32_t)i; + e.has_position = true; + e.position.latitude_i = (int32_t)(1000 + i); + e.position.time = 1000 + (uint32_t)i; + crafted.positions.push_back(e); + } + size_t craftedSize = 0; + TEST_ASSERT_TRUE(pb_get_encoded_size(&craftedSize, meshtastic_NodeDatabase_fields, &crafted)); + TEST_ASSERT_TRUE(db->saveProto(nodeDatabaseFileName, craftedSize, &meshtastic_NodeDatabase_msg, &crafted, false)); + + coldBoot(); + + // Trimmed in RAM to exactly the cap; all entries were orphans, so the + // lowest-recency victims (here: the lowest-numbered) went first. + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotPositionNodeNums(0).size()); + TEST_ASSERT_TRUE(db->hasNodePosition(base + (uint32_t)MAX_SATELLITE_NODES + (uint32_t)overBy - 1)); + TEST_ASSERT_FALSE(db->hasNodePosition(base)); + + // And healed on disk: nodeDBSelfCare rewrote the store once during the boot. + meshtastic_NodeDatabase reloaded{}; + decodeNodesFile(reloaded); + size_t persisted = 0; + for (const auto &e : reloaded.positions) + if (e.has_position) + persisted++; + TEST_ASSERT_EQUAL_UINT_MESSAGE((unsigned)MAX_SATELLITE_NODES, (unsigned)persisted, + "boot must rewrite the over-cap store trimmed"); +} +#endif // !MESHTASTIC_EXCLUDE_POSITIONDB + +// --- resetNodes(keepFavorites): no ghost rows above numMeshNodes --- + +static void test_resetNodesKeepFavorites_compactsWithoutGhostRows(void) +{ + const uint32_t F1 = 0x71000001, F2 = 0x71000002, F3 = 0x71000003, F4 = 0x71000004; + addUserNode(F1, 0x21); + addUserNode(F2, 0x22); + addUserNode(F3, 0x23); + addUserNode(F4, 0x24); + TEST_ASSERT_TRUE(nodeDB->set_favorite(true, F2)); + TEST_ASSERT_TRUE(nodeDB->set_favorite(true, F4)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.latitude_i = 111; + pos.longitude_i = 222; + nodeDB->updatePosition(F1, pos); + nodeDB->updatePosition(F2, pos); +#endif + + nodeDB->resetNodes(/*keepFavorites=*/true); + + // RAM: self + the two favorites, compacted into contiguous low slots. + TEST_ASSERT_EQUAL_INT(3, (int)nodeDB->getNumMeshNodes()); + TEST_ASSERT_NULL(db->getMeshNode(F1)); + TEST_ASSERT_NULL(db->getMeshNode(F3)); + const meshtastic_NodeInfoLite *f2 = db->getMeshNode(F2); + const meshtastic_NodeInfoLite *f4 = db->getMeshNode(F4); + TEST_ASSERT_NOT_NULL(f2); + TEST_ASSERT_NOT_NULL(f4); + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(f2)); + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(f4)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(F1), "non-favorite satellites must be dropped"); + TEST_ASSERT_TRUE_MESSAGE(db->hasNodePosition(F2), "favorite satellites must survive"); +#endif + + // Disk: resetNodes saved; the serialized store must carry the favorites in + // the low slots and NOTHING above numMeshNodes - a zeroed-in-place favorite + // would be invisible to every scan yet still serialized (the ghost bug). + meshtastic_NodeDatabase reloaded{}; + decodeNodesFile(reloaded); + TEST_ASSERT_TRUE(reloaded.nodes.size() >= 3); + size_t liveRows = 0; + bool sawF2 = false, sawF4 = false, sawSelf = false; + for (size_t i = 0; i < reloaded.nodes.size(); i++) { + const meshtastic_NodeInfoLite &row = reloaded.nodes[i]; + if (row.num == 0) + continue; + liveRows++; + TEST_ASSERT_TRUE_MESSAGE(i < 3, "live row serialized above numMeshNodes: a ghost entry"); + if (row.num == F2) + sawF2 = true; + if (row.num == F4) + sawF4 = true; + if (row.num == nodeDB->getNodeNum()) + sawSelf = true; + } + TEST_ASSERT_EQUAL_UINT(3, (unsigned)liveRows); + TEST_ASSERT_TRUE(sawSelf); + TEST_ASSERT_TRUE(sawF2); + TEST_ASSERT_TRUE(sawF4); +} + +NDBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); +#if defined(ARCH_PORTDUINO) + // The stalest-owner eviction case needs hot capacity above the satellite cap + // (the real large-flash topology). Set before the first NodeDB so every boot + // in this suite sees one consistent cap. + portduino_config.MaxNodes = (int)MAX_SATELLITE_NODES + 50; +#endif + // First boot on the empty sandbox: installs defaults, runs keygen, and + // persists the base config files every later cold boot reloads. + coldBoot(); + +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + // Boot keygen is region-gated on real radios (simradio bypasses the gate); + // if this environment blocked it, set a region and mint the identity now so + // the persistence legs run instead of cascading off a locked save. + if (owner.public_key.size != 32) { + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + nodeDB->generateCryptoKeyPair(nullptr); + } +#endif + + UNITY_BEGIN(); + + printf("\n=== Preconditions ===\n"); + RUN_TEST(test_identityReady_saveUnlocked); + + printf("\n=== updateFrom SNR gates ===\n"); + RUN_TEST(test_updateFrom_snrTransportGates); + + printf("\n=== snr_q4 + HAS_SNR round trip ===\n"); + RUN_TEST(test_snrQuantization_roundTripsThroughDisk); + + printf("\n=== Satellite projection/rehydration ===\n"); + RUN_TEST(test_fullRoundTrip_headerAndSatelliteFidelity); + + printf("\n=== Keyless-save gate ===\n"); + RUN_TEST(test_keylessDevice_skipsNodesProtoWrite); + + printf("\n=== Satellite caps ===\n"); +#if !MESHTASTIC_EXCLUDE_STATUSDB + RUN_TEST(test_satelliteCap_evictionPolicy); +#endif +#if !MESHTASTIC_EXCLUDE_POSITIONDB + RUN_TEST(test_bootTrim_overCapSatellitesHealedOnDisk); +#endif + + printf("\n=== resetNodes ghost rows ===\n"); + RUN_TEST(test_resetNodesKeepFavorites_compactsWithoutGhostRows); + + exit(UNITY_END()); +} +NDBR_TEST_ENTRY void loop() {} + +#else // !FSCom - no filesystem, nothing to round-trip + +void setUp(void) {} +void tearDown(void) {} + +NDBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} +NDBR_TEST_ENTRY void loop() {} + +#endif diff --git a/test/test_observer/test_main.cpp b/test/test_observer/test_main.cpp new file mode 100644 index 0000000000..88998575f8 --- /dev/null +++ b/test/test_observer/test_main.cpp @@ -0,0 +1,378 @@ +// Unit tests for src/Observer.h: notification order, the nonzero-return abort chain, +// CallbackObserver dispatch, ~Observer auto-detach, and list mutation from inside onNotify. +#include "Arduino.h" +#include "Observer.h" +#include "TestUtil.h" +#include +#include +#include + +// Tags of observers in the order their onNotify ran, e.g. "ABC". Cleared in setUp. +static std::string callOrder; + +// An observer that records its calls and can optionally mutate observer lists from inside +// onNotify - the mid-notify hazard the detach/attach-during-notify tests drive. +class RecordingObserver : public Observer +{ + public: + explicit RecordingObserver(char _tag) : tag(_tag) {} + + char tag; + int returnCode = 0; + int calls = 0; + int lastArg = 0; + + // When set, onNotify detaches detachWho from detachFrom before returning. + Observer *detachWho = nullptr; + Observable *detachFrom = nullptr; + + // When set, onNotify attaches attachWho to attachTo before returning. + Observer *attachWho = nullptr; + Observable *attachTo = nullptr; + + protected: + int onNotify(int arg) override + { + callOrder += tag; + calls++; + lastArg = arg; + if (detachWho && detachFrom) + detachWho->unobserve(detachFrom); + if (attachWho && attachTo) + attachWho->observe(attachTo); + return returnCode; + } +}; + +// Target class for the CallbackObserver member-pointer dispatch tests. +class CallbackTarget +{ + public: + int calls = 0; + int lastArg = 0; + + int handle(int arg) + { + calls++; + lastArg = arg; + return 0; + } + + int handleAbort(int arg) + { + calls++; + lastArg = arg; + return 42; + } +}; + +// --- basic delivery --- + +void test_notify_with_no_observers_returns_zero() +{ + Observable subject; + TEST_ASSERT_EQUAL(0, subject.notifyObservers(99)); +} + +void test_notify_order_and_arg() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(42)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); // insertion order + TEST_ASSERT_EQUAL(42, a.lastArg); + TEST_ASSERT_EQUAL(42, b.lastArg); + TEST_ASSERT_EQUAL(42, c.lastArg); + + // Delivery is not one-shot: a second notify reaches everyone again. + TEST_ASSERT_EQUAL(0, subject.notifyObservers(43)); + TEST_ASSERT_EQUAL_STRING("ABCABC", callOrder.c_str()); + TEST_ASSERT_EQUAL(2, b.calls); + TEST_ASSERT_EQUAL(43, b.lastArg); +} + +// --- abort contract --- + +void test_nonzero_return_aborts_chain_and_propagates() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + + b.returnCode = 7; + TEST_ASSERT_EQUAL(7, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); + TEST_ASSERT_EQUAL(0, c.calls); // chain stopped before C + + // Clearing the abort restores full delivery. + b.returnCode = 0; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); +} + +// --- CallbackObserver --- + +void test_callback_observer_dispatches_member_function() +{ + Observable subject; + CallbackTarget target; + CallbackObserver cb(&target, &CallbackTarget::handle); + cb.observe(&subject); + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1234)); + TEST_ASSERT_EQUAL(1, target.calls); + TEST_ASSERT_EQUAL(1234, target.lastArg); +} + +void test_callback_observer_return_code_aborts_chain() +{ + Observable subject; + CallbackTarget target; + CallbackObserver cb(&target, &CallbackTarget::handleAbort); + RecordingObserver after('X'); + cb.observe(&subject); + after.observe(&subject); + + TEST_ASSERT_EQUAL(42, subject.notifyObservers(5)); + TEST_ASSERT_EQUAL(1, target.calls); + TEST_ASSERT_EQUAL(0, after.calls); // callback's abort code stopped the chain +} + +// --- lifecycle: destructor auto-detach --- + +void test_destroyed_observer_is_not_notified() +{ + Observable subject; + RecordingObserver a('A'), c('C'); + a.observe(&subject); + RecordingObserver *b = new RecordingObserver('B'); + b->observe(&subject); + c.observe(&subject); + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); + + delete b; // ~Observer must remove it from the observable's list + + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); // ASan-clean: no dangling pointer left behind + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); +} + +void test_observer_watching_two_observables_detaches_from_both() +{ + Observable subject1; + Observable subject2; + { + RecordingObserver x('X'); + x.observe(&subject1); + x.observe(&subject2); // re-target onto a second observable: both now deliver + subject1.notifyObservers(1); + subject2.notifyObservers(2); + TEST_ASSERT_EQUAL(2, x.calls); + TEST_ASSERT_EQUAL(2, x.lastArg); + } // x destroyed here - must have detached from both observables + + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject1.notifyObservers(3)); + TEST_ASSERT_EQUAL(0, subject2.notifyObservers(4)); + TEST_ASSERT_EQUAL_STRING("", callOrder.c_str()); +} + +// --- duplicate observe / unobserve semantics --- + +void test_duplicate_observe_delivers_twice_and_unobserve_removes_all() +{ + Observable subject; + RecordingObserver a('A'); + a.observe(&subject); + a.observe(&subject); // current semantics: second observe means double delivery + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(9)); + TEST_ASSERT_EQUAL_STRING("AA", callOrder.c_str()); + TEST_ASSERT_EQUAL(2, a.calls); + + // One unobserve removes every entry (std::list::remove semantics), not just one. + a.unobserve(&subject); + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(10)); + TEST_ASSERT_EQUAL_STRING("", callOrder.c_str()); + TEST_ASSERT_EQUAL(2, a.calls); +} + +void test_unobserve_of_never_observed_observable_is_noop() +{ + Observable subject; + RecordingObserver a('A'), stranger('S'); + a.observe(&subject); + + stranger.unobserve(&subject); // never attached: must be a safe no-op + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("A", callOrder.c_str()); + TEST_ASSERT_EQUAL(0, stranger.calls); +} + +// --- list mutation from inside onNotify (the safe cases) --- + +void test_detach_of_earlier_observer_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + b.detachWho = &a; // B removes already-visited A mid-notify + b.detachFrom = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); // A was visited before removal; C unaffected + + b.detachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("BC", callOrder.c_str()); // A stays detached +} + +void test_detach_of_later_observer_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + a.detachWho = &c; // A removes not-yet-visited C mid-notify + a.detachFrom = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); // iteration stays valid, C never called + TEST_ASSERT_EQUAL(0, c.calls); + + a.detachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); +} + +// Tightest safe case: removing the node the iterator will step to next. std::list relinks A's +// next pointer when B's node is erased, so ++iterator lands on C. +void test_detach_of_immediately_next_observer_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + a.detachWho = &b; + a.detachFrom = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); + TEST_ASSERT_EQUAL(0, b.calls); + + a.detachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); +} + +// Self-detach is only safe when the observer also aborts the chain: returning nonzero exits +// before the iterator is advanced past the node unobserve() just erased. PhoneAPI is the one +// observer in the tree that does this (onNotify -> checkConnectionTimeout -> close() -> +// unobserve, returning -1), and its -1 is load-bearing, not incidental. A self-detaching +// observer that returned 0 would walk a freed node - not covered here, because asserting that +// would be asserting UB; notifyObservers() has to be hardened before it can be tested. +void test_self_detach_with_abort_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + b.detachWho = &b; + b.detachFrom = &subject; + b.returnCode = -1; + + TEST_ASSERT_EQUAL(-1, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); // C never runs: the chain aborted + TEST_ASSERT_EQUAL(0, c.calls); + + b.detachWho = nullptr; + b.returnCode = 0; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); +} + +void test_attach_during_notify_is_safe_and_delivers_next_time() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'), d('D'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + a.attachWho = &d; // A appends D mid-notify (push_back never invalidates list iterators) + a.attachTo = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + // The pre-existing observers all ran, in order. Whether the same pass also reaches the + // freshly appended D is deliberately not asserted - a hardened notifyObservers that + // snapshots the list would legitimately change that, and it should not go red for it. + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.substr(0, 3).c_str()); + + a.attachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("ABCD", callOrder.c_str()); // D is a full participant from now on +} + +// --- Unity lifecycle --- + +void setUp(void) +{ + callOrder.clear(); +} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Basic delivery ===\n"); + RUN_TEST(test_notify_with_no_observers_returns_zero); + RUN_TEST(test_notify_order_and_arg); + + printf("\n=== Abort contract ===\n"); + RUN_TEST(test_nonzero_return_aborts_chain_and_propagates); + + printf("\n=== CallbackObserver ===\n"); + RUN_TEST(test_callback_observer_dispatches_member_function); + RUN_TEST(test_callback_observer_return_code_aborts_chain); + + printf("\n=== Lifecycle ===\n"); + RUN_TEST(test_destroyed_observer_is_not_notified); + RUN_TEST(test_observer_watching_two_observables_detaches_from_both); + + printf("\n=== Duplicate observe / unobserve ===\n"); + RUN_TEST(test_duplicate_observe_delivers_twice_and_unobserve_removes_all); + RUN_TEST(test_unobserve_of_never_observed_observable_is_noop); + + printf("\n=== Mutation during notify (safe cases) ===\n"); + RUN_TEST(test_detach_of_earlier_observer_during_notify); + RUN_TEST(test_detach_of_later_observer_during_notify); + RUN_TEST(test_detach_of_immediately_next_observer_during_notify); + RUN_TEST(test_self_detach_with_abort_during_notify); + RUN_TEST(test_attach_during_notify_is_safe_and_delivers_next_time); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index d8234290a1..2bfd2b6e66 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -174,6 +174,11 @@ class AuthPipelineRouter : public ReliableRouter PendingPacket *entry = findPendingPacket(from, id); return entry ? entry->nextTxMsec : 0; } + uint8_t pendingTotalAttempts(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + return entry ? entry->initialNumRetransmissions + 1 : 0; + } size_t pendingCount() const { return pending.size(); } void clearPending() { @@ -417,6 +422,9 @@ void setUp(void) resetRoutingAuthEvaluationCount(); } +// Set while C14's saturated AirTime is installed; see useDutyCycleSaturatedAirTime() below. +static AirTime *c14SavedAirTime = nullptr; + void tearDown(void) { delete mockNodeDB; @@ -425,13 +433,15 @@ void tearDown(void) // Restore globals here, not at the end of a test body: an assertion aborts the body, and these // would otherwise leak into every later case. The injected clock is the one the N8-N11 - // suppression-window cases drive; the region and TX bucket are C14's duty-cycle setup. + // suppression-window cases drive; the region and the AirTime swap are C14's duty-cycle setup. Time::useRealClock(); Time::resetMonotonicForTests(); - if (airTime) - airTime->utilizationTX[0] = 0; config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; initRegion(); + if (c14SavedAirTime) { + airTime = c14SavedAirTime; + c14SavedAirTime = nullptr; + } } // =========================================================================== @@ -1381,7 +1391,7 @@ void test_C8_trusted_local_decoded_delivery_is_not_filtered(void) packetPool.release(local); } -void test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque(void) +void test_C9_known_channel_malformed_plaintext_has_no_pipeline_effects(void) { meshtastic_MeshPacket malformed = meshtastic_MeshPacket_init_zero; malformed.from = REMOTE_NODE; @@ -1394,6 +1404,12 @@ void test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque(void) malformed.encrypted.bytes[2] = 0xFF; malformed.channel = channels.setActiveByIndex(0); crypto->encryptPacket(malformed.from, malformed.id, malformed.encrypted.size, malformed.encrypted.bytes); + + // Verdict is opaque-relay-eligible now (see test_C17); hop_limit 0 is what keeps this a no-op. + meshtastic_MeshPacket verdictCopy = malformed; + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), + static_cast(passesRoutingAuthGate(&verdictCopy))); + mockNodeDB->addNode(REMOTE_NODE); const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; runPipelineIngress(malformed); @@ -1458,9 +1474,12 @@ void test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass runPipelineIngress(valid); TEST_ASSERT_EQUAL_MESSAGE(2, routingAuthEvaluationCount(), "consumed verdict must not authenticate a later replay"); + // Broadcast, so isToUs() is false like any colliding-hash foreign broadcast (see test_C17); + // this still guards that the cache is reevaluated per exact bytes, not reused for a same-ID replay. meshtastic_MeshPacket collision = valid; collision.encrypted.bytes[0] ^= 0x80; - TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::REJECT), static_cast(passesRoutingAuthGate(&collision))); + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), + static_cast(passesRoutingAuthGate(&collision))); TEST_ASSERT_EQUAL_MESSAGE(3, routingAuthEvaluationCount(), "same packet ID with different bytes must be reevaluated"); } @@ -1500,12 +1519,32 @@ void test_C13_failed_initial_reliable_send_does_not_retry(void) "failed interface enqueue must not leave a retransmission pending"); } +// C14 needs a node that has used its whole hourly duty-cycle allowance. Swaps in a separate AirTime +// rather than poking the global's buckets, which are private now. +// +// Deliberately NOT a scoped guard: Unity's TEST_ABORT() is longjmp, which does not run destructors +// of automatic objects, so a guard would leave `airTime` dangling into an abandoned stack frame on +// any assertion failure - and later cases dereference it (NodeInfoModule::allocReply). tearDown() +// restores the global unconditionally instead. The instance is a function-local static so it +// outlives the longjmp. +// +// Note it also parks channel utilisation at ~6000%, because logAirtime() credits that for every +// report type. C14 gates on utilizationTXPercent() alone; do not reuse this for an +// isTxAllowedChannelUtil() path, which would then pass for the wrong reason. +static void useDutyCycleSaturatedAirTime() +{ + static AirTime saturated; + c14SavedAirTime = airTime; + airTime = &saturated; + saturated.logAirtime(TX_LOG, MS_IN_HOUR); // utilizationTXPercent() sums every bucket -> 100% +} + void test_C14_duty_cycle_limited_reliable_send_remains_pending(void) { config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; config.lora.override_duty_cycle = false; initRegion(); - airTime->utilizationTX[0] = MS_IN_HOUR; + useDutyCycleSaturatedAirTime(); meshtastic_MeshPacket initial = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); initial.id = 0xC14C14C1; @@ -1519,11 +1558,57 @@ void test_C14_duty_cycle_limited_reliable_send_remains_pending(void) TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouter->pendingCount(), "duty-cycle rejection must retain the retry for when airtime is available"); - airTime->utilizationTX[0] = 0; config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; initRegion(); } +void test_C15_reliable_unicast_tracks_five_total_attempts(void) +{ + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); + p.id = 0x51530001; + p.want_ack = true; + + TEST_ASSERT_EQUAL(ERRNO_OK, pipelineRouter->send(packetPool.allocCopy(p))); + TEST_ASSERT_EQUAL_UINT8(5, pipelineRouter->pendingTotalAttempts(LOCAL_NODE, p.id)); +} + +void test_C16_reliable_broadcast_keeps_three_total_attempts(void) +{ + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); + p.id = 0x51530002; + p.want_ack = true; + + TEST_ASSERT_EQUAL(ERRNO_OK, pipelineRouter->send(packetPool.allocCopy(p))); + TEST_ASSERT_EQUAL_UINT8(3, pipelineRouter->pendingTotalAttempts(LOCAL_NODE, p.id)); +} + +void test_C17_colliding_channel_hash_foreign_broadcast_is_relay_only(void) +{ + // Foreign channel whose PSK collides with our channel 0's one-byte hash (see test_C9/test_C12 + // for the paired tradeoff): indistinguishable from tampering, so it must relay opaquely. + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + meshtastic_MeshPacket foreign = meshtastic_MeshPacket_init_zero; + foreign.from = REMOTE_NODE; + foreign.to = NODENUM_BROADCAST; + foreign.id = 0xC1700017; + foreign.hop_limit = 1; + foreign.hop_start = 2; + foreign.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + const int16_t hash = channels.setActiveByIndex(0); + TEST_ASSERT_GREATER_OR_EQUAL_MESSAGE(0, hash, "no usable primary channel"); + foreign.channel = (uint8_t)hash; // collides with our channel 0, but the ciphertext below is not ours + foreign.encrypted.size = 16; + memset(foreign.encrypted.bytes, 0xA5, foreign.encrypted.size); + + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), static_cast(passesRoutingAuthGate(&foreign))); + + // Same undecodable frame claiming to be from us must still be dropped: OPAQUE_RELAY_ONLY would + // reach perhapsGenerateImplicitAckForOwnOverheard, which acts on header bytes alone. + meshtastic_MeshPacket spoofed = foreign; + spoofed.from = LOCAL_NODE; + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::REJECT), static_cast(passesRoutingAuthGate(&spoofed))); +} + // C5: the packet survives (C4) but the identity claim inside it must not land - the pubkey guard // can't tell a signer from an impersonator replaying its (public) key. Only the write is refused. void test_N5_unsigned_unicast_nodeinfo_from_signer_does_not_change_name(void) @@ -2093,12 +2178,15 @@ void setup() RUN_TEST(test_C6_opaque_unknown_channel_is_relay_only); RUN_TEST(test_C7_strict_rejects_unsigned_decoded_simradio_ingress); RUN_TEST(test_C8_trusted_local_decoded_delivery_is_not_filtered); - RUN_TEST(test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque); + RUN_TEST(test_C9_known_channel_malformed_plaintext_has_no_pipeline_effects); RUN_TEST(test_C10_legacy_channel_dm_failure_has_no_pipeline_effects); RUN_TEST(test_C11_malformed_pki_plaintext_has_no_pipeline_effects); RUN_TEST(test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass); RUN_TEST(test_C13_failed_initial_reliable_send_does_not_retry); RUN_TEST(test_C14_duty_cycle_limited_reliable_send_remains_pending); + RUN_TEST(test_C15_reliable_unicast_tracks_five_total_attempts); + RUN_TEST(test_C16_reliable_broadcast_keeps_three_total_attempts); + RUN_TEST(test_C17_colliding_channel_hash_foreign_broadcast_is_relay_only); printf("\n=== Group N: NodeInfoModule authentication ===\n"); RUN_TEST(test_N1_unsigned_nodeinfo_from_signer_dropped); RUN_TEST(test_N2_signed_nodeinfo_from_signer_not_dropped); diff --git a/test/test_phone_api_config_dump/test_main.cpp b/test/test_phone_api_config_dump/test_main.cpp new file mode 100644 index 0000000000..2dff75d6ec --- /dev/null +++ b/test/test_phone_api_config_dump/test_main.cpp @@ -0,0 +1,574 @@ +// PhoneAPI::getFromRadio() config-dump sequence, asserted on decoded FromRadio protobufs: the +// order client apps depend on, the heartbeat preempt, SPECIAL_NONCE_ONLY_* jumps, mid-dump +// restart, and the post-complete drain reaching idle. +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#include "Channels.h" +#include "CryptoEngine.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "PhoneAPI.h" +#include "Router.h" +#include "mesh-pb-constants.h" +#include "meshtastic/admin.pb.h" +#include +#include +#include + +// File-scope flag in PhoneAPI.cpp: set by a client heartbeat, cleared by the queueStatus reply. +extern bool heartbeatReceived; + +namespace +{ +constexpr uint32_t FULL_DUMP_NONCE = 0x51C0FFEE; +constexpr uint32_t SECOND_NONCE = 0x0DDBA11; +constexpr NodeNum SEEDED_NODE_A = 0x00000A01; +constexpr NodeNum SEEDED_NODE_B = 0x00000A02; + +constexpr unsigned NUM_SINGLETON_PREFIX = 5; // my_info, deviceuiConfig, own node_info, metadata, region_presets +constexpr unsigned NUM_CONFIG_MESSAGES = _meshtastic_AdminMessage_ConfigType_MAX + 1; +constexpr unsigned NUM_MODULE_CONFIG_MESSAGES = _meshtastic_AdminMessage_ModuleConfigType_MAX + 1; + +// STATE_SEND_CONFIG iterates config_state over the AdminMessage ConfigType enum but emits +// Config oneof tags: a proto bump that grows one without the other makes a config message +// carry inner variant 0. The static_asserts turn that drift into a compile error here. +const pb_size_t kExpectedConfigVariants[] = { + meshtastic_Config_device_tag, meshtastic_Config_position_tag, meshtastic_Config_power_tag, + meshtastic_Config_network_tag, meshtastic_Config_display_tag, meshtastic_Config_lora_tag, + meshtastic_Config_bluetooth_tag, meshtastic_Config_security_tag, meshtastic_Config_sessionkey_tag, + meshtastic_Config_device_ui_tag, +}; +static_assert(sizeof(kExpectedConfigVariants) / sizeof(kExpectedConfigVariants[0]) == NUM_CONFIG_MESSAGES, + "AdminMessage ConfigType enum and Config oneof diverged - update PhoneAPI's STATE_SEND_CONFIG and this list"); + +const pb_size_t kExpectedModuleConfigVariants[] = { + meshtastic_ModuleConfig_mqtt_tag, + meshtastic_ModuleConfig_serial_tag, + meshtastic_ModuleConfig_external_notification_tag, + meshtastic_ModuleConfig_store_forward_tag, + meshtastic_ModuleConfig_range_test_tag, + meshtastic_ModuleConfig_telemetry_tag, + meshtastic_ModuleConfig_canned_message_tag, + meshtastic_ModuleConfig_audio_tag, + meshtastic_ModuleConfig_remote_hardware_tag, + meshtastic_ModuleConfig_neighbor_info_tag, + meshtastic_ModuleConfig_ambient_lighting_tag, + meshtastic_ModuleConfig_detection_sensor_tag, + meshtastic_ModuleConfig_paxcounter_tag, + meshtastic_ModuleConfig_statusmessage_tag, + meshtastic_ModuleConfig_traffic_management_tag, + meshtastic_ModuleConfig_tak_tag, +#if !MESHTASTIC_EXCLUDE_BEACON + meshtastic_ModuleConfig_mesh_beacon_tag, +#else + 0, // beacon compiled out: the slot still ships, as an empty ModuleConfig +#endif +}; +static_assert(sizeof(kExpectedModuleConfigVariants) / sizeof(kExpectedModuleConfigVariants[0]) == NUM_MODULE_CONFIG_MESSAGES, + "AdminMessage ModuleConfigType enum and ModuleConfig oneof diverged - update STATE_SEND_MODULECONFIG and this " + "list"); + +/// PhoneAPI over a permanently-connected fake transport. +class PhoneAPITestShim : public PhoneAPI +{ + protected: + bool checkIsConnected() override { return true; } +}; + +/// Concrete Router with no radio interface: getQueueStatus() reports an all-zero queue. +class TestRouter : public Router +{ + public: + // Router's ctor allocated the global cryptLock; nothing else frees it. + ~TestRouter() + { + delete cryptLock; + cryptLock = nullptr; + } +}; + +// Saved-global fixture, template test_event_channel_phone_api. Restored in tearDown() rather +// than by RAII because a failed TEST_ASSERT longjmps out of the test without running destructors. +struct GlobalState { + MeshService *service; + Router *router; + NodeDB *nodeDB; + concurrency::Lock *cryptLock; + meshtastic_MyNodeInfo myNodeInfo; + Channels channels; + meshtastic_ChannelFile channelFile; + meshtastic_LocalConfig config; + meshtastic_LocalModuleConfig moduleConfig; + meshtastic_DeviceState deviceState; +}; + +GlobalState *savedState = nullptr; +MeshService *mockService = nullptr; +TestRouter *testRouter = nullptr; +NodeDB *testNodeDB = nullptr; +PhoneAPITestShim *api = nullptr; + +/// Give every channel slot a distinct index so the dump's 0..7 ordering is observable. +void configureTestChannels() +{ + channelFile = meshtastic_ChannelFile_init_default; + channelFile.channels_count = MAX_NUM_CHANNELS; + for (pb_size_t i = 0; i < MAX_NUM_CHANNELS; i++) { + channelFile.channels[i].index = (int8_t)i; + channelFile.channels[i].has_settings = true; + channelFile.channels[i].role = i == 0 ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY; + } + channels.onConfigChanged(); +} + +/// Create a remote node in the scratch NodeDB the way received traffic would. +void seedRemoteNode(NodeNum num) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + p.from = num; + p.to = NODENUM_BROADCAST; + nodeDB->updateFrom(p); +} + +bool sendToRadio(const meshtastic_ToRadio &message) +{ + uint8_t encoded[meshtastic_ToRadio_size] = {}; + const size_t encodedSize = + pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ToRadio_msg, const_cast(&message)); + TEST_ASSERT_GREATER_THAN_UINT(0, encodedSize); + return api->handleToRadio(encoded, encodedSize); +} + +void startHandshake(uint32_t nonce) +{ + meshtastic_ToRadio request = meshtastic_ToRadio_init_zero; + request.which_payload_variant = meshtastic_ToRadio_want_config_id_tag; + request.want_config_id = nonce; + sendToRadio(request); +} + +void sendPlainHeartbeat() +{ + meshtastic_ToRadio hb = meshtastic_ToRadio_init_zero; + hb.which_payload_variant = meshtastic_ToRadio_heartbeat_tag; + hb.heartbeat = meshtastic_Heartbeat_init_zero; // nonce 0 = plain keepalive, expects a queueStatus reply + sendToRadio(hb); +} + +/// One decoded FromRadio pulled off the wire; zero-length reads return false. +bool readOneFromRadio(meshtastic_FromRadio &out) +{ + uint8_t buf[meshtastic_FromRadio_size]; + const size_t len = api->getFromRadio(buf); + if (len == 0) + return false; + out = meshtastic_FromRadio_init_zero; + TEST_ASSERT_TRUE_MESSAGE(pb_decode_from_bytes(buf, len, &meshtastic_FromRadio_msg, &out), + "device emitted an undecodable FromRadio"); + return true; +} + +/// Everything the dump emitted, in order, as decoded facts rather than internals. +struct DumpTranscript { + std::vector variants; // outer which_payload_variant per message + std::vector configVariants; // inner variant of each FromRadio.config + std::vector moduleConfigVariants; // inner variant of each FromRadio.moduleConfig + std::vector channelIndices; + std::vector nodeNums; + unsigned fileInfoCount = 0; + unsigned queueStatusCount = 0; + uint32_t completeId = 0; + bool sawComplete = false; +}; + +/// Pull messages until config_complete_id; false if the stream stalls or overruns the cap. +bool drainUntilComplete(DumpTranscript &t, unsigned maxMessages = 600) +{ + for (unsigned i = 0; i < maxMessages; i++) { + meshtastic_FromRadio msg; + if (!readOneFromRadio(msg)) + return false; + t.variants.push_back(msg.which_payload_variant); + switch (msg.which_payload_variant) { + case meshtastic_FromRadio_config_tag: + t.configVariants.push_back(msg.config.which_payload_variant); + break; + case meshtastic_FromRadio_moduleConfig_tag: + t.moduleConfigVariants.push_back(msg.moduleConfig.which_payload_variant); + break; + case meshtastic_FromRadio_channel_tag: + t.channelIndices.push_back(msg.channel.index); + break; + case meshtastic_FromRadio_node_info_tag: + t.nodeNums.push_back(msg.node_info.num); + break; + case meshtastic_FromRadio_fileInfo_tag: + t.fileInfoCount++; + break; + case meshtastic_FromRadio_queueStatus_tag: + t.queueStatusCount++; + break; + case meshtastic_FromRadio_config_complete_id_tag: + t.completeId = msg.config_complete_id; + t.sawComplete = true; + return true; + default: + break; + } + } + return false; +} + +unsigned countVariant(const DumpTranscript &t, pb_size_t tag) +{ + unsigned n = 0; + for (pb_size_t v : t.variants) + if (v == tag) + n++; + return n; +} + +/// Assert the two non-self node records are the seeded pair (DB iteration order not pinned). +void assertSeededPair(uint32_t first, uint32_t second) +{ + const bool inOrder = first == SEEDED_NODE_A && second == SEEDED_NODE_B; + const bool swapped = first == SEEDED_NODE_B && second == SEEDED_NODE_A; + TEST_ASSERT_TRUE_MESSAGE(inOrder || swapped, "other node_infos are not the seeded pair"); +} + +// --- Tests --- + +// The full documented sequence, section by section, ending in the nonce echo. Also pins the +// channel section: exactly MAX_NUM_CHANNELS messages, indices 0..7 in order, between +// region_presets and the first config. +void test_full_want_config_dump_emits_documented_sequence() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(FULL_DUMP_NONCE); + + DumpTranscript t; + TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "dump stalled before config_complete_id"); + + const pb_size_t expectedPrefix[NUM_SINGLETON_PREFIX] = { + meshtastic_FromRadio_my_info_tag, meshtastic_FromRadio_deviceuiConfig_tag, meshtastic_FromRadio_node_info_tag, + meshtastic_FromRadio_metadata_tag, meshtastic_FromRadio_region_presets_tag}; + TEST_ASSERT_GREATER_OR_EQUAL_UINT(NUM_SINGLETON_PREFIX, t.variants.size()); + for (unsigned i = 0; i < NUM_SINGLETON_PREFIX; i++) + TEST_ASSERT_EQUAL_UINT_MESSAGE(expectedPrefix[i], t.variants[i], "header sequence changed"); + + // Bound the raw indexing below: header + channels + configs + moduleConfigs + 2 seeded + // node_infos + complete is the minimum a full dump can be. + TEST_ASSERT_GREATER_OR_EQUAL_UINT( + NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + NUM_CONFIG_MESSAGES + NUM_MODULE_CONFIG_MESSAGES + 3, t.variants.size()); + + // Channel section: contiguous, complete, ordered. + const size_t channelStart = NUM_SINGLETON_PREFIX; + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size()); + for (unsigned i = 0; i < MAX_NUM_CHANNELS; i++) { + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_channel_tag, t.variants[channelStart + i]); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)i, t.channelIndices[i], "channels must arrive as indices 0..7 in order"); + } + + const size_t configStart = channelStart + MAX_NUM_CHANNELS; + for (unsigned i = 0; i < NUM_CONFIG_MESSAGES; i++) + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_config_tag, t.variants[configStart + i]); + + const size_t moduleStart = configStart + NUM_CONFIG_MESSAGES; + for (unsigned i = 0; i < NUM_MODULE_CONFIG_MESSAGES; i++) + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_moduleConfig_tag, t.variants[moduleStart + i]); + + // Other node_infos follow the module configs; the own record was already sent in the header. + const size_t nodesStart = moduleStart + NUM_MODULE_CONFIG_MESSAGES; + TEST_ASSERT_EQUAL_UINT(3, t.nodeNums.size()); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + assertSeededPair(t.nodeNums[1], t.nodeNums[2]); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, t.variants[nodesStart]); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, t.variants[nodesStart + 1]); + + // Everything between the node_infos and the completion id is file manifest (count is + // whatever the sandbox filesystem holds, so only the position is asserted). + for (size_t i = nodesStart + 2; i + 1 < t.variants.size(); i++) + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_fileInfo_tag, t.variants[i]); + + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_config_complete_id_tag, t.variants.back()); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(FULL_DUMP_NONCE, t.completeId, "config_complete_id must echo the request nonce"); + + // Singletons exactly once, and no stray preempts. + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_my_info_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_deviceuiConfig_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_metadata_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_region_presets_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_config_complete_id_tag)); + TEST_ASSERT_EQUAL_UINT(0, t.queueStatusCount); + TEST_ASSERT_EQUAL_UINT(NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + NUM_CONFIG_MESSAGES + NUM_MODULE_CONFIG_MESSAGES + 2 + + t.fileInfoCount + 1, + t.variants.size()); +} + +// Guards the ConfigType-enum-to-oneof-tag iteration: a desync emits a config message whose +// inner variant is 0, which every phone app decodes as an empty Config. +void test_config_section_inner_variants_match_config_type_enum() +{ + startHandshake(FULL_DUMP_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size()); + for (unsigned i = 0; i < NUM_CONFIG_MESSAGES; i++) { + TEST_ASSERT_NOT_EQUAL_MESSAGE(0, t.configVariants[i], + "config with inner variant 0: ConfigType enum drifted from the Config oneof"); + TEST_ASSERT_EQUAL_UINT(kExpectedConfigVariants[i], t.configVariants[i]); + } +} + +// Same closed-set guard for the module config section (the drift class already happened once, +// for statusmessage). +void test_module_config_section_inner_variants_match_module_config_type_enum() +{ + startHandshake(FULL_DUMP_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + for (unsigned i = 0; i < NUM_MODULE_CONFIG_MESSAGES; i++) { + if (kExpectedModuleConfigVariants[i] != 0) + TEST_ASSERT_NOT_EQUAL_MESSAGE( + 0, t.moduleConfigVariants[i], + "moduleConfig with inner variant 0: ModuleConfigType enum drifted from the ModuleConfig oneof"); + TEST_ASSERT_EQUAL_UINT(kExpectedModuleConfigVariants[i], t.moduleConfigVariants[i]); + } +} + +// SPECIAL_NONCE_ONLY_NODES jumps straight to the node stream: own record, others, completion - +// no headers, channels, configs, or manifest. +void test_only_nodes_nonce_sends_nodes_then_complete() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(SPECIAL_NONCE_ONLY_NODES); + + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(4, t.variants.size()); // own + 2 seeded + complete + TEST_ASSERT_EQUAL_UINT(3, t.nodeNums.size()); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + assertSeededPair(t.nodeNums[1], t.nodeNums[2]); + TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_NODES, t.completeId); + + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_my_info_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_deviceuiConfig_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_metadata_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_region_presets_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_channel_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_config_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_moduleConfig_tag)); + TEST_ASSERT_EQUAL_UINT(0, t.fileInfoCount); +} + +// SPECIAL_NONCE_ONLY_CONFIG delivers the full config but skips the non-self node DB, and must +// not arm the post-complete satellite replay. +void test_only_config_nonce_skips_other_nodeinfos() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(SPECIAL_NONCE_ONLY_CONFIG); + + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_node_info_tag)); // own record only + TEST_ASSERT_EQUAL_UINT(1, t.nodeNums.size()); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, countVariant(t, meshtastic_FromRadio_channel_tag)); + TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size()); + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_CONFIG, t.completeId); + + // ONLY_CONFIG skips node/satellite sync entirely: the stream must be idle immediately. + uint8_t buf[meshtastic_FromRadio_size]; + TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf)); + TEST_ASSERT_FALSE(api->available()); +} + +// A keepalive heartbeat mid-dump preempts exactly one read with a queueStatus, then the dump +// resumes where it left off; the flag self-clears so nothing repeats or restarts. +void test_heartbeat_mid_dump_preempts_once_then_resumes() +{ + startHandshake(FULL_DUMP_NONCE); + + // Pull the first three header messages, leaving the machine about to send metadata. + meshtastic_FromRadio msg; + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant); + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_deviceuiConfig_tag, msg.which_payload_variant); + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, msg.which_payload_variant); + + sendPlainHeartbeat(); + + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_queueStatus_tag, msg.which_payload_variant, + "heartbeat must be answered with a queueStatus before the dump continues"); + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_metadata_tag, msg.which_payload_variant, + "dump must resume exactly where the heartbeat preempted it"); + + DumpTranscript rest; + TEST_ASSERT_TRUE(drainUntilComplete(rest)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, rest.queueStatusCount, "heartbeat flag must self-clear after one reply"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, countVariant(rest, meshtastic_FromRadio_my_info_tag), + "heartbeat must not restart the dump"); + TEST_ASSERT_EQUAL_UINT32(FULL_DUMP_NONCE, rest.completeId); +} + +// Disconnect mid-dump, then a fresh handshake: the machine restarts from my_info with the new +// nonce and every section is delivered exactly once. +void test_close_mid_dump_then_reconnect_restarts_clean() +{ + seedRemoteNode(SEEDED_NODE_A); + startHandshake(FULL_DUMP_NONCE); + + meshtastic_FromRadio msg; + for (unsigned i = 0; i < 5; i++) + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + + api->close(); + TEST_ASSERT_FALSE(api->isConnected()); + uint8_t buf[meshtastic_FromRadio_size]; + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, api->getFromRadio(buf), "a closed connection must emit nothing"); + + startHandshake(SECOND_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_my_info_tag, t.variants[0]); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size()); + TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size()); + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_config_complete_id_tag)); + TEST_ASSERT_EQUAL_UINT32(SECOND_NONCE, t.completeId); +} + +// A new want_config while a dump is in flight (no disconnect) also restarts the machine, and +// stale mid-section progress must not leak into the new dump. +void test_rehandshake_mid_dump_restarts_from_my_info() +{ + startHandshake(FULL_DUMP_NONCE); + + // Read into the middle of the config section (5 headers + 8 channels + 7 configs). + meshtastic_FromRadio msg; + for (unsigned i = 0; i < NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + 7; i++) + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + + startHandshake(SECOND_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, t.variants[0], "re-handshake must restart from my_info"); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size()); + for (unsigned i = 0; i < MAX_NUM_CHANNELS; i++) + TEST_ASSERT_EQUAL_INT((int)i, t.channelIndices[i]); + TEST_ASSERT_EQUAL_UINT_MESSAGE(NUM_CONFIG_MESSAGES, t.configVariants.size(), + "stale config_state leaked into the restarted dump"); + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + TEST_ASSERT_EQUAL_UINT32(SECOND_NONCE, t.completeId); +} + +// After config_complete_id the trailing satellite replay must reach idle in bounded reads - a +// drain loop keyed on available() must terminate (the infinite-drain regression class). +void test_dump_reaches_idle_after_complete() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(FULL_DUMP_NONCE); + + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + uint8_t buf[meshtastic_FromRadio_size]; + bool idle = false; + for (unsigned i = 0; i < 8 && !idle; i++) { + if (!api->available()) + idle = true; + else + api->getFromRadio(buf); // replay drain: empty phases must advance toward idle + } + TEST_ASSERT_TRUE_MESSAGE(idle, "post-complete drain never went idle: available() stuck true"); + TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf)); +} + +} // namespace + +void setUp(void) +{ + savedState = + new GlobalState{service, router, nodeDB, cryptLock, myNodeInfo, channels, channelFile, config, moduleConfig, devicestate}; + + service = mockService = new MeshService(); + // A real boot starts with a zeroed nodeDatabase; in-process the global retains the previous + // test's vector (the decode callback appends, it does not clear), so reset it first. + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDB = testNodeDB = new NodeDB(); + configureTestChannels(); + cryptLock = nullptr; // Router's ctor asserts this is unset before allocating its own. + router = testRouter = new TestRouter(); + api = new PhoneAPITestShim(); + heartbeatReceived = false; +} + +void tearDown(void) +{ + delete api; // dtor runs close(), which still needs the mock service installed + api = nullptr; + delete testRouter; // ~TestRouter() deletes the cryptLock its ctor allocated + testRouter = nullptr; + delete testNodeDB; + testNodeDB = nullptr; + delete mockService; + mockService = nullptr; + heartbeatReceived = false; + + service = savedState->service; + router = savedState->router; + nodeDB = savedState->nodeDB; + cryptLock = savedState->cryptLock; // ~TestRouter() nulled it; hand the saved router its own back + myNodeInfo = savedState->myNodeInfo; + channels = savedState->channels; + channelFile = savedState->channelFile; + config = savedState->config; + moduleConfig = savedState->moduleConfig; + devicestate = savedState->deviceState; + delete savedState; + savedState = nullptr; +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== want_config dump sequence ===\n"); + RUN_TEST(test_full_want_config_dump_emits_documented_sequence); + RUN_TEST(test_config_section_inner_variants_match_config_type_enum); + RUN_TEST(test_module_config_section_inner_variants_match_module_config_type_enum); + + printf("\n=== special nonces ===\n"); + RUN_TEST(test_only_nodes_nonce_sends_nodes_then_complete); + RUN_TEST(test_only_config_nonce_skips_other_nodeinfos); + + printf("\n=== preemption and restart ===\n"); + RUN_TEST(test_heartbeat_mid_dump_preempts_once_then_resumes); + RUN_TEST(test_close_mid_dump_then_reconnect_restarts_clean); + RUN_TEST(test_rehandshake_mid_dump_restarts_from_my_info); + RUN_TEST(test_dump_reaches_idle_after_complete); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_pki_admin_fallback/test_main.cpp b/test/test_pki_admin_fallback/test_main.cpp index 5c3b408c9f..127d4f9997 100644 --- a/test/test_pki_admin_fallback/test_main.cpp +++ b/test/test_pki_admin_fallback/test_main.cpp @@ -9,6 +9,7 @@ // The whole feature is compiled out when PKI is excluded. #if !(MESHTASTIC_EXCLUDE_PKI) +#include "UptimeClock.h" #include "mesh/Channels.h" #include "mesh/CryptoEngine.h" #include "mesh/NodeDB.h" @@ -148,6 +149,11 @@ void setUp(void) void tearDown(void) { + // The rate-limit case drives a virtual timebase; leave the real clock for everyone else, and + // re-stamp the budget so the next case does not measure a virtual stamp against real millis. + Time::useRealClock(); + resetAdminKeyFallbackBudget(); + delete mockNodeDB; mockNodeDB = nullptr; nodeDB = nullptr; @@ -205,8 +211,10 @@ void test_wrong_admin_key_does_not_decode(void) // The fallback is budget-limited against flooding; see Router.cpp for why the budget is global. void test_admin_key_fallback_is_rate_limited(void) { - // Start from a full bucket regardless of what earlier tests consumed (8 tokens, one per 250ms). - delay(2500); + // Drive the virtual clock: on the wall clock the eight decodes below have to beat the 250ms + // refill, which is ~31ms each - CI misses that and the bucket refills mid-drain. + Time::setTestMillis(1000000); + resetAdminKeyFallbackBudget(); // re-stamp against the virtual clock we just switched to uint8_t otherPub[32], otherPriv[32]; crypto->generateKeyPair(otherPub, otherPriv); @@ -225,7 +233,7 @@ void test_admin_key_fallback_is_rate_limited(void) TEST_ASSERT_NOT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&blocked), "fallback should be budget-limited"); // The budget refills, so the throttle is not a permanent lockout. - delay(600); + Time::advanceTestMillis(600); meshtastic_MeshPacket allowed = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&allowed), "budget should refill over time"); assertDecodedAndLearned(&allowed, adminPub); diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp index fae50e87fc..034314b1e3 100644 --- a/test/test_position_precision/test_main.cpp +++ b/test/test_position_precision/test_main.cpp @@ -8,10 +8,6 @@ #include #include #include -#if ARCH_PORTDUINO -#include "platform/portduino/PortduinoGlue.h" -#endif - static meshtastic_Position makePosition() { meshtastic_Position position = meshtastic_Position_init_default; @@ -308,6 +304,9 @@ static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum portnum, uint8 packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; packet.decoded.portnum = portnum; packet.channel = channelIndex; + // A real destination: this suite never sets a node number, so a default to=0 would read as + // "to us" (getNodeNum()==0) and take the from-us-to-us loopback exemption. + packet.to = NODENUM_BROADCAST; return packet; } @@ -332,9 +331,6 @@ static void test_eventCoordinatePolicy_coversPortsAndExcludesPki() waypoint.to = 0x12345678; config.security.private_key.size = 32; owner.is_licensed = false; -#if ARCH_PORTDUINO - portduino_config.force_simradio = false; -#endif TEST_ASSERT_TRUE(willUsePki(&waypoint)); TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&waypoint)); #else @@ -380,7 +376,12 @@ static void test_eventCoordinatePolicy_usesResolvedUnicastChannel() configureEventChannels(false, false); meshtastic_NodeInfoLite *node = nodeDB->getNumMeshNodes() > 1 ? nodeDB->getMeshNodeByIndex(1) : nodeDB->getOrCreateMeshNode(0x12345678); + // A persisted DB (unsandboxed host run) can hand back our own entry here; a from-us-to-us packet is + // loopback-exempt, which is not the policy under test. Insist on a remote destination. + if (node && node->num == nodeDB->getNodeNum()) + node = nodeDB->getOrCreateMeshNode(0x12345678); TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_NOT_EQUAL(nodeDB->getNodeNum(), node->num); const NodeNum destination = node->num; const uint8_t savedChannel = node->channel; @@ -403,6 +404,38 @@ static void test_eventCoordinatePolicy_usesResolvedUnicastChannel() #endif } +static void test_findPositionChannel_skipsEventAndDisabledChannels() +{ + // Both channels store precision 16. Under the block gate the event channel never carries + // positions, so the private one (index 1) is the position channel; otherwise index 0 wins. + configureEventChannels(false, false); + uint8_t positionChannel = 0xff; + TEST_ASSERT_TRUE(findPositionChannel(positionChannel)); +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + TEST_ASSERT_EQUAL_UINT8(1, positionChannel); +#else + TEST_ASSERT_EQUAL_UINT8(0, positionChannel); +#endif + + // Reordering follows the effective key, not the index. + configureEventChannels(true, false); + TEST_ASSERT_TRUE(findPositionChannel(positionChannel)); + TEST_ASSERT_EQUAL_UINT8(0, positionChannel); + + // Precision 0 everywhere: nothing to pick. + configureEventChannels(false, false); + channelFile.channels[0].settings.module_settings.position_precision = 0; + channelFile.channels[1].settings.module_settings.position_precision = 0; + channels.onConfigChanged(); + TEST_ASSERT_FALSE(findPositionChannel(positionChannel)); + + // A disabled channel does not count even with a stored precision. + channelFile.channels[1].settings.module_settings.position_precision = 32; + channelFile.channels[1].role = meshtastic_Channel_Role_DISABLED; + channels.onConfigChanged(); + TEST_ASSERT_FALSE(findPositionChannel(positionChannel)); +} + static void test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored() { // A private channel with a full 32-byte key that is not the configured @@ -446,6 +479,7 @@ void setup() RUN_TEST(test_eventCoordinatePolicy_coversPortsAndExcludesPki); RUN_TEST(test_eventCoordinatePolicy_doesNotClassifyOpaquePacketsByHash); RUN_TEST(test_eventCoordinatePolicy_usesResolvedUnicastChannel); + RUN_TEST(test_findPositionChannel_skipsEventAndDisabledChannels); RUN_TEST(test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored); exit(UNITY_END()); } diff --git a/test/test_reliable_ack_matrix/test_main.cpp b/test/test_reliable_ack_matrix/test_main.cpp new file mode 100644 index 0000000000..635b979039 --- /dev/null +++ b/test/test_reliable_ack_matrix/test_main.cpp @@ -0,0 +1,853 @@ +// ReliableRouter ACK/NAK decision matrix: which ACK or NAK sniffReceived() emits per inbound +// shape, retransmission bookkeeping, the #11502 implicit ACK for our own overheard opaque DM +// (Group 5b drives the real OPAQUE_RELAY_ONLY ingress path), and the pending-timer extensions. +// Harness copied from test_nexthop_routing (ReliableRouterTestShim + MockRoutingModule). + +#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc. +#include "TestUtil.h" +#include + +#include "airtime.h" +#include "configuration.h" +#include "gps/RTC.h" +#include "mesh/Channels.h" +#include "mesh/NodeDB.h" +#include "mesh/RadioInterface.h" +#include "mesh/ReliableRouter.h" +#include "mesh/Throttle.h" +#include "modules/RoutingModule.h" +#include +#include +#include +#include +#include +#include + +static constexpr NodeNum kLocalNode = 0x11111111; // last byte 0x11 +static constexpr NodeNum kRemoteNode = 0x22222222; +static constexpr NodeNum kThirdNode = 0x33333333; + +// --------------------------------------------------------------------------- +// MockNodeDB - inject sender records with a controlled public-key size, so the PKI_UNKNOWN_PUBKEY +// vs NO_CHANNEL discrimination in sniffReceived() can be driven per test. +// --------------------------------------------------------------------------- +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNode(NodeNum num, uint8_t publicKeySize = 0) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + node.last_heard = getTime(); + node.public_key.size = publicKeySize; + if (publicKeySize) + memset(node.public_key.bytes, 0x5C, publicKeySize); + nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_HAS_USER_MASK, true); + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + std::vector testNodes; +}; + +// --------------------------------------------------------------------------- +// Test shim - expose the protected sniff/filter entry points and the pending/route-health state. +// --------------------------------------------------------------------------- +class ReliableRouterTestShim : public ReliableRouter +{ + public: + ReliableRouterTestShim() : ReliableRouter() {} + + using NextHopRouter::findRouteHealth; + using NextHopRouter::noteRouteFailure; + using NextHopRouter::noteRouteLearned; + + size_t pendingCount() const { return pending.size(); } + + void seedRetry(const meshtastic_MeshPacket &p, uint8_t attempts) + { + auto *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + startRetransmission(copy, attempts); + } + + void sniffForTest(const meshtastic_MeshPacket *p, const meshtastic_Routing *routing) + { + ReliableRouter::sniffReceived(p, routing); + } + + bool filterForTest(const meshtastic_MeshPacket *p) { return ReliableRouter::shouldFilterReceived(p); } + + bool hasPending(NodeNum from, PacketId id) { return findPendingPacket(from, id) != nullptr; } + + uint32_t pendingNextTx(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(entry); + return entry->nextTxMsec; + } + + const meshtastic_MeshPacket *pendingPacket(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(entry); + return entry->packet; + } + + void clearPendingForTest() + { + while (!pending.empty()) + stopRetransmission(pending.begin()->first); + } + + void resetRouteHealthForTest() + { + for (auto &h : routeHealth) + h = RouteHealth{}; + } +}; + +// Capture radio with a configurable per-packet airtime, so the pending-timer extension loops +// (which are no-ops with a 0-returning stub) become observable. +class TimedCaptureRadio : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + bool cancelSending(NodeNum from, PacketId id) override + { + (void)from; + (void)id; + cancelCount++; + return false; + } + + bool findInTxQueue(NodeNum from, PacketId id) override + { + (void)from; + (void)id; + return false; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return packetTimeMsec; + } + + void reset() + { + sentPackets.clear(); + cancelCount = 0; + packetTimeMsec = 0; + } + + std::vector sentPackets; + uint32_t cancelCount = 0; + uint32_t packetTimeMsec = 0; +}; + +class MockRoutingModule : public RoutingModule +{ + public: + void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, + bool ackWantsAck = false) override + { + ackNaks.emplace_back(err, to, idFrom, chIndex, hopLimit, ackWantsAck); + } + + std::list> ackNaks; +}; + +class ScopedAirTimeFixture +{ + public: + ScopedAirTimeFixture() : previous(airTime) { airTime = &instance; } + ~ScopedAirTimeFixture() { airTime = previous; } + + private: + AirTime instance; + AirTime *previous; +}; + +static MockNodeDB *mockNodeDB = nullptr; +static ReliableRouterTestShim *reliableShim = nullptr; +static TimedCaptureRadio *radio = nullptr; +static MockRoutingModule *mockRoutingModule = nullptr; +static std::unique_ptr airTimeFixture; +static PacketId nextTestPacketId = 0x7A000000; + +// --------------------------------------------------------------------------- +// Packet builders +// --------------------------------------------------------------------------- + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum portnum, NodeNum from, NodeNum to, uint8_t channel, + bool wantAck = false) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = to; + p.id = nextTestPacketId++; + p.channel = channel; + p.hop_start = 3; + p.hop_limit = 3; // hop_start == hop_limit -> getHopsAway() == 0 ("heard directly") + p.relay_node = 0x22; + p.next_hop = NO_NEXT_HOP_PREFERENCE; + p.want_ack = wantAck; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = portnum; + return p; +} + +static meshtastic_MeshPacket makeEncryptedToUs(uint8_t channel, bool wantAck) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = kRemoteNode; + p.to = kLocalNode; + p.id = nextTestPacketId++; + p.channel = channel; + p.hop_start = 3; + p.hop_limit = 3; + p.relay_node = 0x22; + p.next_hop = NO_NEXT_HOP_PREFERENCE; + p.want_ack = wantAck; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 32; + return p; +} + +static void expectSingleAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId id, ChannelIndex chIndex, uint8_t hopLimit, + bool ackWantsAck) +{ + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + const auto &ack = mockRoutingModule->ackNaks.front(); + TEST_ASSERT_EQUAL_INT(err, std::get<0>(ack)); + TEST_ASSERT_EQUAL_HEX32(to, std::get<1>(ack)); + TEST_ASSERT_EQUAL_HEX32(id, std::get<2>(ack)); + TEST_ASSERT_EQUAL_UINT8(chIndex, std::get<3>(ack)); + TEST_ASSERT_EQUAL_UINT8(hopLimit, std::get<4>(ack)); + TEST_ASSERT_EQUAL(ackWantsAck, std::get<5>(ack)); +} + +static void configureChannels() +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel primary = meshtastic_Channel_init_default; + primary.index = 0; + primary.has_settings = true; + primary.role = meshtastic_Channel_Role_PRIMARY; + strncpy(primary.settings.name, "primary", sizeof(primary.settings.name) - 1); + + meshtastic_Channel secondary = meshtastic_Channel_init_default; + secondary.index = 1; + secondary.has_settings = true; + secondary.role = meshtastic_Channel_Role_SECONDARY; + strncpy(secondary.settings.name, "second", sizeof(secondary.settings.name) - 1); + secondary.settings.psk.size = 32; + memset(secondary.settings.psk.bytes, 0xAB, secondary.settings.psk.size); + + channelFile.channels[0] = primary; + channelFile.channels[1] = secondary; + channels.onConfigChanged(); +} + +void setUp(void) +{ + myNodeInfo.my_node_num = kLocalNode; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + config.lora.override_duty_cycle = true; + config.lora.hop_limit = 3; // keep getHopLimitForResponse() deterministic across tests + config.security.private_key.size = 0; + owner.is_licensed = false; + // Keep our own key unset: the PKI_UNKNOWN_PUBKEY NAK handler dereferences nodeInfoModule (a null + // global here) only when owner.public_key.size == 32. + owner.public_key.size = 0; + mockNodeDB->clearTestNodes(); + reliableShim->clearPendingForTest(); + reliableShim->resetRouteHealthForTest(); + radio->reset(); + mockRoutingModule->ackNaks.clear(); + configureChannels(); +} + +void tearDown(void) {} + +// =========================================================================== +// Group 1 - want_ack ACK variants (decoded packets to us) +// =========================================================================== + +void test_text_dm_want_ack_gets_want_ack_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + TEST_ASSERT_NOT_EQUAL(0, expectedHop); // must be distinguishable from the 0-hop ACK branch + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/true); +} + +void test_text_reply_still_gets_want_ack_ack(void) +{ + // shouldSuccessAckWithWantAck() runs before the response branch, so a text DM that is itself a + // reply still gets the reliable want-ack ACK (not the 0-hop response treatment). + auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.reply_id = 0x1234; + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/true); +} + +void test_nontext_dm_want_ack_gets_plain_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + TEST_ASSERT_NOT_EQUAL(0, expectedHop); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/false); +} + +void test_response_heard_directly_gets_zero_hop_ack(void) +{ + // A response (request_id set) heard at 0 hops: the original sender cannot overhear an implicit + // ACK, so we ACK - but only with hop limit 0. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.request_id = 0x4242; + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); +} + +void test_response_relayed_gets_no_ack(void) +{ + // A relayed response with no next-hop addressing already got its implicit ACK from the + // rebroadcast; ACKing again would only burn airtime. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.request_id = 0x4242; + p.hop_limit = 2; // hop_start 3 -> 1 hop away + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +void test_response_relayed_via_next_hop_gets_zero_hop_ack(void) +{ + // Relayed, but directed at a next_hop: the immediate relayer retransmits until stopped, so a + // 0-hop ACK is still required. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.request_id = 0x4242; + p.hop_limit = 2; + p.next_hop = 0x77; + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); +} + +void test_broadcast_want_ack_gets_no_ack(void) +{ + // 0-hop reliability is unicast-only: a want_ack broadcast is never ACKed (isToUs() is false). + auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, NODENUM_BROADCAST, 0, /*wantAck=*/true); + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +// =========================================================================== +// Group 2 - undecodable want_ack NAKs (encrypted packets to us) +// =========================================================================== + +void test_pki_unknown_sender_gets_pki_unknown_pubkey_nak(void) +{ + // channel==0 + sender absent from NodeDB -> the PKI key-amnesia NAK, on the primary channel. + auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +void test_pki_keyless_sender_record_gets_pki_unknown_pubkey_nak(void) +{ + // The sender is in the DB but we hold no key for it - same NAK as a fully unknown node. + mockNodeDB->addNode(kRemoteNode, /*publicKeySize=*/0); + auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +void test_pki_known_key_sender_gets_no_channel_nak(void) +{ + // Discriminator: with the sender's key on hand an undecodable channel-0 want_ack packet is NOT a + // key problem, so it falls through to the generic NO_CHANNEL NAK. + mockNodeDB->addNode(kRemoteNode, /*publicKeySize=*/32); + auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NO_CHANNEL, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +void test_unknown_channel_hash_gets_no_channel_nak(void) +{ + // Nonzero channel hash we cannot decode -> NO_CHANNEL on the primary channel (not the hash). + auto p = makeEncryptedToUs(/*channel=*/0x5A, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NO_CHANNEL, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +// =========================================================================== +// Group 3 - no want_ack, but we are the addressed next hop +// =========================================================================== + +void test_next_hop_addressed_to_us_gets_zero_hop_ack(void) +{ + // We were the addressed next hop: a 0-hop ACK stops the relayer's retransmissions even though + // the packet itself did not ask for an ACK. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + p.next_hop = 0x11; // our last byte + p.hop_limit = 1; + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); +} + +void test_next_hop_with_hop_limit_zero_gets_no_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + p.next_hop = 0x11; + p.hop_limit = 0; + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +void test_next_hop_other_byte_gets_no_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + p.next_hop = 0x22; // someone else's byte + p.hop_limit = 1; + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +// =========================================================================== +// Group 4 - explicit ACK/NAK vs pending retransmissions, MQTT gate, route health +// =========================================================================== + +void test_explicit_ack_stops_retransmissions_and_clears_route_failures(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + reliableShim->noteRouteLearned(kRemoteNode, 0xAB, millis()); + reliableShim->noteRouteFailure(kRemoteNode); + reliableShim->noteRouteFailure(kRemoteNode); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + + auto ack = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + ack.decoded.request_id = original.id; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_NONE; + + reliableShim->sniffForTest(&ack, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); + // The end-to-end ACK proves the route to its sender works -> noteRouteSuccess clears failures. + RouteHealth *h = reliableShim->findRouteHealth(kRemoteNode); + TEST_ASSERT_NOT_NULL(h); + TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures); +} + +void test_nak_stops_retransmissions_but_keeps_route_failures(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + reliableShim->noteRouteLearned(kRemoteNode, 0xAB, millis()); + reliableShim->noteRouteFailure(kRemoteNode); + reliableShim->noteRouteFailure(kRemoteNode); + + auto nak = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + nak.decoded.request_id = original.id; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_MAX_RETRANSMIT; + + reliableShim->sniffForTest(&nak, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); + // A NAK is not a delivery success: the failure count must survive. + RouteHealth *h = reliableShim->findRouteHealth(kRemoteNode); + TEST_ASSERT_NOT_NULL(h); + TEST_ASSERT_EQUAL_UINT8(2, h->consecutiveFailures); +} + +void test_pki_unknown_pubkey_nak_stops_retransmissions(void) +{ + // The remote lost our key: its PKI_UNKNOWN_PUBKEY NAK must still clear the pending record. + // owner.public_key.size == 0 (setUp) keeps the NodeInfo re-send branch (a nodeInfoModule + // dereference, null in this harness) out of the path. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto nak = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + nak.decoded.request_id = original.id; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY; + + reliableShim->sniffForTest(&nak, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_own_ack_echo_via_mqtt_keeps_retransmissions(void) +{ + // An implicit ACK that is our own traffic echoed back via MQTT must not stop LoRa retries. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto echo = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kLocalNode, kLocalNode, 1); + echo.decoded.request_id = original.id; + echo.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + + reliableShim->sniffForTest(&echo, nullptr); + + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + TEST_ASSERT_TRUE(reliableShim->hasPending(kLocalNode, original.id)); +} + +void test_own_ack_echo_via_lora_stops_retransmissions(void) +{ + // Control for the MQTT gate: the identical from-us echo via LoRa does stop the retries. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto echo = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kLocalNode, kLocalNode, 1); + echo.decoded.request_id = original.id; + echo.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + + reliableShim->sniffForTest(&echo, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_remote_ack_via_mqtt_still_stops_retransmissions(void) +{ + // The gate is scoped to from-us echoes: a genuine end-to-end ACK arriving over MQTT counts. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto ack = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + ack.decoded.request_id = original.id; + ack.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_NONE; + + reliableShim->sniffForTest(&ack, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +// =========================================================================== +// Group 5 - implicit ACK for our own overheard DM through shouldFilterReceived. This is the +// pre-existing route (a decodable copy still in encrypted wire form reaches it); the #11502 +// opaque short-circuit is exercised separately in Group 5b. +// =========================================================================== + +void test_overheard_own_dm_rebroadcast_mints_implicit_ack(void) +{ + // The implicit ACK is minted from the header alone (from/id), so this route must work on a + // still-encrypted packet, and the LoRa copy stops the retransmissions. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero; + overheard.from = kLocalNode; + overheard.to = kRemoteNode; + overheard.id = original.id; + overheard.hop_start = 3; + overheard.hop_limit = 2; + overheard.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + overheard.encrypted.size = 32; + + reliableShim->filterForTest(&overheard); + + // ACK is addressed to us (so it reaches the phone) on the pending copy's channel. + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_overheard_own_dm_via_mqtt_acks_but_keeps_retransmissions(void) +{ + // The MQTT copy still surfaces "Delivered to mesh" but must not cancel the LoRa retries. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero; + overheard.from = kLocalNode; + overheard.to = kRemoteNode; + overheard.id = original.id; + overheard.hop_start = 3; + overheard.hop_limit = 2; + overheard.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + overheard.encrypted.size = 32; + + reliableShim->filterForTest(&overheard); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_overheard_foreign_packet_mints_no_implicit_ack(void) +{ + // Someone else's traffic must never mint an ACK, even with a colliding packet id. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + meshtastic_MeshPacket foreign = meshtastic_MeshPacket_init_zero; + foreign.from = kRemoteNode; + foreign.to = kThirdNode; + foreign.id = original.id; + foreign.hop_start = 3; + foreign.hop_limit = 2; + foreign.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + foreign.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + foreign.encrypted.size = 32; + + reliableShim->filterForTest(&foreign); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +// =========================================================================== +// Group 5b - the real #11502 wiring: an overheard own DM under a channel hash we cannot decode +// (a PKI DM we sent) is OPAQUE_RELAY_ONLY in Router::perhapsHandleReceived and returns BEFORE +// shouldFilterReceived; the fix is the isFromUs branch there. Driven through the public ingress +// queue (enqueueReceivedMessage + runOnce), so deleting that branch fails these tests. +// =========================================================================== + +// An encrypted copy of our own DM under an unknown channel hash: not to us (no PKI attempt), no +// hash match -> DECODE_OPAQUE -> OPAQUE_RELAY_ONLY. hop_limit > 0 so the opaque relay does not +// short-circuit before the ACK branch. +static meshtastic_MeshPacket makeOpaqueOwnOverheard(PacketId id, meshtastic_MeshPacket_TransportMechanism transport) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = kLocalNode; + p.to = kRemoteNode; + p.id = id; + p.channel = 0x5A; + p.hop_start = 3; + p.hop_limit = 2; + p.transport_mechanism = transport; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 32; + memset(p.encrypted.bytes, 0xC3, p.encrypted.size); + return p; +} + +static void ingressOverheard(const meshtastic_MeshPacket &p) +{ + meshtastic_MeshPacket *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + reliableShim->enqueueReceivedMessage(copy); + reliableShim->runOnce(); +} + +void test_ingress_opaque_own_dm_lora_mints_implicit_ack_and_stops_retries(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + ingressOverheard(makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA)); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_ingress_opaque_own_dm_mqtt_acks_but_keeps_retries(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + ingressOverheard(makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT)); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_ingress_opaque_foreign_packet_mints_no_implicit_ack(void) +{ + // The isFromUs guard on the opaque branch: someone else's opaque traffic with a colliding id + // is relayed but never ACKed. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto foreign = makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA); + foreign.from = kRemoteNode; + foreign.to = kThirdNode; + ingressOverheard(foreign); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +// =========================================================================== +// Group 6 - pending-timer airtime extension in send() and shouldFilterReceived() +// =========================================================================== + +void test_send_extends_other_pending_deadlines_not_own(void) +{ + // While we transmit packet B we cannot hear an (implicit) ACK for pending A, so A's deadline + // must move out by B's airtime. B's own fresh record must not be self-extended. + radio->packetTimeMsec = 50000; // dwarfs any real time elapsed inside the test + + auto a = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(a, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + uint32_t aBefore = reliableShim->pendingNextTx(kLocalNode, a.id); + + auto b = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, NODENUM_BROADCAST, 0, /*wantAck=*/true); + auto *allocated = packetPool.allocCopy(b); + TEST_ASSERT_NOT_NULL(allocated); + TEST_ASSERT_EQUAL_INT(ERRNO_OK, reliableShim->send(allocated)); + + TEST_ASSERT_EQUAL_UINT32(2, reliableShim->pendingCount()); + TEST_ASSERT_EQUAL_UINT32(aBefore + 50000, reliableShim->pendingNextTx(kLocalNode, a.id)); + + // B's deadline is millis-at-set + getRetransmissionMsec(B); a self-extension would push it a + // further 50s out, past anything the wall clock could account for. + uint32_t bTx = reliableShim->pendingNextTx(kLocalNode, b.id); + uint32_t retrans = radio->getRetransmissionMsec(reliableShim->pendingPacket(kLocalNode, b.id)); + // Via Throttle rather than a bare millis() compare, per the house deadline rule. + TEST_ASSERT_TRUE_MESSAGE(Throttle::deadlinePassed(bTx - retrans), "own record must not be extended by its own send"); +} + +void test_receive_extends_all_pending_deadlines(void) +{ + // While receiving any packet we cannot hear an ACK either: every pending deadline moves out by + // the received packet's airtime. + radio->packetTimeMsec = 40000; + + auto a = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(a, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + auto b = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kThirdNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(b, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + uint32_t aBefore = reliableShim->pendingNextTx(kLocalNode, a.id); + uint32_t bBefore = reliableShim->pendingNextTx(kLocalNode, b.id); + + auto inbound = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + reliableShim->filterForTest(&inbound); + + TEST_ASSERT_EQUAL_UINT32(aBefore + 40000, reliableShim->pendingNextTx(kLocalNode, a.id)); + TEST_ASSERT_EQUAL_UINT32(bBefore + 40000, reliableShim->pendingNextTx(kLocalNode, b.id)); +} + +// =========================================================================== + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + airTimeFixture = std::make_unique(); + mockNodeDB = new MockNodeDB(); + nodeDB = mockNodeDB; + reliableShim = new ReliableRouterTestShim(); + + auto capture = std::make_unique(); + radio = capture.get(); + reliableShim->addInterface(std::move(capture)); + + mockRoutingModule = new MockRoutingModule(); + routingModule = mockRoutingModule; + + printf("\n=== want_ack ACK variants ===\n"); + RUN_TEST(test_text_dm_want_ack_gets_want_ack_ack); + RUN_TEST(test_text_reply_still_gets_want_ack_ack); + RUN_TEST(test_nontext_dm_want_ack_gets_plain_ack); + RUN_TEST(test_response_heard_directly_gets_zero_hop_ack); + RUN_TEST(test_response_relayed_gets_no_ack); + RUN_TEST(test_response_relayed_via_next_hop_gets_zero_hop_ack); + RUN_TEST(test_broadcast_want_ack_gets_no_ack); + + printf("\n=== undecodable want_ack NAKs ===\n"); + RUN_TEST(test_pki_unknown_sender_gets_pki_unknown_pubkey_nak); + RUN_TEST(test_pki_keyless_sender_record_gets_pki_unknown_pubkey_nak); + RUN_TEST(test_pki_known_key_sender_gets_no_channel_nak); + RUN_TEST(test_unknown_channel_hash_gets_no_channel_nak); + + printf("\n=== next-hop 0-hop ACK without want_ack ===\n"); + RUN_TEST(test_next_hop_addressed_to_us_gets_zero_hop_ack); + RUN_TEST(test_next_hop_with_hop_limit_zero_gets_no_ack); + RUN_TEST(test_next_hop_other_byte_gets_no_ack); + + printf("\n=== ACK/NAK vs pending retransmissions ===\n"); + RUN_TEST(test_explicit_ack_stops_retransmissions_and_clears_route_failures); + RUN_TEST(test_nak_stops_retransmissions_but_keeps_route_failures); + RUN_TEST(test_pki_unknown_pubkey_nak_stops_retransmissions); + RUN_TEST(test_own_ack_echo_via_mqtt_keeps_retransmissions); + RUN_TEST(test_own_ack_echo_via_lora_stops_retransmissions); + RUN_TEST(test_remote_ack_via_mqtt_still_stops_retransmissions); + + printf("\n=== implicit ACK for our own overheard DM ===\n"); + RUN_TEST(test_overheard_own_dm_rebroadcast_mints_implicit_ack); + RUN_TEST(test_overheard_own_dm_via_mqtt_acks_but_keeps_retransmissions); + RUN_TEST(test_overheard_foreign_packet_mints_no_implicit_ack); + + printf("\n=== implicit ACK through the opaque ingress short-circuit (#11502) ===\n"); + RUN_TEST(test_ingress_opaque_own_dm_lora_mints_implicit_ack_and_stops_retries); + RUN_TEST(test_ingress_opaque_own_dm_mqtt_acks_but_keeps_retries); + RUN_TEST(test_ingress_opaque_foreign_packet_mints_no_implicit_ack); + + printf("\n=== pending-timer airtime extension ===\n"); + RUN_TEST(test_send_extends_other_pending_deadlines_not_own); + RUN_TEST(test_receive_extends_all_pending_deadlines); + + int result = UNITY_END(); + airTimeFixture.reset(); + exit(result); +} + +void loop() {} diff --git a/test/test_rolling_counter/test_main.cpp b/test/test_rolling_counter/test_main.cpp new file mode 100644 index 0000000000..e8704e0c3f --- /dev/null +++ b/test/test_rolling_counter/test_main.cpp @@ -0,0 +1,142 @@ +// Unit tests for RollingCounter. The case that matters is the span sum() covers: an +// under-sized ring reports WindowMs - BucketMs, and counting the edge bucket whole reports more. +#include "Arduino.h" +#include "TestUtil.h" +#include "UptimeClock.h" +#include "modules/Telemetry/Sensor/RollingCounter.h" +#include + +static constexpr uint32_t kMinute = 60UL * 1000; +static constexpr uint32_t kWindow = 60 * kMinute; +static constexpr uint32_t kBucket = 5 * kMinute; + +using Counter = RollingCounter; + +void setUp() +{ + Time::setTestMillis(1000); +} + +void tearDown() +{ + Time::useRealClock(); +} + +// Everything added inside the window is still counted at the far edge. +void test_counts_within_window() +{ + Counter c; + for (int i = 0; i < 10; i++) { + c.add(); + Time::advanceTestMillis(kMinute); + } + TEST_ASSERT_EQUAL_UINT32(10, c.sum()); +} + +// Expiry is exact to one bucket, not to the event: nothing records where inside a bucket an event +// fell, so it is wholly counted to WindowMs, wholly gone by WindowMs + BucketMs, decaying between. +void test_expires_within_one_bucket_of_the_hour() +{ + Counter c; + c.add(100); + + Time::advanceTestMillis(kWindow - kMinute); + TEST_ASSERT_EQUAL_UINT32(100, c.sum()); // 59 minutes old, wholly inside + + uint32_t previous = 100; + for (int i = 0; i < 7; i++) { // walk a full bucket past the hour + Time::advanceTestMillis(kMinute); + uint32_t current = c.sum(); + TEST_ASSERT_LESS_OR_EQUAL_UINT32(previous, current); // decays, never grows back + previous = current; + } + TEST_ASSERT_EQUAL_UINT32(0, previous); +} + +// The span must not shrink to 55 minutes as the current bucket fills. One event per +// minute for well over an hour means a correct 60-minute window always holds 60. +void test_span_stays_sixty_minutes() +{ + Counter c; + for (int i = 0; i < 60; i++) { + c.add(); + Time::advanceTestMillis(kMinute); + } + // Steady state: sample at every minute across two more bucket widths. A ring that + // under-covers dips to 55, one that over-covers climbs to 65. + for (int i = 0; i < 20; i++) { + TEST_ASSERT_EQUAL_UINT32(60, c.sum()); + c.add(); + Time::advanceTestMillis(kMinute); + } +} + +// Buckets must not be recycled while any part of them is still inside the window. +void test_bucket_not_dropped_early() +{ + Counter c; + c.add(7); // lands in the first bucket + + // Step to just under an hour in bucket-sized hops; the batch stays counted throughout. + for (uint32_t elapsed = 0; elapsed + kBucket < kWindow; elapsed += kBucket) { + Time::advanceTestMillis(kBucket); + TEST_ASSERT_EQUAL_UINT32(7, c.sum()); + } +} + +// Going quiet for longer than the ring leaves nothing behind, and the counter still works. +void test_long_idle_gap() +{ + Counter c; + c.add(3); + Time::advanceTestMillis(5 * kWindow); + TEST_ASSERT_EQUAL_UINT32(0, c.sum()); + + c.add(2); + TEST_ASSERT_EQUAL_UINT32(2, c.sum()); +} + +// A burst far larger than the bucket count still costs the same fixed memory, and is carried +// whole while it is inside the window. +void test_burst_survives_whole() +{ + Counter c; + c.add(50000); + Time::advanceTestMillis(kWindow - kMinute); + TEST_ASSERT_EQUAL_UINT32(50000, c.sum()); +} + +// Weighting the edge bucket must not overflow: 50000 * 240000 exceeds 32 bits, and a 32-bit +// product wraps to 11367 instead of 40000. Four of the bucket's five minutes are still inside. +void test_large_burst_at_window_edge() +{ + Counter c; + c.add(50000); + Time::advanceTestMillis(kWindow + kMinute); + TEST_ASSERT_EQUAL_UINT32(40000, c.sum()); +} + +void test_reset_clears() +{ + Counter c; + c.add(5); + c.reset(); + TEST_ASSERT_EQUAL_UINT32(0, c.sum()); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_counts_within_window); + RUN_TEST(test_expires_within_one_bucket_of_the_hour); + RUN_TEST(test_span_stays_sixty_minutes); + RUN_TEST(test_bucket_not_dropped_early); + RUN_TEST(test_long_idle_gap); + RUN_TEST(test_burst_survives_whole); + RUN_TEST(test_large_burst_at_window_edge); + RUN_TEST(test_reset_clears); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_routing_response_hops/test_main.cpp b/test/test_routing_response_hops/test_main.cpp new file mode 100644 index 0000000000..53bacd9793 --- /dev/null +++ b/test/test_routing_response_hops/test_main.cpp @@ -0,0 +1,273 @@ +// RoutingModule::getHopLimitForResponse - the hop budget stamped on every reply/ACK/NAK - and +// MeshModule::setReplyTo() applying it, driven through getHopsAway()'s sentinel rules. + +#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc. +#include "TestUtil.h" +#include // exit(), needed on both guard branches +#include + +// Event mode compiles out the uncapped long-path branch and swaps the configured limit for the +// event hop limit; this suite pins the standard-mode branches only (the event cap is covered by +// test_default's event-mode group). +#if !USERPREFS_EVENT_MODE + +#include "configuration.h" +#include "mesh/MeshModule.h" +#include "mesh/NodeDB.h" +#include "modules/RoutingModule.h" +#include + +static constexpr NodeNum kRequester = 0x22222222; + +static RoutingModule *testRoutingModule = nullptr; + +// A received request packet whose hop fields we control. Decoded packets carry the bitfield flag +// that getHopsAway() uses to decide whether hop_start==0 is genuine or a legacy-firmware zero. +static meshtastic_MeshPacket makeRequest(uint8_t hopStart, uint8_t hopLimit, bool decoded = true, bool hasBitfield = true) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = kRequester; + p.to = 0x11111111; + p.id = 0xABCD1234; + p.hop_start = hopStart; + p.hop_limit = hopLimit; + if (decoded) { + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.has_bitfield = hasBitfield; + } else { + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 8; + } + return p; +} + +static meshtastic_MeshPacket makeReply() +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + return p; +} + +void setUp(void) +{ + config.lora.hop_limit = 3; +} + +void tearDown(void) {} + +// =========================================================================== +// Group 1 - unknown hop distance: every unreliable-header shape must fall back +// to the configured limit, never to a value derived from the bogus fields. +// =========================================================================== + +void test_encrypted_hop_start_zero_falls_back_to_configured_limit(void) +{ + // Encrypted packet: the bitfield is unreadable, so hop_start==0 cannot be trusted. + auto request = makeRequest(0, 0, /*decoded=*/false); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_decoded_legacy_no_bitfield_falls_back_to_configured_limit(void) +{ + // Pre-2.3.0 senders never populate hop_start and pre-2.5.0 senders never set the bitfield. + auto request = makeRequest(0, 0, /*decoded=*/true, /*hasBitfield=*/false); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_forged_hop_start_below_hop_limit_falls_back_to_configured_limit(void) +{ + // hop_start < hop_limit is impossible for an honest sender; getHopsAway() rejects it. + auto request = makeRequest(2, 5); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_hostile_hop_start_wraps_negative_falls_back_to_configured_limit(void) +{ + // hop_start is 3 bits on the wire but 8 bits via local injection: 255 - 0 narrows to + // int8_t -1 in getHopsAway(), which lands in the same "unknown" fallback (any + // hop_start - hop_limit >= 128 reads as negative). + auto request = makeRequest(255, 0); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 2 - known hop distance: hopsUsed + 2 margin, its clamp boundary, and +// the intentionally uncapped long-path branch. +// =========================================================================== + +void test_direct_neighbor_response_gets_two_hop_margin(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(3, 3); // 0 hops used + TEST_ASSERT_EQUAL_UINT8(2, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_two_hops_used_gets_margin_of_two(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(3, 1); // 2 hops used + TEST_ASSERT_EQUAL_UINT8(4, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_margin_just_below_boundary_still_applies(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(7, 3); // 4 hops used: 4 + 2 = 6 < 7 + TEST_ASSERT_EQUAL_UINT8(6, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_margin_at_boundary_clamps_to_configured_limit(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(7, 2); // 5 hops used: 5 + 2 == 7, not < 7 -> clamp + TEST_ASSERT_EQUAL_UINT8(7, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_hops_equal_to_limit_returns_limit(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(7, 0); // 7 hops used == limit: not "more than", no margin room + TEST_ASSERT_EQUAL_UINT8(7, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_long_path_exceeds_configured_limit_uncapped(void) +{ + // Intentional exceed: a request that took more hops than our configured limit gets a + // response with the same hop count, otherwise the reply dies short of the requester. + auto request = makeRequest(7, 0); // 7 hops used, configured limit 3 + TEST_ASSERT_EQUAL_UINT8(7, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 3 - zero-hop requester +// =========================================================================== + +void test_zero_hop_requester_gets_zero_hop_response(void) +{ + // hop_start==0 with the bitfield present is a genuine "0 hops requested": the sender is + // modern firmware that deliberately sent direct-only, so the response stays local too. + auto request = makeRequest(0, 0, /*decoded=*/true, /*hasBitfield=*/true); + TEST_ASSERT_EQUAL_UINT8(0, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 4 - configured-limit edges through Default::getConfiguredOrDefaultHopLimit +// =========================================================================== + +void test_config_above_hop_max_clamps_to_hop_max(void) +{ + config.lora.hop_limit = 10; // out-of-range config (protobuf allows up to 255) + auto request = makeRequest(0, 0, /*decoded=*/false); + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_config_zero_yields_zero_for_unknown_hops(void) +{ + // Pins current behavior: getConfiguredOrDefaultHopLimit(0) passes the zero through (no + // default substitution), so an unknown-distance requester gets a 0-hop response. + config.lora.hop_limit = 0; + auto request = makeRequest(0, 0, /*decoded=*/false); + TEST_ASSERT_EQUAL_UINT8(0, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_config_zero_known_hops_returns_hops_used(void) +{ + // With a zero configured limit, any known hop count is "more than the limit" and is used + // as-is - a zero config does not strand replies to multi-hop requesters. + config.lora.hop_limit = 0; + auto request = makeRequest(3, 1); // 2 hops used + TEST_ASSERT_EQUAL_UINT8(2, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 5 - setReplyTo() stamps the computed hop limit onto reply packets +// =========================================================================== + +void test_setreplyto_stamps_computed_hop_limit_and_reply_fields(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(3, 1); // 2 hops used -> response hop limit 4 + request.channel = 2; + request.want_ack = true; + + auto reply = makeReply(); + setReplyTo(&reply, request); + + TEST_ASSERT_EQUAL_HEX32(kRequester, reply.to); + TEST_ASSERT_EQUAL_UINT8(2, reply.channel); + TEST_ASSERT_EQUAL_UINT8(4, reply.hop_limit); + TEST_ASSERT_TRUE(reply.want_ack); + TEST_ASSERT_EQUAL_HEX32(request.id, reply.decoded.request_id); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_Priority_RELIABLE, reply.priority); +} + +void test_setreplyto_preserves_existing_priority(void) +{ + auto request = makeRequest(0, 0, /*decoded=*/false); // unknown hops -> configured limit 3 + request.want_ack = false; + + auto reply = makeReply(); + reply.priority = meshtastic_MeshPacket_Priority_ACK; + setReplyTo(&reply, request); + + TEST_ASSERT_EQUAL_UINT8(3, reply.hop_limit); + TEST_ASSERT_FALSE(reply.want_ack); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_Priority_ACK, reply.priority); +} + +// =========================================================================== + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + testRoutingModule = new RoutingModule(); + routingModule = testRoutingModule; // setReplyTo() reaches the module through the global + + printf("\n=== unknown hop distance falls back to configured limit ===\n"); + RUN_TEST(test_encrypted_hop_start_zero_falls_back_to_configured_limit); + RUN_TEST(test_decoded_legacy_no_bitfield_falls_back_to_configured_limit); + RUN_TEST(test_forged_hop_start_below_hop_limit_falls_back_to_configured_limit); + RUN_TEST(test_hostile_hop_start_wraps_negative_falls_back_to_configured_limit); + + printf("\n=== known hop distance: margin, clamp, uncapped long path ===\n"); + RUN_TEST(test_direct_neighbor_response_gets_two_hop_margin); + RUN_TEST(test_two_hops_used_gets_margin_of_two); + RUN_TEST(test_margin_just_below_boundary_still_applies); + RUN_TEST(test_margin_at_boundary_clamps_to_configured_limit); + RUN_TEST(test_hops_equal_to_limit_returns_limit); + RUN_TEST(test_long_path_exceeds_configured_limit_uncapped); + + printf("\n=== zero-hop requester ===\n"); + RUN_TEST(test_zero_hop_requester_gets_zero_hop_response); + + printf("\n=== configured-limit edges ===\n"); + RUN_TEST(test_config_above_hop_max_clamps_to_hop_max); + RUN_TEST(test_config_zero_yields_zero_for_unknown_hops); + RUN_TEST(test_config_zero_known_hops_returns_hops_used); + + printf("\n=== setReplyTo integration ===\n"); + RUN_TEST(test_setreplyto_stamps_computed_hop_limit_and_reply_fields); + RUN_TEST(test_setreplyto_preserves_existing_priority); + + exit(UNITY_END()); +} + +void loop() {} + +#else // USERPREFS_EVENT_MODE + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +void loop() {} + +#endif diff --git a/test/test_rtc/test_main.cpp b/test/test_rtc/test_main.cpp index 02cad01c40..c03358d0af 100644 --- a/test/test_rtc/test_main.cpp +++ b/test/test_rtc/test_main.cpp @@ -1,5 +1,7 @@ #include "TestUtil.h" +#include "UptimeClock.h" #include "gps/RTC.h" +#include #include #include #include @@ -16,12 +18,51 @@ static const uint32_t kAllowedDriftSeconds = 2; static const time_t kUptimeSeconds = 21; // what gettimeofday() returns on RP2040 without a real clock +// Mirrors FORTY_YEARS in RTC.h, which is only visible when BUILD_EPOCH is defined. BUILD_EPOCH is +// injected by bin/platformio-custom.py into the src/ build (projenv) but not into test sources, so +// this TU cannot #ifdef on it; the bounds tests below probe for it at runtime instead. +static const uint64_t kFortyYears = 40ULL * 365 * SEC_PER_DAY; + +#define MSG_BUF_LEN 200 +#define TEST_MSG_FMT(fmt, ...) \ + do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + // A clearly-valid wall-clock epoch, safely inside any BUILD_EPOCH validity window. static time_t makeValidEpoch() { return time(NULL) + SEC_PER_DAY; } +static struct timeval makeTv(time_t secs) +{ + struct timeval tv; + tv.tv_sec = secs; + tv.tv_usec = 0; + return tv; +} + +// Freeze the injected uptime clock at baseMs. perhapsSetRTC() anchors timeStartMs64 at the fake +// "now", so while the clock is frozen getTime() returns the applied epoch exactly - no drift +// tolerance needed. Reset the wrap carry first: a prior test may have published a larger instant, +// and stepping the clock backwards past a published snapshot reads as a ~49.7-day wrap. +static void beginFakeClock(uint32_t baseMs) +{ + Time::resetMonotonicForTests(); + Time::setTestMillis(baseMs); + Time::serviceMonotonic(); +} + +// Step the injected clock the way the firmware does: every advance is followed by a publish. +static void advanceFakeClock(uint32_t deltaMs) +{ + Time::advanceTestMillis(deltaMs); + Time::serviceMonotonic(); +} + void setUp(void) { resetRTCStateForTests(); @@ -29,6 +70,8 @@ void setUp(void) void tearDown(void) { + Time::useRealClock(); // don't leak the fake clock into later tests or other suites + Time::resetMonotonicForTests(); resetRTCStateForTests(); } @@ -68,6 +111,316 @@ static void test_readFromRTC_initializes_time_when_no_better_source(void) TEST_ASSERT_UINT32_WITHIN(kAllowedDriftSeconds, (uint32_t)systemEpoch, getTime()); } +// --- perhapsSetRTC(timeval) quality arbitration --- + +// FromNet/Device sources are always rejected below a higher quality, and the rejection must +// leave quality and the running clock untouched (the #9828 mesh-time-poisoning family). NTP +// below GPS is rejected only while the 30-min drift throttle (stamped by the GPS set) is live; +// after it expires, NTP deliberately replaces even GPS-quality time (RTC.cpp drift-correction +// branch) - both halves are pinned here. +static void test_downgrade_rejected_state_untouched(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); + + struct timeval poison = makeTv(gpsEpoch + 777); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &poison)); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityDevice, &poison)); + // NTP below GPS: within 30 minutes of the GPS set (which stamped the drift throttle), rejected. + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityNTP, &poison)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + + // Time still tracks the GPS epoch, not the rejected one. + advanceFakeClock(5 * 1000); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 5, getTime()); + + // Once the drift throttle expires, NTP replaces GPS-quality time on purpose (drift + // correction), while FromNet/Device stay rejected: the throttle escape is NTP-only. + advanceFakeClock(31 * 60 * 1000); + struct timeval stillPoison = makeTv(gpsEpoch + 555); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &stillPoison)); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityDevice, &stillPoison)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + struct timeval drift = makeTv(gpsEpoch + 999); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &drift)); + TEST_ASSERT_EQUAL_INT(RTCQualityNTP, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 999, getTime()); +} + +// Equal-quality FromNet has no reapply branch: the second set is ignored. +static void test_equal_quality_fromnet_is_not_reapplied(void) +{ + beginFakeClock(60 * 1000); + const time_t firstEpoch = makeValidEpoch(); + struct timeval tv = makeTv(firstEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + + struct timeval second = makeTv(firstEpoch + 500); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &second)); + TEST_ASSERT_EQUAL_INT(RTCQualityFromNet, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch, getTime()); +} + +// Our own GPS is authoritative: a GPS-quality set is always applied, with no throttle. +static void test_gps_reapply_always_accepted(void) +{ + beginFakeClock(60 * 1000); + const time_t firstEpoch = makeValidEpoch(); + struct timeval tv = makeTv(firstEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct timeval second = makeTv(firstEpoch + 123); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &second)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 123, getTime()); +} + +// Equal-quality NTP reapplies only after the 30-minute drift-correction throttle. +static void test_ntp_drift_throttle(void) +{ + beginFakeClock(120 * 1000); + const time_t firstEpoch = makeValidEpoch(); + struct timeval tv = makeTv(firstEpoch); + // The upgrade from None stamps the (function-static, not reset by resetRTCStateForTests) + // throttle timestamp at a known fake instant, keeping this test order-independent. + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &tv)); + + advanceFakeClock(10 * 60 * 1000); // +10 min: still inside the throttle window + struct timeval second = makeTv(firstEpoch + 900); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityNTP, &second)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 600, getTime()); + + advanceFakeClock(21 * 60 * 1000); // total +31 min: past the window + struct timeval third = makeTv(firstEpoch + 2000); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &third)); + TEST_ASSERT_EQUAL_INT(RTCQualityNTP, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 2000, getTime()); +} + +// forceUpdate applies the incoming time even when it is a quality downgrade - the T-Watch +// RTC-pause workaround depends on this override. +static void test_force_update_overrides_downgrade(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct timeval forced = makeTv(gpsEpoch + 42); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityDevice, &forced, true)); + TEST_ASSERT_EQUAL_INT(RTCQualityDevice, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 42, getTime()); +} + +// The BUILD_EPOCH validity window rejects implausible epochs before quality arbitration - even +// with forceUpdate - and leaves state untouched. BUILD_EPOCH is not visible to this TU (see +// kFortyYears above), so probe at runtime whether RTC.cpp was built with the window enabled. +static void test_build_epoch_bounds_rejected(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct timeval ancient = makeTv(1000000); // Jan 1970: below any plausible build epoch + RTCSetResult probe = perhapsSetRTC(RTCQualityGPS, &ancient); + if (probe == RTCSetResultSuccess) { + TEST_IGNORE_MESSAGE("BUILD_EPOCH not defined in the RTC.cpp build; validity window inactive"); + } + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, probe); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); + + // BUILD_EPOCH <= time(NULL) at run time, so this is strictly beyond BUILD_EPOCH + FORTY_YEARS. + struct timeval far = makeTv((time_t)((uint64_t)time(NULL) + kFortyYears + 2 * SEC_PER_DAY)); + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, &far)); + + // The window is checked before the forceUpdate override: force cannot smuggle in garbage. + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, &ancient, true)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); +} + +// --- perhapsSetRTC(tm) overload --- + +// The tm overload converts via gm_mktime and lands on the timeval path: a valid broken-down UTC +// time round-trips to the exact epoch (host gmtime() is the independent inverse). +static void test_tm_overload_roundtrip(void) +{ + beginFakeClock(60 * 1000); + const time_t epoch = makeValidEpoch(); + struct tm t = *gmtime(&epoch); + + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, t)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)epoch, getTime()); +} + +// Implausible years are rejected with state untouched. On BUILD_EPOCH builds the validity window +// fires first, on windowless builds the tm_year guard (<0 or >=300) does; either way the caller +// must see RTCSetResultInvalidTime. +static void test_tm_overload_year_guard(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct tm farFuture = {}; + farFuture.tm_year = 300; // year 2200 + farFuture.tm_mon = 5; + farFuture.tm_mday = 15; + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, farFuture)); + + struct tm preEpoch = {}; + preEpoch.tm_year = -5; // year 1895 + preEpoch.tm_mon = 0; + preEpoch.tm_mday = 1; + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, preEpoch)); + + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); +} + +// --- getValidTime() threshold gating --- + +static void test_getvalidtime_threshold_gating(void) +{ + beginFakeClock(60 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityDevice)); + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityFromNet)); + + const time_t netEpoch = makeValidEpoch(); + struct timeval tv = makeTv(netEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch, getValidTime(RTCQualityFromNet)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch, getValidTime(RTCQualityDevice)); // at-or-below passes + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityNTP)); + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityGPS)); + + struct timeval gps = makeTv(netEpoch + 60); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &gps)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch + 60, getValidTime(RTCQualityGPS)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch + 60, getValidTime(RTCQualityNTP)); +} + +// --- lastSetFromPhoneNtpOrGps stamp --- + +// Stamped only for quality >= NTP: this is the input PositionModule::hasQualityTimesource() uses +// to gate mesh-time acceptance, so a FromNet or Device set must never refresh it. +static void test_lastSetFromPhoneNtpOrGps_stamp(void) +{ + beginFakeClock(200 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps); + + const time_t epoch = makeValidEpoch(); + struct timeval tv = makeTv(epoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps); // FromNet does not stamp + + advanceFakeClock(1000); + struct timeval ntp = makeTv(epoch + 1); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &ntp)); + TEST_ASSERT_EQUAL_UINT32(201 * 1000, lastSetFromPhoneNtpOrGps); + + advanceFakeClock(2000); + struct timeval net = makeTv(epoch + 3); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &net)); + TEST_ASSERT_EQUAL_UINT32(201 * 1000, lastSetFromPhoneNtpOrGps); // rejection leaves the stamp + + advanceFakeClock(3000); + struct timeval gps = makeTv(epoch + 6); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &gps)); + TEST_ASSERT_EQUAL_UINT32(206 * 1000, lastSetFromPhoneNtpOrGps); + + // Device-quality set from a clean slate: applied, but still no stamp. + resetRTCStateForTests(); + struct timeval dev = makeTv(epoch + 9); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityDevice, &dev)); + TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps); +} + +// --- gm_mktime known answers --- + +// Hardcoded expected epochs (no host timegm dependence). The native build compiles the hand-rolled +// UTC path (!MESHTASTIC_EXCLUDE_TZ), so these pin its leap-day and century rules directly. +static void test_gm_mktime_known_epochs(void) +{ + struct KnownAnswer { + int year, mon1, mday, hour, min, sec; // human calendar: year AD, month 1-12 + int64_t expected; + }; + static const KnownAnswer cases[] = { + {1970, 1, 1, 0, 0, 0, 0LL}, + {1970, 3, 1, 0, 0, 0, 5097600LL}, // non-leap February + {1972, 2, 29, 0, 0, 0, 68169600LL}, // first leap day after the epoch + {1999, 12, 31, 23, 59, 59, 946684799LL}, // second before Y2K + {2000, 1, 1, 0, 0, 0, 946684800LL}, + {2000, 2, 29, 12, 0, 0, 951825600LL}, // 400-year-rule leap day + {2000, 3, 1, 0, 0, 0, 951868800LL}, + {2023, 2, 28, 23, 59, 59, 1677628799LL}, // last second of a non-leap February + {2024, 2, 29, 0, 0, 0, 1709164800LL}, + {2024, 3, 1, 0, 0, 0, 1709251200LL}, + {2038, 1, 19, 3, 14, 7, 2147483647LL}, // INT32_MAX second + {2038, 1, 19, 3, 14, 8, 2147483648LL}, // one past it: 64-bit time_t on native + {2100, 2, 28, 0, 0, 0, 4107456000LL}, // 2100 is NOT leap (100-year rule) + {2100, 3, 1, 0, 0, 0, 4107542400LL}, + {2400, 2, 29, 0, 0, 0, 13574563200LL}, // 2400 IS leap (400-year rule) + }; + + for (const KnownAnswer &c : cases) { + struct tm t = {}; + t.tm_year = c.year - 1900; + t.tm_mon = c.mon1 - 1; + t.tm_mday = c.mday; + t.tm_hour = c.hour; + t.tm_min = c.min; + t.tm_sec = c.sec; + const int64_t got = (int64_t)gm_mktime(&t); + if (got != c.expected) { + TEST_MSG_FMT("gm_mktime(%04d-%02d-%02d %02d:%02d:%02d) = %lld, expected %lld", c.year, c.mon1, c.mday, c.hour, c.min, + c.sec, (long long)got, (long long)c.expected); + } + TEST_ASSERT_EQUAL_INT64(c.expected, got); + } +} + +// February length as seen by gm_mktime for the years around each leap rule: Mar 1 minus Feb 28 +// is two days in a leap year and one day otherwise. Self-consistent, anchored by the known +// answers above. +static void test_gm_mktime_leap_rule_sweep(void) +{ + static const int leapYears[] = {1972, 2000, 2024, 2096, 2104, 2400}; // by-4 and by-400 + static const int nonLeapYears[] = {1970, 2023, 2100, 2200, 2300}; // odd years and by-100 + + for (int year : leapYears) { + struct tm feb28 = {}, mar1 = {}; + feb28.tm_year = year - 1900; + feb28.tm_mon = 1; + feb28.tm_mday = 28; + mar1.tm_year = year - 1900; + mar1.tm_mon = 2; + mar1.tm_mday = 1; + TEST_MSG_FMT("leap year %d", year); + TEST_ASSERT_EQUAL_INT64(2 * SEC_PER_DAY, (int64_t)gm_mktime(&mar1) - (int64_t)gm_mktime(&feb28)); + } + for (int year : nonLeapYears) { + struct tm feb28 = {}, mar1 = {}; + feb28.tm_year = year - 1900; + feb28.tm_mon = 1; + feb28.tm_mday = 28; + mar1.tm_year = year - 1900; + mar1.tm_mon = 2; + mar1.tm_mday = 1; + TEST_MSG_FMT("non-leap year %d", year); + TEST_ASSERT_EQUAL_INT64(SEC_PER_DAY, (int64_t)gm_mktime(&mar1) - (int64_t)gm_mktime(&feb28)); + } +} + void setup() { delay(10); @@ -76,6 +429,27 @@ void setup() UNITY_BEGIN(); RUN_TEST(test_readFromRTC_preserves_better_network_time); RUN_TEST(test_readFromRTC_initializes_time_when_no_better_source); + + printf("\n=== perhapsSetRTC(timeval) quality arbitration ===\n"); + RUN_TEST(test_downgrade_rejected_state_untouched); + RUN_TEST(test_equal_quality_fromnet_is_not_reapplied); + RUN_TEST(test_gps_reapply_always_accepted); + RUN_TEST(test_ntp_drift_throttle); + RUN_TEST(test_force_update_overrides_downgrade); + RUN_TEST(test_build_epoch_bounds_rejected); + + printf("\n=== perhapsSetRTC(tm) overload ===\n"); + RUN_TEST(test_tm_overload_roundtrip); + RUN_TEST(test_tm_overload_year_guard); + + printf("\n=== getValidTime / quality-source stamp ===\n"); + RUN_TEST(test_getvalidtime_threshold_gating); + RUN_TEST(test_lastSetFromPhoneNtpOrGps_stamp); + + printf("\n=== gm_mktime known answers ===\n"); + RUN_TEST(test_gm_mktime_known_epochs); + RUN_TEST(test_gm_mktime_leap_rule_sweep); + exit(UNITY_END()); } diff --git a/test/test_serial/SerialModule.cpp b/test/test_serial/SerialModule.cpp index 6539d0ad34..48808db855 100644 --- a/test/test_serial/SerialModule.cpp +++ b/test/test_serial/SerialModule.cpp @@ -2,6 +2,11 @@ #include "TestUtil.h" #include +// Required by Unity: PlatformIO's weak defaults do not link on MinGW (PE-COFF weak externals). +// Outside the guard below so both the portduino and the stub setup() get them. +void setUp(void) {} +void tearDown(void) {} + #ifdef ARCH_PORTDUINO #include "configuration.h" diff --git a/test/test_stream_api/test_main.cpp b/test/test_stream_api/test_main.cpp index 994e82c3d3..fdc87ab8e4 100644 --- a/test/test_stream_api/test_main.cpp +++ b/test/test_stream_api/test_main.cpp @@ -147,6 +147,26 @@ class PhoneAPITestShim : public PhoneAPI bool checkIsConnected() override { return true; } }; +/// Exposes the hasPendingOutput() inputs used by idle-sleep gating. +class PendingOutputStreamAPI : public StreamAPI +{ + public: + /// Construct the shim over a scripted stream. + explicit PendingOutputStreamAPI(Stream *stream) : StreamAPI(stream) {} + + /// Keep connection-timeout handling inactive during tests. + bool checkIsConnected() override { return true; } + + /// Set the transport-writability gate normally controlled by first client contact. + void setCanWrite(bool value) { canWrite = value; } + + bool retainedFrame = false; + + protected: + /// Report the scripted retained-frame state. + bool hasRetainedFrame() override { return retainedFrame; } +}; + /// Exposes framed-log hooks and records best-effort writes. class LogHookStreamAPI : public StreamAPI { @@ -538,7 +558,7 @@ static void queuePendingTimePlaceholderPacket(NodeNum from, uint32_t placeholder service->sendToPhone(packetPool.allocCopy(pending)); } -static void startHandshake(PhoneAPITestShim &api) +static void startHandshake(PhoneAPI &api) { meshtastic_ToRadio request = meshtastic_ToRadio_init_zero; request.which_payload_variant = meshtastic_ToRadio_want_config_id_tag; @@ -566,6 +586,58 @@ static bool drainHandshakeForPacketFrom(PhoneAPITestShim &api, NodeNum from, mes return false; } +// Scratch NodeDB for the config-dump stream; restored by tearDown() rather than RAII +// because a failed TEST_ASSERT longjmps out of the test without running destructors. +static NodeDB *scratchNodeDB = nullptr; +static NodeDB *savedNodeDB = nullptr; + +/// Install a scratch NodeDB; tearDown() restores the previous one after any test outcome. +static void installScratchNodeDB() +{ + savedNodeDB = nodeDB; + scratchNodeDB = new NodeDB(); + nodeDB = scratchNodeDB; +} + +// SerialConsole::runOnce gates its INT32_MAX idle sleep on hasPendingOutput(): pending while +// output is queued or retained (#11164 bounded drain), clear when drained or pre-contact. +static void test_stream_api_pending_output_tracks_queue_and_retained_frame(void) +{ + ScopedMeshService scopedService; + installScratchNodeDB(); + ScriptedStream stream; + PendingOutputStreamAPI api(&stream); + + // Nothing queued and no client yet: an idle console must be allowed to sleep. + TEST_ASSERT_FALSE(api.hasPendingOutput()); + + // A client that has not yet spoken (canWrite false) must not force polling, + // even with a full config dump queued behind the gate. + startHandshake(api); + api.setCanWrite(false); + TEST_ASSERT_FALSE(api.hasPendingOutput()); + + // Once writable, the queued dump is pending output until fully drained. + api.setCanWrite(true); + TEST_ASSERT_TRUE(api.hasPendingOutput()); + unsigned drained = 0; + for (unsigned i = 0; i < 512 && api.hasPendingOutput(); ++i) { + uint8_t responseBytes[meshtastic_FromRadio_size]; + if (api.getFromRadio(responseBytes) != 0) + drained++; + } + TEST_ASSERT_GREATER_THAN_UINT(0, drained); + TEST_ASSERT_FALSE_MESSAGE(api.hasPendingOutput(), "pending output must clear once the dump is drained"); + + // A transport-retained partial frame alone keeps the drain alive. + api.retainedFrame = true; + TEST_ASSERT_TRUE(api.hasPendingOutput()); + api.retainedFrame = false; + TEST_ASSERT_FALSE(api.hasPendingOutput()); + + api.close(); +} + /// Swaps in a scratch NodeDB and the injected clock, restoring both plus the RTC on destruction. /// Unity's TEST_ASSERT longjmps out on failure, so cleanup must not live at the end of the test. class ScopedTimeFixture @@ -715,8 +787,15 @@ static void test_node_heard_during_first_uptime_second_gets_last_heard_backfille /// Unity per-test setup; fixtures are local to each test. void setUp(void) {} -/// Unity per-test teardown; fixtures clean themselves up. -void tearDown(void) {} +/// Unity per-test teardown; restores state that a failed assert's longjmp would leak. +void tearDown(void) +{ + if (scratchNodeDB) { + nodeDB = savedNodeDB; + delete scratchNodeDB; + scratchNodeDB = nullptr; + } +} /// Initialize the native environment and run the stream regression suite. void setup() @@ -735,6 +814,7 @@ void setup() RUN_TEST(test_lockdown_admin_gate_ignores_wire_from); RUN_TEST(test_lockdown_admin_gate_rejects_undecodable_admin); RUN_TEST(test_want_config_includes_status_message_module_config); + RUN_TEST(test_stream_api_pending_output_tracks_queue_and_retained_frame); RUN_TEST(test_time_given_at_handshake_start_reconciles_queued_packet); RUN_TEST(test_time_given_at_handshake_end_does_not_rewrite_already_sent_packet); RUN_TEST(test_node_heard_before_time_gets_last_heard_backfilled); diff --git a/test/test_stream_framing/test_main.cpp b/test/test_stream_framing/test_main.cpp new file mode 100644 index 0000000000..99d5823f51 --- /dev/null +++ b/test/test_stream_framing/test_main.cpp @@ -0,0 +1,397 @@ +#include "MeshTypes.h" +#include "TestUtil.h" +#include "configuration.h" +#include "mesh/MeshService.h" +#include "mesh/StreamAPI.h" +#include +#include +#include +#include +#include +#include +#include + +// Framing constants mirrored from StreamAPI.cpp (defined only in that translation unit). +static constexpr uint8_t kStart1 = 0x94; +static constexpr uint8_t kStart2 = 0xc3; +static constexpr size_t kHeaderLen = 4; + +/// Input-scripted stream feeding queued bytes through the readStream() polling path. +class InputScriptedStream : public Stream +{ + public: + /// Report how many queued input bytes remain. + int available() override { return (int)input.size(); } + + /// Return the next queued byte as an unsigned value, or -1 when drained. + int read() override + { + if (input.empty()) + return -1; + int value = input.front(); + input.pop_front(); + return value; + } + + /// Return the next queued byte without consuming it. + int peek() override { return input.empty() ? -1 : input.front(); } + + /// Accept unlimited output; this suite only exercises the receive side. + int availableForWrite() override { return std::numeric_limits::max(); } + size_t write(uint8_t) override { return 1; } + size_t write(const uint8_t *, size_t size) override { return size; } + void flush() override {} + + /// Queue bytes for the next readStream() poll. + void feed(const std::vector &bytes) { input.insert(input.end(), bytes.begin(), bytes.end()); } + + std::deque input; +}; + +// The global `service` is installed in setUp() and restored in tearDown() rather than by RAII +// because a failed TEST_ASSERT longjmps out of the test without running destructors, which would +// leave `service` dangling for the rest of the suite. testService is intentionally never freed: +// it stays reachable through the static, so LeakSanitizer does not flag it. +static MeshService *testService = nullptr; +static MeshService *previousService = nullptr; + +/// Records every framed ToRadio payload the receive state machine delivers. +class FramingStreamAPIShim : public StreamAPI +{ + public: + /// Construct the shim over a scripted input stream. + explicit FramingStreamAPIShim(Stream *stream) : StreamAPI(stream) {} + + /// Keep connection-timeout handling inactive during tests. + bool checkIsConnected() override { return true; } + + /// Capture one delivered payload instead of running the real PhoneAPI decode. + bool handleToRadio(const uint8_t *buf, size_t len) override + { + deliveries.emplace_back(buf, buf + len); + return true; + } + + std::vector> deliveries; +}; + +/// Wrap a payload in the 0x94C3 big-endian-length stream framing. +static std::vector makeFrame(const std::vector &payload) +{ + std::vector frame = {kStart1, kStart2, (uint8_t)(payload.size() >> 8), (uint8_t)(payload.size() & 0xff)}; + frame.insert(frame.end(), payload.begin(), payload.end()); + return frame; +} + +/// Drive the buffer-fed receive path (SerialModule/native callers) with one burst. +static void feedBufferPath(FramingStreamAPIShim &api, const std::vector &bytes) +{ + std::vector copy = bytes; // runOncePart takes a mutable char* + api.runOncePart(reinterpret_cast(copy.data()), (uint16_t)copy.size()); +} + +/// Drive the stream-polling receive path with one burst. +static void feedStreamPath(FramingStreamAPIShim &api, InputScriptedStream &stream, const std::vector &bytes) +{ + stream.feed(bytes); + api.runOncePart(); +} + +/// Assert delivery `index` matches the expected payload, size first so a short delivery is a +/// clean assertion failure rather than an out-of-bounds read. +static void assertDeliveryAt(const FramingStreamAPIShim &api, size_t index, const std::vector &expected) +{ + TEST_ASSERT_TRUE_MESSAGE(index < api.deliveries.size(), "delivery index out of range"); + TEST_ASSERT_EQUAL_UINT(expected.size(), api.deliveries[index].size()); + if (!expected.empty()) // Unity rejects zero-length array asserts as pointless + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected.data(), api.deliveries[index].data(), expected.size()); +} + +/// Assert the shim recorded exactly one delivery matching the expected payload. +static void assertSingleDelivery(const FramingStreamAPIShim &api, const std::vector &expected) +{ + TEST_ASSERT_EQUAL_UINT_MESSAGE(1, api.deliveries.size(), "expected exactly one handleToRadio delivery"); + assertDeliveryAt(api, 0, expected); +} + +/// Verify one well-formed frame off the scripted stream delivers its exact payload once. +void test_stream_single_frame_delivers_exact_payload() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x08, 0x01, 0x2a, 0x00, 0x7f}; + + feedStreamPath(api, stream, makeFrame(payload)); + + assertSingleDelivery(api, payload); + TEST_ASSERT_TRUE_MESSAGE(stream.input.empty(), "readStream must drain everything available"); +} + +/// Verify parser state persists across stream polls split mid-header and mid-payload. +void test_stream_partial_reads_persist_state() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0xaa, 0xbb, 0xcc}; + std::vector frame = makeFrame(payload); + + // First poll sees only 3 of the 4 header bytes. + feedStreamPath(api, stream, std::vector(frame.begin(), frame.begin() + 3)); + TEST_ASSERT_EQUAL_UINT(0, api.deliveries.size()); + + // Second poll supplies the length byte and part of the payload. + feedStreamPath(api, stream, std::vector(frame.begin() + 3, frame.begin() + 5)); + TEST_ASSERT_EQUAL_UINT(0, api.deliveries.size()); + + // Final poll completes the payload: exactly one delivery. + feedStreamPath(api, stream, std::vector(frame.begin() + 5, frame.end())); + assertSingleDelivery(api, payload); +} + +/// Verify rxPtr persists across buffer-path invocations fed one byte at a time. +void test_buffer_path_one_byte_per_call_persists_state() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x12, 0x34}; + std::vector frame = makeFrame(payload); + + for (size_t i = 0; i + 1 < frame.size(); i++) { + feedBufferPath(api, {frame[i]}); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, api.deliveries.size(), "no delivery before the final byte"); + } + feedBufferPath(api, {frame.back()}); + + assertSingleDelivery(api, payload); +} + +/// Verify the parser hunts past leading ASCII boot-log garbage to the frame marker. +void test_leading_garbage_resyncs_to_frame() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x55, 0x66}; + + const char *bootLog = "INFO | ??:??:?? 1 Booting\r\n"; + std::vector burst(bootLog, bootLog + strlen(bootLog)); + std::vector frame = makeFrame(payload); + burst.insert(burst.end(), frame.begin(), frame.end()); + + feedStreamPath(api, stream, burst); + + assertSingleDelivery(api, payload); +} + +/// Verify a header advertising len 513 is rejected and a later frame in the burst still delivers. +void test_bogus_length_rejected_then_next_frame_recovered() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x77}; + + // MAX_TO_FROM_RADIO_SIZE is 512, so a big-endian length of 513 must fail header validation. + std::vector burst = {kStart1, kStart2, 0x02, 0x01}; + const char *junk = "junk"; + burst.insert(burst.end(), junk, junk + strlen(junk)); + std::vector frame = makeFrame(payload); + burst.insert(burst.end(), frame.begin(), frame.end()); + + feedBufferPath(api, burst); + + assertSingleDelivery(api, payload); +} + +/// Verify a len==512 frame (the exact cap, filling rxBuf to its last byte) is delivered intact +/// on both receive paths. +void test_max_length_frame_accepted_exactly() +{ + std::vector payload(MAX_TO_FROM_RADIO_SIZE); + for (size_t i = 0; i < payload.size(); i++) + payload[i] = (uint8_t)(i & 0xff); + std::vector frame = makeFrame(payload); + + // Total frame is 516 bytes == sizeof(rxBuf); ASan in the coverage env guards the bound. + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, frame); + assertSingleDelivery(streamApi, payload); + + // Buffer path, split so the cap is reached with rxPtr state persisted across calls. + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + const size_t split = frame.size() / 2; + feedBufferPath(bufferApi, std::vector(frame.begin(), frame.begin() + split)); + TEST_ASSERT_EQUAL_UINT(0, bufferApi.deliveries.size()); + feedBufferPath(bufferApi, std::vector(frame.begin() + split, frame.end())); + assertSingleDelivery(bufferApi, payload); +} + +/// Verify a zero-length payload is a valid frame delivering len 0 on both receive paths. +void test_zero_length_payload_delivers_empty() +{ + std::vector frame = makeFrame({}); + TEST_ASSERT_EQUAL_UINT(kHeaderLen, frame.size()); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, frame); + assertSingleDelivery(streamApi, {}); + + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + feedBufferPath(bufferApi, frame); + assertSingleDelivery(bufferApi, {}); +} + +/// Verify two back-to-back frames in one burst deliver twice, in order, on both paths. +void test_back_to_back_frames_deliver_in_order() +{ + std::vector first = {0x01, 0x02, 0x03}; + std::vector second = {0xf0, 0x0d}; + std::vector burst = makeFrame(first); + std::vector secondFrame = makeFrame(second); + burst.insert(burst.end(), secondFrame.begin(), secondFrame.end()); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + TEST_ASSERT_EQUAL_UINT(2, streamApi.deliveries.size()); + assertDeliveryAt(streamApi, 0, first); + assertDeliveryAt(streamApi, 1, second); + + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + feedBufferPath(bufferApi, burst); + TEST_ASSERT_EQUAL_UINT(2, bufferApi.deliveries.size()); + assertDeliveryAt(bufferApi, 0, first); + assertDeliveryAt(bufferApi, 1, second); +} + +/// Verify payload bytes >= 0x80 survive the buffer path identically to the stream path. +/// Pins the unsigned read in StreamAPI::handleRecStream(const char *, uint16_t): a plain +/// (signed) char compare treated any high byte - START1 itself is 0x94 - as EOF and +/// silently dropped frames mid-buffer. +void test_high_bytes_in_payload_delivered_on_both_paths() +{ + // Includes the framing bytes themselves mid-payload: length counts them as data. + std::vector payload = {0x80, kStart1, kStart2, 0xff, 0x00, 0xfe, 0x7f, 0x81}; + std::vector frame = makeFrame(payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, frame); + assertSingleDelivery(streamApi, payload); + + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + feedBufferPath(bufferApi, frame); + assertSingleDelivery(bufferApi, payload); + + TEST_ASSERT_EQUAL_UINT8_ARRAY(streamApi.deliveries[0].data(), bufferApi.deliveries[0].data(), payload.size()); +} + +/// A byte that fails START2 is re-tested as START1, so 0x94 0x94 0xc3 ... keeps the frame behind +/// the stray marker instead of consuming its real marker in the reset. +void test_stray_start1_before_frame_still_delivers() +{ + std::vector payload = {0x42}; + std::vector frame = makeFrame(payload); + std::vector burst = {kStart1}; // stray marker, then the real frame + burst.insert(burst.end(), frame.begin(), frame.end()); + + // The byte that fails START2 is itself START1 here, so the frame behind it must survive. + InputScriptedStream bufStream; + FramingStreamAPIShim bufferApi(&bufStream); + feedBufferPath(bufferApi, burst); + assertSingleDelivery(bufferApi, payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + assertSingleDelivery(streamApi, payload); +} + +/// A run of stray markers before a frame must not consume it either. +void test_repeated_stray_start1_before_frame_still_delivers() +{ + std::vector payload = {0x43, 0x44}; + std::vector frame = makeFrame(payload); + std::vector burst = {kStart1, kStart1, kStart1}; + burst.insert(burst.end(), frame.begin(), frame.end()); + + InputScriptedStream bufStream; + FramingStreamAPIShim bufferApi(&bufStream); + feedBufferPath(bufferApi, burst); + assertSingleDelivery(bufferApi, payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + assertSingleDelivery(streamApi, payload); +} + +/// START1 followed by a non-START1, non-START2 byte still resyncs on the next real frame. +void test_start1_then_unrelated_byte_resyncs() +{ + std::vector payload = {0x45}; + std::vector frame = makeFrame(payload); + std::vector burst = {kStart1, 0x00}; + burst.insert(burst.end(), frame.begin(), frame.end()); + + InputScriptedStream bufStream; + FramingStreamAPIShim bufferApi(&bufStream); + feedBufferPath(bufferApi, burst); + assertSingleDelivery(bufferApi, payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + assertSingleDelivery(streamApi, payload); +} + +/// Unity per-test setup: install the test MeshService the StreamAPI fixtures expect. +void setUp(void) +{ + previousService = service; + if (!testService) + testService = new MeshService(); + service = testService; +} + +/// Unity per-test teardown: runs even after an aborted test, so the restore is failure-safe. +void tearDown(void) +{ + service = previousService; +} + +/// Initialize the native environment and run the receive-framing suite. +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Frame delivery ===\n"); + RUN_TEST(test_stream_single_frame_delivers_exact_payload); + RUN_TEST(test_zero_length_payload_delivers_empty); + RUN_TEST(test_back_to_back_frames_deliver_in_order); + RUN_TEST(test_high_bytes_in_payload_delivered_on_both_paths); + + printf("\n=== Partial reads / state persistence ===\n"); + RUN_TEST(test_stream_partial_reads_persist_state); + RUN_TEST(test_buffer_path_one_byte_per_call_persists_state); + RUN_TEST(test_max_length_frame_accepted_exactly); + + printf("\n=== Resync and rejection ===\n"); + RUN_TEST(test_leading_garbage_resyncs_to_frame); + RUN_TEST(test_bogus_length_rejected_then_next_frame_recovered); + + printf("\n=== Stray framing markers ===\n"); + RUN_TEST(test_stray_start1_before_frame_still_delivers); + RUN_TEST(test_repeated_stray_start1_before_frame_still_delivers); + RUN_TEST(test_start1_then_unrelated_byte_resyncs); + + exit(UNITY_END()); +} + +/// Unused Arduino loop required by the native Unity runner. +void loop() {} diff --git a/test/test_tophone_queue/test_main.cpp b/test/test_tophone_queue/test_main.cpp new file mode 100644 index 0000000000..fb04c5f789 --- /dev/null +++ b/test/test_tophone_queue/test_main.cpp @@ -0,0 +1,167 @@ +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#if ARCH_PORTDUINO // portduino_config.maxtophone is what sizes the queue under test + +#include "configuration.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include "platform/portduino/PortduinoGlue.h" +#include +#include +#include + +// Queue depth for the suite. MAX_RX_TOPHONE resolves to portduino_config.maxtophone, read when +// MeshService constructs its queue. +static const int TEST_QUEUE_LEN = 4; + +static MeshService *testService = nullptr; +static MeshService *savedService = nullptr; +static int savedMaxToPhone = 0; +static meshtastic_Config_DeviceConfig_RebroadcastMode savedRebroadcastMode; + +static meshtastic_MeshPacket basePacket(uint32_t id) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = 0x11223344; + p.to = NODENUM_BROADCAST; + p.id = id; + return p; +} + +static void sendPacket(const meshtastic_MeshPacket &src) +{ + meshtastic_MeshPacket *p = packetPool.allocCopy(src); + TEST_ASSERT_NOT_NULL(p); + service->sendToPhone(p); +} + +static void send(uint32_t id, meshtastic_PortNum portnum, uint32_t requestId = 0) +{ + meshtastic_MeshPacket src = basePacket(id); + src.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + src.decoded.portnum = portnum; + src.decoded.request_id = requestId; + sendPacket(src); +} + +static void fillWith(meshtastic_PortNum portnum, uint32_t firstId) +{ + for (int i = 0; i < TEST_QUEUE_LEN; i++) + send(firstId + i, portnum); +} + +/// Drain the queue, returning the delivered packet ids in order. +static std::vector drainIds() +{ + std::vector ids; + while (meshtastic_MeshPacket *p = service->getForPhone()) { + ids.push_back(p->id); + service->releaseToPool(p); + } + return ids; +} + +static void assertIds(const std::vector &expected, const char *what) +{ + const std::vector actual = drainIds(); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)expected.size(), (int)actual.size(), what); + for (size_t i = 0; i < expected.size(); i++) + TEST_ASSERT_EQUAL_UINT32_MESSAGE(expected[i], actual[i], what); +} + +// An ACK/NAK is the phone's only delivery confirmation, so it must displace the oldest packet +// rather than be dropped when sustained downlink keeps the queue full. +static void test_routing_response_admitted_when_queue_full(void) +{ + fillWith(meshtastic_PortNum_TELEMETRY_APP, 1); + send(100, meshtastic_PortNum_ROUTING_APP, /*requestId=*/7); + + assertIds({2, 3, 4, 100}, "oldest telemetry should have been evicted for the routing response"); +} + +static void test_text_evicts_oldest_when_full(void) +{ + fillWith(meshtastic_PortNum_TELEMETRY_APP, 1); + send(200, meshtastic_PortNum_TEXT_MESSAGE_APP); + + assertIds({2, 3, 4, 200}, "text should still evict the oldest packet"); +} + +static void test_low_priority_packet_still_dropped_when_full(void) +{ + fillWith(meshtastic_PortNum_TEXT_MESSAGE_APP, 1); + send(200, meshtastic_PortNum_TELEMETRY_APP); + + assertIds({1, 2, 3, 4}, "a low-priority arrival should still be dropped on a full queue"); +} + +// decoded.portnum aliases encrypted.size in the payload union, so a still-encrypted packet whose +// ciphertext length happens to equal a privileged portnum must not be read as one. +static void test_encrypted_packet_is_not_classified_by_portnum(void) +{ + fillWith(meshtastic_PortNum_TELEMETRY_APP, 1); + + meshtastic_MeshPacket src = basePacket(300); + src.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + src.encrypted.size = meshtastic_PortNum_ROUTING_APP; + sendPacket(src); + + assertIds({1, 2, 3, 4}, "an encrypted packet must not be classified from the aliased portnum"); +} + +void setUp(void) +{ + savedMaxToPhone = portduino_config.maxtophone; + savedRebroadcastMode = config.device.rebroadcast_mode; + portduino_config.maxtophone = TEST_QUEUE_LEN; + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + + testService = new MeshService(); + savedService = service; + service = testService; +} + +void tearDown(void) +{ + drainIds(); // the queue owns its pointers; a failed assertion longjmps past any in-test drain + service = savedService; + delete testService; + testService = nullptr; + portduino_config.maxtophone = savedMaxToPhone; + config.device.rebroadcast_mode = savedRebroadcastMode; +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== toPhoneQueue overflow policy ===\n"); + + RUN_TEST(test_routing_response_admitted_when_queue_full); + RUN_TEST(test_text_evicts_oldest_when_full); + RUN_TEST(test_low_priority_packet_still_dropped_when_full); + RUN_TEST(test_encrypted_packet_is_not_classified_by_portnum); + + exit(UNITY_END()); +} + +void loop() {} + +#else // !ARCH_PORTDUINO + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +void loop() {} + +#endif diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 0395f58309..d7d947d5a0 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -37,24 +37,26 @@ constexpr NodeNum kTargetNode = 0x33333333; // a fresh requester for their "served again" step to avoid the per-requester window masking them. constexpr NodeNum kRemoteNode2 = 0x44444444; -// Telemetry hop exhaustion is gated on channel congestion (alterReceived checks -// airTime->isTxAllowedChannelUtil/isTxAllowedAirUtil). Installs a global -// airTime reporting 100% channel utilization for the enclosing scope. -class ScopedBusyAirTime -{ - public: - ScopedBusyAirTime() : previous(airTime) - { - for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) - busy.channelUtilization[i] = 10000; // 10 s of airtime per 10 s period - airTime = &busy; - } - ~ScopedBusyAirTime() { airTime = previous; } - - private: - AirTime busy; - AirTime *previous; -}; +// INERT - commented out, not deleted. TrafficManagementModule holds no reference to airTime: +// the gating this described went with exhaust_hop_telemetry / exhaust_hop_position, and +// shouldExhaustHops() is now a compare of three members nothing sets. Writing the buckets did not +// work either - the first accessor call takes AirTime's firstTime branch and memsets them, so this +// reported 0%, not 100%. A revived version must fill them via logAirtime(); they are private now. +// +// class ScopedBusyAirTime +// { +// public: +// ScopedBusyAirTime() : previous(airTime) +// { +// busy.logAirtime(RX_ALL_LOG, CHANNEL_UTILIZATION_PERIODS * 10 * 1000); // a full window +// airTime = &busy; +// } +// ~ScopedBusyAirTime() { airTime = previous; } +// +// private: +// AirTime busy; +// AirTime *previous; +// }; class MockNodeDB : public NodeDB { @@ -209,6 +211,8 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule MockNodeDB *mockNodeDB = nullptr; +static void installWellKnownPrimaryChannel(); // defined below, next to the other channel fixtures + static void resetTrafficConfig() { moduleConfig = meshtastic_LocalModuleConfig_init_zero; @@ -218,7 +222,9 @@ static void resetTrafficConfig() config = meshtastic_LocalConfig_init_zero; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; - channelFile = meshtastic_ChannelFile_init_zero; + // A real device always has a primary channel; leaving channels_count at 0 made every router + // lookup log "Invalid channel index", 12k lines of it, without testing anything. + installWellKnownPrimaryChannel(); owner.is_licensed = false; myNodeInfo.my_node_num = kLocalNode; @@ -2307,7 +2313,7 @@ static void test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed(void) */ static void test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged(void) { - ScopedBusyAirTime busyChannel; // congestion present but exhaust is disabled + // ScopedBusyAirTime busyChannel; // INERT: the module never reads airTime TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); packet.hop_start = 5; diff --git a/test/test_utf8/test_main.cpp b/test/test_utf8/test_main.cpp index 7ce90f250e..ebf47be9b2 100644 --- a/test/test_utf8/test_main.cpp +++ b/test/test_utf8/test_main.cpp @@ -1,3 +1,8 @@ +// Deliberately does NOT include TestUtil.h. This suite is pure-function - no NodeDB, no router, no +// sockets, no PKC - so the harness-wide guards there (no listening sockets, force_simradio clear) +// would assert conditions it cannot reach, and initializeTestEnvironment()'s RTC and OSThread setup +// would add portduino globals it otherwise never touches. Suite-level state cleanliness is still +// checked from outside by bin/pio-test-isolate.sh, which wraps every suite regardless. #include "meshUtils.h" #include #include diff --git a/test/test_xmodem/test_main.cpp b/test/test_xmodem/test_main.cpp index c6a20fdf05..7dbb9e04fe 100644 --- a/test/test_xmodem/test_main.cpp +++ b/test/test_xmodem/test_main.cpp @@ -1,16 +1,27 @@ -// Tests for XModemAdapter::isValidFilename - the path-traversal guard on the XModem file-transfer -// handler (src/xmodem.cpp). The filename in a SOH/STX control frame is attacker-controlled and -// drives FSCom open/remove; on the Portduino daemon FSCom is the host filesystem, so a ".." -// component could escape the mountpoint. Absolute/subdirectory paths must still be accepted. +// Tests for the XModem file-transfer adapter (src/xmodem.cpp). +// +// Group 1: XModemAdapter::isValidFilename - the path-traversal guard on the XModem file-transfer +// handler. The filename in a SOH/STX control frame is attacker-controlled and drives FSCom +// open/remove; on the Portduino daemon FSCom is the host filesystem, so a ".." component could +// escape the mountpoint. Absolute/subdirectory paths must still be accepted. +// +// Group 2 onward: the handlePacket() state machine itself - session start, per-packet seq + CRC +// validation, NAK/retransmit, CAN cleanup, EOT close, and the getForPhone()/resetForPhone() +// contract PhoneAPI uses to drain replies. PhoneAPI feeds handlePacket attacker-controllable +// ToRadio protobufs, and none of this had pinning coverage. These tests assert what the code does +// today; the two tests marked "documents current behaviour" pin known state-confusion edges so a +// deliberate fix has to update them consciously. #include "TestUtil.h" #include "xmodem.h" #include -void setUp(void) {} -void tearDown(void) {} - #ifdef FSCom +#include "SPILock.h" +#include +#include +#include + void test_xmodem_rejects_dotdot_traversal(void) { TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("..")); @@ -57,18 +68,469 @@ void test_xmodem_allows_legit_paths(void) TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("dir/1:30pm.txt")); } +// --- handlePacket state-machine fixture --- + +// Exposes the protected CRC helpers so crafted packets carry the exact checksum the adapter +// computes, and so the transmit-side crc16 field can be cross-checked. +class XModemTestShim : public XModemAdapter +{ + public: + using XModemAdapter::check; + using XModemAdapter::crc16_ccitt; +}; + +static XModemTestShim *xm = nullptr; + +static constexpr size_t kChunk = sizeof(meshtastic_XModem_buffer_t::bytes); // 128 +static const char *kRxPath = "/xmodem_test_rx.bin"; +static const char *kTxPath = "/xmodem_test_tx.bin"; + +// Control-only frame (EOT/ACK/NAK/CAN). +static meshtastic_XModem makeControl(meshtastic_XModem_Control control) +{ + meshtastic_XModem p = meshtastic_XModem_init_zero; + p.control = control; + return p; +} + +// Session-start frame: seq 0, filename in the buffer (NUL included, as the phone sends it). +static meshtastic_XModem makeStart(meshtastic_XModem_Control control, const char *path) +{ + meshtastic_XModem p = meshtastic_XModem_init_zero; + p.control = control; + p.seq = 0; + p.buffer.size = strlen(path) + 1; + memcpy(p.buffer.bytes, path, p.buffer.size); + return p; +} + +// Data frame with a correct (or deliberately corrupted) CRC. +static meshtastic_XModem makeData(uint16_t seq, const uint8_t *data, size_t len, bool goodCrc = true) +{ + meshtastic_XModem p = meshtastic_XModem_init_zero; + p.control = meshtastic_XModem_Control_SOH; + p.seq = seq; + p.buffer.size = len; + memcpy(p.buffer.bytes, data, len); + p.crc16 = xm->crc16_ccitt(p.buffer.bytes, (int)len); + if (!goodCrc) + p.crc16 ^= 0x1; + return p; +} + +static void fillPattern(uint8_t *buf, size_t len, uint8_t seed) +{ + for (size_t i = 0; i < len; i++) + buf[i] = (uint8_t)(seed + i * 7); +} + +static void writeAll(const char *path, const uint8_t *data, size_t len) +{ + File f = FSCom.open(path, FILE_O_WRITE); + TEST_ASSERT_TRUE_MESSAGE(f, path); + TEST_ASSERT_EQUAL_size_t(len, f.write(data, len)); + f.close(); +} + +static size_t readAll(const char *path, uint8_t *buf, size_t maxLen) +{ + File f = FSCom.open(path, FILE_O_READ); + TEST_ASSERT_TRUE_MESSAGE(f, path); + size_t n = f.read(buf, maxLen); + f.close(); + return n; +} + +// Starts a receive session into kRxPath and asserts the adapter accepted it. +static void startReceive(void) +{ + xm->handlePacket(makeStart(meshtastic_XModem_Control_SOH, kRxPath)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_TRUE(xm->isBusy()); +} + +// Writes a patterned file at kTxPath and starts a transmit session; returns the first outbound +// packet after asserting its shape. +static meshtastic_XModem startTransmit(const uint8_t *payload, size_t len) +{ + writeAll(kTxPath, payload, len); + xm->handlePacket(makeStart(meshtastic_XModem_Control_STX, kTxPath)); + meshtastic_XModem out = xm->getForPhone(); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, out.control); + TEST_ASSERT_EQUAL_UINT16(1, out.seq); + TEST_ASSERT_TRUE(xm->isBusy()); + return out; +} + +// --- CRC --- + +void test_xmodem_crc16_known_answer(void) +{ + // CRC-16/XMODEM check value: crc("123456789") == 0x31C3, and the zero-length CRC is 0. + const uint8_t check[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9'}; + TEST_ASSERT_EQUAL_HEX16(0x31C3, xm->crc16_ccitt(check, sizeof(check))); + TEST_ASSERT_EQUAL_HEX16(0x0000, xm->crc16_ccitt(check, 0)); + TEST_ASSERT_TRUE(xm->check(check, sizeof(check), 0x31C3)); + TEST_ASSERT_FALSE(xm->check(check, sizeof(check), 0x31C2)); +} + +// --- Receive path --- + +void test_xmodem_receive_happy_path(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 31); + + startReceive(); + + size_t off = 0; + uint16_t seq = 1; + while (off < sizeof(payload)) { + const size_t chunk = std::min(kChunk, sizeof(payload) - off); + xm->handlePacket(makeData(seq, payload + off, chunk)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + off += chunk; + seq++; + } + + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); + + uint8_t readBack[400]; + TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload)); +} + +void test_xmodem_receive_truncates_a_stale_file(void) +{ + // FILE_O_WRITE on Adafruit_LittleFS is append, not truncate; xmodem.cpp removes the target + // before opening. A shorter transfer over a longer stale file must leave no tail bytes. + uint8_t stale[400]; + memset(stale, 'Z', sizeof(stale)); + writeAll(kRxPath, stale, sizeof(stale)); + + uint8_t payload[10]; + fillPattern(payload, sizeof(payload), 3); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + + uint8_t readBack[400]; + TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload)); +} + +void test_xmodem_receive_rejects_wrong_seq(void) +{ + uint8_t p1[kChunk], p2[kChunk]; + fillPattern(p1, sizeof(p1), 11); + fillPattern(p2, sizeof(p2), 97); + + startReceive(); + xm->handlePacket(makeData(1, p1, sizeof(p1))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + // Duplicate of an already-accepted packet: rejected (NAK), not rewritten. + xm->handlePacket(makeData(1, p1, sizeof(p1))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + + // Skip ahead: also rejected, and packetno must not have advanced past 2. + xm->handlePacket(makeData(3, p2, sizeof(p2))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + + // The expected seq still works after both rejections. + xm->handlePacket(makeData(2, p2, sizeof(p2))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + + uint8_t readBack[3 * kChunk]; + TEST_ASSERT_EQUAL_size_t(2 * kChunk, readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(p1, readBack, kChunk); + TEST_ASSERT_EQUAL_HEX8_ARRAY(p2, readBack + kChunk, kChunk); +} + +void test_xmodem_receive_rejects_bad_crc(void) +{ + uint8_t payload[64]; + fillPattern(payload, sizeof(payload), 55); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload), /*goodCrc=*/false)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + + // The sender retries the same seq with a good CRC; only that copy lands in the file. + xm->handlePacket(makeData(1, payload, sizeof(payload))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + + uint8_t readBack[2 * kChunk]; + TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload)); +} + +void test_xmodem_receive_naks_traversal_filename(void) +{ + xm->handlePacket(makeStart(meshtastic_XModem_Control_SOH, "../evil")); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); + + // isReceiving stayed false, so a follow-up data packet falls through with no reply at all. + xm->resetForPhone(); + uint8_t junk[16]; + fillPattern(junk, sizeof(junk), 1); + xm->handlePacket(makeData(1, junk, sizeof(junk))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +// NOTE: the receive-side open-failure NAK branch (xmodem.cpp "open(%s, WRITE) failed") is not +// testable on native: Portduino's VFSImpl::open() returns a truthy File whenever the mode permits +// creation, even when the underlying fopen fails, so the branch is unreachable here. + +void test_xmodem_can_mid_receive_removes_the_file(void) +{ + uint8_t payload[kChunk]; + fillPattern(payload, sizeof(payload), 42); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload))); + xm->handlePacket(makeData(2, payload, sizeof(payload))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_CAN)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); + TEST_ASSERT_FALSE(FSCom.exists(kRxPath)); +} + +void test_xmodem_can_after_eot_removes_completed_file(void) +{ + // Documents current behaviour: the CAN handler acts on the stale filename from the previous + // session even when no transfer is in flight, deleting a file that completed successfully. + // A deliberate fix (ignoring CAN while idle) should update this test. + uint8_t payload[8]; + fillPattern(payload, sizeof(payload), 5); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload))); + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + TEST_ASSERT_FALSE(xm->isBusy()); + TEST_ASSERT_TRUE(FSCom.exists(kRxPath)); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_CAN)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_FALSE(FSCom.exists(kRxPath)); +} + +// --- Transmit path --- + +void test_xmodem_transmit_happy_path(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 7); + + meshtastic_XModem out = startTransmit(payload, sizeof(payload)); + TEST_ASSERT_EQUAL_UINT16(kChunk, out.buffer.size); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, out.buffer.bytes, kChunk); + TEST_ASSERT_EQUAL_HEX16(xm->crc16_ccitt(out.buffer.bytes, out.buffer.size), out.crc16); + + // ACK-drive the whole stream and reassemble it; the last (short) packet latches EOT, which + // arrives on the following ACK. + uint8_t reassembled[sizeof(payload) + kChunk]; + size_t got = 0; + uint16_t expectSeq = 1; + for (int guard = 0; guard < 10; guard++) { + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, out.control); + TEST_ASSERT_EQUAL_UINT16(expectSeq, out.seq); + TEST_ASSERT_EQUAL_HEX16(xm->crc16_ccitt(out.buffer.bytes, out.buffer.size), out.crc16); + memcpy(reassembled + got, out.buffer.bytes, out.buffer.size); + got += out.buffer.size; + expectSeq++; + + xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK)); + out = xm->getForPhone(); + if (out.control == meshtastic_XModem_Control_EOT) + break; + } + + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_EOT, out.control); + TEST_ASSERT_FALSE(xm->isBusy()); + TEST_ASSERT_EQUAL_size_t(sizeof(payload), got); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, reassembled, sizeof(payload)); +} + +void test_xmodem_transmit_nak_resends_same_packet(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 61); + + meshtastic_XModem first = startTransmit(payload, sizeof(payload)); + + // NAK seeks back and re-reads the same block: identical seq, bytes and CRC. + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + meshtastic_XModem resent = xm->getForPhone(); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, resent.control); + TEST_ASSERT_EQUAL_UINT16(first.seq, resent.seq); + TEST_ASSERT_EQUAL_UINT16(first.buffer.size, resent.buffer.size); + TEST_ASSERT_EQUAL_HEX8_ARRAY(first.buffer.bytes, resent.buffer.bytes, first.buffer.size); + TEST_ASSERT_EQUAL_HEX16(first.crc16, resent.crc16); + + // A subsequent ACK still advances to the next block. + xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK)); + meshtastic_XModem next = xm->getForPhone(); + TEST_ASSERT_EQUAL_UINT16(2, next.seq); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload + kChunk, next.buffer.bytes, kChunk); +} + +void test_xmodem_transmit_retry_cap_cancels(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 23); + + startTransmit(payload, sizeof(payload)); + + // retrans starts at MAXRETRANS on a fresh adapter; NAKs 1..MAXRETRANS-1 resend, the + // MAXRETRANS'th decrements it to zero and aborts with CAN. + for (int i = 1; i < MAXRETRANS; i++) { + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, xm->getForPhone().control); + TEST_ASSERT_EQUAL_UINT16(1, xm->getForPhone().seq); + } + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_transmit_naks_missing_file(void) +{ + xm->handlePacket(makeStart(meshtastic_XModem_Control_STX, "/xmodem_test_missing.bin")); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_soh_mid_transmit_cancels(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 89); + + startTransmit(payload, sizeof(payload)); + + // A data frame arriving while we are the sender is protocol confusion: cancel the transfer. + uint8_t junk[16]; + fillPattern(junk, sizeof(junk), 2); + xm->handlePacket(makeData(5, junk, sizeof(junk))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_eot_mid_transmit_leaves_state_busy(void) +{ + // Documents current behaviour: the EOT handler only clears isReceiving, so an EOT received + // while transmitting ACKs, closes the file, and leaves the adapter wedged busy. A deliberate + // fix should update this test. + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 13); + + startTransmit(payload, sizeof(payload)); + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_TRUE(xm->isBusy()); +} + +// --- Idle replies and the getForPhone/resetForPhone contract --- + +void test_xmodem_ack_nak_while_idle_provoke_can(void) +{ + xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + + // getForPhone() is a read, not a drain: the reply stays until resetForPhone() clears it. + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + xm->resetForPhone(); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_unknown_control_ignored(void) +{ + xm->handlePacket(makeControl(meshtastic_XModem_Control_CTRLZ)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +// --- Unity lifecycle --- + +void setUp(void) +{ + FSCom.remove(kRxPath); + FSCom.remove(kTxPath); + xm = new XModemTestShim(); +} + +void tearDown(void) +{ + delete xm; // File member closes any handle still held + xm = nullptr; + FSCom.remove(kRxPath); + FSCom.remove(kTxPath); +} + +#else // !FSCom + +void setUp(void) {} +void tearDown(void) {} + #endif // FSCom void setup() { initializeTestEnvironment(); +#ifdef FSCom + // handlePacket brackets every FSCom touch with spiLock; nothing in the test environment + // creates it, so do it here (initSPI asserts it only runs once). + if (!spiLock) + initSPI(); +#endif UNITY_BEGIN(); #ifdef FSCom + printf("\n=== isValidFilename ===\n"); RUN_TEST(test_xmodem_rejects_dotdot_traversal); RUN_TEST(test_xmodem_rejects_backslash_traversal); RUN_TEST(test_xmodem_rejects_drive_qualified); RUN_TEST(test_xmodem_rejects_empty); RUN_TEST(test_xmodem_allows_legit_paths); + + printf("\n=== CRC ===\n"); + RUN_TEST(test_xmodem_crc16_known_answer); + + printf("\n=== Receive path ===\n"); + RUN_TEST(test_xmodem_receive_happy_path); + RUN_TEST(test_xmodem_receive_truncates_a_stale_file); + RUN_TEST(test_xmodem_receive_rejects_wrong_seq); + RUN_TEST(test_xmodem_receive_rejects_bad_crc); + RUN_TEST(test_xmodem_receive_naks_traversal_filename); + RUN_TEST(test_xmodem_can_mid_receive_removes_the_file); + RUN_TEST(test_xmodem_can_after_eot_removes_completed_file); + + printf("\n=== Transmit path ===\n"); + RUN_TEST(test_xmodem_transmit_happy_path); + RUN_TEST(test_xmodem_transmit_nak_resends_same_packet); + RUN_TEST(test_xmodem_transmit_retry_cap_cancels); + RUN_TEST(test_xmodem_transmit_naks_missing_file); + RUN_TEST(test_xmodem_soh_mid_transmit_cancels); + RUN_TEST(test_xmodem_eot_mid_transmit_leaves_state_busy); + + printf("\n=== Idle replies / phone contract ===\n"); + RUN_TEST(test_xmodem_ack_nak_while_idle_provoke_can); + RUN_TEST(test_xmodem_unknown_control_ignored); #endif exit(UNITY_END()); } diff --git a/variants/esp32/esp32.ini b/variants/esp32/esp32.ini index 40d43dad9a..1986c1a9ae 100644 --- a/variants/esp32/esp32.ini +++ b/variants/esp32/esp32.ini @@ -44,15 +44,15 @@ custom_sdkconfig = CONFIG_BT_NIMBLE_ENABLED=y CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y -; Override lib_deps to use environmental_extra_no_bsec instead of environmental_extra -; BSEC library uses ~3.5KB DRAM which causes overflow on original ESP32 targets +; Overrides esp32_common's lib_deps: adds networking_extra and omits +; esp32_https_server (mesh/http is excluded from this target's build_src_filter) lib_deps = ${arduino_base.lib_deps} ${networking_base.lib_deps} ${networking_extra.lib_deps} ${radiolib_base.lib_deps} ${environmental_base.lib_deps} - ${environmental_extra_no_bsec.lib_deps} + ${environmental_extra.lib_deps} # TODO renovate https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib diff --git a/variants/esp32p4/esp32p4.ini b/variants/esp32p4/esp32p4.ini index 8a284162d2..a435fdfa72 100644 --- a/variants/esp32p4/esp32p4.ini +++ b/variants/esp32p4/esp32p4.ini @@ -93,7 +93,6 @@ lib_ignore = ${esp32_common.lib_ignore} libpax esp8266-oled-ssd1306 - bsec2 esp32_idf5_https_server esp_driver_cam esp_http_server diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp index fbb9d37c52..17f767c0b5 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp @@ -5,6 +5,4 @@ void initVariant() { pinMode(LED_PAIRING, OUTPUT); digitalWrite(LED_PAIRING, !LED_STATE_ON); // Turn off the LED to start - pinMode(LED_LORA, OUTPUT); - digitalWrite(LED_LORA, !LED_STATE_ON); // Turn off the LED to start } diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/pins_arduino.h b/variants/esp32s3/ELECROW-ThinkNode-M9/pins_arduino.h new file mode 100644 index 0000000000..0099711948 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/pins_arduino.h @@ -0,0 +1,20 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 20; +static const uint8_t SCL = 21; + +static const uint8_t SS = 39; +static const uint8_t MOSI = 47; +static const uint8_t MISO = 38; +static const uint8_t SCK = 40; + +#endif \ No newline at end of file diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini new file mode 100644 index 0000000000..7aea2c5fbc --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini @@ -0,0 +1,99 @@ +[thinknode_m9_base] +custom_meshtastic_hw_model = 131 +custom_meshtastic_hw_model_slug = ELECROW_ThinkNode_M9 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = ThinkNode M9 +custom_meshtastic_images = thinknode_m9.svg +custom_meshtastic_tags = Elecrow +custom_meshtastic_requires_dfu = false +custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = true + +extends = esp32s3_base +board = crowpanel +board_level = release +board_build.partitions = default_16MB.csv +upload_protocol = esptool +build_src_filter = + ${esp32s3_base.build_src_filter} + +<../variants/esp32s3/ELECROW-ThinkNode-M9> +build_flags = + ${esp32s3_base.build_flags} + -I variants/esp32s3/ELECROW-ThinkNode-M9 + -D ELECROW_ThinkNode_M9 + -D BOARD_HAS_PSRAM + -D HAS_SDCARD=1 + -D SDCARD_CS=48 + -D SDCARD_USE_SPI1 + -D SDCARD_SHARE_SPI + -D SDCARD_INIT_SPI + -D SD_SPI_FREQUENCY=75000000U + -D HAS_SCREEN=1 + -D SPI_FREQUENCY=75000000 +; -D COMPASS_SENSOR_DEBUG=1 +lib_deps = ${esp32s3_base.lib_deps} + # renovate: datasource=custom depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.26 + # renovate: datasource=custom depName=SensorLib packageName=lewisxhe/library/SensorLib + lewisxhe/SensorLib@0.4.1 + +[env:thinknode_m9] +extends = thinknode_m9_base +build_flags = + ${thinknode_m9_base.build_flags} + +[env:thinknode_m9-tft] +extends = thinknode_m9_base +build_flags = + ${thinknode_m9_base.build_flags} + -D RADIOLIB_SPI_PARANOID=0 + -D CONFIG_DISABLE_HAL_LOCKS=1 + -D HAS_TFT=1 + -D USE_PACKET_API + -D USE_PIN_BUZZER=PIN_BUZZER + -D LV_LVGL_H_INCLUDE_SIMPLE + -D LV_CONF_INCLUDE_SIMPLE + -D LV_COMP_CONF_INCLUDE_SIMPLE + -D LV_USE_SYSMON=0 + -D LV_USE_PROFILER=0 + -D LV_USE_PERF_MONITOR=0 + -D LV_USE_MEM_MONITOR=0 + -D LV_USE_LOG=0 + -D LV_BUILD_TEST=0 + -D USE_LOG_DEBUG + -D LOG_DEBUG_INC=\"DebugConfiguration.h\" + -D RAM_SIZE=5120 + -D LGFX_BUFSIZE=153600 + -D LGFX_DRIVER_TEMPLATE + -D DISPLAY_SIZE=320x240 + -D LGFX_DRIVER=LGFX_GENERIC + -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_GENERIC.h\" + -D VIEW_320x240 + -D LGFX_PANEL=ST7789 + -D LGFX_ROTATION=3 + -D LGFX_CFG_HOST=SPI3_HOST + -D LGFX_PIN_SCK=40 + -D LGFX_PIN_MOSI=47 + -D LGFX_PIN_DC=15 + -D LGFX_PIN_CS=16 + -D LGFX_PIN_BL=17 + -D LGFX_PIN_RST=14 + -D LGFX_SCREEN_WIDTH=240 + -D LGFX_SCREEN_HEIGHT=320 + -D LGFX_INVERT_LIGHT=true +; -D MAP_FULL_REDRAW + -D MUI_WIFI_PS_MIN_MODEM + -D DEFAULT_FTP_SERVER_NETWORK_TYPE_ESP32=NETWORK_ESP32 + -D DEFAULT_STORAGE_TYPE_ESP32=STORAGE_SD + -D CHARGING_VOLTAGE=4.25 + +lib_deps = + ${thinknode_m9_base.lib_deps} + https://github.com/meshtastic/device-ui/archive/e8a5ff337d1ead20b290307fb2159ad27fe47f86.zip ; PR314 input-policy + https://github.com/mverch67/MultiFTPServer/archive/0e854335b9916ed9f2d3bcfe68975ce746992ccd.zip + +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + ${device-ui_base.custom_sdkconfig} \ No newline at end of file diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/rfswitch.h b/variants/esp32s3/ELECROW-ThinkNode-M9/rfswitch.h new file mode 100644 index 0000000000..e5fe182c4f --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/rfswitch.h @@ -0,0 +1,11 @@ +#include "RadioLib.h" + +static const uint32_t rfswitch_dio_pins[] = {RADIOLIB_LR11X0_DIO5, RADIOLIB_LR11X0_DIO6, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC}; + +static const Module::RfSwitchMode_t rfswitch_table[] = { + // mode DIO5 DIO6 + {LR11x0::MODE_STBY, {LOW, LOW}}, {LR11x0::MODE_RX, {HIGH, LOW}}, + {LR11x0::MODE_TX, {HIGH, HIGH}}, {LR11x0::MODE_TX_HP, {LOW, HIGH}}, + {LR11x0::MODE_TX_HF, {LOW, LOW}}, {LR11x0::MODE_GNSS, {LOW, LOW}}, + {LR11x0::MODE_WIFI, {LOW, LOW}}, END_OF_MODE_TABLE, +}; diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp new file mode 100644 index 0000000000..279dedeb50 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp @@ -0,0 +1,34 @@ +#include "variant.h" +#include "Arduino.h" +#include "SPILock.h" +#include "Wire.h" + +void earlyInitVariant() +{ + pinMode(LORA_CS, OUTPUT); + digitalWrite(LORA_CS, HIGH); + pinMode(SDCARD_CS, OUTPUT); + digitalWrite(SDCARD_CS, HIGH); + pinMode(TFT_CS, OUTPUT); + digitalWrite(TFT_CS, HIGH); + delay(100); +} + +void lateInitVariant() +{ + // configure keyboard long-press time + const uint16_t ms = 700; + concurrency::LockGuard g(spiLock); + Wire.beginTransmission(0x6C); + Wire.write(0x03); + Wire.write((ms >> 8) & 0xFF); + Wire.write(ms & 0xFF); + Wire.endTransmission(); +} + +void variant_shutdown() +{ + uint64_t gpioMask = (1ULL << KB_INT); + gpio_pulldown_en((gpio_num_t)KB_INT); + esp_sleep_enable_ext1_wakeup(gpioMask, ESP_EXT1_WAKEUP_ANY_HIGH); +} \ No newline at end of file diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h new file mode 100644 index 0000000000..543a690899 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h @@ -0,0 +1,107 @@ +#define CANNED_MESSAGE_MODULE_ENABLE 1 +#define PRESET_MESSAGE_MODULE_ENABLE 1 + +/*Power*/ +#define VEXT_ENABLE 18 +#define VEXT_ON_VALUE LOW +#define PIN_GPS_EN 11 +#define GPS_EN_ACTIVE LOW + +#define USE_POWERSAVE +#define SLEEP_TIME 120 + +/*Wire Interface*/ +#define WIRE_INTERFACES_COUNT 2 +// I2C keyboard +#define I2C_SCL 21 +#define I2C_SDA 20 +#define KB_INT 12 // STC8H key-press interrupt (idle low, rising edge on press) +#define KB_INT_WAKE_ON_HIGH 1 // KB_INT rests low; wake light sleep on its HIGH (active) level +#define KB_LED 46 // STC8H keypad backlight LED +// I2C peripheral +#define I2C_SCL1 6 +#define I2C_SDA1 7 + +/*BUZZER*/ +#define PIN_BUZZER 9 + +/*CHARGE_CHECK*/ +#define EXT_PWR_DETECT 1 +// #define EXT_CHRG_DETECT 1 +#define EXT_PWR_DETECT_VALUE LOW + +/*GPS*/ +#define HAS_GPS 1 +#define GPS_BAUDRATE 115200 +#define PIN_GPS_RESET 5 +#define PIN_GPS_PPS 4 +#define GPS_TX_PIN 3 +#define GPS_RX_PIN 2 +#define GPS_THREAD_INTERVAL 50 + +/*SPI*/ +#define SPI_MOSI 47 +#define SPI_SCK 40 +#define SPI_MISO 38 + +/*Screen*/ +#define ST7789_CS 16 +#define ST7789_RS 15 +#define ST7789_TE 19 +#define ST7789_SDA SPI_MOSI // MOSI +#define ST7789_SCK SPI_SCK +#define ST7789_RESET 14 +#define ST7789_MISO SPI_MISO +#define ST7789_BUSY -1 +#define ST7789_BL 17 +#define ST7789_SPI_HOST SPI3_HOST +#define SPI_READ_FREQUENCY 16000000 + +#define USE_TFTDISPLAY 1 +#define HAS_SPI_TFT 1 +#define TFT_CS ST7789_CS +#define TFT_BL ST7789_BL +#define TFT_HEIGHT 320 +#define TFT_WIDTH 240 +#define TFT_OFFSET_X 0 +#define TFT_OFFSET_Y 0 +#define TFT_OFFSET_ROTATION 0 +#define TFT_PWM_FREQ 44000 +#define TFT_PWM_CHANNEL 7 +#define TFT_INVERT_LIGHT true +#define TFT_BACKLIGHT_ON LOW +#define SCREEN_ROTATE +#define SCREEN_TRANSITION_FRAMERATE 10 +#define BRIGHTNESS_DEFAULT 128 + +/*Lora radio*/ +#define HW_SPI1_DEVICE +#define LORA_SCK SPI_SCK +#define LORA_MISO SPI_MISO +#define LORA_MOSI SPI_MOSI +#define LORA_CS 39 +#define LORA_RESET 45 +#define LORA_DIO0 41 +#define LORA_DIO1 42 + +#define USE_LR1110 +#define LR1110_IRQ_PIN LORA_DIO1 +#define LR1110_NRESET_PIN LORA_RESET +#define LR1110_BUSY_PIN LORA_DIO0 +#define LR1110_SPI_NSS_PIN LORA_CS +#define LR1110_SPI_SCK_PIN LORA_SCK +#define LR1110_SPI_MOSI_PIN LORA_MOSI +#define LR1110_SPI_MISO_PIN LORA_MISO +#define LR11X0_DIO3_TCXO_VOLTAGE 3.3 +#define LR11X0_DIO_AS_RF_SWITCH + +/*RTC*/ +#define PCF8563_RTC 0x51 + +/*BATTERY*/ +#define BATTERY_PIN 13 +#define BATTERY_IMMUTABLE +#define ADC_MULTIPLIER 2.0f +#define BAT_MEASURE_ADC_UNIT ADC_UNIT_2 +#define ADC_CHANNEL ADC_CHANNEL_2 +#define OCV_ARRAY 4200, 4080, 3980, 3920, 3870, 3820, 3790, 3750, 3700, 3600, 3100 diff --git a/variants/esp32s3/t-deck-pro/variant.h b/variants/esp32s3/t-deck-pro/variant.h index d95f07f3a9..8fa7e17402 100644 --- a/variants/esp32s3/t-deck-pro/variant.h +++ b/variants/esp32s3/t-deck-pro/variant.h @@ -30,6 +30,7 @@ // vibration motor #define PIN_VIBRATION 2 +#define HAS_DRV2605 1 // Have SPI interface SD card slot #define HAS_SDCARD diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 047371db9e..6448777935 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -39,6 +39,10 @@ lib_deps = ${esp32s3_base.lib_deps} extends = env:t-deck board_level = pr +extra_scripts = + ${env:t-deck.extra_scripts} + extra_scripts/ld_response_file.py + build_flags = ${env:t-deck.build_flags} -D CONFIG_DISABLE_HAL_LOCKS=1 ; "feels" to be a bit more stable without locks diff --git a/variants/esp32s3/t-watch-ultra/pins_arduino.h b/variants/esp32s3/t-watch-ultra/pins_arduino.h new file mode 100644 index 0000000000..18d029ef88 --- /dev/null +++ b/variants/esp32s3/t-watch-ultra/pins_arduino.h @@ -0,0 +1,94 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +// #ifndef digitalPinToInterrupt +// #define digitalPinToInterrupt(p) (((p) < 48) ? (p) : -1) +// #endif + +#define USB_VID 0x303a +#define USB_PID 0x8227 +#define USB_MANUFACTURER "LILYGO" +#define USB_PRODUCT "T-Watch-Ultra" + +#define DISP_WIDTH 502 +#define DISP_HEIGHT 410 + +// QSPI interface display +#define DISP_D0 (38) +#define DISP_D1 (39) +#define DISP_D2 (42) +#define DISP_D3 (45) +#define DISP_SCK (40) +#define DISP_CS (41) +#define DISP_RST (37) +#define DISP_TE (6) + +// Interrupt IO port +#define TP_INT (12) +#define RTC_INT (1) +#define PMU_INT (7) +#define NFC_INT (5) +#define SENSOR_INT (8) +#define NFC_CS (4) + +// PDM microphone +#define MIC_SCK (17) +#define MIC_DAT (18) + +// MAX98357A +#define I2S_BCLK (9) +#define I2S_WCLK (10) +#define I2S_DOUT (11) + +#define SD_CS (21) + +// TX, RX pin connected to GPS +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +// BHI260,PCF85063,AXP2101,DRV2605L share I2C Bus +static const uint8_t SDA = 3; +static const uint8_t SCL = 2; + +// Default sd cs pin +static const uint8_t SS = SD_CS; +static const uint8_t MOSI = 34; +static const uint8_t MISO = 33; +static const uint8_t SCK = 35; + +#define GPS_TX (TX) +#define GPS_RX (RX) +#define GPS_PPS (13) + +#define TP_SDA (SDA) +#define TP_SCL (SCL) + +// LoRa and SD card share SPI bus -> variant.h +// #define LORA_SCK (SCK) // share spi bus +// #define LORA_MISO (MISO) // share spi bus +// #define LORA_MOSI (MOSI) // share spi bus +// #define LORA_CS (36) +// #define LORA_RST (47) +// #define LORA_BUSY (48) +// #define LORA_IRQ (14) + +// External expansion chip IO definition +#define EXPANDS_DRV_EN (6) +#define EXPANDS_DISP_EN (7) +#define EXPANDS_TOUCH_RST (8) +#define EXPANDS_SD_DET (10) +#define EXPANDS_LORA_RF_SW (11) + +// Peripheral definition exists +#define USING_XL9555_EXPANDS +#define USING_PCM_AMPLIFIER +#define USING_PDM_MICROPHONE +#define USING_PMU_MANAGE +#define USING_INPUT_DEV_TOUCHPAD +#define USING_ST25R3916 +#define USING_BHI260_SENSOR +#define HAS_SD_CARD_SOCKET + +#endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/t-watch-ultra/platformio.ini b/variants/esp32s3/t-watch-ultra/platformio.ini new file mode 100644 index 0000000000..8d242d4a3d --- /dev/null +++ b/variants/esp32s3/t-watch-ultra/platformio.ini @@ -0,0 +1,96 @@ +; LilyGo T-Watch S3 +[env:t-watch-ultra] +custom_meshtastic_hw_model = 114 +custom_meshtastic_hw_model_slug = T_WATCH_ULTRA +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = T-Watch Ultra +custom_meshtastic_images = t-watch-ultra.svg +custom_meshtastic_tags = LilyGo +custom_meshtastic_requires_dfu = false +custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = true + +extends = esp32s3_base +board = t-watch-ultra +board_level = release +board_build.partitions = default_16MB.csv +upload_protocol = esptool + +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + ; Keep esp_littlefs buffers in internal RAM (off the shared-bus PSRAM). + CONFIG_LITTLEFS_MALLOC_STRATEGY_INTERNAL=y + CONFIG_SPI_FLASH_SHARE_SPI1_BUS=y + +build_flags = ${esp32_base.build_flags} -Ivariants/esp32s3/t-watch-ultra + ; Route flash reads through the cache/mmap path (esp_partition_read_mmap_wrap.c) + ; to dodge the IDF 5.5 manual-read regression on this board's flash. + -Wl,--wrap=esp_partition_read + -D T_WATCH_ULTRA + -D RADIOLIB_EXCLUDE_SX128X=1 + -D RADIOLIB_EXCLUDE_SX127X=1 + -D RADIOLIB_EXCLUDE_LR11X0=1 + -UMESHTASTIC_EXCLUDE_ACCELEROMETER + -D HAS_SDCARD + -D SDCARD_USE_SPI1 + -D SD_SPI_FREQUENCY=75000000 + -D SPI_MISO=33 + -D SPI_MOSI=34 + -D SPI_SCK=35 + -D SDCARD_CS=21 +; -DHAS_BMA423=1 + +build_src_filter = + ${esp32s3_base.build_src_filter} + +<../variants/esp32s3/t-watch-ultra> + +lib_deps = ${esp32s3_base.lib_deps} + https://github.com/lovyan03/LovyanGFX/archive/tags/1.2.27.zip + adafruit/Adafruit DRV2605 Library@^1.2.4 + # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix + https://github.com/earlephilhower/ESP8266Audio/archive/05f2fb0045cc294b4e0d1a1a9747b89c22c1fea4.zip + # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM + earlephilhower/ESP8266SAM@1.1.0 + lewisxhe/SensorLib@0.3.1 + +[env:t-watch-ultra-tft] +board_level = extra +extends = env:t-watch-ultra +build_flags = + ${env:t-watch-ultra.build_flags} + -D CONFIG_DISABLE_HAL_LOCKS=1 + -D INPUTDRIVER_BUTTON_TYPE=0 + -D HAS_SCREEN=1 + -D HAS_TFT=1 + -D USE_I2S_BUZZER + -D RAM_SIZE=5120 + -D LV_LVGL_H_INCLUDE_SIMPLE + -D LV_CONF_INCLUDE_SIMPLE + -D LV_COMP_CONF_INCLUDE_SIMPLE + -D LV_USE_SYSMON=0 + -D LV_USE_PROFILER=0 + -D LV_USE_PERF_MONITOR=0 + -D LV_USE_MEM_MONITOR=0 + -D LV_USE_LOG=0 + -D USE_LOG_DEBUG + -D LOG_DEBUG_INC=\"DebugConfiguration.h\" + -D RADIOLIB_SPI_PARANOID=0 + -D LGFX_SCREEN_WIDTH=410 + -D LGFX_SCREEN_HEIGHT=502 + -D LGFX_AMOLED_ROUNDER=1 + -D LGFX_BUFSIZE=308732 + -D DISPLAY_SIZE=410x502 ; portrait mode + -D DISPLAY_SET_RESOLUTION + -D LGFX_DRIVER=LGFX_TWATCH_ULTRA + -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_T_WATCH_ULTRA.h\" +; -D LVGL_DRIVER=LVGL_T_WATCH_ULTRA + -D VIEW_320x240 + -D USE_PACKET_API + -D MAP_FULL_REDRAW + -D CUSTOM_TOUCH_DRIVER + +lib_deps = + ${env:t-watch-ultra.lib_deps} + ${device-ui_base.lib_deps} \ No newline at end of file diff --git a/variants/esp32s3/t-watch-ultra/variant.h b/variants/esp32s3/t-watch-ultra/variant.h new file mode 100644 index 0000000000..22f4f62d07 --- /dev/null +++ b/variants/esp32s3/t-watch-ultra/variant.h @@ -0,0 +1,101 @@ + +// CO5300 TFT AMOLED +#define CO5300_CS 41 +#define CO5300_SCK 40 +#define CO5300_RESET 37 +#define CO5300_TE 6 +#define CO5300_IO0 38 +#define CO5300_IO1 39 +#define CO5300_IO2 42 +#define CO5300_IO3 45 +#define CO5300_SPI_HOST SPI2_HOST +#define SPI_FREQUENCY 75000000 +#define SPI_READ_FREQUENCY 16000000 // irrelevant +#define TFT_HEIGHT 502 +#define TFT_WIDTH 410 +#define TFT_OFFSET_X 22 +#define TFT_OFFSET_Y 0 +#define TFT_OFFSET_ROTATION 0 +#define SCREEN_TRANSITION_FRAMERATE 5 // fps +#define USE_TFTDISPLAY 1 +#define HAS_SCREEN 1 +#define TFT_RESET_AFTER_SLEEP +#define OLED_HUGE +#define ROUNDED_SCREEN true +#define BASEUI_HEADER_MARGIN 15 +#define BASEUI_HEADER_LR_MARGIN 55 +#define BASEUI_BELOW_HEADER_MARGIN 15 +#define BASEUI_BODY_LR_MARGIN 35 + +#define HAS_TOUCHSCREEN 1 +#define HAS_SPI_TFT 1 +#define ENABLE_TOUCH_INT 1 +#define VARIANT_TOUCHSCREEN 1 +#define SCREEN_TOUCH_INT 12 +#define TOUCH_I2C_PORT 0 +#define TOUCH_SLAVE_ADDRESS 0x1A +#define WAKE_ON_TOUCH + +#define BUTTON_PIN 0 + +#define USE_POWERSAVE +#define SLEEP_TIME 120 + +// External expansion chip XL9555 +#define USE_XL9555 + +// PCF85063 RTC Module +#define PCF85063_RTC 0x51 +#define HAS_RTC 1 + +// MAX98357A +#define HAS_I2S +#define DAC_I2S_BCK 9 +#define DAC_I2S_WS 10 +#define DAC_I2S_DOUT 11 +#define DAC_I2S_MCLK -1 // TODO + +#define HAS_AXP2101 +#define PMU_IRQ 7 +#define PMU_POWER_BUTTON_IS_CANCEL +#define HAS_DRV2605 1 + +#define HAS_BHI260AP +#define BHI260AP_INT 8 +#undef MESHTASTIC_EXCLUDE_ACCELEROMETER +#define SHOW_STEP_COUNTER + +#define I2C_SDA 3 +#define I2C_SCL 2 +#define I2C_NO_RESCAN + +#define HAS_GPS 1 +#define GPS_BAUDRATE 38400 +#define GPS_RX_PIN 44 +#define GPS_TX_PIN 43 +#define PIN_GPS_PPS 13 + +#define USE_SX1262 +// #define USE_SX1280 +#define HW_SPI1_DEVICE + +#define LORA_SCK 35 +#define LORA_MISO 33 +#define LORA_MOSI 34 +#define LORA_CS 36 + +#define LORA_DIO0 -1 // a No connect on the SX1262 module +#define LORA_RESET 47 +#define LORA_DIO1 14 // SX1262 IRQ +#define LORA_DIO2 48 // SX1262 BUSY +#define LORA_DIO3 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO2 +#define SX126X_RESET LORA_RESET +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define USE_VIRTUAL_KEYBOARD 1 +#define DISPLAY_CLOCK_FRAME 1 diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 5997cf1fbf..7787adc9c4 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -57,6 +57,9 @@ build_flags_common = -std=gnu17 -std=gnu++17 -DMAX_TFT_COLOR_REGIONS=64 + ; Unity omits double support unless asked, compiling TEST_ASSERT_DOUBLE_* into an + ; unconditional "Unity Double Precision Disabled" failure (test_gps_update_scheduling). + -DUNITY_INCLUDE_DOUBLE build_flags = ${portduino_base.build_flags_common} diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 37d5bf2a08..81b96f3e84 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -32,9 +32,16 @@ build_flags = ${native_base.build_flags} ; assertions. Registered here rather than only in bin/run-tests.sh so a bare `pio test` and CI get ; the same boundary. See bin/pio-test-isolate.sh. ; https://docs.platformio.org/en/latest/projectconf/sections/env/options/test/test_testing_command.html +; -s matches [env:coverage]. The sandbox above only covers $HOME, but portduinoSetup() searches +; ./config.yaml and /etc/meshtasticd/config.yaml - absolute, so no $HOME sandbox can hide it. On a +; host running meshtasticd that config selects the real LoRa module and the run proceeds into GPIO +; and SPI setup, so a test run would drive the developer's own radio. -s short-circuits ahead of the +; config search and returns before hardware init. Suites asserting behaviour that simradio changes +; (PKC selection) clear the flag themselves in setUp, after the radio choice is already made. test_testing_command = ${platformio.src_dir}/../bin/pio-test-isolate.sh ${platformio.build_dir}/${this.__env__}/meshtasticd + -s [env:native-tft] extends = native_base diff --git a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini index 1b36d2da93..2b6b9aabfc 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini @@ -18,7 +18,6 @@ build_flags = -DELECROW_ThinkNode_M3 -DGPS_POWER_TOGGLE -D CONFIG_NFCT_PINS_AS_GPIOS=1 - -L "${platformio.libdeps_dir}/${this.__env__}/bsec2/src/cortex-m4/fpv4-sp-d16-hard" build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/ELECROW-ThinkNode-M3> lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini index dfbd918765..a72b8c61e6 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini @@ -20,18 +20,6 @@ build_flags = ${nrf52840_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo> debug_tool = jlink -; TEMPORARY: drop BSEC2 + its BME68x driver. This image is ~2.3 KB OVER the 0xEA000 -; warm-store cap and has been failing the nrf52_warm_region guard on develop since -; 2026-08-05. Unlike the RAK boards there is no Ethernet stack to reclaim here -- nrf52_base -; already filters mesh/eth, mesh/api and mesh/wifi, and HAS_ETHERNET defaults to 0 -- so the -; sensor library is what has to go. BME680Sensor is gated on __has_include(), so -; ignoring the libraries compiles it out. Revert once the environmental sensor roster is -; opt-in per board rather than linked into every target. -lib_ignore = - ${nrf52_base.lib_ignore} - bsec2 - BME68x Sensor library - ; NRF52 ProMicro w/ E-Ink display [env:nrf52_promicro_diy-inkhud] board_level = extra diff --git a/variants/nrf52840/muzi_base/platformio.ini b/variants/nrf52840/muzi_base/platformio.ini index 90c871c200..3a24942818 100644 --- a/variants/nrf52840/muzi_base/platformio.ini +++ b/variants/nrf52840/muzi_base/platformio.ini @@ -15,7 +15,6 @@ build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/muzi_base -D MUZI_BASE -D CONFIG_NFCT_PINS_AS_GPIOS=1 - -L "${platformio.libdeps_dir}/${this.__env__}/bsec2/src/cortex-m4/fpv4-sp-d16-hard" build_src_filter = ${nrf52840_base.build_src_filter} +<../variants/nrf52840/muzi_base> lib_deps = diff --git a/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/platformio.ini b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/platformio.ini new file mode 100644 index 0000000000..772c0152e6 --- /dev/null +++ b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/platformio.ini @@ -0,0 +1,22 @@ +[env:seeed_wio_tracker_L1_Pro_1W] +custom_meshtastic_hw_model = 144 +custom_meshtastic_hw_model_slug = SEEED_WIO_TRACKER_L1_PRO_1W +custom_meshtastic_architecture = nrf52840 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Seeed Wio Tracker L1 Pro 1W +custom_meshtastic_images = wio_tracker_l1_case.svg +custom_meshtastic_tags = Seeed +custom_meshtastic_requires_dfu = true + +board = seeed_wio_tracker_L1_Pro_1W +board_level = release +extends = nrf52840_base +build_flags = ${nrf52840_base.build_flags} + -I variants/nrf52840/seeed_wio_tracker_L1_Pro_1W + -D SEEED_WIO_TRACKER_L1_PRO_1W + -I src/platform/nrf52/softdevice + -I src/platform/nrf52/softdevice/nrf52 +board_build.ldscript = src/platform/nrf52/nrf52840_s140_v7.ld +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/seeed_wio_tracker_L1_Pro_1W> +debug_tool = jlink diff --git a/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.cpp b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.cpp new file mode 100644 index 0000000000..b957db314e --- /dev/null +++ b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.cpp @@ -0,0 +1,93 @@ +/* + * Digital pin mapping (logical Dx to nRF Port.Pin) and initVariant() for the + * Seeed Wio Tracker L1 Pro 1W. + */ + +#include "variant.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +/** + * @brief Digital pin to GPIO port/pin mapping table + * + * Format: Logical Pin (Dx) -> nRF Port.Pin (Px.xx) + */ +extern "C" { +const uint32_t g_ADigitalPinMap[] = { + // D0 .. D10 - Peripheral control pins + 41, // D0 P1.09 GNSS_WAKEUP + 7, // D1 P0.07 LORA_DIO1 + 39, // D2 P1.07 LORA_RESET + 42, // D3 P1.10 LORA_BUSY + 46, // D4 P1.14 LORA_CS + 29, // D5 P0.29 (AIN5) LORA_VDET, Pro 1W uses P0.29 not P1.08 + 27, // D6 P0.27 GNSS_TX + 26, // D7 P0.26 GNSS_RX + 30, // D8 P0.30 SPI_SCK + 3, // D9 P0.03 SPI_MISO + 28, // D10 P0.28 SPI_MOSI + + // D11-D12 - LED outputs / Buzzer + 33, // D11 P1.01 Mesh_LED (orange), Pro 1W uses P1.01 not P1.15 + 32, // D12 P1.00 Buzzer, shared with the LED_BLUE macro alias + + // D13 - User input + 8, // D13 P0.08 User Button + + // D14-D15 - OLED I2C0 + 6, // D14 P0.06 OLED SDA + 5, // D15 P0.05 OLED SCL + + // D16 - Battery voltage ADC + 31, // D16 P0.31 VBAT_ADC + + // D17-D18 - Grove I2C1 + 43, // D17 P1.11 GROVE SCL + 44, // D18 P1.12 GROVE SDA + + // D19-D24 - QSPI Flash + 21, // D19 P0.21 QSPI_SCK + 25, // D20 P0.25 QSPI_CSN + 20, // D21 P0.20 QSPI_SIO_0 + 24, // D22 P0.24 QSPI_SIO_1 + 22, // D23 P0.22 QSPI_SIO_2 + 23, // D24 P0.23 QSPI_SIO_3 + + // D25-D29 - Trackball + 36, // D25 TB_UP + 12, // D26 TB_DOWN + 11, // D27 TB_LEFT + 35, // D28 TB_RIGHT + 37, // D29 TB_PRESS + + // D30 - Battery divider enable + 4, // D30 P0.04 BAT_CTL + + // D31-D33 - Pro 1W only + 13, // D31 P0.13 BOOST_EN (Grove 5V Boost) + 47, // D32 P1.15 nRF_Sig_Charge_State (BQ25616 STAT) + 14, // D33 P0.14 LORA_PWR_EN (SX1262 + 1 W PA LDO) +}; +} + +void initVariant() +{ + pinMode(PIN_QSPI_CS, OUTPUT); + digitalWrite(PIN_QSPI_CS, HIGH); + + // Enable battery divider for ADC sampling + pinMode(BAT_READ, OUTPUT); + digitalWrite(BAT_READ, HIGH); + + // Grove 5V Boost: default OFF to save power at boot / shipping state. + // Apps that need Grove 5V can re-enable by writing BOOST_EN_ACTIVE to PIN_BOOST_EN. + pinMode(PIN_BOOST_EN, OUTPUT); + digitalWrite(PIN_BOOST_EN, !BOOST_EN_ACTIVE); + + // LED: default off + pinMode(PIN_LED1, OUTPUT); + digitalWrite(PIN_LED1, LOW); + // PIN_LED2 (D12) shares the buzzer pin; ExternalNotification configures it. + // Forcing it LOW here would prevent PWM output. +} diff --git a/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.h b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.h new file mode 100644 index 0000000000..75bfe887b6 --- /dev/null +++ b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.h @@ -0,0 +1,201 @@ +#ifndef _SEEED_TRACKER_L1_PRO_1W_H_ +#define _SEEED_TRACKER_L1_PRO_1W_H_ + +#include "WVariant.h" + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Clock Configuration +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define VARIANT_MCK (64000000ul) // Master clock frequency +#define USE_LFXO // 32.768kHz crystal for LFCLK + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Pin Capacity Definitions +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define PINS_COUNT (34u) // Total GPIO pins (D0-D33) +#define NUM_DIGITAL_PINS (34u) // Digital I/O pins +#define NUM_ANALOG_INPUTS (8u) // Analog inputs (A0-A5 + VBAT + AREF) +#define NUM_ANALOG_OUTPUTS (0u) + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// LED Configuration +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Only one real LED (orange, P1.01). PIN_LED2/LED_BLUE/LED_CONN alias D12 (buzzer) for +// ABI compatibility with app code; they drive no hardware LED. +#define PIN_LED1 (11) // Mesh_LED orange P1.01 +#define PIN_LED2 (12) // buzzer pin (no real LED on L1 Pro 1W) + +#define LED_GREEN PIN_LED1 +#define LED_BLUE PIN_LED2 +#define LED_STATE_ON 1 // State when LED is lit + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Button Configuration +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define CANCEL_BUTTON_PIN D13 // Program Button +#define CANCEL_BUTTON_ACTIVE_LOW true +#define CANCEL_BUTTON_ACTIVE_PULLUP false + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Digital Pin Mapping (D0-D32) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// D5 / D11 / D31 / D32 are the Pro 1W V1.0 hardware-revision pins +#define D0 0 // P1.09 GNSS_WAKEUP/IO0 +#define D1 1 // P0.07 LORA_DIO1 +#define D2 2 // P1.07 LORA_RESET +#define D3 3 // P1.10 LORA_BUSY +#define D4 4 // P1.14 LORA_CS +#define D5 5 // P0.29 LORA_VDET (AIN5), replaces the stock L1 LORA_SW on P1.08 +#define D6 6 // P0.27 GNSS_TX +#define D7 7 // P0.26 GNSS_RX +#define D8 8 // P0.30 SPI_SCK +#define D9 9 // P0.03 SPI_MISO +#define D10 10 // P0.28 SPI_MOSI +#define D11 11 // P1.01 Mesh_LED (orange) +#define D12 12 // P1.00 Buzzer +#define D13 13 // P0.08 User Button +#define D14 14 // P0.06 OLED SDA +#define D15 15 // P0.05 OLED SCL +#define D16 16 // P0.31 VBAT_ADC +#define D17 17 // P1.11 Grove I2C1 SCL +#define D18 18 // P1.12 Grove I2C1 SDA +#define D31 31 // P0.13 BOOST_EN (Grove 5V Boost enable), new on Pro 1W +#define D32 32 // P1.15 nRF_Sig_Charge_State (BQ25616 STAT), new on Pro 1W +#define D33 33 // P0.14 LORA_PWR_EN (SX1262 + 1 W PA LDO), new on Pro 1W + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Analog Pin Definitions +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define PIN_A0 0 // P0.02 Analog Input 0 +#define PIN_A1 1 // P0.03 Analog Input 1 +#define PIN_A2 2 // P0.28 Analog Input 2 +#define PIN_A3 3 // P0.29 Analog Input 3 +#define PIN_A4 4 // P0.04 Analog Input 4 +#define PIN_A5 5 // P0.05 Analog Input 5 +#define PIN_VBAT D16 // P0.31 Battery voltage sense + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Communication Interfaces +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// I2C Configuration +#define PIN_WIRE_SDA D14 // P0.06 OLED SDA +#define PIN_WIRE_SCL D15 // P0.05 OLED SCL +#define WIRE_INTERFACES_COUNT 2 +#define PIN_WIRE1_SDA D18 +#define PIN_WIRE1_SCL D17 +#define I2C_NO_RESCAN + +static const uint8_t SDA = PIN_WIRE_SDA; +static const uint8_t SCL = PIN_WIRE_SCL; + +#define HAS_SCREEN 1 +#define USE_SSD1306 1 + +// SPI Configuration (SX1262) +#define SPI_INTERFACES_COUNT 1 +#define PIN_SPI_MISO 9 // P0.03 (D9) +#define PIN_SPI_MOSI 10 // P0.28 (D10) +#define PIN_SPI_SCK 8 // P0.30 (D8) + +// SX1262 LoRa Module Pins +#define USE_SX1262 +#define SX126X_CS D4 // Chip select +#define SX126X_DIO1 D1 // Digital IO 1 (Interrupt) +#define SX126X_BUSY D3 // Busy status +#define SX126X_RESET D2 // Reset control +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 // TCXO supply voltage +#define SX126X_RXEN RADIOLIB_NC +#define SX126X_TXEN RADIOLIB_NC +#define SX126X_DIO2_AS_RF_SWITCH // DIO2 controls antenna switch (no external RXEN/TXEN) + +// SX1262 drives a 1 W external PA; use the fixed PA config, not RadioLib's table. +#define SX126X_NO_POWER_OPTIMIZATION_TABLE + +// Chip-side drive ceiling; limitPower() already subtracted the PA gain. TODO: verify on bench. +#define SX126X_MAX_POWER 22 + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Power Management +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define BAT_READ 30 // D30 = P0.04 Battery divider enable (BAT_CTL) on signal board. +#define ADC_CTRL BAT_READ +#define ADC_CTRL_ENABLED HIGH +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define ADC_MULTIPLIER 2.0 +#define BATTERY_PIN PIN_VBAT +#define AREF_VOLTAGE 3.6 +// We rely on the nrf52840 USB controller to tell us if we are hooked to a power supply +#define NRF_APM + +// BQ25616 single-wire charge status (Pro 1W) +#define PIN_BOOST_EN D31 // D31 / P0.13, Grove 5V Boost enable +#define EXT_CHRG_DETECT D32 // D32 / P1.15, BQ25616 STAT +#define EXT_CHRG_DETECT_VALUE LOW // 0 = charging, 1 = full / charger sleep +#define BOOST_EN_ACTIVE HIGH // HIGH enables Grove 5V Boost + +// External LDO enable for the SX1262 + 1 W PA. D33 rather than raw GPIO 14 because +// g_ADigitalPinMap[14] is D14 (OLED SDA). init() drives it HIGH; deep sleep does not clear it. +#define LORA_PWR_EN D33 +#define SX126X_POWER_EN LORA_PWR_EN + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// GPS L76KB +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define GPS_L76K +#ifdef GPS_L76K +#define GPS_TX_PIN D6 // P0.26 - This is data from the MCU +#define GPS_RX_PIN D7 // P0.27 - This is data from the GNSS +#define HAS_GPS 1 +#define GPS_BAUDRATE 9600 +#define GPS_THREAD_INTERVAL 50 +#define PIN_SERIAL1_RX GPS_RX_PIN +#define PIN_SERIAL1_TX GPS_TX_PIN + +#define PIN_GPS_STANDBY D0 +#endif + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// On-board QSPI Flash +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Logical pin indices; the QSPI block is at D19-D24 in variant.cpp. +#define PIN_QSPI_SCK (19) +#define PIN_QSPI_CS (20) +#define PIN_QSPI_IO0 (21) +#define PIN_QSPI_IO1 (22) +#define PIN_QSPI_IO2 (23) +#define PIN_QSPI_IO3 (24) + +#define EXTERNAL_FLASH_DEVICES P25Q16H +#define EXTERNAL_FLASH_USE_QSPI + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Buzzer +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define PIN_BUZZER D12 // P1.00, pwm output + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Trackball +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define CANNED_MESSAGE_ADD_CONFIRMATION 1 + +#define HAS_TRACKBALL 1 +#define TB_UP 25 +#define TB_DOWN 26 +#define TB_LEFT 27 +#define TB_RIGHT 28 +#define TB_PRESS 29 +#define TB_DIRECTION FALLING + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Compatibility Definitions +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#ifdef __cplusplus +extern "C" { +#endif +#define PIN_SERIAL2_RX (-1) +#define PIN_SERIAL2_TX (-1) +#ifdef __cplusplus +} +#endif + +#endif // _SEEED_TRACKER_L1_PRO_1W_H_ diff --git a/variants/nrf52840/t-echo-plus/variant.h b/variants/nrf52840/t-echo-plus/variant.h index 7ebdf48c02..edc6ff66a2 100644 --- a/variants/nrf52840/t-echo-plus/variant.h +++ b/variants/nrf52840/t-echo-plus/variant.h @@ -59,7 +59,7 @@ static const uint8_t A0 = PIN_A0; #define WIRE_INTERFACES_COUNT 1 #define PIN_WIRE_SDA (0 + 26) #define PIN_WIRE_SCL (0 + 27) -#define HAS_BHI260AP +// #define HAS_BHI260AP ; lewisxhe/SensorLib too big for nrf52 #define TP_SER_IO (0 + 11) diff --git a/variants/stm32/nucleo_wl55jc/platformio.ini b/variants/stm32/nucleo_wl55jc/platformio.ini new file mode 100644 index 0000000000..9ff211a195 --- /dev/null +++ b/variants/stm32/nucleo_wl55jc/platformio.ini @@ -0,0 +1,23 @@ +; ST Nucleo-WL55JC dev board +; https://www.st.com/en/evaluation-tools/nucleo-wl55jc.html +[env:nucleo_wl55jc] +extends = stm32_base +board = nucleo_wl55jc +board_level = extra +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem +build_flags = + ${stm32_base.build_flags} + -Ivariants/stm32/nucleo_wl55jc + -DPRIVATE_HW + -DENABLE_HWSERIAL2 + -DHAS_GPS=1 + -DGPS_SERIAL_PORT=Serial2 ; Default Serial object is used for onboard ST-Link VCP + -DHAS_SENSOR=1 +lib_deps = + ${stm32_base.lib_deps} + # renovate: datasource=github-tags depName=STM32RTC packageName=stm32duino/STM32RTC + https://github.com/stm32duino/STM32RTC/archive/refs/tags/1.9.0.zip + # renovate: datasource=github-tags depName=STM32LowPower packageName=stm32duino/STM32LowPower + https://github.com/stm32duino/STM32LowPower/archive/refs/tags/1.5.0.zip + +upload_port = stlink diff --git a/variants/stm32/nucleo_wl55jc/rfswitch.h b/variants/stm32/nucleo_wl55jc/rfswitch.h new file mode 100644 index 0000000000..04db6192a2 --- /dev/null +++ b/variants/stm32/nucleo_wl55jc/rfswitch.h @@ -0,0 +1,9 @@ +// Canonical RF switch macros from variant_NUCLEO_WL55JC1.h +// UM2592 S6.6.3: RF overview +static const RADIOLIB_PIN_TYPE rfswitch_pins[5] = {LORAWAN_RFSWITCH_PINS, RADIOLIB_NC, RADIOLIB_NC}; + +static const Module::RfSwitchMode_t rfswitch_table[5] = {{STM32WLx::MODE_IDLE, {LORAWAN_RFSWITCH_OFF_VALUES}}, + {STM32WLx::MODE_RX, {LORAWAN_RFSWITCH_RX_VALUES}}, + {STM32WLx::MODE_TX_LP, {LORAWAN_RFSWITCH_RFO_LP_VALUES}}, + {STM32WLx::MODE_TX_HP, {LORAWAN_RFSWITCH_RFO_HP_VALUES}}, + END_OF_MODE_TABLE}; diff --git a/variants/stm32/nucleo_wl55jc/variant.h b/variants/stm32/nucleo_wl55jc/variant.h new file mode 100644 index 0000000000..18af214f2b --- /dev/null +++ b/variants/stm32/nucleo_wl55jc/variant.h @@ -0,0 +1,59 @@ +/* +ST Nucleo-WL55JC (MB1389) +https://www.st.com/en/evaluation-tools/nucleo-wl55jc.html +*/ + +#ifndef _VARIANT_NUCLEO_WL55JC_ +#define _VARIANT_NUCLEO_WL55JC_ + +#define USE_STM32WLx + +// Pin mappings from UM2592: User Manual, STM32WL Nucleo-64 board (MB1389) +// https://www.st.com/resource/en/user_manual/um2592-stm32wl-nucleo64-board-mb1389-stmicroelectronics.pdf + +// Human-readable pin macros from variant_NUCLEO_WL55JC1.h + +// UM2592 S6.6.1: LEDs +#define LED_POWER LED_GREEN +#define LED_STATE_ON 1 +#define LED_LORA LED_RED +#define LED_NOTIFICATION LED_BLUE + +// UM2592 S6.6.2: Push-buttons +#define BUTTON_PIN B1_BTN // WKUP1-capable +#define BUTTON_NEED_PULLUP +#define ALT_BUTTON_PIN B2_BTN +#define CANCEL_BUTTON_PIN B3_BTN +#define CANCEL_BUTTON_ACTIVE_LOW true +#define CANCEL_BUTTON_ACTIVE_PULLUP true + +// UM2592 S7.4: Arduino UNO R3 connectors - SPI +// Arduino UNO R3 header: CS/D10, MOSI/D11, MISO/D12, SCK/D13 +#define PIN_SPI_MOSI PA7 +#define PIN_SPI_MISO PA6 +#define PIN_SPI_SCK PA5 + +// UM2592 S7.4: Arduino UNO R3 connectors - UART (GPS, etc.) +// Arduino UNO R3 header: RX/D0, TX/D1 +#define PIN_SERIAL2_TX PB6 +#define PIN_SERIAL2_RX PB7 + +// UM2592 S7.4: Arduino UNO R3 connectors - I2C +// Arduino UNO R3 header: SDA/D14, SCL/D15 +#define PIN_WIRE_SDA PA11 +#define PIN_WIRE_SCL PA12 + +// RM0453 S18.10: Battery voltage monitoring +// Internal VBAT ADC channel; VBAT bridged to VDD_SYS by SB21 +#define BATTERY_PIN AVBAT +#define ADC_MULTIPLIER (1.01f * 3) + +// UM2592 S6.5.2: LSE clock +#define HAS_LSE 1 +#define STM32WL_LSE_DRIVE RCC_LSEDRIVE_LOW + +// UM2592 S6.5.1: HSE clock (used for sub-GHz radio as well) +// NDK NT2016SF-32M-END5875A +#define SX126X_DIO3_TCXO_VOLTAGE 1.7 + +#endif