Files
livekit-cameras/generate_udev_rules.py
root 95bb7c50ee feat: point the fleet at wss LiveKit room uwh-telhai
Publish and subscribe over wss://livekit.uni-wh.de:7800 and refuse
cleartext ws://. Conference room is uwh-telhai. Includes the uncommitted
encoded H.264 publish path, Rally hairpin, and KMS wall overlay.
2026-10-11 00:48:50 +00:00

149 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Generate udev rules that pin each C920 webcam to a fixed slot.
Slot N (cam-01..cam-20) is bound to the USB *serial number* of the camera
that currently occupies it, giving stable:
- video: /dev/camNN (primary capture node), /dev/camNN.meta (UVC metadata)
- audio: fixed ALSA card index (hw:INDEX,0)
Run: python3 generate_udev_rules.py # prints to stdout
python3 generate_udev_rules.py --write # also writes /etc/udev/rules.d/99-c920-pin.rules
"""
import re
import subprocess
import sys
from usb_ids import is_fleet_video, is_rally_pid, usb_vid_pid_from_sysfs
HEADER = """\
# Stable slots for 20x Logitech HD Pro Webcam C920 (046d:08e5).
# Slot = physical camera, identified by USB serial. Survives reboots,
# re-enumeration, and hub reordering. Generated by generate_udev_rules.py.
#
# /dev/camNN -> primary video capture node (UVC stream 0)
# /dev/camNN.meta -> UVC metadata node (stream 1)
# hw:N,0 -> mic of cam-NN (N from SOUND_CARD_INDEX below)
"""
def sh(cmd):
return subprocess.run(cmd, capture_output=True, text=True).stdout
def _serial_of(sysfs_path):
"""Walk up from a sysfs path (interface, function, or card device)
until we reach the USB device that carries the 'serial' attribute."""
import pathlib
p = pathlib.Path(sysfs_path)
for _ in range(12):
cand = p / "serial"
if cand.is_file():
return cand.read_text().strip()
if str(p).startswith("/sys/devices"):
p = p.parent
else:
return ""
return ""
def current_state():
"""Map serial -> (primary_video_node, sound_card_index)."""
import pathlib
# sound cards: index -> serial
cards = {}
for line in sh(["cat", "/proc/asound/cards"]).splitlines():
m = re.match(r"\s*(\d+)\s+\[([A-Za-z0-9_]+)\s*\]:\s*(.*)", line)
if not m:
continue
idx = int(m.group(1))
if "C920" not in m.group(3) and "920" not in m.group(2):
continue
dev = f"/sys/class/sound/card{idx}/device"
real = sh(["readlink", "-f", dev]).strip()
cards[idx] = _serial_of(real)
# video: primary node (index 0) per USB serial, via sysfs
primary_by_serial = {}
for node in sorted(pathlib.Path("/sys/class/video4linux").glob("video*"),
key=lambda p: int(p.name.replace("video", ""))):
idx_attr = (node / "index").read_text().strip()
if idx_attr != "0":
continue
real = sh(["readlink", "-f", str(node / "device")]).strip()
ids = usb_vid_pid_from_sysfs(real)
if ids is not None and not is_fleet_video(ids[0], ids[1]):
continue
if ids is not None and is_rally_pid(ids[1]):
continue
s = _serial_of(real)
primary_by_serial[s] = f"/dev/{node.name}"
return primary_by_serial, cards
def main():
primary_by_serial, cards = current_state()
# assign slots: cam-01..cam-20 in current video-node order
ordered = []
for serial, node in primary_by_serial.items():
n = int(node.replace("/dev/video", ""))
ordered.append((n, serial, node))
ordered.sort()
if len(ordered) != 20:
print(f"warning: expected 20 cameras, got {len(ordered)}", file=sys.stderr)
lines = [HEADER]
for i, (vnum, serial, node) in enumerate(ordered, start=1):
card = next((c for c, s in cards.items() if s == serial), None)
slot = f"cam{i:02d}"
lines.append(f"# {slot}: serial {serial} (was {node}, card {card})")
lines.append(
f'SUBSYSTEM=="video4linux", KERNEL=="video*", '
f'ENV{{ID_VENDOR_ID}}=="046d", ENV{{ID_MODEL_ID}}=="08e5", '
f'ENV{{ID_USB_SERIAL_SHORT}}=="{serial}", '
f'ATTR{{index}}=="0", SYMLINK+="{slot}"')
lines.append(
f'SUBSYSTEM=="video4linux", KERNEL=="video*", '
f'ENV{{ID_VENDOR_ID}}=="046d", ENV{{ID_MODEL_ID}}=="08e5", '
f'ENV{{ID_USB_SERIAL_SHORT}}=="{serial}", '
f'ATTR{{index}}=="1", SYMLINK+="{slot}.meta"')
if card is not None:
lines.append(
f'SUBSYSTEM=="sound", KERNEL=="card*", '
f'ENV{{ID_VENDOR_ID}}=="046d", ENV{{ID_MODEL_ID}}=="08e5", '
f'ENV{{ID_USB_SERIAL_SHORT}}=="{serial}", '
f'ENV{{SOUND_CARD_INDEX}}="{card}"')
else:
print(f"warning: no sound card for serial {serial}", file=sys.stderr)
lines.append("")
text = "\n".join(lines)
print(text)
if "--write" in sys.argv:
path = "/etc/udev/rules.d/99-c920-pin.rules"
with open(path, "w") as f:
f.write(text)
print(f"# wrote {path}", file=sys.stderr)
# durable slot map for the app (discovery.py reads this)
import json
import os
slots = {}
for i, (vnum, serial, node) in enumerate(ordered, start=1):
card = next((c for c, s in cards.items() if s == serial), None)
slots[f"cam-{i:02d}"] = {
"serial": serial,
"video": f"/dev/cam{i:02d}",
"card": card,
}
base = os.path.dirname(os.path.abspath(__file__))
slots_path = os.path.join(base, "slots.json")
with open(slots_path, "w") as f:
json.dump(slots, f, indent=2)
print(f"# wrote {slots_path}", file=sys.stderr)
# summary table to stderr
for i, (vnum, serial, node) in enumerate(ordered, start=1):
card = next((c for c, s in cards.items() if s == serial), "?")
print(f"# cam-{i:02d} serial={serial} video={node} card={card}",
file=sys.stderr)
if __name__ == "__main__":
main()