Spaces:
Running
Running
Revert "Rebuild as Reachy Mini Dashboard on the modern host-shell pattern"
Browse filesThis reverts commit 1e1d196871c83d2a0cb4e821365a3f9d63309396.
- README.md +19 -104
- assets/auto-CcEOuy4Q.js +0 -0
- assets/embed-DSsDnZHG.js +0 -0
- assets/index-CsmyDA4N.js +0 -4
- assets/protocol-yMW1n-O_.js +0 -3
- icon.svg +0 -17
- index.html +1153 -93
- style.css +467 -0
README.md
CHANGED
|
@@ -1,120 +1,35 @@
|
|
| 1 |
---
|
| 2 |
-
title: Reachy Mini
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
hf_oauth: true
|
| 9 |
hf_oauth_expiration_minutes: 480
|
| 10 |
-
short_description: One-screen control surface for a Reachy Mini robot.
|
| 11 |
tags:
|
| 12 |
-
|
| 13 |
-
|
| 14 |
---
|
| 15 |
|
| 16 |
-
# Reachy Mini
|
| 17 |
|
| 18 |
-
|
| 19 |
-
single screen. Built on the modern host-shell pattern documented in
|
| 20 |
-
the SDK's [`ts/APP_CREATION_GUIDE.md`](https://github.com/pollen-robotics/reachy_mini/blob/main/ts/APP_CREATION_GUIDE.md).
|
| 21 |
-
|
| 22 |
-
The host (OAuth, robot picker, leave flow) lives outside the iframe;
|
| 23 |
-
this app renders the full control surface inside the embed.
|
| 24 |
|
| 25 |
## Features
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
| **Audio** | Mic + speaker toggles, gain sliders both directions, FFT spectrum bars on each channel. |
|
| 31 |
-
| **Head** | Roll / pitch / yaw sliders, two-way bound to daemon state, atomic frame on every change. |
|
| 32 |
-
| **Body+Ant**| Body yaw (tank-coupled to head yaw), right / left antennas. |
|
| 33 |
-
| **Motion** | Wake-up, sleep, torque toggle (auto-labels with current motor mode). |
|
| 34 |
-
| **Sound** | Free-text file name + preset chips, fire-and-forget playback. |
|
| 35 |
-
| **Logs** | Live `journalctl` over WebRTC data channel, fuzzy filter, sticky scroll, clear. |
|
| 36 |
-
|
| 37 |
-
Top-bar pills show the link health (RTT / buffer / jitter, colour-
|
| 38 |
-
coded), motor mode, daemon version, and signed-in HF user.
|
| 39 |
-
|
| 40 |
-
## Keyboard shortcuts
|
| 41 |
-
|
| 42 |
-
| Key | Action |
|
| 43 |
-
|-----|-------------------|
|
| 44 |
-
| `M` | toggle speaker |
|
| 45 |
-
| `N` | toggle microphone |
|
| 46 |
-
| `T` | toggle torque |
|
| 47 |
-
| `W` | wake up |
|
| 48 |
-
| `S` | go to sleep |
|
| 49 |
-
| `/` | focus log filter |
|
| 50 |
-
|
| 51 |
-
Skipped when an input field has focus (so `/` doesn't fight your
|
| 52 |
-
typing); `/` still hands off to the log filter from any context.
|
| 53 |
-
|
| 54 |
-
## Stack
|
| 55 |
-
|
| 56 |
-
- **React 19** + **TypeScript** (strict) + **Vite 7**
|
| 57 |
-
- **MUI 7** with the same theme tokens as `reachy_mini_mobile_app`
|
| 58 |
-
- **`@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.fd4354c`**, the
|
| 59 |
-
same exact pin used by all three Pollen reference apps. Mixing
|
| 60 |
-
versions across the SDK, the host shell, and the daemon causes
|
| 61 |
-
silent protocol drift — see APP_CREATION_GUIDE §10.
|
| 62 |
-
|
| 63 |
-
## Local dev
|
| 64 |
-
|
| 65 |
-
```bash
|
| 66 |
-
npm install
|
| 67 |
-
npm run dev
|
| 68 |
-
# → http://localhost:5173
|
| 69 |
-
```
|
| 70 |
-
|
| 71 |
-
Local sign-in has two options, both forwarded to `mountHost()` in
|
| 72 |
-
`src/dispatch.ts`. Copy `.env.example` to `.env.local` and fill in
|
| 73 |
-
ONE of:
|
| 74 |
-
|
| 75 |
-
- **Option A — personal access token** (recommended for most dev):
|
| 76 |
-
`VITE_HF_TOKEN=...` + `VITE_HF_USERNAME=...`. Skips the OAuth
|
| 77 |
-
redirect entirely.
|
| 78 |
-
- **Option B — OAuth client ID** (mirrors prod): create an app at
|
| 79 |
-
<https://huggingface.co/settings/applications/new>, set the
|
| 80 |
-
redirect URI to `http://localhost:5173`, paste the client ID into
|
| 81 |
-
`VITE_HF_OAUTH_CLIENT_ID`.
|
| 82 |
-
|
| 83 |
-
`.env.local` is gitignored — never commit a token.
|
| 84 |
-
|
| 85 |
-
## Build
|
| 86 |
-
|
| 87 |
-
```bash
|
| 88 |
-
npm run build
|
| 89 |
-
npm run preview
|
| 90 |
-
# → http://localhost:8080
|
| 91 |
-
```
|
| 92 |
-
|
| 93 |
-
The build emits a static `dist/` folder. HF serves it directly from
|
| 94 |
-
its CDN (`sdk: static`) and replaces `__OAUTH_CLIENT_ID__` /
|
| 95 |
-
`__OAUTH_SCOPES__` / `__SPACE_HOST__` / `__SPACE_ID__` inside
|
| 96 |
-
`index.html` at file-serve time, thanks to `hf_oauth: true` above.
|
| 97 |
-
|
| 98 |
-
## Deploy to a Hugging Face Space
|
| 99 |
-
|
| 100 |
-
```bash
|
| 101 |
-
hf repos create reachy-mini-dashboard --repo-type space --space-sdk static
|
| 102 |
-
git remote add space https://huggingface.co/spaces/<you>/reachy-mini-dashboard
|
| 103 |
-
# Build, then push README.md + dist/ contents (and the public/icon.svg
|
| 104 |
-
# duplicated so the mobile catalog's `siblings` probe finds it).
|
| 105 |
-
```
|
| 106 |
-
|
| 107 |
-
See APP_CREATION_GUIDE §11.2 for the full deploy recipe.
|
| 108 |
|
| 109 |
-
##
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
- `?config=<base64({"initialSoundPreset":"..."})>` — deep-link a
|
| 116 |
-
starting sound preset into the Sound card.
|
| 117 |
|
| 118 |
-
##
|
| 119 |
|
| 120 |
-
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Reachy Mini WebRTC Demo
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
hf_oauth: true
|
| 9 |
hf_oauth_expiration_minutes: 480
|
|
|
|
| 10 |
tags:
|
| 11 |
+
- reachy_mini
|
| 12 |
+
- reachy_mini_js_app
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# Reachy Mini WebRTC Demo
|
| 16 |
|
| 17 |
+
WebRTC dashboard to connect to your Reachy Mini robot via the central signaling server.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
## Features
|
| 20 |
|
| 21 |
+
- Video streaming from robot camera
|
| 22 |
+
- Head control via data channel
|
| 23 |
+
- HuggingFace OAuth authentication
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
## How it works
|
| 26 |
|
| 27 |
+
1. Sign in with your HuggingFace account
|
| 28 |
+
2. Connect to the central signaling server
|
| 29 |
+
3. Select your robot from the list
|
| 30 |
+
4. Start the video stream
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
## Requirements
|
| 33 |
|
| 34 |
+
- Robot must be running with WebRTC enabled and connected to the central signaling server
|
| 35 |
+
- Robot must have a valid HuggingFace token configured
|
assets/auto-CcEOuy4Q.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
assets/embed-DSsDnZHG.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
assets/index-CsmyDA4N.js
DELETED
|
@@ -1,4 +0,0 @@
|
|
| 1 |
-
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/embed-DSsDnZHG.js","assets/protocol-yMW1n-O_.js","assets/auto-CcEOuy4Q.js"])))=>i.map(i=>d[i]);
|
| 2 |
-
(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))o(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const n of i.addedNodes)n.tagName==="LINK"&&n.rel==="modulepreload"&&o(n)}).observe(document,{childList:!0,subtree:!0});function t(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function o(s){if(s.ep)return;s.ep=!0;const i=t(s);fetch(s.href,i)}})();const x="modulepreload",N=function(r){return"/"+r},U={},P=function(e,t,o){let s=Promise.resolve();if(t&&t.length>0){let c=function(l){return Promise.all(l.map(d=>Promise.resolve(d).then(h=>({status:"fulfilled",value:h}),h=>({status:"rejected",reason:h}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),a=n?.nonce||n?.getAttribute("nonce");s=c(t.map(l=>{if(l=N(l),l in U)return;U[l]=!0;const d=l.endsWith(".css"),h=d?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${l}"]${h}`))return;const _=document.createElement("link");if(_.rel=d?"stylesheet":x,d||(_.as="script"),_.crossOrigin="",_.href=l,a&&_.setAttribute("nonce",a),document.head.appendChild(_),d)return new Promise((f,p)=>{_.addEventListener("load",f),_.addEventListener("error",()=>p(new Error(`Unable to preload CSS for ${l}`)))})}))}function i(n){const a=new Event("vite:preloadError",{cancelable:!0});if(a.payload=n,window.dispatchEvent(a),!a.defaultPrevented)throw n}return s.then(n=>{for(const a of n||[])a.status==="rejected"&&i(a.reason);return e().catch(i)})};var F=Object.defineProperty,B=(r,e,t)=>e in r?F(r,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):r[e]=t,A=(r,e,t)=>(B(r,typeof e!="symbol"?e+"":e,t),t),O="https://huggingface.co";async function E(r,e){var t,o;const s=new V(r.url,r.status,(t=r.headers.get("X-Request-Id"))!=null?t:void 0);s.message=`Api error with status ${s.statusCode}`;const i=[`URL: ${s.url}`,s.requestId?`Request ID: ${s.requestId}`:void 0].filter(Boolean).join(". ");if((o=r.headers.get("Content-Type"))!=null&&o.startsWith("application/json")){const n=await r.json();s.message=n.error||n.message||s.message,s.data=n}else s.data={message:await r.text()};throw s.message+=`. ${i}`,s}var V=class extends Error{constructor(r,e,t,o){super(o),A(this,"statusCode"),A(this,"url"),A(this,"requestId"),A(this,"data"),this.statusCode=e,this.requestId=t,this.url=r}};new Promise(r=>{});function q(r){if(globalThis.Buffer)return globalThis.Buffer.from(r).toString("base64");{const e=[];return r.forEach(t=>{e.push(String.fromCharCode(t))}),globalThis.btoa(e.join(""))}}async function j(r){var e,t;if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const o=new URLSearchParams(window.location.search),[s,i]=[o.get("error"),o.get("error_description")];if(s)throw new Error(`${s}: ${i}`);const n=o.get("code"),a=localStorage.getItem("huggingface.co:oauth:nonce");if(!n)throw new Error("Missing oauth code from query parameters in redirected URL");if(!a)throw new Error("Missing oauth nonce from localStorage");const c=localStorage.getItem("huggingface.co:oauth:code_verifier");if(!c)throw new Error("Missing oauth code_verifier from localStorage");const l=o.get("state");if(!l)throw new Error("Missing oauth state from query parameters in redirected URL");let d;try{d=JSON.parse(l)}catch{throw new Error("Invalid oauth state in redirected URL, unable to parse JSON: "+l)}if(d.nonce!==a)throw new Error("Invalid oauth state in redirected URL");const h=O,_=`${new URL(h).origin}/.well-known/openid-configuration`,f=await fetch(_,{headers:{Accept:"application/json"}});if(!f.ok)throw await E(f);const p=await f.json(),v=await fetch(p.token_endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"authorization_code",code:n,redirect_uri:d.redirectUri,code_verifier:c}).toString()});if(localStorage.removeItem("huggingface.co:oauth:code_verifier"),localStorage.removeItem("huggingface.co:oauth:nonce"),!v.ok)throw await E(v);const w=await v.json(),g=new Date(Date.now()+w.expires_in*1e3),S=await fetch(p.userinfo_endpoint,{headers:{Authorization:`Bearer ${w.access_token}`}});if(!S.ok)throw await E(S);const u=await S.json();return{accessToken:w.access_token,accessTokenExpiresAt:g,userInfo:{id:u.sub,name:u.name,fullname:u.preferred_username,email:u.email,emailVerified:u.email_verified,avatarUrl:u.picture,websiteUrl:u.website,isPro:u.isPro,orgs:(t=(e=u.orgs)==null?void 0:e.map(m=>({id:m.sub,name:m.name,fullname:m.name,isEnterprise:m.isEnterprise,canPay:m.canPay,avatarUrl:m.picture,roleInOrg:m.roleInOrg})))!=null?t:[]},state:d.state,scope:w.scope}}async function H(r){if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const e=new URLSearchParams(window.location.search);return e.has("error")?j():e.has("code")?localStorage.getItem("huggingface.co:oauth:nonce")?j():(console.warn("Missing oauth nonce from localStorage. This can happen when the user refreshes the page after logging in, without changing the URL."),!1):!1}async function W(r){var e,t;if(typeof window>"u")throw new Error("oauthLogin is only available in the browser");const o=r?.hubUrl||O,s=`${new URL(o).origin}/.well-known/openid-configuration`,i=await fetch(s,{headers:{Accept:"application/json"}});if(!i.ok)throw await E(i);const n=await i.json(),a=globalThis.crypto.randomUUID(),c=globalThis.crypto.randomUUID()+globalThis.crypto.randomUUID();localStorage.setItem("huggingface.co:oauth:nonce",a),localStorage.setItem("huggingface.co:oauth:code_verifier",c);const l=r?.redirectUrl||window.location.href,d=JSON.stringify({nonce:a,redirectUri:l,state:r?.state}),h=(t=(e=window?.huggingface)==null?void 0:e.variables)!=null?t:null,_=r?.clientId||h?.OAUTH_CLIENT_ID;if(!_)throw h?new Error("Missing clientId, please add hf_oauth: true to the README.md's metadata in your static Space"):new Error("Missing clientId");const f=q(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256",new TextEncoder().encode(c)))).replace(/[+]/g,"-").replace(/[/]/g,"_").replace(/=/g,"");return`${n.authorization_endpoint}?${new URLSearchParams({client_id:_,scope:r?.scopes||h?.OAUTH_SCOPES||"openid profile",response_type:"code",redirect_uri:l,state:d,code_challenge:f,code_challenge_method:"S256"}).toString()}`}function R(r){return r*Math.PI/180}function C(r){return r*180/Math.PI}function z(r,e,t){const o=R(r),s=R(e),i=R(t),n=Math.cos(i),a=Math.sin(i),c=Math.cos(s),l=Math.sin(s),d=Math.cos(o),h=Math.sin(o);return[[n*c,n*l*h-a*d,n*l*d+a*h,0],[a*c,a*l*h+n*d,a*l*d-n*h,0],[-l,c*h,c*d,0],[0,0,0,1]]}function te(r){return{roll:C(Math.atan2(r[2][1],r[2][2])),pitch:C(Math.asin(-r[2][0])),yaw:C(Math.atan2(r[1][0],r[0][0]))}}function J(){if(typeof window>"u"||!window.location.hash)return;const r=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;let e;try{e=new URLSearchParams(r)}catch{return}const t=e.get("hf_token");if(!t)return;const o=e.get("hf_username")||"user",s=e.get("hf_token_expires"),i=s&&!Number.isNaN(new Date(s).getTime())?s:new Date(Date.now()+365*24*60*60*1e3).toISOString();try{sessionStorage.setItem("hf_token",t),sessionStorage.setItem("hf_username",o),sessionStorage.setItem("hf_token_expires",i)}catch(c){console.warn("[reachy-mini] could not persist pre-seeded HF credentials:",c)}e.delete("hf_token"),e.delete("hf_username"),e.delete("hf_token_expires");const n=e.toString(),a=window.location.pathname+window.location.search+(n?"#"+n:"");try{window.history.replaceState(null,"",a)}catch{}}function G(){if(typeof window>"u")return null;if(window.location.hash){const r=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;try{const t=new URLSearchParams(r).get("robot_peer_id");if(t)return t}catch{}}if(window.location.search)try{const e=new URLSearchParams(window.location.search).get("robot_peer_id");if(e)return e}catch{}return null}function K(r){const e=r.split(`\r
|
| 3 |
-
`);let t=!1;for(const o of e)if(o.startsWith("m=audio")?t=!0:o.startsWith("m=")&&(t=!1),t&&o==="a=sendrecv")return!0;return!1}const y=12*1024,k=1*1024*1024,Q=512*1024;function X(){return typeof CompressionStream<"u"}function L(){return"u"+Math.random().toString(36).slice(2,11)+Date.now().toString(36)}function T(r){let e="";for(let o=0;o<r.length;o+=32768)e+=String.fromCharCode.apply(null,Array.from(r.subarray(o,o+32768)));return btoa(e)}async function Y(r){const e=new TextEncoder().encode(r),t=await new Response(new Blob([e]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer();return T(new Uint8Array(t))}function D(r){const e=Math.round(Number(r)||0);return Math.max(0,Math.min(100,e))}class Z extends EventTarget{_signalingUrl;_clientId;_appName;_videoJitterBufferTargetMs;_autoStartFromUrl;_autoStartAttempted;_state="disconnected";_robots=[];_robotState={};_preselectedRobotId;_token=null;_username=null;_tokenExpires=null;_sseAbortController=null;_pc=null;_dc=null;_sessionId=null;_selectedRobotId=null;_pendingRemoteIce=[];_micStream=null;_micMuted=!0;_audioMuted=!0;_micSupported=!1;_latencyMonitorId=null;_stateRefreshInterval=null;_versionResolve=null;_hardwareIdResolve=null;_volumeResolve=null;_micVolumeResolve=null;_applyAudioConfigResolve=null;_readAudioParameterResolve=null;_logSubscribers=new Set;_broadcastWaiters=[];_activeMoveUploadId=null;_activeAudioUploadId=null;_sessionResolve=null;_sessionReject=null;_iceConnected=!1;_dcOpen=!1;_pendingMotionCompletions={wake_up:[],goto_sleep:[]};_videoElement=null;constructor(e={}){super(),this._signalingUrl=e.signalingUrl||"https://pollen-robotics-reachy-mini-central.hf.space",this._clientId=e.clientId||null,this._appName=e.appName||"unknown",this._videoJitterBufferTargetMs=e.videoJitterBufferTargetMs??0,this._autoStartFromUrl=e.autoStartFromUrl===!0,this._autoStartAttempted=!1,this._preselectedRobotId=G()}get state(){return this._state}get robots(){return this._robots}get robotState(){return this._robotState}get username(){return this._username}get isAuthenticated(){return!!this._token}get micSupported(){return this._micSupported}get micMuted(){return this._micMuted}get audioMuted(){return this._audioMuted}get preselectedRobotId(){return this._preselectedRobotId}get isEmbedded(){return this._preselectedRobotId!==null}_maybeAutoStart(){if(!this._autoStartFromUrl||this._autoStartAttempted||!this._preselectedRobotId||this._state!=="connected"||!this._robots.find(o=>o.id===this._preselectedRobotId))return;this._autoStartAttempted=!0;const t=this._preselectedRobotId;setTimeout(()=>{this._state==="connected"&&this.startSession(t).catch(o=>{console.warn("[reachy-mini] autoStartFromUrl: startSession rejected:",o)})},0)}async authenticate(){try{J();const e=await H();if(e)return this._username=e.userInfo.preferred_username||e.userInfo.name||null,this._token=e.accessToken,this._tokenExpires=e.accessTokenExpiresAt,sessionStorage.setItem("hf_token",this._token),sessionStorage.setItem("hf_username",this._username??""),sessionStorage.setItem("hf_token_expires",typeof this._tokenExpires=="string"?this._tokenExpires:this._tokenExpires.toISOString()),!0;const t=sessionStorage.getItem("hf_token"),o=sessionStorage.getItem("hf_username"),s=sessionStorage.getItem("hf_token_expires");return t&&o&&s&&new Date(s)>new Date?(this._token=t,this._username=o,this._tokenExpires=s,!0):!1}catch(e){return console.error("Auth error:",e),!1}}async login(){const e={};this._clientId&&(e.clientId=this._clientId),window.location.href=await W(e)}logout(){sessionStorage.removeItem("hf_token"),sessionStorage.removeItem("hf_username"),sessionStorage.removeItem("hf_token_expires"),this._username=null,this._tokenExpires=null,this.disconnect()}async connect(e){if(this._state!=="disconnected")throw new Error("Already connected");if(e&&(this._token=e),!this._token)throw new Error("No token — call authenticate() first or pass a token");this._sseAbortController=new AbortController;let t;try{t=await fetch(`${this._signalingUrl}/events`,{signal:this._sseAbortController.signal,headers:{Authorization:`Bearer ${this._token}`}})}catch(o){throw this._sseAbortController=null,o}if(!t.ok)throw this._sseAbortController=null,new Error(`HTTP ${t.status}`);return new Promise((o,s)=>{let i=!1;const n=t.body.getReader(),a=new TextDecoder;let c="";(async()=>{try{for(;;){const{done:d,value:h}=await n.read();if(d)break;c+=a.decode(h,{stream:!0});const _=c.split(`
|
| 4 |
-
`);c=_.pop()??"";for(const f of _)if(f.startsWith("data:"))try{const p=JSON.parse(f.slice(5).trim());!i&&p.type==="welcome"&&p.peerId&&(i=!0,this._state="connected",await this._sendToServer({type:"setPeerStatus",roles:["listener"],meta:{name:this._appName}}),this._emit("connected",{peerId:p.peerId}),o()),this._handleSignalingMessage(p)}catch{}}}catch(d){if(d.name!=="AbortError"&&this._emit("error",{source:"signaling",error:d}),!i){s(d);return}}this._state!=="disconnected"&&(this._state="disconnected",this._emit("disconnected",{reason:"SSE closed"})),i||s(new Error("Connection closed before welcome"))})()})}async autoConnect(e={}){const{token:t,pickRobot:o,autoPickIfSingle:s=!0,filterBusy:i=!0,wakeOnConnect:n=!0}=e;if(this._state==="streaming"){const c=this._robots?.find(l=>l.id===this._selectedRobotId);return{robotId:this._selectedRobotId,robotName:c?.meta?.name??null,isEmbedded:this.isEmbedded,alreadyStreaming:!0}}const a=this._autoStartFromUrl;this._autoStartFromUrl=!1;try{if(t)this._token=t;else if(!this._token&&!await this.authenticate())throw new Error("Not authenticated — call login() or pass a token");this._state==="disconnected"&&await this.connect();let c,l=null;if(this.isEmbedded){c=this._preselectedRobotId;try{await this._waitForRobotInList(c,5e3)}catch{}l=this._robots?.find(h=>h.id===c)?.meta?.name??null}else{const d=await this._fetchOwnedRobots({filterBusy:i});if(d.length===0)throw new Error("No reachable robots");if(s&&d.length===1&&!d[0].busy)c=d[0].id,l=d[0].name;else if(o){const h=await o(d);if(!h)throw new Error("Robot selection cancelled");c=h,l=d.find(_=>_.id===h)?.name??null}else throw new Error("Multiple robots available — pass a pickRobot callback to autoConnect()")}if(await this.startSession(c),n&&typeof this.ensureAwake=="function")try{await this.ensureAwake()}catch(d){console.warn("[reachy-mini] autoConnect: ensureAwake failed:",d)}return{robotId:c,robotName:l,isEmbedded:this.isEmbedded}}finally{this._autoStartFromUrl=a}}async _fetchOwnedRobots({filterBusy:e=!0}={}){try{const t=await fetch(`${this._signalingUrl}/api/robot-status`,{headers:{Authorization:`Bearer ${this._token}`}});if(!t.ok)throw new Error(`HTTP ${t.status}`);const o=await t.json(),s=new Map;for(const i of o.robots||[]){if(e&&i.busy)continue;const n=i.meta?.install_id??i.meta?.hardware_id??i.peerId;s.set(n,{id:i.peerId,name:i.robotName??i.meta?.name??null,busy:!!i.busy,activeApp:i.activeApp??null,meta:i.meta??{},lastSeenAgeSeconds:i.last_seen_age_seconds??null})}return Array.from(s.values()).sort((i,n)=>(i.lastSeenAgeSeconds??1/0)-(n.lastSeenAgeSeconds??1/0))}catch(t){return console.warn("[reachy-mini] /api/robot-status unavailable, using SSE list:",t),(this._robots||[]).map(o=>({id:o.id,name:o.meta?.name??null,busy:!1,activeApp:null,meta:o.meta??{},lastSeenAgeSeconds:null}))}}_waitForRobotInList(e,t){return this._robots?.find(o=>o.id===e)?Promise.resolve():new Promise((o,s)=>{const i=()=>{this._robots?.find(a=>a.id===e)&&(this.removeEventListener("robotsChanged",i),clearTimeout(n),o())},n=setTimeout(()=>{this.removeEventListener("robotsChanged",i),s(new Error(`Timeout waiting for robot ${e} in list`))},t);this.addEventListener("robotsChanged",i)})}async startSession(e){if(this._state!=="connected")throw new Error("Not connected");this._selectedRobotId=e,this._iceConnected=!1,this._dcOpen=!1,this._micSupported=!1,this._pendingRemoteIce=[];try{const t=window,o=t.AudioContext??t.webkitAudioContext;if(!o)throw new Error("AudioContext not supported");const s=new o,i=s.createMediaStreamDestination(),n=s.createOscillator(),a=s.createGain();a.gain.value=0,n.connect(a).connect(i),n.start();const c=i.stream;c.getAudioTracks().forEach(l=>{l.enabled=!1}),this._micStream=c,this._micMuted=!0}catch(t){console.warn("Audio sender placeholder setup failed:",t),this._micStream=null}return this._pc=new RTCPeerConnection({iceServers:[{urls:"stun:stun.l.google.com:19302"}]}),new Promise((t,o)=>{this._sessionResolve=t,this._sessionReject=o,this._pc.ontrack=s=>{if(s.track.kind==="video"){const i=this._videoJitterBufferTargetMs;try{s.receiver.jitterBufferTarget=i}catch{}try{s.receiver.playoutDelayHint=i/1e3}catch{}this._emit("videoTrack",{track:s.track,stream:s.streams[0]})}},this._pc.onicecandidate=async s=>{s.candidate&&this._sessionId&&await this._sendToServer({type:"peer",sessionId:this._sessionId,ice:{candidate:s.candidate.candidate,sdpMLineIndex:s.candidate.sdpMLineIndex,sdpMid:s.candidate.sdpMid}})},this._pc.oniceconnectionstatechange=()=>{const s=this._pc?.iceConnectionState;if(s)if(s==="connected"||s==="completed")this._iceConnected=!0,this._checkSessionReady();else if(s==="failed"){const i=new Error("ICE connection failed");this._sessionReject&&(this._sessionReject(i),this._sessionResolve=null,this._sessionReject=null),this._emit("error",{source:"webrtc",error:i})}else s==="disconnected"&&this._emit("error",{source:"webrtc",error:new Error("ICE disconnected")})},this._pc.ondatachannel=s=>{this._dc=s.channel,this._dc.onopen=()=>{this._dcOpen=!0,this._checkSessionReady()},this._dc.onmessage=i=>this._handleRobotMessage(JSON.parse(i.data))},this._sendToServer({type:"startSession",peerId:e}).then(s=>{if(s?.type==="sessionRejected"){this._failSessionRejected(s);return}s?.sessionId&&(this._sessionId=s.sessionId)})})}_failSessionRejected(e){const t=new Error(e.reason==="robot_busy"?`Robot is busy: "${e.activeApp||"another app"}" is already connected`:`Session rejected: ${e.reason||"unknown reason"}`);if(t.reason=e.reason??null,t.activeApp=e.activeApp??null,this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(o=>o.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1,this._emit("sessionRejected",{reason:e.reason,activeApp:e.activeApp}),this._sessionReject){const o=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,o(t)}}async stopSession(){this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Session stopped")),this._sessionReject&&(this._sessionReject(new Error("Session stopped")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&await this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(t=>t.stop()),this._micStream=null),this._micMuted=!0,this._micSupported=!1,this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._iceConnected=!1,this._dcOpen=!1,this._state==="streaming"&&(this._state="connected",this._emit("sessionStopped",{reason:"user"}))}disconnect(){this._sseAbortController&&(this._sseAbortController.abort(),this._sseAbortController=null),this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Disconnected")),this._sessionReject&&(this._sessionReject(new Error("Disconnected")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&this._token&&this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(e=>e.stop()),this._micStream=null),this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._micMuted=!0,this._micSupported=!1,this._iceConnected=!1,this._dcOpen=!1,this._robots=[],this._state="disconnected",this._emit("disconnected",{reason:"user"})}setTarget({head:e,antennas:t,body_yaw:o}={}){const s={type:"set_full_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(i=>Number.isFinite(i)))throw new TypeError(`setTarget: head must be a 16-element flat row-major 4×4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);s.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(i=>Number.isFinite(i)))throw new TypeError(`setTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);s.antennas=t}if(o!==void 0){if(!Number.isFinite(o))throw new TypeError(`setTarget: body_yaw must be a finite number (radians); got ${o}`);s.body_yaw=o}return this._sendCommand(s)}gotoTarget({head:e,antennas:t,body_yaw:o,duration:s}){const i={type:"goto_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(n=>Number.isFinite(n)))throw new TypeError(`gotoTarget: head must be a 16-element flat row-major 4×4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);i.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(n=>Number.isFinite(n)))throw new TypeError(`gotoTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);i.antennas=t}if(o!==void 0){if(!Number.isFinite(o))throw new TypeError(`gotoTarget: body_yaw must be a finite number (radians); got ${o}`);i.body_yaw=o}if(!Number.isFinite(s)||s<=0)throw new TypeError(`gotoTarget: duration must be a positive finite number (seconds); got ${s}`);return i.duration=s,this._sendCommand(i)}setHeadRpyDeg(e,t,o){return this.setTarget({head:z(e,t,o).flat()})}setAntennasDeg(e,t){return this.setTarget({antennas:[R(e),R(t)]})}setBodyYawDeg(e){return this.setTarget({body_yaw:R(e)})}playSound(e){return this._sendCommand({type:"play_sound",file:e})}setMotorMode(e){return this._sendCommand({type:"set_motor_mode",mode:e})}setMotorTorque(e,t=null){return this._sendCommand({type:"set_torque",on:e,ids:t})}wakeUp({timeoutMs:e=8e3}={}){return this._sendCommand({type:"set_motor_mode",mode:"enabled"}),this._sendCommandAwaitCompletion("wake_up",e)}gotoSleep({timeoutMs:e=8e3}={}){return this._sendCommandAwaitCompletion("goto_sleep",e)}_sendCommandAwaitCompletion(e,t){return this._sendCommand({type:e})?new Promise((o,s)=>{const i={resolve:o,reject:s,timer:setTimeout(()=>{const n=this._pendingMotionCompletions[e],a=n.indexOf(i);a!==-1&&n.splice(a,1),s(new Error(`${e} timed out after ${t}ms`))},t)};this._pendingMotionCompletions[e].push(i)}):Promise.reject(new Error(`${e}: data channel not open`))}_rejectPendingMotionCompletions(e){for(const t of Object.keys(this._pendingMotionCompletions)){const o=this._pendingMotionCompletions[t];for(;o.length;){const s=o.shift();clearTimeout(s.timer),s.reject(e)}}}isAwake(){const e=this._robotState?.motor_mode;return e==="enabled"||e==="gravity_compensation"}async ensureAwake(e=1e3){return this._robotState?.motor_mode===void 0&&await new Promise(t=>{const o=()=>{this.removeEventListener("state",o),clearTimeout(s),t()},s=setTimeout(o,e);this.addEventListener("state",o),this.requestState()}),this.isAwake()||this.wakeUp().catch(()=>{}),!0}getVersion(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._versionResolve&&this._versionResolve(null),this._versionResolve=e,this._sendCommand({type:"get_version"})})}getHardwareId(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._hardwareIdResolve&&this._hardwareIdResolve(null),this._hardwareIdResolve=e,this._sendCommand({type:"get_hardware_id"})})}getVolume(){return this._slotRoundtrip(()=>this._volumeResolve,e=>{this._volumeResolve=e},{type:"get_volume"})}setVolume(e){return this._slotRoundtrip(()=>this._volumeResolve,t=>{this._volumeResolve=t},{type:"set_volume",volume:D(e)})}getMicrophoneVolume(){return this._slotRoundtrip(()=>this._micVolumeResolve,e=>{this._micVolumeResolve=e},{type:"get_microphone_volume"})}setMicrophoneVolume(e){return this._slotRoundtrip(()=>this._micVolumeResolve,t=>{this._micVolumeResolve=t},{type:"set_microphone_volume",volume:D(e)})}applyAudioConfig(e,{verify:t=!0}={}){return this._slotRoundtrip(()=>this._applyAudioConfigResolve,o=>{this._applyAudioConfigResolve=o},{type:"apply_audio_config",config:e,verify:t}).then(o=>o===!0)}readAudioParameter(e){return this._slotRoundtrip(()=>this._readAudioParameterResolve,t=>{this._readAudioParameterResolve=t},{type:"read_audio_parameter",name:e})}_slotRoundtrip(e,t,o){return new Promise((s,i)=>{if(!this._dc||this._dc.readyState!=="open"){i(new Error("Data channel not open"));return}const n=e();n&&n(null),t(s),this._sendCommand(o)})}sendRaw(e){return this._sendCommand(e)}subscribeLogs({onLine:e,onError:t}){if(typeof e!="function")throw new TypeError("subscribeLogs: onLine callback is required");const o={onLine:e,onError:t},s=this._logSubscribers.size===0;this._logSubscribers.add(o),s&&this._sendCommand({type:"subscribe_logs"});let i=!1;return()=>{i||(i=!0,this._logSubscribers.delete(o),this._logSubscribers.size===0&&this._sendCommand({type:"unsubscribe_logs"}))}}requestState(){return this._sendCommand({type:"get_state"})}setAudioMuted(e){this._audioMuted=e,this._videoElement&&(this._videoElement.muted=e)}setMicMuted(e){this._micMuted=e,this._micStream&&this._micStream.getAudioTracks().forEach(t=>{t.enabled=!e})}attachVideo(e){this._videoElement=e,e.muted=this._audioMuted;const t=s=>{const i=s;e.srcObject=i.detail.stream,e.playsInline=!0,"requestVideoFrameCallback"in e&&this._startLatencyMonitor(e)},o=()=>{e.srcObject=null};return this.addEventListener("videoTrack",t),this.addEventListener("sessionStopped",o),()=>{this.removeEventListener("videoTrack",t),this.removeEventListener("sessionStopped",o),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),e.srcObject=null,this._videoElement=null}}async playMove(e,{audioBlob:t=null,audioLeadMs:o=-100,description:s="move",encoding:i="gzip+base64",playFrequency:n=100,initialGotoDuration:a=0,startTimeoutMs:c=8e3,onProgress:l=()=>{},onStarted:d=()=>{}}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!e?.time?.length||!e?.set_target_data?.length)throw new Error("playMove: motion must have time + set_target_data");const h=L();this._activeMoveUploadId=h;const _={description:s,time:e.time,set_target_data:e.set_target_data},f=JSON.stringify(_);let p,v;i==="gzip+base64"&&X()?(p=await Y(f),v="gzip+base64"):(p=f,v="json");const w=Math.ceil(p.length/y)||1;l({phase:"starting",sent:0,total:w,bytes:p.length,encoding:v}),this._sendCommand({type:"upload_move_start",upload_id:h,total_chunks:w,description:s,encoding:v});for(let u=0;u<w;u++){this._dc.bufferedAmount>k&&await this._awaitDataChannelDrain();const m=u*y;this._sendCommand({type:"upload_move_chunk",upload_id:h,chunk_index:u,chunk:p.slice(m,m+y)}),l({phase:"upload",sent:u+1,total:w})}if(this._sendCommand({type:"upload_move_finish",upload_id:h}),l({phase:"uploaded",sent:w,total:w}),t){const u=new Uint8Array(await t.arrayBuffer()),m=T(u),b=Math.ceil(m.length/y)||1;l({phase:"audio-starting",sent:0,total:b,bytes:m.length}),this._sendCommand({type:"upload_audio_start",upload_id:h,total_chunks:b,encoding:"wav-base64",description:s});for(let I=0;I<b;I++){this._dc.bufferedAmount>k&&await this._awaitDataChannelDrain();const M=I*y;this._sendCommand({type:"upload_audio_chunk",upload_id:h,chunk_index:I,chunk:m.slice(M,M+y)}),l({phase:"audio-upload",sent:I+1,total:b})}this._sendCommand({type:"upload_audio_finish",upload_id:h}),l({phase:"audio-uploaded",sent:b,total:b})}this._sendCommand({type:"play_uploaded_move",upload_id:h,play_frequency:n,initial_goto_duration:a,audio_lead_ms:o});let g;try{g=await this._waitForBroadcast(u=>u?.type==="play_uploaded_move"&&u?.upload_id===h&&(u.started===!0||typeof u.error=="string"),{timeoutMs:c,debugLabel:"play_uploaded_move started"})}catch(u){throw new Error(`Daemon did not respond to play_uploaded_move (requires the reachy_mini daemon with feature/daemon-side-move-upload). Underlying: ${u.message}`)}if(typeof g.error=="string")throw new Error(`play_uploaded_move: ${g.error}`);try{d({duration_s:g.duration_s,has_audio:g.has_audio===!0})}catch(u){console.warn("playMove.onStarted threw:",u)}l({phase:"playing",duration_s:g.duration_s});const S=await this._waitForBroadcast(u=>u?.type==="play_uploaded_move"&&u?.upload_id===h&&(u.finished===!0||u.cancelled===!0||typeof u.error=="string"),{timeoutMs:(g.duration_s+30)*1e3,debugLabel:"play_uploaded_move final"});return this._activeMoveUploadId===h&&(this._activeMoveUploadId=null),S}cancelMove(e=null){const t=e??this._activeMoveUploadId;return t?this._sendCommand({type:"cancel_move",upload_id:t}):!1}async uploadAudio(e,{description:t="audio",onProgress:o=()=>{}}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!(e instanceof Blob))throw new TypeError("uploadAudio: expected a Blob");const s=L(),i=new Uint8Array(await e.arrayBuffer()),n=T(i),a=Math.ceil(n.length/y)||1;o({phase:"audio-starting",sent:0,total:a,bytes:n.length}),this._sendCommand({type:"upload_audio_start",upload_id:s,total_chunks:a,encoding:"wav-base64",description:t});for(let c=0;c<a;c++){this._dc.bufferedAmount>k&&await this._awaitDataChannelDrain();const l=c*y;this._sendCommand({type:"upload_audio_chunk",upload_id:s,chunk_index:c,chunk:n.slice(l,l+y)}),o({phase:"audio-upload",sent:c+1,total:a})}return this._sendCommand({type:"upload_audio_finish",upload_id:s}),o({phase:"audio-uploaded",sent:a,total:a}),s}async playUploadedAudio(e,{timeoutMs:t=8e3}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");const o=this._waitForBroadcast(i=>i?.type==="play_uploaded_audio"&&i?.upload_id===e&&(i.started===!0||typeof i.error=="string"),{timeoutMs:t,debugLabel:"play_uploaded_audio started"});this._sendCommand({type:"play_uploaded_audio",upload_id:e});const s=await o;if(typeof s.error=="string")throw new Error(s.error);return this._activeAudioUploadId=e,s}cancelAudio(e=null){const t=e??this._activeAudioUploadId;return t?(this._activeAudioUploadId===t&&(this._activeAudioUploadId=null),this._sendCommand({type:"cancel_audio",upload_id:t})):!1}_emit(e,t){this.dispatchEvent(new CustomEvent(e,{detail:t}))}_waitForBroadcast(e,{timeoutMs:t=5e3,debugLabel:o=""}={}){return new Promise((s,i)=>{const n={predicate:e,resolve:s,timer:setTimeout(()=>{const a=this._broadcastWaiters.indexOf(n);a!==-1&&this._broadcastWaiters.splice(a,1),i(new Error(`broadcast timeout (${t} ms): ${o}`))},t)};this._broadcastWaiters.push(n)})}async _awaitDataChannelDrain(){for(;this._dc&&this._dc.bufferedAmount>Q;)if(await new Promise(e=>setTimeout(e,30)),!this._dc||this._dc.readyState!=="open")throw new Error("data channel closed mid-upload")}async _sendToServer(e){if(!this._token)throw new Error("No token — authenticate() first");try{const t=await fetch(`${this._signalingUrl}/send`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this._token}`},body:JSON.stringify(e)});if(!t.ok){let o="";try{o=await t.text()}catch{}return console.warn(`[reachy-mini] /send rejected (${t.status}) for type=${e?.type}; body=${o||"<empty>"}`),null}return await t.json()}catch(t){return console.error("Send error:",t),null}}_sendCommand(e){return!this._dc||this._dc.readyState!=="open"?!1:(this._dc.send(JSON.stringify(e)),!0)}_checkSessionReady(){this._iceConnected&&this._dcOpen&&this._sessionResolve&&(this._state="streaming",this.requestState(),this._stateRefreshInterval=setInterval(()=>this.requestState(),500),this._emit("streaming",{sessionId:this._sessionId,robotId:this._selectedRobotId}),this._sessionResolve(),this._sessionResolve=null,this._sessionReject=null)}async _handleSignalingMessage(e){switch(e.type){case"welcome":break;case"list":this._robots=e.producers||[],this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart();break;case"peerStatusChanged":{const t=await this._sendToServer({type:"list"});t?.producers&&(this._robots=t.producers,this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart());break}case"sessionStarted":this._sessionId=e.sessionId??null;break;case"sessionRejected":this._failSessionRejected(e);break;case"endSession":this._handleEndSession(e);break;case"peer":this._handlePeerMessage(e);break}}_handleEndSession(e){const t=e.reason,o=t==="robot_busy_local_app"?"Robot is busy: a local Python app is running":t==="local_app_started"?"Disconnected: a local Python app started on the robot":t==="robot_busy_local"?"Robot is busy: another session is already active":null;if(this._sessionReject){const s=new Error(o||`Session ended before it could start: ${t||"unknown reason"}`);s.reason=t??null,this._emit("sessionRejected",{reason:t,activeApp:null}),this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(n=>n.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1;const i=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,i(s);return}this._state==="streaming"&&(this._emit("sessionStopped",{reason:t||"remote_end",message:o}),this.stopSession().catch(()=>{}))}async _handlePeerMessage(e){if(this._pc)try{if(e.sdp){const t=e.sdp;if(t.type==="offer"){const s=K(t.sdp);if(this._micSupported=s,this._emit("micSupported",{supported:s}),s&&this._micStream)for(const n of this._micStream.getAudioTracks())this._pc.addTrack(n,this._micStream);await this._pc.setRemoteDescription(new RTCSessionDescription(t));const i=await this._pc.createAnswer();await this._pc.setLocalDescription(i),await this._sendToServer({type:"peer",sessionId:this._sessionId,sdp:{type:"answer",sdp:i.sdp}})}else await this._pc.setRemoteDescription(new RTCSessionDescription(t));const o=this._pendingRemoteIce;if(o&&o.length){this._pendingRemoteIce=[];for(const s of o)try{await this._pc.addIceCandidate(new RTCIceCandidate(s))}catch(i){console.warn("[reachy-mini] buffered ICE candidate rejected:",i)}}}if(e.ice){if(!e.ice.candidate)return;this._pc.remoteDescription?await this._pc.addIceCandidate(new RTCIceCandidate(e.ice)):(this._pendingRemoteIce||(this._pendingRemoteIce=[]),this._pendingRemoteIce.push(e.ice))}}catch(t){console.error("WebRTC error:",t),this._emit("error",{source:"webrtc",error:t})}}_handleRobotMessage(e){if("version"in e&&this._versionResolve){this._versionResolve(e.version),this._versionResolve=null;return}if("hardware_id"in e&&this._hardwareIdResolve){this._hardwareIdResolve(e.hardware_id),this._hardwareIdResolve=null;return}if(e.command==="get_volume"||e.command==="set_volume"){this._volumeResolve&&(this._volumeResolve(e.status==="error"?null:e.volume),this._volumeResolve=null);return}if(e.command==="get_microphone_volume"||e.command==="set_microphone_volume"){this._micVolumeResolve&&(this._micVolumeResolve(e.status==="error"?null:e.volume),this._micVolumeResolve=null);return}if(e.command==="apply_audio_config"){this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(e.error?!1:!!e.applied),this._applyAudioConfigResolve=null);return}if(e.command==="read_audio_parameter"){this._readAudioParameterResolve&&(this._readAudioParameterResolve(e.error?null:e.values??null),this._readAudioParameterResolve=null);return}if((e.command==="wake_up"||e.command==="goto_sleep")&&this._pendingMotionCompletions&&this._pendingMotionCompletions[e.command]){const t=this._pendingMotionCompletions[e.command];if(e.completed===!0&&t.length>0){const o=t.shift();clearTimeout(o.timer),o.resolve();return}if(e.error&&t.length>0){const o=t.shift();clearTimeout(o.timer),o.reject(new Error(`${e.command}: ${e.error}`));return}}if(e.type==="log_line"){for(const t of this._logSubscribers)try{t.onLine({timestamp:e.timestamp,line:e.line})}catch(o){console.error("subscribeLogs onLine threw:",o)}return}if(e.type==="log_stream_error"){for(const t of this._logSubscribers)if(typeof t.onError=="function")try{t.onError(e.error)}catch(o){console.error("subscribeLogs onError threw:",o)}return}if(e.state){const t=e.state;t.head_pose&&(this._robotState.head=t.head_pose.flat()),t.antennas&&(this._robotState.antennas=[t.antennas[0],t.antennas[1]]),typeof t.body_yaw=="number"&&(this._robotState.body_yaw=t.body_yaw),t.motor_mode&&(this._robotState.motor_mode=t.motor_mode),typeof t.is_move_running=="boolean"&&(this._robotState.is_move_running=t.is_move_running),this._emit("state",{...this._robotState})}if(e.error&&this._emit("error",{source:"robot",error:e.error}),this._broadcastWaiters.length>0)for(let t=this._broadcastWaiters.length-1;t>=0;t--){const o=this._broadcastWaiters[t];if(o.predicate(e)){this._broadcastWaiters.splice(t,1),clearTimeout(o.timer),o.resolve(e);return}}}_startLatencyMonitor(e){this._latencyMonitorId&&clearInterval(this._latencyMonitorId),this._latencyMonitorId=setInterval(()=>{if(!e.srcObject||e.paused)return;const t=e.buffered;if(t.length>0){const o=t.end(t.length-1),s=o-e.currentTime;s>.5&&(console.log(`Latency correction: was ${s.toFixed(2)}s behind`),e.currentTime=o-.1)}},2e3)}}window.ReachyMini=Z;window.dispatchEvent(new Event("reachymini:ready"));const $=new URLSearchParams(window.location.search),ee=$.get("embedded")==="1"||$.get("embed")==="1";ee?P(()=>import("./embed-DSsDnZHG.js"),__vite__mapDeps([0,1])):P(async()=>{const{mountHost:r}=await import("./auto-CcEOuy4Q.js");return{mountHost:r}},__vite__mapDeps([2,1])).then(({mountHost:r})=>{r({appName:"Reachy Mini Dashboard",appIconUrl:"/icon.svg",appEmoji:"🛰️",enableMicrophone:!0,devToken:void 0,clientId:void 0})});export{z as a,R as d,te as m,C as r};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/protocol-yMW1n-O_.js
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
var L=Object.defineProperty,$=(n,e,t)=>e in n?L(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t,A=(n,e,t)=>($(n,typeof e!="symbol"?e+"":e,t),t),P="https://huggingface.co";async function C(n,e){var t,s;const o=new D(n.url,n.status,(t=n.headers.get("X-Request-Id"))!=null?t:void 0);o.message=`Api error with status ${o.statusCode}`;const i=[`URL: ${o.url}`,o.requestId?`Request ID: ${o.requestId}`:void 0].filter(Boolean).join(". ");if((s=n.headers.get("Content-Type"))!=null&&s.startsWith("application/json")){const r=await n.json();o.message=r.error||r.message||o.message,o.data=r}else o.data={message:await n.text()};throw o.message+=`. ${i}`,o}var D=class extends Error{constructor(n,e,t,s){super(s),A(this,"statusCode"),A(this,"url"),A(this,"requestId"),A(this,"data"),this.statusCode=e,this.requestId=t,this.url=n}};new Promise(n=>{});function x(n){if(globalThis.Buffer)return globalThis.Buffer.from(n).toString("base64");{const e=[];return n.forEach(t=>{e.push(String.fromCharCode(t))}),globalThis.btoa(e.join(""))}}async function T(n){var e,t;if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const s=new URLSearchParams(window.location.search),[o,i]=[s.get("error"),s.get("error_description")];if(o)throw new Error(`${o}: ${i}`);const r=s.get("code"),l=localStorage.getItem("huggingface.co:oauth:nonce");if(!r)throw new Error("Missing oauth code from query parameters in redirected URL");if(!l)throw new Error("Missing oauth nonce from localStorage");const d=localStorage.getItem("huggingface.co:oauth:code_verifier");if(!d)throw new Error("Missing oauth code_verifier from localStorage");const a=s.get("state");if(!a)throw new Error("Missing oauth state from query parameters in redirected URL");let u;try{u=JSON.parse(a)}catch{throw new Error("Invalid oauth state in redirected URL, unable to parse JSON: "+a)}if(u.nonce!==l)throw new Error("Invalid oauth state in redirected URL");const h=P,p=`${new URL(h).origin}/.well-known/openid-configuration`,m=await fetch(p,{headers:{Accept:"application/json"}});if(!m.ok)throw await C(m);const y=await m.json(),v=await fetch(y.token_endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"authorization_code",code:r,redirect_uri:u.redirectUri,code_verifier:d}).toString()});if(localStorage.removeItem("huggingface.co:oauth:code_verifier"),localStorage.removeItem("huggingface.co:oauth:nonce"),!v.ok)throw await C(v);const f=await v.json(),g=new Date(Date.now()+f.expires_in*1e3),R=await fetch(y.userinfo_endpoint,{headers:{Authorization:`Bearer ${f.access_token}`}});if(!R.ok)throw await C(R);const c=await R.json();return{accessToken:f.access_token,accessTokenExpiresAt:g,userInfo:{id:c.sub,name:c.name,fullname:c.preferred_username,email:c.email,emailVerified:c.email_verified,avatarUrl:c.picture,websiteUrl:c.website,isPro:c.isPro,orgs:(t=(e=c.orgs)==null?void 0:e.map(_=>({id:_.sub,name:_.name,fullname:_.name,isEnterprise:_.isEnterprise,canPay:_.canPay,avatarUrl:_.picture,roleInOrg:_.roleInOrg})))!=null?t:[]},state:u.state,scope:f.scope}}async function O(n){if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const e=new URLSearchParams(window.location.search);return e.has("error")?T():e.has("code")?localStorage.getItem("huggingface.co:oauth:nonce")?T():(console.warn("Missing oauth nonce from localStorage. This can happen when the user refreshes the page after logging in, without changing the URL."),!1):!1}async function N(n){var e,t;if(typeof window>"u")throw new Error("oauthLogin is only available in the browser");const s=n?.hubUrl||P,o=`${new URL(s).origin}/.well-known/openid-configuration`,i=await fetch(o,{headers:{Accept:"application/json"}});if(!i.ok)throw await C(i);const r=await i.json(),l=globalThis.crypto.randomUUID(),d=globalThis.crypto.randomUUID()+globalThis.crypto.randomUUID();localStorage.setItem("huggingface.co:oauth:nonce",l),localStorage.setItem("huggingface.co:oauth:code_verifier",d);const a=n?.redirectUrl||window.location.href,u=JSON.stringify({nonce:l,redirectUri:a,state:n?.state}),h=(t=(e=window?.huggingface)==null?void 0:e.variables)!=null?t:null,p=n?.clientId||h?.OAUTH_CLIENT_ID;if(!p)throw h?new Error("Missing clientId, please add hf_oauth: true to the README.md's metadata in your static Space"):new Error("Missing clientId");const m=x(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256",new TextEncoder().encode(d)))).replace(/[+]/g,"-").replace(/[/]/g,"_").replace(/=/g,"");return`${r.authorization_endpoint}?${new URLSearchParams({client_id:p,scope:n?.scopes||h?.OAUTH_SCOPES||"openid profile",response_type:"code",redirect_uri:a,state:u,code_challenge:m,code_challenge_method:"S256"}).toString()}`}function S(n){return n*Math.PI/180}function B(n,e,t){const s=S(n),o=S(e),i=S(t),r=Math.cos(i),l=Math.sin(i),d=Math.cos(o),a=Math.sin(o),u=Math.cos(s),h=Math.sin(s);return[[r*d,r*a*h-l*u,r*a*u+l*h,0],[l*d,l*a*h+r*u,l*a*u-r*h,0],[-a,d*h,d*u,0],[0,0,0,1]]}function F(){if(typeof window>"u"||!window.location.hash)return;const n=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;let e;try{e=new URLSearchParams(n)}catch{return}const t=e.get("hf_token");if(!t)return;const s=e.get("hf_username")||"user",o=e.get("hf_token_expires"),i=o&&!Number.isNaN(new Date(o).getTime())?o:new Date(Date.now()+365*24*60*60*1e3).toISOString();try{sessionStorage.setItem("hf_token",t),sessionStorage.setItem("hf_username",s),sessionStorage.setItem("hf_token_expires",i)}catch(d){console.warn("[reachy-mini] could not persist pre-seeded HF credentials:",d)}e.delete("hf_token"),e.delete("hf_username"),e.delete("hf_token_expires");const r=e.toString(),l=window.location.pathname+window.location.search+(r?"#"+r:"");try{window.history.replaceState(null,"",l)}catch{}}function V(){if(typeof window>"u")return null;if(window.location.hash){const n=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;try{const e=new URLSearchParams(n).get("robot_peer_id");if(e)return e}catch{}}if(window.location.search)try{const n=new URLSearchParams(window.location.search).get("robot_peer_id");if(n)return n}catch{}return null}function q(n){const e=n.split(`\r
|
| 2 |
-
`);let t=!1;for(const s of e)if(s.startsWith("m=audio")?t=!0:s.startsWith("m=")&&(t=!1),t&&s==="a=sendrecv")return!0;return!1}const w=12*1024,E=1*1024*1024,W=512*1024;function J(){return typeof CompressionStream<"u"}function U(){return"u"+Math.random().toString(36).slice(2,11)+Date.now().toString(36)}function k(n){let e="";for(let t=0;t<n.length;t+=32768)e+=String.fromCharCode.apply(null,Array.from(n.subarray(t,t+32768)));return btoa(e)}async function z(n){const e=new TextEncoder().encode(n),t=await new Response(new Blob([e]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer();return k(new Uint8Array(t))}function j(n){const e=Math.round(Number(n)||0);return Math.max(0,Math.min(100,e))}class Y extends EventTarget{_signalingUrl;_clientId;_appName;_videoJitterBufferTargetMs;_autoStartFromUrl;_autoStartAttempted;_state="disconnected";_robots=[];_robotState={};_preselectedRobotId;_token=null;_username=null;_tokenExpires=null;_sseAbortController=null;_pc=null;_dc=null;_sessionId=null;_selectedRobotId=null;_pendingRemoteIce=[];_micStream=null;_micMuted=!0;_audioMuted=!0;_micSupported=!1;_latencyMonitorId=null;_stateRefreshInterval=null;_versionResolve=null;_hardwareIdResolve=null;_volumeResolve=null;_micVolumeResolve=null;_applyAudioConfigResolve=null;_readAudioParameterResolve=null;_logSubscribers=new Set;_broadcastWaiters=[];_activeMoveUploadId=null;_activeAudioUploadId=null;_sessionResolve=null;_sessionReject=null;_iceConnected=!1;_dcOpen=!1;_pendingMotionCompletions={wake_up:[],goto_sleep:[]};_videoElement=null;constructor(e={}){super(),this._signalingUrl=e.signalingUrl||"https://pollen-robotics-reachy-mini-central.hf.space",this._clientId=e.clientId||null,this._appName=e.appName||"unknown",this._videoJitterBufferTargetMs=e.videoJitterBufferTargetMs??0,this._autoStartFromUrl=e.autoStartFromUrl===!0,this._autoStartAttempted=!1,this._preselectedRobotId=V()}get state(){return this._state}get robots(){return this._robots}get robotState(){return this._robotState}get username(){return this._username}get isAuthenticated(){return!!this._token}get micSupported(){return this._micSupported}get micMuted(){return this._micMuted}get audioMuted(){return this._audioMuted}get preselectedRobotId(){return this._preselectedRobotId}get isEmbedded(){return this._preselectedRobotId!==null}_maybeAutoStart(){if(!this._autoStartFromUrl||this._autoStartAttempted||!this._preselectedRobotId||this._state!=="connected"||!this._robots.find(t=>t.id===this._preselectedRobotId))return;this._autoStartAttempted=!0;const e=this._preselectedRobotId;setTimeout(()=>{this._state==="connected"&&this.startSession(e).catch(t=>{console.warn("[reachy-mini] autoStartFromUrl: startSession rejected:",t)})},0)}async authenticate(){try{F();const e=await O();if(e)return this._username=e.userInfo.preferred_username||e.userInfo.name||null,this._token=e.accessToken,this._tokenExpires=e.accessTokenExpiresAt,sessionStorage.setItem("hf_token",this._token),sessionStorage.setItem("hf_username",this._username??""),sessionStorage.setItem("hf_token_expires",typeof this._tokenExpires=="string"?this._tokenExpires:this._tokenExpires.toISOString()),!0;const t=sessionStorage.getItem("hf_token"),s=sessionStorage.getItem("hf_username"),o=sessionStorage.getItem("hf_token_expires");return t&&s&&o&&new Date(o)>new Date?(this._token=t,this._username=s,this._tokenExpires=o,!0):!1}catch(e){return console.error("Auth error:",e),!1}}async login(){const e={};this._clientId&&(e.clientId=this._clientId),window.location.href=await N(e)}logout(){sessionStorage.removeItem("hf_token"),sessionStorage.removeItem("hf_username"),sessionStorage.removeItem("hf_token_expires"),this._username=null,this._tokenExpires=null,this.disconnect()}async connect(e){if(this._state!=="disconnected")throw new Error("Already connected");if(e&&(this._token=e),!this._token)throw new Error("No token — call authenticate() first or pass a token");this._sseAbortController=new AbortController;let t;try{t=await fetch(`${this._signalingUrl}/events`,{signal:this._sseAbortController.signal,headers:{Authorization:`Bearer ${this._token}`}})}catch(s){throw this._sseAbortController=null,s}if(!t.ok)throw this._sseAbortController=null,new Error(`HTTP ${t.status}`);return new Promise((s,o)=>{let i=!1;const r=t.body.getReader(),l=new TextDecoder;let d="";(async()=>{try{for(;;){const{done:a,value:u}=await r.read();if(a)break;d+=l.decode(u,{stream:!0});const h=d.split(`
|
| 3 |
-
`);d=h.pop()??"";for(const p of h)if(p.startsWith("data:"))try{const m=JSON.parse(p.slice(5).trim());!i&&m.type==="welcome"&&m.peerId&&(i=!0,this._state="connected",await this._sendToServer({type:"setPeerStatus",roles:["listener"],meta:{name:this._appName}}),this._emit("connected",{peerId:m.peerId}),s()),this._handleSignalingMessage(m)}catch{}}}catch(a){if(a.name!=="AbortError"&&this._emit("error",{source:"signaling",error:a}),!i){o(a);return}}this._state!=="disconnected"&&(this._state="disconnected",this._emit("disconnected",{reason:"SSE closed"})),i||o(new Error("Connection closed before welcome"))})()})}async autoConnect(e={}){const{token:t,pickRobot:s,autoPickIfSingle:o=!0,filterBusy:i=!0,wakeOnConnect:r=!0}=e;if(this._state==="streaming"){const d=this._robots?.find(a=>a.id===this._selectedRobotId);return{robotId:this._selectedRobotId,robotName:d?.meta?.name??null,isEmbedded:this.isEmbedded,alreadyStreaming:!0}}const l=this._autoStartFromUrl;this._autoStartFromUrl=!1;try{if(t)this._token=t;else if(!this._token&&!await this.authenticate())throw new Error("Not authenticated — call login() or pass a token");this._state==="disconnected"&&await this.connect();let d,a=null;if(this.isEmbedded){d=this._preselectedRobotId;try{await this._waitForRobotInList(d,5e3)}catch{}a=this._robots?.find(u=>u.id===d)?.meta?.name??null}else{const u=await this._fetchOwnedRobots({filterBusy:i});if(u.length===0)throw new Error("No reachable robots");if(o&&u.length===1&&!u[0].busy)d=u[0].id,a=u[0].name;else if(s){const h=await s(u);if(!h)throw new Error("Robot selection cancelled");d=h,a=u.find(p=>p.id===h)?.name??null}else throw new Error("Multiple robots available — pass a pickRobot callback to autoConnect()")}if(await this.startSession(d),r&&typeof this.ensureAwake=="function")try{await this.ensureAwake()}catch(u){console.warn("[reachy-mini] autoConnect: ensureAwake failed:",u)}return{robotId:d,robotName:a,isEmbedded:this.isEmbedded}}finally{this._autoStartFromUrl=l}}async _fetchOwnedRobots({filterBusy:e=!0}={}){try{const t=await fetch(`${this._signalingUrl}/api/robot-status`,{headers:{Authorization:`Bearer ${this._token}`}});if(!t.ok)throw new Error(`HTTP ${t.status}`);const s=await t.json(),o=new Map;for(const i of s.robots||[]){if(e&&i.busy)continue;const r=i.meta?.install_id??i.meta?.hardware_id??i.peerId;o.set(r,{id:i.peerId,name:i.robotName??i.meta?.name??null,busy:!!i.busy,activeApp:i.activeApp??null,meta:i.meta??{},lastSeenAgeSeconds:i.last_seen_age_seconds??null})}return Array.from(o.values()).sort((i,r)=>(i.lastSeenAgeSeconds??1/0)-(r.lastSeenAgeSeconds??1/0))}catch(t){return console.warn("[reachy-mini] /api/robot-status unavailable, using SSE list:",t),(this._robots||[]).map(s=>({id:s.id,name:s.meta?.name??null,busy:!1,activeApp:null,meta:s.meta??{},lastSeenAgeSeconds:null}))}}_waitForRobotInList(e,t){return this._robots?.find(s=>s.id===e)?Promise.resolve():new Promise((s,o)=>{const i=()=>{this._robots?.find(l=>l.id===e)&&(this.removeEventListener("robotsChanged",i),clearTimeout(r),s())},r=setTimeout(()=>{this.removeEventListener("robotsChanged",i),o(new Error(`Timeout waiting for robot ${e} in list`))},t);this.addEventListener("robotsChanged",i)})}async startSession(e){if(this._state!=="connected")throw new Error("Not connected");this._selectedRobotId=e,this._iceConnected=!1,this._dcOpen=!1,this._micSupported=!1,this._pendingRemoteIce=[];try{const t=window,s=t.AudioContext??t.webkitAudioContext;if(!s)throw new Error("AudioContext not supported");const o=new s,i=o.createMediaStreamDestination(),r=o.createOscillator(),l=o.createGain();l.gain.value=0,r.connect(l).connect(i),r.start();const d=i.stream;d.getAudioTracks().forEach(a=>{a.enabled=!1}),this._micStream=d,this._micMuted=!0}catch(t){console.warn("Audio sender placeholder setup failed:",t),this._micStream=null}return this._pc=new RTCPeerConnection({iceServers:[{urls:"stun:stun.l.google.com:19302"}]}),new Promise((t,s)=>{this._sessionResolve=t,this._sessionReject=s,this._pc.ontrack=o=>{if(o.track.kind==="video"){const i=this._videoJitterBufferTargetMs;try{o.receiver.jitterBufferTarget=i}catch{}try{o.receiver.playoutDelayHint=i/1e3}catch{}this._emit("videoTrack",{track:o.track,stream:o.streams[0]})}},this._pc.onicecandidate=async o=>{o.candidate&&this._sessionId&&await this._sendToServer({type:"peer",sessionId:this._sessionId,ice:{candidate:o.candidate.candidate,sdpMLineIndex:o.candidate.sdpMLineIndex,sdpMid:o.candidate.sdpMid}})},this._pc.oniceconnectionstatechange=()=>{const o=this._pc?.iceConnectionState;if(o)if(o==="connected"||o==="completed")this._iceConnected=!0,this._checkSessionReady();else if(o==="failed"){const i=new Error("ICE connection failed");this._sessionReject&&(this._sessionReject(i),this._sessionResolve=null,this._sessionReject=null),this._emit("error",{source:"webrtc",error:i})}else o==="disconnected"&&this._emit("error",{source:"webrtc",error:new Error("ICE disconnected")})},this._pc.ondatachannel=o=>{this._dc=o.channel,this._dc.onopen=()=>{this._dcOpen=!0,this._checkSessionReady()},this._dc.onmessage=i=>this._handleRobotMessage(JSON.parse(i.data))},this._sendToServer({type:"startSession",peerId:e}).then(o=>{if(o?.type==="sessionRejected"){this._failSessionRejected(o);return}o?.sessionId&&(this._sessionId=o.sessionId)})})}_failSessionRejected(e){const t=new Error(e.reason==="robot_busy"?`Robot is busy: "${e.activeApp||"another app"}" is already connected`:`Session rejected: ${e.reason||"unknown reason"}`);if(t.reason=e.reason??null,t.activeApp=e.activeApp??null,this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(s=>s.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1,this._emit("sessionRejected",{reason:e.reason,activeApp:e.activeApp}),this._sessionReject){const s=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,s(t)}}async stopSession(){this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Session stopped")),this._sessionReject&&(this._sessionReject(new Error("Session stopped")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&await this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(e=>e.stop()),this._micStream=null),this._micMuted=!0,this._micSupported=!1,this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._iceConnected=!1,this._dcOpen=!1,this._state==="streaming"&&(this._state="connected",this._emit("sessionStopped",{reason:"user"}))}disconnect(){this._sseAbortController&&(this._sseAbortController.abort(),this._sseAbortController=null),this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Disconnected")),this._sessionReject&&(this._sessionReject(new Error("Disconnected")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&this._token&&this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(e=>e.stop()),this._micStream=null),this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._micMuted=!0,this._micSupported=!1,this._iceConnected=!1,this._dcOpen=!1,this._robots=[],this._state="disconnected",this._emit("disconnected",{reason:"user"})}setTarget({head:e,antennas:t,body_yaw:s}={}){const o={type:"set_full_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(i=>Number.isFinite(i)))throw new TypeError(`setTarget: head must be a 16-element flat row-major 4×4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);o.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(i=>Number.isFinite(i)))throw new TypeError(`setTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);o.antennas=t}if(s!==void 0){if(!Number.isFinite(s))throw new TypeError(`setTarget: body_yaw must be a finite number (radians); got ${s}`);o.body_yaw=s}return this._sendCommand(o)}gotoTarget({head:e,antennas:t,body_yaw:s,duration:o}){const i={type:"goto_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(r=>Number.isFinite(r)))throw new TypeError(`gotoTarget: head must be a 16-element flat row-major 4×4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);i.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(r=>Number.isFinite(r)))throw new TypeError(`gotoTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);i.antennas=t}if(s!==void 0){if(!Number.isFinite(s))throw new TypeError(`gotoTarget: body_yaw must be a finite number (radians); got ${s}`);i.body_yaw=s}if(!Number.isFinite(o)||o<=0)throw new TypeError(`gotoTarget: duration must be a positive finite number (seconds); got ${o}`);return i.duration=o,this._sendCommand(i)}setHeadRpyDeg(e,t,s){return this.setTarget({head:B(e,t,s).flat()})}setAntennasDeg(e,t){return this.setTarget({antennas:[S(e),S(t)]})}setBodyYawDeg(e){return this.setTarget({body_yaw:S(e)})}playSound(e){return this._sendCommand({type:"play_sound",file:e})}setMotorMode(e){return this._sendCommand({type:"set_motor_mode",mode:e})}setMotorTorque(e,t=null){return this._sendCommand({type:"set_torque",on:e,ids:t})}wakeUp({timeoutMs:e=8e3}={}){return this._sendCommand({type:"set_motor_mode",mode:"enabled"}),this._sendCommandAwaitCompletion("wake_up",e)}gotoSleep({timeoutMs:e=8e3}={}){return this._sendCommandAwaitCompletion("goto_sleep",e)}_sendCommandAwaitCompletion(e,t){return this._sendCommand({type:e})?new Promise((s,o)=>{const i={resolve:s,reject:o,timer:setTimeout(()=>{const r=this._pendingMotionCompletions[e],l=r.indexOf(i);l!==-1&&r.splice(l,1),o(new Error(`${e} timed out after ${t}ms`))},t)};this._pendingMotionCompletions[e].push(i)}):Promise.reject(new Error(`${e}: data channel not open`))}_rejectPendingMotionCompletions(e){for(const t of Object.keys(this._pendingMotionCompletions)){const s=this._pendingMotionCompletions[t];for(;s.length;){const o=s.shift();clearTimeout(o.timer),o.reject(e)}}}isAwake(){const e=this._robotState?.motor_mode;return e==="enabled"||e==="gravity_compensation"}async ensureAwake(e=1e3){return this._robotState?.motor_mode===void 0&&await new Promise(t=>{const s=()=>{this.removeEventListener("state",s),clearTimeout(o),t()},o=setTimeout(s,e);this.addEventListener("state",s),this.requestState()}),this.isAwake()||this.wakeUp().catch(()=>{}),!0}getVersion(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._versionResolve&&this._versionResolve(null),this._versionResolve=e,this._sendCommand({type:"get_version"})})}getHardwareId(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._hardwareIdResolve&&this._hardwareIdResolve(null),this._hardwareIdResolve=e,this._sendCommand({type:"get_hardware_id"})})}getVolume(){return this._slotRoundtrip(()=>this._volumeResolve,e=>{this._volumeResolve=e},{type:"get_volume"})}setVolume(e){return this._slotRoundtrip(()=>this._volumeResolve,t=>{this._volumeResolve=t},{type:"set_volume",volume:j(e)})}getMicrophoneVolume(){return this._slotRoundtrip(()=>this._micVolumeResolve,e=>{this._micVolumeResolve=e},{type:"get_microphone_volume"})}setMicrophoneVolume(e){return this._slotRoundtrip(()=>this._micVolumeResolve,t=>{this._micVolumeResolve=t},{type:"set_microphone_volume",volume:j(e)})}applyAudioConfig(e,{verify:t=!0}={}){return this._slotRoundtrip(()=>this._applyAudioConfigResolve,s=>{this._applyAudioConfigResolve=s},{type:"apply_audio_config",config:e,verify:t}).then(s=>s===!0)}readAudioParameter(e){return this._slotRoundtrip(()=>this._readAudioParameterResolve,t=>{this._readAudioParameterResolve=t},{type:"read_audio_parameter",name:e})}_slotRoundtrip(e,t,s){return new Promise((o,i)=>{if(!this._dc||this._dc.readyState!=="open"){i(new Error("Data channel not open"));return}const r=e();r&&r(null),t(o),this._sendCommand(s)})}sendRaw(e){return this._sendCommand(e)}subscribeLogs({onLine:e,onError:t}){if(typeof e!="function")throw new TypeError("subscribeLogs: onLine callback is required");const s={onLine:e,onError:t},o=this._logSubscribers.size===0;this._logSubscribers.add(s),o&&this._sendCommand({type:"subscribe_logs"});let i=!1;return()=>{i||(i=!0,this._logSubscribers.delete(s),this._logSubscribers.size===0&&this._sendCommand({type:"unsubscribe_logs"}))}}requestState(){return this._sendCommand({type:"get_state"})}setAudioMuted(e){this._audioMuted=e,this._videoElement&&(this._videoElement.muted=e)}setMicMuted(e){this._micMuted=e,this._micStream&&this._micStream.getAudioTracks().forEach(t=>{t.enabled=!e})}attachVideo(e){this._videoElement=e,e.muted=this._audioMuted;const t=o=>{const i=o;e.srcObject=i.detail.stream,e.playsInline=!0,"requestVideoFrameCallback"in e&&this._startLatencyMonitor(e)},s=()=>{e.srcObject=null};return this.addEventListener("videoTrack",t),this.addEventListener("sessionStopped",s),()=>{this.removeEventListener("videoTrack",t),this.removeEventListener("sessionStopped",s),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),e.srcObject=null,this._videoElement=null}}async playMove(e,{audioBlob:t=null,audioLeadMs:s=-100,description:o="move",encoding:i="gzip+base64",playFrequency:r=100,initialGotoDuration:l=0,startTimeoutMs:d=8e3,onProgress:a=()=>{},onStarted:u=()=>{}}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!e?.time?.length||!e?.set_target_data?.length)throw new Error("playMove: motion must have time + set_target_data");const h=U();this._activeMoveUploadId=h;const p={description:o,time:e.time,set_target_data:e.set_target_data},m=JSON.stringify(p);let y,v;i==="gzip+base64"&&J()?(y=await z(m),v="gzip+base64"):(y=m,v="json");const f=Math.ceil(y.length/w)||1;a({phase:"starting",sent:0,total:f,bytes:y.length,encoding:v}),this._sendCommand({type:"upload_move_start",upload_id:h,total_chunks:f,description:o,encoding:v});for(let c=0;c<f;c++){this._dc.bufferedAmount>E&&await this._awaitDataChannelDrain();const _=c*w;this._sendCommand({type:"upload_move_chunk",upload_id:h,chunk_index:c,chunk:y.slice(_,_+w)}),a({phase:"upload",sent:c+1,total:f})}if(this._sendCommand({type:"upload_move_finish",upload_id:h}),a({phase:"uploaded",sent:f,total:f}),t){const c=new Uint8Array(await t.arrayBuffer()),_=k(c),b=Math.ceil(_.length/w)||1;a({phase:"audio-starting",sent:0,total:b,bytes:_.length}),this._sendCommand({type:"upload_audio_start",upload_id:h,total_chunks:b,encoding:"wav-base64",description:o});for(let I=0;I<b;I++){this._dc.bufferedAmount>E&&await this._awaitDataChannelDrain();const M=I*w;this._sendCommand({type:"upload_audio_chunk",upload_id:h,chunk_index:I,chunk:_.slice(M,M+w)}),a({phase:"audio-upload",sent:I+1,total:b})}this._sendCommand({type:"upload_audio_finish",upload_id:h}),a({phase:"audio-uploaded",sent:b,total:b})}this._sendCommand({type:"play_uploaded_move",upload_id:h,play_frequency:r,initial_goto_duration:l,audio_lead_ms:s});let g;try{g=await this._waitForBroadcast(c=>c?.type==="play_uploaded_move"&&c?.upload_id===h&&(c.started===!0||typeof c.error=="string"),{timeoutMs:d,debugLabel:"play_uploaded_move started"})}catch(c){throw new Error(`Daemon did not respond to play_uploaded_move (requires the reachy_mini daemon with feature/daemon-side-move-upload). Underlying: ${c.message}`)}if(typeof g.error=="string")throw new Error(`play_uploaded_move: ${g.error}`);try{u({duration_s:g.duration_s,has_audio:g.has_audio===!0})}catch(c){console.warn("playMove.onStarted threw:",c)}a({phase:"playing",duration_s:g.duration_s});const R=await this._waitForBroadcast(c=>c?.type==="play_uploaded_move"&&c?.upload_id===h&&(c.finished===!0||c.cancelled===!0||typeof c.error=="string"),{timeoutMs:(g.duration_s+30)*1e3,debugLabel:"play_uploaded_move final"});return this._activeMoveUploadId===h&&(this._activeMoveUploadId=null),R}cancelMove(e=null){const t=e??this._activeMoveUploadId;return t?this._sendCommand({type:"cancel_move",upload_id:t}):!1}async uploadAudio(e,{description:t="audio",onProgress:s=()=>{}}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!(e instanceof Blob))throw new TypeError("uploadAudio: expected a Blob");const o=U(),i=new Uint8Array(await e.arrayBuffer()),r=k(i),l=Math.ceil(r.length/w)||1;s({phase:"audio-starting",sent:0,total:l,bytes:r.length}),this._sendCommand({type:"upload_audio_start",upload_id:o,total_chunks:l,encoding:"wav-base64",description:t});for(let d=0;d<l;d++){this._dc.bufferedAmount>E&&await this._awaitDataChannelDrain();const a=d*w;this._sendCommand({type:"upload_audio_chunk",upload_id:o,chunk_index:d,chunk:r.slice(a,a+w)}),s({phase:"audio-upload",sent:d+1,total:l})}return this._sendCommand({type:"upload_audio_finish",upload_id:o}),s({phase:"audio-uploaded",sent:l,total:l}),o}async playUploadedAudio(e,{timeoutMs:t=8e3}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");const s=this._waitForBroadcast(i=>i?.type==="play_uploaded_audio"&&i?.upload_id===e&&(i.started===!0||typeof i.error=="string"),{timeoutMs:t,debugLabel:"play_uploaded_audio started"});this._sendCommand({type:"play_uploaded_audio",upload_id:e});const o=await s;if(typeof o.error=="string")throw new Error(o.error);return this._activeAudioUploadId=e,o}cancelAudio(e=null){const t=e??this._activeAudioUploadId;return t?(this._activeAudioUploadId===t&&(this._activeAudioUploadId=null),this._sendCommand({type:"cancel_audio",upload_id:t})):!1}_emit(e,t){this.dispatchEvent(new CustomEvent(e,{detail:t}))}_waitForBroadcast(e,{timeoutMs:t=5e3,debugLabel:s=""}={}){return new Promise((o,i)=>{const r={predicate:e,resolve:o,timer:setTimeout(()=>{const l=this._broadcastWaiters.indexOf(r);l!==-1&&this._broadcastWaiters.splice(l,1),i(new Error(`broadcast timeout (${t} ms): ${s}`))},t)};this._broadcastWaiters.push(r)})}async _awaitDataChannelDrain(){for(;this._dc&&this._dc.bufferedAmount>W;)if(await new Promise(e=>setTimeout(e,30)),!this._dc||this._dc.readyState!=="open")throw new Error("data channel closed mid-upload")}async _sendToServer(e){if(!this._token)throw new Error("No token — authenticate() first");try{const t=await fetch(`${this._signalingUrl}/send`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this._token}`},body:JSON.stringify(e)});if(!t.ok){let s="";try{s=await t.text()}catch{}return console.warn(`[reachy-mini] /send rejected (${t.status}) for type=${e?.type}; body=${s||"<empty>"}`),null}return await t.json()}catch(t){return console.error("Send error:",t),null}}_sendCommand(e){return!this._dc||this._dc.readyState!=="open"?!1:(this._dc.send(JSON.stringify(e)),!0)}_checkSessionReady(){this._iceConnected&&this._dcOpen&&this._sessionResolve&&(this._state="streaming",this.requestState(),this._stateRefreshInterval=setInterval(()=>this.requestState(),500),this._emit("streaming",{sessionId:this._sessionId,robotId:this._selectedRobotId}),this._sessionResolve(),this._sessionResolve=null,this._sessionReject=null)}async _handleSignalingMessage(e){switch(e.type){case"welcome":break;case"list":this._robots=e.producers||[],this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart();break;case"peerStatusChanged":{const t=await this._sendToServer({type:"list"});t?.producers&&(this._robots=t.producers,this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart());break}case"sessionStarted":this._sessionId=e.sessionId??null;break;case"sessionRejected":this._failSessionRejected(e);break;case"endSession":this._handleEndSession(e);break;case"peer":this._handlePeerMessage(e);break}}_handleEndSession(e){const t=e.reason,s=t==="robot_busy_local_app"?"Robot is busy: a local Python app is running":t==="local_app_started"?"Disconnected: a local Python app started on the robot":t==="robot_busy_local"?"Robot is busy: another session is already active":null;if(this._sessionReject){const o=new Error(s||`Session ended before it could start: ${t||"unknown reason"}`);o.reason=t??null,this._emit("sessionRejected",{reason:t,activeApp:null}),this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(r=>r.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1;const i=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,i(o);return}this._state==="streaming"&&(this._emit("sessionStopped",{reason:t||"remote_end",message:s}),this.stopSession().catch(()=>{}))}async _handlePeerMessage(e){if(this._pc)try{if(e.sdp){const t=e.sdp;if(t.type==="offer"){const o=q(t.sdp);if(this._micSupported=o,this._emit("micSupported",{supported:o}),o&&this._micStream)for(const r of this._micStream.getAudioTracks())this._pc.addTrack(r,this._micStream);await this._pc.setRemoteDescription(new RTCSessionDescription(t));const i=await this._pc.createAnswer();await this._pc.setLocalDescription(i),await this._sendToServer({type:"peer",sessionId:this._sessionId,sdp:{type:"answer",sdp:i.sdp}})}else await this._pc.setRemoteDescription(new RTCSessionDescription(t));const s=this._pendingRemoteIce;if(s&&s.length){this._pendingRemoteIce=[];for(const o of s)try{await this._pc.addIceCandidate(new RTCIceCandidate(o))}catch(i){console.warn("[reachy-mini] buffered ICE candidate rejected:",i)}}}if(e.ice){if(!e.ice.candidate)return;this._pc.remoteDescription?await this._pc.addIceCandidate(new RTCIceCandidate(e.ice)):(this._pendingRemoteIce||(this._pendingRemoteIce=[]),this._pendingRemoteIce.push(e.ice))}}catch(t){console.error("WebRTC error:",t),this._emit("error",{source:"webrtc",error:t})}}_handleRobotMessage(e){if("version"in e&&this._versionResolve){this._versionResolve(e.version),this._versionResolve=null;return}if("hardware_id"in e&&this._hardwareIdResolve){this._hardwareIdResolve(e.hardware_id),this._hardwareIdResolve=null;return}if(e.command==="get_volume"||e.command==="set_volume"){this._volumeResolve&&(this._volumeResolve(e.status==="error"?null:e.volume),this._volumeResolve=null);return}if(e.command==="get_microphone_volume"||e.command==="set_microphone_volume"){this._micVolumeResolve&&(this._micVolumeResolve(e.status==="error"?null:e.volume),this._micVolumeResolve=null);return}if(e.command==="apply_audio_config"){this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(e.error?!1:!!e.applied),this._applyAudioConfigResolve=null);return}if(e.command==="read_audio_parameter"){this._readAudioParameterResolve&&(this._readAudioParameterResolve(e.error?null:e.values??null),this._readAudioParameterResolve=null);return}if((e.command==="wake_up"||e.command==="goto_sleep")&&this._pendingMotionCompletions&&this._pendingMotionCompletions[e.command]){const t=this._pendingMotionCompletions[e.command];if(e.completed===!0&&t.length>0){const s=t.shift();clearTimeout(s.timer),s.resolve();return}if(e.error&&t.length>0){const s=t.shift();clearTimeout(s.timer),s.reject(new Error(`${e.command}: ${e.error}`));return}}if(e.type==="log_line"){for(const t of this._logSubscribers)try{t.onLine({timestamp:e.timestamp,line:e.line})}catch(s){console.error("subscribeLogs onLine threw:",s)}return}if(e.type==="log_stream_error"){for(const t of this._logSubscribers)if(typeof t.onError=="function")try{t.onError(e.error)}catch(s){console.error("subscribeLogs onError threw:",s)}return}if(e.state){const t=e.state;t.head_pose&&(this._robotState.head=t.head_pose.flat()),t.antennas&&(this._robotState.antennas=[t.antennas[0],t.antennas[1]]),typeof t.body_yaw=="number"&&(this._robotState.body_yaw=t.body_yaw),t.motor_mode&&(this._robotState.motor_mode=t.motor_mode),typeof t.is_move_running=="boolean"&&(this._robotState.is_move_running=t.is_move_running),this._emit("state",{...this._robotState})}if(e.error&&this._emit("error",{source:"robot",error:e.error}),this._broadcastWaiters.length>0)for(let t=this._broadcastWaiters.length-1;t>=0;t--){const s=this._broadcastWaiters[t];if(s.predicate(e)){this._broadcastWaiters.splice(t,1),clearTimeout(s.timer),s.resolve(e);return}}}_startLatencyMonitor(e){this._latencyMonitorId&&clearInterval(this._latencyMonitorId),this._latencyMonitorId=setInterval(()=>{if(!e.srcObject||e.paused)return;const t=e.buffered;if(t.length>0){const s=t.end(t.length-1),o=s-e.currentTime;o>.5&&(console.log(`Latency correction: was ${o.toFixed(2)}s behind`),e.currentTime=s-.1)}},2e3)}}const K=1,H="reachy-mini";function Q(n){if(!n||typeof n!="object")return!1;const e=n;return e.source===H&&typeof e.type=="string"&&e.version===1}function Z(n){const e=JSON.stringify(n),t=G(e);return`creds=${encodeURIComponent(t)}`}function ee(n){if(!n)return null;const e=n.startsWith("#")?n.slice(1):n;for(const t of e.split("&"))if(t.startsWith("creds="))try{const s=decodeURIComponent(t.slice(6)),o=X(s);return JSON.parse(o)}catch(s){return console.warn("[reachy-mini-sdk/host] failed to decode creds hash",s),null}return null}function G(n){if(typeof window<"u"&&typeof window.btoa=="function"){const e=new TextEncoder().encode(n);let t="";for(const s of e)t+=String.fromCharCode(s);return window.btoa(t)}return Buffer.from(n,"utf8").toString("base64")}function X(n){if(typeof window<"u"&&typeof window.atob=="function"){const e=window.atob(n),t=new Uint8Array(e.length);for(let s=0;s<e.length;s++)t[s]=e.charCodeAt(s);return new TextDecoder().decode(t)}return Buffer.from(n,"base64").toString("utf8")}export{Y as J,ee as a,H as c,Q as d,K as f,Z as u};
|
|
|
|
|
|
|
|
|
|
|
|
icon.svg
DELETED
index.html
CHANGED
|
@@ -1,100 +1,1160 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
<html lang="en">
|
| 3 |
-
|
| 4 |
-
<meta charset="
|
| 5 |
-
<meta
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
/>
|
| 9 |
-
<
|
| 10 |
-
<link
|
| 11 |
-
|
| 12 |
-
<
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
<
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
| 28 |
<script>
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
<!--
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
</script>
|
| 88 |
-
|
| 89 |
-
</head>
|
| 90 |
-
<body>
|
| 91 |
-
<div id="root"></div>
|
| 92 |
-
<!--
|
| 93 |
-
Single dispatcher. Picks the host shell (standalone visit) or
|
| 94 |
-
the embedded app (host's iframe) based on `?embedded=1`.
|
| 95 |
-
`src/dispatch.ts` also self-assigns `window.ReachyMini` so the
|
| 96 |
-
host's own bootstrap (which historically read it from a CDN
|
| 97 |
-
`<script>`) keeps working without a CDN dep.
|
| 98 |
-
-->
|
| 99 |
-
</body>
|
| 100 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
| 6 |
+
<title>Reachy Mini - Pollen Robotics</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 11 |
+
<link rel="stylesheet" href="style.css">
|
| 12 |
+
<!-- Kick off both JS modules during HTML parse so they're in flight
|
| 13 |
+
before the bottom-of-body <script> executes. The embed entry
|
| 14 |
+
pulls two hashed chunks of its own which we can't preload
|
| 15 |
+
(the hashes drift across SDK releases), but front-loading the
|
| 16 |
+
two top-level modules still shaves ~hundreds of ms off a cold
|
| 17 |
+
CDN edge. -->
|
| 18 |
+
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.7.3-main.7654ffe/+esm" crossorigin>
|
| 19 |
+
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.7.3-main.7654ffe/host/dist/entry/embed.js" crossorigin>
|
| 20 |
+
<!-- Inline pre-paint script: if we're loaded as an iframe embed,
|
| 21 |
+
flip the DOM directly to the connected-shell view + a
|
| 22 |
+
"Connecting…" status pill BEFORE the JS module starts
|
| 23 |
+
running. Skips the visual flash of the Sign-in button while
|
| 24 |
+
connectToHost is busy doing its OAuth round-trip + WebRTC
|
| 25 |
+
handshake (which can be 3-8 s on a cold network). The script
|
| 26 |
+
runs after </head> only because the elements it touches are
|
| 27 |
+
in <body>; the cost of moving this script to a `defer="false"`
|
| 28 |
+
block at the start of <body> is one DOM-ready paint that we
|
| 29 |
+
already pay for. -->
|
| 30 |
<script>
|
| 31 |
+
(function () {
|
| 32 |
+
try {
|
| 33 |
+
const params = new URLSearchParams(window.location.search);
|
| 34 |
+
if (params.get('embedded') !== '1') return;
|
| 35 |
+
// Defer the DOM mutations until after the elements
|
| 36 |
+
// exist (we're parsed before <body>). DOMContentLoaded
|
| 37 |
+
// fires once the body is done parsing — cheap, no UX
|
| 38 |
+
// cost vs. the seconds-long connectToHost.
|
| 39 |
+
document.addEventListener('DOMContentLoaded', function () {
|
| 40 |
+
const login = document.getElementById('loginView');
|
| 41 |
+
const main = document.getElementById('mainApp');
|
| 42 |
+
const statusText = document.getElementById('statusText');
|
| 43 |
+
const statusIndicator = document.getElementById('statusIndicator');
|
| 44 |
+
if (login) login.classList.add('hidden');
|
| 45 |
+
if (main) main.classList.remove('hidden');
|
| 46 |
+
if (statusIndicator) statusIndicator.className = 'status-indicator connecting';
|
| 47 |
+
if (statusText) statusText.textContent = 'Connecting…';
|
| 48 |
+
});
|
| 49 |
+
} catch (e) {
|
| 50 |
+
// Best-effort UX polish — never block boot.
|
| 51 |
+
}
|
| 52 |
+
})();
|
| 53 |
</script>
|
| 54 |
+
</head>
|
| 55 |
+
<body>
|
| 56 |
+
<!-- Login View -->
|
| 57 |
+
<div id="loginView" class="login-view">
|
| 58 |
+
<div class="login-card">
|
| 59 |
+
<img class="login-logo" src="https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png" alt="Reachy Mini">
|
| 60 |
+
<h2>Reachy Mini</h2>
|
| 61 |
+
<p>Sign in with your HuggingFace account to connect and control your robot remotely.</p>
|
| 62 |
+
<button class="btn-hf" onclick="loginToHuggingFace()">
|
| 63 |
+
<svg width="18" height="18" viewBox="0 0 95 88" fill="currentColor">
|
| 64 |
+
<path d="M47.5 0C26.3 0 9.1 17.2 9.1 38.4v2.9c0 4.5 1.1 9 3.2 13L0 88h95L82.7 54.3c2.1-4 3.2-8.5 3.2-13v-2.9C85.9 17.2 68.7 0 47.5 0z"/>
|
| 65 |
+
</svg>
|
| 66 |
+
Sign in with Hugging Face
|
| 67 |
+
</button>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
|
| 71 |
+
<!-- Main App -->
|
| 72 |
+
<div id="mainApp" class="hidden">
|
| 73 |
+
<header class="header">
|
| 74 |
+
<div class="logo">
|
| 75 |
+
<img src="https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png" alt="Reachy Mini">
|
| 76 |
+
<div class="logo-text">Reachy Mini <span>by Pollen Robotics</span></div>
|
| 77 |
+
</div>
|
| 78 |
+
<div class="user-section">
|
| 79 |
+
<!-- Daemon version is filled in once the WebRTC data channel
|
| 80 |
+
is open (via robot.getVersion()). Hidden until then so
|
| 81 |
+
we don't show a stale/empty badge during connect. -->
|
| 82 |
+
<div id="daemonVersion" class="user-badge hidden" title="Daemon version"></div>
|
| 83 |
+
<div class="user-badge"><span id="username">@user</span></div>
|
| 84 |
+
<button class="btn-logout" onclick="logout()">Sign out</button>
|
| 85 |
+
</div>
|
| 86 |
+
</header>
|
| 87 |
+
|
| 88 |
+
<div class="app-container">
|
| 89 |
+
<!-- Video -->
|
| 90 |
+
<div class="video-container">
|
| 91 |
+
<video id="remoteVideo" autoplay playsinline muted></video>
|
| 92 |
+
|
| 93 |
+
<div class="video-overlay-top">
|
| 94 |
+
<div class="connection-badge">
|
| 95 |
+
<div class="status-indicator" id="statusIndicator"></div>
|
| 96 |
+
<span id="statusText">Disconnected</span>
|
| 97 |
+
</div>
|
| 98 |
+
<div class="robot-name" id="robotName"></div>
|
| 99 |
+
<div class="latency-badge hidden" id="latencyBadge">
|
| 100 |
+
<span id="latencyValue">--</span>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<div class="video-overlay-bottom">
|
| 105 |
+
<div class="video-controls">
|
| 106 |
+
<!--
|
| 107 |
+
Connect button drives the all-in-one autoConnect()
|
| 108 |
+
flow: SSE → robot selection (auto-pick if one free,
|
| 109 |
+
else pickRobot callback opens the panel) →
|
| 110 |
+
startSession → ensureAwake. The legacy two-step
|
| 111 |
+
Connect+Start split is gone; Start is kept hidden
|
| 112 |
+
for any consumer still wiring against #startBtn.
|
| 113 |
+
-->
|
| 114 |
+
<button class="btn btn-primary" id="connectBtn" onclick="connectAndStream()">Connect</button>
|
| 115 |
+
<button class="btn btn-primary hidden" id="startBtn" onclick="startStream()" disabled>Start</button>
|
| 116 |
+
<button class="btn btn-danger" id="stopBtn" onclick="stopStream()" disabled>Stop</button>
|
| 117 |
+
<button class="btn btn-mute muted" id="muteBtn" onclick="toggleMute()" disabled>
|
| 118 |
+
<svg id="speakerOffIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 119 |
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
| 120 |
+
<line x1="23" y1="9" x2="17" y2="15"></line>
|
| 121 |
+
<line x1="17" y1="9" x2="23" y2="15"></line>
|
| 122 |
+
</svg>
|
| 123 |
+
<svg id="speakerOnIcon" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 124 |
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
| 125 |
+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
| 126 |
+
</svg>
|
| 127 |
+
<span id="muteText">Unmute</span>
|
| 128 |
+
</button>
|
| 129 |
+
<button class="btn btn-mute muted" id="micBtn" onclick="toggleMic()" disabled>
|
| 130 |
+
<svg id="micOffIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 131 |
+
<line x1="1" y1="1" x2="23" y2="23"></line>
|
| 132 |
+
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
|
| 133 |
+
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2c0 .76-.13 1.49-.35 2.17"></path>
|
| 134 |
+
<line x1="12" y1="19" x2="12" y2="23"></line>
|
| 135 |
+
<line x1="8" y1="23" x2="16" y2="23"></line>
|
| 136 |
+
</svg>
|
| 137 |
+
<svg id="micOnIcon" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 138 |
+
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
| 139 |
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
| 140 |
+
<line x1="12" y1="19" x2="12" y2="23"></line>
|
| 141 |
+
<line x1="8" y1="23" x2="16" y2="23"></line>
|
| 142 |
+
</svg>
|
| 143 |
+
<span id="micText">Mic Off</span>
|
| 144 |
+
</button>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
<!-- Robot Selector -->
|
| 151 |
+
<div id="robotSelector" class="panel hidden">
|
| 152 |
+
<div class="panel-header">Available Robots</div>
|
| 153 |
+
<div class="panel-content">
|
| 154 |
+
<div id="robotList" class="robot-list">
|
| 155 |
+
<div style="color: var(--text-muted); font-size: 0.85em;">Searching...</div>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
<!-- Head Control - RPY Sliders -->
|
| 161 |
+
<div class="panel">
|
| 162 |
+
<div class="panel-header">Head Orientation</div>
|
| 163 |
+
<div class="panel-content">
|
| 164 |
+
<div class="slider-row">
|
| 165 |
+
<span class="slider-label">Roll</span>
|
| 166 |
+
<input type="range" class="slider" id="rollSlider" min="-20" max="20" value="0" step="0.5">
|
| 167 |
+
<span class="slider-value" id="rollValue">0.0°</span>
|
| 168 |
+
</div>
|
| 169 |
+
<div class="slider-row">
|
| 170 |
+
<span class="slider-label">Pitch</span>
|
| 171 |
+
<input type="range" class="slider" id="pitchSlider" min="-30" max="30" value="0" step="0.5">
|
| 172 |
+
<span class="slider-value" id="pitchValue">0.0°</span>
|
| 173 |
+
</div>
|
| 174 |
+
<div class="slider-row">
|
| 175 |
+
<span class="slider-label">Yaw</span>
|
| 176 |
+
<input type="range" class="slider" id="yawSlider" min="-160" max="160" value="0" step="0.5">
|
| 177 |
+
<span class="slider-value" id="yawValue">0.0°</span>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
<!-- Body / Base Yaw -->
|
| 183 |
+
<div class="panel">
|
| 184 |
+
<div class="panel-header">Body</div>
|
| 185 |
+
<div class="panel-content">
|
| 186 |
+
<div class="slider-row">
|
| 187 |
+
<span class="slider-label">Yaw</span>
|
| 188 |
+
<input type="range" class="slider" id="bodyYawSlider" min="-160" max="160" value="0" step="1">
|
| 189 |
+
<span class="slider-value" id="bodyYawValue">0°</span>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<!-- Antennas -->
|
| 195 |
+
<div class="panel">
|
| 196 |
+
<div class="panel-header">Antennas</div>
|
| 197 |
+
<div class="panel-content">
|
| 198 |
+
<div class="slider-row">
|
| 199 |
+
<span class="slider-label">Right</span>
|
| 200 |
+
<input type="range" class="slider" id="rightAntSlider" min="-175" max="175" value="0">
|
| 201 |
+
<span class="slider-value" id="rightAntValue">0°</span>
|
| 202 |
+
</div>
|
| 203 |
+
<div class="slider-row">
|
| 204 |
+
<span class="slider-label">Left</span>
|
| 205 |
+
<input type="range" class="slider" id="leftAntSlider" min="-175" max="175" value="0">
|
| 206 |
+
<span class="slider-value" id="leftAntValue">0°</span>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
<!-- Animations -->
|
| 212 |
+
<div class="panel">
|
| 213 |
+
<div class="panel-header">Animations</div>
|
| 214 |
+
<div class="panel-content">
|
| 215 |
+
<div class="sound-row">
|
| 216 |
+
<button class="btn btn-primary" id="btnWakeUp" onclick="playWakeUp()" disabled>Wake up</button>
|
| 217 |
+
<button class="btn btn-secondary" id="btnGotoSleep" onclick="playGotoSleep()" disabled>Go to sleep</button>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<!-- Torque -->
|
| 223 |
+
<div class="panel">
|
| 224 |
+
<div class="panel-header">Torque</div>
|
| 225 |
+
<div class="panel-content">
|
| 226 |
+
<div class="sound-row">
|
| 227 |
+
<!-- Label and action flip based on the current motor
|
| 228 |
+
mode, which is surfaced via the 'state' event. -->
|
| 229 |
+
<button class="btn btn-primary" id="btnTorqueToggle" onclick="toggleTorque()" disabled>Torque …</button>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
<!-- Sound -->
|
| 235 |
+
<div class="panel">
|
| 236 |
+
<div class="panel-header">Sound</div>
|
| 237 |
+
<div class="panel-content">
|
| 238 |
+
<div class="sound-row">
|
| 239 |
+
<input type="text" class="sound-input" id="soundInput" placeholder="Sound file...">
|
| 240 |
+
<button class="btn btn-primary" id="btnPlaySound" onclick="playSound()" disabled>Play</button>
|
| 241 |
+
</div>
|
| 242 |
+
<div class="sound-presets">
|
| 243 |
+
<span class="preset-chip" onclick="playSoundPreset('wake_up.wav')">wake_up</span>
|
| 244 |
+
<span class="preset-chip" onclick="playSoundPreset('go_sleep.wav')">go_sleep</span>
|
| 245 |
+
<span class="preset-chip" onclick="playSoundPreset('yes.wav')">yes</span>
|
| 246 |
+
<span class="preset-chip" onclick="playSoundPreset('no.wav')">no</span>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
<!-- Volume -->
|
| 252 |
+
<div class="panel">
|
| 253 |
+
<div class="panel-header">Volume</div>
|
| 254 |
+
<div class="panel-content">
|
| 255 |
+
<div class="slider-row">
|
| 256 |
+
<span class="slider-label">Speaker</span>
|
| 257 |
+
<input type="range" class="slider" id="volumeSlider" min="0" max="100" value="50" disabled>
|
| 258 |
+
<span class="slider-value" id="volumeValue">--</span>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<!-- Daemon logs -->
|
| 264 |
+
<div class="panel">
|
| 265 |
+
<div class="panel-header">
|
| 266 |
+
Daemon logs
|
| 267 |
+
<button class="btn btn-secondary" id="btnClearLogs" onclick="clearLogs()" style="margin-left:auto;font-size:0.8em;padding:4px 8px;">Clear</button>
|
| 268 |
+
</div>
|
| 269 |
+
<div class="panel-content">
|
| 270 |
+
<pre id="logsView" style="max-height:240px;overflow:auto;background:#0b0b0b;color:#c8c8c8;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;padding:8px;margin:0;white-space:pre-wrap;word-break:break-word;border-radius:6px;">Waiting for stream...</pre>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
<script type="module">
|
| 278 |
+
// Both imports below pull from the same npm package version:
|
| 279 |
+
// @pollen-robotics/reachy-mini-sdk@1.7.3-main.7654ffe.
|
| 280 |
+
//
|
| 281 |
+
// The package exports:
|
| 282 |
+
// - `.` (root) → reachy-mini-sdk.js (the SDK runtime:
|
| 283 |
+
// ReachyMini class,
|
| 284 |
+
// matrix/RPY helpers,
|
| 285 |
+
// autoConnect, etc.)
|
| 286 |
+
// - `./host/embed` → host/dist/entry/embed.js (the embed-
|
| 287 |
+
// side helper that
|
| 288 |
+
// decodes `#creds=`
|
| 289 |
+
// and runs auth +
|
| 290 |
+
// startSession +
|
| 291 |
+
// ensureAwake)
|
| 292 |
+
//
|
| 293 |
+
// The 1.7.3 stable tag publishes ONLY the root (no `host/` subpath
|
| 294 |
+
// yet), so we use the `1.7.3-main.<sha>` prerelease auto-published
|
| 295 |
+
// by CI on every push to main. Pin to a specific `<sha>` (the
|
| 296 |
+
// mobile app pins the same in lockstep). Bump when a stable that
|
| 297 |
+
// ships `host/` lands on npm.
|
| 298 |
+
//
|
| 299 |
+
// SDK URL ends with `/+esm` (a jsdelivr-specific endpoint) rather
|
| 300 |
+
// than the literal `dist/reachy-mini-sdk.js`. `/+esm` runs a Rollup
|
| 301 |
+
// + Terser pass on jsdelivr's edge that inlines the SDK's bare
|
| 302 |
+
// npm dependencies (currently `@huggingface/hub` for OAuth) into a
|
| 303 |
+
// single self-contained ESM bundle. Without it, the browser fails
|
| 304 |
+
// on `import "@huggingface/hub"` (bare specifiers don't resolve in
|
| 305 |
+
// browser-direct ESM without a bundler or import map). The host
|
| 306 |
+
// bundle is already Vite-built and self-contained, so it points
|
| 307 |
+
// at the raw `host/dist/entry/embed.js` path directly.
|
| 308 |
+
//
|
| 309 |
+
// jsdelivr serves npm versions directly — no manual purge needed
|
| 310 |
+
// when bumping the pin (npm tarballs are immutable, the CDN edge
|
| 311 |
+
// refreshes naturally for each new version string).
|
| 312 |
+
import { ReachyMini, matrixToRpy, radToDeg, rpyToMatrix, degToRad } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.7.3-main.7654ffe/+esm";
|
| 313 |
+
// Host-embed entry. Loading this module side-effect-installs
|
| 314 |
+
// `window.ReachyMini` (if unset) and exposes `connectToHost`,
|
| 315 |
+
// which decodes the `#creds=<base64>` bundle the mobile shell
|
| 316 |
+
// writes into the URL hash, instantiates the SDK against those
|
| 317 |
+
// creds, and runs auth + startSession + ensureAwake before
|
| 318 |
+
// resolving. Standalone visits never touch it — the dispatcher
|
| 319 |
+
// below branches on `?embedded=1`.
|
| 320 |
+
import { connectToHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.7.3-main.7654ffe/host/dist/entry/embed.js";
|
| 321 |
+
|
| 322 |
+
// `let` (not `const`) because the embed path below replaces this
|
| 323 |
+
// module-level SDK with the live instance returned by
|
| 324 |
+
// connectToHost(). The pre-constructed object is harmless: its
|
| 325 |
+
// ctor doesn't open sockets, so dropping it on the floor in
|
| 326 |
+
// embed mode is fine.
|
| 327 |
+
let robot = new ReachyMini({
|
| 328 |
+
appName: "Reachy Mini WebRTC Demo",
|
| 329 |
+
// Pin the org-owned canonical central. The SDK default
|
| 330 |
+
// still tracks an older instance, so we keep this
|
| 331 |
+
// explicit for self-documentation.
|
| 332 |
+
signalingUrl: 'https://pollen-robotics-reachy-mini-central.hf.space',
|
| 333 |
+
// NB: `autoStartFromUrl: true` is intentionally NOT set.
|
| 334 |
+
// We use `autoConnect()` for embed bring-up; both running
|
| 335 |
+
// at once raced two `startSession()` calls against the
|
| 336 |
+
// same preselected robot, with central rejecting the
|
| 337 |
+
// second one as "Robot is busy: <our own appName>". The
|
| 338 |
+
// SDK now defensively suppresses `autoStartFromUrl` while
|
| 339 |
+
// `autoConnect()` is in-flight, so this is just docs and
|
| 340 |
+
// belt-and-suspenders.
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
let selectedRobotId = null;
|
| 344 |
+
let headSlidersActive = false;
|
| 345 |
+
let bodyYawSliderActive = false;
|
| 346 |
+
// Last body yaw the user committed via the slider, in degrees.
|
| 347 |
+
// Used to compute the delta we need to subtract from the head yaw
|
| 348 |
+
// command so the head visually fixates a point in world frame
|
| 349 |
+
// while the base rotates.
|
| 350 |
+
let lastBodyYawDeg = 0;
|
| 351 |
+
let detachVideo = null;
|
| 352 |
+
let latencyIntervalId = null;
|
| 353 |
+
// Returned by robot.subscribeLogs(); call to stop the daemon-side
|
| 354 |
+
// journalctl stream. Reset on every sessionStopped.
|
| 355 |
+
let logsUnsub = null;
|
| 356 |
+
// Cap the logs panel so a long session doesn't grow without bound.
|
| 357 |
+
const LOGS_MAX_LINES = 500;
|
| 358 |
+
|
| 359 |
+
// Export functions for inline onclick handlers
|
| 360 |
+
window.loginToHuggingFace = () => robot.login();
|
| 361 |
+
window.logout = logout;
|
| 362 |
+
window.connectAndStream = connectAndStream;
|
| 363 |
+
// Kept exported for any deep-link / debugger script that still
|
| 364 |
+
// pokes them by name — the in-app buttons now route through
|
| 365 |
+
// connectAndStream() instead.
|
| 366 |
+
window.connectSignaling = connectAndStream;
|
| 367 |
+
window.startStream = connectAndStream;
|
| 368 |
+
window.stopStream = stopStream;
|
| 369 |
+
window.playSound = playSound;
|
| 370 |
+
window.playSoundPreset = playSoundPreset;
|
| 371 |
+
window.playWakeUp = playWakeUp;
|
| 372 |
+
window.playGotoSleep = playGotoSleep;
|
| 373 |
+
window.toggleTorque = toggleTorque;
|
| 374 |
+
window.toggleMute = toggleMute;
|
| 375 |
+
window.toggleMic = toggleMic;
|
| 376 |
+
window.clearLogs = clearLogs;
|
| 377 |
+
|
| 378 |
+
document.addEventListener('DOMContentLoaded', async () => {
|
| 379 |
+
// Dispatcher: `?embedded=1` means we're loaded inside the
|
| 380 |
+
// mobile-app shell's iframe. The shell passes credentials
|
| 381 |
+
// in the URL hash as a base64 `creds=` bundle (token +
|
| 382 |
+
// username + robotPeerId + signalingUrl + theme + config).
|
| 383 |
+
// We hand control to `connectToHost`, which decodes that
|
| 384 |
+
// bundle and brings the WebRTC session up before resolving.
|
| 385 |
+
// Standalone visits fall through to the existing
|
| 386 |
+
// authenticate() → user-driven Connect flow.
|
| 387 |
+
const isEmbed =
|
| 388 |
+
new URLSearchParams(window.location.search).get('embedded') === '1';
|
| 389 |
+
|
| 390 |
+
if (isEmbed) {
|
| 391 |
+
try {
|
| 392 |
+
const handle = await connectToHost();
|
| 393 |
+
// Swap the module-level SDK for the live instance
|
| 394 |
+
// the host handed back. All closures below that
|
| 395 |
+
// call `robot.*` resolve `robot` at call time, so
|
| 396 |
+
// they pick up the new value automatically.
|
| 397 |
+
robot = handle.reachy;
|
| 398 |
+
// The host attached the inbound track on the
|
| 399 |
+
// handle's media surface; replay it into our <video>.
|
| 400 |
+
handle.media.attachVideo(document.getElementById('remoteVideo'));
|
| 401 |
+
initSliders();
|
| 402 |
+
initRobotEvents();
|
| 403 |
+
showMainApp();
|
| 404 |
+
// The session is already up. Flip the UI directly
|
| 405 |
+
// to the "connected" visual state — same set of
|
| 406 |
+
// toggles the `streaming` event handler runs in
|
| 407 |
+
// the standalone flow.
|
| 408 |
+
document.getElementById('robotSelector').classList.add('hidden');
|
| 409 |
+
updateStatus('connected', 'Connected');
|
| 410 |
+
enableControls(true);
|
| 411 |
+
document.getElementById('connectBtn').disabled = true;
|
| 412 |
+
document.getElementById('stopBtn').disabled = false;
|
| 413 |
+
startLatencyDisplay();
|
| 414 |
+
fetchDaemonVersionOnce();
|
| 415 |
+
syncVolumeSlider();
|
| 416 |
+
startLogsStream();
|
| 417 |
+
} catch (err) {
|
| 418 |
+
console.error('[embed] connectToHost failed:', err);
|
| 419 |
+
updateStatus(
|
| 420 |
+
'',
|
| 421 |
+
'Embed connect failed: ' + (err?.message || err),
|
| 422 |
+
);
|
| 423 |
+
}
|
| 424 |
+
return;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
// Standalone path — unchanged behaviour. Bind the <video>
|
| 428 |
+
// sink eagerly (attachVideo is lazy: it remembers the
|
| 429 |
+
// element and wires the inbound track when it arrives),
|
| 430 |
+
// then run authenticate(). Falls through to the login
|
| 431 |
+
// screen when no cached token is found.
|
| 432 |
+
initSliders();
|
| 433 |
+
initRobotEvents();
|
| 434 |
+
detachVideo = robot.attachVideo(document.getElementById('remoteVideo'));
|
| 435 |
+
if (await robot.authenticate()) {
|
| 436 |
+
showMainApp();
|
| 437 |
+
} else {
|
| 438 |
+
showLogin();
|
| 439 |
+
}
|
| 440 |
+
});
|
| 441 |
+
|
| 442 |
+
// ===================== Auth =====================
|
| 443 |
+
function logout() {
|
| 444 |
+
if (detachVideo) { detachVideo(); detachVideo = null; }
|
| 445 |
+
robot.logout();
|
| 446 |
+
showLogin();
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
function showLogin() {
|
| 450 |
+
document.getElementById('loginView').classList.remove('hidden');
|
| 451 |
+
document.getElementById('mainApp').classList.add('hidden');
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
function showMainApp() {
|
| 455 |
+
document.getElementById('loginView').classList.add('hidden');
|
| 456 |
+
document.getElementById('mainApp').classList.remove('hidden');
|
| 457 |
+
document.getElementById('username').textContent = '@' + robot.username;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
// ===================== Robot Events =====================
|
| 461 |
+
function initRobotEvents() {
|
| 462 |
+
// The SSE `list` push fires on every producer come-and-go.
|
| 463 |
+
// We don't drive selection from it any more — autoConnect's
|
| 464 |
+
// pickRobot callback owns the picker render — but we still
|
| 465 |
+
// dim cards whose robot dropped off while the panel is open.
|
| 466 |
+
robot.addEventListener('robotsChanged', (e) => refreshPickerOnList(e.detail.robots));
|
| 467 |
+
|
| 468 |
+
robot.addEventListener('streaming', () => {
|
| 469 |
+
updateStatus('connected', 'Connected');
|
| 470 |
+
enableControls(true);
|
| 471 |
+
document.getElementById('connectBtn').disabled = true;
|
| 472 |
+
document.getElementById('stopBtn').disabled = false;
|
| 473 |
+
document.getElementById('robotSelector').classList.add('hidden');
|
| 474 |
+
startLatencyDisplay();
|
| 475 |
+
// Data channel is open — fetch daemon version (one-shot) and
|
| 476 |
+
// sync the volume slider to the robot's current level. Both
|
| 477 |
+
// swallow failures so a missing command handler or an
|
| 478 |
+
// unsupported audio platform doesn't break the UI.
|
| 479 |
+
// Wake/torque is already handled inside autoConnect() —
|
| 480 |
+
// no ensureAwakeOnce() call needed here.
|
| 481 |
+
fetchDaemonVersionOnce();
|
| 482 |
+
syncVolumeSlider();
|
| 483 |
+
startLogsStream();
|
| 484 |
+
});
|
| 485 |
+
|
| 486 |
+
robot.addEventListener('sessionStopped', (e) => {
|
| 487 |
+
document.getElementById('connectBtn').disabled = false;
|
| 488 |
+
document.getElementById('stopBtn').disabled = true;
|
| 489 |
+
document.getElementById('robotSelector').classList.remove('hidden');
|
| 490 |
+
enableControls(false);
|
| 491 |
+
// e.detail.message is set when the stop was server-initiated (e.g.
|
| 492 |
+
// a local Python app took over the robot). null for user-initiated.
|
| 493 |
+
updateStatus('connected', e.detail?.message || 'Connected');
|
| 494 |
+
updateMicButton();
|
| 495 |
+
stopLatencyDisplay();
|
| 496 |
+
// Disable the volume slider and clear the version pill — they
|
| 497 |
+
// only make sense while the data channel is open.
|
| 498 |
+
document.getElementById('volumeSlider').disabled = true;
|
| 499 |
+
document.getElementById('volumeValue').textContent = '--';
|
| 500 |
+
document.getElementById('daemonVersion').classList.add('hidden');
|
| 501 |
+
// Reset the torque button label so it doesn't claim a stale
|
| 502 |
+
// state between sessions.
|
| 503 |
+
const tb = document.getElementById('btnTorqueToggle');
|
| 504 |
+
tb.textContent = 'Torque …';
|
| 505 |
+
delete tb.dataset.currentMode;
|
| 506 |
+
stopLogsStream();
|
| 507 |
+
});
|
| 508 |
+
|
| 509 |
+
robot.addEventListener('state', (e) => updateStateDisplay(e.detail));
|
| 510 |
+
|
| 511 |
+
robot.addEventListener('micSupported', () => {
|
| 512 |
+
enableControls(robot.state === 'streaming');
|
| 513 |
+
});
|
| 514 |
+
|
| 515 |
+
robot.addEventListener('disconnected', () => {
|
| 516 |
+
updateStatus('', 'Disconnected');
|
| 517 |
+
document.getElementById('connectBtn').disabled = false;
|
| 518 |
+
document.getElementById('robotSelector').classList.add('hidden');
|
| 519 |
+
});
|
| 520 |
+
|
| 521 |
+
robot.addEventListener('error', (e) => {
|
| 522 |
+
console.error(`[${e.detail.source}]`, e.detail.error);
|
| 523 |
+
if (e.detail.source === 'webrtc') {
|
| 524 |
+
updateStatus('', 'Connection lost');
|
| 525 |
+
}
|
| 526 |
+
});
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
// ===================== Connection =====================
|
| 530 |
+
function updateStatus(status, text) {
|
| 531 |
+
document.getElementById('statusIndicator').className = 'status-indicator ' + status;
|
| 532 |
+
document.getElementById('statusText').textContent = text;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
// Wake-on-stream is handled inside autoConnect() now (it calls
|
| 536 |
+
// robot.ensureAwake() after startSession resolves), so no
|
| 537 |
+
// ensureAwakeOnce() wrapper lives here any more.
|
| 538 |
+
|
| 539 |
+
// ---- Daemon version (fetched once per session over WebRTC) ----
|
| 540 |
+
async function fetchDaemonVersionOnce() {
|
| 541 |
+
try {
|
| 542 |
+
const v = await robot.getVersion();
|
| 543 |
+
if (!v) return;
|
| 544 |
+
const el = document.getElementById('daemonVersion');
|
| 545 |
+
el.textContent = `Daemon v${v}`;
|
| 546 |
+
el.classList.remove('hidden');
|
| 547 |
+
} catch (e) {
|
| 548 |
+
// Data channel closed mid-request, or lib version too old
|
| 549 |
+
// to support get_version. Non-fatal — but log at WARN so
|
| 550 |
+
// a stale CDN-cached lib (missing the method) is visible
|
| 551 |
+
// in the browser console without "Verbose" level turned on.
|
| 552 |
+
console.warn('getVersion failed:', e);
|
| 553 |
+
}
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
// ---- Volume slider wiring ----
|
| 557 |
+
//
|
| 558 |
+
// Two events matter:
|
| 559 |
+
// - 'input' fires continuously while dragging — used for the
|
| 560 |
+
// live "75%" label so the UI feels responsive.
|
| 561 |
+
// - 'change' fires on release — the only moment we actually
|
| 562 |
+
// call setVolume over WebRTC. Avoids spamming the
|
| 563 |
+
// robot with one command per pixel of drag.
|
| 564 |
+
//
|
| 565 |
+
// syncVolumeSlider() runs on streaming-start to pull the robot's
|
| 566 |
+
// real current volume so the slider reflects reality rather than
|
| 567 |
+
// the default "50" in the markup.
|
| 568 |
+
async function syncVolumeSlider() {
|
| 569 |
+
const slider = document.getElementById('volumeSlider');
|
| 570 |
+
const label = document.getElementById('volumeValue');
|
| 571 |
+
// Early detection: if the loaded JS lib predates the volume
|
| 572 |
+
// commands (stale jsdelivr cache, old branch), fail loudly
|
| 573 |
+
// rather than silently rendering "n/a".
|
| 574 |
+
if (typeof robot.getVolume !== 'function') {
|
| 575 |
+
console.warn(
|
| 576 |
+
'robot.getVolume is not a function — the reachy-mini.js ' +
|
| 577 |
+
'lib loaded from CDN is older than the webrtc_example UI. ' +
|
| 578 |
+
'Purge jsdelivr and hard-refresh the page.'
|
| 579 |
+
);
|
| 580 |
+
label.textContent = 'lib';
|
| 581 |
+
return;
|
| 582 |
+
}
|
| 583 |
+
try {
|
| 584 |
+
const v = await robot.getVolume();
|
| 585 |
+
if (v == null) {
|
| 586 |
+
// Volume control unavailable (unsupported platform or
|
| 587 |
+
// audio stack down) — keep the slider disabled.
|
| 588 |
+
label.textContent = 'n/a';
|
| 589 |
+
return;
|
| 590 |
+
}
|
| 591 |
+
slider.value = v;
|
| 592 |
+
label.textContent = `${v}%`;
|
| 593 |
+
slider.disabled = false;
|
| 594 |
+
} catch (e) {
|
| 595 |
+
// Log at WARN so it's visible without "Verbose" level.
|
| 596 |
+
console.warn('getVolume failed:', e);
|
| 597 |
+
label.textContent = 'n/a';
|
| 598 |
+
}
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
// ===================== Daemon log stream =====================
|
| 602 |
+
//
|
| 603 |
+
// The daemon exposes its `journalctl -u reachy-mini-daemon` lines as
|
| 604 |
+
// typed `log_line` messages on the WebRTC data channel. We open the
|
| 605 |
+
// subscription on streaming-start and tear it down on stop so the
|
| 606 |
+
// daemon-side subprocess only runs while a peer is actively viewing.
|
| 607 |
+
// The same SDK call works for the LAN-direct GStreamer signaling
|
| 608 |
+
// path and the Central + WebRTC remote path — both share the typed
|
| 609 |
+
// command surface.
|
| 610 |
+
function appendLogLine({ timestamp, line }) {
|
| 611 |
+
const view = document.getElementById('logsView');
|
| 612 |
+
if (!view) return;
|
| 613 |
+
const wasInitial = view.firstChild && view.firstChild.nodeType === Node.TEXT_NODE
|
| 614 |
+
&& view.textContent === 'Waiting for stream...';
|
| 615 |
+
if (wasInitial) view.textContent = '';
|
| 616 |
+
// Auto-scroll only if the user is already pinned to the bottom;
|
| 617 |
+
// otherwise let them browse history without snapping back.
|
| 618 |
+
const stick = view.scrollTop + view.clientHeight >= view.scrollHeight - 4;
|
| 619 |
+
const div = document.createElement('div');
|
| 620 |
+
div.textContent = timestamp ? `${timestamp} ${line}` : line;
|
| 621 |
+
view.appendChild(div);
|
| 622 |
+
// Trim oldest lines past the cap so the DOM doesn't grow forever.
|
| 623 |
+
while (view.childElementCount > LOGS_MAX_LINES) {
|
| 624 |
+
view.removeChild(view.firstChild);
|
| 625 |
+
}
|
| 626 |
+
if (stick) view.scrollTop = view.scrollHeight;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
function startLogsStream() {
|
| 630 |
+
if (typeof robot.subscribeLogs !== 'function') {
|
| 631 |
+
console.warn(
|
| 632 |
+
'robot.subscribeLogs is not a function — the reachy-mini.js ' +
|
| 633 |
+
'lib loaded from CDN predates the daemon log feature. ' +
|
| 634 |
+
'Purge jsdelivr and hard-refresh the page.'
|
| 635 |
+
);
|
| 636 |
+
document.getElementById('logsView').textContent = 'subscribeLogs not in SDK';
|
| 637 |
+
return;
|
| 638 |
+
}
|
| 639 |
+
stopLogsStream();
|
| 640 |
+
document.getElementById('logsView').textContent = 'Subscribing...';
|
| 641 |
+
try {
|
| 642 |
+
logsUnsub = robot.subscribeLogs({
|
| 643 |
+
onLine: appendLogLine,
|
| 644 |
+
onError: (err) => {
|
| 645 |
+
appendLogLine({ timestamp: '', line: `[log_stream_error] ${err}` });
|
| 646 |
+
},
|
| 647 |
+
});
|
| 648 |
+
} catch (e) {
|
| 649 |
+
console.warn('subscribeLogs failed:', e);
|
| 650 |
+
document.getElementById('logsView').textContent = `Failed: ${e.message || e}`;
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
function stopLogsStream() {
|
| 655 |
+
if (logsUnsub) {
|
| 656 |
+
try { logsUnsub(); } catch (e) { console.warn('logsUnsub threw:', e); }
|
| 657 |
+
logsUnsub = null;
|
| 658 |
+
}
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
function clearLogs() {
|
| 662 |
+
const view = document.getElementById('logsView');
|
| 663 |
+
if (view) view.textContent = '';
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
(function initVolumeSlider() {
|
| 667 |
+
const slider = document.getElementById('volumeSlider');
|
| 668 |
+
const label = document.getElementById('volumeValue');
|
| 669 |
+
slider.addEventListener('input', () => {
|
| 670 |
+
label.textContent = `${slider.value}%`;
|
| 671 |
+
});
|
| 672 |
+
slider.addEventListener('change', async () => {
|
| 673 |
+
const requested = parseInt(slider.value, 10);
|
| 674 |
+
if (typeof robot.setVolume !== 'function') {
|
| 675 |
+
console.warn(
|
| 676 |
+
'robot.setVolume is not a function — old JS lib on CDN; ' +
|
| 677 |
+
'purge jsdelivr and hard-refresh.'
|
| 678 |
+
);
|
| 679 |
+
return;
|
| 680 |
+
}
|
| 681 |
+
try {
|
| 682 |
+
const applied = await robot.setVolume(requested);
|
| 683 |
+
if (applied != null) {
|
| 684 |
+
// Server may clamp/round — reflect the truth back
|
| 685 |
+
// into the UI so the slider isn't a lie.
|
| 686 |
+
slider.value = applied;
|
| 687 |
+
label.textContent = `${applied}%`;
|
| 688 |
+
}
|
| 689 |
+
} catch (e) {
|
| 690 |
+
console.warn('setVolume failed:', e);
|
| 691 |
+
}
|
| 692 |
+
});
|
| 693 |
+
})();
|
| 694 |
+
|
| 695 |
+
// All-in-one: auth (cached) → SSE → robot selection → session →
|
| 696 |
+
// wake. Replaces the previous "Connect → pick robot → Start"
|
| 697 |
+
// three-step flow with a single button.
|
| 698 |
+
//
|
| 699 |
+
// In standalone mode autoConnect() invokes our `pickRobot`
|
| 700 |
+
// callback when there is more than one free robot; we render
|
| 701 |
+
// the panel and resolve the Promise on user click.
|
| 702 |
+
//
|
| 703 |
+
// In embed mode autoConnect detects `isEmbedded` and skips the
|
| 704 |
+
// picker entirely — the preselected robot id is used directly.
|
| 705 |
+
async function connectAndStream() {
|
| 706 |
+
if (!robot.isAuthenticated) return;
|
| 707 |
+
if (typeof robot.autoConnect !== 'function') {
|
| 708 |
+
console.error(
|
| 709 |
+
'robot.autoConnect is not a function — the reachy-mini.js ' +
|
| 710 |
+
'lib loaded from CDN predates the autoConnect feature. ' +
|
| 711 |
+
'Purge jsdelivr and hard-refresh the page.'
|
| 712 |
+
);
|
| 713 |
+
updateStatus('', 'SDK too old — purge jsdelivr cache');
|
| 714 |
+
return;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
updateStatus('connecting', 'Connecting...');
|
| 718 |
+
document.getElementById('connectBtn').disabled = true;
|
| 719 |
+
|
| 720 |
+
// Re-bind the <video> sink if a previous logout() tore it
|
| 721 |
+
// down. attachVideo() is lazy — the SDK remembers the
|
| 722 |
+
// element and wires the inbound track when it arrives, so
|
| 723 |
+
// calling it before autoConnect() works for both first
|
| 724 |
+
// session and post-logout reconnects.
|
| 725 |
+
if (!detachVideo) {
|
| 726 |
+
detachVideo = robot.attachVideo(document.getElementById('remoteVideo'));
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
try {
|
| 730 |
+
const { robotName } = await robot.autoConnect({
|
| 731 |
+
pickRobot: presentRobotPicker,
|
| 732 |
+
});
|
| 733 |
+
if (robotName) {
|
| 734 |
+
document.getElementById('robotName').textContent = robotName;
|
| 735 |
+
}
|
| 736 |
+
// The `streaming` event handler does the rest (status
|
| 737 |
+
// pill, latency display, daemon version, volume sync,
|
| 738 |
+
// logs subscription). autoConnect already called
|
| 739 |
+
// ensureAwake() before resolving.
|
| 740 |
+
} catch (e) {
|
| 741 |
+
console.error('autoConnect failed:', e);
|
| 742 |
+
// Same error taxonomy as the old startStream():
|
| 743 |
+
// "robot_busy" → central refused: another remote JS app is connected.
|
| 744 |
+
// "robot_busy_local_app" → relay refused: a local Python app holds the robot.
|
| 745 |
+
// "local_app_started" → a local Python app evicted a streaming session.
|
| 746 |
+
// "robot_busy_local" → relay safety net; another session slipped in.
|
| 747 |
+
let msg;
|
| 748 |
+
if (e.reason === 'robot_busy') {
|
| 749 |
+
msg = `Robot busy — "${e.activeApp}" is already connected`;
|
| 750 |
+
} else if ((e.reason && e.reason.startsWith('robot_busy')) || e.reason === 'local_app_started') {
|
| 751 |
+
msg = e.message;
|
| 752 |
+
} else {
|
| 753 |
+
msg = e.message || String(e);
|
| 754 |
+
}
|
| 755 |
+
updateStatus('', msg);
|
| 756 |
+
document.getElementById('connectBtn').disabled = false;
|
| 757 |
+
document.getElementById('robotSelector').classList.add('hidden');
|
| 758 |
+
}
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
// pickRobot callback for autoConnect(). Renders the existing
|
| 762 |
+
// robot-selector panel and resolves with the id the user
|
| 763 |
+
// clicks. Receives the SDK-shaped robot objects (id, name,
|
| 764 |
+
// busy, activeApp, meta, lastSeenAgeSeconds) already filtered
|
| 765 |
+
// to "free" and deduped by install_id.
|
| 766 |
+
function presentRobotPicker(robots) {
|
| 767 |
+
return new Promise((resolve) => {
|
| 768 |
+
const panel = document.getElementById('robotSelector');
|
| 769 |
+
const list = document.getElementById('robotList');
|
| 770 |
+
panel.classList.remove('hidden');
|
| 771 |
+
list.innerHTML = '';
|
| 772 |
+
if (!robots?.length) {
|
| 773 |
+
list.innerHTML = '<div style="color: var(--text-muted);">No robots online</div>';
|
| 774 |
+
resolve(null);
|
| 775 |
+
return;
|
| 776 |
+
}
|
| 777 |
+
for (const r of robots) {
|
| 778 |
+
const div = document.createElement('div');
|
| 779 |
+
div.className = 'robot-card';
|
| 780 |
+
const subtitle = r.busy
|
| 781 |
+
? `busy — ${r.activeApp ?? 'unknown app'}`
|
| 782 |
+
: `${r.id.slice(0, 12)}...`;
|
| 783 |
+
div.innerHTML = `<div class="name">${r.name || 'Reachy Mini'}</div><div class="id">${subtitle}</div>`;
|
| 784 |
+
div.onclick = () => {
|
| 785 |
+
if (r.busy) return; // selecting busy is a no-op
|
| 786 |
+
document.querySelectorAll('.robot-card').forEach(e => e.classList.remove('selected'));
|
| 787 |
+
div.classList.add('selected');
|
| 788 |
+
selectedRobotId = r.id;
|
| 789 |
+
document.getElementById('robotName').textContent = r.name || 'Reachy Mini';
|
| 790 |
+
resolve(r.id);
|
| 791 |
+
};
|
| 792 |
+
list.appendChild(div);
|
| 793 |
+
}
|
| 794 |
+
});
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
// Live SSE list updates: we still refresh the in-panel list as
|
| 798 |
+
// robots come and go, but only while the panel is visible (i.e.
|
| 799 |
+
// we're inside an active pickRobot prompt).
|
| 800 |
+
function refreshPickerOnList(robots) {
|
| 801 |
+
const panel = document.getElementById('robotSelector');
|
| 802 |
+
if (panel.classList.contains('hidden')) return;
|
| 803 |
+
// The picker promise already resolved once — re-rendering
|
| 804 |
+
// here would replace the click handlers with stale ones
|
| 805 |
+
// that try to resolve an already-settled Promise. Just
|
| 806 |
+
// visually mark the cards.
|
| 807 |
+
const list = document.getElementById('robotList');
|
| 808 |
+
const presentIds = new Set((robots || []).map((r) => r.id));
|
| 809 |
+
for (const card of list.querySelectorAll('.robot-card')) {
|
| 810 |
+
// The id snippet is in the second child — best-effort
|
| 811 |
+
// dim cards whose robot dropped off the list.
|
| 812 |
+
const stillPresent = Array.from(presentIds).some((id) =>
|
| 813 |
+
card.textContent.includes(id.slice(0, 12)),
|
| 814 |
+
);
|
| 815 |
+
card.style.opacity = stillPresent ? '' : '0.4';
|
| 816 |
+
}
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
// ===================== Latency =====================
|
| 820 |
+
function startLatencyDisplay() {
|
| 821 |
+
const badge = document.getElementById('latencyBadge');
|
| 822 |
+
const label = document.getElementById('latencyValue');
|
| 823 |
+
badge.classList.remove('hidden');
|
| 824 |
+
|
| 825 |
+
latencyIntervalId = setInterval(async () => {
|
| 826 |
+
const video = document.getElementById('remoteVideo');
|
| 827 |
+
let bufLagMs = null;
|
| 828 |
+
let rttMs = null;
|
| 829 |
+
let vidJitterMs = null;
|
| 830 |
+
let audJitterMs = null;
|
| 831 |
+
|
| 832 |
+
// Buffer lag (how far behind live edge)
|
| 833 |
+
if (video && video.buffered && video.buffered.length > 0) {
|
| 834 |
+
const end = video.buffered.end(video.buffered.length - 1);
|
| 835 |
+
bufLagMs = Math.round((end - video.currentTime) * 1000);
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
// WebRTC stats: RTT + jitter buffer delay (video & audio)
|
| 839 |
+
if (robot._pc) {
|
| 840 |
+
try {
|
| 841 |
+
const stats = await robot._pc.getStats();
|
| 842 |
+
stats.forEach(report => {
|
| 843 |
+
if (report.type === 'candidate-pair' && report.currentRoundTripTime != null) {
|
| 844 |
+
rttMs = Math.round(report.currentRoundTripTime * 1000);
|
| 845 |
+
}
|
| 846 |
+
if (report.type === 'inbound-rtp' && report.jitterBufferDelay != null && report.jitterBufferEmittedCount > 0) {
|
| 847 |
+
const jMs = Math.round((report.jitterBufferDelay / report.jitterBufferEmittedCount) * 1000);
|
| 848 |
+
if (report.kind === 'video') vidJitterMs = jMs;
|
| 849 |
+
if (report.kind === 'audio') audJitterMs = jMs;
|
| 850 |
+
}
|
| 851 |
+
});
|
| 852 |
+
} catch (_) { /* no stats yet */ }
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
// Display
|
| 856 |
+
const parts = [];
|
| 857 |
+
if (bufLagMs != null) parts.push(`buf ${bufLagMs}ms`);
|
| 858 |
+
if (rttMs != null) parts.push(`rtt ${rttMs}ms`);
|
| 859 |
+
if (vidJitterMs != null) parts.push(`v-jit ${vidJitterMs}ms`);
|
| 860 |
+
if (audJitterMs != null) parts.push(`a-jit ${audJitterMs}ms`);
|
| 861 |
+
label.textContent = parts.length ? parts.join(' · ') : '--';
|
| 862 |
+
|
| 863 |
+
// Color based on buffer lag
|
| 864 |
+
badge.classList.remove('good', 'ok', 'bad');
|
| 865 |
+
if (bufLagMs != null) {
|
| 866 |
+
if (bufLagMs < 200) badge.classList.add('good');
|
| 867 |
+
else if (bufLagMs < 500) badge.classList.add('ok');
|
| 868 |
+
else badge.classList.add('bad');
|
| 869 |
+
}
|
| 870 |
+
}, 1000);
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
function stopLatencyDisplay() {
|
| 874 |
+
if (latencyIntervalId) { clearInterval(latencyIntervalId); latencyIntervalId = null; }
|
| 875 |
+
const badge = document.getElementById('latencyBadge');
|
| 876 |
+
badge.classList.add('hidden');
|
| 877 |
+
badge.classList.remove('good', 'ok', 'bad');
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
// ===================== Session =====================
|
| 881 |
+
// The old two-step "Connect → Start" flow is collapsed into
|
| 882 |
+
// connectAndStream() above. Anything that used to call
|
| 883 |
+
// startStream() now routes through connectAndStream() via the
|
| 884 |
+
// window.startStream alias.
|
| 885 |
+
|
| 886 |
+
async function stopStream() {
|
| 887 |
+
if (detachVideo) { detachVideo(); detachVideo = null; }
|
| 888 |
+
await robot.stopSession();
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
// ===================== Audio =====================
|
| 892 |
+
function toggleMute() {
|
| 893 |
+
robot.setAudioMuted(!robot.audioMuted);
|
| 894 |
+
updateMuteButton();
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
function toggleMic() {
|
| 898 |
+
robot.setMicMuted(!robot.micMuted);
|
| 899 |
+
updateMicButton();
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
function updateMuteButton() {
|
| 903 |
+
const muted = robot.audioMuted;
|
| 904 |
+
const btn = document.getElementById('muteBtn');
|
| 905 |
+
btn.classList.toggle('muted', muted);
|
| 906 |
+
document.getElementById('speakerOffIcon').classList.toggle('hidden', !muted);
|
| 907 |
+
document.getElementById('speakerOnIcon').classList.toggle('hidden', muted);
|
| 908 |
+
document.getElementById('muteText').textContent = muted ? 'Unmute' : 'Mute';
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
function updateMicButton() {
|
| 912 |
+
const muted = robot.micMuted;
|
| 913 |
+
const btn = document.getElementById('micBtn');
|
| 914 |
+
btn.classList.toggle('muted', muted);
|
| 915 |
+
document.getElementById('micOffIcon').classList.toggle('hidden', !muted);
|
| 916 |
+
document.getElementById('micOnIcon').classList.toggle('hidden', muted);
|
| 917 |
+
document.getElementById('micText').textContent = muted ? 'Mic Off' : 'Mic On';
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
function enableControls(enabled) {
|
| 921 |
+
document.getElementById('btnPlaySound').disabled = !enabled;
|
| 922 |
+
document.getElementById('btnWakeUp').disabled = !enabled;
|
| 923 |
+
document.getElementById('btnGotoSleep').disabled = !enabled;
|
| 924 |
+
document.getElementById('btnTorqueToggle').disabled = !enabled;
|
| 925 |
+
document.getElementById('muteBtn').disabled = !enabled;
|
| 926 |
+
document.getElementById('micBtn').disabled = !enabled || !robot.micSupported;
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
// ===================== State Display =====================
|
| 930 |
+
// The "state" event ships raw wire units (flat 4×4 matrix, radians);
|
| 931 |
+
// the UI works in degrees, so we convert at the boundary.
|
| 932 |
+
function updateStateDisplay(state) {
|
| 933 |
+
// Also gate on bodyYawSliderActive: dragging the body slider
|
| 934 |
+
// mutates yawSlider.value (head/body rotate together), and the
|
| 935 |
+
// daemon's mid-IK present_head_pose lags the target. Without
|
| 936 |
+
// this gate, matrixToRpy of the in-flight pose pumps drifted
|
| 937 |
+
// roll/pitch/yaw back into the head sliders, which the next
|
| 938 |
+
// body-input event reads and re-sends — visible as pitch/roll
|
| 939 |
+
// jitter while the body slider is being dragged.
|
| 940 |
+
if (state.head && !headSlidersActive && !bodyYawSliderActive) {
|
| 941 |
+
// state.head is number[16] (flat row-major 4×4); matrixToRpy
|
| 942 |
+
// wants nested 4×4, so unflatten.
|
| 943 |
+
const m = [
|
| 944 |
+
state.head.slice(0, 4),
|
| 945 |
+
state.head.slice(4, 8),
|
| 946 |
+
state.head.slice(8, 12),
|
| 947 |
+
state.head.slice(12, 16),
|
| 948 |
+
];
|
| 949 |
+
const rpy = matrixToRpy(m);
|
| 950 |
+
document.getElementById('rollSlider').value = rpy.roll;
|
| 951 |
+
document.getElementById('rollValue').textContent = rpy.roll.toFixed(1) + '°';
|
| 952 |
+
document.getElementById('pitchSlider').value = rpy.pitch;
|
| 953 |
+
document.getElementById('pitchValue').textContent = rpy.pitch.toFixed(1) + '°';
|
| 954 |
+
document.getElementById('yawSlider').value = rpy.yaw;
|
| 955 |
+
document.getElementById('yawValue').textContent = rpy.yaw.toFixed(1) + '°';
|
| 956 |
+
}
|
| 957 |
+
if (state.antennas) {
|
| 958 |
+
const r = radToDeg(state.antennas[0]).toFixed(0);
|
| 959 |
+
const l = radToDeg(state.antennas[1]).toFixed(0);
|
| 960 |
+
document.getElementById('rightAntSlider').value = r;
|
| 961 |
+
document.getElementById('rightAntValue').textContent = r + '°';
|
| 962 |
+
document.getElementById('leftAntSlider').value = l;
|
| 963 |
+
document.getElementById('leftAntValue').textContent = l + '°';
|
| 964 |
+
}
|
| 965 |
+
if (typeof state.body_yaw === 'number' && !bodyYawSliderActive) {
|
| 966 |
+
const y = radToDeg(state.body_yaw).toFixed(0);
|
| 967 |
+
document.getElementById('bodyYawSlider').value = y;
|
| 968 |
+
document.getElementById('bodyYawValue').textContent = y + '°';
|
| 969 |
+
// Sync the delta baseline so the next drag computes from
|
| 970 |
+
// the robot's actual current yaw, not whatever the slider
|
| 971 |
+
// happened to be at on page load.
|
| 972 |
+
lastBodyYawDeg = parseFloat(y);
|
| 973 |
+
}
|
| 974 |
+
if (state.motor_mode) updateTorqueButton(state.motor_mode);
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
function updateTorqueButton(mode) {
|
| 978 |
+
const btn = document.getElementById('btnTorqueToggle');
|
| 979 |
+
if (!btn) return;
|
| 980 |
+
// Normalise: anything other than "disabled" is considered "on"
|
| 981 |
+
// for toggle purposes. gravity_compensation is torque-on too.
|
| 982 |
+
const isOn = mode && mode !== 'disabled';
|
| 983 |
+
btn.textContent = isOn ? 'Torque: On (click to disable)' : 'Torque: Off (click to enable)';
|
| 984 |
+
// Stash the current intent so the click handler knows which
|
| 985 |
+
// direction to flip without rereading _robotState.
|
| 986 |
+
btn.dataset.currentMode = mode;
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
// ===================== Sliders =====================
|
| 990 |
+
function initSliders() {
|
| 991 |
+
// Head
|
| 992 |
+
const rollSlider = document.getElementById('rollSlider');
|
| 993 |
+
const pitchSlider = document.getElementById('pitchSlider');
|
| 994 |
+
const yawSlider = document.getElementById('yawSlider');
|
| 995 |
+
const rollValue = document.getElementById('rollValue');
|
| 996 |
+
const pitchValue = document.getElementById('pitchValue');
|
| 997 |
+
const yawValue = document.getElementById('yawValue');
|
| 998 |
+
|
| 999 |
+
// Body yaw
|
| 1000 |
+
const bodyYawSlider = document.getElementById('bodyYawSlider');
|
| 1001 |
+
const bodyYawValue = document.getElementById('bodyYawValue');
|
| 1002 |
+
|
| 1003 |
+
// Antennas
|
| 1004 |
+
const rightSlider = document.getElementById('rightAntSlider');
|
| 1005 |
+
const leftSlider = document.getElementById('leftAntSlider');
|
| 1006 |
+
const rightAntValue = document.getElementById('rightAntValue');
|
| 1007 |
+
const leftAntValue = document.getElementById('leftAntValue');
|
| 1008 |
+
|
| 1009 |
+
// Single source of truth: read every slider's DOM state and
|
| 1010 |
+
// ship it in one atomic setTarget command. Per-slider input
|
| 1011 |
+
// handlers update their label, optionally run cross-slider
|
| 1012 |
+
// compensation (body-yaw drag → head-yaw counter-rotation),
|
| 1013 |
+
// then call this. One round-trip per gesture, no chance for
|
| 1014 |
+
// the daemon to see a half-applied target between components.
|
| 1015 |
+
function sendCurrentTarget() {
|
| 1016 |
+
robot.setTarget({
|
| 1017 |
+
head: rpyToMatrix(
|
| 1018 |
+
parseFloat(rollSlider.value),
|
| 1019 |
+
parseFloat(pitchSlider.value),
|
| 1020 |
+
parseFloat(yawSlider.value),
|
| 1021 |
+
).flat(),
|
| 1022 |
+
antennas: [
|
| 1023 |
+
degToRad(parseFloat(rightSlider.value)),
|
| 1024 |
+
degToRad(parseFloat(leftSlider.value)),
|
| 1025 |
+
],
|
| 1026 |
+
body_yaw: degToRad(parseFloat(bodyYawSlider.value)),
|
| 1027 |
+
});
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
const onStart = () => { headSlidersActive = true; };
|
| 1031 |
+
const onEnd = () => { headSlidersActive = false; };
|
| 1032 |
+
|
| 1033 |
+
for (const s of [rollSlider, pitchSlider, yawSlider]) {
|
| 1034 |
+
s.addEventListener('mousedown', onStart);
|
| 1035 |
+
s.addEventListener('touchstart', onStart);
|
| 1036 |
+
s.addEventListener('mouseup', onEnd);
|
| 1037 |
+
s.addEventListener('touchend', onEnd);
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
rollSlider.addEventListener('input', () => { rollValue.textContent = parseFloat(rollSlider.value).toFixed(1) + '°'; sendCurrentTarget(); });
|
| 1041 |
+
pitchSlider.addEventListener('input', () => { pitchValue.textContent = parseFloat(pitchSlider.value).toFixed(1) + '°'; sendCurrentTarget(); });
|
| 1042 |
+
yawSlider.addEventListener('input', () => { yawValue.textContent = parseFloat(yawSlider.value).toFixed(1) + '°'; sendCurrentTarget(); });
|
| 1043 |
+
|
| 1044 |
+
// Body yaw — the head pose in setTarget is interpreted in world
|
| 1045 |
+
// frame (see look_at_world in the Python SDK), so the IK splits
|
| 1046 |
+
// the requested world yaw between body rotation and the stewart
|
| 1047 |
+
// platform. Mechanical limit: |head_yaw_world − body_yaw| ≤ 65°.
|
| 1048 |
+
//
|
| 1049 |
+
// To keep that relative yaw constant as the user drags the body
|
| 1050 |
+
// slider, we move the head's commanded world yaw by the same
|
| 1051 |
+
// delta — head and body rotate together (tank-style). The
|
| 1052 |
+
// per-component values are then shipped atomically by
|
| 1053 |
+
// sendCurrentTarget().
|
| 1054 |
+
const onBodyStart = () => { bodyYawSliderActive = true; };
|
| 1055 |
+
const onBodyEnd = () => { bodyYawSliderActive = false; };
|
| 1056 |
+
bodyYawSlider.addEventListener('mousedown', onBodyStart);
|
| 1057 |
+
bodyYawSlider.addEventListener('touchstart', onBodyStart);
|
| 1058 |
+
bodyYawSlider.addEventListener('mouseup', onBodyEnd);
|
| 1059 |
+
bodyYawSlider.addEventListener('touchend', onBodyEnd);
|
| 1060 |
+
|
| 1061 |
+
const yawMin = parseFloat(yawSlider.min);
|
| 1062 |
+
const yawMax = parseFloat(yawSlider.max);
|
| 1063 |
+
bodyYawSlider.addEventListener('input', () => {
|
| 1064 |
+
const newBodyDeg = parseFloat(bodyYawSlider.value);
|
| 1065 |
+
const delta = newBodyDeg - lastBodyYawDeg;
|
| 1066 |
+
// Add the body-yaw delta to the head-yaw slider, clamped
|
| 1067 |
+
// to its range. Once the head slider saturates, the head's
|
| 1068 |
+
// world yaw stops keeping up with the body, the relative
|
| 1069 |
+
// yaw starts to grow, and beyond ~65° the daemon's safe-IK
|
| 1070 |
+
// will modulate body_yaw to stay within mechanical limits.
|
| 1071 |
+
const newHeadYaw = Math.max(yawMin, Math.min(yawMax,
|
| 1072 |
+
parseFloat(yawSlider.value) + delta));
|
| 1073 |
+
yawSlider.value = newHeadYaw;
|
| 1074 |
+
yawValue.textContent = newHeadYaw.toFixed(1) + '°';
|
| 1075 |
+
bodyYawValue.textContent = newBodyDeg.toFixed(0) + '°';
|
| 1076 |
+
sendCurrentTarget();
|
| 1077 |
+
lastBodyYawDeg = newBodyDeg;
|
| 1078 |
+
});
|
| 1079 |
+
|
| 1080 |
+
rightSlider.addEventListener('input', () => {
|
| 1081 |
+
rightAntValue.textContent = rightSlider.value + '°';
|
| 1082 |
+
sendCurrentTarget();
|
| 1083 |
+
});
|
| 1084 |
+
leftSlider.addEventListener('input', () => {
|
| 1085 |
+
leftAntValue.textContent = leftSlider.value + '°';
|
| 1086 |
+
sendCurrentTarget();
|
| 1087 |
+
});
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
// ===================== Sound =====================
|
| 1091 |
+
function playSound() {
|
| 1092 |
+
const file = document.getElementById('soundInput').value.trim();
|
| 1093 |
+
if (file) robot.playSound(file);
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
function playSoundPreset(file) {
|
| 1097 |
+
document.getElementById('soundInput').value = file;
|
| 1098 |
+
robot.playSound(file);
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
// ===================== Animations =====================
|
| 1102 |
+
//
|
| 1103 |
+
// Fire-and-forget: the robot plays the full trajectory (~2 s) and
|
| 1104 |
+
// we don't await completion. The buttons are briefly disabled
|
| 1105 |
+
// during the motion to prevent the user from spamming the same
|
| 1106 |
+
// animation on top of itself, using is_move_running from the
|
| 1107 |
+
// state event as the authoritative "busy" signal.
|
| 1108 |
+
//
|
| 1109 |
+
// Staleness detection: if the lib loaded from CDN predates these
|
| 1110 |
+
// methods (typeof check), warn instead of silently failing. Same
|
| 1111 |
+
// pattern as the volume controls.
|
| 1112 |
+
function playWakeUp() {
|
| 1113 |
+
// Click receipt log: present so that "no log at all" definitively
|
| 1114 |
+
// means the click event never reached this function (button still
|
| 1115 |
+
// disabled, stale HTML in browser cache, or redeploy not live).
|
| 1116 |
+
console.log('[animations] playWakeUp clicked; robot.state=', robot.state,
|
| 1117 |
+
'; robot.wakeUp typeof=', typeof robot.wakeUp);
|
| 1118 |
+
if (typeof robot.wakeUp !== 'function') {
|
| 1119 |
+
console.warn('robot.wakeUp is not a function — stale JS lib; purge jsdelivr + hard-refresh.');
|
| 1120 |
+
return;
|
| 1121 |
+
}
|
| 1122 |
+
const ok = robot.wakeUp();
|
| 1123 |
+
console.log('[animations] robot.wakeUp() returned', ok);
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
function playGotoSleep() {
|
| 1127 |
+
console.log('[animations] playGotoSleep clicked; robot.state=', robot.state,
|
| 1128 |
+
'; robot.gotoSleep typeof=', typeof robot.gotoSleep);
|
| 1129 |
+
if (typeof robot.gotoSleep !== 'function') {
|
| 1130 |
+
console.warn('robot.gotoSleep is not a function — stale JS lib; purge jsdelivr + hard-refresh.');
|
| 1131 |
+
return;
|
| 1132 |
+
}
|
| 1133 |
+
const ok = robot.gotoSleep();
|
| 1134 |
+
console.log('[animations] robot.gotoSleep() returned', ok);
|
| 1135 |
+
}
|
| 1136 |
+
|
| 1137 |
+
// ===================== Torque =====================
|
| 1138 |
+
//
|
| 1139 |
+
// The "current mode" read from the button's dataset is populated by
|
| 1140 |
+
// updateTorqueButton() on every state-event. If a user clicks before
|
| 1141 |
+
// the first state tick arrives, currentMode is falsy and we default
|
| 1142 |
+
// to enabling — the safe-ish choice that matches what wakeUp() would
|
| 1143 |
+
// do. The label then self-corrects on the next state tick.
|
| 1144 |
+
function toggleTorque() {
|
| 1145 |
+
if (typeof robot.setMotorMode !== 'function') {
|
| 1146 |
+
console.warn('robot.setMotorMode is not a function — stale JS lib; purge jsdelivr + hard-refresh.');
|
| 1147 |
+
return;
|
| 1148 |
+
}
|
| 1149 |
+
const btn = document.getElementById('btnTorqueToggle');
|
| 1150 |
+
const currentMode = btn.dataset.currentMode;
|
| 1151 |
+
const next = (currentMode && currentMode !== 'disabled') ? 'disabled' : 'enabled';
|
| 1152 |
+
console.log('[torque] toggle click; currentMode=', currentMode, '→ sending', next);
|
| 1153 |
+
robot.setMotorMode(next);
|
| 1154 |
+
// Optimistic UI update. If the robot rejects or differs, the
|
| 1155 |
+
// next state tick (within ~500 ms) will overwrite.
|
| 1156 |
+
updateTorqueButton(next);
|
| 1157 |
+
}
|
| 1158 |
</script>
|
| 1159 |
+
</body>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1160 |
</html>
|
style.css
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--pollen-coral: #FF6B35;
|
| 3 |
+
--pollen-coral-light: #FF8A5C;
|
| 4 |
+
--pollen-coral-dark: #E55A2B;
|
| 5 |
+
--pollen-dark: #1A1A2E;
|
| 6 |
+
--pollen-darker: #0F0F1A;
|
| 7 |
+
--pollen-card: #16213E;
|
| 8 |
+
--pollen-card-light: #1E2A4A;
|
| 9 |
+
--text-primary: #FFFFFF;
|
| 10 |
+
--text-secondary: #A0AEC0;
|
| 11 |
+
--text-muted: #718096;
|
| 12 |
+
--success: #48BB78;
|
| 13 |
+
--warning: #ECC94B;
|
| 14 |
+
--danger: #F56565;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 18 |
+
|
| 19 |
+
body {
|
| 20 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 21 |
+
background: var(--pollen-darker);
|
| 22 |
+
color: var(--text-primary);
|
| 23 |
+
min-height: 100vh;
|
| 24 |
+
min-height: 100dvh;
|
| 25 |
+
overflow-x: hidden;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/* Header */
|
| 29 |
+
.header {
|
| 30 |
+
background: rgba(0,0,0,0.4);
|
| 31 |
+
backdrop-filter: blur(10px);
|
| 32 |
+
padding: 8px 16px;
|
| 33 |
+
display: flex;
|
| 34 |
+
align-items: center;
|
| 35 |
+
justify-content: space-between;
|
| 36 |
+
border-bottom: 1px solid rgba(255,107,53,0.2);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.logo {
|
| 40 |
+
display: flex;
|
| 41 |
+
align-items: center;
|
| 42 |
+
gap: 10px;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.logo img {
|
| 46 |
+
width: 32px;
|
| 47 |
+
height: 32px;
|
| 48 |
+
border-radius: 6px;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.logo-text {
|
| 52 |
+
font-weight: 700;
|
| 53 |
+
font-size: 1em;
|
| 54 |
+
color: var(--pollen-coral);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.logo-text span {
|
| 58 |
+
color: var(--text-secondary);
|
| 59 |
+
font-weight: 400;
|
| 60 |
+
font-size: 0.85em;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.user-section {
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
gap: 8px;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.user-badge {
|
| 70 |
+
background: var(--pollen-card);
|
| 71 |
+
padding: 4px 12px;
|
| 72 |
+
border-radius: 16px;
|
| 73 |
+
font-size: 0.8em;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.btn-logout {
|
| 77 |
+
background: transparent;
|
| 78 |
+
border: 1px solid var(--text-muted);
|
| 79 |
+
color: var(--text-secondary);
|
| 80 |
+
padding: 4px 12px;
|
| 81 |
+
border-radius: 12px;
|
| 82 |
+
cursor: pointer;
|
| 83 |
+
font-size: 0.75em;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/* Main Layout - Single Column */
|
| 87 |
+
.app-container {
|
| 88 |
+
display: flex;
|
| 89 |
+
flex-direction: column;
|
| 90 |
+
padding: 8px;
|
| 91 |
+
gap: 8px;
|
| 92 |
+
max-width: 800px;
|
| 93 |
+
margin: 0 auto;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/* Video Section */
|
| 97 |
+
.video-container {
|
| 98 |
+
position: relative;
|
| 99 |
+
background: #000;
|
| 100 |
+
border-radius: 12px;
|
| 101 |
+
overflow: hidden;
|
| 102 |
+
aspect-ratio: 16/9;
|
| 103 |
+
width: 100%;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
video {
|
| 107 |
+
width: 100%;
|
| 108 |
+
height: 100%;
|
| 109 |
+
object-fit: cover;
|
| 110 |
+
background: linear-gradient(135deg, #0a0a15 0%, #1a1a2e 100%);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.video-overlay-top {
|
| 114 |
+
position: absolute;
|
| 115 |
+
top: 0;
|
| 116 |
+
left: 0;
|
| 117 |
+
right: 0;
|
| 118 |
+
padding: 12px;
|
| 119 |
+
background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%);
|
| 120 |
+
display: flex;
|
| 121 |
+
justify-content: space-between;
|
| 122 |
+
align-items: flex-start;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.connection-badge {
|
| 126 |
+
display: flex;
|
| 127 |
+
align-items: center;
|
| 128 |
+
gap: 6px;
|
| 129 |
+
background: rgba(0,0,0,0.5);
|
| 130 |
+
padding: 6px 12px;
|
| 131 |
+
border-radius: 16px;
|
| 132 |
+
font-size: 0.8em;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.latency-badge {
|
| 136 |
+
display: flex;
|
| 137 |
+
align-items: center;
|
| 138 |
+
background: rgba(0,0,0,0.5);
|
| 139 |
+
padding: 4px 10px;
|
| 140 |
+
border-radius: 12px;
|
| 141 |
+
font-size: 0.75em;
|
| 142 |
+
font-variant-numeric: tabular-nums;
|
| 143 |
+
color: var(--text-secondary);
|
| 144 |
+
font-family: 'Inter', monospace;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.latency-badge.good { color: var(--success); }
|
| 148 |
+
.latency-badge.ok { color: var(--warning); }
|
| 149 |
+
.latency-badge.bad { color: var(--danger); }
|
| 150 |
+
|
| 151 |
+
.status-indicator {
|
| 152 |
+
width: 8px;
|
| 153 |
+
height: 8px;
|
| 154 |
+
border-radius: 50%;
|
| 155 |
+
background: var(--danger);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.status-indicator.connected {
|
| 159 |
+
background: var(--success);
|
| 160 |
+
box-shadow: 0 0 8px var(--success);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.status-indicator.connecting {
|
| 164 |
+
background: var(--warning);
|
| 165 |
+
animation: blink 0.8s infinite;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
@keyframes blink {
|
| 169 |
+
0%, 100% { opacity: 1; }
|
| 170 |
+
50% { opacity: 0.4; }
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.robot-name {
|
| 174 |
+
background: rgba(0,0,0,0.5);
|
| 175 |
+
padding: 6px 12px;
|
| 176 |
+
border-radius: 16px;
|
| 177 |
+
font-size: 0.8em;
|
| 178 |
+
font-weight: 500;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.video-overlay-bottom {
|
| 182 |
+
position: absolute;
|
| 183 |
+
bottom: 0;
|
| 184 |
+
left: 0;
|
| 185 |
+
right: 0;
|
| 186 |
+
padding: 12px;
|
| 187 |
+
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, transparent 100%);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.video-controls {
|
| 191 |
+
display: flex;
|
| 192 |
+
justify-content: center;
|
| 193 |
+
gap: 8px;
|
| 194 |
+
flex-wrap: wrap;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.btn {
|
| 198 |
+
padding: 8px 16px;
|
| 199 |
+
border: none;
|
| 200 |
+
border-radius: 8px;
|
| 201 |
+
font-weight: 600;
|
| 202 |
+
font-size: 0.85em;
|
| 203 |
+
cursor: pointer;
|
| 204 |
+
transition: all 0.2s;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.btn-primary {
|
| 208 |
+
background: var(--pollen-coral);
|
| 209 |
+
color: white;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.btn-secondary {
|
| 213 |
+
background: rgba(255,255,255,0.15);
|
| 214 |
+
color: white;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.btn-danger {
|
| 218 |
+
background: var(--danger);
|
| 219 |
+
color: white;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.btn:disabled {
|
| 223 |
+
opacity: 0.4;
|
| 224 |
+
cursor: not-allowed;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.btn-mute {
|
| 228 |
+
background: rgba(255,255,255,0.15);
|
| 229 |
+
color: white;
|
| 230 |
+
display: flex;
|
| 231 |
+
align-items: center;
|
| 232 |
+
gap: 6px;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.btn-mute.muted {
|
| 236 |
+
background: var(--danger);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.btn-mute svg {
|
| 240 |
+
width: 16px;
|
| 241 |
+
height: 16px;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
/* Panels */
|
| 245 |
+
.panel {
|
| 246 |
+
background: var(--pollen-card);
|
| 247 |
+
border-radius: 12px;
|
| 248 |
+
overflow: hidden;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.panel-header {
|
| 252 |
+
padding: 10px 14px;
|
| 253 |
+
background: rgba(0,0,0,0.2);
|
| 254 |
+
font-weight: 600;
|
| 255 |
+
font-size: 0.85em;
|
| 256 |
+
color: var(--pollen-coral);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.panel-content {
|
| 260 |
+
padding: 12px;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
/* Sliders */
|
| 264 |
+
.slider-row {
|
| 265 |
+
display: flex;
|
| 266 |
+
gap: 12px;
|
| 267 |
+
align-items: center;
|
| 268 |
+
margin-bottom: 12px;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.slider-row:last-child {
|
| 272 |
+
margin-bottom: 0;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.slider-label {
|
| 276 |
+
font-size: 0.8em;
|
| 277 |
+
color: var(--text-secondary);
|
| 278 |
+
min-width: 80px;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.slider {
|
| 282 |
+
flex: 1;
|
| 283 |
+
height: 8px;
|
| 284 |
+
-webkit-appearance: none;
|
| 285 |
+
background: var(--pollen-darker);
|
| 286 |
+
border-radius: 4px;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.slider::-webkit-slider-thumb {
|
| 290 |
+
-webkit-appearance: none;
|
| 291 |
+
width: 20px;
|
| 292 |
+
height: 20px;
|
| 293 |
+
background: var(--pollen-coral);
|
| 294 |
+
border-radius: 50%;
|
| 295 |
+
cursor: pointer;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.slider-value {
|
| 299 |
+
font-family: monospace;
|
| 300 |
+
font-size: 0.8em;
|
| 301 |
+
color: var(--pollen-coral);
|
| 302 |
+
min-width: 45px;
|
| 303 |
+
text-align: right;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
/* Sound */
|
| 307 |
+
.sound-row {
|
| 308 |
+
display: flex;
|
| 309 |
+
gap: 8px;
|
| 310 |
+
margin-bottom: 10px;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.sound-input {
|
| 314 |
+
flex: 1;
|
| 315 |
+
padding: 8px 10px;
|
| 316 |
+
background: var(--pollen-darker);
|
| 317 |
+
border: 1px solid var(--pollen-card-light);
|
| 318 |
+
border-radius: 6px;
|
| 319 |
+
color: var(--text-primary);
|
| 320 |
+
font-size: 0.85em;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.sound-presets {
|
| 324 |
+
display: flex;
|
| 325 |
+
flex-wrap: wrap;
|
| 326 |
+
gap: 6px;
|
| 327 |
+
margin-bottom: 12px;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.preset-chip {
|
| 331 |
+
padding: 4px 10px;
|
| 332 |
+
background: var(--pollen-darker);
|
| 333 |
+
border: 1px solid var(--pollen-card-light);
|
| 334 |
+
border-radius: 12px;
|
| 335 |
+
color: var(--text-secondary);
|
| 336 |
+
font-size: 0.7em;
|
| 337 |
+
cursor: pointer;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.preset-chip:hover {
|
| 341 |
+
border-color: var(--pollen-coral);
|
| 342 |
+
color: var(--pollen-coral);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
/* Robot Selector */
|
| 346 |
+
.robot-list {
|
| 347 |
+
display: flex;
|
| 348 |
+
flex-direction: column;
|
| 349 |
+
gap: 8px;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.robot-card {
|
| 353 |
+
padding: 10px 14px;
|
| 354 |
+
background: var(--pollen-darker);
|
| 355 |
+
border: 2px solid transparent;
|
| 356 |
+
border-radius: 8px;
|
| 357 |
+
cursor: pointer;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.robot-card:hover {
|
| 361 |
+
background: var(--pollen-card-light);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.robot-card.selected {
|
| 365 |
+
border-color: var(--pollen-coral);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.robot-card .name {
|
| 369 |
+
font-weight: 600;
|
| 370 |
+
font-size: 0.9em;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.robot-card .id {
|
| 374 |
+
font-size: 0.75em;
|
| 375 |
+
color: var(--text-muted);
|
| 376 |
+
font-family: monospace;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
/* Desktop Layout - Side by Side */
|
| 380 |
+
@media (min-width: 900px) {
|
| 381 |
+
.app-container {
|
| 382 |
+
display: grid;
|
| 383 |
+
grid-template-columns: 1fr 1fr;
|
| 384 |
+
grid-template-rows: auto auto auto auto;
|
| 385 |
+
max-width: 1200px;
|
| 386 |
+
gap: 12px;
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.video-container {
|
| 390 |
+
grid-column: 1;
|
| 391 |
+
grid-row: 1 / 3;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
#robotSelector {
|
| 395 |
+
grid-column: 1;
|
| 396 |
+
grid-row: 3;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.panel:nth-of-type(1) { /* Head Control */
|
| 400 |
+
grid-column: 2;
|
| 401 |
+
grid-row: 1;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.panel:nth-of-type(2) { /* Antennas */
|
| 405 |
+
grid-column: 2;
|
| 406 |
+
grid-row: 2;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.panel:nth-of-type(3) { /* Sound */
|
| 410 |
+
grid-column: 2;
|
| 411 |
+
grid-row: 3;
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
/* Login View */
|
| 416 |
+
.login-view {
|
| 417 |
+
min-height: 100vh;
|
| 418 |
+
min-height: 100dvh;
|
| 419 |
+
display: flex;
|
| 420 |
+
align-items: center;
|
| 421 |
+
justify-content: center;
|
| 422 |
+
padding: 20px;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
.login-card {
|
| 426 |
+
background: var(--pollen-card);
|
| 427 |
+
padding: 40px;
|
| 428 |
+
border-radius: 16px;
|
| 429 |
+
text-align: center;
|
| 430 |
+
max-width: 380px;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
.login-logo {
|
| 434 |
+
width: 72px;
|
| 435 |
+
height: 72px;
|
| 436 |
+
margin-bottom: 20px;
|
| 437 |
+
border-radius: 12px;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
.login-card h2 {
|
| 441 |
+
color: var(--pollen-coral);
|
| 442 |
+
margin-bottom: 10px;
|
| 443 |
+
font-size: 1.5em;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.login-card p {
|
| 447 |
+
color: var(--text-secondary);
|
| 448 |
+
margin-bottom: 24px;
|
| 449 |
+
font-size: 0.9em;
|
| 450 |
+
line-height: 1.5;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
.btn-hf {
|
| 454 |
+
background: #FFD21E;
|
| 455 |
+
color: #000;
|
| 456 |
+
border: none;
|
| 457 |
+
padding: 12px 28px;
|
| 458 |
+
border-radius: 8px;
|
| 459 |
+
font-size: 0.95em;
|
| 460 |
+
font-weight: 700;
|
| 461 |
+
cursor: pointer;
|
| 462 |
+
display: inline-flex;
|
| 463 |
+
align-items: center;
|
| 464 |
+
gap: 8px;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
.hidden { display: none !important; }
|