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 1786759aa0..3d20ca974f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -313,7 +313,7 @@ firmware/ │ └── native/ # Linux/Portduino variants ├── protobufs/ # Protocol buffer definitions ├── boards/ # Custom PlatformIO board definitions -├── test/ # Native unit-test suites (count: test/native-suite-count) +├── test/ # Native unit-test suites (count = the test_* dirs, detected on the fly) └── bin/ # Build and utility scripts ``` @@ -332,12 +332,25 @@ firmware/ - Follow existing code style - run `trunk fmt` before commits - Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging +- **Three logging tiers for diagnostics.** `LOG_TRACE` is the per-packet/per-poll firehose - compiled out by default (`MESHTASTIC_TRACE_LOGGING=1` enables; always on for portduino). Subsystem bring-up detail routes through a per-subsystem gate macro instead, e.g. `LOG_DEBUG_GPS(...)` in `src/gps/GPSLog.h` (`GPS_DEBUG=1` enables; costs no flash when off) - model new subsystem gates on it or on `LOG_MIGRATION` (`src/mesh/WarmNodeStore.h`): `#ifndef` value-default, `#if SYM` value test, `((void)0)` off-branch. Genuine anomalies stay unconditional `LOG_WARN`/`LOG_ERROR`. - **Format node IDs and packet IDs as `0x%08x` in logs.** This covers `NodeNum`/`PacketId` and the `uint32_t` packet fields `from`, `to`, `id`, `dest`, `source`, `request_id`, and `node_id`. They are 32-bit, so 8 hex digits is exact - `%08x` never truncates or leaves a value ragged. Do **not** use `%x` (variable width) or `%0x` (a no-op typo for `%08x` - the `0` flag does nothing without a width). User-facing display uses `!%08x` (the `!xxxxxxxx` convention), e.g. `Applet::hexifyNodeNum`. - **Do not zero-pad one-byte values to 8.** `next_hop`, `relay_node`, and the next-hop hint are `uint8_t` last-byte route hints, and `channel` is a one-byte hash/index - log these as `0x%x` (or `%d`). Padding a byte to `0x000000ab` falsely implies a full node number. The same goes for I2C addresses, register values, flags/bitmasks, and error/reason codes: they are not IDs, so leave them `0x%x`. - Use `assert()` for invariants that should never fail - C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.) - **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. -- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check - raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly. +- **Documentation does not live in this repo. Do not add it here.** This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. +- **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison). + - `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown. + - `Throttle::hasElapsed(lastMs, intervalMs)` - its complement, true once the interval has passed (inclusive `>=`). Prefer this to spelling `!isWithinTimespanMs(...)`. + - `Throttle::execute(&lastMs, intervalMs, func)` - function-pointer form that updates the timestamp on fire. + - `Throttle::deadlinePassed(deadlineMs)` - for a stored absolute deadline that cannot be re-expressed as "interval since an event". Uses an unsigned half-range compare; reads deadlines more than ~24.8 days out as already passed, which no interval in this firmware approaches (the longest is 24 h). + - `Throttle::deadlinePassedAt(nowMs, deadlineMs)` - the same test against a caller-supplied `now`, for a loop that snapshots the clock once and then tests many deadlines (`NextHopRouter::doRetransmissions()`). Take the snapshot from `Time::getMillis()`, not `millis()`. + + Raw `millis() > deadline` or `deadline < millis()` is rollover-unsafe: the comparison inverts while the deadline sits on the far side of the 32-bit wrap, so the action fires immediately (losing its whole wait) or blocks for roughly the interval it should have waited - days, for the nRF52 flash-corruption backoff. All five helpers subtract first, so unsigned wraparound cancels out. `Throttle` reads the clock through `Time::getMillis()` (`src/UptimeClock.h`), which means every one of its ~94 call sites is time-injectable - a native test can drive `Time::setTestMillis(0xFFFFFF00)` across the wrap. For _timestamps_ (not deadlines) there is `Time::getMillisMonotonic()` / `Time::getUptimeSecs()` - a 64-bit monotonic uptime read. Readers are pure: they add their own wrap-immune elapsed time to a snapshot published by `Time::serviceMonotonic()`, which the main loop calls every iteration and which is **the only writer**. Never call `serviceMonotonic()` from anywhere else - two writers can count one wrap twice, putting every uptime and wall-clock reading ~49.7 days into the future for the rest of the boot. Not ISR-safe (the snapshot is read under a seqlock); see the contract in `UptimeClock.h`. Deadline and interval checks should still use `Throttle`, which needs no carry state at all. + + **Sentinel hazard.** If a deadline variable also encodes "inactive" - `0` for `rebootAtMsec`, `shutdownAtMsec`, `alertBannerUntil`, `fixHoldEnds`; `UINT32_MAX` for `nagCycleCutoff` - test that sentinel _before_ the elapsed comparison, and match the test to the sentinel actually in use. `if (deadline && Throttle::deadlinePassed(deadline))` covers the `0` family only; `nagCycleCutoff` needs `deadline != UINT32_MAX`, or a separate armed flag as `ExternalNotificationModule` does with `isNagging`. Every sentinel value is arithmetically far in the past, so a correct comparison reads it as "expired" and fires immediately: `rebootAtMsec = -1` meaning "never" is what would have become a reboot loop. Never fold the sentinel into the helper. + + **And decide which way the sentinel should fall.** "Inactive" does not always mean "suppress". At the GPS fix-hold site `fixHoldEnds == 0` means _no hold is in force_, which is exactly when a new hold must be armed - the naive comparison it replaced was `(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`, always true when nothing was armed. Guarding it with `fixHoldEnds != 0 &&` looks like this rule and inverts the site: nothing re-arms, nothing publishes, and the receiver stays powered until the search timeout. Read the surrounding logic before adding the guard. `fixHoldInForce()` in `src/gps/GPS.cpp` is the worked example - state the predicate positively, so the sentinel has an honest answer, and derive both decisions from it - with `test/test_gps_fix_hold/` pinning both directions. ### Naming Conventions @@ -663,7 +676,7 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing. ### Native unit tests (C++) -Unit tests in `test/` directory. The canonical suite count is in `test/native-suite-count`, cross-checked against `test/test_*` on every full run and by the `suite-count-check` CI job. **Never state the count as a literal anywhere else** - point at that file. The list below is a partial description of what suites cover, not an inventory: +Unit tests in `test/` directory. The canonical suite count is detected on the fly: the `test_*` directories under `test/` are the register, and `bin/run-tests.sh` cross-checks the suites that actually ran against them on every full run. **Never state the count as a literal anywhere** - it is whatever `test/test_*` contains right now. In CI, the `suite-shrinkage-check` job (`test_native.yml`) fails a PR that loses a `test_*` directory relative to its merge base unless the suite is named in the PR title, body, or a commit message - deleting a suite therefore requires saying so. The list below is a partial description of what suites cover, not an inventory: - `test_admin_radio/` - LoRa region/config validation, AdminModule dispatch, node-DB metadata saves - `test_fscommon_getfiles/` - bounded file-manifest walk (cap, depth, truncation reporting) @@ -693,7 +706,7 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su - `test_utf8/` - UTF-8 utilities - `test_warm_store/` - Warm-tier node store -**Preferred run command - `bin/run-tests.sh`** (defaults to the `coverage` env; emits a machine-readable verdict on the final line; update `test/native-suite-count` when adding or removing suites): +**Preferred run command - `bin/run-tests.sh`** (defaults to the `coverage` env; emits a machine-readable verdict on the final line; new `test_*` directories are picked up automatically): ```bash ./bin/run-tests.sh # all suites @@ -712,18 +725,18 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su Exit codes and verdicts (exact counts will vary; examples below are illustrative): -| Exit | Verdict | Meaning | -| ---- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 0 | `GREEN` | All canonical suites ran, all passed, no ignored test cases | -| 1 | `RED` | At least one failure, build error, or sanitizer fault | -| 2 | `AMBER` | All that ran passed, but something was lost or unexplained: a suite silently went missing on a full run, individual test cases were skipped (`TEST_IGNORE`), `test/native-suite-count` disagrees with the `test/` directory count, or a suite left behind shared state it does not declare | -| 3 | `FILTERED` | A `-f` run completed cleanly; suites outside the filter were intentionally not run | +| Exit | Verdict | Meaning | +| ---- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | `GREEN` | All canonical suites ran, all passed, no ignored test cases | +| 1 | `RED` | At least one failure, build error, or sanitizer fault | +| 2 | `AMBER` | All that ran passed, but something was lost or unexplained: a suite silently went missing on a full run, individual test cases were skipped (`TEST_IGNORE`), or a suite left behind shared state it does not declare | +| 3 | `FILTERED` | A `-f` run completed cleanly; suites outside the filter were intentionally not run | Examples - exact counts will vary by suite count and env: ```text # GREEN: all suites ran and passed -RESULT: GREEN N/N suites passed [canonical: N/N] +RESULT: GREEN N/N suites passed, all CLEAN # RED: real test failure RESULT: RED 1 failed @@ -731,14 +744,11 @@ RESULT: RED 1 failed # RED: sanitizer exit-time abort (all tests passed but process aborted at exit) RESULT: RED exit-time abort (tests passed; likely sanitizer - see hint above) -# AMBER: native-suite-count disagrees with test/ directory count (too low) -RESULT: AMBER test/ has 24 suite directories but native-suite-count says 5 - update test/native-suite-count after registering new suites - -# AMBER: native-suite-count disagrees with test/ directory count (too high) -RESULT: AMBER test/ has 24 suite directories but native-suite-count says 99 - update test/native-suite-count after removing suites +# AMBER: a suite silently went missing on a full run +RESULT: AMBER 23/24 suites ran (missing: test_radio) - all that ran passed # FILTERED: single suite run completed cleanly -RESULT: FILTERED 1/24 suites ran (not run: test_admin_radio test_atak …) - filtered: test_serial [canonical: 1/24] +RESULT: FILTERED 1/24 suites ran (not run: test_admin_radio test_atak …) - filtered: test_serial ``` > **Copilot interface note:** When running tests via the Copilot chat interface, edits made through the chat may not be reflected in the on-disk files that the test binary reads. If tests pass in chat but fail locally (or vice versa), verify the files on disk match what you expect before trusting the result. Always confirm with a local terminal run. diff --git a/.github/millis-deadline-allowlist.txt b/.github/millis-deadline-allowlist.txt new file mode 100644 index 0000000000..1363acec00 --- /dev/null +++ b/.github/millis-deadline-allowlist.txt @@ -0,0 +1,22 @@ +# Allowlist for the millis-deadline-check guard in .github/workflows/test_native.yml. +# +# That guard rejects comparisons made directly against millis(), because they invert while the +# deadline sits on the far side of the 32-bit wrap. Use Throttle::deadlinePassed(deadline) or +# Throttle::hasElapsed(lastEvent, intervalMs) instead - see .github/copilot-instructions.md. +# +# Only add a line here when the comparison genuinely is not a deadline test. The usual valid case is +# an *uptime threshold*: "has the device been up for at least N ms", where there is no stored +# deadline and no event to measure from. Those still misbehave briefly after a wrap - the threshold +# is simply re-crossed - which is harmless for boot-holdoff logic and not worth new state. +# +# Format: +# Line numbers are deliberately absent so edits above an entry do not invalidate it. A `#` comment +# on the code line is stripped before matching, so do not include one here. + +# Boot holdoff, not a deadline: suppresses a phantom shutdown from floating pins during the first +# 30s of uptime. Pairs with the buttonPressStartTime > 30000 test on the same line. +src/input/ButtonThread.cpp if (millis() > 30000 && buttonPressStartTime > 30000 && _longLongPress != INPUT_BROKER_NONE && + +# Boot-window check, not a deadline: draws the custom OEM logo only during the first 10s of uptime, +# so the ordinary Meshtastic logo is used at shutdown. +src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.cpp if (millis() < 10 * 1000UL) { 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/test_native.yml b/.github/workflows/test_native.yml index 2688a07b93..04e6b3a237 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -23,13 +23,19 @@ env: LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --directory .pio/build/coverage/src --base-directory "${PWD}" jobs: - # Guard the registered native-suite total. `platformio test` discovers and runs whatever - # test_* directories exist, so it never notices when test/native-suite-count drifts from the - # actual directory count (a suite added without registering it, or the file left stale). That - # reconciliation only lives in bin/run-tests.sh, which CI does not invoke - so mirror the exact - # check here and fail the PR on a mismatch, keeping the manual count honest. - suite-count-check: - name: Native Suite Count + # Tripwire against the native suite set shrinking by accident. `platformio test` discovers and + # runs whatever test_* directories exist, and bin/run-tests.sh derives its expected count from + # the same walk - so a suite directory lost in a bad rebase or an overzealous cleanup just means + # fewer suites run, and every remaining check stays green. Compare the test_* directory list + # against the PR's merge base and fail when a suite vanished without the PR saying so: a removed + # suite's name must appear in the PR title, the PR body, or a commit message in the PR's range. + # A deliberate removal satisfies that by stating what it removes; an accidental loss cannot. + # Only pull_request runs have a base to compare against (and PRs are where accidents arrive); + # every other event skips. No job depends on this one: a skipped job would skip its dependents, + # and the expensive jobs should not wait on a full-history clone. + suite-shrinkage-check: + name: Native Suite Shrinkage + if: github.event_name == 'pull_request' runs-on: ubuntu-slim permissions: contents: read @@ -37,40 +43,111 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + # Full history: the merge base must be computed, not guessed from a possibly stale + # event payload, and the acknowledgment scan reads every commit message in the range. + fetch-depth: 0 - - name: Reconcile native-suite-count with test/ directories + - name: Fail if a test_* suite vanished unacknowledged + shell: bash + # PR title/body are attacker-controlled text; they reach the script through env: only, + # never spliced into the shell source (same rule as the suite-order seed below). + env: + BASE_REF: ${{ github.base_ref }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + set -euo pipefail + git fetch --quiet origin "$BASE_REF" + base=$(git merge-base FETCH_HEAD HEAD) + # Same canonical set every other consumer derives: directories named test_* directly + # under test/, read from the git trees so the comparison is exact at both endpoints. + list_suites() { git ls-tree -d --name-only "$1" test/ | sed 's#^test/##' | grep '^test_' | sort; } + removed=$(comm -23 <(list_suites "$base") <(list_suites HEAD)) + if [[ -z $removed ]]; then + echo "No suite removed: $(list_suites HEAD | wc -l) test_* directories, none lost since merge base ${base:0:8}." + exit 0 + fi + messages=$(git log --format=%B "$base..HEAD") + fail=0 + while IFS= read -r suite; do + if printf '%s\n%s\n%s\n' "$PR_TITLE" "$PR_BODY" "$messages" | grep -qF "$suite"; then + echo "Removed suite $suite is named in the PR title/body or a commit message - acknowledged." + else + echo "::error title=Native suite vanished::test/$suite exists on the merge base but is gone from this PR, and nothing in the PR title, body, or commit messages mentions it. If the removal is deliberate, name $suite in the PR description or a commit message; if not, restore the directory - platformio test would silently run without it." + fail=1 + fi + done <<<"$removed" + exit $fail + + # Reject naive deadline comparisons against the 32-bit uptime clocks. `millis() > deadline` and + # `deadline < millis()` invert while the deadline sits on the far side of the 32-bit wrap: the + # action fires immediately, or blocks for about the interval it should have waited. The correct + # forms are + # Throttle::isWithinTimespanMs / hasElapsed (elapsed since a stored event) and + # Throttle::deadlinePassed (an absolute deadline). See .github/copilot-instructions.md. + millis-deadline-check: + # Name is load-bearing: upstream branch protection matches the check by name. Widen the guard, + # not this string. + name: Naive millis() Deadline Compare + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Reject 32-bit uptime clocks used directly in a deadline comparison shell: bash run: | set -euo pipefail - count_file="test/native-suite-count" - if [[ ! -f $count_file ]]; then - echo "::error title=Missing native-suite-count::$count_file not found - it must record the number of test_* suite directories." - exit 1 + allowlist=".github/millis-deadline-allowlist.txt" + + # Flag millis() or its Time::getMillis() wrapper directly adjacent to a comparison + # operator, in either order. The correct idioms subtract first, so they are not matched. + # + # Line comments are stripped before matching, so prose may name the broken idiom (this + # guard's own documentation does). Block comments are not stripped; keep `millis() >` out + # of /* */ blocks. mawk-compatible - ubuntu-latest has no gawk. + find src -type f \( -name '*.cpp' -o -name '*.h' -o -name '*.hpp' -o -name '*.ino' \) \ + ! -path 'src/mesh/generated/*' -print0 | + xargs -0 awk ' + { + line = $0 + sub(/\/\/.*/, "", line) + if (line ~ /((millis|getMillis)\(\)[ \t]*[<>]=?)|([<>]=?[ \t]*(millis|getMillis)\(\))/) { + code = line + sub(/^[ \t]+/, "", code); sub(/[ \t]+$/, "", code) + printf "%s\t%s\t%s\n", FILENAME, FNR, code + } + }' > /tmp/millis-hits.tsv + + # Allowlisted entries are keyed on file + exact source text, deliberately without a line + # number, so unrelated edits above them do not invalidate the entry. + : > /tmp/millis-allowed.tsv + if [[ -f $allowlist ]]; then + grep -vE '^[[:space:]]*(#|$)' "$allowlist" > /tmp/millis-allowed.tsv || true fi - # Same canonical set as bin/run-tests.sh: directories named test_* directly under test/. - expected_count=$(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | wc -l) - canonical_count=$(tr -d '[:space:]' <"$count_file") - if ! [[ $canonical_count =~ ^[0-9]+$ ]]; then - echo "::error title=Invalid native-suite-count::$count_file must contain a single integer, got '$canonical_count'." - exit 1 - fi - echo "test/ directories: $expected_count" - echo "native-suite-count: $canonical_count" - if [[ $expected_count -ne $canonical_count ]]; then - if [[ $expected_count -gt $canonical_count ]]; then - hint="a suite was added - bump $count_file to $expected_count" - else - hint="a suite was removed - lower $count_file to $expected_count" + + violations=0 + while IFS=$'\t' read -r file line code; do + [[ -n ${file:-} ]] || continue + if grep -qxF "$(printf '%s\t%s' "$file" "$code")" /tmp/millis-allowed.tsv; then + continue fi - echo "::error title=native-suite-count mismatch::test/ has $expected_count suite directories but $count_file says $canonical_count ($hint)." + echo "$file:$line: $code" + violations=$((violations + 1)) + done < /tmp/millis-hits.tsv + + if [[ $violations -gt 0 ]]; then + echo "::error title=Naive uptime deadline compare::$violations line(s) compare a 32-bit uptime clock directly, which inverts while the deadline is on the far side of the 32-bit wrap - the action fires immediately, or blocks for about the interval it should have waited. Use Throttle::deadlinePassed(deadline) for a stored absolute deadline, or Throttle::hasElapsed(lastEvent, intervalMs) for an interval. If a match genuinely is not a deadline test (an uptime threshold, say), add it to $allowlist with a reason." exit 1 fi - echo "native-suite-count matches the $expected_count suite directories." + echo "No naive 32-bit uptime deadline comparisons in src/ (allowlist: $(wc -l < /tmp/millis-allowed.tsv) entr(y/ies))." simulator-tests: name: Native Simulator Tests runs-on: ubuntu-24.04-arm - needs: suite-count-check steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -169,7 +246,6 @@ jobs: platformio-tests: name: Native PlatformIO Tests runs-on: ubuntu-24.04-arm - needs: suite-count-check steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -329,13 +405,16 @@ 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: Event channel policy tests + run: platformio test -e coverage-event-policy -v --junit-output-path event-policy-testreport.xml + - name: Save test results if: always() # run this step even if previous step failed uses: actions/upload-artifact@v7 with: name: platformio-test-report-${{ steps.version.outputs.long }} overwrite: true - path: ./testreport.xml + path: ./*testreport.xml - name: Save coverage information uses: actions/upload-artifact@v7 diff --git a/.github/workflows/update_protobufs.yml b/.github/workflows/update_protobufs.yml index 657d9171c1..c30f4587e4 100644 --- a/.github/workflows/update_protobufs.yml +++ b/.github/workflows/update_protobufs.yml @@ -1,12 +1,23 @@ name: Update protobufs and regenerate classes -on: workflow_dispatch +on: + workflow_dispatch: + inputs: + protobufs_branch: + description: Branch of meshtastic/protobufs to generate from + required: true + type: choice + default: same-as-this-branch + options: + - same-as-this-branch + - master + - develop permissions: read-all jobs: update-protobufs: runs-on: ubuntu-latest - permissions: # Needed for peter-evans/create-pull-request. + permissions: contents: write pull-requests: write steps: @@ -14,22 +25,50 @@ jobs: uses: actions/checkout@v7 with: submodules: true + persist-credentials: false + + - name: Resolve protobufs branch + id: resolve + env: + INPUT_BRANCH: ${{ inputs.protobufs_branch }} + TRIGGER_BRANCH: ${{ github.ref_name }} + run: | + set -euo pipefail + if [ "$INPUT_BRANCH" = "same-as-this-branch" ]; then + BRANCH="$TRIGGER_BRANCH" + else + BRANCH="$INPUT_BRANCH" + fi + case "$BRANCH" in + master | develop) ;; + *) + echo "::error::Refusing to generate from branch '$BRANCH'" + exit 1 + ;; + esac + echo "branch=$BRANCH" >>"$GITHUB_OUTPUT" - name: Update submodule - if: ${{ github.ref_name == 'master' || github.ref_name == 'develop' }} working-directory: protobufs env: - # Use the branch that triggered the workflow as the protobuf branch. - GIT_BRANCH: ${{ github.ref_name }} + GIT_BRANCH: ${{ steps.resolve.outputs.branch }} run: | - git fetch --prune origin $GIT_BRANCH - git checkout FETCH_HEAD + set -euo pipefail + git fetch --prune origin "+refs/heads/${GIT_BRANCH}:refs/remotes/origin/${GIT_BRANCH}" + git checkout --detach "refs/remotes/origin/${GIT_BRANCH}" + git rev-parse HEAD - name: Download nanopb + env: + NANOPB_VERSION: 0.4.9.1 + NANOPB_SHA256: 951a9ab2385424a4cdf245d0c84f4c88c6ccbc65a0dade4b246d50c068f24128 run: | - wget https://github.com/nanopb/nanopb/releases/download/nanopb-0.4.9.1/nanopb-0.4.9.1-linux-x86.tar.gz - tar xvzf nanopb-0.4.9.1-linux-x86.tar.gz - mv nanopb-0.4.9.1-linux-x86 nanopb-0.4.9 + set -euo pipefail + TARBALL="nanopb-${NANOPB_VERSION}-linux-x86.tar.gz" + wget -q "https://github.com/nanopb/nanopb/releases/download/nanopb-${NANOPB_VERSION}/${TARBALL}" + echo "${NANOPB_SHA256} ${TARBALL}" | sha256sum -c - + tar xzf "${TARBALL}" + mv "nanopb-${NANOPB_VERSION}-linux-x86" nanopb-0.4.9 - name: Re-generate protocol buffers run: | @@ -38,10 +77,12 @@ jobs: - name: Create pull request uses: peter-evans/create-pull-request@v8 with: - branch: create-pull-request/update-protobufs-${{ github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + branch: create-pull-request/update-protobufs-${{ github.ref_name }}-from-${{ steps.resolve.outputs.branch }} labels: submodules title: Update protobufs and classes commit-message: Update protobufs add-paths: | protobufs src/mesh + diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 7b45c5f830..aec3fc6f87 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -151,6 +151,16 @@ lint: - linters: [ascii-dash] paths: - src/graphics/fonts/** + # millis()-wraparound tests pin dense clusters of hex boundary constants + # (0xFFFFFF00u and neighbors). trufflehog's Lob detector stitches nearby + # hex literals into one candidate string and the result happens to match + # a Lob API key shape. Not secrets - deterministic test fixtures for the + # 32-bit rollover. + - linters: [trufflehog] + paths: + - test/test_airtime/test_main.cpp + - test/test_throttle/test_main.cpp + - test/test_uptime_clock/test_main.cpp runtimes: enabled: - python@3.14.4 diff --git a/AGENTS.md b/AGENTS.md index 9dc3fa22e6..66a8ca6847 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,19 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor - **Never edit or commit files under `src/mesh/generated/`.** They are regenerated from the [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) repo by the `update_protobufs.yml` workflow (entry point: `bin/regen-protos.sh`). Local edits will be overwritten and create merge conflicts. If a `.proto` change is needed, open a PR against the protobufs repo first, then let the workflow re-sync this repo. - **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings. - **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. -- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check - raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly. +- **Documentation does not live in this repo. Do not add it here.** This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. +- **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison). + - `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown. + - `Throttle::hasElapsed(lastMs, intervalMs)` - its complement, true once the interval has passed (inclusive `>=`). Prefer this to spelling `!isWithinTimespanMs(...)`. + - `Throttle::execute(&lastMs, intervalMs, func)` - function-pointer form that updates the timestamp on fire. + - `Throttle::deadlinePassed(deadlineMs)` - for a stored absolute deadline that cannot be re-expressed as "interval since an event". + - `Throttle::deadlinePassedAt(nowMs, deadlineMs)` - the same test against a caller-supplied `now`, for a loop that snapshots the clock once and tests many deadlines. Snapshot from `Time::getMillis()`. + + Raw `millis() > deadline` or `deadline < millis()` is rollover-unsafe: the comparison inverts while the deadline sits on the far side of the 32-bit wrap, so the action fires immediately or blocks for roughly the interval it should have waited. All five helpers subtract first, so unsigned wraparound cancels out. `Throttle` reads the clock through `Time::getMillis()` (`src/UptimeClock.h`), so all ~94 of its call sites are time-injectable and a native test can drive the wrap with `Time::setTestMillis()`. + + **Sentinel hazard.** If a deadline variable also encodes "inactive" (`0` for `rebootAtMsec`, `shutdownAtMsec`, `alertBannerUntil`, `fixHoldEnds`; `UINT32_MAX` for `nagCycleCutoff`), test that sentinel _before_ the elapsed comparison - every such value is arithmetically far in the past, so a correct comparison fires on it immediately. Match the test to the sentinel in use: `if (deadline && Throttle::deadlinePassed(deadline))` covers the `0` family, `nagCycleCutoff` needs `deadline != UINT32_MAX` or a separate armed flag (`isNagging`). + + Then decide which way the sentinel should fall - "inactive" does not always mean "suppress". At the GPS fix-hold site `fixHoldEnds == 0` means _no hold is in force_, which is exactly when one must be armed; guarding it with `fixHoldEnds != 0 &&` looks like this rule and inverts the site. See `fixHoldInForce()` in `src/gps/GPS.cpp` and `test/test_gps_fix_hold/`. ## Typical agent workflows @@ -131,7 +143,7 @@ Sequence these; don't parallelize on the same port. | `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers | | `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) | | `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` | -| `test/` | Firmware unit tests (count: `test/native-suite-count`; `./bin/run-tests.sh` preferred, falls back to `pio test -e native`) | +| `test/` | Firmware unit tests (count = the `test_*` dirs, detected on the fly; `./bin/run-tests.sh` preferred, falls back to `pio test -e native`) | | [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) | Standalone MCP server + tiered pytest hardware harness (`unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/`) - registered here via `.mcp.json` | | `.github/prompts/` | Copilot prompt bodies (firmware scaffolding: new module / sensor / variant) | | `.github/copilot-instructions.md` | **Primary agent instructions - read this** | diff --git a/CLAUDE.md b/CLAUDE.md index c7150cd2d0..a7dbf6991e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,13 +11,18 @@ > > **Need this? It's here.** > -> | | | -> | ------------------------------------------- | ---------------------------------------------------------- | -> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` | -> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` | -> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` | -> | Observer / event wiring | `src/Observer.h` | +> | | | +> | --------------------------------------------------------- | ---------------------------------------------------------- | +> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` | +> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` | +> | Elapsed time / deadlines (never bare `millis()` compares) | `src/mesh/Throttle.h` | +> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` | +> | Observer / event wiring | `src/Observer.h` | **Read `.github/copilot-instructions.md` first.** That file is the canonical agent-facing document for this repo. It covers project layout, coding conventions, the build system, CI/CD, the native C++ test suite, and the MCP Server & Hardware Test Harness. Read it top-to-bottom before starting any non-trivial change. This file (`CLAUDE.md`) is a short pointer for Claude Code sessions. Slash commands live in `.claude/commands/`. + +## House rule: documentation does not live in this repo + +This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. 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/device-install.bat b/bin/device-install.bat index 69469d5810..a4e5953119 100755 --- a/bin/device-install.bat +++ b/bin/device-install.bat @@ -70,7 +70,7 @@ IF "__!FILENAME!__"=="____" ( CALL :LOG_MESSAGE ERROR "Filename containing spaces are not supported." GOTO help ) - IF NOT "__!FILENAME:.factory.bin=!__"=="__!FILENAME!__" ( + IF /I NOT "!FILENAME:~-12!"==".factory.bin" ( CALL :LOG_MESSAGE ERROR "Filename must be a firmware-*.factory.bin file." GOTO help ) @@ -111,7 +111,7 @@ IF EXIST !METAFILE! ( CALL :LOG_MESSAGE DEBUG "Determine the correct esptool command to use..." IF NOT "__%PYTHON%__"=="____" ( - SET "ESPTOOL_CMD=!PYTHON! -m esptool" + SET "ESPTOOL_CMD="!PYTHON!" -m esptool" CALL :LOG_MESSAGE DEBUG "Python interpreter supplied." ) ELSE ( CALL :LOG_MESSAGE DEBUG "Python interpreter NOT supplied. Looking for esptool..." @@ -126,12 +126,31 @@ IF NOT "__%PYTHON%__"=="____" ( ) CALL :LOG_MESSAGE DEBUG "Checking esptool command !ESPTOOL_CMD!..." -!ESPTOOL_CMD! >nul 2>&1 -IF %ERRORLEVEL% EQU 9009 ( - @REM 9009 = command not found on Windows +@REM %VAR% not !VAR!: cmd will not split a delayed-expanded command token that +@REM carries a path, so the "python -m esptool" form never starts. +%ESPTOOL_CMD% >nul 2>&1 +SET "ESPTOOL_EXIT=!ERRORLEVEL!" +@REM 9009 = command not found, 3 = bad path from -P. Both mean unusable. +IF !ESPTOOL_EXIT! EQU 3 SET "ESPTOOL_EXIT=9009" +IF !ESPTOOL_EXIT! EQU 9009 ( CALL :LOG_MESSAGE ERROR "esptool not found: !ESPTOOL_CMD!" EXIT /B 1 ) + +@REM esptool v5 renamed subcommands to dashes; older versions only take underscores. +@REM Probe here: the --debug and --port rewrites below leave ESPTOOL_CMD unusable. +SET "ESPTOOL_WRITE_FLASH=write_flash" +SET "ESPTOOL_ERASE_FLASH=erase_flash" +SET "ESPTOOL_READ_FLASH_STATUS=read_flash_status" +%ESPTOOL_CMD% 2>&1 | findstr /C:"write-flash" >nul +IF !ERRORLEVEL! EQU 0 ( + SET "ESPTOOL_WRITE_FLASH=write-flash" + SET "ESPTOOL_ERASE_FLASH=erase-flash" + SET "ESPTOOL_READ_FLASH_STATUS=read-flash-status" +) +CALL :RESET_ERROR +CALL :LOG_MESSAGE DEBUG "Using esptool write command: !ESPTOOL_WRITE_FLASH!" + IF %DEBUG% EQU 1 ( CALL :LOG_MESSAGE DEBUG "Skipping ESPTOOL_CMD steps." SET "ESPTOOL_CMD=REM !ESPTOOL_CMD!" @@ -148,7 +167,7 @@ CALL :LOG_MESSAGE INFO "Using esptool baud: !ESPTOOL_BAUD!." IF %BPS_RESET% EQU 1 ( @REM Attempt to change mode via 1200bps Reset. - CALL :RUN_ESPTOOL 1200 --after no_reset read_flash_status + CALL :RUN_ESPTOOL 1200 --after no_reset !ESPTOOL_READ_FLASH_STATUS! GOTO eof ) @@ -174,14 +193,14 @@ IF NOT EXIST !SPIFFS_FILENAME! CALL :LOG_MESSAGE ERROR "File does not exist: "!S @REM Flashing operations. CALL :LOG_MESSAGE INFO "Trying to flash "!FILENAME!", but first erasing and writing system information..." -CALL :RUN_ESPTOOL !ESPTOOL_BAUD! erase_flash || GOTO eof -CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write_flash 0x00 "!FILENAME!" || GOTO eof +CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_ERASE_FLASH! || GOTO eof +CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! 0x00 "!FILENAME!" || GOTO eof CALL :LOG_MESSAGE INFO "Trying to flash BLEOTA "!OTA_FILENAME!" at OTA_OFFSET !OTA_OFFSET!..." -CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write_flash !OTA_OFFSET! "!OTA_FILENAME!" || GOTO eof +CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! !OTA_OFFSET! "!OTA_FILENAME!" || GOTO eof CALL :LOG_MESSAGE INFO "Trying to flash SPIFFS "!SPIFFS_FILENAME!" at SPIFFS_OFFSET !SPIFFS_OFFSET!..." -CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write_flash !SPIFFS_OFFSET! "!SPIFFS_FILENAME!" || GOTO eof +CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! !SPIFFS_OFFSET! "!SPIFFS_FILENAME!" || GOTO eof CALL :LOG_MESSAGE INFO "Script complete!." @@ -198,7 +217,7 @@ EXIT /B %ERRORLEVEL% @REM Example:: CALL :RUN_ESPTOOL 115200 write_flash 0x10000 "firmwarefile.bin" IF %DEBUG% EQU 1 CALL :LOG_MESSAGE DEBUG "About to run command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4" CALL :RESET_ERROR -!ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4 +%ESPTOOL_CMD% --baud %~1 %~2 %~3 %~4 IF %BPS_RESET% EQU 1 GOTO :eof IF %ERRORLEVEL% NEQ 0 ( CALL :LOG_MESSAGE ERROR "Error running command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4" diff --git a/bin/device-update.bat b/bin/device-update.bat index a9f7a9e1ea..e76ae946a8 100755 --- a/bin/device-update.bat +++ b/bin/device-update.bat @@ -90,7 +90,7 @@ IF NOT "__!FILENAME:.factory.bin=!__"=="__!FILENAME!__" ( CALL :LOG_MESSAGE DEBUG "Determine the correct esptool command to use..." IF NOT "__%PYTHON%__"=="____" ( - SET "ESPTOOL_CMD=""!PYTHON!"" -m esptool" + SET "ESPTOOL_CMD="!PYTHON!" -m esptool" CALL :LOG_MESSAGE DEBUG "Python interpreter supplied." ) ELSE ( CALL :LOG_MESSAGE DEBUG "Python interpreter NOT supplied. Looking for esptool..." @@ -105,13 +105,32 @@ IF NOT "__%PYTHON%__"=="____" ( ) CALL :LOG_MESSAGE DEBUG "Checking esptool command !ESPTOOL_CMD!..." -!ESPTOOL_CMD! >nul 2>&1 -CALL :LOG_MESSAGE DEBUG "esptool exit code: %ERRORLEVEL%" -IF %ERRORLEVEL% EQU 9009 ( - @REM 9009 = command not found on Windows +@REM %VAR% not !VAR!: cmd will not split a delayed-expanded command token that +@REM carries a path, so the "python -m esptool" form never starts. +%ESPTOOL_CMD% >nul 2>&1 +SET "ESPTOOL_EXIT=!ERRORLEVEL!" +CALL :LOG_MESSAGE DEBUG "esptool exit code: !ESPTOOL_EXIT!" +@REM 9009 = command not found, 3 = bad path from -P. Both mean unusable. +IF !ESPTOOL_EXIT! EQU 3 SET "ESPTOOL_EXIT=9009" +IF !ESPTOOL_EXIT! EQU 9009 ( CALL :LOG_MESSAGE ERROR "esptool not found: !ESPTOOL_CMD!" EXIT /B 1 ) + +@REM esptool v5 renamed subcommands to dashes; older versions only take underscores. +@REM Probe here: the --debug and --port rewrites below leave ESPTOOL_CMD unusable. +SET "ESPTOOL_WRITE_FLASH=write_flash" +SET "ESPTOOL_ERASE_FLASH=erase_flash" +SET "ESPTOOL_READ_FLASH_STATUS=read_flash_status" +%ESPTOOL_CMD% 2>&1 | findstr /C:"write-flash" >nul +IF !ERRORLEVEL! EQU 0 ( + SET "ESPTOOL_WRITE_FLASH=write-flash" + SET "ESPTOOL_ERASE_FLASH=erase-flash" + SET "ESPTOOL_READ_FLASH_STATUS=read-flash-status" +) +CALL :RESET_ERROR +CALL :LOG_MESSAGE DEBUG "Using esptool write command: !ESPTOOL_WRITE_FLASH!" + IF %DEBUG% EQU 1 ( CALL :LOG_MESSAGE DEBUG "Skipping ESPTOOL_CMD steps." SET "ESPTOOL_CMD=REM !ESPTOOL_CMD!" @@ -128,13 +147,13 @@ CALL :LOG_MESSAGE INFO "Using esptool baud: !ESPTOOL_BAUD!." IF %CHANGE_MODE% EQU 1 ( @REM Attempt to change mode via 1200bps Reset. - CALL :RUN_ESPTOOL !RESET_BAUD! --after no_reset read_flash_status + CALL :RUN_ESPTOOL !RESET_BAUD! --after no_reset !ESPTOOL_READ_FLASH_STATUS! GOTO eof ) @REM Flashing operations. CALL :LOG_MESSAGE INFO "Trying to flash update "!FILENAME!" at OFFSET !UPDATE_OFFSET!..." -CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write-flash !UPDATE_OFFSET! "!FILENAME!" || GOTO eof +CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! !UPDATE_OFFSET! "!FILENAME!" || GOTO eof CALL :LOG_MESSAGE INFO "Script complete!." @@ -151,7 +170,7 @@ EXIT /B %ERRORLEVEL% @REM Example:: CALL :RUN_ESPTOOL 115200 write-flash 0x10000 "firmwarefile.bin" IF %DEBUG% EQU 1 CALL :LOG_MESSAGE DEBUG "About to run command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4" CALL :RESET_ERROR -!ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4 +%ESPTOOL_CMD% --baud %~1 %~2 %~3 %~4 IF %CHANGE_MODE% EQU 1 GOTO :eof IF %ERRORLEVEL% NEQ 0 ( CALL :LOG_MESSAGE ERROR "Error running command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4" 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 1c5109282b..dcc3a705f4 100755 --- a/bin/run-tests.sh +++ b/bin/run-tests.sh @@ -163,20 +163,11 @@ export MESHTASTIC_TEST_STATE_SUMMARY="$STATE_SUMMARY" $KEEP_STATE && export MESHTASTIC_TEST_KEEP_STATE=1 $WRITE_MANIFEST && export MESHTASTIC_TEST_KEEP_STATE=1 -# Canonical suite set = the directories in test/. This is the source of truth for -# "what should run"; a filtered run only expects its filtered suite. +# Canonical suite set = the directories in test/, detected on the fly. This is the sole source +# of truth for "what should run"; a filtered run only expects its filtered suite. mapfile -t ALL_SUITES < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort) EXPECTED_COUNT=${#ALL_SUITES[@]} -# Canonical suite count - the registered total, maintained in test/native-suite-count. -# Update that file whenever a test suite is added or removed. -CANONICAL_COUNT_FILE="test/native-suite-count" -if [[ -f $CANONICAL_COUNT_FILE ]]; then - CANONICAL_COUNT=$(tr -d '[:space:]' <"$CANONICAL_COUNT_FILE") -else - CANONICAL_COUNT="" -fi - # Cached object-count for this env, written after each completed build (in the gitignored build # dir). Used as the progress denominator: accurate for a full rebuild (every object recompiles), # only a rough upper bound for an incremental run. @@ -462,31 +453,15 @@ if ! grep -qE "$PASS_RE" "$LOG"; then exit 1 fi -# Canonical-count rating suffix - appended to every verdict line so the result is always -# rated against the registered total, not just the directory count. -# If the two counts diverge (suite added/removed without updating native-suite-count), that -# is itself surfaced as AMBER before we reach any verdict. -canonical_rating() { +# Verdict-line suffix. The suite count itself is derived from the test_* directories on the fly +# (EXPECTED_COUNT above), so the only extra context a verdict needs is the shuffle seed - carried +# into the machine-readable line so a verdict is always replayable from it alone. +verdict_suffix() { local rating="" - if [[ -n $CANONICAL_COUNT ]]; then - rating="[canonical: ${RAN_COUNT}/${CANONICAL_COUNT}]" - fi - # Carry the seed into the machine-readable line so a verdict is always replayable from it alone. - $SHUFFLE && rating="$rating [seed: $SEED]" + $SHUFFLE && rating="[seed: $SEED]" echo "$rating" } -# AMBER: directory count disagrees with native-suite-count - file needs updating. -if [[ -n $CANONICAL_COUNT && $EXPECTED_COUNT -ne $CANONICAL_COUNT ]]; then - echo "" - if [[ $EXPECTED_COUNT -gt $CANONICAL_COUNT ]]; then - echo "RESULT: AMBER test/ has $EXPECTED_COUNT suite directories but native-suite-count says $CANONICAL_COUNT - update test/native-suite-count after registering new suites" - else - echo "RESULT: AMBER test/ has $EXPECTED_COUNT suite directories but native-suite-count says $CANONICAL_COUNT - update test/native-suite-count after removing suites" - fi - exit 2 -fi - # --- 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. @@ -546,7 +521,7 @@ if [[ $IGNORED_COUNT -gt 0 ]]; then echo "" echo "$IGNORE_DETAIL" echo "" - echo "RESULT: AMBER ${IGNORED_COUNT} test case(s) ignored $(canonical_rating)" + echo "RESULT: AMBER ${IGNORED_COUNT} test case(s) ignored $(verdict_suffix)" exit 2 fi @@ -558,7 +533,7 @@ if [[ -z $FILTER && $ACCOUNTED_COUNT -lt $EXPECTED_COUNT ]]; then printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || missing+=("$s") done echo "" - echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) - all that ran passed $(canonical_rating)" + echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) - all that ran passed $(verdict_suffix)" exit 2 fi @@ -572,7 +547,7 @@ if ((${#DIRTY_SUITES[@]} > 0)); then echo "" echo " -> declare these in test/state-manifest.tsv with a reason, or stop the write." echo " -> ./bin/run-tests.sh --write-manifest prints the entries to paste." - echo "RESULT: AMBER ${#DIRTY_SUITES[@]} suite(s) left undeclared shared state $(canonical_rating)" + echo "RESULT: AMBER ${#DIRTY_SUITES[@]} suite(s) left undeclared shared state $(verdict_suffix)" exit 2 fi @@ -588,7 +563,7 @@ if ((${#SURVIVOR_SUITES[@]} > 0)); then echo "" echo " -> end every setup() branch with exit(UNITY_END()), not a bare UNITY_END()." echo " -> ./bin/lint-unity-exit.sh test/**/*.cpp finds the sites; see test/README.md." - echo "RESULT: AMBER ${#SURVIVOR_SUITES[@]} suite(s) still running after the suite finished $(canonical_rating)" + echo "RESULT: AMBER ${#SURVIVOR_SUITES[@]} suite(s) still running after the suite finished $(verdict_suffix)" exit 2 fi @@ -599,10 +574,10 @@ if [[ -n $FILTER ]]; then for s in "${ALL_SUITES[@]}"; do printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || not_run+=("$s") done - echo "RESULT: FILTERED ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (not run: ${not_run[*]}) - filtered: $FILTER $(canonical_rating)" + echo "RESULT: FILTERED ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (not run: ${not_run[*]}) - filtered: $FILTER $(verdict_suffix)" exit 3 fi # GREEN: all canonical suites ran, all passed, no ignored test cases, nothing undeclared left behind. -echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed, all CLEAN $(canonical_rating)" +echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed, all CLEAN $(verdict_suffix)" exit 0 diff --git a/bin/test-lint-unity-exit.sh b/bin/test-lint-unity-exit.sh index 7c1dac30d7..b88f7d2df1 100755 --- a/bin/test-lint-unity-exit.sh +++ b/bin/test-lint-unity-exit.sh @@ -12,8 +12,9 @@ # anything" is what catches a rule that reports the right number of findings in the wrong places, or # that collapses two findings on one line into one. # -# Not a Unity suite and not counted in test/native-suite-count - same arrangement as -# bin/test-state-check.sh, and for the same reason: it asserts the behaviour of a process. +# Not a Unity suite and not a test_* directory, so outside the suite count run-tests.sh derives +# from test/ - same arrangement as bin/test-state-check.sh, and for the same reason: it asserts +# the behaviour of a process. # # Usage: ./bin/test-lint-unity-exit.sh (exit 0 = all fixtures behaved) diff --git a/bin/test-state-check.sh b/bin/test-state-check.sh index 96fa8803d0..14dad9d447 100755 --- a/bin/test-state-check.sh +++ b/bin/test-state-check.sh @@ -8,9 +8,9 @@ # before-empty assertion fires, because an after-diff measured against a dirty baseline reports # green while meaning nothing. # -# Not a Unity suite and not counted in test/native-suite-count - the same arrangement as -# bin/test-config-check.sh, and for the same reason: what it asserts is the behaviour of a process, -# not of a linkable function. +# Not a Unity suite and not a test_* directory, so outside the suite count run-tests.sh derives +# from test/ - the same arrangement as bin/test-config-check.sh, and for the same reason: what it +# asserts is the behaviour of a process, not of a linkable function. # # Usage: ./bin/test-state-check.sh (exit 0 = all fixtures behaved) 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/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/extra_scripts/nrf52_lto.py b/extra_scripts/nrf52_lto.py index 22b236f58d..f62194a053 100644 --- a/extra_scripts/nrf52_lto.py +++ b/extra_scripts/nrf52_lto.py @@ -233,9 +233,26 @@ def _assert_isr_handlers_survived(source, target, env): # and the build stays green. Turn that into a red build: # 1. the linked variant.cpp.o must not be an LTO object (proves the -fno-lto recompile fired); # 2. any override the object defines strong must resolve strong in the ELF. +# +# The list must also cover Meshtastic's OWN weak variant hooks, not just the core's. Those have +# a second, independent way to vanish: their weak default AND their call site sit in the same +# LTO'd translation unit (src/main.cpp, src/platform/nrf52/main-nrf52.cpp), so GCC inlines the +# empty body at the call site and never reaches for the variant's strong override -- the +# -fno-lto middleware above cannot help, the caller is the problem. The definitions carry +# __attribute__((noinline)) to prevent it; this guard is what catches a future one that forgets. +# Regression that motivated the extension: 2.8 dropped earlyInitVariant() on the muzi R1 Neo, so +# DCDC_EN_HOLD/NRF_ON were never driven and the IO controller read the nRF as stuck in DFU +# (purple LED). The build stayed green because only _Z11initVariantv was listed here. _VARIANT_OVERRIDES = ( - "_Z11initVariantv", -) # extend if the core grows more weak variant hooks + "_Z11initVariantv", # core hook (cores/nRF5/main.cpp) + "_Z16earlyInitVariantv", # src/main.cpp -- pre-peripheral board bring-up + "_Z15lateInitVariantv", # src/main.cpp -- post-radio board bring-up + "_Z16variant_shutdownv", # main-nrf52.cpp -- pin parking before System OFF + "_Z21variant_nrf52LoopHookv", # main-nrf52.cpp -- per-loop variant hook + "_Z31variant_enableBatteryLpcompWakev", # main-nrf52.cpp -- LPCOMP wake opt-out + "_Z20variantDefaultConfigv", # NodeDB.cpp -- per-board config defaults + "_Z26variantDefaultModuleConfigv", # NodeDB.cpp -- per-board module defaults +) # extend if the core (or Meshtastic) grows more weak variant hooks def _assert_variant_survived(source, target, env): @@ -289,15 +306,19 @@ def _assert_variant_survived(source, target, env): ): problems.append( "%s is strong in variant.cpp.o but weak/absent in the ELF " - "(LTO resolved the core's call to the empty weak stub)" % sym + "(LTO resolved the call to the empty weak stub)" % sym ) if problems: sys.stderr.write( - "\n*** nrf52 LTO guard: board variant DROPPED from the image ***\n%s\n" - "The variant's early hardware setup (initVariant) will never run on this board.\n" - "Check _is_board_variant() in extra_scripts/nrf52_lto.py -- middleware nodes are\n" - "$BUILD_DIR-mirrored; match srcnode() paths, not node.get_abspath().\n\n" - % "\n".join(" - " + p for p in problems) + "\n*** nrf52 LTO guard: board variant override DROPPED from the image ***\n%s\n" + "That board hardware setup silently will not run. Two possible causes:\n" + " 1. The weak default and its CALL SITE share one LTO'd translation unit\n" + " (src/main.cpp, src/platform/nrf52/main-nrf52.cpp, src/mesh/NodeDB.cpp), so GCC\n" + " inlined the empty body and never reached the override. Fix: mark BOTH the weak\n" + " declaration and definition __attribute__((noinline)) -- see earlyInitVariant().\n" + " 2. The -fno-lto middleware stopped matching the variant. Check _is_board_variant()\n" + " below -- middleware nodes are $BUILD_DIR-mirrored, so match srcnode() paths,\n" + " not node.get_abspath().\n\n" % "\n".join(" - " + p for p in problems) ) from SCons.Script import Exit diff --git a/platformio.ini b/platformio.ini index bd45e4fc2b..6e0d20d463 100644 --- a/platformio.ini +++ b/platformio.ini @@ -137,7 +137,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/289daf6c08799ddb0673a45eb2b5e822d2f92cf4.zip + https://github.com/meshtastic/device-ui/archive/e1de01e0b3c4a6b149c00e95d59cfb0cca7ad49e.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 @@ -230,8 +230,11 @@ lib_deps = # renovate: datasource=github-tags depName=Seeed_PM2_5_sensor_HM3301 packageName=meshtastic/Seeed_PM2_5_sensor_HM3301 https://github.com/meshtastic/Seeed_PM2_5_sensor_HM3301/archive/2704ca254c7e2136c52ac23198dd05f5ba1e2f04.zip -; Common environmental sensor libraries (not included in native / portduino) -[environmental_extra_common] +; Extra environmental sensor libraries (not included in native / portduino). +; BME680/BME688 IAQ comes from the in-tree open estimator (BME680IaqEstimator); +; the proprietary Bosch BSEC blob (measured ~37-39 KB flash + ~4-5 KB static +; RAM per image) is intentionally not linked anywhere. +[environmental_extra] lib_deps = # renovate: datasource=github-tags depName=Adafruit BMP3XX packageName=adafruit/Adafruit_BMP3XX https://github.com/adafruit/Adafruit_BMP3XX/archive/refs/tags/2.1.6.zip @@ -247,21 +250,19 @@ lib_deps = closedcube/ClosedCube OPT3001@1.1.2 # renovate: datasource=git-refs depName=meshtastic-DFRobot_LarkWeatherStation packageName=https://github.com/meshtastic/DFRobot_LarkWeatherStation gitBranch=master https://github.com/meshtastic/DFRobot_LarkWeatherStation/archive/4de3a9cadef0f6a5220a8a906cf9775b02b0040d.zip + # renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core + https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip + # renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x + https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip + # renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x + https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip + # renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30 + https://github.com/Sensirion/arduino-i2c-scd30/archive/1.1.1.zip + # renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht + https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip + # renovate: datasource=custom.pio depName=Adafruit ADS1X15 packageName=adafruit/library/Adafruit ADS1X15 Library + https://github.com/adafruit/Adafruit_ADS1X15/archive/refs/tags/2.6.2.zip # renovate: datasource=github-tags depName=Adafruit DS248x packageName=adafruit/Adafruit_DS248x https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip - -; Environmental sensors with BSEC2 (Bosch proprietary IAQ) -[environmental_extra] -lib_deps = - ${environmental_extra_common.lib_deps} - # renovate: datasource=github-tags depName=Bosch BSEC2 packageName=boschsensortec/Bosch-BSEC2-Library - https://github.com/boschsensortec/Bosch-BSEC2-Library/archive/refs/tags/1.10.2610.zip - # renovate: datasource=github-tags depName=Bosch BME68x packageName=boschsensortec/Bosch-BME68x-Library - https://github.com/boschsensortec/Bosch-BME68x-Library/archive/refs/tags/v1.3.40408.zip - -; Environmental sensors without BSEC (saves ~3.5KB DRAM for original ESP32 targets) -[environmental_extra_no_bsec] -lib_deps = - ${environmental_extra_common.lib_deps} # renovate: datasource=github-tags depName=Adafruit_BME680 packageName=adafruit/Adafruit_BME680 https://github.com/adafruit/Adafruit_BME680/archive/refs/tags/2.0.6.zip diff --git a/protobufs b/protobufs index cd290ba246..84bfb0fdb3 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit cd290ba246fb5130cb449055248f7e22c15bcafb +Subproject commit 84bfb0fdb3b853ea18abc4535497fa41a1b09546 diff --git a/src/AmbientLightingThread.h b/src/AmbientLightingThread.h index d52b10a53f..873e25e66b 100644 --- a/src/AmbientLightingThread.h +++ b/src/AmbientLightingThread.h @@ -66,7 +66,7 @@ class AmbientLightingThread : public concurrency::OSThread #if defined(HAS_NCP5623) || defined(HAS_LP5562) _type = type; if (_type == ScanI2C::DeviceType::NONE) { - LOG_DEBUG("AmbientLighting Disable due to no RGB leds found on I2C bus"); + LOG_DEBUG("AmbientLighting disabled: no RGB leds on I2C"); disable(); return; } @@ -92,7 +92,7 @@ class AmbientLightingThread : public concurrency::OSThread pixels.setBrightness(moduleConfig.ambient_lighting.current); #endif if (!moduleConfig.ambient_lighting.led_state) { - LOG_DEBUG("AmbientLighting Disable due to moduleConfig.ambient_lighting.led_state OFF"); + LOG_DEBUG("AmbientLighting disabled: led_state OFF"); disable(); return; } diff --git a/src/DebugConfiguration.h b/src/DebugConfiguration.h index 65b258fc1b..247776a897 100644 --- a/src/DebugConfiguration.h +++ b/src/DebugConfiguration.h @@ -48,6 +48,16 @@ extern MemGet memGet; #define DEBUG_PORT (*console) // Serial debug port +// LOG_TRACE costs no flash unless enabled: -DMESHTASTIC_TRACE_LOGGING(=1) turns it on, =0 forces it off. +// Default is on only for portduino (traceFilename packet traces, logoutputlevel=trace), off elsewhere. +#ifndef MESHTASTIC_TRACE_LOGGING +#ifdef ARCH_PORTDUINO +#define MESHTASTIC_TRACE_LOGGING 1 +#else +#define MESHTASTIC_TRACE_LOGGING 0 +#endif +#endif + #ifdef USE_SEGGER // #undef DEBUG_PORT #define LOG_DEBUG(...) SEGGER_RTT_printf(0, __VA_ARGS__) @@ -55,16 +65,24 @@ extern MemGet memGet; #define LOG_WARN(...) SEGGER_RTT_printf(0, __VA_ARGS__) #define LOG_ERROR(...) SEGGER_RTT_printf(0, __VA_ARGS__) #define LOG_CRIT(...) SEGGER_RTT_printf(0, __VA_ARGS__) +#if MESHTASTIC_TRACE_LOGGING #define LOG_TRACE(...) SEGGER_RTT_printf(0, __VA_ARGS__) #else +#define LOG_TRACE(...) +#endif +#else #if defined(DEBUG_PORT) && !defined(DEBUG_MUTE) #define LOG_DEBUG(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_DEBUG, __VA_ARGS__) #define LOG_INFO(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_INFO, __VA_ARGS__) #define LOG_WARN(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_WARN, __VA_ARGS__) #define LOG_ERROR(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_ERROR, __VA_ARGS__) #define LOG_CRIT(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_CRIT, __VA_ARGS__) +#if MESHTASTIC_TRACE_LOGGING #define LOG_TRACE(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_TRACE, __VA_ARGS__) #else +#define LOG_TRACE(...) +#endif +#else #define LOG_DEBUG(...) #define LOG_INFO(...) #define LOG_WARN(...) diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index 38b704e738..c00b07684b 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -340,7 +340,7 @@ void listDir(const char *dirname, uint8_t levels, bool del) file.close(); FSCom.remove(buffer); } else { - LOG_DEBUG(" %s (%i Bytes)", filepath, file.size()); + LOG_TRACE(" %s (%i Bytes)", filepath, file.size()); file.close(); } } @@ -394,7 +394,7 @@ void fsInit() #if defined(ARCH_ESP32) LOG_DEBUG("Filesystem files (%d/%d Bytes):", FSCom.usedBytes(), FSCom.totalBytes()); #else - LOG_DEBUG("Filesystem files:"); + LOG_TRACE("Filesystem files:"); #endif listDir("/", 10); #endif diff --git a/src/GPSStatus.h b/src/GPSStatus.h index 25c7b10394..e9f7325c87 100644 --- a/src/GPSStatus.h +++ b/src/GPSStatus.h @@ -2,6 +2,7 @@ #include "NodeDB.h" #include "Status.h" #include "configuration.h" +#include "gps/GPSLog.h" #include namespace meshtastic @@ -17,6 +18,7 @@ class GPSStatus : public Status bool hasLock = false; // default to false, until we complete our first read bool isConnected = false; // Do we have a GPS we are talking to + bool hasTime = false; // GPS has decoded a valid time this acquisition, even without a position fix bool isPowerSaving = false; // Are we in power saving state @@ -29,11 +31,12 @@ class GPSStatus : public Status GPSStatus() { statusType = STATUS_TYPE_GPS; } // preferred method - GPSStatus(bool hasLock, bool isConnected, bool isPowerSaving, const meshtastic_Position &pos) : Status() + GPSStatus(bool hasLock, bool isConnected, bool isPowerSaving, const meshtastic_Position &pos, bool hasTime = false) : Status() { this->hasLock = hasLock; this->isConnected = isConnected; this->isPowerSaving = isPowerSaving; + this->hasTime = hasTime; // all-in-one struct copy this->p = pos; @@ -50,6 +53,8 @@ class GPSStatus : public Status bool getIsPowerSaving() const { return isPowerSaving; } + bool getHasTime() const { return hasTime; } + int32_t getLatitude() const { if (config.position.fixed_position) { @@ -88,10 +93,8 @@ class GPSStatus : public Status bool matches(const GPSStatus *newStatus) const { -#ifdef GPS_DEBUG - LOG_DEBUG("GPSStatus.match() new pos@%x to old pos@%x", newStatus->p.timestamp, p.timestamp); -#endif - return (newStatus->hasLock != hasLock || newStatus->isConnected != isConnected || + LOG_DEBUG_GPS("GPSStatus.match() new pos@%x to old pos@%x", newStatus->p.timestamp, p.timestamp); + return (newStatus->hasLock != hasLock || newStatus->isConnected != isConnected || newStatus->hasTime != hasTime || newStatus->isPowerSaving != isPowerSaving || newStatus->p.latitude_i != p.latitude_i || newStatus->p.longitude_i != p.longitude_i || newStatus->p.altitude != p.altitude || newStatus->p.altitude_hae != p.altitude_hae || newStatus->p.PDOP != p.PDOP || @@ -112,6 +115,7 @@ class GPSStatus : public Status initialized = true; hasLock = newStatus->hasLock; isConnected = newStatus->isConnected; + hasTime = newStatus->hasTime; p = newStatus->p; 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 28c8a7d5af..aa752cf633 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -171,7 +171,7 @@ static bool initAdcCalibration() } #endif - LOG_INFO("ADC calibration not supported; using approximate scaling"); + LOG_INFO("ADC calibration unsupported; use approx scaling"); return false; } @@ -606,7 +606,7 @@ class AnalogBatteryLevel : public HasBatteryLevel // get current flow from INA sensor - negative value means power flowing // into the battery default assuming BATTERY+ <--> INA_VIN+ <--> SHUNT // RESISTOR <--> INA_VIN- <--> LOAD - LOG_DEBUG("Using INA on I2C addr 0x%x for charging detection", config.power.device_battery_ina_address); + LOG_TRACE("Using INA on I2C addr 0x%x for charging detection", config.power.device_battery_ina_address); #if defined(INA_CHARGING_DETECTION_INVERT) return getINACurrent() > 0; #else @@ -837,12 +837,13 @@ bool Power::setup() void Power::powerCommandsCheck() { - if (rebootAtMsec && millis() > rebootAtMsec) { + // 0 means "not scheduled" for both, and reads as long expired - test it first. + if (rebootAtMsec && Throttle::deadlinePassed(rebootAtMsec)) { LOG_INFO("Rebooting"); reboot(); } - if (shutdownAtMsec && millis() > shutdownAtMsec) { + if (shutdownAtMsec && Throttle::deadlinePassed(shutdownAtMsec)) { shutdownAtMsec = 0; shutdown(); } @@ -879,12 +880,13 @@ void Power::reboot() if (screen) { screen = nullptr; } - LOG_DEBUG("final reboot!"); + LOG_DEBUG("final reboot"); ::reboot(); #elif defined(ARCH_STM32) HAL_NVIC_SystemReset(); #else - rebootAtMsec = -1; + // 0 disarms; UINT32_MAX would read as long expired and reboot-loop. + rebootAtMsec = 0; LOG_WARN("FIXME implement reboot for this platform. Note that some settings " "require a restart to be applied"); #endif @@ -1117,6 +1119,7 @@ int32_t Power::runOnce() { readPowerStatus(); logHeapUsage(); + lipoChargerRetry(); #ifdef HAS_PMU // WE no longer use the IRQ line to wake the CPU (due to false wakes from @@ -1733,13 +1736,32 @@ bool Power::cw2015Init() #if defined(HAS_PPM) && HAS_PPM +// The gauge is soldered on, so a failed init means wedged rather than absent - retry from +// the power thread before writing it off. +#define BQ27220_INIT_ATTEMPTS 3 +#define BQ27220_RETRY_INTERVAL_MS (60 * 1000) + /** * Adapter class for BQ25896/BQ27220 Lipo battery charger. + * + * The gauge only adds time-to-full/empty, so its failure must not take the charger down. */ class LipoCharger : public HasBatteryLevel { private: BQ27220 *bq = nullptr; + uint8_t gaugeAttemptsLeft = BQ27220_INIT_ATTEMPTS; + uint32_t lastGaugeAttemptMs = 0; + + // An aborted transfer leaves the i2c_master driver holding a stale transaction, which + // the next transfer trips over. Deleting the bus frees it along with the interrupt. + void recoverI2CBus() + { +#ifdef ARCH_ESP32 + Wire.end(); + Wire.begin(I2C_SDA, I2C_SCL); +#endif + } public: /** @@ -1786,24 +1808,46 @@ class LipoCharger : public HasBatteryLevel return false; } } - if (bq == nullptr) { - bq = new BQ27220; - bq->setDefaultCapacity(BQ27220_DESIGN_CAPACITY); + gaugeRunOnce(); + // Ready on the charger alone, so Power stays enabled and can retry the gauge later. + return true; + } - bool result = bq->init(); - if (result) { - LOG_DEBUG("BQ27220 design capacity: %d", bq->getDesignCapacity()); - LOG_DEBUG("BQ27220 fullCharge capacity: %d", bq->getFullChargeCapacity()); - LOG_DEBUG("BQ27220 remaining capacity: %d", bq->getRemainingCapacity()); - return true; - } else { - LOG_WARN("BQ27220 init failed"); - delete bq; - bq = nullptr; - return false; - } + /// Bring up the BQ27220 fuel gauge, unless it is already up or out of attempts + void gaugeRunOnce() + { + if (bq != nullptr || gaugeAttemptsLeft == 0) + return; + if (gaugeAttemptsLeft < BQ27220_INIT_ATTEMPTS && + Throttle::isWithinTimespanMs(lastGaugeAttemptMs, BQ27220_RETRY_INTERVAL_MS)) + return; + + lastGaugeAttemptMs = millis(); + gaugeAttemptsLeft--; + + // Cheap probe first: a silent gauge costs one transaction instead of the + // multi-second unseal/reset/provision sequence inside init(). + Wire.beginTransmission(BQ27220_I2C_ADDRESS); + if (Wire.endTransmission() != 0) { + LOG_WARN("BQ27220 not responding at 0x%x", BQ27220_I2C_ADDRESS); + return; } - return false; + + bq = new BQ27220; + bq->setDefaultCapacity(BQ27220_DESIGN_CAPACITY); + + if (bq->init()) { + LOG_DEBUG("BQ27220 design capacity: %d", bq->getDesignCapacity()); + LOG_DEBUG("BQ27220 fullCharge capacity: %d", bq->getFullChargeCapacity()); + LOG_DEBUG("BQ27220 remaining capacity: %d", bq->getRemainingCapacity()); + return; + } + + delete bq; + bq = nullptr; + // init() bails out mid-sequence, so hand the next bus user a sane driver state. + recoverI2CBus(); + LOG_WARN("BQ27220 init failed (%d retries left), use BQ25896 for battery state", (int)gaugeAttemptsLeft); } /** @@ -1819,7 +1863,7 @@ class LipoCharger : public HasBatteryLevel /** * The raw voltage of the battery in millivolts, or NAN if unknown */ - virtual uint16_t getBattVoltage() override { return bq->getVoltage(); } + virtual uint16_t getBattVoltage() override { return bq ? bq->getVoltage() : PPM->getBattVoltage(); } /** * return true if there is a battery installed in this unit @@ -1837,11 +1881,13 @@ class LipoCharger : public HasBatteryLevel virtual bool isCharging() override { bool isCharging = PPM->isCharging(); - if (isCharging) { - LOG_DEBUG("BQ27220 time to full charge: %d min", bq->getTimeToFull()); - } else { - if (!PPM->isVbusIn()) { - LOG_DEBUG("BQ27220 time to empty: %d min (%d mAh)", bq->getTimeToEmpty(), bq->getRemainingCapacity()); + if (bq) { + if (isCharging) { + LOG_TRACE("BQ27220 time to full charge: %d min", bq->getTimeToFull()); + } else { + if (!PPM->isVbusIn()) { + LOG_TRACE("BQ27220 time to empty: %d min (%d mAh)", bq->getTimeToEmpty(), bq->getRemainingCapacity()); + } } } return isCharging; @@ -1863,6 +1909,12 @@ bool Power::lipoChargerInit() return true; } +/// Retry a fuel gauge that did not come up during setup +void Power::lipoChargerRetry() +{ + lipoCharger.gaugeRunOnce(); +} + #else /** * The Lipo battery level sensor is unavailable - default to AnalogBatteryLevel @@ -1871,6 +1923,8 @@ bool Power::lipoChargerInit() { return false; } + +void Power::lipoChargerRetry() {} #endif #ifdef HELTEC_MESH_SOLAR @@ -1926,7 +1980,7 @@ meshSolarBatteryLevel meshSolarLevel; bool Power::meshSolarInit() { bool result = meshSolarLevel.runOnce(); - LOG_DEBUG("Power::meshSolarInit mesh solar sensor is %s", result ? "ready" : "not ready yet"); + LOG_DEBUG("Power::meshSolarInit sensor is %s", result ? "ready" : "not ready yet"); if (!result) return false; batteryLevel = &meshSolarLevel; @@ -2059,7 +2113,7 @@ bool Power::serialBatteryInit() #endif bool result = serialBatteryLevel.runOnce(); - LOG_DEBUG("Power::serialBatteryInit serial battery sensor is %s", result ? "ready" : "not ready yet"); + LOG_DEBUG("Power::serialBatteryInit sensor is %s", result ? "ready" : "not ready yet"); if (!result) return false; batteryLevel = &serialBatteryLevel; diff --git a/src/Power.h b/src/Power.h index 38a65b081b..b47d66aff2 100644 --- a/src/Power.h +++ b/src/Power.h @@ -121,6 +121,8 @@ class Power : public concurrency::OSThread bool max17048Init(); /// Setup a Lipo charger bool lipoChargerInit(); + /// Retry a fuel gauge that did not come up during setup + void lipoChargerRetry(); /// Setup a meshSolar battery sensor bool meshSolarInit(); /// Setup a serial battery sensor diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp index 5f2b03f435..268cb8211d 100644 --- a/src/PowerFSM.cpp +++ b/src/PowerFSM.cpp @@ -165,7 +165,7 @@ static void lsIdle() wakeCause2 = doLightSleep(100); // leave led on for 1ms secsSlept += sleepTime; - // LOG_INFO("Sleep, flash led!"); + // LOG_INFO("Sleep, flash led"); break; case ESP_SLEEP_WAKEUP_UART: diff --git a/src/PowerFSMThread.h b/src/PowerFSMThread.h index 47c45c2629..60a52e71da 100644 --- a/src/PowerFSMThread.h +++ b/src/PowerFSMThread.h @@ -5,6 +5,7 @@ #include "concurrency/OSThread.h" #include "configuration.h" #include "main.h" +#include "mesh/Throttle.h" namespace concurrency { @@ -29,9 +30,9 @@ class PowerFSMThread : public OSThread if (powerStatus->getHasUSB()) { timeLastPowered = millis(); } else if (config.power.on_battery_shutdown_after_secs > 0 && config.power.on_battery_shutdown_after_secs != UINT32_MAX && - millis() > (timeLastPowered + - Default::getConfiguredOrDefaultMs( - config.power.on_battery_shutdown_after_secs))) { // shutdown after 30 minutes unpowered + Throttle::hasElapsed( + timeLastPowered, + Default::getConfiguredOrDefaultMs(config.power.on_battery_shutdown_after_secs))) { // unpowered too long powerFSM.trigger(EVENT_SHUTDOWN); } 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/SafeFile.cpp b/src/SafeFile.cpp index 0173fde816..a5893db302 100644 --- a/src/SafeFile.cpp +++ b/src/SafeFile.cpp @@ -70,7 +70,7 @@ bool SafeFile::close() String filenameTmp = filename; filenameTmp += ".tmp"; if (!renameFile(filenameTmp.c_str(), filename.c_str())) { - LOG_ERROR("Error: can't rename new pref file"); + LOG_ERROR("Can't rename new pref file"); return false; } 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/UptimeClock.cpp b/src/UptimeClock.cpp index 5f85f50ff2..45f9affad7 100644 --- a/src/UptimeClock.cpp +++ b/src/UptimeClock.cpp @@ -1,33 +1,98 @@ // See UptimeClock.h for the full contract. #include "UptimeClock.h" #include +#include uint32_t Time::getMillis() { #ifdef PIO_UNIT_TESTING - if (Time::useTestClock) - return Time::testNowMs; + if (Time::useTestClock.load(std::memory_order_relaxed)) + return Time::testNowMs.load(std::memory_order_relaxed); #endif return millis(); } -uint64_t Time::getMillis64() +namespace { - static uint32_t lastLow = 0; // last 32-bit sample - static uint32_t highWord = 0; // number of observed wraps +struct PublishedSnapshot { + std::atomic high{0}; + std::atomic low{0}; +}; - uint32_t now = Time::getMillis(); +// The constexpr atomic initializers make both snapshots available before firmware startup. +PublishedSnapshot published[2]; +std::atomic publishedGeneration{0}; #ifdef PIO_UNIT_TESTING - // A test swapping clock sources (real <-> injected) can make `now` jump backward for - // reasons other than a genuine wrap - rebase rather than miscount it as one. - if (Time::clockSourceChanged) { - lastLow = now; - highWord = 0; - Time::clockSourceChanged = false; - } +std::atomic monotonicPublishHook{nullptr}; #endif - if (now < lastLow) - highWord++; // low word wrapped since last call - lastLow = now; - return (static_cast(highWord) << 32) | now; + +// Extend a published (high, low) snapshot to `now`; unsigned subtraction is exact across the wrap +// for any gap under 49.7 days. One copy, because reader and writer must agree on it exactly. +uint64_t extendPublished(uint32_t high, uint32_t low, uint32_t now) +{ + return ((((uint64_t)high << 32) | low) + (uint32_t)(now - low)); } + +// A generation change means the writer completed a publish while this copy was being read. A +// paused publish leaves the generation unchanged and writes only the inactive snapshot. +void readPublished(uint32_t &high, uint32_t &low) +{ + for (;;) { + const uint32_t before = publishedGeneration.load(std::memory_order_acquire); + PublishedSnapshot &snapshot = published[before & 1u]; + high = snapshot.high.load(std::memory_order_relaxed); + low = snapshot.low.load(std::memory_order_relaxed); + std::atomic_thread_fence(std::memory_order_acquire); + if (publishedGeneration.load(std::memory_order_relaxed) == before) + return; + } +} +} // namespace + +uint64_t Time::getMillisMonotonic() +{ + uint32_t high, low; + readPublished(high, low); + // The reader writes nothing back; it just extends the last published carry to now. + return extendPublished(high, low, getMillis()); +} + +uint32_t Time::getUptimeSecs() +{ + return (uint32_t)(getMillisMonotonic() / 1000); +} + +void Time::serviceMonotonic() +{ + const uint32_t generation = publishedGeneration.load(std::memory_order_relaxed); + PublishedSnapshot &active = published[generation & 1u]; + const uint32_t low = active.low.load(std::memory_order_relaxed); + const uint32_t high = active.high.load(std::memory_order_relaxed); + const uint64_t next = extendPublished(high, low, getMillis()); + + PublishedSnapshot &inactive = published[(generation + 1u) & 1u]; + inactive.high.store((uint32_t)(next >> 32), std::memory_order_relaxed); + inactive.low.store((uint32_t)next, std::memory_order_relaxed); +#ifdef PIO_UNIT_TESTING + if (const auto hook = monotonicPublishHook.load(std::memory_order_relaxed)) + hook(); +#endif + publishedGeneration.store(generation + 1u, std::memory_order_release); +} + +#ifdef PIO_UNIT_TESTING +void Time::resetMonotonicForTests() +{ + publishedGeneration.store(0, std::memory_order_relaxed); + for (auto &snapshot : published) { + snapshot.high.store(0, std::memory_order_relaxed); + snapshot.low.store(0, std::memory_order_relaxed); + } + monotonicPublishHook.store(nullptr, std::memory_order_relaxed); +} + +void Time::setMonotonicPublishHookForTests(MonotonicPublishHook hook) +{ + monotonicPublishHook.store(hook, std::memory_order_relaxed); +} +#endif diff --git a/src/UptimeClock.h b/src/UptimeClock.h index 9329a7bf60..efc04eb899 100644 --- a/src/UptimeClock.h +++ b/src/UptimeClock.h @@ -1,46 +1,69 @@ #pragma once #include +#ifdef PIO_UNIT_TESTING +#include +#endif // Monotonic uptime clock, injectable so tests can drive a virtual timebase instead of sleeping. // Uptime only; see gps/RTC.h for wall-clock. Not named Time.h: -Isrc would shadow C's . namespace Time { #ifdef PIO_UNIT_TESTING -// Test-only virtual clock; OFF by default so suites relying on real time are unaffected. -inline uint32_t testNowMs = 0; -inline bool useTestClock = false; -inline bool clockSourceChanged = true; // forces getMillis64() to rebase its wrap accumulator +// Test-only virtual clock; OFF by default so suites relying on real time are unaffected. Atomic so +// a suite can step the clock from one thread while others read it - the concurrent-reader cases in +// test_uptime_clock/ do exactly that. +inline std::atomic testNowMs{0}; +inline std::atomic useTestClock{false}; +using MonotonicPublishHook = void (*)(); inline void setTestMillis(uint32_t ms) { - testNowMs = ms; - useTestClock = true; - clockSourceChanged = true; + testNowMs.store(ms, std::memory_order_relaxed); + useTestClock.store(true, std::memory_order_relaxed); } inline void advanceTestMillis(uint32_t deltaMs) { - // Advancing from 0 after getMillis64() sampled the real clock steps backward, which would - // otherwise be miscounted as a wrap. - if (!useTestClock) - clockSourceChanged = true; - testNowMs += deltaMs; - useTestClock = true; + testNowMs.fetch_add(deltaMs, std::memory_order_relaxed); + useTestClock.store(true, std::memory_order_relaxed); } // Restore real-clock behaviour (call in test tearDown if a suite mixes real and fake time). inline void useRealClock() { - useTestClock = false; - testNowMs = 0; - clockSourceChanged = true; + useTestClock.store(false, std::memory_order_relaxed); + testNowMs.store(0, std::memory_order_relaxed); } +// Zero the published wrap carry. Suites that assert absolute uptime values call this in setUp(): +// a previous case that moved the test clock backwards left a counted wrap behind. +void resetMonotonicForTests(); +void setMonotonicPublishHookForTests(MonotonicPublishHook hook); #endif -/// Milliseconds since boot, 32-bit (wraps ~49.7 days). Drop-in for millis(). +/// Milliseconds since boot, 32-bit (wraps ~49.7 days). Drop-in for millis(). For "has this interval +/// elapsed / deadline arrived" use Throttle (isWithinTimespanMs / hasElapsed / deadlinePassed), +/// which is wrap-correct with no carry state at all. uint32_t getMillis(); -/// Milliseconds since boot, 64-bit, rollover-immune. Must be polled at least once per ~49.7-day -/// wrap window to catch every wrap, and keeps mutable static carry state, so it is NOT ISR-safe. -uint64_t getMillis64(); +/// Milliseconds since boot as a monotonic 64-bit count. +/// +/// A pure read: it derives its answer from a complete snapshot published by serviceMonotonic() +/// plus the unsigned elapsed time since that snapshot, which is exact across the wrap. A reader +/// that preempts publication uses the previous snapshot. If publication completes during a copy, +/// the reader retries; it never waits for a publish in progress. +/// +/// Not intended for ISR call sites because lock-free std::atomic operations are not guaranteed by +/// every supported toolchain. ISRs use getMillis(); the publication protocol itself never waits. +uint64_t getMillisMonotonic(); + +/// Whole seconds since boot, derived from getMillisMonotonic() (~136 years of range). This is +/// the unit to store when an instant must be dated before the wall clock is trustworthy. +uint32_t getUptimeSecs(); + +/// Advances the published wrap carry. THE ONLY WRITER - call it from the main loop and nowhere +/// else. Two concurrent callers could count one wrap twice, jumping every uptime and wall-clock +/// reading ~49.7 days forward for the rest of the boot. +/// +/// Must run at least once per ~49.7-day wrap window; the main loop calls it every iteration. +void serviceMonotonic(); } // namespace Time diff --git a/src/airtime.cpp b/src/airtime.cpp index 0e0d72e20e..aaacefb092 100644 --- a/src/airtime.cpp +++ b/src/airtime.cpp @@ -1,107 +1,164 @@ #include "airtime.h" #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. - -uint32_t air_period_tx[PERIODS_TO_LOG]; -uint32_t air_period_rx[PERIODS_TO_LOG]; - -void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms) +AirTime *AirTime::Held::armReentryCheck(AirTime *a) { +#ifdef AIRTIME_REENTRY_CHECK + // Before the lock: a nested take blocks forever, so a later check would never run. + assert(!a->reentryFlag); + a->reentryFlag = true; +#endif + return a; +} +AirTime::Held::~Held() +{ +#ifdef AIRTIME_REENTRY_CHECK + owner->reentryFlag = false; +#else + (void)owner; +#endif +} + +// --- the lock-free core ------------------------------------------------------------------------- +// Every method here requires the lock, and says so in its signature. None can take it: Windows has +// no lock to reach. + +void AirTime::Windows::logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &held) +{ + // A packet may be logged immediately after waking from light sleep. Sync first so + // the packet is counted in the current wall-time bucket, not a stale awake-time bucket. + syncNow(held); + + // The caller logs, once the lock is released. if (reportType == TX_LOG) { - LOG_DEBUG("Packet TX: %ums", airtime_ms); this->airtimes.periodTX[0] = this->airtimes.periodTX[0] + airtime_ms; - air_period_tx[0] = air_period_tx[0] + airtime_ms; - - this->utilizationTX[this->getPeriodUtilHour()] = this->utilizationTX[this->getPeriodUtilHour()] + airtime_ms; + this->utilizationTX[this->getPeriodUtilHour(held)] += airtime_ms; } else if (reportType == RX_LOG) { - LOG_DEBUG("Packet RX: %ums", airtime_ms); this->airtimes.periodRX[0] = this->airtimes.periodRX[0] + airtime_ms; - air_period_rx[0] = air_period_rx[0] + airtime_ms; } else if (reportType == RX_ALL_LOG) { - LOG_DEBUG("Packet RX (noise?) : %ums", airtime_ms); this->airtimes.periodRX_ALL[0] = this->airtimes.periodRX_ALL[0] + airtime_ms; } // Log all airtime type for channel utilization - this->channelUtilization[this->getPeriodUtilMinute()] = channelUtilization[this->getPeriodUtilMinute()] + airtime_ms; + this->channelUtilization[this->getPeriodUtilMinute(held)] += airtime_ms; } -uint8_t AirTime::currentPeriodIndex() +uint8_t AirTime::Windows::getPeriodUtilMinute(const Held &) { - return ((getSecondsSinceBoot() / SECONDS_PER_PERIOD) % PERIODS_TO_LOG); + return (secSinceBoot / 10) % CHANNEL_UTILIZATION_PERIODS; } -uint8_t AirTime::getPeriodUtilMinute() +uint8_t AirTime::Windows::getPeriodUtilHour(const Held &) { - return (getSecondsSinceBoot() / 10) % CHANNEL_UTILIZATION_PERIODS; + return (secSinceBoot / 60) % MINUTES_IN_HOUR; } -uint8_t AirTime::getPeriodUtilHour() +void AirTime::Windows::syncNow(const Held &) { - return (getSecondsSinceBoot() / 60) % MINUTES_IN_HOUR; -} + // Monotonic uptime, not RTC/network time: a user, GPS, or NTP clock change must not move + // airtime accounting. Pure read; the main loop publishes the wrap carry it derives from. + uint32_t nowSecs = Time::getUptimeSecs(); -void AirTime::airtimeRotatePeriod() -{ + if (firstTime) { + memset(this->utilizationTX, 0, sizeof(this->utilizationTX)); + memset(this->channelUtilization, 0, sizeof(this->channelUtilization)); + memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX)); + memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX)); + memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL)); - if (this->airtimes.lastPeriodIndex != this->currentPeriodIndex()) { - LOG_DEBUG("Rotate airtimes to a new period = %u", this->currentPeriodIndex()); + this->secSinceBoot = nowSecs; + firstTime = false; + return; + } - for (int i = PERIODS_TO_LOG - 2; i >= 0; --i) { - this->airtimes.periodTX[i + 1] = this->airtimes.periodTX[i]; - this->airtimes.periodRX[i + 1] = this->airtimes.periodRX[i]; - this->airtimes.periodRX_ALL[i + 1] = this->airtimes.periodRX_ALL[i]; + if (nowSecs == this->secSinceBoot) { + return; + } - air_period_tx[i + 1] = this->airtimes.periodTX[i]; - air_period_rx[i + 1] = this->airtimes.periodRX[i]; + uint32_t oldSecSinceBoot = this->secSinceBoot; + this->secSinceBoot = nowSecs; + + // Historical airtime reports use 1-hour buckets. If multiple hours elapsed while + // asleep, rotate each crossed bucket or clear the whole report window. + uint32_t elapsedAirtimePeriods = (this->secSinceBoot / SECONDS_PER_PERIOD) - (oldSecSinceBoot / SECONDS_PER_PERIOD); + if (elapsedAirtimePeriods >= PERIODS_TO_LOG) { + memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX)); + memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX)); + memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL)); + } else { + // Hand the count to runOnce() rather than tracing each crossing here: this runs under + // the lock, and a UART write would stall every other caller waiting on it. + this->rotationsPendingLog += elapsedAirtimePeriods; + for (uint32_t h = 0; h < elapsedAirtimePeriods; h++) { + for (int i = PERIODS_TO_LOG - 2; i >= 0; --i) { + this->airtimes.periodTX[i + 1] = this->airtimes.periodTX[i]; + this->airtimes.periodRX[i + 1] = this->airtimes.periodRX[i]; + this->airtimes.periodRX_ALL[i + 1] = this->airtimes.periodRX_ALL[i]; + } + + this->airtimes.periodTX[0] = 0; + this->airtimes.periodRX[0] = 0; + this->airtimes.periodRX_ALL[0] = 0; } + } - this->airtimes.periodTX[0] = 0; - this->airtimes.periodRX[0] = 0; - this->airtimes.periodRX_ALL[0] = 0; + // Channel utilization is a rolling 60-second view split into six 10-second buckets. + // Clear every bucket crossed while asleep so old airtime decays by real elapsed time. + uint32_t elapsedUtilPeriods = (this->secSinceBoot / 10) - (oldSecSinceBoot / 10); + if (elapsedUtilPeriods >= CHANNEL_UTILIZATION_PERIODS) { + memset(this->channelUtilization, 0, sizeof(this->channelUtilization)); + } else { + for (uint32_t i = 1; i <= elapsedUtilPeriods; i++) { + this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0; + } + } - air_period_tx[0] = 0; - air_period_rx[0] = 0; - - this->airtimes.lastPeriodIndex = this->currentPeriodIndex(); + // TX utilization is a rolling 60-minute view used by duty-cycle checks. + uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60); + if (elapsedUtilTXPeriods >= MINUTES_IN_HOUR) { + memset(this->utilizationTX, 0, sizeof(this->utilizationTX)); + } else { + for (uint32_t i = 1; i <= elapsedUtilTXPeriods; i++) { + this->utilizationTX[((oldSecSinceBoot / 60) + i) % MINUTES_IN_HOUR] = 0; + } } } -uint32_t *AirTime::airtimeReport(reportTypes reportType) +bool AirTime::Windows::airtimeReport(reportTypes reportType, uint32_t *out, size_t count, const Held &held) { + if (!out || count > PERIODS_TO_LOG) + return false; + // Reports may be requested before runOnce() executes after wake. + syncNow(held); + + const uint32_t *src = nullptr; if (reportType == TX_LOG) { - return this->airtimes.periodTX; + src = this->airtimes.periodTX; } else if (reportType == RX_LOG) { - return this->airtimes.periodRX; + src = this->airtimes.periodRX; } else if (reportType == RX_ALL_LOG) { - return this->airtimes.periodRX_ALL; + src = this->airtimes.periodRX_ALL; } - return 0; + if (!src) + return false; + + memcpy(out, src, count * sizeof(*out)); + return true; } -uint8_t AirTime::getPeriodsToLog() +float AirTime::Windows::channelUtilizationPercent(const Held &held) { - return PERIODS_TO_LOG; -} + // Gate decisions should see buckets that have decayed across light-sleep time. + syncNow(held); -uint32_t AirTime::getSecondsPerPeriod() -{ - return SECONDS_PER_PERIOD; -} - -uint32_t AirTime::getSecondsSinceBoot() -{ - return this->secSinceBoot; -} - -float AirTime::channelUtilizationPercent() -{ uint32_t sum = 0; for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) { sum += this->channelUtilization[i]; @@ -110,8 +167,11 @@ float AirTime::channelUtilizationPercent() return (float(sum) / float(CHANNEL_UTILIZATION_PERIODS * 10 * 1000)) * 100; } -float AirTime::utilizationTXPercent() +float AirTime::Windows::utilizationTXPercent(const Held &held) { + // Duty-cycle checks use this value, so keep it current even outside the periodic thread. + syncNow(held); + uint32_t sum = 0; for (uint32_t i = 0; i < MINUTES_IN_HOUR; i++) { sum += this->utilizationTX[i]; @@ -120,33 +180,9 @@ float AirTime::utilizationTXPercent() return (float(sum) / float(MS_IN_HOUR)) * 100; } -bool AirTime::isTxAllowedChannelUtil(bool polite) -{ - uint8_t percentage = (polite ? polite_channel_util_percent : max_channel_util_percent); - if (channelUtilizationPercent() < percentage) { - return true; - } else { - LOG_WARN("Ch. util >%d%%. Skip send", percentage); - return false; - } -} - -bool AirTime::isTxAllowedAirUtil() -{ - float effectiveDutyCycle = getEffectiveDutyCycle(); - if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) { - if (utilizationTXPercent() < effectiveDutyCycle * polite_duty_cycle_percent / 100) { - return true; - } else { - LOG_WARN("TX air util. >%f%%. Skip send", effectiveDutyCycle * polite_duty_cycle_percent / 100); - return false; - } - } - return true; -} - -// Get the amount of minutes we have to be silent before we can send again -uint8_t AirTime::getSilentMinutes(float txPercent, float dutyCycle) +// Minutes we must be silent before sending again. Does not sync, and walks the ring as if the index +// were an age; both are wrong and both are pinned by characterisation tests. See airtime.h's TODO. +uint8_t AirTime::Windows::getSilentMinutes(float txPercent, float dutyCycle, const Held &) { float newTxPercent = txPercent; for (int8_t i = MINUTES_IN_HOUR - 1; i >= 0; --i) { @@ -158,54 +194,119 @@ uint8_t AirTime::getSilentMinutes(float txPercent, float dutyCycle) return MINUTES_IN_HOUR; } -AirTime::AirTime() : concurrency::OSThread("AirTime"), airtimes({}) {} +// --- the locking shell -------------------------------------------------------------------------- +// Each takes the lock exactly once and delegates. Nothing below calls another method on `this`. + +void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms) +{ + { + Held held(this); + w.logAirtime(reportType, airtime_ms, held); + } + + // Outside the lock: DEBUG_PORT.log() blocks on a UART write, and `lock` is a plain binary + // semaphore with no priority inheritance, so holding it here would stall the radio thread. + if (reportType == TX_LOG) { + LOG_DEBUG("Packet TX: %ums", airtime_ms); + } else if (reportType == RX_LOG) { + LOG_DEBUG("Packet RX: %ums", airtime_ms); + } else if (reportType == RX_ALL_LOG) { + LOG_DEBUG("Packet RX (noise?) : %ums", airtime_ms); + } +} + +void AirTime::airtimeRotatePeriod() +{ + // Preserve the public helper while keeping all rotation logic in one monotonic-time path. + Held held(this); + w.syncNow(held); +} + +bool AirTime::airtimeReport(reportTypes reportType, uint32_t *out, size_t count) +{ + Held held(this); + return w.airtimeReport(reportType, out, count, held); +} + +uint32_t AirTime::getSecondsSinceBoot() +{ + // Keep HTTP/debug reporting aligned with the same monotonic clock used by the buckets. + Held held(this); + w.syncNow(held); + return w.secSinceBoot; +} + +float AirTime::channelUtilizationPercent() +{ + Held held(this); + return w.channelUtilizationPercent(held); +} + +float AirTime::utilizationTXPercent() +{ + Held held(this); + return w.utilizationTXPercent(held); +} + +// These lock like everything else, because they call the core rather than the public accessors. +// Both read under the lock and warn after it, for the reason logAirtime() does. +bool AirTime::isTxAllowedChannelUtil(bool polite) +{ + uint8_t percentage = (polite ? polite_channel_util_percent : max_channel_util_percent); + float utilization; + { + Held held(this); + utilization = w.channelUtilizationPercent(held); + } + + if (utilization < percentage) + return true; + LOG_WARN("Ch. util >%d%%. Skip send", percentage); + return false; +} + +bool AirTime::isTxAllowedAirUtil() +{ + float effectiveDutyCycle = getEffectiveDutyCycle(); + if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) { + float limit = effectiveDutyCycle * polite_duty_cycle_percent / 100; + float utilization; + { + Held held(this); + utilization = w.utilizationTXPercent(held); + } + + if (utilization < limit) + return true; + LOG_WARN("TX air util. >%f%%. Skip send", limit); + return false; + } + return true; +} + +uint8_t AirTime::getSilentMinutes(float txPercent, float dutyCycle) +{ + Held held(this); + return w.getSilentMinutes(txPercent, dutyCycle, held); +} + +AirTime::AirTime() : concurrency::OSThread("AirTime") {} int32_t AirTime::runOnce() { - secSinceBoot++; - - uint8_t utilPeriod = this->getPeriodUtilMinute(); - uint8_t utilPeriodTX = this->getPeriodUtilHour(); - - if (firstTime) { - - // Init utilizationTX window to all 0 - for (uint32_t i = 0; i < MINUTES_IN_HOUR; i++) { - this->utilizationTX[i] = 0; - } - - // Init channelUtilization window to all 0 - for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) { - this->channelUtilization[i] = 0; - } - - // Init airtime windows to all 0 - for (int i = 0; i < PERIODS_TO_LOG; i++) { - this->airtimes.periodTX[i] = 0; - this->airtimes.periodRX[i] = 0; - this->airtimes.periodRX_ALL[i] = 0; - - // air_period_tx[i] = 0; - // air_period_rx[i] = 0; - } - - firstTime = false; - lastUtilPeriod = utilPeriod; - } else { - this->airtimeRotatePeriod(); - - // Reset the channelUtilization window when we roll over - if (lastUtilPeriod != utilPeriod) { - lastUtilPeriod = utilPeriod; - - this->channelUtilization[utilPeriod] = 0; - } - - if (lastUtilPeriodTX != utilPeriodTX) { - lastUtilPeriodTX = utilPeriodTX; - - this->utilizationTX[utilPeriodTX] = 0; - } + uint32_t rotations; + { + Held held(this); + w.syncNow(held); + rotations = w.rotationsPendingLog; + w.rotationsPendingLog = 0; } + + // Outside the lock, for the reason logAirtime() gives. Any caller can cross an hour, but only + // this thread reports it, so a crossing raised elsewhere is traced at most one tick late. + if (rotations > 0) { + LOG_DEBUG("Rotate airtimes, crossed %u hour(s)", rotations); + } + return (1000 * 1); } diff --git a/src/airtime.h b/src/airtime.h index 8e3e6c5578..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,10 +86,42 @@ enum reportTypes { TX_LOG, RX_LOG, RX_ALL_LOG }; -void logAirtime(reportTypes reportType, uint32_t airtime_ms); - -uint32_t *airtimeReport(reportTypes reportType); +// Arms AirTime's nested-take check. Sound only where the lock is not a real lock: the check runs +// before the take, because a nested take blocks forever and a later check would never run - so +// under preemption it would false-positive on legitimate contention and race on its own write. +// Portduino is where it earns its keep anyway; there Lock::lock() is empty, so a nested take +// succeeds silently and nothing else would notice. On an on-target test build the nesting it +// catches shows up as a hang instead. Test builds only: nothing in this tree defines DEBUG or +// NDEBUG, so either spelling would ship an abort() to every board, and nrf52_promicro_diy_tcxo +// has no flash for it. +#if defined(PIO_UNIT_TESTING) && !defined(HAS_FREE_RTOS) +#define AIRTIME_REENTRY_CHECK +#endif +// Serialised behind `lock` because two FreeRTOS tasks genuinely reach this class at once on nRF52. +// NRF52Bluetooth registers its ToRadio write callback with defer == false, so a phone's packet runs +// PhoneAPI::handleToRadio -> MeshService::sendToMesh -> Router::send on the Bluefruit BLE task, +// which reads utilizationTXPercent() and getSilentMinutes() while loopTask may be inside +// logAirtime() from a reception. That is an unsynchronised read-modify-write of utilizationTX[] and +// secSinceBoot against a summing read. ESP32 hands BLE work to the main task and does not have it. +// +// Two mechanisms keep it serialised: +// +// - a lock-free inner core (Windows) holds all state and all logic. It has no lock member, and +// must never reach one through the global `airTime` - `airTime->anyPublicMethod()` from inside +// a Windows method would take a second Held and hang, because concurrency::Lock is a +// non-recursive binary semaphore taken with portMAX_DELAY. Nothing does this today; the +// AIRTIME_REENTRY_CHECK assert is the backstop, and it only builds on host test builds. +// - a private Held token takes the lock in its constructor and is the only thing that satisfies a +// core method's `const Held &`, so the lock cannot be forgotten. +// +// Every public method takes the lock exactly once and delegates, with two exceptions: the two +// constexpr accessors below touch no state and take none, and isTxAllowedAirUtil() takes it zero or +// one times, depending on whether the duty-cycle branch is entered at all. Nothing inside locks - +// that includes isTxAllowed*(), which call the core rather than the public accessors. +// +// A new write-path helper belongs to Windows or is a free function, never a method on AirTime: an +// AirTime method locks, and logAirtime() would call it while already holding the lock. class AirTime : private concurrency::OSThread { @@ -49,39 +132,85 @@ class AirTime : private concurrency::OSThread float channelUtilizationPercent(); float utilizationTXPercent(); - float UtilizationPercentTX(); - uint32_t channelUtilization[CHANNEL_UTILIZATION_PERIODS] = {0}; - uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; - + /// Compatibility shim: no caller in the tree, kept for out-of-tree ones. void airtimeRotatePeriod(); - uint8_t getPeriodsToLog(); - uint32_t getSecondsPerPeriod(); + /// Constants, not state: no lock, and usable where a constant expression is required so a + /// caller's buffer and the count it passes to airtimeReport() cannot drift apart. + static constexpr uint8_t getPeriodsToLog() { return PERIODS_TO_LOG; } + static constexpr uint32_t getSecondsPerPeriod() { return SECONDS_PER_PERIOD; } uint32_t getSecondsSinceBoot(); - uint32_t *airtimeReport(reportTypes reportType); + /// Copies `count` buckets into `out`, newest first. Copies rather than returning the array so a + /// caller cannot hold a handle to buckets that every other entry point rotates underneath it. + /// False if `out` is null, `count` exceeds the log depth, or the report type is unknown. + bool airtimeReport(reportTypes reportType, uint32_t *out, size_t count); uint8_t getSilentMinutes(float txPercent, float dutyCycle); bool isTxAllowedChannelUtil(bool polite = false); bool isTxAllowedAirUtil(); private: - bool firstTime = true; - uint8_t lastUtilPeriod = 0; - uint8_t lastUtilPeriodTX = 0; - uint32_t secSinceBoot = 0; + concurrency::Lock lock; + +#ifdef AIRTIME_REENTRY_CHECK + // Set for the lifetime of a Held and checked before the lock is taken, so a nested take is + // reported rather than hung at. See the macro's definition for why it is host-only. + bool reentryFlag = false; +#endif + + /// Takes `lock` for its lifetime and doubles as proof that it is held. Only AirTime can + /// construct one, so a core method taking `const Held &` cannot be called without the lock. + /// A bare LockGuard would not do: it proves only that *some* lock is held. + class Held + { + public: + explicit Held(AirTime *a) : owner(armReentryCheck(a)), guard(&a->lock) {} + ~Held(); + Held(const Held &) = delete; + Held &operator=(const Held &) = delete; + + private: + static AirTime *armReentryCheck(AirTime *a); + AirTime *owner; // declared first, so its initialiser runs before the lock is taken + concurrency::LockGuard guard; + }; + + /// All state, all logic, no lock. Cannot take one, so cannot nest. + struct Windows { + bool firstTime = true; + // Time::getUptimeSecs() as of the last syncNow(). The windows rotate by the gap since, so + // they stay correct across a paused scheduler. + uint32_t secSinceBoot = 0; + + // Modular rings: index is absolute phase, (uptime secs / period) % N, never age. + uint32_t channelUtilization[CHANNEL_UTILIZATION_PERIODS] = {0}; // 6 x 10s + uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only + + // Hour crossings rotated but not yet traced. The core cannot log its own rotations: it + // only ever runs under the lock, and DEBUG_PORT.log() blocks on a UART write. runOnce() + // drains this and logs after releasing, so the trace costs the lock nothing. + uint32_t rotationsPendingLog = 0; + + // Shift-ordered, unlike the rings above: slot 0 is the newest hour and the index is age. + struct airtimeStruct { + uint32_t periodTX[PERIODS_TO_LOG] = {0}; // AirTime transmitted + uint32_t periodRX[PERIODS_TO_LOG] = {0}; // AirTime received and repeated (valid mesh packets) + uint32_t periodRX_ALL[PERIODS_TO_LOG] = {0}; // AirTime received regardless of validity. May be noise. + } airtimes; + + void logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &); + float channelUtilizationPercent(const Held &); + float utilizationTXPercent(const Held &); + bool airtimeReport(reportTypes reportType, uint32_t *out, size_t count, const Held &); + uint8_t getSilentMinutes(float txPercent, float dutyCycle, const Held &); + uint8_t getPeriodUtilMinute(const Held &); + uint8_t getPeriodUtilHour(const Held &); + // Advance rolling airtime windows from monotonic uptime, not from runOnce() calls. + void syncNow(const Held &); + } w; + uint8_t max_channel_util_percent = 40; uint8_t polite_channel_util_percent = 25; uint8_t polite_duty_cycle_percent = 50; // half of Duty Cycle allowance is ok for metadata - struct airtimeStruct { - uint32_t periodTX[PERIODS_TO_LOG]; // AirTime transmitted - uint32_t periodRX[PERIODS_TO_LOG]; // AirTime received and repeated (Only valid mesh packets) - uint32_t periodRX_ALL[PERIODS_TO_LOG]; // AirTime received regardless of valid mesh packet. Could include noise. - uint8_t lastPeriodIndex; - } airtimes; - - uint8_t getPeriodUtilMinute(); - uint8_t getPeriodUtilHour(); - uint8_t currentPeriodIndex(); - protected: virtual int32_t runOnce() override; }; diff --git a/src/configuration.h b/src/configuration.h index f9be2fc46c..1a6550366e 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -88,6 +88,12 @@ along with this program. If not, see . #define MESHTASTIC_PREHOP_DROP 1 #endif +// Use polynomial approximations for trigonometric functions to save flash. +// Override with -D MESHTASTIC_TRIG_APPROX=0 for exact trig for special use cases e.g. close to Earth's poles. +#ifndef MESHTASTIC_TRIG_APPROX +#define MESHTASTIC_TRIG_APPROX 1 +#endif + // Debug/test only: let a wired client (serial/TCP) inject frames into the RX pipeline as if they had // arrived over LoRa - a SIMULATOR_APP ToRadio packet is delivered through the real receive path on real // hardware (see MeshService::injectAsReceived). This forges over-the-air traffic, so it MUST stay 0 in @@ -294,7 +300,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 @@ -305,7 +316,6 @@ along with this program. If not, see . #define DS248X_ADDR_ALT7 0x1F // same as BBQ10_KB_ADDR #define HM330X_ADDR 0x40 - // ----------------------------------------------------------------------------- // ACCELEROMETER // ----------------------------------------------------------------------------- diff --git a/src/detect/ReClockI2C.h b/src/detect/ReClockI2C.h index 503f1a48b1..24a166d53f 100644 --- a/src/detect/ReClockI2C.h +++ b/src/detect/ReClockI2C.h @@ -35,20 +35,20 @@ class ReClockI2C uint32_t currentClock = this->getClock(); if (currentClock) { - LOG_DEBUG("Current I2C frequency: %uHz", currentClock); + LOG_TRACE("Current I2C frequency: %uHz", currentClock); } if (currentClock != desiredClock) { - LOG_DEBUG("Changing I2C clock to %uHz", desiredClock); + LOG_TRACE("Changing I2C clock to %uHz", desiredClock); this->i2cBus->setClock(desiredClock); // If the clock is 0Hz, we still store it // We'll check in restoreClock function setPreviousClock(currentClock); - LOG_DEBUG("Stored previous clock I2C clock: %uHz", this->previousClock); + LOG_TRACE("Stored previous clock I2C clock: %uHz", this->previousClock); return true; } - LOG_DEBUG("I2C clock was already %uHz. Skipping", desiredClock); + LOG_TRACE("I2C clock was already %uHz. Skipping", desiredClock); setPreviousClock(0); return false; } @@ -56,12 +56,12 @@ class ReClockI2C bool restoreClock() { if (this->previousClock) { - LOG_DEBUG("Restoring I2C clock to %uHz", this->previousClock); + LOG_TRACE("Restoring I2C clock to %uHz", this->previousClock); i2cBus->setClock(this->previousClock); setPreviousClock(0); return true; } - LOG_DEBUG("I2C clock was unknown. Not restored"); + LOG_TRACE("I2C clock was unknown. Not restored"); return false; } diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp index e773e72ce9..025b259465 100644 --- a/src/detect/ScanI2C.cpp +++ b/src/detect/ScanI2C.cpp @@ -50,8 +50,8 @@ ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const 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 eaf8ae0770..181f631073 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -97,16 +97,18 @@ class ScanI2C CST3530, BMI270, SEN5X, + SEN6X, SFA30, CW2015, SCD30, - ADS1115, + ADS1X15, + ADS1X15_ALT, IIS2MDCTR, ISM330DHCX, SPA06, DS248X, HM330X - } DeviceType; + } DeviceType; // typedef uint8_t DeviceAddress; typedef enum I2CPort { diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 52aa2e8648..0b9d6065a7 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) { @@ -700,7 +707,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 +728,13 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) type = ISM330DHCX; logFoundDevice("ISM330DHCX", (uint8_t)addr.address); } else { +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR + if (probeSEN6X(i2cBus, addr.address, port)) { + type = SEN6X; + logFoundDevice("SEN6X", addr.address); + break; + } +#endif type = QMI8658; logFoundDevice("QMI8658", (uint8_t)addr.address); } @@ -743,7 +757,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("DS2482-800", (uint8_t)addr.address); break; } - type = HMC5883L; logFoundDevice("HMC5883L", (uint8_t)addr.address); break; @@ -1048,10 +1061,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; } @@ -1060,6 +1074,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); } diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index dcd765c9e8..69000f2fef 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -5,10 +5,12 @@ #if !MESHTASTIC_EXCLUDE_GPS #include "Default.h" #include "GPS.h" +#include "GPSLog.h" #include "GpioLogic.h" #include "NodeDB.h" #include "PowerMon.h" #include "Throttle.h" +#include "UptimeClock.h" #include "buzz.h" #include "concurrency/Periodic.h" #include "gps/RTC.h" @@ -337,7 +339,7 @@ uint8_t GPS::makeCASPacket(uint8_t class_id, uint8_t msg_id, uint8_t payload_siz } CASChecksum(UBXscratch, (payload_size + 10)); -#if defined(GPS_DEBUG) && defined(DEBUG_PORT) +#if GPS_DEBUG && defined(DEBUG_PORT) LOG_DEBUG("CAS packet: "); DEBUG_PORT.hexDump(MESHTASTIC_LOG_LEVEL_DEBUG, UBXscratch, payload_size + 10); #endif @@ -349,27 +351,25 @@ GPS_RESPONSE GPS::getACK(const char *message, uint32_t waitMillis) uint8_t buffer[768] = {0}; uint8_t b; int bytesRead = 0; - uint32_t startTimeout = millis() + waitMillis; -#ifdef GPS_DEBUG + // Start stamp + interval rather than a stored deadline: same wrap-safety, but the full 49.7-day + // range instead of 24.8 days ahead, and Time::getMillis() makes the wait injectable. + const uint32_t waitStartMs = Time::getMillis(); +#if GPS_DEBUG std::string debugmsg = ""; #endif - while (millis() < startTimeout) { + while (Throttle::isWithinTimespanMs(waitStartMs, waitMillis)) { if (_serial_gps->available()) { b = _serial_gps->read(); -#ifdef GPS_DEBUG +#if GPS_DEBUG debugmsg += vformat("%c", (b >= 32 && b <= 126) ? b : '.'); #endif buffer[bytesRead] = b; bytesRead++; if ((bytesRead == 767) || (b == '\r')) { -#ifdef GPS_DEBUG - LOG_DEBUG(debugmsg.c_str()); -#endif + LOG_DEBUG_GPS("%s", debugmsg.c_str()); if (strnstr((char *)buffer, message, bytesRead) != nullptr) { -#ifdef GPS_DEBUG - LOG_DEBUG("Found: %s", message); // Log the found message -#endif + LOG_DEBUG_GPS("Found: %s", message); // Log the found message return GNSS_RESPONSE_OK; } else { bytesRead = 0; @@ -418,17 +418,13 @@ GPS_RESPONSE GPS::getACKCas(uint8_t class_id, uint8_t msg_id, uint32_t waitMilli // Check for an ACK-ACK for the specified class and message id if ((msg_cls == 0x05) && (msg_msg_id == 0x01) && payload_cls == class_id && payload_msg == msg_id) { -#ifdef GPS_DEBUG - LOG_INFO("Got ACK for class %02X message %02X in %dms", class_id, msg_id, millis() - startTime); -#endif + LOG_DEBUG_GPS("Got ACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime); return GNSS_RESPONSE_OK; } // Check for an ACK-NACK for the specified class and message id if ((msg_cls == 0x05) && (msg_msg_id == 0x00) && payload_cls == class_id && payload_msg == msg_id) { -#ifdef GPS_DEBUG - LOG_WARN("Got NACK for class %02X message %02X in %dms", class_id, msg_id, millis() - startTime); -#endif + LOG_DEBUG_GPS("Got NACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime); return GNSS_RESPONSE_NAK; } @@ -450,7 +446,7 @@ GPS_RESPONSE GPS::getACK(uint8_t class_id, uint8_t msg_id, uint32_t waitMillis) uint32_t startTime = millis(); const char frame_errors[] = "More than 100 frame errors"; int sCounter = 0; -#ifdef GPS_DEBUG +#if GPS_DEBUG std::string debugmsg = ""; #endif @@ -467,9 +463,7 @@ GPS_RESPONSE GPS::getACK(uint8_t class_id, uint8_t msg_id, uint32_t waitMillis) while (Throttle::isWithinTimespanMs(startTime, waitMillis)) { if (ack > 9) { -#ifdef GPS_DEBUG - LOG_INFO("Got ACK for class %02X message %02X in %dms", class_id, msg_id, millis() - startTime); -#endif + LOG_DEBUG_GPS("Got ACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime); return GNSS_RESPONSE_OK; // ACK received } if (_serial_gps->available()) { @@ -477,36 +471,29 @@ GPS_RESPONSE GPS::getACK(uint8_t class_id, uint8_t msg_id, uint32_t waitMillis) if (b == frame_errors[sCounter]) { sCounter++; if (sCounter == 26) { -#ifdef GPS_DEBUG - - LOG_DEBUG(debugmsg.c_str()); -#endif + LOG_DEBUG_GPS("%s", debugmsg.c_str()); return GNSS_RESPONSE_FRAME_ERRORS; } } else { sCounter = 0; } -#ifdef GPS_DEBUG +#if GPS_DEBUG debugmsg += vformat("%02X", b); #endif if (b == buf[ack]) { ack++; } else { if (ack == 3 && b == 0x00) { // UBX-ACK-NAK message -#ifdef GPS_DEBUG - LOG_DEBUG(debugmsg.c_str()); -#endif - LOG_WARN("Got NAK for class %02X message %02X", class_id, msg_id); + LOG_DEBUG_GPS("%s", debugmsg.c_str()); + LOG_WARN("Got NAK for class %02X msg %02X", class_id, msg_id); return GNSS_RESPONSE_NAK; // NAK received } ack = 0; // Reset the acknowledgement counter } } } -#ifdef GPS_DEBUG - LOG_DEBUG(debugmsg.c_str()); - LOG_WARN("No response for class %02X message %02X", class_id, msg_id); -#endif + LOG_DEBUG_GPS("%s", debugmsg.c_str()); + LOG_DEBUG_GPS("No response for class %02X msg %02X", class_id, msg_id); return GNSS_RESPONSE_NONE; // No response received within timeout } @@ -577,9 +564,7 @@ int GPS::getACK(uint8_t *buffer, uint16_t size, uint8_t requestedClass, uint8_t ubxFrameCounter = 0; } else { // return payload length -#ifdef GPS_DEBUG - LOG_INFO("Got ACK for class %02X message %02X in %dms", requestedClass, requestedID, millis() - startTime); -#endif + LOG_DEBUG_GPS("Got ACK for class %02X msg %02X in %dms", requestedClass, requestedID, millis() - startTime); return needRead; } break; @@ -789,7 +774,7 @@ bool GPS::verifyCachedProbePresence() present = sawNmeaSentenceAtBaud(_serial_gps, 3000); } if (!present) { - LOG_WARN("Cached GPS probe is stale (%s @ %d), clearing cache", cachedProbeModelName, cachedProbeBaud); + LOG_WARN("Cached GPS probe stale (%s @ %d), clearing", cachedProbeModelName, cachedProbeBaud); clearProbeCache(); return false; } @@ -843,7 +828,7 @@ bool GPS::setup() if (gnssModel != GNSS_MODEL_UNKNOWN) { detectedBaud = rareSerialSpeeds[speedSelect]; } else if (currentStep == 0 && ++speedSelect == array_count(rareSerialSpeeds)) { - LOG_WARN("Give up on GPS probe and set to %d", GPS_BAUDRATE); + LOG_WARN("Give up GPS probe, set to %d", GPS_BAUDRATE); return true; } } @@ -922,14 +907,14 @@ bool GPS::setup() msglen = makeCASPacket(0x06, 0x07, sizeof(_message_CAS_CFG_NAVX_CONF), _message_CAS_CFG_NAVX_CONF); _serial_gps->write(UBXscratch, msglen); if (getACKCas(0x06, 0x07, 250) != GNSS_RESPONSE_OK) { - LOG_WARN("ATGM336H: Could not set Config"); + LOG_WARN("ATGM336H: Can't set Config"); } // Set the update frequency to 1Hz msglen = makeCASPacket(0x06, 0x04, sizeof(_message_CAS_CFG_RATE_1HZ), _message_CAS_CFG_RATE_1HZ); _serial_gps->write(UBXscratch, msglen); if (getACKCas(0x06, 0x04, 250) != GNSS_RESPONSE_OK) { - LOG_WARN("ATGM336H: Could not set Update Frequency"); + LOG_WARN("ATGM336H: Can't set Update Frequency"); } // Set the NEMA output messages @@ -941,7 +926,7 @@ bool GPS::setup() msglen = makeCASPacket(0x06, 0x01, sizeof(cas_cfg_msg_packet), cas_cfg_msg_packet); _serial_gps->write(UBXscratch, msglen); if (getACKCas(0x06, 0x01, 250) != GNSS_RESPONSE_OK) { - LOG_WARN("ATGM336H: Could not enable NMEA MSG: %d", fields[i]); + LOG_WARN("ATGM336H: Can't enable NMEA MSG: %d", fields[i]); } } } else if (gnssModel == GNSS_MODEL_UC6580) { @@ -1009,9 +994,9 @@ bool GPS::setup() msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE); _serial_gps->write(UBXscratch, msglen); if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) { - LOG_WARN("Unable to save GNSS module config"); + LOG_WARN("Can't save GNSS module config"); } else { - LOG_INFO("GNSS module config saved!"); + LOG_INFO("GNSS module config saved"); } } else if (IS_ONE_OF(gnssModel, GNSS_MODEL_UBLOX7, GNSS_MODEL_UBLOX8, GNSS_MODEL_UBLOX9)) { if (gnssModel == GNSS_MODEL_UBLOX7) { @@ -1025,7 +1010,7 @@ bool GPS::setup() if (getACK(0x06, 0x3e, 800) == GNSS_RESPONSE_NAK) { // It's not critical if the module doesn't acknowledge this configuration. - LOG_DEBUG("reconfigure GNSS - defaults maintained. Is this module GPS-only?"); + LOG_DEBUG("reconfigure GNSS - defaults kept. GPS-only module?"); } else { if (gnssModel == GNSS_MODEL_UBLOX7) { LOG_INFO("GPS+SBAS configured"); @@ -1078,9 +1063,9 @@ bool GPS::setup() msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE); _serial_gps->write(UBXscratch, msglen); if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) { - LOG_WARN("Unable to save GNSS module config"); + LOG_WARN("Can't save GNSS module config"); } else { - LOG_INFO("GNSS module configuration saved!"); + LOG_INFO("GNSS module config saved"); } } else if (gnssModel == GNSS_MODEL_UBLOX10) { delay(1000); @@ -1126,9 +1111,9 @@ bool GPS::setup() msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE_10), _message_SAVE_10); _serial_gps->write(UBXscratch, msglen); if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) { - LOG_WARN("Unable to save GNSS module config"); + LOG_WARN("Can't save GNSS module config"); } else { - LOG_INFO("GNSS module configuration saved!"); + LOG_INFO("GNSS module config saved"); } } else if (gnssModel == GNSS_MODEL_CM121) { // only ask for RMC and GGA @@ -1159,7 +1144,7 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime) // Update the stored GPSPowerstate, and create local copies GPSPowerState oldState = powerState; powerState = newState; - LOG_INFO("GPS power state move from %s to %s", getGPSPowerStateString(oldState), getGPSPowerStateString(newState)); + LOG_INFO("GPS power state %s -> %s", getGPSPowerStateString(oldState), getGPSPowerStateString(newState)); switch (newState) { case GPS_ACTIVE: @@ -1234,9 +1219,7 @@ void GPS::writePinEN(bool on) // Write and log enablePin->set(on); -#ifdef GPS_DEBUG - LOG_DEBUG("Pin EN %s", on == HIGH ? "HI" : "LOW"); -#endif + LOG_DEBUG_GPS("Pin EN %s", on == HIGH ? "HI" : "LOW"); } // Set the value of the STANDBY pin, if relevant @@ -1259,9 +1242,7 @@ void GPS::writePinStandby(bool standby) _serial_gps->write("$PMTK225,4*2F\r\n"); } -#ifdef GPS_DEBUG - LOG_DEBUG("Pin STANDBY %s", val == HIGH ? "HI" : "LOW"); -#endif + LOG_DEBUG_GPS("Pin STANDBY %s", val == HIGH ? "HI" : "LOW"); #endif } @@ -1272,9 +1253,7 @@ void GPS::writePinRFEN(bool on) bool val = on ? GPS_RF_EN_ACTIVE : !GPS_RF_EN_ACTIVE; pinMode(PIN_GPS_RF_EN, OUTPUT); digitalWrite(PIN_GPS_RF_EN, val); -#ifdef GPS_DEBUG - LOG_DEBUG("Pin RF EN %s", val == HIGH ? "HI" : "LOW"); -#endif + LOG_DEBUG_GPS("Pin RF EN %s", val == HIGH ? "HI" : "LOW"); #else (void)on; #endif @@ -1310,9 +1289,7 @@ void GPS::setPowerPMU(bool on) // t-beam v1.1 GNSS power channel on ? PMU->enablePowerOutput(XPOWERS_LDO3) : PMU->disablePowerOutput(XPOWERS_LDO3); } -#ifdef GPS_DEBUG - LOG_DEBUG("PMU %s", on ? "on" : "off"); -#endif + LOG_DEBUG_GPS("PMU %s", on ? "on" : "off"); #endif } @@ -1358,9 +1335,7 @@ void GPS::setPowerUBLOX(bool on, uint32_t sleepMs) // Send the UBX packet gps->_serial_gps->write(gps->UBXscratch, msglen); -#ifdef GPS_DEBUG - LOG_DEBUG("UBLOX: sleep for %dmS", sleepMs); -#endif + LOG_DEBUG_GPS("UBLOX: sleep for %dmS", sleepMs); } } @@ -1417,12 +1392,8 @@ void GPS::down() #endif if (softsleepSupported) { - // How long does gps_update_interval need to be, for GPS_HARDSLEEP to become more efficient than - // GPS_SOFTSLEEP? Heuristic equation. A compromise manually fitted to power observations from U-blox NEO-6M - // and M10050 https://www.desmos.com/calculator/6gvjghoumr This is not particularly accurate, but probably an - // improvement over a single, fixed threshold - uint32_t hardsleepThreshold = (2750 * pow(predictedSearchDuration / 1000, 1.22)); - LOG_DEBUG("gps_update_interval >= %us needed to justify hardsleep", hardsleepThreshold / 1000); + uint32_t hardsleepThreshold = gpsHardsleepThresholdMs(predictedSearchDuration / 1000); + LOG_DEBUG("gps_update_interval >= %us needed for hardsleep", hardsleepThreshold / 1000); // If update interval too short: softsleep (if supported by hardware) if (updateInterval < hardsleepThreshold) { @@ -1446,7 +1417,7 @@ void GPS::publishUpdate() LOG_DEBUG("Publish pos@%x:2, hasVal=%d, Sats=%d, GPSlock=%d", p.timestamp, hasValidLocation, p.sats_in_view, hasLock()); // Notify any status instances that are observing us - const meshtastic::GPSStatus status = meshtastic::GPSStatus(hasValidLocation, isConnected(), isPowerSaving(), p); + const meshtastic::GPSStatus status = meshtastic::GPSStatus(hasValidLocation, isConnected(), isPowerSaving(), p, gotTime); newStatus.notifyObservers(&status); if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { positionModule->handleNewPosition(); @@ -1454,6 +1425,29 @@ void GPS::publishUpdate() } } +/// Is a post-lock ephemeris hold currently in force? The `!= 0` is the "never armed" sentinel, which +/// deadlinePassed() reads as passed for the first half of each wrap cycle and as ~24.8 days in the +/// future for the second. No header: test_gps_fix_hold declares the prototypes itself. +bool fixHoldInForce(uint32_t fixHoldEnds, uint32_t threadIntervalMs) +{ + return fixHoldEnds != 0 && !Throttle::deadlinePassed(fixHoldEnds + threadIntervalMs); +} + +/// Did an armed hold just expire? `!= 0` guards against negating fixHoldInForce() alone, which would +/// call an unarmed hold "expired" every cycle. No grace interval: the deadline itself is go-down time. +bool holdJustExpired(uint32_t fixHoldEnds) +{ + return fixHoldEnds != 0 && !fixHoldInForce(fixHoldEnds, 0); +} + +/// Should a post-lock ephemeris hold be (re-)armed this cycle? "No hold in force" fires often, since +/// every publish clears the hold, including ones that don't put the receiver back to sleep. +bool shouldArmFixHold(bool hasValidLocation, uint8_t prevFixQual, uint32_t fixHoldEnds, uint32_t threadIntervalMs) +{ + // First lock of a cycle, first lock after the receiver was off, or nothing holding right now. + return !hasValidLocation || prevFixQual == 0 || !fixHoldInForce(fixHoldEnds, threadIntervalMs); +} + int32_t GPS::runOnce() { #if defined(SENSECAP_INDICATOR) @@ -1480,7 +1474,7 @@ int32_t GPS::runOnce() return currentDelay; // Setup failed, re-run in two seconds if (gnssModel == GNSS_MODEL_UNKNOWN) { - LOG_WARN("GPS not detected; marked not present for this boot"); + LOG_WARN("GPS not detected; not present this boot"); return disable(); } @@ -1502,7 +1496,7 @@ int32_t GPS::runOnce() // gps_update_interval is faster than the position broadcast interval so there's a // fresh position ready when the device wants to broadcast one on the mesh. // - // 1. Got a time for the first time --> set the time, don't publish. + // 1. Got a time for the first time --> set the time, publish so the UI can show the time-only state. // 2. Got a lock for the first time // --> If gps_update_interval is <= 10s --> publishUpdate // --> Otherwise, hold for MIN(gps_update_interval - GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS, 20s) @@ -1536,12 +1530,17 @@ int32_t GPS::runOnce() // 1. Got a time for the first time this cycle if (!gotTime && lookForTime()) { // Note: we count on this && short-circuiting and not resetting the RTC time gotTime = true; + // Publish immediately (rather than via the block below, which would clear fixHoldEnds) so the + // time-only state reaches the UI without waiting for a location. Safe without a valid location: + // PositionModule::handleNewPosition ignores invalid positions. + shouldPublish = true; + publishUpdate(); } // 2. Got a lock for the first time, or 3. Got a lock after turning back on bool gotLoc = lookForLocation(); if (gotLoc) { -#ifdef GPS_DEBUG +#if GPS_DEBUG if (!hasValidLocation) { // declare that we have location ASAP LOG_DEBUG("hasValidLocation RISING EDGE"); } @@ -1549,35 +1548,33 @@ int32_t GPS::runOnce() if (updateInterval <= GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS) { hasValidLocation = true; shouldPublish = true; - } else if (!hasValidLocation || prev_fixQual == 0 || (fixHoldEnds + GPS_THREAD_INTERVAL) < millis()) { + } else if (shouldArmFixHold(hasValidLocation, prev_fixQual, fixHoldEnds, GPS_THREAD_INTERVAL)) { hasValidLocation = true; // Hold for up to 20secs after getting a lock to download ephemeris etc uint32_t holdTime = updateInterval - GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS; if (holdTime > GPS_FIX_HOLD_MAX_MS) holdTime = GPS_FIX_HOLD_MAX_MS; - fixHoldEnds = millis() + holdTime; -#ifdef GPS_DEBUG - LOG_DEBUG("Holding for %ums after lock", holdTime); -#endif + // Same clock the Throttle evaluation reads, and never the "no hold" sentinel. + const uint32_t holdEnds = Time::getMillis() + holdTime; + fixHoldEnds = holdEnds == 0 ? 1 : holdEnds; + LOG_DEBUG_GPS("Holding for %ums after lock", holdTime); } } bool tooLong = scheduling.searchedTooLong(); if (tooLong && !gotLoc) { - LOG_WARN("Couldn't publish a valid location: didn't get a GPS lock in time"); + LOG_WARN("Can't publish valid location: no GPS lock in time"); // we didn't get a location during this ack window, therefore declare loss of lock if (hasValidLocation) { p = meshtastic_Position_init_default; hasValidLocation = false; shouldPublish = true; -#ifdef GPS_DEBUG - LOG_DEBUG("hasValidLocation FALLING EDGE"); -#endif + LOG_DEBUG_GPS("hasValidLocation FALLING EDGE"); } } // Hold has expired , Search time has expired, we got a time only, or we never needed to hold. - bool holdExpired = (fixHoldEnds != 0 && millis() > fixHoldEnds); + bool holdExpired = holdJustExpired(fixHoldEnds); if (shouldPublish || tooLong || holdExpired) { if (gotTime && hasValidLocation) { shouldPublish = true; @@ -1592,9 +1589,9 @@ int32_t GPS::runOnce() down(); } -#ifdef GPS_DEBUG +#if GPS_DEBUG } else if (fixHoldEnds != 0) { - LOG_DEBUG("Holding for GPS data download: %d ms (numSats=%d)", fixHoldEnds - millis(), p.sats_in_view); + LOG_DEBUG("Holding for GPS data download: %d ms (numSats=%d)", fixHoldEnds - Time::getMillis(), p.sats_in_view); #endif } } @@ -1623,7 +1620,7 @@ void GPS::clearBuffer() /// Prepare the GPS for the cpu entering deep or light sleep, expect to be gone for at least 100s of msecs int GPS::prepareDeepSleep(void *unused) { - LOG_INFO("GPS deep sleep!"); + LOG_INFO("GPS deep sleep"); disable(); return 0; } @@ -1819,7 +1816,6 @@ GnssModel_t GPS::probe(int serialSpeed) break; } - LOG_DEBUG("Module Info : "); LOG_DEBUG("Soft version: %s", ublox_info.swVersion); LOG_DEBUG("Hard version: %s", ublox_info.hwVersion); LOG_DEBUG("Extensions:%d", ublox_info.extensionNo); @@ -1899,27 +1895,21 @@ GnssModel_t GPS::getProbeResponse(unsigned long timeout, const std::vector= 2 && response[responseLen - 2] == '\r' && response[responseLen - 1] == '\n') { -#ifdef GPS_DEBUG - LOG_DEBUG(response.get()); -#endif + LOG_DEBUG_GPS("%s", response.get()); // Reset the response buffer for the next potential message responseLen = 0; response[0] = '\0'; } } } -#ifdef GPS_DEBUG - LOG_DEBUG(response.get()); -#endif + LOG_DEBUG_GPS("%s", response.get()); return GNSS_MODEL_UNKNOWN; // Return unknown on timeout } @@ -2025,7 +2015,7 @@ std::unique_ptr GPS::createGps() #endif #if defined(SENSECAP_INDICATOR) - LOG_DEBUG("Use the RP2040 tunnel for GPS, no local pins"); + LOG_DEBUG("Use RP2040 tunnel for GPS, no local pins"); #else LOG_DEBUG("Use GPIO%d for GPS RX", new_gps->rx_gpio); LOG_DEBUG("Use GPIO%d for GPS TX", new_gps->tx_gpio); @@ -2120,10 +2110,10 @@ bool GPS::lookForLocation() #ifndef TINYGPS_OPTION_NO_STATISTICS if (reader.failedChecksum() > lastChecksumFailCount) { // In a GPS_DEBUG build we want to log all of these. In production, we only care if there are many of them. -#ifndef GPS_DEBUG +#if !GPS_DEBUG if (reader.failedChecksum() > 4) #endif - LOG_WARN("%u new GPS checksum failures, for a total of %u", reader.failedChecksum() - lastChecksumFailCount, + LOG_WARN("%u new GPS checksum failures, total %u", reader.failedChecksum() - lastChecksumFailCount, reader.failedChecksum()); lastChecksumFailCount = reader.failedChecksum(); } @@ -2137,7 +2127,7 @@ bool GPS::lookForLocation() if (!hasLock()) return false; -#ifdef GPS_DEBUG +#if GPS_DEBUG LOG_DEBUG("AGE: LOC=%d FIX=%d DATE=%d TIME=%d", reader.location.age(), #ifndef TINYGPS_OPTION_NO_CUSTOM_FIELDS gsafixtype.age(), @@ -2159,7 +2149,7 @@ bool GPS::lookForLocation() (gsafixtype.age() < GPS_SOL_EXPIRY_MS) && #endif (reader.time.age() < GPS_SOL_EXPIRY_MS) && (reader.date.age() < GPS_SOL_EXPIRY_MS))) { - LOG_WARN("SOME data is TOO OLD: LOC %u, TIME %u, DATE %u", reader.location.age(), reader.time.age(), reader.date.age()); + LOG_WARN("SOME data TOO OLD: LOC %u, TIME %u, DATE %u", reader.location.age(), reader.time.age(), reader.date.age()); return false; } @@ -2168,15 +2158,11 @@ bool GPS::lookForLocation() // Bail out EARLY to avoid overwriting previous good data (like #857) if (toDegInt(loc.lat) > 900000000) { -#ifdef GPS_DEBUG - LOG_DEBUG("Bail out EARLY on LAT %i", toDegInt(loc.lat)); -#endif + LOG_DEBUG_GPS("Bail out EARLY on LAT %i", toDegInt(loc.lat)); return false; } if (toDegInt(loc.lng) > 1800000000) { -#ifdef GPS_DEBUG - LOG_DEBUG("Bail out EARLY on LNG %i", toDegInt(loc.lng)); -#endif + LOG_DEBUG_GPS("Bail out EARLY on LNG %i", toDegInt(loc.lng)); return false; } @@ -2261,7 +2247,7 @@ bool GPS::whileActive() { unsigned int charsInBuf = 0; bool isValid = false; -#ifdef GPS_DEBUG +#if GPS_DEBUG std::string debugmsg = ""; #endif if (powerState != GPS_ACTIVE) { @@ -2270,7 +2256,7 @@ bool GPS::whileActive() } #ifdef SERIAL_BUFFER_SIZE if (_serial_gps->available() >= SERIAL_BUFFER_SIZE - 1) { - LOG_WARN("GPS Buffer full with %u bytes waiting. Flush to avoid corruption", _serial_gps->available()); + LOG_WARN("GPS Buffer full (%u bytes). Flush to avoid corruption", _serial_gps->available()); clearBuffer(); } #endif @@ -2278,7 +2264,7 @@ bool GPS::whileActive() while (_serial_gps->available() > 0) { int c = _serial_gps->read(); UBXscratch[charsInBuf] = c; -#ifdef GPS_DEBUG +#if GPS_DEBUG debugmsg += vformat("%c", (c >= 32 && c <= 126) ? c : '.'); #endif isValid |= reader.encode(c); @@ -2291,9 +2277,9 @@ bool GPS::whileActive() charsInBuf++; } } -#ifdef GPS_DEBUG +#if GPS_DEBUG if (debugmsg != "") { - LOG_DEBUG(debugmsg.c_str()); + LOG_DEBUG("%s", debugmsg.c_str()); } #endif return isValid; diff --git a/src/gps/GPSLog.h b/src/gps/GPSLog.h new file mode 100644 index 0000000000..9ee85096d9 --- /dev/null +++ b/src/gps/GPSLog.h @@ -0,0 +1,14 @@ +#pragma once + +#include "DebugConfiguration.h" + +// GPS_DEBUG=1 enables verbose GNSS diagnostics (probe/ACK byte dumps, pin states, NMEA ages). +// Costs no flash when off. Genuine LOG_WARN anomalies stay unconditional. +#ifndef GPS_DEBUG +#define GPS_DEBUG 0 +#endif +#if GPS_DEBUG +#define LOG_DEBUG_GPS(...) LOG_DEBUG(__VA_ARGS__) +#else +#define LOG_DEBUG_GPS(...) ((void)0) +#endif diff --git a/src/gps/GPSUpdateScheduling.cpp b/src/gps/GPSUpdateScheduling.cpp index a19d9c7d57..7f37e100c9 100644 --- a/src/gps/GPSUpdateScheduling.cpp +++ b/src/gps/GPSUpdateScheduling.cpp @@ -1,18 +1,46 @@ #include "GPSUpdateScheduling.h" #include "Default.h" +#include "UptimeClock.h" + +// Sampled from the original `2750 * seconds^1.22` curve. Interpolation tracks it within 0.6% for +// inputs >=10s and 1.7% below that; the 1s/2s/3s points keep the convex first segment from +// overshooting (a 0s-to-5s chord reads 42% high at 1s). +static constexpr uint32_t kThresholdCurveSecs[] = {0, 1, 2, 3, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 300, 450, 600, 900}; +static constexpr uint32_t kThresholdCurveMs[] = {0, 2750, 6406, 10506, 19592, 45639, 74845, + 106314, 174350, 285925, 406141, 666053, 946093, 1551548, + 2203893, 2893481, 4745172, 6740269, 11053722}; +static constexpr size_t kThresholdCurvePoints = sizeof(kThresholdCurveSecs) / sizeof(kThresholdCurveSecs[0]); + +// How long does gps_update_interval need to be, for GPS_HARDSLEEP to become more efficient than +// GPS_SOFTSLEEP? Avoids pow() so this heuristic doesn't pull double-precision libm into the image. +uint32_t gpsHardsleepThresholdMs(uint32_t predictedSearchSecs) +{ + if (predictedSearchSecs >= kThresholdCurveSecs[kThresholdCurvePoints - 1]) + return kThresholdCurveMs[kThresholdCurvePoints - 1]; + + size_t i = 1; + while (kThresholdCurveSecs[i] < predictedSearchSecs) + i++; + + uint32_t x0 = kThresholdCurveSecs[i - 1], x1 = kThresholdCurveSecs[i]; + uint32_t y0 = kThresholdCurveMs[i - 1], y1 = kThresholdCurveMs[i]; + return y0 + (uint32_t)((uint64_t)(y1 - y0) * (predictedSearchSecs - x0) / (x1 - x0)); +} // Mark the time when searching for GPS position begins void GPSUpdateScheduling::informSearching() { - searchStartedMs = millis(); + searching = true; + searchStartedMs = Time::getMillis(); } // Mark the time when searching for GPS is complete, // then update the predicted lock-time void GPSUpdateScheduling::informGotLock() { - searchEndedMs = millis(); + searching = false; + searchEndedMs = Time::getMillis(); LOG_DEBUG("Took %us to get lock", (searchEndedMs - searchStartedMs) / 1000); updateLockTimePrediction(); consecutiveFailures = 0; // Drop back to fast cadence as soon as we acquire any fix @@ -24,7 +52,8 @@ void GPSUpdateScheduling::informGotLock() // down() to fall into GPS_IDLE, leaving the chip awake on subsequent indoor cycles. void GPSUpdateScheduling::informSearchFailed() { - searchEndedMs = millis(); + searching = false; + searchEndedMs = Time::getMillis(); consecutiveFailures++; LOG_DEBUG("GPS search ended without fix after %us (consecutive failures: %u)", (searchEndedMs - searchStartedMs) / 1000, consecutiveFailures); @@ -34,6 +63,7 @@ void GPSUpdateScheduling::informSearchFailed() // When re-enabling GPS with user button. void GPSUpdateScheduling::reset() { + searching = false; searchStartedMs = 0; searchEndedMs = 0; searchCount = 0; @@ -45,7 +75,7 @@ void GPSUpdateScheduling::reset() // Used by GPS hardware directly, to enter timed hardware sleep uint32_t GPSUpdateScheduling::msUntilNextSearch() { - uint32_t now = millis(); + uint32_t now = Time::getMillis(); // Target interval (seconds), between GPS updates uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval, default_gps_update_interval); @@ -80,13 +110,12 @@ uint32_t GPSUpdateScheduling::msUntilNextSearch() // Used to abort a search in progress, if it runs unacceptably long uint32_t GPSUpdateScheduling::elapsedSearchMs() { - // If searching - if (searchStartedMs > searchEndedMs) - return millis() - searchStartedMs; + // Recorded, not inferred from searchStartedMs > searchEndedMs: ordering two stamps inverts + // across the 32-bit wrap, and the inform*() calls already know which state we are in. + if (!searching) + return 0; // Not searching. We shouldn't really consume this value - // If not searching - 0ms. We shouldn't really consume this value - else - return 0; + return Time::getMillis() - searchStartedMs; } // Is it now time to begin searching for a GPS position? diff --git a/src/gps/GPSUpdateScheduling.h b/src/gps/GPSUpdateScheduling.h index 120605c4ef..d7e11ad1ab 100644 --- a/src/gps/GPSUpdateScheduling.h +++ b/src/gps/GPSUpdateScheduling.h @@ -2,6 +2,10 @@ #include "configuration.h" +// Approximates the GPS_HARDSLEEP/GPS_SOFTSLEEP crossover curve without pow(); see .cpp for the +// sampled reference values it interpolates between. +uint32_t gpsHardsleepThresholdMs(uint32_t predictedSearchSecs); + // Encapsulates code responsible for the timing of GPS updates class GPSUpdateScheduling { @@ -21,6 +25,7 @@ class GPSUpdateScheduling private: void updateLockTimePrediction(); // Called from informGotLock + bool searching = false; // Set by the inform*() calls; never inferred from stamp ordering uint32_t searchStartedMs = 0; uint32_t searchEndedMs = 0; uint32_t searchCount = 0; diff --git a/src/gps/GeoCoord.cpp b/src/gps/GeoCoord.cpp index 4afae9394d..4f34966817 100644 --- a/src/gps/GeoCoord.cpp +++ b/src/gps/GeoCoord.cpp @@ -1,4 +1,5 @@ #include "GeoCoord.h" +#include "configuration.h" #include // Narrow a UTM meter value to its unsigned field, clamping non-finite/negative/oversized inputs: an @@ -433,6 +434,43 @@ void GeoCoord::convertWGS84ToOSGB36(const double lat, const double lon, double & //(airyA*airyA/(airyA / sqrt(1 - airyEcc*sin(osgb.latitude)*sin(osgb.latitude)))); // Not used, no OSTN data } +#if MESHTASTIC_TRIG_APPROX +// cos(x) minimax approx for x in [-pi/2, pi/2] ("cos_52"): https://www.ganssle.com/approx.htm +static double cosLatitudeApprox(double latRad) +{ + constexpr double c1 = 0.9999932946, c2 = -0.4999124376, c3 = 0.0414877472, c4 = -0.0012712095; + double x2 = latRad * latRad; + return c1 + x2 * (c2 + x2 * (c3 + c4 * x2)); +} + +/// Approximate distance in meters via equirectangular projection (not exact spherical trig). +/// <1% error to ~500km, degrading near the poles at long range (see test_geocoord_distance). +float GeoCoord::latLongToMeter(double lat_a, double lng_a, double lat_b, double lng_b) +{ + // Don't do math if the points are the same + if (lat_a == lat_b && lng_a == lng_b) + return 0.0; + + double a1 = lat_a / DEG_CONVERT; + double a2 = lng_a / DEG_CONVERT; + double b1 = lat_b / DEG_CONVERT; + double b2 = lng_b / DEG_CONVERT; + + double meanLat = (a1 + b1) / 2; + double dLng = b2 - a2; + // Wrap to [-PI, PI]: unlike cos()/sin(), a raw longitude difference doesn't handle points that + // straddle the antimeridian (e.g. 179.9 and -179.9 are ~0.2 degrees apart, not ~360). + if (dLng > PI) + dLng -= 2 * PI; + else if (dLng < -PI) + dLng += 2 * PI; + double x = dLng * cosLatitudeApprox(meanLat); + double y = b1 - a1; + double tt = sqrt(x * x + y * y); + + return (float)(6366000 * tt); +} +#else /// Ported from my old java code, returns distance in meters along the globe /// surface (by Haversine formula) float GeoCoord::latLongToMeter(double lat_a, double lng_a, double lat_b, double lng_b) @@ -456,6 +494,7 @@ float GeoCoord::latLongToMeter(double lat_a, double lng_a, double lat_b, double return (float)(6366000 * tt); } +#endif /** * Computes the bearing in degrees between two points on Earth. Ported from my @@ -482,41 +521,6 @@ float GeoCoord::bearing(double lat1, double lon1, double lat2, double lon2) return atan2(y, x); } -/** - * Ported from http://www.edwilliams.org/avform147.htm#Intro - * @brief Convert from meters to range in radians on a great circle - * @param range_meters - * The range in meters - * @return range in radians on a great circle - */ -float GeoCoord::rangeMetersToRadians(double range_meters) -{ - // 1 nm is 1852 meters - double distance_nm = range_meters * 1852; - return (PI / (180 * 60)) * distance_nm; -} - -/** - * Create a new point based on the passed-in point - * Ported from http://www.edwilliams.org/avform147.htm#LL - * @param bearing - * The bearing in radians - * @param range_meters - * range in meters - * @return GeoCoord object of point at bearing and range from initial point - */ -std::shared_ptr GeoCoord::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 1559e5e276..93e59e31e6 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -1,9 +1,12 @@ #include "gps/RTC.h" +#include "UptimeClock.h" #include "configuration.h" #include "detect/ScanI2C.h" #include "detect/ScanI2CTwoWire.h" +#include "gps/GPSLog.h" #include "main.h" #include "mesh/MeshService.h" +#include "mesh/NodeDB.h" #include "modules/NodeInfoModule.h" #include #include @@ -22,12 +25,15 @@ static const uint32_t TIME_VALIDATION_WARNING_INTERVAL_MS = 15000; // 15 seconds static void onTimeSourceQualityChanged(RTCQuality oldQuality, RTCQuality newQuality) { if (oldQuality == RTCQualityNone && newQuality > RTCQualityNone && nodeInfoModule) { - LOG_DEBUG("Time source acquired (%s -> %s), triggering NodeInfo recheck", RtcName(oldQuality), RtcName(newQuality)); + LOG_DEBUG("Time source acquired (%s -> %s), recheck NodeInfo", RtcName(oldQuality), RtcName(newQuality)); nodeInfoModule->triggerImmediateNodeInfoCheck(); } - if (oldQuality < RTCQualityFromNet && newQuality >= RTCQualityFromNet && service) { + if (oldQuality < RTCQualityFromNet && newQuality >= RTCQualityFromNet) { LOG_DEBUG("RTC net quality reached (%s -> %s), reconciling rx_time", RtcName(oldQuality), RtcName(newQuality)); - service->reconcilePendingRxTimes(); + if (service) + service->reconcilePendingRxTimes(); + if (nodeDB) + nodeDB->backfillHeardAt(); } } @@ -37,8 +43,9 @@ RTCQuality getRTCQuality() } // stuff that really should be in in the instance instead... -static uint32_t - timeStartMsec; // Once we have a GPS lock, this is where we hold the initial msec clock that corresponds to that time +// The Time::getMillisMonotonic() instant corresponding to zeroOffsetSecs. 64-bit so getTime()'s +// elapsed term cannot wrap: a 32-bit anchor walks the wall clock back 49.7 days per millis() cycle. +static uint64_t timeStartMs64; static uint64_t zeroOffsetSecs; // GPS based time in secs since 1970 - only updated once on initial lock #ifdef PIO_UNIT_TESTING @@ -70,14 +77,14 @@ static struct timeval mockSystemTime = {}; { struct timeval tv; if (readSystemTime(&tv)) { - uint32_t now = millis(); + const uint64_t now = Time::getMillisMonotonic(); uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms if (currentQuality == RTCQualityNone) { LOG_DEBUG("Seed time from system clock: %lu", (unsigned long)printableEpoch); - timeStartMsec = now; + timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; } else { - LOG_DEBUG("Ignore system clock fallback (%lu); current RTC quality is %s", (unsigned long)printableEpoch, + LOG_DEBUG("Ignore system clock fallback (%lu); RTC quality is %s", (unsigned long)printableEpoch, RtcName(currentQuality)); } return RTCSetResultSuccess; @@ -100,7 +107,7 @@ RTCSetResult readFromRTC() [[maybe_unused]] struct timeval tv; /* btw settimeofday() is helpful here too*/ #ifdef RV3028_RTC if (rtc_found.address == RV3028_RTC) { - uint32_t now = millis(); + const uint64_t now = Time::getMillisMonotonic(); Melopero_RV3028 rtc; #if WIRE_INTERFACES_COUNT == 2 rtc.initI2C(*ScanI2CTwoWire::fetchI2CBus(rtc_found)); @@ -121,24 +128,27 @@ RTCSetResult readFromRTC() #ifdef BUILD_EPOCH if (tv.tv_sec < BUILD_EPOCH) { if (Throttle::isWithinTimespanMs(lastTimeValidationWarning, TIME_VALIDATION_WARNING_INTERVAL_MS) == false) { - LOG_WARN("Ignore time (%ld) before build epoch (%ld)!", printableEpoch, BUILD_EPOCH); + LOG_WARN("Ignore time (%ld) before build epoch (%ld)", printableEpoch, BUILD_EPOCH); } return RTCSetResultInvalidTime; } #endif - LOG_DEBUG("Read RTC time from RV3028 getTime as %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, t.tm_mon + 1, - t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); + LOG_DEBUG_GPS("RTC time from RV3028 getTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, t.tm_mon + 1, + t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; - timeStartMsec = now; + timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) + settimeofday(&tv, NULL); +#endif currentQuality = RTCQualityDevice; onTimeSourceQualityChanged(oldQuality, currentQuality); } return RTCSetResultSuccess; } else { - LOG_WARN("RTC not found (found address 0x%02X)", rtc_found.address); + LOG_WARN("RTC read: not found (addr 0x%02X)", rtc_found.address); } #elif defined(PCF8563_RTC) || defined(PCF85063_RTC) #if defined(PCF8563_RTC) @@ -149,7 +159,7 @@ RTCSetResult readFromRTC() SensorPCF85063 rtc; #endif - uint32_t now = millis(); + const uint64_t now = Time::getMillisMonotonic(); #if WIRE_INTERFACES_COUNT == 2 rtc.begin(*ScanI2CTwoWire::fetchI2CBus(rtc_found)); @@ -166,29 +176,32 @@ RTCSetResult readFromRTC() #ifdef BUILD_EPOCH if (tv.tv_sec < BUILD_EPOCH) { if (Throttle::isWithinTimespanMs(lastTimeValidationWarning, TIME_VALIDATION_WARNING_INTERVAL_MS) == false) { - LOG_WARN("Ignore time (%ld) before build epoch (%ld)!", printableEpoch, BUILD_EPOCH); + LOG_WARN("Ignore time (%ld) before build epoch (%ld)", printableEpoch, BUILD_EPOCH); lastTimeValidationWarning = millis(); } return RTCSetResultInvalidTime; } #endif - LOG_DEBUG("Read RTC time from %s getDateTime as %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t.tm_year + 1900, - t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); + LOG_DEBUG_GPS("RTC time from %s getDateTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t.tm_year + 1900, + t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; - timeStartMsec = now; + timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) + settimeofday(&tv, NULL); +#endif currentQuality = RTCQualityDevice; onTimeSourceQualityChanged(oldQuality, currentQuality); } return RTCSetResultSuccess; } else { - LOG_WARN("RTC not found (found address 0x%02X)", rtc_found.address); + LOG_WARN("RTC read: not found (addr 0x%02X)", rtc_found.address); } #elif defined(RX8130CE_RTC) if (rtc_found.address == RX8130CE_RTC) { - uint32_t now = millis(); + const uint64_t now = Time::getMillisMonotonic(); #ifdef MUZI_BASE ArtronShop_RX8130CE rtc(&Wire1); #else @@ -200,12 +213,12 @@ RTCSetResult readFromRTC() tv.tv_usec = 0; uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms - LOG_DEBUG("Read RTC time from RX8130CE getDateTime as %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, - t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); + LOG_DEBUG_GPS("RTC time from RX8130CE getDateTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, + t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); #ifdef BUILD_EPOCH if (tv.tv_sec < BUILD_EPOCH) { if (Throttle::isWithinTimespanMs(lastTimeValidationWarning, TIME_VALIDATION_WARNING_INTERVAL_MS) == false) { - LOG_WARN("Ignore time (%ld) before build epoch (%ld)!", printableEpoch, BUILD_EPOCH); + LOG_WARN("Ignore time (%ld) before build epoch (%ld)", printableEpoch, BUILD_EPOCH); lastTimeValidationWarning = millis(); } return RTCSetResultInvalidTime; @@ -213,8 +226,11 @@ RTCSetResult readFromRTC() #endif if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; - timeStartMsec = now; + timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) + settimeofday(&tv, NULL); +#endif currentQuality = RTCQualityDevice; onTimeSourceQualityChanged(oldQuality, currentQuality); } @@ -223,14 +239,14 @@ RTCSetResult readFromRTC() } #elif HAS_LSE if (stm32wlRtcAvailable()) { - uint32_t now = millis(); + const uint64_t now = Time::getMillisMonotonic(); tv.tv_sec = STM32RTC::getInstance().getEpoch(); tv.tv_usec = 0; uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms #ifdef BUILD_EPOCH if (tv.tv_sec < BUILD_EPOCH) { if (Throttle::isWithinTimespanMs(lastTimeValidationWarning, TIME_VALIDATION_WARNING_INTERVAL_MS) == false) { - LOG_WARN("Ignore time (%ld) before build epoch (%ld)!", printableEpoch, BUILD_EPOCH); + LOG_WARN("Ignore time (%ld) before build epoch (%ld)", printableEpoch, BUILD_EPOCH); lastTimeValidationWarning = millis(); } return RTCSetResultInvalidTime; @@ -238,7 +254,7 @@ RTCSetResult readFromRTC() #endif if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; - timeStartMsec = now; + timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; currentQuality = RTCQualityDevice; onTimeSourceQualityChanged(oldQuality, currentQuality); @@ -263,12 +279,13 @@ RTCSetResult readFromRTC() RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpdate) { static uint32_t lastSetMsec = 0; - uint32_t now = millis(); + const uint64_t now64 = Time::getMillisMonotonic(); + const uint32_t now = (uint32_t)now64; // low word == getMillis(); fine for the Throttle-checked stamps below uint32_t printableEpoch = tv->tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms #ifdef BUILD_EPOCH if (tv->tv_sec < BUILD_EPOCH) { if (Throttle::isWithinTimespanMs(lastTimeValidationWarning, TIME_VALIDATION_WARNING_INTERVAL_MS) == false) { - LOG_WARN("Ignore time (%ld) before build epoch (%ld)!", printableEpoch, BUILD_EPOCH); + LOG_WARN("Ignore time (%ld) before build epoch (%ld)", printableEpoch, BUILD_EPOCH); lastTimeValidationWarning = millis(); } return RTCSetResultInvalidTime; @@ -277,8 +294,8 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd // Calculate max allowed time safely to avoid overflow in logging uint64_t maxAllowedTime = (uint64_t)BUILD_EPOCH + FORTY_YEARS; uint32_t maxAllowedPrintable = (maxAllowedTime > UINT32_MAX) ? UINT32_MAX : (uint32_t)maxAllowedTime; - LOG_WARN("Ignore time (%ld) too far in the future (build epoch: %ld, max allowed: %ld)!", printableEpoch, - (uint32_t)BUILD_EPOCH, maxAllowedPrintable); + LOG_WARN("Ignore time (%ld) too far in future (build epoch: %ld, max: %ld)", printableEpoch, (uint32_t)BUILD_EPOCH, + maxAllowedPrintable); lastTimeValidationWarning = millis(); } return RTCSetResultInvalidTime; @@ -288,21 +305,20 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd bool shouldSet; if (forceUpdate) { shouldSet = true; - LOG_DEBUG("Override current RTC quality (%s) with incoming time of RTC quality of %s", RtcName(currentQuality), - RtcName(q)); + LOG_DEBUG("Override RTC quality (%s) with incoming quality %s", RtcName(currentQuality), RtcName(q)); } else if (q > currentQuality) { shouldSet = true; LOG_DEBUG("Upgrade time to quality %s", RtcName(q)); } else if (q == RTCQualityGPS) { shouldSet = true; - LOG_DEBUG("Reapply GPS time: %ld secs", printableEpoch); + LOG_DEBUG_GPS("Reapply GPS time: %ld secs", printableEpoch); } else if (q == RTCQualityNTP && !Throttle::isWithinTimespanMs(lastSetMsec, (30 * 60 * 1000UL))) { // Every 30 minutes we will slam in a new NTP or Phone GPS / NTP time, to correct for local RTC clock drift shouldSet = true; - LOG_DEBUG("Reapply external time to correct clock drift %ld secs", printableEpoch); + LOG_DEBUG_GPS("Reapply external time to fix clock drift %ld secs", printableEpoch); } else { shouldSet = false; - LOG_DEBUG("Current RTC quality: %s. Ignore time of RTC quality of %s", RtcName(currentQuality), RtcName(q)); + LOG_DEBUG_GPS("RTC quality: %s. Ignore time of quality %s", RtcName(currentQuality), RtcName(q)); } if (shouldSet) { @@ -314,7 +330,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd } // This delta value works on all platforms - timeStartMsec = now; + timeStartMs64 = now64; zeroOffsetSecs = tv->tv_sec; // If this platform has a settable RTC, set it #ifdef RV3028_RTC @@ -328,12 +344,12 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd // tv_sec is a long, which is not time_t everywhere: on Windows // time_t is 64-bit while long is 32-bit. Copy before taking &. time_t setSecs = tv->tv_sec; - tm *t = gmtime(&setSecs); + const tm *t = gmtime(&setSecs); rtc.setTime(t->tm_year + 1900, t->tm_mon + 1, t->tm_wday, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec); - LOG_DEBUG("RV3028_RTC setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, - t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); + LOG_DEBUG_GPS("RV3028_RTC setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, + t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); } else { - LOG_WARN("RTC not found (found address 0x%02X)", rtc_found.address); + LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address); } #elif defined(PCF8563_RTC) || defined(PCF85063_RTC) #if defined(PCF8563_RTC) @@ -353,12 +369,12 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd // tv_sec is a long, which is not time_t everywhere: on Windows // time_t is 64-bit while long is 32-bit. Copy before taking &. time_t setSecs = tv->tv_sec; - tm *t = gmtime(&setSecs); + const tm *t = gmtime(&setSecs); rtc.setDateTime(*t); - LOG_DEBUG("%s setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t->tm_year + 1900, t->tm_mon + 1, - t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); + LOG_DEBUG_GPS("%s setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t->tm_year + 1900, + t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); } else { - LOG_WARN("RTC not found (found address 0x%02X)", rtc_found.address); + LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address); } #elif defined(RX8130CE_RTC) if (rtc_found.address == RX8130CE_RTC) { @@ -370,19 +386,23 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd // tv_sec is a long, which is not time_t everywhere: on Windows // time_t is 64-bit while long is 32-bit. Copy before taking &. time_t setSecs = tv->tv_sec; - tm *t = gmtime(&setSecs); + const tm *t = gmtime(&setSecs); if (rtc.setTime(*t)) { - LOG_DEBUG("RX8130CE setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, - t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); + LOG_DEBUG_GPS("RX8130CE setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, + t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); } else { - LOG_WARN("Failed to set time for RX8130CE"); + LOG_WARN("RX8130CE set time failed"); } } #elif HAS_LSE 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 @@ -435,7 +455,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct tm &t) #ifdef BUILD_EPOCH if (tv.tv_sec < BUILD_EPOCH) { if (Throttle::isWithinTimespanMs(lastTimeValidationWarning, TIME_VALIDATION_WARNING_INTERVAL_MS) == false) { - LOG_WARN("Ignore time (%lu) before build epoch (%lu)!", printableEpoch, BUILD_EPOCH); + LOG_WARN("Ignore time (%lu) before build epoch (%lu)", printableEpoch, BUILD_EPOCH); lastTimeValidationWarning = millis(); } return RTCSetResultInvalidTime; @@ -444,8 +464,8 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct tm &t) // Calculate max allowed time safely to avoid overflow in logging uint64_t maxAllowedTime = (uint64_t)BUILD_EPOCH + FORTY_YEARS; uint32_t maxAllowedPrintable = (maxAllowedTime > UINT32_MAX) ? UINT32_MAX : (uint32_t)maxAllowedTime; - LOG_WARN("Ignore time (%lu) too far in the future (build epoch: %lu, max allowed: %lu)!", printableEpoch, - (uint32_t)BUILD_EPOCH, maxAllowedPrintable); + LOG_WARN("Ignore time (%lu) too far in future (build epoch: %lu, max: %lu)", printableEpoch, (uint32_t)BUILD_EPOCH, + maxAllowedPrintable); lastTimeValidationWarning = millis(); } return RTCSetResultInvalidTime; @@ -486,10 +506,12 @@ int32_t getTZOffset() */ uint32_t getTime(bool local) { + // Both terms are 64-bit monotonic, so the elapsed time cannot wrap - see timeStartMs64. + const uint64_t elapsedSecs = (Time::getMillisMonotonic() - timeStartMs64) / 1000; if (local) { - return (((uint32_t)millis() - timeStartMsec) / 1000) + zeroOffsetSecs + getTZOffset(); + return elapsedSecs + zeroOffsetSecs + getTZOffset(); } else { - return (((uint32_t)millis() - timeStartMsec) / 1000) + zeroOffsetSecs; + return elapsedSecs + zeroOffsetSecs; } } @@ -509,7 +531,7 @@ void setBootRelativeTimeForUnitTest(uint32_t secondsSinceBoot) { currentQuality = RTCQualityNone; zeroOffsetSecs = 0; - timeStartMsec = millis() - (secondsSinceBoot * 1000); + timeStartMs64 = Time::getMillisMonotonic() - ((uint64_t)secondsSinceBoot * 1000); lastSetFromPhoneNtpOrGps = 0; lastTimeValidationWarning = 0; } @@ -538,7 +560,7 @@ void setReadFromRTCUseSystemTimeForTests(bool enabled) void resetRTCStateForTests() { currentQuality = RTCQualityNone; - timeStartMsec = 0; + timeStartMs64 = 0; zeroOffsetSecs = 0; lastSetFromPhoneNtpOrGps = 0; lastTimeValidationWarning = 0; diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index a44e8ef4b3..dca31be605 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -99,7 +99,6 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit) // End the update process endUpdate(); - LOG_DEBUG("done"); return true; } diff --git a/src/graphics/EInkDynamicDisplay.cpp b/src/graphics/EInkDynamicDisplay.cpp index a48ba5c939..c51f0a5bba 100644 --- a/src/graphics/EInkDynamicDisplay.cpp +++ b/src/graphics/EInkDynamicDisplay.cpp @@ -157,7 +157,7 @@ bool EInkDynamicDisplay::determineMode() resetRateLimiting(); // Once determineMode() ends, will have to wait again hashImage(); // Generate here, so we can still copy it to previousImageHash, even if we skip the comparison check - LOG_DEBUG("determineMode(): "); // Begin log entry + LOG_TRACE("determineMode(): "); // Begin log entry // Once mode determined, any remaining checks will bypass checkCosmetic(); @@ -232,9 +232,7 @@ void EInkDynamicDisplay::checkForPromotion() // Is it too soon for another frame of this type? void EInkDynamicDisplay::checkRateLimiting() { - // Sanity check: millis() overflow - just let the update run.. - if (previousRunMs > millis()) - return; + // No millis()-overflow guard needed: the Throttle checks below are wrap-correct already. // Skip update: too soon for BACKGROUND if (frameFlags == BACKGROUND) { @@ -254,7 +252,7 @@ void EInkDynamicDisplay::checkRateLimiting() if (Throttle::isWithinTimespanMs(previousRunMs, 1000)) { refresh = SKIPPED; reason = EXCEEDED_RATELIMIT_FAST; - LOG_DEBUG("refresh=SKIPPED, reason=EXCEEDED_RATELIMIT_FAST, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=SKIPPED, reason=EXCEEDED_RATELIMIT_FAST, frameFlags=0x%x", frameFlags); return; } } @@ -271,7 +269,7 @@ void EInkDynamicDisplay::checkCosmetic() if (frameFlags & COSMETIC) { refresh = FULL; reason = FLAGGED_COSMETIC; - LOG_DEBUG("refresh=FULL, reason=FLAGGED_COSMETIC, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FULL, reason=FLAGGED_COSMETIC, frameFlags=0x%x", frameFlags); } } @@ -286,7 +284,7 @@ void EInkDynamicDisplay::checkDemandingFast() if (frameFlags & DEMAND_FAST) { refresh = FAST; reason = FLAGGED_DEMAND_FAST; - LOG_DEBUG("refresh=FAST, reason=FLAGGED_DEMAND_FAST, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FAST, reason=FLAGGED_DEMAND_FAST, frameFlags=0x%x", frameFlags); } } @@ -306,7 +304,7 @@ void EInkDynamicDisplay::checkFrameMatchesPrevious() if (frameFlags == BACKGROUND && fastRefreshCount > 0) { refresh = FULL; reason = REDRAW_WITH_FULL; - LOG_DEBUG("refresh=FULL, reason=REDRAW_WITH_FULL, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FULL, reason=REDRAW_WITH_FULL, frameFlags=0x%x", frameFlags); return; } #endif @@ -314,7 +312,7 @@ void EInkDynamicDisplay::checkFrameMatchesPrevious() // Not redrawn, not COSMETIC, not DEMAND_FAST refresh = SKIPPED; reason = FRAME_MATCHED_PREVIOUS; - LOG_DEBUG("refresh=SKIPPED, reason=FRAME_MATCHED_PREVIOUS, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=SKIPPED, reason=FRAME_MATCHED_PREVIOUS, frameFlags=0x%x", frameFlags); } // Have too many fast-refreshes occurred consecutively, since last full refresh? @@ -328,7 +326,7 @@ void EInkDynamicDisplay::checkConsecutiveFastRefreshes() if (frameFlags & UNLIMITED_FAST) { refresh = FAST; reason = NO_OBJECTIONS; - LOG_DEBUG("refresh=FAST, reason=UNLIMITED_FAST_MODE_ACTIVE, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FAST, reason=UNLIMITED_FAST_MODE_ACTIVE, frameFlags=0x%x", frameFlags); return; } @@ -336,7 +334,7 @@ void EInkDynamicDisplay::checkConsecutiveFastRefreshes() if (fastRefreshCount >= EINK_LIMIT_FASTREFRESH) { refresh = FULL; reason = EXCEEDED_LIMIT_FASTREFRESH; - LOG_DEBUG("refresh=FULL, reason=EXCEEDED_LIMIT_FASTREFRESH, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FULL, reason=EXCEEDED_LIMIT_FASTREFRESH, frameFlags=0x%x", frameFlags); } } @@ -351,13 +349,13 @@ void EInkDynamicDisplay::checkFastRequested() // If we want BACKGROUND to use fast. (FULL only when a limit is hit) refresh = FAST; reason = BACKGROUND_USES_FAST; - LOG_DEBUG("refresh=FAST, reason=BACKGROUND_USES_FAST, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, + LOG_TRACE("refresh=FAST, reason=BACKGROUND_USES_FAST, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, frameFlags); #else // If we do want to use FULL for BACKGROUND updates refresh = FULL; reason = FLAGGED_BACKGROUND; - LOG_DEBUG("refresh=FULL, reason=FLAGGED_BACKGROUND"); + LOG_TRACE("refresh=FULL, reason=FLAGGED_BACKGROUND"); #endif } @@ -365,7 +363,7 @@ void EInkDynamicDisplay::checkFastRequested() if (frameFlags & RESPONSIVE) { refresh = FAST; reason = NO_OBJECTIONS; - LOG_DEBUG("refresh=FAST, reason=NO_OBJECTIONS, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, frameFlags); + LOG_TRACE("refresh=FAST, reason=NO_OBJECTIONS, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, frameFlags); } } @@ -430,7 +428,7 @@ void EInkDynamicDisplay::countGhostPixels() } } - LOG_DEBUG("ghostPixels=%hu, ", ghostPixelCount); + LOG_TRACE("ghostPixels=%hu, ", ghostPixelCount); } // Check if ghost pixel count exceeds the defined limit @@ -446,7 +444,7 @@ void EInkDynamicDisplay::checkExcessiveGhosting() if (ghostPixelCount > EINK_LIMIT_GHOSTING_PX) { refresh = FULL; reason = EXCEEDED_GHOSTINGLIMIT; - LOG_DEBUG("refresh=FULL, reason=EXCEEDED_GHOSTINGLIMIT, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FULL, reason=EXCEEDED_GHOSTINGLIMIT, frameFlags=0x%x", frameFlags); } } diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index f1d8f33fa9..c8271ddf13 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -430,7 +430,7 @@ void Screen::showAlphanumericPicker(const char *message, const char *initialText void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs, std::function textCallback) { - LOG_INFO("showTextInput called with header='%s', durationMs=%d", header ? header : "NULL", durationMs); + LOG_INFO("showTextInput header='%s', durationMs=%d", header ? header : "NULL", durationMs); // Start OnScreenKeyboardModule session (non-touch variant) OnScreenKeyboardModule::instance().start(header, initialText, durationMs, textCallback); @@ -581,7 +581,7 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O #elif defined(USE_SPISSD1306) dispdev = new SSD1306Spi(SSD1306_RESET, SSD1306_RS, SSD1306_NSS, GEOMETRY_64_48); if (!dispdev->init()) { - LOG_DEBUG("Error: SSD1306 not detected!"); + LOG_DEBUG("SSD1306 not detected"); } else { static_cast(dispdev)->setHorizontalOffset(32); LOG_INFO("SSD1306 init success"); @@ -592,14 +592,14 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O // runtime via config.yaml Display: Panel: HUB75. if (portduino_config.displayPanel == hub75) { #if defined(HAS_HUB75_NATIVE) - LOG_DEBUG("Make HUB75Native!"); + LOG_DEBUG("Make HUB75Native"); dispdev = new HUB75Native(address.address, -1, -1, GEOMETRY_RAWMODE, HW_I2C::I2C_ONE); #else - LOG_ERROR("HUB75 panel requested but rpi-rgb-led-matrix not compiled in!"); + LOG_ERROR("HUB75 panel requested but rpi-rgb-led-matrix not compiled in"); #endif } else if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { if (portduino_config.displayPanel != no_screen) { - LOG_DEBUG("Make TFTDisplay!"); + LOG_DEBUG("Make TFTDisplay"); dispdev = new TFTDisplay(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); } else { @@ -610,7 +610,7 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O } } #elif USE_TFTDISPLAY - LOG_DEBUG("Make TFTDisplay!"); + LOG_DEBUG("Make TFTDisplay"); dispdev = new TFTDisplay(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); #elif defined(USE_EINK) && defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(MESHTASTIC_INCLUDE_INKHUD) @@ -1112,7 +1112,7 @@ int32_t Screen::runOnce() // Show boot screen for first logo_timeout seconds, then switch to normal operation. // serialSinceMsec adjusts for additional serial wait time during nRF52 bootup static bool showingBootScreen = true; - if (showingBootScreen && (millis() > (logo_timeout + serialSinceMsec))) { + if (showingBootScreen && Throttle::hasElapsed(serialSinceMsec, logo_timeout)) { LOG_INFO("Done with boot screen"); stopBootScreen(); showingBootScreen = false; @@ -1120,7 +1120,7 @@ int32_t Screen::runOnce() #ifdef USERPREFS_OEM_TEXT static bool showingOEMBootScreen = true; - if (showingOEMBootScreen && (millis() > ((logo_timeout / 2) + serialSinceMsec))) { + if (showingOEMBootScreen && Throttle::hasElapsed(serialSinceMsec, logo_timeout / 2)) { LOG_INFO("Switch to OEM screen..."); // Change frames. static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen}; @@ -1271,7 +1271,7 @@ int32_t Screen::runOnce() EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); #endif - LOG_DEBUG("LastScreenTransition exceeded %ums transition to next frame", (millis() - lastScreenTransition)); + LOG_DEBUG("LastScreenTransition exceeded %ums, next frame", (millis() - lastScreenTransition)); handleOnPress(); } } diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index f67e35fce8..ba2d50320c 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -851,7 +851,7 @@ class LGFX : public lgfx::LGFX_Device #endif else { _panel_instance = new lgfx::Panel_NULL; - LOG_ERROR("Unknown display panel configured!"); + LOG_ERROR("Unknown display panel configured"); } auto buscfg = _bus_instance.config(); @@ -1187,7 +1187,7 @@ static inline uint16_t getThemeDefaultOffColor() TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus) { - LOG_DEBUG("TFTDisplay!"); + LOG_DEBUG("TFTDisplay"); #ifdef TFT_BL GpioPin *p = new GpioHwPin(TFT_BL); @@ -1441,7 +1441,7 @@ void TFTDisplay::sdlLoop() if (portduino_config.displayPanel == x11) { lgfx::Panel_sdl *sdl_panel_ = (lgfx::Panel_sdl *)tft->_panel_instance; if (sdl_panel_->loop() && !shuttingDown) { - LOG_WARN("Window Closed!"); + LOG_WARN("Window Closed"); InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_SHUTDOWN, .kbchar = 0, .touchX = 0, .touchY = 0}; inputBroker->injectInputEvent(&event); } @@ -1625,9 +1625,9 @@ bool TFTDisplay::connect() #ifdef HACKADAY_COMMUNICATOR bool beginStatus = tft->begin(); if (beginStatus) - LOG_DEBUG("TFT Success!"); + LOG_DEBUG("TFT Success"); else - LOG_ERROR("TFT Fail!"); + LOG_ERROR("TFT Fail"); #else tft->init(); #endif @@ -1656,7 +1656,7 @@ bool TFTDisplay::connect() this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth); if (!this->linePixelBuffer) { - LOG_ERROR("Not enough memory to create TFT line buffer\n"); + LOG_ERROR("Not enough memory to create TFT line buffer"); return false; } memaudit::add("display", sizeof(uint16_t) * displayWidth); @@ -1665,7 +1665,7 @@ bool TFTDisplay::connect() this->repaintChunkBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth * kFullRepaintChunkRows); if (!this->repaintChunkBuffer) { - LOG_ERROR("Not enough memory to create TFT repaint chunk buffer\n"); + LOG_ERROR("Not enough memory to create TFT repaint chunk buffer"); return false; } memaudit::add("display", sizeof(uint16_t) * displayWidth * kFullRepaintChunkRows); 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..5cca4c4aa7 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -224,74 +224,82 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, #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], 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; + + 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; } - 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++], + chUtilPercentage); + } #endif graphics::drawCommonFooter(display, x, y); } diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index ff19e81bcc..6471d70c6b 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -20,6 +20,9 @@ #include "input/UpDownInterruptImpl1.h" #include "main.h" #include "mesh/Default.h" +#if HAS_LORA_FEM +#include "mesh/LoRaFEMInterface.h" +#endif #include "mesh/MeshTypes.h" #include "mesh/RadioLibInterface.h" #include "modules/AdminModule.h" @@ -139,12 +142,38 @@ uint8_t test_count = 0; void menuHandler::loraMenu() { - static const char *optionsArray[] = {"Back", "Device Role", "Radio Preset", "Frequency Slot", "LoRa Region"}; - enum optionsNumbers { Back = 0, DeviceRolePicker = 1, RadioPresetPicker = 2, FrequencySlot = 3, LoraPicker = 4 }; + static const char *optionsArray[] = { + "Back", + "Device Role", + "Radio Preset", + "Frequency Slot", + "LoRa Region", + "Transmit Enabled", +#if HAS_LORA_FEM + "FEM LNA", +#endif + }; + // NOTE: "FEM LNA" must stay last; it is the only entry that can be hidden at runtime by + // trimming optionsCount, which only works for a trailing option. + enum optionsNumbers { + Back = 0, + DeviceRolePicker = 1, + RadioPresetPicker = 2, + FrequencySlot = 3, + LoraPicker = 4, + TxEnabled = 5, +#if HAS_LORA_FEM + LoraFemLna = 6 +#endif + }; BannerOverlayOptions bannerOptions; bannerOptions.message = "LoRa Actions"; bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 5; +#if HAS_LORA_FEM + bannerOptions.optionsCount = loraFEMInterface.isLnaCanControl() ? 7 : 6; +#else + bannerOptions.optionsCount = 6; +#endif bannerOptions.bannerCallback = [](int selected) -> void { if (selected == Back) { // No action @@ -156,7 +185,14 @@ void menuHandler::loraMenu() menuHandler::menuQueue = menuHandler::FrequencySlot; } else if (selected == LoraPicker) { menuHandler::menuQueue = menuHandler::LoraPicker; + } else if (selected == TxEnabled) { + menuHandler::menuQueue = menuHandler::TXEnabledMenu; } +#if HAS_LORA_FEM + else if (selected == LoraFemLna) { + menuHandler::menuQueue = menuHandler::LoraFemLnaToggleMenu; + } +#endif }; screen->showOverlayBanner(bannerOptions); } @@ -193,7 +229,7 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool // flip the region right back. The user picked the region, so the preset follows it. const RegionInfo *newRegion = getRegion(region); if (config.lora.use_preset && !newRegion->supportsPreset(config.lora.modem_preset)) { - LOG_INFO("Preset %s not available in %s, using default %s", + LOG_INFO("Preset %s unavailable in %s, use default %s", DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true), newRegion->name, DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true)); config.lora.modem_preset = newRegion->getDefaultPreset(); @@ -319,7 +355,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration) menuQueue = HamModeConfirm; screen->runNow(); } else if (owner.is_licensed) { - LOG_INFO("Licensed user chose a non-ham region; prompting to revert licensed mode"); + LOG_INFO("Licensed user chose non-ham region; prompt to revert licensed mode"); pendingRegion = selectedRegion; menuQueue = LicensedToNormalConfirm; screen->runNow(); @@ -439,10 +475,10 @@ void menuHandler::FrequencySlotPicker() if (denominator > 0.0) { numChannels = static_cast(round(numerator / denominator)); } else { - LOG_WARN("Invalid region configuration: non-positive channel spacing/width"); + LOG_WARN("Invalid region config: non-positive channel spacing/width"); } } else { - LOG_WARN("Region not set, cannot calculate number of channels"); + LOG_WARN("Region not set, can't calc channel count"); return; } @@ -541,6 +577,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"}; @@ -944,7 +1005,7 @@ void menuHandler::deleteMessagesMenu() // This only appears in non-ALL modes if (selected == DeleteThis) { - LOG_INFO("Deleting all messages in this thread"); + LOG_INFO("Deleting all messages in thread"); if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { messageStore.deleteAllMessagesInChannel(ch); @@ -1792,7 +1853,7 @@ void menuHandler::resetNodeDBMenu() disableBluetooth(); rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); } else if (selected == 2) { - LOG_INFO("Initiate node-db reset but keeping favorites"); + LOG_INFO("Initiate node-db reset, keep favorites"); nodeDB->resetNodes(1); disableBluetooth(); rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); @@ -2359,7 +2420,7 @@ void menuHandler::removeFavoriteMenu() void menuHandler::traceRouteMenu() { screen->showNodePicker("Node to Trace", 30000, [](uint32_t nodenum) -> void { - LOG_INFO("Menu: Node picker selected node 0x%08x, traceRouteModule=%p", nodenum, traceRouteModule); + LOG_INFO("Menu: Node picker selected 0x%08x, traceRouteModule=%p", nodenum, traceRouteModule); if (traceRouteModule) { traceRouteModule->startTraceRoute(nodenum); } @@ -2804,6 +2865,49 @@ void menuHandler::messageBubblesMenu() screen->showOverlayBanner(bannerOptions); } +#if HAS_LORA_FEM +void menuHandler::LoRaFEMLNAToggleMenu() +{ + static const LoRaFEMLNAToggleOption femToggleOptions[] = { + {"Back", OptionsAction::Back}, + {"Enabled", OptionsAction::Select, meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED}, + {"Disabled", OptionsAction::Select, meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED}, + }; + constexpr size_t toggleCount = sizeof(femToggleOptions) / sizeof(femToggleOptions[0]); + static std::array toggleLabels{}; + + auto bannerOptions = createStaticBannerOptions( + "FEM LNA", femToggleOptions, toggleLabels, [](const LoRaFEMLNAToggleOption &option, int) -> void { + if (option.action == OptionsAction::Back) { + menuQueue = LoraMenu; + screen->runNow(); + return; + } + + if (!option.hasValue || config.lora.fem_lna_mode == option.value) { + return; + } + + const bool enabled = option.value != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED; + config.lora.fem_lna_mode = option.value; + loraFEMInterface.setLNAEnable(enabled); + service->reloadConfig(SEGMENT_CONFIG); + LOG_INFO("FEM LNA %s", enabled ? "enabled" : "disabled"); + }); + + int initialSelection = 0; + for (size_t i = 0; i < toggleCount; ++i) { + if (femToggleOptions[i].hasValue && config.lora.fem_lna_mode == femToggleOptions[i].value) { + initialSelection = static_cast(i); + break; + } + } + bannerOptions.InitialSelected = initialSelection; + + screen->showOverlayBanner(bannerOptions); +} +#endif + void menuHandler::themeMenu() { // Build menu dynamically from the theme table. @@ -2870,6 +2974,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case RadioPresetPicker: radioPresetPicker(); break; + case TXEnabledMenu: + txEnabledMenu(); + break; case FrequencySlot: FrequencySlotPicker(); break; @@ -3013,6 +3120,11 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case LicensedToNormalConfirm: licensedToNormalConfirmMenu(); break; +#if HAS_LORA_FEM + case LoraFemLnaToggleMenu: + LoRaFEMLNAToggleMenu(); + break; +#endif } menuQueue = MenuNone; } diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index e05742f97f..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, @@ -59,7 +60,10 @@ class menuHandler MessageBubblesMenu, ThemeMenu, HamModeConfirm, - LicensedToNormalConfirm + LicensedToNormalConfirm, +#if HAS_LORA_FEM + LoraFemLnaToggleMenu +#endif }; static screenMenus menuQueue; static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu @@ -70,6 +74,7 @@ class menuHandler static void loraMenu(); static void deviceRolePicker(); static void radioPresetPicker(); + static void txEnabledMenu(); static void FrequencySlotPicker(); static void handleMenuSwitch(OLEDDisplay *display); static void showConfirmationBanner(const char *message, std::function onConfirm); @@ -120,6 +125,9 @@ class menuHandler static void textMessageMenu(); static void hamModeConfirmMenu(); static void licensedToNormalConfirmMenu(); +#if HAS_LORA_FEM + static void LoRaFEMLNAToggleMenu(); +#endif // Lifted out of its banner-callback lambda so it is reachable without a Screen. The lambda only // ever runs via screen->showOverlayBanner(), which is why nothing here was unit-testable. @@ -159,6 +167,9 @@ using NodeNameOption = MenuOption; using PositionMenuOption = MenuOption; using ManageNodeOption = MenuOption; using ClockFaceOption = MenuOption; +#if HAS_LORA_FEM +using LoRaFEMLNAToggleOption = MenuOption; +#endif } // namespace graphics #endif diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index a6ae217e73..1b23729178 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" @@ -14,6 +15,7 @@ #include "graphics/TFTColorRegions.h" #include "graphics/TFTPalette.h" #include "graphics/TimeFormatters.h" +#include "graphics/draw/NotificationRenderer.h" #include "graphics/emotes.h" #include "main.h" #include "meshUtils.h" @@ -570,7 +572,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; @@ -1128,6 +1130,9 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht if (packet.from != 0) { hasUnreadMessage = true; const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive(); + // Don't let the pop-up clobber a menu/picker the user is interacting with; the wake below + // still happens so a message can light the screen back up. + const bool menuShowing = NotificationRenderer::isMenuShowing(); // Determine if message belongs to a muted channel bool isChannelMuted = false; @@ -1222,7 +1227,7 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht screen->setOn(true); } - if (!suppressBanner) { + if (!suppressBanner && !menuShowing) { screen->showSimpleBanner(banner, inThread ? 1000 : 3000); } } diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 33d36535b2..7abfd210da 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -12,6 +12,7 @@ #include "graphics/images.h" #include "input/RotaryEncoderInterruptImpl1.h" #include "input/UpDownInterruptImpl1.h" +#include "mesh/Throttle.h" #if HAS_BUTTON #include "input/ButtonThread.h" #endif @@ -84,7 +85,7 @@ static inline graphics::NotificationRenderer::BannerFont parseFontTagPrefix(cons { // Tags must be at the start of the line: // [S] small, [M] medium, [L] large - if (p && p[0] == '[' && p[2] == ']' && p[1] != '\0') { + if (p && p[0] == '[' && p[1] != '\0' && p[2] == ']') { char t = p[1]; if (t == 'S') { p += 3; @@ -136,6 +137,26 @@ static inline uint8_t effectiveLineHeightForBannerLine(graphics::NotificationRen return (height > 3) ? (height - 3) : height; } +const char *graphics::NotificationRenderer::resolveBannerLine(uint16_t lineIndex, const char *rawLine, BannerFont &lineFont) +{ + lineFont = BANNER_FONT_DEFAULT; + bool tagAware = (current_notification_type == notificationTypeEnum::text_banner || + current_notification_type == notificationTypeEnum::pairing_pin) && + alertBannerOptions == 0; + if (!tagAware) + return rawLine; + if (lineIndex < alertBannerLineCount) { + lineFont = alertBannerLineFonts[lineIndex]; + return alertBannerLines[lineIndex]; + } + // The parsed-line cache doesn't cover this line (the banner text was stored without a + // re-parse, or a draw raced the parse from another task): strip the tag here too, so it + // acts as a font change and never renders as literal text - the BLE pair PIN banner + // prefixes its PIN line with [M]. + lineFont = parseFontTagPrefix(rawLine); + return rawLine; +} + void graphics::NotificationRenderer::parseBannerMessageWithFonts(const char *message) { alertBannerLineCount = 0; @@ -233,7 +254,7 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU // Handle text_input notifications first - they have their own timeout/banner logic if (current_notification_type == notificationTypeEnum::text_input) { // Check for timeout and reset if needed for text input - if (millis() > alertBannerUntil && alertBannerUntil > 0) { + if (alertBannerUntil > 0 && Throttle::deadlinePassed(alertBannerUntil)) { resetBanner(); return; } @@ -241,7 +262,8 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU return; } - if (millis() > alertBannerUntil && alertBannerUntil > 0) { + // 0 means "no deadline set", and reads as long expired - test it first. + if (alertBannerUntil > 0 && Throttle::deadlinePassed(alertBannerUntil)) { resetBanner(); } @@ -845,9 +867,6 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay BannerFont lineFonts[totalLines] = {}; uint8_t lineEffectiveHeights[totalLines] = {0}; const char *renderLines[totalLines] = {0}; - bool useTaggedBannerFonts = (current_notification_type == notificationTypeEnum::text_banner || - current_notification_type == notificationTypeEnum::pairing_pin) && - alertBannerOptions == 0; if (maxWidth != 0) is_picker = true; @@ -860,12 +879,8 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay uint16_t widestLineWithBars = 0; while (lines[lineCount] != nullptr) { - const char *renderText = lines[lineCount]; BannerFont lineFont = BANNER_FONT_DEFAULT; - if (useTaggedBannerFonts && lineCount < alertBannerLineCount) { - renderText = alertBannerLines[lineCount]; - lineFont = alertBannerLineFonts[lineCount]; - } + const char *renderText = resolveBannerLine(lineCount, lines[lineCount], lineFont); renderLines[lineCount] = renderText; lineFonts[lineCount] = lineFont; lineEffectiveHeights[lineCount] = effectiveLineHeightForBannerLine(lineFont); @@ -879,10 +894,10 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay if (current_notification_type == notificationTypeEnum::node_picker) { char measureBuffer[64] = {0}; - strncpy(measureBuffer, lines[lineCount], std::min(lineLengths[lineCount], sizeof(measureBuffer) - 1)); + strncpy(measureBuffer, renderText, std::min(lineLengths[lineCount], sizeof(measureBuffer) - 1)); lineWidths[lineCount] = UIRenderer::measureStringWithEmotes(display, measureBuffer); } else { - lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true); + lineWidths[lineCount] = display->getStringWidth(renderText, lineLengths[lineCount], true); } // Consider extra width for signal bars on lines that contain "Signal:" @@ -1213,7 +1228,16 @@ void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiStat bool NotificationRenderer::isOverlayBannerShowing() { - return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil); + // Here 0 means "show indefinitely", so it must short-circuit the comparison. + return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || !Throttle::deadlinePassed(alertBannerUntil)); +} + +bool NotificationRenderer::isMenuShowing() +{ + // A menu, picker, keyboard, or pairing-PIN overlay - anything interactive, as opposed to a plain + // informational text banner (which has no options and type text_banner). Menus don't set a + // notificationType of their own, so options are the only thing distinguishing them. + return isOverlayBannerShowing() && (alertBannerOptions > 0 || current_notification_type != notificationTypeEnum::text_banner); } } // namespace graphics diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index 360bfac3c2..d4b7781d5b 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -38,6 +38,10 @@ class NotificationRenderer static uint8_t alertBannerLineCount; static BannerFont alertBannerLineFonts[MAX_LINES + 1]; static void parseBannerMessageWithFonts(const char *message); + // Decide what text and font a banner line actually renders with: parsed (tag-stripped) + // line if the cache covers it, otherwise the raw line with any leading font tag stripped + // on the fly. Exposed for unit tests. + static const char *resolveBannerLine(uint16_t lineIndex, const char *rawLine, BannerFont &lineFont); static void resetBanner(); static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); @@ -53,6 +57,7 @@ class NotificationRenderer static void drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); static void drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); static bool isOverlayBannerShowing(); + static bool isMenuShowing(); static graphics::notificationTypeEnum current_notification_type; }; diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index b8ead27066..a81942abab 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -543,7 +543,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); @@ -582,7 +582,7 @@ void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, } } else if (!gps->getHasLock() && !config.position.fixed_position) { if (strcmp(mode, "line1") == 0) { - strcpy(displayLine, "No GPS Lock"); + strcpy(displayLine, gps->getHasTime() ? "GPS Time Only" : "No GPS Lock"); display->drawString(x, y, displayLine); } } else { diff --git a/src/graphics/eink/Drivers/EInk.cpp b/src/graphics/eink/Drivers/EInk.cpp index cd2e9dc98f..ef9b820e0e 100644 --- a/src/graphics/eink/Drivers/EInk.cpp +++ b/src/graphics/eink/Drivers/EInk.cpp @@ -54,7 +54,7 @@ int32_t EInk::runOnce() // - polling timeout // - other error (derived classes) if (failed) { - LOG_WARN("Display update failed. Check wiring & power supply."); + LOG_WARN("Display update failed. Check wiring & power supply"); updateRunning = false; failed = false; return disable(); diff --git a/src/graphics/niche/Drivers/EInk/EInk.cpp b/src/graphics/niche/Drivers/EInk/EInk.cpp index cd2e9dc98f..ef9b820e0e 100644 --- a/src/graphics/niche/Drivers/EInk/EInk.cpp +++ b/src/graphics/niche/Drivers/EInk/EInk.cpp @@ -54,7 +54,7 @@ int32_t EInk::runOnce() // - polling timeout // - other error (derived classes) if (failed) { - LOG_WARN("Display update failed. Check wiring & power supply."); + LOG_WARN("Display update failed. Check wiring & power supply"); updateRunning = false; failed = false; return disable(); diff --git a/src/graphics/niche/InkHUD/PlatformioConfig.ini b/src/graphics/niche/InkHUD/PlatformioConfig.ini index 4c03773b9f..f5bc5c28c4 100644 --- a/src/graphics/niche/InkHUD/PlatformioConfig.ini +++ b/src/graphics/niche/InkHUD/PlatformioConfig.ini @@ -24,4 +24,4 @@ build_flags = -D HAS_BUTTON=0 ; Suppress default ButtonThread lib_deps = # renovate: datasource=github-tags depName=GFX_Root packageName=ZinggJM/GFX_Root - https://github.com/ZinggJM/GFX_Root/archive/3195764e352a0d2567c8d277ac408ca7293a99b0.zip ; Used by InkHUD as a "slimmer" version of AdafruitGFX + https://github.com/ZinggJM/GFX_Root.git#3195764e352a0d2567c8d277ac408ca7293a99b0 ; Used by InkHUD as a "slimmer" version of AdafruitGFX diff --git a/src/graphics/niche/Utils/FlashData.h b/src/graphics/niche/Utils/FlashData.h index 233d0922eb..43fcd7355a 100644 --- a/src/graphics/niche/Utils/FlashData.h +++ b/src/graphics/niche/Utils/FlashData.h @@ -96,7 +96,7 @@ template class FlashData f.close(); } else { - LOG_ERROR("Could not open / read %s", filename.c_str()); + LOG_ERROR("Can't open/read %s", filename.c_str()); okay = false; } #else @@ -135,10 +135,10 @@ template class FlashData bool writeSucceeded = f.close(); if (!writeSucceeded) { - LOG_ERROR("Can't write data!"); + LOG_ERROR("Can't write data"); } #else - LOG_ERROR("ERROR: Filesystem not implemented\n"); + LOG_ERROR("Filesystem not implemented"); #endif } }; @@ -165,7 +165,7 @@ inline void clearFlashData() file = dir.openNextFile(); } #else - LOG_ERROR("ERROR: Filesystem not implemented\n"); + LOG_ERROR("Filesystem not implemented"); #endif } diff --git a/src/graphics/tftSetup.cpp b/src/graphics/tftSetup.cpp index 0526f4a343..cfb23443e4 100644 --- a/src/graphics/tftSetup.cpp +++ b/src/graphics/tftSetup.cpp @@ -407,7 +407,7 @@ void tftSetup(void) PacketAPI::create(PacketServer::init()); deviceScreen->init(new PacketClient); } else { - LOG_INFO("Running without TFT display!"); + LOG_INFO("Running without TFT display"); } #endif diff --git a/src/input/ButtonThread.cpp b/src/input/ButtonThread.cpp index bf08836912..29f56dba5f 100644 --- a/src/input/ButtonThread.cpp +++ b/src/input/ButtonThread.cpp @@ -226,7 +226,7 @@ int32_t ButtonThread::runOnce() } case BUTTON_EVENT_DOUBLE_PRESSED: { // not wired in if screen detected - LOG_INFO("Double press!"); + 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; 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/RotaryEncoderImpl.cpp b/src/input/RotaryEncoderImpl.cpp index dcdbf0d36b..88075c2f10 100644 --- a/src/input/RotaryEncoderImpl.cpp +++ b/src/input/RotaryEncoderImpl.cpp @@ -3,6 +3,7 @@ #include "RotaryEncoderImpl.h" #include "InputBroker.h" #include "RotaryEncoder.h" +#include "mesh/Throttle.h" #ifdef ARCH_ESP32 #include "sleep.h" #endif @@ -66,7 +67,7 @@ void RotaryEncoderImpl::pollOnce() static uint32_t lastPressed = millis(); if (rotary->readButton() == RotaryEncoder::ButtonState::BUTTON_PRESSED) { - if (lastPressed + 200 < millis()) { + if (Throttle::hasElapsed(lastPressed, 200)) { LOG_DEBUG("Rotary event Press"); lastPressed = millis(); e.inputEvent = this->eventPressed; 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/kbI2cBase.cpp b/src/input/kbI2cBase.cpp index ef61f35232..1d2f1a96a8 100644 --- a/src/input/kbI2cBase.cpp +++ b/src/input/kbI2cBase.cpp @@ -21,20 +21,22 @@ 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; @@ -73,7 +75,7 @@ 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 @@ -91,7 +93,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: @@ -265,10 +267,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; @@ -367,9 +369,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: { @@ -620,6 +622,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 6fe652973c..6a34009be0 100644 --- a/src/input/kbI2cBase.h +++ b/src/input/kbI2cBase.h @@ -7,12 +7,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: @@ -26,6 +31,6 @@ class KbI2cBase : public Observable, public concurrency::OST BBQ10Keyboard Q10keyboard; MCP23017Keyboard MCPkeyboard; 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 280686ba83..f9515e3cf4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,6 +16,7 @@ #include "RadioLibInterface.h" #include "ReliableRouter.h" #include "TransmitHistory.h" +#include "UptimeClock.h" #include "airtime.h" #include "buzz.h" #include "power/PowerHAL.h" @@ -316,11 +317,13 @@ __attribute__((weak, noinline)) bool loopCanSleep() // Weak empty variant initialization function. // May be redefined by variant files. -void lateInitVariant() __attribute__((weak)); -void lateInitVariant() {} +// noinline: weak default and call site share this TU, so LTO would inline the empty body and +// never link the variant's strong override. nrf52_lto.py's _VARIANT_OVERRIDES guards this. +__attribute__((noinline)) void lateInitVariant() __attribute__((weak)); +__attribute__((noinline)) void lateInitVariant() {} -void earlyInitVariant() __attribute__((weak)); -void earlyInitVariant() {} +__attribute__((noinline)) void earlyInitVariant() __attribute__((weak)); +__attribute__((noinline)) void earlyInitVariant() {} // NRF52 (and probably other platforms) can report when system is in power failure mode // (eg. too low battery voltage) and operating it is unsafe (data corruption, bootloops, etc). @@ -390,6 +393,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); @@ -544,9 +552,9 @@ void setup() EncryptedStorage::initLocked(); if (!EncryptedStorage::isUnlocked()) { if (!EncryptedStorage::isProvisioned()) { - LOG_WARN("Lockdown: Device not provisioned - connect and set a passphrase to unlock storage"); + LOG_WARN("Lockdown: Device not provisioned - set passphrase to unlock storage"); } else { - LOG_WARN("Lockdown: Device locked - connect and provide passphrase to unlock storage"); + LOG_WARN("Lockdown: Device locked - provide passphrase to unlock storage"); } } #endif @@ -563,7 +571,7 @@ void setup() if (EncryptedStorage::isProvisioned()) { enableAPProtect(); } else { - LOG_INFO("APPROTECT deferred: device not yet provisioned"); + LOG_INFO("APPROTECT deferred: not provisioned"); } #elif defined(MESHTASTIC_ENABLE_APPROTECT) // Lockdown without encrypted storage shouldn't be reachable per @@ -650,7 +658,7 @@ void setup() // the bus behind it is scanned right below and its devices are registered // once, so the link has to be up by then if (!sensecapIndicator->wait_ready(5000)) - LOG_ERROR("RP2040 co-processor did not answer, its sensors, GPS and SD card are unavailable this session"); + LOG_ERROR("RP2040 co-processor no reply; sensors, GPS, SD card unavailable this session"); #endif #if !MESHTASTIC_EXCLUDE_I2C @@ -693,7 +701,7 @@ void setup() #ifdef ARCH_ESP32 // Don't init display if we don't have one or we are waking headless due to a timer event if (wakeCause == ESP_SLEEP_WAKEUP_TIMER) { - LOG_DEBUG("suppress screen wake because this is a headless timer wakeup"); + LOG_DEBUG("suppress screen wake: headless timer wakeup"); i2cScanner->setSuppressScreen(); } #endif @@ -759,7 +767,7 @@ void setup() 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); + LOG_WARN("kb_info.type unknown(0x%02x), set kb_model=0x00", kb_info.type); kb_model = 0x00; } } @@ -1362,12 +1370,15 @@ void loop() { runASAP = false; + // The single writer of the monotonic wrap carry; every other caller only reads it. + Time::serviceMonotonic(); + #if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL) if (lockdownDisablePending) { lockdownDisablePending = false; LOG_INFO("Lockdown: disabling - reverting encrypted storage to plaintext"); if (nodeDB->disableLockdownToPlaintext()) { - LOG_INFO("Lockdown: disabled, rebooting into normal mode"); + LOG_INFO("Lockdown: disabled, reboot to normal mode"); PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0); rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; } else { @@ -1375,14 +1386,14 @@ void loop() // The DEK file is still present (it's deleted last), so the device // stays in lockdown and the operator can retry disable. Surface // the failure rather than leaving the client hanging. - LOG_ERROR("Lockdown: disable revert failed - device remains in lockdown"); + LOG_ERROR("Lockdown: disable revert failed - still in lockdown"); PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "disable_failed", 0, 0, 0); } } if (lockdownReloadPending) { lockdownReloadPending = false; - LOG_INFO("Lockdown: reloading config from disk after unlock"); + LOG_INFO("Lockdown: reload config after unlock"); bool reloadOk = nodeDB->reloadFromDisk(); if (!reloadOk) { // Storage decrypt/decode failed during reload. Treat as @@ -1393,7 +1404,7 @@ void loop() // might have), and notify clients. Storage will be locked // on next boot anyway; deferring to the user-visible // notification path is sufficient for now. - LOG_ERROR("Lockdown: reload failed - locking and notifying clients"); + LOG_ERROR("Lockdown: reload failed - lock and notify clients"); EncryptedStorage::lockNow(); PhoneAPI::revokeAllAuth(); } @@ -1419,14 +1430,14 @@ void loop() // sessions to grant. Hard lock (token deleted, DEK // zeroed) and reboot. Operator must re-enter passphrase. if (EncryptedStorage::getBootsRemaining() == 0) { - LOG_WARN("Lockdown: session limit reached and boot budget exhausted, locking and rebooting"); + LOG_WARN("Lockdown: session limit hit, boot budget exhausted - lock and reboot"); EncryptedStorage::lockNow(); PhoneAPI::revokeAllAuth(); PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "session_budget_exhausted", 0, 0, 0); rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; } else { uint8_t newBoots = EncryptedStorage::consumeSessionBoot(); - LOG_WARN("Lockdown: session expired, rolled to next budget slot (boots=%u remaining)", newBoots); + LOG_WARN("Lockdown: session expired, next budget slot (boots=%u left)", newBoots); PhoneAPI::revokeAllAuth(); meshtastic_security::lockScreen(); // Signal clients that they need to re-auth on this @@ -1488,17 +1499,16 @@ void loop() ch341Hal->checkError(); } if (portduino_status.LoRa_in_error && rebootAtMsec == 0) { - LOG_ERROR("LoRa in error detected, attempting to recover"); + 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/Channels.cpp b/src/mesh/Channels.cpp index 2d8d4f246c..5860c6fc74 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -497,6 +497,21 @@ bool Channels::isWellKnownChannel(ChannelIndex chIndex) return false; } +bool Channels::isEventChannel(ChannelIndex chIndex) +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + static const uint8_t configuredEventPsk[] = USERPREFS_CHANNEL_0_PSK; + static_assert(sizeof(configuredEventPsk) == 16 || sizeof(configuredEventPsk) == 32, + "USERPREFS_CHANNEL_0_PSK must be an AES-128 or AES-256 key"); + CryptoKey effectiveKey = getKey(chIndex); + return effectiveKey.length == sizeof(configuredEventPsk) && + memcmp(effectiveKey.bytes, configuredEventPsk, sizeof(configuredEventPsk)) == 0; +#else + (void)chIndex; + return false; +#endif +} + bool Channels::hasDefaultChannel() { // If we don't use a preset or the default frequency slot, or we override the frequency, we don't have a default channel diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 6e17a7ab61..27833130c0 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -5,6 +5,10 @@ #include "mesh-pb-constants.h" #include +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && !defined(USERPREFS_CHANNEL_0_PSK) +#error "USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL requires USERPREFS_CHANNEL_0_PSK" +#endif + /** A channel number (index into the channel table) */ typedef uint8_t ChannelIndex; @@ -95,6 +99,9 @@ class Channels // matches the current preset's name and PSK byte 1. bool isWellKnownChannel(ChannelIndex chIndex); + // Returns true if this channel's effective key matches USERPREFS_CHANNEL_0_PSK. + bool isEventChannel(ChannelIndex chIndex); + // Returns true if we can be reached via a channel with the default settings given a region and modem preset bool hasDefaultChannel(); @@ -164,4 +171,4 @@ bool channelFileUsesPublicKey(const meshtastic_ChannelFile &cf, ChannelIndex chI static const uint8_t eventpsk[] = {0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36, 0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74, - 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1}; \ No newline at end of file + 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1}; diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 95c640d539..bd199e8fd9 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -336,7 +336,7 @@ bool CryptoEngine::setDHPublicKey(uint8_t *pubKey) // Calculate the shared secret with the specified node's public key and our private key // This includes an internal weak key check, which among other things looks for an all 0 public key and shared key. if (!Curve25519::dh2(shared_key, local_priv)) { - LOG_WARN("Curve25519DH step 2 failed!"); + LOG_WARN("Curve25519DH step 2 failed"); return false; } return true; @@ -373,7 +373,7 @@ concurrency::Lock *cryptLock; void CryptoEngine::setKey(const CryptoKey &k) { - LOG_DEBUG("Use AES%d key!", k.length * 8); + LOG_DEBUG("Use AES%d key", k.length * 8); key = k; } @@ -389,7 +389,7 @@ void CryptoEngine::encryptPacket(uint32_t fromNode, uint64_t packetId, size_t nu if (numBytes <= MAX_BLOCKSIZE) { encryptAESCtr(key, nonce, numBytes, bytes); } else { - LOG_ERROR("Packet too large for crypto engine: %d. noop encryption!", numBytes); + LOG_ERROR("Packet too large for crypto engine: %d. noop encryption", numBytes); } } } @@ -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/IndicatorSerial.cpp b/src/mesh/IndicatorSerial.cpp index 14f60d9212..74608b5fa9 100644 --- a/src/mesh/IndicatorSerial.cpp +++ b/src/mesh/IndicatorSerial.cpp @@ -486,7 +486,7 @@ bool SensecapIndicator::handle_packet(size_t payload_len) LOG_WARN("Request 0x%08x nacked by the co-processor", expected_id); request_nacked = true; } else if (message.id == 0) { - LOG_WARN("Co-processor could not decode a frame"); + LOG_WARN("Co-processor can't decode a frame"); } return true; case meshtastic_InterdeviceMessage_sd_info_tag: diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index b7a6040e37..8be0b64139 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -104,11 +104,11 @@ template bool LR11x0Interface::init() // DIO3 is free to be used as an IRQ only while no TCXO Vref is driven on it if (tcxoVoltage > 0) - LOG_DEBUG("LR11x0 TCXO Vref %f V on DIO3 (DIO3 unavailable as an IRQ)", tcxoVoltage); + LOG_DEBUG("LR11x0 TCXO Vref %f V on DIO3 (DIO3 unavailable as IRQ)", tcxoVoltage); else - LOG_DEBUG("LR11x0 no TCXO Vref, XTAL only (DIO3 free as an IRQ)"); + LOG_DEBUG("LR11x0 no TCXO Vref, XTAL only (DIO3 free as IRQ)"); #if defined(TCXO_OPTIONAL) - LOG_DEBUG("TCXO_OPTIONAL: oscillator type unknown, probing XTAL first and using any TCXO Vref only as fallback"); + LOG_DEBUG("TCXO_OPTIONAL: osc type unknown, probe XTAL first, TCXO Vref as fallback"); #endif RadioLibInterface::init(); @@ -156,7 +156,7 @@ template bool LR11x0Interface::init() #if defined(TCXO_OPTIONAL) // 2. XTAL failed with the chip present, so fall back to the TCXO if the variant configured one if (res != RADIOLIB_ERR_NONE && res != RADIOLIB_ERR_CHIP_NOT_FOUND && tcxoVoltage > 0) { - LOG_WARN("LR11x0 XTAL init failed (err %d), retrying with TCXO Vref %f V", res, tcxoVoltage); + LOG_WARN("LR11x0 XTAL init failed (err %d), retry with TCXO Vref %f V", res, tcxoVoltage); attemptVoltage = tcxoVoltage; res = tryBegin(2, attemptVoltage); if (res == RADIOLIB_ERR_NONE) @@ -167,7 +167,7 @@ template bool LR11x0Interface::init() // 3. Some units need extra settling time, so give whichever oscillator we settled on one retry. // After a step 2 fallback that is a second TCXO attempt, which is where settling actually matters. if (lr11x0SpiFailed(res)) { - LOG_WARN("LR11x0 init failed with %d (SPI command failure), retrying after delay...", res); + LOG_WARN("LR11x0 init failed with %d (SPI cmd failure), retry after delay", res); delay(100); res = tryBegin(3, attemptVoltage); } @@ -179,9 +179,9 @@ template bool LR11x0Interface::init() #ifdef LR11X0_UPDATE_FIRMWARE_TO // An interrupted update leaves the radio sitting in bootloader mode, where begin() fails. Retry the // flash from here rather than giving up, otherwise the device could never recover on its own. - LOG_WARN("LR11x0 did not start; attempting firmware recovery in case an update was interrupted"); + LOG_WARN("LR11x0 did not start; firmware recovery in case update was interrupted"); if (lora.updateFirmware(lr11xx_firmware_image, LR11XX_FIRMWARE_IMAGE_SIZE, true) == RADIOLIB_ERR_NONE) { - LOG_INFO("LR1110 firmware recovery succeeded, re-initializing radio"); + LOG_INFO("LR1110 firmware recovery OK, re-init radio"); res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); } #endif @@ -202,8 +202,8 @@ template bool LR11x0Interface::init() // One-shot transceiver firmware update, opt-in per variant. Only runs when the part is an LR1110 running // older firmware than the baked-in image, so once it has succeeded it is a no-op on subsequent boots. if (transceiverDevice == RADIOLIB_LR11X0_DEVICE_LR1110 && transceiverFw != 0 && transceiverFw < LR11X0_UPDATE_FIRMWARE_TO) { - LOG_WARN("LR1110 transceiver FW %d.%d is older than %d.%d - updating now. DO NOT POWER OFF: this " - "erases and rewrites the radio's own flash.", + LOG_WARN("LR1110 transceiver FW %d.%d older than %d.%d - updating. DO NOT POWER OFF: " + "rewrites radio's own flash", transceiverFw >> 8, transceiverFw & 0xFF, LR11X0_UPDATE_FIRMWARE_TO >> 8, LR11X0_UPDATE_FIRMWARE_TO & 0xFF); int upd = lora.updateFirmware(lr11xx_firmware_image, LR11XX_FIRMWARE_IMAGE_SIZE, true); @@ -214,7 +214,7 @@ template bool LR11x0Interface::init() return false; } - LOG_INFO("LR1110 firmware update complete, re-initializing radio"); + LOG_INFO("LR1110 firmware update complete, re-init radio"); res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); if (res != RADIOLIB_ERR_NONE) { LOG_ERROR("LR11x0 re-init after firmware update failed %s%d", radioLibErr, res); @@ -259,7 +259,7 @@ template bool LR11x0Interface::init() LOG_INFO("Set RX gain to boosted mode; result: %d", res); } else { res = lora.setRxBoostedGainMode(false); - LOG_INFO("Set RX gain to power saving mode (boosted mode off); result: %d", res); + LOG_INFO("Set RX gain to power saving mode; result: %d", res); } } @@ -318,7 +318,7 @@ template bool LR11x0Interface::reconfigure() return true; } -template void LR11x0Interface::disableInterrupt() +template void LR11x0Interface::clearRadioIsr() { lora.clearIrqAction(); } @@ -330,7 +330,7 @@ template void LR11x0Interface::setStandby() int err = lora.standby(); if (err != RADIOLIB_ERR_NONE) { - LOG_DEBUG("LR11x0 standby failed with error %d", err); + LOG_DEBUG("LR11x0 standby failed, err %d", err); } assert(err == RADIOLIB_ERR_NONE); diff --git a/src/mesh/LR11x0Interface.h b/src/mesh/LR11x0Interface.h index 552dd5e5e9..9280c05dee 100644 --- a/src/mesh/LR11x0Interface.h +++ b/src/mesh/LR11x0Interface.h @@ -47,12 +47,12 @@ template class LR11x0Interface : public RadioLibInterface /** * Glue functions called from ISR land */ - virtual void disableInterrupt() override; + virtual void clearRadioIsr() override; /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*callback)()) { lora.setIrqAction(callback); } + virtual void setRadioIsr(void (*callback)()) override { lora.setIrqAction(callback); } /** can we detect a LoRa preamble on the current channel? */ virtual bool isChannelActive() override; diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index 88ab392e79..dcc514041e 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -69,17 +69,17 @@ template bool LR20x0Interface::init() // FIXME: correct logic to default to not using TCXO if no voltage is specified for LR20x0_DIO3_TCXO_VOLTAGE #elif defined(LR2021_DIO3_TCXO_VOLTAGE) float tcxoVoltage = LR2021_DIO3_TCXO_VOLTAGE; - LOG_DEBUG("LR2021_DIO3_TCXO_VOLTAGE defined, using DIO3 as TCXO reference voltage at %f V", LR2021_DIO3_TCXO_VOLTAGE); + LOG_DEBUG("LR2021_DIO3_TCXO_VOLTAGE defined, DIO3 as TCXO Vref %f V", LR2021_DIO3_TCXO_VOLTAGE); // (DIO3 is not free to be used as an IRQ) #elif defined(TCXO_OPTIONAL) float tcxoVoltage = 1.6f; // TCXO_OPTIONAL: try default 1.6 V first, fall back to XTAL on failure - LOG_DEBUG("TCXO_OPTIONAL: no LR2021_DIO3_TCXO_VOLTAGE defined, trying default TCXO Vref 1.6 V first"); + LOG_DEBUG("TCXO_OPTIONAL: no LR2021_DIO3_TCXO_VOLTAGE, try default TCXO Vref 1.6 V first"); #else float tcxoVoltage = 0; // "TCXO reference voltage to be set on DIO3. Defaults to 1.6 V, set to 0 to skip." per // https://github.com/jgromes/RadioLib/blob/690a050ebb46e6097c5d00c371e961c1caa3b52e/src/modules/LR11x0/LR11x0.h#L471C26-L471C104 // (DIO3 is free to be used as an IRQ) - LOG_DEBUG("LR2021_DIO3_TCXO_VOLTAGE not defined, not using DIO3 as TCXO reference voltage"); + LOG_DEBUG("LR2021_DIO3_TCXO_VOLTAGE not defined, DIO3 not used as TCXO Vref"); #endif RadioLibInterface::init(); @@ -119,7 +119,7 @@ template bool LR20x0Interface::init() // Retry if we get SPI command failed - some units need extra TCXO stabilization time if (res == RADIOLIB_ERR_SPI_CMD_FAILED) { - LOG_WARN("LR20x0 init failed with %d (SPI_CMD_FAILED), retrying after delay...", res); + LOG_WARN("LR20x0 init failed with %d (SPI_CMD_FAILED), retry after delay", res); delay(100); res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); } @@ -127,7 +127,7 @@ template bool LR20x0Interface::init() #if defined(TCXO_OPTIONAL) // If init failed for any reason other than chip not found, retry without TCXO (XTAL mode) if (res != RADIOLIB_ERR_NONE && res != RADIOLIB_ERR_CHIP_NOT_FOUND && tcxoVoltage > 0) { - LOG_WARN("LR20x0 init failed with TCXO Vref %f V (err %d), retrying without TCXO", tcxoVoltage, res); + LOG_WARN("LR20x0 init failed with TCXO Vref %f V (err %d), retry without TCXO", tcxoVoltage, res); tcxoVoltage = 0; res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); if (res == RADIOLIB_ERR_NONE) @@ -166,7 +166,7 @@ template bool LR20x0Interface::init() LOG_INFO("Set RX gain to boosted mode; result: %d", res); } else { res = lora.setRxBoostedGainMode(false); - LOG_INFO("Set RX gain to power saving mode (boosted mode off); result: %d", res); + LOG_INFO("Set RX gain to power saving mode; result: %d", res); } } @@ -220,7 +220,7 @@ template bool LR20x0Interface::reconfigure() int res = lora.begin(freq, bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); if (res == RADIOLIB_ERR_SPI_CMD_FAILED) { - LOG_WARN("LR20x0 band-hop begin SPI_CMD_FAILED, retrying..."); + LOG_WARN("LR20x0 band-hop begin SPI_CMD_FAILED, retrying"); delay(100); res = lora.begin(freq, bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); } @@ -323,7 +323,7 @@ template bool LR20x0Interface::reconfigure() return success; } -template void LR20x0Interface::disableInterrupt() +template void LR20x0Interface::clearRadioIsr() { lora.clearIrqAction(); } @@ -335,7 +335,7 @@ template void LR20x0Interface::setStandby() int err = lora.standby(); if (err != RADIOLIB_ERR_NONE) { - LOG_DEBUG("LR20x0 standby failed with error %d", err); + LOG_DEBUG("LR20x0 standby failed, err %d", err); } assert(err == RADIOLIB_ERR_NONE); diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h index 263c83429d..ed04dfb0e1 100644 --- a/src/mesh/LR20x0Interface.h +++ b/src/mesh/LR20x0Interface.h @@ -42,12 +42,12 @@ template class LR20x0Interface : public RadioLibInterface /** * Glue functions called from ISR land */ - virtual void disableInterrupt() override; + virtual void clearRadioIsr() override; /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*callback)()) { lora.setIrqAction(callback); } + virtual void setRadioIsr(void (*callback)()) override { lora.setIrqAction(callback); } /** can we detect a LoRa preamble on the current channel? */ virtual bool isChannelActive() override; diff --git a/src/mesh/MemoryPool.h b/src/mesh/MemoryPool.h index ed20fb334c..e1b84a4b43 100644 --- a/src/mesh/MemoryPool.h +++ b/src/mesh/MemoryPool.h @@ -115,7 +115,7 @@ template class MemoryDynamic : public Allocator { T *p = (T *)malloc(sizeof(T)); if (!p) { - LOG_WARN("malloc(%u) failed, heap exhausted!", (unsigned)sizeof(T)); + LOG_WARN("malloc(%u) failed, heap exhausted", (unsigned)sizeof(T)); return nullptr; } this->auditAdd((int32_t)sizeof(T)); @@ -156,7 +156,7 @@ template class MemoryPool : public Allocator this->auditAdd(-(int32_t)sizeof(T)); LOG_HEAP("Released static pool item %d at 0x%x", index, p); } else { - LOG_WARN("Pointer 0x%x not from our pool!", p); + LOG_WARN("Pointer 0x%x not from our pool", p); } } @@ -175,7 +175,7 @@ template class MemoryPool : public Allocator } // No free slots available - return nullptr instead of asserting - LOG_WARN("No free slots available in static memory pool!"); + LOG_WARN("No free slots available in static memory pool"); return nullptr; } }; diff --git a/src/mesh/MeshModule.cpp b/src/mesh/MeshModule.cpp index d8bff724e6..71da37145b 100644 --- a/src/mesh/MeshModule.cpp +++ b/src/mesh/MeshModule.cpp @@ -170,7 +170,7 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src) pi.sendResponse(mp); LOG_INFO("Asked module '%s' to send a response", pi.name); } else { - LOG_DEBUG("Module '%s' cannot respond on portnum=%d", pi.name, mp.decoded.portnum); + LOG_DEBUG("Module '%s' can't respond on portnum=%d", pi.name, mp.decoded.portnum); } ignoreRequest = ignoreRequest || pi.ignoreRequest; // If at least one module asks it, we may ignore a request } else { 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 2f3dbb1a5f..0667e0b13e 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -13,6 +13,7 @@ #include "PowerFSM.h" #include "TypeConversions.h" #include "UptimeClock.h" +#include "gps/GPSLog.h" #include "gps/RTC.h" #include "graphics/draw/MessageRenderer.h" #include "main.h" @@ -95,7 +96,7 @@ int MeshService::handleFromRadio(const meshtastic_MeshPacket *mp) meshtastic_Config_DeviceConfig_Role_CLIENT_BASE); if (mp->which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp->decoded.portnum == meshtastic_PortNum_TELEMETRY_APP && mp->decoded.request_id > 0) { - LOG_DEBUG("Received telemetry response. Skip sending our NodeInfo"); + LOG_DEBUG("Got telemetry response. Skip our NodeInfo"); // ignore our request for its NodeInfo } else if (mp->which_payload_variant == meshtastic_MeshPacket_decoded_tag && !nodeInfoLiteHasUser(nodeDB->getMeshNode(mp->from)) && nodeInfoModule && !isPreferredRebroadcaster && @@ -103,16 +104,24 @@ int MeshService::handleFromRadio(const meshtastic_MeshPacket *mp) if (airTime->isTxAllowedChannelUtil(true)) { const int8_t hopsUsed = getHopsAway(*mp, config.lora.hop_limit); if (hopsUsed > (int32_t)(config.lora.hop_limit + 2)) { - LOG_DEBUG("Skip send NodeInfo: %d hops away is too far away", hopsUsed); + LOG_DEBUG("Skip send NodeInfo: %d hops too far", hopsUsed); } else { - LOG_INFO("Heard new node on ch. %d, send NodeInfo and ask for response", mp->channel); + LOG_INFO("Heard new node on ch. %d, send NodeInfo, ask response", mp->channel); nodeInfoModule->sendOurNodeInfo(mp->from, true, mp->channel); } } else { - LOG_DEBUG("Skip sending NodeInfo > 25%% ch. util"); + LOG_DEBUG("Skip NodeInfo > 25%% ch. util"); } } + // 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); @@ -181,14 +190,14 @@ NodeNum MeshService::getNodenumFromRequestId(uint32_t request_id) return nodenum; } -// Back-calculate the real epoch for any queued packet still carrying a millis() rx_time +// Back-calculate the real epoch for any queued packet still carrying an uptime-seconds rx_time // placeholder, now that the clock is trustworthy. void MeshService::reconcilePendingRxTimes() { const uint32_t nowEpoch = getValidTime(RTCQualityFromNet); if (nowEpoch == 0) // called before the clock was actually valid - nothing to reconcile against return; - const uint32_t nowMillis = Time::getMillis(); + const uint32_t nowUptimeSecs = Time::getUptimeSecs(); // Rotate the queue once. TypedQueue is strictly FIFO on both backends, so dequeueing and // re-enqueueing every element in turn leaves the delivery order unchanged. @@ -197,14 +206,16 @@ void MeshService::reconcilePendingRxTimes() if (!p) // drained from under us - nothing left to rotate break; if (!p->has_rx_time) { - // Unsigned subtraction is wraparound-safe; rx_time is a 32-bit wire field, so the - // placeholder was never wider than 32 bits to begin with. - const uint32_t elapsedMs = nowMillis - p->rx_time; - p->rx_time = nowEpoch - (elapsedMs / 1000); - p->has_rx_time = true; + // Both stamps are monotonic uptime seconds, so the elapsed term is exact at any age. + // If it somehow exceeds the epoch, leave the packet un-dated rather than pre-1970. + const uint32_t elapsedSecs = nowUptimeSecs - p->rx_time; + if (elapsedSecs < nowEpoch) { + p->rx_time = nowEpoch - elapsedSecs; + p->has_rx_time = true; + } } if (!toPhoneQueue.enqueue(p, 0)) { // mirrors sendToPhone()'s degrade-on-failure path - LOG_CRIT("Failed to requeue a packet into toPhoneQueue!"); + LOG_CRIT("Requeue to toPhoneQueue failed"); releaseToPool(p); fromNum++; // notify observers so the phone can resync } @@ -232,14 +243,14 @@ void MeshService::injectAsReceived(meshtastic_MeshPacket &p) p.decoded.portnum = scratch.portnum; } } else { - LOG_ERROR("inject: could not decode Compressed envelope, dropping"); + LOG_ERROR("inject: can't decode Compressed envelope, drop"); return; } } // The real RX path (RadioLibInterface::handleReceiveInterrupt) drops sender==0; mirror it so injection // behaves identically to an over-the-air frame. if (p.from == 0) { - LOG_WARN("inject: dropping frame with from==0 (matches real LoRa RX)"); + LOG_WARN("inject: drop frame with from==0 (matches real LoRa RX)"); return; } meshtastic_MeshPacket *mp = packetPool.allocCopy(p); @@ -341,7 +352,7 @@ ErrorCode MeshService::sendQueueStatusToPhone(const meshtastic_QueueStatus &qs, copied->mesh_packet_id = mesh_packet_id; if (toPhoneQueueStatusQueue.numFree() == 0) { - LOG_INFO("tophone queue status queue is full, discard oldest"); + LOG_INFO("tophone queue status queue full, discard oldest"); meshtastic_QueueStatus *d = toPhoneQueueStatusQueue.dequeuePtr(0); if (d) releaseQueueStatusToPool(d); @@ -350,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; @@ -421,9 +434,8 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies) if (!found) { // No channel with position enabled: fall back to sending nodeinfo, as before. if (nodeInfoModule) { - LOG_INFO( - "No channel with position enabled; sending nodeinfo instead to 0x%08x, wantReplies=%d, channel=%d", - dest, wantReplies, node->channel); + 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; @@ -470,7 +482,7 @@ void MeshService::sendToPhone(meshtastic_MeshPacket *p) // Withhold decoded nested payloads a strict phone decoder would reject; still-encrypted packets // pass through (the phone may hold the key). if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && !phonePayloadIsDecodable(p->decoded)) { - LOG_WARN("Dropping undecodable portnum=%d payload from phone delivery (from=0x%08x)", p->decoded.portnum, p->from); + LOG_WARN("Drop undecodable portnum=%d payload from phone delivery (from=0x%08x)", p->decoded.portnum, p->from); releaseToPool(p); fromNum++; // notify observers so the phone can resync return; @@ -488,14 +500,17 @@ 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) { - LOG_WARN("ToPhone queue is full, discard oldest"); + // 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) releaseToPool(d); } else { - LOG_WARN("ToPhone queue is full, drop packet"); + LOG_WARN("ToPhone queue full, drop packet"); releaseToPool(p); fromNum++; // Make sure to notify observers in case they are reconnected so they can get the packets return; @@ -503,7 +518,7 @@ void MeshService::sendToPhone(meshtastic_MeshPacket *p) } if (toPhoneQueue.enqueue(p, 0) == false) { - LOG_CRIT("Failed to queue a packet into toPhoneQueue!"); + LOG_CRIT("Queue to toPhoneQueue failed"); releaseToPool(p); fromNum++; // notify observers so phone can resync return; @@ -513,16 +528,16 @@ void MeshService::sendToPhone(meshtastic_MeshPacket *p) void MeshService::sendMqttMessageToClientProxy(meshtastic_MqttClientProxyMessage *m) { - LOG_DEBUG("Send mqtt message on topic '%s' to client for proxy", m->topic); + LOG_DEBUG("Send mqtt msg on topic '%s' to proxy client", m->topic); if (toPhoneMqttProxyQueue.numFree() == 0) { - LOG_WARN("MqttClientProxyMessagePool queue is full, discard oldest"); + LOG_WARN("MqttClientProxyMessagePool queue full, discard oldest"); meshtastic_MqttClientProxyMessage *d = toPhoneMqttProxyQueue.dequeuePtr(0); if (d) releaseMqttClientProxyMessageToPool(d); } if (toPhoneMqttProxyQueue.enqueue(m, 0) == false) { - LOG_CRIT("Failed to queue a packet into toPhoneMqttProxyQueue!"); + LOG_CRIT("Queue to toPhoneMqttProxyQueue failed"); releaseMqttClientProxyMessageToPool(m); return; } @@ -532,7 +547,7 @@ void MeshService::sendMqttMessageToClientProxy(meshtastic_MqttClientProxyMessage void MeshService::sendRoutingErrorResponse(meshtastic_Routing_Error error, const meshtastic_MeshPacket *mp) { if (!mp) { - LOG_WARN("Cannot send routing error response: null packet"); + LOG_WARN("Can't send routing error response: null packet"); return; } @@ -540,7 +555,7 @@ void MeshService::sendRoutingErrorResponse(meshtastic_Routing_Error error, const if (routingModule) { routingModule->sendAckNak(error, mp->from, mp->id, mp->channel); } else { - LOG_ERROR("Cannot send routing error response: no routing module"); + LOG_ERROR("Can't send routing error response: no routing module"); } } @@ -548,14 +563,14 @@ void MeshService::sendClientNotification(meshtastic_ClientNotification *n) { LOG_DEBUG("Send client notification to phone"); if (toPhoneClientNotificationQueue.numFree() == 0) { - LOG_WARN("ClientNotification queue is full, discard oldest"); + LOG_WARN("ClientNotification queue full, discard oldest"); meshtastic_ClientNotification *d = toPhoneClientNotificationQueue.dequeuePtr(0); if (d) releaseClientNotificationToPool(d); } if (toPhoneClientNotificationQueue.enqueue(n, 0) == false) { - LOG_CRIT("Failed to queue a notification into toPhoneClientNotificationQueue!"); + LOG_CRIT("Queue to toPhoneClientNotificationQueue failed"); releaseClientNotificationToPool(n); return; } @@ -597,9 +612,7 @@ int MeshService::onGPSChanged(const meshtastic::GPSStatus *newStatus) pos = gps->p; } else { // The GPS has lost lock -#ifdef GPS_DEBUG - LOG_DEBUG("onGPSchanged() - lost validLocation"); -#endif + LOG_DEBUG_GPS("onGPSchanged() - lost validLocation"); } // Used fixed position if configured regardless of GPS lock if (config.position.fixed_position) { @@ -629,7 +642,7 @@ bool MeshService::isToPhoneQueueEmpty() uint32_t MeshService::GetTimeSinceMeshPacket(const meshtastic_MeshPacket *mp) { - // rx_time may be a millis() placeholder while has_rx_time is false - don't age it as + // rx_time may be an uptime-seconds placeholder while has_rx_time is false - don't age it as // wall-clock, and don't pass it off as "just now" either. if (!mp->has_rx_time) return SINCE_UNKNOWN; diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index bae955969e..7adcdb7c6d 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -137,8 +137,8 @@ class MeshService // search the queue for a request id and return the matching nodenum NodeNum getNodenumFromRequestId(uint32_t request_id); - // Rewrite any queued-for-phone packet still carrying a millis() rx_time placeholder into a - // real epoch, now that the wall clock is trustworthy. + // Rewrite any queued-for-phone packet still carrying an uptime-seconds rx_time placeholder + // into a real epoch, now that the wall clock is trustworthy. void reconcilePendingRxTimes(); // Release QueueStatus packet to pool @@ -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 0a64a1f1a9..5b4511120f 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -1,6 +1,8 @@ #include "NextHopRouter.h" #include "Default.h" #include "MeshTypes.h" +#include "Throttle.h" +#include "UptimeClock.h" #include "meshUtils.h" #if !MESHTASTIC_EXCLUDE_TRACEROUTE #include "modules/TraceRouteModule.h" @@ -55,23 +57,30 @@ PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmission { 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 wasSeenRecently(p); // FIXME, move this to a sniffSent method p->next_hop = getNextHop(p->to, p->relay_node).value_or(NO_NEXT_HOP_PREFERENCE); // set the next hop - LOG_DEBUG("Setting next hop for packet with dest %x to %x", p->to, p->next_hop); + LOG_TRACE("Set next hop for dest 0x%08x to 0x%x", p->to, p->next_hop); // 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 } @@ -113,7 +122,8 @@ bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) // If repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again if (isRepeated) { if (!findInTxQueue(p->from, p->id)) { - if (reprocessPacket(p) && !perhapsRebroadcast(p) && isToUs(p) && p->want_ack) { + if (reprocessPacket(p) && !isBlockedEventCoordinatePacket(p) && !perhapsRebroadcast(p) && isToUs(p) && + p->want_ack) { sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0); } } @@ -157,7 +167,7 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast // -> store nothing and keep flooding (safe). if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false)) { if (origTx && origTx->next_hop != p->relay_node) { // Not already set - LOG_INFO("Update next hop of 0x%08x to 0x%x based on ACK/reply (was relayer %d we were sole %d)", p->from, + LOG_INFO("Update next hop of 0x%08x to 0x%x from ACK/reply (was relayer %d we were sole %d)", p->from, p->relay_node, wasAlreadyRelayer, weWereSoleRelayer); origTx->next_hop = p->relay_node; } @@ -190,6 +200,14 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast /* Check if we should be rebroadcasting this packet if so, do so. */ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p) { +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + // Never relay coordinate-bearing packets on the event ("everyone") channel. + // Closes the reliable-retransmit-dupe path that runs before handleReceived(). + if (isBlockedEventCoordinatePacket(p)) { + return false; + } +#endif + // Check if traffic management wants to exhaust this packet's hops bool exhaustHops = false; #if HAS_TRAFFIC_MANAGEMENT @@ -214,17 +232,17 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p) meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it if (!tosend) return true; - LOG_INFO("Rebroadcast received message coming from %x", p->relay_node); + LOG_INFO("Rebroadcast msg from %x", p->relay_node); // If exhausting hops, force hop_limit = 0 regardless of other logic if (exhaustHops) { tosend->hop_limit = 0; - LOG_INFO("Traffic management: exhausting hops for 0x%08x, setting hop_limit=0", getFrom(p)); + LOG_INFO("Traffic management: exhaust hops for 0x%08x, hop_limit=0", getFrom(p)); } else if (shouldDecrementHopLimit(p)) { // Use shared logic to determine if hop_limit should be decremented tosend->hop_limit--; // bump down the hop count } else { - LOG_INFO("favorite-ROUTER/CLIENT_BASE-to-ROUTER/CLIENT_BASE rebroadcast: preserving hop_limit"); + LOG_INFO("favorite-ROUTER/CLIENT_BASE-to-ROUTER/CLIENT_BASE rebroadcast: keep hop_limit"); } #if USERPREFS_EVENT_MODE capEventRelayHops(tosend); @@ -266,7 +284,7 @@ std::optional NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node) // TraceRouteModule) with no matching record is left authoritative. const RouteHealth *h = findRouteHealth(to); if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, millis())) { - LOG_INFO("Next hop 0x%x for 0x%08x is stale (age/fails); flood and clear", node->next_hop, to); + LOG_INFO("Next hop 0x%x for 0x%08x stale (age/fails); flood and clear", node->next_hop, to); node->next_hop = NO_NEXT_HOP_PREFERENCE; // clear persisted route clearRouteHealth(to); // clear RAM health return std::nullopt; @@ -298,14 +316,14 @@ std::optional NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node) if (hint && hint != relay_node) { const RouteHealth *h = findRouteHealth(to); if (h && h->lastNextHop == hint && isRouteStale(*h, millis())) { - LOG_INFO("TMM next hop 0x%x for 0x%08x is stale (age/fails); flood and clear", hint, to); + LOG_INFO("TMM next hop 0x%x for 0x%08x stale (age/fails); flood and clear", hint, to); trafficManagementModule->clearNextHop(to); // clear overflow route (setNextHop won't store 0) clearRouteHealth(to); // clear RAM health return std::nullopt; } ResolvedNode r = nodeDB->resolveLastByte(hint, /*requireDirectNeighbor=*/true); if (r.status == LastByteResolution::Unique) { - LOG_DEBUG("Next hop for 0x%08x is 0x%x (TMM cache)", to, hint); + LOG_TRACE("Next hop for 0x%08x is 0x%x (TMM cache)", to, hint); return hint; } LOG_WARN("TMM next hop 0x%x for 0x%08x %s; set no pref", hint, to, @@ -351,7 +369,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 @@ -394,7 +412,9 @@ PendingPacket *NextHopRouter::startRetransmission(meshtastic_MeshPacket *p, uint */ int32_t NextHopRouter::doRetransmissions() { - uint32_t now = millis(); + // Same clock Throttle reads, so setNextTx() deadlines and this test can't diverge under an + // injected test clock. + uint32_t now = Time::getMillis(); int32_t d = INT32_MAX; // FIXME, we should use a better datastructure rather than walking through this map. @@ -405,19 +425,20 @@ int32_t NextHopRouter::doRetransmissions() bool stillValid = true; // assume we'll keep this record around - // FIXME, handle 51 day rolloever here!!! - if (p.nextTxMsec <= now) { + // Judged against the snapshot above, so one pass sees one instant and the 49.7 day wrap + // can't stall retransmission. + if (Throttle::deadlinePassedAt(now, p.nextTxMsec)) { if (p.numRetransmissions == 0) { if (isFromUs(p.packet)) { - LOG_DEBUG("Reliable send failed, returning a nak for fr=0x%08x,to=0x%08x,id=0x%08x", p.packet->from, - p.packet->to, p.packet->id); + LOG_DEBUG("Reliable send failed, return nak fr=0x%08x,to=0x%08x,id=0x%08x", p.packet->from, p.packet->to, + p.packet->id); sendAckNak(meshtastic_Routing_Error_MAX_RETRANSMIT, getFrom(p.packet), p.packet->id, p.packet->channel); } // Note: we don't stop retransmission here, instead the Nak packet gets processed in sniffReceived stopRetransmission(it->first); stillValid = false; // just deleted it } else { - LOG_DEBUG("Sending retransmission fr=0x%08x,to=0x%08x,id=0x%08x, tries left=%d", p.packet->from, p.packet->to, + LOG_DEBUG("Send retransmission fr=0x%08x,to=0x%08x,id=0x%08x, tries left=%d", p.packet->from, p.packet->to, p.packet->id, p.numRetransmissions); if (!isBroadcast(p.packet->to)) { @@ -430,7 +451,7 @@ int32_t NextHopRouter::doRetransmissions() // Also reset it in the nodeDB meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to); if (sentTo) { - LOG_INFO("Resetting next hop for packet with dest 0x%08x", p.packet->to); + LOG_INFO("Reset next hop for dest 0x%08x", p.packet->to); sentTo->next_hop = NO_NEXT_HOP_PREFERENCE; } #if HAS_TRAFFIC_MANAGEMENT @@ -461,13 +482,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 @@ -502,8 +523,8 @@ void NextHopRouter::setNextTx(PendingPacket *pending) { assert(iface); auto d = iface->getRetransmissionMsec(pending->packet); - pending->nextTxMsec = millis() + d; - LOG_DEBUG("Setting next retransmission in %u msecs: ", d); + pending->nextTxMsec = Time::getMillis() + d; + LOG_TRACE("Next retransmission in %u msecs", d); printPacket("", pending->packet); setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time } diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index 3a19191fe3..26cda830ae 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,10 +112,12 @@ 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 @@ -155,6 +160,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 077c926ddc..715daff1b9 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -19,6 +19,7 @@ #include "SafeFile.h" #include "TransmitHistory.h" #include "TypeConversions.h" +#include "UptimeClock.h" #include "error.h" #include "gps/RTC.h" #include "main.h" @@ -98,11 +99,13 @@ static unsigned char userprefs_admin_key_2[] = USERPREFS_USE_ADMIN_KEY_2; // Weak empty variant initialization function. // May be redefined by variant files. -void variantDefaultConfig() __attribute__((weak)); -void variantDefaultConfig() {} +// noinline: weak default and call site share this TU, so LTO would inline the empty body and +// never link the variant's strong override. Same guard as earlyInitVariant() in main.cpp. +__attribute__((noinline)) void variantDefaultConfig() __attribute__((weak)); +__attribute__((noinline)) void variantDefaultConfig() {} -void variantDefaultModuleConfig() __attribute__((weak)); -void variantDefaultModuleConfig() {} +__attribute__((noinline)) void variantDefaultModuleConfig() __attribute__((weak)); +__attribute__((noinline)) void variantDefaultModuleConfig() {} #ifdef HELTEC_MESH_NODE_T114 @@ -427,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 @@ -486,12 +497,12 @@ NodeDB::NodeDB() preferences.begin("meshtastic", false); myNodeInfo.reboot_count = preferences.getUInt("rebootCounter", 0); preferences.end(); - LOG_DEBUG("Number of Device Reboots: %d", myNodeInfo.reboot_count); + LOG_DEBUG("Device reboots: %d", myNodeInfo.reboot_count); #endif // UA_868 is obsolete; migrate to EU_868 before resetRadioConfig() below validates the region. if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UA_868) { - LOG_INFO("UA_868 region is obsolete, migrating saved config to EU_868"); + LOG_INFO("UA_868 obsolete, migrating config to EU_868"); config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; } @@ -504,7 +515,7 @@ NodeDB::NodeDB() // If we are setup to broadcast on any default channel slot (with default frequency slot semantics), // ensure that the telemetry intervals are coerced to the role-aware minimum value. if (channels.hasDefaultChannel()) { - LOG_DEBUG("Coerce telemetry to role-aware minimum on defaults"); + LOG_DEBUG("Coerce telemetry to role-aware min on defaults"); moduleConfig.telemetry.device_update_interval = Default::getConfiguredOrMinimumValue( moduleConfig.telemetry.device_update_interval, min_default_telemetry_interval_secs); moduleConfig.telemetry.environment_update_interval = Default::getConfiguredOrMinimumValue( @@ -527,7 +538,7 @@ NodeDB::NodeDB() } } if (positionUsesDefaultChannel) { - LOG_DEBUG("Coerce position broadcasts to role-aware minimum and smart broadcast min of 5 minutes on defaults"); + LOG_DEBUG("Coerce position broadcasts to role-aware min and smart broadcast min of 5 min on defaults"); config.position.position_broadcast_secs = Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, min_default_broadcast_interval_secs); config.position.broadcast_smart_minimum_interval_secs = Default::getConfiguredOrMinimumValue( @@ -612,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; @@ -738,7 +746,7 @@ void NodeDB::resetRadioConfig(bool is_fresh_install) } if (channelFile.channels_count != MAX_NUM_CHANNELS) { - LOG_INFO("Set default channel and radio preferences!"); + LOG_INFO("Set default channel and radio prefs"); channels.initDefaults(); // Defaults ship the public PSK, so strip it again before onConfigChanged() publishes hashes; @@ -755,14 +763,14 @@ void NodeDB::resetRadioConfig(bool is_fresh_install) bool NodeDB::factoryReset(bool eraseBleBonds) { - LOG_INFO("Perform factory reset!"); + LOG_INFO("Factory reset"); // first, remove the "/prefs" (this removes most prefs) spiLock->lock(); rmDir("/prefs"); // this uses spilock internally... #ifdef FSCom if (FSCom.exists("/static/rangetest.csv") && !FSCom.remove("/static/rangetest.csv")) { - LOG_ERROR("Could not remove rangetest.csv file"); + LOG_ERROR("Can't remove rangetest.csv"); } #endif @@ -806,7 +814,7 @@ bool NodeDB::factoryReset(bool eraseBleBonds) #endif #ifdef ARCH_NRF52 - LOG_INFO("Clear bluetooth bonds!"); + LOG_INFO("Clear bluetooth bonds"); bond_print_list(BLE_GAP_ROLE_PERIPH); bond_print_list(BLE_GAP_ROLE_CENTRAL); Bluefruit.Periph.clearBonds(); @@ -884,7 +892,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) // Restrict ROUTER*, LOST AND FOUND roles for security reasons if (IS_ONE_OF(USERPREFS_CONFIG_DEVICE_ROLE, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND)) { - LOG_WARN("ROUTER roles are restricted, falling back to CLIENT role"); + LOG_WARN("ROUTER roles restricted, fall back to CLIENT"); config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; } else { config.device.role = USERPREFS_CONFIG_DEVICE_ROLE; @@ -983,7 +991,8 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) if (shouldPreserveKey) { config.security.private_key.size = 32; memcpy(config.security.private_key.bytes, private_key_temp, config.security.private_key.size); - printBytes("Restored key", config.security.private_key.bytes, config.security.private_key.size); + // Never log the key bytes: debug logs get pasted into public bug reports. + LOG_DEBUG("Restored preserved private key"); } else { config.security.private_key.size = 0; } @@ -1627,7 +1636,7 @@ void NodeDB::resetNodes(bool keepFavorites) NodeNum ourNum = getNodeNum(); numMeshNodes = 1; if (keepFavorites) { - LOG_INFO("Clearing node database - preserving favorites"); + LOG_INFO("Clear node database, keep favorites"); // Compact favorites into contiguous low slots: zeroing in place leaves one above // numMeshNodes, invisible to every `i < numMeshNodes` scan yet still serialized to flash. for (size_t i = 1; i < meshNodes->size(); i++) { @@ -1642,7 +1651,7 @@ void NodeDB::resetNodes(bool keepFavorites) } std::fill(nodeDatabase.nodes.begin() + numMeshNodes, nodeDatabase.nodes.end(), meshtastic_NodeInfoLite()); } else { - LOG_INFO("Clearing node database - removing favorites"); + LOG_INFO("Clear node database, remove favorites"); for (size_t i = 1; i < meshNodes->size(); i++) { const NodeNum gone = meshNodes->at(i).num; if (gone) @@ -1698,7 +1707,7 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum) trafficManagementModule->purgeNode(nodeNum); #endif - LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed); + LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries, saving", removed); saveNodeDatabaseToDisk(); } @@ -2040,7 +2049,7 @@ void NodeDB::pickNewNodeNum() (nodeNum == NODENUM_BROADCAST || nodeNum < NUM_RESERVED)) { NodeNum candidate = random(NUM_RESERVED, LONG_MAX); // try a new random choice if (found) - LOG_WARN("NOTE! Our desired nodenum 0x%08x is invalid or in use, picking 0x%08x", nodeNum, candidate); + LOG_WARN("NOTE! Desired nodenum 0x%08x invalid or in use, picking 0x%08x", nodeNum, candidate); nodeNum = candidate; } LOG_DEBUG("Use nodenum 0x%08x ", nodeNum); @@ -2072,11 +2081,11 @@ LoadFileResult NodeDB::loadProto(const char *filename, size_t protoSize, size_t if (fields != &meshtastic_NodeDatabase_msg) memset(dest_struct, 0, objSize); if (!pb_decode(&stream, fields, dest_struct)) { - LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream)); + LOG_ERROR("Can't decode protobuf %s", PB_GET_ERROR(&stream)); state = LoadFileResult::DECODE_FAILED; storageCorruptThisLoad = true; } else { - LOG_INFO("Loaded encrypted %s successfully", filename); + LOG_INFO("Loaded encrypted %s", filename); state = LoadFileResult::LOAD_SUCCESS; } } else { @@ -2100,18 +2109,18 @@ LoadFileResult NodeDB::loadProto(const char *filename, size_t protoSize, size_t fields != &meshtastic_NodeDatabase_Legacy_msg) // both NodeDatabase descriptors contain std::vector members memset(dest_struct, 0, objSize); if (!pb_decode(&stream, fields, dest_struct)) { - LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream)); + LOG_ERROR("Can't decode protobuf %s", PB_GET_ERROR(&stream)); state = LoadFileResult::DECODE_FAILED; } else { - LOG_INFO("Loaded %s successfully", filename); + LOG_INFO("Loaded %s", filename); state = LoadFileResult::LOAD_SUCCESS; } f.close(); } else { - LOG_ERROR("Could not open / read %s", filename); + LOG_ERROR("Can't open/read %s", filename); } #else - LOG_ERROR("ERROR: Filesystem not implemented"); + LOG_ERROR("Filesystem not implemented"); state = LoadFileResult::NO_FILESYSTEM; #endif return state; @@ -2250,7 +2259,7 @@ void NodeDB::loadFromDisk() spiLock->lock(); for (const char *filename : eventProfileFiles) { if (FSCom.exists(filename) && !FSCom.remove(filename)) - LOG_WARN("Unable to remove stale event profile file %s", filename); + LOG_WARN("Can't remove stale event profile file %s", filename); } spiLock->unlock(); #endif @@ -2268,7 +2277,7 @@ void NodeDB::loadFromDisk() const size_t usedBytes = fsUsedBytes(); eventProfileStorageUnavailable = !hasEventProfileStorageSpace(totalBytes, usedBytes); if (eventProfileStorageUnavailable) { - LOG_ERROR("Event profile requires %u bytes free; only %u bytes available. Profile changes will not persist.", + LOG_ERROR("Event profile needs %u bytes free; only %u available. Changes won't persist", static_cast(EVENT_PROFILE_STORAGE_RESERVATION_BYTES), static_cast(totalBytes >= usedBytes ? totalBytes - usedBytes : 0)); } @@ -2292,7 +2301,7 @@ void NodeDB::loadFromDisk() #if defined(FACTORY_INSTALL) && !defined(ARCH_PORTDUINO) spiLock->lock(); if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) { - LOG_WARN("Factory Install Reset!"); + LOG_WARN("Factory Install Reset"); rmDir("/prefs"); FSCom.mkdir("/prefs"); File f2 = FSCom.open("/prefs/" xstr(BUILD_EPOCH), FILE_O_WRITE); @@ -2306,11 +2315,11 @@ void NodeDB::loadFromDisk() spiLock->lock(); if (FSCom.exists(legacyPrefFileName)) { spiLock->unlock(); - LOG_WARN("Legacy prefs version found, factory resetting"); + LOG_WARN("Legacy prefs version, factory reset"); if (loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg, &config) == LoadFileResult::LOAD_SUCCESS && config.has_security && config.security.private_key.size > 0) { - LOG_DEBUG("Saving backup of security config and keys"); + LOG_DEBUG("Backup security config and keys"); backupSecurity = config.security; } spiLock->lock(); @@ -2331,7 +2340,7 @@ void NodeDB::loadFromDisk() // Encrypted storage is locked. Install defaults and wait for the // passphrase over BLE/serial; PhoneAPI::handleLockdownAuthInline // calls reloadFromDisk() once the storage is unlocked. - LOG_WARN("NodeDB: Encrypted storage locked, using default config until unlocked"); + LOG_WARN("NodeDB: Encrypted storage locked, default config until unlocked"); installDefaultNodeDatabase(); installDefaultDeviceState(); installDefaultConfig(); @@ -2442,7 +2451,7 @@ void NodeDB::loadFromDisk() // Attempt recovery of owner fields from our own NodeDB entry if available. const meshtastic_NodeInfoLite *us = getMeshNode(getNodeNum()); if (nodeInfoLiteHasUser(us)) { - LOG_WARN("Restoring owner fields (long_name/short_name/is_licensed/is_unmessagable) from NodeDB for our node 0x%08x", + LOG_WARN("Restore owner fields (long_name/short_name/is_licensed/is_unmessagable) from NodeDB for node 0x%08x", us->num); // owner.long_name (40) is wider than the lite source (25); bound by the source memcpy(owner.long_name, us->long_name, sizeof(us->long_name)); @@ -2457,7 +2466,7 @@ void NodeDB::loadFromDisk() saveToDisk(SEGMENT_DEVICESTATE); } } else { - LOG_INFO("Loaded saved devicestate version %d", devicestate.version); + LOG_INFO("Loaded saved devicestate v%d", devicestate.version); } // Devicestate saved by firmware that allowed 39-byte names gets clamped on @@ -2483,7 +2492,7 @@ void NodeDB::loadFromDisk() config.lora = eventLora; state = LoadFileResult::LOAD_SUCCESS; initializedEventConfig = true; - LOG_INFO("Initialized event config without modifying %s", STANDARD_CONFIG_FILE_NAME); + LOG_INFO("Init event config without modifying %s", STANDARD_CONFIG_FILE_NAME); } else { // Keep the event load outcome because loadProto() clears config before decoding. // A normal decode failure must not create a replacement identity. @@ -2497,7 +2506,7 @@ void NodeDB::loadFromDisk() // our NodeNum (== crc32(public_key)) and orphan us on the mesh. configDecodeFailed freezes identity and // skips persisting (see ctor), so a transient failure self-heals on the next clean boot. A genuinely // absent config returns OTHER_FAILURE, so this never fires on first boot. Boot degraded + radio-silent. - LOG_ERROR("Config decode failed - freezing identity, booting degraded (radio silent until restored)"); + LOG_ERROR("Config decode failed - freeze identity, boot degraded (radio silent until restored)"); configDecodeFailed = true; installDefaultConfig(true); config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; @@ -2510,7 +2519,7 @@ void NodeDB::loadFromDisk() LOG_WARN("config %d is old, discard", config.version); installDefaultConfig(true); } else { - LOG_INFO("Loaded saved config version %d", config.version); + LOG_INFO("Loaded saved config v%d", config.version); } configLoadComplete = true; @@ -2558,12 +2567,12 @@ void NodeDB::loadFromDisk() // This is the first durable event-profile write. A failed write is // safe: normal files remain untouched and the next event boot retries. if (!saveToDisk(SEGMENT_CONFIG)) - LOG_ERROR("Unable to persist initial event config"); + LOG_ERROR("Can't persist initial event config"); } #endif if (backupSecurity.private_key.size > 0) { - LOG_DEBUG("Restoring backup of security config"); + LOG_DEBUG("Restore security config backup"); config.security = backupSecurity; saveToDisk(SEGMENT_CONFIG); } @@ -2582,7 +2591,7 @@ void NodeDB::loadFromDisk() } if (sum == 0) { numAdminKeys += 1; - LOG_INFO("Admin 0 key zero. Loading hard coded key from user preferences."); + LOG_INFO("Admin 0 key zero. Load hard coded key from user prefs"); memcpy(config.security.admin_key[0].bytes, userprefs_admin_key_0, 32); config.security.admin_key[0].size = 32; } @@ -2595,7 +2604,7 @@ void NodeDB::loadFromDisk() } if (sum == 0) { numAdminKeys += 1; - LOG_INFO("Admin 1 key zero. Loading hard coded key from user preferences."); + LOG_INFO("Admin 1 key zero. Load hard coded key from user prefs"); memcpy(config.security.admin_key[1].bytes, userprefs_admin_key_1, 32); config.security.admin_key[1].size = 32; } @@ -2608,14 +2617,14 @@ void NodeDB::loadFromDisk() } if (sum == 0) { numAdminKeys += 1; - LOG_INFO("Admin 2 key zero. Loading hard coded key from user preferences."); + LOG_INFO("Admin 2 key zero. Load hard coded key from user prefs"); memcpy(config.security.admin_key[2].bytes, userprefs_admin_key_2, 32); config.security.admin_key[2].size = 32; } #endif if (numAdminKeys > 0) { - LOG_INFO("Saving %d hard coded admin keys.", numAdminKeys); + LOG_INFO("Saving %d hard coded admin keys", numAdminKeys); config.security.admin_key_count = numAdminKeys; saveToDisk(SEGMENT_CONFIG); } @@ -2629,7 +2638,7 @@ void NodeDB::loadFromDisk() LOG_WARN("moduleConfig %d is old, discard", moduleConfig.version); installDefaultModuleConfig(); } else { - LOG_INFO("Loaded saved moduleConfig version %d", moduleConfig.version); + LOG_INFO("Loaded saved moduleConfig v%d", moduleConfig.version); } } @@ -2652,7 +2661,7 @@ void NodeDB::loadFromDisk() LOG_WARN("channelFile %d is old, discard", channelFile.version); installDefaultChannels(); } else { - LOG_INFO("Loaded saved channelFile version %d", channelFile.version); + LOG_INFO("Loaded saved channelFile v%d", channelFile.version); } } @@ -2704,7 +2713,7 @@ void NodeDB::loadFromDisk() if (activeBackupExists && !EncryptedStorage::isEncrypted(backupFileName)) { LOG_INFO("Migrating %s to encrypted storage", backupFileName); if (!EncryptedStorage::migrateFile(backupFileName)) { - LOG_ERROR("Unable to migrate %s to encrypted storage", backupFileName); + LOG_ERROR("Can't migrate %s to encrypted storage", backupFileName); storageCorruptThisLoad = true; } } @@ -2723,7 +2732,7 @@ void NodeDB::loadFromDisk() if (exists && !EncryptedStorage::isEncrypted(fn)) { LOG_INFO("Migrating inactive radio profile %s to encrypted storage", fn); if (!EncryptedStorage::migrateFile(fn)) { - LOG_ERROR("Unable to migrate %s to encrypted storage", fn); + LOG_ERROR("Can't migrate %s to encrypted storage", fn); storageCorruptThisLoad = true; } } @@ -2735,7 +2744,7 @@ void NodeDB::loadFromDisk() // 2.4.X - configuration migration to update new default intervals if (moduleConfig.version < 23) { - LOG_DEBUG("ModuleConfig version %d is stale, upgrading to new default intervals", moduleConfig.version); + LOG_DEBUG("ModuleConfig v%d stale, upgrade to new default intervals", moduleConfig.version); moduleConfig.version = DEVICESTATE_CUR_VER; if (moduleConfig.telemetry.device_update_interval == 900) moduleConfig.telemetry.device_update_interval = 0; @@ -2829,7 +2838,7 @@ bool NodeDB::reloadFromDisk() loadFromDisk(); if (storageCorruptThisLoad) { - LOG_ERROR("NodeDB: storage decrypt/decode failed during reload - surfacing as corrupt"); + LOG_ERROR("NodeDB: reload decrypt/decode failed - treat as corrupt"); // Leave the radio sleeping. Caller will lock storage and emit // a LOCKED(storage_corrupt) status; we must not reconfigure // the chip with the locked-default placeholder values still @@ -2874,7 +2883,7 @@ bool NodeDB::disableLockdownToPlaintext() moduleConfigFileName, deviceStateFileName, nodeDatabaseFileName}; for (const char *fn : filesToCheck) { if (!EncryptedStorage::migrateFileToPlaintext(fn)) { - LOG_ERROR("NodeDB: failed to revert %s to plaintext; aborting disable (device stays in lockdown)", fn); + LOG_ERROR("NodeDB: revert %s to plaintext failed; abort disable (stays in lockdown)", fn); return false; } } @@ -2903,7 +2912,7 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_ // do not try to save anything if power level is not safe. In many cases flash will be lock-protected // and all writes will fail anyway. Device should be sleeping at this point anyway. if (!powerHAL_isPowerLevelSafe()) { - LOG_ERROR("Error: trying to saveProto() on unsafe device power level."); + LOG_ERROR("saveProto() on unsafe device power level"); return false; } @@ -2925,7 +2934,7 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_ pb_ostream_t stream = pb_ostream_from_buffer(pbBuf.get(), protoSize); if (!pb_encode(&stream, fields, dest_struct)) { - LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream)); + LOG_ERROR("Can't encode protobuf %s", PB_GET_ERROR(&stream)); return false; } @@ -2933,7 +2942,7 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_ bool ok = EncryptedStorage::encryptAndWrite(filename, pbBuf.get(), encodedSize, fullAtomic); if (!ok) { - LOG_ERROR("EncryptedStorage: Failed to encrypt and write %s", filename); + LOG_ERROR("EncryptedStorage: encrypt+write %s failed", filename); } return ok; } @@ -2947,7 +2956,7 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_ pb_ostream_t stream = {&writecb, static_cast(&f), protoSize}; if (!pb_encode(&stream, fields, dest_struct)) { - LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream)); + LOG_ERROR("Can't encode protobuf %s", PB_GET_ERROR(&stream)); } else { okay = true; } @@ -2955,10 +2964,10 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_ bool writeSucceeded = f.close(); if (!okay || !writeSucceeded) { - LOG_ERROR("Can't write prefs!"); + LOG_ERROR("Can't write prefs"); } #else - LOG_ERROR("ERROR: Filesystem not implemented"); + LOG_ERROR("Filesystem not implemented"); #endif return okay; } @@ -2969,7 +2978,7 @@ bool NodeDB::saveChannelsToDisk() // do not try to save anything if power level is not safe. In many cases flash will be lock-protected // and all writes will fail anyway. if (!powerHAL_isPowerLevelSafe()) { - LOG_ERROR("Error: trying to saveChannelsToDisk() on unsafe device power level."); + LOG_ERROR("saveChannelsToDisk() on unsafe device power level"); return false; } @@ -2988,7 +2997,7 @@ bool NodeDB::saveDeviceStateToDisk() // do not try to save anything if power level is not safe. In many cases flash will be lock-protected // and all writes will fail anyway. Device should be sleeping at this point anyway. if (!powerHAL_isPowerLevelSafe()) { - LOG_ERROR("Error: trying to saveDeviceStateToDisk() on unsafe device power level."); + LOG_ERROR("saveDeviceStateToDisk() on unsafe device power level"); return false; } @@ -3016,7 +3025,7 @@ bool NodeDB::saveNodeDatabaseToDisk() // do not try to save anything if power level is not safe. In many cases flash will be lock-protected // and all writes will fail anyway. Device should be sleeping at this point anyway. if (!powerHAL_isPowerLevelSafe()) { - LOG_ERROR("Error: trying to saveNodeDatabaseToDisk() on unsafe device power level."); + LOG_ERROR("saveNodeDatabaseToDisk() on unsafe device power level"); return false; } @@ -3024,7 +3033,7 @@ bool NodeDB::saveNodeDatabaseToDisk() // would propagate through saveToDisk() and trigger fsFormat() mid-transfer. #ifdef FSCom if (xModem.isBusy()) { - LOG_DEBUG("Deferring NodeDB save: xmodem transfer in progress"); + LOG_DEBUG("Defer NodeDB save: xmodem in progress"); return true; } #endif @@ -3125,7 +3134,7 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat) // do not try to save anything if power level is not safe. In many cases flash will be lock-protected // and all writes will fail anyway. Device should be sleeping at this point anyway. if (!powerHAL_isPowerLevelSafe()) { - LOG_ERROR("Error: trying to saveToDiskNoRetry() on unsafe device power level."); + LOG_ERROR("saveToDiskNoRetry() on unsafe device power level"); return false; } @@ -3155,11 +3164,11 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat) #if USERPREFS_EVENT_MODE if (eventProfileStorageUnavailable) { if (saveWhat & SEGMENT_CONFIG) { - LOG_WARN("Skipping event config write: insufficient profile storage at boot"); + LOG_WARN("Skip event config write: insufficient profile storage at boot"); saveWhat &= ~SEGMENT_CONFIG; } if (saveWhat & SEGMENT_CHANNELS) { - LOG_WARN("Skipping event channel write: insufficient profile storage at boot"); + LOG_WARN("Skip event channel write: insufficient profile storage at boot"); saveWhat &= ~SEGMENT_CHANNELS; } } @@ -3223,14 +3232,14 @@ bool NodeDB::saveToDisk(int saveWhat) // do not try to save anything if power level is not safe. In many cases flash will be lock-protected // and all writes will fail anyway. Device should be sleeping at this point anyway. if (!powerHAL_isPowerLevelSafe()) { - LOG_ERROR("Error: trying to saveToDisk() on unsafe device power level."); + LOG_ERROR("saveToDisk() on unsafe device power level"); return false; } bool success = saveToDiskNoRetry(saveWhat); if (!success) { - LOG_ERROR("Failed to save to disk, retrying"); + LOG_ERROR("Save to disk failed, retry"); spiLock->lock(); fsFormat(); spiLock->unlock(); @@ -3266,7 +3275,7 @@ uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n) uint32_t sinceReceived(const meshtastic_MeshPacket *p) { - // rx_time may be a millis() placeholder while has_rx_time is false - don't age it as + // rx_time may be an uptime-seconds placeholder while has_rx_time is false - don't age it as // wall-clock, and don't pass it off as "just now" either. if (!p->has_rx_time) return SINCE_UNKNOWN; @@ -3432,9 +3441,9 @@ void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxS if (t.which_variant == meshtastic_Telemetry_device_metrics_tag) { if (src == RX_SRC_LOCAL) { - LOG_DEBUG("updateTelemetry LOCAL device"); + LOG_TRACE("updateTelemetry LOCAL device"); } else { - LOG_DEBUG("updateTelemetry REMOTE device node=0x%08x", nodeId); + LOG_TRACE("updateTelemetry REMOTE device node=0x%08x", nodeId); } #if !MESHTASTIC_EXCLUDE_TELEMETRYDB concurrency::LockGuard guard(&satelliteMutex); @@ -3444,9 +3453,9 @@ void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxS } else if (t.which_variant == meshtastic_Telemetry_environment_metrics_tag) { if (src == RX_SRC_LOCAL) { - LOG_DEBUG("updateTelemetry LOCAL env"); + LOG_TRACE("updateTelemetry LOCAL env"); } else { - LOG_DEBUG("updateTelemetry REMOTE env node=0x%08x", nodeId); + LOG_TRACE("updateTelemetry REMOTE env node=0x%08x", nodeId); } #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB concurrency::LockGuard guard(&satelliteMutex); @@ -3480,7 +3489,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) } } info->num = contact.node_num; + // CopyUserToNodeInfoLite assigns public_key unconditionally, and clients send add_contact before every + // DM - often from an entry that carries no key at all. A contact may still supply or update a full + // 32-byte key (that's what add_contact is for), but it must never *erase* a key we already hold, which + // would be persisted below and break subsequent DMs with PKI_SEND_FAIL_PUBLIC_KEY. + const meshtastic_NodeInfoLite_public_key_t storedKey = info->public_key; TypeConversions::CopyUserToNodeInfoLite(info, contact.user); + if (storedKey.size == 32 && info->public_key.size != 32) { + LOG_INFO("Contact 0x%08x has no key, keep the stored one", contact.node_num); + info->public_key = storedKey; + } if (contact.should_ignore) { // Block the contact and drop its rich satellite data, but keep the // public key copied above - an ignored peer keeps a usable identity @@ -3504,16 +3522,18 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) { // Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it // without the user doing so deliberately. We don't normally expect users to use a CLIENT_BASE to send DMs or to add - // contacts, but we should make sure it doesn't auto-favorite in case they do. Instead, as a workaround, we'll set - // last_heard to now, so that the add_contact node doesn't immediately get evicted. - info->last_heard = getTime(); + // contacts, but we should make sure it doesn't auto-favorite in case they do. Instead, as a workaround, we'll + // stamp the contact as heard now, so that the add_contact node doesn't immediately get evicted. + stampContactHeardNow(info); } else { // Normal case: set is_favorite to prevent expiration. // 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 stamping last_heard so the + // 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)) - info->last_heard = getTime(); + 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 @@ -3536,7 +3556,7 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde // Only a signed update may change the identity of a proven signer; our own record is exempt. // Checked before getOrCreateMeshNode so a refusal cannot evict; isKnownXeddsaSigner covers the warm tier. if (nodeId != getNodeNum() && isKnownXeddsaSigner(nodeId) && !xeddsaSigned) { - LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId); + LOG_WARN("Refuse unsigned identity update for 0x%08x that previously signed", nodeId); return false; } @@ -3577,12 +3597,12 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde if (info->public_key.size == 32) { // if we have a key for this user already, don't overwrite with a new one // if the key doesn't match, don't update nodeDB at all. if (p.public_key.size != 32 || (memcmp(p.public_key.bytes, info->public_key.bytes, 32) != 0)) { - LOG_WARN("Public Key mismatch, dropping NodeInfo"); + LOG_WARN("Public Key mismatch, drop NodeInfo"); return false; } - LOG_INFO("Public Key set for node, not updating!"); + LOG_INFO("Public Key set, not updating"); } else if (p.public_key.size == 32) { - LOG_INFO("Update Node Pubkey!"); + LOG_INFO("Update Node Pubkey"); } #endif @@ -3617,7 +3637,7 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde saveToDisk(SEGMENT_NODEDATABASE); lastNodeDbSave = millis(); } else { - LOG_DEBUG("Defer NodeDB saveToDisk for now"); + LOG_DEBUG("Defer NodeDB saveToDisk"); } } @@ -3647,7 +3667,7 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) return; } if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.from) { - LOG_DEBUG("Update DB node 0x%08x, rx_time=%u", mp.from, mp.rx_time); + LOG_TRACE("Update DB node 0x%08x, rx_time=%u", mp.from, mp.rx_time); // mp.from is unauthenticated, so rate-limit admission once the database is full: otherwise // invented node numbers churn it at packet rate and push real neighbours out. @@ -3666,9 +3686,13 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) return; } - // Gate on has_rx_time, not truthiness - rx_time may hold a millis() placeholder. + // Gate on has_rx_time, not truthiness - rx_time may hold an uptime-seconds placeholder. if (mp.has_rx_time) info->last_heard = mp.rx_time; + else + // rx_time is the arrival instant in uptime seconds. It goes to the RAM sidecar, not + // last_heard, which only ever holds a real epoch or 0. + recordHeardWhileClockUntrusted(getFrom(&mp), mp.rx_time); // Gate on the packet actually having been received over our own radio, not on rx_snr being // truthy, because 0 dB is valid. TRANSPORT_LORA is set only on the real over-the-air RX path @@ -3851,7 +3875,7 @@ void NodeDB::sortMeshDB() } } } - LOG_INFO("Sort took %u milliseconds", millis() - lastSort); + LOG_INFO("Sort took %u ms", millis() - lastSort); } } @@ -4084,6 +4108,84 @@ meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n) return meshtastic_Config_DeviceConfig_Role_CLIENT; } +void NodeDB::recordHeardWhileClockUntrusted(NodeNum num, uint32_t heardAtUptime) +{ + // Update in place if the node already has a stamp. + for (auto &h : heardAt) { + if (h.num == num) { + h.heardAtUptimeSecs = heardAtUptime; + return; + } + } + // Otherwise take an empty slot, or reuse the oldest stamp. + NodeHeardAt *victim = &heardAt[0]; + for (auto &h : heardAt) { + if (h.num == 0) { + victim = &h; + break; + } + if (h.heardAtUptimeSecs < victim->heardAtUptimeSecs) + victim = &h; + } + victim->num = num; + victim->heardAtUptimeSecs = heardAtUptime; +} + +bool NodeDB::getHeardAtUptimeSecs(NodeNum num, uint32_t &stamp) const +{ + for (const auto &h : heardAt) { + if (h.num == num) { + stamp = h.heardAtUptimeSecs; + return true; + } + } + return false; +} + +NodeDB::EvictionRecency NodeDB::evictionRecency(const meshtastic_NodeInfoLite *n) const +{ + uint32_t stamp = 0; + const bool heardThisBoot = getHeardAtUptimeSecs(n->num, stamp); + return {heardThisBoot ? stamp : n->last_heard, heardThisBoot}; +} + +bool NodeDB::evictionRecencyOlder(EvictionRecency candidate, EvictionRecency incumbent) +{ + if (candidate.heardThisBoot != incumbent.heardThisBoot) + return !candidate.heardThisBoot; + return candidate.value < incumbent.value; +} + +void NodeDB::stampContactHeardNow(meshtastic_NodeInfoLite *info) +{ + const uint32_t nowEpoch = getValidTime(RTCQualityFromNet); + if (nowEpoch) + info->last_heard = nowEpoch; + else + recordHeardWhileClockUntrusted(info->num, Time::getUptimeSecs()); +} + +void NodeDB::backfillHeardAt() +{ + const uint32_t nowEpoch = getValidTime(RTCQualityFromNet); + if (nowEpoch == 0) // called before the clock was actually valid - nothing to date against + return; + const uint32_t nowUptimeSecs = Time::getUptimeSecs(); + for (auto &h : heardAt) { + if (h.num == 0) + continue; + meshtastic_NodeInfoLite *info = getMeshNode(h.num); + if (info) { + // Both stamps are monotonic uptime seconds, so the elapsed term is exact at any age. + // Never move last_heard backwards: the node may since have been re-heard on a good clock. + const uint32_t elapsedSecs = nowUptimeSecs - h.heardAtUptimeSecs; + if (elapsedSecs < nowEpoch && nowEpoch - elapsedSecs > info->last_heard) + info->last_heard = nowEpoch - elapsedSecs; + } + h = {}; // evicted or converted either way, the stamp's job is done + } +} + /// Find a node in our DB, create an empty NodeInfo if missing meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) { @@ -4091,11 +4193,12 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) if (!lite) { if (isFull()) { - LOG_INFO("Node database full with %i nodes and %u bytes free. Erasing oldest entry", numMeshNodes, - memGet.getFreeHeap()); + LOG_INFO("Node database full: %i nodes, %u bytes free. Erase oldest", numMeshNodes, memGet.getFreeHeap()); // look for oldest node and erase it - uint32_t oldest = UINT32_MAX; - uint32_t oldestBoring = UINT32_MAX; + // Newest-possible sentinel: a zeroed init ranks older than every candidate, so nothing + // would ever be selected. Keep it maximal even though the index guards below also cover it. + EvictionRecency oldest = {UINT32_MAX, true}; + EvictionRecency oldestBoring = {UINT32_MAX, true}; int oldestIndex = -1; int oldestBoringIndex = -1; for (int i = 1; i < numMeshNodes; i++) { @@ -4103,14 +4206,19 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) const bool isFavoriteNode = nodeInfoLiteIsFavorite(cand); const bool isIgnored = nodeInfoLiteIsIgnored(cand); const bool isVerified = nodeInfoLiteIsKeyManuallyVerified(cand); + // last_heard, except that nodes heard this boot before the clock became trusted + // rank by their RAM arrival stamp instead of the 0 in the stored field. + const EvictionRecency candRecency = evictionRecency(cand); // Simply the oldest non-favorite, non-ignored, non-verified node - if (!isFavoriteNode && !isIgnored && !isVerified && cand->last_heard < oldest) { - oldest = cand->last_heard; + if (!isFavoriteNode && !isIgnored && !isVerified && + (oldestIndex == -1 || evictionRecencyOlder(candRecency, oldest))) { + oldest = candRecency; oldestIndex = i; } // The oldest "boring" node - if (!isFavoriteNode && !isIgnored && cand->public_key.size == 0 && cand->last_heard < oldestBoring) { - oldestBoring = cand->last_heard; + if (!isFavoriteNode && !isIgnored && cand->public_key.size == 0 && + (oldestBoringIndex == -1 || evictionRecencyOlder(candRecency, oldestBoring))) { + oldestBoring = candRecency; oldestBoringIndex = i; } } @@ -4187,7 +4295,7 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) } } #endif - LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap()); + LOG_INFO("Add node to database: %i nodes, %u bytes free", numMeshNodes, memGet.getFreeHeap()); } return lite; @@ -4264,14 +4372,14 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { keygenSuccess = true; } else { - LOG_ERROR("Failed to generate public key from provided private key"); + LOG_ERROR("Can't generate public key from private key"); return false; } } // Try to regenerate public key from existing private key if it's valid and not low entropy else if (config.security.private_key.size == 32 && !keyIsLowEntropy) { config.security.public_key.size = 32; - LOG_DEBUG("Regenerate PKI public key from existing private key"); + LOG_DEBUG("Regenerate PKI public key from private key"); if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { keygenSuccess = true; } @@ -4331,7 +4439,7 @@ bool NodeDB::createNewIdentity() // still streamed to clients, and made any DM/admin aimed at it fail forever with PKI_SEND_FAIL_PUBLIC_KEY. // removeNodeByNum() drops the lite entry, its satellite stores, and the warm-tier copy. if (getMeshNode(oldNodeNum) != NULL) { - LOG_DEBUG("Old node num %u is now %u, removing stale identity", oldNodeNum, newNodeNum); + LOG_DEBUG("Old node num 0x%08x now 0x%08x, remove stale identity", oldNodeNum, newNodeNum); removeNodeByNum(oldNodeNum); } else { // Lite entry already absent: drop any orphaned satellite-store entries directly. @@ -4377,7 +4485,7 @@ bool NodeDB::backupPreferences(meshtastic_AdminMessage_BackupLocation location) if (success) { LOG_INFO("Saved backup preferences"); } else { - LOG_ERROR("Failed to save backup preferences to file"); + LOG_ERROR("Save backup prefs to file failed"); } } else if (location == meshtastic_AdminMessage_BackupLocation_SD) { // TODO: After more mainline SD card support @@ -4394,7 +4502,7 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location, spiLock->lock(); if (!FSCom.exists(backupFileName)) { spiLock->unlock(); - LOG_WARN("Could not restore. No backup file found"); + LOG_WARN("Can't restore, no backup file"); return false; } else { spiLock->unlock(); @@ -4429,12 +4537,12 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location, success = saveToDisk(restoreWhat); if (success) { - LOG_INFO("Restored preferences from backup"); + LOG_INFO("Restored prefs from backup"); } else { - LOG_ERROR("Failed to save restored preferences to flash"); + LOG_ERROR("Save restored prefs to flash failed"); } } else { - LOG_ERROR("Failed to restore preferences from backup file"); + LOG_ERROR("Restore prefs from backup failed"); } } else if (location == meshtastic_AdminMessage_BackupLocation_SD) { // TODO: After more mainline SD card support @@ -4458,7 +4566,7 @@ void recordCriticalError(meshtastic_CriticalErrorCode code, uint32_t address, co // Currently portuino is mostly used for simulation. Make sure the user notices something really bad happened #ifdef ARCH_PORTDUINO - LOG_ERROR("A critical failure occurred"); + LOG_ERROR("Critical failure"); // TODO: Determine if other critical errors should also cause an immediate exit if (code == meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE || code == meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE) diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 0d45d1e1e4..e5b5a67acc 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -248,6 +248,14 @@ enum LoadFileResult { enum UserLicenseStatus { NotKnown, NotLicensed, Licensed }; +// RAM-only arrival stamp (monotonic uptime secs) for nodes heard before the wall clock was trusted, +// backfilled into last_heard as an epoch once it is. last_heard persists, so it cannot hold this. +// Bounded, linear-scan, reuse-oldest, never persisted - dies with the boot, as does its timebase. +struct NodeHeardAt { + NodeNum num = 0; ///< node this stamp describes; 0 == empty slot + uint32_t heardAtUptimeSecs = 0; ///< Time::getUptimeSecs() when last heard +}; + class NodeDB { // NodeNum provisionalNodeNum; // if we are trying to find a node num this is our current attempt @@ -308,6 +316,10 @@ class NodeDB void addFromContact(const meshtastic_SharedContact); + /// On the clock-becoming-trusted transition (see RTC.cpp): convert every RAM arrival stamp into + /// a real last_heard epoch, never backwards, then empty the table. updateFrom() takes over. + void backfillHeardAt(); + /** Update position info for this node based on received position data */ void updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSource src = RX_SRC_RADIO); @@ -638,6 +650,31 @@ class NodeDB uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually uint32_t lastSort = 0; // When last sorted the nodeDB + /// See NodeHeardAt. Caps how many distinct nodes can be dated once the clock arrives; a node + /// pushed out by reuse-oldest just stays "last heard: unknown", the same as before this table. + static constexpr size_t kMaxHeardAt = 32; + NodeHeardAt heardAt[kMaxHeardAt] = {}; + + /// Stamp (or re-stamp) a node's RAM arrival record; used instead of writing a non-epoch into + /// last_heard whenever the wall clock is untrusted. + void recordHeardWhileClockUntrusted(NodeNum num, uint32_t heardAtUptimeSecs); + + /// addFromContact's anti-eviction stamp: a real epoch when the clock is trusted, otherwise a + /// RAM arrival stamp that evictionRecency() honours - never a boot-relative last_heard. + void stampContactHeardNow(meshtastic_NodeInfoLite *info); + + /// Read the node's RAM arrival stamp. The boolean carries presence because uptime second 0 is valid. + bool getHeardAtUptimeSecs(NodeNum num, uint32_t &stamp) const; + + struct EvictionRecency { + uint32_t value; + bool heardThisBoot; + }; + + /// Eviction ranking with current-boot stamps newer than every persisted epoch. + EvictionRecency evictionRecency(const meshtastic_NodeInfoLite *n) const; + static bool evictionRecencyOlder(EvictionRecency candidate, EvictionRecency incumbent); + /* * Internal boolean to track sorting paused */ diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index aa4512a483..da745a25e7 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -64,13 +64,13 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd bool *wasUpgraded) { if (!initOk()) { - LOG_ERROR("Packet History - Was Seen Recently: NOT INITIALIZED!"); + LOG_ERROR("Packet History - Was Seen Recently: NOT INITIALIZED"); return false; } if (p->id == 0) { #if VERBOSE_PACKET_HISTORY - LOG_DEBUG("Packet History - Was Seen Recently: ID is 0, not a floodable message"); + LOG_DEBUG("Packet History - Was Seen Recently: ID 0, not floodable"); #endif return false; // Not a floodable message ID, so we don't care } @@ -107,8 +107,8 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd // Check for hop_limit upgrade scenario if (seenRecently && wasUpgraded && getHighestHopLimit(*found) < p->hop_limit) { - LOG_DEBUG("Packet History - Hop limit upgrade: packet 0x%08x from hop_limit=%d to hop_limit=%d", p->id, - getHighestHopLimit(*found), p->hop_limit); + LOG_TRACE("Packet History - Hop limit upgrade: packet 0x%08x hop_limit=%d -> %d", p->id, getHighestHopLimit(*found), + p->hop_limit); *wasUpgraded = true; } else if (wasUpgraded) { *wasUpgraded = false; // Initialize to false if not an upgrade @@ -234,7 +234,7 @@ void PacketHistory::hashInsert(NodeNum sender, PacketId id, uint16_t slotIdx) } bucket = (bucket + 1) & hashMask; } - LOG_ERROR("Packet History - hashInsert: table full or corrupted, rebuilding"); + LOG_ERROR("Packet History - hashInsert: table full or corrupt, rebuild"); hashRebuild(); } @@ -357,8 +357,7 @@ void PacketHistory::insert(const PacketRecord &r) it = (base + recentPacketsCapacity); } else { if (it->rxTimeMsec == 0) { - LOG_WARN("Packet History - insert: Found packet s=0x%08x id=0x%08x with rxTimeMsec = 0, slot %d/%d. Should never " - "happen!", + LOG_WARN("Packet History - insert: Found s=0x%08x id=0x%08x rxTimeMsec = 0, slot %d/%d. Should never happen", it->sender, it->id, it - base, recentPacketsCapacity); } if ((now_millis - it->rxTimeMsec) > OldtrxTimeMsec) { // 49.7 days rollover friendly @@ -373,7 +372,7 @@ void PacketHistory::insert(const PacketRecord &r) } if (tu == NULL) { - LOG_ERROR("Packet History - insert: No free slot, no matched packet, no oldest to reuse. Something leaked."); // mx + LOG_ERROR("Packet History - insert: No free/matched/oldest slot. Something leaked"); // mx // assert(false); // This should never happen, we should always have at least one packet to clear return; // Return early if we can't update the history } @@ -399,7 +398,7 @@ void PacketHistory::insert(const PacketRecord &r) } else { // debug only #if VERBOSE_PACKET_HISTORY - LOG_WARN("Packet History - insert: Reusing slot aged %.3fs < %ds with MATCHED PACKET - this is normal", + LOG_WARN("Packet History - insert: Reusing slot aged %.3fs < %ds with MATCHED PACKET - normal", OldtrxTimeMsec / 1000., RECENT_WARN_AGE / 1000); #endif } @@ -424,7 +423,7 @@ void PacketHistory::insert(const PacketRecord &r) if (r.rxTimeMsec == 0) { #if VERBOSE_PACKET_HISTORY - LOG_WARN("Packet History - insert: I will not store packet with rxTimeMsec = 0."); + LOG_WARN("Packet History - insert: Won't store packet with rxTimeMsec = 0"); #endif return; // Return early if we can't update the history } @@ -457,7 +456,7 @@ void PacketHistory::insert(const PacketRecord &r) bool PacketHistory::wasRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender, bool *wasSole) { if (!initOk()) { - LOG_ERROR("PacketHistory - wasRelayer: NOT INITIALIZED!"); + LOG_ERROR("PacketHistory - wasRelayer: NOT INITIALIZED"); return false; } @@ -527,7 +526,7 @@ void PacketHistory::checkRelayers(uint8_t relayer1, uint8_t relayer2, uint32_t i *r2WasSole = false; if (!initOk()) { - LOG_ERROR("PacketHistory - checkRelayers: NOT INITIALIZED!"); + LOG_ERROR("PacketHistory - checkRelayers: NOT INITIALIZED"); return; } @@ -545,7 +544,7 @@ void PacketHistory::checkRelayers(uint8_t relayer1, uint8_t relayer2, uint32_t i void PacketHistory::removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender) { if (!initOk()) { - LOG_ERROR("Packet History - remove Relayer: NOT INITIALIZED!"); + LOG_ERROR("Packet History - remove Relayer: NOT INITIALIZED"); return; } diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index f86d257739..813d413dea 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -216,11 +216,11 @@ static PhoneAuthSlot *findOrAllocSlot_LH(PhoneAPI *p) if (!s.authorized) { s.who = p; s.epoch = 0; - LOG_WARN("Lockdown: auth slot table full, evicted stale unauthorized slot for new PhoneAPI %p", p); + LOG_WARN("Lockdown: auth slots full, evicted stale unauthorized slot for new PhoneAPI %p", p); return &s; } } - LOG_WARN("Lockdown: auth slot table full of authorized sessions, refusing new PhoneAPI %p (fail-closed)", p); + LOG_WARN("Lockdown: auth slots full of authorized sessions, refuse new PhoneAPI %p (fail-closed)", p); return nullptr; } @@ -304,7 +304,7 @@ void PhoneAPI::handleStartConfig() if (config_nonce == SPECIAL_NONCE_ONLY_NODES) { // If client only wants node info, jump directly to sending nodes state = STATE_SEND_OWN_NODEINFO; - LOG_INFO("Client only wants node info, skipping other config"); + LOG_INFO("Client only wants node info, skip other config"); } else { state = STATE_SEND_MY_INFO; } @@ -457,7 +457,7 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) ourNum != 0 && toRadioScratch.packet.which_payload_variant == meshtastic_MeshPacket_decoded_tag && toRadioScratch.packet.decoded.portnum == meshtastic_PortNum_ADMIN_APP && toRadioScratch.packet.to == ourNum; if (!isLocalAdmin) { - LOG_INFO("Lockdown: Dropping non-admin ToRadio packet from unauthorized client"); + LOG_INFO("Lockdown: Drop non-admin ToRadio packet from unauthorized client"); return false; } } @@ -475,7 +475,7 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) case meshtastic_ToRadio_xmodemPacket_tag: #ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL if (!getAdminAuthorized()) { - LOG_INFO("Lockdown: Dropping xmodem packet from unauthorized client"); + LOG_INFO("Lockdown: Drop xmodem packet from unauthorized client"); break; } #endif @@ -486,17 +486,16 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) break; #if !MESHTASTIC_EXCLUDE_MQTT case meshtastic_ToRadio_mqttClientProxyMessage_tag: - LOG_DEBUG("Got MqttClientProxy message"); + LOG_TRACE("Got MqttClientProxy message"); if (state != STATE_SEND_PACKETS) { - LOG_WARN("Ignore MqttClientProxy message while completing config handshake"); + LOG_WARN("Ignore MqttClientProxy msg during config handshake"); break; } if (mqtt && moduleConfig.mqtt.proxy_to_client_enabled && moduleConfig.mqtt.enabled && (channels.anyMqttEnabled() || moduleConfig.mqtt.map_reporting_enabled)) { mqtt->onClientProxyReceive(toRadioScratch.mqttClientProxyMessage); } else { - LOG_WARN("MqttClientProxy received but proxy is not enabled, no channels have up/downlink, or map reporting " - "not enabled"); + LOG_WARN("MqttClientProxy received but proxy disabled, no up/downlink channels, or map reporting off"); } break; #endif @@ -511,11 +510,11 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) // a queue-status reply. if (toRadioScratch.heartbeat.nonce == 1) { if (nodeInfoModule) { - LOG_INFO("Broadcasting nodeinfo ping (serial)"); + LOG_INFO("Broadcast nodeinfo ping (serial)"); nodeInfoModule->sendOurNodeInfo(NODENUM_BROADCAST, true, 0, true); } } else { - LOG_DEBUG("Got client heartbeat"); + LOG_TRACE("Got client heartbeat"); heartbeatReceived = true; } break; @@ -524,7 +523,7 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) break; } } else { - LOG_ERROR("Error: ignore malformed toradio"); + LOG_ERROR("Ignore malformed toradio"); } return false; @@ -559,7 +558,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) fromRadioScratch.queueStatus = router->getQueueStatus(); heartbeatReceived = false; size_t numbytes = pb_encode_to_bytes(buf, meshtastic_FromRadio_size, &meshtastic_FromRadio_msg, &fromRadioScratch); - LOG_DEBUG("FromRadio=STATE_SEND_QUEUE_STATUS, numbytes=%u", numbytes); + LOG_TRACE("FromRadio=STATE_SEND_QUEUE_STATUS, numbytes=%u", (unsigned)numbytes); return numbytes; } @@ -572,7 +571,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) // Advance states as needed switch (state) { case STATE_SEND_NOTHING: - LOG_DEBUG("FromRadio=STATE_SEND_NOTHING"); + LOG_TRACE("FromRadio=STATE_SEND_NOTHING"); break; case STATE_SEND_MY_INFO: LOG_DEBUG("FromRadio=STATE_SEND_MY_INFO"); @@ -580,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 @@ -616,6 +618,9 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) auto info = TypeConversions::ConvertToNodeInfo(us); info.has_hops_away = false; info.is_favorite = true; + // NodeInfoLite dropped macaddr, so ConvertToUser() zero-fills it. + if (info.has_user) + memcpy(info.user.macaddr, owner.macaddr, sizeof(info.user.macaddr)); { concurrency::LockGuard guard(&nodeInfoMutex); nodeInfoForPhone = info; @@ -970,6 +975,14 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) } if (infoToSend.num != 0) { + // A record prefetched before the clock became trusted carries last_heard == 0 even + // once the store is backfilled, so re-read it at send time: handshake ordering + // (time-set vs node-list download) must not decide what the phone sees. + if (infoToSend.last_heard == 0 && infoToSend.num != nodeDB->getNodeNum()) { + const meshtastic_NodeInfoLite *fresh = nodeDB->getMeshNode(infoToSend.num); + if (fresh) + infoToSend.last_heard = fresh->last_heard; + } // Just in case we stored a different user.id in the past, but should never happen going forward sprintf(infoToSend.user.id, "!%08x", infoToSend.num); @@ -1000,7 +1013,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) } else { fromRadioScratch.which_payload_variant = meshtastic_FromRadio_fileInfo_tag; fromRadioScratch.fileInfo = filesManifest.at(config_state); - LOG_DEBUG("File: %s (%d) bytes", fromRadioScratch.fileInfo.file_name, fromRadioScratch.fileInfo.size_bytes); + LOG_TRACE("File: %s (%d) bytes", fromRadioScratch.fileInfo.file_name, fromRadioScratch.fileInfo.size_bytes); config_state++; } break; @@ -1013,7 +1026,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) case STATE_SEND_PACKETS: pauseBluetoothLogging = false; // Do we have a message from the mesh or packet from the local device? - LOG_DEBUG("FromRadio=STATE_SEND_PACKETS"); + LOG_TRACE("FromRadio=STATE_SEND_PACKETS"); if (queueStatusPacketForPhone) { fromRadioScratch.which_payload_variant = meshtastic_FromRadio_queueStatus_tag; fromRadioScratch.queueStatus = *queueStatusPacketForPhone; @@ -1098,7 +1111,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) return numbytes; } - LOG_DEBUG("No FromRadio packet available"); + LOG_TRACE("No FromRadio packet available"); return 0; } @@ -1202,7 +1215,7 @@ void PhoneAPI::prefetchNodeInfos() nodeInfoQueue.push_back(info); // Log progress here (at fetch time) so readIndex is accurate and each value logs only once. if (readIndex == 2 || readIndex % 20 == 0) { - LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes()); + LOG_TRACE("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes()); } added = true; } @@ -1804,7 +1817,7 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p) return true; } case LocalAdminGate::DropUnauthorized: - LOG_WARN("Lockdown: dropping admin payload variant=%d from unauthorized connection", admin.which_payload_variant); + LOG_WARN("Lockdown: drop admin payload variant=%d from unauthorized connection", admin.which_payload_variant); return false; case LocalAdminGate::NotAdmin: case LocalAdminGate::AuthorizedPassThrough: @@ -1813,12 +1826,22 @@ 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. + if (isBlockedEventCoordinatePacket(&p)) { + LOG_DEBUG("Suppress phone coordinate send on event (everyone) channel"); + meshtastic_QueueStatus qs = router->getQueueStatus(); + service->sendQueueStatusToPhone(qs, 0, p.id); + sendNotification(meshtastic_LogRecord_Level_WARNING, p.id, "Location sharing is disabled on this channel"); + return false; + } + #if defined(ARCH_PORTDUINO) // For use with the simulator, we should not ignore duplicate packets from the phone if (SimRadio::instance == nullptr) #endif if (p.id > 0 && wasSeenRecently(p.id)) { - LOG_DEBUG("Ignore packet from phone, already seen recently"); + LOG_DEBUG("Ignore phone packet, seen recently"); return false; } @@ -1878,7 +1901,7 @@ int PhoneAPI::onNotify(uint32_t newValue) // doesn't call this from idle) if (state == STATE_SEND_PACKETS) { - LOG_INFO("Tell client we have new packets %u", newValue); + LOG_INFO("Tell client new packets %u", newValue); onNowHasData(newValue); } else { LOG_DEBUG("Client not yet interested in packets (state=%d)", state); @@ -2049,7 +2072,7 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la) zeroPassphrase(); return true; } - LOG_INFO("Lockdown: LOCK NOW command received from authorized connection"); + LOG_INFO("Lockdown: LOCK NOW from authorized connection"); EncryptedStorage::lockNow(); revokeAllAuth(); queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "", 0, 0, 0); @@ -2073,7 +2096,7 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la) } if (!EncryptedStorage::isLockdownActive()) { // Already off - nothing to do; report DISABLED so the client UI settles. - LOG_INFO("Lockdown: disable requested but lockdown is not active"); + LOG_INFO("Lockdown: disable requested but not active"); queueLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0); zeroPassphrase(); return true; @@ -2156,7 +2179,7 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la) slot->pendingUnlockAfterReload = true; } lockdownReloadPending = true; - LOG_INFO("Lockdown: storage unlocked, awaiting reload before client visibility"); + LOG_INFO("Lockdown: storage unlocked, await reload before client visibility"); } } else { LOG_INFO("Lockdown: passphrase re-verify for admin authorization"); @@ -2166,7 +2189,7 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la) // Storage was already unlocked - no reload needed. Authorize // and surface UNLOCKED to the client immediately. setAdminAuthorized(true); - LOG_INFO("Lockdown: passphrase verified, this connection authorized"); + LOG_INFO("Lockdown: passphrase verified, connection authorized"); } } diff --git a/src/mesh/PositionPrecision.cpp b/src/mesh/PositionPrecision.cpp index 4302531a5b..d34c660861 100644 --- a/src/mesh/PositionPrecision.cpp +++ b/src/mesh/PositionPrecision.cpp @@ -16,6 +16,10 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel) uint32_t getPositionPrecisionForChannel(uint8_t channelIndex) { + // Event-channel privacy takes precedence over every stored precision and key policy. + if (channels.isEventChannel(channelIndex)) + return 0; + const meshtastic_Channel &ch = channels.getByIndex(channelIndex); if (ch.role == meshtastic_Channel_Role_DISABLED) return 0; diff --git a/src/mesh/ProtobufModule.h b/src/mesh/ProtobufModule.h index 1d1441c4db..b79867163c 100644 --- a/src/mesh/ProtobufModule.h +++ b/src/mesh/ProtobufModule.h @@ -94,7 +94,7 @@ template class ProtobufModule : protected SinglePortModule LOG_INFO("Received %s from=0x%08x, id=0x%08x, portnum=%d, payloadlen=%d", name, mp.from, mp.id, p.portnum, p.payload.size); } else { - LOG_ERROR("Error decoding proto module!"); + LOG_ERROR("Error decoding proto module"); // if we can't decode it, nobody can process it! return ProcessMessage::STOP; } @@ -115,7 +115,7 @@ template class ProtobufModule : protected SinglePortModule if (pb_decode_from_bytes(p.payload.bytes, p.payload.size, fields, &scratch)) { decoded = &scratch; } else { - LOG_ERROR("Error decoding proto module!"); + LOG_ERROR("Error decoding proto module"); // if we can't decode it, nobody can process it! return; } diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 26a765ea87..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); @@ -201,7 +202,7 @@ bool RF95Interface::init() return res == RADIOLIB_ERR_NONE; } -void RF95Interface::disableInterrupt() +void RF95Interface::clearRadioIsr() { lora->clearDio0Action(); } @@ -318,14 +319,14 @@ bool RF95Interface::isChannelActive() result = lora->scanChannel(); if (result == RADIOLIB_PREAMBLE_DETECTED) { - // LOG_DEBUG("Channel is busy!"); + // LOG_DEBUG("Channel is busy"); return true; } if (result != RADIOLIB_CHANNEL_FREE) LOG_ERROR("RF95 isChannelActive %s%d", radioLibErr, result); assert(result != RADIOLIB_ERR_WRONG_MODEM); - // LOG_DEBUG("Channel is free!"); + // LOG_DEBUG("Channel is free"); return false; } diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index 2226067646..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, @@ -35,14 +41,14 @@ class RF95Interface : public RadioLibInterface /** * Glue functions called from ISR land */ - virtual void disableInterrupt() override; + virtual void clearRadioIsr() override; int16_t getCurrentRSSI() override; /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*callback)()) { lora->setDio0Action(callback, RISING); } + virtual void setRadioIsr(void (*callback)()) override { lora->setDio0Action(callback, RISING); } /** can we detect a LoRa preamble on the current channel? */ virtual bool isChannelActive() override; diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 208e8603d9..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; @@ -692,7 +705,7 @@ void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map) // log once and stop. An incomplete map means clients won't constrain the // omitted regions, so this must be discoverable rather than silent. if (map.region_groups_count >= maxRegions) { - LOG_ERROR("Region preset map full at %u regions; remaining regions omitted", (unsigned)maxRegions); + LOG_ERROR("Region preset map full at %u regions; rest omitted", (unsigned)maxRegions); break; } @@ -832,11 +845,11 @@ uint32_t RadioInterface::getTxDelayMsecWeighted(meshtastic_MeshPacket *p) // LOG_DEBUG("rx_snr of %f so setting CWsize to:%d", snr, CWsize); if (shouldRebroadcastEarlyLikeRouter(p)) { delay = random(0, 2 * CWsize) * slotTimeMsec; - LOG_DEBUG("rx_snr found in packet. Router: setting tx delay:%d", delay); + LOG_DEBUG("rx_snr in packet. Router: tx delay:%d", delay); } else { // offset the maximum delay for routers: (2 * CWmax * slotTimeMsec) delay = (2 * CWmax * slotTimeMsec) + random(0, pow_of_2(CWsize)) * slotTimeMsec; - LOG_DEBUG("rx_snr found in packet. Setting tx delay:%d", delay); + LOG_DEBUG("rx_snr in packet. Tx delay:%d", delay); } return delay; @@ -1108,7 +1121,7 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo // Validation must still fail so callers route into the clamp, but quietly: // the clamp will accept this config by swapping regions, so don't record a // critical error or alarm the user over a change that is about to succeed. - LOG_INFO("Preset %s implies region swap %s to %s, deferring to clamp", presetName, newRegion->name, + LOG_INFO("Preset %s implies region swap %s to %s, defer to clamp", presetName, newRegion->name, swapRegion->name); return false; } @@ -1263,7 +1276,7 @@ void RadioInterface::applyModemConfig() // If custom CR is being used already, check if the new preset is higher if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate < newcr) { cr = newcr; - LOG_INFO("Default Coding Rate is higher than custom setting, using %u", cr); + LOG_INFO("Default Coding Rate above custom setting, use %u", cr); } // If the custom CR is higher than the preset, use it else if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate > newcr) { @@ -1276,8 +1289,7 @@ void RadioInterface::applyModemConfig() } else { // if not using preset, then just use the custom settings if (validateConfigLora(loraConfig)) { } else { - LOG_WARN("Invalid LoRa config settings, cannot apply requested modem config - falling back to %s defaults", - newRegion->name); + LOG_WARN("Invalid LoRa config, can't apply modem config - fall back to %s defaults", newRegion->name); clampConfigLora(loraConfig); } // Clamp at the source so numFreqSlots below can never be 0 (a bandwidth-0 config may already be persisted) @@ -1370,9 +1382,9 @@ void RadioInterface::applyModemConfig() newRegion->freqEnd - newRegion->freqStart); LOG_INFO("numFreqSlots: %u x %.3fkHz", numFreqSlots, bw); if (newRegion->overrideSlot > 0) { - LOG_INFO("Using region explicit override slot: %d", newRegion->overrideSlot); + LOG_INFO("Region explicit override slot: %d", newRegion->overrideSlot); } else if (newRegion->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) { - LOG_INFO("Using region preset name hash for slot selection"); + LOG_INFO("Use region preset name hash for slot"); } LOG_INFO("channel_num: %d", channel_num + 1); LOG_INFO("frequency: %f", getFreq()); @@ -1410,7 +1422,7 @@ void RadioInterface::limitPower(int8_t loraMaxPower) maxPower = myRegion->powerLimit; if ((power > maxPower) && !devicestate.owner.is_licensed) { - LOG_INFO("Lower transmit power because of regulatory limits"); + LOG_INFO("Lower Tx power: regulatory limits"); power = maxPower; } @@ -1477,7 +1489,7 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p) radioBuffer.header.next_hop = p->next_hop; radioBuffer.header.relay_node = p->relay_node; if (p->hop_limit > HOP_MAX) { - LOG_WARN("hop limit %d is too high, setting to %d", p->hop_limit, HOP_RELIABLE); + LOG_WARN("hop limit %d too high, set to %d", p->hop_limit, HOP_RELIABLE); p->hop_limit = HOP_RELIABLE; } radioBuffer.header.flags = diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 5a9b292cda..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" @@ -107,7 +108,7 @@ bool RadioLibInterface::canSendImmediately() // If we've been trying to send the same packet more than one minute and we haven't gotten a // TX IRQ from the radio, the radio is probably broken. if (busyTx && !Throttle::isWithinTimespanMs(lastTxStart, 60000)) { - LOG_ERROR("Hardware Failure! busyTx for more than 60s"); + LOG_ERROR("Hardware Failure! busyTx >60s"); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_TRANSMIT_FAILED); // reboot in 5 seconds when this condition occurs. rebootAtMsec = lastTxStart + 65000; @@ -131,14 +132,14 @@ bool RadioLibInterface::receiveDetected(uint16_t irq, unsigned long syncWordHead if (!(irq & syncWordHeaderValidFlag)) { // The HEADER_VALID flag should be set by now if it was really a packet, so ignore PREAMBLE_DETECTED flag activeReceiveStart = 0; - LOG_DEBUG("Ignore false preamble detection"); + LOG_TRACE("Ignore false preamble detection"); return false; } else { uint32_t maxPacketTimeMsec = getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader)); if (!Throttle::isWithinTimespanMs(activeReceiveStart, maxPacketTimeMsec)) { // We should have gotten an RX_DONE IRQ by now if it was really a packet, so ignore HEADER_VALID flag activeReceiveStart = 0; - LOG_DEBUG("Ignore false header detection"); + LOG_TRACE("Ignore false header detection"); return false; } } @@ -187,7 +188,7 @@ ErrorCode RadioLibInterface::send(meshtastic_MeshPacket *p) #ifndef LORA_DISABLE_SENDING printPacket("enqueue for send", p); - LOG_DEBUG("txGood=%d,txRelay=%d,rxGood=%d,rxBad=%d", txGood, txRelay, rxGood, rxBad); + LOG_TRACE("txGood=%d,txRelay=%d,rxGood=%d,rxBad=%d", txGood, txRelay, rxGood, rxBad); bool dropped = false; ErrorCode res = txQueue.enqueue(p, &dropped) ? ERRNO_OK : ERRNO_UNKNOWN; @@ -290,7 +291,7 @@ void RadioLibInterface::updateNoiseFloor() currentNoiseFloor = getAverageNoiseFloorInternal(); - LOG_DEBUG("Noise floor: %d dBm (samples: %d, latest: %d dBm)", currentNoiseFloor, getNoiseFloorSampleCountInternal(), rssi); + LOG_TRACE("Noise floor: %d dBm (samples: %d, latest: %d dBm)", currentNoiseFloor, getNoiseFloorSampleCountInternal(), rssi); } uint8_t RadioLibInterface::getNoiseFloorSampleCountInternal() const @@ -339,7 +340,7 @@ void RadioLibInterface::resetNoiseFloor() currentSampleIndex = 0; isNoiseFloorBufferFull = false; currentNoiseFloor = NOISE_FLOOR_DEFAULT; - LOG_INFO("Noise floor reset - rolling window collection will restart"); + LOG_INFO("Noise floor reset - rolling window will restart"); } bool RadioLibInterface::randomBytes(uint8_t *buffer, size_t length) @@ -436,16 +437,18 @@ 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 // unlicensed node keying up on a ham-only region). Drop the packet - never // transmit it on the current (home) config - and move on to the next queued packet. - LOG_DEBUG("Beacon: invalid TX radio config, dropping packet 0x%08x", txp->id); + LOG_DEBUG("Beacon: invalid TX radio config, drop packet 0x%08x", txp->id); meshtastic_MeshPacket *bad = txQueue.dequeue(); MeshBeaconModule::clearTargetRadioSettings(bad); packetPool.release(bad); @@ -468,7 +471,7 @@ void RadioLibInterface::onNotify(uint32_t notification) txp = txQueue.dequeue(); assert(txp); startSend(txp); - LOG_DEBUG("%d packets remain in the TX queue", txQueue.getMaxLen() - txQueue.getFree()); + LOG_TRACE("%d packets in TX queue", txQueue.getMaxLen() - txQueue.getFree()); } } } @@ -505,7 +508,7 @@ void RadioLibInterface::setTransmitDelay() startTransmitTimer(true); } else { // If there is a SNR, start a timer scaled based on that SNR. - LOG_DEBUG("rx_snr found. hop_limit:%d rx_snr:%f", p->hop_limit, p->rx_snr); + LOG_TRACE("rx_snr found. hop_limit:%d rx_snr:%f", p->hop_limit, p->rx_snr); startTransmitTimerRebroadcast(p); } } @@ -539,7 +542,7 @@ void RadioLibInterface::clampToLateRebroadcastWindow(NodeNum from, PacketId id) p->tx_after = millis() + getTxDelayMsecWeightedWorst(p->rx_snr); bool dropped = false; if (txQueue.enqueue(p, &dropped)) { - LOG_DEBUG("Move existing queued packet to the late rebroadcast window %dms from now", p->tx_after - millis()); + LOG_TRACE("Move queued packet to late rebroadcast window %ums from now", (uint32_t)(p->tx_after - millis())); } else { packetPool.release(p); } @@ -557,7 +560,7 @@ bool RadioLibInterface::removePendingTXPacket(NodeNum from, PacketId id, uint32_ { meshtastic_MeshPacket *p = txQueue.remove(from, id, true, true, hop_limit_lt); if (p) { - LOG_DEBUG("Dropping pending-TX packet 0x%08x with hop limit %d", p->id, p->hop_limit); + LOG_DEBUG("Drop pending-TX packet 0x%08x, hop limit %d", p->id, p->hop_limit); packetPool.release(p); return true; } @@ -607,7 +610,7 @@ void RadioLibInterface::handleReceiveInterrupt() // when this is called, we should be in receive mode - if we are not, just jump out instead of bombing. Possible Race // Condition? if (!isReceiving) { - LOG_ERROR("handleReceiveInterrupt called when not in rx mode, which shouldn't happen"); + LOG_ERROR("handleReceiveInterrupt called while not in rx mode"); return; } @@ -616,6 +619,13 @@ void RadioLibInterface::handleReceiveInterrupt() // read the number of actually received bytes size_t length = iface->getPacketLength(); + // Some drivers report this as a 16 bit value, so a bad readback can overrun radioBuffer in readData() + if (length > sizeof(radioBuffer)) { + LOG_ERROR("Ignore rx packet, bad length %u", (unsigned int)length); + rxBad++; + return; + } + uint32_t rxMsec = getPacketTime(length, true); #ifndef DISABLE_WELCOME_UNSET @@ -634,7 +644,7 @@ void RadioLibInterface::handleReceiveInterrupt() #endif if (state != RADIOLIB_ERR_NONE) { // Log PacketHeader similar to RadioInterface::printPacket so we can try to match RX errors to other packets in the logs. - LOG_ERROR("Ignore received packet due to error=%d (maybe id=0x%08x fr=0x%08x to=0x%08x flags=0x%02x rxSNR=%g rxRSSI=%i " + LOG_ERROR("Ignore rx packet, error=%d (maybe id=0x%08x fr=0x%08x to=0x%08x flags=0x%02x rxSNR=%g rxRSSI=%i " "nextHop=0x%x relay=0x%x)", state, radioBuffer.header.id, radioBuffer.header.from, radioBuffer.header.to, radioBuffer.header.flags, iface->getSNR(), lround(iface->getRSSI()), radioBuffer.header.next_hop, radioBuffer.header.relay_node); @@ -759,7 +769,7 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) /* NOTE: Minimize the actions before startTransmit() to keep the time between channel scan and actual transmit as low as possible to avoid collisions. */ if (disabled || !config.lora.tx_enabled) { - LOG_WARN("Drop Tx packet because LoRa Tx disabled"); + LOG_WARN("Drop Tx packet: LoRa Tx disabled"); #if !MESHTASTIC_EXCLUDE_BEACON // This packet may have already triggered a beacon radio switch in TRANSMIT_DELAY_COMPLETED; // since it never reaches completeSending() here, restore the radio so it isn't left on the diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 295ccc1603..0142721789 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -99,6 +99,9 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified /// are _trying_ to receive a packet currently (note - we might just be waiting for one) bool isReceiving = false; + /// has the radio IRQ ever been armed? latches true and is never cleared, so ISR context only reads it + volatile bool isrEverArmed = false; + protected: // Noise floor tracking - rolling window of samples. static const uint8_t NOISE_FLOOR_SAMPLES = 20; @@ -144,13 +147,26 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified /** * Glue functions called from ISR land + * + * Skip the detach until the IRQ has been armed once: the first setStandby() runs before any + * enableInterrupt(), and ESP-IDF logs "GPIO isr service is not installed" for that call. */ - virtual void disableInterrupt() = 0; + void disableInterrupt() + { + if (!isrEverArmed) + return; + clearRadioIsr(); + } /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*)()) = 0; + void enableInterrupt(void (*callback)()) + { + // Latch before arming: the ISR can fire the moment the handler is installed. + isrEverArmed = true; + setRadioIsr(callback); + } /** * Poll as a backup to catch missed edge-triggered interrupts. @@ -300,6 +316,10 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified */ virtual void addReceiveMetadata(meshtastic_MeshPacket *mp) = 0; + /** Chip specific arm/disarm of the radio IRQ; call enableInterrupt()/disableInterrupt() instead */ + virtual void setRadioIsr(void (*callback)()) = 0; + virtual void clearRadioIsr() = 0; + /** * Subclasses must override, implement and then call into this base class implementation */ @@ -311,23 +331,29 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified template uint32_t computePacketTime(T &lora, uint32_t pl, bool received) { if (received) { - // First get the actual coding rate and CRC status from the received packet - uint8_t rxCR; - bool hasCRC; - lora.getLoRaRxHeaderInfo(&rxCR, &hasCRC); - // Go from raw header value to denominator - if (rxCR < 5) { - rxCR += 4; - } else if (rxCR == 7) { - rxCR = 8; - } - // Received packet configuration must be the same as configured, except for coding rate and CRC DataRate_t dr = getDataRate(); - dr.lora.codingRate = rxCR; - PacketConfig_t pc = getPacketConfig(); - pc.lora.crcEnabled = hasCRC; + + uint8_t rxCR = 0; + bool hasCRC = true; + if (lora.getLoRaRxHeaderInfo(&rxCR, &hasCRC) == RADIOLIB_ERR_NONE) { + // Raw 0 is reserved and >7 is either undefined or an LR2021-only convolutional rate no + // Meshtastic peer can send. calculateTimeOnAir() would multiply by it unchecked. + if (rxCR < 1 || rxCR > 7) { + LOG_WARN("Bogus RX coding rate %d from radio, use configured %d", rxCR, dr.lora.codingRate); + } else { + // Go from raw header value to denominator + if (rxCR < 5) { + rxCR += 4; + } else if (rxCR == 7) { + rxCR = 8; + } + + dr.lora.codingRate = rxCR; + pc.lora.crcEnabled = hasCRC; + } + } return lora.calculateTimeOnAir(modemType, dr, pc, pl) / 1000; } diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index fce6b8a32e..4e8d2c2e90 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -16,6 +16,12 @@ */ ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p) { + if (isBlockedEventCoordinatePacket(p)) { + LOG_DEBUG("Suppress reliable coordinate send on event (everyone) channel"); + packetPool.release(p); + return meshtastic_Routing_Error_NOT_AUTHORIZED; + } + const GlobalPacketId key(p); const bool retransmitting = p->want_ack; @@ -24,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 @@ -45,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 4b2c426938..e4f52c7796 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -69,6 +69,52 @@ Allocator &packetPool = staticPool; static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1] __attribute__((__aligned__)); +static ChannelIndex getEffectiveChannelIndex(const meshtastic_MeshPacket *p) +{ + ChannelIndex chIndex = p->channel; + if (nodeDB && isFromUs(p) && !chIndex && !p->pki_encrypted && !isBroadcast(p->to)) { + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to); + if (node) + chIndex = node->channel; + } + return chIndex; +} + +bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p) +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + if (p->pki_encrypted || willUsePki(p)) { + return false; + } + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + return isCoordinatePortnum(p->decoded.portnum) && channels.isEventChannel(getEffectiveChannelIndex(p)); + } + return false; +#else + (void)p; + return false; +#endif +} + +bool willUsePki(const meshtastic_MeshPacket *p) +{ +#if !(MESHTASTIC_EXCLUDE_PKI) + if (p->which_payload_variant != meshtastic_MeshPacket_decoded_tag || !isFromUs(p)) + return false; + bool haveDestKey = false; + if (p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP) { + meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}}; + haveDestKey = nodeDB->copyPublicKey(p->to, destKey); + if (!haveDestKey && p->pki_encrypted) + haveDestKey = crypto->getPendingPublicKey(p->to, destKey); + } + return wouldEncryptWithPKC(p, getEffectiveChannelIndex(p), haveDestKey); +#else + (void)p; + return false; +#endif +} + struct RoutingAuthCache { bool valid = false; // Deliberately NOT initialized in-class as this eats flash space. @@ -141,7 +187,6 @@ void resetRoutingAuthEvaluationCount() } } #endif - /** * Constructor * @@ -202,7 +247,7 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p) if (node && nodeInfoLiteIsFavorite(node) && nodeInfoLiteHasUser(node) && IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) { - LOG_DEBUG("Identified unique favorite relay router 0x%08x from last byte 0x%x", resolved, p->relay_node); + LOG_DEBUG("Unique favorite relay router 0x%08x from last byte 0x%x", resolved, p->relay_node); return false; // Don't decrement hop_limit } } @@ -223,7 +268,7 @@ int32_t Router::runOnce() perhapsHandleReceived(mp); } - // LOG_DEBUG("Sleep forever!"); + // LOG_DEBUG("Sleep forever"); return INT32_MAX; // Wait a long time - until we get woken for the message queue } @@ -266,14 +311,14 @@ PacketId generatePacketId() rollingPacketId &= ID_COUNTER_MASK; // Mask out the top 22 bits PacketId id = rollingPacketId | random(UINT32_MAX & 0x7fffffff) << 10; // top 22 bits - LOG_DEBUG("Partially randomized packet id %u", id); + LOG_TRACE("Partially randomized packet id 0x%08x", id); return id; } RxTimeStamp computeRxTimeStamp() { const bool haveTime = getRTCQuality() >= RTCQualityFromNet; - return {haveTime ? getValidTime(RTCQualityFromNet) : Time::getMillis(), haveTime}; + return {haveTime ? getValidTime(RTCQualityFromNet) : Time::getUptimeSecs(), haveTime}; } void stampRxTime(meshtastic_MeshPacket *p) @@ -336,7 +381,7 @@ meshtastic_QueueStatus Router::getQueueStatus() ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) { if (p->to == 0) { - LOG_ERROR("Packet received with to: of 0!"); + LOG_ERROR("Packet received with to=0"); } // No need to deliver externally if the destination is the local node if (isToUs(p)) { @@ -360,10 +405,10 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) // don't override if a channel was requested and no need to set it when PKI is enforced if (!p->channel && !p->pki_encrypted && !isBroadcast(p->to)) { - meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->to); - if (node) { - p->channel = node->channel; - LOG_DEBUG("localSend to channel %d", p->channel); + ChannelIndex chIndex = getEffectiveChannelIndex(p); + if (chIndex) { + p->channel = chIndex; + LOG_TRACE("localSend to channel %d", p->channel); } } @@ -385,7 +430,7 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) ErrorCode Router::send(meshtastic_MeshPacket *p) { if (isToUs(p)) { - LOG_ERROR("BUG! send() called with packet destined for local node!"); + LOG_ERROR("BUG! send() with packet for local node"); packetPool.release(p); return meshtastic_Routing_Error_BAD_REQUEST; } // should have already been handled by sendLocal @@ -397,7 +442,7 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) if (hourlyTxPercent > effectiveDutyCycle) { uint8_t silentMinutes = airTime->getSilentMinutes(hourlyTxPercent, effectiveDutyCycle); - LOG_WARN("Duty cycle limit exceeded. Aborting send for now, you can send again in %d mins", silentMinutes); + LOG_WARN("Duty cycle limit exceeded, abort send, retry in %d mins", silentMinutes); meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); if (cn) { @@ -473,9 +518,15 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) fixPriority(p); // Before encryption, fix the priority if it's unset // Position precision is an originator-only privacy policy. Relays keep // p->from as the original sender, so do not rewrite their POSITION_APP payload. + if (isBlockedEventCoordinatePacket(p)) { + LOG_DEBUG("Suppress coordinate send on event (everyone) channel"); + packetPool.release(p); + return meshtastic_Routing_Error_NOT_AUTHORIZED; + } + if (isFromUs(p)) { if (!applyPositionPrecisionForChannel(*p, p->channel)) { - LOG_ERROR("Dropping malformed position packet before send"); + LOG_ERROR("Drop malformed position packet before send"); packetPool.release(p); return meshtastic_Routing_Error_BAD_REQUEST; } @@ -650,20 +701,20 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) if (!node) return false; nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); - LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from); + LOG_TRACE("Verified XEdDSA signature from 0x%08x", p->from); } else { - LOG_WARN("XEdDSA signature verification failed from 0x%08x, dropping", p->from); + LOG_WARN("XEdDSA signature verify failed from 0x%08x, drop", p->from); return false; } } else { const auto bootstrap = verifyFirstContactNodeInfo(p); if (bootstrap == NodeInfoBootstrapResult::INVALID) { - LOG_WARN("Invalid first-contact XEdDSA NodeInfo from 0x%08x, dropping", p->from); + LOG_WARN("Invalid first-contact XEdDSA NodeInfo from 0x%08x, drop", p->from); return false; } if (bootstrap == NodeInfoBootstrapResult::VERIFIED) return true; - LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from); + LOG_DEBUG("No public key for 0x%08x, can't verify XEdDSA signature", p->from); if (strict) return false; } @@ -672,14 +723,13 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) // senders emit only those two sizes (perhapsEncode sets 0 or XEDDSA_SIGNATURE_SIZE). Drop // it: a crafted partial signature would otherwise land in the unsigned branch below while // its bytes inflated the size estimate, letting a forged broadcast dodge the downgrade drop. - LOG_WARN("Malformed XEdDSA signature (%u bytes) from 0x%08x, dropping", (unsigned)p->decoded.xeddsa_signature.size, - p->from); + LOG_WARN("Malformed XEdDSA signature (%u bytes) from 0x%08x, drop", (unsigned)p->decoded.xeddsa_signature.size, p->from); return false; } else { if (p->pki_encrypted) return true; if (strict) { - LOG_WARN("Dropping unsigned packet from 0x%08x in Strict signature mode", p->from); + LOG_WARN("Drop unsigned packet from 0x%08x in Strict signature mode", p->from); return false; } if (compatible) @@ -692,7 +742,7 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) if (!canonicalSignableSize(&p->decoded, &canonicalSize)) return true; // can't size it; never drop on a sizing failure if (canonicalSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) { - LOG_WARN("Dropping unsigned packet from 0x%08x that previously signed", p->from); + LOG_WARN("Drop unsigned packet from 0x%08x that previously signed", p->from); return false; } } @@ -735,11 +785,11 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p) return RoutingAuthVerdict::REJECT; } if (state == DecodeState::DECODE_FATAL) { - LOG_WARN("Fatal decode error, dropping packet"); + LOG_WARN("Fatal decode error, drop packet"); return RoutingAuthVerdict::REJECT; } if (state == DecodeState::DECODE_FAILURE) { - LOG_WARN("Decryptable packet failed decoding, dropping packet"); + LOG_WARN("Decryptable packet failed decoding, drop"); return RoutingAuthVerdict::REJECT; } @@ -804,7 +854,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) if (config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY && !nodeInfoLiteHasUser(nodeDB->getMeshNode(p->from))) { - LOG_DEBUG("Node 0x%08x not in nodeDB-> Rebroadcast mode KNOWN_ONLY will ignore packet", p->from); + LOG_DEBUG("Node 0x%08x not in nodeDB, Rebroadcast KNOWN_ONLY ignores packet", p->from); return DecodeState::DECODE_FAILURE; } @@ -817,7 +867,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) size_t rawSize = p->encrypted.size; if (rawSize > sizeof(bytes)) { - LOG_ERROR("Packet too large to attempt decryption! (rawSize=%d > 256)", rawSize); + LOG_ERROR("Packet too large to decrypt (rawSize=%d > 256)", rawSize); return DecodeState::DECODE_FATAL; } bool decrypted = false; @@ -834,7 +884,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) licensedPkiCandidate = true; } else if (pkiCandidate) { pkiAttempted = true; - LOG_DEBUG("Attempt PKI decryption"); + LOG_TRACE("Attempt PKI decryption"); // Resolve the sender's key only for actual PKI-decrypt candidates, not every encrypted channel // packet: copyPublicKeyForDecrypt() can fall through to a linear scan of TrafficManagement's large // NodeInfo cache. It returns authoritative keys (hot/warm), or a cold-tier cache key only when it is @@ -874,7 +924,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) adminKeyFallbackRefund(); } if (decrypted) { - LOG_INFO("PKI Decryption worked!"); + LOG_INFO("PKI Decryption worked"); meshtastic_Data decodedtmp; memset(&decodedtmp, 0, sizeof(decodedtmp)); size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD; @@ -888,7 +938,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) } decrypted = true; rawSize = payloadSize; // commit the overhead subtraction only on full success - LOG_INFO("Packet decrypted using PKI!"); + LOG_INFO("Packet decrypted using PKI"); p->pki_encrypted = true; memcpy(p->public_key.bytes, remotePublic.bytes, 32); p->public_key.size = 32; @@ -906,7 +956,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) } else { // AEAD already authenticated this ciphertext, so no other candidate could decode it - // the payload is simply malformed. - LOG_ERROR("PKC Decrypted, but pb_decode failed!"); + LOG_ERROR("PKC Decrypted, but pb_decode failed"); return DecodeState::DECODE_FAILURE; } } @@ -960,6 +1010,11 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) return DecodeState::DECODE_POLICY_REJECT; #endif + if (isBlockedEventCoordinatePacket(p)) { + 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; @@ -1014,7 +1069,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) #endif return DecodeState::DECODE_SUCCESS; } else { - LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel); + LOG_WARN("No channel found for decoding, hash 0x%x", p->channel); return (matchedChannel || pkiAttempted || licensedPkiCandidate) ? DecodeState::DECODE_FAILURE : DecodeState::DECODE_OPAQUE; } @@ -1091,7 +1146,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size, p->decoded.xeddsa_signature.bytes)) { p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE; - LOG_DEBUG("XEdDSA signed packet 0x%08x", p->id); + LOG_TRACE("XEdDSA signed packet 0x%08x", p->id); } } #endif @@ -1118,7 +1173,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) // If the compressed length is greater than or equal to the original size, don't use the compressed form if (compressed_len >= p->decoded.payload.size) { - LOG_DEBUG("Not using compressing message"); + LOG_DEBUG("Not compressing"); // Set the uncompressed payload variant anyway. Shouldn't hurt? // p->decoded.which_payloadVariant = Data_payload_tag; @@ -1156,13 +1211,12 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) // We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node // is not in the local nodedb if (wouldEncryptWithPKC(p, chIndex, haveDestKey)) { - LOG_DEBUG("Use PKI!"); + LOG_DEBUG("Use PKI"); if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN) return meshtastic_Routing_Error_TOO_LARGE; // Check for a usable public key for the destination (NodeDB or a pending key-verification key) if (!haveDestKey) { - LOG_WARN("Unknown public key for destination node 0x%08x (portnum %d), refusing to send legacy DM", p->to, - p->decoded.portnum); + LOG_WARN("Unknown public key for 0x%08x (portnum %d), refuse legacy DM", p->to, p->decoded.portnum); return meshtastic_Routing_Error_PKI_SEND_FAIL_PUBLIC_KEY; } if (p->pki_encrypted && !memfll(p->public_key.bytes, 0, 32) && memcmp(p->public_key.bytes, destKey.bytes, 32) != 0) { @@ -1173,7 +1227,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) // On failure encrypted.bytes holds no ciphertext, so continuing would put the plaintext // on the air labelled pki_encrypted. if (!crypto->encryptCurve25519(p->to, getFrom(p), destKey, p->id, numbytes, bytes, p->encrypted.bytes)) { - LOG_WARN("PKI encryption failed for destination node 0x%08x", p->to); + LOG_WARN("PKI encryption failed for 0x%08x", p->to); return meshtastic_Routing_Error_PKI_FAILED; } numbytes += MESHTASTIC_PKC_OVERHEAD; @@ -1289,7 +1343,7 @@ void Router::deliverLocal(meshtastic_MeshPacket *p, RxSource src) // broadcast). Mirrors sendToPhone()'s degrade-on-exhaustion behavior. if (copy) packetPool.release(copy); - LOG_WARN("Deferred local queue full/alloc failed, dropping loopback of 0x%08x", p->id); + LOG_WARN("Deferred local queue full/alloc failed, drop loopback of 0x%08x", p->id); #ifdef PIO_UNIT_TESTING deferredLocalDropped++; #endif @@ -1372,8 +1426,8 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) // Fatal decoding error, we can't do anything with this packet LOG_WARN(decodedState == DecodeState::DECODE_POLICY_REJECT ? "Packet rejected by signature policy" - : (decodedState == DecodeState::DECODE_FATAL ? "Fatal decode error, dropping packet" - : "Decryptable packet failed decoding, dropping packet")); + : (decodedState == DecodeState::DECODE_FATAL ? "Fatal decode error, drop packet" + : "Decryptable packet failed decoding, drop")); // A policy rejection is attacker-controlled input and must not cancel a valid pending // transmission with the same (from, id). Preserve the pre-existing fatal-decode behavior. if (decodedState == DecodeState::DECODE_FATAL) @@ -1404,7 +1458,7 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && p->decoded.portnum == meshtastic_PortNum_NEIGHBORINFO_APP && (!moduleConfig.has_neighbor_info || !moduleConfig.neighbor_info.enabled)) { - LOG_DEBUG("Neighbor info module is disabled, ignore neighbor packet"); + LOG_DEBUG("Neighbor info module disabled, ignore packet"); cancelSending(p->from, p->id); skipHandle = true; } @@ -1416,7 +1470,7 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) p->decoded.portnum == meshtastic_PortNum_MESH_BEACON_APP && (!moduleConfig.has_mesh_beacon || !(moduleConfig.mesh_beacon.flags & meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LISTEN_ENABLED))) { - LOG_DEBUG("Beacon listening is disabled, ignore beacon packet"); + LOG_DEBUG("Beacon listening disabled, ignore packet"); cancelSending(p->from, p->id); skipHandle = true; } @@ -1438,6 +1492,16 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) cancelSending(p->from, p->id); skipHandle = true; } + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + // Discard coordinate-bearing packets that arrive on the event ("everyone") + // channel: don't process, store in NodeDB, or rebroadcast them. + if (!skipHandle && isBlockedEventCoordinatePacket(p)) { + LOG_DEBUG("Drop coordinate packet on event (everyone) channel"); + cancelSending(p->from, p->id); + skipHandle = true; + } +#endif } else { printPacket("packet decoding failed or skipped (no PSK?)", p); } @@ -1449,7 +1513,7 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) #if !MESHTASTIC_EXCLUDE_MQTT if (p_encrypted == nullptr) { - LOG_WARN("p_encrypted is null, skipping MQTT publish"); + LOG_WARN("p_encrypted null, skip MQTT publish"); } else { // Mark as pki_encrypted if it is not yet decoded and MQTT encryption is also enabled, hash matches and it's a DM not // to us (because we would be able to decrypt it) @@ -1469,7 +1533,7 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) if (encodeResult != meshtastic_Routing_Error_NONE) { // Encryption failed, release the new packet and fall back to sending the original encrypted packet to // MQTT - LOG_WARN("Encryption of new TR packet failed, sending original TR to MQTT"); + LOG_WARN("New TR packet encrypt failed, send original TR to MQTT"); packetPool.release(p_encrypted_new); p_encrypted_new = nullptr; } else { @@ -1479,7 +1543,7 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) } } else { // Allocation failed, log a warning and fall back to sending the original encrypted packet to MQTT - LOG_WARN("Failed to allocate new encrypted packet for TR, sending original TR to MQTT"); + LOG_WARN("Alloc encrypted TR packet failed, send original TR to MQTT"); } } mqtt->onSend(*p_encrypted, *p, p->channel); @@ -1504,7 +1568,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) // assert(radioConfig.has_preferences); if (is_in_repeated(config.lora.ignore_incoming, p->from)) { clearRoutingAuthCache(); - LOG_DEBUG("Ignore msg, 0x%08x is in our ignore list", p->from); + LOG_DEBUG("Ignore msg, 0x%08x in ignore list", p->from); packetPool.release(p); return; } @@ -1547,6 +1611,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; @@ -1554,7 +1624,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) if (shouldFilterReceived(p)) { clearRoutingAuthCache(); - LOG_DEBUG("Incoming msg was filtered from 0x%08x", p->from); + LOG_DEBUG("Incoming msg filtered from 0x%08x", p->from); packetPool.release(p); return; } diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 2a4d979c2e..eb1213de3d 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -11,8 +11,18 @@ #include "concurrency/OSThread.h" #include +inline bool isCoordinatePortnum(meshtastic_PortNum portnum) +{ + return portnum == meshtastic_PortNum_POSITION_APP || portnum == meshtastic_PortNum_WAYPOINT_APP || + portnum == meshtastic_PortNum_MAP_REPORT_APP; +} + +bool isBlockedEventCoordinatePacket(const 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 -/// Time::getMillis() placeholder with valid=false. +/// Time::getUptimeSecs() placeholder with valid=false. Uptime seconds are monotonic, so +/// reconciliation against a later epoch is exact at any age. struct RxTimeStamp { uint32_t time; bool valid; @@ -127,6 +137,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: diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 7c46bee71c..750ebbbefb 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -77,9 +77,9 @@ template bool SX126xInterface::init() } #endif if (tcxoVoltage == 0.0) - LOG_DEBUG("SX126X_DIO3_TCXO_VOLTAGE not defined, not using DIO3 as TCXO reference voltage"); + LOG_DEBUG("SX126X_DIO3_TCXO_VOLTAGE not defined, DIO3 not used as TCXO Vref"); else - LOG_DEBUG("SX126X_DIO3_TCXO_VOLTAGE defined, using DIO3 as TCXO reference voltage at %f V", tcxoVoltage); + LOG_DEBUG("SX126X_DIO3_TCXO_VOLTAGE defined, DIO3 as TCXO Vref %f V", tcxoVoltage); setTransmitEnable(false); // FIXME: May want to set depending on a definition, currently all SX126x variant files use the DC-DC regulator option bool useRegulatorLDO = false; // Seems to depend on the connection to pin 9/DCC_SW - if an inductor DCDC? @@ -139,21 +139,21 @@ template bool SX126xInterface::init() // no effect #if ARCH_PORTDUINO if (res == RADIOLIB_ERR_NONE) { - LOG_DEBUG("Use MCU pin %i as RXEN and pin %i as TXEN to control RF switching", portduino_config.lora_rxen_pin.pin, + LOG_DEBUG("Use MCU pin %i as RXEN, pin %i as TXEN for RF switching", portduino_config.lora_rxen_pin.pin, portduino_config.lora_txen_pin.pin); lora.setRfSwitchPins(portduino_config.lora_rxen_pin.pin, portduino_config.lora_txen_pin.pin); } #else #ifndef SX126X_RXEN #define SX126X_RXEN RADIOLIB_NC - LOG_DEBUG("SX126X_RXEN not defined, defaulting to RADIOLIB_NC"); + LOG_DEBUG("SX126X_RXEN not defined, default RADIOLIB_NC"); #endif #ifndef SX126X_TXEN #define SX126X_TXEN RADIOLIB_NC - LOG_DEBUG("SX126X_TXEN not defined, defaulting to RADIOLIB_NC"); + LOG_DEBUG("SX126X_TXEN not defined, default RADIOLIB_NC"); #endif if (res == RADIOLIB_ERR_NONE) { - LOG_DEBUG("Use MCU pin %i as RXEN and pin %i as TXEN to control RF switching", SX126X_RXEN, SX126X_TXEN); + LOG_DEBUG("Use MCU pin %i as RXEN, pin %i as TXEN for RF switching", SX126X_RXEN, SX126X_TXEN); lora.setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); } #endif @@ -162,15 +162,15 @@ template bool SX126xInterface::init() LOG_INFO("Set RX gain to boosted mode; result: %d", result); } else { uint16_t result = lora.setRxBoostedGainMode(false); - LOG_INFO("Set RX gain to power saving mode (boosted mode off); result: %d", result); + LOG_INFO("Set RX gain to power saving mode; result: %d", result); } // Undocumented SX1262 register patch recommended by Heltec/Semtech for improved RX sensitivity. // Sets bit 0 of register 0x8B5. if (module.SPIsetRegValue(0x8B5, 0x01, 0, 0) == RADIOLIB_ERR_NONE) { - LOG_INFO("Applied SX1262 register 0x8B5 patch for RX improvement"); + LOG_INFO("Applied SX1262 reg 0x8B5 RX patch"); } else { - LOG_WARN("Failed to apply SX1262 register 0x8B5 patch for RX improvement"); + LOG_WARN("Can't apply SX1262 reg 0x8B5 RX patch"); } if (res == RADIOLIB_ERR_NONE) @@ -230,7 +230,7 @@ template bool SX126xInterface::reconfigure() 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. - LOG_ERROR("SX126X setOutputPower %d dBm rejected (%s%d); keeping previous Tx power", power, radioLibErr, err); + LOG_ERROR("SX126X setOutputPower %d dBm rejected (%s%d); keep previous Tx power", power, radioLibErr, err); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); } @@ -250,7 +250,7 @@ template int16_t SX126xInterface::getCurrentRSSI() return (int16_t)round(rssi); } -template void SX126xInterface::enableInterrupt(void (*callback)()) +template void SX126xInterface::setRadioIsr(void (*callback)()) { #ifdef LORA_DIO1_SOFTWARE_POLL irqPollingActive = true; @@ -261,7 +261,7 @@ template void SX126xInterface::enableInterrupt(void (*callback)( #endif } -template void SX126xInterface::disableInterrupt() +template void SX126xInterface::clearRadioIsr() { #ifdef LORA_DIO1_SOFTWARE_POLL irqPollingActive = false; @@ -336,7 +336,7 @@ template void SX126xInterface::addReceiveMetadata(meshtastic_Mes mp->rx_snr = lora.getSNR(); mp->rx_rssi = lround(lora.getRSSI()); mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding - LOG_DEBUG("Corrected frequency offset: %f", lora.getFrequencyError()); + LOG_TRACE("Corrected frequency offset: %f", lora.getFrequencyError()); } /** We override to turn on transmitter power as needed. @@ -478,7 +478,7 @@ template void SX126xInterface::resetAGC() } if (module.hal->digitalRead(module.getGpio())) { - LOG_WARN("SX126x AGC reset: calibration did not complete within 50ms"); + LOG_WARN("SX126x AGC reset: calibration not done in 50ms"); startReceive(); return; } @@ -506,7 +506,7 @@ template void SX126xInterface::resetAGC() // Without this re-apply, every SX1262 node loses its RX boost ~60s after boot // and never recovers until reboot. See empirical evidence in the PR description. if (module.SPIsetRegValue(0x8B5, 0x01, 0, 0) != RADIOLIB_ERR_NONE) { - LOG_WARN("SX126x resetAGC: failed to re-apply 0x8B5 RX sensitivity patch"); + LOG_WARN("SX126x resetAGC: 0x8B5 RX patch re-apply failed"); } // 6. Resume receiving diff --git a/src/mesh/SX126xInterface.h b/src/mesh/SX126xInterface.h index 0bf977ba2a..9465064b8a 100644 --- a/src/mesh/SX126xInterface.h +++ b/src/mesh/SX126xInterface.h @@ -47,12 +47,12 @@ template class SX126xInterface : public RadioLibInterface /** * Glue functions called from ISR land */ - virtual void disableInterrupt() override; + virtual void clearRadioIsr() override; /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*callback)()) override; + virtual void setRadioIsr(void (*callback)()) override; #ifdef LORA_DIO1_SOFTWARE_POLL void handleSoftwareLoraIrqPoll() override; diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 7848d51db3..bb1d890247 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -156,7 +156,7 @@ template bool SX128xInterface::reconfigure() return true; } -template void SX128xInterface::disableInterrupt() +template void SX128xInterface::clearRadioIsr() { lora.clearDio1Action(); } diff --git a/src/mesh/SX128xInterface.h b/src/mesh/SX128xInterface.h index 1205087b71..3b9015249e 100644 --- a/src/mesh/SX128xInterface.h +++ b/src/mesh/SX128xInterface.h @@ -43,12 +43,12 @@ template class SX128xInterface : public RadioLibInterface /** * Glue functions called from ISR land */ - virtual void disableInterrupt() override; + virtual void clearRadioIsr() override; /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*callback)()) { lora.setDio1Action(callback); } + virtual void setRadioIsr(void (*callback)()) override { lora.setDio1Action(callback); } /** can we detect a LoRa preamble on the current channel? */ virtual bool isChannelActive() override; diff --git a/src/mesh/StreamAPI.cpp b/src/mesh/StreamAPI.cpp index e20434042c..412a9786a5 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 */ 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/Throttle.cpp b/src/mesh/Throttle.cpp index a4f8347b26..606ba737e9 100644 --- a/src/mesh/Throttle.cpp +++ b/src/mesh/Throttle.cpp @@ -1,4 +1,5 @@ #include "Throttle.h" +#include "UptimeClock.h" #include /// @brief Execute a function throttled to a minimum interval @@ -10,11 +11,11 @@ bool Throttle::execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, void (*throttleFunc)(void), void (*onDefer)(void)) { if (*lastExecutionMs == 0) { - *lastExecutionMs = millis(); + *lastExecutionMs = Time::getMillis(); throttleFunc(); return true; } - uint32_t now = millis(); + uint32_t now = Time::getMillis(); if ((now - *lastExecutionMs) >= minumumIntervalMs) { throttleFunc(); @@ -31,6 +32,14 @@ bool Throttle::execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, vo /// @param timeSpanMs The interval in milliseconds of the timespan bool Throttle::isWithinTimespanMs(uint32_t lastExecutionMs, uint32_t timeSpanMs) { - uint32_t now = millis(); + uint32_t now = Time::getMillis(); return (now - lastExecutionMs) < timeSpanMs; +} + +/// @brief Check whether an absolute deadline has arrived, correctly across the millis() wrap +/// @param deadlineMs The deadline, as a millis() value +/// See the header for the range limit and the sentinel requirement. +bool Throttle::deadlinePassed(uint32_t deadlineMs) +{ + return deadlinePassedAt(Time::getMillis(), deadlineMs); } \ No newline at end of file diff --git a/src/mesh/Throttle.h b/src/mesh/Throttle.h index 8b4bb5d305..f9d68a4143 100644 --- a/src/mesh/Throttle.h +++ b/src/mesh/Throttle.h @@ -7,4 +7,48 @@ class Throttle public: static bool execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, void (*func)(void), void (*onDefer)(void) = NULL); static bool isWithinTimespanMs(uint32_t lastExecutionMs, uint32_t intervalMs); + + /// Complement of isWithinTimespanMs(): true once intervalMs has passed since lastExecutionMs. + /// Boundary is inclusive (>=), mirroring isWithinTimespanMs()'s exclusive <. + /// Deliberately does not treat lastExecutionMs == 0 as "never run" - callers that use 0 as a + /// sentinel must test for it separately, so the sentinel never reaches the arithmetic. + static bool hasElapsed(uint32_t lastExecutionMs, uint32_t intervalMs) + { + return !isWithinTimespanMs(lastExecutionMs, intervalMs); + } + + /// True once an absolute deadline has arrived. Use this rather than comparing against millis() + /// directly: that inverts while the deadline sits on the far side of the 32-bit wrap, so the + /// action either fires immediately or blocks for about the interval it should have waited. + /// + /// Use this when the site stores a deadline; use hasElapsed() when it stores the time of the + /// last event, which allows the full ~49.7 day range instead of ~24.8 days ahead. + /// + /// Callers that overload the deadline with an "inactive" sentinel (0, or UINT32_MAX) MUST test + /// for that separately, first: every such value is arithmetically far in the past, so it reads + /// as passed. + /// + /// TODO(deadline-type): mistake-proof that MUST by giving a deadline its own one-field type - + /// Deadline::in(ms) / .armed() / .passed() / .disarm(). A hand-built `now + interval` could then + /// no longer land on the sentinel by accident, and "armed" would stay a question separate from + /// "passed" - the split that has to survive, because which way "inactive" falls is the caller's + /// to decide. Same size and cost as the bare uint32_t. The conversion sites, grouped by the four + /// meanings they give the sentinel today: + /// 0 = unarmed - Power.cpp rebootAtMsec/shutdownAtMsec (the cheapest pair to convert), and + /// GPS.cpp fixHoldEnds, whose arm site remaps a 0 result to 1 by hand. + /// 0 = forever - NotificationRenderer.cpp alertBannerUntil. Every read spells its own `> 0` + /// guard, so this third state wants naming rather than repeating. + /// 0 = due now - ethClient.cpp ntp_renew, forced at link-up. + /// UINT32_MAX - ExternalNotificationModule.cpp nagCycleCutoff, whose armed() also lives in a + /// second variable (isNagging) and whose arm site can land on the sentinel. + static bool deadlinePassed(uint32_t deadlineMs); + + /// deadlinePassed() against a caller-supplied "now", for a loop that snapshots the time once and + /// tests many deadlines against it. Same range limit and sentinel rules as above. + static bool deadlinePassedAt(uint32_t nowMs, uint32_t deadlineMs) + { + // Passed iff now - deadline has not wrapped past 2^31 ms; further-ahead deadlines land in + // the top half. Not an int32_t cast, which is implementation-defined beyond INT32_MAX. + return (uint32_t)(nowMs - deadlineMs) < 0x80000000u; + } }; \ No newline at end of file diff --git a/src/mesh/WarmNodeStore.cpp b/src/mesh/WarmNodeStore.cpp index d8d9dcf296..6971e6ff4a 100644 --- a/src/mesh/WarmNodeStore.cpp +++ b/src/mesh/WarmNodeStore.cpp @@ -494,7 +494,7 @@ void WarmNodeStore::load() bool WarmNodeStore::save() { if (!powerHAL_isPowerLevelSafe()) { - LOG_ERROR("Error: trying to save WarmStore on unsafe device power level."); + LOG_ERROR("Trying to save WarmStore on unsafe device power level"); return false; } concurrency::LockGuard g(spiLock); @@ -605,7 +605,7 @@ bool WarmNodeStore::save() if (!entries) return false; if (!powerHAL_isPowerLevelSafe()) { - LOG_ERROR("Error: trying to save WarmStore on unsafe device power level."); + LOG_ERROR("Trying to save WarmStore on unsafe device power level"); return false; } diff --git a/src/mesh/api/PacketAPI.cpp b/src/mesh/api/PacketAPI.cpp index ad4d06729d..c8adda5204 100644 --- a/src/mesh/api/PacketAPI.cpp +++ b/src/mesh/api/PacketAPI.cpp @@ -92,7 +92,7 @@ bool PacketAPI::receivePacket(void) } break; default: - LOG_ERROR("Error: unhandled meshtastic_ToRadio variant: %d", mr->which_payload_variant); + LOG_ERROR("Unhandled meshtastic_ToRadio variant: %d", mr->which_payload_variant); break; } } 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/ethCert.cpp b/src/mesh/eth/ethCert.cpp index e97db916a0..b6019cd333 100644 --- a/src/mesh/eth/ethCert.cpp +++ b/src/mesh/eth/ethCert.cpp @@ -270,7 +270,7 @@ bool ensureCertForIp(IPAddress ip, EthCertMaterial &out) (unsigned)out.keyDer.size(), ipStr.c_str()); return true; } - LOG_WARN("ETH CERT: cached cert/key failed to parse (partial write?), regenerating"); + LOG_WARN("ETH CERT: cached cert/key parse failed (partial write?), regen"); out.certDer.clear(); out.keyDer.clear(); } @@ -279,7 +279,7 @@ bool ensureCertForIp(IPAddress ip, EthCertMaterial &out) } } - LOG_INFO("ETH CERT: generating ECDSA P-256 self-signed cert for IP %s...", ipStr.c_str()); + LOG_INFO("ETH CERT: gen ECDSA P-256 self-signed cert for IP %s", ipStr.c_str()); uint32_t t0 = millis(); if (!generateCert(ip, out)) { LOG_ERROR("ETH CERT: generation failed"); @@ -299,7 +299,7 @@ bool ensureCertForIp(IPAddress ip, EthCertMaterial &out) writeText(IP_PATH, ""); if (!writeBinary(CERT_PATH, out.certDer.data(), out.certDer.size()) || !writeBinary(KEY_PATH, out.keyDer.data(), out.keyDer.size()) || !writeText(IP_PATH, ipStr)) { - LOG_WARN("ETH CERT: persist failed - will regenerate next boot"); + LOG_WARN("ETH CERT: persist failed, regen next boot"); } else { LOG_INFO("ETH CERT: persisted to LittleFS"); } @@ -337,7 +337,7 @@ class EthCertThread : public concurrency::OSThread // regenerates whenever its saved IP != ip, so the cert SAN follows. bool ok = ensureCertForIp(ip, material_); if (!ok) { - LOG_ERROR("ETH CERT: pipeline FAILED - TLS server will not start"); + LOG_ERROR("ETH CERT: pipeline FAILED, no TLS server"); // Don't leave isReady() reporting true with empty material: a later TLS // teardown (e.g. a W5500 reset) would then fail initTlsContext() and stay // disabled. Clear readiness so the TLS worker waits and the next poll @@ -375,7 +375,7 @@ void initEthCertThread() if (certThread) return; certThread = new EthCertThread(); - LOG_INFO("ETH CERT: deferred worker scheduled (waits for DHCP, runs once)"); + LOG_INFO("ETH CERT: deferred worker scheduled (awaits DHCP)"); } bool isEthCertReady() diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index bf6be0b9c7..bf85eef925 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -4,6 +4,7 @@ #include "configuration.h" #include "gps/RTC.h" #include "main.h" +#include "mesh/Throttle.h" #include "mesh/api/ethServerAPI.h" #include "target_specific.h" #if HAS_ETHERNET && defined(HAS_ETHERNET_OTA) @@ -196,7 +197,9 @@ static int32_t reconnectETH() } #ifndef DISABLE_NTP - if (isEthernetAvailable() && (ntp_renew < millis())) { + // 0 here means "renew now" (forced at link-up). deadlinePassed(0) only reads as passed for the + // first half of each wrap cycle, so treat 0 as always-due rather than relying on that. + if (isEthernetAvailable() && (ntp_renew == 0 || Throttle::deadlinePassed(ntp_renew))) { LOG_INFO("Update NTP time from %s", config.network.ntp_server); if (timeClient.update()) { diff --git a/src/mesh/eth/ethOTA.cpp b/src/mesh/eth/ethOTA.cpp index 0bac5b8a29..b99ff73046 100644 --- a/src/mesh/eth/ethOTA.cpp +++ b/src/mesh/eth/ethOTA.cpp @@ -99,7 +99,7 @@ static bool authenticateClient(EthernetClient &client) // Rate-limit after failed auth - close silently so the error byte is not // misinterpreted as part of the nonce by a re-trying client. if (lastAuthFailure != 0 && (millis() - lastAuthFailure) < OTA_AUTH_COOLDOWN_MS) { - LOG_WARN("ETH OTA: Auth cooldown active, rejecting connection"); + LOG_WARN("ETH OTA: Auth cooldown, reject connection"); client.stop(); return false; } @@ -260,7 +260,7 @@ static void handleOTAClient(EthernetClient &client) return; } - LOG_INFO("ETH OTA: Update staged successfully (%u bytes). Rebooting...", hdr.firmwareSize); + LOG_INFO("ETH OTA: Update staged (%u bytes). Rebooting", hdr.firmwareSize); client.write(OTA_OK); client.flush(); delay(500); 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 945840c0f4..d029daf314 100644 --- a/src/mesh/generated/meshtastic/admin.pb.cpp +++ b/src/mesh/generated/meshtastic/admin.pb.cpp @@ -30,7 +30,7 @@ PB_BIND(meshtastic_SharedContact, meshtastic_SharedContact, AUTO) PB_BIND(meshtastic_KeyVerificationAdmin, meshtastic_KeyVerificationAdmin, AUTO) -PB_BIND(meshtastic_SensorConfig, meshtastic_SensorConfig, AUTO) +PB_BIND(meshtastic_SensorConfig, meshtastic_SensorConfig, 2) PB_BIND(meshtastic_SCD4X_config, meshtastic_SCD4X_config, AUTO) @@ -39,12 +39,18 @@ PB_BIND(meshtastic_SCD4X_config, meshtastic_SCD4X_config, AUTO) PB_BIND(meshtastic_SEN5X_config, meshtastic_SEN5X_config, AUTO) +PB_BIND(meshtastic_SEN6X_config, meshtastic_SEN6X_config, AUTO) + + PB_BIND(meshtastic_SCD30_config, meshtastic_SCD30_config, AUTO) PB_BIND(meshtastic_SHTXX_config, meshtastic_SHTXX_config, AUTO) +PB_BIND(meshtastic_DS248X_config, meshtastic_DS248X_config, AUTO) + + diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index 4c00a568cf..9d73b85088 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -186,7 +186,7 @@ typedef struct _meshtastic_LockdownAuth { token at unlock time: the client-supplied boots_remaining when non-zero, otherwise the firmware default (TOKEN_DEFAULT_BOOTS). Note that boots_remaining == 0 in this message means "use firmware - default", NOT "zero boots" - a client computing the ceiling for + default", NOT "zero boots" — a client computing the ceiling for display should mirror that resolution rather than multiplying the raw request value. @@ -196,7 +196,7 @@ typedef struct _meshtastic_LockdownAuth { Uses millis() (CPU uptime), not wall-clock time, so the cap is immune to GPS spoofing, RTC backup-battery removal, and Faraday - cage isolation - none of those move the uptime counter. The only + cage isolation — none of those move the uptime counter. The only way to reset the session clock is a reboot, which costs a boot from the on-flash, HMAC-bound counter. */ uint32_t max_session_seconds; @@ -213,7 +213,7 @@ typedef struct _meshtastic_LockdownAuth { NOT reversed by this operation: APPROTECT. Once the debug port lockout has been burned (on silicon where it is effective) it is - permanent - disabling lockdown decrypts your data and removes the + permanent — disabling lockdown decrypts your data and removes the access gates, but the SWD/JTAG port stays locked for the life of the device (recoverable only via a full chip erase over a debug probe, which destroys all data). Clients should make this @@ -303,8 +303,38 @@ typedef struct _meshtastic_SEN5X_config { /* One-shot mode (true for low power - one-shot mode, false for normal - continuous mode) */ bool has_set_one_shot_mode; bool set_one_shot_mode; + /* Trigger a fan cleaning cycle */ + bool has_start_fan_cleaning; + bool start_fan_cleaning; } meshtastic_SEN5X_config; +typedef struct _meshtastic_SEN6X_config { + /* Reference temperature in degC */ + bool has_set_temperature; + float set_temperature; + /* One-shot mode (true for low power - one-shot mode, false for normal - continuous mode) */ + bool has_set_one_shot_mode; + bool set_one_shot_mode; + /* Trigger a fan cleaning cycle */ + bool has_start_fan_cleaning; + bool start_fan_cleaning; + /* Set Automatic self-calibration enabled (CO2-capable variants only: SEN63C, SEN66, SEN69C) */ + bool has_set_asc; + bool set_asc; + /* Recalibration target CO2 concentration in ppm (FRC only), CO2-capable variants only */ + bool has_set_target_co2_conc; + uint32_t set_target_co2_conc; + /* Altitude of sensor in meters above sea level. 0 - 3000m (overrides ambient pressure), CO2-capable variants only */ + bool has_set_altitude; + uint32_t set_altitude; + /* Sensor ambient pressure in Pa. 70000 - 120000 Pa (overrides altitude), CO2-capable variants only */ + bool has_set_ambient_pressure; + uint32_t set_ambient_pressure; + /* Perform a factory reset of the CO2 sensor's calibration, CO2-capable variants only */ + bool has_factory_reset; + bool factory_reset; +} meshtastic_SEN6X_config; + typedef struct _meshtastic_SCD30_config { /* Set Automatic self-calibration enabled */ bool has_set_asc; @@ -332,6 +362,12 @@ typedef struct _meshtastic_SHTXX_config { uint32_t set_accuracy; } meshtastic_SHTXX_config; +typedef struct _meshtastic_DS248X_config { + /* Main channel for temperature reporting (0-7) */ + bool has_main_temperature_channel; + uint32_t main_temperature_channel; +} meshtastic_DS248X_config; + typedef struct _meshtastic_SensorConfig { /* SCD4X CO2 Sensor configuration */ bool has_scd4x_config; @@ -345,6 +381,12 @@ typedef struct _meshtastic_SensorConfig { /* SHTXX temperature and relative humidity sensor configuration */ bool has_shtxx_config; meshtastic_SHTXX_config shtxx_config; + /* DS248X-800 temperature sensor configuration */ + bool has_ds248x_config; + meshtastic_DS248X_config ds248x_config; + /* SEN6X PM/RHT/VOC/NOx/CO2/HCHO Sensor configuration */ + bool has_sen6x_config; + meshtastic_SEN6X_config sen6x_config; } meshtastic_SensorConfig; typedef PB_BYTES_ARRAY_T(8) meshtastic_AdminMessage_session_passkey_t; @@ -544,6 +586,8 @@ 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} @@ -553,11 +597,13 @@ 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} +#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_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} +#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_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}}} @@ -566,11 +612,13 @@ 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} +#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_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} +#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} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_AdminMessage_InputEvent_event_code_tag 1 @@ -608,6 +656,15 @@ extern "C" { #define meshtastic_SCD4X_config_set_power_mode_tag 7 #define meshtastic_SEN5X_config_set_temperature_tag 1 #define meshtastic_SEN5X_config_set_one_shot_mode_tag 2 +#define meshtastic_SEN5X_config_start_fan_cleaning_tag 3 +#define meshtastic_SEN6X_config_set_temperature_tag 1 +#define meshtastic_SEN6X_config_set_one_shot_mode_tag 2 +#define meshtastic_SEN6X_config_start_fan_cleaning_tag 3 +#define meshtastic_SEN6X_config_set_asc_tag 4 +#define meshtastic_SEN6X_config_set_target_co2_conc_tag 5 +#define meshtastic_SEN6X_config_set_altitude_tag 6 +#define meshtastic_SEN6X_config_set_ambient_pressure_tag 7 +#define meshtastic_SEN6X_config_factory_reset_tag 8 #define meshtastic_SCD30_config_set_asc_tag 1 #define meshtastic_SCD30_config_set_target_co2_conc_tag 2 #define meshtastic_SCD30_config_set_temperature_tag 3 @@ -615,10 +672,13 @@ extern "C" { #define meshtastic_SCD30_config_set_measurement_interval_tag 5 #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_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_AdminMessage_get_channel_request_tag 1 #define meshtastic_AdminMessage_get_channel_response_tag 2 #define meshtastic_AdminMessage_get_owner_request_tag 3 @@ -824,13 +884,17 @@ X(a, STATIC, OPTIONAL, UINT32, security_number, 4) X(a, STATIC, OPTIONAL, MESSAGE, scd4x_config, 1) \ 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, shtxx_config, 4) \ +X(a, STATIC, OPTIONAL, MESSAGE, ds248x_config, 5) \ +X(a, STATIC, OPTIONAL, MESSAGE, sen6x_config, 6) #define meshtastic_SensorConfig_CALLBACK NULL #define meshtastic_SensorConfig_DEFAULT NULL #define meshtastic_SensorConfig_scd4x_config_MSGTYPE meshtastic_SCD4X_config #define meshtastic_SensorConfig_sen5x_config_MSGTYPE meshtastic_SEN5X_config #define meshtastic_SensorConfig_scd30_config_MSGTYPE meshtastic_SCD30_config #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_SCD4X_config_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, BOOL, set_asc, 1) \ @@ -845,10 +909,23 @@ X(a, STATIC, OPTIONAL, BOOL, set_power_mode, 7) #define meshtastic_SEN5X_config_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, FLOAT, set_temperature, 1) \ -X(a, STATIC, OPTIONAL, BOOL, set_one_shot_mode, 2) +X(a, STATIC, OPTIONAL, BOOL, set_one_shot_mode, 2) \ +X(a, STATIC, OPTIONAL, BOOL, start_fan_cleaning, 3) #define meshtastic_SEN5X_config_CALLBACK NULL #define meshtastic_SEN5X_config_DEFAULT NULL +#define meshtastic_SEN6X_config_FIELDLIST(X, a) \ +X(a, STATIC, OPTIONAL, FLOAT, set_temperature, 1) \ +X(a, STATIC, OPTIONAL, BOOL, set_one_shot_mode, 2) \ +X(a, STATIC, OPTIONAL, BOOL, start_fan_cleaning, 3) \ +X(a, STATIC, OPTIONAL, BOOL, set_asc, 4) \ +X(a, STATIC, OPTIONAL, UINT32, set_target_co2_conc, 5) \ +X(a, STATIC, OPTIONAL, UINT32, set_altitude, 6) \ +X(a, STATIC, OPTIONAL, UINT32, set_ambient_pressure, 7) \ +X(a, STATIC, OPTIONAL, BOOL, factory_reset, 8) +#define meshtastic_SEN6X_config_CALLBACK NULL +#define meshtastic_SEN6X_config_DEFAULT NULL + #define meshtastic_SCD30_config_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, BOOL, set_asc, 1) \ X(a, STATIC, OPTIONAL, UINT32, set_target_co2_conc, 2) \ @@ -864,6 +941,11 @@ X(a, STATIC, OPTIONAL, UINT32, set_accuracy, 1) #define meshtastic_SHTXX_config_CALLBACK NULL #define meshtastic_SHTXX_config_DEFAULT NULL +#define meshtastic_DS248X_config_FIELDLIST(X, a) \ +X(a, STATIC, OPTIONAL, UINT32, main_temperature_channel, 1) +#define meshtastic_DS248X_config_CALLBACK NULL +#define meshtastic_DS248X_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; @@ -875,8 +957,10 @@ extern const pb_msgdesc_t meshtastic_KeyVerificationAdmin_msg; extern const pb_msgdesc_t meshtastic_SensorConfig_msg; extern const pb_msgdesc_t meshtastic_SCD4X_config_msg; extern const pb_msgdesc_t meshtastic_SEN5X_config_msg; +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; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ #define meshtastic_AdminMessage_fields &meshtastic_AdminMessage_msg @@ -890,23 +974,27 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_SensorConfig_fields &meshtastic_SensorConfig_msg #define meshtastic_SCD4X_config_fields &meshtastic_SCD4X_config_msg #define meshtastic_SEN5X_config_fields &meshtastic_SEN5X_config_msg +#define meshtastic_SEN6X_config_fields &meshtastic_SEN6X_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 /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_ADMIN_PB_H_MAX_SIZE meshtastic_AdminMessage_size #define meshtastic_AdminMessage_InputEvent_size 14 #define meshtastic_AdminMessage_OTAEvent_size 36 #define meshtastic_AdminMessage_size 511 +#define meshtastic_DS248X_config_size 6 #define meshtastic_HamParameters_size 47 #define meshtastic_KeyVerificationAdmin_size 25 #define meshtastic_LockdownAuth_size 56 #define meshtastic_NodeRemoteHardwarePinsResponse_size 496 #define meshtastic_SCD30_config_size 27 #define meshtastic_SCD4X_config_size 29 -#define meshtastic_SEN5X_config_size 7 +#define meshtastic_SEN5X_config_size 9 +#define meshtastic_SEN6X_config_size 31 #define meshtastic_SHTXX_config_size 6 -#define meshtastic_SensorConfig_size 77 +#define meshtastic_SensorConfig_size 120 #define meshtastic_SharedContact_size 127 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/atak.pb.h b/src/mesh/generated/meshtastic/atak.pb.h index e0dab86590..6ea298f9ed 100644 --- a/src/mesh/generated/meshtastic/atak.pb.h +++ b/src/mesh/generated/meshtastic/atak.pb.h @@ -332,7 +332,7 @@ typedef enum _meshtastic_CotType { /* y-: TAKTALK room/membership broadcast. Payload carried via the TakTalkRoomData typed variant (sender_callsign, room_id, room_name, participants). The CoT type literally has a trailing dash and no - second atom - not a typo. */ + second atom — not a typo. */ meshtastic_CotType_CotType_y = 126 } meshtastic_CotType; @@ -380,7 +380,7 @@ typedef enum _meshtastic_DrawnShape_Kind { /* u-r-b-bullseye: Bullseye ring with range rings and bearing reference */ meshtastic_DrawnShape_Kind_Kind_Bullseye = 7, /* u-d-c-e: Ellipse with distinct major/minor axes (same storage as - Kind_Circle - uses major_cm/minor_cm/angle_deg - but receivers + Kind_Circle — uses major_cm/minor_cm/angle_deg — but receivers render it as a non-circular ellipse rather than a round circle). */ meshtastic_DrawnShape_Kind_Kind_Ellipse = 8, /* u-d-v: 2D vehicle outline drawn on the map. Vertices carry the @@ -400,7 +400,7 @@ typedef enum _meshtastic_DrawnShape_Kind { end of parse; builder uses it to decide which of / to emit in the reconstructed XML. */ typedef enum _meshtastic_DrawnShape_StyleMode { - /* Unspecified - receiver infers from which color fields are non-zero. */ + /* Unspecified — receiver infers from which color fields are non-zero. */ meshtastic_DrawnShape_StyleMode_StyleMode_Unspecified = 0, /* Stroke only. No in the source XML. Used for polylines, ranging lines, bullseye rings. */ @@ -417,7 +417,7 @@ typedef enum _meshtastic_DrawnShape_StyleMode { alone is ambiguous (e.g. a-u-G could be a 2525 symbol or a custom icon depending on the iconset path). */ typedef enum _meshtastic_Marker_Kind { - /* Unspecified - fall back to TAKPacketV2.cot_type_id */ + /* Unspecified — fall back to TAKPacketV2.cot_type_id */ meshtastic_Marker_Kind_Kind_Unspecified = 0, /* b-m-p-s-m: Spot map marker */ meshtastic_Marker_Kind_Kind_Spot = 1, @@ -680,10 +680,10 @@ typedef struct _meshtastic_AircraftTrack { hundred meters of the anchor has per-vertex deltas in the ±10^4 range. Under sint32+zigzag those encode as 2 bytes each (tag+varint), versus the 4 bytes that sfixed32 would always require. At 32 vertices that is ~128 - bytes of savings - the difference between fitting under the LoRa MTU or + bytes of savings — the difference between fitting under the LoRa MTU or not. Absolute coordinates (values ~10^9) would cost sint32 varint 5 bytes per field, which is why TAKPacketV2's top-level latitude_i / longitude_i - stay sfixed32 - only small values win with sint32. */ + stay sfixed32 — only small values win with sint32. */ typedef struct _meshtastic_CotGeoPoint { /* Latitude delta from TAKPacketV2.latitude_i, in 1e-7 degree units. Add to the enclosing event's latitude_i to recover the absolute latitude. */ @@ -791,7 +791,7 @@ typedef struct _meshtastic_Marker { Covers CoT type u-rb-a. The anchor position is on TAKPacketV2.latitude_i/longitude_i; the target endpoint is carried as a - CotGeoPoint - same delta-from-anchor encoding used by DrawnShape.vertices + CotGeoPoint — same delta-from-anchor encoding used by DrawnShape.vertices so a self-anchored RAB (common case) encodes in zero bytes. */ typedef struct _meshtastic_RangeAndBearing { /* Target/anchor endpoint (delta-encoded from TAKPacketV2.latitude_i/longitude_i). */ @@ -899,12 +899,12 @@ typedef struct _meshtastic_CasevacReport { same as the envelope callsign but ATAK sometimes carries a distinct ops-number here. */ pb_callback_t title; - /* Primary medline free-text - the single most clinically important line + /* Primary medline free-text — the single most clinically important line on a MEDLINE form (e.g. "2 urgent litter patients, smoke on approach"). MUST be preserved under MTU pressure as long as any casevac is sent. */ pb_callback_t medline_remarks; /* Line 3 (newer ATAK format): patient counts by precedence level. - Coexists with the enum-style `precedence` field (tag 1) - older ATAK + Coexists with the enum-style `precedence` field (tag 1) — older ATAK emits a single enum, newer ATAK emits these counts, and both can be set simultaneously. Senders populate whichever style(s) the source XML had; receivers prefer counts when non-zero. */ @@ -946,19 +946,19 @@ typedef struct _meshtastic_CasevacReport { (e.g. "Primary HLZ is soccer field"). */ pb_callback_t hlz_remarks; /* Per-patient clinical records. Each entry is one patient's ZMIST card - (Zap number / Mechanism / Injuries / Signs / Treatment). Repeatable - + (Zap number / Mechanism / Injuries / Signs / Treatment). Repeatable — a mass-casualty event can carry 1-6 entries in practice, limited by the 237 B LoRa MTU. */ pb_callback_t zmist; } meshtastic_CasevacReport; -/* Per-patient clinical summary record - one entry per patient in a CASEVAC. +/* Per-patient clinical summary record — one entry per patient in a CASEVAC. Maps directly to ATAK's child element inside . All fields are optional free-text; senders populate what they have. */ typedef struct _meshtastic_ZMistEntry { /* Patient identifier / sequence label (e.g. "ZMIST-1", "ZMIST-2"). */ pb_callback_t title; - /* Zap number - unique patient tracking ID (often a terse code like + /* Zap number — unique patient tracking ID (often a terse code like "Gunshot" or a serial). */ pb_callback_t z; /* Mechanism of injury (e.g. "Penetrating trauma", "Blast injury"). */ @@ -997,7 +997,7 @@ typedef struct _meshtastic_EmergencyAlert { creation time; the fields below carry structured metadata the raw-detail fallback currently loses. - Fields are deliberately lean - this variant is closer to the MTU ceiling + Fields are deliberately lean — this variant is closer to the MTU ceiling than the others, so every string is capped in options. */ typedef struct _meshtastic_TaskRequest { /* Short tag for the task category (e.g. "engage", "observe", "recon", @@ -1017,7 +1017,7 @@ typedef struct _meshtastic_TaskRequest { /* Weather annotation from CoT detail element. - Attaches to any TAKPacketV2 regardless of payload_variant - an Aircraft, + Attaches to any TAKPacketV2 regardless of payload_variant — an Aircraft, PLI, or Marker can all carry observed conditions at the emitting station. ATAK-CIV ships an XSD for but no dedicated handler, so the element round-trips through the generic detail pipeline; this message @@ -1026,7 +1026,7 @@ typedef struct _meshtastic_TaskRequest { Target wire cost: ~6-8 bytes compressed with a fully populated instance. Named `TAKEnvironment` (not just `Environment`) because the bare name - collides with `SwiftUI.Environment` - every SwiftUI view in a consuming + collides with `SwiftUI.Environment` — every SwiftUI view in a consuming iOS app uses the `@Environment` property wrapper, and importing the generated proto module would make `Environment` ambiguous in every one of those files. The `TAK` prefix matches the convention used by the @@ -1055,7 +1055,7 @@ typedef struct _meshtastic_TAKEnvironment { The receiving ATAK client restores those from its own defaults, same as every other CoT carried over Meshtastic today. - Attaches to any TAKPacketV2 - a PLI with a sensor on the operator's head, + Attaches to any TAKPacketV2 — a PLI with a sensor on the operator's head, an Aircraft with a FLIR turret, a Marker dropped on a UAV. Target wire cost: ~7-14 bytes compressed (dominated by model string). */ typedef struct _meshtastic_SensorFov { @@ -1065,30 +1065,30 @@ typedef struct _meshtastic_SensorFov { SensorDetailHandler default (270°) and save varint bytes over centi-deg. */ uint32_t azimuth_deg; /* Maximum range of the cone in meters. - Optional - if unset, receivers should use the ATAK-CIV default of 100m. */ + Optional — if unset, receivers should use the ATAK-CIV default of 100m. */ bool has_range_m; uint32_t range_m; /* Horizontal field of view in whole degrees (cone's angular width). ATAK-CIV default is 45°. */ uint32_t fov_horizontal_deg; /* Vertical field of view in whole degrees. ATAK-CIV default is 45°. - Optional - a value of 0 means "not set / use horizontal FOV". */ + Optional — a value of 0 means "not set / use horizontal FOV". */ uint32_t fov_vertical_deg; /* Elevation angle in whole degrees. Positive = up, negative = down. Range -90 to +90. sint32 for varint efficiency on small negatives. */ int32_t elevation_deg; /* Roll (camera tilt) in whole degrees, -180 to +180. - Optional - use 0 if the sensor doesn't track roll. */ + Optional — use 0 if the sensor doesn't track roll. */ int32_t roll_deg; /* Free-form device model identifier, e.g. "FLIR-Boson-640", "SEEK". - Optional - empty string means "unknown model" (ATAK-CIV default). */ + Optional — empty string means "unknown model" (ATAK-CIV default). */ pb_callback_t model; } meshtastic_SensorFov; /* TAKTALK chat message payload (CoT type m-t-t). TAKTALK is an ATAK plugin for voice + text team messaging. The voice - audio stream goes over UDP/RTP and is NOT carried by the mesh - only + audio stream goes over UDP/RTP and is NOT carried by the mesh — only the text envelope (this message) is. `from_voice` marks messages sent via push-to-talk speech-to-text so receivers can render a mic icon next to the text. @@ -1122,7 +1122,7 @@ typedef struct _meshtastic_TakTalkMessage { Announces a TAKTALK chatroom's friendly name and roster so peers can resolve room UUIDs (used in TakTalkMessage.chatroom_id and GeoChat.room_id) to a display name and participant list. Not a chat - message itself - these events are emitted by TAKTALK when rooms are + message itself — these events are emitted by TAKTALK when rooms are created or memberships change. */ typedef struct _meshtastic_TakTalkRoomData { /* Callsign of the device broadcasting the room state (typically the @@ -1161,7 +1161,7 @@ typedef struct _meshtastic_Marti { primary-vs-cc distinction the same way ATAK does. If dest_callsign is [TAKPacketV2.callsign] (self-addressed, unusual but - legal - e.g. ATAK echoing back to its own room), the builder still emits + legal — e.g. ATAK echoing back to its own room), the builder still emits the element so loopback shapes round-trip cleanly. */ pb_callback_t dest_callsign; } meshtastic_Marti; diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.cpp b/src/mesh/generated/meshtastic/deviceonly.pb.cpp index 5580866379..ed477630f6 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.cpp +++ b/src/mesh/generated/meshtastic/deviceonly.pb.cpp @@ -24,7 +24,7 @@ PB_BIND(meshtastic_NodePositionEntry, meshtastic_NodePositionEntry, AUTO) PB_BIND(meshtastic_NodeTelemetryEntry, meshtastic_NodeTelemetryEntry, AUTO) -PB_BIND(meshtastic_NodeEnvironmentEntry, meshtastic_NodeEnvironmentEntry, AUTO) +PB_BIND(meshtastic_NodeEnvironmentEntry, meshtastic_NodeEnvironmentEntry, 2) PB_BIND(meshtastic_NodeStatusEntry, meshtastic_NodeStatusEntry, AUTO) diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 669792ddd8..a4757b5ca2 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 170 +#define meshtastic_NodeEnvironmentEntry_size 218 #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 60d817d734..7330143592 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -1112,10 +1112,8 @@ typedef struct _meshtastic_MeshPacket { meshtastic_MeshPacket_Priority priority; /* rssi of received packet. Only sent to phone for dispay purposes. Explicit presence: rssi 0 is a legitimate reading on some radios (SX126x can report exactly - 0 dBm; SX127x's formula can even go positive), so implicit-presence proto3 made an unset - value indistinguishable from a measured one. has_rx_rssi disambiguates; a replayed packet - built from history the device never restored an RSSI for should leave this field absent - rather than emitting 0. */ + 0 dBm; SX127x's formula can even go positive). has_rx_rssi disambiguates; a replayed packet + built from history should leave this field absent rather than emitting 0. */ bool has_rx_rssi; int32_t rx_rssi; /* Describe if this message is delayed */ @@ -1269,15 +1267,15 @@ typedef struct _meshtastic_LockdownStatus { /* Current lockdown state being reported. */ meshtastic_LockdownStatus_State state; /* For LOCKED: machine-readable reason. Known values: - "needs_auth" - storage already unlocked, client must auth - "token_missing" - no boot token on flash - "token_expired" - boot token wall-clock TTL elapsed - "token_boots_zero" - boot token boot-count TTL exhausted - "token_hmac_fail" - token tampered or wrong device - "token_dek_fail" - token DEK decrypt failed - "token_wrong_size" - token file corrupted - "token_bad_magic" - token file corrupted - "not_provisioned" - should generally use NEEDS_PROVISION state instead + "needs_auth" — storage already unlocked, client must auth + "token_missing" — no boot token on flash + "token_expired" — boot token wall-clock TTL elapsed + "token_boots_zero" — boot token boot-count TTL exhausted + "token_hmac_fail" — token tampered or wrong device + "token_dek_fail" — token DEK decrypt failed + "token_wrong_size" — token file corrupted + "token_bad_magic" — token file corrupted + "not_provisioned" — should generally use NEEDS_PROVISION state instead Other values may be added; clients should treat unknown values as "locked, ask for passphrase". */ char lock_reason[32]; diff --git a/src/mesh/generated/meshtastic/mesh_beacon.pb.h b/src/mesh/generated/meshtastic/mesh_beacon.pb.h index 94312eb1e9..028d8269f5 100644 --- a/src/mesh/generated/meshtastic/mesh_beacon.pb.h +++ b/src/mesh/generated/meshtastic/mesh_beacon.pb.h @@ -15,7 +15,7 @@ /* Payload for MESH_BEACON_APP packets. Periodically broadcast by nodes in beacon mode. Listeners deliver the text message to the local inbox and cache any offered - channel/preset for the client app to act on - the firmware never auto-applies them. */ + channel/preset for the client app to act on — the firmware never auto-applies them. */ typedef struct _meshtastic_MeshBeacon { /* Human-readable beacon message. Max 100 bytes enforced by firmware on send. */ char message[101]; diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index 713a911401..b04c358fc4 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -497,7 +497,7 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { /* Single-target TX channel: channel settings (name + PSK) to send beacons on. If unset, beacons go out on the primary channel. Used only when broadcast_targets is empty. NOTE: the single-target path embeds the ChannelSettings inline here, whereas a - broadcast_targets entry references a channel-table slot by channel_index instead - see + broadcast_targets entry references a channel-table slot by channel_index instead — see BroadcastTarget. The two paths are equal, first-class options; only this representation differs. */ bool has_broadcast_on_channel; meshtastic_ChannelSettings broadcast_on_channel; @@ -514,7 +514,7 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { each temporarily switching the radio to that entry's preset/region/channel. When empty, the broadcaster uses the scalar broadcast_on_preset / broadcast_on_region / broadcast_on_channel fields instead (the single-target path). - Single- and multi-target are equal, first-class options - neither is preferred or + Single- and multi-target are equal, first-class options — neither is preferred or deprecated. They differ only in how the TX channel is named: broadcast_on_channel embeds a ChannelSettings inline, while a target references an existing channel-table slot by channel_index (see BroadcastTarget). */ diff --git a/src/mesh/generated/meshtastic/telemetry.pb.cpp b/src/mesh/generated/meshtastic/telemetry.pb.cpp index bc21b9dcbe..64cc0422f0 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.cpp +++ b/src/mesh/generated/meshtastic/telemetry.pb.cpp @@ -9,7 +9,7 @@ PB_BIND(meshtastic_DeviceMetrics, meshtastic_DeviceMetrics, AUTO) -PB_BIND(meshtastic_EnvironmentMetrics, meshtastic_EnvironmentMetrics, AUTO) +PB_BIND(meshtastic_EnvironmentMetrics, meshtastic_EnvironmentMetrics, 2) PB_BIND(meshtastic_PowerMetrics, meshtastic_PowerMetrics, AUTO) @@ -39,6 +39,9 @@ PB_BIND(meshtastic_Nau7802Config, meshtastic_Nau7802Config, AUTO) PB_BIND(meshtastic_SEN5XState, meshtastic_SEN5XState, AUTO) +PB_BIND(meshtastic_SEN6XState, meshtastic_SEN6XState, AUTO) + + diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index 36f9305612..8c01688433 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -123,7 +123,9 @@ typedef enum _meshtastic_TelemetrySensorType { /* SPA06 pressure and temperature */ meshtastic_TelemetrySensorType_SPA06 = 54, /* HM330X PM SENSOR */ - meshtastic_TelemetrySensorType_HM330X = 55 + 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; /* Struct definitions */ @@ -216,9 +218,54 @@ typedef struct _meshtastic_EnvironmentMetrics { /* Soil temperature measured (*C) */ bool has_soil_temperature; float soil_temperature; - /* One-wire temperature (*C) */ - pb_size_t one_wire_temperature_count; - float one_wire_temperature[8]; + /* Multi-channel ADC Voltage Channel 0 (V) */ + bool has_adc_voltage_ch0; + float adc_voltage_ch0; + /* Multi-channel ADC Voltage Channel 1 (V) */ + bool has_adc_voltage_ch1; + float adc_voltage_ch1; + /* Multi-channel ADC Voltage Channel 2 (V) */ + bool has_adc_voltage_ch2; + float adc_voltage_ch2; + /* Multi-channel ADC Voltage Channel 3 (V) */ + bool has_adc_voltage_ch3; + float adc_voltage_ch3; + /* Multi-channel ADC Voltage Channel 4 (V) */ + bool has_adc_voltage_ch4; + float adc_voltage_ch4; + /* Multi-channel ADC Voltage Channel 5 (V) */ + bool has_adc_voltage_ch5; + float adc_voltage_ch5; + /* Multi-channel ADC Voltage Channel 6 (V) */ + bool has_adc_voltage_ch6; + float adc_voltage_ch6; + /* Multi-channel ADC Voltage Channel 7 (V) */ + bool has_adc_voltage_ch7; + float adc_voltage_ch7; + /* Multi-channel One-Wire Temperature Channel 0 (*C) */ + bool has_one_wire_temperature_ch0; + float one_wire_temperature_ch0; + /* Multi-channel One-Wire Temperature Channel 1 (*C) */ + bool has_one_wire_temperature_ch1; + float one_wire_temperature_ch1; + /* Multi-channel One-Wire Temperature Channel 2 (*C) */ + bool has_one_wire_temperature_ch2; + float one_wire_temperature_ch2; + /* Multi-channel One-Wire Temperature Channel 3 (*C) */ + bool has_one_wire_temperature_ch3; + float one_wire_temperature_ch3; + /* Multi-channel One-Wire Temperature Channel 4 (*C) */ + bool has_one_wire_temperature_ch4; + float one_wire_temperature_ch4; + /* Multi-channel One-Wire Temperature Channel 5 (*C) */ + bool has_one_wire_temperature_ch5; + float one_wire_temperature_ch5; + /* Multi-channel One-Wire Temperature Channel 6 (*C) */ + bool has_one_wire_temperature_ch6; + float one_wire_temperature_ch6; + /* Multi-channel One-Wire Temperature Channel 7 (*C) */ + bool has_one_wire_temperature_ch7; + float one_wire_temperature_ch7; } meshtastic_EnvironmentMetrics; /* Power Metrics (voltage / current / etc) */ @@ -241,34 +288,34 @@ typedef struct _meshtastic_PowerMetrics { /* Current (Ch3) */ bool has_ch3_current; float ch3_current; - /* Voltage (Ch4) */ + /* Voltage (Ch4) - TODO Remove */ bool has_ch4_voltage; float ch4_voltage; - /* Current (Ch4) */ + /* Current (Ch4) - TODO Remove */ bool has_ch4_current; float ch4_current; - /* Voltage (Ch5) */ + /* Voltage (Ch5) - TODO Remove */ bool has_ch5_voltage; float ch5_voltage; - /* Current (Ch5) */ + /* Current (Ch5) - TODO Remove */ bool has_ch5_current; float ch5_current; - /* Voltage (Ch6) */ + /* Voltage (Ch6) - TODO Remove */ bool has_ch6_voltage; float ch6_voltage; - /* Current (Ch6) */ + /* Current (Ch6) - TODO Remove */ bool has_ch6_current; float ch6_current; - /* Voltage (Ch7) */ + /* Voltage (Ch7) - TODO Remove */ bool has_ch7_voltage; float ch7_voltage; - /* Current (Ch7) */ + /* Current (Ch7) - TODO Remove */ bool has_ch7_current; float ch7_current; - /* Voltage (Ch8) */ + /* Voltage (Ch8) - TODO Remove */ bool has_ch8_voltage; float ch8_voltage; - /* Current (Ch8) */ + /* Current (Ch8) - TODO Remove */ bool has_ch8_current; float ch8_current; } meshtastic_PowerMetrics; @@ -350,6 +397,12 @@ typedef struct _meshtastic_AirQualityMetrics { /* Typical Particle Size in um */ bool has_particles_tps; float particles_tps; + /* Raw PM sensor device status/error register bitmask, as defined by the sensor's own datasheet + (currently populated by the SEN6X family: bit 4 fan error, bit 6 RH&T error, bit 7 gas/VOC-NOx + error, bit 9 CO2 error (SEN66), bit 10 HCHO error, bit 11 PM error, bit 12 CO2 error (SEN63C/SEN69C), + bit 21 fan speed warning) */ + bool has_pm_status_flags; + uint32_t pm_status_flags; } meshtastic_AirQualityMetrics; /* Local device mesh statistics */ @@ -478,7 +531,7 @@ typedef struct _meshtastic_Nau7802Config { float calibrationFactor; } meshtastic_Nau7802Config; -/* SEN5X State, for saving to flash */ +/* SEN5X State, for saving to flash (to be merged with SEN6XState) */ typedef struct _meshtastic_SEN5XState { /* Last cleaning time for SEN5X */ uint32_t last_cleaning_time; @@ -497,6 +550,25 @@ typedef struct _meshtastic_SEN5XState { uint64_t voc_state_array; } meshtastic_SEN5XState; +/* SEN6X State, for saving to flash */ +typedef struct _meshtastic_SEN6XState { + /* Last cleaning time for SEN6X */ + uint32_t last_cleaning_time; + /* Last cleaning time for SEN6X - valid flag */ + bool last_cleaning_valid; + /* Config flag for one-shot mode (see admin.proto) */ + bool one_shot_mode; + /* Last VOC state time, for models with a VOC sensor (SEN65, SEN66, SEN68, SEN69C) */ + bool has_voc_state_time; + uint32_t voc_state_time; + /* Last VOC state validity flag, for models with a VOC sensor (SEN65, SEN66, SEN68, SEN69C) */ + bool has_voc_state_valid; + bool voc_state_valid; + /* VOC state array (8x uint8t), for models with a VOC sensor (SEN65, SEN66, SEN68, SEN69C) */ + bool has_voc_state_array; + uint64_t voc_state_array; +} meshtastic_SEN6XState; + #ifdef __cplusplus extern "C" { @@ -504,8 +576,9 @@ extern "C" { /* Helper constants for enums */ #define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET -#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_HM330X -#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_HM330X+1)) +#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_SEN6X +#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_SEN6X+1)) + @@ -521,9 +594,9 @@ 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, 0, {0, 0, 0, 0, 0, 0, 0, 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_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} +#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} #define meshtastic_TrafficManagementStats_init_default {0, 0, 0, 0, 0, 0, 0} #define meshtastic_HealthMetrics_init_default {false, 0, false, 0, false, 0} @@ -531,10 +604,11 @@ extern "C" { #define meshtastic_Telemetry_init_default {0, 0, {meshtastic_DeviceMetrics_init_default}} #define meshtastic_Nau7802Config_init_default {0, 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, 0, {0, 0, 0, 0, 0, 0, 0, 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_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} +#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} #define meshtastic_TrafficManagementStats_init_zero {0, 0, 0, 0, 0, 0, 0} #define meshtastic_HealthMetrics_init_zero {false, 0, false, 0, false, 0} @@ -542,6 +616,7 @@ extern "C" { #define meshtastic_Telemetry_init_zero {0, 0, {meshtastic_DeviceMetrics_init_zero}} #define meshtastic_Nau7802Config_init_zero {0, 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} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_DeviceMetrics_battery_level_tag 1 @@ -571,7 +646,22 @@ extern "C" { #define meshtastic_EnvironmentMetrics_rainfall_24h_tag 20 #define meshtastic_EnvironmentMetrics_soil_moisture_tag 21 #define meshtastic_EnvironmentMetrics_soil_temperature_tag 22 -#define meshtastic_EnvironmentMetrics_one_wire_temperature_tag 23 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch0_tag 24 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch1_tag 25 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch2_tag 26 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch3_tag 27 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch4_tag 28 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch5_tag 29 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch6_tag 30 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch7_tag 31 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch0_tag 32 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch1_tag 33 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch2_tag 34 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch3_tag 35 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch4_tag 36 +#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_PowerMetrics_ch1_voltage_tag 1 #define meshtastic_PowerMetrics_ch1_current_tag 2 #define meshtastic_PowerMetrics_ch2_voltage_tag 3 @@ -613,6 +703,7 @@ extern "C" { #define meshtastic_AirQualityMetrics_pm_voc_idx_tag 23 #define meshtastic_AirQualityMetrics_pm_nox_idx_tag 24 #define meshtastic_AirQualityMetrics_particles_tps_tag 25 +#define meshtastic_AirQualityMetrics_pm_status_flags_tag 26 #define meshtastic_LocalStats_uptime_seconds_tag 1 #define meshtastic_LocalStats_channel_utilization_tag 2 #define meshtastic_LocalStats_air_util_tx_tag 3 @@ -664,6 +755,12 @@ extern "C" { #define meshtastic_SEN5XState_voc_state_time_tag 4 #define meshtastic_SEN5XState_voc_state_valid_tag 5 #define meshtastic_SEN5XState_voc_state_array_tag 6 +#define meshtastic_SEN6XState_last_cleaning_time_tag 1 +#define meshtastic_SEN6XState_last_cleaning_valid_tag 2 +#define meshtastic_SEN6XState_one_shot_mode_tag 3 +#define meshtastic_SEN6XState_voc_state_time_tag 4 +#define meshtastic_SEN6XState_voc_state_valid_tag 5 +#define meshtastic_SEN6XState_voc_state_array_tag 6 /* Struct field encoding specification for nanopb */ #define meshtastic_DeviceMetrics_FIELDLIST(X, a) \ @@ -698,7 +795,22 @@ X(a, STATIC, OPTIONAL, FLOAT, rainfall_1h, 19) \ X(a, STATIC, OPTIONAL, FLOAT, rainfall_24h, 20) \ X(a, STATIC, OPTIONAL, UINT32, soil_moisture, 21) \ X(a, STATIC, OPTIONAL, FLOAT, soil_temperature, 22) \ -X(a, STATIC, REPEATED, FLOAT, one_wire_temperature, 23) +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch0, 24) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch1, 25) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch2, 26) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch3, 27) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch4, 28) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch5, 29) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch6, 30) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch7, 31) \ +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch0, 32) \ +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch1, 33) \ +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch2, 34) \ +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) #define meshtastic_EnvironmentMetrics_CALLBACK NULL #define meshtastic_EnvironmentMetrics_DEFAULT NULL @@ -747,7 +859,8 @@ X(a, STATIC, OPTIONAL, FLOAT, pm_temperature, 21) \ X(a, STATIC, OPTIONAL, FLOAT, pm_humidity, 22) \ X(a, STATIC, OPTIONAL, FLOAT, pm_voc_idx, 23) \ X(a, STATIC, OPTIONAL, FLOAT, pm_nox_idx, 24) \ -X(a, STATIC, OPTIONAL, FLOAT, particles_tps, 25) +X(a, STATIC, OPTIONAL, FLOAT, particles_tps, 25) \ +X(a, STATIC, OPTIONAL, UINT32, pm_status_flags, 26) #define meshtastic_AirQualityMetrics_CALLBACK NULL #define meshtastic_AirQualityMetrics_DEFAULT NULL @@ -838,6 +951,16 @@ X(a, STATIC, OPTIONAL, FIXED64, voc_state_array, 6) #define meshtastic_SEN5XState_CALLBACK NULL #define meshtastic_SEN5XState_DEFAULT NULL +#define meshtastic_SEN6XState_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, last_cleaning_time, 1) \ +X(a, STATIC, SINGULAR, BOOL, last_cleaning_valid, 2) \ +X(a, STATIC, SINGULAR, BOOL, one_shot_mode, 3) \ +X(a, STATIC, OPTIONAL, UINT32, voc_state_time, 4) \ +X(a, STATIC, OPTIONAL, BOOL, voc_state_valid, 5) \ +X(a, STATIC, OPTIONAL, FIXED64, voc_state_array, 6) +#define meshtastic_SEN6XState_CALLBACK NULL +#define meshtastic_SEN6XState_DEFAULT NULL + extern const pb_msgdesc_t meshtastic_DeviceMetrics_msg; extern const pb_msgdesc_t meshtastic_EnvironmentMetrics_msg; extern const pb_msgdesc_t meshtastic_PowerMetrics_msg; @@ -849,6 +972,7 @@ 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_SEN5XState_msg; +extern const pb_msgdesc_t meshtastic_SEN6XState_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ #define meshtastic_DeviceMetrics_fields &meshtastic_DeviceMetrics_msg @@ -862,18 +986,20 @@ extern const pb_msgdesc_t meshtastic_SEN5XState_msg; #define meshtastic_Telemetry_fields &meshtastic_Telemetry_msg #define meshtastic_Nau7802Config_fields &meshtastic_Nau7802Config_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_AirQualityMetrics_size 150 +#define meshtastic_AirQualityMetrics_size 157 #define meshtastic_DeviceMetrics_size 27 -#define meshtastic_EnvironmentMetrics_size 161 +#define meshtastic_EnvironmentMetrics_size 209 #define meshtastic_HealthMetrics_size 11 #define meshtastic_HostMetrics_size 264 #define meshtastic_LocalStats_size 87 #define meshtastic_Nau7802Config_size 16 #define meshtastic_PowerMetrics_size 81 #define meshtastic_SEN5XState_size 27 +#define meshtastic_SEN6XState_size 27 #define meshtastic_Telemetry_size 272 #define meshtastic_TrafficManagementStats_size 42 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 5e42aa389d..fd1be5378d 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" @@ -102,7 +103,7 @@ static void taskCreateCert(void *parameter) size_t certLen = prefs.getBytesLength("cert"); if (pkLen && certLen) { - LOG_INFO("Existing SSL Certificate found!"); + LOG_INFO("Existing SSL Certificate found"); uint8_t *pkBuffer = new uint8_t[pkLen]; prefs.getBytes("PK", pkBuffer, pkLen); @@ -180,7 +181,7 @@ void createSSLCert() runLoop = true; } } - LOG_INFO("SSL Cert Ready!"); + LOG_INFO("SSL Cert Ready"); } } @@ -191,28 +192,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; diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index 1c376818af..aa41c16952 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -26,7 +26,7 @@ // FIXME - max_count is actually 32 but we save/load this as one long string of preencoded MeshPacket bytes - not a big array in // RAM #define MAX_RX_TOPHONE (member_size(DeviceState, receive_queue) / member_size(DeviceState, receive_queue[0])) #ifndef MAX_RX_TOPHONE -#if defined(ARCH_ESP32) && !(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3)) +#if defined(ARCH_STM32WL) || (defined(ARCH_ESP32) && !(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3))) #define MAX_RX_TOPHONE 8 #elif defined(NRF52840_XXAA) // Each slot is a ~340 B MeshPacket in the static pool (Router.cpp MAX_PACKETS_STATIC), so 32 slots @@ -34,10 +34,8 @@ // the 8 classic ESP32 has shipped with for years; drops start when a stalled phone/serial client has // 16 packets queued. #define MAX_RX_TOPHONE 16 -#elif MESHTASTIC_MEM_CLASS >= MEM_CLASS_MEDIUM || defined(ARCH_RP2040) || defined(CONFIG_IDF_TARGET_ESP32C3) || \ - defined(ARCH_STM32WL) -// RP2040/RP2350, ESP32-C3 and STM32WL keep their historical 32 (no field pressure to cut them; -// STM32WL's pool is dynamic, so the constant only bounds in-flight packets there). +#elif MESHTASTIC_MEM_CLASS >= MEM_CLASS_MEDIUM || defined(ARCH_RP2040) || defined(CONFIG_IDF_TARGET_ESP32C3) +// RP2040/RP2350 and ESP32-C3 keep their historical 32. #define MAX_RX_TOPHONE 32 #else #define MAX_RX_TOPHONE 16 // unclassified small parts: fail safe-small @@ -131,8 +129,12 @@ static inline int get_max_num_nodes() /// full mesh, floored at 100. Shared by PacketHistory's constructor clamp and /// the boot-cache budget assert below so the two cannot drift. #ifndef PACKETHISTORY_MAX +#if defined(ARCH_STM32WL) +#define PACKETHISTORY_MAX (MAX_NUM_NODES * 2) // 20 entries for 10-node STM32WL +#else #define PACKETHISTORY_MAX (MAX_NUM_NODES * 2 > 100 ? (uint32_t)(MAX_NUM_NODES * 2) : (uint32_t)100) #endif +#endif /// Per-map cap (position/telemetry/environment/status): only the freshest /// MAX_SATELLITE_NODES nodes keep satellite payloads, the rest just the diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp index e5f96aa928..5dae68a736 100644 --- a/src/mesh/raspihttp/PiWebServer.cpp +++ b/src/mesh/raspihttp/PiWebServer.cpp @@ -198,7 +198,7 @@ int callback_static_file(const struct _u_request *request, struct _u_response *r content_type = u_map_get_case(&configWeb.mime_types, get_filename_ext(file_requested)); if (content_type == NULL) { content_type = u_map_get(&configWeb.mime_types, "*"); - LOG_DEBUG("Static File Server - Unknown mime type for extension %s ", get_filename_ext(file_requested)); + LOG_DEBUG("Static File Server - Unknown mime type for ext %s ", get_filename_ext(file_requested)); } u_map_put(response->map_header, "Content-Type", content_type); u_map_copy_into(response->map_header, &configWeb.map_header); @@ -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 { @@ -230,7 +234,7 @@ int callback_static_file(const struct _u_request *request, struct _u_response *r free(real_path); // realpath uses malloc return U_CALLBACK_CONTINUE; } else { - LOG_DEBUG("Static File Server - Error, user_data is NULL or inconsistent"); + LOG_DEBUG("Static File Server - user_data NULL or inconsistent"); return U_CALLBACK_ERROR; } } @@ -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 @@ -303,7 +311,7 @@ int handleAPIv1FromRadio(const struct _u_request *req, struct _u_response *res, ulfius_set_string_body_response(res, 200, tmpa); // LOG_DEBUG("\n----webAPI response all:----"); // LOG_DEBUG(tmpa); - // LOG_DEBUG(""); + // LOG_DEBUG("."); } // Otherwise, just return one protobuf } else { @@ -312,7 +320,7 @@ int handleAPIv1FromRadio(const struct _u_request *req, struct _u_response *res, ulfius_set_binary_body_response(res, 200, tmpa, len); // LOG_DEBUG("\n----webAPI response:"); // LOG_DEBUG(tmpa); - // LOG_DEBUG(""); + // LOG_DEBUG("."); } // LOG_DEBUG("end radio->web", len); @@ -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 } @@ -381,7 +388,7 @@ char *read_file_into_string(const char *filename) // reserve mem for file + 1 byte char *buffer = (char *)malloc(filesize + 1); if (buffer == NULL) { - LOG_ERROR("Malloc of mem failed for file : %s ", filename); + LOG_ERROR("Malloc failed for file : %s ", filename); fclose(file); return NULL; } @@ -406,13 +413,17 @@ int PiWebServerThread::CheckSSLandLoad() // read certificate cert_pem = read_file_into_string(CERT_PATH); if (cert_pem == NULL) { - LOG_ERROR("ERROR SSL Certificate File can't be loaded or is missing"); + LOG_ERROR("SSL Certificate File can't be loaded or missing"); return 1; } // read private key key_pem = read_file_into_string(KEY_PATH); if (key_pem == NULL) { - LOG_ERROR("ERROR file private_key can't be loaded or is missing"); + 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 @@ -479,13 +497,13 @@ PiWebServerThread::PiWebServerThread() webservport = portduino_config.webserverport; LOG_INFO("Use webserver port from yaml config %i ", webservport); } else { - LOG_INFO("Webserver port in yaml config set to 0, defaulting to port 9443"); + LOG_INFO("Webserver port in yaml config 0, default to 9443"); webservport = 9443; } // Web Content Service Instance if (ulfius_init_instance(&instanceWeb, webservport, NULL, DEFAULT_REALM) != U_OK) { - LOG_ERROR("Webserver couldn't be started, abort execution"); + LOG_ERROR("Webserver start failed, abort"); } else { LOG_INFO("Webserver started"); @@ -534,7 +552,7 @@ PiWebServerThread::PiWebServerThread() LOG_INFO("Web Server framework started on port: %i ", webservport); LOG_INFO("Web Server root %s", (char *)webrootpath.c_str()); } else { - LOG_ERROR("Error starting Web Server framework, error number: %d", retssl); + LOG_ERROR("Web Server framework start failed, err: %d", retssl); } } } diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index d84bbfeb75..8bb80cd96a 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -98,21 +98,40 @@ static int32_t ethNetworkConnectedPoll() } #endif +#if defined(USE_WS5500) || defined(USE_CH390D) +// Needs the netif, so only valid after ETH.begin(). Failures fall back to DHCP +static void applyEthStaticIp() +{ + if (config.network.address_mode != meshtastic_Config_NetworkConfig_AddressMode_STATIC) + return; + + if (config.network.ipv4_config.ip == 0) + LOG_WARN("Static address mode but no IP configured, using DHCP"); + else if (!ETH.config(config.network.ipv4_config.ip, config.network.ipv4_config.gateway, config.network.ipv4_config.subnet, + config.network.ipv4_config.dns)) + LOG_ERROR("Failed to apply static IP to Ethernet, using DHCP"); +} +#endif + #ifdef USE_WS5500 // Startup Ethernet bool initEthernet() { - if ((config.network.eth_enabled) && (ETH.begin(ETH_PHY_W5500, 1, ETH_CS_PIN, ETH_INT_PIN, ETH_RST_PIN, SPI3_HOST, - ETH_SCLK_PIN, ETH_MISO_PIN, ETH_MOSI_PIN))) { - WiFi.onEvent(WiFiEvent); -#if !MESHTASTIC_EXCLUDE_WEBSERVER - createSSLCert(); // For WebServer -#endif - new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); - return true; - } + if (!config.network.eth_enabled) + return false; - return false; + // Register before begin(): static config can fire ETH_GOT_IP immediately + WiFi.onEvent(WiFiEvent); + + if (!ETH.begin(ETH_PHY_W5500, 1, ETH_CS_PIN, ETH_INT_PIN, ETH_RST_PIN, SPI3_HOST, ETH_SCLK_PIN, ETH_MISO_PIN, ETH_MOSI_PIN)) + return false; + + applyEthStaticIp(); +#if !MESHTASTIC_EXCLUDE_WEBSERVER + createSSLCert(); // For WebServer +#endif + new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); + return true; } #endif @@ -120,6 +139,9 @@ bool initEthernet() // Startup Ethernet bool initEthernet() { + if (!config.network.eth_enabled) + return false; + // Configure CH390 ch390_config_t ch390_conf = CH390_DEFAULT_CONFIG(); ch390_conf.spi_host = SPI3_HOST; @@ -134,16 +156,19 @@ bool initEthernet() ch390_conf.reset_gpio = -1; #endif ch390_conf.spi_clock_mhz = 20; - if ((config.network.eth_enabled) && (ETH.begin(ch390_conf))) { - WiFi.onEvent(WiFiEvent); -#if !MESHTASTIC_EXCLUDE_WEBSERVER - createSSLCert(); // For WebServer -#endif - new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); - return true; - } - return false; + // Register before begin(): static config can fire ETH_GOT_IP immediately + WiFi.onEvent(WiFiEvent); + + if (!ETH.begin(ch390_conf)) + return false; + + applyEthStaticIp(); +#if !MESHTASTIC_EXCLUDE_WEBSERVER + createSSLCert(); // For WebServer +#endif + new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); + return true; } #endif @@ -155,7 +180,7 @@ static void onNetworkConnected() // start mdns if (!MDNS.begin("Meshtastic")) { - LOG_ERROR("Error setting up mDNS responder!"); + LOG_ERROR("mDNS setup failed"); } else { LOG_INFO("mDNS Host: Meshtastic.local"); MDNS.addService("meshtastic", "tcp", SERVER_API_DEFAULT_PORT); @@ -269,8 +294,8 @@ 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()) { - LOG_DEBUG("NTP Request Success - Setting RTCQualityNTP if needed"); + if (timeClient.forceUpdate()) { + LOG_DEBUG("NTP success - set RTCQualityNTP if needed"); struct timeval tv; tv.tv_sec = timeClient.getEpochTime(); @@ -291,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 } } @@ -477,7 +506,7 @@ static void WiFiEvent(WiFiEvent_t event) } break; case ARDUINO_EVENT_WIFI_STA_AUTHMODE_CHANGE: - LOG_INFO("Authentication mode of access point has changed"); + LOG_INFO("AP auth mode changed"); break; case ARDUINO_EVENT_WIFI_STA_GOT_IP: LOG_INFO("Obtained IP address: %s", WiFi.localIP().toString().c_str()); @@ -493,7 +522,7 @@ static void WiFiEvent(WiFiEvent_t event) #endif break; case ARDUINO_EVENT_WIFI_STA_LOST_IP: - LOG_INFO("Lost IP address and IP address is reset to 0"); + LOG_INFO("Lost IP address, reset to 0"); #if HAS_UDP_MULTICAST if (udpHandler) { udpHandler->stop(); @@ -507,19 +536,19 @@ static void WiFiEvent(WiFiEvent_t event) } break; case ARDUINO_EVENT_WPS_ER_SUCCESS: - LOG_INFO("WiFi Protected Setup (WPS): succeeded in enrollee mode"); + LOG_INFO("WPS: succeeded in enrollee mode"); break; case ARDUINO_EVENT_WPS_ER_FAILED: - LOG_INFO("WiFi Protected Setup (WPS): failed in enrollee mode"); + LOG_INFO("WPS: failed in enrollee mode"); break; case ARDUINO_EVENT_WPS_ER_TIMEOUT: - LOG_INFO("WiFi Protected Setup (WPS): timeout in enrollee mode"); + LOG_INFO("WPS: timeout in enrollee mode"); break; case ARDUINO_EVENT_WPS_ER_PIN: - LOG_INFO("WiFi Protected Setup (WPS): pin code in enrollee mode"); + LOG_INFO("WPS: pin code in enrollee mode"); break; case ARDUINO_EVENT_WPS_ER_PBC_OVERLAP: - LOG_INFO("WiFi Protected Setup (WPS): push button overlap in enrollee mode"); + LOG_INFO("WPS: push button overlap in enrollee mode"); break; case ARDUINO_EVENT_WIFI_AP_START: LOG_INFO("WiFi access point started"); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 410ce8fed2..80bb799036 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -151,7 +151,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta LOG_INFO("Ignore admin response from 0x%08x, no outstanding request", mp.from); return handled; } - LOG_DEBUG("Allow admin response message"); + LOG_TRACE("Allow admin response message"); } else if (mp.from == 0) { // Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the // mesh: RF drops packets without a sender (RadioLibInterface) and MQTT treats @@ -170,13 +170,13 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta // apply: refuse all plain local admin and require PKC instead. #ifndef MESHTASTIC_PHONEAPI_ACCESS_CONTROL if (config.security.is_managed) { - LOG_INFO("Ignore local admin payload because is_managed"); + LOG_INFO("Ignore local admin payload: is_managed"); return handled; } #endif } else if (strcasecmp(ch->settings.name, Channels::adminChannel) == 0) { if (!config.security.admin_channel_enabled) { - LOG_INFO("Ignore admin channel, legacy admin is disabled"); + LOG_INFO("Ignore admin channel, legacy admin disabled"); myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp); return handled; } @@ -206,10 +206,10 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) { // Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it // without the user doing so deliberately. - LOG_INFO("PKC admin valid, but not auto-favoriting node %x because role==CLIENT_BASE", mp.from); + LOG_INFO("PKC admin valid, not auto-favoriting node 0x%08x: role==CLIENT_BASE", mp.from); } else { if (nodeDB->setProtectedFlag(remoteNode, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) { - LOG_INFO("PKC admin valid. Auto-favoriting node %x", mp.from); + LOG_INFO("PKC admin valid. Auto-favoriting node 0x%08x", mp.from); } else { LOG_WARN("PKC admin valid, but auto-favorite refused for node %x (protected-node cap)", mp.from); } @@ -217,7 +217,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } } else { myReply = allocErrorResponse(meshtastic_Routing_Error_ADMIN_PUBLIC_KEY_UNAUTHORIZED, &mp); - LOG_INFO("Received PKC admin payload, but the sender public key does not match the admin authorized key!"); + LOG_INFO("PKC admin payload: sender public key doesn't match admin authorized key"); return handled; } } else { @@ -232,7 +232,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta // any message that changes state, we want to check the passkey for if (mp.from != 0 && !messageIsRequest(r) && !messageIsResponse(r)) { if (!checkPassKey(r)) { - LOG_WARN("Admin message without session_key!"); + LOG_WARN("Admin message without session_key"); myReply = allocErrorResponse(meshtastic_Routing_Error_ADMIN_BAD_SESSION_KEY, &mp); return handled; } @@ -249,7 +249,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta // but if it ever does (e.g. injected via a non-PhoneAPI path), drop // it silently rather than leaking a partial response. case meshtastic_AdminMessage_lockdown_auth_tag: - LOG_WARN("AdminModule: lockdown_auth reached Router/AdminModule path; ignoring (should be handled in PhoneAPI)"); + LOG_WARN("AdminModule: lockdown_auth reached Router/AdminModule path; ignoring (PhoneAPI handles)"); return handled; #endif // MESHTASTIC_ENCRYPTED_STORAGE @@ -293,7 +293,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta while (*start && isspace((unsigned char)*start)) start++; if (*start == '\0') { - LOG_WARN("Rejected long_name: must contain at least 1 non-whitespace character"); + LOG_WARN("Rejected long_name: needs 1+ non-whitespace char"); myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; } @@ -303,7 +303,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta while (*start && isspace((unsigned char)*start)) start++; if (*start == '\0') { - LOG_WARN("Rejected short_name: must contain at least 1 non-whitespace character"); + LOG_WARN("Rejected short_name: needs 1+ non-whitespace char"); myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; } @@ -336,7 +336,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } - LOG_WARN("Radio hardware does not support 2.4 GHz; rejecting LORA_24 region"); + LOG_WARN("No 2.4 GHz radio support; rejecting LORA_24 region"); myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; } @@ -479,14 +479,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_begin_edit_settings_tag: { - LOG_INFO("Begin transaction for editing settings"); + LOG_INFO("Begin settings edit transaction"); hasOpenEditTransaction = true; editTransactionActivityMs = millis(); break; } case meshtastic_AdminMessage_commit_edit_settings_tag: { disableBluetooth(); - LOG_INFO("Commit transaction for edited settings"); + LOG_INFO("Commit settings edit transaction"); hasOpenEditTransaction = false; deferredEditSegments = 0; saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE); @@ -499,7 +499,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_get_module_config_response_tag: { - LOG_INFO("Client received a get_module_config response"); + LOG_INFO("Client got get_module_config response"); // which_payload_variant is the ModuleConfig oneof tag, so compare against that tag, not the // AdminMessage ModuleConfigType enum (whose REMOTEHARDWARE value is a different number). if (fromOthers && r->get_module_config_response.which_payload_variant == meshtastic_ModuleConfig_remote_hardware_tag) { @@ -508,17 +508,17 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_remove_by_nodenum_tag: { - LOG_INFO("Client received remove_nodenum command"); + LOG_INFO("Client got remove_nodenum"); nodeDB->removeNodeByNum(r->remove_by_nodenum); break; } case meshtastic_AdminMessage_add_contact_tag: { - LOG_INFO("Client received add_contact command"); + LOG_INFO("Client got add_contact"); nodeDB->addFromContact(r->add_contact); break; } case meshtastic_AdminMessage_set_favorite_node_tag: { - LOG_INFO("Client received set_favorite_node command"); + LOG_INFO("Client got set_favorite_node"); meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_favorite_node); if (node != NULL) { if (nodeDB->setProtectedFlag(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) { @@ -534,7 +534,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_remove_favorite_node_tag: { - LOG_INFO("Client received remove_favorite_node command"); + LOG_INFO("Client got remove_favorite_node"); meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_favorite_node); if (node != NULL) { nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false); @@ -545,7 +545,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_set_ignored_node_tag: { - LOG_INFO("Client received set_ignored_node command"); + LOG_INFO("Client got set_ignored_node"); // Unlike the sibling node-targeted admin commands, create the entry if // it's absent so the block sticks for a node we've not heard from yet // (e.g. one a remote admin asks us to block) with no NodeInfo or key. @@ -570,7 +570,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_remove_ignored_node_tag: { - LOG_INFO("Client received remove_ignored_node command"); + LOG_INFO("Client got remove_ignored_node"); meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_ignored_node); if (node != NULL) { nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, false); @@ -579,7 +579,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_toggle_muted_node_tag: { - LOG_INFO("Client received toggle_muted_node command"); + LOG_INFO("Client got toggle_muted_node"); meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->toggle_muted_node); if (node != NULL) { nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_MUTED_MASK, !nodeInfoLiteIsMuted(node)); @@ -589,7 +589,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } case meshtastic_AdminMessage_set_fixed_position_tag: { - LOG_INFO("Client received set_fixed_position command"); + LOG_INFO("Client got set_fixed_position"); const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); // Route the fixed position through updatePosition so it lands in the // satellite map (or, on builds with PositionDB excluded, just sets @@ -607,14 +607,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_remove_fixed_position_tag: { - LOG_INFO("Client received remove_fixed_position command"); + LOG_INFO("Client got remove_fixed_position"); nodeDB->clearLocalPosition(); config.position.fixed_position = false; saveChanges(SEGMENT_NODEDATABASE | SEGMENT_CONFIG, false); break; } case meshtastic_AdminMessage_set_time_only_tag: { - LOG_INFO("Client received set_time_only command"); + LOG_INFO("Client got set_time_only"); struct timeval tv; tv.tv_sec = r->set_time_only; tv.tv_usec = 0; @@ -623,7 +623,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_enter_dfu_mode_request_tag: { - LOG_INFO("Client requesting to enter DFU mode"); + LOG_INFO("Client requests DFU mode"); #if HAS_SCREEN IF_SCREEN(screen->showSimpleBanner("Device is rebooting\ninto DFU mode.", 0)); #endif @@ -633,21 +633,21 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_delete_file_request_tag: { - LOG_DEBUG("Client requesting to delete file: %s", r->delete_file_request); + LOG_DEBUG("Client requests delete file: %s", r->delete_file_request); #ifdef FSCom spiLock->lock(); if (FSCom.remove(r->delete_file_request)) { - LOG_DEBUG("Successfully deleted file"); + LOG_DEBUG("Deleted file"); } else { - LOG_DEBUG("Failed to delete file"); + LOG_DEBUG("File delete failed"); } spiLock->unlock(); #endif break; } case meshtastic_AdminMessage_backup_preferences_tag: { - LOG_INFO("Client requesting to backup preferences"); + LOG_INFO("Client requests preferences backup"); if (nodeDB->backupPreferences(r->backup_preferences)) { myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp); } else { @@ -656,11 +656,11 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_restore_preferences_tag: { - LOG_INFO("Client requesting to restore preferences"); + LOG_INFO("Client requests preferences restore"); if (nodeDB->restorePreferences(r->backup_preferences, SEGMENT_DEVICESTATE | SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_CHANNELS)) { myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp); - LOG_DEBUG("Rebooting after successful restore of preferences"); + LOG_DEBUG("Rebooting after preferences restore"); reboot(1000); disableBluetooth(); } else { @@ -669,7 +669,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_remove_backup_preferences_tag: { - LOG_INFO("Client requesting to remove backup preferences"); + LOG_INFO("Client requests preferences backup removal"); #ifdef FSCom if (r->remove_backup_preferences == meshtastic_AdminMessage_BackupLocation_FLASH) { spiLock->lock(); @@ -683,7 +683,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_send_input_event_tag: { - LOG_INFO("Client requesting to send input event"); + LOG_INFO("Client requests send input event"); handleSendInputEvent(r->send_input_event); break; } @@ -721,10 +721,10 @@ void AdminModule::handleViaModuleApi(const meshtastic_MeshPacket &mp, meshtastic setPassKey(&res); myReply = allocDataProtobuf(res); } else if (mp.decoded.want_response) { - LOG_DEBUG("Module API did not respond to admin message. req.variant=%d", r->which_payload_variant); + LOG_DEBUG("Module API didn't respond to admin msg. req.variant=%d", r->which_payload_variant); } else if (handleResult != AdminMessageHandleResult::HANDLED) { // Probably a message sent by us or sent to our local node. FIXME, we should avoid scanning these messages - LOG_DEBUG("Module API did not handle admin message %d", r->which_payload_variant); + LOG_DEBUG("Module API didn't handle admin msg %d", r->which_payload_variant); } } @@ -912,7 +912,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) changes |= SEGMENT_NODEDATABASE | SEGMENT_DEVICESTATE; // Some role defaults affect owner } if (config.device.node_info_broadcast_secs < min_node_info_broadcast_secs) { - LOG_DEBUG("Tried to set node_info_broadcast_secs too low, setting to %d", min_node_info_broadcast_secs); + LOG_DEBUG("node_info_broadcast_secs too low, set to %d", min_node_info_broadcast_secs); config.device.node_info_broadcast_secs = min_node_info_broadcast_secs; } // Router Client and Repeater deprecated; Set it to client @@ -965,7 +965,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) config.power = c.payload_variant.power; if (c.payload_variant.power.on_battery_shutdown_after_secs > 0 && c.payload_variant.power.on_battery_shutdown_after_secs < 30) { - LOG_WARN("Tried to set on_battery_shutdown_after_secs too low, set to min 30 seconds"); + LOG_WARN("on_battery_shutdown_after_secs too low, set to min 30 sec"); config.power.on_battery_shutdown_after_secs = 30; } break; @@ -1007,12 +1007,12 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) config.has_lora = true; if (validatedLora.coding_rate != clampCodingRate(validatedLora.coding_rate)) { - LOG_WARN("Invalid coding_rate %d, setting to %d", validatedLora.coding_rate, LORA_CR_DEFAULT); + LOG_WARN("Invalid coding_rate %d, set to %d", validatedLora.coding_rate, LORA_CR_DEFAULT); validatedLora.coding_rate = LORA_CR_DEFAULT; } if (validatedLora.spread_factor != clampSpreadFactor(validatedLora.spread_factor)) { - LOG_WARN("Invalid spread_factor %d, setting to %d", validatedLora.spread_factor, LORA_SF_DEFAULT); + LOG_WARN("Invalid spread_factor %d, set to %d", validatedLora.spread_factor, LORA_SF_DEFAULT); validatedLora.spread_factor = LORA_SF_DEFAULT; } @@ -1023,7 +1023,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // preset mode bandwidth 0 is expected (the preset supplies it), so leave it untouched. const uint16_t clampedBandwidth = clampBandwidthCode(validatedLora.bandwidth); if (!validatedLora.use_preset && validatedLora.bandwidth != clampedBandwidth) { - LOG_WARN("Invalid bandwidth %d, setting to %d", validatedLora.bandwidth, clampedBandwidth); + LOG_WARN("Invalid bandwidth %d, set to %d", validatedLora.bandwidth, clampedBandwidth); validatedLora.bandwidth = clampedBandwidth; } @@ -1085,13 +1085,13 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) validatedLora.region = swapRegion->code; } if (!swapRegion || !RadioInterface::validateConfigLora(validatedLora)) { - LOG_WARN("Invalid LoRa config received from another node, rejecting changes"); + LOG_WARN("Invalid LoRa config from another node, rejecting changes"); // Rejecting means rejecting everything: a partial restore of region/preset // could still apply other fields the validation already deemed invalid. validatedLora = oldLoraConfig; } } else { - LOG_WARN("Invalid LoRa config received from client, using corrected values"); + LOG_WARN("Invalid LoRa config from client, using corrected values"); RadioInterface::clampConfigLora(validatedLora); } // A preset locked to a sibling EU region swaps the region during the clamp; @@ -1138,7 +1138,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) loraFEMInterface.setLNAEnable(validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED); } else if (validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) { // Hardware FEM does not support LNA control; normalize stored config to match actual capability - LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT"); + LOG_WARN("FEM LNA mode set but FEM lacks LNA control; normalizing to NOT_PRESENT"); validatedLora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT; } #endif @@ -1173,7 +1173,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // partial/legacy client, not an identity reset (that goes through factory_reset). Done outside the // PKI guard so non-PKI builds keep their key bytes too. if (incoming.private_key.size != 32 && config.security.private_key.size == 32) { - LOG_WARN("Security set omitted private key; preserving existing identity keypair"); + LOG_WARN("Security set omitted private key; keeping identity keypair"); incoming.private_key = config.security.private_key; incoming.public_key = config.security.public_key; } @@ -1181,7 +1181,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // recourse but a physical connection. Clearing admin keys still works via a SET that leaves the private // key alone and sends an empty list. if (isBareKeypairRotation(incoming, config.security)) { - LOG_INFO("Security set is a bare keypair rotation; preserving remaining security config"); + LOG_INFO("Security set is bare keypair rotation; keeping other security config"); meshtastic_Config_SecurityConfig rotated = config.security; rotated.public_key = incoming.public_key; // usually empty; derived from the private key below rotated.private_key = incoming.private_key; @@ -1249,7 +1249,7 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) switch (c.which_payload_variant) { case meshtastic_ModuleConfig_mqtt_tag: #if MESHTASTIC_EXCLUDE_MQTT - LOG_WARN("Set module config: MESHTASTIC_EXCLUDE_MQTT is defined. Not setting MQTT config"); + LOG_WARN("Set module config: MESHTASTIC_EXCLUDE_MQTT defined, skip MQTT config"); return false; #else LOG_INFO("Set module config: MQTT"); @@ -1319,7 +1319,7 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) moduleConfig.has_neighbor_info = true; moduleConfig.neighbor_info = c.payload_variant.neighbor_info; if (moduleConfig.neighbor_info.update_interval < min_neighbor_info_broadcast_secs) { - LOG_DEBUG("Tried to set update_interval too low, setting to %d", default_neighbor_info_broadcast_secs); + LOG_DEBUG("update_interval too low, set to %d", default_neighbor_info_broadcast_secs); moduleConfig.neighbor_info.update_interval = default_neighbor_info_broadcast_secs; } break; @@ -1902,7 +1902,7 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot) LOG_INFO("Save changes to disk"); service->reloadConfig(saveWhat); // Calls saveToDisk among other things } else { - LOG_INFO("Delay save of changes to disk until the open transaction is committed"); + LOG_INFO("Delay disk save until open transaction commits"); editTransactionActivityMs = millis(); // still in use, so not the abandoned kind we time out deferredEditSegments |= saveWhat; } @@ -1929,7 +1929,7 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) while (*start && isspace((unsigned char)*start)) start++; if (*start == '\0') { - LOG_WARN("Rejected ham %s: must contain at least 1 non-whitespace character", fieldNames[i]); + LOG_WARN("Rejected ham %s: needs 1+ non-whitespace char", fieldNames[i]); return; } } @@ -2116,7 +2116,7 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p) memcpy(slot->key, destKey.bytes, 32); else memset(slot->key, 0, 32); - LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to); + LOG_DEBUG("Admin request sent to 0x%08x, expect response", p.to); } bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag) @@ -2168,7 +2168,7 @@ bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r) void AdminModule::handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent) { - LOG_DEBUG("Processing input event: event_code=%u, kb_char=%u, touch_x=%u, touch_y=%u", inputEvent.event_code, + LOG_TRACE("Processing input event: event_code=%u, kb_char=%u, touch_x=%u, touch_y=%u", inputEvent.event_code, inputEvent.kb_char, inputEvent.touch_x, inputEvent.touch_y); // Create InputEvent for injection. @@ -2195,7 +2195,7 @@ void AdminModule::handleSendInputEvent(const meshtastic_AdminMessage_InputEvent if (inputBroker) { inputBroker->injectInputEvent(&event); } else { - LOG_ERROR("InputBroker not available for event injection"); + LOG_ERROR("No InputBroker for event injection"); } #endif } diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 00aa786b2c..0013012751 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -71,7 +71,7 @@ CannedMessageModule::CannedMessageModule() { this->loadProtoForModule(); if ((this->splitConfiguredMessages() <= 0) && (cardkb_found.address == 0x00) && !INPUTBROKER_MATRIX_TYPE) { - LOG_INFO("CannedMessageModule: No messages are configured. Module is disabled"); + LOG_INFO("CannedMessage: none configured, disabled"); this->updateState(CANNED_MESSAGE_RUN_STATE_DISABLED); disable(); } else { @@ -112,7 +112,7 @@ void CannedMessageModule::LaunchWithDestination(NodeNum newDest, uint8_t newChan e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; notifyObservers(&e); - LOG_DEBUG("[CannedMessage] LaunchWithDestination dest=0x%08x ch=%d", dest, channel); + LOG_TRACE("[CannedMessage] LaunchWithDestination dest=0x%08x ch=%d", dest, channel); } void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t newChannel) @@ -135,7 +135,7 @@ void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; notifyObservers(&e); - LOG_DEBUG("[CannedMessage] LaunchFreetextWithDestination dest=0x%08x ch=%d", dest, channel); + LOG_TRACE("[CannedMessage] LaunchFreetextWithDestination dest=0x%08x ch=%d", dest, channel); } static bool returnToCannedList = false; @@ -294,7 +294,7 @@ void CannedMessageModule::updateDestinationSelectionList() scrollIndex = 0; // Show first result at the top destIndex = 0; // Highlight the first entry if (nodesChanged && runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) { - LOG_INFO("Nodes changed, forcing UI refresh."); + LOG_INFO("Nodes changed, forcing UI refresh"); screen->forceDisplay(); } } @@ -891,8 +891,8 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event) // Confirm select (Enter) bool isSelect = isSelectEvent(event); if (isSelect) { - LOG_DEBUG("[SELECT] handleFreeTextInput: runState=%d, dest=%u, channel=%d, freetext='%s'", (int)runState, dest, channel, - freetext.c_str()); + LOG_TRACE("[SELECT] handleFreeTextInput: runState=%d, dest=0x%08x, channel=%d, freetext='%s'", (int)runState, dest, + channel, freetext.c_str()); if (dest == 0) dest = NODENUM_BROADCAST; // Defensive: If channel isn't valid, pick the first available channel @@ -1108,10 +1108,10 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha if (config.device.role != meshtastic_Config_DeviceConfig_Role_ROUTER && config.device.role != meshtastic_Config_DeviceConfig_Role_ROUTER_LATE && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) { - LOG_INFO("Proactively adding %x as favorite node", dest); + LOG_INFO("Proactively adding 0x%08x as favorite node", dest); nodeDB->set_favorite(true, dest); } else { - LOG_DEBUG("Not favoriting node %x because role is router-like", dest); + LOG_DEBUG("Not favoriting node 0x%08x: router-like role", dest); } } sm.ackStatus = AckStatus::NONE; @@ -1163,12 +1163,12 @@ int32_t CannedMessageModule::runOnce() if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) { // Virtual keyboard message sending case - text was not empty if (this->freetext.length() > 0) { - LOG_INFO("Processing delayed virtual keyboard send: '%s'", this->freetext.c_str()); + LOG_INFO("Delayed vkbd send: '%s'", this->freetext.c_str()); sendText(this->dest, this->channel, this->freetext.c_str(), true); // Clean up virtual keyboard after sending if (graphics::NotificationRenderer::virtualKeyboard) { - LOG_INFO("Cleaning up virtual keyboard after message send"); + LOG_INFO("Vkbd cleanup after send"); graphics::OnScreenKeyboardModule::instance().stop(false); graphics::NotificationRenderer::resetBanner(); } @@ -1178,7 +1178,7 @@ int32_t CannedMessageModule::runOnce() this->payload = 0; } else { // Empty message, just go inactive - LOG_INFO("Empty freetext detected in delayed processing, returning to inactive state"); + LOG_INFO("Empty freetext, back to inactive"); this->updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE); } @@ -1221,7 +1221,7 @@ int32_t CannedMessageModule::runOnce() // Clean up virtual keyboard if it exists during timeout if (graphics::NotificationRenderer::virtualKeyboard) { - LOG_INFO("Cleaning up virtual keyboard due to module timeout"); + LOG_INFO("Vkbd cleanup on timeout"); graphics::OnScreenKeyboardModule::instance().stop(false); graphics::NotificationRenderer::resetBanner(); } @@ -1230,7 +1230,7 @@ int32_t CannedMessageModule::runOnce() } else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT) { if (this->payload == 0) { // [Exit] button pressed - return to inactive state - LOG_INFO("Processing [Exit] action - returning to inactive state"); + LOG_INFO("Exit action, back to inactive"); this->updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE); } else if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) { if (this->freetext.length() > 0) { @@ -2336,7 +2336,6 @@ AdminMessageHandleResult CannedMessageModule::handleAdminMessageForModule(const void CannedMessageModule::handleGetCannedMessageModuleMessages(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response) { - LOG_DEBUG("*** handleGetCannedMessageModuleMessages"); if (req.decoded.want_response) { response->which_payload_variant = meshtastic_AdminMessage_get_canned_message_module_messages_response_tag; strncpy(response->get_canned_message_module_messages_response, cannedMessageModuleConfig.messages, @@ -2351,7 +2350,7 @@ void CannedMessageModule::handleSetCannedMessageModuleMessages(const char *from_ if (*from_msg) { changed |= strcmp(cannedMessageModuleConfig.messages, from_msg); strncpy(cannedMessageModuleConfig.messages, from_msg, sizeof(cannedMessageModuleConfig.messages)); - LOG_DEBUG("*** from_msg.text:%s", from_msg); + LOG_TRACE("*** from_msg.text:%s", from_msg); } if (changed) { 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/DropzoneModule.cpp b/src/modules/DropzoneModule.cpp index 16bd838496..100b87662c 100644 --- a/src/modules/DropzoneModule.cpp +++ b/src/modules/DropzoneModule.cpp @@ -12,6 +12,7 @@ #include "modules/Telemetry/Sensor/DFRobotLarkSensor.h" #include "modules/Telemetry/UnitConversions.h" +#include "mesh/Throttle.h" #include DropzoneModule *dropzoneModule; @@ -19,7 +20,7 @@ DropzoneModule *dropzoneModule; int32_t DropzoneModule::runOnce() { // Send on a 5 second delay from receiving the matching request - if (startSendConditions != 0 && (startSendConditions + 5000U) < millis()) { + if (startSendConditions != 0 && Throttle::hasElapsed(startSendConditions, 5000U)) { service->sendToMesh(sendConditions(), RX_SRC_LOCAL); startSendConditions = 0; } diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 98feda782c..420697689f 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -21,6 +21,7 @@ #include "configuration.h" #include "gps/RTC.h" #include "main.h" +#include "mesh/Throttle.h" #include "mesh/generated/meshtastic/rtttl.pb.h" #include @@ -85,7 +86,10 @@ int32_t ExternalNotificationModule::runOnce() #if defined(HAS_I2S_SPEAKER_NRF52) isRtttlPlaying = isRtttlPlaying || nrf52RtttlPlayer.isPlaying(); #endif - if ((nagCycleCutoff < millis()) && !isRtttlPlaying) { + // isNagging is the armed flag; nagCycleCutoff holds a real deadline only while it is set + // (UINT32_MAX once stopped, 1 at boot), so short-circuit before the comparison. + const bool nagWindowExpired = !isNagging || Throttle::deadlinePassed(nagCycleCutoff); + if (nagWindowExpired && !isRtttlPlaying) { // Turn off external notification immediately when timeout is reached, regardless of song state nagCycleCutoff = UINT32_MAX; ExternalNotificationModule::stopNow(); @@ -97,14 +101,15 @@ int32_t ExternalNotificationModule::runOnce() if (isNagging) { delay = (moduleConfig.external_notification.output_ms ? moduleConfig.external_notification.output_ms : EXT_NOTIFICATION_MODULE_OUTPUT_MS); - if (externalTurnedOn[0] + delay < millis()) { + // externalTurnedOn[] is when each output was last toggled, so these are intervals. + if (Throttle::hasElapsed(externalTurnedOn[0], delay)) { setExternalState(0, !getExternal(0)); } - if (externalTurnedOn[1] + delay < millis()) { + if (Throttle::hasElapsed(externalTurnedOn[1], delay)) { setExternalState(1, !getExternal(1)); } // Only toggle buzzer output if not using PWM mode (to avoid conflict with RTTTL) - if (!moduleConfig.external_notification.use_pwm && externalTurnedOn[2] + delay < millis()) { + if (!moduleConfig.external_notification.use_pwm && Throttle::hasElapsed(externalTurnedOn[2], delay)) { LOG_DEBUG("EXTERNAL 2 %d compared to %d", externalTurnedOn[2] + moduleConfig.external_notification.output_ms, millis()); setExternalState(2, !getExternal(2)); @@ -146,7 +151,7 @@ int32_t ExternalNotificationModule::runOnce() if (moduleConfig.external_notification.use_i2s_as_buzzer) { if (audioThread->isPlaying()) { // Continue playing - } else if (isNagging && (nagCycleCutoff >= millis())) { + } else if (isNagging && !Throttle::deadlinePassed(nagCycleCutoff)) { audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); } // we need fast updates to play the RTTTL @@ -158,7 +163,7 @@ int32_t ExternalNotificationModule::runOnce() if (canBuzz() && buzzerShouldAlert) { if (nrf52RtttlPlayer.isPlaying()) { nrf52RtttlPlayer.play(); - } else if (isNagging && (nagCycleCutoff >= millis())) { + } else if (isNagging && !Throttle::deadlinePassed(nagCycleCutoff)) { nrf52RtttlPlayer.begin(rtttlConfig.ringtone); } delay = EXT_NOTIFICATION_FAST_THREAD_MS; @@ -168,7 +173,7 @@ int32_t ExternalNotificationModule::runOnce() if (moduleConfig.external_notification.use_pwm && config.device.buzzer_gpio && canBuzz() && buzzerShouldAlert) { if (rtttl::isPlaying()) { rtttl::play(); - } else if (isNagging && (nagCycleCutoff >= millis())) { + } else if (isNagging && !Throttle::deadlinePassed(nagCycleCutoff)) { // start the song again if we have time left rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); } @@ -476,7 +481,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP if (buzzerShouldAlert) { LOG_INFO("externalNotificationModule - Buzzer alert"); if (buzzerModeIsDirectOnly && !isDmToUs && !containsBell) { - LOG_INFO("Message buzzer was suppressed because buzzer mode DIRECT_MSG_ONLY"); + LOG_INFO("Buzzer suppressed: mode DIRECT_MSG_ONLY"); } else { // Buzz if buzzer mode is not in DIRECT_MSG_ONLY or is DM to us if (moduleConfig.external_notification.use_i2s_as_buzzer) { diff --git a/src/modules/HopScalingModule.cpp b/src/modules/HopScalingModule.cpp index 1045e4f021..758715f971 100644 --- a/src/modules/HopScalingModule.cpp +++ b/src/modules/HopScalingModule.cpp @@ -182,7 +182,7 @@ void HopScalingModule::samplePacketForHistogram(uint32_t nodeId, uint8_t hopCoun this->count++; } else { LOG_WARN("[HOPSCALE] Histogram full at samp=1/%u (DENOM_MAX=%u); dropping node hash=0x%04x; hop recommendation may be " - "skewed!!!", + "skewed!!", samplingDenominator, DENOM_MAX, hash); } } diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index f0a1dc8fb0..eb6c496413 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -116,7 +116,7 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & memset(message, 0, sizeof(message)); sprintf(message, "Verification: \n"); generateVerificationCode(message + 15); - LOG_INFO("Hash1 matches!"); + LOG_INFO("Hash1 matches"); static const char *optionsArray[] = {"Reject", "Accept"}; // Don't try to put the array definition in the macro. Does not work with curly braces. IF_SCREEN(graphics::BannerOverlayOptions options; options.message = message; options.durationMs = 30000; diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index cc5c6e807b..a747621838 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -198,7 +198,7 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ // transmit on it; the radio driver drops the packet outright (see RadioLibInterface, // beaconTxConfigInvalid) rather than letting it fall through onto the current config. if (beaconTxConfigInvalid(p)) { - LOG_DEBUG("Beacon: target preset %d/region %d invalid (or ham mismatch), not switching", targetPreset, targetRegion); + LOG_DEBUG("Beacon: target preset %d/region %d invalid (or ham mismatch), skip", targetPreset, targetRegion); return false; } @@ -228,7 +228,7 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ } else if ((!p || !getTargetRadioSettings(p, nullptr, nullptr)) && radioSwitched) { - LOG_INFO("Beacon: restoring radio config after beacon TX"); + LOG_INFO("Beacon: restore radio config after TX"); config.lora.modem_preset = originalModemPreset; config.lora.channel_num = originalLoraChannel; config.lora.region = originalRegion; @@ -315,7 +315,7 @@ void MeshBeaconBroadcastModule::sendBeacon() (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET); if (!hasText && !hasRadioContent) { - LOG_DEBUG("Beacon: nothing to send (empty message, no offer), skipping"); + LOG_DEBUG("Beacon: empty msg, no offer, skip"); return; } @@ -375,7 +375,7 @@ void MeshBeaconBroadcastModule::sendBeacon() offerOnly.offer_region = bcfg.broadcast_offer_region; offerSize = (pb_size_t)pb_encode_to_bytes(offerBuf, sizeof(offerBuf), &meshtastic_MeshBeacon_msg, &offerOnly); if (offerSize == 0) - LOG_WARN("Beacon: offer encode failed, skipping offer packet(s)"); + LOG_WARN("Beacon: offer encode failed, skip"); } if (sendCombined && payloadCacheDirty) rebuildCache(); @@ -429,8 +429,7 @@ void MeshBeaconBroadcastModule::sendBeacon() tgt.slot = config.lora.channel_num; if (bt.has_channel_index) { if (bt.channel_index >= (uint32_t)channels.getNumChannels()) { - LOG_WARN("Beacon: target %d channel_index %u out of range, using default channel for preset", ti, - bt.channel_index); + LOG_WARN("Beacon: target %d channel_index %u out of range, use preset default", ti, bt.channel_index); } else { const meshtastic_ChannelSettings &cs = channels.getByIndex(bt.channel_index).settings; if (cs.name[0] != '\0' || cs.psk.size > 0) { @@ -438,8 +437,7 @@ void MeshBeaconBroadcastModule::sendBeacon() tgt.channel = cs; tgt.slot = cs.channel_num; } else { - LOG_DEBUG("Beacon: target %d channel_index %u is a blank slot, using default channel for preset", ti, - bt.channel_index); + LOG_DEBUG("Beacon: target %d channel_index %u blank, use preset default", ti, bt.channel_index); } } } @@ -463,7 +461,7 @@ void MeshBeaconBroadcastModule::sendBeacon() } } if (duplicate) { - LOG_DEBUG("Beacon: target %d duplicates an earlier target's radio config, skipping", ti); + LOG_DEBUG("Beacon: target %d dup radio config, skip", ti); continue; } sent[sentCount] = tgt; @@ -487,7 +485,7 @@ void MeshBeaconBroadcastModule::sendBeacon() if (sendOfferOnly && offerSize > 0) { meshtastic_MeshPacket *pA = allocDataPacket(); if (!pA) { - LOG_WARN("Beacon: failed to allocate split-A packet (target %d)", ti); + LOG_WARN("Beacon: split-A alloc failed (target %d)", ti); return; } memcpy(pA->decoded.payload.bytes, offerBuf, offerSize); @@ -501,7 +499,7 @@ void MeshBeaconBroadcastModule::sendBeacon() if (sendTextOnly) { meshtastic_MeshPacket *pB = allocDataPacket(); if (!pB) { - LOG_WARN("Beacon: failed to allocate split-B packet (target %d)", ti); + LOG_WARN("Beacon: split-B alloc failed (target %d)", ti); return; } pb_size_t msgLen = (pb_size_t)strnlen(bcfg.broadcast_message, sizeof(bcfg.broadcast_message) - 1); diff --git a/src/modules/NeighborInfoModule.cpp b/src/modules/NeighborInfoModule.cpp index f42194a81f..a05b09b0fa 100644 --- a/src/modules/NeighborInfoModule.cpp +++ b/src/modules/NeighborInfoModule.cpp @@ -14,11 +14,11 @@ NOTE: For debugging only */ void NeighborInfoModule::printNeighborInfo(const char *header, const meshtastic_NeighborInfo *np) { - LOG_DEBUG("%s NEIGHBORINFO PACKET from Node 0x%08x to Node 0x%08x (last sent by 0x%08x)", header, np->node_id, + LOG_TRACE("%s NEIGHBORINFO PACKET from Node 0x%08x to Node 0x%08x (last sent by 0x%08x)", header, np->node_id, nodeDB->getNodeNum(), np->last_sent_by_id); - LOG_DEBUG("Packet contains %d neighbors", np->neighbors_count); + LOG_TRACE("Packet contains %d neighbors", np->neighbors_count); for (int i = 0; i < np->neighbors_count; i++) { - LOG_DEBUG("Neighbor %d: node_id=0x%08x, snr=%.2f", i, np->neighbors[i].node_id, np->neighbors[i].snr); + LOG_TRACE("Neighbor %d: node_id=0x%08x, snr=%.2f", i, np->neighbors[i].node_id, np->neighbors[i].snr); } } @@ -28,9 +28,9 @@ NOTE: for debugging only */ void NeighborInfoModule::printNodeDBNeighbors() { - LOG_DEBUG("Our NodeDB contains %d neighbors", neighbors.size()); + LOG_TRACE("Our NodeDB contains %u neighbors", (unsigned)neighbors.size()); for (size_t i = 0; i < neighbors.size(); i++) { - LOG_DEBUG("Node %d: node_id=0x%08x, snr=%.2f", i, neighbors[i].node_id, neighbors[i].snr); + LOG_TRACE("Node %u: node_id=0x%08x, snr=%.2f", (unsigned)i, neighbors[i].node_id, neighbors[i].snr); } } @@ -138,7 +138,7 @@ int32_t NeighborInfoModule::runOnce() meshtastic_MeshPacket *NeighborInfoModule::allocReply() { - LOG_INFO("NeighborInfoRequested."); + LOG_INFO("NeighborInfoRequested"); if (lastSentReply && Throttle::isWithinTimespanMs(lastSentReply, 3 * 60 * 1000)) { LOG_DEBUG("Skip Neighbors reply since we sent a reply <3min ago"); ignoreRequest = true; // Mark it as ignored for MeshModule @@ -162,18 +162,18 @@ Pass it to an upper client; do not persist this data on the mesh */ bool NeighborInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_NeighborInfo *np) { - LOG_DEBUG("NeighborInfo: handleReceivedProtobuf"); + LOG_TRACE("NeighborInfo: handleReceivedProtobuf"); if (np) { printNeighborInfo("RECEIVED", np); // Ignore dummy/interceptable packets: single neighbor with nodeId 0 and snr 0 if (np->neighbors_count != 1 || np->neighbors[0].node_id != 0 || np->neighbors[0].snr != 0.0f) { - LOG_DEBUG(" Updating neighbours"); + LOG_TRACE(" Updating neighbours"); updateNeighbors(mp, np); } else { LOG_DEBUG(" Ignoring dummy neighbor info packet (single neighbor with nodeId 0, snr 0)"); } } else if (getHopsAway(mp) == 0) { - LOG_DEBUG("Get or create neighbor: %u with snr %f", mp.from, mp.rx_snr); + LOG_TRACE("Get or create neighbor: 0x%08x with snr %f", mp.from, mp.rx_snr); // If the hopLimit is the same as hopStart, then it is a neighbor getOrCreateNeighbor(mp.from, mp.from, 0, mp.rx_snr); // Set the broadcast interval to 0, as we don't know it @@ -202,7 +202,6 @@ void NeighborInfoModule::resetNeighbors() void NeighborInfoModule::updateNeighbors(const meshtastic_MeshPacket &mp, const meshtastic_NeighborInfo *np) { - LOG_DEBUG("updateNeighbors"); // The last sent ID will be 0 if the packet is from the phone, which we don't // count as an edge. So we assume that if it's zero, then this packet is from // our node. diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index 226dfda271..7c4096959c 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -34,22 +34,19 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes // Suppress replies to senders we've replied to recently (12H window) if (mp.decoded.want_response && !isFromUs(&mp)) { const NodeNum sender = getFrom(&mp); - // A local dedup window, not a wall-clock reading - uptime avoids RTC-quality jumps and - // replayed packets' stale rx_time perturbing it. - const uint32_t now = (uint32_t)(Time::getMillis64() / 1000); + // A local dedup window, not a wall-clock reading - uptime avoids RTC jumps and replayed + // packets' stale rx_time perturbing it. Seconds, not millis - this is a wide window. + const uint32_t nowSecs = Time::getUptimeSecs(); auto it = lastNodeInfoSeen.find(sender); - if (it != lastNodeInfoSeen.end()) { - uint32_t sinceLast = now >= it->second ? now - it->second : 0; - if (sinceLast < NodeInfoReplySuppressSeconds) { - suppressReplyForCurrentRequest = true; - } + if (it != lastNodeInfoSeen.end() && (uint32_t)(nowSecs - it->second) < NodeInfoReplySuppressSeconds) { + suppressReplyForCurrentRequest = true; } - lastNodeInfoSeen[sender] = now; + lastNodeInfoSeen[sender] = nowSecs; pruneLastNodeInfoCache(); } if (p.is_licensed != owner.is_licensed) { - LOG_WARN("Invalid nodeInfo detected, is_licensed mismatch!"); + LOG_WARN("Invalid nodeInfo detected, is_licensed mismatch"); return true; } NodeNum sourceNum = getFrom(&mp); @@ -193,19 +190,26 @@ void NodeInfoModule::pruneLastNodeInfoCache() return; const size_t maxEntries = nodeDB->meshNodes->size(); + const uint32_t nowSecs = Time::getUptimeSecs(); + // Drop entries for nodes we no longer know, and any stamp already past the suppression window: + // it can only decide "don't suppress", so keeping it buys nothing. for (auto it = lastNodeInfoSeen.begin(); it != lastNodeInfoSeen.end();) { - if (!nodeDB->getMeshNode(it->first)) { + if (!nodeDB->getMeshNode(it->first) || (uint32_t)(nowSecs - it->second) >= NodeInfoReplySuppressSeconds) { it = lastNodeInfoSeen.erase(it); } else { ++it; } } + // Evict by largest elapsed time rather than smallest stamp, so the victim is still the oldest + // entry if the uptime counter ever wraps underneath us. while (!lastNodeInfoSeen.empty() && lastNodeInfoSeen.size() > maxEntries) { - auto oldestIt = std::min_element(lastNodeInfoSeen.begin(), lastNodeInfoSeen.end(), - [](const std::pair &lhs, - const std::pair &rhs) { return lhs.second < rhs.second; }); + auto oldestIt = std::max_element( + lastNodeInfoSeen.begin(), lastNodeInfoSeen.end(), + [nowSecs](const std::pair &lhs, const std::pair &rhs) { + return (uint32_t)(nowSecs - lhs.second) < (uint32_t)(nowSecs - rhs.second); + }); lastNodeInfoSeen.erase(oldestIt); } } diff --git a/src/modules/NodeInfoModule.h b/src/modules/NodeInfoModule.h index 9b3b66caed..8653c71ebc 100644 --- a/src/modules/NodeInfoModule.h +++ b/src/modules/NodeInfoModule.h @@ -50,6 +50,8 @@ class NodeInfoModule : public ProtobufModule, private concurren private: bool shorterTimeout = false; bool suppressReplyForCurrentRequest = false; + /// Sender -> uptime seconds (Time::getUptimeSecs()) at our last reply. Seconds, not millis: + /// the suppression window is hours wide. See handleReceivedProtobuf(). std::map lastNodeInfoSeen; void pruneLastNodeInfoCache(); 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 9497861fcc..9ee985b156 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -10,6 +10,7 @@ #include "TypeConversions.h" #include "airtime.h" #include "configuration.h" +#include "gps/GPSLog.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" #include "main.h" @@ -68,7 +69,7 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes if (isFromUs(&mp)) { isLocal = true; if (config.position.fixed_position) { - LOG_DEBUG("Ignore incoming position update from myself except for time, because position.fixed_position is true"); + LOG_DEBUG("Ignore own position update except time: position.fixed_position true"); #ifdef T_WATCH_S3 // Since we return early if position.fixed_position is true, set the T-Watch's RTC to the time received from the @@ -81,13 +82,13 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes nodeDB->setLocalPosition(p, true); return false; } else { - LOG_DEBUG("Incoming update from MYSELF"); + LOG_TRACE("Incoming update from MYSELF"); nodeDB->setLocalPosition(p); } } // Log packet size and data fields - LOG_DEBUG("POSITION node=0x%08x l=%d lat=%d lon=%d msl=%d hae=%d geo=%d pdop=%d hdop=%d vdop=%d siv=%d fxq=%d fxt=%d pts=%d " + LOG_TRACE("POSITION node=0x%08x l=%d lat=%d lon=%d msl=%d hae=%d geo=%d pdop=%d hdop=%d vdop=%d siv=%d fxq=%d fxt=%d pts=%d " "time=%d", getFrom(&mp), mp.decoded.payload.size, p.latitude_i, p.longitude_i, p.altitude, p.altitude_hae, p.altitude_geoidal_separation, p.PDOP, p.HDOP, p.VDOP, p.sats_in_view, p.fix_quality, p.fix_type, p.timestamp, @@ -117,7 +118,7 @@ void PositionModule::alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic // Phone position packets need to be truncated to the channel precision if (isFromUs(&mp)) { if (precision == 0) - LOG_DEBUG("Strip phone position due to channel precision 0"); + LOG_DEBUG("Strip phone position: channel precision 0"); else if (precision < 32) LOG_DEBUG("Truncate phone position to channel precision %i", precision); applyPositionPrecision(*p, precision); @@ -129,11 +130,11 @@ void PositionModule::alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic void PositionModule::trySetRtc(meshtastic_Position p, bool isLocal, bool forceUpdate) { if (hasQualityTimesource() && !isLocal) { - LOG_DEBUG("Ignore time from mesh because we have a GPS, RTC, or Phone/NTP time source in the past day"); + LOG_DEBUG("Ignore time from mesh: GPS/RTC/Phone/NTP time source in past day"); return; } if (!isLocal && p.location_source < meshtastic_Position_LocSource_LOC_INTERNAL) { - LOG_DEBUG("Ignore time from mesh because it has a unknown or manual source"); + LOG_DEBUG("Ignore time from mesh: unknown or manual source"); return; } struct timeval tv; @@ -170,7 +171,7 @@ bool PositionModule::hasGPS() meshtastic_MeshPacket *PositionModule::allocPositionPacket(uint32_t atPrecision) { if (atPrecision == 0) { - LOG_DEBUG("Skip location send because precision is set to 0!"); + LOG_DEBUG("Skip location send: precision 0"); return nullptr; } @@ -191,7 +192,7 @@ meshtastic_MeshPacket *PositionModule::allocPositionPacket(uint32_t atPrecision) localPosition.seq_number++; if (localPosition.latitude_i == 0 && localPosition.longitude_i == 0) { - LOG_WARN("Skip position send because lat/lon are zero!"); + LOG_WARN("Skip position send: lat/lon zero"); return nullptr; } @@ -276,7 +277,7 @@ meshtastic_MeshPacket *PositionModule::allocReply() { if (config.device.role != meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND && lastSentReply && Throttle::isWithinTimespanMs(lastSentReply, 3 * 60 * 1000)) { - LOG_DEBUG("Skip Position reply since we sent a reply <3min ago"); + LOG_DEBUG("Skip Position reply: sent one <3min ago"); ignoreRequest = true; // Mark it as ignored for MeshModule return nullptr; } @@ -349,7 +350,7 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli() size_t proto_size = pb_encode_to_bytes(protobuf_bytes, sizeof(protobuf_bytes), &meshtastic_TAKPacketV2_msg, &takPacket); if (proto_size == 0) { - LOG_ERROR("Failed to encode TAK V2 PLI packet"); + LOG_ERROR("TAK V2 PLI packet encode failed"); packetPool.release(mp); return nullptr; } @@ -363,7 +364,7 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli() memcpy(mp->decoded.payload.bytes + 1, protobuf_bytes, proto_size); mp->decoded.payload.size = proto_size + 1; - LOG_DEBUG("TAK V2 PLI payload: %zu bytes (1 flags + %zu protobuf)", mp->decoded.payload.size, proto_size); + LOG_TRACE("TAK V2 PLI payload: %zu bytes (1 flags + %zu protobuf)", mp->decoded.payload.size, proto_size); return mp; } @@ -380,7 +381,7 @@ void PositionModule::sendOurPosition() return; } } - LOG_INFO("Skip pos@%x:6 broadcast; position sharing is opt-in and disabled on all channels", localPosition.timestamp); + LOG_INFO("Skip pos@%x:6 broadcast; position sharing disabled on all channels", localPosition.timestamp); } // Position broadcasts are opt-in per channel in 2.8, but our own position still plays to the @@ -506,7 +507,7 @@ int32_t PositionModule::runOnce() if (sleepOnNextExecution == true) { sleepOnNextExecution = false; uint32_t nightyNightMs = Default::getConfiguredOrDefaultMs(config.position.position_broadcast_secs); - LOG_DEBUG("Sleep for %ims, then awaking to send position again", nightyNightMs); + LOG_DEBUG("Sleep %ims, then wake to send position", nightyNightMs); doDeepSleep(nightyNightMs, false, false); } @@ -557,9 +558,7 @@ int32_t PositionModule::runOnce() if (lastGpsSend == 0 || msSinceLastSend >= effectiveIntervalMs) { if (waitingForFreshPosition) { -#ifdef GPS_DEBUG - LOG_DEBUG("Skip initial position send; no fresh position since boot"); -#endif + LOG_DEBUG_GPS("Skip initial position send; no fresh position since boot"); } else if (nodeDB->hasValidPosition(node)) { lastGpsSend = now; @@ -591,11 +590,7 @@ int32_t PositionModule::runOnce() if (smartPosition.hasTraveledOverThreshold && Throttle::execute( &lastGpsSend, minimumTimeThreshold, []() { positionModule->sendOurPosition(); }, - []() { -#ifdef GPS_DEBUG - LOG_DEBUG("Skip send smart broadcast due to time throttling"); -#endif - })) { + []() { LOG_DEBUG_GPS("Skip smart broadcast: time throttled"); })) { LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, " "minTimeInterval=%ims)", @@ -701,11 +696,7 @@ void PositionModule::handleNewPosition() if (smartPosition.hasTraveledOverThreshold && Throttle::execute( &lastGpsSend, minimumTimeThreshold, []() { positionModule->sendOurPosition(); }, - []() { -#ifdef GPS_DEBUG - LOG_DEBUG("Skip send smart broadcast due to time throttling"); -#endif - })) { + []() { LOG_DEBUG_GPS("Skip smart broadcast: time throttled"); })) { LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, " "minTimeInterval=%ims)", localPosition.timestamp, smartPosition.distanceTraveled, smartPosition.distanceThreshold, msSinceLastSend, diff --git a/src/modules/PowerStressModule.cpp b/src/modules/PowerStressModule.cpp index b818e88950..aca32cd694 100644 --- a/src/modules/PowerStressModule.cpp +++ b/src/modules/PowerStressModule.cpp @@ -125,7 +125,7 @@ int32_t PowerStressModule::runOnce() // FIXME - implement break; default: - LOG_ERROR("PowerStress operation %d not yet implemented!", p.cmd); + LOG_ERROR("PowerStress operation %d not yet implemented", p.cmd); sleep_msec = 0; // Don't do whatever sleep was requested... break; } diff --git a/src/modules/RangeTestModule.cpp b/src/modules/RangeTestModule.cpp index 2ca5891317..46475a3ae2 100644 --- a/src/modules/RangeTestModule.cpp +++ b/src/modules/RangeTestModule.cpp @@ -57,7 +57,7 @@ int32_t RangeTestModule::runOnce() if (moduleConfig.range_test.clear_on_reboot) { // User wants to delete previous range test(s) - LOG_INFO("Range Test Module - Clearing out previous test file"); + LOG_INFO("Range Test Module - Clear previous test file"); rangeTestModuleRadio->removeFile(); } if (moduleConfig.range_test.sender) { @@ -73,7 +73,7 @@ int32_t RangeTestModule::runOnce() if (moduleConfig.range_test.sender) { // If sender - LOG_INFO("Range Test Module - Sending heartbeat every %d ms", (senderHeartbeat)); + LOG_INFO("Range Test Module - Heartbeat every %d ms", (senderHeartbeat)); LOG_INFO("gpsStatus->getLatitude() %d", gpsStatus->getLatitude()); LOG_INFO("gpsStatus->getLongitude() %d", gpsStatus->getLongitude()); @@ -99,7 +99,7 @@ int32_t RangeTestModule::runOnce() } } } else { - LOG_INFO("Range Test Module - Disabled"); + LOG_INFO("Range Test Module Disabled"); } #endif @@ -216,12 +216,12 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp) */ concurrency::LockGuard g(spiLock); if (!FSBegin()) { - LOG_DEBUG("An Error has occurred while mounting the filesystem"); + LOG_DEBUG("Filesystem mount error"); return 0; } if (FSCom.totalBytes() - FSCom.usedBytes() < 51200) { - LOG_DEBUG("Filesystem doesn't have enough free space. Aborting write"); + LOG_DEBUG("Filesystem low on free space. Abort write"); return 0; } @@ -233,14 +233,14 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp) File fileToWrite = FSCom.open("/static/rangetest.csv", FILE_WRITE); if (!fileToWrite) { - LOG_ERROR("There was an error opening the file for writing"); + LOG_ERROR("Error opening file for writing"); return 0; } // Print the CSV header if (fileToWrite.println("time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx " "snr,distance,hop limit,payload,rx rssi")) { - LOG_INFO("File was written"); + LOG_INFO("File written"); } else { LOG_ERROR("File write failed"); } @@ -252,7 +252,7 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp) File fileToAppend = FSCom.open("/static/rangetest.csv", FILE_APPEND); if (!fileToAppend) { - LOG_ERROR("There was an error opening the file for appending"); + LOG_ERROR("Error opening file for appending"); return 0; } @@ -319,7 +319,7 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp) return 1; #else - LOG_ERROR("Failed to store range test results - feature only available for ESP32"); + LOG_ERROR("Can't store range test results - ESP32 only"); return 0; #endif @@ -329,27 +329,27 @@ bool RangeTestModuleRadio::removeFile() { #ifdef ARCH_ESP32 if (!FSBegin()) { - LOG_DEBUG("An Error has occurred while mounting the filesystem"); + LOG_DEBUG("Filesystem mount error"); return 0; } if (!FSCom.exists("/static/rangetest.csv")) { - LOG_DEBUG("No range tests found."); + LOG_DEBUG("No range tests found"); return 0; } - LOG_INFO("Deleting previous range test."); + LOG_INFO("Deleting previous range test"); bool result = FSCom.remove("/static/rangetest.csv"); if (!result) { - LOG_ERROR("Failed to delete range test."); + LOG_ERROR("Failed to delete range test"); return 0; } - LOG_INFO("Range test removed."); + LOG_INFO("Range test removed"); return 1; #else - LOG_ERROR("Failed to remove range test results - feature only available for ESP32"); + LOG_ERROR("Can't remove range test results - ESP32 only"); return 0; #endif diff --git a/src/modules/RemoteHardwareModule.cpp b/src/modules/RemoteHardwareModule.cpp index bd5e156554..220925f984 100644 --- a/src/modules/RemoteHardwareModule.cpp +++ b/src/modules/RemoteHardwareModule.cpp @@ -144,7 +144,7 @@ int32_t RemoteHardwareModule::runOnce() if (curVal != previousWatch) { previousWatch = curVal; - LOG_INFO("Broadcast GPIOS 0x%llx changed!", curVal); + LOG_INFO("Broadcast GPIOS 0x%llx changed", curVal); // Something changed! Tell the world with a broadcast message meshtastic_HardwareMessage r = meshtastic_HardwareMessage_init_default; diff --git a/src/modules/StatusLEDModule.cpp b/src/modules/StatusLEDModule.cpp index 5c6f849423..3a09fb8628 100644 --- a/src/modules/StatusLEDModule.cpp +++ b/src/modules/StatusLEDModule.cpp @@ -2,6 +2,7 @@ #include "MeshService.h" #include "configuration.h" #include "mesh/RadioInterface.h" +#include "mesh/Throttle.h" #include /* @@ -118,7 +119,7 @@ int32_t StatusLEDModule::runOnce() } else if (power_state == charged) { CHARGE_LED_state = LED_STATE_ON; } else if (power_state == critical) { - if (POWER_LED_starttime + 30000 < millis() && !doing_fast_blink) { + if (Throttle::hasElapsed(POWER_LED_starttime, 30000) && !doing_fast_blink) { doing_fast_blink = true; POWER_LED_starttime = millis(); } @@ -126,7 +127,7 @@ int32_t StatusLEDModule::runOnce() PAIRING_LED_state = LED_STATE_OFF; CHARGE_LED_state = !CHARGE_LED_state; my_interval = 250; - if (POWER_LED_starttime + 2000 < millis()) { + if (Throttle::hasElapsed(POWER_LED_starttime, 2000)) { doing_fast_blink = false; CHARGE_LED_state = LED_STATE_OFF; } @@ -165,7 +166,7 @@ int32_t StatusLEDModule::runOnce() } #endif #ifdef LED_PAIRING - if (!config.bluetooth.enabled || PAIRING_LED_starttime + 30 * 1000 < millis() || doing_fast_blink) { + if (!config.bluetooth.enabled || Throttle::hasElapsed(PAIRING_LED_starttime, 30 * 1000) || doing_fast_blink) { PAIRING_LED_state = LED_STATE_OFF; } else if (ble_state == unpaired) { if (slowTrack) { @@ -190,7 +191,7 @@ int32_t StatusLEDModule::runOnce() bool chargeIndicatorLED2 = LED_STATE_OFF; bool chargeIndicatorLED3 = LED_STATE_OFF; bool chargeIndicatorLED4 = LED_STATE_OFF; - if (lastUserbuttonTime + 10 * 1000 > millis() || CHARGE_LED_state == LED_STATE_ON) { + if (Throttle::isWithinTimespanMs(lastUserbuttonTime, 10 * 1000) || CHARGE_LED_state == LED_STATE_ON) { // should this be off at very low percentages? chargeIndicatorLED1 = LED_STATE_ON; if (powerStatus && powerStatus->getBatteryChargePercent() >= 25) diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index bd023716a1..ec0011f4cf 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -421,7 +421,7 @@ ProcessMessage StoreForwardModule::handleReceived(const meshtastic_MeshPacket &m } } else { storeForwardModule->historyAdd(mp); - LOG_INFO("S&F stored. Message history contains %u records now", this->packetHistoryTotalCount); + LOG_INFO("S&F stored, history has %u records", this->packetHistoryTotalCount); } } else if (!isFromUs(&mp) && mp.decoded.portnum == meshtastic_PortNum_STORE_FORWARD_APP) { auto &p = mp.decoded; @@ -431,7 +431,7 @@ ProcessMessage StoreForwardModule::handleReceived(const meshtastic_MeshPacket &m if (pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_StoreAndForward_msg, &scratch)) { decoded = &scratch; } else { - LOG_ERROR("Error decoding proto module!"); + LOG_ERROR("Error decoding proto module"); // if we can't decode it, nobody can process it! return ProcessMessage::STOP; } @@ -567,8 +567,8 @@ bool StoreForwardModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, // These fields only have informational purpose on a client. Fill them to consume later. if (p->which_variant == meshtastic_StoreAndForward_history_tag) { this->historyReturnWindow = p->variant.history.window / 60000; - LOG_INFO("Router Response HISTORY - Sending %d messages from last %d minutes", - p->variant.history.history_messages, this->historyReturnWindow); + LOG_INFO("HISTORY response: %d msgs from last %d min", p->variant.history.history_messages, + this->historyReturnWindow); } } break; diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index dc4612c703..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 @@ -46,7 +47,7 @@ void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) return; } - LOG_INFO("Air Quality Telemetry adding I2C devices..."); + LOG_INFO("Air Quality Telemetry adding I2C devices"); /* Uncomment the preferences below if you want to use the module @@ -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; @@ -81,7 +84,7 @@ void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) if (!firstTime) { // Re-scan for late comming sensors - LOG_INFO("Re-scanning supported sensors..."); + LOG_INFO("Re-scanning supported sensors"); for (const auto &[address, type] : supportedSensors) { @@ -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 @@ -130,7 +134,7 @@ int32_t AirQualityTelemetryModule::runOnce() sleepOnNextExecution = false; uint32_t nightyNightMs = Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval, default_telemetry_broadcast_interval_secs); - LOG_DEBUG("Sleeping for %ims, then awaking to send metrics again.", nightyNightMs); + LOG_DEBUG("Sleep %ims until next send", nightyNightMs); doDeepSleep(nightyNightMs, true, false); } @@ -187,10 +191,10 @@ int32_t AirQualityTelemetryModule::runOnce() // - We can publish the data on the mesh shortly // - Or we can send it to the phone // TODO: This will need to be refurbished once we implement separate intervals - LOG_INFO("Waking up sensors..."); + LOG_INFO("Waking sensors"); for (TelemetrySensor *sensor : sensors) { if (!sensor->canSleep()) { - LOG_DEBUG("%s sensor doesn't have sleep feature. Skipping", sensor->sensorName); + LOG_DEBUG("%s: no sleep support, skip", sensor->sensorName); continue; } @@ -207,7 +211,11 @@ int32_t AirQualityTelemetryModule::runOnce() } if (!sensor->isActive()) { - LOG_DEBUG("Waking up: %s", sensor->sensorName); + LOG_DEBUG("Waking %s", sensor->sensorName); + if (awakeAheadOfTimeMs == 0) + startAirQualityTelemetryCycle = millis(); + awakeAheadOfTimeMs = max(awakeAheadOfTimeMs, sensor->wakeUpTimeMs()); + // TODO multiple sensors with different wake up times collide return sensor->wakeUp(); } @@ -219,19 +227,35 @@ int32_t AirQualityTelemetryModule::runOnce() } bool telemetryDue = (lastTelemetry == 0) || !Throttle::isWithinTimespanMs(lastTelemetry, telemetryIntervalMs); - bool phoneDue = (lastSentToPhone == 0) || !Throttle::isWithinTimespanMs(lastSentToPhone, sendToPhoneIntervalMs); if (telemetryDue && telemetryAllowed) { - sendTelemetry(); - - if (transmitHistory) { - transmitHistory->setLastSentToMesh(TX_HISTORY_KEY_AIR_QUALITY_TELEMETRY); + if (sendTelemetry()) { + if (transmitHistory) { + transmitHistory->setLastSentToMesh(TX_HISTORY_KEY_AIR_QUALITY_TELEMETRY); + } + // Correct the awake time, trimming to 0 + const unsigned long elapsed = millis() - startAirQualityTelemetryCycle; + awakeAheadOfTimeMs = elapsed >= awakeAheadOfTimeMs ? 0 : awakeAheadOfTimeMs - elapsed; + // LOG_DEBUG("Time to publish. Correcting ahead of time by: %d", awakeAheadOfTimeMs); + } else { + awakeAheadOfTimeMs = 0; } } else if (phoneDue && phoneAllowed) { // Mesh transmission isn't due yet, but we can still update the phone. - sendTelemetry(NODENUM_BROADCAST, true); - lastSentToPhone = millis(); + if (sendTelemetry(NODENUM_BROADCAST, true)) { + lastSentToPhone = millis(); + // Correct the awake time, trimming to 0 + const unsigned long elapsed = millis() - startAirQualityTelemetryCycle; + awakeAheadOfTimeMs = elapsed >= awakeAheadOfTimeMs ? 0 : awakeAheadOfTimeMs - elapsed; + // LOG_DEBUG("Time to publish. Correcting ahead of time by: %d", awakeAheadOfTimeMs); + } else { + awakeAheadOfTimeMs = 0; + } + } else { + // if for some reason we end up here after waking up, but not able to send, then reset + // the counter + awakeAheadOfTimeMs = 0; } // Send to sleep sensors that can be to save power @@ -253,7 +277,13 @@ int32_t AirQualityTelemetryModule::runOnce() // mistime the pending deep sleep return FIVE_SECONDS_MS; } - return min(sendToPhoneIntervalMs, result); + + // Update next interval if we were ahead + uint32_t correctedIntervalMs = sendToPhoneIntervalMs + awakeAheadOfTimeMs; + awakeAheadOfTimeMs = 0; + startAirQualityTelemetryCycle = 0; + LOG_DEBUG("Corrected interval in ms: %u", correctedIntervalMs); + return min(correctedIntervalMs, result); } bool AirQualityTelemetryModule::wantUIFrame() @@ -429,7 +459,7 @@ meshtastic_MeshPacket *AirQualityTelemetryModule::allocReply() if (pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &scratch)) { decoded = &scratch; } else { - LOG_ERROR("Error decoding AirQualityTelemetry module!"); + LOG_ERROR("Error decoding AirQualityTelemetry module"); return NULL; } // Check for a request for air quality metrics @@ -529,7 +559,7 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) // until the next telemetry interval and drains its battery if (!phoneOnly && isPowerSavingSensor()) { if (!validTelemetry) - LOG_WARN("Air quality telemetry unavailable this cycle, sleep without sending"); + LOG_WARN("AQ telemetry unavailable, sleep without send"); sleepOnNextExecution = true; preflightSleepDeferrals = 0; LOG_DEBUG("Start next execution in 5s, then sleep"); diff --git a/src/modules/Telemetry/AirQualityTelemetry.h b/src/modules/Telemetry/AirQualityTelemetry.h index 4cc5af420b..7a7bfff5a5 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.h +++ b/src/modules/Telemetry/AirQualityTelemetry.h @@ -66,6 +66,8 @@ class AirQualityTelemetryModule : private concurrency::OSThread, private: bool firstTime = true; + int32_t awakeAheadOfTimeMs = 0; + int32_t startAirQualityTelemetryCycle = 0; meshtastic_MeshPacket *lastMeasurementPacket; uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute // uint32_t sendToPhoneIntervalMs = 1000; // Send to phone every minute diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index 7ae6ac615e..e3ef3f0950 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -6,7 +6,9 @@ #include "PowerFSM.h" #include "RadioLibInterface.h" #include "Router.h" +#include "Throttle.h" #include "TransmitHistory.h" +#include "UptimeClock.h" #include "configuration.h" #include "gps/RTC.h" #include "main.h" @@ -21,13 +23,12 @@ static constexpr uint16_t TX_HISTORY_KEY_DEVICE_TELEMETRY = 0x8001; int32_t DeviceTelemetryModule::runOnce() { - refreshUptime(); uint32_t lastTelemetry = transmitHistory ? transmitHistory->getLastSentToMeshMillis(TX_HISTORY_KEY_DEVICE_TELEMETRY) : 0; bool isImpoliteRole = isSensorOrRouterRole(); - if (((lastTelemetry == 0) || - ((uptimeLastMs - lastTelemetry) >= Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.device_update_interval, - default_telemetry_broadcast_interval_secs, - numOnlineNodes, TrafficType::TELEMETRY))) && + if (((lastTelemetry == 0) || Throttle::hasElapsed(lastTelemetry, Default::getConfiguredOrDefaultMsScaled( + moduleConfig.telemetry.device_update_interval, + default_telemetry_broadcast_interval_secs, + numOnlineNodes, TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(!isImpoliteRole) && airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN && moduleConfig.telemetry.device_telemetry_enabled) { @@ -38,9 +39,9 @@ int32_t DeviceTelemetryModule::runOnce() // Just send to phone when it's not our time to send to mesh yet // Only send while queue is empty (phone assumed connected) sendTelemetry(NODENUM_BROADCAST, true); - if (lastSentStatsToPhone == 0 || (uptimeLastMs - lastSentStatsToPhone) >= sendStatsToPhoneIntervalMs) { + if (lastSentStatsToPhone == 0 || Throttle::hasElapsed(lastSentStatsToPhone, sendStatsToPhoneIntervalMs)) { sendLocalStatsToPhone(); - lastSentStatsToPhone = uptimeLastMs; + lastSentStatsToPhone = Time::getMillis(); } } return sendToPhoneIntervalMs; @@ -76,7 +77,7 @@ meshtastic_MeshPacket *DeviceTelemetryModule::allocReply() if (pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &scratch)) { decoded = &scratch; } else { - LOG_ERROR("Error decoding DeviceTelemetry module!"); + LOG_ERROR("Error decoding DeviceTelemetry module"); return NULL; } // Check for a request for device metrics @@ -114,7 +115,7 @@ meshtastic_Telemetry DeviceTelemetryModule::getDeviceTelemetry() t.variant.device_metrics.has_voltage = true; t.variant.device_metrics.voltage = batteryMv / 1000.0f; } - t.variant.device_metrics.uptime_seconds = getUptimeSeconds(); + t.variant.device_metrics.uptime_seconds = Time::getUptimeSecs(); return t; } @@ -124,7 +125,7 @@ meshtastic_Telemetry DeviceTelemetryModule::getLocalStatsTelemetry() telemetry.which_variant = meshtastic_Telemetry_local_stats_tag; telemetry.variant.local_stats = meshtastic_LocalStats_init_zero; telemetry.time = getTime(); - telemetry.variant.local_stats.uptime_seconds = getUptimeSeconds(); + telemetry.variant.local_stats.uptime_seconds = Time::getUptimeSecs(); telemetry.variant.local_stats.channel_utilization = airTime->channelUtilizationPercent(); telemetry.variant.local_stats.air_util_tx = airTime->utilizationTXPercent(); telemetry.variant.local_stats.num_online_nodes = numOnlineNodes; diff --git a/src/modules/Telemetry/DeviceTelemetry.h b/src/modules/Telemetry/DeviceTelemetry.h index f37afee701..c2d2762f33 100644 --- a/src/modules/Telemetry/DeviceTelemetry.h +++ b/src/modules/Telemetry/DeviceTelemetry.h @@ -18,8 +18,6 @@ class DeviceTelemetryModule : private concurrency::OSThread, : concurrency::OSThread("DeviceTelemetry"), ProtobufModule("DeviceTelemetry", meshtastic_PortNum_TELEMETRY_APP, &meshtastic_Telemetry_msg) { - uptimeWrapCount = 0; - uptimeLastMs = millis(); nodeStatusObserver.observe(&nodeStatus->onNewStatus); setIntervalFromNow(setStartDelay()); // Wait until NodeInfo is sent } @@ -37,12 +35,6 @@ class DeviceTelemetryModule : private concurrency::OSThread, */ bool sendTelemetry(NodeNum dest = NODENUM_BROADCAST, bool phoneOnly = false); - /** - * Get the uptime in seconds - * Loses some accuracy after 49 days, but that's fine - */ - uint32_t getUptimeSeconds() { return (0xFFFFFFFF / 1000) * uptimeWrapCount + (uptimeLastMs / 1000); } - private: meshtastic_Telemetry getDeviceTelemetry(); meshtastic_Telemetry getLocalStatsTelemetry(); @@ -51,17 +43,4 @@ class DeviceTelemetryModule : private concurrency::OSThread, uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute uint32_t sendStatsToPhoneIntervalMs = 15 * SECONDS_IN_MINUTE * 1000; // Send stats to phone every 15 minutes uint32_t lastSentStatsToPhone = 0; - - void refreshUptime() - { - auto now = millis(); - // If we wrapped around (~49 days), increment the wrap count - if (now < uptimeLastMs) - uptimeWrapCount++; - - uptimeLastMs = now; - } - - uint32_t uptimeWrapCount; - uint32_t uptimeLastMs; }; \ No newline at end of file diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 7ca82daee8..aae103e243 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 @@ -131,6 +131,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 @@ -257,7 +261,7 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) if (!moduleConfig.telemetry.environment_measurement_enabled && !ENVIRONMENTAL_TELEMETRY_MODULE_ENABLE) { return; } - LOG_INFO("Environment Telemetry adding I2C devices..."); + LOG_INFO("Environment Telemetry adding I2C devices"); /* Uncomment the preferences below if you want to use the module @@ -302,7 +306,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 +351,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); @@ -366,7 +374,7 @@ int32_t EnvironmentTelemetryModule::runOnce() sleepOnNextExecution = false; uint32_t nightyNightMs = Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval, default_telemetry_broadcast_interval_secs); - LOG_DEBUG("Sleep for %ims, then awake to send metrics again", nightyNightMs); + LOG_DEBUG("Sleep %ims until next send", nightyNightMs); doDeepSleep(nightyNightMs, true, false); } @@ -449,7 +457,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 +521,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 +556,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 @@ -735,7 +744,7 @@ meshtastic_MeshPacket *EnvironmentTelemetryModule::allocReply() if (pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &scratch)) { decoded = &scratch; } else { - LOG_ERROR("Error decoding EnvironmentTelemetry module!"); + LOG_ERROR("Error decoding EnvironmentTelemetry module"); return NULL; } // Check for a request for environment metrics @@ -759,21 +768,48 @@ 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); meshtastic_MeshPacket *p = allocDataProtobuf(m); if (!p) { @@ -809,11 +845,11 @@ 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) - LOG_WARN("Environment telemetry unavailable this cycle, sleep without sending"); + LOG_WARN("Env telemetry unavailable, sleep without send"); sleepOnNextExecution = true; preflightSleepDeferrals = 0; LOG_DEBUG("Start next execution in 5s, then sleep"); diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index dc6d2e8d0e..6ec316e701 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -127,7 +127,7 @@ void HealthTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState * const meshtastic_Data &p = lastMeasurementPacket->decoded; if (!pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &lastMeasurement)) { display->drawString(x, y, "Measurement Error"); - LOG_ERROR("Unable to decode last packet"); + LOG_ERROR("Can't decode last packet"); return; } @@ -223,7 +223,7 @@ meshtastic_MeshPacket *HealthTelemetryModule::allocReply() if (pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &scratch)) { decoded = &scratch; } else { - LOG_ERROR("Error decoding HealthTelemetry module!"); + LOG_ERROR("Error decoding HealthTelemetry module"); return NULL; } // Check for a request for health metrics diff --git a/src/modules/Telemetry/HostMetrics.cpp b/src/modules/Telemetry/HostMetrics.cpp index a9490bc10d..0f9f4d2e8c 100644 --- a/src/modules/Telemetry/HostMetrics.cpp +++ b/src/modules/Telemetry/HostMetrics.cpp @@ -51,7 +51,7 @@ meshtastic_MeshPacket *HostMetricsModule::allocReply() if (pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_HostMetrics_msg, &scratch)) { decoded = &scratch; } else { - LOG_ERROR("Error decoding HostMetrics module!"); + LOG_ERROR("Can't decode HostMetrics module"); return NULL; } // Check for a request for device metrics diff --git a/src/modules/Telemetry/HostMetrics.h b/src/modules/Telemetry/HostMetrics.h index 99ee631c15..a352a5afa3 100644 --- a/src/modules/Telemetry/HostMetrics.h +++ b/src/modules/Telemetry/HostMetrics.h @@ -12,8 +12,6 @@ class HostMetricsModule : private concurrency::OSThread, public ProtobufModuleonNewStatus); setIntervalFromNow(setStartDelay()); // Wait until NodeInfo is sent } @@ -35,6 +33,4 @@ class HostMetricsModule : private concurrency::OSThread, public ProtobufModuledecoded; if (!pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &lastMeasurement)) { display->drawString(x, graphics::getTextPositions(display)[line++], "Measurement Error"); - LOG_ERROR("Unable to decode last packet"); + LOG_ERROR("Can't decode last packet"); return; } @@ -247,7 +247,7 @@ meshtastic_MeshPacket *PowerTelemetryModule::allocReply() if (pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &scratch)) { decoded = &scratch; } else { - LOG_ERROR("Error decoding PowerTelemetry module!"); + LOG_ERROR("Error decoding PowerTelemetry module"); return NULL; } // Check for a request for power metrics @@ -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; @@ -312,7 +317,7 @@ bool PowerTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) LOG_WARN("Power telemetry unavailable this cycle, sleep without sending"); sleepOnNextExecution = true; preflightSleepDeferrals = 0; - LOG_DEBUG("Start next execution in 5s then sleep"); + LOG_DEBUG("Start next execution in 5s, then sleep"); setIntervalFromNow(FIVE_SECONDS_MS); } return validTelemetry; diff --git a/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp b/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp new file mode 100644 index 0000000000..1b22e6358e --- /dev/null +++ b/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp @@ -0,0 +1,175 @@ +#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; + _port = dev->address.port; + _address = dev->address.address; + _deviceType = dev->type; + +#ifdef ADS1X15_I2C_CLOCK_SPEED + reClockI2C.setup(_bus, _port); + reClockI2C.setClock(ADS1X15_I2C_CLOCK_SPEED); +#endif /* ADS1X15_I2C_CLOCK_SPEED */ + + status = ads1x15.begin(_address, _bus); + +#ifdef ADS1X15_I2C_CLOCK_SPEED + reClockI2C.restoreClock(); +#endif /* ADS1X15_I2C_CLOCK_SPEED */ + + 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 + reClockI2C.setClock(ADS1X15_I2C_CLOCK_SPEED); +#endif /* ADS1X15_I2C_CLOCK_SPEED */ + + struct _ADS1X15Measurements m = getMeasurements(); + +#ifdef ADS1X15_I2C_CLOCK_SPEED + reClockI2C.restoreClock(); +#endif /* ADS1X15_I2C_CLOCK_SPEED */ + + 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/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 c202028e18..9162a93212 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.cpp +++ b/src/modules/Telemetry/Sensor/BME680Sensor.cpp @@ -1,56 +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)) { @@ -58,151 +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("ERROR: Filesystem not implemented"); + 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); - } + BME680IaqState state; + uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice); + iaqEstimator.serialize(&state, nowSecs); + + // 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 { - /* Update every STATE_SAVE_PERIOD minutes */ - if ((stateUpdateCounter * STATE_SAVE_PERIOD) < millis()) { - LOG_DEBUG("%s state update every %d minutes", sensorName, STATE_SAVE_PERIOD / 60000); - update = true; - stateUpdateCounter++; - } + LOG_WARN("Can't write %s state (File: %s)", sensorName, stateFileName); } - - 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(); - } else { - LOG_INFO("Can't write %s state (File: %s)", sensorName, bsecConfigFileName); - } - } - spiLock->unlock(); #else - LOG_ERROR("ERROR: Filesystem not implemented"); + 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 1134f04d9d..a10ea1fefc 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.h +++ b/src/modules/Telemetry/Sensor/BME680Sensor.h @@ -1,65 +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; - 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 c98d2d0e36..d0e1385528 100644 --- a/src/modules/Telemetry/Sensor/DS248XSensor.cpp +++ b/src/modules/Telemetry/Sensor/DS248XSensor.cpp @@ -63,8 +63,6 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef DS248X_I2C_CLOCK_SPEED reClockI2C.setup(_bus, _port); - - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, DS248X_I2C_CLOCK_SPEED); reClockI2C.setClock(DS248X_I2C_CLOCK_SPEED); #endif /* DS248X_I2C_CLOCK_SPEED */ @@ -78,7 +76,6 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) // Try to init One-Wire with 3 retries. This detects ROMs consistently // on the second one. uint8_t numRetries = 3; - uint8_t rom[8]{}; for (uint8_t retry = 1; retry <= numRetries; retry++) { bool initError = false; @@ -149,7 +146,7 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) printROM(ds248xData.rom); } } else { - LOG_WARN("%s: Could not determine variant (%u/%u)", sensorName, retry, numRetries); + LOG_WARN("%s: Can't determine variant (%u/%u)", sensorName, retry, numRetries); initError = true; } @@ -162,12 +159,14 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } if (!initError) { - LOG_INFO("%s: Started one-wire (%u/%u)", sensorName, retry, numRetries); status = true; // We want to keep searching for ROMs on the DS248X_DS2482_800 // and always do the three passes if (_variant == ds248x_variant_t::DS248X_DS2484) { + LOG_INFO("%s: Started one-wire (%u/%u)", sensorName, retry, numRetries); break; + } else { + LOG_INFO("%s: One-wire startup cycle (%u/%u)", sensorName, retry, numRetries); } } // TODO Potentially not needed, but taken from Adafruit's library example @@ -175,7 +174,6 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } #ifdef DS248X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* DS248X_I2C_CLOCK_SPEED */ @@ -192,7 +190,6 @@ bool DS248XSensor::isValidROM(const uint8_t *rom) float DS248XSensor::readTemperatureROM(const uint8_t *rom) { #ifdef DS248X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: attempting to reclock speed to %uHz", sensorName, DS248X_I2C_CLOCK_SPEED); reClockI2C.setClock(DS248X_I2C_CLOCK_SPEED); #endif /* DS248X_I2C_CLOCK_SPEED */ @@ -223,7 +220,6 @@ float DS248XSensor::readTemperatureROM(const uint8_t *rom) } #ifdef DS248X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* DS248X_I2C_CLOCK_SPEED */ @@ -282,18 +278,112 @@ bool DS248XSensor::getMetrics(meshtastic_Telemetry *measurement) return true; } } else if (_variant == ds248x_variant_t::DS248X_DS2482_800) { - // Only ch0 is reported, and each populated channel blocks 750ms on its conversion - // TODO Support more than one temperature via repeated (3.0) - // TODO Select which channel can be reported as main temperature - if (readTemperatureChannel(0)) { - measurement->variant.environment_metrics.temperature = ds2482800Data.ds248xData[0].temperature; - measurement->variant.environment_metrics.has_temperature = true; - LOG_DEBUG("Got %s readings: temperature=%.2f", sensorName, measurement->variant.environment_metrics.temperature); - return true; + // If using DS248X_DS2482_800, we read all channels + uint8_t channelCount = 0; + + // Note, the reason why we are using an unpacked version of this message + // (instead of repeated) it's to save space. With repeated, we have to send all + // channels (even if null) or otherwise we don't know where each channel is + // being reported + for (uint8_t channel = 0; channel < 8; channel++) { + if (readTemperatureChannel(channel)) { + channelCount += 1; + + if (channel == mainTemperatureChannel) { + measurement->variant.environment_metrics.has_temperature = true; + measurement->variant.environment_metrics.temperature = ds2482800Data.ds248xData[channel].temperature; + } + + switch (channel) { + case 0: + measurement->variant.environment_metrics.has_one_wire_temperature_ch0 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch0 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 1: + measurement->variant.environment_metrics.has_one_wire_temperature_ch1 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch1 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 2: + measurement->variant.environment_metrics.has_one_wire_temperature_ch2 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch2 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 3: + measurement->variant.environment_metrics.has_one_wire_temperature_ch3 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch3 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 4: + measurement->variant.environment_metrics.has_one_wire_temperature_ch4 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch4 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 5: + measurement->variant.environment_metrics.has_one_wire_temperature_ch5 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch5 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 6: + measurement->variant.environment_metrics.has_one_wire_temperature_ch6 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch6 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 7: + measurement->variant.environment_metrics.has_one_wire_temperature_ch7 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch7 = + ds2482800Data.ds248xData[channel].temperature; + break; + } + + LOG_DEBUG("Got %s readings: temperature_ch%u=%.2f", sensorName, channel, + ds2482800Data.ds248xData[channel].temperature); + } } - return false; + return channelCount > 0; } return false; } +void DS248XSensor::setMainTemperature(uint8_t channel) +{ + if (channel > 7) { + LOG_ERROR("%s: Requested channel (%u) not available", sensorName, channel); + return; + } + + LOG_INFO("%s: Setting requested channel (%u) as main temperature", sensorName, channel); + mainTemperatureChannel = channel; + return; +} + +AdminMessageHandleResult DS248XSensor::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_ds248x_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + + // Check for main temperature channel request + if (request->sensor_config.ds248x_config.has_main_temperature_channel) { + this->setMainTemperature(request->sensor_config.ds248x_config.main_temperature_channel); + } + + result = AdminMessageHandleResult::HANDLED; + break; + + default: + result = AdminMessageHandleResult::NOT_HANDLED; + } + + return result; +} + #endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/DS248XSensor.h b/src/modules/Telemetry/Sensor/DS248XSensor.h index bfe18a0350..f1215149c0 100644 --- a/src/modules/Telemetry/Sensor/DS248XSensor.h +++ b/src/modules/Telemetry/Sensor/DS248XSensor.h @@ -66,6 +66,7 @@ class DS248XSensor : public TelemetrySensor ds248x_variant_t _variant = DS248X_UNKNOWN; _DS248XData ds248xData{}; _DS2482800Data ds2482800Data{}; + uint8_t mainTemperatureChannel = 0; #ifdef DS248X_I2C_CLOCK_SPEED ReClockI2C reClockI2C; #endif @@ -73,12 +74,16 @@ class DS248XSensor : public TelemetrySensor bool isValidROM(const uint8_t *rom); float readTemperatureROM(const uint8_t *rom); bool readTemperatureChannel(uint8_t channel); + void setMainTemperature(uint8_t channel); public: DS248XSensor(); ds248x_variant_t detectVariant(); virtual bool getMetrics(meshtastic_Telemetry *measurement) override; virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; }; #endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/HM330XSensor.cpp b/src/modules/Telemetry/Sensor/HM330XSensor.cpp index 1d44cd133f..91c0267e73 100644 --- a/src/modules/Telemetry/Sensor/HM330XSensor.cpp +++ b/src/modules/Telemetry/Sensor/HM330XSensor.cpp @@ -17,14 +17,11 @@ bool HM330XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef HM330X_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, HM330X_I2C_CLOCK_SPEED); reClockI2C.setClock(HM330X_I2C_CLOCK_SPEED); #endif /* HM330X_I2C_CLOCK_SPEED */ if (hm330x.init(_bus) != HM330XErrorCode::NO_ERROR) { #ifdef HM330X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* HM330X_I2C_CLOCK_SPEED */ LOG_WARN("%s error in sensor init", sensorName); @@ -32,7 +29,6 @@ bool HM330XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } #ifdef HM330X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* HM330X_I2C_CLOCK_SPEED */ @@ -46,7 +42,7 @@ bool HM330XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) uint32_t HM330XSensor::wakeUp() { state = State::ACTIVE; - measureStarted = getTime(); + measureStarted = millis(); return HM330X_WARMUP_MS; } @@ -68,9 +64,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) { @@ -83,21 +77,18 @@ int32_t HM330XSensor::pendingForReadyMs() bool HM330XSensor::getMetrics(meshtastic_Telemetry *measurement) { #ifdef HM330X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: attempting to reclock speed to %uHz", sensorName, HM330X_I2C_CLOCK_SPEED); reClockI2C.setClock(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 - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* HM330X_I2C_CLOCK_SPEED */ return false; } #ifdef HM330X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* HM330X_I2C_CLOCK_SPEED */ 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/MAX17048Sensor.cpp b/src/modules/Telemetry/Sensor/MAX17048Sensor.cpp index 1a6792d3a8..8e6406b80d 100644 --- a/src/modules/Telemetry/Sensor/MAX17048Sensor.cpp +++ b/src/modules/Telemetry/Sensor/MAX17048Sensor.cpp @@ -53,7 +53,7 @@ bool MAX17048Singleton::isBatteryCharging() chargeState = MAX17048ChargeState::IDLE; } - LOG_DEBUG("%s::isBatteryCharging %s volts: %.3f soc: %.3f rate: %.3f", sensorStr, chargeLabels[chargeState], volts, + LOG_TRACE("%s::isBatteryCharging %s volts: %.3f soc: %.3f rate: %.3f", sensorStr, chargeLabels[chargeState], volts, sample.cellPercent, sample.chargeRate); return chargeState == MAX17048ChargeState::IMPORT; } @@ -65,14 +65,14 @@ uint16_t MAX17048Singleton::getBusVoltageMv() LOG_DEBUG("%s::getBusVoltageMv is not connected", sensorStr); return 0; } - LOG_DEBUG("%s::getBusVoltageMv %.3fmV", sensorStr, volts); + LOG_TRACE("%s::getBusVoltageMv %.3fmV", sensorStr, volts); return (uint16_t)(volts * 1000.0f); } uint8_t MAX17048Singleton::getBusBatteryPercent() { float soc = cellPercent(); - LOG_DEBUG("%s::getBusBatteryPercent %.1f%%", sensorStr, soc); + LOG_TRACE("%s::getBusBatteryPercent %.1f%%", sensorStr, soc); return clamp(static_cast(round(soc)), static_cast(0), static_cast(100)); } @@ -82,7 +82,7 @@ uint16_t MAX17048Singleton::getTimeToGoSecs() float soc = cellPercent(); // state of charge in percent 0 to 100 soc = clamp(soc, 0.0f, 100.0f); // clamp soc between 0 and 100% float ttg = ((100.0f - soc) / rate) * 3600.0f; // calculate seconds to charge/discharge - LOG_DEBUG("%s::getTimeToGoSecs %.0f seconds", sensorStr, ttg); + LOG_TRACE("%s::getTimeToGoSecs %.0f seconds", sensorStr, ttg); return (uint16_t)ttg; } @@ -108,7 +108,7 @@ bool MAX17048Singleton::isExternallyPowered() } // if the bus voltage is over MAX17048_BUS_POWER_VOLTS, then the external power // is assumed to be connected - LOG_DEBUG("%s::isExternallyPowered %s connected", sensorStr, volts >= MAX17048_BUS_POWER_VOLTS ? "is" : "is not"); + LOG_TRACE("%s::isExternallyPowered %s connected", sensorStr, volts >= MAX17048_BUS_POWER_VOLTS ? "is" : "is not"); return volts >= MAX17048_BUS_POWER_VOLTS; } @@ -140,7 +140,7 @@ void MAX17048Sensor::setup() {} bool MAX17048Sensor::getMetrics(meshtastic_Telemetry *measurement) { - LOG_DEBUG("MAX17048 getMetrics id: %i", measurement->which_variant); + LOG_TRACE("MAX17048 getMetrics id: %i", measurement->which_variant); float volts = max17048->cellVoltage(); if (isnan(volts)) { diff --git a/src/modules/Telemetry/Sensor/NAU7802Sensor.cpp b/src/modules/Telemetry/Sensor/NAU7802Sensor.cpp index e67b781451..198e87c489 100644 --- a/src/modules/Telemetry/Sensor/NAU7802Sensor.cpp +++ b/src/modules/Telemetry/Sensor/NAU7802Sensor.cpp @@ -107,7 +107,7 @@ bool NAU7802Sensor::saveCalibrationData() pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_Nau7802Config_size}; if (!pb_encode(&stream, &meshtastic_Nau7802Config_msg, &nau7802config)) { - LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream)); + LOG_ERROR("Can't encode protobuf %s", PB_GET_ERROR(&stream)); } else { okay = true; } @@ -126,7 +126,7 @@ bool NAU7802Sensor::loadCalibrationData() LOG_INFO("%s state read from %s", sensorName, nau7802ConfigFileName); pb_istream_t stream = {&readcb, &file, meshtastic_Nau7802Config_size}; if (!pb_decode(&stream, &meshtastic_Nau7802Config_msg, &nau7802config)) { - LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream)); + LOG_ERROR("Can't decode protobuf %s", PB_GET_ERROR(&stream)); } else { nau7802.setZeroOffset(nau7802config.zeroOffset); nau7802.setCalibrationFactor(nau7802config.calibrationFactor); diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp index 7fae87b984..8b91513799 100644 --- a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp +++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp @@ -24,8 +24,6 @@ bool PMSA003ISensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef PMSA003I_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, PMSA003I_I2C_CLOCK_SPEED); reClockI2C.setClock(PMSA003I_I2C_CLOCK_SPEED); #endif /* PMSA003I_I2C_CLOCK_SPEED */ @@ -33,7 +31,6 @@ bool PMSA003ISensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) if (_bus->endTransmission() != 0) { LOG_WARN("%s not found on I2C at 0x12", sensorName); #ifdef PMSA003I_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* PMSA003I_I2C_CLOCK_SPEED */ sleep(); @@ -41,7 +38,6 @@ bool PMSA003ISensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } #ifdef PMSA003I_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* PMSA003I_I2C_CLOCK_SPEED */ @@ -61,7 +57,6 @@ bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) } #ifdef PMSA003I_I2C_CLOCK_SPEED - LOG_DEBUG("%s: attempting to reclock speed to %uHz", sensorName, PMSA003I_I2C_CLOCK_SPEED); reClockI2C.setClock(PMSA003I_I2C_CLOCK_SPEED); #endif /* PMSA003I_I2C_CLOCK_SPEED */ @@ -69,7 +64,6 @@ bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) if (_bus->available() < PMSA003I_FRAME_LENGTH) { LOG_WARN("%s: read failed: incomplete data (%d bytes)", sensorName, _bus->available()); #ifdef PMSA003I_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* PMSA003I_I2C_CLOCK_SPEED */ return false; @@ -80,7 +74,6 @@ bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) } #ifdef PMSA003I_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* PMSA003I_I2C_CLOCK_SPEED */ @@ -164,9 +157,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) { @@ -199,10 +190,10 @@ void PMSA003ISensor::sleep() uint32_t PMSA003ISensor::wakeUp() { #ifdef PMSA003I_ENABLE_PIN - LOG_INFO("%s: Waking up", sensorName); + 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/RAK9154Sensor.cpp b/src/modules/Telemetry/Sensor/RAK9154Sensor.cpp index ad3925f081..367f5bdc9f 100644 --- a/src/modules/Telemetry/Sensor/RAK9154Sensor.cpp +++ b/src/modules/Telemetry/Sensor/RAK9154Sensor.cpp @@ -63,7 +63,7 @@ static void onewire_evt(const uint8_t pid, const uint8_t sid, const SNHUBAPI_EVT // { // LOG_INFO("%02x,", msg[i]); // } - // LOG_INFO(""); + // LOG_INFO("."); switch (msg[0]) { case RAK_IPSO_CAPACITY: dc_prec = msg[1]; @@ -91,7 +91,7 @@ static void onewire_evt(const uint8_t pid, const uint8_t sid, const SNHUBAPI_EVT // { // LOG_INFO("%02x,", msg[i]); // } - // LOG_INFO(""); + // LOG_INFO("."); switch (msg[0]) { case RAK_IPSO_CAPACITY: diff --git a/src/modules/Telemetry/Sensor/RCWL9620Sensor.cpp b/src/modules/Telemetry/Sensor/RCWL9620Sensor.cpp index 3dbd06e8d6..27a3f0e282 100644 --- a/src/modules/Telemetry/Sensor/RCWL9620Sensor.cpp +++ b/src/modules/Telemetry/Sensor/RCWL9620Sensor.cpp @@ -52,7 +52,7 @@ float RCWL9620Sensor::getDistance() _wire->requestFrom(_addr, (uint8_t)3); if (_wire->available() < 3) { - LOG_DEBUG("[RCWL9620] less than 3 octets !"); + LOG_DEBUG("[RCWL9620] less than 3 octets "); return 0.0; } diff --git a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp index 59a50779c3..c2631f5b0f 100644 --- a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp @@ -18,28 +18,24 @@ bool SCD30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SCD30_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ scd30.begin(*_bus, _address); if (!startMeasurement()) { - LOG_ERROR("%s: Failed to start periodic measurement", sensorName); + LOG_ERROR("%s: Periodic measurement start failed", sensorName); #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ return false; } if (!getASC(ascActive)) { - LOG_WARN("%s: Could not determine ASC state", sensorName); + LOG_WARN("%s: Can't determine ASC state", sensorName); } #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ @@ -59,21 +55,18 @@ bool SCD30Sensor::getMetrics(meshtastic_Telemetry *measurement) float co2, temperature, humidity; #ifdef SCD30_I2C_CLOCK_SPEED - LOG_DEBUG("%s: attempting to reclock speed to %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ if (scd30.readMeasurementData(co2, temperature, humidity) != SCD30_NO_ERROR) { - LOG_ERROR("%s: Failed to read measurement data", sensorName); + LOG_ERROR("%s: Measurement read failed", sensorName); #ifdef SCD30_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ return false; } #ifdef SCD30_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ @@ -102,7 +95,7 @@ bool SCD30Sensor::setMeasurementInterval(uint16_t measInterval) error = scd30.setMeasurementInterval(measInterval); if (error != SCD30_NO_ERROR) { - LOG_ERROR("%s: Unable to set measurement interval. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't set measurement interval, rc=%u", sensorName, error); return false; } @@ -123,7 +116,7 @@ bool SCD30Sensor::getMeasurementInterval(uint16_t &measInterval) error = scd30.getMeasurementInterval(measInterval); if (error != SCD30_NO_ERROR) { - LOG_ERROR("%s: Unable to get measurement interval. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't get measurement interval, rc=%u", sensorName, error); return false; } @@ -153,7 +146,7 @@ bool SCD30Sensor::startMeasurement() state = SCD30_MEASUREMENT; return true; } else { - LOG_ERROR("%s: Couldn't start measurement mode", sensorName); + LOG_ERROR("%s: Can't start measurement mode", sensorName); return false; } } @@ -168,7 +161,7 @@ bool SCD30Sensor::stopMeasurement() error = scd30.stopPeriodicMeasurement(); if (error != SCD30_NO_ERROR) { - LOG_ERROR("%s: Unable to stop measurement", sensorName); + LOG_ERROR("%s: Can't stop measurement", sensorName); return false; } @@ -180,17 +173,17 @@ bool SCD30Sensor::performFRC(uint16_t targetCO2) { uint16_t error; - LOG_INFO("%s: Issuing FRC. Ensure device has been working at least 3 minutes in stable target environment", sensorName); + LOG_INFO("%s: Issuing FRC. Needs 3+ min in stable target environment", sensorName); LOG_INFO("%s: Target CO2: %u ppm", sensorName, targetCO2); error = scd30.forceRecalibration((uint16_t)targetCO2); if (error != SCD30_NO_ERROR) { - LOG_ERROR("%s: Unable to perform forced recalibration.", sensorName); + LOG_ERROR("%s: Can't perform FRC", sensorName); return false; } - LOG_INFO("%s: FRC Correction successful.", sensorName); + LOG_INFO("%s: FRC done", sensorName); return true; } @@ -204,12 +197,12 @@ bool SCD30Sensor::setASC(bool ascEnabled) error = scd30.activateAutoCalibration((uint16_t)ascEnabled); if (error != SCD30_NO_ERROR) { - LOG_ERROR("%s: Unable to send command.", sensorName); + LOG_ERROR("%s: Can't send command", sensorName); return false; } if (!getASC(ascActive)) { - LOG_ERROR("%s: Unable to check if ASC is enabled", sensorName); + LOG_ERROR("%s: Can't check if ASC enabled", sensorName); return false; } @@ -224,7 +217,7 @@ bool SCD30Sensor::getASC(uint16_t &_ascActive) error = scd30.getAutoCalibrationStatus(_ascActive); if (error != SCD30_NO_ERROR) { - LOG_ERROR("%s: Unable to send command.", sensorName); + LOG_ERROR("%s: Can't get ASC status", sensorName); return false; } @@ -260,23 +253,23 @@ bool SCD30Sensor::setTemperature(float tempReference) if (tempReference == 100) { // Requesting the value of 100 will restore the temperature offset - LOG_INFO("%s: Setting reference temperature at 0degC", sensorName); + LOG_INFO("%s: Setting reference temp at 0degC", sensorName); _tempOffset = 0; } else { - LOG_INFO("%s: Setting reference temperature at: %.2f", sensorName, tempReference); + LOG_INFO("%s: Setting reference temp at: %.2f", sensorName, tempReference); error = scd30.readMeasurementData(co2, temperature, humidity); if (error != SCD30_NO_ERROR) { - LOG_ERROR("%s: Unable to read current temperature. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't read current temp, rc=%u", sensorName, error); return false; } - LOG_INFO("%s: Current sensor temperature: %.2f", sensorName, temperature); + LOG_INFO("%s: Current sensor temp: %.2f", sensorName, temperature); tempOffset = (temperature - tempReference); if (tempOffset < 0) { - LOG_ERROR("%s: temperature offset is only positive", sensorName); + LOG_ERROR("%s: temp offset is only positive", sensorName); return false; } @@ -284,16 +277,16 @@ bool SCD30Sensor::setTemperature(float tempReference) _tempOffset = static_cast(tempOffset); } - LOG_INFO("%s: Setting temperature offset: %u (*100)", sensorName, _tempOffset); + LOG_INFO("%s: Setting temp offset: %u (*100)", sensorName, _tempOffset); error = scd30.setTemperatureOffset(_tempOffset); if (error != SCD30_NO_ERROR) { - LOG_ERROR("%s: Unable to set temperature offset. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't set temp offset, rc=%u", sensorName, error); return false; } scd30.getTemperatureOffset(updatedTempOffset); - LOG_INFO("%s: Updated sensor temperature offset: %u (*100)", sensorName, updatedTempOffset); + LOG_INFO("%s: Updated sensor temp offset: %u (*100)", sensorName, updatedTempOffset); return true; } @@ -307,7 +300,7 @@ bool SCD30Sensor::setAltitude(uint16_t altitude) error = scd30.setAltitudeCompensation(altitude); if (error != SCD30_NO_ERROR) { - LOG_ERROR("%s: Unable to set altitude. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't set altitude, rc=%u", sensorName, error); return false; } @@ -325,7 +318,7 @@ bool SCD30Sensor::getAltitude(uint16_t &altitude) error = scd30.getAltitudeCompensation(altitude); if (error != SCD30_NO_ERROR) { - LOG_ERROR("%s: Unable to get altitude. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't get altitude, rc=%u", sensorName, error); return false; } LOG_INFO("%s: Sensor altitude: %u", sensorName, altitude); @@ -342,11 +335,11 @@ bool SCD30Sensor::softReset() error = scd30.softReset(); if (error != SCD30_NO_ERROR) { - LOG_ERROR("%s: Unable to do soft reset. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't soft reset, rc=%u", sensorName, error); return false; } - LOG_INFO("%s: soft reset successful", sensorName); + LOG_INFO("%s: soft reset done", sensorName); return true; } @@ -366,14 +359,12 @@ bool SCD30Sensor::isActive() uint32_t SCD30Sensor::wakeUp() { #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ startMeasurement(); #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ @@ -387,14 +378,12 @@ uint32_t SCD30Sensor::wakeUp() void SCD30Sensor::sleep() { #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ stopMeasurement(); #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ } @@ -420,7 +409,6 @@ AdminMessageHandleResult SCD30Sensor::handleAdminMessage(const meshtastic_MeshPa AdminMessageHandleResult result; #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ @@ -436,37 +424,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); } } @@ -478,7 +463,6 @@ AdminMessageHandleResult SCD30Sensor::handleAdminMessage(const meshtastic_MeshPa } #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ 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 26d50ab3e7..261f8259b5 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp @@ -19,8 +19,6 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SCD4X_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -32,7 +30,6 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) // Stop periodic measurement if (!stopMeasurement()) { #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; @@ -44,9 +41,8 @@ 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: Error trying to execute powerUp()", sensorName); + LOG_ERROR("%s: powerUp() failed", sensorName); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; @@ -54,9 +50,8 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } if (!getASC(ascActive)) { - LOG_ERROR("%s: Unable to check if ASC is enabled", sensorName); + LOG_ERROR("%s: Can't check if ASC enabled", sensorName); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; @@ -64,16 +59,14 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) // Start measurement in selected power mode (low power by default) if (!startMeasurement()) { - LOG_ERROR("%s: Couldn't start measurement", sensorName); + LOG_ERROR("%s: Can't start measurement", sensorName); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; } #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -100,7 +93,6 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) float temperature, humidity; #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: attempting to reclock speed to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -118,7 +110,6 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) if (error != SCD4X_NO_ERROR || !dataReady) { #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ LOG_ERROR("SCD4X: Data is not ready"); @@ -128,15 +119,14 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) error = scd4x.readMeasurement(co2, temperature, humidity); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); 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 while getting measurements: %u", sensorName, error); + LOG_DEBUG("%s: Error getting measurements: %u", sensorName, error); if (co2 == 0) { - LOG_ERROR("%s: Skipping invalid measurement.", sensorName); + LOG_ERROR("%s: Skipping invalid measurement", sensorName); } return false; } else { @@ -167,7 +157,7 @@ bool SCD4XSensor::performFRC(uint32_t targetCO2) { uint16_t error, frcCorr; - LOG_INFO("%s: Issuing FRC. Ensure device has been working at least 3 minutes in stable target environment", sensorName); + LOG_INFO("%s: Issuing FRC. Needs 3+ min in stable target environment", sensorName); if (!stopMeasurement()) { return false; @@ -180,16 +170,16 @@ bool SCD4XSensor::performFRC(uint32_t targetCO2) delay(400); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to perform forced recalibration.", sensorName); + LOG_ERROR("%s: Can't perform FRC", sensorName); return false; } if (frcCorr == 0xFFFF) { - LOG_ERROR("%s: Error while performing forced recalibration.", sensorName); + LOG_ERROR("%s: FRC failed", sensorName); return false; } - LOG_INFO("%s: FRC Correction successful. Correction output: %u", sensorName, (uint16_t)(frcCorr - 0x8000)); + LOG_INFO("%s: FRC done. Correction output: %u", sensorName, (uint16_t)(frcCorr - 0x8000)); return true; } @@ -224,7 +214,7 @@ bool SCD4XSensor::startMeasurement() state = SCD4X_MEASUREMENT; return true; } else { - LOG_ERROR("%s: Unable to start measurement mode", sensorName); + LOG_ERROR("%s: Can't start measurement mode", sensorName); return false; } } @@ -239,7 +229,7 @@ bool SCD4XSensor::stopMeasurement() error = scd4x.stopPeriodicMeasurement(); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to stop measurement.", sensorName); + LOG_ERROR("%s: Can't stop measurement", sensorName); return false; } @@ -286,11 +276,11 @@ bool SCD4XSensor::getASC(uint16_t &_ascActive) error = scd4x.getAutomaticSelfCalibrationEnabled(_ascActive); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to send command.", sensorName); + LOG_ERROR("%s: Can't send command", sensorName); return false; } - LOG_INFO("%s ASC is %s", sensorName, _ascActive ? "enabled" : "disabled"); + LOG_INFO("%s: ASC is %s", sensorName, _ascActive ? "enabled" : "disabled"); return true; } @@ -308,7 +298,7 @@ bool SCD4XSensor::setASC(bool ascEnabled) { uint16_t error; - LOG_INFO("%s %s ASC", sensorName, ascEnabled ? "Enabling" : "Disabling"); + LOG_INFO("%s: %s ASC", sensorName, ascEnabled ? "Enabling" : "Disabling"); if (!stopMeasurement()) { return false; @@ -317,18 +307,18 @@ bool SCD4XSensor::setASC(bool ascEnabled) error = scd4x.setAutomaticSelfCalibrationEnabled((uint16_t)ascEnabled); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to send command.", sensorName); + LOG_ERROR("%s: Can't send command", sensorName); return false; } error = scd4x.persistSettings(); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to make settings persistent.", sensorName); + LOG_ERROR("%s: Can't persist settings", sensorName); return false; } if (!getASC(ascActive)) { - LOG_ERROR("%s: Unable to check if ASC is enabled", sensorName); + LOG_ERROR("%s: Can't check if ASC enabled", sensorName); return false; } @@ -356,7 +346,7 @@ bool SCD4XSensor::setASCBaseline(uint32_t targetCO2) getASC(ascActive); if (!ascActive) { - LOG_ERROR("%s: Can't set ASC baseline. ASC is not active", sensorName); + LOG_ERROR("%s: Can't set ASC baseline, ASC not active", sensorName); return false; } @@ -367,17 +357,17 @@ bool SCD4XSensor::setASCBaseline(uint32_t targetCO2) error = scd4x.setAutomaticSelfCalibrationTarget((uint16_t)targetCO2); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to send command.", sensorName); + LOG_ERROR("%s: Can't send command", sensorName); return false; } error = scd4x.persistSettings(); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to make settings persistent.", sensorName); + LOG_ERROR("%s: Can't persist settings", sensorName); return false; } - LOG_INFO("%s: Setting ASC baseline successful", sensorName); + LOG_INFO("%s: ASC baseline set", sensorName); return true; } @@ -414,7 +404,7 @@ bool SCD4XSensor::setTemperature(float tempReference) float temperature; float humidity; - LOG_INFO("%s: Setting reference temperature at: %.2f", sensorName, tempReference); + LOG_INFO("%s: Setting reference temp at: %.2f", sensorName, tempReference); error = scd4x.getDataReadyStatus(dataReady); if (error != SCD4X_NO_ERROR || !dataReady) { @@ -424,11 +414,11 @@ bool SCD4XSensor::setTemperature(float tempReference) error = scd4x.readMeasurement(co2, temperature, humidity); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to read current temperature. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't read current temp, rc=%u", sensorName, error); return false; } - LOG_INFO("%s: Current sensor temperature: %.2f", sensorName, temperature); + LOG_INFO("%s: Current sensor temp: %.2f", sensorName, temperature); if (!stopMeasurement()) { return false; @@ -437,28 +427,28 @@ bool SCD4XSensor::setTemperature(float tempReference) error = scd4x.getTemperatureOffset(prevTempOffset); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to get temperature offset. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't get temp offset, rc=%u", sensorName, error); return false; } - LOG_INFO("%s: Current sensor temperature offset: %.2f", sensorName, prevTempOffset); + LOG_INFO("%s: Current sensor temp offset: %.2f", sensorName, prevTempOffset); tempOffset = temperature - tempReference + prevTempOffset; - LOG_INFO("%s: Setting temperature offset: %.2f", sensorName, tempOffset); + LOG_INFO("%s: Setting temp offset: %.2f", sensorName, tempOffset); error = scd4x.setTemperatureOffset(tempOffset); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to set temperature offset. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't set temp offset, rc=%u", sensorName, error); return false; } error = scd4x.persistSettings(); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to make settings persistent. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't persist settings, rc=%u", sensorName, error); return false; } scd4x.getTemperatureOffset(updatedTempOffset); - LOG_INFO("%s: Updated sensor temperature offset: %.2f", sensorName, updatedTempOffset); + LOG_INFO("%s: Updated sensor temp offset: %.2f", sensorName, updatedTempOffset); return true; } @@ -484,7 +474,7 @@ bool SCD4XSensor::getAltitude(uint16_t &altitude) error = scd4x.getSensorAltitude(altitude); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to get altitude. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't get altitude, rc=%u", sensorName, error); return false; } LOG_INFO("%s: Sensor altitude: %u", sensorName, altitude); @@ -508,7 +498,7 @@ bool SCD4XSensor::getAmbientPressure(uint32_t &ambientPressure) error = scd4x.getAmbientPressure(ambientPressure); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to get altitude. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't get ambient pressure, rc=%u", sensorName, error); return false; } LOG_INFO("%s: Sensor ambient pressure: %u", sensorName, ambientPressure); @@ -537,7 +527,7 @@ bool SCD4XSensor::setAltitude(uint32_t altitude) error = scd4x.setSensorAltitude(altitude); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to set altitude. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't set altitude, rc=%u", sensorName, error); return false; } @@ -545,7 +535,7 @@ bool SCD4XSensor::setAltitude(uint32_t altitude) // doesn't indicate it's needed. // error = scd4x.persistSettings(); // if (error != SCD4X_NO_ERROR) { - // LOG_ERROR("%s: Unable to make settings persistent. Error code: %u", sensorName, error); + // LOG_ERROR("%s: Can't make settings persistent. Error code: %u", sensorName, error); // return false; // } @@ -577,18 +567,18 @@ bool SCD4XSensor::setAmbientPressure(uint32_t ambientPressure) error = scd4x.setAmbientPressure(ambientPressure); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to set altitude. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't set ambient pressure, rc=%u", sensorName, error); return false; } // Sensirion doesn't indicate if this is necessary. We send it anyway error = scd4x.persistSettings(); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to make settings persistent. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't persist settings, rc=%u", sensorName, error); return false; } - LOG_INFO("%s: ambient pressure set set", sensorName); + LOG_INFO("%s: ambient pressure set", sensorName); return true; } @@ -615,11 +605,11 @@ bool SCD4XSensor::factoryReset() error = scd4x.performFactoryReset(); if (error != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Unable to do factory reset. Error code: %u", sensorName, error); + LOG_ERROR("%s: Can't factory reset, rc=%u", sensorName, error); return false; } - LOG_INFO("%s: Factory reset successful", sensorName); + LOG_INFO("%s: Factory reset done", sensorName); return true; } @@ -636,37 +626,33 @@ bool SCD4XSensor::factoryReset() */ bool SCD4XSensor::powerDown() { - LOG_INFO("%s: Trying to send sensor to sleep", sensorName); + LOG_INFO("%s: Sending sensor to sleep", sensorName); if (sensorVariant != SCD4X_SENSOR_VARIANT_SCD41) { - LOG_WARN("SCD4X: Can't send sensor to sleep. Incorrect variant. Ignoring"); + LOG_WARN("SCD4X: Can't sleep: wrong variant, ignoring"); return true; } #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ if (!stopMeasurement()) { #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; } if (scd4x.powerDown() != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Error trying to execute sleep()", sensorName); + LOG_ERROR("%s: sleep() failed", sensorName); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; } #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -687,10 +673,10 @@ bool SCD4XSensor::powerDown() */ bool SCD4XSensor::powerUp() { - LOG_INFO("%s: Waking up", sensorName); + LOG_INFO("%s Waking", sensorName); if (scd4x.wakeUp() != SCD4X_NO_ERROR) { - LOG_ERROR("%s: Error trying to execute wakeUp()", sensorName); + LOG_ERROR("%s: wakeUp() failed", sensorName); return false; } @@ -715,21 +701,18 @@ uint32_t SCD4XSensor::wakeUp() { #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ if (startMeasurement()) { - co2MeasureStarted = getTime(); + co2MeasureStarted = millis(); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return SCD4X_WARMUP_MS; } #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -743,14 +726,12 @@ uint32_t SCD4XSensor::wakeUp() void SCD4XSensor::sleep() { #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ stopMeasurement(); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ } @@ -774,13 +755,11 @@ 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) { - LOG_INFO("%s: not enough time passed since starting measurement", sensorName); + LOG_INFO("%s: not enough time since measurement start", sensorName); return SCD4X_WARMUP_MS - sinceCO2MeasureStarted; } return 0; @@ -792,7 +771,6 @@ AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPa AdminMessageHandleResult result; #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -805,85 +783,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; @@ -897,7 +838,6 @@ AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPa this->startMeasurement(); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ 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 58ac4370ad..0000000000 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp +++ /dev/null @@ -1,997 +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 - - uint8_t versionBuffer[12]{}; - size_t charNumber = readBuffer(&versionBuffer[0], 3); - if (charNumber == 0) { - LOG_ERROR("%s: Error getting data ready flag value", sensorName); - return false; - } - - firmwareVer = versionBuffer[0] + (versionBuffer[1] / 10); - hardwareVer = versionBuffer[3] + (versionBuffer[4] / 10); - protocolVer = versionBuffer[5] + (versionBuffer[6] / 10); - - 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 - - const uint8_t nameSize = 48; - uint8_t name[nameSize]; - size_t charNumber = readBuffer(&name[0], nameSize); - bool foundModel = false; - - if (charNumber == 0) { - 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 - LOG_DEBUG("%s: Attempting to reclock speed to %uHz", sensorName, 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 - LOG_DEBUG("%s: restoring clock speed", sensorName); - 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 - LOG_DEBUG("%s: Attempting to reclock speed to %uHz", sensorName, 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 - LOG_DEBUG("%s: restoring clock speed", sensorName); - 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 while receiving msg", sensorName); -#ifdef SEN5X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); - reClockI2C.restoreClock(); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - return 0; - } - readBytes -= 3; - receivedBytes += 2; - } - -#ifdef SEN5X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); - 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 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); - } - - // 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 - // Allocate buffer to account for CRC - size_t receivedNumber = readBuffer(&vocState[0], SEN5X_VOC_STATE_BUFFER_SIZE + (SEN5X_VOC_STATE_BUFFER_SIZE / 2)); - delay(20); // From Sensirion Datasheet - - if (receivedNumber == 0) { - LOG_DEBUG("%s: Error getting VOC's state", sensorName); - return false; - } - - // 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 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); -#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 successful", sensorName, sen5XStateFileName); - - return okay; -#else - LOG_ERROR("%s: Filesystem not implemented", sensorName); -#endif -} - -bool SEN5XSensor::isActive() -{ - return state == SEN5X_MEASUREMENT || state == SEN5X_MEASUREMENT_2; -} - -uint32_t SEN5XSensor::wakeUp() -{ - - LOG_DEBUG("%s: Waking up sensor", 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; - if (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_DEBUG("%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 it will take 10 seconds...", sensorName); - - uint16_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 is too old and will not work with this implementation", 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: 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 (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 is too old or date is 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_DEBUG("%s: Reading PM Values", sensorName); - delay(20); // From Sensirion Datasheet - - uint8_t dataBuffer[16]{}; - size_t receivedNumber = readBuffer(&dataBuffer[0], 24); - if (receivedNumber == 0) { - 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 - sen5xmeasurement.pM1p0 = !isnan(uint_pM1p0) ? uint_pM1p0 / 10 : UINT16_MAX; - sen5xmeasurement.pM2p5 = !isnan(uint_pM2p5) ? uint_pM2p5 / 10 : UINT16_MAX; - sen5xmeasurement.pM4p0 = !isnan(uint_pM4p0) ? uint_pM4p0 / 10 : UINT16_MAX; - sen5xmeasurement.pM10p0 = !isnan(uint_pM10p0) ? uint_pM10p0 / 10 : UINT16_MAX; - sen5xmeasurement.humidity = !isnan(int_humidity) ? int_humidity / 100.0f : FLT_MAX; - sen5xmeasurement.temperature = !isnan(int_temperature) ? int_temperature / 200.0f : FLT_MAX; - sen5xmeasurement.vocIndex = !isnan(int_vocIndex) ? int_vocIndex / 10.0f : FLT_MAX; - sen5xmeasurement.noxIndex = !isnan(int_noxIndex) ? int_noxIndex / 10.0f : FLT_MAX; - - LOG_DEBUG("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, sen5xmeasurement.pM1p0, - sen5xmeasurement.pM2p5, sen5xmeasurement.pM4p0, sen5xmeasurement.pM10p0); - - if (model != SEN50) { - LOG_DEBUG("%s: Got readings: humidity=%.2f, temperature=%.2f, vocIndex=%.2f", sensorName, sen5xmeasurement.humidity, - sen5xmeasurement.temperature, sen5xmeasurement.vocIndex); - } - - if (model == SEN55) { - LOG_DEBUG("%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_DEBUG("%s: Reading PN Values", sensorName); - delay(20); // From Sensirion Datasheet - - uint8_t dataBuffer[20]{}; - size_t receivedNumber = readBuffer(&dataBuffer[0], 30); - if (receivedNumber == 0) { - 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 - // Multiply by 100 for converting from #/cm3 to #/0.1l for PN values - sen5xmeasurement.pN0p5 = !isnan(uint_pN0p5) ? uint_pN0p5 / 10 * 100 : UINT32_MAX; - sen5xmeasurement.pN1p0 = !isnan(uint_pN1p0) ? uint_pN1p0 / 10 * 100 : UINT32_MAX; - sen5xmeasurement.pN2p5 = !isnan(uint_pN2p5) ? uint_pN2p5 / 10 * 100 : UINT32_MAX; - sen5xmeasurement.pN4p0 = !isnan(uint_pN4p0) ? uint_pN4p0 / 10 * 100 : UINT32_MAX; - sen5xmeasurement.pN10p0 = !isnan(uint_pN10p0) ? uint_pN10p0 / 10 * 100 : UINT32_MAX; - sen5xmeasurement.tSize = !isnan(uint_tSize) ? uint_tSize / 1000.0f : FLT_MAX; - - // Remove accumuluative values: - // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85 - if (!cumulative) { - sen5xmeasurement.pN10p0 -= sen5xmeasurement.pN4p0; - sen5xmeasurement.pN4p0 -= sen5xmeasurement.pN2p5; - sen5xmeasurement.pN2p5 -= sen5xmeasurement.pN1p0; - sen5xmeasurement.pN1p0 -= sen5xmeasurement.pN0p5; - } - - LOG_DEBUG("%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[3]; - size_t charNumber = readBuffer(&dataReadyBuffer[0], 3); - if (charNumber == 0) { - LOG_ERROR("%s: Error getting device version 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_DEBUG("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted); - - switch (state) { - case SEN5X_MEASUREMENT: { - - if (sincePmMeasureStarted < SEN5X_WARMUP_MS_1) { - LOG_INFO("%s: not enough time passed since starting measurement", 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 is low, we will ask again in the 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: Attempting to 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.noxIndex != 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: setting mode to one shot mode", sensorName); - } else { - LOG_INFO("%s: setting mode to 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 5d84b89169..2c8aaf524b 100644 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.h +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.h @@ -1,173 +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 - -#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); - bool sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber = 0); - uint8_t readBuffer(uint8_t *buffer, uint8_t byteNumber); // Return number of bytes received - 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..42b8a4ae39 --- /dev/null +++ b/src/modules/Telemetry/Sensor/SENXXSensor.cpp @@ -0,0 +1,1614 @@ +#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); +#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; + } + } + +#ifdef SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: Attempting to reclock speed to %uHz", sensorName, SENXX_I2C_CLOCK_SPEED); + reClockI2C.setClock(SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + // Transmit the data + // 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 SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: restoring clock speed", sensorName); + reClockI2C.restoreClock(); +#endif /* SENXX_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 SENXXSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber) +{ +#ifdef SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: Attempting to reclock speed to %uHz", sensorName, SENXX_I2C_CLOCK_SPEED); + reClockI2C.setClock(SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + size_t readBytes = _bus->requestFrom(_address, byteNumber); + if (readBytes != byteNumber) { + LOG_ERROR("%s: Error reading I2C bus", sensorName); +#ifdef SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: restoring clock speed", sensorName); + reClockI2C.restoreClock(); +#endif /* SENXX_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 = senxxCRC(&buffer[i - 2]); + if (recvCRC != calcCRC) { + LOG_ERROR("%s: Checksum error while receiving msg", sensorName); +#ifdef SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: restoring clock speed", sensorName); + reClockI2C.restoreClock(); +#endif /* SENXX_I2C_CLOCK_SPEED */ + return 0; + } + readBytes -= 3; + receivedBytes += 2; + } + +#ifdef SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: restoring clock speed", sensorName); + reClockI2C.restoreClock(); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + 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; + } + 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() +{ + + 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); +#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() +{ + 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; + } + + 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: { + 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) { + this->wakeUp(); + } + } + } 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..1bc80efd8c --- /dev/null +++ b/src/modules/Telemetry/Sensor/SENXXSensor.h @@ -0,0 +1,310 @@ +#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(); + + // 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 5befb44748..90671e2292 100644 --- a/src/modules/Telemetry/Sensor/SFA30Sensor.cpp +++ b/src/modules/Telemetry/Sensor/SFA30Sensor.cpp @@ -17,8 +17,6 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SFA30_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - - LOG_INFO("%s attempting to reclock speed to %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ @@ -27,7 +25,6 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) if (this->isError(sfa30.deviceReset())) { #ifdef SFA30_I2C_CLOCK_SPEED - LOG_INFO("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ return false; @@ -36,7 +33,6 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) state = State::IDLE; if (this->isError(sfa30.startContinuousMeasurement())) { #ifdef SFA30_I2C_CLOCK_SPEED - LOG_INFO("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ return false; @@ -45,13 +41,12 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) LOG_INFO("%s starting measurement", sensorName); #ifdef SFA30_I2C_CLOCK_SPEED - LOG_INFO("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ status = 1; state = State::ACTIVE; - measureStarted = getTime(); + measureStarted = millis(); LOG_INFO("%s Enabled", sensorName); initI2CSensor(); @@ -71,17 +66,15 @@ bool SFA30Sensor::isError(uint16_t response) void SFA30Sensor::sleep() { #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s attempting to reclock speed to %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ // Note - not recommended for this sensor on a periodic basis if (this->isError(sfa30.stopMeasurement())) { - LOG_ERROR("%s: can't stop measurement", sensorName); + LOG_ERROR("%s: Can't stop measurement", sensorName); }; #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ @@ -93,26 +86,23 @@ void SFA30Sensor::sleep() uint32_t SFA30Sensor::wakeUp() { #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s attempting to reclock speed to %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ - LOG_DEBUG("Waking up %s", sensorName); + LOG_DEBUG("Waking %s", sensorName); if (this->isError(sfa30.startContinuousMeasurement())) { #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ return 0; } #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ state = State::ACTIVE; - measureStarted = getTime(); + measureStarted = millis(); return SFA30_WARMUP_MS; } @@ -135,9 +125,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) { @@ -154,21 +142,18 @@ bool SFA30Sensor::getMetrics(meshtastic_Telemetry *measurement) float temperature = 0.0; #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s attempting to reclock speed to %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); reClockI2C.setClock(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 - LOG_DEBUG("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ return false; } #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ 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/Telemetry/Sensor/SHTXXSensor.cpp b/src/modules/Telemetry/Sensor/SHTXXSensor.cpp index 92cac7f777..1512e7cc8c 100644 --- a/src/modules/Telemetry/Sensor/SHTXXSensor.cpp +++ b/src/modules/Telemetry/Sensor/SHTXXSensor.cpp @@ -50,12 +50,12 @@ bool SHTXXSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) _address = dev->address.address; if (sht.init(*_bus)) { - LOG_INFO("%s: init(): success", sensorName); + LOG_INFO("%s init success", sensorName); getSensorVariant(sht.mSensorType); LOG_INFO("%s Sensor detected: %s on 0x%x", sensorName, sensorVariant, _address); status = 1; } else { - LOG_ERROR("%s: init(): failed", sensorName); + LOG_ERROR("%s init failed", sensorName); } initI2CSensor(); diff --git a/src/modules/TraceRouteModule.cpp b/src/modules/TraceRouteModule.cpp index 4d3819f12e..310cf4bc1b 100644 --- a/src/modules/TraceRouteModule.cpp +++ b/src/modules/TraceRouteModule.cpp @@ -310,7 +310,7 @@ void TraceRouteModule::updateNextHops(const meshtastic_MeshPacket &p, meshtastic // point any node's next_hop anywhere. relay_node is 0 for MQTT-sourced packets, which cannot // corroborate an RF route either. if (p.relay_node == NO_RELAY_NODE || nextHopByte != p.relay_node) { - LOG_DEBUG("Ignore traceroute next-hop 0x%02x, packet was relayed by 0x%02x", nextHopByte, p.relay_node); + LOG_DEBUG("Ignore traceroute next-hop 0x%02x, relayed by 0x%02x", nextHopByte, p.relay_node); return; } @@ -333,7 +333,7 @@ void TraceRouteModule::maybeSetNextHop(NodeNum target, uint8_t nextHopByte) meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(target); if (node && node->next_hop != nextHopByte) { - LOG_INFO("Updating next-hop for 0x%08x to 0x%02x based on traceroute", target, nextHopByte); + LOG_INFO("Update next-hop for 0x%08x to 0x%02x via traceroute", target, nextHopByte); node->next_hop = nextHopByte; } @@ -437,7 +437,7 @@ void TraceRouteModule::appendMyIDandSNR(meshtastic_RouteDiscovery *updated, floa route[*route_count] = myNodeInfo.my_node_num; *route_count += 1; } else { - LOG_WARN("Route exceeded maximum hop limit!"); // Are you bridging networks? + LOG_WARN("Route exceeded max hop limit"); // Are you bridging networks? } } @@ -533,11 +533,11 @@ const char *TraceRouteModule::getNodeName(NodeNum node) bool TraceRouteModule::startTraceRoute(NodeNum node) { - LOG_INFO("=== TraceRoute startTraceRoute CALLED: node=0x%08x ===", node); + LOG_INFO("TraceRoute startTraceRoute: node=0x%08x", node); unsigned long now = millis(); if (node == 0 || node == NODENUM_BROADCAST) { - LOG_ERROR("Invalid node number for trace route: 0x%08x", node); + LOG_ERROR("Invalid trace route node: 0x%08x", node); runState = TRACEROUTE_STATE_RESULT; setResultText("Invalid node"); resultShowTime = millis(); @@ -551,7 +551,7 @@ bool TraceRouteModule::startTraceRoute(NodeNum node) } if (node == nodeDB->getNodeNum()) { - LOG_ERROR("Cannot trace route to self: 0x%08x", node); + LOG_ERROR("Can't trace route to self: 0x%08x", node); runState = TRACEROUTE_STATE_RESULT; setResultText("Cannot trace self"); resultShowTime = millis(); @@ -567,7 +567,7 @@ bool TraceRouteModule::startTraceRoute(NodeNum node) if (!initialized) { lastTraceRouteTime = 0; initialized = true; - LOG_INFO("TraceRoute initialized for first time"); + LOG_INFO("TraceRoute first init"); } if (runState == TRACEROUTE_STATE_TRACKING) { @@ -587,7 +587,7 @@ bool TraceRouteModule::startTraceRoute(NodeNum node) UIFrameEvent e; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; notifyObservers(&e); - LOG_INFO("Cooldown active, please wait %lu seconds before starting a new trace route.", wait); + LOG_INFO("Cooldown active, wait %lu sec before new trace route", wait); return false; } @@ -598,7 +598,7 @@ bool TraceRouteModule::startTraceRoute(NodeNum node) clearResultLines(); bannerText = String("Tracing ") + getNodeName(node); - LOG_INFO("TraceRoute UI: Starting trace route to node 0x%08x, requesting focus", node); + LOG_INFO("TraceRoute UI: Start trace to 0x%08x, request focus", node); // 请求焦点,然后触发UI更新事件 requestFocus(); @@ -610,7 +610,7 @@ bool TraceRouteModule::startTraceRoute(NodeNum node) setIntervalFromNow(1000); // 每秒检查一次状态 meshtastic_RouteDiscovery req = meshtastic_RouteDiscovery_init_zero; - LOG_INFO("Creating RouteDiscovery protobuf..."); + LOG_INFO("Creating RouteDiscovery protobuf"); // Allocate a packet directly from router like the reference code meshtastic_MeshPacket *p = router->allocForSending(); @@ -627,16 +627,16 @@ bool TraceRouteModule::startTraceRoute(NodeNum node) p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_RouteDiscovery_msg, &req); - LOG_INFO("Packet allocated successfully: to=0x%08x, portnum=%d, want_response=%d, payload_size=%d", p->to, - p->decoded.portnum, p->decoded.want_response, p->decoded.payload.size); - LOG_INFO("About to call service->sendToMesh..."); + LOG_INFO("Packet allocated: to=0x%08x, portnum=%d, want_response=%d, payload_size=%d", p->to, p->decoded.portnum, + p->decoded.want_response, p->decoded.payload.size); + LOG_INFO("Calling service->sendToMesh"); if (service) { - LOG_INFO("MeshService is available, sending packet..."); + LOG_INFO("MeshService is available, sending packet"); service->sendToMesh(p, RX_SRC_USER); - LOG_INFO("sendToMesh called successfully for trace route to node 0x%08x", node); + LOG_INFO("sendToMesh called for trace route to node 0x%08x", node); } else { - LOG_ERROR("MeshService is NULL!"); + LOG_ERROR("MeshService is NULL"); runState = TRACEROUTE_STATE_RESULT; setResultText("Service unavailable"); resultShowTime = millis(); @@ -649,7 +649,7 @@ bool TraceRouteModule::startTraceRoute(NodeNum node) return false; } } else { - LOG_ERROR("Failed to allocate TraceRoute packet from router"); + LOG_ERROR("TraceRoute packet alloc from router failed"); runState = TRACEROUTE_STATE_RESULT; setResultText("Failed to send"); resultShowTime = millis(); @@ -667,7 +667,7 @@ bool TraceRouteModule::startTraceRoute(NodeNum node) void TraceRouteModule::launch(NodeNum node) { if (node == 0 || node == NODENUM_BROADCAST) { - LOG_ERROR("Invalid node number for trace route: 0x%08x", node); + LOG_ERROR("Invalid trace route node: 0x%08x", node); runState = TRACEROUTE_STATE_RESULT; setResultText("Invalid node"); resultShowTime = millis(); @@ -681,7 +681,7 @@ void TraceRouteModule::launch(NodeNum node) } if (node == nodeDB->getNodeNum()) { - LOG_ERROR("Cannot trace route to self: 0x%08x", node); + LOG_ERROR("Can't trace route to self: 0x%08x", node); runState = TRACEROUTE_STATE_RESULT; setResultText("Cannot trace self"); resultShowTime = millis(); @@ -697,7 +697,7 @@ void TraceRouteModule::launch(NodeNum node) if (!initialized) { lastTraceRouteTime = 0; initialized = true; - LOG_INFO("TraceRoute initialized for first time"); + LOG_INFO("TraceRoute first init"); } unsigned long now = millis(); @@ -712,7 +712,7 @@ void TraceRouteModule::launch(NodeNum node) UIFrameEvent e; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; notifyObservers(&e); - LOG_INFO("Cooldown active, please wait %lu seconds before starting a new trace route.", wait); + LOG_INFO("Cooldown active, wait %lu sec before new trace route", wait); return; } @@ -732,7 +732,7 @@ void TraceRouteModule::launch(NodeNum node) setIntervalFromNow(1000); meshtastic_RouteDiscovery req = meshtastic_RouteDiscovery_init_zero; - LOG_INFO("Creating RouteDiscovery protobuf..."); + LOG_INFO("Creating RouteDiscovery protobuf"); meshtastic_MeshPacket *p = router->allocForSending(); if (p) { @@ -746,21 +746,21 @@ void TraceRouteModule::launch(NodeNum node) p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_RouteDiscovery_msg, &req); - LOG_INFO("Packet allocated successfully: to=0x%08x, portnum=%d, want_response=%d, payload_size=%d", p->to, - p->decoded.portnum, p->decoded.want_response, p->decoded.payload.size); + LOG_INFO("Packet allocated: to=0x%08x, portnum=%d, want_response=%d, payload_size=%d", p->to, p->decoded.portnum, + p->decoded.want_response, p->decoded.payload.size); if (service) { service->sendToMesh(p, RX_SRC_USER); - LOG_INFO("sendToMesh called successfully for trace route to node 0x%08x", node); + LOG_INFO("sendToMesh called for trace route to node 0x%08x", node); } else { - LOG_ERROR("MeshService is NULL!"); + LOG_ERROR("MeshService is NULL"); runState = TRACEROUTE_STATE_RESULT; setResultText("Service unavailable"); resultShowTime = millis(); tracingNode = 0; } } else { - LOG_ERROR("Failed to allocate TraceRoute packet from router"); + LOG_ERROR("TraceRoute packet alloc from router failed"); runState = TRACEROUTE_STATE_RESULT; setResultText("Failed to send"); resultShowTime = millis(); @@ -775,7 +775,7 @@ void TraceRouteModule::handleTraceRouteResult(const String &result) resultShowTime = millis(); tracingNode = 0; - LOG_INFO("TraceRoute result ready, requesting focus. Result: %s", result.c_str()); + LOG_INFO("TraceRoute result ready, request focus: %s", result.c_str()); setIntervalFromNow(1000); @@ -784,7 +784,7 @@ void TraceRouteModule::handleTraceRouteResult(const String &result) e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; notifyObservers(&e); - LOG_INFO("=== TraceRoute handleTraceRouteResult END ==="); + LOG_INFO("TraceRoute handleTraceRouteResult END"); } bool TraceRouteModule::shouldDraw() @@ -850,7 +850,7 @@ int32_t TraceRouteModule::runOnce() // Check for tracking timeout if (runState == TRACEROUTE_STATE_TRACKING && now - lastTraceRouteTime > trackingTimeoutMs) { - LOG_INFO("TraceRoute timeout, no response received"); + LOG_INFO("TraceRoute timeout, no response"); runState = TRACEROUTE_STATE_RESULT; setResultText("No response received"); resultShowTime = now; @@ -886,7 +886,7 @@ int32_t TraceRouteModule::runOnce() return 1000; } else { // Cooldown finished - LOG_INFO("TraceRoute cooldown finished, returning to IDLE"); + LOG_INFO("TraceRoute cooldown done, return to IDLE"); runState = TRACEROUTE_STATE_IDLE; resultText = ""; clearResultLines(); diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 50cc81e3fa..0fdd8c7222 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -19,6 +19,7 @@ #include #include +#define TM_LOG_TRACE(fmt, ...) LOG_TRACE("[TM] " fmt, ##__VA_ARGS__) #define TM_LOG_DEBUG(fmt, ...) LOG_DEBUG("[TM] " fmt, ##__VA_ARGS__) #define TM_LOG_INFO(fmt, ...) LOG_INFO("[TM] " fmt, ##__VA_ARGS__) #define TM_LOG_WARN(fmt, ...) LOG_WARN("[TM] " fmt, ##__VA_ARGS__) @@ -148,7 +149,7 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme if (cache) { cacheFromPsram = true; } else { - TM_LOG_WARN("PSRAM allocation failed, falling back to heap"); + TM_LOG_WARN("PSRAM alloc failed, fall back to heap"); cache = new UnifiedCacheEntry[allocSize](); } #else @@ -171,7 +172,7 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme nodeInfoPayloadFromPsram = true; TM_LOG_INFO("NodeInfo PSRAM cache ready"); } else { - TM_LOG_WARN("NodeInfo PSRAM payload allocation failed; direct responses will fall back to NodeDB"); + TM_LOG_WARN("NodeInfo PSRAM payload alloc failed; direct responses fall back to NodeDB"); } #else // Native unit-test build (see TMM_HAS_NODEINFO_CACHE): plain heap, so the cache paths @@ -585,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++) { @@ -634,7 +636,7 @@ void TrafficManagementModule::maintainNodeInfoCacheLocked() // O(entries x members) every 60 s under cacheLock. The hourly reconcile pass // owns it (see reconcileNodeInfoFromNodeDBLocked). } - TM_LOG_DEBUG("NodeInfo cache: %u/%u (%u went stale)", static_cast(countNodeInfoEntriesLocked()), + TM_LOG_TRACE("NodeInfo cache: %u/%u (%u went stale)", static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoSaturated)); // Anti-entropy: seed identities NodeDB knows but this cache lacks - a full pass at @@ -728,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) @@ -1323,7 +1326,7 @@ int32_t TrafficManagementModule::runOnce() } } - TM_LOG_DEBUG("Maintenance: %u active, %u expired, %u/%u slots, %lums elapsed", activeEntries, expiredEntries, + TM_LOG_TRACE("Maintenance: %u active, %u expired, %u/%u slots, %lums elapsed", activeEntries, expiredEntries, static_cast(activeEntries), static_cast(cacheSize()), static_cast(TrafficManagementModule::clockMs() - sweepStartMs)); @@ -1400,7 +1403,7 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, const bool withinInterval = hasPositionState && (windowTicks != 0) && (static_cast(nowPosTick - entry->pos_time) < windowTicks); - TM_LOG_DEBUG("Position dedup 0x%08x: fp=0x%02x prev=0x%02x same=%d within=%d new=%d", p->from, fingerprint, + TM_LOG_TRACE("Position dedup 0x%08x: fp=0x%02x prev=0x%02x same=%d within=%d new=%d", p->from, fingerprint, entry->pos_fingerprint, samePosition, withinInterval, isNew); // Update cache entry (raw tick; 0 is a valid tick value) @@ -1513,9 +1516,10 @@ 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 instead", getFrom(p)); + 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/modules/esp32/PaxcounterModule.cpp b/src/modules/esp32/PaxcounterModule.cpp index db38165c6e..d9135c3a2f 100644 --- a/src/modules/esp32/PaxcounterModule.cpp +++ b/src/modules/esp32/PaxcounterModule.cpp @@ -24,7 +24,7 @@ static void startWifiChannelTimer(uint16_t wifi_channel_switch_interval) WifiChanTimer = xTimerCreate("WifiChannelTimer", pdMS_TO_TICKS(wifi_channel_switch_interval * 10), pdTRUE, (void *)0, switchWifiChannel); if (!WifiChanTimer) { - LOG_WARN("Paxcounter could not create WiFi channel switch timer"); + LOG_WARN("Paxcounter can't create WiFi channel switch timer"); return; } xTimerStart(WifiChanTimer, 0); @@ -34,7 +34,7 @@ static void ensureDefaultEventLoop() { esp_err_t result = esp_event_loop_create_default(); if (result != ESP_OK && result != ESP_ERR_INVALID_STATE) { - LOG_WARN("Paxcounter could not create ESP event loop: %d", result); + LOG_WARN("Paxcounter can't create ESP event loop: %d", result); } } diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h index 571767715b..0bcb504fbb 100755 --- a/src/motion/AccelerometerThread.h +++ b/src/motion/AccelerometerThread.h @@ -21,6 +21,8 @@ #include "LSM6DS3Sensor.h" #include "MPU6050Sensor.h" #include "MotionSensor.h" + +#include #ifdef HAS_QMA6100P #include "QMA6100PSensor.h" #endif @@ -33,7 +35,7 @@ extern ScanI2C::DeviceAddress accelerometer_found; class AccelerometerThread : public concurrency::OSThread { private: - MotionSensor *sensor = nullptr; + std::unique_ptr sensor; bool isInitialised = false; public: @@ -93,56 +95,56 @@ 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 default: @@ -185,8 +187,7 @@ class AccelerometerThread : public concurrency::OSThread void clean() { isInitialised = false; - delete sensor; - sensor = nullptr; + sensor.reset(); } }; diff --git a/src/motion/MagnetometerThread.h b/src/motion/MagnetometerThread.h index 1f558eb574..8185f296aa 100644 --- a/src/motion/MagnetometerThread.h +++ b/src/motion/MagnetometerThread.h @@ -10,12 +10,14 @@ #include "MMC5983MASensor.h" #include "MotionSensor.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; @@ -67,9 +69,11 @@ 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 default: disable(); return; @@ -104,8 +108,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 b1744ad923..6cbe8e21db 100755 --- a/src/motion/MotionSensor.cpp +++ b/src/motion/MotionSensor.cpp @@ -259,8 +259,11 @@ void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState const uint32_t now = millis(); const uint32_t endCalibrationAt = screen->getEndCalibration(); uint32_t timeRemaining = 0; - if (endCalibrationAt > now) { - timeRemaining = (endCalibrationAt - now + 999) / 1000; + // Signed delta, as in finishCalibrationIfExpired(): this needs the remaining magnitude, not + // just whether the deadline passed, so it cannot use Throttle::deadlinePassed(). + const int32_t remainingMs = (int32_t)(endCalibrationAt - now); + if (remainingMs > 0) { + timeRemaining = ((uint32_t)remainingMs + 999) / 1000; } int16_t compassX = 0, compassY = 0; diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 6427b4c315..6bd2f3688f 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -89,7 +89,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) { const DecodedServiceEnvelope e(payload, length); if (!e.validDecode || e.channel_id == NULL || e.gateway_id == NULL || e.packet == NULL) { - LOG_ERROR("Invalid MQTT service envelope, topic %s, len %u!", topic, length); + LOG_ERROR("Invalid MQTT service envelope, topic %s, len %u", topic, length); return; } @@ -127,12 +127,12 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) if (router->sendLocal(pAck) == ERRNO_SHOULD_RELEASE) packetPool.release(pAck); } else { - LOG_INFO("Ignore downlink message we originally sent"); + LOG_INFO("Ignore downlink msg we sent"); } return; } if (isFromUs(e.packet)) { - LOG_INFO("Ignore downlink message we originally sent"); + LOG_INFO("Ignore downlink msg we sent"); return; } @@ -163,7 +163,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { if (moduleConfig.mqtt.encryption_enabled) { - LOG_INFO("Ignore decoded message on MQTT, encryption is enabled"); + LOG_INFO("Ignore decoded msg on MQTT, encryption enabled"); return; } if (p->decoded.portnum == meshtastic_PortNum_ADMIN_APP) { @@ -179,7 +179,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) // (perhapsDecode) does - checkXeddsaReceivePolicy -> xeddsa_verify mutates shared // CryptoEngine cache state, and MQTT ingress can run on a different task. if (passesRoutingAuthGate(p.get()) != RoutingAuthVerdict::ACCEPT) { - LOG_INFO("Ignore decoded message failing XEdDSA policy"); + LOG_INFO("Ignore decoded msg failing XEdDSA policy"); return; } #endif @@ -281,8 +281,8 @@ bool connectPubSub(const PubSubConfig &config, PubSubClient &pubSub, Client &cli pubSub.setClient(client); pubSub.setServer(config.serverAddr.c_str(), config.serverPort); - LOG_INFO("Connecting directly to MQTT server %s, port: %d, username: %s, password: ***", config.serverAddr.c_str(), - config.serverPort, config.mqttUsername); + LOG_INFO("Direct MQTT connect %s, port %d, user %s, password ***", config.serverAddr.c_str(), config.serverPort, + config.mqttUsername); // Generate node ID from nodenum for client identification std::string nodeId = nodeDB->getNodeId(); @@ -292,7 +292,7 @@ bool connectPubSub(const PubSubConfig &config, PubSubClient &pubSub, Client &cli LOG_INFO("MQTT connected"); } else { isConnected = false; - LOG_WARN("Failed to connect to MQTT server"); + LOG_WARN("MQTT server connect failed"); } return connected; } @@ -347,7 +347,7 @@ void MQTT::onClientProxyReceive(meshtastic_MqttClientProxyMessage msg) strnlen(msg.payload_variant.text, sizeof(msg.payload_variant.text))); break; default: - LOG_WARN("MQTT proxy message carries no payload, topic %s", msg.topic); + LOG_WARN("MQTT proxy msg has no payload, topic %s", msg.topic); break; } } @@ -355,7 +355,7 @@ void MQTT::onClientProxyReceive(meshtastic_MqttClientProxyMessage msg) void MQTT::onReceive(char *topic, byte *payload, size_t length) { if (length == 0) { - LOG_WARN("Empty MQTT payload received, topic %s!", topic); + LOG_WARN("Empty MQTT payload, topic %s", topic); return; } @@ -413,7 +413,7 @@ MQTT::MQTT() : concurrency::OSThread("mqtt"), mqttQueue(MAX_MQTT_QUEUE) #endif if (moduleConfig.mqtt.proxy_to_client_enabled) { - LOG_INFO("MQTT configured to use client proxy"); + LOG_INFO("MQTT uses client proxy"); enabled = true; runASAP = true; reconnectCount = 0; @@ -520,16 +520,16 @@ void MQTT::reconnect() } else { #if HAS_WIFI && !defined(ARCH_PORTDUINO) reconnectCount++; - LOG_ERROR("Failed to contact MQTT server directly (%d/%d)", reconnectCount, reconnectMax); + LOG_ERROR("Direct MQTT contact failed (%d/%d)", reconnectCount, reconnectMax); if (reconnectCount >= reconnectMax) { #if defined(USE_WS5500) || defined(USE_CH390D) - LOG_WARN("MQTT connect failed repeatedly; waiting for Ethernet reconnect"); + LOG_WARN("MQTT connect keeps failing; wait for Ethernet reconnect"); #else needReconnect = true; if (wifiReconnect) { wifiReconnect->setIntervalFromNow(0); } else { - LOG_WARN("MQTT connect failed repeatedly, but WiFi reconnect is unavailable"); + LOG_WARN("MQTT connect keeps failing, WiFi reconnect unavailable"); } #endif reconnectCount = 0; @@ -615,7 +615,7 @@ bool MQTT::isValidConfig(const meshtastic_ModuleConfig_MQTTConfig &config, MQTTC #if HAS_NETWORKING if (config.tls_enabled) { #if !MQTT_SUPPORTS_TLS - LOG_ERROR("Invalid MQTT config: tls_enabled is not supported on this node"); + LOG_ERROR("Invalid MQTT config: tls_enabled unsupported on this node"); return false; #endif } @@ -700,6 +700,12 @@ void MQTT::onSend(const meshtastic_MeshPacket &mp_encrypted, const meshtastic_Me { if (mp_encrypted.via_mqtt) return; // Don't send messages that came from MQTT back into MQTT +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + if (isBlockedEventCoordinatePacket(&mp_decoded)) { + LOG_DEBUG("MQTT onSend - Suppress coordinate packet on event channel"); + return; + } +#endif bool uplinkEnabled = false; for (int i = 0; i <= 7; i++) { if (channels.getByIndex(i).settings.uplink_enabled) @@ -715,13 +721,13 @@ void MQTT::onSend(const meshtastic_MeshPacket &mp_encrypted, const meshtastic_Me bool dontUplink = !mp_decoded.decoded.has_bitfield || !(mp_decoded.decoded.bitfield & BITFIELD_OK_TO_MQTT_MASK); // Respect the DontMqttMeBro flag for other nodes' packets on public MQTT servers if (!isFromUs(&mp_decoded) && !isMqttServerAddressPrivate && dontUplink) { - LOG_INFO("MQTT onSend - Not forwarding packet due to DontMqttMeBro flag"); + LOG_INFO("MQTT onSend - drop packet: DontMqttMeBro flag"); return; } if (isConfiguredForDefaultServer && (mp_decoded.decoded.portnum == meshtastic_PortNum_RANGE_TEST_APP || mp_decoded.decoded.portnum == meshtastic_PortNum_DETECTION_SENSOR_APP)) { - LOG_DEBUG("MQTT onSend - Ignoring range test or detection sensor message on public mqtt"); + LOG_DEBUG("MQTT onSend - Ignore range test/detection sensor msg on public mqtt"); return; } } @@ -769,7 +775,7 @@ void MQTT::onSend(const meshtastic_MeshPacket &mp_encrypted, const meshtastic_Me entry->topic = std::move(topic); entry->envBytes.assign(bytes, numBytes); if (mqttQueue.enqueue(entry, 0) == false) { - LOG_CRIT("Failed to add a message to mqttQueue!"); + LOG_CRIT("Can't add msg to mqttQueue"); abort(); } } @@ -777,6 +783,12 @@ void MQTT::onSend(const meshtastic_MeshPacket &mp_encrypted, const meshtastic_Me void MQTT::perhapsReportToMap() { +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + if (channels.isEventChannel(channels.getPrimaryIndex())) { + LOG_DEBUG("Suppress MQTT map report on event (everyone) channel"); + return; + } +#endif if (!moduleConfig.mqtt.map_reporting_enabled || !moduleConfig.mqtt.map_report_settings.should_report_location || !(moduleConfig.mqtt.proxy_to_client_enabled || isConnectedDirectly())) return; @@ -784,7 +796,7 @@ void MQTT::perhapsReportToMap() // Coerce the map position precision to be within the valid range // This removes obtusely large radius and privacy problematic ones from the map if (map_position_precision < 12 || map_position_precision > 15) { - LOG_WARN("MQTT Map report position precision %u is out of range, using default %u", map_position_precision, + LOG_WARN("MQTT Map report position precision %u out of range, use default %u", map_position_precision, default_map_position_precision); map_position_precision = default_map_position_precision; } @@ -794,7 +806,7 @@ void MQTT::perhapsReportToMap() if (localPosition.latitude_i == 0 && localPosition.longitude_i == 0) { if (Throttle::isWithinTimespanMs(lastPositionUnavailableWarning, POSITION_UNAVAILABLE_WARNING_INTERVAL_MS) == false) { - LOG_WARN("MQTT Map report enabled, but no position available"); + LOG_WARN("MQTT Map report enabled but no position"); lastPositionUnavailableWarning = millis(); } return; diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index bd3de1601f..e01c32c72d 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -70,10 +70,10 @@ static void purgeIncompatibleBleBonds() bool wiped = false; if (mismatch) { - LOG_WARN("Wiping incompatible NimBLE bonds (on-disk format changed)"); + LOG_WARN("Wiping incompatible NimBLE bonds (format changed)"); wiped = nvs_erase_all(handle) == ESP_OK && nvs_commit(handle) == ESP_OK; if (!wiped) { - LOG_ERROR("Failed to erase nimble_bond namespace"); + LOG_ERROR("nimble_bond namespace erase failed"); } } @@ -347,8 +347,7 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread toPhoneQueueSize++; } #ifdef DEBUG_NIMBLE_ON_READ_TIMING - LOG_DEBUG("BLE getFromRadio returned numBytes=%u, pushed toPhoneQueueSize=%u", numBytes, - toPhoneQueueSize.load()); + LOG_DEBUG("BLE getFromRadio numBytes=%u, toPhoneQueueSize=%u", numBytes, toPhoneQueueSize.load()); #endif } else { // Shouldn't happen because the onRead callback shouldn't be waiting if the queue is full! @@ -370,7 +369,7 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread // Note: the comparison above is safe without a mutex because we are the only method that *decreases* // fromPhoneQueueSize. (It's okay if fromPhoneQueueSize *increases* in the NimBLE task meanwhile.) - LOG_DEBUG("NimbleBluetooth: handling ToRadio packet, fromPhoneQueueSize=%u", fromPhoneQueueSize.load()); + LOG_DEBUG("NimbleBluetooth: ToRadio packet, fromPhoneQueueSize=%u", fromPhoneQueueSize.load()); // Pop the front of fromPhoneQueue, holding the mutex only briefly while we pop. BLEValue val; @@ -546,7 +545,7 @@ class NimbleBluetoothFromRadioCallback : public BLECharacteristicCallbacks // There's already a packet queued. Great! We don't need to wait for onReadCallbackIsWaitingForData. #ifdef DEBUG_NIMBLE_ON_READ_TIMING - LOG_DEBUG("BLE onRead(%d): packet already waiting, no need to set onReadCallbackIsWaitingForData", currentReadCount); + LOG_DEBUG("BLE onRead(%d): packet already waiting, skip onReadCallbackIsWaitingForData", currentReadCount); #endif } else if (!bleDraining) { // (If deinit() is tearing the stack down, skip the wait entirely and just return a 0-size @@ -580,9 +579,8 @@ class NimbleBluetoothFromRadioCallback : public BLECharacteristicCallbacks tries++; if (tries == 4000) { - LOG_WARN( - "BLE onRead(%d): timeout waiting for data after %u ms, %d tries, giving up and returning 0-size response", - currentReadCount, millis() - startMillis, tries); + LOG_WARN("BLE onRead(%d): data timeout after %u ms, %d tries, returning 0-size response", currentReadCount, + millis() - startMillis, tries); } } } @@ -697,7 +695,7 @@ class NimbleBluetoothSecurityCallback : public BLESecurityCallbacks // yields a *failed* encryption change here -- don't latch a connected/authenticated state // on a link that is actually being torn down. if (desc == nullptr || !desc->sec_state.encrypted) { - LOG_WARN("BLE encryption change without an encrypted link; ignoring"); + LOG_WARN("BLE encryption change without encrypted link; ignoring"); return; } @@ -764,7 +762,7 @@ class NimbleBluetoothServerCallback : public BLEServerCallbacks if (dataLenResult == 0) { LOG_INFO("BLE conn %u requested data length %u bytes", connHandle, kPreferredBleTxOctets); } else { - LOG_WARN("Failed to raise data length for conn %u, rc=%d", connHandle, dataLenResult); + LOG_WARN("Can't raise data length for conn %u, rc=%d", connHandle, dataLenResult); } #endif @@ -812,7 +810,7 @@ void NimbleBluetooth::startAdvertising() pAdvertising->setMaxPreferred(0x12); if (!pAdvertising->start(0)) { - LOG_ERROR("BLE failed to start advertising"); + LOG_ERROR("BLE advertising start failed"); } else { LOG_DEBUG("BLE Advertising started"); } @@ -908,7 +906,7 @@ void NimbleBluetooth::setup() // Uncomment for testing // NimbleBluetooth::clearBonds(); - LOG_INFO("Init the NimBLE bluetooth module"); + LOG_INFO("Init NimBLE bluetooth"); // deinit() latches these teardown guards; clear them so a re-init on the same boot (e.g. an // admin disable-bluetooth followed by re-enable) doesn't leave onRead stuck draining or @@ -929,7 +927,7 @@ void NimbleBluetooth::setup() if (mtuResult == 0) { LOG_INFO("BLE MTU request set to %u", kPreferredBleMtu); } else { - LOG_WARN("Unable to request MTU %u, rc=%d", kPreferredBleMtu, mtuResult); + LOG_WARN("Can't request MTU %u, rc=%d", kPreferredBleMtu, mtuResult); } // BLESecurity only forwards to static NimBLEDevice setters; a stack instance suffices. @@ -1027,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) @@ -1063,7 +1064,7 @@ void updateBatteryLevel(uint8_t level) void NimbleBluetooth::clearBonds() { - LOG_INFO("Clearing bluetooth bonds!"); + LOG_INFO("Clearing bluetooth bonds"); ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_OUR_SEC, nullptr); ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_PEER_SEC, nullptr); ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_CCCD, nullptr); diff --git a/src/platform/esp32/ESP32CryptoEngine.cpp b/src/platform/esp32/ESP32CryptoEngine.cpp index b554a3de44..f73895b6e9 100644 --- a/src/platform/esp32/ESP32CryptoEngine.cpp +++ b/src/platform/esp32/ESP32CryptoEngine.cpp @@ -32,7 +32,7 @@ class ESP32CryptoEngine : public CryptoEngine sizeof(scratch) - numBytes); // Fill rest of buffer with zero (in case cypher looks at it) mbedtls_aes_crypt_ctr(&aes, numBytes, &nc_off, _nonce, stream_block, scratch, bytes); } else { - LOG_ERROR("Packet too large for crypto engine: %d. noop encryption!", numBytes); + LOG_ERROR("Packet too large for crypto engine: %d. noop encryption", numBytes); } } } diff --git a/src/platform/esp32/MeshtasticOTA.cpp b/src/platform/esp32/MeshtasticOTA.cpp index dfe04583d4..ce59188c78 100644 --- a/src/platform/esp32/MeshtasticOTA.cpp +++ b/src/platform/esp32/MeshtasticOTA.cpp @@ -102,14 +102,14 @@ bool trySwitchToOTA() const esp_partition_t *part = getAppPartition(); if (part == NULL) { - LOG_WARN("Unable to get app partition in preparation of OTA reboot"); + LOG_WARN("Can't get app partition in preparation of OTA reboot"); return false; } uint8_t result = esp_ota_set_boot_partition(part); // Partition and app checks should now be done in the AdminModule before this is called if (result != ESP_OK) { - LOG_WARN("Unable to switch to OTA partiton. (Reason %d)", result); + LOG_WARN("Can't switch to OTA partition (reason %d)", result); return false; } diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index afbf4482d1..f7879a333f 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -69,7 +69,7 @@ static bool shouldReleaseBluetoothMemory() // Paxcounter disables the Meshtastic BLE service, but libpax still needs the // ESP32 BLE controller memory for scanning. if (isPaxcounterActiveForBoot()) { - LOG_DEBUG("Skipping Bluetooth memory release because Paxcounter is active"); + LOG_DEBUG("Skip BT memory release: Paxcounter active"); return false; } @@ -96,7 +96,7 @@ void setBluetoothEnable(bool enable) if (enable && bluetoothMemoryReleased) { if (!shouldReleaseBluetoothMemory() && !bluetoothMemoryReleaseWarned) { bluetoothMemoryReleaseWarned = true; - LOG_WARN("Bluetooth memory has been released; reboot to re-enable Bluetooth"); + LOG_WARN("BT memory released; reboot to re-enable"); } return; } @@ -205,7 +205,7 @@ void enableSlowCLK() LOG_DEBUG("32k XTAL OSC has not started up"); } else { rtc_clk_slow_freq_set(RTC_SLOW_FREQ_32K_XTAL); - LOG_DEBUG("Switch RTC Source to 32.768kHz succeeded, using 32k XTAL"); + LOG_DEBUG("RTC source now 32k XTAL"); CALIBRATE_ONE(RTC_CAL_RTC_MUX); CALIBRATE_ONE(RTC_CAL_32K_XTAL); } @@ -285,14 +285,14 @@ void esp32Setup() }; res = esp_task_wdt_init(&wdt_config); if (res == ESP_ERR_INVALID_STATE) { - LOG_WARN("Task watchdog already initialized, reconfiguring existing instance"); + LOG_WARN("Task watchdog already init, reconfiguring"); res = esp_task_wdt_reconfigure(&wdt_config); } assert(res == ESP_OK); #else res = esp_task_wdt_init(APP_WATCHDOG_SECS, true); if (res == ESP_ERR_INVALID_STATE) { - LOG_WARN("Task watchdog already initialized, reusing existing instance"); + LOG_WARN("Task watchdog already init, reusing"); res = ESP_OK; } assert(res == ESP_OK); diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp index 6c1dacb64b..a83b04b0fb 100644 --- a/src/platform/extra_variants/t5s3_epaper/variant.cpp +++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp @@ -10,6 +10,7 @@ #include "input/InputBroker.h" #include "input/TouchScreenImpl1.h" #include "main.h" +#include "mesh/Throttle.h" #include "sleep.h" #include @@ -100,7 +101,10 @@ volatile bool touchControllerReady = false; volatile bool touchLightSleepActive = false; volatile bool touchNeedsWake = false; volatile bool touchIndicatorRefreshPending = false; -volatile uint32_t touchResumeBlockUntilMs = 0; +// When the light-sleep resume happened, not when the block expires: an interval bounds a missed +// 0-check by the settle time, where a stored deadline would block for up to half a wrap cycle. +constexpr uint32_t TOUCH_RESUME_BLOCK_MS = 150; +volatile uint32_t touchResumeAtMs = 0; volatile uint32_t touchStateEpoch = 1; volatile bool homeCapButtonEventsEnabled = false; #if HAS_SCREEN @@ -184,7 +188,8 @@ class SideKeyInterruptThread : public concurrency::OSThread { const uint32_t now = millis(); - if (now < touchResumeBlockUntilMs) { + // 0 means the device has never light-slept, so no block is armed - test it first. + if (touchResumeAtMs != 0 && Throttle::isWithinTimespanMs(touchResumeAtMs, TOUCH_RESUME_BLOCK_MS)) { resetStateAndStop(); return OSThread::disable(); } @@ -279,8 +284,8 @@ class SideKeyInterruptThread : public concurrency::OSThread if (touchLightSleepActive) { return; } - const uint32_t now = millis(); - if (now < touchResumeBlockUntilMs) { + // See the runOnce() guard above for why 0 must be tested separately. + if (touchResumeAtMs != 0 && Throttle::isWithinTimespanMs(touchResumeAtMs, TOUCH_RESUME_BLOCK_MS)) { return; } if (state != State::REST) { @@ -550,7 +555,7 @@ struct TouchLightSleepEndObserver { } touchStateEpoch++; - touchResumeBlockUntilMs = millis() + 150; + touchResumeAtMs = millis(); touchIndicatorRefreshPending = !isTouchInputEnabled(); #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS // Clear sleep-time touch overlay after wake. @@ -569,17 +574,18 @@ struct TouchLightSleepEndObserver { bool readTouch(int16_t *x, int16_t *y) { #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - static uint32_t suppressUntilMs = 0; + constexpr uint32_t TOUCH_WAKE_SUPPRESS_MS = 60; + static uint32_t suppressFromMs = 0; // 0 = not suppressing, same reading as touchResumeAtMs static uint32_t seenTouchStateEpoch = 0; // Reset transient gesture helpers whenever touch mode changes. if (seenTouchStateEpoch != touchStateEpoch) { seenTouchStateEpoch = touchStateEpoch; - suppressUntilMs = 0; + suppressFromMs = 0; } - // Let buses and peripherals settle briefly after light-sleep wake. - if (millis() < touchResumeBlockUntilMs) { + // Let buses and peripherals settle briefly after light-sleep wake. 0 means no wake yet. + if (touchResumeAtMs != 0 && Throttle::isWithinTimespanMs(touchResumeAtMs, TOUCH_RESUME_BLOCK_MS)) { return false; } @@ -596,12 +602,12 @@ bool readTouch(int16_t *x, int16_t *y) LOG_DEBUG("touchscreen1: wakeup() on deferred resume"); touch.wakeup(); touchNeedsWake = false; - suppressUntilMs = millis() + 60; + suppressFromMs = millis(); return false; } // After a recovery pulse, emit a brief "released" window so gesture state can reset. - if (suppressUntilMs != 0 && millis() < suppressUntilMs) { + if (suppressFromMs != 0 && Throttle::isWithinTimespanMs(suppressFromMs, TOUCH_WAKE_SUPPRESS_MS)) { return false; } #endif @@ -696,7 +702,7 @@ void lateInitVariant() #endif } else { touchControllerReady = false; - LOG_ERROR("Failed to find touch controller!"); + LOG_ERROR("Failed to find touch controller"); } #if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK) diff --git a/src/platform/extra_variants/t_deck_pro/variant.cpp b/src/platform/extra_variants/t_deck_pro/variant.cpp index 2915ff3633..20105da60c 100644 --- a/src/platform/extra_variants/t_deck_pro/variant.cpp +++ b/src/platform/extra_variants/t_deck_pro/variant.cpp @@ -119,7 +119,7 @@ void lateInitVariant() break; } else { - LOG_DEBUG("CST3530 not response ~!"); + LOG_DEBUG("CST3530 no response"); } } uint8_t cmd1[] = {0xD0, 0x00, 0x04, 0x00}; diff --git a/src/platform/nrf52/AsyncUDP.cpp b/src/platform/nrf52/AsyncUDP.cpp index 8c937d71f3..5431597c5b 100644 --- a/src/platform/nrf52/AsyncUDP.cpp +++ b/src/platform/nrf52/AsyncUDP.cpp @@ -8,8 +8,9 @@ bool AsyncUDP::listenMulticast(IPAddress multicastIP, uint16_t port, uint8_t ttl { if (!isMulticast(multicastIP)) return false; + if (!udp.beginMulticast(multicastIP, port)) + return false; localPort = port; - udp.beginMulticast(multicastIP, port); return true; } @@ -80,4 +81,4 @@ int32_t AsyncUDP::runOnce() return 5; // check every 5ms } -#endif // HAS_ETHERNET \ No newline at end of file +#endif // HAS_ETHERNET diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index f1d9c6845d..85a29e05a9 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -255,7 +255,7 @@ void NRF52Bluetooth::startDisabled() // Shutdown bluetooth for minimum power draw Bluefruit.Advertising.stop(); Bluefruit.setTxPower(-40); // Minimum power - LOG_INFO("Disable NRF52 Bluetooth. (Workaround: tx power min, advertise stopped)"); + LOG_INFO("Disable NRF52 BT (tx power min, advertise stopped)"); } bool NRF52Bluetooth::isConnected() { @@ -283,7 +283,7 @@ void NRF52Bluetooth::setup() // current Bluefruit config. Without this check the node would silently run without BLE. // Rebuild with -DCFG_DEBUG=1 to get "SoftDevice's RAM requires: 0x..." in the log, then // raise the ORIGIN accordingly. - LOG_ERROR("Bluefruit.begin failed - SoftDevice RAM reservation too small for this config"); + LOG_ERROR("Bluefruit.begin failed: SoftDevice RAM too small"); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_UNSPECIFIED); return; } @@ -399,7 +399,7 @@ void updateBatteryLevel(uint8_t level) } void NRF52Bluetooth::clearBonds() { - LOG_INFO("Clear bluetooth bonds!"); + LOG_INFO("Clear bluetooth bonds"); bond_print_list(BLE_GAP_ROLE_PERIPH); bond_print_list(BLE_GAP_ROLE_CENTRAL); Bluefruit.Periph.clearBonds(); @@ -446,7 +446,7 @@ bool NRF52Bluetooth::onPairingPasskey(uint16_t conn_handle, uint8_t const passke if (match_request) { uint32_t start_time = millis(); - while (millis() < start_time + 30000) { + while (Throttle::isWithinTimespanMs(start_time, 30000)) { if (!Bluefruit.connected(conn_handle)) break; } @@ -481,7 +481,7 @@ void NRF52Bluetooth::disconnect() delay(1); if (Bluefruit.connected()) - LOG_WARN("BLE disconnect unconfirmed after %ums, continuing shutdown", millis() - start); + LOG_WARN("BLE disconnect unconfirmed after %ums, shutdown anyway", millis() - start); else LOG_INFO("Ended BLE connection"); } diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index ae12ef4a0c..865e1c3633 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -1,4 +1,6 @@ +#include "UptimeClock.h" #include "configuration.h" +#include "mesh/Throttle.h" #include #include #include @@ -51,12 +53,14 @@ uint16_t getVDDVoltage(); // Weak empty variant shutdown prep function. // May be redefined by variant files. -void variant_shutdown() __attribute__((weak)); -void variant_shutdown() {} +// noinline: same reason as variant_enableBatteryLpcompWake() below -- weak default and call +// site are in this file, so LTO would inline the empty body and drop the variant's override. +__attribute__((noinline)) void variant_shutdown() __attribute__((weak)); +__attribute__((noinline)) void variant_shutdown() {} // Optional variant hook called each nrf52Loop(); e.g. for low-VDD System OFF. -void variant_nrf52LoopHook(void) __attribute__((weak)); -void variant_nrf52LoopHook(void) {} +__attribute__((noinline)) void variant_nrf52LoopHook(void) __attribute__((weak)); +__attribute__((noinline)) void variant_nrf52LoopHook(void) {} // Return false to skip LPCOMP wake when entering System OFF (e.g. user CLI shutdown). // noinline: weak default and call site are in this file; without it GCC may inline the @@ -268,12 +272,17 @@ namespace { constexpr uint8_t NRF52_MAGIC_LFS_IS_CORRUPT = 0xF5; constexpr uint32_t MULTIPLE_CORRUPTION_DELAY_MILLIS = 20 * 60 * 1000; -static unsigned long millis_until_formatting_again = 0; +// When the last format happened, not when the next one is due: measuring forward from the event +// bounds the pause below by the constant, where a stored deadline could hand delay() any value. +// Armed separately because preFSBegin() runs in the first millisecond of boot, so a zero timestamp +// is a legitimate value here, not an "unset" marker. +static uint32_t last_format_ms = 0; +static bool formatted_this_boot = false; // Report the critical error from loop(), giving a chance for the screen to be initialized first. inline void reportLittleFSCorruptionOnce() { - static bool report_corruption = !!millis_until_formatting_again; + static bool report_corruption = formatted_this_boot; if (report_corruption) { report_corruption = false; RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE); @@ -288,7 +297,8 @@ void preFSBegin() if (!(NRF_POWER->RESETREAS == 0 && NRF_POWER->GPREGRET == NRF52_MAGIC_LFS_IS_CORRUPT)) return; NRF_POWER->GPREGRET = 0; - millis_until_formatting_again = millis() + MULTIPLE_CORRUPTION_DELAY_MILLIS; + last_format_ms = Time::getMillis(); + formatted_this_boot = true; InternalFS.format(); LOG_INFO("LittleFS format complete; restoring default settings"); } @@ -296,10 +306,16 @@ void preFSBegin() extern "C" void lfs_assert(const char *reason) { LOG_ERROR("LittleFS corruption detected: %s", reason); - if (millis_until_formatting_again > millis()) { + // Test the armed flag first, since elapsed-since-0 is inside the backoff for the first 20 + // 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 = millis_until_formatting_again - millis(); - 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"); @@ -419,7 +435,7 @@ void nrf52Setup() #ifdef BQ25703A_ADDR auto *bq = new BQ25713(); if (!bq->setup()) - LOG_ERROR("ERROR! Charge controller init failed"); + LOG_ERROR("Charge controller init failed"); #endif // Init random seed @@ -526,7 +542,7 @@ void cpuDeepSleep(uint32_t msecToWake) auto ok = sd_power_system_off(); if (ok != NRF_SUCCESS) { - LOG_ERROR("FIXME: Ignoring soft device (EasyDMA pending?) and forcing system-off!"); + LOG_ERROR("FIXME: Ignoring soft device (EasyDMA pending?) and forcing system-off"); NRF_POWER->SYSTEMOFF = 1; } } diff --git a/src/platform/nrf54l15/Arduino.h b/src/platform/nrf54l15/Arduino.h index b608c4856c..0d7449e891 100644 --- a/src/platform/nrf54l15/Arduino.h +++ b/src/platform/nrf54l15/Arduino.h @@ -297,6 +297,7 @@ class Print } virtual void flush() {} + virtual int availableForWrite() { return 0; } }; // ── Stream base class ──────────────────────────────────────────────────────── @@ -598,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; @@ -611,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/nrf54l15/InternalFileSystem.cpp b/src/platform/nrf54l15/InternalFileSystem.cpp index 18ea368e21..15fdec6df6 100644 --- a/src/platform/nrf54l15/InternalFileSystem.cpp +++ b/src/platform/nrf54l15/InternalFileSystem.cpp @@ -77,7 +77,7 @@ bool InternalFileSystem::begin() } // Mount failed: attempt to format (creates a fresh LittleFS) - LOG_WARN("LittleFS mount failed (%d), formatting storage partition...", rc); + LOG_WARN("LittleFS mount failed (%d), formatting storage partition", rc); int fmt_rc = fs_mkfs(FS_LITTLEFS, (uintptr_t)FIXED_PARTITION_ID(storage_partition), NULL, 0); if (fmt_rc != 0) { LOG_ERROR("LittleFS format failed (%d)", fmt_rc); diff --git a/src/platform/nrf54l15/NRF54L15Bluetooth.cpp b/src/platform/nrf54l15/NRF54L15Bluetooth.cpp index c6c751ae27..9ce0326316 100644 --- a/src/platform/nrf54l15/NRF54L15Bluetooth.cpp +++ b/src/platform/nrf54l15/NRF54L15Bluetooth.cpp @@ -442,7 +442,7 @@ static void security_changed_cb(struct bt_conn *conn, bt_security_t level, enum if (err == BT_SECURITY_ERR_PIN_OR_KEY_MISSING) { // Phone has a stale bond (device was wiped/reflashed). Unpair the stale // entry so the phone re-pairs cleanly on the next connection attempt. - LOG_WARN("BLE stale bond detected (key missing) - unpairing"); + LOG_WARN("BLE stale bond (key missing) - unpairing"); bt_unpair(BT_ID_DEFAULT, bt_conn_get_dst(conn)); bt_conn_disconnect(conn, BT_HCI_ERR_AUTH_FAIL); } else if (err) { @@ -691,7 +691,7 @@ static bool nrf54l15_bt_init_common() // instead of leaving BLE silently broken. if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) { LOG_WARN("BLE: NO_PIN not supported on nRF54L15-DK (MITM-only build); " - "treating as RANDOM_PIN"); + "treat as RANDOM_PIN"); } bt_conn_auth_cb_register(&auth_cb); @@ -759,7 +759,7 @@ void NRF54L15Bluetooth::startDisabled() return; } ble_enabled = false; - LOG_INFO("BLE initialized, advertising stopped (startDisabled)"); + LOG_INFO("BLE initialized, adv stopped (startDisabled)"); } void NRF54L15Bluetooth::resumeAdvertising() diff --git a/src/platform/portduino/GpsdSerial.cpp b/src/platform/portduino/GpsdSerial.cpp index 1b80c7d06d..e85b951c04 100644 --- a/src/platform/portduino/GpsdSerial.cpp +++ b/src/platform/portduino/GpsdSerial.cpp @@ -115,7 +115,7 @@ bool GpsdSerial::connectToGpsd() std::string portStr = std::to_string(_port); if (getaddrinfo(_host.c_str(), portStr.c_str(), &hints, &res) != 0 || !res) { - LOG_WARN("gpsdSerial: could not resolve %s", _host.c_str()); + LOG_WARN("gpsdSerial: can't resolve %s", _host.c_str()); 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/SimRadio.cpp b/src/platform/portduino/SimRadio.cpp index 2786903ebb..d60c34db07 100644 --- a/src/platform/portduino/SimRadio.cpp +++ b/src/platform/portduino/SimRadio.cpp @@ -27,7 +27,7 @@ ErrorCode SimRadio::send(meshtastic_MeshPacket *p) // set (random) transmit delay to let others reconfigure their radio, // to avoid collisions and implement timing-based flooding - LOG_DEBUG("Set random delay before tx"); + LOG_TRACE("Set random delay before tx"); setTransmitDelay(); return res; } @@ -47,7 +47,7 @@ void SimRadio::setTransmitDelay() startTransmitTimer(true); } else { // If there is a SNR, start a timer scaled based on that SNR. - LOG_DEBUG("rx_snr found. hop_limit:%d rx_snr:%f", p->hop_limit, p->rx_snr); + LOG_TRACE("rx_snr found. hop_limit:%d rx_snr:%f", p->hop_limit, p->rx_snr); startTransmitTimerRebroadcast(p); } } @@ -169,7 +169,7 @@ void SimRadio::onNotify(uint32_t notification) startTransmitTimer(); break; } - LOG_DEBUG("delay done"); + LOG_TRACE("delay done"); // If we are not currently in receive mode, then restart the random delay (this can happen if the main thread // has placed the unit into standby) FIXME, how will this work if the chipset is in sleep mode? @@ -238,7 +238,7 @@ void SimRadio::startSend(meshtastic_MeshPacket *txp) memcpy(&c.data.bytes, p->encrypted.bytes, p->encrypted.size); c.data.size = p->encrypted.size; } else { - LOG_WARN("Encrypted payload (%u) exceeds sim loopback capacity (%u)! Send empty payload", (unsigned)p->encrypted.size, + LOG_WARN("Encrypted payload (%u) > sim loopback capacity (%u), send empty", (unsigned)p->encrypted.size, (unsigned)loopbackCapacity); } } else { @@ -248,7 +248,7 @@ void SimRadio::startSend(meshtastic_MeshPacket *txp) memcpy(&c.data.bytes, p->decoded.payload.bytes, p->decoded.payload.size); c.data.size = p->decoded.payload.size; } else { - LOG_WARN("Payload size larger than compressed message allows! Send empty payload"); + LOG_WARN("Payload > compressed max, send empty"); } } @@ -295,7 +295,7 @@ void SimRadio::unpackAndReceive(meshtastic_MeshPacket &p) p.decoded.portnum = scratch.portnum; } } else - LOG_ERROR("Error decoding proto for simulator message!"); + LOG_ERROR("Error decoding proto for simulator message"); } // Let SimRadio receive as if it did via its LoRa chip startReceive(&p); @@ -305,7 +305,7 @@ void SimRadio::startReceive(meshtastic_MeshPacket *p) { #ifdef USERPREFS_SIMRADIO_EMULATE_COLLISIONS if (isActivelyReceiving()) { - LOG_WARN("Collision detected, dropping current and previous packet!"); + LOG_WARN("Collision detected, dropping current and previous packet"); rxBad++; airTime->logAirtime(RX_ALL_LOG, getPacketTime(receivingPacket, true)); packetPool.release(receivingPacket); @@ -319,7 +319,7 @@ void SimRadio::startReceive(meshtastic_MeshPacket *p) } else if ((interval - airtimeLeft) > preambleTimeMsec) { // Only if transmitting for longer than preamble there is a collision // (channel should actually be detected as active otherwise) - LOG_WARN("Collision detected during transmission!"); + LOG_WARN("Collision detected during transmission"); return; } } @@ -359,11 +359,11 @@ void SimRadio::handleReceiveInterrupt() } if (!isReceiving) { - LOG_DEBUG("*** WAS_ASSERT *** handleReceiveInterrupt called when not in receive mode"); + LOG_DEBUG("*** WAS_ASSERT *** handleReceiveInterrupt outside receive mode"); return; } - LOG_DEBUG("HANDLE RECEIVE INTERRUPT"); + LOG_TRACE("HANDLE RECEIVE INTERRUPT"); rxGood++; meshtastic_MeshPacket *mp = packetPool.allocCopy(*receivingPacket); // keep a copy in packetPool diff --git a/src/platform/portduino/USBHal.h b/src/platform/portduino/USBHal.h index 2c00ed3904..e07fb70dc8 100644 --- a/src/platform/portduino/USBHal.h +++ b/src/platform/portduino/USBHal.h @@ -89,7 +89,7 @@ class Ch341Hal : public RadioLibHal } auto res = pinedio_set_pin_mode(&pinedio, pin, mode); if (res < 0 && rebootAtMsec == 0) { - LOG_ERROR("USBHal pinMode: Could not set pin %u mode to %u: %d", pin, mode, res); + LOG_ERROR("USBHal pinMode: Can't set pin %u mode to %u: %d", pin, mode, res); } } @@ -103,7 +103,7 @@ class Ch341Hal : public RadioLibHal } auto res = pinedio_digital_write(&pinedio, pin, value); if (res < 0 && rebootAtMsec == 0) { - LOG_ERROR("USBHal digitalWrite: Could not write pin %u: %d", pin, res); + LOG_ERROR("USBHal digitalWrite: Can't write pin %u: %d", pin, res); portduino_status.LoRa_in_error = true; } } @@ -118,7 +118,7 @@ class Ch341Hal : public RadioLibHal } auto res = pinedio_digital_read(&pinedio, pin); if (res < 0 && rebootAtMsec == 0) { - LOG_ERROR("USBHal digitalRead: Could not read pin %u: %d", pin, res); + LOG_ERROR("USBHal digitalRead: Can't read pin %u: %d", pin, res); portduino_status.LoRa_in_error = true; return 0; } diff --git a/src/platform/rp2xx0/main-rp2xx0.cpp b/src/platform/rp2xx0/main-rp2xx0.cpp index cb95f8e845..75646a52f7 100644 --- a/src/platform/rp2xx0/main-rp2xx0.cpp +++ b/src/platform/rp2xx0/main-rp2xx0.cpp @@ -111,7 +111,7 @@ bool getDeviceId(uint8_t *deviceId) void rp2040Setup() { if (watchdog_caused_reboot()) { - LOG_WARN("Rebooted by watchdog!"); + LOG_WARN("Rebooted by watchdog"); } /* Sets a random seed to make sure we get different random numbers on each boot. */ 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/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index d429c5662d..50f7ae3d81 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -18,20 +18,9 @@ static bool stm32wlRtcValid = false; #endif // ─── Bootloader redirect ────────────────────────────────────────────────────── -// -// Why .noinit + constructor instead of TAMP backup registers: -// -// The STM32duino startup sequence initialises clocks which may call -// __HAL_RCC_BACKUPRESET_FORCE/RELEASE when configuring the LSE oscillator, -// wiping the entire backup domain (including TAMP->BKP0R) before setup() -// ever runs. The backup-register approach therefore cannot reliably survive -// a soft reset in this toolchain. -// -// Solution: store the magic in a .noinit SRAM variable. -// - NVIC_SystemReset() does NOT clear SRAM. -// - The linker script skips zero-init for .noinit sections. -// - __attribute__((constructor)) fires before main()/HAL_Init(), so we can -// intercept and jump before anything disturbs peripheral state. +// Uses .noinit SRAM instead of TAMP backup registers: STM32duino's clock init can wipe the +// backup domain via __HAL_RCC_BACKUPRESET_FORCE/RELEASE before setup() runs, but .noinit +// survives NVIC_SystemReset() and this constructor fires before HAL_Init() touches anything. #define BOOTLOADER_MAGIC 0xD00DB007UL #define SYS_MEM_BASE 0x1FFF0000UL @@ -58,8 +47,10 @@ __attribute__((constructor(101), used)) static void earlyBootCheck(void) SCB->VTOR = SYS_MEM_BASE; __set_MSP(*(volatile uint32_t *)SYS_MEM_BASE); ((void (*)(void))(*(volatile uint32_t *)(SYS_MEM_BASE + 4)))(); - while (1) - ; + // Should never be reached: the bootloader ROM does not return. A bare reset + // (rather than returning normally) avoids unwinding through this function's + // epilogue, which would restore registers relative to the now-repointed MSP. + NVIC_SystemReset(); } void enterDfuMode() @@ -146,7 +137,7 @@ void cpuDeepSleep(uint32_t msecToWake) if (!stm32wlRtcAvailable()) { // Hardware can't shutdown, but firmware has already prepared itself for shutdown // Do not leave the device unresponsive, reset instead - LOG_WARN("STM32WL: hardware RTC failed, cannot deep sleep/shutdown"); + LOG_WARN("STM32WL: hardware RTC failed, can't deep sleep/shutdown"); if (Serial) { Serial.flush(); Serial.end(); @@ -169,15 +160,7 @@ void cpuDeepSleep(uint32_t msecToWake) #endif } -// Hacks to force more code and data out. - -// By default __assert_func uses fiprintf which pulls in stdio. -extern "C" void __wrap___assert_func(const char *, int, const char *, const char *) -{ - while (true) - ; - return; -} +// ─── Linker hacks to reduce code size ───────────────────────────────────────── // By default strerror has a lot of strings we probably don't use. Make it return an empty string instead. char empty = 0; @@ -197,6 +180,8 @@ extern "C" void __wrap__tzset_unlocked_r(struct _reent *reent_ptr) } #endif +// ─── Fault handling & recovery ──────────────────────────────────────────────── + // Taken from https://interrupt.memfault.com/blog/cortex-m-hardfault-debug typedef struct __attribute__((packed)) ContextStateFrame { uint32_t r0; @@ -233,32 +218,11 @@ static void debug_printf(const char *format, ...) uart_debug_write((uint8_t *)hardfault_message_buffer, min((unsigned int)length, sizeof(hardfault_message_buffer) - 1)); } -// N picked by guessing -#define DOT_TIME 1200000 -static void dot() +// By default __assert_func uses fiprintf which pulls in stdio. +extern "C" void __wrap___assert_func(const char *file, int line, const char *func, const char *failedexpr) { - digitalWrite(LED_POWER, LED_STATE_ON); - for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ - } - digitalWrite(LED_POWER, LED_STATE_OFF); - for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ - } -} - -static void dash() -{ - digitalWrite(LED_POWER, LED_STATE_ON); - for (volatile int i = 0; i < (DOT_TIME * 3); i++) { /* busy wait */ - } - digitalWrite(LED_POWER, LED_STATE_OFF); - for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ - } -} - -static void space() -{ - for (volatile int i = 0; i < (DOT_TIME * 3); i++) { /* busy wait */ - } + debug_printf("assert: %s:%d in %s: %s\r\n", file, line, func, failedexpr); + HAL_NVIC_SystemReset(); } // Disable optimizations for this function so "frame" argument @@ -277,17 +241,5 @@ extern "C" __attribute__((optimize("O0"))) void HardFault_Handler_C(sContextStat HALT_IF_DEBUGGING(); - // blink SOS forever - while (1) { - dot(); - dot(); - dot(); - dash(); - dash(); - dash(); - dot(); - dot(); - dot(); - space(); - } -} \ No newline at end of file + HAL_NVIC_SystemReset(); +} diff --git a/src/security/EncryptedStorage.cpp b/src/security/EncryptedStorage.cpp index d34906eaad..9572f7ea07 100644 --- a/src/security/EncryptedStorage.cpp +++ b/src/security/EncryptedStorage.cpp @@ -204,7 +204,7 @@ static void writeBackoff(uint8_t attempts, uint8_t bootsSinceFail, uint32_t last bool ok = computeBackoffHmac(buf.data(), mac); nRFCrypto.end(); if (!ok) { - LOG_ERROR("EncryptedStorage: backoff HMAC compute failed"); + LOG_ERROR("EncryptedStorage: backoff HMAC failed"); return; } memcpy(buf.data() + BACKOFF_BODY_SIZE, mac, HMAC_SIZE); @@ -489,7 +489,7 @@ static bool loadDEK() const uint8_t *storedHmac = buf + DEK_SIZE - HMAC_SIZE; if (!constTimeEq(expectedHmac.data(), storedHmac, HMAC_SIZE)) { - LOG_ERROR("EncryptedStorage: DEK HMAC mismatch - wrong passphrase or tampered file"); + LOG_ERROR("EncryptedStorage: DEK HMAC mismatch - wrong passphrase or tampered"); return false; } @@ -791,7 +791,7 @@ static bool writeUnlockToken(uint8_t bootsRemaining, uint32_t validUntilEpoch, u // greater than the persisted value); readAndConsumeToken will // promote .tokmono on the next read. if (!writeMonoCounter(newMonoCounter)) { - LOG_WARN("EncryptedStorage: mono-counter persist failed (will self-heal on next read)"); + LOG_WARN("EncryptedStorage: mono-counter persist failed (self-heals on next read)"); } LOG_INFO("EncryptedStorage: Unlock token written (boots=%d, epoch=%u, mono=%u)", bootsRemaining, validUntilEpoch, @@ -893,7 +893,7 @@ static bool readAndConsumeToken() // current value. Equal is the normal case post-write. uint32_t maxSeenCounter = readMonoCounter(); if (tokenMonoCounter < maxSeenCounter) { - LOG_ERROR("EncryptedStorage: Token rollback detected (counter=%u, max-seen=%u), deleting", (unsigned)tokenMonoCounter, + LOG_ERROR("EncryptedStorage: Token rollback (counter=%u, max-seen=%u), deleting", (unsigned)tokenMonoCounter, (unsigned)maxSeenCounter); concurrency::LockGuard g(spiLock); FSCom.remove(TOKEN_FILENAME); @@ -931,8 +931,7 @@ static bool readAndConsumeToken() if (validUntilEpoch != 0) { uint32_t now = getValidTime(RTCQualityDevice); if (now == 0) { - LOG_WARN("EncryptedStorage: Token wall-clock TTL unverifiable (no RTC), falling back to boot count (%u left)", - bootsRemaining); + LOG_WARN("EncryptedStorage: Token wall-clock TTL unverifiable (no RTC), using boot count (%u left)", bootsRemaining); } else if (now > validUntilEpoch) { LOG_WARN("EncryptedStorage: Token expired (now=%u, until=%u), deleting", now, validUntilEpoch); concurrency::LockGuard g(spiLock); @@ -1041,7 +1040,7 @@ void initLocked() if (isProvisioned()) { LOG_WARN("EncryptedStorage: Device LOCKED - reason: %s", lockReason); } else { - LOG_WARN("EncryptedStorage: Device NOT PROVISIONED - operator must set passphrase"); + LOG_WARN("EncryptedStorage: Device NOT PROVISIONED - set passphrase"); } } @@ -1145,7 +1144,7 @@ bool provisionPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8_ // Create unlock token (validUntilEpoch is an absolute Unix timestamp from the client; 0 = no limit) if (!writeUnlockToken(bootsRemaining, validUntilEpoch, sessionMaxSeconds)) { - LOG_WARN("EncryptedStorage: Token write failed after provision (continuing unlocked)"); + LOG_WARN("EncryptedStorage: Token write failed after provision (still unlocked)"); } // H4 (audit): seed an attempts=0 backoff sentinel so the file is @@ -1234,7 +1233,7 @@ bool unlockWithPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8 if (maxRemaining > 0) { s_backoffSecondsRemaining = maxRemaining; - LOG_WARN("EncryptedStorage: Passphrase attempt blocked by backoff (~%us remaining)", s_backoffSecondsRemaining); + LOG_WARN("EncryptedStorage: Passphrase blocked by backoff (~%us left)", s_backoffSecondsRemaining); return false; } } @@ -1296,7 +1295,7 @@ bool unlockWithPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8 // Create fresh unlock token (validUntilEpoch is an absolute Unix timestamp from the client; 0 = no limit) if (!writeUnlockToken(bootsRemaining, validUntilEpoch, sessionMaxSeconds)) { - LOG_WARN("EncryptedStorage: Token write failed after unlock (continuing unlocked this boot)"); + LOG_WARN("EncryptedStorage: Token write failed after unlock (unlocked this boot)"); } dekLoaded = true; @@ -1318,7 +1317,7 @@ void lockNow() secureWipeKeys(); s_sessionMaxMs = 0; s_sessionStartedMs = 0; - LOG_INFO("EncryptedStorage: Device locked - token deleted, DEK and KEK material zeroed"); + LOG_INFO("EncryptedStorage: Device locked - token deleted, DEK/KEK zeroed"); } void secureWipeKeys() @@ -1433,8 +1432,7 @@ bool readAndDecrypt(const char *filename, uint8_t *outBuf, size_t outBufSize, si // MAX_NUM_NODES pushes the serialised protobuf past that limit. const size_t maxAcceptedFileSize = outBufSize + OVERHEAD; if (fileSize > maxAcceptedFileSize) { - LOG_ERROR("EncryptedStorage: File %s too large (%d bytes, max %d), refusing", filename, fileSize, - maxAcceptedFileSize); + LOG_ERROR("EncryptedStorage: File %s too large (%d bytes, max %d)", filename, fileSize, maxAcceptedFileSize); f.close(); meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot)); return false; @@ -1512,7 +1510,7 @@ bool readAndDecrypt(const char *filename, uint8_t *outBuf, size_t outBufSize, si hmacData.reset(); if (!hmacOk || !constTimeEq(computedHmac, storedHmac, HMAC_SIZE)) { - LOG_ERROR("EncryptedStorage: HMAC verification failed for %s", filename); + LOG_ERROR("EncryptedStorage: HMAC verify failed for %s", filename); meshtastic_security::secure_zero(computedHmac, sizeof(computedHmac)); meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot)); return false; @@ -1620,7 +1618,7 @@ bool encryptAndWrite(const char *filename, const uint8_t *plaintext, size_t plai hmacData.reset(); if (!hmacOk) { - LOG_ERROR("EncryptedStorage: HMAC computation failed for %s", filename); + LOG_ERROR("EncryptedStorage: HMAC compute failed for %s", filename); meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot)); return false; } @@ -1688,7 +1686,7 @@ bool migrateFile(const char *filename) // the device. constexpr size_t kMigrateMaxFileSize = 64 * 1024; if (fileSize > kMigrateMaxFileSize) { - LOG_ERROR("EncryptedStorage: refusing to migrate %s - size %u exceeds %u-byte cap", filename, (unsigned)fileSize, + LOG_ERROR("EncryptedStorage: won't migrate %s - size %u > %u-byte cap", filename, (unsigned)fileSize, (unsigned)kMigrateMaxFileSize); f.close(); return false; @@ -1733,7 +1731,7 @@ bool migrateFileToPlaintext(const char *filename) return true; } if (!dekLoaded) { - LOG_ERROR("EncryptedStorage: cannot revert %s - not unlocked", filename); + LOG_ERROR("EncryptedStorage: can't revert %s - not unlocked", filename); return false; } @@ -1797,7 +1795,7 @@ void removeLockdownArtifacts() secureWipeKeys(); s_sessionMaxMs = 0; s_sessionStartedMs = 0; - LOG_INFO("EncryptedStorage: lockdown artifacts removed - device is no longer in lockdown"); + LOG_INFO("EncryptedStorage: lockdown artifacts removed - lockdown off"); } } // namespace EncryptedStorage diff --git a/src/serialization/MeshPacketSerializer.cpp b/src/serialization/MeshPacketSerializer.cpp index f889c4f3ca..a50bc747df 100644 --- a/src/serialization/MeshPacketSerializer.cpp +++ b/src/serialization/MeshPacketSerializer.cpp @@ -416,7 +416,7 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, break; } } else if (shouldLog) { - LOG_WARN("Couldn't convert encrypted payload of MeshPacket to JSON"); + LOG_WARN("Can't convert encrypted payload of MeshPacket to JSON"); } jsonObj["id"] = (Json::UInt)mp->id; diff --git a/src/sleep.cpp b/src/sleep.cpp index 370c3338a1..c5d469b42a 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -611,18 +611,18 @@ void enableLoraInterrupt() loraFEMInterface.setRxModeEnableWhenMCUSleep(); #endif - LOG_INFO("setup LORA_DIO1 (GPIO%02d) with wakeup by gpio interrupt", LORA_DIO1); + LOG_INFO("Wake on LORA_DIO1 (GPIO%02d) gpio interrupt", LORA_DIO1); gpio_wakeup_enable((gpio_num_t)LORA_DIO1, GPIO_INTR_HIGH_LEVEL); #elif defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) if (radioType != RF95_RADIO) { - LOG_INFO("setup LORA_DIO1 (GPIO%02d) with wakeup by gpio interrupt", LORA_DIO1); + LOG_INFO("Wake on LORA_DIO1 (GPIO%02d) gpio interrupt", LORA_DIO1); gpio_wakeup_enable((gpio_num_t)LORA_DIO1, GPIO_INTR_HIGH_LEVEL); // SX126x/SX128x interrupt, active high } #endif #if defined(RF95_IRQ) && (RF95_IRQ != RADIOLIB_NC) if (radioType == RF95_RADIO) { - LOG_INFO("setup RF95_IRQ (GPIO%02d) with wakeup by gpio interrupt", RF95_IRQ); + LOG_INFO("Wake on RF95_IRQ (GPIO%02d) gpio interrupt", RF95_IRQ); gpio_wakeup_enable((gpio_num_t)RF95_IRQ, GPIO_INTR_HIGH_LEVEL); // RF95 interrupt, active high } #endif diff --git a/test/README.md b/test/README.md index 4074d94f51..d1dbd804c4 100644 --- a/test/README.md +++ b/test/README.md @@ -467,8 +467,8 @@ Unity suite, because what it asserts - the exit status and printed report of `meshtasticd --check`, and the fact that a normal run still refuses a bad config - are properties of the process, not of a linkable function. Fixtures live in `test/fixtures/portduino-config/` (see the README there); CI runs it in -`test_native.yml`. It is not counted in `native-suite-count`, which only tracks `test_*` -directories. +`test_native.yml`. It is not a `test_*` directory, so it sits outside the suite count the +harness derives from `test/`. ```bash pio run -e native && ./bin/test-config-check.sh @@ -476,10 +476,11 @@ pio run -e native && ./bin/test-config-check.sh ## Existing Test Suites -**This table is a description, not an inventory.** The canonical suite total lives in -`test/native-suite-count`, is machine-checked against `test/test_*` on every full run and in CI -(`test_native.yml`), and is the only number that should be trusted or quoted. Entries below carry -per-suite descriptions the count cannot; do not infer completeness from the row count. +**This table is a description, not an inventory.** The canonical suite total is the number of +`test_*` directories under `test/`, detected on the fly by `bin/run-tests.sh` on every full run +and cross-checked against the suites that actually ran. That derived count is the only number +that should be trusted or quoted. Entries below carry per-suite descriptions the count cannot; +do not infer completeness from the row count. | Suite | Module Under Test | | ---------------------------- | ----------------------------- | diff --git a/test/TestUtil.cpp b/test/TestUtil.cpp index f9e14373d9..58cd34c151 100644 --- a/test/TestUtil.cpp +++ b/test/TestUtil.cpp @@ -63,6 +63,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 +110,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/native-suite-count b/test/native-suite-count deleted file mode 100644 index c739b42c4d..0000000000 --- a/test/native-suite-count +++ /dev/null @@ -1 +0,0 @@ -44 diff --git a/test/state-manifest.tsv b/test/state-manifest.tsv index 45a8fa9621..7420504e85 100644 --- a/test/state-manifest.tsv +++ b/test/state-manifest.tsv @@ -42,6 +42,7 @@ # 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_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_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_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_hop_scaling writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB to hold the hop-distance fixtures test_mesh_beacon writes=module.proto exercises the beacon's module-config save path diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index f5237b0555..ebdad88273 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -607,21 +607,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 +728,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 +739,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) @@ -1436,6 +1473,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 +1546,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 // @@ -1919,7 +2008,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 +2018,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 +2068,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_airtime/test_main.cpp b/test/test_airtime/test_main.cpp new file mode 100644 index 0000000000..6edab96fb3 --- /dev/null +++ b/test/test_airtime/test_main.cpp @@ -0,0 +1,1281 @@ +// Unit tests for src/airtime.{h,cpp} - AirTime::syncNow() and its rolling windows. +// +// syncNow() replaced a per-second runOnce() tick with monotonic-uptime bucket rotation so windows +// stay correct across light sleep. It now takes its seconds from Time::getUptimeSecs(), which is a +// pure read of a carry the main loop publishes via Time::serviceMonotonic(); these tests exercise +// 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 --- + +void test_logAirtime_writes_into_current_bucket_immediately() +{ + 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]); +} + +void test_getSecondsSinceBoot_tracks_elapsed_time() +{ + Time::setTestMillis(0); + AirTime a; + + TEST_ASSERT_EQUAL_UINT32(0, a.getSecondsSinceBoot()); + Time::advanceTestMillis(5000); + TEST_ASSERT_EQUAL_UINT32(5, a.getSecondsSinceBoot()); +} + +// --- hourly period rotation --- + +void test_period_rotates_after_one_hour() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 500); + + Time::advanceTestMillis(3600u * 1000u); // exactly one SECONDS_PER_PERIOD + + 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 +} + +// The property runOnce() alone could never exercise: several hours pass in a single sync (e.g. the +// device was light-sleeping), so the rotation has to walk forward more than one period at once. +void test_period_rotates_once_per_hour_crossed_while_asleep() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 200); + + Time::advanceTestMillis(3u * 3600u * 1000u); // 3 hours in one jump + + 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]); + TEST_ASSERT_EQUAL_UINT32(0, report[2]); +} + +// More periods elapse than there are slots to rotate through: the whole history is stale, not just +// the oldest slot, so it must be wiped rather than rotated PERIODS_TO_LOG times. +void test_period_history_clears_when_asleep_longer_than_the_whole_log() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 999); + + Time::advanceTestMillis(9u * 3600u * 1000u); // 9 hours > PERIODS_TO_LOG (8) + + 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"); + } +} + +// --- channel utilization: rolling 60s window --- + +void test_channel_utilization_reflects_recent_airtime() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 6000); // 6s of airtime inside the 60s window + + TEST_ASSERT_FLOAT_WITHIN(0.01f, 10.0f, a.channelUtilizationPercent()); +} + +void test_channel_utilization_decays_once_the_60s_window_passes() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 6000); + + Time::advanceTestMillis(70u * 1000u); // longer than the 60s rolling window + + TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.0f, a.channelUtilizationPercent()); +} + +void test_isTxAllowedChannelUtil_blocks_once_over_threshold() +{ + Time::setTestMillis(0); + AirTime a; + + TEST_ASSERT_TRUE(a.isTxAllowedChannelUtil()); // nothing logged yet + + a.logAirtime(RX_LOG, 25000); // 25s / 60s = 41.7%, over the 40% default max + TEST_ASSERT_FALSE(a.isTxAllowedChannelUtil()); +} + +// --- TX utilization: rolling 60-minute window --- + +void test_tx_utilization_decays_once_the_60_minute_window_passes() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 60000); // 1 minute of TX airtime + + TEST_ASSERT_TRUE(a.utilizationTXPercent() > 0.0f); + + Time::advanceTestMillis(61u * 60u * 1000u); // longer than the 60-minute rolling window + + TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.0f, a.utilizationTXPercent()); +} + +// --- the headline property: syncNow() must survive the 32-bit millis() wrap --- + +void test_syncNow_survives_millis_wrap() +{ + const uint32_t beforeWrap = 4294967000u; // 296ms before the wrap, on a whole-second boundary + Time::setTestMillis(beforeWrap); + Time::serviceMonotonic(); // the main loop's publish, which is what carries the wrap + AirTime a; + + TEST_ASSERT_EQUAL_UINT32(4294967u, a.getSecondsSinceBoot()); + + Time::advanceTestMillis(1000); // crosses the wrap + Time::serviceMonotonic(); + TEST_ASSERT_EQUAL_UINT32(4294968u, a.getSecondsSinceBoot()); +} + +// A bucket logged just before the wrap must still be the one that rotates out after it - pinning +// the same property test_period_rotates_after_one_hour checks, but across the wrap boundary. +void test_period_rotation_survives_millis_wrap() +{ + const uint32_t beforeWrap = 0xFFFFFFFFu - (3600u * 1000u) + 1; // one hour minus 1ms before the wrap + Time::setTestMillis(beforeWrap); + Time::serviceMonotonic(); + AirTime a; + a.logAirtime(TX_LOG, 777); + + Time::advanceTestMillis(3600u * 1000u); // wraps partway through + Time::serviceMonotonic(); + + 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(); + UNITY_BEGIN(); + RUN_TEST(test_logAirtime_writes_into_current_bucket_immediately); + RUN_TEST(test_getSecondsSinceBoot_tracks_elapsed_time); + RUN_TEST(test_period_rotates_after_one_hour); + RUN_TEST(test_period_rotates_once_per_hour_crossed_while_asleep); + RUN_TEST(test_period_history_clears_when_asleep_longer_than_the_whole_log); + RUN_TEST(test_channel_utilization_reflects_recent_airtime); + RUN_TEST(test_channel_utilization_decays_once_the_60s_window_passes); + RUN_TEST(test_isTxAllowedChannelUtil_blocks_once_over_threshold); + 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()); +} + +void loop() {} diff --git a/test/test_banner_font_tags/test_main.cpp b/test/test_banner_font_tags/test_main.cpp new file mode 100644 index 0000000000..4033c2785f --- /dev/null +++ b/test/test_banner_font_tags/test_main.cpp @@ -0,0 +1,176 @@ +// Regression tests for the alert-banner font-tag pipeline ([S]/[M]/[L] line prefixes). +// +// The BLE pairing banner (src/platform/nrf52/NRF52Bluetooth.cpp) sends +// "Bluetooth\nPIN\n[M]" with notification type pairing_pin. The [M] prefix is a +// font-change tag, never text: it must be stripped by parseBannerMessageWithFonts and, +// crucially, must also be stripped when a draw resolves a line the parsed cache doesn't +// cover (the shape of the historical bug where the pairing PIN rendered a literal "[M]"). +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, etc.) +#include "TestUtil.h" // initializeTestEnvironment() +#include + +#if HAS_SCREEN // Same guard as the module under test + +#include "graphics/draw/NotificationRenderer.h" +#include + +using graphics::NotificationRenderer; +using graphics::notificationTypeEnum; + +static const char *BLE_PIN_MESSAGE = "Bluetooth\nPIN\n[M]123 456"; + +// Reset every static the tests touch, so each case starts from a known state. +void setUp(void) +{ + NotificationRenderer::alertBannerMessage[0] = '\0'; + NotificationRenderer::parseBannerMessageWithFonts(""); + NotificationRenderer::alertBannerOptions = 0; + NotificationRenderer::current_notification_type = notificationTypeEnum::none; +} + +void tearDown(void) {} + +// Simulate Screen::showOverlayBanner storing and parsing a banner message. +static void showBanner(const char *message, notificationTypeEnum type, uint8_t options = 0) +{ + strncpy(NotificationRenderer::alertBannerMessage, message, 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; + NotificationRenderer::parseBannerMessageWithFonts(NotificationRenderer::alertBannerMessage); + NotificationRenderer::alertBannerOptions = options; + NotificationRenderer::current_notification_type = type; +} + +// --- parseBannerMessageWithFonts --- + +void test_pairing_message_parses_and_strips_medium_tag() +{ + showBanner(BLE_PIN_MESSAGE, notificationTypeEnum::pairing_pin); + + TEST_ASSERT_EQUAL_UINT8(3, NotificationRenderer::alertBannerLineCount); + TEST_ASSERT_EQUAL_STRING("Bluetooth", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL_STRING("PIN", NotificationRenderer::alertBannerLines[1]); + TEST_ASSERT_EQUAL_STRING("123 456", NotificationRenderer::alertBannerLines[2]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, NotificationRenderer::alertBannerLineFonts[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, NotificationRenderer::alertBannerLineFonts[1]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_MEDIUM, NotificationRenderer::alertBannerLineFonts[2]); +} + +void test_small_and_large_tags_parse() +{ + showBanner("[S]small\n[L]large", notificationTypeEnum::text_banner); + + TEST_ASSERT_EQUAL_UINT8(2, NotificationRenderer::alertBannerLineCount); + TEST_ASSERT_EQUAL_STRING("small", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_SMALL, NotificationRenderer::alertBannerLineFonts[0]); + TEST_ASSERT_EQUAL_STRING("large", NotificationRenderer::alertBannerLines[1]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_LARGE, NotificationRenderer::alertBannerLineFonts[1]); +} + +void test_unknown_tag_is_kept_as_text() +{ + showBanner("[X]hello", notificationTypeEnum::text_banner); + + TEST_ASSERT_EQUAL_STRING("[X]hello", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, NotificationRenderer::alertBannerLineFonts[0]); +} + +void test_tag_not_at_line_start_is_kept_as_text() +{ + showBanner("PIN [M]x", notificationTypeEnum::text_banner); + + TEST_ASSERT_EQUAL_STRING("PIN [M]x", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, NotificationRenderer::alertBannerLineFonts[0]); +} + +void test_tag_only_line_yields_empty_text_with_font() +{ + showBanner("[L]", notificationTypeEnum::text_banner); + + TEST_ASSERT_EQUAL_STRING("", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_LARGE, NotificationRenderer::alertBannerLineFonts[0]); +} + +void test_lone_bracket_line_is_kept_as_text() +{ + showBanner("[", notificationTypeEnum::text_banner); + + TEST_ASSERT_EQUAL_STRING("[", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, NotificationRenderer::alertBannerLineFonts[0]); +} + +// --- resolveBannerLine: what the draw code actually puts on the panel --- + +void test_resolve_uses_parsed_lines_for_pairing_pin() +{ + showBanner(BLE_PIN_MESSAGE, notificationTypeEnum::pairing_pin); + + NotificationRenderer::BannerFont font = NotificationRenderer::BANNER_FONT_DEFAULT; + const char *text = NotificationRenderer::resolveBannerLine(2, "[M]123 456", font); + TEST_ASSERT_EQUAL_STRING("123 456", text); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_MEDIUM, font); +} + +// The historical bug: the pairing banner drawn from the raw message, with the parsed-line +// cache not consulted (before the pairing_pin type was tag-aware) or not populated (a draw +// racing the parse from the BLE task). The tag must still act as a font change, not text. +void test_resolve_strips_tag_when_parsed_cache_missing() +{ + strncpy(NotificationRenderer::alertBannerMessage, BLE_PIN_MESSAGE, 255); + NotificationRenderer::current_notification_type = notificationTypeEnum::pairing_pin; + NotificationRenderer::alertBannerOptions = 0; + // Deliberately no parseBannerMessageWithFonts call: cache empty. + + NotificationRenderer::BannerFont font = NotificationRenderer::BANNER_FONT_DEFAULT; + const char *text = NotificationRenderer::resolveBannerLine(2, "[M]123 456", font); + TEST_ASSERT_EQUAL_STRING("123 456", text); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_MEDIUM, font); +} + +// Picker content can be user data (e.g. node names); it must never be tag-interpreted. +void test_resolve_leaves_picker_lines_untouched() +{ + NotificationRenderer::current_notification_type = notificationTypeEnum::node_picker; + NotificationRenderer::alertBannerOptions = 0; + + NotificationRenderer::BannerFont font = NotificationRenderer::BANNER_FONT_LARGE; + const char *text = NotificationRenderer::resolveBannerLine(0, "[M]allory", font); + TEST_ASSERT_EQUAL_STRING("[M]allory", text); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, font); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + RUN_TEST(test_pairing_message_parses_and_strips_medium_tag); + RUN_TEST(test_small_and_large_tags_parse); + RUN_TEST(test_unknown_tag_is_kept_as_text); + RUN_TEST(test_tag_not_at_line_start_is_kept_as_text); + RUN_TEST(test_tag_only_line_yields_empty_text_with_font); + RUN_TEST(test_lone_bracket_line_is_kept_as_text); + + RUN_TEST(test_resolve_uses_parsed_lines_for_pairing_pin); + RUN_TEST(test_resolve_strips_tag_when_parsed_cache_missing); + RUN_TEST(test_resolve_leaves_picker_lines_untouched); + + exit(UNITY_END()); +} + +void loop() {} + +#else // !HAS_SCREEN + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +void loop() {} + +#endif // HAS_SCREEN 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_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 new file mode 100644 index 0000000000..f7932b9c3f --- /dev/null +++ b/test/test_event_channel_phone_api/test_main.cpp @@ -0,0 +1,263 @@ +#include "Channels.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "RadioInterface.h" +#include "Router.h" +#include "StreamAPI.h" +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include +#include +#include +#include + +namespace +{ +constexpr PacketId BLOCKED_PACKET_ID = 0x10203040; +constexpr PacketId FOLLOWUP_PACKET_ID = 0x50607080; +constexpr ChannelIndex EVENT_CHANNEL = 0; +constexpr ChannelIndex PRIVATE_CHANNEL = 1; +constexpr NodeNum REMOTE_NODE = 0x12345678; + +class MockRadioInterface : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *packet) override + { + packetPool.release(packet); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t, bool) override { return 0; } +}; + +class MockRouter : public Router +{ + public: + MockRouter() { addInterface(std::make_unique()); } + + ~MockRouter() + { + delete cryptLock; + cryptLock = nullptr; + } + + ErrorCode send(meshtastic_MeshPacket *packet) override + { + sentPackets.push_back(*packet); + packetPool.release(packet); + return ERRNO_OK; + } + + std::vector sentPackets; +}; + +class MockMeshService : public MeshService +{ + public: + ~MockMeshService() + { + while (auto *status = getQueueStatusForPhone()) { + releaseQueueStatusToPool(status); + } + } + + void sendClientNotification(meshtastic_ClientNotification *notification) override + { + notifications.push_back(*notification); + releaseClientNotificationToPool(notification); + } + + void assertQueueStatus(PacketId packetId) + { + auto *status = getQueueStatusForPhone(); + TEST_ASSERT_NOT_NULL(status); + TEST_ASSERT_EQUAL_UINT32(packetId, status->mesh_packet_id); + releaseQueueStatusToPool(status); + } + + std::vector notifications; +}; + +class TestStreamAPI : public StreamAPI +{ + public: + TestStreamAPI() : StreamAPI(nullptr) {} + bool checkIsConnected() override { return true; } +}; + +struct GlobalState { + MeshService *service; + Router *router; + NodeDB *nodeDB; + // Router's ctor asserts !cryptLock and allocates one; ~MockRouter() deletes it. Save the + // incoming lock so the restored router keeps the one it was built with. + concurrency::Lock *cryptLock; + meshtastic_MyNodeInfo myNodeInfo; + Channels channels; + meshtastic_ChannelFile channelFile; + meshtastic_LocalConfig config; + meshtastic_LocalModuleConfig moduleConfig; + meshtastic_DeviceState deviceState; +}; + +GlobalState *savedState; +MockMeshService *mockService; +MockRouter *mockRouter; +NodeDB *mockNodeDB; +TestStreamAPI *streamAPI; + +void configureChannels() +{ + const meshtastic_ChannelFile defaultChannelFile = meshtastic_ChannelFile_init_default; + channelFile = defaultChannelFile; + channelFile.channels_count = 2; + + auto &eventChannel = channelFile.channels[EVENT_CHANNEL]; + eventChannel.index = EVENT_CHANNEL; + eventChannel.has_settings = true; + eventChannel.role = meshtastic_Channel_Role_PRIMARY; + strncpy(eventChannel.settings.name, "everyone", sizeof(eventChannel.settings.name) - 1); +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t eventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(eventPsk); + memcpy(eventChannel.settings.psk.bytes, eventPsk, sizeof(eventPsk)); +#endif + + auto &privateChannel = channelFile.channels[PRIVATE_CHANNEL]; + privateChannel.index = PRIVATE_CHANNEL; + privateChannel.has_settings = true; + privateChannel.role = meshtastic_Channel_Role_SECONDARY; + strncpy(privateChannel.settings.name, "private", sizeof(privateChannel.settings.name) - 1); + privateChannel.settings.psk.size = 32; + memset(privateChannel.settings.psk.bytes, 0xab, privateChannel.settings.psk.size); + + channels.onConfigChanged(); +} + +meshtastic_ToRadio makePositionToRadio(PacketId id, ChannelIndex channel) +{ + 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.id = id; + message.packet.channel = channel; + message.packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + message.packet.decoded.portnum = meshtastic_PortNum_POSITION_APP; + return message; +} + +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)); + if (encodedSize == 0) { + return false; + } + return streamAPI->handleToRadio(encoded, encodedSize); +} + +void assertSentPacket(size_t index, PacketId id, ChannelIndex channel) +{ + 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); +} +} // namespace + +void setUp(void) +{ + savedState = + new GlobalState{service, router, nodeDB, cryptLock, myNodeInfo, channels, channelFile, config, moduleConfig, devicestate}; + + service = mockService = new MockMeshService(); + nodeDB = mockNodeDB = new NodeDB(); + myNodeInfo.my_node_num = 0x87654321; + configureChannels(); + cryptLock = nullptr; // Router's ctor asserts this is unset before allocating its own. + router = mockRouter = new MockRouter(); + streamAPI = new TestStreamAPI(); + testDelay(1); +} + +void tearDown(void) +{ + delete streamAPI; + streamAPI = nullptr; + delete mockRouter; + mockRouter = nullptr; + delete mockNodeDB; + mockNodeDB = nullptr; + delete mockService; + mockService = nullptr; + + service = savedState->service; + router = savedState->router; + nodeDB = savedState->nodeDB; + cryptLock = savedState->cryptLock; // ~MockRouter() 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; +} + +static void test_event_position_ingress_does_not_poison_retry_state() +{ + const auto eventAttempt = makePositionToRadio(BLOCKED_PACKET_ID, EVENT_CHANNEL); + const auto sameIdPrivateRetry = makePositionToRadio(BLOCKED_PACKET_ID, PRIVATE_CHANNEL); + const auto immediatePrivateFollowup = makePositionToRadio(FOLLOWUP_PACKET_ID, PRIVATE_CHANNEL); + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + TEST_ASSERT_FALSE(sendToRadio(eventAttempt)); + TEST_ASSERT_EQUAL(0, mockRouter->sentPackets.size()); + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + TEST_ASSERT_EQUAL(1, mockService->notifications.size()); + TEST_ASSERT_EQUAL_UINT32(BLOCKED_PACKET_ID, mockService->notifications[0].reply_id); + + TEST_ASSERT_TRUE(sendToRadio(sameIdPrivateRetry)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + assertSentPacket(0, BLOCKED_PACKET_ID, PRIVATE_CHANNEL); + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + + TEST_ASSERT_FALSE(sendToRadio(immediatePrivateFollowup)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + mockService->assertQueueStatus(FOLLOWUP_PACKET_ID); + TEST_ASSERT_EQUAL(1, mockService->notifications.size()); +#else + TEST_ASSERT_TRUE(sendToRadio(eventAttempt)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + assertSentPacket(0, BLOCKED_PACKET_ID, EVENT_CHANNEL); + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); + + TEST_ASSERT_FALSE(sendToRadio(sameIdPrivateRetry)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + TEST_ASSERT_NULL(mockService->getQueueStatusForPhone()); + + TEST_ASSERT_FALSE(sendToRadio(immediatePrivateFollowup)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + mockService->assertQueueStatus(FOLLOWUP_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); +#endif +} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_event_position_ingress_does_not_poison_retry_state); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_event_channel_router/test_main.cpp b/test/test_event_channel_router/test_main.cpp new file mode 100644 index 0000000000..c1f5e8becc --- /dev/null +++ b/test/test_event_channel_router/test_main.cpp @@ -0,0 +1,405 @@ +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#include "airtime.h" +#include "mesh/Channels.h" +#include "mesh/CryptoEngine.h" +#include "mesh/MeshModule.h" +#include "mesh/MeshRadio.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include "mesh/Router.h" +#if ARCH_PORTDUINO +#include "platform/portduino/PortduinoGlue.h" +#endif +#include +#include +#include +#include +#include +#include + +#if ARCH_PORTDUINO +#define EVENT_ROUTER_TEST_ENTRY extern "C" +#else +#define EVENT_ROUTER_TEST_ENTRY +#endif + +namespace +{ + +constexpr NodeNum kLocalNode = 0x11111111; +constexpr NodeNum kRemoteNode = 0x22222222; +constexpr NodeNum kPkiPeer = 0x33333333; +constexpr ChannelIndex kEventChannel = 0; +constexpr ChannelIndex kPrivateChannel = 1; + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL +constexpr bool kBlockEventCoordinates = true; +constexpr ErrorCode kExpectedEventTxResult = meshtastic_Routing_Error_NOT_AUTHORIZED; +constexpr size_t kExpectedEventDeliveryCount = 0; +#else +constexpr bool kBlockEventCoordinates = false; +constexpr ErrorCode kExpectedEventTxResult = ERRNO_OK; +constexpr size_t kExpectedEventDeliveryCount = 3; +#endif + +constexpr std::array kCoordinatePorts = { + meshtastic_PortNum_POSITION_APP, + meshtastic_PortNum_WAYPOINT_APP, + meshtastic_PortNum_MAP_REPORT_APP, +}; + +class TestNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNode(NodeNum num, ChannelIndex channel, const uint8_t *publicKey = nullptr) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + node.channel = channel; + if (publicKey) { + node.public_key.size = 32; + memcpy(node.public_key.bytes, publicKey, 32); + } + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + private: + std::vector testNodes; +}; + +class CaptureRadio : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *packet) override + { + packets.push_back(*packet); + packetPool.release(packet); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t, bool = false) override { return 0; } + + std::vector packets; +}; + +class CaptureModule : public MeshModule +{ + public: + CaptureModule() : MeshModule("event-router-capture") { encryptedOk = true; } + + bool wantPacket(const meshtastic_MeshPacket *) override { return true; } + + ProcessMessage handleReceived(const meshtastic_MeshPacket &packet) override + { + packets.push_back(packet); + return ProcessMessage::CONTINUE; + } + + std::vector packets; +}; + +struct SavedGlobals { + meshtastic_LocalConfig config; + meshtastic_LocalModuleConfig moduleConfig; + meshtastic_ChannelFile channelFile; + meshtastic_User owner; + meshtastic_MyNodeInfo myNodeInfo; + NodeDB *nodeDB; + Router *router; + MeshService *service; + AirTime *airTime; + concurrency::Lock *cryptLock; +#if ARCH_PORTDUINO + bool forceSimRadio; +#endif +}; + +SavedGlobals saved; +TestNodeDB *testNodeDB = nullptr; +Router *testRouter = nullptr; +CaptureRadio *captureRadio = nullptr; +CaptureModule *captureModule = nullptr; +AirTime *testAirTime = nullptr; + +static void installChannels() +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel &event = channelFile.channels[kEventChannel]; + memset(&event, 0, sizeof(event)); + event.index = kEventChannel; + event.role = meshtastic_Channel_Role_PRIMARY; + event.has_settings = true; + strncpy(event.settings.name, "everyone", sizeof(event.settings.name) - 1); +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t eventKey[] = USERPREFS_CHANNEL_0_PSK; + static_assert(sizeof(eventKey) == 16 || sizeof(eventKey) == 32); + event.settings.psk.size = sizeof(eventKey); + memcpy(event.settings.psk.bytes, eventKey, sizeof(eventKey)); +#else + static const uint8_t eventKey[16] = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; + event.settings.psk.size = sizeof(eventKey); + memcpy(event.settings.psk.bytes, eventKey, sizeof(eventKey)); +#endif + + meshtastic_Channel &privateChannel = channelFile.channels[kPrivateChannel]; + memset(&privateChannel, 0, sizeof(privateChannel)); + privateChannel.index = kPrivateChannel; + privateChannel.role = meshtastic_Channel_Role_SECONDARY; + privateChannel.has_settings = true; + strncpy(privateChannel.settings.name, "private", sizeof(privateChannel.settings.name) - 1); + privateChannel.settings.psk.size = 32; + for (size_t i = 0; i < privateChannel.settings.psk.size; ++i) + privateChannel.settings.psk.bytes[i] = static_cast(0x80 + i); + + channels.onConfigChanged(); +} + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum port, NodeNum from, NodeNum to, ChannelIndex channel) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = from; + packet.to = to; + packet.id = 0x40000000u + static_cast(port); + packet.channel = channel; + packet.hop_start = 3; + packet.hop_limit = 3; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = port; + + if (port == meshtastic_PortNum_POSITION_APP) { + meshtastic_Position position = meshtastic_Position_init_zero; + position.has_latitude_i = true; + position.latitude_i = 374221234; + position.has_longitude_i = true; + position.longitude_i = -1220845678; + packet.decoded.payload.size = pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), + &meshtastic_Position_msg, &position); + } else { + packet.decoded.payload.size = 1; + packet.decoded.payload.bytes[0] = 0x5a; + } + return packet; +} + +static ErrorCode sendCoordinate(meshtastic_PortNum port, ChannelIndex channel, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket *packet = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(packet); + const meshtastic_MeshPacket contents = makeDecodedPacket(port, kLocalNode, to, channel); + packet->to = contents.to; + packet->channel = contents.channel; + packet->decoded = contents.decoded; + return testRouter->send(packet); +} + +static void receivePacket(const meshtastic_MeshPacket &contents) +{ + meshtastic_MeshPacket *packet = packetPool.allocCopy(contents); + TEST_ASSERT_NOT_NULL(packet); + testRouter->enqueueReceivedMessage(packet); + testRouter->runOnce(); +} + +static void test_tx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports() +{ + TEST_ASSERT_EQUAL(kBlockEventCoordinates, channels.isEventChannel(kEventChannel)); + + for (meshtastic_PortNum port : kCoordinatePorts) { + const size_t before = captureRadio->packets.size(); + TEST_ASSERT_EQUAL_INT(kExpectedEventTxResult, sendCoordinate(port, kEventChannel)); + TEST_ASSERT_EQUAL_UINT32(before + (kBlockEventCoordinates ? 0 : 1), captureRadio->packets.size()); + } +} + +static void test_rx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports() +{ + for (meshtastic_PortNum port : kCoordinatePorts) + receivePacket(makeDecodedPacket(port, kRemoteNode, NODENUM_BROADCAST, kEventChannel)); + + TEST_ASSERT_EQUAL_UINT32(kExpectedEventDeliveryCount, captureModule->packets.size()); +} + +static void test_private_channel_preserves_legacy_tx_and_rx_for_all_coordinate_ports() +{ + TEST_ASSERT_FALSE(channels.isEventChannel(kPrivateChannel)); + + for (meshtastic_PortNum port : kCoordinatePorts) { + TEST_ASSERT_EQUAL_INT(ERRNO_OK, sendCoordinate(port, kPrivateChannel)); + receivePacket(makeDecodedPacket(port, kRemoteNode, NODENUM_BROADCAST, kPrivateChannel)); + } + + TEST_ASSERT_EQUAL_UINT32(kCoordinatePorts.size(), captureRadio->packets.size()); + TEST_ASSERT_EQUAL_UINT32(kCoordinatePorts.size(), captureModule->packets.size()); +} + +#if !(MESHTASTIC_EXCLUDE_PKI) +static void test_tx_event_coordinate_that_uses_pki_reaches_radio() +{ + uint8_t peerPublic[32], peerPrivate[32]; + uint8_t localPublic[32], localPrivate[32]; + crypto->generateKeyPair(peerPublic, peerPrivate); + crypto->generateKeyPair(localPublic, localPrivate); + + config.has_security = true; + config.security.private_key.size = 32; + config.security.public_key.size = 32; + memcpy(config.security.private_key.bytes, localPrivate, 32); + memcpy(config.security.public_key.bytes, localPublic, 32); + crypto->setDHPrivateKey(localPrivate); + testNodeDB->addNode(kPkiPeer, kEventChannel, peerPublic); + + TEST_ASSERT_EQUAL_INT(ERRNO_OK, sendCoordinate(meshtastic_PortNum_WAYPOINT_APP, kEventChannel, kPkiPeer)); + TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); + TEST_ASSERT_TRUE(captureRadio->packets.front().pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, captureRadio->packets.front().which_payload_variant); +} +#endif + +static void test_opaque_tx_is_not_misclassified_as_coordinates() +{ + meshtastic_MeshPacket *outgoing = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(outgoing); + outgoing->channel = channels.getHash(kEventChannel); + outgoing->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + outgoing->encrypted.size = 1; + outgoing->encrypted.bytes[0] = 0xa5; + + TEST_ASSERT_EQUAL_INT(ERRNO_OK, testRouter->send(outgoing)); + TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); +} + +static void test_capture_endpoints_release_packet_pool_ownership() +{ + constexpr size_t iterations = 64; + for (size_t i = 0; i < iterations; ++i) { + meshtastic_MeshPacket *outgoing = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(outgoing); + outgoing->channel = kPrivateChannel; + outgoing->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + outgoing->decoded.payload.size = 1; + outgoing->decoded.payload.bytes[0] = static_cast(i); + TEST_ASSERT_EQUAL_INT(ERRNO_OK, testRouter->send(outgoing)); + + meshtastic_MeshPacket incoming = + makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, NODENUM_BROADCAST, kPrivateChannel); + incoming.id += i; + receivePacket(incoming); + } + + TEST_ASSERT_EQUAL_UINT32(iterations, captureRadio->packets.size()); + TEST_ASSERT_EQUAL_UINT32(iterations, captureModule->packets.size()); +} + +} // namespace + +void setUp(void) +{ + saved.config = config; + saved.moduleConfig = moduleConfig; + saved.channelFile = channelFile; + saved.owner = owner; + saved.myNodeInfo = myNodeInfo; + saved.nodeDB = nodeDB; + saved.router = router; + saved.service = service; + saved.airTime = airTime; + saved.cryptLock = cryptLock; +#if ARCH_PORTDUINO + saved.forceSimRadio = portduino_config.force_simradio; +#endif + + testNodeDB = new TestNodeDB(); + testNodeDB->clearTestNodes(); + nodeDB = testNodeDB; + + memset(&config, 0, sizeof(config)); + config.lora.override_duty_cycle = true; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + memset(&moduleConfig, 0, sizeof(moduleConfig)); + memset(&owner, 0, sizeof(owner)); + 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(); + airTime = testAirTime; + + cryptLock = nullptr; + testRouter = new Router(); + router = testRouter; + std::unique_ptr radio(new CaptureRadio()); + captureRadio = radio.get(); + testRouter->addInterface(std::move(radio)); + captureModule = new CaptureModule(); +} + +void tearDown(void) +{ + delete captureModule; + captureModule = nullptr; + + router = nullptr; + delete testRouter; + testRouter = nullptr; + captureRadio = nullptr; + delete cryptLock; + cryptLock = saved.cryptLock; + + delete testNodeDB; + testNodeDB = nullptr; + delete testAirTime; + testAirTime = nullptr; + + config = saved.config; + moduleConfig = saved.moduleConfig; + channelFile = saved.channelFile; + owner = saved.owner; + myNodeInfo = saved.myNodeInfo; + channels.onConfigChanged(); + nodeDB = saved.nodeDB; + 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() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Router event-channel coordinate enforcement ===\n"); + RUN_TEST(test_tx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports); + RUN_TEST(test_rx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports); + RUN_TEST(test_private_channel_preserves_legacy_tx_and_rx_for_all_coordinate_ports); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_tx_event_coordinate_that_uses_pki_reaches_radio); +#endif + RUN_TEST(test_opaque_tx_is_not_misclassified_as_coordinates); + RUN_TEST(test_capture_endpoints_release_packet_pool_ownership); + + exit(UNITY_END()); +} + +EVENT_ROUTER_TEST_ENTRY void loop() {} 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 new file mode 100644 index 0000000000..de3430f1c3 --- /dev/null +++ b/test/test_geocoord_distance/test_main.cpp @@ -0,0 +1,165 @@ +#include "configuration.h" +#include "gps/GeoCoord.h" +#include +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +// Pins latLongToMeter()'s equirectangular-approximation accuracy against the original spherical +// law of cosines, so a future change can't silently regress it. + +static constexpr double kPi = 3.14159265358979323846; + +static double referenceSphericalLawOfCosines(double lat_a, double lng_a, double lat_b, double lng_b) +{ + double a1 = lat_a * kPi / 180.0; + double a2 = lng_a * kPi / 180.0; + double b1 = lat_b * kPi / 180.0; + double b2 = lng_b * kPi / 180.0; + double t1 = std::cos(a1) * std::cos(a2) * std::cos(b1) * std::cos(b2); + double t2 = std::cos(a1) * std::sin(a2) * std::cos(b1) * std::sin(b2); + double t3 = std::sin(a1) * std::sin(b1); + double arg = t1 + t2 + t3; + if (arg > 1.0) + arg = 1.0; + if (arg < -1.0) + arg = -1.0; + return 6366000 * std::acos(arg); +} + +// Below ~1m, relative error is dominated by rounding noise rather than the formula itself, so +// assert an absolute bound instead (still catches a badly-broken implementation). +static constexpr double kNearZeroAbsoluteToleranceMeters = 0.5; + +// An order of magnitude above what the implementation currently produces per group - tight enough to +// catch a regression, loose enough not to track float rounding. Groups differ because +// equirectangular error grows with both separation and latitude. +static constexpr double kLocalTolerancePercent = 0.01; +static constexpr double kRegionalTolerancePercent = 0.1; +static constexpr double kHighLatitudeTolerancePercent = 0.2; +static constexpr double kAntimeridianTolerancePercent = 0.01; + +static void assertWithinPercent(double expected, double actual, double pct, const char *msg) +{ + if (expected < 1.0) { + if (std::fabs(actual - expected) > kNearZeroAbsoluteToleranceMeters) { + char buf[160]; + snprintf(buf, sizeof(buf), "%s: expected=%.3f actual=%.3f (near-zero, limit %.1fm absolute)", msg, expected, actual, + kNearZeroAbsoluteToleranceMeters); + TEST_FAIL_MESSAGE(buf); + } + return; + } + double err = std::fabs(actual - expected) / expected * 100.0; + if (err > pct) { + char buf[160]; + snprintf(buf, sizeof(buf), "%s: expected=%.1f actual=%.1f err=%.2f%% (limit %.2f%%)", msg, expected, actual, err, pct); + TEST_FAIL_MESSAGE(buf); + } +} + +static void test_identical_points_is_zero(void) +{ + TEST_ASSERT_EQUAL_FLOAT(0.0f, GeoCoord::latLongToMeter(51.5, -0.1, 51.5, -0.1)); +} + +static void test_local_distances(void) +{ + // Movement-threshold scale (meters to a few km) - the most common real usage. + struct { + double la, lo, lb, lob; + } cases[] = { + {51.5074, -0.1278, 51.5080, -0.1278}, // ~67m north + {51.5074, -0.1278, 51.5074, -0.1200}, // ~540m east at London's latitude + {0.0, 0.0, 0.001, 0.001}, // ~157m near the equator + {65.0, 25.0, 65.001, 25.002}, // high-ish latitude, small delta + {-33.87, 151.21, -33.865, 151.215}, // Sydney, southern hemisphere + }; + for (auto &c : cases) { + double expected = referenceSphericalLawOfCosines(c.la, c.lo, c.lb, c.lob); + double actual = GeoCoord::latLongToMeter(c.la, c.lo, c.lb, c.lob); + assertWithinPercent(expected, actual, kLocalTolerancePercent, "local distance"); + } +} + +static void test_regional_distances(void) +{ + // City-to-city scale (tens to ~500km) below 60 degrees; see test_high_latitude_distances. + struct { + double la, lo, lb, lob; + } cases[] = { + {51.5074, -0.1278, 48.8566, 2.3522}, // London to Paris, ~344km + {40.7128, -74.0060, 42.3601, -71.0589}, // NYC to Boston, ~306km + {35.6762, 139.6503, 34.6937, 135.5023}, // Tokyo to Osaka, ~400km + {-33.8688, 151.2093, -37.8136, 144.9631}, // Sydney to Melbourne, ~714km + }; + for (auto &c : cases) { + double expected = referenceSphericalLawOfCosines(c.la, c.lo, c.lb, c.lob); + double actual = GeoCoord::latLongToMeter(c.la, c.lo, c.lb, c.lob); + assertWithinPercent(expected, actual, kRegionalTolerancePercent, "regional distance"); + } +} + +static void test_high_latitude_distances(void) +{ + // Regional scale above 60 degrees, where equirectangular error grows fastest - a 500km pair at + // 80 degrees already exceeds 1%. + struct { + double la, lo, lb, lob; + } cases[] = { + {69.6492, 18.9553, 67.2804, 14.4049}, // Tromso to Bodo, ~322km + {64.8378, -147.7164, 61.2181, -149.9003}, // Fairbanks to Anchorage, ~417km + {78.2232, 15.6469, 78.9230, 11.9219}, // Longyearbyen to Ny-Alesund, ~113km + }; + for (auto &c : cases) { + double expected = referenceSphericalLawOfCosines(c.la, c.lo, c.lb, c.lob); + double actual = GeoCoord::latLongToMeter(c.la, c.lo, c.lb, c.lob); + assertWithinPercent(expected, actual, kHighLatitudeTolerancePercent, "high-latitude distance"); + } +} + +static void test_antimeridian_wraparound(void) +{ + // Two points ~22km apart straddling the 180th meridian - regression case for the antimeridian + // wraparound fix (a naive b2-a2 would compute this as ~40,000km). + double expected = referenceSphericalLawOfCosines(0.0, 179.9, 0.0, -179.9); + double actual = GeoCoord::latLongToMeter(0.0, 179.9, 0.0, -179.9); + assertWithinPercent(expected, actual, kAntimeridianTolerancePercent, "antimeridian distance"); + TEST_ASSERT_LESS_THAN_FLOAT(1000000.0f, actual); // sanity: nowhere near the naive-bug's ~40,000km +} + +static void test_symmetry(void) +{ + // distance(a,b) should equal distance(b,a) + double d1 = GeoCoord::latLongToMeter(51.5074, -0.1278, 48.8566, 2.3522); + double d2 = GeoCoord::latLongToMeter(48.8566, 2.3522, 51.5074, -0.1278); + TEST_ASSERT_FLOAT_WITHIN(0.01f, d1, d2); +} + +static void test_no_nan_at_extreme_latitudes(void) +{ + float d1 = GeoCoord::latLongToMeter(90.0, 0.0, -90.0, 0.0); + float d2 = GeoCoord::latLongToMeter(89.9, 10.0, 89.9, -170.0); + float d3 = GeoCoord::latLongToMeter(-89.9, 45.0, -89.9, -135.0); + TEST_ASSERT_FALSE(std::isnan(d1)); + TEST_ASSERT_FALSE(std::isnan(d2)); + TEST_ASSERT_FALSE(std::isnan(d3)); + TEST_ASSERT_TRUE(d1 > 0); +} + +void setup() +{ + UNITY_BEGIN(); + RUN_TEST(test_identical_points_is_zero); + RUN_TEST(test_local_distances); + RUN_TEST(test_regional_distances); + RUN_TEST(test_high_latitude_distances); + RUN_TEST(test_antimeridian_wraparound); + RUN_TEST(test_symmetry); + RUN_TEST(test_no_nan_at_extreme_latitudes); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_gps_fix_hold/test_main.cpp b/test/test_gps_fix_hold/test_main.cpp new file mode 100644 index 0000000000..c6a4aec5e1 --- /dev/null +++ b/test/test_gps_fix_hold/test_main.cpp @@ -0,0 +1,218 @@ +// Unit tests for shouldArmFixHold() / fixHoldInForce() in src/gps/GPS.cpp - the post-lock +// ephemeris hold. +// +// In power-saving mode (gps_update_interval above GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS) the GPS holds +// for up to 20s after a lock to download ephemeris, then publishes and sleeps. The predicate below +// decides, once per GPS thread cycle that has a location, whether a hold should be armed. +// +// The case that matters is a hold that was consumed by a publish which did not sleep: GPS::runOnce() +// clears fixHoldEnds whenever it publishes, but only calls down() when the search timed out or a +// hold expired. If the predicate treats "not holding" as a reason to skip, nothing re-arms, nothing +// publishes, and the receiver stays powered until searchedTooLong() fires. +#include "Arduino.h" +#include "TestUtil.h" +#include "Throttle.h" +#include "UptimeClock.h" +#include +#include + +// The predicates live beside their only caller in src/gps/GPS.cpp rather than in a header of their +// own; the native test build compiles that file, so declaring the prototypes here is enough. A +// signature change breaks the link rather than silently diverging from the definition. +bool fixHoldInForce(uint32_t fixHoldEnds, uint32_t threadIntervalMs); +bool holdJustExpired(uint32_t fixHoldEnds); +bool shouldArmFixHold(bool hasValidLocation, uint8_t prevFixQual, uint32_t fixHoldEnds, uint32_t threadIntervalMs); + +// GPS_THREAD_INTERVAL, spelled out so the suite does not pull in GPS.h and its hardware deps. +static constexpr uint32_t kThreadInterval = 200; + +// The two hold durations the firmware uses: GPS_FIX_HOLD_MAX_MS, and a short one. +static constexpr uint32_t kHoldMs = 20 * 1000; + +void setUp(void) +{ + Time::setTestMillis(0); +} +void tearDown(void) +{ + Time::useRealClock(); +} + +// Arms a hold at the current test time and returns the resulting fixHoldEnds. +static uint32_t armHoldNow(uint32_t holdMs = kHoldMs) +{ + return Time::getMillis() + holdMs; +} + +// --- the reasons to arm --- + +// First lock of a cycle: hasValidLocation is still false on the rising edge. +void test_arms_on_the_first_lock_of_a_cycle(void) +{ + Time::setTestMillis(50 * 1000); + TEST_ASSERT_TRUE(shouldArmFixHold(false, 3, 0, kThreadInterval)); +} + +// Lock after the receiver was off: down() zeroes fixQual, so prev_fixQual is 0 on the way back up. +void test_arms_on_the_first_lock_after_the_gps_was_off(void) +{ + Time::setTestMillis(50 * 1000); + TEST_ASSERT_TRUE(shouldArmFixHold(true, 0, 0, kThreadInterval)); +} + +// The regression. A publish that did not sleep leaves hasValidLocation set, prev_fixQual non-zero +// and fixHoldEnds cleared to 0. Nothing else in runOnce() re-arms, so if this returns false the +// GPS never holds, never publishes again and never calls down() until the search times out. +void test_arms_after_a_publish_cleared_the_hold_without_sleeping(void) +{ + Time::setTestMillis(50 * 1000); + TEST_ASSERT_TRUE_MESSAGE(shouldArmFixHold(true, 3, 0, kThreadInterval), + "fixHoldEnds == 0 means 'not holding', which is a reason to arm"); +} + +void test_arms_once_the_hold_has_expired(void) +{ + Time::setTestMillis(50 * 1000); + const uint32_t fixHoldEnds = armHoldNow(); + + Time::advanceTestMillis(kHoldMs + kThreadInterval); + TEST_ASSERT_TRUE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); +} + +// --- the reason not to arm --- + +void test_does_not_arm_while_a_hold_is_in_force(void) +{ + Time::setTestMillis(50 * 1000); + const uint32_t fixHoldEnds = armHoldNow(); + + Time::advanceTestMillis(kHoldMs / 2); + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); +} + +// The GPS_THREAD_INTERVAL grace period: at the exact deadline the hold has not yet expired, because +// the next cycle is one interval away. +void test_does_not_arm_in_the_thread_interval_grace_after_the_deadline(void) +{ + Time::setTestMillis(50 * 1000); + const uint32_t fixHoldEnds = armHoldNow(); + + Time::advanceTestMillis(kHoldMs); // exactly at the deadline + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); + + Time::advanceTestMillis(kThreadInterval - 1); + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); + + Time::advanceTestMillis(1); // deadline + GPS_THREAD_INTERVAL, inclusive boundary + TEST_ASSERT_TRUE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); +} + +// --- across the 32-bit wrap --- + +// A hold armed just before the wrap must still be held through it. The naive form this replaced +// (`(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`) read as expired for the whole pre-wrap window, +// re-arming the hold on every single cycle. +void test_does_not_arm_while_a_hold_straddling_the_wrap_is_in_force(void) +{ + Time::setTestMillis(0xFFFFFF00u); // 256ms short of the wrap + const uint32_t fixHoldEnds = armHoldNow(); + + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); + + Time::advanceTestMillis(0x200u); // now past the wrap, still inside the hold + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); + + Time::advanceTestMillis(kHoldMs); // well past the deadline, still past the wrap + TEST_ASSERT_TRUE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); +} + +// The deadline itself wrapping (fixHoldEnds numerically below millis()) must not read as expired. +void test_holds_when_the_deadline_wraps_but_now_has_not(void) +{ + Time::setTestMillis(0xFFFFFF00u); + const uint32_t fixHoldEnds = armHoldNow(); // wraps to ~0x4CFF + + TEST_ASSERT_TRUE_MESSAGE(fixHoldEnds < Time::getMillis(), "test setup: the deadline must have wrapped"); + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); +} + +// --- the two readings of the same sentinel --- + +// runOnce() asks two questions of fixHoldEnds and they take opposite answers when nothing is armed: +// "should I arm one?" (yes) and "did one just expire, so publish and sleep?" (no). Both are derived +// from fixHoldInForce(), which is the only place the sentinel is interpreted. +void test_no_hold_means_arm_but_does_not_mean_expired(void) +{ + Time::setTestMillis(50 * 1000); + + TEST_ASSERT_FALSE_MESSAGE(fixHoldInForce(0, kThreadInterval), "a hold that was never armed is not in force"); + TEST_ASSERT_TRUE_MESSAGE(shouldArmFixHold(true, 3, 0, kThreadInterval), "...so it is a reason to arm one"); + TEST_ASSERT_FALSE_MESSAGE(holdJustExpired(0), "...but not a reason to publish and sleep"); +} + +// holdJustExpired()'s sentinel guard is load-bearing on every cycle, not just past the half-range: +// fixHoldInForce() calls an unarmed hold "not in force", so negating it alone reads as expired. +void test_only_an_armed_hold_can_expire(void) +{ + Time::setTestMillis(50 * 1000); + const uint32_t fixHoldEnds = armHoldNow(); + + TEST_ASSERT_FALSE_MESSAGE(holdJustExpired(fixHoldEnds), "still inside the hold"); + + Time::advanceTestMillis(kHoldMs); // the deadline itself, no grace interval at this site + TEST_ASSERT_TRUE_MESSAGE(holdJustExpired(fixHoldEnds), "the deadline is the moment to publish and sleep"); + + TEST_ASSERT_TRUE_MESSAGE(!fixHoldInForce(0, 0), "test premise: the negation alone calls an unarmed hold expired"); + TEST_ASSERT_FALSE_MESSAGE(holdJustExpired(0), "so the sentinel test is what keeps it from expiring"); +} + +// The `fixHoldEnds != 0` term inside fixHoldInForce() looks redundant, and for the first half of +// each wrap cycle it is: deadlinePassed(0 + interval) is true once uptime exceeds one interval, so +// "not in force" would fall out of the arithmetic on its own. Past 2^31 ms of uptime it flips. +// deadlinePassed() is an unsigned half-range test, so `now - interval` lands in the top half and +// the sentinel reads as a deadline ~24.8 days in the FUTURE - an unarmed hold would look like one +// in force for the whole second half of every cycle, and nothing would ever re-arm. +void test_the_sentinel_guard_is_load_bearing_past_the_half_range(void) +{ + Time::setTestMillis(0x90000000u); // ~27.8 days of uptime, past the ~24.8-day half-range point + + // The arithmetic alone now says "not yet" for the sentinel... + TEST_ASSERT_FALSE_MESSAGE(Throttle::deadlinePassed(0 + kThreadInterval), + "test premise: past half-range the sentinel reads as a future deadline"); + + // ...so the explicit sentinel test is the only thing keeping the answer right. + TEST_ASSERT_FALSE_MESSAGE(fixHoldInForce(0, kThreadInterval), "an unarmed hold is never in force"); + TEST_ASSERT_TRUE_MESSAGE(shouldArmFixHold(true, 3, 0, kThreadInterval), "...so a hold must still be armed"); +} + +void test_hold_in_force_tracks_the_deadline(void) +{ + Time::setTestMillis(50 * 1000); + const uint32_t fixHoldEnds = armHoldNow(); + + TEST_ASSERT_TRUE(fixHoldInForce(fixHoldEnds, kThreadInterval)); + + Time::advanceTestMillis(kHoldMs + kThreadInterval); + TEST_ASSERT_FALSE(fixHoldInForce(fixHoldEnds, kThreadInterval)); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_no_hold_means_arm_but_does_not_mean_expired); + RUN_TEST(test_only_an_armed_hold_can_expire); + RUN_TEST(test_the_sentinel_guard_is_load_bearing_past_the_half_range); + RUN_TEST(test_hold_in_force_tracks_the_deadline); + RUN_TEST(test_arms_on_the_first_lock_of_a_cycle); + RUN_TEST(test_arms_on_the_first_lock_after_the_gps_was_off); + RUN_TEST(test_arms_after_a_publish_cleared_the_hold_without_sleeping); + RUN_TEST(test_arms_once_the_hold_has_expired); + RUN_TEST(test_does_not_arm_while_a_hold_is_in_force); + RUN_TEST(test_does_not_arm_in_the_thread_interval_grace_after_the_deadline); + RUN_TEST(test_does_not_arm_while_a_hold_straddling_the_wrap_is_in_force); + RUN_TEST(test_holds_when_the_deadline_wraps_but_now_has_not); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_gps_update_scheduling/test_main.cpp b/test/test_gps_update_scheduling/test_main.cpp new file mode 100644 index 0000000000..72efe89043 --- /dev/null +++ b/test/test_gps_update_scheduling/test_main.cpp @@ -0,0 +1,191 @@ +#include "Arduino.h" +#include "TestUtil.h" +#include "UptimeClock.h" +#include "gps/GPSUpdateScheduling.h" +#include +#include +#include + +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. +static double originalFormula(uint32_t seconds) +{ + return 2750.0 * std::pow((double)seconds, 1.22); +} + +static void test_matches_original_formula_at_sampled_points(void) +{ + // Off-breakpoint values only - a breakpoint interpolates exactly by construction, so it would + // test nothing here (test_exact_at_table_breakpoints covers those). Includes both worst-error + // inputs: 7s (1.60%) and 728s (0.55%). Capped at 900s, the pre-existing 15-minute search clamp. + const uint32_t samples[] = {4, 6, 7, 8, 9, 33, 100, 150, 500, 728, 899}; + for (uint32_t s : samples) { + double expected = originalFormula(s); + uint32_t actual = gpsHardsleepThresholdMs(s); + // Pure integer arithmetic, so results are bit-identical everywhere - no float noise to + // leave headroom for, and these sit just above the measured worst cases. + double tolerance = expected * (s < 10 ? 0.02 : 0.0075); + TEST_ASSERT_DOUBLE_WITHIN(tolerance, expected, (double)actual); + } +} + +static void test_zero_seconds_is_zero(void) +{ + TEST_ASSERT_EQUAL_UINT32(0, gpsHardsleepThresholdMs(0)); +} + +static void test_monotonically_nondecreasing(void) +{ + uint32_t prev = gpsHardsleepThresholdMs(0); + for (uint32_t s = 1; s <= 1200; s += 7) { + uint32_t cur = gpsHardsleepThresholdMs(s); + TEST_ASSERT_GREATER_OR_EQUAL_UINT32(prev, cur); + prev = cur; + } +} + +static void test_exact_at_table_breakpoints(void) +{ + // Every breakpoint must return its own sampled value. Catches an off-by-one in the segment + // scan, which a percentage bound on interpolated points would absorb. + const uint32_t breakpoints[] = {0, 1, 2, 3, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 300, 450, 600, 900}; + for (uint32_t s : breakpoints) { + char msg[64]; + snprintf(msg, sizeof(msg), "breakpoint %us", s); + // Within 2ms, not exact: the 30s entry is rounded 1ms high, and pow() can differ by an ULP + // across libm implementations. A real off-by-one in the scan misses by thousands. + TEST_ASSERT_UINT32_WITHIN_MESSAGE(2, (uint32_t)(originalFormula(s) + 0.5), gpsHardsleepThresholdMs(s), msg); + } +} + +static void test_clamps_above_table_range(void) +{ + uint32_t atMax = gpsHardsleepThresholdMs(900); + TEST_ASSERT_EQUAL_UINT32(atMax, gpsHardsleepThresholdMs(2000)); + TEST_ASSERT_EQUAL_UINT32(atMax, gpsHardsleepThresholdMs(UINT32_MAX)); +} + +static void test_clamp_boundary(void) +{ + // The clamp must engage exactly at the last table point, not before or after it. + TEST_ASSERT_LESS_THAN_UINT32(gpsHardsleepThresholdMs(900), gpsHardsleepThresholdMs(899)); + 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); + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_matches_original_formula_at_sampled_points); + RUN_TEST(test_zero_seconds_is_zero); + RUN_TEST(test_monotonically_nondecreasing); + 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()); +} + +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/ports/test_timestamp.cpp b/test/test_meshpacket_serializer/ports/test_timestamp.cpp index 333945f80a..d6e38bcf1e 100644 --- a/test/test_meshpacket_serializer/ports/test_timestamp.cpp +++ b/test/test_meshpacket_serializer/ports/test_timestamp.cpp @@ -21,7 +21,7 @@ void test_timestamp_zeroed_when_rx_time_absent() std::string json = MeshPacketSerializer::JsonSerialize(&packet, false); Json::Value root = parse_json(json); TEST_ASSERT_TRUE(root.isMember("timestamp")); - TEST_ASSERT_EQUAL_UINT32(0u, root["timestamp"].asUInt()); // must not leak the millis() placeholder + TEST_ASSERT_EQUAL_UINT32(0u, root["timestamp"].asUInt()); // must not leak the uptime placeholder } void test_encrypted_timestamp_zeroed_when_rx_time_absent() diff --git a/test/test_meshpacket_serializer/test_helpers.h b/test/test_meshpacket_serializer/test_helpers.h index 2dc06cec78..63447d5092 100644 --- a/test/test_meshpacket_serializer/test_helpers.h +++ b/test/test_meshpacket_serializer/test_helpers.h @@ -70,7 +70,7 @@ static meshtastic_MeshPacket create_test_packet_no_rx_time(meshtastic_PortNum po int payload_variant = meshtastic_MeshPacket_decoded_tag) { meshtastic_MeshPacket packet = create_test_packet(port, payload, payload_size, payload_variant); - packet.rx_time = 123456; // a plausible millis() placeholder, not a real epoch + packet.rx_time = 123456; // a plausible uptime-seconds placeholder, not a real epoch packet.has_rx_time = false; return packet; } diff --git a/test/test_meshpacket_serializer/test_serializer.cpp b/test/test_meshpacket_serializer/test_serializer.cpp index 82e79f8e1a..db863ca3c2 100644 --- a/test/test_meshpacket_serializer/test_serializer.cpp +++ b/test/test_meshpacket_serializer/test_serializer.cpp @@ -23,6 +23,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 e2d006e382..b67cf31abe 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -17,7 +17,13 @@ #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 @@ -338,16 +344,64 @@ const meshtastic_MeshPacket encrypted = { .encrypted = {.size = 0}, .id = 3, }; + +void configureCoordinatePolicyChannels(bool eventChannelIsPrimary = true) +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + auto &eventChannel = channelFile.channels[0]; + eventChannel.index = 0; + eventChannel.has_settings = true; + strncpy(eventChannel.settings.name, "everyone", sizeof(eventChannel.settings.name) - 1); + eventChannel.settings.uplink_enabled = true; + eventChannel.settings.downlink_enabled = true; + eventChannel.role = eventChannelIsPrimary ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY; +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t configuredEventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(configuredEventPsk); + memcpy(eventChannel.settings.psk.bytes, configuredEventPsk, sizeof(configuredEventPsk)); +#endif + + auto &privateChannel = channelFile.channels[1]; + privateChannel.index = 1; + privateChannel.has_settings = true; + strncpy(privateChannel.settings.name, "private", sizeof(privateChannel.settings.name) - 1); + privateChannel.settings.psk.size = 32; + memset(privateChannel.settings.psk.bytes, 0xab, privateChannel.settings.psk.size); + privateChannel.settings.uplink_enabled = true; + privateChannel.settings.downlink_enabled = true; + privateChannel.role = eventChannelIsPrimary ? meshtastic_Channel_Role_SECONDARY : meshtastic_Channel_Role_PRIMARY; + + channels.onConfigChanged(); +} + +meshtastic_MeshPacket makePositionPacket(ChannelIndex channel) +{ + meshtastic_MeshPacket packet = decoded; + packet.to = NODENUM_BROADCAST; + packet.channel = channel; + packet.decoded.portnum = meshtastic_PortNum_POSITION_APP; + return packet; +} + +void clearPublicationState() +{ + TEST_ASSERT_EQUAL(0, unitTest->queueSize()); + pubsub->published_.clear(); + mockMeshService->messages_.clear(); +} } // namespace // Initialize mocks and configuration before running each test. void setUp(void) { - config = meshtastic_LocalConfig_init_zero; + memset(&config, 0, sizeof(config)); moduleConfig.mqtt = meshtastic_ModuleConfig_MQTTConfig{.enabled = true, .map_reporting_enabled = true, .has_map_report_settings = true}; moduleConfig.mqtt.map_report_settings = meshtastic_ModuleConfig_MapReportSettings{ .publish_interval_secs = 0, .position_precision = 14, .should_report_location = true}; + memset(&channelFile, 0, sizeof(channelFile)); channelFile.channels[0] = meshtastic_Channel{ .index = 0, .has_settings = true, @@ -355,6 +409,7 @@ void setUp(void) .role = meshtastic_Channel_Role_PRIMARY, }; channelFile.channels_count = 1; + channels.onConfigChanged(); owner = meshtastic_User{.id = "!12345678"}; myNodeInfo = meshtastic_MyNodeInfo{.my_node_num = 0x12345678}; // Match the expected gateway ID in topic localPosition = @@ -412,6 +467,50 @@ void test_sendDirectlyConnectedEncrypted(void) TEST_ASSERT_EQUAL(encrypted.id, env.packet->id); } +void test_eventPositionPublicationFollowsCompileTimePolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + const meshtastic_MeshPacket position = makePositionPacket(0); + + mqtt->onSend(encrypted, position, 0); + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + TEST_ASSERT_TRUE(pubsub->published_.empty()); + TEST_ASSERT_EQUAL(0, unitTest->queueSize()); +#else + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); +#endif +} + +void test_privatePositionStillPublishesWithEventPolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + const meshtastic_MeshPacket position = makePositionPacket(1); + + mqtt->onSend(encrypted, position, 1); + + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); + TEST_ASSERT_EQUAL_STRING("msh/2/e/private/!12345678", pubsub->published_.front().first.c_str()); +} + +void test_explicitPkiPositionStillPublishesWithEventPolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + meshtastic_MeshPacket position = makePositionPacket(0); + meshtastic_MeshPacket encryptedPki = encrypted; + position.to = 2; + position.pki_encrypted = true; + encryptedPki.pki_encrypted = true; + + mqtt->onSend(encryptedPki, position, 0); + + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); + TEST_ASSERT_EQUAL_STRING("msh/2/e/PKI/!12345678", pubsub->published_.front().first.c_str()); +} + // Verify that the decoded MeshPacket is proxied through the MeshService when encryption_enabled = false. void test_proxyToMeshServiceDecoded(void) { @@ -918,6 +1017,32 @@ void test_reportToMapDefaultImprecise(void) TEST_ASSERT_EQUAL_STRING("msh/2/map/", topic.c_str()); } +void test_eventPrimaryMapReportFollowsCompileTimePolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + + unitTest->reportToMap(); + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + TEST_ASSERT_TRUE(pubsub->published_.empty()); + TEST_ASSERT_EQUAL(0, unitTest->queueSize()); +#else + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); +#endif +} + +void test_privatePrimaryMapReportStillPublishesWithEventPolicy(void) +{ + configureCoordinatePolicyChannels(false); + clearPublicationState(); + + unitTest->reportToMap(); + + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); + TEST_ASSERT_EQUAL_STRING("msh/2/map/", pubsub->published_.front().first.c_str()); +} + // Location is sent over the phone proxy. void test_reportToMapImpreciseProxied(void) { @@ -1135,6 +1260,9 @@ void setup() UNITY_BEGIN(); RUN_TEST(test_sendDirectlyConnectedDecoded); RUN_TEST(test_sendDirectlyConnectedEncrypted); + RUN_TEST(test_eventPositionPublicationFollowsCompileTimePolicy); + RUN_TEST(test_privatePositionStillPublishesWithEventPolicy); + RUN_TEST(test_explicitPkiPositionStillPublishesWithEventPolicy); RUN_TEST(test_proxyToMeshServiceDecoded); RUN_TEST(test_proxyToMeshServiceEncrypted); RUN_TEST(test_dontMqttMeOnPublicServer); @@ -1171,6 +1299,8 @@ void setup() RUN_TEST(test_publishTextMessageDirect); RUN_TEST(test_publishTextMessageWithProxy); RUN_TEST(test_reportToMapDefaultImprecise); + RUN_TEST(test_eventPrimaryMapReportFollowsCompileTimePolicy); + RUN_TEST(test_privatePrimaryMapReportStillPublishesWithEventPolicy); RUN_TEST(test_reportToMapImpreciseProxied); RUN_TEST(test_usingDefaultServer); RUN_TEST(test_usingDefaultServerWithPort); diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index 7d3dd9eec8..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 @@ -11,15 +11,21 @@ #include "TestUtil.h" #include +#include "airtime.h" #include "configuration.h" #include "gps/RTC.h" #include "mesh/Default.h" #include "mesh/NextHopRouter.h" #include "mesh/NodeDB.h" #include "mesh/RadioInterface.h" +#include "mesh/ReliableRouter.h" +#include "modules/RoutingModule.h" #include #include +#include #include +#include +#include #define MSG_BUF_LEN 200 #define TEST_MSG_FMT(fmt, ...) \ @@ -30,6 +36,13 @@ } while (0) static constexpr NodeNum kLocalNode = 0x11111111; // last byte 0x11 +static constexpr NodeNum kRemoteNode = 0x22222222; + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) +static constexpr bool kEventPolicyEnabled = true; +#else +static constexpr bool kEventPolicyEnabled = false; +#endif // --------------------------------------------------------------------------- // MockNodeDB - inject nodes with controlled last byte, hop distance, age, role, favorite flag. @@ -93,6 +106,53 @@ 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); } + + void clearPendingForTest() + { + while (!pending.empty()) + stopRetransmission(pending.begin()->first); + } + void resetRouteHealthForTest() { for (auto &h : routeHealth) @@ -100,10 +160,8 @@ class NextHopRouterTestShim : public NextHopRouter } }; -// --------------------------------------------------------------------------- -// MockRadioInterface - mirrors RadioLibInterface::send()'s NODENUM_BROADCAST_NO_LORA branch, which -// returns ERRNO_SHOULD_RELEASE without releasing. -// --------------------------------------------------------------------------- +// Mirrors RadioLibInterface::send()'s NODENUM_BROADCAST_NO_LORA branch, which +// returns ERRNO_SHOULD_RELEASE without releasing the packet. class MockRadioInterface : public RadioInterface { public: @@ -112,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; @@ -126,14 +185,153 @@ 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 +{ + 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 0; + } + + void reset() + { + sentPackets.clear(); + cancelCount = 0; + } + + std::vector sentPackets; + uint32_t cancelCount = 0; +}; + +class ReliableRouterTestShim : public ReliableRouter +{ + public: + ReliableRouterTestShim() : ReliableRouter() {} + + 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 makeRetryDue(NodeNum from, PacketId id) + { + PendingPacket *record = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(record); + record->nextTxMsec = 0; + } + + int32_t runDueRetries() { return doRetransmissions(); } + void sniffForTest(const meshtastic_MeshPacket *p, const meshtastic_Routing *routing) + { + ReliableRouter::sniffReceived(p, routing); + } + + void implicitAckForTest(const meshtastic_MeshPacket *p) { perhapsGenerateImplicitAckForOwnOverheard(p); } + + void clearPendingForTest() + { + while (!pending.empty()) + stopRetransmission(pending.begin()->first); + } +}; + +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 meshtastic_MeshPacket makeRebroadcastCandidate(NodeNum to) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = kRemoteNode; + p.to = to; + p.id = 0x0BADF00D; + p.hop_start = 3; + p.hop_limit = 3; + p.next_hop = NO_NEXT_HOP_PREFERENCE; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 8; + return p; +} + static MockNodeDB *mockNodeDB = nullptr; static NextHopRouterTestShim *shim = nullptr; +static ReliableRouterTestShim *reliableShim = nullptr; +static CaptureRadioInterface *nextHopRadio = nullptr; +static CaptureRadioInterface *reliableRadio = nullptr; +static MockRoutingModule *mockRoutingModule = nullptr; +static std::unique_ptr airTimeFixture; +static PacketId nextBehaviorPacketId = 0x70000000; + +static MockRadioInterface *installMockIface() +{ + MockRadioInterface *mock = new MockRadioInterface(); + // addInterface replaces and destroys the suite's original capture interface. + // Clear its borrowed pointer before the next Unity setUp() runs. + nextHopRadio = nullptr; + shim->addInterface(std::unique_ptr(mock)); + return mock; +} static constexpr uint32_t TTL = NextHopRouter::ROUTE_TTL_MSEC; static constexpr uint8_t THRESH = NextHopRouter::ROUTE_FAILURE_THRESHOLD; @@ -150,12 +348,84 @@ static meshtastic_MeshPacket makeRelayedPacket(uint8_t relay, uint8_t hopsAway) return p; } +static meshtastic_Channel makeBehaviorChannel(meshtastic_Channel_Role role, const char *name) +{ + meshtastic_Channel channel = meshtastic_Channel_init_default; + channel.has_settings = true; + channel.role = role; + channel.settings.has_module_settings = true; + channel.settings.module_settings.position_precision = 16; + strncpy(channel.settings.name, name, sizeof(channel.settings.name) - 1); + return channel; +} + +static void configureBehaviorChannels() +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel eventChannel = makeBehaviorChannel(meshtastic_Channel_Role_PRIMARY, "everyone"); + eventChannel.index = 0; +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t eventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(eventPsk); + memcpy(eventChannel.settings.psk.bytes, eventPsk, sizeof(eventPsk)); +#endif + + meshtastic_Channel privateChannel = makeBehaviorChannel(meshtastic_Channel_Role_SECONDARY, "private"); + privateChannel.index = 1; + privateChannel.settings.psk.size = 32; + memset(privateChannel.settings.psk.bytes, 0xAB, privateChannel.settings.psk.size); + + channelFile.channels[0] = eventChannel; + channelFile.channels[1] = privateChannel; + channels.onConfigChanged(); +} + +static meshtastic_MeshPacket makeBehaviorPacket(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 = nextBehaviorPacketId++; + 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_decoded_tag; + p.decoded.portnum = portnum; + return p; +} + +static meshtastic_MeshPacket *allocBehaviorPacket(meshtastic_PortNum portnum, NodeNum to, uint8_t channel, bool wantAck) +{ + auto packet = makeBehaviorPacket(portnum, kLocalNode, to, channel, wantAck); + auto *allocated = packetPool.allocCopy(packet); + TEST_ASSERT_NOT_NULL(allocated); + return allocated; +} + 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.security.private_key.size = 0; + owner.is_licensed = false; mockNodeDB->clearTestNodes(); shim->resetRouteHealthForTest(); + shim->clearPendingForTest(); + reliableShim->clearPendingForTest(); + if (nextHopRadio) + nextHopRadio->reset(); + reliableRadio->reset(); + mockRoutingModule->ackNaks.clear(); + configureBehaviorChannels(); } void tearDown(void) {} @@ -446,30 +716,246 @@ void test_hoplimit_decrement_when_resolved_not_favorite(void) } // =========================================================================== -// Rebroadcast of NODENUM_BROADCAST_NO_LORA +// Group 5 - event-coordinate routing behavior // =========================================================================== -static MockRadioInterface *installMockIface() +void test_eventPolicy_reliableOriginSendSuppressesTxAndPending(void) { - MockRadioInterface *m = new MockRadioInterface(); - shim->addInterface(std::unique_ptr(m)); - return m; + ErrorCode result = reliableShim->send( + allocBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, NODENUM_BROADCAST, /*event channel=*/0, /*wantAck=*/true)); + + if (kEventPolicyEnabled) { + TEST_ASSERT_EQUAL_INT(meshtastic_Routing_Error_NOT_AUTHORIZED, result); + TEST_ASSERT_EQUAL_UINT32(0, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); + } else { + TEST_ASSERT_EQUAL_INT(ERRNO_OK, result); + TEST_ASSERT_EQUAL_UINT32(1, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + } } -// Eligible for rebroadcast: not from/to us, hops left, nonzero id, no next-hop preference. -// Encrypted variant so Router::send() skips the encode path. -static meshtastic_MeshPacket makeRebroadcastCandidate(NodeNum to) +void test_eventPolicy_reliablePrivateCoordinateStillSends(void) { - meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; - p.from = 0x22222222; // not us - p.to = to; - p.id = 0x0BADF00D; - p.hop_start = 3; - p.hop_limit = 3; - p.next_hop = NO_NEXT_HOP_PREFERENCE; - p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; - p.encrypted.size = 8; - return p; + ErrorCode result = reliableShim->send( + allocBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, NODENUM_BROADCAST, /*private channel=*/1, /*wantAck=*/true)); + + TEST_ASSERT_EQUAL_INT(ERRNO_OK, result); + TEST_ASSERT_EQUAL_UINT32(1, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_eventPolicy_floodingDuplicateSuppressesCoordinateButRelaysText(void) +{ + mockNodeDB->addNode(kRemoteNode, 0, true, 0); + auto coordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, NODENUM_BROADCAST, 0); + TEST_ASSERT_FALSE(shim->filterViaFlooding(&coordinate)); + TEST_ASSERT_TRUE(shim->filterViaFlooding(&coordinate)); + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, nextHopRadio->sentPackets.size()); + + nextHopRadio->reset(); + auto text = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, NODENUM_BROADCAST, 0); + TEST_ASSERT_FALSE(shim->filterViaFlooding(&text)); + TEST_ASSERT_TRUE(shim->filterViaFlooding(&text)); + TEST_ASSERT_EQUAL_UINT32(1, nextHopRadio->sentPackets.size()); +} + +void test_eventPolicy_nextHopDuplicateSuppressesEventButRelaysPrivateCoordinate(void) +{ + mockNodeDB->addNode(kRemoteNode, 0, true, 0); + auto eventCoordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, NODENUM_BROADCAST, 0); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&eventCoordinate)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&eventCoordinate)); + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, nextHopRadio->sentPackets.size()); + + nextHopRadio->reset(); + auto privateCoordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, NODENUM_BROADCAST, 1); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&privateCoordinate)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&privateCoordinate)); + TEST_ASSERT_EQUAL_UINT32(1, nextHopRadio->sentPackets.size()); +} + +void test_eventPolicy_repeatedLocalPacketSuppressesCoordinateAckButKeepsTextAck(void) +{ + mockNodeDB->addNode(kRemoteNode, 0, true, 0); + auto coordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, kLocalNode, 0, /*wantAck=*/true); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&coordinate)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&coordinate)); + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, mockRoutingModule->ackNaks.size()); + + mockRoutingModule->ackNaks.clear(); + auto text = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 0, /*wantAck=*/true); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&text)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&text)); + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + const auto &ack = mockRoutingModule->ackNaks.front(); + TEST_ASSERT_EQUAL_INT(meshtastic_Routing_Error_NONE, std::get<0>(ack)); + TEST_ASSERT_EQUAL_HEX32(kRemoteNode, std::get<1>(ack)); + TEST_ASSERT_EQUAL_HEX32(text.id, std::get<2>(ack)); +} + +void test_eventPolicy_seededRetrySuppressesTxUntilGateOff(void) +{ + auto coordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kLocalNode, NODENUM_BROADCAST, 0, /*wantAck=*/true); + reliableShim->seedRetry(coordinate, /*attempts=*/2); + reliableShim->makeRetryDue(kLocalNode, coordinate.id); + + reliableShim->runDueRetries(); + + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_reliableAckStopsNormalPendingTransmission(void) +{ + auto original = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_RETX); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + + auto ack = makeBehaviorPacket(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()); +} + +// 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. @@ -491,13 +977,10 @@ void test_rebroadcast_no_lora_broadcast_is_not_relayed(void) TEST_ASSERT_EQUAL_MESSAGE(0, mockIface->sendCount, "no packet should be handed to the radio at all"); } -// Declining mock bypasses the guard so send() is reached; the release itself is only observable as -// a sanitizer leak report, not an assertion. void test_rebroadcast_declined_send_releases_packet(void) { MockRadioInterface *mockIface = installMockIface(); mockIface->declineAll = true; - meshtastic_MeshPacket p = makeRebroadcastCandidate(NODENUM_BROADCAST); TEST_ASSERT_TRUE_MESSAGE(shim->perhapsRebroadcast(&p), "the rebroadcast must still be attempted"); @@ -549,12 +1032,27 @@ void test_event_mode_hop_behavior(void) void setup() { initializeTestEnvironment(); + AirTime testAirTime; + airTime = &testAirTime; UNITY_BEGIN(); + airTimeFixture = std::make_unique(); mockNodeDB = new MockNodeDB(); shim = new NextHopRouterTestShim(); + reliableShim = new ReliableRouterTestShim(); nodeDB = mockNodeDB; + auto nextRadio = std::make_unique(); + nextHopRadio = nextRadio.get(); + shim->addInterface(std::move(nextRadio)); + + auto reliableCapture = std::make_unique(); + reliableRadio = reliableCapture.get(); + reliableShim->addInterface(std::move(reliableCapture)); + + mockRoutingModule = new MockRoutingModule(); + routingModule = mockRoutingModule; + printf("\n=== resolveLastByte (M1) ===\n"); RUN_TEST(test_resolve_none_when_empty); RUN_TEST(test_resolve_zero_byte_is_none); @@ -594,6 +1092,24 @@ void setup() RUN_TEST(test_hoplimit_decrement_on_colliding_favorites); RUN_TEST(test_hoplimit_decrement_when_resolved_not_favorite); + printf("\n=== event-coordinate routing behavior ===\n"); + RUN_TEST(test_eventPolicy_reliableOriginSendSuppressesTxAndPending); + RUN_TEST(test_eventPolicy_reliablePrivateCoordinateStillSends); + RUN_TEST(test_eventPolicy_floodingDuplicateSuppressesCoordinateButRelaysText); + RUN_TEST(test_eventPolicy_nextHopDuplicateSuppressesEventButRelaysPrivateCoordinate); + RUN_TEST(test_eventPolicy_repeatedLocalPacketSuppressesCoordinateAckButKeepsTextAck); + 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); @@ -602,7 +1118,9 @@ void setup() RUN_TEST(test_event_mode_hop_behavior); #endif - exit(UNITY_END()); + int result = UNITY_END(); + airTimeFixture.reset(); + exit(result); } void loop() {} diff --git a/test/test_nodedb_blocked/test_main.cpp b/test/test_nodedb_blocked/test_main.cpp index 88d7f0259d..96d392cd85 100644 --- a/test/test_nodedb_blocked/test_main.cpp +++ b/test/test_nodedb_blocked/test_main.cpp @@ -25,6 +25,7 @@ class NodeDBTestShim : public NodeDB public: void runDemote() { demoteOldestHotNodesToWarm(); } void runCleanup() { cleanupMeshDB(); } + void stampUntrusted(NodeNum num, uint32_t uptimeSecs) { recordHeardWhileClockUntrusted(num, uptimeSecs); } // 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); } @@ -178,6 +179,27 @@ static void test_eviction_preservesFavorite(void) TEST_ASSERT_NOT_NULL(db->getMeshNode(0x99990000)); } +// 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_prefersCurrentBootStampOverPost2038Epoch(void) +{ + constexpr NodeNum futureDated = 0x70000001; + constexpr NodeNum heardThisBoot = 0x70000002; + + db->seedSelf(); + db->push(futureDated, 0xB5000000u, false, false, /*withUser=*/true, /*withKey=*/true); + db->push(heardThisBoot, 0, false, false, /*withUser=*/true, /*withKey=*/true); + db->stampUntrusted(heardThisBoot, 10); + for (int i = 3; i < MAX_NUM_NODES; i++) + db->push(0x70000000u + i, UINT32_MAX, false, false, /*withUser=*/true, /*withKey=*/true); + + TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes()); + TEST_ASSERT_NOT_NULL(db->getOrCreateMeshNode(0x79999999)); + + TEST_ASSERT_NULL(db->getMeshNode(futureDated)); + TEST_ASSERT_NOT_NULL(db->getMeshNode(heardThisBoot)); +} + // Ignored handling: an ignored node survives eviction (like a favourite), and is // never purged by cleanupMeshDB even with no user info (a block set by bare ID). static void test_ignored_survivesEvictionAndCleanup(void) @@ -269,6 +291,7 @@ NDB_TEST_ENTRY void setup() RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm); RUN_TEST(test_migration_carriesSignerBitThroughWarm); RUN_TEST(test_eviction_preservesFavorite); + 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_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index c3abb7bc95..d7453e29a2 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -22,6 +22,7 @@ // compiled out unless both PKI and XEdDSA are enabled (e.g. stm32 sets MESHTASTIC_EXCLUDE_XEDDSA). #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) +#include "UptimeClock.h" #include "mesh/Channels.h" #include "mesh/CryptoEngine.h" #include "mesh/MeshRadio.h" @@ -173,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() { @@ -416,11 +422,26 @@ void setUp(void) resetRoutingAuthEvaluationCount(); } +// Set while C14's saturated AirTime is installed; see useDutyCycleSaturatedAirTime() below. +static AirTime *c14SavedAirTime = nullptr; + void tearDown(void) { delete mockNodeDB; mockNodeDB = nullptr; nodeDB = nullptr; + + // 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 the AirTime swap are C14's duty-cycle setup. + Time::useRealClock(); + Time::resetMonotonicForTests(); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + if (c14SavedAirTime) { + airTime = c14SavedAirTime; + c14SavedAirTime = nullptr; + } } // =========================================================================== @@ -1073,6 +1094,7 @@ void test_B13_licensed_port_and_destination_signing_matrix(void) class NodeInfoTestShim : public NodeInfoModule { public: + using MeshModule::currentRequest; // allocReply() only suppresses while a request is in flight using NodeInfoModule::allocReply; using NodeInfoModule::handleReceivedProtobuf; }; @@ -1221,14 +1243,18 @@ void test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state(void) prior.hop_start = 2; prior.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; pipelineRouter->remember(&prior); - pipelineRouter->addPending(prior, UINT32_MAX); + // "Far future, so no retransmission is due." Must be a representable future time, not + // UINT32_MAX: doRetransmissions() compares with an unsigned half-range test, under which + // UINT32_MAX is ~1ms in the *past* and would fire a retransmit and rewrite nextTxMsec. + const uint32_t notDueTxMsec = Time::getMillis() + 3600000UL; + pipelineRouter->addPending(prior, notDueTxMsec); const uint32_t lastHeard = mockNodeDB->getMeshNode(LOCAL_NODE)->last_heard; meshtastic_MeshPacket invalid = makeSignedWirePacket(LOCAL_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x34, false); runPipelineIngress(invalid); assertNoRejectedPipelineEffects(LOCAL_NODE, lastHeard); TEST_ASSERT_EQUAL(1, pipelineRouter->pendingCount()); - TEST_ASSERT_EQUAL_UINT32(UINT32_MAX, pipelineRouter->pendingNextTx(LOCAL_NODE, id)); + TEST_ASSERT_EQUAL_UINT32(notDueTxMsec, pipelineRouter->pendingNextTx(LOCAL_NODE, id)); } void test_C4_invalid_fallback_packet_cannot_relay(void) @@ -1484,12 +1510,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; @@ -1503,11 +1549,30 @@ 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)); +} + // 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) @@ -1569,6 +1634,113 @@ void test_N7_unsigned_unicast_nodeinfo_from_nonsigner_changes_name(void) "non-signer identity learning must be unaffected"); } +// --------------------------------------------------------------------------- +// N8-N11: the 12h reply-suppression window. +// +// The stamp is uptime SECONDS, not milliseconds: entries live for as long as the node stays in the +// DB, so a 32-bit millisecond stamp aliased back into the window once uptime passed 49.7 days and +// suppressed a legitimate reply for up to 12h. Driven through Time::setTestMillis() rather than by +// waiting. +// --------------------------------------------------------------------------- + +static constexpr uint32_t kSuppressSecs = 12 * 60 * 60; + +// Deliver a NodeInfo request from `sender` and report whether we would reply to it. +static bool wouldReplyToNodeInfoRequest(NodeInfoTestShim &shim, NodeNum sender) +{ + meshtastic_MeshPacket mp = makeDecoded(sender, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); + mp.decoded.want_response = true; + meshtastic_User user = meshtastic_User_init_zero; + user.is_licensed = owner.is_licensed; + + shim.handleReceivedProtobuf(mp, &user); + + NodeInfoTestShim::currentRequest = ∓ + meshtastic_MeshPacket *reply = shim.allocReply(); + NodeInfoTestShim::currentRequest = nullptr; + + if (reply) { + packetPool.release(reply); + return true; + } + return false; +} + +// Step the injected clock the way the main loop does - advance, then publish the wrap carry. +static void advanceUptime(uint32_t deltaMs) +{ + Time::advanceTestMillis(deltaMs); + Time::serviceMonotonic(); +} + +void test_N8_second_request_inside_the_window_is_suppressed(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + Time::setTestMillis(60 * 1000); + Time::serviceMonotonic(); + + NodeInfoTestShim shim; + TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "first request must be answered"); + + advanceUptime(60 * 60 * 1000); // 1h later, well inside the 12h window + TEST_ASSERT_FALSE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "repeat request inside 12h must be suppressed"); +} + +void test_N9_request_after_the_window_is_answered(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + Time::setTestMillis(60 * 1000); + Time::serviceMonotonic(); + + NodeInfoTestShim shim; + TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE)); + + advanceUptime((kSuppressSecs + 60) * 1000); // 12h + a minute + TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "request after 12h must be answered"); +} + +// The regression. A stamp is only aliased by a counter that wraps underneath it, so the failure +// needs a *full* 2^32 ms of uptime to elapse, not merely a crossing of the boundary: with 32-bit +// millisecond stamps `now - stamp` then computes as 0 and the sender looks like it was answered +// this instant. Uptime seconds do not wrap for 136 years, so the entry reads as ~49.7 days old. +void test_N10_stale_stamp_does_not_alias_after_a_full_wrap(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + Time::setTestMillis(0x80000000u); // ~24.8 days of uptime + Time::serviceMonotonic(); + + NodeInfoTestShim shim; + TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE)); + + // A whole millis() cycle, in two serviced halves - one publish per window is the contract. + advanceUptime(0x80000000u); + advanceUptime(0x80000000u); + + TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), + "a stamp one full wrap old must read as ~49.7 days, not as this instant"); +} + +// Suppression must still behave normally either side of the boundary: still suppressing inside the +// window, and answering again once 12h have passed, with the stamp and the reading on opposite +// sides of the wrap. +void test_N11_window_still_applies_across_the_wrap(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + Time::setTestMillis(0xFFFF0000u); // just short of the wrap + Time::serviceMonotonic(); + + NodeInfoTestShim shim; + TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE)); + + advanceUptime(0x20000u); // ~131s later, and now past the wrap + TEST_ASSERT_FALSE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), + "the window must still bite when the stamp sits the other side of the wrap"); + + advanceUptime((kSuppressSecs + 60) * 1000); + TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), + "and must still release once 12h have passed across the wrap"); +} + void test_L1_licensed_nodeinfo_publishes_public_key(void) { owner.is_licensed = true; @@ -1976,6 +2148,8 @@ void setup() 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); 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); @@ -1984,6 +2158,10 @@ void setup() RUN_TEST(test_N5_unsigned_unicast_nodeinfo_from_signer_does_not_change_name); RUN_TEST(test_N6_signed_unicast_nodeinfo_from_signer_changes_name); RUN_TEST(test_N7_unsigned_unicast_nodeinfo_from_nonsigner_changes_name); + RUN_TEST(test_N8_second_request_inside_the_window_is_suppressed); + RUN_TEST(test_N9_request_after_the_window_is_answered); + RUN_TEST(test_N10_stale_stamp_does_not_alias_after_a_full_wrap); + RUN_TEST(test_N11_window_still_applies_across_the_wrap); printf("\n=== Group L: licensed identity and plaintext signing ===\n"); RUN_TEST(test_L1_licensed_nodeinfo_publishes_public_key); diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp index 5f55697522..fae50e87fc 100644 --- a/test/test_position_precision/test_main.cpp +++ b/test/test_position_precision/test_main.cpp @@ -1,11 +1,16 @@ #include "Channels.h" #include "GeoCoord.h" +#include "NodeDB.h" #include "PositionPrecision.h" +#include "Router.h" #include "TestUtil.h" #include "mesh-pb-constants.h" #include #include #include +#if ARCH_PORTDUINO +#include "platform/portduino/PortduinoGlue.h" +#endif static meshtastic_Position makePosition() { @@ -129,6 +134,8 @@ static void test_getPositionPrecisionForChannel_clampsPreciseOnDefaultKeyChannel channels.initDefaults(); // channel 0: primary, default key (psk {0x01}) -> publicly decryptable uint8_t idx = 0; meshtastic_Channel &ch = channels.getByIndex(idx); + ch.settings.psk.size = 1; + ch.settings.psk.bytes[0] = 0x01; ch.settings.has_module_settings = true; ch.settings.module_settings.position_precision = 32; // user requests "Precise" on a public channel @@ -236,6 +243,178 @@ static void test_geocoord_extreme_coords_no_oob() } } +static void configureEventChannels(bool eventAtIndexOne, bool inheritEventKeyOnSecondary) +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel eventChannel = makeChannel(meshtastic_Channel_Role_PRIMARY, true, 16); + meshtastic_Channel otherChannel = makeChannel(meshtastic_Channel_Role_SECONDARY, true, 16); + strncpy(eventChannel.settings.name, "everyone", sizeof(eventChannel.settings.name) - 1); +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t configuredEventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(configuredEventPsk); + memcpy(eventChannel.settings.psk.bytes, configuredEventPsk, sizeof(configuredEventPsk)); +#endif + if (!inheritEventKeyOnSecondary) { + otherChannel.settings.psk.size = 32; + memset(otherChannel.settings.psk.bytes, 0xAB, 32); + strncpy(otherChannel.settings.name, "private", sizeof(otherChannel.settings.name) - 1); + } + + eventChannel.index = eventAtIndexOne ? 1 : 0; + otherChannel.index = eventAtIndexOne ? 0 : 1; + channelFile.channels[eventChannel.index] = eventChannel; + channelFile.channels[otherChannel.index] = otherChannel; + channels.onConfigChanged(); +} + +static void test_getPositionPrecisionForChannel_eventChannelClampedToZero() +{ + // The event ("everyone") channel must never share location, even when the + // stored precision is non-zero. Under the block gate the clamp forces 0; + // otherwise the stored value is honored like any other channel. +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, false); + TEST_ASSERT_TRUE(channels.isEventChannel(0)); + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(0)); +#else + meshtastic_Channel channel = makeChannel(meshtastic_Channel_Role_PRIMARY, true, 16); + TEST_ASSERT_EQUAL_UINT32(16, getPositionPrecisionForChannel(channel)); +#endif +} + +static void test_eventChannelIdentity_usesEffectiveKeyAndSurvivesReorder() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, true); + TEST_ASSERT_TRUE(channels.isEventChannel(0)); + TEST_ASSERT_TRUE(channels.isEventChannel(1)); + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(1)); + + configureEventChannels(true, false); + TEST_ASSERT_FALSE(channels.isEventChannel(0)); + TEST_ASSERT_TRUE(channels.isEventChannel(1)); + TEST_ASSERT_EQUAL_UINT32(16, getPositionPrecisionForChannel(0)); + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(1)); +#else + TEST_ASSERT_FALSE(channels.isEventChannel(0)); +#endif +} + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum portnum, uint8_t channelIndex) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_default; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = portnum; + packet.channel = channelIndex; + return packet; +} + +static void test_eventCoordinatePolicy_coversPortsAndExcludesPki() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, false); + auto position = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, 0); + auto waypoint = makeDecodedPacket(meshtastic_PortNum_WAYPOINT_APP, 0); + auto mapReport = makeDecodedPacket(meshtastic_PortNum_MAP_REPORT_APP, 0); + auto text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, 0); + + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&position)); + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&waypoint)); + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&mapReport)); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&text)); + + waypoint.pki_encrypted = true; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&waypoint)); + + waypoint.pki_encrypted = false; + 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 + auto position = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, 0); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&position)); +#endif +} + +static void test_eventCoordinatePolicy_doesNotClassifyOpaquePacketsByHash() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, false); + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_default; + packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + packet.channel = channels.getHash(0); + packet.from = 0; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.pki_encrypted = true; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.channel = 0; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.pki_encrypted = false; + packet.channel = channels.getHash(0); + packet.from = 0x12345678; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.from = 0; + packet.channel = channels.getHash(1); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); +#else + TEST_ASSERT_TRUE(true); +#endif +} + +static void test_eventCoordinatePolicy_usesResolvedUnicastChannel() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + NodeDB *savedNodeDB = nodeDB; + nodeDB = new NodeDB(); + configureEventChannels(false, false); + meshtastic_NodeInfoLite *node = + nodeDB->getNumMeshNodes() > 1 ? nodeDB->getMeshNodeByIndex(1) : nodeDB->getOrCreateMeshNode(0x12345678); + TEST_ASSERT_NOT_NULL(node); + const NodeNum destination = node->num; + const uint8_t savedChannel = node->channel; + + auto position = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, 0); + position.to = destination; + node->channel = 1; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&position)); + + position.from = 0x87654321; + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&position)); + + position.from = 0; + node->channel = 0; + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&position)); + node->channel = savedChannel; + delete nodeDB; + nodeDB = savedNodeDB; +#else + TEST_ASSERT_TRUE(true); +#endif +} + +static void test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored() +{ + // A private channel with a full 32-byte key that is not the configured + // channel-0 PSK must be + // unaffected by the clamp on either side of the gate. + meshtastic_Channel channel = makeChannel(meshtastic_Channel_Role_PRIMARY, true, 16); + channel.settings.psk.size = 32; + memset(channel.settings.psk.bytes, 0xAB, 32); + + TEST_ASSERT_EQUAL_UINT32(16, getPositionPrecisionForChannel(channel)); +} + void setUp(void) {} void tearDown(void) {} @@ -262,6 +441,12 @@ void setup() RUN_TEST(test_cryptoKeyIsPublic_aes256KeyIsPrivate); RUN_TEST(test_cryptoKeyIsPublic_invalidKeyIsNotPublic); RUN_TEST(test_geocoord_extreme_coords_no_oob); + RUN_TEST(test_getPositionPrecisionForChannel_eventChannelClampedToZero); + RUN_TEST(test_eventChannelIdentity_usesEffectiveKeyAndSurvivesReorder); + RUN_TEST(test_eventCoordinatePolicy_coversPortsAndExcludesPki); + RUN_TEST(test_eventCoordinatePolicy_doesNotClassifyOpaquePacketsByHash); + RUN_TEST(test_eventCoordinatePolicy_usesResolvedUnicastChannel); + RUN_TEST(test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored); 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 5075fe461f..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 { @@ -526,19 +546,19 @@ static void test_want_config_includes_status_message_module_config(void) } /// Queue a packet as Router::dispatchReceived would have, before any time source existed. -static void queuePendingTimePlaceholderPacket(NodeNum from, uint32_t placeholderMillis) +static void queuePendingTimePlaceholderPacket(NodeNum from, uint32_t placeholderUptimeSecs) { meshtastic_MeshPacket pending = meshtastic_MeshPacket_init_zero; pending.which_payload_variant = meshtastic_MeshPacket_decoded_tag; pending.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; pending.from = from; pending.to = NODENUM_BROADCAST; - pending.rx_time = placeholderMillis; + pending.rx_time = placeholderUptimeSecs; // computeRxTimeStamp() stamps Time::getUptimeSecs() pending.has_rx_time = false; 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 @@ -574,6 +646,7 @@ class ScopedTimeFixture ScopedTimeFixture(uint32_t startMillis) : previous(nodeDB) { resetRTCStateForTests(); + Time::resetMonotonicForTests(); // uptime-seconds placeholders assume no carried wrap nodeDB = &instance; Time::setTestMillis(startMillis); } @@ -597,7 +670,7 @@ static void test_time_given_at_handshake_start_reconciles_queued_packet(void) ScopedTimeFixture timeFixture(5000); const NodeNum sender = 0x12345678; - queuePendingTimePlaceholderPacket(sender, 2000); // "received" 3s before the test's current millis() + queuePendingTimePlaceholderPacket(sender, 2); // "received" at uptime 2s, 3s before the fixture's 5000ms now PhoneAPITestShim api; startHandshake(api); @@ -624,7 +697,7 @@ static void test_time_given_at_handshake_end_does_not_rewrite_already_sent_packe ScopedTimeFixture timeFixture(5000); const NodeNum sender = 0x12345678; - queuePendingTimePlaceholderPacket(sender, 2000); + queuePendingTimePlaceholderPacket(sender, 2); PhoneAPITestShim api; startHandshake(api); @@ -650,10 +723,79 @@ static void test_time_given_at_handshake_end_does_not_rewrite_already_sent_packe api.close(); } +// The NodeDB half of the same transition: a node heard while the clock was untrusted gets no +// last_heard at all (the arrival instant waits in the RAM sidecar as uptime seconds), and the +// clock-valid hook backfills it to the real epoch of the sighting - so the phone reads +// "last heard: unknown" only until time arrives, never a boot-relative value. +static void test_node_heard_before_time_gets_last_heard_backfilled(void) +{ + ScopedMeshService scopedService; + ScopedTimeFixture timeFixture(5000); + + const NodeNum sender = 0x22334455; + meshtastic_MeshPacket heard = meshtastic_MeshPacket_init_zero; + heard.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + heard.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + heard.from = sender; + heard.to = NODENUM_BROADCAST; + heard.rx_time = 2; // uptime-seconds placeholder: "arrived at uptime 2s" + heard.has_rx_time = false; + nodeDB->updateFrom(heard); + + const meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(sender); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_EQUAL_UINT32(0u, info->last_heard); // absent, never a boot-relative stamp + + struct timeval networkTime; + networkTime.tv_sec = time(NULL) + SEC_PER_DAY; + networkTime.tv_usec = 0; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &networkTime)); + + // Heard at uptime 2s, clock arrived at uptime 5s: the sighting dates to nowEpoch - 3. + TEST_ASSERT_UINT32_WITHIN(2, (uint32_t)networkTime.tv_sec - 3, info->last_heard); +} + +// Uptime zero is a valid arrival instant during the first second of boot. It must not be confused +// with an absent sidecar record when network time arrives. +static void test_node_heard_during_first_uptime_second_gets_last_heard_backfilled(void) +{ + ScopedMeshService scopedService; + ScopedTimeFixture timeFixture(500); + + const NodeNum sender = 0x33445566; + TEST_ASSERT_NOT_NULL(nodeDB->getOrCreateMeshNode(sender)); + meshtastic_MeshPacket heard = meshtastic_MeshPacket_init_zero; + heard.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + heard.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + heard.from = sender; + heard.to = NODENUM_BROADCAST; + heard.rx_time = 0; // received during uptime second zero + heard.has_rx_time = false; + nodeDB->updateFrom(heard); + + const meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(sender); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_EQUAL_UINT32(0u, info->last_heard); + + struct timeval networkTime; + networkTime.tv_sec = time(NULL) + SEC_PER_DAY; + networkTime.tv_usec = 0; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &networkTime)); + + TEST_ASSERT_UINT32_WITHIN(1, (uint32_t)networkTime.tv_sec, info->last_heard); +} + /// 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() @@ -672,8 +814,11 @@ 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); + RUN_TEST(test_node_heard_during_first_uptime_second_gets_last_heard_backfilled); // usingProtobufs intentionally has no reset path, so this must run last. RUN_TEST(test_serial_console_suppresses_raw_output_in_protobuf_mode); exit(UNITY_END()); diff --git a/test/test_throttle/test_main.cpp b/test/test_throttle/test_main.cpp new file mode 100644 index 0000000000..e2630ba3dd --- /dev/null +++ b/test/test_throttle/test_main.cpp @@ -0,0 +1,237 @@ +// Unit tests for src/mesh/Throttle.{h,cpp} - the firmware's elapsed-time and deadline helpers. +// +// These drive the injected clock across the 32-bit millis() wrap, which is not otherwise reachable +// in a test, and which every caller of these helpers depends on being handled correctly. +#include "Arduino.h" +#include "TestUtil.h" +#include "UptimeClock.h" +#include "mesh/Throttle.h" +#include +#include + +void setUp(void) {} +void tearDown(void) +{ + Time::useRealClock(); // don't leak the fake clock into other suites +} + +// --- basic window semantics --- + +void test_isWithinTimespan_true_inside_window() +{ + Time::setTestMillis(10000); + TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(9500, 1000)); // 500ms elapsed of a 1000ms window +} + +void test_isWithinTimespan_false_outside_window() +{ + Time::setTestMillis(10000); + TEST_ASSERT_FALSE(Throttle::isWithinTimespanMs(8000, 1000)); // 2000ms elapsed +} + +// The boundary is exclusive: elapsed == interval is NOT "within". +void test_isWithinTimespan_boundary_is_exclusive() +{ + Time::setTestMillis(10000); + TEST_ASSERT_FALSE(Throttle::isWithinTimespanMs(9000, 1000)); // exactly 1000ms elapsed + TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(9001, 1000)); // 999ms elapsed +} + +// --- hasElapsed is the exact complement --- + +void test_hasElapsed_is_complement_of_isWithinTimespan() +{ + Time::setTestMillis(10000); + const uint32_t cases[][2] = {{9500, 1000}, {8000, 1000}, {9000, 1000}, {10000, 1}, {0, 5000}}; + for (auto &c : cases) { + TEST_ASSERT_EQUAL(!Throttle::isWithinTimespanMs(c[0], c[1]), Throttle::hasElapsed(c[0], c[1])); + } +} + +void test_hasElapsed_boundary_is_inclusive() +{ + Time::setTestMillis(10000); + TEST_ASSERT_TRUE(Throttle::hasElapsed(9000, 1000)); // exactly 1000ms elapsed + TEST_ASSERT_FALSE(Throttle::hasElapsed(9001, 1000)); // 999ms elapsed +} + +// --- rollover: the headline property --- + +// A window opened just before the 32-bit wrap must still close correctly after it. +void test_isWithinTimespan_survives_millis_wrap() +{ + const uint32_t lastRun = 0xFFFFFF00u; // 256ms before the wrap + Time::setTestMillis(lastRun); + + Time::advanceTestMillis(100); // 0xFFFFFF64 - still before the wrap + TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(lastRun, 1000)); + + Time::advanceTestMillis(200); // wraps to 0x0000002C - 300ms elapsed in total + TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(lastRun, 1000)); + TEST_ASSERT_FALSE(Throttle::hasElapsed(lastRun, 1000)); + + Time::advanceTestMillis(800); // 1100ms elapsed in total, well past the wrap + TEST_ASSERT_FALSE(Throttle::isWithinTimespanMs(lastRun, 1000)); + TEST_ASSERT_TRUE(Throttle::hasElapsed(lastRun, 1000)); +} + +// The long-interval end of the range: a 24h window (the longest in the tree) across the wrap. +void test_long_interval_survives_wrap() +{ + const uint32_t dayMs = 24u * 60u * 60u * 1000u; // 86,400,000 + const uint32_t lastRun = 0xFFFFFF00u; + Time::setTestMillis(lastRun); + + Time::advanceTestMillis(dayMs - 1); + TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(lastRun, dayMs)); + + Time::advanceTestMillis(1); // exactly one day elapsed + TEST_ASSERT_TRUE(Throttle::hasElapsed(lastRun, dayMs)); +} + +// --- deadlinePassed() --- + +void test_deadlinePassed_basic() +{ + Time::setTestMillis(10000); + TEST_ASSERT_FALSE(Throttle::deadlinePassed(10001)); // 1ms in the future + TEST_ASSERT_TRUE(Throttle::deadlinePassed(10000)); // exactly now counts as passed + TEST_ASSERT_TRUE(Throttle::deadlinePassed(9999)); // 1ms in the past +} + +// The property the naive `millis() > deadline` compare fails: a deadline set before the wrap must +// fire once, and only once, after the wrap. +void test_deadlinePassed_survives_millis_wrap() +{ + Time::setTestMillis(0xFFFFFF00u); // 256ms before the wrap + const uint32_t deadline = 0xFFFFFF00u + 500; + + TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline)); // not yet + Time::advanceTestMillis(400); // 0x00000090 - wrapped, still not due + TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline)); + Time::advanceTestMillis(100); // exactly due, past the wrap + TEST_ASSERT_TRUE(Throttle::deadlinePassed(deadline)); + Time::advanceTestMillis(60000); // stays passed + TEST_ASSERT_TRUE(Throttle::deadlinePassed(deadline)); +} + +// The naive compare's actual failure mode, pinned so a regression is unmistakable: before the wrap +// the deadline is numerically smaller than now, so `millis() > deadline` would fire it early. +void test_deadlinePassed_does_not_fire_early_when_deadline_wraps() +{ + Time::setTestMillis(0xFFFFFF00u); + const uint32_t deadline = 0xFFFFFF00u + 1000; // wraps to 0x000002E8 + + TEST_ASSERT_TRUE(deadline < Time::getMillis()); // the naive compare would fire here + TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline)); +} + +// deadlinePassedAt() judges against a caller-supplied now, so a loop that snapshots the clock once +// gets one instant for every entry - including across the wrap, where the clock has moved on. +void test_deadlinePassedAt_uses_the_supplied_now() +{ + Time::setTestMillis(0xFFFFFF00u); + const uint32_t now = Time::getMillis(); + const uint32_t deadline = 0xFFFFFF00u + 500; // wraps to 0x000000F4 + + TEST_ASSERT_FALSE(Throttle::deadlinePassedAt(now, deadline)); + TEST_ASSERT_TRUE(Throttle::deadlinePassedAt(deadline, deadline)); // inclusive boundary + TEST_ASSERT_TRUE(Throttle::deadlinePassedAt(deadline + 1, deadline)); // past the wrap + Time::advanceTestMillis(60000); // clock moved, snapshot did not + TEST_ASSERT_FALSE(Throttle::deadlinePassedAt(now, deadline)); + TEST_ASSERT_TRUE(Throttle::deadlinePassed(deadline)); +} + +// deadlinePassed() cannot know about sentinels, so it reports them as passed. This pins that +// contract, since callers relying on it must test armed-ness first. +void test_deadlinePassed_reads_disarmed_sentinels_as_passed() +{ + Time::setTestMillis(6247); + + TEST_ASSERT_TRUE(Throttle::deadlinePassed(0)); // "inactive" for rebootAtMsec et al + TEST_ASSERT_TRUE(Throttle::deadlinePassed(UINT32_MAX)); // "inactive" for nagCycleCutoff + + // The guarded form every caller must use. + const uint32_t disarmed = 0; + TEST_ASSERT_FALSE(disarmed && Throttle::deadlinePassed(disarmed)); + + // And it still holds after a wrap. + Time::setTestMillis(0xFFFFFF00u); + Time::advanceTestMillis(1000); + TEST_ASSERT_FALSE(disarmed && Throttle::deadlinePassed(disarmed)); +} + +// --- execute() --- + +static int executeCount = 0; +static int deferCount = 0; +static void countExecute() +{ + executeCount++; +} +static void countDefer() +{ + deferCount++; +} + +void test_execute_runs_first_time_then_throttles() +{ + executeCount = 0; + deferCount = 0; + Time::setTestMillis(5000); + + uint32_t last = 0; // 0 means "never run" to execute() + TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute, countDefer)); + TEST_ASSERT_EQUAL(1, executeCount); + + // Immediately again: deferred. + TEST_ASSERT_FALSE(Throttle::execute(&last, 1000, countExecute, countDefer)); + TEST_ASSERT_EQUAL(1, executeCount); + TEST_ASSERT_EQUAL(1, deferCount); + + // After the interval: runs again. + Time::advanceTestMillis(1000); + TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute, countDefer)); + TEST_ASSERT_EQUAL(2, executeCount); +} + +void test_execute_survives_millis_wrap() +{ + executeCount = 0; + Time::setTestMillis(0xFFFFFF00u); + + uint32_t last = 0; + TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute)); // arms at 0xFFFFFF00 + TEST_ASSERT_EQUAL(1, executeCount); + + Time::advanceTestMillis(500); // wraps past 0 + TEST_ASSERT_FALSE(Throttle::execute(&last, 1000, countExecute)); // not due yet + TEST_ASSERT_EQUAL(1, executeCount); + + Time::advanceTestMillis(600); // 1100ms total + TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute)); + TEST_ASSERT_EQUAL(2, executeCount); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_isWithinTimespan_true_inside_window); + RUN_TEST(test_isWithinTimespan_false_outside_window); + RUN_TEST(test_isWithinTimespan_boundary_is_exclusive); + RUN_TEST(test_hasElapsed_is_complement_of_isWithinTimespan); + RUN_TEST(test_hasElapsed_boundary_is_inclusive); + RUN_TEST(test_isWithinTimespan_survives_millis_wrap); + RUN_TEST(test_long_interval_survives_wrap); + RUN_TEST(test_deadlinePassed_basic); + RUN_TEST(test_deadlinePassed_survives_millis_wrap); + RUN_TEST(test_deadlinePassed_does_not_fire_early_when_deadline_wraps); + RUN_TEST(test_deadlinePassedAt_uses_the_supplied_now); + RUN_TEST(test_deadlinePassed_reads_disarmed_sentinels_as_passed); + RUN_TEST(test_execute_runs_first_time_then_throttles); + RUN_TEST(test_execute_survives_millis_wrap); + exit(UNITY_END()); +} + +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..cfe06e7f52 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 { @@ -2307,7 +2309,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_uptime_clock/test_main.cpp b/test/test_uptime_clock/test_main.cpp new file mode 100644 index 0000000000..f950102c24 --- /dev/null +++ b/test/test_uptime_clock/test_main.cpp @@ -0,0 +1,356 @@ +// Unit tests for src/UptimeClock.{h,cpp} - the monotonic uptime seam. +// Covers: test-clock injection, stepping the injected clock, the real-clock fallback, and the +// single-writer wrap carry (readers derive, serviceMonotonic() publishes). getMillis() itself is a +// plain 32-bit read with no wrap handling of its own - its consumers' wrap arithmetic is tested in +// test_throttle/. +#include "Arduino.h" +#include "TestUtil.h" +#include "UptimeClock.h" +#include "gps/RTC.h" +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +std::atomic publishPaused{false}; +std::atomic releasePublish{false}; + +void pauseMonotonicPublish() +{ + publishPaused.store(true, std::memory_order_release); + while (!releasePublish.load(std::memory_order_acquire)) + std::this_thread::yield(); +} +} // namespace + +void setUp(void) +{ + Time::resetMonotonicForTests(); // absolute uptime assertions must not depend on case order +} +void tearDown(void) +{ + Time::useRealClock(); // don't leak the fake clock into other suites + resetRTCStateForTests(); +} + +// Step the injected clock the way the firmware does: the main loop calls serviceMonotonic() every +// iteration, so any advance is followed by a publish. +static void advanceAndService(uint32_t deltaMs) +{ + Time::advanceTestMillis(deltaMs); + Time::serviceMonotonic(); +} + +// --- injection --- + +void test_getMillis_returns_injected_value() +{ + Time::setTestMillis(123456); + TEST_ASSERT_EQUAL_UINT32(123456, Time::getMillis()); +} + +void test_advanceTestMillis_steps_clock() +{ + Time::setTestMillis(1000); + Time::advanceTestMillis(500); + TEST_ASSERT_EQUAL_UINT32(1500, Time::getMillis()); +} + +// Advancing past 0xFFFFFFFF wraps like millis() does, rather than saturating. This is the property +// the Throttle wrap tests are built on, so it is worth pinning here too. +void test_advanceTestMillis_wraps_like_millis() +{ + Time::setTestMillis(0xFFFFFF00u); + Time::advanceTestMillis(0x200u); + TEST_ASSERT_EQUAL_UINT32(0x00000100u, Time::getMillis()); +} + +// --- getMillisMonotonic(): the published wrap carry --- + +void test_monotonic_matches_millis_before_any_wrap() +{ + Time::setTestMillis(123456); + TEST_ASSERT_EQUAL_UINT64(123456u, Time::getMillisMonotonic()); +} + +void test_monotonic_counts_a_wrap() +{ + Time::setTestMillis(0xFFFFFF00u); + Time::serviceMonotonic(); + TEST_ASSERT_EQUAL_UINT64(0xFFFFFF00u, Time::getMillisMonotonic()); + + advanceAndService(0x200u); // crosses the 32-bit wrap; low word is now 0x00000100 + TEST_ASSERT_EQUAL_UINT64(0x100000100ull, Time::getMillisMonotonic()); +} + +// The property that lets readers stay pure: a reader adds its own unsigned elapsed time to the +// published snapshot, so it is exact across a wrap that no publish has observed yet. Nothing here +// needs to detect the boundary, which is why concurrent readers cannot double-count it. +void test_monotonic_reader_crosses_the_wrap_without_a_publish() +{ + Time::setTestMillis(0xFFFFFF00u); + Time::serviceMonotonic(); // last publish before the wrap + + Time::advanceTestMillis(0x200u); // cross the wrap with no publish at all + TEST_ASSERT_EQUAL_UINT64(0x100000100ull, Time::getMillisMonotonic()); +} + +// Reads must not advance the carry. Under the old read-modify-write accessor each reader bumped +// the wrap counter itself, which is what made two of them able to count one wrap twice. +void test_monotonic_reads_do_not_advance_the_carry() +{ + Time::setTestMillis(0xFFFFFF00u); + Time::serviceMonotonic(); + + Time::advanceTestMillis(0x200u); + for (int i = 0; i < 8; i++) + TEST_ASSERT_EQUAL_UINT64(0x100000100ull, Time::getMillisMonotonic()); + + Time::serviceMonotonic(); // the eight reads must not have left eight wraps behind + TEST_ASSERT_EQUAL_UINT64(0x100000100ull, Time::getMillisMonotonic()); +} + +void test_monotonic_counts_every_wrap_when_serviced_each_window() +{ + Time::setTestMillis(0x80000000u); + Time::serviceMonotonic(); + TEST_ASSERT_EQUAL_UINT64(0x80000000ull, Time::getMillisMonotonic()); + + // Three full 2^32 cycles, published once per half-cycle - well inside the required + // one-publish-per-49.7-days window. + for (int wrap = 1; wrap <= 3; wrap++) { + advanceAndService(0x80000000u); // crosses the wrap; low word back to 0 + advanceAndService(0x80000000u); // completes the cycle; low word back to 0x80000000 + TEST_ASSERT_EQUAL_UINT64(0x80000000ull + ((uint64_t)wrap << 32), Time::getMillisMonotonic()); + } +} + +// The documented contract, pinned: a full 2^32 ms elapsing between two publishes is +// indistinguishable from no time passing, so the wrap is lost. This is why the main loop's +// per-iteration serviceMonotonic() matters - and it is now the only obligation, where before every +// reader had to participate. +void test_monotonic_misses_a_wrap_not_serviced_within_the_window() +{ + Time::setTestMillis(1000); + Time::serviceMonotonic(); + TEST_ASSERT_EQUAL_UINT64(1000u, Time::getMillisMonotonic()); + + Time::advanceTestMillis(0x80000000u); + advanceAndService(0x80000000u); // full cycle with no publish in between: low word is 1000 again + + TEST_ASSERT_EQUAL_UINT64(1000u, Time::getMillisMonotonic()); // the elapsed 2^32 ms is lost +} + +void test_getUptimeSecs_stays_exact_across_the_wrap() +{ + Time::setTestMillis(4294967000u); // 4294967 whole seconds, 296ms short of the wrap + Time::serviceMonotonic(); + TEST_ASSERT_EQUAL_UINT32(4294967u, Time::getUptimeSecs()); + + advanceAndService(1000); // crosses the wrap + TEST_ASSERT_EQUAL_UINT32(4294968u, Time::getUptimeSecs()); +} + +// --- concurrent readers --- + +// Readers run flat out while the clock is stepped across several wraps. Under the old accessor two +// readers interleaving inside the wrap window could each bump the counter, jumping every later +// reading 2^32 ms forward; here they only ever read, so the final value has to be exact. +// +// A one-instruction race is not something a test can hit on demand, so this is corroboration +// rather than the guarantee - the guarantee is structural, and test_monotonic_reads_do_not_advance +// _the_carry pins it. What this case does catch is any future change that puts a write back on the +// read path. +void test_monotonic_exact_with_concurrent_readers() +{ + constexpr int kReaders = 4; + constexpr int kWraps = 3; + constexpr uint32_t kStep = 0x40000000u; // quarter of a cycle, so each wrap is crossed mid-step + + Time::setTestMillis(0xFFFFF000u); + Time::serviceMonotonic(); + + std::atomic stop{false}; + std::atomic wentBackwards{false}; + std::vector readers; + for (int i = 0; i < kReaders; i++) { + readers.emplace_back([&stop, &wentBackwards]() { + uint64_t previous = 0; + while (!stop.load(std::memory_order_relaxed)) { + const uint64_t now = Time::getMillisMonotonic(); + if (now < previous) + wentBackwards.store(true, std::memory_order_relaxed); + previous = now; + } + }); + } + + uint64_t expected = 0xFFFFF000ull; + for (int i = 0; i < kWraps * 4; i++) { + advanceAndService(kStep); + expected += kStep; + } + + stop.store(true, std::memory_order_relaxed); + for (auto &reader : readers) + reader.join(); + + TEST_ASSERT_FALSE_MESSAGE(wentBackwards.load(std::memory_order_relaxed), "monotonic clock retreated for a reader"); + TEST_ASSERT_EQUAL_UINT64(expected, Time::getMillisMonotonic()); +} + +// nRF BLE callbacks run above the main loop. A reader that preempts publication must be able to +// consume the previous complete snapshot without waiting for the suspended writer. +void test_monotonic_reader_completes_while_publish_is_paused() +{ + Time::setTestMillis(100); + Time::serviceMonotonic(); + Time::advanceTestMillis(1); + + publishPaused.store(false, std::memory_order_relaxed); + releasePublish.store(false, std::memory_order_relaxed); + Time::setMonotonicPublishHookForTests(pauseMonotonicPublish); + + std::thread writer([]() { Time::serviceMonotonic(); }); + while (!publishPaused.load(std::memory_order_acquire)) + std::this_thread::yield(); + + std::atomic readerStarted{false}; + std::atomic readerDone{false}; + uint64_t readerValue = 0; + std::thread reader([&readerStarted, &readerDone, &readerValue]() { + readerStarted.store(true, std::memory_order_release); + readerValue = Time::getMillisMonotonic(); + readerDone.store(true, std::memory_order_release); + }); + while (!readerStarted.load(std::memory_order_acquire)) + std::this_thread::yield(); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(100); + while (!readerDone.load(std::memory_order_acquire) && std::chrono::steady_clock::now() < deadline) + std::this_thread::yield(); + const bool completedWhilePaused = readerDone.load(std::memory_order_acquire); + + releasePublish.store(true, std::memory_order_release); + writer.join(); + reader.join(); + Time::setMonotonicPublishHookForTests(nullptr); + + TEST_ASSERT_TRUE_MESSAGE(completedWhilePaused, "reader waited for a lower-priority publisher"); + TEST_ASSERT_EQUAL_UINT64(101u, readerValue); +} + +// --- getTime(): the wall clock must not retreat at the millis() wrap --- + +// Epoch used by the wall-clock cases; must sit between BUILD_EPOCH (stamped at build time) and +// BUILD_EPOCH + 40 years or perhapsSetRTC() rejects it as implausible - so derive it. +#ifdef BUILD_EPOCH +static constexpr uint32_t kTestEpoch = (uint32_t)BUILD_EPOCH + 3600; +#else +static constexpr uint32_t kTestEpoch = 1800000000u; +#endif + +void test_getTime_stays_exact_across_the_wrap() +{ + resetRTCStateForTests(); + Time::setTestMillis(0xFFFFFF00u); // 256ms short of the wrap + Time::serviceMonotonic(); + + struct timeval tv = {}; + tv.tv_sec = kTestEpoch; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + TEST_ASSERT_EQUAL_UINT32(kTestEpoch, getTime(false)); + + advanceAndService(400u * 1000u); // crosses the wrap partway through + // With a 32-bit anchor this read came back 49.7 days in the past. + TEST_ASSERT_EQUAL_UINT32(kTestEpoch + 400, getTime(false)); +} + +// The anchor must also be correct when the time-set itself happens after a counted wrap, i.e. +// when the monotonic clock is already past 32-bit range. +void test_getTime_anchored_after_a_wrap_is_exact() +{ + resetRTCStateForTests(); + Time::setTestMillis(0xFFFFFF00u); + Time::serviceMonotonic(); // latch the pre-wrap value + advanceAndService(0x200u); // cross the wrap; monotonic is now > 2^32 + + struct timeval tv = {}; + tv.tv_sec = kTestEpoch; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + + advanceAndService(100u * 1000u); + TEST_ASSERT_EQUAL_UINT32(kTestEpoch + 100, getTime(false)); +} + +// A reader on another thread must not be able to perturb the wall clock. This is the user-visible +// shape of the race: getTime() is reached from the nRF52 BLE task and the portduino web server +// threads, and a double-counted wrap put every rx_time and last_heard ~49.7 days in the future. +void test_getTime_unaffected_by_concurrent_readers_across_the_wrap() +{ + resetRTCStateForTests(); + Time::setTestMillis(0xFFFFF800u); // exactly 0x800 short of the wrap, so the first advance lands on it + Time::serviceMonotonic(); + + struct timeval tv = {}; + tv.tv_sec = kTestEpoch; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + + std::atomic stop{false}; + std::vector readers; + for (int i = 0; i < 4; i++) { + readers.emplace_back([&stop]() { + while (!stop.load(std::memory_order_relaxed)) + (void)getTime(false); // what the BLE / web-server threads actually call + }); + } + + advanceAndService(0x800u); // cross the wrap while the readers are running + advanceAndService(60u * 1000u); // and some ordinary time after it + + stop.store(true, std::memory_order_relaxed); + for (auto &reader : readers) + reader.join(); + + TEST_ASSERT_EQUAL_UINT32(kTestEpoch + 62, getTime(false)); // 0x800ms + 60s, rounded down +} + +// --- real clock fallback --- + +void test_real_clock_advances_when_not_injected() +{ + Time::useRealClock(); + uint32_t t0 = Time::getMillis(); + testDelay(5); + uint32_t t1 = Time::getMillis(); + TEST_ASSERT_TRUE(t1 >= t0); // real millis() is monotonic over a short delay +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_getMillis_returns_injected_value); + RUN_TEST(test_advanceTestMillis_steps_clock); + RUN_TEST(test_advanceTestMillis_wraps_like_millis); + RUN_TEST(test_monotonic_matches_millis_before_any_wrap); + RUN_TEST(test_monotonic_counts_a_wrap); + RUN_TEST(test_monotonic_reader_crosses_the_wrap_without_a_publish); + RUN_TEST(test_monotonic_reads_do_not_advance_the_carry); + RUN_TEST(test_monotonic_counts_every_wrap_when_serviced_each_window); + RUN_TEST(test_monotonic_misses_a_wrap_not_serviced_within_the_window); + RUN_TEST(test_getUptimeSecs_stays_exact_across_the_wrap); + RUN_TEST(test_monotonic_exact_with_concurrent_readers); + RUN_TEST(test_monotonic_reader_completes_while_publish_is_paused); + RUN_TEST(test_getTime_stays_exact_across_the_wrap); + RUN_TEST(test_getTime_anchored_after_a_wrap_is_exact); + RUN_TEST(test_getTime_unaffected_by_concurrent_readers_across_the_wrap); + RUN_TEST(test_real_clock_advances_when_not_injected); + exit(UNITY_END()); +} + +void loop() {} diff --git a/userPrefs.jsonc b/userPrefs.jsonc index fc18c00978..eb9ff3faf9 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -25,6 +25,7 @@ // "USERPREFS_CONFIG_DEVICE_ROLE": "meshtastic_Config_DeviceConfig_Role_CLIENT", // Defaults to CLIENT. ROUTER*, and LOST AND FOUND roles are restricted. // "USERPREFS_EVENT_MODE": "1", // "USERPREFS_EVENT_MODE_HOP_LIMIT": "3", // Event-mode default and firmware-generated/relay hop cap (0-7; default 3) + // "USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL": "1", // Block location TX + discard inbound location on channels keyed with USERPREFS_CHANNEL_0_PSK. Defaults off, and must be set explicitly. // "USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS": "1", // Extend TMM position dedup and precision clamping to private/custom-key channels (default: well-known channels only) // "USERPREFS_FIRMWARE_EDITION": "meshtastic_FirmwareEdition_BURNING_MAN", // "USERPREFS_FIXED_BLUETOOTH": "121212", diff --git a/variants/esp32/chatter2/variant.h b/variants/esp32/chatter2/variant.h index d13db08c6a..0dc5cfaa95 100644 --- a/variants/esp32/chatter2/variant.h +++ b/variants/esp32/chatter2/variant.h @@ -5,7 +5,7 @@ ////////////////////////////////////////////////////////////////////////////////// // Debugging -// #define GPS_DEBUG +// #define GPS_DEBUG 1 // Lora #define USE_LLCC68 // Original Chatter2 with LLCC68 module 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/esp32/tbeam/variant.h b/variants/esp32/tbeam/variant.h index 1bab8c3c3d..3d13f9cbdb 100644 --- a/variants/esp32/tbeam/variant.h +++ b/variants/esp32/tbeam/variant.h @@ -43,7 +43,7 @@ #define GPS_UBLOX #define GPS_RX_PIN 34 #define GPS_TX_PIN 12 -// #define GPS_DEBUG +// #define GPS_DEBUG 1 // Used when the display shield is chosen #ifdef USE_ST7796 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-G3/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini index f3d5e0f3b8..92236f638d 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini @@ -28,4 +28,4 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 - https://github.com/meshtastic/ESP32-CH390/archive/refs/tags/v1.1.0.zip + https://github.com/meshtastic/ESP32-CH390/archive/v1.1.1.zip diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini index bfa69ab6c0..c1a872f89e 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini @@ -28,4 +28,4 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 - https://github.com/meshtastic/ESP32-CH390/archive/refs/tags/v1.1.0.zip + https://github.com/meshtastic/ESP32-CH390/archive/v1.1.1.zip 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/t-beam-bpf/variant.h b/variants/esp32s3/t-beam-bpf/variant.h index d4f316ae85..eaf5012d23 100644 --- a/variants/esp32s3/t-beam-bpf/variant.h +++ b/variants/esp32s3/t-beam-bpf/variant.h @@ -67,4 +67,5 @@ // PMU #define HAS_AXP2101 -// #define PMU_IRQ 4 // Leave disabled for now +#define PMU_IRQ 4 +#define PMU_POWER_BUTTON_IS_CANCEL // maps a short click of the power button to a cancel action (turning off the screen) diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index be0be7d023..047371db9e 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -69,7 +69,7 @@ build_flags = -D RADIOLIB_DEBUG_SPI=0 -D RADIOLIB_DEBUG_PROTOCOL=0 -D RADIOLIB_SPI_PARANOID=0 -; -D CALIBRATE_TOUCH=0 + -D CALIBRATE_TOUCH=0 -D LGFX_SCREEN_WIDTH=240 -D LGFX_SCREEN_HEIGHT=320 -D LGFX_BUFSIZE=153600 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 feaff1c80e..37d5bf2a08 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -137,6 +137,18 @@ test_testing_command = ${platformio.build_dir}/${this.__env__}/meshtasticd -s +[env:coverage-event-policy] +extends = env:coverage +build_flags = ${env:coverage.build_flags} + -DUSERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL=1 + -DUSERPREFS_CHANNEL_0_PSK='{0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f}' +test_filter = + test_position_precision + test_event_channel_router + test_nexthop_routing + test_event_channel_phone_api + test_mqtt + ; --------------------------------------------------------------------------- ; Native build for macOS (Darwin / arm64 + x86_64). Headless meshtasticd that ; runs in SimRadio mode (`-s`) or against real LoRa hardware via a CH341 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/variant.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h index 323873660b..5a6f0074e5 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h @@ -135,7 +135,7 @@ https://github.com/brad112358/easy_E22 #endif #define GPS_UBLOX -// define GPS_DEBUG +// #define GPS_DEBUG 1 // UART interfaces #define PIN_SERIAL1_TX GPS_TX_PIN diff --git a/variants/nrf52840/dls_Minimesh_Lite/variant.h b/variants/nrf52840/dls_Minimesh_Lite/variant.h index 32c16f06df..47c6727dd5 100644 --- a/variants/nrf52840/dls_Minimesh_Lite/variant.h +++ b/variants/nrf52840/dls_Minimesh_Lite/variant.h @@ -57,7 +57,7 @@ extern "C" { #define PIN_GPS_EN (0 + 24) #define GPS_UBLOX -// define GPS_DEBUG +// #define GPS_DEBUG 1 // UART interfaces #define PIN_SERIAL1_TX GPS_TX_PIN 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/rak4631/platformio.ini b/variants/nrf52840/rak4631/platformio.ini index 69eed83ea3..70095f334a 100644 --- a/variants/nrf52840/rak4631/platformio.ini +++ b/variants/nrf52840/rak4631/platformio.ini @@ -23,19 +23,14 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} \ +<../variants/nrf52840/rak4631> \ - + \ - + \ + \ - \ - \ - -lib_deps = +lib_deps = ${nrf52840_base.lib_deps} - ${networking_base.lib_deps} # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 - # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 # renovate: datasource=custom.pio depName=RAK12035_SoilMoisture packageName=beegee-tokyo/library/RAK12035_SoilMoisture @@ -68,9 +63,9 @@ build_flags = -DLOW_VDD_SYSTEMOFF_DELAY_MS=5000 -DSAFE_VDD_VOLTAGE_THRESHOLD_MV=2900 -DSAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV=100 -build_src_filter = ${env:rak4631.build_src_filter} - - - - +; env:rak4631 no longer pulls in mesh/eth or mesh/api, so the negations that used to live +; here are redundant -- this env now inherits an Ethernet-free src filter with MQTT retained, +; unchanged. ; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm) ; Note: as of 6/2013 the serial/bootloader based programming takes approximately 30 seconds diff --git a/variants/nrf52840/rak4631/variant.h b/variants/nrf52840/rak4631/variant.h index 9c3e10d6e7..105b3af5cc 100644 --- a/variants/nrf52840/rak4631/variant.h +++ b/variants/nrf52840/rak4631/variant.h @@ -290,11 +290,8 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG // VDD=3.3V AIN3=6/8*VDD=2.47V VBAT=1.66*AIN3=4.1V #define BATTERY_LPCOMP_THRESHOLD NRF_LPCOMP_REF_SUPPLY_11_16 -#if defined(WISMESH_POCKET) +// General-purpose RAK4631 builds disable Ethernet; use env:rak4631_eth_gw for RAK13800 W5100S. #define HAS_ETHERNET 0 -#else -#define HAS_ETHERNET 1 -#endif #define RAK_4631 1 diff --git a/variants/nrf52840/rak4631_eth_gw/platformio.ini b/variants/nrf52840/rak4631_eth_gw/platformio.ini index 7bad13a88f..8c4ece2c44 100644 --- a/variants/nrf52840/rak4631_eth_gw/platformio.ini +++ b/variants/nrf52840/rak4631_eth_gw/platformio.ini @@ -1,5 +1,14 @@ ; The very slick RAK wireless RAK 4631 / 4630 board - Unified firmware for 5005/19003, with or without OLED RAK 1921 [env:rak4631_eth_gw] +custom_meshtastic_hw_model = 9 +custom_meshtastic_hw_model_slug = RAK4631 +custom_meshtastic_architecture = nrf52840 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = RAK WisBlock 4631 +custom_meshtastic_images = rak4631.svg, rak4631_case.svg +custom_meshtastic_tags = RAK + extends = nrf52840_base board_level = release board = wiscore_rak4631 @@ -40,8 +49,10 @@ lib_deps = ; Allows programming and debug via the RAK NanoDAP as the default debugger tool for the RAK4631 (it is only $10!) ; programming time is about the same as the bootloader version. ; For information on this see the meshtastic developers documentation for "Development on the NRF52" +; Extends env:rak4631_eth_gw, not env:rak4631: it already takes that env's build_flags and +; lib_deps, and only the gateway env still compiles mesh/eth + mesh/api. [env:rak4631_eth_gw_dbg] -extends = env:rak4631 +extends = env:rak4631_eth_gw board_level = extra ; if the builtin version of openocd has a buggy version of semihosting, so use the external version diff --git a/variants/nrf52840/seeed_wio_tracker_L1/variant.h b/variants/nrf52840/seeed_wio_tracker_L1/variant.h index 9e1df0fa34..aeaa8f44af 100644 --- a/variants/nrf52840/seeed_wio_tracker_L1/variant.h +++ b/variants/nrf52840/seeed_wio_tracker_L1/variant.h @@ -129,7 +129,7 @@ static const uint8_t SCL = PIN_WIRE_SCL; #define PIN_GPS_STANDBY D0 -// #define GPS_DEBUG +// #define GPS_DEBUG 1 // #define GPS_EN D18 // P1.05 #endif diff --git a/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h b/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h index 1ff18ec2fa..9dd92a5f5d 100644 --- a/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h +++ b/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h @@ -137,7 +137,7 @@ static const uint8_t SCL = PIN_WIRE_SCL; #define PIN_GPS_STANDBY D0 -// #define GPS_DEBUG +// #define GPS_DEBUG 1 // #define GPS_EN D18 // P1.05 #endif diff --git a/variants/nrf52840/t-echo-lite/variant.h b/variants/nrf52840/t-echo-lite/variant.h index 54c7bdfb51..fe2c3076c8 100644 --- a/variants/nrf52840/t-echo-lite/variant.h +++ b/variants/nrf52840/t-echo-lite/variant.h @@ -131,7 +131,7 @@ static const uint8_t A0 = PIN_A0; #define PIN_SPI1_SCK PIN_EINK_SCLK // GPS pins -// #define GPS_DEBUG +// #define GPS_DEBUG 1 #define GPS_L76K #define GPS_BAUDRATE 9600 #define HAS_GPS 1 diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index 31adaee102..45e997271e 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -1,5 +1,18 @@ [nrf54l15_base] platform = https://github.com/Seeed-Studio/platform-seeedboards.git +; Pin the Zephyr package explicitly. Seeed's platform script only maps their +; own "seeed-xiao-*" board ids to a framework-zephyr package; any other board +; -- nrf54l15dk included -- falls back to whatever platform.json declares as +; the default, which is now framework-zephyr-nrf54lm20 (Zephyr 4.4.0). Its +; west manifest pulls a CMSIS_6 whose cmsis_gcc.h calls the ACLE builtins +; __sxtb16/__sxtab16, and none of the GCC ARM toolchains PlatformIO ships +; (8.2.1/9.2.1/9.3.1) declare them in arm_acle.h. In C that is only an +; implicit-declaration warning, so the pure-C Zephyr core never notices; in +; C++ it is a hard error, and any .cpp pulling in zephyr/kernel.h hits it. +; Without the pin a fresh package cache breaks this build with nothing in the +; tree having changed. +platform_packages = + platformio/framework-zephyr-nrf54lm20@https://dl.registry.platformio.org/download/platformio/tool/framework-zephyr/3.40201.251021/framework-zephyr-3.40201.251021.tar.gz framework = zephyr extends = arduino_base diff --git a/variants/rp2350/rp2350.ini b/variants/rp2350/rp2350.ini index 3cff5534d6..0705ed8eca 100644 --- a/variants/rp2350/rp2350.ini +++ b/variants/rp2350/rp2350.ini @@ -7,7 +7,7 @@ platform = extends = arduino_base platform_packages = # TODO renovate - arduino-pico@https://github.com/earlephilhower/arduino-pico/releases/download/5.7.0/rp2040-5.7.0.zip + arduino-pico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip board_build.core = earlephilhower board_build.filesystem_size = 0.5m 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