Commit Graph
12706 Commits
Author SHA1 Message Date
jp-bennett 99ffa427ad Update protobufs 2026-09-17 17:08:14 +00:00
Jason P a4e8b9444f baseui_fixfavoritesonsmalllcds (#11883) 2026-09-17 15:46:42 +00:00
rcarteraz 7bfa062f27 fix(esp32): raise T-Watch S3 to support level 1 (#11884) 2026-09-17 15:46:38 +00:00
renovate[bot] dbbd42687b chore(deps): update meshtastic/crypto digest to 1c817c2 (#11879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-17 12:03:28 +00:00
renovate[bot] d1cefc8382 chore(deps): update fusion digest to 2051197 (#11878)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-17 12:03:19 +00:00
Thomas Göttgens f36a1ea821 nRF52: reclaim flash to bring rak4631 back under its size budget (#11873)
* build(nrf52): drop unused TinyUSB classes and assert function-name strings

Only the CDC class is used on nRF52. Disable the MSC, HID, MIDI, vendor and
video class drivers in the Adafruit TinyUSB config, and pass an empty
__ASSERT_FUNC so assert() no longer embeds __PRETTY_FUNCTION__ strings.
File and line are still reported.

rak4631 estimate: ~7.4 KB flash, ~2.3 KB RAM.

* fix(nrf52): link only the secp256r1 cc310 curve domain

CRYS_ECPKI_GetEcDomain indexes ecDomainsFuncP, which references the
parameter tables of all eleven cc310 curves. Bluefruit LESC pairing only
requests secp256r1, so override the lookup to return that domain alone.

rak4631 estimate: ~7.4 KB flash.

* fix(airtime): replace powf in the channel-utilization EMA fold

foldChannelUtil was the only powf caller on nRF52. The exponent is an
integer step count, so raise the EMA factor by squaring instead; a
multi-day sleep still folds in at most 32 multiplications.

rak4631 estimate: ~1.9 KB flash.

* fix(graphics): use double sin/cos in the compass renderers

The compass renderers were the only sinf/cosf callers on nRF52 screen
builds, pulling in the float trig kernels next to the double ones GeoCoord
already links. Call the double variants instead.

rak4631 estimate: ~3.2 KB flash.

* fix(motion): use double atan2 for magnetometer heading fallbacks

MMC5983MA, QMC6309 and the InkHUD map centre were the remaining
application atan2f callers. The double atan2 is already linked, so the
float variant only added atan2f, __ieee754_atan2f and atanf. The saving
lands once meshtastic/Fusion#1 removes the library's atan2f as well.

rak4631 estimate: ~0.8 KB flash with Fusion#1.

* fix(hopscale): trim diagnostic logging to state changes and anomalies

Drop the save/restore confirmations, the hourly histogram and trend dumps,
the denominator step logs and the per-packet hop_limit log (printPacket
already reports HopLim). Keep the save-failure and histogram-full warnings,
the congestion on/off transition and a single periodic status line, and
remove lastScaledPerHop, which only fed the logs.

* fix(hopscale): silence cppcheck uselessAssignmentArg on restored count

* perf(crypto): use full-schedule AES128/AES256 for AES-CCM

aesSetKey used AESSmall128/AESSmall256, which re-derive round keys for
every block. AES128/AES256 precompute the schedule, encrypt faster and are
already linked by encryptAESCtr, so the AESSmall*/AESTiny* code drops out.
No change on ESP32, where AESSmall* already aliases AES128/AES256.

rak4631 estimate: ~3.9 KB flash; cipher object up to 184 bytes larger.

* perf(nrf52): use the shared software CTR for AES-256 and remove tiny-aes

CryptoCell only accelerates AES-128, which stays on hardware. AES-256 CTR
now calls CryptoEngine::encryptAESCtr (rweather CTR<AES256>, already
linked) instead of the in-tree tiny-aes copy, whose sources were removed
in the previous commit. Output is identical.

rak4631 estimate: ~0.8 KB flash.

* perf(mesh): use std::map for pending retransmissions and API port timestamps

NextHopRouter::pending and PhoneAPI::lastPortNumToRadio were the only
unordered_map instances linked on nRF52. Switching them to std::map, which
is already linked, drops the libstdc++ hashtable, rehash policy and prime
table. GlobalPacketId gains operator<; the unused hash functor is removed.

rak4631 estimate: ~2.1 KB flash.

* perf: parse sensor decimals without strtod

The WS85 serial parser (strtof) and DFRobotLarkSensor (String::toFloat)
were the only callers of newlib's strtod. Add parseDecimalFloat to
meshUtils for plain [+-]digits[.digits] fields and use it at both sites.
Covered by test_type_conversions against strtof.

rak4631 estimate: ~4.5 KB flash.

* perf(gps): compute tan from sin/cos in UTM and OSGR conversion

latLongToUTM and latLongToOSGR were the only tan callers. sin and cos are
already linked, so deriving tan from them drops tan and __kernel_tan.

rak4631 estimate: ~1.1 KB flash.

* fix(graphics): only dispatch the theme menu when TFT coloring is enabled

The Theme option is only offered with GRAPHICS_TFT_COLORING_ENABLED, but
handleMenuSwitch dispatched ThemeMenu unconditionally, linking kThemes and
the theme accessors into monochrome builds where the menu is unreachable.

rak4631 estimate: ~1 KB flash.

* fix(senxx): trim diagnostic logging to errors and user-visible actions

Keep all errors and warnings and a single version line; shorten the admin
action messages; drop progress chatter, state save/restore confirmations and
the per-reading and VOC-state debug dumps. The nested VOC restore branch
collapses to one condition with the same behaviour.

rak4631 estimate: ~2 KB flash.

* build(nrf52): define CRYPTO_AES_NO_DECRYPT

CTR and CCM only encrypt, so the AES inverse tables and round helpers are
dead code on nRF52. Takes effect once the Crypto dependency includes
meshtastic/Crypto#5.

rak4631 estimate: ~1.0 KB flash.
2026-09-17 09:36:41 +00:00
Thomas Göttgens 67e8aafef7 fix(http): hold spiLock only for filesystem calls in the HTTP file handlers (#11870)
* fix(http): hold spiLock only for filesystem calls in the static and upload handlers

* fix(http): hold spiLock only for filesystem calls in the browse and delete handlers

* fix(http): abort an upload when a write comes up short
2026-09-16 22:01:55 +00:00
ae8dee9582 Fix: MQTT topic not updated when LoRa region changes (#10565)
* Initial plan

* Fix: update MQTT topics when LoRa region changes

When the LoRa region is changed via AdminModule::handleSetConfig,
moduleConfig.mqtt.root is updated (e.g. from msh/US to msh/EU_868)
but the running MQTT instance kept using the stale topic strings
(cryptTopic / jsonTopic / mapTopic) that were set at construction time.

Introduce MQTT::reinitTopics() which:
- resets the topic strings to their base values and prepends the
  current moduleConfig.mqtt.root, and
- disconnects from the broker so the next reconnect re-subscribes
  under the new topic prefix.

Call reinitTopics() from MQTT's constructor (replacing the inline
block) so the logic lives in one place, and call it from
AdminModule::handleSetConfig right after moduleConfig.mqtt.root is
rewritten on a region change.

Add a unit test (test_reinitTopicsUpdatesOnRegionChange) that verifies
both the updated subscriptions and the updated publish topic after a
simulated region change.

* Fix: call mqtt->reinitTopics() on region change via menuhandler

* Format MQTT test with trunk style

* fix(mqtt): drop undeclared jsonTopic refs in reinitTopics()

reinitTopics() assigned to a jsonTopic member that does not exist on this
branch (the MQTT class only has cryptTopic and mapTopic), so MQTT.cpp failed
to compile ("'jsonTopic' was not declared in this scope") and broke every
build that compiles it. Remove the jsonTopic lines so reinitTopics() rebuilds
exactly the topics the original constructor did.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(mqtt): simplify reinitTopics and its call sites

* fix(mqtt): rebuild topics in runOnce when the root changes

Replaces the per-call-site reinitTopics() calls. Also keep the device state and node database segments when the EU clamp swaps the region.

* fix(mqtt): refresh topics in onSend when the root changed

Rename the region change test to underscore-separated segments.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-09-16 21:47:27 +00:00
Thomas Göttgens ce7e6e448d Separate the nRF54 platform code from src/platform/nrf52 (#11867)
* Move the nRF54L platform code into src/platform/nrf54l15 and drop the ARCH_NRF54L branches from src/platform/nrf52

* Name the platform directory nrf54 so future nRF54 variants can share it

* Rename the nRF54 platform base to nrf54_base in variants/nrf54l15/nrf54.ini

* Leave the SoftDevice random seed to the Bluefruit core, which seeds in begin() and answers NRF_EVT_RAND_SEED_REQUEST

* Seed the SoftDevice from checkSDEvents() when it pops NRF_EVT_RAND_SEED_REQUEST
2026-09-16 21:21:41 +00:00
github-actions[bot]andjp-bennett 7469d52900 Update protobufs (#11874)
Co-authored-by: jp-bennett <5630967+jp-bennett@users.noreply.github.com>
2026-09-16 23:19:47 +02:00
Jonathan Bennett 19dfa1385b Use new store-and-forward original_id field (#11849) 2026-09-16 17:28:24 +00:00
Ben MeadorsandThomas Göttgens ff9a2b1b86 fix(http): give a TLS session the contiguous heap it actually needs (#6960) (#11838)
* fix(http): give a TLS session the contiguous heap it actually needs (#6960)

An HTTPS connection to a no-PSRAM ESP32-S3 fails its handshake with
MBEDTLS_ERR_SSL_ALLOC_FAILED and the client gets a TCP reset. Four things
were in the way, and the first two are the bug.

mbedtls_ssl_setup() makes two independent calloc() calls, one inbound
record buffer and one outbound, each needing its own contiguous block of
16717 bytes. The admission gate compared ESP.getFreeHeap(), which is a sum
over every free block and says nothing about the largest one, so on a
fragmented heap it waved the connection through and let the handshake be
the thing that discovered there was no room.

Size the two record buffers separately. Inbound stays at the RFC maximum
because browsers and our own MQTT and OTA clients receive full-size
records; outbound only ever carries what this device sends, 4 kB at a time
at most. That is 12288 bytes of contiguous heap back per session, which is
the difference between one usable HTTPS connection and two. These are the
ESP-IDF defaults - Espressif's arduino-lib-builder is what turns the
asymmetric split off. It replaces CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN,
which no longer applies: that symbol is `depends on
!MBEDTLS_ASYMMETRIC_CONTENT_LEN`, and mbedTLS dropped it in 3.0.

Then ask the allocator the question mbedTLS is about to ask it, rather
than a different one. The probe holds both record buffers at once, as
setup() does, through the same heap that CONFIG_MBEDTLS_*_MEM_ALLOC pins
mbedTLS to - a plain malloc would be served from PSRAM that mbedTLS never
touches. Not heap_caps_get_largest_free_block(), which walks every block
under the allocator lock and tripped the interrupt watchdog in #11666.
Closed connections are reaped before the measurement, so the library
cannot free a slot behind it and accept into it unmeasured, and so the
probe sees the memory a finished session just returned. The answer is
advisory: the heap can still turn between the probe and setup(), which
makes the failure rare rather than routine.

A smaller outgoing record makes an existing bug much easier to hit.
mbedtls_ssl_write() accepts at most one record per call and returns a
short count for the rest, and neither the server library nor Print::print()
loops on that, so any response body larger than one record was quietly cut
off - /json/nodes has been truncating at 16 kB already. Loop at the bulk
write sites. Looping on the returned count also covers a client that
negotiates a smaller maximum fragment length, which a fixed-size chunker
would not.

/json/nodes also built its whole body in one std::string. A full node DB
is 200 entries of ~240 bytes, and std::string grows by doubling, so it
asked for a 64 kB contiguous block while still holding a 32 kB one, on a
board with ~50 kB of heap, through operator new, which aborts rather than
throws here. Stream it a node at a time instead.

Finally, stop every node in the fleet paying for geofencing. The crossing
table reserved all 256 slots at construction, 4 kB, though a node with no
geofenced waypoints never tracks a crossing at all. Allocate on the first
one instead, stepping the capacity explicitly so there is a size to probe
for, and hand the block back once the last waypoint is gone. The probe
matters because this allocation has moved off the pristine boot heap onto
a live one, where a reserve() that cannot find the block would abort;
a refusal now degrades into the bounded-drop path that already exists.

heltec-v3 builds clean from scratch with the IDF recompiled, and comes out
20,656 bytes smaller in flash and 172 bytes smaller in static RAM. Full
native suite is green at 1414 cases.

* style(http,geofence): cut the added comments to the repo's one-or-two-line rule

The rationale belongs in the commit message and the PR, not in block comments
above every new symbol. No behaviour change; the binary is byte-identical.

* fix(geofence): index the capacity probe write to clear cppcheck uninitdata

* fix(http): stop the /json/nodes body at the first failed write

* fix(geofence): grow crossing state with realloc so a refused allocation drops instead of aborting

* refactor(http): probe through esp_mbedtls_mem_calloc and fold the free-slot scan into reaping

* refactor(geofence): allocate the crossing table once on first use and free it on store changes

* fix(http): probe through mbedtls_calloc so the calls keep C linkage

* fix(http): name the check that refused a TLS session, and sum the right heap

Both failure modes logged the same line, so a refusal on the free-heap
threshold read as fragmentation. The probe returns a verdict now and the
warning names the check that failed.

That threshold also read ESP.getFreeHeap(), the internal heap, while the probe
below it goes through mbedtls_calloc() - PSRAM wherever EXTERNAL_MEM_ALLOC
sends mbedTLS, which [device-ui_base] sets. There is no free-size query on the
allocator, and the handshake's remaining small blocks want a sum rather than a
block to probe for, so the sum asks heap_caps for the same heap.

* fix(http): probe the TLS heap only when a client is waiting for a slot

The probe holds ~21 kB across two callocs and ran whenever a slot was free,
which on a board with nobody connecting is every 50 ms while the web server is
active and every second when it is idle - on exactly the low-heap boards this
branch is for.

It now runs only when a zero-timeout select() on the listen socket says a
client is already in the backlog, which is the question loop() asks a few
microseconds later. With nothing knocking the server drives its open
connections and accepts nothing, so a connection is never accepted past an
unmeasured gate; one that lands between the two select()s waits a tick.

* perf(http): flush /json/nodes a couple of kB at a time

One writeAll() per node is one mbedtls_ssl_write() per node, about 200 of them
for a full node DB. Filling a 2 kB buffer first cuts that by roughly 9x and
still bounds the allocation, which is why the whole-body string went away in
the first place.

* fix(geofence): keep the table-full warning for a full table

A refused malloc shared the one-shot table-full warning, so a momentary heap
dip spent the message that means the 256-entry cap was reached, and the real
thing then never printed. They are separate now, and the allocation failure
throttles rather than latching, because the next position may well find the
4 kB the table needs.

* fix(http): close a waiting client the TLS heap check refuses

* fix(http): hold spiLock only for filesystem calls in the browse and delete handlers

* refactor(http): leave the browse and delete spiLock scoping to #11870

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-09-16 16:52:00 +00:00
Manuel 4b3f2a2beb fix drop_stale_sdkconfig_defaults() (#11861) 2026-09-16 16:15:22 +00:00
Austin a70fe9ea10 Actions: Build MacOS 27, Drop 15 (#11851)
MacOS 15 will still be built in homebrew, but not tested as part of CI here.
2026-09-16 14:04:51 +00:00
Tom ee76117835 fix(router): relay opaque packets in CORE_PORTNUMS_ONLY (#11844)
* fix(router): relay opaque packets per rebroadcast_mode, not only in ALL

d6b12ea3f (#10967) moved undecryptable packets onto relayOpaquePacket(), which
relays only in ALL and ALL_SKIP_DECODING. A PKI unicast between two other
nodes - remote admin, a DM, key verification - is opaque to a relay, so a
router in CORE_PORTNUMS_ONLY (the ROUTER role default) stopped carrying any
of it, and KNOWN_ONLY / LOCAL_ONLY lost the rule 6eabbaf43 added in 2024 that
relays a PKI-shaped unicast with one known party. The old rule was still in
RoutingModule::handleReceivedProtobuf, unreachable: encrypted packets no
longer reach modules.

opaqueRelayAllowedByMode() gives each mode an explicit opaque rule:
ALL / ALL_SKIP_DECODING / CORE_PORTNUMS_ONLY relay (the port list cannot apply
to a packet with no readable port); KNOWN_ONLY / LOCAL_ONLY relay a channel-0
unicast whose sender or destination has a User in NodeDB; NONE relays nothing.
The dead RoutingModule block is removed; its licensed-party check stays for
decoded packets. Nothing here reads packet_signature_policy.

* fix(router): NAK, phone delivery and MQTT uplink for packets we cannot read

Before #10967 an undecryptable packet addressed to us reached RoutingModule,
which handed it to the phone and, via ReliableRouter::sniffReceived, answered
a want_ack unicast from an unknown sender with PKI_UNKNOWN_PUBKEY - the NAK
that makes the sender transmit its NodeInfo so its retry decrypts. A PKI DM
between two other nodes was uplinked to MQTT as ciphertext when encrypted
uplink was on. All three stopped: the gate REJECTs a to-us decode failure
before any module runs, and the pki_encrypted marking in dispatchReceived is
unreachable for opaque ingress.

passesRoutingAuthGate() now treats every DECODE_FAILURE not from us as opaque
(isFromUs stays REJECT, #11544). The opaque branch calls handleOpaqueForUs(),
which NAKs a want_ack unicast to us (PKI_UNKNOWN_PUBKEY when we hold no key
for the sender, NO_CHANNEL otherwise) and queues a frame we had no way to
read - PKI without the sender's key, or a channel hash matching nothing we
hold - straight to the phone via sendToPhone(), bypassing handleFromRadio()
so an unverified sender never touches NodeDB. A matched-and-failed frame
(bad key, tampering, junk) is NAKed but not delivered. uplinkOpaqueUnicast()
restores the MQTT path for channel-0 unicasts not to or from us, gated on
mqtt.enabled and mqtt.encryption_enabled; the dead marking is removed.

* test(packet_signing): pin what a relay does with traffic it cannot read

Group R builds genuine PKI-encrypted packets between two generated
identities and runs them through ingress under every rebroadcast_mode:

  R1  remote admin between two known nodes relays in every mode but NONE
  R2  between strangers: KNOWN_ONLY / LOCAL_ONLY decline, the rest relay
  R3  one known party satisfies KNOWN_ONLY / LOCAL_ONLY
  R4  an unknown-channel broadcast relays in ALL / ALL_SKIP / CORE only
  R5  undecryptable DM to us: one PKI_UNKNOWN_PUBKEY NAK, phone gets the
      frame, nothing relayed, sender not added to NodeDB - in every mode
  R6  the same frame claiming to be from us gets no reaction
  R7  sender key held but wrong: NAK NO_CHANNEL, no phone delivery
  R8  opaque PKI unicast is uplinked only with encrypted MQTT uplink
  R9  unknown-channel broadcast reaches the phone without touching NodeDB
  R10 the relay decision is identical under all three signature policies

C6 no longer lists CORE_PORTNUMS_ONLY as a mode that suppresses opaque relay
and expects the phone to see an unreadable frame; the RoutingModule mock
records the NAK reason.

* test(rebroadcast_mode): give the relay policy its own suite, sharing the ingress harness

test/support/AuthPipelineHarness.h now holds the mock NodeDB, the counting radio /
router / routing-module / module / MQTT, the packet builders (decoded, channel-
encrypted, PKI between two generated identities) and the per-process / per-test
lifecycle that test_packet_signing kept locally.

test_rebroadcast_mode pins what this node carries for others, per
DeviceConfig.rebroadcast_mode: remote admin between two known nodes relays in
every mode but NONE; strangers are declined by KNOWN_ONLY / LOCAL_ONLY and
carried elsewhere; one known party suffices; an unknown-channel broadcast relays
in ALL / ALL_SKIP / CORE only; a licensed node never relays ciphertext and
relays plaintext unless a party is known unlicensed; hop_limit 0, id 0, a
foreign next_hop and CLIENT_MUTE each stop a relay; the signature policy
changes none of it. Registered in state-manifest.tsv and the routing shard.

test_packet_signing keeps what follows from the auth gate's verdict: C9-C11 now
carry want_ack and hops so their "nothing happens" assertions are no longer
vacuous (junk on a held channel relays but never reaches the phone; a legacy
DM and a malformed PKI plaintext to us are NAKed NO_CHANNEL once and nothing
else), and C18-C22 cover the to-us NAK / phone / MQTT outcomes. Comments on the
src side trimmed to the two-line rule.

* fix(router): classify an opaque frame from the decode attempt, not the header

handleOpaqueForUs() re-derived "unreadable" from the wire header and NodeDB,
which disagreed with what perhapsDecode() had just found: a hash-0 broadcast
from a sender whose key we hold read as readable although PKI never applies to
a broadcast, and a pending-key decrypt rejected as malformed read as unreadable
because no stored key existed. Both changed the NAK reason and whether
ciphertext reached the phone.

passesRoutingAuthGate() now hands the attempt's DecodeState out and the handler
takes unreadable = (state == DECODE_OPAQUE). For that to be precise,
perhapsDecode() sets pkiAttempted only when a sender, pending or admin key was
actually tried, and the KNOWN_ONLY short-circuit - which declines before any
attempt - reports OPAQUE for a PKI-shaped unicast to us or an unheld hash and
FAILURE for a held channel. isUnreadableToUs() is gone; Channels::hasHash()
replaces its loop.

* test(support): free the harness AirTime and NodeStatus before restoring the originals

pipelineHarnessDestroy() restored the saved pointers and orphaned the two
objects it had installed. LeakSanitizer reported the NodeStatus as a 160-byte
direct leak and errored test_packet_signing and test_rebroadcast_mode at exit
in the coverage shards while every case passed.

* fix(router): a failed admin-key fallback does not count as a decrypt attempt

Every configured admin key set pkiAttempted, so on a node with any admin key
an unknown sender's DM read as DECODE_FAILURE: NO_CHANNEL instead of
PKI_UNKNOWN_PUBKEY, and withheld from the phone, so the sender never learned to
send its NodeInfo. An admin key that fails says nothing about the sender; only
the sender's own (or pending) key counts. A successful-but-malformed admin
decrypt already returns DECODE_FAILURE directly.

* test(packet_signing): pin decode provenance for admin keys, forged from-us frames, hash-0 broadcasts and KNOWN_ONLY strangers

C18 configures an unrelated admin key so the fallback runs and fails. C19
installs our identity so the forged frame is a real decrypt attempt and asserts
the gate's REJECT before the side effects. C23: a hash-0 broadcast from a keyed
sender on a channel we do not hold is unreadable and reaches the phone. C24:
KNOWN_ONLY declines a stranger on a held channel as matched, so the phone never
sees it, while the same stranger on an unheld channel is unreadable.

* test(support): restore the caller's DH key, model the TX queue and ACK/NAK log, clear per-process state between tests

The ingress harness builds its router, radio, routing module and crypto engine
once per process, so anything they carry decides the next test's outcome. Three
of those carried surfaces were already deciding one.

makePkiUnicastBetween() ended by installing a fresh random DH key, so a caller
that set its own key before building a frame silently lost it. C19 did exactly
that: its REJECT assertion passed because the decrypt failed on a key mismatch,
not because the from-us arm rejected the forgery, and would have stayed green
with that arm deleted. CryptoEngine::private_key is public under
PIO_UNIT_TESTING, so the helper now saves the engine's key and puts it back;
the trap is closed for every caller rather than worked around in one. C19 also
builds the frame before installing our identity and gains a control assertion:
without our key in NodeDB the same frame is OPAQUE_RELAY_ONLY, which is what
makes the REJECT attributable.

The opaque dedup ring survived a whole suite unreset. That was tolerable while
it only gated relay; it is about to gate the NAK, phone delivery and MQTT
uplink too, where a stale (from,id) would silently zero a later test's
expectations instead of failing it. Cleared per test, along with the DH key,
any pending handshake key, and the admin-key fallback budget that C18 drains
six tokens from. resetAdminKeyFallbackBudget() is defined under
!MESHTASTIC_EXCLUDE_PKI but declared unguarded, so the call site is guarded.

installOurIdentity() now marks HAS_USER on our own node, as NodeDB does on a
device. The rebroadcast_mode predicates read that bit, and markOurselvesLicensed()
keeps owner.is_licensed and our NodeDB record in agreement for the same reason:
getLicenseStatus(us) must say Licensed, not NotLicensed.

The radio and routing-module mocks become models rather than counters, since
every suite including this header gets them. The radio holds a real TX queue
that findInTxQueue() consults and that cancelSending()/removePendingTXPacket()
take entries out of, and it records each frame it was handed so a test can
assert a hop limit or relay_node instead of a call count. The routing module
keeps every ACK/NAK with its destination, channel and hop limit, so a second
NAK can be pinned without losing the first; its reset() replaces the six sites
that zeroed ackCalls by hand, which would otherwise desync the log from the
counter. Phone-queue draining moves into the harness for the suites that both
need it.

* refactor(router): the opaque path lives in Router, not NextHopRouter

A pure move. Nothing about handling a frame we cannot read is next-hop
specific: relayOpaquePacket() reads iface, isToUs/isFromUs, the device role,
owner.is_licensed, the last byte of our node number and the packet's own
header, then calls Router::send(). The one thing that held it in the subclass
was the (from,id) dedup ring, and that landed in NextHopRouter next to the
pending and route-health tables by proximity rather than dependency - its
whole purpose is to stay isolated from routing state, which argues for sitting
beside PacketHistory instead.

So the ring, opaqueWasSeenRecently(), relayOpaquePacket() and the
rebroadcast_mode predicate (now Router::opaqueAllowedByMode) move down, and
relayOpaquePacket() stops being virtual: there is one router chain, nothing
else overrode it, and the base implementation returned false to no one. The
alternative was a second virtual to reach the same array from the same caller,
which is what the dedup work that follows would otherwise have needed.

isRebroadcaster() comes along because relayOpaquePacket() needs it and it reads
only config.device - no FloodingRouter state - so it was already misplaced.
capEventRelayHops() moves too, and is now declared for NextHopRouter's own
rebroadcast path rather than being file-static.

* fix(router): apply the packet's own rules to every consumer of an opaque frame

Five rules that pre-#10967 applied to a packet we could not read were left
applying to the relay alone. This puts them back on all four consumers - relay,
NAK, phone, MQTT - which is one change of shape, so it lands as one commit
rather than five: the branch head now computes what is true of the frame once
and every consumer below reads the same answer.

Duplicate suppression. The only dedup sat inside relayOpaquePacket(), behind
its isToUs() early return, so it never saw a frame addressed to us. Three
neighbours rebroadcasting a stranger's want_ack DM to us cost three
PKI_UNKNOWN_PUBKEY NAKs on the air, three encrypted frames queued for the
phone, and at a gateway three publishes of every opaque PKI DM between other
nodes. Before these packets stopped going through PacketHistory,
shouldFilterReceived() ran first and made each of those once per (from,id).
The originator's own retransmission keeps its exemption, and gains the two
rules the decoded path already applies to a repeat: do not queue a second copy
while the first is still in the TX queue, and answer again at hop 0, since only
a direct neighbour ever sees hop_start == hop_limit.

rebroadcast_mode. LOCAL_ONLY and KNOWN_ONLY say the node ignores what it cannot
decrypt; the deleted RoutingModule branch gated phone delivery as well as
relay, and only the relay half was carried over, so a stranger's unknown-channel
ciphertext reached the phone in every mode. The phone now follows the same
predicate. NONE still delivers: it means do not relay, not do not listen.

Licence. A licensed station transmits in the clear and may not answer, or hand
on, traffic to or from a node it knows to be unlicensed - the rule RoutingModule
applies to decoded packets, which the opaque path never got.

NAK reason. PKI_UNKNOWN_PUBKEY claimed a missing key even for a channel-0 frame
too short to have carried PKI overhead. Such a frame was never a candidate, so
the reason is NO_CHANNEL, matching the size test perhapsDecode uses.

Uplink. uplinkOpaqueUnicast() read the header only, so a channel-0 unicast that
matched a held hash-0 channel and failed its AEAD was published to the PKI
topic as ciphertext we never tried to read. It now takes the gate's verdict.

One consequence worth stating: the dedup ring records frames the relay gate
used to reject before reaching it - to us, from us, hop-exhausted, mode-blocked,
and everything on a licensed node - so its 32 slots serve four consumers on
nodes that previously never touched it.

Two clean-ups ride along because the same rules move: ReliableRouter's
sniffReceived() loses its own undecryptable-NAK arms, which radio ingress has
not been able to reach since the auth gate started answering those frames
before handleReceived() (deliverLocal, the only other caller, is always
decoded), and test_rebroadcast_mode stops declaring a warm.dat write it never
makes - no case in it reads a signer back from the warm store.

* test(rebroadcast_mode): declare the warm-store write again

The suite stopped writing warm.dat only until this branch gave it a test that
installs an identity of our own, which puts a key through the warm store. The
declaration was removed on the evidence of a run that predated that test, in
the same commit that added it; the harness caught the undeclared write.

* fix(router): put back the undecodable-NAK arms in ReliableRouter::sniffReceived

Deleted as dead code, and they are not. Radio ingress genuinely cannot reach
them any more - the auth gate answers an unreadable frame in
handleOpaqueForUs() and returns before handleReceived(), and deliverLocal(),
the only other caller, is always decoded - but sniffReceived() has a contract
of its own that test_reliable_ack_matrix drives directly, and a local or
SimRadio caller still arrives with an encrypted packet. CI caught it in the
misc-4 shard.

Restored verbatim, with the reachability noted where the next reader will look
rather than in a commit message nobody greps.

* fix(router): classify a PKI-shaped unicast from key material alone

Addresses the review on #11844.

A channel we hold whose hash is 0 matched every PKI DM on the mesh and
failed every one, and that failure was read as "we tried". One unlucky
1-in-256 channel hash therefore withheld every PKI DM from the phone and
the broker, answered NO_CHANNEL where the sender needs PKI_UNKNOWN_PUBKEY
to recover, and classified our own overheard DMs as a forgery so the
implicit "Delivered to mesh" ACK never fired. Hash 0 on a unicast is the
PKI sentinel, so isPkiShapedUnicast() now decides it in one place, used by
the KNOWN_ONLY short-circuit, the decode provenance and the NAK reason.
The isToUs asymmetry in the short-circuit is gone with it.

Also from the review:

- id 0 cannot be deduped by the (from,id) ring, so an undecryptable
  want_ack DM carrying it drew a NAK and a phone frame on every copy
  heard. Every consumer now declines it, as relay already did; a NAK for
  request_id 0 is unmatchable at the sender anyway.
- The ring records only frames some consumer can act on. Our own
  overheard rebroadcasts and unicasts that can neither be relayed nor
  uplinked were evicting live entries, spending the anti-amplification
  bound #11522 added it for.
- The MQTT uplink applies the licensed-station rule, and deliberately not
  rebroadcast_mode: that setting governs what goes back on the air, and
  MQTT has its own switches for what leaves over IP, which is where the
  pre-#10967 uplink sat.
- gateState is initialised rather than relying on the gate's first
  statement.

Comments on the opaque path trimmed to the two-line rule; the reasoning
lives in the tests, which are exempt.

* ci(size-budget): raise the rak4631 flash budget to 748000

The opaque-relay restore lands at 746,080 bytes on rak4631, 80 bytes over
the previous 746,000 limit. Image ends at 0xDC260, 55 KB clear of the warm
region.

* fix(router): keep #10967's removal of the undecodable-frame NAK

The PKI_UNKNOWN_PUBKEY / NO_CHANNEL NAK for a frame we cannot read is not
restored. Every input to that decision - to, from, id, want_ack, hop_start,
hop_limit - is unauthenticated cleartext, so the NAK is a reflector: one
frame in from a node with no key material, one flooded reply out to
whichever `from` it names, with a hop budget the sender chooses. #10967
removed it as an ACK side effect on purpose; the security review of this PR
shows why, and the relay regression it fixes does not need it.

handleOpaqueForUs() now only delivers to the phone. The originator-retx
re-NAK and its `repeat` plumbing go with the NAK. A to-us DECODE_FAILURE is
REJECT again at the gate, as #10967 had it: nothing on the opaque path acts
on a frame we matched and failed on. ReliableRouter::sniffReceived() is back
to develop byte for byte; its undecodable arms are reachable by local and
SimRadio callers only and stay as they are.

A PKI DM to a node that does not hold the sender's key fails silently, as on
develop; the receiving phone still sees the frame. The sender-side recovery
this NAK used to trigger is the follow-on's problem to solve without a
header-driven reply.

Tests: C10, C11, C18, C20, C26, C27, C29, C31 assert no NAK; C28 (the NAK's
hop budget) is deleted; the matched-failure helper drops its want_ack arm.

* fix(router): do not re-decode a frame the gate already classified; scope capEventRelayHops

handleOpaqueForUs() hands the phone a copy the auth gate has already run
perhapsDecode() on. MeshService::sendToPhone() ran it again, and for a
PKI-shaped DM from a sender whose key we lack that second pass re-enters the
admin-key fallback and spends a second token from a budget the code documents
as global and attacker-facing: the sustained rate halved from 4/s to 2/s on
any node with an admin key configured. sendToPhone() takes an
alreadyClassified flag and skips the decode; nothing else calls it that way.
Nodes with no admin key configured were never affected, since
adminKeyFallbackAllowed() returns before touching the bucket.

test_C34 freezes the clock, configures an unrelated admin key, and pins one
token spent per unreadable frame delivered to the phone.
adminKeyFallbackTokensRemaining() is a PIO_UNIT_TESTING accessor beside
resetAdminKeyFallbackBudget().

ReliableRouter::sniffReceived()'s PKI_UNKNOWN_PUBKEY arm now requires the
frame to be long enough to have carried the PKI overhead, the same shape test
Router's opaque classification applies; a shorter channel-0 frame was never a
PKI candidate, so no key was missing and it gets NO_CHANNEL. Reachable by
local and SimRadio callers; pinned in test_reliable_ack_matrix.

capEventRelayHops() moved out of NextHopRouter.cpp as a file-static and
became a free function at global scope. Both callers are Router subclasses,
so it is now a static member of Router next to isRebroadcaster().

* fix(router): size the opaque dedup ring like PACKETHISTORY_MAX

Every ROUTER relays opaque frames now, not just nodes set to ALL, so the
churn through this ring is what bounds a duplicate storm on the backbone.
32 untimed slots shared by three consumers is thin for that; 128 on every
target but STM32WL, which keeps 32 alongside its 20-entry PacketHistory.
8 B per slot in .bss: +768 B, no flash.

* revert(router): return the branch to develop

* fix(router): relay opaque packets in CORE_PORTNUMS_ONLY

A packet a relay cannot decrypt - a PKI unicast between two other nodes, an
unknown-channel broadcast - is relayed only from its header, and only in the
rebroadcast modes relayOpaquePacket() lists. CORE_PORTNUMS_ONLY was not among
them, so a node in that mode, which is the ROUTER role default, dropped every
such packet. The portnum filter that mode exists for cannot be applied to a
payload the relay cannot read; add the mode to the list.

Fixes #11843.

* test(packet_signing): CORE_PORTNUMS_ONLY carries an opaque frame

C6 listed CORE_PORTNUMS_ONLY among the modes that must suppress an opaque
relay, pinning the behaviour #11843 reports. It now asserts the frame is
relayed in that mode, with the same no-side-effect checks as the ALL case, and
keeps LOCAL_ONLY and NONE as the suppressing modes.
2026-09-16 13:14:47 +00:00
Tom 0caf3a09f8 ci(size-budget): raise the rak4631 flash budget 746 000 -> 752 000 (#11866)
develop has been over the 746 000 line since #11826 (748 088 on the
CI runner at 3468af94a), so the size-budget-gate fails on every PR that
does not itself shed 2 KB, and the queue behind it cannot land. 6 KB
covers the current overage plus the pending NodeDB/NodeInfo work
(+584) and one or two of the mid-sized branches, while staying 47 KB
inside the 0xEA000 warm-region guard that is the real wall (image ends
at 0xDCA38 today). Raised deliberately, per the file's own rule.
2026-09-16 11:20:14 +00:00
Tom 2d6dad9ee9 Portduino: Fix LR2021 switch tables, power ceilings and IRQ handling (#11382)
* fix(portduino): recognise the LR2021 power ceilings in --check

loadConfig() has read Lora.LR2021_MAX_POWER and Lora.LR2021_MAX_POWER_HF
since LR2021 support landed, but neither was listed in the config checker's
schema. --check therefore reported both as "unknown key ... ignored by
meshtasticd" -- false, and actively misleading: it tells the user to delete a
key that is doing exactly what they wanted.

This breaks the contract stated above schema(), that a key taught to
loadConfig() is added there too. CI enforces that by running --check over
bin/config.d/**, but no shipped config sets either key -- or mentions lr2021
at all -- so nothing ever tripped over the omission. It could only surface
for someone hand-writing an LR2021 config.

LR20x0 is the only module with two power ceilings, one per band, selected at
runtime by region; every other family expresses the split as separate module
names and needs a single key. That is the likely reason the pair was missed
while every other *_MAX_POWER key was added.

Also adds both to valueSpecs(), so a wrong-typed value is reported rather
than silently replaced by the default.

* feat(portduino): configurable IRQ DIO and a chip-neutral RF switch table for LR20x0

Two gaps found bringing an LR2021 up under meshtasticd on a Luckfox Lyra
Zero W. Both sit in the LR20x0 support added in #11252, and they interact:
the switch table has to be written slightly wrong to pass validation, and
the interrupt lands on a pin that table is driving. Every symptom is silent,
because begin() only exercises SPI and BUSY -- the radio reports init
success and then receives nothing.

IRQ DIO could not be set on Portduino
-------------------------------------
LR20x0Interface picked the IRQ DIO purely at compile time, and neither
LR2021_IRQ_DIO_NUM nor IRQ_DIO_NUM exists for a Portduino target, so
meshtasticd always fell through to RadioLib's default of DIO5 -- which is
also the first RF switch line on carriers using the DIO5-DIO8 table. A
variant says this with a #define (the pro-micro DIY board uses DIO9); a
carrier has only the YAML, and had no way to say it.

Adds Lora.IRQ_DIO_NUM, and an ARCH_PORTDUINO branch after the two existing
#define branches, so a variant that already sets one still wins.

The switch table was parsed as LR11xx-only
------------------------------------------
Pin names resolved to RADIOLIB_LR11X0_DIOn whatever the radio, and the mode
set was the LR11xx's, so MODE_RX_HF -- a mode the LR20x0 really has -- was
rejected as an unknown key and had to be omitted. The two families are not
interchangeable: an LR11xx has no DIO9, so its fifth switch slot is DIO10,
while an LR20x0's fifth slot is DIO9 and DIO10 is its sixth. A table naming
DIO10 was therefore driving the wrong pin on an LR20x0.

The YAML layer now stores what was written -- a DIO number and a neutral
mode id -- and each interface supplies its own DIO constants and OpMode_t
map to a shared builder. Neither family's constants are assumed to coincide
with the other's.

This also fixes a round trip in the config writer, which decoded pins by
comparing against RADIOLIB_LR11X0_* and always emitted five values per mode
row: for a four-pin table it produced YAML that --check would reject for
mismatched row lengths.

--check
-------
Findings are now judged against the resolved module rather than a fixed
list, so a mode or pin the part does have can no longer be rejected, and one
it does not have is named instead of silently accepted. The claim that the
table "is only applied to LR11xx radios" was stale and is corrected, and the
missing-table warning now covers both families. "auto" is excluded
throughout: the module has not been probed yet, so absence cannot be judged.

The IRQ/switch-pin collision is reported in both directions, including the
harder case where no key is set and the radio default collides -- nothing in
the file looks wrong. Note that listing a pin is what breaks it, not driving
it: setRfSwitchTable() reassigns the DIO function for every pin in the list
whatever the levels say, so an all-LOW column is still a collision.

Seven fixtures cover these, including a false-positive guard: DIO5 as the
interrupt is normal, and must stay silent when the table is elsewhere.

* feat(portduino): let the YAML ask for a TCXO probe, across every family that has one

A variant declares "a TCXO may or may not be fitted" at compile time with
TCXO_OPTIONAL, because the board is known when the image is built. A
Portduino carrier cannot: the same meshtasticd binary runs on hardware
populated either way, so the statement has to arrive as YAML and be answered
at runtime.

Adds Lora.TCXO_OPTIONAL, and TCXO_OPTIONAL_ENABLED in RadioLibInterface.h to
unify the two, so each driver asks the question once rather than growing a
second, Portduino-shaped code path. On an embedded target it stays a
compile-time constant, so `if (TCXO_OPTIONAL_ENABLED)` folds away exactly as
the old `#if` did: the nrf52_promicro_diy_tcxo image, which defines
TCXO_OPTIONAL and so exercises the converted branches, still ends at
0xDF1D0 -- the same address as before this change.

It is defined there rather than in a header of its own because
InterfacesTemplates.cpp includes all three interface .cpp files into one
translation unit, where a per-file definition would collide.

Covers every family that has a TCXO reference to probe for: SX126x
(sx1262/sx1268/LLCC68), LR11xx and LR20x0. With no DIO3_TCXO_VOLTAGE given,
the TCXO attempt uses RadioLib's own 1.6 V default rather than being skipped
-- otherwise there is nothing to fall back FROM and the flag would silently
do nothing. This is also the FIXME that sat on the Portduino branch in
LR20x0Interface: an unset voltage now means "no TCXO" explicitly.

Two things are deliberately left alone:

Each family keeps its own probe order. LR11xx tries XTAL first, because a
TCXO-first attempt hangs RadioLib's unbounded calibration wait on a module
with no TCXO fitted, whereas XTAL fails fast and cleanly on a module that
has one; LR20x0 and SX126x try the TCXO first. A carrier therefore behaves
the same way in a Portduino build as in an embedded one, and changing an
order stays a hardware-behaviour decision rather than a tidying-up one.

The SX126x retry is Portduino-only. An embedded TCXO_OPTIONAL board already
gets this from initLoRa(), which constructs a second SX126x interface with
no Vref when the first fails; retrying inside init() as well would leave
that ladder step unreachable and change how every existing t-echo-class
board reports its oscillator. A Portduino build has no ladder to fall
through, because the module is named in YAML rather than probed.

--check learns the key, reports which Vref will actually be tried, and warns
when it is set on a radio with no TCXO reference, where it is read, stored
and inert.

* docs(portduino): condense the comments on this branch

The repo asks for one or two lines and no multi-paragraph blocks, on the
grounds that the diff and the commit message carry the rationale while the
code carries the behaviour. What landed here was well past that: 163 added
comment lines, including a 26-line block above a single macro.

Removes the rhetoric, the issue numbers and the before-and-after asides, and
the notes on where a thing used to live. No added block is longer than three
lines now.

Two facts needed stating and are stated once each rather than repeated at
every use: the slot/DIO divergence between the families, in PortduinoGlue.h,
and the per-family TCXO probe order, in RadioLibInterface.h. The longest
surviving explanation is why an all-LOW switch column still collides with the
interrupt, which sits in the fixtures README because without it that pair of
fixtures reads as contradictory.

Comments only; no functional change.

* address CodeRabbit review on #11382

- SX126xInterface: distinguish an explicit DIO3_TCXO_VOLTAGE from the
  TCXO_OPTIONAL probing default in the debug log instead of always
  claiming the config field was set.
- ConfigCheck: modesFor() now reports an unresolved use_autoconf against
  the union of both radio families' modes, not the LR11xx subset - fixes
  a false "not a mode this part has" warning for valid LR2021-only modes
  (e.g. RFSW_RX_HF) before autodetection resolves the module.
- PortduinoGlue loadConfig: build rfswitch_mode_high[m] as a fresh
  per-row bitmask instead of OR-accumulating onto a stale value, so a
  config re-parse can clear a slot back to LOW.
- PortduinoGlue YAML serialization: gate rfswitch_table emission on
  has_rfswitch_table rather than rfswitch_dio_num[0] >= 0 (missed sparse
  pin lists), and track each emitted pin's original slot so row values
  line up correctly instead of shifting when a low slot is absent.
- config-dist.yaml: document the per-family TCXO/XTAL probe order
  (SX126x/LR20x0 TCXO-first, LR11x0 XTAL-first).
- Trim three overlong comments per the coding-guideline nitpicks.

Left the SX126x XTAL-retry-on-oscillator-failure nitpick alone -
RadioLib's begin() already does its own XOSC_START_ERR recovery
internally, and narrowing our wrapper's retry condition on top of that
needs hardware to verify it doesn't regress a real failure path.

Verified: bin/test-config-check.sh GREEN 69/69 against an isolated
native build; pio test -e native -f test_rtc PASSED.

* fix CI: cppcheck duplicateValueTernary, harden kRfSwitchModes init

LR11x0Interface::init(): work around cppcheck's duplicateValueTernary
on `TCXO_OPTIONAL_ENABLED ? 0 : tcxoVoltage` (both branches fold to 0
on a board with no ARCH_PORTDUINO, no TCXO_OPTIONAL, and no explicit
Vref, since tcxoVoltage already reduces to 0 via the same macro chain)
by splitting it into a plain assignment + if, rather than suppressing
the warning. The other TCXO_OPTIONAL_ENABLED ternaries in this PR
(LR11x0Interface.cpp:75, SX126xInterface.cpp:76, LR20x0Interface.cpp:
86,240) pick between TCXO_OPTIONAL_DEFAULT_VOLTAGE (1.6f) and 0, which
can never coincide, so they're unaffected and left as-is.

ConfigCheck.cpp: kRfSwitchModes was a namespace-scope global with
dynamic initialization (a lambda IIFE) reading kRfSwitchModeNames,
which is defined in a different translation unit (PortduinoGlue.cpp).
Currently safe only because kRfSwitchModeNames's initializer is
constant-expression-only (string literals + enum constants), which
the standard guarantees completes before any TU's dynamic
initializers - but that safety is silent and would break if
PortduinoGlue.cpp's array initializer ever stopped being a constant
expression, with nothing to warn a future editor. Converted to a
function-local static (Meyers' singleton), which is correct by
construction regardless of the other TU's initializer, updating all
4 call sites (definition + 3 uses) from kRfSwitchModes to
kRfSwitchModes().

Verified: pio test -e native -f test_radio PASSED; bin/test-config-
check.sh GREEN 69/69 against an isolated native build.

* refactor: simplify TCXO voltage handling across interfaces and improve comments

* fix rfswitch_table cross-file merge; drop now-stale checker warning

Three CodeRabbit findings on 09e0d390c, addressed together since #2
and #3 are the same root cause:

1. PortduinoGlue.cpp: require an exact "DIO<n>" match when parsing
   rfswitch_table.pins. sscanf's %d stops at the first non-digit, so
   "DIO5invalid" silently parsed as DIO5 at runtime even though
   ConfigCheck.cpp's static validator (exact match against
   kRfSwitchPins) already rejected it - checker and loader disagreed.

2. PortduinoGlue.cpp: reset all 5 pin slots and all 8 mode rows before
   applying a table, rather than only overwriting what the new table
   mentions. A later config.d file that omitted a mode a prior file
   had set (e.g. only redefining MODE_TX) let the earlier file's
   MODE_RX leak through, contradicting "last file wins" - the rule
   every other Lora: key already follows.

3. ConfigCheck.cpp: with #2 fixed, rfswitch_table behaves like any
   other cross-file key, so removed the special-cased ERROR in
   checkCrossFileOverlap ("These do NOT override each other... OR of
   every table") - it described the pre-fix OR-accumulation bug and
   is no longer accurate. Falls through to the generic "last file
   wins" INFO now. Renamed/repurposed the rfswitch-sticky fixture to
   rfswitch-last-wins and updated its assertion (was rc=1 asserting
   the old error text, now rc=0 asserting the generic info) and the
   fixtures README.

Verified: bin/test-config-check.sh GREEN 69/69 against an isolated
native build, including the renamed assertion.

* Assert the effective rfswitch table, not just the overlap diagnostic

The "last one wins" case checked that the cross-file info fires and that the
result is clean. Neither observes the table the loader actually ended up with,
so the merge bug ee9b5b81e fixed - a later table leaving an earlier file's pins
and mode rows as carryover - would still have passed it. Raised by CodeRabbit.

Asserting the value needs two things the existing case cannot supply.

The winner has to be deterministic. Both files in rfswitch-last-wins/ sit in
config.d/, which is walked with a bare directory_iterator and no sort, so which
one lands last is up to the filesystem - the point configd-conflict/ exists to
make, and the reason the checker warns rather than assuming alphabetical order.
rfswitch-replace/ puts the losing table in config.yaml instead, which is always
loaded before config.d/. The loser is the wider of the two, four pins and three
all-HIGH mode rows against the winner's two pins and one all-LOW row, so
carryover shows up as a surviving pin, a surviving mode row, or a HIGH that
should be LOW.

The table has to be observable. The check report says no more than "RF switch
table   : set", and check-yaml cannot help: it is --check --output-yaml, and
--check wins and exits before the dump - which the case just below it asserts.
emit_yaml() does serialise the effective table, so the assert helper grows a
yaml mode that passes --output-yaml alone.

Confirmed non-vacuous: with the reset loop in loadConfig() removed, the new
assertion fails and the old one still passes.

Config-check suite GREEN 70/70. Native suite GREEN 44/44, 968 cases.

* Say nothing about rows the radio will never read

Two of the RF-switch diagnostics judged a table against a family's mode
list without first asking whether the module reads a table at all.

modesFor() treated every module that was not an LR20x0 as LR11xx-like, so
an sx1262 carrying a table was told which of its rows were "not a mode
sx1262 has" and which modes it had omitted - alongside the correct warning
that the whole table is inert. pinsFor() already returned an empty set for
these parts and its caller already guarded on that; the mode path now
matches.

The missing-mode advice is dropped under "auto" as well. The module has
not been probed, so the union of both families is all there is to compare
against, and naming its absent modes would advise adding MODE_TX_HP,
MODE_GNSS and MODE_WIFI rows to what may turn out to be an LR20x0.

Fixtures for both silences, and an assert() needle prefixed with '!' to
hold them: a line that is merely absent today is otherwise nobody's
regression.

Also corrects the kLr11x0SwitchDios/kLr20x0SwitchDios comments. They
describe a slot mapping, but buildRfSwitchTable() searches them by value
to find the parallel pin constant - and with 7 DIOs against 5 YAML pin
slots, the LR20x0 array could not be positional.

* Warn when the two TCXO keys ask for opposite things

DIO3_TCXO_VOLTAGE written out as false or 0 asks for DIO3 to be left
alone, and stores identically to the key being absent - so TCXO_OPTIONAL
then probes DIO3 at the radio default anyway. Both keys behave exactly as
documented; only together are they wrong, which is what makes the outcome
surprising. loadConfig() now keeps the distinction that the store loses,
and --check reports the contradiction and which key to drop.

The flag is diagnostic only and is not serialized: an explicit false and
an absent key both round-trip as absent, as they did before.

Also fixes the Portduino TCXO log lines, which named the variant define
SX126X_DIO3_TCXO_VOLTAGE on a path where the knob is the YAML key, and
adds a TODO over the SX126x XTAL retry. RadioLib has autocorrected that
case itself since 7.5.0 - SX126x::modSetup() retries config() on the XTAL
when begin() fails with SPI_CMD_FAILED and XOSC_START_ERR - so the
ordinary case never reaches our retry and what does is mostly invalid
settings, logged as a TCXO fault.

* feat(portduino): bound Lora.IRQ_DIO_NUM, accept the older spelling, document both

An LR20x0 raises its interrupt on DIO5 through DIO11. Anything else was read
straight out of the YAML and programmed into RadioLib, where it routes the IRQ
nowhere: begin() touches only SPI and BUSY, so the radio reports init success
and then never receives a packet - the same failure the switch-pin collision
check already covers, reached by a typo instead.

Refuse it in loadConfig() and again in the driver before it reaches RadioLib,
warning both times. Because loadConfig() discards the value, the merged config
cannot tell a rejected number from an absent key, so --check judges the range in
its per-file pass where the offending line is still known.

LR2021_IRQ_DIO_NUM, the spelling carried by the two earlier LR2021 branches, is
read when IRQ_DIO_NUM is absent and reported as shadowed when it is not. Both
keys, the DIO range and the collision that makes the setting matter are now
described in config-dist.yaml.

* fix(portduino): a rejected IRQ_DIO_NUM returns to the radio default

loadConfig() runs once per file - the main config, then each file in
config.d/ - and they all write the same portduino_config. An out-of-range
Lora.IRQ_DIO_NUM warned that it was falling back to the radio default but
left any valid value an earlier file had set, so LR20x0Interface went on
programming that stale DIO.

Reset it to -1, the unset sentinel every other reader already tests for.

Pinned by a new fixture: DIO9 in the main config, out of range in
config.d/. The main config is always read first, so the ordering is
deterministic, unlike two files in config.d/ (see rfswitch-last-wins).
The assertion requires the summary to name the radio default and not DIO9;
with the reset removed and rebuilt, it is the only assertion that fails.
2026-09-16 10:44:06 +00:00
Thomas Göttgens 3468af94aa fix(hopscale): gate hop scaling on measured channel congestion (#11826)
* fix(hopscale): gate hop scaling on measured channel congestion

* fix(hopscale): rename test tripping trufflehog and release congestion at the threshold

* test(hopscale): pin the unscaled precondition in the busy-channel gate test

* fix(hopscale): floor infrastructure roles and engage below the polite gate

* test(hopscale): name the symbol under test and reset the gate in clear()

* fix(hopscale): drop the unneeded congestion reset in clear()

* refactor(hopscale): drop the unused utilization accessor and duplicated log fields

* refactor(hopscale): drive the politeness extension from measured congestion (#11831)

* refactor(hopscale): drive the politeness extension from measured congestion

* refactor(airtime): own the smoothed channel utilization (#11832)

* refactor(airtime): own the smoothed channel utilization

* refactor(airtime): cut comments to the two-line limit

* fix(airtime): fold the smoothed utilization once per crossed bucket

* fix(hopscale): compare the congestion thresholds at whole-percent resolution
2026-09-15 12:06:19 +00:00
Thomas Göttgens bc2528b005 Show the full Bluetooth pairing PIN on tiny OLED panels by drawing it full-screen with all lines spread evenly. (#11855) 2026-09-15 11:16:11 +00:00
Thomas Göttgens e0cf782131 Build xiao_nrf54l15_lr2021 on every PR as the nrf54l15 canary and mark it community supported (#11856) 2026-09-15 13:00:34 +02:00
Ethac.chen 2fe711115e feat(nrf52): add RAK3401 + LR2021 (RAK13700) variant (#11819)
* feat(nrf52): add RAK3401 + LR2021 (RAK13700) variant
WisBlock core with IO-slot LR2021: DIO RF switch, board LF PA table, and 1.6 V TCXO. Keep extra/unsupported until it has its own hw_model.

* fix(lr2021): log custom PA setOutputPower in fullBegin
Match init(): a calibration miss stays a warning so band-hop keeps the begin() PA config.

* refactor(lr2021): share custom LF PA table helper

init() and fullBegin() both re-install the board table after begin(); keep the warn-only setOutputPower miss.
2026-09-15 08:15:33 +00:00
Thomas Göttgensandvidplace7 ea7d4aa410 Port nRF54L15 to the s145 SoftDevice Arduino core (#11842)
* Remove the Zephyr based nRF54L15 port

* Add nRF54L15 port on the s145 Arduino core: nrf54l15dk and xiao_nrf54l15 variants

* nRF54L: errno-style nrfx results, flush console before assert reset

* nRF54L: log the SoftDevice status on Bluefruit failure, ignore the seed request event

* Support the Wio-LR2021 LoRa Plus expansion board with OLED and K1 on the XIAO nRF54L15 variant

* Consume the nRF54L15 platform, core and bootloader from their repositories

* Pin the nRF54L15 platform to v0.2.0

* nRF52: forward SoftDevice flash events taken by the main loop to the flash driver, log the pairing failure status

* Pin the nRF54L15 platform to v0.2.1

* Pin the nRF54L15 platform to v0.3.0

* Split the XIAO nRF54L15 variant into SX1262 and LoRa Plus environments, seed the SoftDevice on request, add the nrf54l15 CI build script

* Pin the nRF54L15 platform to meshtastic/platform-nordicnrf54 v0.3.1

* NRF54: Fix mtjson generation

---------

Co-authored-by: vidplace7 <vidplace7@gmail.com>
2026-09-15 07:16:33 +00:00
Austin 9d27b276aa NRF54: Update to toolchain-gccarmnoneeabi@1.90301.200702, align NRF52840 (#11850)
Version 1.90201.191206 supports arm64 MacOS, but not arm64 Linux (1.90301.200702 supports both)

Also change the fuzzy match for gccarmnoneeabi to an exact match for nRF52840 (for reproducible builds), this is effectively a no-op change, today.
2026-09-14 14:00:55 -04:00
Austin 2826c6712b Pin nrf54 platform, update toolchain-gccarmnoneeabi for arm64 build hosts (#11848) 2026-09-14 12:19:56 -04:00
Tadayoshi MIURA f5158f50be feat(rp2040/rp2350): Update earlephilhower/arduino-pico to 6.1.0, bump maxgerhardt/platform-raspberrypi to latest (#11814)
* Update earlephilhower/arduino-pico to 6.1.0

* feat(rp2040/rp2350) bump platform-raspberrypi

* fix(rp2040/rp2350) recover printf and scanf
2026-09-14 13:02:57 +00:00
Matias DendaandJonathan Bennett 32eb1a1237 Honor an explicit -c config path when -s is given (#11348)
The simradio flag (-s) is the first branch of an if/else-if chain that
also handles config loading, so it short-circuits every later branch --
including the one for an explicit -c <path>. Skipping config discovery
under -s is intended, but a config path the user passed by hand is not
discovery, and it is silently ignored today.

Move the -s check after the -c branch so an explicit path is always
parsed, and skip only the implicit discovery (./config.yaml,
/etc/meshtasticd/config.yaml) when -s is given without -c.

The radio override then runs after every config source, since -c and
its ConfigDirectory entries can both set Lora.Module and -s has to win
over them. Doing it there also fixes --check and --output-yaml, which
reported the configured module rather than the simulated one because
the old override sat behind an early return.

Behaviour with a bare -s is unchanged: no YAML is loaded and the radio
is the simulator.

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-09-14 12:39:25 +00:00
ee15508494 time: arm the remaining 0-means-unset stamps through the helpers (#11830)
* time: add skipZero/safeMillis/timerEndsAtMillis helpers

skipZero() steps a millis value past 0, since stored stamps and deadlines
conventionally use 0 for "unset" and the one tick per ~49.7-day wrap that
lands on 0 would otherwise read as never-set.

safeMillis() covers a bare stamp; timerEndsAtMillis(delayMs) covers a
deadline, where the sum is what has to dodge 0 - a non-zero read plus a
delay lands there once per wrap - so it is not safeMillis() + delayMs.

* time: replace hand-rolled zero-dodging with the UptimeClock helpers

PacketHistory rxTimeMsec, EncryptedStorage s_lastFailMillis (stamps), and
SGM41562 lastRefreshMs_ / NextHopRouter learnedAtMsec (ternary stamps) each
hand-rolled skipZero() in place; swap in safeMillis()/skipZero() directly.

HapticFeedback pulseOffAt/delayedPulseAt and GPS fixHoldEnds hand-rolled the
deadline form - millis() + delay, then remap a 0 result to 1 - swap in
timerEndsAtMillis(delay).

No behavior change; each site keeps the value it already computed.

* time: guard the remaining 0-means-unset deadline/stamp writes

rebootAtMsec, shutdownAtMsec, and NotificationRenderer::alertBannerUntil are
all read back with a bare == 0 / != 0 check for 'not scheduled', but every
write site computed millis() + delay (or a bare millis() stamp) with no
guard against landing exactly on 0 - the same wrap hazard skipZero() exists
for, just never applied here.

Route every rebootAtMsec/shutdownAtMsec/alertBannerUntil write through
timerEndsAtMillis()/safeMillis(); RadioLibInterface's reboot-on-stuck-tx
sums an already-captured stamp rather than "now", so it goes through
skipZero() directly instead.

No behavior change outside the ~1-in-2^32 wrap window each site was
already exposed to.

* time: guard three more 0-means-unset deadline writes

ntp_renew (ethClient.cpp), suppressTouchTapUntilMs (Events.cpp), and tx_after
(RadioLibInterface.cpp) all read back 0 as a real state - forced NTP renewal,
no suppress window active, no TX delay armed, respectively - but each arm
site wrote a bare millis()/getMillis() + delay with no guard against the sum
landing exactly on 0.

Route each through Time::timerEndsAtMillis(). No behavior change outside the
wrap window each site was already exposed to.

Refresh the Throttle.h TODO list to note ntp_renew is converted too.

* motion: guard the calibration deadline and use Throttle::deadlinePassed

endCalibrationAt's arm site wrote millis() + calibrateFor with no guard
against landing on 0, the same value finishCalibrationIfExpired()/
drawFrameCalibration() treat as "not calibrating". Route it through
Time::timerEndsAtMillis().

Also swap finishCalibrationIfExpired()'s hand-rolled (int32_t)(now - deadline)
< 0 for Throttle::deadlinePassed(): same wrap-safe comparison the codebase
already provides, without the signed-cast pattern Throttle.h documents as
implementation-defined past INT32_MAX, and it drops the file's last direct
millis() call in favor of the Time:: wrapper the rest of it already uses.

* time: fix Throttle::execute()'s own zero-dodging

Both places execute() writes *lastExecutionMs - the first-ever-run branch
and the regular update - used bare Time::getMillis() with no guard against
landing on 0, which is the exact sentinel this function reads back as
"never run" one line above. A hit there makes the next call re-fire
immediately instead of respecting minumumIntervalMs.

Capture now via Time::safeMillis() once; every use downstream (the elapsed
comparison, the stored value) is then safe by construction instead of
needing the guard reapplied at each write.

* revert some safeMillis cases where overflow is a bad thing

* test(uptime): pin skipZero/safeMillis/timerEndsAtMillis at the wrap boundary

Covers the zero case, an ordinary nonzero value, and a sum that lands
exactly on 0 from a nonzero start - the case timerEndsAtMillis() exists
for, and the one the prior suite had no direct coverage of.

* time: restore the route-health write normalization and put it on one clock

noteRouteLearned()/noteRouteSuccess() lost their `now ? now : 1` normalization,
leaving learnedAtMsec able to store 0 - which getOrAllocRouteHealth() reads as an
ever-growing age, making the slot the first eviction candidate and permanently
stale. Normalize at the write, where the block comment already says it happens,
so every caller is covered rather than just today's two.

Both callers, the two isRouteStale() sites and doRetransmissions() now read
Time::getMillis(), so the stamp and every comparison against it share a clock.
doRetransmissions() goes back to getMillis(): its `now` feeds only comparisons,
never a 0-sentinel field, so skipping zero there only cost accuracy.

* time: read the haptic, InkHUD and calibration deadlines on the write's clock

These three deadlines were converted to Time::timerEndsAtMillis() on the write
side while their reads stayed on millis(), so each spanned two clocks and would
fire immediately or never under an injected test clock. Convert the reads to
match: HapticFeedback::scheduleNext()/runOnce(), the InkHUD tap-suppression
window, and the calibration countdown's read-back of screen->getEndCalibration().

MotionSensor's sampledAtMs is left alone - its write and read are both millis()
and consistent already.

* time: correct the sentinel notes to match what the code actually does

The Throttle.h enumeration claimed the remaining timerEndsAtMillis() callers
"already dodge the sentinel", which reads as a completeness claim the same branch
contradicts: RadioLibInterface's tx_after and activeReceiveStart are both 0=unarmed
and both still arm from bare millis(). Name them instead, so the deadline-type
conversion has the real list. The ntp_renew entry now separates a deliberate 0
("due now", forced at link-up) from a computed one, which is what changed there.

The three TODO(elapsed-stamp) blocks ran four and five lines against the repo's
one-or-two rule, and two of them argued their case wrongly. Throttle.cpp implied
safeMillis() simply doesn't help; in fact neither store is safe on the wrap tick -
the 1 underflows a same-instant read, the 0 re-takes the never-run branch - which
is the symmetry worth recording. PacketHistory.cpp called its dodge "reflecting
the previous pattern" when it is load-bearing: rxTimeMsec 0 means "empty slot"
(PacketHistory.h:21) and insert() drops a record stamped 0 outright, so without it
a packet arriving on the wrap tick is never stored and loses its dedup.

Also picks up trunk fmt's trailing-whitespace fix in Throttle.cpp and the comment
realignment in SGM41562.cpp that this branch's added comment knocked out.

* test(nexthop): pin the route-health stamp against the 0 sentinel

The uptime suite covers skipZero/safeMillis/timerEndsAtMillis themselves, but
nothing covered a call site, so the branch deleted noteRouteLearned()'s
normalization and stayed green. None of the existing route-health tests pass 0 as
`now` - they use 1000, learnAt, or millis() - (TTL + 5000) - which is exactly the
gap the regression went through.

Both new tests fail with "Expected 0 to be not equal to 0" when the skipZero() is
backed out of NextHopRouter, and pass with it. noteRouteSuccess() only refreshes
an existing record, so its twin learns a route first to reach the write.

Also drops a self-referential assertion in the uptime suite: comparing
getMillis() against safeMillis() passes even if safeMillis() does no dodge at
all, so it now asserts the literal.

* discard safemillis for skipzero (better semantics and therefore maintainability) and make consistent use of getmillis where it is called (to permit testing)

* more wrapzero safety

* STM gets some too

* time: stop the next 0-means-unset deadline being armed from raw millis()

The fields this branch armed through Time::timerEndsAtMillis() / Time::skipZero()
are the kind that get added by copy-paste: `rebootAtMsec = millis() + N` appears
at twenty-odd sites across six files, and the next module to defer a reboot will
be written from one of them. Nothing catches the mistake afterwards - the sum
lands on 0 for one tick per ~49.7-day wrap, so a test run, a soak and a bench
session all pass while a pending reboot, shutdown, DFU jump or banner expiry is
silently dropped.

Two guards, at the two places it can go wrong.

The helpers themselves: skipZero() is constexpr, so its contract is now pinned by
static_assert in the header rather than only by test_uptime_clock. The asserts are
chosen against the two plausible rewrites - `ms | 1` perturbs every even value and
`ms + 1` turns the last tick of the wrap into the 0 the function exists to avoid.
Both compile, and both pass a test that only checks skipZero(0); each trips a
distinct assert here, naming the failure mode.

The call sites: bin/lint-unset-sentinel-millis.sh flags a sentinel field in src/
assigned from a raw millis()/getMillis() read, and names the helper to use. It is
name-driven because the 0 contract is declared in src/main.h and enforced in six
other files, so no single-file scan can infer it; every one of the thirteen fields
was checked to actually test against 0 before being listed. nagCycleCutoff and
LinuxJoystick's nextRepeatX/nextRepeatY are deliberately absent - their unset state
is a separate bool - and the nine remaining `millis() + x` sites in src/ are locals
that never store 0 for anything to misread.

Blocking, unlike its note-level neighbours: there is no run-time enforcer to pair
with, and the tree has zero violations today, so gating costs nothing. Scoped to
src/ so test_uptime_clock can keep building raw wrap values on purpose.
bin/test-lint-unset-sentinel-millis.sh pins the scanner against 23 fixtures -
reads, disarms, shadowing locals, comments, string literals and the already-fixed
forms all have to stay quiet.

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

* time: guard the four 0-means-unset stamps this branch had missed

Sweeping src/ for the `if (stamp && <deadline check>)` idiom - the shape that
makes 0 mean "unset" - turned up four stamps still armed from a raw clock read,
so the new lint rule would have had to either ignore them or go red on checkout.
Each is the same one-tick-per-wrap hole the rest of the branch closes:

  * TrackballInterruptBase lastInterruptTime, armed in all four ISR handlers and
    explicitly disarmed to 0 at the threshold reset. getMillis() is the ISR-safe
    read by construction - it compiles to millis() outside PIO_UNIT_TESTING - and
    skipZero() is pure, so neither adds anything to interrupt context.
  * NeighborInfoModule lastSentReply, read as `if (lastSentReply && ...)` before
    the 3-minute reply throttle. Needed the UptimeClock.h include.
  * PositionModule lastSentReply, same throttle; already on the injectable clock
    but still missing the guard.
  * NodeDB lastSort, whose own read spells the sentinel out as `lastSort == 0 ||`.

On the wrap tick each would read as never-stamped: a trackball debounce window
lost, a neighbour or position reply sent inside the throttle it was meant to
respect, one extra NodeDB sort. Cheap individually, which is why they were missed.

All four are now listed in bin/lint-unset-sentinel-millis.sh, so the rule covers
every field in the tree that actually tests against 0 rather than a subset, and
the header records the eight stamps left off for the opposite reason - their unset
state is a separate flag (isNagging, busyTx, heldX/heldY, formatted_this_boot,
heartbeat, gotwind, haveSample, lastIaqValid), so 0 is a value they may legally
hold. The rule is silent across src/ on this tree.

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

* lint: let a site opt out of the sentinel rule, with its reason on the record

The rule is blocking, so it needs an escape hatch for the site where 0 genuinely
is a legal timestamp - and the hatch should cost something, or it becomes the
first thing anyone reaches for. `unset-sentinel-ok: <reason>` in a comment on the
write, or on a comment line above it, suppresses that one statement:

  // unset-sentinel-ok: busyTx carries the armed state, so 0 is a legal stamp here
  lastTxStart = Time::getMillis();

The reason is mandatory. A bare `unset-sentinel-ok`, or a colon with nothing
after it, is reported instead of honoured - with a message saying so - so the
only way to silence a site is to write down why it is safe. trunk-ignore still
works, but this states the justification at the write and also applies when the
script runs outside trunk.

The marker is read from comment text collected during the same character-level
pass that strips comments and literals, not by re-scanning the raw line. That is
what keeps it out of reach of data: LOG_DEBUG("unset-sentinel-ok: ...") mutes
nothing, because a string literal is not a comment. It is also consumed by the
statement it was written for, so it cannot leak onto the next write - while still
carrying across any number of intervening comment lines to the statement below,
which is where a real justification wants to be written.

Twelve fixtures added for the new behaviour: both comment styles, block and
multi-line block comments, the bare form, the marker-in-a-string cases, and three
leak cases. 35 total, all green, under bash 3.2 as well.

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

* lint: watch the separate-flag stamps too, with their exemption stated at the write

The nine stamps whose armed state lives in a companion boolean were previously
just absent from the rule's list, which meant the reasoning for leaving them out
existed only as prose in a shell script. They are now listed and individually
opted out at the write, naming the flag that actually carries the armed state:

  // unset-sentinel-ok: haveSample carries the armed state, so 0 is a legal stamp
  lastSampleMs = Time::getMillis();

The point is what happens later. If someone rewrites `if (haveSample && ...)` as
`if (lastSampleMs && ...)`, the field has silently acquired the 0 contract; with
the opt-out sitting at the write, the claim to re-examine is in front of whoever
makes that edit instead of buried in bin/.

Every exemption was checked against its real read sites before being written, and
three candidates did not survive that check. They stay off the list, because
listing one would mean stamping an opt-out over a claim that does not hold:

  * nagCycleCutoff. handleInputEvent reads `if (nagCycleCutoff != UINT32_MAX)`
    without consulting isNagging, so at that read the field is its own armed flag
    with UINT32_MAX as the sentinel - and the arm at ExternalNotificationModule
    .cpp:521 can land exactly there. skipZero() cannot help: it lifts 0 to 1 and
    leaves UINT32_MAX alone, which UptimeClock.h's own static_assert pins. There
    is also a live boot-state bug behind this - the in-class initializer is 1
    while isNagging starts false - and fixing the read is a behaviour change that
    belongs in its own PR.
  * TouchScreenBase::_start. Overloaded as an event stamp AND a `+ 30000`
    suppression deadline compared by signed subtraction, so a near-zero value
    reads as "long ago" rather than "armed 30s out" and LONG_PRESS re-fires.
    skipZero() does not fix this one either: 1 reads as long-ago exactly as 0
    does. It needs the stamp and the deadline held separately.
  * StoreForwardModule::retry_delay. No reads at all today, so nothing misbehaves
    yet; exempting it now would pre-approve the raw arm for whoever implements the
    retry its own comment promises.

The rule is silent across src/ on this tree, and the header records all three
rejections so the next person does not have to re-derive them. The self-test's
negative fixture no longer uses nagCycleCutoff as its example of a safely
unlisted field - that would have encoded the opposite of what the header says.

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

* fix(time,lint): guard the recomputed tx_after, and judge one write at a time

Two review findings, both real.

setTransmitDelay() recomputes p->tx_after from a clamp of three candidates, and
that recomputation was still raw. Two lines above it, `if (p->tx_after)` is the read
that takes 0 as "no delay wanted", so a clamp landing on 0 drops the CSMA backoff
and the packet goes out immediately instead of after its computed delay. The first
arm site in this function was already guarded; this one was missed because the
value is not a plain `now + delay` and so does not fit timerEndsAtMillis() - it
takes skipZero() instead.

The narrowing order matters here and is spelled out at the site: add_delay is
unsigned long, 64-bit on the portduino host, so the clamp can exceed UINT32_MAX
there. skipZero() on the wide value would pass 0x100000000 through as non-zero and
the store to this uint32_t field would then truncate it back to the 0 being
avoided, so the cast comes first.

The lint rule judged each write by the wrong text. rhs was taken from the write to
the end of the accumulated statement, so a neighbour on the same line decided the
verdict - and it was wrong in both directions:

  rebootAtMsec = millis() + 5; shutdownAtMsec = Time::timerEndsAtMillis(10);
      the later helper call suppressed a genuine raw arm

  rebootAtMsec = otherDeadline; shutdownAtMsec = millis();
      the later millis() reported a safe copy

rhs is now cut at its own semicolon. Six fixtures cover it, including both cases
above, two raw writes on one line, two helper writes on one line, and a statement
split across lines, which must still see its whole right-hand side. 41 fixtures
total, green under bash 3.2.

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

* time: arm the remaining 0-means-unset stamps through the helpers

The follow-up sweep to the sixteen fields the previous commits covered. A field
here uses 0 to mean "unset" - some read spells `if (f)`, `f != 0`, `f == 0 ||` or
`f > 0`, or a site disarms it with `f = 0` - but it was armed from a raw clock read,
so once per ~49.7-day wrap it stores the value its own readers treat as never-set.
34 fields, 58 arm sites.

The rule could not have found most of them first. It treated `field = <variable>`
as inheriting whatever that variable did, which made the commonest shape in the tree
invisible: one `now = millis()` at the top of a runOnce(), then several
`xStartTime = now` below it. Listing those names would have bought no protection at
all, so the scanner now tracks a local assigned from a clock and treats a write from
it as the raw arm it is. One hop, one function, name-based, and it forgets a local
reassigned from anything else; taint is dropped at each function boundary. Twelve
fixtures pin it, including the negative cases - no leak across functions, `now` does
not match `nowMs`, and neither `==` nor `+=` records anything.

That pass immediately found a site the previous commits missed: setTransmitDelay()
recomputes p->tx_after from a tainted `now`, two lines under the `if (p->tx_after)`
read that takes 0 as "no delay wanted".

Three of the fields are worth naming because the consequence is not cosmetic:

  * UpDownInterruptBase press/up/downStartTime - xDetected is only cleared INSIDE
    the block guarded by `xDetected && xStartTime > 0`, so a stored 0 makes both the
    entry and the exit condition unreachable and that button is dead for the rest of
    the boot, not for one tick.
  * PhoneAPI lastContactMsec - ServerAPI reads `lastContactMsec > 0` before the TCP
    idle close, and the field stays 0 until the next inbound packet, so a client that
    never speaks again leaks the socket for the life of the connection.
  * EInkDisplay lastDrawMsec - `if (lastDrawMsec)` gates every plain display() call
    on a keyframe having been shown, so a stored 0 stops the screen updating until
    something calls Screen::forceDisplay() again.

TransmitHistory needed more than its arm sites. getLastSentToMeshMillis() returns 0
to mean "module has never sent", and besides the two stores, both reconstruction
helpers end in `millis() - msAgo`, which can produce a 0 of their own. All three
computed returns are guarded; the deliberate `return 0;` sentinels are untouched.

Judged and deliberately not changed:

  * nRF54L15 connect_time_ms is armed from k_uptime_get_32(), not millis(). It is
    guarded with skipZero() but keeps its own clock - swapping in Time::getMillis()
    would have it compared against a k_uptime now at the watchdog read. The rule now
    recognises that clock too, so listing the field is not an empty gesture.
  * RotaryEncoderInterruptBase pressStartTime shares a name with the UpDown field and
    has a different contract: no read here tests the stamp against 0, pressDetected
    is the only armed flag. Opted out at the write. Its lastPressLongEventTime
    sibling IS a `== 0` latch and is fixed.
  * PositionModule line 38 copies a value the enclosing `if (restored != 0)` has
    already proven non-zero. Opted out.
  * pmMeasureStarted, adminKeyFallbackRefillMs, the two autosave stamps and
    scrollStartDelay are lazy initialisations whose wrap behaviour costs at most one
    interval and drops nothing. Left alone, and not listed.

47 lint fixtures green, the rule silent across src/, full native suite 1421/1421.

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

* time: dodge the wrap at the clock read, not only at the store

Review on #11830 made a point that was right and that this branch had wrong.
Applying skipZero() at the STORE while a reader measures elapsed time against a
raw clock splits the two sides apart for one tick per ~49.7-day wrap: the stamp
becomes 1 while `now` is still 0, so `now - stamp` is UINT32_MAX and a brand new
stamp reads as about 49.7 days old. Every elapsed-since guard then fires when it
must not. Concretely, UpDownInterruptBase computed `now - pressStartTime` and
emitted a long press for a fresh press, and TraceRouteModule read
`now - lastTraceRouteTime < cooldownMs` as false and bypassed its cooldown.

So the dodge moves to the read. Time::stampMillis() is getMillis() with the one 0
tick called 1; a site that both stores a stamp and measures against stamps reads
the clock once through it and stores that value directly. Nine files, and the 1 ms
skew is the same one skipZero() already documents.

Where the clock arrives as a PARAMETER the store keeps its own skipZero() as well,
because the function cannot assume the caller dodged anything. Removing that was a
real regression and test_nexthop_routing caught it: noteRouteLearned() and
noteRouteSuccess() are called with a literal 0 by
test_health_learn_never_stores_zero_sentinel and
test_health_success_never_stores_zero_sentinel, which assert the store normalises
it - 0 is the empty-slot marker getOrAllocRouteHealth() evicts on. The two guards
compose without shifting twice, since skipZero() of a non-zero value is itself.
trySmartBroadcast() and directResponseAllowed() have the same parameter shape and
keep their store-side guard for the same reason. Only stores fed by a stampMillis()
local in the same function are bare.

EInkParallelDisplay was missed the first time: the third class in the family, still
storing skipZero(getMillis()) while rate-limiting against a raw millis() local.
Normalised like its siblings.

The lint rule gained three false positives with the class-scope tracking, all of
them shapes that are not class bodies at all:

  template <class T> void f(T x) { uint32_t lastSort = millis(); }
  class Foo { void tick() { uint32_t lastSort = millis(); } };
  void g(struct Bar *b) { uint32_t lastSort = millis(); }

Two causes. pending_class matched class/struct anywhere on the line, so a template
parameter list and an elaborated type in a parameter list both marked the following
FUNCTION body as class scope; it is anchored to the start of the line now. And
update_scope() runs at the end of a line, so a body opened earlier on the same line
had not been counted when the statement was judged; is_declaration() now also
counts unmatched braces earlier in the statement. The rule is blocking and
`template <class T>` is ordinary C++, so these would have reddened files nobody
touched.

note_taint() also never received the per-write `;` cut the judging path was given
earlier in review, so on a line holding two statements it learned taint from the
neighbour. Same cut applied.

Five tests in test_uptime_clock pin the contract, including one that asserts the
old store-only shape really does produce UINT32_MAX, and one that pins the 1 ms
skew at 399 rather than 400 so nobody "corrects" it back into a raw read. Lint
fixtures 53 -> 65. Full native suite 1426/1426, rule silent across src/.

Known residual, deliberately not changed: a store that dodges zero while its reader
measures through a Throttle:: helper still splits for that one tick, because those
helpers read the clock internally and raw. About eight sites tree-wide, including
PositionModule trySmartBroadcast and the lastContactMsec TCP idle check. Closing it
means making Throttle read through the dodge, which was proposed on #11692 and
declined there pending a caller audit, so fixing one site here would only make the
tree inconsistent. The direction is also the same one the un-dodged code already
took: a fresh stamp reads as old, and the guards involved were already passing on a
0 stamp.

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

* lint: a dodged value is safe to copy, not to do arithmetic on

Two more review findings on the rule, both real, both false negatives.

Arithmetic on an already-dodged value was excused. stampMillis() guarantees only
its own result, so `now + 5000` can carry a non-zero stamp straight back onto the
sentinel - 0xFFFFEC78 + 5000 is exactly 0. That sum is precisely what
Time::timerEndsAtMillis() exists to dodge, and the rule was waving it through
because a helper name appeared somewhere in the expression. Worse, a fixture
asserted that behaviour was correct, so the self-test was pinning the hole open.

A local holding a dodged value is now tracked separately from a tainted one: it may
be stored or copied straight through, but + or - applied at the OUTERMOST level is
reported and the message points at timerEndsAtMillis(). Depth-aware, so the operator
inside Time::skipZero(getMillis() - msAgo) is still fine, and so is the
`(d == 0) ? 0 : timerEndsAtMillis(d)` arming form, which has no top-level operator
at all. The wrong fixture is replaced by four: store-through, copy one more hop,
arithmetic on a dodged local, and arithmetic on a direct helper call.

A class body that opens and closes on one line was never recognised. The header
check rejected it because the line ends in a semicolon, which a one-liner body
always does, and even once armed the class brace counted as a function body and
excused the member. Both halves fixed: the header arms on the brace rather than on
the absence of a semicolon, and when the body opened on the statement being judged,
one unmatched brace is class scope while two is a method body inside it. Getting
that wrong first broke every multi-line class, because setting the per-statement
flag without also arming pending_class meant update_scope() never registered the
body - the three existing class fixtures caught it.

72 fixtures, green under bash 3.2, shellcheck clean, rule silent across src/.
No src/ or test/ file changes, so the native suite is untouched by this commit.

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

* style(time): trim comments to the house limit

---------

Co-authored-by: Tom <116762865+Nestpebble@users.noreply.github.com>
Co-authored-by: nomdetom <nomdetom@protonmail.com>
Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:19:02 +00:00
Thomas Göttgens be449b525f fix(esp32): rebuild IDF libs when the HybridCompile cache is stale (#11834)
The platform decides whether framework-arduinoespressif32-libs matches the
current env from a hash written into sdkconfig.defaults at the start of the
IDF-libs pass, before those libs are compiled, so an interrupted or
metadata-only pass leaves a hash describing libs that were never built. Later
builds match that hash, skip the recompile and link the previously compiled
board's IDF configuration; on tlora-t3s3-v1 this linked a t-connect-pro build's
CONFIG_SPIRAM_MODE_OCT libs into a quad-PSRAM ESP32-S3FH4R2, which aborts in
esp_psram_init() before the console exists and boot loops with no serial output.
Drop sdkconfig.defaults when the package's own <mcu>/sdkconfig, rewritten as the
last step of a completed compile, predates it.
2026-09-14 10:38:19 +00:00
31f05ab057 fix(touch): stop LONG_PRESS repeating when the suppression deadline wraps (#11829)
* fix(touch): stop LONG_PRESS repeating when the suppression deadline wraps

TouchScreenBase::_start was one field doing two incompatible jobs. It held the
press-down timestamp, and then the LONG_PRESS handler overwrote it with
`millis() + 30000` to stop the event repeating for the rest of the hold. Every read
was a hand-rolled signed subtraction on time_t, and suppression worked only because
`time_t(millis()) - _start` came out around -30000.

Where time_t is 64 bits - the portduino host - that uint32_t sum wraps to a small
number while millis() is still just under 0xFFFFFFFF. The subtraction then goes
hugely positive instead of negative, the threshold test passes on every 20ms poll,
and each pass re-arms to another wrapped value. It keeps firing until millis()
itself wraps, up to ~30 s later: about 1500 TOUCH_ACTION_LONG_PRESS events injected
into InputBroker for one finger that never moved. Modelling the old expression
across press-start offsets puts the worst case at exactly 1500 for a 60 s hold,
where three is correct. On a 32-bit time_t build the signed wrap happens to keep
suppressing, so this is host-and-variant dependent rather than universal.

The zero-dodging helpers in src/UptimeClock.h are no use here: they map 0 to 1, and
1 reads as "long ago" exactly as 0 does. The defect is the overload, not the zero,
so the field is split by what it is actually asked:

  _pressStartMs             a past event time - how long has the finger been down
  _longPressSuppressed      is repeat suppression armed
  _longPressSuppressUntilMs when it expires, read only while the bool is set

Two fields for the suppression rather than one, for the reason Throttle.h's
TODO(deadline-type) gives: armed has to stay a separate question from passed. No
single value can stand in for "unarmed" here either, since deadlinePassed() reads 0
as long past below ~24.8 days of uptime and as far future above it. Nothing new uses
0 as a sentinel, so bin/lint-unset-sentinel-millis.sh needs no entry.

All three comparisons now go through Throttle - hasElapsed() for the two
elapsed-since-press questions, which also buys the full ~49.7 day range that a
stored event time gets, and deadlinePassed() for the suppression window.

Behaviour is preserved deliberately, including the part that is easy to miss: the
old `+ 30000` made a held finger re-report LONG_PRESS once every 30 s, not once per
touch. A bool latch would have been simpler and quietly narrowed that, so the
window is kept as LONG_PRESS_REPEAT_SUPPRESS_MS. Old and new were compared across
five wrap scenarios and agree everywhere except the wrap window the old code got
wrong. The tap-on-release suppression the old write also provided is not needed: a
hold long enough to reach here has duration >= TIME_LONG_PRESS, so the tap branch
already takes its else and clears _tapped.

One guard added while here. The RAK14014 deferred-tap window is TIME_LONG_PRESS - 50
and that subtraction is unsigned now, so a variant lowering TIME_LONG_PRESS below 50
would underflow it into a ~49.7 day wait and the deferred TAP would never fire. The
only override in the tree is t5s3_epaper at 500; a static_assert fails the build
instead of the touch panel.

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

* style(touch): trim comments to the house limit

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nomdetom <nomdetom@protonmail.com>
2026-09-14 09:34:31 +00:00
bef289ef42 fix(extnotif): make isNagging the only armed flag for the nag cycle (#11828)
* fix(extnotif): make isNagging the only armed flag for the nag cycle

ExternalNotificationModule kept the nag cycle's armed state in two places that
could disagree: the isNagging bool, and nagCycleCutoff reserving UINT32_MAX for
"not armed". handleInputEvent() read only the second one:

    if (nagCycleCutoff != UINT32_MAX) { stopNow(); return 1; }

The field is declared `= 1`, while isNagging starts false, so at boot that test
said "armed" when nothing was nagging. The first input event of every boot was
therefore answered with stopNow() and a non-zero return - and a non-zero return
ends the observer chain (Observable::notifyObservers in src/Observer.h returns on
the first one), so that event was swallowed from every later observer. The handler
is registered whenever external_notification.enabled, and InputBroker only
short-circuits while nagging() is true, so the event does reach it.

The same read had a second failure mode once per ~49.7-day wrap: armNagCycle()
computes `millis() + durationMs`, which can land exactly on UINT32_MAX. When it
does, a real nag is running with isNagging true, but this read says "not armed" and
the module's own handler never stops it. Time::skipZero() cannot help here - it
lifts 0 to 1 and leaves UINT32_MAX alone, which src/UptimeClock.h static_asserts.

So the fix is not a zero guard, it is removing the second opinion. isNagging is
the armed flag - which is what the comment above the expiry check already claimed,
and what the other four reads already use - and nagCycleCutoff is now only ever a
deadline, read after isNagging has been checked. Nothing reserves a value, which
matters because an arm site spelled `millis() + interval` can produce any value
there is, so no value is safe to reserve. That is the shape the TODO(deadline-type)
note in src/mesh/Throttle.h is aiming at, and that note is updated to match rather
than keep describing the sentinel this removes.

Worth knowing for review, though not changed here: InputBroker::handleInputEvent
already calls stopNow() itself when nagging() is true, and returns without
notifying observers. Every path that starts a notification calls armNagCycle()
first, so isNagging is true for the whole life of any real nag. That makes this
handler reachable only when there is nothing to stop - its stopNow() was never
doing useful work. Gated rather than deleted, because removing a public handler
and its observer registration is a bigger call than fixing the defect.

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

* style(extnotif): trim comments to the house limit

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nomdetom <nomdetom@protonmail.com>
2026-09-14 09:33:53 +00:00
github-actions[bot]andjp-bennett 644a43ca9b Update protobufs (#11841)
Co-authored-by: jp-bennett <5630967+jp-bennett@users.noreply.github.com>
2026-09-14 07:27:39 +00:00
Matias DendaandThomas Göttgens d05fbec64c Add AEAD (AES-CCM) authenticated encryption for PSK channels (#9749)
* Add AEAD (AES-CCM) authenticated encryption for PSK channels

Extend PSK channel encryption with optional AES-CCM authenticated
encryption (use_aead flag in ChannelSettings). When enabled, messages
include a 12-byte authentication tag that prevents forgery, bit-flipping,
and injection attacks by anyone with the channel PSK.

Changes:
- Add encryptPacketCCM/decryptPacketCCM to CryptoEngine with key
  promotion (16-byte keys zero-padded to 32 for AESSmall256 compat)
- Move AES-CCM primitives (aes-ccm.h/cpp, aesSetKey, aesEncrypt)
  outside PKI guard so they're available unconditionally
- Add isAEADEnabled() to Channels with hash differentiation (XOR 0xAE)
- Add AEAD encrypt/decrypt branches in Router perhapsEncode/perhapsDecode
  with no CTR fallback on AEAD channels
- Add use_aead field to channel.pb.h (bool, tag 8)
- Add MESHTASTIC_AEAD_OVERHEAD constant to RadioInterface.h
- Add comprehensive test suite: round-trip (AES-128/256), tamper
  detection (ciphertext, tag, sweep), wrong PSK, wrong sender,
  packet-too-small, deterministic output verification

Addresses firmware#4030.

* Apply clang-format to match project style

* Guard AEAD path against empty PSK and check encrypt return value

- Add early return in encryptPacketCCM/decryptPacketCCM when
  psk.length == 0, preventing null dereference in aesSetKey
- Check encryptPacketCCM return value in Router::perhapsEncode
  (both PKI and non-PKI paths), returning BAD_REQUEST on failure
  instead of silently transmitting corrupt packets
- Add unit test for empty PSK (encrypt and decrypt must return
  false without crashing)

* Use true AES-128 for 16-byte PSKs instead of promoting to AES-256

aesSetKey now dispatches based on key length: 16 bytes creates
AESSmall128, 32 bytes creates AESSmall256. The aes member type
changes from AESSmall256 to BlockCipher (polymorphic base class).

This removes the unnecessary key promotion that added two extra
AES rounds (14 vs 12) with no security benefit since the entropy
stays at 128 bits for 16-byte keys.

encryptPacketCCM/decryptPacketCCM now pass psk.length directly
to aes_ccm_ae/aes_ccm_ad instead of promoting to 32.

New tests: ECB AES-128 with NIST vectors, AEAD test verifying
AES-128 and AES-256 produce different ciphertexts with same key
material and cross-key decryption fails.

* Reject the invalid-key sentinel in the AEAD paths

CryptoKey documents length == -1 as "invalid key - do not use", but the
AEAD guards only tested for 0. Since length is int8_t and the aes_ccm_*
key length parameter is size_t, a -1 would widen into a huge unsigned
length and be handed to the cipher instead of being rejected.

Both callers in Router.cpp are gated on a non-negative channel hash, and
generateHash() already returns -1 exactly when getKey() yields an invalid
key, so the sentinel cannot reach these functions today. Guard against it
anyway rather than relying on callers to keep that invariant.

* Tie MESHTASTIC_AEAD_OVERHEAD to CryptoEngine::AEAD_TAG_SIZE

The packet-size boundary checks in perhapsEncode/perhapsDecode budget for
MESHTASTIC_AEAD_OVERHEAD, but the tag actually written is AEAD_TAG_SIZE.
Nothing tied the two together, so changing one would have silently produced
oversized packets or truncated payloads. Assert they match instead of
coupling RadioInterface.h to CryptoEngine.

Also trims the sentinel comment to the two-line limit in AGENTS.md.

* Add RFC 3610 known-answer vectors and widen the tamper sweep

Packet Vectors #1, #2 and #7 pin aes_ccm_ae()/aes_ccm_ad() to published data
rather than to their own output, covering M=8 and M=10, a trailing partial block
in every case, and rejection of a modified AAD. Test 1 in test_AES_CCM_AEAD is
relabelled as the smoke test it actually is.

The per-byte tamper loop now walks the whole buffer including the tag, instead of
only the first four ciphertext bytes.

* Cover the second nonce input and tighten the AEAD test buffers

Test 10 only ever varied fromNode, leaving packetId — the other half of the
nonce — unexercised. It now checks each one wrong on its own, both wrong, and
both right, so the negative assertions cannot pass vacuously.

The undersized-packet test wrote into a one-byte buffer and only survived
because decryptPacketCCM() returns before touching it; size it for the whole
input so a regressed length guard fails an assertion instead of the stack.
Also assert makePsk() cannot overrun CryptoKey::bytes.

* Rewrite Unicode dashes to ASCII in AEAD comments

The ascii-dash formatter that landed in develop rewrites U+2014/U+2013 to
an ASCII hyphen. Three files on this branch still carried em dashes in
comments, so Trunk Check went red once develop was merged in. Comments
only, no code change.

* Authenticate sender and destination IDs as AEAD associated data

The nonce binds the sender and the packet id, but nothing bound the
destination, so `to` could be rewritten in flight and the tag would still
validate. Pass `from || to` as associated data to aes_ccm_ae/aes_ccm_ad so
a redirected packet fails authentication.

The hop fields stay out of the AAD on purpose: relays legitimately rewrite
hop_limit, hop_start, relay_node and next_hop.

Adds a sub-test covering redirection to another node and promotion of a
unicast to a broadcast; both must be rejected, and the unmodified
destination must still round-trip.

This changes the on-the-wire format for AEAD packets. Nothing ships with
use_aead yet, so there is no deployed traffic to stay compatible with.

* fix(crypto): repair EXCLUDE_PKI builds and guard AEAD channel config

aes-ccm.cpp is compiled in every build now and calls CryptoEngine::aesSetKey
and CryptoEngine::aesEncrypt, whose definitions were still inside the
!(MESHTASTIC_EXCLUDE_PKI) block in CryptoEngine.cpp, so MESHTASTIC_EXCLUDE_PKI=1
failed at the link step. Move both definitions outside the guard, and move the
pending-public-key declarations back inside it next to the fields they read.

fixupChannel() clears use_aead on a channel that resolves to no key material.
That combination kept a valid-looking channel hash while every encode returned
BAD_REQUEST and every decode dropped, with nothing in the config to show why.

encryptPacketCCM/decryptPacketCCM are virtual, so a platform engine can back
them with hardware CCM the way it already overrides encryptAESCtr.

perhapsEncode() carries one copy of the AEAD/CTR branch instead of an identical
copy in each arm of the MESHTASTIC_EXCLUDE_PKI ifdef.

Tests: three use_aead cases in test_channel_keys covering the hash split, the
no-key clear, and a secondary that borrows the primary's key.

* fix(crypto): move CryptoEngine::hash out of the PKI guard

hash() is plain SHA256, and PortduinoGlue calls it unguarded to derive a MAC address from the CH341 serial, so MESHTASTIC_EXCLUDE_PKI=1 failed to compile. With this and the previous commit that build links clean.

* fix(channels): resolve primaryIndex before hashing in onConfigChanged

A keyless secondary resolves its key through primaryIndex, so fixing up channels in the same pass that finds the primary hashed the early slots against the previous one and cleared their use_aead against a key they do in fact inherit. Split the pass, and re-run the fixups in the no-primary restore path, which moves the primary after the fact. Also splits the thirteen AES-CCM AEAD scenarios into separate test functions so a Unity failure names the one that broke.

* chore(crypto): trim the AEAD maintainer commits

Shortens three comments that outgrew the one-to-two line house rule, drops a truncated sentence and the braces around a single return in perhapsEncode(), and removes a channel test that the moved-primary regression test already covers. No behaviour change.

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-09-14 06:32:35 +00:00
80cfa52665 Add zero guards on time calculations where they were missing (#11692)
* time: add skipZero/safeMillis/timerEndsAtMillis helpers

skipZero() steps a millis value past 0, since stored stamps and deadlines
conventionally use 0 for "unset" and the one tick per ~49.7-day wrap that
lands on 0 would otherwise read as never-set.

safeMillis() covers a bare stamp; timerEndsAtMillis(delayMs) covers a
deadline, where the sum is what has to dodge 0 - a non-zero read plus a
delay lands there once per wrap - so it is not safeMillis() + delayMs.

* time: replace hand-rolled zero-dodging with the UptimeClock helpers

PacketHistory rxTimeMsec, EncryptedStorage s_lastFailMillis (stamps), and
SGM41562 lastRefreshMs_ / NextHopRouter learnedAtMsec (ternary stamps) each
hand-rolled skipZero() in place; swap in safeMillis()/skipZero() directly.

HapticFeedback pulseOffAt/delayedPulseAt and GPS fixHoldEnds hand-rolled the
deadline form - millis() + delay, then remap a 0 result to 1 - swap in
timerEndsAtMillis(delay).

No behavior change; each site keeps the value it already computed.

* time: guard the remaining 0-means-unset deadline/stamp writes

rebootAtMsec, shutdownAtMsec, and NotificationRenderer::alertBannerUntil are
all read back with a bare == 0 / != 0 check for 'not scheduled', but every
write site computed millis() + delay (or a bare millis() stamp) with no
guard against landing exactly on 0 - the same wrap hazard skipZero() exists
for, just never applied here.

Route every rebootAtMsec/shutdownAtMsec/alertBannerUntil write through
timerEndsAtMillis()/safeMillis(); RadioLibInterface's reboot-on-stuck-tx
sums an already-captured stamp rather than "now", so it goes through
skipZero() directly instead.

No behavior change outside the ~1-in-2^32 wrap window each site was
already exposed to.

* time: guard three more 0-means-unset deadline writes

ntp_renew (ethClient.cpp), suppressTouchTapUntilMs (Events.cpp), and tx_after
(RadioLibInterface.cpp) all read back 0 as a real state - forced NTP renewal,
no suppress window active, no TX delay armed, respectively - but each arm
site wrote a bare millis()/getMillis() + delay with no guard against the sum
landing exactly on 0.

Route each through Time::timerEndsAtMillis(). No behavior change outside the
wrap window each site was already exposed to.

Refresh the Throttle.h TODO list to note ntp_renew is converted too.

* motion: guard the calibration deadline and use Throttle::deadlinePassed

endCalibrationAt's arm site wrote millis() + calibrateFor with no guard
against landing on 0, the same value finishCalibrationIfExpired()/
drawFrameCalibration() treat as "not calibrating". Route it through
Time::timerEndsAtMillis().

Also swap finishCalibrationIfExpired()'s hand-rolled (int32_t)(now - deadline)
< 0 for Throttle::deadlinePassed(): same wrap-safe comparison the codebase
already provides, without the signed-cast pattern Throttle.h documents as
implementation-defined past INT32_MAX, and it drops the file's last direct
millis() call in favor of the Time:: wrapper the rest of it already uses.

* time: fix Throttle::execute()'s own zero-dodging

Both places execute() writes *lastExecutionMs - the first-ever-run branch
and the regular update - used bare Time::getMillis() with no guard against
landing on 0, which is the exact sentinel this function reads back as
"never run" one line above. A hit there makes the next call re-fire
immediately instead of respecting minumumIntervalMs.

Capture now via Time::safeMillis() once; every use downstream (the elapsed
comparison, the stored value) is then safe by construction instead of
needing the guard reapplied at each write.

* revert some safeMillis cases where overflow is a bad thing

* test(uptime): pin skipZero/safeMillis/timerEndsAtMillis at the wrap boundary

Covers the zero case, an ordinary nonzero value, and a sum that lands
exactly on 0 from a nonzero start - the case timerEndsAtMillis() exists
for, and the one the prior suite had no direct coverage of.

* time: restore the route-health write normalization and put it on one clock

noteRouteLearned()/noteRouteSuccess() lost their `now ? now : 1` normalization,
leaving learnedAtMsec able to store 0 - which getOrAllocRouteHealth() reads as an
ever-growing age, making the slot the first eviction candidate and permanently
stale. Normalize at the write, where the block comment already says it happens,
so every caller is covered rather than just today's two.

Both callers, the two isRouteStale() sites and doRetransmissions() now read
Time::getMillis(), so the stamp and every comparison against it share a clock.
doRetransmissions() goes back to getMillis(): its `now` feeds only comparisons,
never a 0-sentinel field, so skipping zero there only cost accuracy.

* time: read the haptic, InkHUD and calibration deadlines on the write's clock

These three deadlines were converted to Time::timerEndsAtMillis() on the write
side while their reads stayed on millis(), so each spanned two clocks and would
fire immediately or never under an injected test clock. Convert the reads to
match: HapticFeedback::scheduleNext()/runOnce(), the InkHUD tap-suppression
window, and the calibration countdown's read-back of screen->getEndCalibration().

MotionSensor's sampledAtMs is left alone - its write and read are both millis()
and consistent already.

* time: correct the sentinel notes to match what the code actually does

The Throttle.h enumeration claimed the remaining timerEndsAtMillis() callers
"already dodge the sentinel", which reads as a completeness claim the same branch
contradicts: RadioLibInterface's tx_after and activeReceiveStart are both 0=unarmed
and both still arm from bare millis(). Name them instead, so the deadline-type
conversion has the real list. The ntp_renew entry now separates a deliberate 0
("due now", forced at link-up) from a computed one, which is what changed there.

The three TODO(elapsed-stamp) blocks ran four and five lines against the repo's
one-or-two rule, and two of them argued their case wrongly. Throttle.cpp implied
safeMillis() simply doesn't help; in fact neither store is safe on the wrap tick -
the 1 underflows a same-instant read, the 0 re-takes the never-run branch - which
is the symmetry worth recording. PacketHistory.cpp called its dodge "reflecting
the previous pattern" when it is load-bearing: rxTimeMsec 0 means "empty slot"
(PacketHistory.h:21) and insert() drops a record stamped 0 outright, so without it
a packet arriving on the wrap tick is never stored and loses its dedup.

Also picks up trunk fmt's trailing-whitespace fix in Throttle.cpp and the comment
realignment in SGM41562.cpp that this branch's added comment knocked out.

* test(nexthop): pin the route-health stamp against the 0 sentinel

The uptime suite covers skipZero/safeMillis/timerEndsAtMillis themselves, but
nothing covered a call site, so the branch deleted noteRouteLearned()'s
normalization and stayed green. None of the existing route-health tests pass 0 as
`now` - they use 1000, learnAt, or millis() - (TTL + 5000) - which is exactly the
gap the regression went through.

Both new tests fail with "Expected 0 to be not equal to 0" when the skipZero() is
backed out of NextHopRouter, and pass with it. noteRouteSuccess() only refreshes
an existing record, so its twin learns a route first to reach the write.

Also drops a self-referential assertion in the uptime suite: comparing
getMillis() against safeMillis() passes even if safeMillis() does no dodge at
all, so it now asserts the literal.

* discard safemillis for skipzero (better semantics and therefore maintainability) and make consistent use of getmillis where it is called (to permit testing)

* more wrapzero safety

* STM gets some too

* time: stop the next 0-means-unset deadline being armed from raw millis()

The fields this branch armed through Time::timerEndsAtMillis() / Time::skipZero()
are the kind that get added by copy-paste: `rebootAtMsec = millis() + N` appears
at twenty-odd sites across six files, and the next module to defer a reboot will
be written from one of them. Nothing catches the mistake afterwards - the sum
lands on 0 for one tick per ~49.7-day wrap, so a test run, a soak and a bench
session all pass while a pending reboot, shutdown, DFU jump or banner expiry is
silently dropped.

Two guards, at the two places it can go wrong.

The helpers themselves: skipZero() is constexpr, so its contract is now pinned by
static_assert in the header rather than only by test_uptime_clock. The asserts are
chosen against the two plausible rewrites - `ms | 1` perturbs every even value and
`ms + 1` turns the last tick of the wrap into the 0 the function exists to avoid.
Both compile, and both pass a test that only checks skipZero(0); each trips a
distinct assert here, naming the failure mode.

The call sites: bin/lint-unset-sentinel-millis.sh flags a sentinel field in src/
assigned from a raw millis()/getMillis() read, and names the helper to use. It is
name-driven because the 0 contract is declared in src/main.h and enforced in six
other files, so no single-file scan can infer it; every one of the thirteen fields
was checked to actually test against 0 before being listed. nagCycleCutoff and
LinuxJoystick's nextRepeatX/nextRepeatY are deliberately absent - their unset state
is a separate bool - and the nine remaining `millis() + x` sites in src/ are locals
that never store 0 for anything to misread.

Blocking, unlike its note-level neighbours: there is no run-time enforcer to pair
with, and the tree has zero violations today, so gating costs nothing. Scoped to
src/ so test_uptime_clock can keep building raw wrap values on purpose.
bin/test-lint-unset-sentinel-millis.sh pins the scanner against 23 fixtures -
reads, disarms, shadowing locals, comments, string literals and the already-fixed
forms all have to stay quiet.

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

* time: guard the four 0-means-unset stamps this branch had missed

Sweeping src/ for the `if (stamp && <deadline check>)` idiom - the shape that
makes 0 mean "unset" - turned up four stamps still armed from a raw clock read,
so the new lint rule would have had to either ignore them or go red on checkout.
Each is the same one-tick-per-wrap hole the rest of the branch closes:

  * TrackballInterruptBase lastInterruptTime, armed in all four ISR handlers and
    explicitly disarmed to 0 at the threshold reset. getMillis() is the ISR-safe
    read by construction - it compiles to millis() outside PIO_UNIT_TESTING - and
    skipZero() is pure, so neither adds anything to interrupt context.
  * NeighborInfoModule lastSentReply, read as `if (lastSentReply && ...)` before
    the 3-minute reply throttle. Needed the UptimeClock.h include.
  * PositionModule lastSentReply, same throttle; already on the injectable clock
    but still missing the guard.
  * NodeDB lastSort, whose own read spells the sentinel out as `lastSort == 0 ||`.

On the wrap tick each would read as never-stamped: a trackball debounce window
lost, a neighbour or position reply sent inside the throttle it was meant to
respect, one extra NodeDB sort. Cheap individually, which is why they were missed.

All four are now listed in bin/lint-unset-sentinel-millis.sh, so the rule covers
every field in the tree that actually tests against 0 rather than a subset, and
the header records the eight stamps left off for the opposite reason - their unset
state is a separate flag (isNagging, busyTx, heldX/heldY, formatted_this_boot,
heartbeat, gotwind, haveSample, lastIaqValid), so 0 is a value they may legally
hold. The rule is silent across src/ on this tree.

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

* lint: let a site opt out of the sentinel rule, with its reason on the record

The rule is blocking, so it needs an escape hatch for the site where 0 genuinely
is a legal timestamp - and the hatch should cost something, or it becomes the
first thing anyone reaches for. `unset-sentinel-ok: <reason>` in a comment on the
write, or on a comment line above it, suppresses that one statement:

  // unset-sentinel-ok: busyTx carries the armed state, so 0 is a legal stamp here
  lastTxStart = Time::getMillis();

The reason is mandatory. A bare `unset-sentinel-ok`, or a colon with nothing
after it, is reported instead of honoured - with a message saying so - so the
only way to silence a site is to write down why it is safe. trunk-ignore still
works, but this states the justification at the write and also applies when the
script runs outside trunk.

The marker is read from comment text collected during the same character-level
pass that strips comments and literals, not by re-scanning the raw line. That is
what keeps it out of reach of data: LOG_DEBUG("unset-sentinel-ok: ...") mutes
nothing, because a string literal is not a comment. It is also consumed by the
statement it was written for, so it cannot leak onto the next write - while still
carrying across any number of intervening comment lines to the statement below,
which is where a real justification wants to be written.

Twelve fixtures added for the new behaviour: both comment styles, block and
multi-line block comments, the bare form, the marker-in-a-string cases, and three
leak cases. 35 total, all green, under bash 3.2 as well.

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

* lint: watch the separate-flag stamps too, with their exemption stated at the write

The nine stamps whose armed state lives in a companion boolean were previously
just absent from the rule's list, which meant the reasoning for leaving them out
existed only as prose in a shell script. They are now listed and individually
opted out at the write, naming the flag that actually carries the armed state:

  // unset-sentinel-ok: haveSample carries the armed state, so 0 is a legal stamp
  lastSampleMs = Time::getMillis();

The point is what happens later. If someone rewrites `if (haveSample && ...)` as
`if (lastSampleMs && ...)`, the field has silently acquired the 0 contract; with
the opt-out sitting at the write, the claim to re-examine is in front of whoever
makes that edit instead of buried in bin/.

Every exemption was checked against its real read sites before being written, and
three candidates did not survive that check. They stay off the list, because
listing one would mean stamping an opt-out over a claim that does not hold:

  * nagCycleCutoff. handleInputEvent reads `if (nagCycleCutoff != UINT32_MAX)`
    without consulting isNagging, so at that read the field is its own armed flag
    with UINT32_MAX as the sentinel - and the arm at ExternalNotificationModule
    .cpp:521 can land exactly there. skipZero() cannot help: it lifts 0 to 1 and
    leaves UINT32_MAX alone, which UptimeClock.h's own static_assert pins. There
    is also a live boot-state bug behind this - the in-class initializer is 1
    while isNagging starts false - and fixing the read is a behaviour change that
    belongs in its own PR.
  * TouchScreenBase::_start. Overloaded as an event stamp AND a `+ 30000`
    suppression deadline compared by signed subtraction, so a near-zero value
    reads as "long ago" rather than "armed 30s out" and LONG_PRESS re-fires.
    skipZero() does not fix this one either: 1 reads as long-ago exactly as 0
    does. It needs the stamp and the deadline held separately.
  * StoreForwardModule::retry_delay. No reads at all today, so nothing misbehaves
    yet; exempting it now would pre-approve the raw arm for whoever implements the
    retry its own comment promises.

The rule is silent across src/ on this tree, and the header records all three
rejections so the next person does not have to re-derive them. The self-test's
negative fixture no longer uses nagCycleCutoff as its example of a safely
unlisted field - that would have encoded the opposite of what the header says.

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

* fix(time,lint): guard the recomputed tx_after, and judge one write at a time

Two review findings, both real.

setTransmitDelay() recomputes p->tx_after from a clamp of three candidates, and
that recomputation was still raw. Two lines above it, `if (p->tx_after)` is the read
that takes 0 as "no delay wanted", so a clamp landing on 0 drops the CSMA backoff
and the packet goes out immediately instead of after its computed delay. The first
arm site in this function was already guarded; this one was missed because the
value is not a plain `now + delay` and so does not fit timerEndsAtMillis() - it
takes skipZero() instead.

The narrowing order matters here and is spelled out at the site: add_delay is
unsigned long, 64-bit on the portduino host, so the clamp can exceed UINT32_MAX
there. skipZero() on the wide value would pass 0x100000000 through as non-zero and
the store to this uint32_t field would then truncate it back to the 0 being
avoided, so the cast comes first.

The lint rule judged each write by the wrong text. rhs was taken from the write to
the end of the accumulated statement, so a neighbour on the same line decided the
verdict - and it was wrong in both directions:

  rebootAtMsec = millis() + 5; shutdownAtMsec = Time::timerEndsAtMillis(10);
      the later helper call suppressed a genuine raw arm

  rebootAtMsec = otherDeadline; shutdownAtMsec = millis();
      the later millis() reported a safe copy

rhs is now cut at its own semicolon. Six fixtures cover it, including both cases
above, two raw writes on one line, two helper writes on one line, and a statement
split across lines, which must still see its whole right-hand side. 41 fixtures
total, green under bash 3.2.

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

---------

Co-authored-by: Tom <116762865+Nestpebble@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:54:50 +00:00
renovate[bot] dcdfa32250 chore(deps): update meshtastic/device-ui digest to 776ab04 (#11815)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-12 00:10:54 +00:00
Ben Meadors df47f95ff3 fix(nodedb): build the fixed-GPS userprefs path again (#11825)
* fix(nodedb): build the fixed-GPS userprefs path again

The USERPREFS_FIXED_GPS block in the NodeDB constructor carries two defects
that only surface on vendor builds setting USERPREFS_FIXED_GPS_LAT and
USERPREFS_FIXED_GPS_LON. userPrefs.jsonc ships those keys commented out and no
CI target defines them, so the block is never compiled here and neither defect
was caught.

info has not existed in this scope since 94bb21ecc7 removed the constructor's
local NodeInfoLite pointer, leaving a hard compile error behind. That local was
initialised from getOrCreateMeshNode(getNodeNum()), so getNodeNum() resolves to
the same key it always did, and it matches how clearLocalPosition() and every
other own-node satellite write address the local node.

setLocalPosition() was reached through the global nodeDB, which main.cpp only
assigns once the constructor has returned. Inside the constructor it is still
nullptr, so the call stored localPosition through a null this. This one dates
to #5341 rather than the later restructuring. Calling directly matches the
sibling setLocalPosition() earlier in the same constructor.

Verified by forcing the two userprefs keys on: the native target fails to
compile before this change and builds clean after it.

Fixes #11812

* fix(nodedb): persist the fixed position the userprefs block writes

saveWhat is finalised by the CRC compares near the top of the constructor,
which run before the USERPREFS_FIXED_GPS block. nodePositions is a member map,
so crc32Buffer(&nodeDatabase, ...) cannot observe the position write at all,
and the config writes land after their own compare. saveToDisk(saveWhat) is the
only save left in the constructor, so both updates survived a reboot only by
chance.

The bad case is asymmetric. On a build that also pins a region, key generation
dirties config, so fixed_position = true persists while the coordinates do not.
The next boot then finds no stored position, GPS wake stays suppressed because
the fixed flag is set, and the block cannot re-run because it is gated on
reboot_count == 1.

Flag each segment next to the write that dirties it. SEGMENT_CONFIG keeps the
degraded-boot guard the compare above uses, so an unreadable config is still
never overwritten with UNSET defaults.
2026-09-11 23:41:23 +00:00
Thomas Göttgens 2a01676227 fix(nodedb): track whether each node was heard on the current LoRa config (#11811)
* fix(nodedb): track whether each node was heard on the current LoRa config

Set NODEINFO_BITFIELD_HEARD_ON_CURRENT_LORA on a genuine RF hear and clear it for every node when the LoRa slot config moves, so clients can tell which nodes went unreachable after a preset, region, slot or primary-channel-name change. Hooked into MeshService::reloadConfig(), the single funnel for the device menu, admin/CLI and scanned-URL paths, plus NodeDB::restorePreferences(), which reboots without passing through it.

Fixes #11745

* fix(nodedb): store the slot each node was heard on instead of sweeping a bit

A client scanning for traffic rolls through presets with live set_config writes, so every hop reached reloadConfig and the sweep cleared the marks on the way out and again on the way home. Each node now carries a 12-bit fingerprint of the slot it was heard on in spare bitfield bits, and heard_on_current_lora is derived by comparing that against the slot the radio is committed to. Config changes no longer touch the node database at all.

* fix(nodedb): keep comments inside the two-line limit, rename a test

Trunk read test_fingerprint_channelNumIsASlotChange as a Lob API key, since it is test_ followed by exactly 35 alphanumerics, so the tail is now shorter. The comments added under src/ are back within the one-or-two-line limit in AGENTS.md.

* fix(nodedb): drop legacy bitfield bits above 10 during v24 migration

v24 assigned bits 0..10, so a legacy record carrying anything higher would arrive claiming an RF hear with a stray slot fingerprint, and a never-heard node would read as reachable whenever that stray value matched ours. The migration now masks those bits off, and a new case in test_nodedb_legacy_migration pins it.
2026-09-11 18:51:10 +00:00
Thomas GöttgensandClaude Opus 5 8a9e10d120 fix(power): stop a battery-less board deep-sleeping itself forever (#11821)
* fix(power): stop a battery-less board deep-sleeping itself forever

The low-battery counter only reset inside its `hasBattery && !hasUSB` guard, so a board with no
battery - whose floating divider drifts in and out of the battery-present window - ratcheted the
count up across the gaps until it tripped `sds_secs`, which defaults to a ~24.8-day deep sleep. The
button could not rescue it either, because `doDeepSleep()` force-holds `BUTTON_PIN` and a held pad
ignores `ext1_wakeup_prepare()`'s re-route to RTC; `rtc_gpio_isolate()`'s pin list has the same
effect on boards whose button is GPIO 2 or 34. Separately the cutoff now scales by `NUM_CELLS`,
without which no multi-cell pack can ever read low enough to shut down at all.

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

* fix(power): satisfy trunk check

Apply the `ascii-dash` autoformat that `trunk fmt` wants on the comments this PR's file already
carries, and rename the no-battery test so its `test_` prefix plus exactly 35 characters stops
matching trufflehog's Lob API-key shape.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 16:43:28 +00:00
Austin c3df4914b4 Make Nightly index beautiful, commit nightly release_notes (#11816)
Link back to the Action, mention the commit, build date, make it pretty with CSS.

Also moves release notes into .github/nightly so they can be more easily edited. (Contents unchanged).
2026-09-11 01:53:15 +00:00
Thomas Göttgens d4482a28a1 Fail the build when Telemetry no longer fits the packet payload (#11810)
* Fail the build when Telemetry no longer fits the packet payload

* Trim the comments to the house limit
2026-09-10 17:46:44 +00:00
Thomas Göttgens 34190aac07 fix(nodedb): drop satellite entries that no hot node owns (#11808)
* fix(nodedb): drop satellite entries that no hot node owns

* test(nodedb): assert every persisted satellite key is owned
2026-09-10 15:50:48 +00:00
Manuel 5c113c730b feat: add exFat support for SDIO SD cards (#11805)
* exFat for SDIO

* update device-ui commt
2026-09-10 15:08:33 +00:00
Austin 6554194e66 Generate (very basic) HTML index for Meshtastic Nightlies (#11807)
So https://nightly.meshtastic.org doesn't just 404.
We ❤️ our nightly testers, if they don't want to use the Flasher that is fine!
2026-09-10 14:37:09 +00:00
Thomas Göttgens 546b678d50 fix(motion): drive screen wake from the accelerometer interrupt (#11758)
* fix(motion): drive screen wake from the accelerometer interrupt

The BHI260AP ISR body was empty and BHI_IRQ was never set or read, so the
attach only consumed a GPIO slot. ICM20948 could not reach its interrupt
path at all: the ICM_20948_INT_PIN fallback in the header is guarded on
ICM_20948_WOM_THRESHOLD, which the block above it always defines, so the
pin was never defined and the config, attach and interrupt-driven
runOnce() were dropped by the preprocessor on every board.

BHI260AP now configures the FIFO interrupt, attaches an ISR that sets a
flag, and enables the wrist tilt gesture so runOnce() can call
wakeScreen(). BMA423 arms INT1 push-pull active-high, which the BMA4
reset default leaves disabled, and drains on the interrupt instead of
every 50 ms. Both keep a slow keepalive drain so a pin that never
asserts degrades to polling rather than losing tilt and tap wake.

MOTION_WAKE_INT_PIN resolves whichever motion interrupt a variant
declares. doLightSleep() arms it as a GPIO wake source and lsIdle()
attributes the resulting wake to motion, which it previously charged to
BUTTON_PIN and dropped. Both are gated on
config.display.wake_on_tap_or_motion, matching MotionSensor::wakeScreen().

Closes #11755

* fix(motion): use the ICM20948 interrupt without dropping the compass

The ICM_20948_INT_PIN build of runOnce() was a full replacement for the
polled one and kept only wake-on-motion, so defining the pin would have
dropped the magnetometer fusion that feeds screen->setHeading(), the
calibration flow and the IMU sleep handling. providesHeading() returns
true for this part, so that is the compass.

Merge the two: the pin now selects the wake-on-motion mechanism only.
The status register poll stays compiled in behind a keepalive, since no
shipped firmware has exercised this line, so a pin that never asserts
costs latency rather than wake-on-motion.

Declare the pin on t-echo-card. Sensor_INT is P1.13, open drain with a
10K pullup to VDD3V3, matching the driver's active-low config and
FALLING attach. The schematic's SCL P1.02 / SDA P1.04 match PIN_WIRE_SCL
and PIN_WIRE_SDA.

* Revert the t-echo-card ICM20948 interrupt pin

Sensor_INT is not the IMU. In both T-Echo-Lite_V1.0 and
T-Echo-Lite-Card_V1.0 it appears only on the unannotated 5-pin expansion
header (P?, 5PIN_PA1.0) carrying SDA_P1.04, SCL_P1.02, VDD3V3, GND and
Sensor_INT with its 10K pullup, and it leaves the sheet as an off-sheet
port. Neither schematic contains an ICM20948 symbol at all, and the
vendor pin map declares only ICM20948_SDA, ICM20948_SCL and
ICM20948_ADDRESS for the part.

The interrupt belongs to whatever plugs into that header, so the onboard
IMU has no reason to drive it. The driver keeps polling.

* Poll until an ICM20948 interrupt pin proves itself

A variant that declares ICM_20948_INT_PIN is asserting routing no vendor
firmware has ever exercised, so treat the line as unproven: keep polling
the wake-on-motion status register at full rate, and only back off to the
keepalive once the pin has actually fired. A wrong pin then behaves
exactly as before rather than trading wake latency for the guess.

* feat(t-impulse-plus): drive ICM20948 wake-on-motion from its INT pin

The LilyGO pinmap documents the IMU's INT on P0.07, and variant.cpp
already maps and names it as D27, but the pin was never handed to the
driver, so wake-on-motion polled the status register every 50 ms.

Use the D number: pinMode() and attachInterrupt() index
g_ADigitalPinMap, where a raw 7 selects P1.13, the LoRa RF_VC1 TXEN
line. The driver polls until the pin proves itself, so an ICM20948 that
turns out not to drive it keeps working as before.

* Derive MOTION_WAKE_INT_PIN after the build exclusions

MESHTASTIC_MINIMIZE_BUILD defines MESHTASTIC_EXCLUDE_I2C further down the
file, so the guard read as unset and a minimized build defined the pin
anyway. doLightSleep() would then arm a GPIO no motion driver configures,
since every driver is compiled out with I2C.

Latent rather than live: nothing sets MESHTASTIC_MINIMIZE_BUILD today,
and the variants that pass -DMESHTASTIC_EXCLUDE_I2C were already correct
because a build flag is defined before this file is parsed.

* fix(motion): keep the BMA423 INT1 config failure non-fatal

Restores the resolution made when feature/sensorlib-0.4.1 was merged into
this branch. That merge is gone after the rebase, and neither parent
carried this: the interrupt path is an optimisation over the existing
poll, so a pin-config failure should log and fall back rather than be
ignored outright.
2026-09-10 11:50:37 +00:00
renovate[bot] 06f2111784 chore(deps): update rak13800-w5100s to v1.0.4 (#11783)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-10 11:04:28 +00:00
Manuel 27afe1159b fix vbus detection (#11801) 2026-09-10 11:02:58 +00:00
github-actions[bot]andcaveman99 2dbc33e4d1 Update protobufs (#11809)
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
2026-09-10 17:05:13 +02:00
103463e26d fix(esp32): identify LilyGo T5 S3 ePaper Pro targets (#11368)
* fix(esp32): identify LilyGo T5 S3 ePaper Pro targets

* fix(esp32): mark T5 S3 ePaper Pro targets actively supported

---------

Co-authored-by: George <509474+giannoug@users.noreply.github.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: rcarteraz <robert.l.carter2@gmail.com>
2026-09-10 06:42:39 -07:00
Tom 777c79f6d8 revert a conflict regression and utilise the full power of the lr2021 lna (#10633)
* revert a conflict regression and introduce the DCDC workaround from semtech example code.

* fix: Adjust DCDC workaround placement for

* clod fixes stuff

* clod fixes some more things
2026-09-10 10:35:33 +00:00
github-actions[bot]andcaveman99 a495ef007c Update protobufs (#11806)
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
2026-09-10 13:16:07 +02:00
github-actions[bot]andcaveman99 a25ff005f0 Update protobufs (#11804)
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
2026-09-10 12:35:20 +02:00