@pollen-robotics/reachy-mini-sdk 1.8.0 → 1.8.1
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 +1795 -0
- package/README.md +2 -5
- package/dist/animation/distance.d.ts +87 -0
- package/dist/animation/distance.d.ts.map +1 -0
- package/dist/animation/distance.js +140 -0
- package/dist/animation/distance.js.map +1 -0
- package/dist/animation/index.d.ts +34 -0
- package/dist/animation/index.d.ts.map +1 -0
- package/dist/animation/index.js +33 -0
- package/dist/animation/index.js.map +1 -0
- package/dist/animation/pose.d.ts +40 -0
- package/dist/animation/pose.d.ts.map +1 -0
- package/dist/animation/pose.js +15 -0
- package/dist/animation/pose.js.map +1 -0
- package/dist/animation/presets.d.ts +85 -0
- package/dist/animation/presets.d.ts.map +1 -0
- package/dist/animation/presets.js +82 -0
- package/dist/animation/presets.js.map +1 -0
- package/dist/animation/safe-return.d.ts +90 -0
- package/dist/animation/safe-return.d.ts.map +1 -0
- package/dist/animation/safe-return.js +123 -0
- package/dist/animation/safe-return.js.map +1 -0
- package/dist/lib/reachy-mini.d.ts +99 -0
- package/dist/lib/reachy-mini.d.ts.map +1 -1
- package/dist/lib/reachy-mini.js +314 -9
- package/dist/lib/reachy-mini.js.map +1 -1
- package/dist/lib/types.d.ts +27 -0
- package/dist/lib/types.d.ts.map +1 -1
- package/host/README.md +14 -12
- package/host/dist/ReachyHost.d.ts +2 -1
- package/host/dist/ReachyHost.d.ts.map +1 -1
- package/host/dist/chunks/index-lMs2sCXG.js +439 -0
- package/host/dist/chunks/{mountHost-D7cgkU9Q.js → mountHost-DjE5zE9R.js} +11142 -11014
- package/host/dist/chunks/{reachy-mini-B1hlBmDQ.js → reachy-mini-GHNS4GSp.js} +494 -307
- package/host/dist/components/PickerView.d.ts.map +1 -1
- package/host/dist/components/PostOAuthSplash.d.ts +25 -0
- package/host/dist/components/PostOAuthSplash.d.ts.map +1 -0
- package/host/dist/components/ReachyHostShell.d.ts.map +1 -1
- package/host/dist/components/TopBar.d.ts +8 -1
- 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 +3 -2
- package/host/dist/embed/index.d.ts.map +1 -1
- package/host/dist/entry/auto.js +2 -2
- package/host/dist/entry/embed.js +2 -2
- package/host/dist/hooks/useHostBridge.d.ts +6 -0
- package/host/dist/hooks/useHostBridge.d.ts.map +1 -1
- package/host/dist/hooks/useSdk.d.ts.map +1 -1
- package/host/dist/index.js +3 -3
- package/host/dist/lib/protocol.d.ts +17 -3
- package/host/dist/lib/protocol.d.ts.map +1 -1
- package/host/dist/lib/settings.d.ts +3 -2
- package/host/dist/lib/settings.d.ts.map +1 -1
- package/host/dist/mountHost.d.ts.map +1 -1
- package/host/dist/ui/design/IdentityChipBar.d.ts +46 -0
- package/host/dist/ui/design/IdentityChipBar.d.ts.map +1 -0
- package/host/dist/ui/design/LinkQualityBars.d.ts +32 -0
- package/host/dist/ui/design/LinkQualityBars.d.ts.map +1 -0
- package/host/dist/ui/design/MetaPill.d.ts +41 -0
- package/host/dist/ui/design/MetaPill.d.ts.map +1 -0
- package/host/dist/ui/design/TransportChip.d.ts +39 -0
- package/host/dist/ui/design/TransportChip.d.ts.map +1 -0
- package/package.json +7 -4
- package/CHANGELOG.md +0 -188
- package/host/APP_AUTHOR_GUIDE.md +0 -646
- package/host/SPEC.md +0 -618
- package/host/dist/chunks/index-CyLPysJS.js +0 -400
package/dist/lib/reachy-mini.js
CHANGED
|
@@ -7,6 +7,39 @@ import { oauthHandleRedirectIfPresent, oauthLoginUrl, } from '@huggingface/hub';
|
|
|
7
7
|
import { degToRad, rpyToMatrix } from './math.js';
|
|
8
8
|
import { consumeFragmentCredentials, readPreselectedRobotIdFromUrl, sdpHasAudioSendRecv, } from './url-helpers.js';
|
|
9
9
|
import { UPLOAD_CHUNK_SIZE, UPLOAD_BUFFERED_HIGH_WATER, UPLOAD_BUFFERED_LOW_WATER, hasCompressionStream, makeUploadId, bytesToBase64, gzipBase64, clampVolume, } from './upload-helpers.js';
|
|
10
|
+
// ─── Internal constants ──────────────────────────────────────────────────────
|
|
11
|
+
/**
|
|
12
|
+
* How long we tolerate `iceConnectionState === 'disconnected'` before
|
|
13
|
+
* surfacing it as an error. The spec defines this state as transient
|
|
14
|
+
* (browsers keep STUN keep-alives running and usually heal in 1-2 s
|
|
15
|
+
* on WiFi blips, AP roams, brief 4G dropouts). Consumers watching
|
|
16
|
+
* `iceStateChange` directly should outlive this window before
|
|
17
|
+
* showing any fatal UI.
|
|
18
|
+
*/
|
|
19
|
+
const ICE_DISCONNECT_GRACE_MS = 3000;
|
|
20
|
+
/**
|
|
21
|
+
* Grace before treating `iceConnectionState === 'failed'` as terminal.
|
|
22
|
+
* The spec says `failed` IS terminal, but we've observed real
|
|
23
|
+
* `failed → connected` flips on rapid AP roams and iOS BT route
|
|
24
|
+
* changes — 1 s of debounce absorbs those without noticeably
|
|
25
|
+
* delaying a real failure.
|
|
26
|
+
*/
|
|
27
|
+
const ICE_FAILED_GRACE_MS = 1000;
|
|
28
|
+
/**
|
|
29
|
+
* Ceiling on how long we'll keep `_armIceGraceOnVisibility` waiting
|
|
30
|
+
* for the tab to come back. The daemon's `webrtcsink` runs a STUN
|
|
31
|
+
* consent-freshness check (RFC 7675, ~30 s default) and unilaterally
|
|
32
|
+
* tears its side of the session down past that window, releasing the
|
|
33
|
+
* producer slot on central. If the user backgrounded the tab for
|
|
34
|
+
* longer than this, running another 3 s foreground grace is a lie —
|
|
35
|
+
* the underlying transport is gone, nothing can recover. Give up
|
|
36
|
+
* straight away so the host shows the real "session expired" UX
|
|
37
|
+
* instead of a fake "Reconnecting…" badge that's never going to
|
|
38
|
+
* heal. 60 s gives a 2× margin over the daemon-side timeout — long
|
|
39
|
+
* enough to absorb a "phone in pocket for 45 s" case, short enough
|
|
40
|
+
* to be honest with the user.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_VISIBILITY_DEFER_MS = 60_000;
|
|
10
43
|
export class ReachyMini extends EventTarget {
|
|
11
44
|
// ─── Config ──────────────────────────────────────────────────────────
|
|
12
45
|
_signalingUrl;
|
|
@@ -62,6 +95,19 @@ export class ReachyMini extends EventTarget {
|
|
|
62
95
|
_sessionReject = null;
|
|
63
96
|
_iceConnected = false;
|
|
64
97
|
_dcOpen = false;
|
|
98
|
+
// ─── Resilience: ICE-blip debounce + network awareness ──────────────
|
|
99
|
+
// Backs `_scheduleIceGrace` / `_armIceGraceOnVisibility` and the
|
|
100
|
+
// `networkOnline` / `networkOffline` / `networkChange` forwarders.
|
|
101
|
+
// All three handler slots are scoped to the lifetime of a live
|
|
102
|
+
// session (installed in `startSession`, cleared in
|
|
103
|
+
// `stopSession` / `disconnect` / `_handleEndSession` /
|
|
104
|
+
// `_failSessionRejected`).
|
|
105
|
+
_iceGraceTimer = null;
|
|
106
|
+
_iceGraceReason = null;
|
|
107
|
+
_pendingVisibilityHandler = null;
|
|
108
|
+
_onlineHandler = null;
|
|
109
|
+
_offlineHandler = null;
|
|
110
|
+
_connectionChangeHandler = null;
|
|
65
111
|
// ─── Motion completion plumbing (wake_up / goto_sleep) ───────────────
|
|
66
112
|
_pendingMotionCompletions = {
|
|
67
113
|
wake_up: [],
|
|
@@ -418,6 +464,9 @@ export class ReachyMini extends EventTarget {
|
|
|
418
464
|
this._pc = new RTCPeerConnection({
|
|
419
465
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
|
420
466
|
});
|
|
467
|
+
// Scope `networkOnline` / `networkOffline` / `networkChange`
|
|
468
|
+
// event forwarding to the lifetime of this session.
|
|
469
|
+
this._installNetworkListeners();
|
|
421
470
|
return new Promise((resolve, reject) => {
|
|
422
471
|
this._sessionResolve = resolve;
|
|
423
472
|
this._sessionReject = reject;
|
|
@@ -454,21 +503,38 @@ export class ReachyMini extends EventTarget {
|
|
|
454
503
|
const s = this._pc?.iceConnectionState;
|
|
455
504
|
if (!s)
|
|
456
505
|
return;
|
|
506
|
+
// Public, granular event: every transition is visible to
|
|
507
|
+
// consumers so they can render finer UX (e.g. a transient
|
|
508
|
+
// "Reconnecting…" badge during `disconnected`) without
|
|
509
|
+
// having to attach their own handler to `_pc`.
|
|
510
|
+
this._emit('iceStateChange', { state: s });
|
|
457
511
|
if (s === 'connected' || s === 'completed') {
|
|
512
|
+
// Healed — cancel any pending grace from a previous blip.
|
|
513
|
+
this._clearIceGrace();
|
|
458
514
|
this._iceConnected = true;
|
|
459
515
|
this._checkSessionReady();
|
|
516
|
+
return;
|
|
460
517
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
518
|
+
if (s === 'disconnected') {
|
|
519
|
+
// TRANSIENT per spec — debounce before escalating.
|
|
520
|
+
// If the tab is hidden, JS timers are throttled and
|
|
521
|
+
// would fire unpredictably late, so defer the grace
|
|
522
|
+
// window to the next foreground frame.
|
|
523
|
+
if (typeof document !== 'undefined' && document.hidden) {
|
|
524
|
+
this._armIceGraceOnVisibility();
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
this._scheduleIceGrace(ICE_DISCONNECT_GRACE_MS, 'disconnected');
|
|
467
528
|
}
|
|
468
|
-
|
|
529
|
+
return;
|
|
469
530
|
}
|
|
470
|
-
|
|
471
|
-
|
|
531
|
+
if (s === 'failed') {
|
|
532
|
+
// Terminal per spec, but in practice we've seen
|
|
533
|
+
// `failed → connected` on rapid AP roams / BT route
|
|
534
|
+
// changes on iOS. Give the ICE agent a short window
|
|
535
|
+
// to surprise us before rejecting the session.
|
|
536
|
+
this._scheduleIceGrace(ICE_FAILED_GRACE_MS, 'failed');
|
|
537
|
+
return;
|
|
472
538
|
}
|
|
473
539
|
};
|
|
474
540
|
this._pc.ondatachannel = (e) => {
|
|
@@ -495,6 +561,10 @@ export class ReachyMini extends EventTarget {
|
|
|
495
561
|
: `Session rejected: ${msg.reason || 'unknown reason'}`);
|
|
496
562
|
err.reason = msg.reason ?? null;
|
|
497
563
|
err.activeApp = msg.activeApp ?? null;
|
|
564
|
+
// Resilience teardown BEFORE closing `_pc` so a queued grace
|
|
565
|
+
// callback can't dereference a dead handle.
|
|
566
|
+
this._clearIceGrace();
|
|
567
|
+
this._uninstallNetworkListeners();
|
|
498
568
|
if (this._pc) {
|
|
499
569
|
this._pc.close();
|
|
500
570
|
this._pc = null;
|
|
@@ -542,6 +612,10 @@ export class ReachyMini extends EventTarget {
|
|
|
542
612
|
}
|
|
543
613
|
this._logSubscribers.clear();
|
|
544
614
|
this._rejectPendingMotionCompletions(new Error('Session stopped'));
|
|
615
|
+
// Tear down resilience plumbing BEFORE closing `_pc` so a
|
|
616
|
+
// queued grace callback can't dereference a dead handle.
|
|
617
|
+
this._clearIceGrace();
|
|
618
|
+
this._uninstallNetworkListeners();
|
|
545
619
|
if (this._sessionReject) {
|
|
546
620
|
this._sessionReject(new Error('Session stopped'));
|
|
547
621
|
this._sessionResolve = null;
|
|
@@ -612,6 +686,9 @@ export class ReachyMini extends EventTarget {
|
|
|
612
686
|
}
|
|
613
687
|
this._logSubscribers.clear();
|
|
614
688
|
this._rejectPendingMotionCompletions(new Error('Disconnected'));
|
|
689
|
+
// Mirrors the resilience teardown in `stopSession()`.
|
|
690
|
+
this._clearIceGrace();
|
|
691
|
+
this._uninstallNetworkListeners();
|
|
615
692
|
if (this._sessionReject) {
|
|
616
693
|
this._sessionReject(new Error('Disconnected'));
|
|
617
694
|
this._sessionResolve = null;
|
|
@@ -649,7 +726,229 @@ export class ReachyMini extends EventTarget {
|
|
|
649
726
|
this._state = 'disconnected';
|
|
650
727
|
this._emit('disconnected', { reason: 'user' });
|
|
651
728
|
}
|
|
729
|
+
// ─── Resilience: ICE-blip debounce + network awareness ───────────────
|
|
730
|
+
//
|
|
731
|
+
// Both halves below are intentionally generic (they don't know about
|
|
732
|
+
// motion, audio, or the FSM): they just smooth out browser-level
|
|
733
|
+
// events so the consumer's own state machine doesn't get torn down
|
|
734
|
+
// by routine WiFi/4G/screen-off noise.
|
|
735
|
+
/**
|
|
736
|
+
* Cancel any pending ICE grace timer and visibility handler. Called
|
|
737
|
+
* on a healed `connected`/`completed` transition AND from the
|
|
738
|
+
* lifecycle teardown paths so a callback can't fire after `_pc`
|
|
739
|
+
* is closed.
|
|
740
|
+
*/
|
|
741
|
+
_clearIceGrace() {
|
|
742
|
+
if (this._iceGraceTimer !== null) {
|
|
743
|
+
clearTimeout(this._iceGraceTimer);
|
|
744
|
+
this._iceGraceTimer = null;
|
|
745
|
+
}
|
|
746
|
+
this._iceGraceReason = null;
|
|
747
|
+
if (this._pendingVisibilityHandler && typeof document !== 'undefined') {
|
|
748
|
+
document.removeEventListener('visibilitychange', this._pendingVisibilityHandler);
|
|
749
|
+
}
|
|
750
|
+
this._pendingVisibilityHandler = null;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Start a grace window. After `ms`, re-check the live ICE state:
|
|
754
|
+
* - If we healed back to `connected`/`completed`, the timer was
|
|
755
|
+
* already cancelled in `oniceconnectionstatechange`, so we
|
|
756
|
+
* never get here.
|
|
757
|
+
* - If we're still in the originally-observed bad state (or
|
|
758
|
+
* worse), surface the error and reject any pending session
|
|
759
|
+
* promise. The original code path is preserved verbatim so
|
|
760
|
+
* downstream consumers see the same `error` payload shape.
|
|
761
|
+
*/
|
|
762
|
+
_scheduleIceGrace(ms, reason) {
|
|
763
|
+
// Coalesce: if a grace is already pending and the reason hasn't
|
|
764
|
+
// changed, keep the original timer so a flurry of identical
|
|
765
|
+
// transitions doesn't reset the clock. If the reason changed
|
|
766
|
+
// (typically `disconnected` → `failed`, but also the reverse on
|
|
767
|
+
// some Android WebViews), replace the timer with the new
|
|
768
|
+
// (reason, ms) pair — the latest signal wins.
|
|
769
|
+
if (this._iceGraceTimer !== null) {
|
|
770
|
+
if (this._iceGraceReason === reason)
|
|
771
|
+
return;
|
|
772
|
+
clearTimeout(this._iceGraceTimer);
|
|
773
|
+
}
|
|
774
|
+
this._iceGraceReason = reason;
|
|
775
|
+
this._iceGraceTimer = setTimeout(() => {
|
|
776
|
+
this._iceGraceTimer = null;
|
|
777
|
+
const r = this._iceGraceReason;
|
|
778
|
+
this._iceGraceReason = null;
|
|
779
|
+
const s = this._pc?.iceConnectionState;
|
|
780
|
+
if (s === 'connected' || s === 'completed')
|
|
781
|
+
return; // healed
|
|
782
|
+
if (r === 'disconnected' && s === 'disconnected') {
|
|
783
|
+
this._emit('error', {
|
|
784
|
+
source: 'webrtc',
|
|
785
|
+
error: new Error(`ICE stuck in 'disconnected' for > ${ms}ms`),
|
|
786
|
+
});
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (r === 'failed' || s === 'failed') {
|
|
790
|
+
const err = new Error('ICE connection failed');
|
|
791
|
+
if (this._sessionReject) {
|
|
792
|
+
this._sessionReject(err);
|
|
793
|
+
this._sessionResolve = null;
|
|
794
|
+
this._sessionReject = null;
|
|
795
|
+
}
|
|
796
|
+
this._emit('error', { source: 'webrtc', error: err });
|
|
797
|
+
}
|
|
798
|
+
}, ms);
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* `disconnected` while the tab is hidden. JS timers are throttled
|
|
802
|
+
* in background tabs (Chrome clamps to ~1 Hz, Safari can pause
|
|
803
|
+
* altogether), so a foreground grace timer would either miss the
|
|
804
|
+
* window or fire long after the connection healed. Wait for the
|
|
805
|
+
* tab to come back, then re-evaluate.
|
|
806
|
+
*/
|
|
807
|
+
_armIceGraceOnVisibility() {
|
|
808
|
+
if (this._pendingVisibilityHandler)
|
|
809
|
+
return;
|
|
810
|
+
const deferredAt = Date.now();
|
|
811
|
+
const handler = () => {
|
|
812
|
+
if (typeof document !== 'undefined' && document.hidden)
|
|
813
|
+
return;
|
|
814
|
+
document.removeEventListener('visibilitychange', handler);
|
|
815
|
+
this._pendingVisibilityHandler = null;
|
|
816
|
+
if (!this._pc)
|
|
817
|
+
return;
|
|
818
|
+
const s = this._pc.iceConnectionState;
|
|
819
|
+
if (s === 'connected' || s === 'completed')
|
|
820
|
+
return; // healed in bg
|
|
821
|
+
// Ceiling: if the user backgrounded past the daemon's
|
|
822
|
+
// ICE-consent freshness window the session is gone from
|
|
823
|
+
// the daemon's side regardless of what `_pc` reports
|
|
824
|
+
// locally. Running another foreground grace would tell
|
|
825
|
+
// the user "Reconnecting…" for a recovery that can never
|
|
826
|
+
// happen. Escalate immediately so the host renders the
|
|
827
|
+
// real "session expired" UX. See MAX_VISIBILITY_DEFER_MS.
|
|
828
|
+
if (Date.now() - deferredAt > MAX_VISIBILITY_DEFER_MS) {
|
|
829
|
+
const err = new Error('Session expired while tab was backgrounded');
|
|
830
|
+
if (this._sessionReject) {
|
|
831
|
+
this._sessionReject(err);
|
|
832
|
+
this._sessionResolve = null;
|
|
833
|
+
this._sessionReject = null;
|
|
834
|
+
}
|
|
835
|
+
this._emit('error', { source: 'webrtc', error: err });
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (s === 'failed') {
|
|
839
|
+
this._scheduleIceGrace(ICE_FAILED_GRACE_MS, 'failed');
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
// Still disconnected when we came back — give it a normal
|
|
843
|
+
// foreground grace window now that timers fire reliably.
|
|
844
|
+
this._scheduleIceGrace(ICE_DISCONNECT_GRACE_MS, 'disconnected');
|
|
845
|
+
};
|
|
846
|
+
document.addEventListener('visibilitychange', handler);
|
|
847
|
+
this._pendingVisibilityHandler = handler;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Install browser-level network listeners and forward them as
|
|
851
|
+
* public `networkOnline` / `networkOffline` / `networkChange`
|
|
852
|
+
* events on this instance. Idempotent: called from
|
|
853
|
+
* `startSession()`, removed by `_uninstallNetworkListeners` on
|
|
854
|
+
* teardown. Reachable only when there's a live `window`
|
|
855
|
+
* (defensive guard for SSR / test environments).
|
|
856
|
+
*
|
|
857
|
+
* `online` / `offline` are semantically about CONNECTIVITY:
|
|
858
|
+
* "does the OS think we can reach the internet". They flip
|
|
859
|
+
* symmetrically.
|
|
860
|
+
*
|
|
861
|
+
* `connection.change` (NetworkInformation API, Chrome / Android
|
|
862
|
+
* WebView only) is semantically about the TRANSPORT: it fires
|
|
863
|
+
* on Wi-Fi → 4G swaps, AP roams, etc. without necessarily going
|
|
864
|
+
* through `offline`. We forward it as its own `networkChange`
|
|
865
|
+
* event rather than aliasing it onto `networkOnline`, so
|
|
866
|
+
* consumers don't have to guess whether they're seeing a real
|
|
867
|
+
* connectivity recovery or a silent transport swap.
|
|
868
|
+
*/
|
|
869
|
+
_installNetworkListeners() {
|
|
870
|
+
if (this._onlineHandler || typeof window === 'undefined')
|
|
871
|
+
return;
|
|
872
|
+
const onOnline = () => this._emit('networkOnline', {});
|
|
873
|
+
const onOffline = () => this._emit('networkOffline', {});
|
|
874
|
+
window.addEventListener('online', onOnline);
|
|
875
|
+
window.addEventListener('offline', onOffline);
|
|
876
|
+
this._onlineHandler = onOnline;
|
|
877
|
+
this._offlineHandler = onOffline;
|
|
878
|
+
const conn = navigator.connection;
|
|
879
|
+
if (conn && typeof conn.addEventListener === 'function') {
|
|
880
|
+
const onChange = () => this._emit('networkChange', {
|
|
881
|
+
effectiveType: conn.effectiveType,
|
|
882
|
+
downlink: conn.downlink,
|
|
883
|
+
rtt: conn.rtt,
|
|
884
|
+
saveData: conn.saveData,
|
|
885
|
+
});
|
|
886
|
+
conn.addEventListener('change', onChange);
|
|
887
|
+
this._connectionChangeHandler = onChange;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
/** Counterpart to `_installNetworkListeners`. */
|
|
891
|
+
_uninstallNetworkListeners() {
|
|
892
|
+
if (typeof window !== 'undefined') {
|
|
893
|
+
if (this._onlineHandler) {
|
|
894
|
+
window.removeEventListener('online', this._onlineHandler);
|
|
895
|
+
}
|
|
896
|
+
if (this._offlineHandler) {
|
|
897
|
+
window.removeEventListener('offline', this._offlineHandler);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
const conn = navigator.connection;
|
|
901
|
+
if (conn && this._connectionChangeHandler && typeof conn.removeEventListener === 'function') {
|
|
902
|
+
conn.removeEventListener('change', this._connectionChangeHandler);
|
|
903
|
+
}
|
|
904
|
+
this._onlineHandler = null;
|
|
905
|
+
this._offlineHandler = null;
|
|
906
|
+
this._connectionChangeHandler = null;
|
|
907
|
+
}
|
|
652
908
|
// ─── Commands ────────────────────────────────────────────────────────
|
|
909
|
+
/**
|
|
910
|
+
* Atomic raw-units pose update over the data channel. Channels you
|
|
911
|
+
* omit are held at their last commanded value (per-axis, independent).
|
|
912
|
+
*
|
|
913
|
+
* **Head pose is in the WORLD frame.** The daemon's IK splits the
|
|
914
|
+
* requested head world-yaw between body rotation and the stewart
|
|
915
|
+
* platform, subject to the mechanical limit
|
|
916
|
+
* `|head_yaw_world − body_yaw| ≤ 65°`.
|
|
917
|
+
*
|
|
918
|
+
* **If you want the head to FOLLOW the body** (tank-style rotation):
|
|
919
|
+
* a `setTarget({ body_yaw })` on its own does NOT rotate the head —
|
|
920
|
+
* the head's commanded world yaw is unchanged, so its gaze stays
|
|
921
|
+
* pinned in world frame while the body turns under it. To make the
|
|
922
|
+
* head turn with the body, include a `head` matrix in the SAME call
|
|
923
|
+
* with the body-yaw delta added to the head RPY's yaw:
|
|
924
|
+
*
|
|
925
|
+
* ```ts
|
|
926
|
+
* // Body-yaw drag handler: tank-couple the head so it follows.
|
|
927
|
+
* const delta = newBodyDeg - lastCommandedBodyDeg;
|
|
928
|
+
* const nextHeadYaw = lastCommandedHeadYawDeg + delta;
|
|
929
|
+
* robot.setTarget({
|
|
930
|
+
* head: rpyToMatrix(headRoll, headPitch, nextHeadYaw).flat(),
|
|
931
|
+
* body_yaw: degToRad(newBodyDeg),
|
|
932
|
+
* });
|
|
933
|
+
* lastCommandedHeadYawDeg = nextHeadYaw;
|
|
934
|
+
* lastCommandedBodyDeg = newBodyDeg;
|
|
935
|
+
* ```
|
|
936
|
+
*
|
|
937
|
+
* **Baseline must be the last COMMANDED value, not telemetry.** For
|
|
938
|
+
* continuous-input controllers (slider drag, joystick), do not use
|
|
939
|
+
* `state.head` from the `state` event as the baseline for incremental
|
|
940
|
+
* commands — telemetry lags one WebRTC round-trip, so cumulative
|
|
941
|
+
* deltas computed against it stall (every iteration in a rapid drag
|
|
942
|
+
* adds the same `delta` to the same stale baseline → the head fails
|
|
943
|
+
* to keep up). Track the last-commanded RPY in your own buffer.
|
|
944
|
+
*
|
|
945
|
+
* @param head Flat row-major 4×4 matrix (16 finite numbers) in
|
|
946
|
+
* the world frame. Omit to hold the previous head target.
|
|
947
|
+
* @param antennas `[rightRad, leftRad]` (radians). Omit to hold.
|
|
948
|
+
* @param body_yaw Signed radians. Omit to hold.
|
|
949
|
+
* @returns `true` if the command was queued on the data channel,
|
|
950
|
+
* `false` if the channel is not open.
|
|
951
|
+
*/
|
|
653
952
|
setTarget({ head, antennas, body_yaw } = {}) {
|
|
654
953
|
const cmd = { type: 'set_full_target' };
|
|
655
954
|
if (head !== undefined) {
|
|
@@ -718,6 +1017,9 @@ export class ReachyMini extends EventTarget {
|
|
|
718
1017
|
playSound(file) {
|
|
719
1018
|
return this._sendCommand({ type: 'play_sound', file });
|
|
720
1019
|
}
|
|
1020
|
+
clearIncomingAudio() {
|
|
1021
|
+
return this._sendCommand({ type: 'clear_incoming_audio' });
|
|
1022
|
+
}
|
|
721
1023
|
setMotorMode(mode) {
|
|
722
1024
|
return this._sendCommand({ type: 'set_motor_mode', mode });
|
|
723
1025
|
}
|
|
@@ -1232,6 +1534,9 @@ export class ReachyMini extends EventTarget {
|
|
|
1232
1534
|
const err = new Error(friendly || `Session ended before it could start: ${reason || 'unknown reason'}`);
|
|
1233
1535
|
err.reason = reason ?? null;
|
|
1234
1536
|
this._emit('sessionRejected', { reason, activeApp: null });
|
|
1537
|
+
// Resilience teardown alongside the PC close path.
|
|
1538
|
+
this._clearIceGrace();
|
|
1539
|
+
this._uninstallNetworkListeners();
|
|
1235
1540
|
if (this._pc) {
|
|
1236
1541
|
this._pc.close();
|
|
1237
1542
|
this._pc = null;
|