Skip to content

Bike Computer Part II

Introduction

Event-driven model

In part 1 of this codelab, we have implemented a version of the bike computer program that uses Timeline Cyclic Scheduling scheduling using a Super Loop mechanism. This implementation used polling for checking the button status. The limitations of this approach have been demonstrated and the need for an event-driven approach has been made clear.

In this part of the codelab, we will study how to implement an event-driven model for non periodic tasks. We will compare the event response time to the ones obtained in part 1 of the codelab.

Before starting the work on this codelab, make sure you carefully read the deliverable sections of Project Phase 2.

What you’ll build

In this codelab, you’re going to adapt your bike_computer program to handle non periodic events using an event-driven approach, with Timeline Cyclic Scheduling of the periodic tasks.

What you’ll learn

  • How to use and log statistics about CPU usage.
  • How to use the Work and WorkQueue API (from zpp_lib) for programming periodic tasks.
  • How to develop a simple embedded program using an event-driven approach.

What you’ll need

  • The Zephyr Development Environment for developing and debugging C++ code snippets.
  • The Bike computer - part I codelab is a prerequisite to this codelab.

Start from BikeComputer Part 1

To implement the next version of the bike_computer program, start from the version developed in Part 1 of the codelab:

  • Copy the code from the static_scheduling folder into a new subfolder named static_scheduling_with_event. The code in this new folder will contain the changes described in this codelab.

  • Modify the namespace in all classes from static_scheduling to static_scheduling_with_event.

  • Modify your main program to also include this new implementation and use it depending on project configuration.

  • Compile and run your bike_computer program. The result should be the same as in Part 1.

Implement the BikeComputer Program using Time-Triggered Cyclic Executive Scheduling

The first change compared to Part 1 relates to the implementation of cyclic scheduling. Rather than using a Super Loop, the program will now be implemented using Time-Triggered Cyclic Executive. Task scheduling remains the same as that implemented in the Super Loop, but tasks will now be triggered using timers.

The Time-Triggered Cyclic Executive mechanism is implemented using the TTCE class below:

TTCE class
bike_computer/src/common/ttce.hpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file ttce.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief TTCE implementation
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

#pragma once

// zephyr
#include <zephyr/kernel.h>

// std
#include <chrono>
#include <string>

// zpp_lib
#include "zpp_include/clock.hpp"
#include "zpp_include/zephyr_result.hpp"
#include "zpp_include/zpp_assert.hpp"

namespace bike_computer {

template <typename F, uint16_t NbrOfMinorCycles, uint16_t MaxMinorCycleSize> class TTCE {
public:
  explicit TTCE(std::chrono::milliseconds minorCycle) : _minor_cycle(minorCycle) {
    k_timer_init(&_timer, &TTCE::s_thunk, nullptr);
    // specify this instance as user data
    // this cast is ugly but the only way to pass a reference to this instance to the
    // timer
    // NOLINTNEXTLINE(modernize-avoid-c-style-cast)
    _timer.user_data = (void*)this;  // NOLINT(readability/casting)
    k_work_init(&_work, &TTCE::s_work_handler);
    // initialize the work queue
    k_work_queue_init(&_work_queue);
  }

  ~TTCE() {
    stop();
  }

  /** Explicity prevent (move) copy and assignment
      rather than inheriting from NonCopyable. This avoids
      cppcoreguidelines-special-member-functions warning by clang-tidy.
  */
  TTCE(const TTCE&)            = delete;
  TTCE(TTCE&&)                 = delete;
  TTCE& operator=(const TTCE&) = delete;
  TTCE& operator=(TTCE&&)      = delete;

  void start() {
    // first start the timer
    k_timeout_t period = zpp_lib::milliseconds_to_ticks(_minor_cycle);
    k_timer_start(&_timer, period, period);

    // then run the work queue
    struct k_work_queue_config cfg = {
        .name     = "TTCE Work Queue",
        .no_yield = true,
    };
    _is_started = true;
    k_work_queue_run(&_work_queue, &cfg);
  }

  // Complexity is increased by zephyr macros
  // NOLINTNEXTLINE(readability-function-cognitive-complexity)
  void stop() {
    if (!_is_started) {
      return;
    }
    // first stop the time
    k_timer_stop(&_timer);
    // drain the work queue
    auto ret = k_work_queue_drain(&_work_queue, true);
    if (ret < 0) {
      ZPP_ASSERT(false, "k_work_queue_drain failed with code %d", ret);
    }
    // This is a Zephyr macro
    // NOLINTNEXTLINE(readability-math-missing-parentheses)
    ret = k_work_queue_stop(&_work_queue, K_SECONDS(1));
    if (ret != 0) {
      ZPP_ASSERT(false, "k_work_queue_stop failed with code %d", ret);
    }
    _is_started = false;
  }

  bool is_started() {
    return _is_started;
  }

  void add_initial_task(F f) {
    _initial_task = std::move(f);
  }

  [[nodiscard]] zpp_lib::ZephyrResult add_task(uint16_t minor_cycle_index, F f) {
    zpp_lib::ZephyrResult res;
    if (minor_cycle_index >= NbrOfMinorCycles) {
      ZPP_ASSERT(false, "Invalid minor cycle index %d", minor_cycle_index);
      res.assign_error(zpp_lib::ZephyrErrorCode::Inval);
      return res;
    }
    // Everything is known at compile time, so we can safely use the minor_cycle_index as an index
    // NOLINTBEGIN(cppcoreguidelines-pro-bounds-constant-array-index)
    if (_nbr_of_tasks_in_minor_cycle[minor_cycle_index] >= MaxMinorCycleSize) {
      ZPP_ASSERT(false, "Too many tasks in minor cycle %d: %d", minor_cycle_index, _nbr_of_tasks_in_minor_cycle[minor_cycle_index] + 1);
      res.assign_error(zpp_lib::ZephyrErrorCode::Inval);
      return res;
    }

    _tasks[minor_cycle_index][_nbr_of_tasks_in_minor_cycle[minor_cycle_index]] = std::move(f);
    _nbr_of_tasks_in_minor_cycle[minor_cycle_index]++;
    // NOLINTEND(cppcoreguidelines-pro-bounds-constant-array-index)

    return res;
  }

private:
  static void s_thunk(struct k_timer* timer_id) {
    // submit the periodic TTCE task
    if (timer_id != nullptr) {
      // get instance from user data
      // this cast is ugly but the only way to pass a reference
      // to this instance to the timer
      // NOLINTNEXTLINE(modernize-avoid-c-style-cast)
      TTCE* p_ttce = (TTCE*)timer_id->user_data;  // NOLINT(readability/casting)
      auto ret     = k_work_submit_to_queue(&p_ttce->_work_queue, &p_ttce->_work);
      if (ret != 0 && ret != 1 && ret != 2) {
        ZPP_ASSERT(false, "Failed to submit work: %d", ret);
        return;
      }
    }
  }

  static void s_work_handler(struct k_work* item) {
    // this ugly casting is the simplest way of getting the information
    // we need in the s_workHandler method
    // CASTING IS POSSIBLE ONLY WHEN k_work IS THE FIRST ATTRIBUTE IN THE CLASS
    // NOLINTNEXTLINE(modernize-avoid-c-style-cast)
    TTCE* p_ttce = (TTCE*)item;  // NOLINT(readability/casting)

    // if an initial task is set, execute it and reset it
    if (p_ttce->_initial_task != nullptr) {
      p_ttce->_initial_task();
      p_ttce->_initial_task = nullptr;
    }

    // execute tasks based on schedule table
    // Everything is known at compile time, so we can safely use the minor_cycle_index as an index
    // NOLINTBEGIN(cppcoreguidelines-pro-bounds-constant-array-index)
    for (uint16_t task_index = 0; task_index < MaxMinorCycleSize; task_index++) {
      if (p_ttce->_tasks[p_ttce->_minor_cycle_index][task_index] != nullptr) {
        p_ttce->_tasks[p_ttce->_minor_cycle_index][task_index]();
      }
    }
    p_ttce->_minor_cycle_index = (p_ttce->_minor_cycle_index + 1) % NbrOfMinorCycles;
    // NOLINTEND(cppcoreguidelines-pro-bounds-constant-array-index)
  }

  // _work MUST be the first attribute
  struct k_work _work         = {};
  struct k_work_q _work_queue = {};
  bool _is_started            = false;
  struct k_timer _timer       = {};
  std::chrono::milliseconds _minor_cycle;
  uint16_t _minor_cycle_index = 0;
  // Declaring these arrays as static constexpr allows the compiler to optimize them and avoid unnecessary copies.
  // NOLINTBEGIN(cppcoreguidelines-avoid-c-arrays,modernize-avoid-c-arrays)
  F _tasks[NbrOfMinorCycles][MaxMinorCycleSize]           = {nullptr};
  uint16_t _nbr_of_tasks_in_minor_cycle[NbrOfMinorCycles] = {0};
  // NOLINTEND(cppcoreguidelines-avoid-c-arrays,modernize-avoid-c-arrays)
  F _initial_task = nullptr;
};

}  // namespace bike_computer

To understand how the TTCE class works, you need to understand the following:

  • TTCE is a template class with three template parameters defined as

    template <typename F, uint16_t NbrOfMinorCycles, uint16_t MaxMinorCycleSize> class TTCE`
    

    • typename F is a type template parameter. F is a callable type meaning that one can call f() when f is an instance of type F.
    • uint16_t NbrOfMinorCycles is a constant template parameter, which specifies the number of minor cycles in the major cycle.
    • uint16_t MaxMinorCycleSize is a constant template parameter, which specifies the maximum number of tasks in each minor cycle.
  • The TTCE constructor performs the following actions:

    • It initializes the _minor_cycle data member. This data member represents the duration of the minor cycle in milliseconds.
    • It initializes the _timer data member. The timer calls the TTCE::_thunk static method at regular time intervals. The timer is started in the TTCE::start() method, and the k_timer data structure contains a user_data field that stores a reference to the TTCE instance.
    • It initializes the _work data member. It allows to set the handler function that will be called when a work is submitted to a work queue. In this case, the handler function is the static method TTCE::_workHandler. See below the description of the TTCE::_thunk() and TTCE::_workHandler() methods.
    • It initializes the _work_queue data member.
  • The TTCE::start() method performs the following actions:

    • It starts the timer at the _minor_cycle period.
    • It runs the _work_queue work queue. It is important to note that the work queue will be run forever and that the k_work_queue_run call will not return, unless the work queue is stopped by calling the TTCE::stop() method. This means that the thread executing the TTCE::start() method will run the work queue forever and will not execute anything else. TTCE::stop() must be executed from another thread.
  • The TTCE::stop() method performs the following actions:

    • It stops the timer _timer.
    • It drains and stops the work queue _work_queue.
    • As stated above, it is important to note that the TTCE::stop() method must be called from a different thread than the thread calling TTCE::start().
  • The TTCE::add_task() method enables the addition of a task to a specific minor cycle. The task is specified as an instance of F. Tasks are added in FIFO order.

  • The private TTCE::s_thunk() static method is called by the timer and it performs the following actions:

    • It gets the TTCE instance from the user_data void pointer. This pointer is set in the class constructor.
    • Using the TTCE instance, it does submit the work to the work queue. This will invoke the s_work_handler() static method.
  • The private TTCE::s_work_handler() static method is invoked by the work queue and it performs the following actions:

    • It gets the TTCE instance from the k_work* parameter that is passed as parameter. Each time the work queue invokes the work handler attached to the work item, a pointer to the work item is passed to the work handler function.
    • Since _work is the first attribute, it is possible to cast the work item pointer to a pointer to the containing class.
  • It is important to note that the C-style castings used in the TTCE class are used because no more robust and elegant solution exists. This is imposed by the Zephyr RTOS timer and work queue APIs.

Before proceeding further, ensure that you understand the basic principles of the TTCE class.

The next step is to integrate it into your BikeComputer class and to use it in the BikeComputer::start() method to replace the Super Loop mechanism. This is accomplished as follows:

  • In C++, a class template is not a compilation unit in itself; it needs to be instantiated. This means that you must declare a variable with the template parameters specified. The code below demonstrates the declaration of the BikeSystem class attribute _ttce that instantiates the template class TTCE. In this instance, the template parameter F is defined as a function with the signature void(), and kNbrOfMinorCycles and kMaxNbrOfTasksInMinorCycles are constants that define the TTCE constant parameters.

    using VoidFunction                                     = std::function<void()>;
    static constexpr uint16_t kNbrOfMinorCycles            = ..;
    static constexpr uint16_t kMaxNbrOfTasksInMinorCycles  = ..;
    static constexpr std::chrono::milliseconds kMinorCycle = ..ms;
    TTCE<VoidFunction, kNbrOfMinorCycles, kMaxNbrOfTasksInMinorCycles> _ttce{kMinorCycle};
    
    You must initialize all variables with the correct TTCE parameters.

  • Replace each call to a task function with a call to TTCE::add_task(). The parameter F to be passed to the add_task() method is the task function applied to the BikeSystem instance, which is declared as [this]() { this->gear_task(); } for instance. For correct calls to TTCE::add_task(), the task distribution over minor cycles needs to be known and applied correctly.

  • After adding all tasks, add the _taskManager.initialize_phase() as initial task of the TTCE instance. Finally, start your TTCE instance.

At this point, your bike_computer program should now behave in the same way as it did with the Super Loop implementation.

Use zpp_lib::Utils::log_cpu_load() to Log CPU statistics

In order to understand the behaviour of the bike_computer program, it is helpful to understand its CPU load. The zpp_lib library provides a wrapper to the Zephyr RTOS cpu_load_get() function to compute and log cpu load in the console. You may call this wrapper function with:

#ifdef CONFIG_CPU_LOAD
#include "zpp_include/utils.hpp"
#endif
...
#ifdef CONFIG_CPU_LOAD
zpp_lib::Utils::log_cpu_load();
#endif

With the TTCE implementation, the call to zpp_lib::Utils::log_cpu_load() must be implemented by adding one additional task to one minor cycle. Remember to account for this additional task when defining the constant parameters of the TTCE instance. To build the application with cpu load measurement enabled, you can use

just build bike_computer log_app+gpio+sensor+display+phase_b+log_cpu

Question 1

If you log CPU statistics as one task of the TTCE instance, what CPU usage do you observe? How can you explain the observed CPU uptime?

Reduce CPU Usage with Sleep Calls rather than Busy Waits

To match the expected task periods, the TaskManager::simulate_computation_time() method implements two different mechanisms based on busy wait or sleep. Until now, we used a busy wait mechanism. With this mechanism, the CPU remains active while waiting for a given time to elapse. This results in higher-than-necessary CPU usage. This can be improved by replacing the busy wait implementation with calls to the zpp_lib::ThisThread::sleep_for() method. The allow_sleep parameter passed to the TaskManager::simulate_computation_time() method determines the mechanism used to simulate computation time.

Question 2

If you run the program after the change from busy wait to sleep calls, what CPU usage do you observe? How can you explain the observed CPU uptime? It is important that you document the answer to this question before applying further changes in your code. Some changes implemented below will again modify the CPU load.

Test your TTCE Implementation

To test your TTCE implementation, duplicate the test program created for your Super Loop implementation of the bike_computer program. Then, apply the following changes:

  • Rename the folder to bike_computer/tests/bike_computer/bike_system_part2.
  • Adapt the code to use the static_scheduling_with_event::BikeSystem implementation.
  • Modify the tag from phase_a to phase_b in the testcase.yaml file.
  • Modify the ../../../deps/zpp_lib/configs/prj_phase_a.conf configuration file to ../../../deps/zpp_lib/configs/prj_phase_b.conf in the testcase.yaml file.

Then run

just test bike_computer/tests/bike_system_part2/ "your_map_file.yaml"

You need to validate again the longest test duration for a successfull test on each platform. With a sleep mechanism rather than a busy wait mechanism, we expect the test duration to be larger than with the Super Loop implementation.

Static Scheduling Event-Driven Implementation

One of the major drawback of the implementation of our BikeComputer program so far, is that events can be missed. Moreover, polling continuously for detecting the button state consumes useless CPU time.

On embedded systems, this can be improved by handling events using interrupts. Interrupts are implemented using special dedicated hardware in the MCU that detects the event and runs an Interrupt Service Routine (ISR) in response. For handling asynchronous events that are by nature non-predictable, using interrupts is the most appropriate approach:

  • An interrupt-based routine only runs the code when it is called/initiated by the interrupt-service routine. This routine directly executes the addressed code, sometimes without any CPU interaction.
  • Therefore, it is fully scalable, because it is independent from the number of monitored events. In other words, the interrupt supervision doesn’t consume CPU time.
  • Using interrupts, the controller is prevented from doing unnecessary tasks by operating in a passive mode for as long as possible, responding mostly just to events and triggers.

When the interrupt trigger occurs, the processor does some hard-wired processing and then executes the ISR, including return-from-interrupt processing at the end, as depicted in the figure below.

Interrupt processing

Interrupt processing

Using the zpp_lib InterruptIn API for detecting an event on an input pin is very easy. to detect an event on an input pin is easy. Instead of polling for input status periodically and continuously, you only need to create an instance of the InterruptIn class and trigger an event when a digital input pin changes. You can trigger interrupts on the falling edge of signals by setting a callback function with the InterruptIn::fall() method. Note that the InterruptIn class is a template class, and the template parameter is the PinName of the button. In this context, PinName is an enum class type that defines the four buttons present on your nrf5340dk/nrf5340/cpuapp device.

Implement the BikeComputer Program using an Event-driven Approach

To implement a version of your program with an event-driven detection of the button press, you must apply some changes to the program developed in the part I of the codelab. The major change is that you must register a function that is called upon button press.

The steps for applying the changes are:

  • Modify the ResetDevice class so that it sets an internal flag upon button press. The checkReset() method used by the BikeComputer class must be modified accordingly. Be careful in implementing the internal state that is modified in a ISR context and can be queried from another thread.

  • Modify the GearDevice and PedalDevice classes similarly for an event-driven implementation. Instead of polling to check the button state at regular intervals, register an event handler using the Button::fall() method. This will enable you to change the gear (GearDevice) or the pedal rotation speed (PedalDevice) without polling.

Implementation note for all device classes

For all device classes, the button fallback methods remain internal to each class and modify the device’s internal state. These classes continue to provide ‘getter’ methods for accessing this internal state.

Should internal state attributes be protected against concurrent access

In principle, attributes that can be modified in ISR routines and accessed from other threads should be protected. This is important because the attribute could be modified by the ISR at any time and in particular when a thread has started to read the attribute. In some situations, it may not be necessary to do so, if all following conditions are met:

  • The attribute is a single atomic machine word (e.g., uint32_t on a 32-bit MCU).
  • The ISR only writes/reads the attribute.
  • The thread only reads/writes the attribute.
  • The variable is declared atomic_t or volatile, or you access it with Zephyr RTOS’s atomic_*() APIs.

When these conditions are met, nested interrupts (e.g., a higher priority ISR interrupts a lower priority ISR) are not an issue. In particular, in our scenario, only one ISR can modify the shared data.

About the use of std::atomic<T>

Alternatively, one can use std::atomic<T> rather than Zephyr RTOS’s atomic_*() APIs. However, you need to consider that std::atomic<T> works correctly as long as:

  • T is a type that the underlying architecture supports for lock-free atomics (e.g., uint32_t on a 32-bit MCU). This can be checked with the std::atomic<T>::is_always_lock_free() function. For instance, under Zephyr RTOS, std::atomic<uint64_t> is not implemented for 32-bit architectures.
  • Your ISR and threads only use atomic stores/loads or atomic operations (no non-atomic accesses).
  • You use relaxed / acquire / release memory orders correctly. This means that when protecting data access with a flag, then the order is which data is written/accessed must be guaranted such as in
    // in ISR
    data = 123; // normal write
    flag.store(true, std::memory_order_release); // Prevents stores after this operation
    // in thread
    if (flag.load(std::memory_order_acquire)) { // Prevents loads before this operation
      auto x = data; // guaranteed correct
    }
    
  • Note that by default the memory order to std::atomic<T> methods is memory_order_seq_cst, which guarantees the strongest ordering but is also the slowest.

Once all classes have been changed accordingly, you may buid and test your application again.

Run the test program

Once you have implemented all changes documented above, you can run the “bike_system_part2” test program again. Check whether the test duration before failure and document the changes.

Question 3

If you run the static_scheduling_with_event program, what CPU usage do you observe? How can you explain the observed CPU uptime?

Volatile data

At this stage, it is important to introduce the concept of volatile data. Volatile data is created in your program by adding the volatile keyword to a variable declaration. This is sometimes required because compilers assume that variables in memory don’t change spontaneously, and optimize the code based on that belief. The reasons for optimizing the code are:

  • Don’t reload a variable from memory if the current function hasn’t changed it.
  • Read a variable from memory into a register for faster access.
  • Write back to memory at the end of the procedure, or before a procedure call, or when the compiler runs out of free registers, but not before, for saving memory write.

This optimization can fail in some circumstances. For example, while reading from input port or polling for key press using while (status);. The compiler may generate code that reads from status only once and reuse that value. This will generate an infinite loop triggered by status being true.

The variables for which it fails in all circumstances are:

  • Memory-mapped peripheral register: register changes on its own.
  • Global variables modified by an ISR: the ISR changes the variable.
  • Global variables in a multithreaded application: another thread or ISR changes the variable.

This is the reason why you should declare the reset variable modified in the ISR as volatile. However, Zephyr RTOS’s atomic_*() functions do not expect volatile parameters and as documented above it is sufficient to use these atomic_*() functions.

Static Scheduling with Interrupts

This implementation of the bike computer program can be described as cyclic static scheduling with interrupts. It essentially has two operating modes:

  • The main application code runs in the foreground and schedule all period tasks as expected. Some period tasks check for device status stored in global volatile memory for executing portions of the main application code.
  • The interrupt service routines run in the background with high priority. These routines are executed when an event is triggered, they handle the most urgent work and modify global volatile variables that will be queried for processing in the main application code.

This implementation is an improvement over the implementation that does not use events, since it guarantees that no event is ever missed.

Question 4: Response time of the reset event

When you run multiple tests for computing the response time of the reset event, what do you observe? Is there an improvement as compared to the static_scheduling::BikeSystem implementation?

Based on the program itself and on the task scheduling, explain the observed behavior.

Wrap-Up

At the end of this codelab, make sure that you have accomplished the following:

  • The BikeSystem class has replace busy waits with sleep calls.
  • The ResetDevice, GearDevice and PedalDevice classes has been modified as specified, to support event-driven handling of the buttons.
  • The BikeSystem class has been modified for registering ISRs and handling events.
  • You have run all tests successfully.
  • You have answered all questions.
  • The pre-commit configuration file has been modified to include the necessary hooks. All software quality tools succeed, changes are committed and pushed to your repository.

Deliverables

Deliverables/Requirements (Project Phase B)

The deliverables/requirements for project Phase B include the following:

  • The implementation of the bike_computer using a a Timeline Cyclic Scheduling algorithm with a Time-Triggered Cyclic Executive mechanism is functional. Buttons are handled using events. All related files are available in the static_scheduling_with_event folder and all related code is developed under the static_scheduling_with_event namespace.

  • The developed code is compliant to the clang-tidy rules and just run-clang-tidy bike_computer log+debug+gpio+display+sensor+phase_b runs successfully.

  • The following hook is added to the pre-commit configuration:

  • id: clang-tidy-bike-computer-bike-system-part2 pre-commit runs successfully.

  • The bike_computer/tests/bike_system_part2 test runs successfully on your board and on qemu_x86. The CONFIG_TEST_DURATION_IN_MS parameter have been estimated and updated in the bike_computer/tests/bike_system_part2/testcase.yaml file. The ci/applications_for_build.yaml and ci/applications_for_test.yaml files are updated. The following commands run successfully:

    • just test bike_computer/tests/bike_system_part2 "you_map_file.yaml"
    • just test-qemu bike_computer/tests/bike_system_part2
    • just test-all "your_map_file.yaml", including the bike_computer/tests/bike_system_part2 test.
    • just test-all-qemu, including the bike_computer/tests/bike_system_part2 test.

    All build and tests pass on the GitHub repository.

  • The answers to the four questions of this codelab 1 are documented in the README.md file.