| Developer: | Simon's Plugins |
| Category: | Security & Locks |
| Github: | Github Repo |
| Assistance: | Get help! |
| Plugin ID: | com.simons-plugins.indigo-unifi-protect |
| Latest release: | v2026.7.0 released on Aug. 31, 2026 |
| Release downloaded: | 15 times |
| Requires: | Indigo v2023.2.0 or higher |
| (Check the Releases tab below for older releases that may have different requirements) | |
| Download release v2026.3.1 |
Bridges UniFi Protect camera motion (person, vehicle, animal) and audio (speech, baby cry, smoke/CO alarm sounds) detection into Indigo sensor devices, via the official UniFi OS integration API.
/proxy/protect/integration/v1) and console API keys; older releases may
not work.On your UniFi OS console: Settings → Control Plane → Integrations → Create API Key.
The plugin ships an optional HTML page — pages/cameras.html —
that turns the camera devices into something you can actually look at:
takeSnapshot action for the selected
camera and reloads the image. No per-camera action group needed.It also does two things deliberately:
Last-Modified
header, and turns amber past two minutes. A camera page showing a silently
stale still is worse than showing nothing.Copy the file into Indigo's user pages folder:
cp pages/cameras.html \
"/Library/Application Support/Perceptive Automation/Indigo 2025.2/Web Assets/static/pages/"
That folder survives plugin upgrades, and it is one of the two directories the Domio iOS app scans. Restart the Domio plugin and the page appears as Cameras.
It is deliberately not bundled inside this plugin. Domio only scans its own plugin folder and
Web Assets/static/pages— a page inside this plugin'sContents/Resourcescan never be discovered by it, so shipping a copy there would only create two files that drift apart. (simons-plugins/indigo-domio-plugin#24 tracks fixing that upstream.)
https://<indigo-host>:8176/static/pages/cameras.html?api-key=<your-key>
The ?api-key= form is how the page authenticates outside the Domio app.
Why the page fetches images rather than using
<img src>: Indigo's web server requires authentication for everything underWeb Assets, including the snapshot JPEGs. An<img src="...">tag cannot carry anAuthorizationheader, so it gets a silent 401 that looks exactly like a missing file. The page fetches the bytes with the bearer token and hands the<img>a blob URL instead.
/Library/Application Support/Perceptive Automation/Indigo <version>/Web Assets/images/unifi-protect/camera_<deviceId>.jpg
Served by Indigo at /images/unifi-protect/camera_<deviceId>.jpg. Files are
keyed by Indigo device id, not camera name, so renaming a device doesn't
orphan its image. They live outside the plugin bundle on purpose — Indigo
replaces Contents/ on every plugin upgrade, which would delete them.
More detail in pages/README.md.
Indigo booleans cannot express "unknown". When the Protect event
WebSocket is down, this plugin forces both motionDetected and connected
to False — there is no third state for "I can't tell". Any trigger that
acts on motion must gate on connected first, or a dead socket reads
as an empty, quiet house. This is the single most important thing to know
before wiring this plugin into an automation.
Cameras with a microphone also report audio events — speech, a baby crying, or a smoke/CO alarm sounding — over the same event socket as motion, but tracked completely separately:
audioDetected is true while any audio event is active on the camera
(even before Protect has classified what it heard — classification
lands about a second after the add frame arrives).speechDetected, babyCryDetected, smokeAlarmDetected, and
coAlarmDetected are true when that specific type is part of the
currently active audio event(s).lastAudio / lastAudioTypes record the most recent audio event, the
same way lastMotion / lastDetectTypes do for motion.By default, onOffState — what a "device turned on" trigger watches —
goes on for motion, speech, or a baby crying. Uncheck Audio counts as
activity on the device to exclude speech/baby-cry and have onOffState
track motion only. A smoke or CO alarm sound never turns the device on,
checkbox or not — build automations against smokeAlarmDetected /
coAlarmDetected directly, not against onOffState, so a real alarm is
never mistaken for (or buried under) routine motion/presence handling.
The same connected gating above applies here too: when the event socket
is down, the live audio booleans and lastAudioTypes go False/empty
exactly like motionDetected does (lastAudio is a historical
timestamp and is kept, same as lastMotion). Silence is not safety —
it can just as easily mean the plugin can't hear anything right now.
Each device also reports the camera's current hardware/config as read-only
states: cameraModel, videoMode, hdrType, micEnabled, micVolume,
ledEnabled, osdNameEnabled, and osdDateEnabled. Unlike the motion/audio
states above, these come from the cached GET /cameras object rather than
the event socket, and they are only written when that object is available —
a failed lookup leaves them at their last-known value rather than overwriting
real hardware state with a fabricated False/"". dev.model is also kept
in sync with the camera's hardware type, so the device list shows e.g. "UVC
G5 Turret Ultra" instead of the generic "Protect Camera".
Deliberately not available, because it needs the private (not the
official integration) API: firmware version, camera IP, isDark, IR LED
mode/level, zoom position, motion tuning, and wifi stats. See
docs/API-REFERENCE.md (added by PR #10) for
the full verified field list.
This is the plugin's first write path — everything above only reads.
Five device actions, all under Device Actions, send a partial
PATCH /cameras/{id}; the console applies just the fields sent and the
plugin refreshes the camera-info states above from the response
immediately, rather than waiting for the next refresh:
ledEnabled state, so it does the right thing even if the LED
was last changed from the UniFi app, not this plugin.featureFlags.videoModes, so it only ever offers modes that camera
actually supports. If the camera isn't cached yet, it falls back to
Protect's published mode list with each option marked (unverified) —
picking one there is still checked against the real camera before
anything is sent.Each action checks the camera's featureFlags before sending
anything — setStatusLed needs hasLedStatus, setHdrMode needs
hasHdr, setMicVolume needs hasMic — because not every camera has
every capability (one on the reference rig reports no speaker at all). A
camera that doesn't support the action gets an Event Log error instead of
a silently-ignored PATCH.
A refused write always shows up in the Event Log, including the exact
field the controller rejected (e.g. /videoMode: must be equal to one of
the allowed values) when Protect's API returns one. A successful write
never gets silently swallowed either way — the camera-info states above
are only ever updated from an actual 2xx response, never assumed. A
network problem or a controller error reports its outcome as unknown,
not as a refusal — the write may well have landed even though this
particular response couldn't confirm it — and the plugin re-reads the
camera to catch up rather than guessing either way.
Verified live against a UNVR on Protect 7.2.105, 2026-08-31: the API key alone authorises these writes, the same as every read in this plugin — no separate write credential was needed.
Each camera device can optionally expose its RTSPS live-stream URLs as device states, for a control page or an external viewer (VLC, ffplay, a home-theatre system) to pull actual video rather than the periodic stills above.
This is off by default, and it should stay off unless you have a real use for it. The URL is not just a network address — it embeds an access token, so anyone who can read it can stream from the camera. Indigo device states are readable by anything that can read the Indigo database (other plugins, control pages, scripts), so turning this on widens who can watch your camera beyond "people with the UniFi app." Tick Expose RTSPS stream URLs on the device only when something concrete needs it; unticking it immediately clears the stored URLs back to empty, it doesn't just stop refreshing them.
When enabled, four states are populated: streamUrlHigh, streamUrlMedium,
streamUrlLow, and streamUrlPackage. streamUrlPackage stays empty
unless the camera reports hasPackageCamera — most cameras don't have a
package-detection lens and the controller returns null for it.
They refresh automatically after the device starts and on every event-socket reconnect — but not instantly: fetching a stream URL talks to the controller, and that happens in the background, throttled alongside every other request this plugin makes, so the first URLs typically appear a few seconds after the plugin connects, not the moment you tick the checkbox or the socket comes up. With several opted-in cameras, they fill in one at a time rather than all at once. If a viewer stops working, fire the plugin's Refresh Stream URLs action (or Send Status Request, which is synchronous and reports the outcome immediately) rather than assuming the URL is permanently dead: on the one console this was verified against, the token was identical across two calls ten seconds apart, but whether it rotates over a longer window (hours/days) is untested, and a stale token would look exactly like a broken stream. A momentary failure (e.g. a rate limit) never blanks a working URL — the plugin keeps whichever value it already had rather than clearing a stream a viewer might be using.
A one-line example: copy the streamUrlHigh state's value and hand it
straight to a player -- ffplay "rtsps://192.168.0.10:7441/<token>?enableSrtp",
or paste the same URL into VLC's Open Network Stream dialog.
Built from the official OpenAPI spec, never exercised on real hardware. Every camera feature in this plugin (above) was verified against a live UniFi Protect console. Sensors, floodlights, and chimes were not: the reference console has none, and
GET /sensors,GET /lights, andGET /chimesall return[]there. This code is built entirely from Protect's published OpenAPI 3.1 spec (v6.2.83) -- field names, shapes, and event types should be right, but nothing here has been proven against a real Protect sensor, floodlight, or chime. If something looks wrong, please open an issue with a debug capture: rundocs/ws_probe.pywhile triggering the sensor/light/chime, and attach its output alongside aGET /sensors(or/lights,/chimes) JSON dump from your console. The NVR device below is the one exception -- its arm-state fields were captured live.One known limitation, independent of the spec-derived caveat above: a leak that began before the plugin started (or before the event socket last reconnected) is not shown until the sensor reports again -- the
leakDetected/alarmTriggered/tamperedbooleans only ever reflect a LIVE event, because Protect's own poll response has no "currently active" flag for these three, only a timestamp of when it was last reported. The timestamps themselves (lastLeak/lastAlarm/lastTamperon Protect Sensor) are still shown, so the information isn't lost -- just not folded into the boolean.
Each class gets its own Indigo device type, added and configured the same way as a camera: create a device, pick the Protect object from the dropdown (populated live), done. Use Discover Devices (plugin menu, renamed from "Discover Cameras") to list everything your console reports across all four classes.
Sensors, floodlights, and chimes have no live push feed from Protect for most of their state -- only cameras get one. This plugin polls each class over REST every 60 seconds, and only for classes that actually have a registered Indigo device (a console with sensors but no chimes never triggers a chime poll). Sensors and floodlights also receive live updates over the same camera event socket for the fields that have a matching Protect event (motion/leak/alarm/tamper lifecycle events, and the open/closed/battery-low/extreme-value/PIR-motion "pulse" notifications) -- the poll is what keeps everything else (temperature, battery percentage, light mode, chime ring settings, ...) current, and it's also what corrects a live motion flag that's gotten stuck (if Protect's own poll answer says motion has stopped, that wins).
A poll failure logs one ERROR per class per outage (not once a minute, and
one INFO when it recovers), and PROACTIVELY marks that class's own state
string (sensorState/lightState/chimeState/armStatus) "unavailable"
-- every other value for that class (temperature, isOpen, ring volume, ...)
is left exactly where it last was, not re-read from a stale cache and not
blanked either. The same happens if a device that's still configured in
Indigo simply stops appearing in Protect's list (removed, or the console
briefly returned an empty list) -- a WARNING names the device, and its
values are held the same way. Before the very first poll ever completes
(a brand-new device, or one added while the controller was down), the
plugin writes only what it can genuinely vouch for -- lifecycle-driven
booleans and "unavailable" -- and never fabricates a fresh-looking
False/0/empty value for anything poll-derived. connected always reflects
the event socket itself, independent of whether polling is succeeding --
a device won't get stuck reporting disconnected just because its first
poll after a reconnect happened to fail.
Tracks a Protect multi-purpose sensor (door/window/garage contact, leak, motion, tamper, and a smoke/CO/glass-break alarm listener, plus temperature/humidity/light-level readings on models that have them).
States: isOpen, motionDetected, leakDetected, alarmTriggered,
alarmType, tampered, batteryLow, temperature, humidity,
lightLevel, mountType, sensorState, connected, lastMotion,
lastOpenChange, lastLeak, lastAlarm, lastTamper, lastPoll.
Which state drives the device's on/off status (what a "device turned
on" trigger watches) is the Primary state device setting: Auto
(default) picks by mount type -- door/window/garage mounts use isOpen,
a leak mount uses leakDetected, no mount uses motionDetected -- or
pin it explicitly to Open/Closed, Motion, Leak, or Alarm.
Battery percentage is Indigo's native battery state, not a plugin
state -- it shows up wherever Indigo already shows battery level for any
device, via the SupportsBatteryLevel device property. (The controller
API marks the field this reads, batteryStatus, "deprecated" in favour of
one that doesn't otherwise exist in the spec -- see docs/CONTRACT.md.)
motionDetected/leakDetected/alarmTriggered/tampered are
live-event-driven, so they follow the same honesty rule as camera motion:
when the event socket is down they go False, not "unknown". Everything
else here (isOpen, batteryLow, temperature, ...) comes from the
60-second poll and is kept at its last-known value through a socket
outage, the same way a camera's hardware states survive one.
leakDetected/alarmTriggered/tampered cannot recover a pre-existing
condition across a restart (see the banner above): Protect's poll only
gives a timestamp of the last report for these three, never a "still
active" flag, so the boolean starts False until a new live event
arrives. lastLeak, lastAlarm, and lastTamper hold the poll's own
timestamp for each (blank if the sensor has never reported one), so the
fact isn't lost even while the boolean can't show it. isOpen doesn't
have this gap -- Protect's poll gives a real current open/closed flag.
States: isDark, pirMotionDetected, forceEnabled, ledLevel,
lightMode, lightState, connected, lastMotion, lastPoll. The
device's on/off state is the light's actual LED (isLightOn).
Turning the device "on" or "off" force-enables/disables the light's main
LED (PATCH .../lights/{id} with isLightForceEnabled). "Off" only
clears the force flag -- it does not disable the floodlight's own motion
mode. If the light's mode is set to turn on for motion, it can still
light up on its own right after you turn it "off" here. There's a
Set LED Level action (1-6) for the light's brightness.
States: chimeState, pairedCameraCount, ringVolume (the first paired
camera's ring volume -- skipped entirely if the chime has no ringtone
settings), connected, lastPoll. There's a Set Chime Volume action
(0-100) that applies the same volume to every camera paired to the chime;
it errors without doing anything if the chime has no ring settings to set
volume on in the first place (nothing paired to it yet).
One device, no picker -- there is only one NVR per console. Tracks your
UniFi OS console's arm/disarm state: nvrName, nvrModel,
protectVersion, armStatus, armedAt, breachDetectedAt,
breachEventCount, connected, lastPoll. armStatus reads
"unavailable" before the first successful poll, or if the console never
reports an arm state at all. Arm/disarm itself is not exposed as an
action -- it isn't part of the published integration API.
Motion detection rides the Protect event WebSocket, not polling. Measured
from Protect's own event timestamp to frame receipt (docs/ws_probe.py, 2
events, ±1s — the capture's receive timestamps only have whole-second
resolution):
add trailed Protect's
own start by ~16s and the end arrived ~12s after it — do not
assume motion-like latency for audio.These figures exclude the plugin's own message parsing and the Indigo state-write. This is fine for occupancy and security automation, but the off-latency in particular means this plugin is not a PIR replacement for lighting triggers — don't wire it directly to a "turn lights off when motion stops" rule expecting sub-second response.
docs/API-REFERENCE.md — the full official API
surface: every endpoint, every /subscribe/events event type, what the
official API can't give you that the private one can, and why. Internal
module contract is in docs/CONTRACT.md.
| Released on: | Aug. 31, 2026 |
| Requires: | Indigo v2023.2.0+ |
| Downloaded: | 15 times |
| Download this release | |
Full Changelog: https://github.com/simons-plugins/indigo-unifi-protect/compare/v2026.6.0...v2026.7.0
| Released on: | Aug. 31, 2026 |
| Requires: | Indigo v2023.2.0+ |
| Downloaded: | 13 times |
| Download this release | |
Full Changelog: https://github.com/simons-plugins/indigo-unifi-protect/compare/v2026.5.0...v2026.6.0
| Released on: | Aug. 31, 2026 |
| Requires: | Indigo v2023.2.0+ |
| Downloaded: | 14 times |
| Download this release | |
Full Changelog: https://github.com/simons-plugins/indigo-unifi-protect/compare/v2026.4.0...v2026.5.0
| Released on: | Aug. 31, 2026 |
| Requires: | Indigo v2023.2.0+ |
| Downloaded: | 16 times |
| Download this release | |
Full Changelog: https://github.com/simons-plugins/indigo-unifi-protect/compare/v2026.3.1...v2026.4.0
| Released on: | Aug. 31, 2026 |
| Requires: | Indigo v2023.2.0+ |
| Downloaded: | 13 times |
| Download this release | |
Full Changelog: https://github.com/simons-plugins/indigo-unifi-protect/compare/v2026.3.0...v2026.3.1
| Released on: | Aug. 31, 2026 |
| Requires: | Indigo v2023.2.0+ |
| Downloaded: | 14 times |
| Download this release | |
Full Changelog: https://github.com/simons-plugins/indigo-unifi-protect/compare/v2026.2.3...v2026.3.0
| Released on: | Aug. 31, 2026 |
| Requires: | Indigo v2023.2.0+ |
| Downloaded: | 14 times |
| Download this release | |
Full Changelog: https://github.com/simons-plugins/indigo-unifi-protect/compare/v2026.2.2...v2026.2.3
| Released on: | Aug. 26, 2026 |
| Requires: | Indigo v2023.2.0+ |
| Downloaded: | 13 times |
| Download this release | |
Full Changelog: https://github.com/simons-plugins/indigo-unifi-protect/compare/v2026.2.1...v2026.2.2
| Released on: | Aug. 26, 2026 |
| Requires: | Indigo v2023.2.0+ |
| Downloaded: | 15 times |
| Download this release | |
Full Changelog: https://github.com/simons-plugins/indigo-unifi-protect/compare/v2026.2.0...v2026.2.1
| Released on: | Aug. 26, 2026 |
| Requires: | Indigo v2023.2.0+ |
| Downloaded: | 15 times |
| Download this release | |
Full Changelog: https://github.com/simons-plugins/indigo-unifi-protect/commits/v2026.2.0