Project consists of two devices, where one of them is controlling device (phone in this case), and the other one is remote wearable device. Remote device has LEDs indicating when it goes out of range. Controlling device opens connection, and periodically sends data packet with RSSI measured.
Getting startedHardware required for a project is one of Nordic Semiconductors Bluetooth development kits. All following steps were done on nRF5340-DK.
Before starting it's good to follow guide for setting up asoftware environment.
Code was developed on nRF Connect SDK v1.5.0 with patched zephyr. The patch is required to add support for LCD display and is available in NordicPlayground github repo. Possibly newer versions of SDK will already support Arduino header definitions in the board dts file.
First, we will go through easy steps to get the code running, and then we will dive into SDK details.
Programming nRF5340-DK- Apply patch from Nordic example - clone project, and follow instructions to apply the patch
- Clone virtual-leash project from Github
git clone https://github.com/theotowngarage-com/nrf5340-dk-experiments.git
- Import project to SEGGER Embedded Studio
- Connect nRF5340-DK
- Connect red LED to pin P0.30, and green LED to pin P0.31
- Connect Adafruit 2.8" LCD display
- Select "build and run" from "Build" menu.
The display should show RSSI label and RSSI graph background.
For test purposes LEDs can be connected through reasonably large (>= 10k ohm) current limiting resistors directly to port outputs.
Controlling from Android phone- Open MIT App Inventor page
- Download and install thelatest BluetoothLE extension.
- Import virtual-leash/app-inventor/control-application.aia from project repository
- Compile apk, or install "MIT AI2 Companion" from Play store
- Download and run application on the phone
- Press "Scan devices"
- Select "Otown" device - this is how the DK advertises over Bluetooth
- Press "Attach"
- Green LED should start flashing every second
Here is a short video showing project in action:
Implementation detailsIn the following chapters, we tried to add as much information as possible to help others understand how everything works under the hood. It was quite a journey to learn about development on Nordic devices, using Zephyr OS, and using Bluetooth itself.
To make it easier to understand we show a description of working code first, and then there are some steps more or less successful that led us to this particular solution. Overall learning part took couple of weeks of after-work-hours experiments to get started.
There is a quite steep learning curve with configuring projects, understanding device tree files, overlay files, understanding Bluetooth parameters, and more. We're not aiming at providing a full tutorial on those subjects here.
Nordic examples shipped with SDK worked mostly out-of-the-box. That was an encouraging start, however later, spoiled by Arduino, we thought that copy-pasting code would be enough to get parts of examples to our code. That was the first mistake that cost us 2-3 weeks. It's not enough to copy source code, there are also project configuration files, and a usually fair amount of customization was necessary on top of example code.
Final code was developed using regular connection with DK acting as "peripheral" device, and phone as "central" device. In retrospect, we probably would use Broadcaster-Observer roles of BluetoothLE, since quick prototyping platforms (app inventor, flutter) have poor or no support for connection-less data transfers.
Bluetooth configuration details on nRF5340-DKThe first, and the most difficult part is to find proper configuration settings. Set that we ended up with to enable Bluetooth peripheral device:
# Incresed stack due to settings API usage
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048
CONFIG_BT=y
CONFIG_BT_DEBUG_LOG=y
CONFIG_BT_SMP=y
CONFIG_BT_SIGNING=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DIS=y
CONFIG_BT_ATT_PREPARE_COUNT=5
CONFIG_BT_PRIVACY=y
CONFIG_BT_DEVICE_NAME="Otown"
CONFIG_BT_DEVICE_APPEARANCE=833
Configuration is saved in prj.conf
. This is by far the most cryptic, and poorly documented part when just starting with Zephyr. What worked for us is a mix of configuration copied from examples, documentation, and Zephyr source code.
Afterwards enabling and starting Bluetooth is quite straight forward, and essentially looks the same in all examples. All the magic happens in code auto-generated from configuration settings.
int err = bt_enable(NULL);
if (err) {
LOG_ERR("Bluetooth init failed (err %d)\n", err);
return;
}
The first part of any connection-oriented Bluetooth link is setting up advertising details. There are some very complex macros in Zephyr for this purpose. Here is a structure that worked for us:
//Unique Universal ID of service
#define OTOWN_UUID BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x39342d62, 0x3932, 0x662d, 0x6538, 0x313134343332))
// Advertising details for just one service, and generally discoverable peripheral
static const struct bt_data advertising_data[] = {
BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
BT_DATA_BYTES(BT_DATA_UUID128_ALL, BT_UUID_128_ENCODE(0x39342d62, 0x3932, 0x662d, 0x6538, 0x313134343332)),
};
// Bluetooth connect and disconnect callbacks
static struct bt_conn_cb conn_callbacks = {
.connected = connected,
.disconnected = disconnected,
};
...
// register connect and disonnect callbacks
bt_conn_cb_register(&conn_callbacks);
// Pass structure to bt_le_adv_start method
err = bt_le_adv_start(BT_LE_ADV_CONN_NAME, advertising_data, ARRAY_SIZE(advertising_data), NULL, 0);
We used UUIDs generated from one of many online generators. For custom communication channels, they can essentially be random values known by both sides of the connection.
On the most basic level Bluetooth is composed of services, that are further decomposed to characteristics, which can be read or written to.
There are a lot of configuration parameters for each object. In our project, we used a single service with 2 characteristics. One has Read/Write methods, other is Write only. For simplicity, there is no encryption or any special pairing requirements to access characteristics.
#define REMOTE_RSSI_CHARACTERISTIC_UUID BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x63342d31, 0x3836, 0x372d, 0x3166, 0x306331633562))
#define DETACH_CHARACTERISTIC_UUID BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x1e086d95, 0x7faa, 0x4993, 0x984e, 0xcf234cec373b))
/* Primary Service Declaration */
BT_GATT_SERVICE_DEFINE(otown_svc, //create a struct with _name
BT_GATT_PRIMARY_SERVICE(OTOWN_UUID), //Main UUID
BT_GATT_CHARACTERISTIC(REMOTE_RSSI_CHARACTERISTIC_UUID,
BT_GATT_CHRC_READ | BT_GATT_CHRC_WRITE, // Properties
BT_GATT_PERM_READ | BT_GATT_PERM_WRITE, // permissions read/write no security
read_otown, write_otown, otown_value), // Callback functions and value
BT_GATT_CHARACTERISTIC(DETACH_CHARACTERISTIC_UUID,
BT_GATT_CHRC_WRITE, // Properties
BT_GATT_PERM_WRITE, // permissions write no security
NULL, write_detach, detach_request), //Callback functions and value
BT_GATT_CCC(vnd_ccc_cfg_changed, //Client Configuration Configuration
BT_GATT_PERM_READ | BT_GATT_PERM_WRITE_ENCRYPT),
);
The entire code that handles these characteristics is auto-generated.
Writing and reading characteristics happen through callback functions. It's quite important to not use too much time inside those callbacks. Updating the LCD display inside the callback caused connection instability after a couple of seconds. Log outputs seem to be tolerated.
When writing, data fragment has to be stored in a buffer:
//Callback function of write command
static ssize_t write_otown(struct bt_conn *conn, const struct bt_gatt_attr *attr,
const void *buf, uint16_t len, uint16_t offset,
uint8_t flags) {
uint8_t *value = attr->user_data;
if (offset + len > sizeof(otown_value)) {
return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET);
}
memcpy(value + offset, buf, len);
Then received string can be parsed and passed to the main application. In this case, the Zephyr message queue was used.
int value_int = atoi(value);
k_msgq_put(&rssi_queue, &value_int, K_NO_WAIT);
return len;
}
Message queues are fixed-size circular buffers that provide ways of communication between application threads. Here is a simple example:
// Queue for passing received RSSI values to main thread (4 elements)
K_MSGQ_DEFINE(rssi_queue, sizeof(int), 4, 4);
...
// write callback on Bluetooth thread
k_msgq_put(&rssi_queue, &value_int, K_NO_WAIT);
...
// main thread - get value from queue, and display on LCD
int rssi;
if(k_msgq_get(&rssi_queue, &rssi, K_NO_WAIT) == 0) {
LOG_INF("RSSI = %d", rssi);
gui_add_point_to_chart(rssi);
}
Simpler methods that just change state don't need to use queues. Here is code for write callback for detach characteristic
#define DETACH_COMMAND "detach"
static ssize_t write_detach(...) {
...
// compare received string against predefined command
if(strncmp(value, DETACH_COMMAND, strlen(DETACH_COMMAND)) == 0) {
...
detached_safely = true;
}
return len;
}
Finally disconnect callback is responsible for checking if phone "safely" disconnected
static void disconnected(...) {
...
// turn on red leds if remote device did not detach safely before disconnecting
if(!detached_safely) {
gpio_set_red(true);
}
}
Using GPIOsFirst GPIO library has to be enabled in project configuration file
CONFIG_GPIO=y
Using GPIOs normally requires defining ports in board overlay files, however there is a shortcut that could be used for prototyping
#define RED_LED_PIN 30
// "guess" that port 0 is named GPIO_0 on nRF boards
gpio = device_get_binding("GPIO_0");
if (gpio == NULL) {
printk("error getting GPIO_0 device\n");
return;
}
// configure pin 30 as an output
ret = gpio_pin_configure(gpio, RED_LED_PIN, GPIO_OUTPUT);
...
// set output
gpio_pin_set(gpio, RED_LED_PIN, true);
Using LCD displayAdafruit 2.8" LCD display is supported by lvgl library (after proper board configuration). Even though it wasn't strictly necessary in our project, it was fun to have, and provided nice debugging opportunities.
At the time of writing this project there was proof of concept of GUI designer for screen layouts, however code generator was not ready, and very few gui components were available. Code that we used was mostly copied from Nordic example on NordicPlayground.
Documentation for graphic components wasn't that great and often look up to the source code was necessary. On top of that sometimes order of setting component properties was important. On the positive side touch screen, and display worked with no issues with Nordic and Zephyr example code.
Few configuration options that we had to set in project files to enable LVGL support
# LVGL DISPLAY
CONFIG_HEAP_MEM_POOL_SIZE=16384
CONFIG_MAIN_STACK_SIZE=4096
CONFIG_DISPLAY=y
CONFIG_DISPLAY_LOG_LEVEL_ERR=y
CONFIG_LVGL=y
CONFIG_LVGL_ANTIALIAS=y
CONFIG_LVGL_USE_LABEL=y
CONFIG_LVGL_USE_CONT=y
CONFIG_LVGL_USE_BTN=y
CONFIG_LVGL_USE_CHECKBOX=y
CONFIG_LVGL_USE_IMG=y
CONFIG_LVGL_USE_THEME_MATERIAL=y
CONFIG_LVGL_USE_ANIMATION=y
CONFIG_LVGL_USE_SHADOW=y
CONFIG_LVGL_USE_CHART=y
CONFIG_LVGL_CHART_AXIS_TICK_LABEL_MAX_LEN=256
CONFIG_NEWLIB_LIBC=y
Specific display has to be selected in CMakeLists.txt
set(SHIELD adafruit_2_8_tft_touch_v2)
All component configuration code, including some experiments with GUI components is available in gui.c
Phone application is very simple, and functionality is limited to scanning for nearby Bluetooth devices, then sending strings with data after attaching to device.
Periodically RSSI of connected device is measured and written to "RSSI" characteristic. This works around issue on nRF SDK, that RSSI can not be read on peripheral device once it connects to central device.
Pressing detach button will send "detach" command to "detach" characteristic.
For a phone application initially, we planned to go with Flutter, because of its native cross-platform support, however, the lack of proper ble libraries lead us to prototype on something simpler instead. At first, we wanted to use App Inventor to quickly make a prototype for testing purposes, and while it looks very childish and non-versatile at first, ironically it supports more BLE features than any available Flutter BLE library (for example getting RSSI value from an already connected device), so we decided to go with it.
Another setback was an attempt to get RSSI in nRF SDK from connected device. RSSI is readily available when in scanning phase, however there is no way to get it after theconnection is established. We got to dead-end while pursuing modifications to network processor code, and HCI interface. We triedto utilize a hci_pwr_ctrl example, in which the Bluetooth controller (in DK case the networking core) tunnels the RSSI value to our application thread on a second core. Unfortunately, we did not get this example to work as there is apparently a known bug in the nRF's Zephyr SDK. It was also too advanced for beginners.
Initially, we wanted to use nRF5340-DK as a central device, and simple key-finder Bluetooth beacon
We couldn't find a good combination of parameters to keep the connection with the beacon alive. We tried multiple security/pairing parameters, but the connection was almost immediately dropped after a short negotiation phase. Error codes were not very helpful so that path was dropped.
BluetoothLE connection-less Broadcaster-Observer roles were very promising. We made a simple advertising setup between nRF5340-DK, and nRF52840 dongle based on Zephyr example, however we couldn't transfer any meaningful data easily. All modifications led to code failing. Possibly having 2 full development kits would make it easier. The lack of support of these roles in phone app prototyping frameworks also contributed to dropping this path.
Code for some of those experiments will be available on the GitHub repo once we find some time to get it retested and cleaned up.
Tips & TricksWhile learning Zephyr we developed a simple SPI driver for MAX6675 thermocouple ADC. It's available as a patch for Zephyr 2.4.99 (shipped with nRF SDK 1.5.0).
It was a great help to get nRF Connect application from Play store. It's perfect for getting detailed information about the peripheral device. Very stable and feature-packed for Bluetooth connection debugging. We couldn't however figure out if it's possible to use it as Brodcaster, or Observer.
There was some success with getting debug interface on nRF52840 dongle with external TTL to USB converter.
By default debug output is disabled. To redirect it to serial port (TX on pin 0.20, and RX on pin 0.24 by default) enable SERIAL, and UART_CONSOLE in project configuration.
Configuring USB interface for debugging initially seemed simple, but ultimately it only worked with Zephyr USB logging example. When configuration and code was copy-pasted to our application it was failing on first logging macro.
Here is some random note that I don't really remember where it comes from, but it was quite important when building Bluetooth examples from Zephyr for nRF52840 dongle:
Enable FLASH setting.To have a control over Bluetooh device name enable SETTINGS, and NVS. Then device name can be changed.
On the AppInventor side: Make sure you are not using outdated BLE plugin from 2019, newer android devices do not work on it because of much more restricted operating system, but it was fixed with 2020-december plugin.
Occasionally app was messing Bluetooth subsystem on the phone, showing bunch of errors. Closing app, and disabling->enabling cycle helped to get things back on track.
Pictures of the prototype
Comments