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
10 changes: 8 additions & 2 deletions dotty-behaviour/consumers/idle_photographer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +25,7 @@
import logging
import random
import time
from typing import Callable

from dispatch import XiaozhiAdminClient
from logs import NdjsonWriter
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions dotty-behaviour/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 15 additions & 1 deletion dotty-behaviour/tests/test_consumer_idle_photographer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
)


Expand Down Expand Up @@ -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())
15 changes: 14 additions & 1 deletion receiveAudioHandle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions tests/test_voice_camera_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading