Timbo89 commited on
Commit
a3af295
·
verified ·
1 Parent(s): 94a99cd

Add private local Reachy camera viewer

Browse files
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 64 automated tests pass.
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 on-demand camera: one JPEG is captured only when a visual request needs it.
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 a local camera diagnostic.
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. When enabled in Realtime mode, the model can request a single fresh frame for prompts such as “What do you see?” or “Look at this object. Frames are not streamed continuously and the local camera test 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,7 +297,7 @@ uv build --wheel
297
  reachy-mini-app-assistant check .
298
  ```
299
 
300
- Current automated suite: **64 tests**.
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>64</strong> automated tests</span>
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>Look only when asked.</h3>
114
- <p>For an explicit visual request, Reachy captures one fresh, bounded JPEG and adds it to the active Realtime conversation. No continuous cloud video. Meeting and Sleep block camera capture.</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
- <p class="label">Private diagnostic</p>
139
- <h2>Camera</h2>
140
- <p class="muted">Capture one frame locally to verify the camera. The image is not returned to this page or sent to Hermes.</p>
141
- <div class="actions left-actions">
 
 
 
 
 
 
 
 
 
 
 
 
142
  <button type="button" id="camera-test-button" class="secondary">Capture one local test frame</button>
143
  </div>
144
- <p id="camera-message" class="message" role="status"></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-v1";
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": {