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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rom-control",
|
"name": "rom-control",
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"description": "Discord.js-style OOP client for the Jibo ROM WebSocket API",
|
"description": "Discord.js-style OOP client for the Jibo ROM WebSocket API",
|
||||||
"main": "./index.js",
|
"main": "./index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -122,8 +122,9 @@ class Client extends EventEmitter {
|
|||||||
conn.on('disconnected', () => { this.tracks.clear(); this.emit('disconnect'); });
|
conn.on('disconnected', () => { this.tracks.clear(); this.emit('disconnect'); });
|
||||||
conn.on('error', (err) => this.emit('error', err));
|
conn.on('error', (err) => this.emit('error', err));
|
||||||
|
|
||||||
// Entity tracking
|
// Entity tracking — wire event names per APK Command$EventType @SerializedName:
|
||||||
conn.on('onTrackGained', (txId, body) => {
|
// "onEntityGained" / "onEntityUpdate" / "onEntityLost" (NOT onTrackGained/Update/Lost).
|
||||||
|
conn.on('onEntityGained', (txId, body) => {
|
||||||
for (const raw of (body.Tracks || [])) {
|
for (const raw of (body.Tracks || [])) {
|
||||||
const track = new Track(raw, this);
|
const track = new Track(raw, this);
|
||||||
this.tracks.set(track.id, track);
|
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 || [])) {
|
for (const raw of (body.Tracks || [])) {
|
||||||
const existing = this.tracks.get(raw.EntityID);
|
const existing = this.tracks.get(raw.EntityID);
|
||||||
// Shallow-clone the old track so listeners get a frozen snapshot
|
// 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 || [])) {
|
for (const raw of (body.Tracks || [])) {
|
||||||
const track = this.tracks.get(raw.EntityID) || new Track(raw, this);
|
const track = this.tracks.get(raw.EntityID) || new Track(raw, this);
|
||||||
this.tracks.delete(track.id);
|
this.tracks.delete(track.id);
|
||||||
@@ -155,8 +156,8 @@ class Client extends EventEmitter {
|
|||||||
this.emit('motionDetected', new Motion(body));
|
this.emit('motionDetected', new Motion(body));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Head touch
|
// Head touch — wire event is "onHeadTouch" (singular), not "onHeadTouched".
|
||||||
conn.on('onHeadTouched', (txId, body) => {
|
conn.on('onHeadTouch', (txId, body) => {
|
||||||
this.emit('headTouch', new HeadTouchEvent(body));
|
this.emit('headTouch', new HeadTouchEvent(body));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,21 @@ const crypto = require('crypto');
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const { sanitizeEsml, chunkEsml } = require('./util/esml');
|
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([
|
const DEFAULT_COMMAND_SET = Object.freeze([
|
||||||
'StartSession', 'GetConfig', 'SetConfig', 'Cancel',
|
'StartSession', 'GetConfig', 'SetConfig', 'Cancel',
|
||||||
'SetAttention', 'Say', 'Listen', 'LookAt',
|
'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([
|
const DEFAULT_STREAM_SET = Object.freeze([
|
||||||
'Entity', 'Motion', 'HeadTouch', 'ScreenGesture', 'HotWord',
|
'Entity', 'Motion', 'HeadTouch', 'ScreenGesture', 'Speech', 'HotWord',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||||
@@ -258,11 +265,10 @@ class RomConnection extends EventEmitter {
|
|||||||
this.version = msg.Response.ResponseBody.Version || '1.0';
|
this.version = msg.Response.ResponseBody.Version || '1.0';
|
||||||
|
|
||||||
if (this.autoSubscribe) {
|
if (this.autoSubscribe) {
|
||||||
this._txSend({ Type: 'Subscribe', StreamType: 'Entity' });
|
//this.subscribeEntity();
|
||||||
this._txSend({ Type: 'Subscribe', StreamType: 'Motion' });
|
//this.subscribeMotion();
|
||||||
this._txSend({ Type: 'Subscribe', StreamType: 'HeadTouch', StreamFilter: {} });
|
this.subscribeHeadTouch();
|
||||||
this._txSend({ Type: 'Subscribe', StreamType: 'ScreenGesture',
|
this.subscribeScreenGesture();
|
||||||
StreamFilter: { Type: 'Tap', Area: { x: 0, y: 0, width: 1, height: 1 } } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.autoHeartbeat) this._startHeartbeat();
|
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) {
|
if (evtName) {
|
||||||
this.emit('event', txId, body);
|
this.emit('event', txId, body);
|
||||||
this.emit(evtName, txId, body);
|
this.emit(evtName, txId, body);
|
||||||
@@ -326,6 +337,7 @@ class RomConnection extends EventEmitter {
|
|||||||
|
|
||||||
_txSend(command) {
|
_txSend(command) {
|
||||||
const txId = this._txId();
|
const txId = this._txId();
|
||||||
|
const wsState = this.ws ? this.ws.readyState : 'no-ws';
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
// Don't send any command except StartSession before the session ID arrives.
|
// 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.
|
// 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._txCommands.delete(this._txCommands.keys().next().value);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ws.send(JSON.stringify({
|
const frame = JSON.stringify({
|
||||||
ClientHeader: {
|
ClientHeader: {
|
||||||
TransactionID: txId,
|
TransactionID: txId,
|
||||||
SessionID: this.sessionID,
|
SessionID: this.sessionID,
|
||||||
@@ -347,7 +359,11 @@ class RomConnection extends EventEmitter {
|
|||||||
Version: this.version,
|
Version: this.version,
|
||||||
},
|
},
|
||||||
Command: command,
|
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;
|
return txId;
|
||||||
}
|
}
|
||||||
@@ -562,7 +578,7 @@ class RomConnection extends EventEmitter {
|
|||||||
lookAtEntity(entityId, track = true) { return this.lookAt({ Entity: entityId }, track, false); }
|
lookAtEntity(entityId, track = true) { return this.lookAt({ Entity: entityId }, track, false); }
|
||||||
|
|
||||||
// Camera
|
// 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 });
|
return this._txSend({ Type: 'TakePhoto', Camera: camera, Resolution: resolution, Distortion: distortion });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,13 +622,29 @@ class RomConnection extends EventEmitter {
|
|||||||
|
|
||||||
// Assets
|
// Assets
|
||||||
fetchAsset(uri, name) { return this._txSend({ Type: 'FetchAsset', URI: uri, Name: name }); }
|
fetchAsset(uri, name) { return this._txSend({ Type: 'FetchAsset', URI: uri, Name: name }); }
|
||||||
unloadAsset(name) { return this._txSend({ Type: 'UnloadAsset', Name: name }); }
|
|
||||||
|
|
||||||
// Subscriptions
|
// Subscriptions
|
||||||
subscribe(streamType, filter = null) {
|
//
|
||||||
const cmd = { Type: 'Subscribe', StreamType: streamType };
|
// Wire shape derived from the APK's Command$BaseSubscribeCommand:
|
||||||
if (filter != null) cmd.StreamFilter = filter;
|
// Entity / Motion / HeadTouch / Speech → Type:'Subscribe', StreamType:<...>, StreamFilter:''
|
||||||
return this._txSend(cmd);
|
// 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
|
// Wakeword
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const AttentionMode = Object.freeze({
|
const AttentionMode = Object.freeze({
|
||||||
Off: 'Off',
|
Off: 'OFF',
|
||||||
Idle: 'Idle',
|
Idle: 'IDLE',
|
||||||
Disengage: 'Disengage',
|
Disengage: 'DISENGAGE',
|
||||||
Engaged: 'Engaged',
|
Engaged: 'ENGAGED',
|
||||||
Speaking: 'Speaking',
|
Speaking: 'SPEAKING',
|
||||||
Fixated: 'Fixated',
|
Fixated: 'FIXATED',
|
||||||
Attractable: 'Attractable',
|
Attractable: 'ATTRACTABLE',
|
||||||
Menu: 'Menu',
|
Menu: 'MENU',
|
||||||
Command: 'Command',
|
Command: 'COMMAND',
|
||||||
});
|
});
|
||||||
|
|
||||||
const Camera = Object.freeze({
|
const Camera = Object.freeze({
|
||||||
Left: 'Left',
|
Left: 'left',
|
||||||
Right: 'Right',
|
Right: 'right',
|
||||||
});
|
});
|
||||||
|
|
||||||
const Resolution = Object.freeze({
|
const Resolution = Object.freeze({
|
||||||
HighRes: 'HighRes',
|
HighRes: 'highRes',
|
||||||
MedRes: 'MedRes',
|
MedRes: 'medRes',
|
||||||
LowRes: 'LowRes',
|
LowRes: 'lowRes',
|
||||||
MicroRes: 'MicroRes',
|
MicroRes: 'microRes',
|
||||||
});
|
});
|
||||||
|
|
||||||
const VideoType = Object.freeze({
|
const VideoType = Object.freeze({
|
||||||
|
|||||||
@@ -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;
|
module.exports = AssetManager;
|
||||||
|
|||||||
@@ -144,6 +144,13 @@ class BehaviorManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Play an animation by emotional category.
|
* 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 {string} cat e.g. 'happy', 'excited', 'sad', 'dance', 'emoji'
|
||||||
* @param {object} [options]
|
* @param {object} [options]
|
||||||
* @param {string} [options.filter] e.g. 'music, rom-upbeat'
|
* @param {string} [options.filter] e.g. 'music, rom-upbeat'
|
||||||
@@ -153,7 +160,12 @@ class BehaviorManager {
|
|||||||
async playAnimCat(cat, options = {}) {
|
async playAnimCat(cat, options = {}) {
|
||||||
const { filter = null, nonBlocking = false } = options;
|
const { filter = null, nonBlocking = false } = options;
|
||||||
const txId = this._conn.playAnimCat(cat, filter, nonBlocking);
|
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);
|
const result = await this._conn.awaitDone(txId, 30000);
|
||||||
if (!result) throw Object.assign(new Error(`playAnimCat('${cat}') timed out`), { code: 'ANIM_TIMEOUT' });
|
if (!result) throw Object.assign(new Error(`playAnimCat('${cat}') timed out`), { code: 'ANIM_TIMEOUT' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ class CameraManager {
|
|||||||
*/
|
*/
|
||||||
async takePhoto(options = {}) {
|
async takePhoto(options = {}) {
|
||||||
const {
|
const {
|
||||||
camera = 'Right',
|
camera = 'right',
|
||||||
resolution = 'HighRes',
|
resolution = 'highRes',
|
||||||
distortion = false,
|
distortion = false,
|
||||||
timeout = 15000,
|
timeout = 15000,
|
||||||
} = options;
|
} = options;
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
class MotionZone {
|
class MotionZone {
|
||||||
constructor(raw) {
|
constructor(raw) {
|
||||||
|
// ScreenCoords arrives as a 4-element bounding box [x, y, width, height].
|
||||||
this.screenCoords = raw.ScreenCoords
|
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;
|
: null;
|
||||||
this.worldCoords = raw.WorldCoords
|
this.worldCoords = raw.WorldCoords
|
||||||
? { x: raw.WorldCoords[0], y: raw.WorldCoords[1], z: raw.WorldCoords[2] }
|
? { x: raw.WorldCoords[0], y: raw.WorldCoords[1], z: raw.WorldCoords[2] }
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
class Track {
|
class Track {
|
||||||
constructor(raw, client) {
|
constructor(raw, client) {
|
||||||
this.id = raw.EntityID;
|
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
|
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;
|
: null;
|
||||||
this.worldCoords = raw.WorldCoords
|
this.worldCoords = raw.WorldCoords
|
||||||
? { x: raw.WorldCoords[0], y: raw.WorldCoords[1], z: raw.WorldCoords[2] }
|
? { x: raw.WorldCoords[0], y: raw.WorldCoords[1], z: raw.WorldCoords[2] }
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ function sanitizeEsml(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function chunkEsml(text, maxLen = 450) {
|
function chunkEsml(text, maxLen = 450) {
|
||||||
if (text.length <= maxLen) {
|
if (text.length <= maxLen) return [text];
|
||||||
return [/<[a-zA-Z]/.test(text) ? text : `<break size='0.1'/> ${text}`];
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let remaining = text;
|
let remaining = text;
|
||||||
@@ -54,9 +52,7 @@ function chunkEsml(text, maxLen = 450) {
|
|||||||
|
|
||||||
if (remaining.trim()) chunks.push(remaining.trim());
|
if (remaining.trim()) chunks.push(remaining.trim());
|
||||||
|
|
||||||
return chunks
|
return chunks.filter(c => c.length > 0);
|
||||||
.filter(c => c.length > 0)
|
|
||||||
.map(c => /<[a-zA-Z]/.test(c) ? c : `<break size='0.1'/> ${c}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { sanitizeEsml, chunkEsml };
|
module.exports = { sanitizeEsml, chunkEsml };
|
||||||
|
|||||||
Reference in New Issue
Block a user