BLE beacons are the backbone of proximity marketing, asset tracking, indoor navigation, and countless IoT scenarios. The ESP32's built-in Bluetooth 4.2 radio makes it a perfect low-cost beacon transmitter. In this tutorial you'll configure the ESP32 to broadcast a custom 128-bit service UUID plus manufacturer-specific data bytes — then build a Flutter app for iOS and Android that scans for the beacon, parses the UUID and payload, displays RSSI, and calculates estimated distance. No connection needed — everything is carried in the advertisement packet itself.

// What you'll build: An ESP32 BLE beacon that broadcasts a custom 128-bit UUID and a 10-byte manufacturer payload, paired with a Flutter scanner app that discovers the beacon, decodes the payload, shows RSSI strength as a bar, and estimates the distance in metres using the log-distance path loss model.
FULL BUILD DEMO ON YOUTUBE

Watch the complete build on the ESPSTACK YouTube channel →

// AD SLOT — IN-CONTENT RESPONSIVE

Prerequisites

RequirementVersion / Notes
ESP32 DevKitAny variant; Arduino IDE 2.x + ESP32 by Espressif boards package
Arduino ESP32 BLE libraryBundled with Espressif board package — no extra install needed
Flutter SDK3.x (stable channel)
flutter_blue_plus^1.31.0 — add to pubspec.yaml
iOS permissionsAdd NSBluetoothAlwaysUsageDescription to Info.plist
Android permissionsBLUETOOTH_SCAN + BLUETOOTH_CONNECT in AndroidManifest.xml (API 31+)

Step 1 — Configure the ESP32 BLE Beacon

The ESP32 BLE stack is event-driven. For a beacon you only need to configure the advertising data — no GATT server required. The key trick is embedding both a 128-bit service UUID and manufacturer-specific data (company ID + custom payload) in the same advertisement packet. The total advertisement payload is 31 bytes, so keep your data lean.

#include <BLEDevice.h>
#include <BLEAdvertising.h>
#include <BLEAdvertisementData.h>

// Custom 128-bit Service UUID — generate yours at https://www.uuidgenerator.net/
#define BEACON_UUID "12345678-1234-1234-1234-123456789abc"

// Company ID (little-endian): 0xFFFF = test/unknown company
// Payload: 8 bytes of application data (e.g. beacon ID + sensor reading)
const uint8_t COMPANY_ID[2]  = { 0xFF, 0xFF };
const uint8_t PAYLOAD[8]     = { 0x01, 0x02, 0xAA, 0xBB, 0x00, 0x01, 0x64, 0x00 };

BLEAdvertising *pAdvert;

void buildAdvertisementData(BLEAdvertisementData &advData,
                             BLEAdvertisementData &scanData) {
  // Flags: LE General Discoverable, BR/EDR not supported
  advData.setFlags(ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT);

  // Add 128-bit complete service UUID
  advData.setCompleteServices(BLEUUID(BEACON_UUID));

  // Manufacturer-specific data: 2-byte company ID + payload
  std::string mfr;
  mfr += (char)COMPANY_ID[0];
  mfr += (char)COMPANY_ID[1];
  for (int i = 0; i < 8; i++) mfr += (char)PAYLOAD[i];
  advData.setManufacturerData(mfr);

  // Scan response carries the device name
  scanData.setName("ESPSTACK-BCN");
}

void setup() {
  Serial.begin(115200);
  BLEDevice::init("ESPSTACK-BCN");
  BLEDevice::setMTU(23);

  pAdvert = BLEDevice::getAdvertising();

  BLEAdvertisementData advData, scanData;
  buildAdvertisementData(advData, scanData);
  pAdvert->setAdvertisementData(advData);
  pAdvert->setScanResponseData(scanData);

  // TX Power: ESP_PWR_LVL_P9 = +9 dBm (max), N12 = -12 dBm (min)
  BLEDevice::setPower(ESP_PWR_LVL_P9);

  // Advertising interval: 100 ms (160 × 0.625 ms units)
  pAdvert->setMinInterval(160);
  pAdvert->setMaxInterval(160);

  pAdvert->start();
  Serial.println("Beacon advertising started");
}

void loop() {
  // Nothing to do — advertising runs in the BLE stack background task
  delay(1000);
  Serial.printf("Beacon TX power: +9 dBm, interval: 100ms\n");
}

Step 2 — Flutter: Setup and Permissions

Add flutter_blue_plus to pubspec.yaml, then configure platform permissions.

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_blue_plus: ^1.31.0
<!-- AndroidManifest.xml (inside <manifest>) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!-- ios/Runner/Info.plist -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Required to scan for ESPSTACK BLE beacons.</string>
// AD SLOT — IN-CONTENT RESPONSIVE

Step 3 — Flutter: Scanning and Parsing Advertisement Data

// lib/beacon_scanner.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';

const String TARGET_UUID = "12345678-1234-1234-1234-123456789abc";
const int COMPANY_ID = 0xFFFF;

class BeaconScanner extends StatefulWidget {
  const BeaconScanner({super.key});
  @override
  State<BeaconScanner> createState() => _BeaconScannerState();
}

class _BeaconScannerState extends State<BeaconScanner> {
  List<_BeaconInfo> beacons = [];
  StreamSubscription? _scanSub;

  @override
  void initState() {
    super.initState();
    _startScan();
  }

  void _startScan() {
    FlutterBluePlus.startScan(
      timeout: const Duration(seconds: 30),
      continuousUpdates: true,
    );

    _scanSub = FlutterBluePlus.onScanResults.listen((results) {
      final found = <_BeaconInfo>[];
      for (final r in results) {
        final uuids = r.advertisementData.serviceUuids
            .map((u) => u.toString().toLowerCase())
            .toList();
        if (!uuids.contains(TARGET_UUID.toLowerCase())) continue;

        final mfrData = r.advertisementData.manufacturerData;
        List<int> payload = [];
        if (mfrData.containsKey(COMPANY_ID)) {
          payload = mfrData[COMPANY_ID]!;
        }

        found.add(_BeaconInfo(
          name: r.device.advName.isNotEmpty ? r.device.advName : r.device.remoteId.str,
          rssi: r.rssi,
          distance: _estimateDistance(r.rssi),
          payload: payload,
        ));
      }
      setState(() => beacons = found);
    });
  }

  // Log-distance path loss: d = 10^((txPower - rssi) / (10 * n))
  // txPower at 1 m ≈ -59 dBm (calibrate for your hardware), n = 2.0 (free space)
  double _estimateDistance(int rssi) {
    const int txPower = -59;
    const double n = 2.0;
    if (rssi == 0) return -1;
    return Math.pow(10, (txPower - rssi) / (10 * n)).toDouble();
  }

  @override
  void dispose() {
    _scanSub?.cancel();
    FlutterBluePlus.stopScan();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0A0E1A),
      appBar: AppBar(
        backgroundColor: const Color(0xFF0A0E1A),
        title: const Text('BLE Beacon Scanner',
            style: TextStyle(color: Color(0xFF00F5FF))),
      ),
      body: beacons.isEmpty
          ? const Center(child: CircularProgressIndicator(color: Color(0xFF00F5FF)))
          : ListView.builder(
              itemCount: beacons.length,
              itemBuilder: (_, i) {
                final b = beacons[i];
                return Card(
                  color: const Color(0xFF141928),
                  margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                  child: Padding(
                    padding: const EdgeInsets.all(16),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(b.name, style: const TextStyle(
                            color: Color(0xFF00F5FF), fontSize: 16,
                            fontWeight: FontWeight.bold)),
                        const SizedBox(height: 8),
                        Text('RSSI: ${b.rssi} dBm',
                            style: const TextStyle(color: Colors.white70)),
                        Text('Distance: ~${b.distance.toStringAsFixed(2)} m',
                            style: const TextStyle(color: Colors.white70)),
                        Text('Payload: ${b.payload.map((v) => v.toRadixString(16).padLeft(2,'0')).join(' ')}',
                            style: const TextStyle(color: Colors.white54, fontSize: 12)),
                        const SizedBox(height: 8),
                        LinearProgressIndicator(
                          value: ((b.rssi + 100) / 70).clamp(0.0, 1.0),
                          color: const Color(0xFF00F5FF),
                          backgroundColor: Colors.white12,
                        ),
                      ],
                    ),
                  ),
                );
              },
            ),
    );
  }
}

class _BeaconInfo {
  final String name;
  final int rssi;
  final double distance;
  final List<int> payload;
  const _BeaconInfo({required this.name, required this.rssi,
      required this.distance, required this.payload});
}

// Add to imports at top of file:
// import 'dart:math' as Math;

Step 4 — Parsing the Custom Payload Bytes

The 8-byte payload in this demo encodes a beacon ID (2 bytes), a temperature reading (2 bytes, little-endian, in tenths of a degree), a battery level (1 byte, 0–100), and 3 reserved bytes. Parse them as follows in the Flutter app:

// Payload parser utility
class BeaconPayload {
  final int beaconId;
  final double temperature; // °C
  final int battery;        // percent

  BeaconPayload({required this.beaconId,
                 required this.temperature,
                 required this.battery});

  factory BeaconPayload.fromBytes(List<int> b) {
    if (b.length < 6) return BeaconPayload(beaconId: 0, temperature: 0, battery: 0);
    final id   = b[0] | (b[1] << 8);
    final raw  = b[2] | (b[3] << 8);
    final temp = raw / 10.0;
    final bat  = b[4];
    return BeaconPayload(beaconId: id, temperature: temp, battery: bat);
  }
}

// On the ESP32 side, encode temperature and battery into PAYLOAD:
// float tempC = 25.3; uint16_t rawTemp = (uint16_t)(tempC * 10);
// PAYLOAD[2] = rawTemp & 0xFF; PAYLOAD[3] = (rawTemp >> 8) & 0xFF;
// PAYLOAD[4] = batteryPercent;

Step 5 — Dynamic Payload Updates on the ESP32

Beacons become useful when their payload reflects live sensor data. Restart advertising with updated data every 5 seconds without any connection:

#include <BLEDevice.h>
#include <BLEAdvertising.h>

extern BLEAdvertising *pAdvert;

void updateBeaconPayload(float tempC, uint8_t battPct) {
  uint16_t rawTemp = (uint16_t)(tempC * 10);
  uint8_t payload[8] = {
    0x01, 0x00,                          // beacon ID = 1
    (uint8_t)(rawTemp & 0xFF),
    (uint8_t)((rawTemp >> 8) & 0xFF),
    battPct,
    0x00, 0x00, 0x00
  };

  BLEAdvertisementData advData, scanData;
  advData.setFlags(ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT);
  advData.setCompleteServices(BLEUUID("12345678-1234-1234-1234-123456789abc"));
  std::string mfr;
  mfr += (char)0xFF; mfr += (char)0xFF;
  for (int i = 0; i < 8; i++) mfr += (char)payload[i];
  advData.setManufacturerData(mfr);
  scanData.setName("ESPSTACK-BCN");

  pAdvert->stop();
  pAdvert->setAdvertisementData(advData);
  pAdvert->setScanResponseData(scanData);
  pAdvert->start();
}

void loop() {
  float temp = readTemperatureSensor(); // your sensor function
  uint8_t batt = readBatteryPercent();
  updateBeaconPayload(temp, batt);
  delay(5000);
}
// Warning: Stopping and restarting advertising to update payload causes a brief (~50 ms) gap where the beacon is invisible to scanners. For continuous operation keep advertising and only update the payload at infrequent intervals (every 5–30 seconds) or use a connected GATT characteristic for real-time data.

Troubleshooting

SymptomLikely CauseFix
Flutter app never finds the beaconUUID format mismatch (upper/lowercase)Compare with .toLowerCase() on both sides; flutter_blue_plus returns lowercase
Manufacturer data map is emptyAdvertisement packet too large (>31 bytes)Shorten device name in scan response or reduce UUID length
RSSI jumps wildlyMultipath fading / reflectionsApply a moving average over the last 5 RSSI samples before displaying
iOS doesn't show beacon in backgroundiOS pauses BLE scanning when app is backgroundedUse Core Bluetooth region monitoring for iBeacon format for background scanning
ESP32 restarts every few secondsBLE task stack overflowIncrease BLE task stack via BLEDevice::init("", 4096) — second param is stack size

Next Steps

  • Implement the iBeacon format (Major/Minor + proximity UUID) for iOS background region monitoring.
  • Add deep sleep between advertising bursts to extend battery life to months on a CR2032 coin cell.
  • Build a fleet management dashboard in Flutter that tracks multiple beacons simultaneously on a floor plan image.
  • Encode GPS coordinates in the manufacturer payload for outdoor asset tracking without a network connection.
  • Use BLE 5 extended advertising for longer payloads and higher TX power on ESP32-C3 or ESP32-S3.