Merge branch 'meshtastic:develop' into develop

This commit is contained in:
吴文峰
2026-07-31 10:32:02 +08:00
committed by GitHub
co-authored by GitHub
200 changed files with 4800 additions and 311 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ runs:
- name: Install libs needed for native build
shell: bash
run: |
sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev
sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev libcurl4-gnutls-dev
+1
View File
@@ -47,6 +47,7 @@ jobs:
mingw-w64-ucrt-x86_64-libuv
mingw-w64-ucrt-x86_64-jsoncpp
mingw-w64-ucrt-x86_64-openssl
mingw-w64-ucrt-x86_64-curl
mingw-w64-ucrt-x86_64-libusb
mingw-w64-ucrt-x86_64-cmake
mingw-w64-ucrt-x86_64-ninja
+4 -4
View File
@@ -140,7 +140,7 @@ jobs:
secrets: inherit
MacOS:
if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }}
if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }}
strategy:
fail-fast: false
matrix:
@@ -155,7 +155,7 @@ jobs:
# secrets: inherit
Windows:
if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }}
if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }}
strategy:
fail-fast: false
matrix:
@@ -182,7 +182,7 @@ jobs:
uses: ./.github/workflows/test_native.yml
build-wasm:
if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }}
if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }}
# Build the WebAssembly portduino node ([env:native-wasm]) as part of normal CI,
# like the other platforms. It's a dedicated job (not a row in the `build`
# matrix) because its artifact is meshnode.{mjs,wasm} - not a flashable
@@ -191,7 +191,7 @@ jobs:
uses: ./.github/workflows/build_portduino_wasm.yml
docker:
if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }}
if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }}
permissions: # Needed for pushing to GHCR.
contents: read
packages: write
+8
View File
@@ -81,6 +81,14 @@ jobs:
lcov ${{ env.LCOV_CAPTURE_FLAGS }} --initial --output-file coverage_base.info
sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative.
- name: Config check tests
# Drives the same binary against test/fixtures/portduino-config: asserts that
# `--check` reports each planted fault, and that a normal run still refuses the
# configs it should. Runs before the simulator test because it is seconds long
# and a failure here explains a lot of downstream weirdness.
timeout-minutes: 5
run: ./bin/test-config-check.sh .pio/build/coverage/meshtasticd
- name: Integration test
# Cap the whole step: if the simulator ever fails to exit (e.g. the
# exit_simulator admin path regresses again) the job must fail fast,
-2
View File
@@ -1,2 +0,0 @@
tasks:
- init: pip install platformio && pip install --upgrade pip
+17 -9
View File
@@ -4,7 +4,7 @@ cli:
plugins:
sources:
- id: trunk
ref: v1.10.0
ref: v1.10.2
uri: https://github.com/trunk-io/plugins
lint:
# Custom file set + formatter that rewrites Unicode em-dash (U+2014, UTF-8
@@ -69,19 +69,19 @@ lint:
enabled:
- ascii-dash@SYSTEM
- too-many-defined@SYSTEM
- checkov@3.2.529
- renovate@43.150.0
- prettier@3.8.3
- trufflehog@3.95.3
- checkov@3.3.8
- renovate@44.2.3
- prettier@3.9.6
- trufflehog@3.96.0
- yamllint@1.38.0
- bandit@1.9.4
- trivy@0.70.0
- trivy@0.72.0
- taplo@0.10.0
- ruff@0.15.13
- ruff@0.16.0
- isort@8.0.1
- markdownlint@0.48.0
- markdownlint@0.49.1
- oxipng@10.1.1
- svgo@4.0.1
- svgo@4.0.2
- actionlint@1.7.12
- flake8@7.3.0
- hadolint@2.14.0
@@ -102,6 +102,14 @@ lint:
- linters: [gitleaks]
paths:
- test/fixtures/nodedb/seed_v25_*.jsonl
# Deliberately broken YAML: these are the inputs to bin/test-config-check.sh,
# which asserts that `meshtasticd --check` reports each planted fault. Prettier
# rejects the duplicate keys and bad indentation that are the whole point of
# them, so the directory is exempt. The README there is normal prose and stays
# linted.
- linters: [ALL]
paths:
- test/fixtures/portduino-config/**/*.yaml
# 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
+2 -2
View File
@@ -16,7 +16,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
curl wget g++ zip git ca-certificates pkg-config \
python3-pip python3-grpc-tools \
libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
libcurl4-gnutls-dev libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir -U platformio \
@@ -55,7 +55,7 @@ USER root
RUN apt-get update && apt-get --no-install-recommends -y install \
libc-bin libc6 libgpiod3 libyaml-cpp0.8 libjsoncpp26 libi2c0 libuv1t64 libusb-1.0-0-dev \
liborcania2.3 libulfius2.7t64 libssl3t64 \
libcurl4t64 liborcania2.3 libulfius2.7t64 libssl3t64 \
libx11-6 libinput10 libxkbcommon-x11-0 libsdl2-2.0-0 \
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
&& mkdir -p /var/lib/meshtasticd \
+2 -2
View File
@@ -17,7 +17,7 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1
RUN apk --no-cache add \
bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \
py3-pip py3-grpcio-tools \
libgpiod-dev yaml-cpp-dev jsoncpp-dev bluez-dev \
libgpiod-dev yaml-cpp-dev jsoncpp-dev bluez-dev curl-dev \
libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \
libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \
&& rm -rf /var/cache/apk/* \
@@ -50,7 +50,7 @@ USER root
RUN apk --no-cache add \
shadow libstdc++ libbsd libgpiod yaml-cpp jsoncpp libusb \
i2c-tools libuv libx11 libinput libxkbcommon sdl2 \
libcurl i2c-tools libuv libx11 libinput libxkbcommon sdl2 \
&& rm -rf /var/cache/apk/* \
&& mkdir -p /var/lib/meshtasticd \
&& mkdir -p /etc/meshtasticd/config.d \
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env bash
# Drive a built meshtasticd against the fixtures in test/fixtures/portduino-config
# and assert what it does with each one.
#
# Why this is a shell test and not a Unity suite: both behaviours under test are
# properties of the process, not of a function. `--check` is judged by its exit
# status and its printed report, and the "normal run rejects a bad config" path
# ends in exit(EXIT_FAILURE) inside portduinoSetup() - neither is reachable from
# a native unit test that links a single translation unit.
#
# Usage:
# ./bin/test-config-check.sh # auto-detect the binary
# ./bin/test-config-check.sh path/to/meshtasticd # explicit binary
# MESHTASTICD_BIN=... ./bin/test-config-check.sh
#
# Exit codes: 0 = GREEN, 1 = RED. The final line is machine-readable:
# RESULT: GREEN 42/42 assertions passed
# RESULT: RED 2 of 42 assertions failed
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
FIXTURES="$ROOT_DIR/test/fixtures/portduino-config"
BIN="${1:-${MESHTASTICD_BIN-}}"
if [[ -z $BIN ]]; then
for candidate in "$ROOT_DIR/.pio/build/native/meshtasticd" "$ROOT_DIR/.pio/build/coverage/meshtasticd"; do
[[ -x $candidate ]] && BIN="$candidate" && break
done
fi
if [[ -z $BIN || ! -x $BIN ]]; then
echo "RESULT: RED no meshtasticd binary (build one with 'pio run -e native', or pass a path)"
exit 1
fi
# Each case runs from a directory of its own choosing, so a relative path (CI passes
# .pio/build/coverage/meshtasticd) has to be resolved before the first cd.
BIN="$(cd "$(dirname "$BIN")" && pwd)/$(basename "$BIN")"
# Note: `pio test -e native` writes its Unity test program to this same path, so after a test
# run the auto-detected binary is that program rather than the application. That turns out to be
# harmless here -- portduino's main() parses argv and runs portduinoSetup(), which prints the
# report and exit()s before setup() (Unity's entry point) is ever reached -- so every case below
# behaves identically either way. It stops being true only for a case that lets meshtasticd run
# past portduinoSetup(), which this suite deliberately never does: a config it accepts boots a
# node and blocks.
# The coverage env links ASan/LSan. Every case here ends in exit(), so LSan would
# report the still-reachable config objects and turn a passing case into a non-zero
# exit that has nothing to do with what is being asserted.
export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=0}"
# Keep the virtual filesystem out of the user's home, and run from a directory with
# no config.yaml in it so the fixture we name is the only config in play.
WORKDIR="$(mktemp -d -t meshcheck.XXXXXX)"
trap 'rm -rf "$WORKDIR"' EXIT
PASS=0
FAIL=0
# assert <description> <expected-exit> <fixture> <mode> [expected-substring...]
# mode: "check" adds --check, "check-yaml" adds --check --output-yaml,
# "normal" runs with neither.
# fixture: a bare name runs from a scratch directory. A name containing a slash
# (configd-conflict/config.yaml) runs from that fixture's own directory,
# so a relative ConfigDirectory in it resolves the way it would in situ.
assert() {
local desc="$1" want_rc="$2" fixture="$3" mode="$4"
shift 4
local cwd="$WORKDIR" config="$FIXTURES/$fixture"
if [[ $fixture == */* ]]; then
cwd="$FIXTURES/$(dirname "$fixture")"
config="$(basename "$fixture")"
fi
local args=(--config "$config" -d "$WORKDIR/fs")
case $mode in
check) args+=(--check) ;;
check-yaml) args+=(--check --output-yaml) ;;
esac
local out rc
# A regression that hangs must fail the test, not stall CI. Every case here is
# expected to exit in well under a second.
out="$(cd "$cwd" && timeout 60 "$BIN" "${args[@]}" 2>&1)"
rc=$?
local problems=()
[[ $rc -ne $want_rc ]] && problems+=("exit $rc, wanted $want_rc")
local needle
for needle in "$@"; do
grep -qF -- "$needle" <<<"$out" || problems+=("missing: $needle")
done
if [[ ${#problems[@]} -eq 0 ]]; then
PASS=$((PASS + 1))
echo " ok $desc"
else
FAIL=$((FAIL + 1))
echo " FAIL $desc"
for problem in "${problems[@]}"; do
echo " $problem"
done
sed 's/^/ | /' <<<"$out"
fi
}
# A config that meshtasticd fully accepts is asserted clean AND asserted to resolve
# to the module we asked for - otherwise a silent fallback to sim would still pass.
assert_clean_module() {
local fixture="$1" module="$2"
assert "$module" 0 "$fixture" check \
"Result: 0 errors, 0 warnings" \
"Module : $module"
}
echo "meshtasticd config-check tests"
echo " binary: $BIN"
echo
echo "a valid config for every radio module family is clean:"
assert_clean_module module-rf95.yaml RF95
assert_clean_module module-sx1262.yaml sx1262
assert_clean_module module-sx1268.yaml sx1268
assert_clean_module module-llcc68.yaml LLCC68
assert_clean_module module-sx1280.yaml sx1280
assert_clean_module module-lr1110.yaml lr1110
assert_clean_module module-lr1120.yaml lr1120
assert_clean_module module-lr1121.yaml lr1121
assert_clean_module module-sim.yaml sim
assert_clean_module module-auto.yaml auto
echo
echo "other configs that must not be flagged:"
assert "minimal valid config" 0 valid.yaml check \
"Result: 0 errors, 0 warnings"
assert "empty sections are not a fault" 0 empty-sections.yaml check \
"Result: 0 errors, 0 warnings"
assert "warnings alone do not fail the run" 0 unknown-key.yaml check \
"unknown key 'Lora.Frequency'" \
"Result: 0 errors, 1 warning"
echo
echo "module names are matched exactly:"
assert "unknown module names the valid set" 1 module-unknown.yaml check \
"Lora.Module 'sx1263' is not a module meshtasticd knows" \
"Result: 1 error, 0 warnings"
assert "wrong-case module suggests the right spelling" 1 module-wrong-case.yaml check \
"did you mean 'LLCC68'?" \
"Result: 1 error, 0 warnings"
echo
echo "LR11xx rfswitch table:"
assert "unrecognised switch pin" 1 rfswitch-bad-pin.yaml check \
"'DIO9' is not a recognised pin" \
"Result: 1 error, 0 warnings"
assert "row length must match the pin count" 1 rfswitch-row-length.yaml check \
"MODE_STBY has 2 values but 3 pins are declared" \
"MODE_RX has 4 values but 3 pins are declared" \
"Result: 2 errors, 0 warnings"
# Anything not exactly "HIGH" is silently treated as LOW, so lowercase "high" is a
# switch that never closes - the failure this check exists to catch.
assert "levels must be exactly HIGH or LOW" 1 rfswitch-bad-level.yaml check \
"'high' is not HIGH or LOW" \
"'On' is not HIGH or LOW" \
"Result: 2 errors, 0 warnings"
assert "table with no pins list" 1 rfswitch-no-pins.yaml check \
"has no 'pins' list, so no switch pins are driven" \
"Result: 1 error, 0 warnings"
assert "only the first 5 pins are read" 1 rfswitch-too-many-pins.yaml check \
"lists 6 pins but only the first 5 are read" \
"Result: 1 error, 0 warnings"
assert "table must be a mapping" 1 rfswitch-not-a-map.yaml check \
"Lora.rfswitch_table must be a mapping" \
"Result: 1 error, 0 warnings"
assert "unknown MODE_ key" 1 rfswitch-unknown-mode.yaml check \
"unknown key 'Lora.rfswitch_table.MODE_TRANSMIT'" \
"Result: 1 error, 0 warnings"
# One level of over-indentation strands MODE_ rows under Lora: instead of under the
# table, where they do nothing. The hint is what makes that findable.
assert "MODE_ row stranded outside the table" 0 rfswitch-stranded-modes.yaml check \
"unknown key 'Lora.MODE_RX'" \
"It is a valid key of Lora.rfswitch_table" \
"Result: 0 errors, 1 warning"
# A partial table is legal, but the modes left out are driven all-LOW, which for most
# modules is the shutdown state - so the report says which ones rather than leaving it
# to be discovered on air.
assert "omitted modes are called out" 0 rfswitch-partial.yaml check \
"omits MODE_GNSS, MODE_TX_HF, MODE_TX_HP, MODE_WIFI" \
"default to all pins LOW" \
"Result: 0 errors, 0 warnings"
echo
echo "radio module and switch table must agree:"
assert "LR11xx without a table cannot transmit" 0 module-mismatch-lr11xx.yaml check \
"Module is lr1121 but no Lora.rfswitch_table is set" \
"Result: 0 errors, 1 warning"
assert "table on a non-LR11xx radio is ignored" 0 module-mismatch-sx126x.yaml check \
"the table is only applied to LR11xx radios" \
"Result: 0 errors, 1 warning"
echo
echo "PA gain table (TX_GAIN_LORA):"
# Both shapes are legal and they fail differently. The scalar case is a regression
# guard: an earlier version of the checker called this working config a fatal error.
assert "a bare scalar is legal" 0 txgain-scalar.yaml check \
"Result: 0 errors, 0 warnings"
assert "a non-numeric list entry stops meshtasticd" 1 value-type-fatal-list.yaml check \
"TX_GAIN_LORA entry 'high' is not a whole number" \
"refuses to start" \
"Result: 1 error, 0 warnings"
assert "entries outside the uint16 range wrap" 1 txgain-out-of-range.yaml check \
"entry -5 does not fit the 0-65535 range" \
"entry 70000 does not fit the 0-65535 range" \
"Result: 2 errors, 0 warnings"
assert "points past the 22nd are dropped" 0 txgain-too-many.yaml check \
"lists 25 points but only the first 22 are stored" \
"Result: 0 errors, 1 warning"
echo
echo "values of the wrong type:"
# The two settings read without a fallback: a bad value throws inside loadConfig() and
# meshtasticd will not start. Before this check, --check called these files clean.
assert "no-fallback read is fatal" 1 value-type-fatal.yaml check \
"Logging.AsciiLogs is not a true/false value" \
"refuses to start" \
"Result: 1 error, 0 warnings"
assert "defaulted reads are silently ignored" 0 value-type-silent.yaml check \
"Lora.spiSpeed is not a whole number" \
"Lora.DIO2_AS_RF_SWITCH is not a true/false value" \
"General.MaxNodes is not a whole number" \
"silently replaced by the default" \
"Result: 0 errors, 4 warnings"
echo
echo "out-of-range and unit mistakes:"
# The volts/millivolts trap: every other Meshtastic surface says millivolts, so 1800 is
# the natural thing to write and it silently asks for 1800V.
assert "TCXO voltage written in millivolts" 1 tcxo-millivolts.yaml check \
"resolves to 1800000 mV" \
"The value is in VOLTS" \
"Result: 1 error, 0 warnings"
assert "ports outside their usable range" 1 port-out-of-range.yaml check \
"General.APIPort 80 is outside 1024-65535" \
"Webserver.Port 99999 is not a usable TCP port" \
"Result: 1 error, 1 warning"
assert "StatusMessage longer than its buffer" 0 statusmessage-long.yaml check \
"is truncated to 79 when it is stored" \
"Result: 0 errors, 1 warning"
# Regression guard for a crash: this used to abort meshtasticd (and --check with it)
# via an uncaught filesystem_error from directory_iterator.
assert "unreadable ConfigDirectory is reported, not fatal" 1 configdir-missing.yaml check \
"is not a directory that can be read" \
"Result: 1 error, 0 warnings"
echo
echo "MAC address sources:"
assert "both MACAddress and MACAddressSource" 1 mac-conflict.yaml check \
"General.MACAddress and General.MACAddressSource are both set" \
"Result: 1 error, 0 warnings"
assert "MACAddress that is not 12 hex digits" 1 mac-malformed.yaml check \
"is not 12 hex digits" \
"Result: 1 error, 0 warnings"
assert "MACAddressSource naming no interface" 0 mac-source-missing.yaml check \
"has no /sys/class/net/nosuchiface99/address" \
"Result: 0 errors, 1 warning"
echo
echo "pin mappings:"
assert "unknown pin sub-key" 1 pin-unknown-subkey.yaml check \
"unknown key 'Lora.CS.chipline'" \
"A pin mapping accepts only pin, gpiochip and line" \
"Result: 1 error, 0 warnings"
assert "pin value that resolves to -1" 1 pin-unreadable.yaml check \
"Lora.CS is set, but its value could not be read as a pin number" \
"Result: 1 error, 0 warnings"
echo
echo "CH341 USB-SPI adapters:"
# The Lora pins of a ch341 device are indexes on the adapter, driven by the usermode
# driver: portduinoSetup() skips initGPIOPin() for all of them. Reporting them as
# gpiochip lines to confirm with gpioinfo is wrong on Linux and meaningless on the
# Windows and macOS hosts where a USB adapter is the only way to attach a radio.
assert "adapter pins are not reported as GPIO lines" 0 usb-ch341.yaml check \
"CH341 adapter pins (driven over USB, not claimed from a gpiochip)" \
"pin indexes on the CH341 itself" \
"Module : sx1262" \
"Result: 0 errors, 0 warnings"
assert "gpiochip mapping on a ch341 device is ignored" 0 ch341-gpiochip.yaml check \
"Lora.spidev is ch341" \
"Lora.CS.gpiochip, Lora.CS.line, Lora.gpiochip are read but never used" \
"Result: 0 errors, 1 warning"
echo
echo "structural faults:"
assert "duplicate key" 1 duplicate-key.yaml check \
"duplicate key 'Lora.CS'" \
"yaml-cpp keeps the FIRST occurrence" \
"Result: 1 error, 0 warnings"
assert "section body that is not a mapping" 1 nonmap-section.yaml check \
"'Lora' is not a mapping" \
"Result: 1 error, 0 warnings"
assert "unknown top-level section" 1 unknown-section.yaml check \
"unknown top-level section 'Telemetry'" \
"Result: 1 error, 0 warnings"
assert "key left at the top level" 1 stranded-key.yaml check \
"'spidev' is a key of Display or Lora or Touchscreen" \
"indent it one level" \
"Result: 1 error, 0 warnings"
assert "top level is a list" 1 top-level-list.yaml check \
"top level is not a mapping" \
"Result: 1 error, 0 warnings"
assert "empty file" 0 empty-file.yaml check \
"is empty" \
"Result: 0 errors, 1 warning"
# Only the unknown key is asserted: the accompanying "built without HUB75 support"
# error depends on whether rgbmatrix was present at build time, so it is not a stable
# expectation across builds.
assert "unknown HUB75 option" 1 hub75-unknown-key.yaml check \
"unknown key 'Display.HUB75.Colours'"
# The point of the case: a config that will not parse must still reach the report
# with a file and a line, rather than exiting early with just "Unable to use ...".
# The asserted line number counts the fixture's comment header - the only assertion
# here that moves if you edit those comments.
assert "unparseable config still reaches the report" 1 malformed-indent.yaml check \
"malformed-indent.yaml" \
"could not be parsed" \
"error at line 7" \
"Result: 1 error, 0 warnings"
echo
echo "across a config directory:"
# Three files each opening a 'Lora:' section. Every key not repeated in the last
# one loaded is silently reset to its default - here config.yaml's TCXO voltage.
# The warning COUNT is deliberately not asserted: the two config.d files name
# different modules, so which one wins - and therefore whether the LR11xx-without-a
# switch-table warning fires - depends on the order the filesystem returns them in,
# which is the very thing this fixture exists to demonstrate.
assert "config.d overrides are reported" 0 configd-conflict/config.yaml check \
"config.d/lora-a.yaml" \
"config.d/lora-b.yaml" \
"not necessarily alphabetical" \
"files define a 'Lora:' section" \
"The file loaded last wins" \
"Result: 0 errors,"
# Switch tables are the one place "last wins" is false: the loader only ever writes
# HIGH, so the effective table is the OR of every file. Proven with --output-yaml.
assert "switch tables across files do not override" 1 rfswitch-sticky/config.yaml check \
"These do NOT override each other" \
"a HIGH from an earlier file survives a later file that sets LOW" \
"Enable exactly one"
echo
echo "--check takes precedence over --output-yaml:"
assert "report wins over the yaml dump" 0 valid.yaml check-yaml \
"meshtasticd configuration check" \
"Result: 0 errors, 0 warnings"
echo
echo "normal operation rejects a bad config:"
assert "unparseable config is refused" 1 malformed-indent.yaml normal \
"Unable to use" \
"as config file"
assert "non-mapping section is refused" 1 nonmap-section.yaml normal \
"Unable to use" \
"as config file"
assert "unknown module is refused" 1 module-unknown.yaml normal \
"Unknown Lora.Module: sx1263"
assert "MACAddress conflict is refused" 1 mac-conflict.yaml normal \
"Cannot set both MACAddress and MACAddressSource!"
# Only meaningful on a build without rgbmatrix. A build that supports HUB75 accepts
# the panel and goes on to start a node, which is not something to assert here.
# Captured rather than piped: this run exits non-zero by design, and pipefail would
# make the pipeline look failed however the grep went.
HUB75_PROBE="$("$BIN" --check --config "$FIXTURES/hub75-unknown-key.yaml" -d "$WORKDIR/fs" 2>&1)"
if grep -qF "built without HUB75 support" <<<"$HUB75_PROBE"; then
assert "unsupported display panel is refused" 1 hub75-unknown-key.yaml normal \
"this build does not support HUB75"
else
echo " skip unsupported display panel is refused (this build has HUB75 support)"
fi
echo
TOTAL=$((PASS + FAIL))
if [[ $FAIL -eq 0 ]]; then
echo "RESULT: GREEN $PASS/$TOTAL assertions passed"
exit 0
fi
echo "RESULT: RED $FAIL of $TOTAL assertions failed"
exit 1
+53
View File
@@ -0,0 +1,53 @@
{
"build": {
"arduino": {
"ldscript": "nrf52840_s140_v6.ld"
},
"core": "nRF5",
"cpu": "cortex-m4",
"extra_flags": "-DARDUINO_NRF52840_ThinkNode_M8 -DNRF52840_XXAA",
"f_cpu": "64000000L",
"hwids": [
["0x239A", "0x4405"],
["0x239A", "0x0029"],
["0x239A", "0x002A"]
],
"usb_product": "elecrow_thinknode_m8",
"mcu": "nrf52840",
"variant": "ELECROW-ThinkNode-M8",
"variants_dir": "variants",
"bsp": {
"name": "adafruit"
},
"softdevice": {
"sd_flags": "-DS140",
"sd_name": "s140",
"sd_version": "6.1.1",
"sd_fwid": "0x00B6"
},
"bootloader": {
"settings_addr": "0xFF000"
}
},
"connectivity": ["bluetooth"],
"debug": {
"jlink_device": "nRF52840_xxAA",
"onboard_tools": ["jlink"],
"svd_path": "nrf52840.svd",
"openocd_target": "nrf52840-mdk-rs"
},
"frameworks": ["arduino"],
"name": "elecrow thinknode m8",
"upload": {
"maximum_ram_size": 248832,
"maximum_size": 815104,
"speed": 115200,
"protocol": "nrfutil",
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"],
"use_1200bps_touch": true,
"require_upload_port": true,
"wait_for_upload_port": true
},
"url": "",
"vendor": "ELECROW"
}
+39
View File
@@ -0,0 +1,39 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"memory_type": "qio_opi"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DLILYGO_TBEAM_BPF",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"psram_type": "opi",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "t-beam-bpf"
},
"connectivity": ["wifi", "bluetooth", "lora"],
"debug": {
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino"],
"name": "LilyGo TBeam-BPF",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"require_upload_port": true,
"speed": 921600
},
"url": "http://www.lilygo.cn/",
"vendor": "LilyGo"
}
+85 -41
View File
@@ -18,6 +18,22 @@ its 4-bit cached role acts as a final fallback when all three identity tiers mis
Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
`src/modules/TrafficManagementModule.{h,cpp}`, sizing in `src/mesh/mesh-pb-constants.h`.
**Memory classes.** The warm tier (§2) and unified cache (§3) size themselves from
`MESHTASTIC_MEM_CLASS` (`src/memory/MemClass.h`), which ranks a build by _usable app heap after
platform overheads_ (SoftDevice, WiFi+BLE stacks) rather than by raw RAM or chip family. The hot
store (§1) is flash-shaped and the NodeInfo cache (§4) is present-or-absent, so neither is classed:
| Class | Heap | Parts |
| ------ | --------------------- | -------------------------------------------- |
| LARGE | PSRAM or host | ESP32-S3 with PSRAM, portduino/native |
| MEDIUM | ~250-500 KB, no PSRAM | ESP32-S3/C6/P4 without PSRAM |
| SMALL | ~100-250 KB | classic ESP32/S2/C3, nRF52840, RP2040/RP2350 |
| TINY | <32 KB | STM32WL |
An unclassified chip lands in SMALL on purpose: small caches are a recoverable default, an
exhausted heap is not. Where a capacity table names a specific part beside these classes, that
part is deliberately class-deviant and the reason is given under the table.
---
## 1. NodeDB hot store (authoritative)
@@ -26,8 +42,6 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
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.
@@ -45,6 +59,17 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
warm-evicted signer be impersonated with unsigned frames.
- `getNodeRole(n)` - hot store, then the role cached in the warm tier, else `CLIENT`.
**Capacity** - `MAX_NUM_NODES` (`mesh-pb-constants.h`):
| ESP32-S3 | Native | nRF52840, generic ESP32 | STM32WL |
| --------------- | ------ | ----------------------- | ------- |
| 250 / 200 / 100 | 250 | 120 | 10 |
This one is flash-shaped rather than heap-shaped, so it is unclassed: `nodes.proto` has to fit the
filesystem. ESP32-S3 is the only runtime tier, picked at boot from the flash chip (>=15 MB />=7 MB
/ smaller); the 120 covers nRF52840 plus generic ESP32 including C3, and is what keeps `nodes.proto`
inside the stock 28 KB LittleFS.
## 2. Warm tier - `WarmNodeStore` (NodeDB-owned)
- **What:** the "long-tail" second tier. When a node ages out of the hot store, a minimal
@@ -53,7 +78,6 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
- **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
@@ -61,6 +85,18 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
- **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.
**Capacity** - `WARM_NODE_COUNT` (`mesh-pb-constants.h`):
| LARGE | MEDIUM | RP2040 / RP2350 | nRF52840 | SMALL | TINY |
| ----- | ------ | --------------- | -------- | ----- | ---- |
| 2000 | 150 | 150 | 100 | 100 | 0 |
TINY's 0 disables the tier outright. At 40 B/entry, LARGE costs ~80 KB and lives in PSRAM, MEDIUM
~6 KB of heap. Both named parts are class-deviant on purpose: RP2040/RP2350 is bounded so the
`warm.dat` write fits the 8 s watchdog (#10746) rather than by RAM, and nRF52840 dropped from 200 to
100 because its RAM cache is calloc'd from the ~115 KB heap arena shared with SoftDevice, which
2.8.0 field reports showed at 99% use.
## 3. TMM unified cache (base, traffic state)
- **What:** TMM's own flat array of packed 10-byte `UnifiedCacheEntry` records - the
@@ -76,15 +112,22 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
`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.
**Capacity** - `TRAFFIC_MANAGEMENT_CACHE_SIZE` (`mesh-pb-constants.h`), variant-overridable:
| LARGE | MEDIUM | SMALL | nRF52840 | `HAS_TRAFFIC_MANAGEMENT=0` |
| ----- | ------ | ----- | -------- | -------------------------- |
| 2048 | 500 | 400 | 250 | 0 |
At 10 B/entry that is ~5 KB on MEDIUM and ~2.5 KB on nRF52840, which is class-deviant for the same
heap reason as the warm tier (its class would give 400); 250 entries still tracks over 2x the
120-node hot store, and LRU victim recycling absorbs busier meshes.
## 4. TMM NodeInfo payload cache (extended, the ephemeral third tier)
- **What:** a flat array of `NodeInfoPayloadEntry` (PSRAM-backed on hardware; see
@@ -101,11 +144,20 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
`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.
**Capacity** - `kNodeInfoCacheEntries` (`TrafficManagementModule.h`), gated by
`TMM_HAS_NODEINFO_CACHE`:
| ESP32 + PSRAM | Native unit-test builds | Everything else |
| ------------- | ----------------------- | --------------- |
| 2000 | 2000 | not compiled |
Not class-tiered: the array is either compiled or it isn't. ESP32+PSRAM is the production home (in
PSRAM); native test builds put the same 2000 entries on the plain heap so the trust and retention
paths run in CI. Linear scan in every build - NodeInfo traffic is low-rate.
### Trust & provenance model
- **Key pin, three layers deep:** an incoming NodeInfo key is checked against
@@ -172,22 +224,14 @@ 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.
This cache's `obsTick` recency stamp, like the unified cache's pos/rate/unknown stamps, is a
free-running modular tick rather than an absolute time, and depends on the maintenance sweep to
clear expired state before it aliases. The per-clock periods, windows, and what keeps each honest
are documented with the module in
[traffic_management_module.md](traffic_management_module.md#tick-clocks-and-wrap-safety). The sharp
case for this tier is `obsTick`: the sweep clearing `hasObserved` is the _sole_ guarantee the 6 h
serve gate never reads an aliased stamp, which is why it is a compile-time invariant guarded by
`TMM_HAS_NODEINFO_CACHE` alone.
The warm tier is different by design: `WarmNodeStore.last_heard` is an **absolute** unix-seconds
timestamp (128 s quantised), so it cannot wrap until 2106 and needs no sweep - the TMM caches
@@ -207,23 +251,23 @@ behaviour - is documented with the module in
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 |
| 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` (10-250) | `WARM_NODE_COUNT` (0-2000) | `kNodeInfoCacheEntries` (2000) | `TRAFFIC_MANAGEMENT_CACHE_SIZE` (0-2048) |
| 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
+27
View File
@@ -177,6 +177,33 @@ carries throttle state.
---
## Tick clocks and wrap safety
Every per-node timestamp in TMM's caches is a free-running modular tick (uint8 or nibble) taken
from `clockMs()` - never an absolute time. That is what keeps `UnifiedCacheEntry` at 10 bytes
across up to 2048 entries. The cost is that modular subtraction is only correct while the true age
stays below the counter's period, so every clock needs something to clear expired state before it
aliases. (The direct-serve throttle above is the deliberate exception: full `uint32` milliseconds
compared by wrap-safe subtraction, hence no tick and no sweep.)
| Clock | Tick / period | Window | Kept honest by |
| ------------------ | -------------- | --------------- | -------------------------------------------------- |
| pos | 6 min / 25.6 h | <=255 ticks | 60 s sweep (margin as low as 1 tick at the clamp) |
| rate | 5 min / 80 min | <=15 ticks | sweep + read-time window reset (`isRateLimited()`) |
| unknown | 1 min / 16 min | 12 ticks | sweep + read-time window reset |
| NodeInfo `obsTick` | 3 min / 12.8 h | 120 ticks (6 h) | sweep only |
`obsTick` is the sharp case: `maintainNodeInfoCacheLocked()` clearing `hasObserved` is the
_sole_ guarantee the 6 h serve gate never reads an aliased stamp. That makes the sweep a
compile-time invariant - guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never
`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring `purgeAll()`:
a build that has the cache always has its sweep.
The stores these clocks stamp, and the warm tier's contrasting absolute timestamps, are described
in [node_info_stores.md](node_info_stores.md).
---
## Configuration
All tunables live under `moduleConfig.traffic_management`; the whole module is gated by the
+1 -1
View File
@@ -128,7 +128,7 @@ lib_deps =
[device-ui_base]
lib_deps =
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
https://github.com/meshtastic/device-ui/archive/ef573c368767625ffbe8c32cf921ea7366f2dd53.zip
https://github.com/meshtastic/device-ui/archive/bcb327f058469282408eed93e27249d6447de9c0.zip
; Common libs for environmental measurements in telemetry module
[environmental_base]
+6 -1
View File
@@ -215,7 +215,12 @@ const StoredMessage *MessageStore::tryAddFromPacket(const meshtastic_MeshPacket
sm.channelIndex = packet.channel;
const char *payload = reinterpret_cast<const char *>(packet.decoded.payload.bytes);
size_t len = strnlen(payload, MAX_MESSAGE_SIZE - 1);
// payload.bytes is not NUL-terminated, so bound by the received size too: a shorter message
// stored after a longer one would otherwise pick up the previous occupant's trailing bytes.
size_t avail = packet.decoded.payload.size;
if (avail > MAX_MESSAGE_SIZE - 1)
avail = MAX_MESSAGE_SIZE - 1;
size_t len = strnlen(payload, avail);
sm.textOffset = storeTextInPool(payload, len);
sm.textLength = len;
+17 -1
View File
@@ -566,7 +566,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
// technically speaking this should work for all(?) NRF52 boards
// but needs testing across multiple devices. NRF52 USB would not even work if
// VBUS was not properly connected and detected by the CPU
#elif defined(MUZI_BASE) || defined(PROMICRO_DIY_TCXO)
#elif defined(MUZI_BASE) || defined(PROMICRO_DIY_TCXO) || defined(ELECROW_ThinkNode_M8)
return powerHAL_isVBUSConnected();
#endif
return getBattVoltage() > chargingVolt;
@@ -1401,6 +1401,22 @@ bool Power::axpChipInit()
PMU->disablePowerOutput(XPOWERS_DLDO1); // Invalid power channel, it does not exist
PMU->disablePowerOutput(XPOWERS_DLDO2); // Invalid power channel, it does not exist
PMU->disablePowerOutput(XPOWERS_VBACKUP);
} else if (HW_VENDOR == meshtastic_HardwareModel_TBEAM_BPF) {
// T-Beam BPF rail map (per schematic LilyGo_TBeam_BPF r2025-05-08):
// DCDC1 -> ESP32 + OLED 3V3 (always on, protected)
// ALDO2 -> MicroSD 3V3 (OFF at reset, must enable)
// ALDO4 -> L76K GNSS 3V3 (OFF at reset, must enable)
// ALDO1/3, BLDO1/2, DLDO1 -> user headers / unused at boot, leave at reset defaults.
// LoRa power is outside the PMU (external P-MOSFET switched by RF95_POWER_EN / IO16).
PMU->setPowerChannelVoltage(XPOWERS_ALDO4, 3300);
PMU->enablePowerOutput(XPOWERS_ALDO4);
PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300);
PMU->enablePowerOutput(XPOWERS_ALDO2);
// Make sure nothing's driving into an unused rail
PMU->disablePowerOutput(XPOWERS_DCDC5);
PMU->disablePowerOutput(XPOWERS_DLDO1);
}
// disable all axp chip interrupt
+33
View File
@@ -0,0 +1,33 @@
// See UptimeClock.h for the full contract.
#include "UptimeClock.h"
#include <Arduino.h>
uint32_t Time::getMillis()
{
#ifdef PIO_UNIT_TESTING
if (Time::useTestClock)
return Time::testNowMs;
#endif
return millis();
}
uint64_t Time::getMillis64()
{
static uint32_t lastLow = 0; // last 32-bit sample
static uint32_t highWord = 0; // number of observed wraps
uint32_t now = Time::getMillis();
#ifdef PIO_UNIT_TESTING
// A test swapping clock sources (real <-> injected) can make `now` jump backward for
// reasons other than a genuine wrap - rebase rather than miscount it as one.
if (Time::clockSourceChanged) {
lastLow = now;
highWord = 0;
Time::clockSourceChanged = false;
}
#endif
if (now < lastLow)
highWord++; // low word wrapped since last call
lastLow = now;
return (static_cast<uint64_t>(highWord) << 32) | now;
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <cstdint>
// Monotonic uptime clock, injectable so tests can drive a virtual timebase instead of sleeping.
// Uptime only; see gps/RTC.h for wall-clock. Not named Time.h: -Isrc would shadow C's <time.h>.
namespace Time
{
#ifdef PIO_UNIT_TESTING
// Test-only virtual clock; OFF by default so suites relying on real time are unaffected.
inline uint32_t testNowMs = 0;
inline bool useTestClock = false;
inline bool clockSourceChanged = true; // forces getMillis64() to rebase its wrap accumulator
inline void setTestMillis(uint32_t ms)
{
testNowMs = ms;
useTestClock = true;
clockSourceChanged = true;
}
inline void advanceTestMillis(uint32_t deltaMs)
{
// Advancing from 0 after getMillis64() sampled the real clock steps backward, which would
// otherwise be miscounted as a wrap.
if (!useTestClock)
clockSourceChanged = true;
testNowMs += deltaMs;
useTestClock = true;
}
// Restore real-clock behaviour (call in test tearDown if a suite mixes real and fake time).
inline void useRealClock()
{
useTestClock = false;
testNowMs = 0;
clockSourceChanged = true;
}
#endif
/// Milliseconds since boot, 32-bit (wraps ~49.7 days). Drop-in for millis().
uint32_t getMillis();
/// Milliseconds since boot, 64-bit, rollover-immune. Must be polled at least once per ~49.7-day
/// wrap window to catch every wrap, and keeps mutable static carry state, so it is NOT ISR-safe.
uint64_t getMillis64();
} // namespace Time
+3 -3
View File
@@ -37,9 +37,9 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
{
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P, ISM330DHCX};
return firstOfOrNONE(12, types);
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, SC7A20, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, BMM150, BMI270, ICM42607P, ISM330DHCX, QMA6100P};
return firstOfOrNONE(13, types);
}
ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
+1
View File
@@ -46,6 +46,7 @@ class ScanI2C
QMA6100P,
MPU6050,
LIS3DH,
SC7A20, // LIS3DH register map, different WHO_AM_I
BMA423,
BQ24295,
LSM6DS3,
+4
View File
@@ -691,6 +691,10 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
if (registerValue == 0x3300 || registerValue == 0x3333) { // RAK4631 WisBlock has LIS3DH register at 0x3333
type = LIS3DH;
logFoundDevice("LIS3DH", (uint8_t)addr.address);
} else if ((registerValue & 0xFF00) == 0x1100) {
// Silan SC7A20: LIS3DH register map, but answers 0x11 here.
type = SC7A20;
logFoundDevice("SC7A20", (uint8_t)addr.address);
} else {
type = BMA423;
logFoundDevice("BMA423", (uint8_t)addr.address);
+11 -6
View File
@@ -2,6 +2,7 @@
#include "configuration.h"
#include "detect/ScanI2C.h"
#include "main.h"
#include "mesh/MeshService.h"
#include "modules/NodeInfoModule.h"
#include <Throttle.h>
#include <sys/time.h>
@@ -17,12 +18,16 @@ uint32_t lastSetFromPhoneNtpOrGps = 0;
static uint32_t lastTimeValidationWarning = 0;
static const uint32_t TIME_VALIDATION_WARNING_INTERVAL_MS = 15000; // 15 seconds
static void triggerNodeInfoCheckOnTimeSource(RTCQuality oldQuality, RTCQuality newQuality)
static void onTimeSourceQualityChanged(RTCQuality oldQuality, RTCQuality newQuality)
{
if (oldQuality == RTCQualityNone && newQuality > RTCQualityNone && nodeInfoModule) {
LOG_DEBUG("Time source acquired (%s -> %s), triggering NodeInfo recheck", RtcName(oldQuality), RtcName(newQuality));
nodeInfoModule->triggerImmediateNodeInfoCheck();
}
if (oldQuality < RTCQualityFromNet && newQuality >= RTCQualityFromNet && service) {
LOG_DEBUG("RTC net quality reached (%s -> %s), reconciling rx_time", RtcName(oldQuality), RtcName(newQuality));
service->reconcilePendingRxTimes();
}
}
RTCQuality getRTCQuality()
@@ -128,7 +133,7 @@ RTCSetResult readFromRTC()
timeStartMsec = now;
zeroOffsetSecs = tv.tv_sec;
currentQuality = RTCQualityDevice;
triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality);
onTimeSourceQualityChanged(oldQuality, currentQuality);
}
return RTCSetResultSuccess;
} else {
@@ -174,7 +179,7 @@ RTCSetResult readFromRTC()
timeStartMsec = now;
zeroOffsetSecs = tv.tv_sec;
currentQuality = RTCQualityDevice;
triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality);
onTimeSourceQualityChanged(oldQuality, currentQuality);
}
return RTCSetResultSuccess;
} else {
@@ -210,7 +215,7 @@ RTCSetResult readFromRTC()
timeStartMsec = now;
zeroOffsetSecs = tv.tv_sec;
currentQuality = RTCQualityDevice;
triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality);
onTimeSourceQualityChanged(oldQuality, currentQuality);
}
return RTCSetResultSuccess;
}
@@ -235,7 +240,7 @@ RTCSetResult readFromRTC()
timeStartMsec = now;
zeroOffsetSecs = tv.tv_sec;
currentQuality = RTCQualityDevice;
triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality);
onTimeSourceQualityChanged(oldQuality, currentQuality);
}
return RTCSetResultSuccess;
}
@@ -381,7 +386,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
#endif
readFromRTC();
triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality);
onTimeSourceQualityChanged(oldQuality, currentQuality);
return RTCSetResultSuccess;
} else {
return RTCSetResultNotSet; // RTC was already set with a higher quality time
+71
View File
@@ -0,0 +1,71 @@
#include "graphics/Backlight.h"
#if HAS_PWM_BACKLIGHT
#include "mesh/NodeDB.h"
namespace graphics
{
namespace
{
bool pinConfigured = false;
// Level restored when the backlight is switched back on after being toggled off.
uint8_t lastOnLevel = PWM_BACKLIGHT_DEFAULT;
void drive(uint8_t level)
{
if (!pinConfigured) {
pinMode(PIN_PWM_BACKLIGHT, OUTPUT);
pinConfigured = true;
}
analogWrite(PIN_PWM_BACKLIGHT, level);
}
} // namespace
void backlightSet(uint8_t level)
{
if (level > 0)
lastOnLevel = level;
uiconfig.screen_brightness = level;
drive(level);
}
uint8_t backlightGet()
{
return uiconfig.screen_brightness;
}
void backlightOn()
{
drive(uiconfig.screen_brightness);
}
void backlightOff()
{
drive(0);
}
void backlightToggle()
{
backlightSet(uiconfig.screen_brightness > 0 ? 0 : lastOnLevel);
}
void backlightStepUp()
{
uint16_t raised = (uint16_t)uiconfig.screen_brightness + PWM_BACKLIGHT_STEP;
backlightSet(raised > PWM_BACKLIGHT_MAX ? PWM_BACKLIGHT_MAX : (uint8_t)raised);
}
void backlightStepDown()
{
// Leave an off backlight off; otherwise clamp at the minimum.
if (uiconfig.screen_brightness == 0)
return;
backlightSet(uiconfig.screen_brightness <= PWM_BACKLIGHT_MIN + PWM_BACKLIGHT_STEP
? PWM_BACKLIGHT_MIN
: uiconfig.screen_brightness - PWM_BACKLIGHT_STEP);
}
} // namespace graphics
#endif // HAS_PWM_BACKLIGHT
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include "configuration.h"
// PWM backlight control. A variant opts in by defining PIN_PWM_BACKLIGHT, optionally with
// PWM_BACKLIGHT_DEFAULT, _MIN, _MAX and _STEP. Levels are 0..255 in uiconfig.screen_brightness.
#if defined(PIN_PWM_BACKLIGHT)
#define HAS_PWM_BACKLIGHT 1
#else
#define HAS_PWM_BACKLIGHT 0
#endif
#if HAS_PWM_BACKLIGHT
#ifndef PWM_BACKLIGHT_DEFAULT
#define PWM_BACKLIGHT_DEFAULT 128
#endif
#ifndef PWM_BACKLIGHT_MIN
#define PWM_BACKLIGHT_MIN 8
#endif
#ifndef PWM_BACKLIGHT_MAX
#define PWM_BACKLIGHT_MAX 248
#endif
#ifndef PWM_BACKLIGHT_STEP
#define PWM_BACKLIGHT_STEP 20
#endif
namespace graphics
{
void backlightSet(uint8_t level);
uint8_t backlightGet();
void backlightOn(); // drive the stored level
void backlightOff(); // drive 0, leaving the stored level alone
void backlightToggle();
void backlightStepUp();
void backlightStepDown();
} // namespace graphics
#endif // HAS_PWM_BACKLIGHT
+4 -2
View File
@@ -1,4 +1,5 @@
#include "configuration.h"
#include "graphics/Backlight.h"
#if defined(USE_EINK) && !defined(USE_EINK_PARALLELDISPLAY)
#include "EInkDisplay2.h"
@@ -158,13 +159,14 @@ bool EInkDisplay::connect()
#endif
#endif
#if defined(TTGO_T_ECHO) || defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(TTGO_T_ECHO_PLUS)
#if defined(TTGO_T_ECHO) || defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(TTGO_T_ECHO_PLUS) || \
defined(ELECROW_ThinkNode_M8)
{
auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1);
adafruitDisplay = new GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
adafruitDisplay->init();
#if defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE)
#if defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(ELECROW_ThinkNode_M8)
adafruitDisplay->setRotation(4);
#else
adafruitDisplay->setRotation(3);
+29 -8
View File
@@ -61,6 +61,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "error.h"
#include "gps/GeoCoord.h"
#include "gps/RTC.h"
#include "graphics/Backlight.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTPalette.h"
@@ -691,7 +692,9 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
dispdev->displayOn();
#endif
#ifdef PIN_EINK_EN
#if HAS_PWM_BACKLIGHT
graphics::backlightOn();
#elif defined(PIN_EINK_EN)
if (uiconfig.screen_brightness == 1)
digitalWrite(PIN_EINK_EN, HIGH);
#elif defined(PCA_PIN_EINK_EN)
@@ -751,7 +754,9 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
drawLockdownLockScreen(dispdev);
#endif
#ifdef PIN_EINK_EN
#if HAS_PWM_BACKLIGHT
graphics::backlightOff();
#elif defined(PIN_EINK_EN)
digitalWrite(PIN_EINK_EN, LOW);
#elif defined(PCA_PIN_EINK_EN)
io.digitalWrite(PCA_PIN_EINK_EN, LOW);
@@ -1848,8 +1853,12 @@ void Screen::handleStartFirmwareUpdateScreen()
void Screen::increaseBrightness()
{
#if HAS_PWM_BACKLIGHT
graphics::backlightStepUp();
brightness = graphics::backlightGet();
#else
brightness = ((brightness + 62) > 254) ? brightness : (brightness + 62);
#endif
#if defined(ST7789_CS)
// run the setDisplayBrightness function. This works on t-decks
static_cast<TFTDisplay *>(dispdev)->setDisplayBrightness(brightness);
@@ -1860,8 +1869,12 @@ void Screen::increaseBrightness()
void Screen::decreaseBrightness()
{
#if HAS_PWM_BACKLIGHT
graphics::backlightStepDown();
brightness = graphics::backlightGet();
#else
brightness = (brightness < 70) ? brightness : (brightness - 62);
#endif
#if defined(ST7789_CS)
static_cast<TFTDisplay *>(dispdev)->setDisplayBrightness(brightness);
#endif
@@ -2057,10 +2070,18 @@ int Screen::handleInputEvent(const InputEvent *event)
}
#ifdef USE_EINK // the screen is the last input handler, so if an event makes it here, we can assume it will prompt a screen draw.
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please
EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update
handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?)
setFastFramerate(); // Draw ASAP
#if HAS_PWM_BACKLIGHT
// A PWM backlight change leaves the frame identical, and the refresh is blocking.
const bool backlightOnly =
event->kbchar == INPUT_BROKER_MSG_BRIGHTNESS_UP || event->kbchar == INPUT_BROKER_MSG_BRIGHTNESS_DOWN;
if (!backlightOnly)
#endif
{
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please
EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update
handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?)
setFastFramerate(); // Draw ASAP
}
#endif
if (NotificationRenderer::isOverlayBannerShowing()) {
NotificationRenderer::inEvent = *event;
+17 -2
View File
@@ -10,6 +10,7 @@
#include "MessageStore.h"
#include "NodeDB.h"
#include "buzz.h"
#include "graphics/Backlight.h"
#include "graphics/Screen.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTColorRegions.h"
@@ -227,6 +228,16 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool
void menuHandler::LoraRegionPicker(uint32_t duration)
{
#ifdef HAS_HAM_2M_ONLY
// Hardware is restricted to the amateur 2m band - offer only the 2m regions
// so the user cannot pick a sub-GHz region the RF path cannot emit or receive.
static const LoraRegionOption regionOptions[] = {
{"Back", OptionsAction::Back},
{"ITU1_2M (144-146)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M},
{"ITU2_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_2M},
{"ITU3_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU3_2M},
};
#else
static const LoraRegionOption regionOptions[] = {
{"Back", OptionsAction::Back},
{"US", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_US},
@@ -265,6 +276,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
{"ITU3_70CM (430-450)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU3_70CM},
};
#endif
constexpr size_t regionCount = sizeof(regionOptions) / sizeof(regionOptions[0]);
static std::array<const char *, regionCount> regionLabels{};
@@ -1097,7 +1109,7 @@ void menuHandler::homeBaseMenu()
}
optionsEnumArray[options++] = Mute;
}
#if defined(PIN_EINK_EN) || defined(PCA_PIN_EINK_EN)
#if HAS_PWM_BACKLIGHT || defined(PIN_EINK_EN) || defined(PCA_PIN_EINK_EN)
optionsArray[options] = "Toggle Backlight";
optionsEnumArray[options++] = Backlight;
#else
@@ -1127,7 +1139,10 @@ void menuHandler::homeBaseMenu()
}
} else if (selected == Backlight) {
screen->setOn(false);
#if defined(PIN_EINK_EN)
#if HAS_PWM_BACKLIGHT
graphics::backlightToggle();
saveUIConfig();
#elif defined(PIN_EINK_EN)
if (uiconfig.screen_brightness == 1) {
uiconfig.screen_brightness = 0;
digitalWrite(PIN_EINK_EN, LOW);
+7
View File
@@ -11,6 +11,8 @@
#ifdef ARCH_PORTDUINO
#include "PortduinoGlue.h"
#include <cstdio>
#include <cstdlib>
#include <thread>
#endif
@@ -60,6 +62,11 @@ void tftSetup(void)
} else
#elif defined(USE_FRAMEBUFFER)
if (portduino_config.displayPanel == fb) {
// Rotation from yaml Display.OffsetRotate: 1=90, 2=180, 3=270 deg
char rbuf[4];
snprintf(rbuf, sizeof(rbuf), "%d", portduino_config.displayRotate ? (portduino_config.displayOffsetRotate & 3) : 0);
if (setenv("MESHTASTIC_FB_ROTATION", rbuf, 1) != 0)
LOG_ERROR("Failed to set MESHTASTIC_FB_ROTATION, framebuffer will use its default rotation");
if (portduino_config.displayWidth && portduino_config.displayHeight)
displayConfig = DisplayDriverConfig(DisplayDriverConfig::device_t::FB, (uint16_t)portduino_config.displayWidth,
(uint16_t)portduino_config.displayHeight);
+7 -1
View File
@@ -227,7 +227,13 @@ int32_t ButtonThread::runOnce()
case BUTTON_EVENT_DOUBLE_PRESSED: { // not wired in if screen detected
LOG_INFO("Double press!");
#if defined(ELECROW_ThinkNode_M8)
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED)
config.device.buzzer_mode = meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED;
else if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED)
config.device.buzzer_mode = meshtastic_Config_DeviceConfig_BuzzerMode_ALL_ENABLED;
service->reloadConfig(SEGMENT_CONFIG);
#endif
// Reset combination tracking
waitingForLongPress = false;
+40 -6
View File
@@ -35,11 +35,12 @@
#endif
#if HAS_BUTTON || defined(ARCH_PORTDUINO)
#include "graphics/Backlight.h"
#include "input/ButtonThread.h"
#if defined(BUTTON_PIN_TOUCH)
ButtonThread *TouchButtonThread = nullptr;
#if defined(PIN_EINK_EN)
#if HAS_PWM_BACKLIGHT || defined(PIN_EINK_EN)
static bool touchBacklightWasOn = false;
static bool touchBacklightActive = false;
#endif
@@ -241,20 +242,28 @@ void InputBroker::Init()
};
touchConfig.singlePress = INPUT_BROKER_NONE;
touchConfig.longPress = INPUT_BROKER_BACK;
#if defined(PIN_EINK_EN)
// Touch pad drives the backlight on devices with e-ink backlight pin
#if HAS_PWM_BACKLIGHT || defined(PIN_EINK_EN)
// Touch pad drives the backlight on devices that have one
touchConfig.longPress = INPUT_BROKER_NONE;
touchConfig.suppressLeadUpSound = true;
touchConfig.onPress = []() {
touchBacklightWasOn = uiconfig.screen_brightness == 1;
touchBacklightWasOn = uiconfig.screen_brightness > 0;
if (!touchBacklightWasOn) {
#if HAS_PWM_BACKLIGHT
graphics::backlightOn();
#else
digitalWrite(PIN_EINK_EN, HIGH);
#endif
}
touchBacklightActive = true;
};
touchConfig.onRelease = []() {
if (touchBacklightActive && !touchBacklightWasOn) {
#if HAS_PWM_BACKLIGHT
graphics::backlightOff();
#else
digitalWrite(PIN_EINK_EN, LOW);
#endif
}
touchBacklightActive = false;
};
@@ -327,6 +336,30 @@ void InputBroker::Init()
#define BUTTON_ACTIVE_PULLUP true
#endif
#if defined(ELECROW_ThinkNode_M8)
// Rotary encoder drives the UI, so the function button keeps a fixed map.
LOG_DEBUG("ThinkNode_M8 button");
UserButtonThread = new ButtonThread("FunctionButton");
{
ButtonConfig userConfig;
userConfig.pinNumber = (uint8_t)_pinNum;
userConfig.activeLow = BUTTON_ACTIVE_LOW;
userConfig.activePullup = BUTTON_ACTIVE_PULLUP;
userConfig.pullupSense = pullup_sense;
userConfig.intRoutine = []() {
UserButtonThread->userButton.tick();
UserButtonThread->setIntervalFromNow(0);
runASAP = true;
BaseType_t higherWake = 0;
concurrency::mainDelay.interruptFromISR(&higherWake);
};
userConfig.singlePress = INPUT_BROKER_SEND_PING;
userConfig.longPress = INPUT_BROKER_SHUTDOWN;
userConfig.longPressTime = 5000;
userConfig.doublePress = INPUT_BROKER_GPS_TOGGLE;
UserButtonThread->initButton(userConfig);
}
#else
// Buttons. Moved here cause we need NodeDB to be initialized
// If your variant.h has a BUTTON_PIN defined, go ahead and define BUTTON_ACTIVE_LOW and BUTTON_ACTIVE_PULLUP
UserButtonThread = new ButtonThread("UserButton");
@@ -378,8 +411,9 @@ void InputBroker::Init()
userConfigNoScreen.triplePress = INPUT_BROKER_GPS_TOGGLE;
UserButtonThread->initButton(userConfigNoScreen);
}
#endif
#endif
#endif // ELECROW_ThinkNode_M8
#endif // BUTTON_PIN
#endif // HAS_BUTTON
#if (HAS_BUTTON || ARCH_PORTDUINO) && !MESHTASTIC_EXCLUDE_INPUTBROKER
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
+44 -6
View File
@@ -56,24 +56,50 @@ int32_t RotaryEncoderInterruptBase::runOnce()
if (!pressDetected && buttonPressed) {
pressDetected = true;
pressStartTime = now;
pressAndTurnFired = false;
}
if (pressDetected) {
// Press-and-turn takes precedence over the press itself.
if (pressAndTurnEnabled() && pressAndTurnDelta.load(std::memory_order_relaxed) != 0) {
// Drain in one pass: releasing the button would discard anything left over.
// Exchange, so a detent arriving from the ISR mid-drain is not lost.
int32_t pending = pressAndTurnDelta.exchange(0, std::memory_order_relaxed);
LOG_DEBUG("Rotary event Press %s (%d detents)", pending > 0 ? "CW" : "CCW", pending);
while (pending != 0) {
bool cw = pending > 0;
InputEvent turn = {};
turn.source = this->_originName;
turn.inputEvent = INPUT_BROKER_NONE;
turn.kbchar = cw ? _pressAndTurnCw : _pressAndTurnCcw;
pending -= cw ? 1 : -1;
this->notifyObservers(&turn);
}
pressAndTurnFired = true;
}
uint32_t duration = now - pressStartTime;
if (!buttonPressed) {
// released -> if short press, send short, else already sent long
if (duration < LONG_PRESS_DURATION && now - lastPressKeyTime >= pressDebounceMs) {
if (!pressAndTurnFired && duration < LONG_PRESS_DURATION && now - lastPressKeyTime >= pressDebounceMs) {
lastPressKeyTime = now;
LOG_DEBUG("Rotary event Press short");
e.inputEvent = this->_eventPressed;
} else if (pressAndTurnEnabled() && !pressAndTurnFired && duration >= LONG_PRESS_DURATION &&
this->_eventPressedLong != INPUT_BROKER_NONE) {
// Held long enough and no turn came, so the long press stands
LOG_DEBUG("Rotary event Press long");
e.inputEvent = this->_eventPressedLong;
}
pressDetected = false;
pressStartTime = 0;
lastPressLongEventTime = 0;
pressAndTurnDelta.store(0, std::memory_order_relaxed);
pressAndTurnFired = false;
this->action = ROTARY_ACTION_NONE;
} else if (duration >= LONG_PRESS_DURATION && this->_eventPressedLong != INPUT_BROKER_NONE &&
lastPressLongEventTime == 0) {
// fire single-shot long press
} else if (!pressAndTurnEnabled() && duration >= LONG_PRESS_DURATION &&
this->_eventPressedLong != INPUT_BROKER_NONE && lastPressLongEventTime == 0) {
// fire single-shot long press; press-and-turn encoders defer this to release
lastPressLongEventTime = now;
LOG_DEBUG("Rotary event Press long");
e.inputEvent = this->_eventPressedLong;
@@ -87,7 +113,7 @@ int32_t RotaryEncoderInterruptBase::runOnce()
e.inputEvent = this->_eventCcw;
}
if (e.inputEvent != INPUT_BROKER_NONE) {
if (e.inputEvent != INPUT_BROKER_NONE || e.kbchar != 0) {
this->notifyObservers(&e);
}
@@ -95,11 +121,20 @@ int32_t RotaryEncoderInterruptBase::runOnce()
this->action = ROTARY_ACTION_NONE;
} else if (now - pressStartTime < LONG_PRESS_DURATION) {
return (20); // keep checking for long/short until time expires
} else if (pressAndTurnEnabled()) {
// Keep polling while held, rather than relying on intHandler()'s reschedule from ISR.
return (20);
}
return INT32_MAX;
}
void RotaryEncoderInterruptBase::setPressAndTurnChars(unsigned char cw, unsigned char ccw)
{
this->_pressAndTurnCw = cw;
this->_pressAndTurnCcw = ccw;
}
void RotaryEncoderInterruptBase::intPressHandler()
{
this->action = ROTARY_ACTION_PRESSED;
@@ -145,7 +180,10 @@ RotaryEncoderInterruptBaseStateType RotaryEncoderInterruptBase::intHandler(bool
if (actualPinRaising && (otherPinLevel == LOW)) {
if (state == ROTARY_EVENT_CLEARED) {
newState = ROTARY_EVENT_OCCURRED;
if ((this->action != ROTARY_ACTION_PRESSED) && (this->action != action)) {
if (this->action == ROTARY_ACTION_PRESSED) {
// Turning while held; runOnce() ignores this unless press-and-turn is enabled.
pressAndTurnDelta.fetch_add((action == ROTARY_ACTION_CW) ? 1 : -1, std::memory_order_relaxed);
} else {
this->action = action;
}
}
+14 -1
View File
@@ -3,9 +3,9 @@
#include "InputBroker.h"
#include "concurrency/OSThread.h"
#include "mesh/NodeDB.h"
#include <atomic>
enum RotaryEncoderInterruptBaseStateType { ROTARY_EVENT_OCCURRED, ROTARY_EVENT_CLEARED };
enum RotaryEncoderInterruptBaseActionType { ROTARY_ACTION_NONE, ROTARY_ACTION_PRESSED, ROTARY_ACTION_CW, ROTARY_ACTION_CCW };
class RotaryEncoderInterruptBase : public Observable<const InputEvent *>, public concurrency::OSThread
@@ -20,6 +20,10 @@ class RotaryEncoderInterruptBase : public Observable<const InputEvent *>, public
void intAHandler();
void intBHandler();
// Turning while the button is held emits `cw` / `ccw` as kbchar and suppresses the press
// event. Both default to 0, leaving rotation-while-pressed ignored.
void setPressAndTurnChars(unsigned char cw, unsigned char ccw);
protected:
virtual int32_t runOnce() override;
RotaryEncoderInterruptBaseStateType intHandler(bool actualPinRaising, int otherPinLevel,
@@ -31,6 +35,8 @@ class RotaryEncoderInterruptBase : public Observable<const InputEvent *>, public
volatile int rotaryLevelA = LOW;
volatile int rotaryLevelB = LOW;
volatile RotaryEncoderInterruptBaseActionType action = ROTARY_ACTION_NONE;
// Detents counted while the button is held: positive clockwise, negative counter-clockwise.
std::atomic<int32_t> pressAndTurnDelta{0};
private:
// pins and events
@@ -43,9 +49,16 @@ class RotaryEncoderInterruptBase : public Observable<const InputEvent *>, public
input_broker_event _eventPressedLong = INPUT_BROKER_NONE;
const char *_originName;
// Press-and-turn characters, 0 when the gesture is not in use
unsigned char _pressAndTurnCw = 0;
unsigned char _pressAndTurnCcw = 0;
bool pressAndTurnEnabled() const { return _pressAndTurnCw != 0 || _pressAndTurnCcw != 0; }
// Long press detection variables
uint32_t pressStartTime = 0;
bool pressDetected = false;
// Suppresses the press event on release once press-and-turn has fired.
bool pressAndTurnFired = false;
uint32_t lastPressLongEventTime = 0;
unsigned long lastPressKeyTime = 0;
static const uint32_t LONG_PRESS_DURATION = 300; // ms
@@ -26,6 +26,9 @@ bool RotaryEncoderInterruptImpl1::init()
RotaryEncoderInterruptBase::init(pinA, pinB, pinPress, eventCw, eventCcw, eventPressed, eventPressedLong,
RotaryEncoderInterruptImpl1::handleIntA, RotaryEncoderInterruptImpl1::handleIntB,
RotaryEncoderInterruptImpl1::handleIntPressed);
#if defined(INPUTDRIVER_PRESS_AND_TURN_CW) && defined(INPUTDRIVER_PRESS_AND_TURN_CCW)
setPressAndTurnChars(INPUTDRIVER_PRESS_AND_TURN_CW, INPUTDRIVER_PRESS_AND_TURN_CCW);
#endif
inputBroker->registerSource(this);
#ifndef HAS_PHYSICAL_KEYBOARD
osk_found = true;
+1 -1
View File
@@ -92,7 +92,7 @@ uint32_t Default::getConfiguredOrMinimumValue(uint32_t configured, uint32_t minV
uint8_t Default::getConfiguredOrDefaultHopLimit(uint8_t configured)
{
#if USERPREFS_EVENT_MODE
return (configured > HOP_RELIABLE) ? HOP_RELIABLE : config.lora.hop_limit;
return (configured >= eventModeHopLimit) ? eventModeHopLimit : config.lora.hop_limit;
#else
return (configured >= HOP_MAX) ? HOP_MAX : config.lora.hop_limit;
#endif
+15 -1
View File
@@ -5,6 +5,7 @@
#include <cmath>
#include <cstdint>
#include <meshUtils.h>
#include <type_traits>
#define ONE_DAY 24 * 60 * 60
#define ONE_MINUTE_MS 60 * 1000
#define THIRTY_SECONDS_MS 30 * 1000
@@ -75,7 +76,20 @@ enum class TrafficType { POSITION, TELEMETRY };
class Default
{
#if USERPREFS_EVENT_MODE && defined(USERPREFS_EVENT_MODE_HOP_LIMIT)
static constexpr auto eventModeHopLimitSetting = USERPREFS_EVENT_MODE_HOP_LIMIT;
#else
static constexpr auto eventModeHopLimitSetting = HOP_RELIABLE;
#endif
using EventModeHopLimitType = typename std::remove_cv<decltype(eventModeHopLimitSetting)>::type;
static_assert(std::is_integral<EventModeHopLimitType>::value && !std::is_same<EventModeHopLimitType, bool>::value &&
eventModeHopLimitSetting >= 0 && eventModeHopLimitSetting <= HOP_MAX,
"USERPREFS_EVENT_MODE_HOP_LIMIT must be an integer between 0 and 7");
public:
static constexpr uint8_t eventModeHopLimit = static_cast<uint8_t>(eventModeHopLimitSetting);
static constexpr uint8_t eventModeRelayHopLimit = eventModeHopLimit > 0 ? eventModeHopLimit - 1 : 0;
static uint32_t getConfiguredOrDefaultMs(uint32_t configuredInterval);
static uint32_t getConfiguredOrDefaultMs(uint32_t configuredInterval, uint32_t defaultInterval);
static uint32_t getConfiguredOrDefault(uint32_t configured, uint32_t defaultValue);
@@ -129,4 +143,4 @@ class Default
return 1.0 + (nodesOverForty * throttlingFactor); // Each number of online node scales by throttle factor
}
}
};
};
+1 -1
View File
@@ -27,7 +27,7 @@ template class LR11x0Interface<LR1110>;
template class LR11x0Interface<LR1120>;
template class LR11x0Interface<LR1121>;
#endif
#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1
#if (defined(USE_LR2021) || defined(ARCH_PORTDUINO)) && RADIOLIB_EXCLUDE_LR2021 != 1
template class LR20x0Interface<LR2021>;
#endif
#ifdef ARCH_STM32WL
+5
View File
@@ -247,6 +247,7 @@ template <typename T> void LR11x0Interface<T>::addReceiveMetadata(meshtastic_Mes
// LOG_DEBUG("PacketStatus %x", lora.getPacketStatus());
mp->rx_snr = lora.getSNR();
mp->rx_rssi = lround(lora.getRSSI());
mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding
LOG_DEBUG("Corrected frequency offset: %f", lora.getFrequencyError());
}
@@ -378,4 +379,8 @@ template <typename T> int16_t LR11x0Interface<T>::getCurrentRSSI()
#endif
return (int16_t)round(rssi);
}
// Don't leak the aliases into the files InterfacesTemplates.cpp includes after this one.
#undef rfswitch_dio_pins
#undef rfswitch_table
#endif
+1 -1
View File
@@ -1,6 +1,6 @@
#include "configuration.h"
#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1
#if (defined(USE_LR2021) || defined(ARCH_PORTDUINO)) && RADIOLIB_EXCLUDE_LR2021 != 1
#include "LR2021Interface.h"
#include "error.h"
+7 -1
View File
@@ -1,6 +1,6 @@
#include "configuration.h"
#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1
#if (defined(USE_LR2021) || defined(ARCH_PORTDUINO)) && RADIOLIB_EXCLUDE_LR2021 != 1
#include "LR20x0Interface.h"
#include "error.h"
#include "mesh/NodeDB.h"
@@ -253,6 +253,7 @@ template <typename T> void LR20x0Interface<T>::addReceiveMetadata(meshtastic_Mes
// LOG_DEBUG("PacketStatus %x", lora.getPacketStatus());
mp->rx_snr = lora.getSNR();
mp->rx_rssi = lround(lora.getRSSI());
mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding
// LOG_DEBUG("Corrected frequency offset: %f", lora.getFrequencyError()); // not implemented for LR20x0, but noop for LR11x0
// too(!)
}
@@ -380,4 +381,9 @@ template <typename T> int16_t LR20x0Interface<T>::getCurrentRSSI()
float rssi = lora.getRSSI(false, true);
return (int16_t)round(rssi);
}
// Don't leak the aliases into the files InterfacesTemplates.cpp includes after this one.
#undef lr20x0_rfswitch_dio_pins
#undef lr20x0_rfswitch_table
#undef LR20x0
#endif
+2
View File
@@ -87,6 +87,8 @@ meshtastic_MeshPacket *MeshModule::allocErrorResponse(meshtastic_Routing_Error e
uint8_t channelIndex =
p->which_payload_variant == meshtastic_MeshPacket_decoded_tag ? p->channel : channels.getPrimaryIndex();
auto r = allocAckNak(err, getFrom(p), p->id, channelIndex);
if (!r) // pool exhausted; callers treat a null reply as "no response"
return nullptr;
setReplyTo(r, *p);
+44 -3
View File
@@ -12,6 +12,7 @@
#include "Power.h"
#include "PowerFSM.h"
#include "TypeConversions.h"
#include "UptimeClock.h"
#include "gps/RTC.h"
#include "graphics/draw/MessageRenderer.h"
#include "main.h"
@@ -180,6 +181,36 @@ NodeNum MeshService::getNodenumFromRequestId(uint32_t request_id)
return nodenum;
}
// Back-calculate the real epoch for any queued packet still carrying a millis() rx_time
// placeholder, now that the clock is trustworthy.
void MeshService::reconcilePendingRxTimes()
{
const uint32_t nowEpoch = getValidTime(RTCQualityFromNet);
if (nowEpoch == 0) // called before the clock was actually valid - nothing to reconcile against
return;
const uint32_t nowMillis = Time::getMillis();
// Rotate the queue once. TypedQueue is strictly FIFO on both backends, so dequeueing and
// re-enqueueing every element in turn leaves the delivery order unchanged.
for (int remaining = toPhoneQueue.numUsed(); remaining > 0; remaining--) {
meshtastic_MeshPacket *p = toPhoneQueue.dequeuePtr(0);
if (!p) // drained from under us - nothing left to rotate
break;
if (!p->has_rx_time) {
// Unsigned subtraction is wraparound-safe; rx_time is a 32-bit wire field, so the
// placeholder was never wider than 32 bits to begin with.
const uint32_t elapsedMs = nowMillis - p->rx_time;
p->rx_time = nowEpoch - (elapsedMs / 1000);
p->has_rx_time = true;
}
if (!toPhoneQueue.enqueue(p, 0)) { // mirrors sendToPhone()'s degrade-on-failure path
LOG_CRIT("Failed to requeue a packet into toPhoneQueue!");
releaseToPool(p);
fromNum++; // notify observers so the phone can resync
}
}
}
#if MESHTASTIC_ENABLE_FRAME_INJECTION
// Deliver a client-supplied frame into the receive pipeline as if it arrived off the LoRa chip. Mirrors
// the portduino SimRadio SIMULATOR_APP unwrap so the same host wire format works on real hardware: the
@@ -216,9 +247,13 @@ void MeshService::injectAsReceived(meshtastic_MeshPacket &p)
return;
if (mp->rx_snr == 0) // plausible synthetic link metadata unless the caller set it
mp->rx_snr = 8;
if (mp->rx_rssi == 0)
if (!mp->has_rx_rssi) { // rx_rssi has explicit presence; only fabricate if the caller didn't supply a real one
mp->rx_rssi = -40;
mp->rx_time = getValidTime(RTCQualityFromNet);
mp->has_rx_rssi = true;
}
// dispatchReceived() restamps this when the packet re-enters the pipeline below; stamp it
// here anyway so the packet is never observable with an unset arrival time.
stampRxTime(mp);
LOG_INFO("inject: RX from=0x%08x to=0x%08x id=0x%08x ch=%d %s", mp->from, mp->to, mp->id, mp->channel,
mp->which_payload_variant == meshtastic_MeshPacket_encrypted_tag ? "encrypted" : "decoded");
router->enqueueReceivedMessage(mp);
@@ -256,7 +291,8 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p)
if (p.id == 0)
p.id = generatePacketId(); // If the phone didn't supply one, then pick one
p.rx_time = getValidTime(RTCQualityFromNet); // Record the time the packet arrived from the phone
// Record the time the packet arrived from the phone.
stampRxTime(&p);
IF_SCREEN(if (p.decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP && p.decoded.payload.size > 0 &&
p.to != NODENUM_BROADCAST && p.to != 0) // DM only
@@ -593,6 +629,11 @@ bool MeshService::isToPhoneQueueEmpty()
uint32_t MeshService::GetTimeSinceMeshPacket(const meshtastic_MeshPacket *mp)
{
// rx_time may be a millis() placeholder while has_rx_time is false - don't age it as
// wall-clock, and don't pass it off as "just now" either.
if (!mp->has_rx_time)
return SINCE_UNKNOWN;
uint32_t now = getTime();
uint32_t last_seen = mp->rx_time;
+5
View File
@@ -137,6 +137,10 @@ class MeshService
// search the queue for a request id and return the matching nodenum
NodeNum getNodenumFromRequestId(uint32_t request_id);
// Rewrite any queued-for-phone packet still carrying a millis() rx_time placeholder into a
// real epoch, now that the wall clock is trustworthy.
void reconcilePendingRxTimes();
// Release QueueStatus packet to pool
void releaseQueueStatusToPool(meshtastic_QueueStatus *p) { queueStatusPool.release(p); }
@@ -205,6 +209,7 @@ class MeshService
ErrorCode sendQueueStatusToPhone(const meshtastic_QueueStatus &qs, ErrorCode res, uint32_t mesh_packet_id);
/// Seconds since the packet arrived, or SINCE_UNKNOWN if it carries no trustworthy rx_time.
uint32_t GetTimeSinceMeshPacket(const meshtastic_MeshPacket *mp);
private:
+23 -6
View File
@@ -1,4 +1,5 @@
#include "NextHopRouter.h"
#include "Default.h"
#include "MeshTypes.h"
#include "meshUtils.h"
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
@@ -9,6 +10,18 @@
#endif
#include "NodeDB.h"
#if USERPREFS_EVENT_MODE
static void capEventRelayHops(meshtastic_MeshPacket *packet)
{
if (packet->hop_limit <= Default::eventModeRelayHopLimit)
return;
const uint8_t reduction = packet->hop_limit - Default::eventModeRelayHopLimit;
packet->hop_start = reduction <= packet->hop_start ? packet->hop_start - reduction : 0;
packet->hop_limit = Default::eventModeRelayHopLimit;
}
#endif
NextHopRouter::NextHopRouter() {}
bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p)
@@ -26,8 +39,16 @@ bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p)
if (!relay)
return false;
relay->hop_limit--;
#if USERPREFS_EVENT_MODE
capEventRelayHops(relay);
#endif
relay->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum());
return Router::send(relay) == ERRNO_OK;
// The interface declines some packets (NODENUM_BROADCAST_NO_LORA) with ERRNO_SHOULD_RELEASE,
// which leaves the copy ours to free. Dropping it here would leak a pool slot per opaque frame.
ErrorCode res = Router::send(relay);
if (res == ERRNO_SHOULD_RELEASE)
packetPool.release(relay);
return res == ERRNO_OK;
}
PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions)
@@ -206,11 +227,7 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
LOG_INFO("favorite-ROUTER/CLIENT_BASE-to-ROUTER/CLIENT_BASE rebroadcast: preserving hop_limit");
}
#if USERPREFS_EVENT_MODE
if (tosend->hop_limit > 2) {
// if we are "correcting" the hop_limit, "correct" the hop_start by the same amount to preserve hops away.
tosend->hop_start -= (tosend->hop_limit - 2);
tosend->hop_limit = 2;
}
capEventRelayHops(tosend);
#endif
ErrorCode res =
+73 -15
View File
@@ -202,7 +202,9 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre
if (ostream) {
const auto *vec = static_cast<const std::vector<meshtastic_NodeInfoLite> *>(iter->pData);
for (auto item : *vec) {
item.snr_q4 = (int32_t)(item.snr * 4.0f);
// Round rather than truncate: truncation wiped any |SNR| < 0.25 dB to exactly
// 0, which collided with the "never stored" sentinel below.
item.snr_q4 = (int32_t)lroundf(item.snr * 4.0f);
item.snr = 0.0f;
if (!pb_encode_tag_for_field(ostream, iter))
return false;
@@ -214,8 +216,14 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
auto *vec = static_cast<std::vector<meshtastic_NodeInfoLite> *>(iter->pData);
if (pb_decode(istream, meshtastic_NodeInfoLite_fields, &node)) {
if (node.snr_q4)
// snr_q4 = 0 is byte-identical to "field never written" but 0 dB is valid.
// NODEINFO_BITFIELD_HAS_SNR_MASK disambiguates going forward; legacy
// records (bit clear) treat this as unknown.
if (nodeInfoLiteHasSnr(&node)) {
node.snr = node.snr_q4 / 4.0f;
} else if (node.snr_q4) {
node.snr = node.snr_q4 / 4.0f;
}
node.snr_q4 = 0;
vec->push_back(node);
}
@@ -733,6 +741,10 @@ void NodeDB::resetRadioConfig(bool is_fresh_install)
LOG_INFO("Set default channel and radio preferences!");
channels.initDefaults();
// Defaults ship the public PSK, so strip it again before onConfigChanged() publishes hashes;
// loadFromDisk's sanitation is a no-op when the channel file was absent or corrupt.
if (owner.is_licensed)
channels.ensureLicensedOperation();
}
channels.onConfigChanged();
@@ -919,7 +931,11 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY;
#endif
#if USERPREFS_EVENT_MODE
config.lora.hop_limit = Default::eventModeHopLimit;
#else
config.lora.hop_limit = HOP_RELIABLE;
#endif
#ifdef USERPREFS_CONFIG_LORA_IGNORE_MQTT
config.lora.ignore_mqtt = USERPREFS_CONFIG_LORA_IGNORE_MQTT;
#else
@@ -1254,6 +1270,15 @@ void NodeDB::installDefaultModuleConfig()
moduleConfig.external_notification.active = true;
#endif // NANO_G2_ULTRA
#ifdef ELECROW_ThinkNode_M8
moduleConfig.canned_message.rotary1_enabled = true;
moduleConfig.canned_message.inputbroker_pin_a = PIN_BUTTON_EC04_A;
moduleConfig.canned_message.inputbroker_pin_b = PIN_BUTTON_EC04_B;
moduleConfig.canned_message.inputbroker_pin_press = PIN_BUTTON_EC04;
moduleConfig.canned_message.inputbroker_event_cw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT;
moduleConfig.canned_message.inputbroker_event_ccw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT;
moduleConfig.canned_message.inputbroker_event_press = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT;
#endif
#ifdef T_LORA_PAGER
moduleConfig.canned_message.updown1_enabled = true;
moduleConfig.canned_message.inputbroker_pin_a = ROTARY_A;
@@ -1591,15 +1616,19 @@ void NodeDB::resetNodes(bool keepFavorites)
numMeshNodes = 1;
if (keepFavorites) {
LOG_INFO("Clearing node database - preserving favorites");
for (size_t i = 0; i < meshNodes->size(); i++) {
meshtastic_NodeInfoLite &node = meshNodes->at(i);
if (i > 0 && !nodeInfoLiteIsFavorite(&node)) {
eraseNodeSatellites(node.num);
node = meshtastic_NodeInfoLite();
} else {
// Compact favorites into contiguous low slots: zeroing in place leaves one above
// numMeshNodes, invisible to every `i < numMeshNodes` scan yet still serialized to flash.
for (size_t i = 1; i < meshNodes->size(); i++) {
const meshtastic_NodeInfoLite &node = meshNodes->at(i);
if (nodeInfoLiteIsFavorite(&node)) {
if (numMeshNodes != i)
meshNodes->at(numMeshNodes) = node;
numMeshNodes += 1;
} else if (node.num) {
eraseNodeSatellites(node.num);
}
};
}
std::fill(nodeDatabase.nodes.begin() + numMeshNodes, nodeDatabase.nodes.end(), meshtastic_NodeInfoLite());
} else {
LOG_INFO("Clearing node database - removing favorites");
for (size_t i = 1; i < meshNodes->size(); i++) {
@@ -1960,6 +1989,13 @@ void NodeDB::installDefaultDeviceState()
memcpy(owner.macaddr, ourMacAddr, sizeof(owner.macaddr));
owner.has_is_unmessagable = true;
owner.is_unmessagable = false;
#ifdef HAS_HAM_2M_ONLY
// Ham-band-only hardware defaults to licensed operation. The user can still flip this off later
// (e.g. a commercial operator on an adjacent allocation who wants to keep encryption on) - we
// only set the default here, not on every boot.
owner.is_licensed = true;
#endif
}
// We reserve a few nodenums for future use
@@ -3218,6 +3254,11 @@ uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n)
uint32_t sinceReceived(const meshtastic_MeshPacket *p)
{
// rx_time may be a millis() placeholder while has_rx_time is false - don't age it as
// wall-clock, and don't pass it off as "just now" either.
if (!p->has_rx_time)
return SINCE_UNKNOWN;
uint32_t now = getTime();
int delta = (int)(now - p->rx_time);
@@ -3480,10 +3521,9 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
*/
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) {
// Only a signed update may change the identity of a proven signer; our own record is exempt.
// Checked before getOrCreateMeshNode so a refusal cannot evict; isKnownXeddsaSigner covers the warm tier.
if (nodeId != getNodeNum() && isKnownXeddsaSigner(nodeId) && !xeddsaSigned) {
LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId);
return false;
}
@@ -3614,11 +3654,24 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
return;
}
if (mp.rx_time) // if the packet has a valid timestamp use it to update our last_heard
// Gate on has_rx_time, not truthiness - rx_time may hold a millis() placeholder.
if (mp.has_rx_time)
info->last_heard = mp.rx_time;
if (mp.rx_snr)
// Gate on the packet actually having been received over our own radio, not on rx_snr being
// truthy, because 0 dB is valid. TRANSPORT_LORA is set only on the real over-the-air RX path
// (RadioInterface.cpp); it excludes TRANSPORT_INTERNAL and TRANSPORT_MQTT, while still accepting
// an MQTT-origin packet that a gateway rebroadcasts onto LoRa - we genuinely measured that one
// ourselves. Mirrors hop histogram below.
// Belt-and-braces: also require has_rx_rssi, which every genuine RF-reception site sets
// unconditionally alongside rx_snr - unlike PhoneAPI's replay packets, which set TRANSPORT_LORA
// too (so the client treats restored history as if heard over the air) but never has_rx_rssi.
// Replay packets don't reach updateFrom() today; this check guards against a future change that
// routes them back through this path silently recording a replayed rx_snr as a fresh measurement.
if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && mp.has_rx_rssi) {
info->snr = mp.rx_snr; // keep the most recent SNR we received for this node.
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_HAS_SNR_MASK, true);
}
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_VIA_MQTT_MASK,
mp.via_mqtt); // Store if we received this packet via MQTT
@@ -3630,6 +3683,11 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
// inflate the local mesh-size estimate with non-RF nodes (and they usually carry
// hop_start==0, landing in the hop-0 bucket that pulls the recommendation lowest), so
// exclude via_mqtt too.
//
// The std::max clamp below is deliberate, not a bug: Counting an unproven-but-real neighbor as 0 hops is the
// conservative direction. This intentionally does not agree with the `hopsAway >= 0`
// gate below, which rejects the same -1 rather than storing it as a fabricated 0 -
// the histogram and the stored hops_away serve different purposes.
if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && !mp.via_mqtt &&
hopScalingModule) {
uint8_t hopCount = std::max(int8_t(0), getHopsAway(mp));
+18 -2
View File
@@ -166,10 +166,15 @@ inline bool isRadioProfileFile(const char *filename)
strcmp(filename, backupFileName) == 0;
}
/// "No trustworthy arrival time", as distinct from "zero seconds ago". Deliberately huge so the
/// display formatters fall into their existing unknown-age branches ("unknown age" / "?").
inline constexpr uint32_t SINCE_UNKNOWN = UINT32_MAX;
/// 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);
/// Given a packet, return how many seconds in the past (vs now) it was received
/// Given a packet, return how many seconds in the past (vs now) it was received,
/// or SINCE_UNKNOWN if it carries no trustworthy rx_time.
uint32_t sinceReceived(const meshtastic_MeshPacket *p);
/// Outcome of mapping a single on-wire last-byte (next_hop / relay_node) back to a full NodeNum.
@@ -740,7 +745,12 @@ extern uint32_t error_address;
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT)
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT 9
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK (1u << NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT)
// Bits 10..31 reserved for future single-bit flags.
// snr_q4 (persisted, sint32) is proto3 singular, so 0 == "never written", but 0 dB is valid.
// This bit disambiguates: whenever snr_q4 is written from a genuine RF measurement.
// Use this instead of `if (snr_q4)`. Legacy records (bit clear) are unambiguously "unknown".
#define NODEINFO_BITFIELD_HAS_SNR_SHIFT 10
#define NODEINFO_BITFIELD_HAS_SNR_MASK (1u << NODEINFO_BITFIELD_HAS_SNR_SHIFT)
// Bits 11..31 reserved for future single-bit flags.
// Convenience accessors so call sites read like the old struct fields.
inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n)
@@ -783,6 +793,12 @@ inline bool nodeInfoLiteHasXeddsaSigned(const meshtastic_NodeInfoLite *n)
{
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK);
}
/// True if this node's snr_q4 was written from a genuine RF measurement (including a real
/// 0 dB reading). False means "never measured" - do not treat 0 as data.
inline bool nodeInfoLiteHasSnr(const meshtastic_NodeInfoLite *n)
{
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_SNR_MASK);
}
/// A node that the eviction/migration paths must not drop: a favourite, an
/// ignored (blocked) node, or a manually-verified key.
inline bool nodeInfoLiteIsProtected(const meshtastic_NodeInfoLite *n)
+58 -9
View File
@@ -767,6 +767,12 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
LOG_DEBUG("Send config: bluetooth");
fromRadioScratch.config.which_payload_variant = meshtastic_Config_bluetooth_tag;
fromRadioScratch.config.payload_variant.bluetooth = config.bluetooth;
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!getAdminAuthorized()) {
// The pairing PIN is a shared secret; never expose it to an unauthenticated client.
fromRadioScratch.config.payload_variant.bluetooth.fixed_pin = 0;
}
#endif
break;
case meshtastic_Config_security_tag:
LOG_DEBUG("Send config: security");
@@ -1222,17 +1228,40 @@ uint32_t makeReplayPacketId(NodeNum num, uint32_t timestamp, uint32_t kind)
return h ? h : 1; // some clients treat id 0 as "unset"
}
/// Populate hop_start/hop_limit from the node's real last-known hop count (if any) so a
/// replayed packet doesn't read as "heard directly" when it wasn't.
/// Populate hop_start/hop_limit from the node's last-known hop count - never fabricate one.
/// hop_start == 0 with no decoded bitfield means unknown, not a direct neighbor; clients must
/// treat it that way too (see hop_start in mesh.proto).
void setReplayHopFields(meshtastic_MeshPacket &pkt, const meshtastic_NodeInfoLite *header)
{
if (!header || !header->has_hops_away) {
pkt.hop_start = 0; // unknown - do not fabricate a direct-neighbor reading
pkt.hop_limit = 0;
return;
}
uint8_t hopLimit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit);
uint8_t hopsAway = (header && header->has_hops_away) ? header->hops_away : 0;
uint8_t hopsAway = header->hops_away;
pkt.hop_start = hopLimit;
pkt.hop_limit = hopsAway < hopLimit ? (uint8_t)(hopLimit - hopsAway) : 0;
}
/// 2020-01-01: a boot-relative counter needs ~50 years of uptime to reach this, so it cannot be
/// confused with a real epoch.
constexpr uint32_t MIN_PLAUSIBLE_EPOCH = 1577836800u;
/// Not every last_heard writer gates on RTC quality - NodeDB::addFromContact stamps it with a bare
/// getTime(), which is boot-relative seconds on a node that has never had a clock.
bool lastHeardIsWallClock(const meshtastic_NodeInfoLite *header)
{
return header && header->last_heard >= MIN_PLAUSIBLE_EPOCH;
}
} // namespace
// Replayed packets deliberately leave rx_rssi absent. NodeInfoLite stores no RSSI, and
// rx_rssi has explicit presence on the wire, indicating "unknown".
// Previously these packets carried a bare 0, which a client renders as a real reading.
// Note the asymmetry with rx_snr below: that field is still proto3 singular, so "unknown" and
// "0 dB" remain indistinguishable there.
meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const meshtastic_PositionLite &pos)
{
// Shape this exactly like a fresh live broadcast Position from the peer so the
@@ -1242,12 +1271,18 @@ meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const mesh
const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num);
pkt.from = num;
pkt.to = NODENUM_BROADCAST;
pkt.rx_time = pos.time;
// rx_time means "when *we* received this" - use last_heard, not the position's own GPS
// fix time (which is often 0 and, when present, already round-trips inside the payload
// via ConvertToPosition).
pkt.rx_time = header ? header->last_heard : 0;
// Present only when last_heard is a genuine epoch - see lastHeardIsWallClock().
pkt.has_rx_time = lastHeardIsWallClock(header);
// Stable per-node/per-fix id: replaying the same unchanged history on every
// reconnect must not look like a brand new packet to the phone's history/dedup.
pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_POSITION_APP);
pkt.channel = 0;
pkt.channel = header ? header->channel : 0;
pkt.rx_snr = header ? header->snr : 0;
pkt.via_mqtt = nodeInfoLiteViaMqtt(header);
setReplayHopFields(pkt, header);
pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// Mark as if heard over the air, not internally generated
@@ -1269,9 +1304,12 @@ meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const mes
// No native timestamp on telemetry packets here; use last_heard.
const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num);
pkt.rx_time = header ? header->last_heard : 0;
// Present only when last_heard is a genuine epoch - see lastHeardIsWallClock().
pkt.has_rx_time = lastHeardIsWallClock(header);
pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_device_metrics_tag);
pkt.channel = 0;
pkt.channel = header ? header->channel : 0;
pkt.rx_snr = header ? header->snr : 0;
pkt.via_mqtt = nodeInfoLiteViaMqtt(header);
setReplayHopFields(pkt, header);
pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// Mark as if heard over the air, not internally generated - iOS client filters
@@ -1374,9 +1412,12 @@ meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const
pkt.to = NODENUM_BROADCAST;
const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num);
pkt.rx_time = header ? header->last_heard : 0;
// Present only when last_heard is a genuine epoch - see lastHeardIsWallClock().
pkt.has_rx_time = lastHeardIsWallClock(header);
pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_environment_metrics_tag);
pkt.channel = 0;
pkt.channel = header ? header->channel : 0;
pkt.rx_snr = header ? header->snr : 0;
pkt.via_mqtt = nodeInfoLiteViaMqtt(header);
setReplayHopFields(pkt, header);
pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// Mark as if heard over the air, not internally generated - iOS client filters
@@ -1438,9 +1479,12 @@ meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const mesht
// StatusMessage has no native timestamp; use last_heard.
const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num);
pkt.rx_time = header ? header->last_heard : 0;
// Present only when last_heard is a genuine epoch - see lastHeardIsWallClock().
pkt.has_rx_time = lastHeardIsWallClock(header);
pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_NODE_STATUS_APP);
pkt.channel = 0;
pkt.channel = header ? header->channel : 0;
pkt.rx_snr = header ? header->snr : 0;
pkt.via_mqtt = nodeInfoLiteViaMqtt(header);
setReplayHopFields(pkt, header);
pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND;
// Mark as if heard over the air, not internally generated - client filters
@@ -1817,7 +1861,12 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p)
p.want_ack = true;
}
lastPortNumToRadio[p.decoded.portnum] = millis();
// Only the rate-limited ports above are ever read back, so recording any other portnum would let
// a client grow this map without bound by cycling through them.
if (IS_ONE_OF(p.decoded.portnum, meshtastic_PortNum_TRACEROUTE_APP, meshtastic_PortNum_POSITION_APP,
meshtastic_PortNum_WAYPOINT_APP, meshtastic_PortNum_ALERT_APP, meshtastic_PortNum_TELEMETRY_APP,
meshtastic_PortNum_TEXT_MESSAGE_APP))
lastPortNumToRadio[p.decoded.portnum] = millis();
service->handleToRadio(p);
return true;
}
+10
View File
@@ -113,6 +113,11 @@ void RF95Interface::setTransmitEnable(bool txon)
/// \return true if initialisation succeeded.
bool RF95Interface::init()
{
#ifdef RF95_POWER_EN
pinMode(RF95_POWER_EN, OUTPUT);
digitalWrite(RF95_POWER_EN, HIGH);
#endif
RadioLibInterface::init();
#if defined(RADIOMASTER_900_BANDIT_NANO) || defined(RADIOMASTER_900_BANDIT)
@@ -262,6 +267,7 @@ void RF95Interface::addReceiveMetadata(meshtastic_MeshPacket *mp)
{
mp->rx_snr = lora->getSNR();
mp->rx_rssi = lround(lora->getRSSI());
mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding
LOG_DEBUG("Corrected frequency offset: %f", lora->getFrequencyError());
}
@@ -335,6 +341,10 @@ bool RF95Interface::sleep()
setStandby(); // First cancel any active receiving/sending
lora->sleep();
#ifdef RF95_POWER_EN
digitalWrite(RF95_POWER_EN, LOW);
#endif
#ifdef RF95_FAN_EN
digitalWrite(RF95_FAN_EN, 0);
#endif
+4 -2
View File
@@ -401,6 +401,8 @@ std::unique_ptr<RadioInterface> initLoRa()
return std::unique_ptr<RadioInterface>(new LR1121Interface(hal, cs, irq, rst, busy));
case use_llcc68:
return std::unique_ptr<RadioInterface>(new LLCC68Interface(hal, cs, irq, rst, busy));
case use_lr2021:
return std::unique_ptr<RadioInterface>(new LR2021Interface(hal, cs, irq, rst, busy));
case use_simradio:
return std::unique_ptr<RadioInterface>(new SimRadio);
default:
@@ -877,11 +879,11 @@ void printPacket(const char *prefix, const meshtastic_MeshPacket *p)
out += DEBUG_PORT.mt_sprintf(" len=%d", p->encrypted.size + sizeof(PacketHeader));
}
if (p->rx_time != 0)
if (p->has_rx_time) // rx_time has explicit presence; a millis() placeholder isn't a real reading to print
out += DEBUG_PORT.mt_sprintf(" rxtime=%u", p->rx_time);
if (p->rx_snr != 0.0)
out += DEBUG_PORT.mt_sprintf(" rxSNR=%g", p->rx_snr);
if (p->rx_rssi != 0)
if (p->has_rx_rssi) // rx_rssi has explicit presence; a != 0 check would hide a genuine 0 dBm reading
out += DEBUG_PORT.mt_sprintf(" rxRSSI=%i", p->rx_rssi);
if (p->via_mqtt != 0)
out += DEBUG_PORT.mt_sprintf(" via MQTT");
+10 -2
View File
@@ -16,6 +16,9 @@
*/
ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p)
{
const GlobalPacketId key(p);
const bool retransmitting = p->want_ack;
if (p->want_ack) {
DEBUG_HEAP_BEFORE;
auto copy = packetPool.allocCopy(*p);
@@ -34,7 +37,12 @@ ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p)
}
}
return isBroadcast(p->to) ? FloodingRouter::send(p) : NextHopRouter::send(p);
ErrorCode result = isBroadcast(p->to) ? FloodingRouter::send(p) : NextHopRouter::send(p);
// Duty-cycle rejections may clear before the scheduled retry.
if (retransmitting && result != ERRNO_OK && result != meshtastic_Routing_Error_DUTY_CYCLE_LIMIT)
stopRetransmission(key);
return result;
}
bool ReliableRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
@@ -196,4 +204,4 @@ bool ReliableRouter::shouldSuccessAckWithWantAck(const meshtastic_MeshPacket *p)
}
return false;
}
}
+92 -38
View File
@@ -5,6 +5,7 @@
#include "MeshService.h"
#include "NodeDB.h"
#include "PositionPrecision.h"
#include "UptimeClock.h"
#include "gps/RTC.h"
#include "configuration.h"
@@ -269,6 +270,19 @@ PacketId generatePacketId()
return id;
}
RxTimeStamp computeRxTimeStamp()
{
const bool haveTime = getRTCQuality() >= RTCQualityFromNet;
return {haveTime ? getValidTime(RTCQualityFromNet) : Time::getMillis(), haveTime};
}
void stampRxTime(meshtastic_MeshPacket *p)
{
const RxTimeStamp ts = computeRxTimeStamp();
p->rx_time = ts.time;
p->has_rx_time = ts.valid;
}
meshtastic_MeshPacket *Router::allocForSending()
{
meshtastic_MeshPacket *p = packetPool.allocZeroed();
@@ -280,8 +294,8 @@ meshtastic_MeshPacket *Router::allocForSending()
p->to = NODENUM_BROADCAST;
p->hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit);
p->id = generatePacketId();
p->rx_time =
getValidTime(RTCQualityFromNet); // Just in case we process the packet locally - make sure it has a valid timestamp
// Just in case we process the packet locally - make sure it has a timestamp.
stampRxTime(p);
return p;
}
@@ -451,6 +465,8 @@ ErrorCode Router::send(meshtastic_MeshPacket *p)
if (!(p->which_payload_variant == meshtastic_MeshPacket_encrypted_tag ||
p->which_payload_variant == meshtastic_MeshPacket_decoded_tag)) {
// Error returns from here own the packet, as the position-precision path below does.
packetPool.release(p);
return meshtastic_Routing_Error_BAD_REQUEST;
}
@@ -619,7 +635,9 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
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 (nodeDB->copyPublicKey(p->from, senderKey)) {
// Authoritative keys only: verifying against an opportunistic cache key would let a planted
// key mark its own node a signer, the trust loop #11116 closed on the decrypt path.
if (nodeDB->copyPublicKeyAuthoritative(p->from, senderKey)) {
p->xeddsa_signed =
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);
@@ -667,20 +685,14 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
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)) {
// Balanced rejects only what a signer always signs: non-PKI broadcasts whose signed encoding
// would have fit, plus unicasts on ham where licensed senders sign too. Mirrors perhapsEncode.
if (nodeDB->isKnownXeddsaSigner(p->from) && (isBroadcast(p->to) || owner.is_licensed)) {
size_t canonicalSize;
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);
LOG_WARN("Dropping unsigned packet from 0x%08x that previously signed", p->from);
return false;
}
}
@@ -1158,7 +1170,12 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
*destKey.bytes);
return meshtastic_Routing_Error_PKI_FAILED;
}
crypto->encryptCurve25519(p->to, getFrom(p), destKey, p->id, numbytes, bytes, p->encrypted.bytes);
// On failure encrypted.bytes holds no ciphertext, so continuing would put the plaintext
// on the air labelled pki_encrypted.
if (!crypto->encryptCurve25519(p->to, getFrom(p), destKey, p->id, numbytes, bytes, p->encrypted.bytes)) {
LOG_WARN("PKI encryption failed for destination node 0x%08x", p->to);
return meshtastic_Routing_Error_PKI_FAILED;
}
numbytes += MESHTASTIC_PKC_OVERHEAD;
p->channel = 0;
p->pki_encrypted = true;
@@ -1232,7 +1249,12 @@ bool Router::dequeueDeferredLocal(DeferredLocal &out)
void Router::deliverLocal(meshtastic_MeshPacket *p, RxSource src)
{
// Top level: handle synchronously, exactly as before the depth guard existed.
if (handleDepth == 0) {
bool nested;
{
concurrency::LockGuard g(&deferredLock);
nested = handleDepth > 0;
}
if (!nested) {
handleReceived(p, src);
return;
}
@@ -1241,8 +1263,26 @@ void Router::deliverLocal(meshtastic_MeshPacket *p, RxSource src)
// 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;
if (copy) {
// Re-check depth under the lock that also gates the drain's decrement, so a drain finishing
// while we allocated cannot leave this copy stranded in the ring.
bool stillNested = false, queued = false;
{
concurrency::LockGuard g(&deferredLock);
stillNested = handleDepth > 0;
if (stillNested)
queued = enqueueDeferredLocal(copy, src);
}
if (queued)
return;
if (!stillNested) {
// The drain finished first, so nothing would pick this up. Go through handleReceived()
// rather than dispatchReceived() so a loopback from its modules still defers.
handleReceived(copy, src);
packetPool.release(copy);
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
@@ -1261,29 +1301,39 @@ void Router::deliverLocal(meshtastic_MeshPacket *p, RxSource src)
*/
void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
{
handleDepth++;
{
concurrency::LockGuard g(&deferredLock);
handleDepth++;
#ifdef PIO_UNIT_TESTING
if (handleDepth > maxHandleDepthObserved)
maxHandleDepthObserved = handleDepth;
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) {
// Decide "am I the last frame" and drop the depth in one critical section. Splitting them lets
// two frames both read the same pre-decrement value, skip the drain, and strand the ring.
for (;;) {
DeferredLocal d;
while (dequeueDeferredLocal(d)) {
dispatchReceived(d.p, d.src);
packetPool.release(d.p);
{
concurrency::LockGuard g(&deferredLock);
if (handleDepth > 1) {
// Another frame is still live and will own the drain once it is last.
handleDepth--;
return;
}
if (!dequeueDeferredLocal(d)) {
// Last frame and nothing queued, so zero is reached only with the ring empty.
handleDepth--;
return;
}
}
// Depth stays at 1 across the drain, so a loopback from these modules defers instead of
// recursing, and dispatch runs outside the lock.
dispatchReceived(d.p, d.src);
packetPool.release(d.p);
}
handleDepth--;
}
void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src)
@@ -1305,12 +1355,15 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src)
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;
// See computeRxTimeStamp() for the placeholder/has_rx_time semantics.
const RxTimeStamp rxStamp = computeRxTimeStamp();
p->rx_time = rxStamp.time;
p->has_rx_time = rxStamp.valid;
if (p_encrypted) {
p_encrypted->rx_time = rxStamp.time;
p_encrypted->has_rx_time = rxStamp.valid;
}
// Take those raw bytes and convert them back into a well structured protobuf we can understand
auto decodedState = perhapsDecode(p);
@@ -1443,7 +1496,8 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
#if ARCH_PORTDUINO
// Even ignored packets get logged in the trace
if (portduino_config.traceFilename != "" || portduino_config.logoutputlevel == level_trace) {
p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone
// Store the arrival timestamp for the phone before it's traced.
stampRxTime(p);
LOG_TRACE("%s", MeshPacketSerializer::JsonSerializeEncrypted(p).c_str());
}
#endif
+20 -6
View File
@@ -7,9 +7,21 @@
#include "PacketHistory.h"
#include "PointerQueue.h"
#include "RadioInterface.h"
#include "concurrency/LockGuard.h"
#include "concurrency/OSThread.h"
#include <memory>
/// rx_time/has_rx_time for "now": a real epoch when the clock is trustworthy, else a
/// Time::getMillis() placeholder with valid=false.
struct RxTimeStamp {
uint32_t time;
bool valid;
};
RxTimeStamp computeRxTimeStamp();
/// Stamp p->rx_time/p->has_rx_time with computeRxTimeStamp().
void stampRxTime(meshtastic_MeshPacket *p);
/**
* A mesh aware router that supports multiple interfaces.
*/
@@ -151,12 +163,8 @@ class Router : protected concurrency::OSThread, protected PacketHistory
void perhapsHandleReceived(meshtastic_MeshPacket *p);
/**
* Called from perhapsHandleReceived() - allows subclass message delivery behavior.
* Handle any packet that is received by an interface on this node.
* Note: some packets may merely being passed through this node and will be forwarded elsewhere.
*
* Note: this packet will never be called for messages sent/generated by this node.
* Note: this method will free the provided packet.
* Called from perhapsHandleReceived() for radio ingress and from deliverLocal() for our own
* loopback, so p may be locally generated. Does NOT free p; the caller still owns it.
*/
void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO);
@@ -181,6 +189,10 @@ class Router : protected concurrency::OSThread, protected PacketHistory
/// so a locally-sent loopback packet must be deferred rather than handled synchronously.
uint8_t handleDepth = 0;
/// Guards handleDepth and the deferred ring below. nRF52 drives the router from the BLE task as
/// well as the loop task, so both are read-modify-written from two tasks.
concurrency::Lock deferredLock;
/// A local loopback packet whose handleReceived() was deferred because it was produced from
/// inside callModules(). The queue owns the packet; its RxSource travels with it so the drain
/// dispatches it with the origin the sender intended (RX_SRC_LOCAL stays local).
@@ -199,8 +211,10 @@ class Router : protected concurrency::OSThread, protected PacketHistory
uint8_t deferredLocalCount = 0; // entries currently queued
/// Queue a deferred local packet. Returns false (and queues nothing) when full.
/// Caller must hold deferredLock: the enqueue decision is atomic with the drain's depth update.
bool enqueueDeferredLocal(meshtastic_MeshPacket *p, RxSource src);
/// Pop the oldest deferred local packet into out. Returns false when empty.
/// Caller must hold deferredLock.
bool dequeueDeferredLocal(DeferredLocal &out);
/** Frees the provided packet, and generates a NAK indicating the specifed error while sending */
+1
View File
@@ -335,6 +335,7 @@ template <typename T> void SX126xInterface<T>::addReceiveMetadata(meshtastic_Mes
// LOG_DEBUG("PacketStatus %x", lora.getPacketStatus());
mp->rx_snr = lora.getSNR();
mp->rx_rssi = lround(lora.getRSSI());
mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding
LOG_DEBUG("Corrected frequency offset: %f", lora.getFrequencyError());
}
+1
View File
@@ -205,6 +205,7 @@ template <typename T> void SX128xInterface<T>::addReceiveMetadata(meshtastic_Mes
// LOG_DEBUG("PacketStatus %x", lora.getPacketStatus());
mp->rx_snr = lora.getSNR();
mp->rx_rssi = lround(lora.getRSSI());
mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding
LOG_DEBUG("Corrected frequency offset: %f", lora.getFrequencyError());
}
+3
View File
@@ -10,6 +10,9 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo
meshtastic_NodeInfo info = meshtastic_NodeInfo_init_default;
info.num = lite->num;
// NodeInfo.snr (wire) is still proto3 singular float - unlike NodeInfoLite.snr_q4, it has no
// presence bit and cannot distinguish a genuine 0 dB from "unknown". nodeInfoLiteHasSnr(lite)
// is available if a future NodeInfo revision needs to carry that distinction to clients.
info.snr = lite->snr;
info.last_heard = lite->last_heard;
info.channel = lite->channel;
+6
View File
@@ -6,6 +6,12 @@
#include "RadioInterface.h"
#include "modules/NodeInfoModule.h"
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
// receivePacket() dispatches ToRadio straight to MeshService, bypassing handleToRadioPacket and so
// the lockdown admin gate. Fail the build rather than silently ship an admin-auth bypass.
#error "USE_PACKET_API is incompatible with MESHTASTIC_PHONEAPI_ACCESS_CONTROL (PacketAPI bypasses the lockdown admin gate)"
#endif
PacketAPI *packetAPI = nullptr;
PacketAPI *PacketAPI::create(PacketServer *_server)
@@ -95,8 +95,11 @@ typedef struct _meshtastic_NodeInfoLite {
/* The public key of the user's device, for PKI-based encrypted DMs. */
meshtastic_NodeInfoLite_public_key_t public_key;
/* Q4-encoded SNR: dB × 4, sint32 zigzag. Matches RouteDiscovery convention.
Encode: snr_q4 = (int32_t)(snr * 4.0f). Decode: snr = snr_q4 / 4.0f.
float snr is always zeroed on disk; this field carries all persisted SNR. */
Encode: snr_q4 = (int32_t)lroundf(snr * 4.0f). Decode: snr = snr_q4 / 4.0f.
float snr is always zeroed on disk; this field carries all persisted SNR.
A stored 0 does not by itself mean "unknown" here - see NODEINFO_BITFIELD_HAS_SNR in
src/mesh/NodeDB.h for the presence bit that disambiguates a genuine 0 dB reading from
"never measured". */
int32_t snr_q4;
} meshtastic_NodeInfoLite;
+24 -7
View File
@@ -1079,7 +1079,14 @@ typedef struct _meshtastic_MeshPacket {
/* The time this message was received by the esp32 (secs since 1970).
Note: this field is _never_ sent on the radio link itself (to save space) Times
are typically not sent over the mesh, but they will be added to any Packet
(chain of SubPacket) sent to the phone (so the phone can know exact time of reception) */
(chain of SubPacket) sent to the phone (so the phone can know exact time of reception)
Explicit presence: firmware cannot always attach a trustworthy wall-clock timestamp at the
moment of reception - a node with no GPS and no phone connected yet has no time source at
all. has_rx_time disambiguates that state from a genuine (if coincidental) 1970-01-01
reading. A packet delivered with this field absent may still be re-timestamped once a valid
clock becomes available, before the phone ever sees it - "absent" is not guaranteed
permanent, only "not yet known at last observation". */
bool has_rx_time;
uint32_t rx_time;
/* *Never* sent over the radio links.
Set during reception to indicate the SNR of this packet.
@@ -1103,14 +1110,24 @@ typedef struct _meshtastic_MeshPacket {
/* The priority of this message for sending.
See MeshPacket.Priority description for more details. */
meshtastic_MeshPacket_Priority priority;
/* rssi of received packet. Only sent to phone for dispay purposes. */
/* rssi of received packet. Only sent to phone for dispay purposes.
Explicit presence: rssi 0 is a legitimate reading on some radios (SX126x can report exactly
0 dBm; SX127x's formula can even go positive), so implicit-presence proto3 made an unset
value indistinguishable from a measured one. has_rx_rssi disambiguates; a replayed packet
built from history the device never restored an RSSI for should leave this field absent
rather than emitting 0. */
bool has_rx_rssi;
int32_t rx_rssi;
/* Describe if this message is delayed */
meshtastic_MeshPacket_Delayed delayed;
/* Describes whether this packet passed via MQTT somewhere along the path it currently took. */
bool via_mqtt;
/* Hop limit with which the original packet started. Sent via LoRa using three bits in the unencrypted header.
When receiving a packet, the difference between hop_start and hop_limit gives how many hops it traveled. */
When receiving a packet, the difference between hop_start and hop_limit gives how many hops it traveled.
hop_start == 0 does not necessarily mean a direct (0-hop) neighbor: firmware prior to 2.3.0
never populated this field, so a receiver can only trust hop_start == 0 as genuine once it has
decoded the packet and confirmed the sender's bitfield is present (added in 2.5.0). Until then,
or for a sender that never sets that bitfield, treat hop_start == 0 as unknown, not direct. */
uint8_t hop_start;
/* Records the public key the packet was encrypted with, if applicable. */
meshtastic_MeshPacket_public_key_t public_key;
@@ -1730,7 +1747,7 @@ extern "C" {
#define meshtastic_Waypoint_init_default {0, false, 0, false, 0, 0, 0, "", "", 0, 0, false, meshtastic_BoundingBox_init_default, 0, 0, 0}
#define meshtastic_StatusMessage_init_default {""}
#define meshtastic_MqttClientProxyMessage_init_default {"", 0, {{0, {0}}}, 0}
#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0}
#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, false, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0}
#define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0, 0}
#define meshtastic_MyNodeInfo_init_default {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0}
#define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN}
@@ -1769,7 +1786,7 @@ extern "C" {
#define meshtastic_Waypoint_init_zero {0, false, 0, false, 0, 0, 0, "", "", 0, 0, false, meshtastic_BoundingBox_init_zero, 0, 0, 0}
#define meshtastic_StatusMessage_init_zero {""}
#define meshtastic_MqttClientProxyMessage_init_zero {"", 0, {{0, {0}}}, 0}
#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0}
#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, false, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0}
#define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0, 0}
#define meshtastic_MyNodeInfo_init_zero {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0}
#define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN}
@@ -2189,12 +2206,12 @@ X(a, STATIC, SINGULAR, UINT32, channel, 3) \
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,decoded,decoded), 4) \
X(a, STATIC, ONEOF, BYTES, (payload_variant,encrypted,encrypted), 5) \
X(a, STATIC, SINGULAR, FIXED32, id, 6) \
X(a, STATIC, SINGULAR, FIXED32, rx_time, 7) \
X(a, STATIC, OPTIONAL, FIXED32, rx_time, 7) \
X(a, STATIC, SINGULAR, FLOAT, rx_snr, 8) \
X(a, STATIC, SINGULAR, UINT32, hop_limit, 9) \
X(a, STATIC, SINGULAR, BOOL, want_ack, 10) \
X(a, STATIC, SINGULAR, UENUM, priority, 11) \
X(a, STATIC, SINGULAR, INT32, rx_rssi, 12) \
X(a, STATIC, OPTIONAL, INT32, rx_rssi, 12) \
X(a, STATIC, SINGULAR, UENUM, delayed, 13) \
X(a, STATIC, SINGULAR, BOOL, via_mqtt, 14) \
X(a, STATIC, SINGULAR, UINT32, hop_start, 15) \
+10 -1
View File
@@ -79,6 +79,11 @@ class UdpMulticastHandler final
LOG_WARN("UDP packet with spoofed local from=0x%08x, dropping", mp.from);
return;
}
// Same clamp the MQTT ingress applies: an out-of-range hop count is not relayable.
if (mp.hop_limit > HOP_MAX || mp.hop_start > HOP_MAX) {
LOG_WARN("UDP packet with invalid hop_limit(%u) or hop_start(%u), dropping", mp.hop_limit, mp.hop_start);
return;
}
mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP;
// Authentication metadata is local-only; Router re-establishes it after successful PKI decryption.
mp.pki_encrypted = false;
@@ -86,9 +91,13 @@ class UdpMulticastHandler final
UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp);
if (!p)
return;
// Unset received SNR/RSSI
// Unset received SNR/RSSI - no local RF measurement exists for a UDP arrival. rx_rssi
// has explicit presence, so also clear has_rx_rssi: `mp` may have arrived already
// carrying a real measurement from whichever node forwarded it onto UDP, and leaving
// the presence bit set would misrepresent that stale value as "0 dBm over UDP".
p->rx_snr = 0;
p->rx_rssi = 0;
p->has_rx_rssi = false;
router->enqueueReceivedMessage(p.release());
}
}
+24 -1
View File
@@ -69,7 +69,6 @@
#endif
AdminModule *adminModule;
bool hasOpenEditTransaction;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
static bool licensedIdentityWillMigrate()
@@ -240,6 +239,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
return handled;
}
}
// Before the switch, so every case below sees consistent transaction state.
expireStaleEditTransaction();
switch (r->which_payload_variant) {
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
@@ -481,12 +483,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
case meshtastic_AdminMessage_begin_edit_settings_tag: {
LOG_INFO("Begin transaction for editing settings");
hasOpenEditTransaction = true;
editTransactionActivityMs = millis();
break;
}
case meshtastic_AdminMessage_commit_edit_settings_tag: {
disableBluetooth();
LOG_INFO("Commit transaction for edited settings");
hasOpenEditTransaction = false;
deferredEditSegments = 0;
saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE);
flushChannelWarnings(); // one coalesced message for everything edited in this transaction
break;
@@ -1875,6 +1879,23 @@ void AdminModule::reboot(int32_t seconds)
rebootAtMsec = (seconds < 0) ? 0 : (millis() + seconds * 1000);
}
// Without this, a commit that never arrives leaves the transaction open forever and every later
// config write from any client is applied, acknowledged, and then never saved.
void AdminModule::expireStaleEditTransaction()
{
if (!hasOpenEditTransaction || Throttle::isWithinTimespanMs(editTransactionActivityMs, EDIT_TRANSACTION_IDLE_MS))
return;
LOG_WARN("Edit transaction abandoned for %us; committing what it applied", EDIT_TRANSACTION_IDLE_MS / 1000);
hasOpenEditTransaction = false;
int segments = deferredEditSegments;
deferredEditSegments = 0;
// No reboot: the settings are already live in RAM and the client that would expect one is gone.
if (segments)
saveChanges(segments, false);
flushChannelWarnings();
}
void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
{
#ifdef PIO_UNIT_TESTING
@@ -1885,6 +1906,8 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
service->reloadConfig(saveWhat); // Calls saveToDisk among other things
} else {
LOG_INFO("Delay save of changes to disk until the open transaction is committed");
editTransactionActivityMs = millis(); // still in use, so not the abandoned kind we time out
deferredEditSegments |= saveWhat;
}
if (shouldReboot && !hasOpenEditTransaction) {
reboot(DEFAULT_REBOOT_SECONDS);
+7
View File
@@ -40,6 +40,13 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
private:
bool hasOpenEditTransaction = false;
// Each deferred write restarts the clock, so this bounds the gap between writes, not the length
// of the edit; a bulk import sends them milliseconds apart.
static constexpr uint32_t EDIT_TRANSACTION_IDLE_MS = 60 * 1000;
uint32_t editTransactionActivityMs = 0; // millis() of the last save this transaction deferred
int deferredEditSegments = 0; // segments that transaction has touched but not yet saved
/// Retire an open edit transaction whose client stopped talking, persisting what it applied.
void expireStaleEditTransaction();
#ifdef PIO_UNIT_TESTING
int lastSaveWhatForTest = 0;
#endif
+9 -2
View File
@@ -880,8 +880,10 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
// All hardware keys fall through to here (CardKB, physical, etc.)
if (event->kbchar == INPUT_BROKER_MSG_EMOTE_LIST) {
updateState(CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER);
screen->forceDisplay();
if (graphics::numEmotes > 0) { // no picker on EXCLUDE_EMOJI builds (empty emotes[])
updateState(CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER);
screen->forceDisplay();
}
return true;
}
// Confirm select (Enter)
@@ -965,6 +967,11 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
int CannedMessageModule::handleEmotePickerInput(const InputEvent *event)
{
int numEmotes = graphics::numEmotes;
if (numEmotes == 0) { // EXCLUDE_EMOJI: emotes[] is empty, any index would read out of bounds
updateState(CANNED_MESSAGE_RUN_STATE_FREETEXT, true);
screen->forceDisplay();
return 1;
}
// Override isDown and isSelect ONLY for emote picker behavior
bool isUp = isUpEvent(event);
+7 -4
View File
@@ -32,14 +32,17 @@ ProcessMessage DropzoneModule::handleReceived(const meshtastic_MeshPacket &mp)
auto &p = mp.decoded;
char matchCompare[54];
auto incomingMessage = reinterpret_cast<const char *>(p.payload.bytes);
sprintf(matchCompare, "%s conditions", owner.short_name);
if (strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) {
// payload.bytes is not NUL-terminated, so a comparison longer than the received size would read
// whatever the previous occupant of the packet left behind.
const size_t received = p.payload.size;
snprintf(matchCompare, sizeof(matchCompare), "%s conditions", owner.short_name);
if (received >= strlen(matchCompare) && strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) {
LOG_DEBUG("Received dropzone conditions request");
startSendConditions = millis();
}
sprintf(matchCompare, "%s conditions", owner.long_name);
if (strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) {
snprintf(matchCompare, sizeof(matchCompare), "%s conditions", owner.long_name);
if (received >= strlen(matchCompare) && strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) {
LOG_DEBUG("Received dropzone conditions request");
startSendConditions = millis();
}
+1 -1
View File
@@ -23,7 +23,7 @@ extern AmbientLightingThread *ambientLightingThread;
#endif
#endif
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !defined(CONFIG_IDF_TARGET_ESP32C6)
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL)
#include <NonBlockingRtttl.h>
#else
// Noop class for portduino.
+1 -1
View File
@@ -342,7 +342,7 @@ void MeshBeaconBroadcastModule::sendBeacon()
p->hop_limit = 0; // all beacon packets are zero hopped to limit spamming.
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
p->want_ack = false;
p->rx_time = getValidTime(RTCQualityFromNet);
stampRxTime(p);
};
// ── Packet type decisions ────────────────────────────────────────────────
+7 -5
View File
@@ -5,6 +5,7 @@
#include "NodeStatus.h"
#include "Router.h"
#include "TransmitHistory.h"
#include "UptimeClock.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "main.h"
@@ -33,7 +34,9 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes
// Suppress replies to senders we've replied to recently (12H window)
if (mp.decoded.want_response && !isFromUs(&mp)) {
const NodeNum sender = getFrom(&mp);
const uint32_t now = mp.rx_time ? mp.rx_time : getTime();
// A local dedup window, not a wall-clock reading - uptime avoids RTC-quality jumps and
// replayed packets' stale rx_time perturbing it.
const uint32_t now = (uint32_t)(Time::getMillis64() / 1000);
auto it = lastNodeInfoSeen.find(sender);
if (it != lastNodeInfoSeen.end()) {
uint32_t sinceLast = now >= it->second ? now - it->second : 0;
@@ -50,10 +53,9 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes
return true;
}
NodeNum sourceNum = getFrom(&mp);
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(sourceNum);
// Broadcasts only: senders never sign unicast NodeInfo, so dropping it would break exchanges
// with signer nodes. Backstops ingress that skips Router's downgrade drop (e.g. decoded MQTT).
if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed && isBroadcast(mp.to)) {
// Broadcasts only: unicast NodeInfo is unsigned off ham, so updateUser refuses the identity
// write instead. isKnownXeddsaSigner also covers the warm tier.
if (nodeDB->isKnownXeddsaSigner(sourceNum) && !mp.xeddsa_signed && isBroadcast(mp.to)) {
LOG_WARN("Dropping unsigned NodeInfo broadcast from node 0x%08x that previously signed", sourceNum);
return true;
}
+49 -8
View File
@@ -167,9 +167,9 @@ bool PositionModule::hasGPS()
}
// Allocate a packet with our position data if we have one
meshtastic_MeshPacket *PositionModule::allocPositionPacket()
meshtastic_MeshPacket *PositionModule::allocPositionPacket(uint32_t atPrecision)
{
if (precision == 0) {
if (atPrecision == 0) {
LOG_DEBUG("Skip location send because precision is set to 0!");
return nullptr;
}
@@ -196,12 +196,12 @@ meshtastic_MeshPacket *PositionModule::allocPositionPacket()
}
// lat/lon are unconditionally included - IF AVAILABLE!
LOG_DEBUG("Send location with precision %i", precision);
LOG_DEBUG("Send location with precision %i", atPrecision);
p.latitude_i = localPosition.latitude_i;
p.longitude_i = localPosition.longitude_i;
p.has_latitude_i = true;
p.has_longitude_i = true;
applyPositionPrecision(p, precision);
applyPositionPrecision(p, atPrecision);
// Always use NTP / GPS time if available
if (getValidTime(RTCQualityNTP) > 0) {
p.time = getValidTime(RTCQualityNTP);
@@ -281,7 +281,7 @@ meshtastic_MeshPacket *PositionModule::allocReply()
return nullptr;
}
meshtastic_MeshPacket *reply = allocPositionPacket();
meshtastic_MeshPacket *reply = allocPositionPacket(precision);
if (reply) {
lastSentReply = millis(); // Track when we sent this reply
}
@@ -373,13 +373,34 @@ void PositionModule::sendOurPosition()
currentGeneration = radioGeneration;
// If we changed channels, ask everyone else for their latest info
LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies);
for (uint8_t channelNum = 0; channelNum < 8; channelNum++) {
if (getPositionPrecisionForChannel(channelNum) != 0) {
LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies);
sendOurPosition(NODENUM_BROADCAST, requestReplies, channelNum);
return;
}
}
LOG_INFO("Skip pos@%x:6 broadcast; position sharing is opt-in and disabled on all channels", localPosition.timestamp);
}
// Position broadcasts are opt-in per channel in 2.8, but our own position still plays to the
// connected phone/UI at full precision, like telemetry does. This copy never touches the mesh.
// Returns true only when a packet was actually handed to the phone queue, so the caller can
// hold off the cadence stamp (and retry soon) after a guard or allocation failure.
bool PositionModule::sendOurPositionToPhone()
{
if (!config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot())
return false; // Same stale-restored-position guard as sendOurPosition()
meshtastic_MeshPacket *p = allocPositionPacket(32);
if (p == nullptr)
return false;
p->to = NODENUM_BROADCAST;
p->decoded.want_response = false;
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
service->sendToPhone(p);
return true;
}
void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t channel)
@@ -396,7 +417,7 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha
// Set the class precision value for this particular packet.
precision = getPositionPrecisionForChannel(channel);
meshtastic_MeshPacket *p = allocPositionPacket();
meshtastic_MeshPacket *p = allocPositionPacket(precision);
if (p == nullptr) {
LOG_DEBUG("allocPositionPacket returned a nullptr");
return;
@@ -465,6 +486,14 @@ bool PositionModule::positionWithinPrecisionCell(int32_t aLat, int32_t aLon, int
truncateCoordinate(aLon, precision) == truncateCoordinate(bLon, precision);
}
bool PositionModule::shouldSendPositionToPhone(bool hasValidPosition, bool phoneQueueEmpty, bool everSentToPhone, uint32_t nowMs,
uint32_t lastSentMs, uint32_t intervalMs)
{
if (!hasValidPosition || !phoneQueueEmpty)
return false;
return !everSentToPhone || (nowMs - lastSentMs) >= intervalMs;
}
uint32_t PositionModule::effectiveBroadcastIntervalMs(uint32_t configuredIntervalMs, bool stationary, uint32_t stationaryFloorMs)
{
if (stationary && stationaryFloorMs > configuredIntervalMs)
@@ -485,8 +514,20 @@ int32_t PositionModule::runOnce()
if (node == nullptr)
return RUNONCE_INTERVAL;
// We limit our GPS broadcasts to a max rate
uint32_t now = millis();
// Local-only delivery, so it runs regardless of mesh opt-in state or channel utilization.
// Only send while the queue is empty (phone assumed connected), like telemetry. The cadence
// stamp only advances when a packet was actually queued, so a guard or allocation failure
// retries on the next tick instead of waiting out a full interval.
if (shouldSendPositionToPhone(nodeDB->hasValidPosition(node), service->isToPhoneQueueEmpty(), hasSentPositionToPhone, now,
lastPhoneSendMs, sendToPhoneIntervalMs) &&
sendOurPositionToPhone()) {
hasSentPositionToPhone = true;
lastPhoneSendMs = now;
}
// We limit our GPS broadcasts to a max rate
uint32_t intervalMs = Default::getConfiguredOrDefaultMsScaled(
config.position.position_broadcast_secs, default_broadcast_interval_secs, numOnlineNodes, TrafficType::POSITION);
uint32_t msSinceLastSend = now - lastGpsSend;
+14 -1
View File
@@ -45,6 +45,12 @@ class PositionModule : public ProtobufModule<meshtastic_Position>, private concu
// Effective min interval: stationary positions are held to stationaryFloorMs (when that is the
// longer of the two); otherwise the normal configured interval.
static uint32_t effectiveBroadcastIntervalMs(uint32_t configuredIntervalMs, bool stationary, uint32_t stationaryFloorMs);
// Pure local-play policy: stream our own position to the phone/UI when we have a valid
// position, the phone queue is idle, and the cadence has elapsed. everSentToPhone (rather
// than a lastSentMs sentinel) marks the never-sent state, so a send stamped at millis()==0
// still honors the cadence.
static bool shouldSendPositionToPhone(bool hasValidPosition, bool phoneQueueEmpty, bool everSentToPhone, uint32_t nowMs,
uint32_t lastSentMs, uint32_t intervalMs);
protected:
/** Called to handle a particular incoming message
@@ -63,7 +69,14 @@ class PositionModule : public ProtobufModule<meshtastic_Position>, private concu
virtual int32_t runOnce() override;
private:
meshtastic_MeshPacket *allocPositionPacket();
meshtastic_MeshPacket *allocPositionPacket(uint32_t atPrecision);
// Streams our own position to the connected phone/UI at full precision without touching the
// mesh. Keeps the local view alive now that mesh position sharing is opt-in. Returns true
// only when a packet was handed to the phone queue.
bool sendOurPositionToPhone();
bool hasSentPositionToPhone = false;
uint32_t lastPhoneSendMs = 0;
static constexpr uint32_t sendToPhoneIntervalMs = 60 * 1000; // Matches telemetry's local cadence
struct SmartPosition getDistanceTraveledSinceLastSend(meshtastic_PositionLite currentPosition);
// True when our position is unchanged since the last broadcast: it truncates to the same
// precision grid cell, so re-sending would be a duplicate that traffic management dedups
+6 -3
View File
@@ -103,8 +103,10 @@ bool RemoteHardwareModule::handleReceivedProtobuf(const meshtastic_MeshPacket &r
r.gpio_value = res;
r.gpio_mask = p.gpio_mask;
meshtastic_MeshPacket *p2 = allocDataProtobuf(r);
setReplyTo(p2, req);
myReply = p2;
if (p2) {
setReplyTo(p2, req);
myReply = p2;
}
break;
}
@@ -149,7 +151,8 @@ int32_t RemoteHardwareModule::runOnce()
r.type = meshtastic_HardwareMessage_Type_GPIOS_CHANGED;
r.gpio_value = curVal;
meshtastic_MeshPacket *p = allocDataProtobuf(r);
service->sendToMesh(p);
if (p)
service->sendToMesh(p);
}
}
} else {
+7 -6
View File
@@ -64,19 +64,20 @@ void RoutingModule::sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketI
uint8_t RoutingModule::getHopLimitForResponse(const meshtastic_MeshPacket &mp)
{
const int8_t hopsUsed = getHopsAway(mp);
const uint8_t responseHopLimit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit);
if (hopsUsed >= 0) {
if (hopsUsed > (int32_t)(config.lora.hop_limit)) {
// In event mode, we never want to send packets with more than our default 3 hops.
#if !(EVENTMODE) // This falls through to the default.
if (hopsUsed > static_cast<int32_t>(responseHopLimit)) {
// In event mode, never exceed the configured event hop limit.
#if !USERPREFS_EVENT_MODE // This falls through to the default.
return hopsUsed; // If the request used more hops than the limit, use the same amount of hops
#endif
} else if (mp.hop_start == 0) {
return 0; // The requesting node wanted 0 hops, so the response also uses a direct/local path.
} else if ((uint8_t)(hopsUsed + 2) < config.lora.hop_limit) {
} else if (static_cast<uint8_t>(hopsUsed + 2) < responseHopLimit) {
return hopsUsed + 2; // Use only the amount of hops needed with some margin as the way back may be different
}
}
return Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); // Use the default hop limit
return responseHopLimit;
}
meshtastic_MeshPacket *RoutingModule::allocAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex,
@@ -93,4 +94,4 @@ RoutingModule::RoutingModule() : ProtobufModule("routing", meshtastic_PortNum_RO
// LocalOnly requires either the from or to to be a known node
// knownOnly specifically requires the from to be a known node.
encryptedOk = true;
}
}
+6
View File
@@ -193,6 +193,9 @@ void StoreForwardModule::historyAdd(const meshtastic_MeshPacket &mp)
}
this->packetHistory[this->packetHistoryTotalCount].time = getTime();
// getTime() silently falls back to a boot-relative count with no RTC source at all; record
// whether it was actually trustworthy so replay doesn't have to guess from current quality.
this->packetHistory[this->packetHistoryTotalCount].has_rx_time = (getRTCQuality() >= RTCQualityFromNet);
this->packetHistory[this->packetHistoryTotalCount].to = mp.to;
this->packetHistory[this->packetHistoryTotalCount].channel = mp.channel;
this->packetHistory[this->packetHistoryTotalCount].from = getFrom(&mp);
@@ -201,6 +204,7 @@ void StoreForwardModule::historyAdd(const meshtastic_MeshPacket &mp)
this->packetHistory[this->packetHistoryTotalCount].emoji = (bool)p.emoji;
this->packetHistory[this->packetHistoryTotalCount].payload_size = p.payload.size;
this->packetHistory[this->packetHistoryTotalCount].rx_rssi = mp.rx_rssi;
this->packetHistory[this->packetHistoryTotalCount].has_rx_rssi = mp.has_rx_rssi;
this->packetHistory[this->packetHistoryTotalCount].rx_snr = mp.rx_snr;
this->packetHistory[this->packetHistoryTotalCount].hop_start = mp.hop_start;
this->packetHistory[this->packetHistoryTotalCount].hop_limit = mp.hop_limit;
@@ -257,8 +261,10 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t
p->channel = this->packetHistory[i].channel;
p->decoded.reply_id = this->packetHistory[i].reply_id;
p->rx_time = this->packetHistory[i].time;
p->has_rx_time = this->packetHistory[i].has_rx_time; // presence captured at store time, not replay time
p->decoded.emoji = (uint32_t)this->packetHistory[i].emoji;
p->rx_rssi = this->packetHistory[i].rx_rssi;
p->has_rx_rssi = this->packetHistory[i].has_rx_rssi; // presence captured at store time, not replay time
p->rx_snr = this->packetHistory[i].rx_snr;
p->hop_start = this->packetHistory[i].hop_start;
p->hop_limit = this->packetHistory[i].hop_limit;
+2
View File
@@ -12,6 +12,7 @@
struct PacketHistoryStruct {
uint32_t time;
bool has_rx_time; // whether `time` was a trustworthy epoch when captured, not a getTime() boot-relative fallback
uint32_t to;
uint32_t from;
uint32_t id;
@@ -21,6 +22,7 @@ struct PacketHistoryStruct {
uint8_t payload[meshtastic_Constants_DATA_PAYLOAD_LEN];
pb_size_t payload_size;
int32_t rx_rssi;
bool has_rx_rssi; // whether rx_rssi was a genuine measurement (e.g. not MQTT-relayed) when captured
float rx_snr;
uint8_t hop_start;
uint8_t hop_limit;
+6 -1
View File
@@ -132,8 +132,13 @@ void HealthTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *
}
// Display "Health From: ..." on its own
char agoStr[16];
if (agoSecs == SINCE_UNKNOWN)
snprintf(agoStr, sizeof(agoStr), "?"); // no trustworthy arrival time to age against
else
snprintf(agoStr, sizeof(agoStr), "%us", (unsigned)agoSecs);
char headerStr[64];
snprintf(headerStr, sizeof(headerStr), "Health From: %s(%ds)", lastSender, (int)agoSecs);
snprintf(headerStr, sizeof(headerStr), "Health From: %s(%s)", lastSender, agoStr);
display->drawString(x, y, headerStr);
char last_temp[16];
+6 -1
View File
@@ -154,8 +154,13 @@ void PowerTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *s
}
// Display "Pow. From: ..."
char agoStr[16];
if (agoSecs == SINCE_UNKNOWN)
snprintf(agoStr, sizeof(agoStr), "?"); // no trustworthy arrival time to age against
else
snprintf(agoStr, sizeof(agoStr), "%us", (unsigned)agoSecs);
char fromStr[64];
snprintf(fromStr, sizeof(fromStr), "Pow. From: %s (%us)", lastSender, agoSecs);
snprintf(fromStr, sizeof(fromStr), "Pow. From: %s (%s)", lastSender, agoStr);
display->drawString(x, graphics::getTextPositions(display)[line++], fromStr);
// Display current and voltage based on ...power_metrics.has_[channel/voltage/current]... flags
+5 -1
View File
@@ -422,7 +422,11 @@ void TraceRouteModule::appendMyIDandSNR(meshtastic_RouteDiscovery *updated, floa
}
if (*snr_count < ROUTE_SIZE) {
snr_list[*snr_count] = (int8_t)(snr * 4); // Convert SNR to 1 byte
// Clamp before the cast: q4-scaled SNR at or below the demodulation floor can reach
// -128 (=-32dB), which is bit-identical to the INT8_MIN "unknown SNR" sentinel used
// throughout this file. Reserve -128 for the sentinel; clamp real readings to -127.
int32_t q4 = clamp<int32_t>(lroundf(snr * 4.0f), -127, 127);
snr_list[*snr_count] = (int8_t)q4;
*snr_count += 1;
}
if (SNRonly)
+1
View File
@@ -106,6 +106,7 @@ class AccelerometerThread : public concurrency::OSThread
break;
#if __has_include(<Adafruit_LIS3DH.h>)
case ScanI2C::DeviceType::LIS3DH:
case ScanI2C::DeviceType::SC7A20:
sensor = new LIS3DHSensor(device);
break;
#endif
+5 -3
View File
@@ -7,14 +7,16 @@ LIS3DHSensor::LIS3DHSensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::Mot
bool LIS3DHSensor::init()
{
if (sensor.begin(deviceAddress())) {
// The SC7A20 shares the register map but identifies as 0x11.
const uint8_t whoAmI = (deviceType() == ScanI2C::DeviceType::SC7A20) ? 0x11 : 0x33;
if (sensor.begin(deviceAddress(), whoAmI)) {
sensor.setRange(LIS3DH_RANGE_2_G);
// Adjust threshold, higher numbers are less sensitive
sensor.setClick(config.device.double_tap_as_button_press ? 2 : 1, MOTION_SENSOR_CHECK_INTERVAL_MS);
LOG_DEBUG("LIS3DH init ok");
LOG_DEBUG("%s init ok", deviceType() == ScanI2C::DeviceType::SC7A20 ? "SC7A20" : "LIS3DH");
return true;
}
LOG_DEBUG("LIS3DH init failed");
LOG_DEBUG("%s init failed", deviceType() == ScanI2C::DeviceType::SC7A20 ? "SC7A20" : "LIS3DH");
return false;
}
+23
View File
@@ -637,6 +637,21 @@ class NimbleBluetoothFromRadioCallback : public BLECharacteristicCallbacks
}
};
// One log notify per log line shares the msys_1 mbuf pool with the fromNum doorbell and ATT
// responses, so a logging burst starves them; back off on rejection until the pool refills.
static constexpr uint32_t LOG_NOTIFY_BACKOFF_MS = 250;
static std::atomic<uint32_t> lastLogNotifyFailureMs{0};
class NimbleBluetoothLogRadioCallback : public BLECharacteristicCallbacks
{
void onStatus(BLECharacteristic *, Status s, uint32_t) override
{
// ERROR_GATT is the only status meaning the host refused it; the rest never allocated.
if (s == Status::ERROR_GATT)
lastLogNotifyFailureMs.store(millis());
}
};
class NimbleBluetoothSecurityCallback : public BLESecurityCallbacks
{
void onPassKeyNotify(uint32_t passkey) override
@@ -1005,6 +1020,9 @@ void NimbleBluetooth::setupService()
static NimbleBluetoothFromRadioCallback fromRadioCallbacks;
FromRadioCharacteristic->setCallbacks(&fromRadioCallbacks);
static NimbleBluetoothLogRadioCallback logRadioCallbacks;
logRadioCharacteristic->setCallbacks(&logRadioCallbacks);
bleService->start();
// Setup the battery service
@@ -1056,6 +1074,11 @@ void NimbleBluetooth::sendLog(const uint8_t *logMessage, size_t length)
if (!isConnected() || length > 512) {
return;
}
if (!logRadioCharacteristic) // BLE may have been torn down; never notify a freed characteristic
return;
// Pool still under pressure; drop this line rather than spend a buffer fromNum needs.
if (Throttle::isWithinTimespanMs(lastLogNotifyFailureMs.load(), LOG_NOTIFY_BACKOFF_MS))
return;
logRadioCharacteristic->setValue(logMessage, length);
logRadioCharacteristic->notify();
}
+2
View File
@@ -198,6 +198,8 @@
#define HW_VENDOR meshtastic_HardwareModel_T_DECK_PRO
#elif defined(T_BEAM_1W)
#define HW_VENDOR meshtastic_HardwareModel_TBEAM_1_WATT
#elif defined(T_BEAM_BPF)
#define HW_VENDOR meshtastic_HardwareModel_TBEAM_BPF
#elif defined(T_LORA_PAGER)
#define HW_VENDOR meshtastic_HardwareModel_T_LORA_PAGER
#elif defined(HELTEC_V4)
+4 -1
View File
@@ -18,7 +18,10 @@ class NRF52CryptoEngine : public CryptoEngine
} else if (_key.length > 0) {
nRFCrypto.begin();
nRFCrypto_AES ctx;
uint8_t myLen = ctx.blockLen(numBytes);
// Must not be uint8_t: blockLen() rounds up to the AES block size, so numBytes in
// 241..256 yields 256, which truncates to 0 and leaves Process() writing numBytes
// attacker-controlled bytes onto a zero-length stack buffer.
size_t myLen = ctx.blockLen(numBytes);
char encBuf[myLen] = {0};
ctx.begin();
ctx.Process((char *)bytes, numBytes, _nonce, _key.bytes, _key.length, encBuf, ctx.encryptFlag, ctx.ctrMode);
+2
View File
@@ -99,6 +99,8 @@
#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M6
#elif defined(ELECROW_ThinkNode_M4)
#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M4
#elif defined(ELECROW_ThinkNode_M8)
#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M8
#elif defined(NANO_G2_ULTRA)
#define HW_VENDOR meshtastic_HardwareModel_NANO_G2_ULTRA
#elif defined(CANARYONE)
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#ifndef ARCH_PORTDUINO_WASM
#include <string>
#include <vector>
// Validates the loaded meshtasticd YAML, prints a report, and returns an exit code: 0 when
// nothing fatal was found, 1 otherwise. configFiles lists every attempted file in load order.
int runConfigCheck(const std::vector<std::string> &configFiles);
#endif // !ARCH_PORTDUINO_WASM
+74 -23
View File
@@ -6,6 +6,7 @@
#include "sleep.h"
#include "target_specific.h"
#include "ConfigCheck.h"
#include "PortduinoGlue.h"
#include "SHA256.h"
#include "api/ServerAPI.h"
@@ -65,6 +66,9 @@ char *configPath = nullptr;
char *optionMac = nullptr;
bool verboseEnabled = false;
bool yamlOnly = false;
bool configCheck = false;
// Every config file we attempted to load, in load order, for --check to report on.
std::vector<std::string> attemptedConfigFiles;
const char *argp_program_version = optstr(APP_VERSION);
@@ -86,9 +90,16 @@ void updateBatteryLevel(uint8_t level) NOT_IMPLEMENTED("updateBatteryLevel");
int TCPPort = SERVER_API_DEFAULT_PORT;
bool checkConfigPort = true;
// Long-only option: argp treats any key above the printable ASCII range as having no
// single-character equivalent.
#define OPT_CONFIG_CHECK 1001
static error_t parse_opt(int key, char *arg, struct argp_state *state)
{
switch (key) {
case OPT_CONFIG_CHECK:
configCheck = true;
break;
case 'p':
if (sscanf(arg, "%d", &TCPPort) < 1) {
return ARGP_ERR_UNKNOWN;
@@ -160,13 +171,15 @@ static void checkSpidevBufsiz()
void portduinoCustomInit()
{
static struct argp_option options[] = {{"port", 'p', "PORT", 0, "The TCP port to use."},
{"config", 'c', "CONFIG_PATH", 0, "Full path of the .yaml config file to use."},
{"hwid", 'h', "HWID", 0, "The mac address to assign to this virtual machine"},
{"sim", 's', 0, 0, "Run in Simulated radio mode"},
{"verbose", 'v', 0, 0, "Set log level to full debug"},
{"output-yaml", 'y', 0, 0, "Output config yaml and exit"},
{0}};
static struct argp_option options[] = {
{"port", 'p', "PORT", 0, "The TCP port to use."},
{"config", 'c', "CONFIG_PATH", 0, "Full path of the .yaml config file to use."},
{"hwid", 'h', "HWID", 0, "The mac address to assign to this virtual machine"},
{"sim", 's', 0, 0, "Run in Simulated radio mode"},
{"verbose", 'v', 0, 0, "Set log level to full debug"},
{"output-yaml", 'y', 0, 0, "Output config yaml and exit"},
{"check", OPT_CONFIG_CHECK, 0, 0, "Check the configuration for problems, print a report, and exit"},
{0}};
static void *childArguments;
static char doc[] = "Meshtastic native build.";
static char args_doc[] = "...";
@@ -310,40 +323,54 @@ void portduinoSetup()
portduino_config.lora_module = use_simradio;
} else if (configPath != nullptr) {
if (loadConfig(configPath)) {
if (!yamlOnly)
if (!yamlOnly && !configCheck)
std::cout << "Using " << configPath << " as config file" << std::endl;
} else {
} else if (!configCheck) {
// In check mode the path is already in attemptedConfigFiles, so fall through
// to runConfigCheck() and let it report the parse error with a file and line.
std::cout << "Unable to use " << configPath << " as config file" << std::endl;
exit(EXIT_FAILURE);
}
} else if (access("config.yaml", R_OK) == 0) {
if (loadConfig("config.yaml")) {
if (!yamlOnly)
if (!yamlOnly && !configCheck)
std::cout << "Using local config.yaml as config file" << std::endl;
} else {
} else if (!configCheck) {
std::cout << "Unable to use local config.yaml as config file" << std::endl;
exit(EXIT_FAILURE);
}
} else if (access("/etc/meshtasticd/config.yaml", R_OK) == 0) {
if (loadConfig("/etc/meshtasticd/config.yaml")) {
if (!yamlOnly)
if (!yamlOnly && !configCheck)
std::cout << "Using /etc/meshtasticd/config.yaml as config file" << std::endl;
} else {
} else if (!configCheck) {
std::cout << "Unable to use /etc/meshtasticd/config.yaml as config file" << std::endl;
exit(EXIT_FAILURE);
}
} else {
if (!yamlOnly)
if (!yamlOnly && !configCheck)
std::cout << "No 'config.yaml' found..." << std::endl;
portduino_config.lora_module = use_simradio;
}
if (portduino_config.config_directory != "") {
std::string filetype = ".yaml";
for (const std::filesystem::directory_entry &entry :
std::filesystem::directory_iterator{portduino_config.config_directory}) {
// The throwing form of directory_iterator turns an unreadable ConfigDirectory into an
// uncaught filesystem_error and a SIGABRT, so take the error_code overload instead.
std::error_code dirError;
std::filesystem::directory_iterator entries{portduino_config.config_directory, dirError};
if (dirError) {
// Half a configuration is worse than none. --check continues so the report can say
// so with the rest of the findings.
if (!configCheck) {
std::cout << "Unable to read ConfigDirectory " << portduino_config.config_directory << ": " << dirError.message()
<< std::endl;
exit(EXIT_FAILURE);
}
}
for (const std::filesystem::directory_entry &entry : entries) {
if (ends_with(entry.path().string(), ".yaml")) {
std::cout << "Also using " << entry << " as additional config file" << std::endl;
if (!configCheck)
std::cout << "Also using " << entry << " as additional config file" << std::endl;
// .string() rather than .c_str(): path::value_type is wchar_t on
// Windows, and loadConfig() takes a const char *.
loadConfig(entry.path().string().c_str());
@@ -352,6 +379,11 @@ void portduinoSetup()
}
#ifndef ARCH_PORTDUINO_WASM
// --check wins over --output-yaml: asking for validation and getting a config dump
// with no report at all would be the more surprising of the two outcomes.
if (configCheck)
exit(runConfigCheck(attemptedConfigFiles));
if (yamlOnly) {
std::cout << portduino_config.emit_yaml() << std::endl;
exit(EXIT_SUCCESS);
@@ -828,6 +860,10 @@ bool loadConfig(const char *configPath)
#else
bool loadConfig(const char *configPath)
{
// Recorded even when the load below fails: an unparseable config.d entry is skipped and its
// return value discarded by the caller, so --check needs to know it was attempted.
attemptedConfigFiles.push_back(configPath);
YAML::Node yamlConfig;
try {
yamlConfig = YAML::LoadFile(configPath);
@@ -886,7 +922,9 @@ bool loadConfig(const char *configPath)
break;
}
}
if (!found) {
if (!found && !configCheck) {
// --check names the valid modules in its report; exiting here would
// replace that with a bare one-liner.
std::cerr << "Unknown Lora.Module: " << moduleName << std::endl;
exit(EXIT_FAILURE);
}
@@ -899,6 +937,10 @@ bool loadConfig(const char *configPath)
portduino_config.lr1110_max_power = yamlConfig["Lora"]["LR1110_MAX_POWER"].as<int>(22);
if (yamlConfig["Lora"]["LR1120_MAX_POWER"])
portduino_config.lr1120_max_power = yamlConfig["Lora"]["LR1120_MAX_POWER"].as<int>(13);
if (yamlConfig["Lora"]["LR2021_MAX_POWER"])
portduino_config.lr2021_max_power = yamlConfig["Lora"]["LR2021_MAX_POWER"].as<int>(22);
if (yamlConfig["Lora"]["LR2021_MAX_POWER_HF"])
portduino_config.lr2021_max_power_hf = yamlConfig["Lora"]["LR2021_MAX_POWER_HF"].as<int>(12);
if (yamlConfig["Lora"]["RF95_MAX_POWER"])
portduino_config.rf95_max_power = yamlConfig["Lora"]["RF95_MAX_POWER"].as<int>(20);
@@ -1077,7 +1119,9 @@ bool loadConfig(const char *configPath)
}
}
#if !defined(HAS_HUB75_NATIVE)
if (portduino_config.displayPanel == hub75) {
if (portduino_config.displayPanel == hub75 && !configCheck) {
// --check still validates the rest of the file and reports this as a
// finding, so it must not exit from inside the load.
std::cerr << "HUB75 display panel selected, but this build does not support HUB75" << std::endl;
exit(EXIT_FAILURE);
}
@@ -1221,8 +1265,12 @@ bool loadConfig(const char *configPath)
(yamlConfig["General"]["AvailableDirectory"]).as<std::string>("/etc/meshtasticd/available.d/");
if ((yamlConfig["General"]["MACAddress"]).as<std::string>("") != "" &&
(yamlConfig["General"]["MACAddressSource"]).as<std::string>("") != "") {
std::cout << "Cannot set both MACAddress and MACAddressSource!" << std::endl;
exit(EXIT_FAILURE);
// --check reports this as a finding against the file it came from, so
// exiting here would kill the report before it is printed.
if (!configCheck) {
std::cout << "Cannot set both MACAddress and MACAddressSource!" << std::endl;
exit(EXIT_FAILURE);
}
}
if (checkConfigPort) {
portduino_config.api_port = (yamlConfig["General"]["APIPort"]).as<int>(-1);
@@ -1245,7 +1293,10 @@ bool loadConfig(const char *configPath)
portduino_config.mac_address.end());
}
} catch (YAML::Exception &e) {
std::cout << "*** Exception " << e.what() << std::endl;
// The check report repeats this against the file it came from, so printing it
// here too would only put a stray line above the report.
if (!configCheck)
std::cout << "*** Exception " << e.what() << std::endl;
return false;
}
return true;
+12 -4
View File
@@ -47,7 +47,8 @@ enum lora_module_enum {
use_lr1110,
use_lr1120,
use_lr1121,
use_llcc68
use_llcc68,
use_lr2021
};
struct pinMapping {
@@ -76,9 +77,10 @@ std::string exec(const char *cmd);
extern struct portduino_config_struct {
// Lora
std::map<lora_module_enum, std::string> loraModules = {
{use_simradio, "sim"}, {use_autoconf, "auto"}, {use_rf95, "RF95"}, {use_sx1262, "sx1262"}, {use_sx1268, "sx1268"},
{use_sx1280, "sx1280"}, {use_lr1110, "lr1110"}, {use_lr1120, "lr1120"}, {use_lr1121, "lr1121"}, {use_llcc68, "LLCC68"}};
std::map<lora_module_enum, std::string> loraModules = {{use_simradio, "sim"}, {use_autoconf, "auto"}, {use_rf95, "RF95"},
{use_sx1262, "sx1262"}, {use_sx1268, "sx1268"}, {use_sx1280, "sx1280"},
{use_lr1110, "lr1110"}, {use_lr1120, "lr1120"}, {use_lr1121, "lr1121"},
{use_llcc68, "LLCC68"}, {use_lr2021, "lr2021"}};
std::map<screen_modules, std::string> screen_names = {{x11, "X11"}, {fb, "FB"}, {st7789, "ST7789"},
{st7735, "ST7735"}, {st7735s, "ST7735S"}, {st7796, "ST7796"},
@@ -100,6 +102,8 @@ extern struct portduino_config_struct {
int sx128x_max_power = 13;
int lr1110_max_power = 22;
int lr1120_max_power = 13;
int lr2021_max_power = 22;
int lr2021_max_power_hf = 12;
int rf95_max_power = 20;
bool dio2_as_rf_switch = false;
int dio3_tcxo_voltage = 0;
@@ -287,6 +291,10 @@ extern struct portduino_config_struct {
out << YAML::Key << "LR1110_MAX_POWER" << YAML::Value << lr1110_max_power;
if (lr1120_max_power != 13)
out << YAML::Key << "LR1120_MAX_POWER" << YAML::Value << lr1120_max_power;
if (lr2021_max_power != 22)
out << YAML::Key << "LR2021_MAX_POWER" << YAML::Value << lr2021_max_power;
if (lr2021_max_power_hf != 12)
out << YAML::Key << "LR2021_MAX_POWER_HF" << YAML::Value << lr2021_max_power_hf;
if (rf95_max_power != 20)
out << YAML::Key << "RF95_MAX_POWER" << YAML::Value << rf95_max_power;
+10 -4
View File
@@ -420,13 +420,16 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp,
}
jsonObj["id"] = (Json::UInt)mp->id;
jsonObj["timestamp"] = (Json::UInt)mp->rx_time;
// Emit 0 rather than leak a millis() placeholder when has_rx_time is false.
jsonObj["timestamp"] = mp->has_rx_time ? (Json::UInt)mp->rx_time : 0;
jsonObj["to"] = (Json::UInt)mp->to;
jsonObj["from"] = (Json::UInt)mp->from;
jsonObj["channel"] = (Json::UInt)mp->channel;
jsonObj["type"] = msgType;
jsonObj["sender"] = nodeDB->getNodeId();
if (mp->rx_rssi != 0)
// rx_rssi has explicit presence on the wire (unlike rx_snr): trust has_rx_rssi rather than a
// != 0 heuristic, since 0 dBm is a legitimate reading on some radios.
if (mp->has_rx_rssi)
jsonObj["rssi"] = (int)mp->rx_rssi;
if (mp->rx_snr != 0)
jsonObj["snr"] = (float)mp->rx_snr;
@@ -450,13 +453,16 @@ std::string MeshPacketSerializer::JsonSerializeEncrypted(const meshtastic_MeshPa
jsonObj["id"] = (Json::UInt)mp->id;
jsonObj["time_ms"] = (double)millis();
jsonObj["timestamp"] = (Json::UInt)mp->rx_time;
// Emit 0 rather than leak a millis() placeholder when has_rx_time is false.
jsonObj["timestamp"] = mp->has_rx_time ? (Json::UInt)mp->rx_time : 0;
jsonObj["to"] = (Json::UInt)mp->to;
jsonObj["from"] = (Json::UInt)mp->from;
jsonObj["channel"] = (Json::UInt)mp->channel;
jsonObj["want_ack"] = mp->want_ack;
if (mp->rx_rssi != 0)
// rx_rssi has explicit presence on the wire (unlike rx_snr): trust has_rx_rssi rather than a
// != 0 heuristic, since 0 dBm is a legitimate reading on some radios.
if (mp->has_rx_rssi)
jsonObj["rssi"] = (int)mp->rx_rssi;
if (mp->rx_snr != 0)
jsonObj["snr"] = (float)mp->rx_snr;
+14
View File
@@ -386,6 +386,20 @@ A well-structured test suite follows this pattern:
4. **Lifecycle tests** - state persistence, startup from blank, restart recovery
5. **Summary test** (optional) - emits a scenario table into the log for quick CI review
## Not a Unity suite: `bin/test-config-check.sh`
Portduino YAML validation is tested by driving a built `meshtasticd` rather than by a
Unity suite, because what it asserts - the exit status and printed report of
`meshtasticd --check`, and the fact that a normal run still refuses a bad config - are
properties of the process, not of a linkable function. Fixtures live in
`test/fixtures/portduino-config/` (see the README there); CI runs it in
`test_native.yml`. It is not counted in `native-suite-count`, which only tracks `test_*`
directories.
```bash
pio run -e native && ./bin/test-config-check.sh
```
## Existing Test Suites
| Suite | Module Under Test |
+158
View File
@@ -0,0 +1,158 @@
# Portduino config fixtures
Input files for `bin/test-config-check.sh`, which drives a built `meshtasticd`
binary and asserts what `meshtasticd --check` reports about each one. Every file
here is referenced by name from that script, so renaming one means editing it too.
The theme is configuration that is accepted by the YAML parser but does not mean
what it looks like it means - the failures that otherwise only show up as a radio
that never transmits.
Each file carries a comment header naming its planted fault and what the checker is
expected to say about it, so a fixture can be read on its own. One assertion,
`malformed-indent.yaml`'s reported line number, counts those header lines - editing
that file's comments means updating the expected line in the script.
These files are exempt from `trunk fmt` (see `.trunk/trunk.yaml`): prettier rejects
the duplicate keys and bad indentation that are the entire point of them.
## Valid, one per radio module family
These must each report zero errors and zero warnings, and must resolve to the
module named in the file. A silent fallback to `sim` would otherwise pass.
| File | Module | Family |
| -------------------- | -------- | ---------- |
| `module-rf95.yaml` | `RF95` | SX127x |
| `module-sx1262.yaml` | `sx1262` | SX126x |
| `module-sx1268.yaml` | `sx1268` | SX126x |
| `module-llcc68.yaml` | `LLCC68` | SX126x |
| `module-sx1280.yaml` | `sx1280` | SX128x |
| `module-lr1110.yaml` | `lr1110` | LR11xx |
| `module-lr1120.yaml` | `lr1120` | LR11xx |
| `module-lr1121.yaml` | `lr1121` | LR11xx |
| `module-sim.yaml` | `sim` | simulated |
| `module-auto.yaml` | `auto` | autodetect |
`valid.yaml` is a minimal SX126x config, and `empty-sections.yaml` (`Lora:` with
no body) exists to prove the checker does not invent a finding for it.
## Module naming
Names are matched exactly and are not consistently cased - `RF95` and `LLCC68`
are upper, `sx1262` and `lr1121` lower.
| File | Expected |
| ------------------------ | --------------------------------------------------- |
| `module-unknown.yaml` | `sx1263` is refused, and the valid set is listed. |
| `module-wrong-case.yaml` | `llcc68` is refused with a "did you mean" for case. |
## LR11xx rfswitch table
| File | Expected |
| ------------------------------ | ----------------------------------------------------------------- |
| `rfswitch-valid.yaml` | A full seven-mode table on an `lr1121` is clean. |
| `rfswitch-partial.yaml` | Legal, but the omitted modes are named - they are driven all-LOW. |
| `rfswitch-bad-pin.yaml` | `DIO9` is not one of DIO5/6/7/8/10. |
| `rfswitch-row-length.yaml` | Rows shorter and longer than the declared pin count. |
| `rfswitch-bad-level.yaml` | `high` and `On`: anything not exactly `HIGH` is silently LOW. |
| `rfswitch-no-pins.yaml` | No `pins` list, so no switch pin is ever driven. |
| `rfswitch-too-many-pins.yaml` | Six pins declared; only the first five are read. |
| `rfswitch-not-a-map.yaml` | `rfswitch_table` given a scalar. |
| `rfswitch-unknown-mode.yaml` | `MODE_TRANSMIT` is not a mode. |
| `rfswitch-stranded-modes.yaml` | A `MODE_` row one level out, sitting under `Lora:` doing nothing. |
`rfswitch-partial.yaml` is legal but noted: the omitted modes are driven all-LOW.
`module-mismatch-lr11xx.yaml` (LR11xx with no table - cannot transmit) and
`module-mismatch-sx126x.yaml` (a table on a radio that never applies one) cover
the module/table disagreement in both directions.
## PA gain table (`TX_GAIN_LORA`)
Two shapes are accepted and they fail differently. A list is read element-by-element
with `.as<int>()` and NO fallback, so one bad entry throws and meshtasticd will not
start. A bare scalar is read as `.as<int>(0)` and merely falls back to 0. The table
is `uint16_t[22]`, so extra points are dropped and out-of-range values wrap.
| File | Expected |
| ---------------------------- | ------------------------------------------------------------------------- |
| `txgain-scalar.yaml` | **Clean, and a regression guard** - an earlier checker called this fatal. |
| `value-type-fatal-list.yaml` | A non-numeric list entry: throws, so meshtasticd will not start. |
| `txgain-out-of-range.yaml` | `-5` and `70000` wrap to a different gain than written. |
| `txgain-too-many.yaml` | 25 points; everything past the 22nd is dropped. |
## Value types, ranges and units
| File | Expected |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `value-type-fatal.yaml` | `Logging.AsciiLogs` is the other no-fallback read: a bad value stops meshtasticd starting. |
| `value-type-silent.yaml` | Wrong-typed values where the read has a default: silently replaced, so the setting does nothing. |
| `tcxo-millivolts.yaml` | `DIO3_TCXO_VOLTAGE` is in VOLTS and multiplied by 1000, so `1800` silently asks for 1800V. Write `1.8`. |
| `port-out-of-range.yaml` | `APIPort` outside 1024-65535 is silently ignored; `Webserver.Port` has no guard at all. |
| `statusmessage-long.yaml` | Copied into a `char[80]`, so it is safe but silently shortened to 79 characters. |
| `configdir-missing.yaml` | **Crash regression guard** - an unreadable `ConfigDirectory` used to abort meshtasticd (and `--check`) with SIGABRT via an uncaught `filesystem_error`. |
## MAC address
The MAC no longer determines NodeNum - that comes from the public key - but a MAC
that fails to apply still falls through to the BlueZ and LoRa-serial fallbacks, and
if those yield nothing meshtasticd exits with "Blank MAC Address not allowed!".
| File | Expected |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `mac-conflict.yaml` | Both `MACAddress` and `MACAddressSource`; meshtasticd refuses. |
| `mac-malformed.yaml` | `AA:BB:CC` is under 12 hex digits, so it is silently dropped. |
| `mac-source-missing.yaml` | Names an interface with no `/sys/class/net/<n>/address`. Warning, not an error: it is machine-dependent and may be checked on another host. |
## CH341 USB-SPI adapters
`spidev: ch341` is a different hardware model, not a variant of the same one. The Lora
pins become indexes on the adapter and are driven by the usermode USB driver -
`portduinoSetup()` skips `initGPIOPin()` for every one of them - so nothing is claimed
from a gpiochip. This is also the only shape that works on Windows and macOS, which have
no gpiochip, `gpiodetect` or `gpioinfo` to check anything against.
| File | Expected |
| --------------------- | ----------------------------------------------------------------------------- |
| `usb-ch341.yaml` | Clean, and the report lists adapter pins rather than resolved gpiochip lines. |
| `ch341-gpiochip.yaml` | A gpiochip and line mapping alongside `ch341`: read, stored, and never used. |
## Structure
| File | Expected |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `duplicate-key.yaml` | yaml-cpp keeps the FIRST duplicate, so the later value is lost. |
| `nonmap-section.yaml` | `Lora: invalid` - a known section whose body cannot be read. |
| `unknown-section.yaml` | A top-level section meshtasticd never reads. |
| `stranded-key.yaml` | `spidev` left at the top level instead of inside a section. |
| `top-level-list.yaml` | Document root is a sequence. |
| `empty-file.yaml` | No content: comments only, which parse to a null document. A warning, not an error. |
| `malformed-indent.yaml` | Will not parse; the report must still name the file and line. |
| `pin-unknown-subkey.yaml` | A pin mapping accepts only `pin`, `gpiochip` and `line`. |
| `pin-unreadable.yaml` | A non-numeric pin resolves to -1 and trips an assertion at startup. |
| `hub75-unknown-key.yaml` | An unknown `Display.HUB75` option. On a build without rgbmatrix this also reports the missing HUB75 support, so the test asserts only the unknown key. |
## Across a config directory
`configd-conflict/` is a whole tree: a `config.yaml` pointing at a `config.d/`
holding two more `Lora:` sections. It covers the trap that a key not repeated in
the last-loaded file is reset to its default - here `config.yaml` sets
`DIO3_TCXO_VOLTAGE: 1800` and the effective configuration ends up without it.
The load order within `config.d/` comes from the filesystem, so the report warns
rather than assuming alphabetical order.
`rfswitch-sticky/` covers the one place where "the file loaded last wins" is false,
and it documents a firmware bug rather than a configuration mistake. Its `config.d/`
holds two switch tables; the last one loaded sets `MODE_RX` LOW on both pins, but the
loader only ever writes HIGH and never writes LOW back, so the HIGH from the earlier
file survives and the effective table is the OR of both. Verified with
`meshtasticd --output-yaml`. Until the loader is fixed, `--check` reports this as an
error and tells you to enable exactly one.
## Running these as a normal boot
`malformed-indent.yaml`, `nonmap-section.yaml`, `module-unknown.yaml`,
`mac-conflict.yaml` and `hub75-unknown-key.yaml` are also run _without_ `--check`,
where each must be rejected with a non-zero exit. That is the guard on `--check`
mode not having quietly made the normal path permissive. No other fixture is run
that way: a config meshtasticd accepts makes it boot a node and block.
+16
View File
@@ -0,0 +1,16 @@
# FAULT: a CH341 config that also maps its pins onto a gpiochip. Every Lora pin is
# skipped by the GPIO claim loop when spidev is ch341, so the chip and line numbers below
# are read, stored, and never used - while reading as though they were authoritative.
Lora:
Module: sx1262
spidev: ch341
gpiochip: 4
CS:
pin: 0
gpiochip: 4
line: 21
IRQ: 6
Reset: 2
Busy: 4
USB_PID: 0x5512
USB_VID: 0x1A86
@@ -0,0 +1,6 @@
# Second of three files. Re-opens "Lora:", which is what resets the keys set in
# ../config.yaml that are not repeated here.
Lora:
Module: sx1262
CS: 21
IRQ: 16
@@ -0,0 +1,8 @@
# Third of three files, and the one that wins -- but only because the filesystem
# happens to return it last. config.d/ load order is not guaranteed to be
# alphabetical and can differ between machines, which is why the report warns
# rather than assuming an order.
Lora:
Module: lr1121
CS: 99
IRQ: 16
@@ -0,0 +1,12 @@
# The primary config of a three-file set (see config.d/ alongside it).
# FAULT, and it is invisible in this file alone: two more files in config.d/ each open
# their own "Lora:" section, and the keys below that they do not repeat -- notably
# DIO3_TCXO_VOLTAGE -- are reset to their defaults when the last of them is loaded.
# The effective configuration ends up with no TCXO voltage at all.
General:
ConfigDirectory: config.d/
Lora:
Module: sx1262
spidev: spidev0.0
DIO3_TCXO_VOLTAGE: 1800
CS: 21
+8
View File
@@ -0,0 +1,8 @@
# FAULT, and it used to be a hard crash: a ConfigDirectory that cannot be read made
# std::filesystem::directory_iterator throw an uncaught filesystem_error, aborting
# meshtasticd with SIGABRT -- and aborting --check along with it, so the one tool meant
# to diagnose the problem died on it. It now fails cleanly and is reported here.
Lora:
Module: sx1262
General:
ConfigDirectory: /nonexistent/does-not-exist/
+8
View File
@@ -0,0 +1,8 @@
# FAULT: Lora.CS is defined twice. YAML forbids this but yaml-cpp does not complain:
# it keeps the FIRST occurrence and silently discards the rest, so the CS: 22 below
# looks applied and is not. Expect an ERROR naming both lines.
Lora:
Module: sx1262
CS: 21
CS: 22
IRQ: 16
+2
View File
@@ -0,0 +1,2 @@
# FAULT: a zero-content file. Only comments here, which yaml-cpp parses to a null
# document -- the same as truly empty. Expect a WARNING, not an ERROR.
+5
View File
@@ -0,0 +1,5 @@
# CLEAN, and deliberately so. A section with no body is a null node, not a map.
# loadConfig() tests the section node before reading it, so this is harmless -- the
# checker must not report it. Regression guard for the null-vs-non-map distinction.
Lora:
Display:
+11
View File
@@ -0,0 +1,11 @@
# FAULT: Colours is not a HUB75 option (the real ones are listed in ConfigCheck.cpp).
# On a meshtasticd built without rgbmatrix this also reports the missing HUB75
# support, so the test asserts only the unknown key -- the second finding depends on
# how the binary was built.
Lora:
Module: sx1262
Display:
Panel: HUB75
HUB75:
Rows: 64
Colours: 3
+9
View File
@@ -0,0 +1,9 @@
# FAULT: MACAddress and MACAddressSource are mutually exclusive; meshtasticd exits
# rather than pick one. That exit is inside loadConfig(), so --check has to report it
# without letting the exit kill the report. Also run without --check, where
# meshtasticd must refuse to start.
General:
MACAddress: AA:BB:CC:DD:EE:FF
MACAddressSource: eth0
Lora:
Module: sx1262
+9
View File
@@ -0,0 +1,9 @@
# FAULT: AA:BB:CC is three octets, not six. loadConfig() strips the colons and then
# requires more than 11 characters, so this is dropped without a word and the MAC
# comes from the Bluetooth adapter or the LoRa serial instead. NodeNum is unaffected
# (that comes from the public key), but if no fallback yields a MAC, meshtasticd
# exits with "Blank MAC Address not allowed!".
General:
MACAddress: AA:BB:CC
Lora:
Module: sx1262
@@ -0,0 +1,7 @@
# FAULT: names a network interface that does not exist, so /sys/class/net/<n>/address
# reads back empty and no MAC is set. A WARNING rather than an ERROR: which interfaces
# exist is a property of the machine, and a config is often checked on another host.
General:
MACAddressSource: nosuchiface99
Lora:
Module: sx1262

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