<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ zephyr - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ zephyr - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 24 Jun 2026 15:09:39 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/zephyr/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build Bluetooth Applications with Zephyr OS: A Handbook for Devs ]]>
                </title>
                <description>
                    <![CDATA[ Your phone just connected to wireless earbuds, your smartwatch synced health data to an app, and a sensor somewhere in your building reported its temperature to a gateway. All of those interactions ha ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-bluetooth-applications-with-zephyr-os-a-handbook-for-devs/</link>
                <guid isPermaLink="false">6a1de78e328352c4a380157a</guid>
                
                    <category>
                        <![CDATA[ bluetooth ]]>
                    </category>
                
                    <category>
                        <![CDATA[ zephyr ]]>
                    </category>
                
                    <category>
                        <![CDATA[ embedded ]]>
                    </category>
                
                    <category>
                        <![CDATA[ RTOS ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikheel Vishwas Savant ]]>
                </dc:creator>
                <pubDate>Mon, 01 Jun 2026 20:11:58 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/2915a74f-5ad2-4e92-b1e6-6516ce9f0ca0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your phone just connected to wireless earbuds, your smartwatch synced health data to an app, and a sensor somewhere in your building reported its temperature to a gateway. All of those interactions happened over Bluetooth Low Energy (BLE). And increasingly, the firmware behind those devices is built on Zephyr OS.</p>
<p>This handbook teaches you how to build Bluetooth applications on Zephyr from the ground up. You'll start with the fundamentals of BLE (what GAP, GATT, services, and characteristics actually mean), then move into writing real Zephyr firmware: advertising a device, creating custom services, handling connections, reading sensor data over BLE, and building a complete BLE peripheral that a phone can talk to.</p>
<p>Every concept comes with working code, and every code block comes with an explanation of what it does and why it matters.</p>
<p>This is a long, detailed guide. Bluetooth has a lot of moving parts, and most tutorials gloss over the pieces that trip people up in real projects.</p>
<p>This one does not. Work through it sequentially, build the code as you go, and by the end you'll have the knowledge to build production BLE devices on Zephyr.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-zephyr-os-and-why-use-it-for-bluetooth">What Is Zephyr OS (And Why Use It for Bluetooth)</a>?</p>
</li>
<li><p><a href="#heading-bluetooth-low-energy-fundamentals">Bluetooth Low Energy Fundamentals</a></p>
</li>
<li><p><a href="#heading-the-gap-layer-advertising-and-connections">The GAP Layer: Advertising and Connections</a></p>
</li>
<li><p><a href="#heading-the-gatt-layer-services-and-characteristics">The GATT Layer: Services and Characteristics</a></p>
</li>
<li><p><a href="#heading-setting-up-your-zephyr-development-environment">Setting Up Your Zephyr Development Environment</a></p>
</li>
<li><p><a href="#heading-your-first-ble-application-a-simple-beacon">Your First BLE Application: A Simple Beacon</a></p>
</li>
<li><p><a href="#heading-building-a-ble-peripheral-with-a-custom-service">Building a BLE Peripheral with a Custom Service</a></p>
</li>
<li><p><a href="#heading-handling-connections-and-connection-callbacks">Handling Connections and Connection Callbacks</a></p>
</li>
<li><p><a href="#heading-adding-write-support-receiving-data-from-a-phone">Adding Write Support: Receiving Data from a Phone</a></p>
</li>
<li><p><a href="#heading-notifications-pushing-data-to-a-connected-device">Notifications: Pushing Data to a Connected Device</a></p>
</li>
<li><p><a href="#heading-building-a-complete-ble-sensor-node">Building a Complete BLE Sensor Node</a></p>
</li>
<li><p><a href="#heading-pairing-and-security">Pairing and Security</a></p>
</li>
<li><p><a href="#heading-implementing-a-standard-ble-profile-heart-rate">Implementing a Standard BLE Profile (Heart Rate)</a></p>
</li>
<li><p><a href="#heading-building-a-ble-central">Building a BLE Central</a></p>
</li>
<li><p><a href="#heading-mtu-negotiation-and-data-throughput">MTU Negotiation and Data Throughput</a></p>
</li>
<li><p><a href="#heading-phy-selection-for-range-and-speed">PHY Selection for Range and Speed</a></p>
</li>
<li><p><a href="#heading-firmware-updates-over-ble">Firmware Updates Over BLE</a></p>
</li>
<li><p><a href="#heading-bluetooth-mesh-on-zephyr">Bluetooth Mesh on Zephyr</a></p>
</li>
<li><p><a href="#heading-le-audio-the-next-generation-of-bluetooth-audio">LE Audio: The Next Generation of Bluetooth Audio</a></p>
</li>
<li><p><a href="#heading-debugging-bluetooth-applications">Debugging Bluetooth Applications</a></p>
</li>
<li><p><a href="#heading-power-optimization-for-ble-devices">Power Optimization for BLE Devices</a></p>
</li>
<li><p><a href="#heading-zephyr-bluetooth-vs-other-stacks">Zephyr Bluetooth vs Other Stacks</a></p>
</li>
<li><p><a href="#heading-where-to-go-from-here">Where to Go from Here</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along with this tutorial, you should be comfortable reading and writing C code. Pointers, structs, function pointers, and callbacks should not be foreign concepts. You need a command-line terminal and basic familiarity with building C projects. Prior Bluetooth experience isn't required. This article explains BLE concepts from scratch.</p>
<p>For hardware, a Nordic Semiconductor nRF52840 DK is the ideal board for following along. Nordic's chips have the best-supported Bluetooth stack in Zephyr, and the nRF52840 DK is affordable (around $40), widely available, and includes an onboard debugger.</p>
<p>If you have a different Zephyr-supported board with Bluetooth (nRF52832 DK, nRF5340 DK, or any board with a BLE-capable radio), that works too.</p>
<p>For testing the BLE side, you'll need a phone with a BLE scanner app (nRF Connect for Mobile is excellent and free on both iOS and Android).</p>
<p>You'll also need a computer running Linux (Ubuntu 22.04 or newer), macOS, or Windows with WSL2.</p>
<h2 id="heading-what-is-zephyr-os-and-why-use-it-for-bluetooth">What Is Zephyr OS (And Why Use It for Bluetooth)?</h2>
<p>Zephyr OS is a small, open-source, real-time operating system built for resource-constrained embedded devices. It's hosted by the Linux Foundation, licensed under Apache 2.0, and runs on microcontrollers with as little as 8 KB of RAM. It supports over 600 boards across ARM, RISC-V, x86, Xtensa, and other architectures.</p>
<p>But this article is about Bluetooth, so here's why Zephyr matters specifically for BLE development.</p>
<p>Zephyr includes a full, Bluetooth SIG-qualified BLE stack. That means the stack has been through the official Bluetooth qualification process and meets the specification requirements. You aren't building on a hobby implementation. You're building on a stack that has passed conformance testing.</p>
<p>The stack covers both the host (GAP, GATT, SMP, L2CAP, ATT) and the controller (Link Layer, HCI). For supported radios (primarily Nordic nRF series), Zephyr provides its own open-source controller implementation. This means the entire Bluetooth stack, from your application code down to the radio registers, is open source. No binary blobs. No closed-source libraries. You can read, debug, and modify every line.</p>
<p>Nordic Semiconductor, the company whose chips dominate the BLE market, built their nRF Connect SDK on top of Zephyr. When Nordic's own engineers write BLE firmware, they use Zephyr. That's a strong endorsement.</p>
<p>The Zephyr Bluetooth stack supports Bluetooth 5.x features (2M PHY, Coded PHY for long range, Extended Advertising), Bluetooth Mesh (relay, proxy, friend, and low-power nodes), LE Audio (the newest Bluetooth audio standard with LC3 codec, broadcast, and hearing aid support), Direction Finding (Angle of Arrival and Angle of Departure for indoor positioning), and all the standard BLE profiles and services you need for product development.</p>
<p>In short: if you're building a BLE product these days, Zephyr is one of the best platforms available. Google uses it in Chromebooks, Nordic uses it for their entire SDK, and hundreds of companies ship products built on it.</p>
<h2 id="heading-bluetooth-low-energy-fundamentals">Bluetooth Low Energy Fundamentals</h2>
<p>Before you write any code, you need a mental model of how BLE works. Skip this section if you already know BLE well. Read it carefully if you don't, because everything that follows builds on these concepts.</p>
<p>BLE is not the same as "classic" Bluetooth (the kind used for audio streaming to speakers before LE Audio). Classic Bluetooth (BR/EDR) is designed for continuous, high-throughput data streaming. BLE is designed for intermittent, low-power communication.</p>
<p>A BLE sensor might wake up once per minute, send 20 bytes of data, and go back to sleep. That interaction consumes microwatts of energy. This fundamental design difference is why BLE devices can run on a coin cell battery for years.</p>
<p>BLE communication is organized into two main layers that you interact with as a developer: GAP and GATT.</p>
<p><strong>GAP (Generic Access Profile)</strong> controls how devices discover each other and establish connections. Think of GAP as the "meeting people at a party" layer.</p>
<p>A device can be in one of several GAP roles. A <strong>peripheral</strong> (also called an advertiser) broadcasts small packets of data at regular intervals, announcing its presence and basic information. A <strong>central</strong> (also called a scanner) listens for these broadcasts and can initiate a connection. Your phone is typically a central. A sensor, earbud, or smartwatch is typically a peripheral.</p>
<p><strong>GATT (Generic Attribute Profile)</strong> controls how data is exchanged after two devices are connected. Think of GATT as the "having a conversation" layer.</p>
<p>GATT defines a hierarchical data structure. At the top level, a device exposes one or more <strong>services</strong>. A service groups related data together.</p>
<p>For example, a heart rate monitor might have a Heart Rate Service. Inside each service are one or more <strong>characteristics</strong>. A characteristic is a single data point with a value and metadata. The Heart Rate Service might have a Heart Rate Measurement characteristic (the actual BPM value) and a Body Sensor Location characteristic (where the sensor is worn).</p>
<p>Each characteristic has <strong>properties</strong> that define what you can do with it. A characteristic can be readable (a central can request its value), writable (a central can set its value), notifiable (the peripheral can push updates to the central without being asked), or indicatable (like notify but with acknowledgment). A characteristic can have multiple properties.</p>
<p>Every service and characteristic is identified by a <strong>UUID</strong>. The Bluetooth SIG defines standard 16-bit UUIDs for common services and characteristics (Heart Rate is 0x180D, Battery Service is 0x180F, and so on). For custom functionality, you define your own 128-bit UUIDs.</p>
<p>Here's a concrete mental model. Imagine a temperature sensor device:</p>
<pre><code class="language-plaintext">Device: "My Temp Sensor"
  |
  +-- Environmental Sensing Service (UUID: 0x181A)
  |     |
  |     +-- Temperature characteristic (UUID: 0x2A6E)
  |     |     Properties: Read, Notify
  |     |     Value: 23.5 (degrees C)
  |     |
  |     +-- Humidity characteristic (UUID: 0x2A6F)
  |           Properties: Read, Notify
  |           Value: 65.2 (percent)
  |
  +-- Battery Service (UUID: 0x180F)
        |
        +-- Battery Level characteristic (UUID: 0x2A19)
              Properties: Read, Notify
              Value: 87 (percent)
</code></pre>
<p>This diagram shows a device with two services and three characteristics. A connected phone could read the temperature, subscribe to humidity notifications, and check the battery level. The services and characteristics are the API of your BLE device.</p>
<p>Designing them well is like designing a good REST API: think about what data your device exposes and how clients will interact with it.</p>
<p>One more concept: <strong>advertising data</strong>. When a peripheral advertises, it broadcasts small packets (up to 31 bytes in legacy advertising, larger with Extended Advertising). These packets contain structured data like the device name, supported services, manufacturer-specific data, and flags. The advertising data is what a scanner sees before making a connection. It is your device's "business card."</p>
<h2 id="heading-the-gap-layer-advertising-and-connections">The GAP Layer: Advertising and Connections</h2>
<p>GAP is the first layer you interact with when building a BLE device. Your peripheral needs to advertise its presence before anything else can happen.</p>
<p>Advertising works like this: the peripheral's radio wakes up at regular intervals (the "advertising interval"), transmits a short packet on one of three advertising channels (channels 37, 38, and 39), and goes back to sleep. A central scanning those channels picks up the packet and learns about the device.</p>
<p>The advertising interval is a tradeoff: shorter intervals (like 20 ms) make the device easier to discover but consume more power. Longer intervals (like 1000 ms) save power but make discovery slower. For most applications, 100 to 500 ms is a reasonable range.</p>
<p>An advertising packet contains structured fields called AD (Advertising Data) structures. Each AD structure has a length byte, a type byte, and data bytes. Common types include flags (indicating discoverability and BR/EDR support), complete or shortened local name, list of service UUIDs, TX power level, and manufacturer-specific data.</p>
<p>The total payload is limited to 31 bytes for legacy advertising (Bluetooth 4.x), so you can't fit much. You often have to choose between including the device name or the service UUID, because both might not fit.</p>
<p>Bluetooth 5.0 introduced Extended Advertising, which allows advertising payloads up to 254 bytes per fragment (chained into even larger payloads), advertising on all 40 BLE channels (not just the three advertising channels), and multiple simultaneous advertising sets. Zephyr supports Extended Advertising when the hardware and controller support it.</p>
<p>When a central decides to connect, it sends a connection request to the peripheral. The two devices negotiate connection parameters: the <strong>connection interval</strong> (how often they communicate, typically 7.5 ms to 4 seconds), the <strong>peripheral latency</strong> (how many connection events the peripheral can skip to save power), and the <strong>supervision timeout</strong> (how long to wait before considering the connection lost).</p>
<p>These parameters affect both data throughput and power consumption. A short connection interval gives you fast data transfer but costs more power. A high peripheral latency saves power but adds latency to data exchange.</p>
<h2 id="heading-the-gatt-layer-services-and-characteristics">The GATT Layer: Services and Characteristics</h2>
<p>Once a connection is established, GATT takes over. The connected devices exchange data through the service/characteristic hierarchy described earlier.</p>
<p>On the peripheral side, you define the GATT database: the list of services and characteristics your device exposes. On the central side, you perform service discovery: querying the peripheral for its available services and characteristics.</p>
<p>Each characteristic in the GATT database has several components. The <strong>value</strong> is the actual data (a byte array). The <strong>properties</strong> define permitted operations: Read (0x02), Write Without Response (0x04), Write (0x08), Notify (0x10), and Indicate (0x20) are the most common. The <strong>permissions</strong> are security requirements: whether reading/writing requires encryption, authentication, or authorization.</p>
<p>For notifications and indications, the characteristic has a <strong>Client Characteristic Configuration Descriptor (CCCD)</strong>. This is a special 2-byte value that the central writes to enable or disable notifications/indications. When the central writes 0x0001 to the CCCD, notifications are enabled. When it writes 0x0000, they're disabled. Zephyr handles the CCCD automatically when you define a characteristic with the notify property.</p>
<p>The GATT operations flow like this.</p>
<ul>
<li><p>For a read: the central sends a read request, the peripheral responds with the characteristic value.</p>
</li>
<li><p>For a write: the central sends a write request with the new value, the peripheral updates the value and sends a response.</p>
</li>
<li><p>For a notification: the peripheral sends the value to the central without the central asking. The central must have previously enabled notifications on that characteristic.</p>
</li>
</ul>
<p>This request/response model means that BLE isn't a streaming protocol. It's a message-passing protocol. If you need to send continuous sensor data, you use notifications at a fixed interval. If you need to configure the device, you write to a characteristic. This shapes how you design your BLE application.</p>
<h2 id="heading-setting-up-your-zephyr-development-environment">Setting Up Your Zephyr Development Environment</h2>
<p>This section walks through a complete environment setup. If you already have a Zephyr environment, skip to the next section.</p>
<p>Install system dependencies (Ubuntu):</p>
<pre><code class="language-shell">sudo apt update
sudo apt install --no-install-recommends git cmake ninja-build gperf \
  ccache dfu-util device-tree-compiler wget python3-dev python3-pip \
  python3-setuptools python3-tk python3-wheel xz-utils file \
  make gcc gcc-multilib g++-multilib libsdl2-dev libmagic1
</code></pre>
<p>These packages provide the compiler toolchain, build systems, and utilities that Zephyr's build process requires.</p>
<p>The devicetree compiler (<code>device-tree-compiler</code>) is particularly important for Zephyr because it processes the hardware description files that tell the build system about your board's peripherals and pin assignments.</p>
<p>Install west, the Zephyr command-line tool:</p>
<pre><code class="language-shell">pip3 install west
</code></pre>
<p>West manages the multi-repository workspace that Zephyr uses, and provides commands for building, flashing, and debugging firmware. It's the single tool you interact with most.</p>
<p>Initialize and update the workspace:</p>
<pre><code class="language-shell">west init ~/zephyrproject
cd ~/zephyrproject
west update
</code></pre>
<p>The <code>west init</code> command creates a workspace and clones the main Zephyr repository. The <code>west update</code> command then fetches all module dependencies: vendor HALs (the low-level chip support packages), cryptography libraries, the Bluetooth controller code, and other components. This downloads several gigabytes, so it takes a while.</p>
<p>Install Python requirements:</p>
<pre><code class="language-shell">pip3 install -r ~/zephyrproject/zephyr/scripts/requirements.txt
</code></pre>
<p>This installs the Python packages that Zephyr's build scripts and west extensions depend on, including the devicetree processing tools, the Kconfig frontend, and various code generation utilities. The requirements file pins specific versions to ensure build reproducibility.</p>
<p>Install the Zephyr SDK (provides cross-compilation toolchains for all supported architectures):</p>
<pre><code class="language-shell">cd ~
wget https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v0.16.8/zephyr-sdk-0.16.8_linux-x86_64.tar.xz
tar xvf zephyr-sdk-0.16.8_linux-x86_64.tar.xz
cd zephyr-sdk-0.16.8
./setup.sh
</code></pre>
<p>The SDK includes GCC-based toolchains for ARM, RISC-V, x86, Xtensa, and more. The <code>setup.sh</code> script registers them with CMake. Check the Zephyr releases page for the latest SDK version number, as it may have been updated since this handbook was written.</p>
<p>Set environment variables (add these to your <code>~/.bashrc</code> or <code>~/.zshrc</code>):</p>
<pre><code class="language-shell">export ZEPHYR_BASE=~/zephyrproject/zephyr
source ~/zephyrproject/zephyr/zephyr-env.sh
</code></pre>
<p>The <code>ZEPHYR_BASE</code> variable tells the build system where the Zephyr source tree lives. The <code>zephyr-env.sh</code> script sets up additional paths. With both configured, you can build for any supported board from any directory.</p>
<h2 id="heading-your-first-ble-application-a-simple-beacon">Your First BLE Application: A Simple Beacon</h2>
<p>We'll start with the simplest possible BLE application: a device that advertises but does nothing else. No connections, no services, no data exchange. Just a beacon broadcasting its existence.</p>
<p>Create the project structure:</p>
<pre><code class="language-shell">mkdir -p ~/my_ble_apps/beacon/src
</code></pre>
<p>This creates the standard Zephyr application directory layout. Every Zephyr application lives in its own directory with a <code>src/</code> subdirectory for C source files. The project root holds the build configuration files.</p>
<p>Create <code>~/my_ble_apps/beacon/CMakeLists.txt</code>:</p>
<pre><code class="language-plaintext">cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(ble_beacon)
target_sources(app PRIVATE src/main.c)
</code></pre>
<p>The <code>find_package(Zephyr)</code> line loads the entire Zephyr build system. The <code>target_sources</code> line adds your source file to the build target named <code>app</code>, which is the standard target name for Zephyr applications.</p>
<p>Create <code>~/my_ble_apps/beacon/prj.conf</code>:</p>
<pre><code class="language-plaintext">CONFIG_BT=y
CONFIG_BT_BROADCASTER=y
</code></pre>
<p>Two configuration lines: <code>CONFIG_BT=y</code> enables the Bluetooth subsystem, pulling in the host stack, HCI layer, and (on supported boards) the controller. <code>CONFIG_BT_BROADCASTER=y</code> enables the broadcaster role, which is the minimal role for a device that only advertises. You don't need the peripheral role yet because this beacon doesn't accept connections.</p>
<p>Create <code>~/my_ble_apps/beacon/src/main.c</code>:</p>
<pre><code class="language-c">#include &lt;zephyr/kernel.h&gt;
#include &lt;zephyr/bluetooth/bluetooth.h&gt;

static const struct bt_data ad[] = {
    BT_DATA_BYTES(BT_DATA_FLAGS, BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR),
    BT_DATA_BYTES(BT_DATA_NAME_COMPLETE,
                  'M', 'y', 'B', 'e', 'a', 'c', 'o', 'n'),
};

int main(void)
{
    int err;

    printk("Starting BLE Beacon\n");

    err = bt_enable(NULL);
    if (err) {
        printk("Bluetooth init failed (err %d)\n", err);
        return 0;
    }

    printk("Bluetooth initialized\n");

    err = bt_le_adv_start(BT_LE_ADV_NCONN, ad, ARRAY_SIZE(ad), NULL, 0);
    if (err) {
        printk("Advertising failed to start (err %d)\n", err);
        return 0;
    }

    printk("Beacon is advertising\n");

    return 0;
}
</code></pre>
<p>Let's walk through this code piece by piece.</p>
<p>The <code>ad</code> array defines the advertising data. It contains two AD structures.</p>
<p>The first is the Flags field, which is mandatory in BLE advertising. <code>BT_LE_AD_GENERAL</code> means the device is in General Discoverable mode (visible to all scanners). <code>BT_LE_AD_NO_BREDR</code> indicates that the device doesn't support classic Bluetooth (BR/EDR), only BLE.</p>
<p>The second AD structure is the complete local name, spelled out character by character. The <code>BT_DATA_BYTES</code> macro packs these into the correct AD structure format.</p>
<p>The <code>bt_enable(NULL)</code> call initializes the entire Bluetooth subsystem. This sets up the HCI transport, initializes the controller (if using an onboard radio), and prepares the host stack. The <code>NULL</code> argument means this is a synchronous call: it blocks until initialization is complete. You could pass a callback function to make it asynchronous.</p>
<p>The <code>bt_le_adv_start</code> call begins advertising. The first argument, <code>BT_LE_ADV_NCONN</code>, specifies non-connectable advertising. This means scanners can see the beacon but cannot connect to it. The <code>ad</code> array and its size (<code>ARRAY_SIZE(ad)</code>) provide the advertising data. The last two arguments (<code>NULL, 0</code>) are for scan response data, which is additional data sent when a scanner actively scans (sends a scan request). You aren't using scan response data in this simple example.</p>
<p>After <code>main()</code> returns, the Zephyr main thread terminates, but the system keeps running. The Bluetooth subsystem continues advertising in the background, driven by the controller and the Bluetooth host thread.</p>
<p>Build and flash:</p>
<pre><code class="language-shell">cd ~/zephyrproject
west build -b nrf52840dk/nrf52840 ~/my_ble_apps/beacon
west flash
</code></pre>
<p>The <code>west build</code> command compiles the application for the nRF52840 DK, producing an ELF binary and a HEX file in the <code>build/</code> directory. The <code>west flash</code> command programs that binary onto the board via the onboard J-Link debugger, automatically detecting the connected board and using the correct programming protocol.</p>
<p>Open the nRF Connect app on your phone, start scanning, and you should see "MyBeacon" appear in the list of discovered devices. That's your firmware, running on your board, advertising over BLE.</p>
<h2 id="heading-building-a-ble-peripheral-with-a-custom-service">Building a BLE Peripheral with a Custom Service</h2>
<p>A beacon that broadcasts is useful for some applications (iBeacon, Eddystone, asset tracking). But most BLE devices need to be connectable and expose data through GATT services. Now you'll build a peripheral with a custom service.</p>
<p>You'll create a "LED Service" that lets a phone control an LED on your board and read a button state. This is a classic BLE demo that teaches you the patterns you'll use in every BLE project.</p>
<p>Create the project:</p>
<pre><code class="language-shell">mkdir -p ~/my_ble_apps/led_service/src
</code></pre>
<p>Same project layout as the beacon: a root directory for build configuration and a <code>src/</code> directory for source code. This separation keeps build artifacts isolated from your source files.</p>
<p>Create <code>~/my_ble_apps/led_service/prj.conf</code>:</p>
<pre><code class="language-plaintext">CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="Zephyr LED"
CONFIG_BT_DEVICE_APPEARANCE=0
CONFIG_GPIO=y
CONFIG_BT_GATT_DYNAMIC_DB=y
</code></pre>
<p><code>CONFIG_BT_PERIPHERAL=y</code> enables the peripheral role, which includes the broadcaster role plus the ability to accept connections. <code>CONFIG_BT_DEVICE_NAME</code> sets the default device name used in advertising and the GAP Device Name characteristic. <code>CONFIG_GPIO=y</code> enables the GPIO driver so you can control the LED and read the button.</p>
<p>Create <code>~/my_ble_apps/led_service/CMakeLists.txt</code>:</p>
<pre><code class="language-cmake">cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(ble_led_service)
target_sources(app PRIVATE src/main.c)
</code></pre>
<p>This is the same CMake boilerplate from the beacon project. The only change is the project name (<code>ble_led_service</code>). The <code>find_package(Zephyr)</code> call loads the full Zephyr build system, and <code>target_sources</code> registers your application source file.</p>
<p>Create <code>~/my_ble_apps/led_service/src/main.c</code>:</p>
<pre><code class="language-c">#include &lt;zephyr/kernel.h&gt;
#include &lt;zephyr/bluetooth/bluetooth.h&gt;
#include &lt;zephyr/bluetooth/gatt.h&gt;
#include &lt;zephyr/bluetooth/uuid.h&gt;
#include &lt;zephyr/drivers/gpio.h&gt;

/* Custom service UUID: 00001234-0000-1000-8000-00805f9b34fb */
#define BT_UUID_LED_SERVICE_VAL \
    BT_UUID_128_ENCODE(0x00001234, 0x0000, 0x1000, 0x8000, 0x00805f9b34fb)
#define BT_UUID_LED_SERVICE BT_UUID_DECLARE_128(BT_UUID_LED_SERVICE_VAL)

/* LED characteristic UUID */
#define BT_UUID_LED_CHAR_VAL \
    BT_UUID_128_ENCODE(0x00001235, 0x0000, 0x1000, 0x8000, 0x00805f9b34fb)
#define BT_UUID_LED_CHAR BT_UUID_DECLARE_128(BT_UUID_LED_CHAR_VAL)

/* Button characteristic UUID */
#define BT_UUID_BUTTON_CHAR_VAL \
    BT_UUID_128_ENCODE(0x00001236, 0x0000, 0x1000, 0x8000, 0x00805f9b34fb)
#define BT_UUID_BUTTON_CHAR BT_UUID_DECLARE_128(BT_UUID_BUTTON_CHAR_VAL)

#define LED0_NODE DT_ALIAS(led0)
#define SW0_NODE  DT_ALIAS(sw0)

static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
static const struct gpio_dt_spec button = GPIO_DT_SPEC_GET(SW0_NODE, gpios);

static uint8_t led_state;
static uint8_t button_state;

static ssize_t read_led(struct bt_conn *conn,
                        const struct bt_gatt_attr *attr,
                        void *buf, uint16_t len, uint16_t offset)
{
    return bt_gatt_attr_read(conn, attr, buf, len, offset,
                             &amp;led_state, sizeof(led_state));
}

static ssize_t write_led(struct bt_conn *conn,
                         const struct bt_gatt_attr *attr,
                         const void *buf, uint16_t len,
                         uint16_t offset, uint8_t flags)
{
    if (len != 1) {
        return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
    }

    led_state = *((const uint8_t *)buf);
    gpio_pin_set_dt(&amp;led, led_state ? 1 : 0);

    printk("LED %s\n", led_state ? "ON" : "OFF");

    return len;
}

static ssize_t read_button(struct bt_conn *conn,
                           const struct bt_gatt_attr *attr,
                           void *buf, uint16_t len, uint16_t offset)
{
    button_state = gpio_pin_get_dt(&amp;button);
    return bt_gatt_attr_read(conn, attr, buf, len, offset,
                             &amp;button_state, sizeof(button_state));
}

BT_GATT_SERVICE_DEFINE(led_service,
    BT_GATT_PRIMARY_SERVICE(BT_UUID_LED_SERVICE),

    BT_GATT_CHARACTERISTIC(BT_UUID_LED_CHAR,
        BT_GATT_CHRC_READ | BT_GATT_CHRC_WRITE,
        BT_GATT_PERM_READ | BT_GATT_PERM_WRITE,
        read_led, write_led, NULL),

    BT_GATT_CHARACTERISTIC(BT_UUID_BUTTON_CHAR,
        BT_GATT_CHRC_READ,
        BT_GATT_PERM_READ,
        read_button, NULL, NULL),
);

static const struct bt_data ad[] = {
    BT_DATA_BYTES(BT_DATA_FLAGS, BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR),
    BT_DATA(BT_DATA_NAME_COMPLETE, CONFIG_BT_DEVICE_NAME,
            sizeof(CONFIG_BT_DEVICE_NAME) - 1),
};

static const struct bt_data sd[] = {
    BT_DATA_BYTES(BT_DATA_UUID128_ALL, BT_UUID_LED_SERVICE_VAL),
};

int main(void)
{
    int err;

    if (!gpio_is_ready_dt(&amp;led) || !gpio_is_ready_dt(&amp;button)) {
        printk("GPIO devices not ready\n");
        return 0;
    }

    gpio_pin_configure_dt(&amp;led, GPIO_OUTPUT_INACTIVE);
    gpio_pin_configure_dt(&amp;button, GPIO_INPUT);

    err = bt_enable(NULL);
    if (err) {
        printk("Bluetooth init failed (err %d)\n", err);
        return 0;
    }

    printk("Bluetooth initialized\n");

    err = bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad),
                          sd, ARRAY_SIZE(sd));
    if (err) {
        printk("Advertising failed to start (err %d)\n", err);
        return 0;
    }

    printk("Advertising as '%s'\n", CONFIG_BT_DEVICE_NAME);

    return 0;
}
</code></pre>
<p>This is a substantial piece of code, so let's go through it section by section.</p>
<p>The UUID definitions at the top create custom 128-bit UUIDs for the service and its two characteristics. When you build a custom BLE device, you generate your own UUIDs rather than using the standard Bluetooth SIG UUIDs (unless you're implementing a standard profile like Heart Rate).</p>
<p>In production, you would generate random 128-bit UUIDs using a tool like <code>uuidgen</code>. The UUIDs here are simple for readability. The <code>BT_UUID_128_ENCODE</code> macro formats the UUID in the byte order that the Bluetooth stack expects. <code>BT_UUID_DECLARE_128</code> creates a <code>struct bt_uuid_128</code> from the encoded value.</p>
<p>The <code>led</code> and <code>button</code> GPIO specs use the devicetree aliases <code>led0</code> and <code>sw0</code>, which most Zephyr boards define. The <code>GPIO_DT_SPEC_GET</code> macro pulls the pin number, GPIO controller, and flags directly from the devicetree, keeping the code portable across boards.</p>
<p>The <code>read_led</code> callback is called when a connected central reads the LED characteristic. It uses <code>bt_gatt_attr_read</code>, a helper function that handles offset and length correctly (GATT reads can be partial if the value is larger than the MTU). The function returns the current LED state as a single byte.</p>
<p>The <code>write_led</code> callback is called when a central writes to the LED characteristic. It validates that exactly one byte was written, updates the <code>led_state</code> variable, and calls <code>gpio_pin_set_dt</code> to physically toggle the LED. It returns the number of bytes consumed (<code>len</code>), or a GATT error if the length was wrong. The <code>BT_GATT_ERR</code> macro wraps an ATT error code into the return value that the stack expects.</p>
<p>The <code>read_button</code> callback reads the current physical button state at the moment of the read request. It calls <code>gpio_pin_get_dt</code> to sample the pin, stores the result, and returns it.</p>
<p>The <code>BT_GATT_SERVICE_DEFINE</code> macro is where the GATT database is constructed at compile time. The first argument names the service variable. <code>BT_GATT_PRIMARY_SERVICE</code> declares a primary service with the custom UUID. Each <code>BT_GATT_CHARACTERISTIC</code> declaration takes the characteristic UUID, the properties (Read + Write for the LED, Read-only for the button), the permissions (who can read/write), the read callback, the write callback, and a pointer to user data (NULL in both cases here).</p>
<p>The <code>ad</code> array contains the advertising data: flags and the device name. The <code>sd</code> array contains the scan response data: the 128-bit service UUID.</p>
<p>Splitting data between advertising and scan response is common because the 31-byte advertising packet limit is tight. The service UUID alone is 16 bytes, which would leave little room for other data in the main advertising packet. By putting the UUID in the scan response, you keep the advertising packet small and include the UUID only when a scanner explicitly requests it.</p>
<p>The <code>bt_le_adv_start</code> call uses <code>BT_LE_ADV_CONN</code> instead of <code>BT_LE_ADV_NCONN</code>. This makes the advertising connectable: scanners can now establish a connection to your device. The <code>sd</code> and <code>ARRAY_SIZE(sd)</code> arguments provide the scan response data that was NULL in the beacon example.</p>
<p>Build, flash, and test:</p>
<pre><code class="language-bash">cd ~/zephyrproject
west build -b nrf52840dk/nrf52840 ~/my_ble_apps/led_service
west flash
</code></pre>
<p>The build command compiles the application with the full Bluetooth stack, GPIO drivers, and GATT database linked into a single binary. The flash command programs it onto the board. Because <code>CONFIG_BT_PERIPHERAL</code> is enabled, the binary is significantly larger than the beacon (the connectable advertising, GATT server, and ATT protocol layers are all included).</p>
<p>Open nRF Connect on your phone, scan, and find "Zephyr LED." Tap Connect. After connecting, you'll see the GATT services. Find the custom service (UUID starting with 00001234). You'll see two characteristics. Read the button characteristic to see the button state. Write 0x01 to the LED characteristic to turn on the LED, and 0x00 to turn it off. You just controlled hardware over Bluetooth from your phone.</p>
<h2 id="heading-handling-connections-and-connection-callbacks">Handling Connections and Connection Callbacks</h2>
<p>In a real application, you need to know when devices connect and disconnect. Maybe you want to stop advertising when a device connects (to save power), restart advertising when it disconnects (to allow reconnection), or update a status LED to indicate connection state.</p>
<p>Zephyr provides connection callbacks through a registration mechanism:</p>
<pre><code class="language-c">#include &lt;zephyr/bluetooth/conn.h&gt;

static void connected(struct bt_conn *conn, uint8_t err)
{
    if (err) {
        printk("Connection failed (err %u)\n", err);
        return;
    }

    char addr[BT_ADDR_LE_STR_LEN];
    bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
    printk("Connected: %s\n", addr);
}

static void disconnected(struct bt_conn *conn, uint8_t reason)
{
    char addr[BT_ADDR_LE_STR_LEN];
    bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
    printk("Disconnected: %s (reason %u)\n", addr, reason);

    /* Restart advertising after disconnect */
    bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad),
                    sd, ARRAY_SIZE(sd));
}

BT_CONN_CB_DEFINE(conn_callbacks) = {
    .connected = connected,
    .disconnected = disconnected,
};
</code></pre>
<p>The <code>BT_CONN_CB_DEFINE</code> macro statically registers a set of connection callbacks. The <code>.connected</code> callback fires when a connection is established. The <code>err</code> parameter indicates whether the connection was successful (0 means success). The <code>.disconnected</code> callback fires when a connection ends. The <code>reason</code> parameter is an HCI disconnect reason code (0x13 is "Remote User Terminated Connection," which is the normal disconnect).</p>
<p>In the <code>connected</code> callback, the code retrieves the remote device's Bluetooth address using <code>bt_conn_get_dst</code> and converts it to a printable string. This is useful for logging and debugging.</p>
<p>In the <code>disconnected</code> callback, the code restarts advertising. By default, Zephyr stops advertising when a connection is established (because the radio is now used for the connection). When the connection drops, you typically want to start advertising again so the device can be rediscovered.</p>
<p>The <code>bt_conn</code> pointer represents the connection. You can use it to query connection parameters, request parameter updates, initiate pairing, or disconnect programmatically. Hold a reference to it (using <code>bt_conn_ref</code>) if you need to use it outside the callback. Release the reference (using <code>bt_conn_unref</code>) when you're done.</p>
<h2 id="heading-adding-write-support-receiving-data-from-a-phone">Adding Write Support: Receiving Data from a Phone</h2>
<p>The LED service already has a write characteristic, but let's look more closely at the write callback pattern and how to handle more complex data.</p>
<p>Consider a scenario where the phone sends a configuration struct to the device:</p>
<pre><code class="language-c">struct device_config {
    uint8_t mode;
    uint16_t interval_ms;
    uint8_t threshold;
} __packed;

static struct device_config current_config = {
    .mode = 0,
    .interval_ms = 1000,
    .threshold = 50,
};

static ssize_t write_config(struct bt_conn *conn,
                            const struct bt_gatt_attr *attr,
                            const void *buf, uint16_t len,
                            uint16_t offset, uint8_t flags)
{
    if (offset != 0) {
        return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET);
    }

    if (len != sizeof(struct device_config)) {
        return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
    }

    memcpy(&amp;current_config, buf, len);

    printk("Config updated: mode=%u, interval=%u ms, threshold=%u\n",
           current_config.mode,
           current_config.interval_ms,
           current_config.threshold);

    return len;
}

static ssize_t read_config(struct bt_conn *conn,
                           const struct bt_gatt_attr *attr,
                           void *buf, uint16_t len, uint16_t offset)
{
    return bt_gatt_attr_read(conn, attr, buf, len, offset,
                             &amp;current_config, sizeof(current_config));
}
</code></pre>
<p>The <code>__packed</code> attribute on the struct ensures that there is no padding between fields, so the byte layout matches what the phone sends. Without <code>__packed</code>, the compiler might insert padding bytes between <code>mode</code> and <code>interval_ms</code> for alignment, and the data wouldn't match.</p>
<p>The write callback validates two things. First, it checks that the offset is zero (no partial writes for this simple case). Second, it checks that the length matches the expected struct size. If either check fails, it returns a GATT error code that the central receives as a write response error.</p>
<p>Validating input is critical in BLE applications because the central is sending raw bytes over the air. Malformed data should be rejected, not blindly accepted.</p>
<p>The <code>memcpy</code> copies the validated data into the configuration struct. In a real application, you would likely apply the new configuration to your device's behavior (change a sensor polling interval, switch operating modes, and so on).</p>
<p>The <code>flags</code> parameter in the write callback indicates whether this is a Write With Response (the central expects an acknowledgment) or Write Without Response (fire-and-forget). You can check <code>flags &amp; BT_GATT_WRITE_FLAG_CMD</code> to distinguish the two. For configuration data, you typically want Write With Response so the central knows the write succeeded.</p>
<h2 id="heading-notifications-pushing-data-to-a-connected-device">Notifications: Pushing Data to a Connected Device</h2>
<p>Reading and writing work for on-demand data. But many BLE applications need the peripheral to push data to the central proactively. A heart rate monitor doesn't wait for the phone to ask for the heart rate every second. It pushes the value via notifications.</p>
<p>Here's how to add notification support to a characteristic:</p>
<pre><code class="language-c">static uint8_t sensor_value;
static bool notifications_enabled;

static void sensor_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
    notifications_enabled = (value == BT_GATT_CCC_NOTIFY);
    printk("Notifications %s\n", notifications_enabled ? "enabled" : "disabled");
}

static ssize_t read_sensor(struct bt_conn *conn,
                           const struct bt_gatt_attr *attr,
                           void *buf, uint16_t len, uint16_t offset)
{
    return bt_gatt_attr_read(conn, attr, buf, len, offset,
                             &amp;sensor_value, sizeof(sensor_value));
}

BT_GATT_SERVICE_DEFINE(sensor_service,
    BT_GATT_PRIMARY_SERVICE(BT_UUID_LED_SERVICE),

    BT_GATT_CHARACTERISTIC(BT_UUID_LED_CHAR,
        BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY,
        BT_GATT_PERM_READ,
        read_sensor, NULL, &amp;sensor_value),

    BT_GATT_CCC(sensor_ccc_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
);
</code></pre>
<p>The characteristic now has <code>BT_GATT_CHRC_NOTIFY</code> in its properties, which tells connected centrals that this characteristic supports notifications.</p>
<p>The <code>BT_GATT_CCC</code> macro adds the Client Characteristic Configuration Descriptor (CCCD). The <code>sensor_ccc_changed</code> callback is called when a central enables or disables notifications by writing to the CCCD. The <code>value</code> parameter will be <code>BT_GATT_CCC_NOTIFY</code> (0x0001) when notifications are enabled and 0 when disabled.</p>
<p>To actually send a notification:</p>
<pre><code class="language-c">void send_sensor_notification(void)
{
    if (!notifications_enabled) {
        return;
    }

    sensor_value = read_actual_sensor();

    int err = bt_gatt_notify(NULL, &amp;sensor_service.attrs[1],
                             &amp;sensor_value, sizeof(sensor_value));
    if (err) {
        printk("Notify failed (err %d)\n", err);
    }
}
</code></pre>
<p>The <code>bt_gatt_notify</code> function sends a notification to all connected centrals that have enabled notifications on this characteristic.</p>
<p>The first argument is <code>NULL</code> to notify all connections (you can pass a specific <code>bt_conn</code> pointer to notify only one). The second argument is a pointer to the characteristic attribute in the GATT table. The <code>&amp;sensor_service.attrs[1]</code> points to the first characteristic value attribute (index 0 is the service declaration, index 1 is the characteristic declaration, and the value follows). The third and fourth arguments are the data and its length.</p>
<p>A common pattern is to call this function from a timer or a work queue at a regular interval:</p>
<pre><code class="language-c">void sensor_work_handler(struct k_work *work)
{
    send_sensor_notification();
}

K_WORK_DELAYABLE_DEFINE(sensor_work, sensor_work_handler);

/* In main(), after Bluetooth is initialized and advertising: */
k_work_schedule(&amp;sensor_work, K_SECONDS(1));

/* In the work handler, reschedule for periodic execution: */
void sensor_work_handler(struct k_work *work)
{
    send_sensor_notification();
    k_work_schedule(&amp;sensor_work, K_SECONDS(1));
}
</code></pre>
<p>This approach uses a delayable work item that reschedules itself every second. Each time it fires, it reads the sensor value and sends a notification (if notifications are enabled). The work item runs on the system work queue thread, not in interrupt context, so it's safe to call <code>bt_gatt_notify</code> and other Bluetooth APIs.</p>
<h2 id="heading-building-a-complete-ble-sensor-node">Building a Complete BLE Sensor Node</h2>
<p>Now we'll tie everything together into a complete application. This is a BLE environmental sensor that reads temperature (simulated), exposes it through a custom GATT service with read and notify support, handles connections and disconnections, and manages advertising.</p>
<p>Create <code>~/my_ble_apps/sensor_node/src/main.c</code>:</p>
<pre><code class="language-c">#include &lt;zephyr/kernel.h&gt;
#include &lt;zephyr/bluetooth/bluetooth.h&gt;
#include &lt;zephyr/bluetooth/gatt.h&gt;
#include &lt;zephyr/bluetooth/uuid.h&gt;
#include &lt;zephyr/bluetooth/conn.h&gt;
#include &lt;zephyr/drivers/gpio.h&gt;

/* UUIDs */
#define BT_UUID_ENV_SERVICE_VAL \
    BT_UUID_128_ENCODE(0xaabbccdd, 0x0000, 0x1000, 0x8000, 0x00805f9b34fb)
#define BT_UUID_ENV_SERVICE BT_UUID_DECLARE_128(BT_UUID_ENV_SERVICE_VAL)

#define BT_UUID_TEMP_CHAR_VAL \
    BT_UUID_128_ENCODE(0xaabbccdd, 0x0001, 0x1000, 0x8000, 0x00805f9b34fb)
#define BT_UUID_TEMP_CHAR BT_UUID_DECLARE_128(BT_UUID_TEMP_CHAR_VAL)

#define BT_UUID_INTERVAL_CHAR_VAL \
    BT_UUID_128_ENCODE(0xaabbccdd, 0x0002, 0x1000, 0x8000, 0x00805f9b34fb)
#define BT_UUID_INTERVAL_CHAR BT_UUID_DECLARE_128(BT_UUID_INTERVAL_CHAR_VAL)

/* LED for connection status */
#define LED0_NODE DT_ALIAS(led0)
static const struct gpio_dt_spec status_led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);

/* Sensor state */
static int16_t temperature_value = 2250;
static uint16_t notify_interval_ms = 1000;
static bool temp_notifications_enabled;
static struct bt_conn *current_conn;

/* Forward declaration */
static void sensor_work_handler(struct k_work *work);
K_WORK_DELAYABLE_DEFINE(sensor_work, sensor_work_handler);

static int16_t simulate_temperature(void)
{
    static int16_t base = 2250;
    base += (k_uptime_get_32() % 11) - 5;
    if (base &gt; 3500) base = 3500;
    if (base &lt; 1000) base = 1000;
    return base;
}

/* GATT callbacks */
static ssize_t read_temperature(struct bt_conn *conn,
                                const struct bt_gatt_attr *attr,
                                void *buf, uint16_t len, uint16_t offset)
{
    temperature_value = simulate_temperature();
    return bt_gatt_attr_read(conn, attr, buf, len, offset,
                             &amp;temperature_value, sizeof(temperature_value));
}

static void temp_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
    temp_notifications_enabled = (value == BT_GATT_CCC_NOTIFY);
    printk("Temperature notifications %s\n",
           temp_notifications_enabled ? "enabled" : "disabled");

    if (temp_notifications_enabled) {
        k_work_schedule(&amp;sensor_work, K_MSEC(notify_interval_ms));
    } else {
        k_work_cancel_delayable(&amp;sensor_work);
    }
}

static ssize_t read_interval(struct bt_conn *conn,
                             const struct bt_gatt_attr *attr,
                             void *buf, uint16_t len, uint16_t offset)
{
    return bt_gatt_attr_read(conn, attr, buf, len, offset,
                             &amp;notify_interval_ms, sizeof(notify_interval_ms));
}

static ssize_t write_interval(struct bt_conn *conn,
                              const struct bt_gatt_attr *attr,
                              const void *buf, uint16_t len,
                              uint16_t offset, uint8_t flags)
{
    if (len != sizeof(uint16_t)) {
        return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
    }

    uint16_t new_interval = *((const uint16_t *)buf);

    if (new_interval &lt; 100 || new_interval &gt; 60000) {
        return BT_GATT_ERR(BT_ATT_ERR_VALUE_NOT_ALLOWED);
    }

    notify_interval_ms = new_interval;
    printk("Notification interval changed to %u ms\n", notify_interval_ms);

    if (temp_notifications_enabled) {
        k_work_cancel_delayable(&amp;sensor_work);
        k_work_schedule(&amp;sensor_work, K_MSEC(notify_interval_ms));
    }

    return len;
}

/* GATT service definition */
BT_GATT_SERVICE_DEFINE(env_service,
    BT_GATT_PRIMARY_SERVICE(BT_UUID_ENV_SERVICE),

    BT_GATT_CHARACTERISTIC(BT_UUID_TEMP_CHAR,
        BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY,
        BT_GATT_PERM_READ,
        read_temperature, NULL, NULL),
    BT_GATT_CCC(temp_ccc_changed,
        BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),

    BT_GATT_CHARACTERISTIC(BT_UUID_INTERVAL_CHAR,
        BT_GATT_CHRC_READ | BT_GATT_CHRC_WRITE,
        BT_GATT_PERM_READ | BT_GATT_PERM_WRITE,
        read_interval, write_interval, NULL),
);

/* Notification sender */
static void sensor_work_handler(struct k_work *work)
{
    temperature_value = simulate_temperature();

    if (temp_notifications_enabled) {
        int err = bt_gatt_notify(NULL, &amp;env_service.attrs[2],
                                 &amp;temperature_value,
                                 sizeof(temperature_value));
        if (err &amp;&amp; err != -ENOTCONN) {
            printk("Notify error: %d\n", err);
        }

        k_work_schedule(&amp;sensor_work, K_MSEC(notify_interval_ms));
    }
}

/* Advertising data */
static const struct bt_data ad[] = {
    BT_DATA_BYTES(BT_DATA_FLAGS, BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR),
    BT_DATA(BT_DATA_NAME_COMPLETE, CONFIG_BT_DEVICE_NAME,
            sizeof(CONFIG_BT_DEVICE_NAME) - 1),
};

static const struct bt_data sd[] = {
    BT_DATA_BYTES(BT_DATA_UUID128_ALL, BT_UUID_ENV_SERVICE_VAL),
};

/* Connection callbacks */
static void connected(struct bt_conn *conn, uint8_t err)
{
    if (err) {
        printk("Connection failed (err %u)\n", err);
        return;
    }

    current_conn = bt_conn_ref(conn);
    gpio_pin_set_dt(&amp;status_led, 1);

    char addr[BT_ADDR_LE_STR_LEN];
    bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
    printk("Connected: %s\n", addr);
}

static void disconnected(struct bt_conn *conn, uint8_t reason)
{
    char addr[BT_ADDR_LE_STR_LEN];
    bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
    printk("Disconnected: %s (reason %u)\n", addr, reason);

    if (current_conn) {
        bt_conn_unref(current_conn);
        current_conn = NULL;
    }

    temp_notifications_enabled = false;
    k_work_cancel_delayable(&amp;sensor_work);
    gpio_pin_set_dt(&amp;status_led, 0);

    bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
}

BT_CONN_CB_DEFINE(conn_callbacks) = {
    .connected = connected,
    .disconnected = disconnected,
};

int main(void)
{
    int err;

    if (!gpio_is_ready_dt(&amp;status_led)) {
        printk("LED not ready\n");
        return 0;
    }
    gpio_pin_configure_dt(&amp;status_led, GPIO_OUTPUT_INACTIVE);

    err = bt_enable(NULL);
    if (err) {
        printk("Bluetooth init failed (err %d)\n", err);
        return 0;
    }

    printk("Bluetooth initialized\n");

    err = bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad),
                          sd, ARRAY_SIZE(sd));
    if (err) {
        printk("Advertising failed (err %d)\n", err);
        return 0;
    }

    printk("Environmental sensor ready. Advertising as '%s'\n",
           CONFIG_BT_DEVICE_NAME);

    return 0;
}
</code></pre>
<p>The <code>prj.conf</code> for this application:</p>
<pre><code class="language-plaintext">CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="Zephyr Sensor"
CONFIG_BT_GATT_DYNAMIC_DB=y
CONFIG_GPIO=y
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048
</code></pre>
<p>This application demonstrates the full lifecycle of a BLE sensor device, so study the design carefully.</p>
<p>The service exposes two characteristics. The temperature characteristic supports read and notify. When a central reads it, it gets the latest simulated temperature. When the central enables notifications, the device starts pushing temperature updates at a configurable interval.</p>
<p>The interval characteristic lets the central read and write the notification interval, bounded between 100 ms and 60000 ms. The validation in <code>write_interval</code> rejects values outside this range with <code>BT_ATT_ERR_VALUE_NOT_ALLOWED</code>, which the central receives as an error response.</p>
<p>Temperature values are stored as <code>int16_t</code> in hundredths of a degree Celsius (2250 = 22.50 degrees C). This fixed-point representation avoids floating-point math, which is expensive on microcontrollers without an FPU, and gives you 0.01 degree resolution in a 2-byte value.</p>
<p>The connection callbacks manage the full connection lifecycle. On connect, the code takes a reference to the connection object (<code>bt_conn_ref</code>) and turns on the status LED. On disconnect, it releases the reference (<code>bt_conn_unref</code>), cancels any pending notification work, turns off the LED, and restarts advertising.</p>
<p>The reference counting is important because the <code>bt_conn</code> pointer is only valid while you hold a reference. Using it after the reference is released leads to undefined behavior.</p>
<p>The notification work item reschedules itself at the configured interval, creating a periodic loop. When notifications are disabled (either explicitly by the central or implicitly by disconnection), the work is cancelled. This prevents wasted CPU cycles (and battery) when nobody is listening.</p>
<h2 id="heading-pairing-and-security">Pairing and Security</h2>
<p>Production BLE devices almost always need security. Without pairing, any device within radio range can connect and interact with your GATT services. Pairing establishes an encrypted link and optionally authenticates the devices to each other.</p>
<p>BLE supports several pairing methods. "Just Works" provides encryption but no authentication. It protects against passive eavesdropping but not against active man-in-the-middle attacks. "Passkey Entry" requires the user to enter a 6-digit code, providing authentication. "Numeric Comparison" displays a number on both devices and the user confirms they match. "Out of Band (OOB)" uses an external channel (like NFC) to exchange pairing information.</p>
<p>Enable security in your <code>prj.conf</code>:</p>
<pre><code class="language-plaintext">CONFIG_BT_SMP=y
CONFIG_BT_SETTINGS=y
CONFIG_FLASH=y
CONFIG_FLASH_MAP=y
CONFIG_NVS=y
CONFIG_SETTINGS=y
</code></pre>
<p><code>CONFIG_BT_SMP=y</code> enables the Security Manager Protocol, which handles pairing. The Settings, Flash, and NVS options enable persistent storage so that bonding information (the keys exchanged during pairing) survives reboots. Without persistent storage, the device would need to re-pair after every power cycle, which is a terrible user experience.</p>
<p>To require encryption on a characteristic, change its permissions:</p>
<pre><code class="language-c">BT_GATT_CHARACTERISTIC(BT_UUID_TEMP_CHAR,
    BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY,
    BT_GATT_PERM_READ_ENCRYPT,
    read_temperature, NULL, NULL),
</code></pre>
<p><code>BT_GATT_PERM_READ_ENCRYPT</code> means the characteristic can only be read over an encrypted connection. If a central tries to read it without pairing first, the stack automatically triggers pairing. You can also use <code>BT_GATT_PERM_READ_AUTHEN</code> to require authenticated pairing (Passkey or Numeric Comparison, not Just Works).</p>
<p>Register authentication callbacks to handle passkey display or input:</p>
<pre><code class="language-c">static void auth_passkey_display(struct bt_conn *conn, unsigned int passkey)
{
    char addr[BT_ADDR_LE_STR_LEN];
    bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
    printk("Passkey for %s: %06u\n", addr, passkey);
}

static void auth_cancel(struct bt_conn *conn)
{
    char addr[BT_ADDR_LE_STR_LEN];
    bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
    printk("Pairing cancelled: %s\n", addr);
}

static struct bt_conn_auth_cb auth_callbacks = {
    .passkey_display = auth_passkey_display,
    .cancel = auth_cancel,
};

/* In main(), after bt_enable(): */
bt_conn_auth_cb_register(&amp;auth_callbacks);
</code></pre>
<p>The <code>passkey_display</code> callback fires when the stack generates a passkey that the user needs to enter on the central (phone). On a device with a display, you would show the passkey on screen. On a device without a display (like a sensor), you might print it to a serial console during development or use Just Works pairing in production.</p>
<p>The <code>bt_conn_auth_cb_register</code> function registers these callbacks with the stack. Only one set of callbacks can be active at a time.</p>
<h2 id="heading-implementing-a-standard-ble-profile-heart-rate">Implementing a Standard BLE Profile (Heart Rate)</h2>
<p>The examples so far have used custom 128-bit UUIDs. In production, many BLE devices implement standard profiles defined by the Bluetooth SIG. Standard profiles use 16-bit UUIDs, which consume less advertising space and allow generic apps (like nRF Connect) to automatically parse and display the data in a human-readable format. The Heart Rate Profile is one of the most common and illustrates how standard profiles work in Zephyr.</p>
<p>The Heart Rate Service (UUID 0x180D) contains a Heart Rate Measurement characteristic (UUID 0x2A37) that uses notifications to push heart rate data. The measurement characteristic has a specific byte format defined by the Bluetooth SIG: the first byte is a flags field, and the remaining bytes contain the heart rate value and optional fields like energy expended and RR-interval.</p>
<pre><code class="language-c">#include &lt;zephyr/kernel.h&gt;
#include &lt;zephyr/bluetooth/bluetooth.h&gt;
#include &lt;zephyr/bluetooth/gatt.h&gt;
#include &lt;zephyr/bluetooth/uuid.h&gt;
#include &lt;zephyr/bluetooth/conn.h&gt;

static uint8_t heart_rate_bpm = 72;
static bool hr_notifications_enabled;

static void hr_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
    hr_notifications_enabled = (value == BT_GATT_CCC_NOTIFY);
}

static ssize_t read_body_sensor_location(struct bt_conn *conn,
                                         const struct bt_gatt_attr *attr,
                                         void *buf, uint16_t len,
                                         uint16_t offset)
{
    uint8_t location = 0x01; /* Chest */
    return bt_gatt_attr_read(conn, attr, buf, len, offset,
                             &amp;location, sizeof(location));
}

BT_GATT_SERVICE_DEFINE(hr_service,
    BT_GATT_PRIMARY_SERVICE(BT_UUID_HRS),

    BT_GATT_CHARACTERISTIC(BT_UUID_HRS_MEASUREMENT,
        BT_GATT_CHRC_NOTIFY,
        BT_GATT_PERM_NONE,
        NULL, NULL, NULL),
    BT_GATT_CCC(hr_ccc_changed,
        BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),

    BT_GATT_CHARACTERISTIC(BT_UUID_HRS_BODY_SENSOR,
        BT_GATT_CHRC_READ,
        BT_GATT_PERM_READ,
        read_body_sensor_location, NULL, NULL),
);

static void send_heart_rate(void)
{
    if (!hr_notifications_enabled) {
        return;
    }

    uint8_t hr_data[2];
    hr_data[0] = 0x00; /* Flags: uint8 format, no extra fields */
    hr_data[1] = heart_rate_bpm;

    bt_gatt_notify(NULL, &amp;hr_service.attrs[1], hr_data, sizeof(hr_data));
}
</code></pre>
<p>This code uses Zephyr's predefined UUID macros (<code>BT_UUID_HRS</code>, <code>BT_UUID_HRS_MEASUREMENT</code>, <code>BT_UUID_HRS_BODY_SENSOR</code>) instead of custom 128-bit UUIDs. Zephyr defines macros for all standard Bluetooth SIG services and characteristics in <code>zephyr/bluetooth/uuid.h</code>. Using these standard UUIDs means that any BLE heart rate app on a phone can automatically discover, connect, and display data from your device without custom app development.</p>
<p>The Heart Rate Measurement characteristic has <code>BT_GATT_PERM_NONE</code> for permissions because it's notify-only. No read or write access is permitted: the data flows exclusively through notifications.</p>
<p>The first byte of the notification payload (<code>hr_data[0]</code>) is a flags field defined by the SIG specification. A value of 0x00 means the heart rate is in uint8 format (values 0 to 255 BPM) with no optional fields present. Setting bit 0 would switch to uint16 format for heart rates above 255. Setting other bits would indicate the presence of energy expended or RR-interval data.</p>
<p>The Body Sensor Location characteristic is a simple read-only value. The value 0x01 means "Chest." Other defined values include 0x00 (Other), 0x02 (Wrist), 0x03 (Finger), 0x04 (Hand), 0x05 (Ear Lobe), and 0x06 (Foot).</p>
<p>The <code>prj.conf</code> for a standard profile application doesn't need any special configuration beyond the basic Bluetooth peripheral setup:</p>
<pre><code class="language-plaintext">CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="Zephyr HR"
</code></pre>
<p>The standard UUIDs are always available when <code>CONFIG_BT=y</code> is set. No additional Kconfig options are needed to use SIG-defined service and characteristic UUIDs.</p>
<p>The advertising data for a standard profile device typically includes the 16-bit service UUID in the advertising packet, which allows phones to filter scans by service type:</p>
<pre><code class="language-c">static const struct bt_data ad[] = {
    BT_DATA_BYTES(BT_DATA_FLAGS, BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR),
    BT_DATA_BYTES(BT_DATA_UUID16_ALL, BT_UUID_16_ENCODE(0x180D)),
    BT_DATA(BT_DATA_NAME_COMPLETE, CONFIG_BT_DEVICE_NAME,
            sizeof(CONFIG_BT_DEVICE_NAME) - 1),
};
</code></pre>
<p>The <code>BT_DATA_UUID16_ALL</code> type advertises the complete list of 16-bit service UUIDs. The <code>BT_UUID_16_ENCODE(0x180D)</code> macro encodes the Heart Rate Service UUID in little-endian byte order. A 16-bit UUID takes only 2 bytes in the advertising packet (compared to 16 bytes for a 128-bit UUID), leaving much more room for other advertising data. This is a significant advantage of using standard profiles.</p>
<h2 id="heading-building-a-ble-central">Building a BLE Central</h2>
<p>Every example so far has built a peripheral (a device that advertises and accepts connections). The other side of a BLE connection is the central: a device that scans for peripherals, initiates connections, and reads/writes characteristics. Gateways, hubs, and data collectors are typically centrals.</p>
<p>Building a central on Zephyr requires a second board, but understanding the central role is essential for building complete BLE systems.</p>
<p>The <code>prj.conf</code> for a central application:</p>
<pre><code class="language-plaintext">CONFIG_BT=y
CONFIG_BT_CENTRAL=y
CONFIG_BT_GATT_CLIENT=y
CONFIG_BT_SCAN=y
CONFIG_BT_DEVICE_NAME="Zephyr Central"
</code></pre>
<p><code>CONFIG_BT_CENTRAL=y</code> enables the central role (scanning and connection initiation). <code>CONFIG_BT_GATT_CLIENT=y</code> enables the GATT client APIs for service discovery, reading, writing, and subscribing. <code>CONFIG_BT_SCAN=y</code> enables the scan module, which provides a higher-level scanning API with filtering.</p>
<p>A BLE central starts by scanning for peripherals:</p>
<pre><code class="language-c">#include &lt;zephyr/kernel.h&gt;
#include &lt;zephyr/bluetooth/bluetooth.h&gt;
#include &lt;zephyr/bluetooth/conn.h&gt;
#include &lt;zephyr/bluetooth/gatt.h&gt;
#include &lt;zephyr/bluetooth/uuid.h&gt;

static struct bt_conn *default_conn;

static void device_found(const bt_addr_le_t *addr, int8_t rssi,
                          uint8_t type, struct net_buf_simple *ad)
{
    char addr_str[BT_ADDR_LE_STR_LEN];
    bt_addr_le_to_str(addr, addr_str, sizeof(addr_str));

    if (rssi &lt; -70) {
        return; /* Skip distant devices */
    }

    printk("Found device: %s (RSSI %d)\n", addr_str, rssi);

    /* Stop scanning before connecting */
    bt_le_scan_stop();

    int err = bt_conn_le_create(addr, BT_CONN_LE_CREATE_CONN,
                                BT_LE_CONN_PARAM_DEFAULT,
                                &amp;default_conn);
    if (err) {
        printk("Connection failed (err %d)\n", err);
        bt_le_scan_start(BT_LE_SCAN_ACTIVE, device_found);
    }
}

int main(void)
{
    int err;

    err = bt_enable(NULL);
    if (err) {
        printk("Bluetooth init failed (err %d)\n", err);
        return 0;
    }

    printk("Starting BLE scan...\n");

    err = bt_le_scan_start(BT_LE_SCAN_ACTIVE, device_found);
    if (err) {
        printk("Scan failed (err %d)\n", err);
        return 0;
    }

    return 0;
}
</code></pre>
<p>The <code>bt_le_scan_start</code> function begins scanning for BLE advertisements. The first argument, <code>BT_LE_SCAN_ACTIVE</code>, enables active scanning, which means the scanner sends scan request packets to advertisers to get their scan response data (passive scanning only listens). The second argument is a callback function that's called for every advertising packet received.</p>
<p>The <code>device_found</code> callback receives the advertiser's address, RSSI (signal strength in dBm), advertising type, and the raw advertising data.</p>
<p>In this example, the code filters by RSSI to ignore distant devices, then attempts to connect to the first device it finds. The <code>bt_le_scan_stop</code> call is necessary before initiating a connection because the radio can't scan and connect simultaneously.</p>
<p>The <code>bt_conn_le_create</code> function initiates a connection. It takes the target address, creation parameters (which control the scan window used during connection establishment), connection parameters (interval, latency, timeout), and a pointer to store the connection reference. <code>BT_LE_CONN_PARAM_DEFAULT</code> uses the default connection parameters, which are suitable for most applications.</p>
<p>In a real application, you would filter the scan results more carefully, typically by checking the advertised service UUIDs or device name. After connecting, you would perform GATT service discovery and then read/write/subscribe to characteristics:</p>
<pre><code class="language-c">static uint8_t discover_func(struct bt_conn *conn,
                              const struct bt_gatt_attr *attr,
                              struct bt_gatt_discover_params *params)
{
    if (!attr) {
        printk("Discovery complete\n");
        return BT_GATT_ITER_STOP;
    }

    char uuid_str[BT_UUID_STR_LEN];
    bt_uuid_to_str(params-&gt;uuid, uuid_str, sizeof(uuid_str));
    printk("Discovered attribute: handle %u, UUID %s\n",
           attr-&gt;handle, uuid_str);

    return BT_GATT_ITER_CONTINUE;
}

static struct bt_gatt_discover_params discover_params;

static void start_discovery(struct bt_conn *conn)
{
    discover_params.uuid = NULL; /* Discover all services */
    discover_params.func = discover_func;
    discover_params.start_handle = BT_ATT_FIRST_ATTRIBUTE_HANDLE;
    discover_params.end_handle = BT_ATT_LAST_ATTRIBUTE_HANDLE;
    discover_params.type = BT_GATT_DISCOVER_PRIMARY;

    int err = bt_gatt_discover(conn, &amp;discover_params);
    if (err) {
        printk("Discovery failed (err %d)\n", err);
    }
}
</code></pre>
<p>The <code>bt_gatt_discover</code> function initiates GATT service discovery on the connected peripheral. The <code>discover_params</code> structure controls what to discover: setting <code>uuid</code> to NULL discovers all primary services. Setting <code>type</code> to <code>BT_GATT_DISCOVER_PRIMARY</code> discovers primary services. You can also set it to <code>BT_GATT_DISCOVER_CHARACTERISTIC</code> to discover characteristics within a service, or <code>BT_GATT_DISCOVER_DESCRIPTOR</code> to discover descriptors within a characteristic.</p>
<p>The discovery callback (<code>discover_func</code>) is called once for each discovered attribute and once more with <code>attr</code> set to NULL when discovery is complete. Returning <code>BT_GATT_ITER_CONTINUE</code> tells the stack to continue discovering. Returning <code>BT_GATT_ITER_STOP</code> stops discovery early.</p>
<p>After discovering the services and characteristics, you read a characteristic value using <code>bt_gatt_read</code> and subscribe to notifications using <code>bt_gatt_subscribe</code>. The subscribe function takes a <code>bt_gatt_subscribe_params</code> structure that specifies the characteristic handle, a notification callback function, and the CCC value to write (BT_GATT_CCC_NOTIFY).</p>
<h2 id="heading-mtu-negotiation-and-data-throughput">MTU Negotiation and Data Throughput</h2>
<p>The default BLE ATT MTU (Maximum Transmission Unit) is 23 bytes. Subtract 3 bytes of ATT protocol overhead, and you're left with 20 bytes of actual payload per GATT operation. For a single temperature reading, 20 bytes is plenty. For transferring a firmware image or a large sensor data buffer, 20 bytes per operation is painfully slow.</p>
<p>MTU negotiation allows two connected devices to agree on a larger MTU, up to 517 bytes (the BLE maximum). A larger MTU means more data per packet, fewer round-trips, and higher throughput. On an nRF52840 with 2M PHY, the difference between a 23-byte MTU and a 247-byte MTU can be 5x to 10x throughput improvement.</p>
<p>Enable a larger MTU in your <code>prj.conf</code>:</p>
<pre><code class="language-plaintext">CONFIG_BT_L2CAP_TX_MTU=247
CONFIG_BT_BUF_ACL_RX_SIZE=251
CONFIG_BT_BUF_ACL_TX_SIZE=251
CONFIG_BT_CTLR_DATA_LENGTH_MAX=251
</code></pre>
<p><code>CONFIG_BT_L2CAP_TX_MTU=247</code> sets the maximum ATT MTU that the device will request during negotiation. The value 247 is commonly used because it aligns with the maximum single-packet data length (251 bytes at the Link Layer minus 4 bytes of L2CAP header).</p>
<p><code>CONFIG_BT_BUF_ACL_RX_SIZE</code> and <code>CONFIG_BT_BUF_ACL_TX_SIZE</code> set the ACL buffer sizes to accommodate the larger packets. <code>CONFIG_BT_CTLR_DATA_LENGTH_MAX</code> enables Data Length Extension (DLE) at the controller level, allowing the Link Layer to send longer packets instead of fragmenting them into 27-byte chunks.</p>
<p>MTU negotiation happens automatically after a connection is established. The Zephyr stack initiates an MTU exchange on connection if the configured MTU is larger than the default. You can also trigger it explicitly:</p>
<pre><code class="language-c">static void exchange_func(struct bt_conn *conn, uint8_t att_err,
                           struct bt_gatt_exchange_params *params)
{
    if (att_err) {
        printk("MTU exchange failed (err %u)\n", att_err);
        return;
    }

    uint16_t mtu = bt_gatt_get_mtu(conn);
    printk("MTU exchanged: %u bytes\n", mtu);
}

static struct bt_gatt_exchange_params exchange_params = {
    .func = exchange_func,
};

static void connected(struct bt_conn *conn, uint8_t err)
{
    if (err) {
        return;
    }

    bt_gatt_exchange_mtu(conn, &amp;exchange_params);
}
</code></pre>
<p>The <code>bt_gatt_exchange_mtu</code> function sends an MTU exchange request to the remote device. The callback receives the result. After a successful exchange, <code>bt_gatt_get_mtu</code> returns the negotiated MTU, which is the minimum of both devices' supported MTU values. The effective payload per notification or write operation is the negotiated MTU minus 3 bytes of ATT header.</p>
<p>When sending large amounts of data over notifications, the effective throughput depends on three factors: the MTU (larger means fewer packets), the connection interval (shorter means more transmission opportunities), and the PHY (2M PHY doubles the raw data rate compared to 1M PHY).</p>
<p>With a 247-byte MTU, a 7.5 ms connection interval, and 2M PHY, you can achieve throughput in the range of 800 kbps to 1400 kbps, depending on the specific controller and radio conditions.</p>
<p>One practical consideration: iOS and Android handle MTU negotiation differently. Android allows you to request a specific MTU via the app layer, while iOS automatically negotiates the maximum supported MTU (typically 185 or 251 bytes depending on the iOS version) without app intervention. Your firmware should always handle whatever MTU is negotiated, rather than assuming a specific value.</p>
<h2 id="heading-phy-selection-for-range-and-speed">PHY Selection for Range and Speed</h2>
<p>Bluetooth 5.0 introduced two new PHY (Physical Layer) options beyond the original 1M PHY. Selecting the right PHY trades off range, throughput, and power consumption.</p>
<p><strong>1M PHY</strong> is the default and the only PHY available in Bluetooth 4.x. It transmits at 1 megabit per second with standard range. Every BLE device supports 1M PHY.</p>
<p><strong>2M PHY</strong> doubles the data rate to 2 megabits per second. Each packet takes half as long to transmit, which means the radio is active for less time. This improves both throughput (more data per unit time) and power consumption (shorter radio on-time). The tradeoff is slightly reduced range compared to 1M PHY because the receiver has less time to integrate each bit. Use 2M PHY when the central and peripheral are close together (within a few meters) and throughput matters.</p>
<p><strong>Coded PHY</strong> uses forward error correction to extend range significantly, approximately 2x to 4x compared to 1M PHY. It achieves this by transmitting each bit with redundant coding (S=2 for 2x range, S=8 for 4x range). The cost is reduced throughput: Coded PHY S=8 has an effective data rate of 125 kbps, which is 8x slower than 1M PHY. Use Coded PHY for applications that need long range (outdoor asset tracking, building-wide sensor networks) and can tolerate low data rates.</p>
<p>Enable PHY support in <code>prj.conf</code>:</p>
<pre><code class="language-plaintext">CONFIG_BT_USER_PHY_UPDATE=y
CONFIG_BT_CTLR_PHY_2M=y
CONFIG_BT_CTLR_PHY_CODED=y
</code></pre>
<p><code>CONFIG_BT_USER_PHY_UPDATE=y</code> enables the application to request PHY updates after a connection is established. <code>CONFIG_BT_CTLR_PHY_2M</code> and <code>CONFIG_BT_CTLR_PHY_CODED</code> enable support for the respective PHYs in the controller.</p>
<p>Request a PHY update after connection:</p>
<pre><code class="language-c">static void connected(struct bt_conn *conn, uint8_t err)
{
    if (err) {
        return;
    }

    /* Request 2M PHY for higher throughput */
    struct bt_conn_le_phy_param phy_param = {
        .options = BT_CONN_LE_PHY_OPT_NONE,
        .pref_tx_phy = BT_GAP_LE_PHY_2M,
        .pref_rx_phy = BT_GAP_LE_PHY_2M,
    };

    int phy_err = bt_conn_le_phy_update(conn, &amp;phy_param);
    if (phy_err) {
        printk("PHY update request failed (err %d)\n", phy_err);
    }
}
</code></pre>
<p>The <code>bt_conn_le_phy_update</code> function sends a PHY update request to the remote device. The <code>pref_tx_phy</code> and <code>pref_rx_phy</code> fields indicate the preferred PHY for transmitting and receiving data, respectively. The remote device may accept or suggest an alternative. Both devices must support the requested PHY for the update to succeed.</p>
<p>To monitor PHY changes, register a callback:</p>
<pre><code class="language-c">static void phy_updated(struct bt_conn *conn,
                         struct bt_conn_le_phy_info *param)
{
    printk("PHY updated: TX PHY %u, RX PHY %u\n",
           param-&gt;tx_phy, param-&gt;rx_phy);
}

BT_CONN_CB_DEFINE(conn_callbacks) = {
    .connected = connected,
    .disconnected = disconnected,
    .le_phy_updated = phy_updated,
};
</code></pre>
<p>The <code>le_phy_updated</code> callback fires whenever the PHY changes on a connection. The <code>tx_phy</code> and <code>rx_phy</code> fields report the active PHY for each direction. A value of 1 means 1M PHY, 2 means 2M PHY, and 4 means Coded PHY. The TX and RX PHYs can be different (asymmetric PHY), though most applications use the same PHY in both directions.</p>
<p>For Coded PHY with S=8 encoding (maximum range), set the options field:</p>
<pre><code class="language-c">struct bt_conn_le_phy_param phy_param = {
    .options = BT_CONN_LE_PHY_OPT_CODED_S8,
    .pref_tx_phy = BT_GAP_LE_PHY_CODED,
    .pref_rx_phy = BT_GAP_LE_PHY_CODED,
};
</code></pre>
<p>The <code>BT_CONN_LE_PHY_OPT_CODED_S8</code> option selects S=8 coding, which provides the maximum range extension at the cost of the lowest throughput. Omitting this option (or using <code>BT_CONN_LE_PHY_OPT_CODED_S2</code>) selects S=2 coding, which provides moderate range extension with better throughput.</p>
<h2 id="heading-firmware-updates-over-ble">Firmware Updates Over BLE</h2>
<p>Deploying firmware updates to devices in the field is a critical capability for any production BLE product. Users shouldn't need to connect a USB cable or visit a service center to get bug fixes and new features.</p>
<p>Zephyr supports Device Firmware Update (DFU) over BLE through integration with MCUboot, a secure open-source bootloader.</p>
<p>The DFU architecture has two components. MCUboot is a bootloader that runs before your application. It manages two firmware slots: the active slot (running firmware) and the upgrade slot (new firmware waiting to be applied). When new firmware is written to the upgrade slot, MCUboot verifies its cryptographic signature, swaps the slots, and boots the new firmware. If the new firmware fails to confirm itself (mark itself as valid), MCUboot automatically rolls back to the previous version on the next reboot.</p>
<p>The BLE transport for DFU uses the SMP (Simple Management Protocol) over a GATT service. The mcumgr library implements SMP, and Zephyr includes a BLE SMP transport that exposes an SMP GATT service. A phone app (like nRF Connect or mcumgr CLI) connects to this service and uploads the new firmware image in chunks.</p>
<p>Enable DFU in your <code>prj.conf</code>:</p>
<pre><code class="language-plaintext">CONFIG_BOOTLOADER_MCUBOOT=y
CONFIG_MCUMGR=y
CONFIG_MCUMGR_TRANSPORT_BT=y
CONFIG_MCUMGR_GRP_IMG=y
CONFIG_MCUMGR_GRP_OS=y
CONFIG_IMG_MANAGER=y
CONFIG_STREAM_FLASH=y
CONFIG_FLASH_MAP=y
CONFIG_FLASH=y
</code></pre>
<p><code>CONFIG_BOOTLOADER_MCUBOOT=y</code> tells the build system that this application runs under MCUboot, which changes the linker script and image format. <code>CONFIG_MCUMGR=y</code> enables the mcumgr management library. <code>CONFIG_MCUMGR_TRANSPORT_BT=y</code> enables the BLE SMP transport, which creates a GATT service that the phone connects to for uploading firmware. <code>CONFIG_MCUMGR_GRP_IMG=y</code> enables the image management command group (upload, confirm, erase). <code>CONFIG_MCUMGR_GRP_OS=y</code> enables the OS management group (reset, echo).</p>
<p>Register the BLE SMP transport in your application:</p>
<pre><code class="language-c">#include &lt;zephyr/mgmt/mcumgr/transport/smp_bt.h&gt;

int main(void)
{
    int err;

    err = bt_enable(NULL);
    if (err) {
        printk("Bluetooth init failed (err %d)\n", err);
        return 0;
    }

    /* Start SMP BLE transport for DFU */
    smp_bt_register();

    /* Start advertising (include SMP service UUID) */
    bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad), NULL, 0);

    printk("DFU-capable device ready\n");
    return 0;
}
</code></pre>
<p>The <code>smp_bt_register()</code> call registers the SMP GATT service with the Bluetooth stack. After this, any BLE central that connects can discover the SMP service and upload firmware using the mcumgr protocol.</p>
<p>Building with MCUboot requires flashing the bootloader first. MCUboot is built separately and programmed into the boot partition of flash:</p>
<pre><code class="language-bash">west build -b nrf52840dk/nrf52840 bootloader/mcuboot/boot/zephyr \
    -d build_mcuboot
west flash -d build_mcuboot

west build -b nrf52840dk/nrf52840 my_dfu_app
west flash
</code></pre>
<p>The first two commands build and flash MCUboot. The second two commands build and flash your application. MCUboot occupies the first portion of flash and boots your application from the primary slot.</p>
<p>To perform a DFU, you build a new version of your application, which produces a <code>zephyr.signed.bin</code> file in the build directory. Upload that file to the device using the nRF Connect mobile app (which has built-in DFU support) or the mcumgr command-line tool. The upload happens over the BLE SMP connection. After the upload completes, the device reboots, MCUboot verifies the new image, and swaps it into the active slot.</p>
<p>MCUboot supports several security features that are important for production. Image signing ensures that only firmware signed with your private key can be installed. Image encryption prevents reverse-engineering of firmware images during transfer. Rollback protection reverts to the previous firmware if the new version does not boot successfully. These features require additional configuration but are essential for any product that accepts over-the-air updates.</p>
<h2 id="heading-bluetooth-mesh-on-zephyr">Bluetooth Mesh on Zephyr</h2>
<p>BLE point-to-point connections work well for devices that communicate directly with a phone or gateway. But they break down when you need to control hundreds of light bulbs in a building. You can't connect to each one individually. Bluetooth Mesh solves this.</p>
<p>Bluetooth Mesh is a many-to-many networking standard that runs on top of BLE. Devices in a mesh network relay messages to each other, extending the range far beyond a single BLE connection. A command to turn on the lights can originate from one device, bounce through multiple relay nodes, and reach every light bulb in the building.</p>
<p>Zephyr includes a complete Bluetooth Mesh implementation. Here's the conceptual architecture.</p>
<p>Mesh devices have roles. A <strong>relay node</strong> forwards messages from other devices, extending the network range. A <strong>proxy node</strong> bridges between GATT-connected devices (phones) and the mesh network, so a phone without mesh support can control mesh devices through a proxy. A <strong>friend node</strong> stores messages for nearby low-power nodes that spend most of their time sleeping. A <strong>low-power node</strong> periodically wakes up and asks its friend for any stored messages.</p>
<p>Mesh communication uses a publish/subscribe model. Devices publish messages to addresses, and devices subscribed to those addresses receive them. A light switch publishes "turn on" to a group address. All light bulbs subscribed to that group address turn on.</p>
<p>Data in mesh is organized using <strong>models</strong>. A model defines a set of messages that a device can send and receive. The Bluetooth SIG defines standard models for common use cases: Generic OnOff (on/off switches and lights), Generic Level (dimmers), Sensor (sensor data), and Lighting (color temperature, lightness, hue). You can also define custom models.</p>
<p>Here's a minimal mesh node configuration in <code>prj.conf</code>:</p>
<pre><code class="language-plaintext">CONFIG_BT=y
CONFIG_BT_MESH=y
CONFIG_BT_MESH_RELAY=y
CONFIG_BT_MESH_PB_ADV=y
CONFIG_BT_MESH_PB_GATT=y
CONFIG_BT_MESH_GATT_PROXY=y
CONFIG_BT_MESH_CFG_CLI=y
CONFIG_BT_MESH_HEALTH_SRV=y
</code></pre>
<p><code>CONFIG_BT_MESH=y</code> enables the mesh stack. <code>CONFIG_BT_MESH_RELAY=y</code> makes this node relay messages for other nodes. <code>CONFIG_BT_MESH_PB_ADV=y</code> and <code>CONFIG_BT_MESH_PB_GATT=y</code> enable provisioning (the process of adding a device to the mesh network) over both advertising and GATT connections. <code>CONFIG_BT_MESH_GATT_PROXY=y</code> enables the proxy role for phone connectivity.</p>
<p>A full mesh application involves defining your composition data (what models your device supports), implementing model handlers, and setting up provisioning. The Zephyr samples directory (<code>samples/bluetooth/mesh/</code>) contains several complete examples including a light bulb, a light switch, and a sensor server that demonstrate the full pattern.</p>
<h2 id="heading-le-audio-the-next-generation-of-bluetooth-audio">LE Audio: The Next Generation of Bluetooth Audio</h2>
<p><a href="https://www.freecodecamp.org/news/the-bluetooth-le-audio-handbook/">LE Audio</a> is the most significant addition to the Bluetooth specification in years. It replaces the Classic Bluetooth audio profile (A2DP) with a new system built entirely on BLE. Zephyr's implementation of LE Audio is one of the most complete available in any open-source stack.</p>
<p>The core of LE Audio is the LC3 (Low Complexity Communication Codec). LC3 provides better audio quality than the SBC codec used in classic Bluetooth audio, at half the bitrate. This means better sound quality and lower power consumption.</p>
<p>LE Audio introduces two communication modes. <strong>Connected Isochronous Streams (CIS)</strong> are point-to-point audio connections, similar to classic Bluetooth audio but more efficient. CIS is used for phone-to-earbud connections, hearing aids, and other paired audio scenarios.</p>
<p><strong>Broadcast Isochronous Streams (BIS)</strong> are one-to-many broadcasts. A single source can broadcast audio to an unlimited number of receivers. This is the technology behind Auracast, which envisions public venues broadcasting audio that any compatible device can tune into (think: silent TVs in airports, hearing assistance in theaters, multi-language broadcasts in conference halls).</p>
<p>Zephyr implements the full LE Audio profile stack: BAP (Basic Audio Profile), PACS (Published Audio Capabilities), ASCS (Audio Stream Control), VCP (Volume Control), MCP (Media Control), CCP (Call Control), TMAP (Telephony and Media Audio Profile), and CAP (Common Audio Profile).</p>
<p>The <code>prj.conf</code> for an LE Audio project includes:</p>
<pre><code class="language-plaintext">CONFIG_BT=y
CONFIG_BT_AUDIO=y
CONFIG_BT_BAP_UNICAST_SERVER=y
CONFIG_BT_PACS=y
CONFIG_BT_ASCS=y
CONFIG_BT_ISO=y
CONFIG_BT_PAC_SNK=y
CONFIG_BT_PAC_SRC=y
</code></pre>
<p>LE Audio development is considerably more complex than basic BLE peripheral development. It involves managing isochronous channels, configuring codec parameters, handling audio data streams, and implementing the various profile layers. The Zephyr samples in <code>samples/bluetooth/bap_unicast_server</code> and <code>samples/bluetooth/bap_broadcast_source</code> are the best starting points.</p>
<p>If you're building hearing aids, earbuds, or any audio device, LE Audio on Zephyr is worth serious investment. The open-source stack gives you full visibility into the implementation, and Nordic's nRF5340 and nRF54 series chips provide the hardware support needed.</p>
<h2 id="heading-debugging-bluetooth-applications">Debugging Bluetooth Applications</h2>
<p>BLE applications are harder to debug than regular embedded applications because the radio communication is invisible. You can't set a breakpoint in the air. Here are the tools and techniques that make BLE debugging manageable.</p>
<p><strong>Zephyr's logging subsystem</strong> is your first line of defense. Enable detailed Bluetooth logging in <code>prj.conf</code>:</p>
<pre><code class="language-plaintext">CONFIG_LOG=y
CONFIG_BT_DEBUG_LOG=y
CONFIG_BT_LOG_LEVEL_DBG=4
</code></pre>
<p><code>CONFIG_LOG=y</code> enables the logging framework. <code>CONFIG_BT_DEBUG_LOG=y</code> and <code>CONFIG_BT_LOG_LEVEL_DBG=4</code> enable debug-level logging for the Bluetooth stack.</p>
<p>This produces verbose output showing every HCI command, advertising event, connection event, GATT operation, and error. The output goes to the console (typically UART). It's extremely verbose, so only enable it during active debugging.</p>
<p><strong>The Zephyr shell</strong> provides an interactive command line for Bluetooth operations:</p>
<pre><code class="language-plaintext">CONFIG_SHELL=y
CONFIG_BT_SHELL=y
</code></pre>
<p>With these enabled, you get shell commands like <code>bt init</code>, <code>bt advertise on</code>, <code>bt connect</code>, <code>bt gatt discover</code>, and <code>bt gatt read</code> that let you control and inspect the Bluetooth stack interactively over the serial console. This is invaluable for testing because you can manually trigger operations and see the results without modifying firmware.</p>
<p><strong>nRF Connect for Mobile</strong> (iOS/Android) is an essential companion tool. Beyond scanning and connecting, it displays all GATT services and characteristics, lets you read/write/subscribe to characteristics, shows raw advertising data, and logs BLE events with timestamps. Use it to verify that your device is advertising correctly, that your GATT database looks right, and that reads/writes/notifications work as expected.</p>
<p><strong>Bluetooth sniffers</strong> capture the actual radio packets. The nRF52840 DK can be used as a sniffer with Nordic's nRF Sniffer for Bluetooth LE firmware. Combined with Wireshark (which has BLE protocol dissectors), you can inspect every packet on the wire: advertising PDUs, connection events, GATT requests/responses, and pairing exchanges. This is the ultimate debugging tool when something at the protocol level isn't working.</p>
<p><strong>Common debugging patterns.</strong> If advertising isn't visible, check that the advertising data doesn't exceed 31 bytes (for legacy advertising) and that the flags field is present.</p>
<p>If connections drop immediately, check that <code>CONFIG_BT_PERIPHERAL</code> is enabled (not just <code>CONFIG_BT_BROADCASTER</code>). If GATT reads return zero-length data, verify that your read callback returns the correct value from <code>bt_gatt_attr_read</code>. If notifications don't arrive, confirm that the central has written 0x0001 to the CCCD. If pairing fails, ensure <code>CONFIG_BT_SMP=y</code> is set and the authentication callbacks are registered.</p>
<h2 id="heading-power-optimization-for-ble-devices">Power Optimization for BLE Devices</h2>
<p>A BLE device that drains its battery in a week is a failed product. Power optimization isn't an afterthought – it's a core design constraint. Zephyr provides the tools, but you have to use them correctly.</p>
<p>The biggest power consumer in a BLE device is the radio. Every advertising event, connection event, and scan window activates the radio transmitter or receiver for milliseconds at a time, consuming milliamps of current. Reducing radio usage is the primary lever for extending battery life.</p>
<p>For advertising, increase the advertising interval. An interval of 1000 ms uses roughly 5x less power than an interval of 200 ms. If fast discovery isn't critical, use an even longer interval.</p>
<p>You can also use a two-phase approach: advertise at a fast interval (100 ms) for the first 30 seconds after power-on, then switch to a slow interval (1000 ms) for the steady state. Zephyr's advertising API supports changing parameters while advertising.</p>
<p>For connected devices, use the peripheral latency parameter. A peripheral latency of 4 means the peripheral can skip 4 connection events before it must respond. If the connection interval is 50 ms and the peripheral latency is 4, the peripheral only wakes up every 250 ms instead of every 50 ms. Request a longer connection interval from the central when high-throughput isn't needed:</p>
<pre><code class="language-c">static struct bt_le_conn_param conn_params = {
    .interval_min = 80,   /* 100 ms (units of 1.25 ms) */
    .interval_max = 160,  /* 200 ms */
    .latency = 4,
    .timeout = 400,       /* 4 seconds (units of 10 ms) */
};

/* After connection is established: */
bt_conn_le_param_update(conn, &amp;conn_params);
</code></pre>
<p>The <code>bt_conn_le_param_update</code> function sends a connection parameter update request to the central. The central may accept or reject the request. Most centrals (phones) accept reasonable parameter ranges.</p>
<p>Enable Zephyr's system power management:</p>
<pre><code class="language-plaintext">CONFIG_PM=y
CONFIG_PM_DEVICE=y
</code></pre>
<p>With power management enabled, the kernel puts the processor into a low-power state whenever no threads are ready to run.</p>
<p>On an nRF52840, the idle current drops from roughly 3 mA (active) to about 1.5 microamps (System OFF with RAM retention). The difference is enormous. The Zephyr power management policy automatically selects the deepest sleep state that the system can enter given the next scheduled wake-up event.</p>
<p>Disable unused peripherals in your devicetree overlay. Every enabled peripheral (UART, SPI, I2C) consumes power even when idle. If you don't need UART in production (you used it only for development logging), disable it:</p>
<pre><code class="language-dts">&amp;uart0 {
    status = "disabled";
};
</code></pre>
<p>Measure your actual current consumption. Tools like the Nordic Power Profiler Kit II show real-time current draw at microamp resolution. Zephyr's <code>CONFIG_THREAD_ANALYZER</code> helps you right-size thread stacks (over-allocated stacks waste RAM, which means more RAM needs to be powered).</p>
<p>The goal for a well-optimized BLE sensor is single-digit microamp average current, which translates to years of operation on a coin cell battery. Zephyr makes this achievable, but it requires attention to every layer: radio scheduling, peripheral management, clock configuration, and application design.</p>
<h2 id="heading-zephyr-bluetooth-vs-other-stacks">Zephyr Bluetooth vs Other Stacks</h2>
<p>When choosing a Bluetooth stack, you have options. Here is how Zephyr compares.</p>
<h3 id="heading-1-zephyr-vs-nordic-softdevice">1. Zephyr vs. Nordic SoftDevice</h3>
<p>Nordic's SoftDevice was their proprietary BLE stack before they moved to Zephyr. SoftDevice was a pre-compiled binary blob: you couldn't read or modify the stack code. Nordic's nRF Connect SDK (built on Zephyr) replaces SoftDevice with Zephyr's open-source stack. If you're starting a new Nordic project, use Zephyr. SoftDevice is legacy.</p>
<h3 id="heading-zephyr-vs-nimble-apache-mynewt">Zephyr vs. NimBLE (Apache Mynewt)</h3>
<p>NimBLE is a lightweight, open-source BLE host stack originally from the Apache Mynewt project (also usable standalone and ported to ESP-IDF). NimBLE is smaller than Zephyr's stack and may be a better fit for extremely RAM-constrained devices. Zephyr's stack is more feature-complete (LE Audio, Mesh, Direction Finding) and has broader industry adoption. For new products, Zephyr is the stronger choice unless you are severely RAM-constrained.</p>
<h3 id="heading-zephyr-vs-esp-idf-bluetooth">Zephyr vs. ESP-IDF Bluetooth</h3>
<p>Espressif's ESP-IDF includes a Bluetooth stack (Bluedroid or NimBLE) for ESP32 chips. If you're exclusively using ESP32 hardware, ESP-IDF is a valid choice. Zephyr supports ESP32 and gives you portability to other hardware, a unified build system, and a more comprehensive BLE feature set. If you might ever need to switch chips, Zephyr's portability is a significant advantage.</p>
<h3 id="heading-zephyr-vs-vendor-sdks-with-proprietary-stacks">Zephyr vs. vendor SDKs with proprietary stacks</h3>
<p>Many chip vendors provide their own BLE SDKs with closed-source stacks. These work well but lock you into that vendor's ecosystem. Zephyr gives you portability, open source, and the collective effort of a large community. The trade-off is a steeper learning curve.</p>
<p>For a product that needs to ship on a timeline with one known chip, a vendor SDK might be faster to start with. For a product line that spans multiple chips or that values long-term maintainability, Zephyr wins.</p>
<h2 id="heading-where-to-go-from-here">Where to Go from Here</h2>
<p>We've covered a lot of ground in this handbook. You should now understand BLE fundamentals, GAP advertising, GATT services, connections, notifications, pairing, Mesh, LE Audio, debugging, and power optimization. You've written code for every major concept. Here's how to continue.</p>
<p>Explore the Zephyr Bluetooth samples directory (<code>zephyr/samples/bluetooth/</code>). It contains over 30 Bluetooth-specific examples. The <code>peripheral_hr</code> sample implements a complete Heart Rate profile. The <code>central</code> sample shows how to build a scanning/connecting central role. The <code>mesh/</code> subdirectory has light switch, light bulb, and sensor examples for Mesh development. The <code>bap_*</code> samples demonstrate LE Audio.</p>
<p>Read the Zephyr Bluetooth documentation at <code>docs.zephyrproject.org/latest/connectivity/bluetooth/</code>. The API reference covers every function, macro, and configuration option. The Bluetooth architecture documentation explains how the host, controller, and HCI layers interact.</p>
<p>Get an nRF Connect SDK setup if you are using Nordic hardware. The nRF Connect SDK adds Nordic-specific features on top of Zephyr, including their Bluetooth libraries, proprietary wireless protocols (ESB, Gazell), and cellular modem support. It uses the same Zephyr kernel and build system.</p>
<p>Build something real. A BLE remote control for your desk lamp. A sensor that reports room temperature and humidity to your phone. A custom keyboard with BLE connectivity. A pet tracker.</p>
<p>The best way to solidify this knowledge is to encounter and solve real problems on real hardware. Every BLE product has its quirks (connection parameter negotiation with iOS vs Android, handling reconnection after bond loss, managing MTU size for efficient data transfer), and you learn those quirks by building.</p>
<p>The BLE ecosystem is enormous and growing. Zephyr gives you a production-quality, open-source foundation to build on, with a community and corporate backing that ensures it will be actively developed for years to come. You now have the knowledge to start building on it.</p>
<h2 id="heading-summary">Summary</h2>
<p>This handbook covered the full spectrum of Bluetooth development on Zephyr OS, from foundational concepts to production-ready features.</p>
<p>The BLE fundamentals section established the mental model that everything else builds on: GAP controls discovery and connections, GATT defines how data is structured and exchanged, services and characteristics form the API of your device, and UUIDs identify every component. These concepts are not Zephyr-specific&nbsp;– they apply to any BLE stack.</p>
<p>On the Zephyr side, you built progressively more complex applications. The beacon demonstrated the minimal BLE setup: initialize the stack and start advertising. The LED service introduced GATT with read and write characteristics, showing how to control hardware from a phone. The notification examples added real-time data push, and the complete sensor node tied together advertising, GATT, connection management, and periodic data delivery into a single cohesive application.</p>
<p>Each of these patterns (advertising data construction, GATT callback signatures, CCC handling, connection reference management) will appear in every BLE project you build.</p>
<p>The standard Heart Rate profile showed how SIG-defined 16-bit UUIDs integrate with Zephyr's predefined UUID macros, enabling interoperability with generic BLE apps.</p>
<p>The central role example demonstrated the other side of BLE: scanning, connecting, and discovering services on remote devices. MTU negotiation and PHY selection are the two primary levers for optimizing data throughput and range after a connection is established, and both require coordinated configuration in Kconfig and runtime API calls.</p>
<p>The DFU section addressed the production requirement of field-updatable firmware, integrating MCUboot with SMP over BLE.</p>
<p>Beyond point-to-point connections, Bluetooth Mesh extends BLE into many-to-many networks for building automation and large-scale IoT deployments, while LE Audio represents the next generation of wireless audio with the LC3 codec and broadcast capabilities. Pairing and security protect production devices from unauthorized access. Power optimization, debugging tooling, and stack comparison round out the practical knowledge needed to ship a real product.</p>
<p>The code patterns and Kconfig options presented here form the toolkit for building any BLE device on Zephyr, from a simple beacon to a complex multi-protocol gateway.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
