Summary
The UniFi Access events surface is broken end to end. Investigating why the recent-events buffer was always empty turned up a chain of defects that each independently prevent an event path from working, across unifi-core, apps/access and apps/api.
Verified against a live Access controller (UniFi OS console, Access application). Happy to split this into separate issues/PRs if you'd prefer smaller units.
1. The websocket listener is never started
apps/access/src/unifi_access_mcp/main.py carries a TODO and logs "Access event websocket listener not yet implemented", at the point where the Protect server calls start_listening(). EventManager.start_listening exists and is unit-tested, but nothing in the application ever calls it — so the ring buffer can never fill, and access_recent_events / the SSE stream always return nothing.
2. The websocket handlers are keyed on names the client never dispatches
EventManager.start_listening registers handlers keyed door_open, door_close, access_granted, access_denied, door_alarm. The client dispatches on WebsocketMessage.event, whose values are the controller's dotted names — access.logs.add, access.hw.door_bell, access.data.device.remote_unlock, access.base.info, and so on. No key ever matches, so every message falls through to the client's "Unhandled websocket message type" branch. The client supports a "*" wildcard, which is what this needs.
Consequence: even after fixing (1), the listener connects and buffers nothing.
3. The websocket callback reads fields the message does not have
WebsocketMessage is a frozen model with event, event_object_id and door_id. _on_event's object branch reads id, type, user_id and timestamp, so a buffered row becomes:
{"id": None, "type": "unknown", "door_id": "", "user_id": None, "timestamp": None}
EventBuffer.get_recent(event_type=...) filters on type, so nothing is ever retrievable, and every row is timeless.
4. list_events only documents two of the seven topics the controller accepts
The topic parameter documents admin and admin_activity. The controller accepts seven, mirroring the Access UI's syslog Category filter:
unlocks, access_denial, ring, updates, critical, admin, admin_activity
The door history everyone actually wants — access.door.unlock plus door open/close — lives under unlocks, and is unreachable as documented. Guessing at plausible names (door_openings, doors, activity) returns CODE_PARAMS_INVALID ... no such topic, which names nothing valid, so there is no way to discover the vocabulary from the error.
5. get_event searches only the admin pair
get_event loops over ("admin", "admin_activity"), so any event id belonging to the other five topics raises not-found.
6. The event serializer does not understand what insights/system_log/search returns
list_events returns raw rows from insights/system_log/search. Those rows use event_type (not type), published in epoch milliseconds (not timestamp), and nest the actor, door and credential inside a metadata object rather than as *_id keys. event_from_controller reads only the latter set, so every field resolves to None and exclude_none=True drops it. A caller receives:
{"id": "", "result": "SUCCESS"}
No time, no door, no actor, no event type. The rows also carry a human-readable message (e.g. "Access Granted (Face)", "Door status - Opened") which is the single most useful field and is discarded entirely — there is no message field on the model.
7. metadata entries are typed, and reading them ungated mis-attributes events
Each metadata entry looks like {"id": ..., "type": ..., "display_name": ...}. actor is whatever caused the event — for an access.dps.status.update record that is the door hub, not a person. Reading actor.id without checking type reports a device as the user who opened the door. Likewise a reader entry carries its own device UUID, which is not the id of any door; feeding it back as door_id matches nothing.
8. The activity histogram never worked at its own default
get_activity_summary hard-codes interval=3600. The default days=7 therefore requests 168 buckets. The endpoint fails with CODE_SYSTEM_ERROR above roughly 100 buckets (measured: 100 succeeds, 104+ returns a 500), so the tool fails on its default arguments. The interval needs to be derived from the requested window.
9. apps/api never normalises the rows at all
Only the MCP tool routes rows through event_from_controller. The GraphQL type (Event.from_manager_output) and the REST resource route project the raw controller row directly, reading type / timestamp / door_id / user_id. So defect (6) is live on GraphQL and REST independently of the model, and fixing the model alone does not reach either surface.
10. Both sort keys break cursor pagination on system-log rows
The GraphQL resolver and the REST route both key events on (raw["timestamp"], raw["id"]). System-log rows have neither — the time is published and the id is the empty string — so every row keys to (0, ""). paginate() windows with a strict (ts, id) < (last_ts, last_id), and nothing can be strictly less than the key every row shares, so the second page of an events query comes back empty.
On the REST side the bare 0 default also sits next to a string timestamp in the same tuple, which raises TypeError: '<' not supported between 'str' and 'int' inside sorted() as soon as both shapes appear.
Environment
- UniFi Access application on a UniFi OS console
- Reproduced against
main
I have fixes for all of the above with unit tests, verified against a live controller, and will open a PR shortly.
Summary
The UniFi Access events surface is broken end to end. Investigating why the recent-events buffer was always empty turned up a chain of defects that each independently prevent an event path from working, across
unifi-core,apps/accessandapps/api.Verified against a live Access controller (UniFi OS console, Access application). Happy to split this into separate issues/PRs if you'd prefer smaller units.
1. The websocket listener is never started
apps/access/src/unifi_access_mcp/main.pycarries aTODOand logs "Access event websocket listener not yet implemented", at the point where the Protect server callsstart_listening().EventManager.start_listeningexists and is unit-tested, but nothing in the application ever calls it — so the ring buffer can never fill, andaccess_recent_events/ the SSE stream always return nothing.2. The websocket handlers are keyed on names the client never dispatches
EventManager.start_listeningregisters handlers keyeddoor_open,door_close,access_granted,access_denied,door_alarm. The client dispatches onWebsocketMessage.event, whose values are the controller's dotted names —access.logs.add,access.hw.door_bell,access.data.device.remote_unlock,access.base.info, and so on. No key ever matches, so every message falls through to the client's "Unhandled websocket message type" branch. The client supports a"*"wildcard, which is what this needs.Consequence: even after fixing (1), the listener connects and buffers nothing.
3. The websocket callback reads fields the message does not have
WebsocketMessageis a frozen model withevent,event_object_idanddoor_id._on_event's object branch readsid,type,user_idandtimestamp, so a buffered row becomes:{"id": None, "type": "unknown", "door_id": "", "user_id": None, "timestamp": None}EventBuffer.get_recent(event_type=...)filters ontype, so nothing is ever retrievable, and every row is timeless.4.
list_eventsonly documents two of the seven topics the controller acceptsThe
topicparameter documentsadminandadmin_activity. The controller accepts seven, mirroring the Access UI's syslog Category filter:unlocks,access_denial,ring,updates,critical,admin,admin_activityThe door history everyone actually wants —
access.door.unlockplus door open/close — lives underunlocks, and is unreachable as documented. Guessing at plausible names (door_openings,doors,activity) returnsCODE_PARAMS_INVALID ... no such topic, which names nothing valid, so there is no way to discover the vocabulary from the error.5.
get_eventsearches only the admin pairget_eventloops over("admin", "admin_activity"), so any event id belonging to the other five topics raises not-found.6. The event serializer does not understand what
insights/system_log/searchreturnslist_eventsreturns raw rows frominsights/system_log/search. Those rows useevent_type(nottype),publishedin epoch milliseconds (nottimestamp), and nest the actor, door and credential inside ametadataobject rather than as*_idkeys.event_from_controllerreads only the latter set, so every field resolves toNoneandexclude_none=Truedrops it. A caller receives:{"id": "", "result": "SUCCESS"}No time, no door, no actor, no event type. The rows also carry a human-readable
message(e.g."Access Granted (Face)","Door status - Opened") which is the single most useful field and is discarded entirely — there is nomessagefield on the model.7.
metadataentries are typed, and reading them ungated mis-attributes eventsEach metadata entry looks like
{"id": ..., "type": ..., "display_name": ...}.actoris whatever caused the event — for anaccess.dps.status.updaterecord that is the door hub, not a person. Readingactor.idwithout checkingtypereports a device as the user who opened the door. Likewise a reader entry carries its own device UUID, which is not the id of any door; feeding it back asdoor_idmatches nothing.8. The activity histogram never worked at its own default
get_activity_summaryhard-codesinterval=3600. The defaultdays=7therefore requests 168 buckets. The endpoint fails withCODE_SYSTEM_ERRORabove roughly 100 buckets (measured: 100 succeeds, 104+ returns a 500), so the tool fails on its default arguments. The interval needs to be derived from the requested window.9.
apps/apinever normalises the rows at allOnly the MCP tool routes rows through
event_from_controller. The GraphQL type (Event.from_manager_output) and the REST resource route project the raw controller row directly, readingtype/timestamp/door_id/user_id. So defect (6) is live on GraphQL and REST independently of the model, and fixing the model alone does not reach either surface.10. Both sort keys break cursor pagination on system-log rows
The GraphQL resolver and the REST route both key events on
(raw["timestamp"], raw["id"]). System-log rows have neither — the time ispublishedand the id is the empty string — so every row keys to(0, "").paginate()windows with a strict(ts, id) < (last_ts, last_id), and nothing can be strictly less than the key every row shares, so the second page of an events query comes back empty.On the REST side the bare
0default also sits next to a string timestamp in the same tuple, which raisesTypeError: '<' not supported between 'str' and 'int'insidesorted()as soon as both shapes appear.Environment
mainI have fixes for all of the above with unit tests, verified against a live controller, and will open a PR shortly.