Files
firmware/bin/test-config-check.sh
Tom 2d6dad9ee9 Portduino: Fix LR2021 switch tables, power ceilings and IRQ handling (#11382)
* fix(portduino): recognise the LR2021 power ceilings in --check

loadConfig() has read Lora.LR2021_MAX_POWER and Lora.LR2021_MAX_POWER_HF
since LR2021 support landed, but neither was listed in the config checker's
schema. --check therefore reported both as "unknown key ... ignored by
meshtasticd" -- false, and actively misleading: it tells the user to delete a
key that is doing exactly what they wanted.

This breaks the contract stated above schema(), that a key taught to
loadConfig() is added there too. CI enforces that by running --check over
bin/config.d/**, but no shipped config sets either key -- or mentions lr2021
at all -- so nothing ever tripped over the omission. It could only surface
for someone hand-writing an LR2021 config.

LR20x0 is the only module with two power ceilings, one per band, selected at
runtime by region; every other family expresses the split as separate module
names and needs a single key. That is the likely reason the pair was missed
while every other *_MAX_POWER key was added.

Also adds both to valueSpecs(), so a wrong-typed value is reported rather
than silently replaced by the default.

* feat(portduino): configurable IRQ DIO and a chip-neutral RF switch table for LR20x0

Two gaps found bringing an LR2021 up under meshtasticd on a Luckfox Lyra
Zero W. Both sit in the LR20x0 support added in #11252, and they interact:
the switch table has to be written slightly wrong to pass validation, and
the interrupt lands on a pin that table is driving. Every symptom is silent,
because begin() only exercises SPI and BUSY -- the radio reports init
success and then receives nothing.

IRQ DIO could not be set on Portduino
-------------------------------------
LR20x0Interface picked the IRQ DIO purely at compile time, and neither
LR2021_IRQ_DIO_NUM nor IRQ_DIO_NUM exists for a Portduino target, so
meshtasticd always fell through to RadioLib's default of DIO5 -- which is
also the first RF switch line on carriers using the DIO5-DIO8 table. A
variant says this with a #define (the pro-micro DIY board uses DIO9); a
carrier has only the YAML, and had no way to say it.

Adds Lora.IRQ_DIO_NUM, and an ARCH_PORTDUINO branch after the two existing
#define branches, so a variant that already sets one still wins.

The switch table was parsed as LR11xx-only
------------------------------------------
Pin names resolved to RADIOLIB_LR11X0_DIOn whatever the radio, and the mode
set was the LR11xx's, so MODE_RX_HF -- a mode the LR20x0 really has -- was
rejected as an unknown key and had to be omitted. The two families are not
interchangeable: an LR11xx has no DIO9, so its fifth switch slot is DIO10,
while an LR20x0's fifth slot is DIO9 and DIO10 is its sixth. A table naming
DIO10 was therefore driving the wrong pin on an LR20x0.

The YAML layer now stores what was written -- a DIO number and a neutral
mode id -- and each interface supplies its own DIO constants and OpMode_t
map to a shared builder. Neither family's constants are assumed to coincide
with the other's.

This also fixes a round trip in the config writer, which decoded pins by
comparing against RADIOLIB_LR11X0_* and always emitted five values per mode
row: for a four-pin table it produced YAML that --check would reject for
mismatched row lengths.

--check
-------
Findings are now judged against the resolved module rather than a fixed
list, so a mode or pin the part does have can no longer be rejected, and one
it does not have is named instead of silently accepted. The claim that the
table "is only applied to LR11xx radios" was stale and is corrected, and the
missing-table warning now covers both families. "auto" is excluded
throughout: the module has not been probed yet, so absence cannot be judged.

The IRQ/switch-pin collision is reported in both directions, including the
harder case where no key is set and the radio default collides -- nothing in
the file looks wrong. Note that listing a pin is what breaks it, not driving
it: setRfSwitchTable() reassigns the DIO function for every pin in the list
whatever the levels say, so an all-LOW column is still a collision.

Seven fixtures cover these, including a false-positive guard: DIO5 as the
interrupt is normal, and must stay silent when the table is elsewhere.

* feat(portduino): let the YAML ask for a TCXO probe, across every family that has one

A variant declares "a TCXO may or may not be fitted" at compile time with
TCXO_OPTIONAL, because the board is known when the image is built. A
Portduino carrier cannot: the same meshtasticd binary runs on hardware
populated either way, so the statement has to arrive as YAML and be answered
at runtime.

Adds Lora.TCXO_OPTIONAL, and TCXO_OPTIONAL_ENABLED in RadioLibInterface.h to
unify the two, so each driver asks the question once rather than growing a
second, Portduino-shaped code path. On an embedded target it stays a
compile-time constant, so `if (TCXO_OPTIONAL_ENABLED)` folds away exactly as
the old `#if` did: the nrf52_promicro_diy_tcxo image, which defines
TCXO_OPTIONAL and so exercises the converted branches, still ends at
0xDF1D0 -- the same address as before this change.

It is defined there rather than in a header of its own because
InterfacesTemplates.cpp includes all three interface .cpp files into one
translation unit, where a per-file definition would collide.

Covers every family that has a TCXO reference to probe for: SX126x
(sx1262/sx1268/LLCC68), LR11xx and LR20x0. With no DIO3_TCXO_VOLTAGE given,
the TCXO attempt uses RadioLib's own 1.6 V default rather than being skipped
-- otherwise there is nothing to fall back FROM and the flag would silently
do nothing. This is also the FIXME that sat on the Portduino branch in
LR20x0Interface: an unset voltage now means "no TCXO" explicitly.

Two things are deliberately left alone:

Each family keeps its own probe order. LR11xx tries XTAL first, because a
TCXO-first attempt hangs RadioLib's unbounded calibration wait on a module
with no TCXO fitted, whereas XTAL fails fast and cleanly on a module that
has one; LR20x0 and SX126x try the TCXO first. A carrier therefore behaves
the same way in a Portduino build as in an embedded one, and changing an
order stays a hardware-behaviour decision rather than a tidying-up one.

The SX126x retry is Portduino-only. An embedded TCXO_OPTIONAL board already
gets this from initLoRa(), which constructs a second SX126x interface with
no Vref when the first fails; retrying inside init() as well would leave
that ladder step unreachable and change how every existing t-echo-class
board reports its oscillator. A Portduino build has no ladder to fall
through, because the module is named in YAML rather than probed.

--check learns the key, reports which Vref will actually be tried, and warns
when it is set on a radio with no TCXO reference, where it is read, stored
and inert.

* docs(portduino): condense the comments on this branch

The repo asks for one or two lines and no multi-paragraph blocks, on the
grounds that the diff and the commit message carry the rationale while the
code carries the behaviour. What landed here was well past that: 163 added
comment lines, including a 26-line block above a single macro.

Removes the rhetoric, the issue numbers and the before-and-after asides, and
the notes on where a thing used to live. No added block is longer than three
lines now.

Two facts needed stating and are stated once each rather than repeated at
every use: the slot/DIO divergence between the families, in PortduinoGlue.h,
and the per-family TCXO probe order, in RadioLibInterface.h. The longest
surviving explanation is why an all-LOW switch column still collides with the
interrupt, which sits in the fixtures README because without it that pair of
fixtures reads as contradictory.

Comments only; no functional change.

* address CodeRabbit review on #11382

- SX126xInterface: distinguish an explicit DIO3_TCXO_VOLTAGE from the
  TCXO_OPTIONAL probing default in the debug log instead of always
  claiming the config field was set.
- ConfigCheck: modesFor() now reports an unresolved use_autoconf against
  the union of both radio families' modes, not the LR11xx subset - fixes
  a false "not a mode this part has" warning for valid LR2021-only modes
  (e.g. RFSW_RX_HF) before autodetection resolves the module.
- PortduinoGlue loadConfig: build rfswitch_mode_high[m] as a fresh
  per-row bitmask instead of OR-accumulating onto a stale value, so a
  config re-parse can clear a slot back to LOW.
- PortduinoGlue YAML serialization: gate rfswitch_table emission on
  has_rfswitch_table rather than rfswitch_dio_num[0] >= 0 (missed sparse
  pin lists), and track each emitted pin's original slot so row values
  line up correctly instead of shifting when a low slot is absent.
- config-dist.yaml: document the per-family TCXO/XTAL probe order
  (SX126x/LR20x0 TCXO-first, LR11x0 XTAL-first).
- Trim three overlong comments per the coding-guideline nitpicks.

Left the SX126x XTAL-retry-on-oscillator-failure nitpick alone -
RadioLib's begin() already does its own XOSC_START_ERR recovery
internally, and narrowing our wrapper's retry condition on top of that
needs hardware to verify it doesn't regress a real failure path.

Verified: bin/test-config-check.sh GREEN 69/69 against an isolated
native build; pio test -e native -f test_rtc PASSED.

* fix CI: cppcheck duplicateValueTernary, harden kRfSwitchModes init

LR11x0Interface::init(): work around cppcheck's duplicateValueTernary
on `TCXO_OPTIONAL_ENABLED ? 0 : tcxoVoltage` (both branches fold to 0
on a board with no ARCH_PORTDUINO, no TCXO_OPTIONAL, and no explicit
Vref, since tcxoVoltage already reduces to 0 via the same macro chain)
by splitting it into a plain assignment + if, rather than suppressing
the warning. The other TCXO_OPTIONAL_ENABLED ternaries in this PR
(LR11x0Interface.cpp:75, SX126xInterface.cpp:76, LR20x0Interface.cpp:
86,240) pick between TCXO_OPTIONAL_DEFAULT_VOLTAGE (1.6f) and 0, which
can never coincide, so they're unaffected and left as-is.

ConfigCheck.cpp: kRfSwitchModes was a namespace-scope global with
dynamic initialization (a lambda IIFE) reading kRfSwitchModeNames,
which is defined in a different translation unit (PortduinoGlue.cpp).
Currently safe only because kRfSwitchModeNames's initializer is
constant-expression-only (string literals + enum constants), which
the standard guarantees completes before any TU's dynamic
initializers - but that safety is silent and would break if
PortduinoGlue.cpp's array initializer ever stopped being a constant
expression, with nothing to warn a future editor. Converted to a
function-local static (Meyers' singleton), which is correct by
construction regardless of the other TU's initializer, updating all
4 call sites (definition + 3 uses) from kRfSwitchModes to
kRfSwitchModes().

Verified: pio test -e native -f test_radio PASSED; bin/test-config-
check.sh GREEN 69/69 against an isolated native build.

* refactor: simplify TCXO voltage handling across interfaces and improve comments

* fix rfswitch_table cross-file merge; drop now-stale checker warning

Three CodeRabbit findings on 09e0d390c, addressed together since #2
and #3 are the same root cause:

1. PortduinoGlue.cpp: require an exact "DIO<n>" match when parsing
   rfswitch_table.pins. sscanf's %d stops at the first non-digit, so
   "DIO5invalid" silently parsed as DIO5 at runtime even though
   ConfigCheck.cpp's static validator (exact match against
   kRfSwitchPins) already rejected it - checker and loader disagreed.

2. PortduinoGlue.cpp: reset all 5 pin slots and all 8 mode rows before
   applying a table, rather than only overwriting what the new table
   mentions. A later config.d file that omitted a mode a prior file
   had set (e.g. only redefining MODE_TX) let the earlier file's
   MODE_RX leak through, contradicting "last file wins" - the rule
   every other Lora: key already follows.

3. ConfigCheck.cpp: with #2 fixed, rfswitch_table behaves like any
   other cross-file key, so removed the special-cased ERROR in
   checkCrossFileOverlap ("These do NOT override each other... OR of
   every table") - it described the pre-fix OR-accumulation bug and
   is no longer accurate. Falls through to the generic "last file
   wins" INFO now. Renamed/repurposed the rfswitch-sticky fixture to
   rfswitch-last-wins and updated its assertion (was rc=1 asserting
   the old error text, now rc=0 asserting the generic info) and the
   fixtures README.

Verified: bin/test-config-check.sh GREEN 69/69 against an isolated
native build, including the renamed assertion.

* Assert the effective rfswitch table, not just the overlap diagnostic

The "last one wins" case checked that the cross-file info fires and that the
result is clean. Neither observes the table the loader actually ended up with,
so the merge bug ee9b5b81e fixed - a later table leaving an earlier file's pins
and mode rows as carryover - would still have passed it. Raised by CodeRabbit.

Asserting the value needs two things the existing case cannot supply.

The winner has to be deterministic. Both files in rfswitch-last-wins/ sit in
config.d/, which is walked with a bare directory_iterator and no sort, so which
one lands last is up to the filesystem - the point configd-conflict/ exists to
make, and the reason the checker warns rather than assuming alphabetical order.
rfswitch-replace/ puts the losing table in config.yaml instead, which is always
loaded before config.d/. The loser is the wider of the two, four pins and three
all-HIGH mode rows against the winner's two pins and one all-LOW row, so
carryover shows up as a surviving pin, a surviving mode row, or a HIGH that
should be LOW.

The table has to be observable. The check report says no more than "RF switch
table   : set", and check-yaml cannot help: it is --check --output-yaml, and
--check wins and exits before the dump - which the case just below it asserts.
emit_yaml() does serialise the effective table, so the assert helper grows a
yaml mode that passes --output-yaml alone.

Confirmed non-vacuous: with the reset loop in loadConfig() removed, the new
assertion fails and the old one still passes.

Config-check suite GREEN 70/70. Native suite GREEN 44/44, 968 cases.

* Say nothing about rows the radio will never read

Two of the RF-switch diagnostics judged a table against a family's mode
list without first asking whether the module reads a table at all.

modesFor() treated every module that was not an LR20x0 as LR11xx-like, so
an sx1262 carrying a table was told which of its rows were "not a mode
sx1262 has" and which modes it had omitted - alongside the correct warning
that the whole table is inert. pinsFor() already returned an empty set for
these parts and its caller already guarded on that; the mode path now
matches.

The missing-mode advice is dropped under "auto" as well. The module has
not been probed, so the union of both families is all there is to compare
against, and naming its absent modes would advise adding MODE_TX_HP,
MODE_GNSS and MODE_WIFI rows to what may turn out to be an LR20x0.

Fixtures for both silences, and an assert() needle prefixed with '!' to
hold them: a line that is merely absent today is otherwise nobody's
regression.

Also corrects the kLr11x0SwitchDios/kLr20x0SwitchDios comments. They
describe a slot mapping, but buildRfSwitchTable() searches them by value
to find the parallel pin constant - and with 7 DIOs against 5 YAML pin
slots, the LR20x0 array could not be positional.

* Warn when the two TCXO keys ask for opposite things

DIO3_TCXO_VOLTAGE written out as false or 0 asks for DIO3 to be left
alone, and stores identically to the key being absent - so TCXO_OPTIONAL
then probes DIO3 at the radio default anyway. Both keys behave exactly as
documented; only together are they wrong, which is what makes the outcome
surprising. loadConfig() now keeps the distinction that the store loses,
and --check reports the contradiction and which key to drop.

The flag is diagnostic only and is not serialized: an explicit false and
an absent key both round-trip as absent, as they did before.

Also fixes the Portduino TCXO log lines, which named the variant define
SX126X_DIO3_TCXO_VOLTAGE on a path where the knob is the YAML key, and
adds a TODO over the SX126x XTAL retry. RadioLib has autocorrected that
case itself since 7.5.0 - SX126x::modSetup() retries config() on the XTAL
when begin() fails with SPI_CMD_FAILED and XOSC_START_ERR - so the
ordinary case never reaches our retry and what does is mostly invalid
settings, logged as a TCXO fault.

* feat(portduino): bound Lora.IRQ_DIO_NUM, accept the older spelling, document both

An LR20x0 raises its interrupt on DIO5 through DIO11. Anything else was read
straight out of the YAML and programmed into RadioLib, where it routes the IRQ
nowhere: begin() touches only SPI and BUSY, so the radio reports init success
and then never receives a packet - the same failure the switch-pin collision
check already covers, reached by a typo instead.

Refuse it in loadConfig() and again in the driver before it reaches RadioLib,
warning both times. Because loadConfig() discards the value, the merged config
cannot tell a rejected number from an absent key, so --check judges the range in
its per-file pass where the offending line is still known.

LR2021_IRQ_DIO_NUM, the spelling carried by the two earlier LR2021 branches, is
read when IRQ_DIO_NUM is absent and reported as shadowed when it is not. Both
keys, the DIO range and the collision that makes the setting matter are now
described in config-dist.yaml.

* fix(portduino): a rejected IRQ_DIO_NUM returns to the radio default

loadConfig() runs once per file - the main config, then each file in
config.d/ - and they all write the same portduino_config. An out-of-range
Lora.IRQ_DIO_NUM warned that it was falling back to the radio default but
left any valid value an earlier file had set, so LR20x0Interface went on
programming that stale DIO.

Reset it to -1, the unset sentinel every other reader already tests for.

Pinned by a new fixture: DIO9 in the main config, out of range in
config.d/. The main config is always read first, so the ordering is
deterministic, unlike two files in config.d/ (see rfswitch-last-wins).
The assertion requires the summary to name the radio default and not DIO9;
with the reset removed and rebuilt, it is the only assertion that fails.
2026-09-16 10:44:06 +00:00

500 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
# Drive a built meshtasticd against the fixtures in test/fixtures/portduino-config
# and assert what it does with each one.
#
# Why this is a shell test and not a Unity suite: both behaviours under test are
# properties of the process, not of a function. `--check` is judged by its exit
# status and its printed report, and the "normal run rejects a bad config" path
# ends in exit(EXIT_FAILURE) inside portduinoSetup() - neither is reachable from
# a native unit test that links a single translation unit.
#
# Usage:
# ./bin/test-config-check.sh # auto-detect the binary
# ./bin/test-config-check.sh path/to/meshtasticd # explicit binary
# MESHTASTICD_BIN=... ./bin/test-config-check.sh
#
# Exit codes: 0 = GREEN, 1 = RED. The final line is machine-readable:
# RESULT: GREEN 42/42 assertions passed
# RESULT: RED 2 of 42 assertions failed
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
FIXTURES="$ROOT_DIR/test/fixtures/portduino-config"
BIN="${1:-${MESHTASTICD_BIN-}}"
if [[ -z $BIN ]]; then
for candidate in "$ROOT_DIR/.pio/build/native/meshtasticd" "$ROOT_DIR/.pio/build/coverage/meshtasticd"; do
[[ -x $candidate ]] && BIN="$candidate" && break
done
fi
if [[ -z $BIN || ! -x $BIN ]]; then
echo "RESULT: RED no meshtasticd binary (build one with 'pio run -e native', or pass a path)"
exit 1
fi
# Each case runs from a directory of its own choosing, so a relative path (CI passes
# .pio/build/coverage/meshtasticd) has to be resolved before the first cd.
BIN="$(cd "$(dirname "$BIN")" && pwd)/$(basename "$BIN")"
# Note: `pio test -e native` writes its Unity test program to this same path, so after a test
# run the auto-detected binary is that program rather than the application. That turns out to be
# harmless here -- portduino's main() parses argv and runs portduinoSetup(), which prints the
# report and exit()s before setup() (Unity's entry point) is ever reached -- so every case below
# behaves identically either way. It stops being true only for a case that lets meshtasticd run
# past portduinoSetup(), which this suite deliberately never does: a config it accepts boots a
# node and blocks.
# The coverage env links ASan/LSan. Every case here ends in exit(), so LSan would
# report the still-reachable config objects and turn a passing case into a non-zero
# exit that has nothing to do with what is being asserted.
export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=0}"
# Keep the virtual filesystem out of the user's home, and run from a directory with
# no config.yaml in it so the fixture we name is the only config in play.
WORKDIR="$(mktemp -d -t meshcheck.XXXXXX)"
trap 'rm -rf "$WORKDIR"' EXIT
PASS=0
FAIL=0
# assert <description> <expected-exit> <fixture> <mode> [expected-substring...]
# mode: "check" adds --check, "yaml" adds --output-yaml, "check-yaml" adds both
# (to assert that --check wins), "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.
# Each expected substring must appear in the output. One prefixed with '!' must not:
# an info line that is merely absent today would otherwise be nobody's regression.
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) ;;
yaml) args+=(--output-yaml) ;;
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
if [[ $needle == '!'* ]]; then
grep -qF -- "${needle#!}" <<<"$out" && problems+=("unexpected: ${needle#!}")
else
grep -qF -- "$needle" <<<"$out" || problems+=("missing: $needle")
fi
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 "switch pin the module does not have" 1 rfswitch-bad-pin.yaml check \
"'DIO9' is not an RF switch pin on lr1121" \
"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"
# Under autodetect the part is not known yet, so neither is which modes it has. Naming them
# against the union of both families would advise adding rows an LR20x0 cannot use.
assert "omitted modes are not guessed at under auto" 0 rfswitch-auto-partial.yaml check \
'!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 radio that never applies one" 0 module-mismatch-sx126x.yaml check \
"the table is only applied to LR11xx and LR20x0 radios" \
"Result: 0 errors, 1 warning"
# ...and that is the only thing worth saying. Judging the rows against a family's mode list
# would report MODE_RX_HF as the ignored one, implying the others are applied.
assert "an inert table is not judged row by row" 0 rfswitch-inert-table.yaml check \
"the table is only applied to LR11xx and LR20x0 radios" \
'!is not a mode' \
'!default to all pins LOW' \
"Result: 0 errors, 1 warning"
echo
echo "LR20x0 switch table and interrupt DIO:"
# MODE_RX_HF is a real mode on this part, so it must not be rejected as an unknown key.
assert "a correct LR20x0 table is accepted" 0 rfswitch-lr2021.yaml check \
"Module : lr2021" \
"RF switch table : set" \
"IRQ DIO : DIO9" \
"Result: 0 errors, 0 warnings"
# begin() needs only SPI and BUSY, so the radio reports init success either way.
assert "IRQ DIO collides with a switch pin" 1 rfswitch-lr2021-irq-collision.yaml check \
"Lora.IRQ_DIO_NUM is DIO5, which Lora.rfswitch_table.pins also drives" \
"Result: 1 error, 0 warnings"
# The same collision, reached by omitting the key: the radio default is DIO5.
assert "default IRQ DIO collides with a switch pin" 1 rfswitch-lr2021-irq-default.yaml check \
"no Lora.IRQ_DIO_NUM is set" \
"raises its interrupt on DIO5 by default" \
"IRQ DIO : DIO5 (radio default)" \
"Result: 1 error, 0 warnings"
# DIO5 is the radio's own default, so a check keyed on the DIO number rather than the pins
# list would fire on most working configs.
assert "IRQ on DIO5 with the table elsewhere is clean" 0 rfswitch-lr2021-irq-clear.yaml check \
"IRQ DIO : DIO5" \
"RF switch table : set" \
"Result: 0 errors, 0 warnings"
# Listing the pin is what breaks it, not driving it: setRfSwitchTable() reassigns the DIO
# function for every pin in the list whatever the levels say.
assert "IRQ pin listed but never driven HIGH" 1 rfswitch-lr2021-irq-all-low.yaml check \
"Lora.IRQ_DIO_NUM is DIO5, which Lora.rfswitch_table.pins also drives" \
"Result: 1 error, 0 warnings"
# Out of range is discarded at load, so the merged view sees an absent key: only the per-file
# pass still knows a value was written at all.
assert "IRQ DIO outside DIO5-DIO11" 1 rfswitch-lr2021-irq-out-of-range.yaml check \
"Lora.IRQ_DIO_NUM is 3, outside DIO5-DIO11" \
"IRQ DIO : DIO5 (radio default)" \
"Result: 1 error, 0 warnings"
# A rejected override must not leave the earlier file's value in place: the warning promises the
# radio default, so the merged view has to show it.
assert "out-of-range override drops back to the radio default" 1 irq-stale-override/config.yaml check \
"Lora.IRQ_DIO_NUM is 3, outside DIO5-DIO11" \
"IRQ DIO : DIO5 (radio default)" \
"!IRQ DIO : DIO9"
# The older spelling is accepted, but never over the generic key.
assert "LR2021_IRQ_DIO_NUM is shadowed by IRQ_DIO_NUM" 0 rfswitch-lr2021-irq-alias.yaml check \
"Lora.LR2021_IRQ_DIO_NUM is the older spelling" \
"IRQ DIO : DIO9" \
"Result: 0 errors, 1 warning"
# The collision check is gated on there being a table, so only the missing table is reported.
assert "LR20x0 without a table cannot transmit" 0 rfswitch-lr2021-no-table.yaml check \
"Module is lr2021 but no Lora.rfswitch_table is set" \
"RF switch table : not set" \
"Result: 0 errors, 1 warning"
# A mode belonging to the other family is a dropped row, not a typo.
assert "modes the LR20x0 does not have" 0 rfswitch-lr2021-wrong-mode.yaml check \
"Lora.rfswitch_table.MODE_TX_HP is not a mode lr2021 has" \
"Lora.rfswitch_table.MODE_GNSS is not a mode lr2021 has" \
"Result: 0 errors, 2 warnings"
echo
echo "TCXO probing (Lora.TCXO_OPTIONAL):"
# With no explicit Vref the TCXO attempt uses the radio default rather than being skipped,
# otherwise there would be nothing to fall back from.
assert "probe with no Vref names the radio default" 0 tcxo-optional.yaml check \
"TCXO probe : yes, 1600 mV (radio default) and XTAL" \
"Result: 0 errors, 0 warnings"
# The same flag on another family, with an explicit Vref that must be the one reported.
assert "probe on an SX126x uses the given Vref" 0 tcxo-optional-sx1262.yaml check \
"Module : sx1262" \
"TCXO probe : yes, 1800 mV and XTAL" \
"Result: 0 errors, 0 warnings"
# On a part with no TCXO reference the key is read, stored and inert.
assert "probe on a radio with no TCXO does nothing" 0 tcxo-optional-unsupported.yaml check \
"Lora.TCXO_OPTIONAL is set but Module is sx1280" \
"no TCXO reference to probe for" \
"Result: 0 errors, 1 warning"
# A written-out false stores the same as an absent key, so the probe drives DIO3 regardless
# of what the user asked for. Both keys behave as documented; only together are they wrong.
assert "DIO3_TCXO_VOLTAGE off contradicts the probe" 0 tcxo-optional-contradiction.yaml check \
"Lora.DIO3_TCXO_VOLTAGE is off, which asks for DIO3 not to be driven" \
"The probe wins" \
"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,"
# rfswitch_table follows "last file wins" like every other Lora: key: no special-cased
# error, just the standard cross-file-overlap info.
assert "rfswitch tables across files: last one wins" 0 rfswitch-last-wins/config.yaml check \
"'Lora.rfswitch_table' is set in 2 files" \
"The file loaded last wins" \
"Result: 0 errors,"
# The case above proves the diagnostic fires; it cannot prove the table was replaced rather than
# merged, because which of two config.d/ files wins is up to the filesystem. This fixture puts the
# loser in config.yaml, which is always loaded first, so the effective table is deterministic and
# can be asserted by value. --output-yaml is the only output that reports it: the check report
# says no more than "set".
assert "a later rfswitch table replaces the earlier one" 0 rfswitch-replace/config.yaml yaml \
"pins: [DIO5, DIO6]" \
"MODE_RX: [LOW, LOW]"
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