diff --git a/src/configuration.h b/src/configuration.h
index 2c084174d00..83d27e43b46 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -426,6 +426,25 @@ along with this program. If not, see .
#ifndef HAS_BLUETOOTH
#define HAS_BLUETOOTH 0
#endif
+#ifndef HAS_BLE_MESH_ADVERTISING
+// Keep the experimental BLE advertisement bearer opt-in by platform capability:
+// it currently needs a NimBLE2 build with a spare legacy advertising instance.
+// Bluefruit/nRF52 builds compile the shared codec but leave the bearer disabled
+// until there is a backend that can scan and advertise without disturbing the
+// normal phone GATT advertising path.
+#if HAS_BLUETOOTH && defined(ARCH_ESP32) && defined(NIMBLE_TWO) && defined(CONFIG_BT_NIMBLE_EXT_ADV) && \
+ CONFIG_BT_NIMBLE_EXT_ADV && defined(CONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES) && CONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES > 1
+#define HAS_BLE_MESH_ADVERTISING 1
+#else
+#define HAS_BLE_MESH_ADVERTISING 0
+#endif
+#endif
+#ifndef BLE_MESH_ADVERTISEMENT_COMPANY_ID
+// Experimental company id used by the BLE advertisement mesh bearer. Builds
+// can override this once Meshtastic chooses a final Bluetooth SIG/company-data
+// allocation or switches the bearer to service data.
+#define BLE_MESH_ADVERTISEMENT_COMPANY_ID 0x05e1
+#endif
#ifndef USE_TFTDISPLAY
#define USE_TFTDISPLAY 0
#endif
diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp
index bb24b365e54..fdf0e078dad 100644
--- a/src/mesh/Router.cpp
+++ b/src/mesh/Router.cpp
@@ -15,6 +15,9 @@
#if !MESHTASTIC_EXCLUDE_MQTT
#include "mqtt/MQTT.h"
#endif
+#if HAS_BLE_MESH_ADVERTISING
+#include "mesh/ble/BleAdvertisementMesh.h"
+#endif
#include "Default.h"
#if ARCH_PORTDUINO
#include "Throttle.h"
@@ -387,6 +390,13 @@ ErrorCode Router::send(meshtastic_MeshPacket *p)
}
#endif
+#if HAS_BLE_MESH_ADVERTISING
+ if (bleAdvertisementMesh &&
+ config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_ADVERTISEMENT_BROADCAST) {
+ bleAdvertisementMesh->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/mesh/ble/BleAdvertisementMesh.cpp b/src/mesh/ble/BleAdvertisementMesh.cpp
new file mode 100644
index 00000000000..223441d0f96
--- /dev/null
+++ b/src/mesh/ble/BleAdvertisementMesh.cpp
@@ -0,0 +1,337 @@
+#include "configuration.h"
+#include "mesh/ble/BleAdvertisementMesh.h"
+#include "mesh/mesh-pb-constants.h"
+
+#if HAS_BLE_MESH_ADVERTISING
+#include "Router.h"
+#include "main.h"
+#endif
+#include
+#include
+#include
+
+namespace
+{
+uint16_t crc16ccitt(const uint8_t *data, size_t length)
+{
+ uint16_t crc = 0xffff;
+ for (size_t i = 0; i < length; i++) {
+ crc ^= static_cast(data[i]) << 8;
+ for (uint8_t bit = 0; bit < 8; bit++) {
+ crc = (crc & 0x8000) ? static_cast((crc << 1) ^ 0x1021) : static_cast(crc << 1);
+ }
+ }
+ return crc;
+}
+
+void putLe16(uint8_t *p, uint16_t v)
+{
+ p[0] = static_cast(v);
+ p[1] = static_cast(v >> 8);
+}
+
+void putLe32(uint8_t *p, uint32_t v)
+{
+ p[0] = static_cast(v);
+ p[1] = static_cast(v >> 8);
+ p[2] = static_cast(v >> 16);
+ p[3] = static_cast(v >> 24);
+}
+
+uint16_t getLe16(const uint8_t *p)
+{
+ return static_cast(p[0]) | (static_cast(p[1]) << 8);
+}
+
+uint32_t getLe32(const uint8_t *p)
+{
+ return static_cast(p[0]) | (static_cast(p[1]) << 8) | (static_cast(p[2]) << 16) |
+ (static_cast(p[3]) << 24);
+}
+} // namespace
+
+BleAdvertisementMeshCodec::BleAdvertisementMeshCodec() {}
+
+uint8_t BleAdvertisementMeshCodec::frameCount(const meshtastic_MeshPacket *mp)
+{
+ if (!mp || mp->which_payload_variant != meshtastic_MeshPacket_encrypted_tag) {
+ return 0;
+ }
+
+ uint8_t encoded[meshtastic_MeshPacket_size] = {0};
+ size_t encodedLength = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_MeshPacket_msg, mp);
+ if (encodedLength == 0) {
+ return 0;
+ }
+
+ return (encodedLength + MAX_FRAGMENT_PAYLOAD_SIZE - 1) / MAX_FRAGMENT_PAYLOAD_SIZE;
+}
+
+bool BleAdvertisementMeshCodec::forEachFrame(const meshtastic_MeshPacket *mp, EmitFrame emit, void *context)
+{
+ if (!mp || !emit || mp->which_payload_variant != meshtastic_MeshPacket_encrypted_tag) {
+ return false;
+ }
+
+ uint8_t encoded[meshtastic_MeshPacket_size] = {0};
+ size_t encodedLength = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_MeshPacket_msg, mp);
+ if (encodedLength == 0) {
+ return false;
+ }
+
+ uint8_t fragmentCount = (encodedLength + MAX_FRAGMENT_PAYLOAD_SIZE - 1) / MAX_FRAGMENT_PAYLOAD_SIZE;
+ if (fragmentCount == 0 || fragmentCount > MAX_FRAGMENTS) {
+ return false;
+ }
+
+ uint16_t crc = crc16ccitt(encoded, encodedLength);
+ for (uint8_t fragment = 0; fragment < fragmentCount; fragment++) {
+ size_t offset = fragment * MAX_FRAGMENT_PAYLOAD_SIZE;
+ size_t remaining = encodedLength - offset;
+ uint8_t payloadLength =
+ static_cast(remaining > MAX_FRAGMENT_PAYLOAD_SIZE ? MAX_FRAGMENT_PAYLOAD_SIZE : remaining);
+
+ uint8_t frame[MAX_FRAME_SIZE] = {0};
+ frame[0] = FRAME_MAGIC_0;
+ frame[1] = FRAME_MAGIC_1;
+ frame[2] = FRAME_VERSION;
+ frame[3] = fragment;
+ frame[4] = fragmentCount;
+ frame[5] = payloadLength;
+ putLe32(&frame[6], mp->from);
+ putLe32(&frame[10], mp->id);
+ putLe16(&frame[14], crc);
+ memcpy(&frame[FRAME_HEADER_SIZE], &encoded[offset], payloadLength);
+
+ if (!emit(frame, FRAME_HEADER_SIZE + payloadLength, context)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+BleAdvertisementMeshCodec::ReassemblyContext *BleAdvertisementMeshCodec::getContext(uint32_t from, uint32_t packetId,
+ uint16_t crc, uint8_t fragmentCount)
+{
+ for (auto &context : contexts) {
+ if (context.expectedFragments != 0 && context.from == from && context.packetId == packetId && context.crc == crc &&
+ context.expectedFragments == fragmentCount) {
+ return &context;
+ }
+ }
+
+ for (auto &context : contexts) {
+ if (context.expectedFragments == 0) {
+ reset(context, from, packetId, crc, fragmentCount);
+ return &context;
+ }
+ }
+
+ ReassemblyContext &context = contexts[nextContext];
+ nextContext = (nextContext + 1) % REASSEMBLY_CONTEXTS;
+ reset(context, from, packetId, crc, fragmentCount);
+ return &context;
+}
+
+void BleAdvertisementMeshCodec::reset(ReassemblyContext &context, uint32_t from, uint32_t packetId, uint16_t crc,
+ uint8_t fragmentCount)
+{
+ context.from = from;
+ context.packetId = packetId;
+ context.crc = crc;
+ context.expectedFragments = fragmentCount;
+ context.lastFragmentLength = 0;
+ context.receivedMask = 0;
+ memset(context.encodedPacket, 0, sizeof(context.encodedPacket));
+}
+
+void BleAdvertisementMeshCodec::clear(ReassemblyContext &context)
+{
+ reset(context, 0, 0, 0, 0);
+}
+
+bool BleAdvertisementMeshCodec::receiveFrame(const uint8_t *frame, size_t frameLength, meshtastic_MeshPacket *out)
+{
+ if (!frame || !out || frameLength < FRAME_HEADER_SIZE || frameLength > MAX_FRAME_SIZE || frame[0] != FRAME_MAGIC_0 ||
+ frame[1] != FRAME_MAGIC_1 || frame[2] != FRAME_VERSION) {
+ return false;
+ }
+
+ uint8_t fragment = frame[3];
+ uint8_t fragmentCount = frame[4];
+ uint8_t payloadLength = frame[5];
+ uint32_t from = getLe32(&frame[6]);
+ uint32_t packetId = getLe32(&frame[10]);
+ uint16_t crc = getLe16(&frame[14]);
+
+ if (fragmentCount == 0 || fragmentCount > MAX_FRAGMENTS || fragment >= fragmentCount ||
+ payloadLength > MAX_FRAGMENT_PAYLOAD_SIZE || frameLength != FRAME_HEADER_SIZE + payloadLength) {
+ return false;
+ }
+
+ size_t offset = fragment * MAX_FRAGMENT_PAYLOAD_SIZE;
+ if (offset + payloadLength > sizeof(contexts[0].encodedPacket)) {
+ return false;
+ }
+
+ ReassemblyContext *context = getContext(from, packetId, crc, fragmentCount);
+
+ memcpy(&context->encodedPacket[offset], &frame[FRAME_HEADER_SIZE], payloadLength);
+ context->receivedMask |= 1ULL << fragment;
+ if (fragment == fragmentCount - 1) {
+ context->lastFragmentLength = payloadLength;
+ }
+
+ uint64_t completeMask = fragmentCount == 64 ? UINT64_MAX : ((1ULL << fragmentCount) - 1);
+ if (context->receivedMask != completeMask || context->lastFragmentLength == 0) {
+ return false;
+ }
+
+ size_t encodedLength = (fragmentCount - 1) * MAX_FRAGMENT_PAYLOAD_SIZE + context->lastFragmentLength;
+ if (crc16ccitt(context->encodedPacket, encodedLength) != context->crc) {
+ clear(*context);
+ return false;
+ }
+
+ memset(out, 0, sizeof(*out));
+ bool decoded = pb_decode_from_bytes(context->encodedPacket, encodedLength, &meshtastic_MeshPacket_msg, out);
+ clear(*context);
+ return decoded;
+}
+
+#if HAS_BLE_MESH_ADVERTISING
+
+BleAdvertisementMesh *bleAdvertisementMesh = nullptr;
+
+namespace
+{
+struct PlatformEmitContext
+{
+ BleAdvertisementMesh::QueuedFrame *frames;
+ uint8_t maxFrames;
+ uint8_t count;
+};
+
+bool emitPlatformFrame(const uint8_t *frame, size_t frameLength, void *context)
+{
+ auto *platformContext = static_cast(context);
+ if (!platformContext || !frame || frameLength == 0 || frameLength > BleAdvertisementMeshCodec::MAX_FRAME_SIZE ||
+ platformContext->count >= platformContext->maxFrames) {
+ return false;
+ }
+
+ BleAdvertisementMesh::QueuedFrame &queued = platformContext->frames[platformContext->count++];
+ memcpy(queued.data, frame, frameLength);
+ queued.length = frameLength;
+ return true;
+}
+} // namespace
+
+BleAdvertisementMesh::BleAdvertisementMesh(PlatformAdvertiseFrame advertiseFrame)
+ : concurrency::OSThread("BleAdvMesh"), advertiseFrame(advertiseFrame)
+{
+}
+
+bool BleAdvertisementMesh::onSend(const meshtastic_MeshPacket *mp)
+{
+ if (!mp || !advertiseFrame ||
+ mp->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADVERTISEMENT) {
+ return false;
+ }
+
+ uint8_t neededFrames = BleAdvertisementMeshCodec::frameCount(mp);
+ uint16_t queuedCopies = static_cast(neededFrames) * FRAME_REPEAT_ROUNDS;
+ if (neededFrames == 0 || queuedCopies > freeSlots()) {
+ LOG_WARN("Drop BLE advertisement broadcast id=%u, fragments=%u free=%u", mp->id, neededFrames, freeSlots());
+ return false;
+ }
+
+ QueuedFrame frames[BleAdvertisementMeshCodec::MAX_FRAGMENTS];
+ PlatformEmitContext context = {frames, BleAdvertisementMeshCodec::MAX_FRAGMENTS, 0};
+ if (!BleAdvertisementMeshCodec::forEachFrame(mp, emitPlatformFrame, &context) || context.count != neededFrames) {
+ return false;
+ }
+
+ LOG_DEBUG("Queue packet for BLE advertisements (id=%u fragments=%u rounds=%u)", mp->id, neededFrames, FRAME_REPEAT_ROUNDS);
+ for (uint8_t round = 0; round < FRAME_REPEAT_ROUNDS; round++) {
+ for (uint8_t i = 0; i < context.count; i++) {
+ const QueuedFrame &frame = context.frames[(round + i) % context.count];
+ if (!enqueueFrame(frame.data, frame.length)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+void BleAdvertisementMesh::onAdvertisement(const uint8_t *frame, size_t frameLength)
+{
+ meshtastic_MeshPacket mp = {};
+ if (!codec.receiveFrame(frame, frameLength, &mp)) {
+ return;
+ }
+
+ if (!rememberPacket(mp.from, mp.id)) {
+ LOG_DEBUG("Drop duplicate BLE advertisement packet (from=0x%x id=%u)", mp.from, mp.id);
+ return;
+ }
+
+ if (router && mp.which_payload_variant == meshtastic_MeshPacket_encrypted_tag) {
+ mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADVERTISEMENT;
+ UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp);
+ p->rx_snr = 0;
+ p->rx_rssi = 0;
+ router->enqueueReceivedMessage(p.release());
+ }
+}
+
+bool BleAdvertisementMesh::enqueueFrame(const uint8_t *frame, size_t frameLength)
+{
+ if (!frame || frameLength == 0 || frameLength > BleAdvertisementMeshCodec::MAX_FRAME_SIZE ||
+ queuedFrames >= FRAME_QUEUE_SIZE) {
+ return false;
+ }
+
+ QueuedFrame &queued = queue[queueTail];
+ memcpy(queued.data, frame, frameLength);
+ queued.length = frameLength;
+ queueTail = (queueTail + 1) % FRAME_QUEUE_SIZE;
+ queuedFrames++;
+ setIntervalFromNow(0);
+ return true;
+}
+
+bool BleAdvertisementMesh::rememberPacket(uint32_t from, uint32_t packetId)
+{
+ for (uint8_t i = 0; i < RECENT_PACKET_COUNT; i++) {
+ if (recentFrom[i] == from && recentPacketId[i] == packetId) {
+ return false;
+ }
+ }
+
+ recentFrom[recentPacketIndex] = from;
+ recentPacketId[recentPacketIndex] = packetId;
+ recentPacketIndex = (recentPacketIndex + 1) % RECENT_PACKET_COUNT;
+ return true;
+}
+
+int32_t BleAdvertisementMesh::runOnce()
+{
+ if (!advertiseFrame || queuedFrames == 0) {
+ return 1000;
+ }
+
+ QueuedFrame queued = queue[queueHead];
+ queueHead = (queueHead + 1) % FRAME_QUEUE_SIZE;
+ queuedFrames--;
+
+ if (!advertiseFrame(queued.data, queued.length)) {
+ LOG_WARN("BLE advertisement frame transmit failed");
+ }
+
+ return queuedFrames > 0 ? FRAME_INTERVAL_MS : 1000;
+}
+
+#endif // HAS_BLE_MESH_ADVERTISING
diff --git a/src/mesh/ble/BleAdvertisementMesh.h b/src/mesh/ble/BleAdvertisementMesh.h
new file mode 100644
index 00000000000..bde3488da02
--- /dev/null
+++ b/src/mesh/ble/BleAdvertisementMesh.h
@@ -0,0 +1,114 @@
+#pragma once
+
+#include "mesh/generated/meshtastic/mesh.pb.h"
+#include
+#include
+
+/**
+ * BLE advertisement bearer for encrypted MeshPacket protobufs.
+ *
+ * The platform BLE layer is intentionally kept outside this codec. ESP32 and
+ * nRF52 implementations can carry these frames in service data, manufacturer
+ * data, or an extended advertising set without changing router semantics.
+ */
+class BleAdvertisementMeshCodec
+{
+ public:
+ static constexpr uint8_t FRAME_MAGIC_0 = 'M';
+ static constexpr uint8_t FRAME_MAGIC_1 = 'T';
+ static constexpr uint8_t FRAME_VERSION = 1;
+ // Legacy BLE advertisements are 31 bytes including AD type overhead. A
+ // manufacturer-data bearer leaves 27 bytes for our frame after the 16-bit
+ // company id.
+ static constexpr size_t MAX_FRAME_SIZE = 27;
+ static constexpr size_t FRAME_HEADER_SIZE = 16;
+ static constexpr size_t MAX_FRAGMENT_PAYLOAD_SIZE = MAX_FRAME_SIZE - FRAME_HEADER_SIZE;
+ static constexpr uint8_t MAX_FRAGMENTS = 64;
+
+ typedef bool (*EmitFrame)(const uint8_t *frame, size_t frameLength, void *context);
+
+ BleAdvertisementMeshCodec();
+
+ /**
+ * Encode an encrypted MeshPacket into one or more BLE ADV frames.
+ */
+ static bool forEachFrame(const meshtastic_MeshPacket *mp, EmitFrame emit, void *context);
+ static uint8_t frameCount(const meshtastic_MeshPacket *mp);
+
+ /**
+ * Accept one BLE ADV frame. Returns true when a complete MeshPacket has
+ * been reassembled into out.
+ */
+ bool receiveFrame(const uint8_t *frame, size_t frameLength, meshtastic_MeshPacket *out);
+
+ private:
+ static constexpr uint8_t REASSEMBLY_CONTEXTS = 4;
+
+ struct ReassemblyContext
+ {
+ uint32_t from = 0;
+ uint32_t packetId = 0;
+ uint16_t crc = 0;
+ uint8_t expectedFragments = 0;
+ uint8_t lastFragmentLength = 0;
+ uint64_t receivedMask = 0;
+ uint8_t encodedPacket[meshtastic_MeshPacket_size] = {0};
+ };
+
+ ReassemblyContext contexts[REASSEMBLY_CONTEXTS];
+ uint8_t nextContext = 0;
+
+ ReassemblyContext *getContext(uint32_t from, uint32_t packetId, uint16_t crc, uint8_t fragmentCount);
+ void reset(ReassemblyContext &context, uint32_t from, uint32_t packetId, uint16_t crc, uint8_t fragmentCount);
+ void clear(ReassemblyContext &context);
+};
+
+#if HAS_BLE_MESH_ADVERTISING
+
+#include "MeshTypes.h"
+#include "concurrency/OSThread.h"
+
+class BleAdvertisementMesh : public concurrency::OSThread
+{
+ public:
+ typedef bool (*PlatformAdvertiseFrame)(const uint8_t *frame, size_t frameLength);
+
+ struct QueuedFrame
+ {
+ uint8_t data[BleAdvertisementMeshCodec::MAX_FRAME_SIZE] = {0};
+ uint8_t length = 0;
+ };
+
+ explicit BleAdvertisementMesh(PlatformAdvertiseFrame advertiseFrame);
+
+ bool onSend(const meshtastic_MeshPacket *mp);
+ void onAdvertisement(const uint8_t *frame, size_t frameLength);
+
+ private:
+ static constexpr uint8_t MAX_PACKET_FRAMES =
+ (meshtastic_MeshPacket_size + BleAdvertisementMeshCodec::MAX_FRAGMENT_PAYLOAD_SIZE - 1) /
+ BleAdvertisementMeshCodec::MAX_FRAGMENT_PAYLOAD_SIZE;
+ static constexpr uint8_t FRAME_REPEAT_ROUNDS = 8;
+ static constexpr uint16_t FRAME_QUEUE_SIZE = MAX_PACKET_FRAMES * FRAME_REPEAT_ROUNDS;
+ static constexpr uint32_t FRAME_INTERVAL_MS = 140;
+ static constexpr uint8_t RECENT_PACKET_COUNT = 8;
+
+ PlatformAdvertiseFrame advertiseFrame = nullptr;
+ BleAdvertisementMeshCodec codec;
+ QueuedFrame queue[FRAME_QUEUE_SIZE];
+ uint16_t queueHead = 0;
+ uint16_t queueTail = 0;
+ uint16_t queuedFrames = 0;
+ uint32_t recentFrom[RECENT_PACKET_COUNT] = {0};
+ uint32_t recentPacketId[RECENT_PACKET_COUNT] = {0};
+ uint8_t recentPacketIndex = 0;
+
+ uint16_t freeSlots() const { return FRAME_QUEUE_SIZE - queuedFrames; }
+ bool enqueueFrame(const uint8_t *frame, size_t frameLength);
+ bool rememberPacket(uint32_t from, uint32_t packetId);
+ virtual int32_t runOnce() override;
+};
+
+extern BleAdvertisementMesh *bleAdvertisementMesh;
+
+#endif // HAS_BLE_MESH_ADVERTISING
diff --git a/src/mesh/ble/BleAdvertisementMeshPlatform.cpp b/src/mesh/ble/BleAdvertisementMeshPlatform.cpp
new file mode 100644
index 00000000000..9403f2636d5
--- /dev/null
+++ b/src/mesh/ble/BleAdvertisementMeshPlatform.cpp
@@ -0,0 +1,164 @@
+#include "configuration.h"
+#include "mesh/ble/BleAdvertisementMeshPlatform.h"
+
+#if HAS_BLE_MESH_ADVERTISING
+
+#include "DebugConfiguration.h"
+#include "mesh/ble/BleAdvertisementMesh.h"
+
+#if defined(ARCH_ESP32) && defined(NIMBLE_TWO) && defined(CONFIG_BT_NIMBLE_EXT_ADV) && CONFIG_BT_NIMBLE_EXT_ADV && \
+ defined(CONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES) && CONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES > 1
+#define BLE_ADV_MESH_ESP32_NIMBLE_BACKEND 1
+#else
+#define BLE_ADV_MESH_ESP32_NIMBLE_BACKEND 0
+#endif
+
+#if BLE_ADV_MESH_ESP32_NIMBLE_BACKEND
+#include
+#include
+#if defined(CONFIG_BT_NIMBLE_ROLE_OBSERVER) && CONFIG_BT_NIMBLE_ROLE_OBSERVER
+#include
+#include
+#endif
+#endif
+#include
+#include
+
+namespace
+{
+constexpr uint16_t BLE_ADV_MESH_COMPANY_ID = BLE_MESH_ADVERTISEMENT_COMPANY_ID;
+constexpr uint8_t BLE_ADV_MESH_INSTANCE = 1;
+constexpr uint32_t BLE_ADV_MESH_BURST_MS = 120;
+
+void putLe16(uint8_t *p, uint16_t v)
+{
+ p[0] = static_cast(v);
+ p[1] = static_cast(v >> 8);
+}
+
+#if BLE_ADV_MESH_ESP32_NIMBLE_BACKEND
+
+#if defined(CONFIG_BT_NIMBLE_ROLE_OBSERVER) && CONFIG_BT_NIMBLE_ROLE_OBSERVER
+class BleAdvMeshScanCallbacks : public NimBLEScanCallbacks
+{
+ public:
+ void onResult(const NimBLEAdvertisedDevice *advertisedDevice) override
+ {
+ handleAdvertisement(advertisedDevice);
+ }
+
+ void onDiscovered(const NimBLEAdvertisedDevice *advertisedDevice) override
+ {
+ handleAdvertisement(advertisedDevice);
+ }
+
+ private:
+ void handleAdvertisement(const NimBLEAdvertisedDevice *advertisedDevice)
+ {
+ if (!advertisedDevice || !advertisedDevice->haveManufacturerData() || !bleAdvertisementMesh) {
+ return;
+ }
+
+ uint8_t dataCount = advertisedDevice->getManufacturerDataCount();
+ for (uint8_t i = 0; i < dataCount; i++) {
+ std::string manufacturerData = advertisedDevice->getManufacturerData(i);
+ if (manufacturerData.size() < 2 + BleAdvertisementMeshCodec::FRAME_HEADER_SIZE) {
+ continue;
+ }
+
+ const auto *data = reinterpret_cast(manufacturerData.data());
+ uint16_t companyId = static_cast(data[0]) | (static_cast(data[1]) << 8);
+ if (companyId != BLE_ADV_MESH_COMPANY_ID) {
+ continue;
+ }
+
+ bleAdvertisementMesh->onAdvertisement(data + 2, manufacturerData.size() - 2);
+ }
+ }
+};
+
+BleAdvMeshScanCallbacks scanCallbacks;
+#endif
+
+bool advertiseBleAdvMeshFrame(const uint8_t *frame, size_t frameLength)
+{
+ if (!frame || frameLength == 0 || frameLength > BleAdvertisementMeshCodec::MAX_FRAME_SIZE) {
+ return false;
+ }
+
+ uint8_t manufacturerData[2 + BleAdvertisementMeshCodec::MAX_FRAME_SIZE] = {0};
+ putLe16(manufacturerData, BLE_ADV_MESH_COMPANY_ID);
+ memcpy(&manufacturerData[2], frame, frameLength);
+
+ NimBLEExtAdvertisement advertisement;
+ advertisement.setLegacyAdvertising(true);
+ advertisement.setConnectable(false);
+ advertisement.setScannable(false);
+ advertisement.setMinInterval(160);
+ advertisement.setMaxInterval(240);
+ if (!advertisement.setManufacturerData(manufacturerData, frameLength + 2)) {
+ return false;
+ }
+
+ NimBLEExtAdvertising *advertising = NimBLEDevice::getAdvertising();
+ if (!advertising) {
+ return false;
+ }
+
+ advertising->stop(BLE_ADV_MESH_INSTANCE);
+ advertising->removeInstance(BLE_ADV_MESH_INSTANCE);
+ return advertising->setInstanceData(BLE_ADV_MESH_INSTANCE, advertisement) &&
+ advertising->start(BLE_ADV_MESH_INSTANCE, BLE_ADV_MESH_BURST_MS, 0);
+}
+
+void startBleAdvMeshScan()
+{
+#if defined(CONFIG_BT_NIMBLE_ROLE_OBSERVER) && CONFIG_BT_NIMBLE_ROLE_OBSERVER
+ NimBLEScan *scan = NimBLEDevice::getScan();
+ if (!scan) {
+ LOG_WARN("BLE advertisement mesh scan unavailable");
+ return;
+ }
+
+ scan->setScanCallbacks(&scanCallbacks, true);
+ scan->setActiveScan(false);
+ scan->setInterval(500);
+ scan->setWindow(80);
+ scan->setDuplicateFilter(0);
+ scan->setMaxResults(0);
+ if (!scan->start(0, false, true)) {
+ LOG_WARN("BLE advertisement mesh scan start failed");
+ }
+#else
+ LOG_WARN("BLE advertisement mesh receive disabled: NimBLE observer role is not enabled");
+#endif
+}
+
+#else
+
+bool advertiseBleAdvMeshFrame(const uint8_t *, size_t)
+{
+ LOG_WARN("BLE advertisement mesh transmit disabled on this platform");
+ return false;
+}
+
+void startBleAdvMeshScan()
+{
+ LOG_WARN("BLE advertisement mesh scan disabled on this platform");
+}
+
+#endif
+} // namespace
+
+void initBleAdvertisementMeshPlatform()
+{
+ if (bleAdvertisementMesh) {
+ return;
+ }
+
+ LOG_INFO("Init BLE advertisement mesh bearer");
+ bleAdvertisementMesh = new BleAdvertisementMesh(advertiseBleAdvMeshFrame);
+ startBleAdvMeshScan();
+}
+
+#endif // HAS_BLE_MESH_ADVERTISING
diff --git a/src/mesh/ble/BleAdvertisementMeshPlatform.h b/src/mesh/ble/BleAdvertisementMeshPlatform.h
new file mode 100644
index 00000000000..6b70c386083
--- /dev/null
+++ b/src/mesh/ble/BleAdvertisementMeshPlatform.h
@@ -0,0 +1,11 @@
+#pragma once
+
+#ifndef HAS_BLE_MESH_ADVERTISING
+#define HAS_BLE_MESH_ADVERTISING 0
+#endif
+
+#if HAS_BLE_MESH_ADVERTISING
+
+void initBleAdvertisementMeshPlatform();
+
+#endif
diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h
index 820bb276450..7f1b211e347 100644
--- a/src/mesh/generated/meshtastic/config.pb.h
+++ b/src/mesh/generated/meshtastic/config.pb.h
@@ -171,7 +171,9 @@ typedef enum _meshtastic_Config_NetworkConfig_ProtocolFlags {
/* Do not broadcast packets over any network protocol */
meshtastic_Config_NetworkConfig_ProtocolFlags_NO_BROADCAST = 0,
/* Enable broadcasting packets via UDP over the local network */
- meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST = 1
+ meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST = 1,
+ /* Enable broadcasting packets over Bluetooth LE advertisements. */
+ meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_ADVERTISEMENT_BROADCAST = 2
} meshtastic_Config_NetworkConfig_ProtocolFlags;
/* Deprecated in 2.7.4: Unused */
@@ -710,8 +712,8 @@ extern "C" {
#define _meshtastic_Config_NetworkConfig_AddressMode_ARRAYSIZE ((meshtastic_Config_NetworkConfig_AddressMode)(meshtastic_Config_NetworkConfig_AddressMode_STATIC+1))
#define _meshtastic_Config_NetworkConfig_ProtocolFlags_MIN meshtastic_Config_NetworkConfig_ProtocolFlags_NO_BROADCAST
-#define _meshtastic_Config_NetworkConfig_ProtocolFlags_MAX meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST
-#define _meshtastic_Config_NetworkConfig_ProtocolFlags_ARRAYSIZE ((meshtastic_Config_NetworkConfig_ProtocolFlags)(meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST+1))
+#define _meshtastic_Config_NetworkConfig_ProtocolFlags_MAX meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_ADVERTISEMENT_BROADCAST
+#define _meshtastic_Config_NetworkConfig_ProtocolFlags_ARRAYSIZE ((meshtastic_Config_NetworkConfig_ProtocolFlags)(meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_ADVERTISEMENT_BROADCAST+1))
#define _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_UNUSED
#define _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MAX meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_UNUSED
diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h
index cb5f19df5a0..e7b1119fe43 100644
--- a/src/mesh/generated/meshtastic/mesh.pb.h
+++ b/src/mesh/generated/meshtastic/mesh.pb.h
@@ -621,7 +621,9 @@ typedef enum _meshtastic_MeshPacket_TransportMechanism {
/* Arrived via Multicast UDP */
meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP = 6,
/* Arrived via API connection */
- meshtastic_MeshPacket_TransportMechanism_TRANSPORT_API = 7
+ meshtastic_MeshPacket_TransportMechanism_TRANSPORT_API = 7,
+ /* Arrived via Bluetooth LE advertisements */
+ meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADVERTISEMENT = 8
} meshtastic_MeshPacket_TransportMechanism;
/* Log levels, chosen to match python logging conventions. */
@@ -1516,8 +1518,8 @@ extern "C" {
#define _meshtastic_MeshPacket_Delayed_ARRAYSIZE ((meshtastic_MeshPacket_Delayed)(meshtastic_MeshPacket_Delayed_DELAYED_DIRECT+1))
#define _meshtastic_MeshPacket_TransportMechanism_MIN meshtastic_MeshPacket_TransportMechanism_TRANSPORT_INTERNAL
-#define _meshtastic_MeshPacket_TransportMechanism_MAX meshtastic_MeshPacket_TransportMechanism_TRANSPORT_API
-#define _meshtastic_MeshPacket_TransportMechanism_ARRAYSIZE ((meshtastic_MeshPacket_TransportMechanism)(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_API+1))
+#define _meshtastic_MeshPacket_TransportMechanism_MAX meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADVERTISEMENT
+#define _meshtastic_MeshPacket_TransportMechanism_ARRAYSIZE ((meshtastic_MeshPacket_TransportMechanism)(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADVERTISEMENT+1))
#define _meshtastic_LogRecord_Level_MIN meshtastic_LogRecord_Level_UNSET
#define _meshtastic_LogRecord_Level_MAX meshtastic_LogRecord_Level_CRITICAL
diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp
index d4cb1d9effa..e8851dbf36a 100644
--- a/src/nimble/NimbleBluetooth.cpp
+++ b/src/nimble/NimbleBluetooth.cpp
@@ -8,6 +8,7 @@
#include "concurrency/OSThread.h"
#include "main.h"
#include "mesh/PhoneAPI.h"
+#include "mesh/ble/BleAdvertisementMeshPlatform.h"
#include "mesh/mesh-pb-constants.h"
#include "sleep.h"
#include
@@ -867,6 +868,11 @@ void NimbleBluetooth::setup()
bleServer->setCallbacks(serverCallbacks, true);
setupService();
startAdvertising();
+#if HAS_BLE_MESH_ADVERTISING
+ if (config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_ADVERTISEMENT_BROADCAST) {
+ initBleAdvertisementMeshPlatform();
+ }
+#endif
}
void NimbleBluetooth::setupService()
diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp
index 52e45ccccc9..00147858fc7 100644
--- a/src/platform/nrf52/NRF52Bluetooth.cpp
+++ b/src/platform/nrf52/NRF52Bluetooth.cpp
@@ -5,6 +5,7 @@
#include "PowerFSM.h"
#include "configuration.h"
#include "main.h"
+#include "mesh/ble/BleAdvertisementMeshPlatform.h"
#include "mesh/PhoneAPI.h"
#include "mesh/mesh-pb-constants.h"
#include
@@ -343,6 +344,11 @@ void NRF52Bluetooth::setup()
// Setup the advertising packet(s)
LOG_INFO("Set up the advertising payload(s)");
startAdv();
+#if HAS_BLE_MESH_ADVERTISING
+ if (config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_ADVERTISEMENT_BROADCAST) {
+ initBleAdvertisementMeshPlatform();
+ }
+#endif
LOG_INFO("Advertise");
}
void NRF52Bluetooth::resumeAdvertising()
diff --git a/test/test_ble_advertisement_mesh/test_main.cpp b/test/test_ble_advertisement_mesh/test_main.cpp
new file mode 100644
index 00000000000..99f49f24539
--- /dev/null
+++ b/test/test_ble_advertisement_mesh/test_main.cpp
@@ -0,0 +1,169 @@
+#include "mesh/ble/BleAdvertisementMesh.h"
+#include
+#include
+#include
+
+namespace
+{
+struct Capture
+{
+ uint8_t frames[BleAdvertisementMeshCodec::MAX_FRAGMENTS][BleAdvertisementMeshCodec::MAX_FRAME_SIZE] = {};
+ size_t lengths[BleAdvertisementMeshCodec::MAX_FRAGMENTS] = {};
+ size_t count = 0;
+};
+
+bool captureFrame(const uint8_t *frame, size_t frameLength, void *context)
+{
+ auto *capture = static_cast(context);
+ if (capture->count >= BleAdvertisementMeshCodec::MAX_FRAGMENTS) {
+ return false;
+ }
+ memcpy(capture->frames[capture->count], frame, frameLength);
+ capture->lengths[capture->count] = frameLength;
+ capture->count++;
+ return true;
+}
+
+meshtastic_MeshPacket makePacket()
+{
+ meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
+ mp.from = 0x11223344;
+ mp.to = 0xffffffff;
+ mp.id = 0x55667788;
+ mp.channel = 8;
+ mp.hop_limit = 3;
+ mp.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
+ mp.encrypted.size = 32;
+ for (uint8_t i = 0; i < mp.encrypted.size; i++) {
+ mp.encrypted.bytes[i] = i;
+ }
+ return mp;
+}
+
+meshtastic_MeshPacket makeMaxEncryptedPacket()
+{
+ meshtastic_MeshPacket mp = makePacket();
+ mp.encrypted.size = sizeof(mp.encrypted.bytes);
+ for (uint16_t i = 0; i < mp.encrypted.size; i++) {
+ mp.encrypted.bytes[i] = i & 0xff;
+ }
+ return mp;
+}
+} // namespace
+
+void test_ble_adv_codec_round_trips_fragmented_mesh_packet()
+{
+ meshtastic_MeshPacket original = makePacket();
+ Capture capture;
+
+ TEST_ASSERT_TRUE(BleAdvertisementMeshCodec::forEachFrame(&original, captureFrame, &capture));
+ TEST_ASSERT_GREATER_THAN(1, capture.count);
+ TEST_ASSERT_EQUAL_UINT8(capture.count, BleAdvertisementMeshCodec::frameCount(&original));
+
+ BleAdvertisementMeshCodec codec;
+ meshtastic_MeshPacket decoded = meshtastic_MeshPacket_init_zero;
+ bool complete = false;
+ for (size_t i = 0; i < capture.count; i++) {
+ complete = codec.receiveFrame(capture.frames[i], capture.lengths[i], &decoded);
+ }
+
+ TEST_ASSERT_TRUE(complete);
+ TEST_ASSERT_EQUAL_UINT32(original.from, decoded.from);
+ TEST_ASSERT_EQUAL_UINT32(original.to, decoded.to);
+ TEST_ASSERT_EQUAL_UINT32(original.id, decoded.id);
+ TEST_ASSERT_EQUAL_UINT8(original.channel, decoded.channel);
+ TEST_ASSERT_EQUAL_UINT8(original.hop_limit, decoded.hop_limit);
+ TEST_ASSERT_EQUAL_UINT8(original.encrypted.size, decoded.encrypted.size);
+ TEST_ASSERT_EQUAL_UINT8_ARRAY(original.encrypted.bytes, decoded.encrypted.bytes, original.encrypted.size);
+}
+
+void test_ble_adv_codec_round_trips_max_encrypted_mesh_packet()
+{
+ meshtastic_MeshPacket original = makeMaxEncryptedPacket();
+ Capture capture;
+
+ TEST_ASSERT_TRUE(BleAdvertisementMeshCodec::forEachFrame(&original, captureFrame, &capture));
+ TEST_ASSERT_GREATER_THAN(8, capture.count);
+ TEST_ASSERT_LESS_OR_EQUAL(BleAdvertisementMeshCodec::MAX_FRAGMENTS, capture.count);
+ TEST_ASSERT_EQUAL_UINT8(capture.count, BleAdvertisementMeshCodec::frameCount(&original));
+
+ BleAdvertisementMeshCodec codec;
+ meshtastic_MeshPacket decoded = meshtastic_MeshPacket_init_zero;
+ bool complete = false;
+ for (size_t i = 0; i < capture.count; i++) {
+ complete = codec.receiveFrame(capture.frames[i], capture.lengths[i], &decoded);
+ }
+
+ TEST_ASSERT_TRUE(complete);
+ TEST_ASSERT_EQUAL_UINT32(original.from, decoded.from);
+ TEST_ASSERT_EQUAL_UINT32(original.id, decoded.id);
+ TEST_ASSERT_EQUAL_UINT16(original.encrypted.size, decoded.encrypted.size);
+ TEST_ASSERT_EQUAL_UINT8_ARRAY(original.encrypted.bytes, decoded.encrypted.bytes, original.encrypted.size);
+}
+
+void test_ble_adv_codec_rejects_crc_mismatch()
+{
+ meshtastic_MeshPacket original = makePacket();
+ Capture capture;
+ TEST_ASSERT_TRUE(BleAdvertisementMeshCodec::forEachFrame(&original, captureFrame, &capture));
+
+ capture.frames[capture.count - 1][capture.lengths[capture.count - 1] - 1] ^= 0x55;
+
+ BleAdvertisementMeshCodec codec;
+ meshtastic_MeshPacket decoded = meshtastic_MeshPacket_init_zero;
+ bool complete = false;
+ for (size_t i = 0; i < capture.count; i++) {
+ complete = codec.receiveFrame(capture.frames[i], capture.lengths[i], &decoded);
+ }
+
+ TEST_ASSERT_FALSE(complete);
+}
+
+void test_ble_adv_codec_handles_interleaved_packets()
+{
+ meshtastic_MeshPacket first = makePacket();
+ meshtastic_MeshPacket second = makePacket();
+ second.from = 0x99aabbcc;
+ second.id = 0x12345678;
+ for (uint8_t i = 0; i < second.encrypted.size; i++) {
+ second.encrypted.bytes[i] = 0xff - i;
+ }
+
+ Capture firstCapture;
+ Capture secondCapture;
+ TEST_ASSERT_TRUE(BleAdvertisementMeshCodec::forEachFrame(&first, captureFrame, &firstCapture));
+ TEST_ASSERT_TRUE(BleAdvertisementMeshCodec::forEachFrame(&second, captureFrame, &secondCapture));
+ TEST_ASSERT_EQUAL(firstCapture.count, secondCapture.count);
+
+ BleAdvertisementMeshCodec codec;
+ meshtastic_MeshPacket decoded = meshtastic_MeshPacket_init_zero;
+ bool sawFirst = false;
+ bool sawSecond = false;
+
+ for (size_t i = 0; i < firstCapture.count; i++) {
+ if (codec.receiveFrame(firstCapture.frames[i], firstCapture.lengths[i], &decoded)) {
+ TEST_ASSERT_EQUAL_UINT32(first.id, decoded.id);
+ sawFirst = true;
+ }
+ if (codec.receiveFrame(secondCapture.frames[i], secondCapture.lengths[i], &decoded)) {
+ TEST_ASSERT_EQUAL_UINT32(second.id, decoded.id);
+ sawSecond = true;
+ }
+ }
+
+ TEST_ASSERT_TRUE(sawFirst);
+ TEST_ASSERT_TRUE(sawSecond);
+}
+
+void setup()
+{
+ delay(10);
+ UNITY_BEGIN();
+ RUN_TEST(test_ble_adv_codec_round_trips_fragmented_mesh_packet);
+ RUN_TEST(test_ble_adv_codec_round_trips_max_encrypted_mesh_packet);
+ RUN_TEST(test_ble_adv_codec_rejects_crc_mismatch);
+ RUN_TEST(test_ble_adv_codec_handles_interleaved_packets);
+ exit(UNITY_END());
+}
+
+void loop() {}
diff --git a/variants/esp32c6/m5stack_unitc6l/platformio.ini b/variants/esp32c6/m5stack_unitc6l/platformio.ini
index bf605ca612d..61540d70337 100644
--- a/variants/esp32c6/m5stack_unitc6l/platformio.ini
+++ b/variants/esp32c6/m5stack_unitc6l/platformio.ini
@@ -33,8 +33,9 @@ build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
-D HAS_BLUETOOTH=1
- -DCONFIG_BT_NIMBLE_EXT_ADV=1
- -DCONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES=2
+ -DCONFIG_BT_NIMBLE_EXT_ADV=1
+ -DCONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES=2
+ -DCONFIG_BT_NIMBLE_ROLE_OBSERVER=1
-D NIMBLE_TWO
monitor_speed=115200
lib_ignore =