DIY Smartwatch Walkthrough

Note: full Juniper code for this tutorial is available here: https://github.com/calebh/cwatch/tree/clockonly/jun

Bluetooth Low Energy

Image of watch displaying time and date.

In this section of the tutorial we will discuss the implementation of the Bluetooth Low Energy functionality. This will allow the CLUE board to determine the current time by communicating with an Android smartphone app. The BLE spec contains a lot of details, so we will just focus on what's needed to get this part of the project working. BLE allows a device to set up what is essentially a shared "chalkboard" between two devices. Devices may advertise their existence, pair, and read and write to the shared chalkboard.

BLE devices may advertise multiple services, which can contain multiple characteristics. There is a list of common service and characteristic identifiers published in the BLE standards. In our project, the CLUE board will start one BLE service: SVC_CURRENT_TIME, under which we will have two characteristics: CHR_DAY_DATE_TIME and CHR_DAY_OF_WEEK.

module Ble
include("<bluefruit.h>", "<bluefruit_common.h>")

type servicet = service(ptr)
type characterstict = characterstic(ptr)

type advertisingFlagt = advertisingFlag(uint8)
type appearancet = appearance(uint16)
type secureModet = secureMode(uint16)
type propertiest = properties(uint8)

We begin by creating a new module in a file called Ble.jun. The library that we are wrapping is called bluefruit, so we will import the required C/C++ header files. We start by defining a wrapper type for a characteristic and service. These will hold a ptr, which is compiled to void *. Inside of our CWatch.jun we will define different services and characteristics as global C++ objects and wrap them in these types. We also define wrapper types for other BLE configuration options. Note that we could have made a value constructor for every configuration option instead of wrapping around an integer, however the bluefruit library expects these numbers.

// Wrap #define constants into a type
// The full Ble.jun wrapper contains many of these entries:

let propertiesWrite = {
    var n : uint8
    #n = (uint8_t) CHR_PROPS_WRITE;#
    properties(n)
}

let secModeNoAccess = {
    var n : uint16
    #n = (uint16_t) SECMODE_NO_ACCESS;#
    secureMode(n)
}

let secModeOpen = {
    var n : uint16
    #n = (uint16_t) SECMODE_OPEN;#
    secureMode(n)
}

let bleGapAdvFlagsLeOnlyGeneralDiscMode = {
    var n : uint8
    #n = BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE;#
    advertisingFlag(n)
}

let appearanceGenericWatch = {
    var n : uint16
    #n = BLE_APPEARANCE_GENERIC_WATCH;#
    appearance(n)
}

We now define a few functions for starting services and characteristics and configuring them. Note that we use pattern matching inside the let expressions to pull out the ptrs, then cast them appropriately. The readGeneric function is particularly notable as it is polymorphic in its return type. By constraining the return type, we can change how many bytes are read into the variable and what is therefore returned by the function.

fun beginService(s) = {
    let service(p) = s
    // p is a ptr aka a void *
    // Inside of our inline C++, we cast it back to a BLEService *
    #((BLEService *) p)->begin();#
}

fun beginCharacterstic(c) = {
    let characterstic(p) = c
    #((BLECharacteristic *) p)->begin();#
}

fun readGeneric(c) : t = {
    let characterstic(p) = c
    var ret : t
    #((BLECharacteristic *) p)->read((void *) &ret, sizeof(t));#
    ret
}

fun setCharacteristicPermission(c, readPermission, writePermission) = {
    let secureMode(readN) = readPermission
    let secureMode(writeN) = writePermission
    let characterstic(p) = c
    #((BLECharacteristic *) p)->setPermission((SecureMode_t) readN, (SecureMode_t) writeN);#
}

fun setCharacteristicProperties(c, prop) = {
    let properties(propN) = prop
    let characterstic(p) = c
    #((BLECharacteristic *) p)->setProperties(propN);#
}

fun setCharacteristicFixedLen(c, size : uint32) = {
    let characterstic(p) = c
    #((BLECharacteristic *) p)->setFixedLen(size);#
}

We now add a few more functions for controlling the top level BLE device on the CLUE board. These functions will things like advertising names, intervals, and power levels.

// Bluefruit top level functions
fun bluefruitBegin() = #Bluefruit.begin();#
fun bluefruitPeriphSetConnInterval(minTime : uint16, maxTime : uint16) = #Bluefruit.Periph.setConnInterval(minTime, maxTime);#
fun bluefruitSetTxPower(power : int8) = #Bluefruit.setTxPower(power);#
fun bluefruitSetName(name : string) = #Bluefruit.setName(name);#

// Bluefruit advertising functions
fun bluefruitAdvertisingAddFlags(flag) = {
    let advertisingFlag(flagNum) = flag
    #Bluefruit.Advertising.addFlags(flagNum);#
}
fun bluefruitAdvertisingAddTxPower() = #Bluefruit.Advertising.addTxPower();#
fun bluefruitAdvertisingAddAppearance(app) = {
    let appearance(flagNum) = app
    #Bluefruit.Advertising.addAppearance(flagNum);#
}
fun bluefruitAdvertisingAddService(serv) = {
    let service(p) = serv
    #Bluefruit.Advertising.addService(*((BLEService *) p));#
}
fun bluefruitAdvertisingAddName() = #Bluefruit.Advertising.addName();#
fun bluefruitAdvertisingRestartOnDisconnect(restart : bool) = #Bluefruit.Advertising.restartOnDisconnect(restart);#
fun bluefruitAdvertisingSetInterval(slow : uint16, fast : uint16) = #Bluefruit.Advertising.setInterval(slow, fast);#
fun bluefruitAdvertisingSetFastTimeout(sec : uint16) = #Bluefruit.Advertising.setFastTimeout(sec);#
fun bluefruitAdvertisingStart(timeout : uint16) = #Bluefruit.Advertising.start(timeout);#

We now return to the CWatch.jun file and add some inline C++ in the top level module. We define the service object, characteristics and handlers for when the CLUE board receives data from the smartphone. We also define two packed record types. These types are packed, which means that no members of the record will be padded. On the Android app side, we will ensure that the data we will write is packed as well. When Juniper transpiles to C++, these record types will be defined as packed structs.

module CWatch
include("<bluefruit.h>")

#
BLEUuid timeUuid(UUID16_SVC_CURRENT_TIME);
BLEService rawTimeService(timeUuid);

bool rawHasNewDayDateTime = false;
BLEUuid dayDateTimeUuid(UUID16_CHR_DAY_DATE_TIME);
BLECharacteristic rawDayDateTimeCharacterstic(dayDateTimeUuid);
void onWriteDayDateTime(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* data, uint16_t len) {
    rawHasNewDayDateTime = true;
}

bool rawHasNewDayOfWeek = false;
BLEUuid dayOfWeekUuid(UUID16_CHR_DAY_OF_WEEK);
BLECharacteristic rawDayOfWeek(dayOfWeekUuid);
void onWriteDayOfWeek(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* data, uint16_t len) {
    rawHasNewDayOfWeek = true;
}
#

alias dayDateTimeBLE = packed { month : uint8, day : uint8, year : uint32, hours : uint8, minutes : uint8, seconds : uint8 }
alias dayOfWeekBLE = packed { dayOfWeek : uint8 }

We now define a few more global constants, namely the wrappers around the services and characteristics. Recall that in Juniper, curly braces containing expressions separated by newlines are sequence expressions, and the return result of a sequence expression is determined by the final expression in the sequence. We also add a bunch of BLE initialization code to the setup function. This includes setting the characteristics to be writable, disabling security/encryption, setting the size of the characteristics via the sizeof expression, and setting up the advertising information.

let timeService = {
    var p : ptr
    #p = (void *) &rawTimeService;#
    Ble:service(p)
}

let dayDateTimeCharacterstic = {
    var p : ptr
    #p = (void *) &rawDayDateTimeCharacterstic;#
    Ble:characterstic(p)
}

let dayOfWeekCharacterstic = {
    var p : ptr
    #p = (void *) &rawDayOfWeek;#
    Ble:characterstic(p)
}

fun setup() = {
    Arcada:arcadaBegin()
    Arcada:displayBegin()
    Arcada:setBacklight(255)
    Arcada:createDoubleBuffer()
    Ble:bluefruitBegin()
    Ble:bluefruitPeriphSetConnInterval(9, 16)
    Ble:bluefruitSetTxPower(4)
    Ble:bluefruitSetName("CWatch")
    Ble:setCharacteristicProperties(dayDateTimeCharacterstic, Ble:propertiesWrite)
    Ble:setCharacteristicProperties(dayOfWeekCharacterstic, Ble:propertiesWrite)
    Ble:setCharacteristicPermission(dayDateTimeCharacterstic, Ble:secModeNoAccess, Ble:secModeOpen)
    Ble:setCharacteristicPermission(dayOfWeekCharacterstic, Ble:secModeNoAccess, Ble:secModeOpen)
    Ble:beginService(timeService)
    Ble:beginCharacterstic(dayDateTimeCharacterstic)
    Ble:beginCharacterstic(dayOfWeekCharacterstic)
    Ble:setCharacteristicFixedLen(dayDateTimeCharacterstic, sizeof(dayDateTimeBLE))
    Ble:setCharacteristicFixedLen(dayOfWeekCharacterstic, sizeof(dayOfWeekBLE))
    #
    rawDayDateTimeCharacterstic.setWriteCallback(onWriteDayDateTime);
    rawDayOfWeek.setWriteCallback(onWriteDayOfWeek);
    #
    Ble:bluefruitAdvertisingAddFlags(Ble:bleGapAdvFlagsLeOnlyGeneralDiscMode)
    Ble:bluefruitAdvertisingAddTxPower()
    Ble:bluefruitAdvertisingAddAppearance(Ble:appearanceGenericWatch)
    Ble:bluefruitAdvertisingAddService(timeService)
    Ble:bluefruitAdvertisingAddName()
    Ble:bluefruitAdvertisingRestartOnDisconnect(true)
    Ble:bluefruitAdvertisingSetInterval(32u16, 244u16)
    Ble:bluefruitAdvertisingSetFastTimeout(30u16)
    Ble:bluefruitAdvertisingStart(0u16)
}

We are now ready to write a function that will update the clockState global variable. Recall that clockState is a mutable record. This means that we are allowed to mutate the fields of the record via the record field access operator ..

fun processBluetoothUpdates() = {
    let mut hasNewDayDateTime = false
    let mut hasNewDayOfWeek = false
    #
    // rawHasNewDayDateTime and rawHasNewDayOfWeek are C/C++ global
    // variables that we defined previously. These variables are set
    // to true when the smartphone has finished writing data
    hasNewDayDateTime = rawHasNewDayDateTime;
    rawHasNewDayDateTime = false;
    hasNewDayOfWeek = rawHasNewDayOfWeek;
    rawHasNewDayOfWeek = false;
    #
    if hasNewDayDateTime {
        let bleData : dayDateTimeBLE = Ble:readGeneric(dayDateTimeCharacterstic)
        clockState.month =
            match bleData.month {
                0 => january()
                1 => february()
                2 => march()
                3 => april()
                4 => may()
                5 => june()
                6 => july()
                7 => august()
                8 => september()
                9 => october()
                10 => november()
                11 => december()
                _ => january()
            }
        clockState.day = bleData.day
        clockState.year = bleData.year
        clockState.hours = bleData.hours
        clockState.minutes = bleData.minutes
        clockState.seconds = bleData.seconds
    }
    if hasNewDayOfWeek {
        let bleData : dayOfWeekBLE = Ble:readGeneric(dayOfWeekCharacterstic)
        clockState.dayOfWeek =
            match bleData.dayOfWeek {
                0 => sunday()
                1 => monday()
                2 => tuesday()
                3 => wednesday()
                4 => thursday()
                5 => friday()
                6 => saturday()
                _ => sunday()
            }
    }
}

The only thing left to do is call the processBluetoothUpdates function inside of loop. This will potentially mutate the global clock state just before we start updating it and drawing the time.

fun loop() = {
    Gfx:drawVerticalGradient(0i16, 0i16, toInt16(Arcada:displayWidth()), toInt16(Arcada:displayHeight()), pink, purpleBlue)

    processBluetoothUpdates()

    // Rest of the loop code goes here
    // ...
}