Merge branch 'develop' into indicator-comms

This commit is contained in:
Manuel
2026-07-27 00:14:19 +02:00
committed by GitHub
147 changed files with 7293 additions and 974 deletions

View File

@@ -4,19 +4,16 @@
# 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
# 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_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(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(trivy/DS-0002): We must run as root for this container
FROM gcr.io/oss-fuzz-base/base-builder:v1
ENV PIP_ROOT_USER_ACTION=ignore
# 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 \
cmake git zip libgpiod-dev libjsoncpp-dev libbluetooth-dev libi2c-dev \
libunistring-dev libmicrohttpd-dev libgnutls28-dev libgcrypt20-dev \

View File

@@ -13,7 +13,7 @@ reviews:
drafts: false
# Review PRs regardless of target branch.
base_branches:
- ".*"
- .*
# Skip Renovate dependency updates - CI gates dependencies; we review for substance, not every bump.
ignore_usernames:
- renovate
@@ -27,7 +27,7 @@ reviews:
- "!protobufs/**"
path_instructions:
- path: "bin/config.d/**"
- path: bin/config.d/**
instructions: >
meshtasticd configuration files. Bundled with meshtasticd Linux/MacOS packaging.
Ensure configurations include metadata found in other configs.

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/DL3013): Do not pin pip package versions
FROM mcr.microsoft.com/devcontainers/cpp:2-debian-13

View File

@@ -8,7 +8,7 @@
"features": {
"ghcr.io/devcontainers/features/python:1": {
"installTools": true,
"version": "3.13"
"version": "3.14"
}
},
"customizations": {

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_rtc/` - RTC / time handling
- `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_transmit_history/` - Retransmission tracking
- `test_type_conversions/` - NodeDB v25 type conversion (bitfield round-trips, NodeInfoLite)

View File

@@ -14,30 +14,71 @@ permissions:
jobs:
build-MacOS:
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:
- name: Checkout code
uses: actions/checkout@v7
with:
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
run: |
brew update
brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius
env:
# GitHub-Hosted MacOS runner images are updated frequently
# 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
run: |
echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
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
run: |
platformio run -e native-macos
env:
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
run: ls -lah .pio/build/native-macos/

View File

@@ -18,6 +18,10 @@ jobs:
build-wasm:
name: Build PortDuino WASM
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:
- uses: actions/checkout@v7
with:
@@ -29,14 +33,30 @@ jobs:
uses: ./.github/actions/setup-native
- name: Setup Emscripten SDK
uses: emscripten-core/setup-emsdk@v16
uses: emscripten-core/setup-emsdk@0822153d7a5488b70a269cfa0a631b2a86ab4da2 # 'v17' main
with:
version: 6.0.1
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
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
run: |
OUT=.pio/build/native-wasm

View File

@@ -14,6 +14,10 @@ permissions:
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`
@@ -33,6 +37,7 @@ jobs:
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: >-
@@ -50,7 +55,8 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
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.
@@ -69,6 +75,15 @@ jobs:
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
@@ -84,6 +99,13 @@ jobs:
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/

View File

@@ -45,6 +45,10 @@ jobs:
outputs:
digest: ${{ steps.docker_variant.outputs.digest }}
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:
- name: Checkout code
uses: actions/checkout@v7
@@ -59,11 +63,12 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: true
cache-image: ${{ env.USE_CACHE }}
- name: Docker setup
uses: docker/setup-buildx-action@v4
with:
cache-binary: ${{ env.USE_CACHE }}
# Add Google/Amazon DockerHub mirrors to buildkit config
# https://docs.docker.com/build/ci/github-actions/configure-builder/#registry-mirror
buildkitd-config-inline: |
@@ -105,6 +110,6 @@ jobs:
platforms: ${{ inputs.platform }}
build-args: |
PIO_ENV=${{ inputs.pio_env }}
# Cache image layers in GitHub Actions cache to speed up subsequent builds.
cache-from: type=gha
cache-to: type=gha,mode=max
# Disabled for now: Cache image layers in GitHub Actions cache to speed up subsequent builds.
# cache-from: type=gha
# cache-to: type=gha,mode=max

View File

@@ -3,12 +3,21 @@ concurrency:
group: ci-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
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:
branches:
- master
- develop
- pioarduino # Remove when merged // use `feature/` in the future.
- event/*
- feature/*
paths-ignore:
@@ -19,7 +28,6 @@ on:
branches:
- master
- develop
- pioarduino # Remove when merged // use `feature/` in the future.
- event/*
- feature/*
paths-ignore:
@@ -28,9 +36,9 @@ on:
schedule:
# 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.
- cron: 0 9 * * * # Nightly develop build/publish (default branch is develop)
- cron: 0 7 * * * # Nightly develop build/publish (default branch is develop)
workflow_dispatch:
inputs:
@@ -61,10 +69,12 @@ jobs:
- name: Generate matrix
id: jsonStep
run: |
if [[ "$GITHUB_HEAD_REF" == "" ]]; then
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}})
else
# PRs and (for now) merge_group builds use the narrowed --level pr board
# subset. Full-matrix builds run on push / schedule / workflow_dispatch.
if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level pr)
else
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}})
fi
echo "Name: $GITHUB_REF_NAME Base: $GITHUB_BASE_REF Ref: $GITHUB_REF"
echo "${{matrix.arch}}=$TARGETS" >> $GITHUB_OUTPUT
@@ -189,15 +199,8 @@ jobs:
fail-fast: false
matrix:
distro: [debian, alpine]
platform: [linux/amd64, linux/arm64, linux/arm/v7]
pio_env: [native, native-tft]
exclude:
- distro: alpine
platform: linux/arm/v7
- pio_env: native-tft
platform: linux/arm64
- pio_env: native-tft
platform: linux/arm/v7
platform: [linux/arm64]
pio_env: [native-tft]
uses: ./.github/workflows/docker_build.yml
with:
distro: ${{ matrix.distro }}
@@ -207,7 +210,6 @@ jobs:
push: false
gather-artifacts:
# trunk-ignore(checkov/CKV2_GHA_1)
if: github.repository == 'meshtastic/firmware'
strategy:
fail-fast: false

View File

@@ -36,4 +36,4 @@ jobs:
- name: Trunk Upgrade
uses: trunk-io/trunk-action/upgrade@v1
with:
base: master
base: develop

View File

@@ -4,10 +4,11 @@ name: Tests
on:
workflow_dispatch:
inputs:
# trunk-ignore(checkov/CKV_GHA_7): the reason input is a free-text run label, never fed into the build
reason:
description: "Reason for manual test run"
description: Reason for manual test run
required: false
default: "Manual test execution"
default: Manual test execution
concurrency:
group: tests-${{ github.head_ref || github.run_id }}
@@ -21,7 +22,7 @@ permissions:
jobs:
native-tests:
name: "🧪 Native Tests"
name: 🧪 Native Tests
if: github.repository == 'meshtastic/firmware'
uses: ./.github/workflows/test_native.yml
permissions:
@@ -30,7 +31,7 @@ jobs:
checks: write
test-summary:
name: "📊 Test Results"
name: 📊 Test Results
runs-on: ubuntu-latest
needs: [native-tests]
if: always()

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}"
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:
name: Native Simulator Tests
runs-on: ubuntu-latest
needs: suite-count-check
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
submodules: recursive
@@ -87,8 +132,9 @@ jobs:
platformio-tests:
name: Native PlatformIO Tests
runs-on: ubuntu-latest
needs: suite-count-check
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
submodules: recursive
@@ -105,14 +151,90 @@ jobs:
- name: Disable BUILD_EPOCH
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: |
set -o pipefail
# Filter out SKIPPED summary rows for hardware variants that can't run on the
# native host. They flood the log and make it harder to spot real failures.
# The JUnit XML is written directly to testreport.xml before the pipe, so
# the test artifact is unaffected.
platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$"
set -uo pipefail
# One runner, no matrix, no concurrency. Group the test_* suites by area and run each
# area sequentially, reusing the single build above (--without-building). Each area gets
# its own JUnit report and its own collapsible log, so a failure lands in a small named
# section instead of being buried past the log limit. Sequential runs share one build
# 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
if: always() # run this step even if previous step failed
@@ -122,13 +244,6 @@ jobs:
overwrite: true
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
uses: actions/upload-artifact@v7
if: always() # run this step even if previous step failed
@@ -149,7 +264,7 @@ jobs:
- platformio-tests
if: always()
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Get release version string
run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
@@ -184,9 +299,17 @@ jobs:
merge-multiple: true
- 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: |
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
- name: Save Code Coverage Report

View File

@@ -24,3 +24,4 @@ jobs:
uses: trunk-io/trunk-action@v1
with:
post-annotations: true
cache: false

View File

@@ -22,3 +22,4 @@ jobs:
uses: trunk-io/trunk-action@v1
with:
save-annotations: true
cache: false

View File

@@ -102,6 +102,13 @@ lint:
- linters: [gitleaks]
paths:
- 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
# each glyph's codepoint (e.g. "8212=:2549"); rewriting them would make
# those docs inaccurate. Glyph byte data is unaffected either way.

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/DL3008): Do not pin apt package versions
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions

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/DL3018): Do not pin apk package versions
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions

View File

@@ -12,7 +12,8 @@ rm -f $OUTDIR/firmware*
rm -r $OUTDIR/* || true
# 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"
rm -f $BUILDDIR/firmware*

View File

@@ -1,26 +1,82 @@
#!/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
export APP_VERSION=$VERSION
if [[ $# -gt 0 ]]; then
# can override which environment by passing arg
BOARDS="$@"
BOARDS=("$@")
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
echo "BOARDS:${BOARDS}"
echo "BOARDS:${BOARDS[*]}"
CHECK=""
for BOARD in $BOARDS; do
CHECK="${CHECK} -e ${BOARD}"
# Array, not a string: board names reach pio as literal arguments, so an override containing
# whitespace or a glob character cannot turn into extra pio arguments.
CHECK=()
for BOARD in "${BOARDS[@]}"; do
CHECK+=(-e "${BOARD}")
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

248
docs/node_info_stores.md Normal file
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.

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.

View File

@@ -71,3 +71,99 @@ def mtjson_esp32_part(target, source, env):
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)

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>
#
# Licensed under the Apache License, Version 2.0 (the "License");

View File

@@ -54,6 +54,7 @@ build_flags = -Wno-missing-field-initializers
-DRADIOLIB_EXCLUDE_LORAWAN=1
-DMESHTASTIC_EXCLUDE_DROPZONE=1
-DMESHTASTIC_EXCLUDE_REPLYBOT=1
-DMESHTASTIC_EXCLUDE_RANGETEST=1
-DMESHTASTIC_EXCLUDE_REMOTEHARDWARE=1
-DMESHTASTIC_EXCLUDE_HEALTH_TELEMETRY=1
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
@@ -122,12 +123,12 @@ lib_deps =
[radiolib_base]
lib_deps =
# 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]
lib_deps =
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
https://github.com/meshtastic/device-ui/archive/8d007e0c7ff17f9435905725bf06ee487c2a5d87.zip
https://github.com/meshtastic/device-ui/archive/6968b23e5270dccb2fc8531a7a37687131354fa8.zip
; Common libs for environmental measurements in telemetry module
[environmental_base]

View File

@@ -10,13 +10,11 @@
# trunk-ignore-all(ruff/F821)
# 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 os
Import("env")
framework_dir = env.PioPlatform().get_package_dir("framework-arduinopico")
if not framework_dir:
print("[add_mbedtls_sources] framework-arduinopico package not found - skipping")

View File

@@ -868,7 +868,6 @@ void Power::reboot()
Wire.end();
Serial1.end();
if (screen) {
delete screen;
screen = nullptr;
}
LOG_DEBUG("final reboot!");

View File

@@ -18,7 +18,7 @@
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
{

View File

@@ -223,6 +223,44 @@ bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
}
#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, ...) \
case ADDR: \
logFoundDevice(__VA_ARGS__); \
@@ -479,13 +517,23 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
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 (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address);
type = SHTXX;
}
#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);
type = INA219;
}
@@ -864,15 +912,27 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break;
case 0x48: {
i2cBus->beginTransmission(addr.address);
uint8_t getInfo[] = {0x5A, 0xC0, 0x00, 0xFF, 0xFC};
uint8_t expectedInfo[] = {0xa5, 0xE0, 0x00, 0x3F, 0x19};
uint8_t info[5];
// T=1oI2C soft reset; an SE050 answers A5 E0 00 3F 19. requestFrom() is
// required: readBytes() only drains the RX buffer requestFrom() fills.
const uint8_t getInfo[] = {0x5A, 0xC0, 0x00, 0xFF, 0xFC};
const uint8_t expectedInfo[] = {0xA5, 0xE0, 0x00, 0x3F, 0x19};
uint8_t info[sizeof(expectedInfo)] = {0};
size_t len = 0;
i2cBus->write(getInfo, 5);
i2cBus->endTransmission();
len = i2cBus->readBytes(info, 5);
if (len == 5 && memcmp(expectedInfo, info, len) == 0) {
bool isSE050 = false;
i2cBus->beginTransmission(addr.address);
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");
type = NXP_SE050;
break;

View File

@@ -41,9 +41,8 @@ bool HUB75Display::connect()
cfg.i2sspeed = HUB75_I2S_CFG::HZ_8M; // timing headroom (signal integrity)
cfg.latch_blanking = 4; // clean row transitions
cfg.clkphase = false; // inverted clock phase: fixes 1px skew of lower half
cfg.double_buff = false; // single buffer: halves the internal-SRAM DMA
// footprint. display() draws directly into the
// live buffer with a dirty-diff
cfg.double_buff = false; // single buffer halves the internal-SRAM DMA
// footprint; display() dirty-diffs into the live buffer
matrix = new MatrixPanel_I2S_DMA(cfg);
bool ok = matrix->begin();

View File

@@ -6,6 +6,7 @@
#include "mesh/generated/meshtastic/config.pb.h"
#include <OLEDDisplay.h>
#include <functional>
#include <memory>
#include <string>
#include <vector>
@@ -842,6 +843,6 @@ class Screen : public concurrency::OSThread
// Extern declarations for function symbols used in UIRenderer
extern std::vector<std::string> functionSymbol;
extern std::string functionSymbolString;
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
#endif

View File

@@ -38,7 +38,7 @@
using namespace meshtastic;
// External variables
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
extern PowerStatus *powerStatus;
extern NodeStatus *nodeStatus;
extern GPSStatus *gpsStatus;

View File

@@ -17,12 +17,13 @@
#include "graphics/emotes.h"
#include "main.h"
#include "meshUtils.h"
#include "modules/CannedMessageModule.h"
#include <string>
#include <vector>
// External declarations
extern bool hasUnreadMessage;
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
using graphics::Emote;
using graphics::emotes;
@@ -1073,6 +1074,7 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
{
if (packet.from != 0) {
hasUnreadMessage = true;
const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive();
// Determine if message belongs to a muted channel
bool isChannelMuted = false;
@@ -1163,11 +1165,13 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
// Shorter banner if already in a conversation (Channel or Direct)
bool inThread = (getThreadMode() != ThreadMode::ALL);
if (shouldWakeOnReceivedMessage()) {
if (!suppressBanner && shouldWakeOnReceivedMessage()) {
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

View File

@@ -18,7 +18,7 @@
#include <algorithm>
// Global screen instance
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
#if defined(OLED_TINY)
static uint32_t lastSwitchTime = 0;

View File

@@ -28,7 +28,7 @@
#include <gps/RTC.h>
// External variables
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
#if defined(OLED_TINY)
static uint32_t lastSwitchTime = 0;
#endif
@@ -42,9 +42,9 @@ static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y)
{
int yOffset = (currentResolution == ScreenResolution::High) ? -5 : 1;
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 {
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
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 {
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];

View File

@@ -11,6 +11,9 @@ const uint8_t SATELLITE_IMAGE[] PROGMEM = {0x00, 0x08, 0x00, 0x1C, 0x00, 0x0E, 0
const uint8_t imgSatellite[] PROGMEM = {
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_HighResolution[] PROGMEM = {0x00, 0x3e, 0xf8, 0x80, 0x43, 0xf8, 0xc0, 0xc2, 0xff, 0x60, 0x42, 0xfc,

View File

@@ -2614,6 +2614,8 @@ uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight()
void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char *message)
{
meshtastic_MeshPacket *p = router->allocForSending();
if (!p)
return;
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
p->to = dest;
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);
// 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 + 1] = '\0'; // Append Null Terminator
p->decoded.payload.size++;

View File

@@ -217,7 +217,7 @@ using namespace concurrency;
volatile static const char slipstreamTZString[] = {USERPREFS_TZ_STRING};
// 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
meshtastic::PowerStatus *powerStatus = new meshtastic::PowerStatus();
@@ -988,15 +988,15 @@ void setup()
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
#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)
if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || portduino_config.displayPanel) &&
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
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 // HAS_SCREEN
@@ -1133,6 +1133,23 @@ void setup()
#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();
lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation)
@@ -1284,6 +1301,8 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
#if !defined(HAS_RGB_LED) && !RAK_4631
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_AMBIENTLIGHTING_CONFIG;
#endif
// Range test is always excluded as of 2.8
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_RANGETEST_CONFIG;
// No bluetooth on these targets (yet):
// Pico W / 2W may get it at some point
@@ -1300,6 +1319,9 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
#if !(MESHTASTIC_EXCLUDE_PKI)
deviceMetadata.hasPKC = true;
#endif
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
deviceMetadata.has_xeddsa = true;
#endif
return deviceMetadata;
}

View File

@@ -11,6 +11,7 @@
#include "mesh/generated/meshtastic/telemetry.pb.h"
#include <SPI.h>
#include <map>
#include <memory>
#if defined(ARCH_ESP32) && !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH
#include "nimble/NimbleBluetooth.h"
extern NimbleBluetooth *nimbleBluetooth;
@@ -68,7 +69,7 @@ extern UdpMulticastHandler *udpHandler;
#endif
// Global Screen singleton.
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_ACCELEROMETER
#include "motion/AccelerometerThread.h"

View File

@@ -51,8 +51,8 @@ bool FloodingRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
LOG_DEBUG("Repeated reliable tx");
// Check if it's still in the Tx queue, if not, we have to relay it again
if (!findInTxQueue(p->from, p->id)) {
reprocessPacket(p);
perhapsRebroadcast(p);
if (reprocessPacket(p))
perhapsRebroadcast(p);
}
} else {
perhapsCancelDupe(p);
@@ -68,6 +68,12 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p)
{
// isRebroadcaster() is duplicated in perhapsRebroadcast(), but this avoids confusing log messages
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
// 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
@@ -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,
p->hop_limit, dropThreshold);
reprocessPacket(p);
if (!reprocessPacket(p))
return true;
perhapsRebroadcast(p);
rxDupe++;
@@ -87,32 +94,24 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p)
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)
nodeDB->updateFrom(*p);
#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 &&
p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) {
traceRouteModule->processUpgradedPacket(*p);
}
#endif
return true;
}
bool FloodingRouter::roleAllowsCancelingDupe(const meshtastic_MeshPacket *p)

View File

@@ -64,7 +64,7 @@ class FloodingRouter : public Router
bool perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p);
/* 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
// the same packet
@@ -75,4 +75,4 @@ class FloodingRouter : public Router
// Return true if we are a rebroadcaster
bool isRebroadcaster();
};
};

View File

@@ -114,7 +114,10 @@ template <class T> class MemoryDynamic : public Allocator<T>
virtual T *alloc(TickType_t maxWait) override
{
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));
return p;
}

View File

@@ -18,7 +18,7 @@ uint8_t MeshModule::numPeriodicModules = 0;
*/
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
if (!modules)
@@ -27,6 +27,12 @@ MeshModule::MeshModule(const char *_name) : name(_name)
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() {}
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
// auto p = allocDataProtobuf(c);
meshtastic_MeshPacket *p = router->allocForSending();
if (!p)
return nullptr;
p->decoded.portnum = meshtastic_PortNum_ROUTING_APP;
p->decoded.payload.size =
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;
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)
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
// any other node.
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
LOG_INFO("Asked module '%s' to send a response", pi.name);
} else {
LOG_DEBUG("Module '%s' considered", pi.name);
}
@@ -311,4 +324,4 @@ bool MeshModule::isRequestingFocus()
} else
return false;
}
#endif
#endif

View File

@@ -68,10 +68,12 @@ class MeshModule
/** Constructor
* name is for debugging output
*/
MeshModule(const char *_name);
MeshModule(const char *_name, meshtastic_PortNum _ourPortNum = meshtastic_PortNum_UNKNOWN_APP);
virtual ~MeshModule();
static bool replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp);
/** For use only by MeshService
*/
static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO);
@@ -88,6 +90,7 @@ class MeshModule
#endif
protected:
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
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 */
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;
/**

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
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
nodeDB->resetRadioConfig(); // Don't let the phone send us fatally bad settings
// This will also update the region as needed
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);
}
@@ -319,13 +324,20 @@ void MeshService::sendToMesh(meshtastic_MeshPacket *p, RxSource src, bool ccToPh
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...)
// 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
ErrorCode res = router->sendLocal(p, src);
/* NOTE(pboldin): Prepare and send QueueStatus message to the phone as a
* high-priority message. */
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) {
LOG_DEBUG("Can't send status to phone");
}

View File

@@ -11,6 +11,25 @@
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)
{
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);
// Check if it's still in the Tx queue, if not, we have to relay it again
if (!findInTxQueue(p->from, p->id)) {
reprocessPacket(p);
perhapsRebroadcast(p);
if (reprocessPacket(p))
perhapsRebroadcast(p);
}
} else {
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 (isRepeated) {
if (!findInTxQueue(p->from, p->id)) {
reprocessPacket(p);
if (!perhapsRebroadcast(p) && isToUs(p) && p->want_ack) {
if (reprocessPacket(p) && !perhapsRebroadcast(p) && isToUs(p) && p->want_ack) {
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0);
}
}

View File

@@ -137,6 +137,7 @@ class NextHopRouter : public FloodingRouter
* @return true to abandon the packet
*/
virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override;
bool relayOpaquePacket(const meshtastic_MeshPacket *p) override;
/**
* Look for packets we need to relay
@@ -213,4 +214,4 @@ class NextHopRouter : public FloodingRouter
/** Check if we should be rebroadcasting this packet if so, do so.
* @return true if we did rebroadcast */
bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override;
};
};

View File

@@ -31,6 +31,9 @@
#if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h"
#endif
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
#include "xmodem.h"
#include <ErriezCRC32.h>
#include <algorithm>
@@ -767,6 +770,12 @@ bool NodeDB::factoryReset(bool eraseBleBonds)
warmStore.clear();
warmStore.saveIfDirty();
#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)
installDefaultNodeDatabase();
@@ -948,6 +957,12 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
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) {
config.security.private_key.size = 32;
memcpy(config.security.private_key.bytes, private_key_temp, config.security.private_key.size);
@@ -1597,6 +1612,11 @@ void NodeDB::resetNodes(bool keepFavorites)
#if WARM_NODE_COUNT > 0
warmStore.clear(); // warm entries are never favorites; a DB reset clears them too
#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;
saveNodeDatabaseToDisk();
@@ -1629,6 +1649,12 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum)
// Explicit user removal: don't let the warm tier resurrect the node
warmStore.remove(nodeNum);
#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);
saveNodeDatabaseToDisk();
@@ -1841,6 +1867,8 @@ bool NodeDB::enforceSatelliteCaps()
// them if they do); otherwise tracker/sensor/tak_tracker are role-protected.
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 |
NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK))
return static_cast<uint8_t>(WarmProtected::Flag);
@@ -2980,6 +3008,7 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
moduleConfig.has_audio = true;
moduleConfig.has_paxcounter = true;
moduleConfig.has_statusmessage = true;
moduleConfig.has_tak = true;
#if !MESHTASTIC_EXCLUDE_BEACON
moduleConfig.has_mesh_beacon = true;
#endif
@@ -3120,6 +3149,9 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly)
#include "MeshModule.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;
void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context)
@@ -3312,15 +3344,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
*/
bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned)
{
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
if (!info) {
// 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;
}
// Once a node has proven it signs, only a signed update may change its identity. The public-key guard
// below is no help - an attacker can replay the victim's real (public) key. Our own record is exempt.
if (nodeId != getNodeNum() && nodeInfoLiteHasXeddsaSigned(info) && !xeddsaSigned) {
LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId);
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
if (!info) {
return false;
}
@@ -3400,6 +3433,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;
}
@@ -3414,7 +3461,19 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
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);
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) {
return;
}
@@ -3698,7 +3757,7 @@ uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const
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);
if (info && info->public_key.size == 32) {
@@ -3714,6 +3773,97 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
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)
{
const meshtastic_NodeInfoLite *info = getMeshNode(n);
@@ -3809,8 +3959,27 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
lite->public_key.size = 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);
}
#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
LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap());
}

View File

@@ -360,6 +360,37 @@ class NodeDB
/// tier. Returns false if we don't know a key for n.
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
/// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for
/// nodes that have aged out of the hot store.
@@ -529,9 +560,10 @@ class NodeDB
/// 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.
bool configDecodeFailed = false;
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
uint32_t lastSort = 0; // When last sorted the nodeDB
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

View File

@@ -887,6 +887,11 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag;
fromRadioScratch.moduleConfig.payload_variant.paxcounter = moduleConfig.paxcounter;
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:
LOG_DEBUG("Send module config: traffic management");
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;

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)
meshtastic_MeshPacket *p = allocDataPacket();
if (!p)
return nullptr;
p->decoded.payload.size =
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), fields, &payload);

View File

@@ -12,9 +12,10 @@
#include "mesh-pb-constants.h"
#include "meshUtils.h"
#include "modules/RoutingModule.h"
#include <ErriezCRC32.h>
#include <pb_decode.h>
#include <pb_encode.h>
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
#if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h"
@@ -67,6 +68,79 @@ Allocator<meshtastic_MeshPacket> &packetPool = staticPool;
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
*
@@ -85,6 +159,10 @@ Router::Router() : concurrency::OSThread("Router"), fromRadioQueue(MAX_RX_FROMRA
// init Lockguard for crypt operations
assert(!cryptLock);
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)
@@ -194,6 +272,8 @@ PacketId generatePacketId()
meshtastic_MeshPacket *Router::allocForSending()
{
meshtastic_MeshPacket *p = packetPool.allocZeroed();
if (!p)
return nullptr;
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // Assume payload is decoded at start.
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
if (isToUs(p)) {
printPacket("Enqueued local", p);
enqueueReceivedMessage(p);
return ERRNO_OK;
// Preserve the trusted origin explicitly. Queueing used to erase src and make a local
// phone/module packet indistinguishable from remote already-decoded ingress.
deliverLocal(p, src);
return ERRNO_SHOULD_RELEASE;
} else if (!iface) {
// We must be sending to remote nodes also, fail if no interface found
abortSendAndNak(meshtastic_Routing_Error_NO_INTERFACE, p);
@@ -256,9 +338,10 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src)
return ERRNO_NO_INTERFACES;
} else {
// 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)) {
handleReceived(p, src);
deliverLocal(p, src);
}
// don't override if a channel was requested and no need to set it when PKI is enforced
@@ -499,18 +582,55 @@ static bool canonicalSignableSize(meshtastic_Data *d, size_t *size)
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)
{
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.
p->xeddsa_signed = false;
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
meshtastic_NodeInfoLite_public_key_t senderKey = {0, {0}};
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && node->public_key.size == 32) {
if (nodeDB->copyPublicKey(p->from, senderKey)) {
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);
if (p->xeddsa_signed) {
// 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);
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
} else {
@@ -518,7 +638,16 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
return false;
}
} else {
const auto bootstrap = verifyFirstContactNodeInfo(p);
if (bootstrap == NodeInfoBootstrapResult::INVALID) {
LOG_WARN("Invalid first-contact XEdDSA NodeInfo from 0x%08x, dropping", p->from);
return false;
}
if (bootstrap == NodeInfoBootstrapResult::VERIFIED)
return true;
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) {
// A signature field that is neither empty nor a full 64 bytes is malformed - honest
@@ -529,15 +658,24 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
p->from);
return false;
} else {
// Truly unsigned (signature size 0) - only reject the class a signing node always signs: a
// non-PKI broadcast whose signed encoding would still fit the LoRa frame. Size p->decoded
// canonically so this counts the same fields the sender's signedDataFits() gate counted;
// adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever
// fields the Data carried, with padding hidden inside Data.payload stripped. Unicast/PKI
// 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.
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) {
if (p->pki_encrypted)
return true;
if (strict) {
LOG_WARN("Dropping unsigned packet from 0x%08x in Strict signature mode", p->from);
return false;
}
if (compatible)
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;
if (!canonicalSignableSize(&p->decoded, &canonicalSize))
return true; // can't size it; never drop on a sizing failure
@@ -551,6 +689,103 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
}
#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)
{
concurrency::LockGuard g(cryptLock);
@@ -564,39 +799,62 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag)
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;
if (rawSize > sizeof(bytes)) {
LOG_ERROR("Packet too large to attempt decryption! (rawSize=%d > 256)", rawSize);
return DecodeState::DECODE_FATAL;
}
bool decrypted = false;
bool pkiAttempted = false;
bool matchedChannel = false;
ChannelIndex chIndex = 0;
#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;
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
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
// reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates.
bool viaAdminKey = false;
bool viaPendingKey = false;
if (haveRemoteKey && crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
decrypted = true;
viaPendingKey = havePendingKey;
}
for (int i = 0; i < 3 && !decrypted; i++) {
if (config.security.admin_key[i].size != 32)
continue;
remotePublic.size = 32;
memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32);
if (!decrypted && adminKeyFallbackAllowed()) {
for (int i = 0; i < 3 && !decrypted; i++) {
if (config.security.admin_key[i].size != 32)
continue;
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)) {
decrypted = true;
viaAdminKey = true;
break; // stop after first successful decryption
if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
decrypted = true;
viaAdminKey = true;
break; // stop after first successful decryption
}
}
if (decrypted)
adminKeyFallbackRefund();
}
if (decrypted) {
LOG_INFO("PKI Decryption worked!");
@@ -605,6 +863,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD;
if (pb_decode_from_bytes(bytes, payloadSize, &meshtastic_Data_msg, &decodedtmp) &&
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;
rawSize = payloadSize; // commit the overhead subtraction only on full success
LOG_INFO("Packet decrypted using PKI!");
@@ -615,10 +879,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
if (viaAdminKey) {
// 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.
meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from);
if (fromNode != nullptr)
fromNode->public_key = remotePublic;
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated
// it. commitRemoteKey is the bare-key commit primitive: it bypasses updateUser's
// User-payload path deliberately and handles the TrafficManagement write-through.
// 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 {
// AEAD already authenticated this ciphertext, so no other candidate could decode it -
@@ -636,6 +902,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
for (chIndex = 0; chIndex < channels.getNumChannels(); chIndex++) {
// Try to use this hash/channel pair
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
// fresh copy for each decrypt attempt.
memcpy(bytes, p->encrypted.bytes, rawSize);
@@ -671,10 +938,9 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
p->channel = chIndex; // change to store the index instead of the hash
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Runs before the bitfield merge below: that merge can set want_response, adding wire bytes
// the sender never encoded and skewing the policy's sizing of p->decoded.
// Run before merging local-only bitfield state into the decoded Data.
if (!checkXeddsaReceivePolicy(p))
return DecodeState::DECODE_FAILURE;
return DecodeState::DECODE_POLICY_REJECT;
#endif
if (p->decoded.has_bitfield)
@@ -732,7 +998,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
return DecodeState::DECODE_SUCCESS;
} else {
LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel);
return DecodeState::DECODE_FAILURE;
return (matchedChannel || pkiAttempted) ? DecodeState::DECODE_FAILURE : DecodeState::DECODE_OPAQUE;
}
}
@@ -750,6 +1016,34 @@ static bool signedDataFits(meshtastic_Data *d)
}
#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
*/
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
@@ -843,28 +1137,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
// is not in the local nodedb
// First, only PKC encrypt packets we are originating
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)) {
if (wouldEncryptWithPKC(p, chIndex, haveDestKey)) {
LOG_DEBUG("Use PKI!");
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
return meshtastic_Routing_Error_TOO_LARGE;
@@ -929,31 +1202,123 @@ NodeNum Router::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.
* Note: some packets may merely being passed through this node and will be forwarded elsewhere.
*/
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;
// 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.
// Local, not a class member: handleReceived re-enters itself when a module
// reply broadcast goes through MeshService::sendToMesh -> Router::sendLocal,
// and a member would be silently overwritten without release on the inner
// call. Each invocation now owns its own copy (issue #9632, #10101, #8729).
// Kept as a local (not a class member) so each dispatch owns its own copy. A shared member was
// historically overwritten without release when a module's reply re-entered this path through
// MeshService::sendToMesh -> Router::sendLocal (issues #9632, #10101, #8729). Nested local
// 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;
meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p);
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
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
LOG_WARN("Fatal decode error, dropping packet");
cancelSending(p->from, p->id);
LOG_WARN(decodedState == DecodeState::DECODE_POLICY_REJECT
? "Packet rejected by signature policy"
: (decodedState == DecodeState::DECODE_FATAL ? "Fatal decode error, dropping packet"
: "Decryptable packet failed decoding, dropping packet"));
// 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;
} else if (decodedState == DecodeState::DECODE_SUCCESS) {
// parsing was successful, queue for our recipient
@@ -1029,7 +1394,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
} else {
// Mark as pki_encrypted if it is not yet decoded and MQTT encryption is also enabled, hash matches and it's a DM not
// to us (because we would be able to decrypt it)
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))
p_encrypted->pki_encrypted = true;
// After potentially altering it, publish received message to MQTT if we're not the original transmitter of the packet
@@ -1078,6 +1443,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
#endif
// assert(radioConfig.has_preferences);
if (is_in_repeated(config.lora.ignore_incoming, p->from)) {
clearRoutingAuthCache();
LOG_DEBUG("Ignore msg, 0x%08x is in our ignore list", p->from);
packetPool.release(p);
return;
@@ -1085,30 +1451,49 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from);
if (nodeInfoLiteIsIgnored(node)) {
clearRoutingAuthCache();
LOG_DEBUG("Ignore msg, 0x%08x is ignored", p->from);
packetPool.release(p);
return;
}
if (p->from == NODENUM_BROADCAST) {
clearRoutingAuthCache();
LOG_DEBUG("Ignore msg from broadcast address");
packetPool.release(p);
return;
}
if (config.lora.ignore_mqtt && p->via_mqtt) {
clearRoutingAuthCache();
LOG_DEBUG("Msg came in via MQTT from 0x%08x", p->from);
packetPool.release(p);
return;
}
if (shouldDropPacketForPreHop(*p)) {
clearRoutingAuthCache();
logHopStartDrop(*p, "pre-hop drop");
packetPool.release(p);
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)) {
clearRoutingAuthCache();
LOG_DEBUG("Incoming msg was filtered from 0x%08x", p->from);
packetPool.release(p);
return;

View File

@@ -112,6 +112,9 @@ class Router : protected concurrency::OSThread, protected PacketHistory
*/
virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) { return false; }
/** Relay an opaque packet without admitting it to local routing/history state. */
virtual bool relayOpaquePacket(const meshtastic_MeshPacket *) { return false; }
/**
* Determine if hop_limit should be decremented for a relay operation.
* Returns false (preserve hop_limit) only if all conditions are met:
@@ -157,11 +160,66 @@ class Router : protected concurrency::OSThread, protected PacketHistory
*/
void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO);
/**
* The body of handleReceived(): decode, run modules, publish to MQTT. Split out so the
* depth-guarded drain in handleReceived() can process a deferred packet without re-entering
* the drain (and without touching handleDepth) - keeping the stack flat.
*/
void dispatchReceived(meshtastic_MeshPacket *p, RxSource src);
/**
* Route a packet addressed to us (or a local broadcast we loop back) into handleReceived().
* Called synchronously at the top level, but if a module sends this from inside callModules()
* (handleDepth > 0) the packet is copied into the deferred queue instead, so we never stack a
* second handleReceived() on top of a module handler - that nesting is what overflows the
* nRF52 task stack on a config save. Does not consume p; the caller's existing free path is
* unchanged.
*/
void deliverLocal(meshtastic_MeshPacket *p, RxSource src);
/// Depth of handleReceived() frames currently on the stack. >0 means a module is dispatching,
/// so a locally-sent loopback packet must be deferred rather than handled synchronously.
uint8_t handleDepth = 0;
/// A local loopback packet whose handleReceived() was deferred because it was produced from
/// inside callModules(). The queue owns the packet; its RxSource travels with it so the drain
/// dispatches it with the origin the sender intended (RX_SRC_LOCAL stays local).
struct DeferredLocal {
meshtastic_MeshPacket *p;
RxSource src;
};
/// Fixed, small ring buffer of deferred local packets. A config save fans out only a few
/// loopback packets (a self-addressed reply plus a nodeinfo/config broadcast or two), so four
/// slots cover the realistic nesting. On overflow the deferral is dropped (the packet still
/// followed its normal non-loopback path) rather than blocking or growing the heap.
static constexpr uint8_t deferredLocalCapacity = 4;
DeferredLocal deferredLocalQueue[deferredLocalCapacity];
uint8_t deferredLocalHead = 0; // index of the oldest queued entry
uint8_t deferredLocalCount = 0; // entries currently queued
/// Queue a deferred local packet. Returns false (and queues nothing) when full.
bool enqueueDeferredLocal(meshtastic_MeshPacket *p, RxSource src);
/// Pop the oldest deferred local packet into out. Returns false when empty.
bool dequeueDeferredLocal(DeferredLocal &out);
/** Frees the provided packet, and generates a NAK indicating the specifed error while sending */
void abortSendAndNak(meshtastic_Routing_Error err, meshtastic_MeshPacket *p);
#ifdef PIO_UNIT_TESTING
public:
/// High-water mark of handleDepth across this Router's life. The deferral must keep it at 1:
/// a nested local send may never re-enter handleReceived() synchronously.
uint8_t maxHandleDepthObserved = 0;
/// Count of deferrals dropped because the queue was full or a copy could not be allocated.
uint32_t deferredLocalDropped = 0;
/// Number of deferred local packets currently queued.
uint8_t deferredLocalPending() const { return deferredLocalCount; }
#endif
};
enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL };
enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_OPAQUE, DECODE_FATAL, DECODE_POLICY_REJECT };
enum class RoutingAuthVerdict { ACCEPT, OPAQUE_RELAY_ONLY, REJECT };
/** FIXME - move this into a mesh packet class
* Remove any encryption and decode the protobufs inside this packet (if necessary).
@@ -170,29 +228,35 @@ enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL };
*/
DecodeState perhapsDecode(meshtastic_MeshPacket *p);
/** Apply receive authentication before routing state mutation; unknown-channel packets may remain opaque relay-only. */
RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p);
#ifdef PIO_UNIT_TESTING
uint32_t routingAuthEvaluationCount();
void resetRoutingAuthEvaluationCount();
#endif
/** Return 0 for success or a Routing_Error code for failure
*/
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p);
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
/** XEdDSA receive-side signature policy. When the packet carries a 64-byte signature *and* the
* sender's public key is known, verify it: on success learn the sender's signer bit, on failure
* drop. If the key is unknown the signature is left unverified and the packet passes. A signature
* of any other non-zero length is treated as malformed and dropped. For unsigned packets, enforce
* downgrade protection: drop a non-PKI broadcast from a known signer whose signed encoding would
* still fit a LoRa frame (unicast, PKI, and oversized broadcasts always pass).
*
* The fit test sizes p->decoded with the real encoder, so it measures the fields the sender
* encoded rather than any raw wire length.
*
* The caller MUST hold cryptLock: verification runs through the shared CryptoEngine key cache.
* (perhapsDecode already holds it; other call sites must take it themselves.)
*
* @return false if the packet must be dropped.
*/
/** Enforce the configured XEdDSA receive policy. The caller must hold cryptLock.
* Returns false when the packet must be dropped. */
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p);
#endif
#if !(MESHTASTIC_EXCLUDE_PKI)
/**
* Would perhapsEncode() PKC-encrypt this outgoing packet? Callers that must know the encryption a
* packet will get before it is encoded (e.g. pinning a peer key at request time) have to ask this
* rather than inspect p, whose pki_encrypted/public_key fields are only populated on the RX path.
*
* @param chIndex the channel index p carries before encoding rewrites it to a hash.
* @param haveDestKey whether a public key for p->to was resolvable.
*/
bool wouldEncryptWithPKC(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey);
#endif
extern Router *router;
/// Generate a unique packet id

View File

@@ -8,14 +8,11 @@
*/
class SinglePortModule : public MeshModule
{
protected:
meshtastic_PortNum ourPortNum;
public:
/** Constructor
* name is for debugging output
*/
SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name), ourPortNum(_ourPortNum) {}
SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name, _ourPortNum) {}
protected:
/**
@@ -32,8 +29,10 @@ class SinglePortModule : public MeshModule
{
// Update our local node info with our position (even if we don't decide to update anyone else)
meshtastic_MeshPacket *p = router->allocForSending();
if (!p)
return nullptr;
p->decoded.portnum = ourPortNum;
return p;
}
};
};

View File

@@ -161,6 +161,12 @@ bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat
return true;
}
bool WarmNodeStore::isVerifiedSigner(NodeNum num) const
{
const WarmNodeEntry *e = find(num);
return e && warmSignerOf(*e);
}
bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out)
{
WarmNodeEntry *e = find(num);

View File

@@ -63,7 +63,7 @@ static constexpr uint32_t WARM_SIGNER_MASK = 0x01u;
enum class WarmFormat : uint8_t { Current, V2, V1 };
// Protected category cached alongside role so consumers needn't re-derive the mapping.
enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 };
enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2, XeddsaSigner = 3 };
inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot, bool signer)
{
@@ -119,6 +119,10 @@ class WarmNodeStore
/// @return false if the node is not in the warm tier.
bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const;
/// True if the warm tier holds this node with its signer bit set (an XEdDSA signature
/// was verified from it before eviction).
bool isVerifiedSigner(NodeNum num) const;
/// Find and remove an entry (used when the node is re-admitted to the hot store).
bool take(NodeNum num, WarmNodeEntry &out);
@@ -131,6 +135,15 @@ class WarmNodeStore
size_t count() const;
size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; }
/// Slot-indexed read for whole-tier reconciliation: the entry in slot i, or nullptr
/// when the slot is empty or i >= capacity().
const WarmNodeEntry *entryAt(size_t i) const
{
if (!entries || i >= WARM_NODE_COUNT || entries[i].num == 0)
return nullptr;
return &entries[i];
}
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
/// Debug: dump every live warm entry (num / last_heard / has-key) to the
/// console. Compiled out unless MESHTASTIC_NODEDB_MIGRATION_VERBOSE.

View File

@@ -21,7 +21,7 @@
class UdpMulticastHandler final
{
public:
UdpMulticastHandler() : isRunning(false) { udpIpAddress = IPAddress(224, 0, 0, 69); }
UdpMulticastHandler() : isRunning(false) { udpIpAddress = IPAddress(239, 0, 0, 69); }
void start()
{
@@ -80,10 +80,12 @@ class UdpMulticastHandler final
return;
}
mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP;
// Preserve the whole MeshPacket as received: while payload_variant is encrypted, `channel` is a hash (and is 0 for
// PKI DMs), so it must be copied verbatim for the router to attempt PKI/channel decryption. Keep
// pki_encrypted/public_key too so downstream auth/metadata can reflect PKI usage correctly.
// Authentication metadata is local-only; Router re-establishes it after successful PKI decryption.
mp.pki_encrypted = false;
mp.public_key.size = 0;
UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp);
if (!p)
return;
// Unset received SNR/RSSI
p->rx_snr = 0;
p->rx_rssi = 0;

View File

@@ -1,5 +1,6 @@
#include "AdminModule.h"
#include "Channels.h"
#include "CryptoEngine.h"
#include "DisplayFormatters.h"
#include "HardwareRNG.h"
#include "MeshService.h"
@@ -928,11 +929,15 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
config.power.on_battery_shutdown_after_secs = 30;
}
break;
case meshtastic_Config_network_tag:
case meshtastic_Config_network_tag: {
LOG_INFO("Set config: WiFi");
config.has_network = true;
char prevPsk[sizeof(config.network.wifi_psk)];
memcpy(prevPsk, config.network.wifi_psk, sizeof(prevPsk));
config.network = c.payload_variant.network;
writeSecret(config.network.wifi_psk, sizeof(config.network.wifi_psk), prevPsk);
break;
}
case meshtastic_Config_display_tag:
LOG_INFO("Set config: Display");
config.has_display = true;
@@ -1131,6 +1136,16 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
rotated.private_key = incoming.private_key;
incoming = rotated;
}
#if MESHTASTIC_EXCLUDE_PKI || MESHTASTIC_EXCLUDE_XEDDSA
if (incoming.packet_signature_policy !=
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE) {
incoming.packet_signature_policy =
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE;
const char *warning = "Packet authenticity policy is unavailable on this firmware build";
LOG_WARN(warning);
sendWarning(warning);
}
#endif
config.security = incoming;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
// First provisioning (no key) generates one; a private key supplied without its public key derives it.
@@ -1193,7 +1208,12 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
// Disable Bluetooth to prevent interference during MQTT configuration
disableBluetooth();
moduleConfig.has_mqtt = true;
moduleConfig.mqtt = c.payload_variant.mqtt;
{
char prevPass[sizeof(moduleConfig.mqtt.password)];
memcpy(prevPass, moduleConfig.mqtt.password, sizeof(prevPass));
moduleConfig.mqtt = c.payload_variant.mqtt;
writeSecret(moduleConfig.mqtt.password, sizeof(moduleConfig.mqtt.password), prevPass);
}
#endif
break;
case meshtastic_ModuleConfig_serial_tag:
@@ -1279,6 +1299,11 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
moduleConfig.has_traffic_management = true;
moduleConfig.traffic_management = c.payload_variant.traffic_management;
break;
case meshtastic_ModuleConfig_tak_tag:
LOG_INFO("Set module config: TAK");
moduleConfig.has_tak = true;
moduleConfig.tak = c.payload_variant.tak;
break;
#if !MESHTASTIC_EXCLUDE_BEACON
case meshtastic_ModuleConfig_mesh_beacon_tag: {
LOG_INFO("Set module config: MeshBeacon");
@@ -1416,6 +1441,9 @@ void AdminModule::handleGetOwner(const meshtastic_MeshPacket &req)
res.which_payload_variant = meshtastic_AdminMessage_get_owner_response_tag;
setPassKey(&res);
myReply = allocDataProtobuf(res);
if (!myReply) {
return;
}
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
@@ -1447,8 +1475,9 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32
LOG_INFO("Get config: Network");
res.get_config_response.which_payload_variant = meshtastic_Config_network_tag;
res.get_config_response.payload_variant.network = config.network;
writeSecret(res.get_config_response.payload_variant.network.wifi_psk,
sizeof(res.get_config_response.payload_variant.network.wifi_psk), config.network.wifi_psk);
if (req.from != 0)
strncpy(res.get_config_response.payload_variant.network.wifi_psk, secretReserved,
sizeof(res.get_config_response.payload_variant.network.wifi_psk));
break;
case meshtastic_AdminMessage_ConfigType_DISPLAY_CONFIG:
LOG_INFO("Get config: Display");
@@ -1499,6 +1528,9 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32
res.which_payload_variant = meshtastic_AdminMessage_get_config_response_tag;
setPassKey(&res);
myReply = allocDataProtobuf(res);
if (!myReply) {
return;
}
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
@@ -1516,6 +1548,9 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
configName = "MQTT";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag;
res.get_module_config_response.payload_variant.mqtt = moduleConfig.mqtt;
if (req.from != 0)
strncpy(res.get_module_config_response.payload_variant.mqtt.password, secretReserved,
sizeof(res.get_module_config_response.payload_variant.mqtt.password));
break;
case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG:
configName = "Serial";
@@ -1587,6 +1622,11 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management;
break;
case meshtastic_AdminMessage_ModuleConfigType_TAK_CONFIG:
configName = "TAK";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_tak_tag;
res.get_module_config_response.payload_variant.tak = moduleConfig.tak;
break;
#if !MESHTASTIC_EXCLUDE_BEACON
case meshtastic_AdminMessage_ModuleConfigType_MESHBEACON_CONFIG:
configName = "MeshBeacon";
@@ -1608,6 +1648,9 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
res.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag;
setPassKey(&res);
myReply = allocDataProtobuf(res);
if (!myReply) {
return;
}
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
@@ -1635,6 +1678,9 @@ void AdminModule::handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &r
}
setPassKey(&r);
myReply = allocDataProtobuf(r);
if (!myReply) {
return;
}
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
@@ -1654,6 +1700,9 @@ void AdminModule::handleGetDeviceMetadata(const meshtastic_MeshPacket &req)
r.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_response_tag;
setPassKey(&r);
myReply = allocDataProtobuf(r);
if (!myReply) {
return;
}
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
@@ -1729,6 +1778,9 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r
r.which_payload_variant = meshtastic_AdminMessage_get_device_connection_status_response_tag;
setPassKey(&r);
myReply = allocDataProtobuf(r);
if (!myReply) {
return;
}
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
@@ -1743,6 +1795,9 @@ void AdminModule::handleGetChannel(const meshtastic_MeshPacket &req, uint32_t ch
r.which_payload_variant = meshtastic_AdminMessage_get_channel_response_tag;
setPassKey(&r);
myReply = allocDataProtobuf(r);
if (!myReply) {
return;
}
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
@@ -1755,6 +1810,9 @@ void AdminModule::handleGetDeviceUIConfig(const meshtastic_MeshPacket &req)
r.which_payload_variant = meshtastic_AdminMessage_get_ui_config_response_tag;
r.get_ui_config_response = uiconfig;
myReply = allocDataProtobuf(r);
if (!myReply) {
return;
}
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
@@ -1849,8 +1907,14 @@ void AdminModule::setPassKey(meshtastic_AdminMessage *res)
if (!sessionPasskeyValid || !Throttle::isWithinTimespanMs(session_time, 150 * 1000UL)) {
// Session passkey authenticates admin replies, so it must be unpredictable: prefer the
// hardware RNG, falling back to the seeded CSPRNG only when no hardware source exists.
if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey)))
CryptRNG.rand(session_passkey, sizeof(session_passkey));
// Hold cryptLock like the signing path does: this runs on the admin receive path, which on
// nRF52 is the BLE task, and the fill toggles the CC310 that packet crypto also uses while
// the CryptRNG state is shared with signing.
{
concurrency::LockGuard g(cryptLock);
if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey)))
CryptRNG.rand(session_passkey, sizeof(session_passkey));
}
session_time = millis();
sessionPasskeyValid = true;
}
@@ -1931,7 +1995,15 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
if (!responseVariant)
return; // not a getter whose response we can pair
const bool keyValid = p.pki_encrypted && p.public_key.size == 32;
// Pin the key perhapsEncode will actually encrypt to, resolved the same way it resolves it.
// p.public_key is NOT it: nothing populates that field on the outgoing path (only perhapsDecode
// sets it, on RX), so reading it here pinned nothing and left `from` as the sole check.
bool keyValid = false;
meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}};
#if !(MESHTASTIC_EXCLUDE_PKI)
const bool haveDestKey = nodeDB->copyPublicKey(p.to, destKey);
keyValid = haveDestKey && wouldEncryptWithPKC(&p, p.channel, haveDestKey);
#endif
// One entry per request (a client sends N indexed get_channel requests, each answered once, so
// entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time.
@@ -1950,6 +2022,7 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
}
slot->to = p.to;
slot->requestId = p.id;
slot->sentAtMs = millis();
slot->expectedResponse = responseVariant;
slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag
@@ -1957,7 +2030,7 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
: 0;
slot->keyValid = keyValid;
if (keyValid)
memcpy(slot->key, p.public_key.bytes, 32);
memcpy(slot->key, destKey.bytes, 32);
else
memset(slot->key, 0, 32);
LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to);
@@ -1970,6 +2043,11 @@ bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t
for (auto &o : outstandingAdminRequests) {
if (o.to != mp.from || o.expectedResponse != responseVariant)
continue;
// mp.from is unauthenticated, so also require the response to echo our request's packet id
// (setReplyTo puts it in decoded.request_id). A blind injector must now guess it. Id 0 is
// no token at all - an omitted request_id decodes to 0 - so such a slot never matches.
if (o.requestId == 0 || mp.decoded.request_id != o.requestId)
continue;
if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) {
o.to = 0; // lapsed; free the slot and keep looking for another live match
continue;

View File

@@ -90,10 +90,11 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey
struct OutstandingAdminRequest {
NodeNum to; // 0 = free slot
uint32_t requestId; // our request's packet id; the response must echo it as request_id
uint32_t sentAtMs; // millis() when this request went out
pb_size_t expectedResponse; // the one response variant this request authorizes
uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked
uint8_t key[32]; // pinned destination key when the request went out over PKC
uint8_t key[32]; // pinned destination key when the request goes out over PKC
bool keyValid;
};
OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {};

View File

@@ -179,7 +179,7 @@ int CannedMessageModule::splitConfiguredMessages()
while (i < upTo) {
if (this->messageBuffer[i] == '|') {
this->messageBuffer[i] = '\0'; // End previous message
if (tempCount >= CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT)
if (tempCount >= CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT - 1)
break;
tempMessages[tempCount++] = (this->messageBuffer + i + 1);
}
@@ -1022,6 +1022,8 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha
lastDestSet = true;
meshtastic_MeshPacket *p = allocDataPacket();
if (!p)
return;
p->to = dest;
p->channel = channel;
p->want_ack = true;
@@ -1054,7 +1056,7 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha
p->decoded.payload.size = strlen(message);
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
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;
p->decoded.payload.bytes[p->decoded.payload.size] = '\0';
}

View File

@@ -72,6 +72,7 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
void resetSearch();
void updateDestinationSelectionList();
void drawDestinationSelectionScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
bool isFreeTextActive() const { return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT; }
String drawWithCursor(String text, int cursor);
// === Emote Picker ===

View File

@@ -131,10 +131,14 @@ void DetectionSensorModule::sendDetectionMessage()
char *message = new char[40];
sprintf(message, "%s detected", moduleConfig.detection_sensor.name);
meshtastic_MeshPacket *p = allocDataPacket();
if (!p) {
delete[] message;
return;
}
p->want_ack = false;
p->decoded.payload.size = strlen(message);
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
if (moduleConfig.detection_sensor.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
if (moduleConfig.detection_sensor.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 + 1] = '\0'; // Bell character
p->decoded.payload.size++;
@@ -153,6 +157,10 @@ void DetectionSensorModule::sendCurrentStateMessage(bool state)
char *message = new char[40];
sprintf(message, "%s state: %i", moduleConfig.detection_sensor.name, state);
meshtastic_MeshPacket *p = allocDataPacket();
if (!p) {
delete[] message;
return;
}
p->want_ack = false;
p->decoded.payload.size = strlen(message);
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);

View File

@@ -68,6 +68,8 @@ meshtastic_MeshPacket *DropzoneModule::sendConditions()
// the dropzone is open
auto dropzoneStatus = analogRead(A1) < 100 ? "OPEN" : "CLOSED";
auto reply = allocDataPacket();
if (!reply)
return nullptr;
auto node = nodeDB->getMeshNode(nodeDB->getNodeNum());
if (sensor.hasSensor()) {

View File

@@ -6,12 +6,19 @@
#include "gps/RTC.h"
#include "graphics/draw/MenuHandler.h"
#include "main.h"
#include "mesh/Throttle.h"
#include "meshUtils.h"
#include "modules/AdminModule.h"
#include "modules/NodeInfoModule.h"
#include <RNG.h>
#include <SHA256.h>
#define KEY_VERIFICATION_TIMEOUT_SECS 60
// Hard cap on one session, independent of the refreshable idle timeout above.
#define KEY_VERIFICATION_MAX_SESSION_MS (3 * 60 * 1000UL)
// Minimum spacing between remote-initiated sessions.
#define KEY_VERIFICATION_REMOTE_COOLDOWN_MS (60 * 1000UL)
KeyVerificationModule *keyVerificationModule;
namespace
@@ -64,7 +71,8 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons
bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_KeyVerification *r)
{
updateState();
// No refresh here: this runs before the sender is checked, so any node could hold the session open.
updateState(false);
// Note: pki_encrypted is not required here. The first response (M2) may arrive channel-encrypted in
// the bootstrap case; the follow-on hash1 packet (M3) is required to be PKI in its branch below.
if (mp.from != currentRemoteNode) { // because the inital connection request is handled in allocReply()
@@ -98,6 +106,7 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
service->sendClientNotification(cn);
}
LOG_INFO("Received hash2");
currentNonceTimestamp = getTime(); // the protocol advanced, so extend the deadline
currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER;
return true;
@@ -134,6 +143,7 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
service->sendClientNotification(cn);
}
currentNonceTimestamp = getTime(); // the protocol advanced, so extend the deadline
currentState = KEY_VERIFICATION_RECEIVER_AWAITING_USER;
return true;
}
@@ -151,8 +161,16 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode)
return false;
}
updateState(true);
currentNonce = random();
// The nonce binds the handshake, so draw it from the hardware RNG (falling back to the CSPRNG)
// under cryptLock, as allocReply does for the security number. random() is both predictable and
// only 32 bits wide, leaving half of this nonce zero.
{
concurrency::LockGuard g(cryptLock);
if (!HardwareRNG::fill((uint8_t *)&currentNonce, sizeof(currentNonce)))
CryptRNG.rand((uint8_t *)&currentNonce, sizeof(currentNonce));
}
currentNonceTimestamp = getTime();
sessionStartedMs = millis();
currentRemoteNode = remoteNode;
meshtastic_KeyVerification KeyVerification = meshtastic_KeyVerification_init_zero;
KeyVerification.nonce = currentNonce;
@@ -162,6 +180,8 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode)
KeyVerification.hash1.size = 32;
memcpy(KeyVerification.hash1.bytes, owner.public_key.bytes, 32);
meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification);
if (!p)
return false;
p->to = remoteNode;
p->channel = 0;
// Only request PKI when we already hold the destination's key. Otherwise this first message goes out
@@ -181,10 +201,19 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply()
SHA256 hash;
NodeNum ourNodeNum = nodeDB->getNodeNum();
updateState();
if (currentState != KEY_VERIFICATION_IDLE) { // TODO: cooldown period
if (currentState != KEY_VERIFICATION_IDLE) {
LOG_WARN("Key Verification requested, but already in a request");
ignoreRequest = true; // do not let the busy path emit a NAK back to the requester
return nullptr;
}
// Opening a session raises a banner and locks the only slot, before the peer has authenticated.
if (lastRemoteSessionMs != 0 && Throttle::isWithinTimespanMs(lastRemoteSessionMs, KEY_VERIFICATION_REMOTE_COOLDOWN_MS)) {
LOG_WARN("Key Verification requested, but within cooldown");
ignoreRequest = true;
return nullptr;
}
sessionStartedMs = millis();
sessionFromRemote = true;
currentState = KEY_VERIFICATION_RECEIVER_AWAITING_HASH1;
auto req = *currentRequest;
@@ -250,6 +279,12 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply()
memcpy(response.hash2.bytes, hash2, 32);
responsePacket = allocDataProtobuf(response);
if (!responsePacket) {
LOG_WARN("Key Verification response allocation failed");
ignoreRequest = true;
resetToIdle();
return nullptr;
}
// PKI-encrypt the response only if we already held the requester's key. In the bootstrap case it goes
// out channel-encrypted so the requester (who lacks our key) can decode it and read hash1.
@@ -318,6 +353,8 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
KeyVerification.hash1.size = 32;
memcpy(KeyVerification.hash1.bytes, hash1, 32);
meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification);
if (!p)
return;
p->to = currentRemoteNode;
p->channel = 0;
p->pki_encrypted = true;
@@ -346,11 +383,14 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
void KeyVerificationModule::updateState(bool resetTimer)
{
if (currentState != KEY_VERIFICATION_IDLE) {
// check for the 60 second timeout
if (currentNonceTimestamp < getTime() - 60) {
uint32_t now = getTime();
// Absolute cap first: it is millis() based, so it bounds the session even when the RTC is unset.
if (!Throttle::isWithinTimespanMs(sessionStartedMs, KEY_VERIFICATION_MAX_SESSION_MS)) {
resetToIdle();
} else if (now - currentNonceTimestamp >= KEY_VERIFICATION_TIMEOUT_SECS) {
resetToIdle();
} else if (resetTimer) {
currentNonceTimestamp = getTime();
currentNonceTimestamp = now;
}
}
}
@@ -359,8 +399,12 @@ void KeyVerificationModule::resetToIdle()
{
memset(hash1, 0, 32);
memset(hash2, 0, 32);
if (sessionFromRemote)
lastRemoteSessionMs = millis(); // start the cooldown when the session ends, not when it opened
sessionFromRemote = false;
currentNonce = 0;
currentNonceTimestamp = 0;
sessionStartedMs = 0;
currentSecurityNumber = 0;
currentRemoteNode = 0;
currentState = KEY_VERIFICATION_IDLE;
@@ -383,6 +427,11 @@ void KeyVerificationModule::commitVerifiedRemoteNode()
if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending))
node->public_key = pending;
node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
// Re-commit via the bare-key primitive: writing the same bytes back is a no-op for the hot
// store, but it routes the TrafficManagement write-through. ManuallyVerified: the user just
// confirmed possession of exactly this key - the strongest provenance that cache can carry.
if (node->public_key.size == 32)
nodeDB->commitRemoteKey(currentRemoteNode, node->public_key.bytes, NodeDB::KeyCommitTrust::ManuallyVerified);
LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber);
if (nodeInfoModule)
nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true);

View File

@@ -84,6 +84,12 @@ class KeyVerificationModule : public ProtobufModule<meshtastic_KeyVerification>
private:
uint64_t currentNonce = 0;
uint32_t currentNonceTimestamp = 0;
// millis() the session opened, never refreshed, so a peer cannot hold the slot open indefinitely.
uint32_t sessionStartedMs = 0;
// millis() a remote-initiated session last ended. Spacing sessions bounds the slot DoS and the
// banner spam; stamped at the end rather than the start so the cooldown is a real gap.
uint32_t lastRemoteSessionMs = 0;
bool sessionFromRemote = false;
NodeNum currentRemoteNode = 0;
uint32_t currentSecurityNumber = 0;
KeyVerificationState currentState = KEY_VERIFICATION_IDLE;

View File

@@ -110,6 +110,8 @@ void NeighborInfoModule::sendNeighborInfo(NodeNum dest, bool wantReplies)
// only send neighbours if we have some to send
if (neighborInfo.neighbors_count > 0) {
meshtastic_MeshPacket *p = allocDataProtobuf(neighborInfo);
if (!p)
return;
p->to = dest;
p->decoded.want_response = wantReplies;
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;

View File

@@ -234,4 +234,4 @@ int32_t NodeInfoModule::runOnce()
sendOurNodeInfo(NODENUM_BROADCAST, requestReplies); // Send our info (don't request replies)
}
return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs);
}
}

View File

@@ -292,6 +292,8 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli()
{
LOG_INFO("Send TAK V2 PLI packet");
meshtastic_MeshPacket *mp = allocDataPacket();
if (!mp)
return nullptr;
mp->decoded.portnum = meshtastic_PortNum_ATAK_PLUGIN_V2;
meshtastic_TAKPacketV2 takPacket = meshtastic_TAKPacketV2_init_zero;
@@ -572,6 +574,8 @@ int32_t PositionModule::runOnce()
void PositionModule::sendLostAndFoundText()
{
meshtastic_MeshPacket *p = allocDataPacket();
if (!p)
return;
p->to = NODENUM_BROADCAST;
char message[128];
int written = snprintf(message, sizeof(message), "🚨I'm lost! Lat / Lon: %f, %f\a", (lastGpsLatitude * 1e-7),

View File

@@ -8,15 +8,16 @@
* The RangeTestModule class is an OSThread that runs the module.
* The RangeTestModuleRadio class handles sending and receiving packets.
*/
#include "RangeTestModule.h"
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_RANGETEST && !MESHTASTIC_EXCLUDE_GPS
#include "FSCommon.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "PowerFSM.h"
#include "RangeTestModule.h"
#include "Router.h"
#include "SPILock.h"
#include "airtime.h"
#include "configuration.h"
#include "gps/GeoCoord.h"
#include "gps/RTC.h"
#include <Arduino.h>
@@ -114,6 +115,8 @@ int32_t RangeTestModule::runOnce()
void RangeTestModuleRadio::sendPayload(NodeNum dest, bool wantReplies)
{
meshtastic_MeshPacket *p = allocDataPacket();
if (!p)
return;
p->to = dest;
p->decoded.want_response = wantReplies;
p->hop_limit = 0;
@@ -351,3 +354,5 @@ bool RangeTestModuleRadio::removeFile()
return 0;
#endif
}
#endif // !MESHTASTIC_EXCLUDE_RANGETEST && !MESHTASTIC_EXCLUDE_GPS

View File

@@ -168,6 +168,8 @@ void ReplyBotModule::sendDm(const meshtastic_MeshPacket &rx, const char *text)
if (!text)
return;
meshtastic_MeshPacket *p = allocDataPacket();
if (!p)
return;
p->to = rx.from;
p->channel = rx.channel;
p->want_ack = false;

View File

@@ -16,7 +16,9 @@ meshtastic_MeshPacket *ReplyModule::allocReply()
#endif
const char *replyStr = "Message Received";
auto reply = allocDataPacket(); // Allocate a packet for sending
auto reply = allocDataPacket(); // Allocate a packet for sending
if (!reply)
return nullptr;
reply->decoded.payload.size = strlen(replyStr); // You must specify how many bytes are in the reply
memcpy(reply->decoded.payload.bytes, replyStr, reply->decoded.payload.size);

View File

@@ -51,6 +51,8 @@ void RoutingModule::sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketI
bool ackWantsAck)
{
auto p = allocAckNak(err, to, idFrom, chIndex, hopLimit);
if (!p)
return;
// Allow the caller to set want_ack on this ACK packet if it's important that the ACK be delivered reliably
p->want_ack = ackWantsAck;

View File

@@ -109,7 +109,7 @@ bool SerialModule::isValidConfig(const meshtastic_ModuleConfig_SerialConfig &con
return true;
}
SerialModuleRadio::SerialModuleRadio() : MeshModule("SerialModuleRadio")
SerialModuleRadio::SerialModuleRadio() : SinglePortModule("SerialModuleRadio", meshtastic_PortNum_SERIAL_APP)
{
switch (moduleConfig.serial.mode) {
case meshtastic_ModuleConfig_SerialConfig_Serial_Mode_TEXTMSG:
@@ -314,6 +314,8 @@ int32_t SerialModule::runOnce()
void SerialModule::sendTelemetry(meshtastic_Telemetry m)
{
meshtastic_MeshPacket *p = router->allocForSending();
if (!p)
return;
p->decoded.portnum = meshtastic_PortNum_TELEMETRY_APP;
p->decoded.payload.size =
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Telemetry_msg, &m);
@@ -328,18 +330,6 @@ void SerialModule::sendTelemetry(meshtastic_Telemetry m)
service->sendToMesh(p, RX_SRC_LOCAL, true);
}
/**
* Allocates a new mesh packet for use as a reply to a received packet.
*
* @return A pointer to the newly allocated mesh packet.
*/
meshtastic_MeshPacket *SerialModuleRadio::allocReply()
{
auto reply = allocDataPacket(); // Allocate a packet for sending
return reply;
}
/**
* Sends a payload to a specified destination node.
*
@@ -349,7 +339,9 @@ meshtastic_MeshPacket *SerialModuleRadio::allocReply()
void SerialModuleRadio::sendPayload(NodeNum dest, bool wantReplies)
{
const meshtastic_Channel *ch = (boundChannel != NULL) ? &channels.getByName(boundChannel) : NULL;
meshtastic_MeshPacket *p = allocReply();
meshtastic_MeshPacket *p = allocDataPacket();
if (!p)
return;
p->to = dest;
if (ch != NULL) {
p->channel = ch->index;

View File

@@ -40,7 +40,7 @@ extern SerialModule *serialModule;
* Radio interface for SerialModule
*
*/
class SerialModuleRadio : public MeshModule
class SerialModuleRadio : public SinglePortModule
{
uint32_t lastRxID = 0;
char outbuf[90] = "";
@@ -54,29 +54,14 @@ class SerialModuleRadio : public MeshModule
void sendPayload(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false);
protected:
virtual meshtastic_MeshPacket *allocReply() override;
/** Called to handle a particular incoming message
@return ProcessMessage::STOP if you've guaranteed you've handled this message and no other handlers should be considered for
it
*/
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
meshtastic_PortNum ourPortNum;
virtual bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; }
meshtastic_MeshPacket *allocDataPacket()
{
// Update our local node info with our position (even if we don't decide to update anyone else)
meshtastic_MeshPacket *p = router->allocForSending();
p->decoded.portnum = ourPortNum;
return p;
}
};
extern SerialModuleRadio *serialModuleRadio;
#endif
#endif

View File

@@ -15,6 +15,8 @@ int32_t StatusMessageModule::runOnce()
strncpy(ourStatus.status, moduleConfig.statusmessage.node_status, sizeof(ourStatus.status));
ourStatus.status[sizeof(ourStatus.status) - 1] = '\0'; // ensure null termination
meshtastic_MeshPacket *p = allocDataPacket();
if (!p)
return 1000 * 12 * 60 * 60;
p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes),
meshtastic_StatusMessage_fields, &ourStatus);
p->to = NODENUM_BROADCAST;

View File

@@ -248,6 +248,8 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t
(this->packetHistory[i].to == NODENUM_BROADCAST || this->packetHistory[i].to == dest)) {
meshtastic_MeshPacket *p = allocDataPacket();
if (!p)
return nullptr;
p->to = local ? this->packetHistory[i].to : dest; // PhoneAPI can handle original `to`
p->from = this->packetHistory[i].from;
@@ -304,6 +306,8 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t
void StoreForwardModule::sendMessage(NodeNum dest, const meshtastic_StoreAndForward &payload)
{
meshtastic_MeshPacket *p = allocDataProtobuf(payload);
if (!p)
return;
p->to = dest;
@@ -340,6 +344,8 @@ void StoreForwardModule::sendMessage(NodeNum dest, meshtastic_StoreAndForward_Re
void StoreForwardModule::sendErrorTextMessage(NodeNum dest, bool want_response)
{
meshtastic_MeshPacket *pr = allocDataPacket();
if (!pr)
return;
pr->to = dest;
pr->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
pr->want_ack = false;

View File

@@ -464,35 +464,39 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
}
meshtastic_MeshPacket *p = allocDataProtobuf(m);
p->to = dest;
p->decoded.want_response = false;
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
else
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// release previous packet before occupying a new spot
if (lastMeasurementPacket != nullptr)
packetPool.release(lastMeasurementPacket);
lastMeasurementPacket = packetPool.allocCopy(*p);
if (phoneOnly) {
LOG_INFO("Sending packet to phone");
service->sendToPhone(p);
if (!p) {
validTelemetry = false;
} else {
LOG_INFO("Sending packet to mesh");
service->sendToMesh(p, RX_SRC_LOCAL, true);
p->to = dest;
p->decoded.want_response = false;
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
else
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
if (isPowerSavingSensor()) {
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
if (notification) {
notification->level = meshtastic_LogRecord_Level_INFO;
notification->time = getValidTime(RTCQualityFromNet);
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval,
default_telemetry_broadcast_interval_secs) /
1000U);
service->sendClientNotification(notification);
// release previous packet before occupying a new spot
if (lastMeasurementPacket != nullptr)
packetPool.release(lastMeasurementPacket);
lastMeasurementPacket = packetPool.allocCopy(*p);
if (phoneOnly) {
LOG_INFO("Sending packet to phone");
service->sendToPhone(p);
} else {
LOG_INFO("Sending packet to mesh");
service->sendToMesh(p, RX_SRC_LOCAL, true);
if (isPowerSavingSensor()) {
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
if (notification) {
notification->level = meshtastic_LogRecord_Level_INFO;
notification->time = getValidTime(RTCQualityFromNet);
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval,
default_telemetry_broadcast_interval_secs) /
1000U);
service->sendClientNotification(notification);
}
}
}
}

View File

@@ -172,6 +172,8 @@ meshtastic_Telemetry DeviceTelemetryModule::getLocalStatsTelemetry()
void DeviceTelemetryModule::sendLocalStatsToPhone()
{
meshtastic_MeshPacket *p = allocDataProtobuf(getLocalStatsTelemetry());
if (!p)
return;
p->to = NODENUM_BROADCAST;
p->decoded.want_response = false;
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
@@ -191,6 +193,8 @@ bool DeviceTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
meshtastic_MeshPacket *p = allocDataProtobuf(telemetry);
DEBUG_HEAP_AFTER("DeviceTelemetryModule::sendTelemetry", p);
if (!p)
return false;
p->to = dest;
p->decoded.want_response = false;
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;

View File

@@ -653,34 +653,38 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
m.variant.environment_metrics.soil_moisture);
meshtastic_MeshPacket *p = allocDataProtobuf(m);
p->to = dest;
p->decoded.want_response = false;
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
else
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// release previous packet before occupying a new spot
if (lastMeasurementPacket != nullptr)
packetPool.release(lastMeasurementPacket);
lastMeasurementPacket = packetPool.allocCopy(*p);
if (phoneOnly) {
LOG_INFO("Send packet to phone");
service->sendToPhone(p);
if (!p) {
validTelemetry = false;
} else {
LOG_INFO("Send packet to mesh");
service->sendToMesh(p, RX_SRC_LOCAL, true);
p->to = dest;
p->decoded.want_response = false;
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
else
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// release previous packet before occupying a new spot
if (lastMeasurementPacket != nullptr)
packetPool.release(lastMeasurementPacket);
if (isPowerSavingSensor()) {
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
if (notification) {
notification->level = meshtastic_LogRecord_Level_INFO;
notification->time = getValidTime(RTCQualityFromNet);
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval,
default_telemetry_broadcast_interval_secs) /
1000U);
service->sendClientNotification(notification);
lastMeasurementPacket = packetPool.allocCopy(*p);
if (phoneOnly) {
LOG_INFO("Send packet to phone");
service->sendToPhone(p);
} else {
LOG_INFO("Send packet to mesh");
service->sendToMesh(p, RX_SRC_LOCAL, true);
if (isPowerSavingSensor()) {
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
if (notification) {
notification->level = meshtastic_LogRecord_Level_INFO;
notification->time = getValidTime(RTCQualityFromNet);
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval,
default_telemetry_broadcast_interval_secs) /
1000U);
service->sendClientNotification(notification);
}
}
}
}

View File

@@ -248,23 +248,27 @@ bool HealthTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
sensor_read_error_count = 0;
meshtastic_MeshPacket *p = allocDataProtobuf(m);
p->to = dest;
p->decoded.want_response = false;
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
else
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// release previous packet before occupying a new spot
if (lastMeasurementPacket != nullptr)
packetPool.release(lastMeasurementPacket);
lastMeasurementPacket = packetPool.allocCopy(*p);
if (phoneOnly) {
LOG_INFO("Send packet to phone");
service->sendToPhone(p);
if (!p) {
validTelemetry = false;
} else {
LOG_INFO("Send packet to mesh");
service->sendToMesh(p, RX_SRC_LOCAL, true);
p->to = dest;
p->decoded.want_response = false;
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
else
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// release previous packet before occupying a new spot
if (lastMeasurementPacket != nullptr)
packetPool.release(lastMeasurementPacket);
lastMeasurementPacket = packetPool.allocCopy(*p);
if (phoneOnly) {
LOG_INFO("Send packet to phone");
service->sendToPhone(p);
} else {
LOG_INFO("Send packet to mesh");
service->sendToMesh(p, RX_SRC_LOCAL, true);
}
}
}

View File

@@ -128,6 +128,8 @@ bool HostMetricsModule::sendMetrics()
// telemetry.variant.host_metrics.has_user_string ? telemetry.variant.host_metrics.user_string : "");
meshtastic_MeshPacket *p = allocDataProtobuf(telemetry);
if (!p)
return false;
p->to = NODENUM_BROADCAST;
p->decoded.want_response = false;
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;

View File

@@ -275,23 +275,27 @@ bool PowerTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
sensor_read_error_count = 0;
meshtastic_MeshPacket *p = allocDataProtobuf(m);
p->to = dest;
p->decoded.want_response = false;
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
else
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// release previous packet before occupying a new spot
if (lastMeasurementPacket != nullptr)
packetPool.release(lastMeasurementPacket);
lastMeasurementPacket = packetPool.allocCopy(*p);
if (phoneOnly) {
LOG_INFO("Send packet to phone");
service->sendToPhone(p);
if (!p) {
validTelemetry = false;
} else {
LOG_INFO("Send packet to mesh");
service->sendToMesh(p, RX_SRC_LOCAL, true);
p->to = dest;
p->decoded.want_response = false;
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
else
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// release previous packet before occupying a new spot
if (lastMeasurementPacket != nullptr)
packetPool.release(lastMeasurementPacket);
lastMeasurementPacket = packetPool.allocCopy(*p);
if (phoneOnly) {
LOG_INFO("Send packet to phone");
service->sendToPhone(p);
} else {
LOG_INFO("Send packet to mesh");
service->sendToMesh(p, RX_SRC_LOCAL, true);
}
}
}

View File

@@ -12,7 +12,7 @@
#include "modules/TrafficManagementModule.h"
#endif
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
TraceRouteModule *traceRouteModule;
@@ -305,6 +305,15 @@ void TraceRouteModule::updateNextHops(const meshtastic_MeshPacket &p, meshtastic
}
uint8_t nextHopByte = nodeDB->getLastByteOfNodeNum(nextHop);
// The route array is unauthenticated payload, so only learn from it when the node it names as our
// next hop is the one that actually relayed this packet to us. Otherwise a forged response could
// point any node's next_hop anywhere. relay_node is 0 for MQTT-sourced packets, which cannot
// corroborate an RF route either.
if (p.relay_node == NO_RELAY_NODE || nextHopByte != p.relay_node) {
LOG_DEBUG("Ignore traceroute next-hop 0x%02x, packet was relayed by 0x%02x", nextHopByte, p.relay_node);
return;
}
// For the rest of the nodes in the route, set their next-hop
// Note: if we are the last in the route, this loop will not run
for (int8_t i = nextHopIndex; i < r->route_count; i++) {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -8,25 +8,32 @@
#if HAS_TRAFFIC_MANAGEMENT
/**
* TrafficManagementModule - Packet inspection and traffic shaping for mesh networks.
*
* This module provides:
* - Position deduplication (drop redundant position broadcasts)
* - Per-node rate limiting (throttle chatty nodes)
* - Unknown packet filtering (drop undecoded packets from repeat offenders)
* - NodeInfo direct response (answer queries from cache to reduce mesh chatter)
* - Local-only telemetry/position (exhaust hop_limit for local broadcasts)
* - Router hop preservation (maintain hop_limit for router-to-router traffic)
*
* Memory Optimization:
* Uses one flat unified cache (plain array, linear scan) shared by all
* per-node features instead of separate per-feature caches. Timestamps are
* stored as free-running modular tick counters (pos: 8-bit 360 s/tick;
* rate+unknown: paired 4-bit nibbles in one byte) for a 10-byte entry.
* LoRa packet rates are low enough that an O(n) scan of ~1000 entries is
* negligible next to packet processing.
*/
// Replay provenance gate: when 1 (default), direct responses are spoofed only for nodes whose
// cached key is signer-proven (XEdDSA-verified), not for trust-on-first-use identities.
// Define as 0 to also serve fresh TOFU-only nodes; bypassed entirely when PKI is excluded.
#ifndef TMM_NODEINFO_REPLAY_REQUIRE_SIGNED
#define TMM_NODEINFO_REPLAY_REQUIRE_SIGNED 1
#endif
// Effective gate: only meaningful when PKI is compiled in.
#if TMM_NODEINFO_REPLAY_REQUIRE_SIGNED && !(MESHTASTIC_EXCLUDE_PKI)
#define TMM_NODEINFO_REPLAY_SIGNED_GATE 1
#else
#define TMM_NODEINFO_REPLAY_SIGNED_GATE 0
#endif
// NodeInfo cache availability. Production home is ESP32+PSRAM (the 2000-entry array is too big
// for MCU internal RAM); native unit-test builds enable it on the plain heap so the cache paths
// run in CI (tests needing the NodeDB fallback call dropNodeInfoCacheForTest()).
#if (defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) || (defined(ARCH_PORTDUINO) && defined(PIO_UNIT_TESTING))
#define TMM_HAS_NODEINFO_CACHE 1
#else
#define TMM_HAS_NODEINFO_CACHE 0
#endif
/// Packet inspection and traffic shaping: position dedup, per-node rate limiting, unknown-packet
/// filtering, NodeInfo direct response, and the next-hop/role overflow caches. One flat 10-byte
/// unified cache backs all per-node features; see docs/node_info_stores.md for the store overview.
class TrafficManagementModule : public MeshModule, private concurrency::OSThread
{
public:
@@ -37,41 +44,66 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
TrafficManagementModule(const TrafficManagementModule &) = delete;
TrafficManagementModule &operator=(const TrafficManagementModule &) = delete;
/// Snapshot of the module's counters (thread-safe).
meshtastic_TrafficManagementStats getStats() const;
/// Zero all counters (thread-safe).
void resetStats();
/// Placeholder for the removed router_preserve_hops stat.
void recordRouterHopPreserved();
// Next-hop overflow cache (routing hint).
// setNextHop: store a confirmed last-byte next hop for `dest`. Called by
// NextHopRouter from its ACK-confirmed decision (see sniffReceived). The
// byte must come from a bidirectionally-verified relay, not one-way inference.
// getNextHopHint: return the cached next-hop byte for `dest`, 0 if unknown.
// clearNextHop: forget any cached next hop for `dest` (setNextHop refuses to store
// 0, so this is the way NextHopRouter decays a stale/failing overflow route).
/// Store a confirmed last-byte next hop for `dest`. Called only from NextHopRouter's
/// ACK-confirmed decision - the byte must come from a bidirectionally-verified relay.
void setNextHop(NodeNum dest, uint8_t nextHopByte);
/// Cached next-hop byte for `dest`, 0 if unknown.
uint8_t getNextHopHint(NodeNum dest);
/// Forget the cached next hop for `dest` (how NextHopRouter decays a failing route).
void clearNextHop(NodeNum dest);
// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed
// hops survive later hot-store (NodeDB) eviction. Idempotent; runs once after
// nodeDB is populated (lazily on first maintenance pass).
// @return true if it actually ran (prereqs met / nothing to do); false if
// prerequisites (cache, nodeDB) weren't ready yet, so the caller should retry.
/// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed hops survive
/// hot-store eviction. @return true if it ran; false if prerequisites (cache, nodeDB) weren't
/// ready and the caller should retry on a later pass.
bool preloadNextHopsFromNodeDB();
/**
* Check if this packet should have its hops exhausted.
* Called from perhapsRebroadcast() to force hop_limit = 0 regardless of
* router_preserve_hops or favorite node logic.
*/
/// Last-resort key source for NodeDB::copyPublicKey() after the hot and warm tiers miss.
/// Copies the 32-byte key for `node` into out[32]; `signerProven` (optional) reports whether
/// the key was XEdDSA-verified vs trust-on-first-use. Thread-safe.
bool copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven = nullptr) const;
/// Copy the full cached User for `node` (used by NodeDB to rehydrate a re-admitted node's
/// name - the warm tier keeps keys but not names). False on miss or key-only records.
/// `signerProven` (optional) reports the cached key's provenance. Thread-safe.
bool copyUser(NodeNum node, meshtastic_User &out, bool *signerProven = nullptr) const;
/// Write-through hook from NodeDB::updateUser(): upsert the committed identity immediately
/// (the reconcile sweep remains the backstop). NodeDB's key is authoritative, but a keyless
/// commit keeps a TOFU key this cache already holds; never touches the observation stamp.
/// No-op while the module is disabled in moduleConfig (maintenance is gated the same way).
void onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown);
/// Key-only commit hook for key writes that bypass updateUser (admin-key learn, manual key
/// verification). A changed key resets provenance; pass proven=true only when the commit
/// itself established possession. Never touches the observation stamp. Thread-safe.
/// No-op while the module is disabled in moduleConfig (maintenance is gated the same way).
void onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven);
/// Zero one node's slots in both caches (identity, key, provenance, role, next-hop, dedup
/// state). Called by NodeDB removal so no TMM tier resurrects a deliberately deleted node;
/// passive eviction is unaffected. Thread-safe.
void purgeNode(NodeNum node);
/// Clear both cache tables outright (resetNodes / factory reset). Thread-safe.
void purgeAll();
/// True when perhapsRebroadcast() must force hop_limit=0 for this packet, regardless of
/// router_preserve_hops or favorite-node logic (set by alterReceived()).
bool shouldExhaustHops(const meshtastic_MeshPacket &mp) const
{
return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id;
}
// Injectable monotonic clock (ms). All TMM time reads go through clockMs() so unit tests can
// advance a virtual timebase instead of sleeping real seconds across the 6 min/360 s tick.
// Mirrors HopScalingModule::s_testNowMs. Writable from tests as TrafficManagementModule::s_testNowMs;
// ignored in production (clockMs() returns millis()).
// Injectable monotonic clock (ms): tests advance s_testNowMs instead of sleeping across
// ticks (mirrors HopScalingModule); production reads millis().
inline static uint32_t s_testNowMs = 0;
/// Monotonic module clock in ms (virtual under PIO_UNIT_TESTING).
#ifdef PIO_UNIT_TESTING
static uint32_t clockMs() { return s_testNowMs; }
#else
@@ -79,43 +111,40 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
#endif
protected:
/// Inspect a received packet; may consume it (STOP) for dedup/rate/unknown/direct-response.
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
/// Promiscuous: this module inspects every packet.
bool wantPacket(const meshtastic_MeshPacket *p) override { return true; }
/// Mutate relayed packets in place (position precision clamp).
void alterReceived(meshtastic_MeshPacket &mp) override;
/// 60 s maintenance sweep: expire timed state, saturate tick stamps, reconcile with NodeDB.
int32_t runOnce() override;
// Protected so test shims can flush per-node traffic state.
/// Clear all per-node traffic state (protected for test shims).
void flushCache();
// Introspection for tests: the cached device role for a node, or -1 if the node has
// no cache entry (distinguishes "not tracked / evicted" from CLIENT == 0).
/// Test introspection: the cached role for `node`, or -1 when it has no entry
/// (distinguishes "not tracked" from CLIENT == 0).
int peekCachedRole(NodeNum node);
/// Test hook: force a cached NodeInfo entry's key to signer-proven so replay-gate tests
/// can skip a full XEdDSA verification. No-op if absent.
void markKeySignerProvenForTest(NodeNum node);
/// Test hook: free the NodeInfo cache so the NodeDB fallback path can be exercised in
/// builds where the cache is compiled in. No-op when already absent.
void dropNodeInfoCacheForTest();
/// Test introspection: NodeInfo flag bits for `node` (-1 if absent): bit0 hasObserved,
/// bit1 isMember, bit2 hasFullUser, bit3 keySignerProven.
int peekNodeInfoFlagsForTest(NodeNum node);
/// Test introspection: NodeInfo cache capacity (kNodeInfoCacheEntries), so tests can
/// fill the cache exactly and force the tiered-LRU eviction paths.
static constexpr uint16_t nodeInfoCacheCapacityForTest() { return kNodeInfoCacheEntries; }
private:
// =========================================================================
// Unified Cache Entry (10 bytes) - Same for ALL platforms
// =========================================================================
//
// Layout:
// [0-3] node - NodeNum (4 bytes, 0 = empty slot)
// [4] pos_fingerprint - 4 bits lat + 4 bits lon (0 = no position seen)
// [5] rate_count - [7:6] role[3:2] | [5:0] packets in rate window (0 = no window active)
// [6] unknown_count - [7:6] role[1:0] | [5:0] unknown packets in window (0 = no window active)
// [7] pos_time - Position tick (uint8, free-running 360 s/tick)
// [8] rate_unknown_time - [7:4] rate nibble (300 s/tick) | [3:0] unknown nibble (60 s/tick)
// [9] next_hop - Last-byte relay to reach `node` (0 = none)
//
// The 4-bit device role (bits [7:6] of rate_count paired with [7:6] of unknown_count)
// caches the sender's meshtastic_Config_DeviceConfig_Role as a third fallback after the
// hot store and warm store, for nodes evicted from both. Read/written via
// resolveSenderRole(). Max encodable value is 15.
//
// Presence sentinels (no epoch, no +1 offset needed):
// pos active: pos_fingerprint != 0
// rate active: getRateCount() != 0 (low 6 bits only)
// unknown active: getUnknownCount() != 0 (low 6 bits only)
//
// next_hop: routing hint written only from ACK-confirmed NextHopRouter decisions.
// No TTL - keeps the slot alive across maintenance sweeps.
//
// 10-byte packed entry, all platforms. Tick stamps are free-running modular counters with
// non-zero presence sentinels; the 4-bit cached role rides the top bits of the two count
// bytes (tier-3 role fallback). Full layout and rationale: docs/node_info_stores.md.
#if _meshtastic_Config_DeviceConfig_Role_MAX > 15
#warning "Device role enum max exceeds 15 - TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values"
#endif
@@ -128,80 +157,72 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
uint8_t rate_unknown_time;
uint8_t next_hop;
/// Packets seen in the current rate window (low 6 bits).
uint8_t getRateCount() const { return rate_count & 0x3F; }
/// Set the rate-window count, preserving the role bits.
void setRateCount(uint8_t c) { rate_count = static_cast<uint8_t>((rate_count & 0xC0) | (c & 0x3F)); }
/// Unknown packets seen in the current window (low 6 bits).
uint8_t getUnknownCount() const { return unknown_count & 0x3F; }
/// Set the unknown-window count, preserving the role bits.
void setUnknownCount(uint8_t c) { unknown_count = static_cast<uint8_t>((unknown_count & 0xC0) | (c & 0x3F)); }
/// Cached 4-bit device role, reassembled from the two count bytes' top bits.
uint8_t getCachedRole() const { return static_cast<uint8_t>(((rate_count >> 6) << 2) | (unknown_count >> 6)); }
/// Store a 4-bit device role across the two count bytes' top bits.
void setCachedRole(uint8_t role)
{
rate_count = static_cast<uint8_t>((rate_count & 0x3F) | ((role >> 2) << 6));
unknown_count = static_cast<uint8_t>((unknown_count & 0x3F) | ((role & 0x03) << 6));
}
/// Rate-window tick nibble.
uint8_t getRateTime() const { return (rate_unknown_time >> 4) & 0x0F; }
/// Unknown-window tick nibble.
uint8_t getUnknownTime() const { return rate_unknown_time & 0x0F; }
/// Set the rate-window tick nibble.
void setRateTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); }
/// Set the unknown-window tick nibble.
void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0xF0) | (t & 0x0F)); }
};
static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes");
// =========================================================================
// Flat unified cache
// =========================================================================
//
// Plain array, linear scan (same idiom as WarmNodeStore). A lookup walks at
// most cacheSize() × 10 B - microseconds at LoRa packet rates, not worth a
// hash table. Insertion on a full cache evicts the stalest entry,
// preferring entries without a next_hop hint (those are the long-tail
// routing state this cache exists to keep).
//
/// Unified cache capacity. Plain array, linear scan (same idiom as WarmNodeStore); insertion
/// on a full cache evicts the stalest entry, preferring ones without a next_hop hint.
static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; }
// NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload
// entries, linear scan keyed by `node`, LRU eviction by lastObservedMs.
// NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine.
// NodeInfo cache (PSRAM-backed on hardware, heap in native tests): flat payload array,
// linear scan, trust/membership-tiered LRU eviction on insert. NodeInfo traffic is
// low-rate, so full scans are fine.
static constexpr uint16_t kNodeInfoCacheEntries = 2000;
/// NodeInfo cache capacity.
static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; }
// =========================================================================
// Free-Running Tick Counters
// =========================================================================
//
// Timestamps are stored as free-running modular tick counters derived from
// millis(). No epoch anchor needed: modular subtraction gives correct age
// as long as the true age stays below the counter period.
//
// pos_time : uint8 (256 ticks × 360 s = 25.6 h period; max window 12 h = 120 ticks)
// rate_time : nibble (16 ticks × 300 s = 80 min period; max window 1 h = 12 ticks)
// unknown_time: nibble (16 ticks × 60 s = 16 min period; max window 12 min = 12 ticks)
//
// Presence sentinels (no +1 offset needed; count fields serve as guards):
// pos active: pos_fingerprint != 0 (0 is reserved sentinel; computePositionFingerprint() remaps computed-0 → 0xFF)
// rate active: getRateCount() != 0 (low 6 bits; high 2 bits are cached role)
// unknown active: getUnknownCount() != 0
//
static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick
static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick
static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick
// Free-running modular tick clocks derived from clockMs(); modular subtraction gives correct
// age while true age stays below the counter period. Presence is carried by non-zero
// sentinels (unified cache) or explicit validity bits (NodeInfo cache).
static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick (uint8: 25.6 h period)
static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick (nibble: 80 min period)
static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick (nibble: 16 min period)
/// Current position-clock tick (6 min/tick).
static uint8_t currentPosTick() { return static_cast<uint8_t>(clockMs() / kPosTimeTickMs); }
/// Current rate-clock tick nibble (5 min/tick).
static uint8_t currentRateTick() { return static_cast<uint8_t>((clockMs() / kRateTimeTickMs) & 0x0F); }
/// Current unknown-clock tick nibble (1 min/tick).
static uint8_t currentUnknownTick() { return static_cast<uint8_t>((clockMs() / kUnknownTimeTickMs) & 0x0F); }
// =========================================================================
// Position Fingerprint
// =========================================================================
//
// Computes 8-bit fingerprint from truncated lat/lon coordinates.
// Extracts lower 4 significant bits from each coordinate.
//
// fingerprint = (lat_low4 << 4) | lon_low4
//
// Unlike a hash, adjacent grid cells have sequential fingerprints,
// so nearby positions never collide. Collisions only occur for
// positions 16+ grid cells apart in both dimensions.
//
// Guards: If precision < 4 bits, uses min(precision, 4) bits.
//
// NodeInfo observation tick (same idiom). The 60 s sweep clears the presence bit once the serve
// window passes, so the stamp is never read near its uint8 aliasing horizon. (Response throttling
// no longer lives here - it is the fixed per-requester/per-target RAM tables below, which use
// wrap-safe uint32 ms compares and so need no tick clock or sweep.)
static constexpr uint32_t kNodeInfoObsTickMs = 180000UL; // 3 min/tick (12.8 h period)
static constexpr uint8_t kNodeInfoMaxServeAgeTicks = 120; // 6 h serve window
/// Current NodeInfo observation tick (3 min/tick).
static uint8_t currentObsTick() { return static_cast<uint8_t>(clockMs() / kNodeInfoObsTickMs); }
static_assert(kNodeInfoMaxServeAgeTicks * kNodeInfoObsTickMs == 6UL * 60UL * 60UL * 1000UL,
"cache serve window must equal the fallback path's 6 h");
/// 8-bit position fingerprint from truncated lat/lon: low 4 significant bits of each, so
/// adjacent grid cells never collide (collisions need 16+ cells apart in both dimensions).
static uint8_t computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision);
// =========================================================================
@@ -213,42 +234,60 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
bool cacheFromPsram = false; // Tracks allocator for correct deallocation
struct NodeInfoPayloadEntry {
// Node identifier associated with this payload slot.
// 0 means the slot is currently unused.
// Node identifier for this slot; 0 means unused.
NodeNum node;
// Cached NODEINFO_APP payload body. This is separate from NodeDB and is only
// used by the PSRAM-backed direct-response path in this module.
// Cached NODEINFO_APP payload, independent of NodeDB; serves the PSRAM-backed
// direct-response path and the last-resort pubkey pool.
meshtastic_User user;
// Extra response metadata captured from the latest observed NODEINFO_APP
// packet for this node. shouldRespondToNodeInfo() uses this metadata when
// building spoofed replies for requesting clients.
// Last local uptime tick (millis) when this entry was refreshed.
uint32_t lastObservedMs;
// Last RTC/packet timestamp (seconds) observed for this NodeInfo frame.
// If unavailable in packet, remains 0.
uint32_t lastObservedRxTime;
// Tick of the last genuinely HEARD NODEINFO frame (kNodeInfoObsTickMs clock). Drives the
// replay staleness gate and LRU age; seeding/write-through never touch it, so a spoofed
// reply is only ever backed by genuine recent observation. Validity: hasObserved.
uint8_t obsTick;
// Channel where we most recently heard this node's NodeInfo.
uint8_t sourceChannel;
// Cached decoded bitfield metadata from the source packet.
// We preserve non-OK_TO_MQTT bits in direct replies when available.
bool hasDecodedBitfield;
// Cached decoded bitfield from the source packet (non-OK_TO_MQTT bits are preserved
// in direct replies). Validity: hasDecodedBitfield.
uint8_t decodedBitfield;
};
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan)
// 1-bit flags, packed into one byte (6 spare bits; add future booleans here rather
// than new bytes - the array is 2000 entries).
// The source packet carried a decoded bitfield (so decodedBitfield is meaningful).
uint8_t hasDecodedBitfield : 1;
// Key provenance: set once an XEdDSA signature was verified for user.public_key
// (directly, or inherited from NodeDB via isVerifiedSignerForKey). Monotonic per slot;
// the key-pin checks forbid the key changing underneath it. TOFU keys start at 0.
uint8_t keySignerProven : 1;
// obsTick is valid: a NODEINFO frame was actually heard within the observation clock's
// horizon. Cleared by the sweep once the serve window passes (saturation).
uint8_t hasObserved : 1;
// `user` carries a real User payload (from an observed frame or hot-store seed) rather
// than a key-only warm-tier record. copyUser()/name-rehydration require it.
uint8_t hasFullUser : 1;
// Node currently exists in NodeDB (hot or warm), per the last hourly reconcile pass
// (write-through hooks set it immediately on commit; purgeNode clears immediately on
// removal; a passive NodeDB eviction may lag up to an hour). Member entries are
// stickiest under LRU; the bit is the keep-alive (no TTL).
uint8_t isMember : 1;
};
// No exact-size static_assert: sizeof(meshtastic_User) and its padding vary by platform, so
// any fixed byte count would fail the build on some boards.
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads (flat array; PSRAM on hardware, heap in tests)
bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation
meshtastic_TrafficManagementStats stats;
// Flag set during alterReceived() when packet should be exhausted.
// Checked by perhapsRebroadcast() to force hop_limit = 0 only for the
// matching packet key (from + id). Reset at start of handleReceived().
// Set during alterReceived() when the packet's hops should be exhausted; checked by
// perhapsRebroadcast() for the matching packet key. Reset at start of handleReceived().
bool exhaustRequested = false;
NodeNum exhaustRequestedFrom = 0;
PacketId exhaustRequestedId = 0;
@@ -256,48 +295,97 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
// One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass.
bool nextHopPreloaded = false;
// Reconcile cadence: full boot seed on the first maintenance pass, then hourly. The
// write-through hooks give immediacy; this periodic repair self-heals anything they miss.
static constexpr uint8_t kNodeInfoReconcileSweeps = 60; // sweeps between reconciliations (60 x 60 s = 1 h)
bool nodeInfoSeeded = false;
uint8_t sweepsSinceNodeInfoReconcile = 0;
// =========================================================================
// Cache Operations
// =========================================================================
// Find or create entry for node (linear scan; stalest-first eviction when full)
/// Find or create the unified-cache entry for `node` (stalest-first eviction when full).
UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew);
// Find existing entry (no creation)
/// Find an existing unified-cache entry (no creation).
UnifiedCacheEntry *findEntry(NodeNum node);
// Resolve a sender's advertised device role for the position hot path. The tier-3
// cache (this entry's getCachedRole) is authoritative and is kept fresh by
// updateCachedRoleFromNodeInfo() - updated when NodeDB learns a role, not re-derived
// per packet. Only on first tracking (isNew) do we scan NodeDB (hot store → warm
// store, via getNodeRole) to seed the cache, so a resident special-role node is
// correct from its first position; after that the read is O(1) and survives the node
// aging out of both NodeDB stores. Caller must hold cacheLock; entry may be null
// (→ NodeDB scan only).
/// Resolve a sender's device role for the position hot path. The tier-3 cache is
/// authoritative once seeded (NodeDB is scanned only on first tracking), so the read is O(1)
/// and survives the node aging out of both NodeDB stores. Caller must hold cacheLock.
meshtastic_Config_DeviceConfig_Role resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew);
// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates
// NodeDB's role). Reads role from the packet's User payload; updates only nodes already
// tracked (no entry creation). Takes cacheLock.
/// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates
/// NodeDB's role). Updates only nodes already tracked. Takes cacheLock.
void updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp);
// NodeInfo cache operations (flat PSRAM payload array, linear scan)
/// Find an existing NodeInfo cache entry (no creation).
const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const;
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot);
/// Mutable variant of findNodeInfoEntry().
NodeInfoPayloadEntry *findNodeInfoEntryMutable(NodeNum node)
{
return const_cast<NodeInfoPayloadEntry *>(findNodeInfoEntry(node));
}
/// Find or create a NodeInfo cache entry, evicting by trust/membership tier when full.
/// With spareMembers, returns nullptr instead of evicting an isMember entry (the seeding
/// pass never churns one NodeDB-tier node out for another; the packet path may).
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers = false);
/// Number of occupied NodeInfo cache slots. Caller must hold cacheLock.
uint16_t countNodeInfoEntriesLocked() const;
/// 60 s NodeInfo-cache maintenance under cacheLock: saturate the expired obsTick stamp (wrap-safety
/// for the modular clock) and run the boot/hourly reconcile. Guarded by TMM_HAS_NODEINFO_CACHE alone
/// (never the unified cache size); see docs/node_info_stores.md "Tick clocks and wrap safety".
void maintainNodeInfoCacheLocked();
/// Anti-entropy under cacheLock: upsert hot-store + warm-tier records this cache lacks (never sets
/// hasObserved - seeding is knowledge, not observation), and refresh isMember from both NodeDB
/// tiers. Cost/lag: docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)".
void reconcileNodeInfoFromNodeDBLocked();
/// Learn an observed NODEINFO frame into the cache (key hygiene + provenance rules apply).
void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp);
// =========================================================================
// Traffic Management Logic
// =========================================================================
/// True when this position broadcast duplicates the sender's last one within the dedup window.
bool shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs);
/// Decide (and with sendResponse, emit) a spoofed direct NodeInfo reply for a unicast request.
bool shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse);
// Direct-response throttles bounding the reflector risk of spoofed replies: three fixed bounds
// (per requester, per target, 1 s global airtime floor) via 8-slot LRU RAM tables, wrap-safe and
// PSRAM-agnostic. Design & rationale: docs/traffic_management_module.md "Throttling direct responses".
static constexpr uint32_t kDirectResponsePerRequesterMs = 60'000UL;
static constexpr uint32_t kDirectResponsePerTargetMs = 60'000UL;
static constexpr uint32_t kDirectResponseGlobalMs = 1'000UL;
static constexpr size_t kDirectResponseTrackedNodes = 8;
struct DirectResponseThrottleEntry {
NodeNum key; // requester or target node; 0 = unused slot
uint32_t lastReplyMs; // clockMs() of our last reply keyed on this node
};
DirectResponseThrottleEntry directRequesterSeen[kDirectResponseTrackedNodes] = {};
DirectResponseThrottleEntry directTargetSeen[kDirectResponseTrackedNodes] = {};
uint32_t lastDirectResponseMs = 0;
/// True (and records the send) when a spoofed direct reply to `requester` for `target` is within
/// every throttle window; false throttles it. Caller must NOT hold cacheLock (this takes it).
bool directResponseAllowed(NodeNum requester, NodeNum target, uint32_t nowMs);
/// Slot in `table` to stamp for `key` at `nowMs`, or nullptr if `key` is still within `windowMs`
/// of its last reply (throttled). Does not stamp - the caller stamps only once all windows pass.
static DirectResponseThrottleEntry *directResponseSlot(DirectResponseThrottleEntry *table, NodeNum key, uint32_t nowMs,
uint32_t windowMs);
/// True when the requestor is within the role-clamped hop limit for direct responses.
bool isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const;
/// True when `from` exceeded the configured packet budget for the current rate window.
bool isRateLimited(NodeNum from, uint32_t nowMs);
/// True when `p`'s sender exceeded the undecodable-packet threshold for the current window.
bool shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs);
/// Log a traffic action (drop/respond/clamp) with port name and packet routing context.
void logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const;
/// Increment a stats counter under cacheLock.
void incrementStat(uint32_t *field);
};

View File

@@ -258,6 +258,8 @@ bool AudioModule::shouldDraw()
void AudioModule::sendPayload(NodeNum dest, bool wantReplies)
{
meshtastic_MeshPacket *p = allocReply();
if (!p)
return;
p->to = dest;
p->decoded.want_response = wantReplies;

View File

@@ -81,6 +81,8 @@ bool PaxcounterModule::sendInfo(NodeNum dest)
pl.uptime = millis() / 1000;
meshtastic_MeshPacket *p = allocDataProtobuf(pl);
if (!p)
return false;
p->to = dest;
p->decoded.want_response = false;
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;

View File

@@ -105,6 +105,8 @@ void GamesModule::announceHighScore(const char *initials, uint32_t score)
if (!initials || initials[0] == '\0')
return;
meshtastic_MeshPacket *p = allocDataPacket();
if (!p)
return;
p->to = NODENUM_BROADCAST;
p->channel = 0; // primary channel
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;

View File

@@ -5,7 +5,7 @@
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
// screen is defined in main.cpp
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
#endif
BMM150Sensor::BMM150Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {}

View File

@@ -8,7 +8,7 @@ BMX160Sensor::BMX160Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::Mot
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
// screen is defined in main.cpp
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
#endif
bool BMX160Sensor::init()

View File

@@ -5,7 +5,7 @@
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
// screen is defined in main.cpp
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
#endif
// Flag when an interrupt has been detected

View File

@@ -6,7 +6,7 @@
#include "detect/ScanI2CTwoWire.h"
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
#endif
static constexpr float MMC5983MA_ZERO_FIELD = 131072.0f;

View File

@@ -45,7 +45,7 @@ CompassAccelSample latestCompassAccelSample;
} // namespace
// screen is defined in main.cpp
extern graphics::Screen *screen;
extern std::unique_ptr<graphics::Screen> screen;
MotionSensor::MotionSensor(ScanI2C::FoundDevice foundDevice)
{

View File

@@ -121,6 +121,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
// receives it when we get our own packet back. Then we'll stop our retransmissions.
if (isFromUs(e.packet)) {
auto pAck = routingModule->allocAckNak(meshtastic_Routing_Error_NONE, getFrom(e.packet), e.packet->id, ch.index);
if (!pAck)
return;
pAck->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT;
if (router->sendLocal(pAck) == ERRNO_SHOULD_RELEASE)
packetPool.release(pAck);
@@ -141,6 +143,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
}
UniquePacketPoolPacket p = packetPool.allocUniqueZeroed();
if (!p)
return;
p->from = e.packet->from;
p->to = e.packet->to;
p->id = e.packet->id;
@@ -148,7 +152,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
p->hop_limit = e.packet->hop_limit;
p->hop_start = e.packet->hop_start;
p->want_ack = e.packet->want_ack;
p->via_mqtt = true; // Mark that the packet was received via MQTT
p->via_mqtt = true; // Mark that the packet was received via MQTT
p->pki_encrypted = false; // Only local AES-CCM decryption may establish PKI authentication.
p->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT;
p->which_payload_variant = e.packet->which_payload_variant;
memcpy(&p->decoded, &e.packet->decoded, std::max(sizeof(p->decoded), sizeof(p->encrypted)));
@@ -173,12 +178,9 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
// impersonate a signing node with unsigned broadcasts. Hold cryptLock like the RF path
// (perhapsDecode) does - checkXeddsaReceivePolicy -> xeddsa_verify mutates shared
// CryptoEngine cache state, and MQTT ingress can run on a different task.
{
concurrency::LockGuard g(cryptLock);
if (!checkXeddsaReceivePolicy(p.get())) {
LOG_INFO("Ignore decoded message failing XEdDSA policy");
return;
}
if (passesRoutingAuthGate(p.get()) != RoutingAuthVerdict::ACCEPT) {
LOG_INFO("Ignore decoded message failing XEdDSA policy");
return;
}
#endif
}
@@ -191,8 +193,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
// likely they discovered each other via a channel we have downlink enabled for
if (isToUs(p.get()) || (nodeInfoLiteHasUser(tx) && nodeInfoLiteHasUser(rx)))
router->enqueueReceivedMessage(p.release());
} else if (router &&
perhapsDecode(p.get()) == DecodeState::DECODE_SUCCESS) // ignore messages if we don't have the channel key
} else if (router && passesRoutingAuthGate(p.get()) == RoutingAuthVerdict::ACCEPT)
router->enqueueReceivedMessage(p.release());
}
@@ -335,7 +336,20 @@ void MQTT::mqttCallback(char *topic, byte *payload, unsigned int length)
void MQTT::onClientProxyReceive(meshtastic_MqttClientProxyMessage msg)
{
onReceive(msg.topic, msg.payload_variant.data.bytes, msg.payload_variant.data.size);
// payload_variant is a union: on the text variant, data.size aliases the first bytes of the
// string, so reading it unconditionally let a client name any length up to PB_SIZE_MAX.
switch (msg.which_payload_variant) {
case meshtastic_MqttClientProxyMessage_data_tag:
onReceive(msg.topic, msg.payload_variant.data.bytes, msg.payload_variant.data.size);
break;
case meshtastic_MqttClientProxyMessage_text_tag:
onReceive(msg.topic, (byte *)msg.payload_variant.text,
strnlen(msg.payload_variant.text, sizeof(msg.payload_variant.text)));
break;
default:
LOG_WARN("MQTT proxy message carries no payload, topic %s", msg.topic);
break;
}
}
void MQTT::onReceive(char *topic, byte *payload, size_t length)

View File

@@ -36,7 +36,7 @@ class TouchInkHUDBridge : public Observer<const InputEvent *>
// Check whether a system applet (e.g. menu) is currently handling input
bool systemHandlingInput = false;
for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) {
for (const NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleInput) {
systemHandlingInput = true;
break;

View File

@@ -7,6 +7,7 @@
#include "error.h"
#include "main.h"
#include "mesh/PhoneAPI.h"
#include "mesh/Throttle.h"
#include "mesh/mesh-pb-constants.h"
#include <bluefruit.h>
#include <utility/bonding.h>
@@ -466,17 +467,23 @@ bool NRF52Bluetooth::onUnwantedPairing(uint16_t conn_handle, uint8_t const passk
// Disconnect any BLE connections
void NRF52Bluetooth::disconnect()
{
static constexpr uint32_t DISCONNECT_TIMEOUT_MSEC = 1000;
uint8_t connection_num = Bluefruit.connected();
if (connection_num) {
// Close all connections. We're only expecting one.
for (uint8_t i = 0; i < connection_num; i++)
Bluefruit.disconnect(i);
// Wait for disconnection
while (Bluefruit.connected())
yield();
// Best-effort wait: on Bluefruit's BLE event task the DISCONNECTED event can't be processed
// until this callback returns, so an unbounded wait would deadlock until the watchdog fires.
uint32_t start = millis();
while (Bluefruit.connected() && Throttle::isWithinTimespanMs(start, DISCONNECT_TIMEOUT_MSEC))
delay(1);
LOG_INFO("Ended BLE connection");
if (Bluefruit.connected())
LOG_WARN("BLE disconnect unconfirmed after %ums, continuing shutdown", millis() - start);
else
LOG_INFO("Ended BLE connection");
}
}

View File

@@ -323,13 +323,19 @@ void SimRadio::startReceive(meshtastic_MeshPacket *p)
return;
}
}
isReceiving = true;
receivingPacket = packetPool.allocCopy(*p);
if (!receivingPacket) {
return;
}
isReceiving = true;
uint32_t airtimeMsec = getPacketTime(p, true);
notifyLater(airtimeMsec, ISR_RX, false); // Model the time it is busy receiving
#else
isReceiving = true;
receivingPacket = packetPool.allocCopy(*p);
if (!receivingPacket) {
return;
}
isReceiving = true;
handleReceiveInterrupt(); // Simulate receiving the packet immediately
startTransmitTimer();
#endif

View File

@@ -404,6 +404,8 @@ A well-structured test suite follows this pattern:
| `test_position_precision` | Position precision helpers |
| `test_radio` | Radio interface |
| `test_serial` | Serial communication |
| `test_module_config` | AdminModule module config |
| `test_tak_config` | TAK (ATAK) team/role values |
| `test_traffic_management` | Traffic management |
| `test_transmit_history` | Retransmission tracking |
| `test_type_conversions` | NodeDB v25 type conversions |

View File

@@ -1 +1 @@
37
40

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