How to Enable a Bluetooth Beacon on the Seeed Studio XIAO ESP32-S3

A Bluetooth Low Energy (BLE) beacon broadcasts a packet of data without waiting for a connection, so any BLE scanner in range can pick it up. This one-way broadcast model suits applications like proximity detection and asset tracking, where a device needs to announce its presence without establishing a two-way link. What makes it genuinely useful is the payload — a byte array you control completely, which means the beacon can carry any data your application needs.

This tutorial walks through building a working BLE beacon on the Seeed Studio XIAO ESP32-S3 using ESP-IDF and the NimBLE stack. The XIAO ESP32-S3 packs a dual-core Xtensa LX7 processor running at up to 240 MHz with built-in Bluetooth 5, all in a 21 × 17.8 mm form factor. By the end of this tutorial, you will know how to get your board to broadcast a custom advertisement packet detectable by any BLE scanner, with a documented payload structure built to extend.

Hardware and Software Setup

You need the XIAO ESP32-S3, the small WiFi/BT stub antenna included in the box, and a USB-C data cable. Attach the antenna before powering up. The U.FL connector sits in the bottom-left corner of the board. Tilt one side of the antenna connector into the slot, then press the other side down until it clicks. Without it, BLE signal will be too weak to function reliably at any practical distance.

For software, install ESP-IDF v5.0 or later and confirm the installation with idf.py --version. Before running any idf.py commands in a new terminal, source your environment: . $IDF_PATH/export.sh on macOS and Linux, or open the ESP-IDF Command Prompt on Windows.

Setting Up the Project

An ESP-IDF project uses a small set of configuration files alongside your source code. Create a folder named xiao_beacon with a CMakeLists.txt at the root, an sdkconfig.defaults file, and a main/ subfolder containing another CMakeLists.txt and your main.c. After creating these files, run idf.py set-target esp32s3 from the project root to generate the sdkconfig.

The sdkconfig.defaults file enables the NimBLE BLE stack before you run set-target. Without these settings, the BLE headers and libraries won’t compile into your project:

CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_CONTROLLER_ENABLED=y
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=n
CONFIG_BT_NIMBLE_ROLE_CENTRAL=n
CONFIG_BT_NIMBLE_ROLE_OBSERVER=n

NimBLE is Espressif’s recommended BLE host stack for the ESP32-S3. Setting the peripheral, central, and observer roles to n trims the binary down to only what a broadcaster needs.

Designing the Payload

BLE advertisements carry a manufacturer-specific data field — a byte array you define. The first two bytes hold a registered Bluetooth company ID in little-endian order, followed by any application-defined bytes up to a maximum of 27 total. This tutorial uses Espressif’s assigned company ID (0xE5, 0x02), so scanners correctly attribute the packet to the hardware broadcasting it.

Every byte after the company ID is yours to define. You choose the layout, the fields, and what each value means. This makes the manufacturer-specific data field the right place for application payloads: sensor readings, device identifiers, status flags, or any combination that fits in the remaining 25 bytes.

The payload in this tutorial uses 7 bytes total: the 2-byte company ID, a protocol version byte you increment when the format changes, a 2-byte device ID to distinguish individual beacons, a device type byte, and a status byte. That leaves 20 bytes for future additions.

The Code

Paste the following into main/main.c. The only array you need to edit for customization is beacon_mfr_data[].

#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs_flash.h"
#include "esp_log.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "host/util/util.h"

static const char *TAG = "XIAO_BEACON";
#define DEVICE_NAME "XIAO-Beacon"

/*
 * Custom advertisement payload — edit this array to customize your beacon.
 * Bytes 0-1 must be a registered Bluetooth SIG company ID (little-endian).
 * All bytes after that are application-defined. Maximum total size: 27 bytes.
 */
static uint8_t beacon_mfr_data[] = {
    0xE5, 0x02,   /* Company ID: Espressif Systems (0x02E5)           */
    0x01,         /* Protocol version — increment when format changes */
    0x00, 0x01,   /* Device ID: change per beacon for uniqueness      */
    0x01,         /* Device type: 1 = sensor node                     */
    0x00,         /* Status byte                                      */
};

/* Called once the NimBLE host and controller have synchronized.
   This is the earliest safe point to start advertising. */
static void beacon_advertise(void)
{
    struct ble_gap_adv_params adv_params = {0};
    struct ble_hs_adv_fields  fields     = {0};
    struct ble_hs_adv_fields  rsp_fields = {0};
    int rc;

    /* Advertisement packet: carries the custom payload */
    fields.flags        = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
    fields.mfg_data     = beacon_mfr_data;
    fields.mfg_data_len = sizeof(beacon_mfr_data);

    rc = ble_gap_adv_set_fields(&fields);
    if (rc != 0) { ESP_LOGE(TAG, "adv_set_fields failed: %d", rc); return; }

    /* Scan response packet: carries the device name separately so it
       doesn't consume bytes from the 27-byte advertisement payload budget */
    rsp_fields.name             = (uint8_t *)DEVICE_NAME;
    rsp_fields.name_len         = strlen(DEVICE_NAME);
    rsp_fields.name_is_complete = 1;

    rc = ble_gap_adv_rsp_set_fields(&rsp_fields);
    if (rc != 0) { ESP_LOGE(TAG, "rsp_set_fields failed: %d", rc); return; }

    /* Non-connectable so no device can pair with this beacon.
       Generally discoverable so scan responses are sent when requested.
       Interval units are 0.625 ms: 160 = 100 ms, 320 = 200 ms. */
    adv_params.conn_mode = BLE_GAP_CONN_MODE_NON;
    adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
    adv_params.itvl_min  = 160;
    adv_params.itvl_max  = 320;

    rc = ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER,
                           &adv_params, NULL, NULL);
    if (rc != 0) { ESP_LOGE(TAG, "adv_start failed: %d", rc); return; }

    ESP_LOGI(TAG, "Beacon advertising started: %s", DEVICE_NAME);
}

static void on_stack_reset(int reason)
{
    ESP_LOGE(TAG, "BLE stack reset, reason: %d", reason);
}

static void on_stack_sync(void)
{
    ESP_LOGI(TAG, "BLE stack synced.");
    beacon_advertise();
}

/* FreeRTOS task that runs the NimBLE host event loop */
static void nimble_host_task(void *param)
{
    nimble_port_run();
    nimble_port_freertos_deinit();
}

void app_main(void)
{
    /* NVS is required by the BLE stack. Erase and reinitialize if the
       partition is full or was written by a different firmware version. */
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
        ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ret = nvs_flash_init();
    }
    ESP_ERROR_CHECK(ret);

    ESP_ERROR_CHECK(nimble_port_init());
    ble_hs_cfg.sync_cb  = on_stack_sync;
    ble_hs_cfg.reset_cb = on_stack_reset;
    nimble_port_freertos_init(nimble_host_task);
}

The code splits data across two packets. The advertisement packet carries the custom payload. The scan response carries the device name. This preserves the full 27-byte payload budget without sacrificing the device name in scanner apps.

Setting conn_mode = BLE_GAP_CONN_MODE_NON makes the beacon non-connectable, so no device can initiate a pairing request. Setting disc_mode = BLE_GAP_DISC_MODE_GEN marks the beacon as generally discoverable, which is what triggers the scan response containing the device name.

Building and Flashing

Build the project and flash it with these three commands, replacing PORT with your serial port (COM4 on Windows, /dev/cu.usbmodem... on macOS), and using UART as your flash method.

idf.py set-target esp32s3 → idf.py build → idf.py -p PORT flash monitor

A successful boot produces this output in the monitor:

I (xxx) XIAO_BEACON: BLE stack synced.
I (xxx) XIAO_BEACON: Beacon advertising started: XIAO-Beacon

Verifying the Beacon

After you flash the firmware, the easiest way to verify the beacon is with a mobile app like TI SimpleLink Connect or nRF Connect for Mobile.

  1. Open the app on your smartphone.
  2. Scan for nearby Bluetooth devices.
  3. Look for the beacon name you defined in your project “XIAO-Beacon”.
Mobile BLE scanner app showing the XIAO-Beacon advertising packet in scan results

Extending the Beacon

The payload structure is the starting point. Change the device ID bytes (0x00, 0x01) to give each beacon in a deployment a unique number. Add a temperature reading as two additional bytes after the status byte. Lower the advertising interval to 80 ms for faster detectability, or raise it to 1000 ms to reduce power draw on a battery-powered board. Every change to beacon_mfr_data[] shows up immediately in TI SimpleLink Connect or nRF Connect for Mobile after a reflash.

The XIAO ESP32-S3 with NimBLE gives you a compact, production-viable BLE broadcaster with a payload you can define and modify freely.


Ready to connect your devices anywhere on Earth? Get started with Hubble Network for free →