HRMonitor - Heart Rate Monitor

Overview

The HRMonitor app is an Activity-type application designed for wearable devices. It provides continuous heart rate monitoring with real-time display and persistent data recording in FIT file format. The app connects to a heart rate sensor, shows the current BPM and trust level on screen, and writes a one-second FIT record throughout the session.

The app does not use GPS or any other sensor. All logic is HR-driven: the service connects to the heart rate sensor on startup, waits for the GUI to become active, then records one FIT record per second and forwards every valid sensor reading to the display. If the GUI never starts within 5 seconds, the service exits without saving a file.

Key features include:

  • Real-time heart rate display with trust level indicator

  • Continuous FIT recording at 1 Hz while the GUI is active

  • Session summary with average and maximum heart rate

  • Single-screen interface with exit via R2 button

  • Automatic exit if the GUI fails to start within 5 seconds

Architecture

The app is structured as two independent components: a service that handles sensor integration and FIT file recording, and a GUI that displays real-time heart rate data and provides application exit control. Communication between them occurs through a message-based system using the UNA SDK kernel infrastructure.

High-Level Components

  1. Service Layer: Heart rate sensor connection, data processing, FIT recording

  2. GUI Layer: TouchGFX-based single-screen interface, heart rate display

  3. SDK Integration: Kernel, sensor layer, file system, messaging

  4. Data Persistence: FIT file format for heart rate activity data

Component Interaction

[Heart Rate Sensor] <-> [Sensor Layer] <-> [Service]
                                               ^
                                               |
                        [Message System] <-> [GUI]

The service runs as a separate thread, continuously processing heart rate sensor data and writing FIT records. The GUI runs on the TouchGFX framework, handling user input and displaying heart rate data received from the service.

Service Backend

The service backend is implemented in Service.hpp and Service.cpp, providing the core functionality for heart rate monitoring and FIT recording.

Core Classes and Structures

Service Class

class Service
{
public:
    Service(SDK::Kernel& kernel);
    virtual ~Service();
    void run();

private:
    // -- Constants ---
    static constexpr uint32_t skSamplePeriod  = 1000;
    static constexpr uint32_t skSampleLatency = 2000;

    // -- Infrastructure ---
    SDK::Kernel&          mKernel;
    bool                  mGuiStarted;
    CustomMessage::Sender mGuiSender;

    // -- Sensors ---
    SDK::Sensor::Connection mSensorHr;

    // -- Metrics ---
    float mHr;
    float mHrTl;

    // -- Activity ---
    ActivityWriter mActivityWriter;
};

Sensor Integration

The service manages a single sensor connection:

Sensor

Type constant

Period

Latency

Purpose

Heart Rate

HEART_RATE

1000 ms

2000 ms

BPM + trust level

The sensor is connected at service startup (before the GUI is ready) and disconnected on exit, so the sensor driver has time to initialise and deliver the first reading by the time the GUI starts.

Data Processing Pipeline

1. Sensor Data Reception

Sensor data arrives as EVENT_SENSOR_LAYER_DATA. The handleSensorsData() method identifies the source via matchesDriver() and parses the frame:

void Service::handleSensorsData(uint16_t handle, SDK::Sensor::DataBatch& data)
{
    if (mSensorHr.matchesDriver(handle)) {
        if (mGuiStarted) {
            SDK::SensorDataParser::HeartRate parser(data[0]);
            if (parser.isDataValid()) {
                mHr   = parser.getBpm();
                mHrTl = parser.getTrustLevel();
                mGuiSender.heartRate(mHr, mHrTl);
            }
        }
    }
}

mHr and mHrTl are only updated while mGuiStarted is true. Values remain at 0.0 until the first valid sensor reading after GUI start.

2. Data Validation

The HR parser’s isDataValid() check rejects frames with out-of-range or missing signal. Trust level (0–3) is forwarded as-is to both the GUI and the FIT file; the app does not gate recording on trust level β€” all valid sensor frames contribute.

3. FIT File Recording

Heart rate data is recorded every second during active monitoring:

time_t utc = time(nullptr);
if (processedUtc != utc) {
    processedUtc = utc;

    ActivityWriter::RecordData fitRecord{};
    fitRecord.timestamp  = utc;
    fitRecord.heartRate  = static_cast<uint8_t>(mHr);
    fitRecord.trustLevel = static_cast<uint8_t>(mHrTl);
    mActivityWriter.addRecord(fitRecord);
}

processedUtc is initialised to startTime (the timestamp passed to ActivityWriter::start()), so the first FIT record is written at startTime + 1s β€” strictly after the FIT START event. This prevents the FIT validator from flagging a record at the same timestamp as the START event.

Activity State Management

The service maintains a single boolean mGuiStarted to gate both sensor processing and FIT recording:

  • onStartGUI() β€” sets mGuiStarted = true, sends an initial HR_VALUES(0.0, 0.0) to initialise the display.

  • onStopGUI() β€” sets mGuiStarted = false, stops FIT recording.

Recording only occurs while the GUI is active. When mGuiStarted is false, sensor data is ignored and no FIT records are written, but the service continues running and listening for COMMAND_APP_NOTIF_GUI_RUN.

Service Lifecycle

The service’s run() method handles two exit conditions:

Normal exit β€” user presses R2:

  1. GUI sends COMMAND_APP_STOP β†’ service disconnects sensor, writes Lap + Session, returns.

Early exit β€” GUI never starts:

  1. SDK::Timer guiInitTimeout(TIMER_SECONDS(5)) starts at service entry.

  2. If mGuiStarted is still false when the timer expires, the service breaks out of the loop and exits without saving a FIT file.

getMessage Timeout

mKernel.comm.getMessage(msg, 500) uses a 500 ms timeout. This ensures the FIT record check (processedUtc != utc) runs at least twice per second, maintaining a reliable 1 Hz recording cadence independently of sensor message frequency.

Activity Statistics

The service accumulates session statistics for the FIT Lap and Session summary messages:

float    hrAvgSum   = 0.0f;
uint32_t hrAvgCount = 0;
float    hrMax      = 0.0f;

Only samples with mHr > 1.0f contribute to the average and maximum, filtering out zero readings that appear before the sensor delivers valid data.

Persistence (FIT)

FIT file lifecycle is managed by ActivityWriter:

Method

Called when

Writes

start(info)

Service entry

FileHeader (placeholder), FileID, DeveloperDataID, FieldDescription (hr_trust_level), message definitions, START event

addRecord(record)

Every second while mGuiStarted

RECORD with timestamp, heart_rate, hr_trust_level

addLap(lap)

Service exit

LAP with duration, avg HR, max HR

stop(track)

Service exit

STOP event, SESSION, ACTIVITY, updated FileHeader, CRC

discard()

(not used in this app)

Deletes file

FIT file structure per session:

FileHeader (placeholder)
-> FileID -> DeveloperDataID -> FieldDescription(hr_trust_level)
-> [message definitions]
-> Event(START)  @ startTime
-> Record        @ startTime + 1s
-> Record        @ startTime + 2s
-> ...
-> Event(STOP)   @ endTime
-> Session       @ endTime
-> Activity      @ endTime
-> FileHeader    (updated with final data_size)
-> FileCRC

Files are stored under Activity/YYYYMM/activity_YYYYMMDDThhmmss.fit.

See Docs/FitFiles-Structure.md for FIT implementation details and ActivityWriter usage.

GUI

The GUI is built using the TouchGFX framework and follows the Model-View-Presenter pattern.

Project Structure

HRMonitor/Software/Apps/TouchGFX-GUI/
+-- HRMonitorGUI.touchgfx   # TouchGFX Designer project
+-- application.config      # Application configuration
+-- target.config           # Target hardware settings
+-- touchgfx.cmake          # CMake integration
+-- gui/                    # Custom GUI code
+-- assets/                 # Images, fonts, texts
+-- generated/              # Auto-generated TouchGFX code
+-- simulator/              # Simulator builds

Model-View-Presenter Pattern

  • Model: Model.hpp/cpp β€” service communication, lifecycle, idle timeout

  • View: MainView β€” UI rendering and button input

  • Presenter: MainPresenter β€” logic binding model and view

Model

The Model class serves as the central data hub:

class Model : public touchgfx::UIEventListener,
              public SDK::Interface::IGuiLifeCycleCallback,
              public SDK::Interface::ICustomMessageHandler
{
public:
    void bind(ModelListener* listener);
    void tick();
    void handleKeyEvent(uint8_t key);
    void exitApp();
};

Key responsibilities:

  • Lifecycle callbacks (onStart, onResume, onSuspend, onStop)

  • Custom message handling from the service (HR_VALUES)

  • Idle timeout management (kScreenTimeoutSteps = 30 s)

  • Application exit via mKernel.sys.exit()

Screens (1 total)

Screen

Class

Purpose

Main

MainView

Displays current heart rate and trust level; R2 exits

Message Handling System

The Model implements ICustomMessageHandler to receive heart rate updates from the service:

bool Model::customMessageHandler(SDK::MessageBase* msg)
{
    switch (msg->getType()) {
        case CustomMessage::HR_VALUES: {
            auto* m = static_cast<CustomMessage::HRValues*>(msg);
            modelListener->updateHR(m->heartRate, m->trustLevel);
        } break;

        default:
            break;
    }
    return true;
}

Screen Navigation

The app uses a single-screen design with no transitions:

MainView
    |
[R2: exit] --> exitApp()

Custom Containers

The app uses one SDK widget container from Templates/TouchGFX-Widgets:

  • Buttons β€” button arc indicators (L1/L2/R1/R2). Only R2 is lit (AMBER) on the main screen.

Input Handling

Button Mapping:

Button

Action

R2

Exit application

Any button press resets the idle timer.

Idle Timeout

The app implements automatic screen timeout:

  • Timeout: App::Config::kScreenTimeoutSteps = 30 seconds at the frame rate

  • On timeout, modelListener->onIdleTimeout() is called, which triggers exitApp()

  • Timer resets on any button press (handleKeyEvent)

Custom Message System

All service β†’ GUI messages are declared in Libs/Header/Commands.hpp. Message structs inherit from SDK::MessageBase and are wrapped in #pragma pack(push, 4).

namespace CustomMessage {

// Service --> GUI
constexpr SDK::MessageType::Type HR_VALUES = 0x00000001;

struct HRValues : public SDK::MessageBase {
    float heartRate;
    float trustLevel;

    HRValues()
        : SDK::MessageBase(HR_VALUES)
        , heartRate(0.0f)
        , trustLevel(0.0f)
    {}
};

} // namespace CustomMessage

Message summary:

Message

Direction

Trigger

HR_VALUES

Service -> GUI

Every valid sensor reading while mGuiStarted is true; also once on GUI start with (0.0, 0.0)

Message Sender

CustomMessage::Sender provides type-safe message sending from the service to the GUI. It uses the allocateMessage / sendMessage / releaseMessage pattern:

class Sender {
public:
    Sender(const SDK::Kernel& kernel);

    // Service --> GUI
    bool heartRate(float value, float trustLevel);

private:
    const SDK::Kernel& mKernel;
};

Build and Setup

Build System Overview

Primary Build File: CMakeLists.txt in HRMonitor/Software/Apps/HRMonitor-CMake/

# App configuration
set(APP_NAME "HRMonitor")
set(APP_TYPE "Activity")
set(DEV_ID "UNA")
set(APP_ID "A14B7E9C2F8D6A53")

# Include SDK build scripts
include($ENV{UNA_SDK}/cmake/una-app.cmake)
include($ENV{UNA_SDK}/cmake/una-sdk.cmake)

Build Targets

Service Build:

set(SERVICE_SOURCES
    ${LIBS_SOURCES}
    ${UNA_SDK_SOURCES_COMMON}
    ${UNA_SDK_SOURCES_SERVICE}
)
una_app_build_service(${APP_NAME}Service.elf)

GUI Build:

set(GUI_SOURCES
    ${TOUCHGFX_SOURCES}
    ${UNA_SDK_SOURCES_COMMON}
    ${UNA_SDK_SOURCES_GUI}
)
una_app_build_gui(${APP_NAME}GUI.elf)

Complete App:

una_app_build_app()

Dependencies

SDK Components:

  • UNA SDK common, service, and GUI sources

  • Sensor layer interfaces

  • Kernel and messaging systems

App Libraries (Libs/):

  • Service β€” service entry point, sensor handling, FIT recording coordination

  • ActivityWriter β€” FIT file lifecycle management

  • Commands.hpp β€” shared message types and Sender class

Development Setup

See SDK Setup and Build Overview for comprehensive development environment setup, build instructions, and toolchain requirements.

Simulator Build

The TouchGFX project includes a Visual Studio simulator build located in simulator/msvs/Application.vcxproj. The ConfigurationSimulator.hpp file controls which sensors are simulated; HEAT_RATE_SIM_ENABLE 1 activates the HR sensor simulator with configurable BPM range (HEAT_RATE_SIM_MIN_HR / HEAT_RATE_SIM_MAX_HR).

Conclusion

The HRMonitor app is the simplest activity-recording UNA application: a single screen, a single sensor, and a single message type. Its minimal footprint makes it a clear reference for the SDK’s core patterns β€” sensor subscription, service-to-GUI messaging, FIT recording, and MVP-based display.

Key architectural strengths include:

  • Separation of concerns β€” Service owns all sensor logic and FIT recording; GUI owns only display and exit

  • Minimal message surface β€” A single HR_VALUES message type covers the entire Service -> GUI data path

  • Sensor abstraction β€” SDK::Sensor::Connection and SensorDataParser::HeartRate isolate the app from driver details

  • FIT persistence β€” ActivityWriter handles the full recording lifecycle (open, record, lap, session, CRC) independently of the service loop