DIY Smartwatch Walkthrough

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

Clock and Drawing Logic

Image of watch displaying time and date.
module CWatch

let pink : Color:rgb = {r:=0xFCu8, g:=0x5Cu8, b:=0x7Du8}
let purpleBlue : Color:rgb = {r:=0x6Au8, g:=0x82u8, b:=0xFBu8}

type month = january() | february() | march() | april() | may() | june() | july() | august() | september() | october() | november() | december()

type dayOfWeek = sunday() | monday() | tuesday() | wednesday() | thursday() | friday() | saturday()

alias datetime = { month : month, day : uint8, year : uint32, hours : uint8, minutes : uint8, seconds : uint8, dayOfWeek : dayOfWeek }

fun isLeapYear(y : uint32) =
    if (y % 4 != 0)
        false
    else if (y % 100 != 0)
        true
    else if (y % 400 == 0)
        true
    else
        false

fun daysInMonth(y : uint32, m : month) =
    match m {
        january() => 31u8
        february() =>
            if (isLeapYear(y))
                29u8
            else
                28u8
        march() => 31u8
        april() => 30u8
        may() => 31u8
        june() => 30u8
        july() => 31u8
        august() => 31u8
        september() => 30u8
        october() => 31u8
        november() => 30u8
        december() => 31u8
    }

fun nextMonth(m : month) =
    match m {
        january() => february()
        february() => march()
        march() => april()
        may() => june()
        june() => july()
        august() => august()
        september() => october()
        october() => november()
        december() => january()
    }

fun nextDayOfWeek(dw) =
    match dw {
        sunday() => monday()
        monday() => tuesday()
        tuesday() => wednesday()
        wednesday() => thursday()
        thursday() => friday()
        friday() => saturday()
        saturday() => sunday()
    }

fun dayOfWeekToCharList(dw : dayOfWeek) =
    match dw {
        sunday() => 'Sun'
        monday() => 'Mon'
        tuesday() => 'Tue'
        wednesday() => 'Wed'
        thursday() => 'Thu'
        friday() => 'Fri'
        saturday() => 'Sat'
    }

fun monthToCharList(m : month) =
    match m {
        january() => 'Jan'
        february() => 'Feb'
        march() => 'Mar'
        april() => 'Apr'
        may() => 'May'
        june() => 'Jun'
        july() => 'Jul'
        august() => 'Aug'
        september() => 'Sep'
        october() => 'Oct'
        november() => 'Nov'
        december() => 'Dec'
    }

In this section of the tutorial we will define the core watch logic and write the time transition functions. We begin by defining pink and purpleBlue, the colors used for the watch's gradient background. These numbers are defined using hexadecimal constants. We also define types for months and days of week. We then give an alias for a record type which will store all the information about the clock's current time. We then define some basic functions for determining if the current year is a leap year, a function for computing the number of days in a month, a function to transition from one month to the next, a function to transition from one weekday to the next, and functions to obtain charlist representations of these types.

fun secondTick(d : datetime) = {
    let {month := month, day := day, year := year, hours := hours, minutes := minutes, seconds := seconds, dayOfWeek := dayOfWeek} = d
    let seconds1 = seconds + 1u8
    let (seconds2, minutes1) =
        if (seconds1 == 60u8)
            (0u8, minutes + 1u8)
        else
            (seconds1, minutes)
    let (minutes2, hours1) =
        if (minutes1 == 60u8)
            (0u8, hours + 1u8)
        else
            (minutes1, hours)
    let (hours2, day1, dayOfWeek2) =
        if (hours1 == 24u8)
            (0u8, day + 1u8, nextDayOfWeek(dayOfWeek))
        else
            (hours1, day, dayOfWeek)
    let daysInCurrentMonth = daysInMonth(year, month)
    let (day2, (month2, year2)) =
        if (day1 > daysInCurrentMonth)
            (
                1u8,
                match month {
                    december() =>
                        (january(), year + 1u32)
                    _ =>
                        (nextMonth(month), year)
                }
            )
        else
            (day1, (month, year))
    {month := month2, day := day2, year := year2, hours := hours2, minutes := minutes2, seconds := seconds2, dayOfWeek := dayOfWeek2}
}

We now define a function called secondTick which given an input datetime, returns a new datetime advanced 1 second in time. This function handles all the logic for seconds rolling into minutes, hours into days, days into months, and months into years.

let mut clockState = {month := september(), day := 9, year := 2020, hours := 18, minutes := 40, seconds := 0, dayOfWeek := wednesday()} : datetime

fun setup() = {
    Arcada:arcadaBegin()
    Arcada:displayBegin()
    Arcada:setBacklight(255)
    Arcada:createDoubleBuffer()
    // To be expanded with Bluetooth Low Energy initialization
}

We now define a mutable global variable which will hold the current clock state. We also define a setup function, which will be called once when the CLUE board starts. It simply performs the various initializations required by the Arcada and graphics libraries.

let mut clockTickerState = Time:state

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

    // TODO: Process Bluetooth updates

    Time:every(1000u32, inout clockTickerState) |>
    Signal:foldP((t, dt) => secondTick(dt), inout clockState) |>
    Signal:latch(inout clockState) |>
    Signal:sink((dt) => {
        let {month := month, day := day, year := year,
             hours := hours, minutes := minutes,
             seconds := seconds, dayOfWeek := dayOfWeek} = dt

        // Convert 24 hour format to 12 hour format
        let displayHours : int32 = toInt32(
            if (hours == 0u8)
                12u8
            else if (hours > 12u8)
                hours - 12u8
            else
                hours)

        Gfx:setTextColor(Color:white)

        Gfx:setFont(Gfx:freeSans24())
        Gfx:setTextSize(1)
       
        // Construct the hours:minutes string
        let timeHourStr : charlist<2> = CharList:i32ToCharList(displayHours)
        let timeHourStrColon = CharList:safeConcat(timeHourStr, ':')
        let minutesStr : charlist<2> =
            if (minutes < 10u8)
                CharList:safeConcat('0', CharList:i32ToCharList(toInt32(minutes)))
            else
                CharList:i32ToCharList(toInt32(minutes))
        let timeStr = CharList:safeConcat(timeHourStrColon, minutesStr)

        // Move the cursor to the center of the screen
        let (_, _, w, h) = Gfx:getCharListBounds(0, 0, timeStr)
        Gfx:setCursor(toInt16((Arcada:displayWidth() / 2) - (w / 2)),
                      toInt16((Arcada:displayHeight() / 2) + (h / 2)))
       
        // Print the time
        Gfx:printCharList(timeStr)

        Gfx:setTextSize(1)
        Gfx:setFont(Gfx:freeSans9())

        // Determine if we are in AM or PM
        let ampm =
            if (hours < 12u8)
                'AM'
            else
                'PM'
       
        // Print the AM or PM
        let (_, _, w2, h2) = Gfx:getCharListBounds(0, 0, ampm)
        Gfx:setCursor(toInt16((Arcada:displayWidth() / 2) - (w2 / 2)),
                      toInt16((Arcada:displayHeight() / 2) + (h / 2) + h2 + 5))
        Gfx:printCharList(ampm)

        // Construct the day of week, month day string
        // Example: Sun Apr 31
        let dateStr =
            CharList:safeConcat(
            CharList:safeConcat(
            CharList:safeConcat(
            CharList:safeConcat(
                dayOfWeekToCharList(dayOfWeek), ', '),
                monthToCharList(month)),
                ' '),
                CharList:i32ToCharList(toInt32(day)) : charlist<2>)

        // Draw the day string above the time
        let (_, _, w3, h3) = Gfx:getCharListBounds(0, 0, dateStr)
        Gfx:setCursor(cast((Arcada:displayWidth() / 2) - (w3 / 2)),
                      cast((Arcada:displayHeight() / 2) - (h / 2) - 5))
        Gfx:printCharList(dateStr)
    })

    Arcada:blitDoubleBuffer()
}

We now define a global variable to keep the state for Time:every and the loop function which will redraw the screen. The loop function begins by clearing the entire screen with a vertical gradient of our desired color. Then a we construct a value that holds a value every 1000 milliseconds (1 second). When that signal holds a value, we transition the clockState by using the secondTick function. We then pipe this signal to another signal processing function called Signal:latch. The latch function always emits a value on its output signal given by its current state. When it receives a value on its input signal, it mutates its state to contain that value. Therefore the output signal passed to Signal:sink will always hold a value.

/*
    Function: sink

    Applies the function on the given signal for the purpose of performing
    side effects.

    Type signature:
    | ((a) -> unit, sig<a>) -> unit

    Parameters:
        f : (a) -> unit - The function to call
        s : sig<a> - The input signal

    Returns:
        Unit
*/
fun sink(f : (a) -> unit, s : sig<a>) : unit =
    match s {
        signal(just(val)) => f(val)
        _ => ()
    }

As you can see in the above snippet from the Signal module in the Juniper standard library, the sink function will call its input function if the input signal holds a value, and will otherwise return unit. Therefore the purpose of sink is to perform side effects if the input signal holds a value. Here we are we are extracting the current time, computing the string representation of the various values, performing string concatenation where appropriate, moving the cursor around and drawing the text.

Of particular interest are the CharList:i32ToCharList, CharList:safeConcat, and Prelude:cast functions.

i32ToCharList converts an integer to a charlist, where the capacity (maximum size) of the charlist is polymorphic in the return type. The type of this function is (int32) -> charlist<n>, so we must specify a return type constraint at the callsite. In this case, the number of characters in a minute is at most 2.

The safeConcat function uses type level arithmetic to ensure that the concatenation of two charlists always has enough capacity. Its type signature is (charlist<aCap>, charlist<bCap>) -> charlist<aCap+bCap>

/*
    Function: cast

    Converts a number of one type to a number of another type.

    Type signature:
    | (a) -> b where a : num, b : num

    Parameters:
        x : a - The number to convert
   
    Returns:
        The number with converted type
*/
fun cast(x : a) : b where a : num, b : num = {
    var ret : b
    #ret = (b) x;#
    ret
}

The cast function is used to generically convert between two numerical types. If the parameter and return type are both bound by constraints in the program, it can be used to convert between the two types without annotation from the programmer. Its type is (a) -> b where a : num, b : num. As we can see from the definition given in the Juniper standard library, its implementation is very simple. There are two features introduced here: interface constraints, given after the where keyword, and the use of var. Interface constraints are used to add constraints to generic type parameters. In this case, we insist that the type variables a and b must be numerical types. The var keyword is used to define uninitialized variables. Frequently these variables will be mutated by some inline C++ code, as was done here.