An Omarchy bar widget for UniFi Protect cameras: one small icon in the bar, and the pictures in a panel behind it.
The bar shows a camera glyph and nothing else. It lights up, and the name of the camera slides in beside it, while Protect reports motion, so a camera that sees something is noticeable out of the corner of your eye without a video feed sitting in the bar all day.
Everything you would actually look at lives in the panel: the selected camera large and playing live, the other cameras as thumbnails underneath, and below those the frames your cameras filed the last time they saw something. Clicking any picture opens that camera in Protect's own detection browser, which is where you go when a glance is not enough.
omarchy plugin add https://github.com/jankeesvw/omarchy-unifi-protect.git --enableOpen the camera icon in the bar. Until it has an API key the panel opens on settings instead of cameras:
- Set the console address if it is not already right.
- Paste an API key and save it.
The first time the widget talks to the console it files that certificate
and keeps talking only to it. The same two steps can be done from the
shell if you would rather script them: the sections below. The widget
lands in the right section of the bar; move it with omarchy bar move,
or from the bar's own settings panel.
Everything goes through Protect's integration API, which takes a plain
X-API-KEY header. Make a key at https://<your-console>/unifi-api/protect.
⚠️ UniFi hands a key the full rights of the account that created it, and there is no way to scope it down to Protect over the API. A key made on your own admin login is your whole console in a file on your laptop.Make a local user for it instead. Under Settings → Admins & Users → Add User, choose a local account and sign in as that user to make the key. The widget only ever lists cameras, pulls stills and reads stream urls, so Protect: View Only is all it ever needs, and a key that leaks is then a key that can look at your cameras rather than one that can reconfigure your network.
On at least some consoles the integrations page that hands out keys is only reachable by a Full Site Admin, so a view-only account cannot get to it. The way round it is to give that local user admin rights, make the key, and set it straight back to Protect: View Only. Thanks to @GeertJohan for working that out on a UDM Pro.
One thing worth knowing before you rely on that: we have not confirmed whether UniFi re-reads the account's rights on every request or freezes them when the key is made. If it is the latter, the key still carries admin rights however the account is set afterwards. Treat it as an admin key until somebody checks.
The panel's settings view is the ordinary way to file a key: paste it, save,
and it is written to the key file over stdin so it never appears on a process
command line or in shell.json. The widget never reads the stored key back
into the UI; it only shows whether one is saved.
The same file can be written by hand, which is what a script should do. The widget looks for the key in two places, in this order:
UNIFI_API_KEYin the environment.- A file, by default
~/.config/unifi-protect/api-key. SetUNIFI_API_KEY_FILEto put it somewhere else.
The file is the one that works straight away, because a variable exported into your session does not reach a shell that is already running:
printf '%s\n' "paste-your-key-here" | ./bin/unifi-protect set-keyor, equivalently:
mkdir -p ~/.config/unifi-protect
(umask 077; printf '%s\n' "paste-your-key-here" > ~/.config/unifi-protect/api-key)The first line with something on it is the key, trimmed. Nothing else in the file is parsed.
Your console signs its own certificate. There is no authority that can vouch for it, so there is nothing to check it against, and the key above travels on every one of these connections. The first time the widget talks to the console it files whatever certificate is answering, then every later connection has to present that same key or be dropped before the API key goes out. That is trust-on-first-use: you do not have to pin anything in the panel.
unifi-protect trust is still there if you would rather look at the
fingerprint first, or pin again after a console reset (the only other time
the fingerprint should change, and the one case the widget will not
overwrite for you):
~/.config/omarchy/plugins/jankeesvw.unifi-protect/bin/unifi-protect \
--host 192.168.1.1 trustIt prints the fingerprint and waits. trust show prints the same facts as
JSON without writing; trust accept <fingerprint> re-fetches the chain and
pins it only if that fingerprint still matches, so the certificate you
approved is the one filed.
A switch in the panel's header, beside the gear, mutes those popups for the
rest of the session without a trip to settings: a quick "stop interrupting
me" rather than a change to the config. It starts from notify each time the
shell starts and does not write it back, and the bar icon still lights up on
motion either way, since that part is silent anyway.
The console address, the API key, which cameras may interrupt you, and the
motion notification live in the widget's own settings view (the gear on
the panel). The rest, panel width, snapshot interval and how long the icon
stays lit, stays in the bar's settings panel, or by hand in the widget's
entry in ~/.config/omarchy/shell.json:
| Setting | Default | What it does |
|---|---|---|
host |
192.168.1.1 |
The console running Protect: a UDM, a Cloud Key, whatever answers on your network. Address or hostname, no scheme. |
alertCameras |
(empty) | Camera names, comma separated, that may light the icon and file frames. Empty means all of them. |
notify |
true |
Raise a desktop notification, with the frame in it, on motion. Sets the starting position of the switch in the panel's header, see below. |
viewerCommand |
xdg-open {path} |
What clicking that notification runs, to see the frame full size. |
panelWidth |
800 |
Panel width in the shell's spacing units. Trimmed to fit a small screen. |
motionHoldMs |
45000 |
How long the icon stays lit after motion. |
refreshMs |
1000 |
How often the large view gets a fresh still while the stream is connecting. |
captureThrottleMs |
25000 |
How long a camera that just filed a frame files nothing more. |
archiveKeep |
60 |
Frames kept per camera. |
rtspPort |
7447 |
The plain RTSP port on the console. |
eventsUrl |
(see below) | Where clicking a picture takes you. |
alertCameras is worth setting. The camera aimed at your own desk reports
motion all day, and an icon that is always lit is an icon you stop reading:
{ "id": "jankeesvw.unifi-protect", "host": "10.0.0.1", "alertCameras": "Front door, Shed" }Names are matched against what Protect calls the cameras, ignoring case. The setting gates the bar icon and the archive both; every camera stays visible in the panel regardless.
The notification carries the frame as its icon, which is a thumbnail; clicking
it opens the same picture full size. viewerCommand is what runs, with
{path} standing in for the frame:
{ "id": "jankeesvw.unifi-protect", "viewerCommand": "imv {path}" }The default hands the file to whatever your desktop opens a JPEG with. Name a viewer to be sure of which one, or leave it empty for a popup that does nothing. The template is split on whitespace and goes out as an argv vector, which the shell's notification server keeps with the toast, so a frame is still one click away after the popup has moved into the notification history. The frame path is one argument however it is spelled, so a space or a dash in it stays part of the filename.
eventsUrl is Protect's detection browser filtered to one camera. {host} is
the console and {camera} the camera id, and the default is:
https://{host}/protect/detections/find-anything?grade=all&labels=camera%3A{camera}&minConfidence=30
It is a setting rather than a constant because Protect is a single-page app: that route cannot be read off the console, it was taken from the address bar, and a UniFi update is free to move it.
The large view plays the camera's real RTSP stream, and only while the panel is open. A stream left running behind a closed panel is a decoder and a few megabits a second spent on a picture nobody is looking at.
The thumbnails stay stills, refreshed one camera at a time: three decoders running so you can see which camera to click is a lot of CPU for a picture the size of a stamp. A still is also what fills the large view for the second or two the stream takes to connect, so opening the panel never starts on black.
With the panel closed, the widget holds one websocket open for motion events and does nothing else. There is no polling.
Every burst of motion on a camera you listed files one frame, whether or not
the panel happens to be open, in
~/.local/state/unifi-protect/events/<camera-id>-<unix-time>.jpg. The newest
four show up under the panel; clicking one puts it in the large view with how
long ago it was, and clicking it again goes back to live.
Frames are stored around 960px wide, so archiveKeep at its default of 60 per
camera is a few megabytes and covers a busy day. Protect keeps the actual
recordings; this is the shortcut that answers "was somebody at the door"
without opening anything.
The widget shells out to bin/unifi-protect in this repo for everything, and
that script is usable on its own:
cd ~/.config/omarchy/plugins/jankeesvw.unifi-protect
./bin/unifi-protect list # cameras, as JSON
./bin/unifi-protect snapshot <camera-id> # writes a JPEG, prints its path
./bin/unifi-protect events # the archive, newest first
./bin/unifi-protect cached # newest snapshot on disk per camera
./bin/unifi-protect stream <camera-id> # the RTSP url
./bin/unifi-protect live <camera-id> # opens the stream in mpv
./bin/unifi-protect watch # motion as NDJSON, until killed
./bin/unifi-protect status # {ok, hasKey, trusted, host}
./bin/unifi-protect set-key # writes a key from stdin
./bin/unifi-protect trust show # the live certificate, as JSON
./bin/unifi-protect trust accept <fp> # pins that fingerprint
./bin/unifi-protect trust # the same, interactively--host, --rtsp-port and --archive-keep go before the command. It is also
the first place to look when the bar icon stays dim: run list by hand and the
error comes back as JSON instead of disappearing into the shell's log.
To exercise the icon without waiting for somebody to walk past a camera:
omarchy-shell jankeesvw.unifi-protect.test motion "Front door"
omarchy-shell jankeesvw.unifi-protect.test clearOmarchy with omarchy-shell (the Quickshell-based bar), and:
curl, jq |
talking to Protect |
python3 + websockets |
motion events. Without it everything works except the icon lighting up, and the panel says so. If your distro has no package for it, unifi-protect setup offers to put one in a virtualenv of your own after telling you what it is about to do. It installs exactly the version and the file digests listed in requirements.txt in this repository, with pip --require-hashes, so what gets installed is what was reviewed rather than whatever the registry serves that day. A digest that does not match stops the install and leaves the virtualenv empty. |
imagemagick |
shrinking archived frames. Without it they are stored full size. |
libnotify |
the notification on motion |
mpv |
only for unifi-protect live |
On Arch:
sudo pacman -S --needed curl jq python python-websockets imagemagick libnotify mpvChain and hostname checking are off for calls to the console; its
certificate names the console, not the address you reach it on, and nothing on
your network can build a chain to it. What replaces them is the certificate
filed on first contact: curl is given --pinnedpubkey, which it enforces
regardless, and the motion websocket loads that certificate as its only
trust root. A connection that presents anything else is dropped before the
API key is sent.
The key itself never becomes an argument. /proc/PID/cmdline is mode 444 and
readable by every account on the machine, and a widget that polls would hold
that window open all day, so curl is handed the header on stdin through
--config -. The motion websocket gets it on a pipe for the same reason one
step further in: /proc/PID/environ is mode 400, which keeps other accounts
out but not other processes of yours, and that watcher is meant to run for
days, so anything running as you could read it out at leisure. A pipe is
readable only while it is being read, and the watcher drains it in its first
line. Nor does the key reach a log: bash -x prints the words of every
command it runs, so the trace is switched off around the places that hold it.
The key file and the pinned certificate are written under umask 077, and the
directory holding them is checked to be a directory of yours at mode 700,
along with the frame cache and the archive, which are pictures of the inside of
your house.
Camera ids come off those connections and end up in filenames, so they are
checked against [A-Za-z0-9]{1,64}, Protect's own format, and anything else
is refused rather than escaped.
The live view reads the stream off the plain RTSP port rather than the
encrypted one, because Qt Multimedia gives no way to trust a self-signed
certificate; unifi-protect live keeps the encrypted URL, since mpv can be
told to accept it. Neither carries the API key: Protect hands back a
per-camera stream alias and that is all that travels.
omarchy plugin remove jankeesvw.unifi-protectThe archived frames are yours, not the plugin's, so they stay in
~/.local/state/unifi-protect/. Delete that directory, and
~/.config/unifi-protect/ with the key and the pinned certificate in it, if
you want them gone too.
MIT
