* fix(games): correct the high-score announcement argument order GAMES_HIGH_SCORE_STRING is "New %s high score %lu by %s!" but the arguments were passed as (name, initials, score): the initials string was formatted through %lu and the score integer through %s. That is a format/argument mismatch, so the announcement printed garbage at best and dereferenced the score as a pointer at worst. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(input): report which physical gamepad button produced an event A joystick event only carried the action it was mapped to, so a consumer could not tell two buttons apart once they shared one action, and games were limited to the handful of actions the broker defines. Carry the originating evdev button code in InputEvent::kbchar, encoded into a reserved 0xC0..0xDF range that misses printable ASCII and every INPUT_BROKER_MSG_ value (SystemCommands switches on kbchar without looking at inputEvent, so a collision there would reboot the node rather than move a paddle). D-pad events are axes, not buttons, and keep leaving kbchar at 0 -- which is exactly what lets a consumer tell stick from button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(portduino): let one joystick action bind several buttons Input.JoystickButtons took a single evdev code per action, so a pad's A and Y could not both select, and the shoulder buttons could not sit alongside the D-pad. Accept a list of codes as well as a bare scalar; the config writer inverts its code->action map back out, emitting a list only where an action has more than one button. ConfigCheck gains a real checker for the section (it was previously waved through as free-form) covering the three ways a mapping silently does nothing: an action name the driver does not know, an evdev name where the numeric code belongs, and one code claimed by two actions. Two fixtures and shell-test cases cover the clean list form and those three faults. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(games): use the gamepad's extra buttons, and return home when idle Games now receive the physical button alongside the action, so a pad with more than two usable buttons controls more than two things: - Snake: a shoulder button mapped to left/right turns relative to the snake's heading (L counter-clockwise, R clockwise) while the D-pad keeps steering absolutely. The two are told apart by kbchar, not by hardcoding one pad's codes. - Breakout: the ball now rides the paddle after each serve until the player fires it with B or A, so a life is not lost to a ball already in flight when the player looks up. The paddle also keeps its position between lives. A game can claim BACK for the duration (Game::wantsBackButton) so B serves instead of pausing, and releases it once the ball is live. - Start (BTN_BASE4 / BTN_START) is mapped to select like any other button, so it launches games and drives the menus; inside a running game GamesModule picks it out of kbchar and pauses instead. Separately, the games frame no longer holds a walked-away device hostage: after 15 s with no input it returns to the home frame, so the device still reads as a Meshtastic node. The timer is suspended while a picker or banner is up (e.g. high-score initials entry, which the input handler never sees) so it cannot yank the user out mid-entry. Screen::isInteractionBusy() generalises the old module-intercept check -- modal module, intercepting module, game, or an open interactive overlay -- and MessageRenderer uses it before popping an incoming-message banner. A transient banner REPLACES an active overlay, so an arriving message could otherwise discard a half-entered high score. The message is still stored, its thread still selected, and the unread indicator still set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(ui): compose freetext on the on-screen keyboard from a gamepad A gamepad can drive the on-screen keyboard but cannot type, so on a host with a joystick and no configured keyboard device the OSK is the only way to compose freetext. Set osk_found there, and gate the "Freetext" menu entries on whether the device can enter text at all (physical keyboard, OSK, or touchscreen virtual keyboard) rather than on kb_found alone -- those entries were hidden on exactly the devices that needed them. The OSK prompt that CannedMessageModule already had inline in the message selector becomes showOnScreenKeyboard(), so the menu path can reach it too. Menus call in from a banner callback and the banner is torn down as soon as that callback returns, which would take the keyboard down with it, so the menu path defers the launch to runOnce(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(games): address review on frame fallback and joystick input gating Breakout: the paddle suppression was far too broad. aLinuxJoystick is constructed on every Linux host whether or not a gamepad is configured (InputBroker.cpp), so `aLinuxJoystick && kbchar == 0` was true everywhere and swallowed LEFT/RIGHT from the keyboard, trackball and ExpressLRS -- on a host with no joystick attached at all. Gate on the stick actually driving the paddle instead: LinuxJoystick assigns heldX before it emits and only auto-repeats while heldX is set, so every axis LEFT/RIGHT arrives with a zone held and nothing else does. kbchar == 0 still distinguishes an axis from a shoulder button mapped to left/right, which must keep nudging the paddle. Screen: showHomeFrame() did nothing when the home frame was hidden, since setFrames() only assigns positions.home for !hiddenFrames.home. That stranded the games inactivity bounce on the frame it was trying to leave. Fall back to the messages frame, which setFrames() always adds. Test: rename test_ballWaitsOnPaddleUntilLaunched to test_ball_waitsOnPaddleUntilLaunched, matching the repo convention and its neighbours in the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(games): give games the whole InputEvent so Breakout can identify the source Follow-up to review on #11917. The previous narrowing still could not tell sources apart: kbchar == 0 is shared by the joystick's D-pad axis and by every other driver that sends a bare LEFT/RIGHT, so while the D-pad was held a keyboard or touchscreen press was still discarded. heldXZone() proves the axis is driving, not that this particular event came from it. Pass the event itself to Game::handleInput() rather than (ev, kbchar). Games that only care about the action read event->inputEvent; Snake keeps using kbchar for shoulder steering; Breakout now also checks event->source against LinuxJoystick's origin name, so only that driver's own axis repeats are suppressed. Chose the event over a third positional parameter so the signature does not have to grow again the next time a game needs something the event already carries. All three conditions in Breakout are load-bearing: source says it came from this gamepad, kbchar == 0 says it is the axis rather than a shoulder button mapped to left/right, and heldXZone() != 0 says the axis is what is driving right now so tick() already has it covered. LinuxJoystick::originName() exposes the name the driver stamps into InputEvent::source, alongside the existing heldXZone()/heldYZone() accessors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 a real pin name but not a switch pin on an lr1121. |
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-auto-partial.yaml |
Silence guard - under auto the omitted modes are not named. |
rfswitch-inert-table.yaml |
Silence guard - an sx1262's rows are not judged individually. |
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.
Both silence guards exist because a mode list is only meaningful once the radio is known.
Under Module: auto the part has not been probed, so naming the omitted modes against the
union of both families would advise adding MODE_TX_HP, MODE_GNSS and MODE_WIFI rows to
what may turn out to be an LR20x0. On a module that applies no table at all, singling out one
row as ignored would imply the others are used, when the single module-mismatch-sx126x.yaml
warning already says the whole table is inert.
LR20x0 rfswitch table and interrupt DIO
The table is handed to an LR20x0 as well as an LR11xx, and the two parts have neither the same modes nor the same switch pins, so which findings are correct depends on the module.
| File | Expected |
|---|---|
rfswitch-lr2021.yaml |
Clean. MODE_RX_HF is a real mode here, and IRQ_DIO_NUM keeps the IRQ clear. |
rfswitch-lr2021-irq-collision.yaml |
IRQ_DIO_NUM: 5 names a pin the table also drives as a switch line. |
rfswitch-lr2021-irq-default.yaml |
The same collision reached by omitting the key: the radio default is DIO5. |
rfswitch-lr2021-irq-clear.yaml |
False-positive guard - DIO5 as the IRQ, table on DIO6/7/8, is clean. |
rfswitch-lr2021-irq-all-low.yaml |
DIO5 listed in pins but driven LOW everywhere: still a collision. |
rfswitch-lr2021-irq-out-of-range.yaml |
IRQ_DIO_NUM: 3 is outside DIO5-DIO11, so it is discarded and DIO5 is used. |
rfswitch-lr2021-irq-alias.yaml |
LR2021_IRQ_DIO_NUM alongside IRQ_DIO_NUM: the older spelling does nothing. |
rfswitch-lr2021-no-table.yaml |
An LR20x0 with no table cannot transmit, same as an LR11xx without one. |
rfswitch-lr2021-wrong-mode.yaml |
MODE_TX_HP and MODE_GNSS are LR11xx modes an LR20x0 does not have. |
begin() needs only SPI and BUSY, so a radio whose interrupt lands on a switch pin still
reports init success and then never receives a packet.
An out-of-range IRQ_DIO_NUM is refused twice, in loadConfig() and again in the driver before
it reaches RadioLib, so the checker judges it per file: by the time the merged config is built a
rejected value and an absent key look the same.
Why -irq-all-low is a fault and -irq-clear is not: LR2021::config() (from begin())
points the IRQ DIO at FUNCTION_IRQ, then setRfSwitchTable() calls setDioFunction(..., FUNCTION_RF_SWITCH) for every non-NC pin in the list whatever the levels are, and nothing
re-asserts the IRQ function afterwards. Listing the pin is what breaks it, not driving it.
rfswitch-lr2021.yaml also carries LR2021_MAX_POWER and LR2021_MAX_POWER_HF in a case
that must stay at zero warnings, so dropping either from the checker's schema fails it.
TCXO probing (Lora.TCXO_OPTIONAL)
A variant declares "a TCXO may or may not be fitted" at compile time with TCXO_OPTIONAL.
A Portduino carrier cannot: the same meshtasticd binary runs on hardware populated either
way, so the statement arrives as YAML and is answered at runtime.
| File | Expected |
|---|---|
tcxo-optional.yaml |
Clean. No Vref given, so the TCXO attempt uses the 1.6 V radio default. |
tcxo-optional-sx1262.yaml |
Clean. Another family, explicit Vref, which is the one reported. |
tcxo-optional-unsupported.yaml |
An SX128x has no TCXO reference to probe for, so the key is inert. |
tcxo-optional-contradiction.yaml |
DIO3_TCXO_VOLTAGE: false asks for DIO3 to be left alone; the probe drives it anyway. |
With the probe asked for and no voltage given, the driver tries the radio default rather than skipping the TCXO attempt - otherwise there is nothing to fall back from.
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. |
Joystick buttons
Input.JoystickButtons is keyed by action, not by button, so an action can name a
single evdev code or a list of them and every one of those buttons drives it.
| File | Expected |
|---|---|
joystick-buttons.yaml |
Clean, and a regression guard - four actions, two of them with several codes. |
joystick-buttons-bad.yaml |
Three silent no-ops: an action name nothing reads, an evdev name where a code belongs, one code claimed twice. |
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-last-wins/ covers Lora.rfswitch_table across two config.d/ files. It
follows the same "last file loaded wins" rule as every other Lora: key - the loader
resets a table's pins and mode rows before applying a replacement, so an earlier
file's MODE_RX setting cannot leak through a later file that omits it. --check
reports this as the standard cross-file-overlap info, not a special-cased error.
rfswitch-replace/ pins the replacement itself rather than the diagnostic. Which of
two config.d/ files wins is up to the filesystem, so the fixture above cannot assert
the effective table by value; here the losing table sits in config.yaml, which is
always loaded before config.d/, and the winner is therefore deterministic. The loser
is the wider of the two - four pins and three mode rows, all HIGH - so any carryover
appears as a surviving pin, a surviving mode row, or a HIGH that should be LOW.
--output-yaml is what reports it: the --check report says no more than set.
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.