Skip to content
CARP Docs
Ask AISystemLightDark
On this page

Sensor data

Stream heart rate, R-R intervals, ECG, IMU and temperature data as typed Dart objects.

Copy MarkdownOpen inOpen in GitHubView as MarkdownOpen in Scira AIOpen in ChatGPTOpen in ClaudeOpen in Cursor

Each typed stream of a connected MovesenseDevice subscribes to one resource of the sensor through the Movesense mobile library (MDS). It turns the notifications of that resource into Dart objects:

StreamData classMDS resourceRate
hrMovesenseHR/Meas/HRAfter each heartbeat
ecgMovesenseECG/Meas/ECG/125125 Hz
imuMovesenseIMU/Meas/IMU9/1313 Hz
temperatureMovesenseTemperature/Meas/TempOn change

The streams work like this:

  • Each read of hr, ecg, imu or temperature returns a new single-subscription stream. Listening to it opens one MDS subscription, and canceling your StreamSubscription closes it.
  • The plugin checks the connection when you read the property, not when you listen. A stream read before the sensor is connected emits a StateError and closes.
  • When MDS rejects the subscription, the stream emits an MdsError with the status code from MDS. It then emits nothing more but does not close, so cancel your StreamSubscription.
  • When MDS reports an error on a running subscription (Android only), the stream emits an MdsError and goes on with the next notification.
  • A notification that is not valid JSON or not a JSON object arrives as a FormatException, and the stream goes on with the next notification. An empty notification arrives as a reading with zero values and empty lists.

The samples on this page use a connected MovesenseDevice named device. To connect one, see Connect to a sensor. For taps and other state events, see Subscribe to state events.

Stream heart rate

hr emits a MovesenseHR for each notification from /Meas/HR, with the heart rate that the sensor computes from its ECG signal:

PropertyTypeDescription
averagedoubleAverage heart rate in beats per minute, or 0.0 when the sensor has no valid heart rate.
rrDataList<int>The R-R intervals since the previous notification, in milliseconds. It may be empty.
rrint?The latest R-R interval in milliseconds: the last item of rrData, or null when rrData is empty.

An R-R interval is the time between two consecutive heartbeats, measured between the R peaks of the ECG. One notification may hold several, so use rrData, not rr, when you need every interval:

device.hr.listen((hr) {
  print('Heart rate: ${hr.average.toStringAsFixed(1)} bpm');
  for (final interval in hr.rrData) {
    print('R-R interval: $interval ms');
  }
});

Stream ECG

ecg emits a MovesenseECG for each notification from /Meas/ECG/125, with a batch of single-channel ECG samples at 125 Hz:

PropertyTypeDescription
timestampintThe sensor's timestamp of the batch, in milliseconds. See Timestamps.
samplesList<int>The raw ECG samples, not scaled.

The Movesense API reference defines one unit of a raw sample as 0.38147 µV. To get microvolts, multiply each sample by this value:

const microvoltsPerUnit = 0.38147;

device.ecg.listen((ecg) {
  final microvolts = [for (final sample in ecg.samples) sample * microvoltsPerUnit];
  print('${ecg.timestamp}: $microvolts');
});

/Meas/ECG/Info reports the number of samples in a notification as ArraySize. See Use other sample rates for a request that reads this resource.

Stream IMU data

imu emits a MovesenseIMU for each notification from /Meas/IMU9/13. Each one holds a batch of synchronized samples from the three sensors of the inertial measurement unit (IMU), at 13 Hz, the lowest IMU rate:

PropertyTypeDescription
timestampintThe sensor's timestamp of the first sample in the batch, in milliseconds. The other samples follow at the sample rate.
accelerometerList<MovesenseAccelerometerSample>Acceleration in m/s², including gravity.
gyroscopeList<MovesenseGyroscopeSample>Angular velocity in degrees per second.
magnetometerList<MovesenseMagnetometerSample>Magnetic field in microtesla (µT).

Each sample has the fields x, y and z, of type double. Because the accelerometer includes gravity, the acceleration of a sensor at rest has a magnitude of about 9.8 m/s²:

import 'dart:math';

device.imu.listen((imu) {
  for (final sample in imu.accelerometer) {
    final magnitude = sqrt(sample.x * sample.x + sample.y * sample.y + sample.z * sample.z);
    print('Acceleration: ${magnitude.toStringAsFixed(2)} m/s²');
  }
});

Stream temperature

temperature emits a MovesenseTemperature for each notification from /Meas/Temp, which measures the temperature inside the sensor. The sensor sends a notification when the temperature changes:

PropertyTypeDescription
timestampintThe sensor's timestamp of the reading, in milliseconds.
measurementdoubleTemperature in kelvin.
celsiusdoubleTemperature in degrees Celsius, computed from measurement.

The plugin documents its temperature stream for the Movesense MD only. Movesense lists a temperature sensor for the Movesense Active HR+ as well, and none for the Movesense HR2. To listen only on a Movesense MD, check deviceType first:

if (device.deviceType == MovesenseDeviceType.md) {
  device.temperature.listen((temperature) {
    print('Temperature: ${temperature.celsius.toStringAsFixed(1)} °C');
  });
}

deviceType comes from the device information, and it is unknown when the plugin fails to read that information. See Identify the model.

To read the temperature once, send a GET request to /Meas/Temp. Pass the Content object of the response to MovesenseTemperature.fromMovesenseData(), not the whole response, which it reads as 0 K:

import 'dart:convert';

final response = await device.request(MdsRequestMethod.get, '/Meas/Temp');
final content = jsonDecode(response.body)['Content'] as Map<String, dynamic>;
final temperature = MovesenseTemperature.fromMovesenseData(content);

Use other sample rates

The typed streams use fixed sample rates. For another rate, subscribe to the resource with device.subscribe(), and parse each notification with MovesenseECG.fromMovesenseData() or MovesenseIMU.fromMovesenseData(). This example streams ECG at 256 Hz:

import 'dart:convert';

// ECG at 256 Hz, parsed with the typed model (jsonDecode is from dart:convert).
device
    .subscribe('/Meas/ECG/256')
    .map((body) => MovesenseECG.fromMovesenseData(jsonDecode(body)))
    .listen(print);

For IMU data, subscribe to /Meas/IMU9/<sample_rate>, and parse with MovesenseIMU.fromMovesenseData() in the same way. For the raw stream that device.subscribe() returns, see Subscribe to a resource.

The sensor lists the rates it supports in two info resources. The example responses in the Movesense API reference hold these rates:

ResourceFieldExample rates
/Meas/ECG/InfoAvailableSampleRates125, 128, 200, 250, 256, 500 and 512 Hz
/Meas/IMU/InfoSampleRates13, 26, 52, 104, 208, 416, 833 and 1666 Hz

Read the rates of your sensor with GET requests:

import 'dart:convert';

final ecgInfo = await device.request(MdsRequestMethod.get, '/Meas/ECG/Info');
print(jsonDecode(ecgInfo.body)['Content']['AvailableSampleRates']);

final imuInfo = await device.request(MdsRequestMethod.get, '/Meas/IMU/Info');
print(jsonDecode(imuInfo.body)['Content']['SampleRates']);

Higher rates send more data over Bluetooth Low Energy (BLE), and the BLE bandwidth may limit the highest rates.

ECG above 256 Hz

Over BLE, ECG rates above 256 Hz may overflow the small sample buffer of the sensor's ECG circuit, so samples may be lost. Use 256 Hz or lower, as the Movesense API reference advises.

The sensor does not allow ECG subscriptions at different rates at the same time. While another ECG rate is subscribed, do not listen to ecg. Several subscriptions at the same rate are allowed.

Movesense device library 2.3 added /Meas/ECG/<sample_rate>/mV, which reports the samples in millivolts as decimal numbers. MovesenseECG.fromMovesenseData() reads samples as integers and cuts off the decimals, so it does not fit this resource: decode its notifications yourself.

Stop a stream

listen() returns a StreamSubscription. Cancel it to stop the data and close the MDS subscription:

final subscription = device.hr.listen((hr) {
  print('Heart rate: ${hr.average}, R-R: ${hr.rr} ms');
});

// Stop streaming.
await subscription.cancel();

A subscribed stream stays open until you cancel it, even after the sensor disconnects. An await for loop over it does not end by itself.

To stream across reconnections, listen to statusEvents before you call connect(). Then listen to a new stream each time the status becomes connected, and cancel it when the status changes:

import 'dart:async';

StreamSubscription<MovesenseHR>? subscription;

device.statusEvents.listen((status) {
  if (status == MovesenseDeviceStatus.connected) {
    subscription ??= device.hr.listen((hr) => print(hr.average));
  } else {
    subscription?.cancel();
    subscription = null;
  }
});

For how the sensor reconnects on each platform, see Handle a lost connection.

Timestamps

MovesenseECG, MovesenseIMU and MovesenseTemperature extend MovesenseData. Its timestamp comes from the sensor's own clock: the milliseconds since the sensor started or was reset. This clock does not give the time of day, and MovesenseHR has no timestamp.

To place data in time, pair the sensor clock with the phone's clock. This example takes the phone's time when the first ECG batch arrives, and places later batches by their sensor timestamps:

DateTime? firstArrival;
int? firstTimestamp;

device.ecg.listen((ecg) {
  // Pair the first batch with the phone's clock
  firstArrival ??= DateTime.now();
  firstTimestamp ??= ecg.timestamp;

  final batchTime = firstArrival!.add(
    Duration(milliseconds: ecg.timestamp - firstTimestamp!),
  );
  print('$batchTime: ${ecg.samples.length} samples');
});

The arrival time of the first batch includes the Bluetooth delay, so all times are approximate. The intervals between them come from the sensor clock. For MovesenseHR, record DateTime.now() when each reading arrives.

The sensor can also map its clock to Coordinated Universal Time (UTC). A GET request to /Time/Detailed returns utcTime and relativeTime for the same moment. utcTime is the sensor's UTC time in microseconds since 1970, and relativeTime is the sensor clock in milliseconds.

utcTime is only as correct as the sensor's UTC clock. Until a PUT request to /Time sets that clock, it counts from 2015-01-01 or an earlier date, not from the real time. For details, see the Movesense API reference.

Quick links

Page · DocumentationCARP DocumentationPage · DocumentationIntroductionPage · DocumentationQuickstartPage · DocumentationTerminologyPage · DocumentationArchitecture