From 5746fa10db8929a847965529f5220f932684ab0c Mon Sep 17 00:00:00 2001 From: Ankle Date: Tue, 15 Sep 2026 11:32:36 +0800 Subject: [PATCH] tiandytdd --- README.md | 9 +++++-- interactive.sh | 46 ++++++++++++++++++++++++++++++++++- interactive_calibrator.py | 46 +++++++++++++++++++++++++---------- scan_calibrator.py | 27 +++++++++++++++++--- tests/test_scan_calibrator.py | 14 ++++++++++- 5 files changed, 122 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index e80defd..b4a8b11 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ 这是当前型号的控制方式,不是标定程序预先排了多个扫描点,也不能单凭这个现象判定电机故障。 标定时应等待程序自己完成到位回读,不要同时在Web端控制云台。 +`interactive.sh`启动时会先执行`docker exec smart-cam smart-camctl control pause`, +同时停止扫图、巡航和自动追踪对云台的新控制。向导正常完成、发生错误或用户 +按`Ctrl+C`退出时,Shell的`EXIT` trap都会执行`smart-camctl control resume`, +恢复巡航和自动追踪。 + ## 目录结构 ```text @@ -295,8 +300,8 @@ zoom 2. 只更新`scan`中的七个运动参数,保留定时时间、等待时间、容差和HTTP设置; 3. 用`python3 -m json.tool`验证新JSON; 4. 原子替换配置,不另外备份原配置; -5. 执行`docker restart smart-cam`; -6. 等待scan HTTP服务重新可用。 +5. 若宿主机存在`smart-cam.service`,通过systemd重启,避免systemd与`docker restart`同时管理容器;否则执行`docker restart smart-cam`; +6. 等待scan HTTP服务连续稳定15秒后再进入试扫。 回复`3`不会替换配置。 diff --git a/interactive.sh b/interactive.sh index 04098a0..22faf68 100755 --- a/interactive.sh +++ b/interactive.sh @@ -2,4 +2,48 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export PYTHONDONTWRITEBYTECODE=1 -exec python3 "$SCRIPT_DIR/interactive_calibrator.py" + +control_paused=false +wait_for_smart_cam() { + local deadline=$((SECONDS + 180)) + local stable_checks=0 + while (( SECONDS < deadline )); do + if docker inspect -f '{{.State.Running}}' smart-cam 2>/dev/null | grep -qx true \ + && docker exec smart-cam curl -fsS --max-time 5 -o /dev/null \ + http://127.0.0.1:18080/health 2>/dev/null; then + stable_checks=$((stable_checks + 1)) + if (( stable_checks >= 8 )); then + return 0 + fi + else + stable_checks=0 + fi + sleep 2 + done + return 1 +} + +resume_control() { + local exit_code=$? + trap - EXIT + if [[ "$control_paused" == true ]]; then + printf '\n正在恢复smart-cam巡航和自动追踪……\n' + if ! wait_for_smart_cam \ + || ! docker exec smart-cam smart-camctl control resume; then + printf '错误:标定已结束,但自动恢复失败。请立即手动执行:\n' >&2 + printf ' docker exec smart-cam smart-camctl control resume\n' >&2 + exit_code=1 + fi + fi + exit "$exit_code" +} +trap resume_control EXIT + +printf '正在暂停smart-cam的扫图、巡航和自动追踪,准备独占云台……\n' +if ! docker exec smart-cam smart-camctl control pause; then + printf '错误:无法暂停smart-cam自动控制,本次不启动标定。\n' >&2 + exit 1 +fi +control_paused=true + +python3 "$SCRIPT_DIR/interactive_calibrator.py" diff --git a/interactive_calibrator.py b/interactive_calibrator.py index 4e49462..91f9353 100755 --- a/interactive_calibrator.py +++ b/interactive_calibrator.py @@ -10,6 +10,7 @@ from datetime import datetime import io import json import math +import os from pathlib import Path import re import select @@ -906,16 +907,33 @@ def deploy_config(scan: dict) -> None: finally: local_temp.unlink(missing_ok=True) - subprocess.run(["docker", "restart", "smart-cam"], check=True, - stdout=subprocess.DEVNULL) - deadline = time.monotonic() + 90 - while time.monotonic() < deadline: - try: - core.api("GET", "/api/v1/scan/status", timeout=5) - return - except (SystemExit, subprocess.SubprocessError): - time.sleep(2) - raise RuntimeError("smart-cam重启后90秒内scan HTTP服务未就绪") + unit = subprocess.run( + ["systemctl", "show", "-p", "LoadState", "--value", "smart-cam.service"], + text=True, capture_output=True, + ) + if unit.returncode == 0 and unit.stdout.strip() == "loaded": + restart = ["systemctl", "restart", "smart-cam.service"] + if os.geteuid() != 0: + restart = ["sudo", "-n", *restart] + result = subprocess.run(restart, text=True, capture_output=True) + if result.returncode: + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError( + "宿主机由smart-cam.service管理容器,但无法通过systemd重启。" + "请使用root执行本向导,或为systemctl restart smart-cam.service配置sudo权限。" + f"\n{detail}" + ) + else: + subprocess.run( + ["docker", "restart", "smart-cam"], check=True, + stdout=subprocess.DEVNULL, + ) + + if not core.wait_for_scan_http_stable(timeout=180, stable_seconds=15): + raise RuntimeError( + "smart-cam重启后未能连续稳定运行15秒。请执行 " + "systemctl status smart-cam.service 和 docker logs --tail 100 smart-cam 查因。" + ) def run_test_scan() -> dict: @@ -1068,9 +1086,11 @@ def acquire_calibration_control() -> bool: say("已临时暂停巡航;标定结束或正常中断时会自动恢复。", color=YELLOW) try: - # This is a synchronization barrier: GET waits behind any PTZ command - # already in flight when patrol was paused, then claims manual control. - core.get_ptz() + # This synchronization barrier waits behind the one PTZ command that + # was already in flight when patrol was paused. Tiandy emulated + # absolute moves can legitimately take up to 135 seconds. + say("正在等待已暂停巡航的最后一条PTZ指令结束……") + core.get_ptz(timeout=160) except BaseException: if should_resume: try: diff --git a/scan_calibrator.py b/scan_calibrator.py index 5a1512c..df6272b 100755 --- a/scan_calibrator.py +++ b/scan_calibrator.py @@ -58,7 +58,7 @@ def transient_api_failure(result: subprocess.CompletedProcess[str]) -> bool: if result.returncode in TRANSIENT_CURL_EXIT_CODES: return True detail = f"{result.stdout}\n{result.stderr}".lower() - return any(marker in detail for marker in ( + return bool(re.search(r"container(?:\s+[0-9a-f]+)?\s+is not running", detail)) or any(marker in detail for marker in ( "container is not running", "container is restarting", "no such container", @@ -138,6 +138,25 @@ def wait_for_scan_http(timeout: float = API_RECOVERY_TIMEOUT) -> bool: return False +def wait_for_scan_http_stable( + timeout: float = 180.0, stable_seconds: float = 15.0 +) -> bool: + """Wait until scan HTTP stays healthy across the systemd restart window.""" + deadline = time.monotonic() + timeout + ready_since: float | None = None + while time.monotonic() < deadline: + now = time.monotonic() + if scan_http_ready(): + if ready_since is None: + ready_since = now + elif now - ready_since >= stable_seconds: + return True + else: + ready_since = None + time.sleep(API_RECOVERY_POLL_INTERVAL) + return False + + def api(method: str, path: str, body: dict | None = None, timeout: int = 60) -> dict: global _HELPER_READY command = api_command(method, path, body, timeout) @@ -218,8 +237,10 @@ def parse_ptz_payload(payload: dict) -> dict[str, int]: return result -def get_ptz() -> dict[str, int]: - payload = api("GET", "/api/v1/ptz?source=scan_calibrator", timeout=50) +def get_ptz(timeout: int = 50) -> dict[str, int]: + payload = api( + "GET", "/api/v1/ptz?source=scan_calibrator", timeout=timeout + ) return parse_ptz_payload(payload) diff --git a/tests/test_scan_calibrator.py b/tests/test_scan_calibrator.py index f7fd67b..ab00fe8 100644 --- a/tests/test_scan_calibrator.py +++ b/tests/test_scan_calibrator.py @@ -51,6 +51,18 @@ class ApiRecoveryTests(unittest.TestCase): self.assertEqual(payload["scan"]["state"], "idle") wait_mock.assert_called_once_with() + def test_container_id_not_running_is_transient(self): + result = completed( + 1, + stderr=( + "Error response from daemon: container " + "181a3df5ec75c1ed7183528c758078fee72b5f23505e18dd25b46789a01efac2 " + "is not running" + ), + ) + + self.assertTrue(core.transient_api_failure(result)) + def test_move_not_reached_error_is_explained_for_operator(self): result = completed( 22, @@ -230,7 +242,7 @@ class InteractiveWizardTests(unittest.TestCase): "POST", "/api/v1/patrol/stop", {"source": "scan_calibrator"}, timeout=15, ) - get_ptz_mock.assert_called_once_with() + get_ptz_mock.assert_called_once_with(timeout=160) def test_manual_ptz_accepts_annotated_display_format(self): self.assertEqual(