openvibers/openvibe.bot Commits

Echo heartbeat t as heartbeatack.t and normalize Node battery objects (#4)

OP OpenVibers committed 2 Oct 2026 · b722fdb523 · 3 files, +62 −7

local checks ✓ 4/4 · no CI checks · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Bot/pull/4

Changes

ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 3004f84..e2f3628 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 59af240..83920b8 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -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 = () => Dat
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 }; }
++ // 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 = () => Dat
const 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,
ddiff --git a/test/device.test.js b/test/device.test.js
iindex 3cce42f..e4f806a 100644
---- a/test/device.test.js
++++ b/test/device.test.js
@@@ -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();
})();