237 lines
9.0 KiB
Python
237 lines
9.0 KiB
Python
"""Capture-card ("receiver box") video source selection.
|
|
|
|
USB/PCIe HDMI capture cards plugged into this machine are enumerated and exposed
|
|
as an HA select, mirroring audio_control.AudioControl's shape: dynamic discovery,
|
|
options()/current_option()/select(), a payload from HA only ever used to look up
|
|
an already-enumerated device — see the security note in mqtt_discovery.py. The
|
|
actual /dev/videoN path handed to the capture-view launcher is server-side data
|
|
resolved from that lookup, never the payload itself.
|
|
|
|
PRIVACY INVARIANT — read before touching this file. hosts/thin-client/README.md
|
|
documents that the gesture-control camera has no HA entity and never will: "nothing
|
|
reachable over MQTT can turn the camera on." A naive "list every capture-capable
|
|
/dev/video*" would break that the moment gesture control is enabled on a unit, so
|
|
list_devices() excludes gesture-control's configured camera_device whenever
|
|
gesture-config.json's "enabled" is true — the same gate gesture_pointer.py itself
|
|
uses to decide whether the camera is ever opened at all. It is deliberately NOT an
|
|
unconditional exclusion: that file's camera_device defaults to /dev/video0 on
|
|
every image regardless of whether gesture control was even built in, and excluding
|
|
it while gesture control is off would just hide a real capture card that happens
|
|
to enumerate there, for no privacy benefit — there is nothing to protect while
|
|
gesture_pointer.py itself never opens the camera.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import glob
|
|
import logging
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
|
|
from .runtime_state import ensure_runtime_copy, load_json
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
GESTURE_CONFIG_FILENAME = "gesture-config.json"
|
|
NO_SOURCE = "none"
|
|
|
|
_DEVICE_CAPS_HEADER = re.compile(r"^\s*Device Caps\s*:")
|
|
_CAPTURE_CAPS = {"Video Capture", "Video Capture Multiplanar"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CaptureDevice:
|
|
path: str
|
|
label: str
|
|
|
|
|
|
def _run(args: list[str]) -> str | None:
|
|
try:
|
|
result = subprocess.run(args, capture_output=True, text=True, timeout=10, check=False)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
log.warning("%s failed: %s", " ".join(args), exc)
|
|
return None
|
|
if result.returncode != 0:
|
|
log.warning("%s: %s", " ".join(args), result.stderr.strip())
|
|
return None
|
|
return result.stdout
|
|
|
|
|
|
def _parse_list_devices(output: str) -> dict[str, list[str]]:
|
|
"""`v4l2-ctl --list-devices` groups /dev/videoN nodes under a physical-device
|
|
header line, blank-line separated, e.g.:
|
|
|
|
USB Video: USB Video (usb-0000:00:14.0-3):
|
|
\t/dev/video0
|
|
\t/dev/video1
|
|
|
|
The header already includes a bus-path suffix, which is what keeps two
|
|
identical capture dongles distinguishable without AudioControl-style "(2)"
|
|
suffixing.
|
|
"""
|
|
groups: dict[str, list[str]] = {}
|
|
label: str | None = None
|
|
for raw_line in output.splitlines():
|
|
if not raw_line.strip():
|
|
label = None
|
|
continue
|
|
if not raw_line[0].isspace():
|
|
label = raw_line.rstrip(":").strip()
|
|
groups.setdefault(label, [])
|
|
continue
|
|
if label is not None:
|
|
path = raw_line.strip()
|
|
if path:
|
|
groups[label].append(path)
|
|
return groups
|
|
|
|
|
|
def _has_capture_capability(path: str) -> bool:
|
|
output = _run(["v4l2-ctl", "-d", path, "--info"])
|
|
if output is None:
|
|
return False
|
|
|
|
lines = output.splitlines()
|
|
in_device_caps = False
|
|
for line in lines:
|
|
if _DEVICE_CAPS_HEADER.match(line):
|
|
in_device_caps = True
|
|
continue
|
|
if not in_device_caps:
|
|
continue
|
|
stripped = line.strip()
|
|
if not line[:1].isspace() or not stripped:
|
|
break
|
|
if stripped in _CAPTURE_CAPS:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _usb_device_dir(sysfs_device_symlink: str) -> str | None:
|
|
"""Resolves a component's sysfs `device` symlink up to the nearest ancestor
|
|
directory that looks like a USB device (has an `idVendor` file) — the shared
|
|
physical USB device two different interfaces (video, audio) both hang off.
|
|
"""
|
|
try:
|
|
current = os.path.realpath(sysfs_device_symlink)
|
|
except OSError:
|
|
return None
|
|
if not os.path.exists(current):
|
|
return None
|
|
# USB sysfs trees are shallow; a hard cap avoids any chance of looping on a
|
|
# pathological symlink structure.
|
|
for _ in range(6):
|
|
if os.path.exists(os.path.join(current, "idVendor")):
|
|
return current
|
|
parent = os.path.dirname(current)
|
|
if parent == current:
|
|
return None
|
|
current = parent
|
|
return None
|
|
|
|
|
|
def find_audio_card(device_path: str) -> str | None:
|
|
"""Best-effort match of a /dev/videoN to its sibling USB Audio Class ALSA
|
|
card. Many cheap USB HDMI-capture dongles present a UVC video interface and
|
|
a *separate* USB Audio interface — the embedded HDMI audio does not ride
|
|
along in the V4L2 stream, so mpv needs a second, explicit audio source.
|
|
Matched by shared physical USB device (sysfs), not by vendor/product ID.
|
|
|
|
Unverified against real hardware — see hosts/thin-client/README.md. Returns
|
|
None (video-only playback, not a crash) if nothing matches.
|
|
"""
|
|
video_name = os.path.basename(device_path.rstrip("/"))
|
|
video_usb = _usb_device_dir(f"/sys/class/video4linux/{video_name}/device")
|
|
if video_usb is None:
|
|
return None
|
|
|
|
for card_dir in sorted(glob.glob("/sys/class/sound/card[0-9]*")):
|
|
card_usb = _usb_device_dir(os.path.join(card_dir, "device"))
|
|
if card_usb is not None and card_usb == video_usb:
|
|
index = os.path.basename(card_dir).removeprefix("card")
|
|
return f"hw:{index},0"
|
|
return None
|
|
|
|
|
|
class CaptureControl:
|
|
def __init__(self) -> None:
|
|
self._devices: list[CaptureDevice] = []
|
|
self._current: str | None = None # a device path, or None for "no source"
|
|
|
|
def _excluded_device_path(self) -> str:
|
|
# Gated on "enabled", not just present: gesture-config.json's template ships
|
|
# on every image with camera_device defaulting to /dev/video0 regardless of
|
|
# whether gesture control was even built into this image (see its own "always
|
|
# copied in" comment in build-thin-client-iso.sh) — gesture_pointer.py itself
|
|
# never opens the camera while "enabled" is false, so there is nothing to
|
|
# protect against on the (default, common) image where it's off, and excluding
|
|
# /dev/video0 unconditionally would just be silently hiding a real capture card
|
|
# that happens to enumerate there. The exclusion has to track the same gate
|
|
# gesture_pointer.py uses to decide whether the camera is ever opened at all.
|
|
path = ensure_runtime_copy(GESTURE_CONFIG_FILENAME)
|
|
config = load_json(path)
|
|
if not config.get("enabled"):
|
|
return ""
|
|
return str(config.get("camera_device") or "").strip()
|
|
|
|
def list_devices(self) -> list[CaptureDevice]:
|
|
output = _run(["v4l2-ctl", "--list-devices"])
|
|
if output is None:
|
|
self._devices = []
|
|
return self._devices
|
|
|
|
excluded = self._excluded_device_path()
|
|
devices: list[CaptureDevice] = []
|
|
for label, paths in _parse_list_devices(output).items():
|
|
for path in paths:
|
|
if not path.startswith("/dev/video") or path == excluded:
|
|
continue
|
|
if _has_capture_capability(path):
|
|
devices.append(CaptureDevice(path=path, label=label))
|
|
break # one entry per physical device — the first real capture node
|
|
|
|
self._devices = devices
|
|
return devices
|
|
|
|
# --- entity surface -----------------------------------------------------
|
|
def options(self) -> list[str]:
|
|
return [NO_SOURCE] + [d.label for d in self._devices]
|
|
|
|
def current_option(self) -> str:
|
|
if self._current is None:
|
|
return NO_SOURCE
|
|
for device in self._devices:
|
|
if device.path == self._current:
|
|
return device.label
|
|
# Selected, then unplugged since — say so rather than silently reporting
|
|
# a source that is no longer there.
|
|
return NO_SOURCE
|
|
|
|
def _find(self, label: str) -> CaptureDevice | None:
|
|
for device in self._devices:
|
|
if device.label == label:
|
|
return device
|
|
return None
|
|
|
|
def select(self, payload: str) -> CaptureDevice | None:
|
|
"""Handle the HA select. Returns the resolved device, or None for
|
|
"no source" (including an unrecognised payload, treated the same as
|
|
picking "none" rather than acting on anything not currently listed)."""
|
|
label = payload.strip()
|
|
self.list_devices()
|
|
|
|
if label == NO_SOURCE:
|
|
self._current = None
|
|
return None
|
|
|
|
device = self._find(label)
|
|
if device is None:
|
|
log.warning("ignoring unknown capture source %r", label)
|
|
self._current = None
|
|
return None
|
|
|
|
self._current = device.path
|
|
return device
|