Skip to content

Add UniFi Protect API Key Only connection mode - #1

Closed
nberardi wants to merge 3 commits into
mainfrom
cursor/unifi-protect-api-key-935a
Closed

Add UniFi Protect API Key Only connection mode#1
nberardi wants to merge 3 commits into
mainfrom
cursor/unifi-protect-api-key-935a

Conversation

@nberardi

@nberardi nberardi commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an API Key Only connection mode to the UniFi Protect plugin, alongside the existing local user account flow — modeled after Home Assistant’s approach in home-assistant/core#176410.

Connection modes

Mode Auth Scope
Local User (default) Username / password against the private Protect API Full feature set (unchanged)
API Key Only UniFi OS Integration API key (X-API-KEY) against the public Integration API Cameras + lights

API keys are created in the UniFi OS local portal under Settings → Control Plane → Integrations.

API Key Only capabilities

  • Cameras: RTSPS streams, snapshots, motion/smart detections, doorbell ring, status LED
  • Lights: on/off, brightness, PIR motion
  • Public events/devices websockets for realtime updates

Not available in API Key Only (use Local User)

  • Sensors and locks
  • Two-way audio / intercom
  • Privacy masks, dynamic bitrate, optical zoom
  • Fingerprint sensors and package-camera snapshots

Implementation notes

  • New ProtectPublicApi client (plugins/unifi-protect/src/public-api.ts) talks to /proxy/protect/integration/v1/*
  • Public camera/light payloads are adapted into bootstrap-compatible shapes so discovery and device helpers can be shared
  • Settings UI shows username/password or API key based on the selected mode
  • Existing Local User installs are unaffected (default mode)
  • Public API client rate-limits to UniFi’s 10 req/s window, retries 429s, and safely resets CONNECTING websockets

Test plan

  • Local User: confirm existing username/password setup still connects, discovers devices, streams, and snapshots
  • API Key Only: set Connection Mode → API Key Only, enter IP + API key, confirm login via /meta/info
  • Discover and adopt cameras; verify RTSPS stream playback and snapshots
  • Confirm motion / smart detect / doorbell ring events update Scrypted sensors
  • Discover lights; verify on/off and brightness
  • Confirm sensors/locks are not offered in API Key Only mode
  • Switch mode back to Local User and confirm credentials still work
  • Invalid/revoked API key surfaces a clear login alert
  • Reload plugin in API Key Only mode and confirm no 429 / websocket uncaughtException loop
Open in Web Open in Cursor 

Support authenticating with a UniFi OS Integration API key via the
public Protect Integration API, similar to Home Assistant's API-key-only
mode, while keeping the existing local user account path.

API Key Only currently covers cameras (RTSPS streams, snapshots,
detections) and lights; sensors, locks, intercom, privacy masks, and
dynamic bitrate remain local-user features.

Co-authored-by: Nick Berardi <me@nickberardi.com>
@nberardi

nberardi commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

I am getting the following errors when running locally

########################
9/3/2026, 5:48:52 PM
########################
Unifi Protect Plugin: loading @scrypted/unifi-protect
Unifi Protect Plugin: pid 855
starting plugin @scrypted/unifi-protect 855
node modules /server/volume/plugins/@scrypted/unifi-protect/n-node-v127-linux-x64-20250101
plugin loaded
Unifi Protect Plugin: loaded @scrypted/unifi-protect
Connected to Protect public API 7.2.105
Protect public API bootstrap failed Error: Protect public API GET /cameras failed (429): {"error":"Too many requests","name":"TOO_MANY_REQUESTS_ERROR","windowMs":1000,"limit":10}
    at ProtectPublicApi.request (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:277:19)
    at processTicksAndRejections (node:internal/process/task_queues:105:5)
    at async Promise.all (index 0)
    at ProtectPublicApi.getBootstrap (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:309:39)
    at UnifiProtect.connectProtect (/server/volume/plugins/@scrypted/unifi-protect/zip/src/main.ts:756:18)
Event Listener reconnecting in 10 seconds: refresh failed
Unifi Protect Plugin: scheduled plugin restart cancelled, plugin was restarted by user @scrypted/unifi-protect
Connected to Protect public API 7.2.105
Protect public API bootstrap failed Error: Protect public API GET /cameras failed (429): {"error":"Too many requests","name":"TOO_MANY_REQUESTS_ERROR","windowMs":1000,"limit":10}
    at ProtectPublicApi.request (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:277:19)
    at processTicksAndRejections (node:internal/process/task_queues:105:5)
    at async Promise.all (index 0)
    at ProtectPublicApi.getBootstrap (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:309:39)
    at UnifiProtect.connectProtect (/server/volume/plugins/@scrypted/unifi-protect/zip/src/main.ts:756:18)
Event Listener reconnecting in 10 seconds: refresh failed
uncaughtException Error: WebSocket was closed before the connection was established
    at WebSocket.close (/server/volume/plugins/@scrypted/unifi-protect/zip/node_modules/ws/lib/websocket.js:299:7)
    at ProtectPublicApi.resetSockets (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:532:28)
    at ProtectPublicApi.reset (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:546:14)
    at /server/volume/plugins/@scrypted/unifi-protect/zip/src/main.ts:341:23
    at UnifiProtect.connectProtect (/server/volume/plugins/@scrypted/unifi-protect/zip/src/main.ts:757:49)
    at processTicksAndRejections (node:internal/process/task_queues:105:5)
uncaughtException Error: WebSocket was closed before the connection was established
    at WebSocket.close (/server/volume/plugins/@scrypted/unifi-protect/zip/node_modules/ws/lib/websocket.js:299:7)
    at ProtectPublicApi.resetSockets (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:537:29)
    at ProtectPublicApi.reset (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:546:14)
    at /server/volume/plugins/@scrypted/unifi-protect/zip/src/main.ts:341:23
    at UnifiProtect.connectProtect (/server/volume/plugins/@scrypted/unifi-protect/zip/src/main.ts:757:49)
    at processTicksAndRejections (node:internal/process/task_queues:105:5)
Unifi Protect Plugin: uncaughtException Error: WebSocket was closed before the connection was established
    at WebSocket.close (/server/volume/plugins/@scrypted/unifi-protect/zip/node_modules/ws/lib/websocket.js:299:7)
    at ProtectPublicApi.resetSockets (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:532:28)
    at ProtectPublicApi.reset (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:546:14)
    at /server/volume/plugins/@scrypted/unifi-protect/zip/src/main.ts:341:23
    at UnifiProtect.connectProtect (/server/volume/plugins/@scrypted/unifi-protect/zip/src/main.ts:757:49)
    at processTicksAndRejections (node:internal/process/task_queues:105:5)
Unifi Protect Plugin: uncaughtException Error: WebSocket was closed before the connection was established
    at WebSocket.close (/server/volume/plugins/@scrypted/unifi-protect/zip/node_modules/ws/lib/websocket.js:299:7)
    at ProtectPublicApi.resetSockets (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:537:29)
    at ProtectPublicApi.reset (/server/volume/plugins/@scrypted/unifi-protect/zip/src/public-api.ts:546:14)
    at /server/volume/plugins/@scrypted/unifi-protect/zip/src/main.ts:341:23
    at UnifiProtect.connectProtect (/server/volume/plugins/@scrypted/unifi-protect/zip/src/main.ts:757:49)
    at processTicksAndRejections (node:internal/process/task_queues:105:5)
    ```

The API-key-only path was calling getBootstrap twice after login, which
exceeded UniFi's 10 req/s public Integration API limit and returned 429.
A failed second bootstrap then reset still-connecting websockets; closing
a CONNECTING ws emits an error with no listener and crashed the plugin.

- Bootstrap only once after login
- Rate-limit and retry public API requests on 429
- Fetch cameras/lights sequentially; prime RTSPS with GET-only at bootstrap
- Safely terminate CONNECTING websockets on reset

Co-authored-by: Nick Berardi <me@nickberardi.com>
@nberardi

nberardi commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the logs — this was a real bug in the API Key Only path.

Root cause

  1. relogin() already called getBootstrap(), then connectProtect() called it again. That second pass blew past UniFi’s public Integration API limit (10 requests / 1000ms) and returned 429.
  2. When the second bootstrap failed, reconnect/reset() closed websockets that were still in CONNECTING from the first successful pass. Closing a connecting ws emits error with no listener, which Node turns into uncaughtException.

Fix (in a831e95)

  • Bootstrap only once after login
  • Client-side rate limiting + 429 retry/backoff
  • Sequential camera/light fetches; RTSPS primed with GET-only during bootstrap (create lazily on first stream)
  • Safe websocket teardown for CONNECTING sockets (terminate + swallow error)

Please reload @scrypted/unifi-protect@0.1.5 from this branch and try API Key Only again.

Co-authored-by: Nick Berardi <me@nickberardi.com>
@nberardi
nberardi marked this pull request as ready for review September 3, 2026 17:57
@nberardi

nberardi commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

Opened upstream against koush/scrypted: koush#2132

Closing this fork PR in favor of the upstream one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants