Bluey Beacon – Building a Nordic nRF52832 BLE IoT Sensor Beacon



A BLE Beacon


In this project, we’re going to build a BLE Beacon that transmits temperature, humidity and ambient light levels to a dashboard on the internet.


Bluetooth Low Energy (BLE) is a technology that was designed from the ground up to reduce power consumption. It’s common for BLE devices to keep going and going (like our pink furry friend) for months on a coin cell battery. A beacon is a typical example of such a device. All it does is wake up periodically, send data, and go back to sleep. There are different methods of connecting to a BLE device. In the case of the beacon, typically a non-connectable mode is used, either as ADV_NONCONN_IND or ADV_SCAN_IND. For our beacon, we will be using the first of these methods. The two methods are compared in the graphic below:


 


So we’ll be sending sensor data in the advertisement packets, and there will be no scan response from the device.


So how we get this sensor data on to the internet? For that we’ll use a Raspberry Pi 3 as a gateway. As you will see below, a Python script will grab the advertisement packets, parse the sensor data and use a combination of dweet.io and freeboard.io to post this data on the web.


Now, let’s look at the hardware.


Hardware


We’ll be building the beacon using the Bluey nRF52832 development board, which comes built-in with accelerometer/gyroscope, temperature/humidity, and ambient light sensors. Although the code here is specific to this board, you can easily replicate this project using any nRF52 board with similar sensors – just change the code that gets data from your specific sensors. If you want to use Bluey though, it’s available for purchase on our Tindie store.


Firmware


We’ll be using the Nordic nRF 5 SDK (version 12.2.0) to develop the firmware on nRF52832. I won’t cover SDK, toolchain and code upload here. But please check out the other articles I have written on Nordic nRF BLE development for details.


Nordic has a bunch of great examples in their SDK, and a good place to start for our beacon project is the ble_app_beacon project in the examples/ble_peripheral directory.


Here’s our main loop:


 

int main(void)
{
    uint32_t err_code;

    APP_TIMER_INIT(APP_TIMER_PRESCALER, APP_TIMER_OP_QUEUE_SIZE, false);

    ble_stack_init();
    advertising_init();
    timers_init();
    advertising_start();
    application_timers_start();
    
    twi_init();
    HDC1010_init(TEMP_OR_HUMID);
    APDS9301_init();

    // Enter main loop.
    for (;; )
    {
      // one-shot flag to set adv data
      if(g_setAdvData) {
        set_adv_data(false);
        g_setAdvData = false;
      }

      power_manage();
    }
}

The above code goes through initializing the BLE stack, advertising, and timers. Next, it initializes TWI (I2C) for communicating with the sensors, and calls the initialization code for the temperature/humidity and ambient light sensors.


In the main loop, all the code does is the following: if a flag is true, set the advertising data, reset the flag, and go to sleep.


The g_setAdvData flag is set up as follows:


 

// one-shot flag for setting adv data
static volatile bool g_setAdvData = false;

/**@brief Function for handling the Battery measurement timer timeout.
 *
 * @details This function will be called each time the battery level measurement timer expires.
 *
 * @param[in] p_context  Pointer used for passing some arbitrary information (context) from the
 *                       app_start_timer() call to the timeout handler.
 */
static void beacon_timeout_handler(void * p_context)
{
    UNUSED_PARAMETER(p_context);
    // set update data flag
    g_setAdvData = true;
}

In a timer, we periodically set this flag, which is then checked in the main loop and the advertisement packet sent. Before we look at how the data is sent, let’s look at how advertisement is setup.


Here’s the definition for advertising_init():


 

static void advertising_init(void)
{
    uint32_t      err_code;


    ble_gap_conn_sec_mode_t sec_mode;

    BLE_GAP_CONN_SEC_MODE_SET_OPEN(&sec_mode);

    err_code = sd_ble_gap_device_name_set(&sec_mode,
                                          (const uint8_t *)DEVICE_NAME,
                                          strlen(DEVICE_NAME));
    APP_ERROR_CHECK(err_code);

    set_adv_data(true);

    // Initialize advertising parameters (used when starting advertising).
    memset(&m_adv_params, 0, sizeof(m_adv_params));

    m_adv_params.type        = BLE_GAP_ADV_TYPE_ADV_NONCONN_IND;
    m_adv_params.p_peer_addr = NULL;                             // Undirected advertisement.
    m_adv_params.fp          = BLE_GAP_ADV_FP_ANY;
    m_adv_params.interval    = NON_CONNECTABLE_ADV_INTERVAL;
    m_adv_params.timeout     = APP_CFG_NON_CONN_ADV_TIMEOUT;
}

As you can see above, we are using the BLE_GAP_ADV_TYPE_ADV_NONCONN_IN mode for this project. Non-connectable indication only (no scanning) advertisement.


All action happens in set_adv_data():


 

// set adv data
void set_adv_data(bool init)
{
  uint32_t      err_code;

  ble_advdata_t advdata;
  uint8_t       flags = BLE_GAP_ADV_FLAG_BR_EDR_NOT_SUPPORTED;

  ble_advdata_manuf_data_t        manuf_data; // Variable to hold manufacturer specific data
  // Initialize with easily identifiable data
  uint8_t data[]                      = {
    0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9,
    0xb1, 0xb2, 0xb3, 0xb4
  };

  // get sensor data
  if (!init) {
    // get T/H
    uint16_t temp_val, humid_val;
    HDC1010_get_temp_raw(&temp_val);
    data[0] = temp_val >> 8;
    data[1] = temp_val;
    HDC1010_get_humid_raw(&humid_val);
    data[2] = humid_val >> 8;
    data[3] = humid_val;
    // get ambient light value
    uint16_t adc_ch0, adc_ch1;
    APDS9301_read_adc_data(&adc_ch0, &adc_ch1);
    data[4] = adc_ch0 >> 8;
    data[5] = adc_ch0;
    data[6] = adc_ch1 >> 8;
    data[7] = adc_ch1;
  }

  manuf_data.company_identifier       = 0xFFFF;
  manuf_data.data.p_data              = data;
  manuf_data.data.size                = sizeof(data);

  // Build advertising data struct to pass into @ref ble_advertising_init.
  memset(&advdata, 0, sizeof(advdata));

  advdata.name_type               = BLE_ADVDATA_SHORT_NAME;
  advdata.short_name_len          = 5;
  advdata.flags                   = flags; //BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE;
  advdata.p_manuf_specific_data   = &manuf_data;
  // if you don't set tx power, you get 3 extra bytes for manuf data
  //int8_t tx_power                 = -4;
  //advdata.p_tx_power_level        = &tx_power;
  advdata.include_appearance      = true;

  err_code = ble_advdata_set(&advdata, 0);
  APP_ERROR_CHECK(err_code);
}

The init flag above is used so that we don’t try to read sensors before they are initialized. The main thing to understand above is that we are packing off custom sensor data in the manuf_data.data.p_data field. At the receiving end, we have to unpack and put the data together in the same order, as we will see.


So at this point our firmware is ready, the beacon is sending advertising packets containing sensor data periodically. Now we need to pick up this data.


Software


On the receiving side, we’re going to use a Raspberry Pi 3. Assuming you have set up your Pi, the next (required) step is to install the BlueZ – the official Linux bluetooth stack.


Crash Course on BlueZ BLE


BlueZ is a complex, powerful set of tools and delving into it is beyond the scope of this post. Unfortunately, even the official page seems to have no proper documentation. My own (shaky) understanding came from bits and pieces I put together from various websites.


Here’s a taste of how BlueZ BLE tools work on the Pi – we’re running the tools to scan BLE data from our beacon, and all this is on a Raspberry Pi 3 which has built-in WiFi and Bluetooth/BLE.


First, we run hcidump to listen in on incoming raw BLE data:


pi@raspberrypi:~ $ sudo hcidump --raw
HCI sniffer - Bluetooth packet analyzer ver 5.43
device: hci0 snap_len: 1500 filter: 0xffffffff

Then, we run hcitool to scan for BLE devices. Run this in a separate xterm.

pi@raspberrypi:~ $ sudo hcitool lescan
LE Scan ...
E2:91:9F:03:C5:0D bluey

You can see that hcitool is seeing our beacon. At the same time, you’ll see a stream of new data in the hcidump window:


< 01 0B 20 07 01 10 00 10 00 00 00
> 04 0E 04 01 0B 20 00
< 01 0C 20 02 01 01
> 04 0E 04 01 0C 20 00
> 04 3E 2B 02 01 03 01 0D C5 03 9F 91 E2 1F 03 19 00 00 02 01
  04 10 FF FF FF 66 60 D2 7C 02 E3 00 AE A9 B1 B2 B3 B4 06 08
  62 6C 75 65 79 CF


The last three lines of data are what we are concerned about. 62 6C 75 65 79 above – looks familiar? Those are the ASCII hex codes for ‘b’, ‘l’, ‘u’, ‘e’, ‘y’ – “bluey” – the device name we used in set_adv_data() above.


The sensor data is in the following bytes:


1F 03 19 00 00 02 01 04 10 FF FF FF 66 60 D2 7C 02 E3 00 AE A9 B1 B2 B3 B4

All we need to do is parse this data. So, instead of doing all this manually, we’re going to write a Python script that will run the BlueZ tools and grab this data for us.


Scanning the beacon data and Posting it


Now, lets look at the Python code that sets up the BlueZ tools:


 

class BLEScanner:

    hcitool = None
    hcidump = None
    
    def start(self):
        print('Start receiving broadcasts')
        DEVNULL = subprocess.DEVNULL if sys.version_info > (3, 0) else open(os.devnull, 'wb')

        subprocess.call('sudo hciconfig hci0 reset', shell = True, stdout = DEVNULL)
        self.hcitool = subprocess.Popen(['sudo', '-n', 'hcitool', 'lescan', '--duplicates'], stdout = DEVNULL)
        self.hcidump = subprocess.Popen(['sudo', '-n', 'hcidump', '--raw'], stdout=subprocess.PIPE)

    def stop(self):
        print('Stop receiving broadcasts')
        subprocess.call(['sudo', 'kill', str(self.hcidump.pid), '-s', 'SIGINT'])
        subprocess.call(['sudo', '-n', 'kill', str(self.hcitool.pid), '-s', "SIGINT"])

    def get_lines(self):
        data = None
        try:
            print("reading hcidump...\n")
            #for line in hcidump.stdout:
            while True:
                line = self.hcidump.stdout.readline()
                line = line.decode()
                if line.startswith('> '):
                    yield data
                    data = line[2:].strip().replace(' ', '')
                elif line.startswith('< '):
                    data = None
                else:
                    if data:
                        data += line.strip().replace(' ', '')
        except KeyboardInterrupt as ex:
            print("kbi")
            return
        except Exception as ex:
            print(ex)
            return

In the above code, we create a class that will help us create two processes – hcidump and hcitool, and parse the data from the hcidump output.

Here’s the main loop in the code:


 

data = None
    while True:
        for line in scanner.get_lines():
            if line:
                found_mac = line[14:][:12]
                reversed_mac = ''.join(
                    reversed([found_mac[i:i + 2] for i in range(0, len(found_mac), 2)]))
                mac = ':'.join(a+b for a,b in zip(reversed_mac[::2], reversed_mac[1::2]))
                data = line[26:]
                if mac == deviceId and len(data) == 66:
                    #print(mac, data)
                    if u'626C756579' in data:
                        data2 = data[24:50]
                        #print(data)
                        x1 = int(data2[0:4], 16)
                        x2 = int(data2[4:8], 16)
                        x3 = int(data2[8:12], 16)
                        x4 = int(data2[12:16], 16)
                        #print("%x %x %x %x\n" % (x1, x2, x3, x4))
                        T, H, L = decodeData(x1, x2, x3, x4)
                        dweetURL = baseURL + "T=%.2f&&H=%.1f&&L=%d" % (T, H, L)
                        print(dweetURL)
                        try:
                            f = urllib2.urlopen(dweetURL)
                            res = f.read()
                            print(res)
                            f.close()
                        except:
                            print("dweet failed!")
        scanner.stop()
        exit(0)

In the above code, we’re first filtering against a MAC address and also checking for the “bluey” string in the advertisement data. If that matches, then we parse the sensor data.


OK, now have the sensor data, how do we get this on to the web? In this project, we’re going to use a combination of dweet.io and freeboard.io to achieve this.


To be honest, this New Age, Webby way of doing things seems overcomplicated to me. Many of these IoT dashboard services which started off as free have now started charging (naturally) and many of the free ones look awful, or have inconvenient restrictions. But the good news is that you have the sensor data on a Pi and you can send it wherever you want – display it on the Pi, or build your own dashboard – if you have the time and the inclination.


Anyway, the idea here is that you make a web request to a unique dweet URL, and set that as the datasource in freeboard. Thus your sensor data ends up in the dashboard. If you want a graph of historic data, you need to use their paid services. So we are just displaying real-time data here.


Here’s the code for parsing the sensor data:


 

# constant
pow_16 = 65536.0

# decode temperature
def decodeT(temp_val):
    return ((temp_val / pow_16) * 165 - 40)

# decode humidity
def decodeH(humid_val):
    return ((humid_val / pow_16) * 100)

# decode ambient light
def decodeL(adc_ch0, adc_ch1):
    result = 999.99
    channelRatio = (adc_ch1)/(float)(adc_ch0);
    # below formula is from datasheet
    if(channelRatio >= 0 and channelRatio <= 0.52):
        result = (0.0315 * adc_ch0) - (0.0593 * adc_ch0 * pow(channelRatio, 1.4))
    elif(channelRatio > 0.52 and channelRatio <= 0.65):
        result = (0.0229 * adc_ch0) - (0.0291 * adc_ch1)
    elif(channelRatio > 0.65 and channelRatio <= 0.80):
        result = (0.0157 * adc_ch0) - (0.0180 * adc_ch1)
    elif(channelRatio > 0.80 and channelRatio <= 1.30):
        result = (0.00338 * adc_ch0) - (0.00260 * adc_ch1)
    elif(channelRatio > 1.30):
        result = 0;
    return result

# decode T/H/L data
def decodeData(x1, x2, x3, x4):
    T = decodeT(x1)
    H = decodeH(x2)
    L = decodeL(x3, x4)
    return (T, H, L)

We’re just unpacking the data in the same format as was set in the set_adv_data() call in the firmware.


Here’s what the dashboard looks like:


 


Enclosure


Since we want to hand the beacon on a wall some place, we’ll also design a simple enclosure for it, which can be laser cut. Here’s what the design looks like – it was designed using Inkscape.


 


The above design is for 3 mm thick acrylic.


Here’s what the final enclosure looks like:


 


Although you see a coin cell above, I recommend using a 3.7 V LiPo battery instead – that will last much longer.


Downloads


All code and design files for this project can be found at the github repository below. Navigate to the code/bluey-beacon directory. The firmware is in the bluey-beacon-nrf52 directory. Note that the the beacon code depends on code in the code/bluey-common directory – so follow installation instructions in code/README.md.

https://github.com/electronut/electronutlabs-bluey