Implementing Bluetooth or BLE Functionality in Zig with ESP-IDF: A Complete Guide

The kassane/zig-esp-idf-sample repository provides a Zig wrapper in imports/bluetooth.zig that exposes ESP-IDF's Bluetooth stack through ergonomic Zig APIs, allowing you to initialize BLE with a single bt.bleInit() call and manage GAP advertising and GATT server operations via the Gap and GattServer structs.

Implementing Bluetooth or BLE functionality in Zig with ESP-IDF requires bridging the low-level C APIs with Zig's memory safety and error handling. The kassane/zig-esp-idf-sample repository demonstrates this integration through a clean Zig facade that wraps the ESP-IDF Bluedroid stack, providing type-safe access to controller initialization, GAP advertising, and GATT server operations.

Architecture of the Zig Bluetooth Wrapper

The bluetooth.zig Module

Located at imports/bluetooth.zig, this module provides a thin, ergonomic Zig facade over the C ESP-IDF API. It exposes BT controller modes via bluetooth.Mode (wrapping esp_bt_mode_t), BLE TX power control via bluetooth.txPowerSet and bluetooth.txPowerGet, and the full controller lifecycle through the bluetooth.Controller struct.

The module also wraps the Bluedroid stack via bluetooth.Bluedroid (mirroring esp_bluedroid_* functions) and provides a convenient BLE init function bluetooth.bleInit() that sequences controller and Bluedroid calls correctly.

Controller and Bluedroid Lifecycle

The Controller struct in imports/bluetooth.zig provides Zig-friendly wrappers for the ESP-IDF controller API:

pub const Controller = struct {
    pub fn defaultConfig() sys.esp_bt_controller_config_t { ... }
    pub fn init(cfg: *sys.esp_bt_controller_config_t) !void { ... }
    pub fn enable(mode: Mode) !void { ... }
    pub fn deinit() !void { ... }
    pub fn disable() !void { ... }
    pub fn memRelease(mode: Mode) !void { ... } // frees unused Classic-BT memory
};

The bluetooth.bleInit() function (lines 124-136 of bluetooth.zig) bundles the safe-order initialization sequence:

var cfg = Controller.defaultConfig();
try Controller.memRelease(.classic);   // we only need BLE
try Controller.init(&cfg);
try Controller.enable(.ble);
try Bluedroid.init();
try Bluedroid.enable();

Initializing BLE in Zig

NVS Initialization

Before any Bluetooth operations, you must initialize the Non-Volatile Storage (NVS) flash, which is required for the BT controller calibration data:

const idf = @import("esp_idf");

idf.nvs.flashInitOrErase() catch |err| {
    std.log.err("NVS init failed: {s}", .{@errorName(err)});
    return;
};

One-Shot BLE Setup

After NVS initialization, starting the BLE stack requires a single call to bt.bleInit():

const bt = @import("esp_idf").bt;

bt.bleInit() catch |err| {
    std.log.err("BLE init failed: {s}", .{@errorName(err)});
    return;
};

This convenience function handles the complex sequence of releasing Classic Bluetooth memory, initializing the controller with default configuration, enabling BLE mode, and starting the Bluedroid host stack.

Implementing GAP for Advertising

Registering Callbacks

The Generic Access Profile (GAP) manages advertising and scanning. First, register your event handler:

bt.Gap.registerCallback(&gapEventHandler) catch |err| {
    std.log.err("GAP registration failed: {s}", .{@errorName(err)});
};

The callback signature follows the ESP-IDF convention, receiving events such as ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT and ESP_GAP_BLE_ADV_START_COMPLETE_EVT.

Configuring Advertisement Data

Configure your advertisement data and parameters before starting advertising:

// Advertisement data configuration
var adv_data = sys.esp_ble_adv_data_t{
    .set_scan_rsp = false,
    .include_name = true,
    .include_txpower = true,
    .min_interval = 0x0006,
    .max_interval = 0x0010,
    .appearance = 0x00,
    .manufacturer_len = 0,
    .p_manufacturer_data = null,
    .service_data_len = 0,
    .p_service_data = null,
    .service_uuid_len = 0,
    .p_service_uuid = null,
    .flag = (sys.ESP_BLE_ADV_FLAG_GEN_DISC | sys.ESP_BLE_ADV_FLAG_BREDR_NOT_SPT),
};

// Advertising parameters
var adv_params = sys.esp_ble_adv_params_t{
    .adv_int_min = 0x0020,
    .adv_int_max = 0x0040,
    .adv_type = sys.ADV_TYPE_IND,
    .own_addr_type = sys.BLE_ADDR_TYPE_PUBLIC,
    .peer_addr = .{ .addr = [6]u8{0} },
    .peer_addr_type = sys.BLE_ADDR_TYPE_PUBLIC,
    .channel_map = sys.ADV_CHNL_ALL,
    .adv_filter_policy = sys.ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
};

// Set advertisement data and start advertising
bt.Gap.configAdvData(&adv_data) catch return;
bt.Gap.startAdvertising(&adv_params) catch return;

Building a GATT Server

Service and Characteristic Creation

The Generic Attribute Profile (GATT) server manages services and characteristics. After registering your GATT callback with bt.GattServer.registerCallback, register your application:

bt.GattServer.appRegister(0) catch |err| {
    std.log.err("GATT app registration failed: {s}", .{@errorName(err)});
};

When the ESP_GATTS_REG_EVT event fires, create your service:

// Service identifier (primary service with 16-bit UUID 0x00FF)
var srvc_id = sys.esp_gatt_srvc_id_t{
    .is_primary = true,
    .id = .{
        .inst_id = 0,
        .uuid = .{
            .len = sys.ESP_UUID_LEN_16,
            .uuid = .{ .uuid16 = 0x00FF },
        },
    },
};

// Create service with 4 handles
bt.GattServer.createService(gatts_if, &srvc_id, 4) catch return;

On ESP_GATTS_CREATE_EVT, add characteristics:

// Add a read+notify characteristic (UUID 0xFF01)
bt.GattServer.addChar(
    service_handle,
    &(.{ .len = sys.ESP_UUID_LEN_16, .uuid = .{ .uuid16 = 0xFF01 } }),
    sys.ESP_GATT_PERM_READ,
    sys.ESP_GATT_CHAR_PROP_BIT_READ | sys.ESP_GATT_CHAR_PROP_BIT_NOTIFY,
    null,
    null,
) catch return;

Handling CCCD for Notifications

To enable notifications, handle the Client Characteristic Configuration Descriptor (CCCD) write events in your GATT callback:

// Inside gattsEventHandler for ESP_GATTS_WRITE_EVT
if (param.write.handle == cccd_handle) {
    // Check if notifications enabled (0x0001) or disabled (0x0000)
    const cccd_value = std.mem.readInt(u16, param.write.value[0..2], .little);
    notifications_enabled = (cccd_value == 0x0001);
}

Sending BLE Notifications

The Notification Task

Create a FreeRTOS task to send periodic notifications. Use bt.GattServer.sendIndicate with the need_confirm parameter set to false for notifications:

fn notifyTask(_: ?*anyopaque) callconv(.c) void {
    var counter: u32 = 0;
    while (true) {
        if (notifications_enabled and conn_id != 0) {
            var buf: [4]u8 = undefined;
            std.mem.writeInt(u32, &buf, counter, .little);
            
            // Send notification (false = no confirmation needed)
            bt.GattServer.sendIndicate(
                gatts_if,
                conn_id,
                char_handle,
                &buf,
                false,  // notification, not indication
            ) catch |err| {
                std.log.err("Send failed: {s}", .{@errorName(err)});
            };
        }
        counter += 1;
        idf.rtos.Task.delay(1000 / portTICK_PERIOD_MS); // 1 second
    }
}

Create this task during initialization:

_ = idf.rtos.Task.create(
    &notifyTask,
    "notify",
    3 * 1024,
    null,
    5
) catch return;

Complete BLE Peripheral Example

Below is a compact skeleton combining initialization, GAP advertising, GATT service creation, and notification handling. This serves as a starting point for any BLE peripheral written in Zig.

const std = @import("std");
const idf = @import("esp_idf");
const sys = idf.sys;
const bt = idf.bt;

// Global state for connection management
var gatts_if: sys.esp_gatt_if_t = 0;
var conn_id: u16 = 0;
var char_handle: u16 = 0;
var cccd_handle: u16 = 0;
var notifications_enabled: bool = false;

pub fn app_main() callconv(.c) void {
    // 1️⃣ NVS init (required for controller calibration)
    idf.nvs.flashInitOrErase() catch |err| {
        std.log.err("NVS init failed: {s}", .{@errorName(err)});
        return;
    };

    // 2️⃣ BLE stack init (BLE-only mode)
    bt.bleInit() catch |err| {
        std.log.err("BLE init failed: {s}", .{@errorName(err)});
        return;
    };

    // 3️⃣ Register callbacks
    bt.Gap.registerCallback(&gapCb) catch return;
    bt.GattServer.registerCallback(&gattsCb) catch return;

    // 4️⃣ Register GATT app (triggers ESP_GATTS_REG_EVT)
    bt.GattServer.appRegister(0) catch return;

    // 5️⃣ Start notification task
    _ = idf.rtos.Task.create(&notifyTask, "notify", 3 * 1024, null, 5) catch {};
}

// GAP callback - handles advertising events
fn gapCb(event: sys.esp_gap_ble_cb_event_t, param: *sys.esp_ble_gap_cb_param_t) callconv(.c) void {
    switch (event) {
        .ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT => {
            // Start advertising with configured parameters
            var adv_params = sys.esp_ble_adv_params_t{
                .adv_int_min = 0x0020,
                .adv_int_max = 0x0040,
                .adv_type = sys.ADV_TYPE_IND,
                .own_addr_type = sys.BLE_ADDR_TYPE_PUBLIC,
                .peer_addr = .{ .addr = [6]u8{0} },
                .peer_addr_type = sys.BLE_ADDR_TYPE_PUBLIC,
                .channel_map = sys.ADV_CHNL_ALL,
                .adv_filter_policy = sys.ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
            };
            bt.Gap.startAdvertising(&adv_params) catch {};
        },
        else => {},
    }
}

// GATT Server callback - handles service and characteristic events
fn gattsCb(event: sys.esp_gatts_cb_event_t, gatts_if_in: sys.esp_gatt_if_t, param: *sys.esp_ble_gatts_cb_param_t) callconv(.c) void {
    switch (event) {
        .ESP_GATTS_REG_EVT => {
            gatts_if = gatts_if_in;
            // Create service with UUID 0x00FF
            var srvc_id = sys.esp_gatt_srvc_id_t{
                .is_primary = true,
                .id = .{
                    .inst_id = 0,
                    .uuid = .{
                        .len = sys.ESP_UUID_LEN_16,
                        .uuid = .{ .uuid16 = 0x00FF },
                    },
                },
            };
            bt.GattServer.createService(gatts_if, &srvc_id, 4) catch {};
        },
        .ESP_GATTS_CREATE_EVT => {
            const service_handle = param.create.service_handle;
            // Add read+notify characteristic (UUID 0xFF01)
            bt.GattServer.addChar(
                service_handle,
                &(.{ .len = sys.ESP_UUID_LEN_16, .uuid = .{ .uuid16 = 0xFF01 } }),
                sys.ESP_GATT_PERM_READ,
                sys.ESP_GATT_CHAR_PROP_BIT_READ | sys.ESP_GATT_CHAR_PROP_BIT_NOTIFY,
                null,
                null,
            ) catch {};
        },
        .ESP_GATTS_ADD_CHAR_EVT => {
            char_handle = param.add_char.attr_handle;
        },
        .ESP_GATTS_ADD_CHAR_DESCR_EVT => {
            cccd_handle = param.add_char_descr.attr_handle;
        },
        .ESP_GATTS_CONNECT_EVT => {
            conn_id = param.connect.conn_id;
        },
        .ESP_GATTS_DISCONNECT_EVT => {
            conn_id = 0;
            notifications_enabled = false;
            // Restart advertising
            bt.Gap.startAdvertising(&adv_params) catch {};
        },
        .ESP_GATTS_WRITE_EVT => {
            if (param.write.handle == cccd_handle and param.write.len == 2) {
                const cccd_value = std.mem.readInt(u16, param.write.value[0..2], .little);
                notifications_enabled = (cccd_value == 0x0001);
            }
        },
        else => {},
    }
}

// Notification task
fn notifyTask(_: ?*anyopaque) callconv(.c) void {
    var counter: u32 = 0;
    while (true) {
        if (notifications_enabled and conn_id != 0) {
            var buf: [4]u8 = undefined;
            std.mem.writeInt(u32, &buf, counter, .little);
            
            // Send notification (false = no confirmation required)
            bt.GattServer.sendIndicate(
                gatts_if,
                conn_id,
                char_handle,
                &buf,
                false,
            ) catch |err| {
                std.log.err("Send failed: {s}", .{@errorName(err)});
            };
        }
        counter +%= 1;
        idf.rtos.Task.delay(1000 / idf.portTICK_PERIOD_MS);
    }
}

Summary

  • imports/bluetooth.zig exposes the full ESP-IDF Bluetooth API as a clean Zig module with type-safe wrappers for controller, Bluedroid, GAP, and GATT operations.
  • BLE initialization is simplified to a single bt.bleInit() call that handles controller configuration, Classic-BT memory release, and Bluedroid start-up in the correct order.
  • GAP operations such as Gap.registerCallback, Gap.configAdvData, and Gap.startAdvertising map 1-to-1 to their C counterparts with Zig error handling.
  • GATT Server operations including GattServer.createService, GattServer.addChar, and GattServer.sendIndicate provide full peripheral functionality with support for notifications and indications.
  • The BLE GATT Server example in main/examples/ble-gatt-server.zig demonstrates a complete peripheral implementation including advertising, service creation, CCCD handling for notification enablement, and periodic data transmission via a FreeRTOS task.

Frequently Asked Questions

How do I initialize BLE in Zig with ESP-IDF?

First call idf.nvs.flashInitOrErase() to initialize NVS flash, then call bt.bleInit() from imports/bluetooth.zig. This convenience function releases Classic Bluetooth memory, initializes the controller with default configuration, enables BLE mode, and starts the Bluedroid host stack in the correct sequence.

What is the difference between notifications and indications in the Zig ESP-IDF wrapper?

Both use the bt.GattServer.sendIndicate method, but the final boolean parameter determines the type. Pass false for a notification (no confirmation required from the client) or true for an indication (requires client confirmation). The example in ble-gatt-server.zig uses false for unconfirmed periodic counter updates.

Can I use NimBLE instead of Bluedroid with this Zig wrapper?

Yes, the repository includes imports/nimble.zig as an optional alternative to the Bluedroid-based imports/bluetooth.zig. NimBLE provides a lower-memory footprint BLE stack suitable for resource-constrained applications, though the current examples primarily demonstrate the Bluedroid API.

How do I handle BLE connection and disconnection events?

Register a GATT server callback using bt.GattServer.registerCallback, then handle ESP_GATTS_CONNECT_EVT to store the conn_id for subsequent operations, and ESP_GATTS_DISCONNECT_EVT to clear the connection state and restart advertising via bt.Gap.startAdvertising to allow new connections.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →