DIY Smartwatch Walkthrough

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

In this tutorial we will walk through the architecture and design of a small Juniper project: a DIY Smartwatch running on the Adafruit CLUE Board. We will cover important concepts, like how to interface with existing C++ code/drivers and new features like pattern matching. Since the CLUE Board does not contain long-term memory, and the clock does not run while powered down we will have to synchronize time and date information with an external source every time the board starts. This will be accomplished via the CLUE's Bluetooth Low Energy functionality in conjunction with a smartphone app.

Graphics library, Wrapping C++, Strings, Records

Image of watch displaying time and date.
module Arcada
include("\"Adafruit_Arcada.h\"")

#
Adafruit_Arcada arcada;
#

Let's begin by defining a module named Arcada.jun. This module will act as a wrapper around Adafruit's Arcada library which we will use to control the CLUE's screen. We begin by giving the name of the module, then include the Arcada header file. When Juniper transpiles to C++, this header will be included via a C++ preprocessor #include statement. Since Juniper transpiles to C++, Juniper includes the ability to write inline C++. Here we are writing inline C++ to define a global variable called arcada. This globally defined object will be used by the methods we now define in the module:

fun arcadaBegin() = {
    let mut ret = false
    #ret = arcada.arcadaBegin();#
    ret
}

fun displayBegin() = #arcada.displayBegin();#

fun setBacklight(level : uint8) = #arcada.setBacklight(level);#

The arcadaBegin, displayBegin, and setBacklight functions utilize the global variable by calling its methods. Like before, the inline C++ is contained within the number/pound/hashtag sign (#). When used as an expression, a block of inline C++ has access to all Juniper variables and is free to mutate them. The Juniper compiler takes care to minimize name mangling. We can see this in action in the arcadaBegin function, where we mutate the ret variable with the result of calling the C++ method arcada.arcadaBegin. The return value of an inline C++ block is the unit value.

fun displayWidth() : uint16 = {
    let mut w : uint16 = 0u16
    #w = arcada.display->width();#
    w
}

fun displayHeight() : uint16 = {
    let mut h : uint16 = 0u16
    #h = arcada.display->width();#
    h
}

We now define some functions for getting information about the width and height of the screen from the Arcada object. Like before, we are using inline C++ which mutates the local variables defined in the function.

fun createDoubleBuffer() : bool = {
    let w = displayWidth()
    let h = displayHeight()
    let mut ret = true
    #ret = arcada.createFrameBuffer(w, h);#
    ret
}

fun blitDoubleBuffer() : bool = {
    let mut ret = true
    #ret = arcada.blitFrameBuffer(0, 0, true, false);#
    ret
}

Finally we have some functions for creating a double buffer and swapping the two buffers. Double buffering will allow our smartwatch to display text and other information without any screen tearing.

module Gfx
open(Arcada, Color)
include("<Adafruit_GFX.h>", "<Fonts/FreeSans9pt7b.h>", "<Fonts/FreeSans24pt7b.h>")

type font = defaultFont() | freeSans9() | freeSans24()

fun setFont(f : font) =
    match f {
        defaultFont() => #arcada.getCanvas()->setFont();#
        freeSans9() => #arcada.getCanvas()->setFont(&FreeSans9pt7b);#
        freeSans24() => #arcada.getCanvas()->setFont(&FreeSans24pt7b);#
    }

fun drawFastHLine565(x : int16, y : int16, w : int16, c : uint16) =
    #arcada.getCanvas()->drawFastHLine(x, y, w, c);#

fun printCharList(cl : charlist<n>) =
    #arcada.getCanvas()->print((char *) &cl.data[0]);#

fun printString(s : string) =
    #arcada.getCanvas()->print(s);#

We now make a new module called Gfx.jun, open up the Arcada module and the Color module (which is included in the Juniper standard library). We include the relevant C++ headers, one for performing graphics operations with the Arcada library and two font definitions. The C++ drawing library is designed to be stateful. This means that to draw text with a certain font, we first must set the font and then draw the text. The body of the setFont function pattern matches on the input font, and based on that value sets the arcada font.

The primary function we will be using to draw the gradient background is the Arcada drawFastHLine, which fills an entire horizontal line of pixels with a given color. The color is given as an RGB565 uint16 (this is also known as 16-bit RGB).

Here we also encounter our first use of the two primary ways to represent textual data in Juniper: charlists and string. charlists are lists of ASCII characters and can be mutated via the various list manipulation functions. charlists are used when we want to create text dynamically. If we look at the definition of charlist inside Prelude.jun (part of the Juniper standard library), we can see that it is a type alias:

/*
    Type: alias charlist

    The charlist alias represents a list of extended ASCII characters at most
    length n (this does not include the final null byte). To get the length
    of the string not including the null byte, use CharList:length.

    | alias charlist<n : int> = list<uint8, n+1>

    Members:
        data : uint8[n+1]
        length : uint32
*/
alias charlist<n : int> = list<uint8, n + 1>

Type aliases are a way to give shorter names to types to use in our program. Type aliases are automatically expanded where they are used by the Juniper compiler. In this case, we see that the charlist type alias is generic over an integer parameter. This introduces a new feature of Juniper: type level integers that are known at compile time. This is in fact a limited form of dependent types: we can write integers in the type level, do basic arithmetic operations on them, and use them in expressions at the value level. However we cannot promote integers at the value level to be used at the type level. In this case we can see that our alias is polymorphic over an integer parameter, and will create a list of capacity n + 1. This will ensure that we will always reserve a single byte for the terminating null character. This will allow us to easily use charlists when interacting with C/C++ libraries.

/*
    Type: alias list

    The list record type represents an ordered series of elements of a given
    length.

    | alias list<a, n : int> = { data : a[n], length : uint32 }

    Members:
        data : a[n] - The internal array used to store the elements.
        length : uint32 - The length of the list
*/
alias list<a, n : int> = { data : a[n], length : uint32 }

A list is also an alias, but this time for a record type. A record type is defined based on the names and types of its fields. In this case a list has a data field containing an array of fixed size and the number of elements in the list. We can therefore see that the list has an upper bound of n elements, but has a dynamic length.

Turning attention back to printCharList, we see that it consists of an inline C++ block that draws a charlist on the screen be passing a pointer to the first character in the list.

How is the string type different from charlists? The string type is for non-mutable strings - strings that are fixed and known at compile time. These strings are stored in the program memory by the compiler whereas charlists live on the stack or heap. Immutable values of string type are declared using double quotes ("), whereas charlist literals are declared using single quotes (').

fun getCharListBounds(x : int16, y : int16, cl : charlist<n>) = {
    let mut xret : int16 = 0i16
    let mut yret : int16 = 0i16
    let mut wret : uint16 = 0u16
    let mut hret : uint16 = 0u16
    #arcada.getCanvas()->getTextBounds((const char *) &cl.data[0], x, y, &xret, &yret, &wret, &hret);#
    (xret, yret, wret, hret)
}

fun setCursor(x : int16, y : int16) = #arcada.getCanvas()->setCursor(x, y);#

If you look at the picture of the smartwatch at the top of this page, you will notice that the time and date are centered in the middle of the screen. Arcada features a stateful cursor functionality which changes the location of the printed/drawn text. By default text is drawn by specifying the x/y coordinates of the text's top left hand corner. To center the text on our smartwatch face, we will use Arcada's ability to compute character bounds, which will compute the bounding box of a given charlist. We define a function called getCharListBounds returns the bounds as a tuple, and the function setCursor to set the cursor position. This functionality will be used to compute the coordinates at which we will draw the text.

fun setTextColor(c : rgb) = {
    let cPrime = rgbToRgb565(c)
    #arcada.getCanvas()->setTextColor(cPrime);#
}

fun setTextSize(size : uint8) = #arcada.getCanvas()->setTextSize(size);#

fun drawVerticalGradient(x0i : int16, y0i : int16, w : int16, h : int16, c1 : Color:rgb, c2 : Color:rgb) = {
    let dispW = toInt16(Arcada:displayWidth())
    let dispH = toInt16(Arcada:displayHeight())
    let x0 = Math:clamp(x0i, 0i16, dispW - 1i16)
    let y0 = Math:clamp(y0i, 0i16, dispH - 1i16)
    let ymax = y0i + h
    let y1 = Math:clamp(ymax, 0i16, dispH)

    let {r := r1, g := g1, b := b1} = c1
    let {r := r2, g := g2, b := b2} = c2
    let r1f = toFloat(r1)
    let g1f = toFloat(g1)
    let b1f = toFloat(b1)
    let r2f = toFloat(r2)
    let g2f = toFloat(g2)
    let b2f = toFloat(b2)

    for y : int16 in y0 .. y1 {
        let weight1 = toFloat(ymax - y) / toFloat(h)
        let weight2 = 1.0f - weight1
        let gradColor = {
            r := toUInt8(r1f * weight1 + r2f * weight2),
            g := toUInt8(g1f * weight1 + g2f * weight2),
            b := toUInt8(b1f * weight1 + g2f * weight2)
        }
        let gradColor565 = Color:rgbToRgb565(gradColor)
        drawFastHLine565(x0, y, w, gradColor565)
    }
}

We now define setTextColor and setTextSize functions. The setTextColor function takes a rgb color and converts to to 16 bit RGB565 then calls the appropriate arcada method. The drawVerticalGradient function is more interesting. This function will draw a gradient inside a box at the given input position for a given width and height. The function will interpolate between color c1 and c2. These parameters are RGB colors given by the type alias Color:rgb. This alias is defined to be a struct with three fields (r, g, and b) of type uint8. We can see in this function that we use pattern matching in the let binding to store these fields in local variables. Instead of using pattern matching, we could have used dot notation to extract the fields, such as c1.r.

This function also makes use of Juniper's loops - in this case a for-in loop. Besides the for-in loop, Juniper also has a classic C/C++ style for loop, while loops, and do-while loops. All of these loops are expressions which return type unit. There is no break or continue keyword in the Juniper language. The range iterated over in this example is y0 <= y < y1. In this example the color of each horizontal pixel stripe is computed for each iteration of the loop using floating point arithmetic. We also see the construction of a new record by assigning specific values to each field (r, g, b).