Skip to content

Add the BLE GATT mesh-peer transport - #12010

Draft
jamesarich wants to merge 13 commits into
feat/ble-phone-api-pairingfrom
feat/ble-gatt-mesh-peer
Draft

jamesarich wants to merge 13 commits into
feat/ble-phone-api-pairingfrom
feat/ble-gatt-mesh-peer

Conversation

@jamesarich

@jamesarich jamesarich commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Draft so MemBrowse and the PR matrix measure what the GATT mesh-peer transport costs an nRF52 and an ESP32-S3. A phone or another node connects as a mesh peer over GATT and exchanges whole mesh packets, beside the phone API link.

What changed

  • Stacks on Tighten BLE pairing: no bond offer in NO_PIN, a proven passkey for the nRF52 phone API #11946, which stops NO_PIN from offering to bond; the ESP32 mesh-peer relies on that and adds only that security starts when a protected attribute is touched, not on connect.
  • BLEGattMeshHandler carries mesh frames over a mesh-peer GATT service, with NRF52BLEGattMesh and ESP32BLEGattMesh as the platform halves.
  • ESP32 is peripheral only. One legacy connectable advertisement serves the phone and mesh peers, with the mesh-peer UUID in the scan response, as on nRF52. A link becomes a mesh peer when it subscribes to the mesh characteristic, and the phone is the link that uses the phone API.
  • Mesh peers may hold every peripheral link but one, so a phone can always connect and the node keeps advertising. A mesh subscribe that would take the last free link is dropped; a phone that is also a mesh peer over its own link does not count.
  • The ESP32 GATT build prefers 1M PHY, so an Apple central cannot trip esp-idf#15311 on S3/C3.
  • GATT ingress clears tx_after and priority, which the LoRa header does not carry, and keeps via_mqtt, which it does.
  • rak4631_blemesh and heltec-v3_blemesh are the opt-in BLE mesh envs, one per board. Add the BLE advertisement mesh transport #12011 adds the advertisement mesh to the same envs, and BLE_GATT_PEER in enabled_protocols turns GATT on at runtime. rak4631_blemesh uses nrf52840_s140_v6_blemesh.ld, because a second peripheral link raises the SoftDevice's RAM requirement past the shared script's origin. heltec-v3_blemesh raises NimBLE to 2 connections and 3 controller activities for that env only.
  • protobufs points at feat/ble-mesh-protocol-flags for the TRANSPORT_BLE_GATT and BLE_GATT_PEER enums, with the headers regenerated.
  • Both envs inherit board_level = pr from their parents, so the PR matrix and MemBrowse build them.

Testing

On a RAK4631 (OTAFIX 2.5) against stock rak4631 at the fork point: static RAM +3.3 KB, flash +6.9 KB, free heap 15.8 KB lower with one GATT peer linked (a Linux node-kmp peer, 25 min, link held throughout).

On a Heltec V3 (heltec-v3_blemesh, default PIN mode, WiFi off) against a Linux node-kmp central: linked with no pairing prompt, a PKI DM was acked with a valid ack proof in 180 ms, and a RAK4631's LoRa text was relayed to the node-kmp peer over GATT. Static RAM +3.2 KB, flash +8.8 KB against stock heltec-v3.

test_ble_gatt_mesh and test_transport_registry pass. Not tested: iOS and Android peers, free heap with two links on ESP32.

Merge order

meshtastic/protobufs#1111, then #11945, #11946, #12010 and #12011, each based on the one before it. #12010 also waits for #1111 so its protobufs pointer can move from feat/ble-mesh-protocol-flags to protobufs master.

@jamesarich jamesarich added the enhancement New feature or request label Sep 29, 2026
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

⚡ Try this PR in the Web Flasher

Note

Building this pull request… the flash button, badges and supported-board
list will appear here automatically once CI finishes.

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

MemBrowse Memory Report

axiometa-genesis-mini

  • dram0_0_seg: .dram0.bss +16 B (+0.0%, 170,304 B / 341,760 B, total: 50% used)
  • drom0_0_seg: .flash.rodata +92 B (+0.0%, 4,459,028 B / 33,554,400 B, total: 13% used)
  • iram0_2_seg: .flash.text +628 B (+0.0%, 1,818,536 B / 8,388,576 B, total: 22% used)

heltec-mesh-node-t114

  • FLASH: .data +8 B, .text +656 B (+0.1%, 704,260 B / 802,816 B, total: 88% used)
  • RAM: .bss +8 B, .data +8 B (+0.0%, 105,028 B / 245,760 B, total: 43% used)

heltec-v3

  • drom0_0_seg: .flash.rodata +24 B (+0.0%, 4,449,096 B / 33,554,400 B, total: 13% used)
  • iram0_2_seg: .flash.text +956 B (+0.1%, 1,790,724 B / 8,388,576 B, total: 21% used)

rak4631

  • FLASH: .data +8 B, .text +720 B (+0.1%, 697,884 B / 802,816 B, total: 87% used)
  • RAM: .bss +8 B, .data +8 B (+0.0%, 96,652 B / 245,760 B, total: 39% used)

seeed-sensecap-indicator-tft

  • dram0_0_seg: .dram0.bss +112 B (+0.1%, 192,332 B / 341,760 B, total: 56% used)
  • drom0_0_seg: .flash.rodata +172 B (+0.0%, 7,456,696 B / 33,554,400 B, total: 22% used)
  • iram0_2_seg: .flash.text +356 B (+0.0%, 2,357,064 B / 8,388,576 B, total: 28% used)

seeed_wio_tracker_L1

  • FLASH: .data +8 B, .text +672 B (+0.1%, 694,252 B / 798,720 B, total: 87% used)
  • RAM: .bss +8 B, .data +8 B (+0.0%, 104,892 B / 245,760 B, total: 43% used)

t-deck-tft

  • drom0_0_seg: .flash.rodata +112 B (+0.0%, 7,733,440 B / 33,554,400 B, total: 23% used)
  • iram0_2_seg: .flash.text +808 B (+0.0%, 2,432,804 B / 8,388,576 B, total: 29% used)

t-echo-plus

  • FLASH: .text +720 B (+0.1%, 704,724 B / 802,816 B, total: 88% used)
  • RAM: .bss +8 B (+0.0%, 87,788 B / 245,760 B, total: 36% used)

thinknode_m7

  • drom0_0_seg: .flash.rodata +120 B (+0.0%, 4,593,976 B / 33,554,400 B, total: 14% used)
  • iram0_2_seg: .flash.text +748 B (+0.0%, 1,662,088 B / 8,388,576 B, total: 20% used)

tlora-c6

  • irom_seg: .flash.rodata +112 B, .flash.text +798 B (+0.0%, 4,154,313 B / 16,777,184 B, total: 25% used)

tracker-t1000-e

  • FLASH: .text +648 B (+0.1%, 487,132 B / 798,720 B, total: 61% used)
  • RAM: .bss +8 B (+0.0%, 86,300 B / 245,760 B, total: 35% used)

xiao_nrf54l15_lr2021

jamesarich added a commit that referenced this pull request Oct 1, 2026
It rebuilds the host and controller for two connections and three
controller activities (the advertisement and both links); every other
ESP32 env keeps one connection and two activities. board_level = pr so CI
and MemBrowse measure it while #12010 is a draft, like rak4631_gattpeer.
@jamesarich
jamesarich force-pushed the feat/ble-gatt-mesh-peer branch from d4fe5ad to 1d53f35 Compare October 1, 2026 21:58
@jamesarich
jamesarich changed the base branch from develop to feat/transport-registry October 1, 2026 21:58
@jamesarich
jamesarich force-pushed the feat/ble-gatt-mesh-peer branch from 1d53f35 to ea8c4ac Compare October 1, 2026 23:14
@jamesarich
jamesarich changed the base branch from feat/transport-registry to feat/ble-phone-api-pairing October 1, 2026 23:14
jamesarich added a commit that referenced this pull request Oct 2, 2026
… mesh-peer

It rebuilds the host and controller for two connections and three
controller activities (the advertisement and both links); every other
ESP32 env keeps one connection and two activities. It inherits
heltec-v3's board_level = pr, so CI and MemBrowse measure it while #12010
is a draft, as they do rak4631_blemesh.
@jamesarich
jamesarich force-pushed the feat/ble-gatt-mesh-peer branch from ea8c4ac to f5a69e7 Compare October 2, 2026 02:10
@jamesarich
jamesarich force-pushed the feat/ble-phone-api-pairing branch from 177f75f to 5b04d3a Compare October 3, 2026 22:36
jamesarich added a commit that referenced this pull request Oct 3, 2026
… mesh-peer

It rebuilds the host and controller for two connections and three
controller activities (the advertisement and both links); every other
ESP32 env keeps one connection and two activities. It inherits
heltec-v3's board_level = pr, so CI and MemBrowse measure it while #12010
is a draft, as they do rak4631_blemesh.
@jamesarich
jamesarich force-pushed the feat/ble-gatt-mesh-peer branch from f5a69e7 to fb5bfb2 Compare October 3, 2026 22:36
@jamesarich
jamesarich force-pushed the feat/ble-phone-api-pairing branch from 5b04d3a to caa6e59 Compare October 3, 2026 23:48
jamesarich added a commit that referenced this pull request Oct 3, 2026
… mesh-peer

It rebuilds the host and controller for two connections and three
controller activities (the advertisement and both links); every other
ESP32 env keeps one connection and two activities. It inherits
heltec-v3's board_level = pr, so CI and MemBrowse measure it while #12010
is a draft, as they do rak4631_blemesh.
@jamesarich
jamesarich force-pushed the feat/ble-gatt-mesh-peer branch 2 times, most recently from a57d844 to 8fcc4ee Compare October 7, 2026 18:04
@jamesarich
jamesarich force-pushed the feat/ble-phone-api-pairing branch from caa6e59 to 67a0942 Compare October 7, 2026 18:04
The GATT mesh-peer transport gates on ProtocolFlags.BLE_GATT_PEER and tags
its arrivals TransportMechanism.TRANSPORT_BLE_GATT, neither of which exists
on protobufs master yet. This pins the submodule at the protobufs branch
that adds them (feat/ble-mesh-protocol-flags), alongside BLE_BROADCAST and
TRANSPORT_BLE_ADV for the advertisement transport that follows.

Nothing under src/mesh/generated/ changes here: the update_protobufs
workflow regenerates it once the protobufs PR merges, and a local
bin/regen-protos.sh run supplies the headers until then.
… a mesh peer

A phone can already reach a node's phone API over BLE, but it cannot be a
node on the mesh through that link: the phone API carries commands and
FromRadio state, not mesh frames. This adds a second GATT service, the
mesh-peer service, on which a client library (node-transport-ble-gatt)
exchanges whole encrypted MeshPackets with the node - the SIG Mesh "GATT
proxy" role. The node stays the GATT server; the phone is a central that
subscribes to the mesh characteristic and writes fragments to it.

BLEGattMeshHandler is the platform-neutral half and holds everything the
client library speaks, so the native suite covers it: the fragment framing
([version][id][index][total][payload], one ATT value per fragment), bounded
reassembly (per-peer in-flight limit, per-table peer limit, expiry, so a
hostile writer cannot fill memory), the ingress guards shared with the UDP
transport (no sender, our own node number, out-of-range hop counts,
local-only flags cleared), a relay exclusion so a packet is never written
back to the peer that delivered it unless that peer originated it (the
echo is its only implicit ack on a point-to-point bearer), the HELLO
link-control frame that names a node on its GATT links so a node reached
both ways can elect one link, and a liveness probe for a link this node
dialled. No platform here dials yet, so the probe and shed hooks keep their
base-class defaults; the dial-out half is a later change. It registers as
a PostEncode transport, gated on ProtocolFlags.BLE_GATT_PEER, and tags its
arrivals TRANSPORT_BLE_GATT.

NRF52BLEGattMesh is the Bluefruit half: the service and characteristic,
CCCD tracking (a link becomes a mesh peer when it subscribes), a write
queue from Bluefruit's callback task to the main-task pump, per-connection
MTU chunking, and re-arming the connectable advertisement while a
peripheral slot is free, which Bluefruit only does by itself once no link
is left. Bluefruit.begin(2, 0): two peripheral links, the phone and one
mesh peer, no central.

NRF52Bluetooth has to stop assuming one link. The phone is whichever link
uses the phone API, not the most recent connect, so notePhoneLink() learns
it from the first phone-API access and only that link's drop closes the
phone session; fromNum and logRadio notify that handle explicitly rather
than Bluefruit's default. The mesh-peer UUID rides in the scan response in
place of the TX power entry, since S140 has one advertising set.

The nanopb enums come from the protobufs branch this submodule now points
at; nothing under src/mesh/generated/ is committed, the regen workflow
supplies it once that PR merges (bin/regen-protos.sh locally until then).

Native tests in test_ble_gatt_mesh, built by the portduino env's new
HAS_BLE_GATT_MESH flag: framing and its bounds, reassembly, every ingress
guard, per-peer fragmentation, the relay exclusion and its originator
exception, the bounded TX ring and busy-stack retry, the greeting, the
election, and the probe timings.
rak4631_blemesh is the BLE-mesh opt-in build for the board: rak4631 with
HAS_BLE_GATT_MESH=1 and nothing else, so no central link and the stock
linker script. network.enabled_protocols selects the bearer at runtime, so
one opt-in env per board carries every bearer the build has. It inherits
rak4631's board_level, so CI and MemBrowse build it with the PR matrix.
Whether two peripheral links fit the SoftDevice RAM origin that script
reserves is a question for the boot log: Bluefruit.begin() logs
"SoftDevice RAM too small" and records a critical error if they do not.
Measured on a RAK4631: with HAS_BLE_GATT_MESH and the stock linker script,
Bluefruit.begin(2, 0) fails with "SoftDevice RAM too small" and the node
boots with no Bluetooth at all. The SoftDevice sizes its RAM by links, not
by role, so the second peripheral link crosses the shared script's
0x20004000 origin the same way the scanning central does. The re-based
script moves here, to the first rung that needs it; the advertisement bearer
uses the same file.
ESP32BLEGattMesh is the NimBLE half of BLEGattMeshHandler, peripheral only.
One legacy connectable advertisement serves the phone and the mesh peers,
with the mesh-peer service UUID in its scan response as on the nRF52, and a
link becomes a mesh peer when it subscribes to the mesh characteristic.
Notifies go to one connection at a time so a relay skips the peer it came
from.

With the GATT peer protocol on, NimbleBluetooth registers every link from
onConnect, re-arms the advertisement while a connection slot is free, and
identifies the phone by phone-API use instead of by pairing, so a mesh peer
dropping no longer ends the phone's session. Security starts on demand
rather than on connect, so a mesh peer is never pushed into pairing; the
phone API keeps its attribute protection. deinit() disconnects every link
before freeing the server.

All of it is compiled only with HAS_BLE_GATT_MESH and inert unless the
BLE_GATT_PEER protocol bit is set.
… mesh-peer

It rebuilds the host and controller for two connections and three
controller activities (the advertisement and both links); every other
ESP32 env keeps one connection and two activities. It inherits
heltec-v3's board_level = pr, so CI and MemBrowse measure it while #12010
is a draft, as they do rak4631_blemesh.
…tral cannot crash it

Same HCI command as feat/ble-adv-mesh, scoped to HAS_BLE_GATT_MESH so stock builds are unchanged.
A radio arrival has via_mqtt from the header flags, and ignore_mqtt and the
MQTT uplink's loop guard read it, so clearing it on a GATT arrival let an
MQTT-origin packet relayed over GATT be uplinked again. tx_after and
priority are still cleared, since the LoRa header carries neither.
deliverToRouter() carried its own copy of the ingress guards; it now calls
MeshTransportBase::sanitizeIngress(), the rule set UDP and MQTT use. onSend()
encodes stripForTransmit()'s copy, so a GATT peer no longer receives this
node's rx_rssi/rx_snr, priority, tx_after or transport_mechanism.

The five copied ingress tests move to test_transport_ingress, which now runs
every rule for the GATT bearer as well. test_ble_gatt_mesh keeps the
decode-failure case, which is GATT's own, and gains one for the egress strip.
BLE_GATT_PEER rides on the phone-API Bluetooth stack. On an ESP32 that stack
starts only when bluetooth.enabled is set and Wi-Fi is not in use; on an nRF52
with bluetooth.enabled false, startDisabled() registers the service but never
advertises it. Either way the flag did nothing and nothing said so, while
main() started the pump regardless.

setBluetoothEnable()'s gate becomes bluetoothBlockedReason(), the same
expression, which now also names the condition that failed. At boot a set
flag that cannot take effect logs one warning naming it, and the pump is not
started. The GATT bit is read through BLEGattMeshHandler::protocolEnabled()
and main() asks isEnabled().
ESP32BLEGattMesh and NRF52BLEGattMesh each carried the same link table, the
same RX ring and the same disconnect-marker rule, and the copies had started
to disagree: the nRF52 pushRx logged a full queue while onWrite still held the
lock, where the ESP32 copy returned and let its caller log after releasing it.
BLEGattMeshLinks holds the one copy. It never logs, so both platforms now log
after the lock, and the nRF52 drop warning moves out from under it. Each
platform keeps its own lock, its stack calls and its link count.

The ring is platform-neutral now, so test_ble_gatt_mesh pins the marker that
must land in a full ring and the mesh-peer report on disconnect. The platform
static hooks read the GATT bit through protocolEnabled().
@jamesarich
jamesarich force-pushed the feat/ble-gatt-mesh-peer branch from 8fcc4ee to 98cbeb2 Compare October 11, 2026 16:05
@jamesarich
jamesarich force-pushed the feat/ble-phone-api-pairing branch from 67a0942 to 37ff2ec Compare October 11, 2026 16:06
A connectable advertisement stops once every peripheral link is taken. The
rak4631_blemesh build has two, and nothing stopped two mesh peers from
holding both, so the RAK4631 stopped advertising and no phone could reach
its phone API until a peer left. heltec-v3_blemesh has the same two NimBLE
links and the same exposure.

Mesh peers other than the phone's own link may now hold at most all but
one link. A mesh subscribe that would take the last one is dropped at the
CCCD write, which frees the link and brings the advertisement back. The
phone is the link that uses the phone API, so a phone that is also a mesh
peer over one link is never counted against the cap.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant