diff options
| author | Luke Hoersten <[email protected]> | 2026-04-12 11:52:58 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-04-12 11:52:58 -0500 |
| commit | c528cf7268463c84a050129665ce08814fd1d420 (patch) | |
| tree | 43414ad05f9923b0abc1cfe7b48b6da33169534e /doorbell-viewport | |
| parent | 121f23c67f10a0ea3e8bff476f46fdec13112696 (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.
Diffstat (limited to 'doorbell-viewport')
| -rw-r--r-- | doorbell-viewport/README.md | 283 | ||||
| -rw-r--r-- | doorbell-viewport/defaults/main.yaml | 16 | ||||
| -rw-r--r-- | doorbell-viewport/files/doorbell-viewport-debug | 222 | ||||
| -rw-r--r-- | doorbell-viewport/files/doorbell-viewport.py | 567 | ||||
| -rw-r--r-- | doorbell-viewport/handlers/main.yaml | 5 | ||||
| -rw-r--r-- | doorbell-viewport/tasks/main.yaml | 99 | ||||
| -rw-r--r-- | doorbell-viewport/templates/doorbell-viewport.env.j2 | 12 | ||||
| -rw-r--r-- | doorbell-viewport/templates/doorbell-viewport.service.j2 | 18 |
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 |
