tiandytmd
This commit is contained in:
1 parent
f7a782dcef
commit
ddea6d0578
6 files changed
+389
-34
No files matched your search
+95
-8
@@ -8,6 +8,7 @@ from datetime import datetime
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -26,6 +27,7 @@ API_RECOVERY_TIMEOUT = 90.0
|
||||
API_RECOVERY_POLL_INTERVAL = 2.0
|
||||
TRANSIENT_CURL_EXIT_CODES = {7, 28, 52, 55, 56}
|
||||
_HELPER_READY = False
|
||||
REQUIRED_PTZ_CAPABILITIES = {"get_position", "move_absolute"}
|
||||
|
||||
|
||||
def fail(message: str) -> "NoReturn":
|
||||
@@ -69,6 +71,45 @@ def transient_api_failure(result: subprocess.CompletedProcess[str]) -> bool:
|
||||
))
|
||||
|
||||
|
||||
def api_error_detail(result: subprocess.CompletedProcess[str]) -> str:
|
||||
"""Turn scan-service JSON errors into field-operator instructions."""
|
||||
message = ""
|
||||
code = ""
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
error = payload.get("error", {})
|
||||
if isinstance(error, dict):
|
||||
code = str(error.get("code") or "")
|
||||
message = str(error.get("message") or "")
|
||||
except (json.JSONDecodeError, AttributeError, TypeError):
|
||||
message = result.stdout.strip()
|
||||
|
||||
reached = re.search(
|
||||
r"move_not_reached\s+pan_error=(-?\d+)\s+tilt_error=(-?\d+)\s+"
|
||||
r"zoom_error=(-?\d+)\s+target=(-?\d+),(-?\d+),(-?\d+)\s+"
|
||||
r"actual=(-?\d+),(-?\d+),(-?\d+)",
|
||||
message,
|
||||
)
|
||||
if reached:
|
||||
pe, te, ze, tp, tt, tz, ap, at, az = (
|
||||
int(value) for value in reached.groups()
|
||||
)
|
||||
return (
|
||||
"摄像头在限定时间内没有完全到位。\n"
|
||||
f" 目标:pan={tp} ({tp / 100:.2f}°), "
|
||||
f"tilt={tt} ({tt / 100:.2f}°), zoom={tz} ({tz / 100:.2f}×)\n"
|
||||
f" 实际:pan={ap} ({ap / 100:.2f}°), "
|
||||
f"tilt={at} ({at / 100:.2f}°), zoom={az} ({az / 100:.2f}×)\n"
|
||||
f" 未到位差值:pan={pe / 100:.2f}°, "
|
||||
f"tilt={te / 100:.2f}°, zoom={ze / 100:.2f}×\n"
|
||||
"现场处理:请勿再操作Web云台,把第四步标定位置放在"
|
||||
"扫描区域中部后重试;程序会自动限制后续测试步长。"
|
||||
)
|
||||
if message:
|
||||
return f"摄像头控制失败({code or 'unknown'}):{message}"
|
||||
return result.stderr.strip() or f"请求退出码={result.returncode}"
|
||||
|
||||
|
||||
def scan_http_ready() -> bool:
|
||||
try:
|
||||
state = subprocess.run(
|
||||
@@ -114,9 +155,7 @@ def api(method: str, path: str, body: dict | None = None, timeout: int = 60) ->
|
||||
except json.JSONDecodeError:
|
||||
fail(f"scan HTTP返回的不是JSON:{result.stdout}")
|
||||
|
||||
last_detail = result.stderr.strip() or result.stdout.strip() or (
|
||||
f"退出码={result.returncode}"
|
||||
)
|
||||
last_detail = api_error_detail(result)
|
||||
if not transient_api_failure(result) or attempt == API_MAX_ATTEMPTS:
|
||||
break
|
||||
warn(
|
||||
@@ -131,17 +170,65 @@ def api(method: str, path: str, body: dict | None = None, timeout: int = 60) ->
|
||||
# the in-container overlap helper before the next image comparison.
|
||||
_HELPER_READY = False
|
||||
|
||||
fail(f"命令失败:{' '.join(command)}\n{last_detail}")
|
||||
fail(f"scan HTTP请求失败:{method} {path}\n{last_detail}")
|
||||
|
||||
|
||||
def validate_control_status(payload: dict) -> dict:
|
||||
"""Validate the vendor-neutral PTZ contract exposed by smart-cam."""
|
||||
if not isinstance(payload, dict):
|
||||
fail("scan HTTP控制状态不是JSON对象")
|
||||
vendor = str(payload.get("vendor") or "unknown").strip().lower()
|
||||
if payload.get("driver_ready") is not True:
|
||||
detail = str(payload.get("driver_error") or "未返回具体原因")
|
||||
fail(f"摄像头驱动未就绪(vendor={vendor}):{detail}")
|
||||
capabilities = {
|
||||
str(item).strip().lower()
|
||||
for item in payload.get("capabilities", [])
|
||||
if str(item).strip()
|
||||
}
|
||||
missing = sorted(REQUIRED_PTZ_CAPABILITIES - capabilities)
|
||||
if missing:
|
||||
fail(
|
||||
f"当前smart-cam的摄像头适配层缺少标定必需能力:"
|
||||
f"{', '.join(missing)}(vendor={vendor})"
|
||||
)
|
||||
camera = payload.get("camera")
|
||||
return {
|
||||
"vendor": vendor,
|
||||
"capabilities": capabilities,
|
||||
"camera": camera if isinstance(camera, dict) else {},
|
||||
}
|
||||
|
||||
|
||||
def get_control_status() -> tuple[dict, dict]:
|
||||
payload = api("GET", "/api/v1/control/status", timeout=15)
|
||||
return payload, validate_control_status(payload)
|
||||
|
||||
|
||||
def parse_ptz_payload(payload: dict) -> dict[str, int]:
|
||||
try:
|
||||
ptz = payload["ptz"]
|
||||
result = {key: int(ptz[key]) for key in ("pan", "tilt", "zoom")}
|
||||
except (KeyError, TypeError, ValueError):
|
||||
fail(f"scan HTTP未返回合法PTZ坐标:{payload}")
|
||||
if not 0 <= result["pan"] <= 36000:
|
||||
fail(f"scan HTTP返回的pan超出统一坐标范围:{result['pan']}")
|
||||
if not -18000 <= result["tilt"] <= 18000 or result["zoom"] <= 0:
|
||||
fail(f"scan HTTP返回的tilt/zoom无效:{result}")
|
||||
return result
|
||||
|
||||
|
||||
def get_ptz() -> dict[str, int]:
|
||||
payload = api("GET", "/api/v1/ptz", timeout=15)
|
||||
return {key: int(payload["ptz"][key]) for key in ("pan", "tilt", "zoom")}
|
||||
payload = api("GET", "/api/v1/ptz?source=scan_calibrator", timeout=50)
|
||||
return parse_ptz_payload(payload)
|
||||
|
||||
|
||||
def move(ptz: dict[str, int]) -> dict[str, int]:
|
||||
payload = api("POST", "/api/v1/ptz/absolute", ptz, timeout=50)
|
||||
return {key: int(payload["ptz"][key]) for key in ("pan", "tilt", "zoom")}
|
||||
payload = api(
|
||||
"POST", "/api/v1/ptz/absolute",
|
||||
{**ptz, "source": "scan_calibrator"}, timeout=50,
|
||||
)
|
||||
return parse_ptz_payload(payload)
|
||||
|
||||
|
||||
def snapshot() -> str:
|
||||
|
||||
Reference in new issue
Block a user