@pollen-robotics/reachy-mini-sdk 1.7.3 → 1.8.0-rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +188 -0
- package/README.md +68 -5
- package/dist/lib/math.d.ts +23 -0
- package/dist/lib/math.d.ts.map +1 -0
- package/dist/lib/math.js +40 -0
- package/dist/lib/math.js.map +1 -0
- package/dist/lib/reachy-mini.d.ts +150 -0
- package/dist/lib/reachy-mini.d.ts.map +1 -0
- package/dist/lib/reachy-mini.js +1452 -0
- package/dist/lib/reachy-mini.js.map +1 -0
- package/dist/lib/types.d.ts +384 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +9 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/lib/upload-helpers.d.ts +18 -0
- package/dist/lib/upload-helpers.d.ts.map +1 -0
- package/dist/lib/upload-helpers.js +44 -0
- package/dist/lib/upload-helpers.js.map +1 -0
- package/dist/lib/url-helpers.d.ts +63 -0
- package/dist/lib/url-helpers.d.ts.map +1 -0
- package/dist/lib/url-helpers.js +153 -0
- package/dist/lib/url-helpers.js.map +1 -0
- package/dist/reachy-mini-sdk.d.ts +69 -0
- package/dist/reachy-mini-sdk.d.ts.map +1 -0
- package/dist/reachy-mini-sdk.js +68 -0
- package/dist/reachy-mini-sdk.js.map +1 -0
- package/host/APP_AUTHOR_GUIDE.md +646 -0
- package/host/README.md +155 -0
- package/host/SPEC.md +618 -0
- package/host/dist/ReachyHost.d.ts +39 -0
- package/host/dist/ReachyHost.d.ts.map +1 -0
- package/host/dist/assets/index.d.ts +16 -0
- package/host/dist/assets/index.d.ts.map +1 -0
- package/host/dist/chunks/index-CyLPysJS.js +400 -0
- package/host/dist/chunks/mountHost-8f-laxwI.js +48279 -0
- package/host/dist/chunks/reachy-mini-B1hlBmDQ.js +1240 -0
- package/host/dist/components/ConnectingView.d.ts +13 -0
- package/host/dist/components/ConnectingView.d.ts.map +1 -0
- package/host/dist/components/EmbedFrame.d.ts +16 -0
- package/host/dist/components/EmbedFrame.d.ts.map +1 -0
- package/host/dist/components/ErrorView.d.ts +21 -0
- package/host/dist/components/ErrorView.d.ts.map +1 -0
- package/host/dist/components/LeavingView.d.ts +33 -0
- package/host/dist/components/LeavingView.d.ts.map +1 -0
- package/host/dist/components/PickerView.d.ts +51 -0
- package/host/dist/components/PickerView.d.ts.map +1 -0
- package/host/dist/components/ReachyHostShell.d.ts +21 -0
- package/host/dist/components/ReachyHostShell.d.ts.map +1 -0
- package/host/dist/components/SignInView.d.ts +32 -0
- package/host/dist/components/SignInView.d.ts.map +1 -0
- package/host/dist/components/StepsProgressIndicator.d.ts +41 -0
- package/host/dist/components/StepsProgressIndicator.d.ts.map +1 -0
- package/host/dist/components/TopBar.d.ts +52 -0
- package/host/dist/components/TopBar.d.ts.map +1 -0
- package/host/dist/components/WelcomeBackOverlay.d.ts +28 -0
- package/host/dist/components/WelcomeBackOverlay.d.ts.map +1 -0
- package/host/dist/embed/index.d.ts +151 -0
- package/host/dist/embed/index.d.ts.map +1 -0
- package/host/dist/entry/auto.d.ts +12 -0
- package/host/dist/entry/auto.d.ts.map +1 -0
- package/host/dist/entry/auto.js +6 -0
- package/host/dist/entry/embed.d.ts +12 -0
- package/host/dist/entry/embed.d.ts.map +1 -0
- package/host/dist/entry/embed.js +6 -0
- package/host/dist/hooks/useHfProfile.d.ts +7 -0
- package/host/dist/hooks/useHfProfile.d.ts.map +1 -0
- package/host/dist/hooks/useHostBridge.d.ts +37 -0
- package/host/dist/hooks/useHostBridge.d.ts.map +1 -0
- package/host/dist/hooks/useOAuth.d.ts +16 -0
- package/host/dist/hooks/useOAuth.d.ts.map +1 -0
- package/host/dist/hooks/useRobots.d.ts +17 -0
- package/host/dist/hooks/useRobots.d.ts.map +1 -0
- package/host/dist/hooks/useSdk.d.ts +16 -0
- package/host/dist/hooks/useSdk.d.ts.map +1 -0
- package/host/dist/index.d.ts +25 -0
- package/host/dist/index.d.ts.map +1 -0
- package/host/dist/index.js +14 -0
- package/host/dist/lib/centralListener.d.ts +73 -0
- package/host/dist/lib/centralListener.d.ts.map +1 -0
- package/host/dist/lib/centralRest.d.ts +35 -0
- package/host/dist/lib/centralRest.d.ts.map +1 -0
- package/host/dist/lib/protocol.d.ts +230 -0
- package/host/dist/lib/protocol.d.ts.map +1 -0
- package/host/dist/lib/protocol.js +47 -0
- package/host/dist/lib/sdk-types.d.ts +46 -0
- package/host/dist/lib/sdk-types.d.ts.map +1 -0
- package/host/dist/lib/settings.d.ts +69 -0
- package/host/dist/lib/settings.d.ts.map +1 -0
- package/host/dist/lib/signalingUrl.d.ts +28 -0
- package/host/dist/lib/signalingUrl.d.ts.map +1 -0
- package/host/dist/lib/theme.d.ts +4 -0
- package/host/dist/lib/theme.d.ts.map +1 -0
- package/host/dist/lib/themeMode.d.ts +9 -0
- package/host/dist/lib/themeMode.d.ts.map +1 -0
- package/host/dist/lib/tokens.d.ts +64 -0
- package/host/dist/lib/tokens.d.ts.map +1 -0
- package/host/dist/mountHost.d.ts +36 -0
- package/host/dist/mountHost.d.ts.map +1 -0
- package/package.json +76 -11
- package/reachy-mini-sdk.js +0 -2013
|
@@ -0,0 +1,1452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReachyMini — Browser SDK for controlling a Reachy Mini robot over WebRTC.
|
|
3
|
+
* See `../reachy-mini-sdk.ts` for the package's barrel and the README for
|
|
4
|
+
* a quick-start guide.
|
|
5
|
+
*/
|
|
6
|
+
import { oauthHandleRedirectIfPresent, oauthLoginUrl, } from '@huggingface/hub';
|
|
7
|
+
import { degToRad, rpyToMatrix } from './math.js';
|
|
8
|
+
import { consumeFragmentCredentials, readPreselectedRobotIdFromUrl, sdpHasAudioSendRecv, } from './url-helpers.js';
|
|
9
|
+
import { UPLOAD_CHUNK_SIZE, UPLOAD_BUFFERED_HIGH_WATER, UPLOAD_BUFFERED_LOW_WATER, hasCompressionStream, makeUploadId, bytesToBase64, gzipBase64, clampVolume, } from './upload-helpers.js';
|
|
10
|
+
export class ReachyMini extends EventTarget {
|
|
11
|
+
// ─── Config ──────────────────────────────────────────────────────────
|
|
12
|
+
_signalingUrl;
|
|
13
|
+
_clientId;
|
|
14
|
+
_appName;
|
|
15
|
+
_videoJitterBufferTargetMs;
|
|
16
|
+
_autoStartFromUrl;
|
|
17
|
+
_autoStartAttempted;
|
|
18
|
+
// ─── Public-ish state mirrors ────────────────────────────────────────
|
|
19
|
+
_state = 'disconnected';
|
|
20
|
+
_robots = [];
|
|
21
|
+
_robotState = {};
|
|
22
|
+
_preselectedRobotId;
|
|
23
|
+
// ─── Auth ────────────────────────────────────────────────────────────
|
|
24
|
+
_token = null;
|
|
25
|
+
_username = null;
|
|
26
|
+
_tokenExpires = null;
|
|
27
|
+
// ─── Signaling ───────────────────────────────────────────────────────
|
|
28
|
+
_sseAbortController = null;
|
|
29
|
+
// ─── WebRTC ──────────────────────────────────────────────────────────
|
|
30
|
+
_pc = null;
|
|
31
|
+
_dc = null;
|
|
32
|
+
_sessionId = null;
|
|
33
|
+
_selectedRobotId = null;
|
|
34
|
+
_pendingRemoteIce = [];
|
|
35
|
+
// ─── Audio ───────────────────────────────────────────────────────────
|
|
36
|
+
_micStream = null;
|
|
37
|
+
_micMuted = true;
|
|
38
|
+
_audioMuted = true;
|
|
39
|
+
_micSupported = false;
|
|
40
|
+
// ─── Timers ──────────────────────────────────────────────────────────
|
|
41
|
+
_latencyMonitorId = null;
|
|
42
|
+
_stateRefreshInterval = null;
|
|
43
|
+
// ─── Single-slot promise resolvers ───────────────────────────────────
|
|
44
|
+
_versionResolve = null;
|
|
45
|
+
_hardwareIdResolve = null;
|
|
46
|
+
_volumeResolve = null;
|
|
47
|
+
_micVolumeResolve = null;
|
|
48
|
+
// applyAudioConfig() / readAudioParameter() share the same single-slot
|
|
49
|
+
// pattern as the volume helpers. Separate slots so the two can be
|
|
50
|
+
// in-flight concurrently without collision.
|
|
51
|
+
_applyAudioConfigResolve = null;
|
|
52
|
+
_readAudioParameterResolve = null;
|
|
53
|
+
// ─── Log subscribers ─────────────────────────────────────────────────
|
|
54
|
+
_logSubscribers = new Set();
|
|
55
|
+
// ─── Broadcast waiters (playMove / playUploadedAudio) ────────────────
|
|
56
|
+
_broadcastWaiters = [];
|
|
57
|
+
// ─── Active upload ids for no-arg cancels ────────────────────────────
|
|
58
|
+
_activeMoveUploadId = null;
|
|
59
|
+
_activeAudioUploadId = null;
|
|
60
|
+
// ─── Session promise plumbing ────────────────────────────────────────
|
|
61
|
+
_sessionResolve = null;
|
|
62
|
+
_sessionReject = null;
|
|
63
|
+
_iceConnected = false;
|
|
64
|
+
_dcOpen = false;
|
|
65
|
+
// ─── Motion completion plumbing (wake_up / goto_sleep) ───────────────
|
|
66
|
+
_pendingMotionCompletions = {
|
|
67
|
+
wake_up: [],
|
|
68
|
+
goto_sleep: [],
|
|
69
|
+
};
|
|
70
|
+
// ─── Video element ───────────────────────────────────────────────────
|
|
71
|
+
_videoElement = null;
|
|
72
|
+
constructor(options = {}) {
|
|
73
|
+
super();
|
|
74
|
+
this._signalingUrl = options.signalingUrl || 'https://pollen-robotics-reachy-mini-central.hf.space';
|
|
75
|
+
// `enableMicrophone` is intentionally NOT stored: the SDK no longer
|
|
76
|
+
// calls getUserMedia (see startSession). Apps that still pass it for
|
|
77
|
+
// backward compatibility have their value silently ignored — matches
|
|
78
|
+
// the @deprecated annotation on the option type.
|
|
79
|
+
this._clientId = options.clientId || null;
|
|
80
|
+
this._appName = options.appName || 'unknown';
|
|
81
|
+
this._videoJitterBufferTargetMs = options.videoJitterBufferTargetMs ?? 0;
|
|
82
|
+
this._autoStartFromUrl = options.autoStartFromUrl === true;
|
|
83
|
+
this._autoStartAttempted = false;
|
|
84
|
+
this._preselectedRobotId = readPreselectedRobotIdFromUrl();
|
|
85
|
+
}
|
|
86
|
+
// ─── Read-only properties ────────────────────────────────────────────
|
|
87
|
+
get state() { return this._state; }
|
|
88
|
+
get robots() { return this._robots; }
|
|
89
|
+
get robotState() { return this._robotState; }
|
|
90
|
+
get username() { return this._username; }
|
|
91
|
+
get isAuthenticated() { return !!this._token; }
|
|
92
|
+
get micSupported() { return this._micSupported; }
|
|
93
|
+
get micMuted() { return this._micMuted; }
|
|
94
|
+
get audioMuted() { return this._audioMuted; }
|
|
95
|
+
get preselectedRobotId() { return this._preselectedRobotId; }
|
|
96
|
+
get isEmbedded() { return this._preselectedRobotId !== null; }
|
|
97
|
+
/**
|
|
98
|
+
* Internal: try to honour the `autoStartFromUrl` constructor
|
|
99
|
+
* option. Called from the signaling-message handler after every
|
|
100
|
+
* `robotsChanged` emit, so a robot that comes online after the
|
|
101
|
+
* SDK is already `connected` still triggers the auto-start.
|
|
102
|
+
*/
|
|
103
|
+
_maybeAutoStart() {
|
|
104
|
+
if (!this._autoStartFromUrl)
|
|
105
|
+
return;
|
|
106
|
+
if (this._autoStartAttempted)
|
|
107
|
+
return;
|
|
108
|
+
if (!this._preselectedRobotId)
|
|
109
|
+
return;
|
|
110
|
+
if (this._state !== 'connected')
|
|
111
|
+
return;
|
|
112
|
+
const match = this._robots.find((r) => r.id === this._preselectedRobotId);
|
|
113
|
+
if (!match)
|
|
114
|
+
return;
|
|
115
|
+
this._autoStartAttempted = true;
|
|
116
|
+
const peerId = this._preselectedRobotId;
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
if (this._state !== 'connected')
|
|
119
|
+
return;
|
|
120
|
+
this.startSession(peerId).catch((err) => {
|
|
121
|
+
console.warn('[reachy-mini] autoStartFromUrl: startSession rejected:', err);
|
|
122
|
+
});
|
|
123
|
+
}, 0);
|
|
124
|
+
}
|
|
125
|
+
// ─── Auth ────────────────────────────────────────────────────────────
|
|
126
|
+
async authenticate() {
|
|
127
|
+
try {
|
|
128
|
+
consumeFragmentCredentials();
|
|
129
|
+
const result = (await oauthHandleRedirectIfPresent());
|
|
130
|
+
if (result) {
|
|
131
|
+
this._username = result.userInfo.preferred_username || result.userInfo.name || null;
|
|
132
|
+
this._token = result.accessToken;
|
|
133
|
+
this._tokenExpires = result.accessTokenExpiresAt;
|
|
134
|
+
sessionStorage.setItem('hf_token', this._token);
|
|
135
|
+
sessionStorage.setItem('hf_username', this._username ?? '');
|
|
136
|
+
sessionStorage.setItem('hf_token_expires', typeof this._tokenExpires === 'string'
|
|
137
|
+
? this._tokenExpires
|
|
138
|
+
: this._tokenExpires.toISOString());
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
const t = sessionStorage.getItem('hf_token');
|
|
142
|
+
const u = sessionStorage.getItem('hf_username');
|
|
143
|
+
const e = sessionStorage.getItem('hf_token_expires');
|
|
144
|
+
if (t && u && e && new Date(e) > new Date()) {
|
|
145
|
+
this._token = t;
|
|
146
|
+
this._username = u;
|
|
147
|
+
this._tokenExpires = e;
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
console.error('Auth error:', e);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async login() {
|
|
158
|
+
const opts = {};
|
|
159
|
+
if (this._clientId)
|
|
160
|
+
opts.clientId = this._clientId;
|
|
161
|
+
window.location.href = await oauthLoginUrl(opts);
|
|
162
|
+
}
|
|
163
|
+
logout() {
|
|
164
|
+
sessionStorage.removeItem('hf_token');
|
|
165
|
+
sessionStorage.removeItem('hf_username');
|
|
166
|
+
sessionStorage.removeItem('hf_token_expires');
|
|
167
|
+
this._username = null;
|
|
168
|
+
this._tokenExpires = null;
|
|
169
|
+
this.disconnect();
|
|
170
|
+
}
|
|
171
|
+
// ─── Lifecycle ───────────────────────────────────────────────────────
|
|
172
|
+
async connect(token) {
|
|
173
|
+
if (this._state !== 'disconnected')
|
|
174
|
+
throw new Error('Already connected');
|
|
175
|
+
if (token)
|
|
176
|
+
this._token = token;
|
|
177
|
+
if (!this._token)
|
|
178
|
+
throw new Error('No token — call authenticate() first or pass a token');
|
|
179
|
+
this._sseAbortController = new AbortController();
|
|
180
|
+
let res;
|
|
181
|
+
try {
|
|
182
|
+
res = await fetch(`${this._signalingUrl}/events`, {
|
|
183
|
+
signal: this._sseAbortController.signal,
|
|
184
|
+
headers: { 'Authorization': `Bearer ${this._token}` },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
this._sseAbortController = null;
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
if (!res.ok) {
|
|
192
|
+
this._sseAbortController = null;
|
|
193
|
+
throw new Error(`HTTP ${res.status}`);
|
|
194
|
+
}
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
let welcomed = false;
|
|
197
|
+
const reader = res.body.getReader();
|
|
198
|
+
const decoder = new TextDecoder();
|
|
199
|
+
let buffer = '';
|
|
200
|
+
const readLoop = async () => {
|
|
201
|
+
try {
|
|
202
|
+
while (true) {
|
|
203
|
+
const { done, value } = await reader.read();
|
|
204
|
+
if (done)
|
|
205
|
+
break;
|
|
206
|
+
buffer += decoder.decode(value, { stream: true });
|
|
207
|
+
const lines = buffer.split('\n');
|
|
208
|
+
buffer = lines.pop() ?? '';
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
if (!line.startsWith('data:'))
|
|
211
|
+
continue;
|
|
212
|
+
try {
|
|
213
|
+
const msg = JSON.parse(line.slice(5).trim());
|
|
214
|
+
if (!welcomed && msg.type === 'welcome' && msg.peerId) {
|
|
215
|
+
welcomed = true;
|
|
216
|
+
this._state = 'connected';
|
|
217
|
+
await this._sendToServer({
|
|
218
|
+
type: 'setPeerStatus',
|
|
219
|
+
roles: ['listener'],
|
|
220
|
+
meta: { name: this._appName },
|
|
221
|
+
});
|
|
222
|
+
this._emit('connected', { peerId: msg.peerId });
|
|
223
|
+
resolve();
|
|
224
|
+
}
|
|
225
|
+
this._handleSignalingMessage(msg);
|
|
226
|
+
}
|
|
227
|
+
catch { /* malformed JSON — skip */ }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
if (e.name !== 'AbortError') {
|
|
233
|
+
this._emit('error', { source: 'signaling', error: e });
|
|
234
|
+
}
|
|
235
|
+
if (!welcomed) {
|
|
236
|
+
reject(e);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (this._state !== 'disconnected') {
|
|
241
|
+
this._state = 'disconnected';
|
|
242
|
+
this._emit('disconnected', { reason: 'SSE closed' });
|
|
243
|
+
}
|
|
244
|
+
if (!welcomed)
|
|
245
|
+
reject(new Error('Connection closed before welcome'));
|
|
246
|
+
};
|
|
247
|
+
readLoop();
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
async autoConnect(options = {}) {
|
|
251
|
+
const { token, pickRobot, autoPickIfSingle = true, filterBusy = true, wakeOnConnect = true, } = options;
|
|
252
|
+
if (this._state === 'streaming') {
|
|
253
|
+
const cur = this._robots?.find((r) => r.id === this._selectedRobotId);
|
|
254
|
+
return {
|
|
255
|
+
robotId: this._selectedRobotId,
|
|
256
|
+
robotName: cur?.meta?.name ?? null,
|
|
257
|
+
isEmbedded: this.isEmbedded,
|
|
258
|
+
alreadyStreaming: true,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const _prevAutoStartFromUrl = this._autoStartFromUrl;
|
|
262
|
+
this._autoStartFromUrl = false;
|
|
263
|
+
try {
|
|
264
|
+
if (token) {
|
|
265
|
+
this._token = token;
|
|
266
|
+
}
|
|
267
|
+
else if (!this._token) {
|
|
268
|
+
const ok = await this.authenticate();
|
|
269
|
+
if (!ok) {
|
|
270
|
+
throw new Error('Not authenticated — call login() or pass a token');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (this._state === 'disconnected') {
|
|
274
|
+
await this.connect();
|
|
275
|
+
}
|
|
276
|
+
let robotId;
|
|
277
|
+
let robotName = null;
|
|
278
|
+
if (this.isEmbedded) {
|
|
279
|
+
robotId = this._preselectedRobotId;
|
|
280
|
+
try {
|
|
281
|
+
await this._waitForRobotInList(robotId, 5000);
|
|
282
|
+
}
|
|
283
|
+
catch { /* fall through */ }
|
|
284
|
+
const found = this._robots?.find((r) => r.id === robotId);
|
|
285
|
+
robotName = found?.meta?.name ?? null;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
const robots = await this._fetchOwnedRobots({ filterBusy });
|
|
289
|
+
if (robots.length === 0) {
|
|
290
|
+
throw new Error('No reachable robots');
|
|
291
|
+
}
|
|
292
|
+
if (autoPickIfSingle && robots.length === 1 && !robots[0].busy) {
|
|
293
|
+
robotId = robots[0].id;
|
|
294
|
+
robotName = robots[0].name;
|
|
295
|
+
}
|
|
296
|
+
else if (pickRobot) {
|
|
297
|
+
const picked = await pickRobot(robots);
|
|
298
|
+
if (!picked)
|
|
299
|
+
throw new Error('Robot selection cancelled');
|
|
300
|
+
robotId = picked;
|
|
301
|
+
robotName = robots.find((r) => r.id === picked)?.name ?? null;
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
throw new Error('Multiple robots available — pass a pickRobot callback to autoConnect()');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
await this.startSession(robotId);
|
|
308
|
+
if (wakeOnConnect && typeof this.ensureAwake === 'function') {
|
|
309
|
+
try {
|
|
310
|
+
await this.ensureAwake();
|
|
311
|
+
}
|
|
312
|
+
catch (e) {
|
|
313
|
+
console.warn('[reachy-mini] autoConnect: ensureAwake failed:', e);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { robotId, robotName, isEmbedded: this.isEmbedded };
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
this._autoStartFromUrl = _prevAutoStartFromUrl;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async _fetchOwnedRobots({ filterBusy = true } = {}) {
|
|
323
|
+
try {
|
|
324
|
+
const res = await fetch(`${this._signalingUrl}/api/robot-status`, {
|
|
325
|
+
headers: { 'Authorization': `Bearer ${this._token}` },
|
|
326
|
+
});
|
|
327
|
+
if (!res.ok)
|
|
328
|
+
throw new Error(`HTTP ${res.status}`);
|
|
329
|
+
const json = await res.json();
|
|
330
|
+
const seen = new Map();
|
|
331
|
+
for (const r of (json.robots || [])) {
|
|
332
|
+
if (filterBusy && r.busy)
|
|
333
|
+
continue;
|
|
334
|
+
const key = r.meta?.install_id ?? r.meta?.hardware_id ?? r.peerId;
|
|
335
|
+
seen.set(key, {
|
|
336
|
+
id: r.peerId,
|
|
337
|
+
name: r.robotName ?? r.meta?.name ?? null,
|
|
338
|
+
busy: !!r.busy,
|
|
339
|
+
activeApp: r.activeApp ?? null,
|
|
340
|
+
meta: (r.meta ?? {}),
|
|
341
|
+
lastSeenAgeSeconds: r.last_seen_age_seconds ?? null,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return Array.from(seen.values()).sort((a, b) => (a.lastSeenAgeSeconds ?? Infinity) - (b.lastSeenAgeSeconds ?? Infinity));
|
|
345
|
+
}
|
|
346
|
+
catch (e) {
|
|
347
|
+
console.warn('[reachy-mini] /api/robot-status unavailable, using SSE list:', e);
|
|
348
|
+
return (this._robots || []).map((r) => ({
|
|
349
|
+
id: r.id,
|
|
350
|
+
name: r.meta?.name ?? null,
|
|
351
|
+
busy: false,
|
|
352
|
+
activeApp: null,
|
|
353
|
+
meta: (r.meta ?? {}),
|
|
354
|
+
lastSeenAgeSeconds: null,
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
_waitForRobotInList(robotId, timeoutMs) {
|
|
359
|
+
if (this._robots?.find((r) => r.id === robotId))
|
|
360
|
+
return Promise.resolve();
|
|
361
|
+
return new Promise((resolve, reject) => {
|
|
362
|
+
const onChange = () => {
|
|
363
|
+
if (this._robots?.find((r) => r.id === robotId)) {
|
|
364
|
+
this.removeEventListener('robotsChanged', onChange);
|
|
365
|
+
clearTimeout(timeoutId);
|
|
366
|
+
resolve();
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
const timeoutId = setTimeout(() => {
|
|
370
|
+
this.removeEventListener('robotsChanged', onChange);
|
|
371
|
+
reject(new Error(`Timeout waiting for robot ${robotId} in list`));
|
|
372
|
+
}, timeoutMs);
|
|
373
|
+
this.addEventListener('robotsChanged', onChange);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
async startSession(robotId) {
|
|
377
|
+
if (this._state !== 'connected')
|
|
378
|
+
throw new Error('Not connected');
|
|
379
|
+
this._selectedRobotId = robotId;
|
|
380
|
+
this._iceConnected = false;
|
|
381
|
+
this._dcOpen = false;
|
|
382
|
+
this._micSupported = false;
|
|
383
|
+
this._pendingRemoteIce = [];
|
|
384
|
+
// Silent placeholder audio track for the WebRTC audio sender.
|
|
385
|
+
// The SDK does NOT call navigator.mediaDevices.getUserMedia — the
|
|
386
|
+
// user's microphone is the app's responsibility. WebRTC needs a
|
|
387
|
+
// sendrecv audio sender for robot-speaker output to work, so we
|
|
388
|
+
// always set up a 0-gain oscillator → MediaStreamDestination as
|
|
389
|
+
// the initial track. Apps that want to send actual audio (TTS,
|
|
390
|
+
// prerecorded files, the user's mic for teleop, …) do so by
|
|
391
|
+
// calling sender.replaceTrack() on the audio sender exposed via
|
|
392
|
+
// this._pc after the `streaming` event fires.
|
|
393
|
+
try {
|
|
394
|
+
// Safari (and the iOS WKWebView Tauri ships on) exposes
|
|
395
|
+
// AudioContext only under the `webkitAudioContext` prefix.
|
|
396
|
+
// Narrow once, locally, so we don't sprinkle vendor casts
|
|
397
|
+
// through the code.
|
|
398
|
+
const w = window;
|
|
399
|
+
const Ctx = w.AudioContext ?? w.webkitAudioContext;
|
|
400
|
+
if (!Ctx)
|
|
401
|
+
throw new Error('AudioContext not supported');
|
|
402
|
+
const ctx = new Ctx();
|
|
403
|
+
const dst = ctx.createMediaStreamDestination();
|
|
404
|
+
const osc = ctx.createOscillator();
|
|
405
|
+
const gain = ctx.createGain();
|
|
406
|
+
gain.gain.value = 0;
|
|
407
|
+
osc.connect(gain).connect(dst);
|
|
408
|
+
osc.start();
|
|
409
|
+
const stream = dst.stream;
|
|
410
|
+
stream.getAudioTracks().forEach((t) => { t.enabled = false; });
|
|
411
|
+
this._micStream = stream;
|
|
412
|
+
this._micMuted = true;
|
|
413
|
+
}
|
|
414
|
+
catch (e) {
|
|
415
|
+
console.warn('Audio sender placeholder setup failed:', e);
|
|
416
|
+
this._micStream = null;
|
|
417
|
+
}
|
|
418
|
+
this._pc = new RTCPeerConnection({
|
|
419
|
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
|
420
|
+
});
|
|
421
|
+
return new Promise((resolve, reject) => {
|
|
422
|
+
this._sessionResolve = resolve;
|
|
423
|
+
this._sessionReject = reject;
|
|
424
|
+
this._pc.ontrack = (e) => {
|
|
425
|
+
if (e.track.kind === 'video') {
|
|
426
|
+
const ms = this._videoJitterBufferTargetMs;
|
|
427
|
+
try {
|
|
428
|
+
e.receiver
|
|
429
|
+
.jitterBufferTarget = ms;
|
|
430
|
+
}
|
|
431
|
+
catch { /* ignore */ }
|
|
432
|
+
try {
|
|
433
|
+
e.receiver
|
|
434
|
+
.playoutDelayHint = ms / 1000;
|
|
435
|
+
}
|
|
436
|
+
catch { /* ignore */ }
|
|
437
|
+
this._emit('videoTrack', { track: e.track, stream: e.streams[0] });
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
this._pc.onicecandidate = async (e) => {
|
|
441
|
+
if (e.candidate && this._sessionId) {
|
|
442
|
+
await this._sendToServer({
|
|
443
|
+
type: 'peer',
|
|
444
|
+
sessionId: this._sessionId,
|
|
445
|
+
ice: {
|
|
446
|
+
candidate: e.candidate.candidate,
|
|
447
|
+
sdpMLineIndex: e.candidate.sdpMLineIndex,
|
|
448
|
+
sdpMid: e.candidate.sdpMid,
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
this._pc.oniceconnectionstatechange = () => {
|
|
454
|
+
const s = this._pc?.iceConnectionState;
|
|
455
|
+
if (!s)
|
|
456
|
+
return;
|
|
457
|
+
if (s === 'connected' || s === 'completed') {
|
|
458
|
+
this._iceConnected = true;
|
|
459
|
+
this._checkSessionReady();
|
|
460
|
+
}
|
|
461
|
+
else if (s === 'failed') {
|
|
462
|
+
const err = new Error('ICE connection failed');
|
|
463
|
+
if (this._sessionReject) {
|
|
464
|
+
this._sessionReject(err);
|
|
465
|
+
this._sessionResolve = null;
|
|
466
|
+
this._sessionReject = null;
|
|
467
|
+
}
|
|
468
|
+
this._emit('error', { source: 'webrtc', error: err });
|
|
469
|
+
}
|
|
470
|
+
else if (s === 'disconnected') {
|
|
471
|
+
this._emit('error', { source: 'webrtc', error: new Error('ICE disconnected') });
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
this._pc.ondatachannel = (e) => {
|
|
475
|
+
this._dc = e.channel;
|
|
476
|
+
this._dc.onopen = () => {
|
|
477
|
+
this._dcOpen = true;
|
|
478
|
+
this._checkSessionReady();
|
|
479
|
+
};
|
|
480
|
+
this._dc.onmessage = (ev) => this._handleRobotMessage(JSON.parse(ev.data));
|
|
481
|
+
};
|
|
482
|
+
this._sendToServer({ type: 'startSession', peerId: robotId }).then((r) => {
|
|
483
|
+
if (r?.type === 'sessionRejected') {
|
|
484
|
+
this._failSessionRejected(r);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (r?.sessionId)
|
|
488
|
+
this._sessionId = r.sessionId;
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
_failSessionRejected(msg) {
|
|
493
|
+
const err = new Error(msg.reason === 'robot_busy'
|
|
494
|
+
? `Robot is busy: "${msg.activeApp || 'another app'}" is already connected`
|
|
495
|
+
: `Session rejected: ${msg.reason || 'unknown reason'}`);
|
|
496
|
+
err.reason = msg.reason ?? null;
|
|
497
|
+
err.activeApp = msg.activeApp ?? null;
|
|
498
|
+
if (this._pc) {
|
|
499
|
+
this._pc.close();
|
|
500
|
+
this._pc = null;
|
|
501
|
+
}
|
|
502
|
+
if (this._micStream) {
|
|
503
|
+
this._micStream.getTracks().forEach((t) => t.stop());
|
|
504
|
+
this._micStream = null;
|
|
505
|
+
}
|
|
506
|
+
this._iceConnected = false;
|
|
507
|
+
this._dcOpen = false;
|
|
508
|
+
this._micMuted = true;
|
|
509
|
+
this._micSupported = false;
|
|
510
|
+
this._emit('sessionRejected', { reason: msg.reason, activeApp: msg.activeApp });
|
|
511
|
+
if (this._sessionReject) {
|
|
512
|
+
const reject = this._sessionReject;
|
|
513
|
+
this._sessionResolve = null;
|
|
514
|
+
this._sessionReject = null;
|
|
515
|
+
reject(err);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
async stopSession() {
|
|
519
|
+
if (this._versionResolve) {
|
|
520
|
+
this._versionResolve(null);
|
|
521
|
+
this._versionResolve = null;
|
|
522
|
+
}
|
|
523
|
+
if (this._hardwareIdResolve) {
|
|
524
|
+
this._hardwareIdResolve(null);
|
|
525
|
+
this._hardwareIdResolve = null;
|
|
526
|
+
}
|
|
527
|
+
if (this._volumeResolve) {
|
|
528
|
+
this._volumeResolve(null);
|
|
529
|
+
this._volumeResolve = null;
|
|
530
|
+
}
|
|
531
|
+
if (this._micVolumeResolve) {
|
|
532
|
+
this._micVolumeResolve(null);
|
|
533
|
+
this._micVolumeResolve = null;
|
|
534
|
+
}
|
|
535
|
+
if (this._applyAudioConfigResolve) {
|
|
536
|
+
this._applyAudioConfigResolve(false);
|
|
537
|
+
this._applyAudioConfigResolve = null;
|
|
538
|
+
}
|
|
539
|
+
if (this._readAudioParameterResolve) {
|
|
540
|
+
this._readAudioParameterResolve(null);
|
|
541
|
+
this._readAudioParameterResolve = null;
|
|
542
|
+
}
|
|
543
|
+
this._logSubscribers.clear();
|
|
544
|
+
this._rejectPendingMotionCompletions(new Error('Session stopped'));
|
|
545
|
+
if (this._sessionReject) {
|
|
546
|
+
this._sessionReject(new Error('Session stopped'));
|
|
547
|
+
this._sessionResolve = null;
|
|
548
|
+
this._sessionReject = null;
|
|
549
|
+
}
|
|
550
|
+
if (this._stateRefreshInterval) {
|
|
551
|
+
clearInterval(this._stateRefreshInterval);
|
|
552
|
+
this._stateRefreshInterval = null;
|
|
553
|
+
}
|
|
554
|
+
if (this._latencyMonitorId) {
|
|
555
|
+
clearInterval(this._latencyMonitorId);
|
|
556
|
+
this._latencyMonitorId = null;
|
|
557
|
+
}
|
|
558
|
+
if (this._sessionId) {
|
|
559
|
+
await this._sendToServer({ type: 'endSession', sessionId: this._sessionId });
|
|
560
|
+
}
|
|
561
|
+
if (this._micStream) {
|
|
562
|
+
this._micStream.getTracks().forEach((t) => t.stop());
|
|
563
|
+
this._micStream = null;
|
|
564
|
+
}
|
|
565
|
+
this._micMuted = true;
|
|
566
|
+
this._micSupported = false;
|
|
567
|
+
if (this._pc) {
|
|
568
|
+
this._pc.close();
|
|
569
|
+
this._pc = null;
|
|
570
|
+
}
|
|
571
|
+
if (this._dc) {
|
|
572
|
+
this._dc.close();
|
|
573
|
+
this._dc = null;
|
|
574
|
+
}
|
|
575
|
+
this._sessionId = null;
|
|
576
|
+
this._iceConnected = false;
|
|
577
|
+
this._dcOpen = false;
|
|
578
|
+
const wasStreaming = this._state === 'streaming';
|
|
579
|
+
if (wasStreaming) {
|
|
580
|
+
this._state = 'connected';
|
|
581
|
+
this._emit('sessionStopped', { reason: 'user' });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
disconnect() {
|
|
585
|
+
if (this._sseAbortController) {
|
|
586
|
+
this._sseAbortController.abort();
|
|
587
|
+
this._sseAbortController = null;
|
|
588
|
+
}
|
|
589
|
+
if (this._versionResolve) {
|
|
590
|
+
this._versionResolve(null);
|
|
591
|
+
this._versionResolve = null;
|
|
592
|
+
}
|
|
593
|
+
if (this._hardwareIdResolve) {
|
|
594
|
+
this._hardwareIdResolve(null);
|
|
595
|
+
this._hardwareIdResolve = null;
|
|
596
|
+
}
|
|
597
|
+
if (this._volumeResolve) {
|
|
598
|
+
this._volumeResolve(null);
|
|
599
|
+
this._volumeResolve = null;
|
|
600
|
+
}
|
|
601
|
+
if (this._micVolumeResolve) {
|
|
602
|
+
this._micVolumeResolve(null);
|
|
603
|
+
this._micVolumeResolve = null;
|
|
604
|
+
}
|
|
605
|
+
if (this._applyAudioConfigResolve) {
|
|
606
|
+
this._applyAudioConfigResolve(false);
|
|
607
|
+
this._applyAudioConfigResolve = null;
|
|
608
|
+
}
|
|
609
|
+
if (this._readAudioParameterResolve) {
|
|
610
|
+
this._readAudioParameterResolve(null);
|
|
611
|
+
this._readAudioParameterResolve = null;
|
|
612
|
+
}
|
|
613
|
+
this._logSubscribers.clear();
|
|
614
|
+
this._rejectPendingMotionCompletions(new Error('Disconnected'));
|
|
615
|
+
if (this._sessionReject) {
|
|
616
|
+
this._sessionReject(new Error('Disconnected'));
|
|
617
|
+
this._sessionResolve = null;
|
|
618
|
+
this._sessionReject = null;
|
|
619
|
+
}
|
|
620
|
+
if (this._stateRefreshInterval) {
|
|
621
|
+
clearInterval(this._stateRefreshInterval);
|
|
622
|
+
this._stateRefreshInterval = null;
|
|
623
|
+
}
|
|
624
|
+
if (this._latencyMonitorId) {
|
|
625
|
+
clearInterval(this._latencyMonitorId);
|
|
626
|
+
this._latencyMonitorId = null;
|
|
627
|
+
}
|
|
628
|
+
if (this._sessionId && this._token) {
|
|
629
|
+
this._sendToServer({ type: 'endSession', sessionId: this._sessionId });
|
|
630
|
+
}
|
|
631
|
+
if (this._micStream) {
|
|
632
|
+
this._micStream.getTracks().forEach((t) => t.stop());
|
|
633
|
+
this._micStream = null;
|
|
634
|
+
}
|
|
635
|
+
if (this._pc) {
|
|
636
|
+
this._pc.close();
|
|
637
|
+
this._pc = null;
|
|
638
|
+
}
|
|
639
|
+
if (this._dc) {
|
|
640
|
+
this._dc.close();
|
|
641
|
+
this._dc = null;
|
|
642
|
+
}
|
|
643
|
+
this._sessionId = null;
|
|
644
|
+
this._micMuted = true;
|
|
645
|
+
this._micSupported = false;
|
|
646
|
+
this._iceConnected = false;
|
|
647
|
+
this._dcOpen = false;
|
|
648
|
+
this._robots = [];
|
|
649
|
+
this._state = 'disconnected';
|
|
650
|
+
this._emit('disconnected', { reason: 'user' });
|
|
651
|
+
}
|
|
652
|
+
// ─── Commands ────────────────────────────────────────────────────────
|
|
653
|
+
setTarget({ head, antennas, body_yaw } = {}) {
|
|
654
|
+
const cmd = { type: 'set_full_target' };
|
|
655
|
+
if (head !== undefined) {
|
|
656
|
+
if (!Array.isArray(head) || head.length !== 16
|
|
657
|
+
|| !head.every((n) => Number.isFinite(n))) {
|
|
658
|
+
throw new TypeError('setTarget: head must be a 16-element flat row-major 4×4 matrix '
|
|
659
|
+
+ `of finite numbers; got ${Array.isArray(head) ? `Array(${head.length})` : typeof head}`);
|
|
660
|
+
}
|
|
661
|
+
cmd.head = head;
|
|
662
|
+
}
|
|
663
|
+
if (antennas !== undefined) {
|
|
664
|
+
if (!Array.isArray(antennas) || antennas.length !== 2
|
|
665
|
+
|| !antennas.every((n) => Number.isFinite(n))) {
|
|
666
|
+
throw new TypeError('setTarget: antennas must be [rightRad, leftRad] (2 finite numbers); '
|
|
667
|
+
+ `got ${Array.isArray(antennas) ? `Array(${antennas.length})` : typeof antennas}`);
|
|
668
|
+
}
|
|
669
|
+
cmd.antennas = antennas;
|
|
670
|
+
}
|
|
671
|
+
if (body_yaw !== undefined) {
|
|
672
|
+
if (!Number.isFinite(body_yaw)) {
|
|
673
|
+
throw new TypeError(`setTarget: body_yaw must be a finite number (radians); got ${body_yaw}`);
|
|
674
|
+
}
|
|
675
|
+
cmd.body_yaw = body_yaw;
|
|
676
|
+
}
|
|
677
|
+
return this._sendCommand(cmd);
|
|
678
|
+
}
|
|
679
|
+
gotoTarget({ head, antennas, body_yaw, duration }) {
|
|
680
|
+
const cmd = { type: 'goto_target' };
|
|
681
|
+
if (head !== undefined) {
|
|
682
|
+
if (!Array.isArray(head) || head.length !== 16
|
|
683
|
+
|| !head.every((n) => Number.isFinite(n))) {
|
|
684
|
+
throw new TypeError('gotoTarget: head must be a 16-element flat row-major 4×4 matrix '
|
|
685
|
+
+ `of finite numbers; got ${Array.isArray(head) ? `Array(${head.length})` : typeof head}`);
|
|
686
|
+
}
|
|
687
|
+
cmd.head = head;
|
|
688
|
+
}
|
|
689
|
+
if (antennas !== undefined) {
|
|
690
|
+
if (!Array.isArray(antennas) || antennas.length !== 2
|
|
691
|
+
|| !antennas.every((n) => Number.isFinite(n))) {
|
|
692
|
+
throw new TypeError('gotoTarget: antennas must be [rightRad, leftRad] (2 finite numbers); '
|
|
693
|
+
+ `got ${Array.isArray(antennas) ? `Array(${antennas.length})` : typeof antennas}`);
|
|
694
|
+
}
|
|
695
|
+
cmd.antennas = antennas;
|
|
696
|
+
}
|
|
697
|
+
if (body_yaw !== undefined) {
|
|
698
|
+
if (!Number.isFinite(body_yaw)) {
|
|
699
|
+
throw new TypeError(`gotoTarget: body_yaw must be a finite number (radians); got ${body_yaw}`);
|
|
700
|
+
}
|
|
701
|
+
cmd.body_yaw = body_yaw;
|
|
702
|
+
}
|
|
703
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
704
|
+
throw new TypeError(`gotoTarget: duration must be a positive finite number (seconds); got ${duration}`);
|
|
705
|
+
}
|
|
706
|
+
cmd.duration = duration;
|
|
707
|
+
return this._sendCommand(cmd);
|
|
708
|
+
}
|
|
709
|
+
setHeadRpyDeg(rollDeg, pitchDeg, yawDeg) {
|
|
710
|
+
return this.setTarget({ head: rpyToMatrix(rollDeg, pitchDeg, yawDeg).flat() });
|
|
711
|
+
}
|
|
712
|
+
setAntennasDeg(rightDeg, leftDeg) {
|
|
713
|
+
return this.setTarget({ antennas: [degToRad(rightDeg), degToRad(leftDeg)] });
|
|
714
|
+
}
|
|
715
|
+
setBodyYawDeg(yawDeg) {
|
|
716
|
+
return this.setTarget({ body_yaw: degToRad(yawDeg) });
|
|
717
|
+
}
|
|
718
|
+
playSound(file) {
|
|
719
|
+
return this._sendCommand({ type: 'play_sound', file });
|
|
720
|
+
}
|
|
721
|
+
setMotorMode(mode) {
|
|
722
|
+
return this._sendCommand({ type: 'set_motor_mode', mode });
|
|
723
|
+
}
|
|
724
|
+
setMotorTorque(on, ids = null) {
|
|
725
|
+
return this._sendCommand({ type: 'set_torque', on, ids });
|
|
726
|
+
}
|
|
727
|
+
wakeUp({ timeoutMs = 8000 } = {}) {
|
|
728
|
+
this._sendCommand({ type: 'set_motor_mode', mode: 'enabled' });
|
|
729
|
+
return this._sendCommandAwaitCompletion('wake_up', timeoutMs);
|
|
730
|
+
}
|
|
731
|
+
gotoSleep({ timeoutMs = 8000 } = {}) {
|
|
732
|
+
return this._sendCommandAwaitCompletion('goto_sleep', timeoutMs);
|
|
733
|
+
}
|
|
734
|
+
_sendCommandAwaitCompletion(command, timeoutMs) {
|
|
735
|
+
if (!this._sendCommand({ type: command })) {
|
|
736
|
+
return Promise.reject(new Error(`${command}: data channel not open`));
|
|
737
|
+
}
|
|
738
|
+
return new Promise((resolve, reject) => {
|
|
739
|
+
const entry = {
|
|
740
|
+
resolve,
|
|
741
|
+
reject,
|
|
742
|
+
timer: setTimeout(() => {
|
|
743
|
+
const queue = this._pendingMotionCompletions[command];
|
|
744
|
+
const idx = queue.indexOf(entry);
|
|
745
|
+
if (idx !== -1)
|
|
746
|
+
queue.splice(idx, 1);
|
|
747
|
+
reject(new Error(`${command} timed out after ${timeoutMs}ms`));
|
|
748
|
+
}, timeoutMs),
|
|
749
|
+
};
|
|
750
|
+
this._pendingMotionCompletions[command].push(entry);
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
_rejectPendingMotionCompletions(error) {
|
|
754
|
+
for (const command of Object.keys(this._pendingMotionCompletions)) {
|
|
755
|
+
const queue = this._pendingMotionCompletions[command];
|
|
756
|
+
while (queue.length) {
|
|
757
|
+
const entry = queue.shift();
|
|
758
|
+
clearTimeout(entry.timer);
|
|
759
|
+
entry.reject(error);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
isAwake() {
|
|
764
|
+
const mode = this._robotState?.motor_mode;
|
|
765
|
+
return mode === 'enabled' || mode === 'gravity_compensation';
|
|
766
|
+
}
|
|
767
|
+
async ensureAwake(timeoutMs = 1000) {
|
|
768
|
+
if (this._robotState?.motor_mode === undefined) {
|
|
769
|
+
await new Promise((resolve) => {
|
|
770
|
+
const done = () => {
|
|
771
|
+
this.removeEventListener('state', done);
|
|
772
|
+
clearTimeout(timer);
|
|
773
|
+
resolve();
|
|
774
|
+
};
|
|
775
|
+
const timer = setTimeout(done, timeoutMs);
|
|
776
|
+
this.addEventListener('state', done);
|
|
777
|
+
this.requestState();
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
if (this.isAwake())
|
|
781
|
+
return true;
|
|
782
|
+
this.wakeUp().catch(() => { });
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
getVersion() {
|
|
786
|
+
return new Promise((resolve, reject) => {
|
|
787
|
+
if (!this._dc || this._dc.readyState !== 'open') {
|
|
788
|
+
reject(new Error('Data channel not open'));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (this._versionResolve) {
|
|
792
|
+
this._versionResolve(null);
|
|
793
|
+
}
|
|
794
|
+
this._versionResolve = resolve;
|
|
795
|
+
this._sendCommand({ type: 'get_version' });
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
getHardwareId() {
|
|
799
|
+
return new Promise((resolve, reject) => {
|
|
800
|
+
if (!this._dc || this._dc.readyState !== 'open') {
|
|
801
|
+
reject(new Error('Data channel not open'));
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (this._hardwareIdResolve) {
|
|
805
|
+
this._hardwareIdResolve(null);
|
|
806
|
+
}
|
|
807
|
+
this._hardwareIdResolve = resolve;
|
|
808
|
+
this._sendCommand({ type: 'get_hardware_id' });
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
getVolume() {
|
|
812
|
+
return this._slotRoundtrip(() => this._volumeResolve, (next) => { this._volumeResolve = next; }, { type: 'get_volume' });
|
|
813
|
+
}
|
|
814
|
+
setVolume(volume) {
|
|
815
|
+
return this._slotRoundtrip(() => this._volumeResolve, (next) => { this._volumeResolve = next; }, { type: 'set_volume', volume: clampVolume(volume) });
|
|
816
|
+
}
|
|
817
|
+
getMicrophoneVolume() {
|
|
818
|
+
return this._slotRoundtrip(() => this._micVolumeResolve, (next) => { this._micVolumeResolve = next; }, { type: 'get_microphone_volume' });
|
|
819
|
+
}
|
|
820
|
+
setMicrophoneVolume(volume) {
|
|
821
|
+
return this._slotRoundtrip(() => this._micVolumeResolve, (next) => { this._micVolumeResolve = next; }, { type: 'set_microphone_volume', volume: clampVolume(volume) });
|
|
822
|
+
}
|
|
823
|
+
applyAudioConfig(config, { verify = true } = {}) {
|
|
824
|
+
return this._slotRoundtrip(() => this._applyAudioConfigResolve, (next) => { this._applyAudioConfigResolve = next; }, { type: 'apply_audio_config', config, verify }).then((v) => v === true);
|
|
825
|
+
}
|
|
826
|
+
readAudioParameter(name) {
|
|
827
|
+
return this._slotRoundtrip(() => this._readAudioParameterResolve, (next) => { this._readAudioParameterResolve = next; }, { type: 'read_audio_parameter', name });
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Internal: send a command and await the matching daemon response in a
|
|
831
|
+
* named single-resolver slot. Used by the volume helpers and the
|
|
832
|
+
* XVF3800 audio-config helpers — every one of them has a strict
|
|
833
|
+
* request/response shape where a single in-flight call per slot is
|
|
834
|
+
* sufficient. If a previous request on the same slot is still
|
|
835
|
+
* pending when a new one comes in, the older promise is resolved to
|
|
836
|
+
* `null` so its caller doesn't hang forever.
|
|
837
|
+
*
|
|
838
|
+
* Slot access is passed in as getter/setter closures rather than a
|
|
839
|
+
* key into `this`: that keeps the helper fully generic-checked (T is
|
|
840
|
+
* inferred from the slot's resolver type at each call site) with no
|
|
841
|
+
* indexed-property casts.
|
|
842
|
+
*/
|
|
843
|
+
_slotRoundtrip(getSlot, setSlot, command) {
|
|
844
|
+
return new Promise((resolve, reject) => {
|
|
845
|
+
if (!this._dc || this._dc.readyState !== 'open') {
|
|
846
|
+
reject(new Error('Data channel not open'));
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const prev = getSlot();
|
|
850
|
+
if (prev)
|
|
851
|
+
prev(null);
|
|
852
|
+
setSlot(resolve);
|
|
853
|
+
this._sendCommand(command);
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
sendRaw(data) {
|
|
857
|
+
return this._sendCommand(data);
|
|
858
|
+
}
|
|
859
|
+
subscribeLogs({ onLine, onError }) {
|
|
860
|
+
if (typeof onLine !== 'function') {
|
|
861
|
+
throw new TypeError('subscribeLogs: onLine callback is required');
|
|
862
|
+
}
|
|
863
|
+
const sub = { onLine, onError };
|
|
864
|
+
const wasEmpty = this._logSubscribers.size === 0;
|
|
865
|
+
this._logSubscribers.add(sub);
|
|
866
|
+
if (wasEmpty)
|
|
867
|
+
this._sendCommand({ type: 'subscribe_logs' });
|
|
868
|
+
let detached = false;
|
|
869
|
+
return () => {
|
|
870
|
+
if (detached)
|
|
871
|
+
return;
|
|
872
|
+
detached = true;
|
|
873
|
+
this._logSubscribers.delete(sub);
|
|
874
|
+
if (this._logSubscribers.size === 0) {
|
|
875
|
+
this._sendCommand({ type: 'unsubscribe_logs' });
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
requestState() {
|
|
880
|
+
return this._sendCommand({ type: 'get_state' });
|
|
881
|
+
}
|
|
882
|
+
// ─── Audio ───────────────────────────────────────────────────────────
|
|
883
|
+
setAudioMuted(muted) {
|
|
884
|
+
this._audioMuted = muted;
|
|
885
|
+
if (this._videoElement)
|
|
886
|
+
this._videoElement.muted = muted;
|
|
887
|
+
}
|
|
888
|
+
setMicMuted(muted) {
|
|
889
|
+
this._micMuted = muted;
|
|
890
|
+
if (this._micStream) {
|
|
891
|
+
this._micStream.getAudioTracks().forEach((t) => { t.enabled = !muted; });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// ─── Video helper ────────────────────────────────────────────────────
|
|
895
|
+
attachVideo(videoElement) {
|
|
896
|
+
this._videoElement = videoElement;
|
|
897
|
+
videoElement.muted = this._audioMuted;
|
|
898
|
+
const onVideoTrack = (e) => {
|
|
899
|
+
const ev = e;
|
|
900
|
+
videoElement.srcObject = ev.detail.stream;
|
|
901
|
+
videoElement.playsInline = true;
|
|
902
|
+
if ('requestVideoFrameCallback' in videoElement) {
|
|
903
|
+
this._startLatencyMonitor(videoElement);
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
const onSessionStopped = () => { videoElement.srcObject = null; };
|
|
907
|
+
this.addEventListener('videoTrack', onVideoTrack);
|
|
908
|
+
this.addEventListener('sessionStopped', onSessionStopped);
|
|
909
|
+
return () => {
|
|
910
|
+
this.removeEventListener('videoTrack', onVideoTrack);
|
|
911
|
+
this.removeEventListener('sessionStopped', onSessionStopped);
|
|
912
|
+
if (this._latencyMonitorId) {
|
|
913
|
+
clearInterval(this._latencyMonitorId);
|
|
914
|
+
this._latencyMonitorId = null;
|
|
915
|
+
}
|
|
916
|
+
videoElement.srcObject = null;
|
|
917
|
+
this._videoElement = null;
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
// ─── Daemon-side recorded-move playback ──────────────────────────────
|
|
921
|
+
async playMove(motion, { audioBlob = null, audioLeadMs = -100, description = 'move', encoding = 'gzip+base64', playFrequency = 100, initialGotoDuration = 0, startTimeoutMs = 8000, onProgress = () => { }, onStarted = () => { }, } = {}) {
|
|
922
|
+
if (!this._dc || this._dc.readyState !== 'open') {
|
|
923
|
+
throw new Error('data channel not open');
|
|
924
|
+
}
|
|
925
|
+
if (!motion?.time?.length || !motion?.set_target_data?.length) {
|
|
926
|
+
throw new Error('playMove: motion must have time + set_target_data');
|
|
927
|
+
}
|
|
928
|
+
const uploadId = makeUploadId();
|
|
929
|
+
this._activeMoveUploadId = uploadId;
|
|
930
|
+
const moveDict = {
|
|
931
|
+
description,
|
|
932
|
+
time: motion.time,
|
|
933
|
+
set_target_data: motion.set_target_data,
|
|
934
|
+
};
|
|
935
|
+
const jsonStr = JSON.stringify(moveDict);
|
|
936
|
+
let payload;
|
|
937
|
+
let effectiveEncoding;
|
|
938
|
+
if (encoding === 'gzip+base64' && hasCompressionStream()) {
|
|
939
|
+
payload = await gzipBase64(jsonStr);
|
|
940
|
+
effectiveEncoding = 'gzip+base64';
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
payload = jsonStr;
|
|
944
|
+
effectiveEncoding = 'json';
|
|
945
|
+
}
|
|
946
|
+
const totalChunks = Math.ceil(payload.length / UPLOAD_CHUNK_SIZE) || 1;
|
|
947
|
+
onProgress({
|
|
948
|
+
phase: 'starting',
|
|
949
|
+
sent: 0,
|
|
950
|
+
total: totalChunks,
|
|
951
|
+
bytes: payload.length,
|
|
952
|
+
encoding: effectiveEncoding,
|
|
953
|
+
});
|
|
954
|
+
this._sendCommand({
|
|
955
|
+
type: 'upload_move_start',
|
|
956
|
+
upload_id: uploadId,
|
|
957
|
+
total_chunks: totalChunks,
|
|
958
|
+
description,
|
|
959
|
+
encoding: effectiveEncoding,
|
|
960
|
+
});
|
|
961
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
962
|
+
if (this._dc.bufferedAmount > UPLOAD_BUFFERED_HIGH_WATER) {
|
|
963
|
+
await this._awaitDataChannelDrain();
|
|
964
|
+
}
|
|
965
|
+
const start = i * UPLOAD_CHUNK_SIZE;
|
|
966
|
+
this._sendCommand({
|
|
967
|
+
type: 'upload_move_chunk',
|
|
968
|
+
upload_id: uploadId,
|
|
969
|
+
chunk_index: i,
|
|
970
|
+
chunk: payload.slice(start, start + UPLOAD_CHUNK_SIZE),
|
|
971
|
+
});
|
|
972
|
+
onProgress({ phase: 'upload', sent: i + 1, total: totalChunks });
|
|
973
|
+
}
|
|
974
|
+
this._sendCommand({ type: 'upload_move_finish', upload_id: uploadId });
|
|
975
|
+
onProgress({ phase: 'uploaded', sent: totalChunks, total: totalChunks });
|
|
976
|
+
if (audioBlob) {
|
|
977
|
+
const rawBytes = new Uint8Array(await audioBlob.arrayBuffer());
|
|
978
|
+
const audioB64 = bytesToBase64(rawBytes);
|
|
979
|
+
const audioTotal = Math.ceil(audioB64.length / UPLOAD_CHUNK_SIZE) || 1;
|
|
980
|
+
onProgress({
|
|
981
|
+
phase: 'audio-starting',
|
|
982
|
+
sent: 0,
|
|
983
|
+
total: audioTotal,
|
|
984
|
+
bytes: audioB64.length,
|
|
985
|
+
});
|
|
986
|
+
this._sendCommand({
|
|
987
|
+
type: 'upload_audio_start',
|
|
988
|
+
upload_id: uploadId,
|
|
989
|
+
total_chunks: audioTotal,
|
|
990
|
+
encoding: 'wav-base64',
|
|
991
|
+
description,
|
|
992
|
+
});
|
|
993
|
+
for (let i = 0; i < audioTotal; i++) {
|
|
994
|
+
if (this._dc.bufferedAmount > UPLOAD_BUFFERED_HIGH_WATER) {
|
|
995
|
+
await this._awaitDataChannelDrain();
|
|
996
|
+
}
|
|
997
|
+
const start = i * UPLOAD_CHUNK_SIZE;
|
|
998
|
+
this._sendCommand({
|
|
999
|
+
type: 'upload_audio_chunk',
|
|
1000
|
+
upload_id: uploadId,
|
|
1001
|
+
chunk_index: i,
|
|
1002
|
+
chunk: audioB64.slice(start, start + UPLOAD_CHUNK_SIZE),
|
|
1003
|
+
});
|
|
1004
|
+
onProgress({ phase: 'audio-upload', sent: i + 1, total: audioTotal });
|
|
1005
|
+
}
|
|
1006
|
+
this._sendCommand({ type: 'upload_audio_finish', upload_id: uploadId });
|
|
1007
|
+
onProgress({ phase: 'audio-uploaded', sent: audioTotal, total: audioTotal });
|
|
1008
|
+
}
|
|
1009
|
+
this._sendCommand({
|
|
1010
|
+
type: 'play_uploaded_move',
|
|
1011
|
+
upload_id: uploadId,
|
|
1012
|
+
play_frequency: playFrequency,
|
|
1013
|
+
initial_goto_duration: initialGotoDuration,
|
|
1014
|
+
audio_lead_ms: audioLeadMs,
|
|
1015
|
+
});
|
|
1016
|
+
let startedAck;
|
|
1017
|
+
try {
|
|
1018
|
+
startedAck = await this._waitForBroadcast((m) => m?.type === 'play_uploaded_move'
|
|
1019
|
+
&& m?.upload_id === uploadId
|
|
1020
|
+
&& (m.started === true || typeof m.error === 'string'), { timeoutMs: startTimeoutMs, debugLabel: 'play_uploaded_move started' });
|
|
1021
|
+
}
|
|
1022
|
+
catch (e) {
|
|
1023
|
+
throw new Error('Daemon did not respond to play_uploaded_move '
|
|
1024
|
+
+ '(requires the reachy_mini daemon with feature/daemon-side-move-upload). '
|
|
1025
|
+
+ `Underlying: ${e.message}`);
|
|
1026
|
+
}
|
|
1027
|
+
if (typeof startedAck.error === 'string') {
|
|
1028
|
+
throw new Error(`play_uploaded_move: ${startedAck.error}`);
|
|
1029
|
+
}
|
|
1030
|
+
try {
|
|
1031
|
+
onStarted({
|
|
1032
|
+
duration_s: startedAck.duration_s,
|
|
1033
|
+
has_audio: startedAck.has_audio === true,
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
catch (e) {
|
|
1037
|
+
console.warn('playMove.onStarted threw:', e);
|
|
1038
|
+
}
|
|
1039
|
+
onProgress({ phase: 'playing', duration_s: startedAck.duration_s });
|
|
1040
|
+
const final = await this._waitForBroadcast((m) => m?.type === 'play_uploaded_move'
|
|
1041
|
+
&& m?.upload_id === uploadId
|
|
1042
|
+
&& (m.finished === true
|
|
1043
|
+
|| m.cancelled === true
|
|
1044
|
+
|| typeof m.error === 'string'), {
|
|
1045
|
+
timeoutMs: (startedAck.duration_s + 30) * 1000,
|
|
1046
|
+
debugLabel: 'play_uploaded_move final',
|
|
1047
|
+
});
|
|
1048
|
+
if (this._activeMoveUploadId === uploadId) {
|
|
1049
|
+
this._activeMoveUploadId = null;
|
|
1050
|
+
}
|
|
1051
|
+
return final;
|
|
1052
|
+
}
|
|
1053
|
+
cancelMove(uploadId = null) {
|
|
1054
|
+
const id = uploadId ?? this._activeMoveUploadId;
|
|
1055
|
+
if (!id)
|
|
1056
|
+
return false;
|
|
1057
|
+
return this._sendCommand({ type: 'cancel_move', upload_id: id });
|
|
1058
|
+
}
|
|
1059
|
+
async uploadAudio(audioBlob, { description = 'audio', onProgress = () => { } } = {}) {
|
|
1060
|
+
if (!this._dc || this._dc.readyState !== 'open') {
|
|
1061
|
+
throw new Error('data channel not open');
|
|
1062
|
+
}
|
|
1063
|
+
if (!(audioBlob instanceof Blob)) {
|
|
1064
|
+
throw new TypeError('uploadAudio: expected a Blob');
|
|
1065
|
+
}
|
|
1066
|
+
const uploadId = makeUploadId();
|
|
1067
|
+
const rawBytes = new Uint8Array(await audioBlob.arrayBuffer());
|
|
1068
|
+
const audioB64 = bytesToBase64(rawBytes);
|
|
1069
|
+
const total = Math.ceil(audioB64.length / UPLOAD_CHUNK_SIZE) || 1;
|
|
1070
|
+
onProgress({ phase: 'audio-starting', sent: 0, total, bytes: audioB64.length });
|
|
1071
|
+
this._sendCommand({
|
|
1072
|
+
type: 'upload_audio_start',
|
|
1073
|
+
upload_id: uploadId,
|
|
1074
|
+
total_chunks: total,
|
|
1075
|
+
encoding: 'wav-base64',
|
|
1076
|
+
description,
|
|
1077
|
+
});
|
|
1078
|
+
for (let i = 0; i < total; i++) {
|
|
1079
|
+
if (this._dc.bufferedAmount > UPLOAD_BUFFERED_HIGH_WATER) {
|
|
1080
|
+
await this._awaitDataChannelDrain();
|
|
1081
|
+
}
|
|
1082
|
+
const start = i * UPLOAD_CHUNK_SIZE;
|
|
1083
|
+
this._sendCommand({
|
|
1084
|
+
type: 'upload_audio_chunk',
|
|
1085
|
+
upload_id: uploadId,
|
|
1086
|
+
chunk_index: i,
|
|
1087
|
+
chunk: audioB64.slice(start, start + UPLOAD_CHUNK_SIZE),
|
|
1088
|
+
});
|
|
1089
|
+
onProgress({ phase: 'audio-upload', sent: i + 1, total });
|
|
1090
|
+
}
|
|
1091
|
+
this._sendCommand({ type: 'upload_audio_finish', upload_id: uploadId });
|
|
1092
|
+
onProgress({ phase: 'audio-uploaded', sent: total, total });
|
|
1093
|
+
return uploadId;
|
|
1094
|
+
}
|
|
1095
|
+
async playUploadedAudio(uploadId, { timeoutMs = 8000 } = {}) {
|
|
1096
|
+
if (!this._dc || this._dc.readyState !== 'open') {
|
|
1097
|
+
throw new Error('data channel not open');
|
|
1098
|
+
}
|
|
1099
|
+
const waiter = this._waitForBroadcast((m) => m?.type === 'play_uploaded_audio'
|
|
1100
|
+
&& m?.upload_id === uploadId
|
|
1101
|
+
&& (m.started === true || typeof m.error === 'string'), { timeoutMs, debugLabel: 'play_uploaded_audio started' });
|
|
1102
|
+
this._sendCommand({ type: 'play_uploaded_audio', upload_id: uploadId });
|
|
1103
|
+
const ack = await waiter;
|
|
1104
|
+
if (typeof ack.error === 'string')
|
|
1105
|
+
throw new Error(ack.error);
|
|
1106
|
+
this._activeAudioUploadId = uploadId;
|
|
1107
|
+
return ack;
|
|
1108
|
+
}
|
|
1109
|
+
cancelAudio(uploadId = null) {
|
|
1110
|
+
const id = uploadId ?? this._activeAudioUploadId;
|
|
1111
|
+
if (!id)
|
|
1112
|
+
return false;
|
|
1113
|
+
if (this._activeAudioUploadId === id) {
|
|
1114
|
+
this._activeAudioUploadId = null;
|
|
1115
|
+
}
|
|
1116
|
+
return this._sendCommand({ type: 'cancel_audio', upload_id: id });
|
|
1117
|
+
}
|
|
1118
|
+
// ─── Private ─────────────────────────────────────────────────────────
|
|
1119
|
+
_emit(name, detail) {
|
|
1120
|
+
this.dispatchEvent(new CustomEvent(name, { detail }));
|
|
1121
|
+
}
|
|
1122
|
+
_waitForBroadcast(predicate, { timeoutMs = 5000, debugLabel = '' } = {}) {
|
|
1123
|
+
return new Promise((resolve, reject) => {
|
|
1124
|
+
const slot = {
|
|
1125
|
+
predicate,
|
|
1126
|
+
resolve,
|
|
1127
|
+
timer: setTimeout(() => {
|
|
1128
|
+
const i = this._broadcastWaiters.indexOf(slot);
|
|
1129
|
+
if (i !== -1)
|
|
1130
|
+
this._broadcastWaiters.splice(i, 1);
|
|
1131
|
+
reject(new Error(`broadcast timeout (${timeoutMs} ms): ${debugLabel}`));
|
|
1132
|
+
}, timeoutMs),
|
|
1133
|
+
};
|
|
1134
|
+
this._broadcastWaiters.push(slot);
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
async _awaitDataChannelDrain() {
|
|
1138
|
+
while (this._dc && this._dc.bufferedAmount > UPLOAD_BUFFERED_LOW_WATER) {
|
|
1139
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
1140
|
+
if (!this._dc || this._dc.readyState !== 'open') {
|
|
1141
|
+
throw new Error('data channel closed mid-upload');
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
async _sendToServer(message) {
|
|
1146
|
+
if (!this._token)
|
|
1147
|
+
throw new Error('No token — authenticate() first');
|
|
1148
|
+
try {
|
|
1149
|
+
const res = await fetch(`${this._signalingUrl}/send`, {
|
|
1150
|
+
method: 'POST',
|
|
1151
|
+
headers: {
|
|
1152
|
+
'Content-Type': 'application/json',
|
|
1153
|
+
'Authorization': `Bearer ${this._token}`,
|
|
1154
|
+
},
|
|
1155
|
+
body: JSON.stringify(message),
|
|
1156
|
+
});
|
|
1157
|
+
if (!res.ok) {
|
|
1158
|
+
let body = '';
|
|
1159
|
+
try {
|
|
1160
|
+
body = await res.text();
|
|
1161
|
+
}
|
|
1162
|
+
catch { /* ignore */ }
|
|
1163
|
+
console.warn(`[reachy-mini] /send rejected (${res.status}) for type=${message?.type}; body=${body || '<empty>'}`);
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
return await res.json();
|
|
1167
|
+
}
|
|
1168
|
+
catch (e) {
|
|
1169
|
+
console.error('Send error:', e);
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
_sendCommand(cmd) {
|
|
1174
|
+
if (!this._dc || this._dc.readyState !== 'open')
|
|
1175
|
+
return false;
|
|
1176
|
+
this._dc.send(JSON.stringify(cmd));
|
|
1177
|
+
return true;
|
|
1178
|
+
}
|
|
1179
|
+
_checkSessionReady() {
|
|
1180
|
+
if (this._iceConnected && this._dcOpen && this._sessionResolve) {
|
|
1181
|
+
this._state = 'streaming';
|
|
1182
|
+
this.requestState();
|
|
1183
|
+
this._stateRefreshInterval = setInterval(() => this.requestState(), 500);
|
|
1184
|
+
this._emit('streaming', { sessionId: this._sessionId, robotId: this._selectedRobotId });
|
|
1185
|
+
this._sessionResolve();
|
|
1186
|
+
this._sessionResolve = null;
|
|
1187
|
+
this._sessionReject = null;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
async _handleSignalingMessage(msg) {
|
|
1191
|
+
switch (msg.type) {
|
|
1192
|
+
case 'welcome':
|
|
1193
|
+
break;
|
|
1194
|
+
case 'list':
|
|
1195
|
+
this._robots = msg.producers || [];
|
|
1196
|
+
this._emit('robotsChanged', { robots: this._robots });
|
|
1197
|
+
this._maybeAutoStart();
|
|
1198
|
+
break;
|
|
1199
|
+
case 'peerStatusChanged': {
|
|
1200
|
+
const list = await this._sendToServer({ type: 'list' });
|
|
1201
|
+
if (list?.producers) {
|
|
1202
|
+
this._robots = list.producers;
|
|
1203
|
+
this._emit('robotsChanged', { robots: this._robots });
|
|
1204
|
+
this._maybeAutoStart();
|
|
1205
|
+
}
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
case 'sessionStarted':
|
|
1209
|
+
this._sessionId = msg.sessionId ?? null;
|
|
1210
|
+
break;
|
|
1211
|
+
case 'sessionRejected':
|
|
1212
|
+
this._failSessionRejected(msg);
|
|
1213
|
+
break;
|
|
1214
|
+
case 'endSession':
|
|
1215
|
+
this._handleEndSession(msg);
|
|
1216
|
+
break;
|
|
1217
|
+
case 'peer':
|
|
1218
|
+
this._handlePeerMessage(msg);
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
_handleEndSession(msg) {
|
|
1223
|
+
const reason = msg.reason;
|
|
1224
|
+
const friendly = reason === 'robot_busy_local_app'
|
|
1225
|
+
? 'Robot is busy: a local Python app is running'
|
|
1226
|
+
: reason === 'local_app_started'
|
|
1227
|
+
? 'Disconnected: a local Python app started on the robot'
|
|
1228
|
+
: reason === 'robot_busy_local'
|
|
1229
|
+
? 'Robot is busy: another session is already active'
|
|
1230
|
+
: null;
|
|
1231
|
+
if (this._sessionReject) {
|
|
1232
|
+
const err = new Error(friendly || `Session ended before it could start: ${reason || 'unknown reason'}`);
|
|
1233
|
+
err.reason = reason ?? null;
|
|
1234
|
+
this._emit('sessionRejected', { reason, activeApp: null });
|
|
1235
|
+
if (this._pc) {
|
|
1236
|
+
this._pc.close();
|
|
1237
|
+
this._pc = null;
|
|
1238
|
+
}
|
|
1239
|
+
if (this._micStream) {
|
|
1240
|
+
this._micStream.getTracks().forEach((t) => t.stop());
|
|
1241
|
+
this._micStream = null;
|
|
1242
|
+
}
|
|
1243
|
+
this._iceConnected = false;
|
|
1244
|
+
this._dcOpen = false;
|
|
1245
|
+
this._micMuted = true;
|
|
1246
|
+
this._micSupported = false;
|
|
1247
|
+
const reject = this._sessionReject;
|
|
1248
|
+
this._sessionResolve = null;
|
|
1249
|
+
this._sessionReject = null;
|
|
1250
|
+
reject(err);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
if (this._state === 'streaming') {
|
|
1254
|
+
this._emit('sessionStopped', {
|
|
1255
|
+
reason: reason || 'remote_end',
|
|
1256
|
+
message: friendly,
|
|
1257
|
+
});
|
|
1258
|
+
this.stopSession().catch(() => { });
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
async _handlePeerMessage(msg) {
|
|
1262
|
+
if (!this._pc)
|
|
1263
|
+
return;
|
|
1264
|
+
try {
|
|
1265
|
+
if (msg.sdp) {
|
|
1266
|
+
const sdp = msg.sdp;
|
|
1267
|
+
if (sdp.type === 'offer') {
|
|
1268
|
+
const supportsMic = sdpHasAudioSendRecv(sdp.sdp);
|
|
1269
|
+
this._micSupported = supportsMic;
|
|
1270
|
+
this._emit('micSupported', { supported: supportsMic });
|
|
1271
|
+
if (supportsMic && this._micStream) {
|
|
1272
|
+
for (const track of this._micStream.getAudioTracks()) {
|
|
1273
|
+
this._pc.addTrack(track, this._micStream);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
await this._pc.setRemoteDescription(new RTCSessionDescription(sdp));
|
|
1277
|
+
const answer = await this._pc.createAnswer();
|
|
1278
|
+
await this._pc.setLocalDescription(answer);
|
|
1279
|
+
await this._sendToServer({
|
|
1280
|
+
type: 'peer',
|
|
1281
|
+
sessionId: this._sessionId,
|
|
1282
|
+
sdp: { type: 'answer', sdp: answer.sdp },
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
else {
|
|
1286
|
+
await this._pc.setRemoteDescription(new RTCSessionDescription(sdp));
|
|
1287
|
+
}
|
|
1288
|
+
const pending = this._pendingRemoteIce;
|
|
1289
|
+
if (pending && pending.length) {
|
|
1290
|
+
this._pendingRemoteIce = [];
|
|
1291
|
+
for (const ice of pending) {
|
|
1292
|
+
try {
|
|
1293
|
+
await this._pc.addIceCandidate(new RTCIceCandidate(ice));
|
|
1294
|
+
}
|
|
1295
|
+
catch (err) {
|
|
1296
|
+
console.warn('[reachy-mini] buffered ICE candidate rejected:', err);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
if (msg.ice) {
|
|
1302
|
+
// Safari (and the iOS WKWebView Tauri ships on) rejects
|
|
1303
|
+
// empty candidate strings with `OperationError: Expect
|
|
1304
|
+
// line: candidate:<candidate-str>`. The signaling
|
|
1305
|
+
// server uses an empty string as the end-of-candidates
|
|
1306
|
+
// marker (legal per the WebRTC spec but optional).
|
|
1307
|
+
if (!msg.ice.candidate)
|
|
1308
|
+
return;
|
|
1309
|
+
if (this._pc.remoteDescription) {
|
|
1310
|
+
await this._pc.addIceCandidate(new RTCIceCandidate(msg.ice));
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
if (!this._pendingRemoteIce)
|
|
1314
|
+
this._pendingRemoteIce = [];
|
|
1315
|
+
this._pendingRemoteIce.push(msg.ice);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
catch (e) {
|
|
1320
|
+
console.error('WebRTC error:', e);
|
|
1321
|
+
this._emit('error', { source: 'webrtc', error: e });
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
_handleRobotMessage(data) {
|
|
1325
|
+
if ('version' in data && this._versionResolve) {
|
|
1326
|
+
this._versionResolve(data.version);
|
|
1327
|
+
this._versionResolve = null;
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
if ('hardware_id' in data && this._hardwareIdResolve) {
|
|
1331
|
+
this._hardwareIdResolve(data.hardware_id);
|
|
1332
|
+
this._hardwareIdResolve = null;
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (data.command === 'get_volume' || data.command === 'set_volume') {
|
|
1336
|
+
if (this._volumeResolve) {
|
|
1337
|
+
this._volumeResolve(data.status === 'error' ? null : data.volume);
|
|
1338
|
+
this._volumeResolve = null;
|
|
1339
|
+
}
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (data.command === 'get_microphone_volume' || data.command === 'set_microphone_volume') {
|
|
1343
|
+
if (this._micVolumeResolve) {
|
|
1344
|
+
this._micVolumeResolve(data.status === 'error' ? null : data.volume);
|
|
1345
|
+
this._micVolumeResolve = null;
|
|
1346
|
+
}
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
if (data.command === 'apply_audio_config') {
|
|
1350
|
+
if (this._applyAudioConfigResolve) {
|
|
1351
|
+
this._applyAudioConfigResolve(data.error ? false : !!data.applied);
|
|
1352
|
+
this._applyAudioConfigResolve = null;
|
|
1353
|
+
}
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (data.command === 'read_audio_parameter') {
|
|
1357
|
+
if (this._readAudioParameterResolve) {
|
|
1358
|
+
this._readAudioParameterResolve(data.error ? null : (data.values ?? null));
|
|
1359
|
+
this._readAudioParameterResolve = null;
|
|
1360
|
+
}
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
if ((data.command === 'wake_up' || data.command === 'goto_sleep')
|
|
1364
|
+
&& this._pendingMotionCompletions
|
|
1365
|
+
&& this._pendingMotionCompletions[data.command]) {
|
|
1366
|
+
const queue = this._pendingMotionCompletions[data.command];
|
|
1367
|
+
if (data.completed === true && queue.length > 0) {
|
|
1368
|
+
const entry = queue.shift();
|
|
1369
|
+
clearTimeout(entry.timer);
|
|
1370
|
+
entry.resolve();
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
if (data.error && queue.length > 0) {
|
|
1374
|
+
const entry = queue.shift();
|
|
1375
|
+
clearTimeout(entry.timer);
|
|
1376
|
+
entry.reject(new Error(`${data.command}: ${data.error}`));
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (data.type === 'log_line') {
|
|
1381
|
+
for (const sub of this._logSubscribers) {
|
|
1382
|
+
try {
|
|
1383
|
+
sub.onLine({ timestamp: data.timestamp, line: data.line });
|
|
1384
|
+
}
|
|
1385
|
+
catch (e) {
|
|
1386
|
+
console.error('subscribeLogs onLine threw:', e);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
if (data.type === 'log_stream_error') {
|
|
1392
|
+
for (const sub of this._logSubscribers) {
|
|
1393
|
+
if (typeof sub.onError === 'function') {
|
|
1394
|
+
try {
|
|
1395
|
+
sub.onError(data.error);
|
|
1396
|
+
}
|
|
1397
|
+
catch (e) {
|
|
1398
|
+
console.error('subscribeLogs onError threw:', e);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
if (data.state) {
|
|
1405
|
+
const s = data.state;
|
|
1406
|
+
if (s.head_pose)
|
|
1407
|
+
this._robotState.head = s.head_pose.flat();
|
|
1408
|
+
if (s.antennas)
|
|
1409
|
+
this._robotState.antennas = [s.antennas[0], s.antennas[1]];
|
|
1410
|
+
if (typeof s.body_yaw === 'number')
|
|
1411
|
+
this._robotState.body_yaw = s.body_yaw;
|
|
1412
|
+
if (s.motor_mode)
|
|
1413
|
+
this._robotState.motor_mode = s.motor_mode;
|
|
1414
|
+
if (typeof s.is_move_running === 'boolean')
|
|
1415
|
+
this._robotState.is_move_running = s.is_move_running;
|
|
1416
|
+
this._emit('state', { ...this._robotState });
|
|
1417
|
+
}
|
|
1418
|
+
if (data.error) {
|
|
1419
|
+
this._emit('error', { source: 'robot', error: data.error });
|
|
1420
|
+
}
|
|
1421
|
+
if (this._broadcastWaiters.length > 0) {
|
|
1422
|
+
for (let i = this._broadcastWaiters.length - 1; i >= 0; i--) {
|
|
1423
|
+
const slot = this._broadcastWaiters[i];
|
|
1424
|
+
if (slot.predicate(data)) {
|
|
1425
|
+
this._broadcastWaiters.splice(i, 1);
|
|
1426
|
+
clearTimeout(slot.timer);
|
|
1427
|
+
slot.resolve(data);
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
/** Snap video playback to live edge if buffered lag exceeds 0.5 s. */
|
|
1434
|
+
_startLatencyMonitor(video) {
|
|
1435
|
+
if (this._latencyMonitorId)
|
|
1436
|
+
clearInterval(this._latencyMonitorId);
|
|
1437
|
+
this._latencyMonitorId = setInterval(() => {
|
|
1438
|
+
if (!video.srcObject || video.paused)
|
|
1439
|
+
return;
|
|
1440
|
+
const buf = video.buffered;
|
|
1441
|
+
if (buf.length > 0) {
|
|
1442
|
+
const end = buf.end(buf.length - 1);
|
|
1443
|
+
const lag = end - video.currentTime;
|
|
1444
|
+
if (lag > 0.5) {
|
|
1445
|
+
console.log(`Latency correction: was ${lag.toFixed(2)}s behind`);
|
|
1446
|
+
video.currentTime = end - 0.1;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}, 2000);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
//# sourceMappingURL=reachy-mini.js.map
|