Fix firmware wire conformance: event names, subscribe API, constants, structs; bump to 2.0.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paskooter
2026-04-25 20:45:11 -04:00
parent 8c92e1c963
commit 4dac6eeda9
10 changed files with 95 additions and 56 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "rom-control",
"version": "2.0.1",
"version": "2.0.2",
"description": "Discord.js-style OOP client for the Jibo ROM WebSocket API",
"main": "./index.js",
"exports": {

View File

@@ -122,8 +122,9 @@ class Client extends EventEmitter {
conn.on('disconnected', () => { this.tracks.clear(); this.emit('disconnect'); });
conn.on('error', (err) => this.emit('error', err));
// Entity tracking
conn.on('onTrackGained', (txId, body) => {
// Entity tracking — wire event names per APK Command$EventType @SerializedName:
// "onEntityGained" / "onEntityUpdate" / "onEntityLost" (NOT onTrackGained/Update/Lost).
conn.on('onEntityGained', (txId, body) => {
for (const raw of (body.Tracks || [])) {
const track = new Track(raw, this);
this.tracks.set(track.id, track);
@@ -131,7 +132,7 @@ class Client extends EventEmitter {
}
});
conn.on('onTrackUpdate', (txId, body) => {
conn.on('onEntityUpdate', (txId, body) => {
for (const raw of (body.Tracks || [])) {
const existing = this.tracks.get(raw.EntityID);
// Shallow-clone the old track so listeners get a frozen snapshot
@@ -142,7 +143,7 @@ class Client extends EventEmitter {
}
});
conn.on('onTrackLost', (txId, body) => {
conn.on('onEntityLost', (txId, body) => {
for (const raw of (body.Tracks || [])) {
const track = this.tracks.get(raw.EntityID) || new Track(raw, this);
this.tracks.delete(track.id);
@@ -155,8 +156,8 @@ class Client extends EventEmitter {
this.emit('motionDetected', new Motion(body));
});
// Head touch
conn.on('onHeadTouched', (txId, body) => {
// Head touch — wire event is "onHeadTouch" (singular), not "onHeadTouched".
conn.on('onHeadTouch', (txId, body) => {
this.emit('headTouch', new HeadTouchEvent(body));
});

View File

@@ -9,14 +9,21 @@ const crypto = require('crypto');
const http = require('http');
const { sanitizeEsml, chunkEsml } = require('./util/esml');
// Command Type values match com.jibo.atk.model.Command$CommandType (PascalCase per Gson @SerializedName).
// All stream subscriptions (Entity / Motion / HeadTouch / ScreenGesture / Speech) go through Type:'Subscribe'
// with a different StreamType value — even though the APK enum lists ScreenGesture as its own top-level Type,
// the live firmware's JSON schema only validates the Subscribe sub-schema and rejects Type:'ScreenGesture'.
// UnloadAsset is intentionally absent — it's not in the firmware's CommandType enum either.
const DEFAULT_COMMAND_SET = Object.freeze([
'StartSession', 'GetConfig', 'SetConfig', 'Cancel',
'SetAttention', 'Say', 'Listen', 'LookAt',
'TakePhoto', 'Video', 'Display', 'FetchAsset', 'UnloadAsset', 'Subscribe',
'TakePhoto', 'Video', 'Display', 'FetchAsset',
'Subscribe',
]);
// StreamType values match com.jibo.atk.model.Command$StreamTypes (PascalCase per Gson @SerializedName).
const DEFAULT_STREAM_SET = Object.freeze([
'Entity', 'Motion', 'HeadTouch', 'ScreenGesture', 'HotWord',
'Entity', 'Motion', 'HeadTouch', 'ScreenGesture', 'Speech', 'HotWord',
]);
// ── HTTP helpers ─────────────────────────────────────────────────────────────
@@ -258,11 +265,10 @@ class RomConnection extends EventEmitter {
this.version = msg.Response.ResponseBody.Version || '1.0';
if (this.autoSubscribe) {
this._txSend({ Type: 'Subscribe', StreamType: 'Entity' });
this._txSend({ Type: 'Subscribe', StreamType: 'Motion' });
this._txSend({ Type: 'Subscribe', StreamType: 'HeadTouch', StreamFilter: {} });
this._txSend({ Type: 'Subscribe', StreamType: 'ScreenGesture',
StreamFilter: { Type: 'Tap', Area: { x: 0, y: 0, width: 1, height: 1 } } });
//this.subscribeEntity();
//this.subscribeMotion();
this.subscribeHeadTouch();
this.subscribeScreenGesture();
}
if (this.autoHeartbeat) this._startHeartbeat();
@@ -308,6 +314,11 @@ class RomConnection extends EventEmitter {
}
}
if (process.env.ROM_DEBUG) {
const cmdType = txId ? this._txCommands.get(txId) : null;
const sliceLen = body && body.ResponseCode >= 400 ? 4000 : 200;
console.log('[ROM<<]', evtName || '(response)', 'tx=' + (txId ? txId.slice(0,8) : 'none'), cmdType ? 'forCmd=' + cmdType : '', body ? JSON.stringify(body).slice(0, sliceLen) : '');
}
if (evtName) {
this.emit('event', txId, body);
this.emit(evtName, txId, body);
@@ -326,6 +337,7 @@ class RomConnection extends EventEmitter {
_txSend(command) {
const txId = this._txId();
const wsState = this.ws ? this.ws.readyState : 'no-ws';
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
// Don't send any command except StartSession before the session ID arrives.
// Commands sent with an empty SessionID are rejected by ROM with 403 Forbidden.
@@ -338,7 +350,7 @@ class RomConnection extends EventEmitter {
this._txCommands.delete(this._txCommands.keys().next().value);
}
this.ws.send(JSON.stringify({
const frame = JSON.stringify({
ClientHeader: {
TransactionID: txId,
SessionID: this.sessionID,
@@ -347,7 +359,11 @@ class RomConnection extends EventEmitter {
Version: this.version,
},
Command: command,
}));
});
if (process.env.ROM_DEBUG) console.log('[ROM>>]', command.Type, 'tx=' + txId.slice(0,8), frame);
this.ws.send(frame);
} else {
if (process.env.ROM_DEBUG) console.warn('[ROM>>] DROPPED', command.Type, 'wsState=' + wsState);
}
return txId;
}
@@ -562,7 +578,7 @@ class RomConnection extends EventEmitter {
lookAtEntity(entityId, track = true) { return this.lookAt({ Entity: entityId }, track, false); }
// Camera
takePhoto(camera = 'Right', resolution = 'HighRes', distortion = false) {
takePhoto(camera = 'right', resolution = 'highRes', distortion = false) {
return this._txSend({ Type: 'TakePhoto', Camera: camera, Resolution: resolution, Distortion: distortion });
}
@@ -606,13 +622,29 @@ class RomConnection extends EventEmitter {
// Assets
fetchAsset(uri, name) { return this._txSend({ Type: 'FetchAsset', URI: uri, Name: name }); }
unloadAsset(name) { return this._txSend({ Type: 'UnloadAsset', Name: name }); }
// Subscriptions
subscribe(streamType, filter = null) {
const cmd = { Type: 'Subscribe', StreamType: streamType };
if (filter != null) cmd.StreamFilter = filter;
return this._txSend(cmd);
//
// Wire shape derived from the APK's Command$BaseSubscribeCommand:
// Entity / Motion / HeadTouch / Speech → Type:'Subscribe', StreamType:<...>, StreamFilter:''
// ScreenGesture → Type:'ScreenGesture', StreamType:'ScreenGesture',
// StreamFilter:{ Type:gestureType, Area:{x,y,width,height|radius} }
// Speech adds a top-level Listen:bool.
//
// The firmware's JSON schema requires StreamFilter on every subscribe variant — empty string
// satisfies it for the non-ScreenGesture streams (the field is typed `String` in the APK).
subscribeEntity() { return this._txSend({ Type: 'Subscribe', StreamType: 'Entity', StreamFilter: '' }); }
subscribeMotion() { return this._txSend({ Type: 'Subscribe', StreamType: 'Motion', StreamFilter: '' }); }
subscribeHeadTouch() { return this._txSend({ Type: 'Subscribe', StreamType: 'HeadTouch', StreamFilter: '' }); }
subscribeSpeech(listen = true) {
return this._txSend({ Type: 'Subscribe', StreamType: 'Speech', StreamFilter: '', Listen: !!listen });
}
subscribeScreenGesture(filter = { Type: 'Tap', Area: { x: 0, y: 0, width: 1, height: 1 } }) {
return this._txSend({ Type: 'Subscribe', StreamType: 'ScreenGesture', StreamFilter: filter });
}
// Generic escape hatch — caller must supply the correct shape.
subscribe(streamType, filter = '') {
return this._txSend({ Type: 'Subscribe', StreamType: streamType, StreamFilter: filter });
}
// Wakeword

View File

@@ -1,27 +1,27 @@
'use strict';
const AttentionMode = Object.freeze({
Off: 'Off',
Idle: 'Idle',
Disengage: 'Disengage',
Engaged: 'Engaged',
Speaking: 'Speaking',
Fixated: 'Fixated',
Attractable: 'Attractable',
Menu: 'Menu',
Command: 'Command',
Off: 'OFF',
Idle: 'IDLE',
Disengage: 'DISENGAGE',
Engaged: 'ENGAGED',
Speaking: 'SPEAKING',
Fixated: 'FIXATED',
Attractable: 'ATTRACTABLE',
Menu: 'MENU',
Command: 'COMMAND',
});
const Camera = Object.freeze({
Left: 'Left',
Right: 'Right',
Left: 'left',
Right: 'right',
});
const Resolution = Object.freeze({
HighRes: 'HighRes',
MedRes: 'MedRes',
LowRes: 'LowRes',
MicroRes: 'MicroRes',
HighRes: 'highRes',
MedRes: 'medRes',
LowRes: 'lowRes',
MicroRes: 'microRes',
});
const VideoType = Object.freeze({

View File

@@ -35,13 +35,6 @@ class AssetManager {
}
}
/**
* Remove a cached asset from the robot.
* @param {string} name The cache key used in fetch()
*/
unload(name) {
this._conn.unloadAsset(name);
}
}
module.exports = AssetManager;

View File

@@ -144,6 +144,13 @@ class BehaviorManager {
/**
* Play an animation by emotional category.
*
* In nonBlocking mode the call resolves once the firmware has acknowledged
* the command — *not* once the animation has finished. Awaiting the ACK
* (rather than returning synchronously) prevents a follow-up Say from
* racing the anim's Say frame on the wire, which corrupts the firmware's
* ESML parser state and yields an "Unexpected token ] in JSON" error.
*
* @param {string} cat e.g. 'happy', 'excited', 'sad', 'dance', 'emoji'
* @param {object} [options]
* @param {string} [options.filter] e.g. 'music, rom-upbeat'
@@ -153,7 +160,12 @@ class BehaviorManager {
async playAnimCat(cat, options = {}) {
const { filter = null, nonBlocking = false } = options;
const txId = this._conn.playAnimCat(cat, filter, nonBlocking);
if (nonBlocking) return;
if (nonBlocking) {
// Wait for the 202 Accepted (or any txId-matching message) so the next
// Say is not in-flight at the same time as this one.
await this._conn.awaitAck(txId, 5000);
return;
}
const result = await this._conn.awaitDone(txId, 30000);
if (!result) throw Object.assign(new Error(`playAnimCat('${cat}') timed out`), { code: 'ANIM_TIMEOUT' });
}

View File

@@ -27,8 +27,8 @@ class CameraManager {
*/
async takePhoto(options = {}) {
const {
camera = 'Right',
resolution = 'HighRes',
camera = 'right',
resolution = 'highRes',
distortion = false,
timeout = 15000,
} = options;

View File

@@ -2,8 +2,9 @@
class MotionZone {
constructor(raw) {
// ScreenCoords arrives as a 4-element bounding box [x, y, width, height].
this.screenCoords = raw.ScreenCoords
? { x: raw.ScreenCoords[0], y: raw.ScreenCoords[1] }
? { x: raw.ScreenCoords[0], y: raw.ScreenCoords[1], width: raw.ScreenCoords[2], height: raw.ScreenCoords[3] }
: null;
this.worldCoords = raw.WorldCoords
? { x: raw.WorldCoords[0], y: raw.WorldCoords[1], z: raw.WorldCoords[2] }

View File

@@ -3,8 +3,12 @@
class Track {
constructor(raw, client) {
this.id = raw.EntityID;
// EntityType per APK @SerializedName: lowercase 'person' / 'unknown'.
this.type = raw.Type ?? null;
this.confidence = raw.Confidence ?? null;
// ScreenCoords arrives as a 4-element bounding box [x, y, width, height].
this.screenCoords = raw.ScreenCoords
? { x: raw.ScreenCoords[0], y: raw.ScreenCoords[1] }
? { x: raw.ScreenCoords[0], y: raw.ScreenCoords[1], width: raw.ScreenCoords[2], height: raw.ScreenCoords[3] }
: null;
this.worldCoords = raw.WorldCoords
? { x: raw.WorldCoords[0], y: raw.WorldCoords[1], z: raw.WorldCoords[2] }

View File

@@ -18,9 +18,7 @@ function sanitizeEsml(text) {
}
function chunkEsml(text, maxLen = 450) {
if (text.length <= maxLen) {
return [/<[a-zA-Z]/.test(text) ? text : `<break size='0.1'/> ${text}`];
}
if (text.length <= maxLen) return [text];
const chunks = [];
let remaining = text;
@@ -54,9 +52,7 @@ function chunkEsml(text, maxLen = 450) {
if (remaining.trim()) chunks.push(remaining.trim());
return chunks
.filter(c => c.length > 0)
.map(c => /<[a-zA-Z]/.test(c) ? c : `<break size='0.1'/> ${c}`);
return chunks.filter(c => c.length > 0);
}
module.exports = { sanitizeEsml, chunkEsml };