Interface control document
1. Identification
| Item | Value |
|---|---|
| Document | ICD-CMBLEHR-001 rev 1 |
| Product | Bluetooth wearable module (HEAT.BluetoothWearableModule in the module registry, module id BleWearable) |
| Product versions covered | 79b0738 |
| Peer | Any Bluetooth Low Energy device exposing the Bluetooth SIG Heart Rate Service |
| Host | HEAT Capture, HEAT.Capture.ModuleSDK 2.0.5 |
| Data model | SimulationFrame carrying one Entity with a BiometricSensorComponent |
| Settings section | HEAT.BluetoothWearableModule |
| Date | 2026-09-02 |
| Owner | VRAI Simulation |
2. Scope
This document defines how the module finds and selects a BLE device, which GATT objects it reads, how it decodes the Heart Rate Measurement characteristic, and what it publishes to HEAT Capture.
The module is receive-only with respect to measurement data. The only thing it writes to the device is the Client Characteristic Configuration Descriptor that enables notifications. It defines no remote control protocol for the wearable and exposes no network endpoint.
The interface is the public Bluetooth SIG Heart Rate Service, not a VRAI-defined protocol. The authority for field meaning is the Bluetooth SIG Heart Rate Service and Heart Rate Measurement characteristic specifications. What this document fixes is which parts of that specification the module actually implements, and how it maps them onto HEAT Capture readings.
3. Interface inventory
| ID | Name | Direction | Transport | Peer |
|---|---|---|---|---|
| IF-CMBLEHR-01 | Advertisement scan and device selection | Device to module | BLE advertising, active scan | Configured wearable |
| IF-CMBLEHR-02 | GATT discovery and notification subscription | Module to device | BLE GATT over Windows BluetoothLEDevice | Configured wearable |
| IF-CMBLEHR-03 | Heart Rate Measurement notification stream | Device to module | BLE GATT notifications | Configured wearable |
| IF-CMBLEHR-04 | Frame publication | Module to host | Module SDK publication path | HEAT Capture |
| IF-CMBLEHR-05 | Settings-time device list | Module to host | Settings UI async value source | HEAT Capture |
4. IF-CMBLEHR-01 Advertisement scan and device selection
4.1 Transport and addressing
| Item | Value |
|---|---|
| Mechanism | BluetoothLEAdvertisementWatcher with ScanningMode = BluetoothLEScanningMode.Active |
| Address form | Six bytes, uppercase hex, colon separated, most significant byte first, for example AA:BB:CC:DD:EE:FF |
| Selection rule | An advertisement is accepted when its formatted address is exactly equal to the DeviceID setting |
| Service filter | None. The scan does not require the advertisement to list the Heart Rate Service |
The comparison is an ordinal string equality against the uppercase form the module produces itself.
A DeviceID written by hand in lowercase, without separators, or in reverse byte order never
matches, and the scan therefore never completes. An empty DeviceID is not a wildcard: it matches
nothing, so there is no “first available device” behaviour.
4.2 Lifecycle and timing
The watcher is created and started in InitialiseModule, which returns immediately after logging
Scanning for Device.... Scanning therefore runs while the module is still starting.
StartCapture awaits the first matching advertisement. When one arrives the module logs
Found device: LOCAL_NAME, Address: HEX_ADDRESS, stops the watcher, records the device name (falling
back to the formatted address when the advertisement carries no LocalName), and proceeds to
IF-CMBLEHR-02.
The whole sequence of scan, connect, discover and subscribe is bounded by the module’s own startup
timeout, which is raised to 30 seconds from the SDK default of 5. If no matching advertisement
arrives within that window, the SDK logs a warning naming StartCapture and the 30-second timeout,
and the module does not start.
4.3 Authentication
None at this interface. Selection is by advertised address only, and a BLE advertising address is not an authenticator. See section 13.
5. IF-CMBLEHR-02 GATT discovery and notification subscription
5.1 GATT objects used
| Role | UUID | Access performed |
|---|---|---|
| Heart Rate service | 0000180D-0000-1000-8000-00805F9B34FB | Looked up with BluetoothCacheMode.Uncached; the first service in the result is used |
| Heart Rate Measurement characteristic | 00002A37-0000-1000-8000-00805F9B34FB | Looked up with the default cache mode; the first characteristic in the result is used |
| That characteristic’s CCCD | Written implicitly | Set to Notify |
No other service or characteristic is touched at capture time. The module does not read Body Sensor
Location (0x2A38), does not use the Heart Rate Control Point (0x2A39), and does not read Battery
Service or Device Information.
5.2 Connection lifecycle
- Any previously held device object is disposed.
BluetoothLEDevice.FromBluetoothAddressAsyncis called with the matched address.- The pairing state is logged:
Device is paired; connecting.orDevice is not paired; attempting GATT access without pairing.The module does not initiate pairing in either case. - If the connection status is not already
Connected, the module waits a fixed 1000 ms and then logsDevice Connected. Getting GATT Services...unconditionally. That log line is emitted whether or not the link actually came up, so it is not evidence of a connection. - The Heart Rate service and Heart Rate Measurement characteristic are discovered.
- The notification handler is attached and the CCCD is written to
Notify. - On success the module reports the connection state
ConnectedandStartCapturereturns true.
Capture stop detaches the notification handler, disposes the device, cancels the pending scan
result, stops the watcher, and sets the reported state to Stopped. The CCCD is not written back
to None; notifications end because the device object is disposed and the link is released.
5.3 Reconnect behaviour
There is no reconnect. The module subscribes to no connection-status change event, and the connect
routine is only ever called from StartCapture. If the wearable moves out of range, powers off, or
drops the link, notifications stop: nothing is logged, no event is published, and the reported
connection state stays Connected.
Recovery requires stopping and restarting the capture session. That works because the SDK clears its
initialised flag on stop, so the next start re-runs InitialiseModule and begins a fresh scan.
5.4 Startup timeout and orphaned connections
The SDK’s startup timeout races the start operation but does not cancel it, and the module’s connect routine accepts no cancellation token. After a 30-second startup timeout the host is told the module did not start, while the scan and connect sequence continues running. If the configured device appears later, that orphaned sequence can still complete the subscription and, in native-rate mode, start publishing frames, because the SDK publication path has no running-state guard. This is a property of this revision and is not one of the existing audit findings.
5.5 Authentication and pairing
The module never requests pairing. It attempts GATT access on an unpaired link and reports what
happens. A device that gates its Heart Rate service behind bonding returns AccessDenied, which the
module logs as a pairing hint and treats as a start failure. Pairing such a device is an operator
action in Windows Bluetooth settings, taken before the capture session.
6. IF-CMBLEHR-03 Heart Rate Measurement notification stream
6.1 Wire format
Each notification is a Heart Rate Measurement value. The module reads it with a WinRT DataReader
over the characteristic buffer, in the order below. It does not set DataReader.ByteOrder, so
multi-byte fields are decoded using the WinRT default.
| Order | Field | Present when | Read as |
|---|---|---|---|
| 1 | Flags | Always | uint8 |
| 2 | Heart Rate Measurement | Always | uint8, or uint16 when flags bit 0 is set |
| 3 | Energy Expended | Flags bit 3 set | uint16, kilojoules |
| 4 | R-R Interval | Flags bit 4 set and at least 2 bytes remain | uint16, units of 1/1024 s |
Flags handling:
| Bit | Specification meaning | Module handling |
|---|---|---|
| 0 | Value format, 0 = uint8, 1 = uint16 | Honoured; selects the read width |
| 1 | Sensor contact detected | Not read |
| 2 | Sensor contact supported | Not read |
| 3 | Energy Expended present | Honoured; gates the uint16 read |
| 4 | R-R Interval present | Honoured; gates the uint16 read |
| 5 to 7 | Reserved | Not read |
Because bits 1 and 2 add no bytes, ignoring them does not misalign the remaining fields. It does mean the module cannot report whether the strap is in contact with skin, so a device that keeps notifying while off-body produces readings that look valid.
The R-R field may carry several intervals in one notification. The module reads exactly one and leaves the rest in the buffer, so batched intervals after the first are lost.
6.2 Unit conversion
| Source field | Conversion applied | Published as |
|---|---|---|
| Heart Rate Measurement | None | HeartRate |
| Energy Expended, kilojoules | Divided by 4.184 | kcal |
| R-R Interval, 1/1024 s | Multiplied by 1000 and divided by 1024 | RR |
Each decoded notification overwrites the module’s cached value for that reading and stamps a last-reading time used only by the host’s debug view.
6.3 Cadence
The notification rate is device-defined; the module neither requests nor enforces one. Most straps notify about once per second. The module does not detect or report a missed notification.
7. IF-CMBLEHR-04 Frame publication and data model reference
7.1 Frame contents
| Item | Value |
|---|---|
| Frame | SimulationFrame with one EntityState |
| Entity name | Not set; the entity carries an empty name |
| Entity id | A 31-bit value computed once when the module instance is constructed, from the hash of a freshly generated GUID masked with 0x7FFFFFFF |
| Component | BiometricSensorComponent |
| Readings | HeartRate, RR, kcal, always all three, in that order |
| Frame capture time | UTC Unix milliseconds, taken when the frame is built |
| Packet timestamp | UTC Unix milliseconds, taken again by the SDK when the packet is wrapped |
| Node id | NodeId when OverrideNodeId is set, otherwise -1 |
Reading values are protobuf floats. HeartRate is decoded as an integer and widened on publication.
No EntityEvent is ever published by this module.
The entity id is not derived from the device address and is not stable across host processes, so a consumer cannot use it to correlate the same wearable between capture sessions. With no entity name set, that unstable number is the entity’s only identifier. This is a property of this revision and is not one of the existing audit findings.
7.2 Native rate versus polled
SendAtNativeRate selects which of two mutually exclusive publication paths is live.
SendAtNativeRate | Publication trigger | Per-cycle capture call | Effect |
|---|---|---|---|
true (default) | Each decoded notification publishes a frame immediately, from the GATT callback thread | Returns nothing every cycle | Frame rate follows the device; Interval only controls how often the idle loop ticks |
false | The per-cycle capture call publishes the cached values | Builds a frame every cycle | Frame rate is Interval; between notifications the same values republish unchanged |
In polled mode nothing marks a republished frame as unchanged, so a stalled device is indistinguishable from a steady heart rate.
7.3 Publication before the first notification
This is audit finding BLE-01, and it is a defect in the module rather than a device behaviour.
The three cached readings are instance fields with these initial values:
| Reading | Seed value |
|---|---|
HeartRate | 60 |
RR | 0 |
kcal | 0 |
StartCapture returns as soon as the CCCD write succeeds, which is before any notification has
arrived, and in polled mode a frame is built on every capture cycle unconditionally. A consumer
therefore cannot distinguish “no measurement yet” from a real resting heart rate of 60 bpm with a
zero R-R interval and zero energy expended, and those values are persisted as measurements.
Two further consequences follow from the same design:
- The caches are never reset on stop or on start. A second capture session in the same host process seeds from the last values of the previous session rather than from 60, 0 and 0.
- A device that never sets flags bit 3 or bit 4 publishes
RRandkcalas0for the entire session. An absent optional field is indistinguishable from a measured zero.
In native-rate mode the first published frame is triggered by a real notification, so HeartRate in
it is genuine, but RR and kcal in that frame are still the seed zeros unless the device included
them.
Until this is corrected, do not treat an early frame as evidence of a live sensor. Correlate with
the Device Connected. Getting GATT Services... and connection-state logging, or with the host debug
view’s receiving indicator, which reports true only while a notification has arrived within the last
5 seconds.
8. IF-CMBLEHR-05 Settings-time device list
The DeviceID setting is populated by an asynchronous value source that the host calls when the
settings UI needs the device list. It is not part of the capture path.
| Item | Value |
|---|---|
| Scan mode | BluetoothLEAdvertisementWatcher, active scanning |
| Scan duration | Fixed 5000 ms |
| Acceptance rule | The advertisement lists service UUID 0000180d-0000-1000-8000-00805f9b34fb, or it has a non-blank local name and lists no service UUIDs at all |
| Option value | The device address in AA:BB:CC:DD:EE:FF form |
| Option label | The resolved device name, or the address when no name resolves |
| Failure | Any exception is rethrown as InvalidOperationException with the message BLE device scan failed. Verify Bluetooth access and packaged WinRT dependencies. |
Name resolution order for each accepted address: the advertisement’s local name; otherwise connect
and use the device’s own name; otherwise read Device Name
(00002A00-0000-1000-8000-00805F9B34FB) from the Generic Access service, uncached; otherwise no
name.
The scan also enumerates Windows’ known BLE devices to build an address-to-name map, but that map is not consulted when labelling options, so a paired device that advertises without a local name still costs a GATT round trip to name. This is a property of this revision and is not one of the existing audit findings.
9. Error behaviour
| Condition | Log message | Reported connection state | Outcome |
|---|---|---|---|
No advertisement matches DeviceID | SDK warning naming StartCapture and the 30-second timeout | Scanning | Module does not start; see 5.4 |
| Device object could not be opened | Failed to open BLE device. | Disconnected: failed to open device | Start fails |
| Heart Rate service lookup denied | Access denied to Heart Rate service. Device may require pairing. | Disconnected: access denied | Start fails |
| Heart Rate service absent or other status | Heart Rate service not found. Status: {Status} | Disconnected: HR service not found | Start fails |
| Measurement characteristic lookup denied | Access denied to Heart Rate Measurement characteristic. Device may require pairing. | Disconnected: access denied | Start fails |
| Measurement characteristic absent | Heart Rate Measurement characteristic not found. Status: {Status} | Disconnected: HR characteristic not found | Start fails |
| CCCD write did not succeed | Failed to subscribe to Heart Rate notifications. Status: {Status} | Disconnected: subscribe failed | Start fails |
| Exception while decoding a notification | Failed to parse HR Data from BLE Wearable | Unchanged | Notification dropped; caches keep their previous values; nothing publishes for it in native-rate mode |
| Exception while building a polled frame | Failed to parse HR Data from BLE device | Unchanged | That cycle publishes nothing |
| Link lost while capturing | None | Stays Connected | Notifications stop silently; no reconnect; polled mode keeps republishing the last values |
| Capture stop | None | Stopped | Handler detached, device disposed, scan stopped |
An exception escaping the per-cycle capture call would be logged by the SDK as “Error in module” and the loop would continue, but the module catches its own exceptions before that point.
10. Units and conventions
| Concept | Convention |
|---|---|
| Heart rate | Beats per minute, as reported by the device, unvalidated and unrange-checked |
| R-R interval | Milliseconds, converted from the device’s 1/1024 s units |
| Energy expended | Kilocalories, converted from the characteristic’s kilojoule field. Per the Bluetooth specification this is cumulative since the device’s own reset, not a per-notification delta |
| Address text | Six bytes, uppercase hex, colon separated, most significant byte first |
| Time base | Host wall clock, UTC Unix milliseconds. The Heart Rate Measurement characteristic carries no device timestamp, so no device time is available |
| Coordinates | None. The module publishes no spatial data |
11. Configuration affecting the interfaces
Module settings live under HEAT.BluetoothWearableModule.
| Setting | Default | Interface effect |
|---|---|---|
DeviceID | empty string | The only address the advertisement filter accepts. Compared as an exact uppercase colon-separated string. Empty matches nothing |
SendAtNativeRate | true | Selects the publication path; see 7.2 |
Enabled | true | Whether the module runs at all |
Interval | 1000 ms | Poll cadence in polled mode. In native-rate mode the loop still ticks at this interval and publishes nothing |
OverrideNodeId, NodeId | false, 0 | Node id carried on the published packet |
Timeout | 30 s | Not used by this module |
The 30-second startup allowance, the 1000 ms post-connect wait, the 5000 ms settings-scan duration and both GATT UUIDs are compile-time constants, not settings.
See Manage capture modules for the generic settings and the SDK contract for the lifecycle these settings drive.
12. Compatibility and versioning
There is no protocol version negotiation. The module assumes a device that exposes the Bluetooth SIG Heart Rate Service and a Heart Rate Measurement characteristic whose flags follow the published bit assignments. Any conformant device works; no vendor-specific service is used.
The module targets net10.0-windows10.0.26100.0 and depends on HEAT.Capture.ModuleSDK 2.0.5 and
Microsoft.Windows.CsWinRT 2.2.0. It uses Windows’ own Bluetooth LE API, so it runs only on Windows
hosts with a BLE adapter and the projected WinRT dependencies present in the installed package.
12.1 Repository README discrepancies
The repository README disagrees with the code at this revision. Where they differ, the code is authoritative and this document follows the code.
- The README names the settings type
HRWearableSettings; the actual type isBluetoothWearableSettings. - The README describes
DeviceIDas a device name and states that leaving it empty connects to the first available device. Neither is implemented: the value is an address, and empty matches nothing. - The README’s testing and dependency notes require an ANT+ USB stick and ANT+ libraries in a
Process/directory. This module uses no ANT+ hardware and the repository contains no such directory.
13. Security considerations
The module attempts GATT access without pairing and never initiates pairing, so on an unpaired device the link is unencrypted and the heart rate stream is readable by any radio in range. Where a device supports bonding, pair it in Windows Bluetooth settings before capture so the link is encrypted; the module logs which of the two cases applies.
Device selection is by advertised address alone, with no service filter and no authentication. A radio that advertises the configured address would be connected to and subscribed. Treat the configured address as an identifier, not a credential.
The module exposes no network endpoint and holds no application credential. Heart rate, R-R interval and energy expenditure are personal health data: restrict access to the workstation and to captured sessions according to the deployment’s own data-handling policy.
14. Conformance
- Power on a BLE strap that advertises the Heart Rate Service. Open the module settings and confirm the Bluetooth device list offers it within the 5-second scan, labelled by name where the device provides one.
- Select the device so
DeviceIDholds the uppercase colon-separated address, then start a capture session. Confirm the log recordsScanning for Device..., thenFound device: NAME, Address: HEX_ADDRESS, then the pairing-state line, thenDevice Connected. Getting GATT Services..., and that the reported state reachesConnected. - Confirm one entity carrying a
BiometricSensorComponentwith exactly the readingsHeartRate,RRandkcalappears in the captured data. - Wear the strap and confirm
HeartRatechanges across successive frames. - Confirm the seed behaviour in 7.3. With
SendAtNativeRatefalse, capture a frame before the first notification and confirm it reportsHeartRate60,RR0andkcal0. Until BLE-01 is fixed, discard frames captured before the receiving indicator turns true. - Confirm optional-field behaviour for your device. If it never sets flags bit 3 or bit 4, confirm
kcalandRRremain0for the whole session and are not reported downstream as measurements. - Walk the strap out of range while capturing. Confirm notifications stop with no warning and no
reconnect, that the reported state still reads
Connected, and that in polled mode the last values continue republishing. Stop and restart the capture session and confirm the scan and connection sequence runs again. - Set
DeviceIDto the same address in lowercase and start a session. Confirm the module fails to start after the 30-second startup timeout, demonstrating that the address comparison is case sensitive.
15. Revision history
| Rev | Date | Product version | Change |
|---|---|---|---|
| 1 | 2026-09-02 | 79b0738 | Initial issue. |