Elm Pebble
Tutorial

Understanding the Watchface tutorial complete template

A guided walk through the finished watchface project for programmers who are comfortable with code, but new to Elm.

What the template builds

The complete tutorial template is a real watchface: it shows the time, optional date, weather text, battery level, and a Bluetooth warning icon. It adapts to rectangular and round Pebble screens, asks the phone companion for weather, and vibrates when the watch disconnects.

Most of the watch rendering code lives in watch/src/Main.elm. The shared protocol in protocol/src/Companion/Types.elm defines the messages that can travel between watch and phone, and phone/src/CompanionApp.elm implements the companion worker that fetches weather and replies.

The big idea: Elm programs are loops

If you know React, Redux, SwiftUI, game loops, or server request handlers, the structure will feel familiar. The program keeps all important state in one value called the model. Events arrive as messages. An update function receives a message and returns the next model plus any commands that should talk to the outside world.

The watchface is not building HTML. The view function returns Pebble drawing instructions, and elm-pebble turns those instructions into native Pebble rendering code.

Watch app: watch/src/Main.elm
type alias Model =
    { screenW : Int
    , screenH : Int
    , isRound : Bool
    , currentDateTime : Maybe PebbleTime.CurrentDateTime
    , batteryLevel : Maybe Int
    , connected : Maybe Bool
    , temperature : Maybe Temperature
    , condition : Maybe WeatherCondition
    , backgroundColor : Maybe PebbleColor.Color
    , textColor : Maybe PebbleColor.Color
    , showDate : Maybe Bool
    }

Step 1: the model is your app state

The model is an Elm record. A record is close to a typed object or struct: every field has a name and a type, and Elm checks that you use those fields consistently.

Several fields use Maybe. Think of Maybe as a safer nullable value: Nothing means the value has not arrived yet, and Just value means it is available. The watch starts before it has time, battery, connection, weather, or phone settings, so those values are optional.

  • screenW, screenH, and isRound come from the launch context so the same code can draw on different Pebble screen shapes.
  • currentDateTime, batteryLevel, and connected are facts read from the watch.
  • temperature, condition, backgroundColor, textColor, and showDate are supplied by the phone companion protocol.

Step 2: messages describe what happened

Msg is a custom type. In other languages you might model this with an enum, tagged union, sealed class, or discriminated union. Each variant names one thing that can happen to the program, and variants can carry data.

Watch app: watch/src/Main.elm
type Msg
    = CurrentDateTime PebbleTime.CurrentDateTime
    | FromPhone PhoneToWatch
    | MinuteChanged Int
    | HourChanged Int
    | BatteryLevelChanged Int
    | ConnectionStatusChanged Bool

This is why Elm code often reads like a list of cases: time can arrive, the phone can send a protocol message, a minute can tick, the battery can change, or the connection state can change.

Step 3: init builds the first model and asks for data

init runs once when the watchface starts. It copies screen information out of PebblePlatform.LaunchContext, fills everything else with Nothing, then batches several startup commands.

Watch app: watch/src/Main.elm
init context =
    ( { screenW = context.screen.width
      , screenH = context.screen.height
      , isRound = context.screen.isRound
      , currentDateTime = Nothing
      , batteryLevel = Nothing
      , connected = Nothing
      , temperature = Nothing
      , condition = Nothing
      , backgroundColor = Nothing
      , textColor = Nothing
      , showDate = Nothing
      }
    , Cmd.batch
        [ PebbleTime.currentDateTime CurrentDateTime
        , PebbleSystem.batteryLevel BatteryLevelChanged
        , PebbleSystem.connectionStatus ConnectionStatusChanged
        , CompanionWatch.sendWatchToPhone (RequestWeather CurrentLocation)
        ]
    )

Commands are Elm's way to request side effects. The code does not directly mutate global state or block while reading the battery. It asks the Pebble runtime to do work and says which Msg constructor should wrap the result when it comes back.

Step 4: update is the state machine

update receives a message and the current model. It returns the next model and a command. Record update syntax looks like { model | batteryLevel = Just level }: copy the old record, but replace one field.

  • CurrentDateTime stores the current clock value.
  • MinuteChanged updates only the minute, and every 30 minutes asks the phone for fresh weather.
  • HourChanged refreshes the full date/time so day and date rollovers stay correct.
  • BatteryLevelChanged stores the latest battery level, whether it came from the startup command or a later battery-change event.
  • ConnectionStatusChanged stores the latest connection state and vibrates twice when the phone disconnects.
Watch app: watch/src/Main.elm
ConnectionStatusChanged connected ->
    ( { model | connected = Just connected }
    , if connected then
        Cmd.none

      else
        PebbleVibes.doublePulse
    )

Step 5: the shared protocol is the contract

The finished template is split into watch, phone, and protocol packages. The protocol package is the contract both sides import. It says which values can cross the Pebble AppMessage boundary, using normal Elm custom types instead of unstructured JSON strings.

Protocol: protocol/src/Companion/Types.elm
type WatchToPhone
    = RequestWeather Location


type PhoneToWatch
    = ProvideTemperature Temperature
    | ProvideCondition WeatherCondition
    | SetBackgroundColor TutorialColor
    | SetTextColor TutorialColor
    | SetShowDate Bool

Read those two types as the public API between devices. The watch is allowed to ask for weather for a Location. The phone is allowed to send back temperature, condition, and a few display settings. If you add a new setting later, this is where the new message should start.

  • Location, Temperature, WeatherCondition, and TutorialColor are the shared vocabulary.
  • WatchToPhone describes requests sent by the watch.
  • PhoneToWatch describes responses and settings sent by the phone.
  • Companion.Internal is generated from these types and handles the wire encoding, so app code can work with typed values.

Step 6: the companion app answers watch requests

The companion app is another Elm program, but it runs on the phone side rather than on the watch. It uses Platform.worker because it has no visual UI here: it listens for watch messages, performs HTTP requests, and sends messages back.

Companion app: phone/src/CompanionApp.elm
type Msg
    = FromWatch (Result String WatchToPhone)
    | WeatherReceived (Result Http.Error WeatherReport)
    | DemoPosted (Result Http.Error String)

When the watch sends RequestWeather, the phone builds an Open-Meteo URL, starts an Http.get command, and also sends a demo POST request. When the weather response arrives, it rounds the temperature and sends two typed messages back to the watch.

Companion app: phone/src/CompanionApp.elm
WeatherReceived result ->
    case result of
        Ok weather ->
            let
                rounded =
                    round weather.temperature
            in
            ( { model | lastResponse = rounded }
            , Cmd.batch
                [ CompanionPhone.sendPhoneToWatch (ProvideTemperature (Celsius rounded))
                , CompanionPhone.sendPhoneToWatch (ProvideCondition weather.condition)
                ]
            )

        Err _ ->
            ( model, Cmd.none )

The companion app subscribes with CompanionPhone.onWatchToPhone FromWatch. That means incoming phone-side AppMessage payloads enter the same Elm update loop as HTTP responses.

Step 7: phone messages update watch settings and weather

Back on the watch, CompanionWatch.onPhoneToWatch turns incoming phone messages into FromPhone values. The watch receives PhoneToWatch values, so the update code can pattern match on declared messages instead of parsing loose strings.

Watch app: watch/src/Main.elm
updateFromPhone message model =
    case message of
        ProvideTemperature temperature ->
            ( { model | temperature = Just temperature }, Cmd.none )

        ProvideCondition condition ->
            ( { model | condition = Just condition }, Cmd.none )

        SetBackgroundColor color ->
            ( { model | backgroundColor = Just (pebbleColor color) }, Cmd.none )

That is normal Elm style: make the allowed messages explicit, handle every case, and let the compiler complain if the protocol changes and you forget to update the watch.

Step 8: subscriptions keep the watchface alive

Subscriptions register long-running event sources. This template listens for minute ticks, hour ticks, battery changes, connection changes, and phone-to-watch messages.

Watch app: watch/src/Main.elm
subscriptions _ =
    PebbleEvents.batch
        [ PebbleEvents.onMinuteChange MinuteChanged
        , PebbleEvents.onHourChange HourChanged
        , PebbleSystem.onBatteryChange BatteryLevelChanged
        , PebbleSystem.onConnectionChange ConnectionStatusChanged
        , CompanionWatch.onPhoneToWatch FromPhone
        ]

Each subscription names the Msg constructor that should be used when the event fires. That keeps external events flowing through the same update function as startup responses.

Step 9: view computes layout, then draws

The view function is pure: it looks at the model and returns drawing instructions. It first calculates positions from the screen size and shape, chooses default colors, and conditionally builds small lists of render operations.

  • batteryOps draws a battery outline and fill only after a battery level is known.
  • btIcon draws the Bluetooth icon only when connected is Just False.
  • dateOps draws the date only when the phone setting says to show it and the current date is available.
  • The final expression concatenates those lists and converts them with PebbleUi.toUiNode.
Watch app: watch/src/Main.elm
[ PebbleUi.clear backgroundColor
]
    ++ batteryOps
    ++ [ drawCentered model textColor timeY 56 (timeString model)
       , drawCentered model textColor weatherY 22 (weatherString model)
       ]
    ++ btIcon
    ++ dateOps
    |> PebbleUi.toUiNode

Because there is no hidden mutation in view, the screen is always a direct result of the current model. Change the model, and the next render describes the new screen.

Step 10: helpers keep display logic small

The rest of Main.elm is small conversion code: format the time, format the date, turn protocol colors into Pebble colors, and turn weather constructors into user-facing text.

Watch app: watch/src/Main.elm
timeString model =
    case model.currentDateTime of
        Nothing ->
            "--:--"

        Just currentDateTime ->
            pad2 currentDateTime.hour ++ ":" ++ pad2 currentDateTime.minute

Notice the fallback. While the clock value is missing, the UI still has something predictable to draw. Once CurrentDateTime arrives, the same function formats the real value.

Step 11: main connects your code to Pebble

The last value, main, hands the core pieces to the elm-pebble platform: init for startup, update for state transitions, and subscriptions for ongoing events.

Watch app: watch/src/Main.elm
main : Program Decode.Value Model Msg
main =
    PebblePlatform.worker
        { init = init
        , update = update
        , subscriptions = subscriptions
        }

The view function is still the drawing contract used by the watchface tooling. The worker entry point keeps the runtime event loop explicit: initialize state, react to messages, and stay subscribed to Pebble and companion events.

What to change first

Once you understand the loop, safe experiments become obvious.

  • Change the default colors in view by replacing PebbleColor.black or PebbleColor.white.
  • Move text by changing timeY, dateY, weatherY, or batteryY.
  • Change updateFromPhone if you add a new phone setting to the companion protocol.
  • Add a new Msg when a new Pebble event or command result needs to affect the model.

The habit to keep: add data to the model, describe events as Msg values, update the model in update, and make view draw from the model.

Back to the home page