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));
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),
);
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;
}
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),
};
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__);
}
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
Then use west to fetch dependencies:
cd my-workspace
west update
Go to the app folder:
cd simple-web-zephyr
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
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
}
}
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);
}
}
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();
}
...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]));
}
}
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):
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:
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:
After successfully connecting, the page should look like this:
Now try to push the button on the dongle to see the status message change:
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:
When the light on the dongle magically changes to red, more friends come to watch the fun:
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 :)