From e1fda6f4ed9fb537c190d5954068c8e1954f0a9a Mon Sep 17 00:00:00 2001 From: xiaoyu10031 Date: Tue, 16 Dec 2025 00:04:52 +0800 Subject: [PATCH] add camera driver --- unilabos/devices/cameraSII/cameraDriver.py | 712 ++++++++++++++++++ unilabos/devices/cameraSII/cameraUSB.py | 401 ++++++++++ unilabos/devices/cameraSII/cameraUSB_test.py | 51 ++ unilabos/devices/cameraSII/demo_camera_pic.py | 36 + .../devices/cameraSII/demo_camera_push.py | 21 + .../cameraSII/ptz_cameracontroller_test.py | 78 ++ unilabos/devices/cameraSII/ptz_test.py | 50 ++ unilabos/registry/devices/cameraSII.yaml | 105 +++ 8 files changed, 1454 insertions(+) create mode 100644 unilabos/devices/cameraSII/cameraDriver.py create mode 100644 unilabos/devices/cameraSII/cameraUSB.py create mode 100644 unilabos/devices/cameraSII/cameraUSB_test.py create mode 100644 unilabos/devices/cameraSII/demo_camera_pic.py create mode 100644 unilabos/devices/cameraSII/demo_camera_push.py create mode 100644 unilabos/devices/cameraSII/ptz_cameracontroller_test.py create mode 100644 unilabos/devices/cameraSII/ptz_test.py create mode 100644 unilabos/registry/devices/cameraSII.yaml diff --git a/unilabos/devices/cameraSII/cameraDriver.py b/unilabos/devices/cameraSII/cameraDriver.py new file mode 100644 index 00000000..27d27b90 --- /dev/null +++ b/unilabos/devices/cameraSII/cameraDriver.py @@ -0,0 +1,712 @@ +#!/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 \ No newline at end of file diff --git a/unilabos/devices/cameraSII/cameraUSB.py b/unilabos/devices/cameraSII/cameraUSB.py new file mode 100644 index 00000000..5544a8bd --- /dev/null +++ b/unilabos/devices/cameraSII/cameraUSB.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +import asyncio +import json +import subprocess +import sys +import threading +from typing import Optional, Dict, Any + +import requests +import websockets + + +class CameraController: + """ + Uni-Lab-OS 摄像头驱动(Linux USB 摄像头版,无 PTZ) + + - WebSocket 信令:signal_backend_url 连接到后端 + 例如: wss://sciol.ac.cn/api/realtime/signal/host/ + - 媒体服务器:RTMP 推流到 rtmp_url;WebRTC offer 转发到 SRS 的 webrtc_api + - 视频源:本地 USB 摄像头(V4L2,默认 /dev/video0) + """ + + def __init__( + self, + host_id: str = "demo-host", + signal_backend_url: str = "wss://sciol.ac.cn/api/realtime/signal/host", + 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", + video_device: str = "/dev/video0", + width: int = 1280, + height: int = 720, + fps: int = 30, + video_bitrate: str = "1500k", + audio_device: Optional[str] = None, # 比如 "hw:1,0",没有音频就保持 None + audio_bitrate: str = "64k", + ): + self.host_id = host_id + + # 拼接最终 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 + + # 本地采集配置 + self.video_device = video_device + self.width = int(width) + self.height = int(height) + self.fps = int(fps) + self.video_bitrate = video_bitrate + self.audio_device = audio_device + self.audio_bitrate = audio_bitrate + + # 运行时状态 + 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) + + # --------------------------------------------------------------------- + # 对外方法 + # --------------------------------------------------------------------- + + def start(self, config: Optional[Dict[str, Any]] = None): + if self._running: + return {"status": "already_running", "host_id": self.host_id} + + # 应用 config 覆盖(如果有) + if config: + 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) + + self.video_device = config.get("video_device", self.video_device) + self.width = int(config.get("width", self.width)) + self.height = int(config.get("height", self.height)) + self.fps = int(config.get("fps", self.fps)) + self.video_bitrate = config.get("video_bitrate", self.video_bitrate) + self.audio_device = config.get("audio_device", self.audio_device) + self.audio_bitrate = config.get("audio_bitrate", self.audio_bitrate) + + self._running = True + + print("[CameraController] start(): starting FFmpeg streaming...", file=sys.stderr) + self._start_ffmpeg() + + 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, + "video_device": self.video_device, + "width": self.width, + "height": self.height, + "fps": self.fps, + "video_bitrate": self.video_bitrate, + "audio_device": self.audio_device, + } + + def stop(self) -> Dict[str, Any]: + self._running = False + + # 先取消主任务(让 ws connect/sleep 尽快退出) + if self._loop_task is not None and not self._loop_task.done(): + self._loop_task.cancel() + + # 停止推流 + self._stop_ffmpeg() + + # 关闭 WebSocket(在 loop 中执行) + 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 closing WebSocket: {e}", file=sys.stderr) + + try: + asyncio.run_coroutine_threadsafe(close_ws(), self._loop) + except Exception: + pass + + # 停止事件循环 + if self._loop is not None: + try: + self._loop.call_soon_threadsafe(self._loop.stop) + except Exception as e: + print(f"[CameraController] error stopping 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 joining loop thread: {e}", file=sys.stderr) + + self._ws = None + self._loop_task = None + self._loop = None + self._loop_thread = None + + return {"status": "stopped", "host_id": self.host_id} + + def get_status(self) -> Dict[str, Any]: + 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, + "video_device": self.video_device, + "width": self.width, + "height": self.height, + "fps": self.fps, + "video_bitrate": self.video_bitrate, + } + + # --------------------------------------------------------------------- + # WebSocket / 信令 + # --------------------------------------------------------------------- + + async def _run_main_loop(self): + print("[CameraController] main loop started", file=sys.stderr) + try: + while self._running: + try: + async with websockets.connect(self.signal_backend_url) as ws: + self._ws = ws + print(f"[CameraController] WebSocket connected: {self.signal_backend_url}", file=sys.stderr) + 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 + finally: + print("[CameraController] main loop exited", file=sys.stderr) + + 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] non-JSON message: {message}", file=sys.stderr) + continue + + try: + await self._handle_message(data) + except Exception as e: + print(f"[CameraController] error handling message {data}: {e}", file=sys.stderr) + + async def _handle_message(self, data: Dict[str, Any]): + cmd = data.get("command") + + if cmd == "start_stream": + self._start_ffmpeg() + return + + if cmd == "stop_stream": + self._stop_ffmpeg() + return + + if data.get("type") == "offer": + offer_sdp = data.get("sdp", "") + camera_id = data.get("cameraId", "camera-01") + + answer_sdp = await self._handle_webrtc_offer(offer_sdp) + + if self._ws: + answer_payload = { + "type": "answer", + "sdp": answer_sdp, + "cameraId": camera_id, + "hostId": self.host_id, + } + await self._ws.send(json.dumps(answer_payload)) + + # --------------------------------------------------------------------- + # FFmpeg 推流(V4L2 USB 摄像头) + # --------------------------------------------------------------------- + + def _start_ffmpeg(self): + if self._ffmpeg_process and self._ffmpeg_process.poll() is None: + return + + # 兼容性优先:不强制输入像素格式;失败再通过外部调整 width/height/fps + video_size = f"{self.width}x{self.height}" + + cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "warning", + + # video input + "-f", "v4l2", + "-framerate", str(self.fps), + "-video_size", video_size, + "-i", self.video_device, + ] + + # optional audio input + if self.audio_device: + cmd += [ + "-f", "alsa", + "-i", self.audio_device, + "-c:a", "aac", + "-b:a", self.audio_bitrate, + "-ar", "44100", + "-ac", "1", + ] + else: + cmd += ["-an"] + + # video encode + rtmp out + cmd += [ + "-c:v", "libx264", + "-preset", "ultrafast", + "-tune", "zerolatency", + "-profile:v", "baseline", + "-pix_fmt", "yuv420p", + "-b:v", self.video_bitrate, + "-maxrate", self.video_bitrate, + "-bufsize", "2M", + "-g", str(max(self.fps, 10)), + "-keyint_min", str(max(self.fps, 10)), + "-sc_threshold", "0", + "-x264-params", "bframes=0", + + "-f", "flv", + self.rtmp_url, + ] + + print(f"[CameraController] starting FFmpeg: {' '.join(cmd)}", file=sys.stderr) + + try: + # 不再丢弃日志,至少能看到 ffmpeg 报错(调试很关键) + self._ffmpeg_process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=sys.stderr, + shell=False, + ) + except Exception as e: + self._ffmpeg_process = None + print(f"[CameraController] failed to start FFmpeg: {e}", file=sys.stderr) + + 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: + proc.kill() + except Exception as e: + print(f"[CameraController] error stopping FFmpeg: {e}", file=sys.stderr) + self._ffmpeg_process = None + + # --------------------------------------------------------------------- + # WebRTC offer -> SRS + # --------------------------------------------------------------------- + + 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_post(): + return requests.post(self.webrtc_api, json=payload, headers=headers, timeout=10) + + loop = asyncio.get_running_loop() + resp = await loop.run_in_executor(None, _do_post) + + resp.raise_for_status() + data = resp.json() + answer_sdp = data.get("sdp", "") + if not answer_sdp: + raise RuntimeError(f"empty SDP from media server: {data}") + return answer_sdp + + +if __name__ == "__main__": + # 直接运行用于手动测试 + c = CameraController( + host_id="demo-host", + video_device="/dev/video0", + width=1280, + height=720, + fps=30, + video_bitrate="1500k", + audio_device=None, + ) + try: + while True: + asyncio.sleep(1) + except KeyboardInterrupt: + c.stop() \ No newline at end of file diff --git a/unilabos/devices/cameraSII/cameraUSB_test.py b/unilabos/devices/cameraSII/cameraUSB_test.py new file mode 100644 index 00000000..fd2da1ac --- /dev/null +++ b/unilabos/devices/cameraSII/cameraUSB_test.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +import time +import json + +from cameraUSB import CameraController + + +def main(): + # 按你的实际情况改 + cfg = dict( + host_id="demo-host", + signal_backend_url="wss://sciol.ac.cn/api/realtime/signal/host", + rtmp_url="rtmp://srs.sciol.ac.cn:4499/live/camera-01", + webrtc_api="https://srs.sciol.ac.cn/rtc/v1/play/", + webrtc_stream_url="webrtc://srs.sciol.ac.cn:4500/live/camera-01", + video_device="/dev/video7", + width=1280, + height=720, + fps=30, + video_bitrate="1500k", + audio_device=None, + ) + + c = CameraController(**cfg) + + # 可选:如果你不想依赖 __init__ 自动 start,可以这样显式调用: + # c = CameraController(host_id=cfg["host_id"]) + # c.start(cfg) + + run_seconds = 30 # 测试运行时长 + t0 = time.time() + + try: + while True: + st = c.get_status() + print(json.dumps(st, ensure_ascii=False, indent=2)) + + if time.time() - t0 >= run_seconds: + break + + time.sleep(2) + except KeyboardInterrupt: + print("Interrupted, stopping...") + finally: + print("Stopping controller...") + c.stop() + print("Done.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/unilabos/devices/cameraSII/demo_camera_pic.py b/unilabos/devices/cameraSII/demo_camera_pic.py new file mode 100644 index 00000000..6e117a97 --- /dev/null +++ b/unilabos/devices/cameraSII/demo_camera_pic.py @@ -0,0 +1,36 @@ +import cv2 + +# 推荐把 @ 进行 URL 编码:@ -> %40 +RTSP_URL = "rtsp://admin:admin123@192.168.31.164:554/stream1" +OUTPUT_IMAGE = "rtsp_test_frame.jpg" + +def main(): + print(f"尝试连接 RTSP 流: {RTSP_URL}") + cap = cv2.VideoCapture(RTSP_URL) + + if not cap.isOpened(): + print("错误:无法打开 RTSP 流,请检查:") + print(" 1. IP/端口是否正确") + print(" 2. 账号密码(尤其是 @ 是否已转成 %40)是否正确") + print(" 3. 摄像头是否允许当前主机访问(同一网段、防火墙等)") + return + + print("连接成功,开始读取一帧...") + ret, frame = cap.read() + + if not ret or frame is None: + print("错误:已连接但未能读取到帧数据(可能是码流未开启或网络抖动)") + cap.release() + return + + # 保存当前帧 + success = cv2.imwrite(OUTPUT_IMAGE, frame) + cap.release() + + if success: + print(f"成功截取一帧并保存为: {OUTPUT_IMAGE}") + else: + print("错误:写入图片失败,请检查磁盘权限/路径") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/unilabos/devices/cameraSII/demo_camera_push.py b/unilabos/devices/cameraSII/demo_camera_push.py new file mode 100644 index 00000000..8cb02227 --- /dev/null +++ b/unilabos/devices/cameraSII/demo_camera_push.py @@ -0,0 +1,21 @@ +# run_camera_push.py +import time +from cameraDriver import CameraController # 这里根据你的文件名调整 + +if __name__ == "__main__": + controller = CameraController( + host_id="demo-host", + signal_backend_url="wss://sciol.ac.cn/api/realtime/signal/host", + rtmp_url="rtmp://srs.sciol.ac.cn:4499/live/camera-01", + webrtc_api="https://srs.sciol.ac.cn/rtc/v1/play/", + webrtc_stream_url="webrtc://srs.sciol.ac.cn:4500/live/camera-01", + camera_rtsp_url="rtsp://admin:admin123@192.168.31.164:554/stream1", + ) + + try: + while True: + status = controller.get_status() + print(status) + time.sleep(5) + except KeyboardInterrupt: + controller.stop() \ No newline at end of file diff --git a/unilabos/devices/cameraSII/ptz_cameracontroller_test.py b/unilabos/devices/cameraSII/ptz_cameracontroller_test.py new file mode 100644 index 00000000..2d788b62 --- /dev/null +++ b/unilabos/devices/cameraSII/ptz_cameracontroller_test.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +使用 CameraController 来测试 PTZ: +让摄像头按顺序向下、向上、向左、向右运动几次。 +""" + +import time +import sys + +# 根据你的工程结构修改导入路径: +# 假设 CameraController 定义在 cameraController.py 里 +from cameraDriver import CameraController + + +def main(): + # === 根据你的实际情况填 IP、端口、账号密码 === + ptz_host = "192.168.31.164" + ptz_port = 2020 # 注意要和你单独测试 PTZController 时保持一致 + ptz_user = "admin" + ptz_password = "admin123" + + # 1. 创建 CameraController 实例 + cam = CameraController( + # 其他摄像机相关参数按你类的 __init__ 来补充 + ptz_host=ptz_host, + ptz_port=ptz_port, + ptz_user=ptz_user, + ptz_password=ptz_password, + ) + + # 2. 启动 / 初始化(如果你的 CameraController 有 start(config) 之类的接口) + # 这里给一个最小的 config,重点是 PTZ 相关字段 + config = { + "ptz_host": ptz_host, + "ptz_port": ptz_port, + "ptz_user": ptz_user, + "ptz_password": ptz_password, + } + + try: + cam.start(config) + except Exception as e: + print(f"[TEST] CameraController start() 失败: {e}", file=sys.stderr) + return + + # 这里可以判断一下内部 _ptz 是否初始化成功(如果你对 CameraController 做了封装) + if getattr(cam, "_ptz", None) is None: + print("[TEST] CameraController 内部 PTZ 未初始化成功,请检查 ptz_host/port/user/password 配置。", file=sys.stderr) + return + + # 3. 依次调用 CameraController 的 PTZ 方法 + # 这里假设你在 CameraController 中提供了这几个对外方法: + # ptz_move_down / ptz_move_up / ptz_move_left / ptz_move_right + # 如果你命名不一样,把下面调用名改成你的即可。 + + print("向下移动(通过 CameraController)...") + cam.ptz_move_down(speed=0.5, duration=1.0) + time.sleep(1) + + print("向上移动(通过 CameraController)...") + cam.ptz_move_up(speed=0.5, duration=1.0) + time.sleep(1) + + print("向左移动(通过 CameraController)...") + cam.ptz_move_left(speed=0.5, duration=1.0) + time.sleep(1) + + print("向右移动(通过 CameraController)...") + cam.ptz_move_right(speed=0.5, duration=1.0) + time.sleep(1) + + print("测试结束。") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/unilabos/devices/cameraSII/ptz_test.py b/unilabos/devices/cameraSII/ptz_test.py new file mode 100644 index 00000000..018c3d29 --- /dev/null +++ b/unilabos/devices/cameraSII/ptz_test.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +测试 cameraDriver.py中的 PTZController 类,让摄像头按顺序运动几次 +""" + +import time + +from cameraDriver import PTZController + + +def main(): + # 根据你的实际情况填 IP、端口、账号密码 + host = "192.168.31.164" + port = 80 + user = "admin" + password = "admin123" + + ptz = PTZController(host=host, port=port, user=user, password=password) + + # 1. 连接摄像头 + if not ptz.connect(): + print("连接 PTZ 失败,检查 IP/用户名/密码/端口。") + return + + # 2. 依次测试几个动作 + # 每个动作之间 sleep 一下方便观察 + + print("向下移动...") + ptz.move_down(speed=0.5, duration=1.0) + time.sleep(1) + + print("向上移动...") + ptz.move_up(speed=0.5, duration=1.0) + time.sleep(1) + + print("向左移动...") + ptz.move_left(speed=0.5, duration=1.0) + time.sleep(1) + + print("向右移动...") + ptz.move_right(speed=0.5, duration=1.0) + time.sleep(1) + + print("测试结束。") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/unilabos/registry/devices/cameraSII.yaml b/unilabos/registry/devices/cameraSII.yaml new file mode 100644 index 00000000..d817f48a --- /dev/null +++ b/unilabos/registry/devices/cameraSII.yaml @@ -0,0 +1,105 @@ +cameracontroller_device: + category: + - cameraSII + class: + action_value_mappings: + auto-start: + feedback: {} + goal: {} + goal_default: + config: null + handles: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + config: + type: string + required: [] + type: object + result: {} + required: + - goal + title: start参数 + type: object + type: UniLabJsonCommand + auto-stop: + feedback: {} + goal: {} + goal_default: {} + handles: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: stop参数 + type: object + type: UniLabJsonCommand + module: unilabos.devices.cameraSII.cameraUSB:CameraController + status_types: + status: dict + type: python + config_info: [] + description: Uni-Lab-OS 摄像头驱动(Linux USB 摄像头版,无 PTZ) + handles: [] + icon: '' + init_param_schema: + config: + properties: + audio_bitrate: + default: 64k + type: string + audio_device: + type: string + fps: + default: 30 + type: integer + height: + default: 720 + type: integer + host_id: + default: demo-host + type: string + rtmp_url: + default: rtmp://srs.sciol.ac.cn:4499/live/camera-01 + type: string + signal_backend_url: + default: wss://sciol.ac.cn/api/realtime/signal/host + type: string + video_bitrate: + default: 1500k + type: string + video_device: + default: /dev/video0 + type: string + webrtc_api: + default: https://srs.sciol.ac.cn/rtc/v1/play/ + type: string + webrtc_stream_url: + default: webrtc://srs.sciol.ac.cn:4500/live/camera-01 + type: string + width: + default: 1280 + type: integer + required: [] + type: object + data: + properties: + status: + type: object + required: + - status + type: object + registry_type: device + version: 1.0.0