Merge branch 'meshtastic:develop' into develop
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
tasks:
|
||||
- init: pip install platformio && pip install --upgrade pip
|
||||
+17
-9
@@ -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
@@ -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
@@ -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 \
|
||||
|
||||
Executable
+391
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
+1
-1
Submodule protobufs updated: bfd718fa1d...cd290ba246
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -46,6 +46,7 @@ class ScanI2C
|
||||
QMA6100P,
|
||||
MPU6050,
|
||||
LIS3DH,
|
||||
SC7A20, // LIS3DH register map, different WHO_AM_I
|
||||
BMA423,
|
||||
BQ24295,
|
||||
LSM6DS3,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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) \
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user