@pollen-robotics/reachy-mini-sdk 1.10.0-dev.0.main.b50d97e → 1.10.0-dev.0.main.c8367cd
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +202 -88
- package/dist/lib/reachy-mini.d.ts.map +1 -1
- package/dist/lib/reachy-mini.js +651 -566
- 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 +280 -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-DjE5zE9R.js → mountHost-BTTF4Uu9.js} +24933 -27488
- package/host/dist/components/ConnectingView.d.ts.map +1 -1
- 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/LeavingView.d.ts.map +1 -1
- 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/components/WelcomeBackOverlay.d.ts.map +1 -1
- package/host/dist/embed/index.d.ts +68 -5
- package/host/dist/embed/index.d.ts.map +1 -1
- package/host/dist/entry/auto.js +2 -2
- package/host/dist/entry/embed.d.ts +5 -4
- package/host/dist/entry/embed.d.ts.map +1 -1
- package/host/dist/entry/embed.js +5 -4
- 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.d.ts +2 -2
- package/host/dist/index.d.ts.map +1 -1
- package/host/dist/index.js +11 -10
- 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 +11 -4
- package/host/dist/chunks/index-lMs2sCXG.js +0 -439
- package/host/dist/chunks/reachy-mini-CdsdQLIU.js +0 -1523
- 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,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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `
|
|
17
|
-
*
|
|
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
|
|
33
|
+
const POSE_STREAM_FRESH_MS = 750;
|
|
20
34
|
/**
|
|
21
|
-
*
|
|
22
|
-
* The
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
28
|
-
/**
|
|
29
|
-
* Ceiling on how long we'll keep `_armIceGraceOnVisibility` waiting
|
|
30
|
-
* for the tab to come back. The daemon's `webrtcsink` runs a STUN
|
|
31
|
-
* consent-freshness check (RFC 7675, ~30 s default) and unilaterally
|
|
32
|
-
* tears its side of the session down past that window, releasing the
|
|
33
|
-
* producer slot on central. If the user backgrounded the tab for
|
|
34
|
-
* longer than this, running another 3 s foreground grace is a lie —
|
|
35
|
-
* the underlying transport is gone, nothing can recover. Give up
|
|
36
|
-
* straight away so the host shows the real "session expired" UX
|
|
37
|
-
* instead of a fake "Reconnecting…" badge that's never going to
|
|
38
|
-
* heal. 60 s gives a 2× margin over the daemon-side timeout — long
|
|
39
|
-
* enough to absorb a "phone in pocket for 45 s" case, short enough
|
|
40
|
-
* to be honest with the user.
|
|
41
|
-
*/
|
|
42
|
-
const MAX_VISIBILITY_DEFER_MS = 60_000;
|
|
42
|
+
const WAKE_TRAJECTORY_BUDGET_MS = 5000;
|
|
43
43
|
export class ReachyMini extends EventTarget {
|
|
44
44
|
// ─── Config ──────────────────────────────────────────────────────────
|
|
45
45
|
_signalingUrl;
|
|
@@ -52,6 +52,19 @@ export class ReachyMini extends EventTarget {
|
|
|
52
52
|
_state = 'disconnected';
|
|
53
53
|
_robots = [];
|
|
54
54
|
_robotState = {};
|
|
55
|
+
// Highest `seq` seen on the unordered `pose` channel. Frames that arrive
|
|
56
|
+
// out of order (older seq) are dropped so a late packet can't rewind the
|
|
57
|
+
// live mirror. `null` until the first pose frame.
|
|
58
|
+
_lastPoseSeq = null;
|
|
59
|
+
// When the last pushed pose frame was applied, used to park the periodic
|
|
60
|
+
// `get_state` poll while the stream is live (see POSE_STREAM_FRESH_MS).
|
|
61
|
+
_lastPoseFrameAt = 0;
|
|
62
|
+
// Local refcount of pose-stream consumers (the 3D mirror, the wizard's
|
|
63
|
+
// move-end watcher, ...). The daemon's subscription is a per-peer boolean
|
|
64
|
+
// (not refcounted), so we only send `unsubscribe_pose` once the LAST local
|
|
65
|
+
// consumer releases - otherwise one consumer's cleanup would kill the
|
|
66
|
+
// stream for the others.
|
|
67
|
+
_poseSubRefs = 0;
|
|
55
68
|
_preselectedRobotId;
|
|
56
69
|
// ─── Auth ────────────────────────────────────────────────────────────
|
|
57
70
|
_token = null;
|
|
@@ -73,22 +86,17 @@ export class ReachyMini extends EventTarget {
|
|
|
73
86
|
// ─── Timers ──────────────────────────────────────────────────────────
|
|
74
87
|
_latencyMonitorId = null;
|
|
75
88
|
_stateRefreshInterval = null;
|
|
76
|
-
// ───
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
_micVolumeResolve = null;
|
|
81
|
-
_trackedFaceResolve = null;
|
|
82
|
-
// applyAudioConfig() / readAudioParameter() share the same single-slot
|
|
83
|
-
// pattern as the volume helpers. Separate slots so the two can be
|
|
84
|
-
// in-flight concurrently without collision.
|
|
85
|
-
_applyAudioConfigResolve = null;
|
|
86
|
-
_readAudioParameterResolve = null;
|
|
89
|
+
// ─── Pending replies (reply slots / JSON-RPC / motion / broadcast) ───
|
|
90
|
+
// All request/response waiters on the data channel live in one
|
|
91
|
+
// ledger so every teardown path settles them in a single call.
|
|
92
|
+
_pending = new PendingReplies();
|
|
87
93
|
// ─── Log subscribers ─────────────────────────────────────────────────
|
|
88
94
|
_logSubscribers = new Set();
|
|
89
95
|
_updateProgressSubscribers = new Set();
|
|
90
|
-
// ───
|
|
91
|
-
|
|
96
|
+
// ─── JSON-RPC notification listeners (one-way events, no id) ─────────
|
|
97
|
+
// onNotification() subscribes to events the robot/app pushes
|
|
98
|
+
// (conversation.phase/turn/transcript, ...).
|
|
99
|
+
_rpcListeners = new Map();
|
|
92
100
|
// ─── Active upload ids for no-arg cancels ────────────────────────────
|
|
93
101
|
_activeMoveUploadId = null;
|
|
94
102
|
_activeAudioUploadId = null;
|
|
@@ -97,24 +105,12 @@ export class ReachyMini extends EventTarget {
|
|
|
97
105
|
_sessionReject = null;
|
|
98
106
|
_iceConnected = false;
|
|
99
107
|
_dcOpen = false;
|
|
100
|
-
// ─── Resilience
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
// `_failSessionRejected`).
|
|
107
|
-
_iceGraceTimer = null;
|
|
108
|
-
_iceGraceReason = null;
|
|
109
|
-
_pendingVisibilityHandler = null;
|
|
110
|
-
_onlineHandler = null;
|
|
111
|
-
_offlineHandler = null;
|
|
112
|
-
_connectionChangeHandler = null;
|
|
113
|
-
// ─── Motion completion plumbing (wake_up / goto_sleep) ───────────────
|
|
114
|
-
_pendingMotionCompletions = {
|
|
115
|
-
wake_up: [],
|
|
116
|
-
goto_sleep: [],
|
|
117
|
-
};
|
|
108
|
+
// ─── Resilience ──────────────────────────────────────────────────────
|
|
109
|
+
// ICE-blip debounce, network awareness, automatic session re-dial and
|
|
110
|
+
// the data-channel silence watchdog all live in the supervisor; the
|
|
111
|
+
// class forwards pc/browser events to it and implements its deps as
|
|
112
|
+
// closures over the private state below (see the constructor).
|
|
113
|
+
_supervisor;
|
|
118
114
|
// ─── Video element ───────────────────────────────────────────────────
|
|
119
115
|
_videoElement = null;
|
|
120
116
|
constructor(options = {}) {
|
|
@@ -130,6 +126,31 @@ export class ReachyMini extends EventTarget {
|
|
|
130
126
|
this._autoStartFromUrl = options.autoStartFromUrl === true;
|
|
131
127
|
this._autoStartAttempted = false;
|
|
132
128
|
this._preselectedRobotId = readPreselectedRobotIdFromUrl();
|
|
129
|
+
// Arrow closures so every dep call reads the CURRENT class state.
|
|
130
|
+
this._supervisor = new SessionSupervisor({
|
|
131
|
+
iceState: () => this._pc?.iceConnectionState ?? null,
|
|
132
|
+
hasPc: () => !!this._pc,
|
|
133
|
+
isStreaming: () => this._state === 'streaming',
|
|
134
|
+
isMidSetup: () => !!(this._sessionResolve || this._sessionReject),
|
|
135
|
+
isSignalingDown: () => this._state === 'disconnected',
|
|
136
|
+
selectedRobotId: () => this._selectedRobotId,
|
|
137
|
+
rejectPendingSession: (err) => {
|
|
138
|
+
if (!this._sessionReject)
|
|
139
|
+
return false;
|
|
140
|
+
const reject = this._sessionReject;
|
|
141
|
+
this._sessionResolve = null;
|
|
142
|
+
this._sessionReject = null;
|
|
143
|
+
reject(err);
|
|
144
|
+
return true;
|
|
145
|
+
},
|
|
146
|
+
reconnectSignaling: () => this.connect(),
|
|
147
|
+
// The private dial skips the public startSession()'s
|
|
148
|
+
// cancelRedial, so the loop's own attempts don't cancel it.
|
|
149
|
+
dial: (robotId) => this._startSessionInternal(robotId),
|
|
150
|
+
teardownForRedial: () => this._teardownForRedial(),
|
|
151
|
+
nudgeState: () => { this.requestState(); },
|
|
152
|
+
emit: (name, detail) => this._emit(name, detail),
|
|
153
|
+
}, { autoReconnect: options.autoReconnect !== false });
|
|
133
154
|
}
|
|
134
155
|
// ─── Read-only properties ────────────────────────────────────────────
|
|
135
156
|
get state() { return this._state; }
|
|
@@ -142,6 +163,24 @@ export class ReachyMini extends EventTarget {
|
|
|
142
163
|
get audioMuted() { return this._audioMuted; }
|
|
143
164
|
get preselectedRobotId() { return this._preselectedRobotId; }
|
|
144
165
|
get isEmbedded() { return this._preselectedRobotId !== null; }
|
|
166
|
+
/**
|
|
167
|
+
* Live RTCPeerConnection, or `null` between sessions. Read-only escape
|
|
168
|
+
* hatch for stats sampling (`getStats()`); mutating it is unsupported.
|
|
169
|
+
* Auto-reconnect re-dials REPLACE this object, so re-read it on every
|
|
170
|
+
* use — never capture it across ticks.
|
|
171
|
+
*/
|
|
172
|
+
get peerConnection() { return this._pc; }
|
|
173
|
+
/**
|
|
174
|
+
* Build version of this SDK (npm package `version`), injected from
|
|
175
|
+
* package.json at build time. This is the JS SDK's OWN version and is
|
|
176
|
+
* distinct from `getVersion()`, which asks the DAEMON its version over
|
|
177
|
+
* the data channel. `0.0.0-managed-by-ci` means an unreleased/branch
|
|
178
|
+
* build (npm releases carry a real semver).
|
|
179
|
+
*/
|
|
180
|
+
get sdkVersion() { return SDK_VERSION; }
|
|
181
|
+
/** Same value as the instance `sdkVersion`, reachable without an
|
|
182
|
+
* instance: `ReachyMini.version`. */
|
|
183
|
+
static get version() { return SDK_VERSION; }
|
|
145
184
|
/**
|
|
146
185
|
* Internal: try to honour the `autoStartFromUrl` constructor
|
|
147
186
|
* option. Called from the signaling-message handler after every
|
|
@@ -166,7 +205,7 @@ export class ReachyMini extends EventTarget {
|
|
|
166
205
|
if (this._state !== 'connected')
|
|
167
206
|
return;
|
|
168
207
|
this.startSession(peerId).catch((err) => {
|
|
169
|
-
|
|
208
|
+
log.warn('autoStartFromUrl: startSession rejected:', err);
|
|
170
209
|
});
|
|
171
210
|
}, 0);
|
|
172
211
|
}
|
|
@@ -174,44 +213,63 @@ export class ReachyMini extends EventTarget {
|
|
|
174
213
|
async authenticate() {
|
|
175
214
|
try {
|
|
176
215
|
consumeFragmentCredentials();
|
|
216
|
+
// A failed silent login (`login({ prompt: 'none' })`) returns as
|
|
217
|
+
// `?error=login_required` / `consent_required` query params.
|
|
218
|
+
// Strip them so they don't linger in the URL; the fall-through
|
|
219
|
+
// to the cached-token check below then reports "not signed in".
|
|
220
|
+
const silentError = consumeOAuthErrorParams();
|
|
221
|
+
if (silentError) {
|
|
222
|
+
log.info('silent sign-in declined:', silentError);
|
|
223
|
+
}
|
|
177
224
|
const result = (await oauthHandleRedirectIfPresent());
|
|
178
225
|
if (result) {
|
|
179
226
|
this._username = result.userInfo.preferred_username || result.userInfo.name || null;
|
|
180
227
|
this._token = result.accessToken;
|
|
181
228
|
this._tokenExpires = result.accessTokenExpiresAt;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
229
|
+
writeStoredToken({
|
|
230
|
+
token: this._token,
|
|
231
|
+
username: this._username ?? '',
|
|
232
|
+
expires: typeof this._tokenExpires === 'string'
|
|
233
|
+
? this._tokenExpires
|
|
234
|
+
: this._tokenExpires.toISOString(),
|
|
235
|
+
});
|
|
187
236
|
return true;
|
|
188
237
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
this._token =
|
|
194
|
-
this._username =
|
|
195
|
-
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;
|
|
196
245
|
return true;
|
|
197
246
|
}
|
|
198
247
|
return false;
|
|
199
248
|
}
|
|
200
249
|
catch (e) {
|
|
201
|
-
|
|
250
|
+
log.error('authenticate failed:', e);
|
|
202
251
|
return false;
|
|
203
252
|
}
|
|
204
253
|
}
|
|
205
|
-
async login() {
|
|
254
|
+
async login(options) {
|
|
206
255
|
const opts = {};
|
|
207
256
|
if (this._clientId)
|
|
208
257
|
opts.clientId = this._clientId;
|
|
209
|
-
|
|
258
|
+
let url = await oauthLoginUrl(opts);
|
|
259
|
+
// OIDC prompt param. `oauthLoginUrl` doesn't expose it, but the HF
|
|
260
|
+
// authorize endpoint honours it (verified empirically): with
|
|
261
|
+
// `prompt=none` an already-authorized user comes straight back with
|
|
262
|
+
// a code and no screen, anyone else comes back with `?error=...`
|
|
263
|
+
// instead of landing on the HF login page.
|
|
264
|
+
if (options?.prompt) {
|
|
265
|
+
const u = new URL(url);
|
|
266
|
+
u.searchParams.set('prompt', options.prompt);
|
|
267
|
+
url = u.toString();
|
|
268
|
+
}
|
|
269
|
+
window.location.href = url;
|
|
210
270
|
}
|
|
211
271
|
logout() {
|
|
212
|
-
|
|
213
|
-
sessionStorage.removeItem('hf_username');
|
|
214
|
-
sessionStorage.removeItem('hf_token_expires');
|
|
272
|
+
clearStoredToken();
|
|
215
273
|
this._username = null;
|
|
216
274
|
this._tokenExpires = null;
|
|
217
275
|
this.disconnect();
|
|
@@ -358,7 +416,7 @@ export class ReachyMini extends EventTarget {
|
|
|
358
416
|
await this.ensureAwake();
|
|
359
417
|
}
|
|
360
418
|
catch (e) {
|
|
361
|
-
|
|
419
|
+
log.warn('autoConnect: ensureAwake failed:', e);
|
|
362
420
|
}
|
|
363
421
|
}
|
|
364
422
|
return { robotId, robotName, isEmbedded: this.isEmbedded };
|
|
@@ -392,7 +450,7 @@ export class ReachyMini extends EventTarget {
|
|
|
392
450
|
return Array.from(seen.values()).sort((a, b) => (a.lastSeenAgeSeconds ?? Infinity) - (b.lastSeenAgeSeconds ?? Infinity));
|
|
393
451
|
}
|
|
394
452
|
catch (e) {
|
|
395
|
-
|
|
453
|
+
log.warn('/api/robot-status unavailable, using SSE list:', e);
|
|
396
454
|
return (this._robots || []).map((r) => ({
|
|
397
455
|
id: r.id,
|
|
398
456
|
name: r.meta?.name ?? null,
|
|
@@ -422,6 +480,14 @@ export class ReachyMini extends EventTarget {
|
|
|
422
480
|
});
|
|
423
481
|
}
|
|
424
482
|
async startSession(robotId) {
|
|
483
|
+
// An explicit dial from the app supersedes any in-flight
|
|
484
|
+
// auto-reconnect (possibly towards a different robot). The
|
|
485
|
+
// redial loop dials through `_startSessionInternal` directly,
|
|
486
|
+
// so its own attempts never trip this.
|
|
487
|
+
this._supervisor.cancelRedial();
|
|
488
|
+
return this._startSessionInternal(robotId);
|
|
489
|
+
}
|
|
490
|
+
async _startSessionInternal(robotId) {
|
|
425
491
|
if (this._state !== 'connected')
|
|
426
492
|
throw new Error('Not connected');
|
|
427
493
|
this._selectedRobotId = robotId;
|
|
@@ -460,7 +526,7 @@ export class ReachyMini extends EventTarget {
|
|
|
460
526
|
this._micMuted = true;
|
|
461
527
|
}
|
|
462
528
|
catch (e) {
|
|
463
|
-
|
|
529
|
+
log.warn('audio sender placeholder setup failed:', e);
|
|
464
530
|
this._micStream = null;
|
|
465
531
|
}
|
|
466
532
|
this._pc = new RTCPeerConnection({
|
|
@@ -468,7 +534,7 @@ export class ReachyMini extends EventTarget {
|
|
|
468
534
|
});
|
|
469
535
|
// Scope `networkOnline` / `networkOffline` / `networkChange`
|
|
470
536
|
// event forwarding to the lifetime of this session.
|
|
471
|
-
this.
|
|
537
|
+
this._supervisor.installNetworkListeners();
|
|
472
538
|
return new Promise((resolve, reject) => {
|
|
473
539
|
this._sessionResolve = resolve;
|
|
474
540
|
this._sessionReject = reject;
|
|
@@ -512,35 +578,50 @@ export class ReachyMini extends EventTarget {
|
|
|
512
578
|
this._emit('iceStateChange', { state: s });
|
|
513
579
|
if (s === 'connected' || s === 'completed') {
|
|
514
580
|
// Healed — cancel any pending grace from a previous blip.
|
|
515
|
-
this.
|
|
581
|
+
this._supervisor.onIceHealed();
|
|
516
582
|
this._iceConnected = true;
|
|
517
583
|
this._checkSessionReady();
|
|
518
584
|
return;
|
|
519
585
|
}
|
|
520
586
|
if (s === 'disconnected') {
|
|
521
|
-
|
|
522
|
-
// If the tab is hidden, JS timers are throttled and
|
|
523
|
-
// would fire unpredictably late, so defer the grace
|
|
524
|
-
// window to the next foreground frame.
|
|
525
|
-
if (typeof document !== 'undefined' && document.hidden) {
|
|
526
|
-
this._armIceGraceOnVisibility();
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
this._scheduleIceGrace(ICE_DISCONNECT_GRACE_MS, 'disconnected');
|
|
530
|
-
}
|
|
587
|
+
this._supervisor.onIceDisconnected();
|
|
531
588
|
return;
|
|
532
589
|
}
|
|
533
590
|
if (s === 'failed') {
|
|
534
|
-
|
|
535
|
-
// `failed → connected` on rapid AP roams / BT route
|
|
536
|
-
// changes on iOS. Give the ICE agent a short window
|
|
537
|
-
// to surprise us before rejecting the session.
|
|
538
|
-
this._scheduleIceGrace(ICE_FAILED_GRACE_MS, 'failed');
|
|
591
|
+
this._supervisor.onIceFailed();
|
|
539
592
|
return;
|
|
540
593
|
}
|
|
541
594
|
};
|
|
542
595
|
this._pc.ondatachannel = (e) => {
|
|
543
|
-
|
|
596
|
+
const ch = e.channel;
|
|
597
|
+
// On `subscribe_pose` the daemon opens a second,
|
|
598
|
+
// unreliable/unordered channel labelled "pose" that *pushes*
|
|
599
|
+
// the robot state at ~30 Hz (see media_server
|
|
600
|
+
// `_setup_pose_channel`), so this can fire mid-session. It
|
|
601
|
+
// carries the same `{state:{...}}` envelope as a get_state
|
|
602
|
+
// reply, so we route it through the same handler - but it
|
|
603
|
+
// must NOT gate session readiness (that's the reliable
|
|
604
|
+
// control channel's job) nor become `_dc` (commands must
|
|
605
|
+
// never ride the lossy channel).
|
|
606
|
+
if (ch.label === 'pose') {
|
|
607
|
+
// Fresh channel (new session or daemon restart): the
|
|
608
|
+
// daemon's seq counter may have reset, so forget the old
|
|
609
|
+
// high-water mark or we'd drop every new frame.
|
|
610
|
+
this._lastPoseSeq = null;
|
|
611
|
+
ch.onmessage = (ev) => {
|
|
612
|
+
const msg = JSON.parse(ev.data);
|
|
613
|
+
// Drop stale/reordered frames (unordered channel).
|
|
614
|
+
if (typeof msg.seq === 'number') {
|
|
615
|
+
if (this._lastPoseSeq !== null && msg.seq <= this._lastPoseSeq)
|
|
616
|
+
return;
|
|
617
|
+
this._lastPoseSeq = msg.seq;
|
|
618
|
+
}
|
|
619
|
+
this._lastPoseFrameAt = Date.now();
|
|
620
|
+
this._handleRobotMessage(msg, true);
|
|
621
|
+
};
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
this._dc = ch;
|
|
544
625
|
this._dc.onopen = () => {
|
|
545
626
|
this._dcOpen = true;
|
|
546
627
|
this._checkSessionReady();
|
|
@@ -557,77 +638,31 @@ export class ReachyMini extends EventTarget {
|
|
|
557
638
|
});
|
|
558
639
|
});
|
|
559
640
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
574
|
-
if (this._micStream) {
|
|
575
|
-
this._micStream.getTracks().forEach((t) => t.stop());
|
|
576
|
-
this._micStream = null;
|
|
577
|
-
}
|
|
578
|
-
this._iceConnected = false;
|
|
579
|
-
this._dcOpen = false;
|
|
580
|
-
this._micMuted = true;
|
|
581
|
-
this._micSupported = false;
|
|
582
|
-
this._emit('sessionRejected', { reason: msg.reason, activeApp: msg.activeApp });
|
|
641
|
+
/**
|
|
642
|
+
* Common transport teardown shared by every session-ending path
|
|
643
|
+
* (stopSession, disconnect, the auto-redial, a session rejection):
|
|
644
|
+
* settle the pending-reply ledger and the startSession() resolvers
|
|
645
|
+
* with `settleErr`, stand the resilience plumbing down, stop the
|
|
646
|
+
* session-scoped timers, release the mic placeholder and close the
|
|
647
|
+
* pc/dc. Returns the session id (already cleared on the instance)
|
|
648
|
+
* so each caller decides whether and how to send `endSession`.
|
|
649
|
+
* What else differs — events, subscriber wipes, where `_state`
|
|
650
|
+
* lands — stays with the callers.
|
|
651
|
+
*/
|
|
652
|
+
_closeTransport(settleErr) {
|
|
653
|
+
this._pending.settleAll(settleErr);
|
|
583
654
|
if (this._sessionReject) {
|
|
584
655
|
const reject = this._sessionReject;
|
|
585
656
|
this._sessionResolve = null;
|
|
586
657
|
this._sessionReject = null;
|
|
587
|
-
reject(
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
async stopSession() {
|
|
591
|
-
if (this._versionResolve) {
|
|
592
|
-
this._versionResolve(null);
|
|
593
|
-
this._versionResolve = null;
|
|
594
|
-
}
|
|
595
|
-
if (this._hardwareIdResolve) {
|
|
596
|
-
this._hardwareIdResolve(null);
|
|
597
|
-
this._hardwareIdResolve = null;
|
|
598
|
-
}
|
|
599
|
-
if (this._volumeResolve) {
|
|
600
|
-
this._volumeResolve(null);
|
|
601
|
-
this._volumeResolve = null;
|
|
602
|
-
}
|
|
603
|
-
if (this._micVolumeResolve) {
|
|
604
|
-
this._micVolumeResolve(null);
|
|
605
|
-
this._micVolumeResolve = null;
|
|
606
|
-
}
|
|
607
|
-
if (this._trackedFaceResolve) {
|
|
608
|
-
this._trackedFaceResolve(null);
|
|
609
|
-
this._trackedFaceResolve = null;
|
|
610
|
-
}
|
|
611
|
-
if (this._applyAudioConfigResolve) {
|
|
612
|
-
this._applyAudioConfigResolve(false);
|
|
613
|
-
this._applyAudioConfigResolve = null;
|
|
614
|
-
}
|
|
615
|
-
if (this._readAudioParameterResolve) {
|
|
616
|
-
this._readAudioParameterResolve(null);
|
|
617
|
-
this._readAudioParameterResolve = null;
|
|
618
|
-
}
|
|
619
|
-
this._logSubscribers.clear();
|
|
620
|
-
this._updateProgressSubscribers.clear();
|
|
621
|
-
this._rejectPendingMotionCompletions(new Error('Session stopped'));
|
|
622
|
-
// Tear down resilience plumbing BEFORE closing `_pc` so a
|
|
623
|
-
// queued grace callback can't dereference a dead handle.
|
|
624
|
-
this._clearIceGrace();
|
|
625
|
-
this._uninstallNetworkListeners();
|
|
626
|
-
if (this._sessionReject) {
|
|
627
|
-
this._sessionReject(new Error('Session stopped'));
|
|
628
|
-
this._sessionResolve = null;
|
|
629
|
-
this._sessionReject = null;
|
|
658
|
+
reject(settleErr);
|
|
630
659
|
}
|
|
660
|
+
this._sessionResolve = null;
|
|
661
|
+
// Resilience teardown BEFORE closing `_pc` so a queued grace
|
|
662
|
+
// callback can't dereference a dead handle.
|
|
663
|
+
this._supervisor.clearIceGrace();
|
|
664
|
+
this._supervisor.uninstallNetworkListeners();
|
|
665
|
+
this._supervisor.stopDcWatchdog();
|
|
631
666
|
if (this._stateRefreshInterval) {
|
|
632
667
|
clearInterval(this._stateRefreshInterval);
|
|
633
668
|
this._stateRefreshInterval = null;
|
|
@@ -636,9 +671,6 @@ export class ReachyMini extends EventTarget {
|
|
|
636
671
|
clearInterval(this._latencyMonitorId);
|
|
637
672
|
this._latencyMonitorId = null;
|
|
638
673
|
}
|
|
639
|
-
if (this._sessionId) {
|
|
640
|
-
await this._sendToServer({ type: 'endSession', sessionId: this._sessionId });
|
|
641
|
-
}
|
|
642
674
|
if (this._micStream) {
|
|
643
675
|
this._micStream.getTracks().forEach((t) => t.stop());
|
|
644
676
|
this._micStream = null;
|
|
@@ -653,9 +685,38 @@ export class ReachyMini extends EventTarget {
|
|
|
653
685
|
this._dc.close();
|
|
654
686
|
this._dc = null;
|
|
655
687
|
}
|
|
656
|
-
this._sessionId = null;
|
|
657
688
|
this._iceConnected = false;
|
|
658
689
|
this._dcOpen = false;
|
|
690
|
+
const sessionId = this._sessionId;
|
|
691
|
+
this._sessionId = null;
|
|
692
|
+
return sessionId;
|
|
693
|
+
}
|
|
694
|
+
_failSessionRejected(msg) {
|
|
695
|
+
const err = new Error(msg.reason === 'robot_busy'
|
|
696
|
+
? `Robot is busy: "${msg.activeApp || 'another app'}" is already connected`
|
|
697
|
+
: `Session rejected: ${msg.reason || 'unknown reason'}`);
|
|
698
|
+
err.reason = msg.reason ?? null;
|
|
699
|
+
err.activeApp = msg.activeApp ?? null;
|
|
700
|
+
// No endSession: the robot side never granted this session.
|
|
701
|
+
this._closeTransport(err);
|
|
702
|
+
// During an auto-reconnect the rejection is expected noise (the
|
|
703
|
+
// robot side may still hold the dead session for a few seconds) —
|
|
704
|
+
// the loop retries, the app only sees `sessionReconnecting`.
|
|
705
|
+
if (!this._supervisor.redialing) {
|
|
706
|
+
this._emit('sessionRejected', { reason: msg.reason, activeApp: msg.activeApp });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async stopSession() {
|
|
710
|
+
// A deliberate stop always wins over a pending auto-reconnect:
|
|
711
|
+
// the in-flight dial attempt (if any) is rejected inside
|
|
712
|
+
// `_closeTransport`, and the loop exits on the cleared flag.
|
|
713
|
+
this._supervisor.cancelRedial();
|
|
714
|
+
this._logSubscribers.clear();
|
|
715
|
+
this._updateProgressSubscribers.clear();
|
|
716
|
+
const sessionId = this._closeTransport(new Error('Session stopped'));
|
|
717
|
+
if (sessionId) {
|
|
718
|
+
await this._sendToServer({ type: 'endSession', sessionId });
|
|
719
|
+
}
|
|
659
720
|
const wasStreaming = this._state === 'streaming';
|
|
660
721
|
if (wasStreaming) {
|
|
661
722
|
this._state = 'connected';
|
|
@@ -663,259 +724,63 @@ export class ReachyMini extends EventTarget {
|
|
|
663
724
|
}
|
|
664
725
|
}
|
|
665
726
|
disconnect() {
|
|
727
|
+
this._supervisor.cancelRedial();
|
|
666
728
|
if (this._sseAbortController) {
|
|
667
729
|
this._sseAbortController.abort();
|
|
668
730
|
this._sseAbortController = null;
|
|
669
731
|
}
|
|
670
|
-
if (this._versionResolve) {
|
|
671
|
-
this._versionResolve(null);
|
|
672
|
-
this._versionResolve = null;
|
|
673
|
-
}
|
|
674
|
-
if (this._hardwareIdResolve) {
|
|
675
|
-
this._hardwareIdResolve(null);
|
|
676
|
-
this._hardwareIdResolve = null;
|
|
677
|
-
}
|
|
678
|
-
if (this._volumeResolve) {
|
|
679
|
-
this._volumeResolve(null);
|
|
680
|
-
this._volumeResolve = null;
|
|
681
|
-
}
|
|
682
|
-
if (this._micVolumeResolve) {
|
|
683
|
-
this._micVolumeResolve(null);
|
|
684
|
-
this._micVolumeResolve = null;
|
|
685
|
-
}
|
|
686
|
-
if (this._trackedFaceResolve) {
|
|
687
|
-
this._trackedFaceResolve(null);
|
|
688
|
-
this._trackedFaceResolve = null;
|
|
689
|
-
}
|
|
690
|
-
if (this._applyAudioConfigResolve) {
|
|
691
|
-
this._applyAudioConfigResolve(false);
|
|
692
|
-
this._applyAudioConfigResolve = null;
|
|
693
|
-
}
|
|
694
|
-
if (this._readAudioParameterResolve) {
|
|
695
|
-
this._readAudioParameterResolve(null);
|
|
696
|
-
this._readAudioParameterResolve = null;
|
|
697
|
-
}
|
|
698
732
|
this._logSubscribers.clear();
|
|
699
733
|
this._updateProgressSubscribers.clear();
|
|
700
|
-
this.
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
this._uninstallNetworkListeners();
|
|
704
|
-
if (this._sessionReject) {
|
|
705
|
-
this._sessionReject(new Error('Disconnected'));
|
|
706
|
-
this._sessionResolve = null;
|
|
707
|
-
this._sessionReject = null;
|
|
708
|
-
}
|
|
709
|
-
if (this._stateRefreshInterval) {
|
|
710
|
-
clearInterval(this._stateRefreshInterval);
|
|
711
|
-
this._stateRefreshInterval = null;
|
|
712
|
-
}
|
|
713
|
-
if (this._latencyMonitorId) {
|
|
714
|
-
clearInterval(this._latencyMonitorId);
|
|
715
|
-
this._latencyMonitorId = null;
|
|
734
|
+
const sessionId = this._closeTransport(new Error('Disconnected'));
|
|
735
|
+
if (sessionId && this._token) {
|
|
736
|
+
void this._sendToServer({ type: 'endSession', sessionId });
|
|
716
737
|
}
|
|
717
|
-
if (this._sessionId && this._token) {
|
|
718
|
-
this._sendToServer({ type: 'endSession', sessionId: this._sessionId });
|
|
719
|
-
}
|
|
720
|
-
if (this._micStream) {
|
|
721
|
-
this._micStream.getTracks().forEach((t) => t.stop());
|
|
722
|
-
this._micStream = null;
|
|
723
|
-
}
|
|
724
|
-
if (this._pc) {
|
|
725
|
-
this._pc.close();
|
|
726
|
-
this._pc = null;
|
|
727
|
-
}
|
|
728
|
-
if (this._dc) {
|
|
729
|
-
this._dc.close();
|
|
730
|
-
this._dc = null;
|
|
731
|
-
}
|
|
732
|
-
this._sessionId = null;
|
|
733
|
-
this._micMuted = true;
|
|
734
|
-
this._micSupported = false;
|
|
735
|
-
this._iceConnected = false;
|
|
736
|
-
this._dcOpen = false;
|
|
737
738
|
this._robots = [];
|
|
738
739
|
this._state = 'disconnected';
|
|
739
740
|
this._emit('disconnected', { reason: 'user' });
|
|
740
741
|
}
|
|
741
|
-
// ─── Resilience:
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
// events so the consumer's own state machine doesn't get torn down
|
|
746
|
-
// by routine WiFi/4G/screen-off noise.
|
|
747
|
-
/**
|
|
748
|
-
* Cancel any pending ICE grace timer and visibility handler. Called
|
|
749
|
-
* on a healed `connected`/`completed` transition AND from the
|
|
750
|
-
* lifecycle teardown paths so a callback can't fire after `_pc`
|
|
751
|
-
* is closed.
|
|
752
|
-
*/
|
|
753
|
-
_clearIceGrace() {
|
|
754
|
-
if (this._iceGraceTimer !== null) {
|
|
755
|
-
clearTimeout(this._iceGraceTimer);
|
|
756
|
-
this._iceGraceTimer = null;
|
|
757
|
-
}
|
|
758
|
-
this._iceGraceReason = null;
|
|
759
|
-
if (this._pendingVisibilityHandler && typeof document !== 'undefined') {
|
|
760
|
-
document.removeEventListener('visibilitychange', this._pendingVisibilityHandler);
|
|
761
|
-
}
|
|
762
|
-
this._pendingVisibilityHandler = null;
|
|
763
|
-
}
|
|
764
|
-
/**
|
|
765
|
-
* Start a grace window. After `ms`, re-check the live ICE state:
|
|
766
|
-
* - If we healed back to `connected`/`completed`, the timer was
|
|
767
|
-
* already cancelled in `oniceconnectionstatechange`, so we
|
|
768
|
-
* never get here.
|
|
769
|
-
* - If we're still in the originally-observed bad state (or
|
|
770
|
-
* worse), surface the error and reject any pending session
|
|
771
|
-
* promise. The original code path is preserved verbatim so
|
|
772
|
-
* downstream consumers see the same `error` payload shape.
|
|
773
|
-
*/
|
|
774
|
-
_scheduleIceGrace(ms, reason) {
|
|
775
|
-
// Coalesce: if a grace is already pending and the reason hasn't
|
|
776
|
-
// changed, keep the original timer so a flurry of identical
|
|
777
|
-
// transitions doesn't reset the clock. If the reason changed
|
|
778
|
-
// (typically `disconnected` → `failed`, but also the reverse on
|
|
779
|
-
// some Android WebViews), replace the timer with the new
|
|
780
|
-
// (reason, ms) pair — the latest signal wins.
|
|
781
|
-
if (this._iceGraceTimer !== null) {
|
|
782
|
-
if (this._iceGraceReason === reason)
|
|
783
|
-
return;
|
|
784
|
-
clearTimeout(this._iceGraceTimer);
|
|
785
|
-
}
|
|
786
|
-
this._iceGraceReason = reason;
|
|
787
|
-
this._iceGraceTimer = setTimeout(() => {
|
|
788
|
-
this._iceGraceTimer = null;
|
|
789
|
-
const r = this._iceGraceReason;
|
|
790
|
-
this._iceGraceReason = null;
|
|
791
|
-
const s = this._pc?.iceConnectionState;
|
|
792
|
-
if (s === 'connected' || s === 'completed')
|
|
793
|
-
return; // healed
|
|
794
|
-
if (r === 'disconnected' && s === 'disconnected') {
|
|
795
|
-
this._emit('error', {
|
|
796
|
-
source: 'webrtc',
|
|
797
|
-
error: new Error(`ICE stuck in 'disconnected' for > ${ms}ms`),
|
|
798
|
-
});
|
|
799
|
-
return;
|
|
800
|
-
}
|
|
801
|
-
if (r === 'failed' || s === 'failed') {
|
|
802
|
-
const err = new Error('ICE connection failed');
|
|
803
|
-
if (this._sessionReject) {
|
|
804
|
-
this._sessionReject(err);
|
|
805
|
-
this._sessionResolve = null;
|
|
806
|
-
this._sessionReject = null;
|
|
807
|
-
}
|
|
808
|
-
this._emit('error', { source: 'webrtc', error: err });
|
|
809
|
-
}
|
|
810
|
-
}, ms);
|
|
811
|
-
}
|
|
742
|
+
// ─── Resilience: supervisor delegation ───────────────────────────────
|
|
743
|
+
// The policy lives in `session-supervisor.ts`; only the transport
|
|
744
|
+
// teardown below stays here because it manipulates the class's own
|
|
745
|
+
// private state.
|
|
812
746
|
/**
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
747
|
+
* Enable/disable the automatic session re-dial at runtime.
|
|
748
|
+
* Disabling also aborts any re-dial already in flight. Used by
|
|
749
|
+
* flows that EXPECT the transport to die — e.g. the daemon
|
|
750
|
+
* self-update, whose `systemctl restart` teardown must surface as
|
|
751
|
+
* `sessionStopped` immediately (it's the "install done, rebooting"
|
|
752
|
+
* signal), not get absorbed by ~22 s of doomed reconnect attempts
|
|
753
|
+
* against a robot that is rebooting anyway.
|
|
754
|
+
*
|
|
755
|
+
* Sharp edge: cancelling an in-flight re-dial leaves the session
|
|
756
|
+
* torn down without emitting `sessionStopped` or `error` — the
|
|
757
|
+
* caller owns the terminal event (see `ReachyMiniInstance`).
|
|
818
758
|
*/
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
return;
|
|
822
|
-
const deferredAt = Date.now();
|
|
823
|
-
const handler = () => {
|
|
824
|
-
if (typeof document !== 'undefined' && document.hidden)
|
|
825
|
-
return;
|
|
826
|
-
document.removeEventListener('visibilitychange', handler);
|
|
827
|
-
this._pendingVisibilityHandler = null;
|
|
828
|
-
if (!this._pc)
|
|
829
|
-
return;
|
|
830
|
-
const s = this._pc.iceConnectionState;
|
|
831
|
-
if (s === 'connected' || s === 'completed')
|
|
832
|
-
return; // healed in bg
|
|
833
|
-
// Ceiling: if the user backgrounded past the daemon's
|
|
834
|
-
// ICE-consent freshness window the session is gone from
|
|
835
|
-
// the daemon's side regardless of what `_pc` reports
|
|
836
|
-
// locally. Running another foreground grace would tell
|
|
837
|
-
// the user "Reconnecting…" for a recovery that can never
|
|
838
|
-
// happen. Escalate immediately so the host renders the
|
|
839
|
-
// real "session expired" UX. See MAX_VISIBILITY_DEFER_MS.
|
|
840
|
-
if (Date.now() - deferredAt > MAX_VISIBILITY_DEFER_MS) {
|
|
841
|
-
const err = new Error('Session expired while tab was backgrounded');
|
|
842
|
-
if (this._sessionReject) {
|
|
843
|
-
this._sessionReject(err);
|
|
844
|
-
this._sessionResolve = null;
|
|
845
|
-
this._sessionReject = null;
|
|
846
|
-
}
|
|
847
|
-
this._emit('error', { source: 'webrtc', error: err });
|
|
848
|
-
return;
|
|
849
|
-
}
|
|
850
|
-
if (s === 'failed') {
|
|
851
|
-
this._scheduleIceGrace(ICE_FAILED_GRACE_MS, 'failed');
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
// Still disconnected when we came back — give it a normal
|
|
855
|
-
// foreground grace window now that timers fire reliably.
|
|
856
|
-
this._scheduleIceGrace(ICE_DISCONNECT_GRACE_MS, 'disconnected');
|
|
857
|
-
};
|
|
858
|
-
document.addEventListener('visibilitychange', handler);
|
|
859
|
-
this._pendingVisibilityHandler = handler;
|
|
759
|
+
setAutoReconnect(enabled) {
|
|
760
|
+
this._supervisor.setAutoReconnect(enabled);
|
|
860
761
|
}
|
|
861
762
|
/**
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
* symmetrically.
|
|
872
|
-
*
|
|
873
|
-
* `connection.change` (NetworkInformation API, Chrome / Android
|
|
874
|
-
* WebView only) is semantically about the TRANSPORT: it fires
|
|
875
|
-
* on Wi-Fi → 4G swaps, AP roams, etc. without necessarily going
|
|
876
|
-
* through `offline`. We forward it as its own `networkChange`
|
|
877
|
-
* event rather than aliasing it onto `networkOnline`, so
|
|
878
|
-
* consumers don't have to guess whether they're seeing a real
|
|
879
|
-
* connectivity recovery or a silent transport swap.
|
|
763
|
+
* Transport-only teardown: everything stopSession() does EXCEPT the
|
|
764
|
+
* user-facing state flip (`sessionStopped` is not emitted, `_state`
|
|
765
|
+
* falls back to 'connected' so the re-dial can start a session).
|
|
766
|
+
* Pending promises that ride the dead channel are settled just like
|
|
767
|
+
* stopSession() settles them — callers see the same failure shape a
|
|
768
|
+
* manual stop would produce; that includes a timed-out dial
|
|
769
|
+
* attempt's own startSession() promise (its resolvers were left
|
|
770
|
+
* armed, and a late signaling reply could otherwise settle them
|
|
771
|
+
* against a closed pc).
|
|
880
772
|
*/
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
const onChange = () => this._emit('networkChange', {
|
|
893
|
-
effectiveType: conn.effectiveType,
|
|
894
|
-
downlink: conn.downlink,
|
|
895
|
-
rtt: conn.rtt,
|
|
896
|
-
saveData: conn.saveData,
|
|
897
|
-
});
|
|
898
|
-
conn.addEventListener('change', onChange);
|
|
899
|
-
this._connectionChangeHandler = onChange;
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
/** Counterpart to `_installNetworkListeners`. */
|
|
903
|
-
_uninstallNetworkListeners() {
|
|
904
|
-
if (typeof window !== 'undefined') {
|
|
905
|
-
if (this._onlineHandler) {
|
|
906
|
-
window.removeEventListener('online', this._onlineHandler);
|
|
907
|
-
}
|
|
908
|
-
if (this._offlineHandler) {
|
|
909
|
-
window.removeEventListener('offline', this._offlineHandler);
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
const conn = navigator.connection;
|
|
913
|
-
if (conn && this._connectionChangeHandler && typeof conn.removeEventListener === 'function') {
|
|
914
|
-
conn.removeEventListener('change', this._connectionChangeHandler);
|
|
915
|
-
}
|
|
916
|
-
this._onlineHandler = null;
|
|
917
|
-
this._offlineHandler = null;
|
|
918
|
-
this._connectionChangeHandler = null;
|
|
773
|
+
_teardownForRedial() {
|
|
774
|
+
const sessionId = this._closeTransport(new Error('Session reconnecting'));
|
|
775
|
+
// Free the robot-side slot: the relay refuses a second session
|
|
776
|
+
// while it still tracks the dead one, and only endSession (or its
|
|
777
|
+
// own consent-freshness timeout, ~30 s) clears it. Fire and
|
|
778
|
+
// forget — _sendToServer never throws.
|
|
779
|
+
if (sessionId && this._token) {
|
|
780
|
+
void this._sendToServer({ type: 'endSession', sessionId });
|
|
781
|
+
}
|
|
782
|
+
if (this._state === 'streaming')
|
|
783
|
+
this._state = 'connected';
|
|
919
784
|
}
|
|
920
785
|
// ─── Commands ────────────────────────────────────────────────────────
|
|
921
786
|
/**
|
|
@@ -1029,6 +894,54 @@ export class ReachyMini extends EventTarget {
|
|
|
1029
894
|
playSound(file) {
|
|
1030
895
|
return this._sendCommand({ type: 'play_sound', file });
|
|
1031
896
|
}
|
|
897
|
+
playRecordedMove(moveName, { dataset, initialGotoDuration, } = {}) {
|
|
898
|
+
return this._sendCommand({
|
|
899
|
+
type: 'play_recorded_move',
|
|
900
|
+
move_name: moveName,
|
|
901
|
+
...(dataset ? { dataset_name: dataset } : {}),
|
|
902
|
+
...(initialGotoDuration && initialGotoDuration > 0
|
|
903
|
+
? { initial_goto_duration: initialGotoDuration }
|
|
904
|
+
: {}),
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Stop whatever move is currently playing on the daemon (recorded move,
|
|
909
|
+
* uploaded move, goto). Fire-and-forget and idempotent: the daemon acks
|
|
910
|
+
* ok with `stopped: false` when nothing was running. Returns `false` if
|
|
911
|
+
* the data channel is not open.
|
|
912
|
+
*/
|
|
913
|
+
stopMove() {
|
|
914
|
+
return this._sendCommand({ type: 'stop_move' });
|
|
915
|
+
}
|
|
916
|
+
preloadDataset(dataset) {
|
|
917
|
+
return this._sendCommand({ type: 'preload_dataset', dataset_name: dataset });
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Like `preloadDataset()`, but resolves once the daemon acks the preload
|
|
921
|
+
* (`{command: "preload_dataset", ...}` on the data channel), i.e. when the
|
|
922
|
+
* dataset is actually in the local HF cache. Resolves `true` on success,
|
|
923
|
+
* `false` when the daemon reports a download failure, and `null` on the
|
|
924
|
+
* fail-open timeout (download slower than `timeoutMs`, or a daemon that
|
|
925
|
+
* predates the command and never replies) - callers should proceed in all
|
|
926
|
+
* three cases, `playRecordedMove` still downloads on demand. Rejects when
|
|
927
|
+
* the data channel isn't open or the session tears down mid-flight.
|
|
928
|
+
*/
|
|
929
|
+
preloadDatasetAndWait(dataset, { timeoutMs = 120000 } = {}) {
|
|
930
|
+
// Send before registering the waiter (same rationale as `request()`):
|
|
931
|
+
// a channel closed mid-flight rejects instead of hanging a waiter to
|
|
932
|
+
// its timeout.
|
|
933
|
+
if (!this.preloadDataset(dataset)) {
|
|
934
|
+
return Promise.reject(new Error('Data channel not open'));
|
|
935
|
+
}
|
|
936
|
+
return this._pending
|
|
937
|
+
.awaitBroadcast((m) => m.command === 'preload_dataset' && m.dataset_name === dataset, { timeoutMs, debugLabel: `preload_dataset(${dataset})` })
|
|
938
|
+
.then((m) => m.status === 'ok')
|
|
939
|
+
.catch((err) => {
|
|
940
|
+
if (err instanceof BroadcastTimeoutError)
|
|
941
|
+
return null;
|
|
942
|
+
throw err;
|
|
943
|
+
});
|
|
944
|
+
}
|
|
1032
945
|
clearIncomingAudio() {
|
|
1033
946
|
return this._sendCommand({ type: 'clear_incoming_audio' });
|
|
1034
947
|
}
|
|
@@ -1047,7 +960,7 @@ export class ReachyMini extends EventTarget {
|
|
|
1047
960
|
return this._sendCommand({ type: 'set_head_tracking', enabled: false });
|
|
1048
961
|
}
|
|
1049
962
|
getTrackedFace() {
|
|
1050
|
-
return this._slotRoundtrip(
|
|
963
|
+
return this._slotRoundtrip('tracked_face', { type: 'get_tracked_face' });
|
|
1051
964
|
}
|
|
1052
965
|
/**
|
|
1053
966
|
* Trigger a PyPI update of the daemon over the data channel. Remote
|
|
@@ -1085,30 +998,7 @@ export class ReachyMini extends EventTarget {
|
|
|
1085
998
|
if (!this._sendCommand({ type: command })) {
|
|
1086
999
|
return Promise.reject(new Error(`${command}: data channel not open`));
|
|
1087
1000
|
}
|
|
1088
|
-
return
|
|
1089
|
-
const entry = {
|
|
1090
|
-
resolve,
|
|
1091
|
-
reject,
|
|
1092
|
-
timer: setTimeout(() => {
|
|
1093
|
-
const queue = this._pendingMotionCompletions[command];
|
|
1094
|
-
const idx = queue.indexOf(entry);
|
|
1095
|
-
if (idx !== -1)
|
|
1096
|
-
queue.splice(idx, 1);
|
|
1097
|
-
reject(new Error(`${command} timed out after ${timeoutMs}ms`));
|
|
1098
|
-
}, timeoutMs),
|
|
1099
|
-
};
|
|
1100
|
-
this._pendingMotionCompletions[command].push(entry);
|
|
1101
|
-
});
|
|
1102
|
-
}
|
|
1103
|
-
_rejectPendingMotionCompletions(error) {
|
|
1104
|
-
for (const command of Object.keys(this._pendingMotionCompletions)) {
|
|
1105
|
-
const queue = this._pendingMotionCompletions[command];
|
|
1106
|
-
while (queue.length) {
|
|
1107
|
-
const entry = queue.shift();
|
|
1108
|
-
clearTimeout(entry.timer);
|
|
1109
|
-
entry.reject(error);
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1001
|
+
return this._pending.awaitMotion(command, timeoutMs);
|
|
1112
1002
|
}
|
|
1113
1003
|
isAwake() {
|
|
1114
1004
|
const mode = this._robotState?.motor_mode;
|
|
@@ -1127,85 +1017,193 @@ export class ReachyMini extends EventTarget {
|
|
|
1127
1017
|
this.requestState();
|
|
1128
1018
|
});
|
|
1129
1019
|
}
|
|
1130
|
-
|
|
1020
|
+
// Gravity compensation counts as awake - the robot is standing - but
|
|
1021
|
+
// it runs under current control, where the daemon ignores position
|
|
1022
|
+
// targets outright. An app inheriting that state from its predecessor
|
|
1023
|
+
// (a fast handoff cancels the daemon's idle reset, so the mode
|
|
1024
|
+
// survives) would see every goto silently do nothing. Flip back to
|
|
1025
|
+
// position control without replaying the emote: the robot is already
|
|
1026
|
+
// up, and the daemon pins targets to the measured pose on the way in,
|
|
1027
|
+
// so nothing snaps.
|
|
1028
|
+
if (this._robotState?.motor_mode === 'gravity_compensation') {
|
|
1029
|
+
log.info('ensureAwake: gravity compensation inherited, flipping to enabled (no emote)');
|
|
1030
|
+
this.setMotorMode('enabled');
|
|
1031
|
+
// Refresh the cache so a caller reading isAwake() right after us
|
|
1032
|
+
// doesn't see the mode we just left.
|
|
1033
|
+
this.requestState();
|
|
1034
|
+
return true;
|
|
1035
|
+
}
|
|
1036
|
+
if (this.isAwake()) {
|
|
1037
|
+
log.info('ensureAwake: already awake, nothing to do');
|
|
1131
1038
|
return true;
|
|
1132
|
-
|
|
1039
|
+
}
|
|
1040
|
+
// Await the trajectory: callers treat resolution as "robot ready for
|
|
1041
|
+
// position targets", and the emote keeps moving the head for ~2 s
|
|
1042
|
+
// after the command is acked - an app that starts commanding poses
|
|
1043
|
+
// under it fights it. Failures are swallowed: a torn-down session or
|
|
1044
|
+
// a daemon that never acks must not take the whole boot down with it.
|
|
1045
|
+
log.info(`ensureAwake: robot asleep (motor_mode=${this._robotState?.motor_mode ?? 'unknown'}), playing wake-up`);
|
|
1046
|
+
const wakeStartedAt = Date.now();
|
|
1047
|
+
try {
|
|
1048
|
+
await this.wakeUp({ timeoutMs: WAKE_TRAJECTORY_BUDGET_MS });
|
|
1049
|
+
log.info(`ensureAwake: wake-up trajectory done in ${Date.now() - wakeStartedAt}ms`);
|
|
1050
|
+
}
|
|
1051
|
+
catch (e) {
|
|
1052
|
+
/* timed out, or the session went away under us */
|
|
1053
|
+
log.warn(`ensureAwake: wake-up not confirmed after ${Date.now() - wakeStartedAt}ms (continuing):`, e);
|
|
1054
|
+
}
|
|
1133
1055
|
return true;
|
|
1134
1056
|
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Query the daemon version. Resolves `null` when the daemon predates
|
|
1059
|
+
* `get_version` (fail-open on the shared slot timeout) or the reply is
|
|
1060
|
+
* superseded by session teardown.
|
|
1061
|
+
*/
|
|
1135
1062
|
getVersion() {
|
|
1136
|
-
return
|
|
1137
|
-
if (!this._dc || this._dc.readyState !== 'open') {
|
|
1138
|
-
reject(new Error('Data channel not open'));
|
|
1139
|
-
return;
|
|
1140
|
-
}
|
|
1141
|
-
if (this._versionResolve) {
|
|
1142
|
-
this._versionResolve(null);
|
|
1143
|
-
}
|
|
1144
|
-
this._versionResolve = resolve;
|
|
1145
|
-
this._sendCommand({ type: 'get_version' });
|
|
1146
|
-
});
|
|
1063
|
+
return this._slotRoundtrip('version', { type: 'get_version' });
|
|
1147
1064
|
}
|
|
1148
1065
|
getHardwareId() {
|
|
1149
|
-
return
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
this._sendCommand({ type: 'get_hardware_id' });
|
|
1159
|
-
});
|
|
1066
|
+
return this._slotRoundtrip('hardware_id', { type: 'get_hardware_id' });
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* One-shot IMU reading (BMI088, wireless version only). Resolves `null`
|
|
1070
|
+
* when the robot has no IMU (Lite, simulation) or the daemon predates
|
|
1071
|
+
* the `get_imu` command (fail-open on the shared slot timeout).
|
|
1072
|
+
*/
|
|
1073
|
+
getImu() {
|
|
1074
|
+
return this._slotRoundtrip('imu', { type: 'get_imu' });
|
|
1160
1075
|
}
|
|
1161
1076
|
getVolume() {
|
|
1162
|
-
return this._slotRoundtrip(
|
|
1077
|
+
return this._slotRoundtrip('volume', { type: 'get_volume' });
|
|
1163
1078
|
}
|
|
1164
1079
|
setVolume(volume) {
|
|
1165
|
-
return this._slotRoundtrip(
|
|
1080
|
+
return this._slotRoundtrip('volume', { type: 'set_volume', volume: clampVolume(volume) });
|
|
1166
1081
|
}
|
|
1167
1082
|
getMicrophoneVolume() {
|
|
1168
|
-
return this._slotRoundtrip(
|
|
1083
|
+
return this._slotRoundtrip('mic_volume', { type: 'get_microphone_volume' });
|
|
1169
1084
|
}
|
|
1170
1085
|
setMicrophoneVolume(volume) {
|
|
1171
|
-
return this._slotRoundtrip(
|
|
1086
|
+
return this._slotRoundtrip('mic_volume', { type: 'set_microphone_volume', volume: clampVolume(volume) });
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Query whether the first wake-up setup wizard has been completed.
|
|
1090
|
+
* Robot-wide, persisted on the robot. Resolves `false` when pending,
|
|
1091
|
+
* `true` when done, or `null` when the channel isn't open / the daemon
|
|
1092
|
+
* predates the `get_first_wake_up` command (callers should fail-open
|
|
1093
|
+
* and skip the wizard on `null`).
|
|
1094
|
+
*/
|
|
1095
|
+
getFirstWakeUp() {
|
|
1096
|
+
// Fail-open, so this never rejects: the wizard gate runs right after
|
|
1097
|
+
// connect, which is exactly when the channel may not be open yet.
|
|
1098
|
+
if (!this._dc || this._dc.readyState !== 'open')
|
|
1099
|
+
return Promise.resolve(null);
|
|
1100
|
+
return this._slotRoundtrip('first_wake_up', { type: 'get_first_wake_up' });
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Persist the first wake-up wizard completion flag on the robot.
|
|
1104
|
+
* Resolves with the stored value (or `null` on channel-closed).
|
|
1105
|
+
*/
|
|
1106
|
+
setFirstWakeUp(isCompleted) {
|
|
1107
|
+
if (!this._dc || this._dc.readyState !== 'open')
|
|
1108
|
+
return Promise.resolve(null);
|
|
1109
|
+
return this._slotRoundtrip('first_wake_up', { type: 'set_first_wake_up', is_completed: isCompleted });
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Query the persisted robot display name. Resolves the stored name,
|
|
1113
|
+
* `null` when none is set / the channel isn't open / the daemon predates
|
|
1114
|
+
* the `get_robot_name` command.
|
|
1115
|
+
*/
|
|
1116
|
+
getRobotName() {
|
|
1117
|
+
return this._slotRoundtrip('robot_name', { type: 'get_robot_name' });
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Set and persist the robot display name on the robot. Resolves with the
|
|
1121
|
+
* stored (trimmed) name, or `null` on error / channel-closed. Applied live
|
|
1122
|
+
* by the daemon (status + central relay + mDNS), so it takes effect right
|
|
1123
|
+
* away without a restart; the persisted name also overrides --robot-name
|
|
1124
|
+
* on the next start.
|
|
1125
|
+
*/
|
|
1126
|
+
setRobotName(name) {
|
|
1127
|
+
return this._slotRoundtrip('robot_name', { type: 'set_robot_name', name });
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Sign this robot out of Hugging Face: asks the daemon to delete its
|
|
1131
|
+
* stored HF token, which de-registers the robot from the central
|
|
1132
|
+
* signaling relay (it disappears from its owner's robot list until it
|
|
1133
|
+
* is set up again). Works over the WebRTC data channel, so it reaches
|
|
1134
|
+
* the robot remotely (no LAN HTTP path required).
|
|
1135
|
+
*
|
|
1136
|
+
* Resolves `true` when the daemon acked success, `false` on a daemon
|
|
1137
|
+
* error, or `null` when no ack arrives before the timeout (e.g. a
|
|
1138
|
+
* daemon that predates the `delete_hf_token` command silently drops
|
|
1139
|
+
* it). Rejects if the data channel isn't open. Note the sign-out
|
|
1140
|
+
* drops the central relay, so the session may tear down right after
|
|
1141
|
+
* the ack - callers should treat a post-call session drop as expected,
|
|
1142
|
+
* and a successful sign-out may surface as `null` if teardown races
|
|
1143
|
+
* ahead of the ack.
|
|
1144
|
+
*/
|
|
1145
|
+
signOut() {
|
|
1146
|
+
return this._slotRoundtrip('delete_hf_token', { type: 'delete_hf_token' });
|
|
1172
1147
|
}
|
|
1173
1148
|
applyAudioConfig(config, { verify = true } = {}) {
|
|
1174
|
-
return this._slotRoundtrip(
|
|
1149
|
+
return this._slotRoundtrip('apply_audio_config', { type: 'apply_audio_config', config, verify })
|
|
1150
|
+
.then((v) => v === true);
|
|
1175
1151
|
}
|
|
1176
1152
|
readAudioParameter(name) {
|
|
1177
|
-
return this._slotRoundtrip(
|
|
1153
|
+
return this._slotRoundtrip('read_audio_parameter', { type: 'read_audio_parameter', name });
|
|
1178
1154
|
}
|
|
1179
1155
|
/**
|
|
1180
1156
|
* Internal: send a command and await the matching daemon response in a
|
|
1181
|
-
* named single-resolver slot
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
* sufficient. If a previous request on the same slot is still
|
|
1185
|
-
* pending when a new one comes in, the older promise is resolved to
|
|
1186
|
-
* `null` so its caller doesn't hang forever.
|
|
1187
|
-
*
|
|
1188
|
-
* Slot access is passed in as getter/setter closures rather than a
|
|
1189
|
-
* key into `this`: that keeps the helper fully generic-checked (T is
|
|
1190
|
-
* inferred from the slot's resolver type at each call site) with no
|
|
1191
|
-
* indexed-property casts.
|
|
1157
|
+
* named single-resolver slot (see `PendingReplies.slotRoundtrip`).
|
|
1158
|
+
* Every caller has a strict request/response shape where a single
|
|
1159
|
+
* in-flight call per slot is sufficient.
|
|
1192
1160
|
*/
|
|
1193
|
-
_slotRoundtrip(
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
}
|
|
1199
|
-
const prev = getSlot();
|
|
1200
|
-
if (prev)
|
|
1201
|
-
prev(null);
|
|
1202
|
-
setSlot(resolve);
|
|
1203
|
-
this._sendCommand(command);
|
|
1204
|
-
});
|
|
1161
|
+
_slotRoundtrip(slot, command) {
|
|
1162
|
+
if (!this._dc || this._dc.readyState !== 'open') {
|
|
1163
|
+
return Promise.reject(new Error('Data channel not open'));
|
|
1164
|
+
}
|
|
1165
|
+
return this._pending.slotRoundtrip(slot, () => { this._sendCommand(command); });
|
|
1205
1166
|
}
|
|
1206
1167
|
sendRaw(data) {
|
|
1207
1168
|
return this._sendCommand(data);
|
|
1208
1169
|
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Generic command round-trip for daemon commands the SDK has no typed
|
|
1172
|
+
* wrapper for (yet). Escape hatch so an app can use a newer daemon
|
|
1173
|
+
* feature without waiting for an SDK release: sends `command` and
|
|
1174
|
+
* resolves with the first robot message whose `command` field equals
|
|
1175
|
+
* the sent `type` - the daemon's reply convention - or with `null` on
|
|
1176
|
+
* the fail-open timeout (daemon predates the command, or the command
|
|
1177
|
+
* is fire-and-forget and never replies).
|
|
1178
|
+
*
|
|
1179
|
+
* Rejects when the data channel isn't open or the session tears down
|
|
1180
|
+
* mid-flight, mirroring the typed wrappers.
|
|
1181
|
+
*
|
|
1182
|
+
* Replies the SDK already consumes internally (`get_imu`,
|
|
1183
|
+
* `get_volume`, ...) are swallowed by their own handlers and never
|
|
1184
|
+
* reach this matcher - use the typed wrappers for those. Pass `match`
|
|
1185
|
+
* for replies that don't follow the `command` echo convention.
|
|
1186
|
+
*/
|
|
1187
|
+
request(command, { timeoutMs = SLOT_ROUNDTRIP_TIMEOUT_MS, match } = {}) {
|
|
1188
|
+
// Send before registering the waiter (safe: replies arrive on a
|
|
1189
|
+
// later task), so a channel closed mid-flight rejects instead of
|
|
1190
|
+
// hanging a waiter to its timeout and resolving `null`.
|
|
1191
|
+
if (!this._sendCommand(command)) {
|
|
1192
|
+
return Promise.reject(new Error('Data channel not open'));
|
|
1193
|
+
}
|
|
1194
|
+
const predicate = match
|
|
1195
|
+
?? ((m) => m.command === command.type);
|
|
1196
|
+
return this._pending
|
|
1197
|
+
.awaitBroadcast(predicate, { timeoutMs, debugLabel: `request(${command.type})` })
|
|
1198
|
+
.catch((err) => {
|
|
1199
|
+
// Fail-open on the waiter timeout only; teardown rejections
|
|
1200
|
+
// (settleAll) propagate to the caller like every other
|
|
1201
|
+
// in-flight round-trip.
|
|
1202
|
+
if (err instanceof BroadcastTimeoutError)
|
|
1203
|
+
return null;
|
|
1204
|
+
throw err;
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1209
1207
|
subscribeLogs({ onLine, onError }) {
|
|
1210
1208
|
if (typeof onLine !== 'function') {
|
|
1211
1209
|
throw new TypeError('subscribeLogs: onLine callback is required');
|
|
@@ -1229,6 +1227,30 @@ export class ReachyMini extends EventTarget {
|
|
|
1229
1227
|
requestState() {
|
|
1230
1228
|
return this._sendCommand({ type: 'get_state' });
|
|
1231
1229
|
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Ask the daemon to *push* the robot state (~30 Hz) over the dedicated
|
|
1232
|
+
* unreliable/unordered `pose` data channel instead of polling get_state.
|
|
1233
|
+
* Fires `state` events as frames arrive. No-op against an older daemon (no
|
|
1234
|
+
* pose channel) - fall back to `requestState()` polling there.
|
|
1235
|
+
*
|
|
1236
|
+
* Refcounted: pair every `subscribePose()` with exactly one
|
|
1237
|
+
* `unsubscribePose()`. Multiple consumers share a single daemon-side
|
|
1238
|
+
* subscription; the daemon only stops pushing once the last one releases.
|
|
1239
|
+
* If the channel isn't open yet (or the session later reconnects), the
|
|
1240
|
+
* subscription is (re-)asserted from `_checkSessionReady`.
|
|
1241
|
+
*/
|
|
1242
|
+
subscribePose() {
|
|
1243
|
+
this._poseSubRefs++;
|
|
1244
|
+
return this._sendCommand({ type: 'subscribe_pose' });
|
|
1245
|
+
}
|
|
1246
|
+
/** Release one pose-stream consumer; sends `unsubscribe_pose` on the last. */
|
|
1247
|
+
unsubscribePose() {
|
|
1248
|
+
if (this._poseSubRefs > 0)
|
|
1249
|
+
this._poseSubRefs--;
|
|
1250
|
+
if (this._poseSubRefs > 0)
|
|
1251
|
+
return true; // still wanted by another consumer
|
|
1252
|
+
return this._sendCommand({ type: 'unsubscribe_pose' });
|
|
1253
|
+
}
|
|
1232
1254
|
// ─── Audio ───────────────────────────────────────────────────────────
|
|
1233
1255
|
setAudioMuted(muted) {
|
|
1234
1256
|
this._audioMuted = muted;
|
|
@@ -1384,7 +1406,7 @@ export class ReachyMini extends EventTarget {
|
|
|
1384
1406
|
});
|
|
1385
1407
|
}
|
|
1386
1408
|
catch (e) {
|
|
1387
|
-
|
|
1409
|
+
log.warn('playMove.onStarted threw:', e);
|
|
1388
1410
|
}
|
|
1389
1411
|
onProgress({ phase: 'playing', duration_s: startedAck.duration_s });
|
|
1390
1412
|
const final = await this._waitForBroadcast((m) => m?.type === 'play_uploaded_move'
|
|
@@ -1469,20 +1491,8 @@ export class ReachyMini extends EventTarget {
|
|
|
1469
1491
|
_emit(name, detail) {
|
|
1470
1492
|
this.dispatchEvent(new CustomEvent(name, { detail }));
|
|
1471
1493
|
}
|
|
1472
|
-
_waitForBroadcast(predicate,
|
|
1473
|
-
return
|
|
1474
|
-
const slot = {
|
|
1475
|
-
predicate,
|
|
1476
|
-
resolve,
|
|
1477
|
-
timer: setTimeout(() => {
|
|
1478
|
-
const i = this._broadcastWaiters.indexOf(slot);
|
|
1479
|
-
if (i !== -1)
|
|
1480
|
-
this._broadcastWaiters.splice(i, 1);
|
|
1481
|
-
reject(new Error(`broadcast timeout (${timeoutMs} ms): ${debugLabel}`));
|
|
1482
|
-
}, timeoutMs),
|
|
1483
|
-
};
|
|
1484
|
-
this._broadcastWaiters.push(slot);
|
|
1485
|
-
});
|
|
1494
|
+
_waitForBroadcast(predicate, opts = {}) {
|
|
1495
|
+
return this._pending.awaitBroadcast(predicate, opts);
|
|
1486
1496
|
}
|
|
1487
1497
|
async _awaitDataChannelDrain() {
|
|
1488
1498
|
while (this._dc && this._dc.bufferedAmount > UPLOAD_BUFFERED_LOW_WATER) {
|
|
@@ -1510,13 +1520,13 @@ export class ReachyMini extends EventTarget {
|
|
|
1510
1520
|
body = await res.text();
|
|
1511
1521
|
}
|
|
1512
1522
|
catch { /* ignore */ }
|
|
1513
|
-
|
|
1523
|
+
log.warn(`/send rejected (${res.status}) for type=${message?.type}; body=${body || '<empty>'}`);
|
|
1514
1524
|
return null;
|
|
1515
1525
|
}
|
|
1516
1526
|
return await res.json();
|
|
1517
1527
|
}
|
|
1518
1528
|
catch (e) {
|
|
1519
|
-
|
|
1529
|
+
log.error('signaling /send failed:', e);
|
|
1520
1530
|
return null;
|
|
1521
1531
|
}
|
|
1522
1532
|
}
|
|
@@ -1526,11 +1536,70 @@ export class ReachyMini extends EventTarget {
|
|
|
1526
1536
|
this._dc.send(JSON.stringify(cmd));
|
|
1527
1537
|
return true;
|
|
1528
1538
|
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Call a JSON-RPC method on the robot/app over the DataChannel and await
|
|
1541
|
+
* its result. This is the one way to drive an on-robot app (start/stop it
|
|
1542
|
+
* via `apps.*`, or drive a running app via its own namespace, e.g.
|
|
1543
|
+
* `conversation.say`). Rejects on the JSON-RPC error, a closed channel, or
|
|
1544
|
+
* timeout.
|
|
1545
|
+
*/
|
|
1546
|
+
rpcCall(method, params = {}, opts = {}) {
|
|
1547
|
+
const timeoutMs = opts.timeoutMs ?? 20000;
|
|
1548
|
+
return this._pending.rpcRoundtrip(method, timeoutMs, (id) => this._sendCommand({ jsonrpc: '2.0', id, method, params }));
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Subscribe to a JSON-RPC notification (one-way event) pushed by the
|
|
1552
|
+
* robot/app, e.g. `conversation.turn`. Returns an unsubscribe function.
|
|
1553
|
+
*/
|
|
1554
|
+
onNotification(method, cb) {
|
|
1555
|
+
let set = this._rpcListeners.get(method);
|
|
1556
|
+
if (!set) {
|
|
1557
|
+
set = new Set();
|
|
1558
|
+
this._rpcListeners.set(method, set);
|
|
1559
|
+
}
|
|
1560
|
+
set.add(cb);
|
|
1561
|
+
return () => {
|
|
1562
|
+
this._rpcListeners.get(method)?.delete(cb);
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
_handleRpcMessage(data) {
|
|
1566
|
+
// Response to an rpcCall (correlated by id)...
|
|
1567
|
+
if (this._pending.settleRpcResponse(data))
|
|
1568
|
+
return;
|
|
1569
|
+
// ...or a one-way notification (event): dispatch to listeners.
|
|
1570
|
+
if (typeof data.method === 'string') {
|
|
1571
|
+
const params = data.params ?? {};
|
|
1572
|
+
for (const cb of this._rpcListeners.get(data.method) ?? []) {
|
|
1573
|
+
try {
|
|
1574
|
+
cb(params);
|
|
1575
|
+
}
|
|
1576
|
+
catch (e) {
|
|
1577
|
+
log.error(`onNotification(${data.method}) threw:`, e);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1529
1582
|
_checkSessionReady() {
|
|
1530
1583
|
if (this._iceConnected && this._dcOpen && this._sessionResolve) {
|
|
1531
1584
|
this._state = 'streaming';
|
|
1532
1585
|
this.requestState();
|
|
1533
|
-
|
|
1586
|
+
// Re-assert a pose subscription that was requested before the data
|
|
1587
|
+
// channel was open, or lost on reconnect (a fresh peer starts
|
|
1588
|
+
// unsubscribed on the daemon). Sent raw so it doesn't touch the
|
|
1589
|
+
// local refcount, which already reflects the live consumer count.
|
|
1590
|
+
if (this._poseSubRefs > 0)
|
|
1591
|
+
this._sendCommand({ type: 'subscribe_pose' });
|
|
1592
|
+
// A fresh session has received no pose frame yet, so a timestamp
|
|
1593
|
+
// left over from the previous one must not hold off the poll.
|
|
1594
|
+
this._lastPoseFrameAt = 0;
|
|
1595
|
+
this._stateRefreshInterval = setInterval(() => {
|
|
1596
|
+
// Skip while the pose stream is already feeding state; see
|
|
1597
|
+
// POSE_STREAM_FRESH_MS.
|
|
1598
|
+
if (Date.now() - this._lastPoseFrameAt < POSE_STREAM_FRESH_MS)
|
|
1599
|
+
return;
|
|
1600
|
+
this.requestState();
|
|
1601
|
+
}, 500);
|
|
1602
|
+
this._supervisor.startDcWatchdog();
|
|
1534
1603
|
this._emit('streaming', { sessionId: this._sessionId, robotId: this._selectedRobotId });
|
|
1535
1604
|
this._sessionResolve();
|
|
1536
1605
|
this._sessionResolve = null;
|
|
@@ -1581,10 +1650,14 @@ export class ReachyMini extends EventTarget {
|
|
|
1581
1650
|
if (this._sessionReject) {
|
|
1582
1651
|
const err = new Error(friendly || `Session ended before it could start: ${reason || 'unknown reason'}`);
|
|
1583
1652
|
err.reason = reason ?? null;
|
|
1584
|
-
|
|
1653
|
+
// Same suppression as _failSessionRejected: retries during an
|
|
1654
|
+
// auto-reconnect must not surface as `sessionRejected`.
|
|
1655
|
+
if (!this._supervisor.redialing) {
|
|
1656
|
+
this._emit('sessionRejected', { reason, activeApp: null });
|
|
1657
|
+
}
|
|
1585
1658
|
// Resilience teardown alongside the PC close path.
|
|
1586
|
-
this.
|
|
1587
|
-
this.
|
|
1659
|
+
this._supervisor.clearIceGrace();
|
|
1660
|
+
this._supervisor.uninstallNetworkListeners();
|
|
1588
1661
|
if (this._pc) {
|
|
1589
1662
|
this._pc.close();
|
|
1590
1663
|
this._pc = null;
|
|
@@ -1646,7 +1719,7 @@ export class ReachyMini extends EventTarget {
|
|
|
1646
1719
|
await this._pc.addIceCandidate(new RTCIceCandidate(ice));
|
|
1647
1720
|
}
|
|
1648
1721
|
catch (err) {
|
|
1649
|
-
|
|
1722
|
+
log.warn('buffered ICE candidate rejected:', err);
|
|
1650
1723
|
}
|
|
1651
1724
|
}
|
|
1652
1725
|
}
|
|
@@ -1670,72 +1743,76 @@ export class ReachyMini extends EventTarget {
|
|
|
1670
1743
|
}
|
|
1671
1744
|
}
|
|
1672
1745
|
catch (e) {
|
|
1673
|
-
|
|
1746
|
+
log.error('webrtc signaling failed:', e);
|
|
1674
1747
|
this._emit('error', { source: 'webrtc', error: e });
|
|
1675
1748
|
}
|
|
1676
1749
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1750
|
+
/**
|
|
1751
|
+
* @param fromPoseStream Frame came from the pushed `pose` channel rather
|
|
1752
|
+
* than the reliable control channel. Only those may refresh the state
|
|
1753
|
+
* mirror while the stream is live (see the `data.state` branch).
|
|
1754
|
+
*/
|
|
1755
|
+
_handleRobotMessage(data, fromPoseStream = false) {
|
|
1756
|
+
// Liveness stamp for the data-channel silence watchdog: every
|
|
1757
|
+
// inbound message — control replies, broadcasts, pose frames —
|
|
1758
|
+
// proves the transport is alive.
|
|
1759
|
+
this._supervisor.stampDcInbound();
|
|
1760
|
+
// JSON-RPC frames (app control surface) are handled separately from
|
|
1761
|
+
// the legacy {command|type} robot messages that share this channel.
|
|
1762
|
+
if (data.jsonrpc === '2.0') {
|
|
1763
|
+
this._handleRpcMessage(data);
|
|
1681
1764
|
return;
|
|
1682
1765
|
}
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1766
|
+
// Bare `version` / `hardware_id` replies carry no `command` key:
|
|
1767
|
+
// only swallow them when a waiter is actually pending, otherwise
|
|
1768
|
+
// let the message fall through to the branches below.
|
|
1769
|
+
if ('version' in data
|
|
1770
|
+
&& this._pending.settleReplySlot('version', data.version ?? null)) {
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
if ('hardware_id' in data
|
|
1774
|
+
&& this._pending.settleReplySlot('hardware_id', data.hardware_id ?? null)) {
|
|
1686
1775
|
return;
|
|
1687
1776
|
}
|
|
1688
1777
|
if (data.command === 'get_volume' || data.command === 'set_volume') {
|
|
1689
|
-
|
|
1690
|
-
this._volumeResolve(data.status === 'error' ? null : data.volume);
|
|
1691
|
-
this._volumeResolve = null;
|
|
1692
|
-
}
|
|
1778
|
+
this._pending.settleReplySlot('volume', data.status === 'error' ? null : data.volume);
|
|
1693
1779
|
return;
|
|
1694
1780
|
}
|
|
1695
1781
|
if (data.command === 'get_microphone_volume' || data.command === 'set_microphone_volume') {
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1782
|
+
this._pending.settleReplySlot('mic_volume', data.status === 'error' ? null : data.volume);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
if (data.command === 'get_first_wake_up' || data.command === 'set_first_wake_up') {
|
|
1786
|
+
this._pending.settleReplySlot('first_wake_up', data.status === 'error' ? null : !!data.is_completed);
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
if (data.command === 'get_robot_name' || data.command === 'set_robot_name') {
|
|
1790
|
+
this._pending.settleReplySlot('robot_name', data.status === 'error' ? null : (data.name ?? null));
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
if (data.command === 'delete_hf_token') {
|
|
1794
|
+
this._pending.settleReplySlot('delete_hf_token', data.status !== 'error');
|
|
1700
1795
|
return;
|
|
1701
1796
|
}
|
|
1702
1797
|
if (data.command === 'apply_audio_config') {
|
|
1703
|
-
|
|
1704
|
-
this._applyAudioConfigResolve(data.error ? false : !!data.applied);
|
|
1705
|
-
this._applyAudioConfigResolve = null;
|
|
1706
|
-
}
|
|
1798
|
+
this._pending.settleReplySlot('apply_audio_config', data.error ? false : !!data.applied);
|
|
1707
1799
|
return;
|
|
1708
1800
|
}
|
|
1709
1801
|
if (data.command === 'read_audio_parameter') {
|
|
1710
|
-
|
|
1711
|
-
this._readAudioParameterResolve(data.error ? null : (data.values ?? null));
|
|
1712
|
-
this._readAudioParameterResolve = null;
|
|
1713
|
-
}
|
|
1802
|
+
this._pending.settleReplySlot('read_audio_parameter', data.error ? null : (data.values ?? null));
|
|
1714
1803
|
return;
|
|
1715
1804
|
}
|
|
1716
1805
|
if (data.command === 'get_tracked_face') {
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1806
|
+
this._pending.settleReplySlot('tracked_face', data.face_target ?? null);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
if (data.command === 'get_imu') {
|
|
1810
|
+
this._pending.settleReplySlot('imu', data.imu ?? null);
|
|
1721
1811
|
return;
|
|
1722
1812
|
}
|
|
1723
1813
|
if ((data.command === 'wake_up' || data.command === 'goto_sleep')
|
|
1724
|
-
&& this.
|
|
1725
|
-
|
|
1726
|
-
const queue = this._pendingMotionCompletions[data.command];
|
|
1727
|
-
if (data.completed === true && queue.length > 0) {
|
|
1728
|
-
const entry = queue.shift();
|
|
1729
|
-
clearTimeout(entry.timer);
|
|
1730
|
-
entry.resolve();
|
|
1731
|
-
return;
|
|
1732
|
-
}
|
|
1733
|
-
if (data.error && queue.length > 0) {
|
|
1734
|
-
const entry = queue.shift();
|
|
1735
|
-
clearTimeout(entry.timer);
|
|
1736
|
-
entry.reject(new Error(`${data.command}: ${data.error}`));
|
|
1737
|
-
return;
|
|
1738
|
-
}
|
|
1814
|
+
&& this._pending.settleMotion(data.command, data)) {
|
|
1815
|
+
return;
|
|
1739
1816
|
}
|
|
1740
1817
|
if (data.type === 'log_line') {
|
|
1741
1818
|
for (const sub of this._logSubscribers) {
|
|
@@ -1743,7 +1820,7 @@ export class ReachyMini extends EventTarget {
|
|
|
1743
1820
|
sub.onLine({ timestamp: data.timestamp, line: data.line });
|
|
1744
1821
|
}
|
|
1745
1822
|
catch (e) {
|
|
1746
|
-
|
|
1823
|
+
log.error('subscribeLogs onLine threw:', e);
|
|
1747
1824
|
}
|
|
1748
1825
|
}
|
|
1749
1826
|
return;
|
|
@@ -1755,7 +1832,7 @@ export class ReachyMini extends EventTarget {
|
|
|
1755
1832
|
sub.onError(data.error);
|
|
1756
1833
|
}
|
|
1757
1834
|
catch (e) {
|
|
1758
|
-
|
|
1835
|
+
log.error('subscribeLogs onError threw:', e);
|
|
1759
1836
|
}
|
|
1760
1837
|
}
|
|
1761
1838
|
}
|
|
@@ -1773,7 +1850,7 @@ export class ReachyMini extends EventTarget {
|
|
|
1773
1850
|
cb(event);
|
|
1774
1851
|
}
|
|
1775
1852
|
catch (e) {
|
|
1776
|
-
|
|
1853
|
+
log.error('startDaemonUpdate onProgress threw:', e);
|
|
1777
1854
|
}
|
|
1778
1855
|
}
|
|
1779
1856
|
}
|
|
@@ -1790,17 +1867,30 @@ export class ReachyMini extends EventTarget {
|
|
|
1790
1867
|
cb(event);
|
|
1791
1868
|
}
|
|
1792
1869
|
catch (e) {
|
|
1793
|
-
|
|
1870
|
+
log.error('startDaemonUpdate onProgress threw:', e);
|
|
1794
1871
|
}
|
|
1795
1872
|
}
|
|
1796
1873
|
return;
|
|
1797
1874
|
}
|
|
1798
|
-
|
|
1875
|
+
// Only the stream may write the mirror while the stream is live. The
|
|
1876
|
+
// poll stands down in that case (see POSE_STREAM_FRESH_MS), but it
|
|
1877
|
+
// can't unsend a request already in flight: `get_state` rides the
|
|
1878
|
+
// reliable channel, so its reply queues behind whatever else is on it
|
|
1879
|
+
// - a `upload_move_*` burst, typically - and can land hundreds of ms
|
|
1880
|
+
// after the snapshot it carries. Having no `seq`, it slips past the
|
|
1881
|
+
// stale-frame guard and rewinds every consumer to a pose from before
|
|
1882
|
+
// the upload, until the next pushed frame puts them back. That reads
|
|
1883
|
+
// as a one-frame flick to the pre-move pose right as an animation
|
|
1884
|
+
// starts. Nothing is lost by dropping it: pushed frames carry the same
|
|
1885
|
+
// fields (daemon-side `build_state_dict` feeds both).
|
|
1886
|
+
if (data.state && (fromPoseStream || Date.now() - this._lastPoseFrameAt >= POSE_STREAM_FRESH_MS)) {
|
|
1799
1887
|
const s = data.state;
|
|
1800
1888
|
if (s.head_pose)
|
|
1801
1889
|
this._robotState.head = s.head_pose.flat();
|
|
1802
1890
|
if (s.antennas)
|
|
1803
1891
|
this._robotState.antennas = [s.antennas[0], s.antennas[1]];
|
|
1892
|
+
if (s.head_joint_positions)
|
|
1893
|
+
this._robotState.head_joint_positions = s.head_joint_positions;
|
|
1804
1894
|
if (typeof s.body_yaw === 'number')
|
|
1805
1895
|
this._robotState.body_yaw = s.body_yaw;
|
|
1806
1896
|
if (s.motor_mode)
|
|
@@ -1809,22 +1899,17 @@ export class ReachyMini extends EventTarget {
|
|
|
1809
1899
|
this._robotState.is_move_running = s.is_move_running;
|
|
1810
1900
|
if (s.face_target)
|
|
1811
1901
|
this._robotState.face_target = s.face_target;
|
|
1902
|
+
// DoA is null when there's no mic array / no reading yet - reflect
|
|
1903
|
+
// that by clearing our mirror so stale angles don't linger.
|
|
1904
|
+
if ('doa' in s)
|
|
1905
|
+
this._robotState.doa = s.doa ?? undefined;
|
|
1812
1906
|
this._emit('state', { ...this._robotState });
|
|
1813
1907
|
}
|
|
1814
1908
|
if (data.error) {
|
|
1815
1909
|
this._emit('error', { source: 'robot', error: data.error });
|
|
1816
1910
|
}
|
|
1817
|
-
if (this.
|
|
1818
|
-
|
|
1819
|
-
const slot = this._broadcastWaiters[i];
|
|
1820
|
-
if (slot.predicate(data)) {
|
|
1821
|
-
this._broadcastWaiters.splice(i, 1);
|
|
1822
|
-
clearTimeout(slot.timer);
|
|
1823
|
-
slot.resolve(data);
|
|
1824
|
-
return;
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1911
|
+
if (this._pending.matchBroadcast(data))
|
|
1912
|
+
return;
|
|
1828
1913
|
}
|
|
1829
1914
|
/** Snap video playback to live edge if buffered lag exceeds 0.5 s. */
|
|
1830
1915
|
_startLatencyMonitor(video) {
|
|
@@ -1838,7 +1923,7 @@ export class ReachyMini extends EventTarget {
|
|
|
1838
1923
|
const end = buf.end(buf.length - 1);
|
|
1839
1924
|
const lag = end - video.currentTime;
|
|
1840
1925
|
if (lag > 0.5) {
|
|
1841
|
-
|
|
1926
|
+
log.debug(`video latency correction: was ${lag.toFixed(2)}s behind`);
|
|
1842
1927
|
video.currentTime = end - 0.1;
|
|
1843
1928
|
}
|
|
1844
1929
|
}
|