openvibers/openvibe.bot Commits
Echo heartbeat t as heartbeatack.t and normalize Node battery objects (#4)
Changes
@@@ -45,7 +45,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists) and whenever Bot's latch is set or cleared (after the `estop` frame) || `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate || `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |--| `heartbeat_ack` | `echo, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT. The envelope `seq` is Bot's own counter and never echoes the device's |++| `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe.Node reads. The envelope `seq` is Bot's own counter and never echoes the device's || `error` | `code, detail` | a frame the server refused |### Device → server@@@ -53,8 +53,8 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.| type | fields (besides v, seq, ts) | notes ||---|---|---|| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code |--| `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is any correlation value (e.g. the device's send time), returned as `heartbeat_ack.echo`; `rtt_ms` is the device's own measured latency |--| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window |++| `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency |++| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe.Node sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame || `status` | `firmware, capabilities, faults[], estop_latched` | on connect and on change || `ack` | `id` | a command ran || `nack` | `id, fault_code` | a command was refused on the device |@@@ -166,13 +166,14 @@ A drive command (a held control re-sends every 150 ms with a fresh `id`):```json{ "v": 1, "seq": 12, "ts": 1738065600500, "type": "heartbeat", "t": 1738065600500, "rtt_ms": 63 }--{ "v": 1, "seq": 4, "ts": 1738065600501, "type": "heartbeat_ack", "echo": 1738065600500, "server_time": "2026-09-29T19:20:00.501Z" }++{ "v": 1, "seq": 4, "ts": 1738065600501, "type": "heartbeat_ack", "echo": 1738065600500, "t": 1738065600500, "server_time": "2026-09-29T19:20:00.501Z" }``````json{ "v": 1, "seq": 13, "ts": 1738065600800, "type": "telemetry", "battery": 0.72, "voltage": 7.41,"sensors": { "ultrasonic": 118 }, "rssi": -57 }{ "v": 1, "seq": 14, "ts": 1738065600850, "type": "telemetry", "battery": 0.72, "events": [{ "kind": "bump" }] }++{ "v": 1, "seq": 15, "ts": 1738065600900, "type": "telemetry", "battery": { "volts": 7.42, "percent": 59 }, "sensors": { "ultrasonic": 118 } }``````json@@@ -174,10 +174,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat}conn.lastHeartbeat = now();switch (msg.type) {-- case 'heartbeat':++ case 'heartbeat': {if (Number.isFinite(msg.rtt_ms)) conn.rttMs = Math.round(msg.rtt_ms);// `echo` carries the device's `t` back for its RTT; the envelope `seq` stays Bot's own counter.-- return sendFrame(conn, 'heartbeat_ack', { echo: msg.t !== undefined ? msg.t : null, server_time: iso(now()) });++ const ack = { echo: msg.t !== undefined ? msg.t : null, server_time: iso(now()) };++ // OpenVibe.Node reads `heartbeat_ack.t` (its RTT is now - t), so echo it as `t` as well.++ if (Number.isFinite(msg.t)) ack.t = msg.t;++ return sendFrame(conn, 'heartbeat_ack', ack);++ }case 'telemetry': return onTelemetry(conn, msg);case 'status': conn.status = msg; for (const r of deviceRobotIds(conn.device)) broadcast(r); return undefined;case 'ack': case 'nack': return onAck(conn, msg);@@@ -401,6 +405,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datfunction closeDevice(deviceId, reason = 'revoked') { const c = deviceConns.get(deviceId); if (c) { try { c.ws.close(4003, reason); } catch { /* gone */ } dropDevice(c); } }function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }++ // A device may report battery the legacy way (a 0..1 fraction) or as OpenVibe.Node does ({volts, percent}).++ function batteryFraction(telemetry) {++ const b = telemetry ? telemetry.battery : null;++ if (Number.isFinite(b)) return b;++ if (b && typeof b === 'object' && Number.isFinite(b.percent)) return Math.min(1, Math.max(0, b.percent / 100));++ return null;++ }++async function robotState(robotId, subjectForQueue = null) {// The device's state is read before the first await, so each broadcast carries the frame that caused it// (two telemetry frames with events back to back are both delivered, not the second one twice).@@@ -409,7 +421,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconst telemetry = online && conn.telemetry ? conn.telemetry : null;const live = {latency_ms: online ? (conn.rttMs != null ? conn.rttMs : null) : null,-- battery: telemetry ? (telemetry.battery != null ? telemetry.battery : null) : null,++ battery: batteryFraction(telemetry),telemetry,status: online && conn.status ? conn.status : null,device_estop: online ? (conn.deviceEstop.get(robotId) || null) : null,@@@ -51,6 +51,17 @@ const { boot, check, done } = require('./helpers/app');dev.close();});++ await check("a heartbeat's t comes back as heartbeat_ack.t too, next to echo and server_time", async () => {++ const { robot, pairing } = await t.robot(alex);++ const { dev } = await pairOverWs(robot.id, pairing.code);++ dev.send({ type: 'heartbeat', t: 1738065600500 });++ const ack = await dev.waitFor((m) => m.type === 'heartbeat_ack');++ assert.strictEqual(ack.t, 1738065600500, 'OpenVibe.Node reads ack.t for its RTT');++ assert.strictEqual(ack.echo, 1738065600500, 'the existing echo field is unchanged');++ assert.ok(ack.server_time);++ dev.close();++ });++await check('a device that stops heartbeating goes offline; bot.robot.offline is written', async () => {const { robot, pairing } = await t.robot(alex);const { dev, paired } = await pairOverWs(robot.id, pairing.code);@@@ -136,6 +147,37 @@ const { boot, check, done } = require('./helpers/app');op.close(); dev.close();});++ await check('a Node-style battery object becomes a 0..1 fraction, raw frame kept in telemetry', async () => {++ const { robot, pairing } = await t.robot(alex);++ const { dev } = await pairOverWs(robot.id, pairing.code);++ const op = await ownerJoined(robot.id);++ dev.send({ type: 'telemetry', battery: { volts: 7.42, percent: 59 }, sensors: { ultrasonic: 118 } });++ const state = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.battery && m.state.telemetry.battery.percent === 59);++ assert.strictEqual(state.state.battery, 0.59);++ assert.deepStrictEqual(state.state.telemetry.battery, { volts: 7.42, percent: 59 }, 'the raw object is unchanged in telemetry');++ op.close(); dev.close();++ });++++ await check('a legacy fractional battery is passed through unchanged', async () => {++ const { robot, pairing } = await t.robot(alex);++ const { dev } = await pairOverWs(robot.id, pairing.code);++ const op = await ownerJoined(robot.id);++ dev.send({ type: 'telemetry', battery: 0.72, voltage: 7.41, sensors: { ultrasonic: 119 } });++ const state = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.battery === 0.72);++ assert.strictEqual(state.state.battery, 0.72);++ op.close(); dev.close();++ });++++ await check('a battery object without a finite percent yields a null fraction', async () => {++ const { robot, pairing } = await t.robot(alex);++ const { dev } = await pairOverWs(robot.id, pairing.code);++ const op = await ownerJoined(robot.id);++ dev.send({ type: 'telemetry', battery: { volts: 7.4 }, sensors: { ultrasonic: 121 } });++ const state = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.sensors && m.state.telemetry.sensors.ultrasonic === 121);++ assert.strictEqual(state.state.battery, null);++ op.close(); dev.close();++ });++await t.close();done();})();
@@ -45,7 +45,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -45,7 +45,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists) and whenever Bot's latch is set or cleared (after the `estop` frame) |
| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists) and whenever Bot's latch is set or cleared (after the `estop` frame) |
| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate |
| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate |
| `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |
| `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |
-| `heartbeat_ack` | `echo, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT. The envelope `seq` is Bot's own counter and never echoes the device's |
+| `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe.Node reads. The envelope `seq` is Bot's own counter and never echoes the device's |
| `error` | `code, detail` | a frame the server refused |
| `error` | `code, detail` | a frame the server refused |
### Device → server
### Device → server
@@ -53,8 +53,8 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -53,8 +53,8 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| type | fields (besides v, seq, ts) | notes |
| type | fields (besides v, seq, ts) | notes |
|---|---|---|
|---|---|---|
| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code |
| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code |
-| `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is any correlation value (e.g. the device's send time), returned as `heartbeat_ack.echo`; `rtt_ms` is the device's own measured latency |
-| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window |
+| `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency |
+| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe.Node sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame |
| `status` | `firmware, capabilities, faults[], estop_latched` | on connect and on change |
| `status` | `firmware, capabilities, faults[], estop_latched` | on connect and on change |
| `ack` | `id` | a command ran |
| `ack` | `id` | a command ran |
| `nack` | `id, fault_code` | a command was refused on the device |
| `nack` | `id, fault_code` | a command was refused on the device |
@@ -166,13 +166,14 @@ A drive command (a held control re-sends every 150 ms with a fresh `id`):
@@ -166,13 +166,14 @@ A drive command (a held control re-sends every 150 ms with a fresh `id`):
```json
```json
{ "v": 1, "seq": 12, "ts": 1738065600500, "type": "heartbeat", "t": 1738065600500, "rtt_ms": 63 }
{ "v": 1, "seq": 12, "ts": 1738065600500, "type": "heartbeat", "t": 1738065600500, "rtt_ms": 63 }
-{ "v": 1, "seq": 4, "ts": 1738065600501, "type": "heartbeat_ack", "echo": 1738065600500, "server_time": "2026-09-29T19:20:00.501Z" }
+{ "v": 1, "seq": 4, "ts": 1738065600501, "type": "heartbeat_ack", "echo": 1738065600500, "t": 1738065600500, "server_time": "2026-09-29T19:20:00.501Z" }
```
```
```json
```json
{ "v": 1, "seq": 13, "ts": 1738065600800, "type": "telemetry", "battery": 0.72, "voltage": 7.41,
{ "v": 1, "seq": 13, "ts": 1738065600800, "type": "telemetry", "battery": 0.72, "voltage": 7.41,
"sensors": { "ultrasonic": 118 }, "rssi": -57 }
"sensors": { "ultrasonic": 118 }, "rssi": -57 }
{ "v": 1, "seq": 14, "ts": 1738065600850, "type": "telemetry", "battery": 0.72, "events": [{ "kind": "bump" }] }
{ "v": 1, "seq": 14, "ts": 1738065600850, "type": "telemetry", "battery": 0.72, "events": [{ "kind": "bump" }] }
+{ "v": 1, "seq": 15, "ts": 1738065600900, "type": "telemetry", "battery": { "volts": 7.42, "percent": 59 }, "sensors": { "ultrasonic": 118 } }
```
```
```json
```json
@@ -174,10 +174,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -174,10 +174,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
}
conn.lastHeartbeat = now();
conn.lastHeartbeat = now();
switch (msg.type) {
switch (msg.type) {
- case 'heartbeat':
+ case 'heartbeat': {
if (Number.isFinite(msg.rtt_ms)) conn.rttMs = Math.round(msg.rtt_ms);
if (Number.isFinite(msg.rtt_ms)) conn.rttMs = Math.round(msg.rtt_ms);
// `echo` carries the device's `t` back for its RTT; the envelope `seq` stays Bot's own counter.
// `echo` carries the device's `t` back for its RTT; the envelope `seq` stays Bot's own counter.
- return sendFrame(conn, 'heartbeat_ack', { echo: msg.t !== undefined ? msg.t : null, server_time: iso(now()) });
+ const ack = { echo: msg.t !== undefined ? msg.t : null, server_time: iso(now()) };
+ // OpenVibe.Node reads `heartbeat_ack.t` (its RTT is now - t), so echo it as `t` as well.
+ if (Number.isFinite(msg.t)) ack.t = msg.t;
+ return sendFrame(conn, 'heartbeat_ack', ack);
+ }
case 'telemetry': return onTelemetry(conn, msg);
case 'telemetry': return onTelemetry(conn, msg);
case 'status': conn.status = msg; for (const r of deviceRobotIds(conn.device)) broadcast(r); return undefined;
case 'status': conn.status = msg; for (const r of deviceRobotIds(conn.device)) broadcast(r); return undefined;
case 'ack': case 'nack': return onAck(conn, msg);
case 'ack': case 'nack': return onAck(conn, msg);
@@ -401,6 +405,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -401,6 +405,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
function closeDevice(deviceId, reason = 'revoked') { const c = deviceConns.get(deviceId); if (c) { try { c.ws.close(4003, reason); } catch { /* gone */ } dropDevice(c); } }
function closeDevice(deviceId, reason = 'revoked') { const c = deviceConns.get(deviceId); if (c) { try { c.ws.close(4003, reason); } catch { /* gone */ } dropDevice(c); } }
function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }
function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }
+ // A device may report battery the legacy way (a 0..1 fraction) or as OpenVibe.Node does ({volts, percent}).
+ function batteryFraction(telemetry) {
+ const b = telemetry ? telemetry.battery : null;
+ if (Number.isFinite(b)) return b;
+ if (b && typeof b === 'object' && Number.isFinite(b.percent)) return Math.min(1, Math.max(0, b.percent / 100));
+ return null;
+ }
+
async function robotState(robotId, subjectForQueue = null) {
async function robotState(robotId, subjectForQueue = null) {
// The device's state is read before the first await, so each broadcast carries the frame that caused it
// The device's state is read before the first await, so each broadcast carries the frame that caused it
// (two telemetry frames with events back to back are both delivered, not the second one twice).
// (two telemetry frames with events back to back are both delivered, not the second one twice).
@@ -409,7 +421,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -409,7 +421,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const telemetry = online && conn.telemetry ? conn.telemetry : null;
const telemetry = online && conn.telemetry ? conn.telemetry : null;
const live = {
const live = {
latency_ms: online ? (conn.rttMs != null ? conn.rttMs : null) : null,
latency_ms: online ? (conn.rttMs != null ? conn.rttMs : null) : null,
- battery: telemetry ? (telemetry.battery != null ? telemetry.battery : null) : null,
+ battery: batteryFraction(telemetry),
telemetry,
telemetry,
status: online && conn.status ? conn.status : null,
status: online && conn.status ? conn.status : null,
device_estop: online ? (conn.deviceEstop.get(robotId) || null) : null,
device_estop: online ? (conn.deviceEstop.get(robotId) || null) : null,
@@ -51,6 +51,17 @@ const { boot, check, done } = require('./helpers/app');
@@ -51,6 +51,17 @@ const { boot, check, done } = require('./helpers/app');
dev.close();
dev.close();
});
});
+ await check("a heartbeat's t comes back as heartbeat_ack.t too, next to echo and server_time", async () => {
+ const { robot, pairing } = await t.robot(alex);
+ const { dev } = await pairOverWs(robot.id, pairing.code);
+ dev.send({ type: 'heartbeat', t: 1738065600500 });
+ const ack = await dev.waitFor((m) => m.type === 'heartbeat_ack');
+ assert.strictEqual(ack.t, 1738065600500, 'OpenVibe.Node reads ack.t for its RTT');
+ assert.strictEqual(ack.echo, 1738065600500, 'the existing echo field is unchanged');
+ assert.ok(ack.server_time);
+ dev.close();
+ });
+
await check('a device that stops heartbeating goes offline; bot.robot.offline is written', async () => {
await check('a device that stops heartbeating goes offline; bot.robot.offline is written', async () => {
const { robot, pairing } = await t.robot(alex);
const { robot, pairing } = await t.robot(alex);
const { dev, paired } = await pairOverWs(robot.id, pairing.code);
const { dev, paired } = await pairOverWs(robot.id, pairing.code);
@@ -136,6 +147,37 @@ const { boot, check, done } = require('./helpers/app');
@@ -136,6 +147,37 @@ const { boot, check, done } = require('./helpers/app');
op.close(); dev.close();
op.close(); dev.close();
});
});
+ await check('a Node-style battery object becomes a 0..1 fraction, raw frame kept in telemetry', async () => {
+ const { robot, pairing } = await t.robot(alex);
+ const { dev } = await pairOverWs(robot.id, pairing.code);
+ const op = await ownerJoined(robot.id);
+ dev.send({ type: 'telemetry', battery: { volts: 7.42, percent: 59 }, sensors: { ultrasonic: 118 } });
+ const state = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.battery && m.state.telemetry.battery.percent === 59);
+ assert.strictEqual(state.state.battery, 0.59);
+ assert.deepStrictEqual(state.state.telemetry.battery, { volts: 7.42, percent: 59 }, 'the raw object is unchanged in telemetry');
+ op.close(); dev.close();
+ });
+
+ await check('a legacy fractional battery is passed through unchanged', async () => {
+ const { robot, pairing } = await t.robot(alex);
+ const { dev } = await pairOverWs(robot.id, pairing.code);
+ const op = await ownerJoined(robot.id);
+ dev.send({ type: 'telemetry', battery: 0.72, voltage: 7.41, sensors: { ultrasonic: 119 } });
+ const state = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.battery === 0.72);
+ assert.strictEqual(state.state.battery, 0.72);
+ op.close(); dev.close();
+ });
+
+ await check('a battery object without a finite percent yields a null fraction', async () => {
+ const { robot, pairing } = await t.robot(alex);
+ const { dev } = await pairOverWs(robot.id, pairing.code);
+ const op = await ownerJoined(robot.id);
+ dev.send({ type: 'telemetry', battery: { volts: 7.4 }, sensors: { ultrasonic: 121 } });
+ const state = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.sensors && m.state.telemetry.sensors.ultrasonic === 121);
+ assert.strictEqual(state.state.battery, null);
+ op.close(); dev.close();
+ });
+
await t.close();
await t.close();
done();
done();
})();
})();