src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-04-12 11:52:58 -0500
committerLuke Hoersten <[email protected]>2026-04-12 11:52:58 -0500
commitc528cf7268463c84a050129665ce08814fd1d420 (patch)
tree43414ad05f9923b0abc1cfe7b48b6da33169534e
parent121f23c67f10a0ea3e8bff476f46fdec13112696 (diff)
Add doorbell-viewport role
RPi4 portrait touchscreen that shows a live UniFi Protect RTSP stream on doorbell ring or touch, with display fully off at idle. Supports warm prebuffer, vcgencmd/drm/panel display backends, evdev touch input, and configurable DRM device/connector/mode.
-rw-r--r--doorbell-viewport/README.md283
-rw-r--r--doorbell-viewport/defaults/main.yaml16
-rw-r--r--doorbell-viewport/files/doorbell-viewport-debug222
-rw-r--r--doorbell-viewport/files/doorbell-viewport.py567
-rw-r--r--doorbell-viewport/handlers/main.yaml5
-rw-r--r--doorbell-viewport/tasks/main.yaml99
-rw-r--r--doorbell-viewport/templates/doorbell-viewport.env.j212
-rw-r--r--doorbell-viewport/templates/doorbell-viewport.service.j218
8 files changed, 1222 insertions, 0 deletions
diff --git a/doorbell-viewport/README.md b/doorbell-viewport/README.md
new file mode 100644
index 0000000..2de7861
--- /dev/null
+++ b/doorbell-viewport/README.md
@@ -0,0 +1,283 @@
+# doorbell-viewport
+
+Raspberry Pi 4 portrait touchscreen that shows a live UniFi Protect doorbell
+stream when the doorbell is pressed or the screen is touched. Display backlight
+is fully off at idle.
+
+## Hardware
+
+- Raspberry Pi 4 + PoE HAT
+- HDMI touchscreen (800×480, mounted portrait)
+- Wired Ethernet (PoE)
+- UniFi Protect PoE doorbell
+
+## Behavior
+
+| Trigger | From | To |
+|---------|------|----|
+| Doorbell ring | IDLE | ACTIVE |
+| Doorbell ring | ACTIVE | extend timer |
+| Touch | IDLE | ACTIVE |
+| Touch | ACTIVE | immediate OFF |
+| 45s timeout | ACTIVE | IDLE |
+
+IDLE: backlight fully off, no visible pixels.
+ACTIVE: backlight on, live RTSP stream playing fullscreen.
+
+---
+
+## UniFi Protect Setup
+
+### Create a local API user
+
+The daemon authenticates directly with UniFi Protect using a **local account**.
+UniFi SSO accounts (ubiquiti.com login) do not support API authentication and
+will not work.
+
+1. In UniFi OS, go to **OS Settings → Admins & Users → Users**
+2. Click **Add User**
+3. Fill in:
+ - **Username**: choose a service account name (e.g. `doorbell-viewport`)
+ - **Password**: generate a strong password (store in Ansible Vault)
+ - **Account Type**: Local Access Only
+4. Click **Add**
+
+### Assign the user a Protect role
+
+After creating the user:
+
+1. Go to **UniFi Protect → Settings → Manage → Admins**
+2. Find the user you created and click the pencil icon
+3. Set the Protect role to **View Only**
+
+View Only is sufficient. It grants:
+- API login (`POST /api/auth/login`)
+- Camera info and RTSP stream URLs (`GET /proxy/protect/api/cameras/{id}`)
+- Live WebSocket event stream including ring events
+
+### Get the camera ID
+
+You need the camera's ID (a 24-character hex string) to filter ring events.
+Run these two commands from any machine that can reach the Protect host:
+
+```bash
+curl -sk -c /tmp/prot.cookies \
+ -H 'Content-Type: application/json' \
+ -d '{"username":"doorbell-viewport","password":"YOUR_PASSWORD"}' \
+ https://YOUR_PROTECT_HOST/api/auth/login
+```
+
+```bash
+curl -sk -b /tmp/prot.cookies https://YOUR_PROTECT_HOST/proxy/protect/api/cameras | python3 -c "import sys,json; [print(c['id'], c.get('type',''), c['name']) for c in json.load(sys.stdin)]"
+```
+
+Output looks like:
+```
+695c1aa2002a1103e4003559 UVC-G4-Doorbell Front Door
+```
+
+Copy the ID of the doorbell camera and set it as `doorbell_viewport_camera_id`.
+
+### Verify access
+
+Once the role is deployed:
+
+```bash
+doorbell-viewport-debug test-protect
+```
+
+---
+
+## DRM/KMS and the Framebuffer Console
+
+This role uses mpv with `--vo=drm` — direct DRM/KMS rendering, no X11 or
+Wayland required.
+
+### DRM driver: use vc4-fkms-v3d (firmware KMS), not vc4-kms-v3d (full KMS)
+
+`vc4-fkms-v3d` uses the VideoCore GPU firmware for HDMI negotiation and mode
+setting. `vc4-kms-v3d` (full KMS) attempts atomic mode setting directly from
+the kernel, which on some displays causes a color-cycling test pattern that mpv
+cannot override. Use `vc4-fkms-v3d`. The Ansible role sets this automatically.
+
+On Ubuntu Server the kernel framebuffer console (`fbcon`) holds the DRM device
+while a getty is running on tty1. This will cause mpv to fail with a "device
+busy" error on first boot.
+
+### Fix: disable fbcon on HDMI
+
+Add `video=HDMI-A-1:D` to `/boot/firmware/cmdline.txt`. This tells the kernel
+not to bind the framebuffer console to the HDMI output, freeing the DRM device
+for mpv. Since this is an appliance with no local console, this is the correct
+permanent state.
+
+Edit the file on the Pi:
+
+```bash
+sudo sed -i 's/$/ video=HDMI-A-1:D/' /boot/firmware/cmdline.txt
+```
+
+The connector name (`HDMI-A-1`) is configurable via `doorbell_viewport_drm_connector` if your hardware uses a different name. The Ansible role manages this automatically.
+
+Then reboot. The HDMI output will be dark at the console from that point on,
+which is exactly what you want — the display is managed entirely by
+doorbell-viewport.
+
+Verify mpv can open the DRM device after reboot:
+
+```bash
+sudo -u doorbell-viewport mpv --vo=drm --length=3 /dev/zero
+```
+
+If it exits without a "device busy" error, DRM access is working.
+
+---
+
+## Configuration
+
+### host_vars (plain)
+
+```yaml
+doorbell_viewport_protect_host: "unifi.lan.example.com" # or IP address
+doorbell_viewport_camera_id: "abcdef1234567890abcdef12"
+doorbell_viewport_timeout: 45
+doorbell_viewport_touch_match: "" # substring match on evdev device name
+doorbell_viewport_prebuffer_mode: "warm" # warm | cold
+doorbell_viewport_display_backend: "vcgencmd" # vcgencmd | drm | panel
+doorbell_viewport_orientation: 270 # degrees: 90 | 180 | 270
+doorbell_viewport_drm_device: "/dev/dri/card1" # RPi4: card1; RPi3: card0
+doorbell_viewport_drm_connector: "HDMI-A-1"
+doorbell_viewport_drm_mode: "848x480" # nearest mode advertised by your display
+```
+
+### host_vars (vault)
+
+```yaml
+vault_doorbell_viewport_protect_username: "doorbell-viewport"
+vault_doorbell_viewport_protect_password: "secret"
+```
+
+Create and encrypt the vault file:
+
+```bash
+ansible-vault create inventory/host_vars/HOSTNAME/vault.yaml
+```
+
+---
+
+## Display Backends
+
+### vcgencmd (default)
+
+Uses `vcgencmd display_power 0/1` (Raspberry Pi firmware command).
+Cuts the HDMI signal. Displays that support DPMS will cut their backlight.
+
+```yaml
+doorbell_viewport_display_backend: "vcgencmd"
+```
+
+### drm
+
+Writes `0` / `max_brightness` to the first device under `/sys/class/backlight/`.
+Use when the display exposes a sysfs backlight node.
+
+```yaml
+doorbell_viewport_display_backend: "drm"
+```
+
+Check available devices on the Pi: `ls /sys/class/backlight/`
+
+### panel
+
+Alias for `drm`. Use when the backlight path is panel-specific and the `drm`
+label would be confusing.
+
+### Testing
+
+```bash
+doorbell-viewport-debug test-display # on -> 3s -> off
+```
+
+---
+
+## Prebuffer Modes
+
+### warm (default, recommended)
+
+mpv starts at boot and continuously decodes the RTSP stream, rendering to the
+framebuffer while the display stays off. Activation just turns on the backlight
+— essentially zero latency. The live frame is already there.
+
+```yaml
+doorbell_viewport_prebuffer_mode: "warm"
+```
+
+### cold
+
+mpv is launched only when the display activates, then stopped when idle. Higher
+activation latency (stream connect + first keyframe decode). Use as fallback if
+warm mode causes problems (power draw, memory pressure).
+
+```yaml
+doorbell_viewport_prebuffer_mode: "cold"
+```
+
+---
+
+## Touch Discovery
+
+The daemon discovers the touch device via evdev at startup and re-discovers it
+automatically if it disappears and re-enumerates (e.g. USB replug).
+
+If `doorbell_viewport_touch_match` is set, the daemon matches on any device
+whose name contains that substring (case-insensitive). Otherwise it finds the
+first device advertising `ABS_MT_POSITION_X` (multitouch absolute position).
+
+To find the right match string for your display:
+
+```bash
+doorbell-viewport-debug test-touch
+```
+
+Example output:
+```
+/dev/input/event0: 'WaveShare WS170120' (multitouch=True, btn_touch=True)
+```
+
+```yaml
+doorbell_viewport_touch_match: "waveshare"
+```
+
+---
+
+## Debug Commands
+
+```bash
+doorbell-viewport-debug show # turn display on
+doorbell-viewport-debug hide # turn display off
+doorbell-viewport-debug test-display # power cycle: on -> 3s -> off
+doorbell-viewport-debug test-touch # list touch devices
+doorbell-viewport-debug test-stream # fetch RTSP URL and play via mpv
+doorbell-viewport-debug test-protect # verify Protect API + camera info
+```
+
+---
+
+## Logs
+
+```bash
+journalctl -u doorbell-viewport -f
+```
+
+---
+
+## Dependencies
+
+Installed by the role:
+
+| Package | Purpose |
+|---------|---------|
+| `mpv` | DRM/KMS video playback |
+| `python3-evdev` | Touch input via evdev |
+| `python3-requests` | UniFi Protect HTTP API |
+| `python3-websockets` | UniFi Protect WebSocket event stream |
diff --git a/doorbell-viewport/defaults/main.yaml b/doorbell-viewport/defaults/main.yaml
new file mode 100644
index 0000000..8f75e64
--- /dev/null
+++ b/doorbell-viewport/defaults/main.yaml
@@ -0,0 +1,16 @@
+---
+
+doorbell_viewport_user: "doorbell-viewport"
+doorbell_viewport_protect_host: ""
+doorbell_viewport_camera_id: ""
+doorbell_viewport_timeout: 45
+doorbell_viewport_touch_match: ""
+doorbell_viewport_prebuffer_mode: "warm"
+doorbell_viewport_display_backend: "vcgencmd"
+doorbell_viewport_orientation: 270
+doorbell_viewport_drm_connector: "HDMI-A-1"
+doorbell_viewport_drm_device: "/dev/dri/card1"
+doorbell_viewport_drm_mode: "848x480"
+
+# vault_doorbell_viewport_protect_username: — required, set in host_vars
+# vault_doorbell_viewport_protect_password: — required, set in host_vars
diff --git a/doorbell-viewport/files/doorbell-viewport-debug b/doorbell-viewport/files/doorbell-viewport-debug
new file mode 100644
index 0000000..36989df
--- /dev/null
+++ b/doorbell-viewport/files/doorbell-viewport-debug
@@ -0,0 +1,222 @@
+#!/bin/bash
+# doorbell-viewport-debug: CLI debug tool
+#
+# Commands:
+# show Turn display on
+# hide Turn display off
+# test-display Test display power cycle (on -> 3s -> off)
+# test-touch List touch devices and capabilities
+# test-stream Fetch RTSP URL from Protect and play via mpv
+# test-protect Test Protect API authentication and camera info
+
+set -e
+
+ENV_FILE="/etc/doorbell-viewport/doorbell-viewport.env"
+CMD="${1:-help}"
+
+_load_env() {
+ if [ -f "$ENV_FILE" ]; then
+ set -a
+ # shellcheck disable=SC1090
+ source "$ENV_FILE"
+ set +a
+ else
+ echo "Warning: $ENV_FILE not found" >&2
+ fi
+}
+
+_display_on() {
+ case "${DOORBELL_VIEWPORT_DISPLAY_BACKEND:-vcgencmd}" in
+ vcgencmd)
+ vcgencmd display_power 1
+ ;;
+ drm|panel)
+ BACKLIGHT=$(ls /sys/class/backlight/ 2>/dev/null | head -1)
+ if [ -n "$BACKLIGHT" ]; then
+ MAX=$(cat "/sys/class/backlight/$BACKLIGHT/max_brightness")
+ echo "$MAX" > "/sys/class/backlight/$BACKLIGHT/brightness"
+ else
+ echo "No backlight device found" >&2
+ fi
+ ;;
+ esac
+}
+
+_display_off() {
+ case "${DOORBELL_VIEWPORT_DISPLAY_BACKEND:-vcgencmd}" in
+ vcgencmd)
+ vcgencmd display_power 0
+ ;;
+ drm|panel)
+ BACKLIGHT=$(ls /sys/class/backlight/ 2>/dev/null | head -1)
+ if [ -n "$BACKLIGHT" ]; then
+ echo "0" > "/sys/class/backlight/$BACKLIGHT/brightness"
+ else
+ echo "No backlight device found" >&2
+ fi
+ ;;
+ esac
+}
+
+cmd_show() {
+ _load_env
+ echo "Turning display on..."
+ _display_on
+ echo "Display on"
+}
+
+cmd_hide() {
+ _load_env
+ echo "Turning display off..."
+ _display_off
+ echo "Display off"
+}
+
+cmd_test_display() {
+ _load_env
+ echo "Testing display power cycle..."
+ echo "ON..."
+ _display_on
+ sleep 3
+ echo "OFF..."
+ _display_off
+ echo "Done"
+}
+
+cmd_test_touch() {
+ echo "Touch devices:"
+ python3 - <<'PYEOF'
+import evdev
+for path in evdev.list_devices():
+ try:
+ dev = evdev.InputDevice(path)
+ caps = dev.capabilities()
+ has_mt = (
+ evdev.ecodes.EV_ABS in caps
+ and any(
+ code == evdev.ecodes.ABS_MT_POSITION_X
+ for code, _ in caps[evdev.ecodes.EV_ABS]
+ )
+ )
+ has_btn = (
+ evdev.ecodes.EV_KEY in caps
+ and evdev.ecodes.BTN_TOUCH in [code for code, _ in caps.get(evdev.ecodes.EV_KEY, [])]
+ )
+ print(f" {path}: {dev.name!r} (multitouch={has_mt}, btn_touch={has_btn})")
+ dev.close()
+ except Exception as e:
+ print(f" {path}: error: {e}")
+PYEOF
+ echo ""
+ echo "To monitor live touch events interactively:"
+ echo " python3 -m evdev.evtest"
+}
+
+cmd_test_stream() {
+ _load_env
+ if [ -z "$DOORBELL_VIEWPORT_PROTECT_HOST" ]; then
+ echo "Error: DOORBELL_VIEWPORT_PROTECT_HOST not set" >&2
+ exit 1
+ fi
+
+ echo "Fetching RTSP URL from $DOORBELL_VIEWPORT_PROTECT_HOST..."
+ RTSP_URL=$(python3 - <<PYEOF
+import os, sys, requests, urllib3
+urllib3.disable_warnings()
+host = os.environ['DOORBELL_VIEWPORT_PROTECT_HOST']
+user = os.environ['DOORBELL_VIEWPORT_PROTECT_USERNAME']
+passwd = os.environ['DOORBELL_VIEWPORT_PROTECT_PASSWORD']
+camera_id = os.environ['DOORBELL_VIEWPORT_CAMERA_ID']
+s = requests.Session()
+s.verify = False
+r = s.post(f'https://{host}/api/auth/login',
+ json={'username': user, 'password': passwd}, timeout=10)
+r.raise_for_status()
+r2 = s.get(f'https://{host}/proxy/protect/api/cameras/{camera_id}', timeout=10)
+r2.raise_for_status()
+for ch in r2.json().get('channels', []):
+ if ch.get('isRtspEnabled') and ch.get('rtspAlias'):
+ print(f"rtsp://{host}:7447/{ch['rtspAlias']}")
+ sys.exit(0)
+print('no-rtsp-url', file=sys.stderr)
+sys.exit(1)
+PYEOF
+ )
+
+ if [ -z "$RTSP_URL" ]; then
+ echo "Failed to get RTSP URL" >&2
+ exit 1
+ fi
+
+ echo "Playing: $RTSP_URL"
+ echo "(press q to quit)"
+ mpv \
+ --vo=drm \
+ "--video-rotate=${DOORBELL_VIEWPORT_ORIENTATION:-270}" \
+ --fullscreen \
+ --no-border \
+ --no-osc \
+ --no-input-default-bindings \
+ --really-quiet \
+ "$RTSP_URL"
+}
+
+cmd_test_protect() {
+ _load_env
+ if [ -z "$DOORBELL_VIEWPORT_PROTECT_HOST" ]; then
+ echo "Error: DOORBELL_VIEWPORT_PROTECT_HOST not set" >&2
+ exit 1
+ fi
+
+ python3 - <<PYEOF
+import os, requests, urllib3
+urllib3.disable_warnings()
+host = os.environ['DOORBELL_VIEWPORT_PROTECT_HOST']
+user = os.environ['DOORBELL_VIEWPORT_PROTECT_USERNAME']
+passwd = os.environ['DOORBELL_VIEWPORT_PROTECT_PASSWORD']
+camera_id = os.environ['DOORBELL_VIEWPORT_CAMERA_ID']
+s = requests.Session()
+s.verify = False
+
+print(f"Connecting to {host}...")
+r = s.post(f'https://{host}/api/auth/login',
+ json={'username': user, 'password': passwd}, timeout=10)
+r.raise_for_status()
+print("Authentication: OK")
+
+r2 = s.get(f'https://{host}/proxy/protect/api/cameras/{camera_id}', timeout=10)
+r2.raise_for_status()
+data = r2.json()
+print(f"Camera name: {data.get('name', 'unknown')}")
+print(f"Camera type: {data.get('type', 'unknown')}")
+channels = [ch for ch in data.get('channels', []) if ch.get('isRtspEnabled')]
+print(f"RTSP channels: {len(channels)}")
+for ch in channels:
+ name = ch.get('name', '')
+ alias = ch.get('rtspAlias', '')
+ print(f" {name}: rtsp://{host}:7447/{alias}")
+print("Protect connection: OK")
+PYEOF
+}
+
+case "$CMD" in
+ show) cmd_show ;;
+ hide) cmd_hide ;;
+ test-display) cmd_test_display ;;
+ test-touch) cmd_test_touch ;;
+ test-stream) cmd_test_stream ;;
+ test-protect) cmd_test_protect ;;
+ help|*)
+ echo "Usage: doorbell-viewport-debug <command>"
+ echo ""
+ echo "Commands:"
+ echo " show Turn display on"
+ echo " hide Turn display off"
+ echo " test-display Test display power cycle (on -> 3s -> off)"
+ echo " test-touch List touch devices and capabilities"
+ echo " test-stream Fetch RTSP URL and play via mpv"
+ echo " test-protect Test Protect API connection and camera info"
+ echo ""
+ echo "Config: $ENV_FILE"
+ ;;
+esac
diff --git a/doorbell-viewport/files/doorbell-viewport.py b/doorbell-viewport/files/doorbell-viewport.py
new file mode 100644
index 0000000..1bdcc29
--- /dev/null
+++ b/doorbell-viewport/files/doorbell-viewport.py
@@ -0,0 +1,567 @@
+#!/usr/bin/env python3
+"""
+doorbell-viewport: UniFi Protect doorbell display daemon
+
+Connects to UniFi Protect, listens for doorbell ring events, and manages
+display power + video playback on a Raspberry Pi portrait touchscreen.
+
+States:
+ IDLE — display backlight off, no playback
+ ACTIVE — display on, live RTSP stream playing via mpv
+
+Transitions:
+ ring event : IDLE -> ACTIVE (or extend timer if already ACTIVE)
+ touch (idle) : IDLE -> ACTIVE
+ touch (active): ACTIVE -> IDLE (immediate)
+ timeout : ACTIVE -> IDLE (after doorbell_viewport_timeout seconds)
+"""
+
+import asyncio
+import json
+import logging
+import os
+import signal
+import ssl
+import struct
+import subprocess
+import sys
+import time
+import zlib
+from enum import Enum
+from pathlib import Path
+
+import evdev
+import requests
+import urllib3
+import websockets
+
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(message)s",
+ stream=sys.stdout,
+)
+log = logging.getLogger("doorbell-viewport")
+
+
+class State(Enum):
+ IDLE = "idle"
+ ACTIVE = "active"
+
+
+class Config:
+ def __init__(self):
+ self.protect_host = os.environ["DOORBELL_VIEWPORT_PROTECT_HOST"]
+ self.protect_username = os.environ["DOORBELL_VIEWPORT_PROTECT_USERNAME"]
+ self.protect_password = os.environ["DOORBELL_VIEWPORT_PROTECT_PASSWORD"]
+ self.camera_id = os.environ["DOORBELL_VIEWPORT_CAMERA_ID"]
+ self.timeout = int(os.environ.get("DOORBELL_VIEWPORT_TIMEOUT", "45"))
+ self.touch_match = os.environ.get("DOORBELL_VIEWPORT_TOUCH_MATCH", "")
+ self.prebuffer_mode = os.environ.get("DOORBELL_VIEWPORT_PREBUFFER_MODE", "warm")
+ self.display_backend = os.environ.get("DOORBELL_VIEWPORT_DISPLAY_BACKEND", "vcgencmd")
+ self.orientation = int(os.environ.get("DOORBELL_VIEWPORT_ORIENTATION", "270"))
+ self.drm_device = os.environ.get("DOORBELL_VIEWPORT_DRM_DEVICE", "/dev/dri/card1")
+ self.drm_connector = os.environ.get("DOORBELL_VIEWPORT_DRM_CONNECTOR", "HDMI-A-1")
+ self.drm_mode = os.environ.get("DOORBELL_VIEWPORT_DRM_MODE", "848x480")
+ self.rtsp_url = None
+
+ def log_config(self):
+ log.info(
+ "Config: protect_host=%s camera_id=%s timeout=%ds "
+ "prebuffer_mode=%s display_backend=%s orientation=%d "
+ "drm_device=%s drm_connector=%s drm_mode=%s",
+ self.protect_host,
+ self.camera_id,
+ self.timeout,
+ self.prebuffer_mode,
+ self.display_backend,
+ self.orientation,
+ self.drm_device,
+ self.drm_connector,
+ self.drm_mode,
+ )
+
+
+class DisplayController:
+ """
+ Controls display backlight power.
+
+ Backends:
+ vcgencmd — vcgencmd display_power 0/1 (Raspberry Pi firmware)
+ drm — /sys/class/backlight sysfs (DRM/KMS kernel interface)
+ panel — /sys/class/backlight sysfs (alias for drm, panel-specific path)
+ """
+
+ def __init__(self, backend: str):
+ self.backend = backend
+ log.info("Display backend: %s", backend)
+
+ def on(self):
+ log.info("Display power: ON [backend=%s]", self.backend)
+ try:
+ if self.backend == "vcgencmd":
+ subprocess.run(
+ ["vcgencmd", "display_power", "1"],
+ check=True,
+ capture_output=True,
+ )
+ elif self.backend in ("drm", "panel"):
+ self._sysfs_set(True)
+ else:
+ log.warning("Unknown display backend: %s", self.backend)
+ except Exception as exc:
+ log.error("Display ON failed: %s", exc)
+
+ def off(self):
+ log.info("Display power: OFF [backend=%s]", self.backend)
+ try:
+ if self.backend == "vcgencmd":
+ subprocess.run(
+ ["vcgencmd", "display_power", "0"],
+ check=True,
+ capture_output=True,
+ )
+ elif self.backend in ("drm", "panel"):
+ self._sysfs_set(False)
+ else:
+ log.warning("Unknown display backend: %s", self.backend)
+ except Exception as exc:
+ log.error("Display OFF failed: %s", exc)
+
+ def _sysfs_set(self, enabled: bool):
+ paths = sorted(Path("/sys/class/backlight").glob("*"))
+ if not paths:
+ log.warning("No backlight device found in /sys/class/backlight")
+ return
+ path = paths[0]
+ log.debug("Backlight device: %s", path)
+ if enabled:
+ try:
+ max_b = int((path / "max_brightness").read_text().strip())
+ (path / "brightness").write_text(str(max_b))
+ except Exception as exc:
+ log.error("Backlight on failed: %s", exc)
+ else:
+ try:
+ (path / "brightness").write_text("0")
+ except Exception as exc:
+ log.error("Backlight off failed: %s", exc)
+
+
+class ProtectClient:
+ """Handles UniFi Protect authentication and camera info retrieval."""
+
+ def __init__(self, config: Config):
+ self.config = config
+ self.session = requests.Session()
+ self.session.verify = False
+ self.base_url = f"https://{config.protect_host}"
+
+ def authenticate(self) -> bool:
+ log.info("Protect: authenticating at %s", self.config.protect_host)
+ try:
+ resp = self.session.post(
+ f"{self.base_url}/api/auth/login",
+ json={
+ "username": self.config.protect_username,
+ "password": self.config.protect_password,
+ },
+ timeout=10,
+ )
+ resp.raise_for_status()
+ log.info("Protect: authentication successful")
+ return True
+ except Exception as exc:
+ log.error("Protect: authentication failed: %s", exc)
+ return False
+
+ def get_camera_rtsp_url(self) -> str | None:
+ log.info("Protect: fetching camera info for id=%s", self.config.camera_id)
+ try:
+ resp = self.session.get(
+ f"{self.base_url}/proxy/protect/api/cameras/{self.config.camera_id}",
+ timeout=10,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ for channel in data.get("channels", []):
+ if channel.get("isRtspEnabled"):
+ alias = channel.get("rtspAlias")
+ if alias:
+ url = f"rtsp://{self.config.protect_host}:7447/{alias}"
+ log.info("Protect: RTSP URL: %s", url)
+ return url
+ log.error("Protect: no enabled RTSP channel for camera %s", self.config.camera_id)
+ return None
+ except Exception as exc:
+ log.error("Protect: failed to fetch camera info: %s", exc)
+ return None
+
+ def ws_headers(self) -> dict:
+ cookies = self.session.cookies.get_dict()
+ return {"Cookie": "; ".join(f"{k}={v}" for k, v in cookies.items())}
+
+
+def decode_protect_packet(data: bytes) -> dict | None:
+ """
+ Decode a binary UniFi Protect WebSocket packet.
+
+ Header (8 bytes):
+ [0] packet_type 1=action, 2=data
+ [1] payload_format 1=JSON, 2=UTF8, 3=buffer
+ [2] deflated 0 or 1
+ [3] unused
+ [4:8] payload_size big-endian uint32
+
+ Followed by payload_size bytes of payload.
+ """
+ if len(data) < 8:
+ return None
+ packet_type = data[0]
+ payload_format = data[1]
+ deflated = bool(data[2])
+ payload_size = struct.unpack(">I", data[4:8])[0]
+ payload_bytes = data[8:8 + payload_size]
+ if deflated:
+ try:
+ payload_bytes = zlib.decompress(payload_bytes)
+ except Exception as exc:
+ log.debug("Packet decompression failed: %s", exc)
+ return None
+ if payload_format in (1, 2):
+ try:
+ return {
+ "packet_type": packet_type,
+ "payload": json.loads(payload_bytes),
+ }
+ except Exception:
+ return None
+ return None
+
+
+class DoorbellViewport:
+ def __init__(self, config: Config):
+ self.config = config
+ self.state = State.IDLE
+ self.display = DisplayController(config.display_backend)
+ self.protect = ProtectClient(config)
+ self.mpv_proc = None
+ self.timer_task = None
+ self._running = True
+
+ async def run(self):
+ log.info("doorbell-viewport starting")
+ self.config.log_config()
+
+ # Ensure display starts off regardless of prior state
+ self.display.off()
+
+ # Authenticate and obtain RTSP URL
+ loop = asyncio.get_event_loop()
+ ok = await loop.run_in_executor(None, self.protect.authenticate)
+ if ok:
+ url = await loop.run_in_executor(None, self.protect.get_camera_rtsp_url)
+ if url:
+ self.config.rtsp_url = url
+
+ if not self.config.rtsp_url:
+ log.warning("No RTSP URL at startup; will retry after reconnect")
+
+ # Warm prebuffer: mpv starts immediately, rendering to framebuffer
+ # while display remains off — zero-latency activation later
+ if self.config.prebuffer_mode == "warm" and self.config.rtsp_url:
+ await self.start_mpv()
+ log.info("Warm prebuffer active: mpv running, display off")
+
+ await asyncio.gather(
+ self.protect_listener(),
+ self.touch_listener(),
+ )
+
+ async def activate(self):
+ """Transition IDLE->ACTIVE, or extend timer if already ACTIVE."""
+ was_idle = self.state == State.IDLE
+ # Set state before any await so concurrent coroutines see updated state
+ self.state = State.ACTIVE
+
+ if self.timer_task and not self.timer_task.done():
+ self.timer_task.cancel()
+ self.timer_task = asyncio.create_task(self._timeout_task())
+
+ if was_idle:
+ log.info("State: IDLE -> ACTIVE")
+ if self.config.prebuffer_mode == "warm":
+ # mpv already buffering; just enable the display
+ self.display.on()
+ else:
+ await self.start_mpv()
+ self.display.on()
+ else:
+ log.info("State: ACTIVE -> timer extended")
+
+ async def deactivate(self):
+ """Transition ACTIVE->IDLE: immediately stop playback and kill display."""
+ if self.state == State.IDLE:
+ return
+ # Set state before any await
+ self.state = State.IDLE
+ log.info("State: ACTIVE -> IDLE")
+
+ if self.timer_task and not self.timer_task.done():
+ self.timer_task.cancel()
+ self.timer_task = None
+
+ self.display.off()
+ if self.config.prebuffer_mode != "warm":
+ await self.stop_mpv()
+
+ async def on_ring(self):
+ log.info("Event: doorbell ring")
+ await self.activate()
+
+ async def on_touch(self):
+ log.info("Event: touch input (state=%s)", self.state.value)
+ if self.state == State.IDLE:
+ await self.activate()
+ else:
+ await self.deactivate()
+
+ async def _timeout_task(self):
+ await asyncio.sleep(self.config.timeout)
+ log.info("Timeout: %ds elapsed", self.config.timeout)
+ await self.deactivate()
+
+ async def start_mpv(self):
+ if self.mpv_proc and self.mpv_proc.returncode is None:
+ log.debug("mpv already running (pid=%d)", self.mpv_proc.pid)
+ return
+ if not self.config.rtsp_url:
+ log.error("Cannot start mpv: no RTSP URL")
+ return
+
+ cmd = [
+ "mpv",
+ "--vo=drm",
+ f"--drm-device={self.config.drm_device}",
+ f"--drm-connector={self.config.drm_connector}",
+ f"--drm-mode={self.config.drm_mode}",
+ f"--video-rotate={self.config.orientation}",
+ "--fullscreen",
+ "--no-border",
+ "--no-osc",
+ "--no-input-default-bindings",
+ "--no-config",
+ "--really-quiet",
+ "--loop=inf",
+ "--cache=yes",
+ "--cache-secs=3",
+ "--demuxer-max-bytes=50M",
+ "--hwdec=auto",
+ self.config.rtsp_url,
+ ]
+ log.info("Starting mpv: %s", " ".join(cmd))
+ try:
+ self.mpv_proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ log.info("mpv started (pid=%d)", self.mpv_proc.pid)
+ asyncio.create_task(self._watch_mpv())
+ except Exception as exc:
+ log.error("Failed to start mpv: %s", exc)
+
+ async def stop_mpv(self):
+ if not self.mpv_proc or self.mpv_proc.returncode is not None:
+ return
+ pid = self.mpv_proc.pid
+ log.info("Stopping mpv (pid=%d)", pid)
+ try:
+ self.mpv_proc.terminate()
+ await asyncio.wait_for(self.mpv_proc.wait(), timeout=3.0)
+ except asyncio.TimeoutError:
+ log.warning("mpv (pid=%d) did not terminate cleanly, killing", pid)
+ self.mpv_proc.kill()
+ await self.mpv_proc.wait()
+ self.mpv_proc = None
+
+ async def _watch_mpv(self):
+ """Restart mpv once on unexpected exit."""
+ proc = self.mpv_proc
+ if not proc:
+ return
+ await proc.wait()
+ rc = proc.returncode
+ if not self._running:
+ return
+ if rc != 0:
+ log.warning("mpv exited unexpectedly (rc=%d)", rc)
+ if self.mpv_proc is proc:
+ self.mpv_proc = None
+ await asyncio.sleep(2)
+ if self.config.prebuffer_mode == "warm" or self.state == State.ACTIVE:
+ log.info("Restarting mpv after unexpected exit")
+ await self.start_mpv()
+
+ async def protect_listener(self):
+ """Maintain UniFi Protect WebSocket connection with exponential backoff."""
+ backoff = 1
+ while self._running:
+ try:
+ await self._connect_protect_ws()
+ backoff = 1
+ except Exception as exc:
+ log.error("Protect WebSocket error: %s", exc)
+ if not self._running:
+ break
+ log.info("Protect: reconnecting in %ds", backoff)
+ await asyncio.sleep(backoff)
+ backoff = min(backoff * 2, 60)
+ loop = asyncio.get_event_loop()
+ ok = await loop.run_in_executor(None, self.protect.authenticate)
+ if ok and not self.config.rtsp_url:
+ url = await loop.run_in_executor(None, self.protect.get_camera_rtsp_url)
+ if url:
+ self.config.rtsp_url = url
+ if self.config.prebuffer_mode == "warm":
+ await self.start_mpv()
+
+ async def _connect_protect_ws(self):
+ log.info("Protect: connecting to WebSocket")
+ ssl_ctx = ssl.create_default_context()
+ ssl_ctx.check_hostname = False
+ ssl_ctx.verify_mode = ssl.CERT_NONE
+ ws_url = f"wss://{self.config.protect_host}/proxy/protect/ws/updates"
+ headers = self.protect.ws_headers()
+
+ async with websockets.connect(
+ ws_url,
+ additional_headers=headers,
+ ssl=ssl_ctx,
+ ping_interval=20,
+ ping_timeout=10,
+ ) as ws:
+ log.info("Protect: WebSocket connected")
+ pending_action = None
+ async for msg in ws:
+ if isinstance(msg, bytes):
+ pkt = decode_protect_packet(msg)
+ if not pkt:
+ continue
+ if pkt["packet_type"] == 1:
+ pending_action = pkt["payload"]
+ elif pkt["packet_type"] == 2 and pending_action is not None:
+ await self._handle_protect_event(pending_action, pkt["payload"])
+ pending_action = None
+
+ async def _handle_protect_event(self, action: dict, data: dict):
+ if action.get("action") != "add":
+ return
+ if action.get("modelKey") != "event":
+ return
+ event_type = data.get("type")
+ # 'camera' field varies by Protect firmware version
+ camera = data.get("camera") or data.get("cameraId") or ""
+ if event_type == "ring" and camera == self.config.camera_id:
+ log.info("Protect: ring event from camera %s", camera)
+ await self.on_ring()
+
+ async def touch_listener(self):
+ """Monitor evdev touch input with automatic device re-discovery."""
+ while self._running:
+ try:
+ await self._monitor_touch_device()
+ except Exception as exc:
+ log.error("Touch device error: %s", exc)
+ if not self._running:
+ break
+ log.info("Touch: retrying device discovery in 10s")
+ await asyncio.sleep(10)
+
+ def _find_touch_device_sync(self):
+ """Find touch device by name match or multitouch capability."""
+ match = self.config.touch_match.lower()
+ for path in evdev.list_devices():
+ try:
+ dev = evdev.InputDevice(path)
+ if match and match in dev.name.lower():
+ log.info("Touch: matched by name: %s (%s)", dev.name, path)
+ return dev
+ # Fallback: any device advertising multitouch absolute axes
+ caps = dev.capabilities()
+ if evdev.ecodes.EV_ABS in caps:
+ axis_codes = [code for code, _ in caps[evdev.ecodes.EV_ABS]]
+ if evdev.ecodes.ABS_MT_POSITION_X in axis_codes:
+ log.info("Touch: matched by capability: %s (%s)", dev.name, path)
+ return dev
+ dev.close()
+ except Exception:
+ continue
+ return None
+
+ async def _monitor_touch_device(self):
+ loop = asyncio.get_event_loop()
+ dev = await loop.run_in_executor(None, self._find_touch_device_sync)
+ if not dev:
+ log.warning("Touch: no device found")
+ await asyncio.sleep(10)
+ return
+
+ log.info("Touch: monitoring %s (%s)", dev.name, dev.path)
+ last_touch_time = 0.0
+ try:
+ async for event in dev.async_read_loop():
+ if not self._running:
+ break
+ triggered = False
+ if event.type == evdev.ecodes.EV_ABS:
+ # Multitouch: new contact (tracking ID assigned)
+ if (event.code == evdev.ecodes.ABS_MT_TRACKING_ID
+ and event.value >= 0):
+ triggered = True
+ elif event.type == evdev.ecodes.EV_KEY:
+ # Single-touch BTN_TOUCH press
+ if event.code == evdev.ecodes.BTN_TOUCH and event.value == 1:
+ triggered = True
+ if triggered:
+ now = time.monotonic()
+ if now - last_touch_time > 0.5: # 500ms debounce
+ last_touch_time = now
+ await self.on_touch()
+ finally:
+ dev.close()
+
+
+async def main():
+ try:
+ config = Config()
+ except KeyError as exc:
+ log.error("Missing required environment variable: %s", exc)
+ sys.exit(1)
+
+ viewport = DoorbellViewport(config)
+ loop = asyncio.get_event_loop()
+
+ def shutdown(sig):
+ log.info("Signal %s received, shutting down", sig.name)
+ viewport._running = False
+ for task in asyncio.all_tasks(loop):
+ task.cancel()
+
+ for sig in (signal.SIGTERM, signal.SIGINT):
+ loop.add_signal_handler(sig, lambda s=sig: shutdown(s))
+
+ try:
+ await viewport.run()
+ except asyncio.CancelledError:
+ pass
+ finally:
+ log.info("Shutting down")
+ await viewport.stop_mpv()
+ viewport.display.off()
+ log.info("doorbell-viewport stopped")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/doorbell-viewport/handlers/main.yaml b/doorbell-viewport/handlers/main.yaml
new file mode 100644
index 0000000..18fec0f
--- /dev/null
+++ b/doorbell-viewport/handlers/main.yaml
@@ -0,0 +1,5 @@
+---
+
+- name: restart doorbell-viewport
+ become: yes
+ systemd: name="doorbell-viewport.service" state="restarted" daemon_reload="yes"
diff --git a/doorbell-viewport/tasks/main.yaml b/doorbell-viewport/tasks/main.yaml
new file mode 100644
index 0000000..cb27924
--- /dev/null
+++ b/doorbell-viewport/tasks/main.yaml
@@ -0,0 +1,99 @@
+---
+
+- name: add doorbell-viewport user
+ become: yes
+ user:
+ name: "{{doorbell_viewport_user}}"
+ shell: "/bin/false"
+ system: "yes"
+ create_home: "no"
+ groups: "video,input,render"
+ append: "yes"
+
+- name: apt install doorbell-viewport dependencies
+ become: yes
+ apt: name="{{item}}"
+ notify: restart doorbell-viewport
+ loop:
+ - "mpv"
+ - "python3-evdev"
+ - "python3-requests"
+ - "python3-websockets"
+
+- name: create doorbell-viewport config dir
+ become: yes
+ file:
+ path: "/etc/doorbell-viewport"
+ owner: "root"
+ group: "{{doorbell_viewport_user}}"
+ mode: "0750"
+ state: "directory"
+
+- name: install doorbell-viewport daemon
+ become: yes
+ copy:
+ src: "doorbell-viewport.py"
+ dest: "/usr/local/bin/doorbell-viewport"
+ owner: "root"
+ group: "root"
+ mode: "0755"
+ notify: restart doorbell-viewport
+
+- name: install doorbell-viewport debug tool
+ become: yes
+ copy:
+ src: "doorbell-viewport-debug"
+ dest: "/usr/local/bin/doorbell-viewport-debug"
+ owner: "root"
+ group: "root"
+ mode: "0755"
+
+- name: configure doorbell-viewport environment
+ become: yes
+ template:
+ src: "doorbell-viewport.env.j2"
+ dest: "/etc/doorbell-viewport/doorbell-viewport.env"
+ owner: "root"
+ group: "{{doorbell_viewport_user}}"
+ mode: "0640"
+ notify: restart doorbell-viewport
+ no_log: true
+
+- name: install doorbell-viewport systemd service
+ become: yes
+ template:
+ src: "doorbell-viewport.service.j2"
+ dest: "/lib/systemd/system/doorbell-viewport.service"
+ mode: "0644"
+ notify: restart doorbell-viewport
+
+- name: enable vc4 KMS DRM driver
+ become: yes
+ lineinfile:
+ path: "/boot/firmware/config.txt"
+ line: "dtoverlay=vc4-fkms-v3d"
+ regexp: "^dtoverlay=vc4-(f?)kms-v3d"
+ register: vc4_result
+
+- name: remind to reboot for vc4 DRM driver to take effect
+ debug:
+ msg: "config.txt updated — reboot {{inventory_hostname}} for dtoverlay=vc4-kms-v3d to take effect"
+ when: vc4_result.changed
+
+- name: disable fbcon on HDMI to free DRM device for mpv
+ become: yes
+ lineinfile:
+ path: "/boot/firmware/cmdline.txt"
+ backrefs: yes
+ regexp: "^((?!.*video={{doorbell_viewport_drm_connector}}:D).*)$"
+ line: "\\1 video={{doorbell_viewport_drm_connector}}:D"
+ register: cmdline_result
+
+- name: remind to reboot for cmdline.txt change to take effect
+ debug:
+ msg: "cmdline.txt updated — reboot {{inventory_hostname}} for video={{doorbell_viewport_drm_connector}}:D to take effect"
+ when: cmdline_result.changed
+
+- name: ensure doorbell-viewport is started
+ become: yes
+ systemd: name="doorbell-viewport.service" enabled="yes" state="started" daemon_reload="yes"
diff --git a/doorbell-viewport/templates/doorbell-viewport.env.j2 b/doorbell-viewport/templates/doorbell-viewport.env.j2
new file mode 100644
index 0000000..3863dbc
--- /dev/null
+++ b/doorbell-viewport/templates/doorbell-viewport.env.j2
@@ -0,0 +1,12 @@
+DOORBELL_VIEWPORT_PROTECT_HOST={{doorbell_viewport_protect_host}}
+DOORBELL_VIEWPORT_PROTECT_USERNAME={{vault_doorbell_viewport_protect_username}}
+DOORBELL_VIEWPORT_PROTECT_PASSWORD={{vault_doorbell_viewport_protect_password}}
+DOORBELL_VIEWPORT_CAMERA_ID={{doorbell_viewport_camera_id}}
+DOORBELL_VIEWPORT_TIMEOUT={{doorbell_viewport_timeout}}
+DOORBELL_VIEWPORT_TOUCH_MATCH={{doorbell_viewport_touch_match}}
+DOORBELL_VIEWPORT_PREBUFFER_MODE={{doorbell_viewport_prebuffer_mode}}
+DOORBELL_VIEWPORT_DISPLAY_BACKEND={{doorbell_viewport_display_backend}}
+DOORBELL_VIEWPORT_ORIENTATION={{doorbell_viewport_orientation}}
+DOORBELL_VIEWPORT_DRM_DEVICE={{doorbell_viewport_drm_device}}
+DOORBELL_VIEWPORT_DRM_CONNECTOR={{doorbell_viewport_drm_connector}}
+DOORBELL_VIEWPORT_DRM_MODE={{doorbell_viewport_drm_mode}}
diff --git a/doorbell-viewport/templates/doorbell-viewport.service.j2 b/doorbell-viewport/templates/doorbell-viewport.service.j2
new file mode 100644
index 0000000..01cce45
--- /dev/null
+++ b/doorbell-viewport/templates/doorbell-viewport.service.j2
@@ -0,0 +1,18 @@
+[Unit]
+Description=Doorbell Viewport
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User={{doorbell_viewport_user}}
+Group={{doorbell_viewport_user}}
+EnvironmentFile=/etc/doorbell-viewport/doorbell-viewport.env
+ExecStart=/usr/local/bin/doorbell-viewport
+Restart=always
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target