"""VISCA-IP façade for Logitech Rally (UVC + XU), not native VISCA. Listens on TCP (default 127.0.0.1:5678), accepts raw VISCA frames ending in 0xFF, and drives pan/tilt/zoom through v4l2. AutoPTZ should use backend visca_ip, address 127.0.0.1:5678, raw framing. visca_usb is pyserial — do not point it at this camera. """ from __future__ import annotations import argparse import logging import select import socket import subprocess import threading from typing import Callable log = logging.getLogger("cameras.visca_xu") # Sony/PTZOptics ACK + completion for a command addressed as 81. _ACK = bytes([0x90, 0x41, 0xFF]) _COMP = bytes([0x90, 0x51, 0xFF]) _SYNTAX = bytes([0x90, 0x60, 0x02, 0xFF]) def visca_frames(buf: bytes) -> tuple[list[bytes], bytes]: """Split a byte stream into 0xFF-terminated frames; return (frames, rest).""" frames: list[bytes] = [] start = 0 for i, b in enumerate(buf): if b == 0xFF: frames.append(buf[start:i + 1]) start = i + 1 return frames, buf[start:] def parse_visca(frame: bytes) -> dict: """Parse one VISCA command. Unknown frames get action='unknown'.""" f = bytes(frame) if len(f) < 5 or f[-1] != 0xFF: return {"action": "invalid"} # 81 01 06 01 VV WW PP QQ FF pan/tilt drive if len(f) >= 9 and f[1:4] == bytes([0x01, 0x06, 0x01]): pan_dir, tilt_dir = f[6], f[7] pan = 0.0 tilt = 0.0 if pan_dir == 0x01: pan = -1.0 elif pan_dir == 0x02: pan = 1.0 if tilt_dir == 0x01: tilt = 1.0 elif tilt_dir == 0x02: tilt = -1.0 if pan_dir == 0x03 and tilt_dir == 0x03: return {"action": "stop"} return {"action": "move", "pan": pan, "tilt": tilt, "zoom": 0.0} # 81 01 04 07 ZZ FF zoom if len(f) >= 6 and f[1:4] == bytes([0x01, 0x04, 0x07]): z = f[4] if z == 0x00: return {"action": "zoom_stop"} if (z & 0xF0) == 0x20: return {"action": "zoom", "zoom": 1.0} if (z & 0xF0) == 0x30: return {"action": "zoom", "zoom": -1.0} return {"action": "zoom_stop"} # 81 01 06 04 FF home if f[1:4] == bytes([0x01, 0x06, 0x04]): return {"action": "home"} # 81 01 04 3F 01/02 MM FF preset set/recall if len(f) >= 7 and f[1:4] == bytes([0x01, 0x04, 0x3F]): if f[4] == 0x02: return {"action": "preset_recall", "index": int(f[5])} if f[4] == 0x01: return {"action": "preset_set", "index": int(f[5])} # inquiries: answer none (get_position returns None on AutoPTZ) if len(f) >= 5 and f[1] == 0x09: return {"action": "inquiry"} return {"action": "unknown"} class RallyV4L2: """Thin v4l2-ctl wrapper. Rally exposes generic pan/tilt CIDs on this host.""" def __init__(self, device: str = "/dev/rally"): self.device = device def _ctl(self, *args: str) -> str: r = subprocess.run( ["v4l2-ctl", "-d", self.device, *args], capture_output=True, text=True, check=False) if r.returncode != 0: log.warning("v4l2-ctl rc=%s %s", r.returncode, r.stderr.strip()) return r.stdout def get(self) -> dict[str, int]: out = self._ctl("--get-ctrl=pan_absolute,tilt_absolute,zoom_absolute") vals: dict[str, int] = {} for line in out.splitlines(): if ":" not in line: continue k, v = line.split(":", 1) try: vals[k.strip()] = int(v.strip()) except ValueError: pass return vals def set_speed(self, pan: int, tilt: int) -> None: pan = max(-1, min(1, int(pan))) tilt = max(-1, min(1, int(tilt))) self._ctl(f"--set-ctrl=pan_speed={pan},tilt_speed={tilt}") def stop(self) -> None: self.set_speed(0, 0) def nudge_zoom(self, direction: int, step: int = 20) -> None: cur = self.get().get("zoom_absolute", 100) nxt = max(100, min(1500, cur + (step if direction > 0 else -step))) self._ctl(f"--set-ctrl=zoom_absolute={nxt}") def home(self) -> None: self.stop() self._ctl("--set-ctrl=pan_absolute=0,tilt_absolute=0,zoom_absolute=100") def apply(self, cmd: dict) -> None: action = cmd.get("action") if action in ("stop", "zoom_stop"): self.stop() elif action == "move": pan = cmd.get("pan") or 0.0 tilt = cmd.get("tilt") or 0.0 self.set_speed( -1 if pan < -0.01 else (1 if pan > 0.01 else 0), -1 if tilt < -0.01 else (1 if tilt > 0.01 else 0), ) elif action == "zoom": z = cmd.get("zoom") or 0.0 if abs(z) < 0.01: return self.nudge_zoom(1 if z > 0 else -1) elif action == "home": self.home() def serve( host: str, port: int, apply: Callable[[dict], None], stop: threading.Event | None = None, ) -> None: srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((host, port)) srv.listen(4) srv.settimeout(0.5) log.info("VISCA-IP façade %s:%d (raw) -> Rally v4l2", host, port) clients: dict[socket.socket, bytearray] = {} try: while stop is None or not stop.is_set(): rlist = [srv, *clients] try: ready, _, _ = select.select(rlist, [], [], 0.5) except InterruptedError: continue for s in ready: if s is srv: conn, addr = srv.accept() conn.setblocking(False) clients[conn] = bytearray() log.info("visca client %s", addr) continue try: data = s.recv(4096) except BlockingIOError: continue except OSError: data = b"" if not data: clients.pop(s, None) s.close() continue clients[s].extend(data) frames, rest = visca_frames(bytes(clients[s])) clients[s] = bytearray(rest) for fr in frames: cmd = parse_visca(fr) if cmd.get("action") in ("invalid", "inquiry", "unknown"): if cmd.get("action") == "invalid": s.sendall(_SYNTAX) continue try: apply(cmd) except Exception: # noqa: BLE001 log.exception("apply failed %s", cmd) try: s.sendall(_ACK + _COMP) except OSError: pass finally: for c in list(clients): c.close() srv.close() def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--device", default="/dev/rally") ap.add_argument("--host", default="127.0.0.1") ap.add_argument("--port", type=int, default=5678) args = ap.parse_args(argv) logging.basicConfig( level=logging.INFO, format="%(asctime)s [visca-xu] %(levelname)s %(message)s") rally = RallyV4L2(args.device) serve(args.host, args.port, rally.apply) return 0 if __name__ == "__main__": raise SystemExit(main())