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

> Implement Bluetooth or BLE in Zig with ESP-IDF using this guide. The kassane/zig-esp-idf-sample repo offers Zig APIs to initialize BLE, manage advertising, and handle GATT server operations.

- Repository: [Matheus C. França/zig-esp-idf-sample](https://github.com/kassane/zig-esp-idf-sample)
- Tags: how-to-guide
- Published: 2026-03-05

---

**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:

```zig
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:

```zig
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:

```zig
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()`:

```zig
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:

```zig
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:

```zig
// 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:

```zig
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:

```zig
// 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:

```zig
// 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:

```zig
// 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:

```zig
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:

```zig
_ = 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.

```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.