movesense-driver
v0.1.2
Published
Node.js driver for Movesense BLE sensors (accelerometer, gyro, magnetometer, IMU, ECG, HR/RR, temperature) via the GATT SensorData Protocol.
Maintainers
Readme
movesense-driver
A small, dependency-light Node.js driver for Movesense BLE sensors. Talks the GATT SensorData Protocol (GSP) directly — no native bridges, no React Native shims — so you can stream accelerometer, gyro, magnetometer, IMU6/9, ECG, heart rate (with R-R intervals) and temperature into any Node app.
- Pure JavaScript, ships with TypeScript types.
- Single transport dependency:
@abandonware/noble(macOS / Linux / Windows BLE central). EventEmitterAPI per device.- Auto-reconnect with exponential backoff and automatic re-subscription of all active streams.
Requirements
| Platform | Setup |
|---|---|
| macOS | Grant Bluetooth permission to the terminal / Node process the first time you run. |
| Linux | sudo apt install libbluetooth-dev and either run as root or sudo setcap cap_net_raw+eip $(eval readlink -f $(which node)). |
| Windows | Use a recent build of @abandonware/noble with the WinRT backend. |
Node ≥ 18. Sensor must be flashed with gatt-sensordata-app firmware (or the default firmware ≥ 2.3, which exposes the GSP service).
Install
npm install movesense-driverQuick start
const { MovesenseScanner } = require('movesense-driver');
const scanner = new MovesenseScanner();
const device = await scanner.findOne({ timeoutMs: 30000 });
device.on('connect', () => console.log('connected'));
device.on('disconnect', () => console.log('disconnected'));
device.on('reconnect', () => console.log('reconnected & resubscribed'));
device.on('acc', ({ timestamp, samples }) => {
// samples: [{x, y, z}, ...] // mg
});
device.on('hr', ({ average, rrIntervals }) => {
// average: bpm rrIntervals: ms
});
await device.connect();
await device.subscribeAcc(52); // 13 / 26 / 52 / 104 / 208 / 416 / 833 / 1666 Hz
await device.subscribeHr();To target a specific sensor by serial number (the digits after “Movesense ” in the BLE name):
const device = await scanner.findOne({ serial: '210330000123' });What data the sensor provides
The Movesense exposes the following streams via the GATT SensorData Protocol. Subscribe to one with the matching helper, listen on the matching event, get sample batches at the chosen rate.
| Data | Subscribe call | Event | Sample shape | Units | Available rates (Hz) |
|---|---|---|---|---|---|
| Accelerometer | subscribeAcc(rate) | acc | {x, y, z} (float32) | mg | 13 · 26 · 52 · 104 · 208 · 416 · 833 · 1666 |
| Gyroscope | subscribeGyro(rate) | gyro | {x, y, z} | °/s | same as Acc |
| Magnetometer | subscribeMagn(rate) | magn | {x, y, z} | µT | same as Acc |
| IMU6 (acc + gyro, time-aligned) | subscribeImu6(rate) | imu6 | {acc:{x,y,z}, gyro:{x,y,z}} | mg + °/s | same as Acc |
| IMU9 (acc + gyro + magn) | subscribeImu9(rate) | imu9 | {acc, gyro, magn} | mg + °/s + µT | same as Acc |
| ECG | subscribeEcg(rate) | ecg | int32 raw counts | × 0.381 → µV | 125 · 128 · 200 · 250 · 256 · 500 · 512 |
| Heart rate | subscribeHr() | hr | {average, rrIntervals} | bpm + ms | event-driven (per beat) |
| R-R intervals | subscribeHr() | rr | number[] | ms | re-emitted from hr |
| Temperature | subscribeTemp() | temp | {kelvin, celsius} | K / °C | event-driven (~1 Hz) |
Every Meas packet (HR is the exception) includes a timestamp (uint32 ms since sensor boot) and arrives as a batch of N samples per BLE notification — N depends on the rate and the negotiated MTU. Higher rates (≥ 416 Hz motion, ≥ 250 Hz ECG) need a negotiated MTU ≥ 185.
Worked examples
// Motion — accelerometer at 52 Hz
await device.subscribeAcc(52);
device.on('acc', ({ timestamp, samples }) => {
for (const s of samples) {
console.log(timestamp, s.x, s.y, s.z); // mg
}
});
// Heart rate + R-R intervals (HRV)
await device.subscribeHr();
device.on('hr', ({ average, rrIntervals }) => {
console.log(`${average.toFixed(0)} bpm`, rrIntervals, 'ms');
});
device.on('rr', (rr) => myHrvBuffer.push(...rr)); // convenience event
// ECG at 250 Hz, converted to microvolts
await device.subscribeEcg(250);
device.on('ecg', ({ timestamp, samples }) => {
const microvolts = samples.map(s => s * 0.381);
myEcgBuffer.push({ t: timestamp, uV: microvolts });
});
// Combined IMU at 104 Hz
await device.subscribeImu9(104);
device.on('imu9', ({ samples }) => {
for (const s of samples) {
// s.acc.{x,y,z} s.gyro.{x,y,z} s.magn.{x,y,z}
}
});
// Temperature
await device.subscribeTemp();
device.on('temp', ({ celsius }) => console.log(`${celsius.toFixed(1)} °C`));Battery level
Movesense exposes battery via the standard BLE Battery Service (UUID 0x180F), which works on every firmware variant and returns the value instantly:
const percent = await device.getBatteryLevel(); // 0–100, immediate
console.log(`battery: ${percent}%`);
// Optional: live updates when the level changes
await device.subscribeBatteryLevel();
device.on('battery', ({ percent }) => console.log(`battery: ${percent}%`));The Whiteboard endpoints /System/Energy/Level and /System/Energy also exist but are unreliable across firmware builds (some gatt-sensordata-app variants strip them and return 400). Prefer getBatteryLevel().
One-shot reads (GET endpoints)
Beyond the /Meas/* streams and battery, the sensor exposes static and on-demand info you can fetch with device.get(path). The driver returns the raw SBEM payload bytes; you decode the fields you need.
| Path | Returns |
|---|---|
| /Info | manufacturer, product name, serial, sw/hw versions, API level, hw config |
| /System/Mode | current power / operation mode |
| /Time | UTC clock (µs) |
| /Component/Leds | LED control (also writeable) |
| /Mem/Logbook/Entries | list of on-device recorded sessions |
| /Misc/Gear/Id | paired-gear ID (HR-strap variants) |
const { status, payload } = await device.get('/Info');
// status === 200, payload is a Buffer of SBEM-encoded bytesThe full set of resources, parameters and SBEM schemas is documented in the Movesense API YAML specs.
Custom paths
For any resource the driver doesn't wrap, use the generic subscribe(path, parser, eventName):
const { parsers } = require('movesense-driver');
await device.subscribe('/Meas/IMU6/208', parsers.parseImu6, 'imu6_fast');
device.on('imu6_fast', (data) => { /* ... */ });API
class MovesenseScanner
const scanner = new MovesenseScanner(deviceOptions?);
await scanner.start();
scanner.on('discover', (device) => { /* MovesenseDevice */ });
await scanner.stop();
// or one-shot:
const device = await scanner.findOne({ serial?, timeoutMs? });deviceOptions are forwarded to every discovered MovesenseDevice.
class MovesenseDevice (extends EventEmitter)
Connection
| Method | Description |
|---|---|
| connect() | Opens the BLE connection, discovers the GSP service, enables notifications. |
| disconnect() | Unsubscribes everything, drops the connection, disables auto-reconnect for this instance. |
| isConnected() | Boolean. |
Properties: id, localName, serial.
Subscriptions
See What data the sensor provides for the full list of streams, payload shapes, units, and rates. Method defaults: subscribeAcc/Gyro/Magn/Imu6/Imu9 default to 52 Hz, subscribeEcg to 125 Hz; subscribeHr and subscribeTemp take no rate.
All subscribe methods return a Promise<Subscription> and start emitting events after the sensor ACKs the SUBSCRIBE (or sends the first DATA packet, whichever comes first).
unsubscribe(path) stops a stream. get(path) issues a one-shot GET and resolves with { status, payload } (raw SBEM bytes — caller decodes).
Events
| Event | When |
|---|---|
| connect | Connection up, characteristics ready. |
| disconnect | Connection lost (expected or not). |
| reconnecting | { attempt, delayMs } before each retry. |
| reconnect | Reconnect succeeded; all prior subscriptions are restored. |
| error | Non-fatal error (parse failure, timed-out command, MTU drop). |
| sensor events | See subscription table above. |
Error handling
The driver is designed to never crash the host app. Specifically:
- Every device and scanner has an internal guard that catches throws from
'error'listeners and the throws Node'sEventEmitterraises when an'error'event is emitted with no listener attached. - Every async path inside the driver (reconnect loop, notification handler, command writes, parser invocations) wraps its work and surfaces failures as an
'error'event rather than as an unhandled rejection. connect(),subscribeXxx(),get()anddisconnect()all reject their returned promises on failure so the caller cantry/catchor.catch(...)them.
You should still attach device.on('error', fn) so you can see what went wrong; the guard only prevents crashes, it doesn't suppress information.
device.on('error', (err) => {
// err is a normal Error. Examples you may see:
// "Subscribe /Meas/Acc/52 failed (status 404)" // bad rate / firmware
// "Command ref 5 timed out" // sensor stopped responding
// "Disconnected" // mid-flight when link dropped
// "Parse failure for /Meas/HR: ..." // malformed payload
// "Dropped DATA_CONT for /Meas/ECG/500 (MTU too small)"
myLogger.warn(err);
});
// One stream failing doesn't poison the rest:
const results = await Promise.allSettled([
device.subscribeAcc(52),
device.subscribeEcg(125),
]);
for (const r of results) if (r.status === 'rejected') myLogger.warn(r.reason);Reconnect strategy
On unexpected disconnect, the device retries with exponential backoff capped at maxReconnectDelayMs (default 30s). On success it re-subscribes every previously active path with fresh reference numbers. Calling disconnect() cancels the loop. Override:
const scanner = new MovesenseScanner({
autoReconnect: true,
initialReconnectDelayMs: 1000,
maxReconnectDelayMs: 30000,
commandTimeoutMs: 5000,
});Examples
node examples/basic.js # Acc + HR
node examples/battery.js # battery level (one-shot + stream)
MOVESENSE_SERIAL=210330000123 node examples/all-sensors.js # everythingProtocol notes
The sensor exposes one GATT service:
| | UUID |
|---|---|
| Service | 34802252-7185-4d5d-b431-630e7050e8f0 |
| Write | 34800001-7185-4d5d-b431-630e7050e8f0 |
| Notify | 34800002-7185-4d5d-b431-630e7050e8f0 |
Every command is [code, ref, ...path/payload]. Every notification is [code, ref, ...], where code is 0x01 (one-shot reply, has uint16 HTTP status at offset 2) or 0x02/0x03 (streaming DATA / continuation). Sensor Meas/* payloads start with a uint32 LE timestamp at offset 2, followed by a fixed-stride sample batch. HR is the only stream without a timestamp prefix.
References:
License
MIT
