tiandytdd

This commit is contained in:
Ankle committed 2026-09-15 11:32:36 +08:00
1 parent ddea6d0578
commit 5746fa10db
5 files changed
+122 -20

No files matched your search

+7 -2
View File
@@ -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`不会替换配置。
+45 -1
View File
@@ -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"
+33 -13
View File
@@ -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:
+24 -3
View File
@@ -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)
+13 -1
View File
@@ -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(