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 | |
| 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
| -rw-r--r-- | doorbell-viewport/defaults/main.yaml | 7 | ||||
| -rw-r--r-- | doorbell-viewport/files/doorbell-viewport.py | 121 | ||||
| -rw-r--r-- | doorbell-viewport/tasks/main.yaml | 30 |
3 files changed, 112 insertions, 46 deletions
diff --git a/doorbell-viewport/defaults/main.yaml b/doorbell-viewport/defaults/main.yaml index 8f75e64..e4d438c 100644 --- a/doorbell-viewport/defaults/main.yaml +++ b/doorbell-viewport/defaults/main.yaml @@ -10,7 +10,12 @@ 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" +doorbell_viewport_drm_mode: "" +doorbell_viewport_poe_fan: true +doorbell_viewport_poe_fan_temp0: 60000 +doorbell_viewport_poe_fan_temp1: 70000 +doorbell_viewport_poe_fan_temp2: 80000 +doorbell_viewport_poe_fan_temp3: 85000 # 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.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) diff --git a/doorbell-viewport/tasks/main.yaml b/doorbell-viewport/tasks/main.yaml index cb27924..989111d 100644 --- a/doorbell-viewport/tasks/main.yaml +++ b/doorbell-viewport/tasks/main.yaml @@ -19,6 +19,7 @@ - "python3-evdev" - "python3-requests" - "python3-websockets" + - "libraspberrypi-bin" - name: create doorbell-viewport config dir become: yes @@ -67,6 +68,25 @@ mode: "0644" notify: restart doorbell-viewport +- name: configure PoE HAT fan temperature thresholds + become: yes + blockinfile: + path: "/boot/firmware/config.txt" + marker: "# {mark} doorbell-viewport poe fan" + block: | + dtoverlay=rpi-poe + dtparam=poe_fan_temp0={{doorbell_viewport_poe_fan_temp0}},poe_fan_temp0_hyst=5000 + dtparam=poe_fan_temp1={{doorbell_viewport_poe_fan_temp1}},poe_fan_temp1_hyst=5000 + dtparam=poe_fan_temp2={{doorbell_viewport_poe_fan_temp2}},poe_fan_temp2_hyst=5000 + dtparam=poe_fan_temp3={{doorbell_viewport_poe_fan_temp3}},poe_fan_temp3_hyst=5000 + when: doorbell_viewport_poe_fan + register: fan_result + +- name: remind to reboot for PoE fan config to take effect + debug: + msg: "config.txt updated — reboot {{inventory_hostname}} for PoE HAT fan thresholds to take effect" + when: fan_result is not skipped and fan_result.changed + - name: enable vc4 KMS DRM driver become: yes lineinfile: @@ -77,21 +97,21 @@ - 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" + msg: "config.txt updated — reboot {{inventory_hostname}} for dtoverlay=vc4-fkms-v3d to take effect" when: vc4_result.changed -- name: disable fbcon on HDMI to free DRM device for mpv +- name: disable fbcon 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" + regexp: "^((?!.*fbcon=map:99).*)$" + line: "\\1 fbcon=map:99" 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" + msg: "cmdline.txt updated — reboot {{inventory_hostname}} for fbcon=map:99 to take effect" when: cmdline_result.changed - name: ensure doorbell-viewport is started |
