Domotick commited on
Commit
256d8bf
·
1 Parent(s): db44f58

- 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
Files changed (50) hide show
  1. .gitignore +3 -0
  2. README.md +7 -2
  3. VERSION +1 -1
  4. changelog.json +78 -0
  5. fonts/Manrope.ttf +0 -3
  6. fonts/SpaceGrotesk.ttf +0 -3
  7. index.html +188 -12
  8. pyproject.toml +8 -1
  9. reachy_web/audio_eq.py +60 -0
  10. reachy_web/eyes.py +365 -0
  11. reachy_web/log_i18n.py +32 -9
  12. reachy_web/main.py +138 -18
  13. reachy_web/static/assets/icons/antenna.svg +1 -0
  14. reachy_web/static/assets/icons/axis-x-arrow.svg +1 -0
  15. reachy_web/static/assets/icons/axis-x-rotate-clockwise.svg +1 -0
  16. reachy_web/static/assets/icons/axis-y-arrow.svg +1 -0
  17. reachy_web/static/assets/icons/axis-y-rotate-clockwise.svg +1 -0
  18. reachy_web/static/assets/icons/axis-z-arrow.svg +1 -0
  19. reachy_web/static/assets/icons/axis-z-rotate-clockwise.svg +1 -0
  20. reachy_web/static/assets/icons/brightness-6.svg +1 -0
  21. reachy_web/static/assets/icons/chevron-left.svg +1 -0
  22. reachy_web/static/assets/icons/eye-outline.svg +1 -0
  23. reachy_web/static/assets/icons/gesture-swipe-horizontal.svg +1 -0
  24. reachy_web/static/assets/icons/hand-back-right.svg +1 -0
  25. reachy_web/static/assets/icons/rotate-3d-variant.svg +1 -0
  26. reachy_web/static/assets/icons/tray-arrow-up.svg +1 -0
  27. reachy_web/static/assets/logo-192.png +0 -3
  28. reachy_web/static/assets/logo-2048.png +0 -3
  29. reachy_web/static/assets/logo-512.png +0 -3
  30. reachy_web/static/assets/logo-64.png +0 -3
  31. reachy_web/static/assets/logo.png +0 -3
  32. reachy_web/static/assets/og.png +2 -2
  33. reachy_web/static/css/app.css +8 -6
  34. reachy_web/static/css/audio_eq.css +55 -0
  35. reachy_web/static/css/icons.css +14 -0
  36. reachy_web/static/css/logo.css +81 -13
  37. reachy_web/static/css/logs.css +471 -72
  38. reachy_web/static/js/app_stage.js +133 -0
  39. reachy_web/static/js/apps.js +28 -0
  40. reachy_web/static/js/audio_eq.js +52 -44
  41. reachy_web/static/js/camera.js +207 -53
  42. reachy_web/static/js/controller.js +682 -150
  43. reachy_web/static/js/daemon.js +29 -0
  44. reachy_web/static/js/eyes_fx.js +340 -0
  45. reachy_web/static/js/idle.js +7 -0
  46. reachy_web/static/js/landing-i18n.js +161 -30
  47. reachy_web/static/js/locales.js +589 -173
  48. reachy_web/static/js/logo_anim.js +8 -1
  49. reachy_web/static/js/logs.js +31 -54
  50. 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
- ./scripts/run.sh
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 ./scripts/run.sh
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.7.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=2">
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=2">
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=2",
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
- <img src="reachy_web/static/assets/logo.png" alt="Reachy Web logo" class="h-9 w-9 rounded-xl">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  <span>Reachy Web</span>
52
- <span class="version-pill" id="version-pill">v0.7.0</span>
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">Two ways to run it</h2>
118
- <p class="mx-auto max-w-[680px] text-mut">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.</p>
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
- <a class="justify-self-center rounded-xl border border-line bg-soft p-2.5 text-txt"
 
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
- ./scripts/run.sh</code></div>
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 ./scripts/run.sh
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.7.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 %s://%s:%d (Press CTRL+C to quit)": {
228
- "fr": "à l'écoute sur %s://%s:%d (Ctrl+C pour quitter)",
229
- "es": "escuchando en %s://%s:%d (Ctrl+C para salir)",
230
- "de": "lauscht auf %s://%s:%d (Strg+C zum Beenden)",
231
- "it": "in ascolto su %s://%s:%d (Ctrl+C per uscire)",
232
- "pt": "à escuta em %s://%s:%d (Ctrl+C para sair)",
233
- "ja": "%s://%s:%d で待機中 (Ctrl+C で終了)",
234
- "ar": "يستمع على %s://%s:%d (اضغط Ctrl+C للخروج)",
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (scripts/run.sh) --------------------------------------------
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
- await self.gzip(scope, receive, send)
 
 
 
 
 
 
 
 
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
- import socket
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 being installed on the robot: the daemon target
892
- # is loopback there (a laptop points at the robot over mDNS). Not on the
893
- # robot ⇒ no EQ, full stop — the icon never appears.
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 (scripts/run.sh detects this)
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

  • SHA256: 02bce6ec4932a5f331c947a02919a27287102c4ba048a501678d6de66f872cc2
  • Pointer size: 129 Bytes
  • Size of remote file: 7 kB
reachy_web/static/assets/logo-2048.png DELETED

Git LFS Details

  • SHA256: 09e30e132fcf00403598000f804790e28f7016ba1a544a2fa7ab6d58b13f3c07
  • Pointer size: 130 Bytes
  • Size of remote file: 96.1 kB
reachy_web/static/assets/logo-512.png DELETED

Git LFS Details

  • SHA256: 08883bffe056c46b12c598b78f6e4f6b5596d30e57b38856572e76ffd54c977d
  • Pointer size: 130 Bytes
  • Size of remote file: 19.7 kB
reachy_web/static/assets/logo-64.png DELETED

Git LFS Details

  • SHA256: 87d868eec58109c6e54de791bff80c30441f7d6107291ecfad11f314f45f56d3
  • Pointer size: 129 Bytes
  • Size of remote file: 2.41 kB
reachy_web/static/assets/logo.png DELETED

Git LFS Details

  • SHA256: 6164ef41ee67a19fccbe1eafb1f2822053509142bf356977da5163e0008bfa8d
  • Pointer size: 130 Bytes
  • Size of remote file: 41.9 kB
reachy_web/static/assets/og.png CHANGED

Git LFS Details

  • SHA256: 08883bffe056c46b12c598b78f6e4f6b5596d30e57b38856572e76ffd54c977d
  • Pointer size: 130 Bytes
  • Size of remote file: 19.7 kB

Git LFS Details

  • SHA256: 6074e4c8440c5907d2be9af36c0a949867fb7a26d55457346ff5d91945e43605
  • Pointer size: 130 Bytes
  • Size of remote file: 20 kB
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 — the eyes flicker on, CRT style (1.6s) ================= */
111
- .v-boot .lg-eye-l { animation: lgBootL 1.6s steps(1) 1 both; }
112
- .v-boot .lg-eye-r { animation: lgBootR 1.6s steps(1) 1 both; }
113
- .v-boot .lg-ant-l { animation: lgBootAnt 1.6s ease-out 1 both; }
114
- @keyframes lgBootL {
115
- 0%, 10% { opacity: 0 }
116
- 14% { opacity: 1 } 18% { opacity: 0 } 24% { opacity: 1 } 28% { opacity: 0 }
117
- 34%, 100% { opacity: 1 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  }
119
- @keyframes lgBootR {
120
- 0%, 22% { opacity: 0 }
121
- 26% { opacity: 1 } 30% { opacity: 0 } 38% { opacity: 1 } 42% { opacity: 0 }
122
- 48%, 100% { opacity: 1 }
 
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: 4.5rem 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); }
 
 
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
- /* the ring left where you clicked to aim the head */
433
- .look-ping {
434
- position: absolute;
435
- width: 26px;
436
- height: 26px;
437
- margin: -13px 0 0 -13px;
438
- border: 2px solid var(--acc);
439
- border-radius: 50%;
440
- pointer-events: none;
441
- animation: look-ping 0.7s ease-out forwards;
442
- z-index: 12;
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
- /* the two joysticks: a square face, a knob you drag. No spring-back — the pose
451
- stands until you move it again or press rest, exactly like the desktop app. */
452
- .cp-pad { margin: 4px 0 12px; }
453
-
454
- .cp-pad-face {
455
  position: relative;
456
- width: 100%;
457
- aspect-ratio: 1;
458
- max-width: 160px;
459
- margin: 0 auto;
460
  border: 1px solid var(--line);
461
  border-radius: 14px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  background: var(--soft);
463
- cursor: grab;
464
- touch-action: none;
 
465
  }
 
 
466
 
467
- .cp-pad-face:active { cursor: grabbing; }
468
-
469
- /* the faint Reachy behind the knob — context for what the pad moves */
470
- .cp-diagram {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  position: absolute;
472
- inset: 12%;
473
- width: 76%;
474
- height: 76%;
475
- pointer-events: none;
476
- opacity: 0.5;
 
 
 
 
477
  }
 
 
 
 
 
478
 
479
- .cp-diagram [class^="cp-dia-"] {
480
- fill: none;
481
- stroke: var(--mut);
482
- stroke-width: 3;
483
- stroke-linejoin: round;
484
- stroke-linecap: round;
 
 
 
 
485
  }
486
 
487
- .cp-dia-eye { fill: var(--mut); stroke: none; }
488
- .cp-dia-ant { stroke-width: 2.6; }
 
 
 
 
 
 
 
 
489
 
490
- /* the face group rocks with the knob (set inline in bindPad); ease it so a
491
- drag reads as motion, not teleporting */
492
- .cp-dia-bot {
493
- transform-box: fill-box;
494
- transform-origin: center;
495
- transition: transform 0.08s linear;
496
  }
497
 
498
- .cp-pad-axis-x,
499
- .cp-pad-axis-y {
500
- position: absolute;
501
- background: var(--line);
502
- pointer-events: none;
 
 
 
 
 
 
 
 
 
503
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
 
505
- .cp-pad-axis-x { left: 8%; right: 8%; top: 50%; height: 1px; }
506
- .cp-pad-axis-y { top: 8%; bottom: 8%; left: 50%; width: 1px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
 
508
- .cp-pad-knob {
 
509
  position: absolute;
510
- left: 50%;
511
- top: 50%;
512
- width: 22px;
513
- height: 22px;
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
- .cp-pad-legend {
522
- display: flex;
523
- justify-content: space-between;
524
- max-width: 160px;
525
- margin: 6px auto 0;
 
 
 
 
 
 
 
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 (the box that serves the dashboard). No EasyEffects there → no icon.
 
 
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
- // The EQ acts on the box that SERVES the page. Run from a laptop (run.sh, a
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-tone').classList.toggle('hidden', !detected);
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
- function label(id, v) {
77
- el(id).textContent = `${v > 0 ? '+' : ''}${v} dB`;
 
 
 
 
 
 
 
 
 
 
 
78
  }
79
 
80
- function readTone() {
81
- const bass = +el('eqBass').value;
82
- const mid = +el('eqMid').value;
83
- const treble = +el('eqTreble').value;
84
- label('eqBassVal', bass);
85
- label('eqMidVal', mid);
86
- label('eqTrebleVal', treble);
87
- return { bass, mid, treble };
88
- }
89
 
90
- let toneTimer = null;
 
 
 
 
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
- ['eqBass', 'eqMid', 'eqTreble'].forEach((id) => {
108
- el(id).addEventListener('input', () => {
109
- readTone(); // live labels, every frame
110
- clearTimeout(toneTimer); // reload EasyEffects once the drag settles
111
- toneTimer = setTimeout(() => post('/audio/eq/tone', readTone()).catch(() => {}), 250);
 
 
 
 
 
 
 
 
 
112
  });
 
113
  });
114
  }
115
 
116
  function open() {
117
- // one drawer at a time — they share the right edge
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, not on the
151
- // opener that would immediately reopen it
 
152
  document.addEventListener('pointerdown', (e) => {
153
  if (!eqPanel.isOpen) return;
154
  const p = panel();
155
- if (p.contains(e.target) || e.target.closest('[data-eq-toggle], [data-eq-nav]')) return;
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
- const skinbar = document.getElementById('cam-skinbar');
176
- if (skinbar) {
177
- skinbar.classList.toggle('hidden', cameraView.mode !== '3d');
178
- skinbar.classList.toggle('flex', cameraView.mode === '3d');
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
- cameraView.startEyes();
 
 
 
 
 
 
 
 
261
 
262
- cameraView.ensureApi();
263
- const producers = cameraView.api.getAvailableProducers();
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
- if (cameraView.session) {
 
 
 
 
282
  if (typeof topbar !== 'undefined') topbar.setStatus('');
283
  return;
284
  }
@@ -295,9 +330,8 @@ const cameraView = {
295
  }
296
 
297
  cameraView.startEyes();
298
- cameraView.ensureApi();
299
- const producers = cameraView.api.getAvailableProducers();
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
- if (cameraView.mode === 'webrtc' && cameraView.openState && !cameraView.session)
326
- cameraView.consume(producer.id);
327
- else if (cameraView.listening && !cameraView.audioSession)
328
- cameraView.listenViaWebrtc();
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) => cameraView.setMsg(t('camera.webrtc_error', { message: ev.message })));
 
 
 
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
- if (session.streams.length > 0) {
359
- e.video.srcObject = session.streams[0];
360
- e.video.muted = !cameraView.listening; // sound off by default
361
- e.video.play().catch(() => {});
362
- cameraView.setMsg('');
363
- // this stream carries the mic's audio track too — hand it to the waveform
364
- // so it analyses it client-side, sharing THE one session (a second
365
- // consumer on the same producer gets no media)
366
- cameraView.currentStream = session.streams[0];
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 while we were watching it: fall back to the twin
458
- if (!has && cameraView.mode !== '3d') cameraView.setMode('3d', false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if (cameraView.audioSession) {
506
- cameraView.audioSession.close();
507
- cameraView.audioSession = null;
508
- }
509
  if (e.audio) { e.audio.pause(); e.audio.srcObject = null; }
510
  cameraView.syncListenIcon();
511
  },
512
 
 
 
 
513
  listenViaWebrtc: () => {
514
- const e = cameraView.els();
515
- cameraView.ensureApi();
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.05, y: 0.05,
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: both are fixed to the right edge, so they would
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
- pad: (id, labels) => `
96
- <div class="cp-pad" data-pad="${id}">
97
- <div class="cp-pad-face">
98
- ${controller.diagram(id)}
99
- <span class="cp-pad-axis-x"></span>
100
- <span class="cp-pad-axis-y"></span>
101
- <span class="cp-pad-knob"></span>
102
- </div>
103
- <div class="cp-pad-legend"><span>${labels[0]}</span><span>${labels[1]}</span></div>
104
- </div>`,
105
-
106
- // A faint Reachy behind each joystick, so you can SEE what the pad moves.
107
- // pos → looking straight DOWN on the robot: dragging slides the head across
108
- // that plane. Up on the pad is the robot's front (the eyes).
109
- // rot → looking at its FACE: dragging turns it (left/right) and tips it
110
- // (up/down). The whole drawing rocks with the knob so the axis is
111
- // unmistakable — it is wired up in bindPad.
112
- // Traced from the loader's face (loader.js), same hand-drawn family as the
113
- // tab and viewport icons.
114
- diagram: (id) => id === 'pos'
115
- ? `<svg class="cp-diagram" viewBox="0 0 120 120" aria-hidden="true">
116
- <!-- top-down: a rectangular head (same width as the front view), the two
117
- antennas to the back (top), the front edge toward the bottom. No eyes
118
- — you do not see them looking straight down. -->
119
- <g class="cp-dia-bot">
120
- <path class="cp-dia-ant" d="M50 40 L40 16"/><circle class="cp-dia-ant" cx="39" cy="14" r="3.2"/>
121
- <path class="cp-dia-ant" d="M70 40 L80 16"/><circle class="cp-dia-ant" cx="81" cy="14" r="3.2"/>
122
- <rect class="cp-dia-head" x="34" y="38" width="52" height="46" rx="15"/>
123
- <path class="cp-dia-front" d="M46 84 Q60 92 74 84"/>
124
- </g>
125
- </svg>`
126
- : `<svg class="cp-diagram" viewBox="0 0 120 120" aria-hidden="true">
127
- <!-- front: the face, the whole thing shifts with the knob to show where
128
- it is looking -->
129
- <g class="cp-dia-bot">
130
- <path class="cp-dia-ant" d="M46 40 L38 18"/><circle class="cp-dia-ant" cx="37" cy="16" r="3.2"/>
131
- <path class="cp-dia-ant" d="M74 40 L82 18"/><circle class="cp-dia-ant" cx="83" cy="16" r="3.2"/>
132
- <rect class="cp-dia-head" x="34" y="38" width="52" height="40" rx="15"/>
133
- <circle class="cp-dia-eye" cx="50" cy="58" r="8"/>
134
- <circle class="cp-dia-eye" cx="72" cy="58" r="7"/>
135
- <rect class="cp-dia-eye" x="58" y="57" width="8" height="2.4" rx="1.2"/>
136
- <path class="cp-dia-body" d="M46 78 h28 q8 0 8 8 v6 q0 4 -4 4 h-36 q-4 0 -4 -4 v-6 q0 -8 8 -8 z"/>
137
- </g>
138
- </svg>`,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <div class="cp-group">
172
- <div class="cp-group-title">${t('control.head_position')}</div>
173
- ${controller.pad('pos', [t('control.y'), t('control.x')])}
174
- ${controller.slider('z', t('control.z'), L.z, 0.001)}
 
 
 
 
 
 
 
 
 
 
 
 
175
  </div>
176
 
177
- <div class="cp-group">
178
- <div class="cp-group-title">${t('control.head_rotation')}</div>
179
- ${controller.pad('rot', [t('control.yaw'), t('control.pitch')])}
180
- ${controller.slider('roll', t('control.roll'), L.roll, 0.01)}
 
 
 
 
181
  </div>
182
 
183
- <div class="cp-group">
184
- <div class="cp-group-title">${t('control.body')}</div>
185
- ${controller.slider('body_yaw', t('control.body_yaw'), L.body_yaw, 0.1)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  </div>
187
 
188
- <div class="cp-group">
189
- <div class="cp-group-title">${t('control.antennas')}</div>
190
- ${controller.slider('ant_l', t('control.antenna_left'), L.antenna, 0.1)}
191
- ${controller.slider('ant_r', t('control.antenna_right'), L.antenna, 0.1)}
 
 
 
 
 
 
 
 
 
 
 
 
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('[data-controller-toggle]')) return;
208
  controller.close();
209
  });
210
  document.addEventListener('keydown', (ev) => {
211
  if (ev.key === 'Escape' && controller.isOpen) controller.close();
212
  });
213
 
214
- el.querySelectorAll('input[type=range]').forEach((input) => {
 
 
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.bindPad(el.querySelector('[data-pad=pos]'), 'y', 'x', L.y, L.x);
228
- // flipY: on the rotation pad, dragging DOWN must make the head look DOWN
229
- controller.bindPad(el.querySelector('[data-pad=rot]'), 'yaw', 'pitch', L.yaw, L.pitch, true);
230
- },
231
-
232
- // A joystick: horizontal axis → keyX, vertical → keyY. Dragging past the
233
- // edge is clamped, and — like the desktop — releasing does NOT spring back;
234
- // the pose stands until you move it or press rest.
235
- bindPad: (pad, keyX, keyY, limX, limY, flipY) => {
236
- const face = pad.querySelector('.cp-pad-face');
237
- const knob = pad.querySelector('.cp-pad-knob');
238
- let dragging = false;
239
-
240
- // The face pad SHOWS the rotation: the drawing slides toward where the head
241
- // is looking, so you watch what you are doing. The position pad is a top-down
242
- // map instead — there the knob itself is the head's place on the plane, so its
243
- // drawing stays put as a reference.
244
- const bot = pad.dataset.pad === 'rot' ? pad.querySelector('.cp-dia-bot') : null;
245
-
246
- const place = (ev) => {
247
- const r = face.getBoundingClientRect();
248
- const nx = Math.max(-1, Math.min(1, ((ev.clientX - r.left) / r.width) * 2 - 1));
249
- const ny = Math.max(-1, Math.min(1, ((ev.clientY - r.top) / r.height) * 2 - 1));
250
- controller.target[keyX] = nx * limX;
251
- // screen Y grows downward. On the position pad, up = forward, so we negate.
252
- // On the rotation pad, down = look down, so we DON'T (flipY).
253
- controller.target[keyY] = (flipY ? ny : -ny) * limY;
254
- knob.style.left = `${((nx + 1) / 2) * 100}%`;
255
- knob.style.top = `${((ny + 1) / 2) * 100}%`;
256
- // the face shifts where you look: right-drag → looks right, down → looks down
257
- if (bot) bot.style.transform = `translate(${nx * 13}px, ${ny * 13}px)`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
- face.addEventListener('pointerdown', (ev) => {
261
- dragging = true;
262
- face.setPointerCapture(ev.pointerId);
263
- place(ev);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  });
265
- face.addEventListener('pointermove', (ev) => dragging && place(ev));
266
- face.addEventListener('pointerup', () => { dragging = false; });
267
- face.addEventListener('pointercancel', () => { dragging = false; });
268
-
269
- pad._reset = () => {
270
- knob.style.left = '50%';
271
- knob.style.top = '50%';
272
- if (bot) bot.style.transform = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  };
274
- pad._reset();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // caught up? then say nothing at all — an idle panel is silent
327
- const settled = Object.keys(tg).every(
328
- (key) => Math.abs(tg[key] - c[key]) < controller.SETTLED
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.root.querySelectorAll('input[type=range]').forEach((input) => {
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 widgets have
515
- // to say the same thing as the robot.
516
  syncWidgets: () => {
517
- controller.root.querySelectorAll('input[type=range]').forEach((input) => {
 
 
 
 
518
  const v = controller.target[input.dataset.key];
519
- if (v === undefined) return;
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.syncWidgets();
566
- if (controller.root) controller.root.querySelectorAll('.cp-pad').forEach((pad) => pad._reset());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- function apply() {
632
- const dict = T[lang];
633
- if (!dict) return; // unknown language → English stays (the fallback)
634
 
635
- const SKIP = { CODE: 1, PRE: 1, SCRIPT: 1, STYLE: 1, NOSCRIPT: 1 };
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
- const nodes = [];
646
- for (let n = walker.nextNode(); n; n = walker.nextNode()) nodes.push(n);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
 
648
- for (const node of nodes) {
649
- const k = node.nodeValue.trim();
650
- if (k && Object.prototype.hasOwnProperty.call(dict, k)) {
651
- // replace the trimmed substring so surrounding whitespace survives
652
- node.nodeValue = node.nodeValue.replace(k, dict[k]);
653
- }
 
 
 
 
 
 
 
 
 
654
  }
 
655
 
656
- document.documentElement.lang = lang;
657
- if (lang === 'ar') document.documentElement.dir = 'rtl';
 
 
658
  }
659
 
660
  if (document.readyState === 'loading') {
661
- document.addEventListener('DOMContentLoaded', apply);
662
  } else {
663
- apply();
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 minutes…",
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.yoda": "Yoda",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.look_hint": "Click anywhere in the camera view and the head will look at that point.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 mode",
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 minutes…",
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.america": "Amérique",
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.yoda": "Yoda",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.look_hint": "Cliquez n'importe où dans la vue caméra : la tête regardera ce point.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": "Mode 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 minutos más…",
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.america": "América",
857
- "skin.name.urban": "Camuflaje urbano",
 
 
 
 
858
  "skin.name.graffiti": "Grafiti",
859
  "skin.name.rainbow": "Arcoíris",
 
 
860
  "skin.name.breton": "Bretón",
861
- "skin.name.bat": "Murciélago",
862
- "skin.name.steel": "Súper",
863
- "skin.name.iron": "Hierro",
864
- "skin.name.rage": "Furia",
865
- "skin.name.ranger": "Astro",
866
- "skin.name.protocol": "Protocolo",
867
- "skin.name.crewmate": "Tripulante",
868
- "skin.name.volt": "Voltio",
869
- "skin.name.yoda": "Yoda",
 
 
 
 
 
 
 
 
 
 
 
 
 
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.look_hint": "Haz clic en cualquier punto de la cámara y la cabeza mirará hacia allí.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": "Modo depuración",
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 Minuten…",
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.yoda": "Yoda",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.look_hint": "Klicke irgendwo ins Kamerabild, dann schaut der Kopf dorthin.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-Modus",
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 minuti…",
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.yoda": "Yoda",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.look_hint": "Clicca ovunque nella vista telecamera: la testa guarderà quel punto.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": "Modalità 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 minutos…",
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": "Ferro",
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.yoda": "Yoda",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.look_hint": "Clique em qualquer ponto da câmara e a cabeça olhará para lá.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": "Modo de depuração",
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.america": "アメリカ",
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.yoda": "ヨーダ",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.look_hint": "カメラ映像のどこかをクリックすると、頭がその点を見ます。",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.america": "أمريكا",
2867
- "skin.name.urban": "تمويه حضري",
 
 
 
 
2868
  "skin.name.graffiti": "غرافيتي",
2869
  "skin.name.rainbow": "قوس قزح",
 
 
2870
  "skin.name.breton": "بريتاني",
2871
- "skin.name.bat": "الخفاش",
2872
- "skin.name.steel": "خارق",
2873
- "skin.name.iron": "حديدي",
2874
- "skin.name.rage": "غضب",
2875
- "skin.name.ranger": "أسترو",
2876
- "skin.name.protocol": "بروتوكول",
2877
- "skin.name.crewmate": "زميل الطاقم",
2878
- "skin.name.volt": "فولت",
2879
- "skin.name.yoda": "يودا",
 
 
 
 
 
 
 
 
 
 
 
 
 
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.look_hint": "انقر في أي مكان من صورة الكاميرا وسينظر الرأس إلى تلك النقطة.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: 1600, w: 1 },
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
- // --- browser debug mode ---------------------------------------------------
22
- // When on, the browser's own console (and any dbg() calls) are mirrored into
23
- // this same panel, same format, so you can read what the PAGE sees — WebRTC
24
- // producers, audio tracks, mic levels — next to the daemon's logs. Buffered
25
- // until the panel is built, then flushed.
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) { daemonLogs.hookConsole(); daemonLogs.local(1, '🌐 browser debug on'); }
34
  },
35
 
36
- // one line INTO the panel, from the browser side. `level`: 0 dbg 1 inf 2 warn 3 err
37
- local: (level, msg) => {
38
- const word = { 0: 'DEBUG', 1: 'INFO', 2: 'WARNING', 3: 'ERROR' }[level] || 'DEBUG';
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 it,
138
- // nor the topbar button that would immediately reopen it
 
139
  document.addEventListener('pointerdown', e => {
140
  if (!daemonLogs.isOpen || daemonLogs.inline) return;
141
- if (el.contains(e.target) || e.target.closest('[data-logs-toggle]')) return;
142
  daemonLogs.closePanel();
143
  });
144
  },
@@ -149,8 +115,7 @@ const daemonLogs = {
149
  },
150
 
151
  openPanel: () => {
152
- // one drawer at a time (see controller.open)
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
- // Debug mode: hook the console once (cheap; forwarding is gated by debugOn), and
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
- setDataset: (btn) => {
29
- document.querySelectorAll('#move-dataset-seg button').forEach((b) => b.classList.remove('on'));
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');