DEV Community

Cover image for Getting started with Zephyr and Web Bluetooth
Lars Knudsen 🇩🇰
Lars Knudsen 🇩🇰

Posted on

Getting started with Zephyr and Web Bluetooth

For this post, I wrote the embedded firmware and accompanying test web page in an afternoon - and I want everyone to know that they can too.

I did cheat a bit, as I am standing on the shoulders of giants, using the Zephyr RTOS on the embedded side and using APIs in the browser that has taken quite some energy to perfect (credits go to @reillyeon@toot.cafe, @Vincent_Scheib, @quicksave2k and more ).

Anyway, let's get to work!

What this thing does

The firmware exposes a GATT service with a few characteristics:

  • Setting the color of the primary RGB LED
  • Setting an ID, which will be appended to the BT name (convenient when in a workshop with multiple devices)
  • Being notified of button presses

It also enables logging over USB, if the board supports it.

In the root of the repository, there is a very simple self contained HTML file (so it doesn't require a web server when loaded as a file locally) to interact with the hardware via Web Bluetooth.

A simple GATT service

In order to enable easy control from a web application using Web Bluetooth, a simple GATT service must be created.

First, we need to create UUIDs to be used for the service and characteristics.

static struct bt_uuid_128 simple_io_service_uuid = BT_UUID_INIT_128(
    BT_UUID_128_ENCODE(0x13e779a0, 0xbb72, 0x43a4, 0xa748, 0x9781b918258c));

static const struct bt_uuid_128 simple_io_rgb_uuid = BT_UUID_INIT_128(
    BT_UUID_128_ENCODE(0x4332aca6, 0x6d71, 0x4173, 0x9945, 0x6653b6c684a0));

static const struct bt_uuid_128 simple_io_id_uuid = BT_UUID_INIT_128(
    BT_UUID_128_ENCODE(0xb749d964, 0x4efb, 0x408a, 0x82ad, 0x7495e8af8d6d));

static const struct bt_uuid_128 simple_io_button_uuid = BT_UUID_INIT_128(
    BT_UUID_128_ENCODE(0x030de9cf, 0xce4b, 0x44d0, 0x8aa2, 0x1db9185dc069));
Enter fullscreen mode Exit fullscreen mode

The UUIDs are the unique identifiers used by the central device (in this case, the web application) to find the correct services and characteristics in BLE devices. Some are short, officially assigned, 16bit values - the Battery service (= 0x180F) or Heart Rate service (= 0x180D) - but for custom services and characteristics like these, a custom 128bit UUID must be provided.

For UUID generation, I'd recommend using one of the many online tools.

Defining the GATT service structure in Zephyr:

/* Simple IO Service Declaration */
BT_GATT_SERVICE_DEFINE(simple_io_svc,
    BT_GATT_PRIMARY_SERVICE(&simple_io_service_uuid),
    BT_GATT_CHARACTERISTIC(&simple_io_button_uuid.uuid,
                   BT_GATT_CHRC_NOTIFY,
                   BT_GATT_PERM_NONE,
                   NULL, NULL, NULL),
    BT_GATT_CCC(button_ccc_cfg_changed,
            BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
    BT_GATT_CUD("Button(s)", BT_GATT_PERM_READ),
    BT_GATT_CHARACTERISTIC(&simple_io_rgb_uuid.uuid,
                BT_GATT_CHRC_WRITE,
                BT_GATT_PERM_WRITE,
                NULL, write_rgb, NULL),
    BT_GATT_CUD("RGB", BT_GATT_PERM_READ),
    BT_GATT_CHARACTERISTIC(&simple_io_id_uuid.uuid,
                BT_GATT_CHRC_WRITE,
                BT_GATT_PERM_WRITE,
                NULL, write_id, NULL),
    BT_GATT_CUD("Device ID", BT_GATT_PERM_READ),
);
Enter fullscreen mode Exit fullscreen mode

This structure defines a GATT service containing the needed characteristics. For the sake of keeping things simple, a characteristic can be seen like a 'property' (or variable) made available over the air with a specified combination of write, read and/or notify capabilities (Note: there are more advanced options but for now, just focus on these three).

We have the following defined:

  • A button characteristic: Will notify on button presses.
  • An rgb characteristic: To allow writing an RGB value.
  • A device id characteristic: To allow writing an ID.

When a connected device writes an RGB color value (e.g. via Web Bluetooth), the write_rgb function is called:

static ssize_t write_rgb(struct bt_conn *conn, const struct bt_gatt_attr *attr,
            const void *buf, uint16_t len, uint16_t offset, uint8_t flags)
{
    uint8_t val[3];

    printk("%s: len=%zu, offset=%u\n", __func__, len, offset);

    if (offset != 0) {
        return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET);
    } else if (len != sizeof(val)) {
        return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
    }

    (void)memcpy(&val, buf, len);

    if (simple_io_cbs->set_rgb) {
        simple_io_cbs->set_rgb(val[0], val[1], val[2]);
    }

    return len;
}
Enter fullscreen mode Exit fullscreen mode

This basically checks that 3 bytes are sent and if a set_rgb callback is registered, this will be called using the 3 bytes as red, green and blue values.

The full source can be found here.

Advertising

Correctly discovering Bluetooth Low Energy devices requires them to advertise information about who they are, what services they provide or something else to allow unique or filtered identification. You have probably seen this when trying to pair with a new set of earbuds, keyboard or other type of device from e.g. a mobile device.

In our case, we are interested in providing two things:

  • The UUID of our custom service
  • The name of the device

This is primarily done in two steps. First, we define the advertising payload containing the service UUID:

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_UUID128_ALL, BT_UUID_SIMPLE_IO_SERVICE),
};
Enter fullscreen mode Exit fullscreen mode

After the bluetooth stack is enabled and ready, we start advertising, including the name (will be added automatically by the Zephyr stack):

static void bt_ready(void)
{
    int err;

    printk("%s: Bluetooth initialized\n", __func__);

    if (IS_ENABLED(CONFIG_SETTINGS)) {
        settings_load();
    }

    bt_simple_io_register_cb(&io_cbs);

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

    printk("%s: Advertising successfully started\n", __func__);
}
Enter fullscreen mode Exit fullscreen mode

The full source for the main application can be found here.

Cloning, building and flashing

Before cloning the application repository, go to The Zephyr getting started guide and install all dependencies (I'd recommend following the path with the virtual python environment).

This repository contains a stand alone Zephyr application that can be fetched and initialized like this:

west init -m git@github.com:larsgk/simple-web-zephyr.git --mr main my-workspace
Enter fullscreen mode Exit fullscreen mode

Then use west to fetch dependencies:

cd my-workspace
west update
Enter fullscreen mode Exit fullscreen mode

Go to the app folder:

cd simple-web-zephyr
Enter fullscreen mode Exit fullscreen mode

There are a few scripts for building and flashing the Nordic Semiconductor nRF52840 Dongle - run them in this order:

compile_app.sh
create_flash_package.sh
flash_dongle.sh
Enter fullscreen mode Exit fullscreen mode

Note: You'll need to get the dongle in DFU mode by pressing the small side button with a nail. The dongle should then start fading a red light in and out.

Web Bluetooth test page

In order to connect to a BLE device using Web Bluetooth, first scan for the device, using a filter. In this case, we look for the advertising of our custom service UUID:

const SIMPLE_IO_SERVICE = '13e779a0-bb72-43a4-a748-9781b918258c';

...

const scan = async () => {
    try {
        const device = await navigator.bluetooth.requestDevice({
            filters: [{ services: [SIMPLE_IO_SERVICE] }]
        });

        await openDevice(device);
    } catch (err) {
        // ignore if we didn't get a device
    }
}
Enter fullscreen mode Exit fullscreen mode

After connecting to the device, find the GATT service and hook up the characteristics:

const openDevice = async (device) => {
    const server = await device.gatt.connect();

    try {
        const service = await server.getPrimaryService(SIMPLE_IO_SERVICE);

        await startButtonNotifications(service);
        await fetchRGBCharacteristic(service);

        console.log('Connected to device', device);
        statusElement.innerHTML = `Connected to ${device.name}`

        device.ongattserverdisconnected = _ => {
            console.log(`Disconnected ${device.id}`);
            statusElement.innerHTML = "Not Connected";
        };
    } catch (err) {
        console.warn(err);
    }
}
Enter fullscreen mode Exit fullscreen mode

Start listening for button change notifications...

const startButtonNotifications = async (service) => {
    const characteristic = await service.getCharacteristic(SIMPLE_IO_BUTTON_CHAR);
    characteristic.addEventListener('characteristicvaluechanged', (evt) => {
        const value = evt.target.value.getUint8(0);
        console.log(`Button = ${value}`);
        statusElement.innerHTML = `Button ${value === 0 ? "released" : "pressed"}`;
    });
    return characteristic.startNotifications();
}
Enter fullscreen mode Exit fullscreen mode

...and connect the RGB characteristic to allow the application to set a color:

const fetchRGBCharacteristic = async (service) => {
    rgbCharacteristic = await service.getCharacteristic(SIMPLE_IO_RGB_CHAR);
}

...

const setRGB = (r, g, b) => {
    if (rgbCharacteristic) {
        rgbCharacteristic.writeValueWithoutResponse(new Uint8Array([r, g, b]));
    }
}
Enter fullscreen mode Exit fullscreen mode

The full source for the web application is available here

Working together

Let's try to connect the web application to an nRF52840 dongle flashed with the Zephyr application.

First, connect the dongle to a power source (e.g. a PC USB port):

Spiderman connecting the dongle

The dongle should start flashing blue.

NOTE: Spiderman wanted to make sure the dongle was properly web enabled but he is not strictly needed (please don't tell him)

Then open the web application in a Web Bluetooth capable browser.

You should see something like this:

Initial view of web page loaded

Now, press the CONNECT button and a connect dialog should appear, listing the devices found that satisfies the filter given to the requestDevice function. Select the Simple Web Zephyr device and click pair:

Request device dialog

After successfully connecting, the page should look like this:

Web page showing device connected

Now try to push the button on the dongle to see the status message change:

Web page showing button pressed

This should show the page reacting to button press and release notifications sent from the device.

Try to open the color picker and select e.g. red:

Color picker open

When the light on the dongle magically changes to red, more friends come to watch the fun:

Dongle with red LED

Final remarks

I hope you enjoyed this introduction to Zephyr and Web Bluetooth.

Try to expand the Zephyr application with more services or maybe start adding a bit more functionality to the simple web page (e.g. try to make the RGB light change color on button presses).

The full source code is here: https://github.com/larsgk/simple-web-zephyr

Direct link to the test page is here: https://larsgk.github.io/simple-web-zephyr/single_page.html

Remember: if you download the page file, you can also just load it directly in the browser, make changes and reload.

Enjoy :)

Top comments (3)

Collapse
 
wickramanayaka profile image
Chamal Ayesh Wickramanayaka

A great post. Could you please suggest a few recourses or video tutorials to learn Zephyr? Thanks.

Collapse
 
denladeside profile image
Lars Knudsen 🇩🇰

Hi Chamal,

thank you :)

IMO, there could be a few different approaches, depending on the host OS, target hardware and what you'd like to start with (Zephyr supports MANY boards/sensors/other).

Which OS are you using for development? If Windows, I would probably recommend you to try to use WSL (Linux in Windows...), install Zephyr by following the getting started guide (docs.zephyrproject.org/latest/deve...) for Ubuntu (possibly go with the virtual python env approach.

For hardware, I'd recommend using one of the Nordic Development kits as they are very stable and you'll have a separate USB interface for debugging and flashing. Maybe nRF52840 DK for single core development) or nRF5340 DK for more powerful/dual core development.

As a starting point, you can try to build and flash the 'blinky' sample - and for early Bluetooth testing, maybe try the heart rate sample (peripheral_hr).

Feel free to reach out if you get stuck :)

Collapse
 
wickramanayaka profile image
Chamal Ayesh Wickramanayaka

Thank you so much Lars. I will try and let you know.