#!/usr/bin/env python3 import asyncio import json import subprocess import sys import threading from typing import Optional, Dict, Any import logging import requests import websockets logging.getLogger("zeep").setLevel(logging.WARNING) logging.getLogger("zeep.xsd.schema").setLevel(logging.WARNING) logging.getLogger("zeep.xsd.schema.schema").setLevel(logging.WARNING) from onvif import ONVIFCamera # 新增:ONVIF PTZ 控制 # ======================= 独立的 PTZController ======================= class PTZController: def __init__(self, host: str, port: int, user: str, password: str): """ :param host: 摄像机 IP 或域名(和 RTSP 的一样即可) :param port: ONVIF 端口(多数为 80,看你的设备) :param user: 摄像机用户名 :param password: 摄像机密码 """ self.host = host self.port = port self.user = user self.password = password self.cam: Optional[ONVIFCamera] = None self.media_service = None self.ptz_service = None self.profile = None def connect(self) -> bool: """ 建立 ONVIF 连接并初始化 PTZ 能力,失败返回 False(不抛异常) Note: 首先 pip install onvif-zeep """ try: self.cam = ONVIFCamera(self.host, self.port, self.user, self.password) self.media_service = self.cam.create_media_service() self.ptz_service = self.cam.create_ptz_service() profiles = self.media_service.GetProfiles() if not profiles: print("[PTZ] No media profiles found on camera.", file=sys.stderr) return False self.profile = profiles[0] return True except Exception as e: print(f"[PTZ] Failed to init ONVIF PTZ: {e}", file=sys.stderr) return False def _continuous_move(self, pan: float, tilt: float, zoom: float, duration: float) -> bool: """ 连续移动一段时间(秒),之后自动停止。 此函数为阻塞模式:只有在 Stop 调用结束后,才返回 True/False。 """ if not self.ptz_service or not self.profile: print("[PTZ] _continuous_move: ptz_service or profile not ready", file=sys.stderr) return False # 进入前先强行停一下,避免前一次残留动作 self._force_stop() req = self.ptz_service.create_type("ContinuousMove") req.ProfileToken = self.profile.token req.Velocity = { "PanTilt": {"x": pan, "y": tilt}, "Zoom": {"x": zoom}, } try: print(f"[PTZ] ContinuousMove start: pan={pan}, tilt={tilt}, zoom={zoom}, duration={duration}", file=sys.stderr) self.ptz_service.ContinuousMove(req) except Exception as e: print(f"[PTZ] ContinuousMove failed: {e}", file=sys.stderr) return False # 阻塞等待:这里决定“运动时间” import time wait_seconds = max(2 * duration, 0.0) time.sleep(wait_seconds) # 运动完成后强制停止 return self._force_stop() def stop(self) -> bool: """ 阻塞调用 Stop(带重试),成功 True,失败 False。 """ return self._force_stop() # ------- 对外动作接口(给 CameraController 调用) ------- # 所有接口都为“阻塞模式”:只有在运动 + Stop 完成后才返回 True/False def move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool: print(f"[PTZ] move_up called, speed={speed}, duration={duration}", file=sys.stderr) return self._continuous_move(pan=0.0, tilt=+speed, zoom=0.0, duration=duration) def move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool: print(f"[PTZ] move_down called, speed={speed}, duration={duration}", file=sys.stderr) return self._continuous_move(pan=0.0, tilt=-speed, zoom=0.0, duration=duration) def move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool: print(f"[PTZ] move_left called, speed={speed}, duration={duration}", file=sys.stderr) return self._continuous_move(pan=-speed, tilt=0.0, zoom=0.0, duration=duration) def move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool: print(f"[PTZ] move_right called, speed={speed}, duration={duration}", file=sys.stderr) return self._continuous_move(pan=+speed, tilt=0.0, zoom=0.0, duration=duration) # ------- 占位的变倍接口(当前设备不支持) ------- def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool: """ 当前设备不支持变倍;保留方法只是避免上层调用时报错。 """ print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr) return False def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool: """ 当前设备不支持变倍;保留方法只是避免上层调用时报错。 """ print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr) return False def _force_stop(self, retries: int = 3, delay: float = 0.1) -> bool: """ 尝试多次调用 Stop,作为“强制停止”手段。 :param retries: 重试次数 :param delay: 每次重试间隔(秒) """ if not self.ptz_service or not self.profile: print("[PTZ] _force_stop: ptz_service or profile not ready", file=sys.stderr) return False import time last_error = None for i in range(retries): try: print(f"[PTZ] _force_stop: calling Stop(), attempt={i+1}", file=sys.stderr) self.ptz_service.Stop({"ProfileToken": self.profile.token}) print("[PTZ] _force_stop: Stop() returned OK", file=sys.stderr) return True except Exception as e: last_error = e print(f"[PTZ] _force_stop: Stop() failed at attempt {i+1}: {e}", file=sys.stderr) time.sleep(delay) print(f"[PTZ] _force_stop: all {retries} attempts failed, last error: {last_error}", file=sys.stderr) return False # ======================= CameraController(加入 PTZ) ======================= class CameraController: """ Uni-Lab-OS 摄像头驱动(driver 形式) 启动 Uni-Lab-OS 后,立即开始推流 - WebSocket 信令:通过 signal_backend_url 连接到后端 例如: wss://sciol.ac.cn/api/realtime/signal/host/ - 媒体服务器:通过 rtmp_url / webrtc_api / webrtc_stream_url 当前配置为 SRS,与独立 HostSimulator 独立运行脚本保持一致。 """ def __init__( self, host_id: str = "demo-host", # (1)信令后端(WebSocket) signal_backend_url: str = "wss://sciol.ac.cn/api/realtime/signal/host", # (2)媒体后端(RTMP + WebRTC API) rtmp_url: str = "rtmp://srs.sciol.ac.cn:4499/live/camera-01", webrtc_api: str = "https://srs.sciol.ac.cn/rtc/v1/play/", webrtc_stream_url: str = "webrtc://srs.sciol.ac.cn:4500/live/camera-01", camera_rtsp_url: str = "", # (3)PTZ 控制相关(ONVIF) ptz_host: str = "", # 一般就是摄像头 IP,比如 "192.168.31.164" ptz_port: int = 80, # ONVIF 端口,不一定是 80,按实际情况改 ptz_user: str = "", # admin ptz_password: str = "", # admin123 ): self.host_id = host_id self.camera_rtsp_url = camera_rtsp_url # 拼接最终的 WebSocket URL:.../host/ signal_backend_url = signal_backend_url.rstrip("/") if not signal_backend_url.endswith("/host"): signal_backend_url = signal_backend_url + "/host" self.signal_backend_url = f"{signal_backend_url}/{host_id}" # 媒体服务器配置 self.rtmp_url = rtmp_url self.webrtc_api = webrtc_api self.webrtc_stream_url = webrtc_stream_url # PTZ 控制 self.ptz_host = ptz_host self.ptz_port = ptz_port self.ptz_user = ptz_user self.ptz_password = ptz_password self._ptz: Optional[PTZController] = None self._init_ptz_if_possible() # 运行时状态 self._ws: Optional[object] = None self._ffmpeg_process: Optional[subprocess.Popen] = None self._running = False self._loop_task: Optional[asyncio.Future] = None # 事件循环 & 线程 self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None try: self.start() except Exception as e: print(f"[CameraController] __init__ auto start failed: {e}", file=sys.stderr) # ------------------------ PTZ 初始化 ------------------------ # ------------------------ PTZ 公开动作方法(一个动作一个函数) ------------------------ def ptz_move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool: print(f"[CameraController] ptz_move_up called, speed={speed}, duration={duration}") return self._ptz.move_up(speed=speed, duration=duration) def ptz_move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool: print(f"[CameraController] ptz_move_down called, speed={speed}, duration={duration}") return self._ptz.move_down(speed=speed, duration=duration) def ptz_move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool: print(f"[CameraController] ptz_move_left called, speed={speed}, duration={duration}") return self._ptz.move_left(speed=speed, duration=duration) def ptz_move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool: print(f"[CameraController] ptz_move_right called, speed={speed}, duration={duration}") return self._ptz.move_right(speed=speed, duration=duration) def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool: """ 当前设备不支持变倍;保留方法只是避免上层调用时报错。 """ print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr) return False def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool: """ 当前设备不支持变倍;保留方法只是避免上层调用时报错。 """ print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr) return False def ptz_stop(self): if self._ptz is None: print("[CameraController] PTZ not initialized.", file=sys.stderr) return self._ptz.stop() def _init_ptz_if_possible(self): """ 根据 ptz_host / user / password 初始化 PTZ; 如果配置信息不全则不启用 PTZ(静默)。 """ if not (self.ptz_host and self.ptz_user and self.ptz_password): return ctrl = PTZController( host=self.ptz_host, port=self.ptz_port, user=self.ptz_user, password=self.ptz_password, ) if ctrl.connect(): self._ptz = ctrl else: self._ptz = None # --------------------------------------------------------------------- # 对外暴露的方法:供 Uni-Lab-OS 调用 # --------------------------------------------------------------------- def start(self, config: Optional[Dict[str, Any]] = None): """ 启动 Camera 连接 & 消息循环,并在启动时就开启 FFmpeg 推流, """ if self._running: return {"status": "already_running", "host_id": self.host_id} # 应用 config 覆盖(如果有) if config: self.camera_rtsp_url = config.get("camera_rtsp_url", self.camera_rtsp_url) cfg_host_id = config.get("host_id") if cfg_host_id: self.host_id = cfg_host_id signal_backend_url = config.get("signal_backend_url") if signal_backend_url: signal_backend_url = signal_backend_url.rstrip("/") if not signal_backend_url.endswith("/host"): signal_backend_url = signal_backend_url + "/host" self.signal_backend_url = f"{signal_backend_url}/{self.host_id}" self.rtmp_url = config.get("rtmp_url", self.rtmp_url) self.webrtc_api = config.get("webrtc_api", self.webrtc_api) self.webrtc_stream_url = config.get( "webrtc_stream_url", self.webrtc_stream_url ) # PTZ 相关配置也允许通过 config 注入 self.ptz_host = config.get("ptz_host", self.ptz_host) self.ptz_port = int(config.get("ptz_port", self.ptz_port)) self.ptz_user = config.get("ptz_user", self.ptz_user) self.ptz_password = config.get("ptz_password", self.ptz_password) self._init_ptz_if_possible() self._running = True # === start 时启动 FFmpeg 推流 === self._start_ffmpeg() # 创建新的事件循环和线程(用于 WebSocket 信令) self._loop = asyncio.new_event_loop() def loop_runner(loop: asyncio.AbstractEventLoop): asyncio.set_event_loop(loop) try: loop.run_forever() except Exception as e: print(f"[CameraController] event loop error: {e}", file=sys.stderr) self._loop_thread = threading.Thread( target=loop_runner, args=(self._loop,), daemon=True ) self._loop_thread.start() self._loop_task = asyncio.run_coroutine_threadsafe( self._run_main_loop(), self._loop ) return { "status": "started", "host_id": self.host_id, "signal_backend_url": self.signal_backend_url, "rtmp_url": self.rtmp_url, "webrtc_api": self.webrtc_api, "webrtc_stream_url": self.webrtc_stream_url, } def stop(self) -> Dict[str, Any]: """ 停止推流 & 断开 WebSocket,并关闭事件循环线程。 """ self._running = False self._stop_ffmpeg() if self._ws and self._loop is not None: async def close_ws(): try: await self._ws.close() except Exception as e: print( f"[CameraController] error when closing WebSocket: {e}", file=sys.stderr, ) asyncio.run_coroutine_threadsafe(close_ws(), self._loop) if self._loop_task is not None: if not self._loop_task.done(): self._loop_task.cancel() try: self._loop_task.result() except asyncio.CancelledError: pass except Exception as e: print( f"[CameraController] main loop task error in stop(): {e}", file=sys.stderr, ) finally: self._loop_task = None if self._loop is not None: try: self._loop.call_soon_threadsafe(self._loop.stop) except Exception as e: print( f"[CameraController] error when stopping event loop: {e}", file=sys.stderr, ) if self._loop_thread is not None: try: self._loop_thread.join(timeout=5) except Exception as e: print( f"[CameraController] error when joining loop thread: {e}", file=sys.stderr, ) finally: self._loop_thread = None self._ws = None self._loop = None return {"status": "stopped", "host_id": self.host_id} def get_status(self) -> Dict[str, Any]: """ 查询当前状态,方便在 Uni-Lab-OS 中做监控。 """ ws_closed = None if self._ws is not None: ws_closed = getattr(self._ws, "closed", None) if ws_closed is None: websocket_connected = self._ws is not None else: websocket_connected = (self._ws is not None) and (not ws_closed) return { "host_id": self.host_id, "running": self._running, "websocket_connected": websocket_connected, "ffmpeg_running": bool( self._ffmpeg_process and self._ffmpeg_process.poll() is None ), "signal_backend_url": self.signal_backend_url, "rtmp_url": self.rtmp_url, } # --------------------------------------------------------------------- # 内部实现逻辑:WebSocket 循环 / FFmpeg / WebRTC Offer 处理 # --------------------------------------------------------------------- async def _run_main_loop(self): try: while self._running: try: async with websockets.connect(self.signal_backend_url) as ws: self._ws = ws await self._recv_loop() except asyncio.CancelledError: raise except Exception as e: if self._running: print( f"[CameraController] WebSocket connection error: {e}", file=sys.stderr, ) await asyncio.sleep(3) except asyncio.CancelledError: pass async def _recv_loop(self): assert self._ws is not None ws = self._ws async for message in ws: try: data = json.loads(message) except json.JSONDecodeError: print( f"[CameraController] received non-JSON message: {message}", file=sys.stderr, ) continue try: await self._handle_message(data) except Exception as e: print( f"[CameraController] error while handling message {data}: {e}", file=sys.stderr, ) async def _handle_message(self, data: Dict[str, Any]): """ 处理来自信令后端的消息: - command: start_stream / stop_stream / ptz_xxx - type: offer (WebRTC) """ cmd = data.get("command") # ---------- 推流控制 ---------- if cmd == "start_stream": try: self._start_ffmpeg() except Exception as e: print( f"[CameraController] error when starting FFmpeg on start_stream: {e}", file=sys.stderr, ) return if cmd == "stop_stream": try: self._stop_ffmpeg() except Exception as e: print( f"[CameraController] error when stopping FFmpeg on stop_stream: {e}", file=sys.stderr, ) return # # ---------- PTZ 控制 ---------- # # 例如信令可以发: # # {"command": "ptz_move", "direction": "down", "speed": 0.5, "duration": 0.5} # if cmd == "ptz_move": # if self._ptz is None: # # 没有初始化 PTZ,静默忽略或打印一条 # print("[CameraController] PTZ not initialized.", file=sys.stderr) # return # direction = data.get("direction", "") # speed = float(data.get("speed", 0.5)) # duration = float(data.get("duration", 0.5)) # try: # if direction == "up": # self._ptz.move_up(speed=speed, duration=duration) # elif direction == "down": # self._ptz.move_down(speed=speed, duration=duration) # elif direction == "left": # self._ptz.move_left(speed=speed, duration=duration) # elif direction == "right": # self._ptz.move_right(speed=speed, duration=duration) # elif direction == "zoom_in": # self._ptz.zoom_in(speed=speed, duration=duration) # elif direction == "zoom_out": # self._ptz.zoom_out(speed=speed, duration=duration) # elif direction == "stop": # self._ptz.stop() # else: # # 未知方向,忽略 # pass # except Exception as e: # print( # f"[CameraController] error when handling PTZ move: {e}", # file=sys.stderr, # ) # return # ---------- WebRTC Offer ---------- if data.get("type") == "offer": offer_sdp = data.get("sdp", "") camera_id = data.get("cameraId", "camera-01") try: answer_sdp = await self._handle_webrtc_offer(offer_sdp) except Exception as e: print( f"[CameraController] error when handling WebRTC offer: {e}", file=sys.stderr, ) return if self._ws: answer_payload = { "type": "answer", "sdp": answer_sdp, "cameraId": camera_id, "hostId": self.host_id, } try: await self._ws.send(json.dumps(answer_payload)) except Exception as e: print( f"[CameraController] error when sending WebRTC answer: {e}", file=sys.stderr, ) # ------------------------ FFmpeg 相关 ------------------------ def _start_ffmpeg(self): if self._ffmpeg_process and self._ffmpeg_process.poll() is None: return cmd = [ "ffmpeg", "-rtsp_transport", "tcp", "-i", self.camera_rtsp_url, "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "baseline", "-b:v", "1M", "-maxrate", "1M", "-bufsize", "2M", "-g", "10", "-keyint_min", "10", "-sc_threshold", "0", "-pix_fmt", "yuv420p", "-x264-params", "bframes=0", "-c:a", "aac", "-ar", "44100", "-ac", "1", "-b:a", "64k", "-f", "flv", self.rtmp_url, ] try: self._ffmpeg_process = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT, shell=False, ) except Exception as e: print(f"[CameraController] failed to start FFmpeg: {e}", file=sys.stderr) self._ffmpeg_process = None raise def _stop_ffmpeg(self): proc = self._ffmpeg_process if proc and proc.poll() is None: try: proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: try: proc.kill() try: proc.wait(timeout=2) except subprocess.TimeoutExpired: print( f"[CameraController] FFmpeg process did not exit even after kill (pid={proc.pid})", file=sys.stderr, ) except Exception as e: print( f"[CameraController] failed to kill FFmpeg process: {e}", file=sys.stderr, ) except Exception as e: print( f"[CameraController] error when stopping FFmpeg: {e}", file=sys.stderr, ) self._ffmpeg_process = None # ------------------------ WebRTC Offer 相关 ------------------------ async def _handle_webrtc_offer(self, offer_sdp: str) -> str: payload = { "api": self.webrtc_api, "streamurl": self.webrtc_stream_url, "sdp": offer_sdp, } headers = {"Content-Type": "application/json"} def _do_request(): return requests.post( self.webrtc_api, json=payload, headers=headers, timeout=10, ) try: loop = asyncio.get_running_loop() resp = await loop.run_in_executor(None, _do_request) except Exception as e: print( f"[CameraController] failed to send offer to media server: {e}", file=sys.stderr, ) raise try: resp.raise_for_status() except Exception as e: print( f"[CameraController] media server HTTP error: {e}, " f"status={resp.status_code}, body={resp.text[:200]}", file=sys.stderr, ) raise try: data = resp.json() except Exception as e: print( f"[CameraController] failed to parse media server JSON: {e}, " f"raw={resp.text[:200]}", file=sys.stderr, ) raise answer_sdp = data.get("sdp", "") if not answer_sdp: msg = f"empty SDP from media server: {data}" print(f"[CameraController] {msg}", file=sys.stderr) raise RuntimeError(msg) return answer_sdp