diff --git a/.github/actions/setup-native/action.yml b/.github/actions/setup-native/action.yml index 3a38a4a22..7ed22b37d 100644 --- a/.github/actions/setup-native/action.yml +++ b/.github/actions/setup-native/action.yml @@ -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 diff --git a/.github/workflows/build_windows_bin.yml b/.github/workflows/build_windows_bin.yml index 5348a1163..8ad2acefd 100644 --- a/.github/workflows/build_windows_bin.yml +++ b/.github/workflows/build_windows_bin.yml @@ -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 diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index c3c035244..d7a80fbc6 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -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 diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index f41a91199..c5186c9f1 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -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, diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 0af235a3a..000000000 --- a/.gitpod.yml +++ /dev/null @@ -1,2 +0,0 @@ -tasks: - - init: pip install platformio && pip install --upgrade pip diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 7bec30998..741a51e58 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -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 diff --git a/Dockerfile b/Dockerfile index a8d886569..3472c9d22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/alpine.Dockerfile b/alpine.Dockerfile index e82230d61..8b5ace187 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -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 \ diff --git a/bin/test-config-check.sh b/bin/test-config-check.sh new file mode 100755 index 000000000..b3027fe7b --- /dev/null +++ b/bin/test-config-check.sh @@ -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 [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 diff --git a/boards/ThinkNode-M8.json b/boards/ThinkNode-M8.json new file mode 100644 index 000000000..acd7f89c0 --- /dev/null +++ b/boards/ThinkNode-M8.json @@ -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" +} diff --git a/boards/t-beam-bpf.json b/boards/t-beam-bpf.json new file mode 100644 index 000000000..50afb7900 --- /dev/null +++ b/boards/t-beam-bpf.json @@ -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" +} diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md index 33b0c366d..71266d2a1 100644 --- a/docs/node_info_stores.md +++ b/docs/node_info_stores.md @@ -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 diff --git a/docs/traffic_management_module.md b/docs/traffic_management_module.md index bb533edbe..99305009d 100644 --- a/docs/traffic_management_module.md +++ b/docs/traffic_management_module.md @@ -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 diff --git a/platformio.ini b/platformio.ini index 2fea56967..040da932c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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] diff --git a/protobufs b/protobufs index bfd718fa1..cd290ba24 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit bfd718fa1dcb019ed11b7b7185f37318abebdafc +Subproject commit cd290ba246fb5130cb449055248f7e22c15bcafb diff --git a/src/MessageStore.cpp b/src/MessageStore.cpp index 8b6775eec..4030bdd28 100644 --- a/src/MessageStore.cpp +++ b/src/MessageStore.cpp @@ -215,7 +215,12 @@ const StoredMessage *MessageStore::tryAddFromPacket(const meshtastic_MeshPacket sm.channelIndex = packet.channel; const char *payload = reinterpret_cast(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; diff --git a/src/Power.cpp b/src/Power.cpp index 3a2bea10c..a886082c6 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -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 diff --git a/src/UptimeClock.cpp b/src/UptimeClock.cpp new file mode 100644 index 000000000..5f85f50ff --- /dev/null +++ b/src/UptimeClock.cpp @@ -0,0 +1,33 @@ +// See UptimeClock.h for the full contract. +#include "UptimeClock.h" +#include + +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(highWord) << 32) | now; +} diff --git a/src/UptimeClock.h b/src/UptimeClock.h new file mode 100644 index 000000000..9329a7bf6 --- /dev/null +++ b/src/UptimeClock.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +// 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 . +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 diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp index a147194a7..a919bd105 100644 --- a/src/detect/ScanI2C.cpp +++ b/src/detect/ScanI2C.cpp @@ -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 diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index 3dcbe3c6a..87965245a 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -46,6 +46,7 @@ class ScanI2C QMA6100P, MPU6050, LIS3DH, + SC7A20, // LIS3DH register map, different WHO_AM_I BMA423, BQ24295, LSM6DS3, diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index c543aac85..be326f4db 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -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); diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 94288529e..217bbb4c3 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -2,6 +2,7 @@ #include "configuration.h" #include "detect/ScanI2C.h" #include "main.h" +#include "mesh/MeshService.h" #include "modules/NodeInfoModule.h" #include #include @@ -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 diff --git a/src/graphics/Backlight.cpp b/src/graphics/Backlight.cpp new file mode 100644 index 000000000..111af66d6 --- /dev/null +++ b/src/graphics/Backlight.cpp @@ -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 diff --git a/src/graphics/Backlight.h b/src/graphics/Backlight.h new file mode 100644 index 000000000..449fd5736 --- /dev/null +++ b/src/graphics/Backlight.h @@ -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 diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index 7c8b6ab8e..a44e8ef4b 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -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(*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); diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 7892cbdda..9c1688db4 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -61,6 +61,7 @@ along with this program. If not, see . #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(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(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; diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index b65917dad..d4ff187ec 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -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 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); diff --git a/src/graphics/tftSetup.cpp b/src/graphics/tftSetup.cpp index 708cd8967..95b721e67 100644 --- a/src/graphics/tftSetup.cpp +++ b/src/graphics/tftSetup.cpp @@ -11,6 +11,8 @@ #ifdef ARCH_PORTDUINO #include "PortduinoGlue.h" +#include +#include #include #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); diff --git a/src/input/ButtonThread.cpp b/src/input/ButtonThread.cpp index 481b245da..bf0883691 100644 --- a/src/input/ButtonThread.cpp +++ b/src/input/ButtonThread.cpp @@ -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; diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index d6c8fe832..4f09e3224 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -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) { diff --git a/src/input/RotaryEncoderInterruptBase.cpp b/src/input/RotaryEncoderInterruptBase.cpp index 80ac08175..c177403bf 100644 --- a/src/input/RotaryEncoderInterruptBase.cpp +++ b/src/input/RotaryEncoderInterruptBase.cpp @@ -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; } } diff --git a/src/input/RotaryEncoderInterruptBase.h b/src/input/RotaryEncoderInterruptBase.h index 4f9757609..c6bd79e5c 100644 --- a/src/input/RotaryEncoderInterruptBase.h +++ b/src/input/RotaryEncoderInterruptBase.h @@ -3,9 +3,9 @@ #include "InputBroker.h" #include "concurrency/OSThread.h" #include "mesh/NodeDB.h" +#include 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, public concurrency::OSThread @@ -20,6 +20,10 @@ class RotaryEncoderInterruptBase : public Observable, 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, 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 pressAndTurnDelta{0}; private: // pins and events @@ -43,9 +49,16 @@ class RotaryEncoderInterruptBase : public Observable, 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 diff --git a/src/input/RotaryEncoderInterruptImpl1.cpp b/src/input/RotaryEncoderInterruptImpl1.cpp index 1da2ea008..4c1685d5a 100644 --- a/src/input/RotaryEncoderInterruptImpl1.cpp +++ b/src/input/RotaryEncoderInterruptImpl1.cpp @@ -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; diff --git a/src/mesh/Default.cpp b/src/mesh/Default.cpp index c8cea9d89..95e4132bf 100644 --- a/src/mesh/Default.cpp +++ b/src/mesh/Default.cpp @@ -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 diff --git a/src/mesh/Default.h b/src/mesh/Default.h index b38a68185..e5e8b8ab1 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -5,6 +5,7 @@ #include #include #include +#include #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::type; + static_assert(std::is_integral::value && !std::is_same::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(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 } } -}; \ No newline at end of file +}; diff --git a/src/mesh/InterfacesTemplates.cpp b/src/mesh/InterfacesTemplates.cpp index 6e27b56f3..78d08b05f 100644 --- a/src/mesh/InterfacesTemplates.cpp +++ b/src/mesh/InterfacesTemplates.cpp @@ -27,7 +27,7 @@ template class LR11x0Interface; template class LR11x0Interface; template class LR11x0Interface; #endif -#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1 +#if (defined(USE_LR2021) || defined(ARCH_PORTDUINO)) && RADIOLIB_EXCLUDE_LR2021 != 1 template class LR20x0Interface; #endif #ifdef ARCH_STM32WL diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index b051bc114..c54ae91a6 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -247,6 +247,7 @@ template void LR11x0Interface::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 int16_t LR11x0Interface::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 diff --git a/src/mesh/LR2021Interface.cpp b/src/mesh/LR2021Interface.cpp index 9aa4d5f1a..a3ae448ca 100644 --- a/src/mesh/LR2021Interface.cpp +++ b/src/mesh/LR2021Interface.cpp @@ -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" diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index 95dad82e9..c7d00e3ab 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -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 void LR20x0Interface::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 int16_t LR20x0Interface::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 diff --git a/src/mesh/MeshModule.cpp b/src/mesh/MeshModule.cpp index 5dc7fba4a..d8bff724e 100644 --- a/src/mesh/MeshModule.cpp +++ b/src/mesh/MeshModule.cpp @@ -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); diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 0efd23c81..2f3dbb1a5 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -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; diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index b529d2836..bae955969 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -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: diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index f1ed831f8..0a64a1f1a 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -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 = diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e9e8f7d34..f1a32b4b9 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -202,7 +202,9 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (ostream) { const auto *vec = static_cast *>(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 *>(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)); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index cb93754a6..152fdee1d 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -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) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 28d047f13..f86d25773 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -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; } diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 65549207d..26a765ea8 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -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 diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 043bd59ef..208e8603d 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -401,6 +401,8 @@ std::unique_ptr initLoRa() return std::unique_ptr(new LR1121Interface(hal, cs, irq, rst, busy)); case use_llcc68: return std::unique_ptr(new LLCC68Interface(hal, cs, irq, rst, busy)); + case use_lr2021: + return std::unique_ptr(new LR2021Interface(hal, cs, irq, rst, busy)); case use_simradio: return std::unique_ptr(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"); diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index 7ec6bb4b7..fce6b8a32 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -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; -} \ No newline at end of file +} diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 2ec9b43b3..38048fddb 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -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 diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 4a6356cb5..2a4d979c2 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -7,9 +7,21 @@ #include "PacketHistory.h" #include "PointerQueue.h" #include "RadioInterface.h" +#include "concurrency/LockGuard.h" #include "concurrency/OSThread.h" #include +/// 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 */ diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 817134b58..7c46bee71 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -335,6 +335,7 @@ template void SX126xInterface::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()); } diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 608cd37e0..7848d51db 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -205,6 +205,7 @@ template void SX128xInterface::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()); } diff --git a/src/mesh/TypeConversions.cpp b/src/mesh/TypeConversions.cpp index 0b33578de..9fc80fd86 100644 --- a/src/mesh/TypeConversions.cpp +++ b/src/mesh/TypeConversions.cpp @@ -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; diff --git a/src/mesh/api/PacketAPI.cpp b/src/mesh/api/PacketAPI.cpp index 3f145bfe3..9408e9266 100644 --- a/src/mesh/api/PacketAPI.cpp +++ b/src/mesh/api/PacketAPI.cpp @@ -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) diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 059398cf0..669792ddd 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -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; diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 7ce8115ba..60d817d73 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -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) \ diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index 37ab44524..4a05d1292 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -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()); } } diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 08b94dc04..98a9c9d93 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -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); diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 020e9f0d6..b8e8c59ad 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -40,6 +40,13 @@ class AdminModule : public ProtobufModule, 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 diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index d86878a8d..24e8339e3 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -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); diff --git a/src/modules/DropzoneModule.cpp b/src/modules/DropzoneModule.cpp index 2340f31f2..4658cb5b6 100644 --- a/src/modules/DropzoneModule.cpp +++ b/src/modules/DropzoneModule.cpp @@ -32,14 +32,17 @@ ProcessMessage DropzoneModule::handleReceived(const meshtastic_MeshPacket &mp) auto &p = mp.decoded; char matchCompare[54]; auto incomingMessage = reinterpret_cast(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(); } diff --git a/src/modules/ExternalNotificationModule.h b/src/modules/ExternalNotificationModule.h index 03eac036b..a5b9f68da 100644 --- a/src/modules/ExternalNotificationModule.h +++ b/src/modules/ExternalNotificationModule.h @@ -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 #else // Noop class for portduino. diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 2e9d865a6..cc5c6e807 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -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 ──────────────────────────────────────────────── diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index 5b8d46223..91ec9d633 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -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; } diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 40055006a..cb08503e4 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -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; diff --git a/src/modules/PositionModule.h b/src/modules/PositionModule.h index bfd48047b..03754c22b 100644 --- a/src/modules/PositionModule.h +++ b/src/modules/PositionModule.h @@ -45,6 +45,12 @@ class PositionModule : public ProtobufModule, 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, 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 diff --git a/src/modules/RemoteHardwareModule.cpp b/src/modules/RemoteHardwareModule.cpp index ef2b23c8f..bd5e15655 100644 --- a/src/modules/RemoteHardwareModule.cpp +++ b/src/modules/RemoteHardwareModule.cpp @@ -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 { diff --git a/src/modules/RoutingModule.cpp b/src/modules/RoutingModule.cpp index 1ce7c4502..3d0812e5e 100644 --- a/src/modules/RoutingModule.cpp +++ b/src/modules/RoutingModule.cpp @@ -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(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(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; -} \ No newline at end of file +} diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index de1d6865c..bd023716a 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -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; diff --git a/src/modules/StoreForwardModule.h b/src/modules/StoreForwardModule.h index 77565b22c..82406b70a 100644 --- a/src/modules/StoreForwardModule.h +++ b/src/modules/StoreForwardModule.h @@ -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; diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index 944bc4db4..dc6d2e8d0 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -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]; diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index 23ef56ba8..4ec1adb1c 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -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 diff --git a/src/modules/TraceRouteModule.cpp b/src/modules/TraceRouteModule.cpp index c0cac7123..4d3819f12 100644 --- a/src/modules/TraceRouteModule.cpp +++ b/src/modules/TraceRouteModule.cpp @@ -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(lroundf(snr * 4.0f), -127, 127); + snr_list[*snr_count] = (int8_t)q4; *snr_count += 1; } if (SNRonly) diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h index 6847b9aa4..571767715 100755 --- a/src/motion/AccelerometerThread.h +++ b/src/motion/AccelerometerThread.h @@ -106,6 +106,7 @@ class AccelerometerThread : public concurrency::OSThread break; #if __has_include() case ScanI2C::DeviceType::LIS3DH: + case ScanI2C::DeviceType::SC7A20: sensor = new LIS3DHSensor(device); break; #endif diff --git a/src/motion/LIS3DHSensor.cpp b/src/motion/LIS3DHSensor.cpp index 903cc92f7..a29ff2b85 100755 --- a/src/motion/LIS3DHSensor.cpp +++ b/src/motion/LIS3DHSensor.cpp @@ -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; } diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 647c1bef8..bd3de1601 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -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 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(); } diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index e58a9b520..4cc4461cf 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -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) diff --git a/src/platform/nrf52/NRF52CryptoEngine.cpp b/src/platform/nrf52/NRF52CryptoEngine.cpp index 5de13c58b..e8844065f 100644 --- a/src/platform/nrf52/NRF52CryptoEngine.cpp +++ b/src/platform/nrf52/NRF52CryptoEngine.cpp @@ -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); diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h index 600fb8192..0193eeb5c 100644 --- a/src/platform/nrf52/architecture.h +++ b/src/platform/nrf52/architecture.h @@ -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) diff --git a/src/platform/portduino/ConfigCheck.cpp b/src/platform/portduino/ConfigCheck.cpp new file mode 100644 index 000000000..aa0f0c4cb --- /dev/null +++ b/src/platform/portduino/ConfigCheck.cpp @@ -0,0 +1,1094 @@ +#include "ConfigCheck.h" + +#ifndef ARCH_PORTDUINO_WASM + +#include "configuration.h" + +#include "PortduinoGlue.h" + +#include "yaml-cpp/eventhandler.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- +// Mirrors the keys loadConfig() reads: meshtasticd silently ignores anything else, so +// an unlisted key is honestly "unknown". Teach loadConfig() a new key, add it here too -- +// CI runs --check over bin/config.d/**, so an omission fails the build rather than a user. + +const std::set kLoraPinKeys = {"CS", "IRQ", "Busy", "Reset", "TXen", "RXen", "SX126X_ANT_SW", "GPIO_DETECT_PA"}; + +const std::map> &schema() +{ + static const std::map> s = { + {"Lora", + {"Module", + "gpiochip", + "spidev", + "spiSpeed", + "DIO2_AS_RF_SWITCH", + "DIO3_TCXO_VOLTAGE", + "Enable_Pins", + "rfswitch_table", + "LR1110_MAX_POWER", + "LR1120_MAX_POWER", + "RF95_MAX_POWER", + "SX126X_MAX_POWER", + "SX128X_MAX_POWER", + "TX_GAIN_LORA", + "USB_PID", + "USB_VID", + "USB_Serialnum", + "CS", + "IRQ", + "Busy", + "Reset", + "TXen", + "RXen", + "SX126X_ANT_SW", + "GPIO_DETECT_PA"}}, + {"General", + {"MACAddress", "MACAddressSource", "MaxNodes", "MaxMessageQueue", "APIPort", "ConfigDirectory", "AvailableDirectory"}}, + {"Config", {"DisplayMode", "EnableUDP", "StatusMessage"}}, + {"Display", + {"Panel", "spidev", "BusFrequency", "Width", "Height", "Invert", "Rotate", "OffsetX", "OffsetY", "OffsetRotate", + "RGBOrder", "HUB75", "DC", "CS", "Backlight", "BacklightInvert", "BacklightPWMChannel", "Reset"}}, + {"Touchscreen", {"Module", "spidev", "BusFrequency", "I2CAddr", "Rotate", "CS", "IRQ"}}, + {"Input", + {"KeyboardDevice", "PointerDevice", "JoystickDevice", "JoystickButtons", "TrackballDirection", "User", "TrackballUp", + "TrackballDown", "TrackballLeft", "TrackballRight", "TrackballPress"}}, + {"GPIO", {"User", "ExtraPins"}}, + {"GPS", {"SerialPath", "GpsdHost", "GpsdPort"}}, + {"I2C", {"I2CDevice"}}, + {"Logging", {"LogLevel", "TraceFile", "JSONFile", "JSONFileRotate", "JSONFilter", "AsciiLogs"}}, + {"Webserver", {"Port", "RootPath", "SSLCert", "SSLKey"}}, + {"HostMetrics", {"ReportInterval", "Channel", "UserStringCommand"}}, + // Read by packaging/menu tooling rather than by meshtasticd itself. + {"Meta", {}}, + }; + return s; +} + +// Sections whose contents meshtasticd never reads, so unknown keys inside them are +// not worth reporting. +const std::set kFreeFormSections = {"Meta"}; + +const std::set kPinSubKeys = {"pin", "gpiochip", "line"}; + +const std::set kHub75Keys = { + "HardwareMapping", "Rows", "Cols", "ChainLength", "Parallel", "PWMBits", + "PWMLSBNanoseconds", "Brightness", "ScanMode", "RowAddressType", "Multiplexing", "DisableHardwarePulsing", + "ShowRefreshRate", "InverseColors", "RGBSequence", "PixelMapper", "PanelType", "LimitRefreshRateHz", + "GPIOSlowdown"}; + +const std::set kRfSwitchModes = {"MODE_STBY", "MODE_RX", "MODE_TX", "MODE_TX_HP", + "MODE_TX_HF", "MODE_GNSS", "MODE_WIFI"}; + +const std::set kRfSwitchPins = {"DIO5", "DIO6", "DIO7", "DIO8", "DIO10"}; + +// Reverse index: key name -> sections it is valid in. Powers the "you probably meant +// to nest this under X" hint that turns a silent no-op into an actionable message. +const std::map> &keyOwners() +{ + static const std::map> owners = [] { + std::map> m; + for (const auto §ion : schema()) + for (const auto &key : section.second) + m[key].insert(section.first); + // rfswitch_table's sub-keys are worth hinting on too: a table indented one + // level too far leaves MODE_* rows stranded directly under Lora. + for (const auto &mode : kRfSwitchModes) + m[mode].insert("Lora.rfswitch_table"); + m["pins"].insert("Lora.rfswitch_table"); + return m; + }(); + return owners; +} + +// --------------------------------------------------------------------------- +// Findings +// --------------------------------------------------------------------------- + +enum Level { kInfo, kWarn, kError }; + +struct Finding { + Level level; + std::string file; + int line; // 1-based; 0 when the finding is not tied to a line + std::string message; +}; + +const char *levelName(Level l) +{ + switch (l) { + case kError: + return "ERROR"; + case kWarn: + return "WARN "; + default: + return "INFO "; + } +} + +std::string joinSections(const std::set &s) +{ + std::string out; + for (const auto &item : s) { + if (!out.empty()) + out += " or "; + out += item; + } + return out; +} + +int lineOf(const YAML::Node &node) +{ + const YAML::Mark mark = node.Mark(); + return mark.is_null() ? 0 : mark.line + 1; +} + +// --------------------------------------------------------------------------- +// Duplicate key detection +// --------------------------------------------------------------------------- +// yaml-cpp silently keeps the FIRST of duplicate keys, so a later override looks applied +// but is not. The Node API sees an already-collapsed map, so walk the parser events. + +class DuplicateKeyFinder : public YAML::EventHandler +{ + public: + struct Duplicate { + std::string path; + int line; + int firstLine; + }; + std::vector duplicates; + + void OnDocumentStart(const YAML::Mark &) override {} + void OnDocumentEnd() override {} + void OnNull(const YAML::Mark &, YAML::anchor_t) override { advance(); } + void OnAlias(const YAML::Mark &, YAML::anchor_t) override { advance(); } + void OnAnchor(const YAML::Mark &, const std::string &) override {} + + void OnScalar(const YAML::Mark &mark, const std::string &, YAML::anchor_t, const std::string &value) override + { + if (!stack.empty() && stack.back().isMap && stack.back().expectKey) { + auto &seen = stack.back().seen; + const auto existing = seen.find(value); + if (existing != seen.end()) + duplicates.push_back({pathTo(value), static_cast(mark.line) + 1, existing->second}); + else + seen.emplace(value, static_cast(mark.line) + 1); + stack.back().key = value; + } + advance(); + } + + void OnSequenceStart(const YAML::Mark &, const std::string &, YAML::anchor_t, YAML::EmitterStyle::value) override + { + push(false); + } + void OnSequenceEnd() override { pop(); } + void OnMapStart(const YAML::Mark &, const std::string &, YAML::anchor_t, YAML::EmitterStyle::value) override { push(true); } + void OnMapEnd() override { pop(); } + + private: + struct Context { + bool isMap; + bool expectKey; + std::string key; + std::map seen; + }; + std::vector stack; + + // Inside a mapping the parser alternates key, value, key, value... + void advance() + { + if (!stack.empty() && stack.back().isMap) + stack.back().expectKey = !stack.back().expectKey; + } + void push(bool isMap) + { + advance(); // the collection itself occupies a slot in its parent + stack.push_back(Context{isMap, true, "", {}}); + } + // No advance(): push() already consumed the collection's slot in the parent. Guarded + // because balance rests on yaml-cpp emitting matched start/end events -- its invariant. + void pop() + { + if (!stack.empty()) + stack.pop_back(); + } + std::string pathTo(const std::string &leaf) const + { + std::string path; + for (size_t i = 0; i + 1 < stack.size(); i++) + if (stack[i].isMap && !stack[i].key.empty()) + path += stack[i].key + "."; + return path + leaf; + } +}; + +void checkDuplicateKeys(const std::string &file, std::vector &findings) +{ + std::ifstream stream(file); + if (!stream) + return; + DuplicateKeyFinder finder; + try { + YAML::Parser parser(stream); + while (parser.HandleNextDocument(finder)) { + } + } catch (const std::exception &) { + return; // reported separately by checkFile() + } + for (const auto &dup : finder.duplicates) + findings.push_back({kError, file, dup.line, + "duplicate key '" + dup.path + "' (first defined on line " + std::to_string(dup.firstLine) + + "). yaml-cpp keeps the FIRST occurrence, so this one is silently discarded"}); +} + +// --------------------------------------------------------------------------- +// Structural checks +// --------------------------------------------------------------------------- + +void checkPinNode(const std::string &file, const std::string &path, const YAML::Node &node, std::vector &findings) +{ + if (!node.IsMap()) + return; // plain scalar pin number, always fine + for (const auto &entry : node) { + const std::string key = entry.first.as(""); + if (!kPinSubKeys.count(key)) + findings.push_back({kError, file, lineOf(entry.first), + "unknown key '" + path + "." + key + "'. A pin mapping accepts only pin, gpiochip and line"}); + } +} + +void checkRfSwitchTable(const std::string &file, const YAML::Node &table, std::vector &findings) +{ + if (!table.IsMap()) { + findings.push_back({kError, file, lineOf(table), "Lora.rfswitch_table must be a mapping"}); + return; + } + + size_t pinCount = 0; + if (const YAML::Node pins = table["pins"]) { + if (!pins.IsSequence()) { + findings.push_back({kError, file, lineOf(pins), "Lora.rfswitch_table.pins must be a list"}); + } else { + pinCount = pins.size(); + for (const auto &pin : pins) { + const std::string name = pin.as(""); + if (!kRfSwitchPins.count(name)) + findings.push_back({kError, file, lineOf(pin), + "Lora.rfswitch_table.pins: '" + name + + "' is not a recognised pin. Valid values are DIO5, DIO6, DIO7, DIO8 and DIO10"}); + } + if (pinCount > 5) + findings.push_back( + {kError, file, lineOf(pins), + "Lora.rfswitch_table.pins lists " + std::to_string(pinCount) + " pins but only the first 5 are read"}); + } + } else { + findings.push_back({kError, file, lineOf(table), "Lora.rfswitch_table has no 'pins' list, so no switch pins are driven"}); + } + + for (const auto &entry : table) { + const std::string key = entry.first.as(""); + if (key == "pins") + continue; + if (!kRfSwitchModes.count(key)) { + findings.push_back({kError, file, lineOf(entry.first), "unknown key 'Lora.rfswitch_table." + key + "'"}); + continue; + } + const YAML::Node &row = entry.second; + if (!row.IsSequence()) { + findings.push_back({kError, file, lineOf(row), "Lora.rfswitch_table." + key + " must be a list"}); + continue; + } + if (pinCount && row.size() != pinCount) + findings.push_back({kError, file, lineOf(row), + "Lora.rfswitch_table." + key + " has " + std::to_string(row.size()) + " values but " + + std::to_string(pinCount) + " pins are declared"}); + for (const auto &value : row) { + const std::string level = value.as(""); + if (level != "HIGH" && level != "LOW") + findings.push_back({kError, file, lineOf(value), + "Lora.rfswitch_table." + key + ": '" + level + + "' is not HIGH or LOW. Anything that is not exactly \"HIGH\" is treated as LOW"}); + } + } + + // Every mode absent from the table defaults to all-LOW, which for most modules is + // the shutdown state. Worth saying out loud rather than leaving to be discovered. + std::vector missing; + for (const auto &mode : kRfSwitchModes) + if (!table[mode]) + missing.push_back(mode); + if (!missing.empty()) { + std::string list; + for (const auto &mode : missing) + list += (list.empty() ? "" : ", ") + mode; + findings.push_back( + {kInfo, file, lineOf(table), "Lora.rfswitch_table omits " + list + "; those modes default to all pins LOW"}); + } +} + +// --------------------------------------------------------------------------- +// Value types +// --------------------------------------------------------------------------- +// A wrong-typed value silently does nothing under .as(default), but the two reads with +// NO default (Logging.AsciiLogs, TX_GAIN_LORA) throw and stop meshtasticd. Conversions are +// tested by asking yaml-cpp, so this cannot drift from what loadConfig() accepts. + +enum ValueType { kBool, kInt, kFloat, kString, kIntList, kBoolOrFloat, kIntOrString }; + +struct ValueSpec { + ValueType type; + bool fatal; // read without a default: a bad value stops meshtasticd outright +}; + +const std::map &valueSpecs() +{ + static const std::map s = { + {"Lora.spiSpeed", {kInt, false}}, + {"Lora.gpiochip", {kInt, false}}, + {"Lora.spidev", {kString, false}}, + {"Lora.DIO2_AS_RF_SWITCH", {kBool, false}}, + // Accepts a float (volts) or `true` (meaning 1.8V), so both are allowed here. + {"Lora.DIO3_TCXO_VOLTAGE", {kBoolOrFloat, false}}, + {"Lora.LR1110_MAX_POWER", {kInt, false}}, + {"Lora.LR1120_MAX_POWER", {kInt, false}}, + {"Lora.RF95_MAX_POWER", {kInt, false}}, + {"Lora.SX126X_MAX_POWER", {kInt, false}}, + {"Lora.SX128X_MAX_POWER", {kInt, false}}, + // TX_GAIN_LORA is not in this table: it accepts a list OR a bare scalar, and + // only the list path is fatal. See checkTxGain(). + {"Lora.USB_PID", {kInt, false}}, + {"Lora.USB_VID", {kInt, false}}, + {"Lora.USB_Serialnum", {kString, false}}, + {"General.MaxNodes", {kInt, false}}, + {"General.MaxMessageQueue", {kInt, false}}, + {"General.APIPort", {kInt, false}}, + {"General.ConfigDirectory", {kString, false}}, + {"General.AvailableDirectory", {kString, false}}, + {"Config.DisplayMode", {kString, false}}, + {"Config.EnableUDP", {kBool, false}}, + {"Config.StatusMessage", {kString, false}}, + {"Display.Panel", {kString, false}}, + {"Display.spidev", {kString, false}}, + {"Display.BusFrequency", {kInt, false}}, + {"Display.Width", {kInt, false}}, + {"Display.Height", {kInt, false}}, + {"Display.Invert", {kBool, false}}, + {"Display.Rotate", {kInt, false}}, + {"Display.OffsetX", {kInt, false}}, + {"Display.OffsetY", {kInt, false}}, + {"Display.OffsetRotate", {kInt, false}}, + {"Display.RGBOrder", {kBool, false}}, + {"Display.BacklightInvert", {kBool, false}}, + {"Touchscreen.Module", {kString, false}}, + {"Touchscreen.spidev", {kString, false}}, + {"Touchscreen.BusFrequency", {kInt, false}}, + {"Touchscreen.I2CAddr", {kInt, false}}, + {"Touchscreen.Rotate", {kBool, false}}, + {"Input.KeyboardDevice", {kString, false}}, + {"Input.PointerDevice", {kString, false}}, + {"Input.JoystickDevice", {kString, false}}, + {"Input.TrackballDirection", {kString, false}}, + {"GPS.SerialPath", {kString, false}}, + {"GPS.GpsdHost", {kString, false}}, + {"GPS.GpsdPort", {kInt, false}}, + {"I2C.I2CDevice", {kString, false}}, + {"Logging.LogLevel", {kString, false}}, + {"Logging.TraceFile", {kString, false}}, + {"Logging.JSONFile", {kString, false}}, + {"Logging.JSONFileRotate", {kInt, false}}, + // Read as an int and as a string (the "textmessage"-style aliases). + {"Logging.JSONFilter", {kIntOrString, false}}, + {"Logging.AsciiLogs", {kBool, true}}, + {"Webserver.Port", {kInt, false}}, + {"Webserver.RootPath", {kString, false}}, + {"Webserver.SSLCert", {kString, false}}, + {"Webserver.SSLKey", {kString, false}}, + {"HostMetrics.ReportInterval", {kInt, false}}, + {"HostMetrics.Channel", {kInt, false}}, + {"HostMetrics.UserStringCommand", {kString, false}}, + {"Display.HUB75.Rows", {kInt, false}}, + {"Display.HUB75.Cols", {kInt, false}}, + {"Display.HUB75.ChainLength", {kInt, false}}, + {"Display.HUB75.Parallel", {kInt, false}}, + {"Display.HUB75.PWMBits", {kInt, false}}, + {"Display.HUB75.PWMLSBNanoseconds", {kInt, false}}, + {"Display.HUB75.Brightness", {kInt, false}}, + {"Display.HUB75.ScanMode", {kInt, false}}, + {"Display.HUB75.RowAddressType", {kInt, false}}, + {"Display.HUB75.Multiplexing", {kInt, false}}, + {"Display.HUB75.GPIOSlowdown", {kInt, false}}, + {"Display.HUB75.LimitRefreshRateHz", {kInt, false}}, + {"Display.HUB75.DisableHardwarePulsing", {kBool, false}}, + {"Display.HUB75.ShowRefreshRate", {kBool, false}}, + {"Display.HUB75.InverseColors", {kBool, false}}, + {"Display.HUB75.HardwareMapping", {kString, false}}, + {"Display.HUB75.RGBSequence", {kString, false}}, + {"Display.HUB75.PixelMapper", {kString, false}}, + {"Display.HUB75.PanelType", {kString, false}}, + }; + return s; +} + +const char *typeName(ValueType type) +{ + switch (type) { + case kBool: + return "a true/false value"; + case kInt: + return "a whole number"; + case kFloat: + return "a number"; + case kIntList: + return "a list of whole numbers"; + case kBoolOrFloat: + return "a number or true/false"; + case kIntOrString: + return "a whole number or a name"; + default: + return "a text value"; + } +} + +// Ask yaml-cpp to perform the same conversion loadConfig() will. +bool converts(const YAML::Node &node, ValueType type) +{ + try { + switch (type) { + case kBool: + node.as(); + return true; + case kInt: + node.as(); + return true; + case kFloat: + node.as(); + return true; + case kBoolOrFloat: + try { + node.as(); + return true; + } catch (const std::exception &) { + node.as(); + return true; + } + case kIntOrString: + try { + node.as(); + return true; + } catch (const std::exception &) { + node.as(); + return true; + } + case kIntList: + if (!node.IsSequence()) + return false; + for (const auto &item : node) + item.as(); + return true; + default: + node.as(); + return true; + } + } catch (const std::exception &) { + return false; + } +} + +void checkValueType(const std::string &file, const std::string &path, const YAML::Node &value, std::vector &findings) +{ + const auto spec = valueSpecs().find(path); + if (spec == valueSpecs().end() || converts(value, spec->second.type)) + return; + + if (spec->second.fatal) + findings.push_back({kError, file, lineOf(value), + path + " is not " + typeName(spec->second.type) + + ". This one is read without a fallback, so the conversion throws and meshtasticd " + "refuses to start on this file"}); + else + findings.push_back({kWarn, file, lineOf(value), + path + " is not " + typeName(spec->second.type) + + ", so it is silently replaced by the default and the setting does nothing"}); +} + +// The PA gain table's two shapes fail differently: a bad list entry stops meshtasticd (no +// default), a bad scalar falls back to 0. Backed by uint16_t[22], so extras drop and values wrap. +void checkTxGain(const std::string &file, const YAML::Node &node, std::vector &findings) +{ + constexpr size_t kMaxPaPoints = 22; + + if (node.IsSequence()) { + if (node.size() > kMaxPaPoints) + findings.push_back({kWarn, file, lineOf(node), + "Lora.TX_GAIN_LORA lists " + std::to_string(node.size()) + " points but only the first " + + std::to_string(kMaxPaPoints) + " are stored; the rest are dropped"}); + for (const auto &point : node) { + if (!converts(point, kInt)) { + findings.push_back({kError, file, lineOf(point), + "Lora.TX_GAIN_LORA entry '" + point.as("") + + "' is not a whole number. List entries are read without a fallback, so this " + "throws and meshtasticd refuses to start on this file"}); + continue; + } + const int value = point.as(); + // Stored into a uint16_t, so anything outside the range silently wraps. + if (value < 0 || value > 65535) + findings.push_back({kError, file, lineOf(point), + "Lora.TX_GAIN_LORA entry " + std::to_string(value) + + " does not fit the 0-65535 range it is stored in, so it wraps to a different " + "gain than the one written"}); + } + return; + } + + if (node.IsMap()) { + findings.push_back({kError, file, lineOf(node), "Lora.TX_GAIN_LORA must be a list of gain points, or a single number"}); + return; + } + + if (!converts(node, kInt)) + findings.push_back({kWarn, file, lineOf(node), + "Lora.TX_GAIN_LORA is not a whole number, so it is silently read as 0 and no PA gain is applied"}); +} + +// Module names match exactly and are inconsistently cased (RF95 upper, sx1262 lower), +// and loadConfig() exits on an unknown name without printing the valid set, so name it here. +void checkLoraModule(const std::string &file, const YAML::Node &module, std::vector &findings) +{ + const std::string name = module.as(""); + if (name.empty()) + return; + + std::string valid; + std::string caseHint; + for (const auto &known : portduino_config.loraModules) { + if (name == known.second) + return; + valid += (valid.empty() ? "" : ", ") + known.second; + if (caseHint.empty() && name.size() == known.second.size() && + std::equal(name.begin(), name.end(), known.second.begin(), [](char a, char b) { + // Cast first: tolower() on a negative char is undefined, and a stray + // non-ASCII byte in a hand-edited config is exactly how that happens. + return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); + })) + caseHint = known.second; + } + + std::string message = + "Lora.Module '" + name + "' is not a module meshtasticd knows, and it refuses to start. Valid: " + valid; + if (!caseHint.empty()) + message += ". The name is matched exactly -- did you mean '" + caseHint + "'?"; + findings.push_back({kError, file, lineOf(module), message}); +} + +// loadConfig() rejects a bad MACAddress or MACAddressSource silently, falling through to the +// BlueZ and LoRa-serial fallbacks; if those yield nothing, meshtasticd exits on a blank MAC. +void checkMacAddress(const std::string &file, const YAML::Node &general, std::vector &findings) +{ + const YAML::Node address = general["MACAddress"]; + const YAML::Node source = general["MACAddressSource"]; + const std::string addressText = address ? address.as("") : ""; + const std::string sourceText = source ? source.as("") : ""; + + if (!addressText.empty() && !sourceText.empty()) { + findings.push_back({kError, file, lineOf(source), + "General.MACAddress and General.MACAddressSource are both set. meshtasticd refuses to start " + "with both; keep whichever one you want the MAC to come from"}); + return; + } + + if (!addressText.empty()) { + std::string digits; + for (const char c : addressText) + if (c != ':' && c != '-') + digits += c; + const bool hex = digits.find_first_not_of("0123456789abcdefABCDEF") == std::string::npos; + // loadConfig() strips the colons and then requires more than 11 characters; + // anything shorter is dropped without a word and the MAC comes from elsewhere. + if (digits.size() < 12 || !hex) + findings.push_back({kError, file, lineOf(address), + "General.MACAddress '" + addressText + + "' is not 12 hex digits, so it is ignored and the MAC falls back to the Bluetooth " + "adapter or the LoRa device serial. If neither yields one, meshtasticd exits with " + "'Blank MAC Address not allowed!'"}); + } + + if (!sourceText.empty()) { + const std::string path = "/sys/class/net/" + sourceText + "/address"; + std::ifstream probe(path); + if (!probe.good()) + findings.push_back({kWarn, file, lineOf(source), + "General.MACAddressSource '" + sourceText + "' has no " + path + + " on this machine, so the MAC reads back empty and meshtasticd asks you to set one. " + "Expected if you are checking this config on a different host"}); + } +} + +void checkSection(const std::string &file, const std::string §ion, const YAML::Node &body, std::vector &findings) +{ + const auto &allowed = schema().at(section); + if (kFreeFormSections.count(section)) + return; + // An empty section (`Lora:` with no body) is null, not a map, and loadConfig() tests + // before reading it. Any other non-map shape is unreadable, so do not call the file clean. + if (body.IsNull()) + return; + if (!body.IsMap()) { + findings.push_back({kError, file, lineOf(body), "'" + section + "' is not a mapping, so nothing in it is read"}); + return; + } + + for (const auto &entry : body) { + const std::string key = entry.first.as(""); + const YAML::Node &value = entry.second; + + if (!allowed.count(key)) { + std::string message = "unknown key '" + section + "." + key + "', ignored by meshtasticd"; + const auto owner = keyOwners().find(key); + if (owner != keyOwners().end() && !owner->second.count(section)) + message += ". It is a valid key of " + joinSections(owner->second); + findings.push_back({kWarn, file, lineOf(entry.first), message}); + continue; + } + + checkValueType(file, section + "." + key, value, findings); + + if (section == "Lora" && key == "rfswitch_table") { + checkRfSwitchTable(file, value, findings); + } else if (section == "Lora" && key == "Module") { + checkLoraModule(file, value, findings); + } else if (section == "Lora" && key == "TX_GAIN_LORA") { + checkTxGain(file, value, findings); + } else if (section == "Display" && key == "HUB75") { + if (value.IsMap()) + for (const auto &hub : value) { + const std::string hubKey = hub.first.as(""); + if (!kHub75Keys.count(hubKey)) + findings.push_back( + {kWarn, file, lineOf(hub.first), "unknown key 'Display.HUB75." + hubKey + "', ignored"}); + else + checkValueType(file, "Display.HUB75." + hubKey, hub.second, findings); + } + } else if (key == "Enable_Pins" || key == "ExtraPins") { + if (value.IsSequence()) + for (const auto &pin : value) + checkPinNode(file, section + "." + key, pin, findings); + } else if (key == "JoystickButtons") { + // Free-form: any action name mapped to an evdev code. + } else if ((section == "Lora" && kLoraPinKeys.count(key)) || + (section == "Display" && + (key == "DC" || key == "CS" || key == "Backlight" || key == "BacklightPWMChannel" || key == "Reset")) || + (section == "Touchscreen" && (key == "CS" || key == "IRQ")) || + (section == "Input" && (key == "User" || key.rfind("Trackball", 0) == 0)) || + (section == "GPIO" && key == "User")) { + checkPinNode(file, section + "." + key, value, findings); + } + } +} + +// --------------------------------------------------------------------------- +// Cross-file overlap +// --------------------------------------------------------------------------- +// Every .yaml loads into the same portduino_config, so two files setting the same key +// are not merged: the one loaded LAST wins -- the opposite of the within-file rule. + +// path (below the top-level section) -> file -> line of its first appearance there +using PathIndex = std::map>; + +void collectPaths(const std::string &file, const YAML::Node &node, const std::string &prefix, int depth, PathIndex &index) +{ + if (!node.IsMap()) + return; + for (const auto &entry : node) { + const std::string key = entry.first.as(""); + const std::string path = prefix.empty() ? key : prefix + "." + key; + // depth 0 is the top-level section; several files each having a "Lora:" is + // normal, so only record what lives inside a section. + if (depth >= 1) + index[path].emplace(file, lineOf(entry.first)); + collectPaths(file, entry.second, path, depth + 1, index); + } +} + +void checkFile(const std::string &file, std::vector &findings, PathIndex &paths, + std::map> §ionOwners) +{ + YAML::Node doc; + try { + doc = YAML::LoadFile(file); + } catch (const std::exception &e) { + findings.push_back({kError, file, 0, std::string("could not be parsed, so it is being ignored entirely: ") + e.what()}); + return; + } + + if (doc.IsNull()) { + findings.push_back({kWarn, file, 0, "is empty"}); + return; + } + if (!doc.IsMap()) { + findings.push_back({kError, file, 0, "top level is not a mapping, so nothing in it is read"}); + return; + } + + checkDuplicateKeys(file, findings); + collectPaths(file, doc, "", 0, paths); + + for (const auto &entry : doc) { + const std::string sectionName = entry.first.as(""); + // A file that repeats a section still counts once here; the repeat itself is + // reported separately as a duplicate key. + auto &owners = sectionOwners[sectionName]; + if (schema().count(sectionName) && (owners.empty() || owners.back() != file)) + owners.push_back(file); + } + + for (const auto &entry : doc) { + const std::string section = entry.first.as(""); + if (schema().count(section)) { + checkSection(file, section, entry.second, findings); + if (section == "General" && entry.second.IsMap()) + checkMacAddress(file, entry.second, findings); + continue; + } + std::string message = "unknown top-level section '" + section + "', ignored by meshtasticd"; + const auto owner = keyOwners().find(section); + if (owner != keyOwners().end()) + message += ". '" + section + "' is a key of " + joinSections(owner->second) + + " -- indent it one level so it sits " + "inside that section"; + findings.push_back({kError, file, lineOf(entry.first), message}); + } +} + +std::string describeOwners(const std::map &owners) +{ + std::string out; + for (const auto &owner : owners) { + if (!out.empty()) + out += ", "; + out += owner.first; + if (owner.second) + out += " line " + std::to_string(owner.second); + } + return out; +} + +void checkCrossFileOverlap(const PathIndex &paths, const std::map> §ionOwners, + std::vector &findings) +{ + const std::string across = "(across configuration files)"; + + for (const auto &entry : paths) { + if (entry.second.size() < 2) + continue; + // If an ancestor also collides then the whole subtree is replaced together; + // reporting the parent once is clearer than repeating every leaf under it. + bool coveredByAncestor = false; + std::string ancestor = entry.first; + for (size_t dot = ancestor.rfind('.'); dot != std::string::npos; dot = ancestor.rfind('.')) { + ancestor.resize(dot); + const auto found = paths.find(ancestor); + if (found != paths.end() && found->second.size() >= 2) { + coveredByAncestor = true; + break; + } + } + if (coveredByAncestor) + continue; + + // The one place "last wins" is untrue: the loader only ever writes HIGH, so the effective + // table is the OR of every table loaded -- a switch state belonging to none of them. + if (entry.first == "Lora.rfswitch_table") { + findings.push_back({kError, across, 0, + "'Lora.rfswitch_table' is set in " + std::to_string(entry.second.size()) + " files (" + + describeOwners(entry.second) + + "). These do NOT override each other: the loader only ever writes HIGH, so a HIGH " + "from an earlier file survives a later file that sets LOW there, and the radio ends " + "up driving the OR of every table. Enable exactly one"}); + continue; + } + + findings.push_back({kInfo, across, 0, + "'" + entry.first + "' is set in " + std::to_string(entry.second.size()) + " files (" + + describeOwners(entry.second) + "). The file loaded last wins"}); + } + + // Sections do not merge, and these keys are assigned unconditionally with a default, so a + // later file with a Lora section that omits them silently resets them. + const auto loraOwners = sectionOwners.find("Lora"); + if (loraOwners != sectionOwners.end() && loraOwners->second.size() > 1) { + std::string files; + for (const auto &file : loraOwners->second) + files += (files.empty() ? "" : ", ") + file; + findings.push_back( + {kWarn, across, 0, + std::to_string(loraOwners->second.size()) + " files define a 'Lora:' section (" + files + + "). These keys are re-read with a default every time a Lora section is seen, so any of them not repeated " + "in the last file loaded is reset: spidev, spiSpeed, gpiochip, DIO2_AS_RF_SWITCH, DIO3_TCXO_VOLTAGE, " + "USB_PID, USB_VID, USB_Serialnum. Normally exactly one Lora config should be enabled at a time"}); + } +} + +// --------------------------------------------------------------------------- +// Semantic checks against the merged configuration +// --------------------------------------------------------------------------- + +bool isLR11xx(lora_module_enum module) +{ + return module == use_lr1110 || module == use_lr1120 || module == use_lr1121; +} + +std::string moduleName() +{ + const auto it = portduino_config.loraModules.find(portduino_config.lora_module); + return it == portduino_config.loraModules.end() ? "unknown" : it->second; +} + +// A pin whose value will not convert falls back to RADIOLIB_NC (-1) while still marked enabled, +// and initGPIOPin() then trips an assertion inside LinuxGPIOPin rather than failing cleanly. +void checkPinValues(std::vector &findings) +{ + const std::string merged = "(merged configuration)"; + + auto report = [&](const std::string &name) { + findings.push_back({kError, merged, 0, + name + " is set, but its value could not be read as a pin number so it resolves to -1. " + "meshtasticd aborts with an assertion when it tries to claim that line. Check for a " + "non-numeric value, or a stray line folded into it by YAML indentation"}); + }; + + for (const auto *pin : portduino_config.all_pins) + if (pin->enabled && pin->pin < 0) + report(pin->config_section + "." + pin->config_name); + for (const auto &pin : portduino_config.extra_pins) + if (pin.enabled && pin.pin < 0) + report(pin.config_section + "." + pin.config_name); +} + +void checkMergedConfig(const PathIndex &paths, std::vector &findings) +{ + const std::string merged = "(merged configuration)"; + + checkPinValues(findings); + + // portduinoSetup() skips initGPIOPin() for every Lora pin when spidev is ch341, so a + // gpiochip or line mapping written next to one is read, stored, and never used. + if (portduino_config.lora_spi_dev == "ch341") { + auto endsWith = [](const std::string &text, const std::string &suffix) { + return text.size() >= suffix.size() && text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0; + }; + std::string ignored; + for (const auto &entry : paths) { + if (entry.first.rfind("Lora.", 0) != 0) + continue; + if (entry.first == "Lora.gpiochip" || endsWith(entry.first, ".gpiochip") || endsWith(entry.first, ".line")) + ignored += (ignored.empty() ? "" : ", ") + entry.first; + } + if (!ignored.empty()) + findings.push_back({kWarn, merged, 0, + "Lora.spidev is ch341, so the Lora pins are indexes on the USB adapter and are driven by " + "the usermode driver rather than claimed from a gpiochip. " + + ignored + " are read but never used"}); + } + + if (isLR11xx(portduino_config.lora_module) && !portduino_config.has_rfswitch_table) + findings.push_back({kWarn, merged, 0, + "Module is " + moduleName() + + " but no Lora.rfswitch_table is set, so setRfSwitchTable() is never called. Most LR11xx " + "modules cannot transmit or receive without one"}); + + if (!isLR11xx(portduino_config.lora_module) && portduino_config.has_rfswitch_table) + findings.push_back( + {kWarn, merged, 0, + "a Lora.rfswitch_table is set but Module is " + moduleName() + ", and the table is only applied to LR11xx radios"}); + + // Either way -- the old uncaught filesystem_error abort or today's clean exit -- the files + // meant to configure the radio are not being loaded. + if (!portduino_config.config_directory.empty()) { + std::error_code error; + if (!std::filesystem::is_directory(portduino_config.config_directory, error)) + findings.push_back({kError, merged, 0, + "General.ConfigDirectory '" + portduino_config.config_directory + + "' is not a directory that can be read, so none of the files that were meant to be " + "loaded from it are being loaded and meshtasticd stops at startup"}); + } + + // strncpy into a char[80] that is then hard null-terminated: safe, but silently + // shortened, and the operator never sees the message they actually configured. + if (portduino_config.has_statusMessage && portduino_config.statusMessage.size() > 79) + findings.push_back({kWarn, merged, 0, + "Config.StatusMessage is " + std::to_string(portduino_config.statusMessage.size()) + + " characters and is truncated to 79 when it is stored"}); + + // DIO3_TCXO_VOLTAGE is in VOLTS while every other Meshtastic surface uses millivolts, so + // the natural "1800" silently asks for 1800V and nothing downstream range-checks it. + if (portduino_config.dio3_tcxo_voltage > 3600) + findings.push_back({kError, merged, 0, + "Lora.DIO3_TCXO_VOLTAGE resolves to " + std::to_string(portduino_config.dio3_tcxo_voltage) + + " mV, which no TCXO runs at. The value is in VOLTS and is multiplied by 1000, so write " + "1.8 (or true) for a 1.8V part, not 1800"}); + + // loadConfig() only adopts APIPort inside this range and otherwise keeps the + // default without a word, so an out-of-range port silently does nothing. + if (portduino_config.api_port != -1 && (portduino_config.api_port <= 1023 || portduino_config.api_port >= 65536)) + findings.push_back({kWarn, merged, 0, + "General.APIPort " + std::to_string(portduino_config.api_port) + + " is outside 1024-65535, so it is ignored and the default port is used instead"}); + + if (portduino_config.webserverport != -1 && (portduino_config.webserverport <= 0 || portduino_config.webserverport >= 65536)) + findings.push_back({kError, merged, 0, + "Webserver.Port " + std::to_string(portduino_config.webserverport) + " is not a usable TCP port"}); + + if (portduino_config.MaxNodes <= 0) + findings.push_back({kError, merged, 0, + "General.MaxNodes is " + std::to_string(portduino_config.MaxNodes) + + ", which leaves no room for even this node's own entry"}); + +#if !defined(HAS_HUB75_NATIVE) + // A build-time gap rather than a config error: the same file is valid on a + // meshtasticd built with rpi-rgb-led-matrix present. + if (portduino_config.displayPanel == hub75) + findings.push_back({kError, merged, 0, + "Display.Panel is HUB75 but this meshtasticd was built without HUB75 support, so it exits at " + "startup. Rebuild with hzeller/rpi-rgb-led-matrix installed (it provides rgbmatrix.pc)"}); +#endif + + if (portduino_config.lora_cs_pin.enabled && !portduino_config.lora_spi_dev.empty() && + portduino_config.lora_spi_dev != "ch341") + findings.push_back({kInfo, merged, 0, + "both Lora.spidev (" + portduino_config.lora_spi_dev + + ") and Lora.CS are set. If your device tree already assigns a chip select to that spidev " + "node, meshtasticd will fail to claim the CS line at startup; if it does not, CS must be " + "set here. Check with 'gpioinfo'"}); +} + +void printSummary() +{ + std::cout << "\nEffective radio configuration:\n"; + std::cout << " Module : " << moduleName() << "\n"; + if (!portduino_config.lora_spi_dev.empty()) + std::cout << " spidev : " << portduino_config.lora_spi_dev << "\n"; + std::cout << " SPI speed : " << portduino_config.spiSpeed << "\n"; + if (portduino_config.dio3_tcxo_voltage) + std::cout << " DIO3 TCXO voltage : " << portduino_config.dio3_tcxo_voltage << " mV\n"; + + // setRfSwitchTable() is only called for an LR11xx, so "not set" is no gap elsewhere; "auto" + // has not resolved to a module yet, so absence cannot be called either way. + const char *rfSwitch = "not needed for this module"; + if (portduino_config.has_rfswitch_table) + rfSwitch = "set"; + else if (isLR11xx(portduino_config.lora_module)) + rfSwitch = "not set"; + else if (portduino_config.lora_module == use_autoconf) + rfSwitch = "not set (module not resolved yet)"; + std::cout << " RF switch table : " << rfSwitch << "\n"; + + // A ch341 adapter's Lora pins are adapter indexes handed to Ch341Hal, not gpiochip lines + // (portduinoSetup() skips initGPIOPin()), so pointing the user at gpioinfo would be wrong. + const bool usbAdapter = portduino_config.lora_spi_dev == "ch341"; + + std::cout << (usbAdapter ? "\nCH341 adapter pins (driven over USB, not claimed from a gpiochip):\n" + : "\nResolved GPIO lines (what meshtasticd will try to claim):\n"); + bool any = false; + for (const auto *pin : portduino_config.all_pins) { + if (!pin->enabled || pin->config_section != "Lora") + continue; + any = true; + std::cout << " " << pin->config_name; + for (size_t i = pin->config_name.size(); i < 18; i++) + std::cout << ' '; + std::cout << ": pin " << pin->pin; + if (!usbAdapter) + std::cout << " gpiochip" << pin->gpiochip << " line " << pin->line; + std::cout << "\n"; + } + if (!any) + std::cout << " (none configured)\n"; + if (usbAdapter) + std::cout << "\n These are pin indexes on the CH341 itself, so 'gpiodetect' and 'gpioinfo' say\n" + " nothing about them. Lora.gpiochip and any per-pin gpiochip/line mapping are\n" + " ignored for a ch341 device.\n"; + else + std::cout << "\n Confirm these against 'gpiodetect' and 'gpioinfo' on this machine. A line that\n" + " exists on the wrong chip is claimed successfully and silently does nothing.\n"; +} + +} // namespace + +int runConfigCheck(const std::vector &configFiles) +{ + std::vector findings; + + std::cout << "meshtasticd configuration check\n"; + std::cout << "===============================\n\n"; + + if (configFiles.empty()) { + std::cout << "No configuration files were found.\n"; + return 1; + } + + std::cout << "Configuration files, in load order (later files override earlier ones):\n"; + for (size_t i = 0; i < configFiles.size(); i++) + std::cout << " " << (i + 1) << ". " << configFiles[i] << "\n"; + if (configFiles.size() > 1) + std::cout << "\n Files in the config directory are read in whatever order the filesystem\n" + " returns them, which is not necessarily alphabetical and can differ between\n" + " machines. Avoid relying on one file overriding another.\n"; + + PathIndex paths; + std::map> sectionOwners; + for (const auto &file : configFiles) + checkFile(file, findings, paths, sectionOwners); + checkCrossFileOverlap(paths, sectionOwners, findings); + checkMergedConfig(paths, findings); + + int errors = 0, warnings = 0; + std::string currentFile; + for (const auto &finding : findings) { + if (finding.level == kError) + errors++; + else if (finding.level == kWarn) + warnings++; + + if (finding.file != currentFile) { + currentFile = finding.file; + std::cout << "\n" << currentFile << "\n"; + } + std::cout << " " << levelName(finding.level) << ' '; + if (finding.line) + std::cout << "line " << finding.line << ": "; + std::cout << finding.message << "\n"; + } + + printSummary(); + + std::cout << "\nResult: " << errors << (errors == 1 ? " error, " : " errors, ") << warnings + << (warnings == 1 ? " warning" : " warnings") << "\n"; + if (errors == 0 && warnings == 0) + std::cout << "Configuration looks good.\n"; + + return errors ? 1 : 0; +} + +#endif // !ARCH_PORTDUINO_WASM diff --git a/src/platform/portduino/ConfigCheck.h b/src/platform/portduino/ConfigCheck.h new file mode 100644 index 000000000..77aeb53dc --- /dev/null +++ b/src/platform/portduino/ConfigCheck.h @@ -0,0 +1,12 @@ +#pragma once + +#ifndef ARCH_PORTDUINO_WASM + +#include +#include + +// 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 &configFiles); + +#endif // !ARCH_PORTDUINO_WASM diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 750396769..bc714fcbe 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -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 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(22); if (yamlConfig["Lora"]["LR1120_MAX_POWER"]) portduino_config.lr1120_max_power = yamlConfig["Lora"]["LR1120_MAX_POWER"].as(13); + if (yamlConfig["Lora"]["LR2021_MAX_POWER"]) + portduino_config.lr2021_max_power = yamlConfig["Lora"]["LR2021_MAX_POWER"].as(22); + if (yamlConfig["Lora"]["LR2021_MAX_POWER_HF"]) + portduino_config.lr2021_max_power_hf = yamlConfig["Lora"]["LR2021_MAX_POWER_HF"].as(12); if (yamlConfig["Lora"]["RF95_MAX_POWER"]) portduino_config.rf95_max_power = yamlConfig["Lora"]["RF95_MAX_POWER"].as(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("/etc/meshtasticd/available.d/"); if ((yamlConfig["General"]["MACAddress"]).as("") != "" && (yamlConfig["General"]["MACAddressSource"]).as("") != "") { - 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(-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; diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index b5a47787e..a6797c292 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -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 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 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_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; diff --git a/src/serialization/MeshPacketSerializer.cpp b/src/serialization/MeshPacketSerializer.cpp index 7dd728284..f889c4f3c 100644 --- a/src/serialization/MeshPacketSerializer.cpp +++ b/src/serialization/MeshPacketSerializer.cpp @@ -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; diff --git a/test/README.md b/test/README.md index 68b271f1e..4961e36d7 100644 --- a/test/README.md +++ b/test/README.md @@ -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 | diff --git a/test/fixtures/portduino-config/README.md b/test/fixtures/portduino-config/README.md new file mode 100644 index 000000000..3768f55b4 --- /dev/null +++ b/test/fixtures/portduino-config/README.md @@ -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()` and NO fallback, so one bad entry throws and meshtasticd will not +start. A bare scalar is read as `.as(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//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. diff --git a/test/fixtures/portduino-config/ch341-gpiochip.yaml b/test/fixtures/portduino-config/ch341-gpiochip.yaml new file mode 100644 index 000000000..f647ee714 --- /dev/null +++ b/test/fixtures/portduino-config/ch341-gpiochip.yaml @@ -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 diff --git a/test/fixtures/portduino-config/configd-conflict/config.d/lora-a.yaml b/test/fixtures/portduino-config/configd-conflict/config.d/lora-a.yaml new file mode 100644 index 000000000..d0e548ae4 --- /dev/null +++ b/test/fixtures/portduino-config/configd-conflict/config.d/lora-a.yaml @@ -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 diff --git a/test/fixtures/portduino-config/configd-conflict/config.d/lora-b.yaml b/test/fixtures/portduino-config/configd-conflict/config.d/lora-b.yaml new file mode 100644 index 000000000..0377d2a83 --- /dev/null +++ b/test/fixtures/portduino-config/configd-conflict/config.d/lora-b.yaml @@ -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 diff --git a/test/fixtures/portduino-config/configd-conflict/config.yaml b/test/fixtures/portduino-config/configd-conflict/config.yaml new file mode 100644 index 000000000..b5a8b91d3 --- /dev/null +++ b/test/fixtures/portduino-config/configd-conflict/config.yaml @@ -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 diff --git a/test/fixtures/portduino-config/configdir-missing.yaml b/test/fixtures/portduino-config/configdir-missing.yaml new file mode 100644 index 000000000..17eaaaf22 --- /dev/null +++ b/test/fixtures/portduino-config/configdir-missing.yaml @@ -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/ diff --git a/test/fixtures/portduino-config/duplicate-key.yaml b/test/fixtures/portduino-config/duplicate-key.yaml new file mode 100644 index 000000000..ec5bfbfe4 --- /dev/null +++ b/test/fixtures/portduino-config/duplicate-key.yaml @@ -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 diff --git a/test/fixtures/portduino-config/empty-file.yaml b/test/fixtures/portduino-config/empty-file.yaml new file mode 100644 index 000000000..158da87e2 --- /dev/null +++ b/test/fixtures/portduino-config/empty-file.yaml @@ -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. diff --git a/test/fixtures/portduino-config/empty-sections.yaml b/test/fixtures/portduino-config/empty-sections.yaml new file mode 100644 index 000000000..fb374ce70 --- /dev/null +++ b/test/fixtures/portduino-config/empty-sections.yaml @@ -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: diff --git a/test/fixtures/portduino-config/hub75-unknown-key.yaml b/test/fixtures/portduino-config/hub75-unknown-key.yaml new file mode 100644 index 000000000..fc5e9e474 --- /dev/null +++ b/test/fixtures/portduino-config/hub75-unknown-key.yaml @@ -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 diff --git a/test/fixtures/portduino-config/mac-conflict.yaml b/test/fixtures/portduino-config/mac-conflict.yaml new file mode 100644 index 000000000..ed4431cb2 --- /dev/null +++ b/test/fixtures/portduino-config/mac-conflict.yaml @@ -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 diff --git a/test/fixtures/portduino-config/mac-malformed.yaml b/test/fixtures/portduino-config/mac-malformed.yaml new file mode 100644 index 000000000..3311fa7c0 --- /dev/null +++ b/test/fixtures/portduino-config/mac-malformed.yaml @@ -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 diff --git a/test/fixtures/portduino-config/mac-source-missing.yaml b/test/fixtures/portduino-config/mac-source-missing.yaml new file mode 100644 index 000000000..e7fc527c7 --- /dev/null +++ b/test/fixtures/portduino-config/mac-source-missing.yaml @@ -0,0 +1,7 @@ +# FAULT: names a network interface that does not exist, so /sys/class/net//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 diff --git a/test/fixtures/portduino-config/malformed-indent.yaml b/test/fixtures/portduino-config/malformed-indent.yaml new file mode 100644 index 000000000..03a1198d0 --- /dev/null +++ b/test/fixtures/portduino-config/malformed-indent.yaml @@ -0,0 +1,8 @@ +# FAULT: three-space indent on the CS line below, so the file will not parse at all. +# The point of this fixture is that --check still reaches its report and names the +# file and line, rather than exiting early with a bare "Unable to use ...". +# Also run without --check, where meshtasticd must refuse to start. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 diff --git a/test/fixtures/portduino-config/module-auto.yaml b/test/fixtures/portduino-config/module-auto.yaml new file mode 100644 index 000000000..eb5f51f1b --- /dev/null +++ b/test/fixtures/portduino-config/module-auto.yaml @@ -0,0 +1,3 @@ +# CLEAN. Autodetect: the module is probed at startup, so no pins are required here. +Lora: + Module: auto diff --git a/test/fixtures/portduino-config/module-llcc68.yaml b/test/fixtures/portduino-config/module-llcc68.yaml new file mode 100644 index 000000000..38dc23288 --- /dev/null +++ b/test/fixtures/portduino-config/module-llcc68.yaml @@ -0,0 +1,9 @@ +# CLEAN. Valid SX126x config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: LLCC68 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 diff --git a/test/fixtures/portduino-config/module-lr1110.yaml b/test/fixtures/portduino-config/module-lr1110.yaml new file mode 100644 index 000000000..e3f5233fc --- /dev/null +++ b/test/fixtures/portduino-config/module-lr1110.yaml @@ -0,0 +1,18 @@ +# CLEAN. Valid LR11xx config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: lr1110 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + rfswitch_table: + pins: [DIO5, DIO6, DIO7, DIO8] + MODE_STBY: [LOW, LOW, LOW, LOW] + MODE_RX: [HIGH, LOW, LOW, LOW] + MODE_TX: [HIGH, HIGH, LOW, LOW] + MODE_TX_HP: [LOW, HIGH, LOW, LOW] + MODE_TX_HF: [LOW, LOW, LOW, LOW] + MODE_GNSS: [LOW, LOW, HIGH, LOW] + MODE_WIFI: [LOW, LOW, LOW, HIGH] diff --git a/test/fixtures/portduino-config/module-lr1120.yaml b/test/fixtures/portduino-config/module-lr1120.yaml new file mode 100644 index 000000000..de6261184 --- /dev/null +++ b/test/fixtures/portduino-config/module-lr1120.yaml @@ -0,0 +1,18 @@ +# CLEAN. Valid LR11xx config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: lr1120 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + rfswitch_table: + pins: [DIO5, DIO6, DIO7, DIO8] + MODE_STBY: [LOW, LOW, LOW, LOW] + MODE_RX: [HIGH, LOW, LOW, LOW] + MODE_TX: [HIGH, HIGH, LOW, LOW] + MODE_TX_HP: [LOW, HIGH, LOW, LOW] + MODE_TX_HF: [LOW, LOW, LOW, LOW] + MODE_GNSS: [LOW, LOW, HIGH, LOW] + MODE_WIFI: [LOW, LOW, LOW, HIGH] diff --git a/test/fixtures/portduino-config/module-lr1121.yaml b/test/fixtures/portduino-config/module-lr1121.yaml new file mode 100644 index 000000000..5beed4b59 --- /dev/null +++ b/test/fixtures/portduino-config/module-lr1121.yaml @@ -0,0 +1,18 @@ +# CLEAN. Valid LR11xx config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: lr1121 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + rfswitch_table: + pins: [DIO5, DIO6, DIO7, DIO8] + MODE_STBY: [LOW, LOW, LOW, LOW] + MODE_RX: [HIGH, LOW, LOW, LOW] + MODE_TX: [HIGH, HIGH, LOW, LOW] + MODE_TX_HP: [LOW, HIGH, LOW, LOW] + MODE_TX_HF: [LOW, LOW, LOW, LOW] + MODE_GNSS: [LOW, LOW, HIGH, LOW] + MODE_WIFI: [LOW, LOW, LOW, HIGH] diff --git a/test/fixtures/portduino-config/module-mismatch-lr11xx.yaml b/test/fixtures/portduino-config/module-mismatch-lr11xx.yaml new file mode 100644 index 000000000..f2486297d --- /dev/null +++ b/test/fixtures/portduino-config/module-mismatch-lr11xx.yaml @@ -0,0 +1,7 @@ +# FAULT: an LR11xx with no rfswitch_table. setRfSwitchTable() is never called, and +# most LR11xx modules cannot transmit or receive without one. Expect a WARNING from +# the merged-configuration checks rather than from any single file. +Lora: + Module: lr1121 + CS: 21 + IRQ: 16 diff --git a/test/fixtures/portduino-config/module-mismatch-sx126x.yaml b/test/fixtures/portduino-config/module-mismatch-sx126x.yaml new file mode 100644 index 000000000..881afa827 --- /dev/null +++ b/test/fixtures/portduino-config/module-mismatch-sx126x.yaml @@ -0,0 +1,13 @@ +# FAULT: the opposite direction -- a switch table on a radio that never applies one. +# The table is only handed to LR11xx parts, so this one is silently inert. +Lora: + Module: sx1262 + rfswitch_table: + pins: [DIO5] + MODE_STBY: [LOW] + MODE_RX: [HIGH] + MODE_TX: [HIGH] + MODE_TX_HP: [HIGH] + MODE_TX_HF: [LOW] + MODE_GNSS: [LOW] + MODE_WIFI: [LOW] diff --git a/test/fixtures/portduino-config/module-rf95.yaml b/test/fixtures/portduino-config/module-rf95.yaml new file mode 100644 index 000000000..483ae7bc4 --- /dev/null +++ b/test/fixtures/portduino-config/module-rf95.yaml @@ -0,0 +1,10 @@ +# CLEAN. Valid SX127x config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: RF95 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + RF95_MAX_POWER: 20 diff --git a/test/fixtures/portduino-config/module-sim.yaml b/test/fixtures/portduino-config/module-sim.yaml new file mode 100644 index 000000000..f4bc6be65 --- /dev/null +++ b/test/fixtures/portduino-config/module-sim.yaml @@ -0,0 +1,3 @@ +# CLEAN. The simulated radio takes no pins or SPI device at all. +Lora: + Module: sim diff --git a/test/fixtures/portduino-config/module-sx1262.yaml b/test/fixtures/portduino-config/module-sx1262.yaml new file mode 100644 index 000000000..9023f41a3 --- /dev/null +++ b/test/fixtures/portduino-config/module-sx1262.yaml @@ -0,0 +1,12 @@ +# CLEAN. Valid SX126x config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: sx1262 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + DIO2_AS_RF_SWITCH: true + DIO3_TCXO_VOLTAGE: 1.8 + SX126X_MAX_POWER: 22 diff --git a/test/fixtures/portduino-config/module-sx1268.yaml b/test/fixtures/portduino-config/module-sx1268.yaml new file mode 100644 index 000000000..e4cb1651f --- /dev/null +++ b/test/fixtures/portduino-config/module-sx1268.yaml @@ -0,0 +1,9 @@ +# CLEAN. Valid SX126x config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: sx1268 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 diff --git a/test/fixtures/portduino-config/module-sx1280.yaml b/test/fixtures/portduino-config/module-sx1280.yaml new file mode 100644 index 000000000..21d244a82 --- /dev/null +++ b/test/fixtures/portduino-config/module-sx1280.yaml @@ -0,0 +1,10 @@ +# CLEAN. Valid SX128x config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: sx1280 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + SX128X_MAX_POWER: 13 diff --git a/test/fixtures/portduino-config/module-unknown.yaml b/test/fixtures/portduino-config/module-unknown.yaml new file mode 100644 index 000000000..b2026bc92 --- /dev/null +++ b/test/fixtures/portduino-config/module-unknown.yaml @@ -0,0 +1,4 @@ +# FAULT: sx1263 is not a module meshtasticd knows. loadConfig() exits on an unknown +# name, so --check has to report it instead and list the valid set. +Lora: + Module: sx1263 diff --git a/test/fixtures/portduino-config/module-wrong-case.yaml b/test/fixtures/portduino-config/module-wrong-case.yaml new file mode 100644 index 000000000..84b9bbafa --- /dev/null +++ b/test/fixtures/portduino-config/module-wrong-case.yaml @@ -0,0 +1,4 @@ +# FAULT: module names are matched EXACTLY and are not consistently cased -- the +# accepted spelling is LLCC68, not llcc68. Expect an ERROR with a "did you mean". +Lora: + Module: llcc68 diff --git a/test/fixtures/portduino-config/nonmap-section.yaml b/test/fixtures/portduino-config/nonmap-section.yaml new file mode 100644 index 000000000..aead4153d --- /dev/null +++ b/test/fixtures/portduino-config/nonmap-section.yaml @@ -0,0 +1,4 @@ +# FAULT: a known section whose body is a scalar. loadConfig() throws when it indexes +# into it ("operator[] call on a scalar"), so nothing in the file is read. Expect an +# ERROR. Also run without --check, where meshtasticd must refuse to start. +Lora: invalid diff --git a/test/fixtures/portduino-config/pin-unknown-subkey.yaml b/test/fixtures/portduino-config/pin-unknown-subkey.yaml new file mode 100644 index 000000000..1c5ffae96 --- /dev/null +++ b/test/fixtures/portduino-config/pin-unknown-subkey.yaml @@ -0,0 +1,8 @@ +# FAULT: chipline is not a pin-mapping sub-key -- only pin, gpiochip and line are +# read. The typo means the line number is never applied. Expect an ERROR. +Lora: + Module: sx1262 + CS: + pin: 21 + gpiochip: 0 + chipline: 4 diff --git a/test/fixtures/portduino-config/pin-unreadable.yaml b/test/fixtures/portduino-config/pin-unreadable.yaml new file mode 100644 index 000000000..4d87da5e2 --- /dev/null +++ b/test/fixtures/portduino-config/pin-unreadable.yaml @@ -0,0 +1,7 @@ +# FAULT: a pin value that is not a number. It is still marked enabled but resolves to +# -1 (RADIOLIB_NC), and initGPIOPin() then trips an assertion inside LinuxGPIOPin at +# startup. Expect an ERROR -- catching it here is the difference between a report and +# a stack trace from a library file. +Lora: + Module: sx1262 + CS: not-a-number diff --git a/test/fixtures/portduino-config/port-out-of-range.yaml b/test/fixtures/portduino-config/port-out-of-range.yaml new file mode 100644 index 000000000..db6914229 --- /dev/null +++ b/test/fixtures/portduino-config/port-out-of-range.yaml @@ -0,0 +1,9 @@ +# FAULT: loadConfig() only adopts APIPort when it is inside 1024-65535 and otherwise +# keeps the default without a word, so this is silently ignored. Webserver.Port has no +# such guard at all, so an unusable port there is an error rather than a warning. +Lora: + Module: sx1262 +General: + APIPort: 80 +Webserver: + Port: 99999 diff --git a/test/fixtures/portduino-config/rfswitch-bad-level.yaml b/test/fixtures/portduino-config/rfswitch-bad-level.yaml new file mode 100644 index 000000000..efcab98fa --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-bad-level.yaml @@ -0,0 +1,9 @@ +# FAULT: anything that is not the exact string "HIGH" is treated as LOW, so the +# lowercase "high" and the "On" below are silently switches that never close -- the +# quietest way to end up with a radio that cannot transmit. Expect an ERROR each. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO6] + MODE_STBY: [LOW, LOW] + MODE_RX: [high, On] diff --git a/test/fixtures/portduino-config/rfswitch-bad-pin.yaml b/test/fixtures/portduino-config/rfswitch-bad-pin.yaml new file mode 100644 index 000000000..110bbebe4 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-bad-pin.yaml @@ -0,0 +1,6 @@ +# FAULT: DIO9 is not a switch pin. Only DIO5, DIO6, DIO7, DIO8 and DIO10 exist. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO9, DIO7] + MODE_STBY: [LOW, LOW, LOW] diff --git a/test/fixtures/portduino-config/rfswitch-no-pins.yaml b/test/fixtures/portduino-config/rfswitch-no-pins.yaml new file mode 100644 index 000000000..fd6805ac9 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-no-pins.yaml @@ -0,0 +1,6 @@ +# FAULT: no pins list, so no switch pin is ever driven and the mode rows below have +# nothing to apply to. +Lora: + Module: lr1121 + rfswitch_table: + MODE_STBY: [LOW, LOW] diff --git a/test/fixtures/portduino-config/rfswitch-not-a-map.yaml b/test/fixtures/portduino-config/rfswitch-not-a-map.yaml new file mode 100644 index 000000000..c0bb8c454 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-not-a-map.yaml @@ -0,0 +1,4 @@ +# FAULT: rfswitch_table given a scalar instead of a mapping. +Lora: + Module: lr1121 + rfswitch_table: DIO5 diff --git a/test/fixtures/portduino-config/rfswitch-partial.yaml b/test/fixtures/portduino-config/rfswitch-partial.yaml new file mode 100644 index 000000000..244410bc7 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-partial.yaml @@ -0,0 +1,12 @@ +# CLEAN but NOTED. A partial table is legal. The modes left out (MODE_TX_HP, +# MODE_TX_HF, MODE_GNSS, MODE_WIFI) are driven all-LOW, which on most modules is the +# shutdown state -- so the report names them rather than leaving it to be found on air. +Lora: + Module: lr1121 + CS: 21 + IRQ: 16 + rfswitch_table: + pins: [DIO5, DIO6] + MODE_STBY: [LOW, LOW] + MODE_RX: [HIGH, LOW] + MODE_TX: [HIGH, HIGH] diff --git a/test/fixtures/portduino-config/rfswitch-row-length.yaml b/test/fixtures/portduino-config/rfswitch-row-length.yaml new file mode 100644 index 000000000..a56c1007b --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-row-length.yaml @@ -0,0 +1,8 @@ +# FAULT: three pins are declared, but MODE_STBY gives two values and MODE_RX gives +# four. Expect one ERROR per mismatched row. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO6, DIO7] + MODE_STBY: [LOW, LOW] + MODE_RX: [HIGH, LOW, LOW, LOW] diff --git a/test/fixtures/portduino-config/rfswitch-sticky/config.d/a-first.yaml b/test/fixtures/portduino-config/rfswitch-sticky/config.d/a-first.yaml new file mode 100644 index 000000000..2c60dfecc --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-sticky/config.d/a-first.yaml @@ -0,0 +1,6 @@ +# Loaded first. Sets MODE_RX HIGH on both pins. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO6] + MODE_RX: [HIGH, HIGH] diff --git a/test/fixtures/portduino-config/rfswitch-sticky/config.d/b-second.yaml b/test/fixtures/portduino-config/rfswitch-sticky/config.d/b-second.yaml new file mode 100644 index 000000000..1832c7625 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-sticky/config.d/b-second.yaml @@ -0,0 +1,9 @@ +# FAULT, and it is a firmware bug rather than a typo. This file is loaded LAST and says +# MODE_RX is LOW on both pins, but the loader only ever writes HIGH and never writes +# LOW back, so the HIGH from a-first.yaml survives. The effective table is the OR of +# both files -- a switch state that neither file asked for. Verified with --output-yaml. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO6] + MODE_RX: [LOW, LOW] diff --git a/test/fixtures/portduino-config/rfswitch-sticky/config.yaml b/test/fixtures/portduino-config/rfswitch-sticky/config.yaml new file mode 100644 index 000000000..ccc3ab988 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-sticky/config.yaml @@ -0,0 +1,4 @@ +# Primary config for the sticky-switch-table case. The two files in config.d/ each +# define a Lora.rfswitch_table. +General: + ConfigDirectory: config.d/ diff --git a/test/fixtures/portduino-config/rfswitch-stranded-modes.yaml b/test/fixtures/portduino-config/rfswitch-stranded-modes.yaml new file mode 100644 index 000000000..dcfeeb898 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-stranded-modes.yaml @@ -0,0 +1,9 @@ +# FAULT: MODE_RX below is indented one level short, so it sits under Lora: rather than +# inside rfswitch_table and does nothing at all. Expect a WARNING that says it is a +# valid key of Lora.rfswitch_table -- the hint is what makes this findable. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5] + MODE_STBY: [LOW] + MODE_RX: [HIGH] diff --git a/test/fixtures/portduino-config/rfswitch-too-many-pins.yaml b/test/fixtures/portduino-config/rfswitch-too-many-pins.yaml new file mode 100644 index 000000000..9a69e1977 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-too-many-pins.yaml @@ -0,0 +1,7 @@ +# FAULT: six pins declared but only the first five are read (rfswitch_dio_pins[5]), +# so the last one is silently dropped. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO6, DIO7, DIO8, DIO10, DIO5] + MODE_STBY: [LOW, LOW, LOW, LOW, LOW, LOW] diff --git a/test/fixtures/portduino-config/rfswitch-unknown-mode.yaml b/test/fixtures/portduino-config/rfswitch-unknown-mode.yaml new file mode 100644 index 000000000..5bb603704 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-unknown-mode.yaml @@ -0,0 +1,8 @@ +# FAULT: MODE_TRANSMIT is not a mode. The real ones are MODE_STBY, MODE_RX, MODE_TX, +# MODE_TX_HP, MODE_TX_HF, MODE_GNSS and MODE_WIFI. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5] + MODE_STBY: [LOW] + MODE_TRANSMIT: [HIGH] diff --git a/test/fixtures/portduino-config/rfswitch-valid.yaml b/test/fixtures/portduino-config/rfswitch-valid.yaml new file mode 100644 index 000000000..fad8c34f0 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-valid.yaml @@ -0,0 +1,17 @@ +# CLEAN. A full seven-mode table on an LR11xx: every mode present, every row the same +# length as the pins list, every value exactly HIGH or LOW. +Lora: + Module: lr1121 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + rfswitch_table: + pins: [DIO5, DIO6, DIO7, DIO8] + MODE_STBY: [LOW, LOW, LOW, LOW] + MODE_RX: [HIGH, LOW, LOW, LOW] + MODE_TX: [HIGH, HIGH, LOW, LOW] + MODE_TX_HP: [LOW, HIGH, LOW, LOW] + MODE_TX_HF: [LOW, LOW, LOW, LOW] + MODE_GNSS: [LOW, LOW, HIGH, LOW] + MODE_WIFI: [LOW, LOW, LOW, HIGH] diff --git a/test/fixtures/portduino-config/statusmessage-long.yaml b/test/fixtures/portduino-config/statusmessage-long.yaml new file mode 100644 index 000000000..8517c6ceb --- /dev/null +++ b/test/fixtures/portduino-config/statusmessage-long.yaml @@ -0,0 +1,7 @@ +# FAULT: StatusMessage is copied into a char[80] and hard null-terminated, so it is +# safe but silently shortened to 79 characters. The operator never sees the message +# they configured. Expect a WARNING. +Lora: + Module: sx1262 +Config: + StatusMessage: "This status message is comfortably longer than the eighty character buffer it is copied into, so it gets cut off" diff --git a/test/fixtures/portduino-config/stranded-key.yaml b/test/fixtures/portduino-config/stranded-key.yaml new file mode 100644 index 000000000..4f159ee80 --- /dev/null +++ b/test/fixtures/portduino-config/stranded-key.yaml @@ -0,0 +1,6 @@ +# FAULT: spidev left at the top level instead of inside a section, where it does +# nothing. Expect an ERROR that names the sections it IS valid in, so the fix is to +# indent it one level. +Lora: + Module: sx1262 +spidev: /dev/spidev0.0 diff --git a/test/fixtures/portduino-config/tcxo-millivolts.yaml b/test/fixtures/portduino-config/tcxo-millivolts.yaml new file mode 100644 index 000000000..3442bb911 --- /dev/null +++ b/test/fixtures/portduino-config/tcxo-millivolts.yaml @@ -0,0 +1,9 @@ +# FAULT: DIO3_TCXO_VOLTAGE is in VOLTS and is multiplied by 1000, but every other +# Meshtastic surface discusses this setting in millivolts -- so "1800" is the natural +# thing to write and silently asks for 1800V. Nothing downstream range-checks it. +# The correct spelling for a 1.8V part is 1.8, or just true. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + DIO3_TCXO_VOLTAGE: 1800 diff --git a/test/fixtures/portduino-config/top-level-list.yaml b/test/fixtures/portduino-config/top-level-list.yaml new file mode 100644 index 000000000..1007d78d1 --- /dev/null +++ b/test/fixtures/portduino-config/top-level-list.yaml @@ -0,0 +1,3 @@ +# FAULT: the document root is a sequence, not a mapping, so nothing in it is read. +- Lora +- General diff --git a/test/fixtures/portduino-config/txgain-out-of-range.yaml b/test/fixtures/portduino-config/txgain-out-of-range.yaml new file mode 100644 index 000000000..ece546d91 --- /dev/null +++ b/test/fixtures/portduino-config/txgain-out-of-range.yaml @@ -0,0 +1,5 @@ +# FAULT: the gain table is uint16_t[22], so a negative or >65535 entry silently wraps +# to a completely different gain rather than being rejected. Expect an ERROR each. +Lora: + Module: sx1262 + TX_GAIN_LORA: [0, -5, 70000] diff --git a/test/fixtures/portduino-config/txgain-scalar.yaml b/test/fixtures/portduino-config/txgain-scalar.yaml new file mode 100644 index 000000000..93050386d --- /dev/null +++ b/test/fixtures/portduino-config/txgain-scalar.yaml @@ -0,0 +1,8 @@ +# CLEAN, and a regression guard. TX_GAIN_LORA accepts a bare scalar as well as a list: +# loadConfig() has an else branch reading .as(0). An earlier version of the checker +# treated the list shape as the only legal one and reported this working config as a +# fatal error -- a false positive is worse than a missing check, so this file must stay +# clean. +Lora: + Module: sx1262 + TX_GAIN_LORA: 5 diff --git a/test/fixtures/portduino-config/txgain-too-many.yaml b/test/fixtures/portduino-config/txgain-too-many.yaml new file mode 100644 index 000000000..4bff9062b --- /dev/null +++ b/test/fixtures/portduino-config/txgain-too-many.yaml @@ -0,0 +1,5 @@ +# FAULT: only the first 22 PA points are stored (num_pa_points is min(size, 22)), so +# everything past the 22nd is silently dropped. +Lora: + Module: sx1262 + TX_GAIN_LORA: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] diff --git a/test/fixtures/portduino-config/unknown-key.yaml b/test/fixtures/portduino-config/unknown-key.yaml new file mode 100644 index 000000000..1ab2cc7de --- /dev/null +++ b/test/fixtures/portduino-config/unknown-key.yaml @@ -0,0 +1,9 @@ +# FAULT: Lora.Frequency is not a key meshtasticd reads, so setting it does nothing. +# Expect a WARNING and exit 0 -- warnings alone must not fail the run. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + Frequency: 868.0 diff --git a/test/fixtures/portduino-config/unknown-section.yaml b/test/fixtures/portduino-config/unknown-section.yaml new file mode 100644 index 000000000..7c0cb0154 --- /dev/null +++ b/test/fixtures/portduino-config/unknown-section.yaml @@ -0,0 +1,6 @@ +# FAULT: Telemetry is not a section meshtasticd reads, so the whole block is ignored. +# Expect an ERROR. +Lora: + Module: sx1262 +Telemetry: + Enabled: true diff --git a/test/fixtures/portduino-config/usb-ch341.yaml b/test/fixtures/portduino-config/usb-ch341.yaml new file mode 100644 index 000000000..0c75aecdd --- /dev/null +++ b/test/fixtures/portduino-config/usb-ch341.yaml @@ -0,0 +1,15 @@ +# CLEAN. A CH341 USB-SPI adapter, the meshstick/pinedio shape. The pin numbers here are +# indexes on the adapter, not gpiochip lines: portduinoSetup() skips initGPIOPin() for +# every Lora pin when spidev is ch341 and hands the raw numbers to Ch341Hal. Expect the +# report to say so and to stop citing 'gpiodetect'/'gpioinfo', which do not exist on the +# Windows and macOS hosts where this is the only shape that works. +Lora: + Module: sx1262 + CS: 0 + IRQ: 6 + Reset: 2 + Busy: 4 + spidev: ch341 + DIO3_TCXO_VOLTAGE: true + USB_PID: 0x5512 + USB_VID: 0x1A86 diff --git a/test/fixtures/portduino-config/valid.yaml b/test/fixtures/portduino-config/valid.yaml new file mode 100644 index 000000000..c867d4fbf --- /dev/null +++ b/test/fixtures/portduino-config/valid.yaml @@ -0,0 +1,8 @@ +# CLEAN. Minimal SX126x config, used as the baseline: the checker must not invent a +# finding for it. Also the fixture for --check winning over --output-yaml. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 diff --git a/test/fixtures/portduino-config/value-type-fatal-list.yaml b/test/fixtures/portduino-config/value-type-fatal-list.yaml new file mode 100644 index 000000000..77175004a --- /dev/null +++ b/test/fixtures/portduino-config/value-type-fatal-list.yaml @@ -0,0 +1,5 @@ +# FAULT: the other no-fallback read. Each TX_GAIN_LORA entry is .as() with no +# default, so one non-numeric entry throws and stops meshtasticd starting. +Lora: + Module: sx1262 + TX_GAIN_LORA: [0, high, 3] diff --git a/test/fixtures/portduino-config/value-type-fatal.yaml b/test/fixtures/portduino-config/value-type-fatal.yaml new file mode 100644 index 000000000..89ecb64e2 --- /dev/null +++ b/test/fixtures/portduino-config/value-type-fatal.yaml @@ -0,0 +1,8 @@ +# FAULT: Logging.AsciiLogs is one of only two settings read WITHOUT a fallback +# (.as() with no default), so a value that will not convert throws, loadConfig() +# returns false and meshtasticd refuses to start. Before this check existed --check +# called this file clean while the daemon would not boot on it. +Lora: + Module: sx1262 +Logging: + AsciiLogs: yes-please diff --git a/test/fixtures/portduino-config/value-type-silent.yaml b/test/fixtures/portduino-config/value-type-silent.yaml new file mode 100644 index 000000000..d0739893b --- /dev/null +++ b/test/fixtures/portduino-config/value-type-silent.yaml @@ -0,0 +1,10 @@ +# FAULT: values of the wrong type where loadConfig() reads .as(default). yaml-cpp +# hands back the default on a failed conversion, so meshtasticd starts happily and +# every one of these settings silently does nothing. Expect a WARNING each. +Lora: + Module: sx1262 + spiSpeed: fast + SX126X_MAX_POWER: high + DIO2_AS_RF_SWITCH: maybe +General: + MaxNodes: lots diff --git a/test/native-suite-count b/test/native-suite-count index 87523dd7a..d81cc0710 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -41 +42 diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index 1b06457f1..15fe9e782 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -21,9 +21,18 @@ class AdminModuleTestShim : public AdminModule meshtastic_MeshPacket *reply() { return myReply; } // With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot. - void deferSaves() { hasOpenEditTransaction = true; } + // Stamps the clock like begin_edit_settings, so a slow suite can't age the transaction into expiry. + void deferSaves() + { + hasOpenEditTransaction = true; + editTransactionActivityMs = millis(); + } int savedSegments() const { return lastSaveWhatForTest; } + bool editTransactionOpen() const { return hasOpenEditTransaction; } + // Backdate past the idle window so a test sees an abandoned transaction without waiting it out. + void ageEditTransaction() { editTransactionActivityMs = millis() - EDIT_TRANSACTION_IDLE_MS - 1; } + // Setters may allocate an error reply from packetPool; drain it each iteration or the pool leaks. void drainReply() { diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 966cc6859..4b88be701 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -1574,6 +1574,16 @@ static void sendCommitEdit() sendAdmin(m); } +// An admin message that changes nothing. It answers, so drain the reply or the packet pool leaks. +static void sendGetDeviceMetadata() +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_request_tag; + m.get_device_metadata_request = true; + sendAdmin(m); + testAdmin->drainReply(); +} + // Preset = LongFast on US, unlicensed owner. "LongFast" is the display name we compare against. static void usePresetLongFast() { @@ -1640,6 +1650,53 @@ static void test_warn_transaction_singleChannel_keepsSpecificMessage() TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels")); } +// An idle transaction is retired by the next admin message, flushing the warnings it held. +static void test_editTransaction_abandoned_isRetiredOnNextAdminMessage() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + // Deferred, exactly as before: nothing emitted while the transaction looks alive. + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + TEST_ASSERT_TRUE(testAdmin->editTransactionOpen()); + + testAdmin->ageEditTransaction(); + sendGetDeviceMetadata(); // any later admin message, from any client + + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); +} + +// A write arriving after abandonment is saved, not deferred to a commit that never comes. +static void test_editTransaction_abandoned_laterWriteIsNoLongerDeferred() +{ + usePresetLongFast(); + sendBeginEdit(); + testAdmin->ageEditTransaction(); + + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + + // The write itself retired the stale transaction, so its own warning is emitted immediately. + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); +} + +// A transaction still in use is left alone: each write refreshes the window. +static void test_editTransaction_active_isNotRetired() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1)); + + TEST_ASSERT_TRUE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1")); +} + static void test_warn_license_noTransaction_emittedImmediately() { usePresetLongFast(); @@ -1801,6 +1858,9 @@ void setup() RUN_TEST(test_warn_cleanChannel_noMessage); RUN_TEST(test_warn_transaction_multipleChannels_singleCoalescedMessage); RUN_TEST(test_warn_transaction_singleChannel_keepsSpecificMessage); + RUN_TEST(test_editTransaction_abandoned_isRetiredOnNextAdminMessage); + RUN_TEST(test_editTransaction_abandoned_laterWriteIsNoLongerDeferred); + RUN_TEST(test_editTransaction_active_isNotRetired); RUN_TEST(test_warn_license_noTransaction_emittedImmediately); RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage); diff --git a/test/test_default/test_main.cpp b/test/test_default/test_main.cpp index 674475b79..ca9e48b89 100644 --- a/test/test_default/test_main.cpp +++ b/test/test_default/test_main.cpp @@ -3,6 +3,8 @@ #include "MeshRadio.h" #include "TestUtil.h" #include "meshUtils.h" +#include "modules/RoutingModule.h" +#include #include // Helper to compute expected ms using same logic as Default::congestionScalingCoefficient @@ -181,6 +183,32 @@ void test_scaled_overflow_saturates() TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), res); } +void test_configured_or_default_hop_limit() +{ + config.lora.hop_limit = HOP_MAX; + const uint8_t result = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); + +#if USERPREFS_EVENT_MODE + TEST_ASSERT_EQUAL_UINT8(Default::eventModeHopLimit, result); + TEST_ASSERT_EQUAL_UINT8(Default::eventModeHopLimit, Default::getConfiguredOrDefaultHopLimit(Default::eventModeHopLimit)); +#else + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, result); +#endif +} + +#if USERPREFS_EVENT_MODE +void test_event_mode_caps_optimized_response() +{ + config.lora.hop_limit = HOP_MAX; + meshtastic_MeshPacket request = meshtastic_MeshPacket_init_zero; + request.hop_start = HOP_MAX; + request.hop_limit = HOP_MAX - 4; + + RoutingModule module; + TEST_ASSERT_EQUAL_UINT8(std::min(6, Default::eventModeHopLimit), module.getHopLimitForResponse(request)); +} +#endif + void setup() { // Small delay to match other test mains @@ -201,6 +229,10 @@ void setup() RUN_TEST(test_ms_default_clamps); RUN_TEST(test_ms_result_is_int32_safe); RUN_TEST(test_scaled_overflow_saturates); + RUN_TEST(test_configured_or_default_hop_limit); +#if USERPREFS_EVENT_MODE + RUN_TEST(test_event_mode_caps_optimized_response); +#endif exit(UNITY_END()); } diff --git a/test/test_fuzz_config/test_main.cpp b/test/test_fuzz_config/test_main.cpp new file mode 100644 index 000000000..5919ebfe9 --- /dev/null +++ b/test/test_fuzz_config/test_main.cpp @@ -0,0 +1,267 @@ +// Adversarial fuzzing of `meshtasticd --check`, above all DuplicateKeyFinder, whose stack balance +// rests on yaml-cpp emitting matched start/end events. The contract is crash-freedom and +// termination only (what the report *says* is asserted by bin/test-config-check.sh); inputs come +// from a seeded LCG, so a failure reproduces from the printed seed. + +// configuration.h pulls in Arduino.h, whose Common.h declares setup()/loop() inside an extern "C" +// block. Must come BEFORE TestUtil.h, same as the other suites. +#include "configuration.h" + +#include "TestUtil.h" +#include + +#include "ConfigCheck.h" +#include "support/DeterministicRng.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr uint64_t BASE_SEED = 0xC0FFEE01ULL; + +// Where the checked-in fixtures live, relative to the repo root that `pio test` runs from. +static const char *kFixtureDir = "test/fixtures/portduino-config"; + +// Thousands of full reports would bury the CI log, so stdout goes to /dev/null for the suite. +// Restored by duplicated fd: CI has no controlling terminal, so reopening /dev/tty would lose it. +class StdoutSilencer +{ + public: + StdoutSilencer() + { + fflush(stdout); + savedFd = dup(fileno(stdout)); + const int devNull = open("/dev/null", O_WRONLY); + if (devNull >= 0) { + dup2(devNull, fileno(stdout)); + close(devNull); + } + } + ~StdoutSilencer() + { + fflush(stdout); + if (savedFd >= 0) { + dup2(savedFd, fileno(stdout)); + close(savedFd); + } + } + + private: + int savedFd = -1; +}; + +static std::string tempPath(int n) +{ + return "/tmp/meshtastic-fuzz-config-" + std::to_string(n) + ".yaml"; +} + +/// Write `bytes` to a scratch file and hand it to the checker. Returns nothing: the assertion is +/// that we get here at all, without a crash, a hang, or an escaped exception. +static void runOn(const std::string &bytes, int slot) +{ + const std::string path = tempPath(slot); + { + std::ofstream out(path, std::ios::binary); + out.write(bytes.data(), (std::streamsize)bytes.size()); + } + try { + runConfigCheck({path}); + } catch (const std::exception &) { + // An escaped exception is a defect in its own right: portduinoSetup() calls this on the way + // to exit() with nothing above it to catch. + TEST_FAIL_MESSAGE("runConfigCheck() let an exception escape"); + } + std::error_code ec; + std::filesystem::remove(path, ec); +} + +static std::vector loadSeedCorpus() +{ + std::vector corpus; + std::error_code ec; + for (const auto &entry : std::filesystem::recursive_directory_iterator(kFixtureDir, ec)) { + if (!entry.is_regular_file() || entry.path().extension() != ".yaml") + continue; + std::ifstream in(entry.path(), std::ios::binary); + std::ostringstream buf; + buf << in.rdbuf(); + corpus.push_back(buf.str()); + } + return corpus; +} + +// --------------------------------------------------------------------------- +// C1 - the seed corpus itself +// --------------------------------------------------------------------------- + +void test_seed_corpus_survives(void) +{ + StdoutSilencer quiet; + const auto corpus = loadSeedCorpus(); + // A corpus that failed to load would make every later group vacuous, so prove it is there. + TEST_ASSERT_TRUE_MESSAGE(corpus.size() >= 20, "fixture corpus not found - is the test running from the repo root?"); + int slot = 0; + for (const auto &seed : corpus) + runOn(seed, slot++); +} + +// --------------------------------------------------------------------------- +// C2 - byte mutation of the corpus +// --------------------------------------------------------------------------- + +void test_mutated_corpus(void) +{ + StdoutSilencer quiet; + const auto corpus = loadSeedCorpus(); + TEST_ASSERT_TRUE(corpus.size() >= 20); + rngSeed(BASE_SEED + 1); + + for (int i = 0; i < 3000; i++) { + std::string s = corpus[rngRange((uint32_t)corpus.size())]; + if (s.empty()) + continue; + + switch (rngRange(5)) { + case 0: // flip a byte + s[rngRange((uint32_t)s.size())] = (char)rngByte(); + break; + case 1: // truncate + s.resize(rngRange((uint32_t)s.size())); + break; + case 2: // insert a run of one byte + s.insert(rngRange((uint32_t)s.size()), std::string(1 + rngRange(64), (char)rngByte())); + break; + case 3: // splice two fixtures together + s += corpus[rngRange((uint32_t)corpus.size())]; + break; + default: // delete a span + if (s.size() > 1) { + const uint32_t at = rngRange((uint32_t)s.size() - 1); + s.erase(at, 1 + rngRange((uint32_t)(s.size() - at))); + } + break; + } + runOn(s, i % 8); + } +} + +// --------------------------------------------------------------------------- +// C3 - structural torture +// --------------------------------------------------------------------------- +// Shapes chosen to stress the event-stream walker rather than the parser. + +void test_structural_torture(void) +{ + StdoutSilencer quiet; + rngSeed(BASE_SEED + 2); + int slot = 0; + + // Deep nesting, both flow and block style. + for (int depth : {1, 2, 8, 64, 512, 4096}) { + runOn("Lora:\n rfswitch_table: " + std::string(depth, '[') + std::string(depth, ']'), slot++); + std::string block = "Lora:\n"; + for (int i = 0; i < depth; i++) + block += std::string(2 + i * 2, ' ') + "k:\n"; + runOn(block, slot++); + } + + // Duplicate keys at every depth, which is what DuplicateKeyFinder's stack is for. + for (int repeat : {2, 16, 256}) { + std::string dup = "Lora:\n"; + for (int i = 0; i < repeat; i++) + dup += " CS: " + std::to_string(i) + "\n"; + runOn(dup, slot++); + std::string nested = "Lora:\n rfswitch_table:\n"; + for (int i = 0; i < repeat; i++) + nested += " MODE_RX: [HIGH]\n"; + runOn(nested, slot++); + } + + // Anchors and aliases: one node reachable from many paths, including a merge key. + runOn("Lora: &a\n Module: sx1262\nDisplay: *a\n", slot++); + runOn("a: &x [1, 2]\nLora:\n Enable_Pins: *x\n rfswitch_table: *x\n", slot++); + runOn("base: &b {CS: 1}\nLora:\n <<: *b\n IRQ: 2\n", slot++); + + // Collections where a scalar is expected and vice versa, across every known section. + for (const char *section : {"Lora", "General", "Display", "Logging", "Webserver", "Meta", "GPIO"}) { + runOn(std::string(section) + ": [1, 2, 3]\n", slot++); + runOn(std::string(section) + ": ~\n", slot++); + runOn(std::string(section) + ":\n ? [complex, key]\n : value\n", slot++); + } + + // Very long keys and scalars. + runOn("Lora:\n " + std::string(64 * 1024, 'k') + ": 1\n", slot++); + runOn("Lora:\n Module: " + std::string(256 * 1024, 'v') + "\n", slot++); + + // Multiple documents in one file, which HandleNextDocument() loops over. + runOn("Lora:\n CS: 1\n---\nLora:\n CS: 2\n---\n[]\n", slot++); +} + +// --------------------------------------------------------------------------- +// C4 - random bytes (DISABLED) +// --------------------------------------------------------------------------- +// Off for CI cost: half the inputs and half the runtime for the least return, since uniform noise +// is almost always rejected on the first token. Flip to 1 to restore it. +#define FUZZ_CONFIG_RANDOM_BYTES 0 + +#if FUZZ_CONFIG_RANDOM_BYTES +void test_random_bytes(void) +{ + StdoutSilencer quiet; + rngSeed(BASE_SEED + 3); + + for (int i = 0; i < 1500; i++) { + std::string s; + s.resize(rngRange(2048)); + for (auto &c : s) + c = (char)rngByte(); + runOn(s, i % 8); + } + + // Bytes drawn only from YAML's structural alphabet get much deeper into the parser than + // uniform random noise, which is usually rejected on the first token. + static const char kYamlChars[] = " \t\n-:[]{}#&*!|>'\"%@`,?abcLora0123"; + for (int i = 0; i < 1500; i++) { + std::string s; + s.resize(rngRange(2048)); + for (auto &c : s) + c = kYamlChars[rngRange(sizeof(kYamlChars) - 1)]; + runOn(s, i % 8); + } +} +#endif // FUZZ_CONFIG_RANDOM_BYTES + +void setUp(void) {} +void tearDown(void) {} + +// The portduino framework supplies main() and calls setup()/loop(), so the suite's entry point is +// setup() rather than main() -- defining main() here collides with the framework's. +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Group C1: seed corpus ===\n"); + RUN_TEST(test_seed_corpus_survives); + + printf("\n=== Group C2: mutated corpus ===\n"); + RUN_TEST(test_mutated_corpus); + + printf("\n=== Group C3: structural torture ===\n"); + RUN_TEST(test_structural_torture); + +#if FUZZ_CONFIG_RANDOM_BYTES + printf("\n=== Group C4: random bytes ===\n"); + RUN_TEST(test_random_bytes); +#endif + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_fuzz_packets/test_main.cpp b/test/test_fuzz_packets/test_main.cpp index c9edcabb1..2bccf1d40 100644 --- a/test/test_fuzz_packets/test_main.cpp +++ b/test/test_fuzz_packets/test_main.cpp @@ -616,6 +616,7 @@ void test_E7_nodedb_update_fuzz(void) mp.id = rngNext(); mp.rx_snr = (float)((int)rngRange(60) - 30); mp.rx_rssi = (int32_t)rngRange(256) - 128; + mp.has_rx_rssi = (rngRange(2) != 0); // exercise both the presence and absence paths mp.hop_start = (uint8_t)rngRange(8); mp.hop_limit = (uint8_t)rngRange(8); mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; @@ -666,6 +667,7 @@ static void fuzzRxHeader(meshtastic_MeshPacket &mp, meshtastic_PortNum portnum) mp.channel = (uint8_t)rngRange(channels.getNumChannels()); mp.rx_snr = (float)((int)rngRange(40) - 20); mp.rx_rssi = -(int)rngRange(130); + mp.has_rx_rssi = (rngRange(2) != 0); // exercise both the presence and absence paths mp.hop_start = (uint8_t)rngRange(8); // 0..7, wire-bounded mp.hop_limit = (uint8_t)rngRange(8); mp.want_ack = (rngRange(2) == 0); diff --git a/test/test_meshpacket_serializer/ports/test_timestamp.cpp b/test/test_meshpacket_serializer/ports/test_timestamp.cpp new file mode 100644 index 000000000..333945f80 --- /dev/null +++ b/test/test_meshpacket_serializer/ports/test_timestamp.cpp @@ -0,0 +1,37 @@ +#include "../test_helpers.h" + +void test_timestamp_present_when_has_rx_time() +{ + const char *test_text = "hi"; + meshtastic_MeshPacket packet = + create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, reinterpret_cast(test_text), strlen(test_text)); + + std::string json = MeshPacketSerializer::JsonSerialize(&packet, false); + Json::Value root = parse_json(json); + TEST_ASSERT_TRUE(root.isMember("timestamp")); + TEST_ASSERT_EQUAL_UINT32(1609459200u, root["timestamp"].asUInt()); +} + +void test_timestamp_zeroed_when_rx_time_absent() +{ + const char *test_text = "hi"; + meshtastic_MeshPacket packet = create_test_packet_no_rx_time(meshtastic_PortNum_TEXT_MESSAGE_APP, + reinterpret_cast(test_text), strlen(test_text)); + + std::string json = MeshPacketSerializer::JsonSerialize(&packet, false); + Json::Value root = parse_json(json); + TEST_ASSERT_TRUE(root.isMember("timestamp")); + TEST_ASSERT_EQUAL_UINT32(0u, root["timestamp"].asUInt()); // must not leak the millis() placeholder +} + +void test_encrypted_timestamp_zeroed_when_rx_time_absent() +{ + const char *test_text = "hi"; + meshtastic_MeshPacket packet = create_test_packet_no_rx_time(meshtastic_PortNum_TEXT_MESSAGE_APP, + reinterpret_cast(test_text), strlen(test_text)); + + std::string json = MeshPacketSerializer::JsonSerializeEncrypted(&packet); + Json::Value root = parse_json(json); + TEST_ASSERT_TRUE(root.isMember("timestamp")); + TEST_ASSERT_EQUAL_UINT32(0u, root["timestamp"].asUInt()); +} diff --git a/test/test_meshpacket_serializer/test_helpers.h b/test/test_meshpacket_serializer/test_helpers.h index 5f5efd2cf..2dc06cec7 100644 --- a/test/test_meshpacket_serializer/test_helpers.h +++ b/test/test_meshpacket_serializer/test_helpers.h @@ -37,9 +37,11 @@ static meshtastic_MeshPacket create_test_packet(meshtastic_PortNum port, const u packet.want_ack = false; packet.priority = meshtastic_MeshPacket_Priority_UNSET; packet.rx_time = 1609459200; + packet.has_rx_time = true; // rx_time has explicit presence; mark this synthetic reading as measured packet.rx_snr = 10.5f; packet.hop_start = 3; packet.rx_rssi = -85; + packet.has_rx_rssi = true; // rx_rssi has explicit presence; mark this synthetic reading as measured packet.delayed = meshtastic_MeshPacket_Delayed_NO_DELAY; // Set decoded variant @@ -61,3 +63,14 @@ static meshtastic_MeshPacket create_test_packet(meshtastic_PortNum port, const u return packet; } + +// Same as create_test_packet(), but with rx_time absent (has_rx_time = false) and a nonzero +// millis()-style placeholder in rx_time, to verify serializers treat it as unknown, not a reading. +static meshtastic_MeshPacket create_test_packet_no_rx_time(meshtastic_PortNum port, const uint8_t *payload, size_t payload_size, + int payload_variant = meshtastic_MeshPacket_decoded_tag) +{ + meshtastic_MeshPacket packet = create_test_packet(port, payload, payload_size, payload_variant); + packet.rx_time = 123456; // a plausible millis() placeholder, not a real epoch + packet.has_rx_time = false; + return packet; +} diff --git a/test/test_meshpacket_serializer/test_serializer.cpp b/test/test_meshpacket_serializer/test_serializer.cpp index 484db8d74..58483ff91 100644 --- a/test/test_meshpacket_serializer/test_serializer.cpp +++ b/test/test_meshpacket_serializer/test_serializer.cpp @@ -19,6 +19,9 @@ void test_telemetry_environment_metrics_complete_coverage(); void test_telemetry_environment_metrics_unset_fields(); void test_encrypted_packet_serialization(); void test_empty_encrypted_packet(); +void test_timestamp_present_when_has_rx_time(); +void test_timestamp_zeroed_when_rx_time_absent(); +void test_encrypted_timestamp_zeroed_when_rx_time_absent(); void setup() { @@ -52,6 +55,11 @@ void setup() RUN_TEST(test_encrypted_packet_serialization); RUN_TEST(test_empty_encrypted_packet); + // rx_time explicit-presence tests + RUN_TEST(test_timestamp_present_when_has_rx_time); + RUN_TEST(test_timestamp_zeroed_when_rx_time_absent); + RUN_TEST(test_encrypted_timestamp_zeroed_when_rx_time_absent); + UNITY_END(); } diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index c7a27bf5c..7d3dd9eec 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -13,6 +13,7 @@ #include "configuration.h" #include "gps/RTC.h" +#include "mesh/Default.h" #include "mesh/NextHopRouter.h" #include "mesh/NodeDB.h" #include "mesh/RadioInterface.h" @@ -89,6 +90,7 @@ class NextHopRouterTestShim : public NextHopRouter using NextHopRouter::noteRouteLearned; using NextHopRouter::noteRouteSuccess; using NextHopRouter::perhapsRebroadcast; + using NextHopRouter::relayOpaquePacket; using Router::shouldDecrementHopLimit; // protected in Router void resetRouteHealthForTest() @@ -108,6 +110,8 @@ class MockRadioInterface : public RadioInterface ErrorCode send(meshtastic_MeshPacket *p) override { sendCount++; + lastHopLimit = p->hop_limit; + lastHopStart = p->hop_start; if (declineAll || p->to == NODENUM_BROADCAST_NO_LORA) return ERRNO_SHOULD_RELEASE; @@ -124,6 +128,8 @@ class MockRadioInterface : public RadioInterface int sendCount = 0; bool declineAll = false; + uint8_t lastHopLimit = 0; + uint8_t lastHopStart = 0; }; static MockNodeDB *mockNodeDB = nullptr; @@ -498,6 +504,46 @@ void test_rebroadcast_declined_send_releases_packet(void) TEST_ASSERT_EQUAL_MESSAGE(1, mockIface->sendCount, "the copy must have reached the mock radio"); } +#if USERPREFS_EVENT_MODE +void test_event_mode_hop_behavior(void) +{ + MockRadioInterface *mockIface = installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(NODENUM_BROADCAST); + p.from = kLocalNode; + p.hop_start = 0; + p.hop_limit = HOP_MAX; + + TEST_ASSERT_EQUAL(ERRNO_OK, shim->send(packetPool.allocCopy(p))); + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, mockIface->lastHopLimit); + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, mockIface->lastHopStart); + + p = makeRebroadcastCandidate(NODENUM_BROADCAST); + p.hop_start = HOP_MAX; + p.hop_limit = HOP_MAX; + + TEST_ASSERT_TRUE(shim->perhapsRebroadcast(&p)); + TEST_ASSERT_EQUAL_UINT8(Default::eventModeRelayHopLimit, mockIface->lastHopLimit); + TEST_ASSERT_EQUAL_UINT8(static_cast(Default::eventModeRelayHopLimit + 1), mockIface->lastHopStart); + + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + p = makeRebroadcastCandidate(NODENUM_BROADCAST); + p.hop_start = HOP_MAX; + p.hop_limit = HOP_MAX; + + TEST_ASSERT_TRUE(shim->relayOpaquePacket(&p)); + TEST_ASSERT_EQUAL_UINT8(Default::eventModeRelayHopLimit, mockIface->lastHopLimit); + TEST_ASSERT_EQUAL_UINT8(static_cast(Default::eventModeRelayHopLimit + 1), mockIface->lastHopStart); + + p = makeRebroadcastCandidate(NODENUM_BROADCAST); + p.hop_start = 0; + p.hop_limit = HOP_MAX; + + TEST_ASSERT_TRUE(shim->relayOpaquePacket(&p)); + TEST_ASSERT_EQUAL_UINT8(Default::eventModeRelayHopLimit, mockIface->lastHopLimit); + TEST_ASSERT_EQUAL_UINT8(0, mockIface->lastHopStart); +} +#endif + // =========================================================================== void setup() @@ -552,6 +598,9 @@ void setup() RUN_TEST(test_rebroadcast_normal_broadcast_is_relayed); RUN_TEST(test_rebroadcast_no_lora_broadcast_is_not_relayed); RUN_TEST(test_rebroadcast_declined_send_releases_packet); +#if USERPREFS_EVENT_MODE + RUN_TEST(test_event_mode_hop_behavior); +#endif exit(UNITY_END()); } diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index f58c585b3..c3abb7bc9 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -121,7 +121,7 @@ class AuthPipelineRadio : public RadioInterface { sendCalls++; packetPool.release(p); - return ERRNO_OK; + return failSend ? ERRNO_DISABLED : ERRNO_OK; } bool cancelSending(NodeNum, PacketId) override { @@ -139,8 +139,13 @@ class AuthPipelineRadio : public RadioInterface return true; } uint32_t getPacketTime(uint32_t, bool = false) override { return 7; } - void reset() { sendCalls = cancelCalls = findCalls = removeCalls = 0; } + void reset() + { + sendCalls = cancelCalls = findCalls = removeCalls = 0; + failSend = false; + } + bool failSend = false; uint32_t sendCalls = 0; uint32_t cancelCalls = 0; uint32_t findCalls = 0; @@ -406,6 +411,8 @@ void setUp(void) pipelineMqtt->clearQueue(); while (meshtastic_MeshPacket *queued = pipelineService->getForPhone()) packetPool.release(queued); + while (meshtastic_QueueStatus *queued = pipelineService->getQueueStatusForPhone()) + pipelineService->releaseQueueStatusToPool(queued); resetRoutingAuthEvaluationCount(); } @@ -1441,6 +1448,66 @@ void test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass TEST_ASSERT_EQUAL_MESSAGE(3, routingAuthEvaluationCount(), "same packet ID with different bytes must be reevaluated"); } +// A local reliable send that fails before reaching the radio must not outlive the error as a scheduled retransmission. +void test_C13_failed_initial_reliable_send_does_not_retry(void) +{ + meshtastic_MeshPacket initial = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); + initial.id = 0xC13C13C1; + initial.want_ack = true; + initial.channel = MAX_NUM_CHANNELS; // Out of range, so encoding returns NO_CHANNEL. + + auto *packet = packetPool.allocCopy(initial); + TEST_ASSERT_NOT_NULL(packet); + + pipelineRouter->send(packet); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls, + "initial encoding failure must be reported to the originating client"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, pipelineRouter->pendingCount(), + "failed initial send must not leave a retransmission pending"); + + pipelineRadio->failSend = true; + meshtastic_MeshPacket interfaceFailure = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); + interfaceFailure.id = 0xC13C13C2; + interfaceFailure.want_ack = true; + packet = packetPool.allocCopy(interfaceFailure); + TEST_ASSERT_NOT_NULL(packet); + + pipelineService->sendToMesh(packet, RX_SRC_USER); + meshtastic_QueueStatus *status = pipelineService->getQueueStatusForPhone(); + TEST_ASSERT_NOT_NULL(status); + TEST_ASSERT_EQUAL(ERRNO_DISABLED, status->res); + TEST_ASSERT_EQUAL_UINT32(interfaceFailure.id, status->mesh_packet_id); + pipelineService->releaseQueueStatusToPool(status); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls, + "interface errors are reported to the client through QueueStatus, not a routing NAK"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, pipelineRouter->pendingCount(), + "failed interface enqueue must not leave a retransmission pending"); +} + +void test_C14_duty_cycle_limited_reliable_send_remains_pending(void) +{ + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.override_duty_cycle = false; + initRegion(); + airTime->utilizationTX[0] = MS_IN_HOUR; + + meshtastic_MeshPacket initial = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); + initial.id = 0xC14C14C1; + initial.want_ack = true; + auto *packet = packetPool.allocCopy(initial); + TEST_ASSERT_NOT_NULL(packet); + + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_DUTY_CYCLE_LIMIT, pipelineRouter->send(packet)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls, + "duty-cycle rejection must still notify the originating client"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouter->pendingCount(), + "duty-cycle rejection must retain the retry for when airtime is available"); + + airTime->utilizationTX[0] = 0; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); +} + // C5: the packet survives (C4) but the identity claim inside it must not land - the pubkey guard // can't tell a signer from an impersonator replaying its (public) key. Only the write is refused. void test_N5_unsigned_unicast_nodeinfo_from_signer_does_not_change_name(void) @@ -1907,6 +1974,8 @@ void setup() RUN_TEST(test_C10_legacy_channel_dm_failure_has_no_pipeline_effects); RUN_TEST(test_C11_malformed_pki_plaintext_has_no_pipeline_effects); RUN_TEST(test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass); + RUN_TEST(test_C13_failed_initial_reliable_send_does_not_retry); + RUN_TEST(test_C14_duty_cycle_limited_reliable_send_remains_pending); printf("\n=== Group N: NodeInfoModule authentication ===\n"); RUN_TEST(test_N1_unsigned_nodeinfo_from_signer_dropped); RUN_TEST(test_N2_signed_nodeinfo_from_signer_not_dropped); diff --git a/test/test_position_module/test_main.cpp b/test/test_position_module/test_main.cpp index 091eacd48..a5c2c8ea5 100644 --- a/test/test_position_module/test_main.cpp +++ b/test/test_position_module/test_main.cpp @@ -56,6 +56,56 @@ static void test_effectiveInterval_zeroFloorIsNoOp() TEST_ASSERT_EQUAL_UINT32(60000U, PositionModule::effectiveBroadcastIntervalMs(60000U, true, 0U)); } +// Local phone/UI play: positions are opt-in on the mesh, but our own position still streams to +// the connected phone at a fixed cadence (mirroring telemetry's local delivery). + +// A never-sent state streams immediately once a valid position exists, regardless of the clock. +static void test_sendToPhone_firstSendIsImmediate() +{ + TEST_ASSERT_TRUE(PositionModule::shouldSendPositionToPhone(true, true, false, 5000U, 0U, 60000U)); + TEST_ASSERT_TRUE(PositionModule::shouldSendPositionToPhone(true, true, false, 0U, 0U, 60000U)); +} + +static void test_sendToPhone_holdsUntilCadenceElapses() +{ + TEST_ASSERT_FALSE(PositionModule::shouldSendPositionToPhone(true, true, true, 59999U, 1U, 60000U)); +} + +static void test_sendToPhone_sendsWhenCadenceElapses() +{ + TEST_ASSERT_TRUE(PositionModule::shouldSendPositionToPhone(true, true, true, 60001U, 1U, 60000U)); +} + +// No valid local position yet (e.g. GPS has no fix since boot): nothing to stream. +static void test_sendToPhone_requiresValidPosition() +{ + TEST_ASSERT_FALSE(PositionModule::shouldSendPositionToPhone(false, true, true, 60001U, 1U, 60000U)); +} + +// A backed-up toPhone queue means no client is draining it; don't pile on. +static void test_sendToPhone_requiresIdlePhoneQueue() +{ + TEST_ASSERT_FALSE(PositionModule::shouldSendPositionToPhone(true, false, true, 60001U, 1U, 60000U)); +} + +// millis() rollover: unsigned subtraction keeps the elapsed math correct across the wrap. +static void test_sendToPhone_survivesMillisRollover() +{ + constexpr uint32_t lastSent = UINT32_MAX - 29999U; // exactly 30,000 ms before the wrap to 0 + // "now" 40s after the wrap: 70,000 ms elapsed >= the 60s cadence, so it sends. + TEST_ASSERT_TRUE(PositionModule::shouldSendPositionToPhone(true, true, true, 40000U, lastSent, 60000U)); + // "now" 10s after the wrap: only 40,000 ms elapsed, still held. + TEST_ASSERT_FALSE(PositionModule::shouldSendPositionToPhone(true, true, true, 10000U, lastSent, 60000U)); +} + +// Regression: a send stamped at millis()==0 must still hold the cadence - the never-sent state +// is the everSentToPhone flag, not a lastSentMs==0 sentinel. +static void test_sendToPhone_sentAtTimeZeroStillHoldsCadence() +{ + TEST_ASSERT_FALSE(PositionModule::shouldSendPositionToPhone(true, true, true, 30000U, 0U, 60000U)); + TEST_ASSERT_TRUE(PositionModule::shouldSendPositionToPhone(true, true, true, 60000U, 0U, 60000U)); +} + void setUp(void) {} void tearDown(void) {} @@ -74,6 +124,13 @@ void setup() RUN_TEST(test_effectiveInterval_movingKeepsConfigured); RUN_TEST(test_effectiveInterval_longConfiguredWinsOverFloor); RUN_TEST(test_effectiveInterval_zeroFloorIsNoOp); + RUN_TEST(test_sendToPhone_firstSendIsImmediate); + RUN_TEST(test_sendToPhone_holdsUntilCadenceElapses); + RUN_TEST(test_sendToPhone_sendsWhenCadenceElapses); + RUN_TEST(test_sendToPhone_requiresValidPosition); + RUN_TEST(test_sendToPhone_requiresIdlePhoneQueue); + RUN_TEST(test_sendToPhone_survivesMillisRollover); + RUN_TEST(test_sendToPhone_sentAtTimeZeroStillHoldsCadence); exit(UNITY_END()); } diff --git a/test/test_stream_api/test_main.cpp b/test/test_stream_api/test_main.cpp index 1fbd370a0..5075fe461 100644 --- a/test/test_stream_api/test_main.cpp +++ b/test/test_stream_api/test_main.cpp @@ -1,7 +1,9 @@ #include "MeshTypes.h" #include "SerialConsole.h" #include "TestUtil.h" +#include "UptimeClock.h" #include "configuration.h" +#include "gps/RTC.h" #include "mesh-pb-constants.h" #include "mesh/MeshService.h" #include "mesh/NodeDB.h" @@ -10,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -522,6 +525,131 @@ static void test_want_config_includes_status_message_module_config(void) TEST_ASSERT_TRUE(foundStatusMessageConfig); } +/// Queue a packet as Router::dispatchReceived would have, before any time source existed. +static void queuePendingTimePlaceholderPacket(NodeNum from, uint32_t placeholderMillis) +{ + meshtastic_MeshPacket pending = meshtastic_MeshPacket_init_zero; + pending.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + pending.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + pending.from = from; + pending.to = NODENUM_BROADCAST; + pending.rx_time = placeholderMillis; + pending.has_rx_time = false; + service->sendToPhone(packetPool.allocCopy(pending)); +} + +static void startHandshake(PhoneAPITestShim &api) +{ + meshtastic_ToRadio request = meshtastic_ToRadio_init_zero; + request.which_payload_variant = meshtastic_ToRadio_want_config_id_tag; + request.want_config_id = SPECIAL_NONCE_ONLY_CONFIG; + uint8_t requestBytes[meshtastic_ToRadio_size]; + const size_t requestSize = pb_encode_to_bytes(requestBytes, sizeof(requestBytes), &meshtastic_ToRadio_msg, &request); + api.handleToRadio(requestBytes, requestSize); +} + +/// Drain the config stream looking for the first packet from `from`; false if never delivered. +static bool drainHandshakeForPacketFrom(PhoneAPITestShim &api, NodeNum from, meshtastic_MeshPacket &outPacket) +{ + for (unsigned i = 0; i < 256; ++i) { + uint8_t responseBytes[meshtastic_FromRadio_size]; + const size_t responseSize = api.getFromRadio(responseBytes); + if (responseSize == 0) + return false; + meshtastic_FromRadio response = meshtastic_FromRadio_init_zero; + TEST_ASSERT_TRUE(pb_decode_from_bytes(responseBytes, responseSize, &meshtastic_FromRadio_msg, &response)); + if (response.which_payload_variant == meshtastic_FromRadio_packet_tag && response.packet.from == from) { + outPacket = response.packet; + return true; + } + } + return false; +} + +/// Swaps in a scratch NodeDB and the injected clock, restoring both plus the RTC on destruction. +/// Unity's TEST_ASSERT longjmps out on failure, so cleanup must not live at the end of the test. +class ScopedTimeFixture +{ + public: + ScopedTimeFixture(uint32_t startMillis) : previous(nodeDB) + { + resetRTCStateForTests(); + nodeDB = &instance; + Time::setTestMillis(startMillis); + } + ~ScopedTimeFixture() + { + nodeDB = previous; + Time::useRealClock(); + resetRTCStateForTests(); + } + + private: + NodeDB instance; + NodeDB *previous; +}; + +// Time given at the start of the handshake, before the queued packet is drained: reconciliation +// (fired by the RTC quality crossing hook in RTC.cpp) rewrites the placeholder in place. +static void test_time_given_at_handshake_start_reconciles_queued_packet(void) +{ + ScopedMeshService scopedService; + ScopedTimeFixture timeFixture(5000); + + const NodeNum sender = 0x12345678; + queuePendingTimePlaceholderPacket(sender, 2000); // "received" 3s before the test's current millis() + + PhoneAPITestShim api; + startHandshake(api); + + struct timeval networkTime; + networkTime.tv_sec = time(NULL) + SEC_PER_DAY; + networkTime.tv_usec = 0; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &networkTime)); + + meshtastic_MeshPacket delivered; + TEST_ASSERT_TRUE_MESSAGE(drainHandshakeForPacketFrom(api, sender, delivered), + "queued packet was not delivered during the handshake"); + TEST_ASSERT_TRUE(delivered.has_rx_time); + TEST_ASSERT_UINT32_WITHIN(2, (uint32_t)networkTime.tv_sec - 3, delivered.rx_time); + + api.close(); +} + +// Time given at the end - after the queued packet already left via the handshake: the delivered +// copy keeps its unresolved placeholder, since reconciliation can only rewrite what's still queued. +static void test_time_given_at_handshake_end_does_not_rewrite_already_sent_packet(void) +{ + ScopedMeshService scopedService; + ScopedTimeFixture timeFixture(5000); + + const NodeNum sender = 0x12345678; + queuePendingTimePlaceholderPacket(sender, 2000); + + PhoneAPITestShim api; + startHandshake(api); + + // rx_time is proto3 optional, so has_rx_time false omits it from the wire entirely: the + // decoded copy reads back 0 and the placeholder itself never left the device. + meshtastic_MeshPacket delivered; + TEST_ASSERT_TRUE_MESSAGE(drainHandshakeForPacketFrom(api, sender, delivered), + "queued packet was not delivered during the handshake"); + TEST_ASSERT_FALSE(delivered.has_rx_time); + TEST_ASSERT_EQUAL_UINT32(0u, delivered.rx_time); + + // Time-giving transaction happens only now, at the end of the handshake. + struct timeval networkTime; + networkTime.tv_sec = time(NULL) + SEC_PER_DAY; + networkTime.tv_usec = 0; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &networkTime)); + + // The already-delivered copy is a value, not a queue reference - untouched either way. + TEST_ASSERT_FALSE(delivered.has_rx_time); + TEST_ASSERT_EQUAL_UINT32(0u, delivered.rx_time); + + api.close(); +} + /// Unity per-test setup; fixtures are local to each test. void setUp(void) {} /// Unity per-test teardown; fixtures clean themselves up. @@ -544,6 +672,8 @@ void setup() RUN_TEST(test_lockdown_admin_gate_ignores_wire_from); RUN_TEST(test_lockdown_admin_gate_rejects_undecodable_admin); RUN_TEST(test_want_config_includes_status_message_module_config); + RUN_TEST(test_time_given_at_handshake_start_reconciles_queued_packet); + RUN_TEST(test_time_given_at_handshake_end_does_not_rewrite_already_sent_packet); // usingProtobufs intentionally has no reset path, so this must run last. RUN_TEST(test_serial_console_suppresses_raw_output_in_protobuf_mode); exit(UNITY_END()); diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index afdcb9646..34f5a5456 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -766,6 +766,7 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie observed.decoded.bitfield = BITFIELD_WANT_RESPONSE_MASK; observed.channel = 2; observed.rx_time = 123456; + observed.has_rx_time = true; // rx_time has explicit presence; mark this synthetic reading as measured ProcessMessage observedResult = module.handleReceived(observed); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(observedResult)); diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 639ac6e28..fc18c0097 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -24,6 +24,7 @@ // "USERPREFS_CONFIG_OWNER_SHORT_NAME": "MLN", // "USERPREFS_CONFIG_DEVICE_ROLE": "meshtastic_Config_DeviceConfig_Role_CLIENT", // Defaults to CLIENT. ROUTER*, and LOST AND FOUND roles are restricted. // "USERPREFS_EVENT_MODE": "1", + // "USERPREFS_EVENT_MODE_HOP_LIMIT": "3", // Event-mode default and firmware-generated/relay hop cap (0-7; default 3) // "USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS": "1", // Extend TMM position dedup and precision clamping to private/custom-key channels (default: well-known channels only) // "USERPREFS_FIRMWARE_EDITION": "meshtastic_FirmwareEdition_BURNING_MAN", // "USERPREFS_FIXED_BLUETOOTH": "121212", diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 72cc1966e..669e6ed17 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -17,6 +17,9 @@ extra_scripts = ${env.extra_scripts} pre:extra_scripts/esp32_pre.py extra_scripts/esp32_extra.py + ; Spill long link commands into @response files. Big MUI/TFT images exceed the kernel's + ; exec arg limit ("sh: Argument list too long"; rak_wismesh_tap_v2-tft hit it first). + extra_scripts/ld_response_file.py build_src_filter = ${arduino_base.build_src_filter} + - - @@ -291,7 +294,9 @@ custom_sdkconfig = CONFIG_BT_CTRL_BLE_MAX_ACT=2 CONFIG_BT_CTRL_SCAN_DUPL_CACHE_SIZE=10 CONFIG_BT_NIMBLE_WHITELIST_SIZE=1 - CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=8 + # msys_1 is the only pool GATT allocates from, and 8 blocks left no headroom for a notify burst + # (#11245); back to the IDF default of 12 for +1KB. Raising msys_2 would not help. + CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12 CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=8 CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=8 CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=12 diff --git a/variants/esp32/rak11200/platformio.ini b/variants/esp32/rak11200/platformio.ini index 2abb9730f..ee704a23e 100644 --- a/variants/esp32/rak11200/platformio.ini +++ b/variants/esp32/rak11200/platformio.ini @@ -10,7 +10,6 @@ custom_meshtastic_tags = RAK extends = esp32_base board = wiscore_rak11200 -board_level = pr board_check = true build_flags = ${esp32_base.build_flags} diff --git a/variants/esp32p4/esp32p4.ini b/variants/esp32p4/esp32p4.ini index 5ad136806..8a284162d 100644 --- a/variants/esp32p4/esp32p4.ini +++ b/variants/esp32p4/esp32p4.ini @@ -15,9 +15,10 @@ build_flags = build_src_filter = ${esp32_common.build_src_filter} - + - - +; ld_response_file.py is inherited from esp32_common (promoted there when +; rak_wismesh_tap_v2-tft outgrew the exec arg limit; it originally shipped here). extra_scripts = ${esp32_common.extra_scripts} - extra_scripts/ld_response_file.py ; Override esp32_common component pruning: keep esp_hosted + esp_wifi_remote for P4 hosted BT custom_component_remove = diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini index 4ee4590bf..ebe31fb52 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini @@ -1,6 +1,7 @@ [env:thinknode_m7] extends = esp32s3_base board = ThinkNode-M7 +board_level = pr build_flags = ${esp32s3_base.build_flags} diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini index 70858f8a0..b0fc4bcf0 100644 --- a/variants/esp32s3/elecrow_panel/platformio.ini +++ b/variants/esp32s3/elecrow_panel/platformio.ini @@ -127,7 +127,6 @@ custom_meshtastic_partition_scheme = 16MB custom_meshtastic_has_mui = true extends = crowpanel_small_esp32s3_base -board_level = pr build_flags = ${crowpanel_small_esp32s3_base.build_flags} -D LV_CACHE_DEF_SIZE=2097152 diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 04a7bedcd..1cfe1ed14 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -24,7 +24,6 @@ custom_meshtastic_partition_scheme = 16MB custom_meshtastic_has_mui = true extends = heltec_v4_base -board_level = pr build_flags = ${heltec_v4_base.build_flags} -D HELTEC_V4_OLED diff --git a/variants/esp32s3/meshnology-w10/platformio.ini b/variants/esp32s3/meshnology-w10/platformio.ini index 27194a06c..cee86421c 100644 --- a/variants/esp32s3/meshnology-w10/platformio.ini +++ b/variants/esp32s3/meshnology-w10/platformio.ini @@ -10,7 +10,6 @@ custom_meshtastic_partition_scheme = 16MB ; ESP32-S3R8: 8 MB OPI PSRAM, W25Q128JVSIQ = 16 MB QIO flash (pg1) board = esp32-s3-devkitc-1 -board_level = pr board_build.partitions = default_16MB.csv board_upload.flash_size = 16MB board_build.flash_mode = qio diff --git a/variants/esp32s3/meshnology-w12/platformio.ini b/variants/esp32s3/meshnology-w12/platformio.ini index 01574b573..2942abc13 100644 --- a/variants/esp32s3/meshnology-w12/platformio.ini +++ b/variants/esp32s3/meshnology-w12/platformio.ini @@ -12,7 +12,6 @@ custom_meshtastic_partition_scheme = 16MB extends = esp32s3_base board = esp32-s3-devkitc-1 -board_level = pr ; ESP32-S3R8: 8 MB OPI PSRAM, 16 MB QIO flash board_build.partitions = default_16MB.csv board_upload.flash_size = 16MB diff --git a/variants/esp32s3/rak3312/platformio.ini b/variants/esp32s3/rak3312/platformio.ini index dcdb2974c..fa097c897 100644 --- a/variants/esp32s3/rak3312/platformio.ini +++ b/variants/esp32s3/rak3312/platformio.ini @@ -13,7 +13,6 @@ custom_meshtastic_has_mui = false extends = esp32s3_base board = wiscore_rak3312 -board_level = pr board_check = true upload_protocol = esptool diff --git a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini index b750e7dec..77fb165c5 100644 --- a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini +++ b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini @@ -74,6 +74,7 @@ custom_sdkconfig = [env:seeed-sensecap-indicator-tft] extends = env:seeed-sensecap-indicator +board_level = pr board_check = true upload_speed = 460800 diff --git a/variants/esp32s3/seeed_xiao_s3/platformio.ini b/variants/esp32s3/seeed_xiao_s3/platformio.ini index ee457eaf0..85f2c1b9b 100644 --- a/variants/esp32s3/seeed_xiao_s3/platformio.ini +++ b/variants/esp32s3/seeed_xiao_s3/platformio.ini @@ -12,7 +12,6 @@ custom_meshtastic_partition_scheme = 8MB extends = esp32s3_base board = seeed-xiao-s3 -board_level = pr board_check = true board_build.partitions = default_8MB.csv upload_protocol = esptool diff --git a/variants/esp32s3/station-g2/platformio.ini b/variants/esp32s3/station-g2/platformio.ini index 9c6b71cec..c874ce862 100755 --- a/variants/esp32s3/station-g2/platformio.ini +++ b/variants/esp32s3/station-g2/platformio.ini @@ -12,7 +12,6 @@ custom_meshtastic_partition_scheme = 16MB extends = esp32s3_base board = station-g2 -board_level = pr board_check = true board_build.partitions = default_16MB.csv board_build.mcu = esp32s3 diff --git a/variants/esp32s3/station-g3/platformio.ini b/variants/esp32s3/station-g3/platformio.ini index f94809ddf..ee5ee307e 100644 --- a/variants/esp32s3/station-g3/platformio.ini +++ b/variants/esp32s3/station-g3/platformio.ini @@ -12,7 +12,6 @@ custom_meshtastic_partition_scheme = 16MB extends = esp32s3_base board = station-g3 -board_level = pr board_check = true board_build.partitions = default_16MB.csv board_build.mcu = esp32s3 diff --git a/variants/esp32s3/t-beam-bpf/pins_arduino.h b/variants/esp32s3/t-beam-bpf/pins_arduino.h new file mode 100644 index 000000000..195ef2896 --- /dev/null +++ b/variants/esp32s3/t-beam-bpf/pins_arduino.h @@ -0,0 +1,26 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// UART1 (qwiic) +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +// I2C for OLED and sensors +static const uint8_t SDA = 8; +static const uint8_t SCL = 9; + +// Default SPI mapped to Radio/SD +static const uint8_t SS = 1; // LoRa CS +static const uint8_t MOSI = 11; +static const uint8_t MISO = 13; +static const uint8_t SCK = 12; + +// SD Card CS +#define SDCARD_CS 10 + +#endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/t-beam-bpf/platformio.ini b/variants/esp32s3/t-beam-bpf/platformio.ini new file mode 100644 index 000000000..ef12d0ba8 --- /dev/null +++ b/variants/esp32s3/t-beam-bpf/platformio.ini @@ -0,0 +1,23 @@ +; LilyGo T-Beam-BPF (144-148Mhz) ~5 Watt (37 dBm) +[env:t-beam-bpf] +custom_meshtastic_hw_model = 124 +custom_meshtastic_hw_model_slug = TBEAM_BPF +custom_meshtastic_architecture = esp32s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 3 +custom_meshtastic_display_name = LILYGO T-Beam BPF +custom_meshtastic_images = tbeam-1w.svg +custom_meshtastic_tags = LilyGo + +extends = esp32s3_base +board = t-beam-bpf +board_build.partitions = default_16MB.csv +board_check = true + +lib_deps = + ${esp32s3_base.lib_deps} + +build_flags = + ${esp32s3_base.build_flags} + -I variants/esp32s3/t-beam-bpf + -D T_BEAM_BPF diff --git a/variants/esp32s3/t-beam-bpf/variant.h b/variants/esp32s3/t-beam-bpf/variant.h new file mode 100644 index 000000000..d4f316ae8 --- /dev/null +++ b/variants/esp32s3/t-beam-bpf/variant.h @@ -0,0 +1,70 @@ +// LilyGo T-Beam-BPF variant.h +// Configuration based on LilyGO utilities.h and RF documentation + +// Hardware is restricted to the amateur 2m band (144-148 MHz). +#define HAS_HAM_2M_ONLY 1 + +// I2C for OLED display (SH1106 at 0x3C) +#define I2C_SDA 8 +#define I2C_SCL 9 + +// GPS - Quectel L76K. Per schematic sheet 7: + +#define GPS_RX_PIN 5 +#define GPS_TX_PIN 6 +#define GPS_1PPS_PIN 7 +#define HAS_GPS 1 +#define GPS_BAUDRATE 9600 + +// Buttons +#define BUTTON_PIN 0 // BUTTON 1 +#define ALT_BUTTON_PIN 3 // BUTTON 2 + +// SPI (shared by LoRa and SD) +#define SPI_MOSI 11 +#define SPI_SCK 12 +#define SPI_MISO 13 +#define SPI_CS 10 + +// SD Card +#define HAS_SDCARD +#define SDCARD_USE_SPI1 +// #define SDCARD_CS SPI_CS (already defined in pins_arduino.h) + +// LoRa Radio - SX1278 144-148Mhz +#define USE_RF95 +#define LORA_SCK SPI_SCK +#define LORA_MISO SPI_MISO +#define LORA_MOSI SPI_MOSI +#define LORA_CS 1 +#define LORA_RESET 18 +#define LORA_IRQ 14 +#define LORA_DIO0 LORA_IRQ +#define LORA_DIO1 21 +#define RF95_RXEN 39 // LNA enable - HIGH during RX + +// CRITICAL: Radio power enable - MUST be HIGH before lora.begin()! +// GPIO 16 powers the SX1278 via LDO +#define RF95_POWER_EN 16 + +// +27dBm PA +// LilyGo Docs specify SX1278 power must be capped at 10 +#define RF95_MAX_POWER 10 +// PA curve mapping (TX values 1-10) +// Measurements taken with 5V USB power (max 37 dBm on battery power) +// Input values: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 +// Output values: 0, 34, 35, 36, 36, 37, 37, 38, 38, 38 +#define NUM_PA_POINTS 10 +#define TX_GAIN_LORA 0, 32, 32, 32, 31, 31, 30, 30, 29, 28 + +// Display - SH1106 OLED (128x64) +#define USE_SH1106 +#define OLED_WIDTH 128 +#define OLED_HEIGHT 64 + +// 32768 Hz crystal present +#define HAS_32768HZ 1 + +// PMU +#define HAS_AXP2101 +// #define PMU_IRQ 4 // Leave disabled for now diff --git a/variants/esp32s3/t-eth-elite/platformio.ini b/variants/esp32s3/t-eth-elite/platformio.ini index 5b1f01db7..2ed698b0d 100644 --- a/variants/esp32s3/t-eth-elite/platformio.ini +++ b/variants/esp32s3/t-eth-elite/platformio.ini @@ -1,7 +1,6 @@ [env:t-eth-elite] extends = esp32s3_base board = esp32s3box -board_level = pr board_check = true board_build.partitions = default_16MB.csv build_flags = diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 22ac4ff5f..76d3fec82 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -32,7 +32,7 @@ build_type = release lib_deps = ${native_base.lib_deps} ${device-ui_base.lib_deps} -build_flags = ${native_base.build_flags} -Os -lX11 -linput -lxkbcommon -ffunction-sections -fdata-sections -Wl,--gc-sections +build_flags = ${native_base.build_flags} -Os -lcurl -lX11 -linput -lxkbcommon -ffunction-sections -fdata-sections -Wl,--gc-sections -D RAM_SIZE=16384 -D USE_X11=1 -D HAS_TFT=1 @@ -60,7 +60,7 @@ build_type = release lib_deps = ${native_base.lib_deps} ${device-ui_base.lib_deps} -build_flags = ${native_base.build_flags} -Os -ffunction-sections -fdata-sections -Wl,--gc-sections +build_flags = ${native_base.build_flags} -Os -ffunction-sections -fdata-sections -lcurl -Wl,--gc-sections -D RAM_SIZE=8192 -D USE_FRAMEBUFFER=1 -D LV_COLOR_DEPTH=32 @@ -79,6 +79,7 @@ build_flags = ${native_base.build_flags} -Os -ffunction-sections -fdata-sections -D MAP_FULL_REDRAW !pkg-config --libs libulfius --silence-errors || : !pkg-config --libs openssl --silence-errors || : + !pkg-config --cflags --libs sdl2 --silence-errors || : !pkg-config --cflags --libs libbsd-overlay --silence-errors || : build_src_filter = ${native_base.build_src_filter} @@ -89,12 +90,12 @@ build_type = debug lib_deps = ${native_base.lib_deps} ${device-ui_base.lib_deps} -build_flags = ${native_base.build_flags} -O0 -fsanitize=address -lX11 -linput -lxkbcommon +build_flags = ${native_base.build_flags} -g -O0 -fsanitize=address -lcurl -lX11 -linput -lxkbcommon -D DEBUG_HEAP -D RAM_SIZE=16384 -D USE_X11=1 -D HAS_TFT=1 - -D HAS_SCREEN=0 + -D HAS_SCREEN=1 -D LV_CACHE_DEF_SIZE=6291456 -D LV_BUILD_TEST=0 -D LV_USE_LOG=1 @@ -112,6 +113,7 @@ build_flags = ${native_base.build_flags} -O0 -fsanitize=address -lX11 -linput -l -D VIEW_320x240 !pkg-config --libs libulfius --silence-errors || : !pkg-config --libs openssl --silence-errors || : + !pkg-config --cflags --libs sdl2 --silence-errors || : !pkg-config --cflags --libs libbsd-overlay --silence-errors || : build_src_filter = ${env:native-tft.build_src_filter} @@ -421,7 +423,7 @@ build_src_filter = + + + + + + + + - + + + + + + + + + + + + + + + + + + + + @@ -429,7 +431,8 @@ build_src_filter = + + + + + - - - + ; template-only, amalgamated into InterfacesTemplates.cpp + - + + + + diff --git a/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini new file mode 100644 index 000000000..6b845b2ef --- /dev/null +++ b/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini @@ -0,0 +1,41 @@ +; First prototype eink/nrf52840/sx1262 device +[env:thinknode_m8] +custom_meshtastic_support_level = 1 +custom_meshtastic_images = thinknode_m8.svg +custom_meshtastic_tags = Elecrow +custom_meshtastic_hw_model = 130 +custom_meshtastic_hw_model_slug = THINKNODE_M8 +custom_meshtastic_architecture = nrf52840 +custom_meshtastic_display_name = Elecrow ThinkNode M8 +custom_meshtastic_actively_supported = true + +extends = nrf52840_base +board = ThinkNode-M8 +board_check = true +debug_tool = jlink +# add -DCFG_SYSVIEW if you want to use the Segger systemview tool for OS profiling. +build_flags = + ${nrf52840_base.build_flags} + -I variants/nrf52840/ELECROW-ThinkNode-M8 + -D ELECROW_ThinkNode_M8 + -D EINK_DISPLAY_MODEL=GxEPD2_154_D67 + -D EINK_WIDTH=200 + -D EINK_HEIGHT=200 + -D USE_EINK_DYNAMICDISPLAY ; Enable Dynamic EInk + -D EINK_LIMIT_FASTREFRESH=20 ; How many consecutive fast-refreshes are permitted //20 + -D EINK_LIMIT_RATE_BACKGROUND_SEC=10 ; Minimum interval between BACKGROUND updates //30 + -D EINK_LIMIT_RATE_RESPONSIVE_SEC=1 ; Minimum interval between RESPONSIVE updates + -D EINK_BACKGROUND_USES_FAST ; (Optional) Use FAST refresh for both BACKGROUND and RESPONSIVE, until a limit is reached. +build_src_filter = + ${nrf52_base.build_src_filter} + +<../variants/nrf52840/ELECROW-ThinkNode-M8> +lib_deps = + ${nrf52840_base.lib_deps} + # renovate: datasource=git-refs depName=meshtastic-GxEPD2 packageName=https://github.com/meshtastic/GxEPD2 gitBranch=master + https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip + # renovate: datasource=custom.pio depName=nRF52_PWM packageName=khoih-prog/library/nRF52_PWM + khoih-prog/nRF52_PWM@1.0.1 + # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib + lewisxhe/SensorLib@0.3.4 + + diff --git a/variants/nrf52840/ELECROW-ThinkNode-M8/variant.cpp b/variants/nrf52840/ELECROW-ThinkNode-M8/variant.cpp new file mode 100644 index 000000000..99d6b8a91 --- /dev/null +++ b/variants/nrf52840/ELECROW-ThinkNode-M8/variant.cpp @@ -0,0 +1,75 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "variant.h" +#include "main.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" +#include + +const uint32_t g_ADigitalPinMap[] = { + // P0 - pins 0 and 1 are hardwired for xtal and should never be enabled + 0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47}; + +void initVariant() +{ + pinMode(I2C_EN, OUTPUT); + digitalWrite(I2C_EN, HIGH); + pinMode(VCC_ELNK_EN, OUTPUT); + digitalWrite(VCC_ELNK_EN, HIGH); + pinMode(PIN_GPS_EN, OUTPUT); + digitalWrite(PIN_GPS_EN, HIGH); + pinMode(ADC_EN, OUTPUT); + digitalWrite(ADC_EN, HIGH); + Wire.setPins(PIN_WIRE_SDA, PIN_WIRE_SCL); +} + +void variant_shutdown() +{ + auto dispdev = screen->getDisplayDevice(); + dispdev->resetDisplay(); + screen->forceDisplay(); + delay(500); + digitalWrite(I2C_EN, LOW); + digitalWrite(VCC_ELNK_EN, LOW); + digitalWrite(PIN_GPS_EN, LOW); + digitalWrite(ADC_EN, LOW); + for (int pin = 0; pin < 48; pin++) { + if (pin == I2C_EN || pin == VCC_ELNK_EN || pin == PIN_GPS_EN || pin == ADC_EN || pin == PIN_BUTTON1 || + pin == SX1262_SPI_NSS_PIN || pin == SX1262_SPI_SCK_PIN || pin == SX1262_SPI_MOSI_PIN || pin == SX1262_SPI_MISO_PIN || + pin == SX1262_IRQ_PIN || pin == SX1262_NRESET_PIN || pin == SX126X_BUSY) { + continue; + } + pinMode(pin, OUTPUT); + digitalWrite(pin, LOW); + if (pin >= 32) { + NRF_P1->DIRCLR = (1 << (pin - 32)); + } else { + NRF_GPIO->DIRCLR = (1 << pin); + } + } + nrf_gpio_cfg_input(PIN_BUTTON1, NRF_GPIO_PIN_PULLUP); // Configure the pin to be woken up as an input + nrf_gpio_pin_sense_t sense1 = NRF_GPIO_PIN_SENSE_LOW; + nrf_gpio_cfg_sense_set(PIN_BUTTON1, sense1); +} diff --git a/variants/nrf52840/ELECROW-ThinkNode-M8/variant.h b/variants/nrf52840/ELECROW-ThinkNode-M8/variant.h new file mode 100644 index 000000000..bb9cd92dd --- /dev/null +++ b/variants/nrf52840/ELECROW-ThinkNode-M8/variant.h @@ -0,0 +1,182 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef _VARIANT_ELECROW_EINK_V1_0_ +#define _VARIANT_ELECROW_EINK_V1_0_ + +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32khz crystal for LF + +/*---------------------------------------------------------------------------- + * Headers + *----------------------------------------------------------------------------*/ + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +// Number of pins defined in PinDescription array +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (1) +#define NUM_ANALOG_OUTPUTS (0) + +#define PIN_NFC1 (9) +#define PIN_NFC2 (10) + +#define CANNED_MESSAGE_MODULE_ENABLE 1 + +/*Power*/ +#define I2C_EN (0 + 13) +#define VCC_ELNK_EN (32 + 10) +#define PIN_GPS_EN (0 + 16) // active high +#define ADC_EN (32 + 8) + +/*Buttons*/ +#define HAS_BUTTON 1 +#define PIN_BUTTON_E (0 + 12) +#define PIN_BUTTON_EC04_A (0 + 8) +#define PIN_BUTTON_EC04_B (32 + 9) +#define PIN_BUTTON_EC04 (0 + 6) +#define PIN_BUTTON1 PIN_BUTTON_E +// Press and turn the encoder to dim the frontlight +#define INPUTDRIVER_PRESS_AND_TURN_CW INPUT_BROKER_MSG_BRIGHTNESS_UP +#define INPUTDRIVER_PRESS_AND_TURN_CCW INPUT_BROKER_MSG_BRIGHTNESS_DOWN + +/*LED*/ +#define PIN_LED1 -1 +#define LED_STATE_ON HIGH // State when LED is lit +#define LED_BLUE PIN_LED1 + +/*BUZZER*/ +#define PIN_BUZZER (32 + 1) + +/*USB_CHECK*/ +// Reference only: sits on a divider from VBUS, too near the input threshold to read +// digitally. USB presence comes from USBREGSTATUS instead. +#define USB_VBUS (32 + 3) + +/*CHARGE_CHECK*/ +// Reference only: link resistors and pull-ups unpopulated on V0.3, so these never reach the MCU. +#define CHRG (32 + 5) +#define DONE (32 + 6) + +/*Wire Interfaces*/ +// Found by the I2C scan: PCF8563 RTC at 0x51, SC7A20 accelerometer at 0x19. +#define WIRE_INTERFACES_COUNT 1 +#define PIN_WIRE_SDA (0 + 26) +#define PIN_WIRE_SCL (0 + 27) + +/*GPS*/ +// ATGM336H-5NR32. Probes as GNSS_MODEL_MTK; both branches speak CASIC $PCAS. +#define HAS_GPS 1 +#define GPS_BAUDRATE 9600 +#define PIN_GPS_RESET (0 + 17) +#define PIN_GPS_STANDBY (0 + 15) // An output to wake GPS, low means allow sleep, high means force wake +#define PIN_GPS_PPS (0 + 14) // 1PPS output, configured as an input by GPS.cpp +#define PIN_SERIAL1_RX (32 + 2) +#define PIN_SERIAL1_TX (32 + 4) +#define GPS_TX_PIN PIN_SERIAL1_TX +#define GPS_RX_PIN PIN_SERIAL1_RX +#define GPS_THREAD_INTERVAL 50 + +/*FLASH*/ +#define PIN_QSPI_CS (32 + 15) +#define PIN_QSPI_SCK (32 + 14) +#define PIN_QSPI_IO0 (32 + 12) // MOSI if using two bit interface +#define PIN_QSPI_IO1 (32 + 13) // MISO if using two bit interface +#define PIN_QSPI_IO2 (0 + 7) // WP if using two bit interface (i.e. not used) +#define PIN_QSPI_IO3 (0 + 5) // HOLD if using two bit interface (i.e. not used) +#define EXTERNAL_FLASH_DEVICES MX25R1635F +#define EXTERNAL_FLASH_USE_QSPI + +/*SPI*/ +#define SPI_INTERFACES_COUNT 2 +#define PIN_SPI_NSS (0 + 21) +#define PIN_SPI_SCK (0 + 19) +#define PIN_SPI_MOSI (0 + 20) +#define PIN_SPI_MISO (0 + 22) + +#define PIN_SPI1_NSS (0 + 30) +#define PIN_SPI1_SCK (0 + 31) +#define PIN_SPI1_MOSI (0 + 29) +#define PIN_SPI1_MISO -1 +/*EINK*/ +#define MESHTASTIC_USE_EINK_UI 1 +#define USE_EINK 1 +#define PIN_EINK_CS PIN_SPI1_NSS +#define PIN_EINK_SCLK PIN_SPI1_SCK +#define PIN_EINK_MOSI PIN_SPI1_MOSI +#define PIN_EINK_BUSY (0 + 3) +#define PIN_EINK_DC (0 + 28) +#define PIN_EINK_RES (0 + 2) +// Panel supply is VCC_ELNK_EN; P1.11 is the dimmable frontlight. No PIN_EINK_EN on this board. +#define PIN_PWM_BACKLIGHT (32 + 11) +#define PWM_BACKLIGHT_DEFAULT 128 +#define PWM_BACKLIGHT_MIN 8 +#define PWM_BACKLIGHT_MAX 248 +#define PWM_BACKLIGHT_STEP 20 + +/*Lora radio*/ +#define USE_SX1262 +// PE4259 /CTRL pad, held high for single-pin mode; the SX1262 selects the path from DIO2. +#define SX126X_ANT_SW (0 + 23) +#define SX126X_RESET (0 + 24) // RST +#define SX126X_DIO1 (0 + 25) // IRQ +#define SX126X_DIO2 (32 + 0) // BUSY +#define SX126X_SCK PIN_SPI_SCK +#define SX126X_MISO PIN_SPI_MISO +#define SX126X_MOSI PIN_SPI_MOSI +#define SX126X_CS PIN_SPI_NSS + +#define SX1262_IRQ_PIN SX126X_DIO1 +#define SX1262_NRESET_PIN SX126X_RESET +#define SX126X_BUSY SX126X_DIO2 +#define SX1262_SPI_NSS_PIN SX126X_CS +#define SX1262_SPI_SCK_PIN SX126X_SCK +#define SX1262_SPI_MOSI_PIN SX126X_MOSI +#define SX1262_SPI_MISO_PIN SX126X_MISO +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 3.3 + +/*Serial*/ +#define SERIAL_PRINT_PORT 0 // USB CDC is the StreamAPI transport; no secondary UART + +/*RTC*/ +#define PCF8563_RTC 0x51 + +/*Battert*/ +#define BATTERY_PIN (0 + 4) +#define ADC_V (0 + 4) +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define BATTERY_SENSE_RESOLUTION 4096.0 +#define BATTERY_SENSE_SAMPLES 100 +#undef AREF_VOLTAGE +#define AREF_VOLTAGE 2.4 +#define VBAT_AR_INTERNAL AR_INTERNAL_2_4 +#define ADC_MULTIPLIER (1.75) + +#ifdef __cplusplus +} +#endif + +#endif \ No newline at end of file diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini index 82a4e1953..51d058bee 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini @@ -15,6 +15,7 @@ board = promicro-nrf52840 build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/diy/nrf52_promicro_diy_tcxo -D NRF52_PROMICRO_DIY + -D EXCLUDE_EMOJI ; this variant builds 4 radio driver families and is the largest nrf52 image; emote bitmaps don't fit under the 0xEA000 warm-store cap build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo> debug_tool = jlink @@ -38,3 +39,17 @@ lib_deps = extra_scripts = ${nrf52840_base.extra_scripts} variants/nrf52840/diy/nrf52_promicro_diy_tcxo/custom_build_tasks.py ; Add to PIO's Project Tasks pane: preset builds for common displays + + +; NRF52 ProMicro w/ EasyProMicro pinout +[env:nrf52_promicro_diy-easypromicro] +board_level = extra +extends = nrf52840_base +board = promicro-nrf52840 +build_flags = + ${nrf52840_base.build_flags} + -I variants/nrf52840/diy/nrf52_promicro_diy_tcxo + -D NRF52_PROMICRO_DIY + -D EASYPROMICRO ; For EasyProMicro pinout (instead of ProMicro pinout) +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo> +debug_tool = jlink \ No newline at end of file diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h index 5b580f684..9220b9c30 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h @@ -44,6 +44,32 @@ NRF52 PRO MICRO PIN ASSIGNMENT | P1.07 | Free pin   |   |         |             | | */ +/* +Alternative pin assignement for Easy E22 Promicro build +https://github.com/brad112358/easy_E22 + +| Pin   | Function   |   | Pin     | Function     | +| ----- | ----------- | --- | -------- | ------------ | +| Gnd   |             |   | vbat     |             | +| P0.06 | BUTTON_PIN |   | vbat     |             | +| P0.08 | PIN_BUZZER |   | Gnd     |             | +| Gnd   |             |   | reset   |             | +| Gnd   |             |   | ext_vcc | *see 0.13   | +| P0.17 | Serial2 TX |   | P0.31   | BATTERY_PIN | +| P0.20 | Rotary B   |   | P0.29   | DIO1         | +| P0.22 | Rotary A   |   | P0.02   | BUSY | +| P0.24 | Rotary press|   | P1.15   | NRST         | +| P1.00 | TXEN |   | P1.13   | MISO         | +| P0.11 | RXEN        |   | P1.11   | MOSI        | +| P1.04 | SDA         |   | P0.10   | SCK     | +| P1.06 | SCL   |   | P0.09   | CS       | +|       |             |   |         |             | +|       | Mid board   |   |         | Internal     | +| P1.01 | Serial2 RX |   | 0.15     | LED         | +| P1.02 | TX to GPS   |   | 0.13     | 3V3_EN       | +| P1.07 | RX from GPS |   |         |             | +*/ + // Number of pins defined in PinDescription array #define PINS_COUNT (48) #define NUM_DIGITAL_PINS (48) @@ -76,8 +102,13 @@ NRF52 PRO MICRO PIN ASSIGNMENT // WIRE IC AND IIC PINS #define WIRE_INTERFACES_COUNT 1 +#ifndef EASYPROMICRO #define PIN_WIRE_SDA (32 + 4) // P1.04 #define PIN_WIRE_SCL (0 + 11) // P0.11 +#else // Easy E22 Promicro arrangement +#define PIN_WIRE_SDA (32 + 4) // P1.04 +#define PIN_WIRE_SCL (32 + 6) // P1.06 +#endif // LED #define PIN_LED1 (0 + 15) // P0.15 @@ -86,13 +117,23 @@ NRF52 PRO MICRO PIN ASSIGNMENT #define LED_STATE_ON 1 // State when LED is lit // Button +#ifndef EASYPROMICRO #define BUTTON_PIN (32 + 0) // P1.00 +#else // Easy E22 Promicro arrangement +#define BUTTON_PIN (0 + 6) // P0.06 +#endif // GPS +#ifndef EASYPROMICRO #define GPS_TX_PIN (0 + 20) // P0.20 - This is data from the MCU #define GPS_RX_PIN (0 + 22) // P0.22 - This is data from the GNSS - #define PIN_GPS_EN (0 + 24) // P0.24 +#else // Easy E22 Promicro arrangement +#define GPS_TX_PIN (32 + 2) // P1.02 - This is data from the MCU +#define GPS_RX_PIN (32 + 7) // P1.07 - This is data from the GNSS +#define PIN_GPS_EN (0 + 13) // P0.13 - Use power control for GPS enable +#endif + #define GPS_UBLOX // define GPS_DEBUG @@ -100,12 +141,20 @@ NRF52 PRO MICRO PIN ASSIGNMENT #define PIN_SERIAL1_TX GPS_TX_PIN #define PIN_SERIAL1_RX GPS_RX_PIN -#define PIN_SERIAL2_RX (0 + 6) // P0.06 -#define PIN_SERIAL2_TX (0 + 8) // P0.08 +#ifndef EASYPROMICRO +#define PIN_SERIAL2_RX (0 + 6) // P0.06 +#define PIN_SERIAL2_TX (0 + 8) // P0.08 +#else // Easy E22 Promicro arrangement +#define PIN_SERIAL2_RX (32 + 1) // P1.01 +#define PIN_SERIAL2_TX (0 + 17) // P0.17 +// Buzzer - PWM +#define PIN_BUZZER (0 + 8) // p0.08 +#endif // Serial interfaces #define SPI_INTERFACES_COUNT 1 +#ifndef EASYPROMICRO #define PIN_SPI_MISO (0 + 2) // P0.02 #define PIN_SPI_MOSI (32 + 15) // P1.15 #define PIN_SPI_SCK (32 + 11) // P1.11 @@ -166,7 +215,35 @@ NRF52 PRO MICRO PIN ASSIGNMENT #define LR2021_IRQ_DIO_NUM 9 // DIO9 → P0.10 #endif -// #define SX126X_MAX_POWER 8 set this if using a high-power board! +#else // Easy E22 Promicro arrangement +#define USE_SSD1306 +// #define USE_SH1106 + +#define USE_SX1262 +#define PIN_SPI_MISO (32 + 13) // P1.13 +#define PIN_SPI_MOSI (32 + 11) // P1.11 +#define PIN_SPI_SCK (0 + 10) // P0.10 + +#define LORA_MISO PIN_SPI_MISO +#define LORA_MOSI PIN_SPI_MOSI +#define LORA_SCK PIN_SPI_SCK +#define LORA_CS (0 + 9) // P0.09 NSS +#define LORA_DIO0 (0 + 2) // P0.02 BUSY +#define LORA_DIO1 (0 + 29) // P0.29 IRQ +#define LORA_RESET (32 + 15) // P1.15 NRST + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO0 +#define SX126X_RESET LORA_RESET +#define SX126X_RXEN (0 + 11) // P0.11 +#define SX126X_TXEN (32 + 0) // P1.00 +#define SX126X_MAX_POWER 8 // Default to prevent damage to E22_900M33S; Comment out for others +#undef TX_GAIN_LORA +#define TX_GAIN_LORA 22 // 8 for E22 900M30S, 25 for 900M33S, 22 for 3.7V battery powered 900M33S, 0 for 900M22S +#endif + +// #define SX126X_MAX_POWER 8 // set this if using a high-power board! /* On the SX1262, DIO3 sets the voltage for an external TCXO, if one is present. If one is not present, use TCXO_OPTIONAL to try both diff --git a/variants/nrf52840/heltec_mesh_node_t096/platformio.ini b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini index 03aa03310..98ca481c2 100644 --- a/variants/nrf52840/heltec_mesh_node_t096/platformio.ini +++ b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_tags = Heltec extends = nrf52840_base board = heltec_mesh_node_t096 -board_level = pr debug_tool = jlink build_flags = ${nrf52840_base.build_flags} diff --git a/variants/nrf52840/heltec_mesh_node_t1/platformio.ini b/variants/nrf52840/heltec_mesh_node_t1/platformio.ini index 7d822685a..154fa2f35 100644 --- a/variants/nrf52840/heltec_mesh_node_t1/platformio.ini +++ b/variants/nrf52840/heltec_mesh_node_t1/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_tags = Heltec extends = nrf52840_base board = heltec_mesh_node_t1 -board_level = pr debug_tool = jlink build_flags = ${nrf52840_base.build_flags} diff --git a/variants/nrf52840/heltec_mesh_solar/platformio.ini b/variants/nrf52840/heltec_mesh_solar/platformio.ini index 536045cfa..01af41a5f 100644 --- a/variants/nrf52840/heltec_mesh_solar/platformio.ini +++ b/variants/nrf52840/heltec_mesh_solar/platformio.ini @@ -2,7 +2,6 @@ [heltec_mesh_solar_base] extends = nrf52840_base board = heltec_mesh_solar -board_level = pr debug_tool = jlink # add -DCFG_SYSVIEW if you want to use the Segger systemview tool for OS profiling. diff --git a/variants/nrf52840/heltec_mesh_tower_v2/platformio.ini b/variants/nrf52840/heltec_mesh_tower_v2/platformio.ini index 05f3f49d4..9b7090677 100644 --- a/variants/nrf52840/heltec_mesh_tower_v2/platformio.ini +++ b/variants/nrf52840/heltec_mesh_tower_v2/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_tags = Heltec extends = nrf52840_base board = heltec_mesh_tower_v2 -board_level = pr debug_tool = jlink # add -DCFG_SYSVIEW if you want to use the Segger systemview tool for OS profiling. diff --git a/variants/nrf52840/rak_wismeshtag/platformio.ini b/variants/nrf52840/rak_wismeshtag/platformio.ini index 9b0d665b3..9e819ab91 100644 --- a/variants/nrf52840/rak_wismeshtag/platformio.ini +++ b/variants/nrf52840/rak_wismeshtag/platformio.ini @@ -6,7 +6,6 @@ custom_meshtastic_tags = RAK extends = nrf52840_base board = wiscore_rak4631 -board_level = pr board_check = true custom_meshtastic_hw_model = 105 custom_meshtastic_hw_model_slug = WISMESH_TAG diff --git a/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini b/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini index 7018d054e..0fa09d407 100644 --- a/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini +++ b/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_tags = Seeed extends = nrf52840_base board = xiao_ble_sense -board_level = pr build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/seeed_xiao_nrf52840_kit -I src/platform/nrf52/softdevice diff --git a/variants/nrf52840/t-echo/platformio.ini b/variants/nrf52840/t-echo/platformio.ini index 58ad029ae..40396e1a5 100644 --- a/variants/nrf52840/t-echo/platformio.ini +++ b/variants/nrf52840/t-echo/platformio.ini @@ -12,7 +12,6 @@ custom_meshtastic_has_ink_hud = true extends = nrf52840_base board = t-echo -board_level = pr board_check = true debug_tool = jlink @@ -39,7 +38,6 @@ lib_deps = [env:t-echo-inkhud] extends = nrf52840_base, inkhud board = t-echo -board_level = pr board_check = true debug_tool = jlink build_flags = diff --git a/variants/nrf52840/t-impulse-plus/platformio.ini b/variants/nrf52840/t-impulse-plus/platformio.ini index 337b586dc..428724099 100644 --- a/variants/nrf52840/t-impulse-plus/platformio.ini +++ b/variants/nrf52840/t-impulse-plus/platformio.ini @@ -10,7 +10,6 @@ custom_meshtastic_requires_dfu = true extends = nrf52840_base board = t-impulse-plus -board_level = pr build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/t-impulse-plus diff --git a/variants/rp2040/rak11310/platformio.ini b/variants/rp2040/rak11310/platformio.ini index d9bd5cb16..1b1f636a6 100644 --- a/variants/rp2040/rak11310/platformio.ini +++ b/variants/rp2040/rak11310/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_requires_dfu = true extends = rp2040_base board = rakwireless_rak11300 -board_level = pr upload_protocol = picotool # add our variants files to the include and src paths build_flags = diff --git a/variants/rp2040/rpipico/platformio.ini b/variants/rp2040/rpipico/platformio.ini index 4ae134b28..c07d2d24f 100644 --- a/variants/rp2040/rpipico/platformio.ini +++ b/variants/rp2040/rpipico/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_requires_dfu = true extends = rp2040_base board = rpipico -board_level = pr upload_protocol = picotool # add our variants files to the include and src paths build_flags = diff --git a/variants/rp2040/seeed_xiao_rp2040/platformio.ini b/variants/rp2040/seeed_xiao_rp2040/platformio.ini index 985b573a9..1aad30a3d 100644 --- a/variants/rp2040/seeed_xiao_rp2040/platformio.ini +++ b/variants/rp2040/seeed_xiao_rp2040/platformio.ini @@ -1,7 +1,6 @@ [env:seeed_xiao_rp2040] extends = rp2040_base board = seeed_xiao_rp2040 -board_level = pr upload_protocol = picotool # add our variants files to the include and src paths build_src_filter = ${rp2040_base.build_src_filter} +<../variants/rp2040/seeed_xiao_rp2040/> diff --git a/variants/rp2350/diy/pico2_w5500_e22/platformio.ini b/variants/rp2350/diy/pico2_w5500_e22/platformio.ini index b62dbcfb5..1a7f51b7b 100644 --- a/variants/rp2350/diy/pico2_w5500_e22/platformio.ini +++ b/variants/rp2350/diy/pico2_w5500_e22/platformio.ini @@ -1,7 +1,7 @@ [env:pico2_w5500_e22] extends = rp2350_base board = rpipico2 -board_level = community +board_level = extra upload_protocol = picotool # Increase LittleFS from 0.5m to 0.75m so GZIP firmware (~614KB) fits for OTA staging diff --git a/variants/rp2350/diy/wiznet_5500_evb_pico2_e22p/platformio.ini b/variants/rp2350/diy/wiznet_5500_evb_pico2_e22p/platformio.ini index 62bde41eb..12cc37c38 100644 --- a/variants/rp2350/diy/wiznet_5500_evb_pico2_e22p/platformio.ini +++ b/variants/rp2350/diy/wiznet_5500_evb_pico2_e22p/platformio.ini @@ -1,7 +1,7 @@ [env:wiznet_5500_evb_pico2_e22p] extends = rp2350_base board = wiznet_5500_evb_pico2 -board_level = community +board_level = extra upload_protocol = picotool # Reuses the variant.h from variants/rp2350/diy/pico2_w5500_e22 - same LoRa diff --git a/variants/rp2350/rpipico2/platformio.ini b/variants/rp2350/rpipico2/platformio.ini index 30dc15256..2ff65c9cd 100644 --- a/variants/rp2350/rpipico2/platformio.ini +++ b/variants/rp2350/rpipico2/platformio.ini @@ -1,7 +1,6 @@ [env:pico2] extends = rp2350_base board = rpipico2 -board_level = pr upload_protocol = picotool # add our variants files to the include and src paths diff --git a/variants/rp2350/seeed_xiao_rp2350/platformio.ini b/variants/rp2350/seeed_xiao_rp2350/platformio.ini index 20f85876f..c9d85af74 100644 --- a/variants/rp2350/seeed_xiao_rp2350/platformio.ini +++ b/variants/rp2350/seeed_xiao_rp2350/platformio.ini @@ -1,7 +1,6 @@ [env:seeed_xiao_rp2350] extends = rp2350_base board = seeed_xiao_rp2350 -board_level = pr upload_protocol = picotool # add our variants files to the include and src paths diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index ea8673d77..02955ff2a 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -2,7 +2,7 @@ extends = arduino_base platform = # renovate: datasource=custom.pio depName=platformio/ststm32 packageName=platformio/platform/ststm32 - platformio/ststm32@19.7.0 + platformio/ststm32@19.7.1 platform_packages = # renovate: datasource=github-tags depName=Arduino_Core_STM32 packageName=stm32duino/Arduino_Core_STM32 platformio/framework-arduinoststm32@https://github.com/stm32duino/Arduino_Core_STM32/archive/2.10.1.zip