Spaces:
Running
Running
PanGalactic commited on
Commit ·
05ad16c
0
Parent(s):
Initial release — Reachy Mini Hello World DevKit
Browse filesAuthored by Panny Malialis in partnership with various LLMs
This view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +13 -0
- .gitignore +24 -0
- README.md +137 -0
- hello_world/__init__.py +3 -0
- hello_world/api/__init__.py +35 -0
- hello_world/api/conversation.py +1364 -0
- hello_world/api/health.py +80 -0
- hello_world/api/listener.py +648 -0
- hello_world/api/model.py +119 -0
- hello_world/api/music.py +316 -0
- hello_world/api/recordings.py +327 -0
- hello_world/api/settings_api.py +76 -0
- hello_world/api/snapshots.py +196 -0
- hello_world/api/sounds.py +278 -0
- hello_world/api/system.py +97 -0
- hello_world/api/transcript.py +103 -0
- hello_world/app.py +94 -0
- hello_world/config.py +120 -0
- hello_world/main.py +16 -0
- hello_world/settings.py +95 -0
- hello_world/sounds/camera.wav +3 -0
- hello_world/static/css/styles.css +1240 -0
- hello_world/static/index.html +652 -0
- hello_world/static/js/controls.js +1089 -0
- hello_world/static/js/controls/Joystick.js +339 -0
- hello_world/static/js/core/api-client.js +86 -0
- hello_world/static/js/core/constants.js +95 -0
- hello_world/static/js/core/drag-utils.js +529 -0
- hello_world/static/js/core/init.js +928 -0
- hello_world/static/js/core/settings-manager.js +85 -0
- hello_world/static/js/core/tabs.js +88 -0
- hello_world/static/js/features/FloatingPanel.js +375 -0
- hello_world/static/js/features/assistant.js +105 -0
- hello_world/static/js/features/llm-settings.js +380 -0
- hello_world/static/js/features/status-manager.js +106 -0
- hello_world/static/js/features/transcribe.js +554 -0
- hello_world/static/js/intercom-processor.js +41 -0
- hello_world/static/js/kinematics-wasm/reachy_mini_kinematics_wasm.js +166 -0
- hello_world/static/js/kinematics-wasm/reachy_mini_kinematics_wasm_bg.wasm +3 -0
- hello_world/static/js/media/webrtc.js +282 -0
- hello_world/static/js/simulation.js +456 -0
- hello_world/static/js/websocket.js +923 -0
- hello_world/static/lib/mujoco/mujoco_wasm.js +3 -0
- hello_world/static/lib/three/addons/controls/OrbitControls.js +1417 -0
- hello_world/static/lib/three/addons/utils/BufferGeometryUtils.js +1375 -0
- hello_world/static/lib/three/three.module.js +0 -0
- hello_world/stats.py +458 -0
- hello_world/websocket.py +227 -0
- index.html +140 -0
- 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">🔊</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">📷</button>
|
| 53 |
+
<button id="joystickToggle" class="btn btn-secondary btn-sm" title="Toggle joystick">🕹</button>
|
| 54 |
+
<button id="themeToggle" class="btn btn-secondary btn-sm" title="Toggle theme">☀</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">↓</span><span id="netDownValue" class="net-value">--</span></span>
|
| 135 |
+
<span><span class="text-error">↑</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">×</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">↺</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">×</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">📹</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">📸</button>
|
| 622 |
+
<button id="recordBtn" class="cam-btn" title="Record video">⏺</button>
|
| 623 |
+
<button id="micRecordBtn" class="cam-btn" title="Record mic">🎤</button>
|
| 624 |
+
<button id="listenBtn" class="cam-btn" title="Listen to robot mic">🔊</button>
|
| 625 |
+
<button id="speakBtn" class="cam-btn" title="Speak through robot">🎙</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 = '🎙';
|
| 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 = '🔊';
|
| 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 = '🔇'; // mute icon
|
| 331 |
+
} else {
|
| 332 |
+
videoFeed.muted = true;
|
| 333 |
+
listenBtn.classList.remove('listening');
|
| 334 |
+
listenBtn.innerHTML = '🔊'; // 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 = '🔇'; // 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">×</button>`;
|
| 427 |
+
} else {
|
| 428 |
+
lb.innerHTML = `<img src="${src}" alt=""><button class="media-lightbox-close">×</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">⬇</button>
|
| 454 |
+
<button class="media-action-btn delete" title="Delete">🗑</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">⬇</button>
|
| 513 |
+
<button class="media-action-btn delete" title="Delete">🗑</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">▶</button>
|
| 572 |
+
<button class="media-action-btn download" title="Download">⬇</button>
|
| 573 |
+
<button class="media-action-btn delete" title="Delete">🗑</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, '&')
|
| 538 |
+
.replace(/</g, '<')
|
| 539 |
+
.replace(/>/g, '>')
|
| 540 |
+
.replace(/"/g, '"');
|
| 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">🤖📊</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">💬</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">📊</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">👁</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">🎵</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">🎙</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">📹</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 |
+
· Built with <a href="https://github.com/pollen-robotics/reachy_mini" target="_blank">reachy-mini SDK</a>
|
| 136 |
+
· <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 = ["**/*"]
|