A Flutter plugin for Movesense sensors (Movesense MD, Active HR+ and HR2), made by the Copenhagen Research Platform (CARP) at the Technical University of Denmark (DTU).
The plugin bundles the native Movesense MDS libraries for Android and iOS, so there is no native SDK to set up. On top of raw access to the full Movesense API, it provides a strongly typed device API for the parts you use most: connection status, device info, battery and sensor streams.
Contents: Features · Platforms · Installation · Quick start · Usage · Upgrading · Example app · Contributing · Credits · License
- Scanning: find Movesense devices, with start and stop
- Connection management: connect and disconnect, with a connection status per device
- Device status: device info, battery level and system states
- Typed sensor streams: heart rate and R-R intervals, ECG, 9-axis IMU, temperature, and state events (movement, connectors, tap, double tap, free fall)
- Raw MDS access:
GET,PUT,POSTandDELETErequests and subscriptions to any resource mdsfluttercompatibility:MdsandMdsAsyncclasses for code written against mdsflutter- No native setup: the Movesense
mdslibAAR (Android) andMovesenseMDS.xcframework(iOS) ship with the plugin
| Platform | Supported | Minimum version | Notes |
|---|---|---|---|
| Android | ✅ | API 24 (Android 7.0) | Use a physical device. The Movesense library has no x86_64 build. |
| iOS | ✅ | iOS 15.0 | Use a physical device. Bluetooth is not available in the Simulator. |
| macOS | ❌ | The plugin registers, but scanning, connecting, requests and subscriptions fail with an "unsupported" error. |
Sensors: Movesense MD, Movesense Active HR+ and Movesense HR2. They are reported as MovesenseDeviceType.md, hrPlus and hr2.
flutter pub add movesense_plusThe plugin requires Dart 3.12 or later.
The plugin's manifest declares the Bluetooth and location permissions, and they are merged into your app:
BLUETOOTH_SCANandBLUETOOTH_CONNECTon Android 12 and later.BLUETOOTH,BLUETOOTH_ADMINandACCESS_FINE_LOCATIONon Android 11 and lower.
You still have to request BLUETOOTH_SCAN, BLUETOOTH_CONNECT and, on Android 11 and lower, ACCESS_FINE_LOCATION at runtime. See Runtime permissions.
The Movesense library ships native code for arm64-v8a, armeabi-v7a and 32-bit x86, but not for x86_64. To keep x86_64 out of your build, restrict the ABIs in defaultConfig as the example app does:
ndk {
abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a"))
}The plugin builds with both CocoaPods and Swift Package Manager. Add a Bluetooth usage description to your app's Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to connect to Movesense sensors.</string>The plugin sets up Bluetooth when the app starts, so iOS asks for Bluetooth access at launch. To keep streaming in the background, add bluetooth-central to UIBackgroundModes.
The plugin does not request runtime permissions itself:
- Android: use permission_handler to request the Bluetooth scan and connect permissions before scanning. On Android 11 and lower, also request the location permission (
Permission.locationWhenInUse). The example app requests the scan and connect permissions inmovesense_permission_service.dart, but not location. - iOS: the system shows the Bluetooth prompt itself.
Once the runtime permissions are granted, this finds the first sensor, connects to it and prints its heart rate:
import 'package:movesense_plus/movesense_plus.dart';
Future<void> streamHeartRate() async {
final movesense = Movesense();
// 1. Scan, and take the first Movesense device found.
final found = movesense.scanResults.first;
await movesense.startScan();
final device = await found;
await movesense.stopScan();
// 2. Connect, and wait until the device is ready.
final connected = device.statusEvents
.firstWhere((status) => status == MovesenseDeviceStatus.connected);
await device.connect();
await connected;
// 3. Stream heart rate.
device.hr.listen((hr) => print('Heart rate: ${hr.average} bpm'));
}For brevity, the quick start leaves out error handling. In an app, also consider these cases:
- If no sensor is found, or the device never connects, it waits forever. On failure,
statusEventsemitsMovesenseDeviceStatus.error. Handle that, and add a timeout. - On iOS,
scanResultsemits anMdsErrorif Bluetooth is not powered on yet. Scanning then starts by itself once it is.
Import package:movesense_plus/movesense_plus.dart. There are two entry points:
Movesensehandles scanning and raw MDS access.MovesenseDeviceis the typed API for one sensor.
final movesense = Movesense();
movesense.scanResults.listen((device) {
print('Discovered ${device.name} [${device.address}]');
});
await movesense.startScan();
// Stop scanning at some later point.
await movesense.stopScan();The same device is reported again for every advertisement it sends, so deduplicate by address.
A MovesenseDevice comes from the scan, or you can create one directly when you know the address:
final device = MovesenseDevice(address: '0C:8C:DC:1B:23:BF');
device.statusEvents.listen((status) {
print('Connection status: ${status.name}');
});
await device.connect();Note
The address format is platform dependent:
- On Android, it is the Bluetooth MAC address, e.g.
0C:8C:DC:1B:23:BF. - On iOS, it is the UUID of the peripheral, e.g.
89BD8BF8-C9E7-0395-3B1D-119082516DDE.
connect() completes as soon as the platform accepts the connect command, not when the device is connected. When the device connects, the plugin first fetches the device info and battery level. It then sets status to MovesenseDeviceStatus.connected, so isConnected becomes true, and emits the new status on statusEvents.
Call disconnect() to end the connection. When you no longer need the device object, call dispose(). To follow connection events for all devices at once, listen to movesense.connectionEvents.
Once connected, these are available:
| API | Returns |
|---|---|
deviceInfo / getDeviceInfo() |
The full device info as a MovesenseDeviceInfo |
deviceType |
The hardware variant (md, hrPlus, hr2), derived from the device info |
batteryLevel / getBatteryStatus() |
The battery level, which Movesense reports as ok or low |
getState(MovesenseSystemState) |
The current system state of a component (movement, connectors, tap, double tap, free fall) |
print('Product: ${device.deviceInfo?.productName}, serial: ${device.serial}');
print('Type: ${device.deviceType.name}, battery: ${device.batteryLevel.name}');
final connectors = await device.getState(MovesenseSystemState.connectors);
print('Strap: ${connectors?.state.name}');A connected device has the following streams:
| Stream | Data | MDS resource | Rate |
|---|---|---|---|
hr |
MovesenseHR: average heart rate (bpm) and R-R intervals (ms) |
/Meas/HR |
1 Hz |
ecg |
MovesenseECG: raw ECG samples |
/Meas/ECG/125 |
125 Hz |
imu |
MovesenseIMU: synchronized accelerometer (m/s²), gyroscope (deg/s) and magnetometer (µT) samples |
/Meas/IMU9/13 |
13 Hz |
temperature |
MovesenseTemperature: Kelvin, with a celsius getter. Movesense MD only. |
/Meas/Temp |
final subscription = device.hr.listen((hr) {
print('Heart rate: ${hr.average}, R-R: ${hr.rr} ms');
});
// Stop streaming.
await subscription.cancel();How the streams behave:
- Each stream is single-subscription. Every read of
hr,ecgand so on returns a new stream. That stream opens its own MDS subscription when you listen, and closes it when you cancel. - The connection is checked when you read the stream, not when you listen to it. A stream read before the device is connected emits a
StateError. - For other sample rates, use raw MDS access together with
MovesenseECG.fromMovesenseDataorMovesenseIMU.fromMovesenseData.
device.getStateEvents(MovesenseSystemState.tap).listen((state) {
print('State: ${state.state.name} at ${state.timestamp}');
});Note
The Movesense firmware only allows one state subscription at a time, and not every component is supported on every device. Only the connectors and tap states have been confirmed on the Movesense MD and HR2.
You can reach any other Movesense resource with REST-style requests and subscriptions. On a connected device, the serial is filled in for you:
// Turn the LED on.
await device.request(MdsRequestMethod.put, '/Component/Led', contract: '{"isOn": true}');
// Accelerometer at 52 Hz, as raw JSON notification bodies.
device.subscribe('/Meas/Acc/52').listen(print);
// 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);Without a MovesenseDevice, use the Movesense client with full URIs:
final response = await movesense.request(MdsRequestMethod.get, Mds.createRequestUri(serial, '/Info'));
print(response.body);
movesense.subscribe(Mds.createSubscriptionUri(serial, '/Meas/HR')).listen(print);How errors are reported:
- A failed request fails with an
MdsErrorthat carries the MDS status code. The code is0for local failures. - A failed subscription emits an
MdsErroron its stream. device.request()throws aStateErrorwhen the device is not connected.
The static Mds and MdsAsync classes mirror the mdsflutter API (Mds.connect, Mds.get, Mds.subscribe, MdsAsync.subscribe and so on). Existing code can switch to this plugin by changing the import.
Version 2.0.0 is the Pigeon-based plugin that was previously published as carp_movesense_flutter. It replaces the mdsflutter-based 1.x releases of movesense_plus.
Remove the manual Movesense SDK setup from your app:
- iOS: the
pod 'Movesense', :git => ...line in yourPodfile. If you addeduse_modular_headers!oruse_frameworks! :linkage => :staticonly for Movesense, remove those too. - Android: the vendored
android/libs/mdslib-*.aarand itsflatDirrepository.
The API is new. The most common changes:
| 1.x | 2.0.0 |
|---|---|
Movesense().scan(), Movesense().devices |
Movesense().startScan(), Movesense().scanResults |
MovesenseDevice({address, serial, name, deviceType}), all optional |
MovesenseDevice({required address, name}); serial, status and deviceType are read-only |
connect() and disconnect() return void |
They return Future<void>; follow statusEvents for the outcome |
canConnect() |
Removed |
hr, ecg, imu, ... are broadcast streams, empty when not connected |
Single-subscription streams that emit a StateError when not connected |
DeviceConnectionStatus |
MovesenseDeviceStatus |
DeviceBatteryLevel |
MovesenseBatteryLevel |
SystemStateComponent |
MovesenseSystemState |
MovesenseDeviceType.MD, HR_PLUS, HR2, FLASH |
MovesenseDeviceType.md, hrPlus, hr2, flash |
MovesenseHR.average as int |
double, plus the full rrData list |
MovesenseTemperature.measurement as int |
double, plus a celsius getter |
MovesenseState extends MovesenseData, with an int timestamp |
A standalone class with an int? timestamp (null for getState() reads), plus component and value |
MovesenseDeviceInfo.fromMovesenseData(String), fromMap, toMap, toJsonString (and fromMap/toMap on MovesenseAddressInfo) |
fromMovesenseData(Map), fromJson, toJson |
| Positional data model constructors | Named constructors. The IMU sample classes keep positional (x, y, z) constructors, with double instead of num values. |
2.0.0 also fixes the IMU gyroscope and magnetometer samples, which 1.x read from the accelerometer array, so IMU data differs from 1.x.
Rename CarpMovesenseFlutter to Movesense, and change the imports to package:movesense_plus/movesense_plus.dart.
In 0.1.x, MovesenseDevice was the plain scan result class. That class is now MovesenseScanResult. scanResults now emits the typed MovesenseDevice, which still has name and address but no const constructor.
If you have a custom platform implementation:
- Import
package:movesense_plus/movesense_plus_platform_interface.dart. CarpMovesenseFlutterPlatformis nowMovesensePlatform, andPigeonCarpMovesenseFlutteris nowPigeonMovesense.MethodChannelCarpMovesenseFlutterhas been removed.- The platform's
scanResultsnow emitsMovesenseScanResult.
The example/ folder contains a tester app for the Movesense client, with screens for scanning, connecting, sending MDS requests, subscribing to resources and viewing the log. To run it, call flutter run from example/ with a physical Android or iOS device connected.
| Scan | Device | Requests | Subscriptions | Logs |
|---|---|---|---|---|
Please file feature requests and bug reports as issues. Pull requests are welcome.
| Path | Contents |
|---|---|
lib/movesense_plus.dart |
The public library, which exports the lib/src/ files below (not internal/) |
lib/src/movesense_client.dart |
Movesense, the public client over the platform interface |
lib/src/movesense_device.dart |
MovesenseDevice, the typed device API |
lib/src/movesense_data.dart |
The typed data models (MovesenseHR, MovesenseECG, MovesenseIMU, ...) |
lib/src/movesense_models.dart |
MdsRequestMethod, MovesenseScanResult, connection events, MdsResponse and MdsError |
lib/src/mds_compat.dart |
The mdsflutter-compatible Mds and MdsAsync facades |
lib/src/internal/ |
The Pigeon event router and JSON helpers |
lib/movesense_plus_platform_interface.dart, lib/movesense_plus_pigeon.dart |
The platform interface and its Pigeon-backed implementation. Pigeon DTOs never leak into the public API. |
The Dart/native transport is generated with Pigeon. The contracts live in pigeons/ and are split by responsibility: movesense_scan_*, movesense_connection_*, movesense_request_* and movesense_subscription_*. After editing a contract, regenerate the bindings:
tool/generate_pigeon.shThe generated code is written to these gen/ folders:
lib/src/gen/pigeon/android/src/main/kotlin/dk/carp/movesense_plus/gen/ios/movesense_plus/Sources/movesense_plus/gen/macos/movesense_plus/Sources/movesense_plus/gen/
The original mdsflutter callback message schema is kept in protos/movesense_mds.proto for reference. At runtime the plugin uses Pigeon DTOs instead of protobuf byte arrays.
The compiled SDKs come from the movesense-mobile-lib repository and are vendored in this repository:
- Android uses the Movesense
mdslibAAR underandroid/vendor. - iOS uses
MovesenseMDS.xcframeworkunderios/movesense_plus/Vendor. You can rebuild it from the Movesense SDK withtool/generate_movesense_mds_xcframework.sh. Pass--sign <identity>to code-sign the framework (optional), or run the script with--helpto see all options. - macOS registers the same Pigeon channels but returns "unsupported" errors, because there is no macOS Movesense MDS binary to bundle.
The native MDS bridging is based on mdsflutter and the improved fork at petri-lipponen-movesense/mdsflutter.
This software is copyright (c) the Copenhagen Research Platform at the Technical University of Denmark and is available 'as-is' under a MIT license.