feat(telemetry): broadcast door state transitions as their own message (#55) #94
No reviewers
Labels
No labels
blocked
bug
enhancement
high-priority
low-priority
needs-info
needs-triage
ready-for-agent
ready-for-human
referenced
research
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
gabogg/hikcentral!94
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/telemetry-door-transition-broadcast"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #55
Problem
Door state transitions were written to
door_hardware_state_transitionsbut never broadcast. A client could only infer them by diffing successivedoors_updateoverviews, which misses any change that happens between two polls.Approach
This follows the 2026-09-23 triage resolution on #55.
Message. A new
DoorStateTransitionDTO inapp/schemas/models.pyhas these fields:id,door_index_code,door_name,previous_state,new_state,state_key,trigger_source,timestamp_epoch,exclude_from_rankingsandexclusion_source. It covers physical, commanded (remain open or closed) and offline/recovery changes. It carries nodetails_json, person names or card numbers; those stay in the access-cycle messages. The message is{"type": "door_transitions", "transitions": [...], "server_time", "facility_utc_offset_minutes"}.Emission (server).
DoorManager._record_transitions_async/_record_transitions_sync: async polling, sync polling, webhook and reconciliation. Each one persists first and then broadcasts one message per persisted batch, containing each new transition once.record_hardware_transitions_batch_async/_syncnow return the new row ids in input order, and the four copies of the INSERT statement became one_INSERT_TRANSITION_SQL.doors_updatesent on connect still restores current state.Ingestion (client).
TelemetryEnginehandlesdoor_transitions:doorTransitions(DOOR_TRANSITION_BUFFER_SIZE);Docs.
docs/api/README.mdnow lists the/ws/realtimemessage types.Verification
tests/test_door_transition_broadcast.py(9 tests, real SQLite, HikCentral stubbed):REMAIN_OPEN, an offline change and its recovery;doors_update.tests/frontend/test_telemetry_engine.test.js: state change without an overview, remain-open and offline, a late transition, batch ordering, repeated ids, excluded doors, unknown doors, the buffer limit, and resync by the next overview.pytest: 291 passed, 1 skipped.node --test tests/frontend/*.test.js: 76 passed.ruffis clean,scripts/check_docs.pypasses, and pre-commit passed.Notes for review
Overlap with the other telemetry PRs:
door_service.py.telemetry_engine.js.The hunks are in different places, but whichever PR merges second may need a small rebase.
Displaying transitions: nothing in the UI renders
doorTransitionsyet. The engine keeps door state current from them, and a transition log panel can read the buffer later.🤖 Generated with Claude Code
Checklist
Review follow-up
Broadcast tasks are retained until complete; first-seen door exclusions are read from persistence; unused single-row transition writers were removed. The telemetry engine updates door state immediately, while the dashboard grid still renders on the next
doors_updateoverview. The pre-existing synchronous database work in the async webhook route is tracked by #95.Code review — pass 1 (two-axis)
Reviewed the PR's own diff (
git diff <base>...<head>); Standards and Spec ran as independent passes and are reported separately, not reranked. Severity: P1 must fix · P2 fix before merge · P3 minor. Per review policy, pass 1 findings are all to be fixed on the branch.Standards
No P1 bugs. Layering holds: SQL stays in the repository (
_INSERT_TRANSITION_SQLalso consolidates four copies), the DTO lives inapp/schemas/, services do the broadcasting, and each new path has a test.app/services/door_service.py_record_transitions_sync(~L1397-1405),loop.create_task(self._broadcast_transitions(events))discards the task handle. The loop holds only a weak reference (asyncio docs), so the task can be garbage-collected mid-run. The webhook path goes through here (called from the async/webhookroute). Keep tasks in a set, or reuse the done-callback pattern._schedule_broadcast(~L1450):get_running_loop()/RuntimeError/create_task, minus the done-callback. Extract one_spawn(coro)helper. The now-redundant localimport asyncioin_schedule_broadcast(~L1453) can go, since this PR adds it at module top.except RuntimeErrorcomment says "No event loop (CLI / sync callers): persisted, nothing to broadcast to". True only for CLI callers; a future caller on a worker thread (to_thread, threadpooldefendpoint) would silently lose broadcasts while clients are connected. At least log at debug.code-standards.md§2.3 ("Avoid passing untyped, raw dictionaries between service layers when structured schemas are available"). The webhook hunk (~L858-880) builds a new 11-keydict[str, Any], and_transition_eventsre-reads it key by key. Also possible Data Clumps / Primitive Obsession: the same transition fields travel as a dict through four paths. A typed input record (aDoorStateTransition-like model withoutid) would remove thestr()/int()coercions./webhookroute still does blocking sync SQLite writes viahandle_webhook_event, breaching §2.4 "Never call blocking synchronous functions within async routes". This PR adds one more sync write there. Worth an issue rather than a fix here.door.get("exclusionSource") or door.get("exclusion_source")(~L1425) is a dual-key fallback; pick one canonical key.app/db/door_repository.pycursor.lastrowidisint | None, but methods now declare-> list[int](~L664-689). Add an assert or cast.app/static/js/src/telemetry/telemetry_engine.js_ingestDoorTransitions(~L641-667) adds a third copy of the door-state → open/closed/offline flag logic (others in_ingestDoorsand the snapshot builder).stateKeybut leavesstateLabelstale (e.g. "🟢 Asegurada" on a now-open door) until the next overview. Nothing rendersstateLabeltoday, so latent.PR description
docs/standards/git-and-workflow.md§2.2 requires a Checklist section; none present.Spec
Verdict: meets every acceptance criterion in the 2026-09-23 triage resolution. No P1 or P2 findings; the P3s are hardening and test gaps.
(a) Missing or partial
tests/test_door_transition_broadcast.py:222— "failed persistence, duplicate emission prevention". Failed persistence is tested only on the async path, not sync or webhook. The duplicate test (:235) checks one poll only; no test shows that a later unchanged poll, or a reconciliation after a poll, emits nothing more.REMAIN_OPENis tested (:150and the JS engine test).REMAIN_CLOSED(3) is untested on both sides.app/static/js/app.js:515— "dashboard ingestion through the telemetry module".TelemetryEngineingests transitions and they reach snapshot subscribers, butrenderDoorsDashboardstill renders only fromdata.overview, so the door grid waits for the nextdoors_update. Acceptable; say so explicitly in the PR.(b) Scope creep
app/db/door_repository.py:266,661-689— four INSERT copies become one_INSERT_TRANSITION_SQL, andexecutemanybecomes per-rowexecuteso ids come back. Needed for the transition id, so justified. Return typeint→list[int]is fine; only the new helpers call it.docs/api/README.mdgains a message-type table. Welcome, not creep.(c) Implemented but questionable
app/db/door_repository.py:573,609— single-rowrecord_hardware_transition_sync/_asyncstay public with no callers. A future caller would persist without broadcasting, breaking "Cover all persistence paths". Remove or mark internal.app/services/door_service.py:1404—loop.create_task(...)keeps no reference and adds no done-callback, unlike_schedule_broadcastat:1450. (Same finding as Standards P2.)app/services/door_service.py:1413— exclusion is read fromself.doors, so a door first seen in a poll always reportsexclude_from_rankings=False, even when the DB row says it is excluded.Open design points
doors_updatedoor_transitionsmessage._record_transitions_async/_record_transitions_syncemit; repositories only persist.:258).Test fidelity
Backend tests use real SQLite, check stored row id against broadcast id (
:131), and assert the exact field set, proving nodetails_jsonor person data is sent (:135). They cover webhook, async and sync polling, reconciliation, offline/recovery and excluded doors. Engine tests cover ordering, late transitions, dedup, unknown doors, buffer cap and resync from the next overview — matching the PR's claims.Summary: Standards 10 (1×P2, 9×P3) — worst: untracked
create_taskin_record_transitions_sync(webhook path). Spec 7 (all P3) — worst: orphaned public single-row transition writers bypass the broadcast.🤖 Generated with Claude Code
Code review — pass 2 (two-axis)
Re-reviewed at head
d852097. Each axis verified every pass-1 finding against the code (not the commit messages), then reviewed the fix commits for new problems. Verdicts: ✅ fixed · ◐ partial · ❌ not addressed · ↺ regressed · ⊘ declined with documented reason. Per review policy, from pass 2 on P1/P2 block merge; P3s go to one follow-up issue for this PR.Standards
Verified at head (fix commits
0beef06,ea7e01d,d852097). In a throwaway worktree:ruff checkandruff format --checkpass;pytest tests/test_door_transition_broadcast.py tests/test_analytics.py18 passed;node --test tests/frontend/test_telemetry_engine.test.js31 passed.Pass-1 findings
create_taskhandle discarded in_record_transitions_sync0beef06door_service.py:1463-1470: task kept inself._broadcast_tasks(:94), done-callback discards it. Sound: no blocking, exceptions logged, cancellation handled.create_taskblock; stray localimport asyncio_spawn_broadcast(make_coro)(:1403,:1461); only module-topimport asyncio(:1) remains.:1477); see wording finding._transition_events(:1405-1440) still reads key by key withstr()/int()coercions. No reason documented./webhookexclusionSource/exclusion_sourcefallback:1435; the fix even builds a new dict with the camelCase key (:1416) to feed it.lastrowidisint | Noneassert cursor.lastrowid is not Noneatdoor_repository.py:601,617.telemetry_engine.js:654-663still maps state→flags inline, and now also state→label inline.stateLabelstaleea7e01dtelemetry_engine.js:663sets it, but labels don't match the server's canonicalDoorState.label(app/schemas/models.py:36-42).## Checklistwith three items.New findings
app/services/door_service.py:1411-1422, possible Speculative Generality + sync I/O in an async path. The new fallback for doors missing fromself.doorscallsdoor_repo.get_by_code_sync()._record_transitions_asyncgoes through the same function, so this is a blocking SQLite read on the event loop — soft breach of §2.4 and AGENTS.md async hygiene;get_by_code_asyncexists. No current path appears to reach it (polling only records a transition whenprev_dooris inself.doors,:443-551; webhook and reconciliation also take the door fromself.doors), and no test covers it. Delete it, or make it async and test it. (Spec axis reached the same conclusion independently.)telemetry_engine.js:663: labels differ from the server's. Offline shows⚫ Sin conexión(used nowhere else; server:⚪ Fuera de Línea).REMAIN_OPEN/REMAIN_CLOSEDcollapse to🟡 Abierta/🟢 Asegurada, while the server showsPermanecer Abierta/🔒 Permanecer Cerrada. A transition and the next overview give different labels for the same door.door_service.py:1477: misleading log text. "Door broadcast deferred: no running event loop" implies a retry; nothing retries. Say skipped/dropped.test_analytics.py. Refresh the counts (git-and-workflow.md asks for passing-test evidence).Nothing new at P1/P2 in a re-scan of the full diff.
Merge readiness (this axis): Ready. P2 fixed; remaining P3s → follow-up issue.
Spec
Head
d852097.Pass-1 findings
d852097addstest_failed_sync_persistence_broadcasts_nothing(tests/test_door_transition_broadcast.py:245). It calls_record_transitions_syncdirectly withtrigger_source="WEBHOOK"rather than throughhandle_webhook_event, but that is the webhook's only persistence seam (door_service.py:860), so this is enough.test_polling_broadcasts_remain_closed_once(:160) runs two polls and asserts one emission — "later unchanged poll" covered. Reconciliation after a poll still untested.REMAIN_CLOSEDuntested:160(1→3) plus engine test "remain-closed updates the state and label" (ea7e01d).0beef06removes both fromapp/db/door_repository.py;tests/test_analytics.pymoved to the batch API. Only INSERT left is_INSERT_TRANSITION_SQL(:267), called only by the two service helpers.create_task_spawn_broadcast(door_service.py:1463-1479) keeps tasks in_broadcast_tasks, discards on completion;_record_transitions_sync(:1399-1403) uses it.exclude_from_rankings=Falsedoor_service.py:1411-1422falls back todoor_repo.get_by_code_sync.New findings
app/services/door_service.py:1411-1422: the new DB fallback in_transition_eventsnever runs. Every path creates a transition only for a door already inself.doors: async poll (:443-444needsprev_door,:551needsprev_state is not None), sync poll (:632-633), webhook (if door:,:856), reconciliation (loops over known doors). So pass-1's first-seen case can't happen, and the fix adds an untested branch. If it ever ran on the async poll or reconciliation path it would be a blocking sync SQLite call inside an async function (code-standards §2.4); spec: "Services own emission after successful persistence". Remove the branch, or make it async and test it. (The pass-1 finding rested on a false premise.):123-294) and 10 engine transition tests.No P1/P2. Fix commits cause no spec drift: emission only after persistence; one message per batch, no
details_jsonor person data; excluded doors still included; reconnect snapshot kept and tested (:294).Tests run in a throwaway worktree:
tests/test_door_transition_broadcast.py+tests/test_analytics.py18 passed;tests/frontend/test_telemetry_engine.test.js31 passed.Merge readiness (this axis): Ready. Remaining P3s (N1–N3) → follow-up issue.
Summary: Ready on both axes (no P1/P2). Both axes independently flag the new
get_by_code_syncfallback in_transition_eventsas unreachable and, if reached, blocking I/O on the loop (P3). Worst per axis: Standards → that fallback; Spec → same, plus stale test counts in the description.🤖 Generated with Claude Code