diff --git a/docs/ble-mesh-implementation-plan.md b/docs/ble-mesh-implementation-plan.md new file mode 100644 index 0000000000..889ed467f0 --- /dev/null +++ b/docs/ble-mesh-implementation-plan.md @@ -0,0 +1,625 @@ +# BLE Mesh Implementation Plan + +## Overview + +Add BLE as a mesh transport alongside LoRa, enabling Meshtastic nodes to relay packets over Bluetooth Low Energy. Follow the same handler pattern used by UDP multicast ([UdpMulticastHandler](src/mesh/udp/UdpMulticastHandler.h)) — a standalone handler class with `onSend()` hooked into `Router::send()` and received packets injected via `router->enqueueReceivedMessage()`. No changes to `RadioInterface` or Router architecture needed. + +BLE mesh is useful for: + +- Dense indoor deployments where LoRa range is excessive +- Extending mesh reach through devices that lack LoRa hardware +- Hybrid LoRa+BLE networks (gateway nodes bridge both transports) +- Lower latency links between nearby nodes + +--- + +## Architecture + +### Pattern: Same as UDP Multicast + +``` + Router::send() + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ + iface->send(p) udpHandler->onSend(p) bleMeshHandler->onSend(p) + (LoRa) (UDP) (BLE mesh) + + Router::enqueueReceivedMessage() + ▲ + ┌───────────────────┼───────────────────┐ + │ │ │ + LoRa RX ISR udpHandler->onReceive() bleMeshHandler->onReceive() +``` + +The existing UDP pattern ([Router.cpp:378-382](src/mesh/Router.cpp#L378-L382)): + +```cpp +#if HAS_UDP_MULTICAST + if (udpHandler && config.network.enabled_protocols & ...) { + udpHandler->onSend(const_cast(p)); + } +#endif +``` + +BLE mesh hooks in identically, right next to it. + +--- + +## Phase 1: Protobuf & Configuration + +### 1.1 Transport mechanism enum + +**File:** protobufs repo (`mesh.proto`) + +Add a new transport type alongside the existing ones: + +```protobuf +enum TransportMechanism { + // ... existing 0-7 ... + TRANSPORT_BLE_MESH = 8; +} +``` + +### 1.2 BLE Mesh configuration + +Add BLE mesh config to the device config protobuf: + +```protobuf +message BleMeshConfig { + bool enabled = 1; + + // Operating mode + enum Mode { + OFF = 0; + ADVERTISE_AND_SCAN = 1; // connectionless (extended adv) + GATT = 2; // connection-based + HYBRID = 3; // both + } + Mode mode = 2; + + // Scan/advertising interval in ms (latency vs power tradeoff) + uint32_t interval_ms = 3; // default: 500ms + + // TX power level (platform-mapped) + int8_t tx_power = 4; // dBm + + // Max BLE mesh peers to track + uint32_t max_peers = 5; // default: 8 +} +``` + +--- + +## Phase 2: BLE Mesh Handler — Core Class + +### 2.1 Base handler (platform-agnostic) + +**New file:** `src/mesh/BLEMeshHandler.h` + +Follow the `UdpMulticastHandler` pattern — a simple class, not a `RadioInterface`: + +```cpp +#pragma once +#if HAS_BLE_MESH + +#include "configuration.h" +#include "mesh/Router.h" + +class BLEMeshHandler { +public: + BLEMeshHandler() : isRunning(false) {} + + virtual void start() = 0; + virtual void stop() = 0; + + // Called from Router::send() — broadcast packet over BLE + virtual bool onSend(const meshtastic_MeshPacket *mp) = 0; + +protected: + bool isRunning; + + // Shared RX path: decode and enqueue into router + void deliverToRouter(const uint8_t *data, size_t len) { + meshtastic_MeshPacket mp; + if (!pb_decode_from_bytes(data, len, &meshtastic_MeshPacket_msg, &mp)) + return; + if (mp.which_payload_variant != meshtastic_MeshPacket_encrypted_tag) + return; + + mp.transport_mechanism = + meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_MESH; + mp.rx_snr = 0; + mp.rx_rssi = 0; + + UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp); + if (router) + router->enqueueReceivedMessage(p.release()); + } + + // Shared TX path: encode packet to bytes + size_t encodeForBLE(const meshtastic_MeshPacket *mp, uint8_t *buf, size_t bufLen) { + return pb_encode_to_bytes(buf, bufLen, &meshtastic_MeshPacket_msg, mp); + } +}; + +#endif // HAS_BLE_MESH +``` + +### 2.2 Wire format + +Same as UDP multicast — protobuf-encoded `meshtastic_MeshPacket` (already encrypted). This means: + +- Packets are encoded with `pb_encode_to_bytes()` on TX +- Decoded with `pb_decode_from_bytes()` on RX +- Encryption already applied by Router before `onSend()` is called +- Same deduplication via `PacketHistory` (keyed on `sender, id`) + +### 2.3 Echo prevention + +Same pattern as UDP ([UdpMulticastHandler.h:102](src/mesh/udp/UdpMulticastHandler.h#L102)): + +```cpp +if (mp->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_MESH) { + LOG_ERROR("Attempt to send BLE-sourced packet over BLE"); + return false; +} +``` + +--- + +## Phase 3: ESP32 Implementation (NimBLE) + +### Platform: ESP32, ESP32-S3, ESP32-C3, ESP32-C6 + +**New files:** `src/nimble/NimbleBLEMesh.h` / `NimbleBLEMesh.cpp` + +```cpp +#pragma once +#if HAS_BLE_MESH && defined(ARCH_ESP32) + +#include "mesh/BLEMeshHandler.h" +#include "nimble/NimbleBluetooth.h" + +class NimbleBLEMesh : public BLEMeshHandler { +public: + void start() override; + void stop() override; + bool onSend(const meshtastic_MeshPacket *mp) override; + +private: + // --- Advertising mode --- + void startScanning(); + void stopScanning(); + static void onScanResult(const ble_gap_disc_desc *desc, void *arg); + void broadcastViaAdv(const uint8_t *data, size_t len); + + // --- GATT mode --- + void setupMeshService(); + static int onMeshCharWrite(uint16_t conn_handle, + const ble_gatt_error *error, + ble_gatt_attr *attr, void *arg); + void sendViaGATT(const uint8_t *data, size_t len); + + // --- Peer tracking --- + struct BLEPeer { + NodeNum nodeNum; + ble_addr_t addr; + int8_t rssi; + uint32_t lastSeenMs; + uint16_t connHandle; // 0 if not connected + }; + std::vector peers; + void updatePeer(const ble_addr_t &addr, NodeNum nodeNum, int8_t rssi); + void pruneStale(); +}; + +#endif +``` + +### 3.1 Advertising mode (connectionless) + +Encode mesh packets into BLE extended advertisements with Meshtastic manufacturer data. + +**TX flow:** + +1. `onSend()` called from `Router::send()` +2. Encode packet via `encodeForBLE()` +3. Pack into extended advertisement manufacturer data +4. Broadcast for configurable number of intervals + +**RX flow:** + +1. NimBLE scan callback fires +2. Check manufacturer ID for Meshtastic magic +3. Extract protobuf bytes +4. Call `deliverToRouter()` + +**Advertisement structure:** + +``` +AD Type: 0xFF (Manufacturer Specific) +Company ID: 0x????? (Meshtastic BLE SIG ID or 0xFFFF) +Mesh Version: 1 byte +Protobuf payload: variable (up to ~244 bytes with extended adv) +``` + +**Key NimBLE APIs:** + +- `ble_gap_ext_adv_set_data()` / `ble_gap_ext_adv_start()` — extended adv TX +- `ble_gap_disc()` or `ble_gap_ext_disc()` — scanning +- Scan callback receives `ble_gap_disc_desc` or `ble_gap_ext_disc_desc` + +### 3.2 GATT mode (connection-based) + +Add a new GATT service for mesh relay, separate from the phone API service: + +**New Mesh Relay Service:** + +``` +Mesh Relay Service UUID: (new, distinct from phone service in BluetoothCommon.h) +├── MeshTX (WRITE_NO_RSP) — peers write mesh packets to us +├── MeshRX (NOTIFY) — we notify peers of new mesh packets +└── MeshPeer (READ) — our NodeNum for discovery +``` + +**TX:** Write to MeshTX on all connected peers (broadcast) or specific next-hop peer (directed). +**RX:** MeshTX write callback → `deliverToRouter()`. + +### 3.3 Coexistence with phone BLE + +Both services registered on the same NimBLE host: + +- Phone API service: existing UUIDs ([BluetoothCommon.h](src/BluetoothCommon.h)) +- Mesh relay service: new UUIDs +- Separate connection handles for phone vs mesh peers +- Use extended adv set 0 for phone, set 1 for mesh (NimBLE supports multiple adv sets) +- Reserve 1 connection for phone, remaining for mesh peers + +### 3.4 ESP32 variant considerations + +| Variant | BLE | Extended Adv | Max Conn | Strategy | +| -------- | --- | ------------ | -------- | --------------------------------------- | +| ESP32 | 4.2 | No | 9 | GATT-only (legacy adv too small at 31B) | +| ESP32-S3 | 5.0 | Yes | 9 | Full adv+GATT | +| ESP32-C3 | 5.0 | Yes | 3 | Adv preferred (limited connections) | +| ESP32-C6 | 5.0 | Yes | 9 | Full adv+GATT | + +--- + +## Phase 4: nRF52 Implementation (SoftDevice/Bluefruit) + +### Platform: nRF52840 (primary), nRF52833 + +**New files:** `src/platform/nrf52/NRF52BLEMesh.h` / `NRF52BLEMesh.cpp` + +```cpp +#pragma once +#if HAS_BLE_MESH && defined(ARCH_NRF52) + +#include "mesh/BLEMeshHandler.h" +#include + +class NRF52BLEMesh : public BLEMeshHandler { +public: + void start() override; + void stop() override; + bool onSend(const meshtastic_MeshPacket *mp) override; + +private: + // Advertising mode + void startScanning(); + static void onScanCallback(ble_gap_evt_adv_report_t *report); + void broadcastViaAdv(const uint8_t *data, size_t len); + + // GATT mode + BLEService meshService; + BLECharacteristic meshTxChar; + BLECharacteristic meshRxChar; + void setupMeshService(); + static void onMeshWrite(uint16_t connHandle, BLECharacteristic *chr, + uint8_t *data, uint16_t len); + void sendViaGATT(const uint8_t *data, size_t len); + + // Peers + struct BLEPeer { + NodeNum nodeNum; + ble_gap_addr_t addr; + int8_t rssi; + uint32_t lastSeenMs; + uint16_t connHandle; + }; + std::vector peers; +}; + +#endif +``` + +### 4.1 Advertising mode + +nRF52840 supports BLE 5.0 extended advertisements (up to 247 bytes). + +**Bluefruit APIs:** + +- `Bluefruit.Scanner.setRxCallback()` — scan result callback +- `Bluefruit.Scanner.start()` / `.stop()` — control scanning +- `Bluefruit.Advertising` — for standard adv +- Raw SoftDevice `sd_ble_gap_adv_set_configure()` if Bluefruit doesn't expose extended adv data + +**Implementation:** + +- Use Bluefruit Scanner for RX (filter by Meshtastic manufacturer ID) +- For TX, either use Bluefruit Advertising API or drop to raw SoftDevice for extended adv payloads + +### 4.2 GATT mode + +Same service structure as ESP32, using Bluefruit GATT APIs: + +```cpp +meshService = BLEService(MESH_RELAY_SERVICE_UUID); +meshTxChar = BLECharacteristic(MESH_TX_CHAR_UUID); +meshTxChar.setProperties(CHR_PROPS_WRITE_WO_RESP); +meshTxChar.setWriteCallback(onMeshWrite); +meshTxChar.setMaxLen(512); + +meshRxChar = BLECharacteristic(MESH_RX_CHAR_UUID); +meshRxChar.setProperties(CHR_PROPS_NOTIFY); +meshRxChar.setMaxLen(512); +``` + +**Peer connections:** + +- nRF52840 SoftDevice S140 supports up to 20 concurrent connections +- Use `Bluefruit.Central` for outbound connections to mesh peers +- Bluefruit supports simultaneous peripheral (phone) + central (mesh peers) + +### 4.3 Coexistence with phone BLE + +Existing phone service ([NRF52Bluetooth.cpp](src/platform/nrf52/NRF52Bluetooth.cpp)) uses peripheral role: + +- Keep phone API service as-is +- Add mesh relay service alongside it +- Scanner shared between peer discovery and mesh RX (filter by service UUID or manufacturer data) +- SoftDevice connection budget: 1 peripheral (phone) + N central (mesh peers) + +### 4.4 nRF52 extras + +| Feature | nRF52840 | nRF52833 | +| ---------------------- | -------- | -------- | +| Extended Adv | Yes | Yes | +| Max Connections | 20 | 20 | +| RAM per connection | ~1.8KB | ~1.8KB | +| Coded PHY (Long Range) | Yes | Yes | + +**Coded PHY:** BLE Coded PHY (125kbps) provides ~4x range vs 1Mbps PHY. Could be used for extended-range BLE mesh links as a configuration option. + +--- + +## Phase 5: Router Integration + +### 5.1 Hook into Router::send() + +**File:** [src/mesh/Router.cpp](src/mesh/Router.cpp) — add right next to UDP handler: + +```cpp +#if HAS_BLE_MESH + if (bleMeshHandler && config.bluetooth.mesh_enabled) { + bleMeshHandler->onSend(p); + } +#endif +``` + +### 5.2 Global handler instance + +**File:** [src/main.h](src/main.h): + +```cpp +#ifdef HAS_BLE_MESH +#include "mesh/BLEMeshHandler.h" +extern BLEMeshHandler *bleMeshHandler; +#endif +``` + +**File:** [src/main.cpp](src/main.cpp): + +```cpp +#ifdef HAS_BLE_MESH +BLEMeshHandler *bleMeshHandler = nullptr; +#endif + +// In setup(): +#ifdef HAS_BLE_MESH + #if defined(ARCH_ESP32) + bleMeshHandler = new NimbleBLEMesh(); + #elif defined(ARCH_NRF52) + bleMeshHandler = new NRF52BLEMesh(); + #endif + bleMeshHandler->start(); +#endif +``` + +### 5.3 NODENUM_BROADCAST_NO_LORA + +Already reserved ([MeshTypes.h:13-14](src/mesh/MeshTypes.h#L13-L14)) and already dropped by LoRa ([RadioLibInterface.cpp:170](src/mesh/RadioLibInterface.cpp#L170)). BLE mesh handler should still send packets addressed to `NODENUM_BROADCAST_NO_LORA` — this is its purpose. + +### 5.4 Feature flag + +**File:** `src/configuration.h` or per-variant `platformio.ini`: + +```cpp +#define HAS_BLE_MESH 1 // Enable BLE mesh transport +``` + +Disabled by default. Enabled per-board in variant configs for boards with sufficient BLE capability. + +--- + +## Phase 6: Peer Discovery & Management + +### 6.1 Discovery methods + +1. **Passive scanning**: Listen for Meshtastic manufacturer data in BLE advertisements +2. **Service discovery**: Scan for Mesh Relay Service UUID (GATT mode) +3. **NodeDB integration**: Update `meshtastic_NodeInfoLite` with BLE reachability when a peer is discovered + +### 6.2 Peer table + +```cpp +struct BLEMeshPeer { + NodeNum nodeNum; + // Platform-specific BLE address (ble_addr_t on ESP32, ble_gap_addr_t on nRF52) + uint8_t addr[6]; + uint8_t addrType; + int8_t rssi; + uint32_t lastSeenMs; + uint16_t connHandle; // 0 = not connected + uint8_t failedConnAttempts; +}; +``` + +Max peers: configurable (default 8), bounded by platform connection limits. + +### 6.3 Connection policy (GATT mode) + +- Auto-connect if RSSI > threshold and slots available +- Disconnect stale peers (no traffic for N minutes) +- Prefer advertising for broadcasts, GATT for directed/reliable +- Exponential backoff on failed connection attempts + +--- + +## Phase 7: Power Management + +### 7.1 Scan duty cycling + +``` +Active scanning: ~15mA (nRF52), ~90mA (ESP32) +Advertising: ~5mA peak per event +Idle: ~0mA additional + +Default strategy: +- Scan window: 50ms every 500ms (10% duty cycle) +- ~1.5mA average on nRF52, ~9mA on ESP32 +- Configurable via BleMeshConfig.interval_ms +``` + +### 7.2 Sleep integration + +- Light sleep: pause scanning, continue advertising at reduced rate +- Deep sleep: disable BLE mesh entirely +- Hook into existing power management observers + +### 7.3 Role-based behavior + +| Device Role | BLE Mesh Behavior | +| ----------- | ------------------------------------------- | +| CLIENT | Scan on-demand, advertise when sending | +| CLIENT_MUTE | Receive only, no rebroadcast | +| ROUTER | Continuous scan+advertise, max connections | +| ROUTER_LATE | Delayed rebroadcast (same as LoRa behavior) | +| SENSOR | Minimal: advertise telemetry only | + +--- + +## Phase 8: Testing & Validation + +### 8.1 Unit tests + +- BLE wire format encode/decode roundtrip +- PacketHistory deduplication across LoRa + BLE +- Peer table management (add, prune, limits) +- Echo prevention (don't re-send BLE-sourced packets over BLE) + +### 8.2 Integration tests + +- Two ESP32 nodes: BLE-only mesh (no LoRa) +- Two nRF52 nodes: BLE-only mesh +- Three nodes: multi-hop BLE relay +- Hybrid: Node A (LoRa+BLE) <-> Node B (BLE only) <-> Node C (LoRa+BLE) +- Phone API + BLE mesh coexistence +- Power consumption profiling + +### 8.3 Compatibility matrix + +| Test | ESP32 | ESP32-S3 | ESP32-C3 | ESP32-C6 | nRF52840 | +| ------------- | ----------- | -------- | -------- | -------- | -------- | +| Adv mode | GATT only\* | Yes | Yes | Yes | Yes | +| GATT mode | Yes | Yes | Yes | Yes | Yes | +| Phone coexist | Yes | Yes | Yes | Yes | Yes | +| Multi-hop | Yes | Yes | Yes | Yes | Yes | + +\*ESP32 (BLE 4.2): legacy adv limited to 31B, use GATT-only mode + +--- + +## Implementation Order + +### Milestone 1: Foundation + +1. Protobuf additions (`TRANSPORT_BLE_MESH`, `BleMeshConfig`) +2. `BLEMeshHandler` base class +3. Router hook (alongside UDP handler) +4. Feature flag + main.cpp wiring + +### Milestone 2: ESP32 MVP + +5. `NimbleBLEMesh` — advertising mode on ESP32-S3 +6. Phone BLE coexistence validation +7. Basic two-node BLE mesh test + +### Milestone 3: nRF52 MVP + +8. `NRF52BLEMesh` — advertising mode +9. Cross-platform interop (ESP32 <-> nRF52 BLE mesh) + +### Milestone 4: GATT Mode + +10. GATT service + connection management for ESP32 +11. GATT service + connection management for nRF52 +12. Peer discovery & table management + +### Milestone 5: Polish + +13. Power management integration +14. Full test matrix +15. ESP32 (BLE 4.2) GATT-only fallback +16. Admin UI / config in phone apps + +--- + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +| -------------------------------- | ------------------------- | ---------------------------------------------------------------------------- | +| BLE scan power drain | Battery life | Duty cycling, role-based policy | +| Phone BLE drops during mesh scan | UX regression | Separate adv sets, careful interleaving | +| NimBLE/Bluefruit API differences | Platform code duplication | Thin platform subclasses, shared base logic | +| No extended adv on ESP32 (4.2) | Can't fit packets in adv | GATT-only fallback | +| BLE range (~30-100m) | Coverage gaps | Hybrid LoRa+BLE; Coded PHY on nRF52 | +| Packet storms in dense BLE mesh | Saturation | Existing flood mitigation (PacketHistory, hop_limit, role-based rebroadcast) | + +--- + +## Files to Create + +| File | Purpose | +| ------------------------------------- | --------------------------------------------- | +| `src/mesh/BLEMeshHandler.h` | Base handler class (like UdpMulticastHandler) | +| `src/nimble/NimbleBLEMesh.h` | ESP32 BLE mesh header | +| `src/nimble/NimbleBLEMesh.cpp` | ESP32 NimBLE implementation | +| `src/platform/nrf52/NRF52BLEMesh.h` | nRF52 BLE mesh header | +| `src/platform/nrf52/NRF52BLEMesh.cpp` | nRF52 Bluefruit implementation | + +## Files to Modify + +| File | Change | +| --------------------------------------- | -------------------------------------------------- | +| `src/mesh/Router.cpp` | Add `bleMeshHandler->onSend()` next to UDP handler | +| `src/main.h` | Declare `extern BLEMeshHandler *bleMeshHandler` | +| `src/main.cpp` | Instantiate + start BLE mesh handler | +| `src/configuration.h` | `HAS_BLE_MESH` feature flag | +| Protobufs (`mesh.proto`) | `TRANSPORT_BLE_MESH` enum + `BleMeshConfig` | +| `src/nimble/NimbleBluetooth.cpp` | Coexistence (adv set separation) | +| `src/platform/nrf52/NRF52Bluetooth.cpp` | Coexistence (scanner sharing) | +| Platform `variant.h` / `platformio.ini` | Enable per-board | diff --git a/src/main.cpp b/src/main.cpp index 8a46b3f5bf..09e446af99 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -147,6 +147,15 @@ extern void tftSetup(void); UdpMulticastHandler *udpHandler = nullptr; #endif +#if HAS_BLE_MESH +BLEMeshHandler *bleMeshHandler = nullptr; +#if defined(ARCH_NRF52) +#include "platform/nrf52/NRF52BLEMesh.h" +#elif defined(ARCH_ESP32) +#include "platform/esp32/ESP32BLEMesh.h" +#endif +#endif + #if defined(TCXO_OPTIONAL) float tcxoVoltage = SX126X_DIO3_TCXO_VOLTAGE; // if TCXO is optional, put this here so it can be changed further down. #endif @@ -884,6 +893,17 @@ void setup() udpHandler->start(); } #endif +#endif + +#if HAS_BLE_MESH + LOG_DEBUG("Start BLE mesh handler"); +#if defined(ARCH_NRF52) + bleMeshHandler = new NRF52BLEMesh(); +#elif defined(ARCH_ESP32) + bleMeshHandler = new ESP32BLEMesh(); +#endif + if (bleMeshHandler) + bleMeshHandler->start(); #endif service = new MeshService(); service->init(); diff --git a/src/main.h b/src/main.h index 91e27951ff..06de12059b 100644 --- a/src/main.h +++ b/src/main.h @@ -62,6 +62,11 @@ extern AudioThread *audioThread; extern UdpMulticastHandler *udpHandler; #endif +#if HAS_BLE_MESH +#include "mesh/BLEMeshHandler.h" +extern BLEMeshHandler *bleMeshHandler; +#endif + // Global Screen singleton. extern graphics::Screen *screen; diff --git a/src/mesh/BLEMeshHandler.h b/src/mesh/BLEMeshHandler.h new file mode 100644 index 0000000000..a317859c55 --- /dev/null +++ b/src/mesh/BLEMeshHandler.h @@ -0,0 +1,63 @@ +#pragma once + +#if HAS_BLE_MESH + +#include "MeshTypes.h" +#include "Router.h" +#include "mesh-pb-constants.h" + +// Meshtastic BLE mesh manufacturer data identifier +// Using 0xFFFF (reserved for testing) until a real BLE SIG company ID is assigned +#define BLE_MESH_COMPANY_ID 0xFFFF +#define BLE_MESH_PROTOCOL_VERSION 1 + +// TODO: Add TRANSPORT_BLE_MESH = 8 to mesh.proto TransportMechanism enum and regenerate. +// Until then, define it here so the code compiles. +#ifndef meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_MESH +#define meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_MESH ((meshtastic_MeshPacket_TransportMechanism)8) +#endif + +class BLEMeshHandler +{ + public: + BLEMeshHandler() : isRunning(false) {} + virtual ~BLEMeshHandler() {} + + virtual void start() = 0; + virtual void stop() = 0; + virtual void onBluetoothReady() {} + + // Called from Router::send() to broadcast packet over BLE mesh + virtual bool onSend(const meshtastic_MeshPacket *mp) = 0; + + protected: + bool isRunning; + + // Decode received BLE mesh data and enqueue into router + void deliverToRouter(const uint8_t *data, size_t len) + { + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + bool decoded = pb_decode_from_bytes(data, len, &meshtastic_MeshPacket_msg, &mp); + if (!decoded || mp.which_payload_variant != meshtastic_MeshPacket_encrypted_tag) + return; + + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_MESH; + mp.pki_encrypted = false; + mp.public_key.size = 0; + memset(mp.public_key.bytes, 0, sizeof(mp.public_key.bytes)); + mp.rx_snr = 0; + mp.rx_rssi = 0; + + UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp); + if (p && router) + router->enqueueReceivedMessage(p.release()); + } + + // Encode a mesh packet for BLE transmission, returns encoded length + size_t encodeForBLE(const meshtastic_MeshPacket *mp, uint8_t *buf, size_t bufLen) + { + return pb_encode_to_bytes(buf, bufLen, &meshtastic_MeshPacket_msg, mp); + } +}; + +#endif // HAS_BLE_MESH diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 2e4c4d7d93..9b8947dc96 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -381,6 +381,12 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) } #endif +#if HAS_BLE_MESH + if (bleMeshHandler) { + bleMeshHandler->onSend(p); + } +#endif + assert(iface); // This should have been detected already in sendLocal (or we just received a packet from outside) return iface->send(p); } diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 3bb4ce8179..96c56d8d25 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -10,11 +10,17 @@ #include "mesh/PhoneAPI.h" #include "mesh/mesh-pb-constants.h" #include "sleep.h" +#include #include +#include #include #include -#ifdef NIMBLE_TWO +#ifdef ARCH_ESP32 +#include +#endif + +#if defined(NIMBLE_TWO) || (defined(CONFIG_BT_NIMBLE_EXT_ADV) && CONFIG_BT_NIMBLE_EXT_ADV) #include "NimBLEAdvertising.h" #include "NimBLEExtAdvertising.h" #include "PowerStatus.h" @@ -731,7 +737,7 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks // Restart Advertising ble->startAdvertising(); #else - NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); + auto *pAdvertising = NimBLEDevice::getAdvertising(); if (!pAdvertising->start(0)) { if (pAdvertising->isAdvertising()) { LOG_DEBUG("BLE advertising already running"); @@ -746,13 +752,48 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks static NimbleBluetoothToRadioCallback *toRadioCallbacks; static NimbleBluetoothFromRadioCallback *fromRadioCallbacks; +#ifdef ARCH_ESP32 +static void clearCorruptBondStoreOnce() +{ + Preferences prefs; + if (!prefs.begin("meshtastic", false)) { + return; + } + + const bool alreadyCleared = prefs.getBool("nimbleBondClr", false); + if (alreadyCleared) { + prefs.end(); + return; + } + + nvs_handle_t nimbleHandle; + esp_err_t err = nvs_open("nimble_bond", NVS_READWRITE, &nimbleHandle); + if (err == ESP_OK) { + err = nvs_erase_all(nimbleHandle); + if (err == ESP_OK) { + err = nvs_commit(nimbleHandle); + } + nvs_close(nimbleHandle); + + if (err == ESP_OK) { + LOG_WARN("Cleared NimBLE bond database from NVS (one-time recovery)"); + } else { + LOG_WARN("Failed clearing NimBLE bond database, err=%d", err); + } + } + + prefs.putBool("nimbleBondClr", true); + prefs.end(); +} +#endif + void NimbleBluetooth::shutdown() { // No measurable power saving for ESP32 during light-sleep(?) #ifndef ARCH_ESP32 // Shutdown bluetooth for minimum power draw LOG_INFO("Disable bluetooth"); - NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); + auto *pAdvertising = NimBLEDevice::getAdvertising(); pAdvertising->reset(); pAdvertising->stop(); #endif @@ -825,6 +866,11 @@ void NimbleBluetooth::setup() LOG_INFO("Init the NimBLE bluetooth module"); +#ifdef ARCH_ESP32 + // Recovery for units that carry corrupted bond blobs that crash NimBLE during populate_db_from_nvs. + clearCorruptBondStoreOnce(); +#endif + NimBLEDevice::init(getDeviceName()); NimBLEDevice::setPower(ESP_PWR_LVL_P9); @@ -867,6 +913,12 @@ void NimbleBluetooth::setup() bleServer->setCallbacks(serverCallbacks, true); setupService(); startAdvertising(); + +#if HAS_BLE_MESH + if (bleMeshHandler) { + bleMeshHandler->onBluetoothReady(); + } +#endif } void NimbleBluetooth::setupService() @@ -922,8 +974,12 @@ void NimbleBluetooth::setupService() void NimbleBluetooth::startAdvertising() { -#ifdef NIMBLE_TWO +#if defined(NIMBLE_TWO) || (defined(CONFIG_BT_NIMBLE_EXT_ADV) && CONFIG_BT_NIMBLE_EXT_ADV) + // NimBLE-Arduino 1.4.x can leave ext-adv callbacks uninitialized. + // Explicitly install a default callback sink before starting any instance. + static NimBLEExtAdvertisingCallbacks extAdvCallbacks; NimBLEExtAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); + pAdvertising->setCallbacks(&extAdvCallbacks, false); NimBLEExtAdvertisement legacyAdvertising; legacyAdvertising.setLegacyAdvertising(true); @@ -950,7 +1006,7 @@ void NimbleBluetooth::startAdvertising() LOG_ERROR("BLE failed to start legacyAdvertising"); } #else - NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); + auto *pAdvertising = NimBLEDevice::getAdvertising(); pAdvertising->reset(); pAdvertising->addServiceUUID(MESH_SERVICE_UUID); pAdvertising->addServiceUUID(NimBLEUUID((uint16_t)0x180f)); // 0x180F is the Battery Service diff --git a/src/platform/esp32/ESP32BLEMesh.cpp b/src/platform/esp32/ESP32BLEMesh.cpp new file mode 100644 index 0000000000..d9cc7a2df9 --- /dev/null +++ b/src/platform/esp32/ESP32BLEMesh.cpp @@ -0,0 +1,421 @@ +#include "configuration.h" + +#if HAS_BLE_MESH && defined(ARCH_ESP32) + +#include "ESP32BLEMesh.h" +#include "main.h" +#include "mesh/Router.h" + +#if defined(CONFIG_NIMBLE_CPP_IDF) +#include "host/ble_gap.h" +#include "host/ble_hs_adv.h" +#else +#include "nimble/nimble/host/include/host/ble_gap.h" +#include "nimble/nimble/host/include/host/ble_hs_adv.h" +#endif + +void ESP32BLEMesh::start() +{ + if (isRunning) { + LOG_DEBUG("BLE mesh already running"); + return; + } + + memset(peers, 0, sizeof(peers)); + peerCount = 0; + bluetoothReady = false; + isRunning = true; + LOG_INFO("BLE mesh started (waiting for Bluetooth ready)"); +} + +void ESP32BLEMesh::onBluetoothReady() +{ + if (!isRunning) + return; + + if (bluetoothReady) + return; + + bluetoothReady = true; + startScanning(); + LOG_DEBUG("BLE mesh Bluetooth ready"); +} + +void ESP32BLEMesh::stop() +{ + if (!isRunning) + return; + + stopScanning(); + isRunning = false; + LOG_INFO("BLE mesh stopped"); +} + +bool ESP32BLEMesh::onSend(const meshtastic_MeshPacket *mp) +{ + if (!isRunning || !mp) + return false; + + if (!bluetoothReady) { + LOG_DEBUG("BLE mesh TX deferred, Bluetooth not ready"); + return false; + } + + // Don't echo packets that arrived via BLE mesh + if (mp->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_MESH) { + LOG_DEBUG("Drop BLE mesh echo"); + return false; + } + + uint8_t buffer[meshtastic_MeshPacket_size]; + size_t encodedLen = encodeForBLE(mp, buffer, sizeof(buffer)); + if (encodedLen == 0) { + LOG_WARN("BLE mesh encode failed"); + return false; + } + + // Build manufacturer data: company ID (2 bytes) + version (1 byte) + protobuf payload + size_t mfgDataLen = 2 + 1 + encodedLen; + + // Extended advertising can carry up to 255 bytes total. + // Reserve 5 bytes for the flags and manufacturer-data AD headers. + if (mfgDataLen > 250) { + LOG_WARN("BLE mesh packet too large: %u bytes", mfgDataLen); + return false; + } + + uint8_t mfgData[250]; + mfgData[0] = (uint8_t)(BLE_MESH_COMPANY_ID & 0xFF); + mfgData[1] = (uint8_t)((BLE_MESH_COMPANY_ID >> 8) & 0xFF); + mfgData[2] = BLE_MESH_PROTOCOL_VERSION; + memcpy(&mfgData[3], buffer, encodedLen); + + // Stop scanning while we advertise + stopScanning(); + + // Build raw advertisement data + uint8_t advBuf[251]; + size_t advLen = 0; + + // Flags AD structure (3 bytes) + advBuf[advLen++] = 2; // length + advBuf[advLen++] = BLE_HS_ADV_TYPE_FLAGS; + advBuf[advLen++] = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP; + + // Manufacturer data AD structure + advBuf[advLen++] = (uint8_t)(mfgDataLen + 1); // length (type byte + data) + advBuf[advLen++] = BLE_HS_ADV_TYPE_MFG_DATA; + memcpy(&advBuf[advLen], mfgData, mfgDataLen); + advLen += mfgDataLen; + + const bool priorityRetries = shouldUsePriorityRetries(mp); + const uint8_t extraRetries = priorityRetries ? BLE_MESH_PRIORITY_EXTRA_RETRIES : 0; + bool sentAny = false; + + for (uint8_t attempt = 0; attempt <= extraRetries; ++attempt) { + if (attempt > 0) { + const uint16_t retryDelayMs = + random(BLE_MESH_PRIORITY_RETRY_JITTER_MIN_MS, BLE_MESH_PRIORITY_RETRY_JITTER_MAX_MS + 1); + delay(retryDelayMs); + } + + if (sendAdvertisementBurst(advBuf, advLen, mp->id, encodedLen)) { + sentAny = true; + } + } + + // Resume scanning + startScanning(); + + return sentAny; +} + +bool ESP32BLEMesh::sendAdvertisementBurst(const uint8_t *advBuf, size_t advLen, PacketId packetId, size_t encodedLen) +{ +#if MYNEWT_VAL(BLE_EXT_ADV) + // Use extended advertising if available (ESP32-S3, C3, C6) + struct ble_gap_ext_adv_params extAdvParams = {}; + extAdvParams.connectable = 0; + extAdvParams.scannable = 0; + extAdvParams.directed = 0; + extAdvParams.high_duty_directed = 0; + extAdvParams.legacy_pdu = 0; + extAdvParams.anonymous = 0; + extAdvParams.include_tx_power = 0; + extAdvParams.scan_req_notif = 0; + extAdvParams.itvl_min = BLE_MESH_ADV_INTERVAL; + extAdvParams.itvl_max = BLE_MESH_ADV_INTERVAL; + extAdvParams.channel_map = 0; // all channels + extAdvParams.own_addr_type = BLE_OWN_ADDR_PUBLIC; + extAdvParams.primary_phy = BLE_HCI_LE_PHY_1M; + extAdvParams.secondary_phy = BLE_HCI_LE_PHY_1M; + extAdvParams.tx_power = 127; // host picks max + + // Use instance 1 to avoid conflicting with phone advertising on instance 0 + const uint8_t meshAdvInstance = 1; + int8_t selectedTxPower = 0; + + int rc = ble_gap_ext_adv_configure(meshAdvInstance, &extAdvParams, &selectedTxPower, onGapEvent, this); + if (rc != 0) { + LOG_WARN("BLE mesh ext adv configure failed: %d", rc); + return false; + } + + struct os_mbuf *advData = os_msys_get_pkthdr(advLen, 0); + if (!advData) { + LOG_WARN("BLE mesh: failed to allocate mbuf"); + ble_gap_ext_adv_remove(meshAdvInstance); + return false; + } + rc = os_mbuf_append(advData, advBuf, advLen); + if (rc != 0) { + os_mbuf_free_chain(advData); + ble_gap_ext_adv_remove(meshAdvInstance); + return false; + } + + rc = ble_gap_ext_adv_set_data(meshAdvInstance, advData); + if (rc != 0) { + LOG_WARN("BLE mesh ext adv set data failed: %d", rc); + ble_gap_ext_adv_remove(meshAdvInstance); + return false; + } + + // Advertise for BLE_MESH_ADV_COUNT * 100ms, then stop. + rc = ble_gap_ext_adv_start(meshAdvInstance, BLE_MESH_ADV_COUNT, 0); + if (rc != 0) { + LOG_WARN("BLE mesh ext adv start failed: %d", rc); + ble_gap_ext_adv_remove(meshAdvInstance); + return false; + } + + LOG_DEBUG("BLE mesh adv sent (id=%u, len=%u)", packetId, encodedLen); + delay(BLE_MESH_ADV_BURST_MS); + ble_gap_ext_adv_stop(meshAdvInstance); + ble_gap_ext_adv_remove(meshAdvInstance); + return true; +#else + // Legacy advertising fallback (ESP32 classic - BLE 4.2, limited to 31 bytes) + if (advLen > 31) { + LOG_WARN("BLE mesh packet too large for legacy adv: %u bytes", advLen); + return false; + } + + struct ble_gap_adv_params legacyAdvParams = {}; + legacyAdvParams.conn_mode = BLE_GAP_CONN_MODE_NON; + legacyAdvParams.disc_mode = BLE_GAP_DISC_MODE_GEN; + legacyAdvParams.itvl_min = BLE_MESH_ADV_INTERVAL; + legacyAdvParams.itvl_max = BLE_MESH_ADV_INTERVAL; + legacyAdvParams.channel_map = 0; // all channels + + int rc = ble_gap_adv_set_data(advBuf, advLen); + if (rc != 0) { + LOG_WARN("BLE mesh legacy adv set data failed: %d", rc); + return false; + } + + const int32_t durationMs = BLE_MESH_ADV_BURST_MS; + rc = ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, durationMs / 10, &legacyAdvParams, onGapEvent, this); + if (rc != 0) { + LOG_WARN("BLE mesh legacy adv start failed: %d", rc); + return false; + } + + LOG_DEBUG("BLE mesh adv sent (id=%u, len=%u)", packetId, encodedLen); + delay(durationMs); + ble_gap_adv_stop(); + return true; +#endif +} + +bool ESP32BLEMesh::shouldUsePriorityRetries(const meshtastic_MeshPacket *mp) const +{ + if (!mp) + return false; + + // Prioritize retries for ack-sensitive traffic and direct messages only. + const bool isDirect = (mp->to != 0) && !isBroadcast(mp->to); + return mp->want_ack || isDirect; +} + +void ESP32BLEMesh::startScanning() +{ + if (!bluetoothReady) + return; + +#if MYNEWT_VAL(BLE_EXT_ADV) + struct ble_gap_ext_disc_params uncodedParams = {}; + uncodedParams.itvl = BLE_MESH_SCAN_INTERVAL; + uncodedParams.window = BLE_MESH_SCAN_WINDOW; + uncodedParams.passive = 1; + + int rc = ble_gap_ext_disc(BLE_OWN_ADDR_PUBLIC, + 0, // duration 0 => forever + 0, // period + 0, // no duplicate filtering + BLE_HCI_SCAN_FILT_NO_WL, + 0, // not limited + &uncodedParams, NULL, onGapEvent, this); +#else + struct ble_gap_disc_params scanParams = {}; + scanParams.passive = 1; // Passive scan to save power + scanParams.itvl = BLE_MESH_SCAN_INTERVAL; + scanParams.window = BLE_MESH_SCAN_WINDOW; + scanParams.filter_duplicates = 0; // We want to see repeated mesh advertisements + scanParams.limited = 0; + scanParams.filter_policy = BLE_HCI_SCAN_FILT_NO_WL; + + int rc = ble_gap_disc(BLE_OWN_ADDR_PUBLIC, BLE_HS_FOREVER, &scanParams, onGapEvent, this); +#endif + if (rc == 0) { + LOG_DEBUG("BLE mesh scanning started"); + } else if (rc == BLE_HS_EALREADY) { + LOG_DEBUG("BLE mesh scanning already active"); + } else { + LOG_WARN("BLE mesh scan start failed: %d", rc); + } +} + +void ESP32BLEMesh::stopScanning() +{ + if (!bluetoothReady) + return; + + ble_gap_disc_cancel(); +} + +int ESP32BLEMesh::onGapEvent(struct ble_gap_event *event, void *arg) +{ + auto *self = static_cast(arg); + if (!self) + return 0; + + switch (event->type) { + case BLE_GAP_EVENT_DISC: + self->handleAdvertisement(&event->disc); + break; +#if MYNEWT_VAL(BLE_EXT_ADV) + case BLE_GAP_EVENT_EXT_DISC: + self->handleExtendedAdvertisement(&event->ext_disc); + break; +#endif + case BLE_GAP_EVENT_DISC_COMPLETE: + // Scanning timed out or was stopped - restart if still running + if (self->isRunning) { + self->startScanning(); + } + break; + default: + break; + } + + return 0; +} + +void ESP32BLEMesh::handleAdvertisement(const struct ble_gap_disc_desc *desc) +{ + if (!isRunning || !desc) + return; + + handleAdvertisementData(desc->addr, desc->rssi, desc->data, desc->length_data); +} + +#if MYNEWT_VAL(BLE_EXT_ADV) +void ESP32BLEMesh::handleExtendedAdvertisement(const struct ble_gap_ext_disc_desc *desc) +{ + if (!isRunning || !desc) + return; + + if (desc->data_status != BLE_GAP_EXT_ADV_DATA_STATUS_COMPLETE || !desc->data) + return; + + handleAdvertisementData(desc->addr, desc->rssi, desc->data, desc->length_data); +} +#endif + +void ESP32BLEMesh::handleAdvertisementData(const ble_addr_t &addr, int8_t rssi, const uint8_t *data, uint8_t len) +{ + if (!isRunning || !data) + return; + + // Parse advertisement data to find manufacturer-specific data + uint16_t offset = 0; + + while (offset < len) { + uint8_t adLen = data[offset]; + if (adLen == 0 || offset + adLen >= len) + break; + + uint8_t adType = data[offset + 1]; + if (adType == BLE_HS_ADV_TYPE_MFG_DATA && adLen >= 4) { + // Check company ID + uint16_t companyId = data[offset + 2] | (data[offset + 3] << 8); + if (companyId == BLE_MESH_COMPANY_ID) { + // Check protocol version + uint8_t version = data[offset + 4]; + if (version == BLE_MESH_PROTOCOL_VERSION) { + // Extract protobuf payload (skip company ID + version = 3 bytes) + const uint8_t *payload = &data[offset + 5]; + size_t payloadLen = adLen - 4; // adLen includes type byte, subtract type + company(2) + version(1) + + LOG_DEBUG("BLE mesh RX: rssi=%d, len=%u", rssi, payloadLen); + updatePeer(addr, rssi); + deliverToRouter(payload, payloadLen); + return; + } + } + } + offset += adLen + 1; + } +} + +void ESP32BLEMesh::updatePeer(const ble_addr_t &addr, int8_t rssi) +{ + uint32_t now = millis(); + + // Check if peer already known + for (uint8_t i = 0; i < peerCount; i++) { + if (memcmp(&peers[i].addr, &addr, sizeof(ble_addr_t)) == 0) { + peers[i].rssi = rssi; + peers[i].lastSeenMs = now; + return; + } + } + + // Add new peer + if (peerCount < BLE_MESH_MAX_PEERS) { + peers[peerCount].addr = addr; + peers[peerCount].rssi = rssi; + peers[peerCount].lastSeenMs = now; + peers[peerCount].nodeNum = 0; + peerCount++; + LOG_DEBUG("BLE mesh new peer (%u total)", peerCount); + } else { + // Replace oldest peer + pruneStale(); + if (peerCount < BLE_MESH_MAX_PEERS) { + peers[peerCount].addr = addr; + peers[peerCount].rssi = rssi; + peers[peerCount].lastSeenMs = now; + peers[peerCount].nodeNum = 0; + peerCount++; + } + } +} + +void ESP32BLEMesh::pruneStale() +{ + uint32_t now = millis(); + uint8_t writeIdx = 0; + + for (uint8_t i = 0; i < peerCount; i++) { + if (now - peers[i].lastSeenMs < BLE_MESH_PEER_TIMEOUT_MS) { + if (writeIdx != i) + peers[writeIdx] = peers[i]; + writeIdx++; + } + } + peerCount = writeIdx; +} + +#endif // HAS_BLE_MESH && ARCH_ESP32 diff --git a/src/platform/esp32/ESP32BLEMesh.h b/src/platform/esp32/ESP32BLEMesh.h new file mode 100644 index 0000000000..132cd5f0ce --- /dev/null +++ b/src/platform/esp32/ESP32BLEMesh.h @@ -0,0 +1,96 @@ +#pragma once + +#if HAS_BLE_MESH && defined(ARCH_ESP32) + +#include "mesh/BLEMeshHandler.h" + +#if defined(CONFIG_NIMBLE_CPP_IDF) +#include "host/ble_gap.h" +#else +#include "nimble/nimble/host/include/host/ble_gap.h" +#endif + +// Max number of BLE mesh peers we can track +#ifndef BLE_MESH_MAX_PEERS +#define BLE_MESH_MAX_PEERS 8 +#endif + +// Scan interval and window in units of 0.625ms +// Default: continuous scan (100% duty) to maximize packet capture reliability. +// For lower power builds, these can be overridden in build flags. +#ifndef BLE_MESH_SCAN_INTERVAL +#define BLE_MESH_SCAN_INTERVAL 160 // 100ms +#endif +#ifndef BLE_MESH_SCAN_WINDOW +#define BLE_MESH_SCAN_WINDOW 160 // 100ms +#endif + +// Advertising interval for mesh data in units of 0.625ms +#ifndef BLE_MESH_ADV_INTERVAL +#define BLE_MESH_ADV_INTERVAL 160 // 100ms +#endif + +// How many times to repeat a mesh advertisement +#ifndef BLE_MESH_ADV_COUNT +#define BLE_MESH_ADV_COUNT 3 +#endif + +#ifndef BLE_MESH_ADV_BURST_MS +#define BLE_MESH_ADV_BURST_MS (BLE_MESH_ADV_COUNT * 100) +#endif + +// Extra retry bursts for higher-priority traffic only. +// Base burst always runs once for every packet. +#ifndef BLE_MESH_PRIORITY_EXTRA_RETRIES +#define BLE_MESH_PRIORITY_EXTRA_RETRIES 2 +#endif + +// Randomized delay range between extra retry bursts in milliseconds. +#ifndef BLE_MESH_PRIORITY_RETRY_JITTER_MIN_MS +#define BLE_MESH_PRIORITY_RETRY_JITTER_MIN_MS 50 +#endif +#ifndef BLE_MESH_PRIORITY_RETRY_JITTER_MAX_MS +#define BLE_MESH_PRIORITY_RETRY_JITTER_MAX_MS 200 +#endif + +// How long before a peer is considered stale (ms) +#ifndef BLE_MESH_PEER_TIMEOUT_MS +#define BLE_MESH_PEER_TIMEOUT_MS 300000 // 5 minutes +#endif + +class ESP32BLEMesh : public BLEMeshHandler +{ + public: + void start() override; + void stop() override; + void onBluetoothReady() override; + bool onSend(const meshtastic_MeshPacket *mp) override; + + private: + // Scanning + void startScanning(); + void stopScanning(); + static int onGapEvent(struct ble_gap_event *event, void *arg); + void handleAdvertisement(const struct ble_gap_disc_desc *desc); +#if MYNEWT_VAL(BLE_EXT_ADV) + void handleExtendedAdvertisement(const struct ble_gap_ext_disc_desc *desc); +#endif + void handleAdvertisementData(const ble_addr_t &addr, int8_t rssi, const uint8_t *data, uint8_t len); + bool sendAdvertisementBurst(const uint8_t *advBuf, size_t advLen, PacketId packetId, size_t encodedLen); + bool shouldUsePriorityRetries(const meshtastic_MeshPacket *mp) const; + + // Peer tracking + struct BLEMeshPeer { + NodeNum nodeNum; + ble_addr_t addr; + int8_t rssi; + uint32_t lastSeenMs; + }; + BLEMeshPeer peers[BLE_MESH_MAX_PEERS]; + uint8_t peerCount = 0; + bool bluetoothReady = false; + void updatePeer(const ble_addr_t &addr, int8_t rssi); + void pruneStale(); +}; + +#endif // HAS_BLE_MESH && ARCH_ESP32 diff --git a/src/platform/nrf52/NRF52BLEMesh.cpp b/src/platform/nrf52/NRF52BLEMesh.cpp new file mode 100644 index 0000000000..054eafa21e --- /dev/null +++ b/src/platform/nrf52/NRF52BLEMesh.cpp @@ -0,0 +1,340 @@ +#include "configuration.h" + +#if HAS_BLE_MESH && defined(ARCH_NRF52) + +#include "NRF52BLEMesh.h" +#include "main.h" +#include "mesh/Router.h" +#include + +NRF52BLEMesh *NRF52BLEMesh::instance = nullptr; + +static uint8_t bleMeshScanBuffer[BLE_GAP_SCAN_BUFFER_EXTENDED_MAX_SUPPORTED]; +static ble_data_t bleMeshScanReportData = {.p_data = bleMeshScanBuffer, .len = sizeof(bleMeshScanBuffer)}; +static ble_gap_scan_params_t bleMeshScanParams = { + .extended = 1, + .report_incomplete_evts = 0, + .active = 0, + .filter_policy = BLE_GAP_SCAN_FP_ACCEPT_ALL, + .scan_phys = BLE_GAP_PHY_1MBPS, + .interval = BLE_MESH_SCAN_INTERVAL, + .window = BLE_MESH_SCAN_WINDOW, + .timeout = 0, + .channel_mask = {0, 0, 0, 0, 0}, +}; + +void NRF52BLEMesh::start() +{ + if (isRunning) { + LOG_DEBUG("BLE mesh already running"); + return; + } + + instance = this; + memset(peers, 0, sizeof(peers)); + peerCount = 0; + + isRunning = true; + LOG_INFO("BLE mesh started"); +} + +void NRF52BLEMesh::onBluetoothReady() +{ + if (!isRunning) + return; + + LOG_DEBUG("BLE mesh Bluetooth ready"); + Bluefruit.setEventCallback(onBleEvent); + startScanning(); +} + +void NRF52BLEMesh::stop() +{ + if (!isRunning) + return; + + stopScanning(); + isRunning = false; + LOG_INFO("BLE mesh stopped"); +} + +bool NRF52BLEMesh::onSend(const meshtastic_MeshPacket *mp) +{ + if (!isRunning || !mp) + return false; + + // Don't echo packets that arrived via BLE mesh + if (mp->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_MESH) { + LOG_DEBUG("Drop BLE mesh echo"); + return false; + } + + uint8_t buffer[meshtastic_MeshPacket_size]; + size_t encodedLen = encodeForBLE(mp, buffer, sizeof(buffer)); + if (encodedLen == 0) { + LOG_WARN("BLE mesh encode failed"); + return false; + } + + // Build manufacturer data: company ID (2 bytes) + version (1 byte) + protobuf payload + size_t mfgDataLen = 2 + 1 + encodedLen; + + // Extended advertising can carry up to 255 bytes total. + // Reserve 5 bytes for the flags and manufacturer-data AD headers. + if (mfgDataLen > 250) { + LOG_WARN("BLE mesh packet too large: %u bytes", mfgDataLen); + return false; + } + + uint8_t mfgData[250]; + mfgData[0] = (uint8_t)(BLE_MESH_COMPANY_ID & 0xFF); + mfgData[1] = (uint8_t)((BLE_MESH_COMPANY_ID >> 8) & 0xFF); + mfgData[2] = BLE_MESH_PROTOCOL_VERSION; + memcpy(&mfgData[3], buffer, encodedLen); + + // Stop scanning during mesh advertisement burst + stopScanning(); + + static uint8_t adv_buf[BLE_GAP_ADV_SET_DATA_SIZE_EXTENDED_MAX_SUPPORTED]; + + size_t adv_len = 0; + adv_buf[adv_len++] = 2; + adv_buf[adv_len++] = BLE_GAP_AD_TYPE_FLAGS; + adv_buf[adv_len++] = BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE; + adv_buf[adv_len++] = (uint8_t)(mfgDataLen + 1); + adv_buf[adv_len++] = BLE_GAP_AD_TYPE_MANUFACTURER_SPECIFIC_DATA; + memcpy(&adv_buf[adv_len], mfgData, mfgDataLen); + adv_len += mfgDataLen; + + Bluefruit.Advertising.stop(); + delay(BLE_MESH_ADV_SWITCH_SETTLE_MS); + + const bool priorityRetries = shouldUsePriorityRetries(mp); + const uint8_t extraRetries = priorityRetries ? BLE_MESH_PRIORITY_EXTRA_RETRIES : 0; + bool sentAny = false; + + for (uint8_t attempt = 0; attempt <= extraRetries; ++attempt) { + if (attempt > 0) { + const uint16_t retryDelayMs = + random(BLE_MESH_PRIORITY_RETRY_JITTER_MIN_MS, BLE_MESH_PRIORITY_RETRY_JITTER_MAX_MS + 1); + delay(retryDelayMs); + } + + if (sendAdvertisementBurst(adv_buf, adv_len, mp->id, encodedLen)) { + sentAny = true; + } + } + + // Restore phone advertising through the normal Bluefruit setup path. + if (config.bluetooth.enabled && nrf52Bluetooth) { + nrf52Bluetooth->resumeAdvertising(); + } + + // Resume scanning + startScanning(); + + return sentAny; +} + +bool NRF52BLEMesh::sendAdvertisementBurst(const uint8_t *advBuf, size_t advLen, PacketId packetId, size_t encodedLen) +{ + static ble_gap_adv_data_t gapAdvData; + static ble_gap_adv_params_t advParams; + + gapAdvData.adv_data.p_data = const_cast(advBuf); + gapAdvData.adv_data.len = advLen; + gapAdvData.scan_rsp_data.p_data = NULL; + gapAdvData.scan_rsp_data.len = 0; + + memset(&advParams, 0, sizeof(advParams)); + advParams.properties.type = BLE_GAP_ADV_TYPE_EXTENDED_NONCONNECTABLE_NONSCANNABLE_UNDIRECTED; + advParams.p_peer_addr = NULL; + advParams.filter_policy = BLE_GAP_ADV_FP_ANY; + advParams.interval = BLE_MESH_ADV_INTERVAL; + advParams.duration = 0; + advParams.primary_phy = BLE_GAP_PHY_1MBPS; + advParams.secondary_phy = BLE_GAP_PHY_1MBPS; + advParams.max_adv_evts = BLE_MESH_ADV_COUNT; + + uint8_t meshAdvHandle = 0; + uint32_t stopErr = sd_ble_gap_adv_stop(meshAdvHandle); + if (stopErr != NRF_SUCCESS && stopErr != NRF_ERROR_INVALID_STATE) { + LOG_WARN("BLE mesh adv stop failed: 0x%x", stopErr); + } + delay(BLE_MESH_ADV_SWITCH_SETTLE_MS); + + uint32_t err = sd_ble_gap_adv_set_configure(&meshAdvHandle, &gapAdvData, &advParams); + if (err != NRF_SUCCESS) { + LOG_WARN("BLE mesh adv configure failed: 0x%x", err); + return false; + } + + err = sd_ble_gap_adv_start(meshAdvHandle, BLE_CONN_CFG_TAG_DEFAULT); + if (err != NRF_SUCCESS) { + LOG_WARN("BLE mesh adv start failed: 0x%x", err); + return false; + } + + LOG_DEBUG("BLE mesh adv sent (id=%u, len=%u)", packetId, encodedLen); + delay(BLE_MESH_ADV_COUNT * 10 + BLE_MESH_ADV_BURST_GUARD_MS); + + stopErr = sd_ble_gap_adv_stop(meshAdvHandle); + if (stopErr != NRF_SUCCESS && stopErr != NRF_ERROR_INVALID_STATE) { + LOG_WARN("BLE mesh adv stop after send failed: 0x%x", stopErr); + } + delay(BLE_MESH_ADV_SWITCH_SETTLE_MS); + return true; +} + +bool NRF52BLEMesh::shouldUsePriorityRetries(const meshtastic_MeshPacket *mp) const +{ + if (!mp) + return false; + + // Prioritize retries for ack-sensitive traffic and direct messages only. + const bool isDirect = (mp->to != 0) && !isBroadcast(mp->to); + return mp->want_ack || isDirect; +} + +void NRF52BLEMesh::startScanning() +{ + bleMeshScanReportData.len = sizeof(bleMeshScanBuffer); + bleMeshScanParams.interval = BLE_MESH_SCAN_INTERVAL; + bleMeshScanParams.window = BLE_MESH_SCAN_WINDOW; + + uint32_t err = sd_ble_gap_scan_start(&bleMeshScanParams, &bleMeshScanReportData); + if (err == NRF_SUCCESS) { + LOG_DEBUG("BLE mesh scanning started"); + } else if (err == NRF_ERROR_INVALID_STATE) { + LOG_DEBUG("BLE mesh scanning already active"); + } else { + LOG_WARN("BLE mesh scan start failed: 0x%x", err); + } +} + +void NRF52BLEMesh::stopScanning() +{ + uint32_t err = sd_ble_gap_scan_stop(); + if (err != NRF_SUCCESS && err != NRF_ERROR_INVALID_STATE) { + LOG_WARN("BLE mesh scan stop failed: 0x%x", err); + } +} + +void NRF52BLEMesh::onBleEvent(ble_evt_t *event) +{ + if (!instance || !instance->isRunning || !event) + return; + + switch (event->header.evt_id) { + case BLE_GAP_EVT_ADV_REPORT: { + ble_gap_evt_adv_report_t *report = &event->evt.gap_evt.params.adv_report; + + if (report->type.status == BLE_GAP_ADV_DATA_STATUS_COMPLETE) { + instance->handleScanResult(report); + } + + bleMeshScanReportData.len = sizeof(bleMeshScanBuffer); + uint32_t err = sd_ble_gap_scan_start(NULL, &bleMeshScanReportData); + if (err != NRF_SUCCESS && err != NRF_ERROR_INVALID_STATE) { + LOG_WARN("BLE mesh scan resume failed: 0x%x", err); + } + break; + } + case BLE_GAP_EVT_TIMEOUT: + if (event->evt.gap_evt.params.timeout.src == BLE_GAP_TIMEOUT_SRC_SCAN) { + instance->startScanning(); + } + break; + default: + break; + } +} + +void NRF52BLEMesh::handleScanResult(ble_gap_evt_adv_report_t *report) +{ + if (!isRunning) + return; + + // Parse advertisement data to find manufacturer-specific data + uint8_t *data = report->data.p_data; + uint16_t len = report->data.len; + uint16_t offset = 0; + + while (offset < len) { + uint8_t adLen = data[offset]; + if (adLen == 0 || offset + adLen >= len) + break; + + uint8_t adType = data[offset + 1]; + if (adType == BLE_GAP_AD_TYPE_MANUFACTURER_SPECIFIC_DATA && adLen >= 4) { + // Check company ID + uint16_t companyId = data[offset + 2] | (data[offset + 3] << 8); + if (companyId == BLE_MESH_COMPANY_ID) { + // Check protocol version + uint8_t version = data[offset + 4]; + if (version == BLE_MESH_PROTOCOL_VERSION) { + // Extract protobuf payload (skip company ID + version = 3 bytes) + const uint8_t *payload = &data[offset + 5]; + size_t payloadLen = adLen - 4; // adLen includes type byte, subtract type + company(2) + version(1) + + LOG_DEBUG("BLE mesh RX: rssi=%d, len=%u", report->rssi, payloadLen); + updatePeer(report->peer_addr, report->rssi); + deliverToRouter(payload, payloadLen); + return; + } + } + } + offset += adLen + 1; + } +} + +void NRF52BLEMesh::updatePeer(const ble_gap_addr_t &addr, int8_t rssi) +{ + uint32_t now = millis(); + + // Check if peer already known + for (uint8_t i = 0; i < peerCount; i++) { + if (memcmp(&peers[i].addr, &addr, sizeof(ble_gap_addr_t)) == 0) { + peers[i].rssi = rssi; + peers[i].lastSeenMs = now; + return; + } + } + + // Add new peer + if (peerCount < BLE_MESH_MAX_PEERS) { + peers[peerCount].addr = addr; + peers[peerCount].rssi = rssi; + peers[peerCount].lastSeenMs = now; + peers[peerCount].nodeNum = 0; // Unknown until we decode a packet from them + peerCount++; + LOG_DEBUG("BLE mesh new peer (%u total)", peerCount); + } else { + // Replace oldest peer + pruneStale(); + if (peerCount < BLE_MESH_MAX_PEERS) { + peers[peerCount].addr = addr; + peers[peerCount].rssi = rssi; + peers[peerCount].lastSeenMs = now; + peers[peerCount].nodeNum = 0; + peerCount++; + } + } +} + +void NRF52BLEMesh::pruneStale() +{ + uint32_t now = millis(); + uint8_t writeIdx = 0; + + for (uint8_t i = 0; i < peerCount; i++) { + if (now - peers[i].lastSeenMs < BLE_MESH_PEER_TIMEOUT_MS) { + if (writeIdx != i) + peers[writeIdx] = peers[i]; + writeIdx++; + } + } + peerCount = writeIdx; +} + +#endif // HAS_BLE_MESH && ARCH_NRF52 diff --git a/src/platform/nrf52/NRF52BLEMesh.h b/src/platform/nrf52/NRF52BLEMesh.h new file mode 100644 index 0000000000..beb8f5478d --- /dev/null +++ b/src/platform/nrf52/NRF52BLEMesh.h @@ -0,0 +1,94 @@ +#pragma once + +#if HAS_BLE_MESH && defined(ARCH_NRF52) + +#include "mesh/BLEMeshHandler.h" +#include + +// Max number of BLE mesh peers we can track +#ifndef BLE_MESH_MAX_PEERS +#define BLE_MESH_MAX_PEERS 8 +#endif + +// Scan interval and window in units of 0.625ms +// Default: scan 50ms every 500ms (10% duty cycle) +#ifndef BLE_MESH_SCAN_INTERVAL +#define BLE_MESH_SCAN_INTERVAL 800 // 500ms +#endif +#ifndef BLE_MESH_SCAN_WINDOW +#define BLE_MESH_SCAN_WINDOW 80 // 50ms +#endif + +// Advertising interval for mesh data in units of 0.625ms +#ifndef BLE_MESH_ADV_INTERVAL +#define BLE_MESH_ADV_INTERVAL 160 // 100ms +#endif + +// How many times to repeat a mesh advertisement +#ifndef BLE_MESH_ADV_COUNT +#define BLE_MESH_ADV_COUNT 3 +#endif + +// Small guard intervals help the SoftDevice finish the stop/start transition +// when we temporarily borrow the single advertising handle. +#ifndef BLE_MESH_ADV_SWITCH_SETTLE_MS +#define BLE_MESH_ADV_SWITCH_SETTLE_MS 2 +#endif + +#ifndef BLE_MESH_ADV_BURST_GUARD_MS +#define BLE_MESH_ADV_BURST_GUARD_MS 5 +#endif + +// Extra retry bursts for higher-priority traffic only. +// Base burst always runs once for every packet. +#ifndef BLE_MESH_PRIORITY_EXTRA_RETRIES +#define BLE_MESH_PRIORITY_EXTRA_RETRIES 2 +#endif + +// Randomized delay range between extra retry bursts in milliseconds. +#ifndef BLE_MESH_PRIORITY_RETRY_JITTER_MIN_MS +#define BLE_MESH_PRIORITY_RETRY_JITTER_MIN_MS 50 +#endif +#ifndef BLE_MESH_PRIORITY_RETRY_JITTER_MAX_MS +#define BLE_MESH_PRIORITY_RETRY_JITTER_MAX_MS 200 +#endif + +// How long before a peer is considered stale (ms) +#ifndef BLE_MESH_PEER_TIMEOUT_MS +#define BLE_MESH_PEER_TIMEOUT_MS 300000 // 5 minutes +#endif + +class NRF52BLEMesh : public BLEMeshHandler +{ + public: + void start() override; + void stop() override; + bool onSend(const meshtastic_MeshPacket *mp) override; + void onBluetoothReady() override; + + private: + // Scanning + void startScanning(); + void stopScanning(); + static void onBleEvent(ble_evt_t *event); + void handleScanResult(ble_gap_evt_adv_report_t *report); + bool sendAdvertisementBurst(const uint8_t *advBuf, size_t advLen, PacketId packetId, size_t encodedLen); + bool shouldUsePriorityRetries(const meshtastic_MeshPacket *mp) const; + + // Peer tracking + struct BLEMeshPeer { + NodeNum nodeNum; + ble_gap_addr_t addr; + int8_t rssi; + uint32_t lastSeenMs; + }; + BLEMeshPeer peers[BLE_MESH_MAX_PEERS]; + uint8_t peerCount = 0; + void updatePeer(const ble_gap_addr_t &addr, int8_t rssi); + void pruneStale(); + + // Singleton for static callbacks + static NRF52BLEMesh *instance; +}; + +#endif // HAS_BLE_MESH && ARCH_NRF52 diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 307e35b0c2..0ca1c7a232 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -233,6 +233,7 @@ void NRF52Bluetooth::shutdown() Bluefruit.Security.setPairPasskeyCallback(NRF52Bluetooth::onUnwantedPairing); // Actively refuse (during factory reset) disconnect(); Bluefruit.Advertising.stop(); + meshReadyNotified = false; } void NRF52Bluetooth::startDisabled() { @@ -241,6 +242,7 @@ void NRF52Bluetooth::startDisabled() // Shutdown bluetooth for minimum power draw Bluefruit.Advertising.stop(); Bluefruit.setTxPower(-40); // Minimum power + meshReadyNotified = false; LOG_INFO("Disable NRF52 Bluetooth. (Workaround: tx power min, advertise stopped)"); } bool NRF52Bluetooth::isConnected() @@ -263,7 +265,8 @@ void NRF52Bluetooth::setup() LOG_INFO("Init the Bluefruit nRF52 module"); Bluefruit.autoConnLed(false); Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); - Bluefruit.begin(); + Bluefruit.configCentralBandwidth(BANDWIDTH_MAX); + Bluefruit.begin(1, 1); // Clear existing data. Bluefruit.Advertising.stop(); Bluefruit.Advertising.clearData(); @@ -338,14 +341,22 @@ void NRF52Bluetooth::setup() // Setup the advertising packet(s) LOG_INFO("Set up the advertising payload(s)"); startAdv(); + if (bleMeshHandler && !meshReadyNotified) { + bleMeshHandler->onBluetoothReady(); + meshReadyNotified = true; + } LOG_INFO("Advertise"); } void NRF52Bluetooth::resumeAdvertising() { - Bluefruit.Advertising.restartOnDisconnect(true); - Bluefruit.Advertising.setInterval(32, 668); // in unit of 0.625 ms - Bluefruit.Advertising.setFastTimeout(30); // number of seconds in fast mode - Bluefruit.Advertising.start(0); + Bluefruit.Advertising.stop(); + Bluefruit.Advertising.clearData(); + Bluefruit.ScanResponse.clearData(); + startAdv(); + if (bleMeshHandler && !meshReadyNotified) { + bleMeshHandler->onBluetoothReady(); + meshReadyNotified = true; + } } /// Given a level between 0-100, update the BLE attribute void updateBatteryLevel(uint8_t level) diff --git a/src/platform/nrf52/NRF52Bluetooth.h b/src/platform/nrf52/NRF52Bluetooth.h index 630ab05bc8..cb5a796fda 100644 --- a/src/platform/nrf52/NRF52Bluetooth.h +++ b/src/platform/nrf52/NRF52Bluetooth.h @@ -22,4 +22,6 @@ class NRF52Bluetooth : BluetoothApi static bool onUnwantedPairing(uint16_t conn_handle, uint8_t const passkey[6], bool match_request); static void disconnect(); + + bool meshReadyNotified = false; }; \ No newline at end of file diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 05f15de0f3..105b3cf76e 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -35,6 +35,7 @@ build_flags = -Wextra -Isrc/platform/esp32 -std=c++11 + -DHAS_BLE_MESH=1 -DLOG_LOCAL_LEVEL=ESP_LOG_DEBUG -DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_DEBUG -DMYNEWT_VAL_BLE_HS_LOG_LVL=LOG_LEVEL_CRITICAL diff --git a/variants/esp32c3/esp32c3.ini b/variants/esp32c3/esp32c3.ini index e5f117ad77..c7ea27eab4 100644 --- a/variants/esp32c3/esp32c3.ini +++ b/variants/esp32c3/esp32c3.ini @@ -2,6 +2,12 @@ extends = esp32_common custom_esp32_kind = esp32c3 +build_flags = + ${esp32_common.build_flags} + -DCONFIG_BT_NIMBLE_EXT_ADV=1 + -DCONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES=2 + -DCONFIG_BT_NIMBLE_MAX_EXT_ADV_DATA_LEN=251 + monitor_speed = 115200 monitor_filters = esp32_c3_exception_decoder diff --git a/variants/esp32s3/esp32s3.ini b/variants/esp32s3/esp32s3.ini index 2994154423..18d7732ca2 100644 --- a/variants/esp32s3/esp32s3.ini +++ b/variants/esp32s3/esp32s3.ini @@ -2,6 +2,13 @@ extends = esp32_common custom_esp32_kind = esp32s3 +build_flags = + ${esp32_common.build_flags} + -DCONFIG_BT_NIMBLE_EXT_ADV=1 + -DCONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES=2 + -DCONFIG_BT_NIMBLE_MAX_EXT_ADV_DATA_LEN=251 + -include mbedtls/error.h + monitor_speed = 115200 lib_deps = diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index aeda68a2e2..209396520e 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -24,6 +24,7 @@ build_flags = -DLFS_NO_ASSERT ; Disable LFS assertions , see https://github.com/meshtastic/firmware/pull/3818 -DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 + -DHAS_BLE_MESH=1 -Os build_unflags = -Ofast