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ο
Service Layer: Heart rate sensor connection, data processing, FIT recording
GUI Layer: TouchGFX-based single-screen interface, heart rate display
SDK Integration: Kernel, sensor layer, file system, messaging
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 |
|
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()β setsmGuiStarted = true, sends an initialHR_VALUES(0.0, 0.0)to initialise the display.onStopGUI()β setsmGuiStarted = 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:
GUI sends
COMMAND_APP_STOPβ service disconnects sensor, writes Lap + Session, returns.
Early exit β GUI never starts:
SDK::Timer guiInitTimeout(TIMER_SECONDS(5))starts at service entry.If
mGuiStartedis 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 |
|---|---|---|
|
Service entry |
FileHeader (placeholder), FileID, DeveloperDataID, FieldDescription ( |
|
Every second while |
RECORD with timestamp, heart_rate, hr_trust_level |
|
Service exit |
LAP with duration, avg HR, max HR |
|
Service exit |
STOP event, SESSION, ACTIVITY, updated FileHeader, CRC |
|
(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 timeoutView:
MainViewβ UI rendering and button inputPresenter:
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 |
|
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;
}
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 rateOn timeout,
modelListener->onIdleTimeout()is called, which triggersexitApp()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 |
|---|---|---|
|
Service -> GUI |
Every valid sensor reading while |
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 coordinationActivityWriterβ FIT file lifecycle managementCommands.hppβ shared message types andSenderclass
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_VALUESmessage type covers the entire Service -> GUI data pathSensor abstraction β
SDK::Sensor::ConnectionandSensorDataParser::HeartRateisolate the app from driver detailsFIT persistence β
ActivityWriterhandles the full recording lifecycle (open, record, lap, session, CRC) independently of the service loop