Hello World App for PineTime Using PineSim

2024-01-24

The intro of this text can be a bit too "bloggy". If you want to check out the step by step hello world app, go directly to this section.

Why Buy If You Can Build Your Own?#

Recently I moved to a place quite close to tennis courts. It was such an inaccessible sport when was younger. Today, living here in Ireland, trying a couple of matches didn't seem such a distant idea. For the moment I'm trying to make that a habit, and I've been playing with some neighbours whenever I have a chance.

A few matches in I noticed something really bothered me: keeping the score is horrible. Maybe because I'm a father of two, and because of that I'm constantly sleep-deprived?

As soon as I decided that I needed a solution for my problem, I assumed a couple of alternatives would be available. And looking into them, the most tempting idea was phone apps that can be triggered through tiny bluetooth remotes. But then I realized the ideal solution would be a smartwatch. I've never really been a smartwatch guy... I just find them expensive and don't think I would use them enough for such an acquisition to make sense.

Well, and then I met PineTime. I had looked into Pine64 stuff before, but had never got myself anything. So maybe it was time (no pun intended). PineTime is one of the cheapest smartwatches that could run an app to keep track of tennis score! It's just that... The app doesn't exist yet. So there we go, as a good programmer, why use something that's already there if you can reinvent the wheel build your own?

What Is PineTime#

PineTime is an opensource (hardware and software) smartwatch with quite a reasonable price. They offer two different options: a sealed watch, just targeted at end-users, and a development kit which can even be debugged with OpenOCD! How awesome is that?

The hardware is based on an ESP32 platform and contains a few sensors (like heart rate, step counter, accelerometer, etc.). The default firmware is InfiniTime, but there's a couple of other options also available, including some Rust-based attempts.

And the cool thing is, even before it arrives at your door, you can easily start creating your own apps or watch faces. Some of the firmware options offer emulators. And these seem to work reasonably well, as most of these firmwares work on a very neat way using dependency inversion (you basically inject your app into the framework) and all the UI stuff is based on LVGL.

Getting hyped before my own PineTime arrives, I decided to write a quick hello world for InfiniTime, using InfiniSim. InfiniSim is pretty neat. It basically runs the injected app abstracting its surroundings, providing access to hardware components to the app through what it calls controllers. LVGL plays also a big role here allowing a well-defined interface for the watch's display.

I had a couple of hiccups along the way with the official documentation, so having a step-by-step tutorial here might be useful to other people (hopefully I'll make sometime later to update some of the official documentation on Github).

Writing a Hello World In InfiniSim#

So for the starts, this was me on Ubuntu 20.04 LTS, using WSL2 (Windows 11). The cool thing about using old distros is you'll always catch version issues with older stuff (I'll use this as an excuse for not having an updated version).

Building InfiniSim#

First of all, we clone the InfiniSim repo available on GitHub:

$ git clone --recursive https://github.com/InfiniTimeOrg/InfiniSim.git

Note here that we're including submodules (as some of the InfiniTime code used in the emulator comes from the actual InfiniTime repo, included as a submodule).

Now there's a couple of dependencies, and it might get a bit finicky with Ubuntu 20.04, but we'll get there.

# apt-get install cmake libsdl2-dev g++-10 libpng-dev

Note that we're installing GCC 10. InfiniSim also compiles InfiniTime code, which depends on GCC 11 according to their docs, but looks like we can get away with version 10 (the codebase requires support to codeval). Also, because of that, we'll have to do some juggling with compiler versions in your system:

# update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 100 --slave /usr/bin/g++ g++ /usr/bin/g++-10

Note you might want to add your other GCC versions (8 is the standard installation for Ubuntu 20.04) with update-alternatives too.

We have some fancy match statements in InfiniTime's Python scripts too, so we'll have to get Python >= 3.10 (3.8 is the default). In order to do that, we'll need to add a repo manually:

# add-apt-repository ppa:deadsnakes/ppa
# apt-get install python3.12
$ curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
# python3.12 get-pip.py

Last Python dependency is Pillow:

# pip3.12 install pillow

If you run into trouble with multiple Python versions (like I did), we can force CMake to use Python 3.12. Unfortunately I couldn't find an easy way to do that from CMake's command line, neither using only Debian's wonderful update-alternatives. The way out was a workaround using both CMake and update-alternatives.

For the CMake part, we edit InfiniTime/src/resources/CMakesList.txt (a few Python scripts to deal with resources like fonts and images). And change the find_package command for Python 3 as follows (and note this is particularly true for CMake 3.12 and above only, as you can see in the comments):

...
if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.12)
   # FindPython3 module introduces with CMake 3.12
   # https://cmake.org/cmake/help/latest/module/FindPython3.html
   find_package(Python3 3.12 REQUIRED) # this is the line you want to change!
else()
   set(Python3_EXECUTABLE "python")
endif()
...

For the update-alternatives part:

$ sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 110
$ sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.8 100

And now, the last annoying dependency, I promise, node.js 14 (default is 10).

$ curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
# apt-get install nodejs

And that's because we need lv_font_conv (which is a bit weird actually, as it looks like we have some Python scripts that do its job now).

# npm i lv_font_conv -g

Now, fingers crossed.

$ cd InfiniSim
$ cmake -S . -B build
$ cmake --build build

If you're lucky enough, now you can run the emulator:

$ ./build/infinisim

InfiniSim running It worked pretty well with WSL2 running directly from PowerShell. On my attempts trying to run it from MobaXTerm, it would crash in SDL2 code while creating the status window. So a quick workaround is trying to hide it. It won't crash, but unfortunately you may lose precious information about what's going on with the watch.

$ ./build/infinisim --hide-status

Creating Your App#

As we mentioned before, there's a git submodule for InfiniTime inside InfiniSim's repository. For now, we'll just use that to make things easier for us.

In InfiniSim, apps are called "screens". Screens are their own classes, and you can find a bunch of them in src/displayapp/screens. All of those can be used as examples, which is great for understanding the system.

Let's start with a header file with the declarations for our screen class, let's call it HelloWorld.h (so InfiniTime/src/displayapp/screens/HelloWorld.h). It's declared inside the namespace Pinetime::Applications::Screens, and it extends Screen (which is declared in Screen.h).

#pragma once

#include "displayapp/apps/Apps.h"
#include "displayapp/screens/Screen.h"
#include "displayapp/Controllers.h"
#include "Symbols.h"

namespace Pinetime {
  namespace Applications {
    namespace Screens {
      class HelloWorld : public Screen {
      public:
        HelloWorld();
        ~HelloWorld() override;
      };
    }

    template <>
    struct AppTraits<Apps::HelloWorld> {
      static constexpr Apps app = Apps::HelloWorld;
      static constexpr const char* icon = Screens::Symbols::eye;
      static Screens::Screen* Create(AppControllers& controllers) {
        return new Screens::HelloWorld();
      };
    };
  }
}

For a simple hello world, our class doesn't need anything fancy, we declare a constructor and a destructor. The lifecycle of a screen is quite simple, the constructor will be called when the app is launched, and once the app is closed, the destructor is called.

Note as well the static trait pattern below the class declaration. That's used to capture some meta-information about the app and to provide a factory member function (Create). AppTraits<Apps::HelloWorld>::icon will define which icon will be used for the app when listed in the PineTime menus. Create receives an AppController reference, which is basically a way for the InfiniTime framework to provide access to system resources. We won't need those today.

We're creating now the definitions for our class. That will be InfiniTime/src/displayapp/screens/HelloWorld.cpp.

#include "displayapp/screens/HelloWorld.h"

namespace Pinetime::Applications::Screens {
  HelloWorld::HelloWorld() {
    lv_obj_t* title = lv_label_create(lv_scr_act(), nullptr);
    lv_label_set_text_static(title, "Hello World!");
    lv_label_set_align(title, LV_LABEL_ALIGN_CENTER);
    lv_obj_align(title, lv_scr_act(), LV_ALIGN_CENTER, 0, 0);
  }

  HelloWorld::~HelloWorld() {
    lv_obj_clean(lv_scr_act());
  }
}

That's even shorter than our header. In the constructor we simply create a label using LVGL and write "Hello World!" to it. We align the text to the center of the label and the label to the center of the screen.

lv_scr_act returns a pointer to the active screen, lv_obj_clean... Well, cleans the screen up once the app is gone.

Now, before we make our screen run in the emulator, there's a couple of things we have to get sorted first. We need to tell InfiniTime about our app. And the way we do it is inserting the following lines.

In src/CMakeLists.txt, when setting the INCLUDE_FILES variables.

...
set(INCLUDE_FILES
  ...
  displayapp/screens/HelloWorld.cpp
  ...
  displayapp/screens/HelloWorld.h
  ...
)
...

In src/displayapp/UserApps.h:

...
#include "displayapp/screens/WatchFaceInfineat.h"
#include "displayapp/screens/WatchFacePineTimeStyle.h"
#include "displayapp/screens/WatchFaceTerminal.h"
#include "displayapp/screens/HelloWorld.h" // add this header!
...

In src/displayapp/apps/Apps.h.in:

namespace Pinetime {
  namespace Applications {
    enum class Apps : uint8_t {
    ...
    Error,
    Weather,
    HelloWorld // add this enum value!
  }
  ...

In src/displayapp/apps/CMakeLists.txt, add Apps::HelloWorld to the USERAPP_TYPES variable:

...
set(USERAPP_TYPES "Apps::Navigation, Apps::StopWatch, Apps::Alarm, Apps::Timer, Apps::Steps, Apps::HeartRate, Apps::Music, Apps::Paint, Apps::Paddle, Apps::Twos, Apps::Metronome, Apps::HelloWorld" CACHE STRING "List of user apps to build into the firmware")
...

And don't forget:

# cd src/displayapp/apps && cmake .

This will generate an Apps.h header in case you're missing something.

And to get this finally over with, once again:

$ cmake -S . -B build
$ cmake --build build

So here you are. Once that's all done, you're able to run InfiniSim including now your own hello world app. InfiniSim running our Hello World app Note that the icon used by the Hello World app is an "eye". That was specified in that traits struct we had in our HelloWorld.h header.

...
static constexpr const char* icon = Screens::Symbols::eye;
...

That was basically our icon of choice as it was something already available. But honestly it looks pretty lame, I really think we should find something a bit better.

Changing the Icon#

For screens, InfiniTime already provides a collection of icons from the free Font Awesome collection. In order to use one of those icons, you can start browsing what's available here (no guarantees what's currently in InfiniTime is the most updated version of the icon library, though).

For this how-to let's pick the stroopwafel icon, just because I'm a big fan of this Dutch specialty. Going into the icon details you will find the Unicode code point, in this particular case it's f551.

We'll now edit src/displayapp/fonts/fonts.json. This file is read by the generate.py script in the same directory and it's used to select just the icons in use by InfiniTime from TTF/WOFF files and turn them into static content used by LVGL later.

We'll add our code point to be extract from the Font Awesome file. We do that by adding it to one of the "range" entries. Note that even though we're extracting the icon from a Font Awesome WOFF, it's being added to the jetbrains_mono_bold_20 bundle. If you're curious, the final result will be available in InfiniSim/build/fonts/jetbrains_mono_bold_20.c.

  ...
   "jetbrains_mono_bold_20": {
      "sources": [
         {
            "file": "JetBrainsMono-Bold.ttf",
            "range": "0x20-0x7e, 0x410-0x44f, 0xB0"
         },
         {
            "file": "FontAwesome5-Solid+Brands+Regular.woff",
            "range": "0xf294, 0xf242, 0xf54b, 0xf21e, 0xf1e6, 0xf017, 0xf129, 0xf03a, 0xf185, 0xf560, 0xf001, 0xf3fd, 0xf1fc, 0xf45d, 0xf59f, 0xf5a0, 0xf027, 0xf028, 0xf6a9, 0xf04b, 0xf04c, 0xf048, 0xf051, 0xf095, 0xf3dd, 0xf04d, 0xf2f2, 0xf024, 0xf252, 0xf569, 0xf06e, 0xf015, 0xf00c, 0xf551"
         }
      ],
      "bpp": 1,
      "size": 20,
      "patches": ["jetbrains_mono_bold_20.c_zero.patch", "jetbrains_mono_bold_20.c_M.patch"]
   },
  ...

Now we need to convert the code point into UTF-8. Let's use python for that.

>>> chr(0xf551).encode('utf-8')
b'\xef\x95\x91'

Let's add the result to InfiniTime/src/displayapp/screens/Symbols.h.

    ...
    static constexpr const char* stroopwafel = "\xEF\x95\x91";
    ...

And the most important, don't forget to update your app's traits in HelloWorld.h!

    ...
    template <>
    struct AppTraits<Apps::HelloWorld> {
      static constexpr Apps app = Apps::HelloWorld;
      static constexpr const char* icon = Screens::Symbols::stroopwafel;
      static Screens::Screen* Create(AppControllers& controllers) {
        return new Screens::HelloWorld();
      };
    };
    ...

That should do the job. Now you can compile and run InfiniSim once again.

cmake --build build
./build/infinisim

Our app now shows with a stroopwafel icon! And that's it, now you have a Hello World app running on an open source smartwatch (well, actually it's an emulator of a smartwatch)! And as a bonus, the app uses a stroopwafel icon, which for me is the best part :)