Commit Graph
121 Commits
Author SHA1 Message Date
Jonathan BennettandClaude Opus 5 315ec0e4ed DMShell: an on-board test client for the remote shell (#12111)
* DMShell: an on-board test client for the remote shell

A DMShell session is normally driven by bin/dmshell_client.py over the API,
which puts the test's own traffic on the serial port it is logging. This
builds the client into the firmware instead: -DDMSHELL_TEST_PEER=0x<node>
gives the board a client that opens a session to that node and types a
workload itself, so a run needs no host and the serial port stays in
logging mode for its whole length.

It speaks the same protocol as the Python client, on the sequence and
replay bookkeeping already in DMShellRecovery.h, and keeps the same
per-session counters so the two can be compared directly.

Unset, nothing is built and nothing is linked: the flag guards both source
files and the one hook in Modules.cpp. DMSHELL_TEST_* set the workload (the
command, keystroke interval, session length and count, how keystrokes are
batched into frames, the input window). DMSHELL_TEST_SLOT_PARITY, when set,
stamps slot_parity on every packet it sends.

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

* DMShellTest: require PKI on the peer's frames

The test client accepted any decoded packet on the remote shell port whose
`from` matched its peer. A channel-encrypted packet can claim any `from`, so
a forged CLOSED with the live session id would end a session and a forged
`ack_seq` would move the send window. The server already refuses a frame
without PKI; the client now does the same.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 03:34:00 +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
Thomas Göttgens 427ed0f1a0 Load optional modules dropped into src/modules/optional/ (#11673)
* Load optional modules dropped into src/modules/optional/

bin/optional-modules.py scans src/modules/optional/ for a directory <Name>/ holding <Name>.h and generates $BUILD_DIR/OptionalModules.h with an include and a setup<Name>() call for each, which Modules.cpp picks up through __has_include. The directory does not exist in a stock checkout, so a stock build generates a header that defines nothing, OPTIONAL_MODULES_SETUP compiles away, and nothing is registered. Sources under the directory are already covered by the default recursive build_src_filter, so dropping a module in needs no platformio.ini edit.

* Address review: skip a module directory that is not a usable identifier

The directory name becomes a setup<Name>() call, so foo-bar/ would have generated setupfoo-bar() and failed to compile with the error pointing at generated code rather than at the directory. Names that cannot form an identifier are now skipped with a message that names the directory.
2026-09-01 11:13:30 +00:00
cd6ac90f7e Add waypoint & geofence support with notifications for BaseUI and InkHUD (#10920)
* Implement GeofenceModule for waypoint crossing notifications and integrate with existing modules

* Waypoint Applet Initial Support on InkHUD

* undo tile change

* Update screen when Waypoint shows or dissapears

* Merge branch 'develop' into waypoint-geofence

* Geofence on InkHUD

* Update MapTile.h

* Update WaypointStore.cpp

* Notifications

* remove GF from waypoint screen

* Prevent Focus from closing the notifiaction banner

* Trunk fix

* cleanup

* undo merge conflix mistake

* Waypoint screen on BaseUI

* Focus preserve fix

* UI bugs

* Allow Inkhud to remove waypoint

* Respect Locked Waypoints

* Trunk fix

* Update WaypointStore.cpp

* Use 8-digit hex formatting for waypoint IDs.

0x%x was inconsistent with the repo's own convention (0x%08x for 32-bit IDs, used elsewhere in this file). Fixed here and in two other spots I found with the same issue (WaypointModule.cpp, GeofenceModule.cpp).

* Update ExternalNotificationModule.cpp

* Reject invalid surrogate codepoints in waypoint icon rendering

* Update WaypointModule.cpp

* Update WaypointStore.cpp

* Update WaypointStore.cpp

* Update WaypointStore.cpp

* trunk fix

* fix warnings

* power.h rename to Power.h

* Update Power.h

* Fix executable bit on bin/lint-ifdef-complexity.sh

Lost during a prior merge from develop (Windows checkout doesn't
preserve file mode), causing "execve failed: Permission denied" in
the Trunk Check Runner CI job. develop has this file at 100755;
restoring that here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update README.md

* Clean up waypoint and geofence integration

* Minimize waypoint and geofence implementation

* removed unnecessary gating

* Geofence alert

* trunk fix

* Update test_main.cpp

* Update WaypointStore.cpp

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 16:17:28 +00:00
TomandBen Meadors 7e11bde8c8 fix(beacon): repair the MeshBeacon radio switch/restore regression from #11573 (#11596)
* fix(radio): put the beacon restore back inside completeSending's if (p)

Reverts the RadioLibInterface and RadioInterface changes from #11573
(ac330e6a6). Hoisting MeshBeaconModule::reconfigureForBeaconTX() out of the
if (p) block changed its meaning from "a send completed" to "the radio went to
standby, for any reason" - and every driver's setStandby() calls
completeSending() unconditionally: on the pre-TX LBT scan, on startReceive(),
and inside reconfigure().

Two shipping faults followed, both confirmed on hardware the next day.

Every beacon transmitted on the wrong preset. isChannelActive() standbys the
radio immediately before each transmit, so the restore ran between the switch
and the key-up. The packet went out carrying the beacon channel hash with home
modem settings - inaudible to listeners on the target preset, an unknown hash
to listeners on the home one. Inert in both directions.

And unbounded recursion: the restore calls iface->reconfigure(), which
standbys, which calls completeSending(), which restores again, each level
running a full applyModemConfig(). It terminated in a HardFault and a silent
reboot (Reset reason 0x4 on nRF52, no panic output). The crash masked the
misdirection - the node died before Started Tx, so the wrong preset was
invisible until the recursion was fixed.

completeSending() clears sendingPacket at the top, so any nested call sees
p == NULL. The if (p) block was an accidental re-entrancy guard, and nothing
named it as such; removing it created both faults at once. Name it now.

This also reverts the beginSending() failure return that motivated the move,
and the startSend() scaffolding built to reach the restore on that path. The
payload bounds check it replaced is reinstated in the next commit, at a point
where refusing a packet is already a supported outcome.

* fix(radio): bound the payload at the radio queue, not mid-transmit

#11573 replaced beginSending()'s assert with a runtime check that logged,
released the packet and returned 0. beginSending() had never returned 0
before, so startSend() gained a failure path it had to unwind - and the
release moved ownership of the packet out of the caller that held it. That
new return value is what made hoisting the beacon restore look necessary.

The check itself is worth keeping. MeshPacket.encrypted has a nanopb maximum
of 256 bytes against a 240-byte radio buffer, and beginSending() is on the
path for relayed frames and phone-sourced packets, neither under our control.
Asserts are commonly compiled out in release builds, so what shipped was an
unchecked 256-into-240 memcpy driven by remote input.

Move it to Router::send(), immediately before iface->send(p) - the single
funnel for every over-the-air transmit. Refusing a packet there is already a
supported outcome: it returns TOO_LARGE, which is what perhapsEncode() already
returns for the same condition on the decoded path, and releases or NAKs
exactly as the duty-cycle limit above it does. Nothing radio-side has happened
at that point, so there is no half-started transmit to tear back down.

perhapsEncode()'s existing check does not cover this case: relayed and
phone-sourced frames arrive already encrypted and never reach it.

beginSending() keeps a last line of defence, but clamps rather than failing,
so it stays a call that always succeeds. Adds MAX_RADIO_PAYLOAD_LEN so both
sites name the same number instead of recomputing it.

Nothing about a beacon can trigger any of this - broadcast_message is
admin-truncated to 100 bytes, the whole MeshBeacon protobuf tops out at 180,
and observed beacons run to 106 - which is why this is separated from the
beacon changes rather than carried with them.

Tests: Router::send() refuses an oversized payload and still sends one that
exactly fills the buffer; beginSending() clamps instead of rejecting, and
leaves ordinary traffic whole.

* fix(beacon): guard the radio switch/restore against re-entry and early restore

Two checks in reconfigureForBeaconTX(), both independent of radio state, so
the switch/restore state machine no longer rests on sendingPacket's lifetime -
which is exactly the implicit coupling that let #11573 through.

A re-entrancy guard. Both branches end in iface->reconfigure(), whose
setStandby() runs completeSending(), which calls straight back in here. While
one call is applying a config, a nested call returns false and leaves it
alone. This covers the switch branch too, which had the same exposure with a
quieter symptom: a second switch before the restore would take the re-entrant
call as a restore and undo the switch still being applied, sending the beacon
on the home channel instead of its target.

A restore gate. The restore now waits for the packet that armed the switch to
actually finish, tracked by id against our own target table rather than by
asking the radio. Every caller that completes or abandons a beacon clears that
packet's target settings first, so a live entry means the TX has not happened
yet. cancelSending() now clears too, which is what keeps a cancelled beacon
from pinning the radio on the beacon config.

Together these make explicit the invariant completeSending()'s if (p) block
was carrying by accident: a future hoist of that call gets a logged no-op
instead of a crash and a misdirected beacon.

Also sets radioSwitched before reconfigure() rather than after, in both
branches, so the flag never describes a radio state that is not yet true.

Diagnostics, because every step of this dance was previously silent about its
own state. Count consecutive switches with no restore between them and log the
depth on both sides, so a change-change-change-restore run reads off the log;
switch #2 onwards prints the held home snapshot, which is the value that has
to survive a second switch. The restore names the config it is restoring to,
so a stale snapshot is visible directly. The re-entrancy guard logs when it
fires - expected exactly twice per beacon, so a burst means something new is
re-entering rather than a silent reboot. And setTargetRadioSettings() now
warns on the slot eviction that previously left a packet to key up on whatever
config was running - no crash, no log, wrong channel.

Reachable only with beacon broadcast enabled (the default flags are
LISTEN_ENABLED | LEGACY_SPLIT, so broadcast is off) and a target differing
from the running config; an identical target takes the early return and never
switches.

Tests: three re-entrancy cases against a RadioInterface whose reconfigure()
re-enters exactly as completeSending() does - bounded, so a regression fails
an assertion instead of overflowing the stack and taking the runner with it -
plus a restore that must defer until the beacon it switched for completes.

* fix(beacon,radio): address review findings on #11596

Payload ceiling was one byte too generous. RadioBuffer::payload is 240 bytes
because the buffer reserves MAX_LORA_PAYLOAD_LEN + 1, but the PHY caps a whole
frame at 255 and beginSending() adds a 16-byte header - so a 240-byte payload
produced a 256-byte frame. Define the ceiling as MAX_LORA_PAYLOAD_LEN -
sizeof(PacketHeader), matching what perhapsEncode() already enforces, with a
static_assert that it still fits the buffer.

Target-table eviction could unblock the restore gate. With every slot live,
setTargetRadioSettings() overwrote slot 0 - and if that slot held the packet the
outstanding switch is gated on, the restore came unblocked and put the home
config back under a beacon that had not keyed up. Skip that entry when choosing
a victim, and refuse the target outright if every slot is in flight. Needs
radioSwitched/switchedForId at file scope so the setter can see them.

Restore on every abandon path, not just the clear. cancelSending() dropped a
queued packet's target without restoring, so a beacon pre-switched by onNotify()
and then cancelled left the radio receiving on the beacon config;
removePendingTXPacket() did neither. Both now route through
abandonBeaconTarget(), as does startSend()'s tx-disabled branch. The restore
gate makes it a no-op when the abandoned packet is not the one we switched for.

No NAK on the oversize drop. p->channel is a wire hash by that point, not an
index, and Channels::getIndexByHash() is declared but never defined. Only
already-encrypted ingress can reach the gate anyway - perhapsEncode() bounds
everything it encodes - and those carry no index to answer on. Release and log.

Tests clear sendingPacket before releasing their packet, and assert against the
payload ceiling rather than the buffer size.

* fix(beacon): route the invalid-target drop through abandonBeaconTarget

onNotify()'s invalid-config drop was the one packet-abandonment path still
clearing the target directly instead of going through abandonBeaconTarget(),
so a packet that armed the radio switch and then failed validation would be
released with the radio left on the beacon config and nothing to restore it.
The helper's restore gate (targetRadioSettingsLive(switchedForId)) makes the
call a no-op for any packet that did not arm the switch, so this closes the
gap without risking a premature restore.

Also trims the switch-state comment to the two-line limit.

* fix(radio): take the abandoned packet as a pointer to const

cppcheck's constParameterPointer failed the check matrix on every board:
abandonBeaconTarget() only forwards the packet to clearTargetRadioSettings(),
which already takes a const pointer, so the parameter should be const too.

* refactor(radio): drive the beacon radio switch through TX hooks

RadioLibInterface named MeshBeaconModule at six call sites behind
MESHTASTIC_EXCLUDE_BEACON guards, so the driver carried per-packet beacon
state: when to switch preset, when a target config was invalid mid-transmit,
and when not to listen on a busy channel. Review on #11596 asked for the
module dependency to come out.

RadioTxHook is what the driver knows instead - beforeTransmit() returning
send/defer/drop, holdsRadio(), packetReleased() - on a self-registering
intrusive list, so nothing is allocated and a build without the beacon module
registers nothing and every call is a no-op. The four abandon paths (cancel,
remove-pending, TX disabled, completeSending) collapse onto one
packetReleased(), and the tri-state means the driver no longer has to know why
a packet wanted a re-delay or a drop.

MeshBeaconTxHook wraps the existing statics; the switch/restore logic, its
re-entrancy guard and its restore gate are untouched. It is created in
Modules.cpp inside the existing exclusion guard, so MESHTASTIC_EXCLUDE_BEACON
now works by nothing registering rather than by #ifdefs in the driver.

Behaviour is unchanged. The invalid-config LOG_DEBUG moves into the module and
the driver logs a generic refusal. Four tests cover the send/defer/drop mapping
and that an empty hook list is a no-op; native:test_mesh_beacon is 59/59.

Also notes in sendBeaconPacket that beacons uplink to MQTT on the primary
slot's uplink_enabled, and that the topic follows the beacon channel under the
crypto-override swap - both intentional.

* fix(beacon): restore the home config for a packet that jumps the queue

The restore gate added in 9cb7b96c9 refused to put the home config back while
the beacon that armed the switch was still live. That is right for a release -
completeSending() runs on every setStandby(), and restoring there would undo
the switch before the beacon had keyed up - but it also caught the case where
the driver is asking about a different packet it is about to transmit.

MeshPacketQueue::enqueue() inserts by priority (std::upper_bound over
CompareMeshPacketFunc), so an ACK or routing packet queued during the beacon's
deferred transmit delay lands ahead of it. beforeTransmit() then saw an
untagged packet, found the beacon still queued, skipped the restore and
returned PRETX_SEND - and the packet transmitted on the beacon's preset, slot
and region. It was encrypted and hashed for the home channel, so no receiver
on either preset could use it.

Apply the gate only to a null p. A non-null untagged packet is the driver
about to key up, which always restores; the restore returns PRETX_DEFER, so
the driver re-runs the delay and the channel scan on the config it will
actually transmit on. beforeTransmit() is the only caller that passes a
non-null untagged packet, so nothing else changes.

Found by CodeRabbit on #11596. native:test_mesh_beacon 60/60, including a
regression test for the queue transition; the four re-entrancy tests still
cover the null-p gate.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-25 19:29:34 +00:00
Andrew YongandThomas Göttgens 93d15a5368 Add AS3935 lightning sensor support (#10931)
* Add AS3935 lightning sensor support

Implements meshtastic/firmware#10774: an AS3935Sensor (TelemetrySensor
subclass) that reports lightning_strike_count_1h and lightning_distance_km
on the normal environment telemetry interval, like a rain gauge -
strikes are counted over a fixed rolling ~1h window and read
non-destructively, so replying to a peer's telemetry request in
between broadcasts can't silently drop counted strikes.

The AS3935's IRQ pin (opt-in per board via AS3935_IRQ) is polled with a
plain digitalRead() in runOnce(), deliberately not attachInterrupt():
the IRQ line is a level that stays asserted until its interrupt
register is read, so polling can't miss an event regardless of timing,
matching the SparkFun library's own reference examples. An interrupt
would also buy nothing here even setting that aside - classification
requires an I2C read (readInterruptReg(), which itself calls delay(2)
per the datasheet's settle-time requirement), and blocking I2C/delay()
calls aren't safe from ISR context on any of this codebase's target
platforms, so the ISR could only ever set a flag for later draining -
no less work than just polling the pin directly on the next tick.

A genuine lightning classification also requests an immediate
out-of-cycle send via a new EnvironmentTelemetryModule::
requestImmediateSend() hook. There's no fixed debounce on the request
itself - EnvironmentTelemetryModule's existing airtime/duty-cycle gate
already paces every send, so it sends as often as airtime allows rather
than an arbitrary fixed rate. The request does expire after 5 minutes
unfulfilled, so it can't fire an arbitrarily stale broadcast if airtime
was blocked for a long stretch.

The AS3935's I2C addresses (0x01-0x03) fall inside the range this
codebase's I2C scanner otherwise skips as reserved, so detection is a
small dedicated probe gated behind AS3935_IRQ and respecting the
caller's address filter, rather than a change to the general scan
loop. Presence is confirmed via a register write/readback round-trip
rather than a fixed expected value, since the AS3935 has no WHOAMI
register and a power-on-reset-only check can't survive a warm reboot
that doesn't power-cycle the sensor (initDevice() permanently rewrites
that register on first configuration).

Generated files under src/mesh/generated/ are intentionally excluded
from this commit - they're regenerated from the protobufs submodule by
update_protobufs.yml, and hand edits get overwritten and conflict once
the companion protobufs PR merges and the submodule pointer updates.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

* fix(as3935): calibration and telemetry logging

initDevice() never called the library's calibrateOsc(). The AS3935's
internal oscillators are calibrated against the antenna's resonance,
which the AFE/watchdog/spike-rejection thresholds depend on; without
it, only a directly-driven IRQ pin (bypassing detection entirely)
reacted during testing.

The sensor could already have a historical detection event latching
the IRQ pin high before our initialization. Added an explicit drain
read after the IRQ pin is configured, so the sensor doesn't start out
stuck asserting IRQ.

EnvironmentTelemetryModule::sendTelemetry() logs every other
environment metric category on send but was missing lightning; added
a matching log line.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

* Support AS3935 without an IRQ line, make the antenna trim configurable

Detection no longer requires AS3935_IRQ. The probe is gated like the
other environmental sensors, so an I2C-only breakout is found on any
board. Where AS3935_IRQ is defined the pin still gates the I2C read,
otherwise runOnce() polls the interrupt register, which latches until
read.

Antenna tuning capacitance moves to
AdminMessage.sensor_config.as3935_config, persisted to /prefs/as3935.dat
and defaulting to 96pF. The chip does not retain it across power loss.

Disturbers are masked in the chip, since runOnce() now polls every
second. The lightning telemetry log is guarded so nodes without the
sensor no longer log it on every send.

Requires meshtastic/protobufs#981.

* Revert protobufs pointer to the develop baseline

The submodule bump conflicts on merge and the generated headers come
from an out of band CI job, so the pointer moves with that job rather
than in this branch.

* Report lightning strikes over a true rolling hour

strikeCountWindow was zeroed on a fixed interval, so
lightning_strike_count_1h reported strikes since the last reset rather
than over the preceding hour.

RollingCounter is a fixed memory sliding window: one counter per bucket,
nothing stored per event, so a storm cannot grow it. The ring holds one
bucket more than the window needs so none is recycled while part of it
is still inside, and the oldest bucket contributes only the fraction
still in range. Both are needed to hold the span at exactly the window
length rather than letting it drift by a bucket either way.

Expiry is exact to one bucket rather than to the event, which is below
the 5 minute floor on mesh telemetry sends.

The distance expires with the last strike in the window instead of on
the interval reset. Covered by test/test_rolling_counter.

* Widen the RollingCounter edge weighting to 64 bit

counts * inWindow is a 32 bit product, so a bucket holding more than
2^32 / BucketMs events wraps. At a 5 minute width that is about 14k: a
bucket of 50000 reported 11367 instead of 40000 once it reached the
window edge.

Below the threshold nothing changes, so lightning was unaffected, but
the helper is meant to be reused by counters with far higher rates.

test_large_burst_at_window_edge covers it. The existing burst test
sampled only inside the window, where the bucket is whole and never
weighted.

* Trim RollingCounter comments to the house limit

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-08-19 10:17:02 +00:00
8d1fbbf55f Snake! (#10936)
* Snake!

* Add spiLock to snake score saving

* Check fixes

* More careful locking

* WIP: Big Display Node

* Update src/graphics/HUB75Display.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Add HUB75 Native

* Add Tetris game module with core logic and UI integration

- Implement TetrisGame class for game logic, including piece movement, rotation, and line clearing.
- Create TetrisModule class to manage game state, input handling, and rendering on OLED display.
- Introduce high score tracking with persistence and optional mesh broadcasting.
- Define UI states for title, playing, paused, game over, and high scores.
- Implement input handling for game controls and state transitions.
- Add rendering functions for the game board, high scores, and title screen.

* feat(snake): add snake graphics and update display logic in SnakeModule

* Prompt for Initials for Tetris, too

* refactor games module

* Games refactor

* hub75 native double buffer

* Games tuning

* Make joystick repeat events on held button

* Add clouds and colors

* Fix breakout and more color

* difficulty tuning

* trunk

* refactor game announcements, etc

* Scale chirpy gravity as game speed increases

* Portduino, check for hub75 display before reading in hub75 options

* Final(?) games tuning

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Properly ignore input when games screen not shown

* Fail gracefully when HUB75 is selected but not supported.

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Ixitxachitl <kramerfm@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-16 18:35:33 -05:00
ec5d230305 Feat/mesh beacon (#10618)
* Tips robot virtual node / relayer to different LoRa modes & channels

Note that this commit has details hardcoded for the Wellington (NZ)
mesh, and also requires the following patch to the protobufs:

-----
diff --git a/meshtastic/mesh.proto b/meshtastic/mesh.proto
index 03162d8..ec54c99 100644
--- a/meshtastic/mesh.proto
+++ b/meshtastic/mesh.proto
@@ -1393,6 +1393,21 @@ message MeshPacket {
    * Set by the firmware internally, clients are not supposed to set this.
    */
   uint32 tx_after = 20;
+
+  /*
+   * The modem preset to use fo rthis packet
+   */
+  uint32 modem_preset = 21;
+
+  /*
+   * The frequency slot to use for this packet
+   */
+  uint32 frequency_slot = 22;
+
+  /*
+   * Whether the packet has a nonstandard radio config
+   */
+  bool nonstandard_radio_config = 23;
 }

 /*
-----

* fix: repair mesh tips CI build

* feat: add MeshBeacon module (Phase 1 — proto + generated code + initial stub)

* feat(beacon): implement broadcaster + listener (phases 2-5)

* feat(beacon): wire RadioLibInterface hooks + admin validation (phases 6-7)

* fix(beacon): fix LocalModuleConfig flat access (no payload_variant), add localonly proto field

* feat(beacon): fix broadcaster inheritance, add preset/region validation + proto cache

- MeshBeaconBroadcastModule now inherits ProtobufModule<meshtastic_MeshBeacon>
  (alongside private MeshBeaconModule + OSThread), giving it allocDataPacket()
  and setStartDelay() without extra includes.

- Payload cache: rebuildCache() encodes the MeshBeacon protobuf once and stores
  it in payloadCache[]/payloadCacheSize; sendBeacon() only calls rebuildCache()
  when payloadCacheDirty==true. AdminModule calls invalidateCache() after saving
  new config so the next broadcast picks up changes.

- Region/preset validation in handleSetModuleConfig (mesh_beacon_tag):
  broadcast_on_preset is validated against the device's current region via
  RadioInterface::validateConfigLora(); broadcast_offer_region is validated via
  RadioInterface::validateConfigRegion(). Invalid values are zeroed with a
  LOG_WARN before saving.

* feat(beacon): add unit tests for MeshBeaconModule and AdminModule configuration validation

* remove old meshtips

* more  validation in NodeDB and AdminModule, and userprefs for baked in goodness

* copilot is my gravity

* mmmmm... beacon

* oops

* Enhance unit tests for MeshBeaconModule with detailed validation checks and output formatting

* new lines. Why not?

* finally

* legacy mode activate!

* Update protobufs (#17)

Co-authored-by: NomDeTom <116762865+NomDeTom@users.noreply.github.com>

* better logic, fixed a test

* updated for packet signing
fixed a test
added guards for licensed/ham mode

* channel numbers

* beacon: encrypt on the beacon channel PSK; fix split note

When broadcast_on_channel overrides the primary channel's name/PSK, the
beacon was encrypted with the PRIMARY PSK: perhapsEncode keys encryption
off the primary slot, but the radio-thread channel switch happens only
after encryption. sendBeaconPacket() now installs the beacon channel into
the primary slot for the synchronous duration of send() (cooperative
threading => no interleaving) so encryption/hash use the beacon channel,
then restores it. A shared beaconChannelSettings() helper builds the
channel for both the encrypt-time swap and the RF-time swap so the
key+hash cannot drift.

Also: correct the legacy-split comments (both packets go out on the same
beacon radio settings, not the normal config) and merge the two
consecutive `if (hasText)` blocks in the listener (cppcheck
duplicateCondition).

Tests: add channelPskOverride_swapsBeaconChannelAndRestores and
noChannelOverride_doesNotSwapPrimary; MockRouter snapshots the primary
channel at send() time.

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

* test/beacon: drain toPhoneQueue in tearDown to fix LSan leak abort

The listener delivers received text via MeshService::sendToPhone(), which
enqueues the packet into toPhoneQueue and takes ownership. Nothing dequeues
it in tests, so the three listener tests carrying message text stranded a
MeshPacket each — 1272 bytes / 3 allocations that LeakSanitizer flagged at
process exit, aborting the coverage run (surfaced by pio as [ERRORED] /
SIGHUP even though all 40 assertions passed).

Drain the phone queue in tearDown (getForPhone()/releaseToPool) so the
packets return to packetPool. Suite is now GREEN with no sanitizer abort.

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

* legacy hop override for zero-hoppers

* ever more beacons

* beacon: comment out broadcast_send_as_node pending further review

Functionality preserved in comments with full signing/has_bitfield notes
for when it is re-enabled. Proto tag 3 retained on the wire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test/beacon: fix fromIsCustomNodeWhenSet now that send-as-node is disabled

broadcast_send_as_node is commented out; from is always the local node.
Update the test assertion and doc comment to match current behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update protobufs (#21)

Co-authored-by: NomDeTom <116762865+NomDeTom@users.noreply.github.com>

* flags for beacons

* beacon: do more with less — slot-index targets + validation

Multi-target beacons embedded a full ChannelSettings in every BroadcastTarget,
blowing ModuleConfig past the 512-byte BLE FromRadio budget so the firmware would
not compile. Targets now reference an existing channel-table slot by channel_index
and the broadcaster resolves it via channels.getByIndex() at TX time. Net effect:
the same multi-target capability for a fraction of the bytes —
FromRadio 609 -> 510 B, MeshBeaconConfig 596 -> 324 B, AdminMessage 615 -> 511 B.

- proto: BroadcastTarget.channel (embedded) -> channel_index (uint32 ref); regen all
  generated headers (size constants propagate to admin/localonly/deviceonly/mesh).
- broadcaster: resolve channel_index from the channel table; an out-of-range or blank
  slot falls back to the default channel for the target preset rather than borrowing
  the primary's name/PSK.
- AdminModule: validate broadcast_targets entries on write (region/preset sanitised
  like the single-target fields; channel_index range-checked).
- userPrefs: TARGET_<n>_CHANNEL_{NAME,NUM,PSK} collapse to a single CHANNEL_INDEX.
- docs: two-step (set_channel -> set_module_config) multi-target setup, inline-vs-
  reference distinction, and single-/multi-target are equal (not "legacy") options.
- tests: target validation + channel-index resolution incl. blank-slot fallback
  (47/47 green on `./bin/run-tests.sh -e native -f test_mesh_beacon`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRAF5csgsMn6p1zEcFL8Qz

* throttling after reboot

* address copilot review

* simplify

* fix(beacon): use 0x%08x for node/packet IDs in logs; register test suite

The %#08lx log specifiers passed uint32_t (NodeNum/PacketId) to a %lx
length modifier — undefined behaviour on 64-bit (native test) targets and
non-standard width. Switch to the project-standard 0x%08x. Also bump
test/native-suite-count to 25 for the added test_mesh_beacon suite.

clod helped too

* copilot & clarity
clod helped too

* refactor(beacon): use auto for the sanitized config copy

clod helped too

* fix(beacon): guard empty-payload sends; gate has_mesh_beacon on build flag; document ISR_TX pre-switch

clod helped too

---------

Co-authored-by: Steve Gilberd <steve@erayd.net>
Co-authored-by: Darafei Praliaskouski <me@komzpa.net>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 08:20:51 -05:00
TomandClaude Sonnet 4.6 c51c01607d Traffic Management module: dedup, rate limiting, role-aware policing (#10706)
Adds the Traffic Management module (TMM) plus the NodeDB/warm-store and
next-hop foundations it builds on:

- Unified per-node cache (flat array, 8-bit relative ticks) shared by all
  features; role-aware throttles for tracker / lost-and-found.
- Position deduplication: drop unchanged position rebroadcasts within a
  configurable interval; precision driven off the channel ceiling (clamped to
  the public-key max on well-known channels). Enabled by default at 11h.
- Per-node rate limiting and unknown-packet filtering (config-driven; a
  non-zero companion field enables each feature -- no bool toggles).
- NodeInfo direct response from cache with role-based hop clamps.
- Persistent next-hop overflow store: confirmed hops have no TTL, are seeded
  from NodeInfoLite at boot, and survive hot-store eviction.
- Three-tier sender-role resolution (hot NodeInfoLite -> warm store -> TMM
  cache). Role is cached write-time (seeded on first track, refreshed from
  NodeInfo), pins its cache entry like a next-hop hint, and is evicted last.
- Warm store caches device role + protected category across reboot/eviction.
- PositionModule stationary floor for tracker / lost-and-found.
- PSRAM gating for warm/satellite/TMM cache sizes; STM32WL excluded.

Protobufs: TrafficManagementConfig trimmed to the five uint32 fields actually
used; submodule repointed to protobufs develop.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:35:14 -05:00
TomandCopilot de345939af Automatic variable hop limits based on mesh activity and size estimation (#10176)
* asdf

* Implement SphereOfInfluenceModule for traffic management and eviction tracking

* Implement Sphere of Influence module for dynamic hop limit adjustment and role-based floor

* Update SAMPLING_DENOMINATOR to improve mesh size estimation accuracy

* Add debug logging for scale factor estimation and per-hop node counts in SphereOfInfluenceModule

* Enable variable hop limits and role-based hop floors in Sphere of Influence module

* Respond to copilot review

* Disable variable hop limits and role-based hop floors in Sphere of Influence module

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Implement adaptive sampling for unique node ID tracking in Sphere of Influence module

* Add state persistence for Sphere of Influence module

* Enhance Sphere of Influence module with state management and adaptive sampling adjustments

* Refactor hop scaling functionality: remove SphereOfInfluenceModule and introduce HopScalingModule

- Deleted SphereOfInfluenceModule.h, consolidating its responsibilities into the new HopScalingModule.
- Added HopScalingModule.h and HopScalingModule.cpp to manage hop scaling logic, including eviction tracking and sampling-based mesh size estimation.
- Implemented methods for recording evictions and packet senders, estimating scale factors, and computing required hops based on node activity.
- Introduced state persistence for hop scaling parameters to maintain continuity across reboots.
- Enhanced thread safety and modularity by utilizing concurrency features.

* Guard out STM32. Sowwy.

* Refactor HopScalingModule: enhance sampling logic and improve state management

* Add unit tests for HopScalingModule: implement mock database and various test scenarios

* Refactor test output in HopScalingModule tests: replace printf with TEST_MESSAGE for better integration with Unity

* Refactor HopScalingModule logging: replace lastStatusMode with descriptive mode names for improved readability

* Refactor test_main.cpp: change NodeNum variable to static and improve comments for clarity

* Remove unnecessary delay in setup function for improved test performance

* Add missing include for MeshTypes in test_main.cpp

* Refactor HopScalingModule tests: enhance mesh topology scenarios and improve test clarity

* Update HopScalingModule tests: flesh out node scenarios and improve clarity for dense and sparse mesh cases

* Fix politeness factor calculations in HopScalingModule and update related test scenarios for clarity. Remove outdated design doc.

* Enhance HopScalingModule: add sampled estimate for scaling decisions and refactor initial run state management

* Add sample traffic injection for HopScaling tests to enhance sampledEst visibility

* Enhance HopScalingModule: adjust windowFraction calculation for early triggers and improve test output formatting

* Enhance HopScalingModule: add jitter functionality to sampling denominator and update tests for consistent behavior

* Enhance HopScalingModule: implement adaptive sampling denominator adjustment and add reset functionality for tests

* Enhance HopScalingTestShim: add test-only clock and window helpers, update injectSampleTraffic for adaptive sampling, and improve scenario summary output

* Enhance HopScalingModule: add detailed documentation for functions, improve clarity of jitter and sampling logic, and reset functionality in tests

* Enhance HopScalingModule: add evictionEstimate parameter to estimateScaleFactor and update related logging for improved mesh size estimation

* Enhance HopScalingModule: adjust effective rolls calculation for improved accuracy, add eviction estimate logic, responding to all copilot review points

* Implement CompactHistogram for parallel hop scaling sampling

- Added CompactHistogram class to track node hop distances with bitwise sampling.
- Integrated CompactHistogram into HopScalingModule for independent packet sampling.
- Updated NodeDB to feed both the hop scaling module and the new histogram sampler.
- Enhanced HopScalingModule with methods to sample packets for the histogram and retrieve hop distribution statistics.
- Implemented tests for CompactHistogram functionality, including sampling, window rolling, and adaptive denominator scaling.
- Updated existing tests to validate the integration of the new histogram sampling mechanism.

* Enhance CompactHistogram and HopScalingModule: add per-hop distribution functionality, improve time handling for unit tests, and refine test setup for deterministic behavior

* CompactHistogram: add mesh size estimation, improve entry replacement logic, and update logging for per-hop distribution

* Refactor HopScalingModule and CompactHistogram integration

- Removed the suggestedHopFromCompactHistogram function to streamline hop suggestion logic.
- Updated HopScalingModule to directly utilize CompactHistogram's internal methods for hop suggestions and sampling.
- Enhanced logging in HopScalingModule to provide detailed histogram statistics.
- Modified test cases to ensure comprehensive coverage of new histogram behaviors and sampling logic.
- Improved node ID distribution in tests to better exercise sampling mechanisms.
- Ensured that filtering denominators are held for 12 hours before dropping, enhancing stability in sampling.

* Refactor CompactHistogram to support 13-hour activity tracking and introduce politeness regimes

- Updated the bitfield structure to accommodate 13-hour seen tracking.
- Changed the logic in rollHour() to analyze activity over the last 0-2 hours vs. 1-3 hours for politeness factor calculation.
- Introduced three politeness levels: GENEROUS, DEFAULT, and STRICT based on recent activity ratios.
- Adjusted filtering and sampling logic to reflect the new 13-hour tracking period.
- Updated unit tests to validate new behavior and ensure proper functionality of politeness regimes.

* Enhance CompactHistogram and HopScalingModule for improved sampling and decision-making

- Introduced a session-specific hash seed in CompactHistogram to reduce bias in node ID sampling.
- Updated sampling logic to use hashed node IDs instead of raw IDs for filtering and entry management.
- Added histogram rollover tracking in HopScalingModule to ensure proper decision-making after initial data collection.
- Adjusted logging to reflect the active state of the histogram and its comparison with NodeDB advisory hops.
- Enhanced unit tests to validate new sampling logic and memory layout changes.

* Expose hashNodeId for testing in CompactHistogram

* Add mesh trend statistics to CompactHistogram for enhanced activity tracking

* Implement histogram state persistence in CompactHistogram with save and load functions

* Refactor CompactHistogram to improve entry management and enhance rollHour logging

* feat: add HopScalingModule for adaptive hop limit recommendations

Introduces HopScalingModule, a sampled hop-distance histogram that
recommends the minimum hop limit needed to reach ~40 nodes, and
automatically reducing the hops as the mesh grows.

Key design:
- 512-byte packed histogram (128 × 4-byte Record entries) embedded
   in a new HopScalingModule.
- Each Record: 16-bit node hash, 3-bit hop distance, 13-bit seen bitmap
- Sampling filter: only nodes where (hash & (denom-1)) == 0 are kept;
  denominator doubles on overflow and halves when utilisation is low
- Hourly rollHour(): tallies per-hop counts, walks scaled buckets to
  find the minimum hop satisfying TARGET_AFFECTED_NODES (40), applies a
  politeness extension based on recent/older activity ratio, shifts all
  seen bitmaps, and persists state to /prefs/hopScalingState.bin
- Hop recommendation gated by bootstrap (requires >=1 rollHour before
  overriding HOP_MAX)
- NodeDB calls samplePacketForHistogram() on every non-MQTT rx packet
- Module also estimates total mesh size and logs useful information about
  mesh characteristics.

Changes:
- src/modules/HopScalingModule.h/.cpp: new module
- src/mesh/NodeDB.cpp: wire up samplePacketForHistogram
- src/mesh/Router.cpp: consume getLastRequiredHop()
- test/test_hop_scaling/: 12-test suite covering all mesh topologies and
  anticipated operational requirements

* test: increase run iterations in sparse to dense transition test

* feat: refactor HopScalingModule to use RUNS_PER_HOUR constant and improve logging

* feat: enhance HopScalingModule with filtering denominator management and add tests for state transitions

* refactor: remove CompactHistogram module and related files

* address copilot review comments

* Tweak: packet sampling only lora

* ove role-based hop floor logic and related definitions into the module - keep it in one place.

* Refactor MockNodeDB to use nodeInfoLiteSetBit for MQTT flag setting

* Refactor hop scaling parameters and logic to integer maths and put default values in defaults.h. Small flash size reduction, no functional impact.

* Update unit test preprocessor directives to PIO_UNIT_TESTING for consistency

* refactor: improve test output organization and clarity in hop scaling tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-06-04 05:59:49 -05:00
Clive BlackledgeandCopilot 016e68ec53 Traffic Management Module for packet forwarding logic (#9358)
* Add ESP32 Power Management lessons learned document

Documents our experimentation with ESP-IDF DFS and why it doesn't
work well for Meshtastic (RTOS locks, BLE locks, USB issues).

Proposes simpler alternative: manual setCpuFrequencyMhz() control
with explicit triggers for when to go fast vs slow.

* Addition of traffic management module

* Fixing compile issues, but may still need to update protobufs.

* Fixing log2Floor in cuckoo hash function

* Adding support for traffic management in PhoneAPI.

* Making router_preserve_hops work without checking if the previous hop was a router. Also works for CLIENT_BASE.

* Adding station-g2 and portduino varients to be able to use this module.

* Spoofing from address for nodeinfo cache

* Changing name and behavior for zero_hop_telemetry / zero_hop_position

* Name change for exhausting telemetry packets and setting hop_limit to 1 so it will be 0 when sent.

* Updated hop logic, including exhaustRequested flag to bypass some checks later in the code.

* Reducing memory on nrf52 nodes further to 12 bytes per entry, 12KB total using 8 bit hashes with 0.4% collision. Probably ok. Adding portduino to the platforms that don't need to worry about memory as much.

* Fixing hopsAway for nodeinfo responses.

* traffic_management.nodeinfo_direct_response_min_hops -> traffic_management.nodeinfo_direct_response_max_hops

* Removing dry run mode

* Updates to UnifiedCacheEntry to use a common cache, created defaults for some values, reduced a couple bytes per entry by using a resolution-scale time selection based on configuration value.

* Enhance traffic management logging and configuration. Updated log messages in NextHopRouter and Router to include more context. Adjusted traffic management configuration checks in AdminModule and improved cache handling in TrafficManagementModule. Ensured consistent enabling of traffic management across various variants.

* Implement destructor for TrafficManagementModule and improve cache allocation handling. The destructor ensures proper deallocation of cache memory based on its allocation source (PSRAM or heap). Additionally, updated cache allocation logic to log warnings only when PSRAM allocation fails.

* Update TrafficManagementModule with enhanced comments for clarity and improve cache handling logic. Update protobuf submodule to latest commit.

* Creating consistent log messages

* Remove docs/ESP32_Power_Management.md from traffic_module

* Add unit tests for Traffic Management Module functionality

* Fixing compile issues, but may still need to update protobufs.

* Adding support for traffic management in PhoneAPI.

* Making router_preserve_hops work without checking if the previous hop was a router. Also works for CLIENT_BASE.

* Enhance traffic management logging and configuration. Updated log messages in NextHopRouter and Router to include more context. Adjusted traffic management configuration checks in AdminModule and improved cache handling in TrafficManagementModule. Ensured consistent enabling of traffic management across various variants.

* Implement destructor for TrafficManagementModule and improve cache allocation handling. The destructor ensures proper deallocation of cache memory based on its allocation source (PSRAM or heap). Additionally, updated cache allocation logic to log warnings only when PSRAM allocation fails.

* Update TrafficManagementModule with enhanced comments for clarity and improve cache handling logic. Update protobuf submodule to latest commit.

* Add mock classes and unit tests for Traffic Management Module functionality.

* Refactor setup and loop functions in test_main.cpp to include extern "C" linkage

* Update comment to include reduced  memory requirements

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Re-arranging comments for programmers with the attention span of less than 5 lines of code.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update comments in TrafficManagementModule to reflect changes in timestamp epoch handling and memory optimization details.

* bug: Use node-wide config_ok_to_mqtt setting for cached NodeInfo replies.

* Better way to handle clearing the ok_to_mqtt bit

* Add bucketing to cuckoo hashing, allowing for 95% occupied rate before major eviction problems.

* Extend nodeinfo cache for psram devices.

* Refactor traffic management to make hop exhaustion packet-scoped. Nice catch.

* Implement better position precision sanitization in TrafficManagementModule.

* Added logic in TrafficManagementModule to invalidate stale traffic state. Also, added some tests to avoid future me from creating a regression here.

* Fixing tests for native

* Enhance TrafficManagementModule to improve NodeInfo response handling and position deduplication logic. Added tests to ensure local packets bypass transit filters and that NodeInfo requests correctly update the requester information in the cache. Updated deduplication checks to prevent dropping valid position packets under certain conditions.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 06:12:12 -05:00
oscgonfer 417ca86c30 Split module includes for AQ module (#9711) 2026-02-22 21:07:32 -06:00
Jonathan Bennett 2361776992 Rename LED_PIN to LED_POWER, move handling out of main to dedicated module (#9512)
* Rename LED_PIN to LED_POWER, move handling out of main to dedicated module

* Misc

* Remove errant endif
2026-02-05 05:41:00 -06:00
Jason P bfc3eebd54 HotFix for ReplyBot - Modules.cpp included and moved configuration.h (#9532) 2026-02-04 08:56:50 -06:00
Jonathan Bennett 9d06c1bf34 Add StatusMessage module and config overrides (#9351)
* Add StatusMessage module and config overrides

* Trunk

* Don't reboot node simply for a StatusMessage config update
2026-01-31 14:55:51 -06:00
Quency-DandBen Meadors b2f2f6b305 Add a watchdog module to meshsolar. (#9337)
* add watchdog module

* Restore the code in power.h

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-01-28 20:41:27 -06:00
Jonathan Bennett 6ab2f02dbc re-add unintentionally removed include 2026-01-28 17:53:12 -06:00
1d219a93ab Move input init to an init function in InputBroker (#9463)
* Move input init to an init function in iInputBroker

* Unbreak targets with EXCLUDE_INPUTBROKER

* Update src/input/InputBroker.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix conditional compilation for input broker

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Trunk

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-28 14:58:05 -06:00
oscgonfer 5a81403594 Move PMSA003I to separate class and update AQ telemetry (#7190) 2026-01-14 13:00:08 -06:00
Jonathan Bennett fba92229a6 Add I2C device check for seesaw device on native (#8927)
It turns out the logic here was attempting to access i2c without being told to do so. Not good, especially on desktops.
2025-12-10 18:01:52 -06:00
WilsonandCopilot eb087849c0 OnScreenKeyboard Improvement with Joystick and UpDown Encoder (#8379)
* Add mesh/Default.h include.

* Reflacter OnScreenKeyBoard Module, do not interrupt keyboard when new message comes.

* feat: Add long press scrolling for Joystick and upDown Encoder on baseUI frames and menus.

* refactor: Clean up code formatting and improve readability in Screen and OnScreenKeyboardModule

* Fix navigation on UpDownEncoder, default was RotaryEncoder while bringing the T_LORA_PAGER

* Update src/graphics/draw/MenuHandler.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/OnScreenKeyboardModule.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/graphics/draw/NotificationRenderer.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Optimize the detection logic for repeated events of the arrow keys.

* Fixed parameter names in the OnScreenKeyboardModule::start

* Trunk fix

* Reflator OnScreenKeyboard Input checking, make it simple

* Simplify long press logic in OnScreenKeyboardModule.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-08 05:40:30 -06:00
Ben Meadors 66193e1776 Prevent double-registering of Rotary Encoder on TLora Pager (#8746)
* Reduce noise

* Prevent double registering of rotary encoder broker
2025-11-25 14:34:55 -06:00
ed4a798c60 Thinknode M3 support against master (#8630)
* Add variant_shutdown() as a week function in main-nrf52.cpp

* Add Status LED module

* Add Thinknode M3 support

* Catch case of BLE disabled

* Update src/modules/StatusLEDModule.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/StatusLEDModule.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update variants/nrf52840/ELECROW-ThinkNode-M3/rfswitch.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove unused pin

* M3 pairing LED only active for 30 seconds after state change

* Thinknode M3 shutdown work

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-24 16:35:54 -06:00
Austin Lane f72a4c50bd Don't use IS_ONE_OF when loading Modules 2025-10-03 17:14:00 -04:00
Ben Meadors a15d654767 Finish deprecating the Repeater role behavior (#8144)
* Finish deprecating the Repeater role behavior

* Validate

* Fixed bad if/else block

* Get your crap together!
2025-09-28 15:30:53 -05:00
Ben Meadors e1485b530f Handle ext. notification module things even if not enabled (#8089) 2025-09-22 19:59:05 -05:00
Markus 44968415a5 fix build with HAS_TELEMETRY 0 (#8051) 2025-09-20 06:34:47 -05:00
71d84404c6 add WIP for Unit C6L (#7433)
* add WIP for Unit C6L
* adapt to new config structure
* Add c6l BLE and screen support (#7991)
* Minor c6l fix
* Move out of PRIVATE_HW
---------
Co-authored-by: Austin <vidplace7@gmail.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Jason P <Xaositek@users.noreply.github.com>
Co-authored-by: Markus <Links2004@users.noreply.github.com>
2025-09-17 22:40:55 +02:00
Ben Meadors f267b5f5f7 Exclude trackball if we aren't a trackball device 2025-09-09 11:15:55 -05:00
Ben Meadors 0cd860e300 RangeTest must be enabled 2025-09-09 10:53:18 -05:00
Ben Meadors 31fdb36987 Detection sensor add module only when enabled 2025-09-09 10:46:33 -05:00
Ben Meadors 803e96800e ATAK module should be disabled for non-TAK roles (#7928) 2025-09-08 17:21:55 -05:00
Chloe Bethel 0bd4cefad3 Make ExternalNotification show up in excluded_modules, more STM32 modules (#7797)
* Show ExternalNotification as excluded if it is

* Enable ExternalNotification, SerialModule and RangeTest on STM32WL

* Misc fixes for #7797 - ARCH_STM32 -> ARCH_STM32WL, use less flash by dropping weather station support for serialmodule, set tx/rx pins before begin

* Enable Serial1 on RAK3172, make SerialModule use it (console is on LPUART1)

* Fix SerialModule on RAK3172, fix board definition of RAK3172 to include the right pin mapping.
2025-09-02 07:09:15 -05:00
3f5c30e3b3 T-Lora Pager (#7613)
* initial commit

* preset rotary1 encoder

* define TAB+ESC

* haptic feedback

* allow switch off haptic feedback

* enable audio amplifier

* include PR4684

* fix for tft target

* add ES8311 audio codec

* fix KB scan duplicate

* display workaround to avoid debris

* fix debris on display

* keyboard backlight

* enable screen options

* fsm based bounce-free rotary encoder implementation

* use fsm RotaryEncoder only for T-Lora Pager

* change inputbroker default config to allow using rotary wheel for screens AND menues

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2025-08-26 09:35:25 -05:00
Jonathan BennettandBen Meadors cb47325f08 Seesaw Rotary (#7310)
* Initial add of Adafruit seesaw encoder

* Fully wire up seesaw

* Trunk

* Add #include configuration.h back to unbreak logging

* Tryfix the dumb compilation error

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2025-07-12 12:36:44 -05:00
Chloe Bethel d494c23a88 Enable telemetry and I2C sensors on STM32WL (except accelerometers) (#7008)
* Update platformio inis for stm32 platform and wio-e5 variant for enabling i2c

* Don't reference timezone functions if MESHTASTIC_EXCLUDE_TZ is defined

* Use custom pow_of_two in RadioInterface instead of floating-point pow()

* First pass: enable sensors for STM32wL

* Fix AirQualityTelemetryModule being created if the PM25AQI header is missing

* Link in power sensor libraries

* more ini tweaks

* Add =1 to EXCLUDE defines, fix indentation.

* Drop HAS_WIRE in ini, it's defined in architecture.h

* Fix build when power sensor libraries are missing

Make MAX sensor integration into Power.cpp optional based on its library header existing.
Also make NullSensor expose a voltage and current sensor, because Power calls directly into these for INA sensors.
This lets us remove all the deps for the STM32WL platform.

* Change default I2C for RAK3172 to be I2C1, not I2C2

* Respect the laws of mathematics (oops)
2025-07-02 06:01:45 -05:00
4feaec651f Unify the native display config between legacy display and MUI (#6838)
* Add missed include

* Another Warning fix

* Add another HAS_SCREEN

* Namespace fixes

* Removed depricated destination types and re-factored destination screen

* Get rid of Arduino Strings

* Clean up after Copilot

* SixthLine Def, Screen Rename

Added Sixth Line Definition Screen Rename, and Automatic Line Adjustment

* Consistency is hard - fixed "Sixth"

* System Frame Updates

Adjusted line construction to ensure we fit maximum content per screen.

* Fix up notifications

* Add a couple more ifdef HAS_SCREEN lines

* Add screen->isOverlayBannerShowing()

* Don't forget the invert!

* Adjust Nodelist Center Divider

Adjust Nodelist Center Divider

* Fix variable casting

* Fix entryText variable as empty before update to fix validation

* Altitude is int32_t

* Update PowerTelemetry to have correct data type

* Fix cppcheck warnings (#6945)

* Fix cppcheck warnings

* Adjust logic in Power.cpp for power sensor

---------

Co-authored-by: Jason P <applewiz@mac.com>

* More pixel wrangling so things line up NodeList edition

* Adjust NodeList alignments and plumb some background padding for a possible title fix

* Better alignment for banner notifications

* Move title into drawCommonHeader; initial screen tested

* Fonts make spacing items difficult

* Improved beeping booping and other buzzer based feedback (#6947)

* Improved beeping booping and other buzzer based feedback

* audible button feedback (#6949)

* Refactor

---------

Co-authored-by: todd-herbert <herbert.todd@gmail.com>

* Sandpapered the corners of the notification popup

* Finalize drawCommonHeader migration

* Update Title of Favorite Node Screens

* Update node metric alignment on LoRa screen

* Update the border for popups to separate it from background

* Update PaxcounterModule.cpp with CommonHeader

* Update WiFi screen with CommonHeader and related data reflow

* It was not, in fact, pointing up

* Fix build on wismeshtap

* T-deck trackball debounce

* Fix uptime on Device Focused page to actually detail

* Update Sys screen for new uptime, add label to Freq/Chan on LoRa

* Don't display DOP any longer, make Uptime consistent

* Revert Uptime change on Favorites, Apply to Device Focused

* Label the satelite number to avoid confusion

* Boop boop boop boop

* Correct GPS positioning and string consistency across strings for GPS

* Fix GPS text alignment

* Enable canned messages by default

* Don't wake screen on new nodes

* Cannedmessage list emote support added

* Fn+e emote picker for freetext screen

* Actually block CannedInput actions while display is shown

* Add selection menu to bannerOverlay

* Off by one

* Move to unified text layouts and spacing

* Still my Fav without an "e"

* Fully remove EVENT_NODEDB_UPDATED

* Simply LoRa screen

* Make some char pointers const to fix compilation on native targets

* Update drawCompassNorth to include radius

* Fix warning

* button thread cleanup

* Pull OneButton handling from PowerFSM and add MUI switch (#6973)

* Trunk

* Onebutton Menu Support

* Add temporary clock icon

* Add gps location to fsi

* Banner message state reset

* Cast to char to satisfy compiler

* Better fast handling of input during banner

* Fix warning

* Derp

* oops

* Update ref

* Wire buzzer_mode

* remove legacy string->print()

* Only init screen if one found

* Unsigned Char

* More buttonThread cleaning

* screen.cpp button handling cleanup

* The Great Event Rename of 2025

* Fix the Radiomaster

* Missed trackball type change

* Remove unused function

* Make ButtonThread an InputBroker

* Coffee hadn't kicked in yet

* Add clock icon for Navigation Bar

* Restore clock screen definition code - whoops

* ExternalNotifications now observe inputBroker

* Clock rework (#6992)

* Move Clock bits into ClockRenderer space

* Rework clock into all device navigation

* T-Watch Actually Builds Different

* Compile fix

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>

* Add AM/PM to Digital Clock

* Flip Seconds and AM/PM on Clock Display

* Tik-tok pixels are hard

* Fix builds on Thinknode M1

* Check for GPS and don't crash

* Don't endif til the end

* Rework the OneButton thread to be much less of a mess. (#6997)

* Rework the OneButton thread to be much less of a mess. And break lots of targets temporarily

* Update src/input/ButtonThread.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix GPS toggle

* Send the shutdown event, not just the kbchar

* Honor the back button in a notificaiton popup

* Draw the right size box for popup with options

* Try to un-break all the things

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* 24-hour Clock Should have leading zero, but not 12-hour

* Fixup some compile errors

* Add intRoutine to ButtonThread init, to get more responsive user button back

* Add Timezone picker

* Fix Warning

* Optionally set the initial selection for the chooser popup

* Make back buttons work in canned messages

* Drop the wrapper classes

* LonPressTime now configurable

* Clock Frame can not longer be blank; just add valid time

* Back buttons everywhere!

* Key Verification confirm banner

* Make Elecrow M* top button a back button

* Add settings saves

* EInk responsiveness fixes

* Linux Input Fixes

* Add Native Trackball/Joystick support, and move UserButton to Input

* No Flight Stick Mode

* Send input event

* Add Channel Utilization to Device Focused frame

* Don't shift screens when we draw new ones

* Add showOverlayBanner arguments to no-op

* trunk

* Default Native trackball to NC

* Fix crash in simulator mode

* Add longLong button press

* Get the args right

* Adjust Bluetooth Pairing Screen to account for bottom navigation.

* Trackball everywhere, and unPhone buttons

* Remap visionmaster secondary button to TB_UP

* Kill ScanAndSelect

* trunk

* No longer need the canned messages input filter

* All Canned All the time

* Fix stm32 compile error regarding inputBroker

* Unify tft lineheights (#7033)

* Create variable line heights based upon SCREEN_HEIGHT

* Refactor textPositions into method -> getTextPositions

* Update SharedUIDisplay.h

---------

Co-authored-by: Jason P <applewiz@mac.com>

* Adjust top distance for larger displays

* Adjust icon sizes for larger displays

* Fix Paxcounter compile errors after code updates

* Pixel wrangling to make larger screens fit better

* Alert frame has precedence over banner -- for now

* Unify on ALT_BUTTON

* Align AM/PM to the digit, not the segment on larger displays

* Move some global pin defines into configuration.h

* Scaffolding for BMM150 9-axis gyro

* Alt button behavior

* Don't add the blank GPS frames without HAS_GPS

* EVENT_NODEDB_UPDATED has been retired

* Clean out LOG_WARN messages from debugging

* Add dismiss message function

* Minor buttonThread cleanup

* Add BMM150 support

* Clean up last warning from dev

* Simplify bmm150 init return logic

* Add option to reply to messages

* Add minimal menu upon selecting home screen

* Move Messages to slot 2, rename GPS to Position, move variables nearer functional usage in Screen.cpp

* Properly dismiss message

* T-Deck Trackball press is not user button

* Add select on favorite frame to launch cannedMessage DM

* Minor wording change

* Less capital letters

* Fix empty message check, time isn't reliable

* drop dead code

* Make UIRenderer a static class instead of namespace

* Fix the select on favorite

* Check if message is empty early and then 'return'

* Add kb_found, and show the option to launch freetype if appropriate

* Ignore impossible touchscreen touches

* Auto scroll fix

* Move linebreak after "from" for banners to maximize screen usage.

* Center "No messages to show" on Message frame

* Start consolidating buzzer behavior

* Fixed signed / unsigned warning

* Cast second parameter of max() to make some targets happy

* Cast kbchar to (char) to make arduino string happy

* Shorten the notice of "No messages"

* Add buzzer mode chooser

* Add regionPicker to Lora icon

* Reduce line spacing and reorder Position screen to resolve overlapping issues

* Update message titles, fix GPS icons, add Back options

* Leftover boops

* Remove chirp

* Make the region selection dismissable when a region is already set

* Add read-aloud functionality on messages w/ esp8266sam

* "Last Heard" is a better label

* tweak the beep

* 5 options

* properly tear down freetext upon cancel

* de-convelute canned messages just a bit

* Correct height of Mail icon in navigation bar

* Remove unused warning

* Consolidate time methods into TimeFormatters

* Oops

* Change LoRa Picker Cancel to Back

* Tweak selection characters on Banner

* Message render not scrolling on 5th line

* More fixes for message scrolling

* Remove the safety next on text overflow - we found that root cause

* Add pin definitions to fix compilation for obscure target

* Don't let the touchscreen send unitialized kbchar values

* Make virtual KB just a bit quicker

* No more double tap, swipe!

* Left is left, and Right is right

* Update horizontal lightning bolt design

* Move from solid to dashed separator for Message Frame

* Single emote feature fix

* Manually sort overlapping elements for now

* Freetext and clearer choices

* Fix ESP32 InkHUD builds on the unify-tft branch (#7087)

* Remove BaseUI branding

* Capitalization is fun

* Revert Meshtastic Boot Frame Changes

* Add ANZ_433 LoRa region to picker

* Update settings.json

---------

Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jason P <applewiz@mac.com>
Co-authored-by: todd-herbert <herbert.todd@gmail.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-21 06:36:04 -05:00
Jonathan Bennett 1ef4caea05 Host metrics (#6817)
* Add std::string exec() function to PortduinoGlue for future work

* MVP for HostMetrics module.

* Fix compilation for other targets

* Remove extra debug calls

* Big numbers don't do well as INTs.

* Add HostMetrics to config.yaml
2025-05-15 09:23:37 -05:00
TavisandBen Meadors e7ce910c3b Add generic thread module (#5484)
* compiling, untested

* use INCLUDE not EXLUDE for option to include module

* protobuf update

* working genericthread module

Update protobufs

* use EXCLUDE style instead of INCLUDE

* Update Modules.cpp

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2025-04-11 19:38:44 +02:00
848a3ed6a1 implement littlefs for stm32 (#5987)
Co-authored-by: GUVWAF <78759985+GUVWAF@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Daniel Peter Chokola <dan.chokola@gmail.com>
Co-authored-by: Mark Trevor Birss <markbirss@gmail.com>
Co-authored-by: Austin <vidplace7@gmail.com>
2025-03-21 16:12:27 +01:00
Tom Fifield 43d6b31603 TFT branch grab-bag (#5683)
Selection of minor edits from the TFT branch that are too
challenging to cherry-pick cleanly, including:
* introducing the HAS_TFT flag
* fixing pins in unphone
* Adding pinterdevice to portduino settings
2024-12-28 08:31:54 -06:00
Ben Meadors 2b33be2fea Exclude health telemetry by macro (#5679) 2024-12-28 08:49:24 +11:00
Tom Fifieldandmverch67 03770b799f Synch minor changes from TFT branch (#5520)
* Synch minor changes from TFT branch

Includes:
* New nordicnrf52 minor version (10.5.0 --> 10.6.0)
* Optimisations for T_DECK
* preparation for MESH_TAB
* add ext notification module to portduino

---------

Co-authored-by: mverch67 <manuel.verch@gmx.de>
2024-12-12 17:42:41 +01:00
Ben MeadorsandTom Fifield 411aedaf5d Add health telemetry module (#4927)
* Add stub health telemetry module

* Add detection for MAX30102 Health Sensor

It lives on I2C bus at 0x57, which conflicts with an existing
sensor. Add code to check the PARTID register for its response 0x15
per spec.

* Add detection for MLX90614

An IR Temperature sensor suitable for livestock monitoring.

* Add libraries for MLX90614 and MAX30102 sensors

* Fix Trunk

* Add support for MLX90614 IR Temperature Sensor

* Add support for MAX30102 (Temperature)

* Make it build - our first HealthTelemetry on the mesh.

If a MAX30102 is connected, its temperature will be sent to the
mesh as HealthTelemetry.

* Add spo2 and heart rate calculations to MAX30102

* Switch MLX90614 to Adafruit library

Sparkfun was having fun with SDA/SCL variables which we can avoid
by switching to this highly similar library.

* Enable HealthTelemetry if MLX90614 detected

* Change MLX90614 emissivity for human skin.

* Add health screen!

* Remove autogenerated file from branch

* Preparing for review

* Fix MeshService master sync from before.

* Prepare for review

* For the americans

* Fix native build

* Fix for devices with no screen

* Remove extra log causing issues

---------

Co-authored-by: Tom Fifield <tom@tomfifield.net>
2024-10-07 19:50:44 -05:00
GUVWAF 3492c9aa99 Construct StoreForwardModule for Portduino as well (#4903)
* Construct StoreForwardModule for Portduino as well

* Remove duplicate variables
2024-09-29 16:17:23 -05:00
Jonathan Bennett 6e1aa52723 More store-n-forward on native 2024-09-28 11:37:27 +02:00
todd-herbert e0b4a8e31e Radio Master Joystick (#4476)
* Radio Master Bandit 5-Way Joystick: first draft
Untested on genuine hardware

* "Okay" moves to next frame, even when canned message disabled

* Refactor to allow easier customization

* Implement feedback from testing
* guard toggleGPS()
* show "Shutting down.." screen
* split adhoc ping alert onto two lines

* Don't block while waiting for shutdown
Was preventing the alert from showing
2024-08-17 05:51:53 -05:00
Jonathan BennettandBen Meadors 789e8f02bf Add more exclude options to save program ram/flash (#4408)
* Add PowerFSM Exclude option

* Add TEXTMESSAGE module exclude option

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2024-08-06 18:48:55 -05:00
todd-herbert 92526fca23 "Scan and Select" input for Canned Messages (#4365)
* Add "Scan and Select" input method for canned messages

* Adapt canned message drawing if USE_EINK
* Indicate current selection with indent rather than inverse text
* Avoid large text on "sending" and delivery report pop-ups
* Fit SNR and RSSI details on screen

* Change hash function which detects changes in E-Ink images
The old function struggled to distingush between images on the canned-message frame, failing to update when scrolling between messages. No real justification for the new algorithm, other than "it works" and doesn't seem "too expensive". For context, this function runs once a second.

* Use canned messages (scan and select) by default with HT-VME213 and HT-VME290

* Guard for HAS_SCREEN
2024-08-06 17:16:56 -05:00
Ben Meadors 24ecfa1a45 Trunk fmt 2024-07-31 07:42:23 -05:00