@pollen-robotics/reachy-mini-sdk 1.7.3-main.7457b2b
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/LICENSE +201 -0
- package/README.md +129 -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-C3B3FuBC.js +402 -0
- package/host/dist/chunks/index-C3B3FuBC.js.map +1 -0
- package/host/dist/chunks/mountHost-8f-laxwI.js +48280 -0
- package/host/dist/chunks/mountHost-8f-laxwI.js.map +1 -0
- package/host/dist/chunks/reachy-mini-sdk-T3Zr7Hwc.js +1726 -0
- package/host/dist/chunks/reachy-mini-sdk-T3Zr7Hwc.js.map +1 -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 +150 -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 +7 -0
- package/host/dist/entry/auto.js.map +1 -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 +7 -0
- package/host/dist/entry/embed.js.map +1 -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 +15 -0
- package/host/dist/index.js.map +1 -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 +48 -0
- package/host/dist/lib/protocol.js.map +1 -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 +109 -0
- package/reachy-mini-sdk.d.ts +285 -0
- package/reachy-mini-sdk.js +2625 -0
|
@@ -0,0 +1,1726 @@
|
|
|
1
|
+
var D = Object.defineProperty, $ = (r, e, t) => e in r ? D(r, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : r[e] = t, k = (r, e, t) => ($(r, typeof e != "symbol" ? e + "" : e, t), t), L = "https://huggingface.co";
|
|
2
|
+
async function C(r, e) {
|
|
3
|
+
var t, s;
|
|
4
|
+
const i = new P(r.url, r.status, (t = r.headers.get("X-Request-Id")) != null ? t : void 0);
|
|
5
|
+
i.message = `Api error with status ${i.statusCode}`;
|
|
6
|
+
const o = [`URL: ${i.url}`, i.requestId ? `Request ID: ${i.requestId}` : void 0].filter(Boolean).join(". ");
|
|
7
|
+
if ((s = r.headers.get("Content-Type")) != null && s.startsWith("application/json")) {
|
|
8
|
+
const n = await r.json();
|
|
9
|
+
i.message = n.error || n.message || i.message, i.data = n;
|
|
10
|
+
} else
|
|
11
|
+
i.data = { message: await r.text() };
|
|
12
|
+
throw i.message += `. ${o}`, i;
|
|
13
|
+
}
|
|
14
|
+
var P = class extends Error {
|
|
15
|
+
constructor(r, e, t, s) {
|
|
16
|
+
super(s), k(this, "statusCode"), k(this, "url"), k(this, "requestId"), k(this, "data"), this.statusCode = e, this.requestId = t, this.url = r;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
new Promise((r) => {
|
|
20
|
+
});
|
|
21
|
+
function x(r) {
|
|
22
|
+
if (globalThis.Buffer)
|
|
23
|
+
return globalThis.Buffer.from(r).toString("base64");
|
|
24
|
+
{
|
|
25
|
+
const e = [];
|
|
26
|
+
return r.forEach((t) => {
|
|
27
|
+
e.push(String.fromCharCode(t));
|
|
28
|
+
}), globalThis.btoa(e.join(""));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function M(r) {
|
|
32
|
+
var e, t;
|
|
33
|
+
if (typeof window > "u")
|
|
34
|
+
throw new Error("oauthHandleRedirect is only available in the browser");
|
|
35
|
+
const s = new URLSearchParams(window.location.search), [i, o] = [s.get("error"), s.get("error_description")];
|
|
36
|
+
if (i)
|
|
37
|
+
throw new Error(`${i}: ${o}`);
|
|
38
|
+
const n = s.get("code"), a = localStorage.getItem("huggingface.co:oauth:nonce");
|
|
39
|
+
if (!n)
|
|
40
|
+
throw new Error("Missing oauth code from query parameters in redirected URL");
|
|
41
|
+
if (!a)
|
|
42
|
+
throw new Error("Missing oauth nonce from localStorage");
|
|
43
|
+
const c = localStorage.getItem("huggingface.co:oauth:code_verifier");
|
|
44
|
+
if (!c)
|
|
45
|
+
throw new Error("Missing oauth code_verifier from localStorage");
|
|
46
|
+
const u = s.get("state");
|
|
47
|
+
if (!u)
|
|
48
|
+
throw new Error("Missing oauth state from query parameters in redirected URL");
|
|
49
|
+
let d;
|
|
50
|
+
try {
|
|
51
|
+
d = JSON.parse(u);
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error("Invalid oauth state in redirected URL, unable to parse JSON: " + u);
|
|
54
|
+
}
|
|
55
|
+
if (d.nonce !== a)
|
|
56
|
+
throw new Error("Invalid oauth state in redirected URL");
|
|
57
|
+
const l = L, p = `${new URL(l).origin}/.well-known/openid-configuration`, f = await fetch(p, {
|
|
58
|
+
headers: {
|
|
59
|
+
Accept: "application/json"
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
if (!f.ok)
|
|
63
|
+
throw await C(f);
|
|
64
|
+
const m = await f.json(), b = await fetch(m.token_endpoint, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
68
|
+
},
|
|
69
|
+
body: new URLSearchParams({
|
|
70
|
+
grant_type: "authorization_code",
|
|
71
|
+
code: n,
|
|
72
|
+
redirect_uri: d.redirectUri,
|
|
73
|
+
code_verifier: c
|
|
74
|
+
}).toString()
|
|
75
|
+
});
|
|
76
|
+
if (localStorage.removeItem("huggingface.co:oauth:code_verifier"), localStorage.removeItem("huggingface.co:oauth:nonce"), !b.ok)
|
|
77
|
+
throw await C(b);
|
|
78
|
+
const w = await b.json(), g = new Date(Date.now() + w.expires_in * 1e3), R = await fetch(m.userinfo_endpoint, {
|
|
79
|
+
headers: {
|
|
80
|
+
Authorization: `Bearer ${w.access_token}`
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
if (!R.ok)
|
|
84
|
+
throw await C(R);
|
|
85
|
+
const h = await R.json();
|
|
86
|
+
return {
|
|
87
|
+
accessToken: w.access_token,
|
|
88
|
+
accessTokenExpiresAt: g,
|
|
89
|
+
userInfo: {
|
|
90
|
+
id: h.sub,
|
|
91
|
+
name: h.name,
|
|
92
|
+
fullname: h.preferred_username,
|
|
93
|
+
email: h.email,
|
|
94
|
+
emailVerified: h.email_verified,
|
|
95
|
+
avatarUrl: h.picture,
|
|
96
|
+
websiteUrl: h.website,
|
|
97
|
+
isPro: h.isPro,
|
|
98
|
+
orgs: (t = (e = h.orgs) == null ? void 0 : e.map((_) => ({
|
|
99
|
+
id: _.sub,
|
|
100
|
+
name: _.name,
|
|
101
|
+
fullname: _.name,
|
|
102
|
+
isEnterprise: _.isEnterprise,
|
|
103
|
+
canPay: _.canPay,
|
|
104
|
+
avatarUrl: _.picture,
|
|
105
|
+
roleInOrg: _.roleInOrg
|
|
106
|
+
}))) != null ? t : []
|
|
107
|
+
},
|
|
108
|
+
state: d.state,
|
|
109
|
+
scope: w.scope
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
async function O(r) {
|
|
113
|
+
if (typeof window > "u")
|
|
114
|
+
throw new Error("oauthHandleRedirect is only available in the browser");
|
|
115
|
+
const e = new URLSearchParams(window.location.search);
|
|
116
|
+
return e.has("error") ? M() : e.has("code") ? localStorage.getItem("huggingface.co:oauth:nonce") ? M() : (console.warn(
|
|
117
|
+
"Missing oauth nonce from localStorage. This can happen when the user refreshes the page after logging in, without changing the URL."
|
|
118
|
+
), !1) : !1;
|
|
119
|
+
}
|
|
120
|
+
async function N(r) {
|
|
121
|
+
var e, t;
|
|
122
|
+
if (typeof window > "u")
|
|
123
|
+
throw new Error("oauthLogin is only available in the browser");
|
|
124
|
+
const s = r?.hubUrl || L, i = `${new URL(s).origin}/.well-known/openid-configuration`, o = await fetch(i, {
|
|
125
|
+
headers: {
|
|
126
|
+
Accept: "application/json"
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
if (!o.ok)
|
|
130
|
+
throw await C(o);
|
|
131
|
+
const n = await o.json(), a = globalThis.crypto.randomUUID(), c = globalThis.crypto.randomUUID() + globalThis.crypto.randomUUID();
|
|
132
|
+
localStorage.setItem("huggingface.co:oauth:nonce", a), localStorage.setItem("huggingface.co:oauth:code_verifier", c);
|
|
133
|
+
const u = r?.redirectUrl || window.location.href, d = JSON.stringify({
|
|
134
|
+
nonce: a,
|
|
135
|
+
redirectUri: u,
|
|
136
|
+
state: r?.state
|
|
137
|
+
}), l = (t = (e = window?.huggingface) == null ? void 0 : e.variables) != null ? t : null, p = r?.clientId || l?.OAUTH_CLIENT_ID;
|
|
138
|
+
if (!p)
|
|
139
|
+
throw l ? new Error("Missing clientId, please add hf_oauth: true to the README.md's metadata in your static Space") : new Error("Missing clientId");
|
|
140
|
+
const f = x(
|
|
141
|
+
new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(c)))
|
|
142
|
+
).replace(/[+]/g, "-").replace(/[/]/g, "_").replace(/=/g, "");
|
|
143
|
+
return `${n.authorization_endpoint}?${new URLSearchParams({
|
|
144
|
+
client_id: p,
|
|
145
|
+
scope: r?.scopes || l?.OAUTH_SCOPES || "openid profile",
|
|
146
|
+
response_type: "code",
|
|
147
|
+
redirect_uri: u,
|
|
148
|
+
state: d,
|
|
149
|
+
code_challenge: f,
|
|
150
|
+
code_challenge_method: "S256"
|
|
151
|
+
}).toString()}`;
|
|
152
|
+
}
|
|
153
|
+
function S(r) {
|
|
154
|
+
return r * Math.PI / 180;
|
|
155
|
+
}
|
|
156
|
+
function F(r, e, t) {
|
|
157
|
+
const s = S(r), i = S(e), o = S(t), n = Math.cos(o), a = Math.sin(o), c = Math.cos(i), u = Math.sin(i), d = Math.cos(s), l = Math.sin(s);
|
|
158
|
+
return [
|
|
159
|
+
[n * c, n * u * l - a * d, n * u * d + a * l, 0],
|
|
160
|
+
[a * c, a * u * l + n * d, a * u * d - n * l, 0],
|
|
161
|
+
[-u, c * l, c * d, 0],
|
|
162
|
+
[0, 0, 0, 1]
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
function U(r) {
|
|
166
|
+
const e = Math.round(Number(r) || 0);
|
|
167
|
+
return Math.max(0, Math.min(100, e));
|
|
168
|
+
}
|
|
169
|
+
function B() {
|
|
170
|
+
if (typeof window > "u" || !window.location.hash) return;
|
|
171
|
+
const r = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : window.location.hash;
|
|
172
|
+
let e;
|
|
173
|
+
try {
|
|
174
|
+
e = new URLSearchParams(r);
|
|
175
|
+
} catch {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const t = e.get("hf_token");
|
|
179
|
+
if (!t) return;
|
|
180
|
+
const s = e.get("hf_username") || "user", i = e.get("hf_token_expires"), o = i && !Number.isNaN(new Date(i).getTime()) ? i : new Date(Date.now() + 365 * 24 * 60 * 60 * 1e3).toISOString();
|
|
181
|
+
try {
|
|
182
|
+
sessionStorage.setItem("hf_token", t), sessionStorage.setItem("hf_username", s), sessionStorage.setItem("hf_token_expires", o);
|
|
183
|
+
} catch (c) {
|
|
184
|
+
console.warn("[reachy-mini] could not persist pre-seeded HF credentials:", c);
|
|
185
|
+
}
|
|
186
|
+
e.delete("hf_token"), e.delete("hf_username"), e.delete("hf_token_expires");
|
|
187
|
+
const n = e.toString(), a = window.location.pathname + window.location.search + (n ? "#" + n : "");
|
|
188
|
+
try {
|
|
189
|
+
window.history.replaceState(null, "", a);
|
|
190
|
+
} catch {
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function V() {
|
|
194
|
+
if (typeof window > "u") return null;
|
|
195
|
+
if (window.location.hash) {
|
|
196
|
+
const r = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : window.location.hash;
|
|
197
|
+
try {
|
|
198
|
+
const t = new URLSearchParams(r).get("robot_peer_id");
|
|
199
|
+
if (t) return t;
|
|
200
|
+
} catch {
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (window.location.search)
|
|
204
|
+
try {
|
|
205
|
+
const e = new URLSearchParams(window.location.search).get("robot_peer_id");
|
|
206
|
+
if (e) return e;
|
|
207
|
+
} catch {
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
function q(r) {
|
|
212
|
+
const e = r.split(`\r
|
|
213
|
+
`);
|
|
214
|
+
let t = !1;
|
|
215
|
+
for (const s of e)
|
|
216
|
+
if (s.startsWith("m=audio") ? t = !0 : s.startsWith("m=") && (t = !1), t && s === "a=sendrecv") return !0;
|
|
217
|
+
return !1;
|
|
218
|
+
}
|
|
219
|
+
const y = 12 * 1024, E = 1 * 1024 * 1024, H = 512 * 1024;
|
|
220
|
+
function W() {
|
|
221
|
+
return typeof CompressionStream < "u";
|
|
222
|
+
}
|
|
223
|
+
function j() {
|
|
224
|
+
return "u" + Math.random().toString(36).slice(2, 11) + Date.now().toString(36);
|
|
225
|
+
}
|
|
226
|
+
function A(r) {
|
|
227
|
+
let e = "";
|
|
228
|
+
for (let s = 0; s < r.length; s += 32768)
|
|
229
|
+
e += String.fromCharCode.apply(null, r.subarray(s, s + 32768));
|
|
230
|
+
return btoa(e);
|
|
231
|
+
}
|
|
232
|
+
async function z(r) {
|
|
233
|
+
const e = new TextEncoder().encode(r), t = await new Response(
|
|
234
|
+
new Blob([e]).stream().pipeThrough(new CompressionStream("gzip"))
|
|
235
|
+
).arrayBuffer();
|
|
236
|
+
return A(new Uint8Array(t));
|
|
237
|
+
}
|
|
238
|
+
class J extends EventTarget {
|
|
239
|
+
/** @param {{ signalingUrl?: string, enableMicrophone?: boolean, clientId?: string, appName?: string, videoJitterBufferTargetMs?: number, autoStartFromUrl?: boolean }} [options] */
|
|
240
|
+
constructor(e = {}) {
|
|
241
|
+
super(), this._signalingUrl = e.signalingUrl || "https://pollen-robotics-reachy-mini-central.hf.space", this._enableMicrophone = e.enableMicrophone !== !1, this._clientId = e.clientId || null, this._appName = e.appName || "unknown", this._videoJitterBufferTargetMs = e.videoJitterBufferTargetMs ?? 0, this._autoStartFromUrl = e.autoStartFromUrl === !0, this._autoStartAttempted = !1, this._state = "disconnected", this._robots = [], this._robotState = {}, this._preselectedRobotId = V(), this._token = null, this._username = null, this._tokenExpires = null, this._peerId = null, this._sseAbortController = null, this._pc = null, this._dc = null, this._sessionId = null, this._selectedRobotId = null, this._micStream = null, this._micMuted = !0, this._audioMuted = !0, this._micSupported = !1, this._latencyMonitorId = null, this._stateRefreshInterval = null, this._versionResolve = null, this._hardwareIdResolve = null, this._volumeResolve = null, this._micVolumeResolve = null, this._logSubscribers = /* @__PURE__ */ new Set(), this._broadcastWaiters = [], this._activeMoveUploadId = null, this._activeAudioUploadId = null, this._sessionResolve = null, this._sessionReject = null, this._iceConnected = !1, this._dcOpen = !1, this._pendingMotionCompletions = {
|
|
242
|
+
wake_up: [],
|
|
243
|
+
goto_sleep: []
|
|
244
|
+
}, this._videoElement = null;
|
|
245
|
+
}
|
|
246
|
+
// ─── Read-only properties ────────────────────────────────────────────
|
|
247
|
+
/** @returns {"disconnected"|"connected"|"streaming"} */
|
|
248
|
+
get state() {
|
|
249
|
+
return this._state;
|
|
250
|
+
}
|
|
251
|
+
/** @returns {Array<{id: string, meta: {name: string}}>} */
|
|
252
|
+
get robots() {
|
|
253
|
+
return this._robots;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Latest robot state (same shape as the "state" event detail).
|
|
257
|
+
* Mirrors the daemon's wire format — fields appear only once the
|
|
258
|
+
* daemon has sent the corresponding source field. Use the exported
|
|
259
|
+
* math utilities (``matrixToRpy``, ``radToDeg``) for human units.
|
|
260
|
+
* @returns {{
|
|
261
|
+
* head?: number[],
|
|
262
|
+
* antennas?: number[],
|
|
263
|
+
* body_yaw?: number,
|
|
264
|
+
* motor_mode?: "enabled"|"disabled"|"gravity_compensation",
|
|
265
|
+
* is_move_running?: boolean,
|
|
266
|
+
* }}
|
|
267
|
+
*/
|
|
268
|
+
get robotState() {
|
|
269
|
+
return this._robotState;
|
|
270
|
+
}
|
|
271
|
+
/** @returns {string|null} HuggingFace username, set after authenticate(). */
|
|
272
|
+
get username() {
|
|
273
|
+
return this._username;
|
|
274
|
+
}
|
|
275
|
+
/** @returns {boolean} True if a valid HF token is available. */
|
|
276
|
+
get isAuthenticated() {
|
|
277
|
+
return !!this._token;
|
|
278
|
+
}
|
|
279
|
+
/** @returns {boolean} True if the robot's SDP offered bidirectional audio. */
|
|
280
|
+
get micSupported() {
|
|
281
|
+
return this._micSupported;
|
|
282
|
+
}
|
|
283
|
+
/** @returns {boolean} */
|
|
284
|
+
get micMuted() {
|
|
285
|
+
return this._micMuted;
|
|
286
|
+
}
|
|
287
|
+
/** @returns {boolean} */
|
|
288
|
+
get audioMuted() {
|
|
289
|
+
return this._audioMuted;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Peer id of the robot the embedding host wants this session to
|
|
293
|
+
* target, captured from the URL at construction time. Apps that
|
|
294
|
+
* want to support iframe-embedding without forcing the user to
|
|
295
|
+
* re-pick a robot read this and pass it straight to
|
|
296
|
+
* `startSession()` once `connect()` resolves:
|
|
297
|
+
*
|
|
298
|
+
* await robot.connect();
|
|
299
|
+
* await robot.startSession(robot.preselectedRobotId ?? pickedId);
|
|
300
|
+
*
|
|
301
|
+
* Returns `null` when the URL carries no `robot_peer_id` (typical
|
|
302
|
+
* standalone Space load). The value is also exposed on the
|
|
303
|
+
* "robotsChanged" payload via the `meta` sidecar for
|
|
304
|
+
* convenience, but the most direct read is right here.
|
|
305
|
+
*
|
|
306
|
+
* @returns {string|null}
|
|
307
|
+
*/
|
|
308
|
+
get preselectedRobotId() {
|
|
309
|
+
return this._preselectedRobotId;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Convenience flag for apps that want to branch their UX on
|
|
313
|
+
* "am I embedded in a host shell?". True iff the URL carried a
|
|
314
|
+
* `robot_peer_id` hint at construction time (which only happens
|
|
315
|
+
* when a host iframe — mobile shell, vibe-coder preview, etc. —
|
|
316
|
+
* is the parent). Apps typically use it to skip their robot
|
|
317
|
+
* picker and their sign-in screen, since both are duplicated
|
|
318
|
+
* work the host has already done.
|
|
319
|
+
*
|
|
320
|
+
* @returns {boolean}
|
|
321
|
+
*/
|
|
322
|
+
get isEmbedded() {
|
|
323
|
+
return this._preselectedRobotId !== null;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Internal: try to honour the `autoStartFromUrl` constructor
|
|
327
|
+
* option. Called from the signaling-message handler after every
|
|
328
|
+
* `robotsChanged` emit, so a robot that comes online after the
|
|
329
|
+
* SDK is already `connected` still triggers the auto-start.
|
|
330
|
+
* No-op unless `autoStartFromUrl` is set, the URL carries a
|
|
331
|
+
* preselect, the SDK is `connected`, the preselected robot is
|
|
332
|
+
* in the latest list, and we haven't already attempted in this
|
|
333
|
+
* page load. Errors are swallowed to a `console.warn` — the
|
|
334
|
+
* normal `startSession` rejection / `sessionRejected` event
|
|
335
|
+
* still fires for app-level handling.
|
|
336
|
+
*
|
|
337
|
+
* Defers the actual `startSession()` call by one macrotask
|
|
338
|
+
* (`setTimeout(..., 0)`) so it runs OUTSIDE the
|
|
339
|
+
* `_handleSignalingMessage` callstack that just processed the
|
|
340
|
+
* `'list'` message. Reproduced on Android WebView: firing
|
|
341
|
+
* `startSession` synchronously inside the SSE handler races the
|
|
342
|
+
* daemon's setup, leading to a connected-but-no-keyframe state
|
|
343
|
+
* where the receiver eternally NACKs and the iframe shows a
|
|
344
|
+
* black <video>. The macrotask-deferral is the minimum nudge
|
|
345
|
+
* that consistently resolves the race in our reproduction; if
|
|
346
|
+
* it ever proves insufficient on slower hardware, bump to
|
|
347
|
+
* a small explicit delay (e.g. 250 ms).
|
|
348
|
+
*/
|
|
349
|
+
_maybeAutoStart() {
|
|
350
|
+
if (!this._autoStartFromUrl || this._autoStartAttempted || !this._preselectedRobotId || this._state !== "connected" || !this._robots.find((s) => s.id === this._preselectedRobotId)) return;
|
|
351
|
+
this._autoStartAttempted = !0;
|
|
352
|
+
const t = this._preselectedRobotId;
|
|
353
|
+
setTimeout(() => {
|
|
354
|
+
this._state === "connected" && this.startSession(t).catch((s) => {
|
|
355
|
+
console.warn("[reachy-mini] autoStartFromUrl: startSession rejected:", s);
|
|
356
|
+
});
|
|
357
|
+
}, 0);
|
|
358
|
+
}
|
|
359
|
+
// ─── Auth ────────────────────────────────────────────────────────────
|
|
360
|
+
/**
|
|
361
|
+
* Check for a valid HuggingFace token.
|
|
362
|
+
*
|
|
363
|
+
* Resolution order:
|
|
364
|
+
* 1. URL fragment hand-off (`#hf_token=…&hf_username=…&hf_token_expires=…`).
|
|
365
|
+
* A host iframe — typically the Reachy Mini mobile app or a
|
|
366
|
+
* vibe-coder preview — can pass credentials through the URL
|
|
367
|
+
* fragment to bypass HF's `X-Frame-Options: SAMEORIGIN` block
|
|
368
|
+
* on `huggingface.co/login`. Seeded into `sessionStorage` and
|
|
369
|
+
* then stripped from the address bar so a page reload does not
|
|
370
|
+
* keep the credentials visible.
|
|
371
|
+
* 2. OAuth redirect callback (standalone Space, first sign-in).
|
|
372
|
+
* 3. `sessionStorage` cache (subsequent loads in any context).
|
|
373
|
+
*
|
|
374
|
+
* @returns {Promise<boolean>} true → token ready, false → call login()
|
|
375
|
+
*/
|
|
376
|
+
async authenticate() {
|
|
377
|
+
try {
|
|
378
|
+
B();
|
|
379
|
+
const e = await O();
|
|
380
|
+
if (e)
|
|
381
|
+
return this._username = e.userInfo.preferred_username || e.userInfo.name, this._token = e.accessToken, this._tokenExpires = e.accessTokenExpiresAt, sessionStorage.setItem("hf_token", this._token), sessionStorage.setItem("hf_username", this._username), sessionStorage.setItem("hf_token_expires", this._tokenExpires), !0;
|
|
382
|
+
const t = sessionStorage.getItem("hf_token"), s = sessionStorage.getItem("hf_username"), i = sessionStorage.getItem("hf_token_expires");
|
|
383
|
+
return t && s && i && new Date(i) > /* @__PURE__ */ new Date() ? (this._token = t, this._username = s, this._tokenExpires = i, !0) : !1;
|
|
384
|
+
} catch (e) {
|
|
385
|
+
return console.error("Auth error:", e), !1;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** Redirect the browser to the HuggingFace OAuth login page. */
|
|
389
|
+
async login() {
|
|
390
|
+
const e = {};
|
|
391
|
+
this._clientId && (e.clientId = this._clientId), window.location.href = await N(e);
|
|
392
|
+
}
|
|
393
|
+
/** Clear stored HF credentials and disconnect everything. */
|
|
394
|
+
logout() {
|
|
395
|
+
sessionStorage.removeItem("hf_token"), sessionStorage.removeItem("hf_username"), sessionStorage.removeItem("hf_token_expires"), this._username = null, this._tokenExpires = null, this.disconnect();
|
|
396
|
+
}
|
|
397
|
+
// ─── Lifecycle ───────────────────────────────────────────────────────
|
|
398
|
+
/**
|
|
399
|
+
* Open SSE signaling connection. Resolves once the server sends `welcome`.
|
|
400
|
+
* Emits "robotsChanged" as robots come and go.
|
|
401
|
+
* @param {string} [token] — HF access token. Omit to use the one from authenticate().
|
|
402
|
+
* @returns {Promise<void>}
|
|
403
|
+
*/
|
|
404
|
+
async connect(e) {
|
|
405
|
+
if (this._state !== "disconnected") throw new Error("Already connected");
|
|
406
|
+
if (e && (this._token = e), !this._token) throw new Error("No token — call authenticate() first or pass a token");
|
|
407
|
+
this._sseAbortController = new AbortController();
|
|
408
|
+
let t;
|
|
409
|
+
try {
|
|
410
|
+
t = await fetch(
|
|
411
|
+
`${this._signalingUrl}/events`,
|
|
412
|
+
{
|
|
413
|
+
signal: this._sseAbortController.signal,
|
|
414
|
+
headers: { Authorization: `Bearer ${this._token}` }
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
} catch (s) {
|
|
418
|
+
throw this._sseAbortController = null, s;
|
|
419
|
+
}
|
|
420
|
+
if (!t.ok)
|
|
421
|
+
throw this._sseAbortController = null, new Error(`HTTP ${t.status}`);
|
|
422
|
+
return new Promise((s, i) => {
|
|
423
|
+
let o = !1;
|
|
424
|
+
const n = t.body.getReader(), a = new TextDecoder();
|
|
425
|
+
let c = "";
|
|
426
|
+
(async () => {
|
|
427
|
+
try {
|
|
428
|
+
for (; ; ) {
|
|
429
|
+
const { done: d, value: l } = await n.read();
|
|
430
|
+
if (d) break;
|
|
431
|
+
c += a.decode(l, { stream: !0 });
|
|
432
|
+
const p = c.split(`
|
|
433
|
+
`);
|
|
434
|
+
c = p.pop();
|
|
435
|
+
for (const f of p)
|
|
436
|
+
if (f.startsWith("data:"))
|
|
437
|
+
try {
|
|
438
|
+
const m = JSON.parse(f.slice(5).trim());
|
|
439
|
+
!o && m.type === "welcome" && (o = !0, this._peerId = m.peerId, this._state = "connected", await this._sendToServer({
|
|
440
|
+
type: "setPeerStatus",
|
|
441
|
+
roles: ["listener"],
|
|
442
|
+
meta: { name: this._appName }
|
|
443
|
+
}), this._emit("connected", { peerId: m.peerId }), s()), this._handleSignalingMessage(m);
|
|
444
|
+
} catch {
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
} catch (d) {
|
|
448
|
+
if (d.name !== "AbortError" && this._emit("error", { source: "signaling", error: d }), !o) {
|
|
449
|
+
i(d);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
this._state !== "disconnected" && (this._state = "disconnected", this._emit("disconnected", { reason: "SSE closed" })), o || i(new Error("Connection closed before welcome"));
|
|
454
|
+
})();
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* One-shot bring-up: auth → SSE connect → robot selection → session →
|
|
459
|
+
* wake up. The all-in-one entry point that captures the common
|
|
460
|
+
* "embed *or* standalone, just get me streaming" flow so each
|
|
461
|
+
* consumer does not have to re-implement it.
|
|
462
|
+
*
|
|
463
|
+
* What it does, in order:
|
|
464
|
+
* 1. **Auth.** If `this._token` is not set, calls `authenticate()`
|
|
465
|
+
* (which honours the iframe URL-fragment hand-off, the OAuth
|
|
466
|
+
* redirect callback, and the `sessionStorage` cache). Throws if
|
|
467
|
+
* none yield a token — the consumer should call `login()` and
|
|
468
|
+
* retry after the redirect. Pass an explicit `token` to skip
|
|
469
|
+
* `authenticate()` entirely.
|
|
470
|
+
* 2. **Connect.** If `state === 'disconnected'`, opens the SSE
|
|
471
|
+
* signaling channel.
|
|
472
|
+
* 3. **Pick a robot.**
|
|
473
|
+
* - **Embed mode** (`this.isEmbedded`): uses
|
|
474
|
+
* `this._preselectedRobotId` from the URL. No picker callback
|
|
475
|
+
* invoked; we briefly wait for that robot to appear in the
|
|
476
|
+
* SSE list, then proceed.
|
|
477
|
+
* - **Standalone**: GETs `/api/robot-status` for the owner's
|
|
478
|
+
* robots with busy state, dedupes by `install_id`, sorts by
|
|
479
|
+
* freshness. If `autoPickIfSingle` and exactly one free, picks
|
|
480
|
+
* it. Else calls the consumer-supplied `pickRobot(robots)`
|
|
481
|
+
* callback. Throws if neither yields an id.
|
|
482
|
+
* 4. **Start session.** Awaits `startSession(robotId)` (ICE + DC).
|
|
483
|
+
* 5. **Wake up.** Awaits `ensureAwake()` so sliders don't silently
|
|
484
|
+
* no-op against a torque-off robot.
|
|
485
|
+
*
|
|
486
|
+
* @param {{
|
|
487
|
+
* token?: string, // skip authenticate(); use this raw HF token
|
|
488
|
+
* pickRobot?: (robots: Array<{
|
|
489
|
+
* id: string,
|
|
490
|
+
* name: string|null,
|
|
491
|
+
* busy: boolean,
|
|
492
|
+
* activeApp: string|null,
|
|
493
|
+
* meta: object,
|
|
494
|
+
* lastSeenAgeSeconds: number|null,
|
|
495
|
+
* }>) => Promise<string|null>, // called only in standalone, multi-robot case
|
|
496
|
+
* autoPickIfSingle?: boolean, // default true — skip the callback when 1 free robot
|
|
497
|
+
* filterBusy?: boolean, // default true — hide busy robots from the picker
|
|
498
|
+
* wakeOnConnect?: boolean, // default true — call ensureAwake() after startSession
|
|
499
|
+
* }} [options]
|
|
500
|
+
* @returns {Promise<{
|
|
501
|
+
* robotId: string,
|
|
502
|
+
* robotName: string|null,
|
|
503
|
+
* isEmbedded: boolean,
|
|
504
|
+
* alreadyStreaming?: boolean,
|
|
505
|
+
* }>}
|
|
506
|
+
*/
|
|
507
|
+
async autoConnect(e = {}) {
|
|
508
|
+
const {
|
|
509
|
+
token: t = null,
|
|
510
|
+
pickRobot: s = null,
|
|
511
|
+
autoPickIfSingle: i = !0,
|
|
512
|
+
filterBusy: o = !0,
|
|
513
|
+
wakeOnConnect: n = !0
|
|
514
|
+
} = e;
|
|
515
|
+
if (this._state === "streaming") {
|
|
516
|
+
const c = this._robots?.find((u) => u.id === this._selectedRobotId);
|
|
517
|
+
return {
|
|
518
|
+
robotId: this._selectedRobotId,
|
|
519
|
+
robotName: c?.meta?.name ?? null,
|
|
520
|
+
isEmbedded: this.isEmbedded,
|
|
521
|
+
alreadyStreaming: !0
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const a = this._autoStartFromUrl;
|
|
525
|
+
this._autoStartFromUrl = !1;
|
|
526
|
+
try {
|
|
527
|
+
if (t)
|
|
528
|
+
this._token = t;
|
|
529
|
+
else if (!this._token && !await this.authenticate())
|
|
530
|
+
throw new Error("Not authenticated — call login() or pass a token");
|
|
531
|
+
this._state === "disconnected" && await this.connect();
|
|
532
|
+
let c, u = null;
|
|
533
|
+
if (this.isEmbedded) {
|
|
534
|
+
c = this._preselectedRobotId;
|
|
535
|
+
try {
|
|
536
|
+
await this._waitForRobotInList(c, 5e3);
|
|
537
|
+
} catch {
|
|
538
|
+
}
|
|
539
|
+
u = this._robots?.find((l) => l.id === c)?.meta?.name ?? null;
|
|
540
|
+
} else {
|
|
541
|
+
const d = await this._fetchOwnedRobots({ filterBusy: o });
|
|
542
|
+
if (d.length === 0)
|
|
543
|
+
throw new Error("No reachable robots");
|
|
544
|
+
if (i && d.length === 1 && !d[0].busy)
|
|
545
|
+
c = d[0].id, u = d[0].name;
|
|
546
|
+
else if (s) {
|
|
547
|
+
const l = await s(d);
|
|
548
|
+
if (!l) throw new Error("Robot selection cancelled");
|
|
549
|
+
c = l, u = d.find((p) => p.id === l)?.name ?? null;
|
|
550
|
+
} else
|
|
551
|
+
throw new Error(
|
|
552
|
+
"Multiple robots available — pass a pickRobot callback to autoConnect()"
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
if (await this.startSession(c), n && typeof this.ensureAwake == "function")
|
|
556
|
+
try {
|
|
557
|
+
await this.ensureAwake();
|
|
558
|
+
} catch (d) {
|
|
559
|
+
console.warn("[reachy-mini] autoConnect: ensureAwake failed:", d);
|
|
560
|
+
}
|
|
561
|
+
return { robotId: c, robotName: u, isEmbedded: this.isEmbedded };
|
|
562
|
+
} finally {
|
|
563
|
+
this._autoStartFromUrl = a;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Fetch the caller's robots with busy state, deduped + sorted.
|
|
568
|
+
* One-shot snapshot — no live subscription. Falls back to the SSE
|
|
569
|
+
* `_robots` cache if `/api/robot-status` is unavailable (older
|
|
570
|
+
* central deployments don't expose it).
|
|
571
|
+
*
|
|
572
|
+
* Dedup: same physical robot can appear twice transiently after a
|
|
573
|
+
* daemon reinstall (new peerId, same install_id). Last-writer-wins
|
|
574
|
+
* on `install_id`, then `hardware_id`, then `peerId` (= no dedup).
|
|
575
|
+
*
|
|
576
|
+
* @returns {Promise<Array<{
|
|
577
|
+
* id: string,
|
|
578
|
+
* name: string|null,
|
|
579
|
+
* busy: boolean,
|
|
580
|
+
* activeApp: string|null,
|
|
581
|
+
* meta: object,
|
|
582
|
+
* lastSeenAgeSeconds: number|null,
|
|
583
|
+
* }>>}
|
|
584
|
+
*/
|
|
585
|
+
async _fetchOwnedRobots({ filterBusy: e = !0 } = {}) {
|
|
586
|
+
try {
|
|
587
|
+
const t = await fetch(`${this._signalingUrl}/api/robot-status`, {
|
|
588
|
+
headers: { Authorization: `Bearer ${this._token}` }
|
|
589
|
+
});
|
|
590
|
+
if (!t.ok) throw new Error(`HTTP ${t.status}`);
|
|
591
|
+
const s = await t.json(), i = /* @__PURE__ */ new Map();
|
|
592
|
+
for (const o of s.robots || []) {
|
|
593
|
+
if (e && o.busy) continue;
|
|
594
|
+
const n = o.meta?.install_id ?? o.meta?.hardware_id ?? o.peerId;
|
|
595
|
+
i.set(n, {
|
|
596
|
+
id: o.peerId,
|
|
597
|
+
name: o.robotName ?? o.meta?.name ?? null,
|
|
598
|
+
busy: !!o.busy,
|
|
599
|
+
activeApp: o.activeApp ?? null,
|
|
600
|
+
meta: o.meta ?? {},
|
|
601
|
+
lastSeenAgeSeconds: o.last_seen_age_seconds ?? null
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
return Array.from(i.values()).sort(
|
|
605
|
+
(o, n) => (o.lastSeenAgeSeconds ?? 1 / 0) - (n.lastSeenAgeSeconds ?? 1 / 0)
|
|
606
|
+
);
|
|
607
|
+
} catch (t) {
|
|
608
|
+
return console.warn("[reachy-mini] /api/robot-status unavailable, using SSE list:", t), (this._robots || []).map((s) => ({
|
|
609
|
+
id: s.id,
|
|
610
|
+
name: s.meta?.name ?? null,
|
|
611
|
+
busy: !1,
|
|
612
|
+
// unknown — SSE list does not carry busy state
|
|
613
|
+
activeApp: null,
|
|
614
|
+
meta: s.meta ?? {},
|
|
615
|
+
lastSeenAgeSeconds: null
|
|
616
|
+
}));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Resolve once `robotId` appears in `_robots`, or reject after
|
|
621
|
+
* `timeoutMs`. Used by `autoConnect()`'s embed branch so the preselected
|
|
622
|
+
* robot has a chance to surface from the first SSE `list` push before
|
|
623
|
+
* `startSession()` is fired.
|
|
624
|
+
*/
|
|
625
|
+
_waitForRobotInList(e, t) {
|
|
626
|
+
return this._robots?.find((s) => s.id === e) ? Promise.resolve() : new Promise((s, i) => {
|
|
627
|
+
const o = () => {
|
|
628
|
+
this._robots?.find((a) => a.id === e) && (this.removeEventListener("robotsChanged", o), clearTimeout(n), s());
|
|
629
|
+
}, n = setTimeout(() => {
|
|
630
|
+
this.removeEventListener("robotsChanged", o), i(new Error(`Timeout waiting for robot ${e} in list`));
|
|
631
|
+
}, t);
|
|
632
|
+
this.addEventListener("robotsChanged", o);
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Start a WebRTC session with the given robot.
|
|
637
|
+
* Acquires the microphone (if enabled), negotiates SDP, and waits for
|
|
638
|
+
* both ICE connection and data channel to be ready before resolving.
|
|
639
|
+
* Emits "videoTrack" when the robot's camera stream arrives.
|
|
640
|
+
* Emits "micSupported" once SDP negotiation reveals whether the robot
|
|
641
|
+
* accepts bidirectional audio.
|
|
642
|
+
* @param {string} robotId — one of the ids from the robots list
|
|
643
|
+
* @returns {Promise<void>}
|
|
644
|
+
*/
|
|
645
|
+
async startSession(e) {
|
|
646
|
+
if (this._state !== "connected") throw new Error("Not connected");
|
|
647
|
+
if (this._selectedRobotId = e, this._iceConnected = !1, this._dcOpen = !1, this._micSupported = !1, this._pendingRemoteIce = [], this._enableMicrophone)
|
|
648
|
+
try {
|
|
649
|
+
this._micStream = await navigator.mediaDevices.getUserMedia({ audio: !0 }), this._micStream.getAudioTracks().forEach((t) => {
|
|
650
|
+
t.enabled = !1;
|
|
651
|
+
}), this._micMuted = !0;
|
|
652
|
+
} catch (t) {
|
|
653
|
+
console.warn("Microphone not available:", t);
|
|
654
|
+
try {
|
|
655
|
+
const s = new (window.AudioContext || window.webkitAudioContext)(), i = s.createMediaStreamDestination(), o = s.createOscillator(), n = s.createGain();
|
|
656
|
+
n.gain.value = 0, o.connect(n).connect(i), o.start(), this._micStream = i.stream, this._micStream.getAudioTracks().forEach((a) => {
|
|
657
|
+
a.enabled = !1;
|
|
658
|
+
}), this._micMuted = !0, this._silentMicFallback = { ctx: s, osc: o };
|
|
659
|
+
} catch (s) {
|
|
660
|
+
console.warn("Silent mic fallback failed:", s), this._micStream = null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return this._pc = new RTCPeerConnection({
|
|
664
|
+
iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
|
|
665
|
+
}), new Promise((t, s) => {
|
|
666
|
+
this._sessionResolve = t, this._sessionReject = s, this._pc.ontrack = (i) => {
|
|
667
|
+
if (i.track.kind === "video") {
|
|
668
|
+
const o = this._videoJitterBufferTargetMs;
|
|
669
|
+
try {
|
|
670
|
+
i.receiver.jitterBufferTarget = o;
|
|
671
|
+
} catch {
|
|
672
|
+
}
|
|
673
|
+
try {
|
|
674
|
+
i.receiver.playoutDelayHint = o / 1e3;
|
|
675
|
+
} catch {
|
|
676
|
+
}
|
|
677
|
+
this._emit("videoTrack", { track: i.track, stream: i.streams[0] });
|
|
678
|
+
}
|
|
679
|
+
}, this._pc.onicecandidate = async (i) => {
|
|
680
|
+
i.candidate && this._sessionId && await this._sendToServer({
|
|
681
|
+
type: "peer",
|
|
682
|
+
sessionId: this._sessionId,
|
|
683
|
+
ice: {
|
|
684
|
+
candidate: i.candidate.candidate,
|
|
685
|
+
sdpMLineIndex: i.candidate.sdpMLineIndex,
|
|
686
|
+
sdpMid: i.candidate.sdpMid
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
}, this._pc.oniceconnectionstatechange = () => {
|
|
690
|
+
const i = this._pc?.iceConnectionState;
|
|
691
|
+
if (i)
|
|
692
|
+
if (i === "connected" || i === "completed")
|
|
693
|
+
this._iceConnected = !0, this._checkSessionReady();
|
|
694
|
+
else if (i === "failed") {
|
|
695
|
+
const o = new Error("ICE connection failed");
|
|
696
|
+
this._sessionReject && (this._sessionReject(o), this._sessionResolve = null, this._sessionReject = null), this._emit("error", { source: "webrtc", error: o });
|
|
697
|
+
} else i === "disconnected" && this._emit("error", { source: "webrtc", error: new Error("ICE disconnected") });
|
|
698
|
+
}, this._pc.ondatachannel = (i) => {
|
|
699
|
+
this._dc = i.channel, this._dc.onopen = () => {
|
|
700
|
+
this._dcOpen = !0, this._checkSessionReady();
|
|
701
|
+
}, this._dc.onmessage = (o) => this._handleRobotMessage(JSON.parse(o.data));
|
|
702
|
+
}, this._sendToServer({ type: "startSession", peerId: e }).then((i) => {
|
|
703
|
+
if (i?.type === "sessionRejected") {
|
|
704
|
+
this._failSessionRejected(i);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
i?.sessionId && (this._sessionId = i.sessionId);
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Internal: handle a sessionRejected response from central.
|
|
713
|
+
* Releases resources allocated by startSession() (RTCPeerConnection,
|
|
714
|
+
* microphone stream) and rejects the pending startSession() promise
|
|
715
|
+
* with an Error carrying `.reason` and `.activeApp`.
|
|
716
|
+
*
|
|
717
|
+
* Called from both the POST-response path (primary) and the SSE
|
|
718
|
+
* handler (defensive, in case the server changes).
|
|
719
|
+
*/
|
|
720
|
+
_failSessionRejected(e) {
|
|
721
|
+
const t = new Error(
|
|
722
|
+
e.reason === "robot_busy" ? `Robot is busy: "${e.activeApp || "another app"}" is already connected` : `Session rejected: ${e.reason || "unknown reason"}`
|
|
723
|
+
);
|
|
724
|
+
if (t.reason = e.reason, t.activeApp = e.activeApp, this._pc && (this._pc.close(), this._pc = null), this._micStream && (this._micStream.getTracks().forEach((s) => s.stop()), this._micStream = null), this._iceConnected = !1, this._dcOpen = !1, this._micMuted = !0, this._micSupported = !1, this._emit("sessionRejected", { reason: e.reason, activeApp: e.activeApp }), this._sessionReject) {
|
|
725
|
+
const s = this._sessionReject;
|
|
726
|
+
this._sessionResolve = null, this._sessionReject = null, s(t);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* End the WebRTC session. Returns to "connected" state so you can
|
|
731
|
+
* startSession() again with the same or a different robot.
|
|
732
|
+
* @returns {Promise<void>}
|
|
733
|
+
*/
|
|
734
|
+
async stopSession() {
|
|
735
|
+
this._versionResolve && (this._versionResolve(null), this._versionResolve = null), this._hardwareIdResolve && (this._hardwareIdResolve(null), this._hardwareIdResolve = null), this._volumeResolve && (this._volumeResolve(null), this._volumeResolve = null), this._micVolumeResolve && (this._micVolumeResolve(null), this._micVolumeResolve = null), this._logSubscribers.clear(), this._rejectPendingMotionCompletions(new Error("Session stopped")), this._sessionReject && (this._sessionReject(new Error("Session stopped")), this._sessionResolve = null, this._sessionReject = null), this._stateRefreshInterval && (clearInterval(this._stateRefreshInterval), this._stateRefreshInterval = null), this._latencyMonitorId && (clearInterval(this._latencyMonitorId), this._latencyMonitorId = null), this._sessionId && await this._sendToServer({ type: "endSession", sessionId: this._sessionId }), this._micStream && (this._micStream.getTracks().forEach((t) => t.stop()), this._micStream = null), this._micMuted = !0, this._micSupported = !1, this._pc && (this._pc.close(), this._pc = null), this._dc && (this._dc.close(), this._dc = null), this._sessionId = null, this._iceConnected = !1, this._dcOpen = !1, this._state === "streaming" && (this._state = "connected", this._emit("sessionStopped", { reason: "user" }));
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Full teardown — abort SSE, close WebRTC.
|
|
739
|
+
* Auth state is preserved (call logout() to also clear credentials).
|
|
740
|
+
*/
|
|
741
|
+
disconnect() {
|
|
742
|
+
this._sseAbortController && (this._sseAbortController.abort(), this._sseAbortController = null), this._versionResolve && (this._versionResolve(null), this._versionResolve = null), this._hardwareIdResolve && (this._hardwareIdResolve(null), this._hardwareIdResolve = null), this._volumeResolve && (this._volumeResolve(null), this._volumeResolve = null), this._micVolumeResolve && (this._micVolumeResolve(null), this._micVolumeResolve = null), this._logSubscribers.clear(), this._rejectPendingMotionCompletions(new Error("Disconnected")), this._sessionReject && (this._sessionReject(new Error("Disconnected")), this._sessionResolve = null, this._sessionReject = null), this._stateRefreshInterval && (clearInterval(this._stateRefreshInterval), this._stateRefreshInterval = null), this._latencyMonitorId && (clearInterval(this._latencyMonitorId), this._latencyMonitorId = null), this._sessionId && this._token && this._sendToServer({ type: "endSession", sessionId: this._sessionId }), this._micStream && (this._micStream.getTracks().forEach((e) => e.stop()), this._micStream = null), this._pc && (this._pc.close(), this._pc = null), this._dc && (this._dc.close(), this._dc = null), this._sessionId = null, this._micMuted = !0, this._micSupported = !1, this._iceConnected = !1, this._dcOpen = !1, this._peerId = null, this._robots = [], this._state = "disconnected", this._emit("disconnected", { reason: "user" });
|
|
743
|
+
}
|
|
744
|
+
// ─── Commands ────────────────────────────────────────────────────────
|
|
745
|
+
// All return false if the data channel is not open, true if sent.
|
|
746
|
+
/**
|
|
747
|
+
* Send a target pose to the robot. Wire-shape, raw units only —
|
|
748
|
+
* single source of truth for motion commands. Every field is
|
|
749
|
+
* optional; omitted fields leave the daemon's previous target
|
|
750
|
+
* unchanged, so partial updates compose naturally.
|
|
751
|
+
*
|
|
752
|
+
* For human units (degrees), use the ``setHeadRpyDeg`` /
|
|
753
|
+
* ``setAntennasDeg`` / ``setBodyYawDeg`` thin wrappers below.
|
|
754
|
+
*
|
|
755
|
+
* @param {object} [target]
|
|
756
|
+
* @param {number[]} [target.head] 16-element flat row-major 4×4
|
|
757
|
+
* matrix (full SE(3); preserves translation, no XYZ loss).
|
|
758
|
+
* @param {number[]} [target.antennas] ``[rightRad, leftRad]``.
|
|
759
|
+
* @param {number} [target.body_yaw] Body yaw in radians.
|
|
760
|
+
* @returns {boolean} false if the data channel is not open.
|
|
761
|
+
* @throws {TypeError} if any provided field has the wrong shape or
|
|
762
|
+
* contains a non-finite value (NaN, Infinity). Validation runs at
|
|
763
|
+
* the JS boundary so caller mistakes surface with a stack trace
|
|
764
|
+
* pointing to the call site, not as a confusing daemon-side error.
|
|
765
|
+
*/
|
|
766
|
+
setTarget({ head: e, antennas: t, body_yaw: s } = {}) {
|
|
767
|
+
const i = { type: "set_full_target" };
|
|
768
|
+
if (e !== void 0) {
|
|
769
|
+
if (!Array.isArray(e) || e.length !== 16 || !e.every((o) => Number.isFinite(o)))
|
|
770
|
+
throw new TypeError(
|
|
771
|
+
`setTarget: head must be a 16-element flat row-major 4×4 matrix of finite numbers; got ${Array.isArray(e) ? `Array(${e.length})` : typeof e}`
|
|
772
|
+
);
|
|
773
|
+
i.head = e;
|
|
774
|
+
}
|
|
775
|
+
if (t !== void 0) {
|
|
776
|
+
if (!Array.isArray(t) || t.length !== 2 || !t.every((o) => Number.isFinite(o)))
|
|
777
|
+
throw new TypeError(
|
|
778
|
+
`setTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t) ? `Array(${t.length})` : typeof t}`
|
|
779
|
+
);
|
|
780
|
+
i.antennas = t;
|
|
781
|
+
}
|
|
782
|
+
if (s !== void 0) {
|
|
783
|
+
if (!Number.isFinite(s))
|
|
784
|
+
throw new TypeError(
|
|
785
|
+
`setTarget: body_yaw must be a finite number (radians); got ${s}`
|
|
786
|
+
);
|
|
787
|
+
i.body_yaw = s;
|
|
788
|
+
}
|
|
789
|
+
return this._sendCommand(i);
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Smooth daemon-side interpolation to a target pose over
|
|
793
|
+
* ``duration`` seconds. Mirrors ``setTarget``'s wire shape (head
|
|
794
|
+
* is a 16-element flat row-major 4×4, antennas are
|
|
795
|
+
* ``[rightRad, leftRad]``, body_yaw is radians) and adds a
|
|
796
|
+
* required ``duration`` field. The daemon dispatches the command
|
|
797
|
+
* to its lerp planner instead of jumping to the target.
|
|
798
|
+
*
|
|
799
|
+
* Use this for one-shot smooth approaches to an arbitrary pose
|
|
800
|
+
* (e.g. soft-return-to-base after recording, or pre-positioning
|
|
801
|
+
* before a streamed playback). For continuous streamed motion,
|
|
802
|
+
* use ``setTarget`` and lerp client-side.
|
|
803
|
+
*
|
|
804
|
+
* @param {{head?: number[], antennas?: number[], body_yaw?: number, duration: number}} args
|
|
805
|
+
* @returns {boolean} false if the data channel is not open.
|
|
806
|
+
* @throws {TypeError} if any provided field has the wrong shape
|
|
807
|
+
* or contains a non-finite value (NaN, Infinity), or if
|
|
808
|
+
* ``duration`` is missing or non-positive.
|
|
809
|
+
*/
|
|
810
|
+
gotoTarget({ head: e, antennas: t, body_yaw: s, duration: i } = {}) {
|
|
811
|
+
const o = { type: "goto_target" };
|
|
812
|
+
if (e !== void 0) {
|
|
813
|
+
if (!Array.isArray(e) || e.length !== 16 || !e.every((n) => Number.isFinite(n)))
|
|
814
|
+
throw new TypeError(
|
|
815
|
+
`gotoTarget: head must be a 16-element flat row-major 4×4 matrix of finite numbers; got ${Array.isArray(e) ? `Array(${e.length})` : typeof e}`
|
|
816
|
+
);
|
|
817
|
+
o.head = e;
|
|
818
|
+
}
|
|
819
|
+
if (t !== void 0) {
|
|
820
|
+
if (!Array.isArray(t) || t.length !== 2 || !t.every((n) => Number.isFinite(n)))
|
|
821
|
+
throw new TypeError(
|
|
822
|
+
`gotoTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t) ? `Array(${t.length})` : typeof t}`
|
|
823
|
+
);
|
|
824
|
+
o.antennas = t;
|
|
825
|
+
}
|
|
826
|
+
if (s !== void 0) {
|
|
827
|
+
if (!Number.isFinite(s))
|
|
828
|
+
throw new TypeError(
|
|
829
|
+
`gotoTarget: body_yaw must be a finite number (radians); got ${s}`
|
|
830
|
+
);
|
|
831
|
+
o.body_yaw = s;
|
|
832
|
+
}
|
|
833
|
+
if (!Number.isFinite(i) || i <= 0)
|
|
834
|
+
throw new TypeError(
|
|
835
|
+
`gotoTarget: duration must be a positive finite number (seconds); got ${i}`
|
|
836
|
+
);
|
|
837
|
+
return o.duration = i, this._sendCommand(o);
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Set head orientation from roll/pitch/yaw in degrees.
|
|
841
|
+
* Convenience wrapper over ``setTarget``.
|
|
842
|
+
* @param {number} rollDeg @param {number} pitchDeg @param {number} yawDeg
|
|
843
|
+
* @returns {boolean}
|
|
844
|
+
*/
|
|
845
|
+
setHeadRpyDeg(e, t, s) {
|
|
846
|
+
return this.setTarget({ head: F(e, t, s).flat() });
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Set antenna positions from degrees.
|
|
850
|
+
* Convenience wrapper over ``setTarget``.
|
|
851
|
+
* @param {number} rightDeg @param {number} leftDeg
|
|
852
|
+
* @returns {boolean}
|
|
853
|
+
*/
|
|
854
|
+
setAntennasDeg(e, t) {
|
|
855
|
+
return this.setTarget({ antennas: [S(e), S(t)] });
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Set body yaw from degrees.
|
|
859
|
+
* Convenience wrapper over ``setTarget``.
|
|
860
|
+
* @param {number} yawDeg
|
|
861
|
+
* @returns {boolean}
|
|
862
|
+
*/
|
|
863
|
+
setBodyYawDeg(e) {
|
|
864
|
+
return this.setTarget({ body_yaw: S(e) });
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Play a sound file on the robot.
|
|
868
|
+
* @param {string} file — filename available on the robot (e.g. "wake_up.wav")
|
|
869
|
+
* @returns {boolean}
|
|
870
|
+
*/
|
|
871
|
+
playSound(e) {
|
|
872
|
+
return this._sendCommand({ type: "play_sound", file: e });
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Set the motor control mode.
|
|
876
|
+
*
|
|
877
|
+
* @param {"enabled"|"disabled"|"gravity_compensation"} mode
|
|
878
|
+
* - "enabled" torque on, position-controlled.
|
|
879
|
+
* - "disabled" torque off; the robot is backdrivable
|
|
880
|
+
* and will not hold any pose.
|
|
881
|
+
* - "gravity_compensation" torque on in current-control mode;
|
|
882
|
+
* motors actively cancel gravity so the
|
|
883
|
+
* robot is easy to move by hand.
|
|
884
|
+
* @returns {boolean} false if the data channel is not open.
|
|
885
|
+
*/
|
|
886
|
+
setMotorMode(e) {
|
|
887
|
+
return this._sendCommand({ type: "set_motor_mode", mode: e });
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Toggle torque on/off, optionally per-motor.
|
|
891
|
+
*
|
|
892
|
+
* @param {boolean} on
|
|
893
|
+
* @param {string[]} [ids] motor names (e.g. ["left_antenna"]). When
|
|
894
|
+
* omitted, applies globally — equivalent to setMotorMode("enabled"
|
|
895
|
+
* | "disabled").
|
|
896
|
+
* @returns {boolean} false if the data channel is not open.
|
|
897
|
+
*/
|
|
898
|
+
setMotorTorque(e, t = null) {
|
|
899
|
+
return this._sendCommand({ type: "set_torque", on: e, ids: t });
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Play the wake-up animation (full head/antennas trajectory on the
|
|
903
|
+
* robot, ~1-3 s depending on the starting head pose) and resolve
|
|
904
|
+
* when the daemon reports the trajectory player has actually
|
|
905
|
+
* finished.
|
|
906
|
+
*
|
|
907
|
+
* This helper sends a ``set_motor_mode: "enabled"`` command *before*
|
|
908
|
+
* the ``wake_up`` command so the animation actually moves the motors.
|
|
909
|
+
* The robot's ``wake_up`` handler does not touch motor mode itself;
|
|
910
|
+
* if torque is off when the trajectory runs, the commanded positions
|
|
911
|
+
* are silently ignored and the robot stays limp. Both commands
|
|
912
|
+
* travel over the same data channel so ordering at the backend is
|
|
913
|
+
* preserved.
|
|
914
|
+
*
|
|
915
|
+
* The returned promise resolves on the daemon's
|
|
916
|
+
* ``{command: "wake_up", completed: true}`` response (sent after
|
|
917
|
+
* the trajectory player is fully done, not just when the command
|
|
918
|
+
* is enqueued). Lets a UI overlay (e.g. the host's "Wake-up" step)
|
|
919
|
+
* stay up for exactly the right duration, and lets callers chain
|
|
920
|
+
* setup that depends on the head being in the awake pose without
|
|
921
|
+
* racing the trajectory.
|
|
922
|
+
*
|
|
923
|
+
* Semantics match the REST endpoint ``POST /api/move/play/wake_up``
|
|
924
|
+
* plus the LAN convention of enabling motors before playing motion
|
|
925
|
+
* trajectories.
|
|
926
|
+
*
|
|
927
|
+
* @param {object} [options]
|
|
928
|
+
* @param {number} [options.timeoutMs=8000] hard upper bound; the
|
|
929
|
+
* promise rejects with a TimeoutError-shaped Error if the daemon
|
|
930
|
+
* stops responding (e.g. data channel went down mid-animation
|
|
931
|
+
* without firing close events).
|
|
932
|
+
* @returns {Promise<void>}
|
|
933
|
+
*/
|
|
934
|
+
wakeUp({ timeoutMs: e = 8e3 } = {}) {
|
|
935
|
+
return this._sendCommand({ type: "set_motor_mode", mode: "enabled" }), this._sendCommandAwaitCompletion("wake_up", e);
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Play the goto-sleep animation and resolve when the daemon reports
|
|
939
|
+
* the trajectory player has finished. See ``wakeUp`` for the
|
|
940
|
+
* completion-signal rationale.
|
|
941
|
+
*
|
|
942
|
+
* Does NOT touch motor mode: the daemon's ``goto_sleep`` handler
|
|
943
|
+
* manages the transition out of torque on its own (motors must stay
|
|
944
|
+
* powered during the trajectory to move into the sleep pose, then
|
|
945
|
+
* are typically disabled by the daemon once the pose is reached).
|
|
946
|
+
*
|
|
947
|
+
* The awaitable form lets callers chain ``setMotorMode('disabled')``
|
|
948
|
+
* AFTER the trajectory lands instead of racing it, which previously
|
|
949
|
+
* caused the head to drop mid-animation when consumers tore down
|
|
950
|
+
* too eagerly.
|
|
951
|
+
*
|
|
952
|
+
* Semantics match ``POST /api/move/play/goto_sleep`` and the
|
|
953
|
+
* ``"goto_sleep"`` WebRTC command.
|
|
954
|
+
*
|
|
955
|
+
* @param {object} [options]
|
|
956
|
+
* @param {number} [options.timeoutMs=8000]
|
|
957
|
+
* @returns {Promise<void>}
|
|
958
|
+
*/
|
|
959
|
+
gotoSleep({ timeoutMs: e = 8e3 } = {}) {
|
|
960
|
+
return this._sendCommandAwaitCompletion("goto_sleep", e);
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Internal: send a motion command and resolve when the daemon's
|
|
964
|
+
* matching ``{command, completed: true}`` response lands.
|
|
965
|
+
*
|
|
966
|
+
* Pushes one entry onto ``_pendingMotionCompletions[command]``; the
|
|
967
|
+
* data-channel reader (``_handleRobotMessage``) shifts the oldest
|
|
968
|
+
* entry off the queue when a response arrives, which preserves the
|
|
969
|
+
* FIFO matching that the daemon's serialised dispatcher relies on.
|
|
970
|
+
*
|
|
971
|
+
* Rejects immediately if the data channel is not open; the underlying
|
|
972
|
+
* ``_sendCommand`` returns false in that case and we never enqueue an
|
|
973
|
+
* awaiter that the daemon could never reach.
|
|
974
|
+
*
|
|
975
|
+
* @param {"wake_up"|"goto_sleep"} command
|
|
976
|
+
* @param {number} timeoutMs
|
|
977
|
+
* @returns {Promise<void>}
|
|
978
|
+
*/
|
|
979
|
+
_sendCommandAwaitCompletion(e, t) {
|
|
980
|
+
return this._sendCommand({ type: e }) ? new Promise((s, i) => {
|
|
981
|
+
const o = {
|
|
982
|
+
resolve: s,
|
|
983
|
+
reject: i,
|
|
984
|
+
timer: setTimeout(() => {
|
|
985
|
+
const n = this._pendingMotionCompletions[e], a = n.indexOf(o);
|
|
986
|
+
a !== -1 && n.splice(a, 1), i(new Error(`${e} timed out after ${t}ms`));
|
|
987
|
+
}, t)
|
|
988
|
+
};
|
|
989
|
+
this._pendingMotionCompletions[e].push(o);
|
|
990
|
+
}) : Promise.reject(new Error(`${e}: data channel not open`));
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Internal: drain every pending motion-completion resolver with the
|
|
994
|
+
* given error. Called by ``stopSession()`` and ``disconnect()`` so a
|
|
995
|
+
* teardown that interrupts an in-flight ``gotoSleep`` does not leave
|
|
996
|
+
* the caller awaiting forever.
|
|
997
|
+
*/
|
|
998
|
+
_rejectPendingMotionCompletions(e) {
|
|
999
|
+
for (const t of Object.keys(this._pendingMotionCompletions)) {
|
|
1000
|
+
const s = this._pendingMotionCompletions[t];
|
|
1001
|
+
for (; s.length; ) {
|
|
1002
|
+
const i = s.shift();
|
|
1003
|
+
clearTimeout(i.timer), i.reject(e);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Whether the robot's motors are currently powered (the "awake" state).
|
|
1009
|
+
*
|
|
1010
|
+
* Reads ``motor_mode`` from the last state event. Both ``"enabled"``
|
|
1011
|
+
* and ``"gravity_compensation"`` count as awake: in gravity-comp the
|
|
1012
|
+
* motors are actively holding the arm against gravity, so the robot
|
|
1013
|
+
* is *not* limp and playing wake_up on top would fight the user.
|
|
1014
|
+
* Only ``"disabled"`` (true sleep) is considered not-awake.
|
|
1015
|
+
*
|
|
1016
|
+
* Returns ``false`` before the first state event arrives (typical
|
|
1017
|
+
* right after ``startSession()``). Use ``ensureAwake()`` if you want
|
|
1018
|
+
* to wait for the first state before deciding.
|
|
1019
|
+
*
|
|
1020
|
+
* @returns {boolean}
|
|
1021
|
+
*/
|
|
1022
|
+
isAwake() {
|
|
1023
|
+
const e = this._robotState?.motor_mode;
|
|
1024
|
+
return e === "enabled" || e === "gravity_compensation";
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Wake the robot up if it is currently asleep, otherwise no-op.
|
|
1028
|
+
*
|
|
1029
|
+
* Intended as the first line of any app after ``startSession()``
|
|
1030
|
+
* resolves — robots are often left in the sleep pose (torque off,
|
|
1031
|
+
* head resting on the base) and commanded positions are silently
|
|
1032
|
+
* ignored in that state.
|
|
1033
|
+
*
|
|
1034
|
+
* If no state event has arrived yet, waits up to ``timeoutMs`` for
|
|
1035
|
+
* one before deciding. If still no state, falls back to sending
|
|
1036
|
+
* ``wakeUp()`` (safe: the daemon's wake_up handler is idempotent
|
|
1037
|
+
* at the motion level — it moves to the awake pose from wherever
|
|
1038
|
+
* the head currently is).
|
|
1039
|
+
*
|
|
1040
|
+
* @param {number} [timeoutMs=1000] how long to wait for the first
|
|
1041
|
+
* state event before falling through to wakeUp().
|
|
1042
|
+
* @returns {Promise<boolean>} true if the robot is awake afterwards.
|
|
1043
|
+
*/
|
|
1044
|
+
async ensureAwake(e = 1e3) {
|
|
1045
|
+
return this._robotState?.motor_mode === void 0 && await new Promise((t) => {
|
|
1046
|
+
const s = () => {
|
|
1047
|
+
this.removeEventListener("state", s), clearTimeout(i), t();
|
|
1048
|
+
}, i = setTimeout(s, e);
|
|
1049
|
+
this.addEventListener("state", s), this.requestState();
|
|
1050
|
+
}), this.isAwake() || this.wakeUp().catch(() => {
|
|
1051
|
+
}), !0;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Request the daemon version.
|
|
1055
|
+
* Resolves with the version string (or null if unavailable).
|
|
1056
|
+
* @returns {Promise<string|null>}
|
|
1057
|
+
*/
|
|
1058
|
+
getVersion() {
|
|
1059
|
+
return new Promise((e, t) => {
|
|
1060
|
+
if (!this._dc || this._dc.readyState !== "open") {
|
|
1061
|
+
t(new Error("Data channel not open"));
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
this._versionResolve && this._versionResolve(null), this._versionResolve = e, this._sendCommand({ type: "get_version" });
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Request the robot's unique hardware ID — the Pollen audio device's
|
|
1069
|
+
* USB serial. Same value across Lite and Wireless variants, stable
|
|
1070
|
+
* across reboots and OS reinstalls. Useful for fleet management,
|
|
1071
|
+
* per-robot calibration cache keys, or identifying which physical
|
|
1072
|
+
* robot a session is bound to.
|
|
1073
|
+
* Resolves with the hardware ID string (or null if no robot is
|
|
1074
|
+
* attached, e.g. the daemon is running on a developer machine).
|
|
1075
|
+
* @returns {Promise<string|null>}
|
|
1076
|
+
*/
|
|
1077
|
+
getHardwareId() {
|
|
1078
|
+
return new Promise((e, t) => {
|
|
1079
|
+
if (!this._dc || this._dc.readyState !== "open") {
|
|
1080
|
+
t(new Error("Data channel not open"));
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
this._hardwareIdResolve && this._hardwareIdResolve(null), this._hardwareIdResolve = e, this._sendCommand({ type: "get_hardware_id" });
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Query the current speaker volume (0-100).
|
|
1088
|
+
* Resolves with null if volume control is unavailable (platform unsupported
|
|
1089
|
+
* or audio stack down).
|
|
1090
|
+
* @returns {Promise<number|null>}
|
|
1091
|
+
*/
|
|
1092
|
+
getVolume() {
|
|
1093
|
+
return this._volumeRoundtrip({ type: "get_volume" }, "_volumeResolve");
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Set the speaker volume (0-100). Persists for the next connection
|
|
1097
|
+
* (same semantics as the REST /api/volume/set endpoint).
|
|
1098
|
+
* Resolves with the applied volume, or null on failure.
|
|
1099
|
+
* @param {number} volume 0-100
|
|
1100
|
+
* @returns {Promise<number|null>}
|
|
1101
|
+
*/
|
|
1102
|
+
setVolume(e) {
|
|
1103
|
+
return this._volumeRoundtrip(
|
|
1104
|
+
{ type: "set_volume", volume: U(e) },
|
|
1105
|
+
"_volumeResolve"
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Query the current microphone input volume (0-100).
|
|
1110
|
+
* @returns {Promise<number|null>}
|
|
1111
|
+
*/
|
|
1112
|
+
getMicrophoneVolume() {
|
|
1113
|
+
return this._volumeRoundtrip(
|
|
1114
|
+
{ type: "get_microphone_volume" },
|
|
1115
|
+
"_micVolumeResolve"
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Set the microphone input volume (0-100). Persists across sessions.
|
|
1120
|
+
* @param {number} volume 0-100
|
|
1121
|
+
* @returns {Promise<number|null>}
|
|
1122
|
+
*/
|
|
1123
|
+
setMicrophoneVolume(e) {
|
|
1124
|
+
return this._volumeRoundtrip(
|
|
1125
|
+
{ type: "set_microphone_volume", volume: U(e) },
|
|
1126
|
+
"_micVolumeResolve"
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Internal: send a volume command and await the single-slot response.
|
|
1131
|
+
* The slot name selects which resolver (speaker vs mic) owns the
|
|
1132
|
+
* pending request so the two can be in-flight concurrently without
|
|
1133
|
+
* collision.
|
|
1134
|
+
*/
|
|
1135
|
+
_volumeRoundtrip(e, t) {
|
|
1136
|
+
return new Promise((s, i) => {
|
|
1137
|
+
if (!this._dc || this._dc.readyState !== "open") {
|
|
1138
|
+
i(new Error("Data channel not open"));
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
this[t] && this[t](null), this[t] = s, this._sendCommand(e);
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Send an arbitrary JSON command over the data channel.
|
|
1146
|
+
* @param {object} data @returns {boolean}
|
|
1147
|
+
*/
|
|
1148
|
+
sendRaw(e) {
|
|
1149
|
+
return this._sendCommand(e);
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Subscribe to the daemon's `journalctl -u reachy-mini-daemon`
|
|
1153
|
+
* stream over the WebRTC data channel.
|
|
1154
|
+
*
|
|
1155
|
+
* One daemon-side subprocess is shared across all local subscribers:
|
|
1156
|
+
* the first call sends `subscribe_logs`, removing the last subscriber
|
|
1157
|
+
* sends `unsubscribe_logs`. Calling the returned `unsubscribe()`
|
|
1158
|
+
* twice is a no-op.
|
|
1159
|
+
*
|
|
1160
|
+
* @param {{
|
|
1161
|
+
* onLine: (entry: { timestamp: string, line: string }) => void,
|
|
1162
|
+
* onError?: (error: string) => void,
|
|
1163
|
+
* }} options
|
|
1164
|
+
* @returns {() => void} unsubscribe
|
|
1165
|
+
*/
|
|
1166
|
+
subscribeLogs({ onLine: e, onError: t } = {}) {
|
|
1167
|
+
if (typeof e != "function")
|
|
1168
|
+
throw new TypeError("subscribeLogs: onLine callback is required");
|
|
1169
|
+
const s = { onLine: e, onError: t }, i = this._logSubscribers.size === 0;
|
|
1170
|
+
this._logSubscribers.add(s), i && this._sendCommand({ type: "subscribe_logs" });
|
|
1171
|
+
let o = !1;
|
|
1172
|
+
return () => {
|
|
1173
|
+
o || (o = !0, this._logSubscribers.delete(s), this._logSubscribers.size === 0 && this._sendCommand({ type: "unsubscribe_logs" }));
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Request a state snapshot. The response arrives as a "state" event.
|
|
1178
|
+
* Called automatically every 500 ms while streaming.
|
|
1179
|
+
*
|
|
1180
|
+
* Safe to call at a higher rate if you need faster telemetry: e.g.
|
|
1181
|
+
* ``setInterval(() => robot.requestState(), 20)`` for ~50 Hz, or drive
|
|
1182
|
+
* it from a ``requestAnimationFrame`` loop for display-rate updates.
|
|
1183
|
+
* On LAN the daemon can sustain ~90-100 Hz round-trips over the
|
|
1184
|
+
* datachannel; over the internet expect the WebRTC path's RTT to
|
|
1185
|
+
* dominate. The built-in 500 ms poll keeps running in parallel — it
|
|
1186
|
+
* is harmless, as state responses are idempotent.
|
|
1187
|
+
*
|
|
1188
|
+
* @returns {boolean}
|
|
1189
|
+
*/
|
|
1190
|
+
requestState() {
|
|
1191
|
+
return this._sendCommand({ type: "get_state" });
|
|
1192
|
+
}
|
|
1193
|
+
// ─── Audio ───────────────────────────────────────────────────────────
|
|
1194
|
+
/**
|
|
1195
|
+
* Mute/unmute the robot's audio playback (speaker) locally.
|
|
1196
|
+
* Audio is muted by default — browsers require a user gesture to unmute.
|
|
1197
|
+
* @param {boolean} muted
|
|
1198
|
+
*/
|
|
1199
|
+
setAudioMuted(e) {
|
|
1200
|
+
this._audioMuted = e, this._videoElement && (this._videoElement.muted = e);
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Mute/unmute your microphone. Only works if micSupported is true.
|
|
1204
|
+
* Mic is muted by default even after acquisition.
|
|
1205
|
+
* @param {boolean} muted
|
|
1206
|
+
*/
|
|
1207
|
+
setMicMuted(e) {
|
|
1208
|
+
this._micMuted = e, this._micStream && this._micStream.getAudioTracks().forEach((t) => {
|
|
1209
|
+
t.enabled = !e;
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
// ─── Video helper ────────────────────────────────────────────────────
|
|
1213
|
+
/**
|
|
1214
|
+
* Bind a `<video>` element to this robot's stream.
|
|
1215
|
+
* Call before startSession(). Sets srcObject when the video track arrives,
|
|
1216
|
+
* applies audio mute state, and runs a latency monitor that snaps to the
|
|
1217
|
+
* live edge if the buffer grows > 0.5 s.
|
|
1218
|
+
*
|
|
1219
|
+
* @param {HTMLVideoElement} videoElement
|
|
1220
|
+
* @returns {() => void} cleanup function — call to detach video and stop monitoring
|
|
1221
|
+
*/
|
|
1222
|
+
attachVideo(e) {
|
|
1223
|
+
this._videoElement = e, e.muted = this._audioMuted;
|
|
1224
|
+
const t = (i) => {
|
|
1225
|
+
e.srcObject = i.detail.stream, e.playsInline = !0, "requestVideoFrameCallback" in e && this._startLatencyMonitor(e);
|
|
1226
|
+
}, s = () => {
|
|
1227
|
+
e.srcObject = null;
|
|
1228
|
+
};
|
|
1229
|
+
return this.addEventListener("videoTrack", t), this.addEventListener("sessionStopped", s), () => {
|
|
1230
|
+
this.removeEventListener("videoTrack", t), this.removeEventListener("sessionStopped", s), this._latencyMonitorId && (clearInterval(this._latencyMonitorId), this._latencyMonitorId = null), e.srcObject = null, this._videoElement = null;
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
// ─── Daemon-side recorded-move playback ──────────────────────────────
|
|
1234
|
+
//
|
|
1235
|
+
// These methods talk to the daemon's `feature/daemon-side-move-upload`
|
|
1236
|
+
// protocol: motion (and optional audio) are uploaded once over the
|
|
1237
|
+
// data channel, then the daemon's play_move loop runs at the requested
|
|
1238
|
+
// frequency server-side — no per-frame WebRTC round-trip, smooth on
|
|
1239
|
+
// wireless robots. Audio, when present, plays on the same daemon-side
|
|
1240
|
+
// GStreamer pipeline so motion and sound share a single clock.
|
|
1241
|
+
//
|
|
1242
|
+
// For record-time flows that need the SAME audio pipeline at capture
|
|
1243
|
+
// and replay (so pipeline latency cancels), use uploadAudio +
|
|
1244
|
+
// playUploadedAudio to play audio standalone with a sync anchor.
|
|
1245
|
+
/**
|
|
1246
|
+
* Upload a recorded move (and optionally its audio) and play it on
|
|
1247
|
+
* the daemon's local clock. Resolves when playback ends with the
|
|
1248
|
+
* daemon's final broadcast — `{finished: true}`, `{cancelled: true}`,
|
|
1249
|
+
* or `{error: string}`.
|
|
1250
|
+
*
|
|
1251
|
+
* `audioLeadMs` shifts audio relative to motion:
|
|
1252
|
+
* - Positive: audio fires N ms BEFORE motion (compensates motor pickup).
|
|
1253
|
+
* - Negative: motion fires N ms BEFORE audio (compensates pipeline warmup).
|
|
1254
|
+
* - Default `-100` is the empirically-measured system-wide constant
|
|
1255
|
+
* (combined motor + GStreamer playbin warmup); tune per setup only
|
|
1256
|
+
* if you've measured a different value.
|
|
1257
|
+
*
|
|
1258
|
+
* @param {{ time: number[], set_target_data: object[] }} motion
|
|
1259
|
+
* @param {object} [opts]
|
|
1260
|
+
* @param {Blob} [opts.audioBlob] - canonical 16 kHz mono PCM WAV
|
|
1261
|
+
* @param {number} [opts.audioLeadMs=-100]
|
|
1262
|
+
* @param {string} [opts.description="move"]
|
|
1263
|
+
* @param {"gzip+base64"|"json"} [opts.encoding="gzip+base64"]
|
|
1264
|
+
* @param {number} [opts.playFrequency=100]
|
|
1265
|
+
* @param {number} [opts.initialGotoDuration=0]
|
|
1266
|
+
* @param {number} [opts.startTimeoutMs=8000]
|
|
1267
|
+
* @param {(p: { phase: string, sent?: number, total?: number,
|
|
1268
|
+
* bytes?: number, encoding?: string,
|
|
1269
|
+
* duration_s?: number }) => void} [opts.onProgress]
|
|
1270
|
+
* @param {(s: { duration_s: number, has_audio: boolean }) => void} [opts.onStarted]
|
|
1271
|
+
* @returns {Promise<{ finished?: boolean, cancelled?: boolean,
|
|
1272
|
+
* error?: string, has_audio?: boolean }>}
|
|
1273
|
+
*/
|
|
1274
|
+
async playMove(e, {
|
|
1275
|
+
audioBlob: t = null,
|
|
1276
|
+
audioLeadMs: s = -100,
|
|
1277
|
+
description: i = "move",
|
|
1278
|
+
encoding: o = "gzip+base64",
|
|
1279
|
+
playFrequency: n = 100,
|
|
1280
|
+
initialGotoDuration: a = 0,
|
|
1281
|
+
startTimeoutMs: c = 8e3,
|
|
1282
|
+
onProgress: u = () => {
|
|
1283
|
+
},
|
|
1284
|
+
onStarted: d = () => {
|
|
1285
|
+
}
|
|
1286
|
+
} = {}) {
|
|
1287
|
+
if (!this._dc || this._dc.readyState !== "open")
|
|
1288
|
+
throw new Error("data channel not open");
|
|
1289
|
+
if (!e?.time?.length || !e?.set_target_data?.length)
|
|
1290
|
+
throw new Error("playMove: motion must have time + set_target_data");
|
|
1291
|
+
const l = j();
|
|
1292
|
+
this._activeMoveUploadId = l;
|
|
1293
|
+
const p = {
|
|
1294
|
+
description: i,
|
|
1295
|
+
time: e.time,
|
|
1296
|
+
set_target_data: e.set_target_data
|
|
1297
|
+
}, f = JSON.stringify(p);
|
|
1298
|
+
let m, b;
|
|
1299
|
+
o === "gzip+base64" && W() ? (m = await z(f), b = "gzip+base64") : (m = f, b = "json");
|
|
1300
|
+
const w = Math.ceil(m.length / y) || 1;
|
|
1301
|
+
u({
|
|
1302
|
+
phase: "starting",
|
|
1303
|
+
sent: 0,
|
|
1304
|
+
total: w,
|
|
1305
|
+
bytes: m.length,
|
|
1306
|
+
encoding: b
|
|
1307
|
+
}), this._sendCommand({
|
|
1308
|
+
type: "upload_move_start",
|
|
1309
|
+
upload_id: l,
|
|
1310
|
+
total_chunks: w,
|
|
1311
|
+
description: i,
|
|
1312
|
+
encoding: b
|
|
1313
|
+
});
|
|
1314
|
+
for (let h = 0; h < w; h++) {
|
|
1315
|
+
this._dc.bufferedAmount > E && await this._awaitDataChannelDrain();
|
|
1316
|
+
const _ = h * y;
|
|
1317
|
+
this._sendCommand({
|
|
1318
|
+
type: "upload_move_chunk",
|
|
1319
|
+
upload_id: l,
|
|
1320
|
+
chunk_index: h,
|
|
1321
|
+
chunk: m.slice(_, _ + y)
|
|
1322
|
+
}), u({ phase: "upload", sent: h + 1, total: w });
|
|
1323
|
+
}
|
|
1324
|
+
if (this._sendCommand({ type: "upload_move_finish", upload_id: l }), u({ phase: "uploaded", sent: w, total: w }), t) {
|
|
1325
|
+
const h = new Uint8Array(await t.arrayBuffer()), _ = A(h), v = Math.ceil(_.length / y) || 1;
|
|
1326
|
+
u({
|
|
1327
|
+
phase: "audio-starting",
|
|
1328
|
+
sent: 0,
|
|
1329
|
+
total: v,
|
|
1330
|
+
bytes: _.length
|
|
1331
|
+
}), this._sendCommand({
|
|
1332
|
+
type: "upload_audio_start",
|
|
1333
|
+
upload_id: l,
|
|
1334
|
+
total_chunks: v,
|
|
1335
|
+
encoding: "wav-base64",
|
|
1336
|
+
description: i
|
|
1337
|
+
});
|
|
1338
|
+
for (let I = 0; I < v; I++) {
|
|
1339
|
+
this._dc.bufferedAmount > E && await this._awaitDataChannelDrain();
|
|
1340
|
+
const T = I * y;
|
|
1341
|
+
this._sendCommand({
|
|
1342
|
+
type: "upload_audio_chunk",
|
|
1343
|
+
upload_id: l,
|
|
1344
|
+
chunk_index: I,
|
|
1345
|
+
chunk: _.slice(T, T + y)
|
|
1346
|
+
}), u({ phase: "audio-upload", sent: I + 1, total: v });
|
|
1347
|
+
}
|
|
1348
|
+
this._sendCommand({ type: "upload_audio_finish", upload_id: l }), u({ phase: "audio-uploaded", sent: v, total: v });
|
|
1349
|
+
}
|
|
1350
|
+
this._sendCommand({
|
|
1351
|
+
type: "play_uploaded_move",
|
|
1352
|
+
upload_id: l,
|
|
1353
|
+
play_frequency: n,
|
|
1354
|
+
initial_goto_duration: a,
|
|
1355
|
+
audio_lead_ms: s
|
|
1356
|
+
});
|
|
1357
|
+
let g;
|
|
1358
|
+
try {
|
|
1359
|
+
g = await this._waitForBroadcast(
|
|
1360
|
+
(h) => h?.type === "play_uploaded_move" && h?.upload_id === l && (h.started === !0 || typeof h.error == "string"),
|
|
1361
|
+
{ timeoutMs: c, debugLabel: "play_uploaded_move started" }
|
|
1362
|
+
);
|
|
1363
|
+
} catch (h) {
|
|
1364
|
+
throw new Error(
|
|
1365
|
+
`Daemon did not respond to play_uploaded_move (requires the reachy_mini daemon with feature/daemon-side-move-upload). Underlying: ${h.message}`
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
if (typeof g.error == "string")
|
|
1369
|
+
throw new Error(`play_uploaded_move: ${g.error}`);
|
|
1370
|
+
try {
|
|
1371
|
+
d({
|
|
1372
|
+
duration_s: g.duration_s,
|
|
1373
|
+
has_audio: g.has_audio === !0
|
|
1374
|
+
});
|
|
1375
|
+
} catch (h) {
|
|
1376
|
+
console.warn("playMove.onStarted threw:", h);
|
|
1377
|
+
}
|
|
1378
|
+
u({ phase: "playing", duration_s: g.duration_s });
|
|
1379
|
+
const R = await this._waitForBroadcast(
|
|
1380
|
+
(h) => h?.type === "play_uploaded_move" && h?.upload_id === l && (h.finished === !0 || h.cancelled === !0 || typeof h.error == "string"),
|
|
1381
|
+
{
|
|
1382
|
+
timeoutMs: (g.duration_s + 30) * 1e3,
|
|
1383
|
+
debugLabel: "play_uploaded_move final"
|
|
1384
|
+
}
|
|
1385
|
+
);
|
|
1386
|
+
return this._activeMoveUploadId === l && (this._activeMoveUploadId = null), R;
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Cancel an in-flight `playMove`. Fire-and-forget; the daemon
|
|
1390
|
+
* broadcasts the cancelled event which `playMove` resolves with.
|
|
1391
|
+
*
|
|
1392
|
+
* Pass `uploadId` explicitly to target a specific run; defaults to
|
|
1393
|
+
* the most recent in-flight `playMove`. The daemon now scopes
|
|
1394
|
+
* cancels by upload_id so two back-to-back plays can't cross-cancel
|
|
1395
|
+
* each other — a cancel with no live target is a no-op.
|
|
1396
|
+
*
|
|
1397
|
+
* @param {string} [uploadId] - optional; defaults to the active playMove id
|
|
1398
|
+
* @returns {boolean} false if the data channel isn't open or no run to target
|
|
1399
|
+
*/
|
|
1400
|
+
cancelMove(e = null) {
|
|
1401
|
+
const t = e ?? this._activeMoveUploadId;
|
|
1402
|
+
return t ? this._sendCommand({ type: "cancel_move", upload_id: t }) : !1;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Upload audio to the daemon as a standalone slot (no motion attached).
|
|
1406
|
+
* Used by recording flows that want the SAME audio pipeline at record
|
|
1407
|
+
* time and play time — pipeline latency cancels, so a single per-system
|
|
1408
|
+
* `audioLeadMs` is enough for sync.
|
|
1409
|
+
*
|
|
1410
|
+
* @param {Blob} audioBlob - canonical 16 kHz mono PCM WAV
|
|
1411
|
+
* @param {object} [opts]
|
|
1412
|
+
* @param {string} [opts.description="audio"]
|
|
1413
|
+
* @param {(p: { phase: string, sent?: number, total?: number,
|
|
1414
|
+
* bytes?: number }) => void} [opts.onProgress]
|
|
1415
|
+
* @returns {Promise<string>} uploadId — pair with playUploadedAudio
|
|
1416
|
+
*/
|
|
1417
|
+
async uploadAudio(e, { description: t = "audio", onProgress: s = () => {
|
|
1418
|
+
} } = {}) {
|
|
1419
|
+
if (!this._dc || this._dc.readyState !== "open")
|
|
1420
|
+
throw new Error("data channel not open");
|
|
1421
|
+
if (!(e instanceof Blob))
|
|
1422
|
+
throw new TypeError("uploadAudio: expected a Blob");
|
|
1423
|
+
const i = j(), o = new Uint8Array(await e.arrayBuffer()), n = A(o), a = Math.ceil(n.length / y) || 1;
|
|
1424
|
+
s({ phase: "audio-starting", sent: 0, total: a, bytes: n.length }), this._sendCommand({
|
|
1425
|
+
type: "upload_audio_start",
|
|
1426
|
+
upload_id: i,
|
|
1427
|
+
total_chunks: a,
|
|
1428
|
+
encoding: "wav-base64",
|
|
1429
|
+
description: t
|
|
1430
|
+
});
|
|
1431
|
+
for (let c = 0; c < a; c++) {
|
|
1432
|
+
this._dc.bufferedAmount > E && await this._awaitDataChannelDrain();
|
|
1433
|
+
const u = c * y;
|
|
1434
|
+
this._sendCommand({
|
|
1435
|
+
type: "upload_audio_chunk",
|
|
1436
|
+
upload_id: i,
|
|
1437
|
+
chunk_index: c,
|
|
1438
|
+
chunk: n.slice(u, u + y)
|
|
1439
|
+
}), s({ phase: "audio-upload", sent: c + 1, total: a });
|
|
1440
|
+
}
|
|
1441
|
+
return this._sendCommand({ type: "upload_audio_finish", upload_id: i }), s({ phase: "audio-uploaded", sent: a, total: a }), i;
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Trigger daemon-side playback of a previously-uploaded audio.
|
|
1445
|
+
* Resolves when the daemon broadcasts the "started" event — this is
|
|
1446
|
+
* the sync anchor callers use as t=0 for related capture.
|
|
1447
|
+
*
|
|
1448
|
+
* The daemon does NOT emit a finished event for standalone audio;
|
|
1449
|
+
* callers know the duration from the WAV header and send
|
|
1450
|
+
* `cancelAudio()` when they're done (e.g. recording stopped).
|
|
1451
|
+
*
|
|
1452
|
+
* @param {string} uploadId
|
|
1453
|
+
* @param {object} [opts]
|
|
1454
|
+
* @param {number} [opts.timeoutMs=8000]
|
|
1455
|
+
* @returns {Promise<{ started: true }>}
|
|
1456
|
+
*/
|
|
1457
|
+
async playUploadedAudio(e, { timeoutMs: t = 8e3 } = {}) {
|
|
1458
|
+
if (!this._dc || this._dc.readyState !== "open")
|
|
1459
|
+
throw new Error("data channel not open");
|
|
1460
|
+
const s = this._waitForBroadcast(
|
|
1461
|
+
(o) => o?.type === "play_uploaded_audio" && o?.upload_id === e && (o.started === !0 || typeof o.error == "string"),
|
|
1462
|
+
{ timeoutMs: t, debugLabel: "play_uploaded_audio started" }
|
|
1463
|
+
);
|
|
1464
|
+
this._sendCommand({ type: "play_uploaded_audio", upload_id: e });
|
|
1465
|
+
const i = await s;
|
|
1466
|
+
if (typeof i.error == "string") throw new Error(i.error);
|
|
1467
|
+
return this._activeAudioUploadId = e, i;
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Cancel an in-flight `playUploadedAudio`. Fire-and-forget.
|
|
1471
|
+
*
|
|
1472
|
+
* Pass `uploadId` explicitly to target a specific run; defaults
|
|
1473
|
+
* to the most recent `playUploadedAudio`. The daemon scopes
|
|
1474
|
+
* cancels by upload_id so a stale cancel won't kill the audio
|
|
1475
|
+
* attached to a concurrently-running `playMove`.
|
|
1476
|
+
*
|
|
1477
|
+
* @param {string} [uploadId] - optional; defaults to the active playUploadedAudio id
|
|
1478
|
+
* @returns {boolean} false if the data channel isn't open or no run to target
|
|
1479
|
+
*/
|
|
1480
|
+
cancelAudio(e = null) {
|
|
1481
|
+
const t = e ?? this._activeAudioUploadId;
|
|
1482
|
+
return t ? (this._activeAudioUploadId === t && (this._activeAudioUploadId = null), this._sendCommand({ type: "cancel_audio", upload_id: t })) : !1;
|
|
1483
|
+
}
|
|
1484
|
+
// ─── Private ─────────────────────────────────────────────────────────
|
|
1485
|
+
_emit(e, t) {
|
|
1486
|
+
this.dispatchEvent(new CustomEvent(e, { detail: t }));
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Register a one-shot waiter for a daemon broadcast event. Resolves
|
|
1490
|
+
* with the matching payload, rejects on `timeoutMs`. Used internally
|
|
1491
|
+
* by `playMove` / `playUploadedAudio`.
|
|
1492
|
+
*/
|
|
1493
|
+
_waitForBroadcast(e, { timeoutMs: t = 5e3, debugLabel: s = "" } = {}) {
|
|
1494
|
+
return new Promise((i, o) => {
|
|
1495
|
+
const n = { predicate: e, resolve: i };
|
|
1496
|
+
n.timer = setTimeout(() => {
|
|
1497
|
+
const a = this._broadcastWaiters.indexOf(n);
|
|
1498
|
+
a !== -1 && this._broadcastWaiters.splice(a, 1), o(new Error(`broadcast timeout (${t} ms): ${s}`));
|
|
1499
|
+
}, t), this._broadcastWaiters.push(n);
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Wait until `_dc.bufferedAmount` drops below the low watermark. Polls
|
|
1504
|
+
* at ~30 ms; browsers don't expose a uniform event for SCTP data channels
|
|
1505
|
+
* (`onbufferedamountlow` is patchy across engines).
|
|
1506
|
+
*/
|
|
1507
|
+
async _awaitDataChannelDrain() {
|
|
1508
|
+
for (; this._dc && this._dc.bufferedAmount > H; )
|
|
1509
|
+
if (await new Promise((e) => setTimeout(e, 30)), !this._dc || this._dc.readyState !== "open")
|
|
1510
|
+
throw new Error("data channel closed mid-upload");
|
|
1511
|
+
}
|
|
1512
|
+
async _sendToServer(e) {
|
|
1513
|
+
if (!this._token) throw new Error("No token — authenticate() first");
|
|
1514
|
+
try {
|
|
1515
|
+
const t = await fetch(`${this._signalingUrl}/send`, {
|
|
1516
|
+
method: "POST",
|
|
1517
|
+
headers: {
|
|
1518
|
+
"Content-Type": "application/json",
|
|
1519
|
+
Authorization: `Bearer ${this._token}`
|
|
1520
|
+
},
|
|
1521
|
+
body: JSON.stringify(e)
|
|
1522
|
+
});
|
|
1523
|
+
if (!t.ok) {
|
|
1524
|
+
let s = "";
|
|
1525
|
+
try {
|
|
1526
|
+
s = await t.text();
|
|
1527
|
+
} catch {
|
|
1528
|
+
}
|
|
1529
|
+
return console.warn(
|
|
1530
|
+
`[reachy-mini] /send rejected (${t.status}) for type=${e?.type}; body=${s || "<empty>"}`
|
|
1531
|
+
), null;
|
|
1532
|
+
}
|
|
1533
|
+
return await t.json();
|
|
1534
|
+
} catch (t) {
|
|
1535
|
+
return console.error("Send error:", t), null;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
_sendCommand(e) {
|
|
1539
|
+
return !this._dc || this._dc.readyState !== "open" ? !1 : (this._dc.send(JSON.stringify(e)), !0);
|
|
1540
|
+
}
|
|
1541
|
+
/** Resolves the startSession() promise once both ICE and datachannel are ready. */
|
|
1542
|
+
_checkSessionReady() {
|
|
1543
|
+
this._iceConnected && this._dcOpen && this._sessionResolve && (this._state = "streaming", this.requestState(), this._stateRefreshInterval = setInterval(() => this.requestState(), 500), this._emit("streaming", { sessionId: this._sessionId, robotId: this._selectedRobotId }), this._sessionResolve(), this._sessionResolve = null, this._sessionReject = null);
|
|
1544
|
+
}
|
|
1545
|
+
async _handleSignalingMessage(e) {
|
|
1546
|
+
switch (e.type) {
|
|
1547
|
+
case "welcome":
|
|
1548
|
+
break;
|
|
1549
|
+
// handled in connect()
|
|
1550
|
+
case "list":
|
|
1551
|
+
this._robots = e.producers || [], this._emit("robotsChanged", { robots: this._robots }), this._maybeAutoStart();
|
|
1552
|
+
break;
|
|
1553
|
+
case "peerStatusChanged": {
|
|
1554
|
+
const t = await this._sendToServer({ type: "list" });
|
|
1555
|
+
t?.producers && (this._robots = t.producers, this._emit("robotsChanged", { robots: this._robots }), this._maybeAutoStart());
|
|
1556
|
+
break;
|
|
1557
|
+
}
|
|
1558
|
+
case "sessionStarted":
|
|
1559
|
+
this._sessionId = e.sessionId;
|
|
1560
|
+
break;
|
|
1561
|
+
case "sessionRejected":
|
|
1562
|
+
this._failSessionRejected(e);
|
|
1563
|
+
break;
|
|
1564
|
+
case "endSession":
|
|
1565
|
+
this._handleEndSession(e);
|
|
1566
|
+
break;
|
|
1567
|
+
case "peer":
|
|
1568
|
+
this._handlePeerMessage(e);
|
|
1569
|
+
break;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Internal: handle an endSession pushed from central.
|
|
1574
|
+
*
|
|
1575
|
+
* Two user-visible scenarios converge here:
|
|
1576
|
+
*
|
|
1577
|
+
* 1. Pending startSession — central accepted our request but the
|
|
1578
|
+
* robot-side relay then refused (e.g. a local Python app holds
|
|
1579
|
+
* the daemon's robot lock). Without this handler, the client's
|
|
1580
|
+
* startSession() promise would hang forever because ICE/datachannel
|
|
1581
|
+
* never come up.
|
|
1582
|
+
* 2. Streaming was evicted — a local Python app started on the robot
|
|
1583
|
+
* while we were connected, so the relay tore down our session.
|
|
1584
|
+
*
|
|
1585
|
+
* The ``reason`` is forwarded verbatim from the relay through central:
|
|
1586
|
+
* - "robot_busy_local_app": daemon lock held by a local Python app
|
|
1587
|
+
* (refused before any media negotiation).
|
|
1588
|
+
* - "local_app_started": a local Python app started mid-session and
|
|
1589
|
+
* evicted us.
|
|
1590
|
+
* - "robot_busy_local": relay's safety-net for stale/concurrent
|
|
1591
|
+
* sessions (should be rare).
|
|
1592
|
+
*/
|
|
1593
|
+
_handleEndSession(e) {
|
|
1594
|
+
const t = e.reason, s = t === "robot_busy_local_app" ? "Robot is busy: a local Python app is running" : t === "local_app_started" ? "Disconnected: a local Python app started on the robot" : t === "robot_busy_local" ? "Robot is busy: another session is already active" : null;
|
|
1595
|
+
if (this._sessionReject) {
|
|
1596
|
+
const i = new Error(
|
|
1597
|
+
s || `Session ended before it could start: ${t || "unknown reason"}`
|
|
1598
|
+
);
|
|
1599
|
+
i.reason = t, this._emit("sessionRejected", { reason: t, activeApp: null }), this._pc && (this._pc.close(), this._pc = null), this._micStream && (this._micStream.getTracks().forEach((n) => n.stop()), this._micStream = null), this._iceConnected = !1, this._dcOpen = !1, this._micMuted = !0, this._micSupported = !1;
|
|
1600
|
+
const o = this._sessionReject;
|
|
1601
|
+
this._sessionResolve = null, this._sessionReject = null, o(i);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
this._state === "streaming" && (this._emit("sessionStopped", {
|
|
1605
|
+
reason: t || "remote_end",
|
|
1606
|
+
message: s
|
|
1607
|
+
}), this.stopSession().catch(() => {
|
|
1608
|
+
}));
|
|
1609
|
+
}
|
|
1610
|
+
async _handlePeerMessage(e) {
|
|
1611
|
+
if (this._pc)
|
|
1612
|
+
try {
|
|
1613
|
+
if (e.sdp) {
|
|
1614
|
+
const t = e.sdp;
|
|
1615
|
+
if (t.type === "offer") {
|
|
1616
|
+
const i = q(t.sdp);
|
|
1617
|
+
if (this._micSupported = i, this._emit("micSupported", { supported: i }), i && this._micStream)
|
|
1618
|
+
for (const n of this._micStream.getAudioTracks())
|
|
1619
|
+
this._pc.addTrack(n, this._micStream);
|
|
1620
|
+
await this._pc.setRemoteDescription(new RTCSessionDescription(t));
|
|
1621
|
+
const o = await this._pc.createAnswer();
|
|
1622
|
+
await this._pc.setLocalDescription(o), await this._sendToServer({
|
|
1623
|
+
type: "peer",
|
|
1624
|
+
sessionId: this._sessionId,
|
|
1625
|
+
sdp: { type: "answer", sdp: o.sdp }
|
|
1626
|
+
});
|
|
1627
|
+
} else
|
|
1628
|
+
await this._pc.setRemoteDescription(new RTCSessionDescription(t));
|
|
1629
|
+
const s = this._pendingRemoteIce;
|
|
1630
|
+
if (s && s.length) {
|
|
1631
|
+
this._pendingRemoteIce = [];
|
|
1632
|
+
for (const i of s)
|
|
1633
|
+
try {
|
|
1634
|
+
await this._pc.addIceCandidate(new RTCIceCandidate(i));
|
|
1635
|
+
} catch (o) {
|
|
1636
|
+
console.warn("[reachy-mini] buffered ICE candidate rejected:", o);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
if (e.ice) {
|
|
1641
|
+
if (!e.ice.candidate) return;
|
|
1642
|
+
this._pc.remoteDescription ? await this._pc.addIceCandidate(new RTCIceCandidate(e.ice)) : (this._pendingRemoteIce || (this._pendingRemoteIce = []), this._pendingRemoteIce.push(e.ice));
|
|
1643
|
+
}
|
|
1644
|
+
} catch (t) {
|
|
1645
|
+
console.error("WebRTC error:", t), this._emit("error", { source: "webrtc", error: t });
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
/** Parse robot messages and dispatch. */
|
|
1649
|
+
_handleRobotMessage(e) {
|
|
1650
|
+
if ("version" in e && this._versionResolve) {
|
|
1651
|
+
this._versionResolve(e.version), this._versionResolve = null;
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
if ("hardware_id" in e && this._hardwareIdResolve) {
|
|
1655
|
+
this._hardwareIdResolve(e.hardware_id), this._hardwareIdResolve = null;
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
if (e.command === "get_volume" || e.command === "set_volume") {
|
|
1659
|
+
this._volumeResolve && (this._volumeResolve(e.status === "error" ? null : e.volume), this._volumeResolve = null);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
if (e.command === "get_microphone_volume" || e.command === "set_microphone_volume") {
|
|
1663
|
+
this._micVolumeResolve && (this._micVolumeResolve(e.status === "error" ? null : e.volume), this._micVolumeResolve = null);
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
if ((e.command === "wake_up" || e.command === "goto_sleep") && this._pendingMotionCompletions && this._pendingMotionCompletions[e.command]) {
|
|
1667
|
+
const t = this._pendingMotionCompletions[e.command];
|
|
1668
|
+
if (e.completed === !0 && t.length > 0) {
|
|
1669
|
+
const s = t.shift();
|
|
1670
|
+
clearTimeout(s.timer), s.resolve();
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
if (e.error && t.length > 0) {
|
|
1674
|
+
const s = t.shift();
|
|
1675
|
+
clearTimeout(s.timer), s.reject(new Error(`${e.command}: ${e.error}`));
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
if (e.type === "log_line") {
|
|
1680
|
+
for (const t of this._logSubscribers)
|
|
1681
|
+
try {
|
|
1682
|
+
t.onLine({ timestamp: e.timestamp, line: e.line });
|
|
1683
|
+
} catch (s) {
|
|
1684
|
+
console.error("subscribeLogs onLine threw:", s);
|
|
1685
|
+
}
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
if (e.type === "log_stream_error") {
|
|
1689
|
+
for (const t of this._logSubscribers)
|
|
1690
|
+
if (typeof t.onError == "function")
|
|
1691
|
+
try {
|
|
1692
|
+
t.onError(e.error);
|
|
1693
|
+
} catch (s) {
|
|
1694
|
+
console.error("subscribeLogs onError threw:", s);
|
|
1695
|
+
}
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
if (e.state) {
|
|
1699
|
+
const t = e.state;
|
|
1700
|
+
t.head_pose && (this._robotState.head = t.head_pose.flat()), t.antennas && (this._robotState.antennas = [t.antennas[0], t.antennas[1]]), typeof t.body_yaw == "number" && (this._robotState.body_yaw = t.body_yaw), t.motor_mode && (this._robotState.motor_mode = t.motor_mode), typeof t.is_move_running == "boolean" && (this._robotState.is_move_running = t.is_move_running), this._emit("state", { ...this._robotState });
|
|
1701
|
+
}
|
|
1702
|
+
if (e.error && this._emit("error", { source: "robot", error: e.error }), this._broadcastWaiters.length > 0)
|
|
1703
|
+
for (let t = this._broadcastWaiters.length - 1; t >= 0; t--) {
|
|
1704
|
+
const s = this._broadcastWaiters[t];
|
|
1705
|
+
if (s.predicate(e)) {
|
|
1706
|
+
this._broadcastWaiters.splice(t, 1), clearTimeout(s.timer), s.resolve(e);
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
/** Snap video playback to live edge if buffered lag exceeds 0.5 s. */
|
|
1712
|
+
_startLatencyMonitor(e) {
|
|
1713
|
+
this._latencyMonitorId && clearInterval(this._latencyMonitorId), this._latencyMonitorId = setInterval(() => {
|
|
1714
|
+
if (!e.srcObject || e.paused) return;
|
|
1715
|
+
const t = e.buffered;
|
|
1716
|
+
if (t.length > 0) {
|
|
1717
|
+
const s = t.end(t.length - 1), i = s - e.currentTime;
|
|
1718
|
+
i > 0.5 && (console.log(`Latency correction: was ${i.toFixed(2)}s behind`), e.currentTime = s - 0.1);
|
|
1719
|
+
}
|
|
1720
|
+
}, 2e3);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
export {
|
|
1724
|
+
J as R
|
|
1725
|
+
};
|
|
1726
|
+
//# sourceMappingURL=reachy-mini-sdk-T3Zr7Hwc.js.map
|