If you’re like me (rich), it’s likely that you want to monitor and protect your ice and gold chainz with technical measures. Since cloud based solutions are not an option for me, I’ve built a system that’s self-hosted. It’s based on my existing UniFi network setup, a UniFi camera and this software:
- UniFi Controller: This is used to manage all UniFi devices in a network.
- UniFi Network Video Recorder (NVR): This is a controller and video recording manager for UniFi cameras.
All of this can be self-hosted without requiring any cloud connections. In fact, you can add some firewall rules to prevent any outbound connections and it will still work as expected.
What to Build
Now, what this system should be doing is:
- Tell the camera to record as soon as motion is detected.
- Check if there’s a new recording and send out an alarm via Telegram and Signal, optionally with the recorded video as an attachment.
- Play a crazy MP3 on all Sonos devices connected to the network.
Enabling and disabling all of this based on the WiFi connection status of my smartphone. This way, the alarm system gets armed and disarmed automatically. For this to work as efficient as possible, the UniFi API of both the controller and the NVR can be of use. There’s only one slight problem: Some required API functions are undocumented. At least there’s some unofficial documentation available so not all is lost. Also, these repositories offer great Python libraries that can be used as well:
- unifi-video-api for the NVR.
- PyUnifi for the Controller.
- unifi-events to properly make use of the websocket APIs.
Websocket Event API
Websockets allow receiving event driven data without requiring constant polling for new info, so that’s pretty cool. Both the Controller and the NVR offer such an API. It’s undocumented as well but I’ll show my use cases in this section.
OK, how to inspect and check out websockets? Just use the Chrome developer tools and switch to the WS tab under Network after logging in into the Controller web application:
The Controller sends out messages that contain information on clients entering and leaving the WiFi network. The NVR also sends out a websocket message as soon as a new recording gets created. These are the relevant API endpoints for the websockets:
- Controller:
wss://IP:PORT/wss/s/default/events
- NVR:
wss://IP:PORT/ws/update?compress=true
Let’s see how to get this event data using Python code.
Using the Websocket API
Luckily, the unifi-events
repository I’ve mentioned above already contains some code that’s useful when interacting with websockets. I’ve modified it slightly for this post:
# Get a session cookie
jar = aiohttp.CookieJar(unsafe=True)
json_request = { 'username': CONTROLLER_USER,
'password': CONTROLLER_PASSWORD,
'strict': True
}
async with aiohttp.ClientSession(cookie_jar=jar) as session:
async with session.ws_connect(self.ws_url,
autoping=True,
heartbeat=5,
ssl=self.ssl_verify,
timeout=self.timeout) as ws:
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
self.update_data(msg.json(loads=json.loads))
elif msg.type == aiohttp.WSMsgType.BINARY:
deflated = json.loads(zlib.decompress(msg.data))
self.update_data(deflated)
elif msg.type == aiohttp.WSMsgType.CLOSE:
log.warn('WS closed: %s' % msg.extra)
break
elif msg.type == aiohttp.WSMsgType.ERROR:
log.error('WS closed with Error')
break
A few things are important here:
- Authentication is required, so a session cookie has to be received first. A read-only user is sufficient for the Controller. In case of the NVR, the user has to be able to alter camera settings.
- Using a session keepalive with the
autoping
andheartbeat
parameters is required for the NVR websocket. If you don’t use it, the server may close the connection after a while. - There can be various types of received data. The messages containing useful data are of type
TEXT
andBINARY
. The difference is that binary data has to be decompressed first, as seen above. I’ve found that the NVR only sends out events for new recordings in binary mode, so it’s important to use thecompress=true
URL parameter when creating the websocket.
I’m creating a new thread for each websocket that adds data to a queue. The main thread then removes data from those queues and processes it.
Controller API Limitations ❤
The Controller is not aware of leaving WiFi clients in case they don’t send a disassociation frame. This happens if the client just walks away and loses the connection. There’s no way to get notified for this by the API because there’s simply no event being generated. This results in the alarm not being armed in this case.
Ping to the rescue. I’m pinging my phone regularly to determine the connection status. Since this runs in an own thread and may trigger camera settings to change, a mutex is now required to prevent race conditions with the main thread.
Disarming the alarm system works like a charm though, since this always generates an new event.
Camera API
OK cool, now the system is able to determine when to arm and it gets notified in case a new recording is available. It’s now required that the camera settings get adjusted accordingly. As already mentioned, I’ve used unifi-video-api
for this purpose. This works via the normal HTTP API and has nothing to do with websockets.
Changing Settings
Changing camera settings isn’t fully implemented by the library for all settings though. That’s why I’m using my own wrapper functions. The current settings are being retrieved, changed sent back to the NVR:
def getCameraSettings():
cfg = NVR.get("camera/%s" % (NVR_CAMERA_ID))["data"][0]
assert cfg["deviceSettings"]["name"] == NVR_CAMERA_NAME
return cfg
def setCameraSettings(led, sound):
recMode = "disable" if SOMEONE_HOME else "motion"
G3 = NVR.get_camera(NVR_CAMERA_NAME)
G3.set_recording_settings(recording_mode=recMode)
cfg = getCameraSettings()
cfg["systemSoundsEnabled"] = sound
cfg["enableSoundAlert"] = sound
cfg["enableStatusLed"] = led
cfg["enableSpeaker"] = sound
cfg["enableRecordingIndicator"] = sound
NVR.put("camera/%s" % (NVR_CAMERA_ID), data=cfg)
Downloading Recordings
It’s alert time, how to get the newest recording? I’ve found that you can generate and use an API key for the NVR that allows downloading the recordings. Here’s how to use it:
def isVideoInProgress(id):
info = requests.get("%s/api/2.0/recording/%s/?apiKey=%s" %
(NVR_BASE_URL, id, NVR_API_KEY),
stream=True).json()
inProgress = info["data"][0]["inProgress"]
return inProgress
def getAndSendVideo(id):
while isVideoInProgress(id):
time.sleep(1)
fpath = "/tmp/%s.mp4" % (id)
with requests.get(
"%s/api/2.0/recording/%s/download?apiKey=%s" % (NVR_BASE_URL, id, NVR_API_KEY), stream=True) as video:
with open(fpath, "wb") as localfile:
shutil.copyfileobj(video.raw, localfile)
if "MPEG v4 " not in magic.from_file(fpath):
raise ValueError("[Downloader] Error, Invalid recording")
messaging.sendAttachment(CHAT_ID, fpath)
In case of errors it’s not guaranteed whether the server’s answer is really a video file, so I’m checking the magic bytes of the downloaded file to make sure everything is cool.
Alerting and Messaging
The NVR already supports email notifications but it’s 2021 and I want to use messenger services instead. I’ve decided to use two communication channels:
- Telegram because of the nice API.
- Signal because it’s end-to-end encrypted. Recordings will be transmitted via Signal only since I don’t want russian dudes to check out my video feed, okay?
Using two messengers may seem strange, but on the other hand it’s not uncommon that a messenger is down for a whole day.
Signal Account Creation
Signal requires you to have access to a telephone number to create an account. This can be a mobile number or a landline number, but the important thing is that you keep the number. Otherwise shady people are able to re-register the number and take over your account.
If you’re living in Germany, you can get a free and dedicated number for a Signal account at satellite.me. In other countries, Google Voice may be an option to acquire a mobile number to complete the registration.
Signal-CLI Key Issues
I know this sucks but there’s a workaround: Signal-CLI fails to send messages at some point if no receive
operation is being executed. I guess that the client runs out of rolling keys and that’s why it just fails quietly when sending another message. Nice. I guess having two messengers in parallel is a really good idea.
The workaround is to execute receive
using a Cron job or before sending a new message.
Sonos Integration
I’ve got some Sonos devices, so why not play an alarm sound at full volume in case an alarm gets triggered? The SoCo library can be used for this exact purpose. Additionally, I’ve found the sonosplay repository that includes code to play an MP3 file on a Sonos device, so that’s what I’m using.
This code scans the network for all available Sonos zones and starts the MP3 playback:
def playEverywhere():
try:
# make sure to send multicast using the correct interface
for zone in soco.discover(interface_addr=IP):
zone.volume = 50
playFile(zone)
except Exception as e:
messaging.sendImportant("Sonos play error: %s" % (str(e)))
def playFile(zone):
stream_url = 'http://%s:%s/%s' % (IP, str(PORT), STREAM_PATH)
log.info('Streaming on %s' % stream_url)
try:
zone.clear_queue()
zone.play_uri(stream_url)
except soco.exceptions.SoCoSlaveException as e:
# Expected error, only Sonos group coordinators can start playback
messaging.sendImportant("Sonos play file error: %s" % (str(e)))
The discovery process can be done for each playback since it’s quite fast and doesn’t introduce too much additional lag.
Monitoring
OK so it would be great if the system monitored the system’s health status. For example, a camera may disconnect or there may be high system load that interferes with the NVR operations. Luckily, the NVR websocket sends out this kind of information:
# a JSON object containing an NVR status update
data = getNVRUpdate()
action = data.get("action")
if action == "UPDATE":
dataType = data.get("dataType", None)
if dataType is None:
return
if dataType == "health":
# disconnected cameras
disconnectedCamera = data["data"]["camerasDisconnected"]
if disconnectedCamera is not None:
warningText = "Camera disconnected: %s" % str(
disconnectedCamera)
log.warn(warningText)
messaging.sendImportant(warningText)
# general status
status = data["data"]["status"]
phrase = data["data"]["statusPhrase"]
if status != "GREEN":
warningText = "Status: %s: %s" % (status, phrase)
log.warn(warningText)
messaging.sendImportant(warningText)
All status updates with status != "GREEN"
can be monitored to detect system or camera errors.
Where’s the Code?
I might publish it at some point, maybe. I don’t know.
Now please don’t raid my place, I have JavaScript-enabled turrets around.