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