@pollen-robotics/reachy-mini-sdk 1.10.0-dev.0.main.b50d97e → 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 (128) 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 +202 -88
  39. package/dist/lib/reachy-mini.d.ts.map +1 -1
  40. package/dist/lib/reachy-mini.js +651 -566
  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 +280 -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-DjE5zE9R.js → mountHost-BTTF4Uu9.js} +24933 -27488
  79. package/host/dist/components/ConnectingView.d.ts.map +1 -1
  80. package/host/dist/components/DaemonUpdateGate.d.ts +65 -0
  81. package/host/dist/components/DaemonUpdateGate.d.ts.map +1 -0
  82. package/host/dist/components/ErrorView.d.ts.map +1 -1
  83. package/host/dist/components/HostErrorBoundary.d.ts +30 -0
  84. package/host/dist/components/HostErrorBoundary.d.ts.map +1 -0
  85. package/host/dist/components/LeavingView.d.ts.map +1 -1
  86. package/host/dist/components/PickerView.d.ts +16 -14
  87. package/host/dist/components/PickerView.d.ts.map +1 -1
  88. package/host/dist/components/PostOAuthSplash.d.ts +14 -9
  89. package/host/dist/components/PostOAuthSplash.d.ts.map +1 -1
  90. package/host/dist/components/ReachyHostShell.d.ts.map +1 -1
  91. package/host/dist/components/SignInView.d.ts.map +1 -1
  92. package/host/dist/components/TopBar.d.ts +5 -6
  93. package/host/dist/components/TopBar.d.ts.map +1 -1
  94. package/host/dist/components/WelcomeBackOverlay.d.ts.map +1 -1
  95. package/host/dist/embed/index.d.ts +68 -5
  96. package/host/dist/embed/index.d.ts.map +1 -1
  97. package/host/dist/entry/auto.js +2 -2
  98. package/host/dist/entry/embed.d.ts +5 -4
  99. package/host/dist/entry/embed.d.ts.map +1 -1
  100. package/host/dist/entry/embed.js +5 -4
  101. package/host/dist/hooks/useHostBridge.d.ts +35 -1
  102. package/host/dist/hooks/useHostBridge.d.ts.map +1 -1
  103. package/host/dist/hooks/useLatestDaemonVersion.d.ts +7 -0
  104. package/host/dist/hooks/useLatestDaemonVersion.d.ts.map +1 -0
  105. package/host/dist/hooks/useOAuth.d.ts +18 -0
  106. package/host/dist/hooks/useOAuth.d.ts.map +1 -1
  107. package/host/dist/hooks/useRobots.d.ts +0 -2
  108. package/host/dist/hooks/useRobots.d.ts.map +1 -1
  109. package/host/dist/index.d.ts +2 -2
  110. package/host/dist/index.d.ts.map +1 -1
  111. package/host/dist/index.js +11 -10
  112. package/host/dist/lib/daemonRelease.d.ts +84 -0
  113. package/host/dist/lib/daemonRelease.d.ts.map +1 -0
  114. package/host/dist/lib/protocol.d.ts +141 -7
  115. package/host/dist/lib/protocol.d.ts.map +1 -1
  116. package/host/dist/lib/rebootWatch.d.ts +51 -0
  117. package/host/dist/lib/rebootWatch.d.ts.map +1 -0
  118. package/host/dist/lib/sdk-types.d.ts +1 -1
  119. package/host/dist/lib/sdk-types.d.ts.map +1 -1
  120. package/host/dist/lib/sdkStaleness.d.ts +78 -0
  121. package/host/dist/lib/sdkStaleness.d.ts.map +1 -0
  122. package/host/dist/lib/settings.d.ts +16 -5
  123. package/host/dist/lib/settings.d.ts.map +1 -1
  124. package/package.json +11 -4
  125. package/host/dist/chunks/index-lMs2sCXG.js +0 -439
  126. package/host/dist/chunks/reachy-mini-CdsdQLIU.js +0 -1523
  127. package/host/dist/lib/centralListener.d.ts +0 -73
  128. package/host/dist/lib/centralListener.d.ts.map +0 -1
@@ -4,42 +4,42 @@
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 ──────────────────────────────────────────────────────
17
+ // Resilience tunables (ICE grace windows, re-dial backoff, DC-silence
18
+ // watchdog thresholds) live in `session-supervisor.ts`.
11
19
  /**
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.
20
+ * How long a pushed pose frame keeps the periodic `get_state` poll on hold -
21
+ * both on the way out (no request is sent) and on the way back (a reply that
22
+ * arrives anyway doesn't touch the state mirror).
23
+ *
24
+ * Poll replies carry no `seq`, so they slip past the stale-frame guard: a
25
+ * reply that crosses a fresher pushed frame would rewind the very mirror the
26
+ * stream exists to smooth. While frames flow at ~30 Hz there is nothing left
27
+ * for the poll to add, so it stands down. Keying that on frame arrival rather
28
+ * than on `_poseSubRefs` keeps the poll running against a daemon that doesn't
29
+ * know `subscribe_pose`, and brings it back on its own if the stream stalls.
30
+ * A little over one 500 ms poll period, so a couple of dropped frames don't
31
+ * flip it back and forth.
18
32
  */
19
- const ICE_DISCONNECT_GRACE_MS = 3000;
33
+ const POSE_STREAM_FRESH_MS = 750;
20
34
  /**
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.
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.
26
41
  */
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;
42
+ const WAKE_TRAJECTORY_BUDGET_MS = 5000;
43
43
  export class ReachyMini extends EventTarget {
44
44
  // ─── Config ──────────────────────────────────────────────────────────
45
45
  _signalingUrl;
@@ -52,6 +52,19 @@ export class ReachyMini extends EventTarget {
52
52
  _state = 'disconnected';
53
53
  _robots = [];
54
54
  _robotState = {};
55
+ // Highest `seq` seen on the unordered `pose` channel. Frames that arrive
56
+ // out of order (older seq) are dropped so a late packet can't rewind the
57
+ // live mirror. `null` until the first pose frame.
58
+ _lastPoseSeq = null;
59
+ // When the last pushed pose frame was applied, used to park the periodic
60
+ // `get_state` poll while the stream is live (see POSE_STREAM_FRESH_MS).
61
+ _lastPoseFrameAt = 0;
62
+ // Local refcount of pose-stream consumers (the 3D mirror, the wizard's
63
+ // move-end watcher, ...). The daemon's subscription is a per-peer boolean
64
+ // (not refcounted), so we only send `unsubscribe_pose` once the LAST local
65
+ // consumer releases - otherwise one consumer's cleanup would kill the
66
+ // stream for the others.
67
+ _poseSubRefs = 0;
55
68
  _preselectedRobotId;
56
69
  // ─── Auth ────────────────────────────────────────────────────────────
57
70
  _token = null;
@@ -73,22 +86,17 @@ export class ReachyMini extends EventTarget {
73
86
  // ─── Timers ──────────────────────────────────────────────────────────
74
87
  _latencyMonitorId = null;
75
88
  _stateRefreshInterval = null;
76
- // ─── Single-slot promise resolvers ───────────────────────────────────
77
- _versionResolve = null;
78
- _hardwareIdResolve = null;
79
- _volumeResolve = null;
80
- _micVolumeResolve = null;
81
- _trackedFaceResolve = null;
82
- // applyAudioConfig() / readAudioParameter() share the same single-slot
83
- // pattern as the volume helpers. Separate slots so the two can be
84
- // in-flight concurrently without collision.
85
- _applyAudioConfigResolve = null;
86
- _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();
87
93
  // ─── Log subscribers ─────────────────────────────────────────────────
88
94
  _logSubscribers = new Set();
89
95
  _updateProgressSubscribers = new Set();
90
- // ─── Broadcast waiters (playMove / playUploadedAudio) ────────────────
91
- _broadcastWaiters = [];
96
+ // ─── JSON-RPC notification listeners (one-way events, no id) ─────────
97
+ // onNotification() subscribes to events the robot/app pushes
98
+ // (conversation.phase/turn/transcript, ...).
99
+ _rpcListeners = new Map();
92
100
  // ─── Active upload ids for no-arg cancels ────────────────────────────
93
101
  _activeMoveUploadId = null;
94
102
  _activeAudioUploadId = null;
@@ -97,24 +105,12 @@ export class ReachyMini extends EventTarget {
97
105
  _sessionReject = null;
98
106
  _iceConnected = false;
99
107
  _dcOpen = false;
100
- // ─── Resilience: ICE-blip debounce + network awareness ──────────────
101
- // Backs `_scheduleIceGrace` / `_armIceGraceOnVisibility` and the
102
- // `networkOnline` / `networkOffline` / `networkChange` forwarders.
103
- // All three handler slots are scoped to the lifetime of a live
104
- // session (installed in `startSession`, cleared in
105
- // `stopSession` / `disconnect` / `_handleEndSession` /
106
- // `_failSessionRejected`).
107
- _iceGraceTimer = null;
108
- _iceGraceReason = null;
109
- _pendingVisibilityHandler = null;
110
- _onlineHandler = null;
111
- _offlineHandler = null;
112
- _connectionChangeHandler = null;
113
- // ─── Motion completion plumbing (wake_up / goto_sleep) ───────────────
114
- _pendingMotionCompletions = {
115
- wake_up: [],
116
- goto_sleep: [],
117
- };
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;
118
114
  // ─── Video element ───────────────────────────────────────────────────
119
115
  _videoElement = null;
120
116
  constructor(options = {}) {
@@ -130,6 +126,31 @@ export class ReachyMini extends EventTarget {
130
126
  this._autoStartFromUrl = options.autoStartFromUrl === true;
131
127
  this._autoStartAttempted = false;
132
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 });
133
154
  }
134
155
  // ─── Read-only properties ────────────────────────────────────────────
135
156
  get state() { return this._state; }
@@ -142,6 +163,24 @@ export class ReachyMini extends EventTarget {
142
163
  get audioMuted() { return this._audioMuted; }
143
164
  get preselectedRobotId() { return this._preselectedRobotId; }
144
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; }
145
184
  /**
146
185
  * Internal: try to honour the `autoStartFromUrl` constructor
147
186
  * option. Called from the signaling-message handler after every
@@ -166,7 +205,7 @@ export class ReachyMini extends EventTarget {
166
205
  if (this._state !== 'connected')
167
206
  return;
168
207
  this.startSession(peerId).catch((err) => {
169
- console.warn('[reachy-mini] autoStartFromUrl: startSession rejected:', err);
208
+ log.warn('autoStartFromUrl: startSession rejected:', err);
170
209
  });
171
210
  }, 0);
172
211
  }
@@ -174,44 +213,63 @@ export class ReachyMini extends EventTarget {
174
213
  async authenticate() {
175
214
  try {
176
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
+ }
177
224
  const result = (await oauthHandleRedirectIfPresent());
178
225
  if (result) {
179
226
  this._username = result.userInfo.preferred_username || result.userInfo.name || null;
180
227
  this._token = result.accessToken;
181
228
  this._tokenExpires = result.accessTokenExpiresAt;
182
- sessionStorage.setItem('hf_token', this._token);
183
- sessionStorage.setItem('hf_username', this._username ?? '');
184
- sessionStorage.setItem('hf_token_expires', typeof this._tokenExpires === 'string'
185
- ? this._tokenExpires
186
- : 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
+ });
187
236
  return true;
188
237
  }
189
- const t = sessionStorage.getItem('hf_token');
190
- const u = sessionStorage.getItem('hf_username');
191
- const e = sessionStorage.getItem('hf_token_expires');
192
- if (t && u && e && new Date(e) > new Date()) {
193
- this._token = t;
194
- this._username = u;
195
- 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;
196
245
  return true;
197
246
  }
198
247
  return false;
199
248
  }
200
249
  catch (e) {
201
- console.error('Auth error:', e);
250
+ log.error('authenticate failed:', e);
202
251
  return false;
203
252
  }
204
253
  }
205
- async login() {
254
+ async login(options) {
206
255
  const opts = {};
207
256
  if (this._clientId)
208
257
  opts.clientId = this._clientId;
209
- 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;
210
270
  }
211
271
  logout() {
212
- sessionStorage.removeItem('hf_token');
213
- sessionStorage.removeItem('hf_username');
214
- sessionStorage.removeItem('hf_token_expires');
272
+ clearStoredToken();
215
273
  this._username = null;
216
274
  this._tokenExpires = null;
217
275
  this.disconnect();
@@ -358,7 +416,7 @@ export class ReachyMini extends EventTarget {
358
416
  await this.ensureAwake();
359
417
  }
360
418
  catch (e) {
361
- console.warn('[reachy-mini] autoConnect: ensureAwake failed:', e);
419
+ log.warn('autoConnect: ensureAwake failed:', e);
362
420
  }
363
421
  }
364
422
  return { robotId, robotName, isEmbedded: this.isEmbedded };
@@ -392,7 +450,7 @@ export class ReachyMini extends EventTarget {
392
450
  return Array.from(seen.values()).sort((a, b) => (a.lastSeenAgeSeconds ?? Infinity) - (b.lastSeenAgeSeconds ?? Infinity));
393
451
  }
394
452
  catch (e) {
395
- console.warn('[reachy-mini] /api/robot-status unavailable, using SSE list:', e);
453
+ log.warn('/api/robot-status unavailable, using SSE list:', e);
396
454
  return (this._robots || []).map((r) => ({
397
455
  id: r.id,
398
456
  name: r.meta?.name ?? null,
@@ -422,6 +480,14 @@ export class ReachyMini extends EventTarget {
422
480
  });
423
481
  }
424
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) {
425
491
  if (this._state !== 'connected')
426
492
  throw new Error('Not connected');
427
493
  this._selectedRobotId = robotId;
@@ -460,7 +526,7 @@ export class ReachyMini extends EventTarget {
460
526
  this._micMuted = true;
461
527
  }
462
528
  catch (e) {
463
- console.warn('Audio sender placeholder setup failed:', e);
529
+ log.warn('audio sender placeholder setup failed:', e);
464
530
  this._micStream = null;
465
531
  }
466
532
  this._pc = new RTCPeerConnection({
@@ -468,7 +534,7 @@ export class ReachyMini extends EventTarget {
468
534
  });
469
535
  // Scope `networkOnline` / `networkOffline` / `networkChange`
470
536
  // event forwarding to the lifetime of this session.
471
- this._installNetworkListeners();
537
+ this._supervisor.installNetworkListeners();
472
538
  return new Promise((resolve, reject) => {
473
539
  this._sessionResolve = resolve;
474
540
  this._sessionReject = reject;
@@ -512,35 +578,50 @@ export class ReachyMini extends EventTarget {
512
578
  this._emit('iceStateChange', { state: s });
513
579
  if (s === 'connected' || s === 'completed') {
514
580
  // Healed — cancel any pending grace from a previous blip.
515
- this._clearIceGrace();
581
+ this._supervisor.onIceHealed();
516
582
  this._iceConnected = true;
517
583
  this._checkSessionReady();
518
584
  return;
519
585
  }
520
586
  if (s === 'disconnected') {
521
- // TRANSIENT per spec — debounce before escalating.
522
- // If the tab is hidden, JS timers are throttled and
523
- // would fire unpredictably late, so defer the grace
524
- // window to the next foreground frame.
525
- if (typeof document !== 'undefined' && document.hidden) {
526
- this._armIceGraceOnVisibility();
527
- }
528
- else {
529
- this._scheduleIceGrace(ICE_DISCONNECT_GRACE_MS, 'disconnected');
530
- }
587
+ this._supervisor.onIceDisconnected();
531
588
  return;
532
589
  }
533
590
  if (s === 'failed') {
534
- // Terminal per spec, but in practice we've seen
535
- // `failed → connected` on rapid AP roams / BT route
536
- // changes on iOS. Give the ICE agent a short window
537
- // to surprise us before rejecting the session.
538
- this._scheduleIceGrace(ICE_FAILED_GRACE_MS, 'failed');
591
+ this._supervisor.onIceFailed();
539
592
  return;
540
593
  }
541
594
  };
542
595
  this._pc.ondatachannel = (e) => {
543
- this._dc = e.channel;
596
+ const ch = e.channel;
597
+ // On `subscribe_pose` the daemon opens a second,
598
+ // unreliable/unordered channel labelled "pose" that *pushes*
599
+ // the robot state at ~30 Hz (see media_server
600
+ // `_setup_pose_channel`), so this can fire mid-session. It
601
+ // carries the same `{state:{...}}` envelope as a get_state
602
+ // reply, so we route it through the same handler - but it
603
+ // must NOT gate session readiness (that's the reliable
604
+ // control channel's job) nor become `_dc` (commands must
605
+ // never ride the lossy channel).
606
+ if (ch.label === 'pose') {
607
+ // Fresh channel (new session or daemon restart): the
608
+ // daemon's seq counter may have reset, so forget the old
609
+ // high-water mark or we'd drop every new frame.
610
+ this._lastPoseSeq = null;
611
+ ch.onmessage = (ev) => {
612
+ const msg = JSON.parse(ev.data);
613
+ // Drop stale/reordered frames (unordered channel).
614
+ if (typeof msg.seq === 'number') {
615
+ if (this._lastPoseSeq !== null && msg.seq <= this._lastPoseSeq)
616
+ return;
617
+ this._lastPoseSeq = msg.seq;
618
+ }
619
+ this._lastPoseFrameAt = Date.now();
620
+ this._handleRobotMessage(msg, true);
621
+ };
622
+ return;
623
+ }
624
+ this._dc = ch;
544
625
  this._dc.onopen = () => {
545
626
  this._dcOpen = true;
546
627
  this._checkSessionReady();
@@ -557,77 +638,31 @@ export class ReachyMini extends EventTarget {
557
638
  });
558
639
  });
559
640
  }
560
- _failSessionRejected(msg) {
561
- const err = new Error(msg.reason === 'robot_busy'
562
- ? `Robot is busy: "${msg.activeApp || 'another app'}" is already connected`
563
- : `Session rejected: ${msg.reason || 'unknown reason'}`);
564
- err.reason = msg.reason ?? null;
565
- err.activeApp = msg.activeApp ?? null;
566
- // Resilience teardown BEFORE closing `_pc` so a queued grace
567
- // callback can't dereference a dead handle.
568
- this._clearIceGrace();
569
- this._uninstallNetworkListeners();
570
- if (this._pc) {
571
- this._pc.close();
572
- this._pc = null;
573
- }
574
- if (this._micStream) {
575
- this._micStream.getTracks().forEach((t) => t.stop());
576
- this._micStream = null;
577
- }
578
- this._iceConnected = false;
579
- this._dcOpen = false;
580
- this._micMuted = true;
581
- this._micSupported = false;
582
- 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);
583
654
  if (this._sessionReject) {
584
655
  const reject = this._sessionReject;
585
656
  this._sessionResolve = null;
586
657
  this._sessionReject = null;
587
- reject(err);
588
- }
589
- }
590
- async stopSession() {
591
- if (this._versionResolve) {
592
- this._versionResolve(null);
593
- this._versionResolve = null;
594
- }
595
- if (this._hardwareIdResolve) {
596
- this._hardwareIdResolve(null);
597
- this._hardwareIdResolve = null;
598
- }
599
- if (this._volumeResolve) {
600
- this._volumeResolve(null);
601
- this._volumeResolve = null;
602
- }
603
- if (this._micVolumeResolve) {
604
- this._micVolumeResolve(null);
605
- this._micVolumeResolve = null;
606
- }
607
- if (this._trackedFaceResolve) {
608
- this._trackedFaceResolve(null);
609
- this._trackedFaceResolve = null;
610
- }
611
- if (this._applyAudioConfigResolve) {
612
- this._applyAudioConfigResolve(false);
613
- this._applyAudioConfigResolve = null;
614
- }
615
- if (this._readAudioParameterResolve) {
616
- this._readAudioParameterResolve(null);
617
- this._readAudioParameterResolve = null;
618
- }
619
- this._logSubscribers.clear();
620
- this._updateProgressSubscribers.clear();
621
- this._rejectPendingMotionCompletions(new Error('Session stopped'));
622
- // Tear down resilience plumbing BEFORE closing `_pc` so a
623
- // queued grace callback can't dereference a dead handle.
624
- this._clearIceGrace();
625
- this._uninstallNetworkListeners();
626
- if (this._sessionReject) {
627
- this._sessionReject(new Error('Session stopped'));
628
- this._sessionResolve = null;
629
- this._sessionReject = null;
658
+ reject(settleErr);
630
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();
631
666
  if (this._stateRefreshInterval) {
632
667
  clearInterval(this._stateRefreshInterval);
633
668
  this._stateRefreshInterval = null;
@@ -636,9 +671,6 @@ export class ReachyMini extends EventTarget {
636
671
  clearInterval(this._latencyMonitorId);
637
672
  this._latencyMonitorId = null;
638
673
  }
639
- if (this._sessionId) {
640
- await this._sendToServer({ type: 'endSession', sessionId: this._sessionId });
641
- }
642
674
  if (this._micStream) {
643
675
  this._micStream.getTracks().forEach((t) => t.stop());
644
676
  this._micStream = null;
@@ -653,9 +685,38 @@ export class ReachyMini extends EventTarget {
653
685
  this._dc.close();
654
686
  this._dc = null;
655
687
  }
656
- this._sessionId = null;
657
688
  this._iceConnected = false;
658
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
+ }
659
720
  const wasStreaming = this._state === 'streaming';
660
721
  if (wasStreaming) {
661
722
  this._state = 'connected';
@@ -663,259 +724,63 @@ export class ReachyMini extends EventTarget {
663
724
  }
664
725
  }
665
726
  disconnect() {
727
+ this._supervisor.cancelRedial();
666
728
  if (this._sseAbortController) {
667
729
  this._sseAbortController.abort();
668
730
  this._sseAbortController = null;
669
731
  }
670
- if (this._versionResolve) {
671
- this._versionResolve(null);
672
- this._versionResolve = null;
673
- }
674
- if (this._hardwareIdResolve) {
675
- this._hardwareIdResolve(null);
676
- this._hardwareIdResolve = null;
677
- }
678
- if (this._volumeResolve) {
679
- this._volumeResolve(null);
680
- this._volumeResolve = null;
681
- }
682
- if (this._micVolumeResolve) {
683
- this._micVolumeResolve(null);
684
- this._micVolumeResolve = null;
685
- }
686
- if (this._trackedFaceResolve) {
687
- this._trackedFaceResolve(null);
688
- this._trackedFaceResolve = null;
689
- }
690
- if (this._applyAudioConfigResolve) {
691
- this._applyAudioConfigResolve(false);
692
- this._applyAudioConfigResolve = null;
693
- }
694
- if (this._readAudioParameterResolve) {
695
- this._readAudioParameterResolve(null);
696
- this._readAudioParameterResolve = null;
697
- }
698
732
  this._logSubscribers.clear();
699
733
  this._updateProgressSubscribers.clear();
700
- this._rejectPendingMotionCompletions(new Error('Disconnected'));
701
- // Mirrors the resilience teardown in `stopSession()`.
702
- this._clearIceGrace();
703
- this._uninstallNetworkListeners();
704
- if (this._sessionReject) {
705
- this._sessionReject(new Error('Disconnected'));
706
- this._sessionResolve = null;
707
- this._sessionReject = null;
708
- }
709
- if (this._stateRefreshInterval) {
710
- clearInterval(this._stateRefreshInterval);
711
- this._stateRefreshInterval = null;
712
- }
713
- if (this._latencyMonitorId) {
714
- clearInterval(this._latencyMonitorId);
715
- this._latencyMonitorId = null;
734
+ const sessionId = this._closeTransport(new Error('Disconnected'));
735
+ if (sessionId && this._token) {
736
+ void this._sendToServer({ type: 'endSession', sessionId });
716
737
  }
717
- if (this._sessionId && this._token) {
718
- this._sendToServer({ type: 'endSession', sessionId: this._sessionId });
719
- }
720
- if (this._micStream) {
721
- this._micStream.getTracks().forEach((t) => t.stop());
722
- this._micStream = null;
723
- }
724
- if (this._pc) {
725
- this._pc.close();
726
- this._pc = null;
727
- }
728
- if (this._dc) {
729
- this._dc.close();
730
- this._dc = null;
731
- }
732
- this._sessionId = null;
733
- this._micMuted = true;
734
- this._micSupported = false;
735
- this._iceConnected = false;
736
- this._dcOpen = false;
737
738
  this._robots = [];
738
739
  this._state = 'disconnected';
739
740
  this._emit('disconnected', { reason: 'user' });
740
741
  }
741
- // ─── Resilience: ICE-blip debounce + network awareness ───────────────
742
- //
743
- // Both halves below are intentionally generic (they don't know about
744
- // motion, audio, or the FSM): they just smooth out browser-level
745
- // events so the consumer's own state machine doesn't get torn down
746
- // by routine WiFi/4G/screen-off noise.
747
- /**
748
- * Cancel any pending ICE grace timer and visibility handler. Called
749
- * on a healed `connected`/`completed` transition AND from the
750
- * lifecycle teardown paths so a callback can't fire after `_pc`
751
- * is closed.
752
- */
753
- _clearIceGrace() {
754
- if (this._iceGraceTimer !== null) {
755
- clearTimeout(this._iceGraceTimer);
756
- this._iceGraceTimer = null;
757
- }
758
- this._iceGraceReason = null;
759
- if (this._pendingVisibilityHandler && typeof document !== 'undefined') {
760
- document.removeEventListener('visibilitychange', this._pendingVisibilityHandler);
761
- }
762
- this._pendingVisibilityHandler = null;
763
- }
764
- /**
765
- * Start a grace window. After `ms`, re-check the live ICE state:
766
- * - If we healed back to `connected`/`completed`, the timer was
767
- * already cancelled in `oniceconnectionstatechange`, so we
768
- * never get here.
769
- * - If we're still in the originally-observed bad state (or
770
- * worse), surface the error and reject any pending session
771
- * promise. The original code path is preserved verbatim so
772
- * downstream consumers see the same `error` payload shape.
773
- */
774
- _scheduleIceGrace(ms, reason) {
775
- // Coalesce: if a grace is already pending and the reason hasn't
776
- // changed, keep the original timer so a flurry of identical
777
- // transitions doesn't reset the clock. If the reason changed
778
- // (typically `disconnected` → `failed`, but also the reverse on
779
- // some Android WebViews), replace the timer with the new
780
- // (reason, ms) pair — the latest signal wins.
781
- if (this._iceGraceTimer !== null) {
782
- if (this._iceGraceReason === reason)
783
- return;
784
- clearTimeout(this._iceGraceTimer);
785
- }
786
- this._iceGraceReason = reason;
787
- this._iceGraceTimer = setTimeout(() => {
788
- this._iceGraceTimer = null;
789
- const r = this._iceGraceReason;
790
- this._iceGraceReason = null;
791
- const s = this._pc?.iceConnectionState;
792
- if (s === 'connected' || s === 'completed')
793
- return; // healed
794
- if (r === 'disconnected' && s === 'disconnected') {
795
- this._emit('error', {
796
- source: 'webrtc',
797
- error: new Error(`ICE stuck in 'disconnected' for > ${ms}ms`),
798
- });
799
- return;
800
- }
801
- if (r === 'failed' || s === 'failed') {
802
- const err = new Error('ICE connection failed');
803
- if (this._sessionReject) {
804
- this._sessionReject(err);
805
- this._sessionResolve = null;
806
- this._sessionReject = null;
807
- }
808
- this._emit('error', { source: 'webrtc', error: err });
809
- }
810
- }, ms);
811
- }
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.
812
746
  /**
813
- * `disconnected` while the tab is hidden. JS timers are throttled
814
- * in background tabs (Chrome clamps to ~1 Hz, Safari can pause
815
- * altogether), so a foreground grace timer would either miss the
816
- * window or fire long after the connection healed. Wait for the
817
- * 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`).
818
758
  */
819
- _armIceGraceOnVisibility() {
820
- if (this._pendingVisibilityHandler)
821
- return;
822
- const deferredAt = Date.now();
823
- const handler = () => {
824
- if (typeof document !== 'undefined' && document.hidden)
825
- return;
826
- document.removeEventListener('visibilitychange', handler);
827
- this._pendingVisibilityHandler = null;
828
- if (!this._pc)
829
- return;
830
- const s = this._pc.iceConnectionState;
831
- if (s === 'connected' || s === 'completed')
832
- return; // healed in bg
833
- // Ceiling: if the user backgrounded past the daemon's
834
- // ICE-consent freshness window the session is gone from
835
- // the daemon's side regardless of what `_pc` reports
836
- // locally. Running another foreground grace would tell
837
- // the user "Reconnecting…" for a recovery that can never
838
- // happen. Escalate immediately so the host renders the
839
- // real "session expired" UX. See MAX_VISIBILITY_DEFER_MS.
840
- if (Date.now() - deferredAt > MAX_VISIBILITY_DEFER_MS) {
841
- const err = new Error('Session expired while tab was backgrounded');
842
- if (this._sessionReject) {
843
- this._sessionReject(err);
844
- this._sessionResolve = null;
845
- this._sessionReject = null;
846
- }
847
- this._emit('error', { source: 'webrtc', error: err });
848
- return;
849
- }
850
- if (s === 'failed') {
851
- this._scheduleIceGrace(ICE_FAILED_GRACE_MS, 'failed');
852
- return;
853
- }
854
- // Still disconnected when we came back — give it a normal
855
- // foreground grace window now that timers fire reliably.
856
- this._scheduleIceGrace(ICE_DISCONNECT_GRACE_MS, 'disconnected');
857
- };
858
- document.addEventListener('visibilitychange', handler);
859
- this._pendingVisibilityHandler = handler;
759
+ setAutoReconnect(enabled) {
760
+ this._supervisor.setAutoReconnect(enabled);
860
761
  }
861
762
  /**
862
- * Install browser-level network listeners and forward them as
863
- * public `networkOnline` / `networkOffline` / `networkChange`
864
- * events on this instance. Idempotent: called from
865
- * `startSession()`, removed by `_uninstallNetworkListeners` on
866
- * teardown. Reachable only when there's a live `window`
867
- * (defensive guard for SSR / test environments).
868
- *
869
- * `online` / `offline` are semantically about CONNECTIVITY:
870
- * "does the OS think we can reach the internet". They flip
871
- * symmetrically.
872
- *
873
- * `connection.change` (NetworkInformation API, Chrome / Android
874
- * WebView only) is semantically about the TRANSPORT: it fires
875
- * on Wi-Fi → 4G swaps, AP roams, etc. without necessarily going
876
- * through `offline`. We forward it as its own `networkChange`
877
- * event rather than aliasing it onto `networkOnline`, so
878
- * consumers don't have to guess whether they're seeing a real
879
- * 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).
880
772
  */
881
- _installNetworkListeners() {
882
- if (this._onlineHandler || typeof window === 'undefined')
883
- return;
884
- const onOnline = () => this._emit('networkOnline', {});
885
- const onOffline = () => this._emit('networkOffline', {});
886
- window.addEventListener('online', onOnline);
887
- window.addEventListener('offline', onOffline);
888
- this._onlineHandler = onOnline;
889
- this._offlineHandler = onOffline;
890
- const conn = navigator.connection;
891
- if (conn && typeof conn.addEventListener === 'function') {
892
- const onChange = () => this._emit('networkChange', {
893
- effectiveType: conn.effectiveType,
894
- downlink: conn.downlink,
895
- rtt: conn.rtt,
896
- saveData: conn.saveData,
897
- });
898
- conn.addEventListener('change', onChange);
899
- this._connectionChangeHandler = onChange;
900
- }
901
- }
902
- /** Counterpart to `_installNetworkListeners`. */
903
- _uninstallNetworkListeners() {
904
- if (typeof window !== 'undefined') {
905
- if (this._onlineHandler) {
906
- window.removeEventListener('online', this._onlineHandler);
907
- }
908
- if (this._offlineHandler) {
909
- window.removeEventListener('offline', this._offlineHandler);
910
- }
911
- }
912
- const conn = navigator.connection;
913
- if (conn && this._connectionChangeHandler && typeof conn.removeEventListener === 'function') {
914
- conn.removeEventListener('change', this._connectionChangeHandler);
915
- }
916
- this._onlineHandler = null;
917
- this._offlineHandler = null;
918
- 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';
919
784
  }
920
785
  // ─── Commands ────────────────────────────────────────────────────────
921
786
  /**
@@ -1029,6 +894,54 @@ export class ReachyMini extends EventTarget {
1029
894
  playSound(file) {
1030
895
  return this._sendCommand({ type: 'play_sound', file });
1031
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
+ }
1032
945
  clearIncomingAudio() {
1033
946
  return this._sendCommand({ type: 'clear_incoming_audio' });
1034
947
  }
@@ -1047,7 +960,7 @@ export class ReachyMini extends EventTarget {
1047
960
  return this._sendCommand({ type: 'set_head_tracking', enabled: false });
1048
961
  }
1049
962
  getTrackedFace() {
1050
- return this._slotRoundtrip(() => this._trackedFaceResolve, (next) => { this._trackedFaceResolve = next; }, { type: 'get_tracked_face' });
963
+ return this._slotRoundtrip('tracked_face', { type: 'get_tracked_face' });
1051
964
  }
1052
965
  /**
1053
966
  * Trigger a PyPI update of the daemon over the data channel. Remote
@@ -1085,30 +998,7 @@ export class ReachyMini extends EventTarget {
1085
998
  if (!this._sendCommand({ type: command })) {
1086
999
  return Promise.reject(new Error(`${command}: data channel not open`));
1087
1000
  }
1088
- return new Promise((resolve, reject) => {
1089
- const entry = {
1090
- resolve,
1091
- reject,
1092
- timer: setTimeout(() => {
1093
- const queue = this._pendingMotionCompletions[command];
1094
- const idx = queue.indexOf(entry);
1095
- if (idx !== -1)
1096
- queue.splice(idx, 1);
1097
- reject(new Error(`${command} timed out after ${timeoutMs}ms`));
1098
- }, timeoutMs),
1099
- };
1100
- this._pendingMotionCompletions[command].push(entry);
1101
- });
1102
- }
1103
- _rejectPendingMotionCompletions(error) {
1104
- for (const command of Object.keys(this._pendingMotionCompletions)) {
1105
- const queue = this._pendingMotionCompletions[command];
1106
- while (queue.length) {
1107
- const entry = queue.shift();
1108
- clearTimeout(entry.timer);
1109
- entry.reject(error);
1110
- }
1111
- }
1001
+ return this._pending.awaitMotion(command, timeoutMs);
1112
1002
  }
1113
1003
  isAwake() {
1114
1004
  const mode = this._robotState?.motor_mode;
@@ -1127,85 +1017,193 @@ export class ReachyMini extends EventTarget {
1127
1017
  this.requestState();
1128
1018
  });
1129
1019
  }
1130
- 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');
1131
1038
  return true;
1132
- 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
+ }
1133
1055
  return true;
1134
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
+ */
1135
1062
  getVersion() {
1136
- return new Promise((resolve, reject) => {
1137
- if (!this._dc || this._dc.readyState !== 'open') {
1138
- reject(new Error('Data channel not open'));
1139
- return;
1140
- }
1141
- if (this._versionResolve) {
1142
- this._versionResolve(null);
1143
- }
1144
- this._versionResolve = resolve;
1145
- this._sendCommand({ type: 'get_version' });
1146
- });
1063
+ return this._slotRoundtrip('version', { type: 'get_version' });
1147
1064
  }
1148
1065
  getHardwareId() {
1149
- return new Promise((resolve, reject) => {
1150
- if (!this._dc || this._dc.readyState !== 'open') {
1151
- reject(new Error('Data channel not open'));
1152
- return;
1153
- }
1154
- if (this._hardwareIdResolve) {
1155
- this._hardwareIdResolve(null);
1156
- }
1157
- this._hardwareIdResolve = resolve;
1158
- this._sendCommand({ type: 'get_hardware_id' });
1159
- });
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' });
1160
1075
  }
1161
1076
  getVolume() {
1162
- return this._slotRoundtrip(() => this._volumeResolve, (next) => { this._volumeResolve = next; }, { type: 'get_volume' });
1077
+ return this._slotRoundtrip('volume', { type: 'get_volume' });
1163
1078
  }
1164
1079
  setVolume(volume) {
1165
- 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) });
1166
1081
  }
1167
1082
  getMicrophoneVolume() {
1168
- return this._slotRoundtrip(() => this._micVolumeResolve, (next) => { this._micVolumeResolve = next; }, { type: 'get_microphone_volume' });
1083
+ return this._slotRoundtrip('mic_volume', { type: 'get_microphone_volume' });
1169
1084
  }
1170
1085
  setMicrophoneVolume(volume) {
1171
- 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) });
1087
+ }
1088
+ /**
1089
+ * Query whether the first wake-up setup wizard has been completed.
1090
+ * Robot-wide, persisted on the robot. Resolves `false` when pending,
1091
+ * `true` when done, or `null` when the channel isn't open / the daemon
1092
+ * predates the `get_first_wake_up` command (callers should fail-open
1093
+ * and skip the wizard on `null`).
1094
+ */
1095
+ getFirstWakeUp() {
1096
+ // Fail-open, so this never rejects: the wizard gate runs right after
1097
+ // connect, which is exactly when the channel may not be open yet.
1098
+ if (!this._dc || this._dc.readyState !== 'open')
1099
+ return Promise.resolve(null);
1100
+ return this._slotRoundtrip('first_wake_up', { type: 'get_first_wake_up' });
1101
+ }
1102
+ /**
1103
+ * Persist the first wake-up wizard completion flag on the robot.
1104
+ * Resolves with the stored value (or `null` on channel-closed).
1105
+ */
1106
+ setFirstWakeUp(isCompleted) {
1107
+ if (!this._dc || this._dc.readyState !== 'open')
1108
+ return Promise.resolve(null);
1109
+ return this._slotRoundtrip('first_wake_up', { type: 'set_first_wake_up', is_completed: isCompleted });
1110
+ }
1111
+ /**
1112
+ * Query the persisted robot display name. Resolves the stored name,
1113
+ * `null` when none is set / the channel isn't open / the daemon predates
1114
+ * the `get_robot_name` command.
1115
+ */
1116
+ getRobotName() {
1117
+ return this._slotRoundtrip('robot_name', { type: 'get_robot_name' });
1118
+ }
1119
+ /**
1120
+ * Set and persist the robot display name on the robot. Resolves with the
1121
+ * stored (trimmed) name, or `null` on error / channel-closed. Applied live
1122
+ * by the daemon (status + central relay + mDNS), so it takes effect right
1123
+ * away without a restart; the persisted name also overrides --robot-name
1124
+ * on the next start.
1125
+ */
1126
+ setRobotName(name) {
1127
+ return this._slotRoundtrip('robot_name', { type: 'set_robot_name', name });
1128
+ }
1129
+ /**
1130
+ * Sign this robot out of Hugging Face: asks the daemon to delete its
1131
+ * stored HF token, which de-registers the robot from the central
1132
+ * signaling relay (it disappears from its owner's robot list until it
1133
+ * is set up again). Works over the WebRTC data channel, so it reaches
1134
+ * the robot remotely (no LAN HTTP path required).
1135
+ *
1136
+ * Resolves `true` when the daemon acked success, `false` on a daemon
1137
+ * error, or `null` when no ack arrives before the timeout (e.g. a
1138
+ * daemon that predates the `delete_hf_token` command silently drops
1139
+ * it). Rejects if the data channel isn't open. Note the sign-out
1140
+ * drops the central relay, so the session may tear down right after
1141
+ * the ack - callers should treat a post-call session drop as expected,
1142
+ * and a successful sign-out may surface as `null` if teardown races
1143
+ * ahead of the ack.
1144
+ */
1145
+ signOut() {
1146
+ return this._slotRoundtrip('delete_hf_token', { type: 'delete_hf_token' });
1172
1147
  }
1173
1148
  applyAudioConfig(config, { verify = true } = {}) {
1174
- 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);
1175
1151
  }
1176
1152
  readAudioParameter(name) {
1177
- 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 });
1178
1154
  }
1179
1155
  /**
1180
1156
  * Internal: send a command and await the matching daemon response in a
1181
- * named single-resolver slot. Used by the volume helpers and the
1182
- * XVF3800 audio-config helpers — every one of them has a strict
1183
- * request/response shape where a single in-flight call per slot is
1184
- * sufficient. If a previous request on the same slot is still
1185
- * pending when a new one comes in, the older promise is resolved to
1186
- * `null` so its caller doesn't hang forever.
1187
- *
1188
- * Slot access is passed in as getter/setter closures rather than a
1189
- * key into `this`: that keeps the helper fully generic-checked (T is
1190
- * inferred from the slot's resolver type at each call site) with no
1191
- * 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.
1192
1160
  */
1193
- _slotRoundtrip(getSlot, setSlot, command) {
1194
- return new Promise((resolve, reject) => {
1195
- if (!this._dc || this._dc.readyState !== 'open') {
1196
- reject(new Error('Data channel not open'));
1197
- return;
1198
- }
1199
- const prev = getSlot();
1200
- if (prev)
1201
- prev(null);
1202
- setSlot(resolve);
1203
- this._sendCommand(command);
1204
- });
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); });
1205
1166
  }
1206
1167
  sendRaw(data) {
1207
1168
  return this._sendCommand(data);
1208
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
+ }
1209
1207
  subscribeLogs({ onLine, onError }) {
1210
1208
  if (typeof onLine !== 'function') {
1211
1209
  throw new TypeError('subscribeLogs: onLine callback is required');
@@ -1229,6 +1227,30 @@ export class ReachyMini extends EventTarget {
1229
1227
  requestState() {
1230
1228
  return this._sendCommand({ type: 'get_state' });
1231
1229
  }
1230
+ /**
1231
+ * Ask the daemon to *push* the robot state (~30 Hz) over the dedicated
1232
+ * unreliable/unordered `pose` data channel instead of polling get_state.
1233
+ * Fires `state` events as frames arrive. No-op against an older daemon (no
1234
+ * pose channel) - fall back to `requestState()` polling there.
1235
+ *
1236
+ * Refcounted: pair every `subscribePose()` with exactly one
1237
+ * `unsubscribePose()`. Multiple consumers share a single daemon-side
1238
+ * subscription; the daemon only stops pushing once the last one releases.
1239
+ * If the channel isn't open yet (or the session later reconnects), the
1240
+ * subscription is (re-)asserted from `_checkSessionReady`.
1241
+ */
1242
+ subscribePose() {
1243
+ this._poseSubRefs++;
1244
+ return this._sendCommand({ type: 'subscribe_pose' });
1245
+ }
1246
+ /** Release one pose-stream consumer; sends `unsubscribe_pose` on the last. */
1247
+ unsubscribePose() {
1248
+ if (this._poseSubRefs > 0)
1249
+ this._poseSubRefs--;
1250
+ if (this._poseSubRefs > 0)
1251
+ return true; // still wanted by another consumer
1252
+ return this._sendCommand({ type: 'unsubscribe_pose' });
1253
+ }
1232
1254
  // ─── Audio ───────────────────────────────────────────────────────────
1233
1255
  setAudioMuted(muted) {
1234
1256
  this._audioMuted = muted;
@@ -1384,7 +1406,7 @@ export class ReachyMini extends EventTarget {
1384
1406
  });
1385
1407
  }
1386
1408
  catch (e) {
1387
- console.warn('playMove.onStarted threw:', e);
1409
+ log.warn('playMove.onStarted threw:', e);
1388
1410
  }
1389
1411
  onProgress({ phase: 'playing', duration_s: startedAck.duration_s });
1390
1412
  const final = await this._waitForBroadcast((m) => m?.type === 'play_uploaded_move'
@@ -1469,20 +1491,8 @@ export class ReachyMini extends EventTarget {
1469
1491
  _emit(name, detail) {
1470
1492
  this.dispatchEvent(new CustomEvent(name, { detail }));
1471
1493
  }
1472
- _waitForBroadcast(predicate, { timeoutMs = 5000, debugLabel = '' } = {}) {
1473
- return new Promise((resolve, reject) => {
1474
- const slot = {
1475
- predicate,
1476
- resolve,
1477
- timer: setTimeout(() => {
1478
- const i = this._broadcastWaiters.indexOf(slot);
1479
- if (i !== -1)
1480
- this._broadcastWaiters.splice(i, 1);
1481
- reject(new Error(`broadcast timeout (${timeoutMs} ms): ${debugLabel}`));
1482
- }, timeoutMs),
1483
- };
1484
- this._broadcastWaiters.push(slot);
1485
- });
1494
+ _waitForBroadcast(predicate, opts = {}) {
1495
+ return this._pending.awaitBroadcast(predicate, opts);
1486
1496
  }
1487
1497
  async _awaitDataChannelDrain() {
1488
1498
  while (this._dc && this._dc.bufferedAmount > UPLOAD_BUFFERED_LOW_WATER) {
@@ -1510,13 +1520,13 @@ export class ReachyMini extends EventTarget {
1510
1520
  body = await res.text();
1511
1521
  }
1512
1522
  catch { /* ignore */ }
1513
- 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>'}`);
1514
1524
  return null;
1515
1525
  }
1516
1526
  return await res.json();
1517
1527
  }
1518
1528
  catch (e) {
1519
- console.error('Send error:', e);
1529
+ log.error('signaling /send failed:', e);
1520
1530
  return null;
1521
1531
  }
1522
1532
  }
@@ -1526,11 +1536,70 @@ export class ReachyMini extends EventTarget {
1526
1536
  this._dc.send(JSON.stringify(cmd));
1527
1537
  return true;
1528
1538
  }
1539
+ /**
1540
+ * Call a JSON-RPC method on the robot/app over the DataChannel and await
1541
+ * its result. This is the one way to drive an on-robot app (start/stop it
1542
+ * via `apps.*`, or drive a running app via its own namespace, e.g.
1543
+ * `conversation.say`). Rejects on the JSON-RPC error, a closed channel, or
1544
+ * timeout.
1545
+ */
1546
+ rpcCall(method, params = {}, opts = {}) {
1547
+ const timeoutMs = opts.timeoutMs ?? 20000;
1548
+ return this._pending.rpcRoundtrip(method, timeoutMs, (id) => this._sendCommand({ jsonrpc: '2.0', id, method, params }));
1549
+ }
1550
+ /**
1551
+ * Subscribe to a JSON-RPC notification (one-way event) pushed by the
1552
+ * robot/app, e.g. `conversation.turn`. Returns an unsubscribe function.
1553
+ */
1554
+ onNotification(method, cb) {
1555
+ let set = this._rpcListeners.get(method);
1556
+ if (!set) {
1557
+ set = new Set();
1558
+ this._rpcListeners.set(method, set);
1559
+ }
1560
+ set.add(cb);
1561
+ return () => {
1562
+ this._rpcListeners.get(method)?.delete(cb);
1563
+ };
1564
+ }
1565
+ _handleRpcMessage(data) {
1566
+ // Response to an rpcCall (correlated by id)...
1567
+ if (this._pending.settleRpcResponse(data))
1568
+ return;
1569
+ // ...or a one-way notification (event): dispatch to listeners.
1570
+ if (typeof data.method === 'string') {
1571
+ const params = data.params ?? {};
1572
+ for (const cb of this._rpcListeners.get(data.method) ?? []) {
1573
+ try {
1574
+ cb(params);
1575
+ }
1576
+ catch (e) {
1577
+ log.error(`onNotification(${data.method}) threw:`, e);
1578
+ }
1579
+ }
1580
+ }
1581
+ }
1529
1582
  _checkSessionReady() {
1530
1583
  if (this._iceConnected && this._dcOpen && this._sessionResolve) {
1531
1584
  this._state = 'streaming';
1532
1585
  this.requestState();
1533
- this._stateRefreshInterval = setInterval(() => this.requestState(), 500);
1586
+ // Re-assert a pose subscription that was requested before the data
1587
+ // channel was open, or lost on reconnect (a fresh peer starts
1588
+ // unsubscribed on the daemon). Sent raw so it doesn't touch the
1589
+ // local refcount, which already reflects the live consumer count.
1590
+ if (this._poseSubRefs > 0)
1591
+ this._sendCommand({ type: 'subscribe_pose' });
1592
+ // A fresh session has received no pose frame yet, so a timestamp
1593
+ // left over from the previous one must not hold off the poll.
1594
+ this._lastPoseFrameAt = 0;
1595
+ this._stateRefreshInterval = setInterval(() => {
1596
+ // Skip while the pose stream is already feeding state; see
1597
+ // POSE_STREAM_FRESH_MS.
1598
+ if (Date.now() - this._lastPoseFrameAt < POSE_STREAM_FRESH_MS)
1599
+ return;
1600
+ this.requestState();
1601
+ }, 500);
1602
+ this._supervisor.startDcWatchdog();
1534
1603
  this._emit('streaming', { sessionId: this._sessionId, robotId: this._selectedRobotId });
1535
1604
  this._sessionResolve();
1536
1605
  this._sessionResolve = null;
@@ -1581,10 +1650,14 @@ export class ReachyMini extends EventTarget {
1581
1650
  if (this._sessionReject) {
1582
1651
  const err = new Error(friendly || `Session ended before it could start: ${reason || 'unknown reason'}`);
1583
1652
  err.reason = reason ?? null;
1584
- 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
+ }
1585
1658
  // Resilience teardown alongside the PC close path.
1586
- this._clearIceGrace();
1587
- this._uninstallNetworkListeners();
1659
+ this._supervisor.clearIceGrace();
1660
+ this._supervisor.uninstallNetworkListeners();
1588
1661
  if (this._pc) {
1589
1662
  this._pc.close();
1590
1663
  this._pc = null;
@@ -1646,7 +1719,7 @@ export class ReachyMini extends EventTarget {
1646
1719
  await this._pc.addIceCandidate(new RTCIceCandidate(ice));
1647
1720
  }
1648
1721
  catch (err) {
1649
- console.warn('[reachy-mini] buffered ICE candidate rejected:', err);
1722
+ log.warn('buffered ICE candidate rejected:', err);
1650
1723
  }
1651
1724
  }
1652
1725
  }
@@ -1670,72 +1743,76 @@ export class ReachyMini extends EventTarget {
1670
1743
  }
1671
1744
  }
1672
1745
  catch (e) {
1673
- console.error('WebRTC error:', e);
1746
+ log.error('webrtc signaling failed:', e);
1674
1747
  this._emit('error', { source: 'webrtc', error: e });
1675
1748
  }
1676
1749
  }
1677
- _handleRobotMessage(data) {
1678
- if ('version' in data && this._versionResolve) {
1679
- this._versionResolve(data.version);
1680
- this._versionResolve = null;
1750
+ /**
1751
+ * @param fromPoseStream Frame came from the pushed `pose` channel rather
1752
+ * than the reliable control channel. Only those may refresh the state
1753
+ * mirror while the stream is live (see the `data.state` branch).
1754
+ */
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();
1760
+ // JSON-RPC frames (app control surface) are handled separately from
1761
+ // the legacy {command|type} robot messages that share this channel.
1762
+ if (data.jsonrpc === '2.0') {
1763
+ this._handleRpcMessage(data);
1681
1764
  return;
1682
1765
  }
1683
- if ('hardware_id' in data && this._hardwareIdResolve) {
1684
- this._hardwareIdResolve(data.hardware_id);
1685
- this._hardwareIdResolve = 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)) {
1771
+ return;
1772
+ }
1773
+ if ('hardware_id' in data
1774
+ && this._pending.settleReplySlot('hardware_id', data.hardware_id ?? null)) {
1686
1775
  return;
1687
1776
  }
1688
1777
  if (data.command === 'get_volume' || data.command === 'set_volume') {
1689
- if (this._volumeResolve) {
1690
- this._volumeResolve(data.status === 'error' ? null : data.volume);
1691
- this._volumeResolve = null;
1692
- }
1778
+ this._pending.settleReplySlot('volume', data.status === 'error' ? null : data.volume);
1693
1779
  return;
1694
1780
  }
1695
1781
  if (data.command === 'get_microphone_volume' || data.command === 'set_microphone_volume') {
1696
- if (this._micVolumeResolve) {
1697
- this._micVolumeResolve(data.status === 'error' ? null : data.volume);
1698
- this._micVolumeResolve = null;
1699
- }
1782
+ this._pending.settleReplySlot('mic_volume', data.status === 'error' ? null : data.volume);
1783
+ return;
1784
+ }
1785
+ if (data.command === 'get_first_wake_up' || data.command === 'set_first_wake_up') {
1786
+ this._pending.settleReplySlot('first_wake_up', data.status === 'error' ? null : !!data.is_completed);
1787
+ return;
1788
+ }
1789
+ if (data.command === 'get_robot_name' || data.command === 'set_robot_name') {
1790
+ this._pending.settleReplySlot('robot_name', data.status === 'error' ? null : (data.name ?? null));
1791
+ return;
1792
+ }
1793
+ if (data.command === 'delete_hf_token') {
1794
+ this._pending.settleReplySlot('delete_hf_token', data.status !== 'error');
1700
1795
  return;
1701
1796
  }
1702
1797
  if (data.command === 'apply_audio_config') {
1703
- if (this._applyAudioConfigResolve) {
1704
- this._applyAudioConfigResolve(data.error ? false : !!data.applied);
1705
- this._applyAudioConfigResolve = null;
1706
- }
1798
+ this._pending.settleReplySlot('apply_audio_config', data.error ? false : !!data.applied);
1707
1799
  return;
1708
1800
  }
1709
1801
  if (data.command === 'read_audio_parameter') {
1710
- if (this._readAudioParameterResolve) {
1711
- this._readAudioParameterResolve(data.error ? null : (data.values ?? null));
1712
- this._readAudioParameterResolve = null;
1713
- }
1802
+ this._pending.settleReplySlot('read_audio_parameter', data.error ? null : (data.values ?? null));
1714
1803
  return;
1715
1804
  }
1716
1805
  if (data.command === 'get_tracked_face') {
1717
- if (this._trackedFaceResolve) {
1718
- this._trackedFaceResolve(data.face_target ?? null);
1719
- this._trackedFaceResolve = null;
1720
- }
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);
1721
1811
  return;
1722
1812
  }
1723
1813
  if ((data.command === 'wake_up' || data.command === 'goto_sleep')
1724
- && this._pendingMotionCompletions
1725
- && this._pendingMotionCompletions[data.command]) {
1726
- const queue = this._pendingMotionCompletions[data.command];
1727
- if (data.completed === true && queue.length > 0) {
1728
- const entry = queue.shift();
1729
- clearTimeout(entry.timer);
1730
- entry.resolve();
1731
- return;
1732
- }
1733
- if (data.error && queue.length > 0) {
1734
- const entry = queue.shift();
1735
- clearTimeout(entry.timer);
1736
- entry.reject(new Error(`${data.command}: ${data.error}`));
1737
- return;
1738
- }
1814
+ && this._pending.settleMotion(data.command, data)) {
1815
+ return;
1739
1816
  }
1740
1817
  if (data.type === 'log_line') {
1741
1818
  for (const sub of this._logSubscribers) {
@@ -1743,7 +1820,7 @@ export class ReachyMini extends EventTarget {
1743
1820
  sub.onLine({ timestamp: data.timestamp, line: data.line });
1744
1821
  }
1745
1822
  catch (e) {
1746
- console.error('subscribeLogs onLine threw:', e);
1823
+ log.error('subscribeLogs onLine threw:', e);
1747
1824
  }
1748
1825
  }
1749
1826
  return;
@@ -1755,7 +1832,7 @@ export class ReachyMini extends EventTarget {
1755
1832
  sub.onError(data.error);
1756
1833
  }
1757
1834
  catch (e) {
1758
- console.error('subscribeLogs onError threw:', e);
1835
+ log.error('subscribeLogs onError threw:', e);
1759
1836
  }
1760
1837
  }
1761
1838
  }
@@ -1773,7 +1850,7 @@ export class ReachyMini extends EventTarget {
1773
1850
  cb(event);
1774
1851
  }
1775
1852
  catch (e) {
1776
- console.error('startDaemonUpdate onProgress threw:', e);
1853
+ log.error('startDaemonUpdate onProgress threw:', e);
1777
1854
  }
1778
1855
  }
1779
1856
  }
@@ -1790,17 +1867,30 @@ export class ReachyMini extends EventTarget {
1790
1867
  cb(event);
1791
1868
  }
1792
1869
  catch (e) {
1793
- console.error('startDaemonUpdate onProgress threw:', e);
1870
+ log.error('startDaemonUpdate onProgress threw:', e);
1794
1871
  }
1795
1872
  }
1796
1873
  return;
1797
1874
  }
1798
- if (data.state) {
1875
+ // Only the stream may write the mirror while the stream is live. The
1876
+ // poll stands down in that case (see POSE_STREAM_FRESH_MS), but it
1877
+ // can't unsend a request already in flight: `get_state` rides the
1878
+ // reliable channel, so its reply queues behind whatever else is on it
1879
+ // - a `upload_move_*` burst, typically - and can land hundreds of ms
1880
+ // after the snapshot it carries. Having no `seq`, it slips past the
1881
+ // stale-frame guard and rewinds every consumer to a pose from before
1882
+ // the upload, until the next pushed frame puts them back. That reads
1883
+ // as a one-frame flick to the pre-move pose right as an animation
1884
+ // starts. Nothing is lost by dropping it: pushed frames carry the same
1885
+ // fields (daemon-side `build_state_dict` feeds both).
1886
+ if (data.state && (fromPoseStream || Date.now() - this._lastPoseFrameAt >= POSE_STREAM_FRESH_MS)) {
1799
1887
  const s = data.state;
1800
1888
  if (s.head_pose)
1801
1889
  this._robotState.head = s.head_pose.flat();
1802
1890
  if (s.antennas)
1803
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;
1804
1894
  if (typeof s.body_yaw === 'number')
1805
1895
  this._robotState.body_yaw = s.body_yaw;
1806
1896
  if (s.motor_mode)
@@ -1809,22 +1899,17 @@ export class ReachyMini extends EventTarget {
1809
1899
  this._robotState.is_move_running = s.is_move_running;
1810
1900
  if (s.face_target)
1811
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;
1812
1906
  this._emit('state', { ...this._robotState });
1813
1907
  }
1814
1908
  if (data.error) {
1815
1909
  this._emit('error', { source: 'robot', error: data.error });
1816
1910
  }
1817
- if (this._broadcastWaiters.length > 0) {
1818
- for (let i = this._broadcastWaiters.length - 1; i >= 0; i--) {
1819
- const slot = this._broadcastWaiters[i];
1820
- if (slot.predicate(data)) {
1821
- this._broadcastWaiters.splice(i, 1);
1822
- clearTimeout(slot.timer);
1823
- slot.resolve(data);
1824
- return;
1825
- }
1826
- }
1827
- }
1911
+ if (this._pending.matchBroadcast(data))
1912
+ return;
1828
1913
  }
1829
1914
  /** Snap video playback to live edge if buffered lag exceeds 0.5 s. */
1830
1915
  _startLatencyMonitor(video) {
@@ -1838,7 +1923,7 @@ export class ReachyMini extends EventTarget {
1838
1923
  const end = buf.end(buf.length - 1);
1839
1924
  const lag = end - video.currentTime;
1840
1925
  if (lag > 0.5) {
1841
- console.log(`Latency correction: was ${lag.toFixed(2)}s behind`);
1926
+ log.debug(`video latency correction: was ${lag.toFixed(2)}s behind`);
1842
1927
  video.currentTime = end - 0.1;
1843
1928
  }
1844
1929
  }