@pollen-robotics/reachy-mini-sdk 1.10.0-dev.0.main.05ce680 → 1.10.0-dev.0.main.4dc28bb

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 (125) 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 +185 -91
  39. package/dist/lib/reachy-mini.d.ts.map +1 -1
  40. package/dist/lib/reachy-mini.js +604 -648
  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-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 +68 -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.d.ts +5 -4
  96. package/host/dist/entry/embed.d.ts.map +1 -1
  97. package/host/dist/entry/embed.js +5 -4
  98. package/host/dist/hooks/useHostBridge.d.ts +35 -1
  99. package/host/dist/hooks/useHostBridge.d.ts.map +1 -1
  100. package/host/dist/hooks/useLatestDaemonVersion.d.ts +7 -0
  101. package/host/dist/hooks/useLatestDaemonVersion.d.ts.map +1 -0
  102. package/host/dist/hooks/useOAuth.d.ts +18 -0
  103. package/host/dist/hooks/useOAuth.d.ts.map +1 -1
  104. package/host/dist/hooks/useRobots.d.ts +0 -2
  105. package/host/dist/hooks/useRobots.d.ts.map +1 -1
  106. package/host/dist/index.d.ts +2 -2
  107. package/host/dist/index.d.ts.map +1 -1
  108. package/host/dist/index.js +11 -10
  109. package/host/dist/lib/daemonRelease.d.ts +84 -0
  110. package/host/dist/lib/daemonRelease.d.ts.map +1 -0
  111. package/host/dist/lib/protocol.d.ts +141 -7
  112. package/host/dist/lib/protocol.d.ts.map +1 -1
  113. package/host/dist/lib/rebootWatch.d.ts +51 -0
  114. package/host/dist/lib/rebootWatch.d.ts.map +1 -0
  115. package/host/dist/lib/sdk-types.d.ts +1 -1
  116. package/host/dist/lib/sdk-types.d.ts.map +1 -1
  117. package/host/dist/lib/sdkStaleness.d.ts +78 -0
  118. package/host/dist/lib/sdkStaleness.d.ts.map +1 -0
  119. package/host/dist/lib/settings.d.ts +16 -5
  120. package/host/dist/lib/settings.d.ts.map +1 -1
  121. package/package.json +9 -2
  122. package/host/dist/chunks/index-lMs2sCXG.js +0 -439
  123. package/host/dist/chunks/reachy-mini-DJD3mwi2.js +0 -1595
  124. package/host/dist/lib/centralListener.d.ts +0 -73
  125. package/host/dist/lib/centralListener.d.ts.map +0 -1
@@ -4,56 +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;
43
- /**
44
- * Fail-open ceiling for a single `_slotRoundtrip` request/response.
45
- * Every slot command has a strict "one reply per request" contract, so
46
- * a missing reply means the daemon either never got it or - crucially -
47
- * predates that command entirely: an older daemon silently drops an
48
- * unknown `type` and sends nothing back, which would otherwise leave the
49
- * caller's promise pending forever (e.g. a newer SDK calling a command a
50
- * 1.8.x daemon doesn't implement). Resolving `null` on timeout maps
51
- * cleanly onto the "unsupported / failed" value every slot caller already
52
- * handles. 4 s is comfortably above a WebRTC data-channel round trip on a
53
- * congested phone link while still failing fast enough that a gated UI
54
- * doesn't feel hung.
55
- */
56
- const SLOT_ROUNDTRIP_TIMEOUT_MS = 4000;
42
+ const WAKE_TRAJECTORY_BUDGET_MS = 5000;
57
43
  export class ReachyMini extends EventTarget {
58
44
  // ─── Config ──────────────────────────────────────────────────────────
59
45
  _signalingUrl;
@@ -66,6 +52,19 @@ export class ReachyMini extends EventTarget {
66
52
  _state = 'disconnected';
67
53
  _robots = [];
68
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;
69
68
  _preselectedRobotId;
70
69
  // ─── Auth ────────────────────────────────────────────────────────────
71
70
  _token = null;
@@ -87,29 +86,17 @@ export class ReachyMini extends EventTarget {
87
86
  // ─── Timers ──────────────────────────────────────────────────────────
88
87
  _latencyMonitorId = null;
89
88
  _stateRefreshInterval = null;
90
- // ─── Single-slot promise resolvers ───────────────────────────────────
91
- _versionResolve = null;
92
- _hardwareIdResolve = null;
93
- _volumeResolve = null;
94
- _micVolumeResolve = null;
95
- _trackedFaceResolve = null;
96
- // applyAudioConfig() / readAudioParameter() share the same single-slot
97
- // pattern as the volume helpers. Separate slots so the two can be
98
- // in-flight concurrently without collision.
99
- _applyAudioConfigResolve = null;
100
- _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();
101
93
  // ─── Log subscribers ─────────────────────────────────────────────────
102
94
  _logSubscribers = new Set();
103
95
  _updateProgressSubscribers = new Set();
104
- // ─── JSON-RPC app control (over the same DataChannel) ────────────────
105
- // rpcCall() sends {jsonrpc,id,method,params} and awaits the matching
106
- // response; onNotification() subscribes to one-way events (no id) the
107
- // robot/app pushes (conversation.phase/turn/transcript, ...).
108
- _rpcCounter = 0;
109
- _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, ...).
110
99
  _rpcListeners = new Map();
111
- // ─── Broadcast waiters (playMove / playUploadedAudio) ────────────────
112
- _broadcastWaiters = [];
113
100
  // ─── Active upload ids for no-arg cancels ────────────────────────────
114
101
  _activeMoveUploadId = null;
115
102
  _activeAudioUploadId = null;
@@ -118,24 +105,12 @@ export class ReachyMini extends EventTarget {
118
105
  _sessionReject = null;
119
106
  _iceConnected = false;
120
107
  _dcOpen = false;
121
- // ─── Resilience: ICE-blip debounce + network awareness ──────────────
122
- // Backs `_scheduleIceGrace` / `_armIceGraceOnVisibility` and the
123
- // `networkOnline` / `networkOffline` / `networkChange` forwarders.
124
- // All three handler slots are scoped to the lifetime of a live
125
- // session (installed in `startSession`, cleared in
126
- // `stopSession` / `disconnect` / `_handleEndSession` /
127
- // `_failSessionRejected`).
128
- _iceGraceTimer = null;
129
- _iceGraceReason = null;
130
- _pendingVisibilityHandler = null;
131
- _onlineHandler = null;
132
- _offlineHandler = null;
133
- _connectionChangeHandler = null;
134
- // ─── Motion completion plumbing (wake_up / goto_sleep) ───────────────
135
- _pendingMotionCompletions = {
136
- wake_up: [],
137
- goto_sleep: [],
138
- };
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;
139
114
  // ─── Video element ───────────────────────────────────────────────────
140
115
  _videoElement = null;
141
116
  constructor(options = {}) {
@@ -151,6 +126,31 @@ export class ReachyMini extends EventTarget {
151
126
  this._autoStartFromUrl = options.autoStartFromUrl === true;
152
127
  this._autoStartAttempted = false;
153
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 });
154
154
  }
155
155
  // ─── Read-only properties ────────────────────────────────────────────
156
156
  get state() { return this._state; }
@@ -163,6 +163,24 @@ export class ReachyMini extends EventTarget {
163
163
  get audioMuted() { return this._audioMuted; }
164
164
  get preselectedRobotId() { return this._preselectedRobotId; }
165
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; }
166
184
  /**
167
185
  * Internal: try to honour the `autoStartFromUrl` constructor
168
186
  * option. Called from the signaling-message handler after every
@@ -187,7 +205,7 @@ export class ReachyMini extends EventTarget {
187
205
  if (this._state !== 'connected')
188
206
  return;
189
207
  this.startSession(peerId).catch((err) => {
190
- console.warn('[reachy-mini] autoStartFromUrl: startSession rejected:', err);
208
+ log.warn('autoStartFromUrl: startSession rejected:', err);
191
209
  });
192
210
  }, 0);
193
211
  }
@@ -195,44 +213,63 @@ export class ReachyMini extends EventTarget {
195
213
  async authenticate() {
196
214
  try {
197
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
+ }
198
224
  const result = (await oauthHandleRedirectIfPresent());
199
225
  if (result) {
200
226
  this._username = result.userInfo.preferred_username || result.userInfo.name || null;
201
227
  this._token = result.accessToken;
202
228
  this._tokenExpires = result.accessTokenExpiresAt;
203
- sessionStorage.setItem('hf_token', this._token);
204
- sessionStorage.setItem('hf_username', this._username ?? '');
205
- sessionStorage.setItem('hf_token_expires', typeof this._tokenExpires === 'string'
206
- ? this._tokenExpires
207
- : 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
+ });
208
236
  return true;
209
237
  }
210
- const t = sessionStorage.getItem('hf_token');
211
- const u = sessionStorage.getItem('hf_username');
212
- const e = sessionStorage.getItem('hf_token_expires');
213
- if (t && u && e && new Date(e) > new Date()) {
214
- this._token = t;
215
- this._username = u;
216
- 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;
217
245
  return true;
218
246
  }
219
247
  return false;
220
248
  }
221
249
  catch (e) {
222
- console.error('Auth error:', e);
250
+ log.error('authenticate failed:', e);
223
251
  return false;
224
252
  }
225
253
  }
226
- async login() {
254
+ async login(options) {
227
255
  const opts = {};
228
256
  if (this._clientId)
229
257
  opts.clientId = this._clientId;
230
- 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;
231
270
  }
232
271
  logout() {
233
- sessionStorage.removeItem('hf_token');
234
- sessionStorage.removeItem('hf_username');
235
- sessionStorage.removeItem('hf_token_expires');
272
+ clearStoredToken();
236
273
  this._username = null;
237
274
  this._tokenExpires = null;
238
275
  this.disconnect();
@@ -379,7 +416,7 @@ export class ReachyMini extends EventTarget {
379
416
  await this.ensureAwake();
380
417
  }
381
418
  catch (e) {
382
- console.warn('[reachy-mini] autoConnect: ensureAwake failed:', e);
419
+ log.warn('autoConnect: ensureAwake failed:', e);
383
420
  }
384
421
  }
385
422
  return { robotId, robotName, isEmbedded: this.isEmbedded };
@@ -413,7 +450,7 @@ export class ReachyMini extends EventTarget {
413
450
  return Array.from(seen.values()).sort((a, b) => (a.lastSeenAgeSeconds ?? Infinity) - (b.lastSeenAgeSeconds ?? Infinity));
414
451
  }
415
452
  catch (e) {
416
- console.warn('[reachy-mini] /api/robot-status unavailable, using SSE list:', e);
453
+ log.warn('/api/robot-status unavailable, using SSE list:', e);
417
454
  return (this._robots || []).map((r) => ({
418
455
  id: r.id,
419
456
  name: r.meta?.name ?? null,
@@ -443,6 +480,14 @@ export class ReachyMini extends EventTarget {
443
480
  });
444
481
  }
445
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) {
446
491
  if (this._state !== 'connected')
447
492
  throw new Error('Not connected');
448
493
  this._selectedRobotId = robotId;
@@ -481,7 +526,7 @@ export class ReachyMini extends EventTarget {
481
526
  this._micMuted = true;
482
527
  }
483
528
  catch (e) {
484
- console.warn('Audio sender placeholder setup failed:', e);
529
+ log.warn('audio sender placeholder setup failed:', e);
485
530
  this._micStream = null;
486
531
  }
487
532
  this._pc = new RTCPeerConnection({
@@ -489,7 +534,7 @@ export class ReachyMini extends EventTarget {
489
534
  });
490
535
  // Scope `networkOnline` / `networkOffline` / `networkChange`
491
536
  // event forwarding to the lifetime of this session.
492
- this._installNetworkListeners();
537
+ this._supervisor.installNetworkListeners();
493
538
  return new Promise((resolve, reject) => {
494
539
  this._sessionResolve = resolve;
495
540
  this._sessionReject = reject;
@@ -533,35 +578,50 @@ export class ReachyMini extends EventTarget {
533
578
  this._emit('iceStateChange', { state: s });
534
579
  if (s === 'connected' || s === 'completed') {
535
580
  // Healed — cancel any pending grace from a previous blip.
536
- this._clearIceGrace();
581
+ this._supervisor.onIceHealed();
537
582
  this._iceConnected = true;
538
583
  this._checkSessionReady();
539
584
  return;
540
585
  }
541
586
  if (s === 'disconnected') {
542
- // TRANSIENT per spec — debounce before escalating.
543
- // If the tab is hidden, JS timers are throttled and
544
- // would fire unpredictably late, so defer the grace
545
- // window to the next foreground frame.
546
- if (typeof document !== 'undefined' && document.hidden) {
547
- this._armIceGraceOnVisibility();
548
- }
549
- else {
550
- this._scheduleIceGrace(ICE_DISCONNECT_GRACE_MS, 'disconnected');
551
- }
587
+ this._supervisor.onIceDisconnected();
552
588
  return;
553
589
  }
554
590
  if (s === 'failed') {
555
- // Terminal per spec, but in practice we've seen
556
- // `failed → connected` on rapid AP roams / BT route
557
- // changes on iOS. Give the ICE agent a short window
558
- // to surprise us before rejecting the session.
559
- this._scheduleIceGrace(ICE_FAILED_GRACE_MS, 'failed');
591
+ this._supervisor.onIceFailed();
560
592
  return;
561
593
  }
562
594
  };
563
595
  this._pc.ondatachannel = (e) => {
564
- 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;
565
625
  this._dc.onopen = () => {
566
626
  this._dcOpen = true;
567
627
  this._checkSessionReady();
@@ -578,78 +638,31 @@ export class ReachyMini extends EventTarget {
578
638
  });
579
639
  });
580
640
  }
581
- _failSessionRejected(msg) {
582
- const err = new Error(msg.reason === 'robot_busy'
583
- ? `Robot is busy: "${msg.activeApp || 'another app'}" is already connected`
584
- : `Session rejected: ${msg.reason || 'unknown reason'}`);
585
- err.reason = msg.reason ?? null;
586
- err.activeApp = msg.activeApp ?? null;
587
- // Resilience teardown BEFORE closing `_pc` so a queued grace
588
- // callback can't dereference a dead handle.
589
- this._clearIceGrace();
590
- this._uninstallNetworkListeners();
591
- if (this._pc) {
592
- this._pc.close();
593
- this._pc = null;
594
- }
595
- if (this._micStream) {
596
- this._micStream.getTracks().forEach((t) => t.stop());
597
- this._micStream = null;
598
- }
599
- this._iceConnected = false;
600
- this._dcOpen = false;
601
- this._micMuted = true;
602
- this._micSupported = false;
603
- 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);
604
654
  if (this._sessionReject) {
605
655
  const reject = this._sessionReject;
606
656
  this._sessionResolve = null;
607
657
  this._sessionReject = null;
608
- reject(err);
609
- }
610
- }
611
- async stopSession() {
612
- if (this._versionResolve) {
613
- this._versionResolve(null);
614
- this._versionResolve = null;
615
- }
616
- if (this._hardwareIdResolve) {
617
- this._hardwareIdResolve(null);
618
- this._hardwareIdResolve = null;
619
- }
620
- if (this._volumeResolve) {
621
- this._volumeResolve(null);
622
- this._volumeResolve = null;
623
- }
624
- if (this._micVolumeResolve) {
625
- this._micVolumeResolve(null);
626
- this._micVolumeResolve = null;
627
- }
628
- if (this._trackedFaceResolve) {
629
- this._trackedFaceResolve(null);
630
- this._trackedFaceResolve = null;
631
- }
632
- if (this._applyAudioConfigResolve) {
633
- this._applyAudioConfigResolve(false);
634
- this._applyAudioConfigResolve = null;
635
- }
636
- if (this._readAudioParameterResolve) {
637
- this._readAudioParameterResolve(null);
638
- this._readAudioParameterResolve = null;
639
- }
640
- this._logSubscribers.clear();
641
- this._updateProgressSubscribers.clear();
642
- this._rejectPendingMotionCompletions(new Error('Session stopped'));
643
- this._rejectPendingRpc(new Error('Session stopped'));
644
- // Tear down resilience plumbing BEFORE closing `_pc` so a
645
- // queued grace callback can't dereference a dead handle.
646
- this._clearIceGrace();
647
- this._uninstallNetworkListeners();
648
- if (this._sessionReject) {
649
- this._sessionReject(new Error('Session stopped'));
650
- this._sessionResolve = null;
651
- this._sessionReject = null;
658
+ reject(settleErr);
652
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();
653
666
  if (this._stateRefreshInterval) {
654
667
  clearInterval(this._stateRefreshInterval);
655
668
  this._stateRefreshInterval = null;
@@ -658,9 +671,6 @@ export class ReachyMini extends EventTarget {
658
671
  clearInterval(this._latencyMonitorId);
659
672
  this._latencyMonitorId = null;
660
673
  }
661
- if (this._sessionId) {
662
- await this._sendToServer({ type: 'endSession', sessionId: this._sessionId });
663
- }
664
674
  if (this._micStream) {
665
675
  this._micStream.getTracks().forEach((t) => t.stop());
666
676
  this._micStream = null;
@@ -675,9 +685,38 @@ export class ReachyMini extends EventTarget {
675
685
  this._dc.close();
676
686
  this._dc = null;
677
687
  }
678
- this._sessionId = null;
679
688
  this._iceConnected = false;
680
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
+ }
681
720
  const wasStreaming = this._state === 'streaming';
682
721
  if (wasStreaming) {
683
722
  this._state = 'connected';
@@ -685,259 +724,63 @@ export class ReachyMini extends EventTarget {
685
724
  }
686
725
  }
687
726
  disconnect() {
727
+ this._supervisor.cancelRedial();
688
728
  if (this._sseAbortController) {
689
729
  this._sseAbortController.abort();
690
730
  this._sseAbortController = null;
691
731
  }
692
- if (this._versionResolve) {
693
- this._versionResolve(null);
694
- this._versionResolve = null;
695
- }
696
- if (this._hardwareIdResolve) {
697
- this._hardwareIdResolve(null);
698
- this._hardwareIdResolve = null;
699
- }
700
- if (this._volumeResolve) {
701
- this._volumeResolve(null);
702
- this._volumeResolve = null;
703
- }
704
- if (this._micVolumeResolve) {
705
- this._micVolumeResolve(null);
706
- this._micVolumeResolve = null;
707
- }
708
- if (this._trackedFaceResolve) {
709
- this._trackedFaceResolve(null);
710
- this._trackedFaceResolve = null;
711
- }
712
- if (this._applyAudioConfigResolve) {
713
- this._applyAudioConfigResolve(false);
714
- this._applyAudioConfigResolve = null;
715
- }
716
- if (this._readAudioParameterResolve) {
717
- this._readAudioParameterResolve(null);
718
- this._readAudioParameterResolve = null;
719
- }
720
732
  this._logSubscribers.clear();
721
733
  this._updateProgressSubscribers.clear();
722
- this._rejectPendingMotionCompletions(new Error('Disconnected'));
723
- // Mirrors the resilience teardown in `stopSession()`.
724
- this._clearIceGrace();
725
- this._uninstallNetworkListeners();
726
- if (this._sessionReject) {
727
- this._sessionReject(new Error('Disconnected'));
728
- this._sessionResolve = null;
729
- this._sessionReject = null;
730
- }
731
- if (this._stateRefreshInterval) {
732
- clearInterval(this._stateRefreshInterval);
733
- this._stateRefreshInterval = null;
734
- }
735
- if (this._latencyMonitorId) {
736
- clearInterval(this._latencyMonitorId);
737
- this._latencyMonitorId = null;
734
+ const sessionId = this._closeTransport(new Error('Disconnected'));
735
+ if (sessionId && this._token) {
736
+ void this._sendToServer({ type: 'endSession', sessionId });
738
737
  }
739
- if (this._sessionId && this._token) {
740
- this._sendToServer({ type: 'endSession', sessionId: this._sessionId });
741
- }
742
- if (this._micStream) {
743
- this._micStream.getTracks().forEach((t) => t.stop());
744
- this._micStream = null;
745
- }
746
- if (this._pc) {
747
- this._pc.close();
748
- this._pc = null;
749
- }
750
- if (this._dc) {
751
- this._dc.close();
752
- this._dc = null;
753
- }
754
- this._sessionId = null;
755
- this._micMuted = true;
756
- this._micSupported = false;
757
- this._iceConnected = false;
758
- this._dcOpen = false;
759
738
  this._robots = [];
760
739
  this._state = 'disconnected';
761
740
  this._emit('disconnected', { reason: 'user' });
762
741
  }
763
- // ─── Resilience: ICE-blip debounce + network awareness ───────────────
764
- //
765
- // Both halves below are intentionally generic (they don't know about
766
- // motion, audio, or the FSM): they just smooth out browser-level
767
- // events so the consumer's own state machine doesn't get torn down
768
- // 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.
769
746
  /**
770
- * Cancel any pending ICE grace timer and visibility handler. Called
771
- * on a healed `connected`/`completed` transition AND from the
772
- * lifecycle teardown paths so a callback can't fire after `_pc`
773
- * is closed.
774
- */
775
- _clearIceGrace() {
776
- if (this._iceGraceTimer !== null) {
777
- clearTimeout(this._iceGraceTimer);
778
- this._iceGraceTimer = null;
779
- }
780
- this._iceGraceReason = null;
781
- if (this._pendingVisibilityHandler && typeof document !== 'undefined') {
782
- document.removeEventListener('visibilitychange', this._pendingVisibilityHandler);
783
- }
784
- this._pendingVisibilityHandler = null;
785
- }
786
- /**
787
- * Start a grace window. After `ms`, re-check the live ICE state:
788
- * - If we healed back to `connected`/`completed`, the timer was
789
- * already cancelled in `oniceconnectionstatechange`, so we
790
- * never get here.
791
- * - If we're still in the originally-observed bad state (or
792
- * worse), surface the error and reject any pending session
793
- * promise. The original code path is preserved verbatim so
794
- * downstream consumers see the same `error` payload shape.
795
- */
796
- _scheduleIceGrace(ms, reason) {
797
- // Coalesce: if a grace is already pending and the reason hasn't
798
- // changed, keep the original timer so a flurry of identical
799
- // transitions doesn't reset the clock. If the reason changed
800
- // (typically `disconnected` → `failed`, but also the reverse on
801
- // some Android WebViews), replace the timer with the new
802
- // (reason, ms) pair — the latest signal wins.
803
- if (this._iceGraceTimer !== null) {
804
- if (this._iceGraceReason === reason)
805
- return;
806
- clearTimeout(this._iceGraceTimer);
807
- }
808
- this._iceGraceReason = reason;
809
- this._iceGraceTimer = setTimeout(() => {
810
- this._iceGraceTimer = null;
811
- const r = this._iceGraceReason;
812
- this._iceGraceReason = null;
813
- const s = this._pc?.iceConnectionState;
814
- if (s === 'connected' || s === 'completed')
815
- return; // healed
816
- if (r === 'disconnected' && s === 'disconnected') {
817
- this._emit('error', {
818
- source: 'webrtc',
819
- error: new Error(`ICE stuck in 'disconnected' for > ${ms}ms`),
820
- });
821
- return;
822
- }
823
- if (r === 'failed' || s === 'failed') {
824
- const err = new Error('ICE connection failed');
825
- if (this._sessionReject) {
826
- this._sessionReject(err);
827
- this._sessionResolve = null;
828
- this._sessionReject = null;
829
- }
830
- this._emit('error', { source: 'webrtc', error: err });
831
- }
832
- }, ms);
833
- }
834
- /**
835
- * `disconnected` while the tab is hidden. JS timers are throttled
836
- * in background tabs (Chrome clamps to ~1 Hz, Safari can pause
837
- * altogether), so a foreground grace timer would either miss the
838
- * window or fire long after the connection healed. Wait for the
839
- * 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`).
840
758
  */
841
- _armIceGraceOnVisibility() {
842
- if (this._pendingVisibilityHandler)
843
- return;
844
- const deferredAt = Date.now();
845
- const handler = () => {
846
- if (typeof document !== 'undefined' && document.hidden)
847
- return;
848
- document.removeEventListener('visibilitychange', handler);
849
- this._pendingVisibilityHandler = null;
850
- if (!this._pc)
851
- return;
852
- const s = this._pc.iceConnectionState;
853
- if (s === 'connected' || s === 'completed')
854
- return; // healed in bg
855
- // Ceiling: if the user backgrounded past the daemon's
856
- // ICE-consent freshness window the session is gone from
857
- // the daemon's side regardless of what `_pc` reports
858
- // locally. Running another foreground grace would tell
859
- // the user "Reconnecting…" for a recovery that can never
860
- // happen. Escalate immediately so the host renders the
861
- // real "session expired" UX. See MAX_VISIBILITY_DEFER_MS.
862
- if (Date.now() - deferredAt > MAX_VISIBILITY_DEFER_MS) {
863
- const err = new Error('Session expired while tab was backgrounded');
864
- if (this._sessionReject) {
865
- this._sessionReject(err);
866
- this._sessionResolve = null;
867
- this._sessionReject = null;
868
- }
869
- this._emit('error', { source: 'webrtc', error: err });
870
- return;
871
- }
872
- if (s === 'failed') {
873
- this._scheduleIceGrace(ICE_FAILED_GRACE_MS, 'failed');
874
- return;
875
- }
876
- // Still disconnected when we came back — give it a normal
877
- // foreground grace window now that timers fire reliably.
878
- this._scheduleIceGrace(ICE_DISCONNECT_GRACE_MS, 'disconnected');
879
- };
880
- document.addEventListener('visibilitychange', handler);
881
- this._pendingVisibilityHandler = handler;
759
+ setAutoReconnect(enabled) {
760
+ this._supervisor.setAutoReconnect(enabled);
882
761
  }
883
762
  /**
884
- * Install browser-level network listeners and forward them as
885
- * public `networkOnline` / `networkOffline` / `networkChange`
886
- * events on this instance. Idempotent: called from
887
- * `startSession()`, removed by `_uninstallNetworkListeners` on
888
- * teardown. Reachable only when there's a live `window`
889
- * (defensive guard for SSR / test environments).
890
- *
891
- * `online` / `offline` are semantically about CONNECTIVITY:
892
- * "does the OS think we can reach the internet". They flip
893
- * symmetrically.
894
- *
895
- * `connection.change` (NetworkInformation API, Chrome / Android
896
- * WebView only) is semantically about the TRANSPORT: it fires
897
- * on Wi-Fi → 4G swaps, AP roams, etc. without necessarily going
898
- * through `offline`. We forward it as its own `networkChange`
899
- * event rather than aliasing it onto `networkOnline`, so
900
- * consumers don't have to guess whether they're seeing a real
901
- * 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).
902
772
  */
903
- _installNetworkListeners() {
904
- if (this._onlineHandler || typeof window === 'undefined')
905
- return;
906
- const onOnline = () => this._emit('networkOnline', {});
907
- const onOffline = () => this._emit('networkOffline', {});
908
- window.addEventListener('online', onOnline);
909
- window.addEventListener('offline', onOffline);
910
- this._onlineHandler = onOnline;
911
- this._offlineHandler = onOffline;
912
- const conn = navigator.connection;
913
- if (conn && typeof conn.addEventListener === 'function') {
914
- const onChange = () => this._emit('networkChange', {
915
- effectiveType: conn.effectiveType,
916
- downlink: conn.downlink,
917
- rtt: conn.rtt,
918
- saveData: conn.saveData,
919
- });
920
- conn.addEventListener('change', onChange);
921
- this._connectionChangeHandler = onChange;
922
- }
923
- }
924
- /** Counterpart to `_installNetworkListeners`. */
925
- _uninstallNetworkListeners() {
926
- if (typeof window !== 'undefined') {
927
- if (this._onlineHandler) {
928
- window.removeEventListener('online', this._onlineHandler);
929
- }
930
- if (this._offlineHandler) {
931
- window.removeEventListener('offline', this._offlineHandler);
932
- }
933
- }
934
- const conn = navigator.connection;
935
- if (conn && this._connectionChangeHandler && typeof conn.removeEventListener === 'function') {
936
- conn.removeEventListener('change', this._connectionChangeHandler);
937
- }
938
- this._onlineHandler = null;
939
- this._offlineHandler = null;
940
- 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';
941
784
  }
942
785
  // ─── Commands ────────────────────────────────────────────────────────
943
786
  /**
@@ -1051,6 +894,54 @@ export class ReachyMini extends EventTarget {
1051
894
  playSound(file) {
1052
895
  return this._sendCommand({ type: 'play_sound', file });
1053
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
+ }
1054
945
  clearIncomingAudio() {
1055
946
  return this._sendCommand({ type: 'clear_incoming_audio' });
1056
947
  }
@@ -1069,7 +960,7 @@ export class ReachyMini extends EventTarget {
1069
960
  return this._sendCommand({ type: 'set_head_tracking', enabled: false });
1070
961
  }
1071
962
  getTrackedFace() {
1072
- return this._slotRoundtrip(() => this._trackedFaceResolve, (next) => { this._trackedFaceResolve = next; }, { type: 'get_tracked_face' });
963
+ return this._slotRoundtrip('tracked_face', { type: 'get_tracked_face' });
1073
964
  }
1074
965
  /**
1075
966
  * Trigger a PyPI update of the daemon over the data channel. Remote
@@ -1107,30 +998,7 @@ export class ReachyMini extends EventTarget {
1107
998
  if (!this._sendCommand({ type: command })) {
1108
999
  return Promise.reject(new Error(`${command}: data channel not open`));
1109
1000
  }
1110
- return new Promise((resolve, reject) => {
1111
- const entry = {
1112
- resolve,
1113
- reject,
1114
- timer: setTimeout(() => {
1115
- const queue = this._pendingMotionCompletions[command];
1116
- const idx = queue.indexOf(entry);
1117
- if (idx !== -1)
1118
- queue.splice(idx, 1);
1119
- reject(new Error(`${command} timed out after ${timeoutMs}ms`));
1120
- }, timeoutMs),
1121
- };
1122
- this._pendingMotionCompletions[command].push(entry);
1123
- });
1124
- }
1125
- _rejectPendingMotionCompletions(error) {
1126
- for (const command of Object.keys(this._pendingMotionCompletions)) {
1127
- const queue = this._pendingMotionCompletions[command];
1128
- while (queue.length) {
1129
- const entry = queue.shift();
1130
- clearTimeout(entry.timer);
1131
- entry.reject(error);
1132
- }
1133
- }
1001
+ return this._pending.awaitMotion(command, timeoutMs);
1134
1002
  }
1135
1003
  isAwake() {
1136
1004
  const mode = this._robotState?.motor_mode;
@@ -1149,107 +1017,193 @@ export class ReachyMini extends EventTarget {
1149
1017
  this.requestState();
1150
1018
  });
1151
1019
  }
1152
- 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');
1153
1038
  return true;
1154
- 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
+ }
1155
1055
  return true;
1156
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
+ */
1157
1062
  getVersion() {
1158
- return new Promise((resolve, reject) => {
1159
- if (!this._dc || this._dc.readyState !== 'open') {
1160
- reject(new Error('Data channel not open'));
1161
- return;
1162
- }
1163
- if (this._versionResolve) {
1164
- this._versionResolve(null);
1165
- }
1166
- this._versionResolve = resolve;
1167
- this._sendCommand({ type: 'get_version' });
1168
- });
1063
+ return this._slotRoundtrip('version', { type: 'get_version' });
1169
1064
  }
1170
1065
  getHardwareId() {
1171
- return new Promise((resolve, reject) => {
1172
- if (!this._dc || this._dc.readyState !== 'open') {
1173
- reject(new Error('Data channel not open'));
1174
- return;
1175
- }
1176
- if (this._hardwareIdResolve) {
1177
- this._hardwareIdResolve(null);
1178
- }
1179
- this._hardwareIdResolve = resolve;
1180
- this._sendCommand({ type: 'get_hardware_id' });
1181
- });
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' });
1182
1075
  }
1183
1076
  getVolume() {
1184
- return this._slotRoundtrip(() => this._volumeResolve, (next) => { this._volumeResolve = next; }, { type: 'get_volume' });
1077
+ return this._slotRoundtrip('volume', { type: 'get_volume' });
1185
1078
  }
1186
1079
  setVolume(volume) {
1187
- 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) });
1188
1081
  }
1189
1082
  getMicrophoneVolume() {
1190
- return this._slotRoundtrip(() => this._micVolumeResolve, (next) => { this._micVolumeResolve = next; }, { type: 'get_microphone_volume' });
1083
+ return this._slotRoundtrip('mic_volume', { type: 'get_microphone_volume' });
1191
1084
  }
1192
1085
  setMicrophoneVolume(volume) {
1193
- 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' });
1194
1147
  }
1195
1148
  applyAudioConfig(config, { verify = true } = {}) {
1196
- 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);
1197
1151
  }
1198
1152
  readAudioParameter(name) {
1199
- 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 });
1200
1154
  }
1201
1155
  /**
1202
1156
  * Internal: send a command and await the matching daemon response in a
1203
- * named single-resolver slot. Used by the volume helpers and the
1204
- * XVF3800 audio-config helpers — every one of them has a strict
1205
- * request/response shape where a single in-flight call per slot is
1206
- * sufficient. If a previous request on the same slot is still
1207
- * pending when a new one comes in, the older promise is resolved to
1208
- * `null` so its caller doesn't hang forever.
1209
- *
1210
- * Slot access is passed in as getter/setter closures rather than a
1211
- * key into `this`: that keeps the helper fully generic-checked (T is
1212
- * inferred from the slot's resolver type at each call site) with no
1213
- * 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.
1214
1160
  */
1215
- _slotRoundtrip(getSlot, setSlot, command) {
1216
- return new Promise((resolve, reject) => {
1217
- if (!this._dc || this._dc.readyState !== 'open') {
1218
- reject(new Error('Data channel not open'));
1219
- return;
1220
- }
1221
- const prev = getSlot();
1222
- if (prev)
1223
- prev(null);
1224
- let timer;
1225
- // Single settle path shared by the daemon response, supersession
1226
- // by a newer call, and the fail-open timeout below. It clears the
1227
- // timer once and detaches itself from the slot only if it's still
1228
- // the current occupant, so a stale settle (timed-out or superseded)
1229
- // can't clear a newer call's slot registration. The message handler
1230
- // stores `settle` (not `resolve`), so every response route funnels
1231
- // through here.
1232
- // Note: slots are keyed by command type, not request id, so this
1233
- // does not prevent a genuinely late daemon reply from being routed
1234
- // to a newer same-command caller — that cross-talk is inherent to
1235
- // the single-flight slot design and unchanged here.
1236
- const settle = (v) => {
1237
- if (timer !== undefined) {
1238
- clearTimeout(timer);
1239
- timer = undefined;
1240
- }
1241
- if (getSlot() === settle)
1242
- setSlot(null);
1243
- resolve(v);
1244
- };
1245
- setSlot(settle);
1246
- timer = setTimeout(() => settle(null), SLOT_ROUNDTRIP_TIMEOUT_MS);
1247
- this._sendCommand(command);
1248
- });
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); });
1249
1166
  }
1250
1167
  sendRaw(data) {
1251
1168
  return this._sendCommand(data);
1252
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
+ }
1253
1207
  subscribeLogs({ onLine, onError }) {
1254
1208
  if (typeof onLine !== 'function') {
1255
1209
  throw new TypeError('subscribeLogs: onLine callback is required');
@@ -1273,6 +1227,30 @@ export class ReachyMini extends EventTarget {
1273
1227
  requestState() {
1274
1228
  return this._sendCommand({ type: 'get_state' });
1275
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
+ }
1276
1254
  // ─── Audio ───────────────────────────────────────────────────────────
1277
1255
  setAudioMuted(muted) {
1278
1256
  this._audioMuted = muted;
@@ -1428,7 +1406,7 @@ export class ReachyMini extends EventTarget {
1428
1406
  });
1429
1407
  }
1430
1408
  catch (e) {
1431
- console.warn('playMove.onStarted threw:', e);
1409
+ log.warn('playMove.onStarted threw:', e);
1432
1410
  }
1433
1411
  onProgress({ phase: 'playing', duration_s: startedAck.duration_s });
1434
1412
  const final = await this._waitForBroadcast((m) => m?.type === 'play_uploaded_move'
@@ -1513,20 +1491,8 @@ export class ReachyMini extends EventTarget {
1513
1491
  _emit(name, detail) {
1514
1492
  this.dispatchEvent(new CustomEvent(name, { detail }));
1515
1493
  }
1516
- _waitForBroadcast(predicate, { timeoutMs = 5000, debugLabel = '' } = {}) {
1517
- return new Promise((resolve, reject) => {
1518
- const slot = {
1519
- predicate,
1520
- resolve,
1521
- timer: setTimeout(() => {
1522
- const i = this._broadcastWaiters.indexOf(slot);
1523
- if (i !== -1)
1524
- this._broadcastWaiters.splice(i, 1);
1525
- reject(new Error(`broadcast timeout (${timeoutMs} ms): ${debugLabel}`));
1526
- }, timeoutMs),
1527
- };
1528
- this._broadcastWaiters.push(slot);
1529
- });
1494
+ _waitForBroadcast(predicate, opts = {}) {
1495
+ return this._pending.awaitBroadcast(predicate, opts);
1530
1496
  }
1531
1497
  async _awaitDataChannelDrain() {
1532
1498
  while (this._dc && this._dc.bufferedAmount > UPLOAD_BUFFERED_LOW_WATER) {
@@ -1554,13 +1520,13 @@ export class ReachyMini extends EventTarget {
1554
1520
  body = await res.text();
1555
1521
  }
1556
1522
  catch { /* ignore */ }
1557
- 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>'}`);
1558
1524
  return null;
1559
1525
  }
1560
1526
  return await res.json();
1561
1527
  }
1562
1528
  catch (e) {
1563
- console.error('Send error:', e);
1529
+ log.error('signaling /send failed:', e);
1564
1530
  return null;
1565
1531
  }
1566
1532
  }
@@ -1579,21 +1545,7 @@ export class ReachyMini extends EventTarget {
1579
1545
  */
1580
1546
  rpcCall(method, params = {}, opts = {}) {
1581
1547
  const timeoutMs = opts.timeoutMs ?? 20000;
1582
- const id = `rpc-${++this._rpcCounter}`;
1583
- if (!this._sendCommand({ jsonrpc: '2.0', id, method, params })) {
1584
- return Promise.reject(new Error(`rpcCall(${method}): data channel not open`));
1585
- }
1586
- return new Promise((resolve, reject) => {
1587
- const timer = setTimeout(() => {
1588
- this._pendingRpc.delete(id);
1589
- reject(new Error(`rpcCall(${method}) timed out after ${timeoutMs}ms`));
1590
- }, timeoutMs);
1591
- this._pendingRpc.set(id, {
1592
- resolve: (v) => resolve(v),
1593
- reject,
1594
- timer,
1595
- });
1596
- });
1548
+ return this._pending.rpcRoundtrip(method, timeoutMs, (id) => this._sendCommand({ jsonrpc: '2.0', id, method, params }));
1597
1549
  }
1598
1550
  /**
1599
1551
  * Subscribe to a JSON-RPC notification (one-way event) pushed by the
@@ -1610,32 +1562,10 @@ export class ReachyMini extends EventTarget {
1610
1562
  this._rpcListeners.get(method)?.delete(cb);
1611
1563
  };
1612
1564
  }
1613
- _rejectPendingRpc(err) {
1614
- for (const pending of this._pendingRpc.values()) {
1615
- clearTimeout(pending.timer);
1616
- pending.reject(err);
1617
- }
1618
- this._pendingRpc.clear();
1619
- }
1620
1565
  _handleRpcMessage(data) {
1621
1566
  // Response to an rpcCall (correlated by id)...
1622
- if ('id' in data && data.id != null && ('result' in data || 'error' in data)) {
1623
- const pending = this._pendingRpc.get(data.id);
1624
- if (!pending)
1625
- return;
1626
- this._pendingRpc.delete(data.id);
1627
- clearTimeout(pending.timer);
1628
- if ('error' in data && data.error) {
1629
- const err = data.error;
1630
- const e = new Error(err.message ?? 'rpc error');
1631
- e.reason = err.data?.reason;
1632
- pending.reject(e);
1633
- }
1634
- else {
1635
- pending.resolve(data.result);
1636
- }
1567
+ if (this._pending.settleRpcResponse(data))
1637
1568
  return;
1638
- }
1639
1569
  // ...or a one-way notification (event): dispatch to listeners.
1640
1570
  if (typeof data.method === 'string') {
1641
1571
  const params = data.params ?? {};
@@ -1644,7 +1574,7 @@ export class ReachyMini extends EventTarget {
1644
1574
  cb(params);
1645
1575
  }
1646
1576
  catch (e) {
1647
- console.error(`onNotification(${data.method}) threw:`, e);
1577
+ log.error(`onNotification(${data.method}) threw:`, e);
1648
1578
  }
1649
1579
  }
1650
1580
  }
@@ -1653,7 +1583,23 @@ export class ReachyMini extends EventTarget {
1653
1583
  if (this._iceConnected && this._dcOpen && this._sessionResolve) {
1654
1584
  this._state = 'streaming';
1655
1585
  this.requestState();
1656
- 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();
1657
1603
  this._emit('streaming', { sessionId: this._sessionId, robotId: this._selectedRobotId });
1658
1604
  this._sessionResolve();
1659
1605
  this._sessionResolve = null;
@@ -1704,10 +1650,14 @@ export class ReachyMini extends EventTarget {
1704
1650
  if (this._sessionReject) {
1705
1651
  const err = new Error(friendly || `Session ended before it could start: ${reason || 'unknown reason'}`);
1706
1652
  err.reason = reason ?? null;
1707
- 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
+ }
1708
1658
  // Resilience teardown alongside the PC close path.
1709
- this._clearIceGrace();
1710
- this._uninstallNetworkListeners();
1659
+ this._supervisor.clearIceGrace();
1660
+ this._supervisor.uninstallNetworkListeners();
1711
1661
  if (this._pc) {
1712
1662
  this._pc.close();
1713
1663
  this._pc = null;
@@ -1769,7 +1719,7 @@ export class ReachyMini extends EventTarget {
1769
1719
  await this._pc.addIceCandidate(new RTCIceCandidate(ice));
1770
1720
  }
1771
1721
  catch (err) {
1772
- console.warn('[reachy-mini] buffered ICE candidate rejected:', err);
1722
+ log.warn('buffered ICE candidate rejected:', err);
1773
1723
  }
1774
1724
  }
1775
1725
  }
@@ -1793,78 +1743,76 @@ export class ReachyMini extends EventTarget {
1793
1743
  }
1794
1744
  }
1795
1745
  catch (e) {
1796
- console.error('WebRTC error:', e);
1746
+ log.error('webrtc signaling failed:', e);
1797
1747
  this._emit('error', { source: 'webrtc', error: e });
1798
1748
  }
1799
1749
  }
1800
- _handleRobotMessage(data) {
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();
1801
1760
  // JSON-RPC frames (app control surface) are handled separately from
1802
1761
  // the legacy {command|type} robot messages that share this channel.
1803
1762
  if (data.jsonrpc === '2.0') {
1804
1763
  this._handleRpcMessage(data);
1805
1764
  return;
1806
1765
  }
1807
- if ('version' in data && this._versionResolve) {
1808
- this._versionResolve(data.version);
1809
- 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)) {
1810
1771
  return;
1811
1772
  }
1812
- if ('hardware_id' in data && this._hardwareIdResolve) {
1813
- this._hardwareIdResolve(data.hardware_id);
1814
- this._hardwareIdResolve = null;
1773
+ if ('hardware_id' in data
1774
+ && this._pending.settleReplySlot('hardware_id', data.hardware_id ?? null)) {
1815
1775
  return;
1816
1776
  }
1817
1777
  if (data.command === 'get_volume' || data.command === 'set_volume') {
1818
- if (this._volumeResolve) {
1819
- this._volumeResolve(data.status === 'error' ? null : data.volume);
1820
- this._volumeResolve = null;
1821
- }
1778
+ this._pending.settleReplySlot('volume', data.status === 'error' ? null : data.volume);
1822
1779
  return;
1823
1780
  }
1824
1781
  if (data.command === 'get_microphone_volume' || data.command === 'set_microphone_volume') {
1825
- if (this._micVolumeResolve) {
1826
- this._micVolumeResolve(data.status === 'error' ? null : data.volume);
1827
- this._micVolumeResolve = null;
1828
- }
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');
1829
1795
  return;
1830
1796
  }
1831
1797
  if (data.command === 'apply_audio_config') {
1832
- if (this._applyAudioConfigResolve) {
1833
- this._applyAudioConfigResolve(data.error ? false : !!data.applied);
1834
- this._applyAudioConfigResolve = null;
1835
- }
1798
+ this._pending.settleReplySlot('apply_audio_config', data.error ? false : !!data.applied);
1836
1799
  return;
1837
1800
  }
1838
1801
  if (data.command === 'read_audio_parameter') {
1839
- if (this._readAudioParameterResolve) {
1840
- this._readAudioParameterResolve(data.error ? null : (data.values ?? null));
1841
- this._readAudioParameterResolve = null;
1842
- }
1802
+ this._pending.settleReplySlot('read_audio_parameter', data.error ? null : (data.values ?? null));
1843
1803
  return;
1844
1804
  }
1845
1805
  if (data.command === 'get_tracked_face') {
1846
- if (this._trackedFaceResolve) {
1847
- this._trackedFaceResolve(data.face_target ?? null);
1848
- this._trackedFaceResolve = null;
1849
- }
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);
1850
1811
  return;
1851
1812
  }
1852
1813
  if ((data.command === 'wake_up' || data.command === 'goto_sleep')
1853
- && this._pendingMotionCompletions
1854
- && this._pendingMotionCompletions[data.command]) {
1855
- const queue = this._pendingMotionCompletions[data.command];
1856
- if (data.completed === true && queue.length > 0) {
1857
- const entry = queue.shift();
1858
- clearTimeout(entry.timer);
1859
- entry.resolve();
1860
- return;
1861
- }
1862
- if (data.error && queue.length > 0) {
1863
- const entry = queue.shift();
1864
- clearTimeout(entry.timer);
1865
- entry.reject(new Error(`${data.command}: ${data.error}`));
1866
- return;
1867
- }
1814
+ && this._pending.settleMotion(data.command, data)) {
1815
+ return;
1868
1816
  }
1869
1817
  if (data.type === 'log_line') {
1870
1818
  for (const sub of this._logSubscribers) {
@@ -1872,7 +1820,7 @@ export class ReachyMini extends EventTarget {
1872
1820
  sub.onLine({ timestamp: data.timestamp, line: data.line });
1873
1821
  }
1874
1822
  catch (e) {
1875
- console.error('subscribeLogs onLine threw:', e);
1823
+ log.error('subscribeLogs onLine threw:', e);
1876
1824
  }
1877
1825
  }
1878
1826
  return;
@@ -1884,7 +1832,7 @@ export class ReachyMini extends EventTarget {
1884
1832
  sub.onError(data.error);
1885
1833
  }
1886
1834
  catch (e) {
1887
- console.error('subscribeLogs onError threw:', e);
1835
+ log.error('subscribeLogs onError threw:', e);
1888
1836
  }
1889
1837
  }
1890
1838
  }
@@ -1902,7 +1850,7 @@ export class ReachyMini extends EventTarget {
1902
1850
  cb(event);
1903
1851
  }
1904
1852
  catch (e) {
1905
- console.error('startDaemonUpdate onProgress threw:', e);
1853
+ log.error('startDaemonUpdate onProgress threw:', e);
1906
1854
  }
1907
1855
  }
1908
1856
  }
@@ -1919,17 +1867,30 @@ export class ReachyMini extends EventTarget {
1919
1867
  cb(event);
1920
1868
  }
1921
1869
  catch (e) {
1922
- console.error('startDaemonUpdate onProgress threw:', e);
1870
+ log.error('startDaemonUpdate onProgress threw:', e);
1923
1871
  }
1924
1872
  }
1925
1873
  return;
1926
1874
  }
1927
- 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)) {
1928
1887
  const s = data.state;
1929
1888
  if (s.head_pose)
1930
1889
  this._robotState.head = s.head_pose.flat();
1931
1890
  if (s.antennas)
1932
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;
1933
1894
  if (typeof s.body_yaw === 'number')
1934
1895
  this._robotState.body_yaw = s.body_yaw;
1935
1896
  if (s.motor_mode)
@@ -1938,22 +1899,17 @@ export class ReachyMini extends EventTarget {
1938
1899
  this._robotState.is_move_running = s.is_move_running;
1939
1900
  if (s.face_target)
1940
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;
1941
1906
  this._emit('state', { ...this._robotState });
1942
1907
  }
1943
1908
  if (data.error) {
1944
1909
  this._emit('error', { source: 'robot', error: data.error });
1945
1910
  }
1946
- if (this._broadcastWaiters.length > 0) {
1947
- for (let i = this._broadcastWaiters.length - 1; i >= 0; i--) {
1948
- const slot = this._broadcastWaiters[i];
1949
- if (slot.predicate(data)) {
1950
- this._broadcastWaiters.splice(i, 1);
1951
- clearTimeout(slot.timer);
1952
- slot.resolve(data);
1953
- return;
1954
- }
1955
- }
1956
- }
1911
+ if (this._pending.matchBroadcast(data))
1912
+ return;
1957
1913
  }
1958
1914
  /** Snap video playback to live edge if buffered lag exceeds 0.5 s. */
1959
1915
  _startLatencyMonitor(video) {
@@ -1967,7 +1923,7 @@ export class ReachyMini extends EventTarget {
1967
1923
  const end = buf.end(buf.length - 1);
1968
1924
  const lag = end - video.currentTime;
1969
1925
  if (lag > 0.5) {
1970
- console.log(`Latency correction: was ${lag.toFixed(2)}s behind`);
1926
+ log.debug(`video latency correction: was ${lag.toFixed(2)}s behind`);
1971
1927
  video.currentTime = end - 0.1;
1972
1928
  }
1973
1929
  }