Preview PineTime Watch Faces in your Web Browser with WebAssembly

Custom PineTime Watch Face created in C++ by SravanSenthiln1: PineTime Watch Face Simulator vs Real PineTime

Custom PineTime Watch Face created in C++ by SravanSenthiln1: PineTime Watch Face Simulator vs Real PineTime

📝 19 Aug 2020

Now we can build and preview Watch Faces for PineTime Smart Watch in the Web Browser, thanks to WebAssembly...

All we need is a Web Browser... Even a Web Browser on mobile phones will do!

Let's learn how...

1 Create a Fork of PineTime Source Files

(Nope no knife!)

  1. Create a free GitHub Account if we haven't got one...


  2. Browse to the GitHub Repository for the PineTime Firmware...


    Here's the complete Source Code for the InfiniTime Firmware (based on FreeRTOS).

  3. Click the Fork button at top right...

    Create a fork

  4. This creates a Fork of the PineTime Repository under our GitHub Account...

    Created the fork

    The URL looks like this...
  5. The Fork contains our own copy of the entire Source Code for the PineTime Firmware... Ready for us to make any updates!

    GitHub helpfully tracks updates to our Fork, so that one day we may submit a Pull Request to sync our updates (only the useful ones) back to the original PineTime Repository.

    And we may also Pull Updates from the original PineTime Repository and apply them to our Fork.

    That's how we maintain Open Source Projects!

Read on to learn how we add GitHub Actions to our Fork to preview our Custom Watch Face automagically...

2 Enable GitHub Pages

  1. In our Fork on GitHub, click ... and Settings at top right

    GitHub Settings

  2. Scroll down the Settings page (Options tab) and look for GitHub Pages

  3. Set Branch to master

    Set the folder to docs

    Click Save

    GitHub Pages

3 Add GitHub Actions to our Fork

  1. In our Fork on GitHub, click Actions at the top bar

    GitHub Actions

  2. Click Skip this and set up a workflow yourself

    GitHub Actions

  3. GitHub brings us to a page to edit .github/workflows/main.yml

    GitHub Actions

  4. Change main.yml to simulate.yml

    Rename to main.yml to simulate.yml Pages

  5. Open a new web browser tab.

    Browse to this page...

    Copy the contents of this page.

  6. Switch back to the earlier page: .github/workflows/simulate.yml

    Paste and overwrite the contents of the file...

    GitHub Actions

  7. Click Start Commit at the right or bottom of the page...

    GitHub Actions

  8. Click Commit New File

    GitHub Actions

We have just created a Workflow... An automated job that will be run by GitHub whenever we update our source files.

If we ever need to edit the Workflow, just browse to this URL...

(Change ACCOUNT_NAME to our GitHub Account Name)

Let's change a PineTime source file... And trigger our very first PineTime Simulator Build in the Cloud!

4 Modify the PineTime Source Code

We shall modify the source code so that the PineTime Watch Face shows our own special message...

  1. Browse to this URL...

    (Change ACCOUNT_NAME to our GitHub Account Name)

  2. Click the Edit icon at the right...

    Edit Source File

  3. Look for the line with "BPM" (line 71)...

    Edit Source File

  4. BPM is the text that's displayed on the PineTime Watch Face.

    Change BPM to our own short message, like LOVE...

    Edit Source File

  5. Scroll to the bottom of the page.

    Click Commit Changes

    Edit Source File

Guess what?

We have just triggered Our Very First PineTime Simulator Build In The Cloud!

(Because the Simulator Build is triggered by any file update)

Let's check the result of our Simulator Build...

Check out this article to learn more about Clock.cpp

5 Our First PineTime Simulator Build

  1. Click Actions at the top.

    Click the first row that says: Update Clock.cpp - Simulate PineTime Firmware

    (Make sure it's Simulate PineTime Firmware not Build PineTime Firmware)

    Build Result

  2. Click build at left...

    Build Result

  3. We'll see each step of the simulator building process...

    Build Result

  4. Click Build LVGL for WebAssembly

    This shows the messages that were generated by the WebAssembly Compiler (emscripten)...

    Build Result

Check out my build logs

6 Preview our PineTime Watch Face

  1. On our computer or mobile phone, launch the Web Browser.

  2. Browse to this URL to see PineTime Simulator for our Fork...

    (Change ACCOUNT_NAME to our GitHub Account Name)

    PineTime Watch Face Simulator

  3. If we are happy with the Watch Face, we may flash the built firmware to PineTime over Bluetooth. See "Test Our PineTime Fimware"

    We will need to install the GitHub Actions Workflow for building the PineTime Firmware: .github/workflows/main.yml. See "Add GitHub Actions for PineTime Firmware"

  4. Get Creative with Watch Faces! Find out how to add our own Bitmaps to Watch Faces

    Custom PineTime Watch Face created in C++ by SravanSenthiln1: PineTime Watch Face Simulator vs Real PineTime

    View the Source Code for the Custom Watch Face

I have a request...

If you could... With your kind permission... Please post to Twitter and/or Mastodon a pic of your PineTime Simulator with the new Watch Face.

Tag the post with #PineTime so we know that simulating PineTime Firmware in the Cloud works OK for you. Thanks! :-)

If you're stuck, please chat with us in the PineTime Chatroom...

PineTime Chatroom on Discord / Matrix / Telegram / IRC

Custom PineTime Watch Face by SravanSenthiln1

Custom PineTime Watch Face by SravanSenthiln1

7 Other Options

  1. Can we change other files besides Clock.cpp?

    Sorry, the Simulator only renders the code in Clock.cpp

    Any bitmaps and fonts will have to be embedded inside that file.

  2. Can we add labels and other widgets to Clock.h?

    Sorry, the Simulator runs in a "Sandbox" that only renders the code in Clock.cpp and not Clock.h

    The workaround is to declare a static variable in Clock.cpp like this...

    //  In Clock.cpp...
    //  Extern Declarations
    extern lv_font_t jetbrains_mono_extrabold_compressed;
    extern lv_font_t jetbrains_mono_bold_20;
    extern lv_style_t* LabelBigStyle;
    //  Declare your Static Variable here. Must be in Global Scope so that all methods can access it.
    static lv_obj_t* my_label;
    //  Constructor for Clock Class
    Clock::Clock(DisplayApp* app, ...) { ...

    Then set the static variable in the Clock Constructor...

    //  Constructor for Clock Class
    Clock::Clock(DisplayApp* app, ...) {
        //  Create the label
        my_label = lv_label_create(lv_scr_act(), NULL);

    And update it in the Refresh Method...

    //  Refresh Method for Clock Class
    bool Clock::Refresh() {
        //  Refresh the label         
        lv_label_set_text(my_label, my_string);

    Note that the variable must be declared in Global Scope so that the methods can access the variable.

  3. Can we edit our files in GitHub without using the web browser?

    We recommend VSCode or VSCodium for editing files with Git Version Control. (Which works with GitHub files)

    Remember to Commit any updated files and Push the Commits to the master Branch to trigger the simulator build.

    After building the simulator, we need to Pull from our Fork to fetch the updated WebAssembly files in the docs folder.

  4. Can we build the simulator on our own computers?

    Follow the instructions in LVGL WebAssembly doc.

    To troubleshoot the build, compare with my build logs.

  5. What if we don't wish to make our repos public?

    Only public repos get GitHub Actions for free... But there's an alternative:

    Self-Hosted Runners for GitHub Actions

Custom PineTime Watch Face by SravanSenthiln1 rendered by a TV's built-in web browser

Custom PineTime Watch Face by SravanSenthiln1 rendered by a TV's built-in web browser

8 What's Next?

Here's what we'll be implementing next...

  1. Accept Touch Input for LVGL

  2. Convert Clock.cpp from C++ to Rust with lvgl-rs

    Check out the rust branch of lvgl-asm

  3. Allow PineTime Watch Faces to be built online in Rust with online preview. Similar to WebAssembly Studio

We have a lot to do, please chat with us if you're keen to help...

PineTime Chatroom on Discord / Matrix / Telegram / IRC

And remember to enjoy your PineTime :-)

Got a question, comment or suggestion? Create an Issue or submit a Pull Request here...

pinetime-rust-mynewt/rust/ app/src/

9 How It Works

(Warning: The topics below are deeply technical... If you're keen please read on!)

Let's look at the GitHub Actions Workflow we used for previewing PineTime Watch Faces: .github/workflows/simulate.yml

# GitHub Actions Workflow to build PineTime Watch Face Simulator with LVGL and WebAssembly
# See 
# and

# Name of this Workflow
name: Simulate PineTime Firmware

# When to run this Workflow...

  # Run this Workflow when files are updated (Pushed) in the "master" Branch
    branches: [ master ]
  # Also run this Workflow when a Pull Request is created or updated in the "master" Branch
    branches: [ master ]

Here we see the conditions that will trigger our Workflow...

  1. When files are updated (or Pushed) in the master Branch

  2. When a Pull Request is created or updated in the master Branch

More details

Next we specify which Operating System GitHub should use to execute the Workflow Steps...

# Steps to run for the Workflow

    # Run these steps on Ubuntu
    runs-on: ubuntu-latest


This asks GitHub to allocate a free Virtual Machine (Docker Container) to build our firmware, based on Ubuntu 18.04.

We're using Ubuntu, but GitHub supports Windows and macOS as well.

More details

After that we specify the steps to be executed for our Workflow...

9.1 Checkout Source Files

First we fetch a complete set of source files from our Fork...

    - uses: actions/checkout@v2

The actions/checkout GitHub Action copies the source files into /home/runner/work/Pinetime/Pinetime

9.2 Check Cache for emscripten

Our Ubuntu Virtual Machine in the GitHub Cloud is based on the Intel x64 platform... But we're compiling our C and C++ program to WebAssembly.

To do that, we need to install the emscripten WebAssembly Compiler

We'll install this in the next step, but first we check whether emscripten is in our cache...

    - name: Check cache for emscripten
      id:   cache-emsdk
      uses: actions/cache@v2
        cache-name: cache-emsdk
        path: /tmp/emsdk
        key:  ${{ runner.os }}-build-${{ env.cache-name }}
        restore-keys: ${{ runner.os }}-build-${{ env.cache-name }}

Why cache the Embedded Arm Toolchain?

emscripten is a huge 90 MB download (compressed).

Every time GitHub builds our firmware, it creates a fresh new empty Virtual Machine.

(So that our firmware builds may be reproduced consistently... And for security too)

GitHub will take roughly 30 seconds to download and unpack emscripten... Unless we cache it.

    - name: Check cache for emscripten
      id:   cache-emsdk
      uses: actions/cache@v2

The actions/cache GitHub Action lets us cache emscripten for future builds.

We can have multiple caches. Here's our cache for emscripten...

        cache-name: cache-emsdk

Next we tell GitHub what to cache...

        path: /tmp/emsdk
        key:  ${{ runner.os }}-build-${{ env.cache-name }}
        restore-keys: ${{ runner.os }}-build-${{ env.cache-name }}

Given these build settings...

runner.os      = Linux
env.cache-name = cache-emsdk

This means...

9.3 Install emscripten

Now we download and unpack emscripten into the temporary folder /tmp/emsdk...

    - name: Install emscripten
      if:   steps.cache-emsdk.outputs.cache-hit != 'true'  # Install emscripten if not found in cache
      run:  |
        # Based on
        cd /tmp

        # Get the emsdk repo
        git clone

        # Enter that directory
        cd emsdk

        # Download and install the latest SDK tools.
        ./emsdk install latest

        # Make the "latest" SDK "active" for the current user. (writes .emscripten file)
        ./emsdk activate latest

        # Activate PATH and other environment variables in the current terminal
        source ./

        # Show version
        emcc --version
        emcc --version        

Why is there a condition for the step?

      # Install emscripten if not found in cache
      if:   steps.cache-emsdk.outputs.cache-hit != 'true'  

This says that GitHub shall download emscripten only if the previous step cache-emsdk couldn't find an existing cache for emscripten.

Huge downloads and reinstallation averted... So neat!

What software is preinstalled on the GitHub Virtual Machine?

Check out the preinstalled software on Ubuntu 18.04 for GitHub Actions

9.4 Check Cache for wabt

Now that we have installed and cached emscripten, let's do the same for wabt, the WebAssembly Binary Toolkit

First we check the cache for wabt...

    - name: Check cache for wabt
      id:   cache-wabt
      uses: actions/cache@v2
        cache-name: cache-wabt
        path: /tmp/wabt
        key:  ${{ runner.os }}-build-${{ env.cache-name }}
        restore-keys: ${{ runner.os }}-build-${{ env.cache-name }}

9.5 Install wabt

Then we install wabt in /tmp/wabt...

    - name: Install wabt
      if:   steps.cache-wabt.outputs.cache-hit != 'true'  # Install wabt if not found in cache
      run:  |
        cd /tmp
        git clone --recursive
        cd wabt
        mkdir build
        cd build
        cmake ..
        cmake --build .

9.6 Checkout LVGL for WebAssembly

Now it gets interesting. Here we fetch the source code from lvgl-wasm...

    - name: Checkout LVGL for WebAssembly
      run:  |
        cd /tmp
        git clone

And save it to /tmp/lvgl-wasm

What's lvgl-wasm?

PineTime Web Simulator runs in a Web Browser based on WebAssembly (somewhat similar to Java Applets). More about WebAssembly

Clock.cpp is our C++ class that contains the Watch Face code. Clock.cpp calls functions from two providers...

  1. LVGL UI Toolkit Library

  2. InfiniTime Operating System based on FreeRTOS

We have a version of LVGL compiled for WebAssembly... It's inside lvgl-wasm...

So we'll be compiling lvgl-wasm to WebAssembly together with our Watch Face code.

What about the InfiniTime Operating System?

Our PineTime Web Simulator doesn't support all functions provided by InfiniTime... lvgl-wasm simulates the minimal set of InfiniTime functions needed for rendering Watch Faces. (FreeRTOS is not supported by the Simulator)

Hence lvgl-wasm works like a Sandbox. We'll learn more details in the lvgl-wasm documentation

9.7 Copy Watch Face Clock.cpp to LVGL for WebAssembly

Remember that lvgl-wasm is just a Sandbox for simulating Watch Faces... It needs the actual Watch Face code.

Here's how we copy the Watch Face code in Clock.cpp to lvgl-wasm...

    - name: Copy Watch Face Clock.cpp to LVGL for WebAssembly
      run:  |
        cp src/DisplayApp/Screens/Clock.cpp /tmp/lvgl-wasm/clock

9.8 Build LVGL for WebAssembly

Now that the Watch Face code is inside lvgl-wasm, let's build the project with emscripten...

    - name: Build LVGL for WebAssembly
      run:  |
        # Add emscripten and wabt to the PATH
        source /tmp/emsdk/
        export PATH=$PATH:/tmp/wabt/build

        # Build LVGL app: wasm/lvgl.html, lvgl.js, lvgl.wasm
        cd /tmp/lvgl-wasm
        wasm/ shall be explained in the lvgl-wasm documentation

The script calls emscripten to generate three files in /tmp/lvgl-wasm/wasm/...

9.9 Copy WebAssembly to GitHub Pages

Next we copy the WebAssembly files to the docs folder, which will be hosted on GitHub Pages...

    - name: Copy WebAssembly to GitHub Pages
      run:  |
        if [ ! -d docs ]; then
          mkdir docs
        export src=/tmp/lvgl-wasm
        export docs=$src/docs
        export wasm=$src/wasm
        cp \
          $docs/ \
          $docs/lvgl.html \
          $wasm/*.html \
          $wasm/*.js \
          $wasm/*.wasm \
          $wasm/*.txt \

9.10 Commit GitHub Pages

Finally we Commit the changed files in docs back to the Fork so that GitHub Pages will be updated...

    - name: Commit GitHub Pages
      uses: EndBug/add-and-commit@v4.4.0
        add: docs

More about add-and-commit

9.11 Upload Outputs

For troubleshooting, we publish the generated WebAssembly files as an Artifact wasm...

    - name: Upload Outputs
      uses: actions/upload-artifact@v2
        name: wasm
        path: |

9.12 Show Files

Let's take a peek at the environment variables and the files that have been checked out...

    - name: Show files
      run:  set ; pwd ; ls -l /tmp/lvgl-wasm

The current directory pwd is shown as...


Check the section "Environment Variables" below for the complete list of environment variables.

9.13 Caching At The End

Here's a tip about the caches we have created for emscripten and wabt...

The files get cached only if the build succeeds

If the first few builds fail (say due to coding errors), the files will never get cached. And restarting the build becomes painfully slow.

Therefore it's good to tweak the Workflow to make sure that the first build always succeeds... Like commenting out the actions from Build LVGL for WebAssembly onwards.

Subsequent builds will be a lot faster with the caching.

And that's how we build PineTime Simulator in the Cloud!

GitHub Actions Workflow Syntax

10 Environment Variables


This step in our GitHub Actions Workflow...

    - name: Show files
      run:  set ; pwd ; ls -l /tmp/lvgl-wasm

Shows these environment variables...

BASH_VERSINFO=([0]="4" [1]="4" [2]="20" [3]="1" [4]="release" [5]
GITHUB_WORKFLOW='Build PineTime Firmware'
IFS=$' \t\n'
PS4='+ '