Merge branch 'meshtastic:develop' into develop

This commit is contained in:
吴文峰
2026-07-27 14:48:10 +08:00
committed by GitHub
co-authored by GitHub
234 changed files with 10267 additions and 2070 deletions
+2 -5
View File
@@ -4,19 +4,16 @@
# As this is not a long running service, health-checks are not required. ClusterFuzzLite # As this is not a long running service, health-checks are not required. ClusterFuzzLite
# also only works if the user remains unchanged from the base image (it expects to run # also only works if the user remains unchanged from the base image (it expects to run
# as root). # as root).
# trunk-ignore-all(trivy/DS026): No healthcheck is needed for this builder container # trunk-ignore-all(trivy/DS-0026): No healthcheck is needed for this builder container
# trunk-ignore-all(checkov/CKV_DOCKER_2): No healthcheck is needed for this builder container # trunk-ignore-all(checkov/CKV_DOCKER_2): No healthcheck is needed for this builder container
# trunk-ignore-all(checkov/CKV_DOCKER_3): We must run as root for this container # trunk-ignore-all(checkov/CKV_DOCKER_3): We must run as root for this container
# trunk-ignore-all(trivy/DS002): We must run as root for this container # trunk-ignore-all(trivy/DS-0002): We must run as root for this container
# trunk-ignore-all(checkov/CKV_DOCKER_8): We must run as root for this container
# trunk-ignore-all(hadolint/DL3002): We must run as root for this container
FROM gcr.io/oss-fuzz-base/base-builder:v1 FROM gcr.io/oss-fuzz-base/base-builder:v1
ENV PIP_ROOT_USER_ACTION=ignore ENV PIP_ROOT_USER_ACTION=ignore
# trunk-ignore(hadolint/DL3008): apt packages are not pinned. # trunk-ignore(hadolint/DL3008): apt packages are not pinned.
# trunk-ignore(terrascan/AC_DOCKER_0002): apt packages are not pinned.
RUN apt-get update && apt-get install --no-install-recommends -y \ RUN apt-get update && apt-get install --no-install-recommends -y \
cmake git zip libgpiod-dev libjsoncpp-dev libbluetooth-dev libi2c-dev \ cmake git zip libgpiod-dev libjsoncpp-dev libbluetooth-dev libi2c-dev \
libunistring-dev libmicrohttpd-dev libgnutls28-dev libgcrypt20-dev \ libunistring-dev libmicrohttpd-dev libgnutls28-dev libgcrypt20-dev \
+2 -2
View File
@@ -13,7 +13,7 @@ reviews:
drafts: false drafts: false
# Review PRs regardless of target branch. # Review PRs regardless of target branch.
base_branches: base_branches:
- ".*" - .*
# Skip Renovate dependency updates - CI gates dependencies; we review for substance, not every bump. # Skip Renovate dependency updates - CI gates dependencies; we review for substance, not every bump.
ignore_usernames: ignore_usernames:
- renovate - renovate
@@ -27,7 +27,7 @@ reviews:
- "!protobufs/**" - "!protobufs/**"
path_instructions: path_instructions:
- path: "bin/config.d/**" - path: bin/config.d/**
instructions: > instructions: >
meshtasticd configuration files. Bundled with meshtasticd Linux/MacOS packaging. meshtasticd configuration files. Bundled with meshtasticd Linux/MacOS packaging.
Ensure configurations include metadata found in other configs. Ensure configurations include metadata found in other configs.
-1
View File
@@ -1,4 +1,3 @@
# trunk-ignore-all(terrascan/AC_DOCKER_0002): Known terrascan issue
# trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions # trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
FROM mcr.microsoft.com/devcontainers/cpp:2-debian-13 FROM mcr.microsoft.com/devcontainers/cpp:2-debian-13
+1 -1
View File
@@ -8,7 +8,7 @@
"features": { "features": {
"ghcr.io/devcontainers/features/python:1": { "ghcr.io/devcontainers/features/python:1": {
"installTools": true, "installTools": true,
"version": "3.13" "version": "3.14"
} }
}, },
"customizations": { "customizations": {
+2
View File
@@ -677,6 +677,8 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su
- `test_radio/` - Radio interface - `test_radio/` - Radio interface
- `test_rtc/` - RTC / time handling - `test_rtc/` - RTC / time handling
- `test_serial/` - Serial communication - `test_serial/` - Serial communication
- `test_tak_config/` - TAK (ATAK) team/role value fidelity through set/save/load/get
- `test_module_config/` - every ModuleConfig submessage survives admin set -> save -> load -> get
- `test_traffic_management/` - Traffic management (dedup, rate-limit, hop-trim, role exceptions) - `test_traffic_management/` - Traffic management (dedup, rate-limit, hop-trim, role exceptions)
- `test_transmit_history/` - Retransmission tracking - `test_transmit_history/` - Retransmission tracking
- `test_type_conversions/` - NodeDB v25 type conversion (bitfield round-trips, NodeInfoLite) - `test_type_conversions/` - NodeDB v25 type conversion (bitfield round-trips, NodeInfoLite)
+47 -6
View File
@@ -14,30 +14,71 @@ permissions:
jobs: jobs:
build-MacOS: build-MacOS:
runs-on: macos-${{ inputs.macos_ver }} runs-on: macos-${{ inputs.macos_ver }}
# 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: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v7
with: with:
submodules: recursive submodules: recursive
- name: Install deps - name: Capture runner image version (for caching)
id: r_image
run: echo "ver=$ImageVersion" >> "$GITHUB_OUTPUT"
- name: Restore Homebrew cache
id: brew-cache
uses: actions/cache/restore@v6
with:
path: ~/Library/Caches/Homebrew
key: brew-macos-${{ inputs.macos_ver }}-${{ steps.r_image.outputs.ver }}
- name: Install deps (Homebrew)
shell: bash shell: bash
run: | env:
brew update # GitHub-Hosted MacOS runner images are updated frequently
brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius # so skip running `brew update` to avoid unnecessary failures and delays.
HOMEBREW_NO_AUTO_UPDATE: "1"
HOMEBREW_NO_INSTALL_CLEANUP: "1"
run: >
brew install
platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius jsoncpp
- name: Save Homebrew cache
if: env.SAVE_CACHE == 'true' && steps.brew-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: ~/Library/Caches/Homebrew
key: brew-macos-${{ inputs.macos_ver }}-${{ steps.r_image.outputs.ver }}
- name: Get release version string - name: Get release version string
run: | run: |
echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
id: version id: version
- name: Restore PlatformIO cache
id: pio-cache
uses: actions/cache/restore@v6
with:
path: ~/.platformio/.cache
key: pio-macos-${{ inputs.macos_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
restore-keys: |
pio-macos-${{ inputs.macos_ver }}-
- name: Build for MacOS - name: Build for MacOS
run: | run: |
platformio run -e native-macos platformio run -e native-macos
env: env:
PKG_VERSION: ${{ steps.version.outputs.long }} PKG_VERSION: ${{ steps.version.outputs.long }}
# Errors in this step should not fail the entire workflow while MacOS support is in development.
continue-on-error: true - name: Save PlatformIO cache
if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: ~/.platformio/.cache
key: pio-macos-${{ inputs.macos_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
- name: List output files - name: List output files
run: ls -lah .pio/build/native-macos/ run: ls -lah .pio/build/native-macos/
+21 -1
View File
@@ -18,6 +18,10 @@ jobs:
build-wasm: build-wasm:
name: Build PortDuino WASM name: Build PortDuino WASM
runs-on: ubuntu-latest runs-on: ubuntu-latest
# 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: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
@@ -29,14 +33,30 @@ jobs:
uses: ./.github/actions/setup-native uses: ./.github/actions/setup-native
- name: Setup Emscripten SDK - name: Setup Emscripten SDK
uses: emscripten-core/setup-emsdk@v16 uses: emscripten-core/setup-emsdk@0822153d7a5488b70a269cfa0a631b2a86ab4da2 # 'v17' main
with: with:
version: 6.0.1 version: 6.0.1
actions-cache-folder: emsdk-cache actions-cache-folder: emsdk-cache
- name: Restore PlatformIO cache
id: pio-cache
uses: actions/cache/restore@v6
with:
path: ~/.platformio/.cache
key: pio-wasm-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
restore-keys: |
pio-wasm-
- name: Build PortDuino WASM - name: Build PortDuino WASM
run: platformio run -e native-wasm run: platformio run -e native-wasm
- name: Save PlatformIO cache
if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: ~/.platformio/.cache
key: pio-wasm-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
- name: Assert WASM artifacts - name: Assert WASM artifacts
run: | run: |
OUT=.pio/build/native-wasm OUT=.pio/build/native-wasm
+142
View File
@@ -0,0 +1,142 @@
name: Build Windows Binary
on:
workflow_call:
inputs:
windows_ver:
required: false
default: "2025"
type: string
permissions:
contents: read
jobs:
build-Windows:
runs-on: windows-${{ inputs.windows_ver }}
# 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 }}
defaults:
run:
# UCRT64 is the MinGW-w64 environment native-windows targets; the `msys`
# environment would link msys-2.0.dll and produce a Cygwin-style binary.
shell: msys2 {0}
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
submodules: recursive
# Keep the token out of .git/config so later steps and artifacts can't leak it.
persist-credentials: false
- name: Setup MSYS2 / UCRT64
id: msys2
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
cache: true
# argp is not packaged for the mingw environments and is built below.
# Python is omitted too: MSYS2's reports a `mingw` platform tag no wheel matches.
install: >-
mingw-w64-ucrt-x86_64-gcc
mingw-w64-ucrt-x86_64-pkgconf
mingw-w64-ucrt-x86_64-yaml-cpp
mingw-w64-ucrt-x86_64-libuv
mingw-w64-ucrt-x86_64-jsoncpp
mingw-w64-ucrt-x86_64-openssl
mingw-w64-ucrt-x86_64-libusb
mingw-w64-ucrt-x86_64-cmake
mingw-w64-ucrt-x86_64-ninja
git
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
cache: pip
# framework-portduino calls argp_parse(); MSYS2 packages argp only for the
# msys runtime, which cannot link into a native binary. No install() rules.
- name: Build and install argp-standalone
run: |
git clone --depth 1 https://github.com/tom42/argp-standalone /tmp/argp
cd /tmp/argp
cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release .
cmake --build build
cp include/argp-standalone/argp.h /ucrt64/include/argp.h
cp build/src/libargp-standalone.a /ucrt64/lib/libargp.a
- name: Install PlatformIO
shell: pwsh
run: |
python -m pip install --upgrade pip
pip install platformio
- name: Restore PlatformIO cache
id: pio-cache
uses: actions/cache/restore@v6
with:
path: ~/.platformio/.cache
key: pio-windows-${{ inputs.windows_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
restore-keys: |
pio-windows-${{ inputs.windows_ver }}-
- name: Get release version string
shell: pwsh
id: version
run: echo "long=$(python ./bin/buildinfo.py long)" >> $env:GITHUB_OUTPUT
# Runs outside the MSYS2 shell so PlatformIO stays on the runner's
# CPython; the UCRT64 toolchain is reached through PATH instead.
- name: Build for Windows
shell: pwsh
run: |
$env:PATH = "${{ steps.msys2.outputs.msys2-location }}\ucrt64\bin;$env:PATH"
platformio run -e native-windows
env:
PKG_VERSION: ${{ steps.version.outputs.long }}
- name: Save PlatformIO cache
if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: ~/.platformio/.cache
key: pio-windows-${{ inputs.windows_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
- name: List output files
run: ls -lah .pio/build/native-windows/
# The env links statically, so only Windows system DLLs should appear here.
# A third-party DLL means the static link regressed.
- name: Verify the binary is self-contained
run: |
set -euo pipefail
bin=.pio/build/native-windows/meshtasticd.exe
test -f "$bin"
# `|| true` keeps grep's no-match exit out of `set -e`; an empty import
# table is caught by the test below instead of passing as "no deps".
deps=$(objdump -p "$bin" | grep -i 'DLL Name' || true)
test -n "$deps"
extra=$(printf '%s\n' "$deps" \
| grep -viE 'api-ms-win|KERNEL32|WS2_32|ADVAPI32|USER32|msvcrt|ucrtbase|bcrypt|IPHLPAPI|SHELL32|ole32|CRYPT32|SETUPAPI|CFGMGR32|WINMM|dbghelp' || true)
if [ -n "$extra" ]; then
printf '%s\n' "$extra"
echo "::error::meshtasticd.exe has non-system DLL dependencies (static link regressed)"
exit 1
fi
echo "OK: no third-party DLL dependencies"
- name: Smoke test the binary
run: |
.pio/build/native-windows/meshtasticd.exe --version
- name: Store binaries as an artifact
uses: actions/upload-artifact@v7
with:
name: firmware-windows-${{ inputs.windows_ver }}-${{ steps.version.outputs.long }}
overwrite: true
path: |
.pio/build/native-windows/meshtasticd.exe
+9 -4
View File
@@ -45,6 +45,10 @@ jobs:
outputs: outputs:
digest: ${{ steps.docker_variant.outputs.digest }} digest: ${{ steps.docker_variant.outputs.digest }}
runs-on: ${{ inputs.runs-on }} runs-on: ${{ inputs.runs-on }}
env:
# Only cache on default branch (develop).
# 'docker/' actions don't support separate Restore/Save actions, so we have to disable caching entirely on PRs/merge_queue.
USE_CACHE: ${{ github.ref_name == github.event.repository.default_branch }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v7
@@ -59,11 +63,12 @@ jobs:
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v4
with: with:
cache-image: true cache-image: ${{ env.USE_CACHE }}
- name: Docker setup - name: Docker setup
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
with: with:
cache-binary: ${{ env.USE_CACHE }}
# Add Google/Amazon DockerHub mirrors to buildkit config # Add Google/Amazon DockerHub mirrors to buildkit config
# https://docs.docker.com/build/ci/github-actions/configure-builder/#registry-mirror # https://docs.docker.com/build/ci/github-actions/configure-builder/#registry-mirror
buildkitd-config-inline: | buildkitd-config-inline: |
@@ -105,6 +110,6 @@ jobs:
platforms: ${{ inputs.platform }} platforms: ${{ inputs.platform }}
build-args: | build-args: |
PIO_ENV=${{ inputs.pio_env }} PIO_ENV=${{ inputs.pio_env }}
# Cache image layers in GitHub Actions cache to speed up subsequent builds. # Disabled for now: Cache image layers in GitHub Actions cache to speed up subsequent builds.
cache-from: type=gha # cache-from: type=gha
cache-to: type=gha,mode=max # cache-to: type=gha,mode=max
+32 -18
View File
@@ -3,12 +3,21 @@ concurrency:
group: ci-${{ github.head_ref || github.run_id }} group: ci-${{ github.head_ref || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
on: on:
# # Triggers the workflow on push but only for the main branches # The merge queue is the pre-merge gate for the protected branches (master,
# develop): a merge_group run validates the merged result before code lands. For
# now PRs and merge_group runs build the same narrowed board subset (--level pr);
# see the setup job. push still runs the full matrix on master/develop (post-merge)
# as well as on the event/* and feature/* branches, which have no merge queue.
merge_group:
types: [checks_requested]
branches:
- master
- develop
push: push:
branches: branches:
- master - master
- develop - develop
- pioarduino # Remove when merged // use `feature/` in the future.
- event/* - event/*
- feature/* - feature/*
paths-ignore: paths-ignore:
@@ -19,7 +28,6 @@ on:
branches: branches:
- master - master
- develop - develop
- pioarduino # Remove when merged // use `feature/` in the future.
- event/* - event/*
- feature/* - feature/*
paths-ignore: paths-ignore:
@@ -28,9 +36,9 @@ on:
schedule: schedule:
# Nightly develop build, published to meshtastic.github.io firmware-nightly/ (no GitHub release). # Nightly develop build, published to meshtastic.github.io firmware-nightly/ (no GitHub release).
# Scheduled runs execute on the default branch (develop). 09:00 UTC avoids the 00:00 tests # Scheduled runs execute on the default branch (develop). 07:00 UTC avoids the 00:00 tests
# and 02:00 daily_packaging crons. # and 02:00 daily_packaging crons.
- cron: 0 9 * * * # Nightly develop build/publish (default branch is develop) - cron: 0 7 * * * # Nightly develop build/publish (default branch is develop)
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@@ -61,10 +69,12 @@ jobs:
- name: Generate matrix - name: Generate matrix
id: jsonStep id: jsonStep
run: | run: |
if [[ "$GITHUB_HEAD_REF" == "" ]]; then # PRs and (for now) merge_group builds use the narrowed --level pr board
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}}) # subset. Full-matrix builds run on push / schedule / workflow_dispatch.
else if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level pr) TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level pr)
else
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}})
fi fi
echo "Name: $GITHUB_REF_NAME Base: $GITHUB_BASE_REF Ref: $GITHUB_REF" echo "Name: $GITHUB_REF_NAME Base: $GITHUB_BASE_REF Ref: $GITHUB_REF"
echo "${{matrix.arch}}=$TARGETS" >> $GITHUB_OUTPUT echo "${{matrix.arch}}=$TARGETS" >> $GITHUB_OUTPUT
@@ -144,6 +154,18 @@ jobs:
macos_ver: ${{ matrix.macos_ver }} macos_ver: ${{ matrix.macos_ver }}
# secrets: inherit # secrets: inherit
Windows:
if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }}
strategy:
fail-fast: false
matrix:
windows_ver:
- "2025" # x86_64
uses: ./.github/workflows/build_windows_bin.yml
with:
windows_ver: ${{ matrix.windows_ver }}
# secrets: inherit
package-pio-deps-native-tft: package-pio-deps-native-tft:
if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }} if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/package_pio_deps.yml uses: ./.github/workflows/package_pio_deps.yml
@@ -177,15 +199,8 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
distro: [debian, alpine] distro: [debian, alpine]
platform: [linux/amd64, linux/arm64, linux/arm/v7] platform: [linux/arm64]
pio_env: [native, native-tft] pio_env: [native-tft]
exclude:
- distro: alpine
platform: linux/arm/v7
- pio_env: native-tft
platform: linux/arm64
- pio_env: native-tft
platform: linux/arm/v7
uses: ./.github/workflows/docker_build.yml uses: ./.github/workflows/docker_build.yml
with: with:
distro: ${{ matrix.distro }} distro: ${{ matrix.distro }}
@@ -195,7 +210,6 @@ jobs:
push: false push: false
gather-artifacts: gather-artifacts:
# trunk-ignore(checkov/CKV2_GHA_1)
if: github.repository == 'meshtastic/firmware' if: github.repository == 'meshtastic/firmware'
strategy: strategy:
fail-fast: false fail-fast: false
+1 -1
View File
@@ -36,4 +36,4 @@ jobs:
- name: Trunk Upgrade - name: Trunk Upgrade
uses: trunk-io/trunk-action/upgrade@v1 uses: trunk-io/trunk-action/upgrade@v1
with: with:
base: master base: develop
+5 -4
View File
@@ -4,10 +4,11 @@ name: Tests
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
# trunk-ignore(checkov/CKV_GHA_7): the reason input is a free-text run label, never fed into the build
reason: reason:
description: "Reason for manual test run" description: Reason for manual test run
required: false required: false
default: "Manual test execution" default: Manual test execution
concurrency: concurrency:
group: tests-${{ github.head_ref || github.run_id }} group: tests-${{ github.head_ref || github.run_id }}
@@ -21,7 +22,7 @@ permissions:
jobs: jobs:
native-tests: native-tests:
name: "🧪 Native Tests" name: 🧪 Native Tests
if: github.repository == 'meshtastic/firmware' if: github.repository == 'meshtastic/firmware'
uses: ./.github/workflows/test_native.yml uses: ./.github/workflows/test_native.yml
permissions: permissions:
@@ -30,7 +31,7 @@ jobs:
checks: write checks: write
test-summary: test-summary:
name: "📊 Test Results" name: 📊 Test Results
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [native-tests] needs: [native-tests]
if: always() if: always()
+141 -18
View File
@@ -10,11 +10,56 @@ env:
LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --directory .pio/build/coverage/src --base-directory "${PWD}" LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --directory .pio/build/coverage/src --base-directory "${PWD}"
jobs: 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
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Reconcile native-suite-count with test/ directories
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
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"
fi
echo "::error title=native-suite-count mismatch::test/ has $expected_count suite directories but $count_file says $canonical_count ($hint)."
exit 1
fi
echo "native-suite-count matches the $expected_count suite directories."
simulator-tests: simulator-tests:
name: Native Simulator Tests name: Native Simulator Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: suite-count-check
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
submodules: recursive submodules: recursive
@@ -87,8 +132,9 @@ jobs:
platformio-tests: platformio-tests:
name: Native PlatformIO Tests name: Native PlatformIO Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: suite-count-check
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
submodules: recursive submodules: recursive
@@ -105,14 +151,90 @@ jobs:
- name: Disable BUILD_EPOCH - name: Disable BUILD_EPOCH
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
- name: PlatformIO Tests - name: Build test programs once
# One shared build of src + every test program. This is the single source build; gcov then
# accumulates coverage counts into this shared .pio/build/coverage/src as the chunks run.
run: platformio test -e coverage --without-testing
- name: Run tests one area at a time
shell: bash
run: | run: |
set -o pipefail set -uo pipefail
# Filter out SKIPPED summary rows for hardware variants that can't run on the # One runner, no matrix, no concurrency. Group the test_* suites by area and run each
# native host. They flood the log and make it harder to spot real failures. # area sequentially, reusing the single build above (--without-building). Each area gets
# The JUnit XML is written directly to testreport.xml before the pipe, so # its own JUnit report and its own collapsible log, so a failure lands in a small named
# the test artifact is unaffected. # section instead of being buried past the log limit. Sequential runs share one build
platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$" # dir, so gcov coverage accumulates and the single capture in the next step has the union.
# Ordered area rules "name:ERE"; first match wins. Anything unmatched falls to "misc", so
# a newly added suite always runs even before it is placed. Add a suite to an area by
# extending that area's regex; add a new area by inserting a rule line.
area_rules=(
"admin:^test_(admin|pki)_"
"crypto:^test_(crypto|packet_signing)$"
"routing:^test_(mesh|nexthop|traceroute|hop|traffic|nodedb|warm)_"
"position:^test_position_"
"fuzz:^test_fuzz_"
"packets:^test_(packet|transmit|meshpacket)_"
"io:^test_(serial|stream|xmodem|http|mqtt)"
)
mapfile -t suites < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
declare -A group
for s in "${suites[@]}"; do
a="misc"
for rule in "${area_rules[@]}"; do
if [[ "$s" =~ ${rule#*:} ]]; then a="${rule%%:*}"; break; fi
done
group[$a]="${group[$a]:-} -f $s"
done
run_order=()
for rule in "${area_rules[@]}"; do run_order+=("${rule%%:*}"); done
run_order+=("misc")
fail=0
for a in "${run_order[@]}"; do
[ -n "${group[$a]:-}" ] || continue
echo "::group::area $a (${group[$a]# })"
# Capture platformio's real exit status (not grep's) via a log file, then show the log
# with the noisy per-variant SKIPPED rows filtered out.
if ! platformio test -e coverage --without-building -v ${group[$a]# } \
--junit-output-path "testreport-$a.xml" > "area-$a.log" 2>&1; then
fail=1
echo "::error::area $a had test failures"
fi
grep -v "[[:space:]]SKIPPED$" "area-$a.log" || true
echo "::endgroup::"
done
exit $fail
- name: Merge per-area reports into testreport.xml
# Preserve the single-file JUnit contract that downstream consumers rely on
# (pr_tests.yml's summary and generate-reports' Test Report both read testreport.xml).
# The per-area split is only for readable logs; the report stays consolidated.
if: always() # run even when a chunk failed, so the report captures the failures
shell: bash
run: |
python3 - <<'PY'
import glob, xml.etree.ElementTree as ET
out = ET.Element('testsuites')
for f in sorted(glob.glob('testreport-*.xml')):
try:
root = ET.parse(f).getroot()
except ET.ParseError:
continue
# PlatformIO writes a <testsuites> root; fold in a bare <testsuite> too, just in case.
out.extend(root.findall('testsuite') if root.tag == 'testsuites' else [root])
ET.ElementTree(out).write('testreport.xml', encoding='utf-8', xml_declaration=True)
PY
- name: Capture coverage information
if: always() # run this step even if previous step failed
run: |
sudo apt-get install -y lcov
lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name tests --output-file coverage_tests.info
sed -i -e "s#${PWD}#.#" coverage_tests.info # Make paths relative.
- name: Save test results - name: Save test results
if: always() # run this step even if previous step failed if: always() # run this step even if previous step failed
@@ -122,13 +244,6 @@ jobs:
overwrite: true overwrite: true
path: ./testreport.xml path: ./testreport.xml
- name: Capture coverage information
if: always() # run this step even if previous step failed
run: |
sudo apt-get install -y lcov
lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name tests --output-file coverage_tests.info
sed -i -e "s#${PWD}#.#" coverage_tests.info # Make paths relative.
- name: Save coverage information - name: Save coverage information
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
if: always() # run this step even if previous step failed if: always() # run this step even if previous step failed
@@ -149,7 +264,7 @@ jobs:
- platformio-tests - platformio-tests
if: always() if: always()
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Get release version string - name: Get release version string
run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
@@ -184,9 +299,17 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Generate Code Coverage Report - name: Generate Code Coverage Report
# Merge every tracefile the jobs produced: coverage_base.info (zeroed baseline),
# coverage_integration.info, and one coverage_tests_<chunk>.info per chunk. lcov
# sums hit counts across them, so the merged report is the union of all chunks -
# identical to running the whole suite in one job.
run: | run: |
sudo apt-get install -y lcov sudo apt-get install -y lcov
lcov --quiet --add-tracefile code-coverage-report/coverage_base.info --add-tracefile code-coverage-report/coverage_integration.info --add-tracefile code-coverage-report/coverage_tests.info --output-file code-coverage-report/coverage_src.info args=()
for f in code-coverage-report/coverage_*.info; do
args+=(--add-tracefile "$f")
done
lcov --quiet "${args[@]}" --output-file code-coverage-report/coverage_src.info
genhtml --quiet --legend --prefix "${PWD}" code-coverage-report/coverage_src.info --output-directory code-coverage-report genhtml --quiet --legend --prefix "${PWD}" code-coverage-report/coverage_src.info --output-directory code-coverage-report
- name: Save Code Coverage Report - name: Save Code Coverage Report
+1
View File
@@ -24,3 +24,4 @@ jobs:
uses: trunk-io/trunk-action@v1 uses: trunk-io/trunk-action@v1
with: with:
post-annotations: true post-annotations: true
cache: false
+1
View File
@@ -22,3 +22,4 @@ jobs:
uses: trunk-io/trunk-action@v1 uses: trunk-io/trunk-action@v1
with: with:
save-annotations: true save-annotations: true
cache: false
+7
View File
@@ -102,6 +102,13 @@ lint:
- linters: [gitleaks] - linters: [gitleaks]
paths: paths:
- test/fixtures/nodedb/seed_v25_*.jsonl - test/fixtures/nodedb/seed_v25_*.jsonl
# checkov/CKV_SECRET_6 flags the commented-out "large4cats" example,
# which is the public default credential for the meshtastic.org MQTT
# broker rather than a real secret. Ignore the whole file so the
# example config stays free of lint-suppression comments.
- linters: [ALL]
paths:
- userPrefs.jsonc
# The UA font's em/en dashes live only in trailing comments documenting # The UA font's em/en dashes live only in trailing comments documenting
# each glyph's codepoint (e.g. "8212=:2549"); rewriting them would make # each glyph's codepoint (e.g. "8212=:2549"); rewriting them would make
# those docs inaccurate. Glyph byte data is unaffected either way. # those docs inaccurate. Glyph byte data is unaffected either way.
+2 -1
View File
@@ -1,4 +1,5 @@
# trunk-ignore-all(trivy/DS002): We must run as root for this container # trunk-ignore-all(trivy/DS-0002): We must run as root for this container
# trunk-ignore-all(checkov/CKV_DOCKER_8): We must run as root for this container
# trunk-ignore-all(hadolint/DL3002): We must run as root for this container # trunk-ignore-all(hadolint/DL3002): We must run as root for this container
# trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions # trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
+2 -1
View File
@@ -1,4 +1,5 @@
# trunk-ignore-all(trivy/DS002): We must run as root for this container # trunk-ignore-all(trivy/DS-0002): We must run as root for this container
# trunk-ignore-all(checkov/CKV_DOCKER_8): We must run as root for this container
# trunk-ignore-all(hadolint/DL3002): We must run as root for this container # trunk-ignore-all(hadolint/DL3002): We must run as root for this container
# trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions # trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
+2 -1
View File
@@ -12,7 +12,8 @@ rm -f $OUTDIR/firmware*
rm -r $OUTDIR/* || true rm -r $OUTDIR/* || true
# Important to pull latest version of libs into all device flavors, otherwise some devices might be stale # Important to pull latest version of libs into all device flavors, otherwise some devices might be stale
platformio pkg install -e $1 # platformio pkg install -e $1
# ...redundant with pioarduino
echo "Building for $1 with $PLATFORMIO_BUILD_FLAGS" echo "Building for $1 with $PLATFORMIO_BUILD_FLAGS"
rm -f $BUILDDIR/firmware* rm -f $BUILDDIR/firmware*
+66 -10
View File
@@ -1,26 +1,82 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Note: This is a prototype for how we could add static code analysis to the CI. # Run cppcheck static analysis over one or more PlatformIO environments.
#
# Why this is more than a bare `pio check`: the CI `check` matrix job in main_matrix.yml runs this
# script inside meshtastic/gh-action-firmware, and `pio check` must download the platform package
# before it can analyse anything. That download is regularly reset mid-flight by the CDN, e.g.
#
# Checking station-g3 > cppcheck (board: station-g3; platform: .../platform-espressif32.zip)
# requests.exceptions.ConnectionError: ('Connection aborted.', ConnectionResetError(104, ...))
#
# The analysis never starts, so there is no signal at all - just a red X someone has to re-run by
# hand. `pio check` exits 1 for that AND for real defects, so the exit code cannot tell them apart;
# only the output can. This script classifies the failure:
#
# * cppcheck produced results (summary table or defect lines) -> real, propagate the exit status.
# * transport-level network error and nothing else -> warn and skip.
# * anything else -> propagate the exit status.
#
# A missing or forbidden package (404 / UnknownPackageError / 403) is deliberately NOT treated as
# transient: that is a reproducible break from a bad pin in a .ini, not weather.
#
# Exit codes: 0 = clean (or skipped under CI), 1 = defects/error, 2 = skipped locally.
set -e set -uo pipefail
VERSION=$(bin/buildinfo.py long) VERSION=$(bin/buildinfo.py long) || exit 1
# The shell vars the build tool expects to find # The shell vars the build tool expects to find
export APP_VERSION=$VERSION export APP_VERSION=$VERSION
if [[ $# -gt 0 ]]; then if [[ $# -gt 0 ]]; then
# can override which environment by passing arg # can override which environment by passing arg
BOARDS="$@" BOARDS=("$@")
else else
BOARDS="tlora-v2 tlora-v1 tlora_v1_3 tlora-v2-1-1.6 tbeam heltec-v2.0 heltec-v2.1 tbeam0.7 meshtastic-diy-v1 rak4631 rak4631_eink rak11200 t-echo canaryone pca10059_diy_eink" BOARDS=(tlora-v2 tlora-v1 tlora_v1_3 tlora-v2-1-1.6 tbeam heltec-v2.0 heltec-v2.1 tbeam0.7 meshtastic-diy-v1 rak4631 rak4631_eink rak11200 t-echo canaryone pca10059_diy_eink)
fi fi
echo "BOARDS:${BOARDS}" echo "BOARDS:${BOARDS[*]}"
CHECK="" # Array, not a string: board names reach pio as literal arguments, so an override containing
for BOARD in $BOARDS; do # whitespace or a glob character cannot turn into extra pio arguments.
CHECK="${CHECK} -e ${BOARD}" CHECK=()
for BOARD in "${BOARDS[@]}"; do
CHECK+=(-e "${BOARD}")
done done
pio check --flags "-DAPP_VERSION=${APP_VERSION} --suppressions-list=suppressions.txt --inline-suppr" $CHECK --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high LOG=$(mktemp)
trap 'rm -f "$LOG"' EXIT
# Keep streaming to the console so the CI log reads exactly as it did before; tee a copy for the
# post-mortem classification below.
pio check --flags "-DAPP_VERSION=${APP_VERSION} --suppressions-list=suppressions.txt --inline-suppr" "${CHECK[@]}" --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high 2>&1 | tee "$LOG"
STATUS=${PIPESTATUS[0]}
if [[ $STATUS -eq 0 ]]; then
exit 0
fi
# Fail closed: if cppcheck got far enough to report anything, the run is real no matter what other
# noise is in the log.
if grep -Eqi '^Component[[:space:]]+.*HIGH|^Total[[:space:]]+[0-9]|^[^[:space:]]+:[0-9]+: \[(high|medium|low)' "$LOG"; then
exit "$STATUS"
fi
# Transport-level failures only. No 403/404/UnknownPackageError here, on purpose.
TRANSIENT='requests\.exceptions\.(ConnectionError|Timeout|ReadTimeout|ConnectTimeout)|ConnectionResetError|urllib3\.exceptions\.(ProtocolError|MaxRetryError|NameResolutionError|ReadTimeoutError)|Read timed out|Connection aborted|Connection refused|Temporary failure in name resolution|Could not resolve host|(429|500|502|503|504) (Server|Client) Error|Too Many Requests'
if ! grep -Eq "$TRANSIENT" "$LOG"; then
exit "$STATUS"
fi
REASON=$(grep -Em1 "$TRANSIENT" "$LOG" | tr -d '\r' | cut -c1-200)
echo "RESULT: SKIPPED ${BOARDS[*]} - transient network failure: ${REASON}"
if [[ ${GITHUB_ACTIONS:-false} == "true" ]]; then
echo "::warning title=Static analysis skipped (${BOARDS[*]})::pio check could not download its packages: ${REASON}"
exit 0
fi
exit 2
@@ -153,7 +153,10 @@ These rules are what keep the UX correct across firmware versions. Implement all
5. **EU region auto-swap caveat.** The firmware treats the EU sibling regions 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 (`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 preset that belongs to a sibling's list, the firmware **swaps the region** rather than
rejecting the preset. Consequence for clients: **do not assume the region is immutable 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 across a preset change** - after an admin config write, re-read the resulting
`LoRaConfig` and reflect the (possibly changed) region back into the UI. `LoRaConfig` and reflect the (possibly changed) region back into the UI.
@@ -249,12 +252,23 @@ so they are stable as listed here:
| group_index | default_preset | licensed_only | presets | | 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 | | 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 | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE | | 1 (EU 868) | `LONG_FAST` | false | _EU 86x superset_ (see below) |
| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW | | 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | _EU 86x superset_ (see below) |
| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW | | 3 (EU 868 narrow) | `NARROW_SLOW` | false | _EU 86x superset_ (see below) |
| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW | | 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW |
| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_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): `region_groups` (region → group_index):
| group | regions | | group | regions |
@@ -266,9 +280,11 @@ so they are stable as listed here:
| 4 | ITU1_2M, ITU2_2M, ITU3_2M | | 4 | ITU1_2M, ITU2_2M, ITU3_2M |
| 5 | ITU2_125CM | | 5 | ITU2_125CM |
> Note groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups > Note that several groups can carry overlapping preset lists but remain distinct: groups 1,
> because they differ in `licensed_only`. Decoders must key on the group, not on the preset > 2 and 3 share the EU 86x superset yet differ in `default_preset`, and group **5** (ham
> list, to preserve the licensing flag. > 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`, > Regions **absent** from the table (no constraint info; see §5.1): `EU_874`, `EU_917`,
> `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`. > `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`.
+248
View File
@@ -0,0 +1,248 @@
# 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`.
---
## 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.
- **Capacity:** `MAX_NUM_NODES`, per platform - 250 on native, 120 on nRF52840/generic
ESP32, 10 on STM32WL (see `mesh-pb-constants.h`).
- **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 signer bit.
- **Persistence:** the node database file, 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`.
## 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, signer 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`.
- **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/signer bits.
## 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.
- **Capacity:** `TRAFFIC_MANAGEMENT_CACHE_SIZE`, per memory class: 2048 (PSRAM S3 /
native), 500 (medium), 400 (small), 250 (nRF52840 - deliberately class-deviant for heap
headroom), 0 when `HAS_TRAFFIC_MANAGEMENT=0`. Variant-overridable.
- **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 - RAM/PSRAM only, rebuilt from traffic.
## 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`,
`keySignerProven`, `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.)
- **Capacity:** `kNodeInfoCacheEntries = 2000`, linear scan (NodeInfo traffic is
low-rate).
- **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from NodeDB
seeding plus observed traffic after every boot.
### 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).
- **`keySignerProven`:** 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`). Monotonic per slot; a changed key resets it.
- **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 are knowledge, not observation - they
can never make a silent node look alive to the replay path. The 6 h serve window is
enforced by the sweep-cleared `hasObserved` bit; the spoofed-reply throttle that 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 signer-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` maps to `proven=true` 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
All TMM timestamps are free-running modular ticks (uint8 or nibble) from `clockMs()`; modular
subtraction is correct only while the true age stays below the counter period, so every clock
needs something to clear expired state before it aliases.
| 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 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 (`NodeInfoLite`) | 2. Warm tier (`WarmNodeEntry`) | 3. NodeInfo cache (`NodeInfoPayloadEntry`) | 4. Unified cache (`UnifiedCacheEntry`) |
| ------------------------- | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------- |
| Node number | yes | yes | yes (0 = free slot) | yes (0 = free slot) |
| Names + user id | yes (flattened fields) | - | yes (full `User`, when `hasFullUser`) | - |
| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU or proven; pinned against tiers 1-2) | - |
| Signer provenance | `HAS_XEDDSA_SIGNED` bitfield bit | 1 signer bit (shared with `last_heard`) | `keySignerProven` (monotonic per key) | - |
| Device role | `role` field | 4-bit role (metadata steal) | inside the cached `User` | 4-bit role in count-byte top bits (final fallback) |
| Recency | `last_heard` (unix secs) | `last_heard` (unix secs, 128 s quantised) | `obsTick` (3 min modular tick) + `hasObserved` | pos/rate/unknown modular ticks |
| Position / telemetry | via satellite copy-out accessors | - | - | 8-bit position _fingerprint_ only (dedup) |
| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` keep-alive instead) | - |
| Routing hint (`next_hop`) | yes (persisted field) | - | - | ACK-confirmed relay byte (preloaded from tier 1) |
| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` (+ `hasDecodedBitfield`) | - |
| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fingerprint |
| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8, platform-padded (no size assert by design) | 10 B exact |
| Capacity | `MAX_NUM_NODES` (250/120/10) | `WARM_NODE_COUNT` (~100) | `kNodeInfoCacheEntries` (2000) | `TRAFFIC_MANAGEMENT_CACHE_SIZE` (2048/500/400/250/0) |
| Persistence | node DB file | raw-flash ring (nRF52840) or `/prefs/warm.dat` | none (rebuilt from seed + traffic) | none |
| Storage | RAM | RAM + flash | PSRAM on hardware; plain heap in native tests | PSRAM when available, else heap |
## 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/signer 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.
+193
View File
@@ -0,0 +1,193 @@
# 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. **Signer-provenance gate** (`TMM_NODEINFO_REPLAY_SIGNED_GATE`, default on): vouch only for
an identity whose key is signer-proven (XEdDSA-verified, directly or inherited from
NodeDB). 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.
---
## 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.
+96
View File
@@ -71,3 +71,99 @@ def mtjson_esp32_part(target, source, env):
env.AddPreAction("mtjson", mtjson_esp32_part) env.AddPreAction("mtjson", mtjson_esp32_part)
# ---------------------------------------------------------------------------
# HybridCompile cache-poisoning workaround (pioarduino platform-espressif32)
#
# The platform decides whether the precompiled Arduino IDF libs can be reused
# by hashing only the custom_sdkconfig text + mcu
# (builder/frameworks/arduino.py::matching_custom_sdkconfig). The board JSON's
# PSRAM configuration (BOARD_HAS_PSRAM / memory_type) is not part of that
# hash, and nearly all our S3 variants share esp32s3_base's identical
# custom_sdkconfig. Building a PSRAM env (e.g. heltec-v4) followed by a
# no-PSRAM env (e.g. heltec-v3) therefore skips the framework-libs recompile
# and links CONFIG_SPIRAM=y libs into the no-PSRAM firmware, which boot-loops
# with "quad_psram: PSRAM chip is not connected".
#
# Workaround: before the platform's arduino.py SConscript reads the option,
# append a comment line derived from the board's PSRAM/memory configuration to
# this env's custom_sdkconfig. The comment is inert in kconfig but changes the
# md5, forcing the IDF-libs recompile whenever the PSRAM class changes. The
# derivation mirrors espidf.py::generate_board_specific_config().
#
# The marker must stay lowercase: arduino.py greps custom_sdkconfig for the
# substrings "PSRAM" and "CONFIG_SPIRAM=y" (has_psram_config) and would
# otherwise treat every board as having PSRAM.
# ---------------------------------------------------------------------------
SDKCONFIG_CACHE_KEY = "meshtastic_hybridcompile_cache_key"
def spiram_cache_class(env):
board = env.BoardConfig()
mcu = board.get("build.mcu", "esp32")
# memory_type: platformio.ini override > build.arduino.memory_type > build.memory_type
memory_type = None
try:
memory_type = env.GetProjectOption("board_build.memory_type", None)
except Exception:
pass
if not memory_type:
build_section = board.get("build", {})
memory_type = build_section.get("arduino", {}).get(
"memory_type"
) or build_section.get("memory_type")
extra_flags = board.get("build.extra_flags", "")
if isinstance(extra_flags, str):
has_psram = "PSRAM" in extra_flags
else:
has_psram = any("PSRAM" in str(flag) for flag in extra_flags)
if not has_psram:
if memory_type and (
"opi" in memory_type.lower() or "psram" in memory_type.lower()
):
has_psram = True
elif "psram_type" in board.get("build", {}):
has_psram = True
if has_psram:
psram_type = None
try:
psram_type = env.GetProjectOption("board_build.psram_type", None)
except Exception:
pass
if not psram_type and memory_type and len(memory_type.split("_")) == 2:
psram_type = memory_type.split("_")[1]
if not psram_type:
psram_type = board.get("build.psram_type", "") or (
"hex" if mcu == "esp32p4" else "qio"
)
psram_type = psram_type.lower()
if psram_type == "opi" and mcu == "esp32s3":
spiram = "oct"
elif psram_type == "hex":
spiram = "hex"
else:
spiram = "quad"
else:
spiram = "none"
return "memory_type=%s spiram=%s" % ((memory_type or "default").lower(), spiram)
def tag_sdkconfig_cache_key(env):
config = env.GetProjectConfig()
section = "env:" + env["PIOENV"]
if not config.has_option(section, "custom_sdkconfig"):
return
current = env.GetProjectOption("custom_sdkconfig")
if SDKCONFIG_CACHE_KEY in current:
return
marker = "# %s: %s" % (SDKCONFIG_CACHE_KEY, spiram_cache_class(env))
config.set(section, "custom_sdkconfig", current.rstrip("\n") + "\n" + marker)
tag_sdkconfig_cache_key(env)
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821): For SConstruct imports
#
# PlatformIO routes build_flags to the compile step only, so the static link
# flags are appended here, as wasm_link_flags.py does for [env:native-wasm].
Import("env")
if env["PIOENV"].startswith("native-windows"):
env.Append(
LINKFLAGS=[
"-static",
"-static-libgcc",
"-static-libstdc++",
]
)
-3
View File
@@ -1,6 +1,3 @@
# trunk-ignore-all(bandit/B404): subprocess is used to call addr2line
# trunk-ignore-all(bandit/B603): subprocess is used to call addr2line
# Copyright (c) 2014-present PlatformIO <contact@platformio.org> # Copyright (c) 2014-present PlatformIO <contact@platformio.org>
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
+5 -4
View File
@@ -54,6 +54,7 @@ build_flags = -Wno-missing-field-initializers
-DRADIOLIB_EXCLUDE_LORAWAN=1 -DRADIOLIB_EXCLUDE_LORAWAN=1
-DMESHTASTIC_EXCLUDE_DROPZONE=1 -DMESHTASTIC_EXCLUDE_DROPZONE=1
-DMESHTASTIC_EXCLUDE_REPLYBOT=1 -DMESHTASTIC_EXCLUDE_REPLYBOT=1
-DMESHTASTIC_EXCLUDE_RANGETEST=1
-DMESHTASTIC_EXCLUDE_REMOTEHARDWARE=1 -DMESHTASTIC_EXCLUDE_REMOTEHARDWARE=1
-DMESHTASTIC_EXCLUDE_HEALTH_TELEMETRY=1 -DMESHTASTIC_EXCLUDE_HEALTH_TELEMETRY=1
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware -DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
@@ -122,12 +123,12 @@ lib_deps =
[radiolib_base] [radiolib_base]
lib_deps = lib_deps =
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib # renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
https://github.com/jgromes/RadioLib/archive/10e7925db2727e1f5c1c796191905d0a7d955eec.zip https://github.com/jgromes/RadioLib/archive/6d8934836678d8894e3d556550475b37dce3e2b6.zip
[device-ui_base] [device-ui_base]
lib_deps = lib_deps =
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
https://github.com/meshtastic/device-ui/archive/33917d583eb3f4d6f93a24122a88101221afcc2d.zip https://github.com/meshtastic/device-ui/archive/ef573c368767625ffbe8c32cf921ea7366f2dd53.zip
; Common libs for environmental measurements in telemetry module ; Common libs for environmental measurements in telemetry module
[environmental_base] [environmental_base]
@@ -170,7 +171,7 @@ lib_deps =
https://github.com/adafruit/Adafruit_TSL2591_Library/archive/refs/tags/1.4.5.zip https://github.com/adafruit/Adafruit_TSL2591_Library/archive/refs/tags/1.4.5.zip
# renovate: datasource=github-tags depName=EmotiBit MLX90632 packageName=emotibit/EmotiBit_MLX90632 # renovate: datasource=github-tags depName=EmotiBit MLX90632 packageName=emotibit/EmotiBit_MLX90632
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614 # renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit-MLX90614-Library
https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip
# renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT # renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT
https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip
@@ -200,7 +201,7 @@ lib_deps =
https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip
# renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE # renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE
https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip
# renovate: datasource=git-refs depName=Fusion packageName=https://github.com/meshtastic/Fusion gitBranch=master # renovate: datasource=git-refs depName=Fusion packageName=https://github.com/meshtastic/Fusion gitBranch=main
https://github.com/meshtastic/Fusion/archive/936e1eb1e5ea19e8f4ed467526f91adeebb4f53c.zip https://github.com/meshtastic/Fusion/archive/936e1eb1e5ea19e8f4ed467526f91adeebb4f53c.zip
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core # renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
+2 -4
View File
@@ -10,13 +10,11 @@
# trunk-ignore-all(ruff/F821) # trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821): Import/env/Return are SCons-injected globals # trunk-ignore-all(flake8/F821): Import/env/Return are SCons-injected globals
# trunk-ignore-all(ruff/E402)
# trunk-ignore-all(flake8/E402): stdlib imports must follow Import("env")
Import("env")
import glob import glob
import os import os
Import("env")
framework_dir = env.PioPlatform().get_package_dir("framework-arduinopico") framework_dir = env.PioPlatform().get_package_dir("framework-arduinopico")
if not framework_dir: if not framework_dir:
print("[add_mbedtls_sources] framework-arduinopico package not found - skipping") print("[add_mbedtls_sources] framework-arduinopico package not found - skipping")
+79 -38
View File
@@ -12,6 +12,85 @@
#include "SPILock.h" #include "SPILock.h"
#include "configuration.h" #include "configuration.h"
#if defined(ARCH_PORTDUINO)
#include <filesystem>
#endif
#if defined(ARCH_NRF52) || defined(ARCH_STM32)
// Adafruit_LittleFS (nRF52) and STM32_LittleFS both wrap littlefs v1, which predates lfs_fs_size().
// Count the blocks currently in use with lfs_traverse() instead.
static int fsCountBlockCb(void *ctx, lfs_block_t)
{
*static_cast<size_t *>(ctx) += 1;
return 0;
}
size_t fsTotalBytes()
{
lfs_t *fs = FSCom._getFS();
if (!fs || !fs->cfg)
return 0;
return (size_t)fs->cfg->block_count * (size_t)fs->cfg->block_size;
}
size_t fsUsedBytes()
{
lfs_t *fs = FSCom._getFS();
if (!fs || !fs->cfg)
return 0;
size_t blocks = 0;
FSCom._lockFS();
int err = lfs_traverse(fs, fsCountBlockCb, &blocks);
FSCom._unlockFS();
if (err < 0)
return fsTotalBytes(); // report "full" so capacity checks fail safe
return blocks * (size_t)fs->cfg->block_size;
}
#elif defined(ARCH_RP2040)
// arduino-pico reports capacity through FSInfo rather than as methods.
size_t fsTotalBytes()
{
FSInfo info;
return FSCom.info(info) ? (size_t)info.totalBytes : 0;
}
size_t fsUsedBytes()
{
FSInfo info;
return FSCom.info(info) ? (size_t)info.usedBytes : 0;
}
#elif defined(ARCH_PORTDUINO)
// Portduino is backed by the host filesystem; ask the OS about the volume holding the working
// directory. std::filesystem keeps this working on native-windows too, where statvfs() does not
// exist. On error we report "full" so capacity checks fail safe.
size_t fsTotalBytes()
{
std::error_code ec;
const auto info = std::filesystem::space(".", ec);
return ec ? 0 : (size_t)info.capacity;
}
size_t fsUsedBytes()
{
std::error_code ec;
const auto info = std::filesystem::space(".", ec);
if (ec || info.capacity < info.available)
return fsTotalBytes();
return (size_t)(info.capacity - info.available);
}
#else
// ESP32 LittleFS and the nRF54L15 wrapper expose these directly.
size_t fsTotalBytes()
{
return FSCom.totalBytes();
}
size_t fsUsedBytes()
{
return FSCom.usedBytes();
}
#endif
// Software SPI is used by MUI so disable SD card here until it's also implemented // Software SPI is used by MUI so disable SD card here until it's also implemented
#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) #if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI)
#include <SD.h> #include <SD.h>
@@ -30,44 +109,6 @@ SPIClass SPI_HSPI(HSPI);
#endif // HAS_SDCARD #endif // HAS_SDCARD
/**
* @brief Copies a file from one location to another.
*
* @param from The path of the source file.
* @param to The path of the destination file.
* @return true if the file was successfully copied, false otherwise.
*/
bool copyFile(const char *from, const char *to)
{
#ifdef FSCom
// take SPI Lock
concurrency::LockGuard g(spiLock);
unsigned char cbuffer[16];
File f1 = FSCom.open(from, FILE_O_READ);
if (!f1) {
LOG_ERROR("Failed to open source file %s", from);
return false;
}
File f2 = FSCom.open(to, FILE_O_WRITE);
if (!f2) {
LOG_ERROR("Failed to open destination file %s", to);
return false;
}
while (f1.available() > 0) {
byte i = f1.read(cbuffer, 16);
f2.write(cbuffer, i);
}
f2.flush();
f2.close();
f1.close();
return true;
#endif
}
/** /**
* Renames a file from pathFrom to pathTo. * Renames a file from pathFrom to pathTo.
* *
+6 -1
View File
@@ -56,8 +56,13 @@ using namespace Adafruit_LittleFS_Namespace;
using namespace Adafruit_LittleFS_Namespace; using namespace Adafruit_LittleFS_Namespace;
#endif #endif
// Filesystem capacity, in bytes. Only ESP32's LittleFS and the nRF54L15 wrapper expose totalBytes()/usedBytes()
// directly; the other backends need per-platform work (littlefs v1 traversal, FSInfo, statvfs), so callers must
// use these helpers rather than reaching into FSCom.
size_t fsTotalBytes();
size_t fsUsedBytes();
void fsInit(); void fsInit();
bool copyFile(const char *from, const char *to);
bool renameFile(const char *pathFrom, const char *pathTo); bool renameFile(const char *pathFrom, const char *pathTo);
bool fsFormat(); bool fsFormat();
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount = 64, bool *wasLimited = nullptr); std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount = 64, bool *wasLimited = nullptr);
+100 -48
View File
@@ -65,6 +65,15 @@ static inline const char *getTextFromPool(uint16_t offset)
return &g_messagePool[offset]; return &g_messagePool[offset];
} }
static inline bool isIgnoredNodeNum(uint32_t nodeNum)
{
if (nodeNum == 0 || nodeNum == NODENUM_BROADCAST)
return false;
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeNum);
return nodeInfoLiteIsIgnored(node);
}
// Helper: assign a timestamp (RTC if available, else boot-relative) // Helper: assign a timestamp (RTC if available, else boot-relative)
static inline void assignTimestamp(StoredMessage &sm) static inline void assignTimestamp(StoredMessage &sm)
{ {
@@ -163,9 +172,44 @@ static inline void autosaveTick(MessageStore *store)
} }
#endif #endif
// Add from incoming/outgoing packet bool MessageStore::shouldStorePacket(const meshtastic_MeshPacket &packet) const
const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &packet)
{ {
const uint32_t localNode = nodeDB->getNodeNum();
const bool isDM = packet.to != 0 && packet.to != NODENUM_BROADCAST;
if (isDM) {
const bool outgoing = packet.from == 0 || packet.from == localNode;
const uint32_t peer = outgoing ? packet.to : packet.from;
return !isIgnoredNodeNum(peer);
}
if (packet.from != 0 && packet.from != localNode)
return !isIgnoredNodeNum(packet.from);
return true;
}
bool MessageStore::isMessageVisible(const StoredMessage &msg) const
{
const uint32_t localNode = nodeDB->getNodeNum();
if (msg.type == MessageType::DM_TO_US) {
const uint32_t peer = (msg.sender == localNode) ? msg.dest : msg.sender;
return !isIgnoredNodeNum(peer);
}
if (msg.sender != 0 && msg.sender != localNode)
return !isIgnoredNodeNum(msg.sender);
return true;
}
// Add from incoming/outgoing packet
const StoredMessage *MessageStore::tryAddFromPacket(const meshtastic_MeshPacket &packet)
{
if (!shouldStorePacket(packet)) {
LOG_DEBUG("Drop store 0x%08x", packet.from);
return nullptr;
}
StoredMessage sm; StoredMessage sm;
assignTimestamp(sm); assignTimestamp(sm);
sm.channelIndex = packet.channel; sm.channelIndex = packet.channel;
@@ -196,34 +240,7 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa
markMessageStoreUnsaved(); markMessageStoreUnsaved();
#endif #endif
return liveMessages.back(); return &liveMessages.back();
}
// Outgoing/manual message
void MessageStore::addFromString(uint32_t sender, uint8_t channelIndex, const std::string &text)
{
StoredMessage sm;
// Always use our local time (helper handles RTC vs boot time)
assignTimestamp(sm);
sm.sender = sender;
sm.channelIndex = channelIndex;
sm.textOffset = storeTextInPool(text.c_str(), text.size());
sm.textLength = text.size();
// Use the provided destination
sm.dest = sender;
sm.type = MessageType::DM_TO_US;
// Outgoing messages always start with unknown ack status
sm.ackStatus = AckStatus::NONE;
addLiveMessage(sm);
#if ENABLE_MESSAGE_PERSISTENCE
markMessageStoreUnsaved();
#endif
} }
#if ENABLE_MESSAGE_PERSISTENCE #if ENABLE_MESSAGE_PERSISTENCE
@@ -323,28 +340,33 @@ void MessageStore::loadFromFlash()
resetMessagePool(); // reset pool when loading resetMessagePool(); // reset pool when loading
#ifdef FSCom #ifdef FSCom
concurrency::LockGuard guard(spiLock); {
concurrency::LockGuard guard(spiLock);
if (!FSCom.exists(filename.c_str())) if (!FSCom.exists(filename.c_str()))
return; return;
auto f = FSCom.open(filename.c_str(), FILE_O_READ); auto f = FSCom.open(filename.c_str(), FILE_O_READ);
if (!f) if (!f)
return; return;
uint8_t count = 0; uint8_t count = 0;
f.readBytes(reinterpret_cast<char *>(&count), 1); f.readBytes(reinterpret_cast<char *>(&count), 1);
if (count > MAX_MESSAGES_SAVED) if (count > MAX_MESSAGES_SAVED)
count = MAX_MESSAGES_SAVED; count = MAX_MESSAGES_SAVED;
for (uint8_t i = 0; i < count; ++i) { for (uint8_t i = 0; i < count; ++i) {
StoredMessage m; StoredMessage m;
if (!readMessageRecord(f, m)) if (!readMessageRecord(f, m))
break; break;
liveMessages.push_back(m); liveMessages.push_back(m);
}
f.close();
} }
f.close(); if (pruneHiddenMessages())
saveToFlash();
#endif #endif
// Loading messages does not trigger an autosave // Loading messages does not trigger an autosave
g_messageStoreHasUnsavedChanges = false; g_messageStoreHasUnsavedChanges = false;
@@ -406,6 +428,13 @@ template <typename Predicate> static void eraseAllMatches(std::deque<StoredMessa
} }
} }
bool MessageStore::pruneHiddenMessages()
{
const size_t before = liveMessages.size();
eraseAllMatches(liveMessages, [&](const StoredMessage &m) { return !isMessageVisible(m); });
return liveMessages.size() != before;
}
// Delete oldest message (RAM + persisted queue) // Delete oldest message (RAM + persisted queue)
void MessageStore::deleteOldestMessage() void MessageStore::deleteOldestMessage()
{ {
@@ -443,6 +472,20 @@ void MessageStore::deleteAllMessagesWithPeer(uint32_t peer)
saveToFlash(); saveToFlash();
} }
void MessageStore::deleteAllMessagesFromNode(uint32_t nodeNum)
{
const uint32_t local = nodeDB->getNodeNum();
auto pred = [&](const StoredMessage &m) {
if (m.sender == nodeNum)
return true;
if (m.type != MessageType::DM_TO_US)
return false;
return m.sender == local ? m.dest == nodeNum : m.sender == nodeNum;
};
eraseAllMatches(liveMessages, pred);
saveToFlash();
}
// Delete oldest message in a direct chat with a node // Delete oldest message in a direct chat with a node
void MessageStore::deleteOldestMessageWithPeer(uint32_t peer) void MessageStore::deleteOldestMessageWithPeer(uint32_t peer)
{ {
@@ -460,7 +503,7 @@ std::deque<StoredMessage> MessageStore::getChannelMessages(uint8_t channel) cons
{ {
std::deque<StoredMessage> result; std::deque<StoredMessage> result;
for (const auto &m : liveMessages) { for (const auto &m : liveMessages) {
if (m.type == MessageType::BROADCAST && m.channelIndex == channel) { if (isMessageVisible(m) && m.type == MessageType::BROADCAST && m.channelIndex == channel) {
result.push_back(m); result.push_back(m);
} }
} }
@@ -471,13 +514,22 @@ std::deque<StoredMessage> MessageStore::getDirectMessages() const
{ {
std::deque<StoredMessage> result; std::deque<StoredMessage> result;
for (const auto &m : liveMessages) { for (const auto &m : liveMessages) {
if (m.type == MessageType::DM_TO_US) { if (isMessageVisible(m) && m.type == MessageType::DM_TO_US) {
result.push_back(m); result.push_back(m);
} }
} }
return result; return result;
} }
bool MessageStore::hasVisibleMessages() const
{
for (const auto &m : liveMessages) {
if (isMessageVisible(m))
return true;
}
return false;
}
// Upgrade boot-relative timestamps once RTC is valid // Upgrade boot-relative timestamps once RTC is valid
// Only same-boot boot-relative messages are healed. // Only same-boot boot-relative messages are healed.
// Persisted boot-relative messages from old boots stay ??? forever. // Persisted boot-relative messages from old boots stay ??? forever.
+7 -5
View File
@@ -93,10 +93,8 @@ class MessageStore
void addLiveMessage(StoredMessage &&msg); void addLiveMessage(StoredMessage &&msg);
void addLiveMessage(const StoredMessage &msg); // convenience overload void addLiveMessage(const StoredMessage &msg); // convenience overload
const std::deque<StoredMessage> &getLiveMessages() const { return liveMessages; } const std::deque<StoredMessage> &getLiveMessages() const { return liveMessages; }
// Add new messages from packets. Returns nullptr if the packet is filtered out.
// Add new messages from packets or manual input const StoredMessage *tryAddFromPacket(const meshtastic_MeshPacket &mp); // Incoming/outgoing -> RAM only
const StoredMessage &addFromPacket(const meshtastic_MeshPacket &mp); // Incoming/outgoing → RAM only
void addFromString(uint32_t sender, uint8_t channelIndex, const std::string &text); // Manual add
// Persistence methods (used only on boot/shutdown) // Persistence methods (used only on boot/shutdown)
void saveToFlash(); // Save messages to flash void saveToFlash(); // Save messages to flash
@@ -111,13 +109,16 @@ class MessageStore
void deleteOldestMessageWithPeer(uint32_t peer); void deleteOldestMessageWithPeer(uint32_t peer);
void deleteAllMessagesInChannel(uint8_t channel); void deleteAllMessagesInChannel(uint8_t channel);
void deleteAllMessagesWithPeer(uint32_t peer); void deleteAllMessagesWithPeer(uint32_t peer);
void deleteAllMessagesFromNode(uint32_t nodeNum);
// Unified accessor (for UI code, defaults to RAM buffer) // Unified accessor (for UI code, defaults to RAM buffer)
const std::deque<StoredMessage> &getMessages() const { return liveMessages; } const std::deque<StoredMessage> &getMessages() const { return liveMessages; }
bool hasVisibleMessages() const;
// Helper filters for future use // Helper filters for future use
std::deque<StoredMessage> getChannelMessages(uint8_t channel) const; // Only broadcast messages on a channel std::deque<StoredMessage> getChannelMessages(uint8_t channel) const; // Only broadcast messages on a channel
std::deque<StoredMessage> getDirectMessages() const; // Only direct messages std::deque<StoredMessage> getDirectMessages() const; // Only direct messages
bool shouldStorePacket(const meshtastic_MeshPacket &mp) const;
bool isMessageVisible(const StoredMessage &msg) const;
// Upgrade boot-relative timestamps once RTC is valid // Upgrade boot-relative timestamps once RTC is valid
void upgradeBootRelativeTimestamps(); void upgradeBootRelativeTimestamps();
@@ -129,6 +130,7 @@ class MessageStore
static uint16_t storeText(const char *src, size_t len); static uint16_t storeText(const char *src, size_t len);
private: private:
bool pruneHiddenMessages();
std::deque<StoredMessage> liveMessages; // Single in-RAM message buffer (also used for persistence) std::deque<StoredMessage> liveMessages; // Single in-RAM message buffer (also used for persistence)
std::string filename; // Flash filename for persistence std::string filename; // Flash filename for persistence
}; };
-1
View File
@@ -868,7 +868,6 @@ void Power::reboot()
Wire.end(); Wire.end();
Serial1.end(); Serial1.end();
if (screen) { if (screen) {
delete screen;
screen = nullptr; screen = nullptr;
} }
LOG_DEBUG("final reboot!"); LOG_DEBUG("final reboot!");
-10
View File
@@ -57,16 +57,6 @@ void consoleInit()
DEBUG_PORT.rpInit(); // Simply sets up semaphore DEBUG_PORT.rpInit(); // Simply sets up semaphore
} }
/// Print and flush an unclassified formatted console message.
void consolePrintf(const char *format, ...)
{
va_list arg;
va_start(arg, format);
console->vprintf(nullptr, format, arg);
va_end(arg);
console->flush();
}
/// Initialize console, protobuf transport, serial port, and worker thread state. /// Initialize console, protobuf transport, serial port, and worker thread state.
SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), concurrency::OSThread("SerialConsole") SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), concurrency::OSThread("SerialConsole")
{ {
-1
View File
@@ -67,7 +67,6 @@ class SerialConsole : public StreamAPI, public RedirectablePrint, private concur
}; };
// A simple wrapper to allow non class aware code write to the console // A simple wrapper to allow non class aware code write to the console
void consolePrintf(const char *format, ...);
void consoleInit(); void consoleInit();
extern SerialConsole *console; extern SerialConsole *console;
-12
View File
@@ -194,18 +194,6 @@ void playBoop()
playTones(melody, sizeof(melody) / sizeof(ToneDuration)); playTones(melody, sizeof(melody) / sizeof(ToneDuration));
} }
void playLongPressLeadUp()
{
// An ascending lead-up sequence for long press - builds anticipation
ToneDuration melody[] = {
{NOTE_C3, 100}, // Start low
{NOTE_E3, 100}, // Step up
{NOTE_G3, 100}, // Keep climbing
{NOTE_B3, 150} // Peak with longer note for emphasis
};
playTones(melody, sizeof(melody) / sizeof(ToneDuration));
}
// Static state for progressive lead-up notes // Static state for progressive lead-up notes
static int leadUpNoteIndex = 0; static int leadUpNoteIndex = 0;
static const ToneDuration leadUpNotes[] = { static const ToneDuration leadUpNotes[] = {
-1
View File
@@ -12,6 +12,5 @@ void play4ClickUp();
void playBoop(); void playBoop();
void playChirp(); void playChirp();
void playClick(); void playClick();
void playLongPressLeadUp();
bool playNextLeadUpNote(); // Play the next note in the lead-up sequence bool playNextLeadUpNote(); // Play the next note in the lead-up sequence
void resetLeadUpSequence(); // Reset the lead-up sequence to start from beginning void resetLeadUpSequence(); // Reset the lead-up sequence to start from beginning
+1 -1
View File
@@ -18,7 +18,7 @@
Only for cases where we can know it (ESP32 or known screen) we can do this. Only for cases where we can know it (ESP32 or known screen) we can do this.
*/ */
extern graphics::Screen *screen; extern std::unique_ptr<graphics::Screen> screen;
class ReClockI2C class ReClockI2C
{ {
+69 -9
View File
@@ -220,6 +220,44 @@ bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
} }
#endif #endif
// Everest Semiconductor ES7210 4-channel audio ADC, as found on the T-Deck. Its AD1/AD0 straps
// select 0x40..0x43, so it lands squarely on the INA/SHT2X addresses. Chip ID registers and their
// reset values, per the ES7210 datasheet rev 2.0.
static constexpr uint8_t ES7210_CHIP_ID1_REG = 0x3D;
static constexpr uint8_t ES7210_CHIP_ID1 = 0x72;
static constexpr uint8_t ES7210_CHIP_ID0_REG = 0x3E;
static constexpr uint8_t ES7210_CHIP_ID0 = 0x10;
static bool readByteRegister(TwoWire *i2cBus, uint8_t address, uint8_t reg, uint8_t &value)
{
i2cBus->beginTransmission(address);
i2cBus->write(reg);
if (i2cBus->endTransmission() != 0)
return false;
if (i2cBus->requestFrom(address, (uint8_t)1) != 1 || !i2cBus->available())
return false;
value = i2cBus->read();
return true;
}
// The INA219 has no manufacturer or die ID register to check, so it is inferred from "something
// answered here and it wasn't anything else we know". That makes positively identifying the other
// occupants of this address the only way to keep them out of the fallback.
static bool detectES7210(TwoWire *i2cBus, uint8_t address)
{
uint8_t id = 0;
// Read the high ID byte first so anything that clearly isn't an ES7210 costs a single
// transaction, then confirm with the low byte.
if (!readByteRegister(i2cBus, address, ES7210_CHIP_ID1_REG, id) || id != ES7210_CHIP_ID1)
return false;
return readByteRegister(i2cBus, address, ES7210_CHIP_ID0_REG, id) && id == ES7210_CHIP_ID0;
}
#define SCAN_SIMPLE_CASE(ADDR, T, ...) \ #define SCAN_SIMPLE_CASE(ADDR, T, ...) \
case ADDR: \ case ADDR: \
logFoundDevice(__VA_ARGS__); \ logFoundDevice(__VA_ARGS__); \
@@ -472,13 +510,23 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
type = INA260; type = INA260;
} }
} }
// The ES7210 audio ADC shares this address on some boards (T-Deck). It answers
// none of the checks above, so identify it here and leave the type unset - driving
// an audio codec as if it were a power monitor crashes the device. See #11115.
if (type == NONE && detectES7210(i2cBus, (uint8_t)addr.address)) {
LOG_INFO("ES7210 audio codec at 0x%x, not a power sensor", (uint8_t)addr.address);
break;
}
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
if (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) { if (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address); logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address);
type = SHTXX; type = SHTXX;
} }
#endif #endif
else { // Assume INA219 if none of the above ones are found // Guarded on the type rather than chained to the SHT2X check above, so that a
// positively identified INA226/INA260 isn't overwritten right after being found.
if (type == NONE) { // Assume INA219 if none of the above ones are found
logFoundDevice("INA219", (uint8_t)addr.address); logFoundDevice("INA219", (uint8_t)addr.address);
type = INA219; type = INA219;
} }
@@ -857,15 +905,27 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break; break;
case 0x48: { case 0x48: {
i2cBus->beginTransmission(addr.address); // T=1oI2C soft reset; an SE050 answers A5 E0 00 3F 19. requestFrom() is
uint8_t getInfo[] = {0x5A, 0xC0, 0x00, 0xFF, 0xFC}; // required: readBytes() only drains the RX buffer requestFrom() fills.
uint8_t expectedInfo[] = {0xa5, 0xE0, 0x00, 0x3F, 0x19}; const uint8_t getInfo[] = {0x5A, 0xC0, 0x00, 0xFF, 0xFC};
uint8_t info[5]; const uint8_t expectedInfo[] = {0xA5, 0xE0, 0x00, 0x3F, 0x19};
uint8_t info[sizeof(expectedInfo)] = {0};
size_t len = 0; size_t len = 0;
i2cBus->write(getInfo, 5); bool isSE050 = false;
i2cBus->endTransmission();
len = i2cBus->readBytes(info, 5); i2cBus->beginTransmission(addr.address);
if (len == 5 && memcmp(expectedInfo, info, len) == 0) { i2cBus->write(getInfo, sizeof(getInfo));
if (i2cBus->endTransmission() == 0) {
delay(2); // guard time before the answer can be read back
len = i2cBus->requestFrom((uint8_t)addr.address, (uint8_t)sizeof(info));
if (len == sizeof(info)) {
for (size_t i = 0; i < sizeof(info); i++)
info[i] = i2cBus->read();
isSE050 = (memcmp(expectedInfo, info, sizeof(info)) == 0);
}
}
if (isSE050) {
LOG_INFO("NXP SE050 crypto chip found"); LOG_INFO("NXP SE050 crypto chip found");
type = NXP_SE050; type = NXP_SE050;
break; break;
-5
View File
@@ -2201,11 +2201,6 @@ bool GPS::hasLock()
return false; return false;
} }
bool GPS::hasFlow()
{
return reader.passedChecksum() > 0;
}
bool GPS::whileActive() bool GPS::whileActive()
{ {
unsigned int charsInBuf = 0; unsigned int charsInBuf = 0;
-3
View File
@@ -111,9 +111,6 @@ class GPS : private concurrency::OSThread
/// Returns true if we have acquired GPS lock. /// Returns true if we have acquired GPS lock.
virtual bool hasLock(); virtual bool hasLock();
/// Returns true if there's valid data flow with the chip.
virtual bool hasFlow();
/// Return true if we are connected to a GPS /// Return true if we are connected to a GPS
bool isConnected() const { return hasGPS; } bool isConnected() const { return hasGPS; }
-28
View File
@@ -496,34 +496,6 @@ float GeoCoord::rangeMetersToRadians(double range_meters)
return (PI / (180 * 60)) * distance_nm; return (PI / (180 * 60)) * distance_nm;
} }
/**
* Ported from http://www.edwilliams.org/avform147.htm#Intro
* @brief Convert from radians to range in meters on a great circle
* @param range_radians
* The range in radians
* @return Range in meters on a great circle
*/
float GeoCoord::rangeRadiansToMeters(double range_radians)
{
double distance_nm = ((180 * 60) / PI) * range_radians;
// 1 meter is 0.000539957 nm
return distance_nm * 0.000539957;
}
// Find distance from point to passed in point
int32_t GeoCoord::distanceTo(const GeoCoord &pointB)
{
return latLongToMeter(this->getLatitude() * 1e-7, this->getLongitude() * 1e-7, pointB.getLatitude() * 1e-7,
pointB.getLongitude() * 1e-7);
}
// Find bearing from point to passed in point
int32_t GeoCoord::bearingTo(const GeoCoord &pointB)
{
return bearing(this->getLatitude() * 1e-7, this->getLongitude() * 1e-7, pointB.getLatitude() * 1e-7,
pointB.getLongitude() * 1e-7);
}
/** /**
* Create a new point based on the passed-in point * Create a new point based on the passed-in point
* Ported from http://www.edwilliams.org/avform147.htm#LL * Ported from http://www.edwilliams.org/avform147.htm#LL
-3
View File
@@ -103,7 +103,6 @@ class GeoCoord
static void convertWGS84ToOSGB36(const double lat, const double lon, double &osgb_Latitude, double &osgb_Longitude); 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 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 bearing(double lat1, double lon1, double lat2, double lon2);
static float rangeRadiansToMeters(double range_radians);
static float rangeMetersToRadians(double range_meters); static float rangeMetersToRadians(double range_meters);
static unsigned int bearingToDegrees(const char *bearing); static unsigned int bearingToDegrees(const char *bearing);
static const char *degreesToBearing(unsigned int degrees); static const char *degreesToBearing(unsigned int degrees);
@@ -114,8 +113,6 @@ class GeoCoord
static double toDegrees(double r); static double toDegrees(double r);
// Point to point conversions // Point to point conversions
int32_t distanceTo(const GeoCoord &pointB);
int32_t bearingTo(const GeoCoord &pointB);
std::shared_ptr<GeoCoord> pointAtDistance(double bearing, double range); std::shared_ptr<GeoCoord> pointAtDistance(double bearing, double range);
// Lat lon alt getters // Lat lon alt getters
+12 -3
View File
@@ -319,7 +319,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
#else #else
rtc.initI2C(); rtc.initI2C();
#endif #endif
tm *t = gmtime(&tv->tv_sec); // 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);
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); 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, 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); t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
@@ -341,7 +344,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
#else #else
rtc.begin(Wire); rtc.begin(Wire);
#endif #endif
tm *t = gmtime(&tv->tv_sec); // 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);
rtc.setDateTime(*t); rtc.setDateTime(*t);
LOG_DEBUG("%s setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t->tm_year + 1900, t->tm_mon + 1, 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); t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
@@ -355,7 +361,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
#else #else
ArtronShop_RX8130CE rtc(&Wire); ArtronShop_RX8130CE rtc(&Wire);
#endif #endif
tm *t = gmtime(&tv->tv_sec); // 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);
if (rtc.setTime(*t)) { if (rtc.setTime(*t)) {
LOG_DEBUG("RX8130CE setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, 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); t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
+22 -2
View File
@@ -77,12 +77,13 @@ EInkParallelDisplay::~EInkParallelDisplay()
bool EInkParallelDisplay::connect() bool EInkParallelDisplay::connect()
{ {
LOG_INFO("Do EPD init"); LOG_INFO("Do EPD init");
int initRc = BBEP_SUCCESS;
if (!epaper) { if (!epaper) {
epaper = new FASTEPD; epaper = new FASTEPD;
#if defined(T5_S3_EPAPER_PRO_V1) #if defined(T5_S3_EPAPER_PRO_V1)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000); initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
#elif defined(T5_S3_EPAPER_PRO_V2) #elif defined(T5_S3_EPAPER_PRO_V2)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000); initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
// initialize all port 0 pins (0-7) as outputs / HIGH // initialize all port 0 pins (0-7) as outputs / HIGH
for (int i = 0; i < 8; i++) { for (int i = 0; i < 8; i++) {
epaper->ioPinMode(i, OUTPUT); epaper->ioPinMode(i, OUTPUT);
@@ -93,6 +94,16 @@ bool EInkParallelDisplay::connect()
#endif #endif
} }
// FastEPD allocates its framebuffer only from PSRAM; if PSRAM init failed the alloc returns
// NULL and initPanel() returns an error, so clearWhite() below would memset(NULL). Skip EInk
// bring-up but return true so OLEDDisplay still allocates its base buffer (base draw ops stay
// safe); displayReady stays false so the FastEPD push paths no-op -> node runs headless.
if (initRc != BBEP_SUCCESS || epaper->currentBuffer() == nullptr) {
LOG_ERROR("EPD framebuffer unavailable (initPanel rc=%d, PSRAM=%u); running headless", initRc,
(unsigned)ESP.getPsramSize());
return true;
}
// epaper->setRotation(rotation); // does not work, messes up width/height // epaper->setRotation(rotation); // does not work, messes up width/height
epaper->setMode(BB_MODE_1BPP); epaper->setMode(BB_MODE_1BPP);
epaper->clearWhite(); epaper->clearWhite();
@@ -103,6 +114,7 @@ bool EInkParallelDisplay::connect()
resetGhostPixelTracking(); resetGhostPixelTracking();
#endif #endif
displayReady = true;
return true; return true;
} }
@@ -187,6 +199,9 @@ void EInkParallelDisplay::asyncFullUpdateTask(void *pvParameters)
*/ */
void EInkParallelDisplay::display(void) void EInkParallelDisplay::display(void)
{ {
if (!displayReady) // no framebuffer (PSRAM absent / init failed) -> nothing to push
return;
const uint16_t w = this->displayWidth; const uint16_t w = this->displayWidth;
const uint16_t h = this->displayHeight; const uint16_t h = this->displayHeight;
@@ -400,6 +415,9 @@ void EInkParallelDisplay::resetGhostPixelTracking()
*/ */
bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit) bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit)
{ {
if (!displayReady)
return false;
uint32_t now = millis(); uint32_t now = millis();
if (lastDrawMsec == 0 || (now - lastDrawMsec) > msecLimit) { if (lastDrawMsec == 0 || (now - lastDrawMsec) > msecLimit) {
display(); display();
@@ -410,6 +428,8 @@ bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit)
void EInkParallelDisplay::endUpdate() void EInkParallelDisplay::endUpdate()
{ {
if (!displayReady)
return;
{ {
// ensure any async full update is started/completed // ensure any async full update is started/completed
if (asyncFullRunning.load()) { if (asyncFullRunning.load()) {
+3
View File
@@ -40,6 +40,9 @@ class EInkParallelDisplay : public OLEDDisplay
uint32_t lastDrawMsec = 0; uint32_t lastDrawMsec = 0;
FASTEPD *epaper; FASTEPD *epaper;
// Set only when connect() fully succeeds; framebuffer-touching methods no-op while false.
bool displayReady = false;
private: private:
// Async full-refresh support // Async full-refresh support
std::atomic<bool> asyncFullRunning{false}; std::atomic<bool> asyncFullRunning{false};
+2 -3
View File
@@ -41,9 +41,8 @@ bool HUB75Display::connect()
cfg.i2sspeed = HUB75_I2S_CFG::HZ_8M; // timing headroom (signal integrity) cfg.i2sspeed = HUB75_I2S_CFG::HZ_8M; // timing headroom (signal integrity)
cfg.latch_blanking = 4; // clean row transitions cfg.latch_blanking = 4; // clean row transitions
cfg.clkphase = false; // inverted clock phase: fixes 1px skew of lower half cfg.clkphase = false; // inverted clock phase: fixes 1px skew of lower half
cfg.double_buff = false; // single buffer: halves the internal-SRAM DMA cfg.double_buff = false; // single buffer halves the internal-SRAM DMA
// footprint. display() draws directly into the // footprint; display() dirty-diffs into the live buffer
// live buffer with a dirty-diff
matrix = new MatrixPanel_I2S_DMA(cfg); matrix = new MatrixPanel_I2S_DMA(cfg);
bool ok = matrix->begin(); bool ok = matrix->begin();
+6 -40
View File
@@ -1846,40 +1846,6 @@ void Screen::handleStartFirmwareUpdateScreen()
setFrameImmediateDraw(frames); setFrameImmediateDraw(frames);
} }
void Screen::blink()
{
#ifdef MESHTASTIC_LOCKDOWN
// L4: defensive guard. blink() paints arbitrary geometry, not node
// data, so it doesn't actually leak today. But it bypasses the normal
// ui->update() path that the lockdown short-circuit gates, so any
// future change that puts content into blink would silently leak past
// redaction. Refuse to draw when the redaction latch is set.
if (meshtastic_security::shouldRedactDisplay())
return;
#endif
setFastFramerate();
uint8_t count = 10;
dispdev->setBrightness(254);
while (count > 0) {
dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight());
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
dispdev->display();
delay(50);
dispdev->clear();
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
dispdev->display();
delay(50);
count = count - 1;
}
// The dispdev->setBrightness does not work for t-deck display, it seems to run the setBrightness function in
// OLEDDisplay.
dispdev->setBrightness(brightness);
}
void Screen::increaseBrightness() void Screen::increaseBrightness()
{ {
brightness = ((brightness + 62) > 254) ? brightness : (brightness + 62); brightness = ((brightness + 62) > 254) ? brightness : (brightness + 62);
@@ -2110,7 +2076,7 @@ int Screen::handleInputEvent(const InputEvent *event)
if (ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) { if (ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) {
if (event->inputEvent == INPUT_BROKER_UP) { if (event->inputEvent == INPUT_BROKER_UP) {
if (messageStore.getMessages().empty()) { if (!messageStore.hasVisibleMessages()) {
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST);
} else { } else {
graphics::MessageRenderer::scrollUp(); graphics::MessageRenderer::scrollUp();
@@ -2120,7 +2086,7 @@ int Screen::handleInputEvent(const InputEvent *event)
} }
if (event->inputEvent == INPUT_BROKER_DOWN) { if (event->inputEvent == INPUT_BROKER_DOWN) {
if (messageStore.getMessages().empty()) { if (!messageStore.hasVisibleMessages()) {
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST);
} else { } else {
graphics::MessageRenderer::scrollDown(); graphics::MessageRenderer::scrollDown();
@@ -2169,9 +2135,9 @@ int Screen::handleInputEvent(const InputEvent *event)
if (!inputIntercepted) { if (!inputIntercepted) {
#if defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2 #if defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2
bool handledEncoderScroll = false; bool handledEncoderScroll = false;
const bool isTextMessageFrame = (framesetInfo.positions.textMessage != 255 && const bool isTextMessageFrame =
this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage && (framesetInfo.positions.textMessage != 255 &&
!messageStore.getMessages().empty()); this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage && messageStore.hasVisibleMessages());
if (isTextMessageFrame) { if (isTextMessageFrame) {
if (event->inputEvent == INPUT_BROKER_UP_LONG) { if (event->inputEvent == INPUT_BROKER_UP_LONG) {
graphics::MessageRenderer::nudgeScroll(-1); graphics::MessageRenderer::nudgeScroll(-1);
@@ -2254,7 +2220,7 @@ int Screen::handleInputEvent(const InputEvent *event)
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.lora) { } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.lora) {
menuHandler::loraMenu(); menuHandler::loraMenu();
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) { } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) {
if (!messageStore.getMessages().empty()) { if (messageStore.hasVisibleMessages()) {
menuHandler::messageResponseMenu(); menuHandler::messageResponseMenu();
} else { } else {
if (currentResolution == ScreenResolution::UltraLow) { if (currentResolution == ScreenResolution::UltraLow) {
+2 -3
View File
@@ -6,6 +6,7 @@
#include "mesh/generated/meshtastic/config.pb.h" #include "mesh/generated/meshtastic/config.pb.h"
#include <OLEDDisplay.h> #include <OLEDDisplay.h>
#include <functional> #include <functional>
#include <memory>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -310,8 +311,6 @@ class Screen : public concurrency::OSThread
*/ */
void doDeepSleep(); void doDeepSleep();
void blink();
// Draw north // Draw north
float estimatedHeading(double lat, double lon); float estimatedHeading(double lat, double lon);
@@ -844,6 +843,6 @@ class Screen : public concurrency::OSThread
// Extern declarations for function symbols used in UIRenderer // Extern declarations for function symbols used in UIRenderer
extern std::vector<std::string> functionSymbol; extern std::vector<std::string> functionSymbol;
extern std::string functionSymbolString; extern std::string functionSymbolString;
extern graphics::Screen *screen; extern std::unique_ptr<graphics::Screen> screen;
#endif #endif
-5
View File
@@ -718,11 +718,6 @@ void VirtualKeyboard::setInputText(const std::string &text)
inputText = text; inputText = text;
} }
std::string VirtualKeyboard::getInputText() const
{
return inputText;
}
void VirtualKeyboard::setHeader(const std::string &header) void VirtualKeyboard::setHeader(const std::string &header)
{ {
headerText = header; headerText = header;
-1
View File
@@ -27,7 +27,6 @@ class VirtualKeyboard
void draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY); void draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY);
void setInputText(const std::string &text); void setInputText(const std::string &text);
std::string getInputText() const;
void setHeader(const std::string &header); void setHeader(const std::string &header);
void setCallback(std::function<void(const std::string &)> callback); void setCallback(std::function<void(const std::string &)> callback);
+1 -125
View File
@@ -38,7 +38,7 @@
using namespace meshtastic; using namespace meshtastic;
// External variables // External variables
extern graphics::Screen *screen; extern std::unique_ptr<graphics::Screen> screen;
extern PowerStatus *powerStatus; extern PowerStatus *powerStatus;
extern NodeStatus *nodeStatus; extern NodeStatus *nodeStatus;
extern GPSStatus *gpsStatus; extern GPSStatus *gpsStatus;
@@ -230,131 +230,7 @@ void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, i
#endif #endif
} }
void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
display->setFont(FONT_SMALL);
// The coordinates define the left starting point of the text
display->setTextAlignment(TEXT_ALIGN_LEFT);
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) {
display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL);
display->setColor(BLACK);
}
char batStr[20];
if (powerStatus->getHasBattery()) {
int batV = powerStatus->getBatteryVoltageMv() / 1000;
int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10;
snprintf(batStr, sizeof(batStr), "B %01d.%02dV %3d%% %c%c", batV, batCv, powerStatus->getBatteryChargePercent(),
powerStatus->getIsCharging() ? '+' : ' ', powerStatus->getHasUSB() ? 'U' : ' ');
// Line 1
display->drawString(x, y, batStr);
if (config.display.heading_bold)
display->drawString(x + 1, y, batStr);
} else {
// Line 1
display->drawString(x, y, "USB");
if (config.display.heading_bold)
display->drawString(x + 1, y, "USB");
}
uint32_t currentMillis = millis();
uint32_t seconds = currentMillis / 1000;
uint32_t minutes = seconds / 60;
uint32_t hours = minutes / 60;
uint32_t days = hours / 24;
// currentMillis %= 1000;
// seconds %= 60;
// minutes %= 60;
// hours %= 24;
// Show uptime as days, hours, minutes OR seconds
std::string uptime = UIRenderer::drawTimeDelta(days, hours, minutes, seconds);
// Line 1 (Still)
if (currentResolution != graphics::ScreenResolution::UltraLow) {
display->drawString(x + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str());
if (config.display.heading_bold)
display->drawString(x - 1 + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str());
display->setColor(WHITE);
}
// Setup string to assemble analogClock string
std::string analogClock = "";
uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
if (rtc_sec > 0) {
long hms = rtc_sec % SEC_PER_DAY;
// hms += tz.tz_dsttime * SEC_PER_HOUR;
// hms -= tz.tz_minuteswest * SEC_PER_MIN;
// mod `hms` to ensure in positive range of [0...SEC_PER_DAY)
hms = (hms + SEC_PER_DAY) % SEC_PER_DAY;
// Tear apart hms into h:m:s
int hour, min, sec;
graphics::decomposeTime(rtc_sec, hour, min, sec);
char timebuf[12];
if (config.display.use_12h_clock) {
std::string meridiem = "am";
if (hour >= 12) {
if (hour > 12)
hour -= 12;
meridiem = "pm";
}
if (hour == 00) {
hour = 12;
}
snprintf(timebuf, sizeof(timebuf), "%d:%02d:%02d%s", hour, min, sec, meridiem.c_str());
} else {
snprintf(timebuf, sizeof(timebuf), "%02d:%02d:%02d", hour, min, sec);
}
analogClock += timebuf;
}
// Line 2
display->drawString(x, y + FONT_HEIGHT_SMALL * 1, analogClock.c_str());
// Display Channel Utilization
char chUtil[13];
snprintf(chUtil, sizeof(chUtil), "ChUtil %2.0f%%", airTime->channelUtilizationPercent());
display->drawString(x + SCREEN_WIDTH - display->getStringWidth(chUtil), y + FONT_HEIGHT_SMALL * 1, chUtil);
#if HAS_GPS
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
// Line 3
if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_DMS) // if DMS then don't draw altitude
UIRenderer::drawGpsAltitude(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus);
// Line 4
UIRenderer::drawGpsCoordinates(display, x, y + FONT_HEIGHT_SMALL * 3, gpsStatus);
} else {
UIRenderer::drawGpsPowerStatus(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus);
}
#endif
/* Display a heartbeat pixel that blinks every time the frame is redrawn */
#ifdef SHOW_REDRAWS
if (heartbeat)
display->setPixel(0, 0);
heartbeat = !heartbeat;
#endif
}
// Trampoline functions for DebugInfo class access // Trampoline functions for DebugInfo class access
void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
drawFrame(display, state, x, y);
}
void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
drawFrameSettings(display, state, x, y);
}
void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{ {
drawFrameWiFi(display, state, x, y); drawFrameWiFi(display, state, x, y);
-3
View File
@@ -20,12 +20,9 @@ namespace DebugRenderer
{ {
// Debug frame functions // Debug frame functions
void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
// Trampoline functions for framework callback compatibility // Trampoline functions for framework callback compatibility
void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
// LoRa information display // LoRa information display
+14 -13
View File
@@ -70,12 +70,15 @@ const StoredMessage *getNewestMessageForActiveThread()
const uint32_t peer = graphics::MessageRenderer::getThreadPeer(); const uint32_t peer = graphics::MessageRenderer::getThreadPeer();
const uint32_t localNode = nodeDB->getNodeNum(); const uint32_t localNode = nodeDB->getNodeNum();
if (mode == graphics::MessageRenderer::ThreadMode::ALL) {
return &messages.back();
}
for (auto it = messages.rbegin(); it != messages.rend(); ++it) { for (auto it = messages.rbegin(); it != messages.rend(); ++it) {
const StoredMessage &m = *it; const StoredMessage &m = *it;
if (!messageStore.isMessageVisible(m)) {
continue;
}
if (mode == graphics::MessageRenderer::ThreadMode::ALL) {
return &m;
}
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
if (m.type == MessageType::BROADCAST && static_cast<int>(m.channelIndex) == channel) { if (m.type == MessageType::BROADCAST && static_cast<int>(m.channelIndex) == channel) {
@@ -282,21 +285,19 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
return; return;
} }
// Guard: without a reboot, reconfigure() applies the region directly, so reject const RegionInfo *selectedRegionInfo = getRegion(selectedRegion);
// regions this node can't use up front: unrecognized codes, licensed-only regions, bool hamMode = selectedRegionInfo->code == selectedRegion && selectedRegionInfo->profile &&
// and radio hardware mismatches (2.4 GHz vs sub-GHz) - the same checks the admin selectedRegionInfo->profile->licensedOnly;
// set-config path applies, but side-effect-free: ignoring a menu selection should
// not record a critical error or notify clients. getRadio() used to catch hardware // Validate radio compatibility for a prospective Ham region before confirmation.
// mismatches post-reboot only.
auto candidateLora = config.lora; auto candidateLora = config.lora;
candidateLora.region = selectedRegion; candidateLora.region = selectedRegion;
char regionErr[160]; char regionErr[160] = {};
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr))) { if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr), hamMode)) {
LOG_WARN("Ignoring region selection: %s", regionErr); LOG_WARN("Ignoring region selection: %s", regionErr);
return; return;
} }
bool hamMode = getRegion(selectedRegion)->profile->licensedOnly;
if (hamMode) { if (hamMode) {
LOG_INFO("User chose an amateur radio mode region"); LOG_INFO("User chose an amateur radio mode region");
pendingRegion = selectedRegion; pendingRegion = selectedRegion;
+9 -3
View File
@@ -17,12 +17,13 @@
#include "graphics/emotes.h" #include "graphics/emotes.h"
#include "main.h" #include "main.h"
#include "meshUtils.h" #include "meshUtils.h"
#include "modules/CannedMessageModule.h"
#include <string> #include <string>
#include <vector> #include <vector>
// External declarations // External declarations
extern bool hasUnreadMessage; extern bool hasUnreadMessage;
extern graphics::Screen *screen; extern std::unique_ptr<graphics::Screen> screen;
using graphics::Emote; using graphics::Emote;
using graphics::emotes; using graphics::emotes;
@@ -403,6 +404,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
// Filter messages based on thread mode // Filter messages based on thread mode
std::deque<StoredMessage> filtered; std::deque<StoredMessage> filtered;
for (const auto &m : messageStore.getLiveMessages()) { for (const auto &m : messageStore.getLiveMessages()) {
if (!messageStore.isMessageVisible(m))
continue;
bool include = false; bool include = false;
switch (currentMode) { switch (currentMode) {
case ThreadMode::ALL: case ThreadMode::ALL:
@@ -1071,6 +1074,7 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
{ {
if (packet.from != 0) { if (packet.from != 0) {
hasUnreadMessage = true; hasUnreadMessage = true;
const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive();
// Determine if message belongs to a muted channel // Determine if message belongs to a muted channel
bool isChannelMuted = false; bool isChannelMuted = false;
@@ -1161,11 +1165,13 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
// Shorter banner if already in a conversation (Channel or Direct) // Shorter banner if already in a conversation (Channel or Direct)
bool inThread = (getThreadMode() != ThreadMode::ALL); bool inThread = (getThreadMode() != ThreadMode::ALL);
if (shouldWakeOnReceivedMessage()) { if (!suppressBanner && shouldWakeOnReceivedMessage()) {
screen->setOn(true); screen->setOn(true);
} }
screen->showSimpleBanner(banner, inThread ? 1000 : 3000); if (!suppressBanner) {
screen->showSimpleBanner(banner, inThread ? 1000 : 3000);
}
} }
// Always focus into the correct conversation thread when a message with real text arrives // Always focus into the correct conversation thread when a message with real text arrives
+1 -35
View File
@@ -17,14 +17,8 @@
#include "meshUtils.h" #include "meshUtils.h"
#include <algorithm> #include <algorithm>
// Forward declarations for functions defined in Screen.cpp
namespace graphics
{
extern bool haveGlyphs(const char *str);
} // namespace graphics
// Global screen instance // Global screen instance
extern graphics::Screen *screen; extern std::unique_ptr<graphics::Screen> screen;
#if defined(OLED_TINY) #if defined(OLED_TINY)
static uint32_t lastSwitchTime = 0; static uint32_t lastSwitchTime = 0;
@@ -180,11 +174,6 @@ unsigned long getModeCycleIntervalMs()
return 3000; return 3000;
} }
int calculateMaxScroll(int totalEntries, int visibleRows)
{
return max(0, (totalEntries - 1) / (visibleRows * 2));
}
void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd) void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd)
{ {
x = (currentResolution == ScreenResolution::High) ? x - 2 : (currentResolution == ScreenResolution::Low) ? x - 1 : x; x = (currentResolution == ScreenResolution::High) ? x - 2 : (currentResolution == ScreenResolution::Low) ? x - 1 : x;
@@ -910,29 +899,6 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, headingRadian, lat, lon); drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, headingRadian, lat, lon);
} }
/// Draw a series of fields in a column, wrapping to multiple columns if needed
void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields)
{
// The coordinates define the left starting point of the text
display->setTextAlignment(TEXT_ALIGN_LEFT);
const char **f = fields;
int xo = x, yo = y;
while (*f) {
display->drawString(xo, yo, *f);
if ((display->getColor() == BLACK) && config.display.heading_bold)
display->drawString(xo + 1, yo, *f);
display->setColor(WHITE);
yo += FONT_HEIGHT_SMALL;
if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) {
xo += SCREEN_WIDTH / 2;
yo = 0;
}
f++;
}
}
} // namespace NodeListRenderer } // namespace NodeListRenderer
} // namespace graphics } // namespace graphics
#endif #endif
-1
View File
@@ -58,7 +58,6 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
const char *getCurrentModeTitle_Nodes(int screenWidth); const char *getCurrentModeTitle_Nodes(int screenWidth);
const char *getCurrentModeTitle_Location(int screenWidth); const char *getCurrentModeTitle_Location(int screenWidth);
std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth); std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth);
void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields);
// Scrolling controls // Scrolling controls
void scrollUp(); void scrollUp();
@@ -207,8 +207,6 @@ void NotificationRenderer::resetBanner()
alertBannerMessage[0] = '\0'; alertBannerMessage[0] = '\0';
current_notification_type = notificationTypeEnum::none; current_notification_type = notificationTypeEnum::none;
OnScreenKeyboardModule::instance().clearPopup();
inEvent.inputEvent = INPUT_BROKER_NONE; inEvent.inputEvent = INPUT_BROKER_NONE;
inEvent.kbchar = 0; inEvent.kbchar = 0;
curSelected = 0; curSelected = 0;
@@ -1187,9 +1185,6 @@ void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiStat
display->setColor(WHITE); display->setColor(WHITE);
// Draw the virtual keyboard // Draw the virtual keyboard
virtualKeyboard->draw(display, 0, 0); virtualKeyboard->draw(display, 0, 0);
// Draw transient popup overlay (if any) managed by OnScreenKeyboardModule
OnScreenKeyboardModule::instance().drawPopupOverlay(display);
} else { } else {
// If virtualKeyboard is null, reset the banner to avoid getting stuck // If virtualKeyboard is null, reset the banner to avoid getting stuck
LOG_INFO("Virtual keyboard is null - resetting banner"); LOG_INFO("Virtual keyboard is null - resetting banner");
@@ -1202,12 +1197,5 @@ bool NotificationRenderer::isOverlayBannerShowing()
return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil); return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil);
} }
void NotificationRenderer::showKeyboardMessagePopupWithTitle(const char *title, const char *content, uint32_t durationMs)
{
if (!title || !content || current_notification_type != notificationTypeEnum::text_input)
return;
OnScreenKeyboardModule::instance().showPopup(title, content, durationMs);
}
} // namespace graphics } // namespace graphics
#endif #endif
-1
View File
@@ -39,7 +39,6 @@ class NotificationRenderer
static BannerFont alertBannerLineFonts[MAX_LINES + 1]; static BannerFont alertBannerLineFonts[MAX_LINES + 1];
static void parseBannerMessageWithFonts(const char *message); static void parseBannerMessageWithFonts(const char *message);
static void resetBanner(); static void resetBanner();
static void showKeyboardMessagePopupWithTitle(const char *title, const char *content, uint32_t durationMs);
static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
+5 -29
View File
@@ -28,7 +28,7 @@
#include <gps/RTC.h> #include <gps/RTC.h>
// External variables // External variables
extern graphics::Screen *screen; extern std::unique_ptr<graphics::Screen> screen;
#if defined(OLED_TINY) #if defined(OLED_TINY)
static uint32_t lastSwitchTime = 0; static uint32_t lastSwitchTime = 0;
#endif #endif
@@ -42,9 +42,9 @@ static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y)
{ {
int yOffset = (currentResolution == ScreenResolution::High) ? -5 : 1; int yOffset = (currentResolution == ScreenResolution::High) ? -5 : 1;
if (currentResolution == ScreenResolution::High) { if (currentResolution == ScreenResolution::High) {
NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite, display); NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgGPS_width, imgGPS_height, imgGPS, display);
} else { } else {
display->drawXbm(x + 1, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite); display->drawXbm(x + 1, y + yOffset, imgGPS_width, imgGPS_height, imgGPS);
} }
} }
@@ -521,9 +521,9 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht
{ {
// Draw satellite image // Draw satellite image
if (currentResolution == ScreenResolution::High) { if (currentResolution == ScreenResolution::High) {
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgSatellite_width, imgSatellite_height, imgSatellite, display); NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display);
} else { } else {
display->drawXbm(x + 1, y + 1, imgSatellite_width, imgSatellite_height, imgSatellite); display->drawXbm(x + 1, y + 1, imgGPS_width, imgGPS_height, imgGPS);
} }
char textString[10]; char textString[10];
@@ -1386,30 +1386,6 @@ int UIRenderer::formatDateTime(char *buf, size_t bufSize, uint32_t rtc_sec, OLED
return display->getStringWidth(buf); return display->getStringWidth(buf);
} }
// Check if the display can render a string (detect special chars; emoji)
bool UIRenderer::haveGlyphs(const char *str)
{
#if defined(OLED_PL) || defined(OLED_UA) || defined(OLED_RU) || defined(OLED_CS)
// Don't want to make any assumptions about custom language support
return true;
#endif
// Check each character with the lookup function for the OLED library
// We're not really meant to use this directly..
bool have = true;
for (uint16_t i = 0; i < strlen(str); i++) {
uint8_t result = Screen::customFontTableLookup((uint8_t)str[i]);
// If font doesn't support a character, it is substituted for ¿
if (result == 191 && (uint8_t)str[i] != 191) {
have = false;
break;
}
}
// LOG_DEBUG("haveGlyphs=%d", have);
return have;
}
#ifdef USE_EINK #ifdef USE_EINK
/// Used on eink displays while in deep sleep /// Used on eink displays while in deep sleep
void UIRenderer::drawDeepSleepFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) void UIRenderer::drawDeepSleepFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-3
View File
@@ -104,9 +104,6 @@ class UIRenderer
{ {
drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSpacing, fauxBold); drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSpacing, fauxBold);
} }
// Check if the display can render a string (detect special chars; emoji)
static bool haveGlyphs(const char *str);
}; // namespace UIRenderer }; // namespace UIRenderer
} // namespace graphics } // namespace graphics
+3
View File
@@ -11,6 +11,9 @@ const uint8_t SATELLITE_IMAGE[] PROGMEM = {0x00, 0x08, 0x00, 0x1C, 0x00, 0x0E, 0
const uint8_t imgSatellite[] PROGMEM = { const uint8_t imgSatellite[] PROGMEM = {
0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b11011011, 0b11111111, 0b11011011, 0b00011000, 0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b11011011, 0b11111111, 0b11011011, 0b00011000,
}; };
#define imgGPS_width 8
#define imgGPS_height 8
const unsigned char imgGPS[] PROGMEM = {0x00, 0x07, 0x39, 0x2D, 0xFF, 0x48, 0x48, 0x60};
const uint8_t imgUSB[] PROGMEM = {0x00, 0xfc, 0xf0, 0xfc, 0x88, 0xff, 0x86, 0xfe, 0x85, 0xfe, 0x89, 0xff, 0xf1, 0xfc, 0x00, 0xfc}; const uint8_t imgUSB[] PROGMEM = {0x00, 0xfc, 0xf0, 0xfc, 0x88, 0xff, 0x86, 0xfe, 0x85, 0xfe, 0x89, 0xff, 0xf1, 0xfc, 0x00, 0xfc};
const uint8_t imgUSB_HighResolution[] PROGMEM = {0x00, 0x3e, 0xf8, 0x80, 0x43, 0xf8, 0xc0, 0xc2, 0xff, 0x60, 0x42, 0xfc, const uint8_t imgUSB_HighResolution[] PROGMEM = {0x00, 0x3e, 0xf8, 0x80, 0x43, 0xf8, 0xc0, 0xc2, 0xff, 0x60, 0x42, 0xfc,
-24
View File
@@ -649,30 +649,6 @@ std::string InkHUD::Applet::getTimeString()
return getTimeString(getValidTime(RTCQuality::RTCQualityDevice, true)); return getTimeString(getValidTime(RTCQuality::RTCQualityDevice, true));
} }
// Calculate how many nodes have been seen within our preferred window of activity
// This period is set by user, via the menu
// Todo: optimize to calculate once only per WindowManager::render
uint16_t InkHUD::Applet::getActiveNodeCount()
{
// Don't even try to count nodes if RTC isn't set
// The last heard values in nodedb will be incomprehensible
if (getRTCQuality() == RTCQualityNone)
return 0;
uint16_t count = 0;
// For each node in db
for (uint16_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
// Check if heard recently, and not our own node
if (sinceLastSeen(node) < settings->recentlyActiveSeconds && node->num != nodeDB->getNodeNum())
count++;
}
return count;
}
// Get an abbreviated, human readable, distance string // Get an abbreviated, human readable, distance string
// Honors config.display.units, to offer both metric and imperial // Honors config.display.units, to offer both metric and imperial
std::string InkHUD::Applet::localizeDistance(uint32_t meters) std::string InkHUD::Applet::localizeDistance(uint32_t meters)
-1
View File
@@ -183,7 +183,6 @@ class Applet : public GFX
SignalStrength getSignalStrength(float snr, float rssi); // Interpret SNR and RSSI, as an easy to understand value SignalStrength getSignalStrength(float snr, float rssi); // Interpret SNR and RSSI, as an easy to understand value
std::string getTimeString(uint32_t epochSeconds); // Human readable std::string getTimeString(uint32_t epochSeconds); // Human readable
std::string getTimeString(); // Current time, human readable std::string getTimeString(); // Current time, human readable
uint16_t getActiveNodeCount(); // Duration determined by user, in onscreen menu
std::string localizeDistance(uint32_t meters); // Human readable distance, imperial or metric std::string localizeDistance(uint32_t meters); // Human readable distance, imperial or metric
std::string parse(const std::string &text); // Handle text which might contain special chars std::string parse(const std::string &text); // Handle text which might contain special chars
std::string parseShortName(meshtastic_NodeInfoLite *node); // Get the shortname, or a substitute if has unprintable chars std::string parseShortName(meshtastic_NodeInfoLite *node); // Get the shortname, or a substitute if has unprintable chars
@@ -2614,6 +2614,8 @@ uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight()
void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char *message) void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char *message)
{ {
meshtastic_MeshPacket *p = router->allocForSending(); meshtastic_MeshPacket *p = router->allocForSending();
if (!p)
return;
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
p->to = dest; p->to = dest;
p->channel = channel; p->channel = channel;
@@ -2622,7 +2624,7 @@ void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
// Tack on a bell character if requested // Tack on a bell character if requested
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) { if (moduleConfig.canned_message.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) {
p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character
p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Append Null Terminator p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Append Null Terminator
p->decoded.payload.size++; p->decoded.payload.size++;
@@ -234,7 +234,8 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
// Pick source of message // Pick source of message
const StoredMessage *message = const StoredMessage *message =
msgIsBroadcast ? &inkhud->persistence->latestMessage.broadcast : &inkhud->persistence->latestMessage.dm; msgIsBroadcast ? &inkhud->persistence->latestMessage.broadcast : &inkhud->persistence->latestMessage.dm;
if (!message->sender || !messageStore.isMessageVisible(*message))
return parse(text);
// Find info about the sender // Find info about the sender
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(message->sender); meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(message->sender);
@@ -270,4 +271,4 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
return parse(text); return parse(text);
} }
#endif #endif
@@ -46,7 +46,7 @@ void InkHUD::AllMessageApplet::onRender(bool full)
message = &latestMessage->dm; message = &latestMessage->dm;
// Short circuit: no text message // Short circuit: no text message
if (!message->sender) { if (!message->sender || !messageStore.isMessageVisible(*message)) {
printAt(X(0.5), Y(0.5), "No Message", CENTER, MIDDLE); printAt(X(0.5), Y(0.5), "No Message", CENTER, MIDDLE);
return; return;
} }
@@ -138,4 +138,4 @@ bool InkHUD::AllMessageApplet::approveNotification(Notification &n)
return true; return true;
} }
#endif #endif
@@ -42,7 +42,7 @@ int InkHUD::DMApplet::onReceiveTextMessage(const meshtastic_MeshPacket *p)
void InkHUD::DMApplet::onRender(bool full) void InkHUD::DMApplet::onRender(bool full)
{ {
// Abort if no text message // Abort if no text message
if (!latestMessage->dm.sender) { if (!latestMessage->dm.sender || !messageStore.isMessageVisible(latestMessage->dm)) {
printAt(X(0.5), Y(0.5), "No DMs", CENTER, MIDDLE); printAt(X(0.5), Y(0.5), "No DMs", CENTER, MIDDLE);
return; return;
} }
@@ -131,4 +131,4 @@ bool InkHUD::DMApplet::approveNotification(Notification &n)
return true; return true;
} }
#endif #endif
@@ -191,7 +191,8 @@ ProcessMessage InkHUD::ThreadedMessageApplet::handleReceived(const meshtastic_Me
return ProcessMessage::CONTINUE; return ProcessMessage::CONTINUE;
// Store in the global messageStore - this handles sender, timestamp, channel, text, and ack status // Store in the global messageStore - this handles sender, timestamp, channel, text, and ack status
messageStore.addFromPacket(mp); if (!messageStore.tryAddFromPacket(mp))
return ProcessMessage::CONTINUE;
// If this was an incoming message, suggest that our applet becomes foreground, if permitted // If this was an incoming message, suggest that our applet becomes foreground, if permitted
if (getFrom(&mp) != nodeDB->getNodeNum()) if (getFrom(&mp) != nodeDB->getNodeNum())
@@ -216,13 +217,6 @@ bool InkHUD::ThreadedMessageApplet::approveNotification(Notification &n)
return true; return true;
} }
// Save messages to flash via the global messageStore.
// The global store holds messages for all channels; no per-channel file is needed.
void InkHUD::ThreadedMessageApplet::saveMessagesToFlash()
{
messageStore.saveToFlash();
}
// Messages are loaded once by InkHUD::begin() before applets start. // Messages are loaded once by InkHUD::begin() before applets start.
// Nothing to do here at per-applet activation time. // Nothing to do here at per-applet activation time.
void InkHUD::ThreadedMessageApplet::loadMessagesFromFlash() void InkHUD::ThreadedMessageApplet::loadMessagesFromFlash()
@@ -46,7 +46,6 @@ class ThreadedMessageApplet : public Applet, public SinglePortModule
bool approveNotification(Notification &n) override; // Which notifications to suppress bool approveNotification(Notification &n) override; // Which notifications to suppress
protected: protected:
void saveMessagesToFlash();
void loadMessagesFromFlash(); void loadMessagesFromFlash();
uint8_t channelIndex = 0; uint8_t channelIndex = 0;
+7 -1
View File
@@ -534,13 +534,19 @@ int InkHUD::Events::onReceiveTextMessage(const meshtastic_MeshPacket *packet)
if (getFrom(packet) == nodeDB->getNodeNum()) if (getFrom(packet) == nodeDB->getNodeNum())
return 0; return 0;
if (!messageStore.shouldStorePacket(*packet))
return 0;
bool isBroadcastMsg = isBroadcast(packet->to); bool isBroadcastMsg = isBroadcast(packet->to);
inkhud->persistence->latestMessage.wasBroadcast = isBroadcastMsg; inkhud->persistence->latestMessage.wasBroadcast = isBroadcastMsg;
if (!isBroadcastMsg) { if (!isBroadcastMsg) {
// DMs never pass through ThreadedMessageApplet, so add them to the global store here // DMs never pass through ThreadedMessageApplet, so add them to the global store here
// so they survive reboots. Derive the latestMessage cache entry from the stored result. // so they survive reboots. Derive the latestMessage cache entry from the stored result.
inkhud->persistence->latestMessage.dm = messageStore.addFromPacket(*packet); const StoredMessage *stored = messageStore.tryAddFromPacket(*packet);
if (!stored)
return 0;
inkhud->persistence->latestMessage.dm = *stored;
} else { } else {
// Broadcasts are added to the global store by ThreadedMessageApplet::handleReceived(). // Broadcasts are added to the global store by ThreadedMessageApplet::handleReceived().
// Here we only update the latestMessage cache used by AllMessageApplet / NotificationApplet. // Here we only update the latestMessage cache used by AllMessageApplet / NotificationApplet.
-38
View File
@@ -326,44 +326,6 @@ void InkHUD::InkHUD::touchNavDown()
} }
} }
// Call this when touch input needs joystick-like left navigation independent of joystick-enabled mode
void InkHUD::InkHUD::touchNavLeft()
{
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
case 1: // 90 deg
events->onTouchNavDown();
break;
case 2: // 180 deg
events->onTouchNavRight();
break;
case 3: // 270 deg
events->onTouchNavUp();
break;
default: // 0 deg
events->onTouchNavLeft();
break;
}
}
// Call this when touch input needs joystick-like right navigation independent of joystick-enabled mode
void InkHUD::InkHUD::touchNavRight()
{
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
case 1: // 90 deg
events->onTouchNavUp();
break;
case 2: // 180 deg
events->onTouchNavLeft();
break;
case 3: // 270 deg
events->onTouchNavDown();
break;
default: // 0 deg
events->onTouchNavRight();
break;
}
}
void InkHUD::InkHUD::touchTap(uint16_t x, uint16_t y) void InkHUD::InkHUD::touchTap(uint16_t x, uint16_t y)
{ {
events->onTouchTap(x, y, false); events->onTouchTap(x, y, false);
-2
View File
@@ -71,8 +71,6 @@ class InkHUD
void navRight(); void navRight();
void touchNavUp(); void touchNavUp();
void touchNavDown(); void touchNavDown();
void touchNavLeft();
void touchNavRight();
void touchTap(uint16_t x, uint16_t y); void touchTap(uint16_t x, uint16_t y);
void touchLongPress(uint16_t x, uint16_t y); void touchLongPress(uint16_t x, uint16_t y);
@@ -26,6 +26,10 @@ void InkHUD::Persistence::loadLatestMessage()
int lastBroadcastPos = -1, lastDMPos = -1, pos = 0; int lastBroadcastPos = -1, lastDMPos = -1, pos = 0;
for (const StoredMessage &m : messageStore.getLiveMessages()) { for (const StoredMessage &m : messageStore.getLiveMessages()) {
if (!messageStore.isMessageVisible(m)) {
pos++;
continue;
}
if (m.type == MessageType::BROADCAST) { if (m.type == MessageType::BROADCAST) {
latestMessage.broadcast = m; latestMessage.broadcast = m;
lastBroadcastPos = pos; lastBroadcastPos = pos;
+4 -2
View File
@@ -496,8 +496,10 @@ We keep this separate latest-message cache for this purpose, because:
Broadcasts and DMs take different paths into `messageStore`: Broadcasts and DMs take different paths into `messageStore`:
- **Broadcasts** - `ThreadedMessageApplet::handleReceived()` calls `messageStore.addFromPacket()`. `Events::onReceiveTextMessage()` then updates `latestMessage.broadcast` separately for fast access by `AllMessageApplet` and `NotificationApplet`. - **Broadcasts** - `ThreadedMessageApplet::handleReceived()` calls `messageStore.tryAddFromPacket()`. `Events::onReceiveTextMessage()` then updates `latestMessage.broadcast` separately for fast access by `AllMessageApplet` and `NotificationApplet`.
- **DMs** - `ThreadedMessageApplet` skips DMs entirely. `Events::onReceiveTextMessage()` calls `messageStore.addFromPacket()` directly and stores the result in `latestMessage.dm`. - **DMs** - `ThreadedMessageApplet` skips DMs entirely. `Events::onReceiveTextMessage()` calls `messageStore.tryAddFromPacket()` directly and stores the result in `latestMessage.dm`.
`tryAddFromPacket()` returns `nullptr` when the packet is filtered out (see `shouldStorePacket()`), so both paths must null-check before use.
#### Saving / Loading #### Saving / Loading
-8
View File
@@ -117,14 +117,6 @@ void TwoButton::setHandlerDown(uint8_t whichButton, Callback onDown)
buttons[whichButton].onDown = onDown; buttons[whichButton].onDown = onDown;
} }
// Set what should happen when a button becomes unpressed
// Use this to implement a "While held" behavior
void TwoButton::setHandlerUp(uint8_t whichButton, Callback onUp)
{
assert(whichButton < 2);
buttons[whichButton].onUp = onUp;
}
// Set what should happen when a "short press" event has occurred // Set what should happen when a "short press" event has occurred
void TwoButton::setHandlerShortPress(uint8_t whichButton, Callback onShortPress) void TwoButton::setHandlerShortPress(uint8_t whichButton, Callback onShortPress)
{ {
-1
View File
@@ -38,7 +38,6 @@ class TwoButton : protected concurrency::OSThread
void setWiring(uint8_t whichButton, uint8_t pin, bool internalPullup = false); void setWiring(uint8_t whichButton, uint8_t pin, bool internalPullup = false);
void setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs); void setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs);
void setHandlerDown(uint8_t whichButton, Callback onDown); void setHandlerDown(uint8_t whichButton, Callback onDown);
void setHandlerUp(uint8_t whichButton, Callback onUp);
void setHandlerShortPress(uint8_t whichButton, Callback onShortPress); void setHandlerShortPress(uint8_t whichButton, Callback onShortPress);
void setHandlerLongPress(uint8_t whichButton, Callback onLongPress); void setHandlerLongPress(uint8_t whichButton, Callback onLongPress);
@@ -194,14 +194,6 @@ void TwoButtonExtended::setHandlerDown(uint8_t whichButton, Callback onDown)
buttons[whichButton].onDown = onDown; buttons[whichButton].onDown = onDown;
} }
// Set what should happen when a button becomes unpressed
// Use this to implement a "While held" behavior
void TwoButtonExtended::setHandlerUp(uint8_t whichButton, Callback onUp)
{
assert(whichButton < 2);
buttons[whichButton].onUp = onUp;
}
// Set what should happen when a "short press" event has occurred // Set what should happen when a "short press" event has occurred
void TwoButtonExtended::setHandlerShortPress(uint8_t whichButton, Callback onPress) void TwoButtonExtended::setHandlerShortPress(uint8_t whichButton, Callback onPress)
{ {
@@ -217,26 +209,6 @@ void TwoButtonExtended::setHandlerLongPress(uint8_t whichButton, Callback onLong
buttons[whichButton].onLongPress = onLongPress; buttons[whichButton].onLongPress = onLongPress;
} }
// Set what should happen when a joystick button becomes pressed
// Use this to implement a "while held" behavior
void TwoButtonExtended::setJoystickDownHandlers(Callback uDown, Callback dDown, Callback lDown, Callback rDown)
{
joystick[Direction::UP].onDown = uDown;
joystick[Direction::DOWN].onDown = dDown;
joystick[Direction::LEFT].onDown = lDown;
joystick[Direction::RIGHT].onDown = rDown;
}
// Set what should happen when a joystick button becomes unpressed
// Use this to implement a "while held" behavior
void TwoButtonExtended::setJoystickUpHandlers(Callback uUp, Callback dUp, Callback lUp, Callback rUp)
{
joystick[Direction::UP].onUp = uUp;
joystick[Direction::DOWN].onUp = dUp;
joystick[Direction::LEFT].onUp = lUp;
joystick[Direction::RIGHT].onUp = rUp;
}
// Set what should happen when a "press" event has fired // Set what should happen when a "press" event has fired
// Note: this will occur while the joystick button is still held // Note: this will occur while the joystick button is still held
void TwoButtonExtended::setJoystickPressHandlers(Callback uPress, Callback dPress, Callback lPress, Callback rPress) void TwoButtonExtended::setJoystickPressHandlers(Callback uPress, Callback dPress, Callback lPress, Callback rPress)
@@ -49,11 +49,8 @@ class TwoButtonExtended : protected concurrency::OSThread
void setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs); void setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs);
void setJoystickDebounce(uint32_t debounceMs); void setJoystickDebounce(uint32_t debounceMs);
void setHandlerDown(uint8_t whichButton, Callback onDown); void setHandlerDown(uint8_t whichButton, Callback onDown);
void setHandlerUp(uint8_t whichButton, Callback onUp);
void setHandlerShortPress(uint8_t whichButton, Callback onShortPress); void setHandlerShortPress(uint8_t whichButton, Callback onShortPress);
void setHandlerLongPress(uint8_t whichButton, Callback onLongPress); void setHandlerLongPress(uint8_t whichButton, Callback onLongPress);
void setJoystickDownHandlers(Callback uDown, Callback dDown, Callback ldown, Callback rDown);
void setJoystickUpHandlers(Callback uUp, Callback dUp, Callback lUp, Callback rUp);
void setJoystickPressHandlers(Callback uPress, Callback dPress, Callback lPress, Callback rPress); void setJoystickPressHandlers(Callback uPress, Callback dPress, Callback lPress, Callback rPress);
void setTwoWayRockerPressHandlers(Callback lPress, Callback rPress); void setTwoWayRockerPressHandlers(Callback lPress, Callback rPress);
+32 -5
View File
@@ -212,7 +212,7 @@ using namespace concurrency;
volatile static const char slipstreamTZString[] = {USERPREFS_TZ_STRING}; volatile static const char slipstreamTZString[] = {USERPREFS_TZ_STRING};
// We always create a screen object, but we only init it if we find the hardware // We always create a screen object, but we only init it if we find the hardware
graphics::Screen *screen = nullptr; std::unique_ptr<graphics::Screen> screen = nullptr;
// Global power status // Global power status
meshtastic::PowerStatus *powerStatus = new meshtastic::PowerStatus(); meshtastic::PowerStatus *powerStatus = new meshtastic::PowerStatus();
@@ -428,10 +428,14 @@ void setup()
#if ARCH_PORTDUINO #if ARCH_PORTDUINO
RTCQuality ourQuality = RTCQualityDevice; RTCQuality ourQuality = RTCQualityDevice;
#ifdef __linux__
// timedatectl is systemd-only, so macOS, Windows and WASM stay at
// RTCQualityDevice rather than claim NTP quality we have not verified.
std::string timeCommandResult = exec("timedatectl status | grep synchronized | grep yes -c"); std::string timeCommandResult = exec("timedatectl status | grep synchronized | grep yes -c");
if (timeCommandResult[0] == '1') { if (timeCommandResult[0] == '1') {
ourQuality = RTCQualityNTP; ourQuality = RTCQualityNTP;
} }
#endif
struct timeval tv; struct timeval tv;
tv.tv_sec = time(NULL); tv.tv_sec = time(NULL);
@@ -962,15 +966,15 @@ void setup()
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
#if defined(HAS_SPI_TFT) || defined(USE_EINK) || defined(USE_SPISSD1306) #if defined(HAS_SPI_TFT) || defined(USE_EINK) || defined(USE_SPISSD1306)
screen = new graphics::Screen(screen_found, screen_model, screen_geometry); screen = std::make_unique<graphics::Screen>(screen_found, screen_model, screen_geometry);
#elif defined(ARCH_PORTDUINO) #elif defined(ARCH_PORTDUINO)
if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || portduino_config.displayPanel) && if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || portduino_config.displayPanel) &&
config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
screen = new graphics::Screen(screen_found, screen_model, screen_geometry); screen = std::make_unique<graphics::Screen>(screen_found, screen_model, screen_geometry);
} }
#else #else
if (screen_found.port != ScanI2C::I2CPort::NO_I2C) if (screen_found.port != ScanI2C::I2CPort::NO_I2C)
screen = new graphics::Screen(screen_found, screen_model, screen_geometry); screen = std::make_unique<graphics::Screen>(screen_found, screen_model, screen_geometry);
#endif #endif
} }
#endif // HAS_SCREEN #endif // HAS_SCREEN
@@ -1074,6 +1078,7 @@ void setup()
nodeDB->hasWarned = true; nodeDB->hasWarned = true;
} }
#endif #endif
nodeDB->notifyPendingLicensedIdentityMigration();
#if !MESHTASTIC_EXCLUDE_INPUTBROKER #if !MESHTASTIC_EXCLUDE_INPUTBROKER
if (inputBroker) if (inputBroker)
inputBroker->Init(); inputBroker->Init();
@@ -1107,6 +1112,23 @@ void setup()
#endif #endif
#endif #endif
#if defined(SENSECAP_INDICATOR)
// The ST7701 panel shares SCK/MOSI/MISO (41/48/47) with the SX1262, and its host is SPI2_HOST,
// which on the S3 is the same peripheral as the Arduino `SPI` object (FSPI == SPI2).
// LovyanGFX bit-bangs the ST7701 init sequence on those pins, and because this variant builds
// with USE_ARDUINO_HAL_GPIO it does so via Arduino pinMode()/digitalWrite(). pinMode() calls
// perimanSetPinBus(.., ESP32_BUS_TYPE_GPIO, ..), whose deinit callback (spiDetachBus_SCK) ends
// up in spiStopBus() and gates the SPI2 clock. RadioLib then spins forever in spiTransferByte()
// waiting on cmd.update, which never clears on a stopped peripheral, and the watchdog fires.
//
// Restart the bus here, after the panel is up and before the radio is touched. Note that
// SPIClass::begin() early-returns when _spi is already non-NULL, so end() first is required.
SPI.end();
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, -1); // CS is an IO-expander pin, driven by RadioLib
SPI.setFrequency(4000000);
LOG_DEBUG("SPI2 restarted after ST7701 init (SCK=%d, MISO=%d, MOSI=%d)", LORA_SCK, LORA_MISO, LORA_MOSI);
#endif
auto rIf = initLoRa(); auto rIf = initLoRa();
lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation) lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation)
@@ -1218,7 +1240,7 @@ bool runASAP;
// TODO find better home than main.cpp // TODO find better home than main.cpp
extern meshtastic_DeviceMetadata getDeviceMetadata() extern meshtastic_DeviceMetadata getDeviceMetadata()
{ {
meshtastic_DeviceMetadata deviceMetadata; meshtastic_DeviceMetadata deviceMetadata = meshtastic_DeviceMetadata_init_default;
strncpy(deviceMetadata.firmware_version, optstr(APP_VERSION), sizeof(deviceMetadata.firmware_version)); strncpy(deviceMetadata.firmware_version, optstr(APP_VERSION), sizeof(deviceMetadata.firmware_version));
deviceMetadata.device_state_version = DEVICESTATE_CUR_VER; deviceMetadata.device_state_version = DEVICESTATE_CUR_VER;
deviceMetadata.canShutdown = pmu_found || HAS_CPU_SHUTDOWN; deviceMetadata.canShutdown = pmu_found || HAS_CPU_SHUTDOWN;
@@ -1258,6 +1280,8 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
#if !defined(HAS_RGB_LED) && !RAK_4631 #if !defined(HAS_RGB_LED) && !RAK_4631
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_AMBIENTLIGHTING_CONFIG; deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_AMBIENTLIGHTING_CONFIG;
#endif #endif
// Range test is always excluded as of 2.8
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_RANGETEST_CONFIG;
// No bluetooth on these targets (yet): // No bluetooth on these targets (yet):
// Pico W / 2W may get it at some point // Pico W / 2W may get it at some point
@@ -1274,6 +1298,9 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
#if !(MESHTASTIC_EXCLUDE_PKI) #if !(MESHTASTIC_EXCLUDE_PKI)
deviceMetadata.hasPKC = true; deviceMetadata.hasPKC = true;
#endif
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
deviceMetadata.has_xeddsa = true;
#endif #endif
return deviceMetadata; return deviceMetadata;
} }
+2 -1
View File
@@ -11,6 +11,7 @@
#include "mesh/generated/meshtastic/telemetry.pb.h" #include "mesh/generated/meshtastic/telemetry.pb.h"
#include <SPI.h> #include <SPI.h>
#include <map> #include <map>
#include <memory>
#if defined(ARCH_ESP32) && !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH #if defined(ARCH_ESP32) && !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH
#include "nimble/NimbleBluetooth.h" #include "nimble/NimbleBluetooth.h"
extern NimbleBluetooth *nimbleBluetooth; extern NimbleBluetooth *nimbleBluetooth;
@@ -68,7 +69,7 @@ extern UdpMulticastHandler *udpHandler;
#endif #endif
// Global Screen singleton. // Global Screen singleton.
extern graphics::Screen *screen; extern std::unique_ptr<graphics::Screen> screen;
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_ACCELEROMETER #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_ACCELEROMETER
#include "motion/AccelerometerThread.h" #include "motion/AccelerometerThread.h"
-14
View File
@@ -9,7 +9,6 @@
*/ */
#include "memGet.h" #include "memGet.h"
#include "configuration.h" #include "configuration.h"
#include "memory/MemAudit.h"
#if defined(MESHTASTIC_DYNAMIC_SBRK_HEAP) #if defined(MESHTASTIC_DYNAMIC_SBRK_HEAP)
#include <malloc.h> #include <malloc.h>
@@ -108,16 +107,3 @@ uint32_t MemGet::getPsramSize()
return 0; return 0;
#endif #endif
} }
void displayPercentHeapFree()
{
uint32_t freeHeap = memGet.getFreeHeap();
uint32_t totalHeap = memGet.getHeapSize();
if (totalHeap == 0 || totalHeap == UINT32_MAX) {
LOG_INFO("Heap size unavailable");
return;
}
int percent = (int)((freeHeap * 100) / totalHeap);
LOG_INFO("Heap free: %d%% (%u/%u bytes)", percent, freeHeap, totalHeap);
memaudit::logBreakdown("heap"); // per-subsystem breakdown rides along with the periodic heap log
}
+8 -6
View File
@@ -128,11 +128,13 @@ bool Channels::ensureLicensedOperation()
} }
auto &channelSettings = channel.settings; auto &channelSettings = channel.settings;
if (strcasecmp(channelSettings.name, Channels::adminChannel) == 0) { if (strcasecmp(channelSettings.name, Channels::adminChannel) == 0) {
channel.role = meshtastic_Channel_Role_DISABLED; if (channel.role != meshtastic_Channel_Role_DISABLED || channelSettings.psk.size > 0) {
channelSettings.psk.bytes[0] = 0; channel.role = meshtastic_Channel_Role_DISABLED;
channelSettings.psk.size = 0; channelSettings.psk.bytes[0] = 0;
hasEncryptionOrAdmin = true; channelSettings.psk.size = 0;
channels.setChannel(channel); hasEncryptionOrAdmin = true;
channels.setChannel(channel);
}
} else if (channelSettings.psk.size > 0) { } else if (channelSettings.psk.size > 0) {
channelSettings.psk.bytes[0] = 0; channelSettings.psk.bytes[0] = 0;
@@ -563,4 +565,4 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash)
int16_t Channels::setActiveByIndex(ChannelIndex channelIndex) int16_t Channels::setActiveByIndex(ChannelIndex channelIndex)
{ {
return setCrypto(channelIndex); return setCrypto(channelIndex);
} }
+18 -19
View File
@@ -51,8 +51,8 @@ bool FloodingRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
LOG_DEBUG("Repeated reliable tx"); LOG_DEBUG("Repeated reliable tx");
// Check if it's still in the Tx queue, if not, we have to relay it again // Check if it's still in the Tx queue, if not, we have to relay it again
if (!findInTxQueue(p->from, p->id)) { if (!findInTxQueue(p->from, p->id)) {
reprocessPacket(p); if (reprocessPacket(p))
perhapsRebroadcast(p); perhapsRebroadcast(p);
} }
} else { } else {
perhapsCancelDupe(p); perhapsCancelDupe(p);
@@ -68,6 +68,12 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p)
{ {
// isRebroadcaster() is duplicated in perhapsRebroadcast(), but this avoids confusing log messages // isRebroadcaster() is duplicated in perhapsRebroadcast(), but this avoids confusing log messages
if (isRebroadcaster() && iface && p->hop_limit > 0) { if (isRebroadcaster() && iface && p->hop_limit > 0) {
// Verify the replacement before deleting the valid lower-hop copy waiting in the TX queue.
// This is intentionally redundant with ReliableRouter's ingress gate: it keeps this helper
// safe if another caller is introduced later.
if (passesRoutingAuthGate(const_cast<meshtastic_MeshPacket *>(p)) != RoutingAuthVerdict::ACCEPT)
return true;
// If we overhear a duplicate copy of the packet with more hops left than the one we are waiting to // If we overhear a duplicate copy of the packet with more hops left than the one we are waiting to
// rebroadcast, then remove the packet currently sitting in the TX queue and use this one instead. // rebroadcast, then remove the packet currently sitting in the TX queue and use this one instead.
uint8_t dropThreshold = p->hop_limit; // remove queued packets that have fewer hops remaining uint8_t dropThreshold = p->hop_limit; // remove queued packets that have fewer hops remaining
@@ -75,7 +81,8 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p)
LOG_DEBUG("Processing upgraded packet 0x%08x for rebroadcast with hop limit %d (dropping queued < %d)", p->id, LOG_DEBUG("Processing upgraded packet 0x%08x for rebroadcast with hop limit %d (dropping queued < %d)", p->id,
p->hop_limit, dropThreshold); p->hop_limit, dropThreshold);
reprocessPacket(p); if (!reprocessPacket(p))
return true;
perhapsRebroadcast(p); perhapsRebroadcast(p);
rxDupe++; rxDupe++;
@@ -87,32 +94,24 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p)
return false; return false;
} }
void FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p) bool FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p)
{ {
if (p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
auto decodedState = perhapsDecode(const_cast<meshtastic_MeshPacket *>(p));
if (decodedState != DecodeState::DECODE_SUCCESS && decodedState != DecodeState::DECODE_OPAQUE)
return false;
}
if (nodeDB) if (nodeDB)
nodeDB->updateFrom(*p); nodeDB->updateFrom(*p);
#if !MESHTASTIC_EXCLUDE_TRACEROUTE #if !MESHTASTIC_EXCLUDE_TRACEROUTE
if (traceRouteModule && p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
// If we got a packet that is not decoded, try to decode it so we can check for traceroute.
auto decodedState = perhapsDecode(const_cast<meshtastic_MeshPacket *>(p));
if (decodedState == DecodeState::DECODE_SUCCESS) {
// parsing was successful, print for debugging
printPacket("reprocessPacket(DUP)", p);
} else {
// Fatal decoding error, we can't do anything with this packet
LOG_WARN(
"FloodingRouter::reprocessPacket: Fatal decode error (state=%d, id=0x%08x, from=%u), can't check for traceroute",
static_cast<int>(decodedState), p->id, getFrom(p));
return;
}
}
if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) { p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) {
traceRouteModule->processUpgradedPacket(*p); traceRouteModule->processUpgradedPacket(*p);
} }
#endif #endif
return true;
} }
bool FloodingRouter::roleAllowsCancelingDupe(const meshtastic_MeshPacket *p) bool FloodingRouter::roleAllowsCancelingDupe(const meshtastic_MeshPacket *p)
+2 -2
View File
@@ -64,7 +64,7 @@ class FloodingRouter : public Router
bool perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p); bool perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p);
/* Call when we receive a packet that needs some reprocessing, but afterwards should be filtered */ /* Call when we receive a packet that needs some reprocessing, but afterwards should be filtered */
void reprocessPacket(const meshtastic_MeshPacket *p); bool reprocessPacket(const meshtastic_MeshPacket *p);
// Return false for roles like ROUTER which should always rebroadcast even when we've heard another rebroadcast of // Return false for roles like ROUTER which should always rebroadcast even when we've heard another rebroadcast of
// the same packet // the same packet
@@ -75,4 +75,4 @@ class FloodingRouter : public Router
// Return true if we are a rebroadcaster // Return true if we are a rebroadcaster
bool isRebroadcaster(); bool isRebroadcaster();
}; };
+12
View File
@@ -22,6 +22,12 @@ extern Adafruit_nRFCrypto nRFCrypto;
#include <unistd.h> #include <unistd.h>
#ifdef __linux__ #ifdef __linux__
#include <sys/random.h> // getrandom() #include <sys/random.h> // getrandom()
#elif defined(_WIN32)
// Order is load-bearing, hence the blank line: bcrypt.h uses LONG/ULONG from
// windows.h and does not include it itself.
#include <windows.h>
#include <bcrypt.h> // BCryptGenRandom()
#else #else
#include <stdlib.h> // arc4random_buf() on Darwin/BSD #include <stdlib.h> // arc4random_buf() on Darwin/BSD
#endif #endif
@@ -128,6 +134,12 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy)
if (generated == static_cast<ssize_t>(length)) { if (generated == static_cast<ssize_t>(length)) {
filled = true; filled = true;
} }
#elif defined(_WIN32)
// No getrandom/arc4random on Windows; BCryptGenRandom is the documented CSPRNG.
// Preferred over std::random_device, whose libstdc++ Windows backend reports entropy() == 0.
if (BCryptGenRandom(NULL, buffer, static_cast<ULONG>(length), BCRYPT_USE_SYSTEM_PREFERRED_RNG) == 0) { // STATUS_SUCCESS
filled = true;
}
#elif defined(__EMSCRIPTEN__) #elif defined(__EMSCRIPTEN__)
// Browser/wasm: no getrandom/arc4random - fall through to std::random_device, // Browser/wasm: no getrandom/arc4random - fall through to std::random_device,
// which emscripten backs with crypto.getRandomValues(). // which emscripten backs with crypto.getRandomValues().
+4 -1
View File
@@ -114,7 +114,10 @@ template <class T> class MemoryDynamic : public Allocator<T>
virtual T *alloc(TickType_t maxWait) override virtual T *alloc(TickType_t maxWait) override
{ {
T *p = (T *)malloc(sizeof(T)); T *p = (T *)malloc(sizeof(T));
assert(p); if (!p) {
LOG_WARN("malloc(%u) failed, heap exhausted!", (unsigned)sizeof(T));
return nullptr;
}
this->auditAdd((int32_t)sizeof(T)); this->auditAdd((int32_t)sizeof(T));
return p; return p;
} }
+17 -4
View File
@@ -18,7 +18,7 @@ uint8_t MeshModule::numPeriodicModules = 0;
*/ */
meshtastic_MeshPacket *MeshModule::currentReply; meshtastic_MeshPacket *MeshModule::currentReply;
MeshModule::MeshModule(const char *_name) : name(_name) MeshModule::MeshModule(const char *_name, meshtastic_PortNum _ourPortNum) : name(_name), ourPortNum(_ourPortNum)
{ {
// Can't trust static initializer order, so we check each time // Can't trust static initializer order, so we check each time
if (!modules) if (!modules)
@@ -27,6 +27,12 @@ MeshModule::MeshModule(const char *_name) : name(_name)
modules->push_back(this); modules->push_back(this);
} }
bool MeshModule::replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp)
{
return modulePort != meshtastic_PortNum_UNKNOWN_APP && mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
mp.decoded.portnum == modulePort;
}
void MeshModule::setup() {} void MeshModule::setup() {}
MeshModule::~MeshModule() MeshModule::~MeshModule()
@@ -57,6 +63,8 @@ meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, Nod
// So we manually call pb_encode_to_bytes and specify routing port number // So we manually call pb_encode_to_bytes and specify routing port number
// auto p = allocDataProtobuf(c); // auto p = allocDataProtobuf(c);
meshtastic_MeshPacket *p = router->allocForSending(); meshtastic_MeshPacket *p = router->allocForSending();
if (!p)
return nullptr;
p->decoded.portnum = meshtastic_PortNum_ROUTING_APP; p->decoded.portnum = meshtastic_PortNum_ROUTING_APP;
p->decoded.payload.size = p->decoded.payload.size =
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Routing_msg, &c); pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Routing_msg, &c);
@@ -105,6 +113,7 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src)
auto &pi = **i; auto &pi = **i;
pi.currentRequest = &mp; pi.currentRequest = &mp;
pi.ignoreRequest = false;
/// We only call modules that are interested in the packet (and the message is destined to us or we are promiscious) /// We only call modules that are interested in the packet (and the message is destined to us or we are promiscious)
bool wantsPacket = (isDecoded || pi.encryptedOk) && (pi.isPromiscuous || toUs) && pi.wantPacket(&mp); bool wantsPacket = (isDecoded || pi.encryptedOk) && (pi.isPromiscuous || toUs) && pi.wantPacket(&mp);
@@ -155,9 +164,13 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src)
// better solution (FIXME) would be to let phones have their own distinct addresses and we 'route' to them like // better solution (FIXME) would be to let phones have their own distinct addresses and we 'route' to them like
// any other node. // any other node.
if (isDecoded && mp.decoded.want_response && toUs && (!isFromUs(&mp) || isToUs(&mp)) && !currentReply) { if (isDecoded && mp.decoded.want_response && toUs && (!isFromUs(&mp) || isToUs(&mp)) && !currentReply) {
pi.sendResponse(mp); if (replyPortMatches(pi.ourPortNum, mp)) {
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);
}
ignoreRequest = ignoreRequest || pi.ignoreRequest; // If at least one module asks it, we may ignore a request ignoreRequest = ignoreRequest || pi.ignoreRequest; // If at least one module asks it, we may ignore a request
LOG_INFO("Asked module '%s' to send a response", pi.name);
} else { } else {
LOG_DEBUG("Module '%s' considered", pi.name); LOG_DEBUG("Module '%s' considered", pi.name);
} }
@@ -311,4 +324,4 @@ bool MeshModule::isRequestingFocus()
} else } else
return false; return false;
} }
#endif #endif
+5 -2
View File
@@ -68,10 +68,12 @@ class MeshModule
/** Constructor /** Constructor
* name is for debugging output * name is for debugging output
*/ */
MeshModule(const char *_name); MeshModule(const char *_name, meshtastic_PortNum _ourPortNum = meshtastic_PortNum_UNKNOWN_APP);
virtual ~MeshModule(); virtual ~MeshModule();
static bool replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp);
/** For use only by MeshService /** For use only by MeshService
*/ */
static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO); static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO);
@@ -88,6 +90,7 @@ class MeshModule
#endif #endif
protected: protected:
const char *name; const char *name;
meshtastic_PortNum ourPortNum;
/** Most modules only care about packets that are destined for their node (i.e. broadcasts or has their node as the specific /** Most modules only care about packets that are destined for their node (i.e. broadcasts or has their node as the specific
recipient) But some plugs might want to 'sniff' packets that are merely being routed (passing through the current node). Those recipient) But some plugs might want to 'sniff' packets that are merely being routed (passing through the current node). Those
@@ -103,7 +106,7 @@ class MeshModule
* flag */ * flag */
bool encryptedOk = false; bool encryptedOk = false;
/* We allow modules to ignore a request without sending an error if they have a specific reason for it. */ /* Per-packet flag cleared by callModules(); modules can suppress an error response for a specific request. */
bool ignoreRequest = false; bool ignoreRequest = false;
/** /**
+19 -7
View File
@@ -137,12 +137,17 @@ void MeshService::loop()
/// The radioConfig object just changed, call this to force the hw to change to the new settings /// The radioConfig object just changed, call this to force the hw to change to the new settings
void MeshService::reloadConfig(int saveWhat) void MeshService::reloadConfig(int saveWhat)
{ {
// If we can successfully set this radio to these settings, save them to disk // Only LoRa config and channels (freq/PSK/slot) affect the radio. Saves that only touch
// module config, device state, or the node database (e.g. favoriting a node) have no reason
// to re-init the LoRa chip - skip it there to avoid an unnecessary and risky SPI reconfigure.
if (saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS)) {
// If we can successfully set this radio to these settings, save them to disk
// This will also update the region as needed // This will also update the region as needed
nodeDB->resetRadioConfig(); // Don't let the phone send us fatally bad settings nodeDB->resetRadioConfig(); // Don't let the phone send us fatally bad settings
configChanged.notifyObservers(NULL); // This will cause radio hardware to change freqs etc configChanged.notifyObservers(NULL); // This will cause radio hardware to change freqs etc
}
nodeDB->saveToDisk(saveWhat); nodeDB->saveToDisk(saveWhat);
} }
@@ -257,8 +262,8 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p)
p.to != NODENUM_BROADCAST && p.to != 0) // DM only p.to != NODENUM_BROADCAST && p.to != 0) // DM only
{ {
perhapsDecode(&p); perhapsDecode(&p);
const StoredMessage &sm = messageStore.addFromPacket(p); if (const StoredMessage *sm = messageStore.tryAddFromPacket(p))
graphics::MessageRenderer::handleNewMessage(nullptr, sm, p); // notify UI graphics::MessageRenderer::handleNewMessage(nullptr, *sm, p); // notify UI
}) })
#if !MESHTASTIC_EXCLUDE_ADMIN #if !MESHTASTIC_EXCLUDE_ADMIN
// Note admin requests on their way out: AdminModule only accepts a response from a remote we // Note admin requests on their way out: AdminModule only accepts a response from a remote we
@@ -319,13 +324,20 @@ void MeshService::sendToMesh(meshtastic_MeshPacket *p, RxSource src, bool ccToPh
uint32_t mesh_packet_id = p->id; uint32_t mesh_packet_id = p->id;
nodeDB->updateFrom(*p); // update our local DB for this packet (because phone might have sent position packets etc...) nodeDB->updateFrom(*p); // update our local DB for this packet (because phone might have sent position packets etc...)
// callModules' loopback gate keeps RX_SRC_LOCAL packets from RoutingModule, the only module
// that forwards to the phone, so deliver our own reply's copy here or the client never sees it.
const bool localDelivery = isToUs(p);
if (src == RX_SRC_LOCAL && localDelivery)
ccToPhone = true;
// Note: We might return !OK if our fifo was full, at that point the only option we have is to drop it // Note: We might return !OK if our fifo was full, at that point the only option we have is to drop it
ErrorCode res = router->sendLocal(p, src); ErrorCode res = router->sendLocal(p, src);
/* NOTE(pboldin): Prepare and send QueueStatus message to the phone as a /* NOTE(pboldin): Prepare and send QueueStatus message to the phone as a
* high-priority message. */ * high-priority message. */
meshtastic_QueueStatus qs = router->getQueueStatus(); meshtastic_QueueStatus qs = router->getQueueStatus();
ErrorCode r = sendQueueStatusToPhone(qs, res, mesh_packet_id); // SHOULD_RELEASE means "caller frees", not a send failure, so don't report it as one.
ErrorCode r = sendQueueStatusToPhone(qs, (res == ERRNO_SHOULD_RELEASE && localDelivery) ? ERRNO_OK : res, mesh_packet_id);
if (r != ERRNO_OK) { if (r != ERRNO_OK) {
LOG_DEBUG("Can't send status to phone"); LOG_DEBUG("Can't send status to phone");
} }
+49 -19
View File
@@ -11,6 +11,25 @@
NextHopRouter::NextHopRouter() {} NextHopRouter::NextHopRouter() {}
bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p)
{
// Opaque traffic is never admitted to PacketHistory, NodeDB, modules, phone, MQTT, or ACK
// handling. Relay only from the immutable outer routing header and let hop exhaustion bound it.
const auto mode = config.device.rebroadcast_mode;
if (!iface || isToUs(p) || isFromUs(p) || p->id == 0 || p->hop_limit == 0 || !isRebroadcaster() || owner.is_licensed ||
!IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_ALL,
meshtastic_Config_DeviceConfig_RebroadcastMode_ALL_SKIP_DECODING) ||
(p->next_hop != NO_NEXT_HOP_PREFERENCE && p->next_hop != nodeDB->getLastByteOfNodeNum(getNodeNum())))
return false;
meshtastic_MeshPacket *relay = packetPool.allocCopy(*p);
if (!relay)
return false;
relay->hop_limit--;
relay->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum());
return Router::send(relay) == ERRNO_OK;
}
PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions) PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions)
{ {
packet = p; packet = p;
@@ -65,16 +84,15 @@ bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
LOG_INFO("Fallback to flooding from relay_node=0x%x", p->relay_node); LOG_INFO("Fallback to flooding from relay_node=0x%x", p->relay_node);
// Check if it's still in the Tx queue, if not, we have to relay it again // Check if it's still in the Tx queue, if not, we have to relay it again
if (!findInTxQueue(p->from, p->id)) { if (!findInTxQueue(p->from, p->id)) {
reprocessPacket(p); if (reprocessPacket(p))
perhapsRebroadcast(p); perhapsRebroadcast(p);
} }
} else { } else {
bool isRepeated = getHopsAway(*p) == 0; bool isRepeated = getHopsAway(*p) == 0;
// If repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again // If repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again
if (isRepeated) { if (isRepeated) {
if (!findInTxQueue(p->from, p->id)) { if (!findInTxQueue(p->from, p->id)) {
reprocessPacket(p); if (reprocessPacket(p) && !perhapsRebroadcast(p) && isToUs(p) && p->want_ack) {
if (!perhapsRebroadcast(p) && isToUs(p) && p->want_ack) {
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0); sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0);
} }
} }
@@ -159,6 +177,9 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
} }
#endif #endif
if (p->to == NODENUM_BROADCAST_NO_LORA)
return false;
// Allow rebroadcast if hop_limit > 0 OR if we're exhausting hops (which sets hop_limit = 0 but still needs one relay) // Allow rebroadcast if hop_limit > 0 OR if we're exhausting hops (which sets hop_limit = 0 but still needs one relay)
if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) { if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) {
if (p->id != 0) { if (p->id != 0) {
@@ -192,11 +213,10 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
} }
#endif #endif
if (p->next_hop == NO_NEXT_HOP_PREFERENCE) { ErrorCode res =
FloodingRouter::send(tosend); (p->next_hop == NO_NEXT_HOP_PREFERENCE) ? FloodingRouter::send(tosend) : NextHopRouter::send(tosend);
} else { if (res == ERRNO_SHOULD_RELEASE)
NextHopRouter::send(tosend); packetPool.release(tosend);
}
return true; return true;
} }
@@ -401,8 +421,10 @@ int32_t NextHopRouter::doRetransmissions()
trafficManagementModule->clearNextHop(p.packet->to); trafficManagementModule->clearNextHop(p.packet->to);
} }
#endif #endif
if (auto *copy = packetPool.allocCopy(*p.packet)) if (auto *copy = packetPool.allocCopy(*p.packet)) {
FloodingRouter::send(copy); if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
packetPool.release(copy);
}
} else { } else {
#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED #if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
// M4 (gated): if the route isn't proven healthy, don't spend a second directed // M4 (gated): if the route isn't proven healthy, don't spend a second directed
@@ -416,22 +438,30 @@ int32_t NextHopRouter::doRetransmissions()
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to); meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
if (sentTo) if (sentTo)
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE; sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
if (auto *copy = packetPool.allocCopy(*p.packet)) if (auto *copy = packetPool.allocCopy(*p.packet)) {
FloodingRouter::send(copy); if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
packetPool.release(copy);
}
} else { } else {
if (auto *copy = packetPool.allocCopy(*p.packet)) if (auto *copy = packetPool.allocCopy(*p.packet)) {
NextHopRouter::send(copy); if (NextHopRouter::send(copy) == ERRNO_SHOULD_RELEASE)
packetPool.release(copy);
}
} }
#else #else
if (auto *copy = packetPool.allocCopy(*p.packet)) if (auto *copy = packetPool.allocCopy(*p.packet)) {
NextHopRouter::send(copy); if (NextHopRouter::send(copy) == ERRNO_SHOULD_RELEASE)
packetPool.release(copy);
}
#endif #endif
} }
} else { } else {
// Note: we call the superclass version because we don't want to have our version of send() add a new // Note: we call the superclass version because we don't want to have our version of send() add a new
// retransmission record // retransmission record
if (auto *copy = packetPool.allocCopy(*p.packet)) if (auto *copy = packetPool.allocCopy(*p.packet)) {
FloodingRouter::send(copy); if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
packetPool.release(copy);
}
} }
// Queue again // Queue again
+6 -1
View File
@@ -137,6 +137,7 @@ class NextHopRouter : public FloodingRouter
* @return true to abandon the packet * @return true to abandon the packet
*/ */
virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override; virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override;
bool relayOpaquePacket(const meshtastic_MeshPacket *p) override;
/** /**
* Look for packets we need to relay * Look for packets we need to relay
@@ -205,8 +206,12 @@ class NextHopRouter : public FloodingRouter
*/ */
std::optional<uint8_t> getNextHop(NodeNum to, uint8_t relay_node); std::optional<uint8_t> getNextHop(NodeNum to, uint8_t relay_node);
#ifdef PIO_UNIT_TESTING
public: // expose perhapsRebroadcast to the test shim
#else
private: private:
#endif
/** Check if we should be rebroadcasting this packet if so, do so. /** Check if we should be rebroadcasting this packet if so, do so.
* @return true if we did rebroadcast */ * @return true if we did rebroadcast */
bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override; bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override;
}; };
+349 -13
View File
@@ -31,6 +31,9 @@
#if HAS_VARIABLE_HOPS #if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h" #include "modules/HopScalingModule.h"
#endif #endif
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
#include "xmodem.h" #include "xmodem.h"
#include <ErriezCRC32.h> #include <ErriezCRC32.h>
#include <algorithm> #include <algorithm>
@@ -635,6 +638,7 @@ NodeDB::NodeDB()
#endif #endif
sortMeshDB(); sortMeshDB();
saveToDisk(saveWhat); saveToDisk(saveWhat);
bootInitializationInProgress = false;
} }
/** /**
@@ -767,6 +771,12 @@ bool NodeDB::factoryReset(bool eraseBleBonds)
warmStore.clear(); warmStore.clear();
warmStore.saveIfDirty(); warmStore.saveIfDirty();
#endif #endif
#if HAS_TRAFFIC_MANAGEMENT
// Factory reset forgets everything; TMM's RAM caches must not survive to resurrect
// identities (the device usually reboots after this, but don't rely on it).
if (trafficManagementModule)
trafficManagementModule->purgeAll();
#endif
// second, install default state (this will deal with the duplicate mac address issue) // second, install default state (this will deal with the duplicate mac address issue)
installDefaultNodeDatabase(); installDefaultNodeDatabase();
@@ -822,7 +832,7 @@ void NodeDB::installDefaultNodeDatabase()
void NodeDB::installDefaultConfig(bool preserveKey = false) void NodeDB::installDefaultConfig(bool preserveKey = false)
{ {
uint8_t private_key_temp[32]; uint8_t private_key_temp[32];
bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size > 0; bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size == 32;
if (shouldPreserveKey) { if (shouldPreserveKey) {
memcpy(private_key_temp, config.security.private_key.bytes, config.security.private_key.size); memcpy(private_key_temp, config.security.private_key.bytes, config.security.private_key.size);
} }
@@ -948,6 +958,12 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
config.security.admin_key_count = numAdminKeys; config.security.admin_key_count = numAdminKeys;
// Left at COMPATIBLE when signature checking is compiled out, so we never report a policy
// nothing enforces (mirrors the set-config guard in AdminModule).
#if defined(USERPREFS_CONFIG_SECURITY_PACKET_SIGNATURE_POLICY) && !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
config.security.packet_signature_policy = USERPREFS_CONFIG_SECURITY_PACKET_SIGNATURE_POLICY;
#endif
if (shouldPreserveKey) { if (shouldPreserveKey) {
config.security.private_key.size = 32; config.security.private_key.size = 32;
memcpy(config.security.private_key.bytes, private_key_temp, config.security.private_key.size); memcpy(config.security.private_key.bytes, private_key_temp, config.security.private_key.size);
@@ -1597,6 +1613,11 @@ void NodeDB::resetNodes(bool keepFavorites)
#if WARM_NODE_COUNT > 0 #if WARM_NODE_COUNT > 0
warmStore.clear(); // warm entries are never favorites; a DB reset clears them too warmStore.clear(); // warm entries are never favorites; a DB reset clears them too
#endif #endif
#if HAS_TRAFFIC_MANAGEMENT
// A user-initiated DB reset forgets everything; TMM's caches must not resurrect it.
if (trafficManagementModule)
trafficManagementModule->purgeAll();
#endif
devicestate.has_rx_waypoint = false; devicestate.has_rx_waypoint = false;
saveNodeDatabaseToDisk(); saveNodeDatabaseToDisk();
@@ -1629,6 +1650,12 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum)
// Explicit user removal: don't let the warm tier resurrect the node // Explicit user removal: don't let the warm tier resurrect the node
warmStore.remove(nodeNum); warmStore.remove(nodeNum);
#endif #endif
#if HAS_TRAFFIC_MANAGEMENT
// Explicit removal is full removal: the TrafficManagement caches (unified slot +
// NodeInfo identity cache) must not keep serving or resurrect the node either.
if (trafficManagementModule)
trafficManagementModule->purgeNode(nodeNum);
#endif
LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed); LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed);
saveNodeDatabaseToDisk(); saveNodeDatabaseToDisk();
@@ -1841,6 +1868,8 @@ bool NodeDB::enforceSatelliteCaps()
// them if they do); otherwise tracker/sensor/tak_tracker are role-protected. // them if they do); otherwise tracker/sensor/tak_tracker are role-protected.
static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n) static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n)
{ {
if (nodeInfoLiteHasXeddsaSigned(&n))
return static_cast<uint8_t>(WarmProtected::XeddsaSigner);
if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK | if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK |
NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK)) NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK))
return static_cast<uint8_t>(WarmProtected::Flag); return static_cast<uint8_t>(WarmProtected::Flag);
@@ -2165,6 +2194,41 @@ void NodeDB::loadFromDisk()
migrationSavePending = false; migrationSavePending = false;
configDecodeFailed = false; configDecodeFailed = false;
configLoadComplete = false;
#if !USERPREFS_EVENT_MODE
#ifdef FSCom
const char *eventProfileFiles[] = {EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME};
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);
}
spiLock->unlock();
#endif
#endif
#if USERPREFS_EVENT_MODE
// Seed only a missing event config; never overwrite normal files after a corrupt event config.
bool eventConfigMissing = false;
eventProfileStorageUnavailable = false;
#ifdef FSCom
spiLock->lock();
eventConfigMissing = !FSCom.exists(configFileName);
if (eventConfigMissing) {
const size_t totalBytes = fsTotalBytes();
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.",
static_cast<unsigned>(EVENT_PROFILE_STORAGE_RESERVATION_BYTES),
static_cast<unsigned>(totalBytes >= usedBytes ? totalBytes - usedBytes : 0));
}
}
spiLock->unlock();
#endif
bool initializedEventConfig = false;
#endif
meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero; meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero;
@@ -2354,6 +2418,31 @@ void NodeDB::loadFromDisk()
state = loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg, state = loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg,
&config); &config);
#if USERPREFS_EVENT_MODE
if (eventConfigMissing && state != LoadFileResult::LOAD_SUCCESS) {
const LoadFileResult eventConfigState = state;
const LoadFileResult standardConfigState =
loadProto(STANDARD_CONFIG_FILE_NAME, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig),
&meshtastic_LocalConfig_msg, &config);
if (standardConfigState == LoadFileResult::LOAD_SUCCESS) {
// Preserve the user's identity and non-radio preferences, then
// replace only LoRa with this event build's compiled defaults.
const meshtastic_LocalConfig standardConfig = config;
installDefaultConfig(true);
const meshtastic_Config_LoRaConfig eventLora = config.lora;
config = standardConfig;
config.has_lora = true;
config.lora = eventLora;
state = LoadFileResult::LOAD_SUCCESS;
initializedEventConfig = true;
LOG_INFO("Initialized 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.
state = standardConfigState == LoadFileResult::DECODE_FAILED ? LoadFileResult::DECODE_FAILED : eventConfigState;
}
}
#endif
if (state == LoadFileResult::DECODE_FAILED) { if (state == LoadFileResult::DECODE_FAILED) {
// Config file present but undecodable this boot (corruption / torn write / transient decrypt fail). // Config file present but undecodable this boot (corruption / torn write / transient decrypt fail).
// loadProto() already zeroed `config`, so the keypair is gone from RAM; minting a new one would change // loadProto() already zeroed `config`, so the keypair is gone from RAM; minting a new one would change
@@ -2375,6 +2464,7 @@ void NodeDB::loadFromDisk()
} else { } else {
LOG_INFO("Loaded saved config version %d", config.version); LOG_INFO("Loaded saved config version %d", config.version);
} }
configLoadComplete = true;
// Coerce LoRa config fields derived from presets while bootstrapping. // Coerce LoRa config fields derived from presets while bootstrapping.
// Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode. // Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode.
@@ -2415,6 +2505,15 @@ void NodeDB::loadFromDisk()
config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY; config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY;
#endif #endif
#if USERPREFS_EVENT_MODE
if (initializedEventConfig) {
// 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");
}
#endif
if (backupSecurity.private_key.size > 0) { if (backupSecurity.private_key.size > 0) {
LOG_DEBUG("Restoring backup of security config"); LOG_DEBUG("Restoring backup of security config");
config.security = backupSecurity; config.security = backupSecurity;
@@ -2529,7 +2628,7 @@ void NodeDB::loadFromDisk()
const int segments[] = {SEGMENT_CONFIG, SEGMENT_MODULECONFIG, SEGMENT_CHANNELS, SEGMENT_DEVICESTATE, const int segments[] = {SEGMENT_CONFIG, SEGMENT_MODULECONFIG, SEGMENT_CHANNELS, SEGMENT_DEVICESTATE,
SEGMENT_NODEDATABASE}; SEGMENT_NODEDATABASE};
int toSave = 0; int toSave = 0;
for (int i = 0; i < 5; i++) { for (size_t i = 0; i < sizeof(segments) / sizeof(segments[0]); i++) {
if (!EncryptedStorage::isEncrypted(filesToCheck[i])) { if (!EncryptedStorage::isEncrypted(filesToCheck[i])) {
toSave |= segments[i]; toSave |= segments[i];
} }
@@ -2546,6 +2645,43 @@ void NodeDB::loadFromDisk()
EncryptedStorage::migrateFile(fn); EncryptedStorage::migrateFile(fn);
} }
} }
// Backups are outside saveToDisk(), but can contain radio profile PSKs. Only event builds
// introduce a second backup file, so leave normal-firmware backup handling unchanged.
#if USERPREFS_EVENT_MODE
#ifdef FSCom
spiLock->lock();
const bool activeBackupExists = FSCom.exists(backupFileName);
spiLock->unlock();
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);
storageCorruptThisLoad = true;
}
}
#endif
#endif
// Event firmware keeps the normal radio profile inactive, so migrate it separately.
#if USERPREFS_EVENT_MODE
#ifdef FSCom
const char *inactiveRadioProfileFiles[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME,
STANDARD_BACKUP_FILE_NAME};
for (const char *fn : inactiveRadioProfileFiles) {
spiLock->lock();
const bool exists = FSCom.exists(fn);
spiLock->unlock();
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);
storageCorruptThisLoad = true;
}
}
}
#endif
#endif
} }
#endif #endif
@@ -2586,6 +2722,11 @@ void NodeDB::loadFromDisk()
moduleConfig.version = POSITION_TELEMETRY_OPTIN_VER; moduleConfig.version = POSITION_TELEMETRY_OPTIN_VER;
saveToDisk(SEGMENT_MODULECONFIG); saveToDisk(SEGMENT_MODULECONFIG);
} }
if (channels.ensureLicensedOperation()) {
LOG_WARN("Licensed operation removed persisted channel encryption/admin access");
saveToDisk(SEGMENT_CHANNELS);
}
#if ARCH_PORTDUINO #if ARCH_PORTDUINO
// set any config overrides // set any config overrides
if (portduino_config.has_configDisplayMode) { if (portduino_config.has_configDisplayMode) {
@@ -2680,8 +2821,9 @@ bool NodeDB::disableLockdownToPlaintext()
// plaintext->encrypted migrate loop above. Order does not matter here; // plaintext->encrypted migrate loop above. Order does not matter here;
// EncryptedStorage::removeLockdownArtifacts() (which deletes the DEK, // EncryptedStorage::removeLockdownArtifacts() (which deletes the DEK,
// the commit point) only runs after every file is confirmed plaintext. // the commit point) only runs after every file is confirmed plaintext.
const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName, const char *filesToCheck[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME,
nodeDatabaseFileName}; EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME,
moduleConfigFileName, deviceStateFileName, nodeDatabaseFileName};
for (const char *fn : filesToCheck) { for (const char *fn : filesToCheck) {
if (!EncryptedStorage::migrateFileToPlaintext(fn)) { if (!EncryptedStorage::migrateFileToPlaintext(fn)) {
LOG_ERROR("NodeDB: failed to revert %s to plaintext; aborting disable (device stays in lockdown)", fn); LOG_ERROR("NodeDB: failed to revert %s to plaintext; aborting disable (device stays in lockdown)", fn);
@@ -2701,6 +2843,15 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_
bool fullAtomic) bool fullAtomic)
{ {
// Only the radio profile is at risk from an unverified config load, so only defer those writes.
// Devicestate/nodedb/module writes must still land, otherwise boot-time recovery (e.g. loadFromDisk()
// restoring owner fields) is dropped and never retried.
if (isRadioProfileFile(filename) &&
shouldDeferBootPersistence(bootInitializationInProgress, configLoadComplete, configDecodeFailed)) {
LOG_WARN("NodeDB: deferred boot write to %s until config recovery completes", filename);
return true;
}
// do not try to save anything if power level is not safe. In many cases flash will be lock-protected // 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. // and all writes will fail anyway. Device should be sleeping at this point anyway.
if (!powerHAL_isPowerLevelSafe()) { if (!powerHAL_isPowerLevelSafe()) {
@@ -2953,6 +3104,19 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
spiLock->unlock(); spiLock->unlock();
#endif #endif
#if USERPREFS_EVENT_MODE
if (eventProfileStorageUnavailable) {
if (saveWhat & SEGMENT_CONFIG) {
LOG_WARN("Skipping 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");
saveWhat &= ~SEGMENT_CHANNELS;
}
}
#endif
if (saveWhat & SEGMENT_CONFIG) { if (saveWhat & SEGMENT_CONFIG) {
config.has_device = true; config.has_device = true;
config.has_display = true; config.has_display = true;
@@ -2980,6 +3144,7 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
moduleConfig.has_audio = true; moduleConfig.has_audio = true;
moduleConfig.has_paxcounter = true; moduleConfig.has_paxcounter = true;
moduleConfig.has_statusmessage = true; moduleConfig.has_statusmessage = true;
moduleConfig.has_tak = true;
#if !MESHTASTIC_EXCLUDE_BEACON #if !MESHTASTIC_EXCLUDE_BEACON
moduleConfig.has_mesh_beacon = true; moduleConfig.has_mesh_beacon = true;
#endif #endif
@@ -3120,6 +3285,9 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly)
#include "MeshModule.h" #include "MeshModule.h"
#include "Throttle.h" #include "Throttle.h"
// Minimum spacing between evictions once the node database is full.
#define NODEDB_FULL_EVICTION_INTERVAL_MS (2 * 1000UL)
static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000; static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000;
void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context) void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context)
@@ -3268,6 +3436,9 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
LOG_WARN(PROTECTED_CAP_WARN_FMT, "ignore", contact.node_num, MAX_NUM_NODES - 2); LOG_WARN(PROTECTED_CAP_WARN_FMT, "ignore", contact.node_num, MAX_NUM_NODES - 2);
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false); nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false);
eraseNodeSatellites(contact.node_num); eraseNodeSatellites(contact.node_num);
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
messageStore.deleteAllMessagesFromNode(contact.node_num);
#endif
} else { } else {
/* Clients are sending add_contact before every text message DM (because clients may hold a larger node database with /* Clients are sending add_contact before every text message DM (because clients may hold a larger node database with
* public keys than the radio holds). However, we don't want to update last_heard just because we sent someone a DM! * public keys than the radio holds). However, we don't want to update last_heard just because we sent someone a DM!
@@ -3307,8 +3478,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
/** Update user info and channel for this node based on received user data /** Update user info and channel for this node based on received user data
*/ */
bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex) bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned)
{ {
// Only a signed update may change the identity of a node that has proven it signs; our own record is
// exempt. Checked before getOrCreateMeshNode so a refused update cannot evict or write the warm tier.
const meshtastic_NodeInfoLite *existing = getMeshNode(nodeId);
if (nodeId != getNodeNum() && existing && nodeInfoLiteHasXeddsaSigned(existing) && !xeddsaSigned) {
LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId);
return false;
}
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId); meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
if (!info) { if (!info) {
return false; return false;
@@ -3390,6 +3569,20 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde
} }
} }
#if HAS_TRAFFIC_MANAGEMENT
// Write-through: every accepted remote-identity commit lands here (NodeInfoModule,
// MeshService, and TMM's requester learning all funnel through updateUser; the two
// key-write sites that bypass it call onNodeKeyCommitted instead), so TMM's NodeInfo
// cache reflects the commit immediately rather than at the next reconcile pass. Runs on
// acceptance, not on `changed`: an identical update still proves the identity is
// current. `p` is the post-hygiene payload; signerKnown transfers only key-matched
// verified-signer status (isVerifiedSignerForKey semantics), never a bare node flag.
if (nodeId != getNodeNum() && trafficManagementModule) {
const bool signerKnown = p.public_key.size == 32 && isVerifiedSignerForKey(nodeId, p.public_key.bytes);
trafficManagementModule->onNodeIdentityCommitted(nodeId, p, signerKnown);
}
#endif
return changed; return changed;
} }
@@ -3404,7 +3597,19 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.from) { 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_DEBUG("Update DB node 0x%08x, rx_time=%u", mp.from, mp.rx_time);
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getFrom(&mp)); // 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.
meshtastic_NodeInfoLite *info = getMeshNode(getFrom(&mp));
if (!info) {
if (isFull()) {
if (Throttle::isWithinTimespanMs(lastFullEvictionMs, NODEDB_FULL_EVICTION_INTERVAL_MS)) {
LOG_DEBUG("Node database full, defer admitting 0x%08x", mp.from);
return;
}
lastFullEvictionMs = millis();
}
info = getOrCreateMeshNode(getFrom(&mp));
}
if (!info) { if (!info) {
return; return;
} }
@@ -3688,7 +3893,7 @@ uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const
return 0; return 0;
} }
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) bool NodeDB::copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
{ {
const meshtastic_NodeInfoLite *info = getMeshNode(n); const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info && info->public_key.size == 32) { if (info && info->public_key.size == 32) {
@@ -3704,6 +3909,97 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
return false; return false;
} }
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
{
if (copyPublicKeyAuthoritative(n, out))
return true;
#if HAS_TRAFFIC_MANAGEMENT
// Last resort: a key the TrafficManagement NodeInfo cache learned from an observed frame
// for a node no longer in either NodeDB tier. This extends the pool of peers we can
// encrypt to. Keys here may be trust-on-first-use (see copyPublicKey's signerProven), the
// same first-contact trust NodeDB itself applies via updateUser().
if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes)) {
out.size = 32;
return true;
}
#endif
return false;
}
bool NodeDB::copyPublicKeyForDecrypt(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
{
if (copyPublicKeyAuthoritative(n, out))
return true;
#if HAS_TRAFFIC_MANAGEMENT
// A cold-tier cache key backs an authenticated decrypt only when signer-proven; unverified TOFU
// cache keys must not. Outbound encryption still uses the opportunistic copyPublicKey().
bool signerProven = false;
if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes, &signerProven) && signerProven) {
out.size = 32;
return true;
}
#endif
return false;
}
bool NodeDB::isVerifiedSignerForKey(NodeNum n, const uint8_t *key32)
{
if (!key32)
return false;
// Hot store is authoritative when present; a node lives in the hot XOR warm tier, so if the
// hot store holds it the warm tier does not, and we decide entirely from the hot entry.
const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info)
return info->public_key.size == 32 && nodeInfoLiteHasXeddsaSigned(info) && memcmp(info->public_key.bytes, key32, 32) == 0;
#if WARM_NODE_COUNT > 0
uint8_t warmKey[32];
if (warmStore.copyKey(n, warmKey) && memcmp(warmKey, key32, 32) == 0)
return warmStore.isVerifiedSigner(n);
#endif
return false;
}
bool NodeDB::isKnownXeddsaSigner(NodeNum n)
{
// A node lives in the hot XOR warm tier, so the hot verdict is final when present.
const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info)
return nodeInfoLiteHasXeddsaSigned(info);
#if WARM_NODE_COUNT > 0
return warmStore.isVerifiedSigner(n);
#else
return false;
#endif
}
void NodeDB::commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust)
{
if (!key32 || n == 0)
return;
// Local copy first: callers may pass the node's own key bytes back in (e.g. manual
// verification re-committing an already-stored key), and memcpy forbids overlap.
uint8_t key[32];
memcpy(key, key32, 32);
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(n);
if (!info)
return;
// Unconditional overwrite - deliberately NOT updateUser()'s "don't replace a known key" pin.
// That pin protects against unauthenticated NodeInfo broadcasts; the only callers here are
// possession/authority-proven (ManuallyVerified = user confirmed the key; AdminChannelProven =
// decrypted via the admin key with p->from bound into the AEAD nonce), i.e. exactly the paths
// meant to establish or rotate a key. Keep new call sites to that same trust bar.
memcpy(info->public_key.bytes, key, 32);
info->public_key.size = 32;
#if HAS_TRAFFIC_MANAGEMENT
// Write-through, mirroring updateUser()'s identity hook: without it the TrafficManagement
// NodeInfo cache diverges until the next hourly reconcile.
if (trafficManagementModule)
trafficManagementModule->onNodeKeyCommitted(n, key, trust == KeyCommitTrust::ManuallyVerified);
#endif
}
meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n) meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n)
{ {
const meshtastic_NodeInfoLite *info = getMeshNode(n); const meshtastic_NodeInfoLite *info = getMeshNode(n);
@@ -3799,8 +4095,27 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
lite->public_key.size = 32; lite->public_key.size = 32;
memcpy(lite->public_key.bytes, warm.public_key, 32); memcpy(lite->public_key.bytes, warm.public_key, 32);
} }
if (warmProtOf(warm) == static_cast<uint8_t>(WarmProtected::XeddsaSigner))
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32); LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32);
} }
#endif
#if HAS_TRAFFIC_MANAGEMENT
// Name rehydration: the warm tier keeps a node's key but not its name, so a re-admitted
// long-tail node is nameless until its next NodeInfo. The TrafficManagement NodeInfo
// cache is much larger and often still holds the full User. Restore it - but only when
// its cached key matches the key we just restored from warm, so a name never attaches to
// a different identity than the one we encrypt to. No-op without the TMM NodeInfo cache
// or when no key is present (key-matched by design). CopyUserToNodeInfoLite sets only the
// user-related bits, so the warm-restored signer bit survives.
if (lite->public_key.size == 32 && !nodeInfoLiteHasUser(lite) && trafficManagementModule) {
meshtastic_User tmmUser = meshtastic_User_init_zero;
if (trafficManagementModule->copyUser(n, tmmUser) && tmmUser.public_key.size == 32 &&
memcmp(tmmUser.public_key.bytes, lite->public_key.bytes, 32) == 0) {
TypeConversions::CopyUserToNodeInfoLite(lite, tmmUser);
LOG_INFO("Rehydrated node 0x%08x identity from TMM NodeInfo cache", n);
}
}
#endif #endif
LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap()); LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap());
} }
@@ -3851,15 +4166,14 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub
bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
{ {
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
// Only generate keys for non-licensed users and if the LoRa region is set. The native simulator // Generate identity keys once a LoRa region is set. Licensed operation still needs the identity
// boots region-UNSET but still needs a keypair so PKI-encrypted DMs work between sim nodes, so // key for plaintext signatures, even though the key is never used for PKI encryption.
// allow keygen there regardless of region.
bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET; bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET;
#if ARCH_PORTDUINO #if ARCH_PORTDUINO
if (portduino_config.lora_module == use_simradio) if (portduino_config.lora_module == use_simradio)
regionBlocksKeygen = false; regionBlocksKeygen = false;
#endif #endif
if (owner.is_licensed || regionBlocksKeygen) { if (regionBlocksKeygen) {
return false; return false;
} }
@@ -3909,8 +4223,8 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
LOG_DEBUG("Set DH private key for crypto operations"); LOG_DEBUG("Set DH private key for crypto operations");
crypto->setDHPrivateKey(config.security.private_key.bytes); crypto->setDHPrivateKey(config.security.private_key.bytes);
// Conditionally create new identity based on parameter if (createNewIdentity() && owner.is_licensed)
createNewIdentity(); licensedIdentityMigrationPending = true;
} }
return keygenSuccess; return keygenSuccess;
#else #else
@@ -3918,6 +4232,21 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
#endif #endif
} }
bool NodeDB::notifyPendingLicensedIdentityMigration()
{
if (!licensedIdentityMigrationPending || !service)
return false;
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
if (!notification)
return false;
notification->level = meshtastic_LogRecord_Level_WARNING;
notification->time = getValidTime(RTCQualityFromNet);
snprintf(notification->message, sizeof(notification->message), "%s", LICENSED_IDENTITY_MIGRATION_WARNING);
service->sendClientNotification(notification);
licensedIdentityMigrationPending = false;
return true;
}
bool NodeDB::createNewIdentity() bool NodeDB::createNewIdentity()
{ {
uint32_t oldNodeNum = getNodeNum(); uint32_t oldNodeNum = getNodeNum();
@@ -4021,6 +4350,13 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location,
LOG_DEBUG("Restored channels"); LOG_DEBUG("Restored channels");
} }
if (owner.is_licensed && channels.ensureLicensedOperation()) {
restoreWhat |= SEGMENT_CHANNELS;
LOG_WARN("Licensed operation sanitized restored channel encryption/admin access");
}
if (restoreWhat & SEGMENT_CHANNELS)
channels.onConfigChanged();
success = saveToDisk(restoreWhat); success = saveToDisk(restoreWhat);
if (success) { if (success) {
LOG_INFO("Restored preferences from backup"); LOG_INFO("Restored preferences from backup");
+101 -9
View File
@@ -74,6 +74,8 @@ static const uint8_t LOW_ENTROPY_HASHES[][32] = {
0x37, 0x82, 0x8d, 0xb2, 0xcc, 0xd8, 0x97, 0x40, 0x9a, 0x5c, 0x8f, 0x40, 0x55, 0xcb, 0x4c, 0x3e}}; 0x37, 0x82, 0x8d, 0xb2, 0xcc, 0xd8, 0x97, 0x40, 0x9a, 0x5c, 0x8f, 0x40, 0x55, 0xcb, 0x4c, 0x3e}};
static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated."; static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated.";
#endif #endif
static const char LICENSED_IDENTITY_MIGRATION_WARNING[] =
"Licensed signing generated a new identity key; this node identity changed.";
/* /*
DeviceState versions used to be defined in the .proto file but really only this function cares. So changed to a DeviceState versions used to be defined in the .proto file but really only this function cares. So changed to a
#define here. #define here.
@@ -112,11 +114,57 @@ extern meshtastic_Position localPosition;
static constexpr const char *deviceStateFileName = "/prefs/device.proto"; static constexpr const char *deviceStateFileName = "/prefs/device.proto";
static constexpr const char *legacyPrefFileName = "/prefs/db.proto"; static constexpr const char *legacyPrefFileName = "/prefs/db.proto";
static constexpr const char *nodeDatabaseFileName = "/prefs/nodes.proto"; static constexpr const char *nodeDatabaseFileName = "/prefs/nodes.proto";
static constexpr const char *configFileName = "/prefs/config.proto"; // Event builds isolate radio profiles so normal firmware resumes its config and channels after OTA.
static constexpr const char *STANDARD_CONFIG_FILE_NAME = "/prefs/config.proto";
static constexpr const char *STANDARD_CHANNEL_FILE_NAME = "/prefs/channels.proto";
static constexpr const char *EVENT_CONFIG_FILE_NAME = "/prefs/event-config.proto";
static constexpr const char *EVENT_CHANNEL_FILE_NAME = "/prefs/event-channels.proto";
static constexpr const char *STANDARD_BACKUP_FILE_NAME = "/backups/backup.proto";
static constexpr const char *EVENT_BACKUP_FILE_NAME = "/backups/event-backup.proto";
struct RadioProfileStoragePaths {
const char *config;
const char *channels;
const char *backup;
};
constexpr RadioProfileStoragePaths radioProfileStoragePaths(bool eventMode)
{
return eventMode ? RadioProfileStoragePaths{EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME}
: RadioProfileStoragePaths{STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME};
}
// Reserve event files and their atomic temporaries before seeding, preventing retry formatting on full storage.
static constexpr size_t EVENT_PROFILE_STORAGE_RESERVATION_BYTES = 2 * (meshtastic_LocalConfig_size + meshtastic_ChannelFile_size);
constexpr bool hasEventProfileStorageSpace(size_t totalBytes, size_t usedBytes)
{
return usedBytes <= totalBytes && totalBytes - usedBytes >= EVENT_PROFILE_STORAGE_RESERVATION_BYTES;
}
constexpr bool shouldDeferBootPersistence(bool bootInitializationInProgress, bool configLoadComplete, bool configDecodeFailed)
{
return bootInitializationInProgress && (!configLoadComplete || configDecodeFailed);
}
#if USERPREFS_EVENT_MODE
static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(true);
#else
static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(false);
#endif
static constexpr const char *configFileName = RADIO_PROFILE_STORAGE.config;
static constexpr const char *channelFileName = RADIO_PROFILE_STORAGE.channels;
static constexpr const char *backupFileName = RADIO_PROFILE_STORAGE.backup;
static constexpr const char *uiconfigFileName = "/prefs/uiconfig.proto"; static constexpr const char *uiconfigFileName = "/prefs/uiconfig.proto";
static constexpr const char *moduleConfigFileName = "/prefs/module.proto"; static constexpr const char *moduleConfigFileName = "/prefs/module.proto";
static constexpr const char *channelFileName = "/prefs/channels.proto";
static constexpr const char *backupFileName = "/backups/backup.proto"; // An unverified config load only endangers the radio profile, so only these files take part in
// boot-write deferral. Lives next to the path table above so the two cannot drift apart.
inline bool isRadioProfileFile(const char *filename)
{
return strcmp(filename, configFileName) == 0 || strcmp(filename, channelFileName) == 0 ||
strcmp(filename, backupFileName) == 0;
}
/// Given a node, return how many seconds in the past (vs now) that we last heard from it /// Given a node, return how many seconds in the past (vs now) that we last heard from it
uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n); uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n);
@@ -221,6 +269,7 @@ class NodeDB
bool keyIsLowEntropy = false; bool keyIsLowEntropy = false;
bool hasWarned = false; bool hasWarned = false;
bool licensedIdentityMigrationPending = false;
/// don't do mesh based algorithm for node id assignment (initially) /// don't do mesh based algorithm for node id assignment (initially)
/// instead just store in flash - possibly even in the initial alpha release do this hack /// instead just store in flash - possibly even in the initial alpha release do this hack
@@ -255,9 +304,9 @@ class NodeDB
*/ */
void updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxSource src = RX_SRC_RADIO); void updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxSource src = RX_SRC_RADIO);
/** Update user info and channel for this node based on received user data /** Update user info and channel for this node based on received user data.
*/ * A known signer's identity is only learned when xeddsaSigned; defaults false so callers fail closed. */
bool updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex = 0); bool updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex = 0, bool xeddsaSigned = false);
/* /*
* Sets a node either favorite or unfavorite. Returns true if the node ends * Sets a node either favorite or unfavorite. Returns true if the node ends
@@ -360,6 +409,37 @@ class NodeDB
/// tier. Returns false if we don't know a key for n. /// tier. Returns false if we don't know a key for n.
bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
/// Copy the 32-byte key for n from the AUTHORITATIVE tiers only (hot, then warm; never
/// opportunistic caches) - the pin reference for caches that mirror NodeDB's key hygiene.
bool copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
/// Key for the inbound-decrypt path: authoritative (hot/warm), or a cold-tier cache key only when
/// it is signer-proven. Keeps unverified TOFU cache keys from backing pki_encrypted attribution.
bool copyPublicKeyForDecrypt(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
/// True if n is a known XEdDSA signer for exactly `key32` (hot signed bitfield or warm
/// signer bit); the key match stops a rotated key inheriting a stale signer verdict.
bool isVerifiedSignerForKey(NodeNum n, const uint8_t *key32);
/// Key-agnostic "should n's signable traffic arrive signed", per hot bitfield or warm signer
/// bit - hot-only gates would let a warm-evicted signer be impersonated with unsigned frames.
bool isKnownXeddsaSigner(NodeNum n);
/// Provenance of a bare-key commit that deliberately bypasses updateUser()'s
/// User-payload / TOFU-pin path. Maps to the TrafficManagement cache's `proven` flag:
/// only ManuallyVerified vouches for possession of exactly this key.
enum class KeyCommitTrust : uint8_t {
AdminChannelProven, // possession shown to the admin channel (AEAD) - TOFU-grade for signing
ManuallyVerified, // the user confirmed possession of exactly this key
};
/// THE primitive for key writes that bypass updateUser() (no User payload; provenance
/// differs from a received NodeInfo): writes the 32-byte key to the hot store and
/// write-through to the TrafficManagement NodeInfo cache. Any future direct key-write
/// site must call this rather than assigning info->public_key, or the TrafficManagement
/// cache silently diverges until the next hourly reconcile.
void commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust);
/// Resolve a node's device role - hot store (with user) first, then the role /// Resolve a node's device role - hot store (with user) first, then the role
/// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for /// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for
/// nodes that have aged out of the hot store. /// nodes that have aged out of the hot store.
@@ -474,6 +554,8 @@ class NodeDB
/// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys. /// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys.
bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr); bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr);
bool notifyPendingLicensedIdentityMigration();
bool createNewIdentity(); bool createNewIdentity();
bool backupPreferences(meshtastic_AdminMessage_BackupLocation location); bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
@@ -529,9 +611,18 @@ class NodeDB
/// skip boot keygen and skip persisting defaults, so a transient read failure can't change our NodeNum /// skip boot keygen and skip persisting defaults, so a transient read failure can't change our NodeNum
/// or overwrite the on-disk config. Cleared at the top of every loadFromDisk() run. /// or overwrite the on-disk config. Cleared at the top of every loadFromDisk() run.
bool configDecodeFailed = false; bool configDecodeFailed = false;
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash // Defer automatic writes until config load is healthy to protect device and node data from damaged configs.
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually bool bootInitializationInProgress = true;
uint32_t lastSort = 0; // When last sorted the nodeDB bool configLoadComplete = false;
#if USERPREFS_EVENT_MODE
// The active event profile is intentionally non-durable when there was not
// enough room to create it safely at boot. A later boot retries the check.
bool eventProfileStorageUnavailable = false;
#endif
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
uint32_t lastFullEvictionMs = 0; // when we last evicted to admit a new node, once the db is full
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
uint32_t lastSort = 0; // When last sorted the nodeDB
/* /*
* Internal boolean to track sorting paused * Internal boolean to track sorting paused
@@ -548,6 +639,7 @@ class NodeDB
// Grant the unit-test shim access to the private maintenance paths below // Grant the unit-test shim access to the private maintenance paths below
// (migration / cleanup / eviction) without relaxing production access. // (migration / cleanup / eviction) without relaxing production access.
friend class NodeDBTestShim; friend class NodeDBTestShim;
friend class MockNodeDB;
#endif #endif
/// purge db entries without user info /// purge db entries without user info
+5
View File
@@ -887,6 +887,11 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag; fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag;
fromRadioScratch.moduleConfig.payload_variant.paxcounter = moduleConfig.paxcounter; fromRadioScratch.moduleConfig.payload_variant.paxcounter = moduleConfig.paxcounter;
break; break;
case meshtastic_ModuleConfig_statusmessage_tag:
LOG_DEBUG("Send module config: status message");
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_statusmessage_tag;
fromRadioScratch.moduleConfig.payload_variant.statusmessage = moduleConfig.statusmessage;
break;
case meshtastic_ModuleConfig_traffic_management_tag: case meshtastic_ModuleConfig_traffic_management_tag:
LOG_DEBUG("Send module config: traffic management"); LOG_DEBUG("Send module config: traffic management");
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag; fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
+2
View File
@@ -44,6 +44,8 @@ template <class T> class ProtobufModule : protected SinglePortModule
{ {
// Update our local node info with our position (even if we don't decide to update anyone else) // Update our local node info with our position (even if we don't decide to update anyone else)
meshtastic_MeshPacket *p = allocDataPacket(); meshtastic_MeshPacket *p = allocDataPacket();
if (!p)
return nullptr;
p->decoded.payload.size = p->decoded.payload.size =
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), fields, &payload); pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), fields, &payload);
+39 -18
View File
@@ -55,6 +55,34 @@ static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_NARROW[] = {PRESET
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_TINY[] = {PRESET(TINY_FAST), PRESET(TINY_SLOW), MODEM_PRESET_END}; static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_TINY[] = {PRESET(TINY_FAST), PRESET(TINY_SLOW), MODEM_PRESET_END};
// The EU_868/EU_866/EU_N_868 trio share the 868 band but own mutually exclusive preset
// profiles. Selecting a preset locked to a sibling swaps the region to that sibling (see
// regionSwapForPreset), so from any region in the trio every one of these presets is
// selectable. This union is what we advertise to clients as the trio's legal list. It is a
// display-only superset: on-device enforcement still uses each region's own disjoint
// profile->presets, so this must never be assigned to a RegionProfile (that would make
// supportsPreset() accept the preset in place and defeat the swap). Keep in sync with the
// EU_868/EU_866/EU_N_868 profile lists below. Sized to the 11-preset wire cap.
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_SUPERSET[] = {
PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), PRESET(SHORT_SLOW), PRESET(SHORT_FAST),
PRESET(LONG_MODERATE), PRESET(LITE_FAST), PRESET(LITE_SLOW), PRESET(NARROW_FAST), PRESET(NARROW_SLOW), MODEM_PRESET_END};
// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset
// locked to a sibling means the user wants that sibling region, not the default preset.
static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = {
meshtastic_Config_LoRaConfig_RegionCode_EU_868,
meshtastic_Config_LoRaConfig_RegionCode_EU_866,
meshtastic_Config_LoRaConfig_RegionCode_EU_N_868,
};
static bool isSwappableEuRegion(meshtastic_Config_LoRaConfig_RegionCode code)
{
for (auto c : SWAPPABLE_EU_REGIONS)
if (c == code)
return true;
return false;
}
// Region profiles: bundle preset list + regulatory parameters shared across regions // Region profiles: bundle preset list + regulatory parameters shared across regions
// presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle // presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle
const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1}; const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1};
@@ -687,8 +715,13 @@ void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map)
grp.default_preset = r->getDefaultPreset(); grp.default_preset = r->getDefaultPreset();
grp.licensed_only = r->profile->licensedOnly; grp.licensed_only = r->profile->licensedOnly;
grp.presets_count = 0; grp.presets_count = 0;
for (size_t i = 0; r->profile->presets[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++) // EU 86x siblings advertise the trio's superset - any of those presets is
grp.presets[grp.presets_count++] = r->profile->presets[i]; // reachable from here via an automatic region swap. Every other region
// advertises exactly its own enforced profile list.
const meshtastic_Config_LoRaConfig_ModemPreset *advertised =
isSwappableEuRegion(r->code) ? PRESETS_EU_SUPERSET : r->profile->presets;
for (size_t i = 0; advertised[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++)
grp.presets[grp.presets_count++] = advertised[i];
} }
// Map this region to its group (capacity checked at the top of the loop). // Map this region to its group (capacity checked at the top of the loop).
@@ -963,14 +996,6 @@ static void sendErrorNotification(const char *msg, meshtastic_LogRecord_Level le
service->sendClientNotification(cn); service->sendClientNotification(cn);
} }
// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset
// locked to a sibling means the user wants that sibling region, not the default preset.
static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = {
meshtastic_Config_LoRaConfig_RegionCode_EU_868,
meshtastic_Config_LoRaConfig_RegionCode_EU_866,
meshtastic_Config_LoRaConfig_RegionCode_EU_N_868,
};
/** /**
* If currentRegion is one of the swappable EU regions and preset belongs to a sibling in * If currentRegion is one of the swappable EU regions and preset belongs to a sibling in
* that trio, return the sibling region that owns the preset. Returns nullptr otherwise. * that trio, return the sibling region that owns the preset. Returns nullptr otherwise.
@@ -978,12 +1003,7 @@ static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = {
const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion, const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion,
meshtastic_Config_LoRaConfig_ModemPreset preset) meshtastic_Config_LoRaConfig_ModemPreset preset)
{ {
bool currentIsSwappable = false; if (!isSwappableEuRegion(currentRegion))
for (auto code : SWAPPABLE_EU_REGIONS) {
if (code == currentRegion)
currentIsSwappable = true;
}
if (!currentIsSwappable)
return nullptr; return nullptr;
for (auto code : SWAPPABLE_EU_REGIONS) { for (auto code : SWAPPABLE_EU_REGIONS) {
@@ -1002,7 +1022,8 @@ const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConf
* receives the human-readable failure reason. * receives the human-readable failure reason.
* Returns false if not compatible. * Returns false if not compatible.
*/ */
bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen) bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen,
bool prospectiveLicensedOwner)
{ {
const RegionInfo *newRegion = getRegion(loraConfig.region); const RegionInfo *newRegion = getRegion(loraConfig.region);
@@ -1014,7 +1035,7 @@ bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraC
} }
// If you are not licensed, you can't use ham regions. // If you are not licensed, you can't use ham regions.
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) { if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed && !prospectiveLicensedOwner) {
if (errBuf) if (errBuf)
snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name); snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name);
return false; return false;
+4 -2
View File
@@ -256,8 +256,10 @@ class RadioInterface
static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp); static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp);
// Check if a candidate region is compatible and valid, with no side effects (safe for // Check if a candidate region is compatible and valid, with no side effects (safe for
// speculative UI checks). errBuf, if given, receives the failure reason. // speculative UI checks). prospectiveLicensedOwner is for a UI flow that requires
static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0); // confirmation before it sets the owner licensed. errBuf, if given, receives the failure reason.
static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0,
bool prospectiveLicensedOwner = false);
// Check if a candidate region is compatible and valid. On failure, logs at ERROR, // Check if a candidate region is compatible and valid. On failure, logs at ERROR,
// records a critical error, and sends a client notification. // records a critical error, and sends a client notification.
+524 -85
View File
@@ -12,9 +12,10 @@
#include "mesh-pb-constants.h" #include "mesh-pb-constants.h"
#include "meshUtils.h" #include "meshUtils.h"
#include "modules/RoutingModule.h" #include "modules/RoutingModule.h"
#include <ErriezCRC32.h>
#include <pb_decode.h>
#include <pb_encode.h> #include <pb_encode.h>
#if HAS_TRAFFIC_MANAGEMENT #if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif #endif
#if HAS_VARIABLE_HOPS #if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h" #include "modules/HopScalingModule.h"
@@ -67,6 +68,79 @@ Allocator<meshtastic_MeshPacket> &packetPool = staticPool;
static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1] __attribute__((__aligned__)); static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1] __attribute__((__aligned__));
struct RoutingAuthCache {
bool valid = false;
// Deliberately NOT initialized in-class as this eats flash space.
meshtastic_Config_SecurityConfig_PacketSignaturePolicy policy;
meshtastic_MeshPacket wire = meshtastic_MeshPacket_init_zero;
meshtastic_MeshPacket authenticated = meshtastic_MeshPacket_init_zero;
};
static RoutingAuthCache routingAuthCache;
static concurrency::Lock *routingAuthCacheLock;
static uint32_t routingAuthEvaluations;
static bool routingAuthCacheMatches(const meshtastic_MeshPacket &packet)
{
if (!routingAuthCacheLock)
return false;
concurrency::LockGuard guard(routingAuthCacheLock);
if (!routingAuthCache.valid)
return false;
if (routingAuthCache.policy != config.security.packet_signature_policy ||
memcmp(&routingAuthCache.wire, &packet, sizeof(packet)) != 0) {
routingAuthCache.valid = false;
return false;
}
return true;
}
static void storeRoutingAuthCache(const meshtastic_MeshPacket &wire, const meshtastic_MeshPacket &authenticated)
{
concurrency::LockGuard guard(routingAuthCacheLock);
routingAuthCache.wire = wire;
routingAuthCache.authenticated = authenticated;
routingAuthCache.policy = config.security.packet_signature_policy;
routingAuthCache.valid = true;
}
static bool applyRoutingAuthCache(meshtastic_MeshPacket *packet)
{
if (!routingAuthCacheLock)
return false;
concurrency::LockGuard guard(routingAuthCacheLock);
if (!routingAuthCache.valid || routingAuthCache.policy != config.security.packet_signature_policy ||
memcmp(&routingAuthCache.wire, packet, sizeof(*packet)) != 0) {
routingAuthCache.valid = false;
return false;
}
*packet = routingAuthCache.authenticated;
routingAuthCache.valid = false;
return true;
}
static void clearRoutingAuthCache()
{
if (!routingAuthCacheLock)
return;
concurrency::LockGuard guard(routingAuthCacheLock);
routingAuthCache.valid = false;
}
#ifdef PIO_UNIT_TESTING
uint32_t routingAuthEvaluationCount()
{
return routingAuthEvaluations;
}
void resetRoutingAuthEvaluationCount()
{
routingAuthEvaluations = 0;
if (routingAuthCacheLock) {
concurrency::LockGuard guard(routingAuthCacheLock);
routingAuthCache.valid = false;
}
}
#endif
/** /**
* Constructor * Constructor
* *
@@ -85,6 +159,10 @@ Router::Router() : concurrency::OSThread("Router"), fromRadioQueue(MAX_RX_FROMRA
// init Lockguard for crypt operations // init Lockguard for crypt operations
assert(!cryptLock); assert(!cryptLock);
cryptLock = new concurrency::Lock(); cryptLock = new concurrency::Lock();
if (!routingAuthCacheLock)
routingAuthCacheLock = new concurrency::Lock();
// Runtime default for the auth-cache snapshot policy. Keep it here, saves flash.
routingAuthCache.policy = meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED;
} }
bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p) bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p)
@@ -194,6 +272,8 @@ PacketId generatePacketId()
meshtastic_MeshPacket *Router::allocForSending() meshtastic_MeshPacket *Router::allocForSending()
{ {
meshtastic_MeshPacket *p = packetPool.allocZeroed(); meshtastic_MeshPacket *p = packetPool.allocZeroed();
if (!p)
return nullptr;
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // Assume payload is decoded at start. p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // Assume payload is decoded at start.
p->from = nodeDB->getNodeNum(); p->from = nodeDB->getNodeNum();
@@ -247,8 +327,10 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src)
// No need to deliver externally if the destination is the local node // No need to deliver externally if the destination is the local node
if (isToUs(p)) { if (isToUs(p)) {
printPacket("Enqueued local", p); printPacket("Enqueued local", p);
enqueueReceivedMessage(p); // Preserve the trusted origin explicitly. Queueing used to erase src and make a local
return ERRNO_OK; // phone/module packet indistinguishable from remote already-decoded ingress.
deliverLocal(p, src);
return ERRNO_SHOULD_RELEASE;
} else if (!iface) { } else if (!iface) {
// We must be sending to remote nodes also, fail if no interface found // We must be sending to remote nodes also, fail if no interface found
abortSendAndNak(meshtastic_Routing_Error_NO_INTERFACE, p); abortSendAndNak(meshtastic_Routing_Error_NO_INTERFACE, p);
@@ -256,9 +338,10 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src)
return ERRNO_NO_INTERFACES; return ERRNO_NO_INTERFACES;
} else { } else {
// If we are sending a broadcast, we also treat it as if we just received it ourself // If we are sending a broadcast, we also treat it as if we just received it ourself
// this allows local apps (and PCs) to see broadcasts sourced locally // this allows local apps (and PCs) to see broadcasts sourced locally. Only the loopback
// handleReceived is deferred when nested; send(p) below still transmits immediately.
if (isBroadcast(p->to)) { if (isBroadcast(p->to)) {
handleReceived(p, src); deliverLocal(p, src);
} }
// don't override if a channel was requested and no need to set it when PKI is enforced // don't override if a channel was requested and no need to set it when PKI is enforced
@@ -280,15 +363,6 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src)
return send(p); return send(p);
} }
} }
/**
* Send a packet on a suitable interface.
*/
ErrorCode Router::rawSend(meshtastic_MeshPacket *p)
{
assert(iface); // This should have been detected already in sendLocal (or we just received a packet from outside)
return iface->send(p);
}
/** /**
* Send a packet on a suitable interface. This routine will * Send a packet on a suitable interface. This routine will
* later free() the packet to pool. This routine is not allowed to stall. * later free() the packet to pool. This routine is not allowed to stall.
@@ -452,18 +526,111 @@ void Router::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Rout
} }
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
/** Size a decoded Data as the sender's signedDataFits() gate would have, with padding stripped:
* unknown fields inside Data.payload survive in payload.size and would otherwise let a forger
* inflate an unsigned broadcast past the signable budget. Returns false only if sizing failed.
* Sizing only what this build's schema decodes, so a signable type that later grows needs its
* legitimate maximum re-checked against the budget or honest unsigned broadcasts get dropped. */
static bool canonicalSignableSize(meshtastic_Data *d, size_t *size)
{
const pb_msgdesc_t *fields = nullptr;
switch (d->portnum) {
case meshtastic_PortNum_POSITION_APP:
fields = &meshtastic_Position_msg;
break;
case meshtastic_PortNum_TELEMETRY_APP:
fields = &meshtastic_Telemetry_msg;
break;
case meshtastic_PortNum_WAYPOINT_APP:
fields = &meshtastic_Waypoint_msg;
break;
case meshtastic_PortNum_NODEINFO_APP:
fields = &meshtastic_User_msg;
break;
default:
break;
}
if (fields) {
// Scratch kept off the stack: these decoded structs are large for the smaller MCU targets.
// Safe as file-static state because both callers of checkXeddsaReceivePolicy hold cryptLock.
static union {
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
meshtastic_Position position;
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
meshtastic_Telemetry telemetry;
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
meshtastic_Waypoint waypoint;
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
meshtastic_User user;
} inner;
memset(&inner, 0, sizeof(inner));
size_t canonicalPayload;
if (pb_decode_from_bytes(d->payload.bytes, d->payload.size, fields, &inner) &&
pb_get_encoded_size(&canonicalPayload, fields, &inner) && canonicalPayload <= d->payload.size) {
// Only the length matters when sizing a bytes field, so swap it in place instead of
// copying the whole Data; restored below because modules still need the real payload.
const pb_size_t prevSize = d->payload.size;
d->payload.size = (pb_size_t)canonicalPayload;
const bool sized = pb_get_encoded_size(size, &meshtastic_Data_msg, d);
d->payload.size = prevSize;
return sized;
}
}
return pb_get_encoded_size(size, &meshtastic_Data_msg, d);
}
enum class NodeInfoBootstrapResult { NOT_APPLICABLE, VERIFIED, INVALID };
static NodeInfoBootstrapResult verifyFirstContactNodeInfo(meshtastic_MeshPacket *p)
{
if (p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP)
return NodeInfoBootstrapResult::NOT_APPLICABLE;
meshtastic_User user = meshtastic_User_init_zero;
if (!pb_decode_from_bytes(p->decoded.payload.bytes, p->decoded.payload.size, &meshtastic_User_msg, &user) ||
user.public_key.size != 32 || crc32Buffer(user.public_key.bytes, user.public_key.size) != p->from ||
!crypto->xeddsa_verify(user.public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes)) {
return NodeInfoBootstrapResult::INVALID;
}
meshtastic_NodeInfoLite *node = nodeDB->getOrCreateMeshNode(p->from);
if (!node)
return NodeInfoBootstrapResult::INVALID;
node->public_key.size = user.public_key.size;
memcpy(node->public_key.bytes, user.public_key.bytes, user.public_key.size);
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
p->xeddsa_signed = true;
LOG_DEBUG("Verified first-contact XEdDSA NodeInfo from 0x%08x", p->from);
return NodeInfoBootstrapResult::VERIFIED;
}
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
{ {
const auto policy = config.security.packet_signature_policy;
const bool strict = policy == meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT;
const bool compatible = policy == meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE;
// Only a signature we verify below may mark this packet signed; never trust an inbound flag. // Only a signature we verify below may mark this packet signed; never trust an inbound flag.
p->xeddsa_signed = false; p->xeddsa_signed = false;
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) { if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
meshtastic_NodeInfoLite_public_key_t senderKey = {0, {0}};
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && node->public_key.size == 32) { if (nodeDB->copyPublicKey(p->from, senderKey)) {
p->xeddsa_signed = p->xeddsa_signed =
crypto->xeddsa_verify(node->public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, crypto->xeddsa_verify(senderKey.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes); p->decoded.payload.size, p->decoded.xeddsa_signature.bytes);
if (p->xeddsa_signed) { if (p->xeddsa_signed) {
// Learn this node as a signer, so a later unsigned signable broadcast from it is dropped // Learn this node as a signer, so a later unsigned signable broadcast from it is dropped
// A warm-tier key must be re-admitted before setting the signer bit; otherwise Balanced
// forgets downgrade protection as soon as the node is evicted from the hot store.
if (!node)
node = nodeDB->getOrCreateMeshNode(p->from);
if (!node)
return false;
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from); LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
} else { } else {
@@ -471,7 +638,16 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
return false; return false;
} }
} else { } else {
const auto bootstrap = verifyFirstContactNodeInfo(p);
if (bootstrap == NodeInfoBootstrapResult::INVALID) {
LOG_WARN("Invalid first-contact XEdDSA NodeInfo from 0x%08x, dropping", 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, cannot verify XEdDSA signature", p->from);
if (strict)
return false;
} }
} else if (p->decoded.xeddsa_signature.size != 0) { } else if (p->decoded.xeddsa_signature.size != 0) {
// A signature field that is neither empty nor a full 64 bytes is malformed - honest // A signature field that is neither empty nor a full 64 bytes is malformed - honest
@@ -482,16 +658,26 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
p->from); p->from);
return false; return false;
} else { } else {
// Truly unsigned (signature size 0) - only reject the class a signing node always signs: a if (p->pki_encrypted)
// non-PKI broadcast whose signed encoding would still fit the LoRa frame. Size p->decoded return true;
// canonically so this counts the same fields the sender's signedDataFits() gate counted; if (strict) {
// adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever LOG_WARN("Dropping unsigned packet from 0x%08x in Strict signature mode", p->from);
// fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a signature return false;
// are never signed, so they must not be hard-failed here even for a known signer. }
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); if (compatible)
if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) { return true;
// In Balanced, preserve legacy unsigned-unicast compatibility and only reject the class a
// signing node always signs: a non-PKI broadcast whose signed encoding would still fit the
// LoRa frame. Canonical sizing removes unknown protobuf fields before mirroring the
// sender-side signedDataFits() gate, so this counts the same fields that gate counted.
// Unicast packets and broadcasts too big to carry a signature are never signed, so they
// must not be hard-failed here even for a known signer (PKI already returned above).
// isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store
// must not become impersonatable via unsigned broadcasts until it is re-heard.
if (nodeDB->isKnownXeddsaSigner(p->from) && isBroadcast(p->to)) {
size_t canonicalSize; size_t canonicalSize;
if (!pb_get_encoded_size(&canonicalSize, &meshtastic_Data_msg, &p->decoded)) if (!canonicalSignableSize(&p->decoded, &canonicalSize))
return true; // can't size it; never drop on a sizing failure 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) { if (canonicalSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) {
LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from); LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from);
@@ -503,6 +689,103 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
} }
#endif #endif
RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p)
{
// Routing still needs the original encrypted representation for byte-for-byte relay and for
// MQTT uplink. Authenticate a copy here; handleReceived() performs the normal in-place decode
// only after stateful routing filters have completed.
if (routingAuthCacheMatches(*p))
return RoutingAuthVerdict::ACCEPT;
meshtastic_MeshPacket wire = *p;
meshtastic_MeshPacket authCandidate = *p;
routingAuthEvaluations++;
if (authCandidate.which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
// Already-decoded remote ingress (notably Portduino SimRadio) did not pass through a
// decryptor. Never trust serialized local authentication metadata on that boundary.
authCandidate.pki_encrypted = false;
authCandidate.public_key.size = 0;
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
concurrency::LockGuard g(cryptLock);
if (!checkXeddsaReceivePolicy(&authCandidate)) {
LOG_WARN("Already-decoded packet rejected by signature policy");
return RoutingAuthVerdict::REJECT;
}
#endif
p->xeddsa_signed = authCandidate.xeddsa_signed;
wire = *p;
storeRoutingAuthCache(wire, authCandidate);
return RoutingAuthVerdict::ACCEPT;
}
const DecodeState state = perhapsDecode(&authCandidate);
if (state == DecodeState::DECODE_POLICY_REJECT) {
LOG_WARN("Packet rejected by signature policy");
return RoutingAuthVerdict::REJECT;
}
if (state == DecodeState::DECODE_FATAL) {
LOG_WARN("Fatal decode error, dropping packet");
return RoutingAuthVerdict::REJECT;
}
if (state == DecodeState::DECODE_FAILURE) {
LOG_WARN("Decryptable packet failed decoding, dropping packet");
return RoutingAuthVerdict::REJECT;
}
// Only an explicit unknown-channel result remains eligible for opaque relay.
if (state == DecodeState::DECODE_OPAQUE)
return RoutingAuthVerdict::OPAQUE_RELAY_ONLY;
storeRoutingAuthCache(wire, authCandidate);
return RoutingAuthVerdict::ACCEPT;
}
#if !(MESHTASTIC_EXCLUDE_PKI)
// The fallback costs three X25519 ops before the AEAD tag is checked. Budget is global because p->from is
// attacker-controlled; successful runs refund, and their key is then persisted for the fast path.
#define ADMIN_KEY_FALLBACK_BURST 8
#define ADMIN_KEY_FALLBACK_REFILL_MS 250
static uint32_t adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST;
static uint32_t adminKeyFallbackRefillMs = 0;
static bool adminKeyFallbackAllowed()
{
bool haveAdminKey = false;
for (int i = 0; i < 3; i++) {
if (config.security.admin_key[i].size == 32) {
haveAdminKey = true;
break;
}
}
if (!haveAdminKey)
return false; // nothing to try, so do not spend a token
uint32_t now = millis();
if (adminKeyFallbackRefillMs == 0)
adminKeyFallbackRefillMs = now;
uint32_t elapsed = now - adminKeyFallbackRefillMs;
if (elapsed >= ADMIN_KEY_FALLBACK_REFILL_MS) {
uint32_t refill = elapsed / ADMIN_KEY_FALLBACK_REFILL_MS;
adminKeyFallbackRefillMs += refill * ADMIN_KEY_FALLBACK_REFILL_MS;
if (refill >= ADMIN_KEY_FALLBACK_BURST - adminKeyFallbackTokens)
adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST;
else
adminKeyFallbackTokens += refill;
}
if (adminKeyFallbackTokens == 0)
return false;
adminKeyFallbackTokens--;
return true;
}
static void adminKeyFallbackRefund()
{
if (adminKeyFallbackTokens < ADMIN_KEY_FALLBACK_BURST)
adminKeyFallbackTokens++;
}
#endif
DecodeState perhapsDecode(meshtastic_MeshPacket *p) DecodeState perhapsDecode(meshtastic_MeshPacket *p)
{ {
concurrency::LockGuard g(cryptLock); concurrency::LockGuard g(cryptLock);
@@ -516,39 +799,67 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag)
return DecodeState::DECODE_SUCCESS; // If packet was already decoded just return return DecodeState::DECODE_SUCCESS; // If packet was already decoded just return
// Authentication metadata is local-only. Re-establish it below only after successful PKI decryption.
p->pki_encrypted = false;
p->public_key.size = 0;
size_t rawSize = p->encrypted.size; size_t rawSize = p->encrypted.size;
if (rawSize > sizeof(bytes)) { if (rawSize > sizeof(bytes)) {
LOG_ERROR("Packet too large to attempt decryption! (rawSize=%d > 256)", rawSize); LOG_ERROR("Packet too large to attempt decryption! (rawSize=%d > 256)", rawSize);
return DecodeState::DECODE_FATAL; return DecodeState::DECODE_FATAL;
} }
bool decrypted = false; bool decrypted = false;
bool pkiAttempted = false;
bool licensedPkiCandidate = false;
bool matchedChannel = false;
ChannelIndex chIndex = 0; ChannelIndex chIndex = 0;
#if !(MESHTASTIC_EXCLUDE_PKI) #if !(MESHTASTIC_EXCLUDE_PKI)
// Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else
// fall back to a not-yet-committed key held during an in-progress key-verification handshake.
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic);
meshtastic_NodeInfoLite *ourNode = nullptr; meshtastic_NodeInfoLite *ourNode = nullptr;
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD && const bool pkiCandidate = p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) &&
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) { rawSize > MESHTASTIC_PKC_OVERHEAD && (ourNode = nodeDB->getMeshNode(p->to)) != nullptr &&
ourNode->public_key.size > 0;
if (pkiCandidate && owner.is_licensed) {
licensedPkiCandidate = true;
} else if (pkiCandidate) {
pkiAttempted = true;
LOG_DEBUG("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
// signer-proven - an unverified TOFU cache key must not back authenticated (pki_encrypted, p->from)
// DM attribution.
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
bool haveRemoteKey = nodeDB->copyPublicKeyForDecrypt(p->from, remotePublic);
// A pending key is an unverified identity claim supplied by whoever opened the handshake, so it is
// accepted only for the exchange itself (checked after decode). perhapsEncode applies the same rule.
bool havePendingKey = false;
if (!haveRemoteKey) {
havePendingKey = crypto->getPendingPublicKey(p->from, remotePublic);
haveRemoteKey = havePendingKey;
}
// Try the sender's known key first, then each configured admin key so an authorized admin can // Try the sender's known key first, then each configured admin key so an authorized admin can
// reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates. // reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates.
bool viaAdminKey = false; bool viaAdminKey = false;
bool viaPendingKey = false;
if (haveRemoteKey && crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { if (haveRemoteKey && crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
decrypted = true; decrypted = true;
viaPendingKey = havePendingKey;
} }
for (int i = 0; i < 3 && !decrypted; i++) { if (!decrypted && adminKeyFallbackAllowed()) {
if (config.security.admin_key[i].size != 32) for (int i = 0; i < 3 && !decrypted; i++) {
continue; if (config.security.admin_key[i].size != 32)
remotePublic.size = 32; continue;
memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32); remotePublic.size = 32;
memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32);
if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
decrypted = true; decrypted = true;
viaAdminKey = true; viaAdminKey = true;
break; // stop after first successful decryption break; // stop after first successful decryption
}
} }
if (decrypted)
adminKeyFallbackRefund();
} }
if (decrypted) { if (decrypted) {
LOG_INFO("PKI Decryption worked!"); LOG_INFO("PKI Decryption worked!");
@@ -557,6 +868,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD; size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD;
if (pb_decode_from_bytes(bytes, payloadSize, &meshtastic_Data_msg, &decodedtmp) && if (pb_decode_from_bytes(bytes, payloadSize, &meshtastic_Data_msg, &decodedtmp) &&
decodedtmp.portnum != meshtastic_PortNum_UNKNOWN_APP) { decodedtmp.portnum != meshtastic_PortNum_UNKNOWN_APP) {
if (viaPendingKey && decodedtmp.portnum != meshtastic_PortNum_KEY_VERIFICATION_APP) {
// The pending key only proves the handshake initiator holds it, not that they are
// p->from. Beyond the exchange it would let them send DMs that look authenticated.
LOG_WARN("Refusing pending-key decrypt of port %u from 0x%08x", (unsigned)decodedtmp.portnum, p->from);
return DecodeState::DECODE_FAILURE;
}
decrypted = true; decrypted = true;
rawSize = payloadSize; // commit the overhead subtraction only on full success rawSize = payloadSize; // commit the overhead subtraction only on full success
LOG_INFO("Packet decrypted using PKI!"); LOG_INFO("Packet decrypted using PKI!");
@@ -567,10 +884,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
if (viaAdminKey) { if (viaAdminKey) {
// Persist the admin key for the sender so future packets take the fast path and we can // Persist the admin key for the sender so future packets take the fast path and we can
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated it. // PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated
meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from); // it. commitRemoteKey is the bare-key commit primitive: it bypasses updateUser's
if (fromNode != nullptr) // User-payload path deliberately and handles the TrafficManagement write-through.
fromNode->public_key = remotePublic; // AdminChannelProven = possession shown to the admin channel, not via an XEdDSA
// NodeInfo signature, so the key stays TOFU-grade for signing purposes.
nodeDB->commitRemoteKey(p->from, remotePublic.bytes, NodeDB::KeyCommitTrust::AdminChannelProven);
} }
} else { } else {
// AEAD already authenticated this ciphertext, so no other candidate could decode it - // AEAD already authenticated this ciphertext, so no other candidate could decode it -
@@ -588,6 +907,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
for (chIndex = 0; chIndex < channels.getNumChannels(); chIndex++) { for (chIndex = 0; chIndex < channels.getNumChannels(); chIndex++) {
// Try to use this hash/channel pair // Try to use this hash/channel pair
if (channels.decryptForHash(chIndex, p->channel)) { if (channels.decryptForHash(chIndex, p->channel)) {
matchedChannel = true;
// we have to copy into a scratch buffer, because these bytes are a union with the decoded protobuf. Create a // we have to copy into a scratch buffer, because these bytes are a union with the decoded protobuf. Create a
// fresh copy for each decrypt attempt. // fresh copy for each decrypt attempt.
memcpy(bytes, p->encrypted.bytes, rawSize); memcpy(bytes, p->encrypted.bytes, rawSize);
@@ -623,10 +943,9 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
p->channel = chIndex; // change to store the index instead of the hash p->channel = chIndex; // change to store the index instead of the hash
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Runs before the bitfield merge below: that merge can set want_response, adding wire bytes // Run before merging local-only bitfield state into the decoded Data.
// the sender never encoded and skewing the policy's sizing of p->decoded.
if (!checkXeddsaReceivePolicy(p)) if (!checkXeddsaReceivePolicy(p))
return DecodeState::DECODE_FAILURE; return DecodeState::DECODE_POLICY_REJECT;
#endif #endif
if (p->decoded.has_bitfield) if (p->decoded.has_bitfield)
@@ -684,7 +1003,8 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
return DecodeState::DECODE_SUCCESS; return DecodeState::DECODE_SUCCESS;
} else { } else {
LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel); LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel);
return DecodeState::DECODE_FAILURE; return (matchedChannel || pkiAttempted || licensedPkiCandidate) ? DecodeState::DECODE_FAILURE
: DecodeState::DECODE_OPAQUE;
} }
} }
@@ -702,6 +1022,34 @@ static bool signedDataFits(meshtastic_Data *d)
} }
#endif #endif
#if !(MESHTASTIC_EXCLUDE_PKI)
bool wouldEncryptWithPKC(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey)
{
// First, only PKC encrypt packets we are originating
return isFromUs(p) &&
#if ARCH_PORTDUINO
// Sim radio via the cli flag skips PKC
!portduino_config.force_simradio &&
#endif
// Don't use PKC with Ham mode
!owner.is_licensed &&
// Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested
!(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 ||
strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) &&
// Check for valid keys and single node destination
config.security.private_key.size == 32 && !isBroadcast(p->to) &&
// Some portnums either make no sense to send with PKC
p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP &&
p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP &&
// We allow Key Verification messages to be sent without a known destination key, since the point of those messages is
// to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet
// uses the pending key resolved into haveDestKey/destKey above.
// Though possible the first packet each direction should go non-pkc
// to handle the case where the remote node has our key, but we don't have theirs.
!(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey);
}
#endif
/** Return 0 for success or a Routing_Error code for failure /** Return 0 for success or a Routing_Error code for failure
*/ */
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
@@ -722,12 +1070,12 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
// verification at every XEdDSA-enabled receiver that knows our key. // verification at every XEdDSA-enabled receiver that knows our key.
p->decoded.xeddsa_signature.size = 0; p->decoded.xeddsa_signature.size = 0;
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Sign broadcast packets when the Data still fits a LoRa frame with the signature // Licensed packets stay plaintext, so sign both broadcasts and unicasts. Normal mode
// attached. This must be the exact encoded-size criterion, not a payload-size // continues to sign broadcasts only. Use the exact encoded size: a payload-size heuristic
// heuristic: a heuristic band where we sign-then-fail-TOO_LARGE breaks packets that // where we sign-then-fail-TOO_LARGE breaks packets that
// were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when // were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when
// deciding whether an unsigned broadcast from a known signer is a downgrade. // deciding whether an unsigned broadcast from a known signer is a downgrade.
if (!p->pki_encrypted && isBroadcast(p->to) && signedDataFits(&p->decoded)) { if (!p->pki_encrypted && (owner.is_licensed || isBroadcast(p->to)) && signedDataFits(&p->decoded)) {
if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size, 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.bytes)) {
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE; p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
@@ -795,28 +1143,7 @@ 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 // 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 // is not in the local nodedb
// First, only PKC encrypt packets we are originating if (wouldEncryptWithPKC(p, chIndex, haveDestKey)) {
if (isFromUs(p) &&
#if ARCH_PORTDUINO
// Sim radio via the cli flag skips PKC
!portduino_config.force_simradio &&
#endif
// Don't use PKC with Ham mode
!owner.is_licensed &&
// Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested
!(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 ||
strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) &&
// Check for valid keys and single node destination
config.security.private_key.size == 32 && !isBroadcast(p->to) &&
// Some portnums either make no sense to send with PKC
p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP &&
p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP &&
// We allow Key Verification messages to be sent without a known destination key, since the point of those messages is
// to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet
// uses the pending key resolved into haveDestKey/destKey above.
// Though possible the first packet each direction should go non-pkc
// to handle the case where the remote node has our key, but we don't have theirs.
!(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey)) {
LOG_DEBUG("Use PKI!"); LOG_DEBUG("Use PKI!");
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN) if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
return meshtastic_Routing_Error_TOO_LARGE; return meshtastic_Routing_Error_TOO_LARGE;
@@ -881,31 +1208,123 @@ NodeNum Router::getNodeNum()
return nodeDB->getNodeNum(); return nodeDB->getNodeNum();
} }
bool Router::enqueueDeferredLocal(meshtastic_MeshPacket *p, RxSource src)
{
if (deferredLocalCount >= deferredLocalCapacity)
return false;
uint8_t tail = (deferredLocalHead + deferredLocalCount) % deferredLocalCapacity;
deferredLocalQueue[tail].p = p;
deferredLocalQueue[tail].src = src;
deferredLocalCount++;
return true;
}
bool Router::dequeueDeferredLocal(DeferredLocal &out)
{
if (deferredLocalCount == 0)
return false;
out = deferredLocalQueue[deferredLocalHead];
deferredLocalHead = (deferredLocalHead + 1) % deferredLocalCapacity;
deferredLocalCount--;
return true;
}
void Router::deliverLocal(meshtastic_MeshPacket *p, RxSource src)
{
// Top level: handle synchronously, exactly as before the depth guard existed.
if (handleDepth == 0) {
handleReceived(p, src);
return;
}
// Nested: a module sent this from inside callModules(). Defer a copy so the outermost
// handleReceived() drains it once the current dispatch unwinds, instead of stacking another
// handleReceived() frame on top of the module handler (nRF52 stack overflow on config save).
meshtastic_MeshPacket *copy = packetPool.allocCopy(*p);
if (copy && enqueueDeferredLocal(copy, src))
return;
// Pool exhausted or queue full: drop the deferral. Leak-free and degraded but safe - the
// packet still followed its normal non-loopback path (SHOULD_RELEASE, or the TX path for a
// 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);
#ifdef PIO_UNIT_TESTING
deferredLocalDropped++;
#endif
}
/** /**
* Handle any packet that is received by an interface on this node. * Handle any packet that is received by an interface on this node.
* Note: some packets may merely being passed through this node and will be forwarded elsewhere. * Note: some packets may merely being passed through this node and will be forwarded elsewhere.
*/ */
void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
{
handleDepth++;
#ifdef PIO_UNIT_TESTING
if (handleDepth > maxHandleDepthObserved)
maxHandleDepthObserved = handleDepth;
#endif
dispatchReceived(p, src);
// Only the outermost frame drains. Deferred packets were produced by modules sending from
// inside dispatchReceived()'s callModules(); process them here, after the triggering frame has
// unwound, so a second handleReceived() never sits on top of a module handler. handleDepth
// stays >= 1 through the drain, so a drained packet whose own modules send more loopback
// packets enqueues them for this same loop rather than recursing: the stack stays flat and
// processing is breadth-first.
if (handleDepth == 1) {
DeferredLocal d;
while (dequeueDeferredLocal(d)) {
dispatchReceived(d.p, d.src);
packetPool.release(d.p);
}
}
handleDepth--;
}
void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src)
{ {
bool skipHandle = false; bool skipHandle = false;
// Also, we should set the time from the ISR and it should have msec level resolution
p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone
// Store a copy of the encrypted packet for MQTT. // Store a copy of the encrypted packet for MQTT.
// Local, not a class member: handleReceived re-enters itself when a module // Kept as a local (not a class member) so each dispatch owns its own copy. A shared member was
// reply broadcast goes through MeshService::sendToMesh -> Router::sendLocal, // historically overwritten without release when a module's reply re-entered this path through
// and a member would be silently overwritten without release on the inner // MeshService::sendToMesh -> Router::sendLocal (issues #9632, #10101, #8729). Nested local
// call. Each invocation now owns its own copy (issue #9632, #10101, #8729). // sends are now deferred rather than synchronously re-entrant (see the drain in
// handleReceived()), so this no longer strictly needs to be a local, but it is kept per-call.
DEBUG_HEAP_BEFORE; DEBUG_HEAP_BEFORE;
meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p); meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p);
DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted); DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted);
// Consume the decoded/authenticated handoff after preserving the exact encrypted packet and
// before mutating any packet fields that participate in the exact cache match.
if (src == RX_SRC_RADIO)
applyRoutingAuthCache(p);
// Also, we should set the time from the ISR and it should have msec level resolution.
// Keep the decoded working packet and encrypted MQTT copy on the same local arrival timestamp.
const uint32_t rxTime = getValidTime(RTCQualityFromNet);
p->rx_time = rxTime;
if (p_encrypted)
p_encrypted->rx_time = rxTime;
// Take those raw bytes and convert them back into a well structured protobuf we can understand // Take those raw bytes and convert them back into a well structured protobuf we can understand
auto decodedState = perhapsDecode(p); auto decodedState = perhapsDecode(p);
if (decodedState == DecodeState::DECODE_FATAL) { if (decodedState == DecodeState::DECODE_FATAL || decodedState == DecodeState::DECODE_POLICY_REJECT ||
decodedState == DecodeState::DECODE_FAILURE) {
// Fatal decoding error, we can't do anything with this packet // Fatal decoding error, we can't do anything with this packet
LOG_WARN("Fatal decode error, dropping packet"); LOG_WARN(decodedState == DecodeState::DECODE_POLICY_REJECT
cancelSending(p->from, p->id); ? "Packet rejected by signature policy"
: (decodedState == DecodeState::DECODE_FATAL ? "Fatal decode error, dropping packet"
: "Decryptable packet failed decoding, dropping packet"));
// 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)
cancelSending(p->from, p->id);
skipHandle = true; skipHandle = true;
} else if (decodedState == DecodeState::DECODE_SUCCESS) { } else if (decodedState == DecodeState::DECODE_SUCCESS) {
// parsing was successful, queue for our recipient // parsing was successful, queue for our recipient
@@ -981,7 +1400,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
} else { } 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 // 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) // to us (because we would be able to decrypt it)
if (decodedState == DecodeState::DECODE_FAILURE && moduleConfig.mqtt.encryption_enabled && p->channel == 0x00 && if (decodedState == DecodeState::DECODE_OPAQUE && moduleConfig.mqtt.encryption_enabled && p->channel == 0x00 &&
!isBroadcast(p->to) && !isToUs(p)) !isBroadcast(p->to) && !isToUs(p))
p_encrypted->pki_encrypted = true; p_encrypted->pki_encrypted = true;
// After potentially altering it, publish received message to MQTT if we're not the original transmitter of the packet // After potentially altering it, publish received message to MQTT if we're not the original transmitter of the packet
@@ -1030,6 +1449,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
#endif #endif
// assert(radioConfig.has_preferences); // assert(radioConfig.has_preferences);
if (is_in_repeated(config.lora.ignore_incoming, p->from)) { 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 is in our ignore list", p->from);
packetPool.release(p); packetPool.release(p);
return; return;
@@ -1037,30 +1457,49 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from); meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from);
if (nodeInfoLiteIsIgnored(node)) { if (nodeInfoLiteIsIgnored(node)) {
clearRoutingAuthCache();
LOG_DEBUG("Ignore msg, 0x%08x is ignored", p->from); LOG_DEBUG("Ignore msg, 0x%08x is ignored", p->from);
packetPool.release(p); packetPool.release(p);
return; return;
} }
if (p->from == NODENUM_BROADCAST) { if (p->from == NODENUM_BROADCAST) {
clearRoutingAuthCache();
LOG_DEBUG("Ignore msg from broadcast address"); LOG_DEBUG("Ignore msg from broadcast address");
packetPool.release(p); packetPool.release(p);
return; return;
} }
if (config.lora.ignore_mqtt && p->via_mqtt) { if (config.lora.ignore_mqtt && p->via_mqtt) {
clearRoutingAuthCache();
LOG_DEBUG("Msg came in via MQTT from 0x%08x", p->from); LOG_DEBUG("Msg came in via MQTT from 0x%08x", p->from);
packetPool.release(p); packetPool.release(p);
return; return;
} }
if (shouldDropPacketForPreHop(*p)) { if (shouldDropPacketForPreHop(*p)) {
clearRoutingAuthCache();
logHopStartDrop(*p, "pre-hop drop"); logHopStartDrop(*p, "pre-hop drop");
packetPool.release(p); packetPool.release(p);
return; return;
} }
// Decrypt and authenticate before Reliable/Flooding/NextHop filters can update retry
// timers, packet history, implicit ACK state, cancellation, or relay queues. A packet for
// an unknown channel passes as opaque traffic and retains the existing relay behavior.
const auto authVerdict = passesRoutingAuthGate(p);
if (authVerdict == RoutingAuthVerdict::REJECT) {
packetPool.release(p);
return;
}
if (authVerdict == RoutingAuthVerdict::OPAQUE_RELAY_ONLY) {
relayOpaquePacket(p);
packetPool.release(p);
return;
}
if (shouldFilterReceived(p)) { if (shouldFilterReceived(p)) {
clearRoutingAuthCache();
LOG_DEBUG("Incoming msg was filtered from 0x%08x", p->from); LOG_DEBUG("Incoming msg was filtered from 0x%08x", p->from);
packetPool.release(p); packetPool.release(p);
return; return;

Some files were not shown because too many files have changed in this diff Show More