Skip to content

Access events are broken end to end: listener never started, wrong websocket keys, unread system-log shape, 5 of 7 topics undocumented, histogram fails at its own default #548

Description

@rmaher001

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions