Sensors - Integrating Hardware Sensors
In this tutorial, we implement a sensors dashboard app that subscribes to available sensors at maximum frequency (period=0, count=0 except Accelerometer), processes new message structures with timestamps where available, and displays data on a single GUI screen:
Top: Battery level
Middle multiline text: Sensor data with L1/L2 verbosity levels (BASIC/DETAILED/FULL and individual sensor views)
Bottom: Stats (Service/GUI CPU%, TX/RX msg rates, bytes/sec)
As in the earlier tutorials, the app comes with a TouchGFX GUI (Software/Apps/TouchGFX-GUI, built by Sensors-CMake) and an LVGL GUI (Software/Apps/LVGL-GUI, built by SensorsLVGL-CMake) over the same service. Steps 1 and 2 below are the service and apply to both; steps 3 and 4 show each toolkit’s side.
List of Implemented Sensors
Sensor |
Description |
Notes |
|---|---|---|
Heart Rate |
Live BPM + Trust Level |
|
GPS Location |
Lat/Long/Alt (double) |
Always on |
Altimeter |
Elevation (m) |
Barometric pressure based |
Accelerometer |
X/Y/Z acceleration (m/s^2) |
connect(0.1f, 0); sender throttled ~100ms |
Step Counter |
Total steps |
Cumulative |
Floor Counter |
Floors ascended |
Cumulative; parser.getFloorsUp() |
Magnetometer |
X/Y/Z fields + calibrated flag |
Bearing from parser.getAzimuthDeg(); valid only when calibrated |
RTC |
Time (sec since boot) |
From kernel sys.getTimeMs()/1000; not sensor |
Architecture Overview
graph LR
Sensors[(Sensors HW)] --> SDL[Sensor Data Layer]
SDL -->|EVENT_SENSOR_LAYER_DATA| Service[Service Thread]
Service -->|Custom Msgs w/ timestamps| Kernel[(Kernel)]
Kernel -->|Custom Msgs| Model[GUI Model]
Model --> View[MainView]
View --> Display["Single Screen:
Battery Top
Multiline Data (L1/L2)
Bottom Stats"]
Buttons[L1/L2 Buttons] -.-> View
Implementation Steps
Optional step: copy the Sensor tutorial code to edit
Copy sensors tutorial
Change naming: Rename project directory, cmake directory and name of the project in CMakeLists.txt; Also change APP_ID to something else, Step 2 in Creating New Apps gives commmands for generating your own app ID programatically from the name.
Commit initial changes: it’s a good practice to use version control system like git
*Edit TouchGFX if you changed the name:
Rename
*.touchgfxto<MY_APP>.touchgfxRename
*.touchgfx:163"Name": "MY_APP"Click Generate code
Step 1: Define Custom Messages
Create Commands.hpp with message types and structs:
namespace CustomMessage {
constexpr SDK::MessageType::Type HR_VALUES = 0x00000001;
constexpr SDK::MessageType::Type LOCATION_VALUES = 0x00000002;
constexpr SDK::MessageType::Type ELEVATION_VALUES = 0x00000003;
constexpr SDK::MessageType::Type ACCELEROMETER_VALUES = 0x00000004;
constexpr SDK::MessageType::Type STEP_COUNTER_VALUES = 0x00000005;
constexpr SDK::MessageType::Type FLOORS_VALUES = 0x00000006;
constexpr SDK::MessageType::Type COMPASS_VALUES = 0x00000007;
constexpr SDK::MessageType::Type STATS_VALUES = 0x00000008;
constexpr SDK::MessageType::Type RTC_VALUES = 0x00000009;
constexpr SDK::MessageType::Type BATTERY_VALUES = 0x0000000A;
constexpr SDK::MessageType::Type PRESSURE_VALUES = 0x0000000B;
// Every message pairs a default constructor, which sets the message type,
// with one that fills the fields and delegates to it -- the shape every
// example app uses, which keeps the type tag in a single initializer. That
// second constructor is what lets a caller send the message in a single call.
struct HRValues : public SDK::MessageBase {
float heartRate;
float trustLevel;
HRValues()
: SDK::MessageBase(HR_VALUES)
, heartRate()
, trustLevel()
{}
explicit HRValues(float heartRate, float trustLevel)
: HRValues()
{
this->heartRate = heartRate;
this->trustLevel = trustLevel;
}
};
// The rest follow the same shape, over these fields:
// LocationValues: uint64_t timestamp; double latitude, longitude, altitude;
// ElevationValues: uint64_t timestamp; float elevation;
// AccelerometerValues: uint64_t timestamp; float x,y,z;
// StepCounterValues: uint64_t timestamp; uint32_t steps;
// FloorsValues: uint64_t timestamp; uint32_t floors;
// CompassValues: uint64_t timestamp; float heading;
// StatsValues: float serviceCpuPct, guiCpuPct, txMsgRate, rxMsgRate, txByteRate, rxByteRate;
// RtcValues: uint32_t time;
// BatteryValues: float level;
// PressureValues: uint64_t timestamp; float pressure;
}
PressureValues is the one message the service never sends: the sensor is connected and Model.cpp
handles PRESSURE_VALUES, but the service only hex-dumps the raw frame today. Both ends exist, and
so does SDK::SensorDataParser::Pressure — wiring it up means giving that branch the same
parse-then-send shape as the branches below, using parser.getPressure().
Those constructors are what let SDK::send_msg<T>(kernel, args...) do the whole send in one call —
allocate from the kernel pool, forward the arguments to the constructor, send, and release. The app
needs no sender class of its own.
send_msg is for fire-and-forget sends, and it posts with a zero timeout — it never waits for a
reply, and a message that finds no room in the queue is dropped. That is the right trade for sensor
data: the next sample is moments away. Reach for SDK::make_msg<T>() when the reply matters: it
returns an RAII MessageGuard that releases on scope exit, so you can send with a timeout and read
the result back (msg.send(timeout) && msg.ok()). That timeout bounds the wait for the reply, not for
queue space — no send waits out a full queue.
Step 2: Service - Subscribe & Process Sensors
In Service.hpp:
SDK::Sensor::Connection mSensorHR{SDK::Sensor::Type::HEART_RATE, 0, 0};
SDK::Sensor::Connection mSensorGPS{SDK::Sensor::Type::GPS_LOCATION, 0, 0};
SDK::Sensor::Connection mSensorAltimeter{SDK::Sensor::Type::ALTIMETER, 0, 0};
SDK::Sensor::Connection mSensorAccelerometer{SDK::Sensor::Type::ACCELEROMETER, 0, 0};
SDK::Sensor::Connection mSensorStepCounter{SDK::Sensor::Type::STEP_COUNTER, 0, 0};
SDK::Sensor::Connection mSensorFloorCounter{SDK::Sensor::Type::FLOOR_COUNTER, 0, 0};
In run() Service.cpp: connect all (acc.connect(0.1f, 0)), loop getMessage:
case SDK::MessageType::EVENT_SENSOR_LAYER_DATA: {
auto event = static_cast<SDK::Message::Sensor::EventData*>(msg);
SDK::Sensor::DataBatch data(event->data, event->count, event->stride);
onSdlNewData(event->handle, data);
} break;
In onSdlNewData:
if (mSensorHR.matchesDriver(handle)) {
SDK::SensorDataParser::HeartRate parser(data[0]);
if (parser.isDataValid()) {
SDK::send_msg<CustomMessage::HRValues>(mKernel, parser.getBpm(), parser.getTrustLevel());
}
}
// Similar for GPS: SDK::send_msg<CustomMessage::LocationValues>(mKernel, ts, lat, lon, alt);
// Altimeter: SDK::send_msg<CustomMessage::ElevationValues>(mKernel, ts, parser.getAltitude());
// Accel: if (nowMs - mLastAccTimeMs >= 100) SDK::send_msg<CustomMessage::AccelerometerValues>(mKernel, ts, x, y, z);
// Steps: SDK::send_msg<CustomMessage::StepCounterValues>(mKernel, ts, parser.getStepCount());
// Floors: SDK::send_msg<CustomMessage::FloorsValues>(mKernel, ts, parser.getFloorsUp());
// Compass: if (parser.isAzimuthValid()) SDK::send_msg<CustomMessage::CompassValues>(mKernel, ts, parser.getAzimuthDeg());
Track stats every 1s (simplistic CPU% = ms/10, rates=counts/sec) with
SDK::send_msg<CustomMessage::StatsValues>(...), and
SDK::send_msg<CustomMessage::RtcValues>(mKernel, timeMs/1000).
Step 3: GUI Model - Receive Messages
In Model.cpp (TouchGFX, LVGL), implement customMessageHandler:
case CustomMessage::HR_VALUES: {
auto* m = static_cast<CustomMessage::HRValues*>(msg);
modelListener->updateHR(m->heartRate, m->trustLevel);
} break;
// Similar cases for all types: LOCATION_VALUES -> updateGPS(m->latitude, m->longitude, m->altitude);
// etc. for Elevation, Accelerometer, StepCounter, Floors, Compass, Stats, RTC
The handler is the same code in both GUIs. What differs is one line in the constructor: the TouchGFX model registers with SDK::TouchGFXCommandProcessor::GetInstance().setCustomMessageHandler(this), the LVGL model with SDK::LVGL::Port::GetInstance().setCustomMessageHandler(this). ModelListener declares one update...() virtual per message; the screen overrides them.
Step 4: MainView / MainScreen - Display & Controls
TouchGFX, in MainView.hpp, MainView.cpp:
Store data in members, updateHR(float hr, float tl) etc. store values.
handleKeyEvent: L1: verbosity++ % VERB_LEVEL_MAX, L2: verbosity– % VERB_LEVEL_MAX (BASIC/DETAILED/FULL/HR/GPS/ALT/ACC/STEP/FLOOR/MAG), R1: TODO GPS toggle, R2: presenter->exit()
handleTickEvent() every tick: refreshDisplay() refreshStats() refreshBattery()
refreshDisplay(): format multiline in text_body based on verbosity level, with group display for BASIC/DETAILED/FULL and per-sensor detailed views for individual sensors.
Unicode::strncpy(text_bodyBuffer, buffer, TEXT_BODY_SIZE); invalidate
Header: refreshBattery() “Battery: %.1f%%” text_header
Stats: refreshStats() “CPU S: %.1f%% G: %.1f%%\nMsg Tx: %.0f Rx: %.0f\nBytes Tx: %.0f Rx: %.0f” text_stats
LVGL, in MainScreen.hpp, MainScreen.cpp: the same members, the same three format functions and the same key handling, with these differences:
The three text areas are labels made with
Draw::label()at the design’s positions; a label grows to the lines it holds, andlv_label_set_text()replacesUnicode::strncpy+invalidate().The per-frame
handleTickEvent()becomesonFrame(): the model forwards the kernel’s frame tick to the screen throughModelListener, and the screen calls its three refresh functions there, once per frame, as the TouchGFX view does.The body’s face is switched with
lv_obj_set_style_text_font(): Poppins Regular 9 for the group views, Regular 18 for one sensor at a time.The fonts come from
assets/assets.json, at 2 bits per pixel like every LVGL face on this platform: the display has two bits per colour channel, so finer anti-aliasing cannot be shown. The body faces also carry the degree sign for the compass line.
Size on the watch
Build |
|
GUI code (text) |
GUI RAM (bss) |
|---|---|---|---|
Sensors (TouchGFX) |
553 KB |
223 KB |
73 KB |
SensorsLVGL |
489 KB |
164 KB |
139 KB |
Most of either package is the service, which links the sensor layer and the FIT writer.
Additional Notes
Battery: Battery level retrieval implemented using SDK::Sensor::BATTERY_LEVEL
Altimeter: Uses altitude from barometric sensor; pressure not available in parser
Max Frequency: period=0,count=0 except Accel connect(0.1f, 0); sender Accel throttle 100ms.
RTC: Kernel sys.getTimeMs()/1000 (seconds since boot), not SDK::Sensor::RTC.
Build with
Sensors-CMake/CMakeLists.txt(TouchGFX GUI) orSensorsLVGL-CMake/CMakeLists.txt(LVGL GUI); the two appear in the launcher as Sensors and SensorsLVGL.
Running on Simulator
TouchGFX (Windows only):
Build the app following the SDK setup instructions.
Open
Sensors.touchgfxin TouchGFX Designer and click Generate Code (F4) (do this once).Navigate to
Sensors\Software\Apps\TouchGFX-GUI\simulator\msvsOpen
Application.vcxprojin Visual StudioPress F5 to start debugging and run the simulator
LVGL (Windows and Linux): a CMake project in Software/Apps/LVGL-GUI/simulator, built the same way as HelloWorld’s (see that tutorial); the executable is SensorsLVGLSimulator. Which sensors the mock kernel simulates, and how, is set in simulator/ConfigurationSimulator.hpp.
The simulator provides simulated sensor data for all implemented sensors. Use L1/L2 buttons to cycle through verbosity levels and view different sensor data displays.
For detailed sensor simulation configuration and features, see Simulator.