Repository navigation
Merge upstream develop (95ff510) into no-auto-writes - #1
Merged
Merged
Conversation
* 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>
RadioLib waits up to spiConfig.timeout (1000 ms) on the chip's BUSY line before failing a command, and the firmware never set it. A chip left mid-command holds BUSY until that wait gives up, so each collision cost a whole second of a deaf radio and the frames that arrived in it. MESHTASTIC_RADIOLIB_SPI_TIMEOUT_MS (default 50; 0 keeps RadioLib's 1000 ms) is applied after begin() in the SX126x, SX128x, LR11x0 and LR20x0 init, so the boot calibration keeps RadioLib's default. Recovery re-inits run under the bound. Measured on a T1000-E (round 100, before the locking fix): the 50 ms arm removed every one-second block (21 to 0) with no re-inits, recoveries or reboots. Waits were bimodal, one 32 ms boot calibration per run and the rest at the 1000 ms ceiling, so no wait that would have completed falls between the bound and the old timeout. Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Update pioarduino to v3.3.12-1 Updates pioarduino platform and arduino-esp32 to v3.3.12, a minor bump from current v3.3.11 https://github.com/espressif/arduino-esp32/releases/tag/3.3.12 Meshtastic's fixes have been integrated upstream, so move off of our fork and back to official releases. * update indicator to 3.3.12 --------- Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
meshtastic#11930) * fix(nodedb): persist newly discovered nodes saveNodeDatabaseToDisk() is reached from the deep sleep path (sleep.cpp), the shutdown path (Power.cpp) and the classic screen's menu handler. A node that runs with power.is_power_saving disabled and a UI other than the classic screen reaches none of them, and a graceful reboot does not save either: AdminModule::reboot() only arms rebootAtMsec, and Power::reboot() notifies observers (only InkHUD listens) before ESP.restart(). nodes.proto therefore keeps whatever was written on first boot, usually just the local node, and every peer learned since is dropped on restart. Because the peer's public key lives in its node entry, PKI direct messages to that peer then fail until it is heard again: Loaded saved nodedatabase v25: 1 nodes Received a NAK, error reason: PKI_SEND_FAIL_PUBLIC_KEY Save when a node is first added, rate limited to once a minute. * fix(nodedb): keep deferred node saves pending instead of dropping them The previous commit misread the save paths: updateUser() already saves the node DB when a peer's user info changes. But it saves at most once a minute, and a change inside that window is dropped, not deferred: it logs "Defer NodeDB saveToDisk" and nothing retries it. lastNodeDbSave starts at 0, so every change in the first minute after boot is dropped, and a graceful reboot has no save point. A later save writes the whole DB and catches up, so the loss shows when nothing else changes before the restart. This removes the save on admission (nodes without user info are dropped by cleanupMeshDB() at boot anyway) and instead: - marks the node DB dirty when updateUser() records a change; - clears the flag only after nodes.proto is actually written, so a save that was skipped or deferred (no keypair yet, xmodem busy, unsafe power) stays pending; - retries a pending save from the main loop as a single attempt that shares updateUser()'s lastNodeDbSave budget, so no new write trigger is added and the worst case stays one write a minute; - saves a pending change from a notifyReboot observer on graceful reboot. Addresses both CodeRabbit findings on meshtastic#11930. * fix(nodedb): skip the deferred node save while encrypted storage is locked; test it saveNodeDatabaseIfDirty() and the reboot observer call saveNodeDatabaseToDisk() directly, so they missed the locked-storage skip in saveToDiskNoRetry() and logged a failed encrypted write every minute on a locked MESHTASTIC_ENCRYPTED_STORAGE build. Both now apply the same check. test_nodedb_save_retry pins the deferred save: a change held back by the one-minute throttle is written once the minute passes, and a reboot writes it at once. --------- Co-authored-by: marshallumsted <bot@marshallumsted.dev> Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
* Read received LoRa frames out from a FreeRTOS task The radio holds one received frame, and the next one overwrites it. Until now the frame was read by the radio thread, which runs only when the main loop gets to it, so a long hold anywhere in the loop (PKI key derivation, serial console, display) lost the frames that ended during it. On FreeRTOS targets with a DIO1 interrupt the RX_DONE interrupt now wakes a task above the main loop, pinned to the loop's core. It reads the frame with the same RadioLib calls as before (readData, getSNR, getRSSI) into an 8-frame ring, and notifies the radio thread, which delivers the frames and re-arms RX as before. The interrupt stays enabled, so frames that end back to back are all read. It works for every RadioLib radio (SX126x, SX127x, SX128x, LR11x0, LR20x0). -DMESHTASTIC_EXCLUDE_READOUT_TASK=1 turns it off. Portduino, STM32WL and LORA_DIO1_SOFTWARE_POLL boards keep the thread readout. 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 * Initialize the RX readout ring in the constructor cppcheck's uninitMemberVar failed every FreeRTOS check job: rxRing is an array of plain structs with no member initializers, so the constructor left it indeterminate. The ring is only read behind rxRingTail != rxRingHead, so nothing depended on its contents, but zeroing it costs nothing - the interfaces are globals, so the array sits in .bss either way. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H * Keep the RX readout task out of RadioLib call sequences The readout task runs above the radio thread on the same core, and the SPI lock is taken per transaction, so a readout could land between the RadioLib calls of a sequence the radio thread was partway through. Every driver's readData() ends by clearing the chip's IRQ flags, and SX128x's drops to standby first, so a readout inside a channel scan, an RX arm, a transmit setup or a reconfigure could clear flags that sequence was about to rely on, or move the chip out from under it. The scan-to-transmit window in TRANSMIT_DELAY_COMPLETED is the sharpest case: it is deliberately kept as tight as possible, and startTransmit() would run on flags a readout had just wiped. Add a recursive, priority-inheriting mutex the task and the radio thread share, held across each whole sequence: the two re-arms and the scan-to- transmit pair in onNotify(), the recovery ladder, and each driver's reconfigure(). Recursive because the sequences nest; priority-inheriting so a waiting readout lifts the thread holding it rather than queueing behind it. Packet delivery stays outside the lock - deliverCapturedFrames() makes no RadioLib calls, and holding it there would delay the next readout for nothing. This bounds a readout by the radio sequence in flight, not by the main loop, so the latency the task exists to remove stays removed. wakeRxReadout() loses its spin. It is called from inside driver RX arms, which now hold the lock, so waiting there for a task that needs the same lock would deadlock; the lock also makes the wait redundant, since the task cannot reach the radio until the arm finishes. The guard records whether it actually took the lock, because the mutex is created alongside the task from within startReceive() - a sequence already in flight at that moment has nothing to release. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H * Do not clear an unread RX_DONE when resuming a continuous RX resumeRunningReceive() cleared RX_DONE, CRC_ERR, HEADER_ERR and TIMEOUT before picking the RX back up. In continuous RX the chip keeps listening, so a second frame can complete while the first is being handled - between readData() and the resume - and that clear took its RX_DONE without anyone reading the frame. The checkRxDoneIrqFlag() on the next line could then no longer find it, so the frame was lost. The clear was there for the paths that give up on a frame without reading it, which leave the flags latched where readData() would have cleared them; a latched RX_DONE holds DIO1 high past the re-arm. Put the clear in those paths instead, so it happens only where readData() never ran: not in rx mode, a bad readback length, region unset, and readData()'s own early error returns (an Rx timeout or a failed SPI read come back before it clears). Never on the captured path, where the readout task has already read the frame and a latched flag is the next one's. With that, a latched RX_DONE at the resume is by construction a frame nobody has read, so resumeRunningReceive() stops clearing anything and its rxReadoutActive() special case goes away. Uses RadioLib's generic PhysicalLayer clearIrq() rather than the SX126x flag names, so the helper works for every radio. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H * Clear the RX flags only where readData() left them latched The previous commit cleared the terminal flags on every readData() error, which put back the bug it was meant to fix, in the case that matters most. Each driver decides a CRC or damaged-header verdict before its clear and returns it afterwards - SX126x asserts crcState one line past clearIrqStatus(), and SX127x has no early return at all - so those errors come back with the flags already gone. A CRC mismatch is the routine error on a noisy channel, so that clear fired often, and each time it could take the RX_DONE of a frame that completed while the error was being logged. Gate both on the returned code: RADIOLIB_ERR_CRC_MISMATCH and RADIOLIB_ERR_LORA_HEADER_DAMAGED mean the flags are already clear, and every other error - an Rx timeout, a failed SPI stream check or buffer read, a wrong modem - returned before the clear and leaves them to be dropped by hand. The readout task needs the same test for the opposite reason. When its own readData() fails early the flags stay latched, and the radio thread cannot clear them for a captured frame, because it has no way to tell whether the task got past RadioLib's clear. So RX_DONE stayed set for a frame nobody would ever read, and checkRxDoneIrqFlag() woke the task again for the same dead event, round after round. The task now clears its own failed reads, where the answer is known, and the ring-full path reuses the helper rather than repeating the flag list. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H * LR20x0: re-arm RX at TX_DONE from the readout task After TX_DONE the LR2021 sat in standby until the radio thread restarted RX, which a main-loop hold can stretch. Now the TX_DONE interrupt asks the readout task to restart RX through RadioLib (an interrupt cannot call it), and the radio thread adopts that RX instead of restarting it. On by default wherever the readout task runs; -DLR2021_RX_REARM_AT_TX_DONE=0 opts out. The task re-arms inside a radio sequence, and the thread's adopt runs inside the post-TX sequence, so the two cannot interleave: if the thread gets there first it takes the re-arm over. A stale re-arm from a TX that the poll completed is cleared when the next TX is configured. Measured on Jonathan's bench (Seeed Mesh-Tracker X1, LR2021, SHORT_TURBO, DMShell workload): the X1 went from ~612 post-TX deaf windows per 300 s run and a 3.7% board miss rate to better than the T1000-E (LR1110), which already re-arms this way. 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 * LR20x0: read frames that end before the radio thread adopts the re-arm The TX_DONE interrupt detaches the radio interrupt, and the task's re-arm left it detached until the radio thread adopted the RX. During a long main-loop hold the chip listened but nothing woke the readout task, so a second frame overwrote the first in the chip. The task now attaches the RX interrupt once its re-arm is in. Until the thread handles that TX_DONE, a frame the task reads notifies ISR_RX without overwriting the pending ISR_TX, which would otherwise skip completeSending() and stall the TX queue; onNotify() delivers the captured frames ahead of the ISR_TX as it already does for an overwritten ISR_RX. Found by CodeRabbit on meshtastic#11983. 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 * SX126x: re-arm RX at TX_DONE from the readout task After TX_DONE the SX126x sat in standby until the radio thread restarted RX, which a main-loop hold can stretch. Now, as on the LR20x0, the TX_DONE interrupt asks the readout task to restart RX through RadioLib (an interrupt cannot call it), and the radio thread adopts that RX instead of restarting it. The task sends the same continuous or duty-cycled RX command startReceive() would, now shared through startRxCommand(). On by default wherever the readout task runs except nRF52, where meshtastic#11984 re-arms from the TX_DONE interrupt itself; -DSX126X_RX_REARM_AT_TX_DONE=0 opts out. Measured on Jonathan's bench: the T-Beam Supreme (ESP32-S3, SX1262, SHORT_TURBO, DMShell workload) loses less with it than without, in a same-session A/B. 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 * Take the radio-sequence lock around the IRQ-status read in canSendImmediately() canSendImmediately() called isActivelyReceiving() with no lock, and that reads the chip's IRQ status over SPI in every driver - getIrqFlags() on SX126x, getIrqStatus() on LR11x0/LR20x0/SX128x, isReceiving() on RF95. The readout task can preempt the main loop between that command and its response read, leaving the chip mid-command with BUSY asserted; both then spin in RadioLib's BUSY wait until its 1000 ms timeout fires, and the radio, which holds one frame, loses whatever arrives meanwhile. Measured on a T1000-E (LR1110), 300 s sessions, two arms interleaved, 3 runs each, with this lock and the same one on the LR11x0 early TX stage (a bench-only path not on this branch): BUSY waits >=20 ms 5-13 -> 0, 1 s radio stalls 5/11/13 -> 0/0/0, frames the chip decoded but the firmware never fetched 7/16/18 -> 0/0/0, total loss 0.59/1.23/1.30% -> 0.00/0.00/0.07%. The sequence mutex is recursive, so a caller already holding it re-enters, and lockRadioSequence() is a no-op without the readout task. 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 * Hold the radio-sequence lock on the main loop's radio paths The lock exists so a readout cannot land between the calls of a RadioLib sequence, where it would clear flags the sequence is about to use. Three paths reached from the main loop still made such sequences without it: - checkCadHandoffTimeout()'s startReceive() after a handoff RX that never completed. - checkStaleRxFlags(), which reads the IRQ flags, draws a verdict from them and then re-arms or clears - all of which a readout in between invalidates, since it retires the very flags being judged. - resetAGC(), which is a sequence end to end: warm sleep, RC standby, CALIBRATE_ALL, the re-applied DIO2/boosted-gain/0x8B5 settings and the startReceive() that resumes. Its own mid-packet bail is a point check, so it cannot exclude a readout that starts just after it. All three predate the readout task, so this is the task's own lock coverage catching up rather than a regression. Guarding resetAGC() at its call site in periodicRadioMaintenance() covers every driver's override. It can hold the lock for the length of a calibration, which delays a readout, but the chip is not readable for any of that anyway - the RX that would read a frame is the startReceive() at the end. checkRxDoneIrqFlag() is deliberately left unguarded: it is a single flag read and a wake, not a sequence, and serialising it behind a readout in flight would only delay the handoff it exists to make. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVTg7Na1pVvCcwZoSv9Hnd * Readout task: re-arm after a CAD handoff's RX expires empty With the readout task, an empty handoff RX raises TIMEOUT alone. The task returned on the missing RX_DONE, so nothing re-armed the chip and it sat in standby until checkCadHandoffTimeout() ran from the ~1 s missed-IRQ poll. The task now hands that TIMEOUT to the radio thread, which logs "CAD>RX empty" and re-arms; an ISR_RX that finds the handoff RX still running leaves it alone instead of restarting RX over it. 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 * Ask for continuous RX when the duty cycle has nothing to sleep through RadioLib's startReceiveDutyCycleAuto(preambleLength, minSymbols) listens for 2 x minSymbols and sleeps through whatever preamble is left over. We pass preambleLength 16 (12 above 2 GHz) and minSymbols 8, so the sleep window is zero or negative on every board and every modem preset: the chip sits in continuous RX whichever of the two commands we send. rxArmedContinuous recorded the command instead of the state, so it was false everywhere but a CH341 host, and resumeRunningReceive() refused to resume an RX that was in fact still running. Every RX_DONE therefore paid for a standby and a full RX restart - the window in which a packet already arriving is lost - for no power saved. Have continuousRxWanted() ask for continuous RX in that case, which makes rxArmedContinuous describe the RX the chip is really in and lets resumeRunningReceive() pick it back up. If the preamble ever grows past 2 x minSymbols the duty cycle starts sleeping again and this goes back to the duty-cycled command on its own. Measured on the RAK4631, where resuming tied or slightly beat the TX_DONE ISR re-arm. CH341 hosts are unchanged - they already took the continuous branch. LR11x0/LR20x0/SX128x have no resumeRunningReceive() override, so nothing changes for them. The ISR_RX comment claimed rearmReceive() avoids the standby; that was only ever true where the resume could fire. Say so. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVTg7Na1pVvCcwZoSv9Hnd * SX126x: re-arm RX at TX_DONE from the readout task on nRF52 too The readout task's re-arm was held off nRF52 because the TX_DONE interrupt there can re-arm RX itself, with raw SPI, which the task does with RadioLib calls on every other platform. On the bench the two tied once the resume was honest about the RX the chip was already in, so there is no reason to carry two paths: one path for every board the task runs on is the alternative. -DSX126X_RX_REARM_AT_TX_DONE=0 still opts out. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVTg7Na1pVvCcwZoSv9Hnd * LR11x0: re-arm RX at TX_DONE from the readout task The LR11x0 was the one chip the readout task's TX_DONE re-arm skipped, so on a T1000-E the chip still sat in standby from TX_DONE until the radio thread next ran, which a main-loop hold can stretch by hundreds of ms. Same shape as the LR20x0's: the interrupt marks a re-arm pending and wakes the task, the task sends what startReceive() sends less the standby, and the radio thread adopts the RX already running instead of restarting it. No raw SPI - the interrupt only sets a flag, and the task re-arms with RadioLib calls under the radio-sequence lock. -DLR11X0_RX_REARM_AT_TX_DONE=0 opts out. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVTg7Na1pVvCcwZoSv9Hnd * LR11x0, LR20x0: resume a running continuous RX after a frame Both drivers restart RX after every frame: a standby and the whole RX setup again, deaf throughout. We arm RX with RX_TIMEOUT_INF, so the chip is still listening at that point, and resumeRunningReceive() picks it back up instead - what SX126xInterface already does. The chip is asked first. Only a chip that reports RX is resumed: one that left it unseen, through a reset or an RX that did not survive the frame, would otherwise stay deaf with nothing left to re-arm it, and the restart also runs the chip-state recovery. RadioLib's LRxxxx::getStatus() is protected, and reads its reply at the wrong offset, so readChipMode() sends the same NOP transfer itself and takes the mode out of Stat2. -DLR11X0_RESUME_CONTINUOUS_RX=0 and -DLR2021_RESUME_CONTINUOUS_RX=0 opt out and take the restart every time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NeGVaLzYEgfHdj2hcXiSr * Drop the per-chip opt-out flags for the re-arm and the resume SX126X_RX_REARM_AT_TX_DONE, LR11X0_RX_REARM_AT_TX_DONE, LR2021_RX_REARM_AT_TX_DONE, LR11X0_RESUME_CONTINUOUS_RX and LR2021_RESUME_CONTINUOUS_RX were bench levers. Nothing in the tree sets any of them to 0, and neither path needs a build-time escape hatch: a re-arm that the chip does not take falls back to the radio thread's checked startReceive(), and a resume only happens when the chip reports it is still in RX, taking the restart otherwise. So both are now unconditional wherever the readout task runs, and the gates collapse to #ifdef MESHTASTIC_RX_READOUT_TASK. LR11X0_READ_CHIP_MODE and LR2021_READ_CHIP_MODE went with them, having existed only to compile readChipMode() out when the resume was off. MESHTASTIC_EXCLUDE_READOUT_TASK stays: one switch for a board that cannot afford the task. So does the MESHTASTIC_RX_READOUT_TASK condition itself, which is what keeps the re-arm off Portduino and software-polled DIO1, where there is no task to re-arm from. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVTg7Na1pVvCcwZoSv9Hnd * Guard updateNoiseFloor()'s chip reads, and clear a handoff's error flags Two findings from CodeRabbit's re-review of meshtastic#11983. updateNoiseFloor() runs from the main loop on every iteration and made three RadioLib calls - isActivelyReceiving(), isIRQPending() and getCurrentRSSI() - with no RadioSequence, which is the same gap d2fb16a closed for checkCadHandoffTimeout(), checkStaleRxFlags() and resetAGC(), and the one canSendImmediately() was wrapped for. The readout task runs at a higher priority and can land between any two of them, splitting a command and wedging BUSY for RadioLib's whole timeout. The three now sit in one RadioSequence block, with the RSSI validation left outside it. The throttle check also moves ahead of the lock. It was behind the chip reads, so every main-loop iteration paid for them to reach a check that discards the sample; now the skipped iterations cost nothing and do not contend for the sequence lock. readOutFromTask() returned without clearing anything when a DIO1 edge carried no RX_DONE. A CAD handoff's RX arms with CRC_ERR and HEADER_ERR in its DIO1 mask on SX126x, LR11x0 and LR2021, and a damaged header raises HEADER_ERR with no RX_DONE behind it: the task woke, found no RX_DONE, cleared nothing and left the flag latched, so on an edge-triggered line the next frame's RX_DONE raised no edge at all. checkStaleRxFlags() steps aside while cadHandoffRxStart is set, so nothing noticed until checkCadHandoffTimeout() fired at twice the maximum packet airtime. The error flags are now retired there, inside the handoff window only - plain RX does not route them to DIO1, and checkStaleRxFlags() needs HEADER_ERR left alone to spot a wedged SX1280 RX. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H * Don't strand a TX timer, and don't adopt an RX a reconfigure voided Four fixes from a re-review of the readout task. onNotify()'s ISR_RX case had two early breaks in its readout branch, both of which skipped the trailing setTransmitDelay(). There is one notification slot, and readOutFromTask() notifies with overwrite set whenever no TX is in flight, so reaching onNotify() at all can mean a pending TRANSMIT_DELAY_COMPLETED was discarded. Nothing else re-arms that timer: setTransmitDelay() runs from send() and from the tail of these two cases, so a queued packet stranded this way waited for an unrelated send() or radio interrupt. The breaks become a rearmWanted flag, leaving the re-arm conditional and setTransmitDelay() unconditional. Scheduling it on those two paths is safe - the TRANSMIT_DELAY_COMPLETED handler re-delays when the radio turns out to be busy. adoptReceiveArmedFromIsr() trusted rearmState, which the TX_DONE interrupt sets before the radio thread runs. But onNotify()'s ISR_TX sequence calls RadioTxHooks::beforeTransmit() first, and the beacon hook's reconfigureForBeaconTX() reaches reconfigure(), which standbys the chip and reprograms the modem. The task's arm is gone, rearmState still says ARMED, so adopt reported true and onNotify() skipped its startReceive(). Worse, adopt's own RadioLibInterface::startReceive() then cleared rxOffline and chipRecoveryFailures for a chip that never took an RX - and rxOffline is what periodicRadioMaintenance() gates its retry on, so a chip left deaf by a failed reconfigure, or by a post-reconfigure arm that errored, had its recovery path switched off. The three drivers that own rearmState now clear it in reconfigure(). A reconfigure that succeeds ends in its own startReceive(), so the only cost is one extra arm on the beacon-TX path, where correctness is worth more than the standby. sleep() made trySetStandby() and the chip's sleep command as an unguarded pair in all five drivers, so a readout could land between them and talk to a chip on its way down. Each now takes the sequence lock. The comment claiming interrupts are already disabled there was wrong - disable() and NodeDB's config-reload park both reach sleep() with the ISR attached - and now says what actually holds the readout task off. LR11x0Interface and LR20x0Interface's rearmReceiveFromIsr() overrides lacked INTERRUPT_ATTR although isrTxLevel0() calls them, unlike the SX126x override and the base-class ISR helpers. Added, so they are placed in RAM on the architectures that care. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H * Read a captured frame's LoRa header info with the frame, not after it The radio thread accounts for a frame the readout task took by asking the chip for the LoRa header's coding rate and CRC flag, from deliverCapturedFrames(), outside the radio-sequence lock. By then the chip is back in RX - with the continuous-RX resume it never left it - so GetLoRaPacketStatus describes whatever it is receiving now rather than the frame being accounted for, and that read can also land inside a sequence the readout task is running. On an LR2021 that shows up as "Bogus RX coding rate 9 from radio" on about a third of received frames once the resume is on, and on none without it. Nothing is lost - computePacketTime() falls back to the configured rate, which is the right one - but the airtime estimate is taken from a reading that does not belong to the frame. Read it in readOutFromTask() instead, with the frame's SNR and RSSI and under the same lock, carry it in CapturedRxInfo, and have computePacketTime() use the captured value for a captured frame. The thread readout path still reads the chip, where the chip still holds that frame's status. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NeGVaLzYEgfHdj2hcXiSr (cherry picked from commit acdb14a) * Reach the RF95 driver through its pointer when reading header info RF95Interface holds its driver in a std::unique_ptr, so the header-info override has to go through lora-> like the rest of the file does. The value-held form took out every build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVTg7Na1pVvCcwZoSv9Hnd * SX126x: drop the duty-cycled RX arm, which is unreachable startRxCommand() chose between a continuous RX and startReceiveDutyCycleAuto(), and continuousRxWanted() answered that choice partly from the preamble: RadioLib's duty cycle stays awake for 2 x minSymbols and sleeps through whatever preamble is left, so a preamble no longer than that window leaves nothing to sleep through. preambleLength is only ever preambleLengthDefault (16) or, above 2 GHz, wideLoraPreambleLengthDefault (12), set in applyModemConfig() and written nowhere else. Both are inside the 16-symbol wake window, so the predicate was true in every region and every preset and the duty-cycled branch could not be reached. The comment on it, about saving power by letting the radio sit in standby mostly, described something that never happened. Send the continuous RX outright and set rxArmedContinuous unconditionally. A static_assert on the two preamble constants fails the build if a preamble ever outgrows the wake window, which is when the duty-cycled arm and a flag recording which command was sent would have to come back. No behaviour change: the command on the wire is the one the chip was already getting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ckvvn9GeSGZht3RAiMWDax * LR20x0: empty the TX FIFO of leftovers before each transmit The LR2021's TX FIFO appends, and RadioLib's TX staging writes the payload without clearing it first. A TX that ends without sending everything it staged leaves those bytes behind, and the next SET_TX sends them ahead of the new payload, at the new payload's length. The remainder stays behind in turn, so every later packet goes out shifted. On the X1 bench (r108) that is what the uplink collapse looked like: a 45-byte packet went out at the next packet's 59 bytes, carrying the first 14 bytes of that next packet's header, and the server could not decrypt it. Seen only with the continuous-RX resume on, which is what cut the TX short is not yet known. Before each transmit, read the TX FIFO level, warn if it is not empty, and clear it then (or whenever the read fails). One cut-short TX now costs that packet only, and the warning marks where it happened. RadioLib keeps GetTxFifoLevel and ClearTxFifo private, so both go out as raw SPI. 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 * Radio: don't start a delayed transmit from inside a standby Every driver's trySetStandby() calls checkNotification() first, to handle any pending radio interrupt before forcing standby. But notifyLater() stores its notification at once and only delays the wake-up, so while a packet waits out its transmit delay, TRANSMIT_DELAY_COMPLETED is already sitting in the slot. A standby reached from outside the radio thread's own notification handling consumes it: the scan and transmit run right there, ahead of the delay, and the standby that follows cuts the transmit off a few ms into its airtime while it reports as sent. pollMissedIrqs() is such a path, from the main loop: its CAD-handoff timeout and its stale-header re-arm both call startReceive(). On an X1 (LR2021) running meshtastic#11983 the bench caught it directly: CAD>RX timeout, CAD arm, CAD free, Started Tx, then a standby 3 ms into a 25 ms frame. RadioLib 7.7.1 never clears the LR2021's TX FIFO, so the unsent payload went out ahead of every later packet and the node's uplink collapsed. The standby now handles every pending notification except TRANSMIT_DELAY_COMPLETED, which stays pending for its own wake-up. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ic#12114) The deferred-save cases added in meshtastic#11930 set the dirty flag through the test shim. These two go through NodeDB::updateUser() with a new peer inside the one-minute throttle window, check the peer is not yet in nodes.proto, then read nodes.proto back after the minute passes and after a reboot and assert the peer is there under its name. Both fail if updateUser() stops marking the database dirty. Co-authored-by: marshallumsted <bot@marshallumsted.dev> Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
* feat(portduino): Adafruit Arcade Bonnet input driver
Add an InputBroker source for the Adafruit Arcade Bonnet: six buttons
(1A-1F) and a 4-way joystick on an MCP23017 I2C expander.
The expander is polled over I2C every 20 ms rather than serviced from its
interrupt line, because the bonnet ties INT to GPIO17, which is the HUB75
clock on the adafruit-hat mapping. The driver sets INT to open-drain and
enables no interrupt sources so the line never drives against the panel.
Buttons emit once per press and carry a gamepad button code in kbchar
(1E = BTN_SELECT, 1F = BTN_START, so Start pauses games). The joystick
emits directions with the same auto-repeat as LinuxJoystick. The analog
stick bits are ignored; they float with no stick attached.
Config:
Input:
ArcadeBonnet: 0x26
ArcadeBonnetButtons: # optional
select: [1A, 1F]
cancel: 1B
--check validates both keys. The action-name parser moves from
LinuxJoystick.cpp to InputBroker.h so both drivers share it.
Breakout now polls the bonnet joystick's held direction the same way it
polls the USB gamepad, so the paddle glides instead of stepping at the
auto-repeat rate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vaqwGPoD64pqsnPYbvnUn
* fix(portduino): Arcade Bonnet buttons act in games; address review
1F reported BTN_START, which GamesModule treats as pause, so a bonnet
button bound to select paused Tetris and Breakout instead of dropping or
serving. 1E/1F now report BTN_C/BTN_Z; no bonnet button is Start.
From review:
- Clear the held joystick zones when an I2C read fails, so Breakout's
paddle doesn't keep moving after the bonnet stops answering.
- Set Input.ArcadeBonnet only when the key is present, so a later
config.d file with its own Input section doesn't disable the bonnet.
- Reject addresses outside the MCP23017's 0x20-0x27 in the driver and
in --check, instead of truncating them to a uint8_t.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vaqwGPoD64pqsnPYbvnUn
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
pollMissedIrqs() runs from the main loop about once a second. Its checkRxDoneIrqFlag() and checkTxDoneIrqFlag() read the chip's IRQ flags without the radio-sequence lock, so the RX readout task could preempt one mid-read. On LR11x0 and LR20x0 that read is not self-contained. RadioLib's LRxxxx::getIrqStatus() sets the Module-wide spiConfig.widths[STATUS] to 0 for its transfer and restores it after. A readout that lands in between frames its own commands with that width, so its status bytes come back as data. On an LR2021, GET_RX_PKT_LENGTH then returns the two status bytes as the length (always 0x100 or more), and the frame is dropped as a bad length. On an LR1110 the status byte is read as the packet type instead. The bench caught one "RX readout task: ... 1 bad length" on an LR2021 running develop, with the ring not full. Only a value that isn't a length can exceed 256 there. Take the lock for the whole poll. It is recursive, and checkCadHandoffTimeout() and checkStaleRxFlags() already take it inside. 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
computePacketTime() asks the chip for its packet type (getTimeOnAir()) on a transmit, and for the header info on a receive the readout task did not capture. Its callers - finishSentPacket() after every send, ReliableRouter's retransmit timing, DMShell - hold no radio sequence, so a readout could land inside the read. On the LR11x0/LR20x0 a read is two SPI transfers, so a readout between them takes the other's reply. r113's SPI probe saw radio SPI outside any sequence from the main loop task on every board, 2 transactions per received frame on LR chips and 1 on SX126x, which is one chip read per exchange on each. These are the per-send chip reads on that path that were still unlocked (inferred; the next probe run confirms). 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
The LR2021 receives into a FIFO that RadioLib empties only at the end of readData(). Every path that gives up on a frame without reading it (the readout task's bad length and full ring, the thread's early outs) cleared only the IRQ flags, so the dropped frame's bytes stayed at the front of the FIFO and the next frame was read out on top of them. On an X1, 37 of 37 forced drops were followed by a "Decryptable packet failed decoding", so each drop cost two frames. clearReadIrqs(), which every such path already calls, now also asks the driver to discard the frame. LR20x0 sends CLEAR_RX_FIFO (private in RadioLib); the other chips read from a buffer offset and need nothing. 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
…ic#11869) OLED_GR selects Win1253, OLED_RU/OLED_UA Win1251, OLED_PL/OLED_CS Win1250, no flag Win1252. heltec_mesh_pocket returns to the Win1252 default. t5s3_epaper stays on Win1253 (18pt/24pt exist only there). Co-authored-by: Jason P <applewiz@mac.com>
Upstream dependencies in the newest distros are beginning to require c++20. This PR bumps all portduino-based targets to c++20, removes the shared c++17 build_flag, and adds it to each variant family where it is needed.
ubuntu-latest means we don't own the break (if it happens), always pin!
* fix DIO1 storm * fix rabbit
) Before: four of the five radio drivers handle any pending notification before forcing standby, deferring only a TX whose backoff timer has not expired - SX126x, SX128x, LR11x0 and LR20x0 all open trySetStandby() with checkNotificationExcept(TRANSMIT_DELAY_COMPLETED). RF95 was never given it, so on an SX127x board a notification already pending is serviced after trySetStandby() has stood the chip down and detached the ISR, against a chip that has left the state the notification describes. After: RF95 drains like the other four, and a TX still waiting on its backoff timer is left alone exactly as it is elsewhere. How: checkNotificationExcept(TRANSMIT_DELAY_COMPLETED) as the first statement of RF95Interface::trySetStandby(), with the same comment the other drivers carry. Claude-Session: https://claude.ai/code/session_01TCyfeQuP9f3wggY9eF4GZT Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`dput` PPA upload operations began showing their status properly after meshtastic#11941, revealing that many uploads are reaching ~85% and then timing out. This change increases the timeout for dput publish steps to prevent these failures.
…tic#12134) Before: the radio-sequence mutex was created alongside the RX readout task, inside startReceive(). lockRadioSequence() returns false on a null mutex, so the first RadioSequence to wrap that call took nothing and recorded held == false, while the task it had just created was already live and free to take the unheld mutex. On SX126x the exposed window is the tail of SX126xInterface::startReceive() after the base call - rxArmedContinuous = true, enableInterrupt(isrRxLevel0), checkRxDoneIrqFlag() - so the first frame of a session could be read out from under the arm that was still finishing. It is one window per boot, but the guard documented the opposite invariant, which is what made it hard to see. After: the mutex exists before any sequence can start, so every sequence in a readout-task build holds it and the task cannot appear inside an unheld one. How: xSemaphoreCreateRecursiveMutex() moves to the RadioLibInterface constructor, under MESHTASTIC_RX_READOUT_TASK, and is freed in the destructor, since initLoRa() destroys an interface whose init() failed. startRxReadoutTask() now only checks that it exists. The lockRadioSequence() comment is corrected to match. Claude-Session: https://claude.ai/code/session_01TCyfeQuP9f3wggY9eF4GZT Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…c#12032) * Add native Portduino mDNS discovery via Avahi and Bonjour * Guard native discovery in Portduino WASM builds * Add linux dependencies in packaging, support "buildroot" Adds libavahi dependencies for Debian, RPM, Docker and CI. Moves the libavahi definition to the shared `build_flags` (which is only consumed on Linux targets) * Fix platformio formatting * fix(portduino): disable Avahi when daemon unavailable --------- Co-authored-by: Jake-B <jake-b@users.noreply.github.com> Co-authored-by: vidplace7 <vidplace7@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…2141) Before: an ESP32 with native USB, power saving on and a cable attached drops off the USB bus within a couple of minutes and does not come back. The host logs `device descriptor read/64, error -71`, `device not accepting address`, `unable to enumerate`, and from then on the port is gone - pressing the button wakes the screen, so the firmware is alive and responding, but nothing re-triggers enumeration from the host side. Only removing power restores it. A bench board was measured in that state for over ten hours. After: a node whose USB-serial/JTAG console is open by a host does not light sleep. A node on a battery or a charger, with no host, sleeps exactly as before. No shipping chip keeps the USJ pad alive across a light sleep: `SOC_USB_SERIAL_JTAG_SUPPORT_LIGHT_SLEEP` is undefined for the S3, so IDF disables the pad for the duration of every one, and the capability is commented out even on C6/H2. Meshtastic light sleeps in 30 s chunks with a 100 ms awake gap between them, so the device presents itself for ~100 ms every 30 s, which is far below what enumeration needs. After a few attempts the host gives up for good. How: `lsIdle()` is the only entry to light sleep, so the veto goes there, alongside the existing `doPreflightSleep()` one. The predicate is IDF's `usb_serial_jtag_is_connected()`, which answers "a host has the port open" rather than "a cable is in", compiled in only where the console is really the USJ peripheral - the same `ARDUINO_USB_CDC_ON_BOOT` and `ARDUINO_USB_MODE` pair SerialConsole.cpp uses, so a TinyUSB build is untouched and every other target gets a `false`. It is frame-based, so a host counts as attached for 5 s after the last frame seen; a single missed frame must not be enough to cost the port. Not `HWCDC::isPlugged()`, which flaps false while USB is fine (meshtastic#11864). The node stays in LS with the CPU awake for as long as the host is there, rather than advancing to NB, which is the same shape as the preflight veto: the state is unchanged, only the sleep is skipped. Claude-Session: https://claude.ai/code/session_01GTiifXrVc4YdonmLRCeWhw Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Align the two nRF platforms on v12.3.1 Switch to forked RAK12035_SoilMoisture library for now
…htastic#12142) * Add native Portduino mDNS discovery via Avahi and Bonjour * Guard native discovery in Portduino WASM builds * Add linux dependencies in packaging, support "buildroot" Adds libavahi dependencies for Debian, RPM, Docker and CI. Moves the libavahi definition to the shared `build_flags` (which is only consumed on Linux targets) * Fix platformio formatting * fix(portduino): disable Avahi when daemon unavailable * fix(portduino): retry native discovery and defer worker logs * fix(portduino): preserve pending discovery warnings --------- Co-authored-by: Jake-B <jake-b@users.noreply.github.com> Co-authored-by: vidplace7 <vidplace7@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
These three variants were referencing the older (broken) library, where other RAKs had already updated to this fixed version. This PR aligns everything on the fixed version.
…tic#12152) GCC 12.3.1's newlib no longer exposes the BSD `uint` typedef on nRF52, breaking CannedMessageModule::keyForCoordinates on the only nRF52 variant with USE_VIRTUAL_KEYBOARD. Use int, matching the Letter rect fields it compares against. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…he floor from it (meshtastic#11765) The 5 h dedup window and the 6 h stationary broadcast floor were paired by a hand-picked one-hour margin. Round the window to 6 h and derive the floor from it: one dedup tick (6 min) above the window, which is the exact margin a stationary node's refresh needs to clear its neighbours' tick-quantised window regardless of tick phase. A static_assert enforces the pairing. Role caps (tracker 1 h, lost-and-found 15 min) are unchanged. Saved configs keep their value, as with the 11 h -> 5 h change in meshtastic#11606; the new default applies when traffic management config is first installed. Co-authored-by: Ben Meadors <benmmeadors@gmail.com> Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com>
doLightSleep() arms PMU_IRQ with GPIO_INTR_LOW_LEVEL and, unlike every other wake pin it arms, never disarms it. The low-level interrupt type and the wake-enable bit are therefore still set in the GPIO registers once the node is awake, and they stay set for as long as the chip keeps its GPIO peripheral. A panic or software reset does keep it: only a chip-level reset clears those registers. Measured on a T-Beam S3 Supreme (ESP32-S3), reading GPIO_PIN40_REG immediately before the first attachInterrupt() of each boot: 0x00000000 after a flash/chip reset, then 0x00002600 (int_type 4 low level, wakeup_enable 1, int_ena set) on each of three successive CPU-only resets. With this change the same recipe reads 0x00002100 (int_type 2 negedge, wakeup_enable 0) on all three. That leftover level arming is the precondition for the interrupt storm meshtastic#12131 fixed for DIO1: the first attachInterrupt() of a boot installs the GPIO ISR service inside a critical section, and a level condition that is already true re-enters gpio_intr_service with nothing able to clear it, which ends in an interrupt WDT panic that only an EN reset recovers from. On the boards that define PMU_IRQ that first attach is attachPowerInterrupts(), which is where the panic in meshtastic#12114 landed. The storm itself was not reproduced here: PMU_IRQ is open-drain with an external pull-up, and the pad read 0xff both as found and with no internal pull on every boot, so the low-level condition was never true across the attach. A VBUS insert or remove asserting the line in the window between axpChipInit() clearing the PMU status and attachPowerInterrupts() would make it true. This closes that window instead of relying on it staying narrow. gpio_wakeup_disable() on its own leaves int_type behind - the meshtastic#12131 lesson - so the pin is also set back to an edge type, matching what the BUTTON_PIN path above it does. Claude-Session: https://claude.ai/code/session_01GTiifXrVc4YdonmLRCeWhw Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix WakeKey not responding when pressed during boot * fix I2C error address 0x48 during scan * enable GT911 gesture recognition in MUI * shutdown_variant() * disable io expander INT during light sleep * fix BaseUI wakeup * prevent bootloop in case DIO1 fires * fix ADS1X15 scan type * fix power down sequence * external keyboard support * fix I2C port 1 define; skip false positive
Adds an InkHUD driver (nicheGraphics.h) and build target (platformio.ini) for InkHUD on the M5
* Add Makerfabs Nomad Terminal variant * Configure external power detect pins before probing battery gauges * TFTDisplay: add ILI9488 reset pin and colour inversion options * Makerfabs Nomad Terminal: add BaseUI env, early power latch, soft-start LCD/GPS rail, GPS wake * I2C scan: tell FocalTech touch from AHT10 at 0x38 and BMM150 from VEML7700 at 0x10 * Run a BMM150 next to an IMU on the magnetometer thread * Makerfabs Nomad Terminal: BaseUI touch * BMM150: add hard/soft-iron calibration, read at 10 Hz * Makerfabs Nomad Terminal: fix MUI build, restart Wire after MUI touch init * BMM150: seed calibration from the first valid sample * Makerfabs Nomad Terminal: combined BaseUI+MUI -tft firmware, SD defines in build flags * Makerfabs Nomad Terminal: reset touch controller at init, wake touch thread on INT * BMM150: poll every 200 ms * Makerfabs Nomad Terminal: scale CW2015 SOC to the on-USB full reading * BMM150: end an expired calibration when the sample is rejected
…#12145) The config-send path copies config.power and then overwrites one field with the default, unconditionally: fromRadioScratch.config.payload_variant.power = config.power; fromRadioScratch.config.payload_variant.power.ls_secs = default_ls_secs; The note at the end of that state explains why the effective value is sent: 0 means "use the default" and the app needs to know what the device will actually do. But the write is not conditional on the stored value being 0, so a configured value is replaced on the way out. Every client, the CLI included, reads back default_ls_secs (IF_ROUTER(ONE_DAY, 5 * 60)) no matter what is stored, and a set that took effect looks like a set that was refused. It also loses the setting. AdminModule's power case stores what it is sent (src/modules/AdminModule.cpp:967), so a client that reads this config and writes it back - which is what happens when a user changes any other field in the same message - stores the default over the configured value. Observed on an ESP32 node: ls_secs set to 60, read back as 300, and the node then light slept on the 60 s cadence, so the stored value had taken all along. Default::getConfiguredOrDefault() keeps the behaviour the note describes, since 0 is exactly the case it falls through on, and changes only the readback of a non-zero stored value. Note that config.power.ls_secs is initialised to default_ls_secs in NodeDB::installDefaultConfig() (src/mesh/NodeDB.cpp:1366) and consumed raw in PowerFSM's lsIdle() (src/PowerFSM.cpp:133), so a stored 0 is not how a default-configured node looks in the first place. Claude-Session: https://claude.ai/code/session_01GTiifXrVc4YdonmLRCeWhw Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Button E was PIN_BUTTON1 with a hand-rolled FunctionButton map in InputBroker (single press: ping, double: privacy toggle, 5 s hold: shutdown). The rotary encoder already drives the UI, so define the pin as CANCEL_BUTTON_PIN instead and drop the M8 special case: a press now cancels and a 4 s hold shuts down, through the generic cancel button. variant_shutdown() follows the rename so the wake-on-press pin is still parked and sensed. Claude-Session: https://claude.ai/code/session_01B7bFRZ9bBMRr7KPBF8rmzu Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Switch from git reference to github .zip for ZinggJM/GFX_Root library in InkHUD. This should help with transient failures in inkhud builds.
* Stop treating BATTERY_PIN -1 as a battery ADC Six variants defined BATTERY_PIN -1 to mean "no battery sense", but every guard is `#ifdef BATTERY_PIN`, so the analog battery read compiled in anyway. On the Seeed Xiao S3 it samples an unwired GPIO1 and reports a battery that is not there: 0% at 3112 mV on one board, charging at 6310 mV on another. On nRF52, analogRead(-1) indexes past the pin map. Leave BATTERY_PIN undefined on those boards, which takes the existing external-power path, and fail the build when a variant defines a negative pin. * Keep the INA battery path on boards without a battery ADC pin AnalogBatteryLevel is the only reader of a battery INA configured through power.device_battery_ina_address, and analogInit() only installed it when BATTERY_PIN was defined. Dropping the -1 placeholder from six variants therefore also dropped their INA path. Install AnalogBatteryLevel on any board without a pin when the environmental sensors are compiled in, and let the PowerFSM treat such a board as externally powered only while no battery source has been found.
…tastic#12162) * CI: run pio check on PRs only, not in the merge queue The merge queue re-ran cppcheck on the same --level pr boards the PR run had already checked. Restrict the check job to pull_request; ci-gate already treats a skipped check as passing, so merge_group runs gate on the builds alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L * CI: cancel superseded push runs on the same branch Push runs fell back to github.run_id for their concurrency group, so every merge to develop ran the full 156-board matrix to completion even when a newer merge had already landed. Group pushes by github.ref so the newer push cancels the older run. PRs keep grouping by head_ref, and merge_group, schedule and workflow_dispatch runs keep a group of their own, so the queue, the nightly and releases are never cancelled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L * CI: move axiometa-genesis-mini out of the PR board set Set its board_level to release. PRs and the merge queue now build 17 boards instead of 18; the board is still built in the full matrix on push, nightly and release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L * CI: reuse the merge queue's PR-level builds in the post-merge run A post-merge push to master/develop lands the exact commit its merge_group run already built, so the PR-level boards were compiled twice. When the push's merge_group run is found and its ci-gate passed, setup marks those boards 'reuse' and build_firmware.yml copies their firmware and manifest artifacts from that run instead of building. The artifacts keep their names, so the size report baseline, the RAM/flash budget gate and MemBrowse are unchanged. If no such run is found (event/* and feature/* pushes, a push that bypassed the queue, an API failure), every board builds as before. ci-gate is read through the check-runs API: the run's jobs endpoint returns 502 for a run this size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L * CI: skip macOS, Windows and debian-src on reused post-merge pushes When setup finds the merge_group run that already built this commit (reuse_run_id), these jobs ran on that exact commit there and nothing on push consumes their output: the debian-src artifact is only read by release-artifacts on workflow_dispatch. test-native, WASM and Docker still run, because only pushes to develop save their caches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L * CI: only skip OS builds on push when the merge queue run passed them ci-gate needs only build and check, so a merge_group run whose macOS, Windows or debian-src job failed still merges, and skipping those jobs on the push hid the failure. setup now sets skip_os_builds only when the latest attempt of every MacOS, Windows and build-debian-src check run in that merge_group run concluded success and all three are present; the jobs skip on that flag instead of reuse_run_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L * CI: harden the post-merge reuse lookup (review fixes) - Find the merge_group run at any status. The queue merges once ci-gate passes, often while jobs outside it still run, so requiring a completed run usually found nothing and rebuilt every board. - Mark a board 'reuse' only if its firmware and manifest artifacts are still present and unexpired in that run; any other board compiles. setup now needs version to build the exact artifact names. - Never skip MacOS, Windows or build-debian-src on the default branch: those jobs save their caches only on its pushes. The skip stays for master. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L * CI: drop the macOS/Windows/debian-src skip on post-merge pushes Those jobs save their caches only on default-branch pushes, so the skip was already limited to master, where it saved too little to justify the check-run lookup. They now run on every push exactly as before this PR. Reuse of the PR-level firmware builds is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L * CI: look up the merge_group run by workflow file, not display name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L * CI: build no firmware in the merge queue; drop post-merge artifact reuse Per review: the merge queue now only batches PRs into develop/master. setup is skipped on merge_group, which skips the build and check matrices that depend on it, and ci-gate accepts a skipped build on merge_group only; a PR must still build. Post-merge pushes build the full matrix as before. With no firmware built in the queue there is nothing to reuse, so the post-merge artifact copy (reuse-build, the merge_group run lookup and setup's dependency on version) is removed and build_firmware.yml is back to develop's version. The merge_group branches of setup's matrix generation are removed with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L --------- Co-authored-by: Claude <noreply@anthropic.com>
* feat(variants): add RAK WisMesh Dongle. USB-powered headless adapter on the RAK4631 module. Hardware model stays RAK4631 until protobufs add WISMESH_DONGLE. The boot VDD gate is disabled because phone VBUS makes the SAADC sample look unsafe. * add board_level * fix(rak_wismesh_dongle): set VDD gate to 1 mV so cppcheck passes --------- Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
…c#12182) * CI: let a running push run finish instead of cancelling it meshtastic#12162 put pushes in one concurrency group per branch with cancel-in-progress, so a merge landing mid-run threw away a part-built full matrix. Set cancel-in-progress only for non-push events. A push now lets the running run finish and waits as the group's single pending run; a newer push replaces that pending run before it starts, so pushes that land mid-run collapse into one follow-up run. PRs still cancel their superseded runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L * CI: correct the concurrency group comment A PR groups on head_ref and a push on the full ref, so the two never share a group; the comment said they did. The expression is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L --------- Co-authored-by: Claude <noreply@anthropic.com>
…shtastic#12159) * fix-rtcio-number-error * c3 fix
…te after init (meshtastic#11995) * SX126x: follow RadioLib's XTAL fallback and log the chip's state after init Since 7.5.0 (92e2f333f1) RadioLib retries config() on the XTAL inside begin() when the TCXO will not start, zeroes its own Vref and returns success. The TCXO_OPTIONAL ladder therefore never builds its XTAL instance, and Meshtastic kept reporting "TCXO, Vref 1.8" on crystal boards. - reinitChip(): take RadioLib's zeroed Vref after a successful begin() - init(): one line with the oscillator in use, chip mode, command status and device errors - the ladder's SX1262/SX1268 success lines report the oscillator actually in use - drop the embedded pre-init "DIO3 as TCXO Vref" debug line, which only stated the attempt * tronk * SX126x: don't report a stale chip state when the diagnostic read fails status is a function-local static filled by parseStatusCb, so an SPIreadStream() that fails before parsing left the previous call's byte in it, and the init log reported that as the current mode and command status. Check the read and log the failure instead. * fix(sx126x): keep RadioLib's status validation on the diagnostic read The stand-in parseStatusCb captured the status byte but always returned RADIOLIB_ERR_NONE, so a failed command or a 0x00/0xFF (no chip) byte was logged as chip state. Chain to the saved parser so those fail the read. * SX126x: comment out the post-init GetDeviceErrors read The raw SPI read and parseStatusCb swap run after startReceive(), outside a RadioSequence, so they can race the RX readout task. Kept as a commented-out debug aid for init failures. --------- Co-authored-by: Tom <116762865+Nestpebble@users.noreply.github.com>
* initial commit for L76K/AG3352 dual GNSS detection * GPIO fixes according firmware demo * resolve rabbits * define PIN_GPS_SWITCH * fix wake button and power pin * clear response to avoid logging junk data * increase IO07 samples to more reliably detect version * ThinkNode M5: read the v1.0 GPS switch on GPIO10 On a v1.0 (L76K) board the GPS switch is sensed on GPIO10, not GPIO18. Watching GPIO18 there meant flipping the switch did nothing. Pick the pin by revision, as the GPS UART pins already are; m5HasAg3352 is set in earlyInitVariant() before createGps() configures and starts watching it. Tested on a v1.0 board: switch off and on now turns the GPS off and on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ThinkNode M5: trunk fmt variant.h Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…tastic#12194) * Key verification: create the responder's NodeDB entry on commit An initiator that did not hold the responder in NodeDB could not complete manual key verification: DO_VERIFY logged "Attempted to commit key, but unknown node" and the key was never marked verified. KeyVerificationModule consumes the responder's hash2 reply, so module dispatch stops before RoutingModule sniffs the packet into NodeDB and no entry is created. commitVerifiedRemoteNode() assumed one existed. When it does not, commit the pending key the reply carried (the key the user has just confirmed) through commitRemoteKey(), which creates the entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Key verification: commit only from the final-confirmation state DO_VERIFY with a matching nonce, and the on-screen Accept callbacks, called commitVerifiedRemoteNode() in any state. The app learns the nonce at the security-number prompt, so an early DO_VERIFY (or one after a wrong number) committed the unchecked pending key as ManuallyVerified. Require SENDER_AWAITING_USER or RECEIVER_AWAITING_USER before committing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…1913) * Fix RF95 optional pins and CannedMessage initialization * style: clang-format CannedMessageModule else branch --------- Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com> Co-authored-by: caveman99 <tgoettgens@gmail.com> Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
…htastic#12167) * fix(gps): match Airoha idle_int sleep to the documented sequence For boards that wake the AG3352 through idle_int (ThinkNode M8 and M9): - Drop $PAIR382,1 before the sleep command. It locks the CPU out of sleep, which works against both $PAIR003 and $PAIR650. - Hold idle_int low 50 ms before sending, and wait 50 ms after the command takes before EN cuts the core, as Elecrow's reference does. - A successful $PAIR650 need not reply, so also accept NMEA going quiet for a full cycle as success; resend once otherwise. Boards with RTC_INT are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ThinkNode M8: reset the GPS out of backup mode at boot If the MCU resets while the AG3352 is in backup mode, the GPS stays there and the cached-probe check gets no answer, forcing a full re-probe. Pulse RESETB in earlyInitVariant(), as the M5 does with its wake pulse in meshtastic#12113. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…config (meshtastic#12192) * fix(motion): include NodeDB.h for the global `config` MotionSensor.cpp uses config.display unconditionally (compass orientation, wake-on-motion) but only reached the global `config` through a fragile transitive include via Power.h/Screen.h. On a screenless board that also excludes the WiFi/web stack, that transitive path disappears and the file fails to compile with 'config' was not declared in this scope. Include mesh/NodeDB.h directly so MotionSensor always compiles regardless of which modules a variant excludes. Co-authored-by: Cursor <cursoragent@cursor.com> * cleaning up comments Removed comments explaining the inclusion of NodeDB.h. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
The handshake's third message must be PKI-encrypted, and licensed operation neither sends nor decrypts PKI. A licensed node still opened a session, raised the security-number prompt, and waited until it timed out. A licensed node now refuses to start a session, from the admin message or the screen, and tells the client why. It ignores a remote node's request without a NAK, as a busy node does. The screen's node menu hides the Key Verification entry in licensed mode. Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK Co-authored-by: Claude <noreply@anthropic.com>
…c#12183) * LR2021: time received frames on the configured coding rate The LR2021's reported RX coding rate can pick up extra bits at a bad status read right after our own TX and keep them until a hard reset. Explicit-header frames still decode, since the rate travels in the header, but the wrong value inflates RX airtime: a latched 4/8 against a configured 4/5 times a 200-byte frame about 55% long. That feeds channel utilisation, the CSMA contention window and the reliable-router retransmit timers. computePacketTime() gains useRxHeaderInfo; LR20x0Interface passes false, so received frames are timed on the configured coding rate and CRC, and the readout task no longer reads the header info on that chip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTiifXrVc4YdonmLRCeWhw * LR2021: describe the coding-rate accumulator in the comments The reported RX coding rate was described as latching after a post-TX status read. That attribution was wrong: GetLoraPacketStatus accumulates the bitwise OR of every received header's coding rate since reset, which an SX1262 injector reproduces on demand - frames at 4/5, 4/6 and 4/7 each OR their bit in, a frame at the configured rate changes nothing, and the field reaches values DS rev 2.2 Table 9-4 does not define. It is invisible on a single-coding-rate mesh, since cr | cr == cr. No behaviour change: comments only. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…t found (meshtastic#12205) * Fail the native simulator job on a sanitizer fault, and fix the one it found The integration step backgrounded meshtasticd and ended in `wait "$PID" 2>/dev/null || true`, which discards the simulator's exit status. The coverage env is built with -fsanitize=address, so a fault there prints a full report and exits non-zero on the way out, after the Python harness has already printed its own success - and the job concluded green with an AddressSanitizer heap-use-after-free sitting in the log. Gate it: capture the simulator's output, read the status `wait` reports, and scan the output for a sanitizer report. The pattern moves to bin/lib/sanitizer.sh so bin/run-tests.sh and the new bin/check-sanitizer-log.sh share one matcher rather than drifting apart; the output is uploaded as an artifact, since a fault on the exit path prints after the last test has passed. Leak detection is off, as in bin/test-config-check.sh - exit_simulator exit()s with the node's globals still live. The fault it had been hiding: OSThread::run() points currentThread at the running thread for the whole of runOnce(), and exit_simulator calls exit(0) from AdminModule while ServerAPI is that thread. ServerAPI is declared `public StreamAPI, private OSThread`, so its Thread base frees ThreadName first and PhoneAPI::~PhoneAPI's own LOG_DEBUG then reads it back through currentThread. Clear the pointer in ~OSThread when it names the thread being destroyed, which covers any thread deleted from inside its own runOnce, not just this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjAvRt6xuztC3hKqWkW9Dw * Detach observers when an Observable is destroyed The new sanitizer gate turned the simulator job red on a second heap-use-after-free on the same exit path, this one in Observable::removeObserver. ~Observer removes itself from every observable it watches, but Observable has no destructor and dies with its watchers still pointing at it. exit_simulator exit()s from AdminModule, and static destruction takes the global xModem - whose packetReady is an Observable that PhoneAPI::handleStartConfig attached to - before the heap-allocated ServerAPI that is watching it. PhoneAPI::close() then calls unobserve(&xModem.packetReady) on the dead observable, and ~Observer would walk it again afterwards. Both halves of the contract, so neither side has to outlive the other: ~Observable drops itself from each watcher's list, and unobserve() checks membership before reading through the pointer it is given. Also take CodeRabbit's grep -m 4 in bin/check-sanitizer-log.sh: with more than four matching lines, head closing the pipe is a SIGPIPE that pipefail reports and set -e exits on, so the script gave 141 where it documents 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjAvRt6xuztC3hKqWkW9Dw --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A push to membrowse/membrowse-action's v1 tag today shipped an action.yml that
uses ${{ github.workspace }} in the `description:` of its new source_root input.
Expressions are evaluated in the `inputs:` section too, and the github context
does not exist there, so the runner refuses to load the manifest:
membrowse/membrowse-action/v1/action.yml (Line: 77, Col: 18):
Unrecognized named-value: 'github'
Every membrowse job now fails in "Set up job", before any of our steps run, and
build_firmware.yml's membrowse job feeds ci-gate - so the required check is red
on every PR in the repo.
Pin both references to 34719bdb0472, the SHA v1 resolved to on the last green
run (membrowse 1.2.15, 03:34 UTC today), which also stops a third-party tag
move from reaching us unannounced again. comment-action/action.yml is identical
at both SHAs; pinning it is for consistency, not because it broke.
The repo's own size enforcement is unaffected either way: size-budget-gate
reads the manifest-* artifacts from pio-build, not anything MemBrowse produces.
Claude-Session: https://claude.ai/code/session_01JjAvRt6xuztC3hKqWkW9Dw
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Pin python 3.14 (not `3.x`) now that 3.15 exists. Fixes currently broken wasm / test jobs.
) * T-Beam 1W: support the LR2021 radio module LilyGo ships the T-Beam 1W with either an SX1262 or an LR2021 module on the same pins. Build both drivers into the t-beam-1w env; initLoRa() probes the SX1262 first and keeps whichever radio answers, so SX1262 boards are unchanged. LR2021 settings follow LilyGo's t_beam_1w_lr2021 documentation: IRQ on radio DIO10 (GPIO1), TCXO at 3.0 V, and PA/LNA switching on radio DIO5-DIO8 (rfswitch.h). The 2.4 GHz PA must not see more than 8 dBm, so LR2021_MAX_POWER_HF is 8. GPIO21 is radio DIO11 on this module; the SX1262 probe only claims it as RXEN after a successful begin(), so it is never driven on an LR2021 board. TX_GAIN_LORA stays at the SX1262 module's 10 dB. The LR2021 module's gain is about 8 dB, so it is driven slightly below its maximum. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * T-Beam 1W: release SX126X_RXEN before the LR2021 probe SX126xInterface::init() calls setRfSwitchPins() before setCurrentLimit(), setCRC() and setOutputPower(), so a begin() that succeeds before one of those fails leaves RXEN configured as an output driven low. initLoRa() then drops the interface and probes the LR2021, which drives the same pin itself - GPIO21 is radio DIO11 on the T-Beam 1W's LR2021 module. Release the pin on that path, for the boards that can carry either radio and wire an RXEN. * T-Beam 1W: also support the LR1121 radio module LilyGo also ships the T-Beam 1W with an LR1121 module on the same pins. Add it to the t-beam-1w env; initLoRa() probes SX1262, LR1121, LR2021 in that order and keeps whichever answers. LR1121 settings follow LilyGo's t_beam_1w_lr1121 documentation and factory example: TCXO at 3.0 V, PA/LNA switching on radio DIO6-DIO8 (sub-GHz PA on DIO8 in TX_HP, 2.4 GHz PA on DIO7, 2.4 GHz LNA on DIO6). LilyGo warns that more than 1 dBm into the 2.4 GHz PA can damage it, so LR1120_MAX_POWER (the LR11x0 LORA_24 clamp) is 1. On an LR2021 board the LR1121 probe returns RADIOLIB_ERR_CHIP_NOT_FOUND after about 3.2 s and the LR2021 then initialises normally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * T-Beam 1W LR1121: IRQ on GPIO21, force the high-power PA The LR1121 module's IRQ is radio DIO11 on GPIO21 (LilyGO utilities.h: RADIO_DIO_IRQ_PIN = RADIO_DIO11_PIN), not GPIO1. With GPIO1 the first listen-before-talk CAD waited forever for CAD_DONE and froze the node. The module's sub-GHz antenna is only reachable through the high-power PA path (external PA enabled on DIO8 in MODE_TX_HP), but RadioLib selects the low-power PA at 14 dBm and below, where every switch is off. Add an opt-in LR11X0_FORCE_HP_PA that calls setOutputPower(power, true), clamped to the HP PA's -9 dBm floor, at init and in programModemParams(), and enable it for this board. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * T-Beam 1W LR1121: list RF switch DIOs from DIO5 RadioLib's LR11x0::setRfSwitchTable() sets each mode's bits by the pin's position in the array, counting from DIO5, while the enable mask uses the DIO number. A table starting at DIO6 therefore drove every line one DIO low: sub-GHz TX raised DIO7 (the 2.4 GHz PA) instead of DIO8, so the sub-GHz PA and TX/RX switch never left RX and the signal only leaked out. List DIO5 (unused, always low) first, as LilyGo's documentation and factory firmware do. Measured at 10 dBm against an SX1262 node: peer RSSI -100 dBm before, -29 dBm after; 20/20 each way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * T-Beam 1W: also release SX126X_RXEN before the LR1121 probe GPIO21 is the LR1121 module's IRQ (radio DIO11), so an RXEN left driven low by a partly failed SX1262 init would mask its interrupts and hang the first channel scan. Extend the release to boards that build the LR1121. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jp-bennett <5630967+jp-bennett@users.noreply.github.com>
* Add the Nodara nRF52840 board skeleton with e-ink init. * Fix the Nodara e-ink driver leak and rename the board macro to NODARA. * Nodara: use the stock S140 v6 linker script The board-specific script had the same flash layout as nrf52840_s140_v6.ld (app 0x26000-0xEA000) and differed only in the RAM origin (0x20006000 vs 0x20004000). The SoftDevice's RAM requirement is set by the shared Bluefruit config, not the board, so the extra 8 KB was unused. Tested on hardware: BLE comes up, and a phone connects and syncs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Nodara: build the same modules as other nRF52 boards Drop the variant's module exclusions and the -<mqtt/> source filter. nRF52 boards keep MQTT for the phone client proxy, and audio/paxcounter are already excluded for the whole platform in nrf52.ini. Flash use is 89.5%, in line with t-echo (88.7%). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Nodara: probe SX1262/SX1268 and TCXO/XTAL at boot Define both radio types and TCXO_OPTIONAL so initLoRa() picks whichever chip answers (RadioLib tells them apart by the version string) and falls back from a DIO3 TCXO to the crystal. The boot log reports the result. The test unit reports "SX1262 init success, XTAL, Vref 0.0V". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Nodara: map the six keys to the trackball and cancel buttons Use the existing trackball driver for up/down/left/right/select (B key) and CANCEL_BUTTON_PIN for the A key, which also gets the SENSE wake from System OFF. Same pattern as the Wio Tracker L1 e-ink. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Nodara: power on/off through the power MCU Nodara's power button goes to an always-on power MCU, which reports it as Modbus RTU frames on a receive-only 1200 baud line (Serial2, P1.00). Handle it in extra_variants: a shutdown request takes the standard shutdownAtMsec path, and on a power-on reset boot waits up to 5.5 s for the power-on confirm the MCU sends once the button has been held long enough. Also give Serial2 a valid TX pin so the core doesn't index the pin map with -1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Nodara: fix cppcheck and millis() deadline lint in the power MCU link Zero-initialize the frame buffers and use Throttle::deadlinePassed() for the power-on wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Nodara: report the NODARA hardware model Map NODARA to meshtastic_HardwareModel_NODARA and add the flasher metadata (hw model 151, slug, display name, tags, support level). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Nodara: power MCU link review fixes Scan every buffered frame for the power-on confirm, poll the idle line every 250 ms instead of 20 ms, and name the boot wait as the uptime deadline it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Nodara: menu shutdown points at the power button A software shutdown leaves the board powered, since the power MCU only cuts power on its own button. Add POWER_BUTTON_SHUTDOWN_HINT: when a variant defines it, the Shutdown menu shows it as a notification instead of the confirmation. Nodara sets it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: steve.su <steve.su@sainstore.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ides, Strict exemptions (meshtastic#12191) * Rework XEdDSA signing policy: Compatible stops signing, AES-CCM overrides Signing costs 66 bytes per packet. On a crowded mesh that matters, and packets on an AES-CCM channel were paying it on top of the CCM tag. Sending (perhapsEncode): - Compatible, the default, no longer signs. Balanced and Strict sign broadcasts as before; licensed (ham) nodes also sign unicasts, but now only under Balanced or Strict. - Packets on an AES-CCM (use_aead) channel are never signed. This also removes the case where a signed CCM packet passed signedDataFits() but then failed the CCM size check with TOO_LARGE. Receiving (checkXeddsaReceivePolicy): - Only Strict drops a packet for lacking a signature. Balanced no longer drops unsigned broadcasts from known signers; that downgrade rule and its canonical-sizing helper are removed. - An AES-CCM channel overrides Strict: its unsigned or unverifiable packets are accepted, since the CCM tag authenticates them. - A malformed or failing signature still drops the packet in every mode. Identity (new NodeDB::refusesUnsignedIdentity, used by updateUser, NodeInfoModule and TrafficManagementModule): - Balanced and Strict still refuse an unsigned NodeInfo that would change a known signer's identity, except on an AES-CCM channel. - Compatible accepts it, since firmware before 2.8 strips signatures when relaying. Channels::isAEADEnabled() now returns false for an out-of-range index instead of logging, since receive paths pass a packet's index unchecked. XEDDSA_SIGNATURE_FIELD_BYTES lost its only user and is removed. Tests: test_packet_signing drops the cases that pinned the removed downgrade sizing (A7-A9, A18-A19, E5, E10-E13, D1), flips A4 and E1 to acceptance, and adds AES-CCM, Compatible and ham cases (A18-A20, B18-B20, N12-N13). test_mqtt and test_traffic_management pin the same rules on the plaintext-MQTT and NodeInfo-cache paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Sign NodeInfo unicasts, forward signed requests, check signed NodeInfo keys A NodeInfo request or reply carries the key a peer will use for PKI direct messages, but off ham mode only broadcasts were signed. A Strict node therefore dropped every NodeInfo reply and could only learn a new peer's key from a signed broadcast, hours apart. - perhapsEncode: under Balanced and Strict, NodeInfo is signed even when unicast (still never on an AES-CCM channel, never in Compatible). - TrafficManagementModule: a NodeInfo request that carries a signature is forwarded instead of answered from the cache. Its sender may need a signed reply, and a reply sent on the target's behalf can never be signed. - checkXeddsaReceivePolicy: a signed NodeInfo verified against a stored key must advertise that same key, and the key must hash to the sender's node number, as first contact already requires. Otherwise it is dropped. This stops a valid signature vouching for another key, and stops a key planted on trust under the wrong node number proving itself with fresh signatures. Tests: B7 now expects NodeInfo unicasts signed; B21 pins signing per policy; A21-A23 pin the key-consistency check; a traffic-management case pins that a signed request is forwarded while the same request unsigned is answered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Strict: accept unsigned acks and traceroutes; never sign traceroutes Strict dropped every unsigned packet that was not PKI-encrypted. Acks and traceroutes are never signed, so a Strict node saw no acks, retransmitted every reliable packet to exhaustion, and could not take part in traceroutes. - checkXeddsaReceivePolicy: Strict no longer drops an unsigned ROUTING_APP packet; an ack carries its own proof (AckProof.h). Traceroutes are accepted in every mode, and any signature on one is ignored rather than verified. - perhapsEncode: a traceroute is never signed, including in ham mode, which signs other unicasts. Every relay rewrites the route, so a signature fails verification from the second hop on, and a bad signature drops the packet in every mode. - TraceRouteModule::alterReceivedProtobuf: strip any signature when rewriting the route, so a traceroute signed by older firmware is not dropped by older receivers further along. Tests: A24-A26 (Strict accepts unsigned acks and traceroutes; a traceroute signature is ignored), B22 (traceroutes never signed), B7 updated, and test_traceroute_nexthop pins the signature strip on rewrite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Drop the traceroute signature strip Only 2.8.0 and 2.8.1 pre-release nodes ever signed a traceroute: ham mode, which signs every unicast, and client-sent broadcast traceroutes. Stripping the signature on rewrite only helped those pre-releases interoperate, which does not justify the code. Receivers still ignore a traceroute signature, and traceroutes are still never signed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Sign unicast positions under Balanced and Strict A position request or reply is PKI-excluded, so a signature is its only authentication, but off ham mode only broadcasts were signed. Strict dropped every position request and reply, and under Balanced any channel member could fake another node's location with an unsigned reply. perhapsEncode now signs POSITION_APP unicasts under Balanced and Strict, as it does NodeInfo. Nothing rewrites a position in flight - precision is applied by the originator before signing - so the signature survives relaying, and it binds request_id so a reply cannot be retargeted. Compatible and AES-CCM channels still sign nothing. Tests: B23 pins position unicast signing per policy and Strict acceptance; B7 expects position unicasts signed; B2 and B5 use PRIVATE_APP as their unsigned unicast now that position is signed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Strict: accept unsigned key verification messages Key verification's bootstrap messages (M1, M2) go out channel-encrypted because the peer's key is not known yet, so they cannot carry a signature the receiver could verify. Strict dropped them, so two Strict nodes that did not already hold each other's keys could never run key verification. The handshake does not rely on the transport: hash1 binds both public keys to the security number the users exchange, and a key is only stored after the numbers match and the user accepts. A forged request can at most raise a verification prompt, bounded by the single session slot and the 60 s remote cooldown - as it already can under Balanced. Test: A27 pins that Strict accepts an unsigned key verification message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * test_nodedb_identity_hygiene: pin the identity refusal to Balanced Two cases expected updateUser() to refuse an unsigned update from a known signer under a zeroed config. Zeroed is Compatible, which now accepts it, so they failed in CI. Run them under Balanced, and add the Compatible counterpart so both halves of the rule are pinned at the updateUser level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Ham mode: sign in place of PKI, authenticate acks with the ack proof (meshtastic#12200) Ham mode signed every unicast under Balanced and Strict, acks included. A signed ack from a node whose key a Strict receiver lacks is dropped, so the sender retransmits to exhaustion, and the signature costs 66 bytes where the ack proof costs about 10. Ham mode now signs the unicasts PKI would cover in regular mode, plus broadcasts, NodeInfo and position as before. Acks are no longer signed; they carry the ack proof, which needs only the two nodes' keys, not PKI. Strict already accepts unsigned acks. Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK Co-authored-by: Claude <noreply@anthropic.com> * Notify the client when an unsigned identity update is refused Balanced and Strict refuse an unsigned NodeInfo that would change a known signer's name or role, but the refusal only reached the device log, so the user saw a stale name with no reason. Send a ClientNotification naming the node (by the name already held, never the refused claim) and how to accept its updates again: remove the node. Rate limited to once per node per boot, over the last 8 nodes, and to one notice per minute overall, so a signer relayed through pre-2.8 firmware or a burst of them cannot flood the client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Refused identity notice: only for a name or role change, drop the warnings Notify only when the refused NodeInfo would have changed the stored long name, short name or role, compared through CopyUserToNodeInfoLite so truncation and sanitizing cannot read as a change. A signer held only in the warm tier has no stored name to compare and is not reported. Remove the two LOG_WARNs at the refusal sites; the notice replaces them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Refused identity notice: name the security setting that refused it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Refused identity notice: one global hourly throttle, no per-node tracking Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK --------- Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
…tastic#12220) The Adafruit BLEDfu handler switches to the main stack and jumps to the bootloader. Since the nRF52 toolchain moved to GCC 12.3.1 (meshtastic#12027) that call is compiled as a tail call whose epilogue pops the task frame from the main stack, past the end of RAM, and the CPU locks up; the bootloader then starts with GPREGRET 0xB1 and no SoftDevice, faults, and resets into the app. Handle START_DFU here instead: write the same peer data and leave through enterOTADfu(), a reset with the 0xA8 magic that every bootloader handles.
* feat(beacon): per-target and offered frequency slots Implements the slot fields from meshtastic/protobufs#1059 on top of the broadcast_targets scheme from meshtastic#11646: - BroadcastTarget.frequency_slot pins a target's slot. Unset derives it the way a node on that channel would: the target region's override slot, else the hash of the target channel's name on the target preset. A target on the home radio keeps the home slot. A pin the target region does not hold skips the target. The deprecated ChannelSettings.channel_num is no longer read, and the home node's channel_num no longer leaks into other presets. - broadcast_offer_frequency_slot is advertised as MeshBeacon.offer_frequency_slot only when a receiver could not derive it. An offer pinned to a slot its region lacks is withheld; the text still goes out. - The listener keeps a received offer's slot. - Admin clears slot 0, and a slot past an explicit region and preset. - userPrefs: USERPREFS_MESH_BEACON_OFFER_FREQUENCY_SLOT and USERPREFS_MESH_BEACON_TARGET_<N>_FREQUENCY_SLOT. Also from meshtastic#11662: - A beacon is addressed at its target's channel index, so perhapsEncode() keys it like any other packet; the primary channel is never overwritten. The TX hook writes the target preset's channel hash for a blank-named channel on another preset. - A beacon whose send() did not queue releases its target entry on every failure, not only ERRNO_SHOULD_RELEASE. - A beacon still queued a broadcast interval after it was armed, or whose entry was evicted from a full table, is dropped at TX instead of going out on the home radio. Supersedes meshtastic#11662. Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com> * fix(beacon): address review of frequency slots - TX gate checks the target preset against its region or EU sibling, not the home slot - Targets and offers run an EU sibling's preset in that sibling, as the switch does - Switch clears override_frequency and restores tx_power; restore keeps fields edited mid-switch - An unusable channel_index skips the target instead of falling back to the primary - Beacon config validation moved to MeshBeaconModule::sanitizeConfig(), also run on load; message cap no longer ends mid-character - Evicted-id ring sized to the TX queue * fix(radio): frequencySlotCount() reports no slots for UNSET UNSET's table span is a placeholder; an unknown region code resolves to it too. * fix(beacon): slot count and restore for custom bandwidth - frequencySlotCount() counts a custom bandwidth wider than the region at the region default, as the clamp runs it - resolveFrequencySlot() falls back to the hash when the region override slot exceeds the count - The beacon switch restores a custom bandwidth applyModemConfig() clamped for the target region * fix(beacon): run a target's named preset on a node with custom modem params The switch sets use_preset for a target that names a preset and restores it after TX. A target naming no preset keeps the custom params. * fix(beacon): keep an out-of-range target channel_index sanitizeConfig() cleared has_channel_index for an index past the channel table. That turns the entry into an unset-index target, and sendBeacon() sends those on the primary channel - under a name and key the operator never named. sanitizeConfig() now also runs from loadFromDisk(), so a stored or userPrefs index did that at every boot, not only on the write that set it. Keep the index and only warn. sendBeacon() already bounds-checks it against channels.getNumChannels(), which is tighter than MAX_NUM_CHANNELS, before getByIndex(), and skips the target - so nothing can index out of bounds, which is what the clear was there for. test_adminValidation_targetChannelIndexOutOfRange_isCleared becomes _isKeptAndSkipped: the index survives the write, and the stored config emits no beacon rather than one on the primary. Reported by NomDeTom on meshtastic#12126. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SwNPp17REf1c9je42aWyJa --------- Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com> Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
66 upstream commits since 364a111. Conflicts in NodeDB.cpp and sleep.cpp, both resolved to keep the fork's behavior. Dropped from upstream meshtastic#11930 (deferred node saves): the main-loop saveNodeDatabaseIfDirty() retry and the notifyReboot observer that wrote nodes.proto. Both are automatic writes of peers learned over the air, and the reboot one would also fire on automatic reboots. nodes.proto stays self + favorites, written only on a user action. test_nodedb_save_retry's new upstream cases are turned around to pin that: a heard peer is never written, and a reboot writes nothing. sleep.cpp keeps RadioLibInterface.h (upstream's LoRa wake-pin restore uses it) and leaves out TransmitHistory.h, which the fork no longer uses there. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rm1ZFVzCeSRfc2ieYrtKrh
clang-format 20.1.0 and prettier 3.9.10, matching .trunk/trunk.yaml. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rm1ZFVzCeSRfc2ieYrtKrh
prokrypt
marked this pull request as ready for review
October 25, 2026 03:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Clankconductor · project thread
Summary
Merges 66 upstream
developcommits (364a111..95ff510) intono-auto-writes.Two conflicts, both resolved toward the fork's rules:
src/mesh/NodeDB.cpp: kept the favorites-only serializer and the removedupdateUser()save.src/sleep.cpp: keptRadioLibInterface.h(upstream's LoRa wake-pin restore needs it) and left outTransmitHistory.h, which the fork no longer uses there.Upstream code dropped
Upstream meshtastic#11930 ("keep deferred node saves pending") added two automatic
nodes.protowriters. Both are removed:NodeDB::saveNodeDatabaseIfDirty(), called every main-loop pass. It wrote peers heard over the air, up to once a minute.notifyRebootobserver that saved the node DB on every reboot, automatic reboots included.The fork had already removed
lastNodeDbSave, so these would not have compiled anyway. The new upstream cases intest_nodedb_save_retryare turned around to pin the fork's contract: a heard peer is never written, and a reboot writes nothing.Audit of the rest of the upstream diff
No other new flash writers or key storage turned up. I grepped every added line for save/proto/file-open/NVS/raw-flash/format/remove/favorite calls and read the commits that looked risky:
if (favorite) savegate at the end ofcommitVerifiedRemoteNode()still applies.sanitizeConfig()at load runs in RAM only.Testing
Not compiled or run. PlatformIO isn't available in this sandbox. Run
./bin/run-tests.sh(in particulartest_nodedb_save_retry,test_packet_signing,test_nodedb_identity_hygiene) on a real machine before flashing.Generated by Claude Code