Embedded Systems / Laboratory
LABORATORY 03

Real-Time Operating Systems: Firmware on an Open-Source Watch

Duration: 4 hours Reference: Chapters 7 and 8 Platform: PineTime + InfiniTime (FreeRTOS) Book reference chapter RO versiunea română

So far we have written programs that do one single thing. A smart watch has to draw the screen, read the accelerometer, keep the Bluetooth connection alive and count seconds - all at once, on a single processor, with 64 KB of memory and a battery that must last a week. In this lab we study how this is solved, on a real product whose source code is entirely public.

1Objectives of the lab

  • Understanding the difference between a main loop and a preemptive scheduler
  • Applying schedulability criteria: utilization, the Rate Monotonic bound, EDF
  • Building a complete, real firmware from source code
  • Creating your own FreeRTOS task and correctly choosing its priority and stack size
  • Showing on the watch the data produced by your own task and checking it in the system task list
  • Communication between tasks through message queues, instead of global variables
  • Triggering and fixing priority inversion
  • The link between scheduling and consumption: why a poorly written task drains the battery

2About the project

We take the InfiniTime firmware - the system that runs on the PineTime watch - build it from source, add our own task and a new watchface, then flash it onto the watch over Bluetooth. At the end we measure what our addition cost, in consumption.

Why this particular watch PineTime is the only smart watch you have absolutely everything for: the schematic, the wiring, the bootloader code and the firmware code. It is not a teaching exercise - it is a real product that is sold, with real memory and battery constraints. You can read why every design decision was made, then change it and see what happens. This is not possible on any commercial watch.
processor
nRF52832
ARM Cortex-M4F, 64 MHz
memory
64 KB RAM · 512 KB flash
plus 4 MB external flash for resources
display
ST7789, 240×240
color IPS, with a capacitive touch panel
sensors
accelerometer · heart rate
BMA421 and HRS3300
radio
Bluetooth Low Energy
also used for firmware updates
battery
180 mAh
roughly a week of autonomy
The constraint that changes everything 64 KB of memory means a single task with a badly sized stack can bring down the whole system. On an ordinary computer you would casually allocate a 100 KB buffer; here you do not have that much memory in total. Every task in InfiniTime has its stack measured down to the byte - and you will see how.

3Materials needed

  • 1 PineTime (the sealed variant is enough)
  • 1 Computer with Linux or WSL2
  • 1 Docker installed
  • 1 Phone with Gadgetbridge (Android) or InfiniLink (iOS) - see section 12
  • 1 Magnetic charging cable (included with the watch)
  • 1 ESP32-C6 from the previous lab (for the FreeRTOS-without-a-watch part)
The lab also works without a physical watch Sections 5, 6 and 8 (scheduling, tasks, priority inversion) can be done entirely on the ESP32-C6, which also runs FreeRTOS. Section 9 also covers InfiniSim, a simulator that runs the complete firmware on your computer, with an emulated screen and touch. The physical watch is only needed for sections 10 and 11.

4Why a main loop is not enough

A microcontroller program almost always starts like this:

the classic loop - and its problem
void loop() {
  read_accelerometer();     // 2 ms
  update_display();         // 30 ms  <-- long
  handle_bluetooth();       //  5 ms
  count_seconds();          //  1 ms
}

It works until the moment one of the tasks becomes slow. While update_display() is drawing, the other three do not run. If a Bluetooth frame arrives during that interval, it is lost. And if the screen occasionally needs 200 ms, the watch visibly falls behind.

You can patch this with state machines and millis() checks - exactly the technique from the microcontroller lab. But by the fifth or sixth task, the code becomes impossible to follow. The industrial solution is a scheduler that interrupts a task partway through and gives the processor to another, more urgent one.

The vocabulary of scheduling

TermNotationMeaning
PeriodTat what interval the task must be resumed
Execution timeChow much processor time it needs, in the worst case
DeadlineDby when it must finish; usually D = T
UtilizationU = Σ C/Twhat fraction of the processor is demanded in total
Preemption-interrupting a task in progress in favor of a more urgent one

The two classic policies

Rate Monotonic (RM)Earliest Deadline First (EDF)
Priorityfixed: shorter period → higher prioritydynamic: the nearest deadline wins
GuaranteeU ≤ n·(21/n − 1)U ≤ 1
Bound for large n≈ 69%100%
Runtime costlow - priorities are computed oncehigher - deadlines are compared constantly
Behavior under overloadpredictable: the slowest tasks give way firstunpredictable: everything can fail
Used byFreeRTOS, Zephyr, most embedded systemsresearch systems, some real-time Linux kernels
The utilization test is sufficient, not necessary If U is below the RM bound, the system is guaranteed schedulable. If it exceeds it, that does not mean it will not work - it only means this test cannot decide, and the answer must be found through simulation or response-time analysis. This is a distinction the simulator below makes explicit.

5Scheduling simulator

Three periodic tasks, one processor. Move the sliders for the execution time and watch the diagram: the upward arrows are the release times, the colored rectangles are the actual execution, and a red ✕ marks a missed deadline. Switch between the two policies and compare.

Rate Monotonic and EDF, with an execution diagram
Three experiments to do, in order
  1. Find the RM bound. Increase the duration of the "display" task step by step. At what utilization does the first missed deadline appear? Compare it with the theoretical bound of 78% for three tasks.
  2. The uncertain zone. Bring the utilization between the RM bound and 100%. The verdict becomes "the test cannot decide", yet the simulation shows zero missed deadlines. This is the practical proof that the test is sufficient, but not necessary.
  3. EDF's superiority. Find a configuration where RM misses a deadline, then switch to EDF without changing anything else. Same tasks, same processor, different result.
Why industry still chooses RM EDF uses the processor down to the last percent, but has two serious drawbacks. It costs more at runtime, because deadlines must be re-compared at every context switch. And, more importantly, under overload it collapses chaotically: you cannot predict which task will miss. With RM, under overload it is always the tasks with the longest period that give way first - so the designer can decide in advance what is allowed to suffer.

6FreeRTOS tasks in practice

The code below runs unchanged on the ESP32-C6 and, with minimal changes, on any FreeRTOS system - including PineTime.

basic_tasks.ino - three periodic tasks
// FreeRTOS priorities: a BIGGER number = a HIGHER priority.
// We choose them by the Rate Monotonic rule: shorter period, higher priority.
#define PRIO_SENSOR     3        // period  20 ms
#define PRIO_BLUETOOTH  2        // period 100 ms
#define PRIO_DISPLAY    1        // period 200 ms

void sensorTask(void* param) {
  // vTaskDelayUntil keeps an EXACT period, no matter how long the work took.
  TickType_t last = xTaskGetTickCount();
  for (;;) {
    read_accelerometer();
    vTaskDelayUntil(&last, pdMS_TO_TICKS(20));
  }
}

void bluetoothTask(void* param) {
  TickType_t last = xTaskGetTickCount();
  for (;;) {
    handle_bluetooth();
    vTaskDelayUntil(&last, pdMS_TO_TICKS(100));
  }
}

void displayTask(void* param) {
  TickType_t last = xTaskGetTickCount();
  for (;;) {
    draw_display();              // may take 30 ms - will be interrupted by the sensor
    vTaskDelayUntil(&last, pdMS_TO_TICKS(200));
  }
}

void setup() {
  Serial.begin(115200);

  //            function        name         stack  param  priority       handle
  xTaskCreate(sensorTask,    "sensor",     2048,  NULL,  PRIO_SENSOR,    NULL);
  xTaskCreate(bluetoothTask, "bluetooth",  4096,  NULL,  PRIO_BLUETOOTH, NULL);
  xTaskCreate(displayTask,   "display",    4096,  NULL,  PRIO_DISPLAY,   NULL);
}

void loop() {
  // The main loop is itself a FreeRTOS task, at priority 1.
  // We let it sleep, so it does not steal processor time.
  vTaskDelay(pdMS_TO_TICKS(1000));
}

Three mistakes almost everyone makes

1. vTaskDelay instead of vTaskDelayUntil vTaskDelay(20) means "wait 20 ms from now". If the work took 5 ms, the real period becomes 25 ms, and the error accumulates. vTaskDelayUntil is measured from the previous release time and keeps the period exact. For any periodic task, the second option is the correct one.
2. delay() inside a task On the ESP32, delay() is redirected to vTaskDelay() and behaves nicely. On other FreeRTOS systems it is an active-wait loop that blocks every lower-priority task for its entire duration. Always use the FreeRTOS functions.
3. A randomly chosen stack size Every task gets its own stack, and overflowing it does not give a clear error: the system hangs or reboots seemingly at random, often only after hours. Measure it, do not guess:
checking the stack
void diagnosticTask(void* param) {
  for (;;) {
    // Returns the minimum number of words that have remained FREE since startup.
    // If it approaches zero, the stack is undersized.
    UBaseType_t free_words = uxTaskGetStackHighWaterMark(NULL);
    Serial.printf("stack remaining: %u words (%u bytes)\n",
                  free_words, free_words * sizeof(StackType_t));
    vTaskDelay(pdMS_TO_TICKS(5000));
  }
}
A practical rule Run the application through every code path that gets exercised (including the error cases), read the minimum value reached, then keep a margin of about 30%. On PineTime, where the total memory is 64 KB, this measurement is not a refinement - it is the only way to make the application fit at all.

7Communication between tasks

Two tasks that write to the same global variable produce data corruption that is hard to reproduce. FreeRTOS's solution is the message queue: a protected circular buffer, into which one task puts data and another takes it out, without the two knowing about each other.

queue.ino - from sensor to display
typedef struct {
  uint32_t timestamp_ms;
  float    x, y, z;
} Reading;

QueueHandle_t queue;

void sensorTask(void* param) {
  TickType_t last = xTaskGetTickCount();
  for (;;) {
    Reading r = { millis(), readX(), readY(), readZ() };

    // The third parameter is how long we wait if the queue is full.
    // With 0 we do not wait at all: better to lose a reading than to
    // delay a higher-priority task.
    if (xQueueSend(queue, &r, 0) != pdTRUE) {
      // A full queue means the reader is not keeping up - useful information.
      Serial.println("reading lost - the consumer is too slow");
    }
    vTaskDelayUntil(&last, pdMS_TO_TICKS(20));
  }
}

void displayTask(void* param) {
  Reading r;
  for (;;) {
    // portMAX_DELAY: the task sleeps until something arrives. While asleep,
    // it does NOT consume processor time - exactly what we need for the battery.
    if (xQueueReceive(queue, &r, portMAX_DELAY) == pdTRUE) {
      draw(r.x, r.y, r.z);
    }
  }
}

void setup() {
  Serial.begin(115200);
  queue = xQueueCreate(10, sizeof(Reading));   // 10 messages pending
  if (queue == NULL) {
    Serial.println("not enough memory for the queue");
    return;
  }
  xTaskCreate(sensorTask,  "sensor",  2048, NULL, 3, NULL);
  xTaskCreate(displayTask, "display", 4096, NULL, 1, NULL);
}

void loop() { vTaskDelay(pdMS_TO_TICKS(1000)); }
Why a queue is better than a global variable
  • It is atomic - you cannot read half of a half-written structure.
  • It does not lose data - a global variable only keeps the last value; the queue keeps all ten.
  • It blocks efficiently - the consuming task sleeps without consuming the processor, instead of polling in a loop.
  • It tells you when the system cannot keep up - a full queue is a measurable symptom, not a silent failure.

8Priority inversion

This is the most famous trap in real-time systems. It endangered the Mars Pathfinder mission in 1997: the rover on Mars kept rebooting on its own, at irregular intervals, and the cause was exactly the mechanism described below.

How it happens

  1. A low-priority task takes a shared resource (for example the I²C bus).
  2. A high-priority task needs the same resource and blocks, waiting for it.
  3. A medium-priority task, which has nothing to do with the resource, becomes ready and preempts the low-priority one.
  4. Result: the medium-priority task indirectly delays the high-priority one. The priority hierarchy has been inverted.
high medium low takes I²C requests BLOCKED - waiting for the resource runs undisturbed, although it is less important releases finally runs delay caused by a LOWER-priority task
Fig. 1 - Priority inversion. The medium-priority task never touches the disputed resource, but still delays the most important task, simply by preempting the resource's holder.

Trigger it yourself

inversion.ino - one line makes the difference
SemaphoreHandle_t resource;

void busy_wait(uint32_t ms) {
  // ACTIVE wait loop: keeps the processor busy, like real processing.
  uint32_t t0 = millis();
  while (millis() - t0 < ms) { }
}

void lowTask(void* p) {                         // priority 1
  for (;;) {
    xSemaphoreTake(resource, portMAX_DELAY);
    Serial.println("  [low] took the resource");
    busy_wait(300);                              // works with the resource
    Serial.println("  [low] releasing the resource");
    xSemaphoreGive(resource);
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void mediumTask(void* p) {                       // priority 2
  vTaskDelay(pdMS_TO_TICKS(50));                 // starts after the low one
  for (;;) {
    Serial.println(" [medium] occupying the processor for 800 ms");
    busy_wait(800);                              // never touches the resource!
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void highTask(void* p) {                         // priority 3
  vTaskDelay(pdMS_TO_TICKS(100));                // requests the resource after the low one
  for (;;) {
    uint32_t t0 = millis();
    Serial.println("[HIGH] requesting the resource");
    xSemaphoreTake(resource, portMAX_DELAY);
    Serial.printf("[HIGH] waited %lu ms\n", millis() - t0);
    xSemaphoreGive(resource);
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void setup() {
  Serial.begin(115200);
  delay(500);

  // ---- CHANGE ONLY THIS LINE ----
  resource = xSemaphoreCreateBinary();          // WITHOUT priority inheritance
  xSemaphoreGive(resource);                     // binary semaphores start empty
  // resource = xSemaphoreCreateMutex();        // WITH priority inheritance
  // -------------------------------

  xTaskCreate(lowTask,    "low",    2048, NULL, 1, NULL);
  xTaskCreate(mediumTask, "medium", 2048, NULL, 2, NULL);
  xTaskCreate(highTask,   "high",   2048, NULL, 3, NULL);
}

void loop() { vTaskDelay(pdMS_TO_TICKS(1000)); }
What you will measure With xSemaphoreCreateBinary(), the high-priority task waits around 1000 ms: the 300 ms during which the resource is busy, plus the 800 ms during which the medium-priority task preempts the holder. With xSemaphoreCreateMutex() the wait drops to roughly 300 ms - just how long the work with the resource actually takes.
Priority inheritance A FreeRTOS mutex is not a binary semaphore with a different name. When a high-priority task blocks on a mutex, the system temporarily raises the holder's priority to the level of the blocked task. The holder can no longer be preempted by intermediate tasks, finishes quickly and releases the resource, and its priority then returns to its original value. This is the fix that was uploaded over the radio to Mars Pathfinder.

Practical rule: use a mutex for mutual exclusion (protecting a resource) and a semaphore for signaling (announcing an event). They are different things, even though the API looks similar.

9Building the InfiniTime firmware

We move from examples to a complete firmware. InfiniTime has over a hundred thousand lines of code and runs on FreeRTOS with the LVGL graphical interface.

Check the project's documentation first The build commands of open-source projects change from one version to another. Before starting, read the doc/buildAndProgram.md file in the repository. The steps below reflect the usual procedure, but the repository is always the source of truth.
  1. Download the source code
    download
    git clone https://github.com/InfiniTimeOrg/InfiniTime.git
    cd InfiniTime
    git submodule update --init --recursive
  2. Build using the official Docker image

    This is the recommended path: it brings along the ARM compiler and the Nordic SDK, so you do not need to install anything else.

    build
    docker run --rm -it \
      -v "$(pwd)":/sources \
      -u $(id -u):$(id -g) \
      infinitime/infinitime-build

    The first run takes a while, because it downloads the image. The result appears in build/output/ - the file with a .zip extension and the -dfu suffix is the package that gets loaded over Bluetooth.

  3. Explore the project structure
    orientation
    ls src/systemtask/          # the central task, which coordinates the others
    ls src/displayapp/          # the display task and the apps
    ls src/components/          # the drivers: battery, screen, sensors, Bluetooth
    grep -rn "xTaskCreate" src/ # where the tasks are created
    grep -rn "configTOTAL_HEAP_SIZE" src/FreeRTOSConfig.h
  4. The watch-free alternative: the simulator

    InfiniSim builds the same interface code for the computer and displays it in a window, with an emulated screen and touch. It is the fastest way to develop a watchface.

    simulator
    git clone https://github.com/InfiniTimeOrg/InfiniSim.git
    cd InfiniSim && git submodule update --init --recursive
    # follow the instructions in the README for the SDL2 dependencies

Anatomy of the system

Once the code is built, look for the xTaskCreate calls. You will find a clear pattern:

TaskRoleHow it is sized
SystemTaskcoordinates everything: startup, sleep, eventshigh priority, generous stack
DisplayAppdraws the screen and handles touchesthe largest stack - LVGL needs memory
Bluetooth stackmaintains the radio connectionhigh priority, strict deadlines imposed by the protocol
idle taskruns when nothing else has tothis is where the low-power state is entered
The idle task is the most important one for the battery When every task is asleep, the scheduler hands control to the idle task, which stops the processor's clock until the next interrupt. This mechanism is called tickless idle. If a single task in the system busy-waits in a loop instead of using vTaskDelay, the idle task never runs again, consumption rises tenfold and the battery lasts a day instead of a week. A single misplaced while(1){} has this effect.

10Adding your own task

We add to InfiniTime a task that counts the steps taken in every hour of the day, and we show the result on the watch, in the steps app. The example is small, but it sets three tasks in motion and uses both communication mechanisms from section 7:

  1. SystemTask reads the accelerometer and, whenever the step total changes, sends a report through a queue. The queue carries events: "something happened".
  2. statistics, our task, sleeps blocked on the queue. On each report it wakes up, works out how many steps were added, records them in the per-hour table, then goes back to sleep.
  3. DisplayApp reads the table when it draws the screen. The table is shared state, so reading and writing it are protected by a critical section.
src/components/statistics/Statistics.h
#pragma once
#include <FreeRTOS.h>
#include <task.h>
#include <queue.h>
#include <array>
#include <cstdint>
#include "components/datetime/DateTimeController.h"

namespace Pinetime {
  namespace Components {

    class Statistics {
    public:
      struct Report {
        uint8_t  hour;     // 0...23
        uint32_t steps;    // the day's total, as the sensor gives it
      };

      explicit Statistics(Controllers::DateTime& dateTime) : dateTime {dateTime} {}

      void Start();                          // creates the queue and the task
      void ReportSteps(uint32_t steps);      // called from the system task
      uint32_t StepsInHour(uint8_t hour);    // called from the display task

    private:
      static void Task(void* param);         // the task function
      void Loop();

      Controllers::DateTime& dateTime;       // where we read the current hour from
      TaskHandle_t  handle = nullptr;
      QueueHandle_t queue = nullptr;

      uint32_t lastSent = 0;                 // used ONLY by the system task
      uint32_t lastReceived = 0;             // used ONLY by the statistics task
      bool     haveBaseline = false;         // used ONLY by the statistics task
      std::array<uint32_t, 24> per_hour {};  // written by statistics, read by display
    };
  }
}
src/components/statistics/Statistics.cpp
#include "Statistics.h"

using namespace Pinetime::Components;

void Statistics::Start() {
  // The queue is small: we only keep a few reports pending.
  queue = xQueueCreate(4, sizeof(Report));
  if (queue == nullptr) {
    return;                  // no memory even for the queue
  }

  // A 512-word stack = 2 KB. The task does not use LVGL and has no
  // large buffers, so this is enough for it. Check with
  // uxTaskGetStackHighWaterMark before treating this as the final value.
  if (xTaskCreate(Task, "statistics", 512, this,
                  tskIDLE_PRIORITY + 1, &handle) != pdPASS) {
    handle = nullptr;        // no memory for the stack
  }
}

void Statistics::Task(void* param) {
  static_cast<Statistics*>(param)->Loop();
}

void Statistics::Loop() {
  Report r;
  for (;;) {
    // The task SLEEPS here until a report arrives. While waiting it costs
    // nothing, and the idle task can stop the processor's clock.
    if (xQueueReceive(queue, &r, portMAX_DELAY) != pdTRUE) {
      continue;
    }

    // The first report after boot only gives the starting point: we do not
    // know in which hour the steps taken before the reboot happened.
    if (!haveBaseline) {
      lastReceived = r.steps;
      haveBaseline = true;
      continue;
    }

    // The sensor gives the day's total; the difference from the previous
    // total is the number of steps taken in between.
    uint32_t added;
    if (r.steps >= lastReceived) {
      added = r.steps - lastReceived;
    } else {
      // The total went down: midnight has passed and the counter restarted
      // from zero. We begin a new day of statistics.
      added = r.steps;
      taskENTER_CRITICAL();
      per_hour.fill(0);
      taskEXIT_CRITICAL();
    }
    lastReceived = r.steps;

    if (r.hour < per_hour.size()) {
      taskENTER_CRITICAL();              // the display may read the table at any time
      per_hour[r.hour] += added;
      taskEXIT_CRITICAL();
    }
  }
}

void Statistics::ReportSteps(uint32_t steps) {
  // Only send when something changed - otherwise we fill the queue for nothing.
  if (queue == nullptr || steps == lastSent) {
    return;
  }
  Report r {dateTime.Hours(), steps};
  // No waiting: if the queue is full, the report is lost. No steps are lost,
  // though - the next report again carries the day's total.
  if (xQueueSend(queue, &r, 0) == pdTRUE) {
    lastSent = steps;
  }
}

uint32_t Statistics::StepsInHour(uint8_t hour) {
  if (hour >= per_hour.size()) {
    return 0;
  }
  taskENTER_CRITICAL();
  uint32_t value = per_hour[hour];
  taskEXIT_CRITICAL();
  return value;
}
The design decisions, explicitly
  1. Priority tskIDLE_PRIORITY + 1 - the lowest above the idle task. Statistics have no deadline; they must not be allowed to delay drawing the screen or the Bluetooth stack.
  2. A 512-word stack - the starting value, which must be verified with uxTaskGetStackHighWaterMark. On a system with 64 KB, every kilobyte allocated needlessly is taken from someone else. Section 11 shows you where to see it on the watch.
  3. The task waits on the queue instead of waking up periodically - xQueueReceive with portMAX_DELAY takes it out of scheduling entirely until a report arrives. While you stand still the task does not run at all; a loop with vTaskDelay would wake up for nothing thousands of times a day.
  4. Zero wait time when sending - the system task has a higher priority and must not be blocked by a full queue belonging to an unimportant one. If a report is lost, the steps are not: every report carries the day's total, so the next one recovers everything.
  5. Every variable has a single owner - lastSent is used only by the system task, lastReceived only by the statistics task. The only thing used by two tasks is the per_hour table, and only it is protected by the critical section.
Why we do not use configASSERT for checksIn InfiniTime, configASSERT is a macro that disappears entirely in the build that goes onto the watch. A line like BaseType_t r = xTaskCreate(...); configASSERT(r == pdPASS); leaves behind a variable that is never read, and the project is compiled with -Werror, which turns every warning into an error: the build stops with unused variable. Worse, the check itself is gone too. That is why the result is tested explicitly with if, and if something could not be created, the task simply stays inactive instead of bringing the watch down.

11Wiring the task in and rebuilding

The two files from the previous section do nothing yet. If you copy them into the project and build, the build succeeds with no error, the watch boots, and the task does not exist. The reason: the compiler does not know the new file has to be compiled, nobody creates the object or calls its Start(), and nothing displays the result. Four steps follow, then a rebuild.

The most confusing way to get it wrongA .cpp file that is not listed in CMakeLists.txt produces no error at all: it is simply not compiled. If you changed the code and nothing changes on the watch, check this step first.
How to read an undefined reference errorThe message comes from the linker (ld), not the compiler: every file compiled fine, but in the end something calls a function whose code was not compiled into that executable. Look at which target fails - its name appears in the line CMakeFiles/pinetime-recovery.dir/.... The dangerous relocation lines around it are just consequences of the same missing function.
  1. Register the new file with CMake

    Open src/CMakeLists.txt and add the path of the .cpp file, relative to src/, next to the other components. The .h file does not go in the list. Careful: the line has to go into two lists, not one.

    Besides the normal firmware, InfiniTime also builds a recovery firmware - a minimal image the bootloader loads if the main application no longer starts. It contains SystemTask.cpp too, so it also calls Start() and ReportSteps(). Its source list is called RECOVERY_SOURCE_FILES. If you add the file to SOURCE_FILES only, compilation succeeds but linking the recovery firmware stops with undefined reference to Pinetime::Components::Statistics::Start().

    src/CMakeLists.txt
    # 1. the normal firmware
    list(APPEND SOURCE_FILES
      ...
      components/datetime/DateTimeController.cpp
      components/statistics/Statistics.cpp      # <-- the added line
      ...
    )
    
    # 2. the recovery firmware - further down in the same file
    list(APPEND RECOVERY_SOURCE_FILES
      ...
      components/datetime/DateTimeController.cpp
      components/statistics/Statistics.cpp      # <-- the same line, once more
      ...
    )
  2. Create the object in the system task

    Our task needs the date-and-time controller to know the current hour (see the constructor in Statistics.h). SystemTask already holds a reference to it, so the object is declared there. Order matters: class members are constructed in the order they are declared, so statistics must be declared after dateTimeController. The GetStatistics() function gives the steps app access to the object, in step 4.

    Be careful where you put it: right below enum class SystemTaskState starts the declaration of the SystemTask(...) constructor, which spans about 20 lines. If the function ends up inside its parameter list, the compiler reports hundreds of errors (expected ')' before '{' token, then uninitialized reference member for every member), even though the mistake is a single line. Put it after PushMessage, as below.

    src/systemtask/SystemTask.h
    #include "systemtask/Messages.h"
    #include "components/statistics/Statistics.h"     // <-- added
    
    // ... in the public section, RIGHT AFTER the PushMessage declaration.
    //     CAREFUL: not inside the parameter list of the SystemTask(...) constructor,
    //     which spans about 20 lines just above and ends with ");"
          void Start();
          void PushMessage(Messages msg);
          Pinetime::Components::Statistics& GetStatistics() { return statistics; }
    
    // ... in the private section, RIGHT AFTER the dateTimeController line:
          Pinetime::Controllers::DateTime& dateTimeController;
          Pinetime::Components::Statistics statistics {dateTimeController};
  3. Start the task and feed it data

    Start() is called once, at boot, after the other initialisation in SystemTask::Work(). The steps come from the function that reads the accelerometer, UpdateMotion() - that is where we pass them on through the queue.

    src/systemtask/SystemTask.cpp
    void SystemTask::Work() {
      // ... existing initialisation ...
      motionController.Init(motionSensor.DeviceType());
      settingsController.Init();
      statistics.Start();                              // <-- starts our task
      // ...
    }
    
    void SystemTask::UpdateMotion() {
      // ...
      auto motionValues = motionSensor.Process();
    
      motionController.Update(motionValues.x, motionValues.y, motionValues.z, motionValues.steps);
      statistics.ReportSteps(motionValues.steps);      // <-- the day's total, into the queue
      // ... the rest of the function is unchanged ...
    }
  4. Show the result in the steps app

    The Steps app (the shoe icon) receives the object through AppControllers, which already holds a pointer to SystemTask, and adds a new line at the top of the screen. The Refresh() function runs in the display task ten times a second while the app is open - that is where the table is read, through StepsInHour().

    There are four changes in Steps.h, marked (a)–(d). The easiest one to forget is the first, the #include at the top. Without it the compiler does not know what Statistics is and reports 'Pinetime::Components::Statistics' has not been declared, then invalid use of incomplete type 'class Pinetime::System::SystemTask' - that is, it knows SystemTask exists but has not yet seen its definition.

    src/displayapp/screens/Steps.h
    // (a) AT THE TOP, with the includes - without it, Steps.h does not know
    //     what Statistics is, nor what SystemTask contains:
    #include "Symbols.h"
    #include "systemtask/SystemTask.h"                 // <-- added
    
    // (b) the constructor takes two more references:
            Steps(Controllers::MotionController& motionController,
                  Controllers::Settings& settingsController,
                  Components::Statistics& statistics,
                  Controllers::DateTime& dateTime);
    
    // (c) in the private section, below settingsController:
            Controllers::Settings& settingsController;
            Components::Statistics& statistics;
            Controllers::DateTime& dateTime;
            lv_obj_t* lHour;
    
    // (d) and in AppTraits<Apps::Steps>, the Create function becomes:
          static Screens::Screen* Create(AppControllers& controllers) {
            return new Screens::Steps(controllers.motionController,
                                      controllers.settingsController,
                                      controllers.systemTask->GetStatistics(),
                                      controllers.dateTimeController);
          };
    src/displayapp/screens/Steps.cpp
    Steps::Steps(Controllers::MotionController& motionController,
                 Controllers::Settings& settingsController,
                 Components::Statistics& statistics,
                 Controllers::DateTime& dateTime)
      : motionController {motionController}, settingsController {settingsController}, statistics {statistics}, dateTime {dateTime} {
    
      // ... all the existing constructor code ...
    
      // the new label, at the top of the screen - right before lv_task_create:
      lHour = lv_label_create(lv_scr_act(), nullptr);
      lv_obj_set_style_local_text_color(lHour, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::orange);
      lv_label_set_text_static(lHour, "");
      lv_obj_align(lHour, nullptr, LV_ALIGN_IN_TOP_MID, 0, 25);
    
      taskRefresh = lv_task_create(RefreshTaskCallback, 100, LV_TASK_PRIO_MID, this);
    }
    
    void Steps::Refresh() {
      // ... existing code ...
      lv_arc_set_value(stepsArc, int16_t(500 * stepsCount / settingsController.GetStepsGoal()));
    
      uint8_t hour = dateTime.Hours();
      lv_label_set_text_fmt(lHour, "Hour %02d: %lu", int(hour), statistics.StepsInHour(hour));
      lv_obj_align(lHour, nullptr, LV_ALIGN_IN_TOP_MID, 0, 25);
    }
  5. Rebuild

    Exactly the same command as in section 9. The build is incremental: only the changed files and whatever depends on them are recompiled, so it usually takes under a minute, not as long as the first time.

    rebuild
    # from the root of the InfiniTime repository
    docker run --rm -it \
      -v "$(pwd)":/sources \
      -u $(id -u):$(id -g) \
      infinitime/infinitime-build
    
    # where the package for the phone ended up:
    find build -name "*-dfu*.zip"

    If you get an error, read the first error in the list - the others are usually consequences of it. If you changed CMakeLists.txt and the build seems to ignore it, delete the build/ directory and build again from scratch.

Check that the file really was compiledBefore flashing, look for the class name in the memory map produced by the build: grep -l "Statistics" build/src/*.map. Both pinetime-app and pinetime-recovery must show up. If nothing shows up, the file was not compiled - go back to step 1.
What you see on the watch after flashing
  • In the Steps app an orange line appears at the top, Hour 14: 0. The first report after boot only sets the starting point, so counting begins with the first steps taken after the reboot. Walk a few dozen steps and reopen the app: the value goes up. On the hour, the line moves to the new hour and starts from zero, and at midnight the whole table is cleared.
  • In Settings → About, on screen 4 (the list of FreeRTOS tasks) a new row appears, sta. The name is cut to three characters because InfiniTime sets configMAX_TASK_NAME_LEN to 4 - MAI and dis show up the same way. Column S almost always shows B (Blocked): the task sleeps on the queue, exactly as intended. Column Free shows how many stack words were never used - use it to answer the question from section 10: what value would you choose instead of 512?
Reference solutionAll the changes from sections 10 and 11, collected in a single file: lab03-statistics-en.patch. It applies to InfiniTime 1.16.0, from the root of the repository: first git apply --check lab03-statistics-en.patch (checks without changing anything), then git apply lab03-statistics-en.patch. Use it to compare, after you have tried on your own - git diff shows you exactly where your code differs.
A whole new app or watch face is added differentlyFor a completely new screen do not use SOURCE_FILES: screens are registered in src/displayapp/apps/CMakeLists.txt and enabled at build time by adding -e ENABLE_USERAPPS="..." or -e ENABLE_WATCHFACES="..." to the Docker command. Read doc/code/Apps.md in the repository for the exact steps of the version you build.

12Flashing the watch

The watch is updated over Bluetooth, with no wires and no programmer. The bootloader keeps the previous version and automatically falls back to it if the new firmware does not confirm that it started correctly. The procedure differs slightly between Android and iOS - pick the column that applies to your phone.

  1. Fully charge the watch

    An update interrupted by a drained battery is the most realistic way to brick a PineTime. The battery must be above 50%.

  2. Install the companion app that matches your phone
    AndroidiOS (iPhone / iPad)
    AppGadgetbridgeInfiniLink
    SourceF-Droid (free, open source)App Store (free, open source)
    What else it can donotifications, weather, music controltime/date, battery, heart rate, steps, Apple Music, HealthKit
    One name to avoid: nRF Connect for iOS It is the app that shows up most often in searches for flashing Nordic boards, but it no longer works for PineTime: versions after 4.24.3 broke compatibility, and the App Store always installs the latest version. There is no easy way to install the older version that used to work. Use InfiniLink.
  3. Transfer the .zip package to your phone

    The file is in build/output/ and has the -dfu suffix. Do not unzip it - the archived format is what the bootloader expects. On an iPhone, the simplest way is to send it to the Files app (AirDrop from your computer, or a direct download in Safari) - InfiniLink can load it directly from there.

  4. Start the update

    Android (Gadgetbridge): connect the watch, then Firmware/App install and choose the file.
    iOS (InfiniLink): connect the watch from the main screen, then from the firmware menu choose "Update from file" and select the .zip archive from Files. Alternatively, InfiniLink can check the InfiniTime releases page on GitHub itself and offer the latest version directly, without downloading anything manually.
    The transfer takes a few minutes on any platform. Do not move the phone away from the watch during this time.

  5. Confirm the new version

    After rebooting, the watch asks for confirmation that the firmware works. If you do not confirm, the bootloader reverts to the previous version on the next reboot - an elegant safety net, worth studying as a mechanism in its own right.

Safe updating, as a principle The mechanism here - two firmware slots, explicit confirmation after boot, automatic rollback on failure - is exactly what cars, routers and satellites use. A device you have no physical access to must never be turned into a brick by a bad update. Read the bootloader's code: it is short and instructive.
Why a separate app was needed for iOS Gadgetbridge does not have, and will not have, an iOS version: Apple's policy on background Bluetooth access and access to other apps' notifications is far more restrictive than on Android, and the InfiniTime community preferred to write a native Swift app from scratch - InfiniLink - rather than port the Gadgetbridge code. The result: the two apps are not interchangeable, but both speak the same update protocol (Nordic's DFU), so the watch receives exactly the same file no matter which phone you load it from.
If something goes wrong The sealed watch cannot be reflashed over wires without opening it. The bootloader, however, is designed exactly for this situation, and in practice falling back to the previous version works. Never interrupt a transfer in progress.

13Assignments

  1. Using the simulator from section 5, determine the maximum utilization at which the set of three tasks remains schedulable under RM. Compare it with the theoretical bound and explain the difference.
  2. Find a configuration where RM misses deadlines and EDF does not. Copy both diagrams into your report and explain exactly what different decision EDF makes.
  3. Upload basic_tasks.ino to the ESP32-C6. Modify displayTask to take 250 ms (longer than its period) and describe what happens to the other tasks.
  4. Add diagnosticTask and note the remaining stack for each task. Shrink the allocations down to the safe limit and calculate how much memory you recovered.
  5. Run inversion.ino in both variants. Note the high-priority task's wait time in each case and explain the difference through the priority-inheritance mechanism.
  6. Build InfiniTime. Find every xTaskCreate call and produce a table of the system's tasks: name, priority, stack size and the role of each.
  7. Look up the configUSE_TICKLESS_IDLE option in FreeRTOSConfig.h and explain, based on the code, what happens when every task is asleep.
  8. Add the statistics task following sections 10 and 11, flash the firmware onto the watch and photograph the Steps app screen before and after a few dozen steps.
  9. In Settings → About, screen 4, write down the value in the Free column for the sta task. Choose a stack size that keeps a 25 % margin over what was actually used, rebuild and check again. How many bytes of RAM did you recover compared with 512 words?
  10. Explain why losing a report from the queue does not lose any steps. What would happen if ReportSteps() sent only the difference from the previous report, instead of the day's total?

14Deeper-dive challenge

Your own watchface Create a new watchface for InfiniTime that shows the time, the battery level and the step count. Develop it in the simulator, then flash it onto the watch.

The interesting part is not the drawing, but the refresh budget. Every screen redraw wakes the processor and costs energy. Measure, with uxTaskGetStackHighWaterMark and a wakeup counter:
  • How many times a minute does your watchface redraw?
  • What is the minimum number of redraws needed to show the correct time?
  • How much does the autonomy improve if you redraw only the digit that changed, instead of the whole screen?
A watchface that redraws the whole screen ten times a second looks identical to one that does it once a minute - but it consumes hundreds of times more. This is, in essence, the entire art of programming for wearable devices.
The day in a chart Build a new screen that draws 24 bars, one per hour, with the values from StepsInHour(0) ... StepsInHour(23). Hints: an lv_chart object of type LV_CHART_TYPE_COLUMN with 24 points; the screen is registered as a user app (doc/code/Apps.md) and enabled through ENABLE_USERAPPS.

Two questions to answer in your report: Why is no new queue needed for this screen? And how often does the chart have to be refreshed, if the data changes at most every few seconds - are the 10 refreshes per second the Steps app does really necessary?

15Self-check questions

16Resources