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