src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-04-12 13:13:16 -0500
committerLuke Hoersten <[email protected]>2026-04-12 13:13:16 -0500
commit1298d68bb9bc33b84a70e4c9c27c806d8a0748c9 (patch)
tree55411e3ffe37b36f10aff9007646ec06b1a937ad
parent8c58be6b00bd79e3b1c2b9ab3a256939a0a4a832 (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.yaml7
-rw-r--r--doorbell-viewport/files/doorbell-viewport.py121
-rw-r--r--doorbell-viewport/tasks/main.yaml30
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