feat : offline mic cam check
Browse files- README.md +110 -90
- pyproject.toml +3 -0
- reachy_mini_testbench/main.py +149 -0
- reachy_mini_testbench/static/index.html +41 -0
- reachy_mini_testbench/static/main.js +151 -0
- reachy_mini_testbench/static/style.css +119 -0
README.md
CHANGED
|
@@ -14,106 +14,126 @@ tags:
|
|
| 14 |
- ci
|
| 15 |
---
|
| 16 |
|
| 17 |
-
# Reachy Mini
|
| 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 |
-
|
| 58 |
-
pip install -e .
|
| 59 |
-
```
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
| 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_testbench
|
| 67 |
```
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
##
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
- `
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
## File Storage
|
| 110 |
|
| 111 |
- Recordings: `/tmp/reachy_mini_testbench/recordings/`
|
| 112 |
- Captures: `/tmp/reachy_mini_testbench/captures/`
|
| 113 |
-
|
| 114 |
-
## Dependencies
|
| 115 |
-
|
| 116 |
-
- reachy-mini
|
| 117 |
-
- opencv-python
|
| 118 |
-
- soundfile
|
| 119 |
-
- scipy
|
|
|
|
| 14 |
- ci
|
| 15 |
---
|
| 16 |
|
| 17 |
+
# Reachy Mini Testbench
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
Debug and CI validation tool for Reachy Mini. Works in two modes depending on daemon status.
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
**Access:** http://localhost:8042
|
| 22 |
+
|
| 23 |
+
## Quick Start
|
| 24 |
|
|
|
|
|
|
|
| 25 |
```bash
|
| 26 |
+
pip install /path/to/reachy_mini_testbench
|
| 27 |
reachy-mini app run reachy_mini_testbench
|
| 28 |
```
|
| 29 |
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Daemon ON Mode
|
| 33 |
+
|
| 34 |
+
When the daemon backend is running, test robot functionality through the SDK.
|
| 35 |
+
|
| 36 |
+
| Feature | Description |
|
| 37 |
+
|---------|-------------|
|
| 38 |
+
| **Daemon Status** | Full status: version, state, IP, control loop frequency, errors |
|
| 39 |
+
| **System Stats** | Live CPU/RAM/disk in header (polls every 5s) |
|
| 40 |
+
| **Robot Status** | Current head pose (roll/pitch/yaw) |
|
| 41 |
+
| **Motor Status** | All 9 motor positions |
|
| 42 |
+
| **Movement Control** | Head sliders, antenna control, wake/sleep/zero |
|
| 43 |
+
| **Continuous Movement** | Sinusoidal test motion at 20Hz |
|
| 44 |
+
| **Camera** | Live stream, capture/save images |
|
| 45 |
+
| **Audio** | Record/playback through robot's mic/speaker |
|
| 46 |
+
| **Rotation Validation** | Automated test: captures at zero and rolled positions, computes rotation error |
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## Daemon OFF Mode
|
| 51 |
+
|
| 52 |
+
Direct hardware access for diagnostics when daemon is not running.
|
| 53 |
+
|
| 54 |
+
| Feature | Description |
|
| 55 |
+
|---------|-------------|
|
| 56 |
+
| **Camera Check** | Direct OpenCV capture - verifies camera without daemon/WebRTC |
|
| 57 |
+
| **Microphone Check** | Direct sounddevice recording - shows peak level (dB) |
|
| 58 |
+
| **Motor Scan** | Detect motors on bus (IDs 10-18) |
|
| 59 |
+
| **Configuration Check** | Verify motor configs match expected values |
|
| 60 |
+
| **Baudrate Scanner** | Find misconfigured motors at wrong baudrates |
|
| 61 |
+
| **Motor Reflash** | Reconfigure motor ID, baudrate, settings to preset |
|
| 62 |
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## Compatibility
|
| 66 |
+
|
| 67 |
+
| Variant | Daemon ON | Daemon OFF |
|
| 68 |
+
|---------|-----------|------------|
|
| 69 |
+
| Lite | All features | All features |
|
| 70 |
+
| Wireless | All features | All features |
|
| 71 |
+
| Simulation | All features | N/A (no hardware) |
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## What Each Test Validates
|
| 76 |
+
|
| 77 |
+
| Test | Mode | Validates |
|
| 78 |
+
|------|------|-----------|
|
| 79 |
+
| Camera Check | OFF | Direct camera hardware |
|
| 80 |
+
| Mic Check | OFF | Direct microphone hardware |
|
| 81 |
+
| Motor Scan | OFF | Serial bus communication |
|
| 82 |
+
| Config Check | OFF | Motor EEPROM settings |
|
| 83 |
+
| Head Movement | ON | Kinematics + motor control |
|
| 84 |
+
| Camera Stream | ON | WebRTC/GStreamer pipeline |
|
| 85 |
+
| Audio Recording | ON | Media backend pipeline |
|
| 86 |
+
| Rotation Test | ON | Full vision + motion system |
|
| 87 |
+
|
| 88 |
+
---
|
| 89 |
+
|
| 90 |
+
## API Reference
|
| 91 |
+
|
| 92 |
+
### Connection
|
| 93 |
+
- `GET /api/connection_status` - Check daemon connection
|
| 94 |
+
- `GET /api/system_stats` - CPU/RAM/disk usage
|
| 95 |
+
- `GET /api/daemon_status` - Full daemon status
|
| 96 |
+
|
| 97 |
+
### Robot (daemon ON)
|
| 98 |
+
- `GET /api/status` - Head pose
|
| 99 |
+
- `GET /api/motor_status` - Motor positions
|
| 100 |
+
- `GET /api/robot_info` - Variant/version info
|
| 101 |
+
- `POST /api/move_head` - Move head
|
| 102 |
+
- `POST /api/move_antennas` - Move antennas
|
| 103 |
+
- `POST /api/go_to_zero` - Neutral position
|
| 104 |
+
- `POST /api/wake_up` / `POST /api/go_to_sleep`
|
| 105 |
+
- `POST /api/continuous_move/start` / `stop`
|
| 106 |
+
|
| 107 |
+
### Camera (daemon ON)
|
| 108 |
+
- `GET /api/camera/stream` - MJPEG stream
|
| 109 |
+
- `POST /api/camera/save` - Save frame
|
| 110 |
+
- `GET /api/camera/list` / `download/{f}` / `DELETE delete/{f}`
|
| 111 |
+
|
| 112 |
+
### Audio (daemon ON)
|
| 113 |
+
- `POST /api/audio/start_recording` / `stop_recording`
|
| 114 |
+
- `GET /api/audio/list` / `download/{f}` / `POST play/{f}` / `DELETE delete/{f}`
|
| 115 |
+
|
| 116 |
+
### Hardware Check (daemon OFF)
|
| 117 |
+
- `GET /api/hardware/camera_check` - Direct camera test
|
| 118 |
+
- `GET /api/hardware/mic_check` - Direct mic test
|
| 119 |
+
- `GET /api/hardware/list_audio_devices` - List audio devices
|
| 120 |
+
|
| 121 |
+
### Motor Diagnostics (daemon OFF)
|
| 122 |
+
- `GET /api/motors/serial_info` - Serial port info
|
| 123 |
+
- `GET /api/motors/scan` - Scan for motors
|
| 124 |
+
- `GET /api/motors/scan_baudrates` - Scan all baudrates
|
| 125 |
+
- `GET /api/motors/check_all` - Check configurations
|
| 126 |
+
- `POST /api/motors/led` - Control motor LED
|
| 127 |
+
- `GET /api/motors/presets` - Available motor presets
|
| 128 |
+
- `POST /api/motors/reflash` - Reflash motor config
|
| 129 |
+
|
| 130 |
+
### Tests
|
| 131 |
+
- `POST /api/test/rotation_validation` - Run rotation test
|
| 132 |
+
- `GET /api/test/last_rotation_result` - Last test result
|
| 133 |
+
|
| 134 |
+
---
|
| 135 |
|
| 136 |
## File Storage
|
| 137 |
|
| 138 |
- Recordings: `/tmp/reachy_mini_testbench/recordings/`
|
| 139 |
- Captures: `/tmp/reachy_mini_testbench/captures/`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pyproject.toml
CHANGED
|
@@ -13,7 +13,10 @@ dependencies = [
|
|
| 13 |
"reachy-mini",
|
| 14 |
"opencv-python",
|
| 15 |
"soundfile",
|
|
|
|
| 16 |
"scipy",
|
|
|
|
|
|
|
| 17 |
]
|
| 18 |
keywords = ["reachy-mini-app", "debug", "testing", "ci", "validation"]
|
| 19 |
|
|
|
|
| 13 |
"reachy-mini",
|
| 14 |
"opencv-python",
|
| 15 |
"soundfile",
|
| 16 |
+
"sounddevice",
|
| 17 |
"scipy",
|
| 18 |
+
"psutil",
|
| 19 |
+
"aiohttp",
|
| 20 |
]
|
| 21 |
keywords = ["reachy-mini-app", "debug", "testing", "ci", "validation"]
|
| 22 |
|
reachy_mini_testbench/main.py
CHANGED
|
@@ -25,6 +25,7 @@ from typing import Any
|
|
| 25 |
|
| 26 |
import cv2
|
| 27 |
import numpy as np
|
|
|
|
| 28 |
import soundfile as sf
|
| 29 |
import uvicorn
|
| 30 |
from fastapi import FastAPI, HTTPException, Response
|
|
@@ -1279,6 +1280,154 @@ async def reflash_motor(req: MotorReflashRequest):
|
|
| 1279 |
raise HTTPException(500, f"Unexpected error during reflash: {str(e)}")
|
| 1280 |
|
| 1281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1282 |
# ==================== ReachyMiniApp Wrapper for App Manager ====================
|
| 1283 |
|
| 1284 |
from reachy_mini.apps.app import ReachyMiniApp
|
|
|
|
| 25 |
|
| 26 |
import cv2
|
| 27 |
import numpy as np
|
| 28 |
+
import sounddevice as sd
|
| 29 |
import soundfile as sf
|
| 30 |
import uvicorn
|
| 31 |
from fastapi import FastAPI, HTTPException, Response
|
|
|
|
| 1280 |
raise HTTPException(500, f"Unexpected error during reflash: {str(e)}")
|
| 1281 |
|
| 1282 |
|
| 1283 |
+
# ==================== Hardware Check Endpoints (work without daemon) ====================
|
| 1284 |
+
|
| 1285 |
+
@app.get("/api/hardware/camera_check")
|
| 1286 |
+
async def check_camera():
|
| 1287 |
+
"""Check camera by capturing a frame directly (no daemon/webrtc).
|
| 1288 |
+
|
| 1289 |
+
Uses OpenCV to directly access the camera device.
|
| 1290 |
+
Returns a base64-encoded JPEG image.
|
| 1291 |
+
"""
|
| 1292 |
+
cap = None
|
| 1293 |
+
try:
|
| 1294 |
+
# Try common camera indices (0 is usually the default)
|
| 1295 |
+
for camera_idx in [0, 1, 2]:
|
| 1296 |
+
cap = cv2.VideoCapture(camera_idx)
|
| 1297 |
+
if cap.isOpened():
|
| 1298 |
+
break
|
| 1299 |
+
cap.release()
|
| 1300 |
+
cap = None
|
| 1301 |
+
|
| 1302 |
+
if cap is None:
|
| 1303 |
+
raise HTTPException(503, "No camera found. Tried indices 0, 1, 2.")
|
| 1304 |
+
|
| 1305 |
+
# Let camera warm up
|
| 1306 |
+
for _ in range(5):
|
| 1307 |
+
cap.read()
|
| 1308 |
+
|
| 1309 |
+
# Capture frame
|
| 1310 |
+
ret, frame = cap.read()
|
| 1311 |
+
if not ret or frame is None:
|
| 1312 |
+
raise HTTPException(503, "Camera opened but failed to capture frame")
|
| 1313 |
+
|
| 1314 |
+
# Get camera properties
|
| 1315 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 1316 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 1317 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 1318 |
+
|
| 1319 |
+
# Encode to JPEG
|
| 1320 |
+
_, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
| 1321 |
+
b64_image = base64.b64encode(buffer).decode('utf-8')
|
| 1322 |
+
|
| 1323 |
+
return {
|
| 1324 |
+
"success": True,
|
| 1325 |
+
"camera_index": camera_idx,
|
| 1326 |
+
"resolution": f"{width}x{height}",
|
| 1327 |
+
"fps": fps if fps > 0 else "unknown",
|
| 1328 |
+
"image": b64_image,
|
| 1329 |
+
"timestamp": datetime.now().isoformat()
|
| 1330 |
+
}
|
| 1331 |
+
|
| 1332 |
+
except HTTPException:
|
| 1333 |
+
raise
|
| 1334 |
+
except Exception as e:
|
| 1335 |
+
logger.error(f"Camera check error: {e}")
|
| 1336 |
+
raise HTTPException(500, f"Camera check failed: {str(e)}")
|
| 1337 |
+
finally:
|
| 1338 |
+
if cap is not None:
|
| 1339 |
+
cap.release()
|
| 1340 |
+
|
| 1341 |
+
|
| 1342 |
+
@app.get("/api/hardware/mic_check")
|
| 1343 |
+
async def check_microphone(duration: float = 1.0):
|
| 1344 |
+
"""Check microphone by recording a short sample directly (no daemon/webrtc).
|
| 1345 |
+
|
| 1346 |
+
Uses sounddevice for direct microphone access.
|
| 1347 |
+
Returns info about the recording and peak level.
|
| 1348 |
+
"""
|
| 1349 |
+
try:
|
| 1350 |
+
# Get default input device info
|
| 1351 |
+
device_info = sd.query_devices(kind='input')
|
| 1352 |
+
device_name = device_info['name']
|
| 1353 |
+
max_channels = device_info['max_input_channels']
|
| 1354 |
+
default_sr = int(device_info['default_samplerate'])
|
| 1355 |
+
|
| 1356 |
+
# Use default sample rate, mono, duration clamped to 0.5-5s
|
| 1357 |
+
duration = max(0.5, min(5.0, duration))
|
| 1358 |
+
samplerate = default_sr
|
| 1359 |
+
|
| 1360 |
+
# Record audio
|
| 1361 |
+
logger.info(f"Recording {duration}s from mic: {device_name}")
|
| 1362 |
+
recording = sd.rec(
|
| 1363 |
+
int(duration * samplerate),
|
| 1364 |
+
samplerate=samplerate,
|
| 1365 |
+
channels=1,
|
| 1366 |
+
dtype='float32'
|
| 1367 |
+
)
|
| 1368 |
+
sd.wait() # Wait for recording to complete
|
| 1369 |
+
|
| 1370 |
+
# Calculate audio statistics
|
| 1371 |
+
audio_data = recording.flatten()
|
| 1372 |
+
peak_level = float(np.max(np.abs(audio_data)))
|
| 1373 |
+
rms_level = float(np.sqrt(np.mean(audio_data ** 2)))
|
| 1374 |
+
|
| 1375 |
+
# Convert to dB (avoid log of 0)
|
| 1376 |
+
peak_db = 20 * np.log10(peak_level + 1e-10)
|
| 1377 |
+
rms_db = 20 * np.log10(rms_level + 1e-10)
|
| 1378 |
+
|
| 1379 |
+
# Determine if we detected sound (peak above noise floor)
|
| 1380 |
+
sound_detected = peak_db > -40 # -40 dB is typical noise floor
|
| 1381 |
+
|
| 1382 |
+
return {
|
| 1383 |
+
"success": True,
|
| 1384 |
+
"device_name": device_name,
|
| 1385 |
+
"max_channels": max_channels,
|
| 1386 |
+
"samplerate": samplerate,
|
| 1387 |
+
"duration_recorded": duration,
|
| 1388 |
+
"samples": len(audio_data),
|
| 1389 |
+
"peak_level": round(peak_level, 4),
|
| 1390 |
+
"peak_db": round(peak_db, 1),
|
| 1391 |
+
"rms_level": round(rms_level, 4),
|
| 1392 |
+
"rms_db": round(rms_db, 1),
|
| 1393 |
+
"sound_detected": sound_detected,
|
| 1394 |
+
"timestamp": datetime.now().isoformat()
|
| 1395 |
+
}
|
| 1396 |
+
|
| 1397 |
+
except sd.PortAudioError as e:
|
| 1398 |
+
logger.error(f"Microphone access error: {e}")
|
| 1399 |
+
raise HTTPException(503, f"No microphone available: {str(e)}")
|
| 1400 |
+
except Exception as e:
|
| 1401 |
+
logger.error(f"Mic check error: {e}")
|
| 1402 |
+
raise HTTPException(500, f"Microphone check failed: {str(e)}")
|
| 1403 |
+
|
| 1404 |
+
|
| 1405 |
+
@app.get("/api/hardware/list_audio_devices")
|
| 1406 |
+
async def list_audio_devices():
|
| 1407 |
+
"""List all available audio input devices."""
|
| 1408 |
+
try:
|
| 1409 |
+
devices = sd.query_devices()
|
| 1410 |
+
input_devices = []
|
| 1411 |
+
|
| 1412 |
+
for idx, dev in enumerate(devices):
|
| 1413 |
+
if dev['max_input_channels'] > 0:
|
| 1414 |
+
input_devices.append({
|
| 1415 |
+
"index": idx,
|
| 1416 |
+
"name": dev['name'],
|
| 1417 |
+
"channels": dev['max_input_channels'],
|
| 1418 |
+
"default_samplerate": int(dev['default_samplerate']),
|
| 1419 |
+
"is_default": idx == sd.default.device[0] # default input device
|
| 1420 |
+
})
|
| 1421 |
+
|
| 1422 |
+
return {
|
| 1423 |
+
"input_devices": input_devices,
|
| 1424 |
+
"default_input_index": sd.default.device[0]
|
| 1425 |
+
}
|
| 1426 |
+
except Exception as e:
|
| 1427 |
+
logger.error(f"List audio devices error: {e}")
|
| 1428 |
+
raise HTTPException(500, f"Failed to list audio devices: {str(e)}")
|
| 1429 |
+
|
| 1430 |
+
|
| 1431 |
# ==================== ReachyMiniApp Wrapper for App Manager ====================
|
| 1432 |
|
| 1433 |
from reachy_mini.apps.app import ReachyMiniApp
|
reachy_mini_testbench/static/index.html
CHANGED
|
@@ -174,6 +174,47 @@
|
|
| 174 |
</div>
|
| 175 |
</section>
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
<!-- Motor Diagnostics Panel (daemon-off only) -->
|
| 178 |
<section class="panel daemon-off-only" id="diagnostics-panel">
|
| 179 |
<h2>Motor Diagnostics</h2>
|
|
|
|
| 174 |
</div>
|
| 175 |
</section>
|
| 176 |
|
| 177 |
+
<!-- Hardware Check Panel (daemon-off only) -->
|
| 178 |
+
<section class="panel daemon-off-only" id="hardware-check-panel">
|
| 179 |
+
<h2>Hardware Check</h2>
|
| 180 |
+
<div class="panel-content">
|
| 181 |
+
<p class="panel-description">
|
| 182 |
+
Direct camera and microphone access (no daemon/webrtc).
|
| 183 |
+
Use this to verify hardware works independently.
|
| 184 |
+
</p>
|
| 185 |
+
|
| 186 |
+
<div class="control-group">
|
| 187 |
+
<h3>Camera Check</h3>
|
| 188 |
+
<button onclick="checkCamera()" class="btn btn-primary" id="camera-check-btn">Check Camera</button>
|
| 189 |
+
<div id="camera-check-result" class="hardware-check-result hidden">
|
| 190 |
+
<div class="camera-preview-container">
|
| 191 |
+
<img id="camera-check-preview" src="" alt="Camera preview">
|
| 192 |
+
</div>
|
| 193 |
+
<div class="hardware-check-info" id="camera-check-info">
|
| 194 |
+
<!-- Info will be populated by JS -->
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
|
| 199 |
+
<div class="control-group">
|
| 200 |
+
<h3>Microphone Check</h3>
|
| 201 |
+
<p class="panel-description">Records 1 second of audio and shows level</p>
|
| 202 |
+
<button onclick="checkMicrophone()" class="btn btn-primary" id="mic-check-btn">Check Microphone</button>
|
| 203 |
+
<div id="mic-check-result" class="hardware-check-result hidden">
|
| 204 |
+
<div class="mic-level-container">
|
| 205 |
+
<div class="mic-level-bar">
|
| 206 |
+
<div class="mic-level-fill" id="mic-level-fill"></div>
|
| 207 |
+
</div>
|
| 208 |
+
<span class="mic-level-value" id="mic-level-value">-- dB</span>
|
| 209 |
+
</div>
|
| 210 |
+
<div class="hardware-check-info" id="mic-check-info">
|
| 211 |
+
<!-- Info will be populated by JS -->
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
</section>
|
| 217 |
+
|
| 218 |
<!-- Motor Diagnostics Panel (daemon-off only) -->
|
| 219 |
<section class="panel daemon-off-only" id="diagnostics-panel">
|
| 220 |
<h2>Motor Diagnostics</h2>
|
reachy_mini_testbench/static/main.js
CHANGED
|
@@ -847,6 +847,157 @@ async function reflashMotor() {
|
|
| 847 |
}
|
| 848 |
}
|
| 849 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 850 |
// ==================== Connection Status ====================
|
| 851 |
|
| 852 |
async function fetchConnectionStatus() {
|
|
|
|
| 847 |
}
|
| 848 |
}
|
| 849 |
|
| 850 |
+
// ==================== Hardware Check Functions ====================
|
| 851 |
+
|
| 852 |
+
async function checkCamera() {
|
| 853 |
+
const btn = document.getElementById('camera-check-btn');
|
| 854 |
+
const resultDiv = document.getElementById('camera-check-result');
|
| 855 |
+
const previewImg = document.getElementById('camera-check-preview');
|
| 856 |
+
const infoDiv = document.getElementById('camera-check-info');
|
| 857 |
+
|
| 858 |
+
try {
|
| 859 |
+
btn.disabled = true;
|
| 860 |
+
btn.textContent = 'Checking...';
|
| 861 |
+
resultDiv.classList.add('hidden');
|
| 862 |
+
showToast('Checking camera...', 'info');
|
| 863 |
+
|
| 864 |
+
const data = await apiCall('/api/hardware/camera_check');
|
| 865 |
+
|
| 866 |
+
// Show result
|
| 867 |
+
resultDiv.classList.remove('hidden');
|
| 868 |
+
|
| 869 |
+
// Set image
|
| 870 |
+
previewImg.src = `data:image/jpeg;base64,${data.image}`;
|
| 871 |
+
|
| 872 |
+
// Build info HTML
|
| 873 |
+
infoDiv.innerHTML = `
|
| 874 |
+
<div class="check-info-item ok">
|
| 875 |
+
<span class="label">Status</span>
|
| 876 |
+
<span class="value">Camera Working</span>
|
| 877 |
+
</div>
|
| 878 |
+
<div class="check-info-item">
|
| 879 |
+
<span class="label">Device Index</span>
|
| 880 |
+
<span class="value">${data.camera_index}</span>
|
| 881 |
+
</div>
|
| 882 |
+
<div class="check-info-item">
|
| 883 |
+
<span class="label">Resolution</span>
|
| 884 |
+
<span class="value">${data.resolution}</span>
|
| 885 |
+
</div>
|
| 886 |
+
<div class="check-info-item">
|
| 887 |
+
<span class="label">FPS</span>
|
| 888 |
+
<span class="value">${typeof data.fps === 'number' ? data.fps.toFixed(0) : data.fps}</span>
|
| 889 |
+
</div>
|
| 890 |
+
`;
|
| 891 |
+
|
| 892 |
+
showToast('Camera check passed!', 'success');
|
| 893 |
+
|
| 894 |
+
} catch (error) {
|
| 895 |
+
resultDiv.classList.remove('hidden');
|
| 896 |
+
previewImg.src = '';
|
| 897 |
+
infoDiv.innerHTML = `
|
| 898 |
+
<div class="check-info-item error">
|
| 899 |
+
<span class="label">Status</span>
|
| 900 |
+
<span class="value">Camera Failed</span>
|
| 901 |
+
</div>
|
| 902 |
+
<div class="check-info-item error">
|
| 903 |
+
<span class="label">Error</span>
|
| 904 |
+
<span class="value">${error.message}</span>
|
| 905 |
+
</div>
|
| 906 |
+
`;
|
| 907 |
+
console.error('Camera check failed:', error);
|
| 908 |
+
} finally {
|
| 909 |
+
btn.disabled = false;
|
| 910 |
+
btn.textContent = 'Check Camera';
|
| 911 |
+
}
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
async function checkMicrophone() {
|
| 915 |
+
const btn = document.getElementById('mic-check-btn');
|
| 916 |
+
const resultDiv = document.getElementById('mic-check-result');
|
| 917 |
+
const levelFill = document.getElementById('mic-level-fill');
|
| 918 |
+
const levelValue = document.getElementById('mic-level-value');
|
| 919 |
+
const infoDiv = document.getElementById('mic-check-info');
|
| 920 |
+
|
| 921 |
+
try {
|
| 922 |
+
btn.disabled = true;
|
| 923 |
+
btn.textContent = 'Recording...';
|
| 924 |
+
resultDiv.classList.add('hidden');
|
| 925 |
+
showToast('Recording from microphone...', 'info');
|
| 926 |
+
|
| 927 |
+
const data = await apiCall('/api/hardware/mic_check?duration=1.0');
|
| 928 |
+
|
| 929 |
+
// Show result
|
| 930 |
+
resultDiv.classList.remove('hidden');
|
| 931 |
+
|
| 932 |
+
// Calculate level bar width (map -60dB to 0dB to 0% to 100%)
|
| 933 |
+
const peakDb = data.peak_db;
|
| 934 |
+
const barPercent = Math.max(0, Math.min(100, ((peakDb + 60) / 60) * 100));
|
| 935 |
+
levelFill.style.width = `${barPercent}%`;
|
| 936 |
+
|
| 937 |
+
// Color based on level
|
| 938 |
+
if (data.sound_detected) {
|
| 939 |
+
levelFill.className = 'mic-level-fill detected';
|
| 940 |
+
} else {
|
| 941 |
+
levelFill.className = 'mic-level-fill quiet';
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
levelValue.textContent = `${peakDb.toFixed(1)} dB`;
|
| 945 |
+
|
| 946 |
+
// Build info HTML
|
| 947 |
+
const soundStatus = data.sound_detected ? 'Sound Detected' : 'Very Quiet/No Sound';
|
| 948 |
+
const statusClass = data.sound_detected ? 'ok' : 'warning';
|
| 949 |
+
|
| 950 |
+
infoDiv.innerHTML = `
|
| 951 |
+
<div class="check-info-item ${statusClass}">
|
| 952 |
+
<span class="label">Status</span>
|
| 953 |
+
<span class="value">${soundStatus}</span>
|
| 954 |
+
</div>
|
| 955 |
+
<div class="check-info-item">
|
| 956 |
+
<span class="label">Device</span>
|
| 957 |
+
<span class="value">${data.device_name}</span>
|
| 958 |
+
</div>
|
| 959 |
+
<div class="check-info-item">
|
| 960 |
+
<span class="label">Sample Rate</span>
|
| 961 |
+
<span class="value">${data.samplerate} Hz</span>
|
| 962 |
+
</div>
|
| 963 |
+
<div class="check-info-item">
|
| 964 |
+
<span class="label">Peak Level</span>
|
| 965 |
+
<span class="value">${data.peak_db.toFixed(1)} dB</span>
|
| 966 |
+
</div>
|
| 967 |
+
<div class="check-info-item">
|
| 968 |
+
<span class="label">RMS Level</span>
|
| 969 |
+
<span class="value">${data.rms_db.toFixed(1)} dB</span>
|
| 970 |
+
</div>
|
| 971 |
+
`;
|
| 972 |
+
|
| 973 |
+
if (data.sound_detected) {
|
| 974 |
+
showToast('Microphone check passed - sound detected!', 'success');
|
| 975 |
+
} else {
|
| 976 |
+
showToast('Microphone working but very quiet', 'warning');
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
} catch (error) {
|
| 980 |
+
resultDiv.classList.remove('hidden');
|
| 981 |
+
levelFill.style.width = '0%';
|
| 982 |
+
levelFill.className = 'mic-level-fill';
|
| 983 |
+
levelValue.textContent = '-- dB';
|
| 984 |
+
infoDiv.innerHTML = `
|
| 985 |
+
<div class="check-info-item error">
|
| 986 |
+
<span class="label">Status</span>
|
| 987 |
+
<span class="value">Microphone Failed</span>
|
| 988 |
+
</div>
|
| 989 |
+
<div class="check-info-item error">
|
| 990 |
+
<span class="label">Error</span>
|
| 991 |
+
<span class="value">${error.message}</span>
|
| 992 |
+
</div>
|
| 993 |
+
`;
|
| 994 |
+
console.error('Microphone check failed:', error);
|
| 995 |
+
} finally {
|
| 996 |
+
btn.disabled = false;
|
| 997 |
+
btn.textContent = 'Check Microphone';
|
| 998 |
+
}
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
// ==================== Connection Status ====================
|
| 1002 |
|
| 1003 |
async function fetchConnectionStatus() {
|
reachy_mini_testbench/static/style.css
CHANGED
|
@@ -852,6 +852,125 @@ main {
|
|
| 852 |
}
|
| 853 |
}
|
| 854 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
/* ==================== Motor Diagnostics ==================== */
|
| 856 |
.serial-info {
|
| 857 |
display: flex;
|
|
|
|
| 852 |
}
|
| 853 |
}
|
| 854 |
|
| 855 |
+
/* ==================== Hardware Check ==================== */
|
| 856 |
+
.hardware-check-result {
|
| 857 |
+
margin-top: 1rem;
|
| 858 |
+
padding: 1rem;
|
| 859 |
+
background: var(--bg-panel);
|
| 860 |
+
border-radius: 8px;
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
.hardware-check-result.hidden {
|
| 864 |
+
display: none;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.camera-preview-container {
|
| 868 |
+
margin-bottom: 1rem;
|
| 869 |
+
background: #000;
|
| 870 |
+
border-radius: 6px;
|
| 871 |
+
overflow: hidden;
|
| 872 |
+
aspect-ratio: 4/3;
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
.camera-preview-container img {
|
| 876 |
+
width: 100%;
|
| 877 |
+
height: 100%;
|
| 878 |
+
object-fit: contain;
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
.hardware-check-info {
|
| 882 |
+
display: grid;
|
| 883 |
+
grid-template-columns: repeat(2, 1fr);
|
| 884 |
+
gap: 0.5rem;
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
.check-info-item {
|
| 888 |
+
display: flex;
|
| 889 |
+
flex-direction: column;
|
| 890 |
+
padding: 0.5rem 0.75rem;
|
| 891 |
+
background: var(--bg-secondary);
|
| 892 |
+
border-radius: 4px;
|
| 893 |
+
border-left: 3px solid var(--border);
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
.check-info-item.ok {
|
| 897 |
+
border-left-color: var(--success);
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
.check-info-item.warning {
|
| 901 |
+
border-left-color: var(--warning);
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
.check-info-item.error {
|
| 905 |
+
border-left-color: var(--error);
|
| 906 |
+
}
|
| 907 |
+
|
| 908 |
+
.check-info-item .label {
|
| 909 |
+
font-size: 0.65rem;
|
| 910 |
+
color: var(--text-secondary);
|
| 911 |
+
text-transform: uppercase;
|
| 912 |
+
margin-bottom: 0.2rem;
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
.check-info-item .value {
|
| 916 |
+
font-size: 0.85rem;
|
| 917 |
+
font-weight: 500;
|
| 918 |
+
color: var(--text-primary);
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
.check-info-item.ok .value {
|
| 922 |
+
color: var(--success);
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
.check-info-item.warning .value {
|
| 926 |
+
color: var(--warning);
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
.check-info-item.error .value {
|
| 930 |
+
color: var(--error);
|
| 931 |
+
}
|
| 932 |
+
|
| 933 |
+
/* Microphone Level Bar */
|
| 934 |
+
.mic-level-container {
|
| 935 |
+
display: flex;
|
| 936 |
+
align-items: center;
|
| 937 |
+
gap: 1rem;
|
| 938 |
+
margin-bottom: 1rem;
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
.mic-level-bar {
|
| 942 |
+
flex: 1;
|
| 943 |
+
height: 24px;
|
| 944 |
+
background: var(--bg-secondary);
|
| 945 |
+
border-radius: 12px;
|
| 946 |
+
overflow: hidden;
|
| 947 |
+
border: 1px solid var(--border);
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
.mic-level-fill {
|
| 951 |
+
height: 100%;
|
| 952 |
+
width: 0%;
|
| 953 |
+
background: var(--border);
|
| 954 |
+
transition: width 0.3s ease, background 0.3s ease;
|
| 955 |
+
border-radius: 12px;
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
.mic-level-fill.detected {
|
| 959 |
+
background: linear-gradient(90deg, var(--success), #22c55e);
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
.mic-level-fill.quiet {
|
| 963 |
+
background: linear-gradient(90deg, var(--warning), #f59e0b);
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
.mic-level-value {
|
| 967 |
+
font-size: 1rem;
|
| 968 |
+
font-weight: 600;
|
| 969 |
+
color: var(--text-primary);
|
| 970 |
+
min-width: 70px;
|
| 971 |
+
text-align: right;
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
/* ==================== Motor Diagnostics ==================== */
|
| 975 |
.serial-info {
|
| 976 |
display: flex;
|