@pollen-robotics/reachy-mini-sdk 1.10.0-dev.0.main.9f78da7 → 1.10.0-dev.0.main.c8367cd

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.
Files changed (121) hide show
  1. package/APP_CREATION_GUIDE.md +98 -0
  2. package/dist/lib/central-listener.d.ts +71 -0
  3. package/dist/lib/central-listener.d.ts.map +1 -0
  4. package/dist/lib/central-listener.js +277 -0
  5. package/dist/lib/central-listener.js.map +1 -0
  6. package/dist/lib/central-listener.test.d.ts +2 -0
  7. package/dist/lib/central-listener.test.d.ts.map +1 -0
  8. package/dist/lib/central-listener.test.js +378 -0
  9. package/dist/lib/central-listener.test.js.map +1 -0
  10. package/dist/lib/dc-watchdog.test.d.ts +2 -0
  11. package/dist/lib/dc-watchdog.test.d.ts.map +1 -0
  12. package/dist/lib/dc-watchdog.test.js +154 -0
  13. package/dist/lib/dc-watchdog.test.js.map +1 -0
  14. package/dist/lib/ensure-awake.test.d.ts +2 -0
  15. package/dist/lib/ensure-awake.test.d.ts.map +1 -0
  16. package/dist/lib/ensure-awake.test.js +117 -0
  17. package/dist/lib/ensure-awake.test.js.map +1 -0
  18. package/dist/lib/ice-grace.test.d.ts +2 -0
  19. package/dist/lib/ice-grace.test.d.ts.map +1 -0
  20. package/dist/lib/ice-grace.test.js +211 -0
  21. package/dist/lib/ice-grace.test.js.map +1 -0
  22. package/dist/lib/imu.test.d.ts +2 -0
  23. package/dist/lib/imu.test.d.ts.map +1 -0
  24. package/dist/lib/imu.test.js +78 -0
  25. package/dist/lib/imu.test.js.map +1 -0
  26. package/dist/lib/logger.d.ts +38 -0
  27. package/dist/lib/logger.d.ts.map +1 -0
  28. package/dist/lib/logger.js +81 -0
  29. package/dist/lib/logger.js.map +1 -0
  30. package/dist/lib/pending-replies.d.ts +127 -0
  31. package/dist/lib/pending-replies.d.ts.map +1 -0
  32. package/dist/lib/pending-replies.js +255 -0
  33. package/dist/lib/pending-replies.js.map +1 -0
  34. package/dist/lib/pending-replies.test.d.ts +2 -0
  35. package/dist/lib/pending-replies.test.d.ts.map +1 -0
  36. package/dist/lib/pending-replies.test.js +217 -0
  37. package/dist/lib/pending-replies.test.js.map +1 -0
  38. package/dist/lib/reachy-mini.d.ts +118 -94
  39. package/dist/lib/reachy-mini.d.ts.map +1 -1
  40. package/dist/lib/reachy-mini.js +427 -690
  41. package/dist/lib/reachy-mini.js.map +1 -1
  42. package/dist/lib/redial.test.d.ts +2 -0
  43. package/dist/lib/redial.test.d.ts.map +1 -0
  44. package/dist/lib/redial.test.js +206 -0
  45. package/dist/lib/redial.test.js.map +1 -0
  46. package/dist/lib/request.test.d.ts +2 -0
  47. package/dist/lib/request.test.d.ts.map +1 -0
  48. package/dist/lib/request.test.js +90 -0
  49. package/dist/lib/request.test.js.map +1 -0
  50. package/dist/lib/session-supervisor.d.ts +201 -0
  51. package/dist/lib/session-supervisor.d.ts.map +1 -0
  52. package/dist/lib/session-supervisor.js +520 -0
  53. package/dist/lib/session-supervisor.js.map +1 -0
  54. package/dist/lib/token-store.d.ts +50 -0
  55. package/dist/lib/token-store.d.ts.map +1 -0
  56. package/dist/lib/token-store.js +120 -0
  57. package/dist/lib/token-store.js.map +1 -0
  58. package/dist/lib/token-store.test.d.ts +2 -0
  59. package/dist/lib/token-store.test.d.ts.map +1 -0
  60. package/dist/lib/token-store.test.js +82 -0
  61. package/dist/lib/token-store.test.js.map +1 -0
  62. package/dist/lib/types.d.ts +261 -6
  63. package/dist/lib/types.d.ts.map +1 -1
  64. package/dist/lib/url-helpers.d.ts.map +1 -1
  65. package/dist/lib/url-helpers.js +3 -1
  66. package/dist/lib/url-helpers.js.map +1 -1
  67. package/dist/lib/version.d.ts +2 -0
  68. package/dist/lib/version.d.ts.map +1 -0
  69. package/dist/lib/version.js +5 -0
  70. package/dist/lib/version.js.map +1 -0
  71. package/dist/reachy-mini-sdk.d.ts +15 -1
  72. package/dist/reachy-mini-sdk.d.ts.map +1 -1
  73. package/dist/reachy-mini-sdk.js +12 -0
  74. package/dist/reachy-mini-sdk.js.map +1 -1
  75. package/host/dist/ReachyHost.d.ts.map +1 -1
  76. package/host/dist/chunks/centralRest-uXNMidlL.js +2315 -0
  77. package/host/dist/chunks/index-Beu4GNzi.js +821 -0
  78. package/host/dist/chunks/{mountHost-DztGlWwu.js → mountHost-BTTF4Uu9.js} +26739 -29305
  79. package/host/dist/components/DaemonUpdateGate.d.ts +65 -0
  80. package/host/dist/components/DaemonUpdateGate.d.ts.map +1 -0
  81. package/host/dist/components/ErrorView.d.ts.map +1 -1
  82. package/host/dist/components/HostErrorBoundary.d.ts +30 -0
  83. package/host/dist/components/HostErrorBoundary.d.ts.map +1 -0
  84. package/host/dist/components/PickerView.d.ts +16 -14
  85. package/host/dist/components/PickerView.d.ts.map +1 -1
  86. package/host/dist/components/PostOAuthSplash.d.ts +14 -9
  87. package/host/dist/components/PostOAuthSplash.d.ts.map +1 -1
  88. package/host/dist/components/ReachyHostShell.d.ts.map +1 -1
  89. package/host/dist/components/SignInView.d.ts.map +1 -1
  90. package/host/dist/components/TopBar.d.ts +5 -6
  91. package/host/dist/components/TopBar.d.ts.map +1 -1
  92. package/host/dist/embed/index.d.ts +8 -5
  93. package/host/dist/embed/index.d.ts.map +1 -1
  94. package/host/dist/entry/auto.js +2 -2
  95. package/host/dist/entry/embed.js +2 -2
  96. package/host/dist/hooks/useHostBridge.d.ts +35 -1
  97. package/host/dist/hooks/useHostBridge.d.ts.map +1 -1
  98. package/host/dist/hooks/useLatestDaemonVersion.d.ts +7 -0
  99. package/host/dist/hooks/useLatestDaemonVersion.d.ts.map +1 -0
  100. package/host/dist/hooks/useOAuth.d.ts +18 -0
  101. package/host/dist/hooks/useOAuth.d.ts.map +1 -1
  102. package/host/dist/hooks/useRobots.d.ts +0 -2
  103. package/host/dist/hooks/useRobots.d.ts.map +1 -1
  104. package/host/dist/index.js +3 -3
  105. package/host/dist/lib/daemonRelease.d.ts +84 -0
  106. package/host/dist/lib/daemonRelease.d.ts.map +1 -0
  107. package/host/dist/lib/protocol.d.ts +141 -7
  108. package/host/dist/lib/protocol.d.ts.map +1 -1
  109. package/host/dist/lib/rebootWatch.d.ts +51 -0
  110. package/host/dist/lib/rebootWatch.d.ts.map +1 -0
  111. package/host/dist/lib/sdk-types.d.ts +1 -1
  112. package/host/dist/lib/sdk-types.d.ts.map +1 -1
  113. package/host/dist/lib/sdkStaleness.d.ts +78 -0
  114. package/host/dist/lib/sdkStaleness.d.ts.map +1 -0
  115. package/host/dist/lib/settings.d.ts +16 -5
  116. package/host/dist/lib/settings.d.ts.map +1 -1
  117. package/package.json +9 -2
  118. package/host/dist/chunks/index-rW7LY0k3.js +0 -530
  119. package/host/dist/chunks/reachy-mini-DblK_lhG.js +0 -1749
  120. package/host/dist/lib/centralListener.d.ts +0 -73
  121. package/host/dist/lib/centralListener.d.ts.map +0 -1
@@ -4,42 +4,18 @@
4
4
  * a quick-start guide.
5
5
  */
6
6
  import { oauthHandleRedirectIfPresent, oauthLoginUrl, } from '@huggingface/hub';
7
+ import { createLogger } from './logger.js';
7
8
  import { degToRad, rpyToMatrix } from './math.js';
9
+ import { BroadcastTimeoutError, PendingReplies, SLOT_ROUNDTRIP_TIMEOUT_MS } from './pending-replies.js';
10
+ import { SessionSupervisor } from './session-supervisor.js';
11
+ import { SDK_VERSION } from './version.js';
8
12
  import { consumeFragmentCredentials, readPreselectedRobotIdFromUrl, sdpHasAudioSendRecv, } from './url-helpers.js';
13
+ import { clearStoredToken, consumeOAuthErrorParams, readUsableToken, writeStoredToken, } from './token-store.js';
9
14
  import { UPLOAD_CHUNK_SIZE, UPLOAD_BUFFERED_HIGH_WATER, UPLOAD_BUFFERED_LOW_WATER, hasCompressionStream, makeUploadId, bytesToBase64, gzipBase64, clampVolume, audioUploadEncoding, } from './upload-helpers.js';
15
+ const log = createLogger('sdk');
10
16
  // ─── Internal constants ──────────────────────────────────────────────────────
11
- /**
12
- * How long we tolerate `iceConnectionState === 'disconnected'` before
13
- * surfacing it as an error. The spec defines this state as transient
14
- * (browsers keep STUN keep-alives running and usually heal in 1-2 s
15
- * on WiFi blips, AP roams, brief 4G dropouts). Consumers watching
16
- * `iceStateChange` directly should outlive this window before
17
- * showing any fatal UI.
18
- */
19
- const ICE_DISCONNECT_GRACE_MS = 3000;
20
- /**
21
- * Grace before treating `iceConnectionState === 'failed'` as terminal.
22
- * The spec says `failed` IS terminal, but we've observed real
23
- * `failed → connected` flips on rapid AP roams and iOS BT route
24
- * changes — 1 s of debounce absorbs those without noticeably
25
- * delaying a real failure.
26
- */
27
- const ICE_FAILED_GRACE_MS = 1000;
28
- /**
29
- * Ceiling on how long we'll keep `_armIceGraceOnVisibility` waiting
30
- * for the tab to come back. The daemon's `webrtcsink` runs a STUN
31
- * consent-freshness check (RFC 7675, ~30 s default) and unilaterally
32
- * tears its side of the session down past that window, releasing the
33
- * producer slot on central. If the user backgrounded the tab for
34
- * longer than this, running another 3 s foreground grace is a lie —
35
- * the underlying transport is gone, nothing can recover. Give up
36
- * straight away so the host shows the real "session expired" UX
37
- * instead of a fake "Reconnecting…" badge that's never going to
38
- * heal. 60 s gives a 2× margin over the daemon-side timeout — long
39
- * enough to absorb a "phone in pocket for 45 s" case, short enough
40
- * to be honest with the user.
41
- */
42
- const MAX_VISIBILITY_DEFER_MS = 60_000;
17
+ // Resilience tunables (ICE grace windows, re-dial backoff, DC-silence
18
+ // watchdog thresholds) live in `session-supervisor.ts`.
43
19
  /**
44
20
  * How long a pushed pose frame keeps the periodic `get_state` poll on hold -
45
21
  * both on the way out (no request is sent) and on the way back (a reply that
@@ -56,19 +32,14 @@ const MAX_VISIBILITY_DEFER_MS = 60_000;
56
32
  */
57
33
  const POSE_STREAM_FRESH_MS = 750;
58
34
  /**
59
- * Fail-open ceiling for a single `_slotRoundtrip` request/response.
60
- * Every slot command has a strict "one reply per request" contract, so
61
- * a missing reply means the daemon either never got it or - crucially -
62
- * predates that command entirely: an older daemon silently drops an
63
- * unknown `type` and sends nothing back, which would otherwise leave the
64
- * caller's promise pending forever (e.g. a newer SDK calling a command a
65
- * 1.8.x daemon doesn't implement). Resolving `null` on timeout maps
66
- * cleanly onto the "unsupported / failed" value every slot caller already
67
- * handles. 4 s is comfortably above a WebRTC data-channel round trip on a
68
- * congested phone link while still failing fast enough that a gated UI
69
- * doesn't feel hung.
35
+ * Upper bound on how long `ensureAwake()` waits for the wake trajectory to
36
+ * complete. The emote itself takes ~2-3 s on hardware; the extra headroom
37
+ * covers a cold trajectory player. Deliberately shorter than `wakeUp()`'s
38
+ * own 8 s default: `ensureAwake()` gates app boot, and a daemon that hasn't
39
+ * confirmed within 5 s isn't going to - better to let the app in degraded
40
+ * than trap it on the splash.
70
41
  */
71
- const SLOT_ROUNDTRIP_TIMEOUT_MS = 4000;
42
+ const WAKE_TRAJECTORY_BUDGET_MS = 5000;
72
43
  export class ReachyMini extends EventTarget {
73
44
  // ─── Config ──────────────────────────────────────────────────────────
74
45
  _signalingUrl;
@@ -115,32 +86,17 @@ export class ReachyMini extends EventTarget {
115
86
  // ─── Timers ──────────────────────────────────────────────────────────
116
87
  _latencyMonitorId = null;
117
88
  _stateRefreshInterval = null;
118
- // ─── Single-slot promise resolvers ───────────────────────────────────
119
- _versionResolve = null;
120
- _hardwareIdResolve = null;
121
- _volumeResolve = null;
122
- _micVolumeResolve = null;
123
- _firstWakeUpResolve = null;
124
- _trackedFaceResolve = null;
125
- _robotNameResolve = null;
126
- _deleteHfTokenResolve = null;
127
- // applyAudioConfig() / readAudioParameter() share the same single-slot
128
- // pattern as the volume helpers. Separate slots so the two can be
129
- // in-flight concurrently without collision.
130
- _applyAudioConfigResolve = null;
131
- _readAudioParameterResolve = null;
89
+ // ─── Pending replies (reply slots / JSON-RPC / motion / broadcast) ───
90
+ // All request/response waiters on the data channel live in one
91
+ // ledger so every teardown path settles them in a single call.
92
+ _pending = new PendingReplies();
132
93
  // ─── Log subscribers ─────────────────────────────────────────────────
133
94
  _logSubscribers = new Set();
134
95
  _updateProgressSubscribers = new Set();
135
- // ─── JSON-RPC app control (over the same DataChannel) ────────────────
136
- // rpcCall() sends {jsonrpc,id,method,params} and awaits the matching
137
- // response; onNotification() subscribes to one-way events (no id) the
138
- // robot/app pushes (conversation.phase/turn/transcript, ...).
139
- _rpcCounter = 0;
140
- _pendingRpc = new Map();
96
+ // ─── JSON-RPC notification listeners (one-way events, no id) ─────────
97
+ // onNotification() subscribes to events the robot/app pushes
98
+ // (conversation.phase/turn/transcript, ...).
141
99
  _rpcListeners = new Map();
142
- // ─── Broadcast waiters (playMove / playUploadedAudio) ────────────────
143
- _broadcastWaiters = [];
144
100
  // ─── Active upload ids for no-arg cancels ────────────────────────────
145
101
  _activeMoveUploadId = null;
146
102
  _activeAudioUploadId = null;
@@ -149,24 +105,12 @@ export class ReachyMini extends EventTarget {
149
105
  _sessionReject = null;
150
106
  _iceConnected = false;
151
107
  _dcOpen = false;
152
- // ─── Resilience: ICE-blip debounce + network awareness ──────────────
153
- // Backs `_scheduleIceGrace` / `_armIceGraceOnVisibility` and the
154
- // `networkOnline` / `networkOffline` / `networkChange` forwarders.
155
- // All three handler slots are scoped to the lifetime of a live
156
- // session (installed in `startSession`, cleared in
157
- // `stopSession` / `disconnect` / `_handleEndSession` /
158
- // `_failSessionRejected`).
159
- _iceGraceTimer = null;
160
- _iceGraceReason = null;
161
- _pendingVisibilityHandler = null;
162
- _onlineHandler = null;
163
- _offlineHandler = null;
164
- _connectionChangeHandler = null;
165
- // ─── Motion completion plumbing (wake_up / goto_sleep) ───────────────
166
- _pendingMotionCompletions = {
167
- wake_up: [],
168
- goto_sleep: [],
169
- };
108
+ // ─── Resilience ──────────────────────────────────────────────────────
109
+ // ICE-blip debounce, network awareness, automatic session re-dial and
110
+ // the data-channel silence watchdog all live in the supervisor; the
111
+ // class forwards pc/browser events to it and implements its deps as
112
+ // closures over the private state below (see the constructor).
113
+ _supervisor;
170
114
  // ─── Video element ───────────────────────────────────────────────────
171
115
  _videoElement = null;
172
116
  constructor(options = {}) {
@@ -182,6 +126,31 @@ export class ReachyMini extends EventTarget {
182
126
  this._autoStartFromUrl = options.autoStartFromUrl === true;
183
127
  this._autoStartAttempted = false;
184
128
  this._preselectedRobotId = readPreselectedRobotIdFromUrl();
129
+ // Arrow closures so every dep call reads the CURRENT class state.
130
+ this._supervisor = new SessionSupervisor({
131
+ iceState: () => this._pc?.iceConnectionState ?? null,
132
+ hasPc: () => !!this._pc,
133
+ isStreaming: () => this._state === 'streaming',
134
+ isMidSetup: () => !!(this._sessionResolve || this._sessionReject),
135
+ isSignalingDown: () => this._state === 'disconnected',
136
+ selectedRobotId: () => this._selectedRobotId,
137
+ rejectPendingSession: (err) => {
138
+ if (!this._sessionReject)
139
+ return false;
140
+ const reject = this._sessionReject;
141
+ this._sessionResolve = null;
142
+ this._sessionReject = null;
143
+ reject(err);
144
+ return true;
145
+ },
146
+ reconnectSignaling: () => this.connect(),
147
+ // The private dial skips the public startSession()'s
148
+ // cancelRedial, so the loop's own attempts don't cancel it.
149
+ dial: (robotId) => this._startSessionInternal(robotId),
150
+ teardownForRedial: () => this._teardownForRedial(),
151
+ nudgeState: () => { this.requestState(); },
152
+ emit: (name, detail) => this._emit(name, detail),
153
+ }, { autoReconnect: options.autoReconnect !== false });
185
154
  }
186
155
  // ─── Read-only properties ────────────────────────────────────────────
187
156
  get state() { return this._state; }
@@ -194,6 +163,24 @@ export class ReachyMini extends EventTarget {
194
163
  get audioMuted() { return this._audioMuted; }
195
164
  get preselectedRobotId() { return this._preselectedRobotId; }
196
165
  get isEmbedded() { return this._preselectedRobotId !== null; }
166
+ /**
167
+ * Live RTCPeerConnection, or `null` between sessions. Read-only escape
168
+ * hatch for stats sampling (`getStats()`); mutating it is unsupported.
169
+ * Auto-reconnect re-dials REPLACE this object, so re-read it on every
170
+ * use — never capture it across ticks.
171
+ */
172
+ get peerConnection() { return this._pc; }
173
+ /**
174
+ * Build version of this SDK (npm package `version`), injected from
175
+ * package.json at build time. This is the JS SDK's OWN version and is
176
+ * distinct from `getVersion()`, which asks the DAEMON its version over
177
+ * the data channel. `0.0.0-managed-by-ci` means an unreleased/branch
178
+ * build (npm releases carry a real semver).
179
+ */
180
+ get sdkVersion() { return SDK_VERSION; }
181
+ /** Same value as the instance `sdkVersion`, reachable without an
182
+ * instance: `ReachyMini.version`. */
183
+ static get version() { return SDK_VERSION; }
197
184
  /**
198
185
  * Internal: try to honour the `autoStartFromUrl` constructor
199
186
  * option. Called from the signaling-message handler after every
@@ -218,7 +205,7 @@ export class ReachyMini extends EventTarget {
218
205
  if (this._state !== 'connected')
219
206
  return;
220
207
  this.startSession(peerId).catch((err) => {
221
- console.warn('[reachy-mini] autoStartFromUrl: startSession rejected:', err);
208
+ log.warn('autoStartFromUrl: startSession rejected:', err);
222
209
  });
223
210
  }, 0);
224
211
  }
@@ -226,44 +213,63 @@ export class ReachyMini extends EventTarget {
226
213
  async authenticate() {
227
214
  try {
228
215
  consumeFragmentCredentials();
216
+ // A failed silent login (`login({ prompt: 'none' })`) returns as
217
+ // `?error=login_required` / `consent_required` query params.
218
+ // Strip them so they don't linger in the URL; the fall-through
219
+ // to the cached-token check below then reports "not signed in".
220
+ const silentError = consumeOAuthErrorParams();
221
+ if (silentError) {
222
+ log.info('silent sign-in declined:', silentError);
223
+ }
229
224
  const result = (await oauthHandleRedirectIfPresent());
230
225
  if (result) {
231
226
  this._username = result.userInfo.preferred_username || result.userInfo.name || null;
232
227
  this._token = result.accessToken;
233
228
  this._tokenExpires = result.accessTokenExpiresAt;
234
- sessionStorage.setItem('hf_token', this._token);
235
- sessionStorage.setItem('hf_username', this._username ?? '');
236
- sessionStorage.setItem('hf_token_expires', typeof this._tokenExpires === 'string'
237
- ? this._tokenExpires
238
- : this._tokenExpires.toISOString());
229
+ writeStoredToken({
230
+ token: this._token,
231
+ username: this._username ?? '',
232
+ expires: typeof this._tokenExpires === 'string'
233
+ ? this._tokenExpires
234
+ : this._tokenExpires.toISOString(),
235
+ });
239
236
  return true;
240
237
  }
241
- const t = sessionStorage.getItem('hf_token');
242
- const u = sessionStorage.getItem('hf_username');
243
- const e = sessionStorage.getItem('hf_token_expires');
244
- if (t && u && e && new Date(e) > new Date()) {
245
- this._token = t;
246
- this._username = u;
247
- this._tokenExpires = e;
238
+ // Cached-token path. `readUsableToken` enforces both the OAuth
239
+ // expiry and a sliding idle window (see token-store.ts).
240
+ const stored = readUsableToken();
241
+ if (stored && stored.username) {
242
+ this._token = stored.token;
243
+ this._username = stored.username;
244
+ this._tokenExpires = stored.expires;
248
245
  return true;
249
246
  }
250
247
  return false;
251
248
  }
252
249
  catch (e) {
253
- console.error('Auth error:', e);
250
+ log.error('authenticate failed:', e);
254
251
  return false;
255
252
  }
256
253
  }
257
- async login() {
254
+ async login(options) {
258
255
  const opts = {};
259
256
  if (this._clientId)
260
257
  opts.clientId = this._clientId;
261
- window.location.href = await oauthLoginUrl(opts);
258
+ let url = await oauthLoginUrl(opts);
259
+ // OIDC prompt param. `oauthLoginUrl` doesn't expose it, but the HF
260
+ // authorize endpoint honours it (verified empirically): with
261
+ // `prompt=none` an already-authorized user comes straight back with
262
+ // a code and no screen, anyone else comes back with `?error=...`
263
+ // instead of landing on the HF login page.
264
+ if (options?.prompt) {
265
+ const u = new URL(url);
266
+ u.searchParams.set('prompt', options.prompt);
267
+ url = u.toString();
268
+ }
269
+ window.location.href = url;
262
270
  }
263
271
  logout() {
264
- sessionStorage.removeItem('hf_token');
265
- sessionStorage.removeItem('hf_username');
266
- sessionStorage.removeItem('hf_token_expires');
272
+ clearStoredToken();
267
273
  this._username = null;
268
274
  this._tokenExpires = null;
269
275
  this.disconnect();
@@ -410,7 +416,7 @@ export class ReachyMini extends EventTarget {
410
416
  await this.ensureAwake();
411
417
  }
412
418
  catch (e) {
413
- console.warn('[reachy-mini] autoConnect: ensureAwake failed:', e);
419
+ log.warn('autoConnect: ensureAwake failed:', e);
414
420
  }
415
421
  }
416
422
  return { robotId, robotName, isEmbedded: this.isEmbedded };
@@ -444,7 +450,7 @@ export class ReachyMini extends EventTarget {
444
450
  return Array.from(seen.values()).sort((a, b) => (a.lastSeenAgeSeconds ?? Infinity) - (b.lastSeenAgeSeconds ?? Infinity));
445
451
  }
446
452
  catch (e) {
447
- console.warn('[reachy-mini] /api/robot-status unavailable, using SSE list:', e);
453
+ log.warn('/api/robot-status unavailable, using SSE list:', e);
448
454
  return (this._robots || []).map((r) => ({
449
455
  id: r.id,
450
456
  name: r.meta?.name ?? null,
@@ -474,6 +480,14 @@ export class ReachyMini extends EventTarget {
474
480
  });
475
481
  }
476
482
  async startSession(robotId) {
483
+ // An explicit dial from the app supersedes any in-flight
484
+ // auto-reconnect (possibly towards a different robot). The
485
+ // redial loop dials through `_startSessionInternal` directly,
486
+ // so its own attempts never trip this.
487
+ this._supervisor.cancelRedial();
488
+ return this._startSessionInternal(robotId);
489
+ }
490
+ async _startSessionInternal(robotId) {
477
491
  if (this._state !== 'connected')
478
492
  throw new Error('Not connected');
479
493
  this._selectedRobotId = robotId;
@@ -512,7 +526,7 @@ export class ReachyMini extends EventTarget {
512
526
  this._micMuted = true;
513
527
  }
514
528
  catch (e) {
515
- console.warn('Audio sender placeholder setup failed:', e);
529
+ log.warn('audio sender placeholder setup failed:', e);
516
530
  this._micStream = null;
517
531
  }
518
532
  this._pc = new RTCPeerConnection({
@@ -520,7 +534,7 @@ export class ReachyMini extends EventTarget {
520
534
  });
521
535
  // Scope `networkOnline` / `networkOffline` / `networkChange`
522
536
  // event forwarding to the lifetime of this session.
523
- this._installNetworkListeners();
537
+ this._supervisor.installNetworkListeners();
524
538
  return new Promise((resolve, reject) => {
525
539
  this._sessionResolve = resolve;
526
540
  this._sessionReject = reject;
@@ -564,30 +578,17 @@ export class ReachyMini extends EventTarget {
564
578
  this._emit('iceStateChange', { state: s });
565
579
  if (s === 'connected' || s === 'completed') {
566
580
  // Healed — cancel any pending grace from a previous blip.
567
- this._clearIceGrace();
581
+ this._supervisor.onIceHealed();
568
582
  this._iceConnected = true;
569
583
  this._checkSessionReady();
570
584
  return;
571
585
  }
572
586
  if (s === 'disconnected') {
573
- // TRANSIENT per spec — debounce before escalating.
574
- // If the tab is hidden, JS timers are throttled and
575
- // would fire unpredictably late, so defer the grace
576
- // window to the next foreground frame.
577
- if (typeof document !== 'undefined' && document.hidden) {
578
- this._armIceGraceOnVisibility();
579
- }
580
- else {
581
- this._scheduleIceGrace(ICE_DISCONNECT_GRACE_MS, 'disconnected');
582
- }
587
+ this._supervisor.onIceDisconnected();
583
588
  return;
584
589
  }
585
590
  if (s === 'failed') {
586
- // Terminal per spec, but in practice we've seen
587
- // `failed → connected` on rapid AP roams / BT route
588
- // changes on iOS. Give the ICE agent a short window
589
- // to surprise us before rejecting the session.
590
- this._scheduleIceGrace(ICE_FAILED_GRACE_MS, 'failed');
591
+ this._supervisor.onIceFailed();
591
592
  return;
592
593
  }
593
594
  };
@@ -637,90 +638,31 @@ export class ReachyMini extends EventTarget {
637
638
  });
638
639
  });
639
640
  }
640
- _failSessionRejected(msg) {
641
- const err = new Error(msg.reason === 'robot_busy'
642
- ? `Robot is busy: "${msg.activeApp || 'another app'}" is already connected`
643
- : `Session rejected: ${msg.reason || 'unknown reason'}`);
644
- err.reason = msg.reason ?? null;
645
- err.activeApp = msg.activeApp ?? null;
646
- // Resilience teardown BEFORE closing `_pc` so a queued grace
647
- // callback can't dereference a dead handle.
648
- this._clearIceGrace();
649
- this._uninstallNetworkListeners();
650
- if (this._pc) {
651
- this._pc.close();
652
- this._pc = null;
653
- }
654
- if (this._micStream) {
655
- this._micStream.getTracks().forEach((t) => t.stop());
656
- this._micStream = null;
657
- }
658
- this._iceConnected = false;
659
- this._dcOpen = false;
660
- this._micMuted = true;
661
- this._micSupported = false;
662
- this._emit('sessionRejected', { reason: msg.reason, activeApp: msg.activeApp });
641
+ /**
642
+ * Common transport teardown shared by every session-ending path
643
+ * (stopSession, disconnect, the auto-redial, a session rejection):
644
+ * settle the pending-reply ledger and the startSession() resolvers
645
+ * with `settleErr`, stand the resilience plumbing down, stop the
646
+ * session-scoped timers, release the mic placeholder and close the
647
+ * pc/dc. Returns the session id (already cleared on the instance)
648
+ * so each caller decides whether and how to send `endSession`.
649
+ * What else differs — events, subscriber wipes, where `_state`
650
+ * lands — stays with the callers.
651
+ */
652
+ _closeTransport(settleErr) {
653
+ this._pending.settleAll(settleErr);
663
654
  if (this._sessionReject) {
664
655
  const reject = this._sessionReject;
665
656
  this._sessionResolve = null;
666
657
  this._sessionReject = null;
667
- reject(err);
668
- }
669
- }
670
- async stopSession() {
671
- if (this._versionResolve) {
672
- this._versionResolve(null);
673
- this._versionResolve = null;
674
- }
675
- if (this._hardwareIdResolve) {
676
- this._hardwareIdResolve(null);
677
- this._hardwareIdResolve = null;
678
- }
679
- if (this._volumeResolve) {
680
- this._volumeResolve(null);
681
- this._volumeResolve = null;
682
- }
683
- if (this._micVolumeResolve) {
684
- this._micVolumeResolve(null);
685
- this._micVolumeResolve = null;
686
- }
687
- if (this._firstWakeUpResolve) {
688
- this._firstWakeUpResolve(null);
689
- this._firstWakeUpResolve = null;
690
- }
691
- if (this._trackedFaceResolve) {
692
- this._trackedFaceResolve(null);
693
- this._trackedFaceResolve = null;
694
- }
695
- if (this._applyAudioConfigResolve) {
696
- this._applyAudioConfigResolve(false);
697
- this._applyAudioConfigResolve = null;
698
- }
699
- if (this._readAudioParameterResolve) {
700
- this._readAudioParameterResolve(null);
701
- this._readAudioParameterResolve = null;
702
- }
703
- if (this._robotNameResolve) {
704
- this._robotNameResolve(null);
705
- this._robotNameResolve = null;
706
- }
707
- if (this._deleteHfTokenResolve) {
708
- this._deleteHfTokenResolve(null);
709
- this._deleteHfTokenResolve = null;
710
- }
711
- this._logSubscribers.clear();
712
- this._updateProgressSubscribers.clear();
713
- this._rejectPendingMotionCompletions(new Error('Session stopped'));
714
- this._rejectPendingRpc(new Error('Session stopped'));
715
- // Tear down resilience plumbing BEFORE closing `_pc` so a
716
- // queued grace callback can't dereference a dead handle.
717
- this._clearIceGrace();
718
- this._uninstallNetworkListeners();
719
- if (this._sessionReject) {
720
- this._sessionReject(new Error('Session stopped'));
721
- this._sessionResolve = null;
722
- this._sessionReject = null;
658
+ reject(settleErr);
723
659
  }
660
+ this._sessionResolve = null;
661
+ // Resilience teardown BEFORE closing `_pc` so a queued grace
662
+ // callback can't dereference a dead handle.
663
+ this._supervisor.clearIceGrace();
664
+ this._supervisor.uninstallNetworkListeners();
665
+ this._supervisor.stopDcWatchdog();
724
666
  if (this._stateRefreshInterval) {
725
667
  clearInterval(this._stateRefreshInterval);
726
668
  this._stateRefreshInterval = null;
@@ -729,9 +671,6 @@ export class ReachyMini extends EventTarget {
729
671
  clearInterval(this._latencyMonitorId);
730
672
  this._latencyMonitorId = null;
731
673
  }
732
- if (this._sessionId) {
733
- await this._sendToServer({ type: 'endSession', sessionId: this._sessionId });
734
- }
735
674
  if (this._micStream) {
736
675
  this._micStream.getTracks().forEach((t) => t.stop());
737
676
  this._micStream = null;
@@ -746,9 +685,38 @@ export class ReachyMini extends EventTarget {
746
685
  this._dc.close();
747
686
  this._dc = null;
748
687
  }
749
- this._sessionId = null;
750
688
  this._iceConnected = false;
751
689
  this._dcOpen = false;
690
+ const sessionId = this._sessionId;
691
+ this._sessionId = null;
692
+ return sessionId;
693
+ }
694
+ _failSessionRejected(msg) {
695
+ const err = new Error(msg.reason === 'robot_busy'
696
+ ? `Robot is busy: "${msg.activeApp || 'another app'}" is already connected`
697
+ : `Session rejected: ${msg.reason || 'unknown reason'}`);
698
+ err.reason = msg.reason ?? null;
699
+ err.activeApp = msg.activeApp ?? null;
700
+ // No endSession: the robot side never granted this session.
701
+ this._closeTransport(err);
702
+ // During an auto-reconnect the rejection is expected noise (the
703
+ // robot side may still hold the dead session for a few seconds) —
704
+ // the loop retries, the app only sees `sessionReconnecting`.
705
+ if (!this._supervisor.redialing) {
706
+ this._emit('sessionRejected', { reason: msg.reason, activeApp: msg.activeApp });
707
+ }
708
+ }
709
+ async stopSession() {
710
+ // A deliberate stop always wins over a pending auto-reconnect:
711
+ // the in-flight dial attempt (if any) is rejected inside
712
+ // `_closeTransport`, and the loop exits on the cleared flag.
713
+ this._supervisor.cancelRedial();
714
+ this._logSubscribers.clear();
715
+ this._updateProgressSubscribers.clear();
716
+ const sessionId = this._closeTransport(new Error('Session stopped'));
717
+ if (sessionId) {
718
+ await this._sendToServer({ type: 'endSession', sessionId });
719
+ }
752
720
  const wasStreaming = this._state === 'streaming';
753
721
  if (wasStreaming) {
754
722
  this._state = 'connected';
@@ -756,271 +724,63 @@ export class ReachyMini extends EventTarget {
756
724
  }
757
725
  }
758
726
  disconnect() {
727
+ this._supervisor.cancelRedial();
759
728
  if (this._sseAbortController) {
760
729
  this._sseAbortController.abort();
761
730
  this._sseAbortController = null;
762
731
  }
763
- if (this._versionResolve) {
764
- this._versionResolve(null);
765
- this._versionResolve = null;
766
- }
767
- if (this._hardwareIdResolve) {
768
- this._hardwareIdResolve(null);
769
- this._hardwareIdResolve = null;
770
- }
771
- if (this._volumeResolve) {
772
- this._volumeResolve(null);
773
- this._volumeResolve = null;
774
- }
775
- if (this._micVolumeResolve) {
776
- this._micVolumeResolve(null);
777
- this._micVolumeResolve = null;
778
- }
779
- if (this._firstWakeUpResolve) {
780
- this._firstWakeUpResolve(null);
781
- this._firstWakeUpResolve = null;
782
- }
783
- if (this._trackedFaceResolve) {
784
- this._trackedFaceResolve(null);
785
- this._trackedFaceResolve = null;
786
- }
787
- if (this._applyAudioConfigResolve) {
788
- this._applyAudioConfigResolve(false);
789
- this._applyAudioConfigResolve = null;
790
- }
791
- if (this._readAudioParameterResolve) {
792
- this._readAudioParameterResolve(null);
793
- this._readAudioParameterResolve = null;
794
- }
795
- if (this._robotNameResolve) {
796
- this._robotNameResolve(null);
797
- this._robotNameResolve = null;
798
- }
799
- if (this._deleteHfTokenResolve) {
800
- this._deleteHfTokenResolve(null);
801
- this._deleteHfTokenResolve = null;
802
- }
803
732
  this._logSubscribers.clear();
804
733
  this._updateProgressSubscribers.clear();
805
- this._rejectPendingMotionCompletions(new Error('Disconnected'));
806
- // Mirrors the resilience teardown in `stopSession()`.
807
- this._clearIceGrace();
808
- this._uninstallNetworkListeners();
809
- if (this._sessionReject) {
810
- this._sessionReject(new Error('Disconnected'));
811
- this._sessionResolve = null;
812
- this._sessionReject = null;
734
+ const sessionId = this._closeTransport(new Error('Disconnected'));
735
+ if (sessionId && this._token) {
736
+ void this._sendToServer({ type: 'endSession', sessionId });
813
737
  }
814
- if (this._stateRefreshInterval) {
815
- clearInterval(this._stateRefreshInterval);
816
- this._stateRefreshInterval = null;
817
- }
818
- if (this._latencyMonitorId) {
819
- clearInterval(this._latencyMonitorId);
820
- this._latencyMonitorId = null;
821
- }
822
- if (this._sessionId && this._token) {
823
- this._sendToServer({ type: 'endSession', sessionId: this._sessionId });
824
- }
825
- if (this._micStream) {
826
- this._micStream.getTracks().forEach((t) => t.stop());
827
- this._micStream = null;
828
- }
829
- if (this._pc) {
830
- this._pc.close();
831
- this._pc = null;
832
- }
833
- if (this._dc) {
834
- this._dc.close();
835
- this._dc = null;
836
- }
837
- this._sessionId = null;
838
- this._micMuted = true;
839
- this._micSupported = false;
840
- this._iceConnected = false;
841
- this._dcOpen = false;
842
738
  this._robots = [];
843
739
  this._state = 'disconnected';
844
740
  this._emit('disconnected', { reason: 'user' });
845
741
  }
846
- // ─── Resilience: ICE-blip debounce + network awareness ───────────────
847
- //
848
- // Both halves below are intentionally generic (they don't know about
849
- // motion, audio, or the FSM): they just smooth out browser-level
850
- // events so the consumer's own state machine doesn't get torn down
851
- // by routine WiFi/4G/screen-off noise.
742
+ // ─── Resilience: supervisor delegation ───────────────────────────────
743
+ // The policy lives in `session-supervisor.ts`; only the transport
744
+ // teardown below stays here because it manipulates the class's own
745
+ // private state.
852
746
  /**
853
- * Cancel any pending ICE grace timer and visibility handler. Called
854
- * on a healed `connected`/`completed` transition AND from the
855
- * lifecycle teardown paths so a callback can't fire after `_pc`
856
- * is closed.
857
- */
858
- _clearIceGrace() {
859
- if (this._iceGraceTimer !== null) {
860
- clearTimeout(this._iceGraceTimer);
861
- this._iceGraceTimer = null;
862
- }
863
- this._iceGraceReason = null;
864
- if (this._pendingVisibilityHandler && typeof document !== 'undefined') {
865
- document.removeEventListener('visibilitychange', this._pendingVisibilityHandler);
866
- }
867
- this._pendingVisibilityHandler = null;
868
- }
869
- /**
870
- * Start a grace window. After `ms`, re-check the live ICE state:
871
- * - If we healed back to `connected`/`completed`, the timer was
872
- * already cancelled in `oniceconnectionstatechange`, so we
873
- * never get here.
874
- * - If we're still in the originally-observed bad state (or
875
- * worse), surface the error and reject any pending session
876
- * promise. The original code path is preserved verbatim so
877
- * downstream consumers see the same `error` payload shape.
878
- */
879
- _scheduleIceGrace(ms, reason) {
880
- // Coalesce: if a grace is already pending and the reason hasn't
881
- // changed, keep the original timer so a flurry of identical
882
- // transitions doesn't reset the clock. If the reason changed
883
- // (typically `disconnected` → `failed`, but also the reverse on
884
- // some Android WebViews), replace the timer with the new
885
- // (reason, ms) pair — the latest signal wins.
886
- if (this._iceGraceTimer !== null) {
887
- if (this._iceGraceReason === reason)
888
- return;
889
- clearTimeout(this._iceGraceTimer);
890
- }
891
- this._iceGraceReason = reason;
892
- this._iceGraceTimer = setTimeout(() => {
893
- this._iceGraceTimer = null;
894
- const r = this._iceGraceReason;
895
- this._iceGraceReason = null;
896
- const s = this._pc?.iceConnectionState;
897
- if (s === 'connected' || s === 'completed')
898
- return; // healed
899
- if (r === 'disconnected' && s === 'disconnected') {
900
- this._emit('error', {
901
- source: 'webrtc',
902
- error: new Error(`ICE stuck in 'disconnected' for > ${ms}ms`),
903
- });
904
- return;
905
- }
906
- if (r === 'failed' || s === 'failed') {
907
- const err = new Error('ICE connection failed');
908
- if (this._sessionReject) {
909
- this._sessionReject(err);
910
- this._sessionResolve = null;
911
- this._sessionReject = null;
912
- }
913
- this._emit('error', { source: 'webrtc', error: err });
914
- }
915
- }, ms);
916
- }
917
- /**
918
- * `disconnected` while the tab is hidden. JS timers are throttled
919
- * in background tabs (Chrome clamps to ~1 Hz, Safari can pause
920
- * altogether), so a foreground grace timer would either miss the
921
- * window or fire long after the connection healed. Wait for the
922
- * tab to come back, then re-evaluate.
747
+ * Enable/disable the automatic session re-dial at runtime.
748
+ * Disabling also aborts any re-dial already in flight. Used by
749
+ * flows that EXPECT the transport to die — e.g. the daemon
750
+ * self-update, whose `systemctl restart` teardown must surface as
751
+ * `sessionStopped` immediately (it's the "install done, rebooting"
752
+ * signal), not get absorbed by ~22 s of doomed reconnect attempts
753
+ * against a robot that is rebooting anyway.
754
+ *
755
+ * Sharp edge: cancelling an in-flight re-dial leaves the session
756
+ * torn down without emitting `sessionStopped` or `error` — the
757
+ * caller owns the terminal event (see `ReachyMiniInstance`).
923
758
  */
924
- _armIceGraceOnVisibility() {
925
- if (this._pendingVisibilityHandler)
926
- return;
927
- const deferredAt = Date.now();
928
- const handler = () => {
929
- if (typeof document !== 'undefined' && document.hidden)
930
- return;
931
- document.removeEventListener('visibilitychange', handler);
932
- this._pendingVisibilityHandler = null;
933
- if (!this._pc)
934
- return;
935
- const s = this._pc.iceConnectionState;
936
- if (s === 'connected' || s === 'completed')
937
- return; // healed in bg
938
- // Ceiling: if the user backgrounded past the daemon's
939
- // ICE-consent freshness window the session is gone from
940
- // the daemon's side regardless of what `_pc` reports
941
- // locally. Running another foreground grace would tell
942
- // the user "Reconnecting…" for a recovery that can never
943
- // happen. Escalate immediately so the host renders the
944
- // real "session expired" UX. See MAX_VISIBILITY_DEFER_MS.
945
- if (Date.now() - deferredAt > MAX_VISIBILITY_DEFER_MS) {
946
- const err = new Error('Session expired while tab was backgrounded');
947
- if (this._sessionReject) {
948
- this._sessionReject(err);
949
- this._sessionResolve = null;
950
- this._sessionReject = null;
951
- }
952
- this._emit('error', { source: 'webrtc', error: err });
953
- return;
954
- }
955
- if (s === 'failed') {
956
- this._scheduleIceGrace(ICE_FAILED_GRACE_MS, 'failed');
957
- return;
958
- }
959
- // Still disconnected when we came back — give it a normal
960
- // foreground grace window now that timers fire reliably.
961
- this._scheduleIceGrace(ICE_DISCONNECT_GRACE_MS, 'disconnected');
962
- };
963
- document.addEventListener('visibilitychange', handler);
964
- this._pendingVisibilityHandler = handler;
759
+ setAutoReconnect(enabled) {
760
+ this._supervisor.setAutoReconnect(enabled);
965
761
  }
966
762
  /**
967
- * Install browser-level network listeners and forward them as
968
- * public `networkOnline` / `networkOffline` / `networkChange`
969
- * events on this instance. Idempotent: called from
970
- * `startSession()`, removed by `_uninstallNetworkListeners` on
971
- * teardown. Reachable only when there's a live `window`
972
- * (defensive guard for SSR / test environments).
973
- *
974
- * `online` / `offline` are semantically about CONNECTIVITY:
975
- * "does the OS think we can reach the internet". They flip
976
- * symmetrically.
977
- *
978
- * `connection.change` (NetworkInformation API, Chrome / Android
979
- * WebView only) is semantically about the TRANSPORT: it fires
980
- * on Wi-Fi → 4G swaps, AP roams, etc. without necessarily going
981
- * through `offline`. We forward it as its own `networkChange`
982
- * event rather than aliasing it onto `networkOnline`, so
983
- * consumers don't have to guess whether they're seeing a real
984
- * connectivity recovery or a silent transport swap.
763
+ * Transport-only teardown: everything stopSession() does EXCEPT the
764
+ * user-facing state flip (`sessionStopped` is not emitted, `_state`
765
+ * falls back to 'connected' so the re-dial can start a session).
766
+ * Pending promises that ride the dead channel are settled just like
767
+ * stopSession() settles them — callers see the same failure shape a
768
+ * manual stop would produce; that includes a timed-out dial
769
+ * attempt's own startSession() promise (its resolvers were left
770
+ * armed, and a late signaling reply could otherwise settle them
771
+ * against a closed pc).
985
772
  */
986
- _installNetworkListeners() {
987
- if (this._onlineHandler || typeof window === 'undefined')
988
- return;
989
- const onOnline = () => this._emit('networkOnline', {});
990
- const onOffline = () => this._emit('networkOffline', {});
991
- window.addEventListener('online', onOnline);
992
- window.addEventListener('offline', onOffline);
993
- this._onlineHandler = onOnline;
994
- this._offlineHandler = onOffline;
995
- const conn = navigator.connection;
996
- if (conn && typeof conn.addEventListener === 'function') {
997
- const onChange = () => this._emit('networkChange', {
998
- effectiveType: conn.effectiveType,
999
- downlink: conn.downlink,
1000
- rtt: conn.rtt,
1001
- saveData: conn.saveData,
1002
- });
1003
- conn.addEventListener('change', onChange);
1004
- this._connectionChangeHandler = onChange;
1005
- }
1006
- }
1007
- /** Counterpart to `_installNetworkListeners`. */
1008
- _uninstallNetworkListeners() {
1009
- if (typeof window !== 'undefined') {
1010
- if (this._onlineHandler) {
1011
- window.removeEventListener('online', this._onlineHandler);
1012
- }
1013
- if (this._offlineHandler) {
1014
- window.removeEventListener('offline', this._offlineHandler);
1015
- }
1016
- }
1017
- const conn = navigator.connection;
1018
- if (conn && this._connectionChangeHandler && typeof conn.removeEventListener === 'function') {
1019
- conn.removeEventListener('change', this._connectionChangeHandler);
1020
- }
1021
- this._onlineHandler = null;
1022
- this._offlineHandler = null;
1023
- this._connectionChangeHandler = null;
773
+ _teardownForRedial() {
774
+ const sessionId = this._closeTransport(new Error('Session reconnecting'));
775
+ // Free the robot-side slot: the relay refuses a second session
776
+ // while it still tracks the dead one, and only endSession (or its
777
+ // own consent-freshness timeout, ~30 s) clears it. Fire and
778
+ // forget — _sendToServer never throws.
779
+ if (sessionId && this._token) {
780
+ void this._sendToServer({ type: 'endSession', sessionId });
781
+ }
782
+ if (this._state === 'streaming')
783
+ this._state = 'connected';
1024
784
  }
1025
785
  // ─── Commands ────────────────────────────────────────────────────────
1026
786
  /**
@@ -1134,6 +894,54 @@ export class ReachyMini extends EventTarget {
1134
894
  playSound(file) {
1135
895
  return this._sendCommand({ type: 'play_sound', file });
1136
896
  }
897
+ playRecordedMove(moveName, { dataset, initialGotoDuration, } = {}) {
898
+ return this._sendCommand({
899
+ type: 'play_recorded_move',
900
+ move_name: moveName,
901
+ ...(dataset ? { dataset_name: dataset } : {}),
902
+ ...(initialGotoDuration && initialGotoDuration > 0
903
+ ? { initial_goto_duration: initialGotoDuration }
904
+ : {}),
905
+ });
906
+ }
907
+ /**
908
+ * Stop whatever move is currently playing on the daemon (recorded move,
909
+ * uploaded move, goto). Fire-and-forget and idempotent: the daemon acks
910
+ * ok with `stopped: false` when nothing was running. Returns `false` if
911
+ * the data channel is not open.
912
+ */
913
+ stopMove() {
914
+ return this._sendCommand({ type: 'stop_move' });
915
+ }
916
+ preloadDataset(dataset) {
917
+ return this._sendCommand({ type: 'preload_dataset', dataset_name: dataset });
918
+ }
919
+ /**
920
+ * Like `preloadDataset()`, but resolves once the daemon acks the preload
921
+ * (`{command: "preload_dataset", ...}` on the data channel), i.e. when the
922
+ * dataset is actually in the local HF cache. Resolves `true` on success,
923
+ * `false` when the daemon reports a download failure, and `null` on the
924
+ * fail-open timeout (download slower than `timeoutMs`, or a daemon that
925
+ * predates the command and never replies) - callers should proceed in all
926
+ * three cases, `playRecordedMove` still downloads on demand. Rejects when
927
+ * the data channel isn't open or the session tears down mid-flight.
928
+ */
929
+ preloadDatasetAndWait(dataset, { timeoutMs = 120000 } = {}) {
930
+ // Send before registering the waiter (same rationale as `request()`):
931
+ // a channel closed mid-flight rejects instead of hanging a waiter to
932
+ // its timeout.
933
+ if (!this.preloadDataset(dataset)) {
934
+ return Promise.reject(new Error('Data channel not open'));
935
+ }
936
+ return this._pending
937
+ .awaitBroadcast((m) => m.command === 'preload_dataset' && m.dataset_name === dataset, { timeoutMs, debugLabel: `preload_dataset(${dataset})` })
938
+ .then((m) => m.status === 'ok')
939
+ .catch((err) => {
940
+ if (err instanceof BroadcastTimeoutError)
941
+ return null;
942
+ throw err;
943
+ });
944
+ }
1137
945
  clearIncomingAudio() {
1138
946
  return this._sendCommand({ type: 'clear_incoming_audio' });
1139
947
  }
@@ -1152,7 +960,7 @@ export class ReachyMini extends EventTarget {
1152
960
  return this._sendCommand({ type: 'set_head_tracking', enabled: false });
1153
961
  }
1154
962
  getTrackedFace() {
1155
- return this._slotRoundtrip(() => this._trackedFaceResolve, (next) => { this._trackedFaceResolve = next; }, { type: 'get_tracked_face' });
963
+ return this._slotRoundtrip('tracked_face', { type: 'get_tracked_face' });
1156
964
  }
1157
965
  /**
1158
966
  * Trigger a PyPI update of the daemon over the data channel. Remote
@@ -1190,30 +998,7 @@ export class ReachyMini extends EventTarget {
1190
998
  if (!this._sendCommand({ type: command })) {
1191
999
  return Promise.reject(new Error(`${command}: data channel not open`));
1192
1000
  }
1193
- return new Promise((resolve, reject) => {
1194
- const entry = {
1195
- resolve,
1196
- reject,
1197
- timer: setTimeout(() => {
1198
- const queue = this._pendingMotionCompletions[command];
1199
- const idx = queue.indexOf(entry);
1200
- if (idx !== -1)
1201
- queue.splice(idx, 1);
1202
- reject(new Error(`${command} timed out after ${timeoutMs}ms`));
1203
- }, timeoutMs),
1204
- };
1205
- this._pendingMotionCompletions[command].push(entry);
1206
- });
1207
- }
1208
- _rejectPendingMotionCompletions(error) {
1209
- for (const command of Object.keys(this._pendingMotionCompletions)) {
1210
- const queue = this._pendingMotionCompletions[command];
1211
- while (queue.length) {
1212
- const entry = queue.shift();
1213
- clearTimeout(entry.timer);
1214
- entry.reject(error);
1215
- }
1216
- }
1001
+ return this._pending.awaitMotion(command, timeoutMs);
1217
1002
  }
1218
1003
  isAwake() {
1219
1004
  const mode = this._robotState?.motor_mode;
@@ -1232,48 +1017,73 @@ export class ReachyMini extends EventTarget {
1232
1017
  this.requestState();
1233
1018
  });
1234
1019
  }
1235
- if (this.isAwake())
1020
+ // Gravity compensation counts as awake - the robot is standing - but
1021
+ // it runs under current control, where the daemon ignores position
1022
+ // targets outright. An app inheriting that state from its predecessor
1023
+ // (a fast handoff cancels the daemon's idle reset, so the mode
1024
+ // survives) would see every goto silently do nothing. Flip back to
1025
+ // position control without replaying the emote: the robot is already
1026
+ // up, and the daemon pins targets to the measured pose on the way in,
1027
+ // so nothing snaps.
1028
+ if (this._robotState?.motor_mode === 'gravity_compensation') {
1029
+ log.info('ensureAwake: gravity compensation inherited, flipping to enabled (no emote)');
1030
+ this.setMotorMode('enabled');
1031
+ // Refresh the cache so a caller reading isAwake() right after us
1032
+ // doesn't see the mode we just left.
1033
+ this.requestState();
1034
+ return true;
1035
+ }
1036
+ if (this.isAwake()) {
1037
+ log.info('ensureAwake: already awake, nothing to do');
1236
1038
  return true;
1237
- this.wakeUp().catch(() => { });
1039
+ }
1040
+ // Await the trajectory: callers treat resolution as "robot ready for
1041
+ // position targets", and the emote keeps moving the head for ~2 s
1042
+ // after the command is acked - an app that starts commanding poses
1043
+ // under it fights it. Failures are swallowed: a torn-down session or
1044
+ // a daemon that never acks must not take the whole boot down with it.
1045
+ log.info(`ensureAwake: robot asleep (motor_mode=${this._robotState?.motor_mode ?? 'unknown'}), playing wake-up`);
1046
+ const wakeStartedAt = Date.now();
1047
+ try {
1048
+ await this.wakeUp({ timeoutMs: WAKE_TRAJECTORY_BUDGET_MS });
1049
+ log.info(`ensureAwake: wake-up trajectory done in ${Date.now() - wakeStartedAt}ms`);
1050
+ }
1051
+ catch (e) {
1052
+ /* timed out, or the session went away under us */
1053
+ log.warn(`ensureAwake: wake-up not confirmed after ${Date.now() - wakeStartedAt}ms (continuing):`, e);
1054
+ }
1238
1055
  return true;
1239
1056
  }
1057
+ /**
1058
+ * Query the daemon version. Resolves `null` when the daemon predates
1059
+ * `get_version` (fail-open on the shared slot timeout) or the reply is
1060
+ * superseded by session teardown.
1061
+ */
1240
1062
  getVersion() {
1241
- return new Promise((resolve, reject) => {
1242
- if (!this._dc || this._dc.readyState !== 'open') {
1243
- reject(new Error('Data channel not open'));
1244
- return;
1245
- }
1246
- if (this._versionResolve) {
1247
- this._versionResolve(null);
1248
- }
1249
- this._versionResolve = resolve;
1250
- this._sendCommand({ type: 'get_version' });
1251
- });
1063
+ return this._slotRoundtrip('version', { type: 'get_version' });
1252
1064
  }
1253
1065
  getHardwareId() {
1254
- return new Promise((resolve, reject) => {
1255
- if (!this._dc || this._dc.readyState !== 'open') {
1256
- reject(new Error('Data channel not open'));
1257
- return;
1258
- }
1259
- if (this._hardwareIdResolve) {
1260
- this._hardwareIdResolve(null);
1261
- }
1262
- this._hardwareIdResolve = resolve;
1263
- this._sendCommand({ type: 'get_hardware_id' });
1264
- });
1066
+ return this._slotRoundtrip('hardware_id', { type: 'get_hardware_id' });
1067
+ }
1068
+ /**
1069
+ * One-shot IMU reading (BMI088, wireless version only). Resolves `null`
1070
+ * when the robot has no IMU (Lite, simulation) or the daemon predates
1071
+ * the `get_imu` command (fail-open on the shared slot timeout).
1072
+ */
1073
+ getImu() {
1074
+ return this._slotRoundtrip('imu', { type: 'get_imu' });
1265
1075
  }
1266
1076
  getVolume() {
1267
- return this._slotRoundtrip(() => this._volumeResolve, (next) => { this._volumeResolve = next; }, { type: 'get_volume' });
1077
+ return this._slotRoundtrip('volume', { type: 'get_volume' });
1268
1078
  }
1269
1079
  setVolume(volume) {
1270
- return this._slotRoundtrip(() => this._volumeResolve, (next) => { this._volumeResolve = next; }, { type: 'set_volume', volume: clampVolume(volume) });
1080
+ return this._slotRoundtrip('volume', { type: 'set_volume', volume: clampVolume(volume) });
1271
1081
  }
1272
1082
  getMicrophoneVolume() {
1273
- return this._slotRoundtrip(() => this._micVolumeResolve, (next) => { this._micVolumeResolve = next; }, { type: 'get_microphone_volume' });
1083
+ return this._slotRoundtrip('mic_volume', { type: 'get_microphone_volume' });
1274
1084
  }
1275
1085
  setMicrophoneVolume(volume) {
1276
- return this._slotRoundtrip(() => this._micVolumeResolve, (next) => { this._micVolumeResolve = next; }, { type: 'set_microphone_volume', volume: clampVolume(volume) });
1086
+ return this._slotRoundtrip('mic_volume', { type: 'set_microphone_volume', volume: clampVolume(volume) });
1277
1087
  }
1278
1088
  /**
1279
1089
  * Query whether the first wake-up setup wizard has been completed.
@@ -1287,7 +1097,7 @@ export class ReachyMini extends EventTarget {
1287
1097
  // connect, which is exactly when the channel may not be open yet.
1288
1098
  if (!this._dc || this._dc.readyState !== 'open')
1289
1099
  return Promise.resolve(null);
1290
- return this._slotRoundtrip(() => this._firstWakeUpResolve, (next) => { this._firstWakeUpResolve = next; }, { type: 'get_first_wake_up' });
1100
+ return this._slotRoundtrip('first_wake_up', { type: 'get_first_wake_up' });
1291
1101
  }
1292
1102
  /**
1293
1103
  * Persist the first wake-up wizard completion flag on the robot.
@@ -1296,7 +1106,7 @@ export class ReachyMini extends EventTarget {
1296
1106
  setFirstWakeUp(isCompleted) {
1297
1107
  if (!this._dc || this._dc.readyState !== 'open')
1298
1108
  return Promise.resolve(null);
1299
- return this._slotRoundtrip(() => this._firstWakeUpResolve, (next) => { this._firstWakeUpResolve = next; }, { type: 'set_first_wake_up', is_completed: isCompleted });
1109
+ return this._slotRoundtrip('first_wake_up', { type: 'set_first_wake_up', is_completed: isCompleted });
1300
1110
  }
1301
1111
  /**
1302
1112
  * Query the persisted robot display name. Resolves the stored name,
@@ -1304,7 +1114,7 @@ export class ReachyMini extends EventTarget {
1304
1114
  * the `get_robot_name` command.
1305
1115
  */
1306
1116
  getRobotName() {
1307
- return this._slotRoundtrip(() => this._robotNameResolve, (next) => { this._robotNameResolve = next; }, { type: 'get_robot_name' });
1117
+ return this._slotRoundtrip('robot_name', { type: 'get_robot_name' });
1308
1118
  }
1309
1119
  /**
1310
1120
  * Set and persist the robot display name on the robot. Resolves with the
@@ -1314,7 +1124,7 @@ export class ReachyMini extends EventTarget {
1314
1124
  * on the next start.
1315
1125
  */
1316
1126
  setRobotName(name) {
1317
- return this._slotRoundtrip(() => this._robotNameResolve, (next) => { this._robotNameResolve = next; }, { type: 'set_robot_name', name });
1127
+ return this._slotRoundtrip('robot_name', { type: 'set_robot_name', name });
1318
1128
  }
1319
1129
  /**
1320
1130
  * Sign this robot out of Hugging Face: asks the daemon to delete its
@@ -1333,66 +1143,67 @@ export class ReachyMini extends EventTarget {
1333
1143
  * ahead of the ack.
1334
1144
  */
1335
1145
  signOut() {
1336
- return this._slotRoundtrip(() => this._deleteHfTokenResolve, (next) => { this._deleteHfTokenResolve = next; }, { type: 'delete_hf_token' });
1146
+ return this._slotRoundtrip('delete_hf_token', { type: 'delete_hf_token' });
1337
1147
  }
1338
1148
  applyAudioConfig(config, { verify = true } = {}) {
1339
- return this._slotRoundtrip(() => this._applyAudioConfigResolve, (next) => { this._applyAudioConfigResolve = next; }, { type: 'apply_audio_config', config, verify }).then((v) => v === true);
1149
+ return this._slotRoundtrip('apply_audio_config', { type: 'apply_audio_config', config, verify })
1150
+ .then((v) => v === true);
1340
1151
  }
1341
1152
  readAudioParameter(name) {
1342
- return this._slotRoundtrip(() => this._readAudioParameterResolve, (next) => { this._readAudioParameterResolve = next; }, { type: 'read_audio_parameter', name });
1153
+ return this._slotRoundtrip('read_audio_parameter', { type: 'read_audio_parameter', name });
1343
1154
  }
1344
1155
  /**
1345
1156
  * Internal: send a command and await the matching daemon response in a
1346
- * named single-resolver slot. Used by the volume helpers and the
1347
- * XVF3800 audio-config helpers — every one of them has a strict
1348
- * request/response shape where a single in-flight call per slot is
1349
- * sufficient. If a previous request on the same slot is still
1350
- * pending when a new one comes in, the older promise is resolved to
1351
- * `null` so its caller doesn't hang forever.
1352
- *
1353
- * Slot access is passed in as getter/setter closures rather than a
1354
- * key into `this`: that keeps the helper fully generic-checked (T is
1355
- * inferred from the slot's resolver type at each call site) with no
1356
- * indexed-property casts.
1157
+ * named single-resolver slot (see `PendingReplies.slotRoundtrip`).
1158
+ * Every caller has a strict request/response shape where a single
1159
+ * in-flight call per slot is sufficient.
1357
1160
  */
1358
- _slotRoundtrip(getSlot, setSlot, command) {
1359
- return new Promise((resolve, reject) => {
1360
- if (!this._dc || this._dc.readyState !== 'open') {
1361
- reject(new Error('Data channel not open'));
1362
- return;
1363
- }
1364
- const prev = getSlot();
1365
- if (prev)
1366
- prev(null);
1367
- let timer;
1368
- // Single settle path shared by the daemon response, supersession
1369
- // by a newer call, and the fail-open timeout below. It clears the
1370
- // timer once and detaches itself from the slot only if it's still
1371
- // the current occupant, so a stale settle (timed-out or superseded)
1372
- // can't clear a newer call's slot registration. The message handler
1373
- // stores `settle` (not `resolve`), so every response route funnels
1374
- // through here.
1375
- // Note: slots are keyed by command type, not request id, so this
1376
- // does not prevent a genuinely late daemon reply from being routed
1377
- // to a newer same-command caller — that cross-talk is inherent to
1378
- // the single-flight slot design and unchanged here.
1379
- const settle = (v) => {
1380
- if (timer !== undefined) {
1381
- clearTimeout(timer);
1382
- timer = undefined;
1383
- }
1384
- if (getSlot() === settle)
1385
- setSlot(null);
1386
- resolve(v);
1387
- };
1388
- setSlot(settle);
1389
- timer = setTimeout(() => settle(null), SLOT_ROUNDTRIP_TIMEOUT_MS);
1390
- this._sendCommand(command);
1391
- });
1161
+ _slotRoundtrip(slot, command) {
1162
+ if (!this._dc || this._dc.readyState !== 'open') {
1163
+ return Promise.reject(new Error('Data channel not open'));
1164
+ }
1165
+ return this._pending.slotRoundtrip(slot, () => { this._sendCommand(command); });
1392
1166
  }
1393
1167
  sendRaw(data) {
1394
1168
  return this._sendCommand(data);
1395
1169
  }
1170
+ /**
1171
+ * Generic command round-trip for daemon commands the SDK has no typed
1172
+ * wrapper for (yet). Escape hatch so an app can use a newer daemon
1173
+ * feature without waiting for an SDK release: sends `command` and
1174
+ * resolves with the first robot message whose `command` field equals
1175
+ * the sent `type` - the daemon's reply convention - or with `null` on
1176
+ * the fail-open timeout (daemon predates the command, or the command
1177
+ * is fire-and-forget and never replies).
1178
+ *
1179
+ * Rejects when the data channel isn't open or the session tears down
1180
+ * mid-flight, mirroring the typed wrappers.
1181
+ *
1182
+ * Replies the SDK already consumes internally (`get_imu`,
1183
+ * `get_volume`, ...) are swallowed by their own handlers and never
1184
+ * reach this matcher - use the typed wrappers for those. Pass `match`
1185
+ * for replies that don't follow the `command` echo convention.
1186
+ */
1187
+ request(command, { timeoutMs = SLOT_ROUNDTRIP_TIMEOUT_MS, match } = {}) {
1188
+ // Send before registering the waiter (safe: replies arrive on a
1189
+ // later task), so a channel closed mid-flight rejects instead of
1190
+ // hanging a waiter to its timeout and resolving `null`.
1191
+ if (!this._sendCommand(command)) {
1192
+ return Promise.reject(new Error('Data channel not open'));
1193
+ }
1194
+ const predicate = match
1195
+ ?? ((m) => m.command === command.type);
1196
+ return this._pending
1197
+ .awaitBroadcast(predicate, { timeoutMs, debugLabel: `request(${command.type})` })
1198
+ .catch((err) => {
1199
+ // Fail-open on the waiter timeout only; teardown rejections
1200
+ // (settleAll) propagate to the caller like every other
1201
+ // in-flight round-trip.
1202
+ if (err instanceof BroadcastTimeoutError)
1203
+ return null;
1204
+ throw err;
1205
+ });
1206
+ }
1396
1207
  subscribeLogs({ onLine, onError }) {
1397
1208
  if (typeof onLine !== 'function') {
1398
1209
  throw new TypeError('subscribeLogs: onLine callback is required');
@@ -1595,7 +1406,7 @@ export class ReachyMini extends EventTarget {
1595
1406
  });
1596
1407
  }
1597
1408
  catch (e) {
1598
- console.warn('playMove.onStarted threw:', e);
1409
+ log.warn('playMove.onStarted threw:', e);
1599
1410
  }
1600
1411
  onProgress({ phase: 'playing', duration_s: startedAck.duration_s });
1601
1412
  const final = await this._waitForBroadcast((m) => m?.type === 'play_uploaded_move'
@@ -1680,20 +1491,8 @@ export class ReachyMini extends EventTarget {
1680
1491
  _emit(name, detail) {
1681
1492
  this.dispatchEvent(new CustomEvent(name, { detail }));
1682
1493
  }
1683
- _waitForBroadcast(predicate, { timeoutMs = 5000, debugLabel = '' } = {}) {
1684
- return new Promise((resolve, reject) => {
1685
- const slot = {
1686
- predicate,
1687
- resolve,
1688
- timer: setTimeout(() => {
1689
- const i = this._broadcastWaiters.indexOf(slot);
1690
- if (i !== -1)
1691
- this._broadcastWaiters.splice(i, 1);
1692
- reject(new Error(`broadcast timeout (${timeoutMs} ms): ${debugLabel}`));
1693
- }, timeoutMs),
1694
- };
1695
- this._broadcastWaiters.push(slot);
1696
- });
1494
+ _waitForBroadcast(predicate, opts = {}) {
1495
+ return this._pending.awaitBroadcast(predicate, opts);
1697
1496
  }
1698
1497
  async _awaitDataChannelDrain() {
1699
1498
  while (this._dc && this._dc.bufferedAmount > UPLOAD_BUFFERED_LOW_WATER) {
@@ -1721,13 +1520,13 @@ export class ReachyMini extends EventTarget {
1721
1520
  body = await res.text();
1722
1521
  }
1723
1522
  catch { /* ignore */ }
1724
- console.warn(`[reachy-mini] /send rejected (${res.status}) for type=${message?.type}; body=${body || '<empty>'}`);
1523
+ log.warn(`/send rejected (${res.status}) for type=${message?.type}; body=${body || '<empty>'}`);
1725
1524
  return null;
1726
1525
  }
1727
1526
  return await res.json();
1728
1527
  }
1729
1528
  catch (e) {
1730
- console.error('Send error:', e);
1529
+ log.error('signaling /send failed:', e);
1731
1530
  return null;
1732
1531
  }
1733
1532
  }
@@ -1746,21 +1545,7 @@ export class ReachyMini extends EventTarget {
1746
1545
  */
1747
1546
  rpcCall(method, params = {}, opts = {}) {
1748
1547
  const timeoutMs = opts.timeoutMs ?? 20000;
1749
- const id = `rpc-${++this._rpcCounter}`;
1750
- if (!this._sendCommand({ jsonrpc: '2.0', id, method, params })) {
1751
- return Promise.reject(new Error(`rpcCall(${method}): data channel not open`));
1752
- }
1753
- return new Promise((resolve, reject) => {
1754
- const timer = setTimeout(() => {
1755
- this._pendingRpc.delete(id);
1756
- reject(new Error(`rpcCall(${method}) timed out after ${timeoutMs}ms`));
1757
- }, timeoutMs);
1758
- this._pendingRpc.set(id, {
1759
- resolve: (v) => resolve(v),
1760
- reject,
1761
- timer,
1762
- });
1763
- });
1548
+ return this._pending.rpcRoundtrip(method, timeoutMs, (id) => this._sendCommand({ jsonrpc: '2.0', id, method, params }));
1764
1549
  }
1765
1550
  /**
1766
1551
  * Subscribe to a JSON-RPC notification (one-way event) pushed by the
@@ -1777,32 +1562,10 @@ export class ReachyMini extends EventTarget {
1777
1562
  this._rpcListeners.get(method)?.delete(cb);
1778
1563
  };
1779
1564
  }
1780
- _rejectPendingRpc(err) {
1781
- for (const pending of this._pendingRpc.values()) {
1782
- clearTimeout(pending.timer);
1783
- pending.reject(err);
1784
- }
1785
- this._pendingRpc.clear();
1786
- }
1787
1565
  _handleRpcMessage(data) {
1788
1566
  // Response to an rpcCall (correlated by id)...
1789
- if ('id' in data && data.id != null && ('result' in data || 'error' in data)) {
1790
- const pending = this._pendingRpc.get(data.id);
1791
- if (!pending)
1792
- return;
1793
- this._pendingRpc.delete(data.id);
1794
- clearTimeout(pending.timer);
1795
- if ('error' in data && data.error) {
1796
- const err = data.error;
1797
- const e = new Error(err.message ?? 'rpc error');
1798
- e.reason = err.data?.reason;
1799
- pending.reject(e);
1800
- }
1801
- else {
1802
- pending.resolve(data.result);
1803
- }
1567
+ if (this._pending.settleRpcResponse(data))
1804
1568
  return;
1805
- }
1806
1569
  // ...or a one-way notification (event): dispatch to listeners.
1807
1570
  if (typeof data.method === 'string') {
1808
1571
  const params = data.params ?? {};
@@ -1811,7 +1574,7 @@ export class ReachyMini extends EventTarget {
1811
1574
  cb(params);
1812
1575
  }
1813
1576
  catch (e) {
1814
- console.error(`onNotification(${data.method}) threw:`, e);
1577
+ log.error(`onNotification(${data.method}) threw:`, e);
1815
1578
  }
1816
1579
  }
1817
1580
  }
@@ -1836,6 +1599,7 @@ export class ReachyMini extends EventTarget {
1836
1599
  return;
1837
1600
  this.requestState();
1838
1601
  }, 500);
1602
+ this._supervisor.startDcWatchdog();
1839
1603
  this._emit('streaming', { sessionId: this._sessionId, robotId: this._selectedRobotId });
1840
1604
  this._sessionResolve();
1841
1605
  this._sessionResolve = null;
@@ -1886,10 +1650,14 @@ export class ReachyMini extends EventTarget {
1886
1650
  if (this._sessionReject) {
1887
1651
  const err = new Error(friendly || `Session ended before it could start: ${reason || 'unknown reason'}`);
1888
1652
  err.reason = reason ?? null;
1889
- this._emit('sessionRejected', { reason, activeApp: null });
1653
+ // Same suppression as _failSessionRejected: retries during an
1654
+ // auto-reconnect must not surface as `sessionRejected`.
1655
+ if (!this._supervisor.redialing) {
1656
+ this._emit('sessionRejected', { reason, activeApp: null });
1657
+ }
1890
1658
  // Resilience teardown alongside the PC close path.
1891
- this._clearIceGrace();
1892
- this._uninstallNetworkListeners();
1659
+ this._supervisor.clearIceGrace();
1660
+ this._supervisor.uninstallNetworkListeners();
1893
1661
  if (this._pc) {
1894
1662
  this._pc.close();
1895
1663
  this._pc = null;
@@ -1951,7 +1719,7 @@ export class ReachyMini extends EventTarget {
1951
1719
  await this._pc.addIceCandidate(new RTCIceCandidate(ice));
1952
1720
  }
1953
1721
  catch (err) {
1954
- console.warn('[reachy-mini] buffered ICE candidate rejected:', err);
1722
+ log.warn('buffered ICE candidate rejected:', err);
1955
1723
  }
1956
1724
  }
1957
1725
  }
@@ -1975,7 +1743,7 @@ export class ReachyMini extends EventTarget {
1975
1743
  }
1976
1744
  }
1977
1745
  catch (e) {
1978
- console.error('WebRTC error:', e);
1746
+ log.error('webrtc signaling failed:', e);
1979
1747
  this._emit('error', { source: 'webrtc', error: e });
1980
1748
  }
1981
1749
  }
@@ -1985,94 +1753,66 @@ export class ReachyMini extends EventTarget {
1985
1753
  * mirror while the stream is live (see the `data.state` branch).
1986
1754
  */
1987
1755
  _handleRobotMessage(data, fromPoseStream = false) {
1756
+ // Liveness stamp for the data-channel silence watchdog: every
1757
+ // inbound message — control replies, broadcasts, pose frames —
1758
+ // proves the transport is alive.
1759
+ this._supervisor.stampDcInbound();
1988
1760
  // JSON-RPC frames (app control surface) are handled separately from
1989
1761
  // the legacy {command|type} robot messages that share this channel.
1990
1762
  if (data.jsonrpc === '2.0') {
1991
1763
  this._handleRpcMessage(data);
1992
1764
  return;
1993
1765
  }
1994
- if ('version' in data && this._versionResolve) {
1995
- this._versionResolve(data.version);
1996
- this._versionResolve = null;
1766
+ // Bare `version` / `hardware_id` replies carry no `command` key:
1767
+ // only swallow them when a waiter is actually pending, otherwise
1768
+ // let the message fall through to the branches below.
1769
+ if ('version' in data
1770
+ && this._pending.settleReplySlot('version', data.version ?? null)) {
1997
1771
  return;
1998
1772
  }
1999
- if ('hardware_id' in data && this._hardwareIdResolve) {
2000
- this._hardwareIdResolve(data.hardware_id);
2001
- this._hardwareIdResolve = null;
1773
+ if ('hardware_id' in data
1774
+ && this._pending.settleReplySlot('hardware_id', data.hardware_id ?? null)) {
2002
1775
  return;
2003
1776
  }
2004
1777
  if (data.command === 'get_volume' || data.command === 'set_volume') {
2005
- if (this._volumeResolve) {
2006
- this._volumeResolve(data.status === 'error' ? null : data.volume);
2007
- this._volumeResolve = null;
2008
- }
1778
+ this._pending.settleReplySlot('volume', data.status === 'error' ? null : data.volume);
2009
1779
  return;
2010
1780
  }
2011
1781
  if (data.command === 'get_microphone_volume' || data.command === 'set_microphone_volume') {
2012
- if (this._micVolumeResolve) {
2013
- this._micVolumeResolve(data.status === 'error' ? null : data.volume);
2014
- this._micVolumeResolve = null;
2015
- }
1782
+ this._pending.settleReplySlot('mic_volume', data.status === 'error' ? null : data.volume);
2016
1783
  return;
2017
1784
  }
2018
1785
  if (data.command === 'get_first_wake_up' || data.command === 'set_first_wake_up') {
2019
- if (this._firstWakeUpResolve) {
2020
- this._firstWakeUpResolve(data.status === 'error' ? null : !!data.is_completed);
2021
- this._firstWakeUpResolve = null;
2022
- }
1786
+ this._pending.settleReplySlot('first_wake_up', data.status === 'error' ? null : !!data.is_completed);
2023
1787
  return;
2024
1788
  }
2025
1789
  if (data.command === 'get_robot_name' || data.command === 'set_robot_name') {
2026
- if (this._robotNameResolve) {
2027
- this._robotNameResolve(data.status === 'error' ? null : (data.name ?? null));
2028
- this._robotNameResolve = null;
2029
- }
1790
+ this._pending.settleReplySlot('robot_name', data.status === 'error' ? null : (data.name ?? null));
2030
1791
  return;
2031
1792
  }
2032
1793
  if (data.command === 'delete_hf_token') {
2033
- if (this._deleteHfTokenResolve) {
2034
- this._deleteHfTokenResolve(data.status === 'error' ? false : true);
2035
- this._deleteHfTokenResolve = null;
2036
- }
1794
+ this._pending.settleReplySlot('delete_hf_token', data.status !== 'error');
2037
1795
  return;
2038
1796
  }
2039
1797
  if (data.command === 'apply_audio_config') {
2040
- if (this._applyAudioConfigResolve) {
2041
- this._applyAudioConfigResolve(data.error ? false : !!data.applied);
2042
- this._applyAudioConfigResolve = null;
2043
- }
1798
+ this._pending.settleReplySlot('apply_audio_config', data.error ? false : !!data.applied);
2044
1799
  return;
2045
1800
  }
2046
1801
  if (data.command === 'read_audio_parameter') {
2047
- if (this._readAudioParameterResolve) {
2048
- this._readAudioParameterResolve(data.error ? null : (data.values ?? null));
2049
- this._readAudioParameterResolve = null;
2050
- }
1802
+ this._pending.settleReplySlot('read_audio_parameter', data.error ? null : (data.values ?? null));
2051
1803
  return;
2052
1804
  }
2053
1805
  if (data.command === 'get_tracked_face') {
2054
- if (this._trackedFaceResolve) {
2055
- this._trackedFaceResolve(data.face_target ?? null);
2056
- this._trackedFaceResolve = null;
2057
- }
1806
+ this._pending.settleReplySlot('tracked_face', data.face_target ?? null);
1807
+ return;
1808
+ }
1809
+ if (data.command === 'get_imu') {
1810
+ this._pending.settleReplySlot('imu', data.imu ?? null);
2058
1811
  return;
2059
1812
  }
2060
1813
  if ((data.command === 'wake_up' || data.command === 'goto_sleep')
2061
- && this._pendingMotionCompletions
2062
- && this._pendingMotionCompletions[data.command]) {
2063
- const queue = this._pendingMotionCompletions[data.command];
2064
- if (data.completed === true && queue.length > 0) {
2065
- const entry = queue.shift();
2066
- clearTimeout(entry.timer);
2067
- entry.resolve();
2068
- return;
2069
- }
2070
- if (data.error && queue.length > 0) {
2071
- const entry = queue.shift();
2072
- clearTimeout(entry.timer);
2073
- entry.reject(new Error(`${data.command}: ${data.error}`));
2074
- return;
2075
- }
1814
+ && this._pending.settleMotion(data.command, data)) {
1815
+ return;
2076
1816
  }
2077
1817
  if (data.type === 'log_line') {
2078
1818
  for (const sub of this._logSubscribers) {
@@ -2080,7 +1820,7 @@ export class ReachyMini extends EventTarget {
2080
1820
  sub.onLine({ timestamp: data.timestamp, line: data.line });
2081
1821
  }
2082
1822
  catch (e) {
2083
- console.error('subscribeLogs onLine threw:', e);
1823
+ log.error('subscribeLogs onLine threw:', e);
2084
1824
  }
2085
1825
  }
2086
1826
  return;
@@ -2092,7 +1832,7 @@ export class ReachyMini extends EventTarget {
2092
1832
  sub.onError(data.error);
2093
1833
  }
2094
1834
  catch (e) {
2095
- console.error('subscribeLogs onError threw:', e);
1835
+ log.error('subscribeLogs onError threw:', e);
2096
1836
  }
2097
1837
  }
2098
1838
  }
@@ -2110,7 +1850,7 @@ export class ReachyMini extends EventTarget {
2110
1850
  cb(event);
2111
1851
  }
2112
1852
  catch (e) {
2113
- console.error('startDaemonUpdate onProgress threw:', e);
1853
+ log.error('startDaemonUpdate onProgress threw:', e);
2114
1854
  }
2115
1855
  }
2116
1856
  }
@@ -2127,7 +1867,7 @@ export class ReachyMini extends EventTarget {
2127
1867
  cb(event);
2128
1868
  }
2129
1869
  catch (e) {
2130
- console.error('startDaemonUpdate onProgress threw:', e);
1870
+ log.error('startDaemonUpdate onProgress threw:', e);
2131
1871
  }
2132
1872
  }
2133
1873
  return;
@@ -2149,6 +1889,8 @@ export class ReachyMini extends EventTarget {
2149
1889
  this._robotState.head = s.head_pose.flat();
2150
1890
  if (s.antennas)
2151
1891
  this._robotState.antennas = [s.antennas[0], s.antennas[1]];
1892
+ if (s.head_joint_positions)
1893
+ this._robotState.head_joint_positions = s.head_joint_positions;
2152
1894
  if (typeof s.body_yaw === 'number')
2153
1895
  this._robotState.body_yaw = s.body_yaw;
2154
1896
  if (s.motor_mode)
@@ -2157,22 +1899,17 @@ export class ReachyMini extends EventTarget {
2157
1899
  this._robotState.is_move_running = s.is_move_running;
2158
1900
  if (s.face_target)
2159
1901
  this._robotState.face_target = s.face_target;
1902
+ // DoA is null when there's no mic array / no reading yet - reflect
1903
+ // that by clearing our mirror so stale angles don't linger.
1904
+ if ('doa' in s)
1905
+ this._robotState.doa = s.doa ?? undefined;
2160
1906
  this._emit('state', { ...this._robotState });
2161
1907
  }
2162
1908
  if (data.error) {
2163
1909
  this._emit('error', { source: 'robot', error: data.error });
2164
1910
  }
2165
- if (this._broadcastWaiters.length > 0) {
2166
- for (let i = this._broadcastWaiters.length - 1; i >= 0; i--) {
2167
- const slot = this._broadcastWaiters[i];
2168
- if (slot.predicate(data)) {
2169
- this._broadcastWaiters.splice(i, 1);
2170
- clearTimeout(slot.timer);
2171
- slot.resolve(data);
2172
- return;
2173
- }
2174
- }
2175
- }
1911
+ if (this._pending.matchBroadcast(data))
1912
+ return;
2176
1913
  }
2177
1914
  /** Snap video playback to live edge if buffered lag exceeds 0.5 s. */
2178
1915
  _startLatencyMonitor(video) {
@@ -2186,7 +1923,7 @@ export class ReachyMini extends EventTarget {
2186
1923
  const end = buf.end(buf.length - 1);
2187
1924
  const lag = end - video.currentTime;
2188
1925
  if (lag > 0.5) {
2189
- console.log(`Latency correction: was ${lag.toFixed(2)}s behind`);
1926
+ log.debug(`video latency correction: was ${lag.toFixed(2)}s behind`);
2190
1927
  video.currentTime = end - 0.1;
2191
1928
  }
2192
1929
  }