Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions homebody/static/camera.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
producersListener: null,
connectionListener: null,
producerId: null,
playbackGeneration: 0,
enabled: false,
controlsEnabled: false,
controlsHandedness: "right",
Expand Down Expand Up @@ -184,6 +185,7 @@
}

function detachStream() {
state.playbackGeneration += 1;
void endControlGesture("Controls stopped with the camera feed");
const video = byId("reachy-camera-video");
stopVisionOverlay();
Expand All @@ -200,8 +202,9 @@
function cleanupConnection() {
detachStream();
if (state.session) {
try { state.session.close(); } catch { /* The signaling session may already be closed. */ }
state.session = null;
const session = state.session;
state.session = null; // Invalidate callbacks before close can dispatch events.
try { session.close(); } catch { /* The signaling session may already be closed. */ }
}
if (state.api) {
try {
Expand Down Expand Up @@ -255,37 +258,53 @@
}

function attachSession(api, producer) {
if (!state.requested || state.session || !isReachyCameraProducer(producer)) return;
if (!state.requested || state.api !== api || state.session || !isReachyCameraProducer(producer)) return;
const session = api.createConsumerSession(producer.id);
if (!session) return;
state.session = session;
state.producerId = producer.id;
session.addEventListener("error", (event) => {
if (state.session !== session) return;
const message = event?.message || "WebRTC camera stream failed";
state.requested = false;
cleanupConnection();
setUi("Error", message, "error");
});
session.addEventListener("closed", () => {
if (state.session !== session) return;
const requested = state.requested;
state.session = null;
detachStream();
updateButtons();
state.requested = false;
cleanupConnection();
if (requested) setUi("Offline", "Camera connection closed. Press Start to reconnect.", "error");
});
session.addEventListener("streamsChanged", () => {
if (!state.requested || !session.streams?.length) return;
if (!state.requested || state.session !== session || !session.streams?.length) return;
const stream = session.streams[0];
// GStreamer may announce the same stream more than once. Reassignment
// interrupts the existing play request in Chromium.
if (state.stream === stream) return;
if (state.stream) detachStream();
const generation = ++state.playbackGeneration;
const isCurrent = () => state.requested && state.session === session
&& state.stream === stream && state.playbackGeneration === generation;
stream.getAudioTracks().forEach((track) => { track.enabled = false; });
state.stream = stream;
const video = byId("reachy-camera-video");
byId("camera-viewer").classList.add("live");
startVisionOverlay();
video.srcObject = stream;
updateButtons(); // Stop must remain available while play is pending.
video.play().then(() => {
if (!isCurrent()) return;
setUi("Live", "Local camera connected. No frames are sent to Hermes or OpenAI.", "ok");
updateButtons();
}).catch((error) => setUi("Paused", `Camera connected, but playback was blocked: ${String(error)}`, "error"));
}).catch((error) => {
// Stop/privacy/replacement can intentionally abort a pending play.
// Never let its late result overwrite the current connection or policy.
if (!isCurrent()) return;
const reason = error?.name === "AbortError" ? "interrupted" : "blocked";
setUi("Paused", `Camera connected, but playback was ${reason}: ${String(error)}`, "error");
});
});
session.connect();
}
Expand Down Expand Up @@ -314,9 +333,13 @@
});
state.api = api;
state.connectionListener = {
connected: () => setUi("Connecting", "Camera signaling connected; waiting for video…"),
connected: () => {
if (state.api === api && state.requested && !state.stream) {
setUi("Connecting", "Camera signaling connected; waiting for video…");
}
},
disconnected: () => {
if (!state.requested) return;
if (!state.requested || state.api !== api) return;
state.requested = false;
cleanupConnection();
setUi("Offline", "Camera signaling disconnected. Press Start to reconnect.", "error");
Expand All @@ -325,7 +348,7 @@
state.producersListener = {
producerAdded: (producer) => { if (isReachyCameraProducer(producer)) attachSession(api, producer); },
producerRemoved: (producer) => {
if (!state.requested || producer?.id !== state.producerId) return;
if (!state.requested || state.api !== api || producer?.id !== state.producerId) return;
state.requested = false;
cleanupConnection();
setUi("Offline", "Reachy's camera producer stopped.", "error");
Expand Down
16 changes: 8 additions & 8 deletions homebody/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/static/icon-192.png" sizes="192x192" />
<link rel="apple-touch-icon" href="/static/icon-192.png" />
<link rel="stylesheet" href="/static/style.css?v=71">
<link rel="stylesheet" href="/static/style.css?v=72">
</head>
<body>
<section id="owner-pair-panel" class="owner-access" aria-labelledby="owner-pair-title">
Expand Down Expand Up @@ -976,13 +976,13 @@ <h2>App and host lifecycle</h2>
</main>
<span id="notification-status" class="sr-only" role="status" aria-atomic="true"></span>
<span id="notification-alert" class="sr-only" role="alert" aria-atomic="true"></span>
<script src="/static/notifications.js?v=71"></script>
<script src="/static/owner.js?v=71"></script>
<script src="/static/notifications.js?v=72"></script>
<script src="/static/owner.js?v=72"></script>
<script src="/static/gstwebrtc-api.js"></script>
<script src="/static/camera.js?v=71"></script>
<script src="/static/agent-connection.js?v=71"></script>
<script src="/static/native-workspace.js?v=71"></script>
<script src="/static/workspace.js?v=71"></script>
<script src="/static/main.js?v=71"></script>
<script src="/static/camera.js?v=72"></script>
<script src="/static/agent-connection.js?v=72"></script>
<script src="/static/native-workspace.js?v=72"></script>
<script src="/static/workspace.js?v=72"></script>
<script src="/static/main.js?v=72"></script>
</body>
</html>
18 changes: 9 additions & 9 deletions homebody/static/service-worker.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
const CACHE_NAME = "homebody-shell-v71";
const CACHE_NAME = "homebody-shell-v72";
const APP_SHELL = [
"/",
"/manifest.webmanifest",
"/static/style.css?v=71",
"/static/style.css?v=72",
"/static/gstwebrtc-api.js",
"/static/camera.js?v=71",
"/static/main.js?v=71",
"/static/workspace.js?v=71",
"/static/native-workspace.js?v=71",
"/static/agent-connection.js?v=71",
"/static/owner.js?v=71",
"/static/notifications.js?v=71",
"/static/camera.js?v=72",
"/static/main.js?v=72",
"/static/workspace.js?v=72",
"/static/native-workspace.js?v=72",
"/static/agent-connection.js?v=72",
"/static/owner.js?v=72",
"/static/notifications.js?v=72",
"/static/icon-192.png",
"/static/icon-512.png",
"/static/icon-maskable-512.png",
Expand Down
51 changes: 51 additions & 0 deletions tests/camera_ui.browser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ async function setup(t, { width = 900, height = 700, fallback = false } = {}) {
}
createConsumerSession() {
const session = new EventTarget();
(window.cameraSessions ||= []).push(session);
session.streams = [stream];
session.connect = () => session.dispatchEvent(new Event('streamsChanged'));
session.close = () => {};
Expand All @@ -62,6 +63,56 @@ async function setup(t, { width = 900, height = 700, fallback = false } = {}) {
return page;
}

for (const outcome of ['reject', 'resolve']) {
test(`late play ${outcome} cannot overwrite Stop or a new camera connection`, async t => {
const page = await setup(t);
await page.evaluate(() => {
window.ReachyCamera.stop();
document.querySelector('#reachy-camera-video').play = () => new Promise((resolve, reject) => { window.pendingPlay = { resolve, reject }; });
});
await page.locator('#camera-live-start').click();
await page.evaluate(() => {
window.oldSession = window.cameraSessions.at(-1);
window.ReachyCamera.stop('Stopped for privacy.');
});
await page.evaluate(outcome => {
if (outcome === 'reject') window.pendingPlay.reject(new DOMException('Interrupted by pause', 'AbortError'));
else window.pendingPlay.resolve();
}, outcome);
assert.equal(await page.locator('#camera-live-status').textContent(), 'Off');
assert.equal(await page.locator('#camera-message').textContent(), 'Stopped for privacy.');
await page.evaluate(() => { document.querySelector('#reachy-camera-video').play = () => Promise.resolve(); });
await page.locator('#camera-live-start').click();
await page.waitForFunction(() => document.querySelector('#camera-live-status').textContent === 'Live');
await page.evaluate(() => {
window.oldSession.dispatchEvent(new Event('closed'));
window.oldSession.dispatchEvent(new Event('error'));
window.oldSession.dispatchEvent(new Event('streamsChanged'));
});
assert.equal(await page.locator('#camera-live-status').textContent(), 'Live');
assert.equal(await page.locator('#camera-live-fullscreen').isEnabled(), true);
});
}

test('duplicate stream events do not restart pending playback; genuine autoplay denial remains visible', async t => {
const page = await setup(t);
await page.evaluate(() => {
window.ReachyCamera.stop();
window.playCalls = 0;
document.querySelector('#reachy-camera-video').play = () => {
window.playCalls++;
return new Promise((resolve, reject) => { window.pendingPlay = { resolve, reject }; });
};
});
await page.locator('#camera-live-start').click();
await page.evaluate(() => window.cameraSessions.at(-1).dispatchEvent(new Event('streamsChanged')));
assert.equal(await page.evaluate(() => window.playCalls), 1);
assert.equal(await page.locator('#camera-live-stop').isEnabled(), true);
await page.evaluate(() => window.pendingPlay.reject(new DOMException('Autoplay denied', 'NotAllowedError')));
assert.equal(await page.locator('#camera-live-status').textContent(), 'Paused');
assert.match(await page.locator('#camera-message').textContent(), /NotAllowedError/);
});

for (const fallback of [false, true]) {
for (const viewport of [{ width: 900, height: 700 }, { width: 390, height: 844 }, { width: 844, height: 390 }]) {
test(`${fallback ? 'fallback' : 'native'} fullscreen ${viewport.width}x${viewport.height}: disabled guidance, opt-in, release and exit`, async t => {
Expand Down
2 changes: 1 addition & 1 deletion tests/test_announcements.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,4 +437,4 @@ def test_announcement_ui_exposes_full_tts_controls_and_private_routes() -> None:
assert 'role="alert"' in html
assert "Voice only · do not change power state" in html
assert "homebody-shell-v" in worker
assert "/static/main.js?v=71" in html
assert "/static/main.js?v=72" in html
4 changes: 2 additions & 2 deletions tests/test_home_assistant_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ def test_home_assistant_ui_exposes_nested_opt_ins_and_v44_assets() -> None:
assert "toggleHomeAssistantOptions" in script
assert "homebody-shell-v" in worker
for asset in ("style.css", "camera.js", "main.js"):
assert f"/static/{asset}?v=71" in html
assert f'"/static/{asset}?v=71"' in worker
assert f"/static/{asset}?v=72" in html
assert f'"/static/{asset}?v=72"' in worker


def test_runtime_provider_maps_native_daemon_telemetry_without_enabling_controls() -> None:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_kids_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -616,8 +616,8 @@ def test_kids_static_assets_advance_pwa_cache_together() -> None:
worker = (STATIC / "service-worker.js").read_text(encoding="utf-8")
assert "homebody-shell-v" in worker
for asset in ("style.css", "camera.js", "main.js"):
assert f"/static/{asset}?v=71" in html
assert f'"/static/{asset}?v=71"' in worker
assert f"/static/{asset}?v=72" in html
assert f'"/static/{asset}?v=72"' in worker


def test_runtime_reports_kids_session_start_replacement_and_end_to_bridge() -> None:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_motor_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,6 @@ def test_motor_ui_serializes_power_transitions_and_reports_confirmed_state() ->
assert "position: fixed" in style
assert 'data-action-busy="false"' in html
assert 'homebody-shell-v' in worker
assert '/static/style.css?v=71' in html
assert '/static/camera.js?v=71' in html
assert '/static/main.js?v=71' in html
assert '/static/style.css?v=72' in html
assert '/static/camera.js?v=72' in html
assert '/static/main.js?v=72' in html
Loading