Spaces:
Running
Running
Add private local Reachy camera viewer
Browse files- CHANGELOG.md +4 -2
- README.md +4 -4
- SECURITY.md +2 -0
- THIRD_PARTY_NOTICES.md +43 -0
- index.html +4 -4
- pyproject.toml +1 -1
- reachy_mini_hermes/config.py +1 -0
- reachy_mini_hermes/main.py +1 -0
- reachy_mini_hermes/runtime.py +7 -0
- reachy_mini_hermes/static/camera.js +233 -0
- reachy_mini_hermes/static/gstwebrtc-api.js +0 -0
- reachy_mini_hermes/static/index.html +22 -6
- reachy_mini_hermes/static/main.js +17 -1
- reachy_mini_hermes/static/service-worker.js +3 -1
- reachy_mini_hermes/static/style.css +10 -0
- tests/test_camera_ui.py +54 -0
- tests/test_config.py +1 -0
- tests/test_pwa.py +2 -0
- tests/test_runtime_power.py +30 -0
CHANGELOG.md
CHANGED
|
@@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
| 24 |
- Realtime client, silence playback asset, and tests for Realtime audio and power-state behavior.
|
| 25 |
- Optional on-demand Reachy camera tool with local diagnostics and Realtime image input.
|
| 26 |
- Authenticated, non-cacheable one-frame snapshot route for explicit image sharing.
|
|
|
|
| 27 |
- Privacy-controlled daemon-local face following that runs only during an active post-wake conversation.
|
| 28 |
- Optional wake-time DOA orientation using Reachy's local microphone-array direction estimate.
|
| 29 |
- Curated Realtime robot tools for look direction, authentic recorded emotions, and three recorded dance styles.
|
|
@@ -49,7 +50,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
| 49 |
- App-off no longer waits on the daemon response from inside the process being stopped, removing a ten-second shutdown cycle and traceback.
|
| 50 |
- Realtime interruption tracks locally buffered audio after server generation finishes, immediately flushes Reachy playback, and truncates the unplayed OpenAI conversation audio.
|
| 51 |
- Camera access defaults to off and captures only one fresh JPEG per explicit Realtime visual-tool call.
|
| 52 |
-
- Camera capture waits for a completed tool item, deduplicates call IDs, and remains blocked in Meeting/Sleep.
|
|
|
|
| 53 |
- Awake now runs Reachy's physical wake-up motion instead of only enabling motor torque.
|
| 54 |
- Meeting and Sleep stop active playback and microphone capture before disabling motors.
|
| 55 |
- Meeting/Sleep action cancellation now restarts Reachy's playback backend when returning to Standby/Awake.
|
|
@@ -64,7 +66,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
| 64 |
|
| 65 |
### Verified
|
| 66 |
|
| 67 |
-
- Ruff passes and
|
| 68 |
- Realtime session creation, audio response, configurable reasoning, and Hermes tool delegation succeed against the live API.
|
| 69 |
- ElevenLabs TTS/STT round trip succeeds.
|
| 70 |
- Reachy power states, clean app stop/restart, API soak tests, motor mode, and daemon health pass on the reference Reachy Mini Lite deployment.
|
|
|
|
| 24 |
- Realtime client, silence playback asset, and tests for Realtime audio and power-state behavior.
|
| 25 |
- Optional on-demand Reachy camera tool with local diagnostics and Realtime image input.
|
| 26 |
- Authenticated, non-cacheable one-frame snapshot route for explicit image sharing.
|
| 27 |
+
- Opt-in Robot-tab live viewer for the daemon's existing local WebRTC camera feed, with explicit Awake-only policy, muted audio, no public STUN dependency, and automatic disconnect on privacy/background transitions.
|
| 28 |
- Privacy-controlled daemon-local face following that runs only during an active post-wake conversation.
|
| 29 |
- Optional wake-time DOA orientation using Reachy's local microphone-array direction estimate.
|
| 30 |
- Curated Realtime robot tools for look direction, authentic recorded emotions, and three recorded dance styles.
|
|
|
|
| 50 |
- App-off no longer waits on the daemon response from inside the process being stopped, removing a ten-second shutdown cycle and traceback.
|
| 51 |
- Realtime interruption tracks locally buffered audio after server generation finishes, immediately flushes Reachy playback, and truncates the unplayed OpenAI conversation audio.
|
| 52 |
- Camera access defaults to off and captures only one fresh JPEG per explicit Realtime visual-tool call.
|
| 53 |
+
- Camera capture waits for a completed tool item, deduplicates call IDs, and remains blocked in Meeting/Sleep; authenticated snapshots and local diagnostics now enforce and recheck the same privacy boundary while waiting for a frame.
|
| 54 |
+
- The local viewer closes both media sessions and signaling sockets, accepts only Reachy's named camera producer, and fails closed when runtime status disappears or the voice app stops.
|
| 55 |
- Awake now runs Reachy's physical wake-up motion instead of only enabling motor torque.
|
| 56 |
- Meeting and Sleep stop active playback and microphone capture before disabling motors.
|
| 57 |
- Meeting/Sleep action cancellation now restarts Reachy's playback backend when returning to Standby/Awake.
|
|
|
|
| 66 |
|
| 67 |
### Verified
|
| 68 |
|
| 69 |
+
- Ruff passes and 70 automated tests pass.
|
| 70 |
- Realtime session creation, audio response, configurable reasoning, and Hermes tool delegation succeed against the live API.
|
| 71 |
- ElevenLabs TTS/STT round trip succeeds.
|
| 72 |
- Reachy power states, clean app stop/restart, API soak tests, motor mode, and daemon health pass on the reference Reachy Mini Lite deployment.
|
README.md
CHANGED
|
@@ -70,7 +70,7 @@ Pipeline mode supports selectable STT, TTS, agent model, voice, and continued co
|
|
| 70 |
- Curated Realtime embodiment tools for looking, emotions, and authentic recorded Reachy dances.
|
| 71 |
- Optional daemon-local face following, active only after the wake phrase for the current conversation.
|
| 72 |
- Optional wake-time microphone-array direction finding so Reachy turns once toward the speaker locally.
|
| 73 |
-
- Privacy-preserving
|
| 74 |
- Selectable ElevenLabs Scribe/TTS models and account voices without storing provider keys on Reachy.
|
| 75 |
- Stable Hermes memory scope plus rotating conversation sessions after inactivity.
|
| 76 |
- Listening, processing, speaking, and error cues with optional voice-state motion.
|
|
@@ -202,7 +202,7 @@ Press **Test connection**, save, then say:
|
|
| 202 |
The in-app UI is organized into three keyboard-accessible tabs:
|
| 203 |
|
| 204 |
- **Dashboard** — live state, power/privacy modes, app lifecycle, and the latest conversation.
|
| 205 |
-
- **Robot** — safe manual look directions, curated emotions, three recorded dances, stop movement, and
|
| 206 |
- **Settings** — Hermes bridge, voice, embodiment, privacy, and advanced timing configuration.
|
| 207 |
|
| 208 |
Manual controls use the same serialized semantic action worker as Realtime; the browser cannot submit raw joints, arbitrary move names, shell commands, or host actions. A manual movement from Standby first completes Reachy's native wake motion and leaves it Awake. The server rejects additional taps while an action is active, rechecks privacy immediately before physical execution, and blocks all movement during Meeting/Sleep. **Stop movement** stays available out of band, cancels the active move plus queued work without changing power mode or initiating a new pose, and preserves the voice playback pipeline. The UI is intended only for a trusted LAN/VPN.
|
|
@@ -220,7 +220,7 @@ The Dashboard is also a Progressive Web App. Android Chrome exposes an **Install
|
|
| 220 |
|
| 221 |
The settings server stays available in these modes. **Stop voice app** exits the app and releases its resources. **Shut down Pi** requires typing `SHUTDOWN` in the UI before the host power-off command is scheduled.
|
| 222 |
|
| 223 |
-
Camera access is disabled by default
|
| 224 |
|
| 225 |
Local face following is a separate opt-in. It uses Reachy SDK 1.9 daemon-side tracking only after **Hey Hermes** and stops when that conversation ends or Meeting/Sleep begins. Tracking frames are not forwarded to Hermes or OpenAI. Optional DOA uses the microphone array's local angle estimate once after wake detection, then discards it after orienting the head.
|
| 226 |
|
|
@@ -297,7 +297,7 @@ uv build --wheel
|
|
| 297 |
reachy-mini-app-assistant check .
|
| 298 |
```
|
| 299 |
|
| 300 |
-
Current automated suite: **
|
| 301 |
|
| 302 |
The implementation plan and status are in [`plan.md`](plan.md). Changes are recorded in [`CHANGELOG.md`](CHANGELOG.md).
|
| 303 |
|
|
|
|
| 70 |
- Curated Realtime embodiment tools for looking, emotions, and authentic recorded Reachy dances.
|
| 71 |
- Optional daemon-local face following, active only after the wake phrase for the current conversation.
|
| 72 |
- Optional wake-time microphone-array direction finding so Reachy turns once toward the speaker locally.
|
| 73 |
+
- Privacy-preserving cameras: one JPEG is captured only when a visual request needs it, while an independent opt-in UI viewer connects directly to Reachy's local WebRTC feed.
|
| 74 |
- Selectable ElevenLabs Scribe/TTS models and account voices without storing provider keys on Reachy.
|
| 75 |
- Stable Hermes memory scope plus rotating conversation sessions after inactivity.
|
| 76 |
- Listening, processing, speaking, and error cues with optional voice-state motion.
|
|
|
|
| 202 |
The in-app UI is organized into three keyboard-accessible tabs:
|
| 203 |
|
| 204 |
- **Dashboard** — live state, power/privacy modes, app lifecycle, and the latest conversation.
|
| 205 |
+
- **Robot** — safe manual look directions, curated emotions, three recorded dances, stop movement, and an opt-in local WebRTC camera viewer.
|
| 206 |
- **Settings** — Hermes bridge, voice, embodiment, privacy, and advanced timing configuration.
|
| 207 |
|
| 208 |
Manual controls use the same serialized semantic action worker as Realtime; the browser cannot submit raw joints, arbitrary move names, shell commands, or host actions. A manual movement from Standby first completes Reachy's native wake motion and leaves it Awake. The server rejects additional taps while an action is active, rechecks privacy immediately before physical execution, and blocks all movement during Meeting/Sleep. **Stop movement** stays available out of band, cancels the active move plus queued work without changing power mode or initiating a new pose, and preserves the voice playback pipeline. The UI is intended only for a trusted LAN/VPN.
|
|
|
|
| 220 |
|
| 221 |
The settings server stays available in these modes. **Stop voice app** exits the app and releases its resources. **Shut down Pi** requires typing `SHUTDOWN` in the UI before the host power-off command is scheduled.
|
| 222 |
|
| 223 |
+
Camera access is disabled by default and split into two independent controls. **On-demand camera** allows Realtime to request one fresh frame for prompts such as “What do you see?”; it never creates a continuous cloud stream. **Local live camera** enables an explicit Start button in the Robot tab. That viewer uses the same daemon-local WebRTC producer as Reachy Mini Control, disables the remote audio track, uses no public STUN service, and sends video directly from Reachy to the current browser—not through Hermes or OpenAI. It is available only while Reachy is Awake and disconnects when the Robot tab is left, the page is backgrounded, or power enters Standby, Meeting, or Sleep. The local camera test still reports only JPEG metadata, not image content.
|
| 224 |
|
| 225 |
Local face following is a separate opt-in. It uses Reachy SDK 1.9 daemon-side tracking only after **Hey Hermes** and stops when that conversation ends or Meeting/Sleep begins. Tracking frames are not forwarded to Hermes or OpenAI. Optional DOA uses the microphone array's local angle estimate once after wake detection, then discards it after orienting the head.
|
| 226 |
|
|
|
|
| 297 |
reachy-mini-app-assistant check .
|
| 298 |
```
|
| 299 |
|
| 300 |
+
Current automated suite: **70 tests**.
|
| 301 |
|
| 302 |
The implementation plan and status are in [`plan.md`](plan.md). Changes are recorded in [`CHANGELOG.md`](CHANGELOG.md).
|
| 303 |
|
SECURITY.md
CHANGED
|
@@ -42,6 +42,8 @@ The local `set_reachy_power_mode` Realtime tool can select only Standby, Awake,
|
|
| 42 |
|
| 43 |
The snapshot API returns image bytes only after bearer-token authentication and explicit confirmation, and sets `Cache-Control: no-store`. The unauthenticated local camera test returns metadata only.
|
| 44 |
|
|
|
|
|
|
|
| 45 |
## Operational controls
|
| 46 |
|
| 47 |
- Keep `security.redact_secrets` enabled in Hermes.
|
|
|
|
| 42 |
|
| 43 |
The snapshot API returns image bytes only after bearer-token authentication and explicit confirmation, and sets `Cache-Control: no-store`. The unauthenticated local camera test returns metadata only.
|
| 44 |
|
| 45 |
+
The optional local live viewer does not create a Hermes camera endpoint. After an explicit user action while Reachy is Awake, the browser connects directly to the daemon's existing GStreamer WebRTC producer on port 8443—the same feed used by Reachy Mini Control. The UI disables the audio track, uses no public STUN service, and closes its session on tab exit, page backgrounding, Standby, Meeting, or Sleep. The `camera_feed_enabled` setting controls this UI, but it does not disable Reachy's upstream daemon producer or prevent another authorized Reachy Control client from connecting; network access to the daemon and signaling port remains the real trust boundary.
|
| 46 |
+
|
| 47 |
## Operational controls
|
| 48 |
|
| 49 |
- Keep `security.redact_secrets` enabled in Hermes.
|
THIRD_PARTY_NOTICES.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Third-party notices for the local camera viewer
|
| 2 |
+
|
| 3 |
+
The Reachy Mini Hermes browser camera viewer includes an unmodified bundled copy
|
| 4 |
+
of `gstwebrtc-api.js`, sourced from Pollen Robotics' Reachy Mini Control desktop
|
| 5 |
+
application and originally maintained by the GStreamer project:
|
| 6 |
+
|
| 7 |
+
- Project: https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs/-/tree/main/net/webrtc/gstwebrtc-api
|
| 8 |
+
- License: Mozilla Public License 2.0
|
| 9 |
+
- Copyright: Copyright (C) 2022 Igalia S.L.
|
| 10 |
+
- Author: Loïc Le Page
|
| 11 |
+
|
| 12 |
+
The bundle contains `webrtc-adapter`:
|
| 13 |
+
|
| 14 |
+
- Project: https://github.com/webrtcHacks/adapter
|
| 15 |
+
- License: BSD 3-Clause
|
| 16 |
+
- Copyright: Copyright (c) 2014 The WebRTC project authors; Copyright (c) 2018 The adapter.js project authors
|
| 17 |
+
|
| 18 |
+
BSD 3-Clause license terms:
|
| 19 |
+
|
| 20 |
+
> Redistribution and use in source and binary forms, with or without modification,
|
| 21 |
+
> are permitted provided that the following conditions are met:
|
| 22 |
+
>
|
| 23 |
+
> 1. Redistributions of source code must retain the above copyright notice, this
|
| 24 |
+
> list of conditions and the following disclaimer.
|
| 25 |
+
> 2. Redistributions in binary form must reproduce the above copyright notice,
|
| 26 |
+
> this list of conditions and the following disclaimer in the documentation
|
| 27 |
+
> and/or other materials provided with the distribution.
|
| 28 |
+
> 3. Neither the name of the copyright holder nor the names of its contributors
|
| 29 |
+
> may be used to endorse or promote products derived from this software without
|
| 30 |
+
> specific prior written permission.
|
| 31 |
+
>
|
| 32 |
+
> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
| 33 |
+
> ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
| 34 |
+
> WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
| 35 |
+
> DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
| 36 |
+
> ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
| 37 |
+
> (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
| 38 |
+
> LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
| 39 |
+
> ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
| 40 |
+
> (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
| 41 |
+
> SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
| 42 |
+
|
| 43 |
+
The original license notices remain intact at the top of the distributed JavaScript bundle.
|
index.html
CHANGED
|
@@ -30,7 +30,7 @@
|
|
| 30 |
</div>
|
| 31 |
<div class="proof" aria-label="Project facts">
|
| 32 |
<span><strong>Local</strong> pre-wake audio</span>
|
| 33 |
-
<span><strong>
|
| 34 |
<span><strong>Apache-2.0</strong> licensed</span>
|
| 35 |
</div>
|
| 36 |
</div>
|
|
@@ -109,9 +109,9 @@
|
|
| 109 |
<article class="feature feature-camera">
|
| 110 |
<div class="feature-index">02</div>
|
| 111 |
<div class="feature-copy">
|
| 112 |
-
<p class="feature-tag">ON-DEMAND CAMERA</p>
|
| 113 |
-
<h3>
|
| 114 |
-
<p>
|
| 115 |
</div>
|
| 116 |
<div class="camera-panel" aria-hidden="true">
|
| 117 |
<div class="viewfinder"><i></i><i></i><i></i><i></i><span>ONE FRAME</span></div>
|
|
|
|
| 30 |
</div>
|
| 31 |
<div class="proof" aria-label="Project facts">
|
| 32 |
<span><strong>Local</strong> pre-wake audio</span>
|
| 33 |
+
<span><strong>70</strong> automated tests</span>
|
| 34 |
<span><strong>Apache-2.0</strong> licensed</span>
|
| 35 |
</div>
|
| 36 |
</div>
|
|
|
|
| 109 |
<article class="feature feature-camera">
|
| 110 |
<div class="feature-index">02</div>
|
| 111 |
<div class="feature-copy">
|
| 112 |
+
<p class="feature-tag">LOCAL LIVE + ON-DEMAND CAMERA</p>
|
| 113 |
+
<h3>Stream locally. Share one frame only when asked.</h3>
|
| 114 |
+
<p>The Robot tab can connect directly to Reachy's existing local WebRTC feed without routing video through Hermes or OpenAI. Separately, an explicit visual request captures one bounded JPEG for Realtime. Meeting and Sleep block both app-controlled paths.</p>
|
| 115 |
</div>
|
| 116 |
<div class="camera-panel" aria-hidden="true">
|
| 117 |
<div class="viewfinder"><i></i><i></i><i></i><i></i><span>ONE FRAME</span></div>
|
pyproject.toml
CHANGED
|
@@ -9,7 +9,7 @@ description = "Talk to your Hermes Agent through Reachy Mini with the Hey Hermes
|
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.11"
|
| 11 |
license = "Apache-2.0"
|
| 12 |
-
license-files = ["LICENSE"]
|
| 13 |
authors = [{ name = "Tim Verhoogt and contributors" }]
|
| 14 |
dependencies = [
|
| 15 |
"httpx>=0.28,<1",
|
|
|
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.11"
|
| 11 |
license = "Apache-2.0"
|
| 12 |
+
license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
|
| 13 |
authors = [{ name = "Tim Verhoogt and contributors" }]
|
| 14 |
dependencies = [
|
| 15 |
"httpx>=0.28,<1",
|
reachy_mini_hermes/config.py
CHANGED
|
@@ -53,6 +53,7 @@ class AppConfig:
|
|
| 53 |
motion_enabled: bool = True
|
| 54 |
barge_in_enabled: bool = True
|
| 55 |
camera_enabled: bool = False
|
|
|
|
| 56 |
face_tracking_enabled: bool = False
|
| 57 |
face_tracking_weight: float = 0.65
|
| 58 |
doa_enabled: bool = False
|
|
|
|
| 53 |
motion_enabled: bool = True
|
| 54 |
barge_in_enabled: bool = True
|
| 55 |
camera_enabled: bool = False
|
| 56 |
+
camera_feed_enabled: bool = False
|
| 57 |
face_tracking_enabled: bool = False
|
| 58 |
face_tracking_weight: float = 0.65
|
| 59 |
doa_enabled: bool = False
|
reachy_mini_hermes/main.py
CHANGED
|
@@ -50,6 +50,7 @@ class SettingsUpdate(BaseModel):
|
|
| 50 |
motion_enabled: bool | None = None
|
| 51 |
barge_in_enabled: bool | None = None
|
| 52 |
camera_enabled: bool | None = None
|
|
|
|
| 53 |
face_tracking_enabled: bool | None = None
|
| 54 |
face_tracking_weight: float | None = Field(default=None, ge=0, le=1)
|
| 55 |
doa_enabled: bool | None = None
|
|
|
|
| 50 |
motion_enabled: bool | None = None
|
| 51 |
barge_in_enabled: bool | None = None
|
| 52 |
camera_enabled: bool | None = None
|
| 53 |
+
camera_feed_enabled: bool | None = None
|
| 54 |
face_tracking_enabled: bool | None = None
|
| 55 |
face_tracking_weight: float | None = Field(default=None, ge=0, le=1)
|
| 56 |
doa_enabled: bool | None = None
|
reachy_mini_hermes/runtime.py
CHANGED
|
@@ -431,8 +431,13 @@ class HermesVoiceRuntime:
|
|
| 431 |
jpeg = self.camera_snapshot()
|
| 432 |
return {"bytes": len(jpeg), "content_type": "image/jpeg"}
|
| 433 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
def camera_snapshot(self) -> bytes:
|
| 435 |
"""Capture one bounded JPEG for an explicitly authenticated request."""
|
|
|
|
| 436 |
jpeg = self._capture_camera_jpeg()
|
| 437 |
with self._status_lock:
|
| 438 |
self._status.camera_captures += 1
|
|
@@ -446,8 +451,10 @@ class HermesVoiceRuntime:
|
|
| 446 |
raise RuntimeError("Reachy camera capture is unavailable")
|
| 447 |
with self._camera_lock:
|
| 448 |
for _ in range(20):
|
|
|
|
| 449 |
frame = capture()
|
| 450 |
if frame:
|
|
|
|
| 451 |
if not isinstance(frame, (bytes, bytearray, memoryview)):
|
| 452 |
raise RuntimeError("Reachy camera returned an unsupported JPEG payload")
|
| 453 |
jpeg = bytes(frame)
|
|
|
|
| 431 |
jpeg = self.camera_snapshot()
|
| 432 |
return {"bytes": len(jpeg), "content_type": "image/jpeg"}
|
| 433 |
|
| 434 |
+
def _assert_camera_allowed(self) -> None:
|
| 435 |
+
if self._effective_power_mode() in {"meeting", "sleep"} or self._privacy_requested.is_set():
|
| 436 |
+
raise RuntimeError("Camera capture is blocked in the current privacy mode")
|
| 437 |
+
|
| 438 |
def camera_snapshot(self) -> bytes:
|
| 439 |
"""Capture one bounded JPEG for an explicitly authenticated request."""
|
| 440 |
+
self._assert_camera_allowed()
|
| 441 |
jpeg = self._capture_camera_jpeg()
|
| 442 |
with self._status_lock:
|
| 443 |
self._status.camera_captures += 1
|
|
|
|
| 451 |
raise RuntimeError("Reachy camera capture is unavailable")
|
| 452 |
with self._camera_lock:
|
| 453 |
for _ in range(20):
|
| 454 |
+
self._assert_camera_allowed()
|
| 455 |
frame = capture()
|
| 456 |
if frame:
|
| 457 |
+
self._assert_camera_allowed()
|
| 458 |
if not isinstance(frame, (bytes, bytearray, memoryview)):
|
| 459 |
raise RuntimeError("Reachy camera returned an unsupported JPEG payload")
|
| 460 |
jpeg = bytes(frame)
|
reachy_mini_hermes/static/camera.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
"use strict";
|
| 3 |
+
|
| 4 |
+
const byId = (id) => document.getElementById(id);
|
| 5 |
+
const state = {
|
| 6 |
+
api: null,
|
| 7 |
+
session: null,
|
| 8 |
+
stream: null,
|
| 9 |
+
producersListener: null,
|
| 10 |
+
connectionListener: null,
|
| 11 |
+
producerId: null,
|
| 12 |
+
enabled: false,
|
| 13 |
+
powerMode: "unknown",
|
| 14 |
+
requested: false,
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
function setUi(status, message, kind = "") {
|
| 18 |
+
byId("camera-live-status").textContent = status;
|
| 19 |
+
const messageElement = byId("camera-message");
|
| 20 |
+
messageElement.textContent = message;
|
| 21 |
+
messageElement.className = `message ${kind}`;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function updateButtons() {
|
| 25 |
+
const active = Boolean(state.session || state.stream);
|
| 26 |
+
const allowed = state.enabled && state.powerMode === "awake";
|
| 27 |
+
byId("camera-live-start").disabled = active || !allowed;
|
| 28 |
+
byId("camera-live-stop").disabled = !active;
|
| 29 |
+
byId("camera-live-fullscreen").disabled = !state.stream;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function detachStream() {
|
| 33 |
+
const video = byId("reachy-camera-video");
|
| 34 |
+
byId("camera-viewer").classList.remove("live");
|
| 35 |
+
video.pause();
|
| 36 |
+
video.srcObject = null;
|
| 37 |
+
if (state.stream) {
|
| 38 |
+
state.stream.getTracks().forEach((track) => track.stop());
|
| 39 |
+
state.stream = null;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function cleanupConnection() {
|
| 44 |
+
detachStream();
|
| 45 |
+
if (state.session) {
|
| 46 |
+
try {
|
| 47 |
+
state.session.close();
|
| 48 |
+
} catch {
|
| 49 |
+
// The signaling session may already be closed.
|
| 50 |
+
}
|
| 51 |
+
state.session = null;
|
| 52 |
+
}
|
| 53 |
+
if (state.api) {
|
| 54 |
+
try {
|
| 55 |
+
if (state.producersListener) state.api.unregisterProducersListener(state.producersListener);
|
| 56 |
+
if (state.connectionListener) state.api.unregisterConnectionListener(state.connectionListener);
|
| 57 |
+
// GstWebRTCAPI does not expose a public close method. This pinned bundle's
|
| 58 |
+
// channel owns the signaling WebSocket; closing it prevents one idle socket
|
| 59 |
+
// from leaking on every Start/Stop cycle.
|
| 60 |
+
if (typeof state.api._channel?.close === "function") state.api._channel.close();
|
| 61 |
+
} catch {
|
| 62 |
+
// The signaling channel may already be closed.
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
state.api = null;
|
| 66 |
+
state.producersListener = null;
|
| 67 |
+
state.connectionListener = null;
|
| 68 |
+
state.producerId = null;
|
| 69 |
+
updateButtons();
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function stop(message = "Live camera stopped.") {
|
| 73 |
+
const wasActive = state.requested || state.session || state.stream;
|
| 74 |
+
state.requested = false;
|
| 75 |
+
cleanupConnection();
|
| 76 |
+
if (wasActive) setUi("Off", message);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function policyMessage() {
|
| 80 |
+
if (!state.enabled) return "Enable Local live camera in Settings first.";
|
| 81 |
+
if (state.powerMode === "meeting" || state.powerMode === "sleep") {
|
| 82 |
+
return `Camera viewing is blocked in ${state.powerMode}.`;
|
| 83 |
+
}
|
| 84 |
+
if (state.powerMode !== "awake") return "Wake Reachy before starting the live camera.";
|
| 85 |
+
return "Press Start live camera to connect directly to Reachy's local WebRTC feed.";
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function setPolicy({ enabled, powerMode }) {
|
| 89 |
+
state.enabled = Boolean(enabled);
|
| 90 |
+
state.powerMode = powerMode || "unknown";
|
| 91 |
+
if (!state.enabled || state.powerMode !== "awake") {
|
| 92 |
+
if (state.requested || state.session || state.stream) stop(policyMessage());
|
| 93 |
+
else setUi("Off", policyMessage());
|
| 94 |
+
} else if (!state.requested && !state.session && !state.stream) {
|
| 95 |
+
setUi("Ready", policyMessage());
|
| 96 |
+
}
|
| 97 |
+
updateButtons();
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
function isReachyCameraProducer(producer) {
|
| 101 |
+
const name = String(producer?.meta?.name || "").trim().toLowerCase();
|
| 102 |
+
return name === "reachymini" || name === "reachy-mini-camera";
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function attachSession(api, producer) {
|
| 106 |
+
if (!state.requested || state.session || !isReachyCameraProducer(producer)) return;
|
| 107 |
+
const session = api.createConsumerSession(producer.id);
|
| 108 |
+
if (!session) return;
|
| 109 |
+
state.session = session;
|
| 110 |
+
state.producerId = producer.id;
|
| 111 |
+
|
| 112 |
+
session.addEventListener("error", (event) => {
|
| 113 |
+
const message = event?.message || "WebRTC camera stream failed";
|
| 114 |
+
state.requested = false;
|
| 115 |
+
cleanupConnection();
|
| 116 |
+
setUi("Error", message, "error");
|
| 117 |
+
});
|
| 118 |
+
session.addEventListener("closed", () => {
|
| 119 |
+
const requested = state.requested;
|
| 120 |
+
state.session = null;
|
| 121 |
+
detachStream();
|
| 122 |
+
updateButtons();
|
| 123 |
+
if (requested) setUi("Offline", "Camera connection closed. Press Start to reconnect.", "error");
|
| 124 |
+
});
|
| 125 |
+
session.addEventListener("streamsChanged", () => {
|
| 126 |
+
if (!state.requested || !session.streams?.length) return;
|
| 127 |
+
const stream = session.streams[0];
|
| 128 |
+
stream.getAudioTracks().forEach((track) => {
|
| 129 |
+
track.enabled = false;
|
| 130 |
+
});
|
| 131 |
+
state.stream = stream;
|
| 132 |
+
const video = byId("reachy-camera-video");
|
| 133 |
+
byId("camera-viewer").classList.add("live");
|
| 134 |
+
video.srcObject = stream;
|
| 135 |
+
video.play().then(() => {
|
| 136 |
+
setUi("Live", "Local camera connected. No frames are sent to Hermes or OpenAI.", "ok");
|
| 137 |
+
updateButtons();
|
| 138 |
+
}).catch((error) => {
|
| 139 |
+
setUi("Paused", `Camera connected, but playback was blocked: ${String(error)}`, "error");
|
| 140 |
+
});
|
| 141 |
+
});
|
| 142 |
+
session.connect();
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
function start() {
|
| 146 |
+
if (!state.enabled || state.powerMode !== "awake") {
|
| 147 |
+
setUi("Blocked", policyMessage(), "error");
|
| 148 |
+
updateButtons();
|
| 149 |
+
return;
|
| 150 |
+
}
|
| 151 |
+
if (window.location.protocol === "https:") {
|
| 152 |
+
setUi(
|
| 153 |
+
"Unavailable",
|
| 154 |
+
"This robot exposes local WebRTC over ws://. Open its direct LAN HTTP dashboard until a trusted WSS proxy is configured.",
|
| 155 |
+
"error",
|
| 156 |
+
);
|
| 157 |
+
return;
|
| 158 |
+
}
|
| 159 |
+
if (typeof window.RTCPeerConnection !== "function" || !window.GstWebRTCAPI) {
|
| 160 |
+
setUi("Unsupported", "This browser does not provide the required WebRTC camera support.", "error");
|
| 161 |
+
return;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
cleanupConnection();
|
| 165 |
+
state.requested = true;
|
| 166 |
+
setUi("Connecting", "Connecting directly to Reachy's local camera…");
|
| 167 |
+
updateButtons();
|
| 168 |
+
|
| 169 |
+
try {
|
| 170 |
+
const api = new window.GstWebRTCAPI({
|
| 171 |
+
signalingServerUrl: `ws://${window.location.hostname}:8443`,
|
| 172 |
+
reconnectionTimeout: 0,
|
| 173 |
+
meta: { name: "reachy-hermes-ui" },
|
| 174 |
+
// The viewer and robot are on the same LAN; avoid sending ICE queries to public STUN services.
|
| 175 |
+
webrtcConfig: { iceServers: [] },
|
| 176 |
+
});
|
| 177 |
+
state.api = api;
|
| 178 |
+
state.connectionListener = {
|
| 179 |
+
connected: () => setUi("Connecting", "Camera signaling connected; waiting for video…"),
|
| 180 |
+
disconnected: () => {
|
| 181 |
+
if (!state.requested) return;
|
| 182 |
+
state.requested = false;
|
| 183 |
+
cleanupConnection();
|
| 184 |
+
setUi("Offline", "Camera signaling disconnected. Press Start to reconnect.", "error");
|
| 185 |
+
},
|
| 186 |
+
};
|
| 187 |
+
state.producersListener = {
|
| 188 |
+
producerAdded: (producer) => {
|
| 189 |
+
if (!isReachyCameraProducer(producer)) return;
|
| 190 |
+
attachSession(api, producer);
|
| 191 |
+
},
|
| 192 |
+
producerRemoved: (producer) => {
|
| 193 |
+
if (!state.requested || producer?.id !== state.producerId) return;
|
| 194 |
+
state.requested = false;
|
| 195 |
+
cleanupConnection();
|
| 196 |
+
setUi("Offline", "Reachy's camera producer stopped.", "error");
|
| 197 |
+
},
|
| 198 |
+
};
|
| 199 |
+
api.registerConnectionListener(state.connectionListener);
|
| 200 |
+
api.registerProducersListener(state.producersListener);
|
| 201 |
+
} catch (error) {
|
| 202 |
+
state.requested = false;
|
| 203 |
+
cleanupConnection();
|
| 204 |
+
setUi("Error", `Could not connect to Reachy's camera: ${String(error)}`, "error");
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
async function fullscreen() {
|
| 209 |
+
const viewer = byId("camera-viewer");
|
| 210 |
+
try {
|
| 211 |
+
if (document.fullscreenElement) await document.exitFullscreen();
|
| 212 |
+
else await viewer.requestFullscreen();
|
| 213 |
+
} catch (error) {
|
| 214 |
+
setUi("Live", `Fullscreen could not open: ${String(error)}`, "error");
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
byId("camera-live-start").addEventListener("click", start);
|
| 219 |
+
byId("camera-live-stop").addEventListener("click", () => stop());
|
| 220 |
+
byId("camera-live-fullscreen").addEventListener("click", fullscreen);
|
| 221 |
+
window.addEventListener("pagehide", () => stop("Camera stopped because the page closed."));
|
| 222 |
+
document.addEventListener("visibilitychange", () => {
|
| 223 |
+
if (document.hidden) stop("Camera stopped while the app was in the background.");
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
window.ReachyCamera = {
|
| 227 |
+
setPolicy,
|
| 228 |
+
stop,
|
| 229 |
+
isActive: () => Boolean(state.requested || state.session || state.stream),
|
| 230 |
+
};
|
| 231 |
+
setUi("Off", "Reading camera policy…");
|
| 232 |
+
updateButtons();
|
| 233 |
+
})();
|
reachy_mini_hermes/static/gstwebrtc-api.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
reachy_mini_hermes/static/index.html
CHANGED
|
@@ -134,14 +134,27 @@
|
|
| 134 |
<p class="muted action-status">Last action: <strong id="last-robot-action">—</strong><span id="robot-action-error"></span></p>
|
| 135 |
</section>
|
| 136 |
|
| 137 |
-
<section class="card">
|
| 138 |
-
<
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
<button type="button" id="camera-test-button" class="secondary">Capture one local test frame</button>
|
| 143 |
</div>
|
| 144 |
-
<p
|
|
|
|
| 145 |
</section>
|
| 146 |
</div>
|
| 147 |
|
|
@@ -203,6 +216,7 @@
|
|
| 203 |
<label class="toggle-row"><span><strong>Turn toward wake phrase</strong><small>Use one local microphone-array direction estimate after wake</small></span><input id="doa_enabled" name="doa_enabled" type="checkbox" /></label>
|
| 204 |
<label class="toggle-row"><span><strong>Interruption</strong><small>Pipeline wake-phrase interruption or natural Realtime barge-in</small></span><input id="barge_in_enabled" name="barge_in_enabled" type="checkbox" checked /></label>
|
| 205 |
<label class="toggle-row"><span><strong>On-demand camera</strong><small>Realtime captures one frame only for an explicit visual request</small></span><input id="camera_enabled" name="camera_enabled" type="checkbox" /></label>
|
|
|
|
| 206 |
</div>
|
| 207 |
</details>
|
| 208 |
|
|
@@ -236,6 +250,8 @@
|
|
| 236 |
</section>
|
| 237 |
</div>
|
| 238 |
</main>
|
|
|
|
|
|
|
| 239 |
<script src="/static/main.js"></script>
|
| 240 |
</body>
|
| 241 |
</html>
|
|
|
|
| 134 |
<p class="muted action-status">Last action: <strong id="last-robot-action">—</strong><span id="robot-action-error"></span></p>
|
| 135 |
</section>
|
| 136 |
|
| 137 |
+
<section class="card camera-card">
|
| 138 |
+
<div class="section-heading">
|
| 139 |
+
<div><p class="label">Private local viewer</p><h2>Reachy camera</h2></div>
|
| 140 |
+
<span id="camera-live-status" class="badge">Off</span>
|
| 141 |
+
</div>
|
| 142 |
+
<p class="muted">Connect directly to the same local WebRTC camera feed used by Reachy Mini Control. Video remains between this browser and Reachy; it is never sent to Hermes or OpenAI.</p>
|
| 143 |
+
<div id="camera-viewer" class="camera-viewer">
|
| 144 |
+
<video id="reachy-camera-video" autoplay playsinline muted aria-label="Live Reachy camera feed"></video>
|
| 145 |
+
<div class="camera-placeholder" aria-hidden="true">
|
| 146 |
+
<span class="camera-placeholder-icon">●</span>
|
| 147 |
+
<span>Camera off</span>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
<div class="actions camera-actions">
|
| 151 |
+
<button type="button" id="camera-live-start">Start live camera</button>
|
| 152 |
+
<button type="button" id="camera-live-stop" class="secondary" disabled>Stop camera</button>
|
| 153 |
+
<button type="button" id="camera-live-fullscreen" class="secondary" disabled>Fullscreen</button>
|
| 154 |
<button type="button" id="camera-test-button" class="secondary">Capture one local test frame</button>
|
| 155 |
</div>
|
| 156 |
+
<p class="muted compact-note">The live viewer is opt-in, works only while Reachy is Awake, and stops when you leave this tab, background the app, enter Meeting, or enter Sleep.</p>
|
| 157 |
+
<p id="camera-message" class="message" role="status" aria-live="polite"></p>
|
| 158 |
</section>
|
| 159 |
</div>
|
| 160 |
|
|
|
|
| 216 |
<label class="toggle-row"><span><strong>Turn toward wake phrase</strong><small>Use one local microphone-array direction estimate after wake</small></span><input id="doa_enabled" name="doa_enabled" type="checkbox" /></label>
|
| 217 |
<label class="toggle-row"><span><strong>Interruption</strong><small>Pipeline wake-phrase interruption or natural Realtime barge-in</small></span><input id="barge_in_enabled" name="barge_in_enabled" type="checkbox" checked /></label>
|
| 218 |
<label class="toggle-row"><span><strong>On-demand camera</strong><small>Realtime captures one frame only for an explicit visual request</small></span><input id="camera_enabled" name="camera_enabled" type="checkbox" /></label>
|
| 219 |
+
<label class="toggle-row"><span><strong>Local live camera</strong><small>Allow this UI to connect directly to Reachy's local WebRTC video while Awake</small></span><input id="camera_feed_enabled" name="camera_feed_enabled" type="checkbox" /></label>
|
| 220 |
</div>
|
| 221 |
</details>
|
| 222 |
|
|
|
|
| 250 |
</section>
|
| 251 |
</div>
|
| 252 |
</main>
|
| 253 |
+
<script src="/static/gstwebrtc-api.js"></script>
|
| 254 |
+
<script src="/static/camera.js"></script>
|
| 255 |
<script src="/static/main.js"></script>
|
| 256 |
</body>
|
| 257 |
</html>
|
reachy_mini_hermes/static/main.js
CHANGED
|
@@ -2,7 +2,7 @@ const $ = (id) => document.getElementById(id);
|
|
| 2 |
const fields = [
|
| 3 |
"bridge_url", "api_key", "model", "conversation_mode", "language", "stt_provider", "stt_model",
|
| 4 |
"tts_provider", "tts_model", "tts_voice", "continuous_conversation",
|
| 5 |
-
"motion_enabled", "barge_in_enabled", "camera_enabled", "face_tracking_enabled", "face_tracking_weight",
|
| 6 |
"doa_enabled", "robot_tools_enabled", "realtime_model", "realtime_voice", "realtime_reasoning_effort",
|
| 7 |
"end_silence_seconds", "max_utterance_seconds", "vad_min_rms", "vad_noise_multiplier",
|
| 8 |
"wake_keyword_threshold", "wake_keyword_score",
|
|
@@ -23,6 +23,9 @@ function activateTab(name, focus = false, recordHistory = false) {
|
|
| 23 |
button.setAttribute("aria-selected", String(active));
|
| 24 |
button.tabIndex = active ? 0 : -1;
|
| 25 |
});
|
|
|
|
|
|
|
|
|
|
| 26 |
document.querySelectorAll("[data-panel]").forEach((panel) => {
|
| 27 |
panel.hidden = panel.dataset.panel !== target.dataset.tab;
|
| 28 |
});
|
|
@@ -191,6 +194,10 @@ function updateStatus(payload) {
|
|
| 191 |
if (["error", "configuration_error"].includes(state)) dot.classList.add("error");
|
| 192 |
fillConfig(payload.config);
|
| 193 |
currentConfig = payload.config || currentConfig;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
}
|
| 195 |
|
| 196 |
function modelLabel(model) {
|
|
@@ -296,6 +303,9 @@ async function refreshStatus() {
|
|
| 296 |
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 297 |
updateStatus(await response.json());
|
| 298 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
| 299 |
$("runtime-state").textContent = "Disconnected";
|
| 300 |
$("runtime-detail").textContent = String(error);
|
| 301 |
$("status-dot").className = "status-dot error";
|
|
@@ -446,6 +456,9 @@ $("robot-stop-button").addEventListener("click", async () => {
|
|
| 446 |
|
| 447 |
async function setPowerMode(mode, durationMinutes = 60) {
|
| 448 |
const message = $("power-message");
|
|
|
|
|
|
|
|
|
|
| 449 |
message.textContent = `Switching to ${mode}…`;
|
| 450 |
try {
|
| 451 |
const response = await fetch("/api/power", {
|
|
@@ -470,6 +483,9 @@ document.querySelectorAll("[data-power]").forEach((button) => {
|
|
| 470 |
|
| 471 |
$("app-off-button").addEventListener("click", async () => {
|
| 472 |
if (!window.confirm("Stop the voice app? Restart it later from Reachy Control.")) return;
|
|
|
|
|
|
|
|
|
|
| 473 |
await fetch("/api/app-off", {
|
| 474 |
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirm: "off" }),
|
| 475 |
});
|
|
|
|
| 2 |
const fields = [
|
| 3 |
"bridge_url", "api_key", "model", "conversation_mode", "language", "stt_provider", "stt_model",
|
| 4 |
"tts_provider", "tts_model", "tts_voice", "continuous_conversation",
|
| 5 |
+
"motion_enabled", "barge_in_enabled", "camera_enabled", "camera_feed_enabled", "face_tracking_enabled", "face_tracking_weight",
|
| 6 |
"doa_enabled", "robot_tools_enabled", "realtime_model", "realtime_voice", "realtime_reasoning_effort",
|
| 7 |
"end_silence_seconds", "max_utterance_seconds", "vad_min_rms", "vad_noise_multiplier",
|
| 8 |
"wake_keyword_threshold", "wake_keyword_score",
|
|
|
|
| 23 |
button.setAttribute("aria-selected", String(active));
|
| 24 |
button.tabIndex = active ? 0 : -1;
|
| 25 |
});
|
| 26 |
+
if (target.dataset.tab !== "robot" && window.ReachyCamera?.isActive()) {
|
| 27 |
+
window.ReachyCamera.stop("Camera stopped when leaving the Robot tab.");
|
| 28 |
+
}
|
| 29 |
document.querySelectorAll("[data-panel]").forEach((panel) => {
|
| 30 |
panel.hidden = panel.dataset.panel !== target.dataset.tab;
|
| 31 |
});
|
|
|
|
| 194 |
if (["error", "configuration_error"].includes(state)) dot.classList.add("error");
|
| 195 |
fillConfig(payload.config);
|
| 196 |
currentConfig = payload.config || currentConfig;
|
| 197 |
+
window.ReachyCamera?.setPolicy({
|
| 198 |
+
enabled: Boolean(payload.config?.camera_feed_enabled),
|
| 199 |
+
powerMode,
|
| 200 |
+
});
|
| 201 |
}
|
| 202 |
|
| 203 |
function modelLabel(model) {
|
|
|
|
| 303 |
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 304 |
updateStatus(await response.json());
|
| 305 |
} catch (error) {
|
| 306 |
+
if (window.ReachyCamera?.isActive()) {
|
| 307 |
+
window.ReachyCamera.stop("Camera stopped because Hermes status is unavailable.");
|
| 308 |
+
}
|
| 309 |
$("runtime-state").textContent = "Disconnected";
|
| 310 |
$("runtime-detail").textContent = String(error);
|
| 311 |
$("status-dot").className = "status-dot error";
|
|
|
|
| 456 |
|
| 457 |
async function setPowerMode(mode, durationMinutes = 60) {
|
| 458 |
const message = $("power-message");
|
| 459 |
+
if (mode !== "awake" && window.ReachyCamera?.isActive()) {
|
| 460 |
+
window.ReachyCamera.stop(`Camera stopped before switching to ${mode}.`);
|
| 461 |
+
}
|
| 462 |
message.textContent = `Switching to ${mode}…`;
|
| 463 |
try {
|
| 464 |
const response = await fetch("/api/power", {
|
|
|
|
| 483 |
|
| 484 |
$("app-off-button").addEventListener("click", async () => {
|
| 485 |
if (!window.confirm("Stop the voice app? Restart it later from Reachy Control.")) return;
|
| 486 |
+
if (window.ReachyCamera?.isActive()) {
|
| 487 |
+
window.ReachyCamera.stop("Camera stopped before stopping the voice app.");
|
| 488 |
+
}
|
| 489 |
await fetch("/api/app-off", {
|
| 490 |
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirm: "off" }),
|
| 491 |
});
|
reachy_mini_hermes/static/service-worker.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
-
const CACHE_NAME = "reachy-hermes-shell-
|
| 2 |
const APP_SHELL = [
|
| 3 |
"/",
|
| 4 |
"/#dashboard",
|
| 5 |
"/manifest.webmanifest",
|
| 6 |
"/static/style.css",
|
|
|
|
|
|
|
| 7 |
"/static/main.js",
|
| 8 |
"/static/icon-192.png",
|
| 9 |
"/static/icon-512.png",
|
|
|
|
| 1 |
+
const CACHE_NAME = "reachy-hermes-shell-v4";
|
| 2 |
const APP_SHELL = [
|
| 3 |
"/",
|
| 4 |
"/#dashboard",
|
| 5 |
"/manifest.webmanifest",
|
| 6 |
"/static/style.css",
|
| 7 |
+
"/static/gstwebrtc-api.js",
|
| 8 |
+
"/static/camera.js",
|
| 9 |
"/static/main.js",
|
| 10 |
"/static/icon-192.png",
|
| 11 |
"/static/icon-512.png",
|
reachy_mini_hermes/static/style.css
CHANGED
|
@@ -79,6 +79,16 @@ button:disabled, select:disabled { opacity: .46; cursor: not-allowed; transform:
|
|
| 79 |
.readiness { margin: 12px 0 0; padding: 10px 12px; border-left: 3px solid var(--accent2); border-radius: 8px; color: #d8d2ff; background: #7b61ff16; font-size: .88rem; font-weight: 700; }
|
| 80 |
.compact-note { margin: -4px 0 12px; font-size: .82rem; }
|
| 81 |
.danger-zone { border-color: #ff5d6c55; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
.install-card { border-color: #7b61ff66; background: linear-gradient(135deg, #17142aee, #191821e8); }
|
| 83 |
.install-card .actions { margin-top: 16px; }
|
| 84 |
.install-card details { margin-top: 14px; }
|
|
|
|
| 79 |
.readiness { margin: 12px 0 0; padding: 10px 12px; border-left: 3px solid var(--accent2); border-radius: 8px; color: #d8d2ff; background: #7b61ff16; font-size: .88rem; font-weight: 700; }
|
| 80 |
.compact-note { margin: -4px 0 12px; font-size: .82rem; }
|
| 81 |
.danger-zone { border-color: #ff5d6c55; }
|
| 82 |
+
.camera-card { overflow: hidden; }
|
| 83 |
+
.camera-viewer { position: relative; aspect-ratio: 16 / 9; margin-top: 18px; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: #020203; }
|
| 84 |
+
.camera-viewer video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; opacity: 0; transition: opacity .2s ease; }
|
| 85 |
+
.camera-viewer.live video { opacity: 1; }
|
| 86 |
+
.camera-placeholder { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .12em; font-size: .72rem; }
|
| 87 |
+
.camera-viewer.live .camera-placeholder { display: none; }
|
| 88 |
+
.camera-placeholder-icon { width: 46px; height: 46px; display: grid; place-content: center; border: 1px solid var(--line); border-radius: 50%; color: #7b61ff; box-shadow: 0 0 28px #7b61ff44; }
|
| 89 |
+
.camera-actions { margin-top: 16px; }
|
| 90 |
+
.camera-viewer:fullscreen { border: 0; border-radius: 0; background: #000; }
|
| 91 |
+
.camera-viewer:fullscreen video { object-fit: contain; }
|
| 92 |
.install-card { border-color: #7b61ff66; background: linear-gradient(135deg, #17142aee, #191821e8); }
|
| 93 |
.install-card .actions { margin-top: 16px; }
|
| 94 |
.install-card details { margin-top: 14px; }
|
tests/test_camera_ui.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 6 |
+
STATIC = ROOT / "reachy_mini_hermes" / "static"
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_robot_tab_contains_explicit_local_live_camera_controls() -> None:
|
| 10 |
+
html = (STATIC / "index.html").read_text()
|
| 11 |
+
|
| 12 |
+
assert 'id="reachy-camera-video"' in html
|
| 13 |
+
assert 'id="camera-live-start"' in html
|
| 14 |
+
assert 'id="camera-live-stop"' in html
|
| 15 |
+
assert 'id="camera-live-fullscreen"' in html
|
| 16 |
+
assert 'id="camera_feed_enabled"' in html
|
| 17 |
+
assert "never sent to Hermes or OpenAI" in html
|
| 18 |
+
assert html.index('/static/gstwebrtc-api.js') < html.index('/static/camera.js')
|
| 19 |
+
assert html.index('/static/camera.js') < html.index('/static/main.js')
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_camera_viewer_uses_local_webrtc_without_public_stun_or_audio() -> None:
|
| 23 |
+
camera = (STATIC / "camera.js").read_text()
|
| 24 |
+
|
| 25 |
+
assert 'signalingServerUrl: `ws://${window.location.hostname}:8443`' in camera
|
| 26 |
+
assert "webrtcConfig: { iceServers: [] }" in camera
|
| 27 |
+
assert "stun:" not in camera
|
| 28 |
+
assert "track.enabled = false" in camera
|
| 29 |
+
assert 'state.powerMode !== "awake"' in camera
|
| 30 |
+
assert 'window.addEventListener("pagehide"' in camera
|
| 31 |
+
assert 'document.addEventListener("visibilitychange"' in camera
|
| 32 |
+
assert 'state.api._channel?.close' in camera
|
| 33 |
+
assert 'name === "reachymini"' in camera
|
| 34 |
+
assert 'producer?.id !== state.producerId' in camera
|
| 35 |
+
assert "No frames are sent to Hermes or OpenAI" in camera
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_live_camera_is_opt_in_and_stops_for_privacy_transitions() -> None:
|
| 39 |
+
main = (STATIC / "main.js").read_text()
|
| 40 |
+
config = (ROOT / "reachy_mini_hermes" / "config.py").read_text()
|
| 41 |
+
notices = (ROOT / "THIRD_PARTY_NOTICES.md").read_text()
|
| 42 |
+
bundled_api = (STATIC / "gstwebrtc-api.js").read_text()[:1000]
|
| 43 |
+
|
| 44 |
+
assert "camera_feed_enabled: bool = False" in config
|
| 45 |
+
assert "enabled: Boolean(payload.config?.camera_feed_enabled)" in main
|
| 46 |
+
assert 'mode !== "awake" && window.ReachyCamera?.isActive()' in main
|
| 47 |
+
assert "Camera stopped because Hermes status is unavailable" in main
|
| 48 |
+
assert "Camera stopped before stopping the voice app" in main
|
| 49 |
+
assert "Camera stopped when leaving the Robot tab" in main
|
| 50 |
+
assert "MPL-2.0" in bundled_api
|
| 51 |
+
assert "BSD 3-Clause" in bundled_api
|
| 52 |
+
assert "Mozilla Public License 2.0" in notices
|
| 53 |
+
assert "Redistribution and use in source and binary forms" in notices
|
| 54 |
+
assert 'THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"' in notices
|
tests/test_config.py
CHANGED
|
@@ -39,6 +39,7 @@ def test_embodiment_features_are_explicit_and_privacy_bounded_by_default() -> No
|
|
| 39 |
config = AppConfig()
|
| 40 |
|
| 41 |
assert config.face_tracking_enabled is False
|
|
|
|
| 42 |
assert config.face_tracking_weight == 0.65
|
| 43 |
assert config.doa_enabled is False
|
| 44 |
assert config.robot_tools_enabled is True
|
|
|
|
| 39 |
config = AppConfig()
|
| 40 |
|
| 41 |
assert config.face_tracking_enabled is False
|
| 42 |
+
assert config.camera_feed_enabled is False
|
| 43 |
assert config.face_tracking_weight == 0.65
|
| 44 |
assert config.doa_enabled is False
|
| 45 |
assert config.robot_tools_enabled is True
|
tests/test_pwa.py
CHANGED
|
@@ -37,6 +37,8 @@ def test_service_worker_caches_only_the_app_shell_and_bypasses_api() -> None:
|
|
| 37 |
assert 'request.method !== "GET"' in worker
|
| 38 |
assert 'caches.match("/")' in worker
|
| 39 |
assert "/static/main.js" in worker
|
|
|
|
|
|
|
| 40 |
assert "/static/style.css" in worker
|
| 41 |
|
| 42 |
|
|
|
|
| 37 |
assert 'request.method !== "GET"' in worker
|
| 38 |
assert 'caches.match("/")' in worker
|
| 39 |
assert "/static/main.js" in worker
|
| 40 |
+
assert "/static/camera.js" in worker
|
| 41 |
+
assert "/static/gstwebrtc-api.js" in worker
|
| 42 |
assert "/static/style.css" in worker
|
| 43 |
|
| 44 |
|
tests/test_runtime_power.py
CHANGED
|
@@ -357,6 +357,36 @@ def test_camera_test_captures_locally_without_returning_image() -> None:
|
|
| 357 |
assert runtime.status()["camera_captures"] == 1
|
| 358 |
|
| 359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
def test_camera_capture_requires_completed_output_item() -> None:
|
| 361 |
completed: dict[str, object] = {
|
| 362 |
"item": {
|
|
|
|
| 357 |
assert runtime.status()["camera_captures"] == 1
|
| 358 |
|
| 359 |
|
| 360 |
+
@pytest.mark.parametrize("mode", ["meeting", "sleep"])
|
| 361 |
+
def test_local_camera_capture_is_blocked_in_privacy_modes(mode: str) -> None:
|
| 362 |
+
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
|
| 363 |
+
runtime._power_mode = mode
|
| 364 |
+
runtime._meeting_until = float("inf") if mode == "meeting" else 0.0
|
| 365 |
+
runtime._privacy_requested.set()
|
| 366 |
+
|
| 367 |
+
with pytest.raises(RuntimeError, match="privacy"):
|
| 368 |
+
runtime.camera_snapshot()
|
| 369 |
+
|
| 370 |
+
assert runtime.status()["camera_captures"] == 0
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def test_camera_capture_rechecks_privacy_after_frame_arrives() -> None:
|
| 374 |
+
robot = FakeRobot()
|
| 375 |
+
runtime = HermesVoiceRuntime(robot, threading.Event())
|
| 376 |
+
|
| 377 |
+
def enter_sleep_during_capture() -> bytes:
|
| 378 |
+
runtime._power_mode = "sleep"
|
| 379 |
+
runtime._privacy_requested.set()
|
| 380 |
+
return b"late-frame"
|
| 381 |
+
|
| 382 |
+
robot.media.get_frame_jpeg = enter_sleep_during_capture # type: ignore[method-assign]
|
| 383 |
+
|
| 384 |
+
with pytest.raises(RuntimeError, match="privacy"):
|
| 385 |
+
runtime.camera_snapshot()
|
| 386 |
+
|
| 387 |
+
assert runtime.status()["camera_captures"] == 0
|
| 388 |
+
|
| 389 |
+
|
| 390 |
def test_camera_capture_requires_completed_output_item() -> None:
|
| 391 |
completed: dict[str, object] = {
|
| 392 |
"item": {
|