cduss commited on
Commit
a6a40fe
·
0 Parent(s):
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ *.egg-info/
3
+ build/
README.md ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Reachy Mini Debug & CI Toolbox
3
+ emoji: 🔧
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: static
7
+ pinned: false
8
+ short_description: Debug and CI validation toolbox for Reachy Mini
9
+ tags:
10
+ - reachy_mini
11
+ - reachy_mini_python_app
12
+ - debug
13
+ - testing
14
+ - ci
15
+ ---
16
+
17
+ # Reachy Mini Debug & CI Toolbox
18
+
19
+ A comprehensive web-based debugging and CI validation tool for Reachy Mini.
20
+
21
+ ## Features
22
+
23
+ ### Robot Status
24
+ - Real-time head pose display (roll, pitch, yaw)
25
+ - Detailed motor positions for all 9 motors
26
+ - Connection status indicator
27
+
28
+ ### Movement Control
29
+ - Head position control with sliders (roll, pitch, yaw)
30
+ - Antenna position control
31
+ - Quick actions: Go to Zero, Wake Up, Go to Sleep
32
+ - Adjustable movement duration
33
+
34
+ ### Camera
35
+ - Live camera stream (MJPEG)
36
+ - Capture and save images
37
+ - Download captured images
38
+ - List and manage saved captures
39
+
40
+ ### Audio Recording
41
+ - Record audio from the robot's microphone
42
+ - List saved recordings with metadata
43
+ - Play recordings through the robot's speaker
44
+ - Download recordings as WAV files
45
+
46
+ ### Rotation Validation Test
47
+ Automated test for validating head rotation accuracy:
48
+ 1. Moves head to zero position and captures image
49
+ 2. Rolls head by specified angle
50
+ 3. Captures second image
51
+ 4. Computes rotation between images using ORB feature matching
52
+ 5. Compares expected vs actual rotation
53
+ 6. Returns PASS/FAIL with detailed metrics
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ pip install -e .
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ ### As a Reachy Mini App (daemon on)
64
+ The app registers as a Reachy Mini app and can be launched via the daemon:
65
+ ```bash
66
+ reachy-mini app run reachy_mini_toolbox
67
+ ```
68
+
69
+ ### Direct Launch
70
+ ```bash
71
+ python -m reachy_mini_toolbox.main
72
+ ```
73
+
74
+ Then open http://localhost:8042 in your browser.
75
+
76
+ ## API Endpoints
77
+
78
+ ### Status
79
+ - `GET /api/status` - Get robot status and head pose
80
+ - `GET /api/motor_status` - Get detailed motor positions
81
+
82
+ ### Movement
83
+ - `POST /api/move_head` - Move head to specified orientation
84
+ - `POST /api/move_antennas` - Move antennas to specified positions
85
+ - `POST /api/go_to_zero` - Move to neutral position
86
+ - `POST /api/wake_up` - Execute wake up behavior
87
+ - `POST /api/go_to_sleep` - Execute sleep behavior
88
+
89
+ ### Camera
90
+ - `GET /api/camera/stream` - MJPEG camera stream
91
+ - `GET /api/camera/capture` - Capture single frame as base64
92
+ - `POST /api/camera/save` - Save current frame to file
93
+ - `GET /api/camera/list` - List saved captures
94
+ - `GET /api/camera/download/{filename}` - Download capture
95
+ - `DELETE /api/camera/delete/{filename}` - Delete capture
96
+
97
+ ### Audio
98
+ - `POST /api/audio/start_recording` - Start recording
99
+ - `POST /api/audio/stop_recording` - Stop and save recording
100
+ - `GET /api/audio/list` - List recordings
101
+ - `GET /api/audio/download/{filename}` - Download recording
102
+ - `POST /api/audio/play/{filename}` - Play recording on robot
103
+ - `DELETE /api/audio/delete/{filename}` - Delete recording
104
+
105
+ ### Validation Tests
106
+ - `POST /api/test/rotation_validation` - Run rotation validation test
107
+ - `GET /api/test/last_rotation_result` - Get last test result
108
+
109
+ ## File Storage
110
+
111
+ - Recordings: `/tmp/reachy_mini_toolbox/recordings/`
112
+ - Captures: `/tmp/reachy_mini_toolbox/captures/`
113
+
114
+ ## Dependencies
115
+
116
+ - reachy-mini
117
+ - opencv-python
118
+ - soundfile
119
+ - scipy
pyproject.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+
6
+ [project]
7
+ name = "reachy_mini_toolbox"
8
+ version = "0.1.0"
9
+ description = "Debug & CI validation toolbox for Reachy Mini - motor status, movement testing, audio recording, camera capture, and rotation validation"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "reachy-mini",
14
+ "opencv-python",
15
+ "soundfile",
16
+ "scipy",
17
+ ]
18
+ keywords = ["reachy-mini-app", "debug", "testing", "ci", "validation"]
19
+
20
+ [project.entry-points."reachy_mini_apps"]
21
+ reachy_mini_toolbox = "reachy_mini_toolbox.main:ReachyMiniToolbox"
22
+
23
+ [tool.setuptools]
24
+ package-dir = { "" = "." }
25
+ include-package-data = true
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["."]
29
+
30
+ [tool.setuptools.package-data]
31
+ reachy_mini_toolbox = ["**/*"] # Also include all non-.py files
reachy_mini_toolbox/__init__.py ADDED
File without changes
reachy_mini_toolbox/main.py ADDED
@@ -0,0 +1,624 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reachy Mini Debug & CI Validation Toolbox.
2
+
3
+ A comprehensive debugging and validation tool for Reachy Mini.
4
+ Features:
5
+ - Motor status check (daemon-on mode)
6
+ - Movement validation
7
+ - Audio recording, playback, listing, and download
8
+ - Camera capture and download
9
+ - Advanced head rotation validation test
10
+ """
11
+
12
+ import base64
13
+ import logging
14
+ import os
15
+ import threading
16
+ import time
17
+ from dataclasses import dataclass
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import cv2
23
+ import numpy as np
24
+ import soundfile as sf
25
+ from fastapi import FastAPI, HTTPException, Response
26
+ from fastapi.responses import FileResponse, StreamingResponse
27
+ from pydantic import BaseModel
28
+ from scipy.spatial.transform import Rotation as R
29
+
30
+ from reachy_mini import ReachyMini, ReachyMiniApp
31
+ from reachy_mini.utils import create_head_pose
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Storage paths
36
+ RECORDINGS_DIR = Path("/tmp/reachy_mini_toolbox/recordings")
37
+ CAPTURES_DIR = Path("/tmp/reachy_mini_toolbox/captures")
38
+ RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
39
+ CAPTURES_DIR.mkdir(parents=True, exist_ok=True)
40
+
41
+
42
+ # ==================== Data Models ====================
43
+
44
+ class MoveRequest(BaseModel):
45
+ roll: float = 0.0
46
+ pitch: float = 0.0
47
+ yaw: float = 0.0
48
+ duration: float = 1.0
49
+
50
+
51
+ class AntennaRequest(BaseModel):
52
+ right: float = 0.0
53
+ left: float = 0.0
54
+ duration: float = 0.5
55
+
56
+
57
+ class RotationTestRequest(BaseModel):
58
+ roll_angle: float = 90.0
59
+ duration: float = 2.0
60
+
61
+
62
+ # ==================== State Manager ====================
63
+
64
+ @dataclass
65
+ class ToolboxState:
66
+ """Shared state for the toolbox."""
67
+ reachy_mini: ReachyMini | None = None
68
+ is_recording_audio: bool = False
69
+ audio_buffer: list = None
70
+ audio_samplerate: int = 16000
71
+ last_rotation_test: dict | None = None
72
+
73
+ def __post_init__(self):
74
+ self.audio_buffer = []
75
+
76
+
77
+ state = ToolboxState()
78
+
79
+
80
+ # ==================== Main App ====================
81
+
82
+ class ReachyMiniToolbox(ReachyMiniApp):
83
+ """Reachy Mini Debug & CI Validation Toolbox."""
84
+
85
+ custom_app_url: str | None = "http://0.0.0.0:8042"
86
+ request_media_backend: str | None = "gstreamer" # Use GStreamer for on-device camera
87
+
88
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
89
+ global state
90
+ state.reachy_mini = reachy_mini
91
+
92
+ # Ensure camera pipeline is started
93
+ if reachy_mini.media.camera is not None and hasattr(reachy_mini.media.camera, 'pipeline'):
94
+ from gi.repository import Gst
95
+ pipeline = reachy_mini.media.camera.pipeline
96
+ if pipeline.get_state(0).state != Gst.State.PLAYING:
97
+ reachy_mini.media.camera.open()
98
+ time.sleep(0.5)
99
+
100
+ # Setup all API endpoints
101
+ self._setup_endpoints()
102
+
103
+ logger.info("Reachy Mini Toolbox ready")
104
+
105
+ # Main loop - keep app alive
106
+ while not stop_event.is_set():
107
+ time.sleep(0.1)
108
+
109
+ def _setup_endpoints(self):
110
+ """Setup all FastAPI endpoints."""
111
+ app = self.settings_app
112
+
113
+ # ==================== Status Endpoints ====================
114
+
115
+ @app.get("/api/status")
116
+ async def get_status():
117
+ """Get overall robot status."""
118
+ if state.reachy_mini is None:
119
+ raise HTTPException(503, "Robot not connected")
120
+
121
+ try:
122
+ head_joints, antenna_joints = state.reachy_mini.get_current_joint_positions()
123
+ head_pose = state.reachy_mini.get_current_head_pose()
124
+
125
+ # Extract euler angles from head pose
126
+ rotation = R.from_matrix(head_pose[:3, :3])
127
+ euler = rotation.as_euler('xyz', degrees=True)
128
+
129
+ return {
130
+ "connected": True,
131
+ "head_joints": head_joints,
132
+ "antenna_joints": antenna_joints,
133
+ "head_pose": {
134
+ "roll": float(euler[0]),
135
+ "pitch": float(euler[1]),
136
+ "yaw": float(euler[2]),
137
+ },
138
+ "timestamp": datetime.now().isoformat()
139
+ }
140
+ except Exception as e:
141
+ return {"connected": False, "error": str(e)}
142
+
143
+ @app.get("/api/motor_status")
144
+ async def get_motor_status():
145
+ """Get detailed motor status."""
146
+ if state.reachy_mini is None:
147
+ raise HTTPException(503, "Robot not connected")
148
+
149
+ try:
150
+ head_joints, antenna_joints = state.reachy_mini.get_current_joint_positions()
151
+
152
+ motors = {
153
+ "body_rotation": {"position": np.rad2deg(head_joints[0]), "unit": "deg"},
154
+ "stewart_1": {"position": np.rad2deg(head_joints[1]), "unit": "deg"},
155
+ "stewart_2": {"position": np.rad2deg(head_joints[2]), "unit": "deg"},
156
+ "stewart_3": {"position": np.rad2deg(head_joints[3]), "unit": "deg"},
157
+ "stewart_4": {"position": np.rad2deg(head_joints[4]), "unit": "deg"},
158
+ "stewart_5": {"position": np.rad2deg(head_joints[5]), "unit": "deg"},
159
+ "stewart_6": {"position": np.rad2deg(head_joints[6]), "unit": "deg"},
160
+ "right_antenna": {"position": np.rad2deg(antenna_joints[0]), "unit": "deg"},
161
+ "left_antenna": {"position": np.rad2deg(antenna_joints[1]), "unit": "deg"},
162
+ }
163
+
164
+ return {
165
+ "motors": motors,
166
+ "timestamp": datetime.now().isoformat()
167
+ }
168
+ except Exception as e:
169
+ raise HTTPException(500, str(e))
170
+
171
+ # ==================== Movement Endpoints ====================
172
+
173
+ @app.post("/api/move_head")
174
+ async def move_head(req: MoveRequest):
175
+ """Move head to specified orientation."""
176
+ if state.reachy_mini is None:
177
+ raise HTTPException(503, "Robot not connected")
178
+
179
+ try:
180
+ target_pose = create_head_pose(
181
+ roll=req.roll,
182
+ pitch=req.pitch,
183
+ yaw=req.yaw,
184
+ degrees=True
185
+ )
186
+ state.reachy_mini.goto_target(head=target_pose, duration=req.duration)
187
+ return {"success": True, "message": f"Moved to roll={req.roll}, pitch={req.pitch}, yaw={req.yaw}"}
188
+ except Exception as e:
189
+ raise HTTPException(500, str(e))
190
+
191
+ @app.post("/api/move_antennas")
192
+ async def move_antennas(req: AntennaRequest):
193
+ """Move antennas to specified positions."""
194
+ if state.reachy_mini is None:
195
+ raise HTTPException(503, "Robot not connected")
196
+
197
+ try:
198
+ antennas = np.deg2rad([req.right, req.left])
199
+ state.reachy_mini.goto_target(antennas=antennas, duration=req.duration)
200
+ return {"success": True, "message": f"Antennas moved to right={req.right}, left={req.left}"}
201
+ except Exception as e:
202
+ raise HTTPException(500, str(e))
203
+
204
+ @app.post("/api/go_to_zero")
205
+ async def go_to_zero():
206
+ """Move to zero/neutral position."""
207
+ if state.reachy_mini is None:
208
+ raise HTTPException(503, "Robot not connected")
209
+
210
+ try:
211
+ state.reachy_mini.goto_target(
212
+ head=np.eye(4),
213
+ antennas=[0.0, 0.0],
214
+ duration=1.5
215
+ )
216
+ return {"success": True, "message": "Moved to zero position"}
217
+ except Exception as e:
218
+ raise HTTPException(500, str(e))
219
+
220
+ @app.post("/api/wake_up")
221
+ async def wake_up():
222
+ """Execute wake up behavior."""
223
+ if state.reachy_mini is None:
224
+ raise HTTPException(503, "Robot not connected")
225
+
226
+ try:
227
+ state.reachy_mini.wake_up()
228
+ return {"success": True, "message": "Wake up complete"}
229
+ except Exception as e:
230
+ raise HTTPException(500, str(e))
231
+
232
+ @app.post("/api/go_to_sleep")
233
+ async def go_to_sleep():
234
+ """Execute sleep behavior."""
235
+ if state.reachy_mini is None:
236
+ raise HTTPException(503, "Robot not connected")
237
+
238
+ try:
239
+ state.reachy_mini.goto_sleep()
240
+ return {"success": True, "message": "Sleep complete"}
241
+ except Exception as e:
242
+ raise HTTPException(500, str(e))
243
+
244
+ # ==================== Audio Endpoints ====================
245
+
246
+ @app.post("/api/audio/start_recording")
247
+ async def start_audio_recording():
248
+ """Start recording audio."""
249
+ if state.reachy_mini is None:
250
+ raise HTTPException(503, "Robot not connected")
251
+
252
+ try:
253
+ state.audio_buffer = []
254
+ state.is_recording_audio = True
255
+ state.audio_samplerate = state.reachy_mini.media.get_input_audio_samplerate()
256
+ state.reachy_mini.media.start_recording()
257
+
258
+ # Start background thread to collect audio
259
+ def collect_audio():
260
+ while state.is_recording_audio:
261
+ sample = state.reachy_mini.media.get_audio_sample()
262
+ if sample is not None:
263
+ state.audio_buffer.append(sample)
264
+ time.sleep(0.05)
265
+
266
+ threading.Thread(target=collect_audio, daemon=True).start()
267
+
268
+ return {"success": True, "message": "Recording started", "samplerate": state.audio_samplerate}
269
+ except Exception as e:
270
+ raise HTTPException(500, str(e))
271
+
272
+ @app.post("/api/audio/stop_recording")
273
+ async def stop_audio_recording():
274
+ """Stop recording and save audio file."""
275
+ if state.reachy_mini is None:
276
+ raise HTTPException(503, "Robot not connected")
277
+
278
+ try:
279
+ state.is_recording_audio = False
280
+ state.reachy_mini.media.stop_recording()
281
+ time.sleep(0.1) # Allow collection thread to finish
282
+
283
+ if not state.audio_buffer:
284
+ return {"success": False, "message": "No audio recorded"}
285
+
286
+ # Concatenate all audio chunks
287
+ audio_data = np.concatenate(state.audio_buffer, axis=0)
288
+
289
+ # Use only first channel if stereo
290
+ if audio_data.ndim > 1:
291
+ audio_data = audio_data[:, 0]
292
+
293
+ # Save to file
294
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
295
+ filename = f"recording_{timestamp}.wav"
296
+ filepath = RECORDINGS_DIR / filename
297
+
298
+ sf.write(filepath, audio_data, state.audio_samplerate)
299
+
300
+ duration = len(audio_data) / state.audio_samplerate
301
+
302
+ return {
303
+ "success": True,
304
+ "filename": filename,
305
+ "duration": duration,
306
+ "samplerate": state.audio_samplerate
307
+ }
308
+ except Exception as e:
309
+ raise HTTPException(500, str(e))
310
+
311
+ @app.get("/api/audio/list")
312
+ async def list_recordings():
313
+ """List all saved recordings."""
314
+ recordings = []
315
+ for f in sorted(RECORDINGS_DIR.glob("*.wav"), reverse=True):
316
+ try:
317
+ info = sf.info(str(f))
318
+ recordings.append({
319
+ "filename": f.name,
320
+ "duration": info.duration,
321
+ "samplerate": info.samplerate,
322
+ "size_kb": f.stat().st_size / 1024,
323
+ "created": datetime.fromtimestamp(f.stat().st_ctime).isoformat()
324
+ })
325
+ except Exception:
326
+ recordings.append({"filename": f.name, "error": "Could not read file info"})
327
+
328
+ return {"recordings": recordings}
329
+
330
+ @app.get("/api/audio/download/{filename}")
331
+ async def download_recording(filename: str):
332
+ """Download a recording file."""
333
+ filepath = RECORDINGS_DIR / filename
334
+ if not filepath.exists():
335
+ raise HTTPException(404, "Recording not found")
336
+
337
+ return FileResponse(
338
+ filepath,
339
+ media_type="audio/wav",
340
+ filename=filename
341
+ )
342
+
343
+ @app.post("/api/audio/play/{filename}")
344
+ async def play_recording(filename: str):
345
+ """Play a recording through the robot's speaker."""
346
+ if state.reachy_mini is None:
347
+ raise HTTPException(503, "Robot not connected")
348
+
349
+ filepath = RECORDINGS_DIR / filename
350
+ if not filepath.exists():
351
+ raise HTTPException(404, "Recording not found")
352
+
353
+ try:
354
+ state.reachy_mini.media.play_sound(str(filepath))
355
+ return {"success": True, "message": f"Playing {filename}"}
356
+ except Exception as e:
357
+ raise HTTPException(500, str(e))
358
+
359
+ @app.delete("/api/audio/delete/{filename}")
360
+ async def delete_recording(filename: str):
361
+ """Delete a recording."""
362
+ filepath = RECORDINGS_DIR / filename
363
+ if not filepath.exists():
364
+ raise HTTPException(404, "Recording not found")
365
+
366
+ filepath.unlink()
367
+ return {"success": True, "message": f"Deleted {filename}"}
368
+
369
+ # ==================== Camera Endpoints ====================
370
+
371
+ def get_frame_with_retry(timeout: float = 30.0) -> np.ndarray | None:
372
+ """Get a camera frame with retry logic."""
373
+ if state.reachy_mini is None:
374
+ return None
375
+
376
+ start_time = time.time()
377
+ while time.time() - start_time < timeout:
378
+ frame = state.reachy_mini.media.get_frame()
379
+ if frame is not None:
380
+ return frame
381
+ time.sleep(0.1)
382
+ return None
383
+
384
+ @app.get("/api/camera/capture")
385
+ async def capture_image():
386
+ """Capture and return current camera frame as base64 JPEG."""
387
+ if state.reachy_mini is None:
388
+ raise HTTPException(503, "Robot not connected")
389
+
390
+ try:
391
+ frame = get_frame_with_retry(timeout=5.0)
392
+ if frame is None:
393
+ raise HTTPException(503, "No camera frame available after timeout")
394
+
395
+ _, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
396
+ b64 = base64.b64encode(buffer).decode('utf-8')
397
+ return {"image": b64, "timestamp": datetime.now().isoformat()}
398
+ except HTTPException:
399
+ raise
400
+ except Exception as e:
401
+ raise HTTPException(500, str(e))
402
+
403
+ @app.get("/api/camera/stream")
404
+ async def camera_stream():
405
+ """Stream camera frames as MJPEG."""
406
+ if state.reachy_mini is None:
407
+ raise HTTPException(503, "Robot not connected")
408
+
409
+ def generate():
410
+ while True:
411
+ frame = state.reachy_mini.media.get_frame()
412
+ if frame is not None:
413
+ _, buffer = cv2.imencode('.jpg', frame)
414
+ frame_bytes = buffer.tobytes()
415
+ yield (b'--frame\r\n'
416
+ b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
417
+ time.sleep(0.033)
418
+
419
+ return StreamingResponse(
420
+ generate(),
421
+ media_type='multipart/x-mixed-replace; boundary=frame'
422
+ )
423
+
424
+ @app.post("/api/camera/save")
425
+ async def save_capture():
426
+ """Save current frame to file."""
427
+ if state.reachy_mini is None:
428
+ raise HTTPException(503, "Robot not connected")
429
+
430
+ try:
431
+ frame = get_frame_with_retry(timeout=5.0)
432
+ if frame is None:
433
+ raise HTTPException(503, "No camera frame available after timeout")
434
+
435
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
436
+ filename = f"capture_{timestamp}.jpg"
437
+ filepath = CAPTURES_DIR / filename
438
+
439
+ cv2.imwrite(str(filepath), frame)
440
+
441
+ return {
442
+ "success": True,
443
+ "filename": filename,
444
+ "path": str(filepath)
445
+ }
446
+ except HTTPException:
447
+ raise
448
+ except Exception as e:
449
+ raise HTTPException(500, str(e))
450
+
451
+ @app.get("/api/camera/list")
452
+ async def list_captures():
453
+ """List all saved captures."""
454
+ captures = []
455
+ for f in sorted(CAPTURES_DIR.glob("*.jpg"), reverse=True):
456
+ captures.append({
457
+ "filename": f.name,
458
+ "size_kb": f.stat().st_size / 1024,
459
+ "created": datetime.fromtimestamp(f.stat().st_ctime).isoformat()
460
+ })
461
+ return {"captures": captures}
462
+
463
+ @app.get("/api/camera/download/{filename}")
464
+ async def download_capture(filename: str):
465
+ """Download a capture file."""
466
+ filepath = CAPTURES_DIR / filename
467
+ if not filepath.exists():
468
+ raise HTTPException(404, "Capture not found")
469
+
470
+ return FileResponse(
471
+ filepath,
472
+ media_type="image/jpeg",
473
+ filename=filename
474
+ )
475
+
476
+ @app.delete("/api/camera/delete/{filename}")
477
+ async def delete_capture(filename: str):
478
+ """Delete a capture."""
479
+ filepath = CAPTURES_DIR / filename
480
+ if not filepath.exists():
481
+ raise HTTPException(404, "Capture not found")
482
+
483
+ filepath.unlink()
484
+ return {"success": True, "message": f"Deleted {filename}"}
485
+
486
+ # ==================== Rotation Validation Test ====================
487
+
488
+ @app.post("/api/test/rotation_validation")
489
+ async def run_rotation_validation(req: RotationTestRequest):
490
+ """Run the advanced rotation validation test.
491
+
492
+ Steps:
493
+ 1. Move to zero position
494
+ 2. Capture image
495
+ 3. Roll head by specified angle
496
+ 4. Capture image
497
+ 5. Compute rotation between images
498
+ 6. Compare expected vs actual rotation
499
+ """
500
+ if state.reachy_mini is None:
501
+ raise HTTPException(503, "Robot not connected")
502
+
503
+ try:
504
+ results = {"steps": [], "success": False}
505
+
506
+ # Step 1: Go to zero position
507
+ results["steps"].append({"step": "go_to_zero", "status": "started"})
508
+ state.reachy_mini.goto_target(head=np.eye(4), antennas=[0.0, 0.0], duration=1.5)
509
+ time.sleep(0.5) # Stabilization
510
+ results["steps"][-1]["status"] = "completed"
511
+
512
+ # Step 2: Capture image at zero position
513
+ results["steps"].append({"step": "capture_zero", "status": "started"})
514
+ image_zero = get_frame_with_retry(timeout=5.0)
515
+ if image_zero is None:
516
+ raise Exception("No camera frame available after timeout")
517
+ gray_zero = cv2.cvtColor(image_zero, cv2.COLOR_BGR2GRAY)
518
+
519
+ # Save for reference
520
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
521
+ zero_filename = f"rotation_test_{timestamp}_zero.jpg"
522
+ cv2.imwrite(str(CAPTURES_DIR / zero_filename), image_zero)
523
+ results["steps"][-1]["status"] = "completed"
524
+ results["steps"][-1]["filename"] = zero_filename
525
+
526
+ # Step 3: Roll head
527
+ results["steps"].append({
528
+ "step": "roll_head",
529
+ "status": "started",
530
+ "target_angle": req.roll_angle
531
+ })
532
+ target_pose = create_head_pose(roll=req.roll_angle, pitch=0, yaw=0, degrees=True)
533
+ state.reachy_mini.goto_target(head=target_pose, duration=req.duration)
534
+ time.sleep(0.5) # Stabilization
535
+ results["steps"][-1]["status"] = "completed"
536
+
537
+ # Step 4: Capture image at rolled position
538
+ results["steps"].append({"step": "capture_rolled", "status": "started"})
539
+ image_rolled = get_frame_with_retry(timeout=5.0)
540
+ if image_rolled is None:
541
+ raise Exception("No camera frame available after timeout")
542
+ gray_rolled = cv2.cvtColor(image_rolled, cv2.COLOR_BGR2GRAY)
543
+
544
+ rolled_filename = f"rotation_test_{timestamp}_rolled.jpg"
545
+ cv2.imwrite(str(CAPTURES_DIR / rolled_filename), image_rolled)
546
+ results["steps"][-1]["status"] = "completed"
547
+ results["steps"][-1]["filename"] = rolled_filename
548
+
549
+ # Step 5: Compute rotation between images
550
+ results["steps"].append({"step": "compute_rotation", "status": "started"})
551
+
552
+ # Use ORB feature detection
553
+ orb = cv2.ORB_create(nfeatures=500)
554
+ kp1, des1 = orb.detectAndCompute(gray_zero, None)
555
+ kp2, des2 = orb.detectAndCompute(gray_rolled, None)
556
+
557
+ if des1 is None or des2 is None or len(kp1) < 10 or len(kp2) < 10:
558
+ results["steps"][-1]["status"] = "failed"
559
+ results["steps"][-1]["error"] = "Not enough features detected"
560
+ results["computed_rotation"] = None
561
+ else:
562
+ # Match features
563
+ bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
564
+ matches = bf.match(des1, des2)
565
+ matches = sorted(matches, key=lambda x: x.distance)[:50]
566
+
567
+ if len(matches) < 10:
568
+ results["steps"][-1]["status"] = "failed"
569
+ results["steps"][-1]["error"] = "Not enough matches"
570
+ results["computed_rotation"] = None
571
+ else:
572
+ # Extract matched points
573
+ pts1 = np.float32([kp1[m.queryIdx].pt for m in matches])
574
+ pts2 = np.float32([kp2[m.trainIdx].pt for m in matches])
575
+
576
+ # Estimate affine transformation
577
+ M, inliers = cv2.estimateAffinePartial2D(pts1, pts2)
578
+
579
+ if M is not None:
580
+ # Extract rotation angle from transformation matrix
581
+ computed_rotation = np.rad2deg(np.arctan2(M[1, 0], M[0, 0]))
582
+ results["computed_rotation"] = float(computed_rotation)
583
+ results["steps"][-1]["status"] = "completed"
584
+
585
+ # The image rotation should be approximately opposite to head roll
586
+ # (when head rolls right, image content appears to roll left)
587
+ expected_image_rotation = -req.roll_angle
588
+ error = float(abs(computed_rotation - expected_image_rotation))
589
+
590
+ results["expected_rotation"] = float(expected_image_rotation)
591
+ results["rotation_error"] = error
592
+ results["success"] = bool(error < 15) # Allow 15 degree tolerance
593
+ results["num_matches"] = len(matches)
594
+ results["num_inliers"] = int(np.sum(inliers)) if inliers is not None else 0
595
+ else:
596
+ results["steps"][-1]["status"] = "failed"
597
+ results["steps"][-1]["error"] = "Could not estimate transformation"
598
+ results["computed_rotation"] = None
599
+
600
+ # Step 6: Return to zero
601
+ results["steps"].append({"step": "return_to_zero", "status": "started"})
602
+ state.reachy_mini.goto_target(head=np.eye(4), duration=1.0)
603
+ results["steps"][-1]["status"] = "completed"
604
+
605
+ state.last_rotation_test = results
606
+ return results
607
+
608
+ except Exception as e:
609
+ raise HTTPException(500, str(e))
610
+
611
+ @app.get("/api/test/last_rotation_result")
612
+ async def get_last_rotation_result():
613
+ """Get the last rotation test result."""
614
+ if state.last_rotation_test is None:
615
+ return {"message": "No test has been run yet"}
616
+ return state.last_rotation_test
617
+
618
+
619
+ if __name__ == "__main__":
620
+ app = ReachyMiniToolbox()
621
+ try:
622
+ app.wrapped_run()
623
+ except KeyboardInterrupt:
624
+ app.stop()
reachy_mini_toolbox/static/index.html ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Reachy Mini Debug & CI Toolbox</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <h1>Reachy Mini Debug & CI Toolbox</h1>
12
+ <div id="connection-status" class="status-indicator">
13
+ <span class="dot"></span>
14
+ <span class="text">Checking...</span>
15
+ </div>
16
+ </header>
17
+
18
+ <main>
19
+ <!-- Status Panel -->
20
+ <section class="panel" id="status-panel">
21
+ <h2>Robot Status</h2>
22
+ <div class="panel-content">
23
+ <div class="status-grid" id="status-grid">
24
+ <div class="status-item">
25
+ <label>Roll</label>
26
+ <span id="status-roll">--</span>
27
+ </div>
28
+ <div class="status-item">
29
+ <label>Pitch</label>
30
+ <span id="status-pitch">--</span>
31
+ </div>
32
+ <div class="status-item">
33
+ <label>Yaw</label>
34
+ <span id="status-yaw">--</span>
35
+ </div>
36
+ </div>
37
+ <button onclick="refreshStatus()" class="btn btn-secondary">Refresh Status</button>
38
+ </div>
39
+ </section>
40
+
41
+ <!-- Motor Status Panel -->
42
+ <section class="panel" id="motor-panel">
43
+ <h2>Motor Status</h2>
44
+ <div class="panel-content">
45
+ <div class="motor-grid" id="motor-grid">
46
+ <!-- Motors will be populated by JS -->
47
+ </div>
48
+ <button onclick="refreshMotorStatus()" class="btn btn-secondary">Refresh Motors</button>
49
+ </div>
50
+ </section>
51
+
52
+ <!-- Movement Control Panel -->
53
+ <section class="panel" id="movement-panel">
54
+ <h2>Movement Control</h2>
55
+ <div class="panel-content">
56
+ <div class="control-group">
57
+ <h3>Head Position</h3>
58
+ <div class="slider-group">
59
+ <label>Roll: <span id="roll-value">0</span>&deg;</label>
60
+ <input type="range" id="roll-slider" min="-30" max="30" value="0" oninput="updateSliderValue('roll')">
61
+ </div>
62
+ <div class="slider-group">
63
+ <label>Pitch: <span id="pitch-value">0</span>&deg;</label>
64
+ <input type="range" id="pitch-slider" min="-30" max="30" value="0" oninput="updateSliderValue('pitch')">
65
+ </div>
66
+ <div class="slider-group">
67
+ <label>Yaw: <span id="yaw-value">0</span>&deg;</label>
68
+ <input type="range" id="yaw-slider" min="-45" max="45" value="0" oninput="updateSliderValue('yaw')">
69
+ </div>
70
+ <div class="slider-group">
71
+ <label>Duration: <span id="duration-value">1.0</span>s</label>
72
+ <input type="range" id="duration-slider" min="0.5" max="3" step="0.1" value="1.0" oninput="updateSliderValue('duration')">
73
+ </div>
74
+ <button onclick="moveHead()" class="btn btn-primary">Move Head</button>
75
+ </div>
76
+
77
+ <div class="control-group">
78
+ <h3>Antennas</h3>
79
+ <div class="slider-group">
80
+ <label>Right: <span id="antenna-right-value">0</span>&deg;</label>
81
+ <input type="range" id="antenna-right-slider" min="-45" max="45" value="0" oninput="updateSliderValue('antenna-right')">
82
+ </div>
83
+ <div class="slider-group">
84
+ <label>Left: <span id="antenna-left-value">0</span>&deg;</label>
85
+ <input type="range" id="antenna-left-slider" min="-45" max="45" value="0" oninput="updateSliderValue('antenna-left')">
86
+ </div>
87
+ <button onclick="moveAntennas()" class="btn btn-primary">Move Antennas</button>
88
+ </div>
89
+
90
+ <div class="control-group">
91
+ <h3>Quick Actions</h3>
92
+ <div class="button-row">
93
+ <button onclick="goToZero()" class="btn btn-secondary">Go to Zero</button>
94
+ <button onclick="wakeUp()" class="btn btn-success">Wake Up</button>
95
+ <button onclick="goToSleep()" class="btn btn-warning">Go to Sleep</button>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </section>
100
+
101
+ <!-- Camera Panel -->
102
+ <section class="panel" id="camera-panel">
103
+ <h2>Camera</h2>
104
+ <div class="panel-content">
105
+ <div class="camera-view">
106
+ <img id="camera-stream" src="/api/camera/stream" alt="Camera Stream" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22480%22><rect fill=%22%23333%22 width=%22100%%22 height=%22100%%22/><text fill=%22%23888%22 x=%2250%%22 y=%2250%%22 text-anchor=%22middle%22>No Camera</text></svg>'">
107
+ </div>
108
+ <div class="button-row">
109
+ <button onclick="captureImage()" class="btn btn-primary">Capture Image</button>
110
+ <button onclick="refreshCaptures()" class="btn btn-secondary">Refresh List</button>
111
+ </div>
112
+ <div class="file-list" id="captures-list">
113
+ <!-- Captures will be listed here -->
114
+ </div>
115
+ </div>
116
+ </section>
117
+
118
+ <!-- Audio Panel -->
119
+ <section class="panel" id="audio-panel">
120
+ <h2>Audio Recording</h2>
121
+ <div class="panel-content">
122
+ <div class="recording-controls">
123
+ <button id="record-btn" onclick="toggleRecording()" class="btn btn-record">
124
+ <span class="record-icon"></span>
125
+ <span class="record-text">Start Recording</span>
126
+ </button>
127
+ <span id="recording-status"></span>
128
+ </div>
129
+ <div class="button-row">
130
+ <button onclick="refreshRecordings()" class="btn btn-secondary">Refresh List</button>
131
+ </div>
132
+ <div class="file-list" id="recordings-list">
133
+ <!-- Recordings will be listed here -->
134
+ </div>
135
+ </div>
136
+ </section>
137
+
138
+ <!-- Rotation Validation Test Panel -->
139
+ <section class="panel" id="test-panel">
140
+ <h2>Rotation Validation Test</h2>
141
+ <div class="panel-content">
142
+ <p class="panel-description">
143
+ This test validates head rotation by:
144
+ <ol>
145
+ <li>Going to zero position and capturing an image</li>
146
+ <li>Rolling the head by a specified angle</li>
147
+ <li>Capturing another image and computing the rotation</li>
148
+ <li>Comparing expected vs actual rotation</li>
149
+ </ol>
150
+ </p>
151
+ <div class="control-group">
152
+ <div class="slider-group">
153
+ <label>Roll Angle: <span id="test-roll-value">30</span>&deg;</label>
154
+ <input type="range" id="test-roll-slider" min="10" max="45" value="30" oninput="updateSliderValue('test-roll')">
155
+ </div>
156
+ <div class="slider-group">
157
+ <label>Duration: <span id="test-duration-value">2.0</span>s</label>
158
+ <input type="range" id="test-duration-slider" min="1" max="4" step="0.5" value="2.0" oninput="updateSliderValue('test-duration')">
159
+ </div>
160
+ </div>
161
+ <button onclick="runRotationTest()" class="btn btn-primary btn-large" id="run-test-btn">Run Rotation Test</button>
162
+
163
+ <div id="test-results" class="test-results hidden">
164
+ <h3>Test Results</h3>
165
+ <div class="result-grid">
166
+ <div class="result-item">
167
+ <label>Status</label>
168
+ <span id="result-status" class="result-value">--</span>
169
+ </div>
170
+ <div class="result-item">
171
+ <label>Expected Rotation</label>
172
+ <span id="result-expected">--</span>
173
+ </div>
174
+ <div class="result-item">
175
+ <label>Computed Rotation</label>
176
+ <span id="result-computed">--</span>
177
+ </div>
178
+ <div class="result-item">
179
+ <label>Error</label>
180
+ <span id="result-error">--</span>
181
+ </div>
182
+ <div class="result-item">
183
+ <label>Matches Found</label>
184
+ <span id="result-matches">--</span>
185
+ </div>
186
+ </div>
187
+ <div class="test-images">
188
+ <div class="test-image">
189
+ <h4>Zero Position</h4>
190
+ <img id="test-image-zero" src="" alt="Zero position">
191
+ </div>
192
+ <div class="test-image">
193
+ <h4>Rolled Position</h4>
194
+ <img id="test-image-rolled" src="" alt="Rolled position">
195
+ </div>
196
+ </div>
197
+ <div id="test-steps" class="test-steps">
198
+ <!-- Steps will be listed here -->
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </section>
203
+ </main>
204
+
205
+ <footer>
206
+ <p>Reachy Mini Toolbox v0.1.0</p>
207
+ </footer>
208
+
209
+ <div id="toast" class="toast hidden"></div>
210
+
211
+ <script src="/static/main.js"></script>
212
+ </body>
213
+ </html>
reachy_mini_toolbox/static/main.js ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ==================== State ====================
2
+ let isRecording = false;
3
+ let statusInterval = null;
4
+
5
+ // ==================== Utility Functions ====================
6
+
7
+ function showToast(message, type = 'info') {
8
+ const toast = document.getElementById('toast');
9
+ toast.textContent = message;
10
+ toast.className = `toast ${type}`;
11
+ toast.classList.remove('hidden');
12
+
13
+ setTimeout(() => {
14
+ toast.classList.add('hidden');
15
+ }, 3000);
16
+ }
17
+
18
+ async function apiCall(endpoint, options = {}) {
19
+ try {
20
+ const response = await fetch(endpoint, {
21
+ headers: { 'Content-Type': 'application/json' },
22
+ ...options
23
+ });
24
+
25
+ if (!response.ok) {
26
+ const error = await response.json().catch(() => ({ detail: response.statusText }));
27
+ throw new Error(error.detail || 'Request failed');
28
+ }
29
+
30
+ return await response.json();
31
+ } catch (error) {
32
+ showToast(error.message, 'error');
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ function updateSliderValue(name) {
38
+ const slider = document.getElementById(`${name}-slider`);
39
+ const display = document.getElementById(`${name}-value`);
40
+ if (slider && display) {
41
+ display.textContent = slider.value;
42
+ }
43
+ }
44
+
45
+ function formatDuration(seconds) {
46
+ if (seconds < 60) {
47
+ return `${seconds.toFixed(1)}s`;
48
+ }
49
+ const mins = Math.floor(seconds / 60);
50
+ const secs = (seconds % 60).toFixed(0);
51
+ return `${mins}:${secs.padStart(2, '0')}`;
52
+ }
53
+
54
+ function formatDate(isoString) {
55
+ const date = new Date(isoString);
56
+ return date.toLocaleString();
57
+ }
58
+
59
+ // ==================== Status Functions ====================
60
+
61
+ async function refreshStatus() {
62
+ try {
63
+ const data = await apiCall('/api/status');
64
+
65
+ if (data.connected) {
66
+ document.getElementById('status-roll').textContent = `${data.head_pose.roll.toFixed(1)}°`;
67
+ document.getElementById('status-pitch').textContent = `${data.head_pose.pitch.toFixed(1)}°`;
68
+ document.getElementById('status-yaw').textContent = `${data.head_pose.yaw.toFixed(1)}°`;
69
+
70
+ updateConnectionStatus(true);
71
+ } else {
72
+ updateConnectionStatus(false, data.error);
73
+ }
74
+ } catch (error) {
75
+ updateConnectionStatus(false, error.message);
76
+ }
77
+ }
78
+
79
+ function updateConnectionStatus(connected, error = null) {
80
+ const status = document.getElementById('connection-status');
81
+ const dot = status.querySelector('.dot');
82
+ const text = status.querySelector('.text');
83
+
84
+ if (connected) {
85
+ dot.className = 'dot connected';
86
+ text.textContent = 'Connected';
87
+ } else {
88
+ dot.className = 'dot disconnected';
89
+ text.textContent = error ? `Disconnected: ${error}` : 'Disconnected';
90
+ }
91
+ }
92
+
93
+ async function refreshMotorStatus() {
94
+ try {
95
+ const data = await apiCall('/api/motor_status');
96
+ const grid = document.getElementById('motor-grid');
97
+
98
+ grid.innerHTML = '';
99
+
100
+ for (const [name, info] of Object.entries(data.motors)) {
101
+ const item = document.createElement('div');
102
+ item.className = 'motor-item';
103
+ item.innerHTML = `
104
+ <span class="motor-name">${name.replace(/_/g, ' ')}</span>
105
+ <span class="motor-value">${info.position.toFixed(1)}°</span>
106
+ `;
107
+ grid.appendChild(item);
108
+ }
109
+ } catch (error) {
110
+ console.error('Failed to refresh motor status:', error);
111
+ }
112
+ }
113
+
114
+ // ==================== Movement Functions ====================
115
+
116
+ async function moveHead() {
117
+ const roll = parseFloat(document.getElementById('roll-slider').value);
118
+ const pitch = parseFloat(document.getElementById('pitch-slider').value);
119
+ const yaw = parseFloat(document.getElementById('yaw-slider').value);
120
+ const duration = parseFloat(document.getElementById('duration-slider').value);
121
+
122
+ try {
123
+ await apiCall('/api/move_head', {
124
+ method: 'POST',
125
+ body: JSON.stringify({ roll, pitch, yaw, duration })
126
+ });
127
+ showToast('Head movement executed', 'success');
128
+ setTimeout(refreshStatus, duration * 1000 + 200);
129
+ } catch (error) {
130
+ console.error('Move head failed:', error);
131
+ }
132
+ }
133
+
134
+ async function moveAntennas() {
135
+ const right = parseFloat(document.getElementById('antenna-right-slider').value);
136
+ const left = parseFloat(document.getElementById('antenna-left-slider').value);
137
+
138
+ try {
139
+ await apiCall('/api/move_antennas', {
140
+ method: 'POST',
141
+ body: JSON.stringify({ right, left, duration: 0.5 })
142
+ });
143
+ showToast('Antennas moved', 'success');
144
+ } catch (error) {
145
+ console.error('Move antennas failed:', error);
146
+ }
147
+ }
148
+
149
+ async function goToZero() {
150
+ try {
151
+ await apiCall('/api/go_to_zero', { method: 'POST' });
152
+ showToast('Moved to zero position', 'success');
153
+
154
+ // Reset sliders
155
+ document.getElementById('roll-slider').value = 0;
156
+ document.getElementById('pitch-slider').value = 0;
157
+ document.getElementById('yaw-slider').value = 0;
158
+ document.getElementById('antenna-right-slider').value = 0;
159
+ document.getElementById('antenna-left-slider').value = 0;
160
+
161
+ ['roll', 'pitch', 'yaw', 'antenna-right', 'antenna-left'].forEach(updateSliderValue);
162
+
163
+ setTimeout(refreshStatus, 1700);
164
+ } catch (error) {
165
+ console.error('Go to zero failed:', error);
166
+ }
167
+ }
168
+
169
+ async function wakeUp() {
170
+ try {
171
+ showToast('Waking up...', 'info');
172
+ await apiCall('/api/wake_up', { method: 'POST' });
173
+ showToast('Wake up complete', 'success');
174
+ setTimeout(refreshStatus, 500);
175
+ } catch (error) {
176
+ console.error('Wake up failed:', error);
177
+ }
178
+ }
179
+
180
+ async function goToSleep() {
181
+ try {
182
+ showToast('Going to sleep...', 'info');
183
+ await apiCall('/api/go_to_sleep', { method: 'POST' });
184
+ showToast('Sleep complete', 'success');
185
+ setTimeout(refreshStatus, 500);
186
+ } catch (error) {
187
+ console.error('Go to sleep failed:', error);
188
+ }
189
+ }
190
+
191
+ // ==================== Camera Functions ====================
192
+
193
+ async function captureImage() {
194
+ try {
195
+ const data = await apiCall('/api/camera/save', { method: 'POST' });
196
+ showToast(`Captured: ${data.filename}`, 'success');
197
+ refreshCaptures();
198
+ } catch (error) {
199
+ console.error('Capture failed:', error);
200
+ }
201
+ }
202
+
203
+ async function refreshCaptures() {
204
+ try {
205
+ const data = await apiCall('/api/camera/list');
206
+ const list = document.getElementById('captures-list');
207
+
208
+ if (data.captures.length === 0) {
209
+ list.innerHTML = '<p class="empty-message">No captures yet</p>';
210
+ return;
211
+ }
212
+
213
+ list.innerHTML = data.captures.map(capture => `
214
+ <div class="file-item">
215
+ <div class="file-info">
216
+ <span class="file-name">${capture.filename}</span>
217
+ <span class="file-meta">${capture.size_kb.toFixed(1)} KB - ${formatDate(capture.created)}</span>
218
+ </div>
219
+ <div class="file-actions">
220
+ <a href="/api/camera/download/${capture.filename}" download class="btn btn-small">Download</a>
221
+ <button onclick="deleteCapture('${capture.filename}')" class="btn btn-small btn-danger">Delete</button>
222
+ </div>
223
+ </div>
224
+ `).join('');
225
+ } catch (error) {
226
+ console.error('Failed to refresh captures:', error);
227
+ }
228
+ }
229
+
230
+ async function deleteCapture(filename) {
231
+ if (!confirm(`Delete ${filename}?`)) return;
232
+
233
+ try {
234
+ await apiCall(`/api/camera/delete/${filename}`, { method: 'DELETE' });
235
+ showToast('Capture deleted', 'success');
236
+ refreshCaptures();
237
+ } catch (error) {
238
+ console.error('Delete failed:', error);
239
+ }
240
+ }
241
+
242
+ // ==================== Audio Functions ====================
243
+
244
+ async function toggleRecording() {
245
+ const btn = document.getElementById('record-btn');
246
+ const statusSpan = document.getElementById('recording-status');
247
+
248
+ if (!isRecording) {
249
+ // Start recording
250
+ try {
251
+ const data = await apiCall('/api/audio/start_recording', { method: 'POST' });
252
+ isRecording = true;
253
+ btn.classList.add('recording');
254
+ btn.querySelector('.record-text').textContent = 'Stop Recording';
255
+ statusSpan.textContent = `Recording @ ${data.samplerate} Hz...`;
256
+ showToast('Recording started', 'success');
257
+ } catch (error) {
258
+ console.error('Start recording failed:', error);
259
+ }
260
+ } else {
261
+ // Stop recording
262
+ try {
263
+ btn.disabled = true;
264
+ btn.querySelector('.record-text').textContent = 'Saving...';
265
+
266
+ const data = await apiCall('/api/audio/stop_recording', { method: 'POST' });
267
+ isRecording = false;
268
+ btn.classList.remove('recording');
269
+ btn.querySelector('.record-text').textContent = 'Start Recording';
270
+ btn.disabled = false;
271
+ statusSpan.textContent = '';
272
+
273
+ if (data.success) {
274
+ showToast(`Saved: ${data.filename} (${formatDuration(data.duration)})`, 'success');
275
+ refreshRecordings();
276
+ } else {
277
+ showToast(data.message, 'warning');
278
+ }
279
+ } catch (error) {
280
+ console.error('Stop recording failed:', error);
281
+ btn.disabled = false;
282
+ btn.querySelector('.record-text').textContent = 'Start Recording';
283
+ }
284
+ }
285
+ }
286
+
287
+ async function refreshRecordings() {
288
+ try {
289
+ const data = await apiCall('/api/audio/list');
290
+ const list = document.getElementById('recordings-list');
291
+
292
+ if (data.recordings.length === 0) {
293
+ list.innerHTML = '<p class="empty-message">No recordings yet</p>';
294
+ return;
295
+ }
296
+
297
+ list.innerHTML = data.recordings.map(rec => `
298
+ <div class="file-item">
299
+ <div class="file-info">
300
+ <span class="file-name">${rec.filename}</span>
301
+ <span class="file-meta">${formatDuration(rec.duration)} - ${rec.samplerate} Hz - ${rec.size_kb.toFixed(1)} KB</span>
302
+ </div>
303
+ <div class="file-actions">
304
+ <button onclick="playRecording('${rec.filename}')" class="btn btn-small btn-success">Play</button>
305
+ <a href="/api/audio/download/${rec.filename}" download class="btn btn-small">Download</a>
306
+ <button onclick="deleteRecording('${rec.filename}')" class="btn btn-small btn-danger">Delete</button>
307
+ </div>
308
+ </div>
309
+ `).join('');
310
+ } catch (error) {
311
+ console.error('Failed to refresh recordings:', error);
312
+ }
313
+ }
314
+
315
+ async function playRecording(filename) {
316
+ try {
317
+ await apiCall(`/api/audio/play/${filename}`, { method: 'POST' });
318
+ showToast(`Playing: ${filename}`, 'success');
319
+ } catch (error) {
320
+ console.error('Play failed:', error);
321
+ }
322
+ }
323
+
324
+ async function deleteRecording(filename) {
325
+ if (!confirm(`Delete ${filename}?`)) return;
326
+
327
+ try {
328
+ await apiCall(`/api/audio/delete/${filename}`, { method: 'DELETE' });
329
+ showToast('Recording deleted', 'success');
330
+ refreshRecordings();
331
+ } catch (error) {
332
+ console.error('Delete failed:', error);
333
+ }
334
+ }
335
+
336
+ // ==================== Rotation Test Functions ====================
337
+
338
+ async function runRotationTest() {
339
+ const btn = document.getElementById('run-test-btn');
340
+ const resultsDiv = document.getElementById('test-results');
341
+
342
+ const rollAngle = parseFloat(document.getElementById('test-roll-slider').value);
343
+ const duration = parseFloat(document.getElementById('test-duration-slider').value);
344
+
345
+ try {
346
+ btn.disabled = true;
347
+ btn.textContent = 'Running Test...';
348
+ resultsDiv.classList.add('hidden');
349
+
350
+ showToast('Running rotation test...', 'info');
351
+
352
+ const data = await apiCall('/api/test/rotation_validation', {
353
+ method: 'POST',
354
+ body: JSON.stringify({ roll_angle: rollAngle, duration })
355
+ });
356
+
357
+ // Show results
358
+ resultsDiv.classList.remove('hidden');
359
+
360
+ // Update status
361
+ const statusEl = document.getElementById('result-status');
362
+ if (data.success) {
363
+ statusEl.textContent = 'PASSED';
364
+ statusEl.className = 'result-value success';
365
+ } else {
366
+ statusEl.textContent = 'FAILED';
367
+ statusEl.className = 'result-value error';
368
+ }
369
+
370
+ // Update values
371
+ document.getElementById('result-expected').textContent =
372
+ data.expected_rotation !== undefined ? `${data.expected_rotation.toFixed(1)}°` : '--';
373
+ document.getElementById('result-computed').textContent =
374
+ data.computed_rotation !== null ? `${data.computed_rotation.toFixed(1)}°` : 'N/A';
375
+ document.getElementById('result-error').textContent =
376
+ data.rotation_error !== undefined ? `${data.rotation_error.toFixed(1)}°` : '--';
377
+ document.getElementById('result-matches').textContent =
378
+ data.num_matches !== undefined ? `${data.num_matches} (${data.num_inliers} inliers)` : '--';
379
+
380
+ // Update images
381
+ const zeroFilename = data.steps.find(s => s.step === 'capture_zero')?.filename;
382
+ const rolledFilename = data.steps.find(s => s.step === 'capture_rolled')?.filename;
383
+
384
+ if (zeroFilename) {
385
+ document.getElementById('test-image-zero').src = `/api/camera/download/${zeroFilename}`;
386
+ }
387
+ if (rolledFilename) {
388
+ document.getElementById('test-image-rolled').src = `/api/camera/download/${rolledFilename}`;
389
+ }
390
+
391
+ // Update steps
392
+ const stepsDiv = document.getElementById('test-steps');
393
+ stepsDiv.innerHTML = '<h4>Test Steps</h4>' + data.steps.map(step => {
394
+ const statusClass = step.status === 'completed' ? 'step-success' :
395
+ step.status === 'failed' ? 'step-error' : 'step-pending';
396
+ return `
397
+ <div class="step-item ${statusClass}">
398
+ <span class="step-name">${step.step.replace(/_/g, ' ')}</span>
399
+ <span class="step-status">${step.status}</span>
400
+ ${step.error ? `<span class="step-error">${step.error}</span>` : ''}
401
+ </div>
402
+ `;
403
+ }).join('');
404
+
405
+ showToast(data.success ? 'Test PASSED!' : 'Test FAILED', data.success ? 'success' : 'error');
406
+
407
+ // Refresh captures list to show new images
408
+ refreshCaptures();
409
+
410
+ } catch (error) {
411
+ console.error('Rotation test failed:', error);
412
+ } finally {
413
+ btn.disabled = false;
414
+ btn.textContent = 'Run Rotation Test';
415
+ }
416
+ }
417
+
418
+ // ==================== Initialization ====================
419
+
420
+ function startStatusPolling() {
421
+ refreshStatus();
422
+ refreshMotorStatus();
423
+ statusInterval = setInterval(() => {
424
+ refreshStatus();
425
+ refreshMotorStatus();
426
+ }, 2000);
427
+ }
428
+
429
+ function init() {
430
+ // Initialize slider displays
431
+ ['roll', 'pitch', 'yaw', 'duration', 'antenna-right', 'antenna-left', 'test-roll', 'test-duration'].forEach(updateSliderValue);
432
+
433
+ // Start polling
434
+ startStatusPolling();
435
+
436
+ // Load file lists
437
+ refreshCaptures();
438
+ refreshRecordings();
439
+
440
+ console.log('Reachy Mini Toolbox initialized');
441
+ }
442
+
443
+ // Start on page load
444
+ document.addEventListener('DOMContentLoaded', init);
reachy_mini_toolbox/static/style.css ADDED
@@ -0,0 +1,666 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ==================== Variables ==================== */
2
+ :root {
3
+ --bg-primary: #1a1a2e;
4
+ --bg-secondary: #16213e;
5
+ --bg-panel: #0f3460;
6
+ --text-primary: #eaeaea;
7
+ --text-secondary: #a0a0a0;
8
+ --accent: #e94560;
9
+ --accent-hover: #ff6b6b;
10
+ --success: #4ade80;
11
+ --warning: #fbbf24;
12
+ --error: #ef4444;
13
+ --info: #38bdf8;
14
+ --border: #2a4a7f;
15
+ --border-light: #3a5a9f;
16
+ --shadow: rgba(0, 0, 0, 0.3);
17
+ }
18
+
19
+ /* ==================== Base Styles ==================== */
20
+ * {
21
+ box-sizing: border-box;
22
+ margin: 0;
23
+ padding: 0;
24
+ }
25
+
26
+ body {
27
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
28
+ background: var(--bg-primary);
29
+ color: var(--text-primary);
30
+ min-height: 100vh;
31
+ line-height: 1.6;
32
+ }
33
+
34
+ /* ==================== Header ==================== */
35
+ header {
36
+ background: var(--bg-secondary);
37
+ padding: 1rem 2rem;
38
+ display: flex;
39
+ justify-content: space-between;
40
+ align-items: center;
41
+ border-bottom: 2px solid var(--accent);
42
+ position: sticky;
43
+ top: 0;
44
+ z-index: 100;
45
+ }
46
+
47
+ header h1 {
48
+ font-size: 1.5rem;
49
+ font-weight: 600;
50
+ color: var(--text-primary);
51
+ }
52
+
53
+ .status-indicator {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 0.5rem;
57
+ padding: 0.5rem 1rem;
58
+ background: var(--bg-panel);
59
+ border-radius: 20px;
60
+ font-size: 0.875rem;
61
+ }
62
+
63
+ .status-indicator .dot {
64
+ width: 10px;
65
+ height: 10px;
66
+ border-radius: 50%;
67
+ background: var(--text-secondary);
68
+ transition: background 0.3s;
69
+ }
70
+
71
+ .status-indicator .dot.connected {
72
+ background: var(--success);
73
+ box-shadow: 0 0 8px var(--success);
74
+ }
75
+
76
+ .status-indicator .dot.disconnected {
77
+ background: var(--error);
78
+ box-shadow: 0 0 8px var(--error);
79
+ }
80
+
81
+ /* ==================== Main Layout ==================== */
82
+ main {
83
+ max-width: 1400px;
84
+ margin: 0 auto;
85
+ padding: 1.5rem;
86
+ display: grid;
87
+ grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
88
+ gap: 1.5rem;
89
+ }
90
+
91
+ @media (max-width: 900px) {
92
+ main {
93
+ grid-template-columns: 1fr;
94
+ }
95
+ }
96
+
97
+ /* ==================== Panel Styles ==================== */
98
+ .panel {
99
+ background: var(--bg-secondary);
100
+ border-radius: 12px;
101
+ border: 1px solid var(--border);
102
+ overflow: hidden;
103
+ box-shadow: 0 4px 20px var(--shadow);
104
+ }
105
+
106
+ .panel h2 {
107
+ background: var(--bg-panel);
108
+ padding: 1rem 1.25rem;
109
+ font-size: 1.1rem;
110
+ font-weight: 600;
111
+ border-bottom: 1px solid var(--border);
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 0.5rem;
115
+ }
116
+
117
+ .panel-content {
118
+ padding: 1.25rem;
119
+ }
120
+
121
+ .panel-description {
122
+ color: var(--text-secondary);
123
+ font-size: 0.875rem;
124
+ margin-bottom: 1rem;
125
+ }
126
+
127
+ .panel-description ol {
128
+ margin-left: 1.5rem;
129
+ margin-top: 0.5rem;
130
+ }
131
+
132
+ /* ==================== Status Grid ==================== */
133
+ .status-grid {
134
+ display: grid;
135
+ grid-template-columns: repeat(3, 1fr);
136
+ gap: 1rem;
137
+ margin-bottom: 1rem;
138
+ }
139
+
140
+ .status-item {
141
+ background: var(--bg-panel);
142
+ padding: 1rem;
143
+ border-radius: 8px;
144
+ text-align: center;
145
+ }
146
+
147
+ .status-item label {
148
+ display: block;
149
+ font-size: 0.75rem;
150
+ color: var(--text-secondary);
151
+ text-transform: uppercase;
152
+ margin-bottom: 0.25rem;
153
+ }
154
+
155
+ .status-item span {
156
+ font-size: 1.5rem;
157
+ font-weight: 600;
158
+ color: var(--accent);
159
+ }
160
+
161
+ /* ==================== Motor Grid ==================== */
162
+ .motor-grid {
163
+ display: grid;
164
+ grid-template-columns: repeat(3, 1fr);
165
+ gap: 0.75rem;
166
+ margin-bottom: 1rem;
167
+ }
168
+
169
+ .motor-item {
170
+ background: var(--bg-panel);
171
+ padding: 0.75rem;
172
+ border-radius: 6px;
173
+ display: flex;
174
+ flex-direction: column;
175
+ align-items: center;
176
+ gap: 0.25rem;
177
+ }
178
+
179
+ .motor-name {
180
+ font-size: 0.7rem;
181
+ color: var(--text-secondary);
182
+ text-transform: capitalize;
183
+ }
184
+
185
+ .motor-value {
186
+ font-size: 1rem;
187
+ font-weight: 600;
188
+ color: var(--info);
189
+ }
190
+
191
+ /* ==================== Control Groups ==================== */
192
+ .control-group {
193
+ margin-bottom: 1.5rem;
194
+ padding-bottom: 1.5rem;
195
+ border-bottom: 1px solid var(--border);
196
+ }
197
+
198
+ .control-group:last-child {
199
+ margin-bottom: 0;
200
+ padding-bottom: 0;
201
+ border-bottom: none;
202
+ }
203
+
204
+ .control-group h3 {
205
+ font-size: 0.9rem;
206
+ color: var(--text-secondary);
207
+ margin-bottom: 1rem;
208
+ text-transform: uppercase;
209
+ letter-spacing: 0.5px;
210
+ }
211
+
212
+ /* ==================== Slider Styles ==================== */
213
+ .slider-group {
214
+ margin-bottom: 1rem;
215
+ }
216
+
217
+ .slider-group label {
218
+ display: block;
219
+ font-size: 0.875rem;
220
+ margin-bottom: 0.5rem;
221
+ color: var(--text-primary);
222
+ }
223
+
224
+ .slider-group input[type="range"] {
225
+ width: 100%;
226
+ height: 6px;
227
+ border-radius: 3px;
228
+ background: var(--bg-panel);
229
+ outline: none;
230
+ cursor: pointer;
231
+ -webkit-appearance: none;
232
+ }
233
+
234
+ .slider-group input[type="range"]::-webkit-slider-thumb {
235
+ -webkit-appearance: none;
236
+ width: 18px;
237
+ height: 18px;
238
+ border-radius: 50%;
239
+ background: var(--accent);
240
+ cursor: pointer;
241
+ transition: transform 0.15s, box-shadow 0.15s;
242
+ }
243
+
244
+ .slider-group input[type="range"]::-webkit-slider-thumb:hover {
245
+ transform: scale(1.1);
246
+ box-shadow: 0 0 10px var(--accent);
247
+ }
248
+
249
+ .slider-group input[type="range"]::-moz-range-thumb {
250
+ width: 18px;
251
+ height: 18px;
252
+ border-radius: 50%;
253
+ background: var(--accent);
254
+ cursor: pointer;
255
+ border: none;
256
+ }
257
+
258
+ /* ==================== Button Styles ==================== */
259
+ .btn {
260
+ padding: 0.625rem 1.25rem;
261
+ border: none;
262
+ border-radius: 6px;
263
+ font-size: 0.875rem;
264
+ font-weight: 500;
265
+ cursor: pointer;
266
+ transition: all 0.2s;
267
+ text-decoration: none;
268
+ display: inline-block;
269
+ text-align: center;
270
+ }
271
+
272
+ .btn:disabled {
273
+ opacity: 0.5;
274
+ cursor: not-allowed;
275
+ }
276
+
277
+ .btn-primary {
278
+ background: var(--accent);
279
+ color: white;
280
+ }
281
+
282
+ .btn-primary:hover:not(:disabled) {
283
+ background: var(--accent-hover);
284
+ transform: translateY(-1px);
285
+ }
286
+
287
+ .btn-secondary {
288
+ background: var(--border);
289
+ color: var(--text-primary);
290
+ }
291
+
292
+ .btn-secondary:hover:not(:disabled) {
293
+ background: var(--border-light);
294
+ }
295
+
296
+ .btn-success {
297
+ background: var(--success);
298
+ color: var(--bg-primary);
299
+ }
300
+
301
+ .btn-success:hover:not(:disabled) {
302
+ filter: brightness(1.1);
303
+ }
304
+
305
+ .btn-warning {
306
+ background: var(--warning);
307
+ color: var(--bg-primary);
308
+ }
309
+
310
+ .btn-warning:hover:not(:disabled) {
311
+ filter: brightness(1.1);
312
+ }
313
+
314
+ .btn-danger {
315
+ background: var(--error);
316
+ color: white;
317
+ }
318
+
319
+ .btn-danger:hover:not(:disabled) {
320
+ filter: brightness(1.1);
321
+ }
322
+
323
+ .btn-small {
324
+ padding: 0.375rem 0.75rem;
325
+ font-size: 0.75rem;
326
+ }
327
+
328
+ .btn-large {
329
+ padding: 0.875rem 2rem;
330
+ font-size: 1rem;
331
+ width: 100%;
332
+ }
333
+
334
+ .button-row {
335
+ display: flex;
336
+ gap: 0.75rem;
337
+ flex-wrap: wrap;
338
+ }
339
+
340
+ /* ==================== Recording Button ==================== */
341
+ .recording-controls {
342
+ display: flex;
343
+ align-items: center;
344
+ gap: 1rem;
345
+ margin-bottom: 1rem;
346
+ }
347
+
348
+ .btn-record {
349
+ background: var(--bg-panel);
350
+ color: var(--text-primary);
351
+ display: flex;
352
+ align-items: center;
353
+ gap: 0.5rem;
354
+ padding: 0.75rem 1.5rem;
355
+ }
356
+
357
+ .btn-record .record-icon {
358
+ width: 12px;
359
+ height: 12px;
360
+ border-radius: 50%;
361
+ background: var(--error);
362
+ }
363
+
364
+ .btn-record.recording {
365
+ background: var(--error);
366
+ }
367
+
368
+ .btn-record.recording .record-icon {
369
+ background: white;
370
+ animation: pulse 1s infinite;
371
+ }
372
+
373
+ @keyframes pulse {
374
+ 0%, 100% { opacity: 1; }
375
+ 50% { opacity: 0.4; }
376
+ }
377
+
378
+ #recording-status {
379
+ color: var(--text-secondary);
380
+ font-size: 0.875rem;
381
+ }
382
+
383
+ /* ==================== Camera View ==================== */
384
+ .camera-view {
385
+ background: var(--bg-panel);
386
+ border-radius: 8px;
387
+ overflow: hidden;
388
+ margin-bottom: 1rem;
389
+ aspect-ratio: 4/3;
390
+ display: flex;
391
+ align-items: center;
392
+ justify-content: center;
393
+ }
394
+
395
+ .camera-view img {
396
+ width: 100%;
397
+ height: 100%;
398
+ object-fit: contain;
399
+ }
400
+
401
+ /* ==================== File List ==================== */
402
+ .file-list {
403
+ margin-top: 1rem;
404
+ max-height: 300px;
405
+ overflow-y: auto;
406
+ }
407
+
408
+ .file-item {
409
+ display: flex;
410
+ justify-content: space-between;
411
+ align-items: center;
412
+ padding: 0.75rem;
413
+ background: var(--bg-panel);
414
+ border-radius: 6px;
415
+ margin-bottom: 0.5rem;
416
+ }
417
+
418
+ .file-info {
419
+ flex: 1;
420
+ min-width: 0;
421
+ }
422
+
423
+ .file-name {
424
+ display: block;
425
+ font-size: 0.875rem;
426
+ font-weight: 500;
427
+ color: var(--text-primary);
428
+ white-space: nowrap;
429
+ overflow: hidden;
430
+ text-overflow: ellipsis;
431
+ }
432
+
433
+ .file-meta {
434
+ display: block;
435
+ font-size: 0.75rem;
436
+ color: var(--text-secondary);
437
+ margin-top: 0.25rem;
438
+ }
439
+
440
+ .file-actions {
441
+ display: flex;
442
+ gap: 0.5rem;
443
+ flex-shrink: 0;
444
+ margin-left: 1rem;
445
+ }
446
+
447
+ .empty-message {
448
+ text-align: center;
449
+ color: var(--text-secondary);
450
+ padding: 2rem;
451
+ font-size: 0.875rem;
452
+ }
453
+
454
+ /* ==================== Test Results ==================== */
455
+ .test-results {
456
+ margin-top: 1.5rem;
457
+ padding-top: 1.5rem;
458
+ border-top: 1px solid var(--border);
459
+ }
460
+
461
+ .test-results.hidden {
462
+ display: none;
463
+ }
464
+
465
+ .test-results h3 {
466
+ font-size: 1rem;
467
+ margin-bottom: 1rem;
468
+ color: var(--text-primary);
469
+ }
470
+
471
+ .result-grid {
472
+ display: grid;
473
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
474
+ gap: 0.75rem;
475
+ margin-bottom: 1.5rem;
476
+ }
477
+
478
+ .result-item {
479
+ background: var(--bg-panel);
480
+ padding: 0.75rem;
481
+ border-radius: 6px;
482
+ text-align: center;
483
+ }
484
+
485
+ .result-item label {
486
+ display: block;
487
+ font-size: 0.7rem;
488
+ color: var(--text-secondary);
489
+ text-transform: uppercase;
490
+ margin-bottom: 0.25rem;
491
+ }
492
+
493
+ .result-item span {
494
+ font-size: 1rem;
495
+ font-weight: 600;
496
+ }
497
+
498
+ .result-value.success {
499
+ color: var(--success);
500
+ }
501
+
502
+ .result-value.error {
503
+ color: var(--error);
504
+ }
505
+
506
+ .test-images {
507
+ display: grid;
508
+ grid-template-columns: 1fr 1fr;
509
+ gap: 1rem;
510
+ margin-bottom: 1rem;
511
+ }
512
+
513
+ .test-image {
514
+ background: var(--bg-panel);
515
+ border-radius: 8px;
516
+ overflow: hidden;
517
+ }
518
+
519
+ .test-image h4 {
520
+ font-size: 0.75rem;
521
+ color: var(--text-secondary);
522
+ text-align: center;
523
+ padding: 0.5rem;
524
+ background: var(--bg-secondary);
525
+ text-transform: uppercase;
526
+ }
527
+
528
+ .test-image img {
529
+ width: 100%;
530
+ aspect-ratio: 4/3;
531
+ object-fit: contain;
532
+ }
533
+
534
+ .test-steps {
535
+ margin-top: 1rem;
536
+ }
537
+
538
+ .test-steps h4 {
539
+ font-size: 0.875rem;
540
+ color: var(--text-secondary);
541
+ margin-bottom: 0.5rem;
542
+ }
543
+
544
+ .step-item {
545
+ display: flex;
546
+ align-items: center;
547
+ gap: 0.75rem;
548
+ padding: 0.5rem;
549
+ border-radius: 4px;
550
+ margin-bottom: 0.25rem;
551
+ font-size: 0.8rem;
552
+ }
553
+
554
+ .step-item.step-success {
555
+ background: rgba(74, 222, 128, 0.1);
556
+ }
557
+
558
+ .step-item.step-error {
559
+ background: rgba(239, 68, 68, 0.1);
560
+ }
561
+
562
+ .step-item.step-pending {
563
+ background: var(--bg-panel);
564
+ }
565
+
566
+ .step-name {
567
+ flex: 1;
568
+ text-transform: capitalize;
569
+ }
570
+
571
+ .step-status {
572
+ font-weight: 500;
573
+ text-transform: uppercase;
574
+ font-size: 0.7rem;
575
+ }
576
+
577
+ .step-success .step-status {
578
+ color: var(--success);
579
+ }
580
+
581
+ .step-error .step-status {
582
+ color: var(--error);
583
+ }
584
+
585
+ .step-error-msg {
586
+ color: var(--error);
587
+ font-size: 0.75rem;
588
+ }
589
+
590
+ /* ==================== Toast Notifications ==================== */
591
+ .toast {
592
+ position: fixed;
593
+ bottom: 2rem;
594
+ right: 2rem;
595
+ padding: 1rem 1.5rem;
596
+ border-radius: 8px;
597
+ font-size: 0.875rem;
598
+ font-weight: 500;
599
+ z-index: 1000;
600
+ animation: slideIn 0.3s ease;
601
+ max-width: 400px;
602
+ }
603
+
604
+ .toast.hidden {
605
+ display: none;
606
+ }
607
+
608
+ .toast.info {
609
+ background: var(--info);
610
+ color: var(--bg-primary);
611
+ }
612
+
613
+ .toast.success {
614
+ background: var(--success);
615
+ color: var(--bg-primary);
616
+ }
617
+
618
+ .toast.error {
619
+ background: var(--error);
620
+ color: white;
621
+ }
622
+
623
+ .toast.warning {
624
+ background: var(--warning);
625
+ color: var(--bg-primary);
626
+ }
627
+
628
+ @keyframes slideIn {
629
+ from {
630
+ transform: translateX(100%);
631
+ opacity: 0;
632
+ }
633
+ to {
634
+ transform: translateX(0);
635
+ opacity: 1;
636
+ }
637
+ }
638
+
639
+ /* ==================== Footer ==================== */
640
+ footer {
641
+ text-align: center;
642
+ padding: 1.5rem;
643
+ color: var(--text-secondary);
644
+ font-size: 0.75rem;
645
+ border-top: 1px solid var(--border);
646
+ margin-top: 2rem;
647
+ }
648
+
649
+ /* ==================== Scrollbar Styling ==================== */
650
+ ::-webkit-scrollbar {
651
+ width: 8px;
652
+ height: 8px;
653
+ }
654
+
655
+ ::-webkit-scrollbar-track {
656
+ background: var(--bg-secondary);
657
+ }
658
+
659
+ ::-webkit-scrollbar-thumb {
660
+ background: var(--border);
661
+ border-radius: 4px;
662
+ }
663
+
664
+ ::-webkit-scrollbar-thumb:hover {
665
+ background: var(--border-light);
666
+ }