Files
Uni-Lab-OS/unilabos/devices/cameraSII/cameraDriver.py
2025-12-16 00:04:52 +08:00

712 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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/<host_id>
- 媒体服务器:通过 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 = "",
# 3PTZ 控制相关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/<host_id>
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