Files
meshtastic_firmware/bin/test-config-check.sh
T
21e3a583bd Yaml check for Meshtasticd (#11224)
* feat(portduino): add `meshtasticd --check` config validator

Users hand-writing files in /etc/meshtasticd/config.d/ get no feedback when a
key is misplaced, misspelled or duplicated: meshtasticd silently ignores what
it does not read, so a broken config looks identical to a working one.

Add a --check mode that loads the configuration exactly as startup does, then
reports what it found and exits:

- Duplicate keys, via the yaml-cpp Parser/EventHandler stream. The Node API
  cannot see them because the map is already collapsed by the time it exists,
  and yaml-cpp keeps the FIRST occurrence, so a later override is discarded.
- Unknown or misnested keys, against a schema mirroring what loadConfig()
  reads, with a hint naming the section a stray key actually belongs to.
- rfswitch_table validation: unrecognised pins, mode rows whose length does not
  match the pin list, values that are not HIGH/LOW, and unknown modes.
- Cross-file overlap: every .yaml in the config directory merges into one
  portduino_config, so the file loaded LAST wins, the opposite of the
  within-file rule. Those files are read in filesystem order, not alphabetical.
- A warning when more than one file defines a Lora section: spidev, spiSpeed,
  gpiochip, DIO2_AS_RF_SWITCH, DIO3_TCXO_VOLTAGE and USB_PID/VID/Serialnum are
  assigned unconditionally with a default every time one is seen, so any of
  them not repeated in the last file loaded is silently reset.
- The resolved gpiochip/line for each pin, since a line that exists on the
  wrong chip is claimed successfully and then silently does nothing.

Exits non-zero when errors were found so it can also gate CI over
bin/config.d/**, keeping one implementation rather than a second schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(portduino): flag pins that resolve to -1 in --check

A pin key whose value will not convert to a number falls back to RADIOLIB_NC
(-1) while still being marked enabled, and initGPIOPin() then trips an
assertion inside LinuxGPIOPin rather than failing cleanly. YAML indentation
makes this easy to hit by accident: a stray line under "CS: 8" folds into the
value as a multi-line scalar, so the file parses, the daemon crashes with a
stack trace from a library file, and --check reported "Configuration looks
good" while printing "pin -1" two lines above.

Report it as an error naming the likely cause instead.

Also correct a comment claiming unparseable config.d files are skipped
silently. They are not: loadConfig() prints "*** Exception ..." with the line
and column. It is the discarded return value, not the diagnostic, that makes
the file's absence from the merged config easy to miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(portduino): cover `meshtasticd --check` with fixtures and a fuzz suite

Adds the tests the config validator was missing, and the checks and fixes that
writing them turned up. The theme throughout is configuration that the YAML
parser accepts but that does not mean what it looks like it means.

Tests
-----

bin/test-config-check.sh - 57 assertions driving a built meshtasticd against
test/fixtures/portduino-config (50 fixtures plus two config.d trees). A shell
test rather than a Unity suite because both behaviours under test are properties
of the process: --check is judged by its exit status and printed report, and the
"a normal run rejects a bad config" path ends in exit() inside portduinoSetup(),
neither of which is reachable from a suite that links one translation unit.
Every fixture carries a comment header naming its planted fault and the expected
finding, so it can be read on its own. Coverage:

  * a clean config for each of the ten radio module families (RF95, sx1262,
    sx1268, LLCC68, sx1280, lr1110, lr1120, lr1121, sim, auto), asserted both
    findings-free and resolving to that module, so a silent fallback to sim
    cannot pass
  * LR11xx rfswitch tables: unrecognised pins, rows longer and shorter than the
    pin count, levels that are not exactly HIGH, a missing pins list, more than
    five pins, a scalar table, unknown MODE_ keys, a MODE_ row stranded one
    level out, and a legal partial table
  * the PA gain table in both accepted shapes, entries outside the uint16 range
    it is stored in, and more than the 22 points that are kept
  * values of the wrong type, split by consequence: the two settings read with
    no fallback stop meshtasticd starting, everything else is silently replaced
    by its default
  * out-of-range and unit mistakes: TCXO voltage written in millivolts, ports
    outside their usable range, an over-long StatusMessage
  * MAC sources: both keys set at once, a malformed address, an interface that
    does not exist
  * structural faults: duplicate keys, non-mapping and unknown sections, a key
    left at the top level, a sequence at the document root, an empty file,
    unreadable pins, unparseable YAML
  * cross-file behaviour over a config.d directory, including the switch tables
    that do not override each other
  * five configs run WITHOUT --check, each of which must still be refused, so
    check mode cannot quietly make the normal path permissive

test/test_fuzz_config - adversarial fuzzing of the checker itself, the "the tool
meant to diagnose your config crashes on it" failure mode. Scope is deliberately
narrow: yaml-cpp does the parsing and is fuzzed upstream, so what is exercised
here is our code above the parse, above all the duplicate-key detector, which is
the one hand-rolled piece and walks the raw parser event stream with its own
stack. Groups: the checked-in fixtures as a seed corpus, 3000 byte mutations of
them (flips, truncation, insertion, splicing, deletion), and structural torture
(nesting to 4096 in flow and block style, duplicate keys at depth, anchors,
aliases and merge keys, 64KB keys, 256KB scalars, multi-document files). A
fourth group of random bytes is present but disabled behind
FUZZ_CONFIG_RANDOM_BYTES: it was half the runtime for the least return, since
uniform noise is rejected on the first token. The contract is crash-freedom and
termination under AddressSanitizer, not any particular finding.

CI runs the shell test in the existing native simulator job; the fuzz suite is
picked up by the existing ^test_fuzz_ area rule. native-suite-count 40 -> 41.
The fixtures are exempt from trunk in .trunk/trunk.yaml, since prettier rejects
the duplicate keys and bad indentation that are the point of them.

Checker fixes found while writing the tests
-------------------------------------------

--check reported a clean exit 0 on configs meshtasticd then refuses to boot, the
worst failure a diagnostic tool can have. Four hard exits inside loadConfig()
killed the report before it printed: an unparseable file, an unknown Lora.Module,
MACAddress and MACAddressSource both set, and HUB75 on a build without it. All
are now reported as findings, and all are still refused on a normal run.

New validation: Lora.Module against the accepted spellings, which are matched
exactly and inconsistently cased, with a suggestion when only case differs; a
per-key value type table covering ~85 keys, tested by asking yaml-cpp to perform
the same conversion loadConfig() will so it cannot drift; the PA gain table;
DIO3_TCXO_VOLTAGE, which is in volts and multiplied by 1000, so the millivolt
value everything else uses silently asks for 1800V; APIPort and Webserver.Port
ranges; MaxNodes; StatusMessage truncation; MAC address and source; and an
unreadable ConfigDirectory.

Also fixes a crash: a ConfigDirectory that cannot be read threw an uncaught
filesystem_error from directory_iterator and aborted meshtasticd with SIGABRT,
taking --check down with it. It now fails cleanly.

Two smaller ones: cppcheck's uselessCallsSubstr on the ancestor walk, which was
failing every check job; and the duplicate-key detector's stack pop, which was
unguarded and relied on yaml-cpp emitting balanced events.

Switch tables are the one place "the file loaded last wins" is false. The loader
only ever writes HIGH and never writes LOW back, so a HIGH from an earlier file
survives a later file that clears it and the radio drives the OR of every table
loaded. Confirmed with --output-yaml. Reported as an error for now; the loader
itself is left alone, as that changes RF behaviour.

* fix(portduino): report CH341 pins as adapter indexes, not gpiochip lines

--check printed "Resolved GPIO lines (what meshtasticd will try to claim)" for
every config, listing a gpiochip and line for each Lora pin and advising they be
confirmed against gpiodetect and gpioinfo. For spidev: ch341 every part of that
is false. portduinoSetup() skips initGPIOPin() for every Lora pin when spidev is
ch341 and hands the raw numbers to Ch341Hal, so nothing is claimed from a
gpiochip -- and on Windows and macOS, where a USB adapter is the only way to
attach a radio, there is no gpiochip, gpiodetect or gpioinfo to check against in
the first place. The checker had no ch341 coverage at all: not one fixture used
it, so the whole USB-SPI path went unexercised.

The summary now splits on the transport. A ch341 device gets its pins listed as
adapter indexes with the gpiod advice dropped, and a gpiochip or line mapping
written alongside it is reported: those are read, stored, and never used.

Also: "RF switch table: not set" read as a gap on an SX126x, where there is
nothing to set. setRfSwitchTable() is only ever called for an LR11xx, so absence
is now "not needed for this module" everywhere else, and "not resolved yet" for
auto, which has no module to judge against.

Fixtures: usb-ch341.yaml (clean, the meshstick shape) and ch341-gpiochip.yaml.

CI fix
------

test-native was RED on "config.d overrides are reported", which wanted 2
warnings and got 1. The fixture's two config.d files name different modules, so
which one wins -- and whether the LR11xx-without-a-switch-table warning fires --
depends on the order the filesystem returns them in. That is the very thing the
fixture exists to demonstrate, so the count is no longer asserted; the report's
own order caveat is asserted instead.

Review fixes
------------

The unreadable-ConfigDirectory diagnostic was the one new print in
PortduinoGlue.cpp not gated behind !configCheck, so it landed ahead of the report
header and broke the clean output the rest of the change is careful to keep.

Docs: rfswitch-valid.yaml carries seven modes, not eight, and empty-file.yaml is
comments-only rather than zero bytes.

* style(portduino): trim --check comment blocks and reconcile suite count

Condense the multi-paragraph comment blocks in the --check validator to the
one-to-two-line convention, and bump test/native-suite-count to 42 for the
test_fuzz_config suite added here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:06:24 +00:00

392 lines
16 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, "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