tiandytdd
This commit is contained in:
1 parent
ddea6d0578
commit
5746fa10db
5 files changed
+122
-20
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user