Commit Graph
12783 Commits
Author SHA1 Message Date
Thomas Göttgens eec87f8a6d feat(variants): add seeed-m2100 for the SenseCAP S2100 data logger 2026-10-03 21:22:53 +02:00
Thomas Göttgens e45b76355f feat(serial): tunnel the client API over the Modbus bus 2026-10-03 21:22:53 +02:00
Thomas Göttgens 39d8703362 feat(serial): add a Modbus-RTU master mode that sends SenseCAP ONE readings as telemetry 2026-10-03 21:22:53 +02:00
Thomas Göttgens 327a595114 fix(serial): send WS85 telemetry with the environment telemetry cadence and priority 2026-10-03 21:22:53 +02:00
github-actions[bot]andcaveman99 3fdc613d7b Update protobufs (#12065)
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
2026-10-03 21:22:22 +02:00
Jonathan BennettandClaude Opus 5 c7826791d4 feat(portduino): admin keys in config.yaml, beyond the protobuf's three (#12056)
* feat(portduino): admin keys in config.yaml, beyond the protobuf's three

meshtasticd can now list the public keys authorized to administer the node
in its own config, as Security.AdminKeys, and list more than the three
config.security.admin_key holds. The host's file is the authority: it is
root-owned, so unlike the persisted protobuf an authorized remote cannot
rewrite it, and applyHostKeys() re-asserts it at boot and after every
remote write of the security config. A key removed from the file loses
access on the next boot instead of lingering in flash.

The protobuf array stays at three. It is not an arbitrary cap: a security
set_config already encodes to ~207 bytes against the 221 a PKC admin
packet can carry (233 payload bytes less 12 of PKC overhead), so a fourth
key would not fit in one admin message on any platform.

Four sites decided who may administer a node, each unrolling
admin_key[0..2] by hand - the PKC gate in AdminModule, the same gate in
DMShell, the is_managed guard, and the admin-key trial decrypt in
Router::perhapsDecode. They now share AdminKeys::isAuthorized(), so a key
source reaches all four at once, and enumeration skips empty slots rather
than trusting admin_key_count.

Each configured key costs one trial decryption per PKI packet from an
unknown sender, so the host list is capped at 16 and duplicates are
dropped. Files in config.d append to the list, one per operator. A key
that is not exactly 32 canonical base64 bytes stops meshtasticd rather
than silently authorizing something else, and --check reports it with its
line instead.

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

* test(portduino): config-check fixtures for Security.AdminKeys

bin/test-config-check.sh drives a built meshtasticd against the fixtures,
which is the only place the two process-level behaviours here are
reachable: what --check reports, and that a normal boot refuses the file.

A clean two-key list, a key three characters short of 32 bytes, and one
key written as a scalar instead of a list - the last two as errors under
--check and as a non-zero exit on a normal boot, since a truncated admin
key would authorize something other than what was written and a file
whose only key is unreadable would otherwise boot a node nobody can
administer. adminkeys-append/ pins the append-across-config.d rule by
value through --output-yaml, with the first key in config.yaml so the
effective list does not depend on the filesystem's order.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 03:19:01 +00:00
James RichandJonathan Bennett 36201c8edb DMShell: give the shell TERM, a UTF-8 locale and a clean environment (#12054)
* DMShell: give the shell TERM=xterm-256color

The shell inherited meshtasticd's environment, which usually has no TERM
(systemd, containers) or the TERM of whatever terminal started the daemon.
Without one, clear prints "unknown terminal type", ctrl+L only feeds a
line, and pagers fall back to a dumb terminal. The environment is built
before forkpty and passed to execle, since the child of a threaded
process may only make async-signal-safe calls until exec.

* DMShell: give the shell a UTF-8 locale, and drop systemd's service variables

The shell's TERM is right now, but a systemd unit usually has no LANG either,
so the shell still landed in the C locale: ncurses drew its boxes in ASCII and
ls escaped any UTF-8 filename, with a correct terminal underneath. LANG is now
replaced the way TERM is, with C.UTF-8. The LC_* overrides are stripped along
with it, since an LC_ALL or a stale LC_CTYPE out of the daemon's environment
would win over LANG and leave the locale half-set. C.UTF-8 is built into glibc
and musl is UTF-8 throughout, so no locale has to be generated for it; where it
is absent anyway, setlocale() falls back to C, which is where the shell already
was.

The explicit environment is also the place to stop handing the shell systemd's
own service plumbing: NOTIFY_SOCKET, INVOCATION_ID, JOURNAL_STREAM and the
LISTEN_* socket-activation triple. NOTIFY_SOCKET is the one that does
something - a command run in the shell can answer sd_notify() on meshtasticd's
behalf and tell systemd the service is reloading or stopping. That is not an
escalation, since the session is already a root shell on the host, but none of
it describes the shell's own world.

The filter is a prefix table rather than a single strncmp, so all three
concerns read the same way.

* DMShell: keep the environment comments to a line each

The rationale for each stripped variable and for C.UTF-8 is in the
commit that added them.

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-10-02 22:03:58 +00:00
Jonathan BennettandClaude Opus 5 8b8dc611c7 feat(portduino): add a test-menu action to power off the host (#11920)
A portduino node is a process on a computer that outlives it. shutdown() ends
at exit(EXIT_SUCCESS), so "Shutdown" only stops the daemon -- and the shipped
unit sets Restart=always, which makes it a restart in practice. There is no way
from the node's UI to halt the machine it runs on.

Add "Power Off Host" to the hidden test menu, behind its own confirmation. It is
kept separate from shutdownMenu() because the two mean different things: one
ends the node, the other ends the machine, and on a headless node nothing brings
the host back.

Confirming raises hostPowerOffRequested and then injects the ordinary
INPUT_BROKER_SHUTDOWN, so the normal sequence still runs and the NodeDB, message
store and waypoints are saved exactly as for any other shutdown. Only at the
portduino arm does it hand off to logind, falling through to exit() if that is
refused rather than claiming to shut down and sitting there.

The call is org.freedesktop.login1.Manager.PowerOff with interactive=false: the
daemon has no session and no way to answer a polkit prompt, so asking for one
would hang until timeout. It opens its own bus connection rather than reusing
the BLE backend's, which may already be torn down by then.

meshtasticd runs unprivileged with no session, so logind's default of
auth_admin_keep refuses it. bin/polkit-1/meshtasticd.rules grants the two
power-off actions to that one user, shipped from both the deb and the rpm
alongside the existing D-Bus policy. It deliberately does not grant
power-off-ignore-inhibit, so a package upgrade or a held lock still blocks the
halt; power-off-multiple-sessions is granted because logind escalates to it
whenever anyone is logged in, which on a node someone has SSH'd into is the
normal case.

HAS_HOST_POWEROFF gets its own __has_include test rather than reusing
HAS_BLUETOOTH: the two share a transport, not a purpose, and a build with
Bluetooth off should still be able to halt the box.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 17:54:37 +00:00
Jason P 0143980db2 BaseUI: merge number/hex/alphanumeric pickers into one renderer (#12031) 2026-10-02 14:24:05 +00:00
github-actions[bot]andvidplace7 a57453d4c0 Upgrade trunk (#11924)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-10-02 11:59:23 +00:00
renovate[bot] 80fe752102 Update meshtastic/device-ui digest to 57c7bc6 (#11982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-02 10:45:16 +00:00
Jonathan BennettandClaude 80ea7f17fe fix(portduino): rate-limit the BLE setup retry (#12043)
Five PowerFSM state-entry handlers reach setBluetoothEnable(true), and the
retry added for a bluetoothd that is still starting at boot runs setup() on
each one. Every attempt opens a system bus connection, spawns an event-loop
thread and enumerates every BlueZ object before failing, so a host where BLE
can never come up - no bluetoothd, a missing adapter, a policy denial - paid
that plus a log line on every transition, indefinitely.

Gate it on a 30s stamp. The boot race still resolves within one interval, and
a permanently broken host now probes twice a minute instead of continuously.

The stamp goes through Time::stampMillis() and is read with
Throttle::hasElapsed(), with 0 tested separately, so a first attempt is never
mistaken for one that has just run.


Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 04:31:05 +00:00
Jonathan Bennett e1bcaba802 Update libch341-spi-userspace URL in portduino.ini (#12042) 2026-10-02 04:14:42 +00:00
977b1d775c Dmshell (#11976)
* Very WIP dmshell

* Dumb fixes

* Interactive mode

* Try to re-request missed sequences

* troubleshoot dropped packets

* Don't firehose missing packets

* Attempt at better responsiveness

* DMShell heartbeat

* dmshell client serial support and tweaks

* Remove some dead code and LLM overcomplication

* Trunk

* Use RemoteShell in protobufs

* Make the DMShell tests compile

* Harden against possible memory overflows

* Minor cleanups

* No Child Left Behind

* simplify pt 1

* Refactor and Simplify

* Make new protobuf value consistent

* dmshell reliability tweaks

* Add real Lock handling on Portduino

* talking stick

* latest test changes

* Guard the DMShell TX-queue check against a null RadioLibInterface

runOnce() dereferenced RadioLibInterface::instance unconditionally. It is
null on any node whose radio is not RadioLib-backed - meshtasticd with
Lora.Module sim, and the serialHal path - so the first runOnce after a
session went active segfaulted the process.

* Revert the DMShell half-duplex turn-taking ("talking stick") scheme

Before: a DMShell session gated every transmission on a token. The server
only sent shell output while it held the turn; with no token it emitted an
RTS and waited (rate-limited to one per 250 ms) for the client to grant one.
Each grant, yield and re-grant was its own LoRa frame, so an unprompted burst
of shell output cost a full RTS -> grant round trip before the first byte
moved, and a long burst paid another yield/grant pair every 8 frames. On a
link where a single frame already takes a noticeable fraction of a second,
that turned interactive use into seconds of latency per exchange.

After: both ends transmit as soon as they have something to send, which is
how the module behaved before the scheme landed. The flags field stays in the
wire format but is written as 0 and ignored, so nothing in the protobuf
changes and a peer still running the turn-taking build is simply not answered
in kind.

The scheme was introduced in f25e3e893 and refined in 11a14bdda. Neither
commit is reverted wholesale, because both carry unrelated work that stays:

- 11a14bdda's NodeDB.h fix, narrowing the pre-hop drop policy to reject only
  HopStartStatus::INVALID, is untouched.
- The client's hop_limit fix (default 3 plus a --hop-limit flag) and its
  packet.decoded.bitfield assignment are re-applied on top of the revert;
  they address the far-end pre-hop drop, not channel access.
- 5e2d6d225's null guard on RadioLibInterface::instance is re-applied.

Removed with the scheme: the TURN_FLAG_GRANT/MORE/RTS constants and the
TURN_BUDGET_FRAMES, RTS_RETRY_MS and TURN_LINGER_MS tunables; the per-session
turnManaged, hasToken, lastRtsMs, turnDeadlineMs and turnFramesSent state;
applyTurnFlags, ptyHasOutput, serviceTurn, sendOutputFrame, sendTurnGrant and
sendRts; and on the client the TxIntent queue, the sender thread that owned
the token, and the --no-turn-taking, --turn-budget and --turn-reclaim flags.
Nothing else referenced any of it - no test covered the scheme.

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

* DMShell: stop an unfillable gap becoming an endless retry storm

A lost frame could put a DMShell session into a retransmission loop that never
terminated, and the faster the modem preset the sooner it happened. Three causes,
all in the recovery path:

Both ends keep a replay history bounded at 50 *frames*, so the wall-clock span it
covers scales with the preset - about two minutes on LongFast, under seven seconds
on ShortTurbo - while the client's retry timers are fixed in seconds. Once a
requested frame aged out, resendFramesFrom() logged "not found in history" and
returned, telling the peer nothing. Neither side advances past a hole, so the peer
asked again every second until the five-minute idle timeout. Report the eviction
and close the session instead: the request is unanswerable, and saying so turns a
dead session into a diagnosable one.

Every frame arriving above a gap produced its own replay request, and each request
cost the sender a duplicate replay, so one loss cost as many extra transmissions
as the sender had frames in flight. Damp the requests per sequence number, over an
interval derived from getPacketTime() rather than a constant, since one frame's
airtime spans 100 msec on ShortTurbo and over 2 sec on LongFast. A different gap
is still reported immediately, so recovery gains no round trip. The client backs
its own retry off exponentially to 30 sec for the same reason.

A teardown could sit behind a gap: CLOSE and CLOSED both went through the ordering
check, so a session ending because a gap could not be filled was itself buffered
behind that gap. Both are now acted on out of band. ERROR deliberately stays
ordered - it is not always terminal, and skipping its sequence number would open a
gap of its own.

Also drops a sendAck() whose replay field underflowed to 0xffffffff when called
with no argument, which the peer read as a request for a sequence number that had
never been sent.

The sequence and history bookkeeping moves to DMShellRecovery.h, which takes the
time as an argument and pulls in no platform headers, so test_dmshell_recovery
covers it - including the damping boundaries and the millis() wrap.

DMSHELL_LEGACY_RECOVERY=1 on the node, paired with --legacy-recovery on the
client, restores the previous behaviour on the same build so a session can be
measured with and without the fix.

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

* DMShell: act on the first hardware measurements of the retry storm

A two-radio bench run on US/ShortTurbo (473 frames one-way, plus a short
bidirectional run) measured what the previous commit was guessing at, and
three things follow from it.

The client could not start at all. config.proto imports field_metadata.proto,
which was missing from required_protos; protoc resolves it via -I for
compilation but only emits a _pb2 module for files named on the command line,
so config_pb2 imported a module that was never written. Named it.

The missing-sequence backoff was tuned for the wrong failure. It doubled to a
30 s cap on the theory that each repeat was evidence the peer could not fill
the gap. On hardware every repeat was a collision on a gap that did fill: 8 of
8 recovered, worst case 5 attempts, zero unfillable. Doubling from the first
repeat made those recover 2-3x slower, which is plainly visible in an
interactive shell. Hold the base interval for the first four attempts and only
grow after that, with an 8 s ceiling - the ceiling only has to bound the
residue now that an evicted frame is reported in one round trip.

Eviction turns out to be symmetric, so the client needs the same report the
firmware got. prune_sent_frames would have made the client's ring hold only
unacked frames, but its one call site is commented out, so tx_history is a flat
last-50-sent ring exactly like txHistory and can be outrun the same way. When
the peer asks for a frame the client no longer holds, close the session and log
replay_evicted instead of logging replay_unavailable and leaving the peer asking
until the idle timeout. --legacy-recovery keeps the old silent behaviour for the
A/B.

Also document the contiguity invariant DMShellTxHistoryWindow derives its
retained range from, at the one choke point every remembered frame passes
through.

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

* DMShell: bound unacknowledged output instead of the replay ring

Hardware runs at both ends of the preset range showed the replay-eviction
failure is ratio-specific, not preset-specific: whichever side has the higher
frame rate outruns its own ring first. On ShortTurbo the server sends ~3
frames/s against a keystroke every 1.5 s, so the server's ring goes; on
LongFast the client sends many small frames against few large ones, so the
client's goes, and a LongFast run logged 106 unanswerable replay requests with
zero firmware evictions. Enlarging or time-bounding a ring on one side would
therefore have moved the failure to the other side rather than fixing it.

Bound the outstanding data instead. The server tracks the peer's cumulative
receive cursor and stops generating new output once four frames are
unacknowledged, so it can never get more than a window ahead of a gap and can
always still answer the replay request for it. Ring size stops mattering, at
any preset, on both sides.

This also removes the zombie session without touching the idle timer. The
timer could never fire because the server refreshed it with its own output
while the send path was unbounded; a sender blocked on a closed window stops
sending, so it stops refreshing, and the existing five-minute timeout finally
applies to a peer that has vanished. The self-refresh was only a bug in the
absence of a bound.

The window needs the peer to acknowledge, and on a one-way stream the client
transmitted nothing at all - its heartbeat is suppressed while inbound traffic
keeps arriving - so it now sends a bare ack every second in-order frame. The
ack interval must not exceed the server's window or the two deadlock until the
heartbeat breaks it; both are configurable so the pair can be swept, and
DMSHELL_TX_WINDOW=0 or legacy mode restores the unbounded behaviour for the
A/B on one build.

Liveness and the flow-control cursor now come from every authenticated
in-session frame rather than only those passing the ordering gate. An
out-of-order frame still proves the peer is alive and still carries a valid
cursor, and during a gap it may be the only kind arriving.

Also raise the OPEN_OK wait onto its own --open-timeout. A lost OPEN costs a
full frame airtime, ~2.2 s on LongFast against ~0.1 s on ShortTurbo, and a
measured LongFast session needed 150 s to open against a 10 s default.

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

* DMShell: log window open/close transitions

The window opens and closes several times a second, so per-poll logging would
drown the log, but with no line at all there is no way to tell from a run
whether the bound ever engaged - which is the first thing to check on the next
hardware run. Log only the transitions, with the outstanding count.

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

* DMShell: retransmit from the sender when the window latches shut

The window stopped the retry storms and cut the frames needed per delivered
line by 60x, but it introduced a silent permanent stall, reproduced on
hardware in both runs at around 20 s: the window closes one last time and
never reopens, while frames keep arriving from the peer and the session stays
open looking healthy.

The cause is that recovery was entirely receiver-driven, and the window
removed the stimulus it depended on. A lost frame freezes the peer's
cumulative cursor at the last in-order sequence number. The sender then fills
its window against that frozen cursor and stops. From there nothing can make
progress: the peer only asks for a replay when a frame arrives carrying a
higher sequence number, and with the window shut none ever does. The peer's
own INPUT frames carry the frozen cursor, and neither the INPUT path nor
anything else on the ordered path triggers a replay - only ACK and PING do.

So the sender now takes responsibility for its own unacknowledged data, which
is the "no sender-side retransmit timer" gap named in the original analysis.
While the window is shut it retransmits the frame the peer's cursor says is
missing, one round trip apart, derived from the modem config the way replay
requests already are. It resends the missing frame rather than something new,
because sending something new would push the sender further ahead of the gap,
which is what the window exists to prevent.

The client had a second, independent hole in the same path. Its heartbeat is
what makes the server retransmit via the PING handler, but heartbeat_due keyed
off traffic in either direction, so the client's own keystrokes reset it -
typing faster than HEARTBEAT_IDLE_DELAY_SEC suppressed the heartbeat
completely. Measured: zero heartbeats in 60 s while typing every 1.5 s into a
stalled session, against four when idle. An interactive user was silencing
their own recovery. It now keys off inbound silence alone, so a healthy stream
still sends none.

Either change alone breaks the deadlock; both are correct independently.

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

* DMShell: stop retransmitting to a peer that has gone away

Bounding the outstanding data left one unbounded behaviour behind. A sender
whose window is shut repeats the frame the peer's cursor says is missing, and
nothing ever ends that: it stays under the replay ring so nothing is evicted,
and the idle timeout keeps being refreshed while the peer sends anything at
all. On the bench the session became a zombie twice, repeating one sequence
number 88 and 89 times and still going 67 s after the client had exited,
because the client's teardown did not survive the link.

A healthy link on the same rig needed at most 11 repeats of a single sequence
number, so the allowance is 25 per sequence number, reset whenever the peer's
cursor advances. It is a frame count rather than a duration because the
retransmission interval already derives from the modem config, so it scales
with the preset by itself - about 11 s on ShortTurbo, 114 s on LongFast. When
it runs out the session closes as peer_unresponsive: the evidence says the
peer vanished rather than that the link failed. DMSHELL_MAX_RETRANSMITS=0
restores the old behaviour so one build can still be measured both ways.

Also flush the PTY's pending output when a peer's input carries the terminal's
interrupt character. A local line discipline does this for free; over LoRa the
~15 KiB the PTY holds is about 77 frames, 17 s of airtime on ShortTurbo and
nearly three minutes on LongFast, all of it output the user has just
cancelled. The flush happens before the interrupt is written through, so the
shell's own response to it survives.

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

* DMShell: bound unacknowledged input as well as output

The send window bounded the server's output and left the client's input
unbounded, so only half the original failure was fixed. Measured on hardware:
at ShortTurbo the server outran its own replay ring and the window fixed it,
but at LongFast - where keystrokes are emitted far faster than the server
drains them - the client outran its ring instead and the session still died
with replay_evicted seq=5 while its own ring had moved on to 12. Whichever end
has the higher frame rate loses, so both ends need the bound.

The client now tracks the server's cumulative receive cursor from the ack_seq
and last_rx_seq that already ride on every inbound frame, holds typed input
locally once four frames are unacknowledged, and sends it in order as the
cursor advances. Overflowing the local hold is reported rather than silent,
because the bytes are the user's.

Bounding the sender re-creates the latch it created on the server: with the
window full the server never sees a sequence number above the gap, so it never
asks for the replay that would reopen us, and its only trigger is an arriving
frame. So the client also repeats the frame the cursor says is missing while
the window is shut, escalating the interval the way a replay re-ask does - it
cannot read the modem config the way the firmware can - and gives up after 25
repeats, matching the firmware's bound.

For that to work the server has to acknowledge input even when it has no
output: a keystroke a program swallows would otherwise leave the client's
window full against a silent peer. So the server sends a bare ACK carrying its
cursor after two in-order frames with nothing of its own to send, and answers
a repeated frame with one directly. A replay request carries the same cursor
and counts as that answer; a damped gap stays silent, which is what the
damping is for. Frames the server originates already carry the live cursor, so
a busy stream sends no extra frames at all.

Input sends are serialised across the input and heartbeat threads: both flush
the held bytes, and two takes interleaving with two sends would put the user's
keystrokes on the wire out of order.

DMSHELL_INPUT_WINDOW sizes the client bound and 0 disables it, so one build can
still be measured both ways; --legacy-recovery disables it too.

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

* DMShell client: fix the window-transition counts

The client's input-window transition logging was misleading. Both lines lived
inside the flush loop, which only runs while typed bytes are queued, so a
window that reopened with an empty queue logged nothing and a run ended up with
a close that had no matching reopen. That reads as a latch and is not one. The
transitions are now recorded on every service tick, so the counts describe the
window rather than the typing. The retransmit line also carries the attempt
number and the current interval, which is what separates a link recovering
slowly from one not recovering at all.

(The client half of 8384c66 on the bench branch; its radio-side scan logging
stays out of the DMShell branch.)

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* DMShell: name the op and seq on an inbound frame

"Received a DMShell message" could not answer the question that matters when a
gap will not close: whether the peer's replay arrived and was discarded, or
never arrived. It is logged before the session and ordering checks, so a frame
rejected by either still shows up, which is what makes the two cases separable
from one log stream.

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

* DMShell: hold out-of-order frames instead of discarding them

The receiver had no reorder buffer at all. A frame arriving above a gap was
dropped even though it arrived intact, so the peer had to send it again once the
gap filled - a round trip per frame that was already in hand. Measured at
LongFast: the sequence numbers the receiver kept asking for had arrived a dozen
times each, and the session advanced roughly one frame per round trip while
delivering 24 lines in 300 s. Read as loss, it looked like a 5% link; it was
frames arriving and being thrown away.

Frames above the gap are now held, eight slots deep - twice the peer's default
send window, so a well-behaved peer can never fill it and an unbounded one is
bounded here - and delivered in order the moment the gap fills. When the buffer
is full the highest sequence number is the one dropped, because the gap fills
from below and the lowest are needed soonest.

Two things fall out of it. The op handling is now applySessionFrame(), reached
from the radio and from the buffer alike. And a replay request is suppressed for
a sequence number already held: the ordering window deals only in sequence
numbers it has passed to the session, so it cannot know what the buffer has, and
asking for a frame we are holding is pure channel load.

Simulated against the discarding version across loss rates from 5% to 40%, this
roughly halves the transmissions needed per frame delivered - 2.1 to 1.1 at 10%
loss - with the larger saving in wall-clock time, since each frame no longer
costs its own round trip.

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

* DMShell client: send with hop_limit 0, like the firmware already does

A non-zero hop limit on our frames arms two mechanisms in the peer's
firmware that this protocol wants nothing from, and both cost airtime in
the direction that matters.

NextHopRouter::sendWithNextHop starts its own relay retransmissions for a
packet whose gate it passes - (hop_limit > 0 || want_ack) with a known
next hop - so the sending node repeats each of our frames up to twice
more, then floods the last attempt and resets the learned next hop. Those
repeats reuse the packet id, so the destination discards them in
shouldFilterReceived as dupes after paying for them on the air; they
never reach DMShell, which is why they were invisible in per-sequence
arrival counts.

To stop them, ReliableRouter::sniffReceived answers every frame whose
next_hop names it with a 0-hop routing ACK. That is one extra
transmission per input frame, from the peer, competing on a half-duplex
channel with the shell output it is trying to send us.

The firmware end of the session already sends with hop_limit 0, so
DMShell is a direct-neighbour protocol in both directions either way, and
the asymmetry bought nothing. hop_start 0 survives the receiver's pre-hop
drop policy because classifyHopStart accepts it when the Data bitfield is
present, and perhapsEncode sets that on every locally-originated packet -
which is what makes the firmware's own hop_limit 0 frames arrive here.

--hop-limit 3 restores the old behaviour for a same-binary comparison.

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

* DMShell client: time input retransmissions off the measured round trip

The input retransmission reused the replay re-ask escalation: a flat 1 s
for four attempts, then doubling to an 8 s ceiling. One second is shorter
than a single frame's time on air at LongFast (~2.2 s), so a repeat went
out while the frame it was repeating was still being transmitted, and
another behind that. Worse, the deadline was armed by the poll that
noticed the frame rather than by the frame itself, and advancing the
peer's cursor cleared it - so the moment one frame was acknowledged, the
next one, possibly sent milliseconds earlier, was repeated on the
following poll. Measured at LongFast over 300 s: 117 retransmissions and
105 arrivals for 22 distinct sequence numbers, against 17 replay requests
from the peer. The duplication was almost entirely ours.

Two changes, both of which need only what this side can observe:

A frame is never repeated before it has been outstanding for a full
interval. SentShellFrame now carries the time it was last handed to the
API, and the first deadline for a sequence number is measured from that
rather than from the tick that noticed it.

The interval itself comes from the link instead of a constant. Every time
the peer's cumulative cursor passes a frame we sent, the time from
sending it to seeing that is folded into a smoothed estimate, and the
interval is twice the estimate, floored at the old 1 s and capped at the
old 8 s. This side cannot see the modem preset, so measuring beats
deriving, and the OPEN/OPEN_OK exchange supplies the first sample before
any keystroke is sent. The replay re-ask uses the same base, for the same
reason: it was asking again before an answer could arrive.

The run bound is untouched: still exactly MAX_INPUT_RETRANSMITS repeats
of one sequence number before the session is declared unrecoverable, and
a frame that has aged out of the ring still fires immediately.

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

* DMShell client: report the round trip the interval is derived from

The retransmission interval is now measured rather than constant, so a run
has to be able to show what it measured. Reporting it only when a
retransmission happens would describe the traffic instead of the
mechanism - the same mistake that made an earlier input-window diagnostic
read as a latch - so the estimate is reported from the poll, which runs
whether or not anything is queued.

The first sample goes to the transcript as well as the replay log, since
it is what tells the operator whether the interval matches the preset in
use, and it lands before any keystroke is sent. After that it is reported
again only once it has moved by a quarter, which on a settled link is
never. The retransmission line names the estimate alongside the interval.

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

* Lock: make the Portduino mutex implementation conditional on Portduino

Two independent build breaks, both from de6269c6 "Add real Lock handling on
Portduino", and both invisible here because every build on this branch has
been `-e native`.

Before: `-e rak_wismesh_pocket` fails at `src/concurrency/Lock.cpp:4` with
`logging.h: No such file or directory`. That header belongs to the Portduino
framework and the include was unconditional, so it broke every target that
is not Portduino: the whole nRF52 family, ESP32 and RP2040 (which take the
FreeRTOS branch and never needed it) and STM32WL. Nothing else in the tree
includes it, and nothing in this file needs it - `LOG_INFO`, `LOG_WARN` and
`console` all arrive through configuration.h, which pulls in
DebugConfiguration.h and SerialConsole.h.

Removing it exposes the second break, which is the real one. The pthread
implementation sat behind a bare `#else`, so it applied to every target
without FreeRTOS - and that set is not "Portduino", it is "Portduino plus
STM32WL". STM32WL builds on arm-none-eabi newlib, which has no pthread.h at
all, so `pthread_mutex_t mutex;` in the header fails to name a type for every
translation unit that includes Lock.h. Before de6269c6 that branch was
no-ops, so nothing outside Portduino ever relied on real locking there.

After: the split is three-way. FreeRTOS keeps its semaphore, Portduino gets
the pthread mutex, and anything else falls back to the upstream no-ops. The
header also includes pthread.h itself on the Portduino path instead of
relying on Arduino.h to have pulled it in.

Verified by compiling Lock.cpp three ways against stand-ins for
freertosinc.h and configuration.h: the FreeRTOS path, the Portduino path,
and a target with neither, the last one with an include path that makes
pthread.h a hard error so it stands in for newlib. All three build clean
under -Wall -Wextra. The same harness reproduces both original failures
exactly. This is not a substitute for CI, which is still the first real
build of the affected targets.

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

* Lock: stop logging on every mutex acquisition on Portduino

Before: `Lock::lock()` emitted `LOG_WARN("Lock")` on every successful
acquisition, and `LOG_INFO("Attempt to lock an already locked Lock!")`
whenever the mutex was already held. There are ~300 lock sites in the tree,
and they include the hottest paths on the device: spiLock around every SPI
transaction, cryptLock around every encrypt and decrypt, and deferredLock on
every received packet. So each of those paid a formatted write to the console
before doing its work.

That matters beyond noise on this branch specifically, because the open
question here is radio-thread latency - a false preamble is abandoned after
~8 ms at ShortTurbo, and one CH341 SPI transaction was already measured at
4-8 ms. Logging inside the lock that serialises those transactions puts I/O
on exactly the path being measured, so it has to go before the next round's
numbers mean anything.

The already-locked line also could not do what it claimed. `locked` records
that the mutex is held by somebody, so an ordinary contention between two
threads - the normal case for spiLock - triggers it just as readily as a
thread re-entering its own lock. It logged before blocking, too, so on a
genuine recursive lock it printed and then hung.

After: the Portduino implementation locks and unlocks, and says nothing. The
`locked` field goes with the lines that read it; nothing else used it.

Recursive-lock detection would be worth having, but it needs the owning
thread recorded and compared against the caller, not a bare held flag, and
that is a deliberate change rather than something to slip into a build fix.
Reverting this commit restores the old lines without touching the build fix
underneath it.

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

* dmshell client: add --peer-log to capture the node's own log

Measuring the radio's own timing on a board whose logs do not reach a serial
console meant choosing between running the session and capturing the log,
because both want the single USB port.  There was no need to choose: the log
already rides the stream the client is reading.  RedirectablePrint's log path
calls log_to_serial for every line, SerialConsole::log_to_serial hands it to
StreamAPI::emitLogRecord, and that writes it as FromRadio.log_record on the same
stream the client takes packets from.  The client was parsing those frames and
discarding them.

--peer-log <file> writes them out instead, one line per record carrying the
local clock, the node's own clock, the level, the thread and the message.  The
local timestamp is what lines a capture up against the client transcript and
against the other node's log.  Records are dropped, as before, when the option
is absent.

The node only emits them when config.security.debug_log_api_enabled is set,
which defaults off; the help text says so.  The write path is called from the
reader loop and swallows OSError, since a full disk should cost the log rather
than the session, and it holds its own lock because a trace-level node produces
thousands of lines a minute.  Log records deliberately do not refresh the
inbound-activity clock: they come from the local node, and letting them stand in
for peer traffic would suppress the heartbeat that drives recovery.

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

* DMShell client: report a session's traffic when it ends

Every measurement round so far has counted dropped and resent frames by
grepping a session log by hand, and in round 7 that produced a median over two
differently-selected populations because three different log lines carry a
last-look figure and only two of them belong to the same deadline. Counting in
the client removes that class of error from the measurement.

--stats prints a summary to stderr when the session ends; --stats-json writes
the same object to a file, which is what an A/B actually needs. Both run from
the same finally block that closes the peer log, so a session that timed out or
was interrupted still reports - that is the run whose numbers matter most.

The counters hook the paths every frame already passes through rather than
adding new ones: note_received_seq classifies inbound frames, send_shell_frame
is the single transmit choke point, and log_replay_event already sees the whole
replay lifecycle. That last one counts before taking its lock, because the write
it guards is skipped when no --replay-log was asked for and the counts must not
depend on a flag.

Two of the numbers are the ones worth comparing between runs. Inbound loss is
the gap between the peer's highest sequence number and what we delivered in
order, which is observable from this end; outbound loss is not, so the
retransmission count stands in for it. transmissions_per_frame is every frame
handed to the radio divided by the frames we originated, so it prices
retransmissions, replays and acks as the airtime they cost.

The counters are cumulative for the session and sit behind their own lock.
input_retransmits and missing_request_attempts deliberately stay as they are:
they reset per sequence number and describe the current gap, not the run.
Distinct gaps are counted on insertion into requested_missing_seqs rather than
per request, since the set drops a sequence number once it is replayed and the
ratio between requests and gaps is the damping working.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* DMShell: stop logging at WARN on every frame

Three lines on the hot path were left at WARN: both PTY reads, which fire once
per OUTPUT frame, and the packet-building line in sendFrameToPeer, which fires
once per frame of any kind. WARN is not filtered by log level on any normal
build, and when debug_log_api_enabled is on these also ride the API stream to
whatever client is attached.

This matters for what comes next rather than in principle. The next measurement
is dropped and resent frames under an interactive workload, where the thing
being measured is per-frame cost, and console I/O per frame is the same defect
as the per-acquisition lock logging removed in 59cded1 - which was measurably
load-bearing when it went.

TRACE rather than DEBUG, because the information is per-frame protocol detail
and MESHTASTIC_TRACE_LOGGING already defaults on for Portduino with a runtime
level gate, so a diagnostic run can still ask for it and an ordinary run pays
nothing. The recovery lines stay at WARN deliberately: they are damped, they
are rare, and they are what a measurement run counts.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* DMShell client: separate the resend count from total airtime overhead

transmissions_per_frame was every frame handed to the radio over the frames we
originated. That is a real quantity but the name invites reading it as a resend
ratio, and it is not one: in a session where the server is streaming output our
ACKs dominate it, so it can sit at 4.0 on a run with no loss at all. Renamed to
transmissions_per_originated_frame, and the printed line now says what it is.

The number actually wanted is resends, counted in note_frame_resent, which is
the single point both our replays for the peer and our own input
retransmissions pass through. resend_ratio is that over the frames we
originated.

Also fixes an undercount that the old name hid. note_received_seq returns "gap"
for a frame below the cursor when a gap is already open, because a replay
request is the action that frame triggers - so counting the outcome alone
missed the peer's resends during exactly the periods when the peer is
resending. Inbound frames are now counted twice over: what the frame is, judged
against the cursor before it moved, and separately what we did about it. So
duplicates means the peer put that frame on the wire twice, which with resends
above gives loss visible from both directions.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* DMShell: let an OUTPUT frame carry the ack when the window is open

Every frame already carries ack_seq, and sendFrameToPeer() zeroes
framesSinceOutbound because of it, so an OUTPUT frame settles the flow-control
debt for free. runOnce() was not taking advantage: the bare-ACK check ran ahead
of the PTY read and returned, so whenever output was waiting the server sent an
ACK and then the data, two frames carrying one cursor, with the data delayed by
a scheduler pass.

That happens more than the immediate read in applySessionFrame() suggests. The
read there runs the instant the input is written, so a shell that has not
produced its echo yet returns nothing, and the echo lands in runOnce() on the
next pass - where the debt is now due.

The bare ACK stays ahead of the window gate for the case the comment there
describes: while our own window is shut we retransmit, a retransmission carries
a stale cursor, and a bare ACK is then the only thing that keeps the peer's
window open when both directions are blocked at once. It is now limited to that
case, and the open-window case is settled after the read, where the counter has
already been cleared if anything went out. Guarded on session.active, because
the read loop also breaks there after closeSession() on EOF or a read error.

No behaviour changes when the PTY is empty: the ACK goes out in the same pass it
used to, just at the end of it.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* DMShell client: bound how long input can be held before it goes on the wire

The input batch window restarts on every byte, so it is an idle-gap timer, not
a window. Above one character per 0.5 s each keystroke refreshes the deadline
and nothing leaves until the 64-byte cap or an Enter. Driving the loop as
written at 5 char/s with no Enter gives 50 bytes per frame and a maximum hold
of 12.6 s: twelve seconds of typing with no echo, because the frame carrying it
had not been sent yet.

Keep the per-byte reset, which is what makes burst coalescing work, and cap the
total wait at 2 s from the first byte. Measured on the same driver: 5 char/s
with no Enter goes from 50 bytes and 12.6 s to 10 bytes and 2.0 s; 5 char/s
with Enter every ten characters is unchanged at 10 bytes, because the break on
\r already bounded it; 20 char/s still coalesces to 40 bytes per frame; and one
character per 1.5 s is untouched at one byte per frame and a 0.5 s hold, which
no client-side batching can improve on when the keystrokes are 1.5 s apart.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* DMShell: recover a lost OPEN_OK instead of waiting out the open timeout

Round 10 on hardware: the client sent OPEN, heard no OPEN_OK, and waited its
full 180 s --open-timeout, while the server had accepted the session and was
streaming the shell's prompt to nobody.

It could not recover, and not only because nothing retried. OPEN_OK is always
seq 1, and a replay request for seq N encodes as last_rx_seq = N - 1, which
both ends read as "no request" when it is 0. So the client saw the gap when
seq 2 and 3 arrived, and its request for seq 1 went out looking like a bare
ACK. A lost OPEN_OK was unrecoverable by design.

The client now repeats its OPEN while no OPEN_OK has arrived, 5 s doubling to
30 s, with the same session id and seq every time. A naive repeat would have
been worse than none: openSession() preempts the running session, so the
retry would fork a second shell over the first and discard its output. The
server now classifies an OPEN first (classifyOpen() in DMShellRecovery.h):
a repeat of the running session's id from its own peer resends OPEN_OK, and a
new id or another peer still preempts, as before. A repeat is ignored once
the peer's cursor shows it has OPEN_OK, because answering it would replay
seq 1, which on a long session has left the history and would close the
session as replay_evicted.

If the client does give up, it now sends CLOSE, which the server acts on out
of order, rather than leaving the session streaming until its own bounds
notice. Legacy recovery on either end keeps the old single OPEN, so the same
binaries still give an A/B baseline. The retry count is reported as
recovery.open_retries.

Six new cases in test_dmshell_recovery. classifyOpen() compiled and checked
standalone here; the module and the suite were not built, as pio cannot run in
this environment.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* DMShell: time the server's retransmission per frame, from measured ack latency

Round 10 on hardware: arm C, a bulk transfer with zero loss, had 34% of
the frames reaching the client be copies. The server's window-shut
retransmission had the two defects the client fixed in 0246db2:

- The wait was measured from the poll, not the frame. Every cursor
  advance cleared nextRetransmitMs, so the next unacknowledged frame,
  queued moments earlier and often still waiting for the radio, was
  repeated on the very next poll.
- The interval was one derived round trip (two max-length frames plus
  250 ms, ~658 ms at SHORT_FAST). The acknowledgement it waits for
  queues behind up to a window of our own frames, each sitting out a
  CSMA backoff, so it arrives later than that.

Each frame now carries the time it was last handed to the radio, first
send or resend. A cursor advance samples the latency to the frame it
reached, smoothed by a quarter exactly as the client does, and the
interval is twice that, floored at the derived round trip and capped at
REPLAY_REQUEST_MAX_MS. A frame is repeated only once that interval has
passed since its own send.

Also clears the replay history when a session opens. Entries match on
seq alone and every session numbers from 1, so a repeated OPEN could be
answered with the previous session's OPEN_OK.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* DMShell: make the OPEN retry testable, and log the retransmit interval

Round 11 on hardware confirmed the retransmit-timing fix but never
exercised the OPEN retry: no OPEN_OK was lost, so open_retries stayed 0.
The incident it guards against appeared once in a whole session.

--drop-open-ok N discards the first N OPEN_OK frames before any
accounting, as a frame lost on air is, and counts them in
recovery.open_ok_dropped. The client's repeated OPEN then has to recover
the session through the firmware's repeated-OPEN path.

The window-shut retransmit line now carries the interval it waited and
the ack latency estimate behind it. Round 11 measured a median of 5.8 s
from window close to first retransmit, which implies an estimate near
3 s at SHORT_FAST; this shows it directly.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* dmshell_client: stamp --peer-log lines to the millisecond

The host threw the resolution away: strftime gave whole seconds, and LogRecord.time is seconds too.
The stamp is still the arrival time on this host, so it carries the node's API and serial latency.
Measured against 448 frames with known air time, 79.5% of records arrive within 2 ms and 83.3%
within 15 ms, with a queueing tail (p90 +44 ms, p99 +152 ms).

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* DMShell: keep a sessionless ERROR out of the new session's sequence

A frame from an earlier run, still on air when a new client started,
reached the server with no session open. The server rejected it with an
ERROR that took the next sequence number, seq 1, and session id 0. The
new client, still waiting for its OPEN_OK, took that ERROR in order as
seq 1, then dropped the real OPEN_OK (also seq 1) as a duplicate and
acknowledged it. The acknowledgement moved the server's cursor for the
peer, so every repeated OPEN after that read as a late copy, and the
session never opened. Seen twice on the bench, in rounds 30 and 31.

Both ends are fixed; either alone breaks the chain.

- The server sends an ERROR for a frame outside any session of its own
  unsequenced (seq 0), unremembered, echoing the frame's session id.
- The client ignores any frame whose session id is not its own, surfacing
  an ERROR as an event but touching no sequence or cursor state.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* Fix the Windows/macOS build and the native test crash

The Portduino Lock now takes a real pthread mutex instead of doing nothing,
so a null spiLock is a null dereference rather than a harmless no-op. Only
three of the 89 native suites call initSPI(), so every suite that builds a
NodeDB segfaulted in Lock::lock(). Give spiLock static storage on Portduino
so it is live before initSPI() runs; the embedded targets keep the lazy
construction, since their semaphore cannot be created from a static
initializer.

DMShell drives a login shell through forkpty() and includes <pty.h>, which
the Windows and macOS Portduino hosts do not have. Gate the module on
Linux rather than on ARCH_PORTDUINO alone.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Address the CodeRabbit review

Lock::lock(timeout) on Portduino now honours its deadline: poll
pthread_mutex_trylock to the deadline, since pthread_mutex_timedlock is not
portable to macOS, and return false rather than blocking for good. A zero
timeout no longer waits at all.

DMShell keeps the bytes a nonblocking PTY would not take. write() on a
nonblocking fd may accept less than it was given, and the receive cursor has
already moved past those bytes, so the tail is queued and retried ahead of
any later input rather than being dropped.

A killed shell child is now reaped. SIGKILL ends the process but does not
collect it, so the pid is kept until waitpid() confirms the exit instead of
being cleared straight after the signal.

PING replay uses the highest cursor the peer reported, as
notePeerReceiveCursor() already does. A stale last_rx_seq beside a higher
ack_seq asked for a replay from a frame the history had dropped, which ended
a session the peer was perfectly happy with.

A sequenced frame that never reaches the air gives its number back. The
callers draw from nextTxSeq before sendFrameToPeer(), so a failed packet
allocation or encode left a hole no replay could fill. The encode path also
leaked the packet it had allocated.

In the client, the config handshake now applies --timeout to each blocking
read, so a peer that never answers no longer hangs it for good, and a
non-TTY stdin waits for its queued input to be acknowledged before it sends
CLOSE - the server handles CLOSE ahead of any gap, so piped input longer
than the window was being truncated in silence.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Reformat with the pinned clang-format

Trunk Check runs clang-format 20.1.0 over whole modified files, and these two
spots predate this round of changes: the ternary in handleReceived(), a
LOG_WARN argument wrap, and one trailing comment in the recovery test. No
code changes.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Keep every unreaped child, and never take the lock late

A close arriving while the previous child was still dying overwrote the one
pending slot, so that pid was lost and its zombie stayed. Keep a small set of
pending children instead, each held until waitpid() confirms its exit.

The timed lock checked its deadline before sleeping, so a mutex that came
free during the sleep could be taken just past the deadline. Check
immediately before each retry instead.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Give the native tests a cryptLock

The same class of crash as spiLock, one layer further in: only Router's
constructor creates cryptLock, and a suite can reach the admin and crypto
paths that take it without ever building a Router. test_module_config died
in AdminModule::setPassKey for exactly that reason. Create it in
initializeTestEnvironment(), which 86 of the 89 suites call.

Router's constructor then has to stop asserting that the lock is null.
It now uses the idiom on the next line of the same constructor, the one
routingAuthCacheLock has always used: nothing in src/ ever deletes the
lock, so one that already exists is the process's only one.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Never drop a pending child pid

With every slot full, a further close dropped the pid and left a zombie.
Wait for the child in the first slot instead: it has had SIGKILL, which
cannot be caught or blocked, so it dies unless it is stuck in the kernel.
A blanket waitpid(-1) would be shorter but would swallow the exit status
of the popen() in PortduinoGlue.

reapChildIfExited() now treats ECHILD as the shell having exited, so a
child collected elsewhere still ends the session rather than leaving it to
the five-minute idle timeout.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Link the DMShell recovery suite

test_dmshell_recovery ERRORED at link with undefined references to setup
and loop from portduino's main.cpp. Portduino declares both extern "C",
and this is the only suite whose includes never reach a platform header:
DMShellRecovery.h deliberately pulls in nothing but mesh/Throttle.h so it
can be driven from a native test, and TestUtil.h stops at unity.h. The
definitions were therefore mangled and main() could not find them.

Include Arduino.h, as test_throttle already does.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Stop the test mocks deleting cryptLock

The mock routers freed the global crypt lock in their destructors, each
with a comment pointing at the reason: Router's constructor asserted the
global was null before allocating it, so a suite that built more than one
router had to hand it back. That assert is gone - the constructor now
reuses an existing lock, the same idiom as routingAuthCacheLock on the
next line - and nothing in src/ has ever freed it, so these deletes now
free a lock the mock does not own and leave the global null behind them.

test_admin_radio is where that bites: ~HamModeMockRouter runs from
tearDown, and the next test reaches AdminModule's receive path, which
takes LockGuard(cryptLock) - a null dereference now that the Portduino
Lock is a real mutex. The other six were the same hazard waiting for a
suite to add a test after them.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Bind client frames to the selected peer, and bound its reorder buffer

Two client-side holes CodeRabbit's architecture pass kept on its list.

The reader matched a frame to the session on the session id alone, which
is carried in the frame and so is not an identity: any sender whose
decoded packets reach this stream could match it and then write to the
terminal, move the cursors or close the session. Drop anything not from
the node --to selected.

The out-of-order dictionary had no bound, unlike the server's eight fixed
slots, so a peer that never filled a gap grew it for the life of the
session. Bound it at the same eight and evict by the same rule: the
lowest sequence numbers are needed soonest, so the highest held is the
one worth losing, and only to something lower than it.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Never signal a reaped pid, and never silently lose input

Two findings from CodeRabbit's pass over 401b7a6, both real.

reapChildIfExited() calls closeSession() once waitpid() has collected the
shell's exit - but closeSession() then sends SIGTERM to session.childPid,
which at that point names a pid the kernel is free to have handed to an
unrelated process. Clear the pid before closing, so nothing signals it.

A rejected INPUT answered with an ERROR. The frame's sequence number is
already acknowledged by the time the write is attempted, so the peer will
never send those bytes again and no replay can recover them; the client
treats ERROR as non-terminal and carries on with a hole in the command
line. Close the session instead, which for a shell is the lesser harm.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Bound the handshake frame scan by its deadline

--timeout was enforced per read, not over the scan for a frame marker. A
wrong port or a noisy serial line delivers bytes steadily, so every read
returns inside its own timeout and recv_stream_frame() never comes back to
the caller's clock - the handshake then runs past the deadline it was
given. Pass the deadline in and check it each time around the scan.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Hold the handshake deadline over a whole frame, and assert the payload

recv_stream_frame() checked the deadline only while scanning for the start
marker. A sender that trickles returns a byte inside every read timeout,
so a frame arriving one byte at a time still ran past --timeout; the
deadline now reaches recv_exact() and is checked per chunk.

The RemoteShell round-trip helper took a payload and never encoded it, so
the INPUT and CLOSE cases asserted an empty body and would have passed
against a serializer that dropped their bytes. Copy it in and assert it
back.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* Three narrow failure cases around the new timed lock

ReentrantSpiLock::lock(timeout) recorded this thread as the owner whether
or not the underlying lock was taken. That was harmless while Portduino's
timed lock could not fail - it blocked until it had the mutex - but this
branch gives it a real deadline, so a false result would leave the thread
passing the reentrancy check without holding the mutex and unlocking one
it never took. Claim ownership only on success.

Lock::Lock() ignored a failed pthread_mutex_init, leaving every later use
of the mutex undefined. Abort, as the FreeRTOS branch beside it does.

The client set the transport timeout once before reading a frame, so a
frame arriving near the deadline could run nearly a full read timeout past
it. recv_exact() now shortens the transport's timeout to what is left.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeY5PczVYsxWNZhE2Cx4Sv

* DMShell client: bound a torn stream frame, and count the resyncs

Bench fixes that ran on every round since 46 but were never committed, so a
stash across a server rebuild silently dropped them. (The millisecond peer-log
stamps are already here as 17614a5ac.)

MAX_STREAM_FRAME bounds the length a header may claim: a torn header can name
any 16-bit value, and reading that many bytes stalls the session waiting for
bytes the node never sends. A byte that fails START2, or a rejected header, is
scanned again for the real frame's START1, as StreamAPI does.

STREAM_RESYNC and torn_frames count what the framing had to step over. A frame
destroyed on the USB stream is one the node did send and the radio did receive,
and it reaches the ledger as a replay request - indistinguishable from a frame
the radio never heard. A run reporting non-zero has lost frames to the
transport and its receive miss reads high by that much. Both are exported in
stats.json.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

---------

Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 03:48:17 +00:00
34c9457c69 Remove TMM relayed-position precision clamp (#12039)
The clamp rewrote the payload of relayed position broadcasts. The XEdDSA
signature covers Data.payload, so receivers that know the sender's key
dropped the rewritten packet. Relays now forward position payloads
unmodified.

Removes USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS, which only gated the clamp.


Claude-Session: https://claude.ai/code/session_01NmhtcXBe3B3NvS9RttzwHW

Co-authored-by: Jonathan Bennett <jonathan@meshtastic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:50:43 +00:00
dee945cd6a CH341: fewer waits on the USB bus per radio command (#12013)
* CH341: fewer waits on the USB bus per radio command

Every RadioLib command to a radio behind a CH341 waits on the USB bus about
five times: the BUSY read before it, CS low, the command, CS high, and the
BUSY read after, with a 1 us settle between the last two that usleep() turns
into ~50 us of timer slack.

- A BUSY read that follows the read that found BUSY low after a command is
  answered from it, once, within 1 ms and only if nothing else has been sent
  to the adapter in between: RadioLib's wait before a command then costs
  nothing after the wait that ended the last one.
- Delays under 100 us spin on the steady clock instead of sleeping.
- With a libch341 that has pinedio_transceive_select(), and where Lora.CS is
  D0, the CS levels travel with the SPI transfer and RadioLib's separate CS
  writes are dropped. Against an older libch341 this part compiles out.

The HAL learns the radio's CS and BUSY pins from initLoRa(), since USBHal.h
is included by PortduinoGlue.h ahead of portduino_config.

Measured on an SX1262 over a CH341 (SHORT_TURBO, 6 v 6 runs of 300 s): the
three-command CAD setup fell 1.01 -> 0.62 ms, CAD arm to air 6.60 -> 5.23 ms,
and total packet loss 1.51 -> 1.13% (p=0.048), with collisions 4.83 -> 0.67
per run (p=0.002).

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* Bump libch341-spi-userspace to 027cde5 for pinedio_transceive_select()

Picks up libch341#6 (1 ms interrupt poll, poll thread woken on detach) and
#7 (pinedio_transceive_select()), so the packed-CS path in USBHal.h builds
in rather than compiling out.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-10-01 23:57:06 +00:00
github-actions[bot]andjp-bennett 1a5ddec042 Automated version bumps (#12033)
Co-authored-by: jp-bennett <5630967+jp-bennett@users.noreply.github.com>
2026-10-01 23:43:14 +00:00
Georg von StackelbergandClaude Sonnet 5.5 e0c670a65d Meshnology W12: report MESHNOLOGY_W12 hardware model (#12037)
The MESHNOLOGY_W12 enum (145) was added in meshtastic/protobufs#1042, but
the W12 variant predates it and still reports PRIVATE_HW (255). Add the
HW_VENDOR mapping in architecture.h, set custom_meshtastic_hw_model to 145,
and drop the outdated "no hardware model yet" comment.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 22:46:39 +00:00
Jonathan BennettandClaude Opus 5 e691bd3790 Claude/dmshell lock (#12024)
* Lock: give Portduino a real mutex instead of the empty fallback

Lock.cpp has a FreeRTOS implementation and an empty one, and Portduino takes
the empty one: every lock() and unlock() on a Linux build is a no-op, so
concurrency::Lock protects nothing there. TrafficManagementModule's cacheLock
and SPILock are both built on it, and native meshtasticd runs the radio and the
API on separate threads.

Add a pthread implementation under ARCH_PORTDUINO. The timed lock(uint32_t)
blocks rather than returning early, because there is no portable timed
pthread_mutex_lock across Linux and macOS and returning true without acquiring
would leave a caller such as SPILock unlocking a mutex it never took. Targets
that have neither FreeRTOS nor pthreads, such as STM32WL, keep the existing
empty implementation byte for byte.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* test: create spiLock in the shared setup, which NodeDB needs and no test had

Making Portduino's Lock real turns a latent null dereference into a crash. spiLock
is a bare pointer that initSPI() fills in, and only main.cpp calls that, so in a
test binary it stays null. NodeDB's constructor reaches it through loadFromDisk(),
and while Lock::lock() was an empty function the call never touched `this`, so
23 suites have been calling a method on a null pointer and getting away with it.
With a pthread mutex behind it the same call reads through the null pointer and
takes SIGSEGV at offset 0x10, which is what test_phone_api_config_dump,
test_muted_source, test_nodeinfo_send_window and test_module_config hit.

Create it once in initializeTestEnvironment(), which every affected suite calls as
the first statement of setup(), before any of them constructs a NodeDB. The guard
is the idiom test_xmodem and test_nodedb_identity_hygiene already use; theirs stay
correct and become no-ops. test_safefile called initSPI() bare right after the
harness, which would now trip its assert, so that call goes away.

No firmware behaviour changes: main.cpp still calls initSPI() exactly once, and
nothing outside the test harness is touched.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* test: create cryptLock where a suite reaches it without building a Router

Second instance of the same latent null dereference the previous commit fixed
for spiLock. cryptLock is a bare pointer that Router's constructor creates
(Router.cpp:246); AdminModule::setPassKey takes a LockGuard on it, and a suite
that exercises an admin path without standing up a Router leaves it null. While
Lock::lock() was empty on Portduino the guard never touched `this`; with a
pthread mutex it reads through null, which is test_tak_config's SIGSEGV in
handleGetModuleConfig.

It cannot go in initializeTestEnvironment() the way spiLock did, because Router
asserts cryptLock is unset before allocating its own, so creating it for every
suite would break the ones that do build a Router. It is a named helper instead,
testEnsureCryptLock(), called by the six suites that reach a cryptLock path with
no Router: test_ack_proof, test_admin_session_repro, test_fuzz_packets,
test_hop_scaling, test_module_config and test_tak_config. The three that define
setup() twice behind a PKI #if get the call only in the branch that compiles the
tests in.

No firmware behaviour changes; nothing outside test/ is touched.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* test: create cryptLock in the harness instead of chasing suites

Router's constructor asserted cryptLock was unset, then allocated it. That
assert is why ten suites carry a mock-router destructor whose only job is to
delete the global and null it so the next router can be built. While
Lock::lock() was an empty function on Portduino a null cryptLock cost nothing,
so those null windows were invisible; with a real mutex, anything reaching
perhapsDecode() or the ack-proof paths after one of those destructors runs
dereferences null.

The fix is the idiom already on the next line of the same constructor, which
routingAuthCacheLock has used all along: reuse the lock if one exists. Nothing
in src/ ever deleted cryptLock, so a Router that finds one is finding the
process's only one. initializeTestEnvironment() can then create it for every
suite, the way it now does for spiLock, and the ten teardowns and the
per-suite helper from the previous commit all go away.

Replaces the six testEnsureCryptLock() call sites with one creation point, and
removes the null windows in test_admin_radio, test_mesh_beacon,
test_mesh_module, test_mqtt, test_nexthop_routing, test_nodeinfo_send_window,
test_traffic_management, test_event_channel_phone_api,
test_event_channel_router and test_phone_api_config_dump.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-01 21:33:14 +00:00
Jonathan BennettandClaude Opus 5 8c0abbd522 feat(portduino): BLE peripheral support via BlueZ for meshtasticd (Raspberry Pi) (#11396)
* feat(portduino): BLE peripheral support via BlueZ for meshtasticd on Linux

Adds the standard Meshtastic BLE service (toRadio/fromRadio/fromNum/logRadio)
to the Linux native target, so a Raspberry Pi running meshtasticd can be
paired and used over BLE like any other Meshtastic device.

Implementation: a new LinuxBluetooth backend registers a GATT application,
LE advertisement and pairing agent with bluetoothd over the org.bluez D-Bus
APIs, using sdbus-c++ (both the 1.x and 2.x major versions, via a small
compat shim - Debian bookworm/Ubuntu 24.04 ship 1.x, trixie/Fedora ship 2.x).
When the sdbus-c++ dev package is absent the whole backend compiles out via
__has_include, the same optional-dependency idiom as the ulfius webserver.

Threading follows the NimbleBluetooth model, simplified: the sdbus event
loop runs its own thread, and all PhoneAPI calls happen on the main thread.
Writes queue to the main loop; reads park the D-Bus reply and are completed
from the main thread after queued writes, so write-then-read clients see
their answer without any busy-waiting.

Enablement is a double opt-in: a new `Bluetooth:` config.yaml section
(Enabled, default false; AdapterId, default hci0) must turn BLE on for the
host, and the regular device config bluetooth.enabled must be on. The
config-check schema and fixtures cover the new section.

Pairing honors config.bluetooth.mode: NO_PIN maps to a NoInputNoOutput
just-works agent; RANDOM_PIN to DisplayOnly with the kernel-generated
passkey shown on screen/log via the existing BluetoothStatus plumbing.
FIXED_PIN falls back to random-passkey semantics with a warning - BlueZ
does not support forcing a passkey. PIN modes enforce
encrypt-authenticated-read/write on all mesh characteristics.

Packaging: install a D-Bus system policy so the meshtasticd user may talk
to org.bluez, add it to the bluetooth group, order the unit after
bluetooth.service, and add libsdbus-c++-dev to debian/rpm/docker/CI deps.

Verified in-container against a mock bluetoothd: registration flow, GATT
tree enumeration, advertisement properties, and a full config download
(ToRadio wantConfig -> 47 FromRadio packets) through the D-Bus bridge.
Real-hardware pairing/notify testing on a Pi still pending.

Known limitations (v1): meshtasticd must be restarted if bluetoothd
restarts; FIXED_PIN degrades to a random passkey; getRssi() returns 0
(same as nRF52).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): BLE fixes from first real-hardware pass (Pi CM5 + RAK6421)

Findings from testing PR #11396 on a Raspberry Pi CM5 (Pi OS trixie,
BlueZ/sdbus-c++ 2.1 - the v2 compat path) with a RAK6421 HAT and an
Android phone:

- getMacAddr() leaked its HCI socket on every call and never closed it,
  and on failure returned without touching the caller's buffer - which
  getDeviceName() passed in uninitialized. Close the socket on all paths
  and cache the MAC after the first successful read; it cannot change at
  runtime and this now runs on every bluetoothd property read.
- getDeviceName() zero-initializes its MAC buffer, and LinuxBluetooth
  snapshots the name once at setup() on the main thread: the
  advertisement's LocalName getter runs on the D-Bus event-loop thread
  and getDeviceName()'s static buffer is not thread-safe.
- Restore NimBLE-style config-phase packet prefetch (depth 3). The
  initial port answered every FromRadio read with a D-Bus -> main-loop
  round trip, which made the config download noticeably slow; ReadValue
  now answers straight from the prefetch queue on the event-loop thread,
  with NimBLE's safety rules (never in STATE_SEND_PACKETS, writes always
  observed before reads, queue cleared on disconnect).
- Set advertising MinInterval/MaxInterval to 20-100ms (BlueZ >= 5.71;
  older versions ignore the properties). btmon showed the kernel default
  of 1.28s otherwise, and Android's background-connect scan windows are
  sparse enough that tap-to-connect took 8-14s; 20ms is the same floor
  NimBLE uses on ESP32.

Verified on hardware: scan, passkey pairing, connect, config download,
reconnect after bond wipe. Also diagnosed (no code change): the node
identity MAC comes from the RAK HAT EEPROM by design, so the BLE name
suffix follows the HAT rather than the BT adapter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): address CodeRabbit review on BLE support

- setBluetoothEnable: handle disable before the config gate, so a running
  BLE stack is always stoppable even after the device config turns
  Bluetooth off underneath it
- getMacAddr: read the adapter configured as Bluetooth.AdapterId instead
  of hardcoding hci0, falling back to hci0 for unparseable names
- systemd unit: Wants=bluetooth.service so bluetoothd is pulled up when
  present (After= only orders, it does not start it)
- debian/rpm: Recommends: bluez as the runtime contract for BLE
- dbus policy: document why the org.bluez rule is destination-wide
  rather than a per-interface allowlist

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): show the BLE pairing code on BaseUI screens

onDisplayPasskey published the passkey to bluetoothStatus and triggered
PowerFSM, but never called screen->startAlert(), so on BaseUI the code only
ever reached the log. BluetoothStatus has no BaseUI consumer -- only InkHUD's
PairingApplet and StatusLEDModule read it -- so a Pi driving a HUB75/OLED
panel showed nothing while BlueZ sat waiting for the user to type a code they
could not see. NimBLE and nRF52 draw it via startAlert(); this adds the
missing half for Linux.

The agent callbacks run on the sdbus event-loop thread while the screen is
owned by the main thread, so the passkey is handed over as a pending flag and
drawn from runOnce(), matching the existing disconnectCleanupPending pattern
rather than reaching into the screen from the event loop.

Dismissed on all four exits, so a stale code cannot stick on an always-on
panel: Paired -> true (newly watched in PropertiesChanged, which previously
only looked at Connected), agent Cancel, peer disconnect (moved out of the
lastGone branch so a peer leaving mid-pairing clears the code even when
another device is still connected), and doDeinit() -- applied inline there
because runOnce() may never be scheduled again after teardown.

Verified on a Pi 5 + BlueZ 5.66 in RANDOM_PIN mode: the code renders on a
HUB75 panel and clears once the phone completes pairing.

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

* ci: install libsdbus-c++-dev for the native test build

setup-native-test landed on develop while this branch was adding
libsdbus-c++-dev to setup-native, so the new action's "full setup-native
list" of C libraries is missing it. Without the package the test job
builds with HAS_BLUETOOTH 0 and never compiles LinuxBluetooth.cpp.

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

* fix(portduino): warn when a factory reset cannot clear BLE bonds

factoryReset(eraseBleBonds) silently did nothing on Linux when the BLE
backend was not running, so the reset reported success while the host's
pairings stayed. Removing them needs a live connection to bluetoothd that
a disabled backend never opened, so say so rather than imply they went.

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

* fix(portduino): gate the factory-reset bond clear on an enabled backend

setup() leaves linuxBluetooth allocated with its bus torn down when it
throws, so a pointer check alone let factoryReset log "Clear bluetooth
bonds" for a clear that clearBonds() then declined to perform. isEnabled()
is only true after setup() completes, which routes that case to the
warning that says the bonds were left alone.

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

* style: reformat under clang-format 20

#11909 moved trunk from clang-format 16 to 20, which spaces C-style casts
differently and reindents the comment above the HAS_WIFI block. Both files
are ones this branch already touches, and trunk's fmt linter grades whole
files, so its check fails until they are reformatted. No behaviour change.

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

* fix(portduino): three BLE config and lifecycle fixes from review

Bluetooth config keys are now assigned individually rather than per section.
loadConfig() runs once for every file in config.d, so reading an absent key as
its default let a later file that named only one of them silently reset the
other: `AdapterId: hci1` alone turned Bluetooth off, and `Enabled: true` alone
dragged the adapter back to hci0. Only what a file actually states should
override what an earlier one set.

A backend that failed to come up is now retried. setup() can leave
linuxBluetooth non-null but disabled - bluetoothd not ready, adapter missing,
policy refusing - and every later enable then called resumeAdvertising(), which
returns immediately while disabled. A transient failure at boot kept BLE off
until the process restarted. doSetup() already opens with `if (enabled) return`
and tears the bus down on every failure path, so calling it again is safe.

Bluetooth.AdapterId is now checked for the hci<digits> form. LinuxBluetooth uses
the value verbatim as the BlueZ object path while the MAC fallback reads only
the leading hciN, so "hci1junk" looks plausible, yields a MAC, then finds no
adapter and BLE never comes up. Covered by a new fixture and suite case, which
is the kind of silent no-op that directory exists to catalogue.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-01 21:20:09 +00:00
e0c76fd41e Improve performance of encrypted packets with a shared secret cache (#11979)
* Improve performance of encrypted packets with a shared secret cache

Every PKI encrypt, decrypt and ack-proof ran Curve25519::dh2 plus a SHA256
to derive the pairwise key, so a node in a conversation paid a full X25519
per packet, on the main loop, under cryptLock. On a RAK4631 that is ~96 ms
of the ~210 ms it takes to handle a DM.

setCryptoSharedSecret() derives the key only when it is not already held
for that peer, keeping the last MAX_CACHED_SHARED_SECRETS derivations
(8 on nRF52, 2 on STM32WL, 10 elsewhere, under 400 bytes) and evicting the
least recently used. encryptCurve25519, decryptCurve25519 and
ackProofCompute all go through it, so the ack proof gets the same cache
without a second DH path. The cache is emptied whenever our own private
key changes, since every secret in it is then stale.

The lookup key is the first 4 bytes of the peer's public key. A collision
makes us derive against the wrong cached secret, which costs a failed
decrypt for that pair; it cannot disclose either secret.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHxcWJuvSoSSMpz3LWdt3

* Do not let an empty cache slot answer for a zero-prefixed peer key

The cache used lookup_key == 0 to mean "slot unused", so a peer key whose
first 4 bytes are zero matched every unused slot and was handed that slot's
zeroed secret as a hit. The all-zero key is exactly such a key, which is how
test_proof_rejects_weak_peer_key caught it: dh2's weak-point check never ran.

Entries carry an explicit valid flag instead, which the struct's existing
padding absorbs. A weak peer key is also now rejected before the cache is
consulted, since with 4 bytes of lookup key it could otherwise collide with a
cached peer and be served that peer's secret rather than being refused.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHxcWJuvSoSSMpz3LWdt3

* Key the shared secret cache by the whole peer public key

A four-byte lookup key is grindable: anyone can generate a keypair whose
public key shares those bytes with a peer they want to shadow, get their own
entry cached, and then be handed the secret this node uses to talk to that
peer - readable by them, since they hold the matching private key. That is
disclosure of traffic meant for the peer, and a forgeable ack proof in its
name, not the failed exchange a chance collision would cause.

Entries hold the peer key itself and are matched on all 32 bytes, so the
weak-key check dh2 does on a miss can no longer be skipped either and the
explicit isWeakPoint guard goes away with it. The cache costs 66 bytes per
entry: 528 on nRF52, 660 where the default 10 entries apply.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHxcWJuvSoSSMpz3LWdt3

* Allowlist the cache's uptime shift for the millis deadline guard

The guard's regex reads `millis() >> 22` as a comparison against the uptime
clock. It is a right shift, coarsening uptime into the ~1.165 hour units the
cache stamps entries with, and the eviction arithmetic handles that stamp's
8-bit wrap itself.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHxcWJuvSoSSMpz3LWdt3

---------

Co-authored-by: Jason B. Cox <contact@jasonbcox.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 17:47:25 +00:00
f5940ab5dd Meshtasticd notifications (#10216)
* First attempt at sending message notifications on Linux

* Add files via upload

* Notifications: Use libnotify

Use libnotify for meshtasticd desktop notifications.

Add "HAS_LIBNOTIFY" macro to guard for builds where libnotify is expected.
This is not included in buildroot/openwrt builds. (no libnotify except when extra repos are added).

Install desktop icon in the correct location on Debian and Fedora packages.
Update dependencies in packaging and dockerfiles.

* Add libnotify to setup-native (GitHub Actions)

* Address review feedback on the meshtasticd notification path

- platformio.ini: only define HAS_LIBNOTIFY when pkg-config actually finds
  libnotify. The probe previously ran unguarded and the macro was defined
  unconditionally, so a native build without libnotify-dev both hard-failed at
  config time and claimed the feature was available.
- Fix the sender lookup for develop's flattened NodeInfoLite: has_user/user.*
  are gone, replaced by nodeInfoLiteHasUser() and direct long_name/short_name.
  This is a silent semantic conflict - it merges cleanly but does not compile.
- Move the desktop notification off the packet path. notify_notification_show()
  is a synchronous DBus round trip and meshtasticd's packet handling is
  single-threaded, so a slow or wedged notification daemon could stall the
  radio. handleReceived() now resolves the strings and queues them (bounded at
  16); a dedicated worker owns every libnotify call.
- Stop retrying forever: a failed notify_init(), or three consecutive failed
  shows, latches desktop notifications off instead of re-logging per message.
- NodeDB: the ARCH_PORTDUINO default-enable block was nested inside
  #ifdef HAS_I2S, which Portduino never defines, so it never ran. Hoist it out.

* fix(portduino): close input-broker guard before the libnotify block

The libnotify implementation was inserted ahead of the #endif that closed
#if !MESHTASTIC_EXCLUDE_INPUTBROKER, so the file's trailing #endif closed
#if HAS_LIBNOTIFY instead and the input-broker conditional was never closed.
Every build target failed to preprocess:

  src/modules/ExternalNotificationModule.cpp:630: error: unterminated #if

Close the guard immediately after handleInputEvent(), as develop does, so the
two conditionals stay independent and HAS_LIBNOTIFY still resolves when the
input broker is excluded.

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

* fix(portduino): back off desktop notifications instead of latching them off

notifyDisabled was doing two jobs: "libnotify looks unusable" and "the
destructor wants the worker to exit". Because the worker's only exit path was
also its only failure path, three failed shows turned notifications off for the
process lifetime with no way back. The packaged daemon is exactly that case:
bin/meshtasticd.service runs as User=meshtasticd and the rpm spec creates that
account with /sbin/nologin, so there is no session bus and every show() fails.
A stock install logged three warnings and then went silent forever, while
NodeDB now enables the module by default on Portduino.

Split the flag. notifyShutdown is destructor-only and remains the worker's one
exit; a retry window replaces the latch. After maxNotifyFailures the worker
arms a backoff (30s, doubling to a 15min cap), drops the queue rather than
holding stale popups, and keeps looping. The producer refuses to queue while
the window is open, so the retry is driven by the next message after it expires
rather than by a timer - no idle wakeups, no probe notifications. Any success
resets the backoff. notify_init() moved inside the loop so a retry can pick up
a session bus that was absent at startup.

The worker also no longer calls the LOG_ macros. RedirectablePrint formats into
a shared static buffer that nothing guards and every other writer to it is on
the main thread, so the worker now records the state change under the mutex it
already holds and reportNotifyStatus() emits it from portduinoNotify() and
runOnce(). Per-message failure spam becomes one line per transition: the reason
and retry interval when it goes down, and a line when it comes back.

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

* fix(portduino): sanitize mesh text before it reaches libnotify

The notification body was the raw decoded payload and the summary was a node
name, both attacker-controlled, and neither was checked before being handed to
libnotify. g_variant_new_string() rejects invalid UTF-8: a GLib CRITICAL and an
"[Invalid UTF-8]" body by default, and a hard abort under G_DEBUG=fatal-criticals,
so an unauthenticated mesh packet could terminate meshtasticd on any install
running with that flag. An embedded NUL separately truncated the body at the
first one, hiding the rest of the message.

Route both strings through sanitizedMeshText(), which replaces embedded NULs and
then applies the existing sanitizeUtf8() helper. TypeConversions already
sanitizes names on the way into NodeDB, but an abort is too sharp an edge to
leave resting on an invariant owned by another file.

Escape the body for Pango markup as well. Servers advertising "body-markup"
parse a markup subset there, so a message can inject formatting to dress itself
up as trusted UI, and where the server also advertises body-images or
body-hyperlinks it can inject tags that make the notification daemon fetch a
remote URL. The escaping is unconditional rather than gated on
notify_get_server_caps(): a caps query that fails, or goes stale across a daemon
restart, fails open, while on a server without body-markup the only cost is
entities rendering literally, and mesh text carries no markup worth preserving.
The summary is not escaped - the spec gives it no markup, so escaping it would
only ever show entities.

Also call notify_uninit() in the destructor, after the join: the worker owns
every libnotify call, so tearing down while it is still running would be a
use-after-uninit.

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

* fix(portduino): gate the notification default-on behind HAS_LIBNOTIFY

The default-enable block was gated on ARCH_PORTDUINO, so a native build where
pkg-config could not find libnotify still shipped external_notification enabled.
portduinoNotify() is not compiled into that build, and native defines
EXT_NOTIFICATION_MODULE_OUTPUT as 0 with setup() guarding the pin writes behind
output > 0, so nothing was driven - it only exposed a config surface that can
do nothing. Gate it on the same macro that governs the code it exists to feed.

HAS_LIBNOTIFY is a build flag for the whole env and is simply absent when the
probe fails, so this reads as 0 on every other target, matching how
ExternalNotificationModule.cpp already tests it.

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

* fix(portduino): fail closed when markup escaping returns NULL

escapedNotificationBody() returned its input unescaped if g_markup_escape_text()
gave back NULL, which would hand libnotify the exact attacker-controlled string
the function exists to neutralize. The branch is unreachable for the input we
pass - already sanitized to valid UTF-8, and GLib documents no NULL return for
it - but an error path whose fallback is the unsafe action is the wrong shape
for a helper on this boundary. Drop the body instead.

Also note in the doc comment why running this on the caller's thread does not
weaken the rule that the worker owns every libnotify call: it is a pure GLib
string function that touches no libnotify or DBus state.

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

* style: format ExternalNotificationModule.h per the pinned clang-format

Trunk Check Runner failed on c0d5ebff with "1 unformatted file" against this
header. The deviation is a brace-spacing fix that clang-format wants on the
rtttl stub's begin(): the formatter emitted it while reformatting this file for
an earlier commit, and I reverted it then as unrelated churn. It was not -
trunk's fmt check reports a modified file as a whole, so touching this header at
all surfaces that line, and dropping the formatter's output is what turned the
check red.

Verified with the version trunk.yaml pins, clang-format 20.1.0, rather than the
older one available locally: it is the only remaining deviation across the three
files this branch touches.

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

* fix(portduino): stop the libnotify probe swallowing native-tft's link flags

The docker arm64 builds failed to link native-tft:

  libmeshtastic-device-ui.a(CURLService.cpp.o): undefined reference to symbol
  'curl_easy_reset@@CURL_GNUTLS_3'
  /lib/aarch64-linux-gnu/libcurl-gnutls.so.4: DSO missing from command line

-lcurl was never on the link line. The libnotify probe was the last line of
[native_base].build_flags, and an env extending it writes

  build_flags = ${native_base.build_flags} -Os -lcurl -lX11 ...

so the interpolation appended those flags to the probe's own line. The result is
a single shell command ending in `... && echo -D HAS_LIBNOTIFY=1 || : -Os -lcurl
-lX11 -linput -lxkbcommon -ffunction-sections -fdata-sections -Wl,--gc-sections`,
where the trailing flags are arguments to echo when the probe succeeds and to `:`
when it fails. They never reach the compiler, and nothing reports it.

Three envs lost flags this way: native-tft and native-tft-debug (-lcurl -lX11
-linput -lxkbcommon), and native-fb (-lcurl, --gc-sections). env:native appends
nothing on that line, which is why the native test suite stayed green and this
stayed hidden until the module compiled and something actually tried to link.

Move the probe above `-I /usr/include` so a plain flag line ends the value, and
record the constraint so the next flag added here does not re-break it.

Verified with `pio project config --json-output`: before, the three envs carried
the flags inside the probe's command string; after, all three carry them as
build flags and no env still swallows any.

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

---------

Co-authored-by: Austin Lane <vidplace7@gmail.com>
Co-authored-by: Tom Fifield <tom@tomfifield.net>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-01 17:38:14 +00:00
Jason P c50b294e6f BaseUI: use function pointers for banner callbacks (#12026) 2026-10-01 15:28:38 +00:00
Jonathan BennettandClaude Opus 5.5 790944a75e tftSetup: don't claim the SPI bus after a timed-out take (#12025)
ReentrantSpiLock::lock(uint32_t) recorded the calling thread as owner
with depth 1 whether or not spiLock->lock(timeout) succeeded. After a
timeout the thread's next lock() then took the reentrant path and skipped
the real acquire, and the matching unlock() released a semaphore the
thread never held, so device-ui and the radio could both reach the bus.

Record ownership only when the take succeeds, and return false without
touching owner or depth when it times out.

Found while reviewing CodeRabbit's note on #12024.



Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:53:42 +00:00
Tom 7fe3176a40 Radiolib recording hal tests (#11997)
* test: exercise RadioLib's drivers through a recording HAL, pinned to RadioLib 7.8.0

test_radiolib_drivers drives the RadioLib calls Meshtastic's radio interfaces
make against RecordingHal, which logs every SPI transaction and answers with a
success status, a few scripted replies (the packet type the LoRa setters check)
and, for SX127x, a register file. One set per chip family - SX126x, SX127x,
SX128x, LR11x0, LR2021 - each ending in a list of the reply-dependent tests
still to write. SX126x and SX128x get the SPI framing their begin() would set.

RadioLib is pinned to 7.8.0 to show the failure: the LR2021 DC-DC workaround
(jgromes/RadioLib#1864) passes sizeof(uint32_t) as a word count, and under
[env:coverage] ASan aborts the LR2021 set with a stack-buffer-overflow in
readRegMem32. The other four sets pass.

* test: pin RadioLib to the DC-DC word-count fix

NomDeTom/radiolib@3509dbc8e (branch lr2021-dcdc-regmem32-len, on RadioLib
master after 7.8.0) passes 1 as the word count at the three DC-DC workaround
call sites. The overrun is gone and the LR2021 set runs to completion.

test_lr2021_dcdc_freq_lf_write_sends_one_word still fails, on a second RadioLib
bug: LRxxxx::writeCommon() sends 4 + 4*len bytes whatever the address width, so
every LR2021 WriteRegMem32 (24-bit address) carries one uninitialised trailing
byte - 10 bytes where 9 are meant.

* test: keep RecordingHal reachable across a failed assertion

A failed TEST_ASSERT longjmps out of the test, skipping destructors, so each
test's local RecordingHal leaked its transaction log. LeakSanitizer then
reported it at exit, and under [env:coverage] a plain assertion failure came
out as an ERRORED suite (run 36412456860: 26 Tests 1 Failures, then a 384-byte
leak from RecordingHal::spiTransfer). One static HAL, reset at the start of
each test, stays reachable, so a failure reports as FAILED.

* test: run the RadioLib driver suite against develop's RadioLib pin

Revert the pin to develop's 510e00cfb (RadioLib 7.7.1). That RadioLib predates
the LR2021 DC-DC workaround, so its three tests would only fail on their own
premise there. They now register only when LR2021 has the public
setRegulatorDCDC() that jgromes/RadioLib#1864 added with the workaround: any
RadioLib carrying it runs them, and none is left ignored.

Against 7.8.0 the suite shows the DC-DC overrun (ASan, run 36412379592). Against
the DC-DC fix (NomDeTom/radiolib@3509dbc8e) it shows a second bug: every LR2021
WriteRegMem32 sends one byte too many, since LRxxxx::writeCommon() still sizes
the frame for a 4-byte address after jgromes/RadioLib@e3af85867 made it 3 on
the LR2021 (run 36412456860). Neither is reachable at this pin.

* test: pin the coding-rate byte the radio interfaces send

LR11x0Interface, LR20x0Interface and SX128xInterface all call
setCodingRate(cr, cr != 7), so 4/5 goes out with the long interleaver.
The modulation tests used the driver default (short), and only counted
frames. Pass the flag the firmware passes, and add a test per chip that
reads the coding-rate field back from the last modulation-params frame:
5 for 4/5 long, 3 for 4/7 short (RadioLib 7.7.1's encoding).

Also correct test_main.cpp: at the 7.7.1 pin the LR2021 DC-DC tests are
not registered, so the set does not fail; the 7.8.0 overrun is now
stated as conditional on a RadioLib that carries it.
2026-09-30 12:09:08 +00:00
Jonathan BennettandClaude Opus 5.5 778184c7a1 Log: report LOG_TRACE lines to the API as TRACE rather than UNSET (#11977)
RedirectablePrint::getLogLevel switches on the first character of the level
string and has cases for D, I, W, E and C, but none for the "TRACE" that
LOG_TRACE passes. Every trace line therefore reached a client as
meshtastic_LogRecord_Level_UNSET, so a capture taken over the API could not be
filtered or sorted by level, and a reader had no way to tell a trace line from
one the firmware never labelled.

meshtastic_LogRecord_Level_TRACE already exists in the protobuf as 5. Add the
missing case, ordered with the others by ascending severity.



Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:21:44 +00:00
Manuel 17cc649a64 fix(gps): stop the AIROHA engine before power-down on boards with GPS_SLEEP_INT (ThinkNode M9) (#12004)
* fix AIROHA GPS and M9 sleep/wake

* fix nrf compile error

* make coderabbit happy as well
2026-09-29 16:11:52 +00:00
Thomas Göttgens 642076baed Show Ethernet status on the WiFi screen (#11991)
* Show Ethernet status on the WiFi screen

On W5500 and CH390 boards the WiFi frame shows Ethernet link state, speed,
duplex and IP when Ethernet is enabled and has link, or WiFi is not
configured. If WiFi is also configured, one line shows its IP or
connection state.

* Use the CH390 driver for UDP multicast link checks

On USE_CH390D builds ETH resolved to the unused Arduino ETHClass, so
onSend() always saw Ethernet as disconnected and never sent.

* Require HAS_ETHERNET for USE_WS5500 and USE_CH390D
2026-09-29 11:13:51 +00:00
Austin 9d8ce1c780 Actions: Support MemBrowse memory/flash tracking (#12006) 2026-09-29 00:24:09 +00:00
Thomas Göttgens 8e6a88d06f Revert Radiolib Pin
Updated RadioLib dependency to a specific commit version.
v2.8.1.8e6a88d
2026-09-28 12:29:53 +02:00
Thomas Göttgens 11eebcb543 Update RadioLib dependency to version 7.8.0 2026-09-28 09:20:11 +02:00
danandClaude Sonnet 5 d482dc78a1 Fix fixed-position broadcasts going out as lat/lon 0,0 (#11985)
handleReceivedProtobuf() returns early for a fixed-position self-update, to
protect the pinned coordinates from being overwritten by a phone/GPS update -
but that early return also skipped the line that refreshes the `precision`
member for the packet's channel, leaving it at its 0 "safe starting value".

alterReceivedProtobuf() then runs anyway on the same from-us packet (whether
it's our own broadcast looped back locally, or a phone-submitted one) and
truncates it to whatever `precision` currently holds. applyPositionPrecision(_,
0) doesn't clamp - it wipes the whole Position back to defaults, intentionally,
for the channel-privacy case this function was written for. Router::deliverLocal()
runs this same dispatch on the same packet object before it's actually
transmitted, so the wipe landed in the real outgoing radio packet: every
fixed-position broadcast went out as lat=0/lon=0 instead of the fixed
coordinates.

Fix: refresh `precision` on this early-return path too, instead of skipping
it. For our own already-correctly-truncated broadcast this makes
alterReceivedProtobuf()'s re-truncation a no-op (same precision, idempotent).
For a phone-submitted position while fixed_position is on, it's what
correctly applies the channel's privacy truncation - preserving that behavior
was the point of not touching alterReceivedProtobuf() itself. (An earlier
version of this fix skipped alterReceivedProtobuf() entirely whenever
fixed_position was set, which would have let a phone-submitted position
broadcast at full precision regardless of channel setting - caught in review.)

Regression range: v2.7.24 through current develop (introduced in #10383,
which removed the `precision > 0` guard that used to make this a no-op).
v2.7.23 and earlier are unaffected.

Tested on a Portduino/meshtasticd build with position.fixed_position set:
confirmed periodic position broadcasts carried the configured coordinates
instead of 0,0 after this change. Not tested against ESP32/nRF52 hardware -
the affected code path is platform-generic (no ARCH_* gating), so the fix
should apply equally, but I don't have that hardware on hand to verify.

Fixes #11547


Claude-Session: https://claude.ai/code/session_01N7q3Vu58yzcdcP5hCW2au6

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 12:58:55 +00:00
renovate[bot]andManuel d6307752c7 chore(deps): update meshtastic/device-ui digest to a91319c (#11896)
* chore(deps): update meshtastic/device-ui digest to a91319c

* Update device-ui dependency to an older version as sensorlib 0.5 won't compile yet

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
2026-09-26 18:31:52 +00:00
GeorgeandThomas Göttgens cb4efd8f41 Classify 0x55 as BQ27220 on the T-Lora Pager (#11961)
* Classify 0x55 as BQ27220 on the T-Lora Pager

The scanner tells the T-Deck keyboard and the BQ27220 fuel gauge apart
at 0x55 by reading register 0x04: nonzero means BQ27220, zero means
TDECKKB. A Pager gauge that needs a configuration reset reads zero, so
it was detected as a T-Deck keyboard. firstKeyboard() prefers TDECKKB
over TCA8418KB, so the cardKB thread polled the gauge every 300 ms and
fed its bytes to the UI as keystrokes, producing phantom input and
unsent-by-user messages.

The Pager always has the BQ27220 at 0x55 and the TCA8418 keyboard at
0x34, so skip the heuristic there and classify 0x55 as BQ27220.

Fixes #11959

* Classify 0x55 as BQ27220 on every HAS_BQ27220 board

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-09-25 20:15:53 +00:00
Thomas Göttgens 09e514602e feat(variants): add L1 Pro 1W battery curve and brightness levels (#11972)
- Add OCV_ARRAY for the L1 Pro 1W battery.
- Let variants override the brightness menu levels via
  SCREEN_BRIGHTNESS_LEVEL_{MEDIUM,HIGH,VERY_HIGH}; defaults unchanged.
- Set L1 Pro 1W brightness levels to 100/160/255.
- Drop USE_SSD1306 from the L1 Pro 1W variant; USE_SH1106 is set in
  configuration.h.
2026-09-25 20:10:14 +02:00
James Rich 20284840a8 Report the ack proof verdict to the client (#11965)
* Report the ack proof verdict to the client

* Test that a proven ack reaches the phone as VALID

* Say why the verdict clear and the pending gate sit where they do
2026-09-25 13:33:58 +00:00
57937c5ccc Fix NTP time detection on OpenWrt Portduino (#11919)
* Fix NTP time detection on OpenWrt Portduino

* chore: trunk fmt

---------

Co-authored-by: stm32repo <orangepimaster@gmail.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-09-25 12:04:40 +00:00
DoctorRFer c57da9a09d fix(logging): don't route USE_SEGGER LOG_* through SEGGER_RTT_printf (#11970)
SEGGER_RTT_printf() only supports %c %d %u %x %X %s %p. A %f is
skipped without consuming its double from the va_list, so a later %s
reads part of that double as a pointer and HardFaults.

On CanaryOne (USE_SEGGER defined in variant.h) this resets the device
on every packet retransmission, via PacketHistory's
"Reusing slot aged %.3fs TRACE %s" log line. It also means CanaryOne
emits no logs over USB or the API, only over RTT.

RedirectablePrint::write() already mirrors every character to RTT
when USE_SEGGER is set, so drop the SEGGER-specific LOG_* macros and
use the normal logging path: full printf support, the existing log
semaphore, and RTT output preserved.

Tested on CanaryOne (v2.7.26.54e0d8d + this change, USE_SEGGER still
enabled): unacknowledged want_ack sends now retransmit twice and NAK
(err=5) without a reset, and logs reach USB/API again.
2026-09-25 11:09:19 +00:00
Tom f90b48ea6c chore: trunk fmt --all (#11938)
Whitespace and comment-alignment only: the output of `trunk fmt --all` on
develop @ d49cf21c3 with the pinned clang-format@20.1.0 and prettier. 48
files had drifted; trunk-action only checks the files a PR touches, so the
drift never fails CI and instead lands as noise in the next PR to edit any of
them. No code change.
2026-09-24 22:41:37 +00:00
Jonathan Bennett 608ff51c86 Update L2 TCXO value (#11960) 2026-09-24 12:45:11 +00:00
rcarteraz 12db3b6dc1 Declare custom_meshtastic_has_mui for Heltec V4 R8 (#11957)
Matches the heltec-v4 env. Fixes #11956.
2026-09-24 07:40:13 +00:00
95906609db Sign the whole Data envelope, in one unambiguous layout (#11422)
* Bind request_id and reply_id into the XEdDSA signing buffer

A signed reply can be re-pointed at a different message today. The client sets
reply_id on an outgoing text to make a tapback, firmware signs the resulting
broadcast, but reply_id lives in the Data envelope rather than the payload the
signature covers - and channel crypto is AES-CTR with no MAC, so anyone holding
the PSK can rewrite it in flight and the signature still verifies. request_id
has the same shape and is bound with it.

Packets carrying neither field keep the existing [from|id|portnum|payload]
layout, byte-identical to what v2.8.0 alphas are signing today, so the bulk of
signed traffic - broadcasts - stays verifiable in both directions across the
upgrade. Only packets that actually carry one of the two fields use the extended
layout. Both sides pick the layout from the packet's own decoded fields, so
nothing is transmitted to select it.

Open question for review, deliberately not decided here: a format/version byte
in the buffer would be cleaner than a conditional layout, because the safety
argument for the conditional form has to be re-derived whenever a portnum is
added. It costs nothing on the wire since the buffer is never transmitted, but
it changes every signature and so breaks verification against the alphas that
are already signing. If we want it, better done once and before 2.8.0 leaves
alpha.

Tests: request_id/reply_id flips alongside the existing from/id/portnum negative
cases; a hand-built alpha-format signature that must still verify, and must not
be reinterpretable as the extended layout or vice versa; and receive-path cases
for a retargeted tapback, a retargeted response, and an ordinary signed
broadcast that must be unaffected.

* Sign the whole Data envelope, in one unambiguous layout

Replaces the conditional two-layout signing buffer with a single fixed one:

  version(1) | from(4) | id(4) | to(4) | portnum(4) | request_id(4)
            | reply_id(4) | emoji(4) | bitfield(4) | flags(1) | payload(N)

The conditional scheme was ambiguous. Base was header || arbitrary payload, so
any byte string the extended layout emitted was also a legal base payload: an
attacker could move eight payload bytes into request_id/reply_id and truncate a
signed message while its signature still verified. No marker placed only in the
extended layout fixes that, because base can always reproduce it. A fixed-length
header does - the payload boundary is total - XEDDSA_SIGNED_HEADER_LEN and never
depends on content.

Binding reply_id alone was also not enough, because the fields around it are just
as malleable:

  emoji         a reaction is a text packet with the emoji in the payload,
                reply_id naming the parent, and this flag telling the client to
                render it as a reaction. Flipping it turns a signed reply into a
                signed reaction, so it has to travel with reply_id.
  bitfield      bit 0 is OK_TO_MQTT, the sender's consent to upload to a public
                broker, and the exploitable direction is the one that leaks. The
                whole uint32 is signed so bits 2..31 are covered in advance, and
                presence is signed separately so stripping it is not the same as
                sending it zero.
  want_response bit 1 of bitfield mirrors it and Router merges the two with |=,
                so signing either alone protects neither.
  to            without it a signed broadcast can be re-addressed as a direct
                message and still verify, delivering a public statement as an
                apparent private one. Relays rewrite hop_limit, next_hop and
                relay_node, never `to`.

Left out: dest and source (one write in the tree, no readers), channel (the wire
carries a hash where the decoded packet carries an index), and the hop fields,
which relays rewrite by design. Signing the encoded Data wholesale is not an
option either - a relay that holds the channel key decodes and re-encodes it, so
byte fidelity is lost and unknown fields are stripped. The ack_proof excision
trick does not transfer for the same reason: Routing survives because it rides
inside the opaque payload, which Data itself does not.

sign/verify now take the Data rather than a field list, so adding to the covered
set cannot silently miss a call site. Integers are explicitly little-endian, as
in ackProofCompute. The buffer is sized from the schema's own maximum payload
rather than from what the fits-on-air gate currently admits, because an overflow
makes buildSigningBuffer return 0 and signing fail with no error; MAX_BLOCKSIZE
is left alone so the AES-CTR guard and scratch buffers sharing it are unaffected.

This changes every signature, so it is not compatible with the v2.8.0 alphas. It
is deliberately being done while 2.8.0 is still prerelease: a verify failure
against an authoritative key is an unconditional drop, so the same change after
2.8.0 goes stable would split signed traffic across versions.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-09-24 07:26:37 +00:00
Jonathan BennettandClaude Opus 5 57bdedf324 Bind the ack proof to the node we addressed, not the ack's sender (#11932)
* Bind the ack proof to the node we addressed, not the ack's sender

The MAC proves only that its author holds a pairwise key with us, and every
keyed peer holds one. ackProofVerify looks the key up by getFrom(p) - the ack's
claimed sender - and nothing compared that against the node we actually sent to,
so C, whose authoritative key we hold, could read our packet id out of the
cleartext header and mint a receipt for a packet that went to B. It verified
VALID. "An authenticated delivery receipt from the actual recipient" was not
what the code delivered.

Guard on orig->packet->to before verifying. Since that establishes
getFrom(p) == orig->packet->to, the existing key lookup is then correct and
AckProof.cpp is untouched.

Bailing out rather than verifying against the recipient key and reporting
INVALID is deliberate twice over: it skips the X25519 an attacker would
otherwise choose when we pay, and a third-party ack is "not a receipt" rather
than "a forged receipt" - naks from intermediates (NO_CHANNEL,
PKI_UNKNOWN_PUBKEY, MAX_RETRANSMIT) legitimately come from a node that is not
the destination and must not be logged as proof mismatches. A broadcast original
has no single recipient, so there is nothing to bind to.

Also correct the header doc. It claimed channel (non-PKI) traffic gets nothing
from this, but isProvableAck tests only the ack's shape: a DM that travelled
under channel encryption still gets a proven ack when we hold the peer's key,
because the secret comes from X25519 rather than from the channel. That is more
coverage than the receipt needs and it costs one X25519 per ack generated - both
worth stating rather than implying the opposite.

test_proof_from_third_peer_fails_under_recipient_key pins the property the guard
relies on: Carol's proof for a packet Alice sent to Bob verifies under Carol's
key and fails under Bob's. The guard itself is not directly assertable while
every branch of the verdict switch returns true.

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

* Make the sender check honor ACK_PROOF_ENFORCE

The mismatch branch returned true unconditionally. Today every branch of this
function returns true, so that reads as equivalent - but if enforcement is ever
switched on it is a hole rather than a no-op. The sender field is not
authenticated, so an attacker would simply address the ack from anyone other
than the node we sent to, take the early return, and skip the proof requirement
entirely.

Hold only success acks to it. A nak legitimately arrives from an intermediate
rather than from the destination - NO_CHANNEL, PKI_UNKNOWN_PUBKEY and
MAX_RETRANSMIT all do - so naks keep today's behavior in either mode.

Broadcast gets its own unconditional return rather than sharing the condition.
orig->packet->to is NODENUM_BROADCAST there and can never equal any sender, so
folding it into the sender check would, under enforcement, stop every reliable
broadcast from ever being acked.

This does not make enforcement sound on its own and the comment says so: ABSENT
still permits, so spoofing the sender and omitting the proof gets through
regardless, and ABSENT cannot be made to block for the reasons recorded at
ACK_PROOF_ENFORCE. The narrower point is that a flag named "enforce" should not
have a branch that silently ignores it.

Raised by CodeRabbit on #11932. Its reading - that this is a live authorization
bypass a third party can use to suppress retransmissions - does not hold: the
sender field is unauthenticated either way, and perhapsGenerateImplicitAckForOwn
Overheard already clears a pending retransmission on a replayed copy of our own
ciphertext, with no ack and no key involved. The structural point stands on its
own merits.

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

* Avoid cppcheck's duplicateValueTernary in the enforce check

`return isAck ? !ACK_PROOF_ENFORCE : true` has the same value in both arms
while the flag is off, which is the whole point of the line - and is exactly
what cppcheck reports:

  style: Same value in both branches of ternary operator. [duplicateValueTernary]

That failed the `check` matrix on every platform. Write it as an if, which
expresses the same thing and does not trip the rule, and say so in a comment so
it does not get folded back into a ternary.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-24 07:15:54 +00:00
Ben Meadors 8ef996f5b4 fix(radio): never read a RadioLib error code as a packet's time on air (#11940)
* fix(radio): never read a RadioLib error code as a packet's time on air

getTimeOnAir() reads the packet type back over SPI on every chip and answers RADIOLIB_ERR_WRONG_MODEM when the chip is no longer in the mode we left it in (a reset or brownout leaves it in FSK). RadioLib returns times as an unsigned microsecond count and its negative int16_t error codes through that same value, so computePacketTime() divided -20 by 1000 and handed 4294967ms up as the time one packet spent on the air.

That single sample is enough to take the node off the air until it is rebooted:

  - channelUtilizationPercent() reads 7158% and utilizationTXPercent() 119%,
    matching the 7000%+ ChUtil spike and the 120% AirUtil in #11935, so both TX
    gates stay shut for the hour it takes that bucket to age out;
  - receiveDetected() uses the same figure as its false-header timeout, so
    activeReceiveStart is never cleared and canSendImmediately() sees a busy
    channel for 72 minutes.

Classify the returned value instead of trusting it. A failed getTimeOnAir() now falls back to calculateTimeOnAir() on the modem parameters we configured - arithmetic with no readback that can fail - and the RX path, which already used calculateTimeOnAir(), is guarded the same way.

Fixes #11935

* style: trunk fmt RadioLibInterface.h
2026-09-23 10:43:34 +00:00
b1470cd719 fix(heltec): sleep the T1 and T096 panels on screen-off to stop image retention (#11894)
* fix(heltec-t1): sleep the panel on screen-off to stop image retention

The T1 is excluded from the LovyanGFX sleep()/wakeup() calls because it uses
TFT_eSPI, so DISPLAYOFF only dropped the backlight and the ST7735 kept driving
the last frame unlit for the whole screen-off timeout. That constant static
image is what burns ghost pixels into the panel.

Add opt-in TFT_SLEEP_WHEN_OFF for the TFT_eSPI path: DISPOFF + SLPIN on
screen-off, SLPOUT (120 ms) + DISPON on wake, issued as raw MIPI DCS since
TFT_eSPI exposes no sleep API. Frame memory survives sleep-in, so the previous
frame reappears and the dirty-window diff continues unchanged. The sleep flag
keeps the double displayOn() in Screen::handleSetOn() from paying the delay
twice.

* fix(heltec-t096): sleep the panel on screen-off to stop image retention

The T096 sits behind the same TFT_eSPI exclusion as the T1, so DISPLAYOFF only
dropped its backlight while the ST7735S kept driving the last frame unlit for
the whole screen-off timeout. Same panel, same bus, same burn-in.

Opt in to TFT_SLEEP_WHEN_OFF; the TFTDisplay side of the fix is already generic.

* fix(tft): harden the TFT_SLEEP_WHEN_OFF wake/sleep sequence

Drive VTFT_CTRL LOW before SLPOUT, so the rail is up before the panel is
addressed. Wait out the remainder of the 120 ms the controller needs after
SLPIN before sending SLPOUT, so a wake landing as the screen timeout fires is
not dropped. Guard DISPLAYOFF on panelAsleep to match DISPLAYON.

Correct the T1 VTFT_CTRL comment: LOW enables the rail, not HIGH.

* fix(tft): wait out only what is left of the sleep-in window before SLPOUT

Throttle::isWithinTimespanMs() is true for the whole 120 ms after SLPIN, so the
wake path paid a fresh 120 ms on top of however much had already elapsed. A wake
119 ms after the SLPIN waited ~120 ms rather than ~1 ms, up to 119 ms of
avoidable latency on every quick off/on.

Add Throttle::remainingMs(), which returns what is left of the interval and
saturates at 0 instead of underflowing to a ~49 day wait. It reads the clock
once, so a caller that tests and then waits cannot be preempted between the two
and land on that underflow - which a separate isWithinTimespanMs() plus
subtraction at the call site could.

heltec-mesh-node-t1 and -t096 both build; neither is board_level = pr, so CI
does not compile this path. Docker native suite green, 1515/1515.

* trunk

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-09-23 09:10:38 +00:00
Tom 08cd97ea2d test(harness): make run-tests.sh drivable by a caller that cannot see the terminal (#11862)
* test(harness): make run-tests.sh drivable by a caller that cannot see the terminal

A tool call or a fresh session reads a captured output file, gets interrupted
mid-run, and starts cold. Two things in the wrapper tripped that caller: an
interrupt left pio's build tree running with no record of it (the next
invocation started a second build into the same .pio/build/, or pgrep'd and
matched itself); and pio's own "[PASSED]" / "N succeeded" lines made a
half-finished output file read as green.

run-tests.sh:
- Run record in .pio/runtests/current.tsv for the life of a run. A second
  invocation prints RESULT: BUSY and exits 4 without touching the build
  directory. Valid while the holder pid OR the recorded process group is
  alive, so a SIGKILLed wrapper with live scons children reads ORPHANED
  rather than clear.
- --status / --wait / --abort. --abort kills the whole tree by pgid.
- pio runs under setsid with its pgid recorded; INT/TERM/HUP kill the tree
  and record RESULT: ABORTED (exit 5), log kept.
- One result() for every verdict: prints to the stdout the script started
  with (a signal can arrive inside a redirected pio call) and writes
  .pio/runtests/last-result.tsv with head, args, env, finish time, kept log
  and a tree fingerprint (HEAD + working-tree diff + untracked files).
  --status marks the last verdict STALE when the tree has changed since.
- Banner naming the final RESULT: line as the only verdict.
- Non-Linux host: RESULT: UNSUPPORTED, exit 6, instead of the AMBER code.
- A failed build removes .pio/build/<env>/meshtasticd, which run bare would
  reprint the last good run.
- FILTERED lists the not-run count, not 77 suite names.

bin/run-tests.cmd forwards into WSL with the exit code passed through, so the
same command line works from cmd.exe and PowerShell; no logic is duplicated.

test/README.md, copilot-instructions.md and the mirrors document the new
codes and the rule.

* test(harness): one-suite warm-up, and name the build phase instead of freezing the counter

Measured on a full native run: the warm-up, `pio test --without-testing`
with no filter, builds AND links every suite - 78 links, 2320 s, 29.7 s
each, 39 minutes before the first test ran - and prints a "[PASSED]" line
for each program it merely linked. CI never did this; its warm-up is one
`platformio run`. The shared src objects are the same whichever suite links
them, so the warm-up now links one: the filtered suite when there is one,
else test_utf8. The run itself still builds every suite, as it must.

The heartbeat counted objects newer than its marker, which sits still through
PlatformIO's single-threaded scons dependency scan and through each link -
twelve minutes at "430/754 objs, ETA 17m" on that run, which reads as a hung
build to a caller who cannot run ps. It now names the phase from the
processes in the recorded group: [scons] / [compile] (with the ETA) / [link]
/ [test], and --status prints the same phase word.

* test(harness): define phase_of_run before --status can call it

* test(harness): bind --wait to the run it observed; serialize the run-record publish

Review findings on #11862, all three valid:

- --wait stored a state and then waited for any record to clear, so a run
  that finished and a second that started between polls would be followed to
  the second's verdict, and a run that turned ORPHANED mid-wait could print a
  stale last-result. Each run now has an id (pid-start) in current.tsv and
  last-result.tsv; --wait captures it and reports only a matching verdict,
  else ABORTED-without-verdict.
- run_state() then the current.tsv write was a check-then-act pair: two
  invocations in the same instant could both see IDLE. The pair is now one
  critical section under a short-lived flock, and both records are published
  by rename so no reader can see a partial file. The record stays the
  ownership token; the lock only serializes the handoff (a SIGKILLed holder
  releases flock but not the record, which is why flock alone was rejected).
- The FILTERED and AMBER examples in copilot-instructions.md carried a
  literal suite count, which the same document says never to do.

Verified on a live run: --status RUNNING, a concurrent start refused BUSY, a
--wait started before --abort reported the aborted run's own verdict with the
matching id, exit 5; no build process survived.

* fix(waypoints): do not create an empty store file on clear

clearAllWaypoints() wrote a two-byte empty store unconditionally, so test_waypoint_expiry left
Waypoints_default.wpts behind in every run and the suite has read AMBER (undeclared shared state)
since it landed. An existing file - stale or unreadable included - is still rewritten as empty, so
a reset after a failed load clears flash as before; a file that is not there is left not there.
2026-09-22 11:10:53 +00:00
Austin 43479aa4e1 Actions: exclude mutable action tag rule from Semgrep scans (#11944)
We are not pedantic enough to pin Actions versions to a SHA.
Re-enable semgrep scans on previously-ignored .github/workflows and disable
yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
2026-09-21 19:09:28 +00:00
Austin bf2ef8f2d6 Actions: Fix setup-python v7 version pinning (#11942)
We pin official actions to their major-version floating tag, we don't care about minor-revs here.
2026-09-21 14:56:30 +00:00
github-actions[bot]andjp-bennett 9fe0cd8c19 Update protobufs (#11936)
Co-authored-by: jp-bennett <5630967+jp-bennett@users.noreply.github.com>
2026-09-21 06:41:35 -05:00
Tom f49cc46476 docs(agents): list nRF54 as a platform, drop the phantom nRF52833/nRF52832 (#11865)
* docs(agents): list nRF54L15 as a platform, drop the phantom nRF52833/nRF52832

The supported-platform list named nRF52833 and the warm-tier section
excluded "bare nRF52832"; neither part exists anywhere in the tree. The
nRF54L15 (ARCH_NRF54L on the nRF52 platform layer, out-of-tree
meshtastic/platform-nordicnrf54 with the s145 SoftDevice core) does,
with xiao_nrf54l15_lr2021 as the per-PR canary, and was missing.

The warm tier's real exclusion is MEM_CLASS_TINY (STM32WL only); the
nRF54L15 falls into MEM_CLASS_SMALL and keeps its warm records in
/prefs/warm.dat, not the nRF52840 raw-flash ring.

* docs(copilot-instructions): nRF54 has its own platform layer; warm.dat only where WARM_NODE_COUNT > 0

Matches #11867: src/platform/nrf54 (architecture.h, main-nrf54.cpp), env base nrf54_base in
variants/nrf54l15/nrf54.ini, sharing only NRF52Bluetooth.cpp, Nrf52SaadcLock.cpp, alloc.cpp and
hardfault.cpp with src/platform/nrf52. The persistence line named the condition instead of
"everywhere else", which read as including STM32WL, which has no warm tier.

* docs(copilot-instructions): the platform is nRF54; no chip qualifier

* docs(copilot-instructions): state the warm-tier gate before the exception

The section opened with the arch condition folded into a parenthetical
beside STM32WL - "On every arch except STM32WL (`WARM_NODE_COUNT > 0`;
the only `MEM_CLASS_TINY` part)" - which reads as though STM32WL is the
part with the tier, contradicting the bullets below it.

Lead with the rule instead. The gate is the memory class, not the part:
mesh-pb-constants.h zeroes WARM_NODE_COUNT under MESHTASTIC_MEM_CLASS <=
MEM_CLASS_TINY, and STM32WL is merely the only part in that class today,
so naming the part as the rule would go stale the moment another tiny
board lands. Saying it once up front also lets the Persistence bullet
drop its own copy of the caveat.

* ci: run the PR matrix on docs-only PRs so ci-gate reports

ci-gate is the required status check for develop, and it lives in this
workflow, whose pull_request trigger ignored "**.md". A PR that touches only
Markdown never runs the workflow, never reports ci-gate, and stays BLOCKED
with every other check green - which is what this PR has been for a week.

Drop the paths-ignore on pull_request only. push keeps its filter: nothing
gates on the post-merge run. A docs-only PR now costs one --level pr matrix,
which is rare enough to be cheaper than an unmergeable class of PRs.
2026-09-21 10:05:25 +00:00