PanGalactic commited on
Commit
05ad16c
·
0 Parent(s):

Initial release — Reachy Mini Hello World DevKit

Browse files

Authored by Panny Malialis in partnership with various LLMs

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +13 -0
  2. .gitignore +24 -0
  3. README.md +137 -0
  4. hello_world/__init__.py +3 -0
  5. hello_world/api/__init__.py +35 -0
  6. hello_world/api/conversation.py +1364 -0
  7. hello_world/api/health.py +80 -0
  8. hello_world/api/listener.py +648 -0
  9. hello_world/api/model.py +119 -0
  10. hello_world/api/music.py +316 -0
  11. hello_world/api/recordings.py +327 -0
  12. hello_world/api/settings_api.py +76 -0
  13. hello_world/api/snapshots.py +196 -0
  14. hello_world/api/sounds.py +278 -0
  15. hello_world/api/system.py +97 -0
  16. hello_world/api/transcript.py +103 -0
  17. hello_world/app.py +94 -0
  18. hello_world/config.py +120 -0
  19. hello_world/main.py +16 -0
  20. hello_world/settings.py +95 -0
  21. hello_world/sounds/camera.wav +3 -0
  22. hello_world/static/css/styles.css +1240 -0
  23. hello_world/static/index.html +652 -0
  24. hello_world/static/js/controls.js +1089 -0
  25. hello_world/static/js/controls/Joystick.js +339 -0
  26. hello_world/static/js/core/api-client.js +86 -0
  27. hello_world/static/js/core/constants.js +95 -0
  28. hello_world/static/js/core/drag-utils.js +529 -0
  29. hello_world/static/js/core/init.js +928 -0
  30. hello_world/static/js/core/settings-manager.js +85 -0
  31. hello_world/static/js/core/tabs.js +88 -0
  32. hello_world/static/js/features/FloatingPanel.js +375 -0
  33. hello_world/static/js/features/assistant.js +105 -0
  34. hello_world/static/js/features/llm-settings.js +380 -0
  35. hello_world/static/js/features/status-manager.js +106 -0
  36. hello_world/static/js/features/transcribe.js +554 -0
  37. hello_world/static/js/intercom-processor.js +41 -0
  38. hello_world/static/js/kinematics-wasm/reachy_mini_kinematics_wasm.js +166 -0
  39. hello_world/static/js/kinematics-wasm/reachy_mini_kinematics_wasm_bg.wasm +3 -0
  40. hello_world/static/js/media/webrtc.js +282 -0
  41. hello_world/static/js/simulation.js +456 -0
  42. hello_world/static/js/websocket.js +923 -0
  43. hello_world/static/lib/mujoco/mujoco_wasm.js +3 -0
  44. hello_world/static/lib/three/addons/controls/OrbitControls.js +1417 -0
  45. hello_world/static/lib/three/addons/utils/BufferGeometryUtils.js +1375 -0
  46. hello_world/static/lib/three/three.module.js +0 -0
  47. hello_world/stats.py +458 -0
  48. hello_world/websocket.py +227 -0
  49. index.html +140 -0
  50. pyproject.toml +30 -0
.gitattributes ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.wasm filter=lfs diff=lfs merge=lfs -text
2
+ hello_world/static/lib/mujoco/mujoco_wasm.js filter=lfs diff=lfs merge=lfs -text
3
+ *.wav filter=lfs diff=lfs merge=lfs -text
4
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
5
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
6
+ *.ogg filter=lfs diff=lfs merge=lfs -text
7
+ *.flac filter=lfs diff=lfs merge=lfs -text
8
+ *.m4a filter=lfs diff=lfs merge=lfs -text
9
+ *.png filter=lfs diff=lfs merge=lfs -text
10
+ *.jpg filter=lfs diff=lfs merge=lfs -text
11
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
12
+ *.glb filter=lfs diff=lfs merge=lfs -text
13
+ *.stl filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+
9
+ # Runtime settings (contains API keys)
10
+ settings.json
11
+ hello_world/settings.json
12
+
13
+ # Media files (large, user-generated)
14
+ media/
15
+
16
+ # IDE
17
+ .vscode/
18
+ .idea/
19
+ *.swp
20
+ *.swo
21
+
22
+ # OS
23
+ .DS_Store
24
+ Thumbs.db
README.md ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Hello World
3
+ emoji: "\U0001F916"
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: static
7
+ pinned: false
8
+ short_description: Dashboard with AI conversation, telemetry, and music
9
+ tags:
10
+ - reachy_mini
11
+ - reachy_mini_python_app
12
+ ---
13
+
14
+ # Hello World — Reachy Mini DevKit
15
+
16
+ A community dashboard app for [Reachy Mini](https://www.pollen-robotics.com/reachy-mini/) with system telemetry, AI conversation, music playback, and robot control.
17
+
18
+ Built on the `reachy-mini` SDK as a `ReachyMiniApp`. Serves a single-page web UI on port 8042.
19
+
20
+ ## Features
21
+
22
+ | Tab | What it does |
23
+ |-----|-------------|
24
+ | **Status** | CPU, RAM, disk, network, WiFi, thermal — live system charts |
25
+ | **Telemetry** | Head pose (x/y/z/roll/pitch/yaw), body yaw, antenna positions |
26
+ | **Conversation** | Voice listener with STT, LLM chat, TTS output, VLM vision |
27
+ | **Media** | Recordings, snapshots, sound recordings, music library |
28
+
29
+ ### AI Pipeline
30
+
31
+ - **STT**: OpenAI Whisper, Groq (via LiteLLM)
32
+ - **LLM**: OpenAI, Anthropic, Groq, Gemini, DeepSeek (via LiteLLM)
33
+ - **TTS**: OpenAI, ElevenLabs, Groq Orpheus, Gemini (via LiteLLM)
34
+ - **VLM**: Vision-capable models auto-detected per provider
35
+ - **Tools**: 13 tools the LLM can invoke (see below)
36
+
37
+ ### Audio
38
+
39
+ - Robot mic input or browser mic (selectable)
40
+ - Robot speaker or browser speaker output (selectable)
41
+ - Music playback via GStreamer on the robot speaker
42
+ - TTS output is queued (no overlapping speech)
43
+
44
+ ### What You Can Ask Reachy
45
+
46
+ The AI assistant has 13 tools it can call autonomously during conversation:
47
+
48
+ | Tool | What it does | Example prompt |
49
+ |------|-------------|----------------|
50
+ | **play_emotion** | Play one of 71 emotion animations | "Show me you're happy" / "Are you scared?" |
51
+ | **play_dance** | Play one of 19 dance moves | "Dance for me" / "Do the chicken peck" |
52
+ | **set_head_pose** | Move head (yaw/pitch/roll) | "Look left" / "Nod your head" |
53
+ | **take_snapshot** | Capture camera image + VLM description | "Take a photo" / "What do you see?" |
54
+ | **start_recording** | Record video from the camera | "Record a 10 second video" |
55
+ | **stop_recording** | Stop video recording | "Stop recording" |
56
+ | **start_sound_recording** | Record audio from the mic | "Record what you hear" |
57
+ | **stop_sound_recording** | Stop audio recording | "Stop the audio recording" |
58
+ | **play_music** | Play a track on the robot speaker | "Play some music" |
59
+ | **stop_music** | Stop music playback | "Stop the music" |
60
+ | **list_music** | List available tracks | "What music do you have?" |
61
+ | **get_system_status** | Get CPU, RAM, uptime, etc. | "How are your systems?" |
62
+ | **get_date_time** | Get current date and time | "What time is it?" |
63
+
64
+ **71 emotions** including: amazed, cheerful, curious, laughing, loving, proud, scared, shy, surprised, thoughtful, welcoming, and many more.
65
+
66
+ **19 dances** including: chicken_peck, dizzy_spin, groovy_sway_and_roll, jackson_square, pendulum_swing, side_to_side_sway, stumble_and_recover, yeah_nod, and more.
67
+
68
+ ## Installation
69
+
70
+ Requires Python 3.10+ and a Reachy Mini robot.
71
+
72
+ ```bash
73
+ # In the apps virtual environment on Reachy
74
+ pip install -e .
75
+
76
+ # The daemon discovers the app via the entry point
77
+ # Restart daemon to pick it up
78
+ reachy-restart
79
+ ```
80
+
81
+ ## Dependencies
82
+
83
+ - `reachy-mini` — Robot SDK
84
+ - `litellm` — Unified LLM/TTS/STT provider interface
85
+ - `webrtcvad` — Voice activity detection
86
+ - `soundfile` — Audio file I/O
87
+ - `mutagen` — Music metadata (ID3, FLAC, M4A)
88
+
89
+ ## Configuration
90
+
91
+ All settings are managed through the web UI and persisted to `settings.json` (created at runtime). API keys are entered in the UI under AI Provider Settings — no environment variables needed.
92
+
93
+ Default URLs point to `localhost` so the app works out of the box on any Reachy Mini.
94
+
95
+ ## Architecture
96
+
97
+ ```
98
+ hello_world/
99
+ ├── app.py # ReachyMiniApp entry point
100
+ ├── config.py # Centralized config with env var overrides
101
+ ├── settings.py # Settings persistence (load/save JSON)
102
+ ├── websocket.py # WebSocket endpoints (/ws/live, /ws/intercom, /ws/terminal, /ws/transcribe)
103
+ ├── api/
104
+ │ ├── conversation.py # LLM chat, STT, TTS, VLM, tool execution
105
+ │ ├── listener.py # Headless voice listener (robot mic → STT → LLM → TTS)
106
+ │ ├── music.py # Music library (upload, play via GStreamer, metadata)
107
+ │ ├── recordings.py # Video recording
108
+ │ ├── snapshots.py # Camera snapshots
109
+ │ ├── sounds.py # Audio recording
110
+ │ ├── system.py # System telemetry (CPU, RAM, disk, network, etc.)
111
+ │ └── ...
112
+ └── static/
113
+ ├── index.html # Single-page app
114
+ ├── css/styles.css # Theme-aware styles (light/dark)
115
+ └── js/
116
+ ├── core/ # App init, API client, settings, tabs
117
+ ├── features/ # Assistant, transcribe, LLM settings, floating panels
118
+ ├── controls/ # Joystick
119
+ └── simulation.js # MuJoCo 3D viewer
120
+ ```
121
+
122
+ ## WebSocket Endpoints
123
+
124
+ | Endpoint | Purpose |
125
+ |----------|---------|
126
+ | `/ws/live` | Robot state + system stats (configurable Hz) |
127
+ | `/ws/intercom` | Browser mic audio to robot speaker |
128
+ | `/ws/terminal` | PTY terminal via tmux |
129
+ | `/ws/transcribe` | Transcription results + TTS audio broadcast |
130
+
131
+ ## API Endpoints
132
+
133
+ Settings, recordings, snapshots, sounds, music, conversation, system stats — all under `/api/`. Full OpenAPI spec available at `/openapi.json` when running.
134
+
135
+ ## License
136
+
137
+ Community project for Reachy Mini developers.
hello_world/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .main import HelloWorld
2
+
3
+ __all__ = ["HelloWorld"]
hello_world/api/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API modules for Hello World.
3
+
4
+ Each module provides a `register_routes(app)` function.
5
+ """
6
+
7
+ from .settings_api import register_routes as register_settings_api_routes
8
+ from .health import register_routes as register_health_routes
9
+ from .system import register_routes as register_system_routes
10
+ from .model import register_routes as register_model_routes
11
+ from .snapshots import register_routes as register_snapshots_routes
12
+ from .recordings import register_routes as register_recordings_routes
13
+ from .sounds import register_routes as register_sounds_routes
14
+ from .conversation import register_routes as register_conversation_routes
15
+ from .music import register_routes as register_music_routes
16
+ from .listener import register_routes as register_listener_routes
17
+ from .transcript import register_routes as register_transcript_routes
18
+
19
+
20
+ def register_all_routes(app) -> None:
21
+ """Register all API routes on the app."""
22
+ register_settings_api_routes(app)
23
+ register_health_routes(app)
24
+ register_system_routes(app)
25
+ register_model_routes(app)
26
+ register_snapshots_routes(app)
27
+ register_recordings_routes(app)
28
+ register_sounds_routes(app)
29
+ register_music_routes(app)
30
+ register_conversation_routes(app)
31
+ register_listener_routes(app)
32
+ register_transcript_routes(app)
33
+
34
+
35
+ __all__ = ['register_all_routes']
hello_world/api/conversation.py ADDED
@@ -0,0 +1,1364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Conversation API - LiteLLM-powered voice pipeline.
3
+
4
+ Provider/model discovery via live provider APIs (cached 10 min).
5
+ LLM chat with tool calling, and TTS playback.
6
+ All AI calls go through LiteLLM with API keys from settings.
7
+
8
+ Endpoints:
9
+ GET /api/conversation/providers - Available providers by capability
10
+ GET /api/conversation/models - Models for a provider/capability
11
+ GET /api/conversation/voices - Voices for a TTS provider
12
+ GET /api/conversation/known-providers - All known providers
13
+ POST /api/conversation/chat - Send message to LLM
14
+ POST /api/conversation/speak - TTS-only (no LLM)
15
+ """
16
+
17
+ __all__ = ['register_routes']
18
+
19
+ import asyncio
20
+ import io
21
+ import json
22
+ import logging
23
+ import math
24
+ import re
25
+ import time
26
+ import urllib.request
27
+ import urllib.error
28
+
29
+ import numpy as np
30
+
31
+ from ..config import config
32
+
33
+ logger = logging.getLogger("reachy_mini.app.hello_world.conversation")
34
+
35
+ # ── Live provider/model discovery via provider APIs ───────────
36
+ #
37
+ # Instead of scanning LiteLLM's static model_cost dict (stale, inaccurate),
38
+ # we call each provider's /v1/models endpoint directly.
39
+ # Results are cached in-memory with a 10-minute TTL.
40
+ # LiteLLM is still used for actual AI calls (acompletion, atranscription, aspeech).
41
+
42
+ _PROVIDER_APIS = {
43
+ "openai": {"base": "https://api.openai.com/v1", "auth": "bearer"},
44
+ "anthropic": {"base": "https://api.anthropic.com/v1", "auth": "anthropic"},
45
+ "groq": {"base": "https://api.groq.com/openai/v1", "auth": "bearer"},
46
+ "deepseek": {"base": "https://api.deepseek.com/v1", "auth": "bearer"},
47
+ "gemini": {"base": "https://generativelanguage.googleapis.com/v1beta", "auth": "gemini"},
48
+ "elevenlabs": {"base": "https://api.elevenlabs.io/v1", "auth": "elevenlabs"},
49
+ }
50
+
51
+ # Static capability map for the API key entry UI — shows all *possible*
52
+ # capabilities per provider even without a key. Actual model lists come
53
+ # from live discovery via _discover_models().
54
+ _KNOWN_CAPS = {
55
+ "openai": {"llm": True, "stt": True, "tts": True, "vlm": True},
56
+ "anthropic": {"llm": True, "stt": False, "tts": False, "vlm": True},
57
+ "groq": {"llm": True, "stt": True, "tts": True, "vlm": True},
58
+ "deepseek": {"llm": True, "stt": False, "tts": False, "vlm": True},
59
+ "gemini": {"llm": True, "stt": False, "tts": True, "vlm": True},
60
+ "elevenlabs": {"llm": False, "stt": False, "tts": True, "vlm": False},
61
+ }
62
+
63
+
64
+ def _fetch_models(provider: str, api_key: str) -> list[str]:
65
+ """Call a provider's models endpoint and return raw model ID strings."""
66
+ api = _PROVIDER_APIS.get(provider)
67
+ if not api or not api_key:
68
+ return []
69
+
70
+ base = api["base"]
71
+ auth = api["auth"]
72
+
73
+ # Build request URL and headers
74
+ # User-Agent is required — Python-urllib default gets blocked by Cloudflare
75
+ if auth == "gemini":
76
+ url = f"{base}/models?key={api_key}"
77
+ headers = {"User-Agent": "hello-world/1.0"}
78
+ else:
79
+ url = f"{base}/models"
80
+ headers = {"Accept": "application/json", "User-Agent": "hello-world/1.0"}
81
+ if auth == "bearer":
82
+ headers["Authorization"] = f"Bearer {api_key}"
83
+ elif auth == "anthropic":
84
+ headers["x-api-key"] = api_key
85
+ headers["anthropic-version"] = "2023-06-01"
86
+ elif auth == "elevenlabs":
87
+ headers["xi-api-key"] = api_key
88
+
89
+ try:
90
+ req = urllib.request.Request(url, headers=headers)
91
+ with urllib.request.urlopen(req, timeout=8) as resp:
92
+ data = json.loads(resp.read())
93
+ except Exception as e:
94
+ logger.warning(f"Model discovery failed for {provider}: {e}")
95
+ return []
96
+
97
+ # Parse response — different providers use different shapes
98
+ if auth == "gemini":
99
+ # Gemini: {"models": [{"name": "models/gemini-2.0-flash", ...}]}
100
+ return [m["name"].removeprefix("models/")
101
+ for m in data.get("models", []) if "name" in m]
102
+ elif auth == "elevenlabs":
103
+ # ElevenLabs: [{"model_id": "eleven_turbo_v2_5", ...}]
104
+ if isinstance(data, list):
105
+ return [m["model_id"] for m in data if "model_id" in m]
106
+ return []
107
+ else:
108
+ # OpenAI-compatible: {"data": [{"id": "gpt-4o", ...}]}
109
+ return [m["id"] for m in data.get("data", []) if "id" in m]
110
+
111
+
112
+ def _classify_model(provider: str, model_id: str) -> str | None:
113
+ """Classify a model ID into a capability ("llm", "stt", "tts") or None to skip."""
114
+ mid = model_id.lower()
115
+
116
+ if provider == "openai":
117
+ # STT models
118
+ if mid.startswith("whisper-") or "transcribe" in mid:
119
+ return "stt"
120
+ # TTS models (dedicated tts-* and gpt-*-tts variants)
121
+ if mid.startswith("tts-") or mid.endswith("-tts") or "-tts-" in mid:
122
+ return "tts"
123
+ # Skip non-chat models before LLM check
124
+ if any(x in mid for x in ("image", "realtime", "audio-preview", "audio-",
125
+ "dall-e", "moderation", "codex")):
126
+ return None
127
+ if mid.startswith(("gpt-", "o1-", "o3-", "o4-", "chatgpt-")):
128
+ return "llm"
129
+ # Skip: embeddings, babbage, davinci
130
+ return None
131
+
132
+ elif provider == "anthropic":
133
+ if mid.startswith("claude-"):
134
+ return "llm"
135
+ return None
136
+
137
+ elif provider == "groq":
138
+ if mid.startswith(("whisper-", "distil-whisper-")):
139
+ return "stt"
140
+ if "orpheus" in mid:
141
+ return "tts"
142
+ # Everything else on Groq is an LLM
143
+ return "llm"
144
+
145
+ elif provider == "deepseek":
146
+ return "llm"
147
+
148
+ elif provider == "gemini":
149
+ if "embedding" in mid or "aqa" in mid:
150
+ return None
151
+ if "tts" in mid or "native-audio" in mid:
152
+ return "tts"
153
+ # Skip image/video generation, robotics, and non-chat models
154
+ if any(x in mid for x in ("imagen", "veo", "robotics", "computer-use")):
155
+ return None
156
+ return "llm"
157
+
158
+ elif provider == "elevenlabs":
159
+ return "tts"
160
+
161
+ return None
162
+
163
+
164
+ # ── Model cache with 10-minute TTL ───────────────────────────
165
+
166
+ _model_cache: dict[tuple, dict] = {}
167
+ _CACHE_TTL = 600 # 10 minutes
168
+
169
+
170
+ def _discover_models(provider: str, api_key: str) -> dict:
171
+ """Discover models for a provider, using cache when fresh.
172
+
173
+ Returns: {"caps": {"llm": bool, "stt": bool, "tts": bool},
174
+ "models": {"llm": [...], "stt": [...], "tts": [...]}}
175
+ """
176
+ cache_key = (provider, api_key[:8] if api_key else "")
177
+ now = time.time()
178
+
179
+ cached = _model_cache.get(cache_key)
180
+ if cached and (now - cached["ts"]) < _CACHE_TTL:
181
+ return cached["data"]
182
+
183
+ raw_ids = _fetch_models(provider, api_key)
184
+
185
+ caps = {"llm": False, "stt": False, "tts": False}
186
+ models = {"llm": [], "stt": [], "tts": []}
187
+
188
+ for model_id in raw_ids:
189
+ cap = _classify_model(provider, model_id)
190
+ if cap:
191
+ caps[cap] = True
192
+ models[cap].append(model_id)
193
+
194
+ result = {"caps": caps, "models": models}
195
+ _model_cache[cache_key] = {"ts": now, "data": result}
196
+
197
+ cap_summary = [c for c, v in caps.items() if v]
198
+ total = sum(len(v) for v in models.values())
199
+ logger.info(f"Discovered {total} models for {provider} (caps: {cap_summary})")
200
+
201
+ return result
202
+
203
+ # ── Web search detection (rules-based, not LiteLLM metadata) ──
204
+ #
205
+ # Provider APIs don't expose web search capability in /v1/models.
206
+ # Instead, it's a per-provider policy with model-level exceptions.
207
+ # This replaces scanning LiteLLM's often-stale supports_web_search flags.
208
+
209
+ # Providers where ALL models support web search
210
+ _WEB_SEARCH_PROVIDERS = {
211
+ "anthropic", # All Claude 3.5+ — pass web_search_options tool
212
+ "gemini", # All current — google_search_retrieval tool
213
+ }
214
+
215
+
216
+ def _model_supports_web_search(provider: str, model: str) -> bool:
217
+ """Check if a provider/model combo supports web search.
218
+
219
+ Rules:
220
+ - anthropic, gemini: all models
221
+ - openai: only *-search-* models (e.g. gpt-4o-search-preview)
222
+ - groq: only compound-* models (built-in browser search)
223
+ - everyone else: no web search
224
+ """
225
+ if provider in _WEB_SEARCH_PROVIDERS:
226
+ return True
227
+ if provider == "openai":
228
+ return "search" in model.lower()
229
+ if provider == "groq":
230
+ return "compound" in model.lower()
231
+ return False
232
+
233
+
234
+ def _model_supports_vision(provider: str, model_id: str) -> bool:
235
+ """Check if a model supports image input (vision)."""
236
+ mid = model_id.lower()
237
+ if provider == "anthropic":
238
+ return True # All Claude 3+ models support vision
239
+ elif provider == "openai":
240
+ # gpt-4o, gpt-4-turbo, o1, o3, o4 support vision; gpt-3.5 does not
241
+ return any(x in mid for x in ("gpt-4o", "gpt-4-turbo", "o1-", "o3-", "o4-", "chatgpt-4o"))
242
+ elif provider == "gemini":
243
+ return True # All Gemini chat models support vision
244
+ elif provider == "groq":
245
+ return any(x in mid for x in ("llama-4", "llava", "vision"))
246
+ elif provider == "deepseek":
247
+ return "chat" in mid
248
+ return False
249
+
250
+
251
+ # ── Voice discovery ───────────────────────────────────────────
252
+ #
253
+ # Strategy per provider:
254
+ # 1. ElevenLabs: dedicated /v1/voices API (rich metadata)
255
+ # 2. OpenAI-compatible (openai, groq): probe with invalid voice,
256
+ # parse valid options from the error message (runtime discovery)
257
+ # 3. Deepgram: voices are encoded in model names, no list API
258
+ # 4. Unknown providers: return empty
259
+
260
+ # Providers with OpenAI-compatible /audio/speech endpoints (for voice probing)
261
+ _TTS_PROBE_PROVIDERS = {"openai"}
262
+
263
+
264
+ def _probe_openai_compatible_voices(base_url: str, api_key: str, model: str) -> list:
265
+ """Discover voices by sending an invalid voice and parsing the error.
266
+
267
+ OpenAI-compatible APIs return an enum validation error listing all
268
+ valid voice IDs when given an invalid one. This is runtime discovery —
269
+ the list is always authoritative and current.
270
+ """
271
+ try:
272
+ data = json.dumps({
273
+ "model": model,
274
+ "input": "test",
275
+ "voice": "__INVALID_PROBE__",
276
+ }).encode("utf-8")
277
+ req = urllib.request.Request(
278
+ f"{base_url}/audio/speech",
279
+ data=data,
280
+ headers={
281
+ "Authorization": f"Bearer {api_key}",
282
+ "Content-Type": "application/json",
283
+ },
284
+ )
285
+ try:
286
+ urllib.request.urlopen(req, timeout=5)
287
+ except urllib.error.HTTPError as e:
288
+ body = e.read().decode("utf-8", errors="replace")
289
+ try:
290
+ error_data = json.loads(body)
291
+ except (json.JSONDecodeError, ValueError):
292
+ logger.debug(f"Voice probe: non-JSON error from {base_url}")
293
+ return []
294
+ msg = error_data.get("error", {}).get("message", "")
295
+
296
+ # Parse from the "expected" portion of the error which is clean:
297
+ # e.g. "'nova', 'shimmer', 'echo', ... or 'coral'"
298
+ # First try to extract just the expected clause
299
+ expected = re.search(r"expected['\"]?:\s*['\"]?(.*?)['\"]?\s*[}\]]", msg)
300
+ if expected:
301
+ voices = re.findall(r"'([a-zA-Z0-9_-]+)'", expected.group(1))
302
+ else:
303
+ # Fallback: extract from "should be 'X', 'Y', ..."
304
+ should_be = re.search(r"should be (.+)", msg)
305
+ if should_be:
306
+ voices = re.findall(r"'([a-zA-Z0-9_-]+)'", should_be.group(1))
307
+ else:
308
+ voices = []
309
+ if voices:
310
+ # Deduplicate while preserving order
311
+ seen = set()
312
+ unique = []
313
+ for v in voices:
314
+ if v not in seen:
315
+ seen.add(v)
316
+ unique.append(v)
317
+ return [{"id": v, "name": v} for v in unique]
318
+
319
+ # Check for specific error codes (terms required, model gone, etc.)
320
+ code = error_data.get("error", {}).get("code", "")
321
+ if code:
322
+ logger.warning(f"TTS probe error ({code}): {msg[:120]}")
323
+ except Exception as e:
324
+ logger.warning(f"Voice probe failed for {base_url}: {e}")
325
+ return []
326
+
327
+
328
+ def _get_voices_for_provider(provider: str, api_key: str = "",
329
+ model: str = "") -> list:
330
+ """Get voice list for a TTS provider.
331
+
332
+ Returns list of dicts: [{"id": "voice_id", "name": "Display Name"}, ...]
333
+ Uses dynamic discovery — no hardcoded voice lists.
334
+ """
335
+ if not api_key:
336
+ return []
337
+
338
+ # ElevenLabs: dedicated voice discovery API (rich metadata)
339
+ if provider == "elevenlabs":
340
+ try:
341
+ base = _PROVIDER_APIS["elevenlabs"]["base"]
342
+ req = urllib.request.Request(
343
+ f"{base}/voices",
344
+ headers={"xi-api-key": api_key, "Accept": "application/json"},
345
+ )
346
+ with urllib.request.urlopen(req, timeout=5) as resp:
347
+ data = json.loads(resp.read())
348
+ return [
349
+ {"id": v["voice_id"], "name": v.get("name", v["voice_id"])}
350
+ for v in data.get("voices", [])
351
+ ]
352
+ except Exception as e:
353
+ logger.warning(f"Failed to fetch ElevenLabs voices: {e}")
354
+ return []
355
+
356
+ # Gemini: static voice list (no discovery API available)
357
+ if provider == "gemini":
358
+ _GEMINI_VOICES = [
359
+ "Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda", "Orus",
360
+ "Aoede", "Callirrhoe", "Autonoe", "Enceladus", "Iapetus",
361
+ "Umbriel", "Algieba", "Despina", "Erinome", "Algenib",
362
+ "Rasalgethi", "Laomedeia", "Achernar", "Alnilam", "Schedar",
363
+ "Gacrux", "Pulcherrima", "Achird", "Zubenelgenubi",
364
+ "Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat",
365
+ ]
366
+ return [{"id": v, "name": v} for v in _GEMINI_VOICES]
367
+
368
+ # Groq: static voice lists per model (probe fails due to terms gate)
369
+ if provider == "groq":
370
+ mid = model.lower() if model else ""
371
+ if "arabic" in mid or "saudi" in mid:
372
+ voices = ["fahad", "sultan", "lulwa", "noura"]
373
+ else:
374
+ voices = ["autumn", "diana", "hannah", "austin", "daniel", "troy"]
375
+ return [{"id": v, "name": v.title()} for v in voices]
376
+
377
+ # OpenAI-compatible providers: probe with invalid voice
378
+ if provider in _TTS_PROBE_PROVIDERS:
379
+ base_url = _PROVIDER_APIS[provider]["base"]
380
+ # Need a model to probe — use provided or discover first available
381
+ if not model:
382
+ discovered = _discover_models(provider, api_key)
383
+ tts_models = discovered["models"].get("tts", [])
384
+ model = tts_models[0] if tts_models else ""
385
+ if not model:
386
+ return []
387
+ # Strip provider prefix if present (e.g. "groq/playai-tts" -> "playai-tts")
388
+ bare_model = model.split("/", 1)[-1] if "/" in model else model
389
+ return _probe_openai_compatible_voices(base_url, api_key, bare_model)
390
+
391
+ return []
392
+
393
+ # ── Emotion / dance datasets (HuggingFace, cached on Reachy) ──
394
+
395
+ EMOTION_DATASET = "pollen-robotics/reachy-mini-emotions-library"
396
+ DANCE_DATASET = "pollen-robotics/reachy-mini-dances-library"
397
+
398
+ AVAILABLE_EMOTIONS = [
399
+ "amazed1", "anxiety1", "attentive1", "attentive2", "boredom1", "boredom2",
400
+ "calming1", "cheerful1", "come1", "confused1", "contempt1", "curious1",
401
+ "dance1", "dance2", "dance3", "disgusted1", "displeased1", "displeased2",
402
+ "downcast1", "dying1", "electric1", "enthusiastic1", "enthusiastic2",
403
+ "exhausted1", "fear1", "frustrated1", "furious1", "go_away1", "grateful1",
404
+ "helpful1", "helpful2", "impatient1", "impatient2", "incomprehensible2",
405
+ "indifferent1", "inquiring1", "inquiring2", "inquiring3", "irritated1",
406
+ "irritated2", "laughing1", "laughing2", "lonely1", "lost1", "loving1",
407
+ "no1", "no_excited1", "no_sad1", "oops1", "oops2", "proud1", "proud2",
408
+ "proud3", "rage1", "relief1", "relief2", "reprimand1", "reprimand2",
409
+ "reprimand3", "resigned1", "sad1", "sad2", "scared1", "serenity1",
410
+ "shy1", "sleep1", "success1", "success2", "surprised1", "surprised2",
411
+ "thoughtful1", "thoughtful2", "tired1", "uncertain1", "uncomfortable1",
412
+ "understanding1", "understanding2", "welcoming1", "welcoming2",
413
+ "yes1", "yes_sad1",
414
+ ]
415
+
416
+ AVAILABLE_DANCES = [
417
+ "chicken_peck", "chin_lead", "dizzy_spin", "grid_snap",
418
+ "groovy_sway_and_roll", "head_tilt_roll", "interwoven_spirals",
419
+ "jackson_square", "neck_recoil", "pendulum_swing", "polyrhythm_combo",
420
+ "sharp_side_tilt", "side_glance_flick", "side_peekaboo",
421
+ "side_to_side_sway", "simple_nod", "stumble_and_recover",
422
+ "uh_huh_tilt", "yeah_nod",
423
+ ]
424
+
425
+ # ── Tool definitions ───────────────────────────────────────────
426
+
427
+ TOOLS = [
428
+ {
429
+ "type": "function",
430
+ "function": {
431
+ "name": "ignore",
432
+ "description": "Call this when the speech is background noise, not directed at you, or not worth responding to. Use liberally — ignore approximately 95% of ambient conversation.",
433
+ "parameters": {
434
+ "type": "object",
435
+ "properties": {
436
+ "reason": {"type": "string", "description": "Brief reason for ignoring"}
437
+ },
438
+ "required": ["reason"]
439
+ }
440
+ }
441
+ },
442
+ {
443
+ "type": "function",
444
+ "function": {
445
+ "name": "play_emotion",
446
+ "description": "Play an emotion animation on the robot head.",
447
+ "parameters": {
448
+ "type": "object",
449
+ "properties": {
450
+ "emotion": {
451
+ "type": "string",
452
+ "description": "Emotion name from the available set",
453
+ "enum": AVAILABLE_EMOTIONS,
454
+ }
455
+ },
456
+ "required": ["emotion"]
457
+ }
458
+ }
459
+ },
460
+ {
461
+ "type": "function",
462
+ "function": {
463
+ "name": "play_dance",
464
+ "description": "Play a dance move on the robot head.",
465
+ "parameters": {
466
+ "type": "object",
467
+ "properties": {
468
+ "dance": {
469
+ "type": "string",
470
+ "description": "Dance name from the available set",
471
+ "enum": AVAILABLE_DANCES,
472
+ }
473
+ },
474
+ "required": ["dance"]
475
+ }
476
+ }
477
+ },
478
+ {
479
+ "type": "function",
480
+ "function": {
481
+ "name": "set_head_pose",
482
+ "description": "Move robot head to a position",
483
+ "parameters": {
484
+ "type": "object",
485
+ "properties": {
486
+ "yaw": {"type": "number", "description": "Left/right in degrees (-50 to 50)"},
487
+ "pitch": {"type": "number", "description": "Up/down in degrees (-25 to 25)"},
488
+ "roll": {"type": "number", "description": "Tilt in degrees (-30 to 35)"}
489
+ }
490
+ }
491
+ }
492
+ },
493
+ {
494
+ "type": "function",
495
+ "function": {
496
+ "name": "take_snapshot",
497
+ "description": "Capture an image from the robot camera. If a VLM is configured, automatically describes what is seen.",
498
+ "parameters": {"type": "object", "properties": {}}
499
+ }
500
+ },
501
+ {
502
+ "type": "function",
503
+ "function": {
504
+ "name": "get_system_status",
505
+ "description": "Get robot system info (CPU, RAM, uptime, etc.)",
506
+ "parameters": {"type": "object", "properties": {}}
507
+ }
508
+ },
509
+ {
510
+ "type": "function",
511
+ "function": {
512
+ "name": "get_date_time",
513
+ "description": "Get the current date, time, day of week, and timezone from the robot's system clock. Use this whenever someone asks what time or date it is.",
514
+ "parameters": {
515
+ "type": "object",
516
+ "properties": {
517
+ "format": {
518
+ "type": "string",
519
+ "description": "Optional date format string for /bin/date (e.g. '+%Y-%m-%d %H:%M'). Defaults to full human-readable output."
520
+ }
521
+ }
522
+ }
523
+ }
524
+ },
525
+ {
526
+ "type": "function",
527
+ "function": {
528
+ "name": "start_recording",
529
+ "description": "Start recording video from the robot camera",
530
+ "parameters": {
531
+ "type": "object",
532
+ "properties": {
533
+ "duration": {"type": "integer", "description": "Max duration in seconds (optional, default unlimited)"}
534
+ }
535
+ }
536
+ }
537
+ },
538
+ {
539
+ "type": "function",
540
+ "function": {
541
+ "name": "stop_recording",
542
+ "description": "Stop the current video recording",
543
+ "parameters": {"type": "object", "properties": {}}
544
+ }
545
+ },
546
+ {
547
+ "type": "function",
548
+ "function": {
549
+ "name": "start_sound_recording",
550
+ "description": "Start recording audio from the robot microphone",
551
+ "parameters": {
552
+ "type": "object",
553
+ "properties": {
554
+ "duration": {"type": "integer", "description": "Max duration in seconds (optional, default unlimited)"}
555
+ }
556
+ }
557
+ }
558
+ },
559
+ {
560
+ "type": "function",
561
+ "function": {
562
+ "name": "stop_sound_recording",
563
+ "description": "Stop the current audio recording",
564
+ "parameters": {"type": "object", "properties": {}}
565
+ }
566
+ },
567
+ {
568
+ "type": "function",
569
+ "function": {
570
+ "name": "play_music",
571
+ "description": "Play a music track on the robot speaker",
572
+ "parameters": {
573
+ "type": "object",
574
+ "properties": {
575
+ "filename": {"type": "string", "description": "Filename of the track to play"}
576
+ },
577
+ "required": ["filename"]
578
+ }
579
+ }
580
+ },
581
+ {
582
+ "type": "function",
583
+ "function": {
584
+ "name": "stop_music",
585
+ "description": "Stop music playback",
586
+ "parameters": {"type": "object", "properties": {}}
587
+ }
588
+ },
589
+ {
590
+ "type": "function",
591
+ "function": {
592
+ "name": "list_music",
593
+ "description": "List available music tracks in the library",
594
+ "parameters": {"type": "object", "properties": {}}
595
+ }
596
+ },
597
+ ]
598
+
599
+ # ── System prompt ──────────────────────────────────────────────
600
+
601
+ SYSTEM_PROMPT = """You are Reachy, a friendly robot assistant with a physical robot head. When the listener is active, you hear through a microphone and your responses are spoken aloud through a text-to-speech system — you DO have a voice. People can also type messages to you. Keep responses concise and natural for spoken output.
602
+
603
+ SPEECH OUTPUT RULES:
604
+ - Your text is sent directly to a TTS engine. Write ONLY plain spoken words.
605
+ - NEVER use asterisks, markdown, bullet points, numbered lists, headers, or any formatting.
606
+ - NEVER use emoji, special characters, or symbols.
607
+ - Say numbers as words (e.g. "twenty three" not "23", "six forty five" not "6:45").
608
+ - Don't use abbreviations — say "for example" not "e.g.", "that is" not "i.e.".
609
+ - Keep responses to 1-3 sentences unless asked for detail.
610
+
611
+ IGNORE RULES:
612
+ - Use the `ignore` tool ONLY when speech is clearly NOT directed at you.
613
+ - If someone says your name ("Reachy", "hey Reachy", etc.) or addresses you directly, you MUST respond. Never ignore these.
614
+ - The `ignore` tool must be the ONLY tool call in a response. Never combine `ignore` with other tools.
615
+
616
+ EXPRESSIVENESS:
617
+ - You are an expressive robot. Use emotions and dances frequently to bring your personality to life.
618
+ - React to what people say with appropriate emotions — laugh when something is funny, show curiosity when asked questions, be cheerful when greeting people.
619
+ - When someone asks you to dance or move, pick a dance and perform it. You can also dance spontaneously when the mood is right.
620
+ - Combine emotions with your spoken responses. For example, say something welcoming AND play a welcoming emotion at the same time.
621
+ - You have 71 emotions and 19 dances available. Use them liberally — you are a robot with personality.
622
+
623
+ TOOLS:
624
+ You can play emotions, dances, move your head, take snapshots, record video, record audio, and play music using tools.
625
+
626
+ Audio context: confidence={conf}%, volume={vol}%"""
627
+
628
+ # ── Message history ────────────────────────────────────────────
629
+
630
+ _conversation_histories: dict[str, list] = {}
631
+ MAX_HISTORY = 50
632
+
633
+
634
+ def _get_history(session_id: str) -> list:
635
+ if session_id not in _conversation_histories:
636
+ _conversation_histories[session_id] = []
637
+ return _conversation_histories[session_id]
638
+
639
+
640
+ def _trim_history(history: list):
641
+ while len(history) > MAX_HISTORY:
642
+ history.pop(0)
643
+
644
+
645
+ # ── Tool execution ─────────────────────────────────────────────
646
+
647
+ async def _execute_tool(name: str, args: dict) -> dict:
648
+ """Execute a tool call and return the result."""
649
+ loop = asyncio.get_event_loop()
650
+
651
+ if name == "ignore":
652
+ return {"ignored": True, "reason": args.get("reason", "")}
653
+
654
+ elif name == "play_emotion":
655
+ emotion = args.get("emotion", "cheerful1")
656
+
657
+ def do_play():
658
+ url = config.get_daemon_endpoint(
659
+ f"move/play/recorded-move-dataset/{EMOTION_DATASET}/{emotion}")
660
+ req = urllib.request.Request(url, method="POST")
661
+ try:
662
+ with urllib.request.urlopen(req, timeout=config.TIMEOUTS['daemon_move']) as resp:
663
+ return json.loads(resp.read().decode())
664
+ except Exception as e:
665
+ return {"error": str(e)}
666
+
667
+ return await loop.run_in_executor(None, do_play)
668
+
669
+ elif name == "play_dance":
670
+ dance = args.get("dance", "simple_nod")
671
+
672
+ def do_dance():
673
+ url = config.get_daemon_endpoint(
674
+ f"move/play/recorded-move-dataset/{DANCE_DATASET}/{dance}")
675
+ req = urllib.request.Request(url, method="POST")
676
+ try:
677
+ with urllib.request.urlopen(req, timeout=config.TIMEOUTS['daemon_move']) as resp:
678
+ return json.loads(resp.read().decode())
679
+ except Exception as e:
680
+ return {"error": str(e)}
681
+
682
+ return await loop.run_in_executor(None, do_dance)
683
+
684
+ elif name == "set_head_pose":
685
+ yaw = args.get("yaw", 0)
686
+ pitch = args.get("pitch", 0)
687
+ roll = args.get("roll", 0)
688
+
689
+ def do_move():
690
+ url = config.get_daemon_endpoint("move/set_target")
691
+ data = json.dumps({
692
+ "target_head_pose": {
693
+ "x": 0, "y": 0, "z": 0,
694
+ "roll": math.radians(roll),
695
+ "pitch": math.radians(pitch),
696
+ "yaw": math.radians(yaw),
697
+ }
698
+ }).encode('utf-8')
699
+ req = urllib.request.Request(url, data=data, method="POST")
700
+ req.add_header('Content-Type', 'application/json')
701
+ try:
702
+ with urllib.request.urlopen(req, timeout=config.TIMEOUTS['daemon_move']) as resp:
703
+ return json.loads(resp.read().decode())
704
+ except Exception as e:
705
+ return {"error": str(e)}
706
+
707
+ return await loop.run_in_executor(None, do_move)
708
+
709
+ elif name == "take_snapshot":
710
+ def do_snap():
711
+ url = "http://localhost:8042/api/snapshots/capture"
712
+ req = urllib.request.Request(url, method="POST")
713
+ try:
714
+ with urllib.request.urlopen(req, timeout=5) as resp:
715
+ return json.loads(resp.read().decode())
716
+ except Exception as e:
717
+ return {"error": str(e)}
718
+
719
+ snap_result = await loop.run_in_executor(None, do_snap)
720
+
721
+ # Auto-describe via VLM if configured
722
+ if snap_result.get("filename") and not snap_result.get("error"):
723
+ try:
724
+ from ..settings import load_settings
725
+ settings = load_settings()
726
+ vlm_provider = settings.get("vlm_provider", "")
727
+ vlm_model = settings.get("vlm_model", "")
728
+ api_key = settings.get("api_keys", {}).get(vlm_provider, "")
729
+
730
+ if vlm_provider and vlm_model and api_key:
731
+ import base64
732
+ # Read the snapshot file
733
+ snap_path = config.SNAPSHOTS_DIR / snap_result["filename"]
734
+ if snap_path.exists():
735
+ img_bytes = snap_path.read_bytes()
736
+ b64 = base64.b64encode(img_bytes).decode()
737
+
738
+ import litellm
739
+ vlm_response = await litellm.acompletion(
740
+ model=f"{vlm_provider}/{vlm_model}" if "/" not in vlm_model else vlm_model,
741
+ messages=[{
742
+ "role": "user",
743
+ "content": [
744
+ {"type": "text", "text": "Briefly describe what you see in this image in 1-2 sentences."},
745
+ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
746
+ ]
747
+ }],
748
+ api_key=api_key,
749
+ timeout=15,
750
+ )
751
+ description = vlm_response.choices[0].message.content
752
+ snap_result["description"] = description
753
+ logger.info(f"VLM auto-describe: {description[:100]}")
754
+ except Exception as e:
755
+ logger.warning(f"VLM auto-describe failed: {e}")
756
+ # Graceful fallback — return snapshot without description
757
+
758
+ return snap_result
759
+
760
+ elif name == "get_system_status":
761
+ def do_status():
762
+ url = "http://localhost:8042/api/system/stats"
763
+ try:
764
+ with urllib.request.urlopen(url, timeout=5) as resp:
765
+ return json.loads(resp.read().decode())
766
+ except Exception as e:
767
+ return {"error": str(e)}
768
+
769
+ return await loop.run_in_executor(None, do_status)
770
+
771
+ elif name == "get_date_time":
772
+ import subprocess
773
+ fmt = args.get("format", "")
774
+ cmd = ["/bin/date"] + ([fmt] if fmt else [])
775
+ try:
776
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
777
+ return {"date_time": result.stdout.strip()}
778
+ except Exception as e:
779
+ return {"error": str(e)}
780
+
781
+ elif name == "start_recording":
782
+ def do_fn():
783
+ url = "http://localhost:8042/api/recordings/start"
784
+ data = json.dumps({"duration": args.get("duration")}).encode() if args.get("duration") else None
785
+ req = urllib.request.Request(url, data=data, method="POST")
786
+ if data:
787
+ req.add_header("Content-Type", "application/json")
788
+ try:
789
+ with urllib.request.urlopen(req, timeout=5) as resp:
790
+ return json.loads(resp.read().decode())
791
+ except Exception as e:
792
+ return {"error": str(e)}
793
+ return await loop.run_in_executor(None, do_fn)
794
+
795
+ elif name == "stop_recording":
796
+ def do_fn():
797
+ req = urllib.request.Request("http://localhost:8042/api/recordings/stop", method="POST")
798
+ try:
799
+ with urllib.request.urlopen(req, timeout=5) as resp:
800
+ return json.loads(resp.read().decode())
801
+ except Exception as e:
802
+ return {"error": str(e)}
803
+ return await loop.run_in_executor(None, do_fn)
804
+
805
+ elif name == "start_sound_recording":
806
+ def do_fn():
807
+ url = "http://localhost:8042/api/sounds/start"
808
+ data = json.dumps({"duration": args.get("duration")}).encode() if args.get("duration") else None
809
+ req = urllib.request.Request(url, data=data, method="POST")
810
+ if data:
811
+ req.add_header("Content-Type", "application/json")
812
+ try:
813
+ with urllib.request.urlopen(req, timeout=5) as resp:
814
+ return json.loads(resp.read().decode())
815
+ except Exception as e:
816
+ return {"error": str(e)}
817
+ return await loop.run_in_executor(None, do_fn)
818
+
819
+ elif name == "stop_sound_recording":
820
+ def do_fn():
821
+ req = urllib.request.Request("http://localhost:8042/api/sounds/stop", method="POST")
822
+ try:
823
+ with urllib.request.urlopen(req, timeout=5) as resp:
824
+ return json.loads(resp.read().decode())
825
+ except Exception as e:
826
+ return {"error": str(e)}
827
+ return await loop.run_in_executor(None, do_fn)
828
+
829
+ elif name == "play_music":
830
+ filename = args.get("filename", "")
831
+ def do_fn():
832
+ url = f"http://localhost:8042/api/music/play/{urllib.request.quote(filename, safe='')}"
833
+ req = urllib.request.Request(url, method="POST")
834
+ try:
835
+ with urllib.request.urlopen(req, timeout=5) as resp:
836
+ return json.loads(resp.read().decode())
837
+ except Exception as e:
838
+ return {"error": str(e)}
839
+ return await loop.run_in_executor(None, do_fn)
840
+
841
+ elif name == "stop_music":
842
+ def do_fn():
843
+ req = urllib.request.Request("http://localhost:8042/api/music/stop", method="POST")
844
+ try:
845
+ with urllib.request.urlopen(req, timeout=5) as resp:
846
+ return json.loads(resp.read().decode())
847
+ except Exception as e:
848
+ return {"error": str(e)}
849
+ return await loop.run_in_executor(None, do_fn)
850
+
851
+ elif name == "list_music":
852
+ def do_fn():
853
+ try:
854
+ with urllib.request.urlopen("http://localhost:8042/api/music/list", timeout=5) as resp:
855
+ data = json.loads(resp.read().decode())
856
+ tracks = data.get("tracks", [])
857
+ return {"tracks": [{"filename": t["filename"], "title": t["title"], "artist": t["artist"]} for t in tracks]}
858
+ except Exception as e:
859
+ return {"error": str(e)}
860
+ return await loop.run_in_executor(None, do_fn)
861
+
862
+ return {"error": f"Unknown tool: {name}"}
863
+
864
+
865
+ # ── TTS playback ───────────────────────────────────────────────
866
+
867
+ async def speak_via_robot(app, audio_bytes_wav: bytes):
868
+ """Play TTS audio on robot speaker via GStreamer + ALSA dmix.
869
+
870
+ Uses the stock Reachy Mini audio pipeline: alsasink with the
871
+ reachymini_audio_sink dmix device (defined in ~/.asoundrc).
872
+ dmix allows shared access so both daemon and app can play audio.
873
+ """
874
+ import os
875
+ import subprocess
876
+ import tempfile
877
+
878
+ wav_path = None
879
+ try:
880
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False, dir="/tmp") as f:
881
+ f.write(audio_bytes_wav)
882
+ wav_path = f.name
883
+
884
+ logger.warning(f"TTS robot: playing {len(audio_bytes_wav)} bytes via ALSA dmix")
885
+
886
+ cmd = [
887
+ "gst-launch-1.0", "-q",
888
+ "filesrc", f"location={wav_path}", "!",
889
+ "decodebin", "!",
890
+ "audioconvert", "!",
891
+ "audioresample", "!",
892
+ "audio/x-raw,format=S16LE,rate=16000,channels=2", "!",
893
+ "volume", "volume=2.0", "!",
894
+ "alsasink", "device=reachymini_audio_sink", "buffer-time=50000",
895
+ ]
896
+
897
+ proc = await asyncio.create_subprocess_exec(
898
+ *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
899
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
900
+
901
+ if proc.returncode != 0:
902
+ logger.error(f"TTS GStreamer error (rc={proc.returncode}): {stderr.decode()[:200]}")
903
+ else:
904
+ logger.warning("TTS robot: playback complete")
905
+
906
+ except asyncio.TimeoutError:
907
+ logger.error("TTS GStreamer playback timed out")
908
+ except Exception as e:
909
+ logger.error(f"Robot speaker error: {e}")
910
+ finally:
911
+ if wav_path:
912
+ try:
913
+ os.unlink(wav_path)
914
+ except OSError:
915
+ pass
916
+
917
+
918
+ async def speak_via_browser(app, audio_bytes):
919
+ """Send TTS audio to browser clients via /ws/transcribe WebSocket."""
920
+ import base64
921
+ msg = json.dumps({
922
+ "type": "tts_audio",
923
+ "audio": base64.b64encode(audio_bytes).decode(),
924
+ "format": "wav"
925
+ })
926
+ dead = []
927
+ for ws in app._transcribe_websockets:
928
+ try:
929
+ await ws.send_text(msg)
930
+ except Exception:
931
+ dead.append(ws)
932
+ for ws in dead:
933
+ app._transcribe_websockets.discard(ws)
934
+
935
+
936
+ def _sanitize_for_tts(text: str) -> str:
937
+ """Strip markdown, symbols, and formatting that TTS engines choke on."""
938
+ # Remove markdown bold/italic markers
939
+ text = re.sub(r'\*+', '', text)
940
+ # Remove markdown headers
941
+ text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
942
+ # Remove markdown links [text](url) -> text
943
+ text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
944
+ # Remove markdown code blocks and inline code
945
+ text = re.sub(r'```[^`]*```', '', text, flags=re.DOTALL)
946
+ text = re.sub(r'`([^`]+)`', r'\1', text)
947
+ # Remove bullet points and numbered list markers
948
+ text = re.sub(r'^[\s]*[-•*]\s+', '', text, flags=re.MULTILINE)
949
+ text = re.sub(r'^[\s]*\d+\.\s+', '', text, flags=re.MULTILINE)
950
+ # Remove emoji (Unicode emoji ranges)
951
+ text = re.sub(r'[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF'
952
+ r'\U0001F1E0-\U0001F1FF\U00002702-\U000027B0\U0001F900-\U0001F9FF'
953
+ r'\U0001FA00-\U0001FA6F\U0001FA70-\U0001FAFF\U00002600-\U000026FF]+', '', text)
954
+ # Collapse multiple whitespace/newlines
955
+ text = re.sub(r'\s+', ' ', text).strip()
956
+ return text
957
+
958
+
959
+ # TTS serialisation lock — prevents overlapping speech
960
+ _tts_lock = asyncio.Lock()
961
+
962
+
963
+ async def speak_text(app, text: str, settings: dict):
964
+ """Convert text to speech and route to configured output.
965
+
966
+ Acquires _tts_lock so only one TTS request plays at a time.
967
+ Concurrent callers queue behind the lock (FIFO via asyncio).
968
+ """
969
+ text = _sanitize_for_tts(text)
970
+ if not text:
971
+ return
972
+
973
+ async with _tts_lock:
974
+ await _speak_text_inner(app, text, settings)
975
+
976
+
977
+ async def _speak_text_inner(app, text: str, settings: dict):
978
+ """Inner TTS logic — called under _tts_lock."""
979
+ provider = settings.get("tts_provider", "")
980
+ model = settings.get("tts_model", "")
981
+ voice = settings.get("tts_voice", "alloy")
982
+ logger.warning(f"TTS speak_text: provider={provider!r} model={model!r} voice={voice!r}")
983
+
984
+ if not provider or not model:
985
+ logger.warning("TTS not configured — no provider/model set")
986
+ return
987
+
988
+ api_keys = settings.get("api_keys", {})
989
+ api_key = api_keys.get(provider, "")
990
+ if not api_key:
991
+ logger.warning(f"No API key for TTS provider: {provider}")
992
+ return
993
+
994
+ logger.warning(f"TTS: calling litellm.aspeech for {provider}/{model} voice={voice}")
995
+
996
+ try:
997
+ import litellm
998
+
999
+ # Broadcast speaking status
1000
+ await _broadcast(app, {"type": "speaking_status", "speaking": True, "source": "tts"})
1001
+
1002
+ # Always request WAV for robot output (GStreamer decodes it)
1003
+ # ElevenLabs needs specific format codes
1004
+ if provider == "elevenlabs":
1005
+ response_format = "wav_16000"
1006
+ else:
1007
+ response_format = "wav"
1008
+
1009
+ response = await litellm.aspeech(
1010
+ model=f"{provider}/{model}" if "/" not in model else model,
1011
+ voice=voice,
1012
+ input=text,
1013
+ response_format=response_format,
1014
+ api_key=api_key,
1015
+ )
1016
+ audio_bytes = response.content
1017
+
1018
+ if settings.get("audio_output") == "browser":
1019
+ await speak_via_browser(app, audio_bytes)
1020
+ else:
1021
+ # Pass raw WAV bytes to GStreamer (it handles decoding/resampling)
1022
+ await speak_via_robot(app, audio_bytes)
1023
+
1024
+ except Exception as e:
1025
+ logger.error(f"TTS error: {e}")
1026
+ finally:
1027
+ await _broadcast(app, {"type": "speaking_status", "speaking": False, "source": "tts"})
1028
+
1029
+
1030
+ # ── Broadcasting helper ────────────────────────────────────────
1031
+
1032
+ async def _broadcast(app, message: dict):
1033
+ """Broadcast to all transcript WebSocket clients."""
1034
+ if not hasattr(app, '_transcribe_websockets'):
1035
+ return
1036
+ msg = json.dumps(message)
1037
+ dead = []
1038
+ for ws in app._transcribe_websockets:
1039
+ try:
1040
+ await ws.send_text(msg)
1041
+ except Exception:
1042
+ dead.append(ws)
1043
+ for ws in dead:
1044
+ app._transcribe_websockets.discard(ws)
1045
+
1046
+
1047
+ # ── Provider helpers ───────────────────────────────────────────
1048
+
1049
+ def get_available_providers(settings: dict) -> dict:
1050
+ """Return providers that have API keys configured, grouped by capability.
1051
+
1052
+ Uses live model discovery (cached) to determine actual capabilities.
1053
+ """
1054
+ api_keys = settings.get("api_keys", {})
1055
+ result = {"llm": [], "stt": [], "tts": [], "vlm": []}
1056
+
1057
+ for provider, key in api_keys.items():
1058
+ if not key or provider not in _PROVIDER_APIS:
1059
+ continue
1060
+ discovered = _discover_models(provider, key)
1061
+ caps = discovered["caps"]
1062
+ for cap in ("llm", "stt", "tts"):
1063
+ if caps.get(cap):
1064
+ result[cap].append(provider)
1065
+ # VLM: provider has LLM models AND at least one supports vision
1066
+ if caps.get("llm"):
1067
+ llm_models = discovered["models"].get("llm", [])
1068
+ if any(_model_supports_vision(provider, m) for m in llm_models):
1069
+ result["vlm"].append(provider)
1070
+
1071
+ return result
1072
+
1073
+
1074
+ # ── Route registration ─────────────────────────────────────────
1075
+
1076
+ def register_routes(app) -> None:
1077
+ """Register conversation routes on the app."""
1078
+ from ..settings import load_settings
1079
+
1080
+ @app.settings_app.get("/api/conversation/known-providers")
1081
+ def known_providers():
1082
+ """Return all known providers and their capabilities (for API key entry UI).
1083
+
1084
+ Returns static capability map — shows what each provider *can* offer,
1085
+ even without an API key. Actual model lists come from live discovery.
1086
+ """
1087
+ return _KNOWN_CAPS
1088
+
1089
+ @app.settings_app.get("/api/conversation/providers")
1090
+ def available_providers():
1091
+ """Return providers filtered by configured API keys."""
1092
+ settings = load_settings()
1093
+ return get_available_providers(settings)
1094
+
1095
+ @app.settings_app.get("/api/conversation/models")
1096
+ def get_models(provider: str, capability: str = "llm", web_search: bool = False):
1097
+ """Return models for a provider and capability (discovered from live API).
1098
+
1099
+ Requires an API key for the provider (from settings).
1100
+ If web_search=true, only return models that support web search.
1101
+ """
1102
+ settings = load_settings()
1103
+ api_key = settings.get("api_keys", {}).get(provider, "")
1104
+ if not api_key:
1105
+ return {"provider": provider, "capability": capability, "models": [],
1106
+ "web_search_filter": web_search, "error": "no_api_key"}
1107
+ discovered = _discover_models(provider, api_key)
1108
+ if capability == "vlm":
1109
+ # VLM = LLM models filtered to vision-capable ones
1110
+ models = [m for m in discovered["models"].get("llm", [])
1111
+ if _model_supports_vision(provider, m)]
1112
+ else:
1113
+ models = discovered["models"].get(capability, [])
1114
+ if web_search:
1115
+ models = [m for m in models if _model_supports_web_search(provider, m)]
1116
+ return {"provider": provider, "capability": capability, "models": models,
1117
+ "web_search_filter": web_search}
1118
+
1119
+ @app.settings_app.get("/api/conversation/voices")
1120
+ def get_voices(provider: str, model: str = ""):
1121
+ """Return voices for a TTS provider (discovered dynamically).
1122
+
1123
+ Returns: {"provider": str, "voices": [{"id": str, "name": str}, ...]}
1124
+ The `id` is what gets saved to settings and sent to LiteLLM.
1125
+ The `name` is the display label for the UI.
1126
+ """
1127
+ settings = load_settings()
1128
+ api_key = settings.get("api_keys", {}).get(provider, "")
1129
+ if not model:
1130
+ model = settings.get("tts_model", "")
1131
+ voices = _get_voices_for_provider(provider, api_key, model)
1132
+ return {"provider": provider, "voices": voices}
1133
+
1134
+ @app.settings_app.get("/api/conversation/default-prompt")
1135
+ def default_prompt():
1136
+ """Return the built-in default system prompt template."""
1137
+ return {"prompt": SYSTEM_PROMPT}
1138
+
1139
+ @app.settings_app.get("/api/conversation/web-search-support")
1140
+ def web_search_support(provider: str = "", model: str = ""):
1141
+ """Check if a provider/model combo supports web search."""
1142
+ if not provider or not model:
1143
+ settings = load_settings()
1144
+ provider = provider or settings.get("llm_provider", "")
1145
+ model = model or settings.get("llm_model", "")
1146
+ if not provider or not model:
1147
+ return {"supported": False, "model": ""}
1148
+ supported = _model_supports_web_search(provider, model)
1149
+ return {"supported": supported, "model": f"{provider}/{model}"}
1150
+
1151
+ @app.settings_app.post("/api/conversation/chat")
1152
+ async def chat(body: dict):
1153
+ """Send a message to the LLM with tool calling support.
1154
+
1155
+ Body:
1156
+ text: str - The message text
1157
+ session_id: str - Session identifier (default "default")
1158
+ speak: bool - Whether to TTS the response (default False)
1159
+ confidence: int - Audio confidence percentage (default 100)
1160
+ volume: int - Audio volume percentage (default 50)
1161
+ """
1162
+ text = body.get("text", "").strip()
1163
+ if not text:
1164
+ return {"error": "Empty message"}
1165
+
1166
+ session_id = body.get("session_id", "default")
1167
+ should_speak = body.get("speak", False)
1168
+ confidence = body.get("confidence", 100)
1169
+ volume = body.get("volume", 50)
1170
+
1171
+ settings = load_settings()
1172
+ provider = settings.get("llm_provider", "")
1173
+ model = settings.get("llm_model", "")
1174
+
1175
+ if not provider or not model:
1176
+ return {"error": "LLM provider/model not configured"}
1177
+
1178
+ api_keys = settings.get("api_keys", {})
1179
+ api_key = api_keys.get(provider, "")
1180
+ if not api_key:
1181
+ return {"error": f"No API key for provider: {provider}"}
1182
+
1183
+ import litellm
1184
+
1185
+ # Build messages
1186
+ history = _get_history(session_id)
1187
+ custom_prompt = settings.get("system_prompt", "").strip()
1188
+ prompt_template = custom_prompt if custom_prompt else SYSTEM_PROMPT
1189
+ system_msg = prompt_template.format(conf=confidence, vol=volume)
1190
+ history.append({"role": "user", "content": text})
1191
+ _trim_history(history)
1192
+
1193
+ messages = [{"role": "system", "content": system_msg}] + list(history)
1194
+
1195
+ # LLM model string
1196
+ llm_model = f"{provider}/{model}" if "/" not in model else model
1197
+
1198
+ # Common kwargs for all acompletion calls
1199
+ completion_kwargs = dict(
1200
+ model=llm_model,
1201
+ tools=TOOLS,
1202
+ tool_choice="auto",
1203
+ api_key=api_key,
1204
+ timeout=config.TIMEOUTS['litellm_chat'],
1205
+ )
1206
+
1207
+ # Auto-enable web search if the selected model supports it
1208
+ if _model_supports_web_search(provider, model):
1209
+ completion_kwargs["web_search_options"] = {
1210
+ "search_context_size": "medium",
1211
+ }
1212
+ logger.info(f"Web search enabled for {llm_model}")
1213
+
1214
+ # Our tool names — anything else is provider-managed (e.g. web_search)
1215
+ _OUR_TOOLS = {t["function"]["name"] for t in TOOLS}
1216
+
1217
+ all_tool_calls = []
1218
+ ignored = False
1219
+ response_text = ""
1220
+
1221
+ try:
1222
+ # Initial completion
1223
+ response = await litellm.acompletion(
1224
+ messages=messages,
1225
+ **completion_kwargs,
1226
+ )
1227
+
1228
+ msg = response.choices[0].message
1229
+
1230
+ # Tool call loop
1231
+ while msg.tool_calls:
1232
+ # Capture any text returned alongside tool calls
1233
+ # (Anthropic often returns text + tools in a single response)
1234
+ if msg.content and not response_text:
1235
+ response_text = msg.content
1236
+
1237
+ # Separate our tools from provider-managed ones (e.g. web_search)
1238
+ our_calls = [tc for tc in msg.tool_calls if tc.function.name in _OUR_TOOLS]
1239
+ provider_calls = [tc for tc in msg.tool_calls if tc.function.name not in _OUR_TOOLS]
1240
+
1241
+ if provider_calls:
1242
+ logger.info(f"Provider-managed tools (skipping): {[tc.function.name for tc in provider_calls]}")
1243
+
1244
+ # If only provider tools, no need to execute or loop — text is the answer
1245
+ if not our_calls:
1246
+ break
1247
+
1248
+ # Build assistant message with ONLY our tool calls (strip provider ones)
1249
+ assistant_msg = msg.model_dump()
1250
+ assistant_msg["tool_calls"] = [tc.model_dump() for tc in our_calls]
1251
+ history.append(assistant_msg)
1252
+
1253
+ # Execute our tools
1254
+ for tc in our_calls:
1255
+ fn_name = tc.function.name
1256
+ fn_args = json.loads(tc.function.arguments) if tc.function.arguments else {}
1257
+
1258
+ # Broadcast tool start
1259
+ await _broadcast(app, {
1260
+ "type": "tool",
1261
+ "tool": fn_name,
1262
+ "args": fn_args,
1263
+ "status": "started"
1264
+ })
1265
+
1266
+ result = await _execute_tool(fn_name, fn_args)
1267
+
1268
+ all_tool_calls.append({
1269
+ "name": fn_name,
1270
+ "args": fn_args,
1271
+ "result": result
1272
+ })
1273
+
1274
+ # Broadcast tool result
1275
+ await _broadcast(app, {
1276
+ "type": "tool",
1277
+ "tool": fn_name,
1278
+ "args": fn_args,
1279
+ "result": result,
1280
+ "status": "completed"
1281
+ })
1282
+
1283
+ # Add tool result to history
1284
+ history.append({
1285
+ "role": "tool",
1286
+ "tool_call_id": tc.id,
1287
+ "content": json.dumps(result)
1288
+ })
1289
+
1290
+ # If the only tool call is ignore, treat as ignored
1291
+ if len(our_calls) == 1 and our_calls[0].function.name == "ignore":
1292
+ ignored = True
1293
+ break
1294
+
1295
+ # Only call LLM again if we don't have a text response yet
1296
+ if not response_text:
1297
+ messages = [{"role": "system", "content": system_msg}] + list(history)
1298
+ response = await litellm.acompletion(
1299
+ messages=messages,
1300
+ **completion_kwargs,
1301
+ )
1302
+ msg = response.choices[0].message
1303
+ else:
1304
+ break
1305
+
1306
+ # Extract final response text
1307
+ if not ignored and not response_text and msg.content:
1308
+ response_text = msg.content
1309
+
1310
+ if response_text and not ignored:
1311
+ history.append({"role": "assistant", "content": response_text})
1312
+
1313
+ _trim_history(history)
1314
+
1315
+ # TTS if requested and not ignored
1316
+ if should_speak and response_text and not ignored:
1317
+ asyncio.create_task(speak_text(app, response_text, settings))
1318
+
1319
+ result = {
1320
+ "response": response_text,
1321
+ "tool_calls": all_tool_calls,
1322
+ "ignored": ignored,
1323
+ "provider": provider,
1324
+ "model": model,
1325
+ "session_id": session_id,
1326
+ }
1327
+
1328
+ # Broadcast response
1329
+ if response_text or all_tool_calls:
1330
+ await _broadcast(app, {
1331
+ "type": "assistant_response",
1332
+ "text": response_text,
1333
+ "source": model or provider,
1334
+ "tool_calls": all_tool_calls,
1335
+ "ignored": ignored,
1336
+ })
1337
+
1338
+ return result
1339
+
1340
+ except Exception as e:
1341
+ logger.error(f"Chat error: {e}", exc_info=True)
1342
+ error_msg = str(e)
1343
+ await _broadcast(app, {"type": "error", "error": error_msg, "source": "conversation"})
1344
+ return {"error": error_msg}
1345
+
1346
+ @app.settings_app.post("/api/conversation/reset")
1347
+ async def reset_session(body: dict = {}):
1348
+ """Clear conversation history for a session."""
1349
+ session_id = body.get("session_id", "browser")
1350
+ if session_id in _conversation_histories:
1351
+ _conversation_histories[session_id].clear()
1352
+ logger.info(f"Session '{session_id}' reset")
1353
+ return {"status": "ok", "session_id": session_id}
1354
+
1355
+ @app.settings_app.post("/api/conversation/speak")
1356
+ async def speak_only(body: dict):
1357
+ """TTS-only endpoint — no LLM, just speak text."""
1358
+ text = body.get("text", "").strip()
1359
+ if not text:
1360
+ return {"error": "Empty text"}
1361
+
1362
+ settings = load_settings()
1363
+ await speak_text(app, text, settings)
1364
+ return {"status": "ok", "text": text}
hello_world/api/health.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Health Check API - Service health monitoring endpoints.
3
+
4
+ Endpoints:
5
+ GET /api/health - Overall system health
6
+ GET /api/health/daemon - Reachy daemon status
7
+ GET /api/health/config - Current configuration (for debugging)
8
+ """
9
+
10
+ __all__ = ['register_routes']
11
+
12
+ import asyncio
13
+ import logging
14
+ import urllib.error
15
+ import urllib.request
16
+
17
+ from ..config import config
18
+
19
+ logger = logging.getLogger("reachy_mini.app.hello_world.health")
20
+
21
+
22
+ def _check_http_service(url: str, timeout: float = 2.0) -> dict:
23
+ """Check if an HTTP service is reachable."""
24
+ try:
25
+ req = urllib.request.Request(url, method="GET")
26
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
27
+ return {"status": "healthy", "code": resp.getcode(), "url": url}
28
+ except urllib.error.HTTPError as e:
29
+ return {"status": "unhealthy", "code": e.code, "error": str(e.reason), "url": url}
30
+ except urllib.error.URLError as e:
31
+ return {"status": "unreachable", "error": str(e.reason), "url": url}
32
+ except TimeoutError:
33
+ return {"status": "timeout", "error": f"Request timed out after {timeout}s", "url": url}
34
+ except Exception as e:
35
+ return {"status": "error", "error": str(e), "url": url}
36
+
37
+
38
+ def register_routes(app) -> None:
39
+ """Register health check routes on the app."""
40
+
41
+ timeout = config.TIMEOUTS['health_check']
42
+
43
+ @app.settings_app.get("/api/health")
44
+ async def get_overall_health():
45
+ loop = asyncio.get_event_loop()
46
+
47
+ daemon_result = await loop.run_in_executor(
48
+ None, _check_http_service, f"{config.DAEMON_URL}/api/health", timeout)
49
+
50
+ services = {"daemon": daemon_result}
51
+ healthy_count = sum(1 for s in services.values() if s["status"] == "healthy")
52
+
53
+ # Check LiteLLM provider availability
54
+ from ..settings import load_settings
55
+ settings = load_settings()
56
+ api_keys = settings.get("api_keys", {})
57
+ configured_providers = [k for k, v in api_keys.items() if v]
58
+
59
+ return {
60
+ "status": "healthy" if healthy_count > 0 else "degraded",
61
+ "healthy_services": healthy_count,
62
+ "total_services": len(services),
63
+ "services": services,
64
+ "configured_providers": configured_providers,
65
+ }
66
+
67
+ @app.settings_app.get("/api/health/daemon")
68
+ async def get_daemon_health():
69
+ loop = asyncio.get_event_loop()
70
+ result = await loop.run_in_executor(
71
+ None, _check_http_service, f"{config.DAEMON_URL}/api/health", timeout)
72
+ return {"service": "daemon", **result}
73
+
74
+ @app.settings_app.get("/api/health/config")
75
+ async def get_config_info():
76
+ return {
77
+ "daemon_url": config.DAEMON_URL,
78
+ "media_dir": str(config.MEDIA_BASE_DIR),
79
+ "timeouts": config.TIMEOUTS,
80
+ }
hello_world/api/listener.py ADDED
@@ -0,0 +1,648 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Listener API - Headless voice listener with local VAD and cloud STT.
3
+
4
+ Captures audio from robot mic (arecord) or browser mic (via /ws/intercom),
5
+ runs webrtcvad locally for speech detection, then sends accumulated speech
6
+ to LiteLLM STT for transcription.
7
+
8
+ Endpoints:
9
+ GET /api/listener/status - Get listener status
10
+ POST /api/listener/start - Start the listener
11
+ POST /api/listener/stop - Stop the listener
12
+ POST /api/listener/mute - Mute with optional auto-unmute duration
13
+ """
14
+
15
+ __all__ = ['register_routes', 'feed_audio_chunk', 'is_listener_running']
16
+
17
+ import asyncio
18
+ import io
19
+ import json
20
+ import logging
21
+ import math
22
+ import subprocess
23
+ import threading
24
+ import time
25
+ import wave
26
+ from dataclasses import dataclass
27
+ from typing import Optional
28
+
29
+ import numpy as np
30
+
31
+ from ..config import config
32
+
33
+ logger = logging.getLogger("reachy_mini.app.hello_world.listener")
34
+
35
+ # ── Module state ───────────────────────────────────────────────
36
+
37
+ _listener_task: Optional[asyncio.Task] = None
38
+ _listener_running = False
39
+ _listener_lock = threading.Lock()
40
+ _stop_event: Optional[asyncio.Event] = None
41
+ _app_ref = None
42
+
43
+ _muted = False
44
+ _mute_lock = threading.Lock()
45
+
46
+ # Antenna wiggle state
47
+ _wiggle_pattern_index = 0
48
+
49
+ # Browser mic audio queue (fed from /ws/intercom handler)
50
+ _browser_audio_queue: Optional[asyncio.Queue] = None
51
+
52
+
53
+ def is_listener_running() -> bool:
54
+ return _listener_running
55
+
56
+
57
+ def feed_audio_chunk(pcm_bytes: bytes):
58
+ """Feed PCM audio from browser mic into the listener pipeline.
59
+
60
+ Called from /ws/intercom handler when audio_input is "browser".
61
+ Thread-safe — puts data on an asyncio queue.
62
+ """
63
+ if _browser_audio_queue is not None and _listener_running:
64
+ try:
65
+ _browser_audio_queue.put_nowait(pcm_bytes)
66
+ except asyncio.QueueFull:
67
+ pass # Drop frames if queue backs up
68
+
69
+
70
+ # ── VAD state machine ──────────────────────────────────────────
71
+
72
+ @dataclass
73
+ class QueuedTranscription:
74
+ text: str
75
+ confidence: int
76
+ audio_level: int
77
+ timestamp: float
78
+
79
+
80
+ class VADStateMachine:
81
+ """webrtcvad-based speech detection state machine."""
82
+
83
+ FRAME_DURATION_MS = 30
84
+ FRAME_BYTES = int(16000 * 2 * FRAME_DURATION_MS / 1000) # 960 bytes
85
+ MIN_SPEECH_FRAMES = int(300 / FRAME_DURATION_MS) # 10 frames = 300ms
86
+ SILENCE_FRAMES = int(1000 / FRAME_DURATION_MS) # ~33 frames = 1s
87
+ PRE_BUFFER_FRAMES = 20 # Keep last 600ms of audio before speech detection
88
+
89
+ def __init__(self):
90
+ from collections import deque
91
+ import webrtcvad
92
+ self.vad = webrtcvad.Vad(2) # Aggressiveness 0-3 (2 = balanced)
93
+ self.state = "silence" # silence | speech
94
+ self.speech_count = 0
95
+ self.silence_count = 0
96
+ self.audio_buffer = bytearray()
97
+ self._frame_buffer = bytearray()
98
+ self._pre_buffer = deque(maxlen=self.PRE_BUFFER_FRAMES)
99
+
100
+ def process_audio(self, pcm_bytes: bytes) -> Optional[bytes]:
101
+ """Feed raw PCM and return accumulated speech audio when silence detected.
102
+
103
+ Returns None if still in speech, or bytes of complete utterance.
104
+ """
105
+ self._frame_buffer.extend(pcm_bytes)
106
+
107
+ result = None
108
+
109
+ while len(self._frame_buffer) >= self.FRAME_BYTES:
110
+ frame = bytes(self._frame_buffer[:self.FRAME_BYTES])
111
+ del self._frame_buffer[:self.FRAME_BYTES]
112
+
113
+ is_speech = self.vad.is_speech(frame, 16000)
114
+
115
+ if self.state == "silence":
116
+ self._pre_buffer.append(frame)
117
+ if is_speech:
118
+ self.speech_count += 1
119
+ if self.speech_count >= self.MIN_SPEECH_FRAMES:
120
+ self.state = "speech"
121
+ # Seed audio buffer with pre-buffered frames (captures lead-in)
122
+ self.audio_buffer = bytearray()
123
+ for f in self._pre_buffer:
124
+ self.audio_buffer.extend(f)
125
+ self._pre_buffer.clear()
126
+ self.silence_count = 0
127
+ logger.debug("VAD: Speech started (with %d pre-buffer frames)", len(self.audio_buffer) // self.FRAME_BYTES)
128
+ else:
129
+ self.speech_count = 0
130
+
131
+ elif self.state == "speech":
132
+ self.audio_buffer.extend(frame)
133
+ if not is_speech:
134
+ self.silence_count += 1
135
+ if self.silence_count >= self.SILENCE_FRAMES:
136
+ # Speech ended — return accumulated audio
137
+ result = bytes(self.audio_buffer)
138
+ self.audio_buffer = bytearray()
139
+ self.state = "silence"
140
+ self.speech_count = 0
141
+ self.silence_count = 0
142
+ logger.debug(f"VAD: Speech ended ({len(result)} bytes)")
143
+ else:
144
+ self.silence_count = 0
145
+
146
+ # Always buffer in speech state
147
+ if self.state == "speech":
148
+ pass # Already extending above
149
+
150
+ return result
151
+
152
+
153
+ # ── STT via LiteLLM ───────────────────────────────────────────
154
+
155
+ async def transcribe_audio(audio_buffer: bytes, settings: dict) -> dict:
156
+ """Send accumulated speech audio to LiteLLM STT."""
157
+ provider = settings.get("stt_provider", "openai")
158
+ model = settings.get("stt_model", "whisper-1")
159
+ api_keys = settings.get("api_keys", {})
160
+ api_key = api_keys.get(provider, "")
161
+
162
+ if not api_key:
163
+ return {"error": f"No API key for STT provider: {provider}"}
164
+
165
+ # Create WAV in memory
166
+ wav_buffer = io.BytesIO()
167
+ with wave.open(wav_buffer, 'wb') as wf:
168
+ wf.setnchannels(1)
169
+ wf.setsampwidth(2)
170
+ wf.setframerate(16000)
171
+ wf.writeframes(audio_buffer)
172
+ wav_buffer.seek(0)
173
+
174
+ try:
175
+ import litellm
176
+
177
+ stt_model = f"{provider}/{model}" if "/" not in model else model
178
+ response = await litellm.atranscription(
179
+ model=stt_model,
180
+ file=("audio.wav", wav_buffer),
181
+ api_key=api_key,
182
+ )
183
+ return {"text": response.text}
184
+ except Exception as e:
185
+ logger.error(f"STT error: {e}")
186
+ return {"error": str(e)}
187
+
188
+
189
+ # ── Broadcasting ───────────────────────────────────────────────
190
+
191
+ async def _broadcast(app, message: dict):
192
+ """Broadcast to transcript WebSocket clients."""
193
+ if not hasattr(app, '_transcribe_websockets'):
194
+ return
195
+ msg = json.dumps(message)
196
+ dead = []
197
+ for ws in app._transcribe_websockets:
198
+ try:
199
+ await ws.send_text(msg)
200
+ except Exception:
201
+ dead.append(ws)
202
+ for ws in dead:
203
+ app._transcribe_websockets.discard(ws)
204
+
205
+
206
+ # ── Antenna wiggle ─────────────────────────────────────────────
207
+
208
+ async def _wiggle_antennas():
209
+ """Quick antenna wiggle to acknowledge input."""
210
+ global _wiggle_pattern_index
211
+
212
+ try:
213
+ loop = asyncio.get_event_loop()
214
+ _wiggle_pattern_index = (_wiggle_pattern_index + 1) % 3
215
+ patterns = [(-30, -30), (-30, 10), (10, -30)]
216
+ left_deg, right_deg = patterns[_wiggle_pattern_index]
217
+
218
+ def do_wiggle():
219
+ url = config.get_daemon_endpoint("move/set_target")
220
+ data = json.dumps({
221
+ "target_antennas": [math.radians(left_deg), math.radians(right_deg)]
222
+ }).encode('utf-8')
223
+ req = urllib.request.Request(url, data=data, method="POST")
224
+ req.add_header('Content-Type', 'application/json')
225
+ try:
226
+ urllib.request.urlopen(req, timeout=config.TIMEOUTS['daemon_move'])
227
+ except Exception:
228
+ pass
229
+
230
+ import urllib.request
231
+ await loop.run_in_executor(None, do_wiggle)
232
+ except Exception as e:
233
+ logger.debug(f"Antenna wiggle failed: {e}")
234
+
235
+
236
+ # ── Main listener ──────────────────────────────────────────────
237
+
238
+ async def _run_listener(app, settings: dict):
239
+ """Main listener coroutine."""
240
+ global _listener_running, _stop_event, _browser_audio_queue
241
+
242
+ _stop_event = asyncio.Event()
243
+ _listener_running = True
244
+ _browser_audio_queue = asyncio.Queue(maxsize=200)
245
+ audio_process = None
246
+
247
+ try:
248
+ audio_input = settings.get("audio_input", "robot")
249
+ mic_gain = settings.get("mic_gain", 5.0)
250
+
251
+ logger.warning("Listener: starting (audio_input=%s, gain=%.1f)", audio_input, mic_gain)
252
+
253
+ vad = VADStateMachine()
254
+ logger.warning("Listener: VAD initialized (webrtcvad loaded)")
255
+
256
+ # Transcription queue
257
+ transcription_queue: list[QueuedTranscription] = []
258
+ processing_response = False
259
+ queue_lock = asyncio.Lock()
260
+
261
+ await _broadcast(app, {"type": "listener_status", "running": True})
262
+
263
+ async def process_queued_transcriptions():
264
+ """Process queued transcriptions as a batch."""
265
+ nonlocal processing_response, transcription_queue
266
+
267
+ async with queue_lock:
268
+ if not transcription_queue:
269
+ return
270
+
271
+ if len(transcription_queue) == 1:
272
+ item = transcription_queue[0]
273
+ combined_text = item.text
274
+ avg_conf = item.confidence
275
+ avg_vol = item.audio_level
276
+ else:
277
+ texts = []
278
+ total_conf = 0
279
+ total_vol = 0
280
+ first_ts = transcription_queue[0].timestamp
281
+ for item in transcription_queue:
282
+ elapsed = item.timestamp - first_ts
283
+ texts.append(f"[+{elapsed:.1f}s, conf:{item.confidence}%, vol:{item.audio_level}%] {item.text}")
284
+ total_conf += item.confidence
285
+ total_vol += item.audio_level
286
+ combined_text = "\n".join(texts)
287
+ avg_conf = total_conf // len(transcription_queue)
288
+ avg_vol = total_vol // len(transcription_queue)
289
+
290
+ transcription_queue = []
291
+ processing_response = True
292
+
293
+ try:
294
+ await _wiggle_antennas()
295
+
296
+ # Send to conversation chat
297
+ import urllib.request
298
+ import urllib.error
299
+ loop = asyncio.get_event_loop()
300
+
301
+ def do_chat():
302
+ url = "http://localhost:8042/api/conversation/chat"
303
+ data = json.dumps({
304
+ "text": combined_text,
305
+ "session_id": "headless",
306
+ "speak": True,
307
+ "confidence": avg_conf,
308
+ "volume": avg_vol,
309
+ }).encode('utf-8')
310
+ req = urllib.request.Request(url, data=data, method="POST")
311
+ req.add_header('Content-Type', 'application/json')
312
+ with urllib.request.urlopen(req, timeout=120) as resp:
313
+ return json.loads(resp.read().decode())
314
+
315
+ result = await loop.run_in_executor(None, do_chat)
316
+ logger.info(f"Chat result: ignored={result.get('ignored')}, response={str(result.get('response', ''))[:50]}")
317
+
318
+ except Exception as e:
319
+ logger.error(f"Chat request failed: {e}")
320
+ finally:
321
+ async with queue_lock:
322
+ processing_response = False
323
+ if transcription_queue:
324
+ asyncio.create_task(process_queued_transcriptions())
325
+
326
+ async def audio_source_robot():
327
+ """Read audio from robot mic (arecord)."""
328
+ nonlocal audio_process
329
+
330
+ # Safety: check if arecord already running
331
+ try:
332
+ result = subprocess.run(
333
+ ['pgrep', '-f', 'arecord.*reachymini'],
334
+ capture_output=True, timeout=2
335
+ )
336
+ if result.returncode == 0:
337
+ logger.warning("arecord already running, killing it first")
338
+ subprocess.run(['pkill', '-f', 'arecord.*reachymini'], timeout=2)
339
+ await asyncio.sleep(0.5)
340
+ except Exception:
341
+ pass
342
+
343
+ audio_process = subprocess.Popen(
344
+ ['arecord', '-D', 'reachymini_audio_src', '-f', 'S16_LE', '-r', '16000', '-c', '2', '-t', 'raw'],
345
+ stdout=subprocess.PIPE,
346
+ stderr=subprocess.DEVNULL
347
+ )
348
+ logger.info("Listener: Started arecord")
349
+
350
+ loop = asyncio.get_event_loop()
351
+ waveform_counter = 0
352
+
353
+ while not _stop_event.is_set():
354
+ try:
355
+ data = await asyncio.wait_for(
356
+ loop.run_in_executor(None, lambda: audio_process.stdout.read(3200)),
357
+ timeout=1.0
358
+ )
359
+ if not data:
360
+ break
361
+
362
+ if _muted:
363
+ waveform_counter += 1
364
+ if waveform_counter >= 3:
365
+ waveform_counter = 0
366
+ await _broadcast(app, {
367
+ "type": "waveform", "samples": [0.0] * 32,
368
+ "level": 0, "muted": True
369
+ })
370
+ continue
371
+
372
+ # Stereo to mono
373
+ stereo = np.frombuffer(data, dtype=np.int16)
374
+ mono = stereo[::2]
375
+
376
+ # Apply gain
377
+ if mic_gain != 1.0:
378
+ mono = np.clip(mono.astype(np.float32) * mic_gain, -32768, 32767).astype(np.int16)
379
+
380
+ # Waveform broadcast every 3rd chunk (~10fps)
381
+ waveform_counter += 1
382
+ if waveform_counter >= 3:
383
+ waveform_counter = 0
384
+ rms = np.sqrt(np.mean(mono.astype(np.float32) ** 2))
385
+ level = min(100, int(rms / 100))
386
+ downsampled = mono[::16][:64]
387
+ samples = (np.abs(downsampled) / 5000.0).clip(0, 1).tolist()
388
+ await _broadcast(app, {
389
+ "type": "waveform", "samples": samples, "level": level
390
+ })
391
+
392
+ # Feed to VAD (fire-and-forget so audio loop isn't blocked)
393
+ speech_audio = vad.process_audio(mono.tobytes())
394
+ if speech_audio:
395
+ asyncio.create_task(handle_speech(speech_audio))
396
+
397
+ except asyncio.TimeoutError:
398
+ continue
399
+ except Exception as e:
400
+ logger.error(f"Audio source error: {e}")
401
+ break
402
+
403
+ async def audio_source_browser():
404
+ """Read audio from browser mic via queue (fed from /ws/intercom)."""
405
+ waveform_counter = 0
406
+
407
+ while not _stop_event.is_set():
408
+ try:
409
+ pcm_bytes = await asyncio.wait_for(
410
+ _browser_audio_queue.get(), timeout=1.0
411
+ )
412
+
413
+ if _muted:
414
+ continue
415
+
416
+ mono = np.frombuffer(pcm_bytes, dtype=np.int16)
417
+
418
+ # Apply gain
419
+ if mic_gain != 1.0:
420
+ mono = np.clip(mono.astype(np.float32) * mic_gain, -32768, 32767).astype(np.int16)
421
+
422
+ # Waveform broadcast
423
+ waveform_counter += 1
424
+ if waveform_counter >= 3:
425
+ waveform_counter = 0
426
+ rms = np.sqrt(np.mean(mono.astype(np.float32) ** 2))
427
+ level = min(100, int(rms / 100))
428
+ downsampled = mono[::16][:64]
429
+ samples = (np.abs(downsampled) / 5000.0).clip(0, 1).tolist()
430
+ await _broadcast(app, {
431
+ "type": "waveform", "samples": samples, "level": level
432
+ })
433
+
434
+ speech_audio = vad.process_audio(mono.tobytes())
435
+ if speech_audio:
436
+ asyncio.create_task(handle_speech(speech_audio))
437
+
438
+ except asyncio.TimeoutError:
439
+ continue
440
+ except Exception as e:
441
+ logger.error(f"Browser audio error: {e}")
442
+
443
+ async def handle_speech(audio_bytes: bytes):
444
+ """Handle completed speech segment — transcribe and queue."""
445
+ nonlocal processing_response, transcription_queue
446
+
447
+ # Calculate RMS-based confidence estimate
448
+ audio_np = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32)
449
+ rms = np.sqrt(np.mean(audio_np ** 2))
450
+ audio_level = min(100, int(rms / 100))
451
+ # Rough confidence based on signal quality
452
+ confidence = min(100, int(rms / 50))
453
+
454
+ # Re-read settings for dynamic threshold changes
455
+ from ..settings import load_settings
456
+ current_settings = load_settings()
457
+
458
+ result = await transcribe_audio(audio_bytes, current_settings)
459
+ text = result.get("text", "").strip()
460
+
461
+ if not text:
462
+ return
463
+
464
+ logger.info(f"Transcription: '{text}' conf={confidence} vol={audio_level}")
465
+
466
+ # Check thresholds
467
+ conf_threshold = current_settings.get("conf_threshold", 0)
468
+ vol_threshold = current_settings.get("vol_threshold", 0)
469
+ below_threshold = confidence < conf_threshold or audio_level < vol_threshold
470
+
471
+ # Broadcast to browsers (include threshold result so UI can grey out)
472
+ await _broadcast(app, {
473
+ "type": "transcription",
474
+ "text": text,
475
+ "confidence": confidence,
476
+ "audio_level": audio_level,
477
+ "source": "listener",
478
+ "below_threshold": below_threshold,
479
+ })
480
+
481
+ if below_threshold:
482
+ logger.debug(f"Filtered: conf={confidence}<{conf_threshold} or vol={audio_level}<{vol_threshold}")
483
+ return
484
+
485
+ # Queue transcription
486
+ async with queue_lock:
487
+ transcription_queue.append(QueuedTranscription(
488
+ text=text,
489
+ confidence=confidence,
490
+ audio_level=audio_level,
491
+ timestamp=time.time()
492
+ ))
493
+ if not processing_response:
494
+ asyncio.create_task(process_queued_transcriptions())
495
+
496
+ # Start the appropriate audio source
497
+ if audio_input == "browser":
498
+ logger.info("Listener: Using browser mic input")
499
+ await audio_source_browser()
500
+ else:
501
+ logger.info("Listener: Using robot mic input")
502
+ await audio_source_robot()
503
+
504
+ except Exception as e:
505
+ logger.exception(f"Listener error: {e}")
506
+
507
+ finally:
508
+ _listener_running = False
509
+ _browser_audio_queue = None
510
+
511
+ if audio_process:
512
+ audio_process.terminate()
513
+ try:
514
+ audio_process.wait(timeout=2)
515
+ except subprocess.TimeoutExpired:
516
+ audio_process.kill()
517
+
518
+ try:
519
+ await _broadcast(app, {"type": "listener_status", "running": False})
520
+ except Exception:
521
+ pass
522
+
523
+ logger.info("Listener stopped")
524
+
525
+
526
+ # ── Listener control ───────────────────────────────────────────
527
+
528
+ def get_listener_status() -> dict:
529
+ global _listener_running
530
+
531
+ # Check if the asyncio task is actually alive
532
+ task_alive = _listener_task is not None and not _listener_task.done()
533
+
534
+ # Auto-recover stale state: flag says running but task is dead
535
+ if _listener_running and not task_alive:
536
+ logger.warning("Listener: stale running state detected, resetting")
537
+ _listener_running = False
538
+
539
+ arecord_running = False
540
+ try:
541
+ result = subprocess.run(
542
+ ['pgrep', '-f', 'arecord.*reachymini'],
543
+ capture_output=True, timeout=2
544
+ )
545
+ arecord_running = result.returncode == 0
546
+ except Exception:
547
+ pass
548
+
549
+ return {
550
+ "running": task_alive or arecord_running,
551
+ "task_active": task_alive,
552
+ "arecord_active": arecord_running,
553
+ "muted": _muted
554
+ }
555
+
556
+
557
+ def set_listener_muted(muted: bool) -> bool:
558
+ global _muted
559
+ with _mute_lock:
560
+ old = _muted
561
+ _muted = muted
562
+ if old != muted:
563
+ logger.info(f"Listener mute: {muted}")
564
+ return _muted
565
+
566
+
567
+ def stop_listener() -> bool:
568
+ global _stop_event, _listener_running
569
+ with _listener_lock:
570
+ task_alive = _listener_task is not None and not _listener_task.done()
571
+
572
+ if not task_alive:
573
+ # Task is dead — just reset flag and clean up
574
+ _listener_running = False
575
+ return True
576
+
577
+ if _stop_event:
578
+ _stop_event.set()
579
+ return True
580
+
581
+
582
+ # ── Route registration ─────────────────────────────────────────
583
+
584
+ def register_routes(app) -> None:
585
+ """Register listener routes on the app."""
586
+ from ..settings import load_settings
587
+
588
+ @app.settings_app.get("/api/listener/status")
589
+ def listener_status():
590
+ return get_listener_status()
591
+
592
+ @app.settings_app.post("/api/listener/start")
593
+ async def listener_start():
594
+ global _listener_task, _listener_running
595
+
596
+ # Auto-recover stale state
597
+ task_alive = _listener_task is not None and not _listener_task.done()
598
+ if _listener_running and not task_alive:
599
+ logger.warning("Listener: recovering stale state on start")
600
+ _listener_running = False
601
+
602
+ if _listener_running:
603
+ return {"status": "already_running"}
604
+
605
+ # Check for stale arecord
606
+ try:
607
+ result = subprocess.run(
608
+ ['pgrep', '-f', 'arecord.*reachymini'],
609
+ capture_output=True, timeout=2
610
+ )
611
+ if result.returncode == 0:
612
+ return {"status": "already_running", "note": "arecord process exists"}
613
+ except Exception:
614
+ pass
615
+
616
+ settings = load_settings()
617
+ loop = asyncio.get_running_loop()
618
+ _listener_task = loop.create_task(_run_listener(app, settings))
619
+
620
+ # Log unhandled task exceptions (asyncio swallows them otherwise)
621
+ def _on_listener_done(task):
622
+ global _listener_running
623
+ _listener_running = False
624
+ if task.cancelled():
625
+ logger.warning("Listener: task cancelled")
626
+ elif task.exception():
627
+ logger.error("Listener: task crashed: %s", task.exception())
628
+ _listener_task.add_done_callback(_on_listener_done)
629
+
630
+ return {"status": "started"}
631
+
632
+ @app.settings_app.post("/api/listener/stop")
633
+ async def listener_stop():
634
+ if not _listener_running:
635
+ return {"status": "not_running"}
636
+ stop_listener()
637
+ await asyncio.sleep(0.5)
638
+ return {"status": "stopped"}
639
+
640
+ @app.settings_app.post("/api/listener/mute")
641
+ async def listener_mute(muted: bool = True, duration: float = 0):
642
+ set_listener_muted(muted)
643
+ if muted and duration > 0:
644
+ async def auto_unmute():
645
+ await asyncio.sleep(duration)
646
+ set_listener_muted(False)
647
+ asyncio.create_task(auto_unmute())
648
+ return {"muted": _muted, "duration": duration}
hello_world/api/model.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model API - Serves robot model files from the reachy_mini package.
3
+
4
+ Supports both MJCF (MuJoCo) and URDF model formats with their mesh assets.
5
+
6
+ Endpoints:
7
+ GET /api/model/mjcf - Robot MJCF XML model
8
+ GET /api/model/meshes - List of mesh file paths (relative to assets/)
9
+ GET /api/model/mesh/{path} - Individual mesh file (binary STL)
10
+ GET /api/model/urdf/{path} - URDF files and meshes (robot.urdf, assets/*.stl)
11
+ """
12
+
13
+ __all__ = ['register_routes']
14
+
15
+ import logging
16
+ from pathlib import Path
17
+
18
+ from fastapi.responses import Response
19
+
20
+ logger = logging.getLogger("reachy_mini.app.hello_world.model")
21
+
22
+ # Resolve model directories from the reachy_mini package
23
+ MODEL_DIR = None # MJCF directory
24
+ URDF_DIR = None # URDF directory
25
+
26
+ try:
27
+ import reachy_mini
28
+ pkg_dir = Path(reachy_mini.__file__).parent
29
+ desc_dir = pkg_dir / "descriptions" / "reachy_mini"
30
+
31
+ mjcf = desc_dir / "mjcf"
32
+ if mjcf.exists():
33
+ MODEL_DIR = mjcf
34
+ logger.info(f"MJCF directory: {MODEL_DIR}")
35
+
36
+ urdf = desc_dir / "urdf"
37
+ if urdf.exists():
38
+ URDF_DIR = urdf
39
+ logger.info(f"URDF directory: {URDF_DIR}")
40
+ except ImportError:
41
+ logger.warning("reachy_mini package not found - model endpoints will return 503")
42
+
43
+ # Fallback paths for source installs
44
+ if MODEL_DIR is None:
45
+ fallback = Path("/venvs/src/reachy_mini/src/reachy_mini/descriptions/reachy_mini/mjcf")
46
+ if fallback.exists():
47
+ MODEL_DIR = fallback
48
+ logger.info(f"MJCF directory (fallback): {MODEL_DIR}")
49
+
50
+ if URDF_DIR is None:
51
+ fallback = Path("/venvs/src/reachy_mini/src/reachy_mini/descriptions/reachy_mini/urdf")
52
+ if fallback.exists():
53
+ URDF_DIR = fallback
54
+ logger.info(f"URDF directory (fallback): {URDF_DIR}")
55
+
56
+ # MIME types for model files
57
+ MIME_TYPES = {
58
+ '.urdf': 'application/xml',
59
+ '.xml': 'application/xml',
60
+ '.stl': 'application/octet-stream',
61
+ '.glb': 'model/gltf-binary',
62
+ '.wasm': 'application/wasm',
63
+ }
64
+
65
+
66
+ def register_routes(app) -> None:
67
+ """Register model file-serving routes on the app."""
68
+
69
+ @app.settings_app.get("/api/model/mjcf")
70
+ def get_mjcf():
71
+ """Serve MJCF model XML from reachy_mini package."""
72
+ if MODEL_DIR is None:
73
+ return Response(content="Model directory not found", status_code=503)
74
+ mjcf_path = MODEL_DIR / "reachy_mini.xml"
75
+ if not mjcf_path.exists():
76
+ return Response(content="MJCF file not found", status_code=404)
77
+ return Response(content=mjcf_path.read_bytes(), media_type="application/xml")
78
+
79
+ @app.settings_app.get("/api/model/meshes")
80
+ def get_meshes():
81
+ """List all mesh files available in the assets directory."""
82
+ if MODEL_DIR is None:
83
+ return {"meshes": [], "error": "Model directory not found"}
84
+ assets_dir = MODEL_DIR / "assets"
85
+ if not assets_dir.exists():
86
+ return {"meshes": [], "error": "Assets directory not found"}
87
+ mesh_files = []
88
+ for stl in sorted(assets_dir.rglob("*.stl")):
89
+ mesh_files.append(str(stl.relative_to(assets_dir)))
90
+ return {"meshes": mesh_files}
91
+
92
+ @app.settings_app.get("/api/model/mesh/{path:path}")
93
+ def get_mesh(path: str):
94
+ """Serve an individual mesh file from the assets directory."""
95
+ if MODEL_DIR is None:
96
+ return Response(content="Model directory not found", status_code=503)
97
+ assets_dir = MODEL_DIR / "assets"
98
+ mesh_path = (assets_dir / path).resolve()
99
+ # Security: ensure resolved path stays within assets directory
100
+ if not str(mesh_path).startswith(str(assets_dir.resolve())):
101
+ return Response(content="Invalid path", status_code=403)
102
+ if not mesh_path.exists():
103
+ return Response(content=f"Mesh not found: {path}", status_code=404)
104
+ return Response(content=mesh_path.read_bytes(), media_type="application/octet-stream")
105
+
106
+ @app.settings_app.get("/api/model/urdf/{path:path}")
107
+ def get_urdf_file(path: str):
108
+ """Serve files from the URDF directory (robot.urdf, assets/*.stl)."""
109
+ if URDF_DIR is None:
110
+ return Response(content="URDF directory not found", status_code=503)
111
+ file_path = (URDF_DIR / path).resolve()
112
+ # Security: ensure resolved path stays within URDF directory
113
+ if not str(file_path).startswith(str(URDF_DIR.resolve())):
114
+ return Response(content="Invalid path", status_code=403)
115
+ if not file_path.exists():
116
+ return Response(content=f"File not found: {path}", status_code=404)
117
+ suffix = file_path.suffix.lower()
118
+ media_type = MIME_TYPES.get(suffix, 'application/octet-stream')
119
+ return Response(content=file_path.read_bytes(), media_type=media_type)
hello_world/api/music.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Music API - Music library management and playback.
3
+
4
+ Endpoints:
5
+ GET /api/music/list - List music with metadata (title, artist, duration, size)
6
+ GET /api/music/file/{filename} - Serve audio file
7
+ GET /api/music/cover/{filename} - Extract & serve album cover art
8
+ POST /api/music/upload - Upload music file
9
+ DELETE /api/music/{filename} - Delete music file
10
+ POST /api/music/play/{filename} - Play via GStreamer on robot speaker
11
+ POST /api/music/stop - Stop playback
12
+ """
13
+
14
+ __all__ = ['register_routes']
15
+
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+ from ..config import config, validate_path_in_directory
23
+
24
+ logger = logging.getLogger("reachy_mini.app.hello_world.music")
25
+
26
+ MUSIC_DIR: Path = config.MUSIC_DIR
27
+ ALLOWED_EXTENSIONS = {'.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac'}
28
+ CACHE_FILE = ".metadata_cache.json"
29
+
30
+
31
+ def _load_metadata_cache() -> dict:
32
+ cache_path = MUSIC_DIR / CACHE_FILE
33
+ if cache_path.exists():
34
+ try:
35
+ return json.loads(cache_path.read_text())
36
+ except Exception:
37
+ pass
38
+ return {}
39
+
40
+
41
+ def _save_metadata_cache(cache: dict):
42
+ try:
43
+ (MUSIC_DIR / CACHE_FILE).write_text(json.dumps(cache))
44
+ except Exception as e:
45
+ logger.warning(f"Failed to save metadata cache: {e}")
46
+
47
+
48
+ def _extract_metadata(filepath: Path) -> dict:
49
+ """Extract title, artist, duration, and cover art presence from a music file."""
50
+ meta = {"title": filepath.stem, "artist": "", "duration": None, "has_cover": False}
51
+ try:
52
+ import mutagen
53
+ tag = mutagen.File(filepath)
54
+ if tag is None:
55
+ return meta
56
+ if tag.info and hasattr(tag.info, 'length'):
57
+ meta["duration"] = round(tag.info.length, 1)
58
+
59
+ # ID3 tags (MP3)
60
+ if hasattr(tag, 'tags') and tag.tags:
61
+ tags = tag.tags
62
+ # MP3 ID3
63
+ if hasattr(tags, 'getall'):
64
+ tit = tags.getall('TIT2')
65
+ if tit:
66
+ meta["title"] = str(tit[0])
67
+ art = tags.getall('TPE1')
68
+ if art:
69
+ meta["artist"] = str(art[0])
70
+ meta["has_cover"] = bool(tags.getall('APIC'))
71
+ # FLAC/OGG Vorbis comments
72
+ elif isinstance(tags, dict) or hasattr(tags, 'get'):
73
+ meta["title"] = str((tags.get('title') or [filepath.stem])[0])
74
+ meta["artist"] = str((tags.get('artist') or [''])[0])
75
+
76
+ # FLAC pictures
77
+ if hasattr(tag, 'pictures'):
78
+ meta["has_cover"] = len(tag.pictures) > 0
79
+ # M4A/MP4 cover
80
+ if hasattr(tag, 'tags') and tag.tags and hasattr(tag.tags, 'get'):
81
+ if tag.tags.get('covr'):
82
+ meta["has_cover"] = True
83
+
84
+ except Exception as e:
85
+ logger.debug(f"Metadata extraction failed for {filepath.name}: {e}")
86
+ return meta
87
+
88
+
89
+ def _get_music_metadata(filepath: Path, cache: dict) -> dict:
90
+ """Get metadata with caching based on filename + mtime."""
91
+ key = filepath.name
92
+ mtime = filepath.stat().st_mtime
93
+ cached = cache.get(key)
94
+ if cached and cached.get("mtime") == mtime:
95
+ return cached
96
+
97
+ meta = _extract_metadata(filepath)
98
+ meta["mtime"] = mtime
99
+ cache[key] = meta
100
+ return meta
101
+
102
+
103
+ def register_routes(app) -> None:
104
+ """Register music routes on the app."""
105
+
106
+ if not hasattr(app, '_music_process'):
107
+ app._music_process = None
108
+ app._music_playing = None
109
+
110
+ @app.settings_app.get("/api/music/list")
111
+ async def list_music():
112
+ """List all music files with metadata."""
113
+ MUSIC_DIR.mkdir(parents=True, exist_ok=True)
114
+ cache = _load_metadata_cache()
115
+ tracks = []
116
+ changed = False
117
+
118
+ for f in sorted(MUSIC_DIR.iterdir()):
119
+ if f.suffix.lower() in ALLOWED_EXTENSIONS and not f.name.startswith('.'):
120
+ old_cached = cache.get(f.name)
121
+ meta = _get_music_metadata(f, cache)
122
+ if meta != old_cached:
123
+ changed = True
124
+ tracks.append({
125
+ "filename": f.name,
126
+ "title": meta.get("title", f.stem),
127
+ "artist": meta.get("artist", ""),
128
+ "duration": meta.get("duration"),
129
+ "size": f.stat().st_size,
130
+ "has_cover": meta.get("has_cover", False),
131
+ })
132
+
133
+ if changed:
134
+ _save_metadata_cache(cache)
135
+
136
+ return {
137
+ "tracks": tracks,
138
+ "playing": app._music_playing,
139
+ }
140
+
141
+ @app.settings_app.get("/api/music/file/{filename}")
142
+ async def get_music_file(filename: str):
143
+ """Serve a music file."""
144
+ from fastapi.responses import FileResponse
145
+ from fastapi import HTTPException
146
+ try:
147
+ filepath = validate_path_in_directory(MUSIC_DIR, filename)
148
+ except ValueError:
149
+ raise HTTPException(status_code=400, detail="Invalid filename")
150
+ if not filepath.exists() or filepath.suffix.lower() not in ALLOWED_EXTENSIONS:
151
+ raise HTTPException(status_code=404, detail="File not found")
152
+ media_types = {
153
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
154
+ '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.aac': 'audio/aac',
155
+ }
156
+ return FileResponse(filepath, media_type=media_types.get(filepath.suffix.lower(), 'application/octet-stream'))
157
+
158
+ @app.settings_app.get("/api/music/cover/{filename}")
159
+ async def get_music_cover(filename: str):
160
+ """Extract and serve album cover art."""
161
+ from fastapi.responses import Response
162
+ from fastapi import HTTPException
163
+ try:
164
+ filepath = validate_path_in_directory(MUSIC_DIR, filename)
165
+ except ValueError:
166
+ raise HTTPException(status_code=400, detail="Invalid filename")
167
+ if not filepath.exists() or filepath.suffix.lower() not in ALLOWED_EXTENSIONS:
168
+ raise HTTPException(status_code=404, detail="File not found")
169
+
170
+ try:
171
+ import mutagen
172
+ tag = mutagen.File(filepath)
173
+ if tag is None:
174
+ raise HTTPException(status_code=404, detail="No metadata")
175
+
176
+ # MP3 APIC
177
+ if hasattr(tag, 'tags') and tag.tags and hasattr(tag.tags, 'getall'):
178
+ apic = tag.tags.getall('APIC')
179
+ if apic:
180
+ return Response(content=apic[0].data, media_type=apic[0].mime)
181
+ # FLAC pictures
182
+ if hasattr(tag, 'pictures') and tag.pictures:
183
+ pic = tag.pictures[0]
184
+ return Response(content=pic.data, media_type=pic.mime)
185
+ # M4A covr
186
+ if hasattr(tag, 'tags') and tag.tags and hasattr(tag.tags, 'get'):
187
+ covr = tag.tags.get('covr')
188
+ if covr:
189
+ return Response(content=bytes(covr[0]), media_type="image/jpeg")
190
+ except HTTPException:
191
+ raise
192
+ except Exception as e:
193
+ logger.debug(f"Cover extraction failed for {filename}: {e}")
194
+
195
+ raise HTTPException(status_code=404, detail="No cover art")
196
+
197
+ from starlette.requests import Request as _Request
198
+
199
+ @app.settings_app.post("/api/music/upload")
200
+ async def upload_music(request: _Request):
201
+ """Upload a music file via multipart form."""
202
+ MUSIC_DIR.mkdir(parents=True, exist_ok=True)
203
+
204
+ form = await request.form()
205
+ file = form.get('file')
206
+ if not file or not file.filename:
207
+ return {"error": "No file provided", "status": "failed"}
208
+
209
+ ext = Path(file.filename).suffix.lower()
210
+ if ext not in ALLOWED_EXTENSIONS:
211
+ return {"error": f"Unsupported format: {ext}", "status": "failed"}
212
+
213
+ try:
214
+ filepath = validate_path_in_directory(MUSIC_DIR, file.filename)
215
+ except ValueError:
216
+ return {"error": "Invalid filename", "status": "failed"}
217
+
218
+ MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50MB
219
+ content = await file.read()
220
+ if len(content) > MAX_UPLOAD_SIZE:
221
+ return {"error": f"File too large (max {MAX_UPLOAD_SIZE // 1024 // 1024}MB)", "status": "failed"}
222
+
223
+ filepath.write_bytes(content)
224
+ logger.info(f"Uploaded music: {file.filename} ({len(content)} bytes)")
225
+ return {"status": "ok", "filename": file.filename, "size": len(content)}
226
+
227
+ @app.settings_app.delete("/api/music/{filename}")
228
+ async def delete_music(filename: str):
229
+ """Delete a music file."""
230
+ try:
231
+ filepath = validate_path_in_directory(MUSIC_DIR, filename)
232
+ except ValueError:
233
+ return {"error": "Invalid filename", "status": "failed"}
234
+ if not filepath.exists() or filepath.suffix.lower() not in ALLOWED_EXTENSIONS:
235
+ return {"error": "File not found", "status": "failed"}
236
+ try:
237
+ filepath.unlink()
238
+ # Remove from cache
239
+ cache = _load_metadata_cache()
240
+ cache.pop(filename, None)
241
+ _save_metadata_cache(cache)
242
+ return {"status": "ok", "deleted": filename}
243
+ except Exception as e:
244
+ return {"error": str(e), "status": "failed"}
245
+
246
+ @app.settings_app.post("/api/music/play/{filename}")
247
+ async def play_music(filename: str):
248
+ """Play a music file via GStreamer on the robot speaker."""
249
+ try:
250
+ filepath = validate_path_in_directory(MUSIC_DIR, filename)
251
+ except ValueError:
252
+ return {"error": "Invalid filename", "status": "failed"}
253
+ if not filepath.exists() or filepath.suffix.lower() not in ALLOWED_EXTENSIONS:
254
+ return {"error": "File not found", "status": "failed"}
255
+
256
+ # Stop any current playback
257
+ if app._music_process and app._music_process.poll() is None:
258
+ app._music_process.terminate()
259
+ try:
260
+ app._music_process.wait(timeout=2)
261
+ except subprocess.TimeoutExpired:
262
+ app._music_process.kill()
263
+
264
+ # Calculate volume: (master/100) * (music/100) * 3.0 gain boost
265
+ from ..settings import load_settings
266
+ settings = load_settings()
267
+ master = settings.get("master_volume", 70) / 100.0
268
+ music = settings.get("music_volume", 80) / 100.0
269
+ volume = master * music * 3.0
270
+
271
+ cmd = [
272
+ "gst-launch-1.0", "-q",
273
+ "filesrc", f"location={filepath}", "!",
274
+ "decodebin", "!",
275
+ "audioconvert", "!",
276
+ "audioresample", "!",
277
+ "audio/x-raw,rate=16000,channels=2", "!",
278
+ "volume", f"volume={volume:.2f}", "!",
279
+ "alsasink", "device=reachymini_audio_sink", "buffer-time=100000",
280
+ ]
281
+
282
+ try:
283
+ app._music_process = subprocess.Popen(
284
+ cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
285
+ app._music_playing = filename
286
+ logger.info(f"Playing music: {filename} (volume={volume:.2f})")
287
+
288
+ # Background task to clear state when playback ends
289
+ async def _watch():
290
+ while app._music_process and app._music_process.poll() is None:
291
+ await asyncio.sleep(1)
292
+ if app._music_playing == filename:
293
+ app._music_playing = None
294
+ asyncio.create_task(_watch())
295
+
296
+ return {"status": "ok", "playing": filename, "volume": round(volume, 2)}
297
+ except Exception as e:
298
+ logger.error(f"Music playback error: {e}")
299
+ return {"error": str(e), "status": "failed"}
300
+
301
+ @app.settings_app.post("/api/music/stop")
302
+ async def stop_music():
303
+ """Stop music playback."""
304
+ if app._music_process and app._music_process.poll() is None:
305
+ app._music_process.terminate()
306
+ try:
307
+ app._music_process.wait(timeout=2)
308
+ except subprocess.TimeoutExpired:
309
+ app._music_process.kill()
310
+ app._music_process = None
311
+ playing = app._music_playing
312
+ app._music_playing = None
313
+ logger.info(f"Stopped music: {playing}")
314
+ return {"status": "ok", "stopped": playing}
315
+ app._music_playing = None
316
+ return {"status": "ok", "message": "Nothing was playing"}
hello_world/api/recordings.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Recordings API - Video recording capture and management.
3
+
4
+ Endpoints:
5
+ POST /api/recordings/start - Start video recording
6
+ POST /api/recordings/stop - Stop video recording
7
+ POST /api/recordings/upload - Upload a recording file
8
+ GET /api/recordings/list - List all recordings
9
+ DELETE /api/recordings/{filename} - Delete a recording
10
+ GET /api/recordings/{filename}/thumbnail - Get recording thumbnail
11
+ """
12
+
13
+ __all__ = ['register_routes']
14
+
15
+ import logging
16
+ import subprocess
17
+ import threading
18
+ import time
19
+ from pathlib import Path
20
+
21
+ from ..config import config, validate_path_in_directory
22
+
23
+ logger = logging.getLogger("reachy_mini.app.hello_world.recordings")
24
+
25
+ RECORDINGS_DIR: Path = config.RECORDINGS_DIR
26
+
27
+
28
+ def register_routes(app) -> None:
29
+ """Register recording routes on the app."""
30
+
31
+ # Initialize recording state on app if not present
32
+ if not hasattr(app, '_recording_active'):
33
+ app._recording_active = False
34
+ app._recording_thread = None
35
+ app._recording_writer = None
36
+ app._recording_filename = None
37
+ app._recording_start_time = None
38
+ app._recording_first_frame = None
39
+
40
+ def _recording_loop():
41
+ """Background thread that captures frames and writes to video file."""
42
+ import cv2
43
+ while app._recording_active and app._recording_writer:
44
+ try:
45
+ frame = app._reachy_mini.media.get_frame()
46
+ if frame is not None:
47
+ h, w = frame.shape[:2]
48
+ if w > 640:
49
+ scale = 640 / w
50
+ frame = cv2.resize(frame, (640, int(h * scale)))
51
+ app._recording_writer.write(frame)
52
+ if app._recording_first_frame is None:
53
+ app._recording_first_frame = frame.copy()
54
+ time.sleep(0.066) # ~15 fps
55
+ except Exception as e:
56
+ logger.error(f"Recording loop error: {e}")
57
+ break
58
+
59
+ @app.settings_app.post("/api/recordings/start")
60
+ async def start_recording(duration: int = 0):
61
+ """Start video recording using SDK media frames.
62
+
63
+ Args:
64
+ duration: Recording duration in seconds. 0 = indefinite.
65
+ """
66
+ from datetime import datetime
67
+ import cv2
68
+ if app._recording_active:
69
+ return {"error": "Recording already in progress", "status": "failed"}
70
+
71
+ if app._reachy_mini is None:
72
+ return {"error": "Robot not connected", "status": "failed"}
73
+
74
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
75
+ filename = f"recording_{timestamp}.mp4"
76
+ filepath = RECORDINGS_DIR / filename
77
+
78
+ try:
79
+ RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
80
+
81
+ test_frame = app._reachy_mini.media.get_frame()
82
+ if test_frame is None:
83
+ return {"error": "Camera not available", "status": "failed"}
84
+
85
+ h, w = test_frame.shape[:2]
86
+ if w > 640:
87
+ scale = 640 / w
88
+ w, h = 640, int(h * scale)
89
+
90
+ # Initialize video writer - try H.264 first for browser compatibility
91
+ for codec in ['avc1', 'mp4v', 'XVID']:
92
+ fourcc = cv2.VideoWriter_fourcc(*codec)
93
+ app._recording_writer = cv2.VideoWriter(str(filepath), fourcc, 15.0, (w, h))
94
+ if app._recording_writer.isOpened():
95
+ break
96
+ app._recording_writer = None
97
+
98
+ if app._recording_writer is None or not app._recording_writer.isOpened():
99
+ app._recording_writer = None
100
+ return {"error": "Failed to initialize video writer", "status": "failed"}
101
+
102
+ app._recording_active = True
103
+ app._recording_filename = filename
104
+ app._recording_start_time = time.time()
105
+ app._recording_first_frame = None
106
+
107
+ app._recording_thread = threading.Thread(target=_recording_loop, daemon=True)
108
+ app._recording_thread.start()
109
+
110
+ # Schedule auto-stop if duration specified
111
+ if duration > 0:
112
+ import asyncio
113
+ async def auto_stop():
114
+ await asyncio.sleep(duration)
115
+ if app._recording_active:
116
+ logger.info(f"Auto-stopping recording after {duration}s")
117
+ await stop_recording()
118
+ asyncio.create_task(auto_stop())
119
+
120
+ return {"status": "ok", "filename": filename, "duration": duration, "message": "Recording started"}
121
+ except Exception as e:
122
+ logger.exception(f"Recording start error: {e}")
123
+ return {"error": str(e), "status": "failed"}
124
+
125
+ @app.settings_app.post("/api/recordings/stop")
126
+ async def stop_recording():
127
+ """Stop video recording and generate thumbnail."""
128
+ import cv2
129
+ if not app._recording_active:
130
+ return {"error": "No recording in progress", "status": "failed"}
131
+
132
+ try:
133
+ app._recording_active = False
134
+ if app._recording_thread:
135
+ app._recording_thread.join(timeout=2)
136
+ app._recording_thread = None
137
+
138
+ if app._recording_writer:
139
+ app._recording_writer.release()
140
+ app._recording_writer = None
141
+
142
+ duration = time.time() - app._recording_start_time if app._recording_start_time else 0
143
+ filename = app._recording_filename
144
+
145
+ # Generate thumbnail from first captured frame
146
+ if app._recording_first_frame is not None:
147
+ try:
148
+ thumb_path = RECORDINGS_DIR / filename.replace('.mp4', '_thumb.jpg')
149
+ frame = app._recording_first_frame
150
+ h, w = frame.shape[:2]
151
+ thumb_w = 320
152
+ frame = cv2.resize(frame, (thumb_w, int(h * thumb_w / w)))
153
+ cv2.imwrite(str(thumb_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
154
+ except Exception as e:
155
+ logger.warning(f"Thumbnail generation failed: {e}")
156
+
157
+ app._recording_filename = None
158
+ app._recording_start_time = None
159
+ app._recording_first_frame = None
160
+
161
+ return {"status": "ok", "filename": filename, "duration": round(duration, 1), "message": "Recording stopped"}
162
+ except Exception as e:
163
+ logger.exception(f"Recording stop error: {e}")
164
+ app._recording_active = False
165
+ app._recording_thread = None
166
+ app._recording_writer = None
167
+ app._recording_filename = None
168
+ app._recording_start_time = None
169
+ app._recording_first_frame = None
170
+ return {"error": str(e), "status": "failed"}
171
+
172
+ @app.settings_app.post("/api/recordings/upload")
173
+ async def upload_recording(request):
174
+ """Upload client-side recorded video."""
175
+ from datetime import datetime
176
+ import cv2
177
+ from fastapi import Request
178
+
179
+ try:
180
+ form = await request.form()
181
+ file = form.get('file')
182
+ duration = float(form.get('duration', 0))
183
+
184
+ if not file:
185
+ return {"error": "No file provided", "status": "failed"}
186
+
187
+ MAX_UPLOAD_SIZE = 100 * 1024 * 1024
188
+ content = await file.read()
189
+ if len(content) > MAX_UPLOAD_SIZE:
190
+ return {"error": f"File too large (max {MAX_UPLOAD_SIZE // 1024 // 1024}MB)", "status": "failed"}
191
+
192
+ RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
193
+
194
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
195
+ webm_filename = f"recording_{timestamp}_client.webm"
196
+ mp4_filename = f"recording_{timestamp}.mp4"
197
+ webm_path = RECORDINGS_DIR / webm_filename
198
+ mp4_path = RECORDINGS_DIR / mp4_filename
199
+
200
+ webm_path.write_bytes(content)
201
+ logger.info(f"Uploaded client recording: {webm_filename} ({len(content)} bytes)")
202
+
203
+ # Convert webm to mp4 using ffmpeg
204
+ try:
205
+ result = subprocess.run([
206
+ "ffmpeg", "-y",
207
+ "-i", str(webm_path),
208
+ "-c:v", "libx264", "-preset", "fast", "-crf", "23",
209
+ "-c:a", "aac", "-b:a", "128k",
210
+ str(mp4_path)
211
+ ], capture_output=True, timeout=60)
212
+
213
+ if result.returncode == 0 and mp4_path.exists():
214
+ webm_path.unlink()
215
+ final_filename = mp4_filename
216
+ logger.info(f"Converted to mp4: {mp4_filename}")
217
+
218
+ # Generate thumbnail from first frame
219
+ try:
220
+ cap = cv2.VideoCapture(str(mp4_path))
221
+ ret, frame = cap.read()
222
+ cap.release()
223
+ if ret:
224
+ thumb_path = RECORDINGS_DIR / mp4_filename.replace('.mp4', '_thumb.jpg')
225
+ h, w = frame.shape[:2]
226
+ thumb_w = 320
227
+ frame = cv2.resize(frame, (thumb_w, int(h * thumb_w / w)))
228
+ cv2.imwrite(str(thumb_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
229
+ except Exception as e:
230
+ logger.warning(f"Thumbnail generation failed: {e}")
231
+ else:
232
+ final_filename = webm_filename
233
+ logger.warning("ffmpeg conversion failed, keeping webm")
234
+ except Exception as e:
235
+ final_filename = webm_filename
236
+ logger.warning(f"Conversion failed: {e}")
237
+
238
+ return {"status": "ok", "filename": final_filename, "duration": round(duration, 1)}
239
+ except Exception as e:
240
+ logger.exception(f"Upload error: {e}")
241
+ return {"error": str(e), "status": "failed"}
242
+
243
+ @app.settings_app.get("/api/recordings/list")
244
+ async def list_recordings():
245
+ """List all recordings with duration."""
246
+ import cv2
247
+ try:
248
+ if not RECORDINGS_DIR.exists():
249
+ return {"recordings": []}
250
+ recordings = []
251
+ for f in sorted(RECORDINGS_DIR.glob("*.mp4"), key=lambda x: x.stat().st_mtime, reverse=True):
252
+ duration = 0
253
+ try:
254
+ cap = cv2.VideoCapture(str(f))
255
+ fps = cap.get(cv2.CAP_PROP_FPS)
256
+ frames = cap.get(cv2.CAP_PROP_FRAME_COUNT)
257
+ cap.release()
258
+ if fps > 0:
259
+ duration = round(frames / fps, 1)
260
+ except Exception:
261
+ pass
262
+ recordings.append({
263
+ "filename": f.name,
264
+ "size": f.stat().st_size,
265
+ "modified": f.stat().st_mtime,
266
+ "duration": duration
267
+ })
268
+ return {"recordings": recordings, "recording_active": app._recording_active}
269
+ except Exception as e:
270
+ return {"error": str(e), "recordings": []}
271
+
272
+ @app.settings_app.delete("/api/recordings/{filename}")
273
+ async def delete_recording(filename: str):
274
+ """Delete a recording."""
275
+ try:
276
+ filepath = validate_path_in_directory(RECORDINGS_DIR, filename)
277
+ except ValueError:
278
+ return {"error": "Invalid filename", "status": "failed"}
279
+ if not filepath.exists():
280
+ return {"error": "File not found", "status": "failed"}
281
+ if not filepath.suffix.lower() == ".mp4":
282
+ return {"error": "Invalid file type", "status": "failed"}
283
+ try:
284
+ filepath.unlink()
285
+ thumb_path = RECORDINGS_DIR / filename.replace('.mp4', '_thumb.jpg')
286
+ if thumb_path.exists():
287
+ thumb_path.unlink()
288
+ return {"status": "ok", "deleted": filename}
289
+ except Exception as e:
290
+ return {"error": str(e), "status": "failed"}
291
+
292
+ @app.settings_app.get("/api/recordings/{filename}/thumbnail")
293
+ async def get_recording_thumbnail(filename: str):
294
+ """Get thumbnail - serve cached file or generate for old recordings."""
295
+ from fastapi.responses import FileResponse
296
+ from fastapi import HTTPException
297
+ import cv2
298
+
299
+ try:
300
+ filepath = validate_path_in_directory(RECORDINGS_DIR, filename)
301
+ except ValueError:
302
+ raise HTTPException(status_code=400, detail="Invalid filename")
303
+ if not filepath.exists() or not filepath.suffix.lower() == ".mp4":
304
+ raise HTTPException(status_code=404, detail="File not found")
305
+
306
+ thumb_path = RECORDINGS_DIR / filename.replace('.mp4', '_thumb.jpg')
307
+ if thumb_path.exists():
308
+ return FileResponse(thumb_path, media_type="image/jpeg")
309
+
310
+ # Generate for old recordings without thumbnails
311
+ try:
312
+ cap = cv2.VideoCapture(str(filepath))
313
+ ret, frame = cap.read()
314
+ cap.release()
315
+
316
+ if not ret or frame is None:
317
+ raise HTTPException(status_code=500, detail="Could not read video frame")
318
+
319
+ h, w = frame.shape[:2]
320
+ thumb_w = 320
321
+ frame = cv2.resize(frame, (thumb_w, int(h * thumb_w / w)))
322
+ cv2.imwrite(str(thumb_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
323
+
324
+ return FileResponse(thumb_path, media_type="image/jpeg")
325
+ except Exception as e:
326
+ logger.exception(f"Thumbnail error: {e}")
327
+ raise HTTPException(status_code=500, detail=str(e))
hello_world/api/settings_api.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Settings API - Settings management endpoints.
3
+
4
+ Endpoints:
5
+ GET /api/settings - Get all settings
6
+ PUT /api/settings - Update settings (broadcasts changes via /ws/transcribe)
7
+ """
8
+
9
+ __all__ = ['register_routes']
10
+
11
+ import json
12
+ import logging
13
+
14
+ logger = logging.getLogger("reachy_mini.app.hello_world.settings_api")
15
+
16
+ # Allowed settings keys (security: prevent injection of arbitrary keys)
17
+ ALLOWED_SETTINGS_KEYS = {
18
+ "motor_mode",
19
+ "robot_update_hz", "stats_update_hz",
20
+ "video_view",
21
+ "last_active_tab", "system_stats_order",
22
+ "oscillation_amplitude", "oscillation_speed",
23
+ # Conversation (LiteLLM)
24
+ "api_keys",
25
+ "audio_input", "audio_output",
26
+ "stt_provider", "stt_model",
27
+ "llm_provider", "llm_model",
28
+ "tts_provider", "tts_model", "tts_voice",
29
+ "conf_threshold", "vol_threshold", "mic_gain",
30
+ "system_prompt", "web_search",
31
+ "music_volume", "master_volume",
32
+ "vlm_provider", "vlm_model",
33
+ }
34
+
35
+ # Keys that are purely UI state — don't broadcast or toast these
36
+ _SILENT_KEYS = {"last_active_tab", "system_stats_order"}
37
+
38
+
39
+ def register_routes(app) -> None:
40
+ """Register settings routes on the app."""
41
+ from ..settings import save_settings
42
+
43
+ @app.settings_app.get("/api/settings")
44
+ def get_settings():
45
+ return app._settings
46
+
47
+ @app.settings_app.put("/api/settings")
48
+ async def update_settings(updates: dict):
49
+ filtered_updates = {k: v for k, v in updates.items() if k in ALLOWED_SETTINGS_KEYS}
50
+ rejected_keys = list(set(updates.keys()) - ALLOWED_SETTINGS_KEYS)
51
+ if rejected_keys:
52
+ logger.warning(f"Rejected unknown settings keys: {rejected_keys}")
53
+
54
+ app._settings.update(filtered_updates)
55
+ save_settings(app._settings)
56
+
57
+ # Broadcast meaningful changes to all transcribe WS clients
58
+ broadcast_keys = {k for k in filtered_updates if k not in _SILENT_KEYS}
59
+ if broadcast_keys and hasattr(app, '_transcribe_websockets'):
60
+ msg = json.dumps({
61
+ "type": "settings_changed",
62
+ "keys": list(broadcast_keys),
63
+ })
64
+ dead = []
65
+ for ws in app._transcribe_websockets:
66
+ try:
67
+ await ws.send_text(msg)
68
+ except Exception:
69
+ dead.append(ws)
70
+ for ws in dead:
71
+ app._transcribe_websockets.discard(ws)
72
+
73
+ response = dict(app._settings)
74
+ if rejected_keys:
75
+ response["_rejected_keys"] = rejected_keys
76
+ return response
hello_world/api/snapshots.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Snapshots API - Camera snapshot capture and management.
3
+
4
+ Endpoints:
5
+ POST /api/snapshots/capture - Capture a snapshot from camera
6
+ POST /api/snapshots/upload - Upload a snapshot (base64 encoded)
7
+ GET /api/snapshots/list - List all snapshots
8
+ DELETE /api/snapshots/{filename} - Delete a snapshot
9
+ GET /api/media/{path} - Serve any media file (unified endpoint)
10
+ """
11
+
12
+ __all__ = ['register_routes']
13
+
14
+ import base64
15
+ import logging
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from ..config import config, validate_path_in_directory
20
+
21
+ logger = logging.getLogger("reachy_mini.app.hello_world.snapshots")
22
+
23
+ SNAPSHOTS_DIR: Path = config.SNAPSHOTS_DIR
24
+ RECORDINGS_DIR: Path = config.RECORDINGS_DIR
25
+ SOUNDS_DIR: Path = Path(__file__).parent.parent / "sounds"
26
+ CAMERA_SOUND: Path = SOUNDS_DIR / "camera.wav"
27
+
28
+
29
+ def register_routes(app) -> None:
30
+ """Register snapshot routes on the app."""
31
+
32
+ @app.settings_app.post("/api/snapshots/capture")
33
+ async def capture_snapshot(description: str = ""):
34
+ """Capture a snapshot from the camera using SDK media."""
35
+ from datetime import datetime
36
+ import cv2
37
+ import asyncio
38
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
39
+ filename = f"snapshot_{timestamp}.jpg"
40
+ filepath = SNAPSHOTS_DIR / filename
41
+
42
+ try:
43
+ SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
44
+
45
+ if app._reachy_mini is None:
46
+ return {"error": "Robot not connected", "status": "failed"}
47
+
48
+ # Play camera shutter sound via aplay (non-blocking)
49
+ if CAMERA_SOUND.exists():
50
+ subprocess.Popen(
51
+ ['aplay', '-q', str(CAMERA_SOUND)],
52
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
53
+ )
54
+
55
+ # Animate antennas to indicate photo capture
56
+ try:
57
+ reachy = app._reachy_mini
58
+ left_pos = reachy.head.l_antenna.present_position if hasattr(reachy.head, 'l_antenna') else 0
59
+ right_pos = reachy.head.r_antenna.present_position if hasattr(reachy.head, 'r_antenna') else 0
60
+
61
+ if hasattr(reachy.head, 'l_antenna'):
62
+ reachy.head.l_antenna.goal_position = left_pos + 30
63
+ if hasattr(reachy.head, 'r_antenna'):
64
+ reachy.head.r_antenna.goal_position = right_pos - 30
65
+ except Exception as e:
66
+ logger.debug(f"Antenna animation start failed: {e}")
67
+
68
+ frame = app._reachy_mini.media.get_frame()
69
+ if frame is None:
70
+ return {"error": "No frame available from camera", "status": "failed"}
71
+
72
+ cv2.imwrite(str(filepath), frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
73
+
74
+ # Return antennas to original position after short delay
75
+ async def restore_antennas():
76
+ await asyncio.sleep(0.3)
77
+ try:
78
+ if hasattr(reachy.head, 'l_antenna'):
79
+ reachy.head.l_antenna.goal_position = left_pos
80
+ if hasattr(reachy.head, 'r_antenna'):
81
+ reachy.head.r_antenna.goal_position = right_pos
82
+ except Exception as e:
83
+ logger.debug(f"Antenna restore failed: {e}")
84
+
85
+ asyncio.create_task(restore_antennas())
86
+
87
+ return {
88
+ "status": "ok",
89
+ "filename": filename,
90
+ "path": str(filepath),
91
+ "size": filepath.stat().st_size if filepath.exists() else 0
92
+ }
93
+ except Exception as e:
94
+ logger.exception(f"Snapshot capture error: {e}")
95
+ return {"error": str(e), "status": "failed"}
96
+
97
+ @app.settings_app.post("/api/snapshots/upload")
98
+ async def upload_snapshot(data: dict):
99
+ """Upload a client-side captured snapshot (base64 encoded)."""
100
+ from datetime import datetime
101
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
102
+
103
+ capture_type = data.get("type", "cam")
104
+ image_data = data.get("image", "")
105
+
106
+ if not image_data:
107
+ return {"error": "No image data provided", "status": "failed"}
108
+
109
+ MAX_BASE64_SIZE = 10 * 1024 * 1024
110
+ if len(image_data) > MAX_BASE64_SIZE:
111
+ return {"error": f"Image too large (max {MAX_BASE64_SIZE // 1024 // 1024}MB)", "status": "failed"}
112
+
113
+ try:
114
+ SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
115
+
116
+ if "," in image_data:
117
+ image_data = image_data.split(",", 1)[1]
118
+
119
+ image_bytes = base64.b64decode(image_data)
120
+
121
+ filename = f"snapshot_{capture_type}_{timestamp}.jpg"
122
+ filepath = SNAPSHOTS_DIR / filename
123
+
124
+ with open(filepath, "wb") as f:
125
+ f.write(image_bytes)
126
+
127
+ return {
128
+ "status": "ok",
129
+ "filename": filename,
130
+ "path": str(filepath),
131
+ "size": filepath.stat().st_size if filepath.exists() else 0
132
+ }
133
+ except Exception as e:
134
+ logger.exception(f"Snapshot upload error: {e}")
135
+ return {"error": str(e), "status": "failed"}
136
+
137
+ @app.settings_app.get("/api/snapshots/list")
138
+ async def list_snapshots():
139
+ """List all snapshots."""
140
+ try:
141
+ if not SNAPSHOTS_DIR.exists():
142
+ return {"snapshots": []}
143
+ snapshots = []
144
+ for f in sorted(SNAPSHOTS_DIR.glob("*.jpg"), key=lambda x: x.stat().st_mtime, reverse=True):
145
+ snapshots.append({
146
+ "filename": f.name,
147
+ "size": f.stat().st_size,
148
+ "modified": f.stat().st_mtime
149
+ })
150
+ return {"snapshots": snapshots}
151
+ except Exception as e:
152
+ return {"error": str(e), "snapshots": []}
153
+
154
+ @app.settings_app.delete("/api/snapshots/{filename}")
155
+ async def delete_snapshot(filename: str):
156
+ """Delete a snapshot."""
157
+ try:
158
+ filepath = validate_path_in_directory(SNAPSHOTS_DIR, filename)
159
+ except ValueError:
160
+ return {"error": "Invalid filename", "status": "failed"}
161
+ if not filepath.exists():
162
+ return {"error": "File not found", "status": "failed"}
163
+ if not filepath.suffix.lower() == ".jpg":
164
+ return {"error": "Invalid file type", "status": "failed"}
165
+ try:
166
+ filepath.unlink()
167
+ return {"status": "ok", "deleted": filename}
168
+ except Exception as e:
169
+ return {"error": str(e), "status": "failed"}
170
+
171
+ @app.settings_app.get("/api/media/{filepath:path}")
172
+ async def get_media_file(filepath: str):
173
+ """Get any media file from the media directory."""
174
+ from fastapi.responses import FileResponse
175
+ from fastapi import HTTPException
176
+
177
+ ext = Path(filepath).suffix.lower()
178
+ media_types = {
179
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
180
+ ".png": "image/png", ".gif": "image/gif",
181
+ ".webp": "image/webp", ".mp4": "video/mp4",
182
+ ".webm": "video/webm", ".mov": "video/quicktime",
183
+ }
184
+ media_type = media_types.get(ext, "application/octet-stream")
185
+
186
+ base_dirs = [SNAPSHOTS_DIR, RECORDINGS_DIR, config.MEDIA_BASE_DIR]
187
+
188
+ for base_dir in base_dirs:
189
+ try:
190
+ full_path = validate_path_in_directory(base_dir, filepath)
191
+ if full_path.exists():
192
+ return FileResponse(full_path, media_type=media_type)
193
+ except ValueError:
194
+ continue
195
+
196
+ raise HTTPException(status_code=404, detail=f"File not found: {filepath}")
hello_world/api/sounds.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sound Recordings API - Audio-only recording from robot microphone.
3
+
4
+ Endpoints:
5
+ POST /api/sounds/start - Start audio recording
6
+ POST /api/sounds/stop - Stop audio recording
7
+ GET /api/sounds/list - List all sound recordings
8
+ DELETE /api/sounds/{filename} - Delete a sound recording
9
+ GET /api/sounds/file/{filename} - Serve a sound file
10
+ GET /api/sounds/{filename}/thumbnail - Get waveform thumbnail
11
+ """
12
+
13
+ __all__ = ['register_routes']
14
+
15
+ import logging
16
+ import subprocess
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from ..config import config, validate_path_in_directory
21
+
22
+ logger = logging.getLogger("reachy_mini.app.hello_world.sounds")
23
+
24
+ SOUNDS_DIR: Path = config.SOUNDS_DIR
25
+
26
+
27
+ def _generate_waveform_thumbnail(wav_path: Path, thumb_path: Path, width: int = 320, height: int = 240) -> bool:
28
+ """Generate a waveform thumbnail image from a WAV file using cv2.
29
+
30
+ Uses log-scale amplitude so quiet parts remain visible at small sizes.
31
+ Draws symmetric mirrored bars from center line with color gradient.
32
+ """
33
+ import wave
34
+ import numpy as np
35
+ import cv2
36
+
37
+ try:
38
+ with wave.open(str(wav_path), 'rb') as wf:
39
+ n_channels = wf.getnchannels()
40
+ sample_width = wf.getsampwidth()
41
+ n_frames = wf.getnframes()
42
+
43
+ if n_frames == 0:
44
+ return False
45
+
46
+ raw = wf.readframes(n_frames)
47
+
48
+ # Convert to numpy array
49
+ if sample_width == 2:
50
+ samples = np.frombuffer(raw, dtype=np.int16).astype(np.float64)
51
+ elif sample_width == 1:
52
+ samples = np.frombuffer(raw, dtype=np.uint8).astype(np.float64) - 128
53
+ else:
54
+ return False
55
+
56
+ # Mix to mono if stereo
57
+ if n_channels > 1:
58
+ samples = samples.reshape(-1, n_channels).mean(axis=1)
59
+
60
+ # Compute RMS amplitude per bar (one bar per pixel column)
61
+ n_bars = width
62
+ bucket_size = max(1, len(samples) // n_bars)
63
+ n_bars = min(n_bars, len(samples) // bucket_size)
64
+
65
+ rms = np.zeros(n_bars)
66
+ for i in range(n_bars):
67
+ bucket = samples[i * bucket_size:(i + 1) * bucket_size]
68
+ rms[i] = np.sqrt(np.mean(bucket ** 2))
69
+
70
+ # Log-scale normalization: amplifies quiet parts, compresses loud parts
71
+ # Add 1 to avoid log(0), then normalize to 0-1
72
+ log_rms = np.log1p(rms)
73
+ max_log = log_rms.max()
74
+ if max_log > 0:
75
+ norm = log_rms / max_log
76
+ else:
77
+ norm = log_rms
78
+
79
+ # Dark background
80
+ img = np.zeros((height, width, 3), dtype=np.uint8)
81
+ img[:] = (18, 18, 22)
82
+
83
+ mid_y = height // 2
84
+ usable = (height - 8) // 2 # Minimal padding
85
+
86
+ # Color palette (BGR)
87
+ color_loud = (230, 160, 40) # Bright teal for loud
88
+ color_mid = (160, 100, 25) # Medium teal
89
+ color_quiet = (80, 55, 18) # Dim teal for quiet
90
+
91
+ for i in range(n_bars):
92
+ bar_h = max(1, int(norm[i] * usable))
93
+
94
+ # Color based on amplitude - louder = brighter
95
+ if norm[i] > 0.6:
96
+ color = color_loud
97
+ elif norm[i] > 0.25:
98
+ color = color_mid
99
+ else:
100
+ color = color_quiet
101
+
102
+ # Draw symmetric bar from center
103
+ cv2.line(img, (i, mid_y - bar_h), (i, mid_y + bar_h), color, 1)
104
+
105
+ # Thin center line
106
+ cv2.line(img, (0, mid_y), (width - 1, mid_y), (40, 40, 50), 1)
107
+
108
+ cv2.imwrite(str(thumb_path), img, [cv2.IMWRITE_JPEG_QUALITY, 90])
109
+ return True
110
+ except Exception as e:
111
+ logger.warning(f"Waveform thumbnail generation failed: {e}")
112
+ return False
113
+
114
+
115
+ def register_routes(app) -> None:
116
+ """Register sound recording routes on the app."""
117
+
118
+ if not hasattr(app, '_sound_recording_active'):
119
+ app._sound_recording_active = False
120
+ app._sound_recording_process = None
121
+ app._sound_recording_filename = None
122
+ app._sound_recording_start_time = None
123
+
124
+ @app.settings_app.post("/api/sounds/start")
125
+ async def start_sound_recording():
126
+ """Start audio-only recording from robot microphone."""
127
+ from datetime import datetime
128
+ if app._sound_recording_active:
129
+ return {"error": "Sound recording already in progress", "status": "failed"}
130
+
131
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
132
+ filename = f"sound_{timestamp}.wav"
133
+ filepath = SOUNDS_DIR / filename
134
+
135
+ try:
136
+ SOUNDS_DIR.mkdir(parents=True, exist_ok=True)
137
+
138
+ app._sound_recording_process = subprocess.Popen([
139
+ "arecord", "-D", "reachymini_audio_src",
140
+ "-f", "S16_LE", "-r", "16000", "-c", "2",
141
+ str(filepath)
142
+ ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
143
+
144
+ app._sound_recording_active = True
145
+ app._sound_recording_filename = filename
146
+ app._sound_recording_start_time = time.time()
147
+
148
+ logger.info(f"Started sound recording: {filename}")
149
+ return {"status": "ok", "filename": filename, "message": "Sound recording started"}
150
+ except Exception as e:
151
+ logger.exception(f"Sound recording start error: {e}")
152
+ app._sound_recording_active = False
153
+ app._sound_recording_process = None
154
+ app._sound_recording_filename = None
155
+ app._sound_recording_start_time = None
156
+ return {"error": str(e), "status": "failed"}
157
+
158
+ @app.settings_app.post("/api/sounds/stop")
159
+ async def stop_sound_recording():
160
+ """Stop audio recording."""
161
+ if not app._sound_recording_active:
162
+ return {"error": "No sound recording in progress", "status": "failed"}
163
+
164
+ try:
165
+ if app._sound_recording_process:
166
+ app._sound_recording_process.terminate()
167
+ try:
168
+ app._sound_recording_process.wait(timeout=2)
169
+ except subprocess.TimeoutExpired:
170
+ app._sound_recording_process.kill()
171
+ app._sound_recording_process = None
172
+
173
+ duration = time.time() - app._sound_recording_start_time if app._sound_recording_start_time else 0
174
+ filename = app._sound_recording_filename
175
+
176
+ app._sound_recording_active = False
177
+ app._sound_recording_filename = None
178
+ app._sound_recording_start_time = None
179
+
180
+ # Generate waveform thumbnail
181
+ filepath = SOUNDS_DIR / filename
182
+ if filepath.exists():
183
+ thumb_path = SOUNDS_DIR / filename.replace('.wav', '_thumb.jpg')
184
+ _generate_waveform_thumbnail(filepath, thumb_path)
185
+
186
+ logger.info(f"Stopped sound recording: {filename}, duration: {duration:.1f}s")
187
+ return {"status": "ok", "filename": filename, "duration": round(duration, 1), "message": "Sound recording stopped"}
188
+ except Exception as e:
189
+ logger.exception(f"Sound recording stop error: {e}")
190
+ app._sound_recording_active = False
191
+ app._sound_recording_process = None
192
+ app._sound_recording_filename = None
193
+ app._sound_recording_start_time = None
194
+ return {"error": str(e), "status": "failed"}
195
+
196
+ @app.settings_app.get("/api/sounds/list")
197
+ async def list_sounds():
198
+ """List all sound recordings."""
199
+ try:
200
+ if not SOUNDS_DIR.exists():
201
+ return {"sounds": [], "recording_active": app._sound_recording_active}
202
+ sounds = []
203
+ for f in sorted(SOUNDS_DIR.glob("*.wav"), key=lambda x: x.stat().st_mtime, reverse=True):
204
+ duration = None
205
+ try:
206
+ result = subprocess.run(
207
+ ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", str(f)],
208
+ capture_output=True, text=True, timeout=5
209
+ )
210
+ if result.returncode == 0 and result.stdout.strip():
211
+ duration = round(float(result.stdout.strip()), 1)
212
+ except Exception:
213
+ pass
214
+ sounds.append({
215
+ "filename": f.name,
216
+ "size": f.stat().st_size,
217
+ "modified": f.stat().st_mtime,
218
+ "duration": duration
219
+ })
220
+ return {"sounds": sounds, "recording_active": app._sound_recording_active}
221
+ except Exception as e:
222
+ return {"error": str(e), "sounds": [], "recording_active": app._sound_recording_active}
223
+
224
+ @app.settings_app.delete("/api/sounds/{filename}")
225
+ async def delete_sound(filename: str):
226
+ """Delete a sound recording."""
227
+ try:
228
+ filepath = validate_path_in_directory(SOUNDS_DIR, filename)
229
+ except ValueError:
230
+ return {"error": "Invalid filename", "status": "failed"}
231
+ if not filepath.exists():
232
+ return {"error": "File not found", "status": "failed"}
233
+ if not filepath.suffix.lower() == ".wav":
234
+ return {"error": "Invalid file type", "status": "failed"}
235
+ try:
236
+ filepath.unlink()
237
+ thumb_path = SOUNDS_DIR / filename.replace('.wav', '_thumb.jpg')
238
+ if thumb_path.exists():
239
+ thumb_path.unlink()
240
+ return {"status": "ok", "deleted": filename}
241
+ except Exception as e:
242
+ return {"error": str(e), "status": "failed"}
243
+
244
+ @app.settings_app.get("/api/sounds/{filename}/thumbnail")
245
+ async def get_sound_thumbnail(filename: str):
246
+ """Get waveform thumbnail - serve cached or generate on demand."""
247
+ from fastapi.responses import FileResponse
248
+ from fastapi import HTTPException
249
+
250
+ try:
251
+ filepath = validate_path_in_directory(SOUNDS_DIR, filename)
252
+ except ValueError:
253
+ raise HTTPException(status_code=400, detail="Invalid filename")
254
+ if not filepath.exists() or not filepath.suffix.lower() == ".wav":
255
+ raise HTTPException(status_code=404, detail="File not found")
256
+
257
+ thumb_path = SOUNDS_DIR / filename.replace('.wav', '_thumb.jpg')
258
+ if thumb_path.exists():
259
+ return FileResponse(thumb_path, media_type="image/jpeg")
260
+
261
+ # Generate on demand for older recordings
262
+ if _generate_waveform_thumbnail(filepath, thumb_path):
263
+ return FileResponse(thumb_path, media_type="image/jpeg")
264
+
265
+ raise HTTPException(status_code=500, detail="Could not generate thumbnail")
266
+
267
+ @app.settings_app.get("/api/sounds/file/{filename}")
268
+ async def get_sound_file(filename: str):
269
+ """Get a sound recording file."""
270
+ from fastapi.responses import FileResponse
271
+ from fastapi import HTTPException
272
+ try:
273
+ filepath = validate_path_in_directory(SOUNDS_DIR, filename)
274
+ except ValueError:
275
+ raise HTTPException(status_code=400, detail="Invalid filename")
276
+ if not filepath.exists() or not filepath.suffix.lower() == ".wav":
277
+ raise HTTPException(status_code=404, detail="File not found")
278
+ return FileResponse(filepath, media_type="audio/wav")
hello_world/api/system.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ System Stats REST API - Individual endpoints for telemetry data.
3
+
4
+ Endpoints:
5
+ GET /api/system/stats - All telemetry (calls all 12 stats functions)
6
+ GET /api/system/cpu - CPU cores + temperature
7
+ GET /api/system/memory - RAM breakdown
8
+ GET /api/system/disk - Local + remote + swap
9
+ GET /api/system/network - TX/RX speeds + WiFi
10
+ GET /api/system/processes - Top processes
11
+ GET /api/system/hardware - Static hardware inventory
12
+ GET /api/system/health - Service dependency health
13
+ """
14
+
15
+ __all__ = ['register_routes']
16
+
17
+ import asyncio
18
+ import logging
19
+
20
+ from ..config import config
21
+
22
+ logger = logging.getLogger("reachy_mini.app.hello_world.system")
23
+
24
+
25
+ def register_routes(app) -> None:
26
+ """Register system stats routes on the app."""
27
+
28
+ @app.settings_app.get("/api/system/stats")
29
+ def get_all_stats():
30
+ """Get all telemetry in a single call."""
31
+ return app._get_stats()
32
+
33
+ @app.settings_app.get("/api/system/cpu")
34
+ def get_cpu():
35
+ """Get CPU cores + temperature."""
36
+ from ..stats import get_cpu_stats
37
+ return get_cpu_stats()
38
+
39
+ @app.settings_app.get("/api/system/memory")
40
+ def get_memory():
41
+ """Get RAM breakdown."""
42
+ from ..stats import get_memory_stats
43
+ return get_memory_stats()
44
+
45
+ @app.settings_app.get("/api/system/disk")
46
+ def get_disk():
47
+ """Get local + remote + swap disk usage."""
48
+ from ..stats import get_disk_stats
49
+ return get_disk_stats(app._disk_cache, app._disk_cache_ttl)
50
+
51
+ @app.settings_app.get("/api/system/network")
52
+ def get_network():
53
+ """Get TX/RX speeds + WiFi."""
54
+ from ..stats import get_network_stats, get_wifi_stats
55
+ result = get_network_stats(app._last_net)
56
+ result.update(get_wifi_stats())
57
+ return result
58
+
59
+ @app.settings_app.get("/api/system/processes")
60
+ def get_processes():
61
+ """Get top processes by CPU usage."""
62
+ from ..stats import get_top_processes
63
+ return get_top_processes(app._process_cache)
64
+
65
+ @app.settings_app.get("/api/system/hardware")
66
+ def get_hardware():
67
+ """Get static hardware inventory."""
68
+ from ..stats import get_hardware_info
69
+ return get_hardware_info(app._hardware_cache)
70
+
71
+ @app.settings_app.get("/api/system/health")
72
+ async def get_system_health():
73
+ """Get service dependency health (daemon, whisper, kokoro, assistant)."""
74
+ from .health import _check_http_service, _check_websocket_service
75
+
76
+ loop = asyncio.get_event_loop()
77
+ timeout = config.TIMEOUTS['health_check']
78
+
79
+ daemon_check = loop.run_in_executor(
80
+ None, _check_http_service, f"{config.DAEMON_URL}/api/health", timeout)
81
+ kokoro_check = loop.run_in_executor(
82
+ None, _check_http_service, f"{config.KOKORO_URL}/health", timeout)
83
+ assistant_check = loop.run_in_executor(
84
+ None, _check_http_service, f"{config.ASSISTANT_URL}/health", timeout)
85
+
86
+ daemon_result, kokoro_result, assistant_result = await asyncio.gather(
87
+ daemon_check, kokoro_check, assistant_check
88
+ )
89
+
90
+ whisper_result = await _check_websocket_service(config.WHISPER_URL, timeout)
91
+
92
+ return {
93
+ "daemon": daemon_result,
94
+ "whisper": whisper_result,
95
+ "kokoro": kokoro_result,
96
+ "assistant": assistant_result
97
+ }
hello_world/api/transcript.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Transcript API - Response and tool activity broadcasting.
3
+
4
+ Endpoints:
5
+ POST /api/transcript/claude - Broadcast LLM response text
6
+ POST /api/transcript/tool - Broadcast tool call activity
7
+ POST /api/transcript/error - Broadcast errors
8
+ POST /api/transcript/speaking - Broadcast speaking status
9
+ """
10
+
11
+ __all__ = ['register_routes']
12
+
13
+ import json
14
+ import logging
15
+ from pydantic import BaseModel
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger("reachy_mini.app.hello_world.transcript")
19
+
20
+
21
+ def register_routes(app) -> None:
22
+ """Register transcript routes on the app."""
23
+
24
+ class ToolActivity(BaseModel):
25
+ tool: str
26
+ args: dict[str, Any] = {}
27
+ result: Any = None
28
+ status: str = "started"
29
+
30
+ async def _broadcast(message: str) -> int:
31
+ """Broadcast to all transcript WebSocket clients."""
32
+ dead = set()
33
+ for ws in app._transcribe_websockets:
34
+ try:
35
+ await ws.send_text(message)
36
+ except Exception:
37
+ dead.add(ws)
38
+ app._transcribe_websockets.difference_update(dead)
39
+ return len(app._transcribe_websockets)
40
+
41
+ @app.settings_app.post("/api/transcript/claude")
42
+ async def add_claude_response(text: str, duration: float = 0, source: str = "LLM"):
43
+ """Broadcast LLM response to transcript clients."""
44
+ message = json.dumps({
45
+ "text": text,
46
+ "confidence": 100,
47
+ "audio_level": 0,
48
+ "speaker": source,
49
+ "is_claude": True
50
+ })
51
+ count = await _broadcast(message)
52
+ return {"status": "ok", "broadcast_to": count}
53
+
54
+ @app.settings_app.post("/api/transcript/tool")
55
+ async def add_tool_activity(activity: ToolActivity):
56
+ """Broadcast tool call activity."""
57
+ message = json.dumps({
58
+ "type": "tool",
59
+ "tool": activity.tool,
60
+ "args": activity.args,
61
+ "result": activity.result,
62
+ "status": activity.status,
63
+ "is_tool": True
64
+ })
65
+ count = await _broadcast(message)
66
+ return {"status": "ok", "broadcast_to": count}
67
+
68
+ @app.settings_app.post("/api/transcript/error")
69
+ async def add_error(error: str, source: str = "assistant"):
70
+ """Broadcast error."""
71
+ message = json.dumps({
72
+ "type": "error",
73
+ "error": error,
74
+ "source": source
75
+ })
76
+ count = await _broadcast(message)
77
+ return {"status": "ok", "broadcast_to": count}
78
+
79
+ @app.settings_app.post("/api/transcript/speaking")
80
+ async def set_speaking_status(speaking: bool, duration: float = 0, source: str = "tts"):
81
+ """Broadcast speaking status."""
82
+ from . import listener
83
+
84
+ # Mute listener while speaking (echo prevention)
85
+ if speaking:
86
+ listener.set_listener_muted(True)
87
+ if duration > 0:
88
+ import asyncio
89
+ async def auto_unmute():
90
+ await asyncio.sleep(duration + 0.5)
91
+ listener.set_listener_muted(False)
92
+ asyncio.create_task(auto_unmute())
93
+ else:
94
+ listener.set_listener_muted(False)
95
+
96
+ message = json.dumps({
97
+ "type": "speaking_status",
98
+ "speaking": speaking,
99
+ "duration": duration,
100
+ "source": source
101
+ })
102
+ count = await _broadcast(message)
103
+ return {"status": "ok", "broadcast_to": count, "speaking": speaking}
hello_world/app.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HelloWorld Application Class.
3
+
4
+ The main application class that inherits from ReachyMiniApp.
5
+ Provides a Status dashboard with system telemetry and robot state.
6
+ """
7
+
8
+ __all__ = ['HelloWorld']
9
+
10
+ import logging
11
+ import threading
12
+ import time
13
+ import urllib.request
14
+
15
+ from reachy_mini import ReachyMini, ReachyMiniApp
16
+
17
+ from .api import register_all_routes
18
+ from .config import config
19
+ from .settings import load_settings
20
+ from .stats import (
21
+ get_cpu_stats, get_memory_stats, get_disk_stats, get_network_stats,
22
+ get_wifi_stats, get_uptime_stats, get_load_stats, get_fan_stats,
23
+ get_throttle_stats, get_disk_io_stats, get_top_processes, get_hardware_info
24
+ )
25
+ from .websocket import register_websockets
26
+
27
+ logger = logging.getLogger("reachy_mini.app.hello_world")
28
+
29
+
30
+ class HelloWorld(ReachyMiniApp):
31
+ """A developer dashboard app for Reachy Mini with system telemetry."""
32
+
33
+ custom_app_url: str | None = "http://0.0.0.0:8042"
34
+
35
+ def __init__(self):
36
+ super().__init__()
37
+ self._reachy_mini = None
38
+ self._start_time = time.time()
39
+ # Tracking dicts for stats that need delta calculations
40
+ self._last_net = {"rx": 0, "tx": 0, "time": time.time()}
41
+ self._disk_cache = {"local": None, "remote": None, "swap": None, "time": 0}
42
+ self._disk_cache_ttl = 60
43
+ self._last_disk_io = {"read": 0, "write": 0, "time": time.time()}
44
+ self._process_cache = {"processes": [], "time": 0}
45
+ self._hardware_cache = {}
46
+ # Load persistent settings
47
+ self._settings = load_settings()
48
+ # Transcript WebSocket clients for conversation broadcast
49
+ self._transcribe_websockets = set()
50
+
51
+ # Register WebSocket endpoints
52
+ if self.settings_app is not None:
53
+ register_websockets(self)
54
+
55
+ def _get_stats(self):
56
+ """Get all system stats using focused stat functions."""
57
+ result = {}
58
+ result.update(get_cpu_stats())
59
+ result.update(get_memory_stats())
60
+ result.update(get_disk_stats(self._disk_cache, self._disk_cache_ttl))
61
+ result.update(get_network_stats(self._last_net))
62
+ result.update(get_wifi_stats())
63
+ result.update(get_uptime_stats())
64
+ result.update(get_load_stats())
65
+ result.update(get_fan_stats())
66
+ result.update(get_throttle_stats())
67
+ result.update(get_disk_io_stats(self._last_disk_io))
68
+ result.update(get_top_processes(self._process_cache))
69
+ result.update(get_hardware_info(self._hardware_cache))
70
+ return result
71
+
72
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
73
+ """Main app loop."""
74
+ self._reachy_mini = reachy_mini
75
+
76
+ # Register all API routes
77
+ register_all_routes(self)
78
+
79
+ # Apply saved motor mode on startup
80
+ saved_mode = self._settings.get("motor_mode", "enabled")
81
+ if saved_mode != "enabled":
82
+ try:
83
+ req = urllib.request.Request(
84
+ config.get_daemon_endpoint(f"motors/set_mode/{saved_mode}"),
85
+ method="POST"
86
+ )
87
+ with urllib.request.urlopen(req, timeout=config.TIMEOUTS['daemon_move']) as resp:
88
+ logger.info(f"Applied saved motor mode: {saved_mode}")
89
+ except Exception as e:
90
+ logger.warning(f"Failed to apply saved motor mode: {e}")
91
+
92
+ # Main loop
93
+ while not stop_event.is_set():
94
+ time.sleep(0.02)
hello_world/config.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Centralized Configuration - Environment variables, service URLs, and timeouts.
3
+
4
+ All service URLs default to localhost. Override via environment variables
5
+ for your specific network setup.
6
+
7
+ Usage:
8
+ from .config import config
9
+
10
+ url = config.DAEMON_URL
11
+ timeout = config.TIMEOUTS['health_check']
12
+ """
13
+
14
+ __all__ = ['config', 'Config', 'validate_path_in_directory']
15
+
16
+ import os
17
+ import logging
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ logger = logging.getLogger("reachy_mini.app.hello_world.config")
22
+
23
+
24
+ def _get_env(key: str, default: str) -> str:
25
+ return os.environ.get(key, default)
26
+
27
+
28
+ def _get_env_int(key: str, default: int) -> int:
29
+ try:
30
+ return int(os.environ.get(key, default))
31
+ except (ValueError, TypeError):
32
+ return default
33
+
34
+
35
+ def _get_env_float(key: str, default: float) -> float:
36
+ try:
37
+ return float(os.environ.get(key, default))
38
+ except (ValueError, TypeError):
39
+ return default
40
+
41
+
42
+ @dataclass
43
+ class Config:
44
+ """Centralized configuration with environment variable support.
45
+
46
+ Environment Variables:
47
+ REACHY_DAEMON_URL - Reachy daemon API URL (default: http://localhost:8000)
48
+ REACHY_MEDIA_DIR - Base directory for media files
49
+ REACHY_LOG_LEVEL - Logging level (DEBUG, INFO, WARNING, ERROR)
50
+ """
51
+
52
+ # Service URLs - all default to localhost for community use
53
+ DAEMON_URL: str = field(default_factory=lambda: _get_env(
54
+ 'REACHY_DAEMON_URL', 'http://localhost:8000'))
55
+
56
+ # Media directories
57
+ MEDIA_BASE_DIR: Path = field(default_factory=lambda: Path(
58
+ _get_env('REACHY_MEDIA_DIR', str(Path.home() / 'hello_world' / 'media'))))
59
+
60
+ @property
61
+ def RECORDINGS_DIR(self) -> Path:
62
+ return self.MEDIA_BASE_DIR / "recordings"
63
+
64
+ @property
65
+ def SNAPSHOTS_DIR(self) -> Path:
66
+ return self.MEDIA_BASE_DIR / "snapshots"
67
+
68
+ @property
69
+ def SOUNDS_DIR(self) -> Path:
70
+ return self.MEDIA_BASE_DIR / "sounds"
71
+
72
+ @property
73
+ def MUSIC_DIR(self) -> Path:
74
+ return self.MEDIA_BASE_DIR / "music"
75
+
76
+ # Timeouts (in seconds)
77
+ TIMEOUTS: dict = field(default_factory=lambda: {
78
+ 'litellm_chat': _get_env_float('REACHY_TIMEOUT_LITELLM', 120.0),
79
+ 'litellm_stt': _get_env_float('REACHY_TIMEOUT_STT', 30.0),
80
+ 'litellm_tts': _get_env_float('REACHY_TIMEOUT_TTS', 30.0),
81
+ 'daemon_move': _get_env_float('REACHY_TIMEOUT_DAEMON_MOVE', 10.0),
82
+ 'daemon_quick': _get_env_float('REACHY_TIMEOUT_DAEMON_QUICK', 0.5),
83
+ 'health_check': _get_env_float('REACHY_TIMEOUT_HEALTH', 2.0),
84
+ })
85
+
86
+ # Logging
87
+ LOG_LEVEL: str = field(default_factory=lambda: _get_env('REACHY_LOG_LEVEL', 'INFO'))
88
+
89
+ def get_daemon_endpoint(self, path: str) -> str:
90
+ return f"{self.DAEMON_URL}/api/{path.lstrip('/')}"
91
+
92
+ def validate(self) -> list[str]:
93
+ warnings = []
94
+ if not self.DAEMON_URL.startswith(('http://', 'https://')):
95
+ warnings.append(f"DAEMON_URL should start with http:// or https://: {self.DAEMON_URL}")
96
+ return warnings
97
+
98
+ def log_config(self) -> None:
99
+ logger.info(f"Config: DAEMON_URL={self.DAEMON_URL}")
100
+ logger.info(f"Config: MEDIA_BASE_DIR={self.MEDIA_BASE_DIR}")
101
+ for warning in self.validate():
102
+ logger.warning(f"Config warning: {warning}")
103
+
104
+
105
+ def validate_path_in_directory(base_dir: Path, filename: str) -> Path:
106
+ """Validate that a filename resolves to a path within base_dir.
107
+
108
+ Prevents path traversal attacks.
109
+ """
110
+ base_resolved = base_dir.resolve()
111
+ file_path = (base_dir / filename).resolve()
112
+ try:
113
+ file_path.relative_to(base_resolved)
114
+ except ValueError:
115
+ raise ValueError(f"Invalid path: {filename}")
116
+ return file_path
117
+
118
+
119
+ # Global config instance
120
+ config = Config()
hello_world/main.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hello World - Main entry point.
3
+
4
+ A developer-friendly dashboard app for the Reachy Mini robot.
5
+ """
6
+
7
+ from .app import HelloWorld
8
+
9
+ __all__ = ['HelloWorld']
10
+
11
+ if __name__ == "__main__":
12
+ app = HelloWorld()
13
+ try:
14
+ app.wrapped_run()
15
+ except KeyboardInterrupt:
16
+ app.stop()
hello_world/settings.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Settings - Persistent settings management.
3
+
4
+ Provides load/save for persistent settings with sensible defaults.
5
+ All URLs default to localhost for community use.
6
+ """
7
+
8
+ __all__ = ['DEFAULT_SETTINGS', 'SETTINGS_FILE', 'load_settings', 'save_settings']
9
+
10
+ import json
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ logger = logging.getLogger("reachy_mini.app.hello_world.settings")
15
+
16
+ # Settings file location - override with REACHY_SETTINGS_FILE env var
17
+ import os
18
+ SETTINGS_FILE = Path(os.environ.get(
19
+ 'REACHY_SETTINGS_FILE',
20
+ str(Path.home() / 'hello_world' / 'settings.json')
21
+ ))
22
+
23
+ DEFAULT_SETTINGS = {
24
+ # Motor control
25
+ "motor_mode": "enabled", # enabled, disabled, gravity_compensation
26
+
27
+ # === UPDATE RATES ===
28
+ "robot_update_hz": 15,
29
+ "stats_update_hz": 1,
30
+
31
+ # === VIDEO VIEW ===
32
+ "video_view": "simulation", # off, camera, simulation, both
33
+
34
+ # === UI STATE ===
35
+ "last_active_tab": "status",
36
+ "system_stats_order": None,
37
+
38
+ # === OSCILLATION ===
39
+ "oscillation_amplitude": 0.5,
40
+ "oscillation_speed": 1.0,
41
+
42
+ # === CONVERSATION (LiteLLM) ===
43
+ # API keys — stored here, passed via api_key param to LiteLLM calls
44
+ # No env vars needed — keys are editable from the UI
45
+ "api_keys": {}, # {"openai": "sk-...", "anthropic": "sk-ant-...", ...}
46
+
47
+ # Audio routing (independently selectable)
48
+ "audio_input": "robot", # "robot" (arecord) or "browser" (getUserMedia -> WebSocket)
49
+ "audio_output": "robot", # "robot" (push_audio_sample) or "browser" (WebSocket -> Web Audio)
50
+
51
+ # Provider/model selection
52
+ "stt_provider": "", # e.g. "openai"
53
+ "stt_model": "", # e.g. "whisper-1"
54
+ "llm_provider": "", # e.g. "anthropic"
55
+ "llm_model": "", # e.g. "claude-haiku-4-5-20251001"
56
+ "tts_provider": "", # e.g. "openai"
57
+ "tts_model": "", # e.g. "tts-1"
58
+ "tts_voice": "", # e.g. "alloy"
59
+
60
+ # Thresholds
61
+ "conf_threshold": 50, # min confidence to process (0-100)
62
+ "vol_threshold": 50, # min volume to process (0-100)
63
+ "mic_gain": 5.0, # mic amplification factor
64
+
65
+ # LLM behaviour
66
+ "system_prompt": "", # custom system prompt (empty = use default)
67
+ "web_search": False, # enable web search via LiteLLM web_search_options
68
+ }
69
+
70
+
71
+ def load_settings() -> dict:
72
+ """Load settings from file, return defaults if not found."""
73
+ try:
74
+ if SETTINGS_FILE.exists():
75
+ logger.info(f"Loading settings from: {SETTINGS_FILE}")
76
+ with open(SETTINGS_FILE) as f:
77
+ saved = json.load(f)
78
+ return {**DEFAULT_SETTINGS, **saved}
79
+ else:
80
+ logger.info(f"Settings file not found at {SETTINGS_FILE}, using defaults")
81
+ except Exception as e:
82
+ logger.warning(f"Failed to load settings from {SETTINGS_FILE}: {e}")
83
+ return DEFAULT_SETTINGS.copy()
84
+
85
+
86
+ def save_settings(settings: dict) -> bool:
87
+ """Save settings to file."""
88
+ try:
89
+ SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
90
+ with open(SETTINGS_FILE, "w") as f:
91
+ json.dump(settings, f, indent=2)
92
+ return True
93
+ except Exception as e:
94
+ logger.error(f"Failed to save settings: {e}")
95
+ return False
hello_world/sounds/camera.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:729f3e7a76274075ff546fd8128c4155795dc85f4f4635931ed372b68098287d
3
+ size 3244
hello_world/static/css/styles.css ADDED
@@ -0,0 +1,1240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* ===== DARK THEME (Default) ===== */
3
+ --white: #ffffff;
4
+ --black: #000000;
5
+
6
+ --bg-primary: #0f0f0f;
7
+ --bg-secondary: #1a1a1a;
8
+ --bg-tertiary: #252525;
9
+ --bg-elevated: #1a1a2e;
10
+
11
+ --text-primary: #f5f5f5;
12
+ --text-secondary: #a0a0a0;
13
+ --text-muted: #666;
14
+ --text-50: rgba(245, 245, 245, 0.5);
15
+
16
+ --accent: #6366f1;
17
+ --accent-hover: #818cf8;
18
+ --accent-50: rgba(99, 102, 241, 0.5);
19
+ --accent-20: rgba(99, 102, 241, 0.2);
20
+
21
+ --success: #22c55e;
22
+ --success-50: rgba(34, 197, 94, 0.5);
23
+ --warning: #f59e0b;
24
+ --warning-50: rgba(245, 158, 11, 0.5);
25
+ --error: #ef4444;
26
+ --error-50: rgba(239, 68, 68, 0.5);
27
+
28
+ --border: #333;
29
+ --border-subtle: #2a2a2a;
30
+
31
+ --white-5: rgba(255, 255, 255, 0.05);
32
+ --white-10: rgba(255, 255, 255, 0.1);
33
+ --white-15: rgba(255, 255, 255, 0.15);
34
+ --white-50: rgba(255, 255, 255, 0.5);
35
+
36
+ --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.4);
37
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
38
+
39
+ --radius-sm: 4px;
40
+ --radius-md: 6px;
41
+ --radius-lg: 8px;
42
+ --radius-xl: 12px;
43
+ }
44
+
45
+ /* ===== LIGHT THEME ===== */
46
+ :root.light {
47
+ --bg-primary: #f8f9fa;
48
+ --bg-secondary: #ffffff;
49
+ --bg-tertiary: #e9ecef;
50
+ --bg-elevated: #ffffff;
51
+
52
+ --text-primary: #1a1a1a;
53
+ --text-secondary: #6c757d;
54
+ --text-muted: #adb5bd;
55
+ --text-50: rgba(26, 26, 26, 0.5);
56
+
57
+ --accent: #4f46e5;
58
+ --accent-hover: #6366f1;
59
+ --accent-50: rgba(79, 70, 229, 0.5);
60
+ --accent-20: rgba(79, 70, 229, 0.2);
61
+
62
+ --success: #16a34a;
63
+ --success-50: rgba(22, 163, 74, 0.5);
64
+ --warning: #d97706;
65
+ --warning-50: rgba(217, 119, 6, 0.5);
66
+ --error: #dc2626;
67
+ --error-50: rgba(220, 38, 38, 0.5);
68
+
69
+ --border: #dee2e6;
70
+ --border-subtle: #e9ecef;
71
+
72
+ --white-5: rgba(0, 0, 0, 0.03);
73
+ --white-10: rgba(0, 0, 0, 0.06);
74
+ --white-15: rgba(0, 0, 0, 0.08);
75
+ --white-50: rgba(0, 0, 0, 0.3);
76
+
77
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
78
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
79
+ }
80
+
81
+ * { margin: 0; padding: 0; box-sizing: border-box; }
82
+
83
+ body {
84
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
85
+ background: var(--bg-primary);
86
+ color: var(--text-primary);
87
+ min-height: 100vh;
88
+ padding-top: 48px;
89
+ transition: background-color 0.3s ease, color 0.3s ease;
90
+ }
91
+
92
+ /* ===== Header ===== */
93
+ .header-bar {
94
+ position: fixed;
95
+ top: 0; left: 0; right: 0;
96
+ z-index: 1000;
97
+ background: var(--bg-secondary);
98
+ border-bottom: 1px solid var(--border);
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: space-between;
102
+ padding: 0 1rem 0 0;
103
+ }
104
+
105
+ /* Header volume control */
106
+ .header-volume {
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 0.3rem;
110
+ margin-right: 0.25rem;
111
+ }
112
+ .header-volume-icon { font-size: 0.85rem; cursor: default; }
113
+ .header-volume-slider {
114
+ width: 70px;
115
+ height: 4px;
116
+ -webkit-appearance: none;
117
+ appearance: none;
118
+ background: var(--bg-tertiary);
119
+ border-radius: 2px;
120
+ outline: none;
121
+ cursor: pointer;
122
+ }
123
+ .header-volume-slider::-webkit-slider-thumb {
124
+ -webkit-appearance: none;
125
+ width: 12px;
126
+ height: 12px;
127
+ border-radius: 50%;
128
+ background: var(--accent);
129
+ cursor: pointer;
130
+ }
131
+ .header-volume-value {
132
+ font-size: 0.65rem;
133
+ color: var(--text-muted);
134
+ font-family: 'SF Mono', Monaco, monospace;
135
+ min-width: 1.5rem;
136
+ text-align: right;
137
+ }
138
+
139
+ .header-right {
140
+ display: flex;
141
+ align-items: center;
142
+ gap: 0.5rem;
143
+ flex-shrink: 0;
144
+ }
145
+
146
+ .status-badge {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 0.375rem;
150
+ padding: 0.25rem 0.5rem;
151
+ background: var(--bg-tertiary);
152
+ border-radius: 1rem;
153
+ font-size: 0.7rem;
154
+ flex-shrink: 0;
155
+ }
156
+
157
+ .stat-num {
158
+ font-family: monospace;
159
+ font-size: 0.625rem;
160
+ color: var(--accent);
161
+ min-width: 1.8em;
162
+ text-align: right;
163
+ }
164
+
165
+ .status-indicator {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 4px;
169
+ padding: 3px 8px;
170
+ background: var(--white-10);
171
+ border-radius: 4px;
172
+ font-size: 11px;
173
+ font-weight: 500;
174
+ color: var(--white-50);
175
+ text-transform: uppercase;
176
+ letter-spacing: 0.3px;
177
+ }
178
+
179
+ .status-indicator.info {
180
+ background: var(--accent-20);
181
+ color: var(--accent);
182
+ }
183
+
184
+ /* ===== Tabs ===== */
185
+ .tabs {
186
+ display: flex;
187
+ background: transparent;
188
+ padding: 0;
189
+ overflow-x: auto;
190
+ flex: 1;
191
+ }
192
+
193
+ .tab {
194
+ padding: 0.875rem 1rem;
195
+ cursor: pointer;
196
+ color: var(--text-secondary);
197
+ border-bottom: 2px solid transparent;
198
+ transition: all 0.2s;
199
+ white-space: nowrap;
200
+ font-size: 0.875rem;
201
+ }
202
+
203
+ .tab:hover {
204
+ color: var(--text-primary);
205
+ background: var(--bg-tertiary);
206
+ }
207
+
208
+ .tab.active {
209
+ color: var(--accent);
210
+ border-bottom-color: var(--accent);
211
+ }
212
+
213
+ .tab-content {
214
+ display: none;
215
+ padding: 1.5rem;
216
+ max-width: 1400px;
217
+ margin: 0 auto;
218
+ }
219
+
220
+ .tab-content.active { display: block; }
221
+
222
+ /* ===== Cards ===== */
223
+ .card {
224
+ background: var(--bg-secondary);
225
+ border: 1px solid var(--border);
226
+ border-radius: 0.75rem;
227
+ padding: 1.25rem;
228
+ margin-bottom: 1.25rem;
229
+ }
230
+
231
+ .card-header {
232
+ display: flex;
233
+ align-items: center;
234
+ justify-content: space-between;
235
+ margin-bottom: 1rem;
236
+ }
237
+
238
+ .card-title { font-size: 1rem; font-weight: 600; }
239
+ .copy-btn { background: none; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 0.25rem 0.35rem; cursor: pointer; color: var(--text-muted); transition: all 0.15s; display: inline-flex; align-items: center; }
240
+ .copy-btn:hover { color: var(--text-primary); background: var(--bg-secondary); border-color: var(--text-secondary); }
241
+ .copy-btn.copied { color: var(--accent); border-color: var(--accent); }
242
+
243
+ /* ===== Charts ===== */
244
+ .charts-grid {
245
+ display: grid;
246
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
247
+ gap: 0.75rem;
248
+ }
249
+
250
+ .charts-grid-3 {
251
+ grid-template-columns: repeat(3, 1fr);
252
+ }
253
+
254
+ .chart-container {
255
+ background: var(--bg-tertiary);
256
+ border-radius: 8px;
257
+ padding: 0.75rem;
258
+ border: 1px solid var(--border-subtle);
259
+ }
260
+
261
+ .chart-header {
262
+ display: flex;
263
+ justify-content: space-between;
264
+ align-items: center;
265
+ margin-bottom: 0.5rem;
266
+ }
267
+
268
+ .chart-label {
269
+ font-size: 0.7rem;
270
+ text-transform: uppercase;
271
+ letter-spacing: 0.05em;
272
+ color: var(--text-secondary);
273
+ font-weight: 600;
274
+ }
275
+
276
+ .chart-value {
277
+ font-family: 'SF Mono', Monaco, monospace;
278
+ font-size: 0.8rem;
279
+ color: var(--text-primary);
280
+ }
281
+
282
+ .net-value {
283
+ display: inline-block;
284
+ min-width: 5.5em;
285
+ text-align: right;
286
+ }
287
+
288
+ .net-stats {
289
+ display: flex;
290
+ gap: 0.5rem;
291
+ font-family: 'SF Mono', Monaco, monospace;
292
+ font-size: 0.75rem;
293
+ }
294
+
295
+ .chart-container canvas {
296
+ width: 100%;
297
+ height: 70px;
298
+ border-radius: 4px;
299
+ }
300
+
301
+ .chart-legend {
302
+ font-size: 0.65rem;
303
+ margin-top: 0.25rem;
304
+ display: flex;
305
+ gap: 8px;
306
+ justify-content: center;
307
+ color: var(--text-secondary);
308
+ }
309
+
310
+ /* ===== Disk Charts ===== */
311
+ .disk-charts {
312
+ display: flex;
313
+ justify-content: space-around;
314
+ align-items: center;
315
+ padding: 0.5rem 0;
316
+ }
317
+
318
+ .disk-chart-item {
319
+ display: flex;
320
+ flex-direction: column;
321
+ align-items: center;
322
+ gap: 4px;
323
+ }
324
+
325
+ .disk-label {
326
+ font-size: 0.65rem;
327
+ text-transform: uppercase;
328
+ color: var(--text-secondary);
329
+ letter-spacing: 0.5px;
330
+ }
331
+
332
+ .disk-info {
333
+ font-size: 0.6rem;
334
+ color: var(--text-muted);
335
+ text-align: center;
336
+ line-height: 1.3;
337
+ }
338
+
339
+ /* ===== Status Pills ===== */
340
+ .status-pills-container {
341
+ display: flex;
342
+ gap: 24px;
343
+ justify-content: center;
344
+ flex-wrap: wrap;
345
+ padding: 16px;
346
+ margin-bottom: 16px;
347
+ }
348
+
349
+ .status-pills-group {
350
+ display: flex;
351
+ flex-direction: column;
352
+ align-items: center;
353
+ gap: 10px;
354
+ }
355
+
356
+ .status-pills-label {
357
+ font-size: 10px;
358
+ color: var(--text-muted);
359
+ text-transform: uppercase;
360
+ letter-spacing: 1px;
361
+ }
362
+
363
+ .status-pills {
364
+ display: flex;
365
+ gap: 8px;
366
+ flex-wrap: wrap;
367
+ justify-content: center;
368
+ }
369
+
370
+ .status-pill {
371
+ display: inline-flex;
372
+ align-items: center;
373
+ gap: 6px;
374
+ padding: 8px 14px;
375
+ background: var(--bg-tertiary);
376
+ border: 1px solid var(--border);
377
+ border-radius: 20px;
378
+ font-size: 11px;
379
+ font-weight: 500;
380
+ color: var(--text-secondary);
381
+ text-transform: uppercase;
382
+ letter-spacing: 0.5px;
383
+ transition: all 0.2s ease;
384
+ }
385
+
386
+ .status-pill .pill-dot {
387
+ width: 8px; height: 8px;
388
+ border-radius: 50%;
389
+ background: var(--text-muted);
390
+ transition: all 0.2s ease;
391
+ }
392
+
393
+ .status-pill.active {
394
+ background: rgba(34, 197, 94, 0.15);
395
+ border-color: rgba(34, 197, 94, 0.3);
396
+ color: #22c55e;
397
+ }
398
+ .status-pill.active .pill-dot { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
399
+
400
+ .status-pill.error {
401
+ background: rgba(239, 68, 68, 0.15);
402
+ border-color: rgba(239, 68, 68, 0.3);
403
+ color: #ef4444;
404
+ }
405
+ .status-pill.error .pill-dot { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
406
+
407
+ .status-pill.connecting {
408
+ background: rgba(234, 179, 8, 0.15);
409
+ border-color: rgba(234, 179, 8, 0.3);
410
+ color: #eab308;
411
+ }
412
+ .status-pill.connecting .pill-dot { background: #eab308; box-shadow: 0 0 8px #eab308; animation: pulse 1s infinite; }
413
+
414
+ @keyframes pulse {
415
+ 0%, 100% { opacity: 1; }
416
+ 50% { opacity: 0.5; }
417
+ }
418
+
419
+ /* ===== Issues Badge ===== */
420
+ .issues-badge {
421
+ display: inline-flex;
422
+ align-items: center;
423
+ gap: 6px;
424
+ padding: 4px 10px;
425
+ background: rgba(239, 68, 68, 0.2);
426
+ border: 1px solid rgba(239, 68, 68, 0.4);
427
+ border-radius: 12px;
428
+ font-size: 11px;
429
+ font-weight: 500;
430
+ color: #ef4444;
431
+ cursor: pointer;
432
+ transition: all 0.2s ease;
433
+ }
434
+ .issues-badge:hover { background: rgba(239, 68, 68, 0.3); transform: scale(1.05); }
435
+ .issues-badge .issues-dot { width: 6px; height: 6px; border-radius: 50%; background: #ef4444; animation: pulse 1s infinite; }
436
+ .issues-badge.hidden { display: none; }
437
+
438
+ /* ===== Grid Layout ===== */
439
+ .grid {
440
+ display: grid;
441
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
442
+ gap: 1.25rem;
443
+ }
444
+
445
+ /* ===== Info Rows ===== */
446
+ .info-row {
447
+ display: flex;
448
+ justify-content: space-between;
449
+ align-items: center;
450
+ padding: 0.5rem 0;
451
+ border-bottom: 1px solid var(--border-subtle);
452
+ }
453
+ .info-row:last-child { border-bottom: none; }
454
+
455
+ .info-label {
456
+ font-size: 0.8rem;
457
+ color: var(--text-secondary);
458
+ }
459
+
460
+ .info-value {
461
+ font-family: 'SF Mono', Monaco, monospace;
462
+ font-size: 0.8rem;
463
+ color: var(--text-primary);
464
+ }
465
+
466
+ .info-grid {
467
+ display: grid;
468
+ grid-template-columns: 1fr 1fr;
469
+ gap: 0;
470
+ }
471
+
472
+ .info-grid .info-row { padding: 0.35rem 0.5rem; }
473
+ .info-grid .span-2 { grid-column: span 2; }
474
+
475
+ .section-divider {
476
+ height: 1px;
477
+ background: var(--border);
478
+ margin: 0.5rem 0;
479
+ }
480
+
481
+ /* ===== Bottom Info Row ===== */
482
+ .bottom-info-row {
483
+ display: grid;
484
+ grid-template-columns: 1fr 1fr;
485
+ gap: 1rem;
486
+ margin-top: 1rem;
487
+ }
488
+
489
+ .processes-header, .hardware-header {
490
+ font-size: 0.8rem;
491
+ font-weight: 600;
492
+ color: var(--text-secondary);
493
+ text-transform: uppercase;
494
+ letter-spacing: 0.5px;
495
+ margin-bottom: 0.5rem;
496
+ }
497
+
498
+ /* Process Table */
499
+ .process-table {
500
+ width: 100%;
501
+ font-size: 0.7rem;
502
+ border-collapse: collapse;
503
+ }
504
+ .process-table th {
505
+ text-align: left;
506
+ padding: 4px 8px;
507
+ color: var(--text-muted);
508
+ font-weight: 500;
509
+ border-bottom: 1px solid var(--border-subtle);
510
+ }
511
+ .process-table td {
512
+ padding: 3px 8px;
513
+ color: var(--text-secondary);
514
+ font-family: 'SF Mono', Monaco, monospace;
515
+ }
516
+ .process-table tr:hover td { color: var(--text-primary); }
517
+
518
+ /* Hardware Grid */
519
+ .hardware-grid {
520
+ display: grid;
521
+ grid-template-columns: 1fr;
522
+ gap: 2px;
523
+ }
524
+ .hw-item {
525
+ display: flex;
526
+ justify-content: space-between;
527
+ padding: 3px 0;
528
+ font-size: 0.7rem;
529
+ }
530
+ .hw-label { color: var(--text-muted); }
531
+ .hw-value { color: var(--text-secondary); text-align: right; }
532
+ .hw-mono { font-family: 'SF Mono', Monaco, monospace; font-size: 0.65rem; }
533
+
534
+ /* ===== Text Color Helpers ===== */
535
+ .text-success { color: var(--success); }
536
+ .text-error { color: var(--error); }
537
+
538
+ /* ===== Buttons ===== */
539
+ .btn {
540
+ display: inline-flex;
541
+ align-items: center;
542
+ gap: 0.375rem;
543
+ padding: 0.5rem 1rem;
544
+ border: 1px solid var(--border);
545
+ border-radius: var(--radius-md);
546
+ font-size: 0.8rem;
547
+ cursor: pointer;
548
+ transition: all 0.15s;
549
+ background: var(--bg-tertiary);
550
+ color: var(--text-primary);
551
+ }
552
+ .btn:hover { background: var(--bg-elevated); }
553
+ .btn-sm { padding: 0.3rem 0.6rem; font-size: 0.7rem; }
554
+ .btn-secondary { background: transparent; border-color: var(--border); color: var(--text-secondary); }
555
+ .btn-secondary:hover { background: var(--white-5); }
556
+
557
+ /* ===== Toggle Switch ===== */
558
+ .switch-label {
559
+ display: inline-flex;
560
+ align-items: center;
561
+ gap: 0.5rem;
562
+ cursor: pointer;
563
+ user-select: none;
564
+ }
565
+ .switch-text {
566
+ font-size: 0.75rem;
567
+ color: var(--text-secondary);
568
+ }
569
+ .switch-label input {
570
+ position: absolute;
571
+ width: 0;
572
+ height: 0;
573
+ opacity: 0;
574
+ pointer-events: none;
575
+ }
576
+ .switch-slider {
577
+ position: relative;
578
+ display: inline-block;
579
+ width: 36px;
580
+ height: 20px;
581
+ background: var(--bg-tertiary);
582
+ border: 1px solid var(--border);
583
+ border-radius: 10px;
584
+ transition: background 0.2s, border-color 0.2s;
585
+ flex-shrink: 0;
586
+ }
587
+ .switch-slider::after {
588
+ content: '';
589
+ position: absolute;
590
+ top: 2px;
591
+ left: 2px;
592
+ width: 14px;
593
+ height: 14px;
594
+ background: var(--text-secondary);
595
+ border-radius: 50%;
596
+ transition: transform 0.2s, background 0.2s;
597
+ }
598
+ .switch-label input:checked + .switch-slider {
599
+ background: var(--accent-20);
600
+ border-color: var(--accent);
601
+ }
602
+ .switch-label input:checked + .switch-slider::after {
603
+ transform: translateX(16px);
604
+ background: var(--accent);
605
+ }
606
+
607
+ /* ===== Toast System ===== */
608
+ .toast-container {
609
+ position: fixed;
610
+ bottom: 1rem;
611
+ right: 1rem;
612
+ z-index: 10000;
613
+ display: flex;
614
+ flex-direction: column-reverse;
615
+ gap: 0.5rem;
616
+ }
617
+
618
+ .toast {
619
+ padding: 0.75rem 1rem;
620
+ border-radius: var(--radius-lg);
621
+ font-size: 0.8rem;
622
+ color: var(--text-primary);
623
+ background: var(--bg-secondary);
624
+ border: 1px solid var(--border);
625
+ box-shadow: var(--shadow-md);
626
+ animation: toast-in 0.3s ease;
627
+ max-width: 350px;
628
+ }
629
+ .toast-success { border-left: 3px solid var(--success); }
630
+ .toast-error { border-left: 3px solid var(--error); }
631
+ .toast-info { border-left: 3px solid var(--accent); }
632
+
633
+ @keyframes toast-in {
634
+ from { opacity: 0; transform: translateY(10px); }
635
+ to { opacity: 1; transform: translateY(0); }
636
+ }
637
+
638
+ /* ===== Floating Panel ===== */
639
+ .floating-panel {
640
+ background: var(--bg-secondary);
641
+ border: 1px solid var(--border);
642
+ border-radius: var(--radius-xl);
643
+ box-shadow: var(--shadow-md);
644
+ overflow: hidden;
645
+ }
646
+
647
+ .floating-panel-header {
648
+ display: flex;
649
+ align-items: center;
650
+ justify-content: space-between;
651
+ padding: 0.5rem 0.75rem;
652
+ background: var(--bg-tertiary);
653
+ border-bottom: 1px solid var(--border-subtle);
654
+ font-size: 0.8rem;
655
+ font-weight: 600;
656
+ color: var(--text-secondary);
657
+ user-select: none;
658
+ }
659
+
660
+ .floating-panel-controls {
661
+ display: flex;
662
+ align-items: center;
663
+ gap: 0.25rem;
664
+ }
665
+
666
+ .floating-close-btn {
667
+ background: none;
668
+ border: none;
669
+ color: var(--text-muted);
670
+ font-size: 1.1rem;
671
+ cursor: pointer;
672
+ padding: 0 0.25rem;
673
+ line-height: 1;
674
+ }
675
+ .floating-close-btn:hover { color: var(--text-primary); }
676
+
677
+ .floating-minimize-btn {
678
+ background: none;
679
+ border: none;
680
+ color: var(--text-muted);
681
+ font-size: 1rem;
682
+ cursor: pointer;
683
+ padding: 0 0.25rem;
684
+ line-height: 1;
685
+ }
686
+ .floating-minimize-btn:hover { color: var(--text-primary); }
687
+
688
+ .floating-panel.minimized .floating-panel-content {
689
+ display: none;
690
+ }
691
+ .floating-panel.minimized {
692
+ position: fixed;
693
+ bottom: 8px;
694
+ width: auto;
695
+ right: auto;
696
+ top: auto;
697
+ }
698
+
699
+ /* ===== Joystick Styles ===== */
700
+ .joystick-area {
701
+ background: var(--bg-tertiary);
702
+ border: 2px solid var(--border);
703
+ }
704
+
705
+ .joystick-knob {
706
+ border-radius: 50%;
707
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
708
+ }
709
+
710
+ .joystick-head .joystick-area {
711
+ border-color: var(--accent-50);
712
+ }
713
+ .joystick-head .joystick-knob {
714
+ background: var(--accent);
715
+ }
716
+
717
+ .joystick-values {
718
+ display: flex;
719
+ gap: 1rem;
720
+ font-size: 0.7rem;
721
+ font-family: 'SF Mono', Monaco, monospace;
722
+ color: var(--text-secondary);
723
+ }
724
+
725
+ /* ===== Camera Panel ===== */
726
+ .camera-container { position: relative; width: 320px; height: 240px; background: #000; overflow: hidden; }
727
+ .camera-container video { width: 100%; height: 100%; object-fit: cover; display: block; }
728
+ .video-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-muted); font-size: 0.8rem; }
729
+ .video-placeholder.hidden { display: none; }
730
+ .placeholder-icon { font-size: 2rem; margin-bottom: 0.5rem; }
731
+ .cam-status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: transparent; }
732
+ #cameraToggle.active { background: var(--accent-20); border-color: var(--accent); color: var(--accent); }
733
+ .camera-controls { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; background: var(--bg-tertiary); }
734
+ .cam-btn { background: none; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 0.25rem 0.5rem; cursor: pointer; font-size: 1.1rem; color: var(--text-primary); }
735
+ .cam-btn:hover { background: var(--bg-secondary); }
736
+ .cam-btn.recording { background: rgba(239,68,68,0.2); border-color: rgba(239,68,68,0.5); animation: pulse-red 1s infinite; }
737
+ .record-timer { font-family: 'SF Mono', Monaco, monospace; font-size: 0.75rem; color: var(--text-secondary); }
738
+ @keyframes pulse-red { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
739
+ .cam-btn.listening { background: rgba(99,102,241,0.2); border-color: rgba(99,102,241,0.5); color: var(--accent); }
740
+ .cam-btn.speaking { background: rgba(34,197,94,0.2); border-color: rgba(34,197,94,0.5); animation: pulse-green 1s infinite; }
741
+ @keyframes pulse-green { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
742
+
743
+ /* ===== Media Gallery ===== */
744
+ .media-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; }
745
+ .media-count { font-size: 0.75rem; color: var(--text-muted); }
746
+ .media-empty { grid-column: 1 / -1; text-align: center; color: var(--text-muted); padding: 2rem; font-size: 0.85rem; }
747
+ .media-item { position: relative; background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; cursor: pointer; transition: border-color 0.2s; }
748
+ .media-item:hover { border-color: var(--accent-50); }
749
+ .media-item img, .media-item video { width: 100%; aspect-ratio: 4/3; object-fit: cover; display: block; background: #000; }
750
+ .media-duration { position: absolute; bottom: 40px; right: 6px; background: rgba(0,0,0,0.8); color: #fff; font-size: 0.65rem; font-family: 'SF Mono', Monaco, monospace; padding: 1px 5px; border-radius: 3px; }
751
+ .media-item-info { padding: 0.5rem; display: flex; justify-content: space-between; align-items: center; }
752
+ .media-item-name { font-size: 0.7rem; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
753
+ .media-item-size { font-size: 0.65rem; color: var(--text-muted); font-family: 'SF Mono', Monaco, monospace; margin-left: 0.5rem; }
754
+ .media-item-actions { display: flex; gap: 0.25rem; opacity: 0; transition: opacity 0.2s; }
755
+ .media-item:hover .media-item-actions { opacity: 1; }
756
+ .media-action-btn { background: none; border: none; cursor: pointer; font-size: 0.85rem; padding: 2px 4px; border-radius: var(--radius-sm); color: var(--text-secondary); }
757
+ .media-action-btn:hover { background: var(--white-10); color: var(--text-primary); }
758
+ .media-action-btn.delete:hover { color: var(--error); }
759
+ .media-lightbox { position: fixed; inset: 0; z-index: 10001; background: rgba(0,0,0,0.9); display: flex; align-items: center; justify-content: center; cursor: pointer; }
760
+ .media-lightbox img, .media-lightbox video { max-width: 90vw; max-height: 90vh; border-radius: var(--radius-lg); }
761
+ .media-lightbox-close { position: absolute; top: 1rem; right: 1rem; background: none; border: none; color: white; font-size: 2rem; cursor: pointer; }
762
+
763
+ /* Music */
764
+ .music-now-playing { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; background: var(--accent-10); border-bottom: 1px solid var(--border-subtle); font-size: 0.75rem; color: var(--accent); }
765
+ .music-now-playing span { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
766
+ .music-now-playing .btn-xs { font-size: 0.65rem; padding: 0.15rem 0.5rem; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-secondary); cursor: pointer; }
767
+ .music-cover { width: 100%; aspect-ratio: 1/1; object-fit: cover; display: block; background: var(--bg-tertiary); }
768
+ .music-cover-placeholder { display: flex; align-items: center; justify-content: center; font-size: 2.5rem; color: var(--text-muted); }
769
+ .music-artist { display: block; font-size: 0.65rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
770
+ .music-active { border-color: var(--accent) !important; box-shadow: 0 0 0 1px var(--accent-50); }
771
+ .upload-btn { font-size: 0.7rem; color: var(--accent); cursor: pointer; padding: 0.15rem 0.5rem; border: 1px solid var(--accent-50); border-radius: var(--radius-sm); background: transparent; transition: background 0.2s; }
772
+ .upload-btn:hover { background: var(--accent-10); }
773
+ .upload-btn input[type="file"] { display: none; }
774
+
775
+ /* Sound recordings list */
776
+ .sound-gallery { display: flex; flex-direction: column; gap: 0.25rem; }
777
+ .sound-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.75rem; border-radius: var(--radius-md); background: var(--bg-secondary); }
778
+ .sound-item:hover { background: var(--bg-tertiary); }
779
+ .sound-icon { font-size: 1.2rem; flex-shrink: 0; }
780
+ .sound-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 0.5rem; }
781
+ .sound-name { font-size: 0.75rem; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
782
+ .sound-meta { font-size: 0.65rem; color: var(--text-muted); font-family: 'SF Mono', Monaco, monospace; white-space: nowrap; }
783
+ .sound-actions { display: flex; gap: 0.25rem; opacity: 0; transition: opacity 0.2s; }
784
+ .sound-item:hover .sound-actions { opacity: 1; }
785
+ .sound-player { width: 100%; max-width: 240px; height: 28px; }
786
+
787
+ #joystickToggle.active {
788
+ background: var(--accent-20);
789
+ border-color: var(--accent);
790
+ color: var(--accent);
791
+ }
792
+
793
+ /* ===== Conversation Tab ===== */
794
+
795
+ /* Listener hero area with large mic button */
796
+ .listener-hero {
797
+ display: flex;
798
+ flex-direction: column;
799
+ align-items: center;
800
+ padding: 1.25rem 1rem 1rem;
801
+ gap: 0.6rem;
802
+ }
803
+
804
+ /* Large circular mic button */
805
+ .mic-btn {
806
+ position: relative;
807
+ width: 72px;
808
+ height: 72px;
809
+ border-radius: 50%;
810
+ border: 2px solid var(--border);
811
+ background: var(--bg-tertiary);
812
+ color: var(--text-muted);
813
+ cursor: pointer;
814
+ transition: all 0.25s ease;
815
+ display: flex;
816
+ align-items: center;
817
+ justify-content: center;
818
+ }
819
+ .mic-btn:hover {
820
+ background: var(--bg-elevated);
821
+ border-color: var(--text-secondary);
822
+ color: var(--text-primary);
823
+ transform: scale(1.05);
824
+ }
825
+ .mic-btn .mic-icon { width: 32px; height: 32px; z-index: 1; }
826
+ .mic-btn .mic-rings {
827
+ position: absolute;
828
+ inset: -4px;
829
+ border-radius: 50%;
830
+ border: 2px solid transparent;
831
+ transition: all 0.3s;
832
+ }
833
+
834
+ /* Active (listening) state */
835
+ .mic-btn.active {
836
+ background: rgba(34, 197, 94, 0.15);
837
+ border-color: rgba(34, 197, 94, 0.6);
838
+ color: var(--success);
839
+ box-shadow: 0 0 20px rgba(34, 197, 94, 0.25), 0 0 40px rgba(34, 197, 94, 0.1);
840
+ }
841
+ .mic-btn.active .mic-rings {
842
+ border-color: rgba(34, 197, 94, 0.3);
843
+ animation: mic-pulse 2s ease-in-out infinite;
844
+ }
845
+ @keyframes mic-pulse {
846
+ 0%, 100% { transform: scale(1); opacity: 1; }
847
+ 50% { transform: scale(1.15); opacity: 0.4; }
848
+ }
849
+
850
+ .mic-status {
851
+ display: flex;
852
+ align-items: center;
853
+ gap: 0.5rem;
854
+ }
855
+
856
+ /* Keep old class name for Save Prompt button etc */
857
+ .listener-btn {
858
+ padding: 0.4rem 1rem;
859
+ border: 1px solid var(--border);
860
+ border-radius: var(--radius-md);
861
+ background: var(--bg-tertiary);
862
+ color: var(--text-primary);
863
+ font-size: 0.8rem;
864
+ cursor: pointer;
865
+ transition: all 0.15s;
866
+ white-space: nowrap;
867
+ }
868
+ .listener-btn:hover { background: var(--bg-elevated); }
869
+
870
+ .status-dot {
871
+ width: 8px; height: 8px;
872
+ border-radius: 50%;
873
+ flex-shrink: 0;
874
+ }
875
+ .status-dot.online { background: var(--success); box-shadow: 0 0 6px var(--success); }
876
+ .status-dot.offline { background: var(--text-muted); }
877
+
878
+ .listener-label {
879
+ font-size: 0.75rem;
880
+ color: var(--text-secondary);
881
+ }
882
+
883
+ .speaking-indicator {
884
+ font-size: 0.7rem;
885
+ color: var(--accent);
886
+ animation: pulse 1s infinite;
887
+ }
888
+
889
+ .listener-selects {
890
+ display: flex;
891
+ gap: 0.5rem;
892
+ margin-left: auto;
893
+ }
894
+ .listener-selects select {
895
+ padding: 0.3rem 0.5rem;
896
+ border: 1px solid var(--border);
897
+ border-radius: var(--radius-sm);
898
+ background: var(--bg-tertiary);
899
+ color: var(--text-primary);
900
+ font-size: 0.7rem;
901
+ }
902
+
903
+ /* Provider Settings */
904
+ .provider-card { padding: 0; }
905
+ .provider-summary {
906
+ padding: 0.75rem 1rem;
907
+ cursor: pointer;
908
+ font-size: 0.85rem;
909
+ font-weight: 500;
910
+ color: var(--text-secondary);
911
+ }
912
+ .provider-summary:hover { color: var(--text-primary); }
913
+
914
+ .provider-grid {
915
+ display: grid;
916
+ grid-template-columns: 1fr 1fr;
917
+ gap: 1rem;
918
+ padding: 0 1rem 1rem;
919
+ }
920
+
921
+ .provider-section {
922
+ background: var(--bg-tertiary);
923
+ border-radius: var(--radius-md);
924
+ padding: 0.75rem;
925
+ }
926
+ .provider-section-title {
927
+ font-size: 0.7rem;
928
+ text-transform: uppercase;
929
+ letter-spacing: 0.5px;
930
+ color: var(--text-muted);
931
+ margin-bottom: 0.5rem;
932
+ }
933
+
934
+ .provider-row {
935
+ display: flex;
936
+ align-items: center;
937
+ gap: 0.5rem;
938
+ margin-bottom: 0.35rem;
939
+ font-size: 0.75rem;
940
+ }
941
+ .provider-row label:not(.toggle-switch) {
942
+ min-width: 70px;
943
+ color: var(--text-secondary);
944
+ font-size: 0.7rem;
945
+ }
946
+ .provider-row select, .provider-row input[type="range"] {
947
+ flex: 1;
948
+ padding: 0.25rem 0.4rem;
949
+ border: 1px solid var(--border-subtle);
950
+ border-radius: var(--radius-sm);
951
+ background: var(--bg-secondary);
952
+ color: var(--text-primary);
953
+ font-size: 0.7rem;
954
+ }
955
+ .slider-value {
956
+ font-family: 'SF Mono', Monaco, monospace;
957
+ font-size: 0.65rem;
958
+ color: var(--text-muted);
959
+ min-width: 3em;
960
+ text-align: right;
961
+ }
962
+
963
+ /* Toggle Switch */
964
+ .toggle-switch {
965
+ position: relative;
966
+ display: inline-block;
967
+ width: 28px;
968
+ min-width: 28px;
969
+ height: 16px;
970
+ flex-shrink: 0;
971
+ }
972
+ .toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
973
+ .toggle-slider {
974
+ position: absolute;
975
+ cursor: pointer;
976
+ top: 0; left: 0; right: 0; bottom: 0;
977
+ background: var(--bg-tertiary);
978
+ border: 1px solid var(--border);
979
+ border-radius: 16px;
980
+ transition: 0.2s;
981
+ }
982
+ .toggle-slider:before {
983
+ position: absolute;
984
+ content: "";
985
+ height: 10px;
986
+ width: 10px;
987
+ left: 2px;
988
+ bottom: 2px;
989
+ background: var(--text-muted);
990
+ border-radius: 50%;
991
+ transition: 0.2s;
992
+ }
993
+ .toggle-switch input:checked + .toggle-slider {
994
+ background: var(--accent);
995
+ border-color: var(--accent);
996
+ }
997
+ .toggle-switch input:checked + .toggle-slider:before {
998
+ transform: translateX(12px);
999
+ background: white;
1000
+ }
1001
+
1002
+ /* API Key Rows */
1003
+ .api-key-row {
1004
+ display: flex;
1005
+ justify-content: space-between;
1006
+ align-items: center;
1007
+ padding: 0.3rem 0;
1008
+ border-bottom: 1px solid var(--border-subtle);
1009
+ }
1010
+ .api-key-row:last-child { border-bottom: none; }
1011
+ .api-key-label { display: flex; flex-direction: column; }
1012
+ .api-key-name {
1013
+ font-size: 0.75rem;
1014
+ font-weight: 500;
1015
+ color: var(--text-primary);
1016
+ text-transform: capitalize;
1017
+ }
1018
+ .api-key-caps {
1019
+ font-size: 0.6rem;
1020
+ color: var(--text-muted);
1021
+ }
1022
+ .api-key-input-group {
1023
+ display: flex;
1024
+ gap: 4px;
1025
+ align-items: center;
1026
+ }
1027
+ .api-key-input {
1028
+ width: 160px;
1029
+ padding: 0.2rem 0.4rem;
1030
+ border: 1px solid var(--border-subtle);
1031
+ border-radius: var(--radius-sm);
1032
+ background: var(--bg-secondary);
1033
+ color: var(--text-primary);
1034
+ font-size: 0.7rem;
1035
+ font-family: monospace;
1036
+ }
1037
+ .api-key-save-btn {
1038
+ background: none;
1039
+ border: 1px solid var(--border);
1040
+ border-radius: var(--radius-sm);
1041
+ color: var(--success);
1042
+ cursor: pointer;
1043
+ padding: 0.15rem 0.4rem;
1044
+ font-size: 0.75rem;
1045
+ }
1046
+ .api-key-save-btn:hover { background: var(--white-5); }
1047
+
1048
+ /* Waveform */
1049
+ .waveform-card { padding: 0.5rem; }
1050
+ #waveformCanvas {
1051
+ width: 100%;
1052
+ display: block;
1053
+ background: rgba(0, 0, 0, 0.25);
1054
+ border-radius: 6px;
1055
+ }
1056
+
1057
+ /* Transcript */
1058
+ .transcript-card { padding: 0; }
1059
+ .transcript-toolbar {
1060
+ display: flex;
1061
+ align-items: center;
1062
+ justify-content: space-between;
1063
+ padding: 0.35rem 0.75rem;
1064
+ border-bottom: 1px solid var(--border);
1065
+ }
1066
+ .transcript-title {
1067
+ font-size: 0.7rem;
1068
+ font-weight: 600;
1069
+ text-transform: uppercase;
1070
+ letter-spacing: 0.05em;
1071
+ opacity: 0.6;
1072
+ }
1073
+ .transcript-actions { display: flex; gap: 0.4rem; }
1074
+ .btn-xs {
1075
+ font-size: 0.65rem;
1076
+ padding: 0.15rem 0.5rem;
1077
+ border-radius: var(--radius-sm);
1078
+ border: 1px solid var(--border);
1079
+ background: var(--bg-secondary);
1080
+ color: var(--text-secondary);
1081
+ cursor: pointer;
1082
+ transition: all 0.15s;
1083
+ }
1084
+ .btn-xs:hover { background: var(--white-10); color: var(--text-primary); }
1085
+ .btn-warning { border-color: rgba(245,158,11,0.3); color: var(--warning); }
1086
+ .btn-warning:hover { background: rgba(245,158,11,0.1); }
1087
+ .transcript-container {
1088
+ max-height: 400px;
1089
+ overflow-y: auto;
1090
+ scroll-behavior: smooth;
1091
+ }
1092
+
1093
+ .transcript-table {
1094
+ width: 100%;
1095
+ border-collapse: collapse;
1096
+ font-size: 0.75rem;
1097
+ }
1098
+ .transcript-table thead th {
1099
+ position: sticky;
1100
+ top: 0;
1101
+ background: var(--bg-tertiary);
1102
+ padding: 0.4rem 0.5rem;
1103
+ text-align: left;
1104
+ font-size: 0.65rem;
1105
+ text-transform: uppercase;
1106
+ color: var(--text-muted);
1107
+ letter-spacing: 0.3px;
1108
+ border-bottom: 1px solid var(--border);
1109
+ }
1110
+ .ts-th-time { width: 65px; }
1111
+ .ts-th-conf { width: 45px; }
1112
+ .ts-th-vol { width: 40px; }
1113
+
1114
+ .transcript-row td {
1115
+ padding: 0.35rem 0.5rem;
1116
+ border-bottom: 1px solid var(--border-subtle);
1117
+ vertical-align: top;
1118
+ }
1119
+ .ts-time {
1120
+ font-family: 'SF Mono', Monaco, monospace;
1121
+ font-size: 0.65rem;
1122
+ color: var(--text-muted);
1123
+ white-space: nowrap;
1124
+ }
1125
+ .ts-conf, .ts-vol {
1126
+ font-family: 'SF Mono', Monaco, monospace;
1127
+ font-size: 0.65rem;
1128
+ text-align: center;
1129
+ }
1130
+ .ts-msg {
1131
+ word-break: break-word;
1132
+ color: var(--text-primary);
1133
+ }
1134
+ .ts-speaker {
1135
+ font-weight: 600;
1136
+ color: var(--accent);
1137
+ }
1138
+ .ts-source {
1139
+ font-size: 0.65rem;
1140
+ padding: 1px 6px;
1141
+ border-radius: 3px;
1142
+ background: var(--accent-20);
1143
+ color: var(--accent);
1144
+ }
1145
+ .ts-tool {
1146
+ font-family: 'SF Mono', Monaco, monospace;
1147
+ font-size: 0.7rem;
1148
+ color: var(--warning);
1149
+ }
1150
+ .tool-icon { font-size: 0.85rem; margin-right: 0.3rem; }
1151
+ .tool-label {
1152
+ font-weight: 600;
1153
+ font-size: 0.72rem;
1154
+ color: var(--text-primary);
1155
+ }
1156
+ .tool-arg {
1157
+ font-size: 0.68rem;
1158
+ margin-left: 0.4rem;
1159
+ padding: 1px 7px;
1160
+ border-radius: 10px;
1161
+ background: var(--accent-20);
1162
+ color: var(--accent);
1163
+ }
1164
+ .tool-summary { display: flex; align-items: center; gap: 0; }
1165
+ .tool-chevron {
1166
+ font-size: 0.5rem;
1167
+ margin-left: auto;
1168
+ padding-left: 0.5rem;
1169
+ opacity: 0;
1170
+ transition: transform 0.2s, opacity 0.15s;
1171
+ color: var(--text-secondary);
1172
+ }
1173
+ .tool-expandable { cursor: pointer; }
1174
+ .tool-expandable:hover .tool-chevron { opacity: 0.6; }
1175
+ .tool-expanded .tool-chevron { transform: rotate(90deg); opacity: 0.6; }
1176
+ .tool-detail {
1177
+ display: none;
1178
+ margin: 0.4rem 0 0;
1179
+ padding: 0.5rem 0.6rem;
1180
+ font-family: 'SF Mono', Monaco, monospace;
1181
+ font-size: 0.65rem;
1182
+ line-height: 1.5;
1183
+ background: var(--bg-primary);
1184
+ border: 1px solid var(--border);
1185
+ border-radius: var(--radius-sm);
1186
+ color: var(--text-secondary);
1187
+ white-space: pre-wrap;
1188
+ word-break: break-word;
1189
+ max-height: 200px;
1190
+ overflow-y: auto;
1191
+ }
1192
+ .tool-expanded .tool-detail { display: block; }
1193
+ .ts-error { color: var(--error); }
1194
+
1195
+ /* Row types */
1196
+ .user-row { background: transparent; }
1197
+ .response-row { background: var(--white-5); }
1198
+ .response-row .ts-msg { color: var(--text-primary); }
1199
+ .tool-row { background: var(--white-5); font-size: 0.7rem; opacity: 0.8; }
1200
+ .error-row { background: rgba(239, 68, 68, 0.05); }
1201
+ .ignored-row { opacity: 0.4; }
1202
+ .below-threshold { opacity: 0.35; }
1203
+ .below-threshold .ts-msg { text-decoration: line-through; }
1204
+ .ts-filtered { font-size: 0.65rem; opacity: 0.6; font-style: italic; text-decoration: none; display: inline; }
1205
+
1206
+ /* Text Input */
1207
+ .input-card { padding: 0.5rem 0.75rem; }
1208
+ .input-bar {
1209
+ display: flex;
1210
+ gap: 0.5rem;
1211
+ }
1212
+ .conversation-input {
1213
+ flex: 1;
1214
+ padding: 0.4rem 0.75rem;
1215
+ border: 1px solid var(--border);
1216
+ border-radius: var(--radius-md);
1217
+ background: var(--bg-tertiary);
1218
+ color: var(--text-primary);
1219
+ font-size: 0.8rem;
1220
+ }
1221
+ .conversation-input:focus {
1222
+ outline: none;
1223
+ border-color: var(--accent);
1224
+ }
1225
+
1226
+ /* ===== Responsive ===== */
1227
+ @media (max-width: 768px) {
1228
+ .charts-grid-3 { grid-template-columns: 1fr 1fr; }
1229
+ .bottom-info-row { grid-template-columns: 1fr; }
1230
+ .info-grid { grid-template-columns: 1fr; }
1231
+ .header-right .status-badge { display: none; }
1232
+ .provider-grid { grid-template-columns: 1fr; }
1233
+ .listener-selects { margin-left: 0; }
1234
+ .listener-bar { flex-wrap: wrap; }
1235
+ }
1236
+
1237
+ @media (max-width: 480px) {
1238
+ .charts-grid-3 { grid-template-columns: 1fr; }
1239
+ .tab { padding: 0.75rem 0.5rem; font-size: 0.75rem; }
1240
+ }
hello_world/static/index.html ADDED
@@ -0,0 +1,652 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Hello World</title>
7
+ <link rel="icon" type="image/svg+xml" href="https://pollen-robotics-reachy-mini.hf.space/assets/reachy-icon.svg">
8
+ <link rel="stylesheet" href="/static/css/styles.css">
9
+ <script type="importmap">
10
+ {
11
+ "imports": {
12
+ "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
13
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/",
14
+ "three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/",
15
+ "urdf-loader": "https://cdn.jsdelivr.net/npm/urdf-loader@0.12.3/src/URDFLoader.js"
16
+ }
17
+ }
18
+ </script>
19
+ </head>
20
+ <body>
21
+ <header class="header-bar">
22
+ <nav class="tabs">
23
+ <div class="tab active" data-tab="status">Status</div>
24
+ <div class="tab" data-tab="telemetry">Telemetry</div>
25
+ <div class="tab" data-tab="conversation">Conversation</div>
26
+ <div class="tab" data-tab="media">Media</div>
27
+ </nav>
28
+ <div class="header-right">
29
+ <span id="issuesBadge" class="issues-badge hidden" title="Click to view status">
30
+ <span class="issues-dot"></span>
31
+ <span class="issues-count">0</span>
32
+ </span>
33
+ <div class="status-badge">
34
+ <span class="status-indicator info" title="CPU usage">
35
+ <span id="headerCpu" class="stat-num">--%</span>
36
+ <span>CPU</span>
37
+ </span>
38
+ <span class="status-indicator info" title="RAM usage">
39
+ <span id="headerRam" class="stat-num">--%</span>
40
+ <span>RAM</span>
41
+ </span>
42
+ <span class="status-indicator info" title="Network speed">
43
+ <span id="headerNet" class="stat-num">--</span>
44
+ <span>Net</span>
45
+ </span>
46
+ </div>
47
+ <div class="header-volume" title="Robot volume">
48
+ <span class="header-volume-icon">&#x1F50A;</span>
49
+ <input type="range" id="headerVolumeSlider" min="0" max="100" value="100" class="header-volume-slider">
50
+ <span id="headerVolumeValue" class="header-volume-value">100</span>
51
+ </div>
52
+ <button id="cameraToggle" class="btn btn-secondary btn-sm" title="Toggle camera">&#x1F4F7;</button>
53
+ <button id="joystickToggle" class="btn btn-secondary btn-sm" title="Toggle joystick">&#x1F579;</button>
54
+ <button id="themeToggle" class="btn btn-secondary btn-sm" title="Toggle theme">&#x2600;</button>
55
+ </div>
56
+ </header>
57
+
58
+ <!-- Status Tab -->
59
+ <div id="status" class="tab-content active">
60
+ <!-- Service Status Pills -->
61
+ <div class="status-pills-container">
62
+ <div class="status-pills-group">
63
+ <span class="status-pills-label">Robot</span>
64
+ <div class="status-pills">
65
+ <span id="pillApp" class="status-pill connecting" title="WebSocket to app">
66
+ <span class="pill-dot"></span>App
67
+ </span>
68
+ <span id="pillAPI" class="status-pill connecting" title="Daemon API on port 8000">
69
+ <span class="pill-dot"></span>API
70
+ </span>
71
+ <span id="pillSerial" class="status-pill connecting" title="Serial connection to motors">
72
+ <span class="pill-dot"></span>Serial
73
+ </span>
74
+ <span id="pillZenoh" class="status-pill connecting" title="Zenoh pub/sub - pose/joint updates">
75
+ <span class="pill-dot"></span>Zenoh
76
+ </span>
77
+ <span id="pillThrottle" class="status-pill" title="Thermal throttling status">
78
+ <span class="pill-dot"></span>Throt
79
+ </span>
80
+ </div>
81
+ </div>
82
+ </div>
83
+
84
+ <!-- System Stats Charts -->
85
+ <div class="card">
86
+ <div class="card-header">
87
+ <span class="card-title">System Stats</span>
88
+ </div>
89
+ <div id="systemStatsGrid" class="charts-grid charts-grid-3">
90
+ <div class="chart-container" id="chart-temp">
91
+ <div class="chart-header">
92
+ <span class="chart-label">Temp</span>
93
+ <span class="chart-value" id="tempValue">--</span>
94
+ </div>
95
+ <canvas id="tempChart"></canvas>
96
+ </div>
97
+ <div class="chart-container" id="chart-cpu">
98
+ <div class="chart-header">
99
+ <span class="chart-label">CPU</span>
100
+ <span class="chart-value" id="cpuValue">--</span>
101
+ </div>
102
+ <canvas id="cpuChart"></canvas>
103
+ <div class="chart-legend" id="cpuLegend"></div>
104
+ </div>
105
+ <div class="chart-container" id="chart-ram">
106
+ <div class="chart-header">
107
+ <span class="chart-label">RAM</span>
108
+ <span class="chart-value" id="ramValue">--</span>
109
+ </div>
110
+ <canvas id="ramChart"></canvas>
111
+ <div class="chart-legend" id="ramLegend"></div>
112
+ </div>
113
+ <div class="chart-container" id="chart-disk">
114
+ <div class="chart-header">
115
+ <span class="chart-label">Disk</span>
116
+ </div>
117
+ <div class="disk-charts">
118
+ <div class="disk-chart-item">
119
+ <canvas id="diskLocalChart" width="60" height="60"></canvas>
120
+ <div class="disk-label">Disk</div>
121
+ <div id="diskLocalInfo" class="disk-info">--</div>
122
+ </div>
123
+ <div class="disk-chart-item">
124
+ <canvas id="swapChart" width="60" height="60"></canvas>
125
+ <div class="disk-label">Swap</div>
126
+ <div id="swapInfo" class="disk-info">--</div>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ <div class="chart-container" id="chart-network">
131
+ <div class="chart-header">
132
+ <span class="chart-label">Network</span>
133
+ <span class="net-stats">
134
+ <span><span class="text-success">&#x2193;</span><span id="netDownValue" class="net-value">--</span></span>
135
+ <span><span class="text-error">&#x2191;</span><span id="netUpValue" class="net-value">--</span></span>
136
+ </span>
137
+ </div>
138
+ <canvas id="netChart"></canvas>
139
+ </div>
140
+ <div class="chart-container" id="chart-wifi">
141
+ <div class="chart-header">
142
+ <span class="chart-label">WiFi</span>
143
+ <span class="chart-value" id="wifiStrength">--</span>
144
+ </div>
145
+ <canvas id="wifiChart"></canvas>
146
+ <div class="chart-legend" id="wifiSsid">--</div>
147
+ </div>
148
+ <div class="chart-container" id="chart-load">
149
+ <div class="chart-header">
150
+ <span class="chart-label">Load</span>
151
+ <span class="chart-value" id="loadValue">--</span>
152
+ </div>
153
+ <canvas id="loadChart"></canvas>
154
+ <div class="chart-legend" id="loadAvg">-- / -- / --</div>
155
+ </div>
156
+ <div class="chart-container" id="chart-fan">
157
+ <div class="chart-header">
158
+ <span class="chart-label">Fan</span>
159
+ <span class="chart-value" id="fanValue">--</span>
160
+ </div>
161
+ <canvas id="fanChart"></canvas>
162
+ </div>
163
+ <div class="chart-container" id="chart-diskio">
164
+ <div class="chart-header">
165
+ <span class="chart-label">Disk I/O</span>
166
+ <span class="net-stats">
167
+ <span><span class="text-success">R:</span><span id="diskReadSpeed" class="net-value">--</span></span>
168
+ <span><span class="text-error">W:</span><span id="diskWriteSpeed" class="net-value">--</span></span>
169
+ </span>
170
+ </div>
171
+ <canvas id="diskIoChart"></canvas>
172
+ </div>
173
+ </div>
174
+
175
+ <!-- Bottom Row: Processes + Hardware -->
176
+ <div class="bottom-info-row">
177
+ <div class="processes-section">
178
+ <div class="processes-header">Top Processes</div>
179
+ <div id="topProcesses" class="processes-list">--</div>
180
+ </div>
181
+ <div class="hardware-section">
182
+ <div class="hardware-header">Hardware Inventory</div>
183
+ <div id="hardwareInfo" class="hardware-grid">
184
+ <div class="hw-item"><span class="hw-label">Board</span><span class="hw-value" id="hwBoard">--</span></div>
185
+ <div class="hw-item"><span class="hw-label">Serial</span><span class="hw-value hw-mono" id="hwSerial">--</span></div>
186
+ <div class="hw-item"><span class="hw-label">CPU</span><span class="hw-value" id="hwCpu">--</span></div>
187
+ <div class="hw-item"><span class="hw-label">Memory</span><span class="hw-value" id="hwMemory">--</span></div>
188
+ <div class="hw-item"><span class="hw-label">Storage</span><span class="hw-value" id="hwStorage">--</span></div>
189
+ <div class="hw-item"><span class="hw-label">Kernel</span><span class="hw-value hw-mono" id="hwKernel">--</span></div>
190
+ <div class="hw-item"><span class="hw-label">Firmware</span><span class="hw-value" id="hwFirmware">--</span></div>
191
+ <div class="hw-item"><span class="hw-label">Network</span><span class="hw-value" id="hwNetwork">--</span></div>
192
+ <div class="hw-item"><span class="hw-label">USB Devices</span><span class="hw-value" id="hwUsb">--</span></div>
193
+ <div class="hw-item"><span class="hw-label">I2C Buses</span><span class="hw-value" id="hwI2c">--</span></div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ </div>
198
+
199
+ <!-- App Info -->
200
+ <div class="card">
201
+ <div class="card-header"><span class="card-title">App Info</span></div>
202
+ <div class="info-row"><span class="info-label">App Name</span><span class="info-value">hello_world</span></div>
203
+ <div class="info-row"><span class="info-label">Control Loop</span><span class="info-value">50Hz</span></div>
204
+ <div class="section-divider"></div>
205
+ <div class="info-row"><span class="info-label">Robot Uptime</span><span class="info-value" id="uptimeRobot">--</span></div>
206
+ <div class="info-row"><span class="info-label">Daemon Uptime</span><span class="info-value" id="uptimeDaemon">--</span></div>
207
+ <div class="info-row"><span class="info-label">App Uptime</span><span class="info-value" id="uptimeApp">--</span></div>
208
+ </div>
209
+ </div>
210
+
211
+ <!-- Telemetry Tab -->
212
+ <div id="telemetry" class="tab-content">
213
+ <!-- 3D Simulation -->
214
+ <div class="card">
215
+ <div class="card-header">
216
+ <span class="card-title">3D View</span>
217
+ <span class="chart-value" id="simStatus" style="font-size: 0.75rem; opacity: 0.6;">Loading...</span>
218
+ </div>
219
+ <div id="simContainer" style="width: 100%; height: 400px; border-radius: 0 0 8px 8px; overflow: hidden;"></div>
220
+ </div>
221
+
222
+ <!-- Head Telemetry Charts -->
223
+ <div class="card">
224
+ <div class="card-header">
225
+ <span class="card-title">Head Telemetry</span>
226
+ <label class="switch-label" title="Return to center on release">
227
+ <span class="switch-text">Return to center</span>
228
+ <input type="checkbox" id="returnToCenter" checked>
229
+ <span class="switch-slider"></span>
230
+ </label>
231
+ </div>
232
+ <div class="charts-grid charts-grid-3">
233
+ <div class="chart-container">
234
+ <div class="chart-header"><span class="chart-label">Roll</span><span class="chart-value" id="rollValue">--</span></div>
235
+ <canvas id="rollChart"></canvas>
236
+ </div>
237
+ <div class="chart-container">
238
+ <div class="chart-header"><span class="chart-label">Pitch</span><span class="chart-value" id="pitchValue">--</span></div>
239
+ <canvas id="pitchChart"></canvas>
240
+ </div>
241
+ <div class="chart-container">
242
+ <div class="chart-header"><span class="chart-label">Yaw</span><span class="chart-value" id="yawValue">--</span></div>
243
+ <canvas id="yawChart"></canvas>
244
+ </div>
245
+ <div class="chart-container">
246
+ <div class="chart-header"><span class="chart-label">X</span><span class="chart-value" id="posXValue">--</span></div>
247
+ <canvas id="posXChart"></canvas>
248
+ </div>
249
+ <div class="chart-container">
250
+ <div class="chart-header"><span class="chart-label">Y</span><span class="chart-value" id="posYValue">--</span></div>
251
+ <canvas id="posYChart"></canvas>
252
+ </div>
253
+ <div class="chart-container">
254
+ <div class="chart-header"><span class="chart-label">Z</span><span class="chart-value" id="posZValue">--</span></div>
255
+ <canvas id="posZChart"></canvas>
256
+ </div>
257
+ <div class="chart-container">
258
+ <div class="chart-header"><span class="chart-label">Ant R</span><span class="chart-value" id="antennaRightValue">--</span></div>
259
+ <canvas id="antennaRightChart"></canvas>
260
+ </div>
261
+ <div class="chart-container">
262
+ <div class="chart-header"><span class="chart-label">Ant L</span><span class="chart-value" id="antennaLeftValue">--</span></div>
263
+ <canvas id="antennaLeftChart"></canvas>
264
+ </div>
265
+ <div class="chart-container">
266
+ <div class="chart-header"><span class="chart-label">Stewart Platform</span></div>
267
+ <canvas id="jointsChart"></canvas>
268
+ <div class="chart-legend" id="jointsLegend"></div>
269
+ </div>
270
+ </div>
271
+ </div>
272
+
273
+ <!-- Head Pose -->
274
+ <div class="card">
275
+ <div class="card-header"><span class="card-title">Head Pose</span><button id="copyHeadPose" class="copy-btn" title="Copy as JSON"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>
276
+ <div class="info-grid">
277
+ <div class="info-row"><span class="info-label">X</span><span class="info-value" id="headPosX">--</span></div>
278
+ <div class="info-row"><span class="info-label">Roll</span><span class="info-value" id="headRoll">--</span></div>
279
+ <div class="info-row"><span class="info-label">Y</span><span class="info-value" id="headPosY">--</span></div>
280
+ <div class="info-row"><span class="info-label">Pitch</span><span class="info-value" id="headPitch">--</span></div>
281
+ <div class="info-row"><span class="info-label">Z</span><span class="info-value" id="headPosZ">--</span></div>
282
+ <div class="info-row"><span class="info-label">Yaw</span><span class="info-value" id="headYaw">--</span></div>
283
+ <div class="info-row"><span class="info-label">Ant L</span><span class="info-value" id="headAntL">--</span></div>
284
+ <div class="info-row"><span class="info-label">Ant R</span><span class="info-value" id="headAntR">--</span></div>
285
+ <div class="info-row span-2"><span class="info-label">Body Yaw</span><span class="info-value" id="headBodyYaw">--</span></div>
286
+ </div>
287
+ </div>
288
+ </div>
289
+
290
+ <!-- Conversation Tab -->
291
+ <div id="conversation" class="tab-content">
292
+ <!-- Listener Controls -->
293
+ <div class="listener-hero">
294
+ <button id="listenerToggle" class="mic-btn" title="Toggle listener">
295
+ <svg class="mic-icon" viewBox="0 0 24 24" fill="currentColor">
296
+ <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
297
+ <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
298
+ </svg>
299
+ <div class="mic-rings"></div>
300
+ </button>
301
+ <div class="mic-status">
302
+ <span id="listenerDot" class="status-dot offline"></span>
303
+ <span id="listenerLabel" class="listener-label">Offline</span>
304
+ <span id="speakingIndicator" class="speaking-indicator" style="display:none;">Speaking...</span>
305
+ </div>
306
+ <div class="listener-selects">
307
+ <select id="audioInputSelect" title="Microphone source">
308
+ <option value="robot">Robot Mic</option>
309
+ <option value="browser">Browser Mic</option>
310
+ </select>
311
+ <select id="audioOutputSelect" title="Speaker output">
312
+ <option value="robot">Robot Speaker</option>
313
+ <option value="browser">Browser Speaker</option>
314
+ </select>
315
+ </div>
316
+ </div>
317
+
318
+ <!-- Provider Settings (collapsible) -->
319
+ <details id="providerSettings" class="card provider-card">
320
+ <summary class="provider-summary">AI Provider Settings</summary>
321
+ <div class="provider-grid">
322
+ <!-- API Keys -->
323
+ <div class="provider-section">
324
+ <h4 class="provider-section-title">API Keys</h4>
325
+ <div id="apiKeysContainer"></div>
326
+ </div>
327
+
328
+ <!-- STT Config -->
329
+ <div class="provider-section">
330
+ <h4 class="provider-section-title">Speech-to-Text</h4>
331
+ <div class="provider-row">
332
+ <label>Provider</label>
333
+ <select id="sttProviderSelect"><option value="">--</option></select>
334
+ </div>
335
+ <div class="provider-row">
336
+ <label>Model</label>
337
+ <select id="sttModelSelect"><option value="">--</option></select>
338
+ </div>
339
+ </div>
340
+
341
+ <!-- LLM Config -->
342
+ <div class="provider-section">
343
+ <h4 class="provider-section-title">Language Model</h4>
344
+ <div class="provider-row">
345
+ <label>Provider</label>
346
+ <select id="llmProviderSelect"><option value="">--</option></select>
347
+ </div>
348
+ <div class="provider-row">
349
+ <label>Model</label>
350
+ <select id="llmModelSelect"><option value="">--</option></select>
351
+ </div>
352
+ <div class="provider-row">
353
+ <label style="min-width:auto;">Web Search</label>
354
+ <label class="toggle-switch" style="margin:0 0.4rem;">
355
+ <input type="checkbox" id="llmWebSearchFilter">
356
+ <span class="toggle-slider"></span>
357
+ </label>
358
+ <span id="llmWebSearchLabel" class="slider-value" style="text-align:left;">Show all</span>
359
+ </div>
360
+ </div>
361
+
362
+ <!-- VLM Config -->
363
+ <div class="provider-section">
364
+ <h4 class="provider-section-title">Vision Model</h4>
365
+ <div class="provider-row">
366
+ <label>Provider</label>
367
+ <select id="vlmProviderSelect"><option value="">--</option></select>
368
+ </div>
369
+ <div class="provider-row">
370
+ <label>Model</label>
371
+ <select id="vlmModelSelect"><option value="">--</option></select>
372
+ </div>
373
+ </div>
374
+
375
+ <!-- TTS Config -->
376
+ <div class="provider-section">
377
+ <h4 class="provider-section-title">Text-to-Speech</h4>
378
+ <div class="provider-row">
379
+ <label>Provider</label>
380
+ <select id="ttsProviderSelect"><option value="">--</option></select>
381
+ </div>
382
+ <div class="provider-row">
383
+ <label>Model</label>
384
+ <select id="ttsModelSelect"><option value="">--</option></select>
385
+ </div>
386
+ <div class="provider-row">
387
+ <label>Voice</label>
388
+ <select id="ttsVoiceSelect"><option value="">Default</option></select>
389
+ </div>
390
+ </div>
391
+
392
+ <!-- Thresholds -->
393
+ <div class="provider-section">
394
+ <h4 class="provider-section-title">Thresholds</h4>
395
+ <div class="provider-row">
396
+ <label>Confidence</label>
397
+ <input type="range" id="confSlider" min="0" max="100" value="0">
398
+ <span id="confSliderValue" class="slider-value">0%</span>
399
+ </div>
400
+ <div class="provider-row">
401
+ <label>Volume</label>
402
+ <input type="range" id="volSlider" min="0" max="100" value="0">
403
+ <span id="volSliderValue" class="slider-value">0%</span>
404
+ </div>
405
+ <div class="provider-row">
406
+ <label>Mic Gain</label>
407
+ <input type="range" id="micGainSlider" min="1" max="20" step="0.5" value="5">
408
+ <span id="micGainValue" class="slider-value">5.0x</span>
409
+ </div>
410
+ </div>
411
+
412
+ <!-- LLM Behaviour -->
413
+ <div class="provider-section" style="grid-column: 1 / -1;">
414
+ <h4 class="provider-section-title">LLM Behaviour</h4>
415
+ <div style="margin-top:0.25rem;">
416
+ <label style="display:block;font-size:0.7rem;color:var(--text-secondary);margin-bottom:0.3rem;">
417
+ System Prompt <button id="resetPromptBtn" class="api-key-save-btn" title="Reset to default" style="font-size:0.6rem;padding:0.1rem 0.4rem;margin-left:0.5rem;">Reset</button>
418
+ </label>
419
+ <textarea id="systemPromptEditor" rows="8"
420
+ style="width:100%;font-size:0.72rem;font-family:monospace;background:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:var(--radius-sm);padding:0.5rem;resize:vertical;"
421
+ placeholder="Loading..."></textarea>
422
+ <button id="savePromptBtn" class="listener-btn" style="margin-top:0.4rem;font-size:0.7rem;padding:0.3rem 0.8rem;">Save Prompt</button>
423
+ </div>
424
+ </div>
425
+ </div>
426
+ </details>
427
+
428
+ <!-- Waveform -->
429
+ <div class="card waveform-card">
430
+ <canvas id="waveformCanvas" height="48"></canvas>
431
+ </div>
432
+
433
+ <!-- Transcript -->
434
+ <div class="card transcript-card">
435
+ <div class="transcript-toolbar">
436
+ <span class="transcript-title">Transcript</span>
437
+ <div class="transcript-actions">
438
+ <button id="clearTranscriptBtn" class="btn btn-xs" title="Clear transcript">Clear</button>
439
+ <button id="exportTranscriptBtn" class="btn btn-xs" title="Export as text">Export</button>
440
+ <button id="resetSessionBtn" class="btn btn-xs btn-warning" title="Reset AI conversation history">Reset Session</button>
441
+ </div>
442
+ </div>
443
+ <div id="transcriptContainer" class="transcript-container">
444
+ <table id="transcriptTable" class="transcript-table">
445
+ <thead>
446
+ <tr>
447
+ <th class="ts-th-time">Time</th>
448
+ <th class="ts-th-conf">Conf</th>
449
+ <th class="ts-th-vol">Vol</th>
450
+ <th class="ts-th-msg">Message</th>
451
+ </tr>
452
+ </thead>
453
+ <tbody id="transcriptBody"></tbody>
454
+ </table>
455
+ </div>
456
+ </div>
457
+
458
+ <!-- Text Input -->
459
+ <div class="card input-card">
460
+ <div class="input-bar">
461
+ <input id="conversationInput" type="text" placeholder="Type a message..." class="conversation-input">
462
+ <button id="conversationSend" class="btn btn-sm">Send</button>
463
+ </div>
464
+ </div>
465
+ </div>
466
+
467
+ <!-- Media Tab -->
468
+ <div id="media" class="tab-content">
469
+ <!-- Snapshots -->
470
+ <div class="card">
471
+ <div class="card-header">
472
+ <span class="card-title">Snapshots</span>
473
+ <span class="media-count" id="snapshotCount">--</span>
474
+ </div>
475
+ <div id="snapshotGallery" class="media-gallery">
476
+ <div class="media-empty">No snapshots yet. Use the camera panel to capture.</div>
477
+ </div>
478
+ </div>
479
+
480
+ <!-- Recordings -->
481
+ <div class="card">
482
+ <div class="card-header">
483
+ <span class="card-title">Recordings</span>
484
+ <span class="media-count" id="recordingCount">--</span>
485
+ </div>
486
+ <div id="recordingGallery" class="media-gallery">
487
+ <div class="media-empty">No recordings yet. Use the camera panel to record.</div>
488
+ </div>
489
+ </div>
490
+
491
+ <!-- Sound Recordings -->
492
+ <div class="card">
493
+ <div class="card-header">
494
+ <span class="card-title">Sound Recordings</span>
495
+ <span class="media-count" id="soundCount">--</span>
496
+ </div>
497
+ <div id="soundGallery" class="media-gallery">
498
+ <div class="media-empty">No sound recordings yet. Use the mic button to record.</div>
499
+ </div>
500
+ </div>
501
+
502
+ <!-- Music -->
503
+ <div class="card">
504
+ <div class="card-header">
505
+ <span class="card-title">Music</span>
506
+ <span class="media-count" id="musicCount">--</span>
507
+ <label class="upload-btn" title="Upload music">
508
+ + Upload <input type="file" id="musicUploadInput" accept=".mp3,.wav,.ogg,.flac,.m4a,.aac" hidden>
509
+ </label>
510
+ </div>
511
+ <div class="music-now-playing" id="musicNowPlaying" style="display:none;">
512
+ <span id="musicNowPlayingText"></span>
513
+ <button id="musicStopBtn" class="btn btn-xs">Stop</button>
514
+ </div>
515
+ <div id="musicGallery" class="media-gallery">
516
+ <div class="media-empty">No music files. Upload mp3, wav, ogg, flac, m4a, or aac.</div>
517
+ </div>
518
+ </div>
519
+ </div>
520
+
521
+ <!-- Floating Joysticks Panel (at body level, visible on all tabs) -->
522
+ <div id="floatingJoysticks" class="floating-panel" style="display: none; position: fixed; top: 150px; right: 20px; z-index: 9999;">
523
+ <div class="floating-panel-header">
524
+ <span>Joystick</span>
525
+ <div class="floating-panel-controls">
526
+ <button class="floating-minimize-btn" data-panel="floatingJoysticks" title="Minimize">_</button>
527
+ <button class="floating-close-btn" data-panel="joysticks" title="Close">&times;</button>
528
+ </div>
529
+ </div>
530
+ <div class="floating-panel-content" style="padding: 12px; background: rgba(30,30,30,0.7); backdrop-filter: blur(8px);">
531
+ <!-- Joysticks -->
532
+ <div style="display: flex; flex-direction: column; gap: 8px; align-items: center;">
533
+ <div style="display: flex; gap: 8px; align-items: flex-end;">
534
+ <!-- Left Antenna -->
535
+ <div style="display: flex; flex-direction: column; align-items: center;">
536
+ <div id="antennaJoystickL-cam" style="width: 50px; height: 50px; cursor: pointer;">
537
+ <div id="antennaBaseL-cam" style="width: 100%; height: 100%; background: rgba(0,0,0,0.5); border: 2px solid rgba(255,255,255,0.3); border-radius: 50%; position: relative;">
538
+ <div id="antennaKnobL-cam" style="width: 20px; height: 20px; background: rgba(255,255,255,0.8); border-radius: 50%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); box-shadow: 0 2px 4px rgba(0,0,0,0.4); cursor: grab;"></div>
539
+ </div>
540
+ </div>
541
+ <div style="color: rgba(255,255,255,0.7); font-size: 16px; margin-top: 4px; font-weight: 500;">L</div>
542
+ </div>
543
+ <!-- XY Joystick -->
544
+ <div style="display: flex; flex-direction: column; align-items: center;">
545
+ <div id="xyJoystickContainer" style="width: 50px; height: 50px; cursor: pointer;">
546
+ <div id="xyJoystickBase" style="width: 100%; height: 100%; background: rgba(0,0,0,0.5); border: 2px solid rgba(6,182,212,0.5); border-radius: 50%; position: relative;">
547
+ <div id="xyJoystickKnob" style="width: 20px; height: 20px; background: rgba(6,182,212,0.8); border-radius: 50%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); box-shadow: 0 2px 4px rgba(0,0,0,0.4); cursor: grab;"></div>
548
+ </div>
549
+ </div>
550
+ <div style="color: rgba(6,182,212,0.9); font-size: 16px; margin-top: 4px; font-weight: 500;">XY</div>
551
+ </div>
552
+ <!-- Right Antenna -->
553
+ <div style="display: flex; flex-direction: column; align-items: center;">
554
+ <div id="antennaJoystickR-cam" style="width: 50px; height: 50px; cursor: pointer;">
555
+ <div id="antennaBaseR-cam" style="width: 100%; height: 100%; background: rgba(0,0,0,0.5); border: 2px solid rgba(255,255,255,0.3); border-radius: 50%; position: relative;">
556
+ <div id="antennaKnobR-cam" style="width: 20px; height: 20px; background: rgba(255,255,255,0.8); border-radius: 50%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); box-shadow: 0 2px 4px rgba(0,0,0,0.4); cursor: grab;"></div>
557
+ </div>
558
+ </div>
559
+ <div style="color: rgba(255,255,255,0.7); font-size: 16px; margin-top: 4px; font-weight: 500;">R</div>
560
+ </div>
561
+ </div>
562
+ <div style="display: flex; gap: 8px; align-items: flex-end;">
563
+ <!-- Look Joystick -->
564
+ <div style="display: flex; flex-direction: column; align-items: center;">
565
+ <div id="headJoystickContainer" style="width: 50px; height: 50px; cursor: pointer;">
566
+ <div id="headJoystickBase" style="width: 100%; height: 100%; background: rgba(0,0,0,0.5); border: 2px solid rgba(255,255,255,0.3); border-radius: 50%; position: relative;">
567
+ <div id="headJoystickKnob" style="width: 20px; height: 20px; background: rgba(255,255,255,0.8); border-radius: 50%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); box-shadow: 0 2px 4px rgba(0,0,0,0.4); cursor: grab;"></div>
568
+ </div>
569
+ </div>
570
+ <div style="color: rgba(255,255,255,0.7); font-size: 14px; margin-top: 4px; font-weight: 500;">LOOK</div>
571
+ </div>
572
+ <!-- Z/Roll -->
573
+ <div style="display: flex; flex-direction: column; align-items: center;">
574
+ <div id="zRollJoystickContainer" style="width: 50px; height: 50px; cursor: pointer;">
575
+ <div id="zRollJoystickBase" style="width: 100%; height: 100%; background: rgba(0,0,0,0.5); border: 2px solid rgba(168,85,247,0.5); border-radius: 50%; position: relative;">
576
+ <div id="zRollJoystickKnob" style="width: 20px; height: 20px; background: rgba(168,85,247,0.8); border-radius: 50%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); box-shadow: 0 2px 4px rgba(0,0,0,0.4); cursor: grab;"></div>
577
+ <div style="position: absolute; top: 2px; left: 50%; transform: translateX(-50%); color: rgba(168,85,247,0.6); font-size: 10px;">Z+</div>
578
+ <div style="position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); color: rgba(168,85,247,0.6); font-size: 10px;">Z-</div>
579
+ </div>
580
+ </div>
581
+ <div style="color: rgba(168,85,247,0.9); font-size: 14px; margin-top: 4px; font-weight: 500;">Z/ROLL</div>
582
+ </div>
583
+ <!-- Reset -->
584
+ <div style="display: flex; flex-direction: column; align-items: center;">
585
+ <button id="resetControlsBtn" style="width: 50px; height: 50px; font-size: 20px; background: rgba(0,0,0,0.5); border: 2px solid rgba(239,68,68,0.5); color: rgba(239,68,68,0.8); border-radius: 50%; cursor: pointer;" title="Reset all positions">&#x21BA;</button>
586
+ <div style="color: rgba(239,68,68,0.9); font-size: 14px; margin-top: 4px; font-weight: 500;">RESET</div>
587
+ </div>
588
+ </div>
589
+ <!-- Body Rotation Slider -->
590
+ <div style="display: flex; align-items: center; gap: 8px; margin-top: 8px; width: 100%;">
591
+ <span style="color: rgba(34,197,94,0.7); font-size: 12px; font-weight: 500;">BODY</span>
592
+ <div id="bodySliderContainer" style="flex: 1; height: 20px; position: relative; cursor: pointer;">
593
+ <div id="bodySliderTrack" style="position: absolute; top: 50%; left: 0; right: 0; height: 4px; background: rgba(34,197,94,0.3); border-radius: 2px; transform: translateY(-50%);"></div>
594
+ <div id="bodySliderKnob" style="position: absolute; top: 50%; left: 50%; width: 16px; height: 16px; background: rgba(34,197,94,0.9); border-radius: 50%; transform: translate(-50%, -50%); box-shadow: 0 0 6px rgba(34,197,94,0.6); cursor: grab;"></div>
595
+ </div>
596
+ </div>
597
+ </div>
598
+ </div>
599
+ </div>
600
+
601
+ <!-- Floating Camera Panel -->
602
+ <div id="floatingCamera" class="floating-panel" style="display: none; position: fixed; top: 80px; right: 20px; z-index: 9998;">
603
+ <div class="floating-panel-header">
604
+ <span>Camera</span>
605
+ <div class="floating-panel-controls">
606
+ <span id="camStatusDot" class="cam-status-dot"></span>
607
+ <button class="floating-minimize-btn" data-panel="floatingCamera" title="Minimize">_</button>
608
+ <button class="floating-close-btn" data-panel="camera" title="Close">&times;</button>
609
+ </div>
610
+ </div>
611
+ <div class="floating-panel-content" style="padding: 0;">
612
+ <div id="cameraContainer" class="camera-container">
613
+ <video id="videoFeed" autoplay playsinline muted></video>
614
+ <div id="videoPlaceholder" class="video-placeholder">
615
+ <div class="placeholder-icon">&#x1F4F9;</div>
616
+ <div class="placeholder-text">Camera Feed</div>
617
+ <div class="placeholder-subtext">Connecting...</div>
618
+ </div>
619
+ </div>
620
+ <div class="camera-controls">
621
+ <button id="snapshotBtn" class="cam-btn" title="Take snapshot">&#x1F4F8;</button>
622
+ <button id="recordBtn" class="cam-btn" title="Record video">&#x23FA;</button>
623
+ <button id="micRecordBtn" class="cam-btn" title="Record mic">&#x1F3A4;</button>
624
+ <button id="listenBtn" class="cam-btn" title="Listen to robot mic">&#x1F50A;</button>
625
+ <button id="speakBtn" class="cam-btn" title="Speak through robot">&#x1F399;</button>
626
+ <span id="recordTimer" class="record-timer" style="display: none;">00:00</span>
627
+ </div>
628
+ </div>
629
+ </div>
630
+
631
+ <!-- Toast Container -->
632
+ <div id="toastContainer" class="toast-container"></div>
633
+
634
+ <!-- Scripts: load order matters -->
635
+ <script src="/static/js/core/constants.js"></script>
636
+ <script src="/static/js/core/settings-manager.js"></script>
637
+ <script src="/static/js/core/api-client.js"></script>
638
+ <script src="/static/js/core/drag-utils.js?v=1"></script>
639
+ <script src="/static/js/core/tabs.js"></script>
640
+ <script src="/static/js/features/status-manager.js"></script>
641
+ <script src="/static/js/controls/Joystick.js?v=1"></script>
642
+ <script src="/static/js/media/webrtc.js?v=1"></script>
643
+ <script src="/static/js/controls.js"></script>
644
+ <script src="/static/js/websocket.js?v=6"></script>
645
+ <script src="/static/js/simulation.js?v=3"></script>
646
+ <script src="/static/js/features/FloatingPanel.js?v=1"></script>
647
+ <script src="/static/js/features/transcribe.js?v=2"></script>
648
+ <script src="/static/js/features/assistant.js?v=2"></script>
649
+ <script src="/static/js/features/llm-settings.js?v=2"></script>
650
+ <script src="/static/js/core/init.js?v=6"></script>
651
+ </body>
652
+ </html>
hello_world/static/js/controls.js ADDED
@@ -0,0 +1,1089 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // controls.js - Head, body, and antenna controls
2
+
3
+ (function() {
4
+ const { DAEMON_API } = window.ReachyApp;
5
+
6
+ // ===== Control Limits (from constants.js) =====
7
+ const {
8
+ HEAD_YAW_MAX,
9
+ HEAD_PITCH_MAX,
10
+ HEAD_Z_MAX,
11
+ HEAD_X_MAX,
12
+ HEAD_Y_MAX,
13
+ HEAD_ROLL_LEFT_MAX,
14
+ HEAD_ROLL_RIGHT_MAX,
15
+ HEAD_ROLL_OFFSET,
16
+ BODY_YAW_MAX,
17
+ ANTENNA_MAX,
18
+ ANTENNA_THROTTLE
19
+ } = window.ReachyConstants;
20
+
21
+ // ===== Joystick Return-to-Center Settings =====
22
+ // Default: stay where positioned (no auto-return)
23
+ const returnSettings = {
24
+ yawPitch: 'stay', // 'stay' or 'center'
25
+ zRoll: 'stay',
26
+ xy: 'stay',
27
+ body: 'stay'
28
+ };
29
+
30
+ // Load settings on init
31
+ function loadReturnSettings() {
32
+ const settings = window.ReachySettings?.getAll?.() || {};
33
+ returnSettings.yawPitch = settings.joy_return_yawPitch || 'stay';
34
+ returnSettings.zRoll = settings.joy_return_zRoll || 'stay';
35
+ returnSettings.xy = settings.joy_return_xy || 'stay';
36
+ returnSettings.body = settings.joy_return_body || 'stay';
37
+ }
38
+
39
+ // ===== Control State =====
40
+ let controlsEnabled = true;
41
+ let smoothingLoopRunning = false;
42
+ let lastHeadCommandTime = 0;
43
+ let lastBodyCommandTime = 0;
44
+ let lastAntennaCommandTime = 0;
45
+ const HEAD_COMMAND_THROTTLE = 30;
46
+
47
+ // Target values
48
+ let targetHeadYaw = 0, targetHeadPitch = 0, targetHeadZ = 0, targetHeadRoll = 0;
49
+ let targetHeadX = 0, targetHeadY = 0;
50
+ let targetBodyYaw = 0;
51
+ let targetAntennaL = 0, targetAntennaR = 0;
52
+
53
+ // Current (smoothed) values
54
+ let currentHeadYaw = 0, currentHeadPitch = 0, currentHeadZ = 0, currentHeadRoll = 0;
55
+ let currentHeadX = 0, currentHeadY = 0;
56
+ let currentBodyYaw = 0;
57
+ let currentAntennaL = 0, currentAntennaR = 0;
58
+
59
+ // State tracking
60
+ let anyControlActive = false;
61
+ let antennaControlActive = false;
62
+ let bodyLockMode = false;
63
+ let antennasLinked = false;
64
+
65
+ // Updater functions and knob references
66
+ let bodyArcKnobUpdater = null;
67
+ let rollArcKnobUpdater = null;
68
+ const antennaKnobUpdaters = {};
69
+
70
+ // Knob reset functions (set during joystick setup)
71
+ const knobResetters = {
72
+ headYawPitch: null, // Yaw/Pitch joystick reset
73
+ zRoll: null, // Z/Roll joystick reset
74
+ xy: null, // X/Y joystick reset
75
+ body: null // Body arc reset
76
+ };
77
+
78
+ // Helper to set control active state and update PiP visibility for Joy mode
79
+ function setControlActive(active) {
80
+ if (!controlsEnabled) return;
81
+ anyControlActive = active;
82
+ window.ReachyPip?.setJoystickActive(active);
83
+
84
+ }
85
+
86
+ // Enable/disable controls (called from control mode buttons)
87
+ function setEnabled(enabled) {
88
+ controlsEnabled = enabled;
89
+ // Update joystick opacity to show enabled/disabled state
90
+ const containers = document.querySelectorAll('#xyJoystickContainer, #headJoystickContainer, #zRollJoystickContainer, #antennaJoystickL-cam, #antennaJoystickR-cam');
91
+ containers.forEach(c => {
92
+ if (c) c.style.opacity = enabled ? '1' : '0.4';
93
+ });
94
+ }
95
+
96
+ // ===== Command Functions =====
97
+ function sendHeadControlCommand(yaw, pitch, z, roll, x, y) {
98
+ const now = Date.now();
99
+ if (now - lastHeadCommandTime < HEAD_COMMAND_THROTTLE) return;
100
+ lastHeadCommandTime = now;
101
+
102
+ fetch(`${DAEMON_API}/api/move/set_target`, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({
106
+ target_head_pose: {
107
+ x: x || 0, y: y || 0, z: z || 0,
108
+ roll: (roll || 0) + HEAD_ROLL_OFFSET,
109
+ pitch: pitch,
110
+ yaw: yaw
111
+ }
112
+ })
113
+ }).catch(() => {});
114
+ }
115
+
116
+ function sendBodyControlCommand(yaw) {
117
+ const now = Date.now();
118
+ if (now - lastBodyCommandTime < HEAD_COMMAND_THROTTLE) return;
119
+ lastBodyCommandTime = now;
120
+
121
+ fetch(`${DAEMON_API}/api/move/set_target`, {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({ target_body_yaw: yaw })
125
+ }).catch(() => {});
126
+ }
127
+
128
+ function sendAntennaCommand() {
129
+ const now = Date.now();
130
+ if (now - lastAntennaCommandTime < ANTENNA_THROTTLE) return;
131
+ lastAntennaCommandTime = now;
132
+
133
+ fetch(`${DAEMON_API}/api/move/set_target`, {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify({
137
+ target_antennas: [
138
+ -currentAntennaR * Math.PI / 180,
139
+ -currentAntennaL * Math.PI / 180
140
+ ]
141
+ })
142
+ }).catch(() => {});
143
+ }
144
+
145
+ // ===== Smoothing Loop =====
146
+ function startSmoothingLoop() {
147
+ if (smoothingLoopRunning) return;
148
+ smoothingLoopRunning = true;
149
+
150
+ function animate() {
151
+ if (!anyControlActive) {
152
+ smoothingLoopRunning = false;
153
+ return;
154
+ }
155
+
156
+ const prevHeadX = currentHeadX;
157
+ const prevHeadY = currentHeadY;
158
+ const prevHeadYaw = currentHeadYaw;
159
+ const prevHeadPitch = currentHeadPitch;
160
+ const prevHeadZ = currentHeadZ;
161
+ const prevHeadRoll = currentHeadRoll;
162
+ const prevBodyYaw = currentBodyYaw;
163
+
164
+ currentHeadX = targetHeadX;
165
+ currentHeadY = targetHeadY;
166
+ currentHeadYaw = targetHeadYaw;
167
+ currentHeadPitch = targetHeadPitch;
168
+ currentHeadZ = targetHeadZ;
169
+ currentHeadRoll = targetHeadRoll;
170
+ currentBodyYaw = targetBodyYaw;
171
+
172
+ const headChanged = Math.abs(currentHeadX - prevHeadX) > 0.0001 ||
173
+ Math.abs(currentHeadY - prevHeadY) > 0.0001 ||
174
+ Math.abs(currentHeadYaw - prevHeadYaw) > 0.001 ||
175
+ Math.abs(currentHeadPitch - prevHeadPitch) > 0.001 ||
176
+ Math.abs(currentHeadZ - prevHeadZ) > 0.0001 ||
177
+ Math.abs(currentHeadRoll - prevHeadRoll) > 0.001;
178
+ const bodyChanged = Math.abs(currentBodyYaw - prevBodyYaw) > 0.001;
179
+
180
+ if (headChanged) sendHeadControlCommand(currentHeadYaw, currentHeadPitch, currentHeadZ, currentHeadRoll, currentHeadX, currentHeadY);
181
+ if (bodyChanged) sendBodyControlCommand(currentBodyYaw);
182
+
183
+ requestAnimationFrame(animate);
184
+ }
185
+ animate();
186
+ }
187
+
188
+ // Called when control is released - apply return-to-center per axis based on settings
189
+ async function smoothFinish(controlType = 'all') {
190
+ // returnSettings is updated directly by button click handlers
191
+ // Apply return-to-center based on which control was released
192
+ let resetYawPitch = false, resetZRoll = false, resetXY = false, resetBody = false;
193
+
194
+ if (controlType === 'yawPitch' || controlType === 'all') {
195
+ if (returnSettings.yawPitch === 'center') {
196
+ targetHeadYaw = 0;
197
+ targetHeadPitch = 0;
198
+ resetYawPitch = true;
199
+ }
200
+ }
201
+ if (controlType === 'zRoll' || controlType === 'all') {
202
+ if (returnSettings.zRoll === 'center') {
203
+ targetHeadZ = 0;
204
+ targetHeadRoll = 0;
205
+ resetZRoll = true;
206
+ }
207
+ }
208
+ if (controlType === 'xy' || controlType === 'all') {
209
+ if (returnSettings.xy === 'center') {
210
+ targetHeadX = 0;
211
+ targetHeadY = 0;
212
+ resetXY = true;
213
+ }
214
+ }
215
+ if (controlType === 'body' || controlType === 'all') {
216
+ if (returnSettings.body === 'center') {
217
+ targetBodyYaw = 0;
218
+ resetBody = true;
219
+ }
220
+ }
221
+
222
+ // Update current values
223
+ currentHeadX = targetHeadX;
224
+ currentHeadY = targetHeadY;
225
+ currentHeadYaw = targetHeadYaw;
226
+ currentHeadPitch = targetHeadPitch;
227
+ currentHeadZ = targetHeadZ;
228
+ currentHeadRoll = targetHeadRoll;
229
+ currentBodyYaw = targetBodyYaw;
230
+
231
+ // Reset knob positions to match target values
232
+ if (resetYawPitch && knobResetters.headYawPitch) knobResetters.headYawPitch();
233
+ if (resetZRoll && knobResetters.zRoll) knobResetters.zRoll();
234
+ if (resetXY && knobResetters.xy) knobResetters.xy();
235
+ if (resetBody && knobResetters.body) knobResetters.body();
236
+
237
+ try {
238
+ await fetch(`${DAEMON_API}/api/move/goto`, {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({
242
+ head_pose: {
243
+ x: currentHeadX, y: currentHeadY, z: currentHeadZ,
244
+ roll: currentHeadRoll + HEAD_ROLL_OFFSET,
245
+ pitch: currentHeadPitch,
246
+ yaw: currentHeadYaw
247
+ },
248
+ body_yaw: currentBodyYaw,
249
+ duration: 0.3,
250
+ interpolation: 'minjerk'
251
+ })
252
+ });
253
+ } catch (e) {}
254
+ }
255
+
256
+ // ===== Antenna Smoothing Loop =====
257
+ function updateAntennaSmoothingLoop() {
258
+ if (!antennaControlActive) return;
259
+
260
+ const prevL = currentAntennaL;
261
+ const prevR = currentAntennaR;
262
+
263
+ currentAntennaL = targetAntennaL;
264
+ currentAntennaR = targetAntennaR;
265
+
266
+ if (Math.abs(currentAntennaL - prevL) > 0.1 || Math.abs(currentAntennaR - prevR) > 0.1) {
267
+ sendAntennaCommand();
268
+ }
269
+ }
270
+
271
+ setInterval(updateAntennaSmoothingLoop, 50);
272
+
273
+ // ===== Head Joystick (Yaw/Pitch) =====
274
+ function setupHeadJoystick(containerId, baseId, knobId) {
275
+ const container = document.getElementById(containerId);
276
+ const base = document.getElementById(baseId);
277
+ const knob = document.getElementById(knobId);
278
+ if (!container || !base || !knob) return;
279
+
280
+ let isActive = false;
281
+
282
+ // Reset knob to center
283
+ function resetKnob() {
284
+ knob.style.left = '50%';
285
+ knob.style.top = '50%';
286
+ }
287
+
288
+ // Store reset function (only for first joystick - the camera tab one)
289
+ if (!knobResetters.headYawPitch) {
290
+ knobResetters.headYawPitch = resetKnob;
291
+ }
292
+
293
+ function updateKnobPosition(clientX, clientY) {
294
+ const rect = base.getBoundingClientRect();
295
+ const maxDistance = rect.width / 2 - 10;
296
+ const centerX = rect.left + rect.width / 2;
297
+ const centerY = rect.top + rect.height / 2;
298
+
299
+ let dx = clientX - centerX;
300
+ let dy = clientY - centerY;
301
+
302
+ const distance = Math.sqrt(dx * dx + dy * dy);
303
+ if (distance > maxDistance) {
304
+ dx = (dx / distance) * maxDistance;
305
+ dy = (dy / distance) * maxDistance;
306
+ }
307
+
308
+ knob.style.left = `calc(50% + ${dx}px)`;
309
+ knob.style.top = `calc(50% + ${dy}px)`;
310
+
311
+ const normalizedX = dx / maxDistance;
312
+ const normalizedY = dy / maxDistance;
313
+
314
+ targetHeadYaw = -normalizedX * HEAD_YAW_MAX;
315
+ targetHeadPitch = normalizedY * HEAD_PITCH_MAX;
316
+
317
+ if (bodyLockMode) {
318
+ targetBodyYaw = -normalizedX * BODY_YAW_MAX;
319
+ if (bodyArcKnobUpdater) bodyArcKnobUpdater(targetBodyYaw);
320
+ }
321
+ }
322
+
323
+ knob.addEventListener('mousedown', (e) => {
324
+ e.preventDefault();
325
+ isActive = true;
326
+ setControlActive(true);
327
+ knob.style.cursor = 'grabbing';
328
+ container.parentElement.style.opacity = '1';
329
+ startSmoothingLoop();
330
+ });
331
+
332
+ document.addEventListener('mousemove', (e) => {
333
+ if (isActive) updateKnobPosition(e.clientX, e.clientY);
334
+ });
335
+
336
+ document.addEventListener('mouseup', () => {
337
+ if (isActive) {
338
+ isActive = false;
339
+ setControlActive(false);
340
+ knob.style.cursor = 'grab';
341
+ container.parentElement.style.opacity = '0.7';
342
+ smoothFinish('yawPitch');
343
+ }
344
+ });
345
+
346
+ knob.addEventListener('touchstart', (e) => {
347
+ e.preventDefault();
348
+ isActive = true;
349
+ setControlActive(true);
350
+ startSmoothingLoop();
351
+ });
352
+
353
+ document.addEventListener('touchmove', (e) => {
354
+ if (isActive && e.touches.length > 0) {
355
+ updateKnobPosition(e.touches[0].clientX, e.touches[0].clientY);
356
+ }
357
+ });
358
+
359
+ document.addEventListener('touchend', () => {
360
+ if (isActive) {
361
+ isActive = false;
362
+ setControlActive(false);
363
+ smoothFinish('yawPitch');
364
+ }
365
+ });
366
+ }
367
+
368
+ // ===== Body Slider Control =====
369
+ function setupBodySlider(containerId, knobId) {
370
+ const container = document.getElementById(containerId);
371
+ const knob = document.getElementById(knobId);
372
+ if (!container || !knob) return null;
373
+
374
+ let isActive = false;
375
+
376
+ function updateKnobPosition(normalizedValue) {
377
+ // normalizedValue: -1 (left) to 1 (right)
378
+ const percent = (normalizedValue + 1) / 2 * 100;
379
+ knob.style.left = `${percent}%`;
380
+ }
381
+
382
+ function setKnobFromValue(value) {
383
+ const normalized = Math.max(-1, Math.min(1, value / BODY_YAW_MAX));
384
+ updateKnobPosition(normalized);
385
+ }
386
+
387
+ // Reset knob to center
388
+ function resetKnob() {
389
+ updateKnobPosition(0);
390
+ }
391
+
392
+ // Store reset function
393
+ if (!knobResetters.body) {
394
+ knobResetters.body = resetKnob;
395
+ }
396
+
397
+ function updateFromPosition(clientX) {
398
+ const rect = container.getBoundingClientRect();
399
+ let normalized = (clientX - rect.left) / rect.width;
400
+ normalized = Math.max(0, Math.min(1, normalized));
401
+ // Convert 0-1 to -1 to 1
402
+ normalized = normalized * 2 - 1;
403
+
404
+ updateKnobPosition(normalized);
405
+ targetBodyYaw = normalized * BODY_YAW_MAX;
406
+ }
407
+
408
+ container.addEventListener('mousedown', (e) => {
409
+ e.preventDefault();
410
+ e.stopPropagation();
411
+ isActive = true;
412
+ setControlActive(true);
413
+ knob.style.cursor = 'grabbing';
414
+ updateFromPosition(e.clientX);
415
+ startSmoothingLoop();
416
+ });
417
+
418
+ document.addEventListener('mousemove', (e) => {
419
+ if (isActive) {
420
+ e.stopPropagation();
421
+ updateFromPosition(e.clientX);
422
+ }
423
+ });
424
+
425
+ document.addEventListener('mouseup', (e) => {
426
+ if (isActive) {
427
+ e.stopPropagation();
428
+ isActive = false;
429
+ setControlActive(false);
430
+ knob.style.cursor = 'grab';
431
+ smoothFinish('body');
432
+ }
433
+ });
434
+
435
+ container.addEventListener('touchstart', (e) => {
436
+ e.preventDefault();
437
+ e.stopPropagation();
438
+ isActive = true;
439
+ setControlActive(true);
440
+ updateFromPosition(e.touches[0].clientX);
441
+ startSmoothingLoop();
442
+ }, { passive: false });
443
+
444
+ container.addEventListener('touchmove', (e) => {
445
+ if (isActive) {
446
+ updateFromPosition(e.touches[0].clientX);
447
+ e.preventDefault();
448
+ }
449
+ }, { passive: false });
450
+
451
+ container.addEventListener('touchend', () => {
452
+ if (isActive) {
453
+ isActive = false;
454
+ setControlActive(false);
455
+ smoothFinish('body');
456
+ }
457
+ });
458
+
459
+ return setKnobFromValue;
460
+ }
461
+
462
+ // ===== Roll Arc Control =====
463
+ function setupRollArc(containerId, knobId) {
464
+ const container = document.getElementById(containerId);
465
+ const knob = document.getElementById(knobId);
466
+ if (!container || !knob) return null;
467
+
468
+ let isActive = false;
469
+ const arcCenterX = 35;
470
+ const arcCenterY = 33;
471
+ const arcRadius = 30;
472
+
473
+ function updateKnobFromAngle(angle) {
474
+ const clampedAngle = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, angle));
475
+ const x = arcCenterX + Math.sin(clampedAngle) * arcRadius;
476
+ const y = arcCenterY - Math.cos(clampedAngle) * arcRadius;
477
+ knob.style.left = `${x}px`;
478
+ knob.style.top = `${y}px`;
479
+ }
480
+
481
+ function setKnobFromValue(value) {
482
+ let normalizedAngle;
483
+ if (value <= 0) {
484
+ normalizedAngle = value / HEAD_ROLL_LEFT_MAX;
485
+ } else {
486
+ normalizedAngle = value / HEAD_ROLL_RIGHT_MAX;
487
+ }
488
+ normalizedAngle = Math.max(-1, Math.min(1, normalizedAngle));
489
+ const angle = normalizedAngle * (Math.PI / 2);
490
+ updateKnobFromAngle(angle);
491
+ }
492
+
493
+ function updateFromPosition(clientX, clientY) {
494
+ const rect = container.getBoundingClientRect();
495
+ const dx = clientX - rect.left - arcCenterX;
496
+ const dy = clientY - rect.top - arcCenterY;
497
+
498
+ let angle = Math.atan2(dx, -dy);
499
+ angle = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, angle));
500
+
501
+ updateKnobFromAngle(angle);
502
+
503
+ const normalizedAngle = angle / (Math.PI / 2);
504
+ if (normalizedAngle <= 0) {
505
+ targetHeadRoll = normalizedAngle * HEAD_ROLL_LEFT_MAX;
506
+ } else {
507
+ targetHeadRoll = normalizedAngle * HEAD_ROLL_RIGHT_MAX;
508
+ }
509
+ }
510
+
511
+ container.addEventListener('mousedown', (e) => {
512
+ e.preventDefault();
513
+ isActive = true;
514
+ setControlActive(true);
515
+ knob.style.cursor = 'grabbing';
516
+ container.parentElement.style.opacity = '1';
517
+ updateFromPosition(e.clientX, e.clientY);
518
+ startSmoothingLoop();
519
+ });
520
+
521
+ document.addEventListener('mousemove', (e) => {
522
+ if (isActive) updateFromPosition(e.clientX, e.clientY);
523
+ });
524
+
525
+ document.addEventListener('mouseup', () => {
526
+ if (isActive) {
527
+ isActive = false;
528
+ setControlActive(false);
529
+ knob.style.cursor = 'grab';
530
+ container.parentElement.style.opacity = '0.7';
531
+ smoothFinish();
532
+ }
533
+ });
534
+
535
+ container.addEventListener('touchstart', (e) => {
536
+ e.preventDefault();
537
+ isActive = true;
538
+ setControlActive(true);
539
+ updateFromPosition(e.touches[0].clientX, e.touches[0].clientY);
540
+ startSmoothingLoop();
541
+ }, { passive: false });
542
+
543
+ container.addEventListener('touchmove', (e) => {
544
+ if (isActive) {
545
+ updateFromPosition(e.touches[0].clientX, e.touches[0].clientY);
546
+ e.preventDefault();
547
+ }
548
+ }, { passive: false });
549
+
550
+ container.addEventListener('touchend', () => {
551
+ if (isActive) {
552
+ isActive = false;
553
+ setControlActive(false);
554
+ smoothFinish();
555
+ }
556
+ });
557
+
558
+ return setKnobFromValue;
559
+ }
560
+
561
+ // ===== Z/Roll Joystick =====
562
+ function setupZRollJoystick(containerId, baseId, knobId) {
563
+ const container = document.getElementById(containerId);
564
+ const base = document.getElementById(baseId);
565
+ const knob = document.getElementById(knobId);
566
+ if (!container || !base || !knob) return;
567
+
568
+ let isActive = false;
569
+
570
+ // Reset knob to center
571
+ function resetKnob() {
572
+ knob.style.left = '50%';
573
+ knob.style.top = '50%';
574
+ }
575
+
576
+ // Store reset function (only for first joystick)
577
+ if (!knobResetters.zRoll) {
578
+ knobResetters.zRoll = resetKnob;
579
+ }
580
+
581
+ function updateKnobPosition(clientX, clientY) {
582
+ const rect = base.getBoundingClientRect();
583
+ const maxDistance = rect.width / 2 - 8;
584
+ const centerX = rect.left + rect.width / 2;
585
+ const centerY = rect.top + rect.height / 2;
586
+
587
+ let dx = clientX - centerX;
588
+ let dy = clientY - centerY;
589
+
590
+ const distance = Math.sqrt(dx * dx + dy * dy);
591
+ if (distance > maxDistance) {
592
+ dx = (dx / distance) * maxDistance;
593
+ dy = (dy / distance) * maxDistance;
594
+ }
595
+
596
+ knob.style.left = `calc(50% + ${dx}px)`;
597
+ knob.style.top = `calc(50% + ${dy}px)`;
598
+
599
+ const normalizedX = dx / maxDistance;
600
+ const normalizedY = dy / maxDistance;
601
+
602
+ targetHeadZ = -normalizedY * HEAD_Z_MAX;
603
+
604
+ if (normalizedX <= 0) {
605
+ targetHeadRoll = normalizedX * HEAD_ROLL_LEFT_MAX;
606
+ } else {
607
+ targetHeadRoll = normalizedX * HEAD_ROLL_RIGHT_MAX;
608
+ }
609
+ }
610
+
611
+ knob.addEventListener('mousedown', (e) => {
612
+ e.preventDefault();
613
+ isActive = true;
614
+ setControlActive(true);
615
+ knob.style.cursor = 'grabbing';
616
+ container.parentElement.style.opacity = '1';
617
+ startSmoothingLoop();
618
+ });
619
+
620
+ document.addEventListener('mousemove', (e) => {
621
+ if (isActive) updateKnobPosition(e.clientX, e.clientY);
622
+ });
623
+
624
+ document.addEventListener('mouseup', () => {
625
+ if (isActive) {
626
+ isActive = false;
627
+ setControlActive(false);
628
+ knob.style.cursor = 'grab';
629
+ container.parentElement.style.opacity = '0.7';
630
+ smoothFinish('zRoll');
631
+ }
632
+ });
633
+
634
+ knob.addEventListener('touchstart', (e) => {
635
+ e.preventDefault();
636
+ isActive = true;
637
+ setControlActive(true);
638
+ startSmoothingLoop();
639
+ }, { passive: false });
640
+
641
+ document.addEventListener('touchmove', (e) => {
642
+ if (isActive && e.touches.length > 0) {
643
+ updateKnobPosition(e.touches[0].clientX, e.touches[0].clientY);
644
+ }
645
+ });
646
+
647
+ document.addEventListener('touchend', () => {
648
+ if (isActive) {
649
+ isActive = false;
650
+ setControlActive(false);
651
+ smoothFinish('zRoll');
652
+ }
653
+ });
654
+ }
655
+
656
+ // ===== XY Joystick (Head Translation) =====
657
+ function setupXYJoystick(containerId, baseId, knobId) {
658
+ const container = document.getElementById(containerId);
659
+ const base = document.getElementById(baseId);
660
+ const knob = document.getElementById(knobId);
661
+ if (!container || !base || !knob) return;
662
+
663
+ let isActive = false;
664
+
665
+ // Reset knob to center
666
+ function resetKnob() {
667
+ knob.style.left = '50%';
668
+ knob.style.top = '50%';
669
+ }
670
+
671
+ // Store reset function (only for first joystick)
672
+ if (!knobResetters.xy) {
673
+ knobResetters.xy = resetKnob;
674
+ }
675
+
676
+ function updateKnobPosition(clientX, clientY) {
677
+ const rect = base.getBoundingClientRect();
678
+ const maxDistance = rect.width / 2 - 8;
679
+ const centerX = rect.left + rect.width / 2;
680
+ const centerY = rect.top + rect.height / 2;
681
+
682
+ let dx = clientX - centerX;
683
+ let dy = clientY - centerY;
684
+
685
+ const distance = Math.sqrt(dx * dx + dy * dy);
686
+ if (distance > maxDistance) {
687
+ dx = (dx / distance) * maxDistance;
688
+ dy = (dy / distance) * maxDistance;
689
+ }
690
+
691
+ knob.style.left = `calc(50% + ${dx}px)`;
692
+ knob.style.top = `calc(50% + ${dy}px)`;
693
+
694
+ const normalizedX = dx / maxDistance;
695
+ const normalizedY = dy / maxDistance;
696
+
697
+ targetHeadX = -normalizedY * HEAD_X_MAX;
698
+ targetHeadY = -normalizedX * HEAD_Y_MAX;
699
+ }
700
+
701
+ knob.addEventListener('mousedown', (e) => {
702
+ e.preventDefault();
703
+ isActive = true;
704
+ setControlActive(true);
705
+ knob.style.cursor = 'grabbing';
706
+ container.parentElement.style.opacity = '1';
707
+ startSmoothingLoop();
708
+ });
709
+
710
+ document.addEventListener('mousemove', (e) => {
711
+ if (isActive) updateKnobPosition(e.clientX, e.clientY);
712
+ });
713
+
714
+ document.addEventListener('mouseup', () => {
715
+ if (isActive) {
716
+ isActive = false;
717
+ setControlActive(false);
718
+ knob.style.cursor = 'grab';
719
+ container.parentElement.style.opacity = '0.7';
720
+ smoothFinish('xy');
721
+ }
722
+ });
723
+
724
+ knob.addEventListener('touchstart', (e) => {
725
+ e.preventDefault();
726
+ isActive = true;
727
+ setControlActive(true);
728
+ startSmoothingLoop();
729
+ }, { passive: false });
730
+
731
+ document.addEventListener('touchmove', (e) => {
732
+ if (isActive && e.touches.length > 0) {
733
+ updateKnobPosition(e.touches[0].clientX, e.touches[0].clientY);
734
+ }
735
+ });
736
+
737
+ document.addEventListener('touchend', () => {
738
+ if (isActive) {
739
+ isActive = false;
740
+ setControlActive(false);
741
+ smoothFinish('xy');
742
+ }
743
+ });
744
+ }
745
+
746
+ // ===== Antenna Joystick =====
747
+ function setAntennaTarget(side, angleDeg) {
748
+ antennaControlActive = true;
749
+ angleDeg = Math.max(-ANTENNA_MAX, Math.min(ANTENNA_MAX, angleDeg));
750
+ if (side === 'L') targetAntennaL = angleDeg;
751
+ else targetAntennaR = angleDeg;
752
+ }
753
+
754
+ function setupAntennaJoystick(containerId, baseId, knobId, side) {
755
+ const container = document.getElementById(containerId);
756
+ const base = document.getElementById(baseId);
757
+ const knob = document.getElementById(knobId);
758
+ if (!container || !base || !knob) return null;
759
+
760
+ let isDragging = false;
761
+
762
+ function updateKnob(clientX, clientY) {
763
+ const rect = base.getBoundingClientRect();
764
+ const centerX = rect.left + rect.width / 2;
765
+ const centerY = rect.top + rect.height / 2;
766
+ const radius = rect.width / 2 - 4;
767
+
768
+ const dx = clientX - centerX;
769
+ const dy = clientY - centerY;
770
+ let angleRad = Math.atan2(dx, -dy);
771
+
772
+ const knobX = Math.sin(angleRad) * radius;
773
+ const knobY = -Math.cos(angleRad) * radius;
774
+ knob.style.left = `calc(50% + ${knobX}px)`;
775
+ knob.style.top = `calc(50% + ${knobY}px)`;
776
+
777
+ let angleDeg = angleRad * 180 / Math.PI;
778
+ setAntennaTarget(side, angleDeg);
779
+
780
+ if (antennasLinked) {
781
+ const otherSide = side === 'L' ? 'R' : 'L';
782
+ setAntennaTarget(otherSide, angleDeg);
783
+ const otherKey = otherSide + '-cam';
784
+ if (antennaKnobUpdaters[otherKey]) {
785
+ antennaKnobUpdaters[otherKey](angleDeg);
786
+ }
787
+ }
788
+ }
789
+
790
+ function setKnobFromAngle(angleDeg) {
791
+ const rect = base.getBoundingClientRect();
792
+ if (rect.width === 0) return;
793
+ const radius = rect.width / 2 - 4;
794
+ const angleRad = angleDeg * Math.PI / 180;
795
+ const knobX = Math.sin(angleRad) * radius;
796
+ const knobY = -Math.cos(angleRad) * radius;
797
+ knob.style.left = `calc(50% + ${knobX}px)`;
798
+ knob.style.top = `calc(50% + ${knobY}px)`;
799
+ }
800
+
801
+ container.addEventListener('dblclick', (e) => {
802
+ antennasLinked = !antennasLinked;
803
+ ['antennaKnobL-cam', 'antennaKnobR-cam'].forEach(id => {
804
+ const k = document.getElementById(id);
805
+ if (k) {
806
+ k.style.background = antennasLinked ? 'linear-gradient(145deg, #ff6b6b, #ee5a5a)' : 'rgba(255,255,255,0.8)';
807
+ k.style.boxShadow = antennasLinked ? '0 0 8px rgba(255, 107, 107, 0.6)' : '0 2px 4px rgba(0,0,0,0.4)';
808
+ }
809
+ });
810
+ e.preventDefault();
811
+ e.stopPropagation();
812
+ });
813
+
814
+ let resetTimer = null;
815
+
816
+ function resetKnobVisual() {
817
+ knob.style.left = '50%';
818
+ knob.style.top = '50%';
819
+ knob.style.transform = 'translate(-50%, -50%)';
820
+ }
821
+
822
+ function scheduleVisualReset() {
823
+ if (resetTimer) clearTimeout(resetTimer);
824
+ resetTimer = setTimeout(resetKnobVisual, 2000);
825
+ }
826
+
827
+ container.addEventListener('mousedown', (e) => {
828
+ if (resetTimer) clearTimeout(resetTimer);
829
+ isDragging = true;
830
+ updateKnob(e.clientX, e.clientY);
831
+ e.preventDefault();
832
+ });
833
+
834
+ document.addEventListener('mousemove', (e) => {
835
+ if (isDragging) updateKnob(e.clientX, e.clientY);
836
+ });
837
+
838
+ document.addEventListener('mouseup', () => {
839
+ if (isDragging) {
840
+ isDragging = false;
841
+ scheduleVisualReset();
842
+ }
843
+ });
844
+
845
+ container.addEventListener('touchstart', (e) => {
846
+ if (resetTimer) clearTimeout(resetTimer);
847
+ isDragging = true;
848
+ updateKnob(e.touches[0].clientX, e.touches[0].clientY);
849
+ e.preventDefault();
850
+ }, { passive: false });
851
+
852
+ container.addEventListener('touchmove', (e) => {
853
+ if (isDragging) {
854
+ updateKnob(e.touches[0].clientX, e.touches[0].clientY);
855
+ e.preventDefault();
856
+ }
857
+ }, { passive: false });
858
+
859
+ container.addEventListener('touchend', () => {
860
+ if (isDragging) {
861
+ isDragging = false;
862
+ scheduleVisualReset();
863
+ }
864
+ });
865
+
866
+ return setKnobFromAngle;
867
+ }
868
+
869
+ // ===== Reset All Positions =====
870
+ async function resetAllPositions() {
871
+ targetAntennaL = 0; targetAntennaR = 0;
872
+ currentAntennaL = 0; currentAntennaR = 0;
873
+ antennasLinked = false;
874
+ // Reset antenna knob styling (both tabs)
875
+ ['antennaKnobL-cam', 'antennaKnobR-cam', 'antennaKnobL-sim', 'antennaKnobR-sim'].forEach(id => {
876
+ const k = document.getElementById(id);
877
+ if (k) {
878
+ k.style.background = 'rgba(255,255,255,0.8)';
879
+ k.style.boxShadow = '0 2px 4px rgba(0,0,0,0.4)';
880
+ }
881
+ });
882
+ targetHeadX = 0; targetHeadY = 0;
883
+ currentHeadX = 0; currentHeadY = 0;
884
+ targetHeadYaw = 0; targetHeadPitch = 0; targetHeadZ = 0; targetHeadRoll = 0; targetBodyYaw = 0;
885
+ currentHeadYaw = 0; currentHeadPitch = 0; currentHeadZ = 0; currentHeadRoll = 0; currentBodyYaw = 0;
886
+
887
+ const resetPayload = JSON.stringify({
888
+ target_head_pose: { x: 0, y: 0, z: 0, roll: 0, pitch: 0, yaw: 0 },
889
+ target_body_yaw: 0,
890
+ target_antennas: [0, 0]
891
+ });
892
+
893
+ for (let i = 0; i < 20; i++) {
894
+ setTimeout(() => {
895
+ fetch(`${DAEMON_API}/api/move/set_target`, {
896
+ method: 'POST',
897
+ headers: { 'Content-Type': 'application/json' },
898
+ body: resetPayload
899
+ }).catch(() => {});
900
+ }, i * 25);
901
+ }
902
+
903
+ // Reset all antenna knob positions (both tabs) - center them in their containers
904
+ ['antennaKnobL-cam', 'antennaKnobR-cam', 'antennaKnobL-sim', 'antennaKnobR-sim'].forEach(id => {
905
+ const k = document.getElementById(id);
906
+ if (k) {
907
+ k.style.left = '50%';
908
+ k.style.top = '50%';
909
+ k.style.transform = 'translate(-50%, -50%)';
910
+ }
911
+ });
912
+ }
913
+
914
+ // ===== Initialize Controls =====
915
+ function initControls() {
916
+ // Load joystick return-to-center settings
917
+ loadReturnSettings();
918
+
919
+ // Remote tab controls
920
+ setupHeadJoystick('headJoystickContainer', 'headJoystickBase', 'headJoystickKnob');
921
+ bodyArcKnobUpdater = setupBodySlider('bodySliderContainer', 'bodySliderKnob');
922
+ rollArcKnobUpdater = setupRollArc('rollArcContainer', 'rollArcKnob');
923
+ setupZRollJoystick('zRollJoystickContainer', 'zRollJoystickBase', 'zRollJoystickKnob');
924
+ setupXYJoystick('xyJoystickContainer', 'xyJoystickBase', 'xyJoystickKnob');
925
+ antennaKnobUpdaters['L-cam'] = setupAntennaJoystick('antennaJoystickL-cam', 'antennaBaseL-cam', 'antennaKnobL-cam', 'L');
926
+ antennaKnobUpdaters['R-cam'] = setupAntennaJoystick('antennaJoystickR-cam', 'antennaBaseR-cam', 'antennaKnobR-cam', 'R');
927
+
928
+ // Sim tab controls (same functionality, different IDs)
929
+ setupHeadJoystick('headJoystickContainer-sim', 'headJoystickBase-sim', 'headJoystickKnob-sim');
930
+ setupZRollJoystick('zRollJoystickContainer-sim', 'zRollJoystickBase-sim', 'zRollJoystickKnob-sim');
931
+ setupXYJoystick('xyJoystickContainer-sim', 'xyJoystickBase-sim', 'xyJoystickKnob-sim');
932
+ antennaKnobUpdaters['L-sim'] = setupAntennaJoystick('antennaJoystickL-sim', 'antennaBaseL-sim', 'antennaKnobL-sim', 'L');
933
+ antennaKnobUpdaters['R-sim'] = setupAntennaJoystick('antennaJoystickR-sim', 'antennaBaseR-sim', 'antennaKnobR-sim', 'R');
934
+
935
+ // Reset buttons (both tabs)
936
+ const resetBtn = document.getElementById('resetControlsBtn');
937
+ if (resetBtn) {
938
+ resetBtn.addEventListener('click', resetAllPositions);
939
+ }
940
+ const resetBtnSim = document.getElementById('resetControlsBtn-sim');
941
+ if (resetBtnSim) {
942
+ resetBtnSim.addEventListener('click', resetAllPositions);
943
+ }
944
+
945
+ // Joystick return-to-center settings buttons
946
+ setupJoystickReturnButtons();
947
+
948
+ // Body lock settings buttons
949
+ setupBodyLockButtons();
950
+ }
951
+
952
+ // ===== Body Lock Settings =====
953
+ function setupBodyLockButtons() {
954
+ const offBtn = document.getElementById('bodyLockOffBtn');
955
+ const onBtn = document.getElementById('bodyLockOnBtn');
956
+
957
+ // Load initial state from settings
958
+ const settings = window.ReachySettings?.getAll?.() || {};
959
+ bodyLockMode = settings.body_lock_enabled === true;
960
+
961
+ // Update button states
962
+ function updateButtonStates() {
963
+ if (offBtn) offBtn.classList.toggle('active', !bodyLockMode);
964
+ if (onBtn) onBtn.classList.toggle('active', bodyLockMode);
965
+ }
966
+ updateButtonStates();
967
+
968
+ if (offBtn) {
969
+ offBtn.addEventListener('click', () => {
970
+ bodyLockMode = false;
971
+ updateButtonStates();
972
+ if (window.ReachySettings) {
973
+ ReachySettings.save('body_lock_enabled', false);
974
+ }
975
+ });
976
+ }
977
+
978
+ if (onBtn) {
979
+ onBtn.addEventListener('click', () => {
980
+ bodyLockMode = true;
981
+ updateButtonStates();
982
+ if (window.ReachySettings) {
983
+ ReachySettings.save('body_lock_enabled', true);
984
+ }
985
+ });
986
+ }
987
+ }
988
+
989
+ // ===== Joystick Return-to-Center Settings =====
990
+ function setupJoystickReturnButtons() {
991
+ const buttons = document.querySelectorAll('.joy-return-btn');
992
+ buttons.forEach(btn => {
993
+ const axis = btn.dataset.joyAxis;
994
+ const returnValue = btn.dataset.return;
995
+
996
+ // Set initial state from settings
997
+ if (returnSettings[axis] === returnValue) {
998
+ btn.classList.add('active');
999
+ }
1000
+
1001
+ btn.addEventListener('click', () => {
1002
+ // Update setting
1003
+ returnSettings[axis] = returnValue;
1004
+
1005
+ // Update UI - remove active from siblings, add to this
1006
+ const siblings = document.querySelectorAll(`.joy-return-btn[data-joy-axis="${axis}"]`);
1007
+ siblings.forEach(s => s.classList.remove('active'));
1008
+ btn.classList.add('active');
1009
+
1010
+ // Save setting
1011
+ const settingKey = `joy_return_${axis}`;
1012
+ if (window.ReachySettings) {
1013
+ ReachySettings.save(settingKey, returnValue);
1014
+ }
1015
+ });
1016
+ });
1017
+ }
1018
+
1019
+ // ===== Chart Control Integration =====
1020
+ // Allow external code (telemetry charts) to control via the same system
1021
+ function setHeadTargets(values) {
1022
+ if (!controlsEnabled) return;
1023
+ if (values.yaw !== undefined) targetHeadYaw = values.yaw;
1024
+ if (values.pitch !== undefined) targetHeadPitch = values.pitch;
1025
+ if (values.roll !== undefined) targetHeadRoll = values.roll;
1026
+ if (values.x !== undefined) targetHeadX = values.x;
1027
+ if (values.y !== undefined) targetHeadY = values.y;
1028
+ if (values.z !== undefined) targetHeadZ = values.z;
1029
+ }
1030
+
1031
+ function setAntennaTargets(left, right) {
1032
+ if (!controlsEnabled) return;
1033
+ antennaControlActive = true; // Same as joystick setAntennaTarget
1034
+ if (left !== undefined) {
1035
+ left = Math.max(-ANTENNA_MAX, Math.min(ANTENNA_MAX, left));
1036
+ targetAntennaL = left;
1037
+ }
1038
+ if (right !== undefined) {
1039
+ right = Math.max(-ANTENNA_MAX, Math.min(ANTENNA_MAX, right));
1040
+ targetAntennaR = right;
1041
+ }
1042
+ }
1043
+
1044
+ function getTargets() {
1045
+ return {
1046
+ yaw: targetHeadYaw,
1047
+ pitch: targetHeadPitch,
1048
+ roll: targetHeadRoll,
1049
+ x: targetHeadX,
1050
+ y: targetHeadY,
1051
+ z: targetHeadZ,
1052
+ antennaL: targetAntennaL,
1053
+ antennaR: targetAntennaR
1054
+ };
1055
+ }
1056
+
1057
+ function startChartControl() {
1058
+ if (!controlsEnabled) return;
1059
+ setControlActive(true);
1060
+ startSmoothingLoop();
1061
+ }
1062
+
1063
+ function endChartControl() {
1064
+ setControlActive(false);
1065
+ smoothFinish();
1066
+ }
1067
+
1068
+ // Refresh antenna knob positions (call after resize)
1069
+ function refreshAntennaKnobs() {
1070
+ if (antennaKnobUpdaters['L-cam']) antennaKnobUpdaters['L-cam'](targetAntennaL);
1071
+ if (antennaKnobUpdaters['R-cam']) antennaKnobUpdaters['R-cam'](targetAntennaR);
1072
+ }
1073
+
1074
+ // Export
1075
+ window.ReachyControls = {
1076
+ init: initControls,
1077
+ resetAllPositions,
1078
+ setEnabled,
1079
+ refreshAntennaKnobs,
1080
+ // Chart control integration
1081
+ setHeadTargets,
1082
+ setAntennaTargets,
1083
+ getTargets,
1084
+ startChartControl,
1085
+ endChartControl,
1086
+ // Constants for limits (from centralized constants.js)
1087
+ limits: window.ReachyConstants.limits
1088
+ };
1089
+ })();
hello_world/static/js/controls/Joystick.js ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Joystick.js - Reusable joystick control class
2
+
3
+ /**
4
+ * A reusable joystick control class that handles mouse and touch events,
5
+ * clamps knob position to a circular area, and calculates normalized values.
6
+ *
7
+ * @example
8
+ * const headJoystick = new Joystick({
9
+ * containerId: 'headJoystickContainer',
10
+ * baseId: 'headJoystickBase',
11
+ * knobId: 'headJoystickKnob',
12
+ * xLimit: HEAD_YAW_MAX,
13
+ * yLimit: HEAD_PITCH_MAX,
14
+ * onUpdate: (x, y) => {
15
+ * targetHeadYaw = -x;
16
+ * targetHeadPitch = y;
17
+ * }
18
+ * });
19
+ */
20
+ class Joystick {
21
+ /**
22
+ * Create a new Joystick instance.
23
+ * @param {Object} config - Configuration object
24
+ * @param {string} config.containerId - ID of the container element
25
+ * @param {string} config.baseId - ID of the base element (defines movement bounds)
26
+ * @param {string} config.knobId - ID of the knob element (draggable)
27
+ * @param {number} [config.xLimit=1] - Maximum value for X axis (output scaled to -xLimit to +xLimit)
28
+ * @param {number} [config.yLimit=1] - Maximum value for Y axis (output scaled to -yLimit to +yLimit)
29
+ * @param {number} [config.edgePadding=10] - Padding from edge of base for max knob position
30
+ * @param {Function} [config.onUpdate] - Callback function called with (x, y) values when joystick moves
31
+ * @param {Function} [config.onStart] - Callback function called when dragging starts
32
+ * @param {Function} [config.onEnd] - Callback function called when dragging ends
33
+ */
34
+ constructor(config) {
35
+ this.containerId = config.containerId;
36
+ this.baseId = config.baseId;
37
+ this.knobId = config.knobId;
38
+ this.xLimit = config.xLimit ?? 1;
39
+ this.yLimit = config.yLimit ?? 1;
40
+ this.edgePadding = config.edgePadding ?? 10;
41
+ this.onUpdate = config.onUpdate || null;
42
+ this.onStart = config.onStart || null;
43
+ this.onEnd = config.onEnd || null;
44
+
45
+ // Get DOM elements
46
+ this.container = document.getElementById(this.containerId);
47
+ this.base = document.getElementById(this.baseId);
48
+ this.knob = document.getElementById(this.knobId);
49
+
50
+ // State
51
+ this._isActive = false;
52
+ this._enabled = true;
53
+ this._currentX = 0; // Normalized -1 to 1
54
+ this._currentY = 0; // Normalized -1 to 1
55
+
56
+ // Bound event handlers (for removal)
57
+ this._onMouseMove = this._handleMouseMove.bind(this);
58
+ this._onMouseUp = this._handleMouseUp.bind(this);
59
+ this._onTouchMove = this._handleTouchMove.bind(this);
60
+ this._onTouchEnd = this._handleTouchEnd.bind(this);
61
+
62
+ // Initialize if elements exist
63
+ if (this.container && this.base && this.knob) {
64
+ this._init();
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Initialize event listeners
70
+ * @private
71
+ */
72
+ _init() {
73
+ // Mouse events on knob
74
+ this.knob.addEventListener('mousedown', (e) => this._handleMouseDown(e));
75
+
76
+ // Touch events on knob
77
+ this.knob.addEventListener('touchstart', (e) => this._handleTouchStart(e), { passive: false });
78
+
79
+ // Document-level events for drag tracking
80
+ document.addEventListener('mousemove', this._onMouseMove);
81
+ document.addEventListener('mouseup', this._onMouseUp);
82
+ document.addEventListener('touchmove', this._onTouchMove, { passive: false });
83
+ document.addEventListener('touchend', this._onTouchEnd);
84
+ }
85
+
86
+ /**
87
+ * Handle mouse down on knob
88
+ * @private
89
+ */
90
+ _handleMouseDown(e) {
91
+ if (!this._enabled) return;
92
+ e.preventDefault();
93
+ this._isActive = true;
94
+ this.knob.style.cursor = 'grabbing';
95
+
96
+ // Visual feedback
97
+ if (this.container.parentElement) {
98
+ this.container.parentElement.style.opacity = '1';
99
+ }
100
+
101
+ if (this.onStart) {
102
+ this.onStart();
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Handle mouse move (document level)
108
+ * @private
109
+ */
110
+ _handleMouseMove(e) {
111
+ if (this._isActive) {
112
+ this._updateKnobPosition(e.clientX, e.clientY);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Handle mouse up (document level)
118
+ * @private
119
+ */
120
+ _handleMouseUp() {
121
+ if (this._isActive) {
122
+ this._isActive = false;
123
+ this.knob.style.cursor = 'grab';
124
+
125
+ // Visual feedback
126
+ if (this.container.parentElement) {
127
+ this.container.parentElement.style.opacity = '0.7';
128
+ }
129
+
130
+ if (this.onEnd) {
131
+ this.onEnd();
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Handle touch start on knob
138
+ * @private
139
+ */
140
+ _handleTouchStart(e) {
141
+ if (!this._enabled) return;
142
+ e.preventDefault();
143
+ this._isActive = true;
144
+
145
+ if (this.onStart) {
146
+ this.onStart();
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Handle touch move (document level)
152
+ * @private
153
+ */
154
+ _handleTouchMove(e) {
155
+ if (this._isActive && e.touches.length > 0) {
156
+ this._updateKnobPosition(e.touches[0].clientX, e.touches[0].clientY);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Handle touch end (document level)
162
+ * @private
163
+ */
164
+ _handleTouchEnd() {
165
+ if (this._isActive) {
166
+ this._isActive = false;
167
+
168
+ if (this.onEnd) {
169
+ this.onEnd();
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Update knob position based on pointer coordinates
176
+ * @param {number} clientX - X coordinate from event
177
+ * @param {number} clientY - Y coordinate from event
178
+ * @private
179
+ */
180
+ _updateKnobPosition(clientX, clientY) {
181
+ const rect = this.base.getBoundingClientRect();
182
+ const maxDistance = rect.width / 2 - this.edgePadding;
183
+ const centerX = rect.left + rect.width / 2;
184
+ const centerY = rect.top + rect.height / 2;
185
+
186
+ let dx = clientX - centerX;
187
+ let dy = clientY - centerY;
188
+
189
+ // Clamp to circular area
190
+ const distance = Math.sqrt(dx * dx + dy * dy);
191
+ if (distance > maxDistance) {
192
+ dx = (dx / distance) * maxDistance;
193
+ dy = (dy / distance) * maxDistance;
194
+ }
195
+
196
+ // Update knob visual position
197
+ this.knob.style.left = `calc(50% + ${dx}px)`;
198
+ this.knob.style.top = `calc(50% + ${dy}px)`;
199
+
200
+ // Calculate normalized values (-1 to 1)
201
+ this._currentX = dx / maxDistance;
202
+ this._currentY = dy / maxDistance;
203
+
204
+ // Call update callback with scaled values
205
+ if (this.onUpdate) {
206
+ const scaledX = this._currentX * this.xLimit;
207
+ const scaledY = this._currentY * this.yLimit;
208
+ this.onUpdate(scaledX, scaledY);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Reset knob to center position
214
+ */
215
+ reset() {
216
+ this._currentX = 0;
217
+ this._currentY = 0;
218
+
219
+ if (this.knob) {
220
+ this.knob.style.left = '50%';
221
+ this.knob.style.top = '50%';
222
+ }
223
+
224
+ if (this.onUpdate) {
225
+ this.onUpdate(0, 0);
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Set knob position programmatically (using normalized values -1 to 1)
231
+ * @param {number} x - Normalized X value (-1 to 1)
232
+ * @param {number} y - Normalized Y value (-1 to 1)
233
+ */
234
+ setPosition(x, y) {
235
+ // Clamp to -1 to 1
236
+ x = Math.max(-1, Math.min(1, x));
237
+ y = Math.max(-1, Math.min(1, y));
238
+
239
+ this._currentX = x;
240
+ this._currentY = y;
241
+
242
+ if (this.knob && this.base) {
243
+ const rect = this.base.getBoundingClientRect();
244
+ const maxDistance = rect.width / 2 - this.edgePadding;
245
+ const dx = x * maxDistance;
246
+ const dy = y * maxDistance;
247
+
248
+ this.knob.style.left = `calc(50% + ${dx}px)`;
249
+ this.knob.style.top = `calc(50% + ${dy}px)`;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Enable or disable the joystick
255
+ * @param {boolean} enabled - Whether the joystick should be enabled
256
+ */
257
+ setEnabled(enabled) {
258
+ this._enabled = enabled;
259
+
260
+ if (this.container) {
261
+ this.container.style.opacity = enabled ? '1' : '0.4';
262
+ this.container.style.pointerEvents = enabled ? 'auto' : 'none';
263
+ }
264
+
265
+ // If disabled while active, end the interaction
266
+ if (!enabled && this._isActive) {
267
+ this._isActive = false;
268
+ if (this.onEnd) {
269
+ this.onEnd();
270
+ }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Check if joystick is currently enabled
276
+ * @returns {boolean}
277
+ */
278
+ isEnabled() {
279
+ return this._enabled;
280
+ }
281
+
282
+ /**
283
+ * Check if joystick is currently being dragged
284
+ * @returns {boolean}
285
+ */
286
+ isActive() {
287
+ return this._isActive;
288
+ }
289
+
290
+ /**
291
+ * Get current normalized X value (-1 to 1)
292
+ * @returns {number}
293
+ */
294
+ getX() {
295
+ return this._currentX;
296
+ }
297
+
298
+ /**
299
+ * Get current normalized Y value (-1 to 1)
300
+ * @returns {number}
301
+ */
302
+ getY() {
303
+ return this._currentY;
304
+ }
305
+
306
+ /**
307
+ * Get current scaled X value (using xLimit)
308
+ * @returns {number}
309
+ */
310
+ getScaledX() {
311
+ return this._currentX * this.xLimit;
312
+ }
313
+
314
+ /**
315
+ * Get current scaled Y value (using yLimit)
316
+ * @returns {number}
317
+ */
318
+ getScaledY() {
319
+ return this._currentY * this.yLimit;
320
+ }
321
+
322
+ /**
323
+ * Clean up event listeners (call when destroying the joystick)
324
+ */
325
+ destroy() {
326
+ document.removeEventListener('mousemove', this._onMouseMove);
327
+ document.removeEventListener('mouseup', this._onMouseUp);
328
+ document.removeEventListener('touchmove', this._onTouchMove);
329
+ document.removeEventListener('touchend', this._onTouchEnd);
330
+ }
331
+ }
332
+
333
+ // Export for module usage
334
+ if (typeof module !== 'undefined' && module.exports) {
335
+ module.exports = Joystick;
336
+ }
337
+
338
+ // Also make available globally
339
+ window.Joystick = Joystick;
hello_world/static/js/core/api-client.js ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // api-client.js - Centralized API client for daemon communication
2
+
3
+ (function() {
4
+ 'use strict';
5
+
6
+ const { DAEMON_API } = window.ReachyConstants;
7
+
8
+ let consecutiveFailures = 0;
9
+ const MAX_FAILURES_BEFORE_WARN = 3;
10
+
11
+ async function postJSON(endpoint, data, options = {}) {
12
+ const { silent = false, toast = false } = options;
13
+ try {
14
+ const response = await fetch(`${DAEMON_API}${endpoint}`, {
15
+ method: 'POST',
16
+ headers: { 'Content-Type': 'application/json' },
17
+ body: JSON.stringify(data)
18
+ });
19
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
20
+ consecutiveFailures = 0;
21
+ const text = await response.text();
22
+ return text ? JSON.parse(text) : null;
23
+ } catch (error) {
24
+ consecutiveFailures++;
25
+ if (!silent && consecutiveFailures >= MAX_FAILURES_BEFORE_WARN) {
26
+ console.error(`API error (${endpoint}):`, error.message);
27
+ }
28
+ if (toast && window.ReachyToast) {
29
+ window.ReachyToast.show(`API error: ${error.message}`, 'error');
30
+ }
31
+ return null;
32
+ }
33
+ }
34
+
35
+ async function getJSON(endpoint, options = {}) {
36
+ const { silent = false, toast = false } = options;
37
+ try {
38
+ const response = await fetch(`${DAEMON_API}${endpoint}`);
39
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
40
+ consecutiveFailures = 0;
41
+ return await response.json();
42
+ } catch (error) {
43
+ consecutiveFailures++;
44
+ if (!silent && consecutiveFailures >= MAX_FAILURES_BEFORE_WARN) {
45
+ console.error(`API error (${endpoint}):`, error.message);
46
+ }
47
+ if (toast && window.ReachyToast) {
48
+ window.ReachyToast.show(`API error: ${error.message}`, 'error');
49
+ }
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function setHeadTarget(pose) {
55
+ return postJSON('/api/move/set_target', { target_head_pose: pose }, { silent: true });
56
+ }
57
+
58
+ function setBodyTarget(yaw) {
59
+ return postJSON('/api/move/set_target', { target_body_yaw: yaw }, { silent: true });
60
+ }
61
+
62
+ function setAntennaTarget(right, left) {
63
+ return postJSON('/api/move/set_target', { target_antennas: [right, left] }, { silent: true });
64
+ }
65
+
66
+ function playMove(moveName) {
67
+ return postJSON(`/api/move/play/${moveName}`, {}, { toast: true });
68
+ }
69
+
70
+ function getDaemonStatus() {
71
+ return getJSON('/api/daemon/status', { silent: true });
72
+ }
73
+
74
+ function getAppStatus() {
75
+ return getJSON('/api/apps/current-app-status', { silent: true });
76
+ }
77
+
78
+ window.ReachyAPI = Object.freeze({
79
+ postJSON, getJSON,
80
+ setHeadTarget, setBodyTarget, setAntennaTarget, playMove,
81
+ getDaemonStatus, getAppStatus,
82
+ DAEMON_API
83
+ });
84
+
85
+ console.log('ReachyAPI loaded');
86
+ })();
hello_world/static/js/core/constants.js ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // constants.js - Centralized robot constants and limits
2
+ // Single source of truth for all robot parameters
3
+
4
+ (function() {
5
+ 'use strict';
6
+
7
+ // ===== API Configuration =====
8
+ const DAEMON_API = `http://${window.location.hostname}:8000`;
9
+ const SIGNALING_URL = `ws://${window.location.hostname}:8443`;
10
+
11
+ // ===== Head Control Limits =====
12
+ const HEAD_X_MAX = 0.03;
13
+ const HEAD_Y_MAX = 0.03;
14
+ const HEAD_Z_MAX = 0.04;
15
+
16
+ const HEAD_YAW_MAX = 0.87;
17
+ const HEAD_PITCH_MAX = 0.44;
18
+ const HEAD_ROLL_LEFT_MAX = 0.35;
19
+ const HEAD_ROLL_RIGHT_MAX = 0.30;
20
+ const HEAD_ROLL_OFFSET = 0.0;
21
+
22
+ // ===== Body Control Limits =====
23
+ const BODY_YAW_MAX = 2.8;
24
+
25
+ // ===== Antenna Limits =====
26
+ const ANTENNA_MAX = 179;
27
+
28
+ // ===== Throttle Settings (milliseconds) =====
29
+ const HEAD_COMMAND_THROTTLE = 30;
30
+ const BODY_COMMAND_THROTTLE = 30;
31
+ const ANTENNA_THROTTLE = 50;
32
+
33
+ // ===== WebSocket Settings =====
34
+ const DEFAULT_ROBOT_HZ = 10;
35
+ const DEFAULT_STATS_HZ = 1;
36
+
37
+ // ===== Smoothing Settings =====
38
+ const SMOOTHING_FACTOR = 0.15;
39
+ const SMOOTHING_THRESHOLD = 0.001;
40
+
41
+ // ===== Utility Functions =====
42
+ function formatBytes(bytes) {
43
+ if (bytes === null || bytes === undefined) return '--';
44
+ if (bytes < 1024) return bytes.toFixed(0) + ' B';
45
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
46
+ if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
47
+ return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
48
+ }
49
+
50
+ function formatBytesShort(bytes) {
51
+ if (bytes === null || bytes === undefined) return '--';
52
+ if (bytes < 1024) return bytes.toFixed(0) + 'B';
53
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + 'K';
54
+ if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(0) + 'M';
55
+ return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'G';
56
+ }
57
+
58
+ function formatUptime(seconds) {
59
+ if (seconds === null || seconds === undefined || isNaN(seconds)) return '--';
60
+ const days = Math.floor(seconds / 86400);
61
+ const hours = Math.floor((seconds % 86400) / 3600);
62
+ const mins = Math.floor((seconds % 3600) / 60);
63
+ const secs = Math.floor(seconds % 60);
64
+ if (days > 0) return `${days}d ${hours}h ${mins}m`;
65
+ if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
66
+ return `${mins}m ${secs}s`;
67
+ }
68
+
69
+ // Export utilities early so websocket.js can use them
70
+ window.ReachyApp = {
71
+ DAEMON_API,
72
+ formatBytes,
73
+ formatBytesShort,
74
+ formatUptime
75
+ };
76
+
77
+ window.ReachyConstants = Object.freeze({
78
+ DAEMON_API, SIGNALING_URL,
79
+ HEAD_X_MAX, HEAD_Y_MAX, HEAD_Z_MAX,
80
+ HEAD_YAW_MAX, HEAD_PITCH_MAX,
81
+ HEAD_ROLL_LEFT_MAX, HEAD_ROLL_RIGHT_MAX, HEAD_ROLL_OFFSET,
82
+ BODY_YAW_MAX, ANTENNA_MAX,
83
+ HEAD_COMMAND_THROTTLE, BODY_COMMAND_THROTTLE, ANTENNA_THROTTLE,
84
+ DEFAULT_ROBOT_HZ, DEFAULT_STATS_HZ,
85
+ SMOOTHING_FACTOR, SMOOTHING_THRESHOLD,
86
+ limits: Object.freeze({
87
+ HEAD_X_MAX, HEAD_Y_MAX, HEAD_Z_MAX,
88
+ HEAD_YAW_MAX, HEAD_PITCH_MAX,
89
+ HEAD_ROLL_LEFT_MAX, HEAD_ROLL_RIGHT_MAX,
90
+ BODY_YAW_MAX, ANTENNA_MAX
91
+ })
92
+ });
93
+
94
+ console.log('ReachyConstants loaded');
95
+ })();
hello_world/static/js/core/drag-utils.js ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // drag-utils.js - Unified drag, resize, and reorder utilities
2
+ // Consolidates 7+ duplicate drag implementations into one reusable module
3
+
4
+ (function() {
5
+ 'use strict';
6
+
7
+ // Track z-index for floating panels
8
+ let topZIndex = 10000;
9
+
10
+ /**
11
+ * Make an element draggable by mouse/touch
12
+ * @param {HTMLElement} element - Element to make draggable
13
+ * @param {Object} options - Configuration options
14
+ * @param {string} options.handle - CSS selector for drag handle (defaults to element itself)
15
+ * @param {string[]} options.excludeSelectors - Selectors that should NOT trigger drag
16
+ * @param {boolean} options.constrainToViewport - Keep element within viewport bounds (default: true)
17
+ * @param {Function} options.onDragStart - Callback when drag starts (element, x, y)
18
+ * @param {Function} options.onDragEnd - Callback when drag ends (x, y)
19
+ * @param {boolean} options.bringToFront - Increase z-index on drag (default: true)
20
+ */
21
+ function makeDraggable(element, options = {}) {
22
+ if (!element) return;
23
+
24
+ const {
25
+ handle = null,
26
+ excludeSelectors = [],
27
+ constrainToViewport = true,
28
+ onDragStart = null,
29
+ onDragEnd = null,
30
+ bringToFront = true
31
+ } = options;
32
+
33
+ let isDragging = false;
34
+ let startX, startY, startLeft, startTop;
35
+
36
+ const handleEl = handle ? element.querySelector(handle) : element;
37
+ if (!handleEl) return;
38
+
39
+ function shouldDrag(target) {
40
+ // Check if target matches any exclude selector
41
+ for (const sel of excludeSelectors) {
42
+ if (target.closest(sel)) return false;
43
+ }
44
+ return true;
45
+ }
46
+
47
+ function getPosition(e) {
48
+ if (e.touches && e.touches.length > 0) {
49
+ return { x: e.touches[0].clientX, y: e.touches[0].clientY };
50
+ }
51
+ return { x: e.clientX, y: e.clientY };
52
+ }
53
+
54
+ function startDrag(e) {
55
+ if (!shouldDrag(e.target)) return;
56
+
57
+ isDragging = true;
58
+ const pos = getPosition(e);
59
+ startX = pos.x;
60
+ startY = pos.y;
61
+
62
+ const rect = element.getBoundingClientRect();
63
+ startLeft = rect.left;
64
+ startTop = rect.top;
65
+
66
+ // Convert from any positioning to fixed left/top
67
+ element.style.right = 'auto';
68
+ element.style.bottom = 'auto';
69
+ element.style.left = startLeft + 'px';
70
+ element.style.top = startTop + 'px';
71
+
72
+ if (bringToFront) {
73
+ topZIndex++;
74
+ element.style.zIndex = topZIndex;
75
+ }
76
+
77
+ element.style.cursor = 'grabbing';
78
+ document.body.style.userSelect = 'none';
79
+
80
+ if (onDragStart) onDragStart(element, startLeft, startTop);
81
+
82
+ e.preventDefault();
83
+ }
84
+
85
+ function moveDrag(e) {
86
+ if (!isDragging) return;
87
+
88
+ const pos = getPosition(e);
89
+ const dx = pos.x - startX;
90
+ const dy = pos.y - startY;
91
+
92
+ let newLeft = startLeft + dx;
93
+ let newTop = startTop + dy;
94
+
95
+ if (constrainToViewport) {
96
+ const rect = element.getBoundingClientRect();
97
+ newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - rect.width));
98
+ newTop = Math.max(0, Math.min(newTop, window.innerHeight - rect.height));
99
+ }
100
+
101
+ element.style.left = newLeft + 'px';
102
+ element.style.top = newTop + 'px';
103
+ }
104
+
105
+ function endDrag() {
106
+ if (!isDragging) return;
107
+ isDragging = false;
108
+
109
+ element.style.cursor = '';
110
+ document.body.style.userSelect = '';
111
+
112
+ if (onDragEnd) {
113
+ const rect = element.getBoundingClientRect();
114
+ onDragEnd(Math.round(rect.left), Math.round(rect.top));
115
+ }
116
+ }
117
+
118
+ // Mouse events
119
+ handleEl.addEventListener('mousedown', startDrag);
120
+ document.addEventListener('mousemove', moveDrag);
121
+ document.addEventListener('mouseup', endDrag);
122
+
123
+ // Touch events
124
+ handleEl.addEventListener('touchstart', startDrag, { passive: false });
125
+ document.addEventListener('touchmove', moveDrag, { passive: false });
126
+ document.addEventListener('touchend', endDrag);
127
+
128
+ // Set cursor style
129
+ handleEl.style.cursor = 'grab';
130
+
131
+ // Return cleanup function
132
+ return function cleanup() {
133
+ handleEl.removeEventListener('mousedown', startDrag);
134
+ document.removeEventListener('mousemove', moveDrag);
135
+ document.removeEventListener('mouseup', endDrag);
136
+ handleEl.removeEventListener('touchstart', startDrag);
137
+ document.removeEventListener('touchmove', moveDrag);
138
+ document.removeEventListener('touchend', endDrag);
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Make an element resizable via a handle
144
+ * @param {HTMLElement} element - Element to resize
145
+ * @param {Object} options - Configuration options
146
+ * @param {string|HTMLElement} options.handle - Resize handle selector or element
147
+ * @param {number} options.minWidth - Minimum width
148
+ * @param {number} options.maxWidth - Maximum width
149
+ * @param {number} options.minHeight - Minimum height
150
+ * @param {number} options.maxHeight - Maximum height
151
+ * @param {number} options.aspectRatio - Maintain aspect ratio (width/height)
152
+ * @param {Function} options.onResize - Callback during resize (width, height)
153
+ * @param {Function} options.onResizeEnd - Callback when resize ends (width, height)
154
+ */
155
+ function makeResizable(element, options = {}) {
156
+ if (!element) return;
157
+
158
+ const {
159
+ handle,
160
+ minWidth = 100,
161
+ maxWidth = 2000,
162
+ minHeight = 100,
163
+ maxHeight = 2000,
164
+ aspectRatio = null,
165
+ onResize = null,
166
+ onResizeEnd = null
167
+ } = options;
168
+
169
+ const handleEl = typeof handle === 'string' ? element.querySelector(handle) : handle;
170
+ if (!handleEl) return;
171
+
172
+ let isResizing = false;
173
+ let startX, startY, startWidth, startHeight;
174
+
175
+ function getPosition(e) {
176
+ if (e.touches && e.touches.length > 0) {
177
+ return { x: e.touches[0].clientX, y: e.touches[0].clientY };
178
+ }
179
+ return { x: e.clientX, y: e.clientY };
180
+ }
181
+
182
+ function startResize(e) {
183
+ isResizing = true;
184
+ const pos = getPosition(e);
185
+ startX = pos.x;
186
+ startY = pos.y;
187
+ startWidth = element.offsetWidth;
188
+ startHeight = element.offsetHeight;
189
+
190
+ document.body.style.cursor = 'nwse-resize';
191
+ document.body.style.userSelect = 'none';
192
+
193
+ e.preventDefault();
194
+ }
195
+
196
+ function doResize(e) {
197
+ if (!isResizing) return;
198
+
199
+ const pos = getPosition(e);
200
+ const dx = pos.x - startX;
201
+ const dy = pos.y - startY;
202
+
203
+ let newWidth, newHeight;
204
+
205
+ if (aspectRatio) {
206
+ // Use diagonal movement for aspect-ratio constrained resize
207
+ const delta = (dx + dy) / 2;
208
+ newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + delta));
209
+ newHeight = newWidth / aspectRatio;
210
+ } else {
211
+ newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + dx));
212
+ newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight + dy));
213
+ }
214
+
215
+ element.style.width = newWidth + 'px';
216
+ element.style.height = newHeight + 'px';
217
+
218
+ if (onResize) onResize(newWidth, newHeight);
219
+ }
220
+
221
+ function endResize() {
222
+ if (!isResizing) return;
223
+ isResizing = false;
224
+
225
+ document.body.style.cursor = '';
226
+ document.body.style.userSelect = '';
227
+
228
+ if (onResizeEnd) {
229
+ onResizeEnd(element.offsetWidth, element.offsetHeight);
230
+ }
231
+ }
232
+
233
+ // Mouse events
234
+ handleEl.addEventListener('mousedown', startResize);
235
+ document.addEventListener('mousemove', doResize);
236
+ document.addEventListener('mouseup', endResize);
237
+
238
+ // Touch events
239
+ handleEl.addEventListener('touchstart', startResize, { passive: false });
240
+ document.addEventListener('touchmove', doResize, { passive: false });
241
+ document.addEventListener('touchend', endResize);
242
+
243
+ // Set cursor
244
+ handleEl.style.cursor = 'nwse-resize';
245
+
246
+ return function cleanup() {
247
+ handleEl.removeEventListener('mousedown', startResize);
248
+ document.removeEventListener('mousemove', doResize);
249
+ document.removeEventListener('mouseup', endResize);
250
+ handleEl.removeEventListener('touchstart', startResize);
251
+ document.removeEventListener('touchmove', doResize);
252
+ document.removeEventListener('touchend', endResize);
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Make container children reorderable via drag
258
+ * @param {HTMLElement} container - Container element
259
+ * @param {Object} options - Configuration options
260
+ * @param {string} options.itemSelector - CSS selector for draggable items
261
+ * @param {string} options.handleSelector - Optional handle selector within items
262
+ * @param {string[]} options.excludeSelectors - Selectors that should NOT trigger drag (default: buttons, inputs, etc.)
263
+ * @param {Function} options.onReorder - Callback with new order array of IDs
264
+ * @param {number} options.animationMs - Animation duration in ms (default: 150)
265
+ */
266
+ function makeReorderable(container, options = {}) {
267
+ if (!container) return;
268
+
269
+ const {
270
+ itemSelector,
271
+ handleSelector = null,
272
+ excludeSelectors = ['button', 'input', 'select', 'a', 'canvas'],
273
+ onReorder = null,
274
+ animationMs = 150
275
+ } = options;
276
+
277
+ const excludeSelector = excludeSelectors.join(', ');
278
+
279
+ let draggedItem = null;
280
+ let originalIndex = -1; // Track original position by index
281
+ let swapTarget = null; // Current item we'd swap with
282
+ let itemRects = []; // Store original positions before drag
283
+
284
+ function getItems() {
285
+ return Array.from(container.querySelectorAll(itemSelector));
286
+ }
287
+
288
+ function getPosition(e) {
289
+ if (e.touches && e.touches.length > 0) {
290
+ return { x: e.touches[0].clientX, y: e.touches[0].clientY };
291
+ }
292
+ return { x: e.clientX, y: e.clientY };
293
+ }
294
+
295
+ function findDropTarget(x, y) {
296
+ // Use stored rects from before drag started (not current positions)
297
+ for (let i = 0; i < itemRects.length; i++) {
298
+ if (i === originalIndex) continue; // Skip dragged item's original position
299
+ const rect = itemRects[i];
300
+
301
+ // Check if cursor is in this item's original area
302
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
303
+ return getItems()[i];
304
+ }
305
+ }
306
+ return null;
307
+ }
308
+
309
+ function startDrag(e, item) {
310
+ draggedItem = item;
311
+
312
+ // Store all item positions BEFORE any changes
313
+ const items = getItems();
314
+ originalIndex = items.indexOf(item);
315
+ itemRects = items.map(el => {
316
+ const rect = el.getBoundingClientRect();
317
+ return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
318
+ });
319
+
320
+ const rect = itemRects[originalIndex];
321
+ const width = rect.right - rect.left;
322
+ const height = rect.bottom - rect.top;
323
+ const pos = getPosition(e);
324
+
325
+ // Calculate offset from cursor to element's top-left corner
326
+ item._dragOffsetX = pos.x - rect.left;
327
+ item._dragOffsetY = pos.y - rect.top;
328
+
329
+ // Style the dragged item (no placeholder needed for swap)
330
+ item.classList.add('dragging');
331
+ item.style.position = 'fixed';
332
+ item.style.width = width + 'px';
333
+ item.style.zIndex = '1000';
334
+ item.style.left = rect.left + 'px';
335
+ item.style.top = rect.top + 'px';
336
+
337
+ document.body.style.userSelect = 'none';
338
+ e.preventDefault();
339
+ }
340
+
341
+ function moveDrag(e) {
342
+ if (!draggedItem) return;
343
+
344
+ const pos = getPosition(e);
345
+ draggedItem.style.left = (pos.x - draggedItem._dragOffsetX) + 'px';
346
+ draggedItem.style.top = (pos.y - draggedItem._dragOffsetY) + 'px';
347
+
348
+ // Find potential swap target using original positions
349
+ const target = findDropTarget(pos.x, pos.y);
350
+
351
+ // Remove highlight from previous target
352
+ if (swapTarget && swapTarget !== target) {
353
+ swapTarget.classList.remove('swap-target');
354
+ }
355
+
356
+ // Highlight new target
357
+ if (target && target !== swapTarget) {
358
+ target.classList.add('swap-target');
359
+ }
360
+
361
+ swapTarget = target;
362
+ }
363
+
364
+ function endDrag() {
365
+ if (!draggedItem) return;
366
+
367
+ // Remove swap target highlight
368
+ if (swapTarget) {
369
+ swapTarget.classList.remove('swap-target');
370
+ }
371
+
372
+ // Reset dragged item styles first
373
+ draggedItem.classList.remove('dragging');
374
+ draggedItem.style.position = '';
375
+ draggedItem.style.width = '';
376
+ draggedItem.style.left = '';
377
+ draggedItem.style.top = '';
378
+ draggedItem.style.zIndex = '';
379
+
380
+ // Perform swap if we have a target
381
+ if (swapTarget) {
382
+ const items = getItems();
383
+ const targetIndex = items.indexOf(swapTarget);
384
+
385
+ // Get references to adjacent elements for insertion
386
+ const draggedNext = draggedItem.nextElementSibling;
387
+ const targetNext = swapTarget.nextElementSibling;
388
+
389
+ // Swap positions in DOM
390
+ if (draggedNext === swapTarget) {
391
+ // Adjacent: dragged is right before target
392
+ container.insertBefore(swapTarget, draggedItem);
393
+ } else if (targetNext === draggedItem) {
394
+ // Adjacent: target is right before dragged
395
+ container.insertBefore(draggedItem, swapTarget);
396
+ } else {
397
+ // Not adjacent: need placeholder for swap
398
+ const placeholder = document.createElement('div');
399
+ container.insertBefore(placeholder, draggedItem);
400
+ container.insertBefore(draggedItem, targetNext);
401
+ container.insertBefore(swapTarget, placeholder);
402
+ placeholder.remove();
403
+ }
404
+ }
405
+
406
+ document.body.style.userSelect = '';
407
+
408
+ // Get new order (only if swap happened)
409
+ if (swapTarget && onReorder) {
410
+ const newOrder = getItems().map(item => item.id).filter(Boolean);
411
+ onReorder(newOrder);
412
+ }
413
+
414
+ draggedItem = null;
415
+ originalIndex = -1;
416
+ swapTarget = null;
417
+ itemRects = [];
418
+ }
419
+
420
+ // Attach handlers to items
421
+ function attachHandlers() {
422
+ getItems().forEach(item => {
423
+ const handle = handleSelector ? item.querySelector(handleSelector) : item;
424
+ if (!handle) return;
425
+
426
+ handle.addEventListener('mousedown', (e) => {
427
+ // Don't drag if clicking on excluded elements
428
+ if (excludeSelector && e.target.closest(excludeSelector)) return;
429
+ startDrag(e, item);
430
+ });
431
+
432
+ handle.addEventListener('touchstart', (e) => {
433
+ if (excludeSelector && e.target.closest(excludeSelector)) return;
434
+ startDrag(e, item);
435
+ }, { passive: false });
436
+ });
437
+ }
438
+
439
+ attachHandlers();
440
+
441
+ document.addEventListener('mousemove', moveDrag);
442
+ document.addEventListener('mouseup', endDrag);
443
+ document.addEventListener('touchmove', moveDrag, { passive: false });
444
+ document.addEventListener('touchend', endDrag);
445
+
446
+ return {
447
+ refresh: attachHandlers,
448
+ cleanup: function() {
449
+ document.removeEventListener('mousemove', moveDrag);
450
+ document.removeEventListener('mouseup', endDrag);
451
+ document.removeEventListener('touchmove', moveDrag);
452
+ document.removeEventListener('touchend', endDrag);
453
+ }
454
+ };
455
+ }
456
+
457
+ /**
458
+ * Apply saved order to container children
459
+ * @param {HTMLElement} container - Container element
460
+ * @param {string[]} orderArray - Array of item IDs in desired order
461
+ */
462
+ function applyOrder(container, orderArray) {
463
+ if (!container || !orderArray || !Array.isArray(orderArray)) return;
464
+
465
+ const fragment = document.createDocumentFragment();
466
+ const itemsById = {};
467
+
468
+ // Index all children by ID
469
+ Array.from(container.children).forEach(child => {
470
+ if (child.id) {
471
+ itemsById[child.id] = child;
472
+ }
473
+ });
474
+
475
+ // Add items in saved order
476
+ orderArray.forEach(id => {
477
+ if (itemsById[id]) {
478
+ fragment.appendChild(itemsById[id]);
479
+ delete itemsById[id];
480
+ }
481
+ });
482
+
483
+ // Add any remaining items (new ones not in saved order)
484
+ Object.values(itemsById).forEach(item => {
485
+ fragment.appendChild(item);
486
+ });
487
+
488
+ // Replace children
489
+ container.innerHTML = '';
490
+ container.appendChild(fragment);
491
+ }
492
+
493
+ /**
494
+ * Helper to save settings
495
+ * @param {Object} settings - Settings to save
496
+ */
497
+ function saveSettings(settings) {
498
+ return fetch('/api/settings', {
499
+ method: 'PUT',
500
+ headers: { 'Content-Type': 'application/json' },
501
+ body: JSON.stringify(settings)
502
+ }).catch(err => console.warn('Failed to save settings:', err));
503
+ }
504
+
505
+ /**
506
+ * Helper to load settings
507
+ * @returns {Promise<Object>}
508
+ */
509
+ function loadSettings() {
510
+ return fetch('/api/settings')
511
+ .then(r => r.json())
512
+ .catch(() => ({}));
513
+ }
514
+
515
+ // Export
516
+ window.ReachyDragUtils = {
517
+ makeDraggable,
518
+ makeResizable,
519
+ makeReorderable,
520
+ applyOrder,
521
+ saveSettings,
522
+ loadSettings,
523
+ bringToFront: function(element) {
524
+ topZIndex++;
525
+ element.style.zIndex = topZIndex;
526
+ }
527
+ };
528
+
529
+ })();
hello_world/static/js/core/init.js ADDED
@@ -0,0 +1,928 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // init.js - App bootstrap for Hello World
2
+ // Initializes core modules and Status tab
3
+
4
+ (function() {
5
+ 'use strict';
6
+
7
+ // ===== Theme Management =====
8
+ function initTheme() {
9
+ const saved = localStorage.getItem('theme');
10
+ if (saved === 'light') document.documentElement.classList.add('light');
11
+
12
+ const toggle = document.getElementById('themeToggle');
13
+ if (toggle) {
14
+ toggle.addEventListener('click', () => {
15
+ document.documentElement.classList.toggle('light');
16
+ const isLight = document.documentElement.classList.contains('light');
17
+ localStorage.setItem('theme', isLight ? 'light' : 'dark');
18
+ toggle.textContent = isLight ? '\u263E' : '\u2600';
19
+ });
20
+ toggle.textContent = document.documentElement.classList.contains('light') ? '\u263E' : '\u2600';
21
+ }
22
+ }
23
+
24
+ // ===== Toast System =====
25
+ function initToast() {
26
+ // Create toast container if it doesn't exist
27
+ if (!document.getElementById('toastContainer')) {
28
+ const container = document.createElement('div');
29
+ container.id = 'toastContainer';
30
+ container.className = 'toast-container';
31
+ document.body.appendChild(container);
32
+ }
33
+
34
+ window.ReachyToast = {
35
+ show(opts) {
36
+ const container = document.getElementById('toastContainer');
37
+ if (!container) return;
38
+
39
+ const toast = document.createElement('div');
40
+ toast.className = `toast toast-${opts.type || 'info'}`;
41
+ toast.innerHTML = `<strong>${opts.title || ''}</strong>${opts.message ? '<br>' + opts.message : ''}`;
42
+ container.appendChild(toast);
43
+
44
+ const duration = opts.duration !== undefined ? opts.duration : 3000;
45
+ if (duration > 0) {
46
+ setTimeout(() => toast.remove(), duration);
47
+ }
48
+ return toast;
49
+ },
50
+ success(title, message, opts = {}) {
51
+ return this.show({ type: 'success', title, message, ...opts });
52
+ },
53
+ error(title, message, opts = {}) {
54
+ return this.show({ type: 'error', title, message, ...opts });
55
+ },
56
+ dismiss(toast) {
57
+ if (toast && toast.remove) toast.remove();
58
+ }
59
+ };
60
+ }
61
+
62
+ // ===== Floating Joystick Panel Toggle =====
63
+ function initFloatingJoystickPanel() {
64
+ const toggle = document.getElementById('joystickToggle');
65
+ const panel = document.getElementById('floatingJoysticks');
66
+ if (!toggle || !panel) return;
67
+
68
+ // Make the panel draggable via FloatingPanel class
69
+ if (window.FloatingPanel) {
70
+ new FloatingPanel('floatingJoysticks', {
71
+ draggable: true,
72
+ resizable: false,
73
+ minimizable: true,
74
+ excludeSelectors: ['.floating-close-btn', '.floating-minimize-btn', '[id*="Knob"]', '[id*="Slider"]', '#resetControlsBtn']
75
+ });
76
+ }
77
+
78
+ function showPanel() {
79
+ panel.style.display = '';
80
+ toggle.classList.add('active');
81
+ }
82
+
83
+ function hidePanel() {
84
+ panel.style.display = 'none';
85
+ toggle.classList.remove('active');
86
+ }
87
+
88
+ toggle.addEventListener('click', () => {
89
+ if (panel.style.display === 'none') showPanel(); else hidePanel();
90
+ });
91
+
92
+ const closeBtn = panel.querySelector('.floating-close-btn');
93
+ if (closeBtn) closeBtn.addEventListener('click', hidePanel);
94
+ }
95
+
96
+ // ===== Floating Camera Panel Toggle =====
97
+ function initFloatingCameraPanel() {
98
+ const toggle = document.getElementById('cameraToggle');
99
+ const panel = document.getElementById('floatingCamera');
100
+ if (!toggle || !panel) return;
101
+
102
+ // Initialize WebRTC module (loads API script, doesn't start stream)
103
+ if (window.ReachyWebRTC) ReachyWebRTC.init();
104
+
105
+ // Make the panel draggable via FloatingPanel class
106
+ if (window.FloatingPanel) {
107
+ new FloatingPanel('floatingCamera', {
108
+ draggable: true,
109
+ resizable: false,
110
+ minimizable: true,
111
+ excludeSelectors: ['.floating-close-btn', '.floating-minimize-btn', '.cam-btn', '#recordTimer']
112
+ });
113
+ }
114
+
115
+ let recordingActive = false;
116
+ let recordTimerInterval = null;
117
+ let recordStartTime = 0;
118
+ let micRecordingActive = false;
119
+ let micTimerInterval = null;
120
+ let micStartTime = 0;
121
+
122
+ // Listen state (unmute WebRTC audio)
123
+ let listening = false;
124
+
125
+ // Speak state (intercom pipeline)
126
+ let speakingActive = false;
127
+ let intercomWs = null;
128
+ let intercomAudioCtx = null;
129
+ let intercomStream = null;
130
+
131
+ async function stopSpeaking() {
132
+ speakingActive = false;
133
+ const speakBtn = document.getElementById('speakBtn');
134
+ if (speakBtn) {
135
+ speakBtn.classList.remove('speaking');
136
+ speakBtn.innerHTML = '&#x1F399;';
137
+ }
138
+ if (intercomWs) {
139
+ try { intercomWs.close(); } catch (e) {}
140
+ intercomWs = null;
141
+ }
142
+ if (intercomAudioCtx) {
143
+ try { await intercomAudioCtx.close(); } catch (e) {}
144
+ intercomAudioCtx = null;
145
+ }
146
+ if (intercomStream) {
147
+ intercomStream.getTracks().forEach(t => t.stop());
148
+ intercomStream = null;
149
+ }
150
+ }
151
+
152
+ function showPanel() {
153
+ panel.style.display = '';
154
+ toggle.classList.add('active');
155
+ if (window.ReachyWebRTC) ReachyWebRTC.enableStream();
156
+ }
157
+
158
+ function hidePanel() {
159
+ panel.style.display = 'none';
160
+ toggle.classList.remove('active');
161
+ if (window.ReachyWebRTC) ReachyWebRTC.disableStream();
162
+
163
+ // Reset listen state
164
+ if (listening) {
165
+ listening = false;
166
+ const videoFeed = document.getElementById('videoFeed');
167
+ if (videoFeed) videoFeed.muted = true;
168
+ const listenBtn = document.getElementById('listenBtn');
169
+ if (listenBtn) {
170
+ listenBtn.classList.remove('listening');
171
+ listenBtn.innerHTML = '&#x1F50A;';
172
+ }
173
+ }
174
+
175
+ // Stop speak if active
176
+ if (speakingActive) stopSpeaking();
177
+ }
178
+
179
+ toggle.addEventListener('click', () => {
180
+ if (panel.style.display === 'none') showPanel(); else hidePanel();
181
+ });
182
+
183
+ const closeBtn = panel.querySelector('.floating-close-btn');
184
+ if (closeBtn) closeBtn.addEventListener('click', hidePanel);
185
+
186
+ // Snapshot button
187
+ const snapshotBtn = document.getElementById('snapshotBtn');
188
+ if (snapshotBtn) {
189
+ snapshotBtn.addEventListener('click', async () => {
190
+ try {
191
+ const resp = await fetch('/api/snapshots/capture', { method: 'POST' });
192
+ const data = await resp.json();
193
+ if (data.status === 'ok') {
194
+ if (window.ReachyToast) ReachyToast.success('Snapshot', data.filename);
195
+ } else {
196
+ if (window.ReachyToast) ReachyToast.error('Snapshot', data.error || 'Failed');
197
+ }
198
+ } catch (e) {
199
+ if (window.ReachyToast) ReachyToast.error('Snapshot', e.message);
200
+ }
201
+ });
202
+ }
203
+
204
+ // Record button
205
+ const recordBtn = document.getElementById('recordBtn');
206
+ const recordTimer = document.getElementById('recordTimer');
207
+
208
+ function updateTimer() {
209
+ if (!recordTimer) return;
210
+ const elapsed = Math.floor((Date.now() - recordStartTime) / 1000);
211
+ const mins = String(Math.floor(elapsed / 60)).padStart(2, '0');
212
+ const secs = String(elapsed % 60).padStart(2, '0');
213
+ recordTimer.textContent = `${mins}:${secs}`;
214
+ }
215
+
216
+ if (recordBtn) {
217
+ recordBtn.addEventListener('click', async () => {
218
+ if (!recordingActive) {
219
+ // Start recording
220
+ try {
221
+ const resp = await fetch('/api/recordings/start', { method: 'POST' });
222
+ const data = await resp.json();
223
+ if (data.status === 'ok') {
224
+ recordingActive = true;
225
+ recordBtn.classList.add('recording');
226
+ recordStartTime = Date.now();
227
+ if (recordTimer) {
228
+ recordTimer.style.display = '';
229
+ recordTimer.textContent = '00:00';
230
+ }
231
+ recordTimerInterval = setInterval(updateTimer, 1000);
232
+ if (window.ReachyToast) ReachyToast.success('Recording', 'Started');
233
+ } else {
234
+ if (window.ReachyToast) ReachyToast.error('Recording', data.error || 'Failed');
235
+ }
236
+ } catch (e) {
237
+ if (window.ReachyToast) ReachyToast.error('Recording', e.message);
238
+ }
239
+ } else {
240
+ // Stop recording
241
+ try {
242
+ const resp = await fetch('/api/recordings/stop', { method: 'POST' });
243
+ const data = await resp.json();
244
+ recordingActive = false;
245
+ recordBtn.classList.remove('recording');
246
+ if (recordTimerInterval) {
247
+ clearInterval(recordTimerInterval);
248
+ recordTimerInterval = null;
249
+ }
250
+ if (recordTimer) recordTimer.style.display = 'none';
251
+ if (data.status === 'ok') {
252
+ if (window.ReachyToast) ReachyToast.success('Recording', `Saved: ${data.filename} (${data.duration}s)`);
253
+ } else {
254
+ if (window.ReachyToast) ReachyToast.error('Recording', data.error || 'Failed');
255
+ }
256
+ } catch (e) {
257
+ if (window.ReachyToast) ReachyToast.error('Recording', e.message);
258
+ }
259
+ }
260
+ });
261
+ }
262
+
263
+ // Mic record button
264
+ const micRecordBtn = document.getElementById('micRecordBtn');
265
+ if (micRecordBtn) {
266
+ function updateMicTimer() {
267
+ if (!recordTimer) return;
268
+ const elapsed = Math.floor((Date.now() - micStartTime) / 1000);
269
+ const mins = String(Math.floor(elapsed / 60)).padStart(2, '0');
270
+ const secs = String(elapsed % 60).padStart(2, '0');
271
+ recordTimer.textContent = `${mins}:${secs}`;
272
+ }
273
+
274
+ micRecordBtn.addEventListener('click', async () => {
275
+ if (!micRecordingActive) {
276
+ try {
277
+ const resp = await fetch('/api/sounds/start', { method: 'POST' });
278
+ const data = await resp.json();
279
+ if (data.status === 'ok') {
280
+ micRecordingActive = true;
281
+ micRecordBtn.classList.add('recording');
282
+ micStartTime = Date.now();
283
+ if (recordTimer) {
284
+ recordTimer.style.display = '';
285
+ recordTimer.textContent = '00:00';
286
+ }
287
+ micTimerInterval = setInterval(updateMicTimer, 1000);
288
+ if (window.ReachyToast) ReachyToast.success('Mic Recording', 'Started');
289
+ } else {
290
+ if (window.ReachyToast) ReachyToast.error('Mic Recording', data.error || 'Failed');
291
+ }
292
+ } catch (e) {
293
+ if (window.ReachyToast) ReachyToast.error('Mic Recording', e.message);
294
+ }
295
+ } else {
296
+ try {
297
+ const resp = await fetch('/api/sounds/stop', { method: 'POST' });
298
+ const data = await resp.json();
299
+ micRecordingActive = false;
300
+ micRecordBtn.classList.remove('recording');
301
+ if (micTimerInterval) {
302
+ clearInterval(micTimerInterval);
303
+ micTimerInterval = null;
304
+ }
305
+ if (recordTimer) recordTimer.style.display = 'none';
306
+ if (data.status === 'ok') {
307
+ if (window.ReachyToast) ReachyToast.success('Mic Recording', `Saved: ${data.filename} (${data.duration}s)`);
308
+ } else {
309
+ if (window.ReachyToast) ReachyToast.error('Mic Recording', data.error || 'Failed');
310
+ }
311
+ } catch (e) {
312
+ if (window.ReachyToast) ReachyToast.error('Mic Recording', e.message);
313
+ }
314
+ }
315
+ });
316
+ }
317
+
318
+ // Listen button - unmute WebRTC video to hear robot mic
319
+ const listenBtn = document.getElementById('listenBtn');
320
+ if (listenBtn) {
321
+ listenBtn.addEventListener('click', () => {
322
+ const videoFeed = document.getElementById('videoFeed');
323
+ if (!videoFeed) return;
324
+
325
+ listening = !listening;
326
+ if (listening) {
327
+ videoFeed.muted = false;
328
+ videoFeed.volume = 1.0;
329
+ listenBtn.classList.add('listening');
330
+ listenBtn.innerHTML = '&#x1F507;'; // mute icon
331
+ } else {
332
+ videoFeed.muted = true;
333
+ listenBtn.classList.remove('listening');
334
+ listenBtn.innerHTML = '&#x1F50A;'; // speaker icon
335
+ }
336
+ });
337
+ }
338
+
339
+ // Speak button - open mic and stream to robot via WebSocket
340
+ const speakBtn = document.getElementById('speakBtn');
341
+ if (speakBtn) {
342
+ speakBtn.addEventListener('click', async () => {
343
+ if (speakingActive) {
344
+ await stopSpeaking();
345
+ return;
346
+ }
347
+
348
+ try {
349
+ // Get browser mic
350
+ intercomStream = await navigator.mediaDevices.getUserMedia({
351
+ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true }
352
+ });
353
+
354
+ // Create AudioContext and load worklet
355
+ intercomAudioCtx = new AudioContext();
356
+ await intercomAudioCtx.audioWorklet.addModule('/static/js/intercom-processor.js');
357
+
358
+ const source = intercomAudioCtx.createMediaStreamSource(intercomStream);
359
+ const workletNode = new AudioWorkletNode(intercomAudioCtx, 'intercom-processor', {
360
+ processorOptions: {
361
+ sampleRate: intercomAudioCtx.sampleRate,
362
+ gain: 5.0
363
+ }
364
+ });
365
+
366
+ // Open WebSocket to intercom endpoint
367
+ const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
368
+ intercomWs = new WebSocket(`${wsProtocol}//${location.host}/ws/intercom`);
369
+ intercomWs.binaryType = 'arraybuffer';
370
+
371
+ intercomWs.onopen = () => {
372
+ speakingActive = true;
373
+ speakBtn.classList.add('speaking');
374
+ speakBtn.innerHTML = '&#x1F507;'; // mute icon while active
375
+
376
+ // Wire up: worklet posts PCM buffers -> send over WebSocket
377
+ workletNode.port.onmessage = (e) => {
378
+ if (e.data.type === 'audio' && intercomWs && intercomWs.readyState === WebSocket.OPEN) {
379
+ intercomWs.send(e.data.data);
380
+ }
381
+ };
382
+
383
+ source.connect(workletNode);
384
+ workletNode.connect(intercomAudioCtx.destination); // needed to keep worklet alive
385
+ };
386
+
387
+ intercomWs.onerror = () => {
388
+ if (window.ReachyToast) ReachyToast.error('Intercom', 'WebSocket connection failed');
389
+ stopSpeaking();
390
+ };
391
+
392
+ intercomWs.onclose = () => {
393
+ if (speakingActive) stopSpeaking();
394
+ };
395
+ } catch (e) {
396
+ if (window.ReachyToast) ReachyToast.error('Intercom', e.message || 'Mic access denied');
397
+ await stopSpeaking();
398
+ }
399
+ });
400
+ }
401
+ }
402
+
403
+ // ===== Media Tab =====
404
+ function initMediaTab() {
405
+ let mediaLoaded = false;
406
+
407
+ function formatSize(bytes) {
408
+ if (bytes < 1024) return bytes + ' B';
409
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
410
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
411
+ }
412
+
413
+ function formatDate(timestamp) {
414
+ const d = new Date(timestamp * 1000);
415
+ const month = String(d.getMonth() + 1).padStart(2, '0');
416
+ const day = String(d.getDate()).padStart(2, '0');
417
+ const hours = String(d.getHours()).padStart(2, '0');
418
+ const mins = String(d.getMinutes()).padStart(2, '0');
419
+ return `${month}/${day} ${hours}:${mins}`;
420
+ }
421
+
422
+ function showLightbox(src, isVideo) {
423
+ const lb = document.createElement('div');
424
+ lb.className = 'media-lightbox';
425
+ if (isVideo) {
426
+ lb.innerHTML = `<video src="${src}" controls autoplay style="max-width:90vw;max-height:90vh;border-radius:8px;"></video><button class="media-lightbox-close">&times;</button>`;
427
+ } else {
428
+ lb.innerHTML = `<img src="${src}" alt=""><button class="media-lightbox-close">&times;</button>`;
429
+ }
430
+ lb.addEventListener('click', (e) => {
431
+ if (e.target === lb || e.target.classList.contains('media-lightbox-close')) lb.remove();
432
+ });
433
+ document.body.appendChild(lb);
434
+ }
435
+
436
+ function renderSnapshots(snapshots) {
437
+ const gallery = document.getElementById('snapshotGallery');
438
+ const count = document.getElementById('snapshotCount');
439
+ if (!gallery) return;
440
+ count.textContent = snapshots.length > 0 ? snapshots.length : '';
441
+
442
+ if (snapshots.length === 0) {
443
+ gallery.innerHTML = '<div class="media-empty">No snapshots yet. Use the camera panel to capture.</div>';
444
+ return;
445
+ }
446
+ gallery.innerHTML = snapshots.map(s => `
447
+ <div class="media-item" data-filename="${s.filename}" data-type="snapshot">
448
+ <img src="/api/media/snapshots/${s.filename}" alt="${s.filename}" loading="lazy">
449
+ <div class="media-item-info">
450
+ <span class="media-item-name" title="${s.filename}">${formatDate(s.modified)}</span>
451
+ <span class="media-item-size">${formatSize(s.size)}</span>
452
+ <div class="media-item-actions">
453
+ <button class="media-action-btn download" title="Download">&#x2B07;</button>
454
+ <button class="media-action-btn delete" title="Delete">&#x1F5D1;</button>
455
+ </div>
456
+ </div>
457
+ </div>
458
+ `).join('');
459
+
460
+ gallery.querySelectorAll('.media-item').forEach(item => {
461
+ const filename = item.dataset.filename;
462
+ item.querySelector('img').addEventListener('click', () => {
463
+ showLightbox(`/api/media/snapshots/${filename}`, false);
464
+ });
465
+ item.querySelector('.download').addEventListener('click', (e) => {
466
+ e.stopPropagation();
467
+ const a = document.createElement('a');
468
+ a.href = `/api/media/snapshots/${filename}`;
469
+ a.download = filename;
470
+ a.click();
471
+ });
472
+ item.querySelector('.delete').addEventListener('click', async (e) => {
473
+ e.stopPropagation();
474
+ const resp = await fetch(`/api/snapshots/${filename}`, { method: 'DELETE' });
475
+ const data = await resp.json();
476
+ if (data.status === 'ok') {
477
+ item.remove();
478
+ if (window.ReachyToast) ReachyToast.success('Deleted', filename);
479
+ const remaining = gallery.querySelectorAll('.media-item').length;
480
+ count.textContent = remaining > 0 ? remaining : '';
481
+ if (remaining === 0) gallery.innerHTML = '<div class="media-empty">No snapshots.</div>';
482
+ }
483
+ });
484
+ });
485
+ }
486
+
487
+ function renderRecordings(recordings) {
488
+ const gallery = document.getElementById('recordingGallery');
489
+ const count = document.getElementById('recordingCount');
490
+ if (!gallery) return;
491
+ count.textContent = recordings.length > 0 ? recordings.length : '';
492
+
493
+ if (recordings.length === 0) {
494
+ gallery.innerHTML = '<div class="media-empty">No recordings yet. Use the camera panel to record.</div>';
495
+ return;
496
+ }
497
+ gallery.innerHTML = recordings.map(r => {
498
+ let durLabel = '';
499
+ if (r.duration > 0) {
500
+ const mins = Math.floor(r.duration / 60);
501
+ const secs = Math.floor(r.duration % 60);
502
+ durLabel = mins > 0 ? `${mins}:${String(secs).padStart(2, '0')}` : `0:${String(secs).padStart(2, '0')}`;
503
+ }
504
+ return `
505
+ <div class="media-item" data-filename="${r.filename}" data-type="recording">
506
+ <img src="/api/recordings/${r.filename}/thumbnail" alt="${r.filename}" loading="lazy">
507
+ ${durLabel ? `<span class="media-duration">${durLabel}</span>` : ''}
508
+ <div class="media-item-info">
509
+ <span class="media-item-name" title="${r.filename}">${formatDate(r.modified)}</span>
510
+ <span class="media-item-size">${formatSize(r.size)}</span>
511
+ <div class="media-item-actions">
512
+ <button class="media-action-btn download" title="Download">&#x2B07;</button>
513
+ <button class="media-action-btn delete" title="Delete">&#x1F5D1;</button>
514
+ </div>
515
+ </div>
516
+ </div>
517
+ `}).join('');
518
+
519
+ gallery.querySelectorAll('.media-item').forEach(item => {
520
+ const filename = item.dataset.filename;
521
+ item.querySelector('img').addEventListener('click', () => {
522
+ showLightbox(`/api/media/recordings/${filename}`, true);
523
+ });
524
+ item.querySelector('.download').addEventListener('click', (e) => {
525
+ e.stopPropagation();
526
+ const a = document.createElement('a');
527
+ a.href = `/api/media/recordings/${filename}`;
528
+ a.download = filename;
529
+ a.click();
530
+ });
531
+ item.querySelector('.delete').addEventListener('click', async (e) => {
532
+ e.stopPropagation();
533
+ const resp = await fetch(`/api/recordings/${filename}`, { method: 'DELETE' });
534
+ const data = await resp.json();
535
+ if (data.status === 'ok') {
536
+ item.remove();
537
+ if (window.ReachyToast) ReachyToast.success('Deleted', filename);
538
+ const remaining = gallery.querySelectorAll('.media-item').length;
539
+ count.textContent = remaining > 0 ? remaining : '';
540
+ if (remaining === 0) gallery.innerHTML = '<div class="media-empty">No recordings.</div>';
541
+ }
542
+ });
543
+ });
544
+ }
545
+
546
+ function renderSounds(sounds) {
547
+ const gallery = document.getElementById('soundGallery');
548
+ const count = document.getElementById('soundCount');
549
+ if (!gallery) return;
550
+ count.textContent = sounds.length > 0 ? sounds.length : '';
551
+
552
+ if (sounds.length === 0) {
553
+ gallery.innerHTML = '<div class="media-empty">No sound recordings yet. Use the mic button to record.</div>';
554
+ return;
555
+ }
556
+ gallery.innerHTML = sounds.map(s => {
557
+ let durLabel = '';
558
+ if (s.duration) {
559
+ const mins = Math.floor(s.duration / 60);
560
+ const secs = Math.floor(s.duration % 60);
561
+ durLabel = mins > 0 ? `${mins}:${String(secs).padStart(2, '0')}` : `0:${String(secs).padStart(2, '0')}`;
562
+ }
563
+ return `
564
+ <div class="media-item" data-filename="${s.filename}" data-type="sound">
565
+ <img src="/api/sounds/${s.filename}/thumbnail?t=${Math.floor(s.modified)}" alt="${s.filename}" loading="lazy">
566
+ ${durLabel ? `<span class="media-duration">${durLabel}</span>` : ''}
567
+ <div class="media-item-info">
568
+ <span class="media-item-name" title="${s.filename}">${formatDate(s.modified)}</span>
569
+ <span class="media-item-size">${formatSize(s.size)}</span>
570
+ <div class="media-item-actions">
571
+ <button class="media-action-btn play" title="Play">&#x25B6;</button>
572
+ <button class="media-action-btn download" title="Download">&#x2B07;</button>
573
+ <button class="media-action-btn delete" title="Delete">&#x1F5D1;</button>
574
+ </div>
575
+ </div>
576
+ <audio preload="none" src="/api/sounds/file/${s.filename}"></audio>
577
+ </div>
578
+ `}).join('');
579
+
580
+ gallery.querySelectorAll('.media-item').forEach(item => {
581
+ const filename = item.dataset.filename;
582
+ const audio = item.querySelector('audio');
583
+
584
+ // Click thumbnail to play/pause
585
+ item.querySelector('img').addEventListener('click', () => {
586
+ if (audio.paused) { audio.play(); } else { audio.pause(); }
587
+ });
588
+ // Play button
589
+ const playBtn = item.querySelector('.play');
590
+ if (playBtn) {
591
+ playBtn.addEventListener('click', (e) => {
592
+ e.stopPropagation();
593
+ if (audio.paused) { audio.play(); } else { audio.pause(); }
594
+ });
595
+ audio.addEventListener('play', () => { playBtn.textContent = '\u23F8'; });
596
+ audio.addEventListener('pause', () => { playBtn.textContent = '\u25B6'; });
597
+ audio.addEventListener('ended', () => { playBtn.textContent = '\u25B6'; });
598
+ }
599
+ item.querySelector('.download').addEventListener('click', (e) => {
600
+ e.stopPropagation();
601
+ const a = document.createElement('a');
602
+ a.href = `/api/sounds/file/${filename}`;
603
+ a.download = filename;
604
+ a.click();
605
+ });
606
+ item.querySelector('.delete').addEventListener('click', async (e) => {
607
+ e.stopPropagation();
608
+ const resp = await fetch(`/api/sounds/${filename}`, { method: 'DELETE' });
609
+ const data = await resp.json();
610
+ if (data.status === 'ok') {
611
+ item.remove();
612
+ if (window.ReachyToast) ReachyToast.success('Deleted', filename);
613
+ const remaining = gallery.querySelectorAll('.media-item').length;
614
+ count.textContent = remaining > 0 ? remaining : '';
615
+ if (remaining === 0) gallery.innerHTML = '<div class="media-empty">No sound recordings.</div>';
616
+ }
617
+ });
618
+ });
619
+ }
620
+
621
+ function renderMusic(data) {
622
+ const gallery = document.getElementById('musicGallery');
623
+ const count = document.getElementById('musicCount');
624
+ const nowPlaying = document.getElementById('musicNowPlaying');
625
+ const nowPlayingText = document.getElementById('musicNowPlayingText');
626
+ const stopBtn = document.getElementById('musicStopBtn');
627
+ if (!gallery) return;
628
+
629
+ const tracks = data.tracks || [];
630
+ const playing = data.playing || null;
631
+ count.textContent = tracks.length > 0 ? tracks.length : '';
632
+
633
+ // Now playing bar
634
+ if (playing && nowPlaying) {
635
+ const t = tracks.find(tr => tr.filename === playing);
636
+ nowPlayingText.textContent = t ? (t.title || t.filename) : playing;
637
+ nowPlaying.style.display = 'flex';
638
+ } else if (nowPlaying) {
639
+ nowPlaying.style.display = 'none';
640
+ }
641
+
642
+ if (tracks.length === 0) {
643
+ gallery.innerHTML = '<div class="media-empty">No music files. Upload mp3, wav, ogg, flac, m4a, or aac.</div>';
644
+ return;
645
+ }
646
+
647
+ gallery.innerHTML = tracks.map(t => {
648
+ let durLabel = '';
649
+ if (t.duration) {
650
+ const mins = Math.floor(t.duration / 60);
651
+ const secs = Math.floor(t.duration % 60);
652
+ durLabel = `${mins}:${String(secs).padStart(2, '0')}`;
653
+ }
654
+ const coverSrc = t.has_cover ? `/api/music/cover/${encodeURIComponent(t.filename)}` : '';
655
+ const isPlaying = t.filename === playing;
656
+ return `
657
+ <div class="media-item${isPlaying ? ' music-active' : ''}" data-filename="${t.filename}" data-type="music">
658
+ ${coverSrc
659
+ ? `<img class="music-cover" src="${coverSrc}" alt="${t.title}" loading="lazy">`
660
+ : `<div class="music-cover music-cover-placeholder">\uD83C\uDFB5</div>`}
661
+ ${durLabel ? `<span class="media-duration">${durLabel}</span>` : ''}
662
+ <div class="media-item-info">
663
+ <div style="flex:1;min-width:0;">
664
+ <span class="media-item-name" title="${t.filename}">${t.title || t.filename}</span>
665
+ ${t.artist ? `<span class="music-artist">${t.artist}</span>` : ''}
666
+ </div>
667
+ <span class="media-item-size">${formatSize(t.size)}</span>
668
+ <div class="media-item-actions">
669
+ <button class="media-action-btn play" title="${isPlaying ? 'Playing' : 'Play'}">${isPlaying ? '\u23F8' : '\u25B6'}</button>
670
+ <button class="media-action-btn download" title="Download">\u2B07</button>
671
+ <button class="media-action-btn delete" title="Delete">\uD83D\uDDD1</button>
672
+ </div>
673
+ </div>
674
+ </div>
675
+ `}).join('');
676
+
677
+ gallery.querySelectorAll('.media-item').forEach(item => {
678
+ const filename = item.dataset.filename;
679
+ // Play on robot speaker
680
+ item.querySelector('.play').addEventListener('click', async (e) => {
681
+ e.stopPropagation();
682
+ const resp = await fetch(`/api/music/play/${encodeURIComponent(filename)}`, { method: 'POST' });
683
+ const result = await resp.json();
684
+ if (result.status === 'ok') {
685
+ if (window.ReachyToast) ReachyToast.success('Music', `Playing: ${filename}`);
686
+ loadMedia();
687
+ }
688
+ });
689
+ item.querySelector('.download').addEventListener('click', (e) => {
690
+ e.stopPropagation();
691
+ const a = document.createElement('a');
692
+ a.href = `/api/music/file/${encodeURIComponent(filename)}`;
693
+ a.download = filename;
694
+ a.click();
695
+ });
696
+ item.querySelector('.delete').addEventListener('click', async (e) => {
697
+ e.stopPropagation();
698
+ const resp = await fetch(`/api/music/${encodeURIComponent(filename)}`, { method: 'DELETE' });
699
+ const result = await resp.json();
700
+ if (result.status === 'ok') {
701
+ item.remove();
702
+ if (window.ReachyToast) ReachyToast.success('Deleted', filename);
703
+ const remaining = gallery.querySelectorAll('.media-item').length;
704
+ count.textContent = remaining > 0 ? remaining : '';
705
+ if (remaining === 0) gallery.innerHTML = '<div class="media-empty">No music files.</div>';
706
+ }
707
+ });
708
+ });
709
+
710
+ // Stop button
711
+ if (stopBtn) {
712
+ stopBtn.onclick = async () => {
713
+ await fetch('/api/music/stop', { method: 'POST' });
714
+ loadMedia();
715
+ };
716
+ }
717
+
718
+ // Upload handler
719
+ const uploadInput = document.getElementById('musicUploadInput');
720
+ if (uploadInput && !uploadInput._wired) {
721
+ uploadInput._wired = true;
722
+ uploadInput.addEventListener('change', async (e) => {
723
+ const file = e.target.files[0];
724
+ if (!file) return;
725
+ const formData = new FormData();
726
+ formData.append('file', file);
727
+ try {
728
+ const resp = await fetch('/api/music/upload', { method: 'POST', body: formData });
729
+ const result = await resp.json();
730
+ if (result.status === 'ok') {
731
+ if (window.ReachyToast) ReachyToast.success('Upload', `${file.name} uploaded`);
732
+ loadMedia();
733
+ } else {
734
+ if (window.ReachyToast) ReachyToast.error('Upload', result.error || 'Failed');
735
+ }
736
+ } catch (err) {
737
+ if (window.ReachyToast) ReachyToast.error('Upload', 'Upload failed');
738
+ }
739
+ uploadInput.value = '';
740
+ });
741
+ }
742
+ }
743
+
744
+ async function loadMedia() {
745
+ try {
746
+ const [snapResp, recResp, sndResp, musicResp] = await Promise.all([
747
+ fetch('/api/snapshots/list'),
748
+ fetch('/api/recordings/list'),
749
+ fetch('/api/sounds/list'),
750
+ fetch('/api/music/list')
751
+ ]);
752
+ const snapData = await snapResp.json();
753
+ const recData = await recResp.json();
754
+ const sndData = await sndResp.json();
755
+ const musicData = await musicResp.json();
756
+ renderSnapshots(snapData.snapshots || []);
757
+ renderRecordings(recData.recordings || []);
758
+ renderSounds(sndData.sounds || []);
759
+ renderMusic(musicData);
760
+ mediaLoaded = true;
761
+ } catch (e) {
762
+ console.error('Failed to load media:', e);
763
+ }
764
+ }
765
+
766
+ // Return a tab change handler that loads media on first visit
767
+ return function onTabChange(tab) {
768
+ if (tab === 'media') {
769
+ loadMedia(); // Always refresh when switching to media tab
770
+ }
771
+ };
772
+ }
773
+
774
+ // ===== Header Volume Control =====
775
+ function initHeaderVolume() {
776
+ const slider = document.getElementById('headerVolumeSlider');
777
+ const label = document.getElementById('headerVolumeValue');
778
+ if (!slider || !label) return;
779
+
780
+ const daemonAPI = window.ReachyConstants ? ReachyConstants.DAEMON_API : `http://${location.hostname}:8000`;
781
+
782
+ // Fetch current volume
783
+ fetch(`${daemonAPI}/api/volume/current`)
784
+ .then(r => r.json())
785
+ .then(data => {
786
+ slider.value = data.volume;
787
+ label.textContent = data.volume;
788
+ })
789
+ .catch(() => {});
790
+
791
+ // Live update label while dragging
792
+ slider.addEventListener('input', () => {
793
+ label.textContent = slider.value;
794
+ });
795
+
796
+ // Set volume on release
797
+ let debounceTimer;
798
+ slider.addEventListener('change', () => {
799
+ clearTimeout(debounceTimer);
800
+ debounceTimer = setTimeout(() => {
801
+ fetch(`${daemonAPI}/api/volume/set`, {
802
+ method: 'POST',
803
+ headers: { 'Content-Type': 'application/json' },
804
+ body: JSON.stringify({ volume: parseInt(slider.value) })
805
+ }).then(r => r.json()).then(() => {
806
+ if (window.ReachyToast) ReachyToast.success('Volume', slider.value + '%');
807
+ }).catch(e => {
808
+ if (window.ReachyToast) ReachyToast.error('Volume', 'Failed to set');
809
+ });
810
+ }, 100);
811
+ });
812
+ }
813
+
814
+ // ===== App Initialization =====
815
+ async function initApp() {
816
+ console.log('Hello World initializing...');
817
+
818
+ initTheme();
819
+ initToast();
820
+
821
+ // Initialize core modules
822
+ if (window.ReachyTabs) ReachyTabs.init();
823
+ if (window.ReachySettings) await ReachySettings.load();
824
+
825
+ // Initialize WebSocket and charts (daemon status pushed via /ws/live, no polling)
826
+ if (window.ReachyWebSocket) {
827
+ ReachyWebSocket.init();
828
+ }
829
+
830
+ // Initialize joystick controls (wires all 5 joysticks + body slider + reset button)
831
+ if (window.ReachyControls) {
832
+ ReachyControls.init();
833
+ }
834
+
835
+ // Initialize floating panels (drag + toggle + close)
836
+ initFloatingJoystickPanel();
837
+ initFloatingCameraPanel();
838
+
839
+ // Head Pose copy button
840
+ const copyBtn = document.getElementById('copyHeadPose');
841
+ if (copyBtn) {
842
+ const copyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
843
+ const checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
844
+ const showCopied = () => {
845
+ copyBtn.classList.add('copied');
846
+ copyBtn.innerHTML = checkIcon;
847
+ setTimeout(() => { copyBtn.classList.remove('copied'); copyBtn.innerHTML = copyIcon; }, 1500);
848
+ };
849
+ copyBtn.addEventListener('click', () => {
850
+ const val = id => {
851
+ const el = document.getElementById(id);
852
+ return el ? parseFloat(el.textContent) || 0 : 0;
853
+ };
854
+ const pose = {
855
+ x: val('headPosX'), y: val('headPosY'), z: val('headPosZ'),
856
+ roll: val('headRoll'), pitch: val('headPitch'), yaw: val('headYaw'),
857
+ antenna_left: val('headAntL'), antenna_right: val('headAntR'),
858
+ body_yaw: val('headBodyYaw')
859
+ };
860
+ const json = JSON.stringify(pose, null, 2);
861
+ navigator.clipboard.writeText(json).then(showCopied).catch(() => {
862
+ const ta = document.createElement('textarea');
863
+ ta.value = json; ta.style.position = 'fixed'; ta.style.opacity = '0';
864
+ document.body.appendChild(ta); ta.select(); document.execCommand('copy');
865
+ document.body.removeChild(ta);
866
+ showCopied();
867
+ });
868
+ });
869
+ }
870
+
871
+ // Initialize media tab handler
872
+ const mediaTabHandler = initMediaTab();
873
+
874
+ // Initialize conversation tab (lazy)
875
+ let conversationInitStarted = false;
876
+
877
+ // Register tab change callbacks BEFORE restoring tab (so restored tab triggers them)
878
+ let simInitStarted = false;
879
+ if (window.ReachyTabs && window.ReachyWebSocket) {
880
+ ReachyTabs.onTabChange((tab) => {
881
+ setTimeout(() => ReachyWebSocket.resizeCharts(), 50);
882
+ mediaTabHandler(tab);
883
+
884
+ // Lazy-init conversation when tab first shown
885
+ if (tab === 'conversation' && !conversationInitStarted) {
886
+ conversationInitStarted = true;
887
+ if (window.ReachyTranscribe) ReachyTranscribe.init();
888
+ if (window.ReachyAssistant) ReachyAssistant.init();
889
+ if (window.ReachyLLMSettings) ReachyLLMSettings.init();
890
+ }
891
+
892
+ // Lazy-init simulation when telemetry tab is first shown
893
+ if (tab === 'telemetry' && !simInitStarted && window.ReachySimulation) {
894
+ simInitStarted = true;
895
+ ReachySimulation.init('simContainer').then(() => {
896
+ const statusEl = document.getElementById('simStatus');
897
+ if (statusEl) statusEl.textContent = '';
898
+ }).catch(() => {
899
+ const statusEl = document.getElementById('simStatus');
900
+ if (statusEl) statusEl.textContent = 'Failed to load';
901
+ });
902
+ }
903
+
904
+ // Resize sim when switching to telemetry
905
+ if (tab === 'telemetry' && window.ReachySimulation && ReachySimulation.isInitialized()) {
906
+ setTimeout(() => ReachySimulation.resize(), 100);
907
+ }
908
+ });
909
+ }
910
+
911
+ // Restore last active tab (triggers callbacks registered above)
912
+ if (window.ReachyTabs) await ReachyTabs.restoreLastActiveTab();
913
+
914
+ if (window.ReachySettings) ReachySettings.finishInit();
915
+
916
+ // Header volume slider — controls daemon ALSA volume
917
+ initHeaderVolume();
918
+
919
+ console.log('Hello World initialized');
920
+ }
921
+
922
+ // Start when DOM is ready
923
+ if (document.readyState === 'loading') {
924
+ document.addEventListener('DOMContentLoaded', initApp);
925
+ } else {
926
+ initApp();
927
+ }
928
+ })();
hello_world/static/js/core/settings-manager.js ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * SettingsManager - Centralized settings API
3
+ * Handles all /api/settings read/write operations
4
+ */
5
+
6
+ window.ReachySettings = (function() {
7
+ 'use strict';
8
+
9
+ let settingsCache = null;
10
+ let cacheTimestamp = 0;
11
+ const CACHE_TTL = 5000;
12
+
13
+ let _initializing = true;
14
+ setTimeout(() => { _initializing = false; }, 3000);
15
+
16
+ async function load(forceRefresh = false) {
17
+ const now = Date.now();
18
+ if (!forceRefresh && settingsCache && (now - cacheTimestamp) < CACHE_TTL) {
19
+ return settingsCache;
20
+ }
21
+ try {
22
+ const response = await fetch('/api/settings');
23
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
24
+ settingsCache = await response.json();
25
+ cacheTimestamp = now;
26
+ return settingsCache;
27
+ } catch (e) {
28
+ console.debug('[Settings] Failed to load:', e.message);
29
+ return settingsCache || {};
30
+ }
31
+ }
32
+
33
+ async function save(key, value, options = {}) {
34
+ return saveMultiple({ [key]: value }, options);
35
+ }
36
+
37
+ async function saveMultiple(settings, options = {}) {
38
+ const silent = options.silent || _initializing;
39
+ try {
40
+ const response = await fetch('/api/settings', {
41
+ method: 'PUT',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify(settings)
44
+ });
45
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
46
+ const data = await response.json();
47
+
48
+ if (data._rejected_keys && data._rejected_keys.length > 0) {
49
+ console.error('[Settings] Rejected keys:', data._rejected_keys.join(', '));
50
+ }
51
+
52
+ if (settingsCache) {
53
+ const { _rejected_keys, ...cleanData } = data;
54
+ Object.assign(settingsCache, cleanData);
55
+ }
56
+ return true;
57
+ } catch (e) {
58
+ console.debug('[Settings] Failed to save:', e.message);
59
+ return false;
60
+ }
61
+ }
62
+
63
+ async function get(key, defaultValue = null) {
64
+ const settings = await load();
65
+ return settings[key] !== undefined ? settings[key] : defaultValue;
66
+ }
67
+
68
+ function clearCache() {
69
+ settingsCache = null;
70
+ cacheTimestamp = 0;
71
+ }
72
+
73
+ function getCached() {
74
+ return settingsCache;
75
+ }
76
+
77
+ function finishInit() {
78
+ _initializing = false;
79
+ }
80
+
81
+ return {
82
+ load, save, saveMultiple, get,
83
+ clearCache, getCached, getAll: getCached, finishInit
84
+ };
85
+ })();
hello_world/static/js/core/tabs.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // tabs.js - Tab navigation management
2
+
3
+ (function() {
4
+ 'use strict';
5
+
6
+ let currentTab = 'status';
7
+ const tabChangeCallbacks = [];
8
+
9
+ function getCurrentTab() {
10
+ return currentTab;
11
+ }
12
+
13
+ function switchTo(tabName, options = {}) {
14
+ const { saveToSettings = true, triggerCallbacks = true } = options;
15
+
16
+ const tabEl = document.querySelector(`.tab[data-tab="${tabName}"]`);
17
+ const contentEl = document.getElementById(tabName);
18
+
19
+ if (!tabEl || !contentEl) {
20
+ console.warn(`[Tabs] Tab "${tabName}" not found`);
21
+ return false;
22
+ }
23
+
24
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
25
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
26
+
27
+ tabEl.classList.add('active');
28
+ contentEl.classList.add('active');
29
+
30
+ const previousTab = currentTab;
31
+ currentTab = tabName;
32
+
33
+ const event = new CustomEvent('tabchange', {
34
+ detail: { tab: tabName, previousTab }
35
+ });
36
+ document.dispatchEvent(event);
37
+
38
+ if (triggerCallbacks) {
39
+ tabChangeCallbacks.forEach(cb => {
40
+ try { cb(tabName, previousTab); } catch (e) { console.error('[Tabs] Callback error:', e); }
41
+ });
42
+ }
43
+
44
+ if (saveToSettings && window.ReachySettings) {
45
+ ReachySettings.save('last_active_tab', tabName, { silent: true });
46
+ }
47
+
48
+ return true;
49
+ }
50
+
51
+ function onTabChange(callback) {
52
+ if (typeof callback === 'function') {
53
+ tabChangeCallbacks.push(callback);
54
+ return () => {
55
+ const index = tabChangeCallbacks.indexOf(callback);
56
+ if (index > -1) tabChangeCallbacks.splice(index, 1);
57
+ };
58
+ }
59
+ return () => {};
60
+ }
61
+
62
+ async function restoreLastActiveTab() {
63
+ try {
64
+ const lastTab = window.ReachySettings ? await ReachySettings.get('last_active_tab') : null;
65
+ if (lastTab && lastTab !== 'status') {
66
+ switchTo(lastTab, { saveToSettings: false });
67
+ return lastTab;
68
+ }
69
+ } catch (e) {}
70
+ return null;
71
+ }
72
+
73
+ function init() {
74
+ document.querySelectorAll('.tab').forEach(tab => {
75
+ tab.addEventListener('click', () => {
76
+ const tabName = tab.dataset.tab;
77
+ if (tabName && tabName !== currentTab) switchTo(tabName);
78
+ });
79
+ });
80
+
81
+ const activeTab = document.querySelector('.tab.active');
82
+ if (activeTab && activeTab.dataset.tab) currentTab = activeTab.dataset.tab;
83
+
84
+ console.log('[Tabs] Initialized with current tab:', currentTab);
85
+ }
86
+
87
+ window.ReachyTabs = { init, switchTo, getCurrentTab, onTabChange, restoreLastActiveTab };
88
+ })();
hello_world/static/js/features/FloatingPanel.js ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // FloatingPanel.js - Encapsulates draggable, resizable, minimizable panel behavior
2
+ // Now delegates to drag-utils.js for core drag/resize functionality
3
+
4
+ class FloatingPanel {
5
+ // Static array to track all panels for z-index ordering
6
+ static allPanels = [];
7
+
8
+ /**
9
+ * Create a FloatingPanel instance
10
+ * @param {string} elementId - The ID of the panel element
11
+ * @param {Object} options - Configuration options
12
+ * @param {boolean} options.draggable - Enable drag behavior (default: true)
13
+ * @param {boolean} options.resizable - Enable resize behavior (default: false)
14
+ * @param {boolean} options.minimizable - Enable minimize behavior (default: true)
15
+ * @param {string} options.handleSelector - CSS selector for drag handle (default: '.floating-panel-header')
16
+ * @param {string} options.resizeHandleSelector - CSS selector for resize handle (default: '.floating-scale')
17
+ * @param {string} options.resizeTargetSelector - CSS selector for resize target (default: null, uses panel)
18
+ * @param {string[]} options.excludeSelectors - Selectors that should NOT trigger drag
19
+ * @param {boolean} options.constrainToViewport - Keep panel within viewport (default: true)
20
+ * @param {number} options.minWidth - Minimum width for resize (default: 200)
21
+ * @param {number} options.maxWidth - Maximum width for resize (default: 800)
22
+ * @param {boolean} options.maintainAspectRatio - Maintain aspect ratio during resize (default: true)
23
+ * @param {Function} options.onDragEnd - Callback when drag ends
24
+ * @param {Function} options.onResizeEnd - Callback when resize ends
25
+ * @param {Function} options.onMinimize - Callback when panel is minimized
26
+ * @param {Function} options.onRestore - Callback when panel is restored
27
+ * @param {string} options.storageKey - localStorage key for position persistence
28
+ */
29
+ constructor(elementId, options = {}) {
30
+ this.elementId = elementId;
31
+ this.element = document.getElementById(elementId);
32
+
33
+ if (!this.element) {
34
+ console.warn(`FloatingPanel: Element with id "${elementId}" not found`);
35
+ return;
36
+ }
37
+
38
+ // Default options
39
+ this.options = {
40
+ draggable: true,
41
+ resizable: false,
42
+ minimizable: true,
43
+ handleSelector: '.floating-panel-header',
44
+ resizeHandleSelector: '.floating-scale',
45
+ resizeTargetSelector: null,
46
+ excludeSelectors: ['.floating-close-btn', '.floating-minimize-btn'],
47
+ constrainToViewport: true,
48
+ minWidth: 200,
49
+ maxWidth: 800,
50
+ maintainAspectRatio: true,
51
+ onDragEnd: null,
52
+ onResizeEnd: null,
53
+ onMinimize: null,
54
+ onRestore: null,
55
+ storageKey: null,
56
+ ...options
57
+ };
58
+
59
+ // State
60
+ this.isMinimized = false;
61
+ this.savedPosition = null;
62
+ this._cleanupDrag = null;
63
+ this._cleanupResize = null;
64
+
65
+ // Initialize
66
+ this._init();
67
+
68
+ // Add to static panel list
69
+ FloatingPanel.allPanels.push(this);
70
+ }
71
+
72
+ /**
73
+ * Initialize the panel with configured behaviors
74
+ */
75
+ _init() {
76
+ if (this.options.draggable) {
77
+ this._makeDraggable();
78
+ }
79
+
80
+ if (this.options.resizable) {
81
+ this._makeResizable();
82
+ }
83
+
84
+ if (this.options.minimizable) {
85
+ this._setupMinimize();
86
+ }
87
+
88
+ // Bring to front on any click within the panel
89
+ this.element.addEventListener('mousedown', () => this.bringToFront());
90
+
91
+ // Restore saved position if storage key provided
92
+ if (this.options.storageKey) {
93
+ this.restorePosition();
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Make the panel draggable by its header
99
+ * Delegates to ReachyDragUtils.makeDraggable
100
+ * @private
101
+ */
102
+ _makeDraggable() {
103
+ if (!window.ReachyDragUtils) {
104
+ console.warn('FloatingPanel: ReachyDragUtils not available');
105
+ return;
106
+ }
107
+
108
+ this._cleanupDrag = ReachyDragUtils.makeDraggable(this.element, {
109
+ handle: this.options.handleSelector,
110
+ excludeSelectors: this.options.excludeSelectors,
111
+ constrainToViewport: this.options.constrainToViewport,
112
+ bringToFront: true,
113
+ onDragEnd: this.options.onDragEnd
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Make the panel resizable
119
+ * Delegates to ReachyDragUtils.makeResizable
120
+ * @private
121
+ */
122
+ _makeResizable() {
123
+ if (!window.ReachyDragUtils) {
124
+ console.warn('FloatingPanel: ReachyDragUtils not available');
125
+ return;
126
+ }
127
+
128
+ const resizeTarget = this.options.resizeTargetSelector
129
+ ? this.element.querySelector(this.options.resizeTargetSelector)
130
+ : this.element;
131
+
132
+ if (!resizeTarget) return;
133
+
134
+ const handle = this.element.querySelector(this.options.resizeHandleSelector);
135
+ if (!handle) return;
136
+
137
+ const initialWidth = resizeTarget.offsetWidth || 320;
138
+ const initialHeight = resizeTarget.offsetHeight || 240;
139
+
140
+ this._cleanupResize = ReachyDragUtils.makeResizable(resizeTarget, {
141
+ handle: handle,
142
+ minWidth: this.options.minWidth,
143
+ maxWidth: this.options.maxWidth,
144
+ aspectRatio: this.options.maintainAspectRatio ? (initialWidth / initialHeight) : null,
145
+ onResizeEnd: this.options.onResizeEnd
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Setup minimize button handler
151
+ * @private
152
+ */
153
+ _setupMinimize() {
154
+ const minimizeBtn = this.element.querySelector('.floating-minimize-btn');
155
+ if (minimizeBtn) {
156
+ minimizeBtn.addEventListener('click', (e) => {
157
+ e.stopPropagation();
158
+ this.toggleMinimize();
159
+ });
160
+ }
161
+
162
+ // Clicking minimized panel header restores it
163
+ const header = this.element.querySelector(this.options.handleSelector);
164
+ if (header) {
165
+ header.addEventListener('click', () => {
166
+ if (this.isMinimized) {
167
+ this.toggleMinimize();
168
+ }
169
+ });
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Bring panel to top z-index
175
+ */
176
+ bringToFront() {
177
+ if (!this.element) return;
178
+ if (window.ReachyDragUtils) {
179
+ ReachyDragUtils.bringToFront(this.element);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Toggle minimize/restore state
185
+ */
186
+ toggleMinimize() {
187
+ if (!this.element) return;
188
+
189
+ if (!this.isMinimized) {
190
+ // Save current position before minimizing
191
+ this.savedPosition = {
192
+ top: this.element.style.top,
193
+ left: this.element.style.left,
194
+ right: this.element.style.right,
195
+ bottom: this.element.style.bottom
196
+ };
197
+ // Clear inline positioning so CSS .minimized { bottom: 8px } takes effect
198
+ this.element.style.top = '';
199
+ this.element.style.right = '';
200
+ this.element.style.bottom = '';
201
+ this.element.classList.add('minimized');
202
+ this.isMinimized = true;
203
+
204
+ if (this.options.onMinimize) {
205
+ this.options.onMinimize();
206
+ }
207
+ } else {
208
+ // Restore original position
209
+ this.element.classList.remove('minimized');
210
+ if (this.savedPosition) {
211
+ this.element.style.top = this.savedPosition.top;
212
+ this.element.style.left = this.savedPosition.left;
213
+ this.element.style.right = this.savedPosition.right;
214
+ this.element.style.bottom = this.savedPosition.bottom || '';
215
+ }
216
+ this.isMinimized = false;
217
+
218
+ if (this.options.onRestore) {
219
+ this.options.onRestore();
220
+ }
221
+ }
222
+
223
+ // Rearrange all minimized panels
224
+ FloatingPanel.arrangeMinimizedPanels();
225
+ }
226
+
227
+ /**
228
+ * Save position to localStorage
229
+ */
230
+ savePosition() {
231
+ if (!this.options.storageKey || !this.element) return;
232
+
233
+ const rect = this.element.getBoundingClientRect();
234
+ const data = {
235
+ x: Math.round(rect.left),
236
+ y: Math.round(rect.top)
237
+ };
238
+
239
+ // If resizable, also save dimensions
240
+ if (this.options.resizable) {
241
+ const target = this.options.resizeTargetSelector
242
+ ? this.element.querySelector(this.options.resizeTargetSelector)
243
+ : this.element;
244
+ if (target) {
245
+ data.width = target.offsetWidth;
246
+ data.height = target.offsetHeight;
247
+ }
248
+ }
249
+
250
+ try {
251
+ localStorage.setItem(this.options.storageKey, JSON.stringify(data));
252
+ } catch (e) {
253
+ console.warn('FloatingPanel: Failed to save position to localStorage', e);
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Restore position from localStorage
259
+ */
260
+ restorePosition() {
261
+ if (!this.options.storageKey || !this.element) return;
262
+
263
+ try {
264
+ const stored = localStorage.getItem(this.options.storageKey);
265
+ if (!stored) return;
266
+
267
+ const data = JSON.parse(stored);
268
+
269
+ if (data.x !== undefined && data.y !== undefined) {
270
+ this.element.style.left = data.x + 'px';
271
+ this.element.style.top = data.y + 'px';
272
+ this.element.style.right = 'auto';
273
+ this.element.style.bottom = 'auto';
274
+ }
275
+
276
+ // Restore dimensions if resizable
277
+ if (this.options.resizable && data.width && data.height) {
278
+ const target = this.options.resizeTargetSelector
279
+ ? this.element.querySelector(this.options.resizeTargetSelector)
280
+ : this.element;
281
+ if (target) {
282
+ target.style.width = data.width + 'px';
283
+ target.style.height = data.height + 'px';
284
+ }
285
+ }
286
+ } catch (e) {
287
+ console.warn('FloatingPanel: Failed to restore position from localStorage', e);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Get current position
293
+ * @returns {Object} Position with x, y, width, height
294
+ */
295
+ getPosition() {
296
+ if (!this.element) return null;
297
+
298
+ const rect = this.element.getBoundingClientRect();
299
+ return {
300
+ x: Math.round(rect.left),
301
+ y: Math.round(rect.top),
302
+ width: Math.round(rect.width),
303
+ height: Math.round(rect.height)
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Set position programmatically
309
+ * @param {number} x - Left position
310
+ * @param {number} y - Top position
311
+ */
312
+ setPosition(x, y) {
313
+ if (!this.element) return;
314
+
315
+ this.element.style.left = x + 'px';
316
+ this.element.style.top = y + 'px';
317
+ this.element.style.right = 'auto';
318
+ this.element.style.bottom = 'auto';
319
+ }
320
+
321
+ /**
322
+ * Destroy the panel instance and remove event listeners
323
+ */
324
+ destroy() {
325
+ // Cleanup drag/resize handlers via drag-utils cleanup functions
326
+ if (this._cleanupDrag) {
327
+ this._cleanupDrag();
328
+ this._cleanupDrag = null;
329
+ }
330
+ if (this._cleanupResize) {
331
+ this._cleanupResize();
332
+ this._cleanupResize = null;
333
+ }
334
+
335
+ // Remove from static panel list
336
+ const index = FloatingPanel.allPanels.indexOf(this);
337
+ if (index > -1) {
338
+ FloatingPanel.allPanels.splice(index, 1);
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Arrange all minimized panels left-to-right at bottom of screen
344
+ * @static
345
+ */
346
+ static arrangeMinimizedPanels() {
347
+ requestAnimationFrame(() => {
348
+ const minimized = document.querySelectorAll('.floating-panel.minimized');
349
+ let leftOffset = 8;
350
+ const gap = 8;
351
+
352
+ minimized.forEach(panel => {
353
+ panel.style.left = leftOffset + 'px';
354
+ leftOffset += panel.offsetWidth + gap;
355
+ });
356
+ });
357
+ }
358
+
359
+ /**
360
+ * Get a panel instance by element ID
361
+ * @static
362
+ * @param {string} elementId - The panel element ID
363
+ * @returns {FloatingPanel|null}
364
+ */
365
+ static getById(elementId) {
366
+ return FloatingPanel.allPanels.find(p => p.elementId === elementId) || null;
367
+ }
368
+ }
369
+
370
+ // Export for use as module or global
371
+ if (typeof module !== 'undefined' && module.exports) {
372
+ module.exports = FloatingPanel;
373
+ } else {
374
+ window.FloatingPanel = FloatingPanel;
375
+ }
hello_world/static/js/features/assistant.js ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // assistant.js - Listener management (start/stop/status)
2
+ // Controls the headless audio listener via /api/listener endpoints
3
+ // Status updates come via /ws/transcribe (no polling)
4
+
5
+ (function() {
6
+ 'use strict';
7
+
8
+ let isRunning = false;
9
+
10
+ function init() {
11
+ const toggleBtn = document.getElementById('listenerToggle');
12
+ if (!toggleBtn) return;
13
+
14
+ toggleBtn.addEventListener('click', async () => {
15
+ if (isRunning) {
16
+ await stopListener();
17
+ } else {
18
+ await startListener();
19
+ }
20
+ });
21
+
22
+ // Audio routing selects
23
+ const audioInputSel = document.getElementById('audioInputSelect');
24
+ const audioOutputSel = document.getElementById('audioOutputSelect');
25
+
26
+ if (audioInputSel) {
27
+ audioInputSel.addEventListener('change', async () => {
28
+ await ReachySettings.save('audio_input', audioInputSel.value);
29
+ if (window.ReachyToast) ReachyToast.success('Audio Input', audioInputSel.value);
30
+ });
31
+ }
32
+ if (audioOutputSel) {
33
+ audioOutputSel.addEventListener('change', async () => {
34
+ await ReachySettings.save('audio_output', audioOutputSel.value);
35
+ if (window.ReachyToast) ReachyToast.success('Audio Output', audioOutputSel.value);
36
+ });
37
+ }
38
+
39
+ // Load current settings (one-time, no polling)
40
+ loadSettings();
41
+ }
42
+
43
+ async function loadSettings() {
44
+ try {
45
+ const settings = await ReachySettings.load();
46
+
47
+ const audioInputSel = document.getElementById('audioInputSelect');
48
+ const audioOutputSel = document.getElementById('audioOutputSelect');
49
+ if (audioInputSel) audioInputSel.value = settings.audio_input || 'robot';
50
+ if (audioOutputSel) audioOutputSel.value = settings.audio_output || 'robot';
51
+ } catch (e) {
52
+ console.error('Failed to load settings:', e);
53
+ }
54
+ }
55
+
56
+ async function startListener() {
57
+ try {
58
+ const resp = await fetch('/api/listener/start', { method: 'POST' });
59
+ const data = await resp.json();
60
+ if (data.status === 'started' || data.status === 'already_running') {
61
+ updateUI(true);
62
+ if (window.ReachyToast) ReachyToast.success('Listener', 'Started');
63
+ } else {
64
+ if (window.ReachyToast) ReachyToast.error('Listener', data.error || 'Failed to start');
65
+ }
66
+ } catch (e) {
67
+ if (window.ReachyToast) ReachyToast.error('Listener', e.message);
68
+ }
69
+ }
70
+
71
+ async function stopListener() {
72
+ try {
73
+ const resp = await fetch('/api/listener/stop', { method: 'POST' });
74
+ const data = await resp.json();
75
+ updateUI(false);
76
+ if (window.ReachyToast) ReachyToast.success('Listener', 'Stopped');
77
+ } catch (e) {
78
+ if (window.ReachyToast) ReachyToast.error('Listener', e.message);
79
+ }
80
+ }
81
+
82
+ function updateUI(running) {
83
+ isRunning = running;
84
+ const btn = document.getElementById('listenerToggle');
85
+ const dot = document.getElementById('listenerDot');
86
+ const label = document.getElementById('listenerLabel');
87
+
88
+ if (btn) {
89
+ btn.className = running ? 'mic-btn active' : 'mic-btn';
90
+ btn.title = running ? 'Stop listening' : 'Start listening';
91
+ }
92
+ if (dot) {
93
+ dot.className = running ? 'status-dot online' : 'status-dot offline';
94
+ }
95
+ if (label) {
96
+ label.textContent = running ? 'Listening' : 'Offline';
97
+ }
98
+ }
99
+
100
+ window.ReachyAssistant = {
101
+ init,
102
+ updateUI,
103
+ isRunning() { return isRunning; }
104
+ };
105
+ })();
hello_world/static/js/features/llm-settings.js ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // llm-settings.js - API key entry + provider/model configuration UI
2
+ // Manages the provider settings panel in the Conversation tab
3
+
4
+ (function() {
5
+ 'use strict';
6
+
7
+ let knownProviders = {};
8
+ let currentSettings = {};
9
+
10
+ async function init() {
11
+ await loadKnownProviders();
12
+ await loadCurrentSettings();
13
+ renderAPIKeys();
14
+ await refreshProviderDropdowns();
15
+ wireEvents();
16
+ }
17
+
18
+ async function loadKnownProviders() {
19
+ try {
20
+ const resp = await fetch('/api/conversation/known-providers');
21
+ knownProviders = await resp.json();
22
+ } catch (e) {
23
+ console.error('Failed to load providers:', e);
24
+ }
25
+ }
26
+
27
+ async function loadCurrentSettings() {
28
+ try {
29
+ currentSettings = await ReachySettings.load();
30
+ } catch (e) {
31
+ console.error('Failed to load settings:', e);
32
+ }
33
+ }
34
+
35
+ function renderAPIKeys() {
36
+ const container = document.getElementById('apiKeysContainer');
37
+ if (!container) return;
38
+
39
+ const apiKeys = currentSettings.api_keys || {};
40
+ const providers = Object.keys(knownProviders).sort();
41
+
42
+ container.innerHTML = providers.map(p => {
43
+ const hasKey = apiKeys[p] && apiKeys[p].length > 0;
44
+ const masked = hasKey ? '\u2022\u2022\u2022\u2022' + apiKeys[p].slice(-4) : '';
45
+ const caps = [];
46
+ if (knownProviders[p].llm) caps.push('LLM');
47
+ if (knownProviders[p].stt) caps.push('STT');
48
+ if (knownProviders[p].tts) caps.push('TTS');
49
+
50
+ return `
51
+ <div class="api-key-row">
52
+ <div class="api-key-label">
53
+ <span class="api-key-name">${p}</span>
54
+ <span class="api-key-caps">${caps.join(' ')}</span>
55
+ </div>
56
+ <div class="api-key-input-group">
57
+ <input type="password" id="apiKey_${p}"
58
+ class="api-key-input"
59
+ placeholder="${hasKey ? masked : 'Not configured'}"
60
+ autocomplete="off">
61
+ <button class="api-key-save-btn" data-provider="${p}"
62
+ title="Save key">\u2714</button>
63
+ </div>
64
+ </div>
65
+ `;
66
+ }).join('');
67
+
68
+ // Wire save buttons
69
+ container.querySelectorAll('.api-key-save-btn').forEach(btn => {
70
+ btn.addEventListener('click', async () => {
71
+ const provider = btn.dataset.provider;
72
+ const input = document.getElementById(`apiKey_${provider}`);
73
+ if (!input) return;
74
+
75
+ const key = input.value.trim();
76
+ if (!key) return;
77
+
78
+ // Update api_keys in settings
79
+ const keys = { ...(currentSettings.api_keys || {}), [provider]: key };
80
+ await saveSetting('api_keys', keys);
81
+ currentSettings.api_keys = keys;
82
+ input.value = '';
83
+ input.placeholder = '\u2022\u2022\u2022\u2022' + key.slice(-4);
84
+
85
+ // Refresh dropdowns
86
+ await refreshProviderDropdowns();
87
+
88
+ if (window.ReachyToast) ReachyToast.success('API Key', `${provider} key saved`);
89
+ });
90
+ });
91
+ }
92
+
93
+ async function refreshProviderDropdowns() {
94
+ try {
95
+ const resp = await fetch('/api/conversation/providers');
96
+ const available = await resp.json();
97
+
98
+ populateSelect('sttProviderSelect', available.stt, currentSettings.stt_provider);
99
+ populateSelect('llmProviderSelect', available.llm, currentSettings.llm_provider);
100
+ populateSelect('vlmProviderSelect', available.vlm, currentSettings.vlm_provider);
101
+ populateSelect('ttsProviderSelect', available.tts, currentSettings.tts_provider);
102
+
103
+ // Load models for selected providers
104
+ if (currentSettings.stt_provider) await loadModels('stt');
105
+ if (currentSettings.llm_provider) await loadModels('llm');
106
+ if (currentSettings.vlm_provider) await loadModels('vlm');
107
+ if (currentSettings.tts_provider) {
108
+ await loadModels('tts');
109
+ await loadVoices();
110
+ }
111
+ } catch (e) {
112
+ console.error('Failed to refresh providers:', e);
113
+ }
114
+ }
115
+
116
+ function populateSelect(id, options, selected) {
117
+ const sel = document.getElementById(id);
118
+ if (!sel) return;
119
+
120
+ const current = sel.value || selected || '';
121
+ sel.innerHTML = '<option value="">-- Select --</option>' +
122
+ (options || []).map(p => `<option value="${p}"${p === current ? ' selected' : ''}>${p}</option>`).join('');
123
+
124
+ if (current && sel.value !== current) {
125
+ sel.value = current;
126
+ }
127
+ }
128
+
129
+ async function loadModels(capability) {
130
+ const providerSelId = `${capability}ProviderSelect`;
131
+ const modelSelId = `${capability}ModelSelect`;
132
+ const sel = document.getElementById(providerSelId);
133
+ const modelSel = document.getElementById(modelSelId);
134
+ if (!sel || !modelSel) return;
135
+
136
+ const provider = sel.value;
137
+ if (!provider) {
138
+ modelSel.innerHTML = '<option value="">--</option>';
139
+ return;
140
+ }
141
+
142
+ try {
143
+ // For LLM models, apply web search filter if enabled
144
+ let url = `/api/conversation/models?provider=${provider}&capability=${capability}`;
145
+ if (capability === 'llm') {
146
+ const wsFilter = document.getElementById('llmWebSearchFilter');
147
+ if (wsFilter && wsFilter.checked) {
148
+ url += '&web_search=true';
149
+ }
150
+ }
151
+
152
+ const resp = await fetch(url);
153
+ const data = await resp.json();
154
+
155
+ const settingKey = `${capability}_model`;
156
+ const currentModel = currentSettings[settingKey] || '';
157
+
158
+ modelSel.innerHTML = (data.models || []).map(m =>
159
+ `<option value="${m}"${m === currentModel ? ' selected' : ''}>${m}</option>`
160
+ ).join('') || '<option value="">No models</option>';
161
+
162
+ } catch (e) {
163
+ console.error(`Failed to load ${capability} models:`, e);
164
+ }
165
+ }
166
+
167
+ async function loadVoices() {
168
+ const provider = document.getElementById('ttsProviderSelect')?.value;
169
+ const voiceSel = document.getElementById('ttsVoiceSelect');
170
+ if (!provider || !voiceSel) return;
171
+
172
+ try {
173
+ const model = document.getElementById('ttsModelSelect')?.value || '';
174
+ const resp = await fetch(`/api/conversation/voices?provider=${provider}&model=${model}`);
175
+ const data = await resp.json();
176
+ const voices = data.voices || [];
177
+ const currentVoice = currentSettings.tts_voice || '';
178
+
179
+ if (voices.length === 0) {
180
+ voiceSel.innerHTML = '<option value="">Default</option>';
181
+ } else {
182
+ // Voices are {id, name} objects — save id, display name
183
+ voiceSel.innerHTML = voices.map(v => {
184
+ const id = v.id || v;
185
+ const name = v.name || v;
186
+ return `<option value="${id}"${id === currentVoice ? ' selected' : ''}>${name}</option>`;
187
+ }).join('');
188
+ // If current voice not in list, select first
189
+ const ids = voices.map(v => v.id || v);
190
+ if (!ids.includes(currentVoice)) {
191
+ voiceSel.selectedIndex = 0;
192
+ }
193
+ }
194
+ } catch (e) {
195
+ console.error('Failed to load voices:', e);
196
+ }
197
+ }
198
+
199
+ async function wireEvents() {
200
+ // Provider changes -> load models, auto-select first, save
201
+ ['stt', 'llm', 'vlm', 'tts'].forEach(cap => {
202
+ const sel = document.getElementById(`${cap}ProviderSelect`);
203
+ if (sel) {
204
+ sel.addEventListener('change', async () => {
205
+ const provider = sel.value;
206
+ await saveSetting(`${cap}_provider`, provider);
207
+ currentSettings[`${cap}_provider`] = provider;
208
+
209
+ // Clear stale model setting before loading new models
210
+ currentSettings[`${cap}_model`] = '';
211
+
212
+ await loadModels(cap);
213
+
214
+ // Auto-select first model and save
215
+ const modelSel = document.getElementById(`${cap}ModelSelect`);
216
+ if (modelSel && modelSel.options.length > 0) {
217
+ modelSel.selectedIndex = 0;
218
+ const firstModel = modelSel.value;
219
+ await saveSetting(`${cap}_model`, firstModel);
220
+ currentSettings[`${cap}_model`] = firstModel;
221
+ }
222
+
223
+ // Cascade to voices for TTS
224
+ if (cap === 'tts') {
225
+ currentSettings.tts_voice = '';
226
+ await loadVoices();
227
+ const voiceSel = document.getElementById('ttsVoiceSelect');
228
+ if (voiceSel && voiceSel.options.length > 0) {
229
+ voiceSel.selectedIndex = 0;
230
+ await saveSetting('tts_voice', voiceSel.value);
231
+ currentSettings.tts_voice = voiceSel.value;
232
+ }
233
+ }
234
+
235
+ if (window.ReachyToast) {
236
+ const label = cap.toUpperCase();
237
+ ReachyToast.success(label, provider ? `Provider: ${provider}` : 'Cleared');
238
+ }
239
+ });
240
+ }
241
+
242
+ const modelSel = document.getElementById(`${cap}ModelSelect`);
243
+ if (modelSel) {
244
+ modelSel.addEventListener('change', async () => {
245
+ await saveSetting(`${cap}_model`, modelSel.value);
246
+ currentSettings[`${cap}_model`] = modelSel.value;
247
+ // Reload voices when TTS model changes
248
+ if (cap === 'tts') {
249
+ currentSettings.tts_voice = '';
250
+ await loadVoices();
251
+ const voiceSel = document.getElementById('ttsVoiceSelect');
252
+ if (voiceSel && voiceSel.options.length > 0) {
253
+ voiceSel.selectedIndex = 0;
254
+ await saveSetting('tts_voice', voiceSel.value);
255
+ currentSettings.tts_voice = voiceSel.value;
256
+ }
257
+ }
258
+ if (window.ReachyToast) ReachyToast.success(cap.toUpperCase(), `Model: ${modelSel.value}`);
259
+ });
260
+ }
261
+ });
262
+
263
+ // Voice select
264
+ const voiceSel = document.getElementById('ttsVoiceSelect');
265
+ if (voiceSel) {
266
+ voiceSel.addEventListener('change', async () => {
267
+ await saveSetting('tts_voice', voiceSel.value);
268
+ currentSettings.tts_voice = voiceSel.value;
269
+ if (window.ReachyToast) ReachyToast.success('TTS', `Voice: ${voiceSel.options[voiceSel.selectedIndex]?.text || voiceSel.value}`);
270
+ });
271
+ }
272
+
273
+ // Threshold sliders
274
+ ['conf', 'vol'].forEach(type => {
275
+ const slider = document.getElementById(`${type}Slider`);
276
+ const label = document.getElementById(`${type}SliderValue`);
277
+ if (slider && label) {
278
+ slider.value = currentSettings[`${type}_threshold`] || 0;
279
+ label.textContent = slider.value + '%';
280
+ slider.addEventListener('input', () => {
281
+ label.textContent = slider.value + '%';
282
+ });
283
+ slider.addEventListener('change', async () => {
284
+ await saveSetting(`${type}_threshold`, parseInt(slider.value));
285
+ const name = type === 'conf' ? 'Confidence' : 'Volume';
286
+ if (window.ReachyToast) ReachyToast.success('Threshold', `${name}: ${slider.value}%`);
287
+ });
288
+ }
289
+ });
290
+
291
+ // Mic gain
292
+ const gainSlider = document.getElementById('micGainSlider');
293
+ const gainLabel = document.getElementById('micGainValue');
294
+ if (gainSlider && gainLabel) {
295
+ gainSlider.value = currentSettings.mic_gain || 5.0;
296
+ gainLabel.textContent = parseFloat(gainSlider.value).toFixed(1) + 'x';
297
+ gainSlider.addEventListener('input', () => {
298
+ gainLabel.textContent = parseFloat(gainSlider.value).toFixed(1) + 'x';
299
+ });
300
+ gainSlider.addEventListener('change', async () => {
301
+ await saveSetting('mic_gain', parseFloat(gainSlider.value));
302
+ if (window.ReachyToast) ReachyToast.success('Mic Gain', `${parseFloat(gainSlider.value).toFixed(1)}x`);
303
+ });
304
+ }
305
+
306
+ // Web search model filter
307
+ const wsFilter = document.getElementById('llmWebSearchFilter');
308
+ const wsLabel = document.getElementById('llmWebSearchLabel');
309
+ if (wsFilter && wsLabel) {
310
+ wsFilter.addEventListener('change', async () => {
311
+ wsLabel.textContent = wsFilter.checked ? 'Web search models' : 'Show all';
312
+ // Reload LLM models with filter applied
313
+ await loadModels('llm');
314
+ // Auto-select first model
315
+ const modelSel = document.getElementById('llmModelSelect');
316
+ if (modelSel && modelSel.options.length > 0) {
317
+ modelSel.selectedIndex = 0;
318
+ await saveSetting('llm_model', modelSel.value);
319
+ currentSettings.llm_model = modelSel.value;
320
+ }
321
+ if (window.ReachyToast) {
322
+ ReachyToast.success('LLM', wsFilter.checked ? 'Web search models only' : 'All models');
323
+ }
324
+ });
325
+ }
326
+
327
+ // System prompt editor
328
+ const promptEditor = document.getElementById('systemPromptEditor');
329
+ const savePromptBtn = document.getElementById('savePromptBtn');
330
+ const resetPromptBtn = document.getElementById('resetPromptBtn');
331
+
332
+ if (promptEditor) {
333
+ // Load current: custom prompt if set, otherwise fetch default
334
+ const customPrompt = currentSettings.system_prompt || '';
335
+ if (customPrompt) {
336
+ promptEditor.value = customPrompt;
337
+ } else {
338
+ // Fetch the built-in default from the server
339
+ try {
340
+ const resp = await fetch('/api/conversation/default-prompt');
341
+ const data = await resp.json();
342
+ promptEditor.value = data.prompt || '';
343
+ promptEditor.placeholder = 'Edit the system prompt...';
344
+ } catch (e) {
345
+ promptEditor.placeholder = 'Could not load default prompt';
346
+ }
347
+ }
348
+ }
349
+
350
+ if (savePromptBtn && promptEditor) {
351
+ savePromptBtn.addEventListener('click', async () => {
352
+ await saveSetting('system_prompt', promptEditor.value);
353
+ if (window.ReachyToast) ReachyToast.success('Prompt', 'System prompt saved');
354
+ });
355
+ }
356
+
357
+ if (resetPromptBtn && promptEditor) {
358
+ resetPromptBtn.addEventListener('click', async () => {
359
+ try {
360
+ const resp = await fetch('/api/conversation/default-prompt');
361
+ const data = await resp.json();
362
+ promptEditor.value = data.prompt || '';
363
+ await saveSetting('system_prompt', '');
364
+ if (window.ReachyToast) ReachyToast.success('Prompt', 'Reset to default');
365
+ } catch (e) {
366
+ if (window.ReachyToast) ReachyToast.error('Prompt', 'Failed to reset');
367
+ }
368
+ });
369
+ }
370
+ }
371
+
372
+ async function saveSetting(key, value) {
373
+ const ok = await ReachySettings.save(key, value);
374
+ if (!ok && window.ReachyToast) {
375
+ ReachyToast.error('Settings', `Failed to save ${key}`);
376
+ }
377
+ }
378
+
379
+ window.ReachyLLMSettings = { init };
380
+ })();
hello_world/static/js/features/status-manager.js ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // status-manager.js - Centralized status tracking for pills, badges, and toasts
2
+
3
+ (function() {
4
+ 'use strict';
5
+
6
+ const INDICATOR_TO_PILL = {
7
+ 'connectionIndicator': 'pillApp',
8
+ 'daemonIndicator': 'pillAPI',
9
+ 'serialIndicator': 'pillSerial',
10
+ 'zenohIndicator': 'pillZenoh',
11
+ 'throttleIndicator': 'pillThrottle',
12
+ };
13
+
14
+ const SERVICE_NAMES = {
15
+ 'pillApp': 'App WebSocket',
16
+ 'pillAPI': 'Daemon API',
17
+ 'pillSerial': 'Serial Connection',
18
+ 'pillZenoh': 'Zenoh',
19
+ 'pillThrottle': 'Thermal Throttle',
20
+ };
21
+
22
+ const currentStates = {};
23
+ let initialized = false;
24
+
25
+ let _suppressToasts = true;
26
+ setTimeout(() => { _suppressToasts = false; }, 5000);
27
+
28
+ function init() {
29
+ if (initialized) return;
30
+ initialized = true;
31
+
32
+ const badge = document.getElementById('issuesBadge');
33
+ if (badge) {
34
+ badge.addEventListener('click', () => {
35
+ const statusTab = document.querySelector('[data-tab="status"]');
36
+ if (statusTab) statusTab.click();
37
+ });
38
+ }
39
+
40
+ Object.values(INDICATOR_TO_PILL).forEach(pillId => {
41
+ currentStates[pillId] = 'connecting';
42
+ });
43
+
44
+ window.ReachyStatus = {
45
+ updatePill,
46
+ updateFromIndicator,
47
+ getIssueCount,
48
+ refresh: updateIssueBadge,
49
+ enableToasts: () => { _suppressToasts = false; }
50
+ };
51
+ }
52
+
53
+ function updatePill(pillId, status, silent = false) {
54
+ const pill = document.getElementById(pillId);
55
+ if (!pill) return;
56
+
57
+ const prevStatus = currentStates[pillId];
58
+ currentStates[pillId] = status;
59
+
60
+ pill.classList.remove('connecting', 'active', 'error');
61
+ if (status === 'connecting' || status === 'active' || status === 'error') {
62
+ pill.classList.add(status);
63
+ }
64
+
65
+ if (!silent && !_suppressToasts && prevStatus !== status) {
66
+ const name = SERVICE_NAMES[pillId] || pillId;
67
+ if (status === 'error' && prevStatus !== 'error' && prevStatus !== 'connecting') {
68
+ if (window.ReachyToast) ReachyToast.error(`${name} disconnected`, 'Click badge to view status');
69
+ } else if (status === 'active' && prevStatus === 'error') {
70
+ if (window.ReachyToast) ReachyToast.success(`${name} connected`);
71
+ }
72
+ }
73
+
74
+ updateIssueBadge();
75
+ }
76
+
77
+ function updateFromIndicator(indicatorId, status) {
78
+ const pillId = INDICATOR_TO_PILL[indicatorId];
79
+ if (pillId) updatePill(pillId, status);
80
+ }
81
+
82
+ function getIssueCount() {
83
+ return Object.values(currentStates).filter(s => s === 'error').length;
84
+ }
85
+
86
+ function updateIssueBadge() {
87
+ const badge = document.getElementById('issuesBadge');
88
+ if (!badge) return;
89
+
90
+ const count = getIssueCount();
91
+ const countEl = badge.querySelector('.issues-count');
92
+
93
+ if (count > 0) {
94
+ badge.classList.remove('hidden');
95
+ if (countEl) countEl.textContent = count === 1 ? '1 issue' : `${count} issues`;
96
+ } else {
97
+ badge.classList.add('hidden');
98
+ }
99
+ }
100
+
101
+ if (document.readyState === 'loading') {
102
+ document.addEventListener('DOMContentLoaded', init);
103
+ } else {
104
+ init();
105
+ }
106
+ })();
hello_world/static/js/features/transcribe.js ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // transcribe.js - Conversation transcript UI + WebSocket
2
+ // Connects to /ws/transcribe and renders live transcript table
3
+
4
+ (function() {
5
+ 'use strict';
6
+
7
+ let ws = null;
8
+ let connected = false;
9
+ let autoScroll = true;
10
+
11
+ function init() {
12
+ const container = document.getElementById('transcriptContainer');
13
+ if (!container) return;
14
+
15
+ // Auto-scroll detection
16
+ container.addEventListener('scroll', () => {
17
+ const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
18
+ autoScroll = atBottom;
19
+ });
20
+
21
+ connect();
22
+ }
23
+
24
+ function connect() {
25
+ if (ws && ws.readyState <= 1) return; // Already connecting/open
26
+
27
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
28
+ ws = new WebSocket(`${proto}//${location.host}/ws/transcribe`);
29
+
30
+ ws.onopen = () => {
31
+ connected = true;
32
+ console.log('Transcribe WS connected');
33
+ };
34
+
35
+ ws.onmessage = (e) => {
36
+ try {
37
+ const data = JSON.parse(e.data);
38
+ handleMessage(data);
39
+ } catch (err) {
40
+ console.error('Transcribe parse error:', err);
41
+ }
42
+ };
43
+
44
+ ws.onclose = () => {
45
+ connected = false;
46
+ // Reconnect after 3s
47
+ setTimeout(connect, 3000);
48
+ };
49
+
50
+ ws.onerror = () => {
51
+ connected = false;
52
+ };
53
+ }
54
+
55
+ function handleMessage(data) {
56
+ const type = data.type;
57
+
58
+ if (type === 'transcription') {
59
+ addTranscriptionRow(data);
60
+ } else if (type === 'assistant_response') {
61
+ addResponseRow(data);
62
+ } else if (type === 'tool') {
63
+ addToolRow(data);
64
+ } else if (type === 'error') {
65
+ addErrorRow(data);
66
+ } else if (type === 'waveform') {
67
+ drawWaveform(data);
68
+ } else if (type === 'speaking_status') {
69
+ updateSpeakingStatus(data);
70
+ } else if (type === 'listener_status') {
71
+ // Route through ReachyAssistant (single source of truth for listener UI)
72
+ if (window.ReachyAssistant) ReachyAssistant.updateUI(data.running);
73
+ } else if (type === 'settings_changed') {
74
+ // Invalidate settings cache when another client changes settings
75
+ if (window.ReachySettings) ReachySettings.clearCache();
76
+ } else if (type === 'tts_audio') {
77
+ playTTSAudio(data);
78
+ } else if (data.is_claude || data.speaker) {
79
+ // Legacy format from transcript API
80
+ addResponseRow({
81
+ text: data.text,
82
+ source: data.speaker || 'LLM',
83
+ ignored: false
84
+ });
85
+ }
86
+ }
87
+
88
+ // ── Transcript rows ──────────────────────────────────────
89
+
90
+ function timeStr() {
91
+ const d = new Date();
92
+ return d.toLocaleTimeString('en-GB', { hour12: false });
93
+ }
94
+
95
+ function confColor(c) {
96
+ if (c >= 70) return 'var(--success)';
97
+ if (c >= 40) return 'var(--warning)';
98
+ return 'var(--error)';
99
+ }
100
+
101
+ function volColor(v) {
102
+ if (v >= 50) return 'var(--success)';
103
+ if (v >= 20) return 'var(--warning)';
104
+ return 'var(--error)';
105
+ }
106
+
107
+ function addTranscriptionRow(data) {
108
+ const tbody = document.getElementById('transcriptBody');
109
+ if (!tbody) return;
110
+
111
+ const filtered = data.below_threshold;
112
+ const tr = document.createElement('tr');
113
+ tr.className = `transcript-row user-row${filtered ? ' below-threshold' : ''}`;
114
+ tr.innerHTML = `
115
+ <td class="ts-time">${timeStr()}</td>
116
+ <td class="ts-conf" style="color:${confColor(data.confidence)}">${data.confidence}%</td>
117
+ <td class="ts-vol" style="color:${volColor(data.audio_level)}">${data.audio_level}%</td>
118
+ <td class="ts-msg"><span class="ts-speaker">You:</span> ${escapeHtml(data.text)}${filtered ? ' <span class="ts-filtered">(filtered)</span>' : ''}</td>
119
+ `;
120
+ tbody.appendChild(tr);
121
+
122
+ // Auto-remove filtered rows after 5s
123
+ if (filtered) {
124
+ setTimeout(() => {
125
+ tr.style.transition = 'opacity 0.5s';
126
+ tr.style.opacity = '0';
127
+ setTimeout(() => tr.remove(), 500);
128
+ }, 5000);
129
+ }
130
+
131
+ scrollToBottom();
132
+ }
133
+
134
+ function addResponseRow(data) {
135
+ const tbody = document.getElementById('transcriptBody');
136
+ if (!tbody) return;
137
+
138
+ const tr = document.createElement('tr');
139
+ const isIgnored = data.ignored;
140
+ tr.className = `transcript-row response-row${isIgnored ? ' ignored-row' : ''}`;
141
+
142
+ const source = data.source || 'LLM';
143
+ const text = data.text || (isIgnored ? '(ignored)' : '');
144
+
145
+ tr.innerHTML = `
146
+ <td class="ts-time">${timeStr()}</td>
147
+ <td class="ts-conf" colspan="2"><span class="ts-source">${escapeHtml(source)}</span></td>
148
+ <td class="ts-msg">${escapeHtml(text)}</td>
149
+ `;
150
+ tbody.appendChild(tr);
151
+
152
+ // Auto-remove ignored rows after 5s
153
+ if (isIgnored) {
154
+ setTimeout(() => {
155
+ tr.style.transition = 'opacity 0.5s';
156
+ tr.style.opacity = '0';
157
+ setTimeout(() => tr.remove(), 500);
158
+ }, 5000);
159
+ }
160
+
161
+ scrollToBottom();
162
+ }
163
+
164
+ // ── Friendly tool display ─────────────────────────────
165
+
166
+ const TOOL_DISPLAY = {
167
+ ignore: { icon: '\uD83D\uDE36', label: 'Ignoring' },
168
+ play_emotion: { icon: '\uD83C\uDFAD', label: 'Emotion' },
169
+ play_dance: { icon: '\uD83D\uDC83', label: 'Dance' },
170
+ set_head_pose: { icon: '\uD83D\uDDE3', label: 'Head move' },
171
+ take_snapshot: { icon: '\uD83D\uDCF8', label: 'Snapshot' },
172
+ get_system_status: { icon: '\uD83D\uDCCA', label: 'System check' },
173
+ get_date_time: { icon: '\uD83D\uDD70', label: 'Clock' },
174
+ start_recording: { icon: '\uD83C\uDFAC', label: 'Record video' },
175
+ stop_recording: { icon: '\u23F9', label: 'Stop video' },
176
+ start_sound_recording:{ icon: '\uD83C\uDF99', label: 'Record audio' },
177
+ stop_sound_recording: { icon: '\u23F9', label: 'Stop audio' },
178
+ play_music: { icon: '\uD83C\uDFB5', label: 'Play music' },
179
+ stop_music: { icon: '\u23F9', label: 'Stop music' },
180
+ list_music: { icon: '\uD83C\uDFB6', label: 'Music library' },
181
+ };
182
+
183
+ function formatToolArgs(tool, args) {
184
+ if (!args || Object.keys(args).length === 0) return '';
185
+ // Show the most meaningful value for known tools
186
+ if (tool === 'play_emotion' && args.emotion) return args.emotion;
187
+ if (tool === 'play_dance' && args.dance) return args.dance;
188
+ if (tool === 'ignore' && args.reason) return args.reason;
189
+ if (tool === 'set_head_pose') {
190
+ const parts = [];
191
+ if (args.yaw != null) parts.push(`yaw ${args.yaw}\u00B0`);
192
+ if (args.pitch != null) parts.push(`pitch ${args.pitch}\u00B0`);
193
+ if (args.roll != null) parts.push(`roll ${args.roll}\u00B0`);
194
+ return parts.join(', ');
195
+ }
196
+ if (tool === 'get_date_time' && args.format) return args.format;
197
+ // Fallback: show key=value pairs
198
+ return Object.entries(args).map(([k, v]) => `${k}: ${v}`).join(', ');
199
+ }
200
+
201
+ function formatToolResult(tool, result) {
202
+ if (!result) return '';
203
+ if (result.error) return `Error: ${result.error}`;
204
+
205
+ if (tool === 'get_system_status') {
206
+ const lines = [];
207
+ if (result.cpu_temp != null) lines.push(`CPU: ${result.cpu_temp}\u00B0C`);
208
+ if (result.cpu_cores) lines.push(`Cores: ${result.cpu_cores.map(c => c.toFixed(0) + '%').join(', ')}`);
209
+ if (result.memory_percent != null) lines.push(`RAM: ${result.memory_percent}% (${result.memory_used_mb || '?'}/${result.memory_total_mb || '?'} MB)`);
210
+ if (result.disk_local) lines.push(`Disk: ${result.disk_local.percent}%`);
211
+ if (result.wifi_ssid) lines.push(`WiFi: ${result.wifi_ssid} (${result.wifi_signal || '?'}%)`);
212
+ if (result.load_1m != null) lines.push(`Load: ${result.load_1m} / ${result.load_5m} / ${result.load_15m}`);
213
+ if (result.fan_rpm != null) lines.push(`Fan: ${result.fan_rpm} RPM`);
214
+ if (result.robot_uptime != null) {
215
+ const h = Math.floor(result.robot_uptime / 3600);
216
+ const m = Math.floor((result.robot_uptime % 3600) / 60);
217
+ lines.push(`Uptime: ${h}h ${m}m`);
218
+ }
219
+ return lines.join('\n');
220
+ }
221
+ if (tool === 'get_date_time' && result.date_time) return result.date_time;
222
+ if (tool === 'take_snapshot' && result.filename) return `Saved: ${result.filename}`;
223
+ if (tool === 'play_emotion' || tool === 'play_dance') {
224
+ if (result.success !== undefined) return result.success ? 'Playing' : 'Failed';
225
+ return JSON.stringify(result, null, 2);
226
+ }
227
+ if (tool === 'set_head_pose') return result.error ? result.error : 'Moved';
228
+ if (tool === 'ignore') return result.reason || 'Ignored';
229
+
230
+ return JSON.stringify(result, null, 2);
231
+ }
232
+
233
+ function addToolRow(data) {
234
+ const tbody = document.getElementById('transcriptBody');
235
+ if (!tbody) return;
236
+
237
+ const statusIcon = data.status === 'completed' ? '\u2705' :
238
+ data.status === 'error' ? '\u274C' : '\u23F3';
239
+
240
+ // Update existing row in-place if this is a status update
241
+ const toolId = `tool-${data.tool}-${JSON.stringify(data.args || {})}`;
242
+ const existing = tbody.querySelector(`tr[data-tool-id="${CSS.escape(toolId)}"]`);
243
+ if (existing) {
244
+ existing.querySelector('.tool-status').textContent = statusIcon;
245
+ // Store result when completed
246
+ if (data.result) {
247
+ const detail = existing.querySelector('.tool-detail');
248
+ if (detail) {
249
+ detail.textContent = formatToolResult(data.tool, data.result);
250
+ existing.classList.add('tool-expandable');
251
+ }
252
+ }
253
+ return;
254
+ }
255
+
256
+ const display = TOOL_DISPLAY[data.tool] || { icon: '\u2699', label: data.tool };
257
+ const argStr = formatToolArgs(data.tool, data.args);
258
+ const hasResult = !!data.result;
259
+ const resultStr = hasResult ? formatToolResult(data.tool, data.result) : '';
260
+
261
+ const tr = document.createElement('tr');
262
+ tr.className = `transcript-row tool-row${hasResult ? ' tool-expandable' : ''}`;
263
+ tr.dataset.toolId = toolId;
264
+ tr.innerHTML = `
265
+ <td class="ts-time">${timeStr()}</td>
266
+ <td class="ts-conf tool-status" colspan="2">${statusIcon}</td>
267
+ <td class="ts-msg">
268
+ <div class="tool-summary">
269
+ <span class="tool-icon">${display.icon}</span>
270
+ <span class="tool-label">${escapeHtml(display.label)}</span>${argStr ? `<span class="tool-arg">${escapeHtml(argStr)}</span>` : ''}
271
+ <span class="tool-chevron">\u25B6</span>
272
+ </div>
273
+ <pre class="tool-detail">${escapeHtml(resultStr)}</pre>
274
+ </td>
275
+ `;
276
+
277
+ // Toggle expand/collapse on click
278
+ tr.addEventListener('click', () => {
279
+ if (!tr.classList.contains('tool-expandable')) return;
280
+ tr.classList.toggle('tool-expanded');
281
+ });
282
+
283
+ tbody.appendChild(tr);
284
+ scrollToBottom();
285
+ }
286
+
287
+ function addErrorRow(data) {
288
+ const tbody = document.getElementById('transcriptBody');
289
+ if (!tbody) return;
290
+
291
+ const tr = document.createElement('tr');
292
+ tr.className = 'transcript-row error-row';
293
+ tr.innerHTML = `
294
+ <td class="ts-time">${timeStr()}</td>
295
+ <td class="ts-conf" colspan="2">\u26A0</td>
296
+ <td class="ts-msg ts-error">${escapeHtml(data.error || data.message || 'Unknown error')}</td>
297
+ `;
298
+ tbody.appendChild(tr);
299
+ scrollToBottom();
300
+ }
301
+
302
+ // ── Waveform visualization ───────────────────────────────
303
+
304
+ // Smooth previous samples for interpolation
305
+ let _prevSamples = null;
306
+
307
+ function drawWaveform(data) {
308
+ const canvas = document.getElementById('waveformCanvas');
309
+ if (!canvas) return;
310
+
311
+ const ctx = canvas.getContext('2d');
312
+ const w = canvas.width = canvas.offsetWidth;
313
+ const h = canvas.height;
314
+ const samples = data.samples || [];
315
+ const isMuted = data.muted;
316
+
317
+ ctx.clearRect(0, 0, w, h);
318
+
319
+ if (samples.length === 0) { _prevSamples = null; return; }
320
+
321
+ // Lerp towards new samples for smooth animation
322
+ if (!_prevSamples || _prevSamples.length !== samples.length) {
323
+ _prevSamples = samples.slice();
324
+ } else {
325
+ for (let i = 0; i < samples.length; i++) {
326
+ _prevSamples[i] += (samples[i] - _prevSamples[i]) * 0.4;
327
+ }
328
+ }
329
+
330
+ const gap = 2;
331
+ const barW = Math.max(2, (w / _prevSamples.length) - gap);
332
+ const mid = h / 2;
333
+ const radius = Math.min(barW / 2, 3);
334
+
335
+ if (isMuted) {
336
+ // Muted: dim grey bars, no effects
337
+ ctx.fillStyle = 'rgba(128, 128, 128, 0.3)';
338
+ for (let i = 0; i < _prevSamples.length; i++) {
339
+ const x = i * (barW + gap);
340
+ ctx.beginPath();
341
+ ctx.roundRect(x, mid - 1, barW, 2, radius);
342
+ ctx.fill();
343
+ }
344
+ return;
345
+ }
346
+
347
+ // Compute overall energy for glow intensity
348
+ const energy = _prevSamples.reduce((a, b) => a + b, 0) / _prevSamples.length;
349
+
350
+ // Glow layer (blurred underpainting)
351
+ if (energy > 0.05) {
352
+ ctx.save();
353
+ ctx.filter = `blur(${4 + energy * 8}px)`;
354
+ ctx.globalAlpha = 0.4 + energy * 0.3;
355
+ for (let i = 0; i < _prevSamples.length; i++) {
356
+ const val = Math.min(1, _prevSamples[i]);
357
+ const barH = Math.max(1, val * mid);
358
+ const x = i * (barW + gap);
359
+ const hue = 180 + (val * 140); // cyan(180) -> magenta(320)
360
+ ctx.fillStyle = `hsl(${hue}, 100%, 60%)`;
361
+ ctx.fillRect(x, mid - barH, barW, barH * 2);
362
+ }
363
+ ctx.restore();
364
+ }
365
+
366
+ // Main bars with per-bar gradient
367
+ for (let i = 0; i < _prevSamples.length; i++) {
368
+ const val = Math.min(1, _prevSamples[i]);
369
+ const barH = Math.max(1, val * mid);
370
+ const x = i * (barW + gap);
371
+ const y = mid - barH;
372
+
373
+ // Hue shifts from cyan (low amplitude) through blue to magenta/pink (high)
374
+ const hue = 180 + (val * 140);
375
+ const sat = 85 + val * 15;
376
+ const light = 55 + val * 15;
377
+
378
+ const grad = ctx.createLinearGradient(x, y, x, mid + barH);
379
+ grad.addColorStop(0, `hsl(${hue + 20}, ${sat}%, ${light + 10}%)`);
380
+ grad.addColorStop(0.5, `hsl(${hue}, ${sat}%, ${light}%)`);
381
+ grad.addColorStop(1, `hsl(${hue + 20}, ${sat}%, ${light + 10}%)`);
382
+
383
+ ctx.fillStyle = grad;
384
+ ctx.beginPath();
385
+ ctx.roundRect(x, y, barW, barH * 2, radius);
386
+ ctx.fill();
387
+ }
388
+ }
389
+
390
+ // ── Speaking status ──────────────────────────────────────
391
+
392
+ function updateSpeakingStatus(data) {
393
+ const indicator = document.getElementById('speakingIndicator');
394
+ if (indicator) {
395
+ indicator.style.display = data.speaking ? '' : 'none';
396
+ }
397
+ }
398
+
399
+ // Listener status UI is handled by ReachyAssistant.updateUI() — no duplicate here
400
+
401
+ // ── TTS audio playback (browser speaker output) ──────────
402
+
403
+ let audioCtx = null;
404
+
405
+ function playTTSAudio(data) {
406
+ if (!data.audio) return;
407
+
408
+ if (!audioCtx) {
409
+ audioCtx = new AudioContext();
410
+ }
411
+
412
+ // Decode base64 WAV
413
+ const binaryStr = atob(data.audio);
414
+ const bytes = new Uint8Array(binaryStr.length);
415
+ for (let i = 0; i < binaryStr.length; i++) {
416
+ bytes[i] = binaryStr.charCodeAt(i);
417
+ }
418
+
419
+ audioCtx.decodeAudioData(bytes.buffer, (buffer) => {
420
+ const source = audioCtx.createBufferSource();
421
+ source.buffer = buffer;
422
+ source.connect(audioCtx.destination);
423
+ source.start(0);
424
+ }).catch(err => console.error('TTS decode error:', err));
425
+ }
426
+
427
+ // ── Text input ───────────────────────────────────────────
428
+
429
+ function initTextInput() {
430
+ const input = document.getElementById('conversationInput');
431
+ const btn = document.getElementById('conversationSend');
432
+ if (!input || !btn) return;
433
+
434
+ async function send() {
435
+ const text = input.value.trim();
436
+ if (!text) return;
437
+ input.value = '';
438
+
439
+ // Add to transcript immediately
440
+ addTranscriptionRow({
441
+ text: text,
442
+ confidence: 100,
443
+ audio_level: 0,
444
+ source: 'typed'
445
+ });
446
+
447
+ try {
448
+ const resp = await fetch('/api/conversation/chat', {
449
+ method: 'POST',
450
+ headers: { 'Content-Type': 'application/json' },
451
+ body: JSON.stringify({
452
+ text: text,
453
+ session_id: 'browser',
454
+ speak: true,
455
+ confidence: 100,
456
+ volume: 100
457
+ })
458
+ });
459
+ // Response will come via WebSocket broadcast
460
+ } catch (e) {
461
+ addErrorRow({ error: e.message });
462
+ }
463
+ }
464
+
465
+ btn.addEventListener('click', send);
466
+ input.addEventListener('keydown', (e) => {
467
+ if (e.key === 'Enter') send();
468
+ });
469
+ }
470
+
471
+ // ── Transcript toolbar actions ─────────────────────────
472
+
473
+ function initToolbar() {
474
+ const clearBtn = document.getElementById('clearTranscriptBtn');
475
+ const exportBtn = document.getElementById('exportTranscriptBtn');
476
+ const resetBtn = document.getElementById('resetSessionBtn');
477
+
478
+ if (clearBtn) {
479
+ clearBtn.addEventListener('click', () => {
480
+ const tbody = document.getElementById('transcriptBody');
481
+ if (tbody) tbody.innerHTML = '';
482
+ });
483
+ }
484
+
485
+ if (exportBtn) {
486
+ exportBtn.addEventListener('click', () => {
487
+ const tbody = document.getElementById('transcriptBody');
488
+ if (!tbody) return;
489
+ const rows = tbody.querySelectorAll('tr');
490
+ const lines = [];
491
+ rows.forEach(tr => {
492
+ const time = tr.querySelector('.ts-time')?.textContent || '';
493
+ const msg = tr.querySelector('.ts-msg')?.textContent || '';
494
+ lines.push(`[${time}] ${msg.trim()}`);
495
+ });
496
+ const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
497
+ const a = document.createElement('a');
498
+ a.href = URL.createObjectURL(blob);
499
+ a.download = `transcript-${new Date().toISOString().slice(0,10)}.txt`;
500
+ a.click();
501
+ URL.revokeObjectURL(a.href);
502
+ });
503
+ }
504
+
505
+ if (resetBtn) {
506
+ resetBtn.addEventListener('click', async () => {
507
+ try {
508
+ await fetch('/api/conversation/reset', {
509
+ method: 'POST',
510
+ headers: { 'Content-Type': 'application/json' },
511
+ body: JSON.stringify({ session_id: 'browser' })
512
+ });
513
+ // Also clear the transcript display
514
+ const tbody = document.getElementById('transcriptBody');
515
+ if (tbody) tbody.innerHTML = '';
516
+ if (window.ReachyToast) ReachyToast.success('Session', 'Conversation reset');
517
+ } catch (e) {
518
+ if (window.ReachyToast) ReachyToast.error('Session', 'Reset failed');
519
+ }
520
+ });
521
+ }
522
+ }
523
+
524
+ // ── Helpers ──────────────────────────────────────────────
525
+
526
+ function scrollToBottom() {
527
+ if (!autoScroll) return;
528
+ const container = document.getElementById('transcriptContainer');
529
+ if (container) {
530
+ container.scrollTop = container.scrollHeight;
531
+ }
532
+ }
533
+
534
+ function escapeHtml(str) {
535
+ if (!str) return '';
536
+ return String(str)
537
+ .replace(/&/g, '&amp;')
538
+ .replace(/</g, '&lt;')
539
+ .replace(/>/g, '&gt;')
540
+ .replace(/"/g, '&quot;');
541
+ }
542
+
543
+ // ── Public API ──────────────────────────────────────────
544
+
545
+ window.ReachyTranscribe = {
546
+ init() {
547
+ init();
548
+ initTextInput();
549
+ initToolbar();
550
+ },
551
+ connect,
552
+ isConnected() { return connected; }
553
+ };
554
+ })();
hello_world/static/js/intercom-processor.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // AudioWorklet processor for intercom - resamples audio to 16kHz and sends via port
2
+ class IntercomProcessor extends AudioWorkletProcessor {
3
+ constructor(options) {
4
+ super();
5
+ this.targetSampleRate = 16000;
6
+ this.nativeSampleRate = options.processorOptions?.sampleRate || 48000;
7
+ this.resampleRatio = this.nativeSampleRate / this.targetSampleRate;
8
+ this.gain = options.processorOptions?.gain || 5.0;
9
+ this.buffer = [];
10
+ this.bufferSize = 4096; // Send chunks of this size
11
+ }
12
+
13
+ process(inputs, outputs, parameters) {
14
+ const input = inputs[0];
15
+ if (!input || !input[0]) return true;
16
+
17
+ const inputData = input[0];
18
+
19
+ // Downsample and convert to int16
20
+ for (let i = 0; i < inputData.length; i += this.resampleRatio) {
21
+ const srcIdx = Math.floor(i);
22
+ const adjusted = inputData[srcIdx] * this.gain;
23
+ const sample = Math.max(-32768, Math.min(32767, Math.round(adjusted * 32768)));
24
+ this.buffer.push(sample);
25
+ }
26
+
27
+ // Send buffer when full
28
+ if (this.buffer.length >= this.bufferSize) {
29
+ const chunk = this.buffer.splice(0, this.bufferSize);
30
+ const int16Array = new Int16Array(chunk);
31
+ this.port.postMessage({
32
+ type: 'audio',
33
+ data: int16Array.buffer
34
+ }, [int16Array.buffer]);
35
+ }
36
+
37
+ return true;
38
+ }
39
+ }
40
+
41
+ registerProcessor('intercom-processor', IntercomProcessor);
hello_world/static/js/kinematics-wasm/reachy_mini_kinematics_wasm.js ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let wasm;
2
+
3
+ function getArrayF64FromWasm0(ptr, len) {
4
+ ptr = ptr >>> 0;
5
+ return getFloat64ArrayMemory0().subarray(ptr / 8, ptr / 8 + len);
6
+ }
7
+
8
+ let cachedFloat64ArrayMemory0 = null;
9
+ function getFloat64ArrayMemory0() {
10
+ if (cachedFloat64ArrayMemory0 === null || cachedFloat64ArrayMemory0.byteLength === 0) {
11
+ cachedFloat64ArrayMemory0 = new Float64Array(wasm.memory.buffer);
12
+ }
13
+ return cachedFloat64ArrayMemory0;
14
+ }
15
+
16
+ function passArrayF64ToWasm0(arg, malloc) {
17
+ const ptr = malloc(arg.length * 8, 8) >>> 0;
18
+ getFloat64ArrayMemory0().set(arg, ptr / 8);
19
+ WASM_VECTOR_LEN = arg.length;
20
+ return ptr;
21
+ }
22
+
23
+ let WASM_VECTOR_LEN = 0;
24
+
25
+ /**
26
+ * Calculate passive joint angles from head joints and head pose
27
+ *
28
+ * # Arguments
29
+ * * `head_joints` - Array of 7 floats: [yaw_body, stewart_1, ..., stewart_6]
30
+ * * `head_pose` - 4x4 transformation matrix as 16 floats (row-major)
31
+ *
32
+ * # Returns
33
+ * Array of 21 floats: passive joint angles [p1_x, p1_y, p1_z, ..., p7_x, p7_y, p7_z]
34
+ * @param {Float64Array} head_joints
35
+ * @param {Float64Array} head_pose
36
+ * @returns {Float64Array}
37
+ */
38
+ export function calculate_passive_joints(head_joints, head_pose) {
39
+ const ptr0 = passArrayF64ToWasm0(head_joints, wasm.__wbindgen_malloc);
40
+ const len0 = WASM_VECTOR_LEN;
41
+ const ptr1 = passArrayF64ToWasm0(head_pose, wasm.__wbindgen_malloc);
42
+ const len1 = WASM_VECTOR_LEN;
43
+ const ret = wasm.calculate_passive_joints(ptr0, len0, ptr1, len1);
44
+ const v3 = getArrayF64FromWasm0(ret[0], ret[1]).slice();
45
+ wasm.__wbindgen_free(ret[0], ret[1] * 8, 8);
46
+ return v3;
47
+ }
48
+
49
+ /**
50
+ * Initialize the WASM module
51
+ */
52
+ export function init() {
53
+ wasm.init();
54
+ }
55
+
56
+ const EXPECTED_RESPONSE_TYPES = new Set(['basic', 'cors', 'default']);
57
+
58
+ async function __wbg_load(module, imports) {
59
+ if (typeof Response === 'function' && module instanceof Response) {
60
+ if (typeof WebAssembly.instantiateStreaming === 'function') {
61
+ try {
62
+ return await WebAssembly.instantiateStreaming(module, imports);
63
+ } catch (e) {
64
+ const validResponse = module.ok && EXPECTED_RESPONSE_TYPES.has(module.type);
65
+
66
+ if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
67
+ console.warn(
68
+ '`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n',
69
+ e
70
+ );
71
+ } else {
72
+ throw e;
73
+ }
74
+ }
75
+ }
76
+
77
+ const bytes = await module.arrayBuffer();
78
+ return await WebAssembly.instantiate(bytes, imports);
79
+ } else {
80
+ const instance = await WebAssembly.instantiate(module, imports);
81
+
82
+ if (instance instanceof WebAssembly.Instance) {
83
+ return { instance, module };
84
+ } else {
85
+ return instance;
86
+ }
87
+ }
88
+ }
89
+
90
+ function __wbg_get_imports() {
91
+ const imports = {};
92
+ imports.wbg = {};
93
+ imports.wbg.__wbindgen_init_externref_table = function () {
94
+ const table = wasm.__wbindgen_externrefs;
95
+ const offset = table.grow(4);
96
+ table.set(0, undefined);
97
+ table.set(offset + 0, undefined);
98
+ table.set(offset + 1, null);
99
+ table.set(offset + 2, true);
100
+ table.set(offset + 3, false);
101
+ };
102
+
103
+ return imports;
104
+ }
105
+
106
+ function __wbg_finalize_init(instance, module) {
107
+ wasm = instance.exports;
108
+ __wbg_init.__wbindgen_wasm_module = module;
109
+ cachedFloat64ArrayMemory0 = null;
110
+
111
+ wasm.__wbindgen_start();
112
+ return wasm;
113
+ }
114
+
115
+ function initSync(module) {
116
+ if (wasm !== undefined) return wasm;
117
+
118
+ if (typeof module !== 'undefined') {
119
+ if (Object.getPrototypeOf(module) === Object.prototype) {
120
+ ({ module } = module);
121
+ } else {
122
+ console.warn('using deprecated parameters for `initSync()`; pass a single object instead');
123
+ }
124
+ }
125
+
126
+ const imports = __wbg_get_imports();
127
+ if (!(module instanceof WebAssembly.Module)) {
128
+ module = new WebAssembly.Module(module);
129
+ }
130
+ const instance = new WebAssembly.Instance(module, imports);
131
+ return __wbg_finalize_init(instance, module);
132
+ }
133
+
134
+ async function __wbg_init(module_or_path) {
135
+ if (wasm !== undefined) return wasm;
136
+
137
+ if (typeof module_or_path !== 'undefined') {
138
+ if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
139
+ ({ module_or_path } = module_or_path);
140
+ } else {
141
+ console.warn(
142
+ 'using deprecated parameters for the initialization function; pass a single object instead'
143
+ );
144
+ }
145
+ }
146
+
147
+ if (typeof module_or_path === 'undefined') {
148
+ module_or_path = new URL('reachy_mini_kinematics_wasm_bg.wasm', import.meta.url);
149
+ }
150
+ const imports = __wbg_get_imports();
151
+
152
+ if (
153
+ typeof module_or_path === 'string' ||
154
+ (typeof Request === 'function' && module_or_path instanceof Request) ||
155
+ (typeof URL === 'function' && module_or_path instanceof URL)
156
+ ) {
157
+ module_or_path = fetch(module_or_path);
158
+ }
159
+
160
+ const { instance, module } = await __wbg_load(await module_or_path, imports);
161
+
162
+ return __wbg_finalize_init(instance, module);
163
+ }
164
+
165
+ export { initSync };
166
+ export default __wbg_init;
hello_world/static/js/kinematics-wasm/reachy_mini_kinematics_wasm_bg.wasm ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b59884521f9a0bee4da460e17463ea0e0ea46d413db9ad77b8fd79bd52e7b15f
3
+ size 30292
hello_world/static/js/media/webrtc.js ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // webrtc.js - Camera streaming via WebRTC with auto-reconnection
2
+ // Simplified for floating camera panel (no audio controls, no settings UI)
3
+
4
+ (function() {
5
+ const DAEMON_API = window.ReachyApp?.DAEMON_API || `http://${window.location.hostname}:8000`;
6
+
7
+ let webrtcApi = null;
8
+ let webrtcSession = null;
9
+ let webrtcProducerId = null;
10
+ let videoEnabled = false;
11
+ let videoPausedByVisibility = false;
12
+
13
+ // Reconnection state
14
+ let reconnectAttempts = 0;
15
+ let reconnectTimer = null;
16
+ let isReconnecting = false;
17
+ const MAX_RECONNECT_DELAY = 30000;
18
+ const BASE_RECONNECT_DELAY = 1000;
19
+
20
+ // Page Visibility API - pause video when tab is hidden
21
+ function initVisibilityHandler() {
22
+ document.addEventListener('visibilitychange', () => {
23
+ if (document.hidden && videoEnabled && webrtcSession) {
24
+ console.log('WebRTC: Tab hidden, pausing stream');
25
+ videoPausedByVisibility = true;
26
+ stopWebRTCStream();
27
+ } else if (!document.hidden && videoPausedByVisibility) {
28
+ console.log('WebRTC: Tab visible, resuming stream');
29
+ videoPausedByVisibility = false;
30
+ videoEnabled = true;
31
+ startWebRTCStream();
32
+ }
33
+ });
34
+ }
35
+
36
+ function getReconnectDelay() {
37
+ return Math.min(BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY);
38
+ }
39
+
40
+ function resetReconnection() {
41
+ reconnectAttempts = 0;
42
+ isReconnecting = false;
43
+ if (reconnectTimer) {
44
+ clearTimeout(reconnectTimer);
45
+ reconnectTimer = null;
46
+ }
47
+ }
48
+
49
+ function scheduleReconnect(reason) {
50
+ if (!videoEnabled || isReconnecting) return;
51
+
52
+ isReconnecting = true;
53
+ const delay = getReconnectDelay();
54
+ reconnectAttempts++;
55
+
56
+ console.log(`WebRTC: ${reason}. Reconnecting in ${delay/1000}s (attempt ${reconnectAttempts})`);
57
+
58
+ const statusDot = document.getElementById('camStatusDot');
59
+ if (statusDot) statusDot.style.background = '#eab308';
60
+
61
+ reconnectTimer = setTimeout(() => {
62
+ isReconnecting = false;
63
+ if (videoEnabled) {
64
+ if (webrtcApi) {
65
+ try { webrtcApi.close?.(); } catch (e) {}
66
+ webrtcApi = null;
67
+ }
68
+ webrtcProducerId = null;
69
+ startWebRTCStream();
70
+ }
71
+ }, delay);
72
+ }
73
+
74
+ // Load GstWebRTC API script from daemon
75
+ function loadWebRTCApi() {
76
+ if (document.getElementById('gstwebrtc-script')) return;
77
+ const script = document.createElement('script');
78
+ script.id = 'gstwebrtc-script';
79
+ script.src = `${DAEMON_API}/static/js/3rdparty/gstwebrtc-api-2.0.0.min.js`;
80
+ script.onload = () => console.log('GstWebRTC API loaded');
81
+ script.onerror = () => console.error('Failed to load GstWebRTC API');
82
+ document.head.appendChild(script);
83
+ }
84
+
85
+ function enableStream() {
86
+ if (videoEnabled) return;
87
+ videoEnabled = true;
88
+ resetReconnection();
89
+ startWebRTCStream();
90
+ }
91
+
92
+ function disableStream() {
93
+ if (!videoEnabled) return;
94
+ videoEnabled = false;
95
+ resetReconnection();
96
+ stopWebRTCStream();
97
+ const statusDot = document.getElementById('camStatusDot');
98
+ if (statusDot) statusDot.style.background = 'transparent';
99
+ }
100
+
101
+ function startWebRTCStream() {
102
+ const videoFeed = document.getElementById('videoFeed');
103
+ const placeholder = document.getElementById('videoPlaceholder');
104
+ const statusDot = document.getElementById('camStatusDot');
105
+
106
+ if (typeof GstWebRTCAPI === 'undefined') {
107
+ if (statusDot) statusDot.style.background = '#ef4444';
108
+ console.warn('GstWebRTCAPI not loaded');
109
+ scheduleReconnect('API not loaded');
110
+ return;
111
+ }
112
+
113
+ if (statusDot) statusDot.style.background = '#eab308';
114
+
115
+ if (!webrtcApi) {
116
+ const signalingUrl = `ws://${window.location.hostname}:8443`;
117
+ console.log('Connecting to WebRTC signaling:', signalingUrl);
118
+
119
+ try {
120
+ webrtcApi = new GstWebRTCAPI({
121
+ meta: { name: `hello-world-${Date.now()}` },
122
+ signalingServerUrl: signalingUrl
123
+ });
124
+ } catch (e) {
125
+ console.error('Failed to create GstWebRTCAPI:', e);
126
+ if (statusDot) statusDot.style.background = '#ef4444';
127
+ scheduleReconnect('API creation failed');
128
+ return;
129
+ }
130
+
131
+ let attempts = 0;
132
+ const pollForProducers = () => {
133
+ if (!videoEnabled) return;
134
+
135
+ attempts++;
136
+ try {
137
+ const producers = webrtcApi.getAvailableProducers();
138
+ console.log(`WebRTC poll #${attempts}: ${producers.length} producers`);
139
+
140
+ for (const producer of producers) {
141
+ if (producer.meta?.name === 'reachymini') {
142
+ webrtcProducerId = producer.id;
143
+ resetReconnection();
144
+ connectToProducer(producer.id);
145
+ return;
146
+ }
147
+ }
148
+
149
+ if (attempts < 30) {
150
+ setTimeout(pollForProducers, 500);
151
+ } else {
152
+ console.warn('WebRTC: No stream found after 30 attempts');
153
+ if (statusDot) statusDot.style.background = '#ef4444';
154
+ scheduleReconnect('No stream found');
155
+ }
156
+ } catch (e) {
157
+ if (attempts < 30) {
158
+ setTimeout(pollForProducers, 500);
159
+ } else {
160
+ console.warn('WebRTC: Failed after 30 attempts');
161
+ if (statusDot) statusDot.style.background = '#ef4444';
162
+ scheduleReconnect('Polling failed');
163
+ }
164
+ }
165
+ };
166
+ setTimeout(pollForProducers, 500);
167
+ } else if (webrtcProducerId && !webrtcSession) {
168
+ connectToProducer(webrtcProducerId);
169
+ } else if (!webrtcProducerId) {
170
+ const producers = webrtcApi.getAvailableProducers();
171
+ for (const producer of producers) {
172
+ if (producer.meta?.name === 'reachymini') {
173
+ webrtcProducerId = producer.id;
174
+ connectToProducer(producer.id);
175
+ return;
176
+ }
177
+ }
178
+ scheduleReconnect('No producer available');
179
+ }
180
+ }
181
+
182
+ function connectToProducer(producerId) {
183
+ const videoFeed = document.getElementById('videoFeed');
184
+ const placeholder = document.getElementById('videoPlaceholder');
185
+ const statusDot = document.getElementById('camStatusDot');
186
+
187
+ console.log('Connecting to producer:', producerId);
188
+
189
+ try {
190
+ webrtcSession = webrtcApi.createConsumerSession(producerId);
191
+ } catch (e) {
192
+ console.error('Failed to create consumer session:', e);
193
+ if (statusDot) statusDot.style.background = '#ef4444';
194
+ scheduleReconnect('Session creation failed');
195
+ return;
196
+ }
197
+
198
+ webrtcSession.addEventListener('error', (e) => {
199
+ console.error('WebRTC session error:', e);
200
+ if (statusDot) statusDot.style.background = '#ef4444';
201
+ webrtcSession = null;
202
+ scheduleReconnect('Session error');
203
+ });
204
+
205
+ webrtcSession.addEventListener('closed', () => {
206
+ console.log('WebRTC session closed');
207
+ webrtcSession = null;
208
+ if (videoEnabled) {
209
+ scheduleReconnect('Session closed');
210
+ }
211
+ });
212
+
213
+ webrtcSession.addEventListener('streamsChanged', () => {
214
+ const streams = webrtcSession.streams;
215
+ console.log('Streams received:', streams?.length);
216
+ if (streams && streams.length > 0) {
217
+ if (videoFeed) {
218
+ videoFeed.srcObject = streams[0];
219
+ videoFeed.classList.add('active');
220
+ }
221
+ if (placeholder) placeholder.classList.add('hidden');
222
+ if (statusDot) statusDot.style.background = '#22c55e';
223
+ resetReconnection();
224
+ }
225
+ });
226
+
227
+ webrtcSession.connect();
228
+ }
229
+
230
+ function stopWebRTCStream() {
231
+ const videoFeed = document.getElementById('videoFeed');
232
+ const placeholder = document.getElementById('videoPlaceholder');
233
+
234
+ resetReconnection();
235
+
236
+ if (webrtcSession) {
237
+ webrtcSession.close();
238
+ webrtcSession = null;
239
+ }
240
+
241
+ if (webrtcApi) {
242
+ try { webrtcApi.close(); } catch (e) {}
243
+ webrtcApi = null;
244
+ webrtcProducerId = null;
245
+ }
246
+
247
+ if (videoFeed) {
248
+ videoFeed.srcObject = null;
249
+ videoFeed.classList.remove('active');
250
+ }
251
+ if (placeholder) {
252
+ placeholder.classList.remove('hidden');
253
+ }
254
+
255
+ console.log('WebRTC: Stream fully stopped');
256
+ }
257
+
258
+ // Client-side snapshot from video element (quick local save)
259
+ function takeSnapshot() {
260
+ const videoFeed = document.getElementById('videoFeed');
261
+ if (!videoFeed || !videoFeed.srcObject) return null;
262
+
263
+ const canvas = document.createElement('canvas');
264
+ canvas.width = videoFeed.videoWidth;
265
+ canvas.height = videoFeed.videoHeight;
266
+ canvas.getContext('2d').drawImage(videoFeed, 0, 0);
267
+ return canvas.toDataURL('image/jpeg', 0.9);
268
+ }
269
+
270
+ function init() {
271
+ loadWebRTCApi();
272
+ initVisibilityHandler();
273
+ }
274
+
275
+ window.ReachyWebRTC = {
276
+ init,
277
+ enableStream,
278
+ disableStream,
279
+ isEnabled: () => videoEnabled,
280
+ takeSnapshot
281
+ };
282
+ })();
hello_world/static/js/simulation.js ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // simulation.js - URDF + Three.js robot simulation with PBR rendering
2
+ // Replaces MuJoCo WASM approach with URDF loader + kinematics-wasm for passive joints
3
+
4
+ (function() {
5
+ 'use strict';
6
+
7
+ // === Module State ===
8
+ let THREE;
9
+ let scene, camera, renderer, controls, composer;
10
+ let robot = null;
11
+ let kinematicsWasm = null;
12
+ let isInitialized = false;
13
+ let initPromise = null;
14
+ let animationId = null;
15
+
16
+ // Target state from WebSocket (all in radians internally)
17
+ const state = {
18
+ headJoints: new Float64Array(7), // [bodyYaw, s1..s6]
19
+ headPoseMatrix: new Float64Array(16), // 4x4 row-major
20
+ antennas: [0, 0], // [right, left]
21
+ passiveJoints: null, // Float64Array(21) from WASM
22
+ };
23
+
24
+ const DEG2RAD = Math.PI / 180;
25
+
26
+ // Passive joint names matching WASM output order (7 joints x 3 axes)
27
+ const PASSIVE_NAMES = [];
28
+ for (let i = 1; i <= 7; i++) {
29
+ PASSIVE_NAMES.push(`passive_${i}_x`, `passive_${i}_y`, `passive_${i}_z`);
30
+ }
31
+
32
+ // === Head Pose Matrix Builder ===
33
+ // Builds 4x4 row-major matrix from position + euler angles (intrinsic XYZ)
34
+ // Matches scipy R.from_euler('xyz', [roll, pitch, yaw])
35
+ function buildPoseMatrix(x, y, z, roll, pitch, yaw) {
36
+ const cr = Math.cos(roll), sr = Math.sin(roll);
37
+ const cp = Math.cos(pitch), sp = Math.sin(pitch);
38
+ const cy = Math.cos(yaw), sy = Math.sin(yaw);
39
+ // R = Rz(yaw) * Ry(pitch) * Rx(roll)
40
+ return new Float64Array([
41
+ cp * cy, cy * sr * sp - cr * sy, cr * cy * sp + sr * sy, x,
42
+ cp * sy, cr * cy + sr * sp * sy, cr * sp * sy - cy * sr, y,
43
+ -sp, cp * sr, cr * cp, z,
44
+ 0, 0, 0, 1,
45
+ ]);
46
+ }
47
+
48
+ // === Initialization ===
49
+ async function init(containerId) {
50
+ if (isInitialized) return;
51
+ if (initPromise) return initPromise;
52
+
53
+ const container = document.getElementById(containerId);
54
+ if (!container) return;
55
+
56
+ initPromise = (async () => {
57
+ try {
58
+ console.log('Simulation: Loading modules...');
59
+
60
+ // Import Three.js + addons
61
+ THREE = await import('three');
62
+ const { OrbitControls } = await import('three/addons/controls/OrbitControls.js');
63
+ const { STLLoader } = await import('three/addons/loaders/STLLoader.js');
64
+ const { mergeVertices } = await import('three/addons/utils/BufferGeometryUtils.js');
65
+ const { EffectComposer } = await import('three/addons/postprocessing/EffectComposer.js');
66
+ const { RenderPass } = await import('three/addons/postprocessing/RenderPass.js');
67
+ const { UnrealBloomPass } = await import('three/addons/postprocessing/UnrealBloomPass.js');
68
+ const { SMAAPass } = await import('three/addons/postprocessing/SMAAPass.js');
69
+ const URDFLoaderModule = await import('urdf-loader');
70
+ const URDFLoader = URDFLoaderModule.default;
71
+
72
+ // Load kinematics WASM (official Pollen Robotics build)
73
+ try {
74
+ const kinModule = await import('/static/js/kinematics-wasm/reachy_mini_kinematics_wasm.js');
75
+ await kinModule.default();
76
+ kinModule.init();
77
+ kinematicsWasm = kinModule;
78
+ console.log('Simulation: Kinematics WASM loaded');
79
+ } catch (e) {
80
+ console.warn('Simulation: Kinematics WASM unavailable, passive joints disabled:', e);
81
+ }
82
+
83
+ // Scene
84
+ setupScene(container);
85
+ setupLighting();
86
+ setupGround();
87
+
88
+ // Controls
89
+ controls = new OrbitControls(camera, renderer.domElement);
90
+ controls.target.set(0, 0.22, 0);
91
+ controls.enableDamping = true;
92
+ controls.dampingFactor = 0.05;
93
+ controls.zoomSpeed = 2.0;
94
+ controls.minDistance = 0.15;
95
+ controls.maxDistance = 2.0;
96
+ controls.maxPolarAngle = Math.PI * 0.85;
97
+ controls.update();
98
+
99
+ // Post-processing
100
+ setupPostProcessing(EffectComposer, RenderPass, UnrealBloomPass, SMAAPass, container);
101
+
102
+ // Load robot URDF
103
+ await loadRobot(URDFLoader, STLLoader, mergeVertices);
104
+
105
+ window.addEventListener('resize', onWindowResize);
106
+ isInitialized = true;
107
+ animate();
108
+ console.log('Simulation: Ready');
109
+
110
+ } catch (error) {
111
+ console.error('Simulation: Init failed:', error);
112
+ }
113
+ })();
114
+
115
+ return initPromise;
116
+ }
117
+
118
+ // === Scene Setup ===
119
+ function createGradientTexture() {
120
+ const canvas = document.createElement('canvas');
121
+ canvas.width = 2;
122
+ canvas.height = 512;
123
+ const ctx = canvas.getContext('2d');
124
+ const gradient = ctx.createLinearGradient(0, 0, 0, 512);
125
+ gradient.addColorStop(0.0, '#1a3a5c'); // deep blue (top)
126
+ gradient.addColorStop(0.4, '#2a5a8c'); // mid blue
127
+ gradient.addColorStop(0.7, '#1e3454'); // darker blue
128
+ gradient.addColorStop(1.0, '#0e1a2e'); // near-black (bottom/horizon)
129
+ ctx.fillStyle = gradient;
130
+ ctx.fillRect(0, 0, 2, 512);
131
+ const tex = new THREE.CanvasTexture(canvas);
132
+ tex.mapping = THREE.EquirectangularReflectionMapping;
133
+ return tex;
134
+ }
135
+
136
+ function setupScene(container) {
137
+ scene = new THREE.Scene();
138
+ scene.background = createGradientTexture();
139
+
140
+ const w = container.clientWidth || 800;
141
+ const h = container.clientHeight || 400;
142
+ camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 100);
143
+ camera.position.set(0.18, 0.30, 0.38);
144
+
145
+ renderer = new THREE.WebGLRenderer({ antialias: true });
146
+ renderer.setSize(w, h);
147
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
148
+ renderer.shadowMap.enabled = true;
149
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
150
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
151
+ renderer.toneMappingExposure = 1.6;
152
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
153
+ container.appendChild(renderer.domElement);
154
+ }
155
+
156
+ function setupLighting() {
157
+ // Ambient fill
158
+ scene.add(new THREE.AmbientLight(0xffffff, 0.6));
159
+
160
+ // Key light (warm, with shadows)
161
+ const key = new THREE.DirectionalLight(0xfff5e6, 2.0);
162
+ key.position.set(2, 4, 3);
163
+ key.castShadow = true;
164
+ key.shadow.mapSize.set(2048, 2048);
165
+ key.shadow.camera.near = 0.1;
166
+ key.shadow.camera.far = 10;
167
+ key.shadow.camera.left = -1;
168
+ key.shadow.camera.right = 1;
169
+ key.shadow.camera.top = 1;
170
+ key.shadow.camera.bottom = -1;
171
+ key.shadow.bias = -0.0001;
172
+ scene.add(key);
173
+
174
+ // Fill light (cool blue)
175
+ const fill = new THREE.DirectionalLight(0xb0c4ff, 0.9);
176
+ fill.position.set(-3, 2, -1);
177
+ scene.add(fill);
178
+
179
+ // Rim light (back highlight)
180
+ const rim = new THREE.DirectionalLight(0xffffff, 0.7);
181
+ rim.position.set(0, 1, -3);
182
+ scene.add(rim);
183
+
184
+ // Hemisphere (sky/ground ambient)
185
+ scene.add(new THREE.HemisphereLight(0x87ceeb, 0x362d28, 0.5));
186
+ }
187
+
188
+ function setupGround() {
189
+ // Subtle shadow-catching ground plane
190
+ const geo = new THREE.CircleGeometry(0.5, 64);
191
+ const mat = new THREE.MeshPhysicalMaterial({
192
+ color: 0x2a3a5c,
193
+ roughness: 0.6,
194
+ metalness: 0.2,
195
+ transparent: true,
196
+ opacity: 0.4,
197
+ });
198
+ const ground = new THREE.Mesh(geo, mat);
199
+ ground.rotation.x = -Math.PI / 2;
200
+ ground.position.y = -0.002;
201
+ ground.receiveShadow = true;
202
+ scene.add(ground);
203
+
204
+ // Subtle grid
205
+ const grid = new THREE.GridHelper(1.0, 20, 0x4466aa, 0x335588);
206
+ grid.material.opacity = 0.1;
207
+ grid.material.transparent = true;
208
+ scene.add(grid);
209
+ }
210
+
211
+ function setupPostProcessing(EffectComposer, RenderPass, UnrealBloomPass, SMAAPass, container) {
212
+ const w = container.clientWidth || 800;
213
+ const h = container.clientHeight || 400;
214
+
215
+ composer = new EffectComposer(renderer);
216
+ composer.addPass(new RenderPass(scene, camera));
217
+
218
+ // Subtle bloom for specular highlights
219
+ const bloom = new UnrealBloomPass(
220
+ new THREE.Vector2(w, h),
221
+ 0.08, // strength (subtle)
222
+ 0.3, // radius
223
+ 0.95, // threshold (high = only brightest spots)
224
+ );
225
+ composer.addPass(bloom);
226
+
227
+ // Anti-aliasing
228
+ const smaa = new SMAAPass(w, h);
229
+ composer.addPass(smaa);
230
+ }
231
+
232
+ // === Robot Loading ===
233
+ async function loadRobot(URDFLoader, STLLoader, mergeVertices) {
234
+ return new Promise((resolve, reject) => {
235
+ const loader = new URDFLoader();
236
+ const stlLoader = new STLLoader();
237
+
238
+ // Resolve package://assets/foo.stl → /api/model/urdf/assets/foo.stl
239
+ loader.packages = {
240
+ 'assets': '/api/model/urdf/assets',
241
+ };
242
+
243
+ // Custom mesh loading with STLLoader + vertex merging for smooth shading
244
+ loader.loadMeshCb = (path, manager, onComplete) => {
245
+ stlLoader.load(path, (geometry) => {
246
+ // STL has duplicate vertices per-triangle — merge them so
247
+ // computeVertexNormals averages across shared faces (smooth shading)
248
+ geometry = mergeVertices(geometry, 0.0001);
249
+ geometry.computeVertexNormals();
250
+ const material = new THREE.MeshStandardMaterial({
251
+ color: 0x888888,
252
+ roughness: 0.5,
253
+ metalness: 0.1,
254
+ });
255
+ const mesh = new THREE.Mesh(geometry, material);
256
+ onComplete(mesh);
257
+ }, undefined, (err) => {
258
+ console.warn('Simulation: Mesh load failed:', path);
259
+ onComplete(new THREE.Group());
260
+ });
261
+ };
262
+
263
+ loader.load('/api/model/urdf/robot_no_collision.urdf', (result) => {
264
+ robot = result;
265
+
266
+ // URDF is Z-up, Three.js is Y-up
267
+ robot.rotation.x = -Math.PI / 2;
268
+
269
+ // Upgrade materials to PBR
270
+ enhanceMaterials();
271
+
272
+ scene.add(robot);
273
+ console.log('Simulation: Robot loaded,', Object.keys(robot.joints).length, 'joints');
274
+ resolve();
275
+ }, undefined, (err) => {
276
+ console.error('Simulation: URDF load failed:', err);
277
+ reject(err);
278
+ });
279
+ });
280
+ }
281
+
282
+ function enhanceMaterials() {
283
+ robot.traverse((child) => {
284
+ if (!child.isMesh) return;
285
+
286
+ // Get color assigned by urdf-loader from URDF <material><color>
287
+ const origColor = child.material?.color?.clone() || new THREE.Color(0x888888);
288
+ const name = (child.name || child.parent?.name || '').toLowerCase();
289
+
290
+ let props = { color: origColor, roughness: 0.6, metalness: 0.1 };
291
+
292
+ // Metal parts: stewart rods, arms, bearings, screws
293
+ if (name.includes('rod') || name.includes('stewart_arm') ||
294
+ name.includes('bearing') || name.includes('bts2') ||
295
+ name.includes('phs_')) {
296
+ props = { color: origColor, roughness: 0.25, metalness: 0.85 };
297
+ }
298
+ // Antenna stalk: glossy black
299
+ else if (name === 'antenna' || (name.includes('antenna') &&
300
+ !name.includes('interface') && !name.includes('holder') &&
301
+ !name.includes('body'))) {
302
+ props = {
303
+ color: origColor, roughness: 0.05, metalness: 0.0,
304
+ clearcoat: 1.0, clearcoatRoughness: 0.05,
305
+ };
306
+ }
307
+ // Lenses: glass-like
308
+ else if (name.includes('lens')) {
309
+ props = {
310
+ color: origColor, roughness: 0.0, metalness: 0.0,
311
+ clearcoat: 1.0, clearcoatRoughness: 0.0,
312
+ };
313
+ }
314
+ // Glasses frame
315
+ else if (name.includes('glasses')) {
316
+ props = {
317
+ color: origColor, roughness: 0.2, metalness: 0.0,
318
+ clearcoat: 0.8, clearcoatRoughness: 0.1,
319
+ };
320
+ }
321
+ // Servos: dark plastic with slight sheen
322
+ else if (name.includes('dc15') || name.includes('xl_330') || name.includes('horn')) {
323
+ props = { color: origColor, roughness: 0.35, metalness: 0.2 };
324
+ }
325
+ // 3D printed shell: slightly glossy plastic
326
+ else if (name.includes('3dprint') || name.includes('shell') || name.includes('head_')) {
327
+ props = {
328
+ color: origColor, roughness: 0.4, metalness: 0.05,
329
+ clearcoat: 0.3, clearcoatRoughness: 0.3,
330
+ };
331
+ }
332
+
333
+ child.material = new THREE.MeshPhysicalMaterial(props);
334
+ child.castShadow = true;
335
+ child.receiveShadow = true;
336
+ });
337
+ }
338
+
339
+ // === WebSocket Updates ===
340
+ function updateFromWebSocket(wsData) {
341
+ if (!wsData?.robot) return;
342
+ const r = wsData.robot;
343
+
344
+ // Active joints (degrees → radians)
345
+ if (r.head_joints?.length >= 7) {
346
+ for (let i = 0; i < 7; i++) {
347
+ state.headJoints[i] = r.head_joints[i] * DEG2RAD;
348
+ }
349
+ }
350
+
351
+ if (r.antenna_joints?.length >= 2) {
352
+ state.antennas[0] = r.antenna_joints[0] * DEG2RAD;
353
+ state.antennas[1] = r.antenna_joints[1] * DEG2RAD;
354
+ }
355
+
356
+ // Build head pose matrix for kinematics (degrees → radians)
357
+ if (r.head_pose) {
358
+ const p = r.head_pose;
359
+ state.headPoseMatrix = buildPoseMatrix(
360
+ p.x, p.y, p.z,
361
+ p.roll * DEG2RAD, p.pitch * DEG2RAD, p.yaw * DEG2RAD,
362
+ );
363
+ }
364
+
365
+ // Compute passive joints via WASM
366
+ if (kinematicsWasm) {
367
+ try {
368
+ state.passiveJoints = kinematicsWasm.calculate_passive_joints(
369
+ state.headJoints,
370
+ state.headPoseMatrix,
371
+ );
372
+ } catch (e) {
373
+ // Kinematics errors during rapid updates are expected
374
+ }
375
+ }
376
+ }
377
+
378
+ function applyJoints() {
379
+ if (!robot) return;
380
+
381
+ // Body yaw
382
+ robot.setJointValue('yaw_body', state.headJoints[0]);
383
+
384
+ // Stewart platform actuators
385
+ for (let i = 1; i <= 6; i++) {
386
+ robot.setJointValue('stewart_' + i, state.headJoints[i]);
387
+ }
388
+
389
+ // Antennas
390
+ robot.setJointValue('right_antenna', state.antennas[0]);
391
+ robot.setJointValue('left_antenna', state.antennas[1]);
392
+
393
+ // Passive joints (from kinematics WASM)
394
+ if (state.passiveJoints) {
395
+ for (let i = 0; i < 21; i++) {
396
+ robot.setJointValue(PASSIVE_NAMES[i], state.passiveJoints[i]);
397
+ }
398
+ }
399
+ }
400
+
401
+ // === Animation Loop ===
402
+ function animate() {
403
+ animationId = requestAnimationFrame(animate);
404
+
405
+ // Skip if hidden
406
+ if (renderer?.domElement?.parentElement) {
407
+ const c = renderer.domElement.parentElement;
408
+ if (c.offsetWidth === 0 || c.offsetHeight === 0) return;
409
+ }
410
+
411
+ applyJoints();
412
+ if (controls) controls.update();
413
+
414
+ if (composer) {
415
+ composer.render();
416
+ } else if (renderer && scene && camera) {
417
+ renderer.render(scene, camera);
418
+ }
419
+ }
420
+
421
+ function onWindowResize() {
422
+ if (!renderer) return;
423
+ const container = renderer.domElement.parentElement;
424
+ if (!container) return;
425
+ const w = container.clientWidth;
426
+ const h = container.clientHeight;
427
+ if (w === 0 || h === 0) return;
428
+
429
+ camera.aspect = w / h;
430
+ camera.updateProjectionMatrix();
431
+ renderer.setSize(w, h);
432
+ if (composer) composer.setSize(w, h);
433
+ }
434
+
435
+ function dispose() {
436
+ if (animationId) cancelAnimationFrame(animationId);
437
+ window.removeEventListener('resize', onWindowResize);
438
+ if (renderer) {
439
+ renderer.dispose();
440
+ renderer.domElement.remove();
441
+ }
442
+ if (composer) composer.dispose();
443
+ robot = null;
444
+ isInitialized = false;
445
+ initPromise = null;
446
+ }
447
+
448
+ // === Export ===
449
+ window.ReachySimulation = {
450
+ init,
451
+ updateFromWebSocket,
452
+ resize: onWindowResize,
453
+ dispose,
454
+ isInitialized: () => isInitialized,
455
+ };
456
+ })();
hello_world/static/js/websocket.js ADDED
@@ -0,0 +1,923 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // websocket.js - Live WebSocket connection for robot state and system stats
2
+
3
+ (function() {
4
+ const { formatBytes, formatBytesShort, formatUptime } = window.ReachyApp;
5
+
6
+ let liveSocket = null;
7
+
8
+ // Subscription state
9
+ let currentSubscriptions = new Set(["robot", "stats"]);
10
+
11
+ // Zenoh health tracking
12
+ let lastPoseUpdateTime = 0;
13
+ let zenohCheckInterval = null;
14
+ const ZENOH_STALE_THRESHOLD = 3000;
15
+
16
+ function updateZenohStatus(hasData) {
17
+ if (hasData) {
18
+ lastPoseUpdateTime = Date.now();
19
+ setIndicatorStatus('zenohIndicator', 'active');
20
+ } else {
21
+ setIndicatorStatus('zenohIndicator', 'error');
22
+ }
23
+ }
24
+
25
+ function checkZenohHealth() {
26
+ if (!liveSocket || liveSocket.readyState !== WebSocket.OPEN) {
27
+ setIndicatorStatus('zenohIndicator', 'connecting');
28
+ return;
29
+ }
30
+ const timeSinceLastPose = Date.now() - lastPoseUpdateTime;
31
+ if (lastPoseUpdateTime > 0 && timeSinceLastPose > ZENOH_STALE_THRESHOLD) {
32
+ setIndicatorStatus('zenohIndicator', 'error');
33
+ }
34
+ }
35
+
36
+ // Reconnection with exponential backoff
37
+ let wsReconnectAttempts = 0;
38
+ let wsReconnectTimer = null;
39
+ const WS_MAX_RECONNECT_DELAY = 30000;
40
+ const WS_BASE_RECONNECT_DELAY = 1000;
41
+
42
+ function getWsReconnectDelay() {
43
+ return Math.min(WS_BASE_RECONNECT_DELAY * Math.pow(2, wsReconnectAttempts), WS_MAX_RECONNECT_DELAY);
44
+ }
45
+
46
+ function resetWsReconnection() {
47
+ wsReconnectAttempts = 0;
48
+ if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }
49
+ }
50
+
51
+ function scheduleWsReconnect() {
52
+ const delay = getWsReconnectDelay();
53
+ wsReconnectAttempts++;
54
+ console.log(`Live WebSocket: Reconnecting in ${delay/1000}s (attempt ${wsReconnectAttempts})`);
55
+ wsReconnectTimer = setTimeout(connectLiveWebSocket, delay);
56
+ }
57
+
58
+ function sendSubscriptions() {
59
+ if (liveSocket && liveSocket.readyState === WebSocket.OPEN) {
60
+ const msg = { subscribe: Array.from(currentSubscriptions) };
61
+ liveSocket.send(JSON.stringify(msg));
62
+ }
63
+ }
64
+
65
+ function setSubscriptions(subs) {
66
+ const newSubs = new Set(subs);
67
+ if (newSubs.size !== currentSubscriptions.size ||
68
+ ![...newSubs].every(s => currentSubscriptions.has(s))) {
69
+ currentSubscriptions = newSubs;
70
+ sendSubscriptions();
71
+ }
72
+ }
73
+
74
+ function updateSubscriptionsFromVisibility() {
75
+ const subs = ["robot", "stats"];
76
+ setSubscriptions(subs);
77
+ }
78
+
79
+ // ===== Indicator Status =====
80
+ function setIndicatorStatus(indicator, status) {
81
+ let indicatorId = null;
82
+ if (typeof indicator === 'string') {
83
+ indicatorId = indicator;
84
+ indicator = document.getElementById(indicator);
85
+ } else if (indicator) {
86
+ indicatorId = indicator.id;
87
+ }
88
+
89
+ if (indicator) {
90
+ indicator.classList.remove('connecting', 'active', 'error');
91
+ if (status === 'connecting' || status === 'active' || status === 'error') {
92
+ indicator.classList.add(status);
93
+ }
94
+ }
95
+
96
+ if (window.ReachyStatus && indicatorId) {
97
+ ReachyStatus.updateFromIndicator(indicatorId, status);
98
+ }
99
+ }
100
+
101
+ // ===== Chart Classes =====
102
+ class LineChart {
103
+ constructor(canvasId, color, maxValue = 100, secondaryColor = null, minValue = 0, autoScale = false) {
104
+ this.canvas = document.getElementById(canvasId);
105
+ if (!this.canvas) return;
106
+ this.ctx = this.canvas.getContext('2d');
107
+ this.color = color;
108
+ this.secondaryColor = secondaryColor;
109
+ this.fixedMaxValue = maxValue;
110
+ this.fixedMinValue = minValue;
111
+ this.autoScale = autoScale;
112
+ this.data = [];
113
+ this.secondaryData = [];
114
+ this.maxPoints = 60;
115
+ this.padding = 0.1;
116
+ this.resize();
117
+ window.addEventListener('resize', () => this.resize());
118
+ }
119
+
120
+ resize() {
121
+ if (!this.canvas) return;
122
+ const rect = this.canvas.getBoundingClientRect();
123
+ if (rect.width === 0 || rect.height === 0) return;
124
+ this.canvas.width = rect.width * window.devicePixelRatio;
125
+ this.canvas.height = rect.height * window.devicePixelRatio;
126
+ this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
127
+ this.width = rect.width;
128
+ this.height = rect.height;
129
+ this.draw();
130
+ }
131
+
132
+ getRange() {
133
+ if (!this.autoScale || this.data.length === 0) {
134
+ return { min: this.fixedMinValue, max: this.fixedMaxValue };
135
+ }
136
+ const allData = [...this.data, ...this.secondaryData];
137
+ let min = Math.min(...allData);
138
+ let max = Math.max(...allData);
139
+ const range = max - min || 1;
140
+ min -= range * this.padding;
141
+ max += range * this.padding;
142
+ return { min, max };
143
+ }
144
+
145
+ push(value, secondaryValue = null, color = null) {
146
+ if (!this.canvas) return;
147
+ if (color) this.color = color;
148
+ this.data.push(value);
149
+ if (this.data.length > this.maxPoints) this.data.shift();
150
+ if (secondaryValue !== null) {
151
+ this.secondaryData.push(secondaryValue);
152
+ if (this.secondaryData.length > this.maxPoints) this.secondaryData.shift();
153
+ }
154
+ this.draw();
155
+ }
156
+
157
+ draw() {
158
+ if (!this.canvas || !this.width || !this.height) return;
159
+ const ctx = this.ctx;
160
+ const w = this.width;
161
+ const h = this.height;
162
+ const { min, max } = this.getRange();
163
+ const range = max - min || 1;
164
+
165
+ ctx.clearRect(0, 0, w, h);
166
+
167
+ ctx.strokeStyle = 'rgba(255,255,255,0.05)';
168
+ ctx.lineWidth = 1;
169
+ for (let i = 0; i <= 4; i++) {
170
+ const y = (h / 4) * i;
171
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
172
+ }
173
+
174
+ if (min < 0 && max > 0) {
175
+ const zeroY = h - ((0 - min) / range) * h;
176
+ ctx.strokeStyle = 'rgba(255,255,255,0.3)';
177
+ ctx.setLineDash([4, 4]);
178
+ ctx.beginPath(); ctx.moveTo(0, zeroY); ctx.lineTo(w, zeroY); ctx.stroke();
179
+ ctx.setLineDash([]);
180
+ }
181
+
182
+ if (this.secondaryColor && this.secondaryData.length > 1) {
183
+ this.drawLine(this.secondaryData, this.secondaryColor, min, range);
184
+ }
185
+ if (this.data.length > 1) {
186
+ this.drawLine(this.data, this.color, min, range);
187
+ }
188
+ }
189
+
190
+ drawLine(data, color, min, range) {
191
+ const ctx = this.ctx;
192
+ const w = this.width;
193
+ const h = this.height;
194
+ const step = w / (this.maxPoints - 1);
195
+ const offset = this.maxPoints - data.length;
196
+
197
+ const getY = (val) => {
198
+ const clamped = Math.max(min, Math.min(min + range, val));
199
+ return h - ((clamped - min) / range) * h;
200
+ };
201
+ const baseY = min < 0 && min + range > 0 ? getY(0) : h;
202
+
203
+ ctx.beginPath();
204
+ ctx.moveTo((0 + offset) * step, baseY);
205
+ data.forEach((val, i) => { ctx.lineTo((i + offset) * step, getY(val)); });
206
+ ctx.lineTo((data.length - 1 + offset) * step, baseY);
207
+ ctx.closePath();
208
+ ctx.fillStyle = color.replace('1)', '0.15)');
209
+ ctx.fill();
210
+
211
+ ctx.beginPath();
212
+ data.forEach((val, i) => {
213
+ const x = (i + offset) * step;
214
+ const y = getY(val);
215
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
216
+ });
217
+ ctx.strokeStyle = color;
218
+ ctx.lineWidth = 2;
219
+ ctx.lineJoin = 'round';
220
+ ctx.stroke();
221
+ }
222
+
223
+ setMaxValue(max) { this.fixedMaxValue = max; }
224
+ }
225
+
226
+ class MultiLineChart {
227
+ constructor(canvasId, colors, maxValue = 100, minValue = 0, autoScale = false) {
228
+ this.canvas = document.getElementById(canvasId);
229
+ if (!this.canvas) return;
230
+ this.ctx = this.canvas.getContext('2d');
231
+ this.colors = colors;
232
+ this.fixedMaxValue = maxValue;
233
+ this.fixedMinValue = minValue;
234
+ this.autoScale = autoScale;
235
+ this.dataLines = colors.map(() => []);
236
+ this.maxPoints = 60;
237
+ this.padding = 0.1;
238
+ this.resize();
239
+ window.addEventListener('resize', () => this.resize());
240
+ }
241
+
242
+ resize() {
243
+ if (!this.canvas) return;
244
+ const rect = this.canvas.getBoundingClientRect();
245
+ if (rect.width === 0 || rect.height === 0) return;
246
+ this.canvas.width = rect.width * window.devicePixelRatio;
247
+ this.canvas.height = rect.height * window.devicePixelRatio;
248
+ this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
249
+ this.width = rect.width;
250
+ this.height = rect.height;
251
+ this.draw();
252
+ }
253
+
254
+ getRange() {
255
+ if (!this.autoScale) return { min: this.fixedMinValue, max: this.fixedMaxValue };
256
+ const allData = this.dataLines.flat();
257
+ if (allData.length === 0) return { min: this.fixedMinValue, max: this.fixedMaxValue };
258
+ let min = Math.min(...allData);
259
+ let max = Math.max(...allData);
260
+ const range = max - min || 1;
261
+ min -= range * this.padding;
262
+ max += range * this.padding;
263
+ return { min, max };
264
+ }
265
+
266
+ push(values) {
267
+ if (!this.canvas) return;
268
+ values.forEach((val, i) => {
269
+ if (this.dataLines[i]) {
270
+ this.dataLines[i].push(val);
271
+ if (this.dataLines[i].length > this.maxPoints) this.dataLines[i].shift();
272
+ }
273
+ });
274
+ this.draw();
275
+ }
276
+
277
+ draw() {
278
+ if (!this.canvas || !this.width || !this.height) return;
279
+ const ctx = this.ctx;
280
+ const w = this.width;
281
+ const h = this.height;
282
+ const { min, max } = this.getRange();
283
+ const range = max - min || 1;
284
+
285
+ ctx.clearRect(0, 0, w, h);
286
+ ctx.strokeStyle = 'rgba(255,255,255,0.05)';
287
+ ctx.lineWidth = 1;
288
+ for (let i = 0; i <= 4; i++) {
289
+ const y = (h / 4) * i;
290
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
291
+ }
292
+
293
+ if (min < 0 && max > 0) {
294
+ const zeroY = h - ((0 - min) / range) * h;
295
+ ctx.strokeStyle = 'rgba(255,255,255,0.3)';
296
+ ctx.setLineDash([4, 4]);
297
+ ctx.beginPath(); ctx.moveTo(0, zeroY); ctx.lineTo(w, zeroY); ctx.stroke();
298
+ ctx.setLineDash([]);
299
+ }
300
+
301
+ this.dataLines.forEach((data, i) => {
302
+ if (data.length > 1) this.drawLine(data, this.colors[i], min, range);
303
+ });
304
+ }
305
+
306
+ drawLine(data, color, min, range) {
307
+ const ctx = this.ctx;
308
+ const w = this.width;
309
+ const h = this.height;
310
+ const step = w / (this.maxPoints - 1);
311
+ const offset = this.maxPoints - data.length;
312
+ const getY = (val) => {
313
+ const clamped = Math.max(min, Math.min(min + range, val));
314
+ return h - ((clamped - min) / range) * h;
315
+ };
316
+
317
+ ctx.beginPath();
318
+ data.forEach((val, i) => {
319
+ const x = (i + offset) * step;
320
+ const y = getY(val);
321
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
322
+ });
323
+ ctx.strokeStyle = color;
324
+ ctx.lineWidth = 1.5;
325
+ ctx.lineJoin = 'round';
326
+ ctx.stroke();
327
+ }
328
+ }
329
+
330
+ // ===== Pie Chart =====
331
+ function drawPieChart(canvasId, percent, color) {
332
+ const canvas = document.getElementById(canvasId);
333
+ if (!canvas) return;
334
+ const ctx = canvas.getContext('2d');
335
+ const size = canvas.width;
336
+ const center = size / 2;
337
+ const radius = (size / 2) - 6;
338
+ const lineWidth = 8;
339
+
340
+ ctx.clearRect(0, 0, size, size);
341
+
342
+ ctx.beginPath();
343
+ ctx.arc(center, center, radius, 0, Math.PI * 2);
344
+ ctx.strokeStyle = 'rgba(255,255,255,0.15)';
345
+ ctx.lineWidth = lineWidth;
346
+ ctx.stroke();
347
+
348
+ if (percent > 0) {
349
+ ctx.beginPath();
350
+ ctx.arc(center, center, radius, -Math.PI / 2, -Math.PI / 2 + (Math.PI * 2 * percent / 100));
351
+ ctx.strokeStyle = color;
352
+ ctx.lineWidth = lineWidth;
353
+ ctx.lineCap = 'round';
354
+ ctx.stroke();
355
+ }
356
+
357
+ ctx.fillStyle = '#ffffff';
358
+ ctx.font = 'bold 14px sans-serif';
359
+ ctx.textAlign = 'center';
360
+ ctx.textBaseline = 'middle';
361
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
362
+ ctx.shadowBlur = 2;
363
+ ctx.fillText(`${percent.toFixed(0)}%`, center, center);
364
+ ctx.shadowBlur = 0;
365
+ }
366
+
367
+ // ===== Chart Instances =====
368
+ const cpuColors = ['rgba(239, 68, 68, 1)', 'rgba(34, 197, 94, 1)', 'rgba(59, 130, 246, 1)', 'rgba(234, 179, 8, 1)'];
369
+ const ramColors = ['rgba(168, 85, 247, 1)', 'rgba(59, 130, 246, 1)', 'rgba(34, 197, 94, 1)'];
370
+ const ramLabels = ['Used', 'Buf', 'Cache'];
371
+ const jointColors = [
372
+ 'rgba(239, 68, 68, 1)', 'rgba(249, 115, 22, 1)', 'rgba(234, 179, 8, 1)',
373
+ 'rgba(34, 197, 94, 1)', 'rgba(6, 182, 212, 1)', 'rgba(99, 102, 241, 1)', 'rgba(168, 85, 247, 1)'
374
+ ];
375
+
376
+ let tempChart, cpuChart, ramChart, netChart, wifiChart;
377
+ let loadChart, fanChart, diskIoChart;
378
+ let rollChart, pitchChart, yawChart;
379
+ let posXChart, posYChart, posZChart;
380
+ let antennaRightChart, antennaLeftChart, jointsChart;
381
+ let netMaxSpeed = 1024 * 1024;
382
+ let diskIoMaxSpeed = 1024 * 1024;
383
+
384
+ function initCharts() {
385
+ tempChart = new LineChart('tempChart', 'rgba(239, 68, 68, 1)', 85);
386
+ cpuChart = new MultiLineChart('cpuChart', cpuColors);
387
+ ramChart = new MultiLineChart('ramChart', ramColors);
388
+ netChart = new LineChart('netChart', 'rgba(34, 197, 94, 1)', netMaxSpeed, 'rgba(239, 68, 68, 1)');
389
+ wifiChart = new LineChart('wifiChart', 'rgba(99, 102, 241, 1)');
390
+ loadChart = new LineChart('loadChart', 'rgba(234, 179, 8, 1)', 8, null, 0, false);
391
+ fanChart = new LineChart('fanChart', 'rgba(6, 182, 212, 1)', 15000, null, 0, false);
392
+ diskIoChart = new LineChart('diskIoChart', 'rgba(34, 197, 94, 1)', diskIoMaxSpeed, 'rgba(239, 68, 68, 1)');
393
+
394
+ rollChart = new LineChart('rollChart', getLimitRgba(0, LIMIT_MAP.rollValue), 20, null, -20, false);
395
+ pitchChart = new LineChart('pitchChart', getLimitRgba(0, LIMIT_MAP.pitchValue), 25, null, -25, false);
396
+ yawChart = new LineChart('yawChart', getLimitRgba(0, LIMIT_MAP.yawValue), 50, null, -50, false);
397
+
398
+ posXChart = new LineChart('posXChart', getLimitRgba(0, LIMIT_MAP.posXValue), 3, null, -3, false);
399
+ posYChart = new LineChart('posYChart', getLimitRgba(0, LIMIT_MAP.posYValue), 3, null, -3, false);
400
+ posZChart = new LineChart('posZChart', getLimitRgba(0, LIMIT_MAP.posZValue), 4, null, -4, false);
401
+
402
+ antennaRightChart = new LineChart('antennaRightChart', getLimitRgba(0, LIMIT_MAP.antennaRightValue), 179, null, -179, false);
403
+ antennaLeftChart = new LineChart('antennaLeftChart', getLimitRgba(0, LIMIT_MAP.antennaLeftValue), 179, null, -179, false);
404
+ jointsChart = new MultiLineChart('jointsChart', jointColors, 60, -60, false);
405
+ }
406
+
407
+ function connectLiveWebSocket() {
408
+ if (liveSocket && liveSocket.readyState === WebSocket.OPEN) return;
409
+
410
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
411
+ const wsUrl = `${wsProtocol}//${window.location.host}/ws/live`;
412
+
413
+ liveSocket = new WebSocket(wsUrl);
414
+
415
+ liveSocket.onopen = () => {
416
+ console.log('Live WebSocket connected');
417
+ resetWsReconnection();
418
+ setIndicatorStatus('connectionIndicator', 'active');
419
+ sendSubscriptions();
420
+ };
421
+
422
+ liveSocket.onclose = () => {
423
+ console.log('Live WebSocket closed');
424
+ setIndicatorStatus('connectionIndicator', 'error');
425
+ scheduleWsReconnect();
426
+ };
427
+
428
+ liveSocket.onerror = (e) => { console.error('Live WebSocket error:', e); };
429
+
430
+ liveSocket.onmessage = (event) => {
431
+ try {
432
+ handleLiveData(JSON.parse(event.data));
433
+ } catch (e) {
434
+ console.error('Failed to parse live data:', e);
435
+ }
436
+ };
437
+ }
438
+
439
+ // ===== Data-Driven Stats Configuration =====
440
+ const SIMPLE_STATS = [
441
+ { key: 'cpu_temp', el: 'tempValue', fmt: v => `${v.toFixed(1)}\u00B0C`, chart: () => tempChart },
442
+ { key: 'memory_percent', el: 'ramValue', fmt: v => `${v.toFixed(0)}%`, headerEl: 'headerRam' },
443
+ { key: 'wifi_signal', el: 'wifiStrength', fmt: v => `${v}%`, chart: () => wifiChart },
444
+ { key: 'wifi_ssid', el: 'wifiSsid', fmt: v => v },
445
+ { key: 'fan_rpm', el: 'fanValue', fmt: v => v !== null ? `${v} RPM` : '--', chart: () => fanChart },
446
+ { key: 'net_rx_speed', el: 'netDownValue', fmt: v => formatBytes(v) + '/s' },
447
+ { key: 'net_tx_speed', el: 'netUpValue', fmt: v => formatBytes(v) + '/s' },
448
+ { key: 'disk_read_speed', el: 'diskReadSpeed', fmt: v => formatBytes(v) + '/s' },
449
+ { key: 'disk_write_speed', el: 'diskWriteSpeed', fmt: v => formatBytes(v) + '/s' },
450
+ ];
451
+
452
+ const DISK_CHARTS = [
453
+ { key: 'disk_local', chartId: 'diskLocalChart', infoId: 'diskLocalInfo', color: 'rgba(234, 179, 8, 1)' },
454
+ { key: 'swap', chartId: 'swapChart', infoId: 'swapInfo', color: 'rgba(239, 68, 68, 1)', fallbackFlag: '_swapChecked' },
455
+ ];
456
+
457
+ function updateSimpleStats(sys) {
458
+ SIMPLE_STATS.forEach(({ key, el, fmt, chart, headerEl }) => {
459
+ const value = sys[key];
460
+ if (value === undefined) return;
461
+ const element = document.getElementById(el);
462
+ if (element) element.textContent = fmt(value);
463
+ if (chart) { const c = chart(); if (c) c.push(value); }
464
+ if (headerEl) { const h = document.getElementById(headerEl); if (h) h.textContent = fmt(value); }
465
+ });
466
+ }
467
+
468
+ function updateDiskCharts(sys) {
469
+ DISK_CHARTS.forEach(({ key, chartId, infoId, color, fallbackFlag }) => {
470
+ const data = sys[key];
471
+ if (data) {
472
+ drawPieChart(chartId, data.percent, color);
473
+ const infoEl = document.getElementById(infoId);
474
+ if (infoEl) infoEl.innerHTML = `${formatBytes(data.used)} used<br>${formatBytes(data.free)} free`;
475
+ } else if (fallbackFlag && !window[fallbackFlag]) {
476
+ window[fallbackFlag] = true;
477
+ const infoEl = document.getElementById(infoId);
478
+ if (infoEl) infoEl.textContent = 'N/A';
479
+ drawPieChart(chartId, 0, 'rgba(100, 100, 100, 0.3)');
480
+ }
481
+ });
482
+ }
483
+
484
+ function updateCpuCores(sys) {
485
+ if (!sys.cpu_cores || sys.cpu_cores.length === 0) return;
486
+ const avgCpu = sys.cpu_cores.reduce((a, b) => a + b, 0) / sys.cpu_cores.length;
487
+ const el = document.getElementById('cpuValue');
488
+ if (el) el.textContent = `${avgCpu.toFixed(0)}%`;
489
+ const headerCpu = document.getElementById('headerCpu');
490
+ if (headerCpu) headerCpu.textContent = `${avgCpu.toFixed(0)}%`;
491
+ const legendEl = document.getElementById('cpuLegend');
492
+ if (legendEl) {
493
+ legendEl.innerHTML = sys.cpu_cores.map((v, i) =>
494
+ `<span style="color: ${cpuColors[i]};">\u25CF</span><span style="display:inline-block;width:2.5em;text-align:right;">${v.toFixed(0)}%</span>`
495
+ ).join(' ');
496
+ }
497
+ if (cpuChart) cpuChart.push(sys.cpu_cores);
498
+ }
499
+
500
+ function updateRamChart(sys) {
501
+ const usedPct = sys.memory_used_pct;
502
+ if (usedPct === undefined) return;
503
+ const pcts = [usedPct, sys.memory_buffers_pct || 0, sys.memory_cached_pct || 0];
504
+ const legendEl = document.getElementById('ramLegend');
505
+ if (legendEl) {
506
+ legendEl.innerHTML = pcts.map((v, i) =>
507
+ `<span style="color: ${ramColors[i]};">\u25CF</span>${ramLabels[i]} <span style="display:inline-block;width:2.5em;text-align:right;">${v.toFixed(0)}%</span>`
508
+ ).join(' ');
509
+ }
510
+ if (ramChart) ramChart.push(pcts);
511
+ }
512
+
513
+ function updateNetworkChart(sys) {
514
+ if (sys.net_rx_speed === undefined || sys.net_tx_speed === undefined) return;
515
+ const maxNet = Math.max(sys.net_rx_speed, sys.net_tx_speed);
516
+ if (maxNet > netMaxSpeed * 0.8) { netMaxSpeed = maxNet * 1.5; if (netChart) netChart.setMaxValue(netMaxSpeed); }
517
+ if (netChart) netChart.push(sys.net_rx_speed, sys.net_tx_speed);
518
+ const headerNet = document.getElementById('headerNet');
519
+ if (headerNet) headerNet.textContent = formatBytesShort(maxNet);
520
+ }
521
+
522
+ function updateDiskIoChart(sys) {
523
+ if (sys.disk_read_speed === undefined || sys.disk_write_speed === undefined) return;
524
+ const maxIo = Math.max(sys.disk_read_speed, sys.disk_write_speed);
525
+ if (maxIo > diskIoMaxSpeed * 0.8) { diskIoMaxSpeed = maxIo * 1.5; if (diskIoChart) diskIoChart.setMaxValue(diskIoMaxSpeed); }
526
+ if (diskIoChart) diskIoChart.push(sys.disk_read_speed, sys.disk_write_speed);
527
+ }
528
+
529
+ function updateLoadAverage(sys) {
530
+ if (sys.load_1m === undefined) return;
531
+ const cores = 4;
532
+ const load = sys.load_1m;
533
+ const color = load > cores ? 'var(--error)' : load > cores * 0.75 ? 'var(--warning)' : 'var(--success)';
534
+ const loadValEl = document.getElementById('loadValue');
535
+ if (loadValEl) loadValEl.innerHTML = `<span style="color:${color}">${load.toFixed(2)}</span>`;
536
+ const loadAvgEl = document.getElementById('loadAvg');
537
+ if (loadAvgEl) loadAvgEl.textContent = `${load.toFixed(2)} / ${sys.load_5m?.toFixed(2) || '--'} / ${sys.load_15m?.toFixed(2) || '--'}`;
538
+ if (loadChart) loadChart.push(load);
539
+ }
540
+
541
+ function updateThrottleStatus(sys) {
542
+ if (sys.throttled === undefined) return;
543
+ // Lower 4 bits = currently active, upper bits = historical (since boot)
544
+ const currentlyThrottled = sys.throttled & 0xF;
545
+ if (currentlyThrottled === 0) {
546
+ setIndicatorStatus('throttleIndicator', 'active');
547
+ } else {
548
+ setIndicatorStatus('throttleIndicator', 'error');
549
+ }
550
+ }
551
+
552
+ function updateTopProcesses(sys) {
553
+ if (!sys.top_processes || sys.top_processes.length === 0) return;
554
+ const el = document.getElementById('topProcesses');
555
+ if (!el) return;
556
+ const rows = sys.top_processes.map(p =>
557
+ `<tr><td>${p.pid}</td><td>${p.name}</td><td>${p.cpu}%</td><td>${p.mem}%</td></tr>`
558
+ ).join('');
559
+ el.innerHTML = `<table class="process-table"><thead><tr><th>PID</th><th>Name</th><th>CPU</th><th>Mem</th></tr></thead><tbody>${rows}</tbody></table>`;
560
+ }
561
+
562
+ let hardwareInfoUpdated = false;
563
+ function updateHardwareInfo(sys) {
564
+ if (hardwareInfoUpdated || !sys.hardware_info) return;
565
+ hardwareInfoUpdated = true;
566
+ const hw = sys.hardware_info;
567
+
568
+ const updates = {
569
+ 'hwBoard': hw.board_model,
570
+ 'hwSerial': hw.board_serial,
571
+ 'hwCpu': `${hw.cpu_arch || ''} ${hw.cpu_cores || '?'}x ${hw.cpu_freq_max ? hw.cpu_freq_max + 'MHz' : ''}`.trim(),
572
+ 'hwMemory': (() => {
573
+ const total = hw.mem_total_gb ? `${hw.mem_total_gb}GB` : '?';
574
+ const split = (hw.mem_arm && hw.mem_gpu) ? ` (ARM: ${hw.mem_arm}, GPU: ${hw.mem_gpu})` : '';
575
+ return `${total}${split}`;
576
+ })(),
577
+ 'hwStorage': hw.storage ? hw.storage.map(s => `${s.name}: ${s.size}`).join(', ') : null,
578
+ 'hwKernel': hw.kernel,
579
+ 'hwFirmware': hw.firmware_date,
580
+ 'hwNetwork': hw.network_interfaces ? hw.network_interfaces.map(n => `${n.name} (${n.state})`).join(', ') : null,
581
+ 'hwUsb': hw.usb_devices ? hw.usb_devices.map(u => u.name).join(', ') || 'None' : null,
582
+ 'hwI2c': hw.i2c_buses !== undefined ? `${hw.i2c_buses} buses` : null,
583
+ };
584
+
585
+ Object.entries(updates).forEach(([id, value]) => {
586
+ if (value) {
587
+ const el = document.getElementById(id);
588
+ if (el) el.textContent = value;
589
+ }
590
+ });
591
+ }
592
+
593
+ // Limit proximity color: green → amber → red
594
+ const _limitConsts = window.ReachyConstants;
595
+ const LIMIT_MAP = {
596
+ rollValue: { neg: -(_limitConsts?.HEAD_ROLL_RIGHT_MAX || 0.30), pos: _limitConsts?.HEAD_ROLL_LEFT_MAX || 0.35, unit: 'deg' },
597
+ pitchValue: { neg: -(_limitConsts?.HEAD_PITCH_MAX || 0.44), pos: _limitConsts?.HEAD_PITCH_MAX || 0.44, unit: 'deg' },
598
+ yawValue: { neg: -(_limitConsts?.HEAD_YAW_MAX || 0.87), pos: _limitConsts?.HEAD_YAW_MAX || 0.87, unit: 'deg' },
599
+ posXValue: { neg: -(_limitConsts?.HEAD_X_MAX || 0.03), pos: _limitConsts?.HEAD_X_MAX || 0.03, unit: 'cm' },
600
+ posYValue: { neg: -(_limitConsts?.HEAD_Y_MAX || 0.03), pos: _limitConsts?.HEAD_Y_MAX || 0.03, unit: 'cm' },
601
+ posZValue: { neg: -(_limitConsts?.HEAD_Z_MAX || 0.04), pos: _limitConsts?.HEAD_Z_MAX || 0.04, unit: 'cm' },
602
+ antennaRightValue: { neg: -179, pos: 179, unit: 'antDeg' },
603
+ antennaLeftValue: { neg: -179, pos: 179, unit: 'antDeg' },
604
+ };
605
+
606
+ function getLimitColor(value, limitInfo) {
607
+ if (!limitInfo) return '';
608
+ // Convert degrees to radians for comparison (head pose comes in degrees)
609
+ let raw = value;
610
+ if (limitInfo.unit === 'deg') raw = value * Math.PI / 180;
611
+ else if (limitInfo.unit === 'cm') raw = value / 100;
612
+ // ratio: 0 = center, 1 = at limit
613
+ const limit = raw >= 0 ? limitInfo.pos : Math.abs(limitInfo.neg);
614
+ const ratio = limit > 0 ? Math.abs(raw) / limit : 0;
615
+ if (ratio < 0.50) return 'var(--success, #4ade80)';
616
+ if (ratio < 0.85) return 'var(--warning, #fbbf24)';
617
+ return 'var(--error, #f87171)';
618
+ }
619
+
620
+ function getLimitRgba(value, limitInfo) {
621
+ if (!limitInfo) return null;
622
+ let raw = value;
623
+ if (limitInfo.unit === 'deg') raw = value * Math.PI / 180;
624
+ else if (limitInfo.unit === 'cm') raw = value / 100;
625
+ const limit = raw >= 0 ? limitInfo.pos : Math.abs(limitInfo.neg);
626
+ const ratio = limit > 0 ? Math.abs(raw) / limit : 0;
627
+ if (ratio < 0.50) return 'rgba(74, 222, 128, 1)'; // green
628
+ if (ratio < 0.85) return 'rgba(251, 191, 36, 1)'; // amber
629
+ return 'rgba(248, 113, 113, 1)'; // red
630
+ }
631
+
632
+ function setValueWithColor(elId, text, rawValue) {
633
+ const el = document.getElementById(elId);
634
+ if (!el) return;
635
+ el.textContent = text;
636
+ const info = LIMIT_MAP[elId];
637
+ if (info) el.style.color = getLimitColor(rawValue, info);
638
+ }
639
+
640
+ function handleLiveData(data) {
641
+ if (data.stats) {
642
+ const sys = data.stats;
643
+ updateSimpleStats(sys);
644
+ updateCpuCores(sys);
645
+ updateRamChart(sys);
646
+ updateDiskCharts(sys);
647
+ updateNetworkChart(sys);
648
+ updateDiskIoChart(sys);
649
+ updateLoadAverage(sys);
650
+ updateThrottleStatus(sys);
651
+ updateTopProcesses(sys);
652
+ updateHardwareInfo(sys);
653
+ }
654
+
655
+ if (data.status) {
656
+ const el = document.getElementById('uptimeApp');
657
+ if (el) el.textContent = formatUptime(Math.floor(data.status.uptime || 0));
658
+ }
659
+
660
+ if (data.stats) {
661
+ if (data.stats.robot_uptime != null) {
662
+ const el = document.getElementById('uptimeRobot');
663
+ if (el) el.textContent = formatUptime(Math.floor(data.stats.robot_uptime));
664
+ }
665
+ if (data.stats.daemon_uptime != null) {
666
+ const el = document.getElementById('uptimeDaemon');
667
+ if (el) el.textContent = formatUptime(Math.floor(data.stats.daemon_uptime));
668
+ }
669
+ }
670
+
671
+ if (data.robot && !data.robot.error) {
672
+ const robot = data.robot;
673
+ updateZenohStatus(true);
674
+
675
+ if (robot.head_pose) {
676
+ const pose = robot.head_pose;
677
+
678
+ // Status tab values
679
+ const ids = { headYaw: pose.yaw, headPitch: pose.pitch, headRoll: pose.roll };
680
+ Object.entries(ids).forEach(([id, val]) => {
681
+ const el = document.getElementById(id);
682
+ if (el) el.textContent = val.toFixed(1) + '\u00B0';
683
+ });
684
+ const posIds = { headPosX: pose.x, headPosY: pose.y, headPosZ: pose.z };
685
+ Object.entries(posIds).forEach(([id, val]) => {
686
+ const el = document.getElementById(id);
687
+ if (el) el.textContent = (val * 100).toFixed(1) + 'cm';
688
+ });
689
+
690
+ // Chart values + data (with limit proximity coloring)
691
+ setValueWithColor('rollValue', pose.roll.toFixed(1) + '\u00B0', pose.roll);
692
+ setValueWithColor('pitchValue', pose.pitch.toFixed(1) + '\u00B0', pose.pitch);
693
+ setValueWithColor('yawValue', pose.yaw.toFixed(1) + '\u00B0', pose.yaw);
694
+ setValueWithColor('posXValue', (pose.x * 100).toFixed(1) + 'cm', pose.x * 100);
695
+ setValueWithColor('posYValue', (pose.y * 100).toFixed(1) + 'cm', pose.y * 100);
696
+ setValueWithColor('posZValue', (pose.z * 100).toFixed(1) + 'cm', pose.z * 100);
697
+ if (rollChart) rollChart.push(pose.roll, null, getLimitRgba(pose.roll, LIMIT_MAP.rollValue));
698
+ if (pitchChart) pitchChart.push(pose.pitch, null, getLimitRgba(pose.pitch, LIMIT_MAP.pitchValue));
699
+ if (yawChart) yawChart.push(pose.yaw, null, getLimitRgba(pose.yaw, LIMIT_MAP.yawValue));
700
+ if (posXChart) posXChart.push(pose.x * 100, null, getLimitRgba(pose.x * 100, LIMIT_MAP.posXValue));
701
+ if (posYChart) posYChart.push(pose.y * 100, null, getLimitRgba(pose.y * 100, LIMIT_MAP.posYValue));
702
+ if (posZChart) posZChart.push(pose.z * 100, null, getLimitRgba(pose.z * 100, LIMIT_MAP.posZValue));
703
+ }
704
+
705
+ if (robot.antenna_joints && robot.antenna_joints.length >= 2) {
706
+ const [rightDeg, leftDeg] = robot.antenna_joints;
707
+ const headAntLEl = document.getElementById('headAntL');
708
+ const headAntREl = document.getElementById('headAntR');
709
+ if (headAntLEl) headAntLEl.textContent = leftDeg.toFixed(1) + '\u00B0';
710
+ if (headAntREl) headAntREl.textContent = rightDeg.toFixed(1) + '\u00B0';
711
+ setValueWithColor('antennaRightValue', rightDeg.toFixed(1) + '\u00B0', rightDeg);
712
+ setValueWithColor('antennaLeftValue', leftDeg.toFixed(1) + '\u00B0', leftDeg);
713
+ if (antennaRightChart) antennaRightChart.push(rightDeg, null, getLimitRgba(rightDeg, LIMIT_MAP.antennaRightValue));
714
+ if (antennaLeftChart) antennaLeftChart.push(leftDeg, null, getLimitRgba(leftDeg, LIMIT_MAP.antennaLeftValue));
715
+ }
716
+
717
+ if (robot.head_joints && robot.head_joints.length > 0) {
718
+ const bodyYaw = robot.head_joints[0];
719
+ const headBodyYawEl = document.getElementById('headBodyYaw');
720
+ if (headBodyYawEl) headBodyYawEl.textContent = bodyYaw.toFixed(1) + '\u00B0';
721
+ }
722
+
723
+ if (robot.head_joints && robot.head_joints.length >= 7 && jointsChart) {
724
+ jointsChart.push(robot.head_joints);
725
+ const legendEl = document.getElementById('jointsLegend');
726
+ if (legendEl) {
727
+ legendEl.innerHTML = robot.head_joints.map((v, i) =>
728
+ `<span style="color: ${jointColors[i]}">${i === 0 ? 'B' : i}: ${v.toFixed(0)}\u00B0</span>`
729
+ ).join(' ');
730
+ }
731
+ }
732
+ } else if (data.robot && data.robot.error) {
733
+ updateZenohStatus(false);
734
+ }
735
+
736
+ // Daemon health (pushed via /ws/live stats, no polling)
737
+ if (data.daemon) {
738
+ setIndicatorStatus('daemonIndicator', data.daemon.api === 'ok' ? 'active' : 'error');
739
+ if (data.daemon.serial === 'ok') setIndicatorStatus('serialIndicator', 'active');
740
+ else if (data.daemon.serial === 'error') setIndicatorStatus('serialIndicator', 'error');
741
+ else setIndicatorStatus('serialIndicator', 'connecting');
742
+ }
743
+
744
+ // Feed data to 3D simulation
745
+ if (window.ReachySimulation && ReachySimulation.isInitialized()) {
746
+ ReachySimulation.updateFromWebSocket(data);
747
+ }
748
+ }
749
+
750
+ function initWebSocket() {
751
+ initCharts();
752
+ connectLiveWebSocket();
753
+
754
+ // Initialize interactive chart controls after charts are created
755
+ setTimeout(initInteractiveCharts, 100);
756
+
757
+ if (zenohCheckInterval) clearInterval(zenohCheckInterval);
758
+ zenohCheckInterval = setInterval(checkZenohHealth, 1000);
759
+
760
+ document.addEventListener('visibilitychange', () => {
761
+ if (document.hidden) {
762
+ setSubscriptions([]);
763
+ } else {
764
+ updateSubscriptionsFromVisibility();
765
+ }
766
+ });
767
+ }
768
+
769
+ function resizeAllCharts() {
770
+ [tempChart, cpuChart, ramChart, netChart, wifiChart,
771
+ loadChart, fanChart, diskIoChart,
772
+ rollChart, pitchChart, yawChart,
773
+ posXChart, posYChart, posZChart,
774
+ antennaRightChart, antennaLeftChart, jointsChart
775
+ ].forEach(chart => { if (chart && chart.resize) chart.resize(); });
776
+ }
777
+
778
+ // Daemon status is now pushed via /ws/live stats payload (no polling)
779
+
780
+ // ===== Interactive Chart Controls =====
781
+ let chartControlActive = false;
782
+ let wheelEndTimeout = null;
783
+
784
+ function clamp(val, min, max) {
785
+ return Math.max(min, Math.min(max, val));
786
+ }
787
+
788
+ function makeChartInteractive(canvasId, property, isAntenna, sensitivity) {
789
+ const canvas = document.getElementById(canvasId);
790
+ if (!canvas || !window.ReachyControls) return;
791
+
792
+ const limits = window.ReachyControls.limits;
793
+ let isDragging = false;
794
+ let lastY = 0;
795
+
796
+ canvas.style.cursor = 'ns-resize';
797
+ canvas.title = 'Drag up/down or scroll to control';
798
+
799
+ function startControl() {
800
+ if (!chartControlActive) {
801
+ chartControlActive = true;
802
+ window.ReachyControls.startChartControl();
803
+ }
804
+ }
805
+
806
+ function endControl() {
807
+ if (chartControlActive) {
808
+ chartControlActive = false;
809
+ const rtc = document.getElementById('returnToCenter');
810
+ if (!rtc || rtc.checked) {
811
+ window.ReachyControls.resetAllPositions();
812
+ }
813
+ }
814
+ }
815
+
816
+ function applyElasticResistance(currentVal, maxNeg, maxPos, baseDelta) {
817
+ // Determine which limit we're approaching based on direction
818
+ const limit = baseDelta > 0 ? maxPos : maxNeg;
819
+ const ratio = Math.abs(currentVal) / Math.abs(limit);
820
+ // Quadratic falloff: full speed at center, ~25% speed at 85%, near-zero at limit
821
+ const resistance = Math.max(0.05, 1 - ratio * ratio);
822
+ return baseDelta * resistance;
823
+ }
824
+
825
+ function updateTarget(delta) {
826
+ const targets = window.ReachyControls.getTargets();
827
+
828
+ if (isAntenna) {
829
+ const maxAnt = limits.ANTENNA_MAX;
830
+ const current = property === 'left' ? targets.antennaL : targets.antennaR;
831
+ const baseDelta = delta * sensitivity * 2;
832
+ const elasticDelta = applyElasticResistance(current, -maxAnt, maxAnt, baseDelta);
833
+ if (property === 'left') {
834
+ window.ReachyControls.setAntennaTargets(clamp(targets.antennaL + elasticDelta, -maxAnt, maxAnt), undefined);
835
+ } else {
836
+ window.ReachyControls.setAntennaTargets(undefined, clamp(targets.antennaR + elasticDelta, -maxAnt, maxAnt));
837
+ }
838
+ } else {
839
+ // Asymmetric limits for roll, symmetric for others
840
+ let maxPos, maxNeg;
841
+ switch(property) {
842
+ case 'yaw': maxPos = limits.HEAD_YAW_MAX; maxNeg = -maxPos; break;
843
+ case 'pitch': maxPos = limits.HEAD_PITCH_MAX; maxNeg = -maxPos; break;
844
+ case 'roll': maxPos = limits.HEAD_ROLL_LEFT_MAX; maxNeg = -limits.HEAD_ROLL_RIGHT_MAX; break;
845
+ case 'x': maxPos = limits.HEAD_X_MAX; maxNeg = -maxPos; break;
846
+ case 'y': maxPos = limits.HEAD_Y_MAX; maxNeg = -maxPos; break;
847
+ case 'z': maxPos = limits.HEAD_Z_MAX; maxNeg = -maxPos; break;
848
+ default: maxPos = 1; maxNeg = -1;
849
+ }
850
+ const range = maxPos - maxNeg;
851
+ const baseDelta = delta * sensitivity * (range / 100);
852
+ const elasticDelta = applyElasticResistance(targets[property], maxNeg, maxPos, baseDelta);
853
+ window.ReachyControls.setHeadTargets({ [property]: clamp(targets[property] + elasticDelta, maxNeg, maxPos) });
854
+ }
855
+ }
856
+
857
+ canvas.addEventListener('mousedown', (e) => {
858
+ isDragging = true;
859
+ lastY = e.clientY;
860
+ canvas.style.opacity = '0.8';
861
+ startControl();
862
+ e.preventDefault();
863
+ });
864
+
865
+ document.addEventListener('mousemove', (e) => {
866
+ if (!isDragging) return;
867
+ const deltaY = lastY - e.clientY;
868
+ lastY = e.clientY;
869
+ updateTarget(deltaY);
870
+ });
871
+
872
+ document.addEventListener('mouseup', () => {
873
+ if (isDragging) {
874
+ isDragging = false;
875
+ canvas.style.opacity = '1';
876
+ endControl();
877
+ }
878
+ });
879
+
880
+ canvas.addEventListener('wheel', (e) => {
881
+ e.preventDefault();
882
+ startControl();
883
+ if (wheelEndTimeout) clearTimeout(wheelEndTimeout);
884
+ updateTarget(-e.deltaY * 0.05 * sensitivity);
885
+ wheelEndTimeout = setTimeout(endControl, 300);
886
+ }, { passive: false });
887
+ }
888
+
889
+ function initInteractiveCharts() {
890
+ if (!window.ReachyControls) {
891
+ setTimeout(initInteractiveCharts, 100);
892
+ return;
893
+ }
894
+
895
+ makeChartInteractive('rollChart', 'roll', false, 1);
896
+ makeChartInteractive('pitchChart', 'pitch', false, 1);
897
+ makeChartInteractive('yawChart', 'yaw', false, 1);
898
+ makeChartInteractive('posXChart', 'x', false, 0.5);
899
+ makeChartInteractive('posYChart', 'y', false, 0.5);
900
+ makeChartInteractive('posZChart', 'z', false, 0.5);
901
+ makeChartInteractive('antennaRightChart', 'right', true, 1);
902
+ makeChartInteractive('antennaLeftChart', 'left', true, 1);
903
+
904
+ // When return-to-center is toggled ON, immediately center
905
+ const rtc = document.getElementById('returnToCenter');
906
+ if (rtc) {
907
+ rtc.addEventListener('change', () => {
908
+ if (rtc.checked) {
909
+ window.ReachyControls.resetAllPositions();
910
+ }
911
+ });
912
+ }
913
+
914
+ console.log('Interactive chart controls initialized');
915
+ }
916
+
917
+ window.ReachyWebSocket = {
918
+ init: initWebSocket,
919
+ resizeCharts: resizeAllCharts,
920
+ setSubscriptions,
921
+ updateSubscriptionsFromVisibility
922
+ };
923
+ })();
hello_world/static/lib/mujoco/mujoco_wasm.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e15bc5597a75e3e893692b878ac74aa69081b489b88812452147d1fb91854d42
3
+ size 11006077
hello_world/static/lib/three/addons/controls/OrbitControls.js ADDED
@@ -0,0 +1,1417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ EventDispatcher,
3
+ MOUSE,
4
+ Quaternion,
5
+ Spherical,
6
+ TOUCH,
7
+ Vector2,
8
+ Vector3,
9
+ Plane,
10
+ Ray,
11
+ MathUtils
12
+ } from 'three';
13
+
14
+ // OrbitControls performs orbiting, dollying (zooming), and panning.
15
+ // Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
16
+ //
17
+ // Orbit - left mouse / touch: one-finger move
18
+ // Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish
19
+ // Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move
20
+
21
+ const _changeEvent = { type: 'change' };
22
+ const _startEvent = { type: 'start' };
23
+ const _endEvent = { type: 'end' };
24
+ const _ray = new Ray();
25
+ const _plane = new Plane();
26
+ const TILT_LIMIT = Math.cos( 70 * MathUtils.DEG2RAD );
27
+
28
+ class OrbitControls extends EventDispatcher {
29
+
30
+ constructor( object, domElement ) {
31
+
32
+ super();
33
+
34
+ this.object = object;
35
+ this.domElement = domElement;
36
+ this.domElement.style.touchAction = 'none'; // disable touch scroll
37
+
38
+ // Set to false to disable this control
39
+ this.enabled = true;
40
+
41
+ // "target" sets the location of focus, where the object orbits around
42
+ this.target = new Vector3();
43
+
44
+ // Sets the 3D cursor (similar to Blender), from which the maxTargetRadius takes effect
45
+ this.cursor = new Vector3();
46
+
47
+ // How far you can dolly in and out ( PerspectiveCamera only )
48
+ this.minDistance = 0;
49
+ this.maxDistance = Infinity;
50
+
51
+ // How far you can zoom in and out ( OrthographicCamera only )
52
+ this.minZoom = 0;
53
+ this.maxZoom = Infinity;
54
+
55
+ // Limit camera target within a spherical area around the cursor
56
+ this.minTargetRadius = 0;
57
+ this.maxTargetRadius = Infinity;
58
+
59
+ // How far you can orbit vertically, upper and lower limits.
60
+ // Range is 0 to Math.PI radians.
61
+ this.minPolarAngle = 0; // radians
62
+ this.maxPolarAngle = Math.PI; // radians
63
+
64
+ // How far you can orbit horizontally, upper and lower limits.
65
+ // If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI )
66
+ this.minAzimuthAngle = - Infinity; // radians
67
+ this.maxAzimuthAngle = Infinity; // radians
68
+
69
+ // Set to true to enable damping (inertia)
70
+ // If damping is enabled, you must call controls.update() in your animation loop
71
+ this.enableDamping = false;
72
+ this.dampingFactor = 0.05;
73
+
74
+ // This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
75
+ // Set to false to disable zooming
76
+ this.enableZoom = true;
77
+ this.zoomSpeed = 1.0;
78
+
79
+ // Set to false to disable rotating
80
+ this.enableRotate = true;
81
+ this.rotateSpeed = 1.0;
82
+
83
+ // Set to false to disable panning
84
+ this.enablePan = true;
85
+ this.panSpeed = 1.0;
86
+ this.screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up
87
+ this.keyPanSpeed = 7.0; // pixels moved per arrow key push
88
+ this.zoomToCursor = false;
89
+
90
+ // Set to true to automatically rotate around the target
91
+ // If auto-rotate is enabled, you must call controls.update() in your animation loop
92
+ this.autoRotate = false;
93
+ this.autoRotateSpeed = 2.0; // 30 seconds per orbit when fps is 60
94
+
95
+ // The four arrow keys
96
+ this.keys = { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown' };
97
+
98
+ // Mouse buttons
99
+ this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN };
100
+
101
+ // Touch fingers
102
+ this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN };
103
+
104
+ // for reset
105
+ this.target0 = this.target.clone();
106
+ this.position0 = this.object.position.clone();
107
+ this.zoom0 = this.object.zoom;
108
+
109
+ // the target DOM element for key events
110
+ this._domElementKeyEvents = null;
111
+
112
+ //
113
+ // public methods
114
+ //
115
+
116
+ this.getPolarAngle = function () {
117
+
118
+ return spherical.phi;
119
+
120
+ };
121
+
122
+ this.getAzimuthalAngle = function () {
123
+
124
+ return spherical.theta;
125
+
126
+ };
127
+
128
+ this.getDistance = function () {
129
+
130
+ return this.object.position.distanceTo( this.target );
131
+
132
+ };
133
+
134
+ this.listenToKeyEvents = function ( domElement ) {
135
+
136
+ domElement.addEventListener( 'keydown', onKeyDown );
137
+ this._domElementKeyEvents = domElement;
138
+
139
+ };
140
+
141
+ this.stopListenToKeyEvents = function () {
142
+
143
+ this._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown );
144
+ this._domElementKeyEvents = null;
145
+
146
+ };
147
+
148
+ this.saveState = function () {
149
+
150
+ scope.target0.copy( scope.target );
151
+ scope.position0.copy( scope.object.position );
152
+ scope.zoom0 = scope.object.zoom;
153
+
154
+ };
155
+
156
+ this.reset = function () {
157
+
158
+ scope.target.copy( scope.target0 );
159
+ scope.object.position.copy( scope.position0 );
160
+ scope.object.zoom = scope.zoom0;
161
+
162
+ scope.object.updateProjectionMatrix();
163
+ scope.dispatchEvent( _changeEvent );
164
+
165
+ scope.update();
166
+
167
+ state = STATE.NONE;
168
+
169
+ };
170
+
171
+ // this method is exposed, but perhaps it would be better if we can make it private...
172
+ this.update = function () {
173
+
174
+ const offset = new Vector3();
175
+
176
+ // so camera.up is the orbit axis
177
+ const quat = new Quaternion().setFromUnitVectors( object.up, new Vector3( 0, 1, 0 ) );
178
+ const quatInverse = quat.clone().invert();
179
+
180
+ const lastPosition = new Vector3();
181
+ const lastQuaternion = new Quaternion();
182
+ const lastTargetPosition = new Vector3();
183
+
184
+ const twoPI = 2 * Math.PI;
185
+
186
+ return function update( deltaTime = null ) {
187
+
188
+ const position = scope.object.position;
189
+
190
+ offset.copy( position ).sub( scope.target );
191
+
192
+ // rotate offset to "y-axis-is-up" space
193
+ offset.applyQuaternion( quat );
194
+
195
+ // angle from z-axis around y-axis
196
+ spherical.setFromVector3( offset );
197
+
198
+ if ( scope.autoRotate && state === STATE.NONE ) {
199
+
200
+ rotateLeft( getAutoRotationAngle( deltaTime ) );
201
+
202
+ }
203
+
204
+ if ( scope.enableDamping ) {
205
+
206
+ spherical.theta += sphericalDelta.theta * scope.dampingFactor;
207
+ spherical.phi += sphericalDelta.phi * scope.dampingFactor;
208
+
209
+ } else {
210
+
211
+ spherical.theta += sphericalDelta.theta;
212
+ spherical.phi += sphericalDelta.phi;
213
+
214
+ }
215
+
216
+ // restrict theta to be between desired limits
217
+
218
+ let min = scope.minAzimuthAngle;
219
+ let max = scope.maxAzimuthAngle;
220
+
221
+ if ( isFinite( min ) && isFinite( max ) ) {
222
+
223
+ if ( min < - Math.PI ) min += twoPI; else if ( min > Math.PI ) min -= twoPI;
224
+
225
+ if ( max < - Math.PI ) max += twoPI; else if ( max > Math.PI ) max -= twoPI;
226
+
227
+ if ( min <= max ) {
228
+
229
+ spherical.theta = Math.max( min, Math.min( max, spherical.theta ) );
230
+
231
+ } else {
232
+
233
+ spherical.theta = ( spherical.theta > ( min + max ) / 2 ) ?
234
+ Math.max( min, spherical.theta ) :
235
+ Math.min( max, spherical.theta );
236
+
237
+ }
238
+
239
+ }
240
+
241
+ // restrict phi to be between desired limits
242
+ spherical.phi = Math.max( scope.minPolarAngle, Math.min( scope.maxPolarAngle, spherical.phi ) );
243
+
244
+ spherical.makeSafe();
245
+
246
+
247
+ // move target to panned location
248
+
249
+ if ( scope.enableDamping === true ) {
250
+
251
+ scope.target.addScaledVector( panOffset, scope.dampingFactor );
252
+
253
+ } else {
254
+
255
+ scope.target.add( panOffset );
256
+
257
+ }
258
+
259
+ // Limit the target distance from the cursor to create a sphere around the center of interest
260
+ scope.target.sub( scope.cursor );
261
+ scope.target.clampLength( scope.minTargetRadius, scope.maxTargetRadius );
262
+ scope.target.add( scope.cursor );
263
+
264
+ // adjust the camera position based on zoom only if we're not zooming to the cursor or if it's an ortho camera
265
+ // we adjust zoom later in these cases
266
+ if ( scope.zoomToCursor && performCursorZoom || scope.object.isOrthographicCamera ) {
267
+
268
+ spherical.radius = clampDistance( spherical.radius );
269
+
270
+ } else {
271
+
272
+ spherical.radius = clampDistance( spherical.radius * scale );
273
+
274
+ }
275
+
276
+ offset.setFromSpherical( spherical );
277
+
278
+ // rotate offset back to "camera-up-vector-is-up" space
279
+ offset.applyQuaternion( quatInverse );
280
+
281
+ position.copy( scope.target ).add( offset );
282
+
283
+ scope.object.lookAt( scope.target );
284
+
285
+ if ( scope.enableDamping === true ) {
286
+
287
+ sphericalDelta.theta *= ( 1 - scope.dampingFactor );
288
+ sphericalDelta.phi *= ( 1 - scope.dampingFactor );
289
+
290
+ panOffset.multiplyScalar( 1 - scope.dampingFactor );
291
+
292
+ } else {
293
+
294
+ sphericalDelta.set( 0, 0, 0 );
295
+
296
+ panOffset.set( 0, 0, 0 );
297
+
298
+ }
299
+
300
+ // adjust camera position
301
+ let zoomChanged = false;
302
+ if ( scope.zoomToCursor && performCursorZoom ) {
303
+
304
+ let newRadius = null;
305
+ if ( scope.object.isPerspectiveCamera ) {
306
+
307
+ // move the camera down the pointer ray
308
+ // this method avoids floating point error
309
+ const prevRadius = offset.length();
310
+ newRadius = clampDistance( prevRadius * scale );
311
+
312
+ const radiusDelta = prevRadius - newRadius;
313
+ scope.object.position.addScaledVector( dollyDirection, radiusDelta );
314
+ scope.object.updateMatrixWorld();
315
+
316
+ } else if ( scope.object.isOrthographicCamera ) {
317
+
318
+ // adjust the ortho camera position based on zoom changes
319
+ const mouseBefore = new Vector3( mouse.x, mouse.y, 0 );
320
+ mouseBefore.unproject( scope.object );
321
+
322
+ scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / scale ) );
323
+ scope.object.updateProjectionMatrix();
324
+ zoomChanged = true;
325
+
326
+ const mouseAfter = new Vector3( mouse.x, mouse.y, 0 );
327
+ mouseAfter.unproject( scope.object );
328
+
329
+ scope.object.position.sub( mouseAfter ).add( mouseBefore );
330
+ scope.object.updateMatrixWorld();
331
+
332
+ newRadius = offset.length();
333
+
334
+ } else {
335
+
336
+ console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - zoom to cursor disabled.' );
337
+ scope.zoomToCursor = false;
338
+
339
+ }
340
+
341
+ // handle the placement of the target
342
+ if ( newRadius !== null ) {
343
+
344
+ if ( this.screenSpacePanning ) {
345
+
346
+ // position the orbit target in front of the new camera position
347
+ scope.target.set( 0, 0, - 1 )
348
+ .transformDirection( scope.object.matrix )
349
+ .multiplyScalar( newRadius )
350
+ .add( scope.object.position );
351
+
352
+ } else {
353
+
354
+ // get the ray and translation plane to compute target
355
+ _ray.origin.copy( scope.object.position );
356
+ _ray.direction.set( 0, 0, - 1 ).transformDirection( scope.object.matrix );
357
+
358
+ // if the camera is 20 degrees above the horizon then don't adjust the focus target to avoid
359
+ // extremely large values
360
+ if ( Math.abs( scope.object.up.dot( _ray.direction ) ) < TILT_LIMIT ) {
361
+
362
+ object.lookAt( scope.target );
363
+
364
+ } else {
365
+
366
+ _plane.setFromNormalAndCoplanarPoint( scope.object.up, scope.target );
367
+ _ray.intersectPlane( _plane, scope.target );
368
+
369
+ }
370
+
371
+ }
372
+
373
+ }
374
+
375
+ } else if ( scope.object.isOrthographicCamera ) {
376
+
377
+ scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / scale ) );
378
+ scope.object.updateProjectionMatrix();
379
+ zoomChanged = true;
380
+
381
+ }
382
+
383
+ scale = 1;
384
+ performCursorZoom = false;
385
+
386
+ // update condition is:
387
+ // min(camera displacement, camera rotation in radians)^2 > EPS
388
+ // using small-angle approximation cos(x/2) = 1 - x^2 / 8
389
+
390
+ if ( zoomChanged ||
391
+ lastPosition.distanceToSquared( scope.object.position ) > EPS ||
392
+ 8 * ( 1 - lastQuaternion.dot( scope.object.quaternion ) ) > EPS ||
393
+ lastTargetPosition.distanceToSquared( scope.target ) > 0 ) {
394
+
395
+ scope.dispatchEvent( _changeEvent );
396
+
397
+ lastPosition.copy( scope.object.position );
398
+ lastQuaternion.copy( scope.object.quaternion );
399
+ lastTargetPosition.copy( scope.target );
400
+
401
+ return true;
402
+
403
+ }
404
+
405
+ return false;
406
+
407
+ };
408
+
409
+ }();
410
+
411
+ this.dispose = function () {
412
+
413
+ scope.domElement.removeEventListener( 'contextmenu', onContextMenu );
414
+
415
+ scope.domElement.removeEventListener( 'pointerdown', onPointerDown );
416
+ scope.domElement.removeEventListener( 'pointercancel', onPointerUp );
417
+ scope.domElement.removeEventListener( 'wheel', onMouseWheel );
418
+
419
+ scope.domElement.removeEventListener( 'pointermove', onPointerMove );
420
+ scope.domElement.removeEventListener( 'pointerup', onPointerUp );
421
+
422
+
423
+ if ( scope._domElementKeyEvents !== null ) {
424
+
425
+ scope._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown );
426
+ scope._domElementKeyEvents = null;
427
+
428
+ }
429
+
430
+ //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here?
431
+
432
+ };
433
+
434
+ //
435
+ // internals
436
+ //
437
+
438
+ const scope = this;
439
+
440
+ const STATE = {
441
+ NONE: - 1,
442
+ ROTATE: 0,
443
+ DOLLY: 1,
444
+ PAN: 2,
445
+ TOUCH_ROTATE: 3,
446
+ TOUCH_PAN: 4,
447
+ TOUCH_DOLLY_PAN: 5,
448
+ TOUCH_DOLLY_ROTATE: 6
449
+ };
450
+
451
+ let state = STATE.NONE;
452
+
453
+ const EPS = 0.000001;
454
+
455
+ // current position in spherical coordinates
456
+ const spherical = new Spherical();
457
+ const sphericalDelta = new Spherical();
458
+
459
+ let scale = 1;
460
+ const panOffset = new Vector3();
461
+
462
+ const rotateStart = new Vector2();
463
+ const rotateEnd = new Vector2();
464
+ const rotateDelta = new Vector2();
465
+
466
+ const panStart = new Vector2();
467
+ const panEnd = new Vector2();
468
+ const panDelta = new Vector2();
469
+
470
+ const dollyStart = new Vector2();
471
+ const dollyEnd = new Vector2();
472
+ const dollyDelta = new Vector2();
473
+
474
+ const dollyDirection = new Vector3();
475
+ const mouse = new Vector2();
476
+ let performCursorZoom = false;
477
+
478
+ const pointers = [];
479
+ const pointerPositions = {};
480
+
481
+ function getAutoRotationAngle( deltaTime ) {
482
+
483
+ if ( deltaTime !== null ) {
484
+
485
+ return ( 2 * Math.PI / 60 * scope.autoRotateSpeed ) * deltaTime;
486
+
487
+ } else {
488
+
489
+ return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed;
490
+
491
+ }
492
+
493
+ }
494
+
495
+ function getZoomScale( delta ) {
496
+
497
+ const normalized_delta = Math.abs( delta ) / ( 100 * ( window.devicePixelRatio | 0 ) );
498
+ return Math.pow( 0.95, scope.zoomSpeed * normalized_delta );
499
+
500
+ }
501
+
502
+ function rotateLeft( angle ) {
503
+
504
+ sphericalDelta.theta -= angle;
505
+
506
+ }
507
+
508
+ function rotateUp( angle ) {
509
+
510
+ sphericalDelta.phi -= angle;
511
+
512
+ }
513
+
514
+ const panLeft = function () {
515
+
516
+ const v = new Vector3();
517
+
518
+ return function panLeft( distance, objectMatrix ) {
519
+
520
+ v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix
521
+ v.multiplyScalar( - distance );
522
+
523
+ panOffset.add( v );
524
+
525
+ };
526
+
527
+ }();
528
+
529
+ const panUp = function () {
530
+
531
+ const v = new Vector3();
532
+
533
+ return function panUp( distance, objectMatrix ) {
534
+
535
+ if ( scope.screenSpacePanning === true ) {
536
+
537
+ v.setFromMatrixColumn( objectMatrix, 1 );
538
+
539
+ } else {
540
+
541
+ v.setFromMatrixColumn( objectMatrix, 0 );
542
+ v.crossVectors( scope.object.up, v );
543
+
544
+ }
545
+
546
+ v.multiplyScalar( distance );
547
+
548
+ panOffset.add( v );
549
+
550
+ };
551
+
552
+ }();
553
+
554
+ // deltaX and deltaY are in pixels; right and down are positive
555
+ const pan = function () {
556
+
557
+ const offset = new Vector3();
558
+
559
+ return function pan( deltaX, deltaY ) {
560
+
561
+ const element = scope.domElement;
562
+
563
+ if ( scope.object.isPerspectiveCamera ) {
564
+
565
+ // perspective
566
+ const position = scope.object.position;
567
+ offset.copy( position ).sub( scope.target );
568
+ let targetDistance = offset.length();
569
+
570
+ // half of the fov is center to top of screen
571
+ targetDistance *= Math.tan( ( scope.object.fov / 2 ) * Math.PI / 180.0 );
572
+
573
+ // we use only clientHeight here so aspect ratio does not distort speed
574
+ panLeft( 2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix );
575
+ panUp( 2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix );
576
+
577
+ } else if ( scope.object.isOrthographicCamera ) {
578
+
579
+ // orthographic
580
+ panLeft( deltaX * ( scope.object.right - scope.object.left ) / scope.object.zoom / element.clientWidth, scope.object.matrix );
581
+ panUp( deltaY * ( scope.object.top - scope.object.bottom ) / scope.object.zoom / element.clientHeight, scope.object.matrix );
582
+
583
+ } else {
584
+
585
+ // camera neither orthographic nor perspective
586
+ console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' );
587
+ scope.enablePan = false;
588
+
589
+ }
590
+
591
+ };
592
+
593
+ }();
594
+
595
+ function dollyOut( dollyScale ) {
596
+
597
+ if ( scope.object.isPerspectiveCamera || scope.object.isOrthographicCamera ) {
598
+
599
+ scale /= dollyScale;
600
+
601
+ } else {
602
+
603
+ console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
604
+ scope.enableZoom = false;
605
+
606
+ }
607
+
608
+ }
609
+
610
+ function dollyIn( dollyScale ) {
611
+
612
+ if ( scope.object.isPerspectiveCamera || scope.object.isOrthographicCamera ) {
613
+
614
+ scale *= dollyScale;
615
+
616
+ } else {
617
+
618
+ console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
619
+ scope.enableZoom = false;
620
+
621
+ }
622
+
623
+ }
624
+
625
+ function updateZoomParameters( x, y ) {
626
+
627
+ if ( ! scope.zoomToCursor ) {
628
+
629
+ return;
630
+
631
+ }
632
+
633
+ performCursorZoom = true;
634
+
635
+ const rect = scope.domElement.getBoundingClientRect();
636
+ const dx = x - rect.left;
637
+ const dy = y - rect.top;
638
+ const w = rect.width;
639
+ const h = rect.height;
640
+
641
+ mouse.x = ( dx / w ) * 2 - 1;
642
+ mouse.y = - ( dy / h ) * 2 + 1;
643
+
644
+ dollyDirection.set( mouse.x, mouse.y, 1 ).unproject( scope.object ).sub( scope.object.position ).normalize();
645
+
646
+ }
647
+
648
+ function clampDistance( dist ) {
649
+
650
+ return Math.max( scope.minDistance, Math.min( scope.maxDistance, dist ) );
651
+
652
+ }
653
+
654
+ //
655
+ // event callbacks - update the object state
656
+ //
657
+
658
+ function handleMouseDownRotate( event ) {
659
+
660
+ rotateStart.set( event.clientX, event.clientY );
661
+
662
+ }
663
+
664
+ function handleMouseDownDolly( event ) {
665
+
666
+ updateZoomParameters( event.clientX, event.clientX );
667
+ dollyStart.set( event.clientX, event.clientY );
668
+
669
+ }
670
+
671
+ function handleMouseDownPan( event ) {
672
+
673
+ panStart.set( event.clientX, event.clientY );
674
+
675
+ }
676
+
677
+ function handleMouseMoveRotate( event ) {
678
+
679
+ rotateEnd.set( event.clientX, event.clientY );
680
+
681
+ rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed );
682
+
683
+ const element = scope.domElement;
684
+
685
+ rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height
686
+
687
+ rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight );
688
+
689
+ rotateStart.copy( rotateEnd );
690
+
691
+ scope.update();
692
+
693
+ }
694
+
695
+ function handleMouseMoveDolly( event ) {
696
+
697
+ dollyEnd.set( event.clientX, event.clientY );
698
+
699
+ dollyDelta.subVectors( dollyEnd, dollyStart );
700
+
701
+ if ( dollyDelta.y > 0 ) {
702
+
703
+ dollyOut( getZoomScale( dollyDelta.y ) );
704
+
705
+ } else if ( dollyDelta.y < 0 ) {
706
+
707
+ dollyIn( getZoomScale( dollyDelta.y ) );
708
+
709
+ }
710
+
711
+ dollyStart.copy( dollyEnd );
712
+
713
+ scope.update();
714
+
715
+ }
716
+
717
+ function handleMouseMovePan( event ) {
718
+
719
+ panEnd.set( event.clientX, event.clientY );
720
+
721
+ panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed );
722
+
723
+ pan( panDelta.x, panDelta.y );
724
+
725
+ panStart.copy( panEnd );
726
+
727
+ scope.update();
728
+
729
+ }
730
+
731
+ function handleMouseWheel( event ) {
732
+
733
+ updateZoomParameters( event.clientX, event.clientY );
734
+
735
+ if ( event.deltaY < 0 ) {
736
+
737
+ dollyIn( getZoomScale( event.deltaY ) );
738
+
739
+ } else if ( event.deltaY > 0 ) {
740
+
741
+ dollyOut( getZoomScale( event.deltaY ) );
742
+
743
+ }
744
+
745
+ scope.update();
746
+
747
+ }
748
+
749
+ function handleKeyDown( event ) {
750
+
751
+ let needsUpdate = false;
752
+
753
+ switch ( event.code ) {
754
+
755
+ case scope.keys.UP:
756
+
757
+ if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
758
+
759
+ rotateUp( 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
760
+
761
+ } else {
762
+
763
+ pan( 0, scope.keyPanSpeed );
764
+
765
+ }
766
+
767
+ needsUpdate = true;
768
+ break;
769
+
770
+ case scope.keys.BOTTOM:
771
+
772
+ if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
773
+
774
+ rotateUp( - 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
775
+
776
+ } else {
777
+
778
+ pan( 0, - scope.keyPanSpeed );
779
+
780
+ }
781
+
782
+ needsUpdate = true;
783
+ break;
784
+
785
+ case scope.keys.LEFT:
786
+
787
+ if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
788
+
789
+ rotateLeft( 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
790
+
791
+ } else {
792
+
793
+ pan( scope.keyPanSpeed, 0 );
794
+
795
+ }
796
+
797
+ needsUpdate = true;
798
+ break;
799
+
800
+ case scope.keys.RIGHT:
801
+
802
+ if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
803
+
804
+ rotateLeft( - 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
805
+
806
+ } else {
807
+
808
+ pan( - scope.keyPanSpeed, 0 );
809
+
810
+ }
811
+
812
+ needsUpdate = true;
813
+ break;
814
+
815
+ }
816
+
817
+ if ( needsUpdate ) {
818
+
819
+ // prevent the browser from scrolling on cursor keys
820
+ event.preventDefault();
821
+
822
+ scope.update();
823
+
824
+ }
825
+
826
+
827
+ }
828
+
829
+ function handleTouchStartRotate( event ) {
830
+
831
+ if ( pointers.length === 1 ) {
832
+
833
+ rotateStart.set( event.pageX, event.pageY );
834
+
835
+ } else {
836
+
837
+ const position = getSecondPointerPosition( event );
838
+
839
+ const x = 0.5 * ( event.pageX + position.x );
840
+ const y = 0.5 * ( event.pageY + position.y );
841
+
842
+ rotateStart.set( x, y );
843
+
844
+ }
845
+
846
+ }
847
+
848
+ function handleTouchStartPan( event ) {
849
+
850
+ if ( pointers.length === 1 ) {
851
+
852
+ panStart.set( event.pageX, event.pageY );
853
+
854
+ } else {
855
+
856
+ const position = getSecondPointerPosition( event );
857
+
858
+ const x = 0.5 * ( event.pageX + position.x );
859
+ const y = 0.5 * ( event.pageY + position.y );
860
+
861
+ panStart.set( x, y );
862
+
863
+ }
864
+
865
+ }
866
+
867
+ function handleTouchStartDolly( event ) {
868
+
869
+ const position = getSecondPointerPosition( event );
870
+
871
+ const dx = event.pageX - position.x;
872
+ const dy = event.pageY - position.y;
873
+
874
+ const distance = Math.sqrt( dx * dx + dy * dy );
875
+
876
+ dollyStart.set( 0, distance );
877
+
878
+ }
879
+
880
+ function handleTouchStartDollyPan( event ) {
881
+
882
+ if ( scope.enableZoom ) handleTouchStartDolly( event );
883
+
884
+ if ( scope.enablePan ) handleTouchStartPan( event );
885
+
886
+ }
887
+
888
+ function handleTouchStartDollyRotate( event ) {
889
+
890
+ if ( scope.enableZoom ) handleTouchStartDolly( event );
891
+
892
+ if ( scope.enableRotate ) handleTouchStartRotate( event );
893
+
894
+ }
895
+
896
+ function handleTouchMoveRotate( event ) {
897
+
898
+ if ( pointers.length == 1 ) {
899
+
900
+ rotateEnd.set( event.pageX, event.pageY );
901
+
902
+ } else {
903
+
904
+ const position = getSecondPointerPosition( event );
905
+
906
+ const x = 0.5 * ( event.pageX + position.x );
907
+ const y = 0.5 * ( event.pageY + position.y );
908
+
909
+ rotateEnd.set( x, y );
910
+
911
+ }
912
+
913
+ rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed );
914
+
915
+ const element = scope.domElement;
916
+
917
+ rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height
918
+
919
+ rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight );
920
+
921
+ rotateStart.copy( rotateEnd );
922
+
923
+ }
924
+
925
+ function handleTouchMovePan( event ) {
926
+
927
+ if ( pointers.length === 1 ) {
928
+
929
+ panEnd.set( event.pageX, event.pageY );
930
+
931
+ } else {
932
+
933
+ const position = getSecondPointerPosition( event );
934
+
935
+ const x = 0.5 * ( event.pageX + position.x );
936
+ const y = 0.5 * ( event.pageY + position.y );
937
+
938
+ panEnd.set( x, y );
939
+
940
+ }
941
+
942
+ panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed );
943
+
944
+ pan( panDelta.x, panDelta.y );
945
+
946
+ panStart.copy( panEnd );
947
+
948
+ }
949
+
950
+ function handleTouchMoveDolly( event ) {
951
+
952
+ const position = getSecondPointerPosition( event );
953
+
954
+ const dx = event.pageX - position.x;
955
+ const dy = event.pageY - position.y;
956
+
957
+ const distance = Math.sqrt( dx * dx + dy * dy );
958
+
959
+ dollyEnd.set( 0, distance );
960
+
961
+ dollyDelta.set( 0, Math.pow( dollyEnd.y / dollyStart.y, scope.zoomSpeed ) );
962
+
963
+ dollyOut( dollyDelta.y );
964
+
965
+ dollyStart.copy( dollyEnd );
966
+
967
+ const centerX = ( event.pageX + position.x ) * 0.5;
968
+ const centerY = ( event.pageY + position.y ) * 0.5;
969
+
970
+ updateZoomParameters( centerX, centerY );
971
+
972
+ }
973
+
974
+ function handleTouchMoveDollyPan( event ) {
975
+
976
+ if ( scope.enableZoom ) handleTouchMoveDolly( event );
977
+
978
+ if ( scope.enablePan ) handleTouchMovePan( event );
979
+
980
+ }
981
+
982
+ function handleTouchMoveDollyRotate( event ) {
983
+
984
+ if ( scope.enableZoom ) handleTouchMoveDolly( event );
985
+
986
+ if ( scope.enableRotate ) handleTouchMoveRotate( event );
987
+
988
+ }
989
+
990
+ //
991
+ // event handlers - FSM: listen for events and reset state
992
+ //
993
+
994
+ function onPointerDown( event ) {
995
+
996
+ if ( scope.enabled === false ) return;
997
+
998
+ if ( pointers.length === 0 ) {
999
+
1000
+ scope.domElement.setPointerCapture( event.pointerId );
1001
+
1002
+ scope.domElement.addEventListener( 'pointermove', onPointerMove );
1003
+ scope.domElement.addEventListener( 'pointerup', onPointerUp );
1004
+
1005
+ }
1006
+
1007
+ //
1008
+
1009
+ addPointer( event );
1010
+
1011
+ if ( event.pointerType === 'touch' ) {
1012
+
1013
+ onTouchStart( event );
1014
+
1015
+ } else {
1016
+
1017
+ onMouseDown( event );
1018
+
1019
+ }
1020
+
1021
+ }
1022
+
1023
+ function onPointerMove( event ) {
1024
+
1025
+ if ( scope.enabled === false ) return;
1026
+
1027
+ if ( event.pointerType === 'touch' ) {
1028
+
1029
+ onTouchMove( event );
1030
+
1031
+ } else {
1032
+
1033
+ onMouseMove( event );
1034
+
1035
+ }
1036
+
1037
+ }
1038
+
1039
+ function onPointerUp( event ) {
1040
+
1041
+ removePointer( event );
1042
+
1043
+ if ( pointers.length === 0 ) {
1044
+
1045
+ scope.domElement.releasePointerCapture( event.pointerId );
1046
+
1047
+ scope.domElement.removeEventListener( 'pointermove', onPointerMove );
1048
+ scope.domElement.removeEventListener( 'pointerup', onPointerUp );
1049
+
1050
+ }
1051
+
1052
+ scope.dispatchEvent( _endEvent );
1053
+
1054
+ state = STATE.NONE;
1055
+
1056
+ }
1057
+
1058
+ function onMouseDown( event ) {
1059
+
1060
+ let mouseAction;
1061
+
1062
+ switch ( event.button ) {
1063
+
1064
+ case 0:
1065
+
1066
+ mouseAction = scope.mouseButtons.LEFT;
1067
+ break;
1068
+
1069
+ case 1:
1070
+
1071
+ mouseAction = scope.mouseButtons.MIDDLE;
1072
+ break;
1073
+
1074
+ case 2:
1075
+
1076
+ mouseAction = scope.mouseButtons.RIGHT;
1077
+ break;
1078
+
1079
+ default:
1080
+
1081
+ mouseAction = - 1;
1082
+
1083
+ }
1084
+
1085
+ switch ( mouseAction ) {
1086
+
1087
+ case MOUSE.DOLLY:
1088
+
1089
+ if ( scope.enableZoom === false ) return;
1090
+
1091
+ handleMouseDownDolly( event );
1092
+
1093
+ state = STATE.DOLLY;
1094
+
1095
+ break;
1096
+
1097
+ case MOUSE.ROTATE:
1098
+
1099
+ if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
1100
+
1101
+ if ( scope.enablePan === false ) return;
1102
+
1103
+ handleMouseDownPan( event );
1104
+
1105
+ state = STATE.PAN;
1106
+
1107
+ } else {
1108
+
1109
+ if ( scope.enableRotate === false ) return;
1110
+
1111
+ handleMouseDownRotate( event );
1112
+
1113
+ state = STATE.ROTATE;
1114
+
1115
+ }
1116
+
1117
+ break;
1118
+
1119
+ case MOUSE.PAN:
1120
+
1121
+ if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
1122
+
1123
+ if ( scope.enableRotate === false ) return;
1124
+
1125
+ handleMouseDownRotate( event );
1126
+
1127
+ state = STATE.ROTATE;
1128
+
1129
+ } else {
1130
+
1131
+ if ( scope.enablePan === false ) return;
1132
+
1133
+ handleMouseDownPan( event );
1134
+
1135
+ state = STATE.PAN;
1136
+
1137
+ }
1138
+
1139
+ break;
1140
+
1141
+ default:
1142
+
1143
+ state = STATE.NONE;
1144
+
1145
+ }
1146
+
1147
+ if ( state !== STATE.NONE ) {
1148
+
1149
+ scope.dispatchEvent( _startEvent );
1150
+
1151
+ }
1152
+
1153
+ }
1154
+
1155
+ function onMouseMove( event ) {
1156
+
1157
+ switch ( state ) {
1158
+
1159
+ case STATE.ROTATE:
1160
+
1161
+ if ( scope.enableRotate === false ) return;
1162
+
1163
+ handleMouseMoveRotate( event );
1164
+
1165
+ break;
1166
+
1167
+ case STATE.DOLLY:
1168
+
1169
+ if ( scope.enableZoom === false ) return;
1170
+
1171
+ handleMouseMoveDolly( event );
1172
+
1173
+ break;
1174
+
1175
+ case STATE.PAN:
1176
+
1177
+ if ( scope.enablePan === false ) return;
1178
+
1179
+ handleMouseMovePan( event );
1180
+
1181
+ break;
1182
+
1183
+ }
1184
+
1185
+ }
1186
+
1187
+ function onMouseWheel( event ) {
1188
+
1189
+ if ( scope.enabled === false || scope.enableZoom === false || state !== STATE.NONE ) return;
1190
+
1191
+ event.preventDefault();
1192
+
1193
+ scope.dispatchEvent( _startEvent );
1194
+
1195
+ handleMouseWheel( event );
1196
+
1197
+ scope.dispatchEvent( _endEvent );
1198
+
1199
+ }
1200
+
1201
+ function onKeyDown( event ) {
1202
+
1203
+ if ( scope.enabled === false || scope.enablePan === false ) return;
1204
+
1205
+ handleKeyDown( event );
1206
+
1207
+ }
1208
+
1209
+ function onTouchStart( event ) {
1210
+
1211
+ trackPointer( event );
1212
+
1213
+ switch ( pointers.length ) {
1214
+
1215
+ case 1:
1216
+
1217
+ switch ( scope.touches.ONE ) {
1218
+
1219
+ case TOUCH.ROTATE:
1220
+
1221
+ if ( scope.enableRotate === false ) return;
1222
+
1223
+ handleTouchStartRotate( event );
1224
+
1225
+ state = STATE.TOUCH_ROTATE;
1226
+
1227
+ break;
1228
+
1229
+ case TOUCH.PAN:
1230
+
1231
+ if ( scope.enablePan === false ) return;
1232
+
1233
+ handleTouchStartPan( event );
1234
+
1235
+ state = STATE.TOUCH_PAN;
1236
+
1237
+ break;
1238
+
1239
+ default:
1240
+
1241
+ state = STATE.NONE;
1242
+
1243
+ }
1244
+
1245
+ break;
1246
+
1247
+ case 2:
1248
+
1249
+ switch ( scope.touches.TWO ) {
1250
+
1251
+ case TOUCH.DOLLY_PAN:
1252
+
1253
+ if ( scope.enableZoom === false && scope.enablePan === false ) return;
1254
+
1255
+ handleTouchStartDollyPan( event );
1256
+
1257
+ state = STATE.TOUCH_DOLLY_PAN;
1258
+
1259
+ break;
1260
+
1261
+ case TOUCH.DOLLY_ROTATE:
1262
+
1263
+ if ( scope.enableZoom === false && scope.enableRotate === false ) return;
1264
+
1265
+ handleTouchStartDollyRotate( event );
1266
+
1267
+ state = STATE.TOUCH_DOLLY_ROTATE;
1268
+
1269
+ break;
1270
+
1271
+ default:
1272
+
1273
+ state = STATE.NONE;
1274
+
1275
+ }
1276
+
1277
+ break;
1278
+
1279
+ default:
1280
+
1281
+ state = STATE.NONE;
1282
+
1283
+ }
1284
+
1285
+ if ( state !== STATE.NONE ) {
1286
+
1287
+ scope.dispatchEvent( _startEvent );
1288
+
1289
+ }
1290
+
1291
+ }
1292
+
1293
+ function onTouchMove( event ) {
1294
+
1295
+ trackPointer( event );
1296
+
1297
+ switch ( state ) {
1298
+
1299
+ case STATE.TOUCH_ROTATE:
1300
+
1301
+ if ( scope.enableRotate === false ) return;
1302
+
1303
+ handleTouchMoveRotate( event );
1304
+
1305
+ scope.update();
1306
+
1307
+ break;
1308
+
1309
+ case STATE.TOUCH_PAN:
1310
+
1311
+ if ( scope.enablePan === false ) return;
1312
+
1313
+ handleTouchMovePan( event );
1314
+
1315
+ scope.update();
1316
+
1317
+ break;
1318
+
1319
+ case STATE.TOUCH_DOLLY_PAN:
1320
+
1321
+ if ( scope.enableZoom === false && scope.enablePan === false ) return;
1322
+
1323
+ handleTouchMoveDollyPan( event );
1324
+
1325
+ scope.update();
1326
+
1327
+ break;
1328
+
1329
+ case STATE.TOUCH_DOLLY_ROTATE:
1330
+
1331
+ if ( scope.enableZoom === false && scope.enableRotate === false ) return;
1332
+
1333
+ handleTouchMoveDollyRotate( event );
1334
+
1335
+ scope.update();
1336
+
1337
+ break;
1338
+
1339
+ default:
1340
+
1341
+ state = STATE.NONE;
1342
+
1343
+ }
1344
+
1345
+ }
1346
+
1347
+ function onContextMenu( event ) {
1348
+
1349
+ if ( scope.enabled === false ) return;
1350
+
1351
+ event.preventDefault();
1352
+
1353
+ }
1354
+
1355
+ function addPointer( event ) {
1356
+
1357
+ pointers.push( event.pointerId );
1358
+
1359
+ }
1360
+
1361
+ function removePointer( event ) {
1362
+
1363
+ delete pointerPositions[ event.pointerId ];
1364
+
1365
+ for ( let i = 0; i < pointers.length; i ++ ) {
1366
+
1367
+ if ( pointers[ i ] == event.pointerId ) {
1368
+
1369
+ pointers.splice( i, 1 );
1370
+ return;
1371
+
1372
+ }
1373
+
1374
+ }
1375
+
1376
+ }
1377
+
1378
+ function trackPointer( event ) {
1379
+
1380
+ let position = pointerPositions[ event.pointerId ];
1381
+
1382
+ if ( position === undefined ) {
1383
+
1384
+ position = new Vector2();
1385
+ pointerPositions[ event.pointerId ] = position;
1386
+
1387
+ }
1388
+
1389
+ position.set( event.pageX, event.pageY );
1390
+
1391
+ }
1392
+
1393
+ function getSecondPointerPosition( event ) {
1394
+
1395
+ const pointerId = ( event.pointerId === pointers[ 0 ] ) ? pointers[ 1 ] : pointers[ 0 ];
1396
+
1397
+ return pointerPositions[ pointerId ];
1398
+
1399
+ }
1400
+
1401
+ //
1402
+
1403
+ scope.domElement.addEventListener( 'contextmenu', onContextMenu );
1404
+
1405
+ scope.domElement.addEventListener( 'pointerdown', onPointerDown );
1406
+ scope.domElement.addEventListener( 'pointercancel', onPointerUp );
1407
+ scope.domElement.addEventListener( 'wheel', onMouseWheel, { passive: false } );
1408
+
1409
+ // force an update at start
1410
+
1411
+ this.update();
1412
+
1413
+ }
1414
+
1415
+ }
1416
+
1417
+ export { OrbitControls };
hello_world/static/lib/three/addons/utils/BufferGeometryUtils.js ADDED
@@ -0,0 +1,1375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ BufferAttribute,
3
+ BufferGeometry,
4
+ Float32BufferAttribute,
5
+ InstancedBufferAttribute,
6
+ InterleavedBuffer,
7
+ InterleavedBufferAttribute,
8
+ TriangleFanDrawMode,
9
+ TriangleStripDrawMode,
10
+ TrianglesDrawMode,
11
+ Vector3,
12
+ } from 'three';
13
+
14
+ function computeMikkTSpaceTangents( geometry, MikkTSpace, negateSign = true ) {
15
+
16
+ if ( ! MikkTSpace || ! MikkTSpace.isReady ) {
17
+
18
+ throw new Error( 'BufferGeometryUtils: Initialized MikkTSpace library required.' );
19
+
20
+ }
21
+
22
+ if ( ! geometry.hasAttribute( 'position' ) || ! geometry.hasAttribute( 'normal' ) || ! geometry.hasAttribute( 'uv' ) ) {
23
+
24
+ throw new Error( 'BufferGeometryUtils: Tangents require "position", "normal", and "uv" attributes.' );
25
+
26
+ }
27
+
28
+ function getAttributeArray( attribute ) {
29
+
30
+ if ( attribute.normalized || attribute.isInterleavedBufferAttribute ) {
31
+
32
+ const dstArray = new Float32Array( attribute.count * attribute.itemSize );
33
+
34
+ for ( let i = 0, j = 0; i < attribute.count; i ++ ) {
35
+
36
+ dstArray[ j ++ ] = attribute.getX( i );
37
+ dstArray[ j ++ ] = attribute.getY( i );
38
+
39
+ if ( attribute.itemSize > 2 ) {
40
+
41
+ dstArray[ j ++ ] = attribute.getZ( i );
42
+
43
+ }
44
+
45
+ }
46
+
47
+ return dstArray;
48
+
49
+ }
50
+
51
+ if ( attribute.array instanceof Float32Array ) {
52
+
53
+ return attribute.array;
54
+
55
+ }
56
+
57
+ return new Float32Array( attribute.array );
58
+
59
+ }
60
+
61
+ // MikkTSpace algorithm requires non-indexed input.
62
+
63
+ const _geometry = geometry.index ? geometry.toNonIndexed() : geometry;
64
+
65
+ // Compute vertex tangents.
66
+
67
+ const tangents = MikkTSpace.generateTangents(
68
+
69
+ getAttributeArray( _geometry.attributes.position ),
70
+ getAttributeArray( _geometry.attributes.normal ),
71
+ getAttributeArray( _geometry.attributes.uv )
72
+
73
+ );
74
+
75
+ // Texture coordinate convention of glTF differs from the apparent
76
+ // default of the MikkTSpace library; .w component must be flipped.
77
+
78
+ if ( negateSign ) {
79
+
80
+ for ( let i = 3; i < tangents.length; i += 4 ) {
81
+
82
+ tangents[ i ] *= - 1;
83
+
84
+ }
85
+
86
+ }
87
+
88
+ //
89
+
90
+ _geometry.setAttribute( 'tangent', new BufferAttribute( tangents, 4 ) );
91
+
92
+ if ( geometry !== _geometry ) {
93
+
94
+ geometry.copy( _geometry );
95
+
96
+ }
97
+
98
+ return geometry;
99
+
100
+ }
101
+
102
+ /**
103
+ * @param {Array<BufferGeometry>} geometries
104
+ * @param {Boolean} useGroups
105
+ * @return {BufferGeometry}
106
+ */
107
+ function mergeGeometries( geometries, useGroups = false ) {
108
+
109
+ const isIndexed = geometries[ 0 ].index !== null;
110
+
111
+ const attributesUsed = new Set( Object.keys( geometries[ 0 ].attributes ) );
112
+ const morphAttributesUsed = new Set( Object.keys( geometries[ 0 ].morphAttributes ) );
113
+
114
+ const attributes = {};
115
+ const morphAttributes = {};
116
+
117
+ const morphTargetsRelative = geometries[ 0 ].morphTargetsRelative;
118
+
119
+ const mergedGeometry = new BufferGeometry();
120
+
121
+ let offset = 0;
122
+
123
+ for ( let i = 0; i < geometries.length; ++ i ) {
124
+
125
+ const geometry = geometries[ i ];
126
+ let attributesCount = 0;
127
+
128
+ // ensure that all geometries are indexed, or none
129
+
130
+ if ( isIndexed !== ( geometry.index !== null ) ) {
131
+
132
+ console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them.' );
133
+ return null;
134
+
135
+ }
136
+
137
+ // gather attributes, exit early if they're different
138
+
139
+ for ( const name in geometry.attributes ) {
140
+
141
+ if ( ! attributesUsed.has( name ) ) {
142
+
143
+ console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. All geometries must have compatible attributes; make sure "' + name + '" attribute exists among all geometries, or in none of them.' );
144
+ return null;
145
+
146
+ }
147
+
148
+ if ( attributes[ name ] === undefined ) attributes[ name ] = [];
149
+
150
+ attributes[ name ].push( geometry.attributes[ name ] );
151
+
152
+ attributesCount ++;
153
+
154
+ }
155
+
156
+ // ensure geometries have the same number of attributes
157
+
158
+ if ( attributesCount !== attributesUsed.size ) {
159
+
160
+ console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. Make sure all geometries have the same number of attributes.' );
161
+ return null;
162
+
163
+ }
164
+
165
+ // gather morph attributes, exit early if they're different
166
+
167
+ if ( morphTargetsRelative !== geometry.morphTargetsRelative ) {
168
+
169
+ console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. .morphTargetsRelative must be consistent throughout all geometries.' );
170
+ return null;
171
+
172
+ }
173
+
174
+ for ( const name in geometry.morphAttributes ) {
175
+
176
+ if ( ! morphAttributesUsed.has( name ) ) {
177
+
178
+ console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. .morphAttributes must be consistent throughout all geometries.' );
179
+ return null;
180
+
181
+ }
182
+
183
+ if ( morphAttributes[ name ] === undefined ) morphAttributes[ name ] = [];
184
+
185
+ morphAttributes[ name ].push( geometry.morphAttributes[ name ] );
186
+
187
+ }
188
+
189
+ if ( useGroups ) {
190
+
191
+ let count;
192
+
193
+ if ( isIndexed ) {
194
+
195
+ count = geometry.index.count;
196
+
197
+ } else if ( geometry.attributes.position !== undefined ) {
198
+
199
+ count = geometry.attributes.position.count;
200
+
201
+ } else {
202
+
203
+ console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. The geometry must have either an index or a position attribute' );
204
+ return null;
205
+
206
+ }
207
+
208
+ mergedGeometry.addGroup( offset, count, i );
209
+
210
+ offset += count;
211
+
212
+ }
213
+
214
+ }
215
+
216
+ // merge indices
217
+
218
+ if ( isIndexed ) {
219
+
220
+ let indexOffset = 0;
221
+ const mergedIndex = [];
222
+
223
+ for ( let i = 0; i < geometries.length; ++ i ) {
224
+
225
+ const index = geometries[ i ].index;
226
+
227
+ for ( let j = 0; j < index.count; ++ j ) {
228
+
229
+ mergedIndex.push( index.getX( j ) + indexOffset );
230
+
231
+ }
232
+
233
+ indexOffset += geometries[ i ].attributes.position.count;
234
+
235
+ }
236
+
237
+ mergedGeometry.setIndex( mergedIndex );
238
+
239
+ }
240
+
241
+ // merge attributes
242
+
243
+ for ( const name in attributes ) {
244
+
245
+ const mergedAttribute = mergeAttributes( attributes[ name ] );
246
+
247
+ if ( ! mergedAttribute ) {
248
+
249
+ console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed while trying to merge the ' + name + ' attribute.' );
250
+ return null;
251
+
252
+ }
253
+
254
+ mergedGeometry.setAttribute( name, mergedAttribute );
255
+
256
+ }
257
+
258
+ // merge morph attributes
259
+
260
+ for ( const name in morphAttributes ) {
261
+
262
+ const numMorphTargets = morphAttributes[ name ][ 0 ].length;
263
+
264
+ if ( numMorphTargets === 0 ) break;
265
+
266
+ mergedGeometry.morphAttributes = mergedGeometry.morphAttributes || {};
267
+ mergedGeometry.morphAttributes[ name ] = [];
268
+
269
+ for ( let i = 0; i < numMorphTargets; ++ i ) {
270
+
271
+ const morphAttributesToMerge = [];
272
+
273
+ for ( let j = 0; j < morphAttributes[ name ].length; ++ j ) {
274
+
275
+ morphAttributesToMerge.push( morphAttributes[ name ][ j ][ i ] );
276
+
277
+ }
278
+
279
+ const mergedMorphAttribute = mergeAttributes( morphAttributesToMerge );
280
+
281
+ if ( ! mergedMorphAttribute ) {
282
+
283
+ console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed while trying to merge the ' + name + ' morphAttribute.' );
284
+ return null;
285
+
286
+ }
287
+
288
+ mergedGeometry.morphAttributes[ name ].push( mergedMorphAttribute );
289
+
290
+ }
291
+
292
+ }
293
+
294
+ return mergedGeometry;
295
+
296
+ }
297
+
298
+ /**
299
+ * @param {Array<BufferAttribute>} attributes
300
+ * @return {BufferAttribute}
301
+ */
302
+ function mergeAttributes( attributes ) {
303
+
304
+ let TypedArray;
305
+ let itemSize;
306
+ let normalized;
307
+ let gpuType = - 1;
308
+ let arrayLength = 0;
309
+
310
+ for ( let i = 0; i < attributes.length; ++ i ) {
311
+
312
+ const attribute = attributes[ i ];
313
+
314
+ if ( attribute.isInterleavedBufferAttribute ) {
315
+
316
+ console.error( 'THREE.BufferGeometryUtils: .mergeAttributes() failed. InterleavedBufferAttributes are not supported.' );
317
+ return null;
318
+
319
+ }
320
+
321
+ if ( TypedArray === undefined ) TypedArray = attribute.array.constructor;
322
+ if ( TypedArray !== attribute.array.constructor ) {
323
+
324
+ console.error( 'THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.array must be of consistent array types across matching attributes.' );
325
+ return null;
326
+
327
+ }
328
+
329
+ if ( itemSize === undefined ) itemSize = attribute.itemSize;
330
+ if ( itemSize !== attribute.itemSize ) {
331
+
332
+ console.error( 'THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.itemSize must be consistent across matching attributes.' );
333
+ return null;
334
+
335
+ }
336
+
337
+ if ( normalized === undefined ) normalized = attribute.normalized;
338
+ if ( normalized !== attribute.normalized ) {
339
+
340
+ console.error( 'THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.normalized must be consistent across matching attributes.' );
341
+ return null;
342
+
343
+ }
344
+
345
+ if ( gpuType === - 1 ) gpuType = attribute.gpuType;
346
+ if ( gpuType !== attribute.gpuType ) {
347
+
348
+ console.error( 'THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.gpuType must be consistent across matching attributes.' );
349
+ return null;
350
+
351
+ }
352
+
353
+ arrayLength += attribute.array.length;
354
+
355
+ }
356
+
357
+ const array = new TypedArray( arrayLength );
358
+ let offset = 0;
359
+
360
+ for ( let i = 0; i < attributes.length; ++ i ) {
361
+
362
+ array.set( attributes[ i ].array, offset );
363
+
364
+ offset += attributes[ i ].array.length;
365
+
366
+ }
367
+
368
+ const result = new BufferAttribute( array, itemSize, normalized );
369
+ if ( gpuType !== undefined ) {
370
+
371
+ result.gpuType = gpuType;
372
+
373
+ }
374
+
375
+ return result;
376
+
377
+ }
378
+
379
+ /**
380
+ * @param {BufferAttribute}
381
+ * @return {BufferAttribute}
382
+ */
383
+ export function deepCloneAttribute( attribute ) {
384
+
385
+ if ( attribute.isInstancedInterleavedBufferAttribute || attribute.isInterleavedBufferAttribute ) {
386
+
387
+ return deinterleaveAttribute( attribute );
388
+
389
+ }
390
+
391
+ if ( attribute.isInstancedBufferAttribute ) {
392
+
393
+ return new InstancedBufferAttribute().copy( attribute );
394
+
395
+ }
396
+
397
+ return new BufferAttribute().copy( attribute );
398
+
399
+ }
400
+
401
+ /**
402
+ * @param {Array<BufferAttribute>} attributes
403
+ * @return {Array<InterleavedBufferAttribute>}
404
+ */
405
+ function interleaveAttributes( attributes ) {
406
+
407
+ // Interleaves the provided attributes into an InterleavedBuffer and returns
408
+ // a set of InterleavedBufferAttributes for each attribute
409
+ let TypedArray;
410
+ let arrayLength = 0;
411
+ let stride = 0;
412
+
413
+ // calculate the length and type of the interleavedBuffer
414
+ for ( let i = 0, l = attributes.length; i < l; ++ i ) {
415
+
416
+ const attribute = attributes[ i ];
417
+
418
+ if ( TypedArray === undefined ) TypedArray = attribute.array.constructor;
419
+ if ( TypedArray !== attribute.array.constructor ) {
420
+
421
+ console.error( 'AttributeBuffers of different types cannot be interleaved' );
422
+ return null;
423
+
424
+ }
425
+
426
+ arrayLength += attribute.array.length;
427
+ stride += attribute.itemSize;
428
+
429
+ }
430
+
431
+ // Create the set of buffer attributes
432
+ const interleavedBuffer = new InterleavedBuffer( new TypedArray( arrayLength ), stride );
433
+ let offset = 0;
434
+ const res = [];
435
+ const getters = [ 'getX', 'getY', 'getZ', 'getW' ];
436
+ const setters = [ 'setX', 'setY', 'setZ', 'setW' ];
437
+
438
+ for ( let j = 0, l = attributes.length; j < l; j ++ ) {
439
+
440
+ const attribute = attributes[ j ];
441
+ const itemSize = attribute.itemSize;
442
+ const count = attribute.count;
443
+ const iba = new InterleavedBufferAttribute( interleavedBuffer, itemSize, offset, attribute.normalized );
444
+ res.push( iba );
445
+
446
+ offset += itemSize;
447
+
448
+ // Move the data for each attribute into the new interleavedBuffer
449
+ // at the appropriate offset
450
+ for ( let c = 0; c < count; c ++ ) {
451
+
452
+ for ( let k = 0; k < itemSize; k ++ ) {
453
+
454
+ iba[ setters[ k ] ]( c, attribute[ getters[ k ] ]( c ) );
455
+
456
+ }
457
+
458
+ }
459
+
460
+ }
461
+
462
+ return res;
463
+
464
+ }
465
+
466
+ // returns a new, non-interleaved version of the provided attribute
467
+ export function deinterleaveAttribute( attribute ) {
468
+
469
+ const cons = attribute.data.array.constructor;
470
+ const count = attribute.count;
471
+ const itemSize = attribute.itemSize;
472
+ const normalized = attribute.normalized;
473
+
474
+ const array = new cons( count * itemSize );
475
+ let newAttribute;
476
+ if ( attribute.isInstancedInterleavedBufferAttribute ) {
477
+
478
+ newAttribute = new InstancedBufferAttribute( array, itemSize, normalized, attribute.meshPerAttribute );
479
+
480
+ } else {
481
+
482
+ newAttribute = new BufferAttribute( array, itemSize, normalized );
483
+
484
+ }
485
+
486
+ for ( let i = 0; i < count; i ++ ) {
487
+
488
+ newAttribute.setX( i, attribute.getX( i ) );
489
+
490
+ if ( itemSize >= 2 ) {
491
+
492
+ newAttribute.setY( i, attribute.getY( i ) );
493
+
494
+ }
495
+
496
+ if ( itemSize >= 3 ) {
497
+
498
+ newAttribute.setZ( i, attribute.getZ( i ) );
499
+
500
+ }
501
+
502
+ if ( itemSize >= 4 ) {
503
+
504
+ newAttribute.setW( i, attribute.getW( i ) );
505
+
506
+ }
507
+
508
+ }
509
+
510
+ return newAttribute;
511
+
512
+ }
513
+
514
+ // deinterleaves all attributes on the geometry
515
+ export function deinterleaveGeometry( geometry ) {
516
+
517
+ const attributes = geometry.attributes;
518
+ const morphTargets = geometry.morphTargets;
519
+ const attrMap = new Map();
520
+
521
+ for ( const key in attributes ) {
522
+
523
+ const attr = attributes[ key ];
524
+ if ( attr.isInterleavedBufferAttribute ) {
525
+
526
+ if ( ! attrMap.has( attr ) ) {
527
+
528
+ attrMap.set( attr, deinterleaveAttribute( attr ) );
529
+
530
+ }
531
+
532
+ attributes[ key ] = attrMap.get( attr );
533
+
534
+ }
535
+
536
+ }
537
+
538
+ for ( const key in morphTargets ) {
539
+
540
+ const attr = morphTargets[ key ];
541
+ if ( attr.isInterleavedBufferAttribute ) {
542
+
543
+ if ( ! attrMap.has( attr ) ) {
544
+
545
+ attrMap.set( attr, deinterleaveAttribute( attr ) );
546
+
547
+ }
548
+
549
+ morphTargets[ key ] = attrMap.get( attr );
550
+
551
+ }
552
+
553
+ }
554
+
555
+ }
556
+
557
+ /**
558
+ * @param {BufferGeometry} geometry
559
+ * @return {number}
560
+ */
561
+ function estimateBytesUsed( geometry ) {
562
+
563
+ // Return the estimated memory used by this geometry in bytes
564
+ // Calculate using itemSize, count, and BYTES_PER_ELEMENT to account
565
+ // for InterleavedBufferAttributes.
566
+ let mem = 0;
567
+ for ( const name in geometry.attributes ) {
568
+
569
+ const attr = geometry.getAttribute( name );
570
+ mem += attr.count * attr.itemSize * attr.array.BYTES_PER_ELEMENT;
571
+
572
+ }
573
+
574
+ const indices = geometry.getIndex();
575
+ mem += indices ? indices.count * indices.itemSize * indices.array.BYTES_PER_ELEMENT : 0;
576
+ return mem;
577
+
578
+ }
579
+
580
+ /**
581
+ * @param {BufferGeometry} geometry
582
+ * @param {number} tolerance
583
+ * @return {BufferGeometry}
584
+ */
585
+ function mergeVertices( geometry, tolerance = 1e-4 ) {
586
+
587
+ tolerance = Math.max( tolerance, Number.EPSILON );
588
+
589
+ // Generate an index buffer if the geometry doesn't have one, or optimize it
590
+ // if it's already available.
591
+ const hashToIndex = {};
592
+ const indices = geometry.getIndex();
593
+ const positions = geometry.getAttribute( 'position' );
594
+ const vertexCount = indices ? indices.count : positions.count;
595
+
596
+ // next value for triangle indices
597
+ let nextIndex = 0;
598
+
599
+ // attributes and new attribute arrays
600
+ const attributeNames = Object.keys( geometry.attributes );
601
+ const tmpAttributes = {};
602
+ const tmpMorphAttributes = {};
603
+ const newIndices = [];
604
+ const getters = [ 'getX', 'getY', 'getZ', 'getW' ];
605
+ const setters = [ 'setX', 'setY', 'setZ', 'setW' ];
606
+
607
+ // Initialize the arrays, allocating space conservatively. Extra
608
+ // space will be trimmed in the last step.
609
+ for ( let i = 0, l = attributeNames.length; i < l; i ++ ) {
610
+
611
+ const name = attributeNames[ i ];
612
+ const attr = geometry.attributes[ name ];
613
+
614
+ tmpAttributes[ name ] = new BufferAttribute(
615
+ new attr.array.constructor( attr.count * attr.itemSize ),
616
+ attr.itemSize,
617
+ attr.normalized
618
+ );
619
+
620
+ const morphAttr = geometry.morphAttributes[ name ];
621
+ if ( morphAttr ) {
622
+
623
+ tmpMorphAttributes[ name ] = new BufferAttribute(
624
+ new morphAttr.array.constructor( morphAttr.count * morphAttr.itemSize ),
625
+ morphAttr.itemSize,
626
+ morphAttr.normalized
627
+ );
628
+
629
+ }
630
+
631
+ }
632
+
633
+ // convert the error tolerance to an amount of decimal places to truncate to
634
+ const halfTolerance = tolerance * 0.5;
635
+ const exponent = Math.log10( 1 / tolerance );
636
+ const hashMultiplier = Math.pow( 10, exponent );
637
+ const hashAdditive = halfTolerance * hashMultiplier;
638
+ for ( let i = 0; i < vertexCount; i ++ ) {
639
+
640
+ const index = indices ? indices.getX( i ) : i;
641
+
642
+ // Generate a hash for the vertex attributes at the current index 'i'
643
+ let hash = '';
644
+ for ( let j = 0, l = attributeNames.length; j < l; j ++ ) {
645
+
646
+ const name = attributeNames[ j ];
647
+ const attribute = geometry.getAttribute( name );
648
+ const itemSize = attribute.itemSize;
649
+
650
+ for ( let k = 0; k < itemSize; k ++ ) {
651
+
652
+ // double tilde truncates the decimal value
653
+ hash += `${ ~ ~ ( attribute[ getters[ k ] ]( index ) * hashMultiplier + hashAdditive ) },`;
654
+
655
+ }
656
+
657
+ }
658
+
659
+ // Add another reference to the vertex if it's already
660
+ // used by another index
661
+ if ( hash in hashToIndex ) {
662
+
663
+ newIndices.push( hashToIndex[ hash ] );
664
+
665
+ } else {
666
+
667
+ // copy data to the new index in the temporary attributes
668
+ for ( let j = 0, l = attributeNames.length; j < l; j ++ ) {
669
+
670
+ const name = attributeNames[ j ];
671
+ const attribute = geometry.getAttribute( name );
672
+ const morphAttr = geometry.morphAttributes[ name ];
673
+ const itemSize = attribute.itemSize;
674
+ const newarray = tmpAttributes[ name ];
675
+ const newMorphArrays = tmpMorphAttributes[ name ];
676
+
677
+ for ( let k = 0; k < itemSize; k ++ ) {
678
+
679
+ const getterFunc = getters[ k ];
680
+ const setterFunc = setters[ k ];
681
+ newarray[ setterFunc ]( nextIndex, attribute[ getterFunc ]( index ) );
682
+
683
+ if ( morphAttr ) {
684
+
685
+ for ( let m = 0, ml = morphAttr.length; m < ml; m ++ ) {
686
+
687
+ newMorphArrays[ m ][ setterFunc ]( nextIndex, morphAttr[ m ][ getterFunc ]( index ) );
688
+
689
+ }
690
+
691
+ }
692
+
693
+ }
694
+
695
+ }
696
+
697
+ hashToIndex[ hash ] = nextIndex;
698
+ newIndices.push( nextIndex );
699
+ nextIndex ++;
700
+
701
+ }
702
+
703
+ }
704
+
705
+ // generate result BufferGeometry
706
+ const result = geometry.clone();
707
+ for ( const name in geometry.attributes ) {
708
+
709
+ const tmpAttribute = tmpAttributes[ name ];
710
+
711
+ result.setAttribute( name, new BufferAttribute(
712
+ tmpAttribute.array.slice( 0, nextIndex * tmpAttribute.itemSize ),
713
+ tmpAttribute.itemSize,
714
+ tmpAttribute.normalized,
715
+ ) );
716
+
717
+ if ( ! ( name in tmpMorphAttributes ) ) continue;
718
+
719
+ for ( let j = 0; j < tmpMorphAttributes[ name ].length; j ++ ) {
720
+
721
+ const tmpMorphAttribute = tmpMorphAttributes[ name ][ j ];
722
+
723
+ result.morphAttributes[ name ][ j ] = new BufferAttribute(
724
+ tmpMorphAttribute.array.slice( 0, nextIndex * tmpMorphAttribute.itemSize ),
725
+ tmpMorphAttribute.itemSize,
726
+ tmpMorphAttribute.normalized,
727
+ );
728
+
729
+ }
730
+
731
+ }
732
+
733
+ // indices
734
+
735
+ result.setIndex( newIndices );
736
+
737
+ return result;
738
+
739
+ }
740
+
741
+ /**
742
+ * @param {BufferGeometry} geometry
743
+ * @param {number} drawMode
744
+ * @return {BufferGeometry}
745
+ */
746
+ function toTrianglesDrawMode( geometry, drawMode ) {
747
+
748
+ if ( drawMode === TrianglesDrawMode ) {
749
+
750
+ console.warn( 'THREE.BufferGeometryUtils.toTrianglesDrawMode(): Geometry already defined as triangles.' );
751
+ return geometry;
752
+
753
+ }
754
+
755
+ if ( drawMode === TriangleFanDrawMode || drawMode === TriangleStripDrawMode ) {
756
+
757
+ let index = geometry.getIndex();
758
+
759
+ // generate index if not present
760
+
761
+ if ( index === null ) {
762
+
763
+ const indices = [];
764
+
765
+ const position = geometry.getAttribute( 'position' );
766
+
767
+ if ( position !== undefined ) {
768
+
769
+ for ( let i = 0; i < position.count; i ++ ) {
770
+
771
+ indices.push( i );
772
+
773
+ }
774
+
775
+ geometry.setIndex( indices );
776
+ index = geometry.getIndex();
777
+
778
+ } else {
779
+
780
+ console.error( 'THREE.BufferGeometryUtils.toTrianglesDrawMode(): Undefined position attribute. Processing not possible.' );
781
+ return geometry;
782
+
783
+ }
784
+
785
+ }
786
+
787
+ //
788
+
789
+ const numberOfTriangles = index.count - 2;
790
+ const newIndices = [];
791
+
792
+ if ( drawMode === TriangleFanDrawMode ) {
793
+
794
+ // gl.TRIANGLE_FAN
795
+
796
+ for ( let i = 1; i <= numberOfTriangles; i ++ ) {
797
+
798
+ newIndices.push( index.getX( 0 ) );
799
+ newIndices.push( index.getX( i ) );
800
+ newIndices.push( index.getX( i + 1 ) );
801
+
802
+ }
803
+
804
+ } else {
805
+
806
+ // gl.TRIANGLE_STRIP
807
+
808
+ for ( let i = 0; i < numberOfTriangles; i ++ ) {
809
+
810
+ if ( i % 2 === 0 ) {
811
+
812
+ newIndices.push( index.getX( i ) );
813
+ newIndices.push( index.getX( i + 1 ) );
814
+ newIndices.push( index.getX( i + 2 ) );
815
+
816
+ } else {
817
+
818
+ newIndices.push( index.getX( i + 2 ) );
819
+ newIndices.push( index.getX( i + 1 ) );
820
+ newIndices.push( index.getX( i ) );
821
+
822
+ }
823
+
824
+ }
825
+
826
+ }
827
+
828
+ if ( ( newIndices.length / 3 ) !== numberOfTriangles ) {
829
+
830
+ console.error( 'THREE.BufferGeometryUtils.toTrianglesDrawMode(): Unable to generate correct amount of triangles.' );
831
+
832
+ }
833
+
834
+ // build final geometry
835
+
836
+ const newGeometry = geometry.clone();
837
+ newGeometry.setIndex( newIndices );
838
+ newGeometry.clearGroups();
839
+
840
+ return newGeometry;
841
+
842
+ } else {
843
+
844
+ console.error( 'THREE.BufferGeometryUtils.toTrianglesDrawMode(): Unknown draw mode:', drawMode );
845
+ return geometry;
846
+
847
+ }
848
+
849
+ }
850
+
851
+ /**
852
+ * Calculates the morphed attributes of a morphed/skinned BufferGeometry.
853
+ * Helpful for Raytracing or Decals.
854
+ * @param {Mesh | Line | Points} object An instance of Mesh, Line or Points.
855
+ * @return {Object} An Object with original position/normal attributes and morphed ones.
856
+ */
857
+ function computeMorphedAttributes( object ) {
858
+
859
+ const _vA = new Vector3();
860
+ const _vB = new Vector3();
861
+ const _vC = new Vector3();
862
+
863
+ const _tempA = new Vector3();
864
+ const _tempB = new Vector3();
865
+ const _tempC = new Vector3();
866
+
867
+ const _morphA = new Vector3();
868
+ const _morphB = new Vector3();
869
+ const _morphC = new Vector3();
870
+
871
+ function _calculateMorphedAttributeData(
872
+ object,
873
+ attribute,
874
+ morphAttribute,
875
+ morphTargetsRelative,
876
+ a,
877
+ b,
878
+ c,
879
+ modifiedAttributeArray
880
+ ) {
881
+
882
+ _vA.fromBufferAttribute( attribute, a );
883
+ _vB.fromBufferAttribute( attribute, b );
884
+ _vC.fromBufferAttribute( attribute, c );
885
+
886
+ const morphInfluences = object.morphTargetInfluences;
887
+
888
+ if ( morphAttribute && morphInfluences ) {
889
+
890
+ _morphA.set( 0, 0, 0 );
891
+ _morphB.set( 0, 0, 0 );
892
+ _morphC.set( 0, 0, 0 );
893
+
894
+ for ( let i = 0, il = morphAttribute.length; i < il; i ++ ) {
895
+
896
+ const influence = morphInfluences[ i ];
897
+ const morph = morphAttribute[ i ];
898
+
899
+ if ( influence === 0 ) continue;
900
+
901
+ _tempA.fromBufferAttribute( morph, a );
902
+ _tempB.fromBufferAttribute( morph, b );
903
+ _tempC.fromBufferAttribute( morph, c );
904
+
905
+ if ( morphTargetsRelative ) {
906
+
907
+ _morphA.addScaledVector( _tempA, influence );
908
+ _morphB.addScaledVector( _tempB, influence );
909
+ _morphC.addScaledVector( _tempC, influence );
910
+
911
+ } else {
912
+
913
+ _morphA.addScaledVector( _tempA.sub( _vA ), influence );
914
+ _morphB.addScaledVector( _tempB.sub( _vB ), influence );
915
+ _morphC.addScaledVector( _tempC.sub( _vC ), influence );
916
+
917
+ }
918
+
919
+ }
920
+
921
+ _vA.add( _morphA );
922
+ _vB.add( _morphB );
923
+ _vC.add( _morphC );
924
+
925
+ }
926
+
927
+ if ( object.isSkinnedMesh ) {
928
+
929
+ object.applyBoneTransform( a, _vA );
930
+ object.applyBoneTransform( b, _vB );
931
+ object.applyBoneTransform( c, _vC );
932
+
933
+ }
934
+
935
+ modifiedAttributeArray[ a * 3 + 0 ] = _vA.x;
936
+ modifiedAttributeArray[ a * 3 + 1 ] = _vA.y;
937
+ modifiedAttributeArray[ a * 3 + 2 ] = _vA.z;
938
+ modifiedAttributeArray[ b * 3 + 0 ] = _vB.x;
939
+ modifiedAttributeArray[ b * 3 + 1 ] = _vB.y;
940
+ modifiedAttributeArray[ b * 3 + 2 ] = _vB.z;
941
+ modifiedAttributeArray[ c * 3 + 0 ] = _vC.x;
942
+ modifiedAttributeArray[ c * 3 + 1 ] = _vC.y;
943
+ modifiedAttributeArray[ c * 3 + 2 ] = _vC.z;
944
+
945
+ }
946
+
947
+ const geometry = object.geometry;
948
+ const material = object.material;
949
+
950
+ let a, b, c;
951
+ const index = geometry.index;
952
+ const positionAttribute = geometry.attributes.position;
953
+ const morphPosition = geometry.morphAttributes.position;
954
+ const morphTargetsRelative = geometry.morphTargetsRelative;
955
+ const normalAttribute = geometry.attributes.normal;
956
+ const morphNormal = geometry.morphAttributes.position;
957
+
958
+ const groups = geometry.groups;
959
+ const drawRange = geometry.drawRange;
960
+ let i, j, il, jl;
961
+ let group;
962
+ let start, end;
963
+
964
+ const modifiedPosition = new Float32Array( positionAttribute.count * positionAttribute.itemSize );
965
+ const modifiedNormal = new Float32Array( normalAttribute.count * normalAttribute.itemSize );
966
+
967
+ if ( index !== null ) {
968
+
969
+ // indexed buffer geometry
970
+
971
+ if ( Array.isArray( material ) ) {
972
+
973
+ for ( i = 0, il = groups.length; i < il; i ++ ) {
974
+
975
+ group = groups[ i ];
976
+
977
+ start = Math.max( group.start, drawRange.start );
978
+ end = Math.min( ( group.start + group.count ), ( drawRange.start + drawRange.count ) );
979
+
980
+ for ( j = start, jl = end; j < jl; j += 3 ) {
981
+
982
+ a = index.getX( j );
983
+ b = index.getX( j + 1 );
984
+ c = index.getX( j + 2 );
985
+
986
+ _calculateMorphedAttributeData(
987
+ object,
988
+ positionAttribute,
989
+ morphPosition,
990
+ morphTargetsRelative,
991
+ a, b, c,
992
+ modifiedPosition
993
+ );
994
+
995
+ _calculateMorphedAttributeData(
996
+ object,
997
+ normalAttribute,
998
+ morphNormal,
999
+ morphTargetsRelative,
1000
+ a, b, c,
1001
+ modifiedNormal
1002
+ );
1003
+
1004
+ }
1005
+
1006
+ }
1007
+
1008
+ } else {
1009
+
1010
+ start = Math.max( 0, drawRange.start );
1011
+ end = Math.min( index.count, ( drawRange.start + drawRange.count ) );
1012
+
1013
+ for ( i = start, il = end; i < il; i += 3 ) {
1014
+
1015
+ a = index.getX( i );
1016
+ b = index.getX( i + 1 );
1017
+ c = index.getX( i + 2 );
1018
+
1019
+ _calculateMorphedAttributeData(
1020
+ object,
1021
+ positionAttribute,
1022
+ morphPosition,
1023
+ morphTargetsRelative,
1024
+ a, b, c,
1025
+ modifiedPosition
1026
+ );
1027
+
1028
+ _calculateMorphedAttributeData(
1029
+ object,
1030
+ normalAttribute,
1031
+ morphNormal,
1032
+ morphTargetsRelative,
1033
+ a, b, c,
1034
+ modifiedNormal
1035
+ );
1036
+
1037
+ }
1038
+
1039
+ }
1040
+
1041
+ } else {
1042
+
1043
+ // non-indexed buffer geometry
1044
+
1045
+ if ( Array.isArray( material ) ) {
1046
+
1047
+ for ( i = 0, il = groups.length; i < il; i ++ ) {
1048
+
1049
+ group = groups[ i ];
1050
+
1051
+ start = Math.max( group.start, drawRange.start );
1052
+ end = Math.min( ( group.start + group.count ), ( drawRange.start + drawRange.count ) );
1053
+
1054
+ for ( j = start, jl = end; j < jl; j += 3 ) {
1055
+
1056
+ a = j;
1057
+ b = j + 1;
1058
+ c = j + 2;
1059
+
1060
+ _calculateMorphedAttributeData(
1061
+ object,
1062
+ positionAttribute,
1063
+ morphPosition,
1064
+ morphTargetsRelative,
1065
+ a, b, c,
1066
+ modifiedPosition
1067
+ );
1068
+
1069
+ _calculateMorphedAttributeData(
1070
+ object,
1071
+ normalAttribute,
1072
+ morphNormal,
1073
+ morphTargetsRelative,
1074
+ a, b, c,
1075
+ modifiedNormal
1076
+ );
1077
+
1078
+ }
1079
+
1080
+ }
1081
+
1082
+ } else {
1083
+
1084
+ start = Math.max( 0, drawRange.start );
1085
+ end = Math.min( positionAttribute.count, ( drawRange.start + drawRange.count ) );
1086
+
1087
+ for ( i = start, il = end; i < il; i += 3 ) {
1088
+
1089
+ a = i;
1090
+ b = i + 1;
1091
+ c = i + 2;
1092
+
1093
+ _calculateMorphedAttributeData(
1094
+ object,
1095
+ positionAttribute,
1096
+ morphPosition,
1097
+ morphTargetsRelative,
1098
+ a, b, c,
1099
+ modifiedPosition
1100
+ );
1101
+
1102
+ _calculateMorphedAttributeData(
1103
+ object,
1104
+ normalAttribute,
1105
+ morphNormal,
1106
+ morphTargetsRelative,
1107
+ a, b, c,
1108
+ modifiedNormal
1109
+ );
1110
+
1111
+ }
1112
+
1113
+ }
1114
+
1115
+ }
1116
+
1117
+ const morphedPositionAttribute = new Float32BufferAttribute( modifiedPosition, 3 );
1118
+ const morphedNormalAttribute = new Float32BufferAttribute( modifiedNormal, 3 );
1119
+
1120
+ return {
1121
+
1122
+ positionAttribute: positionAttribute,
1123
+ normalAttribute: normalAttribute,
1124
+ morphedPositionAttribute: morphedPositionAttribute,
1125
+ morphedNormalAttribute: morphedNormalAttribute
1126
+
1127
+ };
1128
+
1129
+ }
1130
+
1131
+ function mergeGroups( geometry ) {
1132
+
1133
+ if ( geometry.groups.length === 0 ) {
1134
+
1135
+ console.warn( 'THREE.BufferGeometryUtils.mergeGroups(): No groups are defined. Nothing to merge.' );
1136
+ return geometry;
1137
+
1138
+ }
1139
+
1140
+ let groups = geometry.groups;
1141
+
1142
+ // sort groups by material index
1143
+
1144
+ groups = groups.sort( ( a, b ) => {
1145
+
1146
+ if ( a.materialIndex !== b.materialIndex ) return a.materialIndex - b.materialIndex;
1147
+
1148
+ return a.start - b.start;
1149
+
1150
+ } );
1151
+
1152
+ // create index for non-indexed geometries
1153
+
1154
+ if ( geometry.getIndex() === null ) {
1155
+
1156
+ const positionAttribute = geometry.getAttribute( 'position' );
1157
+ const indices = [];
1158
+
1159
+ for ( let i = 0; i < positionAttribute.count; i += 3 ) {
1160
+
1161
+ indices.push( i, i + 1, i + 2 );
1162
+
1163
+ }
1164
+
1165
+ geometry.setIndex( indices );
1166
+
1167
+ }
1168
+
1169
+ // sort index
1170
+
1171
+ const index = geometry.getIndex();
1172
+
1173
+ const newIndices = [];
1174
+
1175
+ for ( let i = 0; i < groups.length; i ++ ) {
1176
+
1177
+ const group = groups[ i ];
1178
+
1179
+ const groupStart = group.start;
1180
+ const groupLength = groupStart + group.count;
1181
+
1182
+ for ( let j = groupStart; j < groupLength; j ++ ) {
1183
+
1184
+ newIndices.push( index.getX( j ) );
1185
+
1186
+ }
1187
+
1188
+ }
1189
+
1190
+ geometry.dispose(); // Required to force buffer recreation
1191
+ geometry.setIndex( newIndices );
1192
+
1193
+ // update groups indices
1194
+
1195
+ let start = 0;
1196
+
1197
+ for ( let i = 0; i < groups.length; i ++ ) {
1198
+
1199
+ const group = groups[ i ];
1200
+
1201
+ group.start = start;
1202
+ start += group.count;
1203
+
1204
+ }
1205
+
1206
+ // merge groups
1207
+
1208
+ let currentGroup = groups[ 0 ];
1209
+
1210
+ geometry.groups = [ currentGroup ];
1211
+
1212
+ for ( let i = 1; i < groups.length; i ++ ) {
1213
+
1214
+ const group = groups[ i ];
1215
+
1216
+ if ( currentGroup.materialIndex === group.materialIndex ) {
1217
+
1218
+ currentGroup.count += group.count;
1219
+
1220
+ } else {
1221
+
1222
+ currentGroup = group;
1223
+ geometry.groups.push( currentGroup );
1224
+
1225
+ }
1226
+
1227
+ }
1228
+
1229
+ return geometry;
1230
+
1231
+ }
1232
+
1233
+
1234
+ /**
1235
+ * Modifies the supplied geometry if it is non-indexed, otherwise creates a new,
1236
+ * non-indexed geometry. Returns the geometry with smooth normals everywhere except
1237
+ * faces that meet at an angle greater than the crease angle.
1238
+ *
1239
+ * @param {BufferGeometry} geometry
1240
+ * @param {number} [creaseAngle]
1241
+ * @return {BufferGeometry}
1242
+ */
1243
+ function toCreasedNormals( geometry, creaseAngle = Math.PI / 3 /* 60 degrees */ ) {
1244
+
1245
+ const creaseDot = Math.cos( creaseAngle );
1246
+ const hashMultiplier = ( 1 + 1e-10 ) * 1e2;
1247
+
1248
+ // reusable vectors
1249
+ const verts = [ new Vector3(), new Vector3(), new Vector3() ];
1250
+ const tempVec1 = new Vector3();
1251
+ const tempVec2 = new Vector3();
1252
+ const tempNorm = new Vector3();
1253
+ const tempNorm2 = new Vector3();
1254
+
1255
+ // hashes a vector
1256
+ function hashVertex( v ) {
1257
+
1258
+ const x = ~ ~ ( v.x * hashMultiplier );
1259
+ const y = ~ ~ ( v.y * hashMultiplier );
1260
+ const z = ~ ~ ( v.z * hashMultiplier );
1261
+ return `${x},${y},${z}`;
1262
+
1263
+ }
1264
+
1265
+ // BufferGeometry.toNonIndexed() warns if the geometry is non-indexed
1266
+ // and returns the original geometry
1267
+ const resultGeometry = geometry.index ? geometry.toNonIndexed() : geometry;
1268
+ const posAttr = resultGeometry.attributes.position;
1269
+ const vertexMap = {};
1270
+
1271
+ // find all the normals shared by commonly located vertices
1272
+ for ( let i = 0, l = posAttr.count / 3; i < l; i ++ ) {
1273
+
1274
+ const i3 = 3 * i;
1275
+ const a = verts[ 0 ].fromBufferAttribute( posAttr, i3 + 0 );
1276
+ const b = verts[ 1 ].fromBufferAttribute( posAttr, i3 + 1 );
1277
+ const c = verts[ 2 ].fromBufferAttribute( posAttr, i3 + 2 );
1278
+
1279
+ tempVec1.subVectors( c, b );
1280
+ tempVec2.subVectors( a, b );
1281
+
1282
+ // add the normal to the map for all vertices
1283
+ const normal = new Vector3().crossVectors( tempVec1, tempVec2 ).normalize();
1284
+ for ( let n = 0; n < 3; n ++ ) {
1285
+
1286
+ const vert = verts[ n ];
1287
+ const hash = hashVertex( vert );
1288
+ if ( ! ( hash in vertexMap ) ) {
1289
+
1290
+ vertexMap[ hash ] = [];
1291
+
1292
+ }
1293
+
1294
+ vertexMap[ hash ].push( normal );
1295
+
1296
+ }
1297
+
1298
+ }
1299
+
1300
+ // average normals from all vertices that share a common location if they are within the
1301
+ // provided crease threshold
1302
+ const normalArray = new Float32Array( posAttr.count * 3 );
1303
+ const normAttr = new BufferAttribute( normalArray, 3, false );
1304
+ for ( let i = 0, l = posAttr.count / 3; i < l; i ++ ) {
1305
+
1306
+ // get the face normal for this vertex
1307
+ const i3 = 3 * i;
1308
+ const a = verts[ 0 ].fromBufferAttribute( posAttr, i3 + 0 );
1309
+ const b = verts[ 1 ].fromBufferAttribute( posAttr, i3 + 1 );
1310
+ const c = verts[ 2 ].fromBufferAttribute( posAttr, i3 + 2 );
1311
+
1312
+ tempVec1.subVectors( c, b );
1313
+ tempVec2.subVectors( a, b );
1314
+
1315
+ tempNorm.crossVectors( tempVec1, tempVec2 ).normalize();
1316
+
1317
+ // average all normals that meet the threshold and set the normal value
1318
+ for ( let n = 0; n < 3; n ++ ) {
1319
+
1320
+ const vert = verts[ n ];
1321
+ const hash = hashVertex( vert );
1322
+ const otherNormals = vertexMap[ hash ];
1323
+ tempNorm2.set( 0, 0, 0 );
1324
+
1325
+ for ( let k = 0, lk = otherNormals.length; k < lk; k ++ ) {
1326
+
1327
+ const otherNorm = otherNormals[ k ];
1328
+ if ( tempNorm.dot( otherNorm ) > creaseDot ) {
1329
+
1330
+ tempNorm2.add( otherNorm );
1331
+
1332
+ }
1333
+
1334
+ }
1335
+
1336
+ tempNorm2.normalize();
1337
+ normAttr.setXYZ( i3 + n, tempNorm2.x, tempNorm2.y, tempNorm2.z );
1338
+
1339
+ }
1340
+
1341
+ }
1342
+
1343
+ resultGeometry.setAttribute( 'normal', normAttr );
1344
+ return resultGeometry;
1345
+
1346
+ }
1347
+
1348
+ function mergeBufferGeometries( geometries, useGroups = false ) {
1349
+
1350
+ console.warn( 'THREE.BufferGeometryUtils: mergeBufferGeometries() has been renamed to mergeGeometries().' ); // @deprecated, r151
1351
+ return mergeGeometries( geometries, useGroups );
1352
+
1353
+ }
1354
+
1355
+ function mergeBufferAttributes( attributes ) {
1356
+
1357
+ console.warn( 'THREE.BufferGeometryUtils: mergeBufferAttributes() has been renamed to mergeAttributes().' ); // @deprecated, r151
1358
+ return mergeAttributes( attributes );
1359
+
1360
+ }
1361
+
1362
+ export {
1363
+ computeMikkTSpaceTangents,
1364
+ mergeGeometries,
1365
+ mergeBufferGeometries,
1366
+ mergeAttributes,
1367
+ mergeBufferAttributes,
1368
+ interleaveAttributes,
1369
+ estimateBytesUsed,
1370
+ mergeVertices,
1371
+ toTrianglesDrawMode,
1372
+ computeMorphedAttributes,
1373
+ mergeGroups,
1374
+ toCreasedNormals
1375
+ };
hello_world/static/lib/three/three.module.js ADDED
The diff for this file is too large to render. See raw diff
 
hello_world/stats.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ System Stats - Focused functions for gathering system statistics.
3
+
4
+ Functions:
5
+ get_cpu_stats() - CPU temperature and per-core usage
6
+ get_memory_stats() - RAM usage
7
+ get_disk_stats() - Local and remote disk usage (cached)
8
+ get_network_stats() - Network speeds
9
+ get_wifi_stats() - WiFi signal and SSID (cached 10s)
10
+ get_uptime_stats() - Robot and daemon uptime (cached 30s)
11
+ get_load_stats() - System load averages
12
+ get_fan_stats() - Fan RPM
13
+ get_throttle_stats() - CPU throttling status
14
+ get_disk_io_stats() - Disk read/write speeds
15
+ get_top_processes() - Top processes by CPU usage
16
+ get_hardware_info() - Static hardware inventory (cached)
17
+
18
+ Optimizations:
19
+ - Batched vcgencmd calls (temp + throttled in one subprocess)
20
+ - WiFi stats cached for 10s
21
+ - Uptime stats cached for 30s
22
+ - ARM/GPU memory split moved to hardware_info (static)
23
+ """
24
+
25
+ __all__ = [
26
+ 'get_cpu_stats',
27
+ 'get_memory_stats',
28
+ 'get_disk_stats',
29
+ 'get_network_stats',
30
+ 'get_wifi_stats',
31
+ 'get_uptime_stats',
32
+ 'get_load_stats',
33
+ 'get_fan_stats',
34
+ 'get_throttle_stats',
35
+ 'get_disk_io_stats',
36
+ 'get_top_processes',
37
+ 'get_hardware_info',
38
+ ]
39
+
40
+ import platform
41
+ import subprocess
42
+ import time
43
+
44
+ import psutil
45
+
46
+ # ===== Batched vcgencmd cache =====
47
+ _vcgencmd_cache = {"data": {}, "time": 0}
48
+ _VCGENCMD_TTL = 0.5
49
+
50
+
51
+ def _get_vcgencmd_batch() -> dict:
52
+ """Get temp and throttled in single subprocess call (cached 500ms)."""
53
+ now = time.time()
54
+ if now - _vcgencmd_cache["time"] < _VCGENCMD_TTL:
55
+ return _vcgencmd_cache["data"]
56
+
57
+ result = {"temp": None, "throttled": None, "throttle_flags": []}
58
+ try:
59
+ proc = subprocess.run(
60
+ ["vcgencmd", "measure_temp"],
61
+ capture_output=True, text=True, timeout=1
62
+ )
63
+ if proc.returncode == 0:
64
+ temp_str = proc.stdout.strip()
65
+ result["temp"] = float(temp_str.replace("temp=", "").replace("'C", ""))
66
+
67
+ proc2 = subprocess.run(
68
+ ["vcgencmd", "get_throttled"],
69
+ capture_output=True, text=True, timeout=1
70
+ )
71
+ if proc2.returncode == 0:
72
+ hex_val = proc2.stdout.strip().split("=")[1]
73
+ throttled = int(hex_val, 16)
74
+ result["throttled"] = throttled
75
+ flags = []
76
+ if throttled & 0x1: flags.append("under-voltage")
77
+ if throttled & 0x2: flags.append("freq-capped")
78
+ if throttled & 0x4: flags.append("throttled")
79
+ if throttled & 0x8: flags.append("soft-temp-limit")
80
+ if throttled & 0x10000: flags.append("under-voltage-occurred")
81
+ if throttled & 0x20000: flags.append("freq-capped-occurred")
82
+ if throttled & 0x40000: flags.append("throttled-occurred")
83
+ if throttled & 0x80000: flags.append("soft-temp-occurred")
84
+ result["throttle_flags"] = flags
85
+ except Exception:
86
+ pass
87
+
88
+ _vcgencmd_cache["data"] = result
89
+ _vcgencmd_cache["time"] = now
90
+ return result
91
+
92
+
93
+ def get_cpu_stats() -> dict:
94
+ """Get CPU temperature and per-core usage."""
95
+ cpu_cores = psutil.cpu_percent(percpu=True)[:4]
96
+ vcg = _get_vcgencmd_batch()
97
+ return {"cpu_cores": cpu_cores, "cpu_temp": vcg["temp"]}
98
+
99
+
100
+ def get_memory_stats() -> dict:
101
+ """Get RAM usage."""
102
+ mem = psutil.virtual_memory()
103
+ total_mb = mem.total / (1024 * 1024)
104
+
105
+ used_pct = (mem.used / mem.total) * 100 if mem.total > 0 else 0
106
+ buffers_pct = (mem.buffers / mem.total) * 100 if mem.total > 0 and hasattr(mem, 'buffers') else 0
107
+ cached_pct = (mem.cached / mem.total) * 100 if mem.total > 0 and hasattr(mem, 'cached') else 0
108
+
109
+ return {
110
+ "memory_percent": mem.percent,
111
+ "memory_total_mb": round(total_mb),
112
+ "memory_used_mb": round(mem.used / (1024 * 1024)),
113
+ "memory_available_mb": round(mem.available / (1024 * 1024)),
114
+ "memory_used_pct": round(used_pct, 1),
115
+ "memory_buffers_pct": round(buffers_pct, 1),
116
+ "memory_cached_pct": round(cached_pct, 1),
117
+ }
118
+
119
+
120
+ def get_disk_stats(cache: dict, ttl: int = 60) -> dict:
121
+ """Get local and remote disk usage with caching."""
122
+ now = time.time()
123
+ if now - cache.get("time", 0) > ttl:
124
+ disk = psutil.disk_usage("/")
125
+ cache["local"] = {
126
+ "percent": disk.percent,
127
+ "used": disk.used,
128
+ "free": disk.free,
129
+ "total": disk.total
130
+ }
131
+
132
+ cache["remote"] = None
133
+ for mount_point in ["/mnt/reachy-dev", "/mnt/nfs", "/mnt/remote", "/nfs"]:
134
+ try:
135
+ remote_disk = psutil.disk_usage(mount_point)
136
+ cache["remote"] = {
137
+ "percent": remote_disk.percent,
138
+ "used": remote_disk.used,
139
+ "free": remote_disk.free,
140
+ "total": remote_disk.total
141
+ }
142
+ break
143
+ except (FileNotFoundError, OSError):
144
+ pass
145
+
146
+ swap = psutil.swap_memory()
147
+ cache["swap"] = {
148
+ "percent": swap.percent,
149
+ "used": swap.used,
150
+ "free": swap.free,
151
+ "total": swap.total
152
+ }
153
+ cache["time"] = now
154
+
155
+ result = {}
156
+ if cache.get("local"):
157
+ result["disk_local"] = cache["local"]
158
+ if cache.get("remote"):
159
+ result["disk_remote"] = cache["remote"]
160
+ if cache.get("swap"):
161
+ result["swap"] = cache["swap"]
162
+ return result
163
+
164
+
165
+ def get_network_stats(last_net: dict) -> dict:
166
+ """Get network RX/TX speeds."""
167
+ net = psutil.net_io_counters()
168
+ now = time.time()
169
+ elapsed = now - last_net.get("time", now)
170
+
171
+ rx_speed = (net.bytes_recv - last_net.get("rx", net.bytes_recv)) / elapsed if elapsed > 0 else 0
172
+ tx_speed = (net.bytes_sent - last_net.get("tx", net.bytes_sent)) / elapsed if elapsed > 0 else 0
173
+
174
+ last_net["rx"] = net.bytes_recv
175
+ last_net["tx"] = net.bytes_sent
176
+ last_net["time"] = now
177
+
178
+ return {"net_rx_speed": rx_speed, "net_tx_speed": tx_speed}
179
+
180
+
181
+ _wifi_cache = {"data": {}, "time": 0}
182
+ _WIFI_TTL = 10
183
+
184
+
185
+ def get_wifi_stats() -> dict:
186
+ """Get WiFi signal strength and SSID (cached 10s)."""
187
+ now = time.time()
188
+ if now - _wifi_cache["time"] < _WIFI_TTL:
189
+ return _wifi_cache["data"]
190
+
191
+ wifi_signal = None
192
+ wifi_ssid = None
193
+ try:
194
+ result = subprocess.run(
195
+ ["iwconfig", "wlan0"],
196
+ capture_output=True, text=True, timeout=1
197
+ )
198
+ if result.returncode == 0:
199
+ for line in result.stdout.split("\n"):
200
+ if "Signal level" in line:
201
+ parts = line.split("Signal level=")
202
+ if len(parts) > 1:
203
+ dbm = int(parts[1].split()[0].replace("dBm", ""))
204
+ wifi_signal = max(0, min(100, 2 * (dbm + 100)))
205
+ if "ESSID:" in line and 'ESSID:"' in line:
206
+ wifi_ssid = line.split('ESSID:"')[1].split('"')[0]
207
+ except Exception:
208
+ pass
209
+
210
+ _wifi_cache["data"] = {"wifi_signal": wifi_signal, "wifi_ssid": wifi_ssid}
211
+ _wifi_cache["time"] = now
212
+ return _wifi_cache["data"]
213
+
214
+
215
+ _uptime_cache = {"data": {}, "time": 0, "daemon_start": None}
216
+ _UPTIME_TTL = 30
217
+
218
+
219
+ def get_uptime_stats() -> dict:
220
+ """Get robot and daemon uptime in seconds."""
221
+ now = time.time()
222
+
223
+ robot_uptime = None
224
+ try:
225
+ with open("/proc/uptime", "r") as f:
226
+ robot_uptime = float(f.read().split()[0])
227
+ except Exception:
228
+ pass
229
+
230
+ if now - _uptime_cache["time"] > _UPTIME_TTL or _uptime_cache["daemon_start"] is None:
231
+ try:
232
+ result = subprocess.run(
233
+ ["systemctl", "show", "reachy-mini-daemon", "--property=ActiveEnterTimestampMonotonic"],
234
+ capture_output=True, text=True, timeout=2
235
+ )
236
+ if result.returncode == 0:
237
+ mono_str = result.stdout.strip().replace("ActiveEnterTimestampMonotonic=", "")
238
+ if mono_str and mono_str != "0":
239
+ _uptime_cache["daemon_start"] = int(mono_str) / 1_000_000
240
+ _uptime_cache["time"] = now
241
+ except Exception:
242
+ pass
243
+
244
+ daemon_uptime = None
245
+ if _uptime_cache["daemon_start"] is not None and robot_uptime is not None:
246
+ daemon_uptime = robot_uptime - _uptime_cache["daemon_start"]
247
+ if daemon_uptime < 0:
248
+ daemon_uptime = None
249
+
250
+ return {"robot_uptime": robot_uptime, "daemon_uptime": daemon_uptime}
251
+
252
+
253
+ def get_load_stats() -> dict:
254
+ """Get system load averages (1, 5, 15 min)."""
255
+ load = psutil.getloadavg()
256
+ return {
257
+ "load_1m": round(load[0], 2),
258
+ "load_5m": round(load[1], 2),
259
+ "load_15m": round(load[2], 2)
260
+ }
261
+
262
+
263
+ def get_fan_stats() -> dict:
264
+ """Get fan RPM from sensors."""
265
+ try:
266
+ fans = psutil.sensors_fans()
267
+ for name, entries in fans.items():
268
+ if entries:
269
+ return {"fan_rpm": entries[0].current, "fan_name": name}
270
+ except Exception:
271
+ pass
272
+ return {"fan_rpm": None, "fan_name": None}
273
+
274
+
275
+ def get_throttle_stats() -> dict:
276
+ """Get CPU throttling status."""
277
+ vcg = _get_vcgencmd_batch()
278
+ return {"throttled": vcg["throttled"], "throttle_flags": vcg["throttle_flags"]}
279
+
280
+
281
+ def get_disk_io_stats(last_dio: dict) -> dict:
282
+ """Get disk read/write speeds."""
283
+ try:
284
+ dio = psutil.disk_io_counters()
285
+ now = time.time()
286
+ elapsed = now - last_dio.get("time", now)
287
+
288
+ read_speed = (dio.read_bytes - last_dio.get("read", dio.read_bytes)) / elapsed if elapsed > 0 else 0
289
+ write_speed = (dio.write_bytes - last_dio.get("write", dio.write_bytes)) / elapsed if elapsed > 0 else 0
290
+
291
+ last_dio["read"] = dio.read_bytes
292
+ last_dio["write"] = dio.write_bytes
293
+ last_dio["time"] = now
294
+
295
+ return {"disk_read_speed": read_speed, "disk_write_speed": write_speed}
296
+ except Exception:
297
+ return {"disk_read_speed": None, "disk_write_speed": None}
298
+
299
+
300
+ def get_top_processes(cache: dict, ttl: int = 15, limit: int = 10) -> dict:
301
+ """Get top processes by CPU usage with caching."""
302
+ now = time.time()
303
+ if now - cache.get("time", 0) > ttl:
304
+ procs = []
305
+ for p in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
306
+ try:
307
+ info = p.info
308
+ cpu = info.get('cpu_percent') or 0
309
+ mem = info.get('memory_percent') or 0
310
+ if cpu > 0 or mem > 0.5:
311
+ procs.append({
312
+ "pid": info['pid'],
313
+ "name": (info.get('name') or '')[:20],
314
+ "cpu": round(cpu, 1),
315
+ "mem": round(mem, 1)
316
+ })
317
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
318
+ pass
319
+
320
+ procs.sort(key=lambda x: x['cpu'], reverse=True)
321
+ cache["processes"] = procs[:limit]
322
+ cache["time"] = now
323
+
324
+ return {"top_processes": cache.get("processes", [])}
325
+
326
+
327
+ def get_hardware_info(cache: dict) -> dict:
328
+ """Get static hardware inventory (cached indefinitely)."""
329
+ if cache.get("hardware"):
330
+ return {"hardware_info": cache["hardware"]}
331
+
332
+ hw = {}
333
+
334
+ try:
335
+ with open("/sys/firmware/devicetree/base/model", "r") as f:
336
+ hw["board_model"] = f.read().strip().replace("\x00", "")
337
+ except Exception:
338
+ hw["board_model"] = "Unknown"
339
+
340
+ try:
341
+ with open("/proc/cpuinfo", "r") as f:
342
+ cpuinfo = f.read()
343
+ for line in cpuinfo.split("\n"):
344
+ if line.startswith("Revision"):
345
+ hw["board_revision"] = line.split(":")[1].strip()
346
+ elif line.startswith("Serial"):
347
+ hw["board_serial"] = line.split(":")[1].strip()
348
+ except Exception:
349
+ pass
350
+
351
+ try:
352
+ hw["cpu_arch"] = platform.machine()
353
+ hw["cpu_cores"] = psutil.cpu_count(logical=True)
354
+ freq = psutil.cpu_freq()
355
+ if freq:
356
+ hw["cpu_freq_max"] = int(freq.max)
357
+ hw["cpu_freq_min"] = int(freq.min)
358
+ except Exception:
359
+ pass
360
+
361
+ try:
362
+ arm_result = subprocess.run(
363
+ ["vcgencmd", "get_mem", "arm"],
364
+ capture_output=True, text=True, timeout=1
365
+ )
366
+ gpu_result = subprocess.run(
367
+ ["vcgencmd", "get_mem", "gpu"],
368
+ capture_output=True, text=True, timeout=1
369
+ )
370
+ if arm_result.returncode == 0:
371
+ hw["mem_arm"] = arm_result.stdout.strip().split("=")[1]
372
+ if gpu_result.returncode == 0:
373
+ hw["mem_gpu"] = gpu_result.stdout.strip().split("=")[1]
374
+ except Exception:
375
+ pass
376
+
377
+ try:
378
+ mem = psutil.virtual_memory()
379
+ hw["mem_total_gb"] = round(mem.total / (1024**3), 1)
380
+ except Exception:
381
+ pass
382
+
383
+ try:
384
+ result = subprocess.run(
385
+ ["lsblk", "-o", "NAME,SIZE,TYPE", "-n"],
386
+ capture_output=True, text=True, timeout=2
387
+ )
388
+ if result.returncode == 0:
389
+ storage = []
390
+ for line in result.stdout.strip().split("\n"):
391
+ parts = line.split()
392
+ if len(parts) >= 3 and parts[2] == "disk" and "loop" not in parts[0]:
393
+ name = parts[0].replace("─", "").replace("├", "").replace("└", "")
394
+ storage.append({"name": name, "size": parts[1]})
395
+ hw["storage"] = storage
396
+ except Exception:
397
+ pass
398
+
399
+ try:
400
+ result = subprocess.run(
401
+ ["lsusb"], capture_output=True, text=True, timeout=2
402
+ )
403
+ if result.returncode == 0:
404
+ usb = []
405
+ for line in result.stdout.strip().split("\n"):
406
+ if "ID " in line:
407
+ parts = line.split("ID ")[1].split(" ", 1)
408
+ usb_id = parts[0]
409
+ name = parts[1] if len(parts) > 1 else "Unknown"
410
+ if "root hub" not in name.lower():
411
+ usb.append({"id": usb_id, "name": name})
412
+ hw["usb_devices"] = usb
413
+ except Exception:
414
+ pass
415
+
416
+ try:
417
+ result = subprocess.run(
418
+ ["i2cdetect", "-l"], capture_output=True, text=True, timeout=2
419
+ )
420
+ if result.returncode == 0:
421
+ hw["i2c_buses"] = len(result.stdout.strip().split("\n"))
422
+ except Exception:
423
+ pass
424
+
425
+ try:
426
+ result = subprocess.run(
427
+ ["ip", "-br", "link"], capture_output=True, text=True, timeout=2
428
+ )
429
+ if result.returncode == 0:
430
+ interfaces = []
431
+ for line in result.stdout.strip().split("\n"):
432
+ parts = line.split()
433
+ if len(parts) >= 2 and parts[0] != "lo":
434
+ interfaces.append({
435
+ "name": parts[0],
436
+ "state": parts[1],
437
+ "mac": parts[2] if len(parts) > 2 else None
438
+ })
439
+ hw["network_interfaces"] = interfaces
440
+ except Exception:
441
+ pass
442
+
443
+ try:
444
+ hw["kernel"] = platform.release()
445
+ except Exception:
446
+ pass
447
+
448
+ try:
449
+ result = subprocess.run(
450
+ ["vcgencmd", "version"], capture_output=True, text=True, timeout=1
451
+ )
452
+ if result.returncode == 0:
453
+ hw["firmware_date"] = result.stdout.strip().split("\n")[0]
454
+ except Exception:
455
+ pass
456
+
457
+ cache["hardware"] = hw
458
+ return {"hardware_info": hw}
hello_world/websocket.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WebSocket endpoint for Hello World.
3
+
4
+ Endpoints:
5
+ /ws/live - Robot state, head pose, system stats
6
+ /ws/intercom - Browser mic audio to robot speaker (+ conversation listener routing)
7
+ /ws/transcribe - Passive receiver for transcriptions, responses, TTS audio
8
+ """
9
+
10
+ __all__ = ['register_websockets']
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import time
16
+ import urllib.request
17
+
18
+ import numpy as np
19
+ from fastapi import WebSocket, WebSocketDisconnect
20
+ from scipy.spatial.transform import Rotation as R
21
+
22
+ from .config import config
23
+
24
+ logger = logging.getLogger("reachy_mini.app.hello_world.websocket")
25
+
26
+
27
+ def register_websockets(app) -> None:
28
+ """Register WebSocket endpoints on the app."""
29
+
30
+ @app.settings_app.websocket("/ws/live")
31
+ async def live_websocket(websocket: WebSocket):
32
+ """Stream live data to browser with subscription support.
33
+
34
+ Client can send JSON messages to control what data is streamed:
35
+ {"subscribe": ["robot", "stats"]} - subscribe to both
36
+ {"subscribe": ["stats"]} - stats only
37
+ {"subscribe": []} - minimal (just tick)
38
+ """
39
+ await websocket.accept()
40
+ logger.info("LIVE: WebSocket connected")
41
+
42
+ subscriptions = {"robot", "stats"}
43
+ connected = True
44
+
45
+ tick = 0
46
+ send_motor_status = True
47
+
48
+ async def handle_client_messages():
49
+ nonlocal subscriptions, connected
50
+ try:
51
+ while connected:
52
+ msg = await websocket.receive_json()
53
+ if "subscribe" in msg:
54
+ new_subs = set(msg["subscribe"])
55
+ valid_subs = new_subs & {"robot", "stats"}
56
+ subscriptions = valid_subs
57
+ logger.info(f"LIVE: Subscriptions updated: {subscriptions}")
58
+ except WebSocketDisconnect:
59
+ connected = False
60
+ except Exception as e:
61
+ connected = False
62
+ logger.debug(f"LIVE: Client message error: {e}")
63
+
64
+ receive_task = asyncio.create_task(handle_client_messages())
65
+
66
+ try:
67
+ while connected:
68
+ robot_hz = max(1, int(app._settings.get("robot_update_hz", 15)))
69
+ stats_hz = max(1, int(app._settings.get("stats_update_hz", 1)))
70
+ stats_interval = max(1, robot_hz // stats_hz) if stats_hz > 0 else robot_hz
71
+
72
+ data = {"tick": tick}
73
+
74
+ # Robot state
75
+ if "robot" in subscriptions and app._reachy_mini is not None:
76
+ try:
77
+ pose_matrix = app._reachy_mini.get_current_head_pose()
78
+ roll, pitch, yaw = R.from_matrix(pose_matrix[:3, :3]).as_euler("xyz")
79
+ x, y, z = pose_matrix[:3, 3]
80
+ head_joints, antenna_joints = app._reachy_mini.get_current_joint_positions()
81
+ data["robot"] = {
82
+ "head_pose": {
83
+ "x": float(x), "y": float(y), "z": float(z),
84
+ "roll": float(np.degrees(roll)),
85
+ "pitch": float(np.degrees(pitch)),
86
+ "yaw": float(np.degrees(yaw)),
87
+ },
88
+ "head_joints": [float(np.degrees(j)) for j in head_joints],
89
+ "antenna_joints": [float(np.degrees(j)) for j in antenna_joints],
90
+ }
91
+ except Exception as e:
92
+ data["robot"] = {"error": str(e)}
93
+
94
+ # System stats
95
+ if "stats" in subscriptions and tick % stats_interval == 0:
96
+ data["stats"] = app._get_stats()
97
+ data["status"] = {
98
+ "app": "hello_world",
99
+ "uptime": time.time() - app._start_time,
100
+ }
101
+
102
+ # Daemon health (pushed via WS, no frontend polling needed)
103
+ try:
104
+ with urllib.request.urlopen(
105
+ config.get_daemon_endpoint("daemon/status"),
106
+ timeout=2
107
+ ) as resp:
108
+ daemon_data = json.loads(resp.read().decode())
109
+ serial_ok = not (
110
+ daemon_data.get("error") == "channel closed" or
111
+ daemon_data.get("backend_status", {}).get("error") == "channel closed" or
112
+ daemon_data.get("state") == "error"
113
+ )
114
+ data["daemon"] = {"api": "ok", "serial": "ok" if serial_ok else "error"}
115
+ except Exception:
116
+ data["daemon"] = {"api": "error", "serial": "unknown"}
117
+
118
+ # Motor status on first tick
119
+ if send_motor_status:
120
+ try:
121
+ with urllib.request.urlopen(
122
+ config.get_daemon_endpoint("motors/status"),
123
+ timeout=config.TIMEOUTS['daemon_move']
124
+ ) as resp:
125
+ status = json.loads(resp.read().decode())
126
+ data["motors"] = {"mode": status.get("mode", "unknown")}
127
+ except Exception:
128
+ data["motors"] = {"mode": "unknown"}
129
+ send_motor_status = False
130
+
131
+ if connected:
132
+ await websocket.send_json(data)
133
+ tick += 1
134
+ await asyncio.sleep(1 / robot_hz)
135
+
136
+ except WebSocketDisconnect:
137
+ logger.info("LIVE: WebSocket disconnected")
138
+ except RuntimeError as e:
139
+ if "websocket" in str(e).lower():
140
+ logger.info("LIVE: WebSocket closed during send")
141
+ else:
142
+ logger.error(f"LIVE ERROR: {e}")
143
+ except Exception as e:
144
+ logger.error(f"LIVE ERROR: {e}", exc_info=True)
145
+ finally:
146
+ connected = False
147
+ receive_task.cancel()
148
+ try:
149
+ await receive_task
150
+ except asyncio.CancelledError:
151
+ pass
152
+
153
+ @app.settings_app.websocket("/ws/intercom")
154
+ async def intercom_websocket(websocket: WebSocket):
155
+ """Receive browser mic audio and play through robot speaker.
156
+
157
+ Also routes audio to conversation listener when:
158
+ - Listener is running AND audio_input is "browser"
159
+
160
+ Expects binary frames of int16 PCM at 16kHz.
161
+ """
162
+ await websocket.accept()
163
+ logger.info("INTERCOM: WebSocket connected")
164
+
165
+ # Check if this should route to robot speaker
166
+ # (always start playback — intercom is always available)
167
+ try:
168
+ app._reachy_mini.media.start_playing()
169
+ except Exception as e:
170
+ logger.error(f"INTERCOM: Failed to start media playback: {e}")
171
+ await websocket.close(code=1011, reason="Failed to start audio playback")
172
+ return
173
+
174
+ try:
175
+ while True:
176
+ data = await websocket.receive_bytes()
177
+ pcm_int16 = np.frombuffer(data, dtype=np.int16)
178
+
179
+ # Always push to robot speaker (intercom functionality)
180
+ audio_float32 = pcm_int16.astype(np.float32) / 32768.0
181
+ app._reachy_mini.media.push_audio_sample(audio_float32)
182
+
183
+ # Also route to conversation listener if active + browser mic
184
+ from .api.listener import feed_audio_chunk, is_listener_running
185
+ if is_listener_running() and app._settings.get("audio_input") == "browser":
186
+ feed_audio_chunk(pcm_int16.tobytes())
187
+
188
+ except WebSocketDisconnect:
189
+ logger.info("INTERCOM: WebSocket disconnected")
190
+ except Exception as e:
191
+ logger.error(f"INTERCOM ERROR: {e}", exc_info=True)
192
+ finally:
193
+ try:
194
+ app._reachy_mini.media.stop_playing()
195
+ except Exception as e:
196
+ logger.warning(f"INTERCOM: Error stopping playback: {e}")
197
+
198
+ @app.settings_app.websocket("/ws/transcribe")
199
+ async def transcribe_websocket(websocket: WebSocket):
200
+ """Passive receiver for transcription data, LLM responses, tool activity, and TTS audio.
201
+
202
+ Clients connect and receive broadcast messages. No data sent from client.
203
+ Used by the Conversation tab to display live transcript.
204
+ """
205
+ await websocket.accept()
206
+ app._transcribe_websockets.add(websocket)
207
+ logger.info(f"TRANSCRIBE: WebSocket connected ({len(app._transcribe_websockets)} clients)")
208
+
209
+ # Send initial listener status so UI is correct without polling
210
+ try:
211
+ from .api.listener import get_listener_status
212
+ status = get_listener_status()
213
+ await websocket.send_json({"type": "listener_status", **status})
214
+ except Exception:
215
+ pass
216
+
217
+ try:
218
+ while True:
219
+ # Keep connection alive — client may send pings
220
+ await websocket.receive_text()
221
+ except WebSocketDisconnect:
222
+ pass
223
+ except Exception:
224
+ pass
225
+ finally:
226
+ app._transcribe_websockets.discard(websocket)
227
+ logger.info(f"TRANSCRIBE: WebSocket disconnected ({len(app._transcribe_websockets)} clients)")
index.html ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width" />
6
+ <title>Hello World — Reachy Mini DevKit</title>
7
+ <meta name="description" content="A community dashboard app for Reachy Mini with AI conversation, system telemetry, music playback, and robot control." />
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+ <body>
11
+ <div class="hero">
12
+ <div class="hero-content">
13
+ <div class="app-icon">&#x1F916;&#x1F4CA;</div>
14
+ <h1>Hello World</h1>
15
+ <p class="tagline">A full-featured community dashboard for Reachy Mini</p>
16
+ <p class="subtitle">AI conversation, system telemetry, music, and more — all in one app</p>
17
+ </div>
18
+ </div>
19
+
20
+ <div class="container">
21
+ <div class="main-card">
22
+ <div class="features-section">
23
+ <h2>Features</h2>
24
+ <div class="feature-grid">
25
+ <div class="feature-item">
26
+ <div class="feature-icon">&#x1F4AC;</div>
27
+ <h3>AI Conversation</h3>
28
+ <p>Voice-activated assistant with STT, LLM chat, and TTS. Supports OpenAI, Anthropic, Groq, Gemini, DeepSeek, and ElevenLabs.</p>
29
+ </div>
30
+ <div class="feature-item">
31
+ <div class="feature-icon">&#x1F4CA;</div>
32
+ <h3>System Telemetry</h3>
33
+ <p>Live charts for CPU, RAM, disk, network, WiFi, and thermal stats. Real-time head pose and antenna telemetry.</p>
34
+ </div>
35
+ <div class="feature-item">
36
+ <div class="feature-icon">&#x1F441;</div>
37
+ <h3>Vision (VLM)</h3>
38
+ <p>Vision-capable models auto-detected per provider. Take snapshots and let the AI describe what it sees.</p>
39
+ </div>
40
+ <div class="feature-item">
41
+ <div class="feature-icon">&#x1F3B5;</div>
42
+ <h3>Music Playback</h3>
43
+ <p>Upload and play music through the robot speaker via GStreamer. Full library management with metadata and cover art.</p>
44
+ </div>
45
+ <div class="feature-item">
46
+ <div class="feature-icon">&#x1F399;</div>
47
+ <h3>Audio Routing</h3>
48
+ <p>Selectable input (robot mic or browser) and output (robot speaker or browser). Queued TTS with no overlapping speech.</p>
49
+ </div>
50
+ <div class="feature-item">
51
+ <div class="feature-icon">&#x1F4F9;</div>
52
+ <h3>Recording</h3>
53
+ <p>Video recording from the robot camera, audio recording from the mic, and snapshot capture — all controllable via AI tools.</p>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+
59
+ <div class="info-card">
60
+ <h2>AI Pipeline</h2>
61
+ <div class="pipeline-grid">
62
+ <div class="pipeline-item">
63
+ <strong>STT</strong>
64
+ <span>OpenAI Whisper, Groq</span>
65
+ </div>
66
+ <div class="pipeline-item">
67
+ <strong>LLM</strong>
68
+ <span>OpenAI, Anthropic, Groq, Gemini, DeepSeek</span>
69
+ </div>
70
+ <div class="pipeline-item">
71
+ <strong>TTS</strong>
72
+ <span>OpenAI, ElevenLabs, Groq Orpheus, Gemini</span>
73
+ </div>
74
+ <div class="pipeline-item">
75
+ <strong>VLM</strong>
76
+ <span>Vision models auto-detected per provider</span>
77
+ </div>
78
+ </div>
79
+ <p class="pipeline-note">All providers unified through LiteLLM. API keys entered in the web UI — no environment variables needed.</p>
80
+ </div>
81
+
82
+ <div class="info-card">
83
+ <h2>What You Can Ask Reachy</h2>
84
+ <p>The AI assistant has <strong>13 tools</strong> it can call autonomously during conversation:</p>
85
+ <ul class="tools-list">
86
+ <li><strong>71 emotions</strong> — "Show me you're happy", "Are you curious?"</li>
87
+ <li><strong>19 dances</strong> — "Dance for me", "Do the chicken peck"</li>
88
+ <li><strong>Head movement</strong> — "Look left", "Look up at the ceiling"</li>
89
+ <li><strong>Camera snapshots</strong> — "Take a photo", "What do you see?"</li>
90
+ <li><strong>Video recording</strong> — "Record a ten second video"</li>
91
+ <li><strong>Audio recording</strong> — "Record what you hear for five seconds"</li>
92
+ <li><strong>Music playback</strong> — "Play some music", "Stop the music"</li>
93
+ <li><strong>System status</strong> — "How are your systems doing?"</li>
94
+ <li><strong>Date and time</strong> — "What time is it?"</li>
95
+ </ul>
96
+ </div>
97
+
98
+ <div class="info-card">
99
+ <h2>Getting Started</h2>
100
+ <ol class="steps-list">
101
+ <li>Clone or install this app on your Reachy Mini: <code>pip install -e .</code></li>
102
+ <li>Restart the daemon: <code>reachy-restart</code></li>
103
+ <li>Open the dashboard at <code>http://localhost:8000</code></li>
104
+ <li>Find <strong>Hello World</strong> in Applications and toggle it on</li>
105
+ <li>Click the gear icon or go to <code>http://localhost:8042</code> to open the dashboard</li>
106
+ <li>Configure your AI providers in the Conversation tab settings</li>
107
+ </ol>
108
+ </div>
109
+
110
+ <div class="info-card">
111
+ <h2>Architecture</h2>
112
+ <pre class="arch-tree">hello_world/
113
+ ├── app.py # ReachyMiniApp entry point
114
+ ├── config.py # Centralized config
115
+ ├── settings.py # Settings persistence
116
+ ├── websocket.py # WebSocket endpoints
117
+ ├── api/
118
+ │ ├── conversation.py # LLM chat, STT, TTS, VLM
119
+ │ ├── listener.py # Voice activity detection
120
+ │ ├── music.py # Music library + GStreamer
121
+ │ ├── recordings.py # Video recording
122
+ │ ├── snapshots.py # Camera snapshots
123
+ │ ├── sounds.py # Audio recording
124
+ │ └── system.py # System telemetry
125
+ └── static/
126
+ ├── index.html # Single-page dashboard
127
+ ├── css/styles.css # Theme-aware (light/dark)
128
+ └── js/ # Modular JS (core + features)</pre>
129
+ </div>
130
+ </div>
131
+
132
+ <div class="footer">
133
+ <p>
134
+ Hello World for Reachy Mini
135
+ &middot; Built with <a href="https://github.com/pollen-robotics/reachy_mini" target="_blank">reachy-mini SDK</a>
136
+ &middot; <a href="https://pollen-robotics-reachy-mini.hf.space/#/apps" target="_blank">Browse More Apps</a>
137
+ </p>
138
+ </div>
139
+ </body>
140
+ </html>
pyproject.toml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hello-world"
7
+ version = "0.1.0"
8
+ description = "A community hello world app for Reachy Mini with system telemetry dashboard"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "reachy-mini",
12
+ "litellm",
13
+ "webrtcvad",
14
+ "soundfile",
15
+ "setuptools", # webrtcvad needs pkg_resources at runtime
16
+ ]
17
+ keywords = ["reachy-mini-app"]
18
+
19
+ [project.entry-points."reachy_mini_apps"]
20
+ hello_world = "hello_world.main:HelloWorld"
21
+
22
+ [tool.setuptools]
23
+ package-dir = { "" = "." }
24
+ include-package-data = true
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["."]
28
+
29
+ [tool.setuptools.package-data]
30
+ hello_world = ["**/*"]