Spaces:
Running
Running
v0.8.0
Browse files- Reachy Eyes: drive the community LED eyes module from the dashboard, its colors and effects, mirrored live on the 3D twin.
- Movement sequences: record keyframes, replay them, and export or import your own.
- A 10-band graphic equalizer to shape the robot's sound.
- One shared camera stream feeds the picture, the mic and Listen, and it survives power off and on.
- 12 new skins (36 in all), and picking one from the camera view applies it right away.
- A cleaner simulator: tidy wake and fold, a LIVE / SIM badge, and no startup flicker.
- Install straight on the robot with setup.sh for a dashboard that starts at boot, plus a community Eyes showcase on the landing.
Source: ab0191433f09ede52e783d9933dad8f2061ff9ee
Auto-sync: reachy_web
This view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +3 -0
- README.md +7 -2
- VERSION +1 -1
- changelog.json +78 -0
- fonts/Manrope.ttf +0 -3
- fonts/SpaceGrotesk.ttf +0 -3
- index.html +188 -12
- pyproject.toml +8 -1
- reachy_web/audio_eq.py +60 -0
- reachy_web/eyes.py +365 -0
- reachy_web/log_i18n.py +32 -9
- reachy_web/main.py +138 -18
- reachy_web/static/assets/icons/antenna.svg +1 -0
- reachy_web/static/assets/icons/axis-x-arrow.svg +1 -0
- reachy_web/static/assets/icons/axis-x-rotate-clockwise.svg +1 -0
- reachy_web/static/assets/icons/axis-y-arrow.svg +1 -0
- reachy_web/static/assets/icons/axis-y-rotate-clockwise.svg +1 -0
- reachy_web/static/assets/icons/axis-z-arrow.svg +1 -0
- reachy_web/static/assets/icons/axis-z-rotate-clockwise.svg +1 -0
- reachy_web/static/assets/icons/brightness-6.svg +1 -0
- reachy_web/static/assets/icons/chevron-left.svg +1 -0
- reachy_web/static/assets/icons/eye-outline.svg +1 -0
- reachy_web/static/assets/icons/gesture-swipe-horizontal.svg +1 -0
- reachy_web/static/assets/icons/hand-back-right.svg +1 -0
- reachy_web/static/assets/icons/rotate-3d-variant.svg +1 -0
- reachy_web/static/assets/icons/tray-arrow-up.svg +1 -0
- reachy_web/static/assets/logo-192.png +0 -3
- reachy_web/static/assets/logo-2048.png +0 -3
- reachy_web/static/assets/logo-512.png +0 -3
- reachy_web/static/assets/logo-64.png +0 -3
- reachy_web/static/assets/logo.png +0 -3
- reachy_web/static/assets/og.png +2 -2
- reachy_web/static/css/app.css +8 -6
- reachy_web/static/css/audio_eq.css +55 -0
- reachy_web/static/css/icons.css +14 -0
- reachy_web/static/css/logo.css +81 -13
- reachy_web/static/css/logs.css +471 -72
- reachy_web/static/js/app_stage.js +133 -0
- reachy_web/static/js/apps.js +28 -0
- reachy_web/static/js/audio_eq.js +52 -44
- reachy_web/static/js/camera.js +207 -53
- reachy_web/static/js/controller.js +682 -150
- reachy_web/static/js/daemon.js +29 -0
- reachy_web/static/js/eyes_fx.js +340 -0
- reachy_web/static/js/idle.js +7 -0
- reachy_web/static/js/landing-i18n.js +161 -30
- reachy_web/static/js/locales.js +589 -173
- reachy_web/static/js/logo_anim.js +8 -1
- reachy_web/static/js/logs.js +31 -54
- reachy_web/static/js/move_player.js +2 -6
.gitignore
CHANGED
|
@@ -14,3 +14,6 @@ node_modules/
|
|
| 14 |
|
| 15 |
# last daemon host mDNS found, so a relaunch aims there first
|
| 16 |
.reachy-web-daemon-host
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
# last daemon host mDNS found, so a relaunch aims there first
|
| 16 |
.reachy-web-daemon-host
|
| 17 |
+
|
| 18 |
+
# run.sh session logs (on-disk, never shipped)
|
| 19 |
+
logs/
|
README.md
CHANGED
|
@@ -14,8 +14,13 @@ tags:
|
|
| 14 |
- reachy_mini_lite
|
| 15 |
- dashboard
|
| 16 |
- web_dashboard
|
|
|
|
| 17 |
- robot_dashboard
|
|
|
|
|
|
|
| 18 |
- digital_twin
|
|
|
|
|
|
|
| 19 |
- reachy_mirror
|
| 20 |
- reachy_eyes
|
| 21 |
- domotick
|
|
@@ -57,7 +62,7 @@ The dashboard only needs to reach the daemon over HTTP, so it runs just as well
|
|
| 57 |
git clone https://huggingface.co/spaces/Domotick/reachy_web
|
| 58 |
cd reachy_web
|
| 59 |
|
| 60 |
-
./
|
| 61 |
```
|
| 62 |
|
| 63 |
The script builds its own virtualenv, installs the app, then **scans the network** (mDNS) for a Reachy Mini and points the dashboard at it. Open <http://127.0.0.1:8042>.
|
|
@@ -65,7 +70,7 @@ The script builds its own virtualenv, installs the app, then **scans the network
|
|
| 65 |
Know the address already, or running a daemon locally (`reachy-mini-daemon --sim` works too)? Skip the scan:
|
| 66 |
|
| 67 |
```bash
|
| 68 |
-
DAEMON_HOST=192.168.1.42:8000 ./
|
| 69 |
```
|
| 70 |
|
| 71 |
If port 8042 is taken, the desktop app keeps it for the apps it hosts, `run.sh` falls back to 8043 and says so.
|
|
|
|
| 14 |
- reachy_mini_lite
|
| 15 |
- dashboard
|
| 16 |
- web_dashboard
|
| 17 |
+
- web_app
|
| 18 |
- robot_dashboard
|
| 19 |
+
- robot_tools
|
| 20 |
+
- robot_control
|
| 21 |
- digital_twin
|
| 22 |
+
- tableau_de_bord
|
| 23 |
+
- application_web
|
| 24 |
- reachy_mirror
|
| 25 |
- reachy_eyes
|
| 26 |
- domotick
|
|
|
|
| 62 |
git clone https://huggingface.co/spaces/Domotick/reachy_web
|
| 63 |
cd reachy_web
|
| 64 |
|
| 65 |
+
./run.sh
|
| 66 |
```
|
| 67 |
|
| 68 |
The script builds its own virtualenv, installs the app, then **scans the network** (mDNS) for a Reachy Mini and points the dashboard at it. Open <http://127.0.0.1:8042>.
|
|
|
|
| 70 |
Know the address already, or running a daemon locally (`reachy-mini-daemon --sim` works too)? Skip the scan:
|
| 71 |
|
| 72 |
```bash
|
| 73 |
+
DAEMON_HOST=192.168.1.42:8000 ./run.sh
|
| 74 |
```
|
| 75 |
|
| 76 |
If port 8042 is taken, the desktop app keeps it for the apps it hosts, `run.sh` falls back to 8043 and says so.
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
0.
|
|
|
|
| 1 |
+
0.8.0
|
changelog.json
CHANGED
|
@@ -1,4 +1,82 @@
|
|
| 1 |
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
{
|
| 3 |
"version": "0.7.0",
|
| 4 |
"date": "2026-07-17",
|
|
|
|
| 1 |
[
|
| 2 |
+
{
|
| 3 |
+
"version": "0.8.0",
|
| 4 |
+
"date": "2026-07-20",
|
| 5 |
+
"changes": {
|
| 6 |
+
"en": [
|
| 7 |
+
"Reachy Eyes: drive the community LED eyes module from the dashboard, its colors and effects, mirrored live on the 3D twin.",
|
| 8 |
+
"Movement sequences: record keyframes, replay them, and export or import your own.",
|
| 9 |
+
"A 10-band graphic equalizer to shape the robot's sound.",
|
| 10 |
+
"One shared camera stream feeds the picture, the mic and Listen, and it survives power off and on.",
|
| 11 |
+
"12 new skins (36 in all), and picking one from the camera view applies it right away.",
|
| 12 |
+
"A cleaner simulator: tidy wake and fold, a LIVE / SIM badge, and no startup flicker.",
|
| 13 |
+
"Install straight on the robot with setup.sh for a dashboard that starts at boot, plus a community Eyes showcase on the landing."
|
| 14 |
+
],
|
| 15 |
+
"fr": [
|
| 16 |
+
"Reachy Eyes : pilotez le module LED communautaire depuis le tableau de bord, ses couleurs et ses effets, reflétés en direct sur le jumeau 3D.",
|
| 17 |
+
"Séquences de mouvement : enregistrez des images clés, rejouez-les, et exportez ou importez les vôtres.",
|
| 18 |
+
"Un égaliseur graphique à 10 bandes pour façonner le son du robot.",
|
| 19 |
+
"Un seul flux caméra partagé alimente l'image, le micro et Listen, et il survit à l'extinction et au rallumage.",
|
| 20 |
+
"12 nouveaux skins (36 en tout), et en choisir un depuis la vue caméra l'applique aussitôt.",
|
| 21 |
+
"Un simulateur plus propre : réveil et repli soignés, un badge LIVE / SIM, et plus aucun scintillement au démarrage.",
|
| 22 |
+
"Installez directement sur le robot avec setup.sh pour un tableau de bord qui se lance au démarrage, plus une vitrine Eyes communautaire sur la page d'accueil."
|
| 23 |
+
],
|
| 24 |
+
"es": [
|
| 25 |
+
"Reachy Eyes: controla el módulo de ojos LED de la comunidad desde el panel, sus colores y efectos, reflejados en vivo en el gemelo 3D.",
|
| 26 |
+
"Secuencias de movimiento: graba fotogramas clave, reprodúcelos y exporta o importa los tuyos.",
|
| 27 |
+
"Un ecualizador gráfico de 10 bandas para dar forma al sonido del robot.",
|
| 28 |
+
"Una única transmisión de cámara compartida alimenta la imagen, el micrófono y Listen, y sobrevive al apagado y encendido.",
|
| 29 |
+
"12 nuevos skins (36 en total), y elegir uno desde la vista de cámara lo aplica al instante.",
|
| 30 |
+
"Un simulador más limpio: despertar y plegado ordenados, una insignia LIVE / SIM y sin parpadeo al arrancar.",
|
| 31 |
+
"Instala directamente en el robot con setup.sh para un panel que arranca al iniciar, más una vitrina Eyes de la comunidad en la página de inicio."
|
| 32 |
+
],
|
| 33 |
+
"de": [
|
| 34 |
+
"Reachy Eyes: Steuere das LED-Augenmodul der Community vom Dashboard aus, seine Farben und Effekte, live gespiegelt auf dem 3D-Zwilling.",
|
| 35 |
+
"Bewegungssequenzen: Zeichne Keyframes auf, spiele sie ab und exportiere oder importiere eigene.",
|
| 36 |
+
"Ein grafischer 10-Band-Equalizer, um den Klang des Roboters zu formen.",
|
| 37 |
+
"Ein gemeinsamer Kamerastream versorgt Bild, Mikrofon und Listen und übersteht das Aus- und Einschalten.",
|
| 38 |
+
"12 neue Skins (36 insgesamt), und die Auswahl aus der Kameraansicht übernimmt ihn sofort.",
|
| 39 |
+
"Ein aufgeräumterer Simulator: sauberes Aufwecken und Einklappen, ein LIVE / SIM Badge und kein Flackern beim Start.",
|
| 40 |
+
"Installiere direkt auf dem Roboter mit setup.sh für ein Dashboard, das beim Systemstart läuft, plus eine Community-Eyes-Vitrine auf der Landingpage."
|
| 41 |
+
],
|
| 42 |
+
"it": [
|
| 43 |
+
"Reachy Eyes: controlla il modulo occhi LED della community dal pannello, i suoi colori ed effetti, riflessi dal vivo sul gemello 3D.",
|
| 44 |
+
"Sequenze di movimento: registra i fotogrammi chiave, riproducili ed esporta o importa i tuoi.",
|
| 45 |
+
"Un equalizzatore grafico a 10 bande per modellare il suono del robot.",
|
| 46 |
+
"Un unico flusso della camera condiviso alimenta l'immagine, il microfono e Listen, e sopravvive allo spegnimento e alla riaccensione.",
|
| 47 |
+
"12 nuovi skin (36 in tutto), e sceglierne uno dalla vista camera lo applica subito.",
|
| 48 |
+
"Un simulatore più pulito: risveglio e ripiegamento ordinati, un badge LIVE / SIM e nessuno sfarfallio all'avvio.",
|
| 49 |
+
"Installa direttamente sul robot con setup.sh per un pannello che parte all'avvio, più una vetrina Eyes della community sulla landing page."
|
| 50 |
+
],
|
| 51 |
+
"pt": [
|
| 52 |
+
"Reachy Eyes: controle o módulo de olhos LED da comunidade pelo painel, suas cores e efeitos, espelhados ao vivo no gêmeo 3D.",
|
| 53 |
+
"Sequências de movimento: grave keyframes, reproduza-os e exporte ou importe os seus.",
|
| 54 |
+
"Um equalizador gráfico de 10 bandas para moldar o som do robô.",
|
| 55 |
+
"Um único fluxo de câmera compartilhado alimenta a imagem, o microfone e o Listen, e sobrevive ao desligar e ligar.",
|
| 56 |
+
"12 novos skins (36 no total), e escolher um pela visualização da câmera o aplica na hora.",
|
| 57 |
+
"Um simulador mais limpo: despertar e recolher organizados, um selo LIVE / SIM e sem tremulação na inicialização.",
|
| 58 |
+
"Instale direto no robô com setup.sh para um painel que inicia no boot, mais uma vitrine Eyes da comunidade na página inicial."
|
| 59 |
+
],
|
| 60 |
+
"ja": [
|
| 61 |
+
"Reachy Eyes: コミュニティのLEDアイモジュールをダッシュボードから操作。色やエフェクトを3Dツインにリアルタイムで反映します。",
|
| 62 |
+
"動作シーケンス: キーフレームを記録して再生し、自作のものをエクスポートまたはインポートできます。",
|
| 63 |
+
"ロボットのサウンドを調整できる10バンドのグラフィックイコライザー。",
|
| 64 |
+
"共有された1つのカメラストリームが映像、マイク、Listenに供給され、電源のオフとオンをまたいで維持されます。",
|
| 65 |
+
"新しいスキンが12種類(全36種類)。カメラビューから選ぶとすぐに適用されます。",
|
| 66 |
+
"よりすっきりしたシミュレーター: 起動と折りたたみが整い、LIVE / SIMバッジを追加、起動時のちらつきもなし。",
|
| 67 |
+
"setup.sh でロボットに直接インストールすれば、起動時にダッシュボードが立ち上がります。ランディングページにはコミュニティのEyesショーケースも追加。"
|
| 68 |
+
],
|
| 69 |
+
"ar": [
|
| 70 |
+
"Reachy Eyes: تحكّم في وحدة عيون LED المجتمعية من لوحة التحكم، بألوانها وتأثيراتها، مع عكسها مباشرة على التوأم 3D.",
|
| 71 |
+
"تسلسلات الحركة: سجّل الإطارات المفتاحية وأعد تشغيلها، وصدّر أو استورد ما تصنعه بنفسك.",
|
| 72 |
+
"معادل صوتي بياني من 10 نطاقات لتشكيل صوت الروبوت.",
|
| 73 |
+
"بث كاميرا واحد مشترك يغذّي الصورة والميكروفون وListen، ويصمد عند إيقاف التشغيل وتشغيله من جديد.",
|
| 74 |
+
"12 مظهرًا جديدًا (36 في المجمل)، واختيار واحد من عرض الكاميرا يطبّقه على الفور.",
|
| 75 |
+
"محاكٍ أكثر سلاسة: إيقاظ وطي مرتّبان، وشارة LIVE / SIM، وبلا وميض عند بدء التشغيل.",
|
| 76 |
+
"ثبّت مباشرة على الروبوت باستخدام setup.sh للحصول على لوحة تحكم تبدأ عند الإقلاع، إضافةً إلى واجهة عرض Eyes مجتمعية في الصفحة الرئيسية."
|
| 77 |
+
]
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
{
|
| 81 |
"version": "0.7.0",
|
| 82 |
"date": "2026-07-17",
|
fonts/Manrope.ttf
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:d0639be45d0af36e798172419d7bd173c4bd4f29e2b76cbb69db1d11bf8b0a40
|
| 3 |
-
size 165420
|
|
|
|
|
|
|
|
|
|
|
|
fonts/SpaceGrotesk.ttf
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:acad6de1fc93436f5c0f1f4137751ef04f1aea3063e7036535970ffcfbd79f72
|
| 3 |
-
size 136676
|
|
|
|
|
|
|
|
|
|
|
|
index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>Reachy Web: the Reachy Mini web dashboard, back as an app</title>
|
| 7 |
<meta name="description" content="Reachy Web is the good old Reachy Mini web dashboard, back as an installable app. Control your Reachy Mini Lite or Wireless from any browser: live camera, 3D digital twin, moves, app store, volume, no desktop app needed. By Domotick.">
|
| 8 |
-
<meta name="keywords" content="Reachy Web, Reachy Mini, Reachy Mini dashboard, Reachy Mini Lite, Reachy Mini Wireless, web dashboard, browser dashboard, robot dashboard, robot control, 3D digital twin, live camera, Reachy Mirror, Reachy Eyes, Domotick, Pollen Robotics, Hugging Face Space, reachy_mini_python_app">
|
| 9 |
<meta name="author" content="Domotick">
|
| 10 |
<meta name="robots" content="index, follow">
|
| 11 |
<link rel="canonical" href="https://domotick-reachy-web.static.hf.space/">
|
|
@@ -17,13 +17,13 @@
|
|
| 17 |
<meta property="og:title" content="Reachy Web: the dashboard is back">
|
| 18 |
<meta property="og:description" content="Control your Reachy Mini from any browser again. The removed web dashboard, back as an installable app.">
|
| 19 |
<meta property="og:url" content="https://domotick-reachy-web.static.hf.space/">
|
| 20 |
-
<meta property="og:image" content="https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png?v=
|
| 21 |
<meta property="og:image:width" content="512">
|
| 22 |
<meta property="og:image:height" content="512">
|
| 23 |
<meta name="twitter:card" content="summary">
|
| 24 |
<meta name="twitter:title" content="Reachy Web: the dashboard is back">
|
| 25 |
<meta name="twitter:description" content="Control your Reachy Mini from any browser again. The removed web dashboard, back as an installable app.">
|
| 26 |
-
<meta name="twitter:image" content="https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png?v=
|
| 27 |
<script type="application/ld+json">
|
| 28 |
{
|
| 29 |
"@context": "https://schema.org",
|
|
@@ -33,25 +33,66 @@
|
|
| 33 |
"operatingSystem": "Any (web browser)",
|
| 34 |
"description": "The Reachy Mini web dashboard, back as an installable app. Control your Reachy Mini Lite or Wireless from any browser: live camera, 3D digital twin, moves, app store, volume, no desktop app needed.",
|
| 35 |
"url": "https://domotick-reachy-web.static.hf.space/",
|
| 36 |
-
"image": "https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png?v=
|
| 37 |
"author": { "@type": "Organization", "name": "Domotick", "url": "https://huggingface.co/Domotick" },
|
| 38 |
-
"keywords": "Reachy Mini, Reachy Mini Lite, Reachy Mini Wireless, dashboard, web dashboard, 3D digital twin, Reachy Mirror, Reachy Eyes, Domotick, Pollen Robotics",
|
| 39 |
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
|
| 40 |
}
|
| 41 |
</script>
|
| 42 |
<script>try{var d=document.documentElement,t=localStorage.getItem("theme")||(matchMedia("(prefers-color-scheme: light)").matches?"light":"dark");d.setAttribute("data-theme",t);var p=localStorage.getItem("palette")||"default";p!=="default"&&d.setAttribute("data-palette",p)}catch(e){}</script>
|
| 43 |
<link rel="stylesheet" href="style.css" />
|
| 44 |
<link rel="stylesheet" href="reachy_web/static/css/icons.css" />
|
|
|
|
| 45 |
</head>
|
| 46 |
<body>
|
| 47 |
<nav class="sticky top-0 z-10 border-b border-line backdrop-blur-xl bg-[color-mix(in_srgb,var(--bg)_78%,transparent)]">
|
| 48 |
<div class="mx-auto flex max-w-[1100px] flex-col items-stretch gap-3.5 px-5 py-2.5 md:flex-row md:items-center md:justify-between">
|
| 49 |
<div class="flex items-center justify-center gap-2.5 whitespace-nowrap font-display text-lg font-semibold md:justify-start">
|
| 50 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
<span>Reachy Web</span>
|
| 52 |
-
<span class="version-pill" id="version-pill">v0.
|
| 53 |
</div>
|
| 54 |
<div class="flex flex-wrap items-center justify-center gap-3.5 md:justify-end">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
<div class="flex flex-wrap items-center gap-1.5" id="palettes" title="Palette"></div>
|
| 56 |
<div class="flex items-center gap-2 text-mut">
|
| 57 |
<span class="ic i-weather-night"></span>
|
|
@@ -114,8 +155,8 @@
|
|
| 114 |
<div class="mx-auto max-w-[1100px] px-5">
|
| 115 |
<div class="mb-11 text-center">
|
| 116 |
<p class="mb-3.5 text-sm font-bold uppercase tracking-[1.5px] text-acc">Getting it running</p>
|
| 117 |
-
<h2 class="mb-3 text-3xl">
|
| 118 |
-
<p class="mx-auto max-w-[680px] text-mut">
|
| 119 |
</div>
|
| 120 |
|
| 121 |
<div class="grid gap-[18px] md:grid-cols-2">
|
|
@@ -135,7 +176,8 @@
|
|
| 135 |
</a>
|
| 136 |
<p class="text-xs text-mut">Desktop: macOS is production-ready, Windows and Linux are still work in progress. No Android app yet.</p>
|
| 137 |
</div>
|
| 138 |
-
<
|
|
|
|
| 139 |
href="https://apps.apple.com/app/reachy-mini/id6766823749" target="_blank" rel="noopener"
|
| 140 |
title="Scan to get the iPhone app">
|
| 141 |
<img src="reachy_web/static/assets/qr-ios.svg" alt="QR code to the Reachy Mini app on the App Store" class="block h-28 w-28">
|
|
@@ -158,16 +200,34 @@ curl -X POST http://reachy-mini.local:8000/api/apps/start-app/reachy_web</code><
|
|
| 158 |
</div>
|
| 159 |
|
| 160 |
<div class="card">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
<h3 class="mb-2 flex items-center gap-2.5 text-xl"><span class="ic i-cog text-acc"></span> On your machine: <code class="font-mono text-acc">run.sh</code></h3>
|
| 162 |
<p class="mb-4 text-[0.95rem] text-mut">The dashboard only needs to reach the daemon over HTTP, so it can just as well run on your laptop. Nothing is installed on the robot, ideal for trying it out or hacking on the code.</p>
|
| 163 |
<div class="code-block mb-4"><code># grab the app (it is just a git repo)
|
| 164 |
git clone https://huggingface.co/spaces/Domotick/reachy_web
|
| 165 |
cd reachy_web
|
| 166 |
|
| 167 |
-
./
|
| 168 |
<p class="mb-4 text-[0.95rem] text-mut">The script builds its own virtualenv, installs the app, then <strong class="text-txt">scans the network</strong> (mDNS) for a Reachy Mini and points the dashboard at it. Open <strong class="text-txt">http://127.0.0.1:8042</strong> and you are in.</p>
|
| 169 |
<p class="mb-3 text-sm text-mut">Know your robot's address, or running the daemon locally? Skip the scan:</p>
|
| 170 |
-
<div class="code-block"><code>DAEMON_HOST=192.168.1.42:8000 ./
|
| 171 |
|
| 172 |
# port 8042 taken (the desktop app keeps it)?
|
| 173 |
# run.sh falls back to 8043 and tells you.</code></div>
|
|
@@ -203,6 +263,107 @@ cd reachy_web
|
|
| 203 |
</div>
|
| 204 |
</section>
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
<section id="features" class="py-14">
|
| 207 |
<div class="mx-auto max-w-[1100px] px-5">
|
| 208 |
<div class="mb-11 text-center">
|
|
@@ -366,7 +527,22 @@ cd reachy_web
|
|
| 366 |
}
|
| 367 |
})
|
| 368 |
.catch(err => console.error('Failed to load changelog:', err));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
</script>
|
| 370 |
<script src="reachy_web/static/js/landing-i18n.js"></script>
|
|
|
|
| 371 |
</body>
|
| 372 |
</html>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>Reachy Web: the Reachy Mini web dashboard, back as an app</title>
|
| 7 |
<meta name="description" content="Reachy Web is the good old Reachy Mini web dashboard, back as an installable app. Control your Reachy Mini Lite or Wireless from any browser: live camera, 3D digital twin, moves, app store, volume, no desktop app needed. By Domotick.">
|
| 8 |
+
<meta name="keywords" content="Reachy Web, Reachy Mini, Reachy Mini dashboard, Reachy Mini Lite, Reachy Mini Wireless, web dashboard, web app, browser dashboard, robot dashboard, robot control, robot tools, robot web app, control robot from browser, 3D digital twin, live camera, app store, Reachy Mirror, Reachy Eyes, Domotick, Pollen Robotics, Hugging Face Space, reachy_mini_python_app, tableau de bord Reachy Mini, tableau de bord robot, dashboard web robot, application web robot, outils robot, piloter Reachy Mini depuis un navigateur, controle robot en ligne, camera robot en direct, jumeau numerique 3D, boutique d'applications robot">
|
| 9 |
<meta name="author" content="Domotick">
|
| 10 |
<meta name="robots" content="index, follow">
|
| 11 |
<link rel="canonical" href="https://domotick-reachy-web.static.hf.space/">
|
|
|
|
| 17 |
<meta property="og:title" content="Reachy Web: the dashboard is back">
|
| 18 |
<meta property="og:description" content="Control your Reachy Mini from any browser again. The removed web dashboard, back as an installable app.">
|
| 19 |
<meta property="og:url" content="https://domotick-reachy-web.static.hf.space/">
|
| 20 |
+
<meta property="og:image" content="https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png?v=3">
|
| 21 |
<meta property="og:image:width" content="512">
|
| 22 |
<meta property="og:image:height" content="512">
|
| 23 |
<meta name="twitter:card" content="summary">
|
| 24 |
<meta name="twitter:title" content="Reachy Web: the dashboard is back">
|
| 25 |
<meta name="twitter:description" content="Control your Reachy Mini from any browser again. The removed web dashboard, back as an installable app.">
|
| 26 |
+
<meta name="twitter:image" content="https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png?v=3">
|
| 27 |
<script type="application/ld+json">
|
| 28 |
{
|
| 29 |
"@context": "https://schema.org",
|
|
|
|
| 33 |
"operatingSystem": "Any (web browser)",
|
| 34 |
"description": "The Reachy Mini web dashboard, back as an installable app. Control your Reachy Mini Lite or Wireless from any browser: live camera, 3D digital twin, moves, app store, volume, no desktop app needed.",
|
| 35 |
"url": "https://domotick-reachy-web.static.hf.space/",
|
| 36 |
+
"image": "https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png?v=3",
|
| 37 |
"author": { "@type": "Organization", "name": "Domotick", "url": "https://huggingface.co/Domotick" },
|
| 38 |
+
"keywords": "Reachy Mini, Reachy Mini Lite, Reachy Mini Wireless, dashboard, web dashboard, web app, robot tools, robot control, 3D digital twin, app store, Reachy Mirror, Reachy Eyes, Domotick, Pollen Robotics, tableau de bord robot, application web robot, outils robot, piloter Reachy Mini depuis un navigateur, jumeau numerique 3D",
|
| 39 |
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
|
| 40 |
}
|
| 41 |
</script>
|
| 42 |
<script>try{var d=document.documentElement,t=localStorage.getItem("theme")||(matchMedia("(prefers-color-scheme: light)").matches?"light":"dark");d.setAttribute("data-theme",t);var p=localStorage.getItem("palette")||"default";p!=="default"&&d.setAttribute("data-palette",p)}catch(e){}</script>
|
| 43 |
<link rel="stylesheet" href="style.css" />
|
| 44 |
<link rel="stylesheet" href="reachy_web/static/css/icons.css" />
|
| 45 |
+
<link rel="stylesheet" href="reachy_web/static/css/logo.css" />
|
| 46 |
</head>
|
| 47 |
<body>
|
| 48 |
<nav class="sticky top-0 z-10 border-b border-line backdrop-blur-xl bg-[color-mix(in_srgb,var(--bg)_78%,transparent)]">
|
| 49 |
<div class="mx-auto flex max-w-[1100px] flex-col items-stretch gap-3.5 px-5 py-2.5 md:flex-row md:items-center md:justify-between">
|
| 50 |
<div class="flex items-center justify-center gap-2.5 whitespace-nowrap font-display text-lg font-semibold md:justify-start">
|
| 51 |
+
<!-- INLINE like in the app (an <img>-loaded SVG cannot run scripts, so
|
| 52 |
+
its timing would be a fixed loop): logo_anim.js plays a random move
|
| 53 |
+
now and then, the wink first. Same markup as base.html. -->
|
| 54 |
+
<svg class="topbar-logo h-9 w-9" viewBox="24 13 72 72" role="img" aria-label="Reachy Web">
|
| 55 |
+
<g class="lg-sway lg-sway-l">
|
| 56 |
+
<g class="lg-ant lg-ant-l">
|
| 57 |
+
<path d="M45 46 L37 22" fill="none" stroke="#1b2330" stroke-width="3" stroke-linecap="round"/>
|
| 58 |
+
<circle class="lg-ball lg-ball-l" cx="37" cy="22" r="4" fill="#1b2330"/>
|
| 59 |
+
</g>
|
| 60 |
+
</g>
|
| 61 |
+
<g class="lg-sway lg-sway-r">
|
| 62 |
+
<g class="lg-ant lg-ant-r">
|
| 63 |
+
<path d="M75 46 L81.4 26.4" fill="none" stroke="#1b2330" stroke-width="3" stroke-linecap="round"/>
|
| 64 |
+
<circle class="lg-ball lg-ball-r" cx="81.4" cy="26.4" r="3.4" fill="#1b2330"/>
|
| 65 |
+
</g>
|
| 66 |
+
</g>
|
| 67 |
+
<rect x="34" y="44" width="52" height="36" rx="13" fill="#fff" stroke="#1b2330" stroke-width="3"/>
|
| 68 |
+
<circle class="lg-eye lg-eye-l" cx="48.98" cy="62.21" r="8.14" fill="#1b2330"/>
|
| 69 |
+
<circle class="lg-eye lg-eye-r" cx="71.02" cy="62.21" r="7" fill="#1b2330"/>
|
| 70 |
+
<path class="lg-wink" d="M63.9 61.5 Q71 68.5 78.1 61.5" fill="none" stroke="#1b2330" stroke-width="3.6" stroke-linecap="round"/>
|
| 71 |
+
<g class="lg-glasses" fill="none" stroke="#1b2330" stroke-width="3" stroke-linecap="round">
|
| 72 |
+
<circle cx="48.98" cy="62.21" r="11.5"/>
|
| 73 |
+
<circle cx="71.02" cy="62.21" r="10.5"/>
|
| 74 |
+
<path d="M37.48 62.21 L34 59.5"/>
|
| 75 |
+
<path d="M81.52 62.21 L86 59.5"/>
|
| 76 |
+
</g>
|
| 77 |
+
</svg>
|
| 78 |
<span>Reachy Web</span>
|
| 79 |
+
<span class="version-pill" id="version-pill">v0.8.0</span>
|
| 80 |
</div>
|
| 81 |
<div class="flex flex-wrap items-center justify-center gap-3.5 md:justify-end">
|
| 82 |
+
<div class="relative flex items-center text-mut">
|
| 83 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="pointer-events-none absolute left-3 h-[15px] w-[15px]"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.5 2.4 4 5.6 4 9s-1.5 6.6-4 9c-2.5-2.4-4-5.6-4-9s1.5-6.6 4-9z"/></svg>
|
| 84 |
+
<select id="langSel" aria-label="Language" class="cursor-pointer appearance-none rounded-full border border-line bg-card py-1.5 pl-9 pr-8 text-sm font-semibold text-txt transition hover:border-acc focus:border-acc focus:outline-none">
|
| 85 |
+
<option value="en">English</option>
|
| 86 |
+
<option value="fr">Français</option>
|
| 87 |
+
<option value="es">Español</option>
|
| 88 |
+
<option value="de">Deutsch</option>
|
| 89 |
+
<option value="it">Italiano</option>
|
| 90 |
+
<option value="pt">Português</option>
|
| 91 |
+
<option value="ja">日本語</option>
|
| 92 |
+
<option value="ar">العربية</option>
|
| 93 |
+
</select>
|
| 94 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="pointer-events-none absolute right-3 h-3.5 w-3.5"><path d="M6 9l6 6 6-6"/></svg>
|
| 95 |
+
</div>
|
| 96 |
<div class="flex flex-wrap items-center gap-1.5" id="palettes" title="Palette"></div>
|
| 97 |
<div class="flex items-center gap-2 text-mut">
|
| 98 |
<span class="ic i-weather-night"></span>
|
|
|
|
| 155 |
<div class="mx-auto max-w-[1100px] px-5">
|
| 156 |
<div class="mb-11 text-center">
|
| 157 |
<p class="mb-3.5 text-sm font-bold uppercase tracking-[1.5px] text-acc">Getting it running</p>
|
| 158 |
+
<h2 class="mb-3 text-3xl">Three ways to run it</h2>
|
| 159 |
+
<p class="mx-auto max-w-[680px] text-mut">Through Pollen's app, directly on the robot so it starts at boot, or on your own machine pointed at the robot over the network.</p>
|
| 160 |
</div>
|
| 161 |
|
| 162 |
<div class="grid gap-[18px] md:grid-cols-2">
|
|
|
|
| 176 |
</a>
|
| 177 |
<p class="text-xs text-mut">Desktop: macOS is production-ready, Windows and Linux are still work in progress. No Android app yet.</p>
|
| 178 |
</div>
|
| 179 |
+
<!-- a QR scans black on white: the backing stays white in BOTH themes -->
|
| 180 |
+
<a class="justify-self-center rounded-xl border border-line bg-white p-2.5"
|
| 181 |
href="https://apps.apple.com/app/reachy-mini/id6766823749" target="_blank" rel="noopener"
|
| 182 |
title="Scan to get the iPhone app">
|
| 183 |
<img src="reachy_web/static/assets/qr-ios.svg" alt="QR code to the Reachy Mini app on the App Store" class="block h-28 w-28">
|
|
|
|
| 200 |
</div>
|
| 201 |
|
| 202 |
<div class="card">
|
| 203 |
+
<h3 class="mb-2 flex items-center gap-2.5 text-xl"><span class="ic i-power text-acc"></span> Directly on the robot, at boot</h3>
|
| 204 |
+
<p class="mb-4 text-[0.95rem] text-mut">No store, no problem: SSH into the robot and run setup.sh. It registers a systemd service, so the dashboard is up at every boot at http://your-robot.local:8042, with no SSH needed afterwards.</p>
|
| 205 |
+
<div class="code-block mb-4"><code># SSH into the robot (or the Pi beside a Lite)
|
| 206 |
+
ssh pollen@reachy-mini.local
|
| 207 |
+
|
| 208 |
+
git clone https://huggingface.co/spaces/Domotick/reachy_web
|
| 209 |
+
cd reachy_web
|
| 210 |
+
./setup.sh</code></div>
|
| 211 |
+
<p class="mb-4 text-[0.95rem] text-mut">This is the way to go for the Wireless version, which installs it on its onboard CM4, or a Lite paired with a Raspberry Pi beside it (install on that Pi, the one already running the daemon).</p>
|
| 212 |
+
<p class="mb-4 text-[0.95rem] text-mut">setup.sh builds the virtualenv once, writes reachy-web.service and enables it. It boots without waking the robot; the dashboard wakes it on demand.</p>
|
| 213 |
+
<p class="mb-3 text-sm text-mut">Check it, follow its logs, or remove it:</p>
|
| 214 |
+
<div class="code-block"><code>systemctl status reachy-web.service
|
| 215 |
+
journalctl -u reachy-web.service -f
|
| 216 |
+
|
| 217 |
+
./setup.sh --uninstall</code></div>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
<div class="card md:col-span-2">
|
| 221 |
<h3 class="mb-2 flex items-center gap-2.5 text-xl"><span class="ic i-cog text-acc"></span> On your machine: <code class="font-mono text-acc">run.sh</code></h3>
|
| 222 |
<p class="mb-4 text-[0.95rem] text-mut">The dashboard only needs to reach the daemon over HTTP, so it can just as well run on your laptop. Nothing is installed on the robot, ideal for trying it out or hacking on the code.</p>
|
| 223 |
<div class="code-block mb-4"><code># grab the app (it is just a git repo)
|
| 224 |
git clone https://huggingface.co/spaces/Domotick/reachy_web
|
| 225 |
cd reachy_web
|
| 226 |
|
| 227 |
+
./run.sh</code></div>
|
| 228 |
<p class="mb-4 text-[0.95rem] text-mut">The script builds its own virtualenv, installs the app, then <strong class="text-txt">scans the network</strong> (mDNS) for a Reachy Mini and points the dashboard at it. Open <strong class="text-txt">http://127.0.0.1:8042</strong> and you are in.</p>
|
| 229 |
<p class="mb-3 text-sm text-mut">Know your robot's address, or running the daemon locally? Skip the scan:</p>
|
| 230 |
+
<div class="code-block"><code>DAEMON_HOST=192.168.1.42:8000 ./run.sh
|
| 231 |
|
| 232 |
# port 8042 taken (the desktop app keeps it)?
|
| 233 |
# run.sh falls back to 8043 and tells you.</code></div>
|
|
|
|
| 263 |
</div>
|
| 264 |
</section>
|
| 265 |
|
| 266 |
+
<section id="eyes" class="py-14">
|
| 267 |
+
<div class="mx-auto max-w-[1100px] px-5">
|
| 268 |
+
<div class="mb-11 text-center">
|
| 269 |
+
<p class="mb-3.5 text-sm font-bold uppercase tracking-[1.5px] text-acc">Community hardware</p>
|
| 270 |
+
<!-- the section mascot: a Reachy with a USB eyes module, blinking then
|
| 271 |
+
flashing its LEDs through the colours. Pure inline CSS so it runs
|
| 272 |
+
as-is, honours prefers-reduced-motion. -->
|
| 273 |
+
<style>
|
| 274 |
+
.eyes-bot .ex-sway-l{transform-box:fill-box;transform-origin:100% 100%;animation:exSwayL 7.3s ease-in-out infinite}
|
| 275 |
+
.eyes-bot .ex-sway-r{transform-box:fill-box;transform-origin:0% 100%;animation:exSwayR 11.9s ease-in-out infinite}
|
| 276 |
+
@keyframes exSwayL{0%,100%{transform:rotate(-5deg)}50%{transform:rotate(5deg)}}
|
| 277 |
+
@keyframes exSwayR{0%,100%{transform:rotate(4deg)}50%{transform:rotate(-4deg)}}
|
| 278 |
+
.eyes-bot .ex-eL{transform-box:view-box;transform-origin:48.98px 62.21px}
|
| 279 |
+
.eyes-bot .ex-eR{transform-box:view-box;transform-origin:71.02px 62.21px}
|
| 280 |
+
.eyes-bot .ex-rb{animation:exRb 5s linear infinite}
|
| 281 |
+
@keyframes exRb{0%{fill:#ff2d55}16%{fill:#ff9500}33%{fill:#ffd60a}50%{fill:#34c759}66%{fill:#00c7ff}83%{fill:#af52de}100%{fill:#ff2d55}}
|
| 282 |
+
.eyes-bot .ex-lid{animation:exBlink 2.8s ease-in-out infinite}
|
| 283 |
+
@keyframes exBlink{0%,10%{transform:scaleY(1)}16%{transform:scaleY(.1)}22%{transform:scaleY(1)}28%{transform:scaleY(.1)}34%,100%{transform:scaleY(1)}}
|
| 284 |
+
.eyes-bot .ex-zap{animation:exZap 2.8s ease-in-out infinite}
|
| 285 |
+
@keyframes exZap{0%,40%,100%{opacity:0;transform:scale(.2)}50%{opacity:1;transform:scale(1.35)}70%{opacity:.7;transform:scale(1)}82%{opacity:0}}
|
| 286 |
+
@media (prefers-reduced-motion:reduce){.eyes-bot *{animation:none!important}}
|
| 287 |
+
</style>
|
| 288 |
+
<svg class="eyes-bot mx-auto mb-4 block h-24 w-24 text-txt" viewBox="24 14 72 90" style="overflow:visible" role="img" aria-label="A Reachy with LED eyes">
|
| 289 |
+
<defs>
|
| 290 |
+
<filter id="ex-g" x="-140%" y="-140%" width="380%" height="380%"><feGaussianBlur stdDeviation="2.4"/></filter>
|
| 291 |
+
<radialGradient id="ex-hot"><stop offset="0" stop-color="#fff"/><stop offset=".45" stop-color="#fff" stop-opacity=".5"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>
|
| 292 |
+
</defs>
|
| 293 |
+
<g class="ex-sway-l"><path d="M45 46 L37 22" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="37" cy="22" r="4" fill="currentColor"/></g>
|
| 294 |
+
<g class="ex-sway-r"><path d="M75 46 L81.4 26.4" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="81.4" cy="26.4" r="3.4" fill="currentColor"/></g>
|
| 295 |
+
<rect x="34" y="44" width="52" height="36" rx="13" fill="#fff" stroke="currentColor" stroke-width="3"/>
|
| 296 |
+
<g class="ex-lid ex-eL"><circle class="ex-rb" cx="48.98" cy="62.21" r="8.14"/></g>
|
| 297 |
+
<g class="ex-lid ex-eR"><circle class="ex-rb" cx="71.02" cy="62.21" r="7"/></g>
|
| 298 |
+
<g class="ex-zap ex-eL"><circle class="ex-rb" cx="48.98" cy="62.21" r="14" filter="url(#ex-g)" opacity=".9"/></g>
|
| 299 |
+
<g class="ex-zap ex-eR"><circle class="ex-rb" cx="71.02" cy="62.21" r="13" filter="url(#ex-g)" opacity=".9"/></g>
|
| 300 |
+
<g class="ex-zap ex-eL"><circle cx="48.98" cy="62.21" r="6" fill="url(#ex-hot)"/></g>
|
| 301 |
+
<g class="ex-zap ex-eR"><circle cx="71.02" cy="62.21" r="6" fill="url(#ex-hot)"/></g>
|
| 302 |
+
<rect x="52" y="86" width="16" height="9" rx="1.5" fill="#0f2e1e" stroke="#1b2330" stroke-width="1"/><rect x="54" y="88" width="7" height="4" rx=".6" fill="#c0c4cc"/><line x1="60" y1="80" x2="60" y2="86" stroke="#1b2330" stroke-width="1.4"/><rect x="57.5" y="95" width="5" height="4" rx=".8" fill="#8b8f98"/><circle cx="65" cy="88.4" r=".9" fill="#3ddc84"/>
|
| 303 |
+
</svg>
|
| 304 |
+
<h2 class="mb-3 text-3xl">Eyes for your Reachy</h2>
|
| 305 |
+
<p class="mx-auto max-w-[620px] text-mut">The community fits the Reachy Mini with glowing, expressive eyes. Two projects lead the way; plug the USB kit in and the dashboard grows an Eyes panel.</p>
|
| 306 |
+
</div>
|
| 307 |
+
<div class="grid grid-cols-1 gap-[18px] md:grid-cols-2">
|
| 308 |
+
<!-- Tom Mulder, ESP32 eyes -->
|
| 309 |
+
<div class="card flex flex-col">
|
| 310 |
+
<div class="ex-video group relative mb-4 block aspect-video w-full cursor-pointer overflow-hidden rounded-xl border border-line bg-black" data-yt="OOBtFCLjouE" role="button" tabindex="0" aria-label="Play the ESP32 eyes demo video">
|
| 311 |
+
<img src="https://img.youtube.com/vi/OOBtFCLjouE/hqdefault.jpg" alt="ESP32 eyes demo" loading="lazy" class="h-full w-full object-cover opacity-90 transition group-hover:opacity-100">
|
| 312 |
+
<span class="absolute left-1/2 top-1/2 flex h-14 w-14 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white transition group-hover:scale-110 group-hover:bg-[#e5232a]"><svg viewBox="0 0 24 24" fill="currentColor" class="ml-0.5 h-6 w-6"><path d="M8 5v14l11-7z"/></svg></span>
|
| 313 |
+
</div>
|
| 314 |
+
<h3 class="mb-1 flex items-center gap-2.5 text-lg"><span class="ic i-flash text-acc"></span> ESP32 eyes</h3>
|
| 315 |
+
<p class="mb-3 text-sm text-mut">by <a class="font-semibold text-txt underline-offset-2 hover:underline" href="https://algoryn.nl" target="_blank" rel="noopener">Tom Mulder</a></p>
|
| 316 |
+
<p class="mb-4 text-[0.92rem] text-mut">WiFi LED eyes on an ESP32: a REST API, pre-built emotions (joy, anger, amazement), mDNS discovery, and Reachy can drive its own gaze from the conversation app.</p>
|
| 317 |
+
<div class="mb-5 flex flex-wrap gap-1.5">
|
| 318 |
+
<span class="rounded-full border border-line bg-bg px-2.5 py-1 text-xs font-semibold text-mut">ESP32 / S2 / S3 / C3</span>
|
| 319 |
+
<span class="rounded-full border border-line bg-bg px-2.5 py-1 text-xs font-semibold text-mut">2x WS2812</span>
|
| 320 |
+
<span class="rounded-full border border-line bg-bg px-2.5 py-1 text-xs font-semibold text-mut">REST API</span>
|
| 321 |
+
<span class="rounded-full border border-line bg-bg px-2.5 py-1 text-xs font-semibold text-mut">mDNS · rmeyes.local</span>
|
| 322 |
+
</div>
|
| 323 |
+
<div class="mt-auto flex flex-wrap items-center gap-2.5">
|
| 324 |
+
<a class="inline-flex items-center gap-1.5 rounded-full bg-acc px-4 py-2 text-sm font-bold text-white transition hover:brightness-110" href="https://github.com/algoryn-nl/reachy-mini-esp32-eyes" target="_blank" rel="noopener"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><path d="m8 6-6 6 6 6M16 6l6 6-6 6"/></svg>Build it yourself</a>
|
| 325 |
+
<span class="flex-1"></span>
|
| 326 |
+
<div class="flex items-center gap-2.5 text-mut">
|
| 327 |
+
<a class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-line transition hover:border-acc hover:text-acc" href="https://github.com/algoryn-nl/reachy-mini-esp32-eyes" target="_blank" rel="noopener" aria-label="GitHub" title="GitHub"><svg viewBox="0 0 24 24" fill="currentColor" class="h-[18px] w-[18px]"><path d="M12 .5C5.4.5 0 5.9 0 12.6c0 5.3 3.4 9.8 8.2 11.4.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.4-5.5-6 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.6.1-3.3 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17 5 18 5.3 18 5.3c.6 1.7.2 3 .1 3.3.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.3v3.4c0 .3.2.7.8.6A12 12 0 0 0 24 12.6C24 5.9 18.6.5 12 .5z"/></svg></a>
|
| 328 |
+
<a class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-line transition hover:border-acc hover:text-acc" href="https://www.linkedin.com/posts/temulder_i-gave-reachy-mini-access-to-control-its-activity-7417959994995679233-bJ-D" target="_blank" rel="noopener" aria-label="LinkedIn demo" title="LinkedIn demo"><svg viewBox="0 0 24 24" fill="currentColor" class="h-[18px] w-[18px]"><path d="M20.4 20.4h-3.6v-5.6c0-1.3 0-3-1.9-3s-2.1 1.4-2.1 2.9v5.7H9.2V9h3.4v1.6h.1c.5-.9 1.7-1.9 3.4-1.9 3.6 0 4.3 2.4 4.3 5.5v6.2zM5.3 7.4a2.1 2.1 0 1 1 0-4.2 2.1 2.1 0 0 1 0 4.2zM7.1 20.4H3.5V9h3.6v11.4zM22.2 0H1.8C.8 0 0 .8 0 1.7v20.6c0 .9.8 1.7 1.8 1.7h20.4c1 0 1.8-.8 1.8-1.7V1.7C24 .8 23.2 0 22.2 0z"/></svg></a>
|
| 329 |
+
<a class="inline-flex h-9 items-center justify-center rounded-full border border-line px-3 text-xs font-bold transition hover:border-acc hover:text-acc" href="https://huggingface.co/algoryn" target="_blank" rel="noopener" aria-label="Hugging Face" title="Hugging Face">🤗</a>
|
| 330 |
+
<a class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-line transition hover:border-acc hover:text-acc" href="https://algoryn.nl" target="_blank" rel="noopener" aria-label="Website" title="algoryn.nl"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="h-[18px] w-[18px]"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.5 2.4 4 5.6 4 9s-1.5 6.6-4 9c-2.5-2.4-4-5.6-4-9s1.5-6.6 4-9z"/></svg></a>
|
| 331 |
+
</div>
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
<!-- Daniel Ritchie, Reachy Eyes -->
|
| 335 |
+
<div class="card flex flex-col">
|
| 336 |
+
<div class="ex-video group relative mb-4 block aspect-video w-full cursor-pointer overflow-hidden rounded-xl border border-line bg-black" data-yt="pDPKP4a4vfc" role="button" tabindex="0" aria-label="Play the Reachy Mini Eyes demo video">
|
| 337 |
+
<img src="https://img.youtube.com/vi/pDPKP4a4vfc/hqdefault.jpg" alt="Reachy Mini Eyes demo" loading="lazy" class="h-full w-full object-cover opacity-90 transition group-hover:opacity-100">
|
| 338 |
+
<span class="absolute left-1/2 top-1/2 flex h-14 w-14 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white transition group-hover:scale-110 group-hover:bg-[#e5232a]"><svg viewBox="0 0 24 24" fill="currentColor" class="ml-0.5 h-6 w-6"><path d="M8 5v14l11-7z"/></svg></span>
|
| 339 |
+
</div>
|
| 340 |
+
<h3 class="mb-1 flex items-center gap-2.5 text-lg"><span class="ic i-monitor text-acc"></span> Reachy Eyes</h3>
|
| 341 |
+
<p class="mb-3 text-sm text-mut">by <a class="font-semibold text-txt underline-offset-2 hover:underline" href="https://brainwavecollective.ai" target="_blank" rel="noopener">Daniel Ritchie</a>, Brainwave Collective</p>
|
| 342 |
+
<p class="mb-4 text-[0.92rem] text-mut">One of the first eye kits for the Reachy Mini: a USB module with an LED behind each lens, ready-made or DIY. The dashboard auto-detects it and drives colors, moods and effects.</p>
|
| 343 |
+
<div class="mb-5 flex flex-wrap gap-1.5">
|
| 344 |
+
<span class="rounded-full border border-line bg-bg px-2.5 py-1 text-xs font-semibold text-mut">USB plug and play</span>
|
| 345 |
+
<span class="rounded-full border border-line bg-bg px-2.5 py-1 text-xs font-semibold text-mut">WS2812</span>
|
| 346 |
+
<span class="rounded-full border border-line bg-bg px-2.5 py-1 text-xs font-semibold text-mut">Auto-detected</span>
|
| 347 |
+
<span class="rounded-full border border-line bg-bg px-2.5 py-1 text-xs font-semibold text-mut">DIY or ready-made</span>
|
| 348 |
+
</div>
|
| 349 |
+
<div class="mt-auto flex flex-wrap items-center gap-2.5">
|
| 350 |
+
<a class="inline-flex items-center gap-1.5 rounded-full bg-acc px-4 py-2 text-sm font-bold text-white transition hover:brightness-110" href="https://reachyeyes.brainwavecollective.ai/#buy" target="_blank" rel="noopener"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4"><path d="M6 6h15l-1.5 9h-12z"/><circle cx="9" cy="20" r="1.4"/><circle cx="18" cy="20" r="1.4"/><path d="M6 6 5 3H2"/></svg>Get the kit</a>
|
| 351 |
+
<span class="flex-1"></span>
|
| 352 |
+
<div class="flex items-center gap-2.5 text-mut">
|
| 353 |
+
<a class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-line transition hover:border-acc hover:text-acc" href="https://github.com/brainwavecollective/reachy-eyes" target="_blank" rel="noopener" aria-label="GitHub" title="GitHub"><svg viewBox="0 0 24 24" fill="currentColor" class="h-[18px] w-[18px]"><path d="M12 .5C5.4.5 0 5.9 0 12.6c0 5.3 3.4 9.8 8.2 11.4.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.4-5.5-6 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.6.1-3.3 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17 5 18 5.3 18 5.3c.6 1.7.2 3 .1 3.3.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.3v3.4c0 .3.2.7.8.6A12 12 0 0 0 24 12.6C24 5.9 18.6.5 12 .5z"/></svg></a>
|
| 354 |
+
<a class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-line transition hover:border-acc hover:text-acc" href="https://www.linkedin.com/posts/danielritchie123_one-of-the-first-sets-of-reachy-mini-eyes-activity-7424229814674538497-nqEs" target="_blank" rel="noopener" aria-label="LinkedIn" title="LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor" class="h-[18px] w-[18px]"><path d="M20.4 20.4h-3.6v-5.6c0-1.3 0-3-1.9-3s-2.1 1.4-2.1 2.9v5.7H9.2V9h3.4v1.6h.1c.5-.9 1.7-1.9 3.4-1.9 3.6 0 4.3 2.4 4.3 5.5v6.2zM5.3 7.4a2.1 2.1 0 1 1 0-4.2 2.1 2.1 0 0 1 0 4.2zM7.1 20.4H3.5V9h3.6v11.4zM22.2 0H1.8C.8 0 0 .8 0 1.7v20.6c0 .9.8 1.7 1.8 1.7h20.4c1 0 1.8-.8 1.8-1.7V1.7C24 .8 23.2 0 22.2 0z"/></svg></a>
|
| 355 |
+
<a class="inline-flex h-9 items-center justify-center rounded-full border border-line px-3 text-xs font-bold transition hover:border-acc hover:text-acc" href="https://huggingface.co/danielritchie" target="_blank" rel="noopener" aria-label="Hugging Face" title="Hugging Face">🤗</a>
|
| 356 |
+
<a class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-line transition hover:border-acc hover:text-acc" href="https://brainwavecollective.ai" target="_blank" rel="noopener" aria-label="Website" title="brainwavecollective.ai"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="h-[18px] w-[18px]"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.5 2.4 4 5.6 4 9s-1.5 6.6-4 9c-2.5-2.4-4-5.6-4-9s1.5-6.6 4-9z"/></svg></a>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
<div class="mt-9 text-center">
|
| 362 |
+
<a class="inline-flex items-center gap-2 rounded-full bg-acc px-5 py-2.5 text-sm font-bold text-white transition hover:brightness-110" href="https://discord.gg/WpJ7PE9GV" target="_blank" rel="noopener"><svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5"><path d="M20 4.4A19 19 0 0 0 15.3 3l-.2.4a17 17 0 0 1 4.2 1.3 15.7 15.7 0 0 0-13.4 0A17 17 0 0 1 10 3.4L9.8 3A19 19 0 0 0 5 4.4 20 20 0 0 0 1.4 18a19 19 0 0 0 5.8 3l.5-1a13 13 0 0 1-2-1l.5-.4a13.5 13.5 0 0 0 11.6 0l.5.4c-.6.4-1.3.7-2 1l.5 1a19 19 0 0 0 5.8-3A20 20 0 0 0 20 4.4zM8.5 14.7c-1.1 0-2-1-2-2.3s.9-2.3 2-2.3 2 1 2 2.3-.9 2.3-2 2.3zm7 0c-1.1 0-2-1-2-2.3s.9-2.3 2-2.3 2 1 2 2.3-.9 2.3-2 2.3z"/></svg>Discuss the eyes on Discord</a>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
</section>
|
| 366 |
+
|
| 367 |
<section id="features" class="py-14">
|
| 368 |
<div class="mx-auto max-w-[1100px] px-5">
|
| 369 |
<div class="mb-11 text-center">
|
|
|
|
| 527 |
}
|
| 528 |
})
|
| 529 |
.catch(err => console.error('Failed to load changelog:', err));
|
| 530 |
+
|
| 531 |
+
// Video preview: a poster facade that swaps in the YouTube player on click,
|
| 532 |
+
// so nothing loads from YouTube until the visitor asks for it.
|
| 533 |
+
document.querySelectorAll('.ex-video[data-yt]').forEach(el => {
|
| 534 |
+
const play = () => {
|
| 535 |
+
const id = el.dataset.yt;
|
| 536 |
+
el.innerHTML = `<iframe class="h-full w-full" src="https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0" title="Demo video" frameborder="0" allow="autoplay; encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe>`;
|
| 537 |
+
el.classList.remove('cursor-pointer');
|
| 538 |
+
el.removeAttribute('role');
|
| 539 |
+
el.removeAttribute('tabindex');
|
| 540 |
+
};
|
| 541 |
+
el.addEventListener('click', play);
|
| 542 |
+
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); } });
|
| 543 |
+
});
|
| 544 |
</script>
|
| 545 |
<script src="reachy_web/static/js/landing-i18n.js"></script>
|
| 546 |
+
<script src="reachy_web/static/js/logo_anim.js"></script>
|
| 547 |
</body>
|
| 548 |
</html>
|
pyproject.toml
CHANGED
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
| 5 |
|
| 6 |
[project]
|
| 7 |
name = "reachy_web"
|
| 8 |
-
version = "0.
|
| 9 |
description = "The web dashboard for your Reachy Mini, in any browser"
|
| 10 |
readme = "README.md"
|
| 11 |
requires-python = ">=3.10"
|
|
@@ -16,6 +16,7 @@ dependencies = [
|
|
| 16 |
"websockets",
|
| 17 |
"linuxpy; sys_platform == 'linux'",
|
| 18 |
"imageio-ffmpeg",
|
|
|
|
| 19 |
]
|
| 20 |
keywords = [
|
| 21 |
"reachy-mini-app",
|
|
@@ -23,7 +24,13 @@ keywords = [
|
|
| 23 |
"reachy-mini-lite",
|
| 24 |
"dashboard",
|
| 25 |
"web-dashboard",
|
|
|
|
|
|
|
|
|
|
| 26 |
"digital-twin",
|
|
|
|
|
|
|
|
|
|
| 27 |
"reachy-mirror",
|
| 28 |
"reachy-eyes",
|
| 29 |
"domotick",
|
|
|
|
| 5 |
|
| 6 |
[project]
|
| 7 |
name = "reachy_web"
|
| 8 |
+
version = "0.8.0"
|
| 9 |
description = "The web dashboard for your Reachy Mini, in any browser"
|
| 10 |
readme = "README.md"
|
| 11 |
requires-python = ">=3.10"
|
|
|
|
| 16 |
"websockets",
|
| 17 |
"linuxpy; sys_platform == 'linux'",
|
| 18 |
"imageio-ffmpeg",
|
| 19 |
+
"pyserial",
|
| 20 |
]
|
| 21 |
keywords = [
|
| 22 |
"reachy-mini-app",
|
|
|
|
| 24 |
"reachy-mini-lite",
|
| 25 |
"dashboard",
|
| 26 |
"web-dashboard",
|
| 27 |
+
"web-app",
|
| 28 |
+
"robot-tools",
|
| 29 |
+
"robot-control",
|
| 30 |
"digital-twin",
|
| 31 |
+
"tableau-de-bord",
|
| 32 |
+
"application-web",
|
| 33 |
+
"outils-robot",
|
| 34 |
"reachy-mirror",
|
| 35 |
"reachy-eyes",
|
| 36 |
"domotick",
|
reachy_web/audio_eq.py
CHANGED
|
@@ -27,6 +27,11 @@ SCHEMA = "com.github.wwmm.easyeffects"
|
|
| 27 |
TONE_PRESET = "Reachy Tone" # the preset the tone sliders patch
|
| 28 |
TONE_BANDS = ("band0", "band1", "band2") # bass, mid, treble — see the shipped JSON
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
def _preset_dirs() -> List[str]:
|
| 32 |
"""The output-preset folders EasyEffects reads, whichever layout it uses."""
|
|
@@ -152,6 +157,59 @@ def tone(bass: float, mid: float, treble: float) -> bool:
|
|
| 152 |
return load(TONE_PRESET)
|
| 153 |
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
def status() -> Dict[str, object]:
|
| 156 |
"""Everything the GUI needs in one poll."""
|
| 157 |
det = detected()
|
|
@@ -162,4 +220,6 @@ def status() -> Dict[str, object]:
|
|
| 162 |
"enabled": enabled() if det else False,
|
| 163 |
"presets": presets() if det else [],
|
| 164 |
"current": current() if det else "",
|
|
|
|
|
|
|
| 165 |
}
|
|
|
|
| 27 |
TONE_PRESET = "Reachy Tone" # the preset the tone sliders patch
|
| 28 |
TONE_BANDS = ("band0", "band1", "band2") # bass, mid, treble — see the shipped JSON
|
| 29 |
|
| 30 |
+
# the real graphic EQ the panel drives: one Bell band per fixed frequency, like
|
| 31 |
+
# any 10-band graphic equalizer. We write it as its own preset and reload it.
|
| 32 |
+
GRAPHIC_PRESET = "Reachy Graphic"
|
| 33 |
+
GRAPHIC_FREQS = (31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0)
|
| 34 |
+
|
| 35 |
|
| 36 |
def _preset_dirs() -> List[str]:
|
| 37 |
"""The output-preset folders EasyEffects reads, whichever layout it uses."""
|
|
|
|
| 157 |
return load(TONE_PRESET)
|
| 158 |
|
| 159 |
|
| 160 |
+
def graphic_bands() -> List[float]:
|
| 161 |
+
"""The current gain (dB) of each graphic band, or flat if none written yet."""
|
| 162 |
+
for d in _preset_dirs():
|
| 163 |
+
p = os.path.join(d, GRAPHIC_PRESET + ".json")
|
| 164 |
+
if os.path.exists(p):
|
| 165 |
+
try:
|
| 166 |
+
eq = json.load(open(p, encoding="utf-8"))["output"]["equalizer"]
|
| 167 |
+
left = eq.get("left", {})
|
| 168 |
+
return [float(left.get(f"band{i}", {}).get("gain", 0.0)) for i in range(len(GRAPHIC_FREQS))]
|
| 169 |
+
except (OSError, KeyError, ValueError):
|
| 170 |
+
pass
|
| 171 |
+
return [0.0] * len(GRAPHIC_FREQS)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def set_bands(gains: List[float]) -> bool:
|
| 175 |
+
"""Write the graphic EQ (one Bell band per fixed frequency) and load it.
|
| 176 |
+
|
| 177 |
+
Built on the shipped Tone preset's known-good structure: we only swap the band
|
| 178 |
+
list and gains, staying clear of EasyEffects' version-specific schema quirks."""
|
| 179 |
+
if not detected():
|
| 180 |
+
return False
|
| 181 |
+
tone = _tone_path()
|
| 182 |
+
if tone is None:
|
| 183 |
+
return False # the shipped presets provide the template we start from
|
| 184 |
+
gains = (list(gains) + [0.0] * len(GRAPHIC_FREQS))[: len(GRAPHIC_FREQS)]
|
| 185 |
+
try:
|
| 186 |
+
with open(tone, "r", encoding="utf-8") as fh:
|
| 187 |
+
data = json.load(fh)
|
| 188 |
+
eq = data["output"]["equalizer"]
|
| 189 |
+
except (OSError, KeyError, ValueError) as exc:
|
| 190 |
+
logger.warning("audio_eq: could not read the tone template: %s", exc)
|
| 191 |
+
return False
|
| 192 |
+
eq["num-bands"] = len(GRAPHIC_FREQS)
|
| 193 |
+
eq["mode"] = "IIR"
|
| 194 |
+
for channel in ("left", "right"):
|
| 195 |
+
eq[channel] = {
|
| 196 |
+
f"band{i}": {
|
| 197 |
+
"frequency": float(GRAPHIC_FREQS[i]), "gain": float(gains[i]),
|
| 198 |
+
"mode": "RLC (BT)", "mute": False, "q": 2.4, "slope": "x1",
|
| 199 |
+
"solo": False, "type": "Bell",
|
| 200 |
+
}
|
| 201 |
+
for i in range(len(GRAPHIC_FREQS))
|
| 202 |
+
}
|
| 203 |
+
path = os.path.join(os.path.dirname(tone), GRAPHIC_PRESET + ".json")
|
| 204 |
+
try:
|
| 205 |
+
with open(path, "w", encoding="utf-8") as fh:
|
| 206 |
+
json.dump(data, fh, indent=4)
|
| 207 |
+
except OSError as exc:
|
| 208 |
+
logger.warning("audio_eq: could not write the graphic preset: %s", exc)
|
| 209 |
+
return False
|
| 210 |
+
return _run(["easyeffects", "-l", GRAPHIC_PRESET]).returncode == 0
|
| 211 |
+
|
| 212 |
+
|
| 213 |
def status() -> Dict[str, object]:
|
| 214 |
"""Everything the GUI needs in one poll."""
|
| 215 |
det = detected()
|
|
|
|
| 220 |
"enabled": enabled() if det else False,
|
| 221 |
"presets": presets() if det else [],
|
| 222 |
"current": current() if det else "",
|
| 223 |
+
"freqs": [int(f) for f in GRAPHIC_FREQS],
|
| 224 |
+
"bands": graphic_bands() if det else [0.0] * len(GRAPHIC_FREQS),
|
| 225 |
}
|
reachy_web/eyes.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LED eyes: one facade, two community modules.
|
| 2 |
+
|
| 3 |
+
Two different mods give the Reachy Mini light-up eyes, and the dashboard speaks
|
| 4 |
+
to whichever is there behind the SAME /eyes/* routes:
|
| 5 |
+
|
| 6 |
+
· Reachy Eyes (github.com/brainwavecollective/reachy-eyes, no licence — our
|
| 7 |
+
own client, nothing copied): RP2350 on USB serial, VID:PID 2E8A:10FC, a
|
| 8 |
+
one-line text protocol with named colors, per-eye L:/R: prefixes, effects
|
| 9 |
+
and 0..1 state scalars. Detected by enumerating serial ports + a STATUS
|
| 10 |
+
handshake answering REACHY_MINI_EYES.
|
| 11 |
+
|
| 12 |
+
· reachy-mini-esp32-eyes (github.com/algoryn-nl/reachy-mini-esp32-eyes,
|
| 13 |
+
Apache-2.0): an ESP32 on WIFI, REST API at http://rmeyes.local — true RGB
|
| 14 |
+
per LED (0 = left, 1 = right, -1 = both), brightness 0-100, on/off. No
|
| 15 |
+
fades and no ENERGY breathing in the firmware's direct-LED path, so those
|
| 16 |
+
degrade gracefully (the twin still fades and breathes). Detected by
|
| 17 |
+
resolving the mDNS name (or REACHY_WEB_EYES_HOST) and checking that
|
| 18 |
+
GET /api/v1/led answers the module's exact LED-list shape — the system-info
|
| 19 |
+
endpoint is a stock ESP-IDF demo answer, too generic to trust.
|
| 20 |
+
|
| 21 |
+
USB wins when both are present (it is the wired truth). Detection has no
|
| 22 |
+
on-robot gate — the module hangs off whatever box runs this server (or its
|
| 23 |
+
LAN, for the ESP), and the handshakes make false positives impossible. Nothing
|
| 24 |
+
here throws: no hardware, `status()` says `detected: false` with a `why`.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
import logging
|
| 28 |
+
import os
|
| 29 |
+
import threading
|
| 30 |
+
import time
|
| 31 |
+
from typing import Dict, Optional, Tuple
|
| 32 |
+
|
| 33 |
+
import httpx
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger("reachy_web")
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
from serial import Serial
|
| 39 |
+
from serial.tools import list_ports
|
| 40 |
+
except ImportError: # pyserial missing: report it, never crash the app
|
| 41 |
+
Serial = None
|
| 42 |
+
|
| 43 |
+
COLORS = {"WHITE", "RED", "GREEN", "BLUE", "AMBER", "CYAN", "MAGENTA", "OFF"}
|
| 44 |
+
EFFECTS = {"BLINK", "ACK", "STARTLE"}
|
| 45 |
+
EYES = {"L": "L", "R": "R", "LEFT": "L", "RIGHT": "R"}
|
| 46 |
+
|
| 47 |
+
# the palette as RGB, for the ESP module (the same hexes the GUI swatches show)
|
| 48 |
+
RGB: Dict[str, Tuple[int, int, int]] = {
|
| 49 |
+
"WHITE": (245, 245, 240), "RED": (229, 35, 42), "AMBER": (255, 179, 0),
|
| 50 |
+
"GREEN": (31, 191, 58), "CYAN": (0, 200, 215), "BLUE": (30, 91, 255),
|
| 51 |
+
"MAGENTA": (224, 33, 158),
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ===== driver 1: USB serial (Brainwave Reachy Eyes) ================================
|
| 56 |
+
|
| 57 |
+
VID, PID = 0x2E8A, 0x10FC
|
| 58 |
+
DEVICE_ID = "REACHY_MINI_EYES"
|
| 59 |
+
BAUD = 115200
|
| 60 |
+
|
| 61 |
+
# One talker at a time: every command is a line on one shared port.
|
| 62 |
+
_lock = threading.Lock()
|
| 63 |
+
_link: Optional["Serial"] = None
|
| 64 |
+
_port = ""
|
| 65 |
+
_ident = ""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _handshake(link: "Serial") -> str:
|
| 69 |
+
"""Ask STATUS and return the identity line, or '' when nobody answers."""
|
| 70 |
+
link.reset_input_buffer()
|
| 71 |
+
time.sleep(0.2) # let queued async "OK"s land, then discard them
|
| 72 |
+
link.reset_input_buffer()
|
| 73 |
+
link.write(b"STATUS\n")
|
| 74 |
+
time.sleep(0.1)
|
| 75 |
+
answer = link.readline().decode(errors="ignore").strip()
|
| 76 |
+
return answer if DEVICE_ID in answer else ""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _drop() -> None:
|
| 80 |
+
global _link, _port, _ident
|
| 81 |
+
if _link is not None:
|
| 82 |
+
try:
|
| 83 |
+
_link.close()
|
| 84 |
+
except Exception:
|
| 85 |
+
pass
|
| 86 |
+
_link, _port, _ident = None, "", ""
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _discover() -> None:
|
| 90 |
+
"""Find the module among the serial ports and keep the connection open.
|
| 91 |
+
|
| 92 |
+
`exclusive` matters: it turns "someone else already drives the eyes" into a
|
| 93 |
+
clean open failure instead of two writers interleaving bytes on one port.
|
| 94 |
+
"""
|
| 95 |
+
global _link, _port, _ident
|
| 96 |
+
if Serial is None:
|
| 97 |
+
return
|
| 98 |
+
for info in list_ports.comports():
|
| 99 |
+
if info.vid != VID or info.pid != PID:
|
| 100 |
+
continue
|
| 101 |
+
try:
|
| 102 |
+
link = Serial(info.device, BAUD, timeout=0.3, exclusive=True)
|
| 103 |
+
time.sleep(0.15) # settle after open, as the vendor lib does
|
| 104 |
+
ident = _handshake(link)
|
| 105 |
+
if ident:
|
| 106 |
+
_link, _port, _ident = link, info.device, ident
|
| 107 |
+
# full brightness by default (Pierre's ask) — the GUI slider
|
| 108 |
+
# starts at 1.0 too, so the two agree from the first glance
|
| 109 |
+
link.write(b"INTENSITY 1.0\n")
|
| 110 |
+
logger.info("👀 Reachy Eyes connected: %s (%s)", ident, info.device)
|
| 111 |
+
return
|
| 112 |
+
link.close()
|
| 113 |
+
except Exception:
|
| 114 |
+
continue
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _serial_status() -> Optional[Dict[str, object]]:
|
| 118 |
+
"""A held link is verified for real (a stale handle after an unplug must
|
| 119 |
+
not report a device that is gone); with none, re-hunt."""
|
| 120 |
+
if Serial is None:
|
| 121 |
+
return None
|
| 122 |
+
with _lock:
|
| 123 |
+
if _link is not None:
|
| 124 |
+
try:
|
| 125 |
+
if _handshake(_link):
|
| 126 |
+
return {"detected": True, "kind": "serial", "port": _port,
|
| 127 |
+
"ident": _ident, "why": ""}
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
+
_drop() # went silent or unplugged: forget it, re-hunt
|
| 131 |
+
_discover()
|
| 132 |
+
if _link is not None:
|
| 133 |
+
return {"detected": True, "kind": "serial", "port": _port,
|
| 134 |
+
"ident": _ident, "why": ""}
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _serial_send(cmd: str) -> bool:
|
| 139 |
+
"""One command line out. A failed write means the device left: drop the
|
| 140 |
+
link so the next status() probes from scratch."""
|
| 141 |
+
with _lock:
|
| 142 |
+
if _link is None:
|
| 143 |
+
return False
|
| 144 |
+
try:
|
| 145 |
+
_link.write((cmd + "\n").encode())
|
| 146 |
+
return True
|
| 147 |
+
except Exception as exc:
|
| 148 |
+
logger.info("👀 Reachy Eyes lost (%s)", exc)
|
| 149 |
+
_drop()
|
| 150 |
+
return False
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ===== driver 2: WiFi REST (algoryn ESP32 eyes) ====================================
|
| 154 |
+
|
| 155 |
+
ESP_LED = {"L": 0, "R": 1}
|
| 156 |
+
ESP_RETRY_S = 15.0 # negative cache: no re-probe storm on every open
|
| 157 |
+
|
| 158 |
+
_esp_lock = threading.Lock()
|
| 159 |
+
_esp = {
|
| 160 |
+
"url": None, # base URL once detected
|
| 161 |
+
"last_fail": 0.0, # monotonic stamp of the last failed probe
|
| 162 |
+
"brightness": 100, # the module wants 0-100 per write, so we keep it
|
| 163 |
+
# what each eye currently shows — needed because every POST re-states
|
| 164 |
+
# color AND brightness, so a brightness change must replay the colors
|
| 165 |
+
"eyes": {"L": {"on": True, "rgb": RGB["WHITE"]},
|
| 166 |
+
"R": {"on": True, "rgb": RGB["WHITE"]}},
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _esp_candidates() -> list:
|
| 171 |
+
"""Where an ESP module might answer: the pinned env host first, then the
|
| 172 |
+
firmware's default mDNS name."""
|
| 173 |
+
urls = []
|
| 174 |
+
pin = os.environ.get("REACHY_WEB_EYES_HOST", "").strip()
|
| 175 |
+
if pin:
|
| 176 |
+
urls.append(pin if pin.startswith("http") else f"http://{pin}")
|
| 177 |
+
urls.append("http://rmeyes.local")
|
| 178 |
+
return urls
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _esp_check(url: str) -> bool:
|
| 182 |
+
"""GET /api/v1/led must answer the module's exact LED-list shape. The
|
| 183 |
+
system-info route is a stock ESP-IDF example answer — not an identity."""
|
| 184 |
+
r = httpx.get(f"{url}/api/v1/led", timeout=1.5)
|
| 185 |
+
r.raise_for_status()
|
| 186 |
+
leds = r.json()
|
| 187 |
+
return (isinstance(leds, list) and len(leds) >= 2
|
| 188 |
+
and all(isinstance(l, dict) and "led" in l and "brightness" in l for l in leds))
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _esp_status() -> Optional[Dict[str, object]]:
|
| 192 |
+
with _esp_lock:
|
| 193 |
+
if _esp["url"]:
|
| 194 |
+
try:
|
| 195 |
+
if _esp_check(_esp["url"]):
|
| 196 |
+
return {"detected": True, "kind": "esp32", "port": _esp["url"],
|
| 197 |
+
"ident": "reachy-mini-esp32-eyes", "why": ""}
|
| 198 |
+
except Exception:
|
| 199 |
+
pass
|
| 200 |
+
_esp["url"] = None # answered once, silent now: re-hunt below
|
| 201 |
+
if time.monotonic() - _esp["last_fail"] < ESP_RETRY_S:
|
| 202 |
+
return None # it just failed; a .local timeout per open would hurt
|
| 203 |
+
for url in _esp_candidates():
|
| 204 |
+
try:
|
| 205 |
+
if _esp_check(url):
|
| 206 |
+
_esp["url"] = url
|
| 207 |
+
logger.info("👀 ESP32 eyes connected: %s", url)
|
| 208 |
+
return {"detected": True, "kind": "esp32", "port": url,
|
| 209 |
+
"ident": "reachy-mini-esp32-eyes", "why": ""}
|
| 210 |
+
except Exception:
|
| 211 |
+
continue
|
| 212 |
+
_esp["last_fail"] = time.monotonic()
|
| 213 |
+
return None
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _esp_post_led(side: str, on: bool, rgb: Tuple[int, int, int], brightness: int) -> bool:
|
| 217 |
+
url = _esp["url"]
|
| 218 |
+
if not url:
|
| 219 |
+
return False
|
| 220 |
+
try:
|
| 221 |
+
r = httpx.post(f"{url}/api/v1/led", timeout=1.5, json={
|
| 222 |
+
"led": ESP_LED[side], "on": bool(on),
|
| 223 |
+
"red": rgb[0], "green": rgb[1], "blue": rgb[2],
|
| 224 |
+
"brightness": int(brightness),
|
| 225 |
+
})
|
| 226 |
+
r.raise_for_status()
|
| 227 |
+
return True
|
| 228 |
+
except Exception as exc:
|
| 229 |
+
logger.info("👀 ESP32 eyes lost (%s)", exc)
|
| 230 |
+
with _esp_lock:
|
| 231 |
+
_esp["url"] = None
|
| 232 |
+
_esp["last_fail"] = time.monotonic()
|
| 233 |
+
return False
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _esp_apply(sides: list) -> bool:
|
| 237 |
+
"""Replay the tracked state of the given eyes onto the module."""
|
| 238 |
+
ok = True
|
| 239 |
+
for s in sides:
|
| 240 |
+
st = _esp["eyes"][s]
|
| 241 |
+
ok = _esp_post_led(s, st["on"], st["rgb"], _esp["brightness"]) and ok
|
| 242 |
+
return ok
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _esp_color(name: str, sides: list) -> bool:
|
| 246 |
+
for s in sides:
|
| 247 |
+
if name == "OFF":
|
| 248 |
+
_esp["eyes"][s]["on"] = False
|
| 249 |
+
else:
|
| 250 |
+
_esp["eyes"][s] = {"on": True, "rgb": RGB[name]}
|
| 251 |
+
return _esp_apply(sides)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _esp_intensity(level: float) -> bool:
|
| 255 |
+
_esp["brightness"] = round(level * 100)
|
| 256 |
+
return _esp_apply(["L", "R"])
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _esp_effect(name: str, sides: list) -> bool:
|
| 260 |
+
"""The firmware's direct-LED path has no one-shot effects, so we shape them
|
| 261 |
+
here: BLINK goes dark and back, ACK dips politely, STARTLE overshoots.
|
| 262 |
+
Runs in the worker thread main.py already puts us on — sleeping is fine."""
|
| 263 |
+
if name == "BLINK":
|
| 264 |
+
for s in sides:
|
| 265 |
+
_esp_post_led(s, False, _esp["eyes"][s]["rgb"], _esp["brightness"])
|
| 266 |
+
time.sleep(0.12)
|
| 267 |
+
elif name == "ACK":
|
| 268 |
+
for s in sides:
|
| 269 |
+
_esp_post_led(s, True, _esp["eyes"][s]["rgb"], max(5, _esp["brightness"] // 2))
|
| 270 |
+
time.sleep(0.09)
|
| 271 |
+
else: # STARTLE
|
| 272 |
+
for s in sides:
|
| 273 |
+
_esp_post_led(s, True, _esp["eyes"][s]["rgb"], 100)
|
| 274 |
+
time.sleep(0.13)
|
| 275 |
+
return _esp_apply(sides)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ===== the facade main.py talks to =================================================
|
| 279 |
+
|
| 280 |
+
def _sides(eye: object) -> Optional[list]:
|
| 281 |
+
if not eye:
|
| 282 |
+
return ["L", "R"]
|
| 283 |
+
side = EYES.get(str(eye).upper())
|
| 284 |
+
return [side] if side else None
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def status() -> Dict[str, object]:
|
| 288 |
+
"""Everything the GUI needs in one poll. USB first — a wired module beats a
|
| 289 |
+
WiFi one when both are around."""
|
| 290 |
+
st = _serial_status()
|
| 291 |
+
if st:
|
| 292 |
+
return st
|
| 293 |
+
st = _esp_status()
|
| 294 |
+
if st:
|
| 295 |
+
return st
|
| 296 |
+
why = []
|
| 297 |
+
if Serial is None:
|
| 298 |
+
why.append("pyserial is not installed")
|
| 299 |
+
else:
|
| 300 |
+
why.append(f"no USB device {VID:04X}:{PID:04X} answering STATUS")
|
| 301 |
+
why.append("no ESP32 eyes at " + " / ".join(_esp_candidates()))
|
| 302 |
+
return {"detected": False, "kind": None, "port": "", "ident": "",
|
| 303 |
+
"why": "; ".join(why)}
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def set_color(color: object, eye: object = None, duration: object = None) -> bool:
|
| 307 |
+
"""A named color, whole face or one eye, with an optional fade (seconds).
|
| 308 |
+
The fade is serial-only — the ESP firmware snaps, and the twin fades for it."""
|
| 309 |
+
name = str(color or "").upper()
|
| 310 |
+
if name not in COLORS:
|
| 311 |
+
return False
|
| 312 |
+
sides = _sides(eye)
|
| 313 |
+
if sides is None:
|
| 314 |
+
return False
|
| 315 |
+
if _link is not None:
|
| 316 |
+
cmd = name
|
| 317 |
+
if duration is not None:
|
| 318 |
+
try:
|
| 319 |
+
cmd += f" {max(0.0, float(duration))}"
|
| 320 |
+
except (TypeError, ValueError):
|
| 321 |
+
pass
|
| 322 |
+
if eye:
|
| 323 |
+
cmd = f"{sides[0]}:{cmd}"
|
| 324 |
+
return _serial_send(cmd)
|
| 325 |
+
if _esp["url"]:
|
| 326 |
+
return _esp_color(name, sides)
|
| 327 |
+
return False
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def effect(name: object, eye: object = None) -> bool:
|
| 331 |
+
"""A firmware effect: BLINK, ACK or STARTLE, whole face or one eye."""
|
| 332 |
+
cmd = str(name or "").upper()
|
| 333 |
+
if cmd not in EFFECTS:
|
| 334 |
+
return False
|
| 335 |
+
sides = _sides(eye)
|
| 336 |
+
if sides is None:
|
| 337 |
+
return False
|
| 338 |
+
if _link is not None:
|
| 339 |
+
return _serial_send(f"{sides[0]}:{cmd}" if eye else cmd)
|
| 340 |
+
if _esp["url"]:
|
| 341 |
+
return _esp_effect(cmd, sides)
|
| 342 |
+
return False
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def set_state(intensity: object = None, energy: object = None) -> bool:
|
| 346 |
+
"""The runtime scalars, clamped to 0..1. ENERGY (breathing) only exists in
|
| 347 |
+
the serial firmware; on the ESP it is a graceful no-op (the twin breathes)."""
|
| 348 |
+
ok = True
|
| 349 |
+
for key, value in (("INTENSITY", intensity), ("ENERGY", energy)):
|
| 350 |
+
if value is None:
|
| 351 |
+
continue
|
| 352 |
+
try:
|
| 353 |
+
level = max(0.0, min(1.0, float(value)))
|
| 354 |
+
except (TypeError, ValueError):
|
| 355 |
+
ok = False
|
| 356 |
+
continue
|
| 357 |
+
if _link is not None:
|
| 358 |
+
ok = _serial_send(f"{key} {level}") and ok
|
| 359 |
+
elif _esp["url"]:
|
| 360 |
+
if key == "INTENSITY":
|
| 361 |
+
ok = _esp_intensity(level) and ok
|
| 362 |
+
# ENERGY: nothing to send — deliberately not a failure
|
| 363 |
+
else:
|
| 364 |
+
ok = False
|
| 365 |
+
return ok
|
reachy_web/log_i18n.py
CHANGED
|
@@ -224,14 +224,27 @@ MESSAGES: Dict[str, Dict[str, str]] = {
|
|
| 224 |
"ja": "アプリケーションの起動が完了しました",
|
| 225 |
"ar": "اكتمل بدء التطبيق",
|
| 226 |
},
|
| 227 |
-
"Uvicorn running on
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
"
|
| 233 |
-
"
|
| 234 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
},
|
| 236 |
"Shutting down": {
|
| 237 |
"fr": "arrêt en cours",
|
|
@@ -322,7 +335,7 @@ _lang = os.environ.get("REACHY_WEB_LANG", _lang) if os.environ.get(
|
|
| 322 |
"REACHY_WEB_LANG", ""
|
| 323 |
) in LANGS else _lang
|
| 324 |
|
| 325 |
-
# --- the launcher (
|
| 326 |
# Bash cannot read the table above, so run.sh asks us for it: one call, and it
|
| 327 |
# evals the result as shell variables. Keys stay stable; the emojis live here.
|
| 328 |
LAUNCHER = {
|
|
@@ -416,6 +429,16 @@ LAUNCHER = {
|
|
| 416 |
"ja": "⚠️ ポートが他のプログラムに使用されています(デスクトップアプリが保持)",
|
| 417 |
"ar": "⚠️ المنفذ مشغول ببرنامج آخر (يحجزه تطبيق سطح المكتب)",
|
| 418 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
"daemon_target": {
|
| 420 |
"en": "🔌 Daemon target:",
|
| 421 |
"fr": "🔌 Daemon ciblé :",
|
|
|
|
| 224 |
"ja": "アプリケーションの起動が完了しました",
|
| 225 |
"ar": "اكتمل بدء التطبيق",
|
| 226 |
},
|
| 227 |
+
# main.py rewrites uvicorn's "Uvicorn running on …" record into one of these
|
| 228 |
+
# two, with the LAN IP + mDNS URLs as the single argument. The Ctrl+C variant
|
| 229 |
+
# is used only at a real terminal (a tty); installed via the store, the
|
| 230 |
+
# app-manager owns our life and the hint would be a lie.
|
| 231 |
+
"listening on %s (Ctrl+C to quit)": {
|
| 232 |
+
"fr": "à l'écoute sur %s (Ctrl+C pour quitter)",
|
| 233 |
+
"es": "escuchando en %s (Ctrl+C para salir)",
|
| 234 |
+
"de": "lauscht auf %s (Strg+C zum Beenden)",
|
| 235 |
+
"it": "in ascolto su %s (Ctrl+C per uscire)",
|
| 236 |
+
"pt": "à escuta em %s (Ctrl+C para sair)",
|
| 237 |
+
"ja": "%s で待機中 (Ctrl+C で終了)",
|
| 238 |
+
"ar": "يستمع على %s (اضغط Ctrl+C للخروج)",
|
| 239 |
+
},
|
| 240 |
+
"listening on %s": {
|
| 241 |
+
"fr": "à l'écoute sur %s",
|
| 242 |
+
"es": "escuchando en %s",
|
| 243 |
+
"de": "lauscht auf %s",
|
| 244 |
+
"it": "in ascolto su %s",
|
| 245 |
+
"pt": "à escuta em %s",
|
| 246 |
+
"ja": "%s で待機中",
|
| 247 |
+
"ar": "يستمع على %s",
|
| 248 |
},
|
| 249 |
"Shutting down": {
|
| 250 |
"fr": "arrêt en cours",
|
|
|
|
| 335 |
"REACHY_WEB_LANG", ""
|
| 336 |
) in LANGS else _lang
|
| 337 |
|
| 338 |
+
# --- the launcher (run.sh) --------------------------------------------
|
| 339 |
# Bash cannot read the table above, so run.sh asks us for it: one call, and it
|
| 340 |
# evals the result as shell variables. Keys stay stable; the emojis live here.
|
| 341 |
LAUNCHER = {
|
|
|
|
| 429 |
"ja": "⚠️ ポートが他のプログラムに使用されています(デスクトップアプリが保持)",
|
| 430 |
"ar": "⚠️ المنفذ مشغول ببرنامج آخر (يحجزه تطبيق سطح المكتب)",
|
| 431 |
},
|
| 432 |
+
"on_robot": {
|
| 433 |
+
"en": "🤖 On the robot",
|
| 434 |
+
"fr": "🤖 Sur le robot",
|
| 435 |
+
"es": "🤖 En el robot",
|
| 436 |
+
"de": "🤖 Auf dem Roboter",
|
| 437 |
+
"it": "🤖 Sul robot",
|
| 438 |
+
"pt": "🤖 No robô",
|
| 439 |
+
"ja": "🤖 ロボット上で実行中",
|
| 440 |
+
"ar": "🤖 على الروبوت",
|
| 441 |
+
},
|
| 442 |
"daemon_target": {
|
| 443 |
"en": "🔌 Daemon target:",
|
| 444 |
"fr": "🔌 Daemon ciblé :",
|
reachy_web/main.py
CHANGED
|
@@ -12,6 +12,7 @@ import json
|
|
| 12 |
import logging
|
| 13 |
import os
|
| 14 |
import signal
|
|
|
|
| 15 |
import sys
|
| 16 |
import threading
|
| 17 |
import time
|
|
@@ -34,6 +35,7 @@ from . import log_i18n
|
|
| 34 |
|
| 35 |
from . import audio
|
| 36 |
from . import audio_eq
|
|
|
|
| 37 |
from .camera import MEDIA_TYPE, CameraStream
|
| 38 |
from .control import look_at_pose, pose_to_scalars
|
| 39 |
from .moves import names as move_names_data
|
|
@@ -56,6 +58,20 @@ def _c(code: str, text: str) -> str:
|
|
| 56 |
return f"\033[{code}m{text}\033[0m" if _COLOR else text
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
# Routine chatter: polls the GUI runs on its own, and per-item assets. A page
|
| 60 |
# load fires dozens of these, and drowning the log in ✓ lines is how a real
|
| 61 |
# error goes unseen. Only their SUCCESSES are hidden — a failing poll still
|
|
@@ -76,6 +92,7 @@ QUIET_OK = (
|
|
| 76 |
"/system/info",
|
| 77 |
"/look-at", # up to 8 per second while aiming
|
| 78 |
"/moves/ik", # the simulator's IK solve, once per frame (~20/s)
|
|
|
|
| 79 |
"/static/",
|
| 80 |
)
|
| 81 |
|
|
@@ -104,6 +121,31 @@ class _Pretty(logging.Formatter):
|
|
| 104 |
f"{_c(colour, str(status))}"
|
| 105 |
)
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
body = log_i18n.translate(record)
|
| 108 |
if record.levelno >= logging.WARNING:
|
| 109 |
body = _c(self.LEVEL_COLOR[record.levelname], body)
|
|
@@ -295,6 +337,18 @@ def daemon_name() -> str:
|
|
| 295 |
return DAEMON["host"].rsplit(":", 1)[0].rstrip(".")
|
| 296 |
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
def signalling_ws() -> str:
|
| 299 |
"""The robot's WebRTC signalling server (webrtcsink, run-signalling-server)."""
|
| 300 |
return f"ws://{daemon_name()}:8443"
|
|
@@ -522,7 +576,14 @@ def _range_response(path: Any, range_header: Any) -> Response:
|
|
| 522 |
|
| 523 |
|
| 524 |
class StaticGZip:
|
| 525 |
-
"""gzip the static bundle, and only that (streams must pass through raw).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
|
| 527 |
def __init__(self, app: Any) -> None:
|
| 528 |
self.app = app
|
|
@@ -530,7 +591,15 @@ class StaticGZip:
|
|
| 530 |
|
| 531 |
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
| 532 |
if scope["type"] == "http" and scope["path"].startswith("/static/"):
|
| 533 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
else:
|
| 535 |
await self.app(scope, receive, send)
|
| 536 |
|
|
@@ -672,17 +741,7 @@ class ReachyWeb(ReachyMiniApp):
|
|
| 672 |
# --- host identity for the topbar (hostname + LAN IP + the ROBOT's address)
|
| 673 |
@app.get("/system/info")
|
| 674 |
async def system_info() -> dict:
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
ip = None
|
| 678 |
-
try:
|
| 679 |
-
# a UDP "connection" never sends a packet; it just picks the
|
| 680 |
-
# interface that would route out, i.e. the LAN address
|
| 681 |
-
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
| 682 |
-
s.connect(("8.8.8.8", 80))
|
| 683 |
-
ip = s.getsockname()[0]
|
| 684 |
-
except OSError:
|
| 685 |
-
pass
|
| 686 |
|
| 687 |
# Where the robot actually lives. The topbar used to show only the
|
| 688 |
# machine serving the page — which, run from a laptop, is the laptop:
|
|
@@ -888,10 +947,9 @@ class ReachyWeb(ReachyMiniApp):
|
|
| 888 |
async def eq_status() -> dict:
|
| 889 |
# The EQ belongs to the ROBOT only. Detection runs server-side, so a
|
| 890 |
# dashboard run from a laptop would otherwise report the LAPTOP's
|
| 891 |
-
# EasyEffects. Gate on
|
| 892 |
-
#
|
| 893 |
-
|
| 894 |
-
if daemon_name() not in ("127.0.0.1", "localhost", "::1"):
|
| 895 |
return {"detected": False, "running": False, "on_robot": False,
|
| 896 |
"enabled": False, "presets": [], "current": ""}
|
| 897 |
st = await asyncio.to_thread(audio_eq.status)
|
|
@@ -925,6 +983,53 @@ class ReachyWeb(ReachyMiniApp):
|
|
| 925 |
)
|
| 926 |
return {"ok": ok}
|
| 927 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 928 |
@app.get("/camera/status")
|
| 929 |
async def camera_status() -> dict:
|
| 930 |
return self._camera.status()
|
|
@@ -999,6 +1104,21 @@ class ReachyWeb(ReachyMiniApp):
|
|
| 999 |
@asynccontextmanager
|
| 1000 |
async def lifespan(app_: Any) -> Any:
|
| 1001 |
async with inner_lifespan(app_):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1002 |
# keep looking for the robot for as long as we run
|
| 1003 |
hunter = asyncio.create_task(_discover_forever())
|
| 1004 |
# a hunter that dies quietly leaves the robot undiscoverable for
|
|
@@ -1226,7 +1346,7 @@ class ReachyWeb(ReachyMiniApp):
|
|
| 1226 |
assert url.hostname is not None and url.port is not None
|
| 1227 |
# overridable for local dev: the desktop app holds 127.0.0.1:8042 for the
|
| 1228 |
# apps it hosts, and on macOS both binds silently coexist — localhost then
|
| 1229 |
-
# resolves to the desktop app, not to us (
|
| 1230 |
port = int(os.environ.get("REACHY_WEB_PORT", url.port))
|
| 1231 |
|
| 1232 |
server = uvicorn.Server(
|
|
|
|
| 12 |
import logging
|
| 13 |
import os
|
| 14 |
import signal
|
| 15 |
+
import socket
|
| 16 |
import sys
|
| 17 |
import threading
|
| 18 |
import time
|
|
|
|
| 35 |
|
| 36 |
from . import audio
|
| 37 |
from . import audio_eq
|
| 38 |
+
from . import eyes
|
| 39 |
from .camera import MEDIA_TYPE, CameraStream
|
| 40 |
from .control import look_at_pose, pose_to_scalars
|
| 41 |
from .moves import names as move_names_data
|
|
|
|
| 58 |
return f"\033[{code}m{text}\033[0m" if _COLOR else text
|
| 59 |
|
| 60 |
|
| 61 |
+
def _lan_ip() -> Optional[str]:
|
| 62 |
+
"""This machine's LAN address — the one a phone on the network can reach.
|
| 63 |
+
|
| 64 |
+
A UDP "connection" never sends a packet; it only makes the kernel pick the
|
| 65 |
+
interface that would route out, whose address is the answer.
|
| 66 |
+
"""
|
| 67 |
+
try:
|
| 68 |
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
| 69 |
+
s.connect(("8.8.8.8", 80))
|
| 70 |
+
return s.getsockname()[0]
|
| 71 |
+
except OSError:
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
# Routine chatter: polls the GUI runs on its own, and per-item assets. A page
|
| 76 |
# load fires dozens of these, and drowning the log in ✓ lines is how a real
|
| 77 |
# error goes unseen. Only their SUCCESSES are hidden — a failing poll still
|
|
|
|
| 92 |
"/system/info",
|
| 93 |
"/look-at", # up to 8 per second while aiming
|
| 94 |
"/moves/ik", # the simulator's IK solve, once per frame (~20/s)
|
| 95 |
+
"/eyes/", # LED scenarios post colors several times a second
|
| 96 |
"/static/",
|
| 97 |
)
|
| 98 |
|
|
|
|
| 121 |
f"{_c(colour, str(status))}"
|
| 122 |
)
|
| 123 |
|
| 124 |
+
# uvicorn's startup line names the BIND address (0.0.0.0), which nobody
|
| 125 |
+
# can click. Rewrite it to the LAN IP and the mDNS name — real URLs to
|
| 126 |
+
# open from any device on the network. The Ctrl+C hint survives only
|
| 127 |
+
# when a human at a terminal can actually press it: started by the
|
| 128 |
+
# daemon's app-manager (store install), stderr is a captured pipe, not
|
| 129 |
+
# a tty, and the hint would be a lie.
|
| 130 |
+
if (
|
| 131 |
+
str(record.msg).startswith("Uvicorn running on")
|
| 132 |
+
and isinstance(record.args, tuple)
|
| 133 |
+
and len(record.args) >= 3
|
| 134 |
+
):
|
| 135 |
+
proto, host, port = str(record.args[0]), str(record.args[1]), record.args[2]
|
| 136 |
+
if host in ("0.0.0.0", "::"):
|
| 137 |
+
name = socket.gethostname()
|
| 138 |
+
name = name if name.endswith(".local") else name + ".local"
|
| 139 |
+
hosts = [h for h in (_lan_ip(), name.lower()) if h]
|
| 140 |
+
else:
|
| 141 |
+
hosts = [host]
|
| 142 |
+
urls = " · ".join(f"{proto}://{h}:{port}" for h in hosts)
|
| 143 |
+
record.msg = (
|
| 144 |
+
"listening on %s (Ctrl+C to quit)" if sys.stderr.isatty()
|
| 145 |
+
else "listening on %s"
|
| 146 |
+
)
|
| 147 |
+
record.args = (urls,)
|
| 148 |
+
|
| 149 |
body = log_i18n.translate(record)
|
| 150 |
if record.levelno >= logging.WARNING:
|
| 151 |
body = _c(self.LEVEL_COLOR[record.levelname], body)
|
|
|
|
| 337 |
return DAEMON["host"].rsplit(":", 1)[0].rstrip(".")
|
| 338 |
|
| 339 |
|
| 340 |
+
def _on_robot() -> bool:
|
| 341 |
+
"""True when THIS process runs ON the robot. run.sh computes it from a robust
|
| 342 |
+
probe (the daemon's own systemd unit, or the Pi model) and exports
|
| 343 |
+
REACHY_WEB_ON_ROBOT; we trust that first. Only fall back to "the daemon target
|
| 344 |
+
is loopback" for launches that never went through run.sh. The old EQ gate used
|
| 345 |
+
the loopback test ALONE, which fails on a robot whose daemon was cached under
|
| 346 |
+
its mDNS name or LAN IP (the EQ then wrongly reported on_robot:false)."""
|
| 347 |
+
if os.environ.get("REACHY_WEB_ON_ROBOT") == "1":
|
| 348 |
+
return True
|
| 349 |
+
return daemon_name() in ("127.0.0.1", "localhost", "::1")
|
| 350 |
+
|
| 351 |
+
|
| 352 |
def signalling_ws() -> str:
|
| 353 |
"""The robot's WebRTC signalling server (webrtcsink, run-signalling-server)."""
|
| 354 |
return f"ws://{daemon_name()}:8443"
|
|
|
|
| 576 |
|
| 577 |
|
| 578 |
class StaticGZip:
|
| 579 |
+
"""gzip the static bundle, and only that (streams must pass through raw).
|
| 580 |
+
|
| 581 |
+
Also stamps ``Cache-Control: no-cache`` on /static: Chrome does NOT revalidate
|
| 582 |
+
subresources on a normal reload, so an edited skins.js kept being served from
|
| 583 |
+
the browser cache until a hard refresh — every "it's still broken" was often
|
| 584 |
+
just this. no-cache means "revalidate every time": with ETags the answer is a
|
| 585 |
+
304 on the LAN, so it costs almost nothing and the app is never stale again.
|
| 586 |
+
"""
|
| 587 |
|
| 588 |
def __init__(self, app: Any) -> None:
|
| 589 |
self.app = app
|
|
|
|
| 591 |
|
| 592 |
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
| 593 |
if scope["type"] == "http" and scope["path"].startswith("/static/"):
|
| 594 |
+
async def stamped(message: Any) -> None:
|
| 595 |
+
if message["type"] == "http.response.start":
|
| 596 |
+
headers = [(k, v) for k, v in message.get("headers", [])
|
| 597 |
+
if k.lower() != b"cache-control"]
|
| 598 |
+
headers.append((b"cache-control", b"no-cache"))
|
| 599 |
+
message = {**message, "headers": headers}
|
| 600 |
+
await send(message)
|
| 601 |
+
|
| 602 |
+
await self.gzip(scope, receive, stamped)
|
| 603 |
else:
|
| 604 |
await self.app(scope, receive, send)
|
| 605 |
|
|
|
|
| 741 |
# --- host identity for the topbar (hostname + LAN IP + the ROBOT's address)
|
| 742 |
@app.get("/system/info")
|
| 743 |
async def system_info() -> dict:
|
| 744 |
+
ip = _lan_ip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 745 |
|
| 746 |
# Where the robot actually lives. The topbar used to show only the
|
| 747 |
# machine serving the page — which, run from a laptop, is the laptop:
|
|
|
|
| 947 |
async def eq_status() -> dict:
|
| 948 |
# The EQ belongs to the ROBOT only. Detection runs server-side, so a
|
| 949 |
# dashboard run from a laptop would otherwise report the LAPTOP's
|
| 950 |
+
# EasyEffects. Gate on running ON the robot (REACHY_WEB_ON_ROBOT, with a
|
| 951 |
+
# loopback fallback). Not on the robot: no EQ, full stop.
|
| 952 |
+
if not _on_robot():
|
|
|
|
| 953 |
return {"detected": False, "running": False, "on_robot": False,
|
| 954 |
"enabled": False, "presets": [], "current": ""}
|
| 955 |
st = await asyncio.to_thread(audio_eq.status)
|
|
|
|
| 983 |
)
|
| 984 |
return {"ok": ok}
|
| 985 |
|
| 986 |
+
@app.post("/audio/eq/bands")
|
| 987 |
+
async def eq_bands(payload: dict) -> dict:
|
| 988 |
+
"""The real graphic EQ: one gain (dB) per fixed band."""
|
| 989 |
+
def _clamp(v: object) -> float:
|
| 990 |
+
try:
|
| 991 |
+
return max(-12.0, min(12.0, float(v)))
|
| 992 |
+
except (TypeError, ValueError):
|
| 993 |
+
return 0.0
|
| 994 |
+
gains = [_clamp(v) for v in (payload.get("gains") or [])][: len(audio_eq.GRAPHIC_FREQS)]
|
| 995 |
+
ok = await asyncio.to_thread(audio_eq.set_bands, gains)
|
| 996 |
+
return {"ok": ok, "bands": await asyncio.to_thread(audio_eq.graphic_bands)}
|
| 997 |
+
|
| 998 |
+
# --- LED eyes: the Reachy Eyes community module (see eyes.py), when it
|
| 999 |
+
# is plugged into THIS box. Serial talks go through a thread; no on-robot
|
| 1000 |
+
# gate — a module on the laptop (bench test) is as real as one in the head.
|
| 1001 |
+
@app.get("/eyes/status")
|
| 1002 |
+
async def eyes_status() -> dict:
|
| 1003 |
+
return await asyncio.to_thread(eyes.status)
|
| 1004 |
+
|
| 1005 |
+
@app.post("/eyes/color")
|
| 1006 |
+
async def eyes_color(payload: dict) -> dict:
|
| 1007 |
+
ok = await asyncio.to_thread(
|
| 1008 |
+
eyes.set_color,
|
| 1009 |
+
payload.get("color"),
|
| 1010 |
+
payload.get("eye"),
|
| 1011 |
+
payload.get("duration"),
|
| 1012 |
+
)
|
| 1013 |
+
return {"ok": ok}
|
| 1014 |
+
|
| 1015 |
+
@app.post("/eyes/state")
|
| 1016 |
+
async def eyes_state(payload: dict) -> dict:
|
| 1017 |
+
ok = await asyncio.to_thread(
|
| 1018 |
+
eyes.set_state,
|
| 1019 |
+
payload.get("intensity"),
|
| 1020 |
+
payload.get("energy"),
|
| 1021 |
+
)
|
| 1022 |
+
return {"ok": ok}
|
| 1023 |
+
|
| 1024 |
+
@app.post("/eyes/effect")
|
| 1025 |
+
async def eyes_effect(payload: dict) -> dict:
|
| 1026 |
+
ok = await asyncio.to_thread(
|
| 1027 |
+
eyes.effect,
|
| 1028 |
+
payload.get("effect"),
|
| 1029 |
+
payload.get("eye"),
|
| 1030 |
+
)
|
| 1031 |
+
return {"ok": ok}
|
| 1032 |
+
|
| 1033 |
@app.get("/camera/status")
|
| 1034 |
async def camera_status() -> dict:
|
| 1035 |
return self._camera.status()
|
|
|
|
| 1104 |
@asynccontextmanager
|
| 1105 |
async def lifespan(app_: Any) -> Any:
|
| 1106 |
async with inner_lifespan(app_):
|
| 1107 |
+
# An abandoned DNS lookup must not scream. When the daemon target
|
| 1108 |
+
# is an mDNS name that stops resolving (robot off, wifi dropped to
|
| 1109 |
+
# hotspot), a connect attempt can be cancelled or time out while
|
| 1110 |
+
# getaddrinfo is still running in its thread; the thread then
|
| 1111 |
+
# finishes with gaierror on a future nobody awaits, and asyncio
|
| 1112 |
+
# prints "Future exception was never retrieved" at GC time. It is
|
| 1113 |
+
# noise: the failed connect itself was already handled and logged.
|
| 1114 |
+
# Swallow exactly that case, hand everything else to the default.
|
| 1115 |
+
loop = asyncio.get_running_loop()
|
| 1116 |
+
def _quiet_orphan_dns(lp: Any, context: dict) -> None:
|
| 1117 |
+
if isinstance(context.get("exception"), socket.gaierror):
|
| 1118 |
+
return
|
| 1119 |
+
lp.default_exception_handler(context)
|
| 1120 |
+
loop.set_exception_handler(_quiet_orphan_dns)
|
| 1121 |
+
|
| 1122 |
# keep looking for the robot for as long as we run
|
| 1123 |
hunter = asyncio.create_task(_discover_forever())
|
| 1124 |
# a hunter that dies quietly leaves the robot undiscoverable for
|
|
|
|
| 1346 |
assert url.hostname is not None and url.port is not None
|
| 1347 |
# overridable for local dev: the desktop app holds 127.0.0.1:8042 for the
|
| 1348 |
# apps it hosts, and on macOS both binds silently coexist — localhost then
|
| 1349 |
+
# resolves to the desktop app, not to us (run.sh detects this)
|
| 1350 |
port = int(os.environ.get("REACHY_WEB_PORT", url.port))
|
| 1351 |
|
| 1352 |
server = uvicorn.Server(
|
reachy_web/static/assets/icons/antenna.svg
ADDED
|
|
reachy_web/static/assets/icons/axis-x-arrow.svg
ADDED
|
|
reachy_web/static/assets/icons/axis-x-rotate-clockwise.svg
ADDED
|
|
reachy_web/static/assets/icons/axis-y-arrow.svg
ADDED
|
|
reachy_web/static/assets/icons/axis-y-rotate-clockwise.svg
ADDED
|
|
reachy_web/static/assets/icons/axis-z-arrow.svg
ADDED
|
|
reachy_web/static/assets/icons/axis-z-rotate-clockwise.svg
ADDED
|
|
reachy_web/static/assets/icons/brightness-6.svg
ADDED
|
|
reachy_web/static/assets/icons/chevron-left.svg
ADDED
|
|
reachy_web/static/assets/icons/eye-outline.svg
ADDED
|
|
reachy_web/static/assets/icons/gesture-swipe-horizontal.svg
ADDED
|
|
reachy_web/static/assets/icons/hand-back-right.svg
ADDED
|
|
reachy_web/static/assets/icons/rotate-3d-variant.svg
ADDED
|
|
reachy_web/static/assets/icons/tray-arrow-up.svg
ADDED
|
|
reachy_web/static/assets/logo-192.png
DELETED
Git LFS Details
|
reachy_web/static/assets/logo-2048.png
DELETED
Git LFS Details
|
reachy_web/static/assets/logo-512.png
DELETED
Git LFS Details
|
reachy_web/static/assets/logo-64.png
DELETED
Git LFS Details
|
reachy_web/static/assets/logo.png
DELETED
Git LFS Details
|
reachy_web/static/assets/og.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
reachy_web/static/css/app.css
CHANGED
|
@@ -384,9 +384,6 @@
|
|
| 384 |
.h-20 {
|
| 385 |
height: calc(var(--spacing) * 20);
|
| 386 |
}
|
| 387 |
-
.h-36 {
|
| 388 |
-
height: calc(var(--spacing) * 36);
|
| 389 |
-
}
|
| 390 |
.h-40 {
|
| 391 |
height: calc(var(--spacing) * 40);
|
| 392 |
}
|
|
@@ -855,9 +852,6 @@
|
|
| 855 |
.text-left {
|
| 856 |
text-align: left;
|
| 857 |
}
|
| 858 |
-
.text-right {
|
| 859 |
-
text-align: right;
|
| 860 |
-
}
|
| 861 |
.font-mono {
|
| 862 |
font-family: var(--font-mono);
|
| 863 |
}
|
|
@@ -1704,6 +1698,14 @@
|
|
| 1704 |
.hidden {
|
| 1705 |
display: none !important;
|
| 1706 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1707 |
html {
|
| 1708 |
-webkit-font-smoothing: antialiased;
|
| 1709 |
-moz-osx-font-smoothing: grayscale;
|
|
|
|
| 384 |
.h-20 {
|
| 385 |
height: calc(var(--spacing) * 20);
|
| 386 |
}
|
|
|
|
|
|
|
|
|
|
| 387 |
.h-40 {
|
| 388 |
height: calc(var(--spacing) * 40);
|
| 389 |
}
|
|
|
|
| 852 |
.text-left {
|
| 853 |
text-align: left;
|
| 854 |
}
|
|
|
|
|
|
|
|
|
|
| 855 |
.font-mono {
|
| 856 |
font-family: var(--font-mono);
|
| 857 |
}
|
|
|
|
| 1698 |
.hidden {
|
| 1699 |
display: none !important;
|
| 1700 |
}
|
| 1701 |
+
@media (min-width: 40rem) {
|
| 1702 |
+
.sm\:flex.hidden {
|
| 1703 |
+
display: flex !important;
|
| 1704 |
+
}
|
| 1705 |
+
.sm\:grid.hidden {
|
| 1706 |
+
display: grid !important;
|
| 1707 |
+
}
|
| 1708 |
+
}
|
| 1709 |
html {
|
| 1710 |
-webkit-font-smoothing: antialiased;
|
| 1711 |
-moz-osx-font-smoothing: grayscale;
|
reachy_web/static/css/audio_eq.css
CHANGED
|
@@ -11,3 +11,58 @@
|
|
| 11 |
color: var(--txt);
|
| 12 |
font: inherit;
|
| 13 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
color: var(--txt);
|
| 12 |
font: inherit;
|
| 13 |
}
|
| 14 |
+
|
| 15 |
+
/* the real graphic EQ: a row of vertical band sliders, like the EasyEffects one */
|
| 16 |
+
.eq-graphic-head {
|
| 17 |
+
display: flex;
|
| 18 |
+
align-items: center;
|
| 19 |
+
justify-content: space-between;
|
| 20 |
+
}
|
| 21 |
+
.eq-flat {
|
| 22 |
+
border: 1px solid var(--line);
|
| 23 |
+
background: var(--soft);
|
| 24 |
+
color: var(--mut);
|
| 25 |
+
font: 600 0.72rem/1 inherit;
|
| 26 |
+
text-transform: none;
|
| 27 |
+
letter-spacing: 0;
|
| 28 |
+
padding: 4px 10px;
|
| 29 |
+
border-radius: 999px;
|
| 30 |
+
cursor: pointer;
|
| 31 |
+
}
|
| 32 |
+
.eq-flat:hover { border-color: var(--acc); color: var(--acc); }
|
| 33 |
+
|
| 34 |
+
.eq-bands {
|
| 35 |
+
display: flex;
|
| 36 |
+
align-items: stretch;
|
| 37 |
+
gap: 4px;
|
| 38 |
+
padding: 6px 2px 2px;
|
| 39 |
+
overflow-x: auto;
|
| 40 |
+
}
|
| 41 |
+
.eq-band {
|
| 42 |
+
flex: 1 1 0;
|
| 43 |
+
min-width: 26px;
|
| 44 |
+
display: flex;
|
| 45 |
+
flex-direction: column;
|
| 46 |
+
align-items: center;
|
| 47 |
+
gap: 6px;
|
| 48 |
+
}
|
| 49 |
+
.eq-band-val {
|
| 50 |
+
font: 600 0.7rem/1 ui-monospace, "SF Mono", Menlo, monospace;
|
| 51 |
+
color: var(--mut);
|
| 52 |
+
font-variant-numeric: tabular-nums;
|
| 53 |
+
}
|
| 54 |
+
.eq-band-slider {
|
| 55 |
+
appearance: slider-vertical; /* older Chromium / WebKit */
|
| 56 |
+
-webkit-appearance: slider-vertical;
|
| 57 |
+
writing-mode: vertical-lr; /* Chrome 121+, Firefox */
|
| 58 |
+
direction: rtl; /* up = higher gain */
|
| 59 |
+
width: 18px;
|
| 60 |
+
height: 116px;
|
| 61 |
+
accent-color: var(--acc);
|
| 62 |
+
cursor: ns-resize;
|
| 63 |
+
}
|
| 64 |
+
.eq-band-hz {
|
| 65 |
+
font-size: 0.66rem;
|
| 66 |
+
color: var(--mut);
|
| 67 |
+
font-variant-numeric: tabular-nums;
|
| 68 |
+
}
|
reachy_web/static/css/icons.css
CHANGED
|
@@ -26,6 +26,19 @@
|
|
| 26 |
.i-flash { --i: url(../assets/icons/flash.svg); }
|
| 27 |
.i-fullscreen { --i: url(../assets/icons/fullscreen.svg); }
|
| 28 |
.i-hand-wave { --i: url(../assets/icons/hand-wave.svg); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
.i-login-variant { --i: url(../assets/icons/login-variant.svg); }
|
| 30 |
.i-home { --i: url(../assets/icons/home.svg); }
|
| 31 |
.i-heart { --i: url(../assets/icons/heart.svg); }
|
|
@@ -36,6 +49,7 @@
|
|
| 36 |
.i-music-note { --i: url(../assets/icons/music-note.svg); }
|
| 37 |
.i-open-in-new { --i: url(../assets/icons/open-in-new.svg); }
|
| 38 |
.i-package-variant-closed { --i: url(../assets/icons/package-variant-closed.svg); }
|
|
|
|
| 39 |
.i-motion-play-outline { --i: url(../assets/icons/motion-play-outline.svg); }
|
| 40 |
.i-palette { --i: url(../assets/icons/palette.svg); }
|
| 41 |
.i-pause { --i: url(../assets/icons/pause.svg); }
|
|
|
|
| 26 |
.i-flash { --i: url(../assets/icons/flash.svg); }
|
| 27 |
.i-fullscreen { --i: url(../assets/icons/fullscreen.svg); }
|
| 28 |
.i-hand-wave { --i: url(../assets/icons/hand-wave.svg); }
|
| 29 |
+
.i-hand-back-right { --i: url(../assets/icons/hand-back-right.svg); }
|
| 30 |
+
.i-gesture-swipe-horizontal { --i: url(../assets/icons/gesture-swipe-horizontal.svg); }
|
| 31 |
+
.i-axis-x-arrow { --i: url(../assets/icons/axis-x-arrow.svg); }
|
| 32 |
+
.i-axis-y-arrow { --i: url(../assets/icons/axis-y-arrow.svg); }
|
| 33 |
+
.i-axis-z-arrow { --i: url(../assets/icons/axis-z-arrow.svg); }
|
| 34 |
+
.i-axis-x-rotate-clockwise { --i: url(../assets/icons/axis-x-rotate-clockwise.svg); }
|
| 35 |
+
.i-axis-y-rotate-clockwise { --i: url(../assets/icons/axis-y-rotate-clockwise.svg); }
|
| 36 |
+
.i-axis-z-rotate-clockwise { --i: url(../assets/icons/axis-z-rotate-clockwise.svg); }
|
| 37 |
+
.i-antenna { --i: url(../assets/icons/antenna.svg); }
|
| 38 |
+
.i-rotate-3d-variant { --i: url(../assets/icons/rotate-3d-variant.svg); }
|
| 39 |
+
.i-brightness-6 { --i: url(../assets/icons/brightness-6.svg); }
|
| 40 |
+
.i-chevron-left { --i: url(../assets/icons/chevron-left.svg); }
|
| 41 |
+
.i-tray-arrow-up { --i: url(../assets/icons/tray-arrow-up.svg); }
|
| 42 |
.i-login-variant { --i: url(../assets/icons/login-variant.svg); }
|
| 43 |
.i-home { --i: url(../assets/icons/home.svg); }
|
| 44 |
.i-heart { --i: url(../assets/icons/heart.svg); }
|
|
|
|
| 49 |
.i-music-note { --i: url(../assets/icons/music-note.svg); }
|
| 50 |
.i-open-in-new { --i: url(../assets/icons/open-in-new.svg); }
|
| 51 |
.i-package-variant-closed { --i: url(../assets/icons/package-variant-closed.svg); }
|
| 52 |
+
.i-eye-outline { --i: url(../assets/icons/eye-outline.svg); }
|
| 53 |
.i-motion-play-outline { --i: url(../assets/icons/motion-play-outline.svg); }
|
| 54 |
.i-palette { --i: url(../assets/icons/palette.svg); }
|
| 55 |
.i-pause { --i: url(../assets/icons/pause.svg); }
|
reachy_web/static/css/logo.css
CHANGED
|
@@ -5,10 +5,21 @@
|
|
| 5 |
so every keyframe set here runs ONCE and lands back on the resting pose.
|
| 6 |
Y axis only on the eyes (never X); each antenna pivots where it leaves the head. */
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
.lg-ant-l { transform-box: fill-box; transform-origin: 100% 100%; }
|
| 9 |
.lg-ant-r { transform-box: fill-box; transform-origin: 0% 100%; }
|
| 10 |
.lg-eye, .lg-wink { transform-box: fill-box; transform-origin: center; }
|
| 11 |
.lg-wink { opacity: 0; } /* the wink curve only shows while an eye is shut */
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
/* ===== the antennas never go still =====
|
| 14 |
A permanent, tiny breath on the PARENT group of each antenna. It has to be the
|
|
@@ -107,21 +118,44 @@
|
|
| 107 |
}
|
| 108 |
@keyframes lgExEye { 0% { transform: scale(1) } 18% { transform: scale(1.28) } 70% { transform: scale(1.28) } 100% { transform: scale(1) } }
|
| 109 |
|
| 110 |
-
/* ================= 9. BOOT —
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
-
@keyframes
|
| 120 |
-
0%,
|
| 121 |
-
|
| 122 |
-
|
|
|
|
| 123 |
}
|
| 124 |
-
@keyframes lgBootAnt { 0%, 40% { transform: rotate(0) } 52% { transform: rotate(-11deg) } 70% { transform: rotate(4deg) } 100% { transform: rotate(0) } }
|
| 125 |
|
| 126 |
@media (prefers-reduced-motion: reduce) {
|
| 127 |
.topbar-logo * { animation: none !important; }
|
|
@@ -153,3 +187,37 @@
|
|
| 153 |
72% { opacity: 1; transform: translateY(0) } /* worn ~1.6s */
|
| 154 |
86%, 100% { opacity: 0; transform: translateY(-28px) } /* lifted off */
|
| 155 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
so every keyframe set here runs ONCE and lands back on the resting pose.
|
| 6 |
Y axis only on the eyes (never X); each antenna pivots where it leaves the head. */
|
| 7 |
|
| 8 |
+
/* Dark mode: the antennas (stem + ball) are the only ink that sits OUTSIDE the
|
| 9 |
+
white head, on the dark page — the #1b2330 brand ink makes them vanish there.
|
| 10 |
+
Lift just those to a light tone (CSS wins over the inline stroke/fill). Never
|
| 11 |
+
touch the eyes/wink: they are dark ON the white head, and must stay dark. */
|
| 12 |
+
:root[data-theme="dark"] .lg-ant-l path,
|
| 13 |
+
:root[data-theme="dark"] .lg-ant-r path { stroke: #e8eaed; }
|
| 14 |
+
:root[data-theme="dark"] .lg-ball { fill: #e8eaed; }
|
| 15 |
+
|
| 16 |
.lg-ant-l { transform-box: fill-box; transform-origin: 100% 100%; }
|
| 17 |
.lg-ant-r { transform-box: fill-box; transform-origin: 0% 100%; }
|
| 18 |
.lg-eye, .lg-wink { transform-box: fill-box; transform-origin: center; }
|
| 19 |
.lg-wink { opacity: 0; } /* the wink curve only shows while an eye is shut */
|
| 20 |
+
/* whole-mark and head-only groups, for the body moves */
|
| 21 |
+
.lg-all { transform-box: fill-box; transform-origin: center bottom; }
|
| 22 |
+
.lg-hg { transform-box: fill-box; transform-origin: 27px 44.2px; } /* the HEAD centre: the antennas now live inside this group, whose fill-box grew upward, so `center` would pivot above the head */
|
| 23 |
|
| 24 |
/* ===== the antennas never go still =====
|
| 25 |
A permanent, tiny breath on the PARENT group of each antenna. It has to be the
|
|
|
|
| 118 |
}
|
| 119 |
@keyframes lgExEye { 0% { transform: scale(1) } 18% { transform: scale(1.28) } 70% { transform: scale(1.28) } 100% { transform: scale(1) } }
|
| 120 |
|
| 121 |
+
/* ================= 9. BOOT — wakes up, heavy lids first (2.2s) ================= */
|
| 122 |
+
/* eyes start shut, crack open, droop back (too heavy), second try, then a wide
|
| 123 |
+
stretch past rest. The right eye wakes a beat after the left (the asymmetry
|
| 124 |
+
canon), and the antennas rise from their sleep droop as the eyes open. */
|
| 125 |
+
.v-boot .lg-eye-l { animation: lgWakeL 2.2s ease-in-out 1 both; }
|
| 126 |
+
.v-boot .lg-eye-r { animation: lgWakeR 2.2s ease-in-out 1 both; }
|
| 127 |
+
.v-boot .lg-ant-l { animation: lgWakeAntL 2.2s ease-in-out 1 both; }
|
| 128 |
+
.v-boot .lg-ant-r { animation: lgWakeAntR 2.2s ease-in-out 1 both; }
|
| 129 |
+
@keyframes lgWakeL {
|
| 130 |
+
0% { transform: scaleY(.08) } /* asleep */
|
| 131 |
+
22% { transform: scaleY(.45) } /* first crack */
|
| 132 |
+
38% { transform: scaleY(.16) } /* lids too heavy, droops back */
|
| 133 |
+
58% { transform: scaleY(.55) } /* second try */
|
| 134 |
+
74% { transform: scaleY(1.25) } /* wide awake stretch, past rest */
|
| 135 |
+
86% { transform: scaleY(.92) }
|
| 136 |
+
100% { transform: scaleY(1) }
|
| 137 |
+
}
|
| 138 |
+
@keyframes lgWakeR {
|
| 139 |
+
0%, 12% { transform: scaleY(.08) } /* still asleep a beat longer */
|
| 140 |
+
32% { transform: scaleY(.4) }
|
| 141 |
+
46% { transform: scaleY(.14) }
|
| 142 |
+
64% { transform: scaleY(.5) }
|
| 143 |
+
80% { transform: scaleY(1.2) }
|
| 144 |
+
90% { transform: scaleY(.88) }
|
| 145 |
+
100% { transform: scaleY(1) }
|
| 146 |
+
}
|
| 147 |
+
@keyframes lgWakeAntL {
|
| 148 |
+
0%, 30% { transform: rotate(-12deg) } /* drooped in sleep */
|
| 149 |
+
70% { transform: rotate(4deg) } /* rises with the eyes */
|
| 150 |
+
85% { transform: rotate(-2deg) }
|
| 151 |
+
100% { transform: rotate(0) }
|
| 152 |
}
|
| 153 |
+
@keyframes lgWakeAntR {
|
| 154 |
+
0%, 40% { transform: rotate(10deg) } /* the smaller one lags here too */
|
| 155 |
+
76% { transform: rotate(-3deg) }
|
| 156 |
+
90% { transform: rotate(1.5deg) }
|
| 157 |
+
100% { transform: rotate(0) }
|
| 158 |
}
|
|
|
|
| 159 |
|
| 160 |
@media (prefers-reduced-motion: reduce) {
|
| 161 |
.topbar-logo * { animation: none !important; }
|
|
|
|
| 187 |
72% { opacity: 1; transform: translateY(0) } /* worn ~1.6s */
|
| 188 |
86%, 100% { opacity: 0; transform: translateY(-28px) } /* lifted off */
|
| 189 |
}
|
| 190 |
+
|
| 191 |
+
/* ================= 12. PENDULE — the whole mark swings and settles (2.6s) ================= */
|
| 192 |
+
.v-pendule .lg-all { animation: lgPend 2.6s ease-in-out 1 both; }
|
| 193 |
+
@keyframes lgPend { 0%{transform:rotate(0)} 18%{transform:rotate(-6deg)} 42%{transform:rotate(5deg)} 64%{transform:rotate(-3deg)} 82%{transform:rotate(1.5deg)} 100%{transform:rotate(0)} }
|
| 194 |
+
|
| 195 |
+
/* ================= 13. TWIST — head one way, eyes the other (1.8s) ================= */
|
| 196 |
+
.v-twist .lg-hg { animation: lgTwistH 1.8s ease-in-out 1 both; }
|
| 197 |
+
.v-twist .lg-eye-l, .v-twist .lg-eye-r { animation: lgTwistE 1.8s ease-in-out 1 both; }
|
| 198 |
+
@keyframes lgTwistH { 0%,100%{transform:rotate(0)} 16%{transform:rotate(-8deg)} 40%{transform:rotate(8deg)} 64%{transform:rotate(-5deg)} 84%{transform:rotate(2deg)} }
|
| 199 |
+
@keyframes lgTwistE { 0%,100%{transform:translateX(0)} 16%{transform:translateX(2.4px)} 40%{transform:translateX(-2.4px)} 64%{transform:translateX(1.5px)} 84%{transform:translateX(-.6px)} }
|
| 200 |
+
|
| 201 |
+
/* ================= 14. SHIMMY — a lateral head vibration burst (1.3s) ================= */
|
| 202 |
+
.v-shimmy .lg-hg { animation: lgShimmy 1.3s linear 1 both; }
|
| 203 |
+
@keyframes lgShimmy { 0%,100%{transform:translateX(0)} 8%,24%,40%,56%,72%{transform:translateX(-2.2px)} 16%,32%,48%,64%,84%{transform:translateX(2.2px)} }
|
| 204 |
+
|
| 205 |
+
/* ================= 15. PIROUETTE — a full 360, bold (1.6s) ================= */
|
| 206 |
+
.v-pirouette .lg-all { transform-origin: center; animation: lgPir 1.6s cubic-bezier(.5,.1,.2,1) 1 both; }
|
| 207 |
+
@keyframes lgPir { 0%{transform:rotate(0)} 12%{transform:rotate(-8deg)} 88%,100%{transform:rotate(360deg)} }
|
| 208 |
+
|
| 209 |
+
/* ================= 16. BOING — the antennas spring, one after the other (1.7s) ================= */
|
| 210 |
+
.v-boing .lg-ant-l { animation: lgBoingL 1.7s ease-out 1 both; }
|
| 211 |
+
.v-boing .lg-ant-r { animation: lgBoingR 1.7s ease-out 1 both; }
|
| 212 |
+
@keyframes lgBoingL { 0%,100%{transform:scaleY(1)} 12%{transform:scaleY(.68)} 26%{transform:scaleY(1.18)} 40%{transform:scaleY(.9)} 54%{transform:scaleY(1.05)} 70%{transform:scaleY(1)} }
|
| 213 |
+
@keyframes lgBoingR { 0%,10%{transform:scaleY(1)} 22%{transform:scaleY(.68)} 36%{transform:scaleY(1.18)} 50%{transform:scaleY(.9)} 64%{transform:scaleY(1.05)} 80%,100%{transform:scaleY(1)} }
|
| 214 |
+
|
| 215 |
+
/* ================= 17. COUCOU-TÊTE — sinks out of frame, pops back (2.6s) ================= */
|
| 216 |
+
.v-coucoutete .lg-all { animation: lgPeek 2.6s ease-in-out 1 both; }
|
| 217 |
+
@keyframes lgPeek { 0%,6%{transform:translateY(0)} 28%,55%{transform:translateY(15px)} 70%{transform:translateY(-3px)} 82%{transform:translateY(1px)} 100%{transform:translateY(0)} }
|
| 218 |
+
|
| 219 |
+
/* ================= 18. CURIEUX — the head shifts over to see better (2.4s) ================= */
|
| 220 |
+
.v-curieux .lg-hg { animation: lgCurH 2.4s ease-in-out 1 both; }
|
| 221 |
+
.v-curieux .lg-eye-l, .v-curieux .lg-eye-r { animation: lgCurE 2.4s ease-in-out 1 both; }
|
| 222 |
+
@keyframes lgCurH { 0%,100%{transform:translateX(0)} 18%,70%{transform:translateX(5px)} }
|
| 223 |
+
@keyframes lgCurE { 0%,100%{transform:translateX(0)} 18%,70%{transform:translateX(2.4px)} }
|
reachy_web/static/css/logs.css
CHANGED
|
@@ -396,13 +396,15 @@
|
|
| 396 |
|
| 397 |
.cp-row {
|
| 398 |
display: grid;
|
| 399 |
-
grid-template-columns:
|
| 400 |
align-items: center;
|
| 401 |
gap: 10px;
|
| 402 |
padding: 5px 0;
|
| 403 |
}
|
| 404 |
|
| 405 |
-
.cp-label { font-size: 0.85rem; color: var(--txt); }
|
|
|
|
|
|
|
| 406 |
|
| 407 |
.cp-val {
|
| 408 |
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
|
@@ -429,104 +431,315 @@
|
|
| 429 |
|
| 430 |
.cp-hint { font-size: 0.78rem; color: var(--mut); line-height: 1.5; }
|
| 431 |
|
| 432 |
-
/*
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
}
|
| 444 |
-
|
| 445 |
-
@keyframes look-ping {
|
| 446 |
-
from { transform: scale(0.4); opacity: 1; }
|
| 447 |
-
to { transform: scale(1.6); opacity: 0; }
|
| 448 |
}
|
| 449 |
|
| 450 |
-
/*
|
| 451 |
-
|
| 452 |
-
.
|
| 453 |
-
|
| 454 |
-
.cp-pad-face {
|
| 455 |
position: relative;
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
margin: 0 auto;
|
| 460 |
border: 1px solid var(--line);
|
| 461 |
border-radius: 14px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
background: var(--soft);
|
| 463 |
-
cursor:
|
| 464 |
-
|
|
|
|
| 465 |
}
|
|
|
|
|
|
|
| 466 |
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
position: absolute;
|
| 472 |
-
inset:
|
| 473 |
-
width:
|
| 474 |
-
height:
|
| 475 |
-
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
}
|
| 486 |
|
| 487 |
-
.
|
| 488 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
.cp-dia-bot {
|
| 493 |
-
transform-box: fill-box;
|
| 494 |
-
transform-origin: center;
|
| 495 |
-
transition: transform 0.08s linear;
|
| 496 |
}
|
| 497 |
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
-
|
| 506 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
|
| 508 |
-
|
|
|
|
| 509 |
position: absolute;
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
margin: -11px 0 0 -11px;
|
| 515 |
border-radius: 50%;
|
| 516 |
-
background: var(--acc);
|
| 517 |
-
box-shadow: 0 2px 10px color-mix(in srgb, var(--acc) 45%, transparent);
|
| 518 |
pointer-events: none;
|
|
|
|
|
|
|
| 519 |
}
|
| 520 |
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
font-size: 0.72rem;
|
|
|
|
|
|
|
|
|
|
| 527 |
color: var(--mut);
|
|
|
|
| 528 |
}
|
| 529 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
/* ===================== tabs (PiWRT style) =====================
|
| 531 |
Pill buttons, not underlines: a card-coloured chip that fills with the accent
|
| 532 |
when active — lifted straight from the PiWRT reference. Two content tabs here
|
|
@@ -775,3 +988,189 @@
|
|
| 775 |
cursor: pointer;
|
| 776 |
}
|
| 777 |
.wifi-disconnect:hover { filter: brightness(1.08); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
|
| 397 |
.cp-row {
|
| 398 |
display: grid;
|
| 399 |
+
grid-template-columns: 6rem 1fr 3.2rem;
|
| 400 |
align-items: center;
|
| 401 |
gap: 10px;
|
| 402 |
padding: 5px 0;
|
| 403 |
}
|
| 404 |
|
| 405 |
+
.cp-label { font-size: 0.85rem; color: var(--txt); display: flex; align-items: center; gap: 6px; }
|
| 406 |
+
/* the per-movement icon sits before the label, muted so the text still leads */
|
| 407 |
+
.cp-label .ic { flex: none; color: var(--mut); font-size: 1.05rem; }
|
| 408 |
|
| 409 |
.cp-val {
|
| 410 |
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
|
|
|
| 431 |
|
| 432 |
.cp-hint { font-size: 0.78rem; color: var(--mut); line-height: 1.5; }
|
| 433 |
|
| 434 |
+
/* ===================== the puppet =====================
|
| 435 |
+
The controller IS the robot: two stages, front and side, each a hand-drawn
|
| 436 |
+
Reachy (cartoon reference × house DA) whose parts are the controls. Every
|
| 437 |
+
grabbable part shows it: cursor grab, and a soft accent halo on hover so the
|
| 438 |
+
handle announces itself (affordance). */
|
| 439 |
+
.cp-stage-duo {
|
| 440 |
+
display: flex;
|
| 441 |
+
gap: 10px;
|
| 442 |
+
margin-bottom: 16px;
|
| 443 |
+
min-width: 0;
|
| 444 |
+
position: relative; /* anchors the colour-picker popover across both stages */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
}
|
| 446 |
|
| 447 |
+
/* flex-basis 0 + min-width 0 is the only combo that lets an svg-filled box
|
| 448 |
+
shrink below the svg's intrinsic width (a grid fr / auto column would spill
|
| 449 |
+
the side view past the drawer edge). The front gets a touch more room. */
|
| 450 |
+
.cp-stage {
|
|
|
|
| 451 |
position: relative;
|
| 452 |
+
flex: 1 1 0;
|
| 453 |
+
min-width: 0;
|
| 454 |
+
background: var(--soft);
|
|
|
|
| 455 |
border: 1px solid var(--line);
|
| 456 |
border-radius: 14px;
|
| 457 |
+
padding: 8px;
|
| 458 |
+
touch-action: none; /* the whole point is dragging; never scroll-pan here */
|
| 459 |
+
}
|
| 460 |
+
/* equal stages: both puppet SVGs now share the 240x360 frame, so equal width =
|
| 461 |
+
equal height, and the two robots line up */
|
| 462 |
+
.cp-stage svg { display: block; width: 100%; height: auto; }
|
| 463 |
+
|
| 464 |
+
/* the ink/cream sticker pile: dark strokes readable on the dark theme because
|
| 465 |
+
each rides a cream rim underneath (drawn first, wider) */
|
| 466 |
+
.pk-ink { stroke: #1b2330; fill: none; stroke-linecap: round; stroke-linejoin: round; }
|
| 467 |
+
.pk-rim { stroke: #efe9dd; fill: none; stroke-linecap: round; stroke-linejoin: round; }
|
| 468 |
+
.pk-sleeve { fill: #1b2330; stroke: #efe9dd; stroke-width: 3; }
|
| 469 |
+
.pk-gloss { stroke: #ffffff; stroke-opacity: 0.8; stroke-width: 8; stroke-linecap: round; fill: none; }
|
| 470 |
+
.pk-iris { fill: #2a3140; transition: fill 0.3s; }
|
| 471 |
+
|
| 472 |
+
/* grabbable parts: the hand and the halo say "move me" (affordance). The head
|
| 473 |
+
blocks and antennas ease so a drag reads as motion, not teleport. */
|
| 474 |
+
[data-grab] { cursor: grab; }
|
| 475 |
+
[data-grab]:active { cursor: grabbing; }
|
| 476 |
+
[data-grab] > rect:first-of-type,
|
| 477 |
+
[data-grab] > path,
|
| 478 |
+
[data-grab] > ellipse,
|
| 479 |
+
[data-grab] > circle { transition: filter 0.12s; }
|
| 480 |
+
.cp-stage [data-grab]:hover { filter: drop-shadow(0 0 6px color-mix(in srgb, var(--acc) 65%, transparent)); }
|
| 481 |
+
#cp-pf-head, #cp-ps-head, [data-grab="body"], [data-grab="antl"], [data-grab="antr"] {
|
| 482 |
+
transition: transform 0.09s linear;
|
| 483 |
+
}
|
| 484 |
+
/* the eyes announce they are the color picker */
|
| 485 |
+
[data-eyes-open] { cursor: pointer; }
|
| 486 |
+
|
| 487 |
+
/* ===================== eyes: color picker =====================
|
| 488 |
+
Visible IN the eyes section (not a popover): a horizontal STRIP of vignette
|
| 489 |
+
cards (same face-with-LED-dots look as the effects), the OFF card FIRST.
|
| 490 |
+
Vertical padding leaves room for the .on selection ring so it is never clipped
|
| 491 |
+
by the scroll box. */
|
| 492 |
+
.cp-picker {
|
| 493 |
+
display: flex;
|
| 494 |
+
gap: 6px;
|
| 495 |
+
padding: 6px 2px; /* the vertical pad keeps the .on ring off the clip edge */
|
| 496 |
+
margin-bottom: 10px;
|
| 497 |
+
overflow-x: auto;
|
| 498 |
+
overflow-y: hidden;
|
| 499 |
+
scrollbar-width: thin;
|
| 500 |
+
scrollbar-color: var(--line) transparent;
|
| 501 |
+
}
|
| 502 |
+
.cp-picker.hidden { display: none; }
|
| 503 |
+
.cp-picker::-webkit-scrollbar { height: 7px; }
|
| 504 |
+
.cp-picker::-webkit-scrollbar-thumb { background: var(--line); border-radius: 999px; }
|
| 505 |
+
|
| 506 |
+
.eye-chip {
|
| 507 |
+
flex: 0 0 auto;
|
| 508 |
+
width: 38px;
|
| 509 |
+
padding: 0;
|
| 510 |
+
border-radius: 10px;
|
| 511 |
+
border: 1px solid var(--line);
|
| 512 |
background: var(--soft);
|
| 513 |
+
cursor: pointer;
|
| 514 |
+
overflow: hidden;
|
| 515 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 516 |
}
|
| 517 |
+
.eye-chip:hover { border-color: var(--acc); }
|
| 518 |
+
.eye-chip.on { border-color: var(--acc); box-shadow: 0 0 0 2px var(--acc); }
|
| 519 |
|
| 520 |
+
/* the vignette: a dark face with the two LED dots (left bigger, canon) */
|
| 521 |
+
.eye-face {
|
| 522 |
+
display: flex;
|
| 523 |
+
align-items: center;
|
| 524 |
+
justify-content: center;
|
| 525 |
+
gap: 16%;
|
| 526 |
+
width: 100%;
|
| 527 |
+
aspect-ratio: 1;
|
| 528 |
+
background: #10151c;
|
| 529 |
+
}
|
| 530 |
+
.eye-face .eye-dot { width: 27%; aspect-ratio: 1; border-radius: 50%; background: currentColor; }
|
| 531 |
+
.eye-face .eye-dot.r { width: 21%; }
|
| 532 |
+
.eye-chip:not([data-eyes-color="OFF"]) .eye-dot { box-shadow: 0 0 6px currentColor; }
|
| 533 |
+
/* OFF: two UNLIT lenses drawn as visible rings, so the "off" card reads as a
|
| 534 |
+
real card and not an empty dark square (dark dots on a dark face vanished) */
|
| 535 |
+
.eye-face.off .eye-dot { background: transparent; box-shadow: inset 0 0 0 2px #626c7a; }
|
| 536 |
+
|
| 537 |
+
/* the module palette, literal on purpose (like the HF ambers) */
|
| 538 |
+
.eye-chip[data-eyes-color="WHITE"] .eye-dot { color: #f5f5f0; }
|
| 539 |
+
.eye-chip[data-eyes-color="RED"] .eye-dot { color: #e5232a; }
|
| 540 |
+
.eye-chip[data-eyes-color="AMBER"] .eye-dot { color: #ffb300; }
|
| 541 |
+
.eye-chip[data-eyes-color="GREEN"] .eye-dot { color: #1fbf3a; }
|
| 542 |
+
.eye-chip[data-eyes-color="CYAN"] .eye-dot { color: #00c8d7; }
|
| 543 |
+
.eye-chip[data-eyes-color="BLUE"] .eye-dot { color: #1e5bff; }
|
| 544 |
+
.eye-chip[data-eyes-color="MAGENTA"] .eye-dot { color: #e0219e; }
|
| 545 |
+
|
| 546 |
+
/* the custom card: a full colour WHEEL (native <input type=color>, laid over the
|
| 547 |
+
card so a click opens it). At rest the two lenses show a rainbow hint; once a
|
| 548 |
+
free colour is picked, markEyes() paints them that colour inline. */
|
| 549 |
+
.eye-custom { position: relative; }
|
| 550 |
+
.eye-custom input[type="color"] {
|
| 551 |
position: absolute;
|
| 552 |
+
inset: 0;
|
| 553 |
+
width: 100%;
|
| 554 |
+
height: 100%;
|
| 555 |
+
margin: 0;
|
| 556 |
+
padding: 0;
|
| 557 |
+
border: none;
|
| 558 |
+
background: none;
|
| 559 |
+
opacity: 0;
|
| 560 |
+
cursor: pointer;
|
| 561 |
}
|
| 562 |
+
.eye-custom .eye-dot { background: conic-gradient(from -90deg, red, #ff0, lime, cyan, blue, magenta, red); box-shadow: none; }
|
| 563 |
+
|
| 564 |
+
/* the catalogue strips clip the selection ring at the top (overflow-y:hidden +
|
| 565 |
+
only a bottom pad in app.css); give it room so the .on accent is not cut */
|
| 566 |
+
.skin-list { padding-top: 8px; }
|
| 567 |
|
| 568 |
+
/* ===================== eyes: effect vignettes =====================
|
| 569 |
+
The Effects strip in the catalogue. Cards ride the .skin-chip gabarit; the
|
| 570 |
+
vignette is a dark face with the two LED dots (left one bigger, canon) whose
|
| 571 |
+
CSS animation previews what the scenario does. Colors are the firmware's. */
|
| 572 |
+
.fx-face {
|
| 573 |
+
display: flex;
|
| 574 |
+
align-items: center;
|
| 575 |
+
justify-content: center;
|
| 576 |
+
gap: 16%;
|
| 577 |
+
background: #10151c; /* LEDs read on dark, whatever the theme */
|
| 578 |
}
|
| 579 |
|
| 580 |
+
.sc-eye {
|
| 581 |
+
width: 24%;
|
| 582 |
+
aspect-ratio: 1;
|
| 583 |
+
border-radius: 50%;
|
| 584 |
+
background: #00c8d7;
|
| 585 |
+
}
|
| 586 |
+
.sc-eye.r { width: 19%; }
|
| 587 |
+
/* the OFF vignette (first card in the strip): two UNLIT lenses drawn as rings,
|
| 588 |
+
so "éteint" reads as a real card, not an empty dark face */
|
| 589 |
+
.fx-off .sc-eye { background: transparent; box-shadow: inset 0 0 0 2px #626c7a; }
|
| 590 |
|
| 591 |
+
@media (prefers-reduced-motion: reduce) {
|
| 592 |
+
.fx-face .sc-eye { animation: none !important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
}
|
| 594 |
|
| 595 |
+
/* wink: cyan, each eye closing on its own incommensurate beat */
|
| 596 |
+
@keyframes fx-wink { 0%, 88%, 100% { transform: scaleY(1); } 92%, 96% { transform: scaleY(0.08); } }
|
| 597 |
+
.sc-wink .sc-eye.l { animation: fx-wink 3.1s infinite; }
|
| 598 |
+
.sc-wink .sc-eye.r { animation: fx-wink 4.7s 1.2s infinite; }
|
| 599 |
+
|
| 600 |
+
/* breathe: a slow glow swell */
|
| 601 |
+
@keyframes fx-breathe { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
|
| 602 |
+
.sc-breathe .sc-eye { animation: fx-breathe 3s ease-in-out infinite; }
|
| 603 |
+
|
| 604 |
+
/* smooth: the six colors, melted */
|
| 605 |
+
@keyframes fx-smooth {
|
| 606 |
+
0%, 100% { background: #e5232a; } 16% { background: #00c8d7; }
|
| 607 |
+
33% { background: #ffb300; } 50% { background: #e0219e; }
|
| 608 |
+
66% { background: #1e5bff; } 83% { background: #1fbf3a; }
|
| 609 |
}
|
| 610 |
+
.sc-smooth .sc-eye { animation: fx-smooth 9s linear infinite; }
|
| 611 |
+
|
| 612 |
+
/* disco: the same six, snapped */
|
| 613 |
+
.sc-disco .sc-eye { animation: fx-smooth 1.8s steps(1) infinite; }
|
| 614 |
+
|
| 615 |
+
/* chaos: each eye on its own fast random-looking snap */
|
| 616 |
+
.sc-chaos .sc-eye.l { animation: fx-smooth 1.3s steps(1) infinite; }
|
| 617 |
+
.sc-chaos .sc-eye.r { animation: fx-smooth 0.9s steps(1) 0.4s reverse infinite; }
|
| 618 |
+
|
| 619 |
+
/* weewoo: red/blue swapping sides with a flash */
|
| 620 |
+
@keyframes fx-weewoo-l { 0%, 49% { background: #e5232a; } 50%, 100% { background: #1e5bff; } }
|
| 621 |
+
@keyframes fx-weewoo-r { 0%, 49% { background: #1e5bff; } 50%, 100% { background: #e5232a; } }
|
| 622 |
+
@keyframes fx-strobe { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
|
| 623 |
+
.sc-weewoo .sc-eye.l { animation: fx-weewoo-l 0.84s steps(1) infinite, fx-strobe 0.21s infinite; }
|
| 624 |
+
.sc-weewoo .sc-eye.r { animation: fx-weewoo-r 0.84s steps(1) infinite, fx-strobe 0.21s 0.1s infinite; }
|
| 625 |
+
|
| 626 |
+
/* pinpon: three quick blue pulses, one eye then the other */
|
| 627 |
+
@keyframes fx-pinpon-l {
|
| 628 |
+
0%, 4% { opacity: 1; } 6%, 10% { opacity: 0.1; } 12%, 16% { opacity: 1; }
|
| 629 |
+
18%, 22% { opacity: 0.1; } 24%, 28% { opacity: 1; } 30%, 100% { opacity: 0.1; }
|
| 630 |
+
}
|
| 631 |
+
.sc-pinpon .sc-eye { background: #1e5bff; }
|
| 632 |
+
.sc-pinpon .sc-eye.l { animation: fx-pinpon-l 1.6s infinite; }
|
| 633 |
+
.sc-pinpon .sc-eye.r { animation: fx-pinpon-l 1.6s 0.8s infinite; }
|
| 634 |
+
|
| 635 |
+
/* cylon: one red eye sweeping to the other */
|
| 636 |
+
@keyframes fx-cylon-l { 0%, 45% { opacity: 1; } 55%, 95% { opacity: 0.08; } 100% { opacity: 1; } }
|
| 637 |
+
.sc-cylon .sc-eye { background: #e5232a; }
|
| 638 |
+
.sc-cylon .sc-eye.l { animation: fx-cylon-l 1.7s ease-in-out infinite; }
|
| 639 |
+
.sc-cylon .sc-eye.r { animation: fx-cylon-l 1.7s 0.85s ease-in-out infinite; }
|
| 640 |
+
|
| 641 |
+
/* anger: fast, intense red pulsing */
|
| 642 |
+
@keyframes fx-anger { 0%, 100% { transform: scale(1); opacity: 0.75; } 50% { transform: scale(1.22); opacity: 1; } }
|
| 643 |
+
.sc-anger .sc-eye { background: #e5232a; animation: fx-anger 0.4s ease-in-out infinite; }
|
| 644 |
+
|
| 645 |
+
/* amazement: the quick white double-take flashes */
|
| 646 |
+
@keyframes fx-amaze {
|
| 647 |
+
0%, 7% { background: #f5f5f0; } 8%, 16% { background: #00c8d7; }
|
| 648 |
+
17%, 24% { background: #f5f5f0; } 25%, 100% { background: #00c8d7; }
|
| 649 |
+
}
|
| 650 |
+
.sc-amazement .sc-eye { animation: fx-amaze 1.2s infinite; }
|
| 651 |
|
| 652 |
+
/* heartbeat: the double red thump */
|
| 653 |
+
@keyframes fx-heart {
|
| 654 |
+
0%, 100% { transform: scale(1); } 8% { transform: scale(1.25); }
|
| 655 |
+
16% { transform: scale(1); } 24% { transform: scale(1.25); } 32% { transform: scale(1); }
|
| 656 |
+
}
|
| 657 |
+
.sc-heart .sc-eye { background: #e5232a; animation: fx-heart 1.4s infinite; }
|
| 658 |
+
|
| 659 |
+
/* hypno: cyan and magenta trading places */
|
| 660 |
+
@keyframes fx-hypno-l { 0%, 100% { background: #00c8d7; } 50% { background: #e0219e; } }
|
| 661 |
+
@keyframes fx-hypno-r { 0%, 100% { background: #e0219e; } 50% { background: #00c8d7; } }
|
| 662 |
+
.sc-hypno .sc-eye.l { animation: fx-hypno-l 2.8s ease-in-out infinite; }
|
| 663 |
+
.sc-hypno .sc-eye.r { animation: fx-hypno-r 2.8s ease-in-out infinite; }
|
| 664 |
+
|
| 665 |
+
/* sunset: amber sinking to dark */
|
| 666 |
+
@keyframes fx-sunset {
|
| 667 |
+
0% { background: #ffb300; opacity: 1; } 30% { background: #e0219e; opacity: 0.9; }
|
| 668 |
+
60% { background: #e5232a; opacity: 0.7; } 85%, 100% { background: #e5232a; opacity: 0.08; }
|
| 669 |
+
}
|
| 670 |
+
.sc-sunset .sc-eye { animation: fx-sunset 8s infinite; }
|
| 671 |
|
| 672 |
+
/* the ring left where you clicked to aim the head */
|
| 673 |
+
.look-ping {
|
| 674 |
position: absolute;
|
| 675 |
+
width: 26px;
|
| 676 |
+
height: 26px;
|
| 677 |
+
margin: -13px 0 0 -13px;
|
| 678 |
+
border: 2px solid var(--acc);
|
|
|
|
| 679 |
border-radius: 50%;
|
|
|
|
|
|
|
| 680 |
pointer-events: none;
|
| 681 |
+
animation: look-ping 0.7s ease-out forwards;
|
| 682 |
+
z-index: 12;
|
| 683 |
}
|
| 684 |
|
| 685 |
+
@keyframes look-ping {
|
| 686 |
+
from { transform: scale(0.4); opacity: 1; }
|
| 687 |
+
to { transform: scale(1.6); opacity: 0; }
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
/* ===================== precision fold =====================
|
| 691 |
+
The numbers, for the meticulous: every axis as a plain slider, folded away by
|
| 692 |
+
default so the puppet is the face of the panel, not a wall of sliders. */
|
| 693 |
+
/* the per-axis controls, always visible now (was a collapsed <details>) */
|
| 694 |
+
.cp-precision { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 12px; }
|
| 695 |
+
|
| 696 |
+
.cp-section-title {
|
| 697 |
font-size: 0.72rem;
|
| 698 |
+
font-weight: 700;
|
| 699 |
+
text-transform: uppercase;
|
| 700 |
+
letter-spacing: 1px;
|
| 701 |
color: var(--mut);
|
| 702 |
+
margin-bottom: 8px;
|
| 703 |
}
|
| 704 |
|
| 705 |
+
/* movement sequence (keyframes) */
|
| 706 |
+
#cp-seq { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 12px; }
|
| 707 |
+
.cp-seq-count { font-weight: 500; letter-spacing: 0; text-transform: none; }
|
| 708 |
+
.cp-seq-list { max-height: 168px; overflow-y: auto; margin-bottom: 8px; }
|
| 709 |
+
.cp-seq-empty { font-size: 0.8rem; color: var(--mut); padding: 8px 2px; }
|
| 710 |
+
.cp-seq-row {
|
| 711 |
+
display: flex;
|
| 712 |
+
align-items: center;
|
| 713 |
+
gap: 8px;
|
| 714 |
+
padding: 5px 6px;
|
| 715 |
+
border-radius: 8px;
|
| 716 |
+
cursor: pointer;
|
| 717 |
+
font-size: 0.8rem;
|
| 718 |
+
}
|
| 719 |
+
.cp-seq-row:hover { background: var(--soft); }
|
| 720 |
+
.cp-seq-row.on { background: color-mix(in srgb, var(--acc) 18%, var(--card)); }
|
| 721 |
+
.cp-seq-n { flex: none; width: 1.4rem; text-align: right; color: var(--mut); font-variant-numeric: tabular-nums; }
|
| 722 |
+
.cp-seq-desc { flex: 1; color: var(--txt); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
|
| 723 |
+
.cp-seq-del { flex: none; border: 0; background: none; color: var(--mut); cursor: pointer; padding: 2px; border-radius: 6px; line-height: 0; }
|
| 724 |
+
.cp-seq-del:hover { color: var(--bad); }
|
| 725 |
+
.cp-seq-bar { display: flex; align-items: center; gap: 5px; }
|
| 726 |
+
.cp-seq-sp { flex: 1; }
|
| 727 |
+
.cp-seq-bar button {
|
| 728 |
+
flex: none;
|
| 729 |
+
width: 32px;
|
| 730 |
+
height: 32px;
|
| 731 |
+
border-radius: 8px;
|
| 732 |
+
border: 1px solid var(--line);
|
| 733 |
+
background: var(--soft);
|
| 734 |
+
color: var(--txt);
|
| 735 |
+
cursor: pointer;
|
| 736 |
+
display: inline-flex;
|
| 737 |
+
align-items: center;
|
| 738 |
+
justify-content: center;
|
| 739 |
+
}
|
| 740 |
+
.cp-seq-bar button:hover:not(:disabled) { border-color: var(--acc); color: var(--acc); }
|
| 741 |
+
.cp-seq-bar button:disabled { opacity: 0.4; cursor: default; }
|
| 742 |
+
|
| 743 |
/* ===================== tabs (PiWRT style) =====================
|
| 744 |
Pill buttons, not underlines: a card-coloured chip that fills with the accent
|
| 745 |
when active — lifted straight from the PiWRT reference. Two content tabs here
|
|
|
|
| 988 |
cursor: pointer;
|
| 989 |
}
|
| 990 |
.wifi-disconnect:hover { filter: brightness(1.08); }
|
| 991 |
+
|
| 992 |
+
/* ============ simulator: top-right buttons + the "grab me" hint ============ */
|
| 993 |
+
|
| 994 |
+
/* the help button lives next to the Camera/3D switch, top-right of the viewport.
|
| 995 |
+
When the switch is hidden (twin-only robot) the button sits alone at the edge. */
|
| 996 |
+
.cam-topbtns {
|
| 997 |
+
position: absolute;
|
| 998 |
+
top: 10px;
|
| 999 |
+
right: 10px;
|
| 1000 |
+
z-index: 11;
|
| 1001 |
+
display: flex;
|
| 1002 |
+
align-items: center;
|
| 1003 |
+
gap: 8px;
|
| 1004 |
+
}
|
| 1005 |
+
/* the mode switch keeps its look but drops its own absolute placement inside the row */
|
| 1006 |
+
.cam-topbtns .cam-seg { position: static; top: auto; right: auto; }
|
| 1007 |
+
|
| 1008 |
+
.cam-iconbtn {
|
| 1009 |
+
display: inline-flex;
|
| 1010 |
+
align-items: center;
|
| 1011 |
+
justify-content: center;
|
| 1012 |
+
width: 34px;
|
| 1013 |
+
height: 34px;
|
| 1014 |
+
border-radius: 9px;
|
| 1015 |
+
background: rgba(0, 0, 0, 0.5);
|
| 1016 |
+
-webkit-backdrop-filter: blur(6px);
|
| 1017 |
+
backdrop-filter: blur(6px);
|
| 1018 |
+
border: 1px solid rgba(255, 255, 255, 0.14);
|
| 1019 |
+
color: #e8eaed;
|
| 1020 |
+
cursor: pointer;
|
| 1021 |
+
padding: 0;
|
| 1022 |
+
}
|
| 1023 |
+
.cam-iconbtn:hover { background: rgba(255, 255, 255, 0.14); color: #fff; }
|
| 1024 |
+
.cam-iconbtn.hidden { display: none; }
|
| 1025 |
+
.cam-iconbtn .ic { font-size: 1.05rem; }
|
| 1026 |
+
|
| 1027 |
+
/* the hint card: dark glass pill, low-centre so it never hides the robot's head.
|
| 1028 |
+
Fixed dark scheme on purpose (it floats over the 3D scene, like the switch). */
|
| 1029 |
+
.twin-hint {
|
| 1030 |
+
position: absolute;
|
| 1031 |
+
left: 50%;
|
| 1032 |
+
bottom: 56px;
|
| 1033 |
+
transform: translateX(-50%) translateY(6px);
|
| 1034 |
+
z-index: 12;
|
| 1035 |
+
display: flex;
|
| 1036 |
+
align-items: center;
|
| 1037 |
+
gap: 12px;
|
| 1038 |
+
max-width: min(88%, 430px);
|
| 1039 |
+
padding: 10px 8px 10px 14px;
|
| 1040 |
+
border-radius: 14px;
|
| 1041 |
+
background: rgba(0, 0, 0, 0.62);
|
| 1042 |
+
-webkit-backdrop-filter: blur(8px);
|
| 1043 |
+
backdrop-filter: blur(8px);
|
| 1044 |
+
border: 1px solid rgba(255, 255, 255, 0.14);
|
| 1045 |
+
color: #fff;
|
| 1046 |
+
box-shadow: 0 12px 32px #00000055;
|
| 1047 |
+
opacity: 0;
|
| 1048 |
+
pointer-events: none;
|
| 1049 |
+
transition: opacity 0.3s ease, transform 0.3s ease;
|
| 1050 |
+
}
|
| 1051 |
+
.twin-hint.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
|
| 1052 |
+
.twin-hint.hidden { display: none; }
|
| 1053 |
+
.twin-hint-hand {
|
| 1054 |
+
font-size: 1.9rem;
|
| 1055 |
+
color: var(--acc);
|
| 1056 |
+
flex: 0 0 auto;
|
| 1057 |
+
transform-origin: 60% 90%;
|
| 1058 |
+
animation: twinHintDrag 1.9s ease-in-out infinite;
|
| 1059 |
+
}
|
| 1060 |
+
.twin-hint-txt { display: flex; flex-direction: column; gap: 2px; line-height: 1.25; }
|
| 1061 |
+
.twin-hint-txt strong { font-size: 0.9rem; font-weight: 700; }
|
| 1062 |
+
.twin-hint-txt span { font-size: 0.8rem; opacity: 0.85; }
|
| 1063 |
+
.twin-hint-x {
|
| 1064 |
+
flex: 0 0 auto;
|
| 1065 |
+
color: rgba(255, 255, 255, 0.7);
|
| 1066 |
+
background: none;
|
| 1067 |
+
border: 0;
|
| 1068 |
+
cursor: pointer;
|
| 1069 |
+
padding: 4px 6px;
|
| 1070 |
+
align-self: flex-start;
|
| 1071 |
+
}
|
| 1072 |
+
.twin-hint-x:hover { color: #fff; }
|
| 1073 |
+
|
| 1074 |
+
/* the hand mimes a grab-and-drag: a little dip (grab), then a left↔right slide */
|
| 1075 |
+
@keyframes twinHintDrag {
|
| 1076 |
+
0%, 100% { transform: translateX(-8px) rotate(-6deg); }
|
| 1077 |
+
50% { transform: translateX(8px) rotate(6deg); }
|
| 1078 |
+
}
|
| 1079 |
+
@media (prefers-reduced-motion: reduce) {
|
| 1080 |
+
.twin-hint-hand { animation: none; }
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
/* ===================== app stage =====================
|
| 1084 |
+
A running app's own UI in an iframe, splitting the screen next to the
|
| 1085 |
+
dashboard. Toolbar hidden until you hover the top strip; a small screen gets a
|
| 1086 |
+
permanent Dashboard/App switch instead. */
|
| 1087 |
+
|
| 1088 |
+
.astage {
|
| 1089 |
+
position: fixed;
|
| 1090 |
+
top: 0;
|
| 1091 |
+
right: 0;
|
| 1092 |
+
bottom: 0;
|
| 1093 |
+
width: 50vw;
|
| 1094 |
+
z-index: 35;
|
| 1095 |
+
display: flex;
|
| 1096 |
+
flex-direction: column;
|
| 1097 |
+
background: var(--bg);
|
| 1098 |
+
border-left: 1px solid var(--line);
|
| 1099 |
+
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.4);
|
| 1100 |
+
transform: translateX(100%);
|
| 1101 |
+
transition: transform 0.25s ease;
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
.astage-frame { flex: 1; width: 100%; border: 0; background: #fff; }
|
| 1105 |
+
|
| 1106 |
+
/* the hover strip: hidden until the pointer is over the top of the stage */
|
| 1107 |
+
.astage-bar {
|
| 1108 |
+
position: absolute;
|
| 1109 |
+
top: 0; left: 0; right: 0;
|
| 1110 |
+
z-index: 2;
|
| 1111 |
+
display: flex;
|
| 1112 |
+
align-items: center;
|
| 1113 |
+
gap: 8px;
|
| 1114 |
+
padding: 7px 9px;
|
| 1115 |
+
background: color-mix(in srgb, var(--card) 90%, transparent);
|
| 1116 |
+
backdrop-filter: blur(8px);
|
| 1117 |
+
border-bottom: 1px solid var(--line);
|
| 1118 |
+
opacity: 0;
|
| 1119 |
+
transform: translateY(-100%);
|
| 1120 |
+
transition: opacity 0.15s ease, transform 0.15s ease;
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
.astage:hover .astage-bar, .astage-bar:focus-within { opacity: 1; transform: none; }
|
| 1124 |
+
|
| 1125 |
+
/* a faint grab pill so you know the bar is up there */
|
| 1126 |
+
.astage::after {
|
| 1127 |
+
content: "";
|
| 1128 |
+
position: absolute;
|
| 1129 |
+
top: 5px; left: 50%;
|
| 1130 |
+
transform: translateX(-50%);
|
| 1131 |
+
width: 40px; height: 4px;
|
| 1132 |
+
border-radius: 999px;
|
| 1133 |
+
background: var(--line);
|
| 1134 |
+
z-index: 1;
|
| 1135 |
+
pointer-events: none;
|
| 1136 |
+
transition: opacity 0.15s ease;
|
| 1137 |
+
}
|
| 1138 |
+
.astage:hover::after { opacity: 0; }
|
| 1139 |
+
|
| 1140 |
+
.astage-name { display: flex; align-items: center; gap: 7px; font-weight: 600; font-size: 0.88rem; min-width: 0; }
|
| 1141 |
+
.astage-name > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 1142 |
+
.astage-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 7px var(--ok); flex: none; }
|
| 1143 |
+
.astage-perf { display: flex; align-items: center; gap: 6px; margin-left: auto; font-size: 0.72rem; color: var(--mut); flex: none; }
|
| 1144 |
+
.astage-fps-val { font-family: ui-monospace, Menlo, monospace; color: var(--txt); min-width: 44px; }
|
| 1145 |
+
.astage-actions { display: flex; gap: 2px; flex: none; }
|
| 1146 |
+
.astage-actions button {
|
| 1147 |
+
width: 30px; height: 30px;
|
| 1148 |
+
border: none; background: none;
|
| 1149 |
+
color: var(--mut);
|
| 1150 |
+
border-radius: 8px;
|
| 1151 |
+
cursor: pointer;
|
| 1152 |
+
display: flex; align-items: center; justify-content: center;
|
| 1153 |
+
}
|
| 1154 |
+
.astage-actions button:hover { background: var(--soft); color: var(--txt); }
|
| 1155 |
+
.astage-actions button.bad:hover { color: var(--bad); }
|
| 1156 |
+
|
| 1157 |
+
/* the mobile Dashboard/App switch — a floating pill, small screens only */
|
| 1158 |
+
.aswitch { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 47; }
|
| 1159 |
+
.aswitch .seg { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); }
|
| 1160 |
+
|
| 1161 |
+
/* desktop: the stage takes the right half, the dashboard the left */
|
| 1162 |
+
@media (min-width: 1024px) {
|
| 1163 |
+
.astage.open { transform: none; }
|
| 1164 |
+
body.app-open { padding-right: 50vw; }
|
| 1165 |
+
.aswitch { display: none; }
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
/* small screens: full width, no split; the switch decides dashboard vs app, and
|
| 1169 |
+
the toolbar is always visible (no hover on touch) */
|
| 1170 |
+
@media (max-width: 1023px) {
|
| 1171 |
+
.astage { width: 100vw; border-left: none; }
|
| 1172 |
+
body.app-mobile-show .astage { transform: none; }
|
| 1173 |
+
.astage-bar { opacity: 1; transform: none; position: relative; }
|
| 1174 |
+
.astage::after { display: none; }
|
| 1175 |
+
.astage-frame { min-height: 0; }
|
| 1176 |
+
}
|
reachy_web/static/js/app_stage.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// App Stage — when an app runs, split the screen: the dashboard stays as it is
|
| 2 |
+
// on the left, the app's OWN interface loads in an iframe on the right. The
|
| 3 |
+
// app's toolbar is hidden and only appears on hover of the top strip (desktop);
|
| 4 |
+
// on a small screen a permanent bar switches between Dashboard and App. The
|
| 5 |
+
// daemon runs a single app at a time, so there is only ever one stage.
|
| 6 |
+
const appStage = {
|
| 7 |
+
current: null, // name of the app currently on the stage
|
| 8 |
+
url: null, // its interface URL (on the robot)
|
| 9 |
+
fps: [], // ring buffer of the control-loop frequency, for the sparkline
|
| 10 |
+
fpsTimer: null,
|
| 11 |
+
|
| 12 |
+
el: (id) => document.getElementById(id),
|
| 13 |
+
|
| 14 |
+
// Called by installedApps.showRunning: an app is running (with a URL) or not.
|
| 15 |
+
open: (app) => {
|
| 16 |
+
if (!app || !app.url) return;
|
| 17 |
+
const stage = appStage.el('app-stage');
|
| 18 |
+
if (!stage) return;
|
| 19 |
+
appStage.setMeta(app);
|
| 20 |
+
// same app already on the stage: don't reload its iframe on every 4 s poll
|
| 21 |
+
if (appStage.current === app.name) return;
|
| 22 |
+
appStage.current = app.name;
|
| 23 |
+
appStage.url = app.url;
|
| 24 |
+
const frame = appStage.el('astage-frame');
|
| 25 |
+
if (frame && frame.getAttribute('src') !== app.url) frame.setAttribute('src', app.url);
|
| 26 |
+
stage.classList.add('open');
|
| 27 |
+
stage.setAttribute('aria-hidden', 'false');
|
| 28 |
+
document.body.classList.add('app-open', 'app-mobile-show'); // mobile: show the app first
|
| 29 |
+
appStage.showSwitch(true);
|
| 30 |
+
appStage.mobileView('app');
|
| 31 |
+
appStage.startFps();
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
setMeta: (app) => {
|
| 35 |
+
const em = appStage.el('astage-emoji'), ti = appStage.el('astage-title');
|
| 36 |
+
if (em) em.textContent = app.emoji || '';
|
| 37 |
+
if (ti) ti.textContent = app.title || app.name || '';
|
| 38 |
+
},
|
| 39 |
+
|
| 40 |
+
// Hide the split. external=true when the poll saw the app already gone — then we
|
| 41 |
+
// do NOT ask the daemon to stop it again (it already stopped).
|
| 42 |
+
close: (external) => {
|
| 43 |
+
if (!appStage.current && !appStage.el('app-stage')?.classList.contains('open')) return;
|
| 44 |
+
const name = appStage.current;
|
| 45 |
+
const stage = appStage.el('app-stage');
|
| 46 |
+
if (stage) { stage.classList.remove('open'); stage.setAttribute('aria-hidden', 'true'); }
|
| 47 |
+
document.body.classList.remove('app-open', 'app-mobile-show');
|
| 48 |
+
appStage.showSwitch(false);
|
| 49 |
+
appStage.stopFps();
|
| 50 |
+
const frame = appStage.el('astage-frame');
|
| 51 |
+
if (frame) frame.setAttribute('src', 'about:blank');
|
| 52 |
+
appStage.current = null; appStage.url = null;
|
| 53 |
+
if (!external && name && typeof installedApps !== 'undefined') installedApps.stopApp(name);
|
| 54 |
+
},
|
| 55 |
+
|
| 56 |
+
reload: () => {
|
| 57 |
+
const f = appStage.el('astage-frame');
|
| 58 |
+
if (f && appStage.url) { f.setAttribute('src', 'about:blank'); requestAnimationFrame(() => f.setAttribute('src', appStage.url)); }
|
| 59 |
+
},
|
| 60 |
+
|
| 61 |
+
restart: async () => {
|
| 62 |
+
const name = appStage.current;
|
| 63 |
+
if (!name || typeof installedApps === 'undefined') return;
|
| 64 |
+
await installedApps.stopApp(name);
|
| 65 |
+
setTimeout(() => installedApps.startApp(name), 700); // let it settle, then relaunch
|
| 66 |
+
},
|
| 67 |
+
|
| 68 |
+
// its true URL, in a real tab — coming back to the dashboard tab, the split and
|
| 69 |
+
// the iframe are still there
|
| 70 |
+
fullscreen: () => { if (appStage.url) window.open(appStage.url, '_blank', 'noopener'); },
|
| 71 |
+
|
| 72 |
+
// the app's stdout goes to the daemon log — open the logs panel, filtered to it
|
| 73 |
+
debug: () => {
|
| 74 |
+
if (typeof daemonLogs === 'undefined') return;
|
| 75 |
+
daemonLogs.openPanel();
|
| 76 |
+
const search = document.getElementById('lp-search');
|
| 77 |
+
if (search && appStage.current) {
|
| 78 |
+
search.value = appStage.current;
|
| 79 |
+
search.dispatchEvent(new Event('input'));
|
| 80 |
+
}
|
| 81 |
+
},
|
| 82 |
+
|
| 83 |
+
// --- mobile Dashboard / App switch ---------------------------------------
|
| 84 |
+
showSwitch: (on) => { const sw = appStage.el('app-switch'); if (sw) sw.hidden = !on; },
|
| 85 |
+
mobileView: (v) => {
|
| 86 |
+
document.body.classList.toggle('app-mobile-show', v === 'app');
|
| 87 |
+
appStage.el('app-switch')?.querySelectorAll('[data-aview]').forEach((b) =>
|
| 88 |
+
b.classList.toggle('on', b.dataset.aview === v));
|
| 89 |
+
},
|
| 90 |
+
|
| 91 |
+
// --- FPS = the robot's control-loop frequency (Hz), sampled ~1 Hz ---------
|
| 92 |
+
startFps: () => {
|
| 93 |
+
clearInterval(appStage.fpsTimer);
|
| 94 |
+
appStage.fps = [];
|
| 95 |
+
appStage.fpsTimer = setInterval(appStage.sampleFps, 1000);
|
| 96 |
+
appStage.sampleFps();
|
| 97 |
+
},
|
| 98 |
+
stopFps: () => { clearInterval(appStage.fpsTimer); appStage.fpsTimer = null; },
|
| 99 |
+
sampleFps: () => {
|
| 100 |
+
let hz = null;
|
| 101 |
+
try {
|
| 102 |
+
const b = daemon.currentStatus.backend_status;
|
| 103 |
+
hz = b && b.control_loop_stats && b.control_loop_stats.mean_control_loop_frequency;
|
| 104 |
+
} catch (e) { /* no data yet */ }
|
| 105 |
+
if (!Number.isFinite(hz)) return;
|
| 106 |
+
appStage.fps.push(hz);
|
| 107 |
+
if (appStage.fps.length > 40) appStage.fps.shift();
|
| 108 |
+
const path = appStage.el('astage-fps-line');
|
| 109 |
+
const val = appStage.el('astage-fps-val');
|
| 110 |
+
if (val) val.textContent = Math.round(hz) + ' Hz';
|
| 111 |
+
if (!path) return;
|
| 112 |
+
const n = appStage.fps.length;
|
| 113 |
+
const max = Math.max(60, ...appStage.fps); // scale to at least 60 Hz
|
| 114 |
+
let d = '';
|
| 115 |
+
appStage.fps.forEach((v, i) => {
|
| 116 |
+
const x = (i / Math.max(1, n - 1)) * 70;
|
| 117 |
+
const y = 19 - (v / max) * 17;
|
| 118 |
+
d += (i ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1) + ' ';
|
| 119 |
+
});
|
| 120 |
+
path.setAttribute('d', d.trim());
|
| 121 |
+
},
|
| 122 |
+
|
| 123 |
+
init: () => {
|
| 124 |
+
appStage.el('astage-reload')?.addEventListener('click', appStage.reload);
|
| 125 |
+
appStage.el('astage-restart')?.addEventListener('click', appStage.restart);
|
| 126 |
+
appStage.el('astage-debug')?.addEventListener('click', appStage.debug);
|
| 127 |
+
appStage.el('astage-full')?.addEventListener('click', appStage.fullscreen);
|
| 128 |
+
appStage.el('astage-close')?.addEventListener('click', () => appStage.close(false));
|
| 129 |
+
appStage.el('app-switch')?.querySelectorAll('[data-aview]').forEach((b) =>
|
| 130 |
+
b.addEventListener('click', () => appStage.mobileView(b.dataset.aview)));
|
| 131 |
+
},
|
| 132 |
+
};
|
| 133 |
+
window.addEventListener('DOMContentLoaded', appStage.init);
|
reachy_web/static/js/apps.js
CHANGED
|
@@ -497,15 +497,43 @@ const installedApps = {
|
|
| 497 |
}
|
| 498 |
installedApps.currentlyRunningApp = data.info.name;
|
| 499 |
const card = await installedApps.getCard(data.info);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
installedApps.showRunning({
|
|
|
|
| 501 |
emoji: card?.emoji || '',
|
| 502 |
title: card?.title || data.info.name,
|
|
|
|
| 503 |
});
|
| 504 |
} catch (e) { /* keep the last known: a blip is not a stop */ }
|
| 505 |
},
|
| 506 |
|
| 507 |
showRunning: (app) => {
|
| 508 |
if (typeof topbar !== 'undefined') topbar.setApp(app);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
},
|
| 510 |
|
| 511 |
appUninstallLogHandler: async (appName, jobId) => {
|
|
|
|
| 497 |
}
|
| 498 |
installedApps.currentlyRunningApp = data.info.name;
|
| 499 |
const card = await installedApps.getCard(data.info);
|
| 500 |
+
// the app's own interface URL, pointed at the robot (custom_app_url is
|
| 501 |
+
// 0.0.0.0 in the manifest) — the App Stage embeds it in an iframe
|
| 502 |
+
let url = null;
|
| 503 |
+
const raw = data.info.extra?.custom_app_url;
|
| 504 |
+
if (raw) {
|
| 505 |
+
try { const u = new URL(raw); u.hostname = installedApps.appHost(); url = u.toString(); }
|
| 506 |
+
catch (e) { url = null; }
|
| 507 |
+
}
|
| 508 |
+
// The dashboard is ITSELF the current app when launched from the store
|
| 509 |
+
// or the desktop app. Never stage ourselves: our custom_app_url is this
|
| 510 |
+
// very page, so the split would embed the dashboard in its own iframe and
|
| 511 |
+
// squeeze the real one to half the screen forever (author/stars columns
|
| 512 |
+
// gone, everything cramped). Drop the URL so the stage stays closed.
|
| 513 |
+
const selfName = window.REACHY_WEB_CARD?.name;
|
| 514 |
+
let isSelf = !!(selfName && data.info.name === selfName);
|
| 515 |
+
if (!isSelf && url) {
|
| 516 |
+
try { isSelf = new URL(url).origin === location.origin; } catch (e) { /* keep false */ }
|
| 517 |
+
}
|
| 518 |
+
if (isSelf) url = null;
|
| 519 |
installedApps.showRunning({
|
| 520 |
+
name: data.info.name,
|
| 521 |
emoji: card?.emoji || '',
|
| 522 |
title: card?.title || data.info.name,
|
| 523 |
+
url,
|
| 524 |
});
|
| 525 |
} catch (e) { /* keep the last known: a blip is not a stop */ }
|
| 526 |
},
|
| 527 |
|
| 528 |
showRunning: (app) => {
|
| 529 |
if (typeof topbar !== 'undefined') topbar.setApp(app);
|
| 530 |
+
// the App Stage splits the screen with the running app's own UI. It opens
|
| 531 |
+
// only for an app that exposes a URL; a stop (app == null) closes it,
|
| 532 |
+
// flagged external so we don't ask the daemon to stop what already stopped.
|
| 533 |
+
if (typeof appStage !== 'undefined') {
|
| 534 |
+
if (app && app.url) appStage.open(app);
|
| 535 |
+
else appStage.close(true);
|
| 536 |
+
}
|
| 537 |
},
|
| 538 |
|
| 539 |
appUninstallLogHandler: async (appName, jobId) => {
|
reachy_web/static/js/audio_eq.js
CHANGED
|
@@ -17,21 +17,10 @@ const eqPanel = (function () {
|
|
| 17 |
let wired = false;
|
| 18 |
let detected = false;
|
| 19 |
|
| 20 |
-
// Are we served BY the robot (installed there, daemon on loopback) or from a
|
| 21 |
-
// laptop via run.sh (a remote daemon)? /system/info reports the daemon host;
|
| 22 |
-
// loopback ⇒ on the robot. Can't tell ⇒ treat as laptop and hide the EQ.
|
| 23 |
-
async function onRobot() {
|
| 24 |
-
try {
|
| 25 |
-
const info = await fetch('/system/info').then((r) => r.json());
|
| 26 |
-
const host = String(info.daemon_host || '').split(':')[0];
|
| 27 |
-
return ['127.0.0.1', 'localhost', '::1'].includes(host);
|
| 28 |
-
} catch (e) {
|
| 29 |
-
return false;
|
| 30 |
-
}
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
// The opener (topbar icon + burger entry) shows ONLY when EasyEffects is running on
|
| 34 |
-
// the robot
|
|
|
|
|
|
|
| 35 |
async function refresh() {
|
| 36 |
let st;
|
| 37 |
try {
|
|
@@ -40,11 +29,7 @@ const eqPanel = (function () {
|
|
| 40 |
return;
|
| 41 |
}
|
| 42 |
detected = !!(st && st.detected);
|
| 43 |
-
|
| 44 |
-
// REMOTE daemon) that box is the laptop, not the robot, so the control is
|
| 45 |
-
// meaningless there — hide the opener whatever EasyEffects the laptop itself
|
| 46 |
-
// happens to run. Only ON the robot (loopback daemon) does the EQ mean anything.
|
| 47 |
-
const active = !!(st && st.running) && (await onRobot());
|
| 48 |
|
| 49 |
// gate the two ways in on "active AND on the robot", not merely installed
|
| 50 |
document.querySelectorAll('[data-eq-toggle], [data-eq-nav]')
|
|
@@ -53,7 +38,7 @@ const eqPanel = (function () {
|
|
| 53 |
|
| 54 |
// panel body: controls when detected, a note when not
|
| 55 |
el('eq-controls').classList.toggle('hidden', !detected);
|
| 56 |
-
el('eq-
|
| 57 |
el('eq-note').classList.toggle('hidden', detected);
|
| 58 |
if (!detected) return;
|
| 59 |
|
|
@@ -69,25 +54,40 @@ const eqPanel = (function () {
|
|
| 69 |
sel.appendChild(opt);
|
| 70 |
});
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
if (!wired) wire();
|
| 73 |
wired = true;
|
| 74 |
}
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
label('eqBassVal', bass);
|
| 85 |
-
label('eqMidVal', mid);
|
| 86 |
-
label('eqTrebleVal', treble);
|
| 87 |
-
return { bass, mid, treble };
|
| 88 |
-
}
|
| 89 |
|
| 90 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
function wire() {
|
| 93 |
el('eqEnabled').addEventListener('click', async () => {
|
|
@@ -104,20 +104,27 @@ const eqPanel = (function () {
|
|
| 104 |
post('/audio/eq/preset', { preset: el('eqPreset').value }).catch(() => {});
|
| 105 |
});
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
});
|
|
|
|
| 113 |
});
|
| 114 |
}
|
| 115 |
|
| 116 |
function open() {
|
| 117 |
-
// one drawer at a time
|
| 118 |
-
if (typeof controller !== 'undefined' && controller.isOpen) controller.close();
|
| 119 |
-
if (typeof daemonLogs !== 'undefined' && daemonLogs.isOpen && !daemonLogs.inline) daemonLogs.closePanel();
|
| 120 |
-
if (typeof settingsPanel !== 'undefined' && settingsPanel.isOpen) settingsPanel.close();
|
| 121 |
|
| 122 |
const p = panel();
|
| 123 |
if (!p) return;
|
|
@@ -147,12 +154,13 @@ const eqPanel = (function () {
|
|
| 147 |
if (e.key === 'Escape' && eqPanel.isOpen) close();
|
| 148 |
});
|
| 149 |
|
| 150 |
-
// a click on the layout behind the panel closes it — not inside it,
|
| 151 |
-
// opener
|
|
|
|
| 152 |
document.addEventListener('pointerdown', (e) => {
|
| 153 |
if (!eqPanel.isOpen) return;
|
| 154 |
const p = panel();
|
| 155 |
-
if (p.contains(e.target) || e.target.closest('
|
| 156 |
close();
|
| 157 |
});
|
| 158 |
|
|
|
|
| 17 |
let wired = false;
|
| 18 |
let detected = false;
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
// The opener (topbar icon + burger entry) shows ONLY when EasyEffects is running on
|
| 21 |
+
// the robot. Whether we are ON the robot is the BACKEND's call now (st.on_robot,
|
| 22 |
+
// from REACHY_WEB_ON_ROBOT) — the old frontend loopback probe wrongly hid the EQ on
|
| 23 |
+
// a robot whose daemon was cached under its mDNS name.
|
| 24 |
async function refresh() {
|
| 25 |
let st;
|
| 26 |
try {
|
|
|
|
| 29 |
return;
|
| 30 |
}
|
| 31 |
detected = !!(st && st.detected);
|
| 32 |
+
const active = !!(st && st.running && st.on_robot);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
// gate the two ways in on "active AND on the robot", not merely installed
|
| 35 |
document.querySelectorAll('[data-eq-toggle], [data-eq-nav]')
|
|
|
|
| 38 |
|
| 39 |
// panel body: controls when detected, a note when not
|
| 40 |
el('eq-controls').classList.toggle('hidden', !detected);
|
| 41 |
+
el('eq-graphic').classList.toggle('hidden', !detected);
|
| 42 |
el('eq-note').classList.toggle('hidden', detected);
|
| 43 |
if (!detected) return;
|
| 44 |
|
|
|
|
| 54 |
sel.appendChild(opt);
|
| 55 |
});
|
| 56 |
|
| 57 |
+
// render the graphic EQ bands, but don't clobber sliders the user is dragging:
|
| 58 |
+
// only (re)build when the band set changed (open, or a band-count change)
|
| 59 |
+
const freqs = st.freqs || [];
|
| 60 |
+
if (el('eqBands').children.length !== freqs.length) renderBands(freqs, st.bands || []);
|
| 61 |
+
|
| 62 |
if (!wired) wire();
|
| 63 |
wired = true;
|
| 64 |
}
|
| 65 |
|
| 66 |
+
const fmtHz = (f) => (f >= 1000 ? `${f / 1000}k` : `${f}`);
|
| 67 |
+
const fmtGain = (v) => `${v > 0 ? '+' : ''}${Math.round(v)}`;
|
| 68 |
+
|
| 69 |
+
function renderBands(freqs, gains) {
|
| 70 |
+
const box = el('eqBands');
|
| 71 |
+
if (!box) return;
|
| 72 |
+
box.innerHTML = freqs.map((f, i) => `
|
| 73 |
+
<div class="eq-band">
|
| 74 |
+
<output class="eq-band-val" id="eqBandVal${i}">${fmtGain(gains[i] || 0)}</output>
|
| 75 |
+
<input type="range" class="eq-band-slider" id="eqBand${i}" data-i="${i}"
|
| 76 |
+
min="-12" max="12" step="1" value="${gains[i] || 0}">
|
| 77 |
+
<span class="eq-band-hz">${fmtHz(f)}</span>
|
| 78 |
+
</div>`).join('');
|
| 79 |
}
|
| 80 |
|
| 81 |
+
const readBands = () =>
|
| 82 |
+
[...document.querySelectorAll('.eq-band-slider')]
|
| 83 |
+
.sort((a, b) => a.dataset.i - b.dataset.i)
|
| 84 |
+
.map((s) => +s.value);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
+
let bandTimer = null;
|
| 87 |
+
const pushBands = () => {
|
| 88 |
+
clearTimeout(bandTimer);
|
| 89 |
+
bandTimer = setTimeout(() => post('/audio/eq/bands', { gains: readBands() }).catch(() => {}), 250);
|
| 90 |
+
};
|
| 91 |
|
| 92 |
function wire() {
|
| 93 |
el('eqEnabled').addEventListener('click', async () => {
|
|
|
|
| 104 |
post('/audio/eq/preset', { preset: el('eqPreset').value }).catch(() => {});
|
| 105 |
});
|
| 106 |
|
| 107 |
+
// one delegated listener on the strip: it survives every re-render of the bands
|
| 108 |
+
el('eqBands').addEventListener('input', (ev) => {
|
| 109 |
+
const s = ev.target.closest('.eq-band-slider');
|
| 110 |
+
if (!s) return;
|
| 111 |
+
const out = el('eqBandVal' + s.dataset.i);
|
| 112 |
+
if (out) out.textContent = fmtGain(+s.value); // live label, every frame
|
| 113 |
+
pushBands(); // reload EasyEffects once it settles
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
el('eqFlat').addEventListener('click', () => {
|
| 117 |
+
document.querySelectorAll('.eq-band-slider').forEach((s) => {
|
| 118 |
+
s.value = 0;
|
| 119 |
+
const out = el('eqBandVal' + s.dataset.i);
|
| 120 |
+
if (out) out.textContent = '0';
|
| 121 |
});
|
| 122 |
+
pushBands();
|
| 123 |
});
|
| 124 |
}
|
| 125 |
|
| 126 |
function open() {
|
| 127 |
+
closeOtherDrawers('eq'); // one drawer at a time (shared right edge)
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
const p = panel();
|
| 130 |
if (!p) return;
|
|
|
|
| 154 |
if (e.key === 'Escape' && eqPanel.isOpen) close();
|
| 155 |
});
|
| 156 |
|
| 157 |
+
// a click on the layout behind the panel closes it — not inside it, and not
|
| 158 |
+
// on ANY drawer opener (switching drawers; closing on pointerdown would shift
|
| 159 |
+
// the page and eat that opener's click), nor the burger's eq entry
|
| 160 |
document.addEventListener('pointerdown', (e) => {
|
| 161 |
if (!eqPanel.isOpen) return;
|
| 162 |
const p = panel();
|
| 163 |
+
if (p.contains(e.target) || e.target.closest(window.DRAWER_OPENERS + ',[data-eq-nav]')) return;
|
| 164 |
close();
|
| 165 |
});
|
| 166 |
|
reachy_web/static/js/camera.js
CHANGED
|
@@ -172,11 +172,13 @@ const cameraView = {
|
|
| 172 |
// it is visible on the immersive (page-coloured) background.
|
| 173 |
document.getElementById('cam-reset-btn')?.classList.toggle('hidden', twin);
|
| 174 |
document.getElementById('cam-close-btn')?.classList.toggle('hidden', twin);
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
| 180 |
if (cameraView.mode === '3d') cameraView.start3d();
|
| 181 |
else if (cameraView.mode === 'mjpeg') cameraView.startMjpeg();
|
| 182 |
else cameraView.startWebrtc();
|
|
@@ -241,6 +243,28 @@ const cameraView = {
|
|
| 241 |
e.img.removeAttribute('src');
|
| 242 |
},
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
startWebrtc: () => {
|
| 245 |
const e = cameraView.els();
|
| 246 |
cameraView.stop3d();
|
|
@@ -257,11 +281,18 @@ const cameraView = {
|
|
| 257 |
return;
|
| 258 |
}
|
| 259 |
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
-
cameraView.
|
| 263 |
-
|
| 264 |
-
if (producers.length > 0) cameraView.consume(producers[0].id);
|
| 265 |
|
| 266 |
// A booting robot takes far longer than the eight seconds we used to allow
|
| 267 |
// before giving up — which is why the twin kept stealing the stage from a
|
|
@@ -278,7 +309,11 @@ const cameraView = {
|
|
| 278 |
waitForVideo: () => {
|
| 279 |
clearTimeout(cameraView.webrtcTimer);
|
| 280 |
if (cameraView.mode !== 'webrtc' || !cameraView.openState) return;
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
if (typeof topbar !== 'undefined') topbar.setStatus('');
|
| 283 |
return;
|
| 284 |
}
|
|
@@ -295,9 +330,8 @@ const cameraView = {
|
|
| 295 |
}
|
| 296 |
|
| 297 |
cameraView.startEyes();
|
| 298 |
-
cameraView.
|
| 299 |
-
|
| 300 |
-
if (producers.length > 0) cameraView.consume(producers[0].id);
|
| 301 |
cameraView.webrtcTimer = setTimeout(cameraView.waitForVideo, 2000);
|
| 302 |
},
|
| 303 |
|
|
@@ -322,29 +356,79 @@ const cameraView = {
|
|
| 322 |
});
|
| 323 |
cameraView.api.registerProducersListener({
|
| 324 |
producerAdded: (producer) => {
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
// the mic waveform rides the SAME producer — tell it the instant one is
|
| 330 |
-
// announced (event-driven, like the desktop app's registerProducersListener)
|
| 331 |
-
// so it never has to catch the producer with a polled snapshot and miss it.
|
| 332 |
if (typeof waveform !== 'undefined') waveform.onProducer();
|
| 333 |
},
|
| 334 |
producerRemoved: () => {},
|
| 335 |
});
|
| 336 |
},
|
| 337 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
consume: (producerId) => {
|
|
|
|
| 339 |
const e = cameraView.els();
|
| 340 |
const session = cameraView.api.createConsumerSession(producerId);
|
| 341 |
if (!session) {
|
|
|
|
| 342 |
cameraView.setMsg(t('camera.webrtc_failed'));
|
| 343 |
return;
|
| 344 |
}
|
| 345 |
cameraView.session = session;
|
| 346 |
-
session.addEventListener('error', (ev) =>
|
|
|
|
|
|
|
|
|
|
| 347 |
session.addEventListener('closed', () => {
|
|
|
|
| 348 |
e.video.pause();
|
| 349 |
e.video.srcObject = null;
|
| 350 |
cameraView.session = null;
|
|
@@ -355,19 +439,27 @@ const cameraView = {
|
|
| 355 |
clearTimeout(cameraView.webrtcTimer); // video: stop waiting
|
| 356 |
cameraView.stopEyes();
|
| 357 |
if (typeof topbar !== 'undefined') topbar.setStatus('');
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
if (typeof waveform !== 'undefined') waveform.onStream(session.streams[0]);
|
| 368 |
-
}
|
| 369 |
});
|
| 370 |
session.connect();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
},
|
| 372 |
|
| 373 |
stopWebrtc: () => {
|
|
@@ -454,8 +546,62 @@ const cameraView = {
|
|
| 454 |
const has = d.state === 'running' && d.no_media === false;
|
| 455 |
const modes = document.getElementById('cam-modes');
|
| 456 |
if (modes) modes.classList.toggle('hidden', !has);
|
| 457 |
-
// the camera went away
|
| 458 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
},
|
| 460 |
|
| 461 |
syncModes: () => {
|
|
@@ -475,6 +621,27 @@ const cameraView = {
|
|
| 475 |
e.listenBtn.title = canListen ? t('camera.listen') : t('camera.listen_unavailable');
|
| 476 |
}
|
| 477 |
cameraView.syncListenIcon();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
},
|
| 479 |
|
| 480 |
// --- sound: muted by default, on demand through the robot mics ------------------
|
|
@@ -502,31 +669,18 @@ const cameraView = {
|
|
| 502 |
const e = cameraView.els();
|
| 503 |
cameraView.listening = false;
|
| 504 |
if (e.video) e.video.muted = true;
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
cameraView.audioSession = null;
|
| 508 |
-
}
|
| 509 |
if (e.audio) { e.audio.pause(); e.audio.srcObject = null; }
|
| 510 |
cameraView.syncListenIcon();
|
| 511 |
},
|
| 512 |
|
|
|
|
|
|
|
|
|
|
| 513 |
listenViaWebrtc: () => {
|
| 514 |
-
|
| 515 |
-
cameraView.
|
| 516 |
-
const producers = cameraView.api.getAvailableProducers();
|
| 517 |
-
if (!producers.length) return; // producer not up (yet)
|
| 518 |
-
const session = cameraView.api.createConsumerSession(producers[0].id);
|
| 519 |
-
if (!session) return;
|
| 520 |
-
cameraView.audioSession = session;
|
| 521 |
-
session.addEventListener('streamsChanged', () => {
|
| 522 |
-
const stream = session.streams[0];
|
| 523 |
-
if (!stream) return;
|
| 524 |
-
const audioOnly = new MediaStream(stream.getAudioTracks());
|
| 525 |
-
e.audio.srcObject = audioOnly;
|
| 526 |
-
e.audio.play().catch(() => {});
|
| 527 |
-
});
|
| 528 |
-
session.addEventListener('closed', () => { cameraView.audioSession = null; });
|
| 529 |
-
session.connect();
|
| 530 |
},
|
| 531 |
|
| 532 |
syncListenIcon: () => {
|
|
|
|
| 172 |
// it is visible on the immersive (page-coloured) background.
|
| 173 |
document.getElementById('cam-reset-btn')?.classList.toggle('hidden', twin);
|
| 174 |
document.getElementById('cam-close-btn')?.classList.toggle('hidden', twin);
|
| 175 |
+
// the "grab me" help button + first-time hint belong to the simulator only
|
| 176 |
+
document.getElementById('twin-help-btn')?.classList.toggle('hidden', !twin);
|
| 177 |
+
if (twin) cameraView.showTwinHint(false);
|
| 178 |
+
else cameraView.hideTwinHint();
|
| 179 |
+
// the catalogue (skins/moves/effects tabs) stays whatever the viewport shows:
|
| 180 |
+
// picking a skin no longer requires the simulator view — the choice persists
|
| 181 |
+
// and the twin wears it the next time it is on screen (viewer3d.setSkin)
|
| 182 |
if (cameraView.mode === '3d') cameraView.start3d();
|
| 183 |
else if (cameraView.mode === 'mjpeg') cameraView.startMjpeg();
|
| 184 |
else cameraView.startWebrtc();
|
|
|
|
| 243 |
e.img.removeAttribute('src');
|
| 244 |
},
|
| 245 |
|
| 246 |
+
// Show the video from the shared producer stream (set by whoever opened the
|
| 247 |
+
// session — the camera consume or the mic). One producer = one session, so
|
| 248 |
+
// both the picture and the mic read tracks off this single stream.
|
| 249 |
+
showSharedVideo: () => {
|
| 250 |
+
if (cameraView.mode === '3d' || !cameraView.openState) {
|
| 251 |
+
dbg(`[cam] showSharedVideo: not shown (mode=${cameraView.mode} open=${cameraView.openState})`);
|
| 252 |
+
return;
|
| 253 |
+
}
|
| 254 |
+
const s = cameraView.currentStream;
|
| 255 |
+
if (!s || !s.getVideoTracks().length) {
|
| 256 |
+
dbg(`[cam] showSharedVideo: no video track (stream=${!!s})`);
|
| 257 |
+
return;
|
| 258 |
+
}
|
| 259 |
+
const e = cameraView.els();
|
| 260 |
+
if (e.video.srcObject !== s) e.video.srcObject = s;
|
| 261 |
+
e.video.muted = !cameraView.listening;
|
| 262 |
+
e.video.play().catch(() => {});
|
| 263 |
+
cameraView.setMsg('');
|
| 264 |
+
if (typeof topbar !== 'undefined') topbar.setStatus('');
|
| 265 |
+
dbg('[cam] showSharedVideo: video shown ✓');
|
| 266 |
+
},
|
| 267 |
+
|
| 268 |
startWebrtc: () => {
|
| 269 |
const e = cameraView.els();
|
| 270 |
cameraView.stop3d();
|
|
|
|
| 281 |
return;
|
| 282 |
}
|
| 283 |
|
| 284 |
+
// The shared stream may already be flowing (the mic opened THE session first,
|
| 285 |
+
// on the twin, before we switched here): show its video, no new session.
|
| 286 |
+
dbg(`[cam] startWebrtc: session=${!!cameraView.session} currentStream=${!!cameraView.currentStream}`);
|
| 287 |
+
if (cameraView.currentStream && cameraView.currentStream.getVideoTracks().length) {
|
| 288 |
+
cameraView.showSharedVideo();
|
| 289 |
+
cameraView.waitStart = Date.now();
|
| 290 |
+
cameraView.waitForVideo(); // watchdog, in case it later drops
|
| 291 |
+
return;
|
| 292 |
+
}
|
| 293 |
|
| 294 |
+
cameraView.startEyes();
|
| 295 |
+
cameraView.ensureSession(); // open THE shared session (once)
|
|
|
|
| 296 |
|
| 297 |
// A booting robot takes far longer than the eight seconds we used to allow
|
| 298 |
// before giving up — which is why the twin kept stealing the stage from a
|
|
|
|
| 309 |
waitForVideo: () => {
|
| 310 |
clearTimeout(cameraView.webrtcTimer);
|
| 311 |
if (cameraView.mode !== 'webrtc' || !cameraView.openState) return;
|
| 312 |
+
// DONE only when the shared stream actually carries video (a session existing
|
| 313 |
+
// is not enough — it may be a stream still negotiating, and the old code
|
| 314 |
+
// stopped waiting too early and left the spinner up).
|
| 315 |
+
if (cameraView.currentStream && cameraView.currentStream.getVideoTracks().length) {
|
| 316 |
+
cameraView.showSharedVideo();
|
| 317 |
if (typeof topbar !== 'undefined') topbar.setStatus('');
|
| 318 |
return;
|
| 319 |
}
|
|
|
|
| 330 |
}
|
| 331 |
|
| 332 |
cameraView.startEyes();
|
| 333 |
+
cameraView.ensureSession();
|
| 334 |
+
dbg(`[cam] waitForVideo: still waiting (session=${!!cameraView.session} stream=${!!cameraView.currentStream})`);
|
|
|
|
| 335 |
cameraView.webrtcTimer = setTimeout(cameraView.waitForVideo, 2000);
|
| 336 |
},
|
| 337 |
|
|
|
|
| 356 |
});
|
| 357 |
cameraView.api.registerProducersListener({
|
| 358 |
producerAdded: (producer) => {
|
| 359 |
+
dbg('[cam] producerAdded:', producer.id, `mode=${cameraView.mode} open=${cameraView.openState}`);
|
| 360 |
+
// open THE shared session as soon as a producer appears and the viewport
|
| 361 |
+
// is up — the camera, the mic and listen all read off it.
|
| 362 |
+
if (cameraView.openState) cameraView.ensureSession();
|
| 363 |
// the mic waveform rides the SAME producer — tell it the instant one is
|
| 364 |
+
// announced (event-driven, like the desktop app's registerProducersListener).
|
|
|
|
| 365 |
if (typeof waveform !== 'undefined') waveform.onProducer();
|
| 366 |
},
|
| 367 |
producerRemoved: () => {},
|
| 368 |
});
|
| 369 |
},
|
| 370 |
|
| 371 |
+
// Rebuild the WebRTC api from scratch. Its signalling socket to the daemon dies
|
| 372 |
+
// when the robot powers off, and the recovered gstwebrtc-api does not
|
| 373 |
+
// re-enumerate producers on its own after that — so on power-ON it never sees
|
| 374 |
+
// the new producer and the viewport stays on the twin until a reload (a reload
|
| 375 |
+
// "fixing it" is exactly the tell: it just built a fresh api). Tearing it down
|
| 376 |
+
// on OFF means the next ensureApi() connects fresh and the camera returns.
|
| 377 |
+
resetApi: () => {
|
| 378 |
+
if (cameraView.session) { try { cameraView.session.close(); } catch (e) { /* gone */ } cameraView.session = null; }
|
| 379 |
+
if (cameraView.audioSession) { try { cameraView.audioSession.close(); } catch (e) { /* gone */ } cameraView.audioSession = null; }
|
| 380 |
+
cameraView.currentStream = null;
|
| 381 |
+
cameraView.api = null;
|
| 382 |
+
},
|
| 383 |
+
|
| 384 |
+
// The robot just came back (onState 'running'). Do exactly what a page reload
|
| 385 |
+
// does for the camera — the one thing that was known to fix "the camera does
|
| 386 |
+
// not return after OFF/ON": drop the stale WebRTC api, clear the one-shot
|
| 387 |
+
// guards (userPicked / switched) that a fresh load starts without, re-read
|
| 388 |
+
// /camera/status (mjpeg availability can change across a restart), then let the
|
| 389 |
+
// auto-switch probe again. No reload needed.
|
| 390 |
+
reprobe: () => {
|
| 391 |
+
cameraView.resetApi();
|
| 392 |
+
cameraView.userPicked = false;
|
| 393 |
+
if (typeof robotWake !== 'undefined') robotWake.switched = false;
|
| 394 |
+
fetch('/camera/status')
|
| 395 |
+
.then((r) => (r.ok ? r.json() : {}))
|
| 396 |
+
.then((s) => { cameraView.mjpegAvailable = !!s.mjpeg_available; })
|
| 397 |
+
.catch(() => {})
|
| 398 |
+
.finally(() => { if (typeof robotWake !== 'undefined') robotWake.probeCamera(); });
|
| 399 |
+
},
|
| 400 |
+
|
| 401 |
+
// THE single WebRTC consumer session on the daemon's ONE producer. Video AND
|
| 402 |
+
// audio (camera picture + mic + listen) all ride it — a second session on the
|
| 403 |
+
// same producer gets no media (that was the "video hangs, sound works" after
|
| 404 |
+
// OFF/ON: the mic had opened a rival session first). Everyone calls
|
| 405 |
+
// ensureSession and reads tracks off cameraView.currentStream; nobody opens
|
| 406 |
+
// their own. Created on demand by whoever needs the stream first.
|
| 407 |
+
ensureSession: () => {
|
| 408 |
+
if (cameraView.session) return;
|
| 409 |
+
cameraView.ensureApi();
|
| 410 |
+
const producers = cameraView.api ? cameraView.api.getAvailableProducers() : [];
|
| 411 |
+
if (!producers.length) { dbg('[cam] ensureSession: no producer yet'); return; }
|
| 412 |
+
dbg('[cam] ensureSession: creating THE session on', producers[0].id);
|
| 413 |
+
cameraView.consume(producers[0].id);
|
| 414 |
+
},
|
| 415 |
+
|
| 416 |
consume: (producerId) => {
|
| 417 |
+
if (cameraView.session) { dbg('[cam] consume: session already open, skip'); return; }
|
| 418 |
const e = cameraView.els();
|
| 419 |
const session = cameraView.api.createConsumerSession(producerId);
|
| 420 |
if (!session) {
|
| 421 |
+
console.warn('[cam] consume: createConsumerSession returned null');
|
| 422 |
cameraView.setMsg(t('camera.webrtc_failed'));
|
| 423 |
return;
|
| 424 |
}
|
| 425 |
cameraView.session = session;
|
| 426 |
+
session.addEventListener('error', (ev) => {
|
| 427 |
+
console.warn('[cam] session error:', ev && ev.message);
|
| 428 |
+
cameraView.setMsg(t('camera.webrtc_error', { message: ev.message }));
|
| 429 |
+
});
|
| 430 |
session.addEventListener('closed', () => {
|
| 431 |
+
dbg('[cam] session closed');
|
| 432 |
e.video.pause();
|
| 433 |
e.video.srcObject = null;
|
| 434 |
cameraView.session = null;
|
|
|
|
| 439 |
clearTimeout(cameraView.webrtcTimer); // video: stop waiting
|
| 440 |
cameraView.stopEyes();
|
| 441 |
if (typeof topbar !== 'undefined') topbar.setStatus('');
|
| 442 |
+
const stream = session.streams[0];
|
| 443 |
+
dbg(`[cam] streamsChanged: streams=${session.streams.length} video=${stream ? stream.getVideoTracks().length : 0} audio=${stream ? stream.getAudioTracks().length : 0}`);
|
| 444 |
+
if (!stream) return;
|
| 445 |
+
// ONE stream, shared: show the picture (if on the camera view), feed the
|
| 446 |
+
// listen audio, and hand the audio track to the mic waveform
|
| 447 |
+
cameraView.currentStream = stream;
|
| 448 |
+
cameraView.showSharedVideo();
|
| 449 |
+
if (cameraView.listening) cameraView.playListen();
|
| 450 |
+
if (typeof waveform !== 'undefined') waveform.onStream(stream);
|
|
|
|
|
|
|
| 451 |
});
|
| 452 |
session.connect();
|
| 453 |
+
dbg('[cam] consume: session.connect() called');
|
| 454 |
+
},
|
| 455 |
+
|
| 456 |
+
// play the shared stream's audio into the (muted-by-default) listen element
|
| 457 |
+
playListen: () => {
|
| 458 |
+
const e = cameraView.els();
|
| 459 |
+
const s = cameraView.currentStream;
|
| 460 |
+
if (!cameraView.listening || !s || !s.getAudioTracks().length || !e.audio) return;
|
| 461 |
+
e.audio.srcObject = new MediaStream(s.getAudioTracks());
|
| 462 |
+
e.audio.play().catch(() => {});
|
| 463 |
},
|
| 464 |
|
| 465 |
stopWebrtc: () => {
|
|
|
|
| 546 |
const has = d.state === 'running' && d.no_media === false;
|
| 547 |
const modes = document.getElementById('cam-modes');
|
| 548 |
if (modes) modes.classList.toggle('hidden', !has);
|
| 549 |
+
// the camera went away (robot powered off, media stack dropped): fall back
|
| 550 |
+
// to the twin, and RE-ARM the auto-switch so the camera returns on its own
|
| 551 |
+
// when the robot comes back. This is the fix for "the camera does not
|
| 552 |
+
// reconnect after OFF/ON, I have to reload": a single earlier click on the
|
| 553 |
+
// Camera/3D toggle set userPicked for the whole session, and probeCamera()
|
| 554 |
+
// bails on userPicked, so the viewport stayed on the twin until a reload.
|
| 555 |
+
// A reload works precisely because it starts fresh with userPicked=false;
|
| 556 |
+
// clearing it here does the same without the reload. We only clear when the
|
| 557 |
+
// camera actually VANISHED (this !has branch), so a deliberate 3D choice
|
| 558 |
+
// made while the robot keeps running is still honoured.
|
| 559 |
+
if (!has && cameraView.mode !== '3d') {
|
| 560 |
+
cameraView.setMode('3d', false);
|
| 561 |
+
cameraView.userPicked = false;
|
| 562 |
+
if (typeof robotWake !== 'undefined') robotWake.switched = false;
|
| 563 |
+
}
|
| 564 |
+
// robot powered OFF (not merely media-released while running — that would
|
| 565 |
+
// fight the mic's own use of the api): drop the stale WebRTC api so a fresh
|
| 566 |
+
// one is built when the robot returns, else the camera only comes back on a
|
| 567 |
+
// reload. api is nulled, so this fires once, not every poll.
|
| 568 |
+
const off = d.state !== 'running' && d.state !== 'starting';
|
| 569 |
+
if (off && cameraView.api) cameraView.resetApi();
|
| 570 |
+
// keep the LIVE / SIM flag in step with the power on every poll
|
| 571 |
+
cameraView.updateSimFlag();
|
| 572 |
+
},
|
| 573 |
+
|
| 574 |
+
// --- simulator "grab me" hint --------------------------------------------
|
| 575 |
+
//
|
| 576 |
+
// The twin is draggable (head, body, antennas — viewer3d.bindHandles) but
|
| 577 |
+
// nothing on screen says so. The first time the simulator is shown we play a
|
| 578 |
+
// little animated hand that explains it; the top-right ? button replays it on
|
| 579 |
+
// demand. Only the twin has anything to grab, so the button and the hint live
|
| 580 |
+
// on the 3D view alone (startLive toggles them).
|
| 581 |
+
twinHintTimer: null,
|
| 582 |
+
twinHintSeen: () => {
|
| 583 |
+
try { return localStorage.getItem('twin-hint-seen') === '1'; } catch (e) { return false; }
|
| 584 |
+
},
|
| 585 |
+
|
| 586 |
+
showTwinHint: (force) => {
|
| 587 |
+
const hint = document.getElementById('twin-hint');
|
| 588 |
+
if (!hint) return;
|
| 589 |
+
if (!force && cameraView.twinHintSeen()) return; // auto-shows once, ever
|
| 590 |
+
try { localStorage.setItem('twin-hint-seen', '1'); } catch (e) { /* private mode */ }
|
| 591 |
+
clearTimeout(cameraView.twinHintTimer);
|
| 592 |
+
hint.classList.remove('hidden');
|
| 593 |
+
void hint.offsetWidth; // reflow: re-showing restarts the fade-in
|
| 594 |
+
hint.classList.add('show');
|
| 595 |
+
// auto-dismiss after the hand has looped a few times
|
| 596 |
+
cameraView.twinHintTimer = setTimeout(cameraView.hideTwinHint, 8000);
|
| 597 |
+
},
|
| 598 |
+
|
| 599 |
+
hideTwinHint: () => {
|
| 600 |
+
clearTimeout(cameraView.twinHintTimer);
|
| 601 |
+
const hint = document.getElementById('twin-hint');
|
| 602 |
+
if (!hint) return;
|
| 603 |
+
hint.classList.remove('show');
|
| 604 |
+
setTimeout(() => hint.classList.add('hidden'), 320); // after the fade-out
|
| 605 |
},
|
| 606 |
|
| 607 |
syncModes: () => {
|
|
|
|
| 621 |
e.listenBtn.title = canListen ? t('camera.listen') : t('camera.listen_unavailable');
|
| 622 |
}
|
| 623 |
cameraView.syncListenIcon();
|
| 624 |
+
cameraView.updateSimFlag();
|
| 625 |
+
},
|
| 626 |
+
|
| 627 |
+
// The LIVE / SIM flag over the simulator: green (a real robot's joint stream
|
| 628 |
+
// is mirrored) vs amber (pure simulation). Only on the 3D view — on the camera
|
| 629 |
+
// view the picture is self-evidently the real feed. Called on every mode change
|
| 630 |
+
// and whenever the joint stream comes or goes (from viewer3d).
|
| 631 |
+
updateSimFlag: () => {
|
| 632 |
+
const flag = document.getElementById('cam-sim-flag');
|
| 633 |
+
if (!flag) return;
|
| 634 |
+
const on3d = cameraView.mode === '3d' && cameraView.openState;
|
| 635 |
+
const live = typeof viewer3d !== 'undefined' && viewer3d.streamLive();
|
| 636 |
+
flag.classList.toggle('hidden', !on3d);
|
| 637 |
+
flag.classList.toggle('flex', on3d);
|
| 638 |
+
if (!on3d) return;
|
| 639 |
+
const dot = document.getElementById('cam-sim-dot');
|
| 640 |
+
const txt = document.getElementById('cam-sim-text');
|
| 641 |
+
if (dot) dot.className = 'w-2.5 h-2.5 rounded-full ' + (live ? 'bg-green-500 animate-pulse' : 'bg-yellow-500');
|
| 642 |
+
// the sim label reuses the mode name (camera.mode_3d = "Simulateur"), so the
|
| 643 |
+
// flag and the switch always say the same thing
|
| 644 |
+
if (txt) txt.textContent = live ? t('camera.flag_live') : t('camera.mode_3d');
|
| 645 |
},
|
| 646 |
|
| 647 |
// --- sound: muted by default, on demand through the robot mics ------------------
|
|
|
|
| 669 |
const e = cameraView.els();
|
| 670 |
cameraView.listening = false;
|
| 671 |
if (e.video) e.video.muted = true;
|
| 672 |
+
// just stop playing the audio; the shared session stays (the picture and the
|
| 673 |
+
// mic still need it) — we no longer own a separate audio session
|
|
|
|
|
|
|
| 674 |
if (e.audio) { e.audio.pause(); e.audio.srcObject = null; }
|
| 675 |
cameraView.syncListenIcon();
|
| 676 |
},
|
| 677 |
|
| 678 |
+
// Listen now rides THE shared session too (no rival audio session): make sure
|
| 679 |
+
// the session is up, then play its audio. If the stream is not here yet, the
|
| 680 |
+
// session's streamsChanged calls playListen() when it lands.
|
| 681 |
listenViaWebrtc: () => {
|
| 682 |
+
cameraView.ensureSession();
|
| 683 |
+
cameraView.playListen();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
},
|
| 685 |
|
| 686 |
syncListenIcon: () => {
|
reachy_web/static/js/controller.js
CHANGED
|
@@ -14,6 +14,26 @@
|
|
| 14 |
// completely silent — an idle panel costs the daemon nothing.
|
| 15 |
//
|
| 16 |
// Closing the panel walks the robot back to its rest pose, as the desktop does.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
const controller = {
|
| 18 |
THROTTLE: 50, // ms — the desktop's SEND_THROTTLE, ~20 Hz on the wire
|
| 19 |
SETTLED: 0.01, // every axis this close to target → stop talking
|
|
@@ -34,8 +54,13 @@ const controller = {
|
|
| 34 |
// That coupling is the one nothing enforced: yaw 68° with the body at 160°
|
| 35 |
// asks for 92° of neck, which no Stewart platform here can give.
|
| 36 |
// Radians and metres.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
LIMITS: {
|
| 38 |
-
x: 0.
|
| 39 |
z: { min: -0.05, max: 0.02 },
|
| 40 |
roll: 0.7854, // 45°
|
| 41 |
pitch: 0.7854, // 45°
|
|
@@ -56,23 +81,32 @@ const controller = {
|
|
| 56 |
current: null, // where the smoother actually is
|
| 57 |
lastGood: null, // the last pose the robot's own IK could actually solve
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
// --- panel ------------------------------------------------------------
|
| 60 |
|
| 61 |
toggle: () => (controller.isOpen ? controller.close() : controller.open()),
|
| 62 |
|
| 63 |
open: () => {
|
| 64 |
-
// one drawer at a time
|
| 65 |
-
// simply cover each other
|
| 66 |
-
if (typeof daemonLogs !== 'undefined' && daemonLogs.isOpen && !daemonLogs.inline) {
|
| 67 |
-
daemonLogs.closePanel();
|
| 68 |
-
}
|
| 69 |
controller.build();
|
| 70 |
if (!controller.root.isConnected) document.body.appendChild(controller.root);
|
| 71 |
requestAnimationFrame(() => controller.root.classList.add('open'));
|
| 72 |
document.body.classList.add('cp-open');
|
| 73 |
controller.isOpen = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
controller.connect();
|
| 75 |
controller.loop();
|
|
|
|
| 76 |
},
|
| 77 |
|
| 78 |
close: () => {
|
|
@@ -91,59 +125,119 @@ const controller = {
|
|
| 91 |
},
|
| 92 |
|
| 93 |
// --- widgets ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
<
|
| 97 |
-
<
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
// a limit is a number (symmetric) or {min, max} — z is not symmetric
|
| 141 |
lo: (lim) => (typeof lim === 'object' ? lim.min : -lim),
|
| 142 |
hi: (lim) => (typeof lim === 'object' ? lim.max : lim),
|
| 143 |
|
| 144 |
-
slider: (key, label, limit, step) => `
|
| 145 |
<label class="cp-row">
|
| 146 |
-
<span class="cp-label">${label}</span>
|
| 147 |
<input type="range" data-key="${key}" min="${controller.lo(limit)}" max="${controller.hi(limit)}" step="${step}" value="0">
|
| 148 |
<output class="cp-val">0</output>
|
| 149 |
</label>`,
|
|
@@ -168,30 +262,71 @@ const controller = {
|
|
| 168 |
</div>
|
| 169 |
|
| 170 |
<div class="cp-body">
|
| 171 |
-
<
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
</div>
|
| 176 |
|
| 177 |
-
<
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
</div>
|
| 182 |
|
| 183 |
-
<
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
</div>
|
| 187 |
|
| 188 |
-
<
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
${
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
</div>
|
| 193 |
-
|
| 194 |
-
<p class="cp-hint">${t('control.look_hint')}</p>
|
| 195 |
</div>`;
|
| 196 |
|
| 197 |
controller.root = el;
|
|
@@ -204,14 +339,16 @@ const controller = {
|
|
| 204 |
// sliders), nor on the topbar button that toggles it. Escape closes it too.
|
| 205 |
document.addEventListener('pointerdown', (ev) => {
|
| 206 |
if (!controller.isOpen) return;
|
| 207 |
-
if (el.contains(ev.target) || ev.target.closest(
|
| 208 |
controller.close();
|
| 209 |
});
|
| 210 |
document.addEventListener('keydown', (ev) => {
|
| 211 |
if (ev.key === 'Escape' && controller.isOpen) controller.close();
|
| 212 |
});
|
| 213 |
|
| 214 |
-
|
|
|
|
|
|
|
| 215 |
const out = input.parentElement.querySelector('.cp-val');
|
| 216 |
const show = () => {
|
| 217 |
const v = parseFloat(input.value);
|
|
@@ -220,58 +357,245 @@ const controller = {
|
|
| 220 |
input.addEventListener('input', () => {
|
| 221 |
controller.target[input.dataset.key] = parseFloat(input.value);
|
| 222 |
show();
|
|
|
|
| 223 |
});
|
| 224 |
show();
|
| 225 |
});
|
| 226 |
|
| 227 |
-
controller.
|
| 228 |
-
|
| 229 |
-
controller.
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
//
|
| 234 |
-
//
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
});
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
};
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
},
|
| 276 |
|
| 277 |
// --- the wire ---------------------------------------------------------
|
|
@@ -312,10 +636,21 @@ const controller = {
|
|
| 312 |
controller.raf = requestAnimationFrame(controller.loop);
|
| 313 |
|
| 314 |
const c = controller.current, tg = controller.target, k = controller.EMA;
|
| 315 |
-
// The EMA is a mechanical-safety feature: it turns a yanked joystick into a
|
| 316 |
-
// sweep the real robot can follow. The TWIN is just a picture — no motor to
|
| 317 |
-
// protect — so in the simulator we snap (factor 1) and it tracks the finger.
|
| 318 |
const sim = !controller.robotReady();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
const ema = (key, f) => { c[key] += (tg[key] - c[key]) * (sim ? 1 : f); };
|
| 320 |
['x', 'y', 'z'].forEach((key) => ema(key, k.pos));
|
| 321 |
['pitch', 'yaw', 'roll'].forEach((key) => ema(key, k.rot));
|
|
@@ -323,14 +658,12 @@ const controller = {
|
|
| 323 |
ema('ant_l', k.ant);
|
| 324 |
ema('ant_r', k.ant);
|
| 325 |
|
| 326 |
-
//
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
if (settled) return;
|
| 331 |
-
|
| 332 |
const now = Date.now();
|
| 333 |
-
if (now - controller.lastSend < controller.THROTTLE) return;
|
| 334 |
controller.lastSend = now;
|
| 335 |
controller.send();
|
| 336 |
},
|
|
@@ -407,6 +740,10 @@ const controller = {
|
|
| 407 |
// one returns: the twin always catches up to where you actually are.
|
| 408 |
driveSim: (body) => {
|
| 409 |
if (typeof viewer3d === 'undefined' || !viewer3d.robot) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
if (controller.simBusy) { controller.simNext = body; return; }
|
| 411 |
controller.simBusy = true;
|
| 412 |
controller._simSend(body);
|
|
@@ -450,15 +787,11 @@ const controller = {
|
|
| 450 |
},
|
| 451 |
|
| 452 |
// Rest is not a command: we ramp the TARGET to zero and let the smoother walk
|
| 453 |
-
// the robot home, so it never snaps.
|
| 454 |
rest: () => {
|
| 455 |
if (!controller.root) return;
|
| 456 |
controller.target = controller.ZERO();
|
| 457 |
-
controller.
|
| 458 |
-
input.value = 0;
|
| 459 |
-
input.dispatchEvent(new Event('input'));
|
| 460 |
-
});
|
| 461 |
-
controller.root.querySelectorAll('.cp-pad').forEach((pad) => pad._reset());
|
| 462 |
},
|
| 463 |
|
| 464 |
// --- look at ----------------------------------------------------------
|
|
@@ -495,6 +828,7 @@ const controller = {
|
|
| 495 |
|
| 496 |
// Feed the smoother from outside the panel (the panel need not even exist).
|
| 497 |
drive: (pose) => {
|
|
|
|
| 498 |
controller.ensureStream();
|
| 499 |
Object.assign(controller.target, pose);
|
| 500 |
if (controller.root) controller.syncWidgets();
|
|
@@ -511,28 +845,20 @@ const controller = {
|
|
| 511 |
if (!controller.raf) controller.loop();
|
| 512 |
},
|
| 513 |
|
| 514 |
-
// Whatever moved the target (a click in the image, a reset), the
|
| 515 |
-
// to say the same thing as the robot.
|
| 516 |
syncWidgets: () => {
|
| 517 |
-
controller.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
const v = controller.target[input.dataset.key];
|
| 519 |
-
if (v ==
|
| 520 |
-
input.value = v;
|
| 521 |
const out = input.parentElement.querySelector('.cp-val');
|
| 522 |
if (out) out.textContent = input.step === '0.001' ? v.toFixed(3) : v.toFixed(2);
|
| 523 |
});
|
| 524 |
-
const place = (padKey, keyX, keyY, limX, limY) => {
|
| 525 |
-
const pad = controller.root.querySelector(`[data-pad=${padKey}]`);
|
| 526 |
-
if (!pad) return;
|
| 527 |
-
const knob = pad.querySelector('.cp-pad-knob');
|
| 528 |
-
const nx = Math.max(-1, Math.min(1, controller.target[keyX] / limX));
|
| 529 |
-
const ny = Math.max(-1, Math.min(1, -controller.target[keyY] / limY));
|
| 530 |
-
knob.style.left = `${((nx + 1) / 2) * 100}%`;
|
| 531 |
-
knob.style.top = `${((ny + 1) / 2) * 100}%`;
|
| 532 |
-
};
|
| 533 |
-
const L = controller.LIMITS;
|
| 534 |
-
place('pos', 'y', 'x', L.y, L.x);
|
| 535 |
-
place('rot', 'yaw', 'pitch', L.yaw, L.pitch);
|
| 536 |
},
|
| 537 |
|
| 538 |
// Standing back up is NOT the rest pose. A folded robot (asleep, or left that
|
|
@@ -562,8 +888,214 @@ const controller = {
|
|
| 562 |
resetPose: () => {
|
| 563 |
controller.ensureStream();
|
| 564 |
controller.target = controller.ZERO();
|
| 565 |
-
if (controller.root) controller.
|
| 566 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
},
|
| 568 |
|
| 569 |
ping: (x, y, el) => {
|
|
|
|
| 14 |
// completely silent — an idle panel costs the daemon nothing.
|
| 15 |
//
|
| 16 |
// Closing the panel walks the robot back to its rest pose, as the desktop does.
|
| 17 |
+
|
| 18 |
+
// All the slide-in drawers (controller, settings, logs, equalizer) share the
|
| 19 |
+
// right edge, so only one is open at a time, and clicking one opener while
|
| 20 |
+
// another drawer is open must SWITCH, not merely close. Two rules make that
|
| 21 |
+
// reliable, shared here (controller.js loads first, so every other drawer can
|
| 22 |
+
// use these globals):
|
| 23 |
+
// 1. a drawer's outside-click-to-close IGNORES clicks on ANY drawer opener —
|
| 24 |
+
// otherwise it closes on the pointerdown, and on a wide screen the
|
| 25 |
+
// `padding-right` it drops shifts the whole page LEFT out from under the
|
| 26 |
+
// pointer, so the opener never receives its `click` and the new drawer
|
| 27 |
+
// never opens (the "it just closes" bug);
|
| 28 |
+
// 2. every open() closes the others.
|
| 29 |
+
window.DRAWER_OPENERS = '[data-controller-toggle],[data-settings-toggle],[data-logs-toggle],[data-eq-toggle]';
|
| 30 |
+
window.closeOtherDrawers = (except) => {
|
| 31 |
+
if (except !== 'controller' && typeof controller !== 'undefined' && controller.isOpen) controller.close();
|
| 32 |
+
if (except !== 'settings' && typeof settingsPanel !== 'undefined' && settingsPanel.isOpen) settingsPanel.close();
|
| 33 |
+
if (except !== 'logs' && typeof daemonLogs !== 'undefined' && daemonLogs.isOpen && !daemonLogs.inline) daemonLogs.closePanel();
|
| 34 |
+
if (except !== 'eq' && typeof eqPanel !== 'undefined' && eqPanel.isOpen) eqPanel.close();
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
const controller = {
|
| 38 |
THROTTLE: 50, // ms — the desktop's SEND_THROTTLE, ~20 Hz on the wire
|
| 39 |
SETTLED: 0.01, // every axis this close to target → stop talking
|
|
|
|
| 54 |
// That coupling is the one nothing enforced: yaw 68° with the body at 160°
|
| 55 |
// asks for 92° of neck, which no Stewart platform here can give.
|
| 56 |
// Radians and metres.
|
| 57 |
+
// · the OFFICIAL axis table (private_docs/resources/IMG_5025.PNG, Pollen's
|
| 58 |
+
// own doc) gives the head's translation box: Tx -1.5…+2.5 cm, Ty ±4 cm —
|
| 59 |
+
// seen from above the reachable area is a wide RECTANGLE, offset forward,
|
| 60 |
+
// and the position pad draws exactly that box. (Both sit inside the
|
| 61 |
+
// solver-bisected edges above, so every pad pose still solves.)
|
| 62 |
LIMITS: {
|
| 63 |
+
x: { min: -0.015, max: 0.025 }, y: 0.04,
|
| 64 |
z: { min: -0.05, max: 0.02 },
|
| 65 |
roll: 0.7854, // 45°
|
| 66 |
pitch: 0.7854, // 45°
|
|
|
|
| 81 |
current: null, // where the smoother actually is
|
| 82 |
lastGood: null, // the last pose the robot's own IK could actually solve
|
| 83 |
|
| 84 |
+
// movement sequence (keyframes): each captured pose is one step; the list
|
| 85 |
+
// replays, steps back/forth, and saves to the marionette / RecordedMoves json
|
| 86 |
+
seq: [],
|
| 87 |
+
seqIdx: -1,
|
| 88 |
+
seqPlaying: false,
|
| 89 |
+
seqLastCap: 0, // ms of the last capture, for natural replay timing
|
| 90 |
+
|
| 91 |
// --- panel ------------------------------------------------------------
|
| 92 |
|
| 93 |
toggle: () => (controller.isOpen ? controller.close() : controller.open()),
|
| 94 |
|
| 95 |
open: () => {
|
| 96 |
+
closeOtherDrawers('controller'); // one drawer at a time (shared right edge)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
controller.build();
|
| 98 |
if (!controller.root.isConnected) document.body.appendChild(controller.root);
|
| 99 |
requestAnimationFrame(() => controller.root.classList.add('open'));
|
| 100 |
document.body.classList.add('cp-open');
|
| 101 |
controller.isOpen = true;
|
| 102 |
+
// stop the idle greeting AT ONCE: it also poses the twin via applyFrame, and
|
| 103 |
+
// if it is still running when you drag, the two sources alternate and the
|
| 104 |
+
// twin flickers between the sway pose and yours. (idle disarms itself on its
|
| 105 |
+
// next tick anyway, but not before a few contended frames.)
|
| 106 |
+
if (typeof idle !== 'undefined') idle.disarm();
|
| 107 |
controller.connect();
|
| 108 |
controller.loop();
|
| 109 |
+
controller.probeEyes();
|
| 110 |
},
|
| 111 |
|
| 112 |
close: () => {
|
|
|
|
| 125 |
},
|
| 126 |
|
| 127 |
// --- widgets ----------------------------------------------------------
|
| 128 |
+
//
|
| 129 |
+
// THE PUPPET (Pierre's pick among the affordance designs, private_docs/
|
| 130 |
+
// controller-affordance.html): no joysticks, no legend — the robot itself,
|
| 131 |
+
// front and side, and every part is the control for what it moves. The art
|
| 132 |
+
// mixes the house hand-drawn family with the cartoon reference (private_docs/
|
| 133 |
+
// resources/reachy-mini-cartoon.png): thick ink lines, cream shells, big
|
| 134 |
+
// glossy LED eyes, spring sleeves on the antennas — over REAL proportions
|
| 135 |
+
// (the body is the tall part). Ink strokes ride a cream "sticker rim" so the
|
| 136 |
+
// dark parts (antennas, foot, lens) stay readable on a dark theme.
|
| 137 |
+
//
|
| 138 |
+
// What grabs what (bindPuppet):
|
| 139 |
+
// front head → sideways + height (the two axes you SEE from the front)
|
| 140 |
+
// camera dot → the gaze (yaw + pitch — the camera is what looks)
|
| 141 |
+
// antennas → their angles, pivoting where they leave the skull
|
| 142 |
+
// body → body rotation (slide it sideways)
|
| 143 |
+
// eyes → the LED color ring (they ARE the picker)
|
| 144 |
+
// side head → forward/back + height (the axes you see from the side)
|
| 145 |
+
// side lens → pitch (nodding is THE profile gesture)
|
| 146 |
+
// Roll has no honest handle on either view: it lives in the precision fold.
|
| 147 |
|
| 148 |
+
puppetFront: () => `
|
| 149 |
+
<svg viewBox="0 0 240 360" id="cp-p-front">
|
| 150 |
+
<g id="cp-pf-head" data-grab="head">
|
| 151 |
+
<g id="cp-pf-antl" data-grab="antl">
|
| 152 |
+
<path class="pk-rim" stroke-width="12" d="M82 56 L58 6"/>
|
| 153 |
+
<path class="pk-ink" stroke-width="6" d="M82 56 L58 6"/>
|
| 154 |
+
<circle cx="58" cy="6" r="6.6" fill="#efe9dd"/><circle cx="58" cy="6" r="5.2" fill="#1b2330"/>
|
| 155 |
+
<rect x="63" y="33" width="15" height="28" rx="7" class="pk-sleeve" transform="rotate(25.7 70.5 47)"/>
|
| 156 |
+
</g>
|
| 157 |
+
<g id="cp-pf-antr" data-grab="antr">
|
| 158 |
+
<path class="pk-rim" stroke-width="12" d="M158 56 L178 16"/>
|
| 159 |
+
<path class="pk-ink" stroke-width="6" d="M158 56 L178 16"/>
|
| 160 |
+
<circle cx="178" cy="16" r="5.6" fill="#efe9dd"/><circle cx="178" cy="16" r="4.3" fill="#1b2330"/>
|
| 161 |
+
<rect x="157" y="31" width="15" height="26" rx="7" class="pk-sleeve" transform="rotate(-26.6 164.5 44)"/>
|
| 162 |
+
</g>
|
| 163 |
+
<rect x="34" y="50" width="172" height="112" rx="42" fill="#efe9dd" stroke="#1b2330" stroke-width="7"/>
|
| 164 |
+
<path d="M56 80 Q62 62 90 57" class="pk-gloss"/>
|
| 165 |
+
<rect x="112" y="102" width="36" height="15" rx="7.5" fill="#20242e"/>
|
| 166 |
+
<g id="cp-pf-eyes">
|
| 167 |
+
<g data-eyes-open="1">
|
| 168 |
+
<circle cx="90" cy="110" r="32" fill="#20242e"/>
|
| 169 |
+
<circle class="pk-iris" cx="90" cy="110" r="25.4"/>
|
| 170 |
+
<ellipse cx="90" cy="126" rx="14" ry="6.5" fill="#8fa0bd" opacity=".33"/>
|
| 171 |
+
<circle cx="101" cy="98" r="8" fill="#fff" opacity=".95"/>
|
| 172 |
+
<circle cx="80" cy="117" r="3.4" fill="#fff" opacity=".8"/>
|
| 173 |
+
</g>
|
| 174 |
+
<g data-eyes-open="1">
|
| 175 |
+
<circle cx="168" cy="110" r="27.5" fill="#20242e"/>
|
| 176 |
+
<circle class="pk-iris" cx="168" cy="110" r="21.4"/>
|
| 177 |
+
<ellipse cx="168" cy="123" rx="12" ry="5.6" fill="#8fa0bd" opacity=".33"/>
|
| 178 |
+
<circle cx="177" cy="99" r="6.8" fill="#fff" opacity=".95"/>
|
| 179 |
+
<circle cx="160" cy="116" r="2.8" fill="#fff" opacity=".8"/>
|
| 180 |
+
</g>
|
| 181 |
+
</g>
|
| 182 |
+
<g id="cp-pf-cam" data-grab="gaze">
|
| 183 |
+
<circle cx="130" cy="109" r="10" fill="transparent"/>
|
| 184 |
+
<circle cx="130" cy="109" r="7.5" fill="#39414f"/>
|
| 185 |
+
<circle cx="130" cy="109" r="3.2" fill="#cbd5e1"/>
|
| 186 |
+
</g>
|
| 187 |
+
</g>
|
| 188 |
+
<g data-grab="body">
|
| 189 |
+
<ellipse cx="120" cy="174" rx="44" ry="11" fill="#1b2330" stroke="#efe9dd" stroke-width="2.5"/>
|
| 190 |
+
<path d="M76 178 Q66 178 63 192 Q50 232 50 268 Q50 306 84 309 L156 309 Q190 306 190 268 Q190 232 177 192 Q174 178 164 178 Z"
|
| 191 |
+
fill="#efe9dd" stroke="#1b2330" stroke-width="6.5" stroke-linejoin="round"/>
|
| 192 |
+
<path d="M70 198 Q60 256 68 286" class="pk-gloss" stroke-opacity=".65"/>
|
| 193 |
+
<path d="M55 288 L185 288" stroke="#1b2330" stroke-width="2.4" opacity=".3"/>
|
| 194 |
+
<circle id="cp-pf-seam" cx="120" cy="232" r="4.5" fill="#1b2330" opacity=".35"/>
|
| 195 |
+
<path d="M56 303 Q56 322 84 322 L156 322 Q184 322 184 303 L184 300 L56 300 Z"
|
| 196 |
+
fill="#1b2330" stroke="#efe9dd" stroke-width="2.5"/>
|
| 197 |
+
</g>
|
| 198 |
+
</svg>`,
|
| 199 |
+
|
| 200 |
+
puppetSide: () => `
|
| 201 |
+
<!-- same 240x360 frame as the front (content 210 wide, centred by the -15
|
| 202 |
+
left offset) so both stages render at the SAME height side by side -->
|
| 203 |
+
<svg viewBox="-15 0 240 360" id="cp-p-side">
|
| 204 |
+
<g id="cp-ps-head" data-grab="sidehead">
|
| 205 |
+
<g opacity=".3">
|
| 206 |
+
<path class="pk-rim" stroke-width="9" d="M78 54 L62 8"/>
|
| 207 |
+
<path class="pk-ink" stroke-width="4.5" d="M78 54 L62 8"/>
|
| 208 |
+
<circle cx="62" cy="8" r="4.2" fill="#1b2330"/>
|
| 209 |
+
</g>
|
| 210 |
+
<path class="pk-rim" stroke-width="12" d="M64 56 L44 10"/>
|
| 211 |
+
<path class="pk-ink" stroke-width="6" d="M64 56 L44 10"/>
|
| 212 |
+
<circle cx="44" cy="10" r="6.6" fill="#efe9dd"/><circle cx="44" cy="10" r="5.2" fill="#1b2330"/>
|
| 213 |
+
<rect x="46" y="31" width="15" height="28" rx="7" class="pk-sleeve" transform="rotate(23.5 53.5 45)"/>
|
| 214 |
+
<rect x="22" y="50" width="132" height="112" rx="38" fill="#efe9dd" stroke="#1b2330" stroke-width="7"/>
|
| 215 |
+
<path d="M42 80 Q48 62 74 57" class="pk-gloss"/>
|
| 216 |
+
<g id="cp-ps-lens" data-grab="pitch">
|
| 217 |
+
<rect x="142" y="90" width="22" height="40" rx="8" fill="#20242e" stroke="#efe9dd" stroke-width="3"/>
|
| 218 |
+
<ellipse cx="163" cy="110" rx="9" ry="18" fill="#20242e" stroke="#efe9dd" stroke-width="3"/>
|
| 219 |
+
<ellipse class="pk-iris" cx="164" cy="110" rx="5.6" ry="13.5"/>
|
| 220 |
+
<ellipse cx="165.5" cy="101" rx="2.6" ry="4.6" fill="#fff" opacity=".9"/>
|
| 221 |
+
</g>
|
| 222 |
+
</g>
|
| 223 |
+
<g>
|
| 224 |
+
<ellipse cx="102" cy="174" rx="40" ry="11" fill="#1b2330" stroke="#efe9dd" stroke-width="2.5"/>
|
| 225 |
+
<path d="M64 178 Q54 178 51 192 Q40 232 40 268 Q40 306 72 309 L136 309 Q166 306 166 268 Q166 232 156 192 Q153 178 144 178 Z"
|
| 226 |
+
fill="#efe9dd" stroke="#1b2330" stroke-width="6.5" stroke-linejoin="round"/>
|
| 227 |
+
<path d="M58 198 Q48 256 56 286" class="pk-gloss" stroke-opacity=".65"/>
|
| 228 |
+
<path d="M45 288 L161 288" stroke="#1b2330" stroke-width="2.4" opacity=".3"/>
|
| 229 |
+
<path d="M46 303 Q46 322 72 322 L136 322 Q160 322 160 303 L160 300 L46 300 Z"
|
| 230 |
+
fill="#1b2330" stroke="#efe9dd" stroke-width="2.5"/>
|
| 231 |
+
</g>
|
| 232 |
+
</svg>`,
|
| 233 |
|
| 234 |
// a limit is a number (symmetric) or {min, max} — z is not symmetric
|
| 235 |
lo: (lim) => (typeof lim === 'object' ? lim.min : -lim),
|
| 236 |
hi: (lim) => (typeof lim === 'object' ? lim.max : lim),
|
| 237 |
|
| 238 |
+
slider: (key, label, limit, step, icon) => `
|
| 239 |
<label class="cp-row">
|
| 240 |
+
<span class="cp-label">${icon ? `<span class="ic i-${icon}"></span>` : ''}${label}</span>
|
| 241 |
<input type="range" data-key="${key}" min="${controller.lo(limit)}" max="${controller.hi(limit)}" step="${step}" value="0">
|
| 242 |
<output class="cp-val">0</output>
|
| 243 |
</label>`,
|
|
|
|
| 262 |
</div>
|
| 263 |
|
| 264 |
<div class="cp-body">
|
| 265 |
+
<!-- Reachy Eyes, up top: pick a colour (OFF first), a preset, or ANY
|
| 266 |
+
colour with the wheel (the custom card). Brightness lives down in
|
| 267 |
+
Precision. Drives the LED module when one answers, and the twin. -->
|
| 268 |
+
<div class="cp-group" id="cp-eyes">
|
| 269 |
+
<div id="cp-picker" class="cp-picker">
|
| 270 |
+
<button class="eye-chip" data-eyes-color="OFF" title="${t('control.eyes_off')}">
|
| 271 |
+
<span class="eye-face off"><span class="eye-dot l"></span><span class="eye-dot r"></span></span>
|
| 272 |
+
</button>
|
| 273 |
+
${['WHITE', 'RED', 'AMBER', 'GREEN', 'CYAN', 'BLUE', 'MAGENTA']
|
| 274 |
+
.map((c) => `<button class="eye-chip" data-eyes-color="${c}"><span class="eye-face"><span class="eye-dot l"></span><span class="eye-dot r"></span></span></button>`)
|
| 275 |
+
.join('')}
|
| 276 |
+
<label class="eye-chip eye-custom" title="${t('control.eyes_custom')}">
|
| 277 |
+
<span class="eye-face"><span class="eye-dot l rainbow"></span><span class="eye-dot r rainbow"></span></span>
|
| 278 |
+
<input type="color" id="cp-eyes-custom" value="#ff8800">
|
| 279 |
+
</label>
|
| 280 |
+
</div>
|
| 281 |
</div>
|
| 282 |
|
| 283 |
+
<!-- the puppet, front and side: the robot IS the controls, drag to pose -->
|
| 284 |
+
<div class="cp-stage-duo">
|
| 285 |
+
<div class="cp-stage" id="cp-stage-front">
|
| 286 |
+
${controller.puppetFront()}
|
| 287 |
+
</div>
|
| 288 |
+
<div class="cp-stage" id="cp-stage-side">
|
| 289 |
+
${controller.puppetSide()}
|
| 290 |
+
</div>
|
| 291 |
</div>
|
| 292 |
|
| 293 |
+
<!-- every axis as a plain slider, always visible now, each with an icon
|
| 294 |
+
of the movement it drives; the eyes brightness sits at the end -->
|
| 295 |
+
<div class="cp-precision">
|
| 296 |
+
<div class="cp-section-title">${t('control.precision')}</div>
|
| 297 |
+
${controller.slider('x', t('control.x'), L.x, 0.001, 'axis-x-arrow')}
|
| 298 |
+
${controller.slider('y', t('control.y'), L.y, 0.001, 'axis-y-arrow')}
|
| 299 |
+
${controller.slider('z', t('control.z'), L.z, 0.001, 'axis-z-arrow')}
|
| 300 |
+
${controller.slider('yaw', t('control.yaw'), L.yaw, 0.01, 'axis-z-rotate-clockwise')}
|
| 301 |
+
${controller.slider('pitch', t('control.pitch'), L.pitch, 0.01, 'axis-y-rotate-clockwise')}
|
| 302 |
+
${controller.slider('roll', t('control.roll'), L.roll, 0.01, 'axis-x-rotate-clockwise')}
|
| 303 |
+
${controller.slider('ant_l', t('control.antenna_left'), L.antenna, 0.1, 'antenna')}
|
| 304 |
+
${controller.slider('ant_r', t('control.antenna_right'), L.antenna, 0.1, 'antenna')}
|
| 305 |
+
${controller.slider('body_yaw', t('control.body_yaw'), L.body_yaw, 0.1, 'rotate-3d-variant')}
|
| 306 |
+
<label class="cp-row">
|
| 307 |
+
<span class="cp-label"><span class="ic i-brightness-6"></span>${t('control.intensity')}</span>
|
| 308 |
+
<input type="range" id="cp-eyes-int" min="0" max="1" step="0.01" value="1">
|
| 309 |
+
<output class="cp-val">1.00</output>
|
| 310 |
+
</label>
|
| 311 |
</div>
|
| 312 |
|
| 313 |
+
<!-- movement sequence: capture poses as keyframes, replay, step back and
|
| 314 |
+
forth, export/import at the marionette (RecordedMoves) .json format -->
|
| 315 |
+
<div class="cp-group" id="cp-seq">
|
| 316 |
+
<div class="cp-section-title">${t('control.sequence')}<span id="cp-seq-count" class="cp-seq-count"></span></div>
|
| 317 |
+
<div id="cp-seq-list" class="cp-seq-list"></div>
|
| 318 |
+
<div class="cp-seq-bar">
|
| 319 |
+
<button id="cp-seq-add" title="${t('control.seq_add')}"><span class="ic i-plus"></span></button>
|
| 320 |
+
<button id="cp-seq-prev" title="${t('control.seq_prev')}"><span class="ic i-chevron-left"></span></button>
|
| 321 |
+
<button id="cp-seq-next" title="${t('control.seq_next')}"><span class="ic i-chevron-right"></span></button>
|
| 322 |
+
<button id="cp-seq-play" title="${t('control.seq_play')}"><span class="ic i-play"></span></button>
|
| 323 |
+
<span class="cp-seq-sp"></span>
|
| 324 |
+
<button id="cp-seq-import" title="${t('control.seq_import')}"><span class="ic i-tray-arrow-up"></span></button>
|
| 325 |
+
<button id="cp-seq-export" title="${t('control.seq_export')}"><span class="ic i-download"></span></button>
|
| 326 |
+
<button id="cp-seq-clear" title="${t('control.seq_clear')}"><span class="ic i-delete"></span></button>
|
| 327 |
+
<input type="file" id="cp-seq-file" accept="application/json,.json" hidden>
|
| 328 |
+
</div>
|
| 329 |
</div>
|
|
|
|
|
|
|
| 330 |
</div>`;
|
| 331 |
|
| 332 |
controller.root = el;
|
|
|
|
| 339 |
// sliders), nor on the topbar button that toggles it. Escape closes it too.
|
| 340 |
document.addEventListener('pointerdown', (ev) => {
|
| 341 |
if (!controller.isOpen) return;
|
| 342 |
+
if (el.contains(ev.target) || ev.target.closest(window.DRAWER_OPENERS)) return;
|
| 343 |
controller.close();
|
| 344 |
});
|
| 345 |
document.addEventListener('keydown', (ev) => {
|
| 346 |
if (ev.key === 'Escape' && controller.isOpen) controller.close();
|
| 347 |
});
|
| 348 |
|
| 349 |
+
// only the pose sliders feed the smoother — the eyes' intensity slider has
|
| 350 |
+
// no data-key and its own wiring (a serial device, not a joint)
|
| 351 |
+
el.querySelectorAll('input[type=range][data-key]').forEach((input) => {
|
| 352 |
const out = input.parentElement.querySelector('.cp-val');
|
| 353 |
const show = () => {
|
| 354 |
const v = parseFloat(input.value);
|
|
|
|
| 357 |
input.addEventListener('input', () => {
|
| 358 |
controller.target[input.dataset.key] = parseFloat(input.value);
|
| 359 |
show();
|
| 360 |
+
controller.puppetSync();
|
| 361 |
});
|
| 362 |
show();
|
| 363 |
});
|
| 364 |
|
| 365 |
+
controller.bindPuppet(el);
|
| 366 |
+
controller.bindEyes(el);
|
| 367 |
+
controller.bindSeq(el);
|
| 368 |
+
controller.puppetSync(); // draw the initial (rest) pose
|
| 369 |
+
},
|
| 370 |
+
|
| 371 |
+
// --- the puppet's strings ------------------------------------------------
|
| 372 |
+
//
|
| 373 |
+
// Pixel-to-value scale: the puppets draw at K px per metre, so the head's
|
| 374 |
+
// on-screen travel IS its real travel box to scale (and the clamps below are
|
| 375 |
+
// the same LIMITS the wire enforces). Gaze and pitch are radians per pixel.
|
| 376 |
+
PK: 550,
|
| 377 |
+
|
| 378 |
+
// pointer position in a puppet svg's own coordinates
|
| 379 |
+
pkPoint: (svg, ev) => {
|
| 380 |
+
const p = svg.createSVGPoint();
|
| 381 |
+
p.x = ev.clientX; p.y = ev.clientY;
|
| 382 |
+
return p.matrixTransform(svg.getScreenCTM().inverse());
|
| 383 |
+
},
|
| 384 |
+
|
| 385 |
+
// one grab: capture the pointer, remember where the TARGET stood, and hand
|
| 386 |
+
// every move to the mapper as a delta in svg pixels
|
| 387 |
+
pkDrag: (el, svg, map) => {
|
| 388 |
+
el.addEventListener('pointerdown', (ev) => {
|
| 389 |
+
ev.stopPropagation();
|
| 390 |
+
const from = controller.pkPoint(svg, ev);
|
| 391 |
+
const base = { ...controller.target };
|
| 392 |
+
el.setPointerCapture(ev.pointerId);
|
| 393 |
+
const move = (e2) => {
|
| 394 |
+
const p = controller.pkPoint(svg, e2);
|
| 395 |
+
map(p.x - from.x, p.y - from.y, base, p);
|
| 396 |
+
controller.puppetSync();
|
| 397 |
+
};
|
| 398 |
+
el.addEventListener('pointermove', move);
|
| 399 |
+
el.addEventListener('pointerup', () => el.removeEventListener('pointermove', move), { once: true });
|
| 400 |
+
});
|
| 401 |
+
},
|
| 402 |
+
|
| 403 |
+
bindPuppet: (el) => {
|
| 404 |
+
const front = el.querySelector('#cp-p-front');
|
| 405 |
+
const side = el.querySelector('#cp-p-side');
|
| 406 |
+
const L = controller.LIMITS;
|
| 407 |
+
const K = controller.PK;
|
| 408 |
+
const clampL = (v, lim) => Math.max(controller.lo(lim), Math.min(controller.hi(lim), v));
|
| 409 |
+
const T = controller.target;
|
| 410 |
+
|
| 411 |
+
// front head: sideways + height — the two axes you see from the front
|
| 412 |
+
controller.pkDrag(el.querySelector('#cp-pf-head'), front, (dx, dy, base) => {
|
| 413 |
+
T.y = clampL(base.y + dx / K, L.y);
|
| 414 |
+
T.z = clampL(base.z - dy / K, L.z);
|
| 415 |
+
});
|
| 416 |
+
|
| 417 |
+
// the camera looks: yaw + pitch. ~40 px of drag = the full neck, and the
|
| 418 |
+
// wire's own coupling turns the body when the neck runs out
|
| 419 |
+
controller.pkDrag(el.querySelector('#cp-pf-cam'), front, (dx, dy, base) => {
|
| 420 |
+
T.yaw = clampL(base.yaw + dx * 0.035, L.yaw);
|
| 421 |
+
T.pitch = clampL(base.pitch + dy * 0.026, L.pitch);
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
// each antenna pivots where it leaves the skull
|
| 425 |
+
const ant = (id, key, bx, by, sign) => {
|
| 426 |
+
controller.pkDrag(el.querySelector(id), front, (dx, dy, base, p) => {
|
| 427 |
+
const a = Math.atan2(p.x - bx, by - p.y) * sign;
|
| 428 |
+
T[key] = clampL(a, L.antenna);
|
| 429 |
+
});
|
| 430 |
};
|
| 431 |
+
ant('#cp-pf-antl', 'ant_l', 82, 56, 1);
|
| 432 |
+
ant('#cp-pf-antr', 'ant_r', 158, 56, 1);
|
| 433 |
+
|
| 434 |
+
// the body slides sideways under the head: body rotation
|
| 435 |
+
controller.pkDrag(el.querySelector('#cp-p-front [data-grab=body]'), front, (dx, dy, base) => {
|
| 436 |
+
T.body_yaw = clampL(base.body_yaw + dx * 0.02, L.body_yaw);
|
| 437 |
+
});
|
| 438 |
+
|
| 439 |
+
// side head: forward/back + height — the axes you see from the side
|
| 440 |
+
controller.pkDrag(el.querySelector('#cp-ps-head'), side, (dx, dy, base) => {
|
| 441 |
+
T.x = clampL(base.x + dx / K, L.x);
|
| 442 |
+
T.z = clampL(base.z - dy / K, L.z);
|
| 443 |
+
});
|
| 444 |
+
|
| 445 |
+
// the lens nods: pitch (dragging DOWN looks down)
|
| 446 |
+
controller.pkDrag(el.querySelector('#cp-ps-lens'), side, (dx, dy, base) => {
|
| 447 |
+
T.pitch = clampL(base.pitch + dy * 0.02, L.pitch);
|
| 448 |
+
});
|
| 449 |
+
},
|
| 450 |
|
| 451 |
+
// The puppet always says what the TARGET says — whoever moved it (a grab, a
|
| 452 |
+
// precision slider, a look-at click, rest). Pure transforms, no reflow.
|
| 453 |
+
puppetSync: () => {
|
| 454 |
+
const root = controller.root, T = controller.target;
|
| 455 |
+
if (!root || !T) return;
|
| 456 |
+
const K = controller.PK;
|
| 457 |
+
const deg = (rad) => (rad * 180) / Math.PI;
|
| 458 |
+
const set = (sel, tr) => {
|
| 459 |
+
const n = root.querySelector(sel);
|
| 460 |
+
if (n) n.setAttribute('transform', tr);
|
| 461 |
+
};
|
| 462 |
+
// front: the head block (antennas riding along) carries y and z, and it
|
| 463 |
+
// SCALES with x — forward (+x, set from the side view) brings the head toward
|
| 464 |
+
// you, so from the front it grows; back shrinks it. Scale about the head's
|
| 465 |
+
// centre (120,106 in the 240x360 viewBox) so it grows in place.
|
| 466 |
+
const s = Math.max(0.9, Math.min(1.14, 1 + (T.x || 0) * 4.5));
|
| 467 |
+
set('#cp-pf-head', `translate(${(T.y || 0) * K} ${-(T.z || 0) * K})`
|
| 468 |
+
+ ` translate(120 106) scale(${s.toFixed(3)}) translate(-120 -106)`);
|
| 469 |
+
set('#cp-pf-antl', `rotate(${deg(T.ant_l || 0)} 82 56)`);
|
| 470 |
+
set('#cp-pf-antr', `rotate(${deg(T.ant_r || 0)} 158 56)`);
|
| 471 |
+
// the gaze: camera dot and eyes drift toward where the head looks
|
| 472 |
+
const gx = (T.yaw || 0) * 10, gy = (T.pitch || 0) * 12;
|
| 473 |
+
set('#cp-pf-cam', `translate(${Math.max(-14, Math.min(14, gx))} ${Math.max(-10, Math.min(10, gy))})`);
|
| 474 |
+
set('#cp-pf-eyes', `translate(${Math.max(-9, Math.min(9, gx * 0.6))} ${Math.max(-7, Math.min(7, gy * 0.6))})`);
|
| 475 |
+
// the body seam slides with the body's rotation
|
| 476 |
+
set('#cp-pf-seam', `translate(${((T.body_yaw || 0) / controller.LIMITS.body_yaw) * 46} 0)`);
|
| 477 |
+
// side: x and z, and the whole head nods with pitch around its centre
|
| 478 |
+
set('#cp-ps-head', `translate(${(T.x || 0) * K} ${-(T.z || 0) * K}) rotate(${deg(T.pitch || 0) * 0.55} 88 106)`);
|
| 479 |
+
// sync the precision sliders without re-firing their input events
|
| 480 |
+
root.querySelectorAll('input[type=range][data-key]').forEach((input) => {
|
| 481 |
+
const v = T[input.dataset.key];
|
| 482 |
+
if (v === undefined || document.activeElement === input) return;
|
| 483 |
+
input.value = v;
|
| 484 |
+
const out = input.parentElement.querySelector('.cp-val');
|
| 485 |
+
if (out) out.textContent = input.step === '0.001' ? v.toFixed(3) : v.toFixed(2);
|
| 486 |
});
|
| 487 |
+
},
|
| 488 |
+
|
| 489 |
+
// --- eyes (Reachy Eyes LED module) --------------------------------------
|
| 490 |
+
//
|
| 491 |
+
// A community accessory, not a joint. All the actual driving lives in
|
| 492 |
+
// eyes_fx.js (module + twin, both at once); the drawer only carries the RGB:
|
| 493 |
+
// a color picker under the palette icon, and the brightness slider. Shown
|
| 494 |
+
// when the backend shook hands with the module, re-checked at every open
|
| 495 |
+
// (USB is hotpluggable) — the probe also feeds the catalogue's Effects tab.
|
| 496 |
+
|
| 497 |
+
probeEyes: async () => {
|
| 498 |
+
if (typeof eyesFx === 'undefined') return;
|
| 499 |
+
const on = await eyesFx.probe();
|
| 500 |
+
// the colour cards + brightness stay visible whatever answers: they drive the
|
| 501 |
+
// twin even with no LED module. The probe only decides the Effects tab.
|
| 502 |
+
if (typeof catalogue !== 'undefined') catalogue.fx(on);
|
| 503 |
+
},
|
| 504 |
+
|
| 505 |
+
bindEyes: (el) => {
|
| 506 |
+
const picker = el.querySelector('#cp-picker');
|
| 507 |
+
if (!picker) return;
|
| 508 |
+
const slider = el.querySelector('#cp-eyes-int');
|
| 509 |
+
|
| 510 |
+
// OFF <-> brightness: switching off drops luminosité to 0; any colour (preset
|
| 511 |
+
// or custom) brings it back to the last non-zero level (Pierre). Slider follows.
|
| 512 |
+
const relight = (isOff) => {
|
| 513 |
+
const cur = slider ? parseFloat(slider.value) : 1;
|
| 514 |
+
if (isOff) { if (cur > 0) controller.lastEyesInt = cur; controller.setEyesIntensity(0); }
|
| 515 |
+
else controller.setEyesIntensity(controller.lastEyesInt || 1);
|
| 516 |
};
|
| 517 |
+
|
| 518 |
+
// the preset cards (OFF + the firmware palette), visible in the eyes section
|
| 519 |
+
// now, not a popover on the puppet. Each drives the LED module + the twin.
|
| 520 |
+
picker.querySelectorAll('[data-eyes-color]').forEach((b) => {
|
| 521 |
+
b.addEventListener('click', () => {
|
| 522 |
+
const name = b.dataset.eyesColor;
|
| 523 |
+
eyesFx.select(name);
|
| 524 |
+
controller.tintEyes(eyesFx.PALETTE[name] || null);
|
| 525 |
+
controller.markEyes();
|
| 526 |
+
relight(name === 'OFF');
|
| 527 |
+
});
|
| 528 |
+
});
|
| 529 |
+
|
| 530 |
+
// the custom card: the native colour WHEEL, any colour you like. The twin
|
| 531 |
+
// shows the exact hex; the LED module (named colours only) gets the nearest.
|
| 532 |
+
const custom = el.querySelector('#cp-eyes-custom');
|
| 533 |
+
if (custom) {
|
| 534 |
+
custom.addEventListener('input', () => {
|
| 535 |
+
eyesFx.colorHex(custom.value, { dur: 0.3 });
|
| 536 |
+
controller.tintEyes(custom.value);
|
| 537 |
+
controller.markEyes();
|
| 538 |
+
relight(false);
|
| 539 |
+
});
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
controller.markEyes(); // resting state (OFF) selected from the start
|
| 543 |
+
|
| 544 |
+
if (!slider) return;
|
| 545 |
+
const out = slider.parentElement.querySelector('.cp-val');
|
| 546 |
+
let timer = null;
|
| 547 |
+
slider.addEventListener('input', () => {
|
| 548 |
+
const v = parseFloat(slider.value);
|
| 549 |
+
if (out) out.textContent = v.toFixed(2);
|
| 550 |
+
if (v > 0) controller.lastEyesInt = v; // remember it, to restore on relight
|
| 551 |
+
// live label every frame, one serial write once the drag settles
|
| 552 |
+
clearTimeout(timer);
|
| 553 |
+
timer = setTimeout(() => eyesFx.intensity(v), 150);
|
| 554 |
+
});
|
| 555 |
+
},
|
| 556 |
+
|
| 557 |
+
// set the brightness slider AND push it to the eyes (module + twin) in one place
|
| 558 |
+
// — the OFF <-> brightness coupling above uses it.
|
| 559 |
+
lastEyesInt: 1,
|
| 560 |
+
setEyesIntensity: (v) => {
|
| 561 |
+
const slider = controller.root && controller.root.querySelector('#cp-eyes-int');
|
| 562 |
+
if (!slider) return;
|
| 563 |
+
slider.value = String(v);
|
| 564 |
+
const out = slider.parentElement.querySelector('.cp-val');
|
| 565 |
+
if (out) out.textContent = Number(v).toFixed(2);
|
| 566 |
+
if (typeof eyesFx !== 'undefined') eyesFx.intensity(v);
|
| 567 |
+
},
|
| 568 |
+
|
| 569 |
+
// the puppet's irises (front eyes + side lens) wear the picked LED color;
|
| 570 |
+
// null = LEDs off, back to the resting dark glass
|
| 571 |
+
tintEyes: (hex) => {
|
| 572 |
+
if (!controller.root) return;
|
| 573 |
+
controller.root.querySelectorAll('.pk-iris').forEach((n) => {
|
| 574 |
+
n.style.fill = hex || '';
|
| 575 |
+
});
|
| 576 |
+
},
|
| 577 |
+
|
| 578 |
+
// highlight which swatch is the live selection (the OFF button included), so
|
| 579 |
+
// the ring shows a real state and it is obvious what is currently on. Reads
|
| 580 |
+
// eyesFx.current, the single source of truth (OFF at rest).
|
| 581 |
+
markEyes: () => {
|
| 582 |
+
if (!controller.root) return;
|
| 583 |
+
const cur = (typeof eyesFx !== 'undefined' && eyesFx.current) || 'OFF';
|
| 584 |
+
const isHex = typeof cur === 'string' && cur[0] === '#';
|
| 585 |
+
controller.root.querySelectorAll('#cp-picker [data-eyes-color]').forEach((b) =>
|
| 586 |
+
b.classList.toggle('on', b.dataset.eyesColor === cur));
|
| 587 |
+
// the custom card lights up when a FREE colour is showing, and its lenses
|
| 588 |
+
// wear that colour (else they fall back to the rainbow hint, via CSS)
|
| 589 |
+
const custom = controller.root.querySelector('.eye-custom');
|
| 590 |
+
if (custom) {
|
| 591 |
+
custom.classList.toggle('on', isHex);
|
| 592 |
+
custom.querySelectorAll('.eye-dot').forEach((d) => {
|
| 593 |
+
d.style.background = isHex ? cur : '';
|
| 594 |
+
d.style.boxShadow = isHex ? `0 0 6px ${cur}` : '';
|
| 595 |
+
});
|
| 596 |
+
const inp = custom.querySelector('#cp-eyes-custom');
|
| 597 |
+
if (inp && isHex) inp.value = cur;
|
| 598 |
+
}
|
| 599 |
},
|
| 600 |
|
| 601 |
// --- the wire ---------------------------------------------------------
|
|
|
|
| 636 |
controller.raf = requestAnimationFrame(controller.loop);
|
| 637 |
|
| 638 |
const c = controller.current, tg = controller.target, k = controller.EMA;
|
|
|
|
|
|
|
|
|
|
| 639 |
const sim = !controller.robotReady();
|
| 640 |
+
|
| 641 |
+
// Were we ALREADY there before this frame's step? Then nothing to do. This
|
| 642 |
+
// check MUST come before the step: the twin has no motor to protect, so in
|
| 643 |
+
// the simulator we snap (factor 1) — but a snap lands current ON target in
|
| 644 |
+
// one frame, and a settled-check done AFTER the snap would be true instantly
|
| 645 |
+
// and return before send() ever ran (the twin never moved). Checking first
|
| 646 |
+
// means a moved target still gets snapped AND sent, then settles next frame.
|
| 647 |
+
const need = Object.keys(tg).some(
|
| 648 |
+
(key) => Math.abs(tg[key] - c[key]) >= controller.SETTLED
|
| 649 |
+
);
|
| 650 |
+
if (!need) return;
|
| 651 |
+
|
| 652 |
+
// The EMA is a mechanical-safety feature: it turns a yanked joystick into a
|
| 653 |
+
// sweep the real robot can follow. The twin is just a picture, so it snaps.
|
| 654 |
const ema = (key, f) => { c[key] += (tg[key] - c[key]) * (sim ? 1 : f); };
|
| 655 |
['x', 'y', 'z'].forEach((key) => ema(key, k.pos));
|
| 656 |
['pitch', 'yaw', 'roll'].forEach((key) => ema(key, k.rot));
|
|
|
|
| 658 |
ema('ant_l', k.ant);
|
| 659 |
ema('ant_r', k.ant);
|
| 660 |
|
| 661 |
+
// Throttle the WIRE to the real robot (20 Hz). The twin's IK coalesces its
|
| 662 |
+
// own round-trips (driveSim), so drive it every frame — otherwise a throttled
|
| 663 |
+
// frame would snap current onto target and the next frame, seeing no need,
|
| 664 |
+
// would never send that pose.
|
|
|
|
|
|
|
| 665 |
const now = Date.now();
|
| 666 |
+
if (!sim && now - controller.lastSend < controller.THROTTLE) return;
|
| 667 |
controller.lastSend = now;
|
| 668 |
controller.send();
|
| 669 |
},
|
|
|
|
| 740 |
// one returns: the twin always catches up to where you actually are.
|
| 741 |
driveSim: (body) => {
|
| 742 |
if (typeof viewer3d === 'undefined' || !viewer3d.robot) return;
|
| 743 |
+
// a wake/fold animation owns the twin: don't drive (nor spend the IK round
|
| 744 |
+
// trip) until it lands. The loop keeps calling us, so control resumes on its
|
| 745 |
+
// own the frame after the animation ends.
|
| 746 |
+
if (viewer3d.busyAnim && viewer3d.busyAnim()) return;
|
| 747 |
if (controller.simBusy) { controller.simNext = body; return; }
|
| 748 |
controller.simBusy = true;
|
| 749 |
controller._simSend(body);
|
|
|
|
| 787 |
},
|
| 788 |
|
| 789 |
// Rest is not a command: we ramp the TARGET to zero and let the smoother walk
|
| 790 |
+
// the robot home, so it never snaps. The puppet follows the target home.
|
| 791 |
rest: () => {
|
| 792 |
if (!controller.root) return;
|
| 793 |
controller.target = controller.ZERO();
|
| 794 |
+
controller.puppetSync();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 795 |
},
|
| 796 |
|
| 797 |
// --- look at ----------------------------------------------------------
|
|
|
|
| 828 |
|
| 829 |
// Feed the smoother from outside the panel (the panel need not even exist).
|
| 830 |
drive: (pose) => {
|
| 831 |
+
if (typeof idle !== 'undefined') idle.disarm(); // driving and idling both applyFrame
|
| 832 |
controller.ensureStream();
|
| 833 |
Object.assign(controller.target, pose);
|
| 834 |
if (controller.root) controller.syncWidgets();
|
|
|
|
| 845 |
if (!controller.raf) controller.loop();
|
| 846 |
},
|
| 847 |
|
| 848 |
+
// Whatever moved the target (a click in the image, a reset), the puppet and
|
| 849 |
+
// the precision sliders have to say the same thing as the robot.
|
| 850 |
syncWidgets: () => {
|
| 851 |
+
controller.puppetSync();
|
| 852 |
+
if (!controller.root) return;
|
| 853 |
+
// the precision sliders are input-only; reflect the target back onto them so
|
| 854 |
+
// applying a keyframe (or a reset, or click-to-look) moves the sliders too
|
| 855 |
+
controller.root.querySelectorAll('input[type=range][data-key]').forEach((input) => {
|
| 856 |
const v = controller.target[input.dataset.key];
|
| 857 |
+
if (typeof v !== 'number') return;
|
| 858 |
+
input.value = String(v);
|
| 859 |
const out = input.parentElement.querySelector('.cp-val');
|
| 860 |
if (out) out.textContent = input.step === '0.001' ? v.toFixed(3) : v.toFixed(2);
|
| 861 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
},
|
| 863 |
|
| 864 |
// Standing back up is NOT the rest pose. A folded robot (asleep, or left that
|
|
|
|
| 888 |
resetPose: () => {
|
| 889 |
controller.ensureStream();
|
| 890 |
controller.target = controller.ZERO();
|
| 891 |
+
if (controller.root) controller.puppetSync();
|
| 892 |
+
},
|
| 893 |
+
|
| 894 |
+
// --- movement sequence (keyframes) ------------------------------------
|
| 895 |
+
//
|
| 896 |
+
// Each captured pose is one keyframe of the 9 head/antenna/body axes, with a
|
| 897 |
+
// timestamp. The list replays (the smoother eases between keyframes), steps
|
| 898 |
+
// back and forth, and saves to the marionette / RecordedMoves .json — the
|
| 899 |
+
// exact format the robot's move store plays (moves.py reads the same one):
|
| 900 |
+
// { description, time:[s...], set_target_data:[{head:4x4, antennas:[l,r],
|
| 901 |
+
// body_yaw, check_collision}] }
|
| 902 |
+
// head is the 4x4 transform; our 6 scalars (metres + xyz-euler radians) are
|
| 903 |
+
// exactly what R.from_euler("xyz") <-> from_matrix decompose, so it round-trips.
|
| 904 |
+
|
| 905 |
+
pose: () => {
|
| 906 |
+
const T = controller.target || controller.ZERO();
|
| 907 |
+
const p = {};
|
| 908 |
+
['x', 'y', 'z', 'roll', 'pitch', 'yaw', 'ant_l', 'ant_r', 'body_yaw'].forEach((k) => { p[k] = T[k] || 0; });
|
| 909 |
+
return p;
|
| 910 |
+
},
|
| 911 |
+
|
| 912 |
+
seqAdd: () => {
|
| 913 |
+
const now = Date.now();
|
| 914 |
+
const prev = controller.seq[controller.seq.length - 1];
|
| 915 |
+
const dt = prev ? Math.max(0.3, (now - controller.seqLastCap) / 1000) : 0;
|
| 916 |
+
controller.seqLastCap = now;
|
| 917 |
+
controller.seq.push({ t: +(((prev && prev.t) || 0) + dt).toFixed(2), ...controller.pose() });
|
| 918 |
+
controller.seqIdx = controller.seq.length - 1;
|
| 919 |
+
controller.seqRender();
|
| 920 |
+
},
|
| 921 |
+
|
| 922 |
+
seqApply: (i) => {
|
| 923 |
+
if (i < 0 || i >= controller.seq.length) return;
|
| 924 |
+
controller.seqIdx = i;
|
| 925 |
+
const { t, ...pose } = controller.seq[i];
|
| 926 |
+
controller.drive(pose); // sets the whole target + syncs widgets + streams
|
| 927 |
+
controller.seqRender();
|
| 928 |
+
},
|
| 929 |
+
|
| 930 |
+
seqRemove: (i) => {
|
| 931 |
+
controller.seq.splice(i, 1);
|
| 932 |
+
if (controller.seqIdx >= controller.seq.length) controller.seqIdx = controller.seq.length - 1;
|
| 933 |
+
controller.seqRender();
|
| 934 |
+
},
|
| 935 |
+
|
| 936 |
+
seqClear: () => {
|
| 937 |
+
controller.seqPlaying = false;
|
| 938 |
+
controller.seq = [];
|
| 939 |
+
controller.seqIdx = -1;
|
| 940 |
+
controller.seqRender();
|
| 941 |
+
},
|
| 942 |
+
|
| 943 |
+
seqPlay: async () => {
|
| 944 |
+
if (controller.seqPlaying) { controller.seqPlaying = false; return; } // toggle = stop
|
| 945 |
+
if (controller.seq.length < 1) return;
|
| 946 |
+
controller.seqPlaying = true;
|
| 947 |
+
controller.seqPlayIcon(true);
|
| 948 |
+
for (let i = 0; i < controller.seq.length; i++) {
|
| 949 |
+
if (!controller.seqPlaying) break;
|
| 950 |
+
controller.seqApply(i);
|
| 951 |
+
const cur = controller.seq[i].t;
|
| 952 |
+
const next = i + 1 < controller.seq.length ? controller.seq[i + 1].t : cur + 0.9;
|
| 953 |
+
await new Promise((r) => setTimeout(r, Math.max(300, (next - cur) * 1000)));
|
| 954 |
+
}
|
| 955 |
+
controller.seqPlaying = false;
|
| 956 |
+
controller.seqPlayIcon(false);
|
| 957 |
+
},
|
| 958 |
+
|
| 959 |
+
seqPlayIcon: (playing) => {
|
| 960 |
+
const ic = controller.root && controller.root.querySelector('#cp-seq-play .ic');
|
| 961 |
+
if (!ic) return;
|
| 962 |
+
ic.classList.toggle('i-play', !playing);
|
| 963 |
+
ic.classList.toggle('i-stop', playing);
|
| 964 |
+
},
|
| 965 |
+
|
| 966 |
+
// a compact one-line readout so keyframes are distinguishable in the list:
|
| 967 |
+
// time + the notable axes (Yaw/Pitch/Roll/Body in degrees, z in cm)
|
| 968 |
+
seqLabel: (k) => {
|
| 969 |
+
const d = (r) => Math.round((r || 0) * 180 / Math.PI);
|
| 970 |
+
const bits = [];
|
| 971 |
+
if (Math.abs(k.yaw) > 0.02) bits.push(`Y${d(k.yaw)}`);
|
| 972 |
+
if (Math.abs(k.pitch) > 0.02) bits.push(`P${d(k.pitch)}`);
|
| 973 |
+
if (Math.abs(k.roll) > 0.02) bits.push(`R${d(k.roll)}`);
|
| 974 |
+
if (Math.abs(k.body_yaw) > 0.02) bits.push(`B${d(k.body_yaw)}`);
|
| 975 |
+
if (Math.abs(k.z) > 0.003) bits.push(`z${(k.z * 100).toFixed(1)}`);
|
| 976 |
+
return `${k.t.toFixed(1)}s ${bits.join(' ') || '·'}`;
|
| 977 |
+
},
|
| 978 |
+
|
| 979 |
+
seqRender: () => {
|
| 980 |
+
if (!controller.root) return;
|
| 981 |
+
const list = controller.root.querySelector('#cp-seq-list');
|
| 982 |
+
const count = controller.root.querySelector('#cp-seq-count');
|
| 983 |
+
if (count) count.textContent = controller.seq.length ? ` · ${controller.seq.length}` : '';
|
| 984 |
+
if (list) {
|
| 985 |
+
list.innerHTML = controller.seq.length
|
| 986 |
+
? controller.seq.map((k, i) => `
|
| 987 |
+
<div class="cp-seq-row${i === controller.seqIdx ? ' on' : ''}" data-i="${i}">
|
| 988 |
+
<span class="cp-seq-n">${i + 1}</span>
|
| 989 |
+
<span class="cp-seq-desc">${controller.seqLabel(k)}</span>
|
| 990 |
+
<button class="cp-seq-del" data-del="${i}" title="${t('control.seq_clear')}"><span class="ic i-close"></span></button>
|
| 991 |
+
</div>`).join('')
|
| 992 |
+
: `<div class="cp-seq-empty">${t('control.seq_empty')}</div>`;
|
| 993 |
+
}
|
| 994 |
+
const dis = (id, cond) => { const b = controller.root.querySelector(id); if (b) b.disabled = cond; };
|
| 995 |
+
dis('#cp-seq-prev', controller.seqIdx <= 0);
|
| 996 |
+
dis('#cp-seq-next', controller.seqIdx >= controller.seq.length - 1);
|
| 997 |
+
dis('#cp-seq-play', controller.seq.length < 1);
|
| 998 |
+
dis('#cp-seq-export', controller.seq.length < 1);
|
| 999 |
+
dis('#cp-seq-clear', controller.seq.length < 1);
|
| 1000 |
+
},
|
| 1001 |
+
|
| 1002 |
+
bindSeq: (el) => {
|
| 1003 |
+
const q = (id) => el.querySelector(id);
|
| 1004 |
+
q('#cp-seq-add')?.addEventListener('click', controller.seqAdd);
|
| 1005 |
+
q('#cp-seq-prev')?.addEventListener('click', () => controller.seqApply(controller.seqIdx - 1));
|
| 1006 |
+
q('#cp-seq-next')?.addEventListener('click', () => controller.seqApply(controller.seqIdx + 1));
|
| 1007 |
+
q('#cp-seq-play')?.addEventListener('click', controller.seqPlay);
|
| 1008 |
+
q('#cp-seq-export')?.addEventListener('click', controller.seqExport);
|
| 1009 |
+
q('#cp-seq-clear')?.addEventListener('click', controller.seqClear);
|
| 1010 |
+
const file = q('#cp-seq-file');
|
| 1011 |
+
q('#cp-seq-import')?.addEventListener('click', () => file && file.click());
|
| 1012 |
+
file?.addEventListener('change', () => { if (file.files[0]) controller.seqImport(file.files[0]); file.value = ''; });
|
| 1013 |
+
q('#cp-seq-list')?.addEventListener('click', (ev) => {
|
| 1014 |
+
const del = ev.target.closest('[data-del]');
|
| 1015 |
+
if (del) { ev.stopPropagation(); controller.seqRemove(+del.dataset.del); return; }
|
| 1016 |
+
const row = ev.target.closest('[data-i]');
|
| 1017 |
+
if (row) controller.seqApply(+row.dataset.i);
|
| 1018 |
+
});
|
| 1019 |
+
controller.seqRender();
|
| 1020 |
+
},
|
| 1021 |
+
|
| 1022 |
+
// --- marionette / RecordedMoves format --------------------------------
|
| 1023 |
+
//
|
| 1024 |
+
// head 4x4 from our 6 scalars: translation + R = Rz(yaw)·Ry(pitch)·Rx(roll),
|
| 1025 |
+
// the matrix scipy's from_euler("xyz") builds (and from_matrix.as_euler("xyz")
|
| 1026 |
+
// inverts), which is the daemon's own convention — so a re-import round-trips.
|
| 1027 |
+
poseToMatrix: (p) => {
|
| 1028 |
+
const cx = Math.cos(p.roll || 0), sx = Math.sin(p.roll || 0);
|
| 1029 |
+
const cy = Math.cos(p.pitch || 0), sy = Math.sin(p.pitch || 0);
|
| 1030 |
+
const cz = Math.cos(p.yaw || 0), sz = Math.sin(p.yaw || 0);
|
| 1031 |
+
return [
|
| 1032 |
+
[cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx, p.x || 0],
|
| 1033 |
+
[sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx, p.y || 0],
|
| 1034 |
+
[-sy, cy * sx, cy * cx, p.z || 0],
|
| 1035 |
+
[0, 0, 0, 1],
|
| 1036 |
+
];
|
| 1037 |
+
},
|
| 1038 |
+
|
| 1039 |
+
matrixToPose: (head) => {
|
| 1040 |
+
let M = head;
|
| 1041 |
+
if (Array.isArray(head) && typeof head[0] === 'number') { // flat 16 or 12
|
| 1042 |
+
const f = head;
|
| 1043 |
+
M = [[f[0], f[1], f[2], f[3]], [f[4], f[5], f[6], f[7]], [f[8], f[9], f[10], f[11]], [0, 0, 0, 1]];
|
| 1044 |
+
}
|
| 1045 |
+
const c = (v) => Math.max(-1, Math.min(1, v));
|
| 1046 |
+
return {
|
| 1047 |
+
x: M[0][3], y: M[1][3], z: M[2][3],
|
| 1048 |
+
roll: Math.atan2(M[2][1], M[2][2]),
|
| 1049 |
+
pitch: Math.asin(c(-M[2][0])),
|
| 1050 |
+
yaw: Math.atan2(M[1][0], M[0][0]),
|
| 1051 |
+
};
|
| 1052 |
+
},
|
| 1053 |
+
|
| 1054 |
+
seqExport: () => {
|
| 1055 |
+
if (!controller.seq.length) return;
|
| 1056 |
+
const data = {
|
| 1057 |
+
description: 'Reachy Web sequence',
|
| 1058 |
+
time: controller.seq.map((k) => k.t),
|
| 1059 |
+
set_target_data: controller.seq.map((k) => ({
|
| 1060 |
+
head: controller.poseToMatrix(k),
|
| 1061 |
+
antennas: [k.ant_l || 0, k.ant_r || 0],
|
| 1062 |
+
body_yaw: k.body_yaw || 0,
|
| 1063 |
+
check_collision: false,
|
| 1064 |
+
})),
|
| 1065 |
+
};
|
| 1066 |
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
| 1067 |
+
const url = URL.createObjectURL(blob);
|
| 1068 |
+
const a = document.createElement('a');
|
| 1069 |
+
a.href = url;
|
| 1070 |
+
a.download = 'reachy-sequence.json';
|
| 1071 |
+
a.click();
|
| 1072 |
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
| 1073 |
+
},
|
| 1074 |
+
|
| 1075 |
+
seqImport: (file) => {
|
| 1076 |
+
const reader = new FileReader();
|
| 1077 |
+
reader.onload = () => {
|
| 1078 |
+
try {
|
| 1079 |
+
const data = JSON.parse(reader.result);
|
| 1080 |
+
const frames = data.set_target_data || [];
|
| 1081 |
+
const times = data.time || [];
|
| 1082 |
+
if (!frames.length) throw new Error('empty');
|
| 1083 |
+
controller.seq = frames.map((f, i) => {
|
| 1084 |
+
const a = f.antennas || [0, 0];
|
| 1085 |
+
return {
|
| 1086 |
+
t: typeof times[i] === 'number' ? times[i] : i,
|
| 1087 |
+
...controller.matrixToPose(f.head),
|
| 1088 |
+
ant_l: a[0] || 0, ant_r: a[1] || 0, body_yaw: f.body_yaw || 0,
|
| 1089 |
+
};
|
| 1090 |
+
});
|
| 1091 |
+
controller.seqIdx = 0;
|
| 1092 |
+
controller.seqRender();
|
| 1093 |
+
controller.seqApply(0);
|
| 1094 |
+
} catch (e) {
|
| 1095 |
+
if (typeof notificationCenter !== 'undefined') notificationCenter.showError(t('control.seq_bad'));
|
| 1096 |
+
}
|
| 1097 |
+
};
|
| 1098 |
+
reader.readAsText(file);
|
| 1099 |
},
|
| 1100 |
|
| 1101 |
ping: (x, y, el) => {
|
reachy_web/static/js/daemon.js
CHANGED
|
@@ -6,6 +6,12 @@ const daemon = {
|
|
| 6 |
state: null,
|
| 7 |
},
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
start: async (wakeUp) => {
|
| 10 |
await fetch(`/api/daemon/start?wake_up=${wakeUp}`, {
|
| 11 |
method: 'POST',
|
|
@@ -149,9 +155,18 @@ const daemon = {
|
|
| 149 |
&& daemonState !== 'starting' && !viewer3d.streamLive()) {
|
| 150 |
viewer3d.goToSleep();
|
| 151 |
if (typeof robotWake !== 'undefined') robotWake.virtualAwake = false;
|
|
|
|
| 152 |
}
|
| 153 |
if (typeof robotWake !== 'undefined') robotWake.showWake(daemonState);
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
// wake.js: scrolling messages while it boots, auto-switch to the camera
|
| 156 |
// once it actually produces an image
|
| 157 |
if (typeof robotWake !== 'undefined') robotWake.onState(daemonState);
|
|
@@ -181,6 +196,20 @@ const daemon = {
|
|
| 181 |
// a fresh startup re-arms the idle greeting (updateUI only fires on a
|
| 182 |
// state change, so this is the transition INTO running, not every poll)
|
| 183 |
if (typeof idle !== 'undefined') idle.arm();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
// Everything that reads the ROBOT was asked once, at page load — and a
|
| 185 |
// dashboard started before its robot got nothing but 502s. This is the
|
| 186 |
// first moment those questions have an answer, so they are asked again.
|
|
|
|
| 6 |
state: null,
|
| 7 |
},
|
| 8 |
|
| 9 |
+
// true once a power-OFF folded the twin, so the next ON gives it a clean wake
|
| 10 |
+
// (fold → neutral) instead of leaving it folded or letting it jump. Starts
|
| 11 |
+
// false, so a fresh page load — where the twin begins folded on its own — does
|
| 12 |
+
// not trigger an unwanted wake.
|
| 13 |
+
wasOff: false,
|
| 14 |
+
|
| 15 |
start: async (wakeUp) => {
|
| 16 |
await fetch(`/api/daemon/start?wake_up=${wakeUp}`, {
|
| 17 |
method: 'POST',
|
|
|
|
| 155 |
&& daemonState !== 'starting' && !viewer3d.streamLive()) {
|
| 156 |
viewer3d.goToSleep();
|
| 157 |
if (typeof robotWake !== 'undefined') robotWake.virtualAwake = false;
|
| 158 |
+
daemon.wasOff = true; // remember: the next ON should wake it cleanly
|
| 159 |
}
|
| 160 |
if (typeof robotWake !== 'undefined') robotWake.showWake(daemonState);
|
| 161 |
|
| 162 |
+
// power off (or any non-running/non-starting state) darkens the LED eyes:
|
| 163 |
+
// a robot that is off with lit eyes makes no sense. updateUI only runs on
|
| 164 |
+
// a state CHANGE, so this fires once per transition, not every poll.
|
| 165 |
+
if (daemonState !== 'running' && daemonState !== 'starting'
|
| 166 |
+
&& typeof eyesFx !== 'undefined' && eyesFx.select) {
|
| 167 |
+
eyesFx.select('OFF');
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
// wake.js: scrolling messages while it boots, auto-switch to the camera
|
| 171 |
// once it actually produces an image
|
| 172 |
if (typeof robotWake !== 'undefined') robotWake.onState(daemonState);
|
|
|
|
| 196 |
// a fresh startup re-arms the idle greeting (updateUI only fires on a
|
| 197 |
// state change, so this is the transition INTO running, not every poll)
|
| 198 |
if (typeof idle !== 'undefined') idle.arm();
|
| 199 |
+
// the daemon wakes the robot at power-on (wake_up=true): keep idle
|
| 200 |
+
// quiet through that move so its greeting does not fight it
|
| 201 |
+
if (typeof robotWake !== 'undefined' && robotWake.markBusy) robotWake.markBusy();
|
| 202 |
+
// Coming back from a power-OFF while the SIMULATOR is on screen and no
|
| 203 |
+
// real robot is streaming: don't jump: give the twin ONE clean wake,
|
| 204 |
+
// folded (its rest) → neutral. A real robot drives the twin from its
|
| 205 |
+
// own telemetry instead, so this is skipped there.
|
| 206 |
+
if (daemon.wasOff && typeof viewer3d !== 'undefined' && viewer3d.robot
|
| 207 |
+
&& !viewer3d.streamLive()
|
| 208 |
+
&& typeof cameraView !== 'undefined' && cameraView.mode === '3d') {
|
| 209 |
+
viewer3d.wakeUpSim();
|
| 210 |
+
if (typeof robotWake !== 'undefined') robotWake.virtualAwake = true;
|
| 211 |
+
}
|
| 212 |
+
daemon.wasOff = false;
|
| 213 |
// Everything that reads the ROBOT was asked once, at page load — and a
|
| 214 |
// dashboard started before its robot got nothing but 502s. This is the
|
| 215 |
// first moment those questions have an answer, so they are asked again.
|
reachy_web/static/js/eyes_fx.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Reachy Eyes, the browser side. One module owns everything the LED eyes do:
|
| 2 |
+
// the color state, the scenario player, and the digital twin's mirror.
|
| 3 |
+
//
|
| 4 |
+
// Every command goes BOTH ways at once: to the module over /eyes/* when one is
|
| 5 |
+
// detected (serial, server-side), and to the twin's lenses ALWAYS — the
|
| 6 |
+
// simulator is never out of the loop, so what you play is what you see, robot
|
| 7 |
+
// or not. The scenarios re-create the behaviors of the vendor's demo scripts
|
| 8 |
+
// (github.com/brainwavecollective/reachy-eyes, no code copied) plus a few of
|
| 9 |
+
// our own; they run HERE, as cancellable async loops, which is what lets the
|
| 10 |
+
// twin and the LEDs stay in perfect step without a server-side player.
|
| 11 |
+
const eyesFx = {
|
| 12 |
+
// the firmware's palette — the only colors the protocol names
|
| 13 |
+
PALETTE: {
|
| 14 |
+
WHITE: '#f5f5f0', RED: '#e5232a', AMBER: '#ffb300', GREEN: '#1fbf3a',
|
| 15 |
+
CYAN: '#00c8d7', BLUE: '#1e5bff', MAGENTA: '#e0219e', OFF: null,
|
| 16 |
+
},
|
| 17 |
+
|
| 18 |
+
detected: false,
|
| 19 |
+
current: 'OFF', // what the picker shows — OFF at rest, like the real LEDs
|
| 20 |
+
colorL: null, // per-eye overrides (null = follow `current`), so a
|
| 21 |
+
colorR: null, // split scenario (weewoo…) mirrors correctly
|
| 22 |
+
playing: null, // scenario id, or null
|
| 23 |
+
_run: 0, // cancellation token: bumping it kills every loop
|
| 24 |
+
|
| 25 |
+
probe: async () => {
|
| 26 |
+
try {
|
| 27 |
+
const st = await fetch('/eyes/status').then((r) => r.json());
|
| 28 |
+
eyesFx.detected = !!(st && st.detected);
|
| 29 |
+
} catch { eyesFx.detected = false; }
|
| 30 |
+
return eyesFx.detected;
|
| 31 |
+
},
|
| 32 |
+
|
| 33 |
+
// probe once at load so the catalogue's Effects tab appears without ever
|
| 34 |
+
// opening the controller drawer (which re-probes on its own, for hotplug)
|
| 35 |
+
init: async () => {
|
| 36 |
+
const on = await eyesFx.probe();
|
| 37 |
+
if (typeof catalogue !== 'undefined') catalogue.fx(on);
|
| 38 |
+
},
|
| 39 |
+
|
| 40 |
+
post: (path, body) =>
|
| 41 |
+
fetch(path, {
|
| 42 |
+
method: 'POST',
|
| 43 |
+
headers: { 'Content-Type': 'application/json' },
|
| 44 |
+
body: JSON.stringify(body),
|
| 45 |
+
}).catch(() => {}),
|
| 46 |
+
|
| 47 |
+
// --- the two-way commands ---------------------------------------------
|
| 48 |
+
|
| 49 |
+
color: (name, opts = {}) => {
|
| 50 |
+
const { eye = null, dur = 0.4 } = opts;
|
| 51 |
+
if (eyesFx.detected) eyesFx.post('/eyes/color', { color: name, eye, duration: dur });
|
| 52 |
+
if (typeof viewer3d !== 'undefined') {
|
| 53 |
+
viewer3d.eyesColor(eyesFx.PALETTE[name] ?? null, eye, dur);
|
| 54 |
+
}
|
| 55 |
+
if (eye === 'LEFT') eyesFx.colorL = name;
|
| 56 |
+
else if (eye === 'RIGHT') eyesFx.colorR = name;
|
| 57 |
+
else { eyesFx.current = name; eyesFx.colorL = null; eyesFx.colorR = null; }
|
| 58 |
+
eyesFx.reflect();
|
| 59 |
+
},
|
| 60 |
+
|
| 61 |
+
// Mirror the LIVE eye colors into the chrome: the topbar effect label and the
|
| 62 |
+
// top-left logo eyes glow with whatever the eyes are showing, per eye. OFF (or
|
| 63 |
+
// an unknown name) → null, which lets the logo eyes fall back to the ink fill.
|
| 64 |
+
reflect: () => {
|
| 65 |
+
if (typeof topbar === 'undefined' || !topbar.setEyes) return;
|
| 66 |
+
const hex = (n) => {
|
| 67 |
+
if (!n || n === 'OFF') return null;
|
| 68 |
+
if (typeof n === 'string' && n[0] === '#') return n; // a free (custom) hex
|
| 69 |
+
return eyesFx.PALETTE[n] ?? null;
|
| 70 |
+
};
|
| 71 |
+
topbar.setEyes(hex(eyesFx.colorL ?? eyesFx.current), hex(eyesFx.colorR ?? eyesFx.current));
|
| 72 |
+
},
|
| 73 |
+
|
| 74 |
+
// a FREE colour (from the wheel), not one of the firmware's named ones. The
|
| 75 |
+
// twin shows the exact hex; the LED module speaks named colours only, so it
|
| 76 |
+
// gets the nearest name. `current` then holds the hex, not a name.
|
| 77 |
+
colorHex: (hex, opts = {}) => {
|
| 78 |
+
const { eye = null, dur = 0.4 } = opts;
|
| 79 |
+
if (eyesFx.detected) eyesFx.post('/eyes/color', { color: eyesFx.nearestName(hex), eye, duration: dur });
|
| 80 |
+
if (typeof viewer3d !== 'undefined') viewer3d.eyesColor(hex, eye, dur);
|
| 81 |
+
if (eye === 'LEFT') eyesFx.colorL = hex;
|
| 82 |
+
else if (eye === 'RIGHT') eyesFx.colorR = hex;
|
| 83 |
+
else { eyesFx.current = hex; eyesFx.colorL = null; eyesFx.colorR = null; }
|
| 84 |
+
eyesFx.reflect();
|
| 85 |
+
},
|
| 86 |
+
|
| 87 |
+
// nearest named colour to a hex (RGB Euclidean), for the module's named protocol
|
| 88 |
+
nearestName: (hex) => {
|
| 89 |
+
const rgb = (h) => {
|
| 90 |
+
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(h || '');
|
| 91 |
+
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null;
|
| 92 |
+
};
|
| 93 |
+
const c = rgb(hex);
|
| 94 |
+
if (!c) return 'WHITE';
|
| 95 |
+
let best = 'WHITE', bestD = Infinity;
|
| 96 |
+
for (const [name, h] of Object.entries(eyesFx.PALETTE)) {
|
| 97 |
+
const p = rgb(h);
|
| 98 |
+
if (!p) continue; // OFF (null) is skipped
|
| 99 |
+
const d = (c[0] - p[0]) ** 2 + (c[1] - p[1]) ** 2 + (c[2] - p[2]) ** 2;
|
| 100 |
+
if (d < bestD) { bestD = d; best = name; }
|
| 101 |
+
}
|
| 102 |
+
return best;
|
| 103 |
+
},
|
| 104 |
+
|
| 105 |
+
intensity: (v) => {
|
| 106 |
+
if (eyesFx.detected) eyesFx.post('/eyes/state', { intensity: v });
|
| 107 |
+
if (typeof viewer3d !== 'undefined') viewer3d.eyesState({ intensity: v });
|
| 108 |
+
},
|
| 109 |
+
|
| 110 |
+
energy: (v) => {
|
| 111 |
+
if (eyesFx.detected) eyesFx.post('/eyes/state', { energy: v });
|
| 112 |
+
if (typeof viewer3d !== 'undefined') viewer3d.eyesState({ energy: v });
|
| 113 |
+
},
|
| 114 |
+
|
| 115 |
+
effect: (name, eye = null) => {
|
| 116 |
+
if (eyesFx.detected) eyesFx.post('/eyes/effect', { effect: name, eye });
|
| 117 |
+
if (typeof viewer3d !== 'undefined') viewer3d.eyesEffect(name, eye);
|
| 118 |
+
},
|
| 119 |
+
|
| 120 |
+
// picking a plain color is also how you leave a scenario: kill the loop
|
| 121 |
+
// quietly, then settle on the chosen color
|
| 122 |
+
select: (name) => {
|
| 123 |
+
eyesFx._run++;
|
| 124 |
+
eyesFx.playing = null;
|
| 125 |
+
eyesFx.syncCards();
|
| 126 |
+
eyesFx.energy(0);
|
| 127 |
+
eyesFx.color(name, { dur: 0.4 });
|
| 128 |
+
},
|
| 129 |
+
|
| 130 |
+
// --- scenario player ---------------------------------------------------
|
| 131 |
+
|
| 132 |
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
| 133 |
+
rand: (lo, hi) => lo + Math.random() * (hi - lo),
|
| 134 |
+
pick: (arr) => arr[Math.floor(Math.random() * arr.length)],
|
| 135 |
+
|
| 136 |
+
play: async (id) => {
|
| 137 |
+
const sc = eyesFx.SCENARIOS[id];
|
| 138 |
+
if (!sc) return;
|
| 139 |
+
if (eyesFx.playing === id) { eyesFx.stop(); return; } // same card = toggle off
|
| 140 |
+
const run = ++eyesFx._run; // kills any previous loop
|
| 141 |
+
const alive = () => eyesFx._run === run;
|
| 142 |
+
eyesFx.playing = id;
|
| 143 |
+
eyesFx.syncCards();
|
| 144 |
+
if (sc.init) sc.init();
|
| 145 |
+
while (alive()) await sc.step(alive);
|
| 146 |
+
// whoever cancelled us repaints; a finished loop only cleans its own energy
|
| 147 |
+
},
|
| 148 |
+
|
| 149 |
+
stop: () => {
|
| 150 |
+
eyesFx._run++;
|
| 151 |
+
eyesFx.playing = null;
|
| 152 |
+
eyesFx.syncCards();
|
| 153 |
+
// back to a calm state: the picked color, no breathing
|
| 154 |
+
eyesFx.energy(0);
|
| 155 |
+
eyesFx.color(eyesFx.current, { dur: 0.6 });
|
| 156 |
+
},
|
| 157 |
+
|
| 158 |
+
// the running effect, visible everywhere: the active card wears the same .on
|
| 159 |
+
// ring as a playing move or the worn skin, and the topbar's state line says
|
| 160 |
+
// what the eyes are doing next to what the robot is running. The OFF card is
|
| 161 |
+
// active when nothing plays AND the LEDs are off.
|
| 162 |
+
syncCards: () => {
|
| 163 |
+
const isOff = !eyesFx.playing && eyesFx.current === 'OFF';
|
| 164 |
+
document.querySelectorAll('[data-sc]').forEach((b) =>
|
| 165 |
+
b.classList.toggle('on', b.dataset.sc === 'off' ? isOff : b.dataset.sc === eyesFx.playing));
|
| 166 |
+
if (typeof topbar !== 'undefined' && topbar.setFx) {
|
| 167 |
+
topbar.setFx(eyesFx.playing ? t('eyes.sc_' + eyesFx.playing) : null);
|
| 168 |
+
}
|
| 169 |
+
},
|
| 170 |
+
|
| 171 |
+
// switch everything off from the strip: stop any running scenario and turn the
|
| 172 |
+
// LEDs off. This is the OFF card, FIRST in the strip (before the scenarios).
|
| 173 |
+
allOff: () => {
|
| 174 |
+
eyesFx._run++;
|
| 175 |
+
eyesFx.playing = null;
|
| 176 |
+
eyesFx.syncCards();
|
| 177 |
+
eyesFx.energy(0);
|
| 178 |
+
eyesFx.color('OFF', { dur: 0.3 });
|
| 179 |
+
},
|
| 180 |
+
|
| 181 |
+
// the Effects strip in the catalogue — same .skin-chip gabarit as the skins
|
| 182 |
+
// and moves, the vignette being a live CSS preview of what the LEDs will do.
|
| 183 |
+
// The OFF card leads: "éteint" is a card you pick like any scenario.
|
| 184 |
+
buildCards: (container) => {
|
| 185 |
+
if (!container || container.dataset.built) return;
|
| 186 |
+
const off = `
|
| 187 |
+
<button class="skin-chip fx-chip" data-sc="off" title="${t('control.eyes_off')}">
|
| 188 |
+
<span class="skin-thumb fx-face fx-off"><span class="sc-eye l"></span><span class="sc-eye r"></span></span>
|
| 189 |
+
<span class="skin-name">${t('control.eyes_off')}</span>
|
| 190 |
+
</button>`;
|
| 191 |
+
container.innerHTML = off + Object.keys(eyesFx.SCENARIOS).map((id) => `
|
| 192 |
+
<button class="skin-chip fx-chip" data-sc="${id}" title="${t('eyes.sc_' + id)}">
|
| 193 |
+
<span class="skin-thumb fx-face sc-${id}"><span class="sc-eye l"></span><span class="sc-eye r"></span></span>
|
| 194 |
+
<span class="skin-name">${t('eyes.sc_' + id)}</span>
|
| 195 |
+
</button>`).join('');
|
| 196 |
+
container.dataset.built = '1';
|
| 197 |
+
container.querySelectorAll('[data-sc]').forEach((b) => {
|
| 198 |
+
b.addEventListener('click', () =>
|
| 199 |
+
b.dataset.sc === 'off' ? eyesFx.allOff() : eyesFx.play(b.dataset.sc));
|
| 200 |
+
});
|
| 201 |
+
eyesFx.syncCards();
|
| 202 |
+
},
|
| 203 |
+
|
| 204 |
+
// One entry per card, in display order. `step` is ONE cycle of the loop and
|
| 205 |
+
// must stay polite: check alive() after every sleep before sending more.
|
| 206 |
+
SCENARIOS: {
|
| 207 |
+
// — theirs, re-created —
|
| 208 |
+
wink: {
|
| 209 |
+
init: () => eyesFx.color('CYAN'),
|
| 210 |
+
step: async (alive) => {
|
| 211 |
+
eyesFx.effect('BLINK', eyesFx.pick(['LEFT', 'RIGHT', null]));
|
| 212 |
+
await eyesFx.sleep(eyesFx.rand(800, 3200));
|
| 213 |
+
},
|
| 214 |
+
},
|
| 215 |
+
breathe: {
|
| 216 |
+
init: () => { eyesFx.color('CYAN'); eyesFx.energy(0.75); },
|
| 217 |
+
step: async () => eyesFx.sleep(400),
|
| 218 |
+
},
|
| 219 |
+
smooth: {
|
| 220 |
+
step: async (alive) => {
|
| 221 |
+
for (const c of ['RED', 'CYAN', 'AMBER', 'MAGENTA', 'BLUE', 'GREEN']) {
|
| 222 |
+
if (!alive()) return;
|
| 223 |
+
eyesFx.color(c, { dur: 1.0 });
|
| 224 |
+
await eyesFx.sleep(2000);
|
| 225 |
+
}
|
| 226 |
+
},
|
| 227 |
+
},
|
| 228 |
+
disco: {
|
| 229 |
+
step: async (alive) => {
|
| 230 |
+
eyesFx.color(eyesFx.pick(['RED', 'CYAN', 'AMBER', 'MAGENTA', 'BLUE', 'GREEN']), { dur: 0 });
|
| 231 |
+
await eyesFx.sleep(250);
|
| 232 |
+
},
|
| 233 |
+
},
|
| 234 |
+
chaos: {
|
| 235 |
+
init: () => eyesFx.energy(0.8),
|
| 236 |
+
step: async (alive) => {
|
| 237 |
+
eyesFx.color(eyesFx.pick(['RED', 'GREEN', 'BLUE', 'CYAN', 'MAGENTA', 'AMBER']),
|
| 238 |
+
{ eye: 'LEFT', dur: eyesFx.rand(0, 0.3) });
|
| 239 |
+
await eyesFx.sleep(eyesFx.rand(150, 400));
|
| 240 |
+
if (!alive()) return;
|
| 241 |
+
eyesFx.color(eyesFx.pick(['RED', 'GREEN', 'BLUE', 'CYAN', 'MAGENTA', 'AMBER']),
|
| 242 |
+
{ eye: 'RIGHT', dur: eyesFx.rand(0, 0.3) });
|
| 243 |
+
if (Math.random() < 0.25) eyesFx.effect('BLINK', eyesFx.pick(['LEFT', 'RIGHT']));
|
| 244 |
+
await eyesFx.sleep(eyesFx.rand(150, 400));
|
| 245 |
+
},
|
| 246 |
+
},
|
| 247 |
+
weewoo: {
|
| 248 |
+
step: async (alive) => {
|
| 249 |
+
eyesFx.color('RED', { eye: 'LEFT', dur: 0 });
|
| 250 |
+
eyesFx.color('BLUE', { eye: 'RIGHT', dur: 0 });
|
| 251 |
+
eyesFx.effect('STARTLE');
|
| 252 |
+
await eyesFx.sleep(420);
|
| 253 |
+
if (!alive()) return;
|
| 254 |
+
eyesFx.color('BLUE', { eye: 'LEFT', dur: 0 });
|
| 255 |
+
eyesFx.color('RED', { eye: 'RIGHT', dur: 0 });
|
| 256 |
+
eyesFx.effect('STARTLE');
|
| 257 |
+
await eyesFx.sleep(420);
|
| 258 |
+
},
|
| 259 |
+
},
|
| 260 |
+
pinpon: {
|
| 261 |
+
step: async (alive) => {
|
| 262 |
+
for (const eye of ['LEFT', 'RIGHT']) {
|
| 263 |
+
for (let i = 0; i < 3; i++) {
|
| 264 |
+
eyesFx.color('BLUE', { eye, dur: 0 });
|
| 265 |
+
await eyesFx.sleep(70);
|
| 266 |
+
if (!alive()) return;
|
| 267 |
+
eyesFx.color('OFF', { eye, dur: 0 });
|
| 268 |
+
await eyesFx.sleep(70);
|
| 269 |
+
if (!alive()) return;
|
| 270 |
+
}
|
| 271 |
+
await eyesFx.sleep(120);
|
| 272 |
+
if (!alive()) return;
|
| 273 |
+
}
|
| 274 |
+
},
|
| 275 |
+
},
|
| 276 |
+
// — algoryn's ESP32 firmware animations, re-created as universal loops so
|
| 277 |
+
// they run on BOTH modules and the twin (their "joy" rainbow ≈ smooth) —
|
| 278 |
+
cylon: {
|
| 279 |
+
step: async (alive) => {
|
| 280 |
+
const c = eyesFx.current === 'OFF' ? 'RED' : eyesFx.current;
|
| 281 |
+
eyesFx.color(c, { eye: 'LEFT', dur: 0.3 });
|
| 282 |
+
eyesFx.color('OFF', { eye: 'RIGHT', dur: 0.3 });
|
| 283 |
+
await eyesFx.sleep(420);
|
| 284 |
+
if (!alive()) return;
|
| 285 |
+
eyesFx.color('OFF', { eye: 'LEFT', dur: 0.3 });
|
| 286 |
+
eyesFx.color(c, { eye: 'RIGHT', dur: 0.3 });
|
| 287 |
+
await eyesFx.sleep(420);
|
| 288 |
+
},
|
| 289 |
+
},
|
| 290 |
+
anger: {
|
| 291 |
+
init: () => eyesFx.color('RED', { dur: 0 }),
|
| 292 |
+
step: async () => {
|
| 293 |
+
eyesFx.effect('STARTLE');
|
| 294 |
+
await eyesFx.sleep(200);
|
| 295 |
+
},
|
| 296 |
+
},
|
| 297 |
+
amazement: {
|
| 298 |
+
step: async (alive) => {
|
| 299 |
+
for (const [c, hold] of [['WHITE', 90], ['CYAN', 110], ['WHITE', 90], ['CYAN', 900]]) {
|
| 300 |
+
if (!alive()) return;
|
| 301 |
+
eyesFx.color(c, { dur: 0 });
|
| 302 |
+
await eyesFx.sleep(hold);
|
| 303 |
+
}
|
| 304 |
+
},
|
| 305 |
+
},
|
| 306 |
+
// — ours —
|
| 307 |
+
heart: {
|
| 308 |
+
init: () => eyesFx.color('RED'),
|
| 309 |
+
step: async (alive) => {
|
| 310 |
+
eyesFx.effect('STARTLE');
|
| 311 |
+
await eyesFx.sleep(190);
|
| 312 |
+
if (!alive()) return;
|
| 313 |
+
eyesFx.effect('STARTLE');
|
| 314 |
+
await eyesFx.sleep(950);
|
| 315 |
+
},
|
| 316 |
+
},
|
| 317 |
+
hypno: {
|
| 318 |
+
step: async (alive) => {
|
| 319 |
+
eyesFx.color('CYAN', { eye: 'LEFT', dur: 1.2 });
|
| 320 |
+
eyesFx.color('MAGENTA', { eye: 'RIGHT', dur: 1.2 });
|
| 321 |
+
await eyesFx.sleep(1400);
|
| 322 |
+
if (!alive()) return;
|
| 323 |
+
eyesFx.color('MAGENTA', { eye: 'LEFT', dur: 1.2 });
|
| 324 |
+
eyesFx.color('CYAN', { eye: 'RIGHT', dur: 1.2 });
|
| 325 |
+
await eyesFx.sleep(1400);
|
| 326 |
+
},
|
| 327 |
+
},
|
| 328 |
+
sunset: {
|
| 329 |
+
step: async (alive) => {
|
| 330 |
+
for (const [c, hold] of [['AMBER', 2600], ['MAGENTA', 2600], ['RED', 2600], ['OFF', 2000]]) {
|
| 331 |
+
if (!alive()) return;
|
| 332 |
+
eyesFx.color(c, { dur: 2.0 });
|
| 333 |
+
await eyesFx.sleep(hold);
|
| 334 |
+
}
|
| 335 |
+
},
|
| 336 |
+
},
|
| 337 |
+
},
|
| 338 |
+
};
|
| 339 |
+
|
| 340 |
+
document.addEventListener('DOMContentLoaded', eyesFx.init);
|
reachy_web/static/js/idle.js
CHANGED
|
@@ -79,7 +79,14 @@ const idle = {
|
|
| 79 |
physical: () => typeof viewer3d !== 'undefined' && viewer3d.streamLive()
|
| 80 |
&& typeof robotWake !== 'undefined' && robotWake.awake === true,
|
| 81 |
virtual: () => typeof viewer3d !== 'undefined' && !!viewer3d.robot,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
eligible: () => idle.armed && document.visibilityState === 'visible'
|
|
|
|
| 83 |
&& (idle.physical() || idle.virtual()),
|
| 84 |
|
| 85 |
tick: () => {
|
|
|
|
| 79 |
physical: () => typeof viewer3d !== 'undefined' && viewer3d.streamLive()
|
| 80 |
&& typeof robotWake !== 'undefined' && robotWake.awake === true,
|
| 81 |
virtual: () => typeof viewer3d !== 'undefined' && !!viewer3d.robot,
|
| 82 |
+
// NEVER greet while the twin is owned by something else: a wake/fold animation
|
| 83 |
+
// in the sim (busyAnim), or the robot mid wake-up / sleep move (robotWake.busy).
|
| 84 |
+
// Idle sending set_target during wake_up was fighting the move — the twin
|
| 85 |
+
// jittered between many poses. The greeting waits for calm.
|
| 86 |
+
busy: () => (typeof viewer3d !== 'undefined' && viewer3d.busyAnim && viewer3d.busyAnim())
|
| 87 |
+
|| (typeof robotWake !== 'undefined' && robotWake.busy),
|
| 88 |
eligible: () => idle.armed && document.visibilityState === 'visible'
|
| 89 |
+
&& !idle.busy()
|
| 90 |
&& (idle.physical() || idle.virtual()),
|
| 91 |
|
| 92 |
tick: () => {
|
reachy_web/static/js/landing-i18n.js
CHANGED
|
@@ -18,6 +18,22 @@
|
|
| 18 |
|
| 19 |
const T = {
|
| 20 |
fr: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
"Reachy Mini App": "Application Reachy Mini",
|
| 22 |
"The web dashboard is back.": "Le dashboard web du Reachy",
|
| 23 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "Pollen a retiré le dashboard navigateur du daemon. Cette app le ramène, et va même plus loin : une caméra en direct, un jumeau numérique 3D, un panneau de logs colorisé, douze thèmes et huit langues. Installée comme n'importe quelle app Reachy Mini, sans app de bureau.",
|
|
@@ -77,8 +93,6 @@
|
|
| 77 |
"Changelog": "Journal des modifications",
|
| 78 |
"View older versions": "Voir les versions plus anciennes",
|
| 79 |
"Getting it running": "La mise en route",
|
| 80 |
-
"Two ways to run it": "Deux façons de le lancer",
|
| 81 |
-
"On the robot, as a real installed app. Or on your own machine, pointed at the robot over the network, handy for hacking on it.": "Sur le robot, comme une vraie app installée. Ou sur votre propre machine, pointée vers le robot par le réseau, pratique pour bricoler.",
|
| 82 |
"On the robot, through Pollen's app": "Sur le robot, via l'app de Pollen",
|
| 83 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "Il y a là une délicieuse ironie : la boutique qui installe le dashboard web vit dans l'app qui l'a remplacé. Récupérez",
|
| 84 |
"(desktop) or the": "(bureau) ou l'app",
|
|
@@ -105,6 +119,22 @@
|
|
| 105 |
"by": "par",
|
| 106 |
},
|
| 107 |
es: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
"Reachy Mini App": "Aplicación Reachy Mini",
|
| 109 |
"The web dashboard is back.": "El panel web ha vuelto.",
|
| 110 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "Pollen quitó el panel del navegador del daemon. Esta app lo trae de vuelta, y más: una cámara en vivo, un gemelo digital 3D, un panel de registros con colores, doce temas y ocho idiomas. Se instala como cualquier otra app de Reachy Mini, sin necesidad de app de escritorio.",
|
|
@@ -164,8 +194,6 @@
|
|
| 164 |
"Changelog": "Registro de cambios",
|
| 165 |
"View older versions": "Ver versiones anteriores",
|
| 166 |
"Getting it running": "Ponerlo en marcha",
|
| 167 |
-
"Two ways to run it": "Dos formas de ejecutarlo",
|
| 168 |
-
"On the robot, as a real installed app. Or on your own machine, pointed at the robot over the network, handy for hacking on it.": "En el robot, como una app instalada de verdad. O en tu propia máquina, apuntando al robot por la red, práctico para trastear con él.",
|
| 169 |
"On the robot, through Pollen's app": "En el robot, a través de la app de Pollen",
|
| 170 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "Aquí hay una ironía deliciosa: la tienda que instala el panel web vive en la app que lo reemplazó. Consigue",
|
| 171 |
"(desktop) or the": "(escritorio) o la app",
|
|
@@ -192,6 +220,22 @@
|
|
| 192 |
"by": "por",
|
| 193 |
},
|
| 194 |
de: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
"Reachy Mini App": "Reachy Mini App",
|
| 196 |
"The web dashboard is back.": "Das Web-Dashboard ist zurück.",
|
| 197 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "Pollen hat das Browser-Dashboard aus dem Daemon entfernt. Diese App bringt es zurück, und mehr: eine Live-Kamera, ein digitaler 3D-Zwilling, ein farbcodiertes Log-Panel, zwölf Themes und acht Sprachen. Installiert wie jede andere Reachy Mini App, ganz ohne Desktop-App.",
|
|
@@ -251,8 +295,6 @@
|
|
| 251 |
"Changelog": "Änderungsverlauf",
|
| 252 |
"View older versions": "Ältere Versionen anzeigen",
|
| 253 |
"Getting it running": "Zum Laufen bringen",
|
| 254 |
-
"Two ways to run it": "Zwei Wege, es auszuführen",
|
| 255 |
-
"On the robot, as a real installed app. Or on your own machine, pointed at the robot over the network, handy for hacking on it.": "Auf dem Roboter, als echte installierte App. Oder auf deinem eigenen Rechner, übers Netzwerk auf den Roboter gerichtet, praktisch zum Basteln.",
|
| 256 |
"On the robot, through Pollen's app": "Auf dem Roboter, über Pollens App",
|
| 257 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "Eine köstliche Ironie: Der Store, der das Web-Dashboard installiert, steckt in der App, die es ersetzt hat. Hol dir",
|
| 258 |
"(desktop) or the": "(Desktop) oder die",
|
|
@@ -279,6 +321,22 @@
|
|
| 279 |
"by": "von",
|
| 280 |
},
|
| 281 |
it: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
"Reachy Mini App": "App Reachy Mini",
|
| 283 |
"The web dashboard is back.": "Il pannello web è tornato.",
|
| 284 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "Pollen ha rimosso il pannello browser dal daemon. Questa app lo riporta, e fa di più: una telecamera in diretta, un gemello digitale 3D, un pannello di log a colori, dodici temi e otto lingue. Si installa come qualsiasi altra app Reachy Mini, senza bisogno di un'app desktop.",
|
|
@@ -338,8 +396,6 @@
|
|
| 338 |
"Changelog": "Registro modifiche",
|
| 339 |
"View older versions": "Vedi le versioni precedenti",
|
| 340 |
"Getting it running": "Metterlo in funzione",
|
| 341 |
-
"Two ways to run it": "Due modi per eseguirlo",
|
| 342 |
-
"On the robot, as a real installed app. Or on your own machine, pointed at the robot over the network, handy for hacking on it.": "Sul robot, come una vera app installata. Oppure sulla tua macchina, puntata al robot in rete, comodo per smanettarci.",
|
| 343 |
"On the robot, through Pollen's app": "Sul robot, tramite l'app di Pollen",
|
| 344 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "C'è una deliziosa ironia qui: lo store che installa il pannello web vive proprio nell'app che lo ha sostituito. Prendi",
|
| 345 |
"(desktop) or the": "(desktop) o l'app",
|
|
@@ -366,6 +422,22 @@
|
|
| 366 |
"by": "da",
|
| 367 |
},
|
| 368 |
pt: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
"Reachy Mini App": "App Reachy Mini",
|
| 370 |
"The web dashboard is back.": "O painel web está de volta.",
|
| 371 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "A Pollen removeu o painel do navegador do daemon. Este app o traz de volta, e vai além: uma câmera ao vivo, um gêmeo digital 3D, um painel de logs colorido, doze temas e oito idiomas. Instalado como qualquer outro app do Reachy Mini, sem precisar de app de desktop.",
|
|
@@ -425,8 +497,6 @@
|
|
| 425 |
"Changelog": "Registro de alterações",
|
| 426 |
"View older versions": "Ver versões mais antigas",
|
| 427 |
"Getting it running": "Colocando para rodar",
|
| 428 |
-
"Two ways to run it": "Duas formas de executá-lo",
|
| 429 |
-
"On the robot, as a real installed app. Or on your own machine, pointed at the robot over the network, handy for hacking on it.": "No robô, como um app instalado de verdade. Ou na sua própria máquina, apontada para o robô pela rede, prático para mexer nele.",
|
| 430 |
"On the robot, through Pollen's app": "No robô, pelo app da Pollen",
|
| 431 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "Há uma ironia deliciosa aqui: a loja que instala o painel web vive no app que o substituiu. Pegue o",
|
| 432 |
"(desktop) or the": "(desktop) ou o app",
|
|
@@ -453,6 +523,22 @@
|
|
| 453 |
"by": "por",
|
| 454 |
},
|
| 455 |
ja: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
"Reachy Mini App": "Reachy Mini アプリ",
|
| 457 |
"The web dashboard is back.": "Web ダッシュボードが戻ってきました。",
|
| 458 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "Pollen はブラウザ用のダッシュボードを daemon から削除しました。このアプリはそれを取り戻し、さらに進化させます。ライブカメラ、3D デジタルツイン、色分けされたログパネル、12 のテーマと 8 つの言語。他の Reachy Mini アプリと同じようにインストールでき、デスクトップアプリは不要です。",
|
|
@@ -512,8 +598,6 @@
|
|
| 512 |
"Changelog": "変更履歴",
|
| 513 |
"View older versions": "古いバージョンを見る",
|
| 514 |
"Getting it running": "動かしてみる",
|
| 515 |
-
"Two ways to run it": "実行する 2 つの方法",
|
| 516 |
-
"On the robot, as a real installed app. Or on your own machine, pointed at the robot over the network, handy for hacking on it.": "ロボット上で、本物のインストール済みアプリとして。あるいは自分のマシンで、ネットワーク越しにロボットを指定して。いじって遊ぶのに便利です。",
|
| 517 |
"On the robot, through Pollen's app": "ロボット上で、Pollen のアプリ経由",
|
| 518 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "ここには痛快な皮肉があります。Web ダッシュボードをインストールするストアが、それに取って代わったアプリの中にあるのです。入手するには",
|
| 519 |
"(desktop) or the": "(デスクトップ)または",
|
|
@@ -540,6 +624,22 @@
|
|
| 540 |
"by": "作:",
|
| 541 |
},
|
| 542 |
ar: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
"Reachy Mini App": "تطبيق Reachy Mini",
|
| 544 |
"The web dashboard is back.": "لوحة التحكم عبر الويب عادت.",
|
| 545 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "أزالت Pollen لوحة التحكم من المتصفح في الـ daemon. يعيدها هذا التطبيق، وأكثر: كاميرا مباشرة، وتوأم رقمي ثلاثي الأبعاد، ولوحة سجلات ملوّنة، واثنا عشر مظهرًا، وثماني لغات. يُثبَّت مثل أي تطبيق آخر لـ Reachy Mini، دون الحاجة إلى تطبيق سطح مكتب.",
|
|
@@ -599,8 +699,6 @@
|
|
| 599 |
"Changelog": "سجل التغييرات",
|
| 600 |
"View older versions": "عرض الإصدارات الأقدم",
|
| 601 |
"Getting it running": "تشغيله",
|
| 602 |
-
"Two ways to run it": "طريقتان لتشغيله",
|
| 603 |
-
"On the robot, as a real installed app. Or on your own machine, pointed at the robot over the network, handy for hacking on it.": "على الروبوت، كتطبيق حقيقي مثبّت. أو على جهازك الخاص، موجَّهًا إلى الروبوت عبر الشبكة، وهو مفيد للتجربة والتعديل.",
|
| 604 |
"On the robot, through Pollen's app": "على الروبوت، عبر تطبيق Pollen",
|
| 605 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "هناك مفارقة طريفة هنا: المتجر الذي يثبّت لوحة التحكم عبر الويب يعيش داخل التطبيق الذي حلّ محلها. احصل على",
|
| 606 |
"(desktop) or the": "(سطح المكتب) أو تطبيق",
|
|
@@ -628,11 +726,11 @@
|
|
| 628 |
}
|
| 629 |
};
|
| 630 |
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
|
| 635 |
-
|
| 636 |
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
| 637 |
acceptNode: function (node) {
|
| 638 |
for (let p = node.parentNode; p && p.nodeType === 1; p = p.parentNode) {
|
|
@@ -641,25 +739,58 @@
|
|
| 641 |
return NodeFilter.FILTER_ACCEPT;
|
| 642 |
}
|
| 643 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
|
| 645 |
-
|
| 646 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
}
|
|
|
|
| 655 |
|
| 656 |
-
|
| 657 |
-
|
|
|
|
|
|
|
| 658 |
}
|
| 659 |
|
| 660 |
if (document.readyState === 'loading') {
|
| 661 |
-
document.addEventListener('DOMContentLoaded',
|
| 662 |
} else {
|
| 663 |
-
|
| 664 |
}
|
| 665 |
})();
|
|
|
|
| 18 |
|
| 19 |
const T = {
|
| 20 |
fr: {
|
| 21 |
+
"Community hardware": "Matériel communautaire",
|
| 22 |
+
"Build it yourself": "Montez-le vous-même",
|
| 23 |
+
"Three ways to run it": "Trois façons de le lancer",
|
| 24 |
+
"Through Pollen's app, directly on the robot so it starts at boot, or on your own machine pointed at the robot over the network.": "Via l'app de Pollen, directement sur le robot pour qu'il démarre au boot, ou sur votre propre machine dirigée vers le robot sur le réseau.",
|
| 25 |
+
"Directly on the robot, at boot": "Directement sur le robot, au démarrage",
|
| 26 |
+
"No store, no problem: SSH into the robot and run setup.sh. It registers a systemd service, so the dashboard is up at every boot at http://your-robot.local:8042, with no SSH needed afterwards.": "Pas de store, pas de souci : connectez-vous en SSH au robot et lancez setup.sh. Il enregistre un service systemd, donc le tableau de bord est disponible à chaque démarrage sur http://your-robot.local:8042, sans SSH par la suite.",
|
| 27 |
+
"This is the way to go for the Wireless version, which installs it on its onboard CM4, or a Lite paired with a Raspberry Pi beside it (install on that Pi, the one already running the daemon).": "C'est la voie à suivre pour la version Wireless, qui l'installe sur son CM4 embarqué, ou pour une Lite associée à un Raspberry Pi à côté (installez-le sur ce Pi, celui qui fait déjà tourner le daemon).",
|
| 28 |
+
"setup.sh builds the virtualenv once, writes reachy-web.service and enables it. It boots without waking the robot; the dashboard wakes it on demand.": "setup.sh construit le virtualenv une fois, écrit reachy-web.service et l'active. Il démarre sans réveiller le robot ; le tableau de bord le réveille à la demande.",
|
| 29 |
+
"Check it, follow its logs, or remove it:": "Vérifiez-le, suivez ses logs, ou supprimez-le :",
|
| 30 |
+
"ESP32 eyes": "Des yeux ESP32",
|
| 31 |
+
"WiFi LED eyes on an ESP32: a REST API, pre-built emotions (joy, anger, amazement), mDNS discovery, and Reachy can drive its own gaze from the conversation app.": "Des yeux LED en WiFi sur un ESP32 : une REST API, des émotions prêtes à l'emploi (joie, colère, émerveillement), la découverte mDNS, et Reachy peut animer son propre regard depuis l'app de conversation.",
|
| 32 |
+
"One of the first eye kits for the Reachy Mini: a USB module with an LED behind each lens, ready-made or DIY. The dashboard auto-detects it and drives colors, moods and effects.": "L'un des premiers kits d'yeux pour le Reachy Mini : un module USB avec une LED derrière chaque lentille, prêt à l'emploi ou à monter soi-même. Le dashboard le détecte automatiquement et pilote couleurs, humeurs et effets.",
|
| 33 |
+
"Get the kit": "Obtenir le kit",
|
| 34 |
+
"Discuss the eyes on Discord": "Discuter des yeux sur Discord",
|
| 35 |
+
"Eyes for your Reachy": "Des yeux pour votre Reachy",
|
| 36 |
+
"The community fits the Reachy Mini with glowing, expressive eyes. Two projects lead the way; plug the USB kit in and the dashboard grows an Eyes panel.": "La communauté équipe le Reachy Mini d'yeux lumineux et expressifs. Deux projets ouvrent la voie ; branchez le kit USB et le dashboard fait apparaître un panneau Yeux.",
|
| 37 |
"Reachy Mini App": "Application Reachy Mini",
|
| 38 |
"The web dashboard is back.": "Le dashboard web du Reachy",
|
| 39 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "Pollen a retiré le dashboard navigateur du daemon. Cette app le ramène, et va même plus loin : une caméra en direct, un jumeau numérique 3D, un panneau de logs colorisé, douze thèmes et huit langues. Installée comme n'importe quelle app Reachy Mini, sans app de bureau.",
|
|
|
|
| 93 |
"Changelog": "Journal des modifications",
|
| 94 |
"View older versions": "Voir les versions plus anciennes",
|
| 95 |
"Getting it running": "La mise en route",
|
|
|
|
|
|
|
| 96 |
"On the robot, through Pollen's app": "Sur le robot, via l'app de Pollen",
|
| 97 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "Il y a là une délicieuse ironie : la boutique qui installe le dashboard web vit dans l'app qui l'a remplacé. Récupérez",
|
| 98 |
"(desktop) or the": "(bureau) ou l'app",
|
|
|
|
| 119 |
"by": "par",
|
| 120 |
},
|
| 121 |
es: {
|
| 122 |
+
"Community hardware": "Hardware comunitario",
|
| 123 |
+
"Build it yourself": "Constrúyelo tú mismo",
|
| 124 |
+
"Three ways to run it": "Tres formas de ejecutarlo",
|
| 125 |
+
"Through Pollen's app, directly on the robot so it starts at boot, or on your own machine pointed at the robot over the network.": "A través de la app de Pollen, directamente en el robot para que arranque al inicio, o en tu propia máquina apuntando al robot por la red.",
|
| 126 |
+
"Directly on the robot, at boot": "Directamente en el robot, al arrancar",
|
| 127 |
+
"No store, no problem: SSH into the robot and run setup.sh. It registers a systemd service, so the dashboard is up at every boot at http://your-robot.local:8042, with no SSH needed afterwards.": "Sin tienda, sin problema: conéctate por SSH al robot y ejecuta setup.sh. Registra un servicio systemd, así que el panel está disponible en cada arranque en http://your-robot.local:8042, sin necesidad de SSH después.",
|
| 128 |
+
"This is the way to go for the Wireless version, which installs it on its onboard CM4, or a Lite paired with a Raspberry Pi beside it (install on that Pi, the one already running the daemon).": "Es la opción indicada para la versión Wireless, que lo instala en su CM4 integrado, o para una Lite emparejada con una Raspberry Pi al lado (instálalo en esa Pi, la que ya ejecuta el daemon).",
|
| 129 |
+
"setup.sh builds the virtualenv once, writes reachy-web.service and enables it. It boots without waking the robot; the dashboard wakes it on demand.": "setup.sh crea el virtualenv una vez, escribe reachy-web.service y lo activa. Arranca sin despertar al robot; el panel lo despierta cuando hace falta.",
|
| 130 |
+
"Check it, follow its logs, or remove it:": "Compruébalo, sigue sus registros o elimínalo:",
|
| 131 |
+
"ESP32 eyes": "Ojos ESP32",
|
| 132 |
+
"WiFi LED eyes on an ESP32: a REST API, pre-built emotions (joy, anger, amazement), mDNS discovery, and Reachy can drive its own gaze from the conversation app.": "Ojos LED por WiFi en un ESP32: una REST API, emociones predefinidas (alegría, enfado, asombro), descubrimiento mDNS, y Reachy puede dirigir su propia mirada desde la app de conversación.",
|
| 133 |
+
"One of the first eye kits for the Reachy Mini: a USB module with an LED behind each lens, ready-made or DIY. The dashboard auto-detects it and drives colors, moods and effects.": "Uno de los primeros kits de ojos para el Reachy Mini: un módulo USB con una LED detrás de cada lente, listo para usar o para montar tú mismo. El dashboard lo detecta automáticamente y controla colores, estados de ánimo y efectos.",
|
| 134 |
+
"Get the kit": "Consigue el kit",
|
| 135 |
+
"Discuss the eyes on Discord": "Habla de los ojos en Discord",
|
| 136 |
+
"Eyes for your Reachy": "Ojos para tu Reachy",
|
| 137 |
+
"The community fits the Reachy Mini with glowing, expressive eyes. Two projects lead the way; plug the USB kit in and the dashboard grows an Eyes panel.": "La comunidad equipa al Reachy Mini con ojos luminosos y expresivos. Dos proyectos abren el camino; conecta el kit USB y el panel Ojos aparece en el dashboard.",
|
| 138 |
"Reachy Mini App": "Aplicación Reachy Mini",
|
| 139 |
"The web dashboard is back.": "El panel web ha vuelto.",
|
| 140 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "Pollen quitó el panel del navegador del daemon. Esta app lo trae de vuelta, y más: una cámara en vivo, un gemelo digital 3D, un panel de registros con colores, doce temas y ocho idiomas. Se instala como cualquier otra app de Reachy Mini, sin necesidad de app de escritorio.",
|
|
|
|
| 194 |
"Changelog": "Registro de cambios",
|
| 195 |
"View older versions": "Ver versiones anteriores",
|
| 196 |
"Getting it running": "Ponerlo en marcha",
|
|
|
|
|
|
|
| 197 |
"On the robot, through Pollen's app": "En el robot, a través de la app de Pollen",
|
| 198 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "Aquí hay una ironía deliciosa: la tienda que instala el panel web vive en la app que lo reemplazó. Consigue",
|
| 199 |
"(desktop) or the": "(escritorio) o la app",
|
|
|
|
| 220 |
"by": "por",
|
| 221 |
},
|
| 222 |
de: {
|
| 223 |
+
"Community hardware": "Community-Hardware",
|
| 224 |
+
"Build it yourself": "Selbst bauen",
|
| 225 |
+
"Three ways to run it": "Drei Wege, es zu starten",
|
| 226 |
+
"Through Pollen's app, directly on the robot so it starts at boot, or on your own machine pointed at the robot over the network.": "Über die App von Pollen, direkt auf dem Roboter, damit er beim Start hochfährt, oder auf deinem eigenen Rechner, der über das Netzwerk auf den Roboter zeigt.",
|
| 227 |
+
"Directly on the robot, at boot": "Direkt auf dem Roboter, beim Start",
|
| 228 |
+
"No store, no problem: SSH into the robot and run setup.sh. It registers a systemd service, so the dashboard is up at every boot at http://your-robot.local:8042, with no SSH needed afterwards.": "Kein Store, kein Problem: per SSH auf den Roboter und setup.sh ausführen. Es registriert einen systemd-Dienst, sodass das Dashboard bei jedem Start unter http://your-robot.local:8042 bereitsteht, danach ohne SSH.",
|
| 229 |
+
"This is the way to go for the Wireless version, which installs it on its onboard CM4, or a Lite paired with a Raspberry Pi beside it (install on that Pi, the one already running the daemon).": "Das ist der Weg für die Wireless-Version, die es auf ihrem integrierten CM4 installiert, oder für eine Lite, die mit einem daneben stehenden Raspberry Pi gekoppelt ist (auf diesem Pi installieren, dem, der bereits den daemon ausführt).",
|
| 230 |
+
"setup.sh builds the virtualenv once, writes reachy-web.service and enables it. It boots without waking the robot; the dashboard wakes it on demand.": "setup.sh baut den virtualenv einmal, schreibt reachy-web.service und aktiviert ihn. Es startet, ohne den Roboter zu wecken; das Dashboard weckt ihn bei Bedarf.",
|
| 231 |
+
"Check it, follow its logs, or remove it:": "Prüfen, die Logs verfolgen oder wieder entfernen:",
|
| 232 |
+
"ESP32 eyes": "ESP32-Augen",
|
| 233 |
+
"WiFi LED eyes on an ESP32: a REST API, pre-built emotions (joy, anger, amazement), mDNS discovery, and Reachy can drive its own gaze from the conversation app.": "WiFi-LED-Augen auf einem ESP32: eine REST API, fertige Emotionen (Freude, Wut, Staunen), mDNS-Erkennung, und Reachy steuert seinen Blick selbst aus der Konversations-App.",
|
| 234 |
+
"One of the first eye kits for the Reachy Mini: a USB module with an LED behind each lens, ready-made or DIY. The dashboard auto-detects it and drives colors, moods and effects.": "Eines der ersten Augen-Kits für den Reachy Mini: ein USB-Modul mit einer LED hinter jeder Linse, fertig oder zum Selberbauen. Das Dashboard erkennt es automatisch und steuert Farben, Stimmungen und Effekte.",
|
| 235 |
+
"Get the kit": "Kit holen",
|
| 236 |
+
"Discuss the eyes on Discord": "Über die Augen auf Discord sprechen",
|
| 237 |
+
"Eyes for your Reachy": "Augen für deinen Reachy",
|
| 238 |
+
"The community fits the Reachy Mini with glowing, expressive eyes. Two projects lead the way; plug the USB kit in and the dashboard grows an Eyes panel.": "Die Community verpasst dem Reachy Mini leuchtende, ausdrucksstarke Augen. Zwei Projekte gehen voran; steck das USB-Kit ein und im Dashboard erscheint ein Augen-Panel.",
|
| 239 |
"Reachy Mini App": "Reachy Mini App",
|
| 240 |
"The web dashboard is back.": "Das Web-Dashboard ist zurück.",
|
| 241 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "Pollen hat das Browser-Dashboard aus dem Daemon entfernt. Diese App bringt es zurück, und mehr: eine Live-Kamera, ein digitaler 3D-Zwilling, ein farbcodiertes Log-Panel, zwölf Themes und acht Sprachen. Installiert wie jede andere Reachy Mini App, ganz ohne Desktop-App.",
|
|
|
|
| 295 |
"Changelog": "Änderungsverlauf",
|
| 296 |
"View older versions": "Ältere Versionen anzeigen",
|
| 297 |
"Getting it running": "Zum Laufen bringen",
|
|
|
|
|
|
|
| 298 |
"On the robot, through Pollen's app": "Auf dem Roboter, über Pollens App",
|
| 299 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "Eine köstliche Ironie: Der Store, der das Web-Dashboard installiert, steckt in der App, die es ersetzt hat. Hol dir",
|
| 300 |
"(desktop) or the": "(Desktop) oder die",
|
|
|
|
| 321 |
"by": "von",
|
| 322 |
},
|
| 323 |
it: {
|
| 324 |
+
"Community hardware": "Hardware della community",
|
| 325 |
+
"Build it yourself": "Costruiscilo da te",
|
| 326 |
+
"Three ways to run it": "Tre modi per avviarlo",
|
| 327 |
+
"Through Pollen's app, directly on the robot so it starts at boot, or on your own machine pointed at the robot over the network.": "Tramite l'app di Pollen, direttamente sul robot così parte all'avvio, oppure sulla tua macchina puntata verso il robot sulla rete.",
|
| 328 |
+
"Directly on the robot, at boot": "Direttamente sul robot, all'avvio",
|
| 329 |
+
"No store, no problem: SSH into the robot and run setup.sh. It registers a systemd service, so the dashboard is up at every boot at http://your-robot.local:8042, with no SSH needed afterwards.": "Niente store, nessun problema: collegati in SSH al robot ed esegui setup.sh. Registra un servizio systemd, così la dashboard è pronta a ogni avvio su http://your-robot.local:8042, senza più bisogno di SSH.",
|
| 330 |
+
"This is the way to go for the Wireless version, which installs it on its onboard CM4, or a Lite paired with a Raspberry Pi beside it (install on that Pi, the one already running the daemon).": "È la strada giusta per la versione Wireless, che lo installa sul suo CM4 integrato, o per una Lite abbinata a un Raspberry Pi accanto (installalo su quel Pi, quello che già esegue il daemon).",
|
| 331 |
+
"setup.sh builds the virtualenv once, writes reachy-web.service and enables it. It boots without waking the robot; the dashboard wakes it on demand.": "setup.sh crea il virtualenv una volta, scrive reachy-web.service e lo abilita. Si avvia senza svegliare il robot; la dashboard lo sveglia su richiesta.",
|
| 332 |
+
"Check it, follow its logs, or remove it:": "Controllalo, segui i suoi log oppure rimuovilo:",
|
| 333 |
+
"ESP32 eyes": "Occhi ESP32",
|
| 334 |
+
"WiFi LED eyes on an ESP32: a REST API, pre-built emotions (joy, anger, amazement), mDNS discovery, and Reachy can drive its own gaze from the conversation app.": "Occhi LED in WiFi su un ESP32: una REST API, emozioni pronte all'uso (gioia, rabbia, stupore), rilevamento mDNS, e Reachy può guidare il proprio sguardo dall'app di conversazione.",
|
| 335 |
+
"One of the first eye kits for the Reachy Mini: a USB module with an LED behind each lens, ready-made or DIY. The dashboard auto-detects it and drives colors, moods and effects.": "Uno dei primi kit di occhi per il Reachy Mini: un modulo USB con una LED dietro ogni lente, pronto all'uso o da montare da soli. La dashboard lo rileva automaticamente e gestisce colori, stati d'animo ed effetti.",
|
| 336 |
+
"Get the kit": "Ottieni il kit",
|
| 337 |
+
"Discuss the eyes on Discord": "Parla degli occhi su Discord",
|
| 338 |
+
"Eyes for your Reachy": "Occhi per il tuo Reachy",
|
| 339 |
+
"The community fits the Reachy Mini with glowing, expressive eyes. Two projects lead the way; plug the USB kit in and the dashboard grows an Eyes panel.": "La community dota il Reachy Mini di occhi luminosi ed espressivi. Due progetti fanno strada; collega il kit USB e nel dashboard compare un pannello Occhi.",
|
| 340 |
"Reachy Mini App": "App Reachy Mini",
|
| 341 |
"The web dashboard is back.": "Il pannello web è tornato.",
|
| 342 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "Pollen ha rimosso il pannello browser dal daemon. Questa app lo riporta, e fa di più: una telecamera in diretta, un gemello digitale 3D, un pannello di log a colori, dodici temi e otto lingue. Si installa come qualsiasi altra app Reachy Mini, senza bisogno di un'app desktop.",
|
|
|
|
| 396 |
"Changelog": "Registro modifiche",
|
| 397 |
"View older versions": "Vedi le versioni precedenti",
|
| 398 |
"Getting it running": "Metterlo in funzione",
|
|
|
|
|
|
|
| 399 |
"On the robot, through Pollen's app": "Sul robot, tramite l'app di Pollen",
|
| 400 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "C'è una deliziosa ironia qui: lo store che installa il pannello web vive proprio nell'app che lo ha sostituito. Prendi",
|
| 401 |
"(desktop) or the": "(desktop) o l'app",
|
|
|
|
| 422 |
"by": "da",
|
| 423 |
},
|
| 424 |
pt: {
|
| 425 |
+
"Community hardware": "Hardware da comunidade",
|
| 426 |
+
"Build it yourself": "Monte o seu",
|
| 427 |
+
"Three ways to run it": "Três formas de o executar",
|
| 428 |
+
"Through Pollen's app, directly on the robot so it starts at boot, or on your own machine pointed at the robot over the network.": "Pela app da Pollen, diretamente no robô para que arranque na inicialização, ou na sua própria máquina apontada para o robô pela rede.",
|
| 429 |
+
"Directly on the robot, at boot": "Diretamente no robô, na inicialização",
|
| 430 |
+
"No store, no problem: SSH into the robot and run setup.sh. It registers a systemd service, so the dashboard is up at every boot at http://your-robot.local:8042, with no SSH needed afterwards.": "Sem loja, sem problema: entre por SSH no robô e execute setup.sh. Regista um serviço systemd, por isso o painel fica disponível em cada arranque em http://your-robot.local:8042, sem precisar de SSH depois.",
|
| 431 |
+
"This is the way to go for the Wireless version, which installs it on its onboard CM4, or a Lite paired with a Raspberry Pi beside it (install on that Pi, the one already running the daemon).": "É o caminho a seguir para a versão Wireless, que o instala no seu CM4 integrado, ou para uma Lite emparelhada com um Raspberry Pi ao lado (instale nesse Pi, o que já executa o daemon).",
|
| 432 |
+
"setup.sh builds the virtualenv once, writes reachy-web.service and enables it. It boots without waking the robot; the dashboard wakes it on demand.": "setup.sh cria o virtualenv uma vez, escreve reachy-web.service e ativa-o. Arranca sem acordar o robô; o painel acorda-o quando é preciso.",
|
| 433 |
+
"Check it, follow its logs, or remove it:": "Verifique-o, acompanhe os seus registos ou remova-o:",
|
| 434 |
+
"ESP32 eyes": "Olhos ESP32",
|
| 435 |
+
"WiFi LED eyes on an ESP32: a REST API, pre-built emotions (joy, anger, amazement), mDNS discovery, and Reachy can drive its own gaze from the conversation app.": "Olhos LED por WiFi num ESP32: uma REST API, emoções prontas a usar (alegria, raiva, espanto), descoberta mDNS, e o Reachy pode dirigir o próprio olhar a partir da app de conversação.",
|
| 436 |
+
"One of the first eye kits for the Reachy Mini: a USB module with an LED behind each lens, ready-made or DIY. The dashboard auto-detects it and drives colors, moods and effects.": "Um dos primeiros kits de olhos para o Reachy Mini: um módulo USB com uma LED atrás de cada lente, pronto a usar ou para montar. O dashboard deteta-o automaticamente e controla cores, humores e efeitos.",
|
| 437 |
+
"Get the kit": "Obter o kit",
|
| 438 |
+
"Discuss the eyes on Discord": "Fala sobre os olhos no Discord",
|
| 439 |
+
"Eyes for your Reachy": "Olhos para o seu Reachy",
|
| 440 |
+
"The community fits the Reachy Mini with glowing, expressive eyes. Two projects lead the way; plug the USB kit in and the dashboard grows an Eyes panel.": "A comunidade equipa o Reachy Mini com olhos luminosos e expressivos. Dois projetos abrem caminho; conecte o kit USB e o painel Olhos aparece no dashboard.",
|
| 441 |
"Reachy Mini App": "App Reachy Mini",
|
| 442 |
"The web dashboard is back.": "O painel web está de volta.",
|
| 443 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "A Pollen removeu o painel do navegador do daemon. Este app o traz de volta, e vai além: uma câmera ao vivo, um gêmeo digital 3D, um painel de logs colorido, doze temas e oito idiomas. Instalado como qualquer outro app do Reachy Mini, sem precisar de app de desktop.",
|
|
|
|
| 497 |
"Changelog": "Registro de alterações",
|
| 498 |
"View older versions": "Ver versões mais antigas",
|
| 499 |
"Getting it running": "Colocando para rodar",
|
|
|
|
|
|
|
| 500 |
"On the robot, through Pollen's app": "No robô, pelo app da Pollen",
|
| 501 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "Há uma ironia deliciosa aqui: a loja que instala o painel web vive no app que o substituiu. Pegue o",
|
| 502 |
"(desktop) or the": "(desktop) ou o app",
|
|
|
|
| 523 |
"by": "por",
|
| 524 |
},
|
| 525 |
ja: {
|
| 526 |
+
"Community hardware": "コミュニティハードウェア",
|
| 527 |
+
"Build it yourself": "自分で組み立てる",
|
| 528 |
+
"Three ways to run it": "起動する3つの方法",
|
| 529 |
+
"Through Pollen's app, directly on the robot so it starts at boot, or on your own machine pointed at the robot over the network.": "Pollenのアプリ経由で、起動時に立ち上がるようロボット上で直接、あるいはネットワーク越しにロボットを指定した自分のマシンで。",
|
| 530 |
+
"Directly on the robot, at boot": "ロボット上で直接、起動時に",
|
| 531 |
+
"No store, no problem: SSH into the robot and run setup.sh. It registers a systemd service, so the dashboard is up at every boot at http://your-robot.local:8042, with no SSH needed afterwards.": "ストア不要、問題ありません。SSHでロボットに入り、setup.sh を実行します。systemd サービスを登録するので、以降は SSH なしで、起動のたびにダッシュボードが http://your-robot.local:8042 で立ち上がります。",
|
| 532 |
+
"This is the way to go for the Wireless version, which installs it on its onboard CM4, or a Lite paired with a Raspberry Pi beside it (install on that Pi, the one already running the daemon).": "これは、内蔵の CM4 にインストールする Wireless 版、あるいは隣に Raspberry Pi を組み合わせた Lite に最適な方法です(すでに daemon を動かしているその Pi にインストールしてください)。",
|
| 533 |
+
"setup.sh builds the virtualenv once, writes reachy-web.service and enables it. It boots without waking the robot; the dashboard wakes it on demand.": "setup.sh は virtualenv を一度だけ作成し、reachy-web.service を書き込んで有効化します。ロボットを起こさずに立ち上がり、ダッシュボードが必要に応じて起こします。",
|
| 534 |
+
"Check it, follow its logs, or remove it:": "状態を確認したり、ログを追ったり、削除したりできます:",
|
| 535 |
+
"ESP32 eyes": "ESP32のアイ",
|
| 536 |
+
"WiFi LED eyes on an ESP32: a REST API, pre-built emotions (joy, anger, amazement), mDNS discovery, and Reachy can drive its own gaze from the conversation app.": "ESP32上のWiFi対応LEDアイ。REST API、すぐ使える感情表現(喜び、怒り、驚き)、mDNS検出、そしてReachyは会話アプリから自ら視線を動かせます。",
|
| 537 |
+
"One of the first eye kits for the Reachy Mini: a USB module with an LED behind each lens, ready-made or DIY. The dashboard auto-detects it and drives colors, moods and effects.": "Reachy Mini向けの最初期のアイキットのひとつ。各レンズの奥にLEDを備えたUSBモジュールで、完成品でもDIYでも使えます。ダッシュボードが自動で検出し、色やムード、エフェクトを操ります。",
|
| 538 |
+
"Get the kit": "キットを入手",
|
| 539 |
+
"Discuss the eyes on Discord": "Discordで目について話す",
|
| 540 |
+
"Eyes for your Reachy": "Reachyに目を",
|
| 541 |
+
"The community fits the Reachy Mini with glowing, expressive eyes. Two projects lead the way; plug the USB kit in and the dashboard grows an Eyes panel.": "コミュニティがReachy Miniに光る表情豊かな目を与えています。先行する2つのプロジェクト。USBキットをつなぐと、ダッシュボードに「目」パネルが現れます。",
|
| 542 |
"Reachy Mini App": "Reachy Mini アプリ",
|
| 543 |
"The web dashboard is back.": "Web ダッシュボードが戻ってきました。",
|
| 544 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "Pollen はブラウザ用のダッシュボードを daemon から削除しました。このアプリはそれを取り戻し、さらに進化させます。ライブカメラ、3D デジタルツイン、色分けされたログパネル、12 のテーマと 8 つの言語。他の Reachy Mini アプリと同じようにインストールでき、デスクトップアプリは不要です。",
|
|
|
|
| 598 |
"Changelog": "変更履歴",
|
| 599 |
"View older versions": "古いバージョンを見る",
|
| 600 |
"Getting it running": "動かしてみる",
|
|
|
|
|
|
|
| 601 |
"On the robot, through Pollen's app": "ロボット上で、Pollen のアプリ経由",
|
| 602 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "ここには痛快な皮肉があります。Web ダッシュボードをインストールするストアが、それに取って代わったアプリの中にあるのです。入手するには",
|
| 603 |
"(desktop) or the": "(デスクトップ)または",
|
|
|
|
| 624 |
"by": "作:",
|
| 625 |
},
|
| 626 |
ar: {
|
| 627 |
+
"Community hardware": "عتاد المجتمع",
|
| 628 |
+
"Build it yourself": "اصنعه بنفسك",
|
| 629 |
+
"Three ways to run it": "ثلاث طرق لتشغيله",
|
| 630 |
+
"Through Pollen's app, directly on the robot so it starts at boot, or on your own machine pointed at the robot over the network.": "عبر تطبيق Pollen، مباشرة على الروبوت ليبدأ عند الإقلاع، أو على جهازك الخاص موجَّهًا إلى الروبوت عبر الشبكة.",
|
| 631 |
+
"Directly on the robot, at boot": "مباشرة على الروبوت، عند الإقلاع",
|
| 632 |
+
"No store, no problem: SSH into the robot and run setup.sh. It registers a systemd service, so the dashboard is up at every boot at http://your-robot.local:8042, with no SSH needed afterwards.": "لا متجر، لا مشكلة: اتصل بالروبوت عبر SSH وشغّل setup.sh. يسجّل خدمة systemd، فتصبح لوحة التحكم جاهزة عند كل إقلاع على http://your-robot.local:8042، دون حاجة إلى SSH بعد ذلك.",
|
| 633 |
+
"This is the way to go for the Wireless version, which installs it on its onboard CM4, or a Lite paired with a Raspberry Pi beside it (install on that Pi, the one already running the daemon).": "هذه هي الطريقة المثلى لنسخة Wireless، التي تثبّته على وحدة CM4 المدمجة فيها، أو لنسخة Lite مقترنة بجهاز Raspberry Pi بجانبها (ثبّته على ذلك الـ Pi، الذي يشغّل daemon بالفعل).",
|
| 634 |
+
"setup.sh builds the virtualenv once, writes reachy-web.service and enables it. It boots without waking the robot; the dashboard wakes it on demand.": "يبني setup.sh الـ virtualenv مرة واحدة، ويكتب reachy-web.service ويفعّله. يقلع دون إيقاظ الروبوت؛ ولوحة التحكم توقظه عند الحاجة.",
|
| 635 |
+
"Check it, follow its logs, or remove it:": "تحقّق منه، أو تابع سجلّاته، أو أزله:",
|
| 636 |
+
"ESP32 eyes": "عيون ESP32",
|
| 637 |
+
"WiFi LED eyes on an ESP32: a REST API, pre-built emotions (joy, anger, amazement), mDNS discovery, and Reachy can drive its own gaze from the conversation app.": "عيون LED عبر WiFi على ESP32: واجهة REST API، وانفعالات جاهزة (الفرح، الغضب، الدهشة)، واكتشاف mDNS، ويستطيع Reachy توجيه نظرته بنفسه من تطبيق ا��محادثة.",
|
| 638 |
+
"One of the first eye kits for the Reachy Mini: a USB module with an LED behind each lens, ready-made or DIY. The dashboard auto-detects it and drives colors, moods and effects.": "واحدة من أوائل مجموعات العيون لـ Reachy Mini: وحدة USB بها LED خلف كل عدسة، جاهزة أو للتركيب بنفسك. تكتشفها لوحة التحكم تلقائيًا وتتحكم في الألوان والحالات المزاجية والمؤثرات.",
|
| 639 |
+
"Get the kit": "احصل على المجموعة",
|
| 640 |
+
"Discuss the eyes on Discord": "ناقش العيون على Discord",
|
| 641 |
+
"Eyes for your Reachy": "عيون لروبوتك Reachy",
|
| 642 |
+
"The community fits the Reachy Mini with glowing, expressive eyes. Two projects lead the way; plug the USB kit in and the dashboard grows an Eyes panel.": "يمنح المجتمع روبوت Reachy Mini عيونًا مضيئة معبّرة. مشروعان يفتحان الطريق؛ وصّل طقم USB فتظهر لوحة العيون في لوحة التحكم.",
|
| 643 |
"Reachy Mini App": "تطبيق Reachy Mini",
|
| 644 |
"The web dashboard is back.": "لوحة التحكم عبر الويب عادت.",
|
| 645 |
"Pollen removed the browser dashboard from the daemon. This app brings it back, and then some: a live camera, a 3D digital twin, a colour-coded log panel, twelve themes and eight languages. Installed like any other Reachy Mini app, no desktop app needed.": "أزالت Pollen لوحة التحكم من المتصفح في الـ daemon. يعيدها هذا التطبيق، وأكثر: كاميرا مباشرة، وتوأم رقمي ثلاثي الأبعاد، ولوحة سجلات ملوّنة، واثنا عشر مظهرًا، وثماني لغات. يُثبَّت مثل أي تطبيق آخر لـ Reachy Mini، دون الحاجة إلى تطبيق سطح مكتب.",
|
|
|
|
| 699 |
"Changelog": "سجل التغييرات",
|
| 700 |
"View older versions": "عرض الإصدارات الأقدم",
|
| 701 |
"Getting it running": "تشغيله",
|
|
|
|
|
|
|
| 702 |
"On the robot, through Pollen's app": "على الروبوت، عبر تطبيق Pollen",
|
| 703 |
"There is a delicious irony here: the store that installs the web dashboard lives in the app that replaced it. Grab": "هناك مفارقة طريفة هنا: المتجر الذي يثبّت لوحة التحكم عبر الويب يعيش داخل التطبيق الذي حلّ محلها. احصل على",
|
| 704 |
"(desktop) or the": "(سطح المكتب) أو تطبيق",
|
|
|
|
| 726 |
}
|
| 727 |
};
|
| 728 |
|
| 729 |
+
const LANGS = ['en', 'fr', 'es', 'de', 'it', 'pt', 'ja', 'ar'];
|
| 730 |
+
const SKIP = { CODE: 1, PRE: 1, SCRIPT: 1, STYLE: 1, NOSCRIPT: 1 };
|
| 731 |
+
let CACHE = null; // [{node, orig}] collected once, so languages can be switched live
|
| 732 |
|
| 733 |
+
function collect() {
|
| 734 |
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
| 735 |
acceptNode: function (node) {
|
| 736 |
for (let p = node.parentNode; p && p.nodeType === 1; p = p.parentNode) {
|
|
|
|
| 739 |
return NodeFilter.FILTER_ACCEPT;
|
| 740 |
}
|
| 741 |
});
|
| 742 |
+
const out = [];
|
| 743 |
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
| 744 |
+
if (n.nodeValue.trim()) out.push({ node: n, orig: n.nodeValue });
|
| 745 |
+
}
|
| 746 |
+
return out;
|
| 747 |
+
}
|
| 748 |
|
| 749 |
+
// Translate every cached node FROM its original English, so switching is reversible
|
| 750 |
+
// (English is just the case where no dictionary matches). <head> is never touched.
|
| 751 |
+
function apply(target) {
|
| 752 |
+
if (!CACHE) CACHE = collect();
|
| 753 |
+
const dict = T[target];
|
| 754 |
+
for (const item of CACHE) {
|
| 755 |
+
const k = item.orig.trim();
|
| 756 |
+
item.node.nodeValue = (dict && Object.prototype.hasOwnProperty.call(dict, k))
|
| 757 |
+
? item.orig.replace(k, dict[k])
|
| 758 |
+
: item.orig;
|
| 759 |
+
}
|
| 760 |
+
document.documentElement.lang = target;
|
| 761 |
+
document.documentElement.dir = (target === 'ar') ? 'rtl' : 'ltr';
|
| 762 |
+
current = target;
|
| 763 |
+
const sel = document.getElementById('langSel');
|
| 764 |
+
if (sel && sel.value !== target) sel.value = target;
|
| 765 |
+
}
|
| 766 |
|
| 767 |
+
// Default is the browser language (English fallback). A manual pick is remembered
|
| 768 |
+
// in localStorage and wins on the next visit, but detection stays the default.
|
| 769 |
+
let saved = null;
|
| 770 |
+
try { saved = localStorage.getItem('lang'); } catch (e) {}
|
| 771 |
+
let current = (saved && LANGS.indexOf(saved) >= 0)
|
| 772 |
+
? saved
|
| 773 |
+
: (LANGS.indexOf(lang) >= 0 ? lang : 'en');
|
| 774 |
+
|
| 775 |
+
window.landingI18n = {
|
| 776 |
+
langs: LANGS,
|
| 777 |
+
get current() { return current; },
|
| 778 |
+
set: function (target) {
|
| 779 |
+
if (LANGS.indexOf(target) < 0) return;
|
| 780 |
+
try { localStorage.setItem('lang', target); } catch (e) {}
|
| 781 |
+
apply(target);
|
| 782 |
}
|
| 783 |
+
};
|
| 784 |
|
| 785 |
+
function boot() {
|
| 786 |
+
apply(current);
|
| 787 |
+
const sel = document.getElementById('langSel');
|
| 788 |
+
if (sel) sel.addEventListener('change', function () { window.landingI18n.set(this.value); });
|
| 789 |
}
|
| 790 |
|
| 791 |
if (document.readyState === 'loading') {
|
| 792 |
+
document.addEventListener('DOMContentLoaded', boot);
|
| 793 |
} else {
|
| 794 |
+
boot();
|
| 795 |
}
|
| 796 |
})();
|
reachy_web/static/js/locales.js
CHANGED
|
@@ -35,7 +35,7 @@ window.LOCALES = {
|
|
| 35 |
"cam.eyes.5": "Checking I still have both eyes…",
|
| 36 |
"cam.eyes.6": "Recounting my pixels…",
|
| 37 |
"cam.eyes.7": "Wiping off the fog…",
|
| 38 |
-
"cam.eyes.8": "Five more
|
| 39 |
"cam.eyes.9": "And then there was light…",
|
| 40 |
"cam.eyes.10": "I glitch a bit when I wake up…",
|
| 41 |
"cam.eyes.11": "Coffee first?",
|
|
@@ -49,20 +49,39 @@ window.LOCALES = {
|
|
| 49 |
"skin.name.default": "Original",
|
| 50 |
"skin.name.flash": "Flash",
|
| 51 |
"skin.name.spider": "Spider",
|
| 52 |
-
"skin.name.america": "America",
|
| 53 |
-
"skin.name.urban": "Urban Camo",
|
| 54 |
-
"skin.name.graffiti": "Graffiti",
|
| 55 |
-
"skin.name.rainbow": "Rainbow",
|
| 56 |
-
"skin.name.breton": "Breton",
|
| 57 |
"skin.name.bat": "Bat",
|
| 58 |
"skin.name.steel": "Super",
|
| 59 |
"skin.name.iron": "Iron",
|
| 60 |
-
"skin.name.rage": "Rage",
|
| 61 |
-
"skin.name.ranger": "Astro",
|
| 62 |
-
"skin.name.protocol": "Protocol",
|
| 63 |
-
"skin.name.crewmate": "Crewmate",
|
| 64 |
"skin.name.volt": "Volt",
|
| 65 |
-
"skin.name.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
"skin.all": "All",
|
| 67 |
"skin.search": "Search…",
|
| 68 |
"camera.replay": "REPLAY",
|
|
@@ -72,8 +91,20 @@ window.LOCALES = {
|
|
| 72 |
"camera.mode_camera": "Camera",
|
| 73 |
"tab.main": "Reachy",
|
| 74 |
"tab.store": "Store",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
"tab.settings": "Settings",
|
| 76 |
"camera.mode_3d": "Simulator",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
"camera.mode_mjpeg": "Camera (MJPEG)",
|
| 78 |
"camera.mode_webrtc": "Camera + sound (WebRTC)",
|
| 79 |
"camera.listen": "Listen to the robot microphones",
|
|
@@ -96,10 +127,6 @@ window.LOCALES = {
|
|
| 96 |
"control.title": "Controller",
|
| 97 |
"control.home": "Stand back up (home position)",
|
| 98 |
"control.rest": "Back to rest pose",
|
| 99 |
-
"control.head_rotation": "Head rotation",
|
| 100 |
-
"control.head_position": "Head position",
|
| 101 |
-
"control.body": "Body",
|
| 102 |
-
"control.antennas": "Antennas",
|
| 103 |
"control.yaw": "Yaw",
|
| 104 |
"control.pitch": "Pitch",
|
| 105 |
"control.roll": "Roll",
|
|
@@ -109,11 +136,37 @@ window.LOCALES = {
|
|
| 109 |
"control.body_yaw": "Rotation",
|
| 110 |
"control.antenna_left": "Left",
|
| 111 |
"control.antenna_right": "Right",
|
| 112 |
-
"control.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
"control.move_running": "A recorded move is playing, the robot ignores manual control until it ends.",
|
| 114 |
"common.connect": "Connect",
|
| 115 |
"common.connected": "Connected",
|
| 116 |
-
"common.dashboard_title": "Reachy Mini dashboard",
|
| 117 |
"common.error_prefix": "Error: {message}",
|
| 118 |
"common.failed": "Failed",
|
| 119 |
"common.off": "Off",
|
|
@@ -176,7 +229,6 @@ window.LOCALES = {
|
|
| 176 |
"logs.title": "Daemon logs",
|
| 177 |
"logs.warn_plus": "Warn+",
|
| 178 |
"moves.daemon_unreachable": "daemon unreachable, no moves",
|
| 179 |
-
"moves.title": "Moves",
|
| 180 |
"moves.dances": "Dances",
|
| 181 |
"moves.emotions": "Emotions",
|
| 182 |
"moves.loading": "Loading moves…",
|
|
@@ -190,6 +242,8 @@ window.LOCALES = {
|
|
| 190 |
"eq.treble": "Treble",
|
| 191 |
"eq.general": "General",
|
| 192 |
"eq.tone": "Tone",
|
|
|
|
|
|
|
| 193 |
"eq.unavailable": "EasyEffects is not installed on the robot.",
|
| 194 |
"settings.appearance": "Appearance",
|
| 195 |
"settings.language": "Language",
|
|
@@ -197,9 +251,7 @@ window.LOCALES = {
|
|
| 197 |
"settings.light_theme": "Light theme",
|
| 198 |
"settings.palette": "Palette",
|
| 199 |
"settings.random_skin": "Random skin",
|
| 200 |
-
"settings.debug": "Debug
|
| 201 |
-
"settings.debug_hint": "Mirror the browser console into the logs panel",
|
| 202 |
-
"settings.random_skin_hint": "A different skin on every start",
|
| 203 |
"settings.theme": "Theme",
|
| 204 |
"settings.title": "Settings",
|
| 205 |
"store.advanced_title": "Install a private Space",
|
|
@@ -437,7 +489,7 @@ window.LOCALES = {
|
|
| 437 |
"cam.eyes.5": "Je vérifie que j'ai bien mes deux yeux…",
|
| 438 |
"cam.eyes.6": "Je recompte mes pixels…",
|
| 439 |
"cam.eyes.7": "J'essuie la buée…",
|
| 440 |
-
"cam.eyes.8": "Encore cinq petites
|
| 441 |
"cam.eyes.9": "Et la lumière fut…",
|
| 442 |
"cam.eyes.10": "Je bugue un peu au réveil…",
|
| 443 |
"cam.eyes.11": "On commence par un café ?",
|
|
@@ -451,20 +503,39 @@ window.LOCALES = {
|
|
| 451 |
"skin.name.default": "Original",
|
| 452 |
"skin.name.flash": "Flash",
|
| 453 |
"skin.name.spider": "Araignée",
|
| 454 |
-
"skin.name.
|
| 455 |
-
"skin.name.urban": "Camo urbain",
|
| 456 |
-
"skin.name.graffiti": "Graffiti",
|
| 457 |
-
"skin.name.rainbow": "Arc-en-ciel",
|
| 458 |
-
"skin.name.breton": "Breton",
|
| 459 |
-
"skin.name.bat": "Chauve-souris",
|
| 460 |
"skin.name.steel": "Super",
|
| 461 |
"skin.name.iron": "Iron",
|
| 462 |
-
"skin.name.rage": "Rage",
|
| 463 |
-
"skin.name.ranger": "Astro",
|
| 464 |
-
"skin.name.protocol": "Protocole",
|
| 465 |
-
"skin.name.crewmate": "Équipier",
|
| 466 |
"skin.name.volt": "Volt",
|
| 467 |
-
"skin.name.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
"skin.all": "Tous",
|
| 469 |
"skin.search": "Rechercher…",
|
| 470 |
"camera.replay": "REPLAY",
|
|
@@ -474,8 +545,20 @@ window.LOCALES = {
|
|
| 474 |
"camera.mode_camera": "Caméra",
|
| 475 |
"tab.main": "Reachy",
|
| 476 |
"tab.store": "Store",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
"tab.settings": "Paramètres",
|
| 478 |
"camera.mode_3d": "Simulateur",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
"camera.mode_mjpeg": "Caméra (MJPEG)",
|
| 480 |
"camera.mode_webrtc": "Caméra + son (WebRTC)",
|
| 481 |
"camera.listen": "Écouter les micros du robot",
|
|
@@ -498,10 +581,6 @@ window.LOCALES = {
|
|
| 498 |
"control.title": "Contrôleur",
|
| 499 |
"control.home": "Se relever (position initiale)",
|
| 500 |
"control.rest": "Revenir à la position de repos",
|
| 501 |
-
"control.head_rotation": "Rotation de la tête",
|
| 502 |
-
"control.head_position": "Position de la tête",
|
| 503 |
-
"control.body": "Corps",
|
| 504 |
-
"control.antennas": "Antennes",
|
| 505 |
"control.yaw": "Lacet",
|
| 506 |
"control.pitch": "Tangage",
|
| 507 |
"control.roll": "Roulis",
|
|
@@ -511,11 +590,37 @@ window.LOCALES = {
|
|
| 511 |
"control.body_yaw": "Rotation",
|
| 512 |
"control.antenna_left": "Gauche",
|
| 513 |
"control.antenna_right": "Droite",
|
| 514 |
-
"control.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
"control.move_running": "Un mouvement enregistré est en cours : le robot ignore le contrôle manuel jusqu'à la fin.",
|
| 516 |
"common.connect": "Connecter",
|
| 517 |
"common.connected": "Connecté",
|
| 518 |
-
"common.dashboard_title": "Tableau de bord Reachy Mini",
|
| 519 |
"common.error_prefix": "Erreur : {message}",
|
| 520 |
"common.failed": "Échec",
|
| 521 |
"common.off": "Désactivé",
|
|
@@ -578,7 +683,6 @@ window.LOCALES = {
|
|
| 578 |
"logs.title": "Logs du daemon",
|
| 579 |
"logs.warn_plus": "Warn+",
|
| 580 |
"moves.daemon_unreachable": "daemon injoignable, aucun mouvement",
|
| 581 |
-
"moves.title": "Mouvements",
|
| 582 |
"moves.dances": "Danses",
|
| 583 |
"moves.emotions": "Émotions",
|
| 584 |
"moves.loading": "Chargement des mouvements…",
|
|
@@ -592,6 +696,8 @@ window.LOCALES = {
|
|
| 592 |
"eq.treble": "Aigu",
|
| 593 |
"eq.general": "Général",
|
| 594 |
"eq.tone": "Tonalité",
|
|
|
|
|
|
|
| 595 |
"eq.unavailable": "EasyEffects n'est pas installé sur le robot.",
|
| 596 |
"settings.appearance": "Apparence",
|
| 597 |
"settings.language": "Langue",
|
|
@@ -599,9 +705,7 @@ window.LOCALES = {
|
|
| 599 |
"settings.light_theme": "Thème clair",
|
| 600 |
"settings.palette": "Palette",
|
| 601 |
"settings.random_skin": "Skin aléatoire",
|
| 602 |
-
"settings.debug": "
|
| 603 |
-
"settings.debug_hint": "Renvoie la console du navigateur dans le panneau des logs",
|
| 604 |
-
"settings.random_skin_hint": "Un skin différent à chaque démarrage",
|
| 605 |
"settings.theme": "Thème",
|
| 606 |
"settings.title": "Paramètres",
|
| 607 |
"store.advanced_title": "Installer un Space privé",
|
|
@@ -839,7 +943,7 @@ window.LOCALES = {
|
|
| 839 |
"cam.eyes.5": "Compruebo que sigo teniendo los dos ojos…",
|
| 840 |
"cam.eyes.6": "Recuento mis píxeles…",
|
| 841 |
"cam.eyes.7": "Quito el vaho…",
|
| 842 |
-
"cam.eyes.8": "Cinco
|
| 843 |
"cam.eyes.9": "Y se hizo la luz…",
|
| 844 |
"cam.eyes.10": "Fallo un poco al despertar…",
|
| 845 |
"cam.eyes.11": "¿Primero un café?",
|
|
@@ -853,20 +957,39 @@ window.LOCALES = {
|
|
| 853 |
"skin.name.default": "Original",
|
| 854 |
"skin.name.flash": "Flash",
|
| 855 |
"skin.name.spider": "Araña",
|
| 856 |
-
"skin.name.
|
| 857 |
-
"skin.name.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 858 |
"skin.name.graffiti": "Grafiti",
|
| 859 |
"skin.name.rainbow": "Arcoíris",
|
|
|
|
|
|
|
| 860 |
"skin.name.breton": "Bretón",
|
| 861 |
-
"skin.name.
|
| 862 |
-
"skin.name.
|
| 863 |
-
"skin.name.
|
| 864 |
-
"skin.name.
|
| 865 |
-
"skin.name.
|
| 866 |
-
"skin.name.
|
| 867 |
-
"skin.name.
|
| 868 |
-
"skin.name.
|
| 869 |
-
"skin.name.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 870 |
"skin.all": "Todos",
|
| 871 |
"skin.search": "Buscar…",
|
| 872 |
"camera.replay": "REPETICIÓN",
|
|
@@ -876,8 +999,20 @@ window.LOCALES = {
|
|
| 876 |
"camera.mode_camera": "Cámara",
|
| 877 |
"tab.main": "Reachy",
|
| 878 |
"tab.store": "Tienda",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 879 |
"tab.settings": "Ajustes",
|
| 880 |
"camera.mode_3d": "Simulador",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 881 |
"camera.mode_mjpeg": "Cámara (MJPEG)",
|
| 882 |
"camera.mode_webrtc": "Cámara + sonido (WebRTC)",
|
| 883 |
"camera.listen": "Escuchar los micrófonos del robot",
|
|
@@ -900,10 +1035,6 @@ window.LOCALES = {
|
|
| 900 |
"control.title": "Controlador",
|
| 901 |
"control.home": "Enderezarse (posición inicial)",
|
| 902 |
"control.rest": "Volver a la posición de reposo",
|
| 903 |
-
"control.head_rotation": "Rotación de la cabeza",
|
| 904 |
-
"control.head_position": "Posición de la cabeza",
|
| 905 |
-
"control.body": "Cuerpo",
|
| 906 |
-
"control.antennas": "Antenas",
|
| 907 |
"control.yaw": "Guiñada",
|
| 908 |
"control.pitch": "Cabeceo",
|
| 909 |
"control.roll": "Alabeo",
|
|
@@ -913,11 +1044,37 @@ window.LOCALES = {
|
|
| 913 |
"control.body_yaw": "Rotación",
|
| 914 |
"control.antenna_left": "Izquierda",
|
| 915 |
"control.antenna_right": "Derecha",
|
| 916 |
-
"control.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
"control.move_running": "Se está reproduciendo un movimiento grabado: el robot ignora el control manual hasta que termine.",
|
| 918 |
"common.connect": "Conectar",
|
| 919 |
"common.connected": "Conectado",
|
| 920 |
-
"common.dashboard_title": "Panel de Reachy Mini",
|
| 921 |
"common.error_prefix": "Error: {message}",
|
| 922 |
"common.failed": "Falló",
|
| 923 |
"common.off": "Apagado",
|
|
@@ -980,7 +1137,6 @@ window.LOCALES = {
|
|
| 980 |
"logs.title": "Logs del daemon",
|
| 981 |
"logs.warn_plus": "Warn+",
|
| 982 |
"moves.daemon_unreachable": "daemon inaccesible: sin movimientos",
|
| 983 |
-
"moves.title": "Movimientos",
|
| 984 |
"moves.dances": "Bailes",
|
| 985 |
"moves.emotions": "Emociones",
|
| 986 |
"moves.loading": "Cargando movimientos…",
|
|
@@ -994,6 +1150,8 @@ window.LOCALES = {
|
|
| 994 |
"eq.treble": "Agudos",
|
| 995 |
"eq.general": "General",
|
| 996 |
"eq.tone": "Tono",
|
|
|
|
|
|
|
| 997 |
"eq.unavailable": "EasyEffects no está instalado en el robot.",
|
| 998 |
"settings.appearance": "Apariencia",
|
| 999 |
"settings.language": "Idioma",
|
|
@@ -1001,9 +1159,7 @@ window.LOCALES = {
|
|
| 1001 |
"settings.light_theme": "Tema claro",
|
| 1002 |
"settings.palette": "Paleta",
|
| 1003 |
"settings.random_skin": "Skin aleatorio",
|
| 1004 |
-
"settings.debug": "
|
| 1005 |
-
"settings.debug_hint": "Refleja la consola del navegador en el panel de registros",
|
| 1006 |
-
"settings.random_skin_hint": "Un skin distinto en cada inicio",
|
| 1007 |
"settings.theme": "Tema",
|
| 1008 |
"settings.title": "Ajustes",
|
| 1009 |
"store.advanced_title": "Instalar un Space privado",
|
|
@@ -1241,7 +1397,7 @@ window.LOCALES = {
|
|
| 1241 |
"cam.eyes.5": "Ich prüfe, ob ich noch beide Augen habe…",
|
| 1242 |
"cam.eyes.6": "Ich zähle meine Pixel nach…",
|
| 1243 |
"cam.eyes.7": "Ich wische den Beschlag weg…",
|
| 1244 |
-
"cam.eyes.8": "Noch fünf
|
| 1245 |
"cam.eyes.9": "Und es ward Licht…",
|
| 1246 |
"cam.eyes.10": "Ich hänge morgens manchmal kurz…",
|
| 1247 |
"cam.eyes.11": "Erst mal einen Kaffee?",
|
|
@@ -1255,20 +1411,39 @@ window.LOCALES = {
|
|
| 1255 |
"skin.name.default": "Original",
|
| 1256 |
"skin.name.flash": "Flash",
|
| 1257 |
"skin.name.spider": "Spinne",
|
| 1258 |
-
"skin.name.america": "Amerika",
|
| 1259 |
-
"skin.name.urban": "Urban-Tarnung",
|
| 1260 |
-
"skin.name.graffiti": "Graffiti",
|
| 1261 |
-
"skin.name.rainbow": "Regenbogen",
|
| 1262 |
-
"skin.name.breton": "Bretonisch",
|
| 1263 |
"skin.name.bat": "Fledermaus",
|
| 1264 |
"skin.name.steel": "Super",
|
| 1265 |
"skin.name.iron": "Iron",
|
| 1266 |
-
"skin.name.rage": "Wut",
|
| 1267 |
-
"skin.name.ranger": "Astro",
|
| 1268 |
-
"skin.name.protocol": "Protokoll",
|
| 1269 |
-
"skin.name.crewmate": "Crewmate",
|
| 1270 |
"skin.name.volt": "Volt",
|
| 1271 |
-
"skin.name.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1272 |
"skin.all": "Alle",
|
| 1273 |
"skin.search": "Suchen…",
|
| 1274 |
"camera.replay": "REPLAY",
|
|
@@ -1278,8 +1453,20 @@ window.LOCALES = {
|
|
| 1278 |
"camera.mode_camera": "Kamera",
|
| 1279 |
"tab.main": "Reachy",
|
| 1280 |
"tab.store": "Store",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1281 |
"tab.settings": "Einstellungen",
|
| 1282 |
"camera.mode_3d": "Simulator",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1283 |
"camera.mode_mjpeg": "Kamera (MJPEG)",
|
| 1284 |
"camera.mode_webrtc": "Kamera + Ton (WebRTC)",
|
| 1285 |
"camera.listen": "Robotermikrofone abhören",
|
|
@@ -1302,10 +1489,6 @@ window.LOCALES = {
|
|
| 1302 |
"control.title": "Steuerung",
|
| 1303 |
"control.home": "Grundstellung (aufrichten)",
|
| 1304 |
"control.rest": "Zurück in die Ruhelage",
|
| 1305 |
-
"control.head_rotation": "Kopfdrehung",
|
| 1306 |
-
"control.head_position": "Kopfposition",
|
| 1307 |
-
"control.body": "Körper",
|
| 1308 |
-
"control.antennas": "Antennen",
|
| 1309 |
"control.yaw": "Gieren",
|
| 1310 |
"control.pitch": "Nicken",
|
| 1311 |
"control.roll": "Rollen",
|
|
@@ -1315,11 +1498,37 @@ window.LOCALES = {
|
|
| 1315 |
"control.body_yaw": "Drehung",
|
| 1316 |
"control.antenna_left": "Links",
|
| 1317 |
"control.antenna_right": "Rechts",
|
| 1318 |
-
"control.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1319 |
"control.move_running": "Eine aufgezeichnete Bewegung läuft, der Roboter ignoriert die manuelle Steuerung bis zum Ende.",
|
| 1320 |
"common.connect": "Verbinden",
|
| 1321 |
"common.connected": "Verbunden",
|
| 1322 |
-
"common.dashboard_title": "Reachy Mini Dashboard",
|
| 1323 |
"common.error_prefix": "Fehler: {message}",
|
| 1324 |
"common.failed": "Fehlgeschlagen",
|
| 1325 |
"common.off": "Aus",
|
|
@@ -1382,7 +1591,6 @@ window.LOCALES = {
|
|
| 1382 |
"logs.title": "Daemon-Logs",
|
| 1383 |
"logs.warn_plus": "Warn+",
|
| 1384 |
"moves.daemon_unreachable": "Daemon nicht erreichbar, keine Bewegungen",
|
| 1385 |
-
"moves.title": "Bewegungen",
|
| 1386 |
"moves.dances": "Tänze",
|
| 1387 |
"moves.emotions": "Emotionen",
|
| 1388 |
"moves.loading": "Bewegungen werden geladen…",
|
|
@@ -1396,6 +1604,8 @@ window.LOCALES = {
|
|
| 1396 |
"eq.treble": "Höhen",
|
| 1397 |
"eq.general": "Allgemein",
|
| 1398 |
"eq.tone": "Klang",
|
|
|
|
|
|
|
| 1399 |
"eq.unavailable": "EasyEffects ist nicht auf dem Roboter installiert.",
|
| 1400 |
"settings.appearance": "Erscheinungsbild",
|
| 1401 |
"settings.language": "Sprache",
|
|
@@ -1403,9 +1613,7 @@ window.LOCALES = {
|
|
| 1403 |
"settings.light_theme": "Helles Design",
|
| 1404 |
"settings.palette": "Farbpalette",
|
| 1405 |
"settings.random_skin": "Zufalls-Skin",
|
| 1406 |
-
"settings.debug": "Debug-
|
| 1407 |
-
"settings.debug_hint": "Spiegelt die Browser-Konsole ins Log-Panel",
|
| 1408 |
-
"settings.random_skin_hint": "Bei jedem Start ein anderer Skin",
|
| 1409 |
"settings.theme": "Design",
|
| 1410 |
"settings.title": "Einstellungen",
|
| 1411 |
"store.advanced_title": "Privaten Space installieren",
|
|
@@ -1643,7 +1851,7 @@ window.LOCALES = {
|
|
| 1643 |
"cam.eyes.5": "Controllo di avere ancora entrambi gli occhi…",
|
| 1644 |
"cam.eyes.6": "Riconto i miei pixel…",
|
| 1645 |
"cam.eyes.7": "Tolgo l'appannamento…",
|
| 1646 |
-
"cam.eyes.8": "Ancora cinque
|
| 1647 |
"cam.eyes.9": "E luce fu…",
|
| 1648 |
"cam.eyes.10": "Appena sveglio ho qualche glitch…",
|
| 1649 |
"cam.eyes.11": "Prima un caffè?",
|
|
@@ -1657,20 +1865,39 @@ window.LOCALES = {
|
|
| 1657 |
"skin.name.default": "Originale",
|
| 1658 |
"skin.name.flash": "Flash",
|
| 1659 |
"skin.name.spider": "Ragno",
|
| 1660 |
-
"skin.name.america": "America",
|
| 1661 |
-
"skin.name.urban": "Camo urbano",
|
| 1662 |
-
"skin.name.graffiti": "Graffiti",
|
| 1663 |
-
"skin.name.rainbow": "Arcobaleno",
|
| 1664 |
-
"skin.name.breton": "Bretone",
|
| 1665 |
"skin.name.bat": "Pipistrello",
|
| 1666 |
"skin.name.steel": "Super",
|
| 1667 |
"skin.name.iron": "Iron",
|
| 1668 |
-
"skin.name.rage": "Furia",
|
| 1669 |
-
"skin.name.ranger": "Astro",
|
| 1670 |
-
"skin.name.protocol": "Protocollo",
|
| 1671 |
-
"skin.name.crewmate": "Crewmate",
|
| 1672 |
"skin.name.volt": "Volt",
|
| 1673 |
-
"skin.name.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1674 |
"skin.all": "Tutte",
|
| 1675 |
"skin.search": "Cerca…",
|
| 1676 |
"camera.replay": "REPLAY",
|
|
@@ -1680,8 +1907,20 @@ window.LOCALES = {
|
|
| 1680 |
"camera.mode_camera": "Fotocamera",
|
| 1681 |
"tab.main": "Reachy",
|
| 1682 |
"tab.store": "Store",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1683 |
"tab.settings": "Impostazioni",
|
| 1684 |
"camera.mode_3d": "Simulatore",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1685 |
"camera.mode_mjpeg": "Fotocamera (MJPEG)",
|
| 1686 |
"camera.mode_webrtc": "Fotocamera + audio (WebRTC)",
|
| 1687 |
"camera.listen": "Ascolta i microfoni del robot",
|
|
@@ -1704,10 +1943,6 @@ window.LOCALES = {
|
|
| 1704 |
"control.title": "Controller",
|
| 1705 |
"control.home": "Rialzati (posizione iniziale)",
|
| 1706 |
"control.rest": "Torna alla posizione di riposo",
|
| 1707 |
-
"control.head_rotation": "Rotazione della testa",
|
| 1708 |
-
"control.head_position": "Posizione della testa",
|
| 1709 |
-
"control.body": "Corpo",
|
| 1710 |
-
"control.antennas": "Antenne",
|
| 1711 |
"control.yaw": "Imbardata",
|
| 1712 |
"control.pitch": "Beccheggio",
|
| 1713 |
"control.roll": "Rollio",
|
|
@@ -1717,11 +1952,37 @@ window.LOCALES = {
|
|
| 1717 |
"control.body_yaw": "Rotazione",
|
| 1718 |
"control.antenna_left": "Sinistra",
|
| 1719 |
"control.antenna_right": "Destra",
|
| 1720 |
-
"control.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1721 |
"control.move_running": "È in corso un movimento registrato: il robot ignora il controllo manuale fino al termine.",
|
| 1722 |
"common.connect": "Connetti",
|
| 1723 |
"common.connected": "Connesso",
|
| 1724 |
-
"common.dashboard_title": "Dashboard Reachy Mini",
|
| 1725 |
"common.error_prefix": "Errore: {message}",
|
| 1726 |
"common.failed": "Non riuscito",
|
| 1727 |
"common.off": "Off",
|
|
@@ -1784,7 +2045,6 @@ window.LOCALES = {
|
|
| 1784 |
"logs.title": "Log del daemon",
|
| 1785 |
"logs.warn_plus": "Warn+",
|
| 1786 |
"moves.daemon_unreachable": "daemon non raggiungibile, nessun movimento",
|
| 1787 |
-
"moves.title": "Movimenti",
|
| 1788 |
"moves.dances": "Danze",
|
| 1789 |
"moves.emotions": "Emozioni",
|
| 1790 |
"moves.loading": "Caricamento movimenti…",
|
|
@@ -1798,6 +2058,8 @@ window.LOCALES = {
|
|
| 1798 |
"eq.treble": "Alti",
|
| 1799 |
"eq.general": "Generale",
|
| 1800 |
"eq.tone": "Tono",
|
|
|
|
|
|
|
| 1801 |
"eq.unavailable": "EasyEffects non è installato sul robot.",
|
| 1802 |
"settings.appearance": "Aspetto",
|
| 1803 |
"settings.language": "Lingua",
|
|
@@ -1805,9 +2067,7 @@ window.LOCALES = {
|
|
| 1805 |
"settings.light_theme": "Tema chiaro",
|
| 1806 |
"settings.palette": "Palette",
|
| 1807 |
"settings.random_skin": "Skin casuale",
|
| 1808 |
-
"settings.debug": "
|
| 1809 |
-
"settings.debug_hint": "Rispecchia la console del browser nel pannello dei log",
|
| 1810 |
-
"settings.random_skin_hint": "Una skin diversa a ogni avvio",
|
| 1811 |
"settings.theme": "Tema",
|
| 1812 |
"settings.title": "Impostazioni",
|
| 1813 |
"store.advanced_title": "Installa uno Space privato",
|
|
@@ -2045,7 +2305,7 @@ window.LOCALES = {
|
|
| 2045 |
"cam.eyes.5": "Verifico se tenho os dois olhos…",
|
| 2046 |
"cam.eyes.6": "Reconto os meus pixels…",
|
| 2047 |
"cam.eyes.7": "Limpo o embaciado…",
|
| 2048 |
-
"cam.eyes.8": "Mais cinco
|
| 2049 |
"cam.eyes.9": "E fez-se luz…",
|
| 2050 |
"cam.eyes.10": "Dou uns bugs ao acordar…",
|
| 2051 |
"cam.eyes.11": "Um cafezinho primeiro?",
|
|
@@ -2059,20 +2319,39 @@ window.LOCALES = {
|
|
| 2059 |
"skin.name.default": "Original",
|
| 2060 |
"skin.name.flash": "Flash",
|
| 2061 |
"skin.name.spider": "Aranha",
|
| 2062 |
-
"skin.name.america": "América",
|
| 2063 |
-
"skin.name.urban": "Camo urbano",
|
| 2064 |
-
"skin.name.graffiti": "Graffiti",
|
| 2065 |
-
"skin.name.rainbow": "Arco-íris",
|
| 2066 |
-
"skin.name.breton": "Bretão",
|
| 2067 |
"skin.name.bat": "Morcego",
|
| 2068 |
"skin.name.steel": "Super",
|
| 2069 |
-
"skin.name.iron": "
|
| 2070 |
-
"skin.name.rage": "Fúria",
|
| 2071 |
-
"skin.name.ranger": "Astro",
|
| 2072 |
-
"skin.name.protocol": "Protocolo",
|
| 2073 |
-
"skin.name.crewmate": "Tripulante",
|
| 2074 |
"skin.name.volt": "Volt",
|
| 2075 |
-
"skin.name.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2076 |
"skin.all": "Todos",
|
| 2077 |
"skin.search": "Pesquisar…",
|
| 2078 |
"camera.replay": "REPETIÇÃO",
|
|
@@ -2082,8 +2361,20 @@ window.LOCALES = {
|
|
| 2082 |
"camera.mode_camera": "Câmara",
|
| 2083 |
"tab.main": "Reachy",
|
| 2084 |
"tab.store": "Loja",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2085 |
"tab.settings": "Definições",
|
| 2086 |
"camera.mode_3d": "Simulador",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2087 |
"camera.mode_mjpeg": "Câmara (MJPEG)",
|
| 2088 |
"camera.mode_webrtc": "Câmara + som (WebRTC)",
|
| 2089 |
"camera.listen": "Ouvir os microfones do robô",
|
|
@@ -2106,10 +2397,6 @@ window.LOCALES = {
|
|
| 2106 |
"control.title": "Controlador",
|
| 2107 |
"control.home": "Posição inicial (levantar)",
|
| 2108 |
"control.rest": "Voltar à posição de repouso",
|
| 2109 |
-
"control.head_rotation": "Rotação da cabeça",
|
| 2110 |
-
"control.head_position": "Posição da cabeça",
|
| 2111 |
-
"control.body": "Corpo",
|
| 2112 |
-
"control.antennas": "Antenas",
|
| 2113 |
"control.yaw": "Guinada",
|
| 2114 |
"control.pitch": "Inclinação",
|
| 2115 |
"control.roll": "Rolamento",
|
|
@@ -2119,11 +2406,37 @@ window.LOCALES = {
|
|
| 2119 |
"control.body_yaw": "Rotação",
|
| 2120 |
"control.antenna_left": "Esquerda",
|
| 2121 |
"control.antenna_right": "Direita",
|
| 2122 |
-
"control.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2123 |
"control.move_running": "Um movimento gravado está a decorrer: o robô ignora o controlo manual até terminar.",
|
| 2124 |
"common.connect": "Ligar",
|
| 2125 |
"common.connected": "Ligado",
|
| 2126 |
-
"common.dashboard_title": "Painel do Reachy Mini",
|
| 2127 |
"common.error_prefix": "Erro: {message}",
|
| 2128 |
"common.failed": "Falhou",
|
| 2129 |
"common.off": "Desligado",
|
|
@@ -2186,7 +2499,6 @@ window.LOCALES = {
|
|
| 2186 |
"logs.title": "Logs do daemon",
|
| 2187 |
"logs.warn_plus": "Warn+",
|
| 2188 |
"moves.daemon_unreachable": "daemon inacessível: sem movimentos",
|
| 2189 |
-
"moves.title": "Movimentos",
|
| 2190 |
"moves.dances": "Danças",
|
| 2191 |
"moves.emotions": "Emoções",
|
| 2192 |
"moves.loading": "A carregar movimentos…",
|
|
@@ -2200,6 +2512,8 @@ window.LOCALES = {
|
|
| 2200 |
"eq.treble": "Agudos",
|
| 2201 |
"eq.general": "Geral",
|
| 2202 |
"eq.tone": "Tom",
|
|
|
|
|
|
|
| 2203 |
"eq.unavailable": "O EasyEffects não está instalado no robô.",
|
| 2204 |
"settings.appearance": "Aspeto",
|
| 2205 |
"settings.language": "Idioma",
|
|
@@ -2207,9 +2521,7 @@ window.LOCALES = {
|
|
| 2207 |
"settings.light_theme": "Tema claro",
|
| 2208 |
"settings.palette": "Paleta",
|
| 2209 |
"settings.random_skin": "Skin aleatório",
|
| 2210 |
-
"settings.debug": "
|
| 2211 |
-
"settings.debug_hint": "Espelha a consola do navegador no painel de registos",
|
| 2212 |
-
"settings.random_skin_hint": "Um skin diferente a cada início",
|
| 2213 |
"settings.theme": "Tema",
|
| 2214 |
"settings.title": "Definições",
|
| 2215 |
"store.advanced_title": "Instalar um Space privado",
|
|
@@ -2447,7 +2759,7 @@ window.LOCALES = {
|
|
| 2447 |
"cam.eyes.5": "目が二つあるか確認中…",
|
| 2448 |
"cam.eyes.6": "ピクセルを数え直しています…",
|
| 2449 |
"cam.eyes.7": "くもりを拭き取っています…",
|
| 2450 |
-
"cam.eyes.8": "あと五
|
| 2451 |
"cam.eyes.9": "光あれ…",
|
| 2452 |
"cam.eyes.10": "起きたては少しバグりがち…",
|
| 2453 |
"cam.eyes.11": "まずはコーヒー?",
|
|
@@ -2461,20 +2773,39 @@ window.LOCALES = {
|
|
| 2461 |
"skin.name.default": "オリジナル",
|
| 2462 |
"skin.name.flash": "フラッシュ",
|
| 2463 |
"skin.name.spider": "スパイダー",
|
| 2464 |
-
"skin.name.
|
| 2465 |
-
"skin.name.urban": "アーバン迷彩",
|
| 2466 |
-
"skin.name.graffiti": "グラフィティ",
|
| 2467 |
-
"skin.name.rainbow": "レインボー",
|
| 2468 |
-
"skin.name.breton": "ブルターニュ",
|
| 2469 |
-
"skin.name.bat": "バット",
|
| 2470 |
"skin.name.steel": "スーパー",
|
| 2471 |
"skin.name.iron": "アイアン",
|
| 2472 |
-
"skin.name.rage": "レイジ",
|
| 2473 |
-
"skin.name.ranger": "アストロ",
|
| 2474 |
-
"skin.name.protocol": "プロトコル",
|
| 2475 |
-
"skin.name.crewmate": "クルーメイト",
|
| 2476 |
"skin.name.volt": "ボルト",
|
| 2477 |
-
"skin.name.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2478 |
"skin.all": "すべて",
|
| 2479 |
"skin.search": "検索…",
|
| 2480 |
"camera.replay": "リプレイ",
|
|
@@ -2484,8 +2815,20 @@ window.LOCALES = {
|
|
| 2484 |
"camera.mode_camera": "カメラ",
|
| 2485 |
"tab.main": "Reachy",
|
| 2486 |
"tab.store": "ストア",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2487 |
"tab.settings": "設定",
|
| 2488 |
"camera.mode_3d": "シミュレーター",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2489 |
"camera.mode_mjpeg": "カメラ (MJPEG)",
|
| 2490 |
"camera.mode_webrtc": "カメラ + 音声 (WebRTC)",
|
| 2491 |
"camera.listen": "ロボットのマイクを聞く",
|
|
@@ -2508,10 +2851,6 @@ window.LOCALES = {
|
|
| 2508 |
"control.title": "コントローラー",
|
| 2509 |
"control.home": "初期姿勢に戻る",
|
| 2510 |
"control.rest": "待機姿勢に戻す",
|
| 2511 |
-
"control.head_rotation": "頭の回転",
|
| 2512 |
-
"control.head_position": "頭の位置",
|
| 2513 |
-
"control.body": "胴体",
|
| 2514 |
-
"control.antennas": "アンテナ",
|
| 2515 |
"control.yaw": "ヨー",
|
| 2516 |
"control.pitch": "ピッチ",
|
| 2517 |
"control.roll": "ロール",
|
|
@@ -2521,11 +2860,37 @@ window.LOCALES = {
|
|
| 2521 |
"control.body_yaw": "回転",
|
| 2522 |
"control.antenna_left": "左",
|
| 2523 |
"control.antenna_right": "右",
|
| 2524 |
-
"control.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2525 |
"control.move_running": "記録された動作を再生中です。終わるまで手動操作は無視されます。",
|
| 2526 |
"common.connect": "接続",
|
| 2527 |
"common.connected": "接続済み",
|
| 2528 |
-
"common.dashboard_title": "Reachy Mini ダッシュボード",
|
| 2529 |
"common.error_prefix": "エラー: {message}",
|
| 2530 |
"common.failed": "失敗",
|
| 2531 |
"common.off": "オフ",
|
|
@@ -2588,7 +2953,6 @@ window.LOCALES = {
|
|
| 2588 |
"logs.title": "デーモンログ",
|
| 2589 |
"logs.warn_plus": "Warn+",
|
| 2590 |
"moves.daemon_unreachable": "デーモンに接続できません。モーションなし",
|
| 2591 |
-
"moves.title": "モーション",
|
| 2592 |
"moves.dances": "ダンス",
|
| 2593 |
"moves.emotions": "エモーション",
|
| 2594 |
"moves.loading": "モーションを読み込み中…",
|
|
@@ -2602,6 +2966,8 @@ window.LOCALES = {
|
|
| 2602 |
"eq.treble": "高音",
|
| 2603 |
"eq.general": "一般",
|
| 2604 |
"eq.tone": "トーン",
|
|
|
|
|
|
|
| 2605 |
"eq.unavailable": "EasyEffects はロボットにインストールされていません。",
|
| 2606 |
"settings.appearance": "外観",
|
| 2607 |
"settings.language": "言語",
|
|
@@ -2609,9 +2975,7 @@ window.LOCALES = {
|
|
| 2609 |
"settings.light_theme": "ライトテーマ",
|
| 2610 |
"settings.palette": "パレット",
|
| 2611 |
"settings.random_skin": "ランダムスキン",
|
| 2612 |
-
"settings.debug": "デバッグ
|
| 2613 |
-
"settings.debug_hint": "ブラウザのコンソールをログパネルに表示",
|
| 2614 |
-
"settings.random_skin_hint": "起動ごとに違うスキン",
|
| 2615 |
"settings.theme": "テーマ",
|
| 2616 |
"settings.title": "設定",
|
| 2617 |
"store.advanced_title": "非公開の Space をインストール",
|
|
@@ -2849,7 +3213,7 @@ window.LOCALES = {
|
|
| 2849 |
"cam.eyes.5": "أتأكد أنني ما زلت أملك عينين…",
|
| 2850 |
"cam.eyes.6": "أعيد عدّ بكسلاتي…",
|
| 2851 |
"cam.eyes.7": "أمسح الضباب…",
|
| 2852 |
-
"cam.eyes.8": "خمس
|
| 2853 |
"cam.eyes.9": "ثم كان النور…",
|
| 2854 |
"cam.eyes.10": "أتلعثم قليلًا عند الاستيقاظ…",
|
| 2855 |
"cam.eyes.11": "قهوة أولًا؟",
|
|
@@ -2863,20 +3227,39 @@ window.LOCALES = {
|
|
| 2863 |
"skin.name.default": "أصلي",
|
| 2864 |
"skin.name.flash": "فلاش",
|
| 2865 |
"skin.name.spider": "العنكبوت",
|
| 2866 |
-
"skin.name.
|
| 2867 |
-
"skin.name.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2868 |
"skin.name.graffiti": "غرافيتي",
|
| 2869 |
"skin.name.rainbow": "قوس قزح",
|
|
|
|
|
|
|
| 2870 |
"skin.name.breton": "بريتاني",
|
| 2871 |
-
"skin.name.
|
| 2872 |
-
"skin.name.
|
| 2873 |
-
"skin.name.
|
| 2874 |
-
"skin.name.
|
| 2875 |
-
"skin.name.
|
| 2876 |
-
"skin.name.
|
| 2877 |
-
"skin.name.
|
| 2878 |
-
"skin.name.
|
| 2879 |
-
"skin.name.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2880 |
"skin.all": "الكل",
|
| 2881 |
"skin.search": "بحث…",
|
| 2882 |
"camera.replay": "إعادة",
|
|
@@ -2886,8 +3269,20 @@ window.LOCALES = {
|
|
| 2886 |
"camera.mode_camera": "الكاميرا",
|
| 2887 |
"tab.main": "Reachy",
|
| 2888 |
"tab.store": "المتجر",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2889 |
"tab.settings": "الإعدادات",
|
| 2890 |
"camera.mode_3d": "محاكاة",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2891 |
"camera.mode_mjpeg": "الكاميرا (MJPEG)",
|
| 2892 |
"camera.mode_webrtc": "الكاميرا + الصوت (WebRTC)",
|
| 2893 |
"camera.listen": "الاستماع إلى ميكروفونات الروبوت",
|
|
@@ -2910,10 +3305,6 @@ window.LOCALES = {
|
|
| 2910 |
"control.title": "وحدة التحكم",
|
| 2911 |
"control.home": "الوقوف من جديد (الوضع الأولي)",
|
| 2912 |
"control.rest": "العودة إلى وضع الراحة",
|
| 2913 |
-
"control.head_rotation": "دوران الرأس",
|
| 2914 |
-
"control.head_position": "موضع الرأس",
|
| 2915 |
-
"control.body": "الجسم",
|
| 2916 |
-
"control.antennas": "الهوائيات",
|
| 2917 |
"control.yaw": "الانعراج",
|
| 2918 |
"control.pitch": "الميل",
|
| 2919 |
"control.roll": "الدوران",
|
|
@@ -2923,11 +3314,37 @@ window.LOCALES = {
|
|
| 2923 |
"control.body_yaw": "دوران",
|
| 2924 |
"control.antenna_left": "يسار",
|
| 2925 |
"control.antenna_right": "يمين",
|
| 2926 |
-
"control.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2927 |
"control.move_running": "يتم تشغيل حركة مسجّلة؛ يتجاهل الروبوت التحكم اليدوي حتى تنتهي.",
|
| 2928 |
"common.connect": "اتصال",
|
| 2929 |
"common.connected": "متصل",
|
| 2930 |
-
"common.dashboard_title": "لوحة تحكم Reachy Mini",
|
| 2931 |
"common.error_prefix": "خطأ: {message}",
|
| 2932 |
"common.failed": "فشل",
|
| 2933 |
"common.off": "إيقاف",
|
|
@@ -2990,7 +3407,6 @@ window.LOCALES = {
|
|
| 2990 |
"logs.title": "سجلات البرنامج الخفي",
|
| 2991 |
"logs.warn_plus": "Warn+",
|
| 2992 |
"moves.daemon_unreachable": "تعذّر الوصول إلى البرنامج الخفي؛ لا توجد حركات",
|
| 2993 |
-
"moves.title": "الحركات",
|
| 2994 |
"moves.dances": "رقصات",
|
| 2995 |
"moves.emotions": "مشاعر",
|
| 2996 |
"moves.loading": "جارٍ تحميل الحركات…",
|
|
@@ -3004,6 +3420,8 @@ window.LOCALES = {
|
|
| 3004 |
"eq.treble": "الحادة",
|
| 3005 |
"eq.general": "عام",
|
| 3006 |
"eq.tone": "النغمة",
|
|
|
|
|
|
|
| 3007 |
"eq.unavailable": "EasyEffects غير مثبّت على الروبوت.",
|
| 3008 |
"settings.appearance": "المظهر",
|
| 3009 |
"settings.language": "اللغة",
|
|
@@ -3011,9 +3429,7 @@ window.LOCALES = {
|
|
| 3011 |
"settings.light_theme": "السمة الفاتحة",
|
| 3012 |
"settings.palette": "لوحة الألوان",
|
| 3013 |
"settings.random_skin": "مظهر عشوائي",
|
| 3014 |
-
"settings.debug": "و
|
| 3015 |
-
"settings.debug_hint": "يعكس وحدة تحكم المتصفح في لوحة السجلات",
|
| 3016 |
-
"settings.random_skin_hint": "مظهر مختلف عند كل تشغيل",
|
| 3017 |
"settings.theme": "السمة",
|
| 3018 |
"settings.title": "الإعدادات",
|
| 3019 |
"store.advanced_title": "تثبيت Space خاص",
|
|
|
|
| 35 |
"cam.eyes.5": "Checking I still have both eyes…",
|
| 36 |
"cam.eyes.6": "Recounting my pixels…",
|
| 37 |
"cam.eyes.7": "Wiping off the fog…",
|
| 38 |
+
"cam.eyes.8": "Five more milliseconds…",
|
| 39 |
"cam.eyes.9": "And then there was light…",
|
| 40 |
"cam.eyes.10": "I glitch a bit when I wake up…",
|
| 41 |
"cam.eyes.11": "Coffee first?",
|
|
|
|
| 49 |
"skin.name.default": "Original",
|
| 50 |
"skin.name.flash": "Flash",
|
| 51 |
"skin.name.spider": "Spider",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
"skin.name.bat": "Bat",
|
| 53 |
"skin.name.steel": "Super",
|
| 54 |
"skin.name.iron": "Iron",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
"skin.name.volt": "Volt",
|
| 56 |
+
"skin.name.tuxedo": "Tuxedo",
|
| 57 |
+
"skin.name.bee": "Bee",
|
| 58 |
+
"skin.name.graffiti": "Graffiti",
|
| 59 |
+
"skin.name.rainbow": "Rainbow",
|
| 60 |
+
"skin.name.urban": "Urban Camo",
|
| 61 |
+
"skin.name.america": "America",
|
| 62 |
+
"skin.name.breton": "Breton",
|
| 63 |
+
"skin.name.pixel": "Pixel",
|
| 64 |
+
"skin.name.plumber": "Plumber",
|
| 65 |
+
"skin.name.ghost": "Ghost",
|
| 66 |
+
"skin.name.capsule": "Capsule",
|
| 67 |
+
"skin.name.ronin": "Ronin",
|
| 68 |
+
"skin.name.wizard": "Wizard",
|
| 69 |
+
"skin.name.mamba": "Mamba",
|
| 70 |
+
"skin.name.pirate": "Pirate",
|
| 71 |
+
"skin.name.tiger": "Tiger",
|
| 72 |
+
"skin.name.clown": "Clownfish",
|
| 73 |
+
"skin.name.panda": "Panda",
|
| 74 |
+
"skin.name.ladybug": "Ladybug",
|
| 75 |
+
"skin.name.japan": "Japan",
|
| 76 |
+
"skin.name.germany": "Germany",
|
| 77 |
+
"skin.name.ukraine": "Ukraine",
|
| 78 |
+
"skin.name.mondrian": "Mondrian",
|
| 79 |
+
"skin.name.wave": "The Wave",
|
| 80 |
+
"skin.name.sakura": "Sakura",
|
| 81 |
+
"skin.name.aurora": "Aurora",
|
| 82 |
+
"skin.name.wood": "Wood",
|
| 83 |
+
"skin.name.carbon": "Carbon",
|
| 84 |
+
"skin.name.circuit": "Circuit",
|
| 85 |
"skin.all": "All",
|
| 86 |
"skin.search": "Search…",
|
| 87 |
"camera.replay": "REPLAY",
|
|
|
|
| 91 |
"camera.mode_camera": "Camera",
|
| 92 |
"tab.main": "Reachy",
|
| 93 |
"tab.store": "Store",
|
| 94 |
+
"appstage.dashboard": "Dashboard",
|
| 95 |
+
"appstage.app": "App",
|
| 96 |
+
"appstage.reload": "Reload the page",
|
| 97 |
+
"appstage.restart": "Restart the app",
|
| 98 |
+
"appstage.debug": "App logs",
|
| 99 |
+
"appstage.fullscreen": "Open in a new tab",
|
| 100 |
+
"appstage.close": "Close (stop the app)",
|
| 101 |
+
"appstage.fps_hint": "Robot control loop (Hz)",
|
| 102 |
"tab.settings": "Settings",
|
| 103 |
"camera.mode_3d": "Simulator",
|
| 104 |
+
"twin.hint_title": "Move the robot",
|
| 105 |
+
"twin.hint_body": "Grab the head or the body and drag to aim it",
|
| 106 |
+
"twin.replay": "How to move the robot",
|
| 107 |
+
"camera.flag_live": "Live",
|
| 108 |
"camera.mode_mjpeg": "Camera (MJPEG)",
|
| 109 |
"camera.mode_webrtc": "Camera + sound (WebRTC)",
|
| 110 |
"camera.listen": "Listen to the robot microphones",
|
|
|
|
| 127 |
"control.title": "Controller",
|
| 128 |
"control.home": "Stand back up (home position)",
|
| 129 |
"control.rest": "Back to rest pose",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
"control.yaw": "Yaw",
|
| 131 |
"control.pitch": "Pitch",
|
| 132 |
"control.roll": "Roll",
|
|
|
|
| 136 |
"control.body_yaw": "Rotation",
|
| 137 |
"control.antenna_left": "Left",
|
| 138 |
"control.antenna_right": "Right",
|
| 139 |
+
"control.intensity": "Brightness",
|
| 140 |
+
"control.precision": "Fine tuning",
|
| 141 |
+
"control.sequence": "Sequence",
|
| 142 |
+
"control.seq_add": "Capture the pose",
|
| 143 |
+
"control.seq_prev": "Previous pose",
|
| 144 |
+
"control.seq_next": "Next pose",
|
| 145 |
+
"control.seq_play": "Play",
|
| 146 |
+
"control.seq_import": "Import (.json)",
|
| 147 |
+
"control.seq_export": "Export (.json)",
|
| 148 |
+
"control.seq_clear": "Clear",
|
| 149 |
+
"control.seq_empty": "No pose yet. Pose the robot, then +.",
|
| 150 |
+
"control.seq_bad": "Invalid sequence file",
|
| 151 |
+
"control.eyes_off": "Off",
|
| 152 |
+
"control.eyes_custom": "Custom colour",
|
| 153 |
+
"eyes.effects": "Eyes",
|
| 154 |
+
"eyes.sc_wink": "Wink",
|
| 155 |
+
"eyes.sc_breathe": "Breathe",
|
| 156 |
+
"eyes.sc_smooth": "Fade",
|
| 157 |
+
"eyes.sc_disco": "Disco",
|
| 158 |
+
"eyes.sc_chaos": "Chaos",
|
| 159 |
+
"eyes.sc_weewoo": "Weewoo",
|
| 160 |
+
"eyes.sc_pinpon": "Pin-pon",
|
| 161 |
+
"eyes.sc_cylon": "Cylon",
|
| 162 |
+
"eyes.sc_anger": "Anger",
|
| 163 |
+
"eyes.sc_amazement": "Amazement",
|
| 164 |
+
"eyes.sc_heart": "Heartbeat",
|
| 165 |
+
"eyes.sc_hypno": "Hypno",
|
| 166 |
+
"eyes.sc_sunset": "Sunset",
|
| 167 |
"control.move_running": "A recorded move is playing, the robot ignores manual control until it ends.",
|
| 168 |
"common.connect": "Connect",
|
| 169 |
"common.connected": "Connected",
|
|
|
|
| 170 |
"common.error_prefix": "Error: {message}",
|
| 171 |
"common.failed": "Failed",
|
| 172 |
"common.off": "Off",
|
|
|
|
| 229 |
"logs.title": "Daemon logs",
|
| 230 |
"logs.warn_plus": "Warn+",
|
| 231 |
"moves.daemon_unreachable": "daemon unreachable, no moves",
|
|
|
|
| 232 |
"moves.dances": "Dances",
|
| 233 |
"moves.emotions": "Emotions",
|
| 234 |
"moves.loading": "Loading moves…",
|
|
|
|
| 242 |
"eq.treble": "Treble",
|
| 243 |
"eq.general": "General",
|
| 244 |
"eq.tone": "Tone",
|
| 245 |
+
"eq.bands": "Equalizer",
|
| 246 |
+
"eq.flat": "Flat",
|
| 247 |
"eq.unavailable": "EasyEffects is not installed on the robot.",
|
| 248 |
"settings.appearance": "Appearance",
|
| 249 |
"settings.language": "Language",
|
|
|
|
| 251 |
"settings.light_theme": "Light theme",
|
| 252 |
"settings.palette": "Palette",
|
| 253 |
"settings.random_skin": "Random skin",
|
| 254 |
+
"settings.debug": "Debug console",
|
|
|
|
|
|
|
| 255 |
"settings.theme": "Theme",
|
| 256 |
"settings.title": "Settings",
|
| 257 |
"store.advanced_title": "Install a private Space",
|
|
|
|
| 489 |
"cam.eyes.5": "Je vérifie que j'ai bien mes deux yeux…",
|
| 490 |
"cam.eyes.6": "Je recompte mes pixels…",
|
| 491 |
"cam.eyes.7": "J'essuie la buée…",
|
| 492 |
+
"cam.eyes.8": "Encore cinq petites millisecondes…",
|
| 493 |
"cam.eyes.9": "Et la lumière fut…",
|
| 494 |
"cam.eyes.10": "Je bugue un peu au réveil…",
|
| 495 |
"cam.eyes.11": "On commence par un café ?",
|
|
|
|
| 503 |
"skin.name.default": "Original",
|
| 504 |
"skin.name.flash": "Flash",
|
| 505 |
"skin.name.spider": "Araignée",
|
| 506 |
+
"skin.name.bat": "Chauve-Souris",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
"skin.name.steel": "Super",
|
| 508 |
"skin.name.iron": "Iron",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
"skin.name.volt": "Volt",
|
| 510 |
+
"skin.name.tuxedo": "Smoking",
|
| 511 |
+
"skin.name.bee": "Abeille",
|
| 512 |
+
"skin.name.graffiti": "Graffiti",
|
| 513 |
+
"skin.name.rainbow": "Arc-en-ciel",
|
| 514 |
+
"skin.name.urban": "Camo urbain",
|
| 515 |
+
"skin.name.america": "Amérique",
|
| 516 |
+
"skin.name.breton": "Breton",
|
| 517 |
+
"skin.name.pixel": "Pixel",
|
| 518 |
+
"skin.name.plumber": "Plombier",
|
| 519 |
+
"skin.name.ghost": "Fantôme",
|
| 520 |
+
"skin.name.capsule": "Capsule",
|
| 521 |
+
"skin.name.ronin": "Ronin",
|
| 522 |
+
"skin.name.wizard": "Mage",
|
| 523 |
+
"skin.name.mamba": "Mamba",
|
| 524 |
+
"skin.name.pirate": "Pirate",
|
| 525 |
+
"skin.name.tiger": "Tigre",
|
| 526 |
+
"skin.name.clown": "Poisson-clown",
|
| 527 |
+
"skin.name.panda": "Panda",
|
| 528 |
+
"skin.name.ladybug": "Coccinelle",
|
| 529 |
+
"skin.name.japan": "Japon",
|
| 530 |
+
"skin.name.germany": "Allemagne",
|
| 531 |
+
"skin.name.ukraine": "Ukraine",
|
| 532 |
+
"skin.name.mondrian": "Mondrian",
|
| 533 |
+
"skin.name.wave": "La Vague",
|
| 534 |
+
"skin.name.sakura": "Sakura",
|
| 535 |
+
"skin.name.aurora": "Aurore",
|
| 536 |
+
"skin.name.wood": "Bois",
|
| 537 |
+
"skin.name.carbon": "Carbone",
|
| 538 |
+
"skin.name.circuit": "Circuit",
|
| 539 |
"skin.all": "Tous",
|
| 540 |
"skin.search": "Rechercher…",
|
| 541 |
"camera.replay": "REPLAY",
|
|
|
|
| 545 |
"camera.mode_camera": "Caméra",
|
| 546 |
"tab.main": "Reachy",
|
| 547 |
"tab.store": "Store",
|
| 548 |
+
"appstage.dashboard": "Dashboard",
|
| 549 |
+
"appstage.app": "App",
|
| 550 |
+
"appstage.reload": "Recharger la page",
|
| 551 |
+
"appstage.restart": "Redémarrer l'app",
|
| 552 |
+
"appstage.debug": "Logs de l'app",
|
| 553 |
+
"appstage.fullscreen": "Ouvrir dans un nouvel onglet",
|
| 554 |
+
"appstage.close": "Fermer (arrêter l'app)",
|
| 555 |
+
"appstage.fps_hint": "Boucle de contrôle du robot (Hz)",
|
| 556 |
"tab.settings": "Paramètres",
|
| 557 |
"camera.mode_3d": "Simulateur",
|
| 558 |
+
"twin.hint_title": "Manipule le robot",
|
| 559 |
+
"twin.hint_body": "Attrape la tête ou le corps et glisse pour l'orienter",
|
| 560 |
+
"twin.replay": "Comment bouger le robot",
|
| 561 |
+
"camera.flag_live": "En direct",
|
| 562 |
"camera.mode_mjpeg": "Caméra (MJPEG)",
|
| 563 |
"camera.mode_webrtc": "Caméra + son (WebRTC)",
|
| 564 |
"camera.listen": "Écouter les micros du robot",
|
|
|
|
| 581 |
"control.title": "Contrôleur",
|
| 582 |
"control.home": "Se relever (position initiale)",
|
| 583 |
"control.rest": "Revenir à la position de repos",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
"control.yaw": "Lacet",
|
| 585 |
"control.pitch": "Tangage",
|
| 586 |
"control.roll": "Roulis",
|
|
|
|
| 590 |
"control.body_yaw": "Rotation",
|
| 591 |
"control.antenna_left": "Gauche",
|
| 592 |
"control.antenna_right": "Droite",
|
| 593 |
+
"control.intensity": "Luminosité",
|
| 594 |
+
"control.precision": "Réglages fins",
|
| 595 |
+
"control.sequence": "Séquence",
|
| 596 |
+
"control.seq_add": "Capturer la pose",
|
| 597 |
+
"control.seq_prev": "Pose précédente",
|
| 598 |
+
"control.seq_next": "Pose suivante",
|
| 599 |
+
"control.seq_play": "Rejouer",
|
| 600 |
+
"control.seq_import": "Importer (.json)",
|
| 601 |
+
"control.seq_export": "Exporter (.json)",
|
| 602 |
+
"control.seq_clear": "Vider",
|
| 603 |
+
"control.seq_empty": "Aucune pose. Posez le robot, puis +.",
|
| 604 |
+
"control.seq_bad": "Fichier de séquence invalide",
|
| 605 |
+
"control.eyes_off": "Éteints",
|
| 606 |
+
"control.eyes_custom": "Couleur personnalisée",
|
| 607 |
+
"eyes.effects": "Yeux",
|
| 608 |
+
"eyes.sc_wink": "Clin d'œil",
|
| 609 |
+
"eyes.sc_breathe": "Respiration",
|
| 610 |
+
"eyes.sc_smooth": "Fondu",
|
| 611 |
+
"eyes.sc_disco": "Disco",
|
| 612 |
+
"eyes.sc_chaos": "Chaos",
|
| 613 |
+
"eyes.sc_weewoo": "Weewoo",
|
| 614 |
+
"eyes.sc_pinpon": "Pin-pon",
|
| 615 |
+
"eyes.sc_cylon": "Cylon",
|
| 616 |
+
"eyes.sc_anger": "Colère",
|
| 617 |
+
"eyes.sc_amazement": "Stupeur",
|
| 618 |
+
"eyes.sc_heart": "Battement",
|
| 619 |
+
"eyes.sc_hypno": "Hypnose",
|
| 620 |
+
"eyes.sc_sunset": "Couchant",
|
| 621 |
"control.move_running": "Un mouvement enregistré est en cours : le robot ignore le contrôle manuel jusqu'à la fin.",
|
| 622 |
"common.connect": "Connecter",
|
| 623 |
"common.connected": "Connecté",
|
|
|
|
| 624 |
"common.error_prefix": "Erreur : {message}",
|
| 625 |
"common.failed": "Échec",
|
| 626 |
"common.off": "Désactivé",
|
|
|
|
| 683 |
"logs.title": "Logs du daemon",
|
| 684 |
"logs.warn_plus": "Warn+",
|
| 685 |
"moves.daemon_unreachable": "daemon injoignable, aucun mouvement",
|
|
|
|
| 686 |
"moves.dances": "Danses",
|
| 687 |
"moves.emotions": "Émotions",
|
| 688 |
"moves.loading": "Chargement des mouvements…",
|
|
|
|
| 696 |
"eq.treble": "Aigu",
|
| 697 |
"eq.general": "Général",
|
| 698 |
"eq.tone": "Tonalité",
|
| 699 |
+
"eq.bands": "Égaliseur",
|
| 700 |
+
"eq.flat": "Plat",
|
| 701 |
"eq.unavailable": "EasyEffects n'est pas installé sur le robot.",
|
| 702 |
"settings.appearance": "Apparence",
|
| 703 |
"settings.language": "Langue",
|
|
|
|
| 705 |
"settings.light_theme": "Thème clair",
|
| 706 |
"settings.palette": "Palette",
|
| 707 |
"settings.random_skin": "Skin aléatoire",
|
| 708 |
+
"settings.debug": "Debug console",
|
|
|
|
|
|
|
| 709 |
"settings.theme": "Thème",
|
| 710 |
"settings.title": "Paramètres",
|
| 711 |
"store.advanced_title": "Installer un Space privé",
|
|
|
|
| 943 |
"cam.eyes.5": "Compruebo que sigo teniendo los dos ojos…",
|
| 944 |
"cam.eyes.6": "Recuento mis píxeles…",
|
| 945 |
"cam.eyes.7": "Quito el vaho…",
|
| 946 |
+
"cam.eyes.8": "Cinco milisegundos más…",
|
| 947 |
"cam.eyes.9": "Y se hizo la luz…",
|
| 948 |
"cam.eyes.10": "Fallo un poco al despertar…",
|
| 949 |
"cam.eyes.11": "¿Primero un café?",
|
|
|
|
| 957 |
"skin.name.default": "Original",
|
| 958 |
"skin.name.flash": "Flash",
|
| 959 |
"skin.name.spider": "Araña",
|
| 960 |
+
"skin.name.bat": "Murciélago",
|
| 961 |
+
"skin.name.steel": "Super",
|
| 962 |
+
"skin.name.iron": "Iron",
|
| 963 |
+
"skin.name.volt": "Volt",
|
| 964 |
+
"skin.name.tuxedo": "Esmoquin",
|
| 965 |
+
"skin.name.bee": "Abeja",
|
| 966 |
"skin.name.graffiti": "Grafiti",
|
| 967 |
"skin.name.rainbow": "Arcoíris",
|
| 968 |
+
"skin.name.urban": "Camuflaje urbano",
|
| 969 |
+
"skin.name.america": "América",
|
| 970 |
"skin.name.breton": "Bretón",
|
| 971 |
+
"skin.name.pixel": "Píxel",
|
| 972 |
+
"skin.name.plumber": "Fontanero",
|
| 973 |
+
"skin.name.ghost": "Fantasma",
|
| 974 |
+
"skin.name.capsule": "Cápsula",
|
| 975 |
+
"skin.name.ronin": "Ronin",
|
| 976 |
+
"skin.name.wizard": "Mago",
|
| 977 |
+
"skin.name.mamba": "Mamba",
|
| 978 |
+
"skin.name.pirate": "Pirata",
|
| 979 |
+
"skin.name.tiger": "Tigre",
|
| 980 |
+
"skin.name.clown": "Pez payaso",
|
| 981 |
+
"skin.name.panda": "Panda",
|
| 982 |
+
"skin.name.ladybug": "Mariquita",
|
| 983 |
+
"skin.name.japan": "Japón",
|
| 984 |
+
"skin.name.germany": "Alemania",
|
| 985 |
+
"skin.name.ukraine": "Ucrania",
|
| 986 |
+
"skin.name.mondrian": "Mondrian",
|
| 987 |
+
"skin.name.wave": "La Ola",
|
| 988 |
+
"skin.name.sakura": "Sakura",
|
| 989 |
+
"skin.name.aurora": "Aurora",
|
| 990 |
+
"skin.name.wood": "Madera",
|
| 991 |
+
"skin.name.carbon": "Carbono",
|
| 992 |
+
"skin.name.circuit": "Circuito",
|
| 993 |
"skin.all": "Todos",
|
| 994 |
"skin.search": "Buscar…",
|
| 995 |
"camera.replay": "REPETICIÓN",
|
|
|
|
| 999 |
"camera.mode_camera": "Cámara",
|
| 1000 |
"tab.main": "Reachy",
|
| 1001 |
"tab.store": "Tienda",
|
| 1002 |
+
"appstage.dashboard": "Panel",
|
| 1003 |
+
"appstage.app": "App",
|
| 1004 |
+
"appstage.reload": "Recargar la página",
|
| 1005 |
+
"appstage.restart": "Reiniciar la app",
|
| 1006 |
+
"appstage.debug": "Registros de la app",
|
| 1007 |
+
"appstage.fullscreen": "Abrir en una pestaña nueva",
|
| 1008 |
+
"appstage.close": "Cerrar (detener la app)",
|
| 1009 |
+
"appstage.fps_hint": "Bucle de control del robot (Hz)",
|
| 1010 |
"tab.settings": "Ajustes",
|
| 1011 |
"camera.mode_3d": "Simulador",
|
| 1012 |
+
"twin.hint_title": "Mueve el robot",
|
| 1013 |
+
"twin.hint_body": "Agarra la cabeza o el cuerpo y arrástralo para orientarlo",
|
| 1014 |
+
"twin.replay": "Cómo mover el robot",
|
| 1015 |
+
"camera.flag_live": "En vivo",
|
| 1016 |
"camera.mode_mjpeg": "Cámara (MJPEG)",
|
| 1017 |
"camera.mode_webrtc": "Cámara + sonido (WebRTC)",
|
| 1018 |
"camera.listen": "Escuchar los micrófonos del robot",
|
|
|
|
| 1035 |
"control.title": "Controlador",
|
| 1036 |
"control.home": "Enderezarse (posición inicial)",
|
| 1037 |
"control.rest": "Volver a la posición de reposo",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1038 |
"control.yaw": "Guiñada",
|
| 1039 |
"control.pitch": "Cabeceo",
|
| 1040 |
"control.roll": "Alabeo",
|
|
|
|
| 1044 |
"control.body_yaw": "Rotación",
|
| 1045 |
"control.antenna_left": "Izquierda",
|
| 1046 |
"control.antenna_right": "Derecha",
|
| 1047 |
+
"control.intensity": "Brillo",
|
| 1048 |
+
"control.precision": "Ajustes finos",
|
| 1049 |
+
"control.sequence": "Secuencia",
|
| 1050 |
+
"control.seq_add": "Capturar la pose",
|
| 1051 |
+
"control.seq_prev": "Pose anterior",
|
| 1052 |
+
"control.seq_next": "Pose siguiente",
|
| 1053 |
+
"control.seq_play": "Reproducir",
|
| 1054 |
+
"control.seq_import": "Importar (.json)",
|
| 1055 |
+
"control.seq_export": "Exportar (.json)",
|
| 1056 |
+
"control.seq_clear": "Vaciar",
|
| 1057 |
+
"control.seq_empty": "Sin poses. Coloca el robot, luego +.",
|
| 1058 |
+
"control.seq_bad": "Archivo de secuencia no válido",
|
| 1059 |
+
"control.eyes_off": "Apagados",
|
| 1060 |
+
"control.eyes_custom": "Color personalizado",
|
| 1061 |
+
"eyes.effects": "Ojos",
|
| 1062 |
+
"eyes.sc_wink": "Guiño",
|
| 1063 |
+
"eyes.sc_breathe": "Respiración",
|
| 1064 |
+
"eyes.sc_smooth": "Fundido",
|
| 1065 |
+
"eyes.sc_disco": "Disco",
|
| 1066 |
+
"eyes.sc_chaos": "Caos",
|
| 1067 |
+
"eyes.sc_weewoo": "Weewoo",
|
| 1068 |
+
"eyes.sc_pinpon": "Pin-pon",
|
| 1069 |
+
"eyes.sc_cylon": "Cylon",
|
| 1070 |
+
"eyes.sc_anger": "Ira",
|
| 1071 |
+
"eyes.sc_amazement": "Asombro",
|
| 1072 |
+
"eyes.sc_heart": "Latido",
|
| 1073 |
+
"eyes.sc_hypno": "Hipnosis",
|
| 1074 |
+
"eyes.sc_sunset": "Ocaso",
|
| 1075 |
"control.move_running": "Se está reproduciendo un movimiento grabado: el robot ignora el control manual hasta que termine.",
|
| 1076 |
"common.connect": "Conectar",
|
| 1077 |
"common.connected": "Conectado",
|
|
|
|
| 1078 |
"common.error_prefix": "Error: {message}",
|
| 1079 |
"common.failed": "Falló",
|
| 1080 |
"common.off": "Apagado",
|
|
|
|
| 1137 |
"logs.title": "Logs del daemon",
|
| 1138 |
"logs.warn_plus": "Warn+",
|
| 1139 |
"moves.daemon_unreachable": "daemon inaccesible: sin movimientos",
|
|
|
|
| 1140 |
"moves.dances": "Bailes",
|
| 1141 |
"moves.emotions": "Emociones",
|
| 1142 |
"moves.loading": "Cargando movimientos…",
|
|
|
|
| 1150 |
"eq.treble": "Agudos",
|
| 1151 |
"eq.general": "General",
|
| 1152 |
"eq.tone": "Tono",
|
| 1153 |
+
"eq.bands": "Ecualizador",
|
| 1154 |
+
"eq.flat": "Plano",
|
| 1155 |
"eq.unavailable": "EasyEffects no está instalado en el robot.",
|
| 1156 |
"settings.appearance": "Apariencia",
|
| 1157 |
"settings.language": "Idioma",
|
|
|
|
| 1159 |
"settings.light_theme": "Tema claro",
|
| 1160 |
"settings.palette": "Paleta",
|
| 1161 |
"settings.random_skin": "Skin aleatorio",
|
| 1162 |
+
"settings.debug": "Consola de depuración",
|
|
|
|
|
|
|
| 1163 |
"settings.theme": "Tema",
|
| 1164 |
"settings.title": "Ajustes",
|
| 1165 |
"store.advanced_title": "Instalar un Space privado",
|
|
|
|
| 1397 |
"cam.eyes.5": "Ich prüfe, ob ich noch beide Augen habe…",
|
| 1398 |
"cam.eyes.6": "Ich zähle meine Pixel nach…",
|
| 1399 |
"cam.eyes.7": "Ich wische den Beschlag weg…",
|
| 1400 |
+
"cam.eyes.8": "Noch fünf Millisekunden…",
|
| 1401 |
"cam.eyes.9": "Und es ward Licht…",
|
| 1402 |
"cam.eyes.10": "Ich hänge morgens manchmal kurz…",
|
| 1403 |
"cam.eyes.11": "Erst mal einen Kaffee?",
|
|
|
|
| 1411 |
"skin.name.default": "Original",
|
| 1412 |
"skin.name.flash": "Flash",
|
| 1413 |
"skin.name.spider": "Spinne",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1414 |
"skin.name.bat": "Fledermaus",
|
| 1415 |
"skin.name.steel": "Super",
|
| 1416 |
"skin.name.iron": "Iron",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1417 |
"skin.name.volt": "Volt",
|
| 1418 |
+
"skin.name.tuxedo": "Smoking",
|
| 1419 |
+
"skin.name.bee": "Biene",
|
| 1420 |
+
"skin.name.graffiti": "Graffiti",
|
| 1421 |
+
"skin.name.rainbow": "Regenbogen",
|
| 1422 |
+
"skin.name.urban": "Urban-Tarnung",
|
| 1423 |
+
"skin.name.america": "Amerika",
|
| 1424 |
+
"skin.name.breton": "Bretonisch",
|
| 1425 |
+
"skin.name.pixel": "Pixel",
|
| 1426 |
+
"skin.name.plumber": "Klempner",
|
| 1427 |
+
"skin.name.ghost": "Geist",
|
| 1428 |
+
"skin.name.capsule": "Kapsel",
|
| 1429 |
+
"skin.name.ronin": "Ronin",
|
| 1430 |
+
"skin.name.wizard": "Magier",
|
| 1431 |
+
"skin.name.mamba": "Mamba",
|
| 1432 |
+
"skin.name.pirate": "Pirat",
|
| 1433 |
+
"skin.name.tiger": "Tiger",
|
| 1434 |
+
"skin.name.clown": "Clownfisch",
|
| 1435 |
+
"skin.name.panda": "Panda",
|
| 1436 |
+
"skin.name.ladybug": "Marienkäfer",
|
| 1437 |
+
"skin.name.japan": "Japan",
|
| 1438 |
+
"skin.name.germany": "Deutschland",
|
| 1439 |
+
"skin.name.ukraine": "Ukraine",
|
| 1440 |
+
"skin.name.mondrian": "Mondrian",
|
| 1441 |
+
"skin.name.wave": "Die Welle",
|
| 1442 |
+
"skin.name.sakura": "Sakura",
|
| 1443 |
+
"skin.name.aurora": "Aurora",
|
| 1444 |
+
"skin.name.wood": "Holz",
|
| 1445 |
+
"skin.name.carbon": "Karbon",
|
| 1446 |
+
"skin.name.circuit": "Schaltkreis",
|
| 1447 |
"skin.all": "Alle",
|
| 1448 |
"skin.search": "Suchen…",
|
| 1449 |
"camera.replay": "REPLAY",
|
|
|
|
| 1453 |
"camera.mode_camera": "Kamera",
|
| 1454 |
"tab.main": "Reachy",
|
| 1455 |
"tab.store": "Store",
|
| 1456 |
+
"appstage.dashboard": "Dashboard",
|
| 1457 |
+
"appstage.app": "App",
|
| 1458 |
+
"appstage.reload": "Seite neu laden",
|
| 1459 |
+
"appstage.restart": "App neu starten",
|
| 1460 |
+
"appstage.debug": "App-Logs",
|
| 1461 |
+
"appstage.fullscreen": "In neuem Tab öffnen",
|
| 1462 |
+
"appstage.close": "Schließen (App stoppen)",
|
| 1463 |
+
"appstage.fps_hint": "Regelschleife des Roboters (Hz)",
|
| 1464 |
"tab.settings": "Einstellungen",
|
| 1465 |
"camera.mode_3d": "Simulator",
|
| 1466 |
+
"twin.hint_title": "Bewege den Roboter",
|
| 1467 |
+
"twin.hint_body": "Kopf oder Körper greifen und ziehen, um ihn auszurichten",
|
| 1468 |
+
"twin.replay": "So bewegst du den Roboter",
|
| 1469 |
+
"camera.flag_live": "Live",
|
| 1470 |
"camera.mode_mjpeg": "Kamera (MJPEG)",
|
| 1471 |
"camera.mode_webrtc": "Kamera + Ton (WebRTC)",
|
| 1472 |
"camera.listen": "Robotermikrofone abhören",
|
|
|
|
| 1489 |
"control.title": "Steuerung",
|
| 1490 |
"control.home": "Grundstellung (aufrichten)",
|
| 1491 |
"control.rest": "Zurück in die Ruhelage",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1492 |
"control.yaw": "Gieren",
|
| 1493 |
"control.pitch": "Nicken",
|
| 1494 |
"control.roll": "Rollen",
|
|
|
|
| 1498 |
"control.body_yaw": "Drehung",
|
| 1499 |
"control.antenna_left": "Links",
|
| 1500 |
"control.antenna_right": "Rechts",
|
| 1501 |
+
"control.intensity": "Helligkeit",
|
| 1502 |
+
"control.precision": "Feineinstellung",
|
| 1503 |
+
"control.sequence": "Sequenz",
|
| 1504 |
+
"control.seq_add": "Pose aufnehmen",
|
| 1505 |
+
"control.seq_prev": "Vorherige Pose",
|
| 1506 |
+
"control.seq_next": "Nächste Pose",
|
| 1507 |
+
"control.seq_play": "Abspielen",
|
| 1508 |
+
"control.seq_import": "Importieren (.json)",
|
| 1509 |
+
"control.seq_export": "Exportieren (.json)",
|
| 1510 |
+
"control.seq_clear": "Leeren",
|
| 1511 |
+
"control.seq_empty": "Noch keine Pose. Roboter posieren, dann +.",
|
| 1512 |
+
"control.seq_bad": "Ungültige Sequenzdatei",
|
| 1513 |
+
"control.eyes_off": "Aus",
|
| 1514 |
+
"control.eyes_custom": "Eigene Farbe",
|
| 1515 |
+
"eyes.effects": "Augen",
|
| 1516 |
+
"eyes.sc_wink": "Zwinkern",
|
| 1517 |
+
"eyes.sc_breathe": "Atmen",
|
| 1518 |
+
"eyes.sc_smooth": "Farbwechsel",
|
| 1519 |
+
"eyes.sc_disco": "Disco",
|
| 1520 |
+
"eyes.sc_chaos": "Chaos",
|
| 1521 |
+
"eyes.sc_weewoo": "Weewoo",
|
| 1522 |
+
"eyes.sc_pinpon": "Pin-pon",
|
| 1523 |
+
"eyes.sc_cylon": "Cylon",
|
| 1524 |
+
"eyes.sc_anger": "Wut",
|
| 1525 |
+
"eyes.sc_amazement": "Staunen",
|
| 1526 |
+
"eyes.sc_heart": "Herzschlag",
|
| 1527 |
+
"eyes.sc_hypno": "Hypnose",
|
| 1528 |
+
"eyes.sc_sunset": "Abendrot",
|
| 1529 |
"control.move_running": "Eine aufgezeichnete Bewegung läuft, der Roboter ignoriert die manuelle Steuerung bis zum Ende.",
|
| 1530 |
"common.connect": "Verbinden",
|
| 1531 |
"common.connected": "Verbunden",
|
|
|
|
| 1532 |
"common.error_prefix": "Fehler: {message}",
|
| 1533 |
"common.failed": "Fehlgeschlagen",
|
| 1534 |
"common.off": "Aus",
|
|
|
|
| 1591 |
"logs.title": "Daemon-Logs",
|
| 1592 |
"logs.warn_plus": "Warn+",
|
| 1593 |
"moves.daemon_unreachable": "Daemon nicht erreichbar, keine Bewegungen",
|
|
|
|
| 1594 |
"moves.dances": "Tänze",
|
| 1595 |
"moves.emotions": "Emotionen",
|
| 1596 |
"moves.loading": "Bewegungen werden geladen…",
|
|
|
|
| 1604 |
"eq.treble": "Höhen",
|
| 1605 |
"eq.general": "Allgemein",
|
| 1606 |
"eq.tone": "Klang",
|
| 1607 |
+
"eq.bands": "Equalizer",
|
| 1608 |
+
"eq.flat": "Neutral",
|
| 1609 |
"eq.unavailable": "EasyEffects ist nicht auf dem Roboter installiert.",
|
| 1610 |
"settings.appearance": "Erscheinungsbild",
|
| 1611 |
"settings.language": "Sprache",
|
|
|
|
| 1613 |
"settings.light_theme": "Helles Design",
|
| 1614 |
"settings.palette": "Farbpalette",
|
| 1615 |
"settings.random_skin": "Zufalls-Skin",
|
| 1616 |
+
"settings.debug": "Debug-Konsole",
|
|
|
|
|
|
|
| 1617 |
"settings.theme": "Design",
|
| 1618 |
"settings.title": "Einstellungen",
|
| 1619 |
"store.advanced_title": "Privaten Space installieren",
|
|
|
|
| 1851 |
"cam.eyes.5": "Controllo di avere ancora entrambi gli occhi…",
|
| 1852 |
"cam.eyes.6": "Riconto i miei pixel…",
|
| 1853 |
"cam.eyes.7": "Tolgo l'appannamento…",
|
| 1854 |
+
"cam.eyes.8": "Ancora cinque millisecondi…",
|
| 1855 |
"cam.eyes.9": "E luce fu…",
|
| 1856 |
"cam.eyes.10": "Appena sveglio ho qualche glitch…",
|
| 1857 |
"cam.eyes.11": "Prima un caffè?",
|
|
|
|
| 1865 |
"skin.name.default": "Originale",
|
| 1866 |
"skin.name.flash": "Flash",
|
| 1867 |
"skin.name.spider": "Ragno",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1868 |
"skin.name.bat": "Pipistrello",
|
| 1869 |
"skin.name.steel": "Super",
|
| 1870 |
"skin.name.iron": "Iron",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1871 |
"skin.name.volt": "Volt",
|
| 1872 |
+
"skin.name.tuxedo": "Smoking",
|
| 1873 |
+
"skin.name.bee": "Ape",
|
| 1874 |
+
"skin.name.graffiti": "Graffiti",
|
| 1875 |
+
"skin.name.rainbow": "Arcobaleno",
|
| 1876 |
+
"skin.name.urban": "Camo urbano",
|
| 1877 |
+
"skin.name.america": "America",
|
| 1878 |
+
"skin.name.breton": "Bretone",
|
| 1879 |
+
"skin.name.pixel": "Pixel",
|
| 1880 |
+
"skin.name.plumber": "Idraulico",
|
| 1881 |
+
"skin.name.ghost": "Fantasma",
|
| 1882 |
+
"skin.name.capsule": "Capsula",
|
| 1883 |
+
"skin.name.ronin": "Ronin",
|
| 1884 |
+
"skin.name.wizard": "Mago",
|
| 1885 |
+
"skin.name.mamba": "Mamba",
|
| 1886 |
+
"skin.name.pirate": "Pirata",
|
| 1887 |
+
"skin.name.tiger": "Tigre",
|
| 1888 |
+
"skin.name.clown": "Pesce pagliaccio",
|
| 1889 |
+
"skin.name.panda": "Panda",
|
| 1890 |
+
"skin.name.ladybug": "Coccinella",
|
| 1891 |
+
"skin.name.japan": "Giappone",
|
| 1892 |
+
"skin.name.germany": "Germania",
|
| 1893 |
+
"skin.name.ukraine": "Ucraina",
|
| 1894 |
+
"skin.name.mondrian": "Mondrian",
|
| 1895 |
+
"skin.name.wave": "L'Onda",
|
| 1896 |
+
"skin.name.sakura": "Sakura",
|
| 1897 |
+
"skin.name.aurora": "Aurora",
|
| 1898 |
+
"skin.name.wood": "Legno",
|
| 1899 |
+
"skin.name.carbon": "Carbonio",
|
| 1900 |
+
"skin.name.circuit": "Circuito",
|
| 1901 |
"skin.all": "Tutte",
|
| 1902 |
"skin.search": "Cerca…",
|
| 1903 |
"camera.replay": "REPLAY",
|
|
|
|
| 1907 |
"camera.mode_camera": "Fotocamera",
|
| 1908 |
"tab.main": "Reachy",
|
| 1909 |
"tab.store": "Store",
|
| 1910 |
+
"appstage.dashboard": "Dashboard",
|
| 1911 |
+
"appstage.app": "App",
|
| 1912 |
+
"appstage.reload": "Ricarica la pagina",
|
| 1913 |
+
"appstage.restart": "Riavvia l'app",
|
| 1914 |
+
"appstage.debug": "Log dell'app",
|
| 1915 |
+
"appstage.fullscreen": "Apri in una nuova scheda",
|
| 1916 |
+
"appstage.close": "Chiudi (arresta l'app)",
|
| 1917 |
+
"appstage.fps_hint": "Ciclo di controllo del robot (Hz)",
|
| 1918 |
"tab.settings": "Impostazioni",
|
| 1919 |
"camera.mode_3d": "Simulatore",
|
| 1920 |
+
"twin.hint_title": "Muovi il robot",
|
| 1921 |
+
"twin.hint_body": "Afferra la testa o il corpo e trascina per orientarlo",
|
| 1922 |
+
"twin.replay": "Come muovere il robot",
|
| 1923 |
+
"camera.flag_live": "Dal vivo",
|
| 1924 |
"camera.mode_mjpeg": "Fotocamera (MJPEG)",
|
| 1925 |
"camera.mode_webrtc": "Fotocamera + audio (WebRTC)",
|
| 1926 |
"camera.listen": "Ascolta i microfoni del robot",
|
|
|
|
| 1943 |
"control.title": "Controller",
|
| 1944 |
"control.home": "Rialzati (posizione iniziale)",
|
| 1945 |
"control.rest": "Torna alla posizione di riposo",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1946 |
"control.yaw": "Imbardata",
|
| 1947 |
"control.pitch": "Beccheggio",
|
| 1948 |
"control.roll": "Rollio",
|
|
|
|
| 1952 |
"control.body_yaw": "Rotazione",
|
| 1953 |
"control.antenna_left": "Sinistra",
|
| 1954 |
"control.antenna_right": "Destra",
|
| 1955 |
+
"control.intensity": "Luminosità",
|
| 1956 |
+
"control.precision": "Regolazioni fini",
|
| 1957 |
+
"control.sequence": "Sequenza",
|
| 1958 |
+
"control.seq_add": "Cattura la posa",
|
| 1959 |
+
"control.seq_prev": "Posa precedente",
|
| 1960 |
+
"control.seq_next": "Posa successiva",
|
| 1961 |
+
"control.seq_play": "Riproduci",
|
| 1962 |
+
"control.seq_import": "Importa (.json)",
|
| 1963 |
+
"control.seq_export": "Esporta (.json)",
|
| 1964 |
+
"control.seq_clear": "Svuota",
|
| 1965 |
+
"control.seq_empty": "Nessuna posa. Posiziona il robot, poi +.",
|
| 1966 |
+
"control.seq_bad": "File di sequenza non valido",
|
| 1967 |
+
"control.eyes_off": "Spenti",
|
| 1968 |
+
"control.eyes_custom": "Colore personalizzato",
|
| 1969 |
+
"eyes.effects": "Occhi",
|
| 1970 |
+
"eyes.sc_wink": "Occhiolino",
|
| 1971 |
+
"eyes.sc_breathe": "Respiro",
|
| 1972 |
+
"eyes.sc_smooth": "Dissolvenza",
|
| 1973 |
+
"eyes.sc_disco": "Disco",
|
| 1974 |
+
"eyes.sc_chaos": "Caos",
|
| 1975 |
+
"eyes.sc_weewoo": "Weewoo",
|
| 1976 |
+
"eyes.sc_pinpon": "Pin-pon",
|
| 1977 |
+
"eyes.sc_cylon": "Cylon",
|
| 1978 |
+
"eyes.sc_anger": "Rabbia",
|
| 1979 |
+
"eyes.sc_amazement": "Stupore",
|
| 1980 |
+
"eyes.sc_heart": "Battito",
|
| 1981 |
+
"eyes.sc_hypno": "Ipnosi",
|
| 1982 |
+
"eyes.sc_sunset": "Tramonto",
|
| 1983 |
"control.move_running": "È in corso un movimento registrato: il robot ignora il controllo manuale fino al termine.",
|
| 1984 |
"common.connect": "Connetti",
|
| 1985 |
"common.connected": "Connesso",
|
|
|
|
| 1986 |
"common.error_prefix": "Errore: {message}",
|
| 1987 |
"common.failed": "Non riuscito",
|
| 1988 |
"common.off": "Off",
|
|
|
|
| 2045 |
"logs.title": "Log del daemon",
|
| 2046 |
"logs.warn_plus": "Warn+",
|
| 2047 |
"moves.daemon_unreachable": "daemon non raggiungibile, nessun movimento",
|
|
|
|
| 2048 |
"moves.dances": "Danze",
|
| 2049 |
"moves.emotions": "Emozioni",
|
| 2050 |
"moves.loading": "Caricamento movimenti…",
|
|
|
|
| 2058 |
"eq.treble": "Alti",
|
| 2059 |
"eq.general": "Generale",
|
| 2060 |
"eq.tone": "Tono",
|
| 2061 |
+
"eq.bands": "Equalizzatore",
|
| 2062 |
+
"eq.flat": "Piatto",
|
| 2063 |
"eq.unavailable": "EasyEffects non è installato sul robot.",
|
| 2064 |
"settings.appearance": "Aspetto",
|
| 2065 |
"settings.language": "Lingua",
|
|
|
|
| 2067 |
"settings.light_theme": "Tema chiaro",
|
| 2068 |
"settings.palette": "Palette",
|
| 2069 |
"settings.random_skin": "Skin casuale",
|
| 2070 |
+
"settings.debug": "Console di debug",
|
|
|
|
|
|
|
| 2071 |
"settings.theme": "Tema",
|
| 2072 |
"settings.title": "Impostazioni",
|
| 2073 |
"store.advanced_title": "Installa uno Space privato",
|
|
|
|
| 2305 |
"cam.eyes.5": "Verifico se tenho os dois olhos…",
|
| 2306 |
"cam.eyes.6": "Reconto os meus pixels…",
|
| 2307 |
"cam.eyes.7": "Limpo o embaciado…",
|
| 2308 |
+
"cam.eyes.8": "Mais cinco milissegundos…",
|
| 2309 |
"cam.eyes.9": "E fez-se luz…",
|
| 2310 |
"cam.eyes.10": "Dou uns bugs ao acordar…",
|
| 2311 |
"cam.eyes.11": "Um cafezinho primeiro?",
|
|
|
|
| 2319 |
"skin.name.default": "Original",
|
| 2320 |
"skin.name.flash": "Flash",
|
| 2321 |
"skin.name.spider": "Aranha",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2322 |
"skin.name.bat": "Morcego",
|
| 2323 |
"skin.name.steel": "Super",
|
| 2324 |
+
"skin.name.iron": "Iron",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2325 |
"skin.name.volt": "Volt",
|
| 2326 |
+
"skin.name.tuxedo": "Smoking",
|
| 2327 |
+
"skin.name.bee": "Abelha",
|
| 2328 |
+
"skin.name.graffiti": "Grafíti",
|
| 2329 |
+
"skin.name.rainbow": "Arco-íris",
|
| 2330 |
+
"skin.name.urban": "Camo urbano",
|
| 2331 |
+
"skin.name.america": "América",
|
| 2332 |
+
"skin.name.breton": "Bretão",
|
| 2333 |
+
"skin.name.pixel": "Pixel",
|
| 2334 |
+
"skin.name.plumber": "Encanador",
|
| 2335 |
+
"skin.name.ghost": "Fantasma",
|
| 2336 |
+
"skin.name.capsule": "Cápsula",
|
| 2337 |
+
"skin.name.ronin": "Ronin",
|
| 2338 |
+
"skin.name.wizard": "Mago",
|
| 2339 |
+
"skin.name.mamba": "Mamba",
|
| 2340 |
+
"skin.name.pirate": "Pirata",
|
| 2341 |
+
"skin.name.tiger": "Tigre",
|
| 2342 |
+
"skin.name.clown": "Peixe-palhaço",
|
| 2343 |
+
"skin.name.panda": "Panda",
|
| 2344 |
+
"skin.name.ladybug": "Joaninha",
|
| 2345 |
+
"skin.name.japan": "Japão",
|
| 2346 |
+
"skin.name.germany": "Alemanha",
|
| 2347 |
+
"skin.name.ukraine": "Ucrânia",
|
| 2348 |
+
"skin.name.mondrian": "Mondrian",
|
| 2349 |
+
"skin.name.wave": "A Onda",
|
| 2350 |
+
"skin.name.sakura": "Sakura",
|
| 2351 |
+
"skin.name.aurora": "Aurora",
|
| 2352 |
+
"skin.name.wood": "Madeira",
|
| 2353 |
+
"skin.name.carbon": "Carbono",
|
| 2354 |
+
"skin.name.circuit": "Circuito",
|
| 2355 |
"skin.all": "Todos",
|
| 2356 |
"skin.search": "Pesquisar…",
|
| 2357 |
"camera.replay": "REPETIÇÃO",
|
|
|
|
| 2361 |
"camera.mode_camera": "Câmara",
|
| 2362 |
"tab.main": "Reachy",
|
| 2363 |
"tab.store": "Loja",
|
| 2364 |
+
"appstage.dashboard": "Painel",
|
| 2365 |
+
"appstage.app": "App",
|
| 2366 |
+
"appstage.reload": "Recarregar a página",
|
| 2367 |
+
"appstage.restart": "Reiniciar a app",
|
| 2368 |
+
"appstage.debug": "Logs da app",
|
| 2369 |
+
"appstage.fullscreen": "Abrir num novo separador",
|
| 2370 |
+
"appstage.close": "Fechar (parar a app)",
|
| 2371 |
+
"appstage.fps_hint": "Ciclo de controlo do robô (Hz)",
|
| 2372 |
"tab.settings": "Definições",
|
| 2373 |
"camera.mode_3d": "Simulador",
|
| 2374 |
+
"twin.hint_title": "Mova o robô",
|
| 2375 |
+
"twin.hint_body": "Agarra a cabeça ou o corpo e arrasta para orientá-lo",
|
| 2376 |
+
"twin.replay": "Como mover o robô",
|
| 2377 |
+
"camera.flag_live": "Ao vivo",
|
| 2378 |
"camera.mode_mjpeg": "Câmara (MJPEG)",
|
| 2379 |
"camera.mode_webrtc": "Câmara + som (WebRTC)",
|
| 2380 |
"camera.listen": "Ouvir os microfones do robô",
|
|
|
|
| 2397 |
"control.title": "Controlador",
|
| 2398 |
"control.home": "Posição inicial (levantar)",
|
| 2399 |
"control.rest": "Voltar à posição de repouso",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2400 |
"control.yaw": "Guinada",
|
| 2401 |
"control.pitch": "Inclinação",
|
| 2402 |
"control.roll": "Rolamento",
|
|
|
|
| 2406 |
"control.body_yaw": "Rotação",
|
| 2407 |
"control.antenna_left": "Esquerda",
|
| 2408 |
"control.antenna_right": "Direita",
|
| 2409 |
+
"control.intensity": "Brilho",
|
| 2410 |
+
"control.precision": "Ajustes finos",
|
| 2411 |
+
"control.sequence": "Sequência",
|
| 2412 |
+
"control.seq_add": "Capturar a pose",
|
| 2413 |
+
"control.seq_prev": "Pose anterior",
|
| 2414 |
+
"control.seq_next": "Próxima pose",
|
| 2415 |
+
"control.seq_play": "Reproduzir",
|
| 2416 |
+
"control.seq_import": "Importar (.json)",
|
| 2417 |
+
"control.seq_export": "Exportar (.json)",
|
| 2418 |
+
"control.seq_clear": "Limpar",
|
| 2419 |
+
"control.seq_empty": "Sem poses. Posicione o robô, depois +.",
|
| 2420 |
+
"control.seq_bad": "Ficheiro de sequência inválido",
|
| 2421 |
+
"control.eyes_off": "Desligados",
|
| 2422 |
+
"control.eyes_custom": "Cor personalizada",
|
| 2423 |
+
"eyes.effects": "Olhos",
|
| 2424 |
+
"eyes.sc_wink": "Piscadela",
|
| 2425 |
+
"eyes.sc_breathe": "Respiração",
|
| 2426 |
+
"eyes.sc_smooth": "Fusão",
|
| 2427 |
+
"eyes.sc_disco": "Disco",
|
| 2428 |
+
"eyes.sc_chaos": "Caos",
|
| 2429 |
+
"eyes.sc_weewoo": "Weewoo",
|
| 2430 |
+
"eyes.sc_pinpon": "Pin-pon",
|
| 2431 |
+
"eyes.sc_cylon": "Cylon",
|
| 2432 |
+
"eyes.sc_anger": "Raiva",
|
| 2433 |
+
"eyes.sc_amazement": "Espanto",
|
| 2434 |
+
"eyes.sc_heart": "Batimento",
|
| 2435 |
+
"eyes.sc_hypno": "Hipnose",
|
| 2436 |
+
"eyes.sc_sunset": "Pôr do sol",
|
| 2437 |
"control.move_running": "Um movimento gravado está a decorrer: o robô ignora o controlo manual até terminar.",
|
| 2438 |
"common.connect": "Ligar",
|
| 2439 |
"common.connected": "Ligado",
|
|
|
|
| 2440 |
"common.error_prefix": "Erro: {message}",
|
| 2441 |
"common.failed": "Falhou",
|
| 2442 |
"common.off": "Desligado",
|
|
|
|
| 2499 |
"logs.title": "Logs do daemon",
|
| 2500 |
"logs.warn_plus": "Warn+",
|
| 2501 |
"moves.daemon_unreachable": "daemon inacessível: sem movimentos",
|
|
|
|
| 2502 |
"moves.dances": "Danças",
|
| 2503 |
"moves.emotions": "Emoções",
|
| 2504 |
"moves.loading": "A carregar movimentos…",
|
|
|
|
| 2512 |
"eq.treble": "Agudos",
|
| 2513 |
"eq.general": "Geral",
|
| 2514 |
"eq.tone": "Tom",
|
| 2515 |
+
"eq.bands": "Equalizador",
|
| 2516 |
+
"eq.flat": "Plano",
|
| 2517 |
"eq.unavailable": "O EasyEffects não está instalado no robô.",
|
| 2518 |
"settings.appearance": "Aspeto",
|
| 2519 |
"settings.language": "Idioma",
|
|
|
|
| 2521 |
"settings.light_theme": "Tema claro",
|
| 2522 |
"settings.palette": "Paleta",
|
| 2523 |
"settings.random_skin": "Skin aleatório",
|
| 2524 |
+
"settings.debug": "Consola de depuração",
|
|
|
|
|
|
|
| 2525 |
"settings.theme": "Tema",
|
| 2526 |
"settings.title": "Definições",
|
| 2527 |
"store.advanced_title": "Instalar um Space privado",
|
|
|
|
| 2759 |
"cam.eyes.5": "目が二つあるか確認中…",
|
| 2760 |
"cam.eyes.6": "ピクセルを数え直しています…",
|
| 2761 |
"cam.eyes.7": "くもりを拭き取っています…",
|
| 2762 |
+
"cam.eyes.8": "あと五ミリ秒…",
|
| 2763 |
"cam.eyes.9": "光あれ…",
|
| 2764 |
"cam.eyes.10": "起きたては少しバグりがち…",
|
| 2765 |
"cam.eyes.11": "まずはコーヒー?",
|
|
|
|
| 2773 |
"skin.name.default": "オリジナル",
|
| 2774 |
"skin.name.flash": "フラッシュ",
|
| 2775 |
"skin.name.spider": "スパイダー",
|
| 2776 |
+
"skin.name.bat": "コウモリ",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2777 |
"skin.name.steel": "スーパー",
|
| 2778 |
"skin.name.iron": "アイアン",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2779 |
"skin.name.volt": "ボルト",
|
| 2780 |
+
"skin.name.tuxedo": "タキシード",
|
| 2781 |
+
"skin.name.bee": "ミツバチ",
|
| 2782 |
+
"skin.name.graffiti": "グラフィティ",
|
| 2783 |
+
"skin.name.rainbow": "レインボー",
|
| 2784 |
+
"skin.name.urban": "アーバン迷彩",
|
| 2785 |
+
"skin.name.america": "アメリカ",
|
| 2786 |
+
"skin.name.breton": "ブルターニュ",
|
| 2787 |
+
"skin.name.pixel": "ピクセル",
|
| 2788 |
+
"skin.name.plumber": "配管工",
|
| 2789 |
+
"skin.name.ghost": "ゴースト",
|
| 2790 |
+
"skin.name.capsule": "カプセル",
|
| 2791 |
+
"skin.name.ronin": "浪人",
|
| 2792 |
+
"skin.name.wizard": "魔法使い",
|
| 2793 |
+
"skin.name.mamba": "マンバ",
|
| 2794 |
+
"skin.name.pirate": "海賊",
|
| 2795 |
+
"skin.name.tiger": "トラ",
|
| 2796 |
+
"skin.name.clown": "カクレクマノミ",
|
| 2797 |
+
"skin.name.panda": "パンダ",
|
| 2798 |
+
"skin.name.ladybug": "てんとう虫",
|
| 2799 |
+
"skin.name.japan": "日本",
|
| 2800 |
+
"skin.name.germany": "ドイツ",
|
| 2801 |
+
"skin.name.ukraine": "ウクライナ",
|
| 2802 |
+
"skin.name.mondrian": "モンドリアン",
|
| 2803 |
+
"skin.name.wave": "波",
|
| 2804 |
+
"skin.name.sakura": "桜",
|
| 2805 |
+
"skin.name.aurora": "オーロラ",
|
| 2806 |
+
"skin.name.wood": "木目",
|
| 2807 |
+
"skin.name.carbon": "カーボン",
|
| 2808 |
+
"skin.name.circuit": "回路",
|
| 2809 |
"skin.all": "すべて",
|
| 2810 |
"skin.search": "検索…",
|
| 2811 |
"camera.replay": "リプレイ",
|
|
|
|
| 2815 |
"camera.mode_camera": "カメラ",
|
| 2816 |
"tab.main": "Reachy",
|
| 2817 |
"tab.store": "ストア",
|
| 2818 |
+
"appstage.dashboard": "ダッシュボード",
|
| 2819 |
+
"appstage.app": "アプリ",
|
| 2820 |
+
"appstage.reload": "ページを再読み込み",
|
| 2821 |
+
"appstage.restart": "アプリを再起動",
|
| 2822 |
+
"appstage.debug": "アプリのログ",
|
| 2823 |
+
"appstage.fullscreen": "新しいタブで開く",
|
| 2824 |
+
"appstage.close": "閉じる(アプリを停止)",
|
| 2825 |
+
"appstage.fps_hint": "ロボットの制御ループ (Hz)",
|
| 2826 |
"tab.settings": "設定",
|
| 2827 |
"camera.mode_3d": "シミュレーター",
|
| 2828 |
+
"twin.hint_title": "ロボットを動かす",
|
| 2829 |
+
"twin.hint_body": "頭や胴体をつかんでドラッグすると向きが変わります",
|
| 2830 |
+
"twin.replay": "ロボットの動かし方",
|
| 2831 |
+
"camera.flag_live": "ライブ",
|
| 2832 |
"camera.mode_mjpeg": "カメラ (MJPEG)",
|
| 2833 |
"camera.mode_webrtc": "カメラ + 音声 (WebRTC)",
|
| 2834 |
"camera.listen": "ロボットのマイクを聞く",
|
|
|
|
| 2851 |
"control.title": "コントローラー",
|
| 2852 |
"control.home": "初期姿勢に戻る",
|
| 2853 |
"control.rest": "待機姿勢に戻す",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2854 |
"control.yaw": "ヨー",
|
| 2855 |
"control.pitch": "ピッチ",
|
| 2856 |
"control.roll": "ロール",
|
|
|
|
| 2860 |
"control.body_yaw": "回転",
|
| 2861 |
"control.antenna_left": "左",
|
| 2862 |
"control.antenna_right": "右",
|
| 2863 |
+
"control.intensity": "明るさ",
|
| 2864 |
+
"control.precision": "微調整",
|
| 2865 |
+
"control.sequence": "シーケンス",
|
| 2866 |
+
"control.seq_add": "ポーズを記録",
|
| 2867 |
+
"control.seq_prev": "前のポーズ",
|
| 2868 |
+
"control.seq_next": "次のポーズ",
|
| 2869 |
+
"control.seq_play": "再生",
|
| 2870 |
+
"control.seq_import": "読み込み (.json)",
|
| 2871 |
+
"control.seq_export": "書き出し (.json)",
|
| 2872 |
+
"control.seq_clear": "クリア",
|
| 2873 |
+
"control.seq_empty": "ポーズなし。ロボットを動かして + を押す。",
|
| 2874 |
+
"control.seq_bad": "無効なシーケンスファイル",
|
| 2875 |
+
"control.eyes_off": "消灯",
|
| 2876 |
+
"control.eyes_custom": "カスタムカラー",
|
| 2877 |
+
"eyes.effects": "目",
|
| 2878 |
+
"eyes.sc_wink": "ウインク",
|
| 2879 |
+
"eyes.sc_breathe": "呼吸",
|
| 2880 |
+
"eyes.sc_smooth": "フェード",
|
| 2881 |
+
"eyes.sc_disco": "ディスコ",
|
| 2882 |
+
"eyes.sc_chaos": "カオス",
|
| 2883 |
+
"eyes.sc_weewoo": "ウィーウー",
|
| 2884 |
+
"eyes.sc_pinpon": "ピーポー",
|
| 2885 |
+
"eyes.sc_cylon": "サイロン",
|
| 2886 |
+
"eyes.sc_anger": "怒り",
|
| 2887 |
+
"eyes.sc_amazement": "驚き",
|
| 2888 |
+
"eyes.sc_heart": "鼓動",
|
| 2889 |
+
"eyes.sc_hypno": "催眠",
|
| 2890 |
+
"eyes.sc_sunset": "夕焼け",
|
| 2891 |
"control.move_running": "記録された動作を再生中です。終わるまで手動操作は無視されます。",
|
| 2892 |
"common.connect": "接続",
|
| 2893 |
"common.connected": "接続済み",
|
|
|
|
| 2894 |
"common.error_prefix": "エラー: {message}",
|
| 2895 |
"common.failed": "失敗",
|
| 2896 |
"common.off": "オフ",
|
|
|
|
| 2953 |
"logs.title": "デーモンログ",
|
| 2954 |
"logs.warn_plus": "Warn+",
|
| 2955 |
"moves.daemon_unreachable": "デーモンに接続できません。モーションなし",
|
|
|
|
| 2956 |
"moves.dances": "ダンス",
|
| 2957 |
"moves.emotions": "エモーション",
|
| 2958 |
"moves.loading": "モーションを読み込み中…",
|
|
|
|
| 2966 |
"eq.treble": "高音",
|
| 2967 |
"eq.general": "一般",
|
| 2968 |
"eq.tone": "トーン",
|
| 2969 |
+
"eq.bands": "イコライザー",
|
| 2970 |
+
"eq.flat": "フラット",
|
| 2971 |
"eq.unavailable": "EasyEffects はロボットにインストールされていません。",
|
| 2972 |
"settings.appearance": "外観",
|
| 2973 |
"settings.language": "言語",
|
|
|
|
| 2975 |
"settings.light_theme": "ライトテーマ",
|
| 2976 |
"settings.palette": "パレット",
|
| 2977 |
"settings.random_skin": "ランダムスキン",
|
| 2978 |
+
"settings.debug": "デバッグコンソール",
|
|
|
|
|
|
|
| 2979 |
"settings.theme": "テーマ",
|
| 2980 |
"settings.title": "設定",
|
| 2981 |
"store.advanced_title": "非公開の Space をインストール",
|
|
|
|
| 3213 |
"cam.eyes.5": "أتأكد أنني ما زلت أملك عينين…",
|
| 3214 |
"cam.eyes.6": "أعيد عدّ بكسلاتي…",
|
| 3215 |
"cam.eyes.7": "أمسح الضباب…",
|
| 3216 |
+
"cam.eyes.8": "خمس ميلي ثانية إضافية…",
|
| 3217 |
"cam.eyes.9": "ثم كان النور…",
|
| 3218 |
"cam.eyes.10": "أتلعثم قليلًا عند الاستيقاظ…",
|
| 3219 |
"cam.eyes.11": "قهوة أولًا؟",
|
|
|
|
| 3227 |
"skin.name.default": "أصلي",
|
| 3228 |
"skin.name.flash": "فلاش",
|
| 3229 |
"skin.name.spider": "العنكبوت",
|
| 3230 |
+
"skin.name.bat": "الخفاش",
|
| 3231 |
+
"skin.name.steel": "سوبر",
|
| 3232 |
+
"skin.name.iron": "آيرون",
|
| 3233 |
+
"skin.name.volt": "فولت",
|
| 3234 |
+
"skin.name.tuxedo": "بدلة سهرة",
|
| 3235 |
+
"skin.name.bee": "نحلة",
|
| 3236 |
"skin.name.graffiti": "غرافيتي",
|
| 3237 |
"skin.name.rainbow": "قوس قزح",
|
| 3238 |
+
"skin.name.urban": "تمويه حضري",
|
| 3239 |
+
"skin.name.america": "أمريكا",
|
| 3240 |
"skin.name.breton": "بريتاني",
|
| 3241 |
+
"skin.name.pixel": "بكسل",
|
| 3242 |
+
"skin.name.plumber": "سبّاك",
|
| 3243 |
+
"skin.name.ghost": "شبح",
|
| 3244 |
+
"skin.name.capsule": "كبسولة",
|
| 3245 |
+
"skin.name.ronin": "رونين",
|
| 3246 |
+
"skin.name.wizard": "ساحر",
|
| 3247 |
+
"skin.name.mamba": "مامبا",
|
| 3248 |
+
"skin.name.pirate": "قرصان",
|
| 3249 |
+
"skin.name.tiger": "نمر",
|
| 3250 |
+
"skin.name.clown": "سمكة المهرج",
|
| 3251 |
+
"skin.name.panda": "باندا",
|
| 3252 |
+
"skin.name.ladybug": "دعسوقة",
|
| 3253 |
+
"skin.name.japan": "اليابان",
|
| 3254 |
+
"skin.name.germany": "ألمانيا",
|
| 3255 |
+
"skin.name.ukraine": "أوكرانيا",
|
| 3256 |
+
"skin.name.mondrian": "موندريان",
|
| 3257 |
+
"skin.name.wave": "الموجة",
|
| 3258 |
+
"skin.name.sakura": "ساكورا",
|
| 3259 |
+
"skin.name.aurora": "الشفق القطبي",
|
| 3260 |
+
"skin.name.wood": "خشب",
|
| 3261 |
+
"skin.name.carbon": "كربون",
|
| 3262 |
+
"skin.name.circuit": "دائرة",
|
| 3263 |
"skin.all": "الكل",
|
| 3264 |
"skin.search": "بحث…",
|
| 3265 |
"camera.replay": "إعادة",
|
|
|
|
| 3269 |
"camera.mode_camera": "الكاميرا",
|
| 3270 |
"tab.main": "Reachy",
|
| 3271 |
"tab.store": "المتجر",
|
| 3272 |
+
"appstage.dashboard": "لوحة التحكم",
|
| 3273 |
+
"appstage.app": "التطبيق",
|
| 3274 |
+
"appstage.reload": "إعادة تحميل الصفحة",
|
| 3275 |
+
"appstage.restart": "إعادة تشغيل التطبيق",
|
| 3276 |
+
"appstage.debug": "سجلات التطبيق",
|
| 3277 |
+
"appstage.fullscreen": "فتح في علامة تبويب جديدة",
|
| 3278 |
+
"appstage.close": "إغلاق (إيقاف التطبيق)",
|
| 3279 |
+
"appstage.fps_hint": "حلقة تحكم الروبوت (هرتز)",
|
| 3280 |
"tab.settings": "الإعدادات",
|
| 3281 |
"camera.mode_3d": "محاكاة",
|
| 3282 |
+
"twin.hint_title": "حرّك الروبوت",
|
| 3283 |
+
"twin.hint_body": "أمسك الرأس أو الجسم واسحب لتوجيهه",
|
| 3284 |
+
"twin.replay": "كيفية تحريك الروبوت",
|
| 3285 |
+
"camera.flag_live": "مباشر",
|
| 3286 |
"camera.mode_mjpeg": "الكاميرا (MJPEG)",
|
| 3287 |
"camera.mode_webrtc": "الكاميرا + الصوت (WebRTC)",
|
| 3288 |
"camera.listen": "الاستماع إلى ميكروفونات الروبوت",
|
|
|
|
| 3305 |
"control.title": "وحدة التحكم",
|
| 3306 |
"control.home": "الوقوف من جديد (الوضع الأولي)",
|
| 3307 |
"control.rest": "العودة إلى وضع الراحة",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3308 |
"control.yaw": "الانعراج",
|
| 3309 |
"control.pitch": "الميل",
|
| 3310 |
"control.roll": "الدوران",
|
|
|
|
| 3314 |
"control.body_yaw": "دوران",
|
| 3315 |
"control.antenna_left": "يسار",
|
| 3316 |
"control.antenna_right": "يمين",
|
| 3317 |
+
"control.intensity": "السطوع",
|
| 3318 |
+
"control.precision": "ضبط دقيق",
|
| 3319 |
+
"control.sequence": "تسلسل",
|
| 3320 |
+
"control.seq_add": "التقاط الوضعية",
|
| 3321 |
+
"control.seq_prev": "الوضعية السابقة",
|
| 3322 |
+
"control.seq_next": "الوضعية التالية",
|
| 3323 |
+
"control.seq_play": "تشغيل",
|
| 3324 |
+
"control.seq_import": "استيراد (.json)",
|
| 3325 |
+
"control.seq_export": "تصدير (.json)",
|
| 3326 |
+
"control.seq_clear": "مسح",
|
| 3327 |
+
"control.seq_empty": "لا وضعيات بعد. حرّك الروبوت ثم +.",
|
| 3328 |
+
"control.seq_bad": "ملف تسلسل غير صالح",
|
| 3329 |
+
"control.eyes_off": "إطفاء",
|
| 3330 |
+
"control.eyes_custom": "لون مخصص",
|
| 3331 |
+
"eyes.effects": "العيون",
|
| 3332 |
+
"eyes.sc_wink": "غمزة",
|
| 3333 |
+
"eyes.sc_breathe": "تنفّس",
|
| 3334 |
+
"eyes.sc_smooth": "تدرّج",
|
| 3335 |
+
"eyes.sc_disco": "ديسكو",
|
| 3336 |
+
"eyes.sc_chaos": "فوضى",
|
| 3337 |
+
"eyes.sc_weewoo": "وي-وو",
|
| 3338 |
+
"eyes.sc_pinpon": "بين-بون",
|
| 3339 |
+
"eyes.sc_cylon": "سايلون",
|
| 3340 |
+
"eyes.sc_anger": "غضب",
|
| 3341 |
+
"eyes.sc_amazement": "ذهول",
|
| 3342 |
+
"eyes.sc_heart": "نبض",
|
| 3343 |
+
"eyes.sc_hypno": "تنويم",
|
| 3344 |
+
"eyes.sc_sunset": "غروب",
|
| 3345 |
"control.move_running": "يتم تشغيل حركة مسجّلة؛ يتجاهل الروبوت التحكم اليدوي حتى تنتهي.",
|
| 3346 |
"common.connect": "اتصال",
|
| 3347 |
"common.connected": "متصل",
|
|
|
|
| 3348 |
"common.error_prefix": "خطأ: {message}",
|
| 3349 |
"common.failed": "فشل",
|
| 3350 |
"common.off": "إيقاف",
|
|
|
|
| 3407 |
"logs.title": "سجلات البرنامج الخفي",
|
| 3408 |
"logs.warn_plus": "Warn+",
|
| 3409 |
"moves.daemon_unreachable": "تعذّر الوصول إلى البرنامج الخفي؛ لا توجد حركات",
|
|
|
|
| 3410 |
"moves.dances": "رقصات",
|
| 3411 |
"moves.emotions": "مشاعر",
|
| 3412 |
"moves.loading": "جارٍ تحميل الحركات…",
|
|
|
|
| 3420 |
"eq.treble": "الحادة",
|
| 3421 |
"eq.general": "عام",
|
| 3422 |
"eq.tone": "النغمة",
|
| 3423 |
+
"eq.bands": "الموازن",
|
| 3424 |
+
"eq.flat": "مسطّح",
|
| 3425 |
"eq.unavailable": "EasyEffects غير مثبّت على الروبوت.",
|
| 3426 |
"settings.appearance": "المظهر",
|
| 3427 |
"settings.language": "اللغة",
|
|
|
|
| 3429 |
"settings.light_theme": "السمة الفاتحة",
|
| 3430 |
"settings.palette": "لوحة الألوان",
|
| 3431 |
"settings.random_skin": "مظهر عشوائي",
|
| 3432 |
+
"settings.debug": "وحدة تصحيح الأخطاء",
|
|
|
|
|
|
|
| 3433 |
"settings.theme": "السمة",
|
| 3434 |
"settings.title": "الإعدادات",
|
| 3435 |
"store.advanced_title": "تثبيت Space خاص",
|
reachy_web/static/js/logo_anim.js
CHANGED
|
@@ -20,8 +20,15 @@ const logoAnim = {
|
|
| 20 |
{ cls: 'v-sleepy', ms: 2400, w: 2 },
|
| 21 |
{ cls: 'v-squint', ms: 2000, w: 2 },
|
| 22 |
{ cls: 'v-excited', ms: 1800, w: 1 },
|
| 23 |
-
{ cls: 'v-boot', ms:
|
| 24 |
{ cls: 'v-glasses', ms: 2800, w: 1 },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
],
|
| 26 |
PAUSE_MIN: 4000,
|
| 27 |
PAUSE_MAX: 11000,
|
|
|
|
| 20 |
{ cls: 'v-sleepy', ms: 2400, w: 2 },
|
| 21 |
{ cls: 'v-squint', ms: 2000, w: 2 },
|
| 22 |
{ cls: 'v-excited', ms: 1800, w: 1 },
|
| 23 |
+
{ cls: 'v-boot', ms: 2200, w: 1 },
|
| 24 |
{ cls: 'v-glasses', ms: 2800, w: 1 },
|
| 25 |
+
{ cls: 'v-pendule', ms: 2600, w: 1 },
|
| 26 |
+
{ cls: 'v-twist', ms: 1800, w: 1 },
|
| 27 |
+
{ cls: 'v-shimmy', ms: 1300, w: 1 },
|
| 28 |
+
{ cls: 'v-pirouette', ms: 1600, w: 1 },
|
| 29 |
+
{ cls: 'v-boing', ms: 1700, w: 1 },
|
| 30 |
+
{ cls: 'v-coucoutete', ms: 2600, w: 1 },
|
| 31 |
+
{ cls: 'v-curieux', ms: 2400, w: 2 },
|
| 32 |
],
|
| 33 |
PAUSE_MIN: 4000,
|
| 34 |
PAUSE_MAX: 11000,
|
reachy_web/static/js/logs.js
CHANGED
|
@@ -18,57 +18,22 @@ const daemonLogs = {
|
|
| 18 |
root: null,
|
| 19 |
inline: false,
|
| 20 |
|
| 21 |
-
// ---
|
| 22 |
-
//
|
| 23 |
-
//
|
| 24 |
-
//
|
| 25 |
-
//
|
| 26 |
debugOn: false,
|
| 27 |
-
localBuffer: [],
|
| 28 |
-
_hooked: false,
|
| 29 |
|
| 30 |
setDebug: (on) => {
|
| 31 |
daemonLogs.debugOn = !!on;
|
| 32 |
try { localStorage.setItem('debug', on ? '1' : '0'); } catch (e) { /* private mode */ }
|
| 33 |
-
if (on)
|
| 34 |
},
|
| 35 |
|
| 36 |
-
//
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
const raw = `${word} ${msg}`;
|
| 40 |
-
if (document.getElementById('lp-body')) daemonLogs.appendLog(raw);
|
| 41 |
-
else {
|
| 42 |
-
daemonLogs.localBuffer.push(raw);
|
| 43 |
-
if (daemonLogs.localBuffer.length > daemonLogs.MAX_LINES) daemonLogs.localBuffer.shift();
|
| 44 |
-
}
|
| 45 |
-
},
|
| 46 |
-
|
| 47 |
-
flushLocal: () => {
|
| 48 |
-
const q = daemonLogs.localBuffer;
|
| 49 |
-
daemonLogs.localBuffer = [];
|
| 50 |
-
q.forEach(daemonLogs.appendLog);
|
| 51 |
-
},
|
| 52 |
-
|
| 53 |
-
// mirror console.* into the panel while debug is on (the real console still
|
| 54 |
-
// gets everything; we only forward when the flag is set)
|
| 55 |
-
hookConsole: () => {
|
| 56 |
-
if (daemonLogs._hooked) return;
|
| 57 |
-
daemonLogs._hooked = true;
|
| 58 |
-
const lvl = { log: 1, info: 1, debug: 0, warn: 2, error: 3 };
|
| 59 |
-
Object.keys(lvl).forEach((k) => {
|
| 60 |
-
const orig = console[k] ? console[k].bind(console) : () => {};
|
| 61 |
-
console[k] = (...args) => {
|
| 62 |
-
orig(...args);
|
| 63 |
-
if (!daemonLogs.debugOn) return;
|
| 64 |
-
const msg = args.map((a) => {
|
| 65 |
-
if (typeof a === 'string') return a;
|
| 66 |
-
try { return JSON.stringify(a); } catch (e) { return String(a); }
|
| 67 |
-
}).join(' ');
|
| 68 |
-
daemonLogs.local(lvl[k], '🌐 ' + msg);
|
| 69 |
-
};
|
| 70 |
-
});
|
| 71 |
-
},
|
| 72 |
|
| 73 |
build: () => {
|
| 74 |
if (daemonLogs.root) return;
|
|
@@ -134,11 +99,12 @@ const daemonLogs = {
|
|
| 134 |
if (e.key === 'Escape' && daemonLogs.isOpen && !daemonLogs.inline) daemonLogs.closePanel();
|
| 135 |
});
|
| 136 |
|
| 137 |
-
// a click anywhere in the page closes the panel — but not a click inside
|
| 138 |
-
// nor
|
|
|
|
| 139 |
document.addEventListener('pointerdown', e => {
|
| 140 |
if (!daemonLogs.isOpen || daemonLogs.inline) return;
|
| 141 |
-
if (el.contains(e.target) || e.target.closest(
|
| 142 |
daemonLogs.closePanel();
|
| 143 |
});
|
| 144 |
},
|
|
@@ -149,8 +115,7 @@ const daemonLogs = {
|
|
| 149 |
},
|
| 150 |
|
| 151 |
openPanel: () => {
|
| 152 |
-
// one drawer at a time (
|
| 153 |
-
if (typeof controller !== 'undefined' && controller.isOpen) controller.close();
|
| 154 |
daemonLogs.build();
|
| 155 |
// Append BEFORE flushing the buffer: appendLog resolves #lp-body via
|
| 156 |
// document.getElementById, so it must already be in the DOM. With debug
|
|
@@ -158,7 +123,6 @@ const daemonLogs = {
|
|
| 158 |
// threw here — aborting the open before isOpen=true, so the first click
|
| 159 |
// did nothing (and cleared the buffer) and only the second one worked.
|
| 160 |
if (!daemonLogs.root.isConnected) document.body.appendChild(daemonLogs.root);
|
| 161 |
-
daemonLogs.flushLocal();
|
| 162 |
requestAnimationFrame(() => daemonLogs.root.classList.add('open'));
|
| 163 |
document.body.classList.add('lp-open');
|
| 164 |
daemonLogs.isOpen = true;
|
|
@@ -175,7 +139,6 @@ const daemonLogs = {
|
|
| 175 |
// — inline mode (/logs page)
|
| 176 |
mountInline: (container) => {
|
| 177 |
daemonLogs.build();
|
| 178 |
-
daemonLogs.flushLocal();
|
| 179 |
daemonLogs.inline = true;
|
| 180 |
daemonLogs.root.classList.add('lp-inline', 'open');
|
| 181 |
container.appendChild(daemonLogs.root);
|
|
@@ -251,7 +214,19 @@ const daemonLogs = {
|
|
| 251 |
return body.scrollHeight - body.scrollTop <= body.clientHeight + 50;
|
| 252 |
},
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
appendLog: (raw) => {
|
|
|
|
| 255 |
if (daemonLogs.paused) {
|
| 256 |
daemonLogs.pausedQueue.push(raw);
|
| 257 |
if (daemonLogs.pausedQueue.length > daemonLogs.MAX_LINES) daemonLogs.pausedQueue.shift();
|
|
@@ -348,11 +323,13 @@ const daemonLogs = {
|
|
| 348 |
}
|
| 349 |
};
|
| 350 |
|
| 351 |
-
//
|
| 352 |
-
// turn it on now if it was left on last session.
|
| 353 |
window.addEventListener('DOMContentLoaded', () => {
|
| 354 |
-
daemonLogs.hookConsole();
|
| 355 |
let on = false;
|
| 356 |
try { on = localStorage.getItem('debug') === '1'; } catch (e) { /* private mode */ }
|
| 357 |
if (on) daemonLogs.setDebug(true);
|
| 358 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
root: null,
|
| 19 |
inline: false,
|
| 20 |
|
| 21 |
+
// --- debug console --------------------------------------------------------
|
| 22 |
+
// A toggle (Settings → Appearance) that turns the page's verbose debug output
|
| 23 |
+
// ON in the BROWSER console — nothing is copied into the logs panel: that
|
| 24 |
+
// panel is the SERVER's logs, faithfully, and stays that way. Our own debug
|
| 25 |
+
// lines go through dbg() and print to the console only while this is on.
|
| 26 |
debugOn: false,
|
|
|
|
|
|
|
| 27 |
|
| 28 |
setDebug: (on) => {
|
| 29 |
daemonLogs.debugOn = !!on;
|
| 30 |
try { localStorage.setItem('debug', on ? '1' : '0'); } catch (e) { /* private mode */ }
|
| 31 |
+
if (on) console.log('🌐 debug console on');
|
| 32 |
},
|
| 33 |
|
| 34 |
+
// verbose page-side logging, gated on the toggle. Same call shape as
|
| 35 |
+
// console.log; silent unless the Debug console switch is on.
|
| 36 |
+
dbg: (...args) => { if (daemonLogs.debugOn) console.log(...args); },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
build: () => {
|
| 39 |
if (daemonLogs.root) return;
|
|
|
|
| 99 |
if (e.key === 'Escape' && daemonLogs.isOpen && !daemonLogs.inline) daemonLogs.closePanel();
|
| 100 |
});
|
| 101 |
|
| 102 |
+
// a click anywhere in the page closes the panel — but not a click inside
|
| 103 |
+
// it, nor on ANY drawer opener (which switches drawers; closing here on
|
| 104 |
+
// pointerdown would shift the page and eat that opener's click)
|
| 105 |
document.addEventListener('pointerdown', e => {
|
| 106 |
if (!daemonLogs.isOpen || daemonLogs.inline) return;
|
| 107 |
+
if (el.contains(e.target) || e.target.closest(window.DRAWER_OPENERS)) return;
|
| 108 |
daemonLogs.closePanel();
|
| 109 |
});
|
| 110 |
},
|
|
|
|
| 115 |
},
|
| 116 |
|
| 117 |
openPanel: () => {
|
| 118 |
+
closeOtherDrawers('logs'); // one drawer at a time (shared right edge)
|
|
|
|
| 119 |
daemonLogs.build();
|
| 120 |
// Append BEFORE flushing the buffer: appendLog resolves #lp-body via
|
| 121 |
// document.getElementById, so it must already be in the DOM. With debug
|
|
|
|
| 123 |
// threw here — aborting the open before isOpen=true, so the first click
|
| 124 |
// did nothing (and cleared the buffer) and only the second one worked.
|
| 125 |
if (!daemonLogs.root.isConnected) document.body.appendChild(daemonLogs.root);
|
|
|
|
| 126 |
requestAnimationFrame(() => daemonLogs.root.classList.add('open'));
|
| 127 |
document.body.classList.add('lp-open');
|
| 128 |
daemonLogs.isOpen = true;
|
|
|
|
| 139 |
// — inline mode (/logs page)
|
| 140 |
mountInline: (container) => {
|
| 141 |
daemonLogs.build();
|
|
|
|
| 142 |
daemonLogs.inline = true;
|
| 143 |
daemonLogs.root.classList.add('lp-inline', 'open');
|
| 144 |
container.appendChild(daemonLogs.root);
|
|
|
|
| 214 |
return body.scrollHeight - body.scrollTop <= body.clientHeight + 50;
|
| 215 |
},
|
| 216 |
|
| 217 |
+
// Known-noise lines from the DAEMON's side that say nothing anyone can act
|
| 218 |
+
// on. We cannot quiet them at the source (it is Pollen's process, not ours),
|
| 219 |
+
// so they are dropped here, before the panel. Keep every pattern narrow: a
|
| 220 |
+
// line must be provably routine to earn its place.
|
| 221 |
+
NOISE: [
|
| 222 |
+
// onnxruntime probing for a GPU at model load: the Pi's VideoCore is a
|
| 223 |
+
// platform device with no PCI vendor file, so discovery "fails" and it
|
| 224 |
+
// falls back to CPU — the only option on a Pi anyway. Two lines per load.
|
| 225 |
+
/onnxruntime.*device_discovery|GetGpuDevices|Failed to detect devices under "\/sys\/class\/drm/,
|
| 226 |
+
],
|
| 227 |
+
|
| 228 |
appendLog: (raw) => {
|
| 229 |
+
if (daemonLogs.NOISE.some((re) => re.test(raw))) return;
|
| 230 |
if (daemonLogs.paused) {
|
| 231 |
daemonLogs.pausedQueue.push(raw);
|
| 232 |
if (daemonLogs.pausedQueue.length > daemonLogs.MAX_LINES) daemonLogs.pausedQueue.shift();
|
|
|
|
| 323 |
}
|
| 324 |
};
|
| 325 |
|
| 326 |
+
// Restore the Debug console toggle from last session.
|
|
|
|
| 327 |
window.addEventListener('DOMContentLoaded', () => {
|
|
|
|
| 328 |
let on = false;
|
| 329 |
try { on = localStorage.getItem('debug') === '1'; } catch (e) { /* private mode */ }
|
| 330 |
if (on) daemonLogs.setDebug(true);
|
| 331 |
});
|
| 332 |
+
|
| 333 |
+
// dbg(): global page-side verbose logging, printed to the browser console only
|
| 334 |
+
// while the Debug console toggle is on. Safe before daemonLogs exists.
|
| 335 |
+
window.dbg = (...args) => { if (typeof daemonLogs !== 'undefined' && daemonLogs.debugOn) console.log(...args); };
|
reachy_web/static/js/move_player.js
CHANGED
|
@@ -25,12 +25,8 @@ const movePlayer = {
|
|
| 25 |
return label === key ? name.replace(/[_-]/g, ' ') : label;
|
| 26 |
},
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
btn.classList.add('on');
|
| 31 |
-
movePlayer.dataset = btn.dataset.dataset;
|
| 32 |
-
movePlayer.load();
|
| 33 |
-
},
|
| 34 |
|
| 35 |
load: async () => {
|
| 36 |
const grid = document.getElementById('move-grid');
|
|
|
|
| 25 |
return label === key ? name.replace(/[_-]/g, ' ') : label;
|
| 26 |
},
|
| 27 |
|
| 28 |
+
// dataset switching lives in the catalogue tabs now (tabs.js): it sets
|
| 29 |
+
// movePlayer.dataset and calls load()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
load: async () => {
|
| 32 |
const grid = document.getElementById('move-grid');
|