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端控制云台。 标定时应等待程序自己完成到位回读,不要同时在Web端控制云台。
`interactive.sh`启动时会先执行`docker exec smart-cam smart-camctl control pause`,
同时停止扫图、巡航和自动追踪对云台的新控制。向导正常完成、发生错误或用户
按`Ctrl+C`退出时,Shell的`EXIT` trap都会执行`smart-camctl control resume`,
恢复巡航和自动追踪。
## 目录结构 ## 目录结构
```text ```text
@@ -295,8 +300,8 @@ zoom
2. 只更新`scan`中的七个运动参数,保留定时时间、等待时间、容差和HTTP设置; 2. 只更新`scan`中的七个运动参数,保留定时时间、等待时间、容差和HTTP设置;
3. 用`python3 -m json.tool`验证新JSON; 3. 用`python3 -m json.tool`验证新JSON;
4. 原子替换配置,不另外备份原配置; 4. 原子替换配置,不另外备份原配置;
5. 执行`docker restart smart-cam`; 5. 若宿主机存在`smart-cam.service`,通过systemd重启,避免systemd与`docker restart`同时管理容器;否则执行`docker restart smart-cam`;
6. 等待scan HTTP服务重新可用。 6. 等待scan HTTP服务连续稳定15秒后再进入试扫。
回复`3`不会替换配置。 回复`3`不会替换配置。
+45 -1
View File
@@ -2,4 +2,48 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONDONTWRITEBYTECODE=1 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 io
import json import json
import math import math
import os
from pathlib import Path from pathlib import Path
import re import re
import select import select
@@ -906,16 +907,33 @@ def deploy_config(scan: dict) -> None:
finally: finally:
local_temp.unlink(missing_ok=True) local_temp.unlink(missing_ok=True)
subprocess.run(["docker", "restart", "smart-cam"], check=True, unit = subprocess.run(
stdout=subprocess.DEVNULL) ["systemctl", "show", "-p", "LoadState", "--value", "smart-cam.service"],
deadline = time.monotonic() + 90 text=True, capture_output=True,
while time.monotonic() < deadline: )
try: if unit.returncode == 0 and unit.stdout.strip() == "loaded":
core.api("GET", "/api/v1/scan/status", timeout=5) restart = ["systemctl", "restart", "smart-cam.service"]
return if os.geteuid() != 0:
except (SystemExit, subprocess.SubprocessError): restart = ["sudo", "-n", *restart]
time.sleep(2) result = subprocess.run(restart, text=True, capture_output=True)
raise RuntimeError("smart-cam重启后90秒内scan HTTP服务未就绪") 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: def run_test_scan() -> dict:
@@ -1068,9 +1086,11 @@ def acquire_calibration_control() -> bool:
say("已临时暂停巡航;标定结束或正常中断时会自动恢复。", color=YELLOW) say("已临时暂停巡航;标定结束或正常中断时会自动恢复。", color=YELLOW)
try: try:
# This is a synchronization barrier: GET waits behind any PTZ command # This synchronization barrier waits behind the one PTZ command that
# already in flight when patrol was paused, then claims manual control. # was already in flight when patrol was paused. Tiandy emulated
core.get_ptz() # absolute moves can legitimately take up to 135 seconds.
say("正在等待已暂停巡航的最后一条PTZ指令结束……")
core.get_ptz(timeout=160)
except BaseException: except BaseException:
if should_resume: if should_resume:
try: 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: if result.returncode in TRANSIENT_CURL_EXIT_CODES:
return True return True
detail = f"{result.stdout}\n{result.stderr}".lower() 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 not running",
"container is restarting", "container is restarting",
"no such container", "no such container",
@@ -138,6 +138,25 @@ def wait_for_scan_http(timeout: float = API_RECOVERY_TIMEOUT) -> bool:
return False 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: def api(method: str, path: str, body: dict | None = None, timeout: int = 60) -> dict:
global _HELPER_READY global _HELPER_READY
command = api_command(method, path, body, timeout) command = api_command(method, path, body, timeout)
@@ -218,8 +237,10 @@ def parse_ptz_payload(payload: dict) -> dict[str, int]:
return result return result
def get_ptz() -> dict[str, int]: def get_ptz(timeout: int = 50) -> dict[str, int]:
payload = api("GET", "/api/v1/ptz?source=scan_calibrator", timeout=50) payload = api(
"GET", "/api/v1/ptz?source=scan_calibrator", timeout=timeout
)
return parse_ptz_payload(payload) return parse_ptz_payload(payload)
+13 -1
View File
@@ -51,6 +51,18 @@ class ApiRecoveryTests(unittest.TestCase):
self.assertEqual(payload["scan"]["state"], "idle") self.assertEqual(payload["scan"]["state"], "idle")
wait_mock.assert_called_once_with() 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): def test_move_not_reached_error_is_explained_for_operator(self):
result = completed( result = completed(
22, 22,
@@ -230,7 +242,7 @@ class InteractiveWizardTests(unittest.TestCase):
"POST", "/api/v1/patrol/stop", "POST", "/api/v1/patrol/stop",
{"source": "scan_calibrator"}, timeout=15, {"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): def test_manual_ptz_accepts_annotated_display_format(self):
self.assertEqual( self.assertEqual(