Commit Graph
12622 Commits
Author SHA1 Message Date
HarukiToreda 6fa3df0af3 node bridge 2026-09-14 16:20:07 -04:00
James Rich 46b4fdc799 The ESP32 spike is inert again unless a variant opts in, and BLE ingress resets priority
Four findings from the 2026-09-06 feature review, verified against this branch.

The spike was not inert on ESP32. [ble_mesh_esp32] documents itself as opt-in and
the commit that introduced it is titled "Factor the ESP32 BLE mesh build settings
into one opt-in block", but it was referenced with no condition from
[esp32s3_base] and [esp32c3_base] - the sections every S3 and C3 variant extends.
So HAS_BLE_MESH, BLE_MESH_USE_EXT_ADV and HAS_BLE_GATT_MESH were set on every
S3/C3 build, against a NimBLE rebuilt with ext-adv and the observer role, and the
#if in NimbleBluetooth.cpp made the legacy advertising branch dead code - the
phone advertisement on a stock Heltec V3 was the spike's hand-rolled ext-adv path.
The base sections no longer reference it; heltec-v3_blemesh and
heltec-ht62-esp32c3-sx1262_blemesh do, the way rak4631_blemesh already did on
nRF52. Checked with `pio project config` across all 306 envs: no env carries the
flags now without opting in, and the two opt-in envs carry all of them.

clearCorruptBondStoreOnce() erased the NimBLE bond database once per device on the
first boot of any ESP32 build from this branch, unpairing every phone with no
notice. Its own comment says why it exists - NimBLE failing to come up "after
flipping between builds with different BLE configs" - which is this feature's
hazard and nobody else's. Gated on HAS_BLE_MESH.

The NO_PIN branch changed setAuthenticationMode(true, false, false) to
(false, false, false), so no ESP32 build from this branch offered to bond in
NO_PIN. The reason is the unauthenticated mesh-peer characteristic, so it is
gated on HAS_BLE_GATT_MESH and a build without it keeps develop's behaviour.

Both BLE ingress paths now reset priority. via_mqtt and tx_after were already
cleared as local-only, but priority was left as sent - and unlike want_ack,
next_hop and relay_node it is not a field the 16-byte LoRa header carries, so
fixPriority() always derives it locally for a LoRa arrival and this bearer is the
first that lets a sender choose it. Left alone, a crafted frame with priority MAX
outranks ACK, the ceiling fixPriority assigns, and once perhapsRebroadcast copies
it into the TX queue replaceLowerPriorityPacket evicts one of ours for it. No PSK
needed: the guards run before decryption.

Native suites green in Docker: test_ble_mesh 13, test_ble_gatt_mesh 26,
test_transport_registry 7. No ESP32 or nRF52 build was run - the ESP32 toolchain
rewrites a framework sdkconfig shared with every other checkout on this machine,
so that wants a deliberate sitting.
2026-09-06 08:31:34 -05:00
James Rich ca0a39c512 Review fixes: the nRF52 phone is the link that uses the phone API, and a free slot is always advertised
With a mesh peer sharing the radio, connect order says nothing about which
link is the phone's, but NRF52Bluetooth took the last connect as the phone:
fromNum notifies went to Bluefruit's default handle (the newest link), and
a mesh peer dropping - or any link dropping before it subscribed - closed
the phone's session and wiped its ToRadio state. The phone is now whichever
link uses the phone API (toRadio write, fromRadio read, fromNum/logRadio
CCCD); notifies and logs address that handle, and only its drop ends the
session. Non-mesh builds keep the old behaviour.

Bluefruit stops the connectable advertisement on connect and restarts it
only when no peripheral link remains, so the second slot was reachable only
if that peer connected first, and a peer that dropped could not come back
while the other link was up. The mesh layer re-arms the advertisement on
every connect and disconnect while a slot is free. Bench: Pixel connected,
BLE broadcast off so nothing else could re-arm it, iPad killed and
relaunched - connected and subscribed in three seconds.

Both platforms: the zero-length disconnect marker is never dropped from a
full RX ring (it overwrites the newest chunk of a dead link) so reassembly
state cannot leak onto a reused connection handle. nRF52: a departed peer is
refused before Bluefruit's blocking notify rather than by fifty timeouts.
Both BLE ingress paths clear via_mqtt and tx_after, which only the local
stack may set. Native suites test_ble_mesh, test_ble_gatt_mesh and
test_transport_registry pass; rak4631_blemesh and heltec-v3 build.
2026-09-05 08:09:24 -05:00
James Rich df1ae63bf3 nRF52: mesh-peer GATT service over Bluefruit
The same BLEGattMeshHandler the ESP32 runs, with the Bluefruit half it was
missing: an open write/write-without-response/notify characteristic on the
mesh-peer UUID, received values queued from Bluefruit's callback task for
the main-task pump, subscribed links as notify targets, and the negotiated
MTU read live per peer.

S140 has one advertising set, so the service UUID rides in the phone
advertisement's scan response (instead of the TX power) and the name beside
it is shortened. A link becomes a mesh peer when it subscribes to the mesh
characteristic; the phone API's disconnect handler skips those links so a
peer dropping never closes the phone's session. Bluefruit.begin(2, 1): the
phone and one mesh peer at once, on the re-based linker script. Enabled by
the BLE_GATT_PEER protocol bit, built by rak4631_blemesh.

WisMesh Pocket <-> Pixel 6a monitor, both directions: the phone found the
service 3 s after boot and subscribed at chunk 244; its Send test arrived
as BLE GATT mesh RX and decoded; a Pocket text reached the phone as
rx[gatt] a second after sending. RAM 41.1%, flash 92.0%.
2026-09-05 07:15:36 -05:00
James Rich a4fc823495 nRF52: log each BLE mesh advertising burst and how its set terminated
On the S140 the mesh shares advertising set 0 with the phone API, so a
frame that never reaches a phone is indistinguishable from one that was
never sent. Log the burst (handle, length, dedicated/shared) and the
BLE_GAP_EVT_ADV_SET_TERMINATED reason and event count. On a WisMesh Pocket
every burst reads 'shared' then 'reason 2 after 3 events': the shared path
radiates; the silence was the phone's scan being downgraded.
2026-09-05 06:49:29 -05:00
James Rich 361ac318df nRF52: define nrf52BluetoothReady so the BLE mesh build links
The readiness flag was declared extern and set in NRF52Bluetooth.cpp but
never defined, so every nRF52 env with HAS_BLE_MESH failed to link. With it
the rak4631 spike builds (RAM 39.8%, flash 91.4%) and a WisMesh Pocket
receives BLE-adv mesh frames from a phone (BLE mesh RX, rssi -36).
2026-09-04 20:59:48 -05:00
James Rich fcc3c05826 Prefer 1M PHY on the ESP32-S3/C3 controller so an Apple central cannot crash it
An A16 iPad (iPadOS 26) connecting to any ESP32-S3 node asserts the binary
BLE controller in its remote PHY-update handler (BLE assert lld_con.c 3397,
esp-idf#15311) about 200 ms after the link forms, before any GATT traffic,
and the node bootloops for as long as the iPad keeps reconnecting. Stock
develop and the nightly reproduce it with the stock iOS app.

Apple's guidance for accessories is to indicate 1M-only PHY preferences;
iOS negotiates 2M at the controller level and apps cannot change it. The
NimBLE wrapper for that is compiled out of the prebuilt host, so issue
HCI LE Set Default PHY (1M/1M) directly once the host has synced.

Bench, Heltec V3: 0 of ~170 iPad connects survived before; 9 of 9 survive
with this, subscribe, and carry frames. A Pixel requesting 2M was never
affected either way, and still negotiates 2M itself.
2026-09-04 20:13:32 -05:00
James Rich c7fa0e26b8 Revert "Converge the mesh-peer advertisement on the slot count, and stop speaking for links we never held"
This reverts commit 7153c787ab.
2026-09-04 13:33:03 -05:00
James RichandClaude Fable 5.1 7153c787ab Converge the mesh-peer advertisement on the slot count, and stop speaking for links we never held
Review of the previous commit found two problems with the re-arm-on-every-drop
change, both about the slot gate being a check-then-act across two tasks.

startAdvertising() reads the link count under lock and then makes its ble_gap
calls outside it - it has to, because the host task takes the same lock in
addLink and is also the task that delivers the HCI completion those calls block
on, so holding it across them would deadlock. A CONNECT landing in that window
takes the slot after the count said it was free, and the set goes on the air with
no room for the peer it attracts: a second mesh central then connects, both host
connection slots are mesh peers, and the phone API is locked out until one drops.
Now the slot-full branch stops a set it finds running, and the CONNECT-success
path asks for a re-arm so that branch is reached promptly. Whichever task wins
the race, the state converges on "slot held, set off" instead of staying wrong
until the next disconnect.

onDisconnect() logged "conn N disconnected (via ... advertisement)" and latched
pendingAdvertising for any handle at all, including links this transport never
held. With BLE_GATT_PEER off that meant every ordinary phone drop printed a
mesh-transport line about itself and armed an advertisement for a service that
does not exist. It now returns early for a handle the table does not know. This
costs the deferred-start (rc=ENOMEM) retry nothing: with the feature on,
onConnect registers every server link whichever advertising set it arrived on, so
a drop that could free a connection slot is always a link the table knows.

Built for heltec-v3 (pio run, 93 s). Still not flashed - the bench verification
in the previous commit's message covers this one too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:55:36 -05:00
James RichandClaude Fable 5.1 9f5436300c Keep the mesh-peer advertisement alive: a CCCD write is not "arrived on instance 2"
On the bench the Pixel's GATT rx sat at zero from launch and across every node
rebuild while ble-adv, lora and udp all flowed, and its GATT tx was "accepted by
a peer". The peer was the iPad: logcat shows the Pixel's central connecting only
to random-address Apple peers and never to a public-address NimBLE device. The
V3's connectionless broadcast (instance 1) was on the air; its connectable
mesh-peer advertisement (instance 2) was not, so the Pixel's filtered scan had
nothing to find. The phone-API set (instance 0) had gone dark with it.

Root cause is a conflation in ESP32BLEGattMesh: onSubscribe() called
addLink(conn, true), flagging any link that wrote the mesh CCCD as viaMeshAdv.
That flag does two jobs - it is the slot count startAdvertising() gates on
(BLE_GATT_MESH_MAX_LINKS is 1) and it is the early return in
NimbleBluetoothServerCallback::onDisconnect that skips the phone-API session
reset and re-arm. Both sets advertise the same public address, so a central that
finds instance 2 in a scan can land its CONNECT_IND on instance 0's ADV_IND
(the earlier "onSubscribe never fired for a central that connected via the
phone-API instance" commit saw exactly that). Such a link, once subscribed,
counted against the mesh slot; when a second central (the iPad) took instance 2
the slot was full, and when the first link dropped on a monitor rebuild the early
return left instance 0 unarmed too - "peer slots full, not advertising" at
LOG_DEBUG was the only trace. Nothing recovered until reboot.

- onSubscribe() now calls addLink(conn, false): a CCCD write sets `subscribed`
  (still the only thing that makes a link a notify target) and leaves
  `viaMeshAdv` exactly as the instance-2 CONNECT event set it, which is the one
  meaning it can safely carry.
- onDisconnect() re-arms the mesh-peer advertisement for every dropped link and
  lets startAdvertising() decide, since a phone-API link dropping is what frees
  the connection slot a deferred start was waiting for. startAdvertising() skips
  a set that ble_gap_ext_adv_active() says is already running, so the re-arm
  never interrupts a live advertisement.
- The slot-full line is LOG_INFO and names the conn handle holding the slot; an
  ENOMEM start (every connection slot taken) is LOG_WARN "deferred", and every
  ext-adv rc carries a hint: 6 = no free connection, 519 = controller activity
  budget (CONFIG_BT_CTRL_BLE_MAX_ACT).
- Subscribe and disconnect lines say which advertisement the link arrived on.

BLE_GATT_MESH_MAX_LINKS stays 1: two dual-role phones still race for the single
mesh slot by design, and the header now says so and what to do about it (run the
second phone PERIPHERAL_ONLY, or raise the slot with MAX_CONNECTIONS/MAX_ACT).
The esp32-common.ini note records that the duplicated CONFIG_BT_NIMBLE_MAX_
CONNECTIONS / MAX_ACT keys resolve to [ble_mesh_esp32]'s values - verified in
the generated sdkconfig.defaults - and that comment lines are stripped before
the custom_sdkconfig hash, so the note itself does not trigger a libs rebuild.

Built for heltec-v3 (pio run, 90 s, no IDF-lib recompile). Not yet flashed or
verified on the bench.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:25:38 -05:00
James RichandClaude Fable 5.1 ea24b26d52 Notify mesh peers per-connection and drop the spike diagnostics
Egress now sends each fragment to a specific subscribed connection with
ble_gatts_notify_custom(peer, valueHandle, ...) instead of broadcasting to every
subscriber, so the pump's exclude-the-arrival-peer step actually takes effect
(a relayed packet is no longer echoed to the peer it came from). The value handle
resolves correctly now that setupService() forces the server's GATT start.

A link becomes a notify target only when onSubscribe marks it subscribed - the
CCCD write is the "this is a mesh peer" signal - so onConnect just tracks the
link and a plain phone-API client is never sent mesh frames. Removed the verbose
diagnostic logging added while chasing this down and restored the enc-change
warning to its plain form.

Verified on hardware: the Android client's rx counter advances with the v3's own
frames and relayed LoRa traffic (a decoded position from another node, plus
opaque channel-50 frames) over BLE GATT.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:50:54 -05:00
James RichandClaude Fable 5.1 668645fdee Make the mesh-peer egress actually deliver: resolve the handle and register peers server-side
The transport connected and subscribed but never delivered a frame to the phone.
Four things were wrong, found on the bench and fixed here:

- Peers were only registered from onSubscribe/onWrite, which never fired for a
  central that connected through the phone-API advertising instance. Register
  every inbound link from the server's own onConnect callback (ESP32BLEGattMesh::
  onConnect, hooked in NimbleBluetoothServerCallback::onConnect), which fires
  regardless of advertising instance, and also handle the raw
  BLE_GAP_EVENT_SUBSCRIBE in our GAP callback.
- The characteristic's value handle never resolved (getHandle() == 0xFFFF): the
  wrapper only resolves handles inside BLEServer::start(), which it triggers from
  BLEAdvertising::start(). This firmware advertises through the raw
  ble_gap_ext_adv API and never calls the wrapper's advertising, so force
  server->start() after registering the service.
- Notifications went through the raw NimBLE ble_gatts_notify_custom() with an
  esp_gatts handle, which returns success but delivers nothing. Send through the
  wrapper's own BLECharacteristic::notify() - the exact path the phone-API's
  fromNum uses.

Proven on hardware: an Android client (node-kmp monitor) subscribed to the
mesh-peer service receives frames the v3 originates and relays - rx counter
advanced with a frame from the v3's own node number over BLE GATT.

Still spike-quality: verbose diagnostic logs remain (revert), notify() broadcasts
to all subscribers rather than excluding the arrival peer (dedup covers it for
now), and the C3/nRF52 bring-up is unbuilt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:38:13 -05:00
James RichandClaude Fable 5.1 55ee523b27 spike: add egress diagnostics (onSend/pumpTx/onWrite/onSubscribe) — REVERT later
Temporary LOG_INFO probes used to isolate the mesh-peer egress failure on the
bench. They proved: onSend fires (registry OK) and pumpTx runs, but always with
peers=0/0, and onSubscribe/onWrite never fire for a central that connected via
the phone-API advertising instance. This is what's flashed on the bench v3
(2.8.0.6c1c7fe). Revert before the branch is cleaned up for review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 06:42:52 -05:00
James RichandClaude Fable 5.1 6c1c7feba9 Stop the node initiating BLE bonding in NO_PIN mode (mesh-peer pairing wall)
An Android central connecting to the unauthenticated mesh-peer GATT service was
having its link torn down before it could subscribe. Root cause found on the
bench: even in NO_PIN mode the firmware called setAuthenticationMode(bonding=true,
...), so the node advertised the SMP bonding bit and a central "just works"-paired
on connect; that pairing failed and the ACL dropped (the phone looped
connect -> discover -> drop, never subscribing). Nothing in NO_PIN needs an
encrypted link, so offer no bonding at all: setAuthenticationMode(false,false,false).

Verified on a heltec-v3: after this change the recurring
"BLE encryption change without encrypted link; ignoring" is gone (0 occurrences)
and no pairing is attempted. Also logs the enc_change sec_state (bonded/
authenticated/key_size/role) on a failed encryption, which was previously
discarded, to tell a failed pairing apart from a stale-bond reconnect.

PIN modes (RANDOM_PIN/FIXED_PIN) are unchanged, so the secured phone API keeps
its passkey pairing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 06:23:29 -05:00
James RichandClaude Fable 5.1 3022a37768 Add the BLE-GATT mesh-peer transport: a phone connects to the node as a mesh peer
The SIG-Mesh "GATT proxy" role: the node runs a GATT server on a private
service, phones connect to it as centrals and exchange whole mesh frames over a
write/notify characteristic, without a phone-API session. Point-to-point where
LoRa and the advertisement transport are one-to-many; a broadcast is N notifies.

Two halves:

- BLEGattMeshHandler (platform-neutral, built into the native suite): fragment
  framing shared byte-for-byte with the node-kmp client, bounded reassembly
  (per-peer and per-in-flight caps, expiry), the ingress guards the UDP and
  advertisement transports already apply (validate-before-relay, drop senderless
  / self-claimed / impossible-hop packets, strip local-only metadata), a per-peer
  TX ring, and no-echo-back-to-the-arrival-peer via a small (from,id)->peer table.

- ESP32BLEGattMesh (NimBLE): its own connectable advertising set on instance 2
  carrying the service UUID, per-connection notifies, an MTU-derived chunk size,
  and a GAP handler chained ahead of the Arduino wrapper's so the server's
  connection/MTU/subscription bookkeeping covers these links too. A mesh-peer
  disconnect is gated out of the PhoneAPI session teardown.

Registry-gated on the new BLE_GATT_PEER protocol flag, so it carries outbound
packets only while a phone is being served as a peer. The sdkconfig bump that the
second connection needs (CONFIG_BT_NIMBLE_MAX_CONNECTIONS=2, CONFIG_BT_CTRL_BLE_MAX_ACT=6)
lands here with the service rather than ahead of it. Protobufs pointer bumped for
the TRANSPORT_BLE_GATT and BLE_GATT_PEER enums; generated headers regenerated.

Proven: native suite 1418/1418 (26 new cases for this transport); heltec-v3
(ESP32-S3) builds and, with WiFi off, brings the service up and advertises on
instance 2 with no OOM / NimBLE 519 / crash at the 2-connection config.
Not yet proven: a phone connecting as a mesh peer and a frame crossing device to
phone (bench-gated); ESP32-C3 and nRF52 are unbuilt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:02:16 -05:00
James RichandClaude Opus 4.8 d8ea498012 Move the MQTT egress tap onto the transport registry
Add a pre-encode fan-out point to MeshTransportBase so a transport can act on
the decoded packet (with its now-encrypted copy and the channel index) before
the decoded copy is released, alongside the existing post-encode point that UDP
and BLE use. A transport opts into exactly one point via its constructor, and
the two point at separate registries so they never cross.

MQTT registers at the pre-encode point through a thin MQTTTransport adapter, so
Router::send() no longer names MQTT directly. Its moduleConfig.mqtt.enabled and
isFromUs gate stays at the call site (same point, inside the decoded-tag block)
and every MQTT-side gate - via_mqtt loop-prevention, per-channel uplink,
DontMqttMeBro, range-test suppression, PKI-vs-channel encryption choice - stays
inside MQTT::onSend() verbatim. The old `&& mqtt` null check moves into the
adapter's hook. Because the adapter is invisible to the post-encode fan-out, MQTT
still fires only for our own originations and never for the relayed /
already-encrypted broadcast traffic. LoRa iface->send() and the post-encode
UDP/BLE path are untouched. No behaviour change.

test_transport_registry gains three cases pinning the new invariant: pre-encode
transports receive the encrypted packet, decoded copy and channel index in
registration order; the pre- and post-encode points are disjoint; and an empty
pre-encode registry is a safe no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZfYRoJFcYiqUxWQKTLUQR
2026-09-03 11:34:51 -05:00
James RichandClaude Opus 4.8 0091277731 Route the non-LoRa egress taps through a transport registry
Router::send fanned outgoing packets to UDP multicast and BLE mesh through
two hardcoded, guard-wrapped taps sitting just after encryption. Replace
them with MeshTransportBase, a thin registry modeled on MeshModule: each
transport self-registers in its constructor, and callTransports() hands the
encrypted packet to every enabled transport in registration order. A new
broadcast transport now plugs in without editing the funnel.

Behavior is unchanged. UdpMulticastHandler and BLEMeshHandler already exposed
bool onSend(const meshtastic_MeshPacket*), so they become overrides with no
body edits; the per-tap enabled_protocols check moves verbatim into each
transport's isEnabled(), and the old udpHandler/bleMeshHandler null checks are
subsumed by "an unconstructed transport never registers". Registration order
(UDP then BLE) matches the former tap order. Unlike MeshModule there is no
CONTINUE/STOP contract: these are parallel media, so one transport accepting a
packet never suppresses another - callTransports ignores the return.

MQTT stays a hardcoded tap: it fires earlier, inside the decoded-tag block
before p_decoded is released, and needs the decoded copy plus the channel
index - a pre-encryption hook point this registry does not yet model. LoRa's
iface->send is untouched and remains the mandatory path.

test_transport_registry pins the invariant the rewrite could break: every
enabled transport is called in registration order, a true return does not
suppress a later transport, disabled transports are skipped, and an empty
registry is a safe no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZfYRoJFcYiqUxWQKTLUQR
2026-09-03 11:06:10 -05:00
James RichandClaude Opus 5 4aa73bf4ea Regenerate the protobuf headers properly, replacing the hand-edits
TRANSPORT_BLE_ADV and BLE_BROADCAST were hand-written into the generated nanopb
headers because nanopb was not available here. They are now produced by the
generator, so a future regen extends them rather than silently reverting them.

Toolchain: nixpkgs supplies protoc and PyPI the generator, pinned to 0.4.9.1 -
the version the checked-in headers name, not the 0.4.9 bin/regen-protos.sh's
message says. Two shims under nanopb-0.4.9/generator-bin/ pair them so that
script runs unmodified; the directory is already gitignored.

Validated before trusting it: a regen against the unmodified submodule changed
only the two files carrying the hand-edits and left every other generated file
byte-identical. config.pb.h then came back from the real regen identical to what
had been written by hand, which is a decent check on both.

Two things surfaced that are worth knowing. The submodule pointer is behind the
protobufs the checked-in headers were generated from - regenerating at the
pinned commit *removes* doc comments that are in the tree - so this bumps it to
the commit carrying the enum change. And the remaining churn in mesh.pb.h is
hyphens becoming em-dashes in an unrelated comment block, which is drift the
submodule bump brings in rather than anything this change does.

The bump points at a protobufs commit that exists only locally
(spike/ble-mesh-transport). That has to land upstream before this branch is
pushable by anyone else.

heltec-v3 builds; test_ble_mesh 13/13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 17:07:37 -05:00
James RichandClaude Opus 5 4c5863c09a Factor the ESP32 BLE mesh build settings into one opt-in block
The five sdkconfig options and two build flags were copied between esp32s3 and
esp32c3, and the c3 copy had drifted - it carried three of the five, so the
transport there could have advertised and never received, exactly the failure
the s3 took a full afternoon to diagnose. A variant now opts in by referencing
${ble_mesh_esp32.build_flags} and ${ble_mesh_esp32.custom_sdkconfig}, which
also gives the settings one place to carry the reason each is needed.

Order is load-bearing and documented in the block: esp32_common sets all three
NimBLE options to n, so the reference must come after it.

Verified after the refactor rather than assumed - heltec-v3 and
heltec-ht62-esp32c3-sx1262 both build, and a reflashed Heltec still reports
both halves coming up:

  BLE advertising started (extended API, legacy ADV_IND)
  BLE mesh scanning started

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:47:56 -05:00
James RichandClaude Opus 5 890f12ec27 Add native tests for the BLE mesh transport
Thirteen cases over the half of the transport that has no BLE in it: building an
advertisement, the ingress guards, the TX ring and the readiness pump. Full
suite 1385/1385.

Testable because BLEMeshHandler has no platform BLE dependency - ESP32BLEMesh
and NRF52BLEMesh do - so native now compiles it via HAS_BLE_MESH=1 on
native_base. Nothing runs there: main() instantiates neither platform subclass
on portduino, so bleMeshHandler stays null.

Adds one seam for it. deliverToRouter called router->enqueueReceivedMessage
directly, which meant the ingress guards could only be tested by standing up a
live Router; it now goes through a virtual enqueueReceived() that the test
overrides to observe what survives. Production always takes the default.

The cases are the bugs this transport actually had, or the ones its guards
exist to stop: a relayed packet must not be refused (refusing it capped the mesh
at one hop), onSend must queue rather than transmit (advertising inline stalled
the router for the length of every burst), a spoofed from=0 and an out-of-range
hop count must be dropped, claimed PKI authentication must be stripped rather
than believed off the wire, and our own advertisement heard by our own scanner
must not loop back in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:35:08 -05:00
James RichandClaude Opus 5 d4f9a6a202 Make ESP32 BLE mesh actually work: three sdkconfig fixes
Node-to-node confirmed on hardware. A Heltec V3 and a RAK4631 exchange mesh
frames over BLE 5 extended advertisements: a packet originated by the Heltec
(0xd1d90f21) was seen advertised by the RAK carrying transport=9
(TRANSPORT_BLE_ADV, which deliverToRouter stamps only on BLE receive), and the
RAK's region is UNSET so its LoRa RX is disabled - BLE is the only path it could
have arrived by. Two further frames from other nodes relayed the same way.

Getting there needed three settings, each of which failed differently and none
of which is about this transport's own code:

1. CONFIG_BT_NIMBLE_ROLE_OBSERVER=y. Scanning is a NimBLE role and the stock
   Arduino build compiles it out - ROLE_BROADCASTER=y, OBSERVER not set - so
   ble_gap_ext_disc returned BLE_HS_ENOTSUP (8). The transport could always
   advertise and could never receive. Same class of bug as nRF52 needing
   Bluefruit.begin(1, 1).

2. CONFIG_BT_NIMBLE_EXT_ADV_MAX_SIZE=257. The default 1650 is the chained
   ceiling, reserved per instance, for a transport capped at one 251-byte PDU.

3. CONFIG_BT_CTRL_BLE_MAX_ACT=4. This was the real blocker. The controller
   counts advertising sets, scans and connections as "activities" and the
   default budget is 2 - exactly the stock build's one advertisement plus one
   connection. BLE mesh needs three: the PhoneAPI advertisement on instance 0,
   the mesh advertisement on instance 1, and a scan. Both the scan enable and
   the second ext_adv_configure returned HCI 0x07, Memory Capacity Exceeded,
   surfacing as NimBLE 519.

Diagnosing any of this needed boot logs, which were unobtainable for most of
this work: `pio device monitor` cannot run with stdout redirected (miniterm
calls termios.tcgetattr and fails on a non-tty), and debug_log_api routes
firmware logs to protobuf, silencing the UART. Reading the port with pyserial
and pulsing DTR/RTS to reset the board is what finally made the boot visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:02:45 -05:00
James RichandClaude Opus 5 dacdf0dcec Port the PhoneAPI advertisement to ext-adv instance 0; fix the guard the merge broke
Three things, one of which explains the regression I could not account for.

THE REGRESSION. The BLE mesh transport worked before Ben's platform layer was
merged and stopped afterwards, and it was not device state. His code - and mine
after the merge - gates the extended-advertising path on MYNEWT_VAL(BLE_EXT_ADV).
That macro resolves from the *prebuilt* esp_nimble_cfg.h in the framework's
include tree, which does not reflect custom_sdkconfig, so it reads 0 even when
the rebuilt NimBLE library does have extended advertising. The extended path
therefore compiled out and the transport silently fell back to the legacy
31-byte branch, which cannot carry a mesh frame at all. The original spike worked
precisely because it called ble_gap_ext_adv_* unguarded. Now gated on
BLE_MESH_USE_EXT_ADV, a flag this repo sets in build_flags and therefore controls.

THE SCAN. ble_gap_ext_disc returning 8 (BLE_HS_ENOTSUP) is not an ext-adv
conflict - scanning is a NimBLE *role*, and it is compiled out:
framework-arduinoespressif32-libs/esp32s3/sdkconfig has ROLE_BROADCASTER=y and
"# CONFIG_BT_NIMBLE_ROLE_OBSERVER is not set", and variants/esp32/esp32-common.ini
:286-287,311 sets ROLE_CENTRAL=n, ROLE_OBSERVER=n and EXT_ADV=n outright. So the
transport could always advertise and could never receive. This is the same class
of bug as nRF52 needing Bluefruit.begin(1, 1) for a central link. Enables
CONFIG_BT_NIMBLE_ROLE_OBSERVER for esp32s3.

THE PORT. NimbleBluetooth::startAdvertising() now drives ext-adv instance 0 with
legacy_pdu=1, so the on-air PDU stays an ordinary ADV_IND and phones without
BLE 5 discover the node exactly as before. It reuses the wrapper's own GAP
callback rather than replacing it - NimBLE stores the advertising instance's
callback on every connection made through it, so SUBSCRIBE, MTU and pairing keep
reaching BLEServer and fromNum notifications are unaffected. Reaching that
private member uses the explicit-instantiation access idiom, which compiles. The
forwarder re-arms instance 0 on a failed connect, where the stock handler would
otherwise call the now-ENOTSUP legacy path and leave the node invisible.

NOT WORKING YET. With all of the above the Heltec still emits neither a phone
advertisement nor a mesh frame. The ext-adv branch is confirmed present in the
image, so the remaining fault is below this layer and is not isolated. Do not
read this commit as a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:34:40 -05:00
James RichandClaude Opus 5 ef1d0f571d Add a TX-only switch and NimBLE bond recovery; ESP32 ext-adv is the real blocker
Two isolation aids and a conclusion.

BLE_MESH_TX_ONLY skips the scan. Useful in its own right for a broadcast-only
node, and it is what localised the boot loop: with it set the ESP32 build is
stable, without it the node resets every ~3s with "BLE mesh started" as the last
line. So the fault is in ble_gap_ext_disc(), not in the pump or the readiness
poll.

clearCorruptBondStoreOnce() is reinstated from thebentern's branch, which I
dropped during the merge as unrelated. It is not - a stored bond blob that
crashes NimBLE during populate_db_from_nvs produces exactly the symptom seen
here. It did not fix it, but it belongs with this work either way.

The conclusion, evidenced rather than guessed: CONFIG_BT_NIMBLE_EXT_ADV=y is
incompatible with the PhoneAPI's advertising as this tree does it.

 - ble_gap_adv_stop returns rc=8, BLE_HS_ENOTSUP: under ext-adv the legacy
   advertising API the Arduino BLE wrapper calls is simply not there.
 - On one boot the diagnostic caught nimbleBluetooth->isActive() true while a
   BLE scan from the host saw no connectable advertisement at all - setup()
   completing, advertising silently not.
 - On most boots isActive() is false and no advertisement of any kind appears,
   so setup() is not completing.
 - The RAK4631 sitting beside it advertises normally throughout, so this is the
   ESP32 stack, not the environment or the scanner.

Porting the PhoneAPI advertisement onto ext-adv instance 0 is therefore a
prerequisite for ESP32 BLE mesh, not a later tidy-up - which is what
thebentern's "#if defined(NIMBLE_TWO) || CONFIG_BT_NIMBLE_EXT_ADV" guard change
was doing for the NimBLE version his branch targeted, and which I wrongly
dismissed as targeting a stack this tree no longer uses.

nRF52 is unaffected: it reaches extended advertising through the SoftDevice
directly and never touches the legacy API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:49:23 -05:00
James RichandClaude Opus 5 93cdcad712 Poll for BLE readiness instead of waiting to be called; ESP32 scan still faults
Two findings from hardware, one fixed and one not.

FIXED - the readiness handshake was a race. setBluetoothEnable() brings the BLE
stack up, and on ESP32 that happens BEFORE main() constructs the mesh handler.
The hook at the end of NimbleBluetooth::setup() therefore ran against a null
bleMeshHandler about half the time, and when it lost the coin flip the handler
sat in "waiting for Bluetooth ready" forever while the stack was already up.
That is exactly what a working run and a dead run looked like on the same
binary, which is why this took a diagnostic build to see:

  BTDIAG setBluetoothEnable(1) memReleased=0 cfgEnabled=1 nimble=0x3fcd4490 active=1

nimble non-null and already active on the first call this handler ever saw.

runOnce() now polls platformReady() and calls onBluetoothReady() itself, once,
whenever readiness actually arrives - no ordering assumption in either
direction. ESP32 reports readiness as "nimbleBluetooth is active" rather than
ble_hs_synced(), because the host syncs long before setup() has registered its
service; nRF52 exports a flag from NRF52Bluetooth::setup() for the same reason.
The push hooks are gone.

NOT FIXED - with readiness now arriving reliably, the ESP32 build boot-loops:
uptime cycles 1, 2, 1, 2 every ~3s, with "BLE mesh started" as the last line
before each reset. So the fault is in what readiness unblocks - most likely
ble_gap_ext_disc() starting an extended scan while the PhoneAPI's connectable
advertisement is up. That fits the earlier ble_gap_adv_stop rc=8 (ENOTSUP)
evidence: under CONFIG_BT_NIMBLE_EXT_ADV the legacy advertising the PhoneAPI
uses is not merely noisy, it does not belong in the same stack as the extended
path. Porting the PhoneAPI advertisement onto ext-adv instance 0 is now looking
like a prerequisite rather than a tidy-up.

Not isolated further: platformReady() and startScanning() have not been
separated, so the crash could be either. DO NOT flash this to a node you care
about. nRF52 is unaffected by the ESP32 fault and the RAK4631 remains healthy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:33:47 -05:00
James RichandClaude Opus 5 ef5fc118ad nRF52: re-base the linker RAM origin so BLE mesh can scan
Scanning needs a central link. Bluefruit.begin(1, 1) asks the SoftDevice for
one, which raises its RAM requirement past the 0x20004000 origin in
nrf52840_s140_v6.ld - that value was measured for the default 1-peripheral,
0-central config. sd_ble_enable() then rejects the RAM base and the node comes
up with no Bluetooth at all.

Adds nrf52840_s140_v6_blemesh.ld at ORIGIN 0x20006000, the origin this tree
shipped before the 0x20004000 measurement and therefore known to satisfy a
strictly more generous SoftDevice config than 1-peripheral-plus-1-central. It
costs 8 KB of RAM, so it is a separate script selected by a separate env rather
than a change to the shared one - nRF52840 boards that do not run BLE mesh keep
their RAM.

env:rak4631_blemesh selects it and sets BLE_MESH_NRF52_CENTRAL.

Verified on a RAK4631: the ELF's RW segment is based at 0x20006000, and the
board comes up with Bluetooth working and its serial API responsive. The same
build without the re-base left it silent and BLE-less, so the SoftDevice is
accepting the new base with the central link enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:17:28 -05:00
James RichandClaude Opus 5 3c5fa6d29a Gate the nRF52 central role behind its own flag
Bluefruit.begin(1, 1) asks the SoftDevice for a central link, which scanning
needs, but it also raises the SoftDevice's RAM requirement above what the
linker ORIGIN in nrf52840_s140_v*.ld reserves. sd_ble_enable() then rejects the
RAM base and the node comes up with no Bluetooth at all - observed on a
RAK4631, which went silent on the serial API and stopped logging entirely until
reflashed without it.

Gated behind BLE_MESH_NRF52_CENTRAL so the default rak4631 build stays healthy.
Turning it on requires re-basing the linker script first; the existing comment
on the failure path documents how (rebuild with -DCFG_DEBUG=1, read "SoftDevice's
RAM requires: 0x...", raise ORIGIN). Until then nRF52 can transmit but not
receive, so ESP32 is the only platform that can currently participate both ways.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:57:38 -05:00
James RichandClaude Opus 5 dd9a01c90a Merge thebentern's BLE mesh platform layer, fix six defects
Replaces the ESP32-only spike handler with Ben Meadors' architecture from
meshtastic/firmware ble-mesh-working (d84355d15a): an abstract BLEMeshHandler
with ESP32 (NimBLE) and nRF52 (SoftDevice) implementations, peer tracking, and
a legacy-advertising fallback. His platform split covers a platform the spike
did not, and the two designs had independently converged on the same Router
hook, the same HAS_BLE_MESH gate and the same phone-on-instance-0 split.

Six defects fixed on the way in:

1. TRANSPORT_BLE_MESH was an #ifndef placeholder for value 8, pending a proto
   change that never landed. TRANSPORT_UNICAST_UDP = 8 has shipped since, so
   the placeholder now aliases UDP. Uses the real TRANSPORT_BLE_ADV = 9.

2. The echo guard refused any packet already marked as BLE-sourced, which is
   what a rebroadcast looks like: perhapsRebroadcast allocCopy()s the received
   packet and nothing on the TX path rewrites transport_mechanism. That capped
   the mesh at one hop - two nodes could talk, three could not chain.

3. onSend() blocked. It delayed BLE_MESH_ADV_BURST_MS (300ms) per burst, plus
   50-200ms of jitter between up to three priority retries, all inline in
   Router::send() on the main task. Advertising is now clocked by an OSThread;
   onSend only encodes and queues.

4. ble_gap_ext_adv_start(instance, duration, max_events) was called with the
   repeat count in the duration slot. Duration is in 10ms units, so a burst of
   "3 events" advertised for 30ms and then idled for the 300ms delay.

5. advBuf[251] could be overrun by four bytes: the size check bounded the
   manufacturer data at 250 but the buffer also carries a 3-byte flags AD and a
   2-byte manufacturer-data header. Sizing is now derived from one constant.

6. nRF52 passed handle 0 to sd_ble_gap_adv_set_configure, which is Bluefruit's
   own set - hence the phone-advertising teardown and restore around every
   frame. It now allocates a dedicated set with BLE_GAP_ADV_SET_HANDLE_NOT_SET
   and only falls back to sharing when the SoftDevice has no spare, which is
   the nRF52 analogue of the instance-1 split already used on ESP32.

Also: UdpMulticastHandler's ingress guards (spoofed origin, hop clamp, PKI
metadata reset) which his branch did not carry; a BLE_BROADCAST ProtocolFlags
gate so the transport is opt-in like UDP; ext-adv enabled through
custom_sdkconfig rather than a -D, which is what the pioarduino migration
requires; and Bluefruit.begin(1, 1) under HAS_BLE_MESH, because scanning needs
a central link and the default of zero makes sd_ble_gap_scan_start fail.

Wire format follows his choice of an encoded MeshPacket rather than the LoRa
frame the spike used: it matches UdpMulticastHandler, and a Kotlin client node
speaking UDP can then share one codec across both transports.

Builds green on heltec-v3 (ESP32-S3) and rak4631 (nRF52840). Not yet run on
hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:36:51 -05:00
James RichandClaude Opus 5 4ccbe2eed8 Cap the advertising payload at 251 bytes, not 254
BLE_HCI_MAX_EXT_ADV_DATA_LEN is 251: the HCI LE Set Extended Advertising Data
command spends four of its 255 parameter bytes on handle, operation, fragment
preference and length, so an unfragmented payload cannot reach the 254 an
AUX_ADV_IND could otherwise hold. The budget was three bytes optimistic, which
would have let encodeAdvPayload accept frames the controller then rejects at
runtime instead of dropping them at the explicit LOG_WARN.

A static_assert now pins the constant to NimBLE's own so it cannot drift again.
Usable ciphertext is 251 - 5 (AD wrapper) - 16 (PacketHeader) = 230 bytes,
against a DATA_PAYLOAD_LEN of 237.

Also corrects two comments that overclaimed. Advertising does not currently buy
dupe suppression: perhapsCancelDupe is gated on TRANSPORT_LORA and
Router::cancelSending reaches only iface's TX queue, never this handler's ring.
It keeps suppression possible later and costs one TX instead of N regardless,
which is the actual argument for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:13:18 -05:00
James RichandClaude Opus 5 39999f5818 Let a BLE-relayed packet be re-advertised
Copying UdpMulticastHandler's egress guard verbatim capped the BLE mesh at a
single hop. A packet that arrived over BLE and comes back through Router::send
is a rebroadcast, not a reflection: NextHopRouter::perhapsRebroadcast takes an
allocCopy of the received packet, and nothing on the TX path rewrites
transport_mechanism - RadioInterface stamps TRANSPORT_LORA in
deliverToReceiver, which is only reached on receive. So the copy still said
TRANSPORT_BLE_ADV and the guard refused every relay, leaving two nodes able to
talk and a three-node chain unable to.

Log it and proceed instead. Loop protection is the same as LoRa's and needs no
help here: PacketHistory drops a packet seen recently, hop_limit decrements per
relay, and onScanReport already ignores frames whose sender is us.

Note this leaves BLE without dupe suppression: perhapsCancelDupe is gated on
TRANSPORT_LORA, and Router::cancelSending reaches only iface's TX queue, which
cannot see BleMeshHandler's ring. Redundant relays are paid for until both are
extended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:55:06 -05:00
James RichandClaude Opus 5 1f7d371f55 Carry mesh frames over connectionless BLE extended advertisements
Adds BleMeshHandler, a second broadcast transport alongside LoRa, wired the
way UdpMulticastHandler already is: ingress via Router::enqueueReceivedMessage,
egress as a copy taken in Router::send gated on config.network.enabled_protocols.
It is never the only path to the mesh - Router::send still asserts a LoRa iface,
so this is an additional copy path, not a replacement.

Connectionless advertising rather than GATT, on purpose. FloodingRouter cancels
a pending rebroadcast when it overhears another node relaying the same packet,
and that cancellation only works on a medium where every neighbour hears every
transmission. GATT is point-to-point: N peers means N writes, nobody overhears
anybody, nothing cancels. Advertisements restore the overhear property the
flood algorithm depends on.

Frames go on air in LoRa wire format (PacketHeader + ciphertext) rather than as
an encoded MeshPacket the way UDP does it. UDP has a 1500-byte MTU and can
afford the proto; a 254-byte advertisement cannot. It also makes a BLE-heard
frame byte-identical to a LoRa-heard one, so a future BLE<->LoRa bridge is a
memcpy rather than a translation. encodeAdvPayload mirrors
RadioInterface::beginSending; the scan path mirrors RadioLibInterface's RX
block, including the hop_start == 0 rule that invalidates next_hop/relay_node.

Ingress keeps all four of UdpMulticastHandler's guards: spoofed local origin,
out-of-range hop_limit/hop_start, the pki_encrypted/public_key reset, and the
RSSI/SNR reset - the last differing in that BLE RSSI is a real measurement of
this hop, so has_rx_rssi is set rather than cleared.

Capped at one 254-byte PDU. Chaining is possible but
ble_gap_ext_disc_desc.length_data is a uint8_t, so chained advertisements
arrive as several INCOMPLETE reports needing per-advertiser reassembly. The
cost is the top of the payload range: 254 - 5 (AD wrapper) - 16 (PacketHeader)
leaves 233 bytes against a DATA_PAYLOAD_LEN of 237. Those packets still go out
over LoRa.

The scanner's filter_duplicates must stay 0: the controller de-duplicates on
advertiser address rather than payload, so enabling it would deliver one report
per neighbour and then filter away every subsequent mesh frame from that node.

Spike scope and known gaps:
- Company ID 0xFFFF is the SIG-reserved internal/test identifier. Shipping needs
  a member company ID or an assigned 16-bit service UUID.
- The generated nanopb headers are hand-edited to add TRANSPORT_BLE_ADV and
  BLE_BROADCAST; nanopb 0.4.9 was not available here. The matching .proto change
  is on protobufs' spike/ble-mesh-transport branch, so a regen restores rather
  than reverts it - but the submodule pointer is not bumped yet.
- CONFIG_BT_NIMBLE_EXT_ADV is enabled for every esp32c3 board, not a dedicated
  env. It links cleanly alongside the existing PhoneAPI GATT advertising, but
  runtime coexistence of legacy and extended advertising is unverified without
  hardware.
- Untested on a device. Built only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:45:39 -05:00
Thomas Göttgens 14eaa5587d Honor mute when waking the screen for a received message (#11688)
* fix(ui): honor mute when waking the screen for a received message

TextMessageModule fired powerFSM.trigger(EVENT_RECEIVED_MSG) for every text
packet, gated only by shouldWakeOnReceivedMessage(), which checks external
notification, device role and battery level but never the mute flags. A muted
channel therefore suppressed the banner and still lit the screen.

MessageRenderer::handleNewMessage() only computed mute for MessageType::BROADCAST,
so a DM from a muted node produced a banner and a wake.

Add isMutedForPacket() in Channels: a DM addressed to us reads the sender's
NodeInfoLite mute bit, every other packet reads the mute bit of the channel it
arrived on. This is the predicate ExternalNotificationModule already applied to
the buzzer, vibra and LED outputs, hoisted so all three call sites share it.

Bell and alert messages still break through mute on both paths, unchanged.

No protobuf or config change: ChannelSettings.module_settings.is_muted and the
NodeInfoLite mute bit already exist and are already settable from the device menu
and via AdminMessage.toggle_muted_node.

Closes #11674

* fix(ui): let an alert break through mute on the screen wake path

In COLOR display mode TextMessageModule skips handleNewMessage(), so
powerFSM.trigger(EVENT_RECEIVED_MSG) is the only wake an alert gets. Gating it
on mute alone dropped that wake for a bell on a muted channel.

Add MeshService::isAlertPayload(): an ASCII BEL in the payload while at least one
alert_bell_* output is enabled. The wake gate is now "not muted, or an alert".
MessageRenderer uses the same predicate instead of its own inline bell scan,
which also lifts that scan's arbitrary 100 byte cap.

Rename three test cases. Their names carried exactly 35 characters after the
test_ prefix, which matches the Lob API key format and tripped trufflehog in the
trunk check gate.
2026-09-01 11:52:40 +00:00
Thomas Göttgens 427ed0f1a0 Load optional modules dropped into src/modules/optional/ (#11673)
* Load optional modules dropped into src/modules/optional/

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

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

The directory name becomes a setup<Name>() call, so foo-bar/ would have generated setupfoo-bar() and failed to compile with the error pointing at generated code rather than at the directory. Names that cannot form an identifier are now skipped with a message that names the directory.
2026-09-01 11:13:30 +00:00
Thomas Göttgens b823c8d7fe Let a module hold the screen (#11631)
* Let a module hold the screen

Screen::setModalModule() marks one module as owning the screen, honoured at the three places that would otherwise take it away: the carousel advance in runOnce(), the new-message banner in handleNewMessage(), and Cmd::STOP_ALERT_FRAME, which any caller can currently fire to cancel any alert frame regardless of who started it. Only the owning pointer can release it, so a module with a modal state no longer has to patch Screen.cpp to keep an alert from vanishing when a chat message arrives. The default is nullptr and no in-tree caller sets it, so every existing build behaves exactly as before.

* Address review: clear pauseBanner even while a module holds the screen

START_ALERT_FRAME sets NotificationRenderer::pauseBanner and STOP_ALERT_FRAME is
the only thing that clears it, so swallowing the whole command left banners
suppressed for good once a module took the screen. Only the setFrames() teardown
is now gated on the modal owner.

* Take the modal owner as a pointer to const

Screen never dereferences it; the pointer is only stored and compared, so const
is what the parameter and the member both mean. Fixes the cppcheck
constParameterPointer defect on clearModalModule().

* Add isShowingModuleFrame() so a module can claim keys on its own frame

Input observers registered by modules run before Screen's, so a module that handles UP/DOWN has to know whether its own frame is the one being looked at, or it takes the key away from the frame that is. moduleFrames is already index-aligned with the frame list for drawModuleFrame(), so the check is a lookup against the current frame.

* Address review: match drawModuleFrame's frame selection, trim the comment

Mid-transition drawModuleFrame() renders transitionFrameTarget, so comparing only currentFrame reported false while the module's frame was actually on screen and its input observer would have ignored keys. The header comment is back inside the two-line limit.
2026-09-01 08:50:39 +00:00
HarukiToreda 3683566f62 Don't show new message banner on message screen (#11671)
* message banner

* Update MessageRenderer.cpp

* Fix message banner suppression race on Portduino
2026-09-01 00:45:42 +00:00
Ben Meadors 47db0e3020 fix(admin): don't disable BLE on config paths that never reboot (#11651)
Three places took BLE down and left nothing to bring it back. The nRF52
auto-re-advertise bug masked them by restoring advertising ~1s later; with
that fixed (#11650) the outage is real, lasting until the next PowerFSM
transition - up to screen_on_secs, 10 minutes on a default client.

- restore_preferences passed 1000 to reboot(), which takes seconds, arming
  the reset ~16.7 minutes out instead of the intended ~1s. With BLE disabled
  for a pending reboot the node was unreachable for that whole window. Use
  DEFAULT_REBOOT_SECONDS and disable before arming, matching the factory and
  nodedb reset paths.
- mesh_beacon sets shouldReboot=false but was not in the list that spares a
  variant from the blanket disable, unlike statusmessage. Add it.
- MQTT and Serial disable BLE inside their own case, bypassing the
  transaction check above them. Inside an edit transaction saveChanges()
  defers the reboot, so BLE went down with no restore - reachable today by
  importing a device profile containing either module config.

Build: heltec-mesh-node-t096. Tests: test_module_config 3/3.
v2.8.0.47db0e3
2026-08-31 19:38:35 +00:00
Ben Meadors b8faaaf54b fix(fs): size the files manifest with a malloc probe, not a heap walk (#11667)
heap_caps_get_largest_free_block() walks every TLSF block of every
matching heap while holding the allocator lock. On ESP32-S3 boards with
PSRAM in the malloc pool, that walk runs long enough during the config
handshake that WiFi RX on the other core blocks in wifi_malloc() and the
interrupt watchdog reboots the node.

Use the bounded malloc() probe (already the non-ESP32 path) on every
target instead: TLSF malloc is O(1), so the allocator lock is only held
momentarily. Touch the probe through a volatile pointer so LTO cannot
elide the malloc()/free() pair.

Fixes #11666
2026-08-31 18:56:05 +00:00
Ben Meadors 7dffd66c59 fix(radio): recover a chip that lost its state instead of assert-crashing in reconfigure() (#11676)
* fix(lr11x0): recover a chip that lost its state instead of assert-crashing in reconfigure()

* fix(radio): extend chip-state-loss recovery to SX126x, SX128x, RF95, and LR20x0
2026-08-31 16:47:04 +00:00
Manuel 52d521426b Wio Tracker L2: try-fix battery percentage (#11668)
* try-fix battery percentage

* initialize cached_mv

* use AnalogBatteryLevel class to calculate percentage level
2026-08-30 22:34:12 +00:00
IxitxachitlandBen Meadors 7239fe886a fix(BaseUI): let a module frame with no menu fall through the SELECT dispatch (#11659)
#11209 added a module-frame branch to the SELECT chain that claims the press
for any non-null moduleFrames entry, but its body acts only on the
environmental telemetry frame. Every other module frame lands there and the
press dies: the branches below it - waypoint among them - are unreachable.

#11358 already patched one casualty by excluding the nullptr padding, which
restored the node list. Real module frames stayed swallowed, so the waypoint
menu #10920 appended to the end of the chain has never opened on BaseUI.

Enter the branch only when a module frame actually has a menu, so anything
without one falls through to the frames matched after it.

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
v2.8.0.7239fe8
2026-08-29 11:31:55 -05:00
Copilotandthebentern 9625c6bebf nrf52840: prevent duplicate I2C switch case for LP5562/MMC5983MA (#11658)
* Initial plan

* fix: avoid duplicate I2C switch case for LP5562/MMC5983MA

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-08-29 08:47:28 -05:00
renovate[bot] 2afe097be6 chore(deps): update meshtastic/device-ui digest to 9d9b9df (#11653)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 09:50:11 +00:00
Manuelandcoderabbitai[bot] 36c89fa3a7 feat: Support Seeed Wio Tracker L2 (#10909)
* initial commit

* enable power save

* implement mesh LED

* add ADS1115+AW35615 for wio tracker L2

* add ES8311, GT911, AW35615, LP5814 to I2C scanner

* update commit references

* move variant.cpp to extras

* update hw_model

* update lovyanGFX

* point to device-ui commit

* trunk fmt

* fix IO expander (have to take from SensorLib for now as long as AudioThread has the limitation to only support SensorLib and the previous IO expander clashes with duplicate names in arduino-audio-driver)

* workaround duplicate defined symbol

* remove SensorLib; add lightweight Pca9555 class and use unified USE_PCA95X5; add wake button detection

* keep TP_INT disabled(OUTPUT) as we use wake button for wakeup

* PA off by default, enabled when playing sound; add some delay because typical class-D amps (NS4150 family) spec 20–50ms for the output stage to reach full swing after power-on

* refactored AW35615 into new external library

* local revert of PR10571 as this PR completely breaks the alert sound

* fix detection of ADS1115

* update device-ui commit reference

* add synchronisation to IO expander and call toggleDisplay() on wake button press

* add battery curve, fix io expander sync

* add SPILock, simplify macro usage

* update device-ui

* fix wakeup from sleep

* revert because of #11604

* use new AUDIO_AMP_SETTLE_MS

* remove test logs

* enable BaseUI

* use touch screen

* refactor wakekey thread

* fix wake button toggle screen on/off

* fix battery percentage and plugIn state

* Update src/graphics/TFTDisplay.cpp

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

* consider to return I2C errors to make coderabbi happy

* fix warnings

* use Throttle for millis comparison

* fix endTransmission in write

* make the rabbit happy

* spli targets -tft / non-tft

* fix compile

* revert forced use of Throttle

* remove MeshLED

* add HW_MODEL

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-28 23:30:27 +00:00
Ben Meadors f8a8d12477 fix(ble): stop BLE from coming back up during the pre-reboot window (#11650)
* fix(ble): stop BLE from coming back up during the pre-reboot window

Saving a reboot-requiring config over BLE (e.g. screen timeout) made the
node disconnect, re-advertise, let the phone reconnect, and then drop it
again at the reset. Two causes:

nRF52: admin messages from the phone run synchronously on Bluefruit's BLE
event task, so the BLE_GAP_EVT_DISCONNECTED caused by shutdown() is only
processed after we return - and that handler restarts advertising because
restartOnDisconnect(true) was never cleared. Stopping advertising first is
a no-op while a connection is live (the SoftDevice isn't advertising), so
the deferred event brought it straight back. Clear the restart flag and
stop advertising before dropping the link, mirroring nRF54L15's ble_enabled
gate. This also closes a main-thread race on the shutdown path where
Advertising.stop() could land between connection teardown and Bluefruit's
auto-restart within the same event dispatch.

PowerFSM (all platforms): darkEnter/onEnter/powerEnter/powerExit/serialExit
unconditionally re-enable BLE, so any state transition inside the reboot
window - a button press while the banner is up, the screen timeout, USB
plug/unplug - turned BLE back on after AdminModule had deliberately torn it
down. Route them through a helper that skips the re-enable while
rebootAtMsec/shutdownAtMsec is armed; every writer of those deadlines is an
imminent restart.

* style: trim rationale comments to house 1-2 line limit

The full mechanism is in the original commit message and PR description.
2026-08-28 22:32:01 +00:00
Andrew YongandBen Meadors db84bdf3b4 Reduce ExternalNotificationModule flash usage (RTTTL + InputBroker) (#10989)
* Generalize RTTTL exclusion into MESHTASTIC_EXCLUDE_RTTTL

ExternalNotificationModule already stubbed out RTTTL playback for
STM32WL/portduino/ESP32C6 via a raw ARCH/CONFIG_IDF check, but the
ringtone config plumbing around it (protobuf message, encode/decode
tables, /prefs/ringtone.proto persistence, admin get/set-ringtone
handlers) still compiled in even though it can never do anything on
those platforms.

Introduce MESHTASTIC_EXCLUDE_RTTTL and gate the dead ringtone plumbing
behind it too. The flag is set in each architecture's *_base build_flags
(stm32_base, esp32c6_base, portduino_base) rather than in the module
itself - this matches how every other MESHTASTIC_EXCLUDE_* flag in the
tree is set (e.g. stm32_base already sets ten of them directly, and
esp32c6_base already excludes PAXCOUNTER for an analogous
platform-can't-support-this reason), rather than introducing a new
per-architecture C header pattern. Behavior is unchanged on all three
platforms; overridable via -D like every other MESHTASTIC_EXCLUDE_*
flag.

Also guard the two HAS_I2S ringtone-playback call sites with
!MESHTASTIC_EXCLUDE_RTTTL alongside HAS_I2S, since rtttlConfig itself
is now only declared when RTTTL is not excluded. No current platform
defines both HAS_I2S and MESHTASTIC_EXCLUDE_RTTTL simultaneously, so
this has no effect today, but prevents a future HAS_I2S platform that
also excludes RTTTL from failing to compile.

Saves 368 bytes flash / 236 bytes RAM on wio-e5 with no loss to the
GPIO on/off notification toggle itself, which does not depend on
RTTTL.

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

* Skip unused InputBroker observer in ExternalNotificationModule

The inputObserver CallbackObserver member was declared unconditionally,
even though its only use site was already gated behind
MESHTASTIC_EXCLUDE_INPUTBROKER (set for all of stm32 in stm32.ini).
Because it's a non-trivial member, the compiler still generated its
constructor/destructor as part of ExternalNotificationModule's own
lifecycle even when InputBroker is compiled out entirely.

Gate the member and its only consumer, handleInputEvent(), behind the
same flag as their use site, and match the codebase's dominant
!MESHTASTIC_EXCLUDE_X style (used ~330 times) rather than
!defined(MESHTASTIC_EXCLUDE_X) (used ~20 times) while touching this
flag's other call site. Saves an additional 288 bytes flash on wio-e5,
no RAM change, no functional impact since InputBroker was already
unused on this platform.

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

* fix(native-wasm): exclude RTTTL to unbreak build

The browser node builds its own build_flags from arduino_base rather
than inheriting portduino_base, so it did not pick up the
MESHTASTIC_EXCLUDE_RTTTL flag added to portduino_base. With the inline
ARCH_PORTDUINO stub in ExternalNotificationModule.h now replaced by that
flag, native-wasm tried to include the unavailable NonBlockingRtttl.h.

Set MESHTASTIC_EXCLUDE_RTTTL=1 directly in the native-wasm env
alongside its other exclusion flags.

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

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-28 19:58:23 +00:00
Ben Meadors 7afd270f39 Gut beacon send-as-node and consolidate TX onto broadcast_targets (#11646)
* Gut beacon send-as-node and consolidate TX onto broadcast_targets

Two MeshBeaconConfig changes, both against fields that never reached a tagged
release, so there is no migration for existing nodes.

broadcast_send_as_node let a client name a node ID to send beacons AS, rewriting
the packet's `from`. Firmware never applied it - the assignment was commented
out, so `from` was always the local node and the field was a settable, persisted
no-op. It was also unsound as designed: rewriting `from` forges no signature, it
only makes isFromUs() false, so perhapsEncode() skips XEdDSA signing and
receivers get an unsigned packet attributed to another node.

broadcast_on_channel / broadcast_on_region / broadcast_on_preset were a second
way to name a beacon destination alongside broadcast_targets, chosen silently on
whether broadcast_targets was empty. The comments claimed the two were
equivalent; they were not. An inline ChannelSettings carries name and PSK, so
broadcast_on_channel could transmit on a channel absent from the node's channel
table, which channel_index cannot express. That is dropped deliberately - the
channel must exist on the node.

Empty broadcast_targets now synthesises one target on the running preset and
region over the primary channel, matching what the scalar path produced when
left unset, so an otherwise unconfigured node still beacons.

The USERPREFS_MESH_BEACON_ON_* keys go with the fields. A preconfigured build
that still defines one now fails at compile time with a pointer to the
USERPREFS_MESH_BEACON_TARGET_0_* equivalents, rather than silently losing its
beacon channel. The replacement names a channel-table slot, so such a build must
also provision that channel.

MeshBeaconConfig shrinks 324 -> 240 bytes and ModuleConfig 328 -> 244, against
the 512-byte MAX_TO_FROM_RADIO_SIZE ceiling that FromRadio sits 2 bytes under.

The protobufs submodule points at a branch carrying both proto changes; it needs
re-pointing to master once meshtastic/protobufs#1047 and #1048 merge.

* Point protobufs submodule at master now that the beacon protos are merged

meshtastic/protobufs#1047 and #1048 are in master, so drop the temporary
beacon-proto-integration pin. MeshBeaconConfig stays 240 bytes and ModuleConfig
244, unchanged from the integration branch.

The bump also picks up master's unrelated additions: the MESHNOLOGY_W12 and
MESHPAGER_X2 hardware models, and a ground-speed unit correction in Position.
2026-08-28 19:51:43 +00:00
Andrew Yong 78219e09cb fix(stm32wl): add TCXO-optional support and fix hardcoded TCXO voltage (#10964)
* stm32wl: consult SX126X_DIO3_TCXO_VOLTAGE instead of hardcoding 1.7V

Every STM32WL variant except rak3172 got setTCXOVoltage(1.7) unconditionally,
regardless of what the board's hardware actually needs, and rak3172 got no
TCXO configuration at all - so a real RAK3172-T (populated TCXO) failed
radio init outright.

Read SX126X_DIO3_TCXO_VOLTAGE per variant instead. When TCXO_OPTIONAL is
also defined, retry once on XTAL if the TCXO attempt fails, mirroring the
existing pattern in LR11x0Interface.cpp, LR20x0Interface.cpp, and the
SX1262/SX1268 paths in RadioInterface.cpp.

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

* stm32wl(rak3172): support both non-T and -T hardware via TCXO-optional

RAK3172 is XTAL-only; RAK3172-T has a populated 3.0V TCXO, matching RAK's
own reference radio_conf.h. One PlatformIO environment now serves both:
tries the TCXO first, falls back to XTAL if not populated.

Hardware-verified on a TCXO-equipped board electrically equivalent to
RAK3172-T. Genuine non-T hardware not available to re-verify the fallback
path; reasoned from RadioLib source instead (see PR description).

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

* stm32wl(wio-e5): declare the module's 1.7V TCXO explicitly

Matches Seeed's own reference radio driver. wio-e5 previously relied on
the hardcoded 1.7V fallback being removed by the preceding commit, which
would have broken it - declare the voltage explicitly instead.

Hardware-verified via SWD: without this define, the radio interface fails
to come up at all (sendtext NAKs with NO_INTERFACE, meaning rIf is null).
With it, NO_INTERFACE goes away and the device sends/receives normally.

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

* stm32wl(CDEBYTE_E77-MBL): mark TCXO voltage optional, hardware varies by unit

EByte changed the E77-MBL hardware in early 2024: units with serial number
>= 3202995 have a TCXO, older units have a ceramic crystal oscillator
instead. Both ship under the same module name, so probe for the TCXO and
fall back to XTAL rather than assuming either.

https://github.com/olliw42/mLRS-docu/blob/main/docs/EBYTE_E77_MBL.md

Not hardware-tested - no E77-MBL board available this session.

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

* stm32wl: trim TCXO comment blocks to repo's 1-2 line guideline

Per review feedback on PR #10964 (CodeRabbit nitpicks) - the rak3172 and
CDEBYTE_E77-MBL variant.h comments were 4-line blocks, exceeding the
repo's comment-length convention. Condensed to one line each, same
information and links retained.

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

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
2026-08-28 18:21:58 +00:00
Andrew YongandTom 57d17cfd44 fix(stm32wl): recover from littlefs internal corruption instead of hanging (#11230)
LFS_ASSERT (src/platform/stm32wl/littlefs/lfs_util.h) was a plain assert(),
which on STM32WL hangs forever with no diagnostic (__wrap___assert_func is
while(true);, see main-stm32wl.cpp). STM32_LittleFS::begin() is already
designed to treat corruption as recoverable - format and retry, see
fsFormat()/NodeDB::saveToDisk() - but that only works if lfs_mount() cleanly
returns an error. An internal littlefs consistency check failing (metadata
pair/CRC/block-allocator invariants) never returns at all, so a bad flash
sector or power loss mid-write could permanently brick a device that would
otherwise have recovered via the existing reformat path.

nRF52 already hit this and fixed it (LFS_NO_ASSERT + a custom lfs_assert()
that reboots into a reformat, see meshtastic/firmware#3818). Port the same
approach to STM32WL: LFS_NO_ASSERT routes LFS_ASSERT through a custom
lfs_assert() instead of disabling the check outright, and lfs_assert()
requests a reformat-on-next-boot via a .noinit SRAM magic value (the same
mechanism already used for the DFU bootloader redirect in this file, chosen
specifically because backup/TAMP registers don't reliably survive a soft
reset in this toolchain) and reboots, rather than trying to reformat
littlefs from inside its own possibly-mid-operation callback.

Unlike nRF52 (a third-party Adafruit library patched via a -include
override so as not to fork it), STM32WL's littlefs copy is already a
project-owned vendored file, so lfs_util.h is edited directly.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com>
2026-08-28 17:37:32 +00:00
Jason P 467dc44cfa Update applyLoraRegion to enable TX on set (#11643)
* Update applyLoraRegion to enable TX on set

* Don't enable TX if in HamMode. User must set callsign first

* Don't use isHam, use owner.is_licensed
2026-08-28 17:20:02 +00:00
Ben Meadors 7e9525ad83 feat(baseui): default US to LongTurbo on first region selection (#11637)
Selecting US in the BaseUI region chooser now installs LongTurbo instead of
LongFast, but only for out-of-box setup: the outgoing region must be UNSET, so
a later switch to US leaves whatever preset the node is running alone.

Scoped to the menu on purpose. The US entry in regions[] keeps LongFast as its
default preset, so preset repair, admin/phone writes and every other route onto
US are unchanged. A build pinning USERPREFS_LORACONFIG_MODEM_PRESET, a preset
already moved off the install default, or use_preset=false all outrank it.

The decision is lifted into menuHandler::presetForRegionSelection() so it is
reachable without a Screen, following toggleNodeMuted().
2026-08-28 11:55:20 +00:00
IxitxachitlandManuel 7aa8ad3510 fix(t-watch-ultra): build with the esp32s3 flags, not the classic-ESP32 ones (#11619)
* fix(t-watch-ultra): build with the esp32s3 flags, not the classic-ESP32 ones

The env was the only esp32s3 variant extending ${esp32_base.build_flags} (since
#8171). That base adds -D ESP32_FORCE_IRAM_MEMSET -Wl,--wrap=memset
-Wl,--wrap=memcpy, and the wrappers in IramMemcpy.c/IramMemset.c decide whether
the cache is on by reading 0x3FF00040 - DPORT_PRO_CACHE_CTRL_REG on the classic
ESP32, an address the S3 does not map at all (soc.h: DRAM 0x3FC88000-0x3FD00000,
DROM 0x3C000000-0x3E000000, IRAM 0x40370000-0x403E0000, peripherals 0x60000000).

--wrap is link-wide, so every memcpy/memset in the image - including inside the
precompiled WiFi, lwIP and flash driver libraries - branched on that undefined
read. Two long-standing board-specific bugs came from it, both dating to #8171,
which introduced the wrong base and the first workaround in the same commit:

* WPA2 networks associated and completed the 4-way handshake, then never got a
  DHCP lease, while open networks worked normally (#11513).
* Direct flash reads returned 0x00 for data that was correct on flash, so NVS
  came up empty every boot and dropped BLE bonds (#11530).

Switching the env to esp32s3_base fixes both on hardware: WPA2 gets a lease, and
NVS survives a reboot with the bond intact. The read workaround that #11530
needed - -Wl,--wrap=esp_partition_read, -Wl,--wrap=esp_flash_read and
esp_partition_read_mmap_wrap.c - is therefore removed as well.

The module excludes the env inherited from esp32_base go with it, so the board
now matches every other esp32s3 variant: web server and paxcounter are built
(paxcounter still only runs when enabled in config), and MESHTASTIC_EXCLUDE_AUDIO
was already inert here because AudioModule additionally requires USE_SX1280.
-UMESHTASTIC_EXCLUDE_ACCELEROMETER goes too, having only existed to undo an
inherited -D.

Also guards ESP32_FORCE_IRAM_MEMSET behind CONFIG_IDF_TARGET_ESP32, so a variant
cannot enable the classic-ESP32 probe on another target again.

* Update platformio.ini

added missing ${device-ui_base.custom_sdkconfig}

---------

Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
2026-08-27 18:27:02 +00:00