From 69beb43e1358f4987cf625fdd2da80762bd25aad Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Mon, 2 Nov 2026 16:37:55 +1000 Subject: [PATCH] fix(vision): stop background captures in Kid Mode and at the source Every idle face_detected without a cached description asked the robot for a photo. The 120 s cooldown in dotty-behaviour only ran after the upload, and its "no one in view" reply left the cache empty, so the next detection captured again: about 160 captures in an hour, while lowest free SRAM fell to 1.2 KB. Check the cooldown before the MCP call, per device so a reconnect doesn't reset it. Kid Mode says the camera is off, but room_view and the idle photographer still captured. Gate both with the fail-closed camera policy. Security mode's photos are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../consumers/idle_photographer.py | 10 ++++- dotty-behaviour/main.py | 1 + .../tests/test_consumer_idle_photographer.py | 16 +++++++- receiveAudioHandle.py | 15 ++++++- tests/test_voice_camera_policy.py | 39 +++++++++++++++++++ 5 files changed, 77 insertions(+), 4 deletions(-) diff --git a/dotty-behaviour/consumers/idle_photographer.py b/dotty-behaviour/consumers/idle_photographer.py index fe6d1115..eb4fac58 100644 --- a/dotty-behaviour/consumers/idle_photographer.py +++ b/dotty-behaviour/consumers/idle_photographer.py @@ -4,8 +4,9 @@ 1. Sleep a jittered SLEEP_MIN..SLEEP_MAX seconds. 2. Pick a target device (most-recent vision_cache device, else first known). - 3. Skip if device is not in idle state, listening, or has a face - present (room-view path covers those scenarios). + 3. Skip if Kid Mode denies the camera, or the device is not in idle + state, listening, or has a face present (room-view path covers + those scenarios). 4. Snapshot the current vision_cache wall_ts so we can detect freshness later. 5. Dispatch take_photo and wait RESULT_WAIT seconds for the firmware @@ -24,6 +25,7 @@ import logging import random import time +from typing import Callable from dispatch import XiaozhiAdminClient from logs import NdjsonWriter @@ -44,6 +46,7 @@ def __init__( result_wait_sec: float, notable_jaccard: float, question: str, + camera_denied: Callable[[], bool], ) -> None: self._state = state self._xiaozhi = xiaozhi @@ -53,6 +56,7 @@ def __init__( self._result_wait_sec = result_wait_sec self._notable_jaccard = notable_jaccard self._question = question + self._camera_denied = camera_denied async def run(self) -> None: log.info( @@ -75,6 +79,8 @@ async def run(self) -> None: log.exception("idle photographer crashed") async def _one_cycle(self) -> None: + if self._camera_denied(): + return device_id = self._state.pick_idle_device() if not device_id: return diff --git a/dotty-behaviour/main.py b/dotty-behaviour/main.py index 0330c094..ce8c03ea 100644 --- a/dotty-behaviour/main.py +++ b/dotty-behaviour/main.py @@ -237,6 +237,7 @@ async def lifespan(app: FastAPI): result_wait_sec=config.IDLE_PHOTOGRAPHER_RESULT_WAIT_SEC, notable_jaccard=config.IDLE_PHOTOGRAPHER_NOTABLE_JACCARD, question=config.IDLE_WANDER_PROMPT, + camera_denied=config.kid_mode_on, ) ) else: diff --git a/dotty-behaviour/tests/test_consumer_idle_photographer.py b/dotty-behaviour/tests/test_consumer_idle_photographer.py index 9ffe05a8..16e46320 100644 --- a/dotty-behaviour/tests/test_consumer_idle_photographer.py +++ b/dotty-behaviour/tests/test_consumer_idle_photographer.py @@ -20,7 +20,7 @@ _UTC = ZoneInfo("UTC") -def _consumer(td: Path, state, xiaozhi, *, jaccard=0.7) -> IdlePhotographer: +def _consumer(td: Path, state, xiaozhi, *, jaccard=0.7, camera_denied=lambda: False) -> IdlePhotographer: return IdlePhotographer( state, xiaozhi, NdjsonWriter(td, "perception", _UTC), @@ -29,6 +29,7 @@ def _consumer(td: Path, state, xiaozhi, *, jaccard=0.7) -> IdlePhotographer: result_wait_sec=0.05, notable_jaccard=jaccard, question="Describe what you see.", + camera_denied=camera_denied, ) @@ -189,3 +190,16 @@ async def body() -> None: await _drive(consumer, body) asyncio.run(go()) + + +def test_kid_mode_takes_no_photo(): + async def scenario(): + with tempfile.TemporaryDirectory() as td: + state = PerceptionState() + state.state["dev"] = {"current_state": "idle"} + xiaozhi = FakeXiaozhi() + consumer = _consumer(Path(td), state, xiaozhi, camera_denied=lambda: True) + await consumer._one_cycle() + assert xiaozhi.take_photo_calls == [] + + asyncio.run(scenario()) diff --git a/receiveAudioHandle.py b/receiveAudioHandle.py index 1236b816..a204348b 100644 --- a/receiveAudioHandle.py +++ b/receiveAudioHandle.py @@ -483,6 +483,13 @@ async def _handle_vision(conn: "ConnectionHandler", text: str) -> str | None: # constant of the same name. _ROOM_VIEW_NO_PERSON = "no one in view" +# dotty-behaviour's 120 s cooldown only runs after the robot has captured and +# uploaded the frame, and its "no one in view" reply leaves the cache empty, so +# every later face_detected captured again (about 270 an hour). Check here +# first; the margin stops a capture landing just inside that window. +_ROOM_VIEW_MIN_INTERVAL_SEC = 130.0 +_room_view_last_capture: dict[str, float] = {} + async def _capture_room_description_async( conn: "ConnectionHandler", @@ -496,7 +503,7 @@ async def _capture_room_description_async( a failure leaves the cache empty and the next voice turn proceeds without `[Room view]`. """ - if not VISION_BRIDGE_URL: + if not VISION_BRIDGE_URL or _camera_access_denied(): return device_id = "unknown" try: @@ -508,6 +515,12 @@ async def _capture_room_description_async( # a second capture on top of an active one. Cleared in finally. if getattr(conn, "_room_description_in_flight", False): return + # Per device, not per conn: the robot reconnects within a session. + now = time.monotonic() + last_capture = _room_view_last_capture.get(device_id) + if last_capture is not None and now - last_capture < _ROOM_VIEW_MIN_INTERVAL_SEC: + return + _room_view_last_capture[device_id] = now conn._room_description_in_flight = True try: import requests diff --git a/tests/test_voice_camera_policy.py b/tests/test_voice_camera_policy.py index 2e34b080..21e2e18a 100644 --- a/tests/test_voice_camera_policy.py +++ b/tests/test_voice_camera_policy.py @@ -85,3 +85,42 @@ async def synthetic_executor(*args): # requests is imported by the real function, but its client must not run. monkeypatch.setitem(__import__("sys").modules, "requests", types.SimpleNamespace(get=None)) asyncio.run(run()) + + +def _room_conn(): + return types.SimpleNamespace(logger=MagicMock(), headers={"device-id": "fixture"}) + + +def _capture_room(dispatch, conn=None): + with patch.object(_module, "_mcp_call_tool", dispatch): + asyncio.run(_module._capture_room_description_async(conn or _room_conn())) + + +@pytest.fixture +def room_view(policy): + policy.write_text("false") + _module._room_view_last_capture.clear() + yield policy + _module._room_view_last_capture.clear() + + +def test_kid_mode_blocks_background_room_capture(room_view): + room_view.write_text("true") + dispatch = AsyncMock(side_effect=AssertionError("camera dispatch must not happen")) + _capture_room(dispatch) + dispatch.assert_not_awaited() + + +def test_room_capture_cooldown_survives_reconnect(room_view): + dispatch = AsyncMock(side_effect=RuntimeError("stop after dispatch")) + _capture_room(dispatch, _room_conn()) + _capture_room(dispatch, _room_conn()) + assert dispatch.await_count == 1 + + +def test_room_capture_runs_again_after_cooldown(room_view): + dispatch = AsyncMock(side_effect=RuntimeError("stop after dispatch")) + _capture_room(dispatch) + _module._room_view_last_capture["fixture"] -= _module._ROOM_VIEW_MIN_INTERVAL_SEC + _capture_room(dispatch) + assert dispatch.await_count == 2