Files
firmware/bin/test-config-check.sh
Jonathan BennettandClaude Opus 5.5 dbc7daecc6 feat(portduino): Adafruit Arcade Bonnet input driver (#12116)
* feat(portduino): Adafruit Arcade Bonnet input driver

Add an InputBroker source for the Adafruit Arcade Bonnet: six buttons
(1A-1F) and a 4-way joystick on an MCP23017 I2C expander.

The expander is polled over I2C every 20 ms rather than serviced from its
interrupt line, because the bonnet ties INT to GPIO17, which is the HUB75
clock on the adafruit-hat mapping. The driver sets INT to open-drain and
enables no interrupt sources so the line never drives against the panel.

Buttons emit once per press and carry a gamepad button code in kbchar
(1E = BTN_SELECT, 1F = BTN_START, so Start pauses games). The joystick
emits directions with the same auto-repeat as LinuxJoystick. The analog
stick bits are ignored; they float with no stick attached.

Config:
  Input:
    ArcadeBonnet: 0x26
    ArcadeBonnetButtons:   # optional
      select: [1A, 1F]
      cancel: 1B

--check validates both keys. The action-name parser moves from
LinuxJoystick.cpp to InputBroker.h so both drivers share it.

Breakout now polls the bonnet joystick's held direction the same way it
polls the USB gamepad, so the paddle glides instead of stepping at the
auto-repeat rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vaqwGPoD64pqsnPYbvnUn

* fix(portduino): Arcade Bonnet buttons act in games; address review

1F reported BTN_START, which GamesModule treats as pause, so a bonnet
button bound to select paused Tetris and Breakout instead of dropping or
serving. 1E/1F now report BTN_C/BTN_Z; no bonnet button is Start.

From review:
- Clear the held joystick zones when an I2C read fails, so Breakout's
  paddle doesn't keep moving after the bonnet stops answering.
- Set Input.ArcadeBonnet only when the key is present, so a later
  config.d file with its own Input section doesn't disable the bonnet.
- Reject addresses outside the MCP23017's 0x20-0x27 in the driver and
  in --check, instead of truncating them to a uint8_t.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vaqwGPoD64pqsnPYbvnUn

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:36:20 +00:00

555 lines
25 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"
# On a build without BLE support the same file additionally reports the build-time
# gap as a warning, so only the error count is asserted here.
assert "bluetooth section parses" 0 bluetooth.yaml check \
"Result: 0 errors,"
assert "bluetooth bad Enabled is silently defaulted" 0 bluetooth-bad-enabled.yaml check \
"Bluetooth.Enabled is not a true/false value" \
"Result: 0 errors, 1 warning"
assert "bluetooth AdapterId must name a real adapter" 0 bluetooth-bad-adapter.yaml check \
"is not a BlueZ adapter name" \
"Result: 0 errors, 1 warning"
echo
echo "admin keys in the host config:"
assert "a list of admin keys is clean" 0 adminkeys.yaml check \
"Result: 0 errors, 0 warnings"
assert "a malformed admin key is refused" 1 adminkeys-bad.yaml check \
"is not a 32-byte base64 public key" \
"Result: 1 error, 0 warnings"
assert "admin keys written as a scalar are refused" 1 adminkeys-not-a-list.yaml check \
"Security.AdminKeys must be a list of base64 public keys" \
"Result: 1 error, 0 warnings"
# Admin keys append rather than the last file winning, so one file per admin works. config.yaml is
# always loaded first, so the effective list is deterministic; --output-yaml is what reports it.
assert "admin keys append across config.d" 0 adminkeys-append/config.yaml yaml \
"rvzaPCZmlh1nWnqNKUFHj0dLBJJIRJhRrkZmaFLHCVY=" \
"NGC+MSAeaf7aoO7ouZl/XHwpmf2v5ZMlPNZUr0361xQ=" \
"P2jeuoXA1sOGSPH9Vm3eHFlITu1EVnohWGKlGF3t8GU="
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 "joystick buttons:"
# Keyed by action rather than by button so that one action can list several codes.
# The clean case is the regression guard on the list form staying accepted.
assert "several buttons bound to one action" 0 joystick-buttons.yaml check \
"Result: 0 errors, 0 warnings"
assert "joystick mappings that do nothing" 0 joystick-buttons-bad.yaml check \
"'fire' is not a recognised action" \
"'BTN_SOUTH' is not an evdev button code" \
"button 0x121 is mapped to both 'select' and 'cancel'" \
"Result: 0 errors, 3 warnings"
echo
echo "arcade bonnet buttons:"
# Same shape as JoystickButtons, with the bonnet's printed button names instead of evdev codes.
assert "bonnet buttons bound by name" 0 arcade-bonnet.yaml check \
"Result: 0 errors, 0 warnings"
assert "bonnet mappings that do nothing" 0 arcade-bonnet-bad.yaml check \
"Input.ArcadeBonnet '0x120' is not an MCP23017 address (0x20-0x27)" \
"'fire' is not a recognised action" \
"'2A' is not a bonnet button (1A-1F)" \
"button 1B is mapped to both 'cancel' and 'back'" \
"Result: 0 errors, 4 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 "a malformed admin key is refused on a normal boot" 1 adminkeys-bad.yaml normal \
"is not a 32-byte base64 public key"
assert "admin keys as a scalar are refused on a normal boot" 1 adminkeys-not-a-list.yaml normal \
"Security.AdminKeys must be a list of base64 public keys"
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