Usage¶
Assuming that you've followed the installation steps,
you're now ready to use this package. uiprotect is an async library, so the
examples below drive an event loop with asyncio.run.
Command line¶
Most day-to-day tasks are available through the bundled CLI:
| Bash | |
|---|---|
See the Command Line reference for the full list of subcommands.
Connecting from Python¶
Instantiate ProtectApiClient with your console's address and a local-access
user, call update() once to fetch the bootstrap snapshot, then read devices
off the cached bootstrap:
About Ubiquiti SSO accounts
Ubiquiti SSO (cloud) accounts are not supported. Use a local-access user. Driving the cloud owner account over the public Internet is a security risk and there is no MFA support.
Subscribing to events¶
The typed event stream delivers (ProtectEvent, EventChange) pairs. It is
backed by the Public Integration API, so the client must be configured with
an api_key and update_public() must have run at least once before calling
subscribe_events. The example below primes then subscribes; to avoid
encoding that ordering yourself, call subscribe_events_and_prime() (or
subscribe_devices_and_prime() for device state), which connects the
WebSocket and primes in the correct order in a single call. With the combined
helper, frames that arrive while update_public() is priming are buffered
and replayed onto the fresh snapshot, so the already-connected subscriber
does not miss an update from the prime window; with the explicit two-step
form the subscriber is registered after priming and simply starts from the
refreshed cache:
The lower-level subscribe_websocket continues to deliver raw
WSSubscriptionMessage frames for advanced callers over the private API and
does not require an API key. The parallel subscribe_events_websocket drives
the Public Integration API WebSocket and does require an API key. See the
project README for the full notes on the typed event contract.
Detecting a revoked API key¶
A revoked or invalid API key can't be re-authenticated from inside the client
— the key is static — so the public WebSocket would otherwise redial forever on
repeated 401 handshakes without the consumer ever learning the key died.
After two consecutive 401s the client emits WebsocketState.AUTH_FAILED over
the existing state channels (subscribe_events_websocket_state /
subscribe_devices_websocket_state) and switches to a longer backoff to stop
hammering the NVR. AUTH_FAILED implies disconnected — the socket is closed
and cannot recover without a new key — so the client always emits a
DISCONNECTED transition first when it trips. A consumer that tracks
connectivity as state is WebsocketState.CONNECTED and one that only reacts to
DISCONNECTED edges both observe the loss; AUTH_FAILED is the additional
signal that the cause is a dead key rather than a transient drop. Subscribe to
that channel to be notified, then install a fresh key with set_api_key() — it
re-arms both public WebSockets immediately so recovery doesn't wait out the
backoff:
| Python | |
|---|---|
Events arrive only on the events WebSocket, so if it drops and reconnects
while the devices WebSocket stays up, an end frame missed during the gap
would otherwise leave the event active — a camera's derived
is_*_currently_detected stuck ON until the periodic TTL sweep (~45 min worst
case) closes it. On events-WS reconnect the client therefore force-ends
active detection events regardless of age (a still-active detection
re-asserts on its next frame) and flushes other channels past the 1 h
staleness window. Both the typed subscribe_events stream (an ENDED change)
and subscribe_devices (a camera update naming the flipped is_*_detected
fields) see the drop immediately, and the derived camera flags read correct on
the next synchronous access.
Camera RTSPS streams¶
RTSPS stream URLs live on the camera as PublicCamera.rtsps_streams. The
library owns their entire lifecycle: update_public() primes them, so a
consumer reads the field synchronously and carries no fetch/cache code:
| Python | |
|---|---|
Priming is always run by update_public(): connected cameras without
streams are fetched under a bounded-concurrency semaphore, best-effort (one
slow/offline camera is skipped, never aborting the rest), and disconnected
cameras are skipped. get_camera_rtsps_streams(camera_id) is a flag-free fetch
primitive used internally — it issues a plain GET with no cache side-effects.
The field stays correct from the sources the client controls — stream create/delete is not signalled over the WebSocket, so passive observation is impossible:
- Write-through.
create_camera_rtsps_streamswrites its result onto the camera'srtsps_streams;delete_camera_rtsps_streamsdrops the deleted qualities (and clears the field toNoneonce no streams remain). - Prime / refresh on connect. When a public devices-WS frame moves a camera
to
CONNECTED(a reconnect or firmware change can rotate thertsp_alias), a background fetch is scheduled. A camera that was offline atupdate_public()time — so skipped by the connected-only prime — is primed when it comes online mid-session, not left streamless until the next reload. An already-populated camera is refreshed in place instead. Either way a WebSocket reconnect resync also refreshes every populated camera. The field is never emptied — the old URLs stay readable until the fresh ones land, so synchronous consumers readingcamera.rtsps_streamsnever observe a spuriousNone. It is only ever cleared by a cameraremoveframe (which drops the whole camera) or the client's owndelete_camera_rtsps_streams. - Observable. A background prime/refresh that actually changes a camera's
streams is announced: the client emits a synthetic devices-WS
updatefor that camera (new_objis the refreshed camera) through the existing devices subscription, so bothsubscribe_devices_websocketand typedsubscribe_devicesconsumers observe stream availability without polling. A refresh that yields no change — an identity-guard backoff, a fetch failure, or a re-fetch equal to the cached value — emits nothing.
Public vs. private API¶
uiprotect can talk to UniFi Protect two ways, and is actively migrating
from the second to the first:
- Public Integration API — Ubiquiti's officially documented REST API
under
/integration/v1/…. It authenticates with an API key (create one withuiprotect create-api-key NAME), is stable across firmware releases, and is the forward-looking path. The typedsubscribe_eventsstream and the public-API CLI groups (viewers-public,users-public,liveviews, …) are driven by this API. Requests on this path are auto-paced to stay under the server's per-API-key rate budget — the client seeds its rate from theRateLimit-Policyheader on the first response (with safety margin for the shared-budget public WebSocket) and falls back to a conservative default until that header is seen, so a bootstrap fan-out no longer trips a429storm. Rotating the key viaset_api_key()resets the pacing. - Private API — the reverse-engineered, undocumented endpoints under
/api/…plus the binary WebSocket update stream. It authenticates with username/password and powers most of the historicalbootstrap-based surface. It is not documented by Ubiquiti, can shift between firmware versions, and is being deprecated capability-by-capability as the public API gains coverage.
Prefer the public API for new code. Reach for the private API only for capabilities the public API does not yet expose; treat that as a temporary escape hatch rather than the default path.
Device convenience setters (public API)¶
Camera, light, sensor and chime devices from public_bootstrap expose set_*
convenience methods that patch a single setting and write the server's response
straight back into the cached device — so a public-only client (API key, no
username/password) can mutate a device without hand-building nested
update_*_public bodies:
| Python | |
|---|---|
Each setter validates the device's capability flags (e.g. a camera without a
microphone rejects set_mic_volume), applies the same numeric bounds the server
enforces, and serialises concurrent read-modify-write setters (chime ring
volume, camera smart-detect toggles, light mode/device settings) on one device
under a per-object lock.
PublicCamera.rtsps_streams is owned out-of-band by the library and is never
touched by a setter.
The private-API Device.set_*_public methods remain and are unchanged; when a
public bootstrap is loaded they keep the cached public twin fresh through the
same update_*_public endpoints, so the Home Assistant integration and the CLI
continue to work as before.
Shared identity interface¶
The public device models expose the same derived identity attributes as the
private tree: display_name (falling back name → type, mirroring the private
name → market_name → type) and a type alias for the raw device_type
field. So PublicNVR().display_name and PublicCamera().type work the same as
their private-tree counterparts.
Code that handles "the NVR" or "a camera" generically — for example the Home
Assistant integration's device-info paths, which must now accept either tree —
can type against the ProtectDeviceIdentity protocol instead of cast()-ing
between the unrelated private (NVR, Camera, …) and public (PublicNVR,
PublicCamera, …) types. Both trees satisfy it structurally:
| Python | |
|---|---|
The protocol covers id, mac, display_name, type, and model; mac and
type are optional because the public tree omits them on older firmware.