diff options
| author | Luke Hoersten <[email protected]> | 2026-04-12 13:13:16 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-04-12 13:13:16 -0500 |
| commit | 1298d68bb9bc33b84a70e4c9c27c806d8a0748c9 (patch) | |
| tree | 55411e3ffe37b36f10aff9007646ec06b1a937ad /doorbell-viewport/files/doorbell-viewport.py | |
| parent | 8c58be6b00bd79e3b1c2b9ab3a256939a0a4a832 (diff) | |
Fix doorbell-viewport bringup issues
- Fix websockets headers kwarg via inspect (works across all versions)
- Use full path /usr/bin/vcgencmd; add libraspberrypi-bin dependency
- Fix UniFi Protect WS packet decoder to handle multiple concatenated
packets per message (action+data in single frame)
- Fetch lastUpdateId from bootstrap for WS URL
- Switch fbcon disable from video=HDMI-A-1:D to fbcon=map:99 (fkms compat)
- Default drm_connector to DSI-1 for fkms; drop hardcoded drm_mode
- Add --no-audio and --hwdec=no to mpv; fix DRM mode-setting race on startup
- Add PoE HAT fan control with configurable temp thresholds
Diffstat (limited to 'doorbell-viewport/files/doorbell-viewport.py')
| -rw-r--r-- | doorbell-viewport/files/doorbell-viewport.py | 121 |
1 files changed, 81 insertions, 40 deletions
diff --git a/doorbell-viewport/files/doorbell-viewport.py b/doorbell-viewport/files/doorbell-viewport.py index 1bdcc29..3167671 100644 --- a/doorbell-viewport/files/doorbell-viewport.py +++ b/doorbell-viewport/files/doorbell-viewport.py @@ -30,6 +30,8 @@ import zlib from enum import Enum from pathlib import Path +import inspect + import evdev import requests import urllib3 @@ -37,6 +39,14 @@ import websockets urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +# Parameter name for passing headers to websockets.connect() varies by version. +# Detect it at import time rather than assuming based on version number. +_WS_HEADERS_KWARG = ( + "additional_headers" + if "additional_headers" in inspect.signature(websockets.connect).parameters + else "extra_headers" +) + logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", @@ -102,7 +112,7 @@ class DisplayController: try: if self.backend == "vcgencmd": subprocess.run( - ["vcgencmd", "display_power", "1"], + ["/usr/bin/vcgencmd", "display_power", "1"], check=True, capture_output=True, ) @@ -118,7 +128,7 @@ class DisplayController: try: if self.backend == "vcgencmd": subprocess.run( - ["vcgencmd", "display_power", "0"], + ["/usr/bin/vcgencmd", "display_power", "0"], check=True, capture_output=True, ) @@ -176,6 +186,20 @@ class ProtectClient: log.error("Protect: authentication failed: %s", exc) return False + def get_last_update_id(self) -> str | None: + try: + resp = self.session.get( + f"{self.base_url}/proxy/protect/api/bootstrap", + timeout=10, + ) + resp.raise_for_status() + uid = resp.json().get("lastUpdateId") + log.info("Protect: lastUpdateId=%s", uid) + return uid + except Exception as exc: + log.warning("Protect: failed to fetch lastUpdateId: %s", exc) + return None + def get_camera_rtsp_url(self) -> str | None: log.info("Protect: fetching camera info for id=%s", self.config.camera_id) try: @@ -203,11 +227,12 @@ class ProtectClient: return {"Cookie": "; ".join(f"{k}={v}" for k, v in cookies.items())} -def decode_protect_packet(data: bytes) -> dict | None: +def decode_protect_packets(data: bytes) -> list: """ - Decode a binary UniFi Protect WebSocket packet. + Decode all binary UniFi Protect WebSocket packets from a single message. - Header (8 bytes): + Each WebSocket message contains one or more concatenated packets. + Each 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 @@ -216,28 +241,34 @@ def decode_protect_packet(data: bytes) -> dict | None: 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 + packets = [] + offset = 0 + while offset + 8 <= len(data): + packet_type = data[offset] + payload_format = data[offset + 1] + deflated = bool(data[offset + 2]) + payload_size = struct.unpack(">I", data[offset + 4:offset + 8])[0] + end = offset + 8 + payload_size + if end > len(data): + break + payload_bytes = data[offset + 8:end] + if deflated: + try: + payload_bytes = zlib.decompress(payload_bytes) + except Exception as exc: + log.debug("Packet decompression failed: %s", exc) + offset = end + continue + if payload_format in (1, 2): + try: + packets.append({ + "packet_type": packet_type, + "payload": json.loads(payload_bytes), + }) + except Exception: + pass + offset = end + return packets class DoorbellViewport: @@ -269,9 +300,13 @@ class DoorbellViewport: 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 + # while display remains off — zero-latency activation later. + # display.off() is called AFTER start_mpv() because mpv's DRM mode + # setting re-enables the HDMI output, overriding any prior vcgencmd state. if self.config.prebuffer_mode == "warm" and self.config.rtsp_url: await self.start_mpv() + await asyncio.sleep(3) # wait for mpv DRM mode-setting to complete + self.display.off() log.info("Warm prebuffer active: mpv running, display off") await asyncio.gather( @@ -345,11 +380,15 @@ class DoorbellViewport: "--vo=drm", f"--drm-device={self.config.drm_device}", f"--drm-connector={self.config.drm_connector}", - f"--drm-mode={self.config.drm_mode}", + *( + [f"--drm-mode={self.config.drm_mode}"] + if self.config.drm_mode else [] + ), f"--video-rotate={self.config.orientation}", "--fullscreen", "--no-border", "--no-osc", + "--no-audio", "--no-input-default-bindings", "--no-config", "--really-quiet", @@ -357,7 +396,7 @@ class DoorbellViewport: "--cache=yes", "--cache-secs=3", "--demuxer-max-bytes=50M", - "--hwdec=auto", + "--hwdec=no", self.config.rtsp_url, ] log.info("Starting mpv: %s", " ".join(cmd)) @@ -432,12 +471,16 @@ class DoorbellViewport: ssl_ctx = ssl.create_default_context() ssl_ctx.check_hostname = False ssl_ctx.verify_mode = ssl.CERT_NONE + loop = asyncio.get_event_loop() + last_update_id = await loop.run_in_executor(None, self.protect.get_last_update_id) ws_url = f"wss://{self.config.protect_host}/proxy/protect/ws/updates" + if last_update_id: + ws_url += f"?lastUpdateId={last_update_id}" headers = self.protect.ws_headers() async with websockets.connect( ws_url, - additional_headers=headers, + **{_WS_HEADERS_KWARG: headers}, ssl=ssl_ctx, ping_interval=20, ping_timeout=10, @@ -446,14 +489,13 @@ class DoorbellViewport: 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 + packets = decode_protect_packets(msg) + for pkt in packets: + 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": @@ -461,7 +503,6 @@ class DoorbellViewport: 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) |
