How to Use ESP32-S3 as a BLE-to-Wi-Fi Gateway

You’ve got 6 BLE temperature sensors scattered across a greenhouse. They’re happily broadcasting data every few seconds. One problem: Bluetooth has a range of maybe 30 meters, and your cloud dashboard is on the other side of the internet. Those sensors can’t get there on their own.
The obvious fix is a gateway: one device that listens to BLE advertisements and shoves the data over Wi-Fi to an MQTT broker. The ESP32-S3 has both radios built in. Should be simple, right?
Here’s where it gets tricky. Both radios share a single 2.4 GHz antenna. Run them naively, and you’ll get dropped BLE scans during Wi-Fi transmits, MQTT timeouts during BLE scan windows, and stack crashes at 3 AM. Most tutorials skip this part entirely. They show you “hello world” BLE and “hello world” Wi-Fi, wave their hands, and leave you to figure out the collision.
This tutorial won’t do that. By the end, you’ll have a working ESP32-S3 BLE gateway that scans for sensor advertisements, queues the data, and publishes it over MQTT, with coexistence properly configured so neither radio starves the other.
Why the ESP32-S3 Is the Right Chip for This
The ESP32-S3 packs BLE 5.0 and Wi-Fi 4 into a single module, and it includes something critical: hardware coexistence arbitration. The radio can only do one thing at a time on 2.4 GHz. The coexistence arbiter schedules time slices between BLE and Wi-Fi automatically, but only if you turn it on.
Compared to the ESP32-C3, the S3 gives you dual Xtensa cores and roughly 512 KB of SRAM. That dual-core setup is critical: you can pin BLE scanning to one core and your MQTT publish task to the other without them fighting over CPU time.
This tutorial targets ESP-IDF v5.1+ with the NimBLE stack (lower memory footprint than Bluedroid). If you’re still on v4.x, most concepts apply, but some API signatures differ.
Architecture: Three Layers, One Queue
The cleanest way to bridge BLE data to Wi-Fi is a producer-consumer pattern with a FreeRTOS queue in the middle.
┌─────────────────────────────────────────────────────────┐
│ ESP32-S3 Gateway │
│ │
│ ┌──────────────┐ ┌────────────┐ ┌─────────────┐ │
│ │ BLE Scanner │──▶│ FreeRTOS │──▶│ MQTT │ │
│ │ (NimBLE) │ │ Queue │ │ Publisher │ │
│ │ │ │ │ │ Task │ │
│ └──────┬───────┘ └────────────┘ └──────┬──────┘ │
│ │ │ │
│ │ 2.4 GHz Radio (shared, coexist) │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Shared Antenna + Coex Arbiter │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────┬───────────────────────────────────┘
│
──────────────┼──────────────
▼ ▼
┌──────────────┐ ┌──────────────┐
│ BLE Sensors │ │ MQTT Broker │
│ ┌───┐ ┌───┐│ │ (Cloud/LAN) │
│ │ S1│ │ S2││ └──────────────┘
│ └───┘ └───┘│
│ ┌───┐ ┌───┐│
│ │ S3│ │ S4││
│ └───┘ └───┘│
└──────────────┘BLE scan callbacks fire in the NimBLE host task context. You don’t want to block that context with a Wi-Fi transmit. Instead, the callback stuffs a small struct onto the queue and returns immediately. A separate MQTT publisher task, running on the other core, pulls items off the queue and publishes at its own pace.
Task priorities: BLE GAP event handler at priority 5 (high), MQTT publisher at priority 3 (medium), Wi-Fi event handler at priority 5 (high, handled by the system event loop).
Prerequisites and Project Setup
Hardware: ESP32-S3-DevKitC-1 (or any S3 module with an antenna), plus at least one BLE sensor broadcasting advertisements. Xiaomi LYWSD03MMC thermometers running pvvx custom firmware work great for this; they broadcast temperature and humidity in a known format.
Software: ESP-IDF v5.1+, a reachable MQTT broker (local Mosquitto or cloud-hosted).
Create the project:
idf.py create-project ble_wifi_gateway
cd ble_wifi_gatewaySet these critical sdkconfig options (via idf.py menuconfig or directly):
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_SW_COEXIST_ENABLE=y
CONFIG_ESP_WIFI_SW_COEXIST_ENABLE=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_MQTT_PROTOCOL_311=yThe coexistence flag is the one people miss. Without it, BLE and Wi-Fi will clobber each other.
Step 1: Wi-Fi Station Init with Reconnect Logic
A gateway that silently drops offline is worse than useless. The Wi-Fi init must handle disconnections and retry automatically.
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#define WIFI_SSID "your_ssid"
#define WIFI_PASS "your_password"
#define MAX_RETRIES 10
static const char *TAG = "wifi";
static int s_retry_count = 0;
static void wifi_event_handler(void *arg, esp_event_base_t base,
int32_t id, void *data)
{
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
if (s_retry_count < MAX_RETRIES) {
esp_wifi_connect();
s_retry_count++;
ESP_LOGW(TAG, "Retry %d/%d", s_retry_count, MAX_RETRIES);
} else {
ESP_LOGE(TAG, "Wi-Fi failed after %d retries", MAX_RETRIES);
}
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
s_retry_count = 0;
ip_event_got_ip_t *event = (ip_event_got_ip_t *)data;
ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
}
}
void wifi_init_sta(void)
{
esp_netif_init();
esp_event_loop_create_default();
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
esp_wifi_init(&cfg);
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID,
&wifi_event_handler, NULL);
esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP,
&wifi_event_handler, NULL);
wifi_config_t wifi_cfg = {
.sta = {
.ssid = WIFI_SSID,
.password = WIFI_PASS,
},
};
esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg);
esp_wifi_start();
esp_wifi_connect();
}The critical detail: resetting s_retry_count on GOT_IP. Without that, a temporary router reboot will exhaust your retries permanently.
Step 2: BLE Scanner with Advertisement Parsing
We’re using passive scanning here. The gateway just listens for advertisements and never connects to the sensors, which uses less power and frees up more airtime for Wi-Fi.
First, define the data struct and queue:
#include "freertos/queue.h"
typedef struct {
uint8_t mac[6];
float temperature;
float humidity;
int64_t timestamp;
} sensor_reading_t;
QueueHandle_t sensor_queue; // created in main(), depth = 12
The scan init and callback:
#include "host/ble_hs.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_gap.h"
#include "esp_timer.h"
static const char *BLE_TAG = "ble";
/* Called for each discovered advertisement */
static int ble_gap_event_handler(struct ble_gap_event *event, void *arg)
{
if (event->type != BLE_GAP_EVENT_DISC) return 0;
struct ble_hs_adv_fields fields;
int rc = ble_hs_adv_parse_fields(&fields, event->disc.data,
event->disc.length_data);
if (rc != 0) return 0;
/* Filter: look for custom firmware service data (UUID 0x181A) */
if (fields.svc_data_uuid16 == NULL || fields.svc_data_uuid16_len < 13)
return 0;
/* pvvx ATC format: bytes 6-7 = temp (°C × 100), bytes 8-9 = hum (% × 100) */
const uint8_t *d = fields.svc_data_uuid16;
uint16_t raw_temp = (d[7] << 8) | d[6];
uint16_t raw_hum = (d[9] << 8) | d[8];
sensor_reading_t reading = {
.temperature = (int16_t)raw_temp / 100.0f,
.humidity = raw_hum / 100.0f,
.timestamp = esp_timer_get_time() / 1000000, /* seconds */
};
memcpy(reading.mac, event->disc.addr.val, 6);
/* Non-blocking send; drop if queue full */
if (xQueueSend(sensor_queue, &reading, 0) != pdTRUE) {
ESP_LOGW(BLE_TAG, "Queue full, dropped reading");
}
return 0;
}
void ble_scan_init(void)
{
struct ble_gap_disc_params scan_params = {
.passive = 1, /* passive scan, no scan requests */
.itvl = 160, /* 100ms interval (units of 0.625ms) */
.window = 80, /* 50ms window = 50% duty cycle */
.filter_duplicates = 0, /* we want every broadcast */
};
int rc = ble_gap_disc(BLE_ADDR_TYPE_PUBLIC, BLE_HS_FOREVER,
&scan_params, ble_gap_event_handler, NULL);
if (rc != 0) {
ESP_LOGE(BLE_TAG, "Scan start failed: %d", rc);
}
}The 50% scan duty cycle (50 ms window, 100 ms interval) is deliberate. It leaves half the airtime free for Wi-Fi. If you crank the window to 100%, your MQTT publishes will start timing out.
Step 3: MQTT Publisher Task
This task blocks on the queue and publishes each reading as JSON.
#include "mqtt_client.h"
#include <stdio.h>
static const char *MQTT_TAG = "mqtt";
void mqtt_publish_task(void *param)
{
esp_mqtt_client_config_t mqtt_cfg = {
.broker.address.uri = "mqtt://192.168.1.50:1883",
.credentials.username = "gateway",
.credentials.authentication.password = "secret",
.session.keepalive = 30,
};
esp_mqtt_client_handle_t client = esp_mqtt_client_init(&mqtt_cfg);
esp_mqtt_client_start(client);
/* Give MQTT time to connect */
vTaskDelay(pdMS_TO_TICKS(3000));
sensor_reading_t reading;
char topic[64];
char payload[128];
while (1) {
if (xQueueReceive(sensor_queue, &reading, portMAX_DELAY) == pdTRUE) {
snprintf(topic, sizeof(topic),
"greenhouse/sensors/%02X%02X%02X%02X%02X%02X",
reading.mac[5], reading.mac[4], reading.mac[3],
reading.mac[2], reading.mac[1], reading.mac[0]);
snprintf(payload, sizeof(payload),
"{\"temp\":%.1f,\"hum\":%.1f,\"ts\":%lld}",
reading.temperature, reading.humidity,
reading.timestamp);
int msg_id = esp_mqtt_client_publish(client, topic, payload,
0, 1, 0); /* QoS 1 */
ESP_LOGI(MQTT_TAG, "Published msg_id=%d to %s", msg_id, topic);
}
}
}QoS 1 guarantees the broker acknowledges each message. QoS 2 would double the round trips and isn’t worth it for sensor telemetry that arrives every few seconds.
Create this task in app_main() pinned to core 1, while NimBLE runs on core 0:
void app_main(void)
{
nvs_flash_init();
sensor_queue = xQueueCreate(12, sizeof(sensor_reading_t));
wifi_init_sta();
nimble_port_init();
ble_scan_init();
nimble_port_freertos_init(nimble_host_task); /* runs on core 0 */
xTaskCreatePinnedToCore(mqtt_publish_task, "mqtt_pub", 4096,
NULL, 3, NULL, 1); /* core 1 */
}Coexistence Tuning and Common Pitfalls
The ESP32-S3’s coexistence arbiter does time-division multiplexing between BLE and Wi-Fi. You’ve already enabled it with CONFIG_SW_COEXIST_ENABLE=y. But there’s a second knob: CONFIG_SW_COEXIST_PREFERENCE.
Set it to balance for a gateway workload. If you set it to wifi, BLE scans will have gaps. If bt, MQTT publishes will lag.
Here are the pitfalls I’ve seen most often:
| Pitfall | Symptom | Fix |
|---|---|---|
| Coexistence disabled | BLE scans drop when Wi-Fi transmits | Enable CONFIG_SW_COEXIST_ENABLE |
| Scan window too wide | MQTT publish timeouts | Reduce to 50% duty cycle or less |
| No Wi-Fi reconnect logic | Gateway goes offline silently | Retry in disconnect handler |
| Queue too small | Lost sensor readings | Size queue to at least 2x sensor count |
| Scan duplicates filtered | Missing periodic readings | Set filter_duplicates = 0 |
Scan duty cycle is the single biggest lever you have here. For 4–6 sensors broadcasting every 2–3 seconds, 50% catches nearly everything. If you’re tracking 20+ sensors, you might bump to 70%, but test your MQTT latency when you do.
Testing and Validation
Flash and watch:
idf.py flash monitorYou’re looking for 3 log events in order:
wifi: Got IP: 192.168.x.x— Wi-Fi connected.ble: discevents showing sensor MACs — BLE scanning works.mqtt: Published msg_id=X to greenhouse/sensors/...— data is flowing.
On another machine, subscribe to your broker to verify end-to-end:
mosquitto_sub -h 192.168.1.50 -t "greenhouse/sensors/#" -vYou should see JSON payloads arriving every few seconds per sensor. Try power-cycling one BLE sensor; the gateway should pick it back up within one scan interval (100 ms in our config).
Hardening for Production
Getting this production-ready takes a few more steps.
TLS for MQTT. Change the broker URI to mqtts:// and load your CA certificate into the firmware. The esp_mqtt library handles the TLS handshake; you just need to set .broker.verification.certificate in the config struct.
Watchdog timer and OTA updates. If the BLE stack or Wi-Fi driver hangs, you want the device to reboot. Enable the task watchdog (CONFIG_ESP_TASK_WDT=y) and subscribe both your scan and publish tasks. For a fleet of gateways, pair this with OTA: ESP-IDF has a built-in OTA component that can pull new binaries over HTTPS.
Battery operation. If you’re running this on a battery, schedule deep sleep windows where the gateway powers down between scan/publish cycles. This is a substantial redesign but can drop average current from ~120 mA to under 5 mA.
If your BLE sensors are custom hardware and you want to extend coverage beyond a single gateway’s range, it’s worth looking at how network architectures like Hubble’s terrestrial network handle getting BLE data to the cloud at scale, especially for deployments where Wi-Fi infrastructure isn’t available.
Adapting the Gateway to Other Use Cases
You’ve now got a working ESP32-S3 BLE-to-Wi-Fi gateway: passive BLE scanning on one core, MQTT publishing on the other, a FreeRTOS queue keeping them decoupled, and coexistence configured so neither radio starves.
The same architecture works for cold chain monitoring, warehouse asset tracking, or any scenario where you’ve got a cluster of BLE devices that need a bridge to IP. Swap out the advertisement parser for your sensor’s format, change the MQTT topic structure, and you’re running.
Hubble Network enables BLE sensors to reach the cloud without local gateways or Wi-Fi infrastructure. See how it works →