Merge branch 'meshtastic:develop' into develop
This commit is contained in:
@@ -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 \
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/python:1": {
|
||||
"installTools": true,
|
||||
"version": "3.13"
|
||||
"version": "3.14"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
name: Build Windows Binary
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
windows_ver:
|
||||
required: false
|
||||
default: "2025"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-Windows:
|
||||
runs-on: windows-${{ inputs.windows_ver }}
|
||||
# Only pushes to the default branch (develop) populate the cache; PR / merge_group runs
|
||||
# restore it but never save, so they stop filling up the repo's Actions cache storage.
|
||||
env:
|
||||
SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }}
|
||||
defaults:
|
||||
run:
|
||||
# UCRT64 is the MinGW-w64 environment native-windows targets; the `msys`
|
||||
# environment would link msys-2.0.dll and produce a Cygwin-style binary.
|
||||
shell: msys2 {0}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
# Keep the token out of .git/config so later steps and artifacts can't leak it.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup MSYS2 / UCRT64
|
||||
id: msys2
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
cache: true
|
||||
# argp is not packaged for the mingw environments and is built below.
|
||||
# Python is omitted too: MSYS2's reports a `mingw` platform tag no wheel matches.
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-pkgconf
|
||||
mingw-w64-ucrt-x86_64-yaml-cpp
|
||||
mingw-w64-ucrt-x86_64-libuv
|
||||
mingw-w64-ucrt-x86_64-jsoncpp
|
||||
mingw-w64-ucrt-x86_64-openssl
|
||||
mingw-w64-ucrt-x86_64-libusb
|
||||
mingw-w64-ucrt-x86_64-cmake
|
||||
mingw-w64-ucrt-x86_64-ninja
|
||||
git
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
cache: pip
|
||||
|
||||
# framework-portduino calls argp_parse(); MSYS2 packages argp only for the
|
||||
# msys runtime, which cannot link into a native binary. No install() rules.
|
||||
- name: Build and install argp-standalone
|
||||
run: |
|
||||
git clone --depth 1 https://github.com/tom42/argp-standalone /tmp/argp
|
||||
cd /tmp/argp
|
||||
cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release .
|
||||
cmake --build build
|
||||
cp include/argp-standalone/argp.h /ucrt64/include/argp.h
|
||||
cp build/src/libargp-standalone.a /ucrt64/lib/libargp.a
|
||||
|
||||
- name: Install PlatformIO
|
||||
shell: pwsh
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install platformio
|
||||
|
||||
- name: Restore PlatformIO cache
|
||||
id: pio-cache
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ~/.platformio/.cache
|
||||
key: pio-windows-${{ inputs.windows_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
||||
restore-keys: |
|
||||
pio-windows-${{ inputs.windows_ver }}-
|
||||
|
||||
- name: Get release version string
|
||||
shell: pwsh
|
||||
id: version
|
||||
run: echo "long=$(python ./bin/buildinfo.py long)" >> $env:GITHUB_OUTPUT
|
||||
|
||||
# Runs outside the MSYS2 shell so PlatformIO stays on the runner's
|
||||
# CPython; the UCRT64 toolchain is reached through PATH instead.
|
||||
- name: Build for Windows
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:PATH = "${{ steps.msys2.outputs.msys2-location }}\ucrt64\bin;$env:PATH"
|
||||
platformio run -e native-windows
|
||||
env:
|
||||
PKG_VERSION: ${{ steps.version.outputs.long }}
|
||||
|
||||
- name: Save PlatformIO cache
|
||||
if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: ~/.platformio/.cache
|
||||
key: pio-windows-${{ inputs.windows_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
||||
|
||||
- name: List output files
|
||||
run: ls -lah .pio/build/native-windows/
|
||||
|
||||
# The env links statically, so only Windows system DLLs should appear here.
|
||||
# A third-party DLL means the static link regressed.
|
||||
- name: Verify the binary is self-contained
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=.pio/build/native-windows/meshtasticd.exe
|
||||
test -f "$bin"
|
||||
# `|| true` keeps grep's no-match exit out of `set -e`; an empty import
|
||||
# table is caught by the test below instead of passing as "no deps".
|
||||
deps=$(objdump -p "$bin" | grep -i 'DLL Name' || true)
|
||||
test -n "$deps"
|
||||
extra=$(printf '%s\n' "$deps" \
|
||||
| grep -viE 'api-ms-win|KERNEL32|WS2_32|ADVAPI32|USER32|msvcrt|ucrtbase|bcrypt|IPHLPAPI|SHELL32|ole32|CRYPT32|SETUPAPI|CFGMGR32|WINMM|dbghelp' || true)
|
||||
if [ -n "$extra" ]; then
|
||||
printf '%s\n' "$extra"
|
||||
echo "::error::meshtasticd.exe has non-system DLL dependencies (static link regressed)"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: no third-party DLL dependencies"
|
||||
|
||||
- name: Smoke test the binary
|
||||
run: |
|
||||
.pio/build/native-windows/meshtasticd.exe --version
|
||||
|
||||
- name: Store binaries as an artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: firmware-windows-${{ inputs.windows_ver }}-${{ steps.version.outputs.long }}
|
||||
overwrite: true
|
||||
path: |
|
||||
.pio/build/native-windows/meshtasticd.exe
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -144,6 +154,18 @@ jobs:
|
||||
macos_ver: ${{ matrix.macos_ver }}
|
||||
# secrets: inherit
|
||||
|
||||
Windows:
|
||||
if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
windows_ver:
|
||||
- "2025" # x86_64
|
||||
uses: ./.github/workflows/build_windows_bin.yml
|
||||
with:
|
||||
windows_ver: ${{ matrix.windows_ver }}
|
||||
# secrets: inherit
|
||||
|
||||
package-pio-deps-native-tft:
|
||||
if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }}
|
||||
uses: ./.github/workflows/package_pio_deps.yml
|
||||
@@ -177,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 }}
|
||||
@@ -195,7 +210,6 @@ jobs:
|
||||
push: false
|
||||
|
||||
gather-artifacts:
|
||||
# trunk-ignore(checkov/CKV2_GHA_1)
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -36,4 +36,4 @@ jobs:
|
||||
- name: Trunk Upgrade
|
||||
uses: trunk-io/trunk-action/upgrade@v1
|
||||
with:
|
||||
base: master
|
||||
base: develop
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,3 +24,4 @@ jobs:
|
||||
uses: trunk-io/trunk-action@v1
|
||||
with:
|
||||
post-annotations: true
|
||||
cache: false
|
||||
|
||||
@@ -22,3 +22,4 @@ jobs:
|
||||
uses: trunk-io/trunk-action@v1
|
||||
with:
|
||||
save-annotations: true
|
||||
cache: false
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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*
|
||||
|
||||
+66
-10
@@ -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
|
||||
|
||||
@@ -153,7 +153,10 @@ These rules are what keep the UX correct across firmware versions. Implement all
|
||||
5. **EU region auto-swap caveat.** The firmware treats the EU sibling regions
|
||||
(`EU_868` / `EU_866` / `EU_N_868`) specially: if the user is in one of them and selects a
|
||||
preset that belongs to a sibling's list, the firmware **swaps the region** rather than
|
||||
rejecting the preset. Consequence for clients: **do not assume the region is immutable
|
||||
rejecting the preset. To make this visible in the picker, the firmware advertises the
|
||||
**same superset** (the union of the trio's presets) for all three sibling regions, so a
|
||||
client filtering per §6 will offer every EU 86x preset regardless of which sibling is
|
||||
currently selected. Consequence for clients: **do not assume the region is immutable
|
||||
across a preset change** - after an admin config write, re-read the resulting
|
||||
`LoRaConfig` and reflect the (possibly changed) region back into the UI.
|
||||
|
||||
@@ -249,12 +252,23 @@ so they are stable as listed here:
|
||||
| group_index | default_preset | licensed_only | presets |
|
||||
| ----------------------- | -------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO, MEDIUM_TURBO |
|
||||
| 1 (EU 868) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE |
|
||||
| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW |
|
||||
| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW |
|
||||
| 1 (EU 868) | `LONG_FAST` | false | _EU 86x superset_ (see below) |
|
||||
| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | _EU 86x superset_ (see below) |
|
||||
| 3 (EU 868 narrow) | `NARROW_SLOW` | false | _EU 86x superset_ (see below) |
|
||||
| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW |
|
||||
| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW |
|
||||
|
||||
The **EU 86x superset** advertised by groups 1, 2 and 3 is the union of the trio's own
|
||||
band presets, because the firmware auto-swaps region within the trio on preset selection
|
||||
(§5), so any of these is a legal pick from any of the three regions:
|
||||
|
||||
```text
|
||||
LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, LITE_FAST, LITE_SLOW, NARROW_FAST, NARROW_SLOW
|
||||
```
|
||||
|
||||
The three groups still differ by `default_preset` (`LONG_FAST` / `LITE_FAST` / `NARROW_SLOW`),
|
||||
which is why they remain distinct groups despite sharing this preset list.
|
||||
|
||||
`region_groups` (region → group_index):
|
||||
|
||||
| group | regions |
|
||||
@@ -266,9 +280,11 @@ so they are stable as listed here:
|
||||
| 4 | ITU1_2M, ITU2_2M, ITU3_2M |
|
||||
| 5 | ITU2_125CM |
|
||||
|
||||
> Note groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups
|
||||
> because they differ in `licensed_only`. Decoders must key on the group, not on the preset
|
||||
> list, to preserve the licensing flag.
|
||||
> Note that several groups can carry overlapping preset lists but remain distinct: groups 1,
|
||||
> 2 and 3 share the EU 86x superset yet differ in `default_preset`, and group **5** (ham
|
||||
> 100 kHz) shares the `NARROW_*` presets with group 3 but differs in `licensed_only`.
|
||||
> Decoders must key on the group, not on the preset list, to preserve `default_preset` and
|
||||
> the licensing flag.
|
||||
>
|
||||
> Regions **absent** from the table (no constraint info; see §5.1): `EU_874`, `EU_917`,
|
||||
> `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
# trunk-ignore-all(ruff/F821)
|
||||
# trunk-ignore-all(flake8/F821): For SConstruct imports
|
||||
#
|
||||
# PlatformIO routes build_flags to the compile step only, so the static link
|
||||
# flags are appended here, as wasm_link_flags.py does for [env:native-wasm].
|
||||
Import("env")
|
||||
|
||||
if env["PIOENV"].startswith("native-windows"):
|
||||
env.Append(
|
||||
LINKFLAGS=[
|
||||
"-static",
|
||||
"-static-libgcc",
|
||||
"-static-libstdc++",
|
||||
]
|
||||
)
|
||||
@@ -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");
|
||||
|
||||
+5
-4
@@ -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/33917d583eb3f4d6f93a24122a88101221afcc2d.zip
|
||||
https://github.com/meshtastic/device-ui/archive/ef573c368767625ffbe8c32cf921ea7366f2dd53.zip
|
||||
|
||||
; Common libs for environmental measurements in telemetry module
|
||||
[environmental_base]
|
||||
@@ -170,7 +171,7 @@ lib_deps =
|
||||
https://github.com/adafruit/Adafruit_TSL2591_Library/archive/refs/tags/1.4.5.zip
|
||||
# renovate: datasource=github-tags depName=EmotiBit MLX90632 packageName=emotibit/EmotiBit_MLX90632
|
||||
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614
|
||||
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit-MLX90614-Library
|
||||
https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT
|
||||
https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip
|
||||
@@ -200,7 +201,7 @@ lib_deps =
|
||||
https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip
|
||||
# renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE
|
||||
https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip
|
||||
# renovate: datasource=git-refs depName=Fusion packageName=https://github.com/meshtastic/Fusion gitBranch=master
|
||||
# renovate: datasource=git-refs depName=Fusion packageName=https://github.com/meshtastic/Fusion gitBranch=main
|
||||
https://github.com/meshtastic/Fusion/archive/936e1eb1e5ea19e8f4ed467526f91adeebb4f53c.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
|
||||
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
|
||||
|
||||
+1
-1
Submodule protobufs updated: f5b94bc367...bfd718fa1d
@@ -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")
|
||||
|
||||
+79
-38
@@ -12,6 +12,85 @@
|
||||
#include "SPILock.h"
|
||||
#include "configuration.h"
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
#include <filesystem>
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_STM32)
|
||||
// Adafruit_LittleFS (nRF52) and STM32_LittleFS both wrap littlefs v1, which predates lfs_fs_size().
|
||||
// Count the blocks currently in use with lfs_traverse() instead.
|
||||
static int fsCountBlockCb(void *ctx, lfs_block_t)
|
||||
{
|
||||
*static_cast<size_t *>(ctx) += 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t fsTotalBytes()
|
||||
{
|
||||
lfs_t *fs = FSCom._getFS();
|
||||
if (!fs || !fs->cfg)
|
||||
return 0;
|
||||
return (size_t)fs->cfg->block_count * (size_t)fs->cfg->block_size;
|
||||
}
|
||||
|
||||
size_t fsUsedBytes()
|
||||
{
|
||||
lfs_t *fs = FSCom._getFS();
|
||||
if (!fs || !fs->cfg)
|
||||
return 0;
|
||||
size_t blocks = 0;
|
||||
FSCom._lockFS();
|
||||
int err = lfs_traverse(fs, fsCountBlockCb, &blocks);
|
||||
FSCom._unlockFS();
|
||||
if (err < 0)
|
||||
return fsTotalBytes(); // report "full" so capacity checks fail safe
|
||||
return blocks * (size_t)fs->cfg->block_size;
|
||||
}
|
||||
#elif defined(ARCH_RP2040)
|
||||
// arduino-pico reports capacity through FSInfo rather than as methods.
|
||||
size_t fsTotalBytes()
|
||||
{
|
||||
FSInfo info;
|
||||
return FSCom.info(info) ? (size_t)info.totalBytes : 0;
|
||||
}
|
||||
|
||||
size_t fsUsedBytes()
|
||||
{
|
||||
FSInfo info;
|
||||
return FSCom.info(info) ? (size_t)info.usedBytes : 0;
|
||||
}
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
// Portduino is backed by the host filesystem; ask the OS about the volume holding the working
|
||||
// directory. std::filesystem keeps this working on native-windows too, where statvfs() does not
|
||||
// exist. On error we report "full" so capacity checks fail safe.
|
||||
size_t fsTotalBytes()
|
||||
{
|
||||
std::error_code ec;
|
||||
const auto info = std::filesystem::space(".", ec);
|
||||
return ec ? 0 : (size_t)info.capacity;
|
||||
}
|
||||
|
||||
size_t fsUsedBytes()
|
||||
{
|
||||
std::error_code ec;
|
||||
const auto info = std::filesystem::space(".", ec);
|
||||
if (ec || info.capacity < info.available)
|
||||
return fsTotalBytes();
|
||||
return (size_t)(info.capacity - info.available);
|
||||
}
|
||||
#else
|
||||
// ESP32 LittleFS and the nRF54L15 wrapper expose these directly.
|
||||
size_t fsTotalBytes()
|
||||
{
|
||||
return FSCom.totalBytes();
|
||||
}
|
||||
|
||||
size_t fsUsedBytes()
|
||||
{
|
||||
return FSCom.usedBytes();
|
||||
}
|
||||
#endif
|
||||
|
||||
// Software SPI is used by MUI so disable SD card here until it's also implemented
|
||||
#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI)
|
||||
#include <SD.h>
|
||||
@@ -30,44 +109,6 @@ SPIClass SPI_HSPI(HSPI);
|
||||
|
||||
#endif // HAS_SDCARD
|
||||
|
||||
/**
|
||||
* @brief Copies a file from one location to another.
|
||||
*
|
||||
* @param from The path of the source file.
|
||||
* @param to The path of the destination file.
|
||||
* @return true if the file was successfully copied, false otherwise.
|
||||
*/
|
||||
bool copyFile(const char *from, const char *to)
|
||||
{
|
||||
#ifdef FSCom
|
||||
// take SPI Lock
|
||||
concurrency::LockGuard g(spiLock);
|
||||
unsigned char cbuffer[16];
|
||||
|
||||
File f1 = FSCom.open(from, FILE_O_READ);
|
||||
if (!f1) {
|
||||
LOG_ERROR("Failed to open source file %s", from);
|
||||
return false;
|
||||
}
|
||||
|
||||
File f2 = FSCom.open(to, FILE_O_WRITE);
|
||||
if (!f2) {
|
||||
LOG_ERROR("Failed to open destination file %s", to);
|
||||
return false;
|
||||
}
|
||||
|
||||
while (f1.available() > 0) {
|
||||
byte i = f1.read(cbuffer, 16);
|
||||
f2.write(cbuffer, i);
|
||||
}
|
||||
|
||||
f2.flush();
|
||||
f2.close();
|
||||
f1.close();
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a file from pathFrom to pathTo.
|
||||
*
|
||||
|
||||
+6
-1
@@ -56,8 +56,13 @@ using namespace Adafruit_LittleFS_Namespace;
|
||||
using namespace Adafruit_LittleFS_Namespace;
|
||||
#endif
|
||||
|
||||
// Filesystem capacity, in bytes. Only ESP32's LittleFS and the nRF54L15 wrapper expose totalBytes()/usedBytes()
|
||||
// directly; the other backends need per-platform work (littlefs v1 traversal, FSInfo, statvfs), so callers must
|
||||
// use these helpers rather than reaching into FSCom.
|
||||
size_t fsTotalBytes();
|
||||
size_t fsUsedBytes();
|
||||
|
||||
void fsInit();
|
||||
bool copyFile(const char *from, const char *to);
|
||||
bool renameFile(const char *pathFrom, const char *pathTo);
|
||||
bool fsFormat();
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount = 64, bool *wasLimited = nullptr);
|
||||
|
||||
+84
-32
@@ -65,6 +65,15 @@ static inline const char *getTextFromPool(uint16_t offset)
|
||||
return &g_messagePool[offset];
|
||||
}
|
||||
|
||||
static inline bool isIgnoredNodeNum(uint32_t nodeNum)
|
||||
{
|
||||
if (nodeNum == 0 || nodeNum == NODENUM_BROADCAST)
|
||||
return false;
|
||||
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeNum);
|
||||
return nodeInfoLiteIsIgnored(node);
|
||||
}
|
||||
|
||||
// Helper: assign a timestamp (RTC if available, else boot-relative)
|
||||
static inline void assignTimestamp(StoredMessage &sm)
|
||||
{
|
||||
@@ -163,9 +172,44 @@ static inline void autosaveTick(MessageStore *store)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Add from incoming/outgoing packet
|
||||
const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &packet)
|
||||
bool MessageStore::shouldStorePacket(const meshtastic_MeshPacket &packet) const
|
||||
{
|
||||
const uint32_t localNode = nodeDB->getNodeNum();
|
||||
const bool isDM = packet.to != 0 && packet.to != NODENUM_BROADCAST;
|
||||
if (isDM) {
|
||||
const bool outgoing = packet.from == 0 || packet.from == localNode;
|
||||
const uint32_t peer = outgoing ? packet.to : packet.from;
|
||||
return !isIgnoredNodeNum(peer);
|
||||
}
|
||||
|
||||
if (packet.from != 0 && packet.from != localNode)
|
||||
return !isIgnoredNodeNum(packet.from);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MessageStore::isMessageVisible(const StoredMessage &msg) const
|
||||
{
|
||||
const uint32_t localNode = nodeDB->getNodeNum();
|
||||
if (msg.type == MessageType::DM_TO_US) {
|
||||
const uint32_t peer = (msg.sender == localNode) ? msg.dest : msg.sender;
|
||||
return !isIgnoredNodeNum(peer);
|
||||
}
|
||||
|
||||
if (msg.sender != 0 && msg.sender != localNode)
|
||||
return !isIgnoredNodeNum(msg.sender);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add from incoming/outgoing packet
|
||||
const StoredMessage *MessageStore::tryAddFromPacket(const meshtastic_MeshPacket &packet)
|
||||
{
|
||||
if (!shouldStorePacket(packet)) {
|
||||
LOG_DEBUG("Drop store 0x%08x", packet.from);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
StoredMessage sm;
|
||||
assignTimestamp(sm);
|
||||
sm.channelIndex = packet.channel;
|
||||
@@ -196,34 +240,7 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa
|
||||
markMessageStoreUnsaved();
|
||||
#endif
|
||||
|
||||
return liveMessages.back();
|
||||
}
|
||||
|
||||
// Outgoing/manual message
|
||||
void MessageStore::addFromString(uint32_t sender, uint8_t channelIndex, const std::string &text)
|
||||
{
|
||||
StoredMessage sm;
|
||||
|
||||
// Always use our local time (helper handles RTC vs boot time)
|
||||
assignTimestamp(sm);
|
||||
|
||||
sm.sender = sender;
|
||||
sm.channelIndex = channelIndex;
|
||||
sm.textOffset = storeTextInPool(text.c_str(), text.size());
|
||||
sm.textLength = text.size();
|
||||
|
||||
// Use the provided destination
|
||||
sm.dest = sender;
|
||||
sm.type = MessageType::DM_TO_US;
|
||||
|
||||
// Outgoing messages always start with unknown ack status
|
||||
sm.ackStatus = AckStatus::NONE;
|
||||
|
||||
addLiveMessage(sm);
|
||||
|
||||
#if ENABLE_MESSAGE_PERSISTENCE
|
||||
markMessageStoreUnsaved();
|
||||
#endif
|
||||
return &liveMessages.back();
|
||||
}
|
||||
|
||||
#if ENABLE_MESSAGE_PERSISTENCE
|
||||
@@ -323,6 +340,7 @@ void MessageStore::loadFromFlash()
|
||||
resetMessagePool(); // reset pool when loading
|
||||
|
||||
#ifdef FSCom
|
||||
{
|
||||
concurrency::LockGuard guard(spiLock);
|
||||
|
||||
if (!FSCom.exists(filename.c_str()))
|
||||
@@ -345,6 +363,10 @@ void MessageStore::loadFromFlash()
|
||||
}
|
||||
|
||||
f.close();
|
||||
}
|
||||
|
||||
if (pruneHiddenMessages())
|
||||
saveToFlash();
|
||||
#endif
|
||||
// Loading messages does not trigger an autosave
|
||||
g_messageStoreHasUnsavedChanges = false;
|
||||
@@ -406,6 +428,13 @@ template <typename Predicate> static void eraseAllMatches(std::deque<StoredMessa
|
||||
}
|
||||
}
|
||||
|
||||
bool MessageStore::pruneHiddenMessages()
|
||||
{
|
||||
const size_t before = liveMessages.size();
|
||||
eraseAllMatches(liveMessages, [&](const StoredMessage &m) { return !isMessageVisible(m); });
|
||||
return liveMessages.size() != before;
|
||||
}
|
||||
|
||||
// Delete oldest message (RAM + persisted queue)
|
||||
void MessageStore::deleteOldestMessage()
|
||||
{
|
||||
@@ -443,6 +472,20 @@ void MessageStore::deleteAllMessagesWithPeer(uint32_t peer)
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
void MessageStore::deleteAllMessagesFromNode(uint32_t nodeNum)
|
||||
{
|
||||
const uint32_t local = nodeDB->getNodeNum();
|
||||
auto pred = [&](const StoredMessage &m) {
|
||||
if (m.sender == nodeNum)
|
||||
return true;
|
||||
if (m.type != MessageType::DM_TO_US)
|
||||
return false;
|
||||
return m.sender == local ? m.dest == nodeNum : m.sender == nodeNum;
|
||||
};
|
||||
eraseAllMatches(liveMessages, pred);
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
// Delete oldest message in a direct chat with a node
|
||||
void MessageStore::deleteOldestMessageWithPeer(uint32_t peer)
|
||||
{
|
||||
@@ -460,7 +503,7 @@ std::deque<StoredMessage> MessageStore::getChannelMessages(uint8_t channel) cons
|
||||
{
|
||||
std::deque<StoredMessage> result;
|
||||
for (const auto &m : liveMessages) {
|
||||
if (m.type == MessageType::BROADCAST && m.channelIndex == channel) {
|
||||
if (isMessageVisible(m) && m.type == MessageType::BROADCAST && m.channelIndex == channel) {
|
||||
result.push_back(m);
|
||||
}
|
||||
}
|
||||
@@ -471,13 +514,22 @@ std::deque<StoredMessage> MessageStore::getDirectMessages() const
|
||||
{
|
||||
std::deque<StoredMessage> result;
|
||||
for (const auto &m : liveMessages) {
|
||||
if (m.type == MessageType::DM_TO_US) {
|
||||
if (isMessageVisible(m) && m.type == MessageType::DM_TO_US) {
|
||||
result.push_back(m);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool MessageStore::hasVisibleMessages() const
|
||||
{
|
||||
for (const auto &m : liveMessages) {
|
||||
if (isMessageVisible(m))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Upgrade boot-relative timestamps once RTC is valid
|
||||
// Only same-boot boot-relative messages are healed.
|
||||
// Persisted boot-relative messages from old boots stay ??? forever.
|
||||
|
||||
+7
-5
@@ -93,10 +93,8 @@ class MessageStore
|
||||
void addLiveMessage(StoredMessage &&msg);
|
||||
void addLiveMessage(const StoredMessage &msg); // convenience overload
|
||||
const std::deque<StoredMessage> &getLiveMessages() const { return liveMessages; }
|
||||
|
||||
// Add new messages from packets or manual input
|
||||
const StoredMessage &addFromPacket(const meshtastic_MeshPacket &mp); // Incoming/outgoing → RAM only
|
||||
void addFromString(uint32_t sender, uint8_t channelIndex, const std::string &text); // Manual add
|
||||
// Add new messages from packets. Returns nullptr if the packet is filtered out.
|
||||
const StoredMessage *tryAddFromPacket(const meshtastic_MeshPacket &mp); // Incoming/outgoing -> RAM only
|
||||
|
||||
// Persistence methods (used only on boot/shutdown)
|
||||
void saveToFlash(); // Save messages to flash
|
||||
@@ -111,13 +109,16 @@ class MessageStore
|
||||
void deleteOldestMessageWithPeer(uint32_t peer);
|
||||
void deleteAllMessagesInChannel(uint8_t channel);
|
||||
void deleteAllMessagesWithPeer(uint32_t peer);
|
||||
|
||||
void deleteAllMessagesFromNode(uint32_t nodeNum);
|
||||
// Unified accessor (for UI code, defaults to RAM buffer)
|
||||
const std::deque<StoredMessage> &getMessages() const { return liveMessages; }
|
||||
bool hasVisibleMessages() const;
|
||||
|
||||
// Helper filters for future use
|
||||
std::deque<StoredMessage> getChannelMessages(uint8_t channel) const; // Only broadcast messages on a channel
|
||||
std::deque<StoredMessage> getDirectMessages() const; // Only direct messages
|
||||
bool shouldStorePacket(const meshtastic_MeshPacket &mp) const;
|
||||
bool isMessageVisible(const StoredMessage &msg) const;
|
||||
|
||||
// Upgrade boot-relative timestamps once RTC is valid
|
||||
void upgradeBootRelativeTimestamps();
|
||||
@@ -129,6 +130,7 @@ class MessageStore
|
||||
static uint16_t storeText(const char *src, size_t len);
|
||||
|
||||
private:
|
||||
bool pruneHiddenMessages();
|
||||
std::deque<StoredMessage> liveMessages; // Single in-RAM message buffer (also used for persistence)
|
||||
std::string filename; // Flash filename for persistence
|
||||
};
|
||||
|
||||
@@ -868,7 +868,6 @@ void Power::reboot()
|
||||
Wire.end();
|
||||
Serial1.end();
|
||||
if (screen) {
|
||||
delete screen;
|
||||
screen = nullptr;
|
||||
}
|
||||
LOG_DEBUG("final reboot!");
|
||||
|
||||
@@ -57,16 +57,6 @@ void consoleInit()
|
||||
DEBUG_PORT.rpInit(); // Simply sets up semaphore
|
||||
}
|
||||
|
||||
/// Print and flush an unclassified formatted console message.
|
||||
void consolePrintf(const char *format, ...)
|
||||
{
|
||||
va_list arg;
|
||||
va_start(arg, format);
|
||||
console->vprintf(nullptr, format, arg);
|
||||
va_end(arg);
|
||||
console->flush();
|
||||
}
|
||||
|
||||
/// Initialize console, protobuf transport, serial port, and worker thread state.
|
||||
SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), concurrency::OSThread("SerialConsole")
|
||||
{
|
||||
|
||||
@@ -67,7 +67,6 @@ class SerialConsole : public StreamAPI, public RedirectablePrint, private concur
|
||||
};
|
||||
|
||||
// A simple wrapper to allow non class aware code write to the console
|
||||
void consolePrintf(const char *format, ...);
|
||||
void consoleInit();
|
||||
|
||||
extern SerialConsole *console;
|
||||
@@ -194,18 +194,6 @@ void playBoop()
|
||||
playTones(melody, sizeof(melody) / sizeof(ToneDuration));
|
||||
}
|
||||
|
||||
void playLongPressLeadUp()
|
||||
{
|
||||
// An ascending lead-up sequence for long press - builds anticipation
|
||||
ToneDuration melody[] = {
|
||||
{NOTE_C3, 100}, // Start low
|
||||
{NOTE_E3, 100}, // Step up
|
||||
{NOTE_G3, 100}, // Keep climbing
|
||||
{NOTE_B3, 150} // Peak with longer note for emphasis
|
||||
};
|
||||
playTones(melody, sizeof(melody) / sizeof(ToneDuration));
|
||||
}
|
||||
|
||||
// Static state for progressive lead-up notes
|
||||
static int leadUpNoteIndex = 0;
|
||||
static const ToneDuration leadUpNotes[] = {
|
||||
|
||||
@@ -12,6 +12,5 @@ void play4ClickUp();
|
||||
void playBoop();
|
||||
void playChirp();
|
||||
void playClick();
|
||||
void playLongPressLeadUp();
|
||||
bool playNextLeadUpNote(); // Play the next note in the lead-up sequence
|
||||
void resetLeadUpSequence(); // Reset the lead-up sequence to start from beginning
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -220,6 +220,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__); \
|
||||
@@ -472,13 +510,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;
|
||||
}
|
||||
@@ -857,15 +905,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;
|
||||
|
||||
@@ -2201,11 +2201,6 @@ bool GPS::hasLock()
|
||||
return false;
|
||||
}
|
||||
|
||||
bool GPS::hasFlow()
|
||||
{
|
||||
return reader.passedChecksum() > 0;
|
||||
}
|
||||
|
||||
bool GPS::whileActive()
|
||||
{
|
||||
unsigned int charsInBuf = 0;
|
||||
|
||||
@@ -111,9 +111,6 @@ class GPS : private concurrency::OSThread
|
||||
/// Returns true if we have acquired GPS lock.
|
||||
virtual bool hasLock();
|
||||
|
||||
/// Returns true if there's valid data flow with the chip.
|
||||
virtual bool hasFlow();
|
||||
|
||||
/// Return true if we are connected to a GPS
|
||||
bool isConnected() const { return hasGPS; }
|
||||
|
||||
|
||||
@@ -496,34 +496,6 @@ float GeoCoord::rangeMetersToRadians(double range_meters)
|
||||
return (PI / (180 * 60)) * distance_nm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ported from http://www.edwilliams.org/avform147.htm#Intro
|
||||
* @brief Convert from radians to range in meters on a great circle
|
||||
* @param range_radians
|
||||
* The range in radians
|
||||
* @return Range in meters on a great circle
|
||||
*/
|
||||
float GeoCoord::rangeRadiansToMeters(double range_radians)
|
||||
{
|
||||
double distance_nm = ((180 * 60) / PI) * range_radians;
|
||||
// 1 meter is 0.000539957 nm
|
||||
return distance_nm * 0.000539957;
|
||||
}
|
||||
|
||||
// Find distance from point to passed in point
|
||||
int32_t GeoCoord::distanceTo(const GeoCoord &pointB)
|
||||
{
|
||||
return latLongToMeter(this->getLatitude() * 1e-7, this->getLongitude() * 1e-7, pointB.getLatitude() * 1e-7,
|
||||
pointB.getLongitude() * 1e-7);
|
||||
}
|
||||
|
||||
// Find bearing from point to passed in point
|
||||
int32_t GeoCoord::bearingTo(const GeoCoord &pointB)
|
||||
{
|
||||
return bearing(this->getLatitude() * 1e-7, this->getLongitude() * 1e-7, pointB.getLatitude() * 1e-7,
|
||||
pointB.getLongitude() * 1e-7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new point based on the passed-in point
|
||||
* Ported from http://www.edwilliams.org/avform147.htm#LL
|
||||
|
||||
@@ -103,7 +103,6 @@ class GeoCoord
|
||||
static void convertWGS84ToOSGB36(const double lat, const double lon, double &osgb_Latitude, double &osgb_Longitude);
|
||||
static float latLongToMeter(double lat_a, double lng_a, double lat_b, double lng_b);
|
||||
static float bearing(double lat1, double lon1, double lat2, double lon2);
|
||||
static float rangeRadiansToMeters(double range_radians);
|
||||
static float rangeMetersToRadians(double range_meters);
|
||||
static unsigned int bearingToDegrees(const char *bearing);
|
||||
static const char *degreesToBearing(unsigned int degrees);
|
||||
@@ -114,8 +113,6 @@ class GeoCoord
|
||||
static double toDegrees(double r);
|
||||
|
||||
// Point to point conversions
|
||||
int32_t distanceTo(const GeoCoord &pointB);
|
||||
int32_t bearingTo(const GeoCoord &pointB);
|
||||
std::shared_ptr<GeoCoord> pointAtDistance(double bearing, double range);
|
||||
|
||||
// Lat lon alt getters
|
||||
|
||||
+12
-3
@@ -319,7 +319,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
|
||||
#else
|
||||
rtc.initI2C();
|
||||
#endif
|
||||
tm *t = gmtime(&tv->tv_sec);
|
||||
// tv_sec is a long, which is not time_t everywhere: on Windows
|
||||
// time_t is 64-bit while long is 32-bit. Copy before taking &.
|
||||
time_t setSecs = tv->tv_sec;
|
||||
tm *t = gmtime(&setSecs);
|
||||
rtc.setTime(t->tm_year + 1900, t->tm_mon + 1, t->tm_wday, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec);
|
||||
LOG_DEBUG("RV3028_RTC setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday,
|
||||
t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
|
||||
@@ -341,7 +344,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
|
||||
#else
|
||||
rtc.begin(Wire);
|
||||
#endif
|
||||
tm *t = gmtime(&tv->tv_sec);
|
||||
// tv_sec is a long, which is not time_t everywhere: on Windows
|
||||
// time_t is 64-bit while long is 32-bit. Copy before taking &.
|
||||
time_t setSecs = tv->tv_sec;
|
||||
tm *t = gmtime(&setSecs);
|
||||
rtc.setDateTime(*t);
|
||||
LOG_DEBUG("%s setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t->tm_year + 1900, t->tm_mon + 1,
|
||||
t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
|
||||
@@ -355,7 +361,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
|
||||
#else
|
||||
ArtronShop_RX8130CE rtc(&Wire);
|
||||
#endif
|
||||
tm *t = gmtime(&tv->tv_sec);
|
||||
// tv_sec is a long, which is not time_t everywhere: on Windows
|
||||
// time_t is 64-bit while long is 32-bit. Copy before taking &.
|
||||
time_t setSecs = tv->tv_sec;
|
||||
tm *t = gmtime(&setSecs);
|
||||
if (rtc.setTime(*t)) {
|
||||
LOG_DEBUG("RX8130CE setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1,
|
||||
t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
|
||||
|
||||
@@ -77,12 +77,13 @@ EInkParallelDisplay::~EInkParallelDisplay()
|
||||
bool EInkParallelDisplay::connect()
|
||||
{
|
||||
LOG_INFO("Do EPD init");
|
||||
int initRc = BBEP_SUCCESS;
|
||||
if (!epaper) {
|
||||
epaper = new FASTEPD;
|
||||
#if defined(T5_S3_EPAPER_PRO_V1)
|
||||
epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
|
||||
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
|
||||
#elif defined(T5_S3_EPAPER_PRO_V2)
|
||||
epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
|
||||
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
|
||||
// initialize all port 0 pins (0-7) as outputs / HIGH
|
||||
for (int i = 0; i < 8; i++) {
|
||||
epaper->ioPinMode(i, OUTPUT);
|
||||
@@ -93,6 +94,16 @@ bool EInkParallelDisplay::connect()
|
||||
#endif
|
||||
}
|
||||
|
||||
// FastEPD allocates its framebuffer only from PSRAM; if PSRAM init failed the alloc returns
|
||||
// NULL and initPanel() returns an error, so clearWhite() below would memset(NULL). Skip EInk
|
||||
// bring-up but return true so OLEDDisplay still allocates its base buffer (base draw ops stay
|
||||
// safe); displayReady stays false so the FastEPD push paths no-op -> node runs headless.
|
||||
if (initRc != BBEP_SUCCESS || epaper->currentBuffer() == nullptr) {
|
||||
LOG_ERROR("EPD framebuffer unavailable (initPanel rc=%d, PSRAM=%u); running headless", initRc,
|
||||
(unsigned)ESP.getPsramSize());
|
||||
return true;
|
||||
}
|
||||
|
||||
// epaper->setRotation(rotation); // does not work, messes up width/height
|
||||
epaper->setMode(BB_MODE_1BPP);
|
||||
epaper->clearWhite();
|
||||
@@ -103,6 +114,7 @@ bool EInkParallelDisplay::connect()
|
||||
resetGhostPixelTracking();
|
||||
#endif
|
||||
|
||||
displayReady = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -187,6 +199,9 @@ void EInkParallelDisplay::asyncFullUpdateTask(void *pvParameters)
|
||||
*/
|
||||
void EInkParallelDisplay::display(void)
|
||||
{
|
||||
if (!displayReady) // no framebuffer (PSRAM absent / init failed) -> nothing to push
|
||||
return;
|
||||
|
||||
const uint16_t w = this->displayWidth;
|
||||
const uint16_t h = this->displayHeight;
|
||||
|
||||
@@ -400,6 +415,9 @@ void EInkParallelDisplay::resetGhostPixelTracking()
|
||||
*/
|
||||
bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit)
|
||||
{
|
||||
if (!displayReady)
|
||||
return false;
|
||||
|
||||
uint32_t now = millis();
|
||||
if (lastDrawMsec == 0 || (now - lastDrawMsec) > msecLimit) {
|
||||
display();
|
||||
@@ -410,6 +428,8 @@ bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit)
|
||||
|
||||
void EInkParallelDisplay::endUpdate()
|
||||
{
|
||||
if (!displayReady)
|
||||
return;
|
||||
{
|
||||
// ensure any async full update is started/completed
|
||||
if (asyncFullRunning.load()) {
|
||||
|
||||
@@ -40,6 +40,9 @@ class EInkParallelDisplay : public OLEDDisplay
|
||||
uint32_t lastDrawMsec = 0;
|
||||
FASTEPD *epaper;
|
||||
|
||||
// Set only when connect() fully succeeds; framebuffer-touching methods no-op while false.
|
||||
bool displayReady = false;
|
||||
|
||||
private:
|
||||
// Async full-refresh support
|
||||
std::atomic<bool> asyncFullRunning{false};
|
||||
|
||||
@@ -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();
|
||||
|
||||
+6
-40
@@ -1846,40 +1846,6 @@ void Screen::handleStartFirmwareUpdateScreen()
|
||||
setFrameImmediateDraw(frames);
|
||||
}
|
||||
|
||||
void Screen::blink()
|
||||
{
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
// L4: defensive guard. blink() paints arbitrary geometry, not node
|
||||
// data, so it doesn't actually leak today. But it bypasses the normal
|
||||
// ui->update() path that the lockdown short-circuit gates, so any
|
||||
// future change that puts content into blink would silently leak past
|
||||
// redaction. Refuse to draw when the redaction latch is set.
|
||||
if (meshtastic_security::shouldRedactDisplay())
|
||||
return;
|
||||
#endif
|
||||
setFastFramerate();
|
||||
uint8_t count = 10;
|
||||
dispdev->setBrightness(254);
|
||||
while (count > 0) {
|
||||
dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight());
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
prepareFrameColorRegions();
|
||||
#endif
|
||||
dispdev->display();
|
||||
delay(50);
|
||||
dispdev->clear();
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
prepareFrameColorRegions();
|
||||
#endif
|
||||
dispdev->display();
|
||||
delay(50);
|
||||
count = count - 1;
|
||||
}
|
||||
// The dispdev->setBrightness does not work for t-deck display, it seems to run the setBrightness function in
|
||||
// OLEDDisplay.
|
||||
dispdev->setBrightness(brightness);
|
||||
}
|
||||
|
||||
void Screen::increaseBrightness()
|
||||
{
|
||||
brightness = ((brightness + 62) > 254) ? brightness : (brightness + 62);
|
||||
@@ -2110,7 +2076,7 @@ int Screen::handleInputEvent(const InputEvent *event)
|
||||
if (ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) {
|
||||
|
||||
if (event->inputEvent == INPUT_BROKER_UP) {
|
||||
if (messageStore.getMessages().empty()) {
|
||||
if (!messageStore.hasVisibleMessages()) {
|
||||
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST);
|
||||
} else {
|
||||
graphics::MessageRenderer::scrollUp();
|
||||
@@ -2120,7 +2086,7 @@ int Screen::handleInputEvent(const InputEvent *event)
|
||||
}
|
||||
|
||||
if (event->inputEvent == INPUT_BROKER_DOWN) {
|
||||
if (messageStore.getMessages().empty()) {
|
||||
if (!messageStore.hasVisibleMessages()) {
|
||||
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST);
|
||||
} else {
|
||||
graphics::MessageRenderer::scrollDown();
|
||||
@@ -2169,9 +2135,9 @@ int Screen::handleInputEvent(const InputEvent *event)
|
||||
if (!inputIntercepted) {
|
||||
#if defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2
|
||||
bool handledEncoderScroll = false;
|
||||
const bool isTextMessageFrame = (framesetInfo.positions.textMessage != 255 &&
|
||||
this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage &&
|
||||
!messageStore.getMessages().empty());
|
||||
const bool isTextMessageFrame =
|
||||
(framesetInfo.positions.textMessage != 255 &&
|
||||
this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage && messageStore.hasVisibleMessages());
|
||||
if (isTextMessageFrame) {
|
||||
if (event->inputEvent == INPUT_BROKER_UP_LONG) {
|
||||
graphics::MessageRenderer::nudgeScroll(-1);
|
||||
@@ -2254,7 +2220,7 @@ int Screen::handleInputEvent(const InputEvent *event)
|
||||
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.lora) {
|
||||
menuHandler::loraMenu();
|
||||
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) {
|
||||
if (!messageStore.getMessages().empty()) {
|
||||
if (messageStore.hasVisibleMessages()) {
|
||||
menuHandler::messageResponseMenu();
|
||||
} else {
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "mesh/generated/meshtastic/config.pb.h"
|
||||
#include <OLEDDisplay.h>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -310,8 +311,6 @@ class Screen : public concurrency::OSThread
|
||||
*/
|
||||
void doDeepSleep();
|
||||
|
||||
void blink();
|
||||
|
||||
// Draw north
|
||||
float estimatedHeading(double lat, double lon);
|
||||
|
||||
@@ -844,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
|
||||
|
||||
@@ -718,11 +718,6 @@ void VirtualKeyboard::setInputText(const std::string &text)
|
||||
inputText = text;
|
||||
}
|
||||
|
||||
std::string VirtualKeyboard::getInputText() const
|
||||
{
|
||||
return inputText;
|
||||
}
|
||||
|
||||
void VirtualKeyboard::setHeader(const std::string &header)
|
||||
{
|
||||
headerText = header;
|
||||
|
||||
@@ -27,7 +27,6 @@ class VirtualKeyboard
|
||||
|
||||
void draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY);
|
||||
void setInputText(const std::string &text);
|
||||
std::string getInputText() const;
|
||||
void setHeader(const std::string &header);
|
||||
void setCallback(std::function<void(const std::string &)> callback);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -230,131 +230,7 @@ void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, i
|
||||
#endif
|
||||
}
|
||||
|
||||
void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
display->setFont(FONT_SMALL);
|
||||
|
||||
// The coordinates define the left starting point of the text
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
|
||||
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) {
|
||||
display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL);
|
||||
display->setColor(BLACK);
|
||||
}
|
||||
|
||||
char batStr[20];
|
||||
if (powerStatus->getHasBattery()) {
|
||||
int batV = powerStatus->getBatteryVoltageMv() / 1000;
|
||||
int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10;
|
||||
|
||||
snprintf(batStr, sizeof(batStr), "B %01d.%02dV %3d%% %c%c", batV, batCv, powerStatus->getBatteryChargePercent(),
|
||||
powerStatus->getIsCharging() ? '+' : ' ', powerStatus->getHasUSB() ? 'U' : ' ');
|
||||
|
||||
// Line 1
|
||||
display->drawString(x, y, batStr);
|
||||
if (config.display.heading_bold)
|
||||
display->drawString(x + 1, y, batStr);
|
||||
} else {
|
||||
// Line 1
|
||||
display->drawString(x, y, "USB");
|
||||
if (config.display.heading_bold)
|
||||
display->drawString(x + 1, y, "USB");
|
||||
}
|
||||
|
||||
uint32_t currentMillis = millis();
|
||||
uint32_t seconds = currentMillis / 1000;
|
||||
uint32_t minutes = seconds / 60;
|
||||
uint32_t hours = minutes / 60;
|
||||
uint32_t days = hours / 24;
|
||||
// currentMillis %= 1000;
|
||||
// seconds %= 60;
|
||||
// minutes %= 60;
|
||||
// hours %= 24;
|
||||
|
||||
// Show uptime as days, hours, minutes OR seconds
|
||||
std::string uptime = UIRenderer::drawTimeDelta(days, hours, minutes, seconds);
|
||||
|
||||
// Line 1 (Still)
|
||||
if (currentResolution != graphics::ScreenResolution::UltraLow) {
|
||||
display->drawString(x + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str());
|
||||
if (config.display.heading_bold)
|
||||
display->drawString(x - 1 + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str());
|
||||
|
||||
display->setColor(WHITE);
|
||||
}
|
||||
// Setup string to assemble analogClock string
|
||||
std::string analogClock = "";
|
||||
|
||||
uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
|
||||
if (rtc_sec > 0) {
|
||||
long hms = rtc_sec % SEC_PER_DAY;
|
||||
// hms += tz.tz_dsttime * SEC_PER_HOUR;
|
||||
// hms -= tz.tz_minuteswest * SEC_PER_MIN;
|
||||
// mod `hms` to ensure in positive range of [0...SEC_PER_DAY)
|
||||
hms = (hms + SEC_PER_DAY) % SEC_PER_DAY;
|
||||
|
||||
// Tear apart hms into h:m:s
|
||||
int hour, min, sec;
|
||||
graphics::decomposeTime(rtc_sec, hour, min, sec);
|
||||
|
||||
char timebuf[12];
|
||||
|
||||
if (config.display.use_12h_clock) {
|
||||
std::string meridiem = "am";
|
||||
if (hour >= 12) {
|
||||
if (hour > 12)
|
||||
hour -= 12;
|
||||
meridiem = "pm";
|
||||
}
|
||||
if (hour == 00) {
|
||||
hour = 12;
|
||||
}
|
||||
snprintf(timebuf, sizeof(timebuf), "%d:%02d:%02d%s", hour, min, sec, meridiem.c_str());
|
||||
} else {
|
||||
snprintf(timebuf, sizeof(timebuf), "%02d:%02d:%02d", hour, min, sec);
|
||||
}
|
||||
analogClock += timebuf;
|
||||
}
|
||||
|
||||
// Line 2
|
||||
display->drawString(x, y + FONT_HEIGHT_SMALL * 1, analogClock.c_str());
|
||||
|
||||
// Display Channel Utilization
|
||||
char chUtil[13];
|
||||
snprintf(chUtil, sizeof(chUtil), "ChUtil %2.0f%%", airTime->channelUtilizationPercent());
|
||||
display->drawString(x + SCREEN_WIDTH - display->getStringWidth(chUtil), y + FONT_HEIGHT_SMALL * 1, chUtil);
|
||||
|
||||
#if HAS_GPS
|
||||
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
||||
// Line 3
|
||||
if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_DMS) // if DMS then don't draw altitude
|
||||
UIRenderer::drawGpsAltitude(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus);
|
||||
|
||||
// Line 4
|
||||
UIRenderer::drawGpsCoordinates(display, x, y + FONT_HEIGHT_SMALL * 3, gpsStatus);
|
||||
} else {
|
||||
UIRenderer::drawGpsPowerStatus(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus);
|
||||
}
|
||||
#endif
|
||||
/* Display a heartbeat pixel that blinks every time the frame is redrawn */
|
||||
#ifdef SHOW_REDRAWS
|
||||
if (heartbeat)
|
||||
display->setPixel(0, 0);
|
||||
heartbeat = !heartbeat;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Trampoline functions for DebugInfo class access
|
||||
void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
drawFrame(display, state, x, y);
|
||||
}
|
||||
|
||||
void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
drawFrameSettings(display, state, x, y);
|
||||
}
|
||||
|
||||
void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
drawFrameWiFi(display, state, x, y);
|
||||
|
||||
@@ -20,12 +20,9 @@ namespace DebugRenderer
|
||||
{
|
||||
// Debug frame functions
|
||||
void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
// Trampoline functions for framework callback compatibility
|
||||
void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
// LoRa information display
|
||||
|
||||
@@ -70,12 +70,15 @@ const StoredMessage *getNewestMessageForActiveThread()
|
||||
const uint32_t peer = graphics::MessageRenderer::getThreadPeer();
|
||||
const uint32_t localNode = nodeDB->getNodeNum();
|
||||
|
||||
if (mode == graphics::MessageRenderer::ThreadMode::ALL) {
|
||||
return &messages.back();
|
||||
}
|
||||
|
||||
for (auto it = messages.rbegin(); it != messages.rend(); ++it) {
|
||||
const StoredMessage &m = *it;
|
||||
if (!messageStore.isMessageVisible(m)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode == graphics::MessageRenderer::ThreadMode::ALL) {
|
||||
return &m;
|
||||
}
|
||||
|
||||
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
||||
if (m.type == MessageType::BROADCAST && static_cast<int>(m.channelIndex) == channel) {
|
||||
@@ -282,21 +285,19 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: without a reboot, reconfigure() applies the region directly, so reject
|
||||
// regions this node can't use up front: unrecognized codes, licensed-only regions,
|
||||
// and radio hardware mismatches (2.4 GHz vs sub-GHz) - the same checks the admin
|
||||
// set-config path applies, but side-effect-free: ignoring a menu selection should
|
||||
// not record a critical error or notify clients. getRadio() used to catch hardware
|
||||
// mismatches post-reboot only.
|
||||
const RegionInfo *selectedRegionInfo = getRegion(selectedRegion);
|
||||
bool hamMode = selectedRegionInfo->code == selectedRegion && selectedRegionInfo->profile &&
|
||||
selectedRegionInfo->profile->licensedOnly;
|
||||
|
||||
// Validate radio compatibility for a prospective Ham region before confirmation.
|
||||
auto candidateLora = config.lora;
|
||||
candidateLora.region = selectedRegion;
|
||||
char regionErr[160];
|
||||
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr))) {
|
||||
char regionErr[160] = {};
|
||||
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr), hamMode)) {
|
||||
LOG_WARN("Ignoring region selection: %s", regionErr);
|
||||
return;
|
||||
}
|
||||
|
||||
bool hamMode = getRegion(selectedRegion)->profile->licensedOnly;
|
||||
if (hamMode) {
|
||||
LOG_INFO("User chose an amateur radio mode region");
|
||||
pendingRegion = selectedRegion;
|
||||
|
||||
@@ -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;
|
||||
@@ -403,6 +404,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
// Filter messages based on thread mode
|
||||
std::deque<StoredMessage> filtered;
|
||||
for (const auto &m : messageStore.getLiveMessages()) {
|
||||
if (!messageStore.isMessageVisible(m))
|
||||
continue;
|
||||
bool include = false;
|
||||
switch (currentMode) {
|
||||
case ThreadMode::ALL:
|
||||
@@ -1071,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;
|
||||
@@ -1161,12 +1165,14 @@ 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);
|
||||
}
|
||||
|
||||
if (!suppressBanner) {
|
||||
screen->showSimpleBanner(banner, inThread ? 1000 : 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Always focus into the correct conversation thread when a message with real text arrives
|
||||
const char *msgText = MessageStore::getText(sm);
|
||||
|
||||
@@ -17,14 +17,8 @@
|
||||
#include "meshUtils.h"
|
||||
#include <algorithm>
|
||||
|
||||
// Forward declarations for functions defined in Screen.cpp
|
||||
namespace graphics
|
||||
{
|
||||
extern bool haveGlyphs(const char *str);
|
||||
} // namespace graphics
|
||||
|
||||
// Global screen instance
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
|
||||
#if defined(OLED_TINY)
|
||||
static uint32_t lastSwitchTime = 0;
|
||||
@@ -180,11 +174,6 @@ unsigned long getModeCycleIntervalMs()
|
||||
return 3000;
|
||||
}
|
||||
|
||||
int calculateMaxScroll(int totalEntries, int visibleRows)
|
||||
{
|
||||
return max(0, (totalEntries - 1) / (visibleRows * 2));
|
||||
}
|
||||
|
||||
void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd)
|
||||
{
|
||||
x = (currentResolution == ScreenResolution::High) ? x - 2 : (currentResolution == ScreenResolution::Low) ? x - 1 : x;
|
||||
@@ -910,29 +899,6 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
|
||||
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, headingRadian, lat, lon);
|
||||
}
|
||||
|
||||
/// Draw a series of fields in a column, wrapping to multiple columns if needed
|
||||
void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields)
|
||||
{
|
||||
// The coordinates define the left starting point of the text
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
|
||||
const char **f = fields;
|
||||
int xo = x, yo = y;
|
||||
while (*f) {
|
||||
display->drawString(xo, yo, *f);
|
||||
if ((display->getColor() == BLACK) && config.display.heading_bold)
|
||||
display->drawString(xo + 1, yo, *f);
|
||||
|
||||
display->setColor(WHITE);
|
||||
yo += FONT_HEIGHT_SMALL;
|
||||
if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) {
|
||||
xo += SCREEN_WIDTH / 2;
|
||||
yo = 0;
|
||||
}
|
||||
f++;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace NodeListRenderer
|
||||
} // namespace graphics
|
||||
#endif
|
||||
|
||||
@@ -58,7 +58,6 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
|
||||
const char *getCurrentModeTitle_Nodes(int screenWidth);
|
||||
const char *getCurrentModeTitle_Location(int screenWidth);
|
||||
std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth);
|
||||
void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields);
|
||||
|
||||
// Scrolling controls
|
||||
void scrollUp();
|
||||
|
||||
@@ -207,8 +207,6 @@ void NotificationRenderer::resetBanner()
|
||||
alertBannerMessage[0] = '\0';
|
||||
current_notification_type = notificationTypeEnum::none;
|
||||
|
||||
OnScreenKeyboardModule::instance().clearPopup();
|
||||
|
||||
inEvent.inputEvent = INPUT_BROKER_NONE;
|
||||
inEvent.kbchar = 0;
|
||||
curSelected = 0;
|
||||
@@ -1187,9 +1185,6 @@ void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiStat
|
||||
display->setColor(WHITE);
|
||||
// Draw the virtual keyboard
|
||||
virtualKeyboard->draw(display, 0, 0);
|
||||
|
||||
// Draw transient popup overlay (if any) managed by OnScreenKeyboardModule
|
||||
OnScreenKeyboardModule::instance().drawPopupOverlay(display);
|
||||
} else {
|
||||
// If virtualKeyboard is null, reset the banner to avoid getting stuck
|
||||
LOG_INFO("Virtual keyboard is null - resetting banner");
|
||||
@@ -1202,12 +1197,5 @@ bool NotificationRenderer::isOverlayBannerShowing()
|
||||
return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil);
|
||||
}
|
||||
|
||||
void NotificationRenderer::showKeyboardMessagePopupWithTitle(const char *title, const char *content, uint32_t durationMs)
|
||||
{
|
||||
if (!title || !content || current_notification_type != notificationTypeEnum::text_input)
|
||||
return;
|
||||
OnScreenKeyboardModule::instance().showPopup(title, content, durationMs);
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
#endif
|
||||
|
||||
@@ -39,7 +39,6 @@ class NotificationRenderer
|
||||
static BannerFont alertBannerLineFonts[MAX_LINES + 1];
|
||||
static void parseBannerMessageWithFonts(const char *message);
|
||||
static void resetBanner();
|
||||
static void showKeyboardMessagePopupWithTitle(const char *title, const char *content, uint32_t durationMs);
|
||||
static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -1386,30 +1386,6 @@ int UIRenderer::formatDateTime(char *buf, size_t bufSize, uint32_t rtc_sec, OLED
|
||||
return display->getStringWidth(buf);
|
||||
}
|
||||
|
||||
// Check if the display can render a string (detect special chars; emoji)
|
||||
bool UIRenderer::haveGlyphs(const char *str)
|
||||
{
|
||||
#if defined(OLED_PL) || defined(OLED_UA) || defined(OLED_RU) || defined(OLED_CS)
|
||||
// Don't want to make any assumptions about custom language support
|
||||
return true;
|
||||
#endif
|
||||
|
||||
// Check each character with the lookup function for the OLED library
|
||||
// We're not really meant to use this directly..
|
||||
bool have = true;
|
||||
for (uint16_t i = 0; i < strlen(str); i++) {
|
||||
uint8_t result = Screen::customFontTableLookup((uint8_t)str[i]);
|
||||
// If font doesn't support a character, it is substituted for ¿
|
||||
if (result == 191 && (uint8_t)str[i] != 191) {
|
||||
have = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// LOG_DEBUG("haveGlyphs=%d", have);
|
||||
return have;
|
||||
}
|
||||
|
||||
#ifdef USE_EINK
|
||||
/// Used on eink displays while in deep sleep
|
||||
void UIRenderer::drawDeepSleepFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
|
||||
@@ -104,9 +104,6 @@ class UIRenderer
|
||||
{
|
||||
drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSpacing, fauxBold);
|
||||
}
|
||||
|
||||
// Check if the display can render a string (detect special chars; emoji)
|
||||
static bool haveGlyphs(const char *str);
|
||||
}; // namespace UIRenderer
|
||||
|
||||
} // namespace graphics
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -649,30 +649,6 @@ std::string InkHUD::Applet::getTimeString()
|
||||
return getTimeString(getValidTime(RTCQuality::RTCQualityDevice, true));
|
||||
}
|
||||
|
||||
// Calculate how many nodes have been seen within our preferred window of activity
|
||||
// This period is set by user, via the menu
|
||||
// Todo: optimize to calculate once only per WindowManager::render
|
||||
uint16_t InkHUD::Applet::getActiveNodeCount()
|
||||
{
|
||||
// Don't even try to count nodes if RTC isn't set
|
||||
// The last heard values in nodedb will be incomprehensible
|
||||
if (getRTCQuality() == RTCQualityNone)
|
||||
return 0;
|
||||
|
||||
uint16_t count = 0;
|
||||
|
||||
// For each node in db
|
||||
for (uint16_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
|
||||
|
||||
// Check if heard recently, and not our own node
|
||||
if (sinceLastSeen(node) < settings->recentlyActiveSeconds && node->num != nodeDB->getNodeNum())
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// Get an abbreviated, human readable, distance string
|
||||
// Honors config.display.units, to offer both metric and imperial
|
||||
std::string InkHUD::Applet::localizeDistance(uint32_t meters)
|
||||
|
||||
@@ -183,7 +183,6 @@ class Applet : public GFX
|
||||
SignalStrength getSignalStrength(float snr, float rssi); // Interpret SNR and RSSI, as an easy to understand value
|
||||
std::string getTimeString(uint32_t epochSeconds); // Human readable
|
||||
std::string getTimeString(); // Current time, human readable
|
||||
uint16_t getActiveNodeCount(); // Duration determined by user, in onscreen menu
|
||||
std::string localizeDistance(uint32_t meters); // Human readable distance, imperial or metric
|
||||
std::string parse(const std::string &text); // Handle text which might contain special chars
|
||||
std::string parseShortName(meshtastic_NodeInfoLite *node); // Get the shortname, or a substitute if has unprintable chars
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -234,7 +234,8 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
|
||||
// Pick source of message
|
||||
const StoredMessage *message =
|
||||
msgIsBroadcast ? &inkhud->persistence->latestMessage.broadcast : &inkhud->persistence->latestMessage.dm;
|
||||
|
||||
if (!message->sender || !messageStore.isMessageVisible(*message))
|
||||
return parse(text);
|
||||
// Find info about the sender
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(message->sender);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ void InkHUD::AllMessageApplet::onRender(bool full)
|
||||
message = &latestMessage->dm;
|
||||
|
||||
// Short circuit: no text message
|
||||
if (!message->sender) {
|
||||
if (!message->sender || !messageStore.isMessageVisible(*message)) {
|
||||
printAt(X(0.5), Y(0.5), "No Message", CENTER, MIDDLE);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ int InkHUD::DMApplet::onReceiveTextMessage(const meshtastic_MeshPacket *p)
|
||||
void InkHUD::DMApplet::onRender(bool full)
|
||||
{
|
||||
// Abort if no text message
|
||||
if (!latestMessage->dm.sender) {
|
||||
if (!latestMessage->dm.sender || !messageStore.isMessageVisible(latestMessage->dm)) {
|
||||
printAt(X(0.5), Y(0.5), "No DMs", CENTER, MIDDLE);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,7 +191,8 @@ ProcessMessage InkHUD::ThreadedMessageApplet::handleReceived(const meshtastic_Me
|
||||
return ProcessMessage::CONTINUE;
|
||||
|
||||
// Store in the global messageStore - this handles sender, timestamp, channel, text, and ack status
|
||||
messageStore.addFromPacket(mp);
|
||||
if (!messageStore.tryAddFromPacket(mp))
|
||||
return ProcessMessage::CONTINUE;
|
||||
|
||||
// If this was an incoming message, suggest that our applet becomes foreground, if permitted
|
||||
if (getFrom(&mp) != nodeDB->getNodeNum())
|
||||
@@ -216,13 +217,6 @@ bool InkHUD::ThreadedMessageApplet::approveNotification(Notification &n)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save messages to flash via the global messageStore.
|
||||
// The global store holds messages for all channels; no per-channel file is needed.
|
||||
void InkHUD::ThreadedMessageApplet::saveMessagesToFlash()
|
||||
{
|
||||
messageStore.saveToFlash();
|
||||
}
|
||||
|
||||
// Messages are loaded once by InkHUD::begin() before applets start.
|
||||
// Nothing to do here at per-applet activation time.
|
||||
void InkHUD::ThreadedMessageApplet::loadMessagesFromFlash()
|
||||
|
||||
@@ -46,7 +46,6 @@ class ThreadedMessageApplet : public Applet, public SinglePortModule
|
||||
bool approveNotification(Notification &n) override; // Which notifications to suppress
|
||||
|
||||
protected:
|
||||
void saveMessagesToFlash();
|
||||
void loadMessagesFromFlash();
|
||||
|
||||
uint8_t channelIndex = 0;
|
||||
|
||||
@@ -534,13 +534,19 @@ int InkHUD::Events::onReceiveTextMessage(const meshtastic_MeshPacket *packet)
|
||||
if (getFrom(packet) == nodeDB->getNodeNum())
|
||||
return 0;
|
||||
|
||||
if (!messageStore.shouldStorePacket(*packet))
|
||||
return 0;
|
||||
|
||||
bool isBroadcastMsg = isBroadcast(packet->to);
|
||||
inkhud->persistence->latestMessage.wasBroadcast = isBroadcastMsg;
|
||||
|
||||
if (!isBroadcastMsg) {
|
||||
// DMs never pass through ThreadedMessageApplet, so add them to the global store here
|
||||
// so they survive reboots. Derive the latestMessage cache entry from the stored result.
|
||||
inkhud->persistence->latestMessage.dm = messageStore.addFromPacket(*packet);
|
||||
const StoredMessage *stored = messageStore.tryAddFromPacket(*packet);
|
||||
if (!stored)
|
||||
return 0;
|
||||
inkhud->persistence->latestMessage.dm = *stored;
|
||||
} else {
|
||||
// Broadcasts are added to the global store by ThreadedMessageApplet::handleReceived().
|
||||
// Here we only update the latestMessage cache used by AllMessageApplet / NotificationApplet.
|
||||
|
||||
@@ -326,44 +326,6 @@ void InkHUD::InkHUD::touchNavDown()
|
||||
}
|
||||
}
|
||||
|
||||
// Call this when touch input needs joystick-like left navigation independent of joystick-enabled mode
|
||||
void InkHUD::InkHUD::touchNavLeft()
|
||||
{
|
||||
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
|
||||
case 1: // 90 deg
|
||||
events->onTouchNavDown();
|
||||
break;
|
||||
case 2: // 180 deg
|
||||
events->onTouchNavRight();
|
||||
break;
|
||||
case 3: // 270 deg
|
||||
events->onTouchNavUp();
|
||||
break;
|
||||
default: // 0 deg
|
||||
events->onTouchNavLeft();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Call this when touch input needs joystick-like right navigation independent of joystick-enabled mode
|
||||
void InkHUD::InkHUD::touchNavRight()
|
||||
{
|
||||
switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
|
||||
case 1: // 90 deg
|
||||
events->onTouchNavUp();
|
||||
break;
|
||||
case 2: // 180 deg
|
||||
events->onTouchNavLeft();
|
||||
break;
|
||||
case 3: // 270 deg
|
||||
events->onTouchNavDown();
|
||||
break;
|
||||
default: // 0 deg
|
||||
events->onTouchNavRight();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void InkHUD::InkHUD::touchTap(uint16_t x, uint16_t y)
|
||||
{
|
||||
events->onTouchTap(x, y, false);
|
||||
|
||||
@@ -71,8 +71,6 @@ class InkHUD
|
||||
void navRight();
|
||||
void touchNavUp();
|
||||
void touchNavDown();
|
||||
void touchNavLeft();
|
||||
void touchNavRight();
|
||||
void touchTap(uint16_t x, uint16_t y);
|
||||
void touchLongPress(uint16_t x, uint16_t y);
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ void InkHUD::Persistence::loadLatestMessage()
|
||||
|
||||
int lastBroadcastPos = -1, lastDMPos = -1, pos = 0;
|
||||
for (const StoredMessage &m : messageStore.getLiveMessages()) {
|
||||
if (!messageStore.isMessageVisible(m)) {
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
if (m.type == MessageType::BROADCAST) {
|
||||
latestMessage.broadcast = m;
|
||||
lastBroadcastPos = pos;
|
||||
|
||||
@@ -496,8 +496,10 @@ We keep this separate latest-message cache for this purpose, because:
|
||||
|
||||
Broadcasts and DMs take different paths into `messageStore`:
|
||||
|
||||
- **Broadcasts** - `ThreadedMessageApplet::handleReceived()` calls `messageStore.addFromPacket()`. `Events::onReceiveTextMessage()` then updates `latestMessage.broadcast` separately for fast access by `AllMessageApplet` and `NotificationApplet`.
|
||||
- **DMs** - `ThreadedMessageApplet` skips DMs entirely. `Events::onReceiveTextMessage()` calls `messageStore.addFromPacket()` directly and stores the result in `latestMessage.dm`.
|
||||
- **Broadcasts** - `ThreadedMessageApplet::handleReceived()` calls `messageStore.tryAddFromPacket()`. `Events::onReceiveTextMessage()` then updates `latestMessage.broadcast` separately for fast access by `AllMessageApplet` and `NotificationApplet`.
|
||||
- **DMs** - `ThreadedMessageApplet` skips DMs entirely. `Events::onReceiveTextMessage()` calls `messageStore.tryAddFromPacket()` directly and stores the result in `latestMessage.dm`.
|
||||
|
||||
`tryAddFromPacket()` returns `nullptr` when the packet is filtered out (see `shouldStorePacket()`), so both paths must null-check before use.
|
||||
|
||||
#### Saving / Loading
|
||||
|
||||
|
||||
@@ -117,14 +117,6 @@ void TwoButton::setHandlerDown(uint8_t whichButton, Callback onDown)
|
||||
buttons[whichButton].onDown = onDown;
|
||||
}
|
||||
|
||||
// Set what should happen when a button becomes unpressed
|
||||
// Use this to implement a "While held" behavior
|
||||
void TwoButton::setHandlerUp(uint8_t whichButton, Callback onUp)
|
||||
{
|
||||
assert(whichButton < 2);
|
||||
buttons[whichButton].onUp = onUp;
|
||||
}
|
||||
|
||||
// Set what should happen when a "short press" event has occurred
|
||||
void TwoButton::setHandlerShortPress(uint8_t whichButton, Callback onShortPress)
|
||||
{
|
||||
|
||||
@@ -38,7 +38,6 @@ class TwoButton : protected concurrency::OSThread
|
||||
void setWiring(uint8_t whichButton, uint8_t pin, bool internalPullup = false);
|
||||
void setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs);
|
||||
void setHandlerDown(uint8_t whichButton, Callback onDown);
|
||||
void setHandlerUp(uint8_t whichButton, Callback onUp);
|
||||
void setHandlerShortPress(uint8_t whichButton, Callback onShortPress);
|
||||
void setHandlerLongPress(uint8_t whichButton, Callback onLongPress);
|
||||
|
||||
|
||||
@@ -194,14 +194,6 @@ void TwoButtonExtended::setHandlerDown(uint8_t whichButton, Callback onDown)
|
||||
buttons[whichButton].onDown = onDown;
|
||||
}
|
||||
|
||||
// Set what should happen when a button becomes unpressed
|
||||
// Use this to implement a "While held" behavior
|
||||
void TwoButtonExtended::setHandlerUp(uint8_t whichButton, Callback onUp)
|
||||
{
|
||||
assert(whichButton < 2);
|
||||
buttons[whichButton].onUp = onUp;
|
||||
}
|
||||
|
||||
// Set what should happen when a "short press" event has occurred
|
||||
void TwoButtonExtended::setHandlerShortPress(uint8_t whichButton, Callback onPress)
|
||||
{
|
||||
@@ -217,26 +209,6 @@ void TwoButtonExtended::setHandlerLongPress(uint8_t whichButton, Callback onLong
|
||||
buttons[whichButton].onLongPress = onLongPress;
|
||||
}
|
||||
|
||||
// Set what should happen when a joystick button becomes pressed
|
||||
// Use this to implement a "while held" behavior
|
||||
void TwoButtonExtended::setJoystickDownHandlers(Callback uDown, Callback dDown, Callback lDown, Callback rDown)
|
||||
{
|
||||
joystick[Direction::UP].onDown = uDown;
|
||||
joystick[Direction::DOWN].onDown = dDown;
|
||||
joystick[Direction::LEFT].onDown = lDown;
|
||||
joystick[Direction::RIGHT].onDown = rDown;
|
||||
}
|
||||
|
||||
// Set what should happen when a joystick button becomes unpressed
|
||||
// Use this to implement a "while held" behavior
|
||||
void TwoButtonExtended::setJoystickUpHandlers(Callback uUp, Callback dUp, Callback lUp, Callback rUp)
|
||||
{
|
||||
joystick[Direction::UP].onUp = uUp;
|
||||
joystick[Direction::DOWN].onUp = dUp;
|
||||
joystick[Direction::LEFT].onUp = lUp;
|
||||
joystick[Direction::RIGHT].onUp = rUp;
|
||||
}
|
||||
|
||||
// Set what should happen when a "press" event has fired
|
||||
// Note: this will occur while the joystick button is still held
|
||||
void TwoButtonExtended::setJoystickPressHandlers(Callback uPress, Callback dPress, Callback lPress, Callback rPress)
|
||||
|
||||
@@ -49,11 +49,8 @@ class TwoButtonExtended : protected concurrency::OSThread
|
||||
void setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs);
|
||||
void setJoystickDebounce(uint32_t debounceMs);
|
||||
void setHandlerDown(uint8_t whichButton, Callback onDown);
|
||||
void setHandlerUp(uint8_t whichButton, Callback onUp);
|
||||
void setHandlerShortPress(uint8_t whichButton, Callback onShortPress);
|
||||
void setHandlerLongPress(uint8_t whichButton, Callback onLongPress);
|
||||
void setJoystickDownHandlers(Callback uDown, Callback dDown, Callback ldown, Callback rDown);
|
||||
void setJoystickUpHandlers(Callback uUp, Callback dUp, Callback lUp, Callback rUp);
|
||||
void setJoystickPressHandlers(Callback uPress, Callback dPress, Callback lPress, Callback rPress);
|
||||
void setTwoWayRockerPressHandlers(Callback lPress, Callback rPress);
|
||||
|
||||
|
||||
+32
-5
@@ -212,7 +212,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();
|
||||
@@ -428,10 +428,14 @@ void setup()
|
||||
#if ARCH_PORTDUINO
|
||||
RTCQuality ourQuality = RTCQualityDevice;
|
||||
|
||||
#ifdef __linux__
|
||||
// timedatectl is systemd-only, so macOS, Windows and WASM stay at
|
||||
// RTCQualityDevice rather than claim NTP quality we have not verified.
|
||||
std::string timeCommandResult = exec("timedatectl status | grep synchronized | grep yes -c");
|
||||
if (timeCommandResult[0] == '1') {
|
||||
ourQuality = RTCQualityNTP;
|
||||
}
|
||||
#endif
|
||||
|
||||
struct timeval tv;
|
||||
tv.tv_sec = time(NULL);
|
||||
@@ -962,15 +966,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
|
||||
@@ -1074,6 +1078,7 @@ void setup()
|
||||
nodeDB->hasWarned = true;
|
||||
}
|
||||
#endif
|
||||
nodeDB->notifyPendingLicensedIdentityMigration();
|
||||
#if !MESHTASTIC_EXCLUDE_INPUTBROKER
|
||||
if (inputBroker)
|
||||
inputBroker->Init();
|
||||
@@ -1107,6 +1112,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)
|
||||
@@ -1218,7 +1240,7 @@ bool runASAP;
|
||||
// TODO find better home than main.cpp
|
||||
extern meshtastic_DeviceMetadata getDeviceMetadata()
|
||||
{
|
||||
meshtastic_DeviceMetadata deviceMetadata;
|
||||
meshtastic_DeviceMetadata deviceMetadata = meshtastic_DeviceMetadata_init_default;
|
||||
strncpy(deviceMetadata.firmware_version, optstr(APP_VERSION), sizeof(deviceMetadata.firmware_version));
|
||||
deviceMetadata.device_state_version = DEVICESTATE_CUR_VER;
|
||||
deviceMetadata.canShutdown = pmu_found || HAS_CPU_SHUTDOWN;
|
||||
@@ -1258,6 +1280,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
|
||||
@@ -1274,6 +1298,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;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
*/
|
||||
#include "memGet.h"
|
||||
#include "configuration.h"
|
||||
#include "memory/MemAudit.h"
|
||||
|
||||
#if defined(MESHTASTIC_DYNAMIC_SBRK_HEAP)
|
||||
#include <malloc.h>
|
||||
@@ -108,16 +107,3 @@ uint32_t MemGet::getPsramSize()
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
void displayPercentHeapFree()
|
||||
{
|
||||
uint32_t freeHeap = memGet.getFreeHeap();
|
||||
uint32_t totalHeap = memGet.getHeapSize();
|
||||
if (totalHeap == 0 || totalHeap == UINT32_MAX) {
|
||||
LOG_INFO("Heap size unavailable");
|
||||
return;
|
||||
}
|
||||
int percent = (int)((freeHeap * 100) / totalHeap);
|
||||
LOG_INFO("Heap free: %d%% (%u/%u bytes)", percent, freeHeap, totalHeap);
|
||||
memaudit::logBreakdown("heap"); // per-subsystem breakdown rides along with the periodic heap log
|
||||
}
|
||||
@@ -128,11 +128,13 @@ bool Channels::ensureLicensedOperation()
|
||||
}
|
||||
auto &channelSettings = channel.settings;
|
||||
if (strcasecmp(channelSettings.name, Channels::adminChannel) == 0) {
|
||||
if (channel.role != meshtastic_Channel_Role_DISABLED || channelSettings.psk.size > 0) {
|
||||
channel.role = meshtastic_Channel_Role_DISABLED;
|
||||
channelSettings.psk.bytes[0] = 0;
|
||||
channelSettings.psk.size = 0;
|
||||
hasEncryptionOrAdmin = true;
|
||||
channels.setChannel(channel);
|
||||
}
|
||||
|
||||
} else if (channelSettings.psk.size > 0) {
|
||||
channelSettings.psk.bytes[0] = 0;
|
||||
|
||||
+17
-18
@@ -51,7 +51,7 @@ 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);
|
||||
if (reprocessPacket(p))
|
||||
perhapsRebroadcast(p);
|
||||
}
|
||||
} else {
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,12 @@ extern Adafruit_nRFCrypto nRFCrypto;
|
||||
#include <unistd.h>
|
||||
#ifdef __linux__
|
||||
#include <sys/random.h> // getrandom()
|
||||
#elif defined(_WIN32)
|
||||
// Order is load-bearing, hence the blank line: bcrypt.h uses LONG/ULONG from
|
||||
// windows.h and does not include it itself.
|
||||
#include <windows.h>
|
||||
|
||||
#include <bcrypt.h> // BCryptGenRandom()
|
||||
#else
|
||||
#include <stdlib.h> // arc4random_buf() on Darwin/BSD
|
||||
#endif
|
||||
@@ -128,6 +134,12 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy)
|
||||
if (generated == static_cast<ssize_t>(length)) {
|
||||
filled = true;
|
||||
}
|
||||
#elif defined(_WIN32)
|
||||
// No getrandom/arc4random on Windows; BCryptGenRandom is the documented CSPRNG.
|
||||
// Preferred over std::random_device, whose libstdc++ Windows backend reports entropy() == 0.
|
||||
if (BCryptGenRandom(NULL, buffer, static_cast<ULONG>(length), BCRYPT_USE_SYSTEM_PREFERRED_RNG) == 0) { // STATUS_SUCCESS
|
||||
filled = true;
|
||||
}
|
||||
#elif defined(__EMSCRIPTEN__)
|
||||
// Browser/wasm: no getrandom/arc4random - fall through to std::random_device,
|
||||
// which emscripten backs with crypto.getRandomValues().
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+15
-2
@@ -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 = ∓
|
||||
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) {
|
||||
if (replyPortMatches(pi.ourPortNum, mp)) {
|
||||
pi.sendResponse(mp);
|
||||
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' 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
|
||||
} else {
|
||||
LOG_DEBUG("Module '%s' considered", pi.name);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
// 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
|
||||
|
||||
configChanged.notifyObservers(NULL); // This will cause radio hardware to change freqs etc
|
||||
}
|
||||
nodeDB->saveToDisk(saveWhat);
|
||||
}
|
||||
|
||||
@@ -257,8 +262,8 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p)
|
||||
p.to != NODENUM_BROADCAST && p.to != 0) // DM only
|
||||
{
|
||||
perhapsDecode(&p);
|
||||
const StoredMessage &sm = messageStore.addFromPacket(p);
|
||||
graphics::MessageRenderer::handleNewMessage(nullptr, sm, p); // notify UI
|
||||
if (const StoredMessage *sm = messageStore.tryAddFromPacket(p))
|
||||
graphics::MessageRenderer::handleNewMessage(nullptr, *sm, p); // notify UI
|
||||
})
|
||||
#if !MESHTASTIC_EXCLUDE_ADMIN
|
||||
// Note admin requests on their way out: AdminModule only accepts a response from a remote we
|
||||
@@ -319,13 +324,20 @@ void MeshService::sendToMesh(meshtastic_MeshPacket *p, RxSource src, bool ccToPh
|
||||
uint32_t mesh_packet_id = p->id;
|
||||
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");
|
||||
}
|
||||
|
||||
+48
-18
@@ -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,7 +84,7 @@ 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);
|
||||
if (reprocessPacket(p))
|
||||
perhapsRebroadcast(p);
|
||||
}
|
||||
} else {
|
||||
@@ -73,8 +92,7 @@ bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
|
||||
// If repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again
|
||||
if (isRepeated) {
|
||||
if (!findInTxQueue(p->from, p->id)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -159,6 +177,9 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
|
||||
}
|
||||
#endif
|
||||
|
||||
if (p->to == NODENUM_BROADCAST_NO_LORA)
|
||||
return false;
|
||||
|
||||
// Allow rebroadcast if hop_limit > 0 OR if we're exhausting hops (which sets hop_limit = 0 but still needs one relay)
|
||||
if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) {
|
||||
if (p->id != 0) {
|
||||
@@ -192,11 +213,10 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
|
||||
}
|
||||
#endif
|
||||
|
||||
if (p->next_hop == NO_NEXT_HOP_PREFERENCE) {
|
||||
FloodingRouter::send(tosend);
|
||||
} else {
|
||||
NextHopRouter::send(tosend);
|
||||
}
|
||||
ErrorCode res =
|
||||
(p->next_hop == NO_NEXT_HOP_PREFERENCE) ? FloodingRouter::send(tosend) : NextHopRouter::send(tosend);
|
||||
if (res == ERRNO_SHOULD_RELEASE)
|
||||
packetPool.release(tosend);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -401,8 +421,10 @@ int32_t NextHopRouter::doRetransmissions()
|
||||
trafficManagementModule->clearNextHop(p.packet->to);
|
||||
}
|
||||
#endif
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet))
|
||||
FloodingRouter::send(copy);
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet)) {
|
||||
if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
|
||||
packetPool.release(copy);
|
||||
}
|
||||
} else {
|
||||
#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
|
||||
// M4 (gated): if the route isn't proven healthy, don't spend a second directed
|
||||
@@ -416,22 +438,30 @@ int32_t NextHopRouter::doRetransmissions()
|
||||
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
|
||||
if (sentTo)
|
||||
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet))
|
||||
FloodingRouter::send(copy);
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet)) {
|
||||
if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
|
||||
packetPool.release(copy);
|
||||
}
|
||||
} else {
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet))
|
||||
NextHopRouter::send(copy);
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet)) {
|
||||
if (NextHopRouter::send(copy) == ERRNO_SHOULD_RELEASE)
|
||||
packetPool.release(copy);
|
||||
}
|
||||
}
|
||||
#else
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet))
|
||||
NextHopRouter::send(copy);
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet)) {
|
||||
if (NextHopRouter::send(copy) == ERRNO_SHOULD_RELEASE)
|
||||
packetPool.release(copy);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
// Note: we call the superclass version because we don't want to have our version of send() add a new
|
||||
// retransmission record
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet))
|
||||
FloodingRouter::send(copy);
|
||||
if (auto *copy = packetPool.allocCopy(*p.packet)) {
|
||||
if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
|
||||
packetPool.release(copy);
|
||||
}
|
||||
}
|
||||
|
||||
// Queue again
|
||||
|
||||
@@ -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
|
||||
@@ -205,7 +206,11 @@ class NextHopRouter : public FloodingRouter
|
||||
*/
|
||||
std::optional<uint8_t> getNextHop(NodeNum to, uint8_t relay_node);
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
public: // expose perhapsRebroadcast to the test shim
|
||||
#else
|
||||
private:
|
||||
#endif
|
||||
/** 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;
|
||||
|
||||
+349
-13
@@ -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>
|
||||
@@ -635,6 +638,7 @@ NodeDB::NodeDB()
|
||||
#endif
|
||||
sortMeshDB();
|
||||
saveToDisk(saveWhat);
|
||||
bootInitializationInProgress = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -767,6 +771,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();
|
||||
@@ -822,7 +832,7 @@ void NodeDB::installDefaultNodeDatabase()
|
||||
void NodeDB::installDefaultConfig(bool preserveKey = false)
|
||||
{
|
||||
uint8_t private_key_temp[32];
|
||||
bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size > 0;
|
||||
bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size == 32;
|
||||
if (shouldPreserveKey) {
|
||||
memcpy(private_key_temp, config.security.private_key.bytes, config.security.private_key.size);
|
||||
}
|
||||
@@ -948,6 +958,12 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
|
||||
|
||||
config.security.admin_key_count = numAdminKeys;
|
||||
|
||||
// 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 +1613,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 +1650,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 +1868,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);
|
||||
@@ -2165,6 +2194,41 @@ void NodeDB::loadFromDisk()
|
||||
|
||||
migrationSavePending = false;
|
||||
configDecodeFailed = false;
|
||||
configLoadComplete = false;
|
||||
|
||||
#if !USERPREFS_EVENT_MODE
|
||||
#ifdef FSCom
|
||||
const char *eventProfileFiles[] = {EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME};
|
||||
spiLock->lock();
|
||||
for (const char *filename : eventProfileFiles) {
|
||||
if (FSCom.exists(filename) && !FSCom.remove(filename))
|
||||
LOG_WARN("Unable to remove stale event profile file %s", filename);
|
||||
}
|
||||
spiLock->unlock();
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if USERPREFS_EVENT_MODE
|
||||
// Seed only a missing event config; never overwrite normal files after a corrupt event config.
|
||||
bool eventConfigMissing = false;
|
||||
eventProfileStorageUnavailable = false;
|
||||
#ifdef FSCom
|
||||
spiLock->lock();
|
||||
eventConfigMissing = !FSCom.exists(configFileName);
|
||||
if (eventConfigMissing) {
|
||||
const size_t totalBytes = fsTotalBytes();
|
||||
const size_t usedBytes = fsUsedBytes();
|
||||
eventProfileStorageUnavailable = !hasEventProfileStorageSpace(totalBytes, usedBytes);
|
||||
if (eventProfileStorageUnavailable) {
|
||||
LOG_ERROR("Event profile requires %u bytes free; only %u bytes available. Profile changes will not persist.",
|
||||
static_cast<unsigned>(EVENT_PROFILE_STORAGE_RESERVATION_BYTES),
|
||||
static_cast<unsigned>(totalBytes >= usedBytes ? totalBytes - usedBytes : 0));
|
||||
}
|
||||
}
|
||||
spiLock->unlock();
|
||||
#endif
|
||||
bool initializedEventConfig = false;
|
||||
#endif
|
||||
|
||||
meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero;
|
||||
|
||||
@@ -2354,6 +2418,31 @@ void NodeDB::loadFromDisk()
|
||||
|
||||
state = loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg,
|
||||
&config);
|
||||
#if USERPREFS_EVENT_MODE
|
||||
if (eventConfigMissing && state != LoadFileResult::LOAD_SUCCESS) {
|
||||
const LoadFileResult eventConfigState = state;
|
||||
const LoadFileResult standardConfigState =
|
||||
loadProto(STANDARD_CONFIG_FILE_NAME, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig),
|
||||
&meshtastic_LocalConfig_msg, &config);
|
||||
if (standardConfigState == LoadFileResult::LOAD_SUCCESS) {
|
||||
// Preserve the user's identity and non-radio preferences, then
|
||||
// replace only LoRa with this event build's compiled defaults.
|
||||
const meshtastic_LocalConfig standardConfig = config;
|
||||
installDefaultConfig(true);
|
||||
const meshtastic_Config_LoRaConfig eventLora = config.lora;
|
||||
config = standardConfig;
|
||||
config.has_lora = true;
|
||||
config.lora = eventLora;
|
||||
state = LoadFileResult::LOAD_SUCCESS;
|
||||
initializedEventConfig = true;
|
||||
LOG_INFO("Initialized event config without modifying %s", STANDARD_CONFIG_FILE_NAME);
|
||||
} else {
|
||||
// Keep the event load outcome because loadProto() clears config before decoding.
|
||||
// A normal decode failure must not create a replacement identity.
|
||||
state = standardConfigState == LoadFileResult::DECODE_FAILED ? LoadFileResult::DECODE_FAILED : eventConfigState;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (state == LoadFileResult::DECODE_FAILED) {
|
||||
// Config file present but undecodable this boot (corruption / torn write / transient decrypt fail).
|
||||
// loadProto() already zeroed `config`, so the keypair is gone from RAM; minting a new one would change
|
||||
@@ -2375,6 +2464,7 @@ void NodeDB::loadFromDisk()
|
||||
} else {
|
||||
LOG_INFO("Loaded saved config version %d", config.version);
|
||||
}
|
||||
configLoadComplete = true;
|
||||
|
||||
// Coerce LoRa config fields derived from presets while bootstrapping.
|
||||
// Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode.
|
||||
@@ -2415,6 +2505,15 @@ void NodeDB::loadFromDisk()
|
||||
config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY;
|
||||
#endif
|
||||
|
||||
#if USERPREFS_EVENT_MODE
|
||||
if (initializedEventConfig) {
|
||||
// This is the first durable event-profile write. A failed write is
|
||||
// safe: normal files remain untouched and the next event boot retries.
|
||||
if (!saveToDisk(SEGMENT_CONFIG))
|
||||
LOG_ERROR("Unable to persist initial event config");
|
||||
}
|
||||
#endif
|
||||
|
||||
if (backupSecurity.private_key.size > 0) {
|
||||
LOG_DEBUG("Restoring backup of security config");
|
||||
config.security = backupSecurity;
|
||||
@@ -2529,7 +2628,7 @@ void NodeDB::loadFromDisk()
|
||||
const int segments[] = {SEGMENT_CONFIG, SEGMENT_MODULECONFIG, SEGMENT_CHANNELS, SEGMENT_DEVICESTATE,
|
||||
SEGMENT_NODEDATABASE};
|
||||
int toSave = 0;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
for (size_t i = 0; i < sizeof(segments) / sizeof(segments[0]); i++) {
|
||||
if (!EncryptedStorage::isEncrypted(filesToCheck[i])) {
|
||||
toSave |= segments[i];
|
||||
}
|
||||
@@ -2546,6 +2645,43 @@ void NodeDB::loadFromDisk()
|
||||
EncryptedStorage::migrateFile(fn);
|
||||
}
|
||||
}
|
||||
|
||||
// Backups are outside saveToDisk(), but can contain radio profile PSKs. Only event builds
|
||||
// introduce a second backup file, so leave normal-firmware backup handling unchanged.
|
||||
#if USERPREFS_EVENT_MODE
|
||||
#ifdef FSCom
|
||||
spiLock->lock();
|
||||
const bool activeBackupExists = FSCom.exists(backupFileName);
|
||||
spiLock->unlock();
|
||||
if (activeBackupExists && !EncryptedStorage::isEncrypted(backupFileName)) {
|
||||
LOG_INFO("Migrating %s to encrypted storage", backupFileName);
|
||||
if (!EncryptedStorage::migrateFile(backupFileName)) {
|
||||
LOG_ERROR("Unable to migrate %s to encrypted storage", backupFileName);
|
||||
storageCorruptThisLoad = true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// Event firmware keeps the normal radio profile inactive, so migrate it separately.
|
||||
#if USERPREFS_EVENT_MODE
|
||||
#ifdef FSCom
|
||||
const char *inactiveRadioProfileFiles[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME,
|
||||
STANDARD_BACKUP_FILE_NAME};
|
||||
for (const char *fn : inactiveRadioProfileFiles) {
|
||||
spiLock->lock();
|
||||
const bool exists = FSCom.exists(fn);
|
||||
spiLock->unlock();
|
||||
if (exists && !EncryptedStorage::isEncrypted(fn)) {
|
||||
LOG_INFO("Migrating inactive radio profile %s to encrypted storage", fn);
|
||||
if (!EncryptedStorage::migrateFile(fn)) {
|
||||
LOG_ERROR("Unable to migrate %s to encrypted storage", fn);
|
||||
storageCorruptThisLoad = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -2586,6 +2722,11 @@ void NodeDB::loadFromDisk()
|
||||
moduleConfig.version = POSITION_TELEMETRY_OPTIN_VER;
|
||||
saveToDisk(SEGMENT_MODULECONFIG);
|
||||
}
|
||||
|
||||
if (channels.ensureLicensedOperation()) {
|
||||
LOG_WARN("Licensed operation removed persisted channel encryption/admin access");
|
||||
saveToDisk(SEGMENT_CHANNELS);
|
||||
}
|
||||
#if ARCH_PORTDUINO
|
||||
// set any config overrides
|
||||
if (portduino_config.has_configDisplayMode) {
|
||||
@@ -2680,8 +2821,9 @@ bool NodeDB::disableLockdownToPlaintext()
|
||||
// plaintext->encrypted migrate loop above. Order does not matter here;
|
||||
// EncryptedStorage::removeLockdownArtifacts() (which deletes the DEK,
|
||||
// the commit point) only runs after every file is confirmed plaintext.
|
||||
const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName,
|
||||
nodeDatabaseFileName};
|
||||
const char *filesToCheck[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME,
|
||||
EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME,
|
||||
moduleConfigFileName, deviceStateFileName, nodeDatabaseFileName};
|
||||
for (const char *fn : filesToCheck) {
|
||||
if (!EncryptedStorage::migrateFileToPlaintext(fn)) {
|
||||
LOG_ERROR("NodeDB: failed to revert %s to plaintext; aborting disable (device stays in lockdown)", fn);
|
||||
@@ -2701,6 +2843,15 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_
|
||||
bool fullAtomic)
|
||||
{
|
||||
|
||||
// Only the radio profile is at risk from an unverified config load, so only defer those writes.
|
||||
// Devicestate/nodedb/module writes must still land, otherwise boot-time recovery (e.g. loadFromDisk()
|
||||
// restoring owner fields) is dropped and never retried.
|
||||
if (isRadioProfileFile(filename) &&
|
||||
shouldDeferBootPersistence(bootInitializationInProgress, configLoadComplete, configDecodeFailed)) {
|
||||
LOG_WARN("NodeDB: deferred boot write to %s until config recovery completes", filename);
|
||||
return true;
|
||||
}
|
||||
|
||||
// do not try to save anything if power level is not safe. In many cases flash will be lock-protected
|
||||
// and all writes will fail anyway. Device should be sleeping at this point anyway.
|
||||
if (!powerHAL_isPowerLevelSafe()) {
|
||||
@@ -2953,6 +3104,19 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
|
||||
spiLock->unlock();
|
||||
#endif
|
||||
|
||||
#if USERPREFS_EVENT_MODE
|
||||
if (eventProfileStorageUnavailable) {
|
||||
if (saveWhat & SEGMENT_CONFIG) {
|
||||
LOG_WARN("Skipping event config write: insufficient profile storage at boot");
|
||||
saveWhat &= ~SEGMENT_CONFIG;
|
||||
}
|
||||
if (saveWhat & SEGMENT_CHANNELS) {
|
||||
LOG_WARN("Skipping event channel write: insufficient profile storage at boot");
|
||||
saveWhat &= ~SEGMENT_CHANNELS;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (saveWhat & SEGMENT_CONFIG) {
|
||||
config.has_device = true;
|
||||
config.has_display = true;
|
||||
@@ -2980,6 +3144,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 +3285,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)
|
||||
@@ -3268,6 +3436,9 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
|
||||
LOG_WARN(PROTECTED_CAP_WARN_FMT, "ignore", contact.node_num, MAX_NUM_NODES - 2);
|
||||
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false);
|
||||
eraseNodeSatellites(contact.node_num);
|
||||
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
|
||||
messageStore.deleteAllMessagesFromNode(contact.node_num);
|
||||
#endif
|
||||
} else {
|
||||
/* Clients are sending add_contact before every text message DM (because clients may hold a larger node database with
|
||||
* public keys than the radio holds). However, we don't want to update last_heard just because we sent someone a DM!
|
||||
@@ -3307,8 +3478,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
|
||||
|
||||
/** Update user info and channel for this node based on received user data
|
||||
*/
|
||||
bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex)
|
||||
bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned)
|
||||
{
|
||||
// Only a signed update may change the identity of a node that has proven it signs; our own record is
|
||||
// exempt. Checked before getOrCreateMeshNode so a refused update cannot evict or write the warm tier.
|
||||
const meshtastic_NodeInfoLite *existing = getMeshNode(nodeId);
|
||||
if (nodeId != getNodeNum() && existing && nodeInfoLiteHasXeddsaSigned(existing) && !xeddsaSigned) {
|
||||
LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId);
|
||||
return false;
|
||||
}
|
||||
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
|
||||
if (!info) {
|
||||
return false;
|
||||
@@ -3390,6 +3569,20 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde
|
||||
}
|
||||
}
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Write-through: every accepted remote-identity commit lands here (NodeInfoModule,
|
||||
// MeshService, and TMM's requester learning all funnel through updateUser; the two
|
||||
// key-write sites that bypass it call onNodeKeyCommitted instead), so TMM's NodeInfo
|
||||
// cache reflects the commit immediately rather than at the next reconcile pass. Runs on
|
||||
// acceptance, not on `changed`: an identical update still proves the identity is
|
||||
// current. `p` is the post-hygiene payload; signerKnown transfers only key-matched
|
||||
// verified-signer status (isVerifiedSignerForKey semantics), never a bare node flag.
|
||||
if (nodeId != getNodeNum() && trafficManagementModule) {
|
||||
const bool signerKnown = p.public_key.size == 32 && isVerifiedSignerForKey(nodeId, p.public_key.bytes);
|
||||
trafficManagementModule->onNodeIdentityCommitted(nodeId, p, signerKnown);
|
||||
}
|
||||
#endif
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
@@ -3404,7 +3597,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;
|
||||
}
|
||||
@@ -3688,7 +3893,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) {
|
||||
@@ -3704,6 +3909,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);
|
||||
@@ -3799,8 +4095,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());
|
||||
}
|
||||
@@ -3851,15 +4166,14 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub
|
||||
bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
|
||||
{
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
// Only generate keys for non-licensed users and if the LoRa region is set. The native simulator
|
||||
// boots region-UNSET but still needs a keypair so PKI-encrypted DMs work between sim nodes, so
|
||||
// allow keygen there regardless of region.
|
||||
// Generate identity keys once a LoRa region is set. Licensed operation still needs the identity
|
||||
// key for plaintext signatures, even though the key is never used for PKI encryption.
|
||||
bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
||||
#if ARCH_PORTDUINO
|
||||
if (portduino_config.lora_module == use_simradio)
|
||||
regionBlocksKeygen = false;
|
||||
#endif
|
||||
if (owner.is_licensed || regionBlocksKeygen) {
|
||||
if (regionBlocksKeygen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3909,8 +4223,8 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
|
||||
LOG_DEBUG("Set DH private key for crypto operations");
|
||||
crypto->setDHPrivateKey(config.security.private_key.bytes);
|
||||
|
||||
// Conditionally create new identity based on parameter
|
||||
createNewIdentity();
|
||||
if (createNewIdentity() && owner.is_licensed)
|
||||
licensedIdentityMigrationPending = true;
|
||||
}
|
||||
return keygenSuccess;
|
||||
#else
|
||||
@@ -3918,6 +4232,21 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
|
||||
#endif
|
||||
}
|
||||
|
||||
bool NodeDB::notifyPendingLicensedIdentityMigration()
|
||||
{
|
||||
if (!licensedIdentityMigrationPending || !service)
|
||||
return false;
|
||||
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
|
||||
if (!notification)
|
||||
return false;
|
||||
notification->level = meshtastic_LogRecord_Level_WARNING;
|
||||
notification->time = getValidTime(RTCQualityFromNet);
|
||||
snprintf(notification->message, sizeof(notification->message), "%s", LICENSED_IDENTITY_MIGRATION_WARNING);
|
||||
service->sendClientNotification(notification);
|
||||
licensedIdentityMigrationPending = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NodeDB::createNewIdentity()
|
||||
{
|
||||
uint32_t oldNodeNum = getNodeNum();
|
||||
@@ -4021,6 +4350,13 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location,
|
||||
LOG_DEBUG("Restored channels");
|
||||
}
|
||||
|
||||
if (owner.is_licensed && channels.ensureLicensedOperation()) {
|
||||
restoreWhat |= SEGMENT_CHANNELS;
|
||||
LOG_WARN("Licensed operation sanitized restored channel encryption/admin access");
|
||||
}
|
||||
if (restoreWhat & SEGMENT_CHANNELS)
|
||||
channels.onConfigChanged();
|
||||
|
||||
success = saveToDisk(restoreWhat);
|
||||
if (success) {
|
||||
LOG_INFO("Restored preferences from backup");
|
||||
|
||||
+98
-6
@@ -74,6 +74,8 @@ static const uint8_t LOW_ENTROPY_HASHES[][32] = {
|
||||
0x37, 0x82, 0x8d, 0xb2, 0xcc, 0xd8, 0x97, 0x40, 0x9a, 0x5c, 0x8f, 0x40, 0x55, 0xcb, 0x4c, 0x3e}};
|
||||
static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated.";
|
||||
#endif
|
||||
static const char LICENSED_IDENTITY_MIGRATION_WARNING[] =
|
||||
"Licensed signing generated a new identity key; this node identity changed.";
|
||||
/*
|
||||
DeviceState versions used to be defined in the .proto file but really only this function cares. So changed to a
|
||||
#define here.
|
||||
@@ -112,11 +114,57 @@ extern meshtastic_Position localPosition;
|
||||
static constexpr const char *deviceStateFileName = "/prefs/device.proto";
|
||||
static constexpr const char *legacyPrefFileName = "/prefs/db.proto";
|
||||
static constexpr const char *nodeDatabaseFileName = "/prefs/nodes.proto";
|
||||
static constexpr const char *configFileName = "/prefs/config.proto";
|
||||
// Event builds isolate radio profiles so normal firmware resumes its config and channels after OTA.
|
||||
static constexpr const char *STANDARD_CONFIG_FILE_NAME = "/prefs/config.proto";
|
||||
static constexpr const char *STANDARD_CHANNEL_FILE_NAME = "/prefs/channels.proto";
|
||||
static constexpr const char *EVENT_CONFIG_FILE_NAME = "/prefs/event-config.proto";
|
||||
static constexpr const char *EVENT_CHANNEL_FILE_NAME = "/prefs/event-channels.proto";
|
||||
static constexpr const char *STANDARD_BACKUP_FILE_NAME = "/backups/backup.proto";
|
||||
static constexpr const char *EVENT_BACKUP_FILE_NAME = "/backups/event-backup.proto";
|
||||
|
||||
struct RadioProfileStoragePaths {
|
||||
const char *config;
|
||||
const char *channels;
|
||||
const char *backup;
|
||||
};
|
||||
|
||||
constexpr RadioProfileStoragePaths radioProfileStoragePaths(bool eventMode)
|
||||
{
|
||||
return eventMode ? RadioProfileStoragePaths{EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME}
|
||||
: RadioProfileStoragePaths{STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME};
|
||||
}
|
||||
|
||||
// Reserve event files and their atomic temporaries before seeding, preventing retry formatting on full storage.
|
||||
static constexpr size_t EVENT_PROFILE_STORAGE_RESERVATION_BYTES = 2 * (meshtastic_LocalConfig_size + meshtastic_ChannelFile_size);
|
||||
|
||||
constexpr bool hasEventProfileStorageSpace(size_t totalBytes, size_t usedBytes)
|
||||
{
|
||||
return usedBytes <= totalBytes && totalBytes - usedBytes >= EVENT_PROFILE_STORAGE_RESERVATION_BYTES;
|
||||
}
|
||||
|
||||
constexpr bool shouldDeferBootPersistence(bool bootInitializationInProgress, bool configLoadComplete, bool configDecodeFailed)
|
||||
{
|
||||
return bootInitializationInProgress && (!configLoadComplete || configDecodeFailed);
|
||||
}
|
||||
|
||||
#if USERPREFS_EVENT_MODE
|
||||
static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(true);
|
||||
#else
|
||||
static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(false);
|
||||
#endif
|
||||
static constexpr const char *configFileName = RADIO_PROFILE_STORAGE.config;
|
||||
static constexpr const char *channelFileName = RADIO_PROFILE_STORAGE.channels;
|
||||
static constexpr const char *backupFileName = RADIO_PROFILE_STORAGE.backup;
|
||||
static constexpr const char *uiconfigFileName = "/prefs/uiconfig.proto";
|
||||
static constexpr const char *moduleConfigFileName = "/prefs/module.proto";
|
||||
static constexpr const char *channelFileName = "/prefs/channels.proto";
|
||||
static constexpr const char *backupFileName = "/backups/backup.proto";
|
||||
|
||||
// An unverified config load only endangers the radio profile, so only these files take part in
|
||||
// boot-write deferral. Lives next to the path table above so the two cannot drift apart.
|
||||
inline bool isRadioProfileFile(const char *filename)
|
||||
{
|
||||
return strcmp(filename, configFileName) == 0 || strcmp(filename, channelFileName) == 0 ||
|
||||
strcmp(filename, backupFileName) == 0;
|
||||
}
|
||||
|
||||
/// Given a node, return how many seconds in the past (vs now) that we last heard from it
|
||||
uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n);
|
||||
@@ -221,6 +269,7 @@ class NodeDB
|
||||
|
||||
bool keyIsLowEntropy = false;
|
||||
bool hasWarned = false;
|
||||
bool licensedIdentityMigrationPending = false;
|
||||
|
||||
/// don't do mesh based algorithm for node id assignment (initially)
|
||||
/// instead just store in flash - possibly even in the initial alpha release do this hack
|
||||
@@ -255,9 +304,9 @@ class NodeDB
|
||||
*/
|
||||
void updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxSource src = RX_SRC_RADIO);
|
||||
|
||||
/** Update user info and channel for this node based on received user data
|
||||
*/
|
||||
bool updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex = 0);
|
||||
/** Update user info and channel for this node based on received user data.
|
||||
* A known signer's identity is only learned when xeddsaSigned; defaults false so callers fail closed. */
|
||||
bool updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex = 0, bool xeddsaSigned = false);
|
||||
|
||||
/*
|
||||
* Sets a node either favorite or unfavorite. Returns true if the node ends
|
||||
@@ -360,6 +409,37 @@ class NodeDB
|
||||
/// tier. Returns false if we don't know a key for n.
|
||||
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.
|
||||
@@ -474,6 +554,8 @@ class NodeDB
|
||||
/// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys.
|
||||
bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr);
|
||||
|
||||
bool notifyPendingLicensedIdentityMigration();
|
||||
|
||||
bool createNewIdentity();
|
||||
|
||||
bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
|
||||
@@ -529,7 +611,16 @@ 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;
|
||||
// Defer automatic writes until config load is healthy to protect device and node data from damaged configs.
|
||||
bool bootInitializationInProgress = true;
|
||||
bool configLoadComplete = false;
|
||||
#if USERPREFS_EVENT_MODE
|
||||
// The active event profile is intentionally non-durable when there was not
|
||||
// enough room to create it safely at boot. A later boot retries the check.
|
||||
bool eventProfileStorageUnavailable = false;
|
||||
#endif
|
||||
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
|
||||
uint32_t lastFullEvictionMs = 0; // when we last evicted to admit a new node, once the db is full
|
||||
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
|
||||
uint32_t lastSort = 0; // When last sorted the nodeDB
|
||||
|
||||
@@ -548,6 +639,7 @@ class NodeDB
|
||||
// Grant the unit-test shim access to the private maintenance paths below
|
||||
// (migration / cleanup / eviction) without relaxing production access.
|
||||
friend class NodeDBTestShim;
|
||||
friend class MockNodeDB;
|
||||
#endif
|
||||
|
||||
/// purge db entries without user info
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+39
-18
@@ -55,6 +55,34 @@ static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_NARROW[] = {PRESET
|
||||
|
||||
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_TINY[] = {PRESET(TINY_FAST), PRESET(TINY_SLOW), MODEM_PRESET_END};
|
||||
|
||||
// The EU_868/EU_866/EU_N_868 trio share the 868 band but own mutually exclusive preset
|
||||
// profiles. Selecting a preset locked to a sibling swaps the region to that sibling (see
|
||||
// regionSwapForPreset), so from any region in the trio every one of these presets is
|
||||
// selectable. This union is what we advertise to clients as the trio's legal list. It is a
|
||||
// display-only superset: on-device enforcement still uses each region's own disjoint
|
||||
// profile->presets, so this must never be assigned to a RegionProfile (that would make
|
||||
// supportsPreset() accept the preset in place and defeat the swap). Keep in sync with the
|
||||
// EU_868/EU_866/EU_N_868 profile lists below. Sized to the 11-preset wire cap.
|
||||
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_SUPERSET[] = {
|
||||
PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), PRESET(SHORT_SLOW), PRESET(SHORT_FAST),
|
||||
PRESET(LONG_MODERATE), PRESET(LITE_FAST), PRESET(LITE_SLOW), PRESET(NARROW_FAST), PRESET(NARROW_SLOW), MODEM_PRESET_END};
|
||||
|
||||
// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset
|
||||
// locked to a sibling means the user wants that sibling region, not the default preset.
|
||||
static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = {
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_868,
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_866,
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_N_868,
|
||||
};
|
||||
|
||||
static bool isSwappableEuRegion(meshtastic_Config_LoRaConfig_RegionCode code)
|
||||
{
|
||||
for (auto c : SWAPPABLE_EU_REGIONS)
|
||||
if (c == code)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Region profiles: bundle preset list + regulatory parameters shared across regions
|
||||
// presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle
|
||||
const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1};
|
||||
@@ -687,8 +715,13 @@ void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map)
|
||||
grp.default_preset = r->getDefaultPreset();
|
||||
grp.licensed_only = r->profile->licensedOnly;
|
||||
grp.presets_count = 0;
|
||||
for (size_t i = 0; r->profile->presets[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++)
|
||||
grp.presets[grp.presets_count++] = r->profile->presets[i];
|
||||
// EU 86x siblings advertise the trio's superset - any of those presets is
|
||||
// reachable from here via an automatic region swap. Every other region
|
||||
// advertises exactly its own enforced profile list.
|
||||
const meshtastic_Config_LoRaConfig_ModemPreset *advertised =
|
||||
isSwappableEuRegion(r->code) ? PRESETS_EU_SUPERSET : r->profile->presets;
|
||||
for (size_t i = 0; advertised[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++)
|
||||
grp.presets[grp.presets_count++] = advertised[i];
|
||||
}
|
||||
|
||||
// Map this region to its group (capacity checked at the top of the loop).
|
||||
@@ -963,14 +996,6 @@ static void sendErrorNotification(const char *msg, meshtastic_LogRecord_Level le
|
||||
service->sendClientNotification(cn);
|
||||
}
|
||||
|
||||
// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset
|
||||
// locked to a sibling means the user wants that sibling region, not the default preset.
|
||||
static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = {
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_868,
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_866,
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_N_868,
|
||||
};
|
||||
|
||||
/**
|
||||
* If currentRegion is one of the swappable EU regions and preset belongs to a sibling in
|
||||
* that trio, return the sibling region that owns the preset. Returns nullptr otherwise.
|
||||
@@ -978,12 +1003,7 @@ static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = {
|
||||
const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset preset)
|
||||
{
|
||||
bool currentIsSwappable = false;
|
||||
for (auto code : SWAPPABLE_EU_REGIONS) {
|
||||
if (code == currentRegion)
|
||||
currentIsSwappable = true;
|
||||
}
|
||||
if (!currentIsSwappable)
|
||||
if (!isSwappableEuRegion(currentRegion))
|
||||
return nullptr;
|
||||
|
||||
for (auto code : SWAPPABLE_EU_REGIONS) {
|
||||
@@ -1002,7 +1022,8 @@ const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConf
|
||||
* receives the human-readable failure reason.
|
||||
* Returns false if not compatible.
|
||||
*/
|
||||
bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen)
|
||||
bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen,
|
||||
bool prospectiveLicensedOwner)
|
||||
{
|
||||
const RegionInfo *newRegion = getRegion(loraConfig.region);
|
||||
|
||||
@@ -1014,7 +1035,7 @@ bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraC
|
||||
}
|
||||
|
||||
// If you are not licensed, you can't use ham regions.
|
||||
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) {
|
||||
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed && !prospectiveLicensedOwner) {
|
||||
if (errBuf)
|
||||
snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name);
|
||||
return false;
|
||||
|
||||
@@ -256,8 +256,10 @@ class RadioInterface
|
||||
static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp);
|
||||
|
||||
// Check if a candidate region is compatible and valid, with no side effects (safe for
|
||||
// speculative UI checks). errBuf, if given, receives the failure reason.
|
||||
static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0);
|
||||
// speculative UI checks). prospectiveLicensedOwner is for a UI flow that requires
|
||||
// confirmation before it sets the owner licensed. errBuf, if given, receives the failure reason.
|
||||
static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0,
|
||||
bool prospectiveLicensedOwner = false);
|
||||
|
||||
// Check if a candidate region is compatible and valid. On failure, logs at ERROR,
|
||||
// records a critical error, and sends a client notification.
|
||||
|
||||
+514
-75
@@ -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
|
||||
@@ -280,15 +363,6 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src)
|
||||
return send(p);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Send a packet on a suitable interface.
|
||||
*/
|
||||
ErrorCode Router::rawSend(meshtastic_MeshPacket *p)
|
||||
{
|
||||
assert(iface); // This should have been detected already in sendLocal (or we just received a packet from outside)
|
||||
return iface->send(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a packet on a suitable interface. This routine will
|
||||
* later free() the packet to pool. This routine is not allowed to stall.
|
||||
@@ -452,18 +526,111 @@ void Router::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Rout
|
||||
}
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
/** Size a decoded Data as the sender's signedDataFits() gate would have, with padding stripped:
|
||||
* unknown fields inside Data.payload survive in payload.size and would otherwise let a forger
|
||||
* inflate an unsigned broadcast past the signable budget. Returns false only if sizing failed.
|
||||
* Sizing only what this build's schema decodes, so a signable type that later grows needs its
|
||||
* legitimate maximum re-checked against the budget or honest unsigned broadcasts get dropped. */
|
||||
static bool canonicalSignableSize(meshtastic_Data *d, size_t *size)
|
||||
{
|
||||
const pb_msgdesc_t *fields = nullptr;
|
||||
switch (d->portnum) {
|
||||
case meshtastic_PortNum_POSITION_APP:
|
||||
fields = &meshtastic_Position_msg;
|
||||
break;
|
||||
case meshtastic_PortNum_TELEMETRY_APP:
|
||||
fields = &meshtastic_Telemetry_msg;
|
||||
break;
|
||||
case meshtastic_PortNum_WAYPOINT_APP:
|
||||
fields = &meshtastic_Waypoint_msg;
|
||||
break;
|
||||
case meshtastic_PortNum_NODEINFO_APP:
|
||||
fields = &meshtastic_User_msg;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (fields) {
|
||||
// Scratch kept off the stack: these decoded structs are large for the smaller MCU targets.
|
||||
// Safe as file-static state because both callers of checkXeddsaReceivePolicy hold cryptLock.
|
||||
static union {
|
||||
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
|
||||
meshtastic_Position position;
|
||||
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
|
||||
meshtastic_Telemetry telemetry;
|
||||
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
|
||||
meshtastic_Waypoint waypoint;
|
||||
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
|
||||
meshtastic_User user;
|
||||
} inner;
|
||||
|
||||
memset(&inner, 0, sizeof(inner));
|
||||
size_t canonicalPayload;
|
||||
if (pb_decode_from_bytes(d->payload.bytes, d->payload.size, fields, &inner) &&
|
||||
pb_get_encoded_size(&canonicalPayload, fields, &inner) && canonicalPayload <= d->payload.size) {
|
||||
// Only the length matters when sizing a bytes field, so swap it in place instead of
|
||||
// copying the whole Data; restored below because modules still need the real payload.
|
||||
const pb_size_t prevSize = d->payload.size;
|
||||
d->payload.size = (pb_size_t)canonicalPayload;
|
||||
const bool sized = pb_get_encoded_size(size, &meshtastic_Data_msg, d);
|
||||
d->payload.size = prevSize;
|
||||
return sized;
|
||||
}
|
||||
}
|
||||
|
||||
return pb_get_encoded_size(size, &meshtastic_Data_msg, d);
|
||||
}
|
||||
|
||||
enum class NodeInfoBootstrapResult { NOT_APPLICABLE, VERIFIED, INVALID };
|
||||
|
||||
static NodeInfoBootstrapResult verifyFirstContactNodeInfo(meshtastic_MeshPacket *p)
|
||||
{
|
||||
if (p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP)
|
||||
return NodeInfoBootstrapResult::NOT_APPLICABLE;
|
||||
|
||||
meshtastic_User user = meshtastic_User_init_zero;
|
||||
if (!pb_decode_from_bytes(p->decoded.payload.bytes, p->decoded.payload.size, &meshtastic_User_msg, &user) ||
|
||||
user.public_key.size != 32 || crc32Buffer(user.public_key.bytes, user.public_key.size) != p->from ||
|
||||
!crypto->xeddsa_verify(user.public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
|
||||
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes)) {
|
||||
return NodeInfoBootstrapResult::INVALID;
|
||||
}
|
||||
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getOrCreateMeshNode(p->from);
|
||||
if (!node)
|
||||
return NodeInfoBootstrapResult::INVALID;
|
||||
node->public_key.size = user.public_key.size;
|
||||
memcpy(node->public_key.bytes, user.public_key.bytes, user.public_key.size);
|
||||
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||||
p->xeddsa_signed = true;
|
||||
LOG_DEBUG("Verified first-contact XEdDSA NodeInfo from 0x%08x", p->from);
|
||||
return NodeInfoBootstrapResult::VERIFIED;
|
||||
}
|
||||
|
||||
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
||||
{
|
||||
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 {
|
||||
@@ -471,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
|
||||
@@ -482,16 +658,26 @@ 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. 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 (!pb_get_encoded_size(&canonicalSize, &meshtastic_Data_msg, &p->decoded))
|
||||
if (!canonicalSignableSize(&p->decoded, &canonicalSize))
|
||||
return true; // can't size it; never drop on a sizing failure
|
||||
if (canonicalSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) {
|
||||
LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from);
|
||||
@@ -503,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);
|
||||
@@ -516,28 +799,53 @@ 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 licensedPkiCandidate = 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) {
|
||||
const bool pkiCandidate = 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;
|
||||
if (pkiCandidate && owner.is_licensed) {
|
||||
licensedPkiCandidate = true;
|
||||
} else if (pkiCandidate) {
|
||||
pkiAttempted = true;
|
||||
LOG_DEBUG("Attempt PKI decryption");
|
||||
// Resolve the sender's key only for actual PKI-decrypt candidates, not every encrypted channel
|
||||
// packet: copyPublicKeyForDecrypt() can fall through to a linear scan of TrafficManagement's large
|
||||
// NodeInfo cache. It returns authoritative keys (hot/warm), or a cold-tier cache key only when it is
|
||||
// signer-proven - an unverified TOFU cache key must not back authenticated (pki_encrypted, p->from)
|
||||
// DM attribution.
|
||||
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
|
||||
bool haveRemoteKey = nodeDB->copyPublicKeyForDecrypt(p->from, remotePublic);
|
||||
// A pending key is an unverified identity claim supplied by whoever opened the handshake, so it is
|
||||
// accepted only for the exchange itself (checked after decode). perhapsEncode applies the same rule.
|
||||
bool havePendingKey = false;
|
||||
if (!haveRemoteKey) {
|
||||
havePendingKey = crypto->getPendingPublicKey(p->from, remotePublic);
|
||||
haveRemoteKey = havePendingKey;
|
||||
}
|
||||
// Try the sender's known key first, then each configured admin key so an authorized admin can
|
||||
// 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;
|
||||
}
|
||||
if (!decrypted && adminKeyFallbackAllowed()) {
|
||||
for (int i = 0; i < 3 && !decrypted; i++) {
|
||||
if (config.security.admin_key[i].size != 32)
|
||||
continue;
|
||||
@@ -550,6 +858,9 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
break; // stop after first successful decryption
|
||||
}
|
||||
}
|
||||
if (decrypted)
|
||||
adminKeyFallbackRefund();
|
||||
}
|
||||
if (decrypted) {
|
||||
LOG_INFO("PKI Decryption worked!");
|
||||
meshtastic_Data decodedtmp;
|
||||
@@ -557,6 +868,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!");
|
||||
@@ -567,10 +884,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 -
|
||||
@@ -588,6 +907,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);
|
||||
@@ -623,10 +943,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)
|
||||
@@ -684,7 +1003,8 @@ 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 || licensedPkiCandidate) ? DecodeState::DECODE_FAILURE
|
||||
: DecodeState::DECODE_OPAQUE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,6 +1022,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)
|
||||
@@ -722,12 +1070,12 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
// verification at every XEdDSA-enabled receiver that knows our key.
|
||||
p->decoded.xeddsa_signature.size = 0;
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
// Sign broadcast packets when the Data still fits a LoRa frame with the signature
|
||||
// attached. This must be the exact encoded-size criterion, not a payload-size
|
||||
// heuristic: a heuristic band where we sign-then-fail-TOO_LARGE breaks packets that
|
||||
// Licensed packets stay plaintext, so sign both broadcasts and unicasts. Normal mode
|
||||
// continues to sign broadcasts only. Use the exact encoded size: a payload-size heuristic
|
||||
// where we sign-then-fail-TOO_LARGE breaks packets that
|
||||
// were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when
|
||||
// deciding whether an unsigned broadcast from a known signer is a downgrade.
|
||||
if (!p->pki_encrypted && isBroadcast(p->to) && signedDataFits(&p->decoded)) {
|
||||
if (!p->pki_encrypted && (owner.is_licensed || isBroadcast(p->to)) && signedDataFits(&p->decoded)) {
|
||||
if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
|
||||
p->decoded.xeddsa_signature.bytes)) {
|
||||
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
|
||||
@@ -795,28 +1143,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
}
|
||||
// We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node
|
||||
// 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;
|
||||
@@ -881,30 +1208,122 @@ 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");
|
||||
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) {
|
||||
@@ -981,7 +1400,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
|
||||
@@ -1030,6 +1449,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;
|
||||
@@ -1037,30 +1457,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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user