room_view: mirror face_recognized into per-device state#103
Merged
Conversation
#102's broadcast emit fanned the event out to bus subscribers (FaceGreeter / scene_synthesis) but skipped the per-device state mutation that POST /api/perception/event normally does — so `last_face_id` + `last_face_recognized_t` never landed on state.state[device_id]. Downstream readers that depend on these fields silently no-op'd: - face_identified_refresher (reads last_face_id to keep right-ring pixel 6 green past the firmware's 4 s timeout — pixel was going yellow ↔ green ↔ off instead of holding green while the user stayed in frame) - perception/snapshot.py reading last_face_id for the talk-turn PerceptionSnapshot ("Speaking with: …" hint to the LLM) - state.get_fresh_face_id() — the canonical accessor Caught during the #44 bench sweep right after #102 merged. The fix mirrors what POST /api/perception/event does: update_state then broadcast. The integration test grew assertions on the two state fields so the regression can't reappear silently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a missing per-device perception state update in the room_view roster-match path of POST /api/vision/explain, ensuring last_face_id and last_face_recognized_t are written when a face_recognized bus event is emitted (matching the behavior of POST /api/perception/event). This unblocks downstream readers like face_identified_refresher, snapshots, and get_fresh_face_id() that depend on per-device state rather than just the broadcast event.
Changes:
- Update
routes/vision.pyto callstate.update_state(..., "face_recognized", ...)before broadcasting theface_recognizedevent. - Extend the existing room_view happy-path integration test to assert
last_face_idandlast_face_recognized_tare set.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| dotty-behaviour/routes/vision.py | Mirrors /api/perception/event by updating per-device state before broadcasting face_recognized for room_view roster matches. |
| dotty-behaviour/tests/test_routes_vision.py | Adds assertions to lock in the invariant that roster matches populate last_face_id / last_face_recognized_t. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+274
to
+278
| # Identity mirrored into per-device state (face_identified_ | ||
| # refresher reads last_face_id to keep pixel 6 green past | ||
| # its 4 s firmware timeout). Bug caught during the #102 | ||
| # bench sweep — broadcast alone wasn't enough; we also need | ||
| # to call update_state the way /api/perception/event does. |
This was referenced May 23, 2026
BrettKinny
added a commit
that referenced
this pull request
May 23, 2026
* #111: rip ACP/voice path from bridge.py ACPClient + MessageIn/MessageOut + /api/message + /api/message/stream + voice tool helpers + voice escalate/memory_log/remember/remember_person + _voice_preparer + _ConvoLogger are gone. /health drops acp_running / cached_session / session_turns. The dashboard's per-person memory page keeps its three brain.db read/mutate helpers (_voice_memory_person_records_blocking / _voice_memory_approve_blocking / _voice_memory_delete_blocking) since /ui/memory is still live. Perception consumers + vision/audio endpoints stay for commits 2-3 of the #111 rip. _greeter_llm_client is stubbed to RuntimeError so the ProactiveGreeter (still wired into lifespan until commit 2) fails fast on its first prompt() call rather than NameError on a missing acp ref. bridge.py: 6079 → 4564 lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * #111: rip perception consumers from bridge.py All 13 in-process perception consumers — face_lost_aborter, sound_turner, wake_word_turner, face_greeter, face_identified_refresher, purr_player, named_acknowledger, scene_synthesis (loop is partial — see commit 3), idle_photographer, sleep_dreamer, dance_reflector, security_capture, ProactiveGreeter wiring — are gone. The in-memory perception bus (_perception_state / _perception_listeners / _perception_recent_events / _sound_balance_history) goes with them, along with /api/perception/event + /api/perception/state + /api/perception/feed, the legacy convo-log last_user_line cache, and the HouseholdRegistry / SpeakerResolver wiring. Calendar machinery (_fetch_weather / _fetch_calendar_events / _refresh_caches / _calendar_poll_loop / summarize_for_prompt / _build_context / _build_perception_block / Event TypedDict) goes too — dotty-behaviour serves /api/calendar/today now (per #100 / dotty-behaviour/routes/calendar.py). The dashboard perception card now reads empty stubs (_dashboard_perception_state_getter / *_recent_getter / *_last_user_line_getter / *_sound_balance_series / *_vision_failures_last_hour all return empty), so the card renders without errors but shows no data until the dashboard ports to dotty-behaviour. _identity_display_name returns None for the same reason. Dispatch helpers kept: _dispatch_abort, _dispatch_set_state, _dispatch_set_tier1slim_model, _dispatch_set_toggle — all four are needed by the dashboard / admin endpoints. The consumer-only dispatchers (face_greeting / say / set_head_angles / set_face_identified / purr_audio) went with the consumers. bridge.py: 4564 → 2868 lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * #111: rip VLM / vision / audio code from bridge.py /api/vision/explain, /api/vision/latest/{device_id}, /api/audio/explain are gone. The room_view sentinel branch (VISION_ROOM_VIEW_SYSTEM_PROMPT, __ROOM_VIEW_V1__, _build_room_view_question, _parse_room_view_response, _ROOM_VIEW_* constants) goes with it — dotty-behaviour now owns the room_view roster recognition path (PR #101 / #102 / #103). _call_vision_api + VLM HTTP plumbing + _classify_vision_result + _call_narrative_llm + _call_audio_caption_api + _audio_format_from_upload go too — dotty-behaviour owns every VLM call now (and dotty-pi-ext owns narrative calls from voice tools). Scene-synthesis / idle-photographer / dream / dance NDJSON writers (_scene_synthesis_log_path / _idle_perception_log_path / _write_idle_perception_record / _is_notable_perception / _idle_photographer_pick_device / _dreams_log_path / _dances_log_path / _write_jsonl_record / _write_dream_record / _write_dance_record / _split_dream_text / _compose_scene_synthesis / _write_scene_synthesis_ndjson / _maybe_emit_scene_synthesis / _perception_sleep_dreamer / _perception_dance_reflector / _perception_idle_photographer / _scene_synthesis_loop) are gone — every consumer they fed lives in dotty-behaviour. The legacy /admin/* endpoints that targeted the retired ZeroClaw daemon (_apply_model_swap, _read_voice_model_from_cfg, _voice_profile_for_model, _admin_schedule_restart, _ADMIN_DAEMON_CFG, /admin/model) were retired alongside — /admin/persona stays for external persona-file edits, /admin/safety stays as a static MCP_TOOL_ALLOWLIST mutation surface, /admin/smart-mode keeps just the tier1slim hot-swap path, /admin/state + /admin/kid-mode keep their xiaozhi-admin passthrough. bridge.py: 2868 → 911 lines. Final state: dashboard-only. Cache stubs (_vision_cache / _audio_cache / _scene_synthesis_cache / _perception_state) all empty dicts so the dashboard perception card renders without errors but shows no data until it ports to dotty-behaviour. The brain.db memory funcs (_voice_memory_person_records_blocking / *_approve_blocking / *_delete_blocking) survive because /ui/memory is the only operator surface on per-person memory rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to PR #102. The room_view branch in `routes/vision.py` calls `state.broadcast()` on roster match but skipped the `state.update_state()` call that `POST /api/perception/event` normally pairs with broadcast. Result: `last_face_id` and `last_face_recognized_t` never landed on per-device state, even though the bus event fired correctly.
Why
Caught during the #44 LED bench sweep immediately after #102 merged. Brett walked in, named-greet fired correctly (event reached FaceGreeter), but the right-ring pixel 6 wouldn't hold green past the firmware's 4s timeout because `face_identified_refresher` reads `last_face_id` to decide whether to re-fire `set_face_identified`.
Live state confirmation pre-fix (snapshot from `curl /api/perception/state`):
```
"last_name_greet_t": {"brett": 1779532034} ← FaceGreeter saw the event
…
← no last_face_id field
```
Fix
Mirror what `POST /api/perception/event` does:
```python
state.update_state(device_id, "face_recognized", data, ts) # ← added
state.broadcast(PerceptionEvent(...))
```
Other affected readers that were silently no-op'ing:
Test plan
🤖 Generated with Claude Code