Files
meshtastic_firmware/test/fixtures/portduino-config
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
..
2026-07-30 11:06:24 +00:00
2026-07-30 11:06:24 +00:00

Portduino config fixtures

Input files for bin/test-config-check.sh, which drives a built meshtasticd binary and asserts what meshtasticd --check reports about each one. Every file here is referenced by name from that script, so renaming one means editing it too.

The theme is configuration that is accepted by the YAML parser but does not mean what it looks like it means - the failures that otherwise only show up as a radio that never transmits.

Each file carries a comment header naming its planted fault and what the checker is expected to say about it, so a fixture can be read on its own. One assertion, malformed-indent.yaml's reported line number, counts those header lines - editing that file's comments means updating the expected line in the script.

These files are exempt from trunk fmt (see .trunk/trunk.yaml): prettier rejects the duplicate keys and bad indentation that are the entire point of them.

Valid, one per radio module family

These must each report zero errors and zero warnings, and must resolve to the module named in the file. A silent fallback to sim would otherwise pass.

File Module Family
module-rf95.yaml RF95 SX127x
module-sx1262.yaml sx1262 SX126x
module-sx1268.yaml sx1268 SX126x
module-llcc68.yaml LLCC68 SX126x
module-sx1280.yaml sx1280 SX128x
module-lr1110.yaml lr1110 LR11xx
module-lr1120.yaml lr1120 LR11xx
module-lr1121.yaml lr1121 LR11xx
module-sim.yaml sim simulated
module-auto.yaml auto autodetect

valid.yaml is a minimal SX126x config, and empty-sections.yaml (Lora: with no body) exists to prove the checker does not invent a finding for it.

Module naming

Names are matched exactly and are not consistently cased - RF95 and LLCC68 are upper, sx1262 and lr1121 lower.

File Expected
module-unknown.yaml sx1263 is refused, and the valid set is listed.
module-wrong-case.yaml llcc68 is refused with a "did you mean" for case.

LR11xx rfswitch table

File Expected
rfswitch-valid.yaml A full seven-mode table on an lr1121 is clean.
rfswitch-partial.yaml Legal, but the omitted modes are named - they are driven all-LOW.
rfswitch-bad-pin.yaml DIO9 is not one of DIO5/6/7/8/10.
rfswitch-row-length.yaml Rows shorter and longer than the declared pin count.
rfswitch-bad-level.yaml high and On: anything not exactly HIGH is silently LOW.
rfswitch-no-pins.yaml No pins list, so no switch pin is ever driven.
rfswitch-too-many-pins.yaml Six pins declared; only the first five are read.
rfswitch-not-a-map.yaml rfswitch_table given a scalar.
rfswitch-unknown-mode.yaml MODE_TRANSMIT is not a mode.
rfswitch-stranded-modes.yaml A MODE_ row one level out, sitting under Lora: doing nothing.

rfswitch-partial.yaml is legal but noted: the omitted modes are driven all-LOW. module-mismatch-lr11xx.yaml (LR11xx with no table - cannot transmit) and module-mismatch-sx126x.yaml (a table on a radio that never applies one) cover the module/table disagreement in both directions.

PA gain table (TX_GAIN_LORA)

Two shapes are accepted and they fail differently. A list is read element-by-element with .as<int>() and NO fallback, so one bad entry throws and meshtasticd will not start. A bare scalar is read as .as<int>(0) and merely falls back to 0. The table is uint16_t[22], so extra points are dropped and out-of-range values wrap.

File Expected
txgain-scalar.yaml Clean, and a regression guard - an earlier checker called this fatal.
value-type-fatal-list.yaml A non-numeric list entry: throws, so meshtasticd will not start.
txgain-out-of-range.yaml -5 and 70000 wrap to a different gain than written.
txgain-too-many.yaml 25 points; everything past the 22nd is dropped.

Value types, ranges and units

File Expected
value-type-fatal.yaml Logging.AsciiLogs is the other no-fallback read: a bad value stops meshtasticd starting.
value-type-silent.yaml Wrong-typed values where the read has a default: silently replaced, so the setting does nothing.
tcxo-millivolts.yaml DIO3_TCXO_VOLTAGE is in VOLTS and multiplied by 1000, so 1800 silently asks for 1800V. Write 1.8.
port-out-of-range.yaml APIPort outside 1024-65535 is silently ignored; Webserver.Port has no guard at all.
statusmessage-long.yaml Copied into a char[80], so it is safe but silently shortened to 79 characters.
configdir-missing.yaml Crash regression guard - an unreadable ConfigDirectory used to abort meshtasticd (and --check) with SIGABRT via an uncaught filesystem_error.

MAC address

The MAC no longer determines NodeNum - that comes from the public key - but a MAC that fails to apply still falls through to the BlueZ and LoRa-serial fallbacks, and if those yield nothing meshtasticd exits with "Blank MAC Address not allowed!".

File Expected
mac-conflict.yaml Both MACAddress and MACAddressSource; meshtasticd refuses.
mac-malformed.yaml AA:BB:CC is under 12 hex digits, so it is silently dropped.
mac-source-missing.yaml Names an interface with no /sys/class/net/<n>/address. Warning, not an error: it is machine-dependent and may be checked on another host.

CH341 USB-SPI adapters

spidev: ch341 is a different hardware model, not a variant of the same one. The Lora pins become indexes on the adapter and are driven by the usermode USB driver - portduinoSetup() skips initGPIOPin() for every one of them - so nothing is claimed from a gpiochip. This is also the only shape that works on Windows and macOS, which have no gpiochip, gpiodetect or gpioinfo to check anything against.

File Expected
usb-ch341.yaml Clean, and the report lists adapter pins rather than resolved gpiochip lines.
ch341-gpiochip.yaml A gpiochip and line mapping alongside ch341: read, stored, and never used.

Structure

File Expected
duplicate-key.yaml yaml-cpp keeps the FIRST duplicate, so the later value is lost.
nonmap-section.yaml Lora: invalid - a known section whose body cannot be read.
unknown-section.yaml A top-level section meshtasticd never reads.
stranded-key.yaml spidev left at the top level instead of inside a section.
top-level-list.yaml Document root is a sequence.
empty-file.yaml No content: comments only, which parse to a null document. A warning, not an error.
malformed-indent.yaml Will not parse; the report must still name the file and line.
pin-unknown-subkey.yaml A pin mapping accepts only pin, gpiochip and line.
pin-unreadable.yaml A non-numeric pin resolves to -1 and trips an assertion at startup.
hub75-unknown-key.yaml An unknown Display.HUB75 option. On a build without rgbmatrix this also reports the missing HUB75 support, so the test asserts only the unknown key.

Across a config directory

configd-conflict/ is a whole tree: a config.yaml pointing at a config.d/ holding two more Lora: sections. It covers the trap that a key not repeated in the last-loaded file is reset to its default - here config.yaml sets DIO3_TCXO_VOLTAGE: 1800 and the effective configuration ends up without it. The load order within config.d/ comes from the filesystem, so the report warns rather than assuming alphabetical order.

rfswitch-sticky/ covers the one place where "the file loaded last wins" is false, and it documents a firmware bug rather than a configuration mistake. Its config.d/ holds two switch tables; the last one loaded sets MODE_RX LOW on both pins, but the loader only ever writes HIGH and never writes LOW back, so the HIGH from the earlier file survives and the effective table is the OR of both. Verified with meshtasticd --output-yaml. Until the loader is fixed, --check reports this as an error and tells you to enable exactly one.

Running these as a normal boot

malformed-indent.yaml, nonmap-section.yaml, module-unknown.yaml, mac-conflict.yaml and hub75-unknown-key.yaml are also run without --check, where each must be rejected with a non-zero exit. That is the guard on --check mode not having quietly made the normal path permissive. No other fixture is run that way: a config meshtasticd accepts makes it boot a node and block.