|
|
from __future__ import annotations
|
|
|
|
|
|
import logging
|
|
|
import time
|
|
|
from enum import Enum
|
|
|
from typing import NamedTuple, Union, Optional, Tuple
|
|
|
|
|
|
import requests
|
|
|
from requests.auth import HTTPDigestAuth
|
|
|
from requests.adapters import HTTPAdapter
|
|
|
|
|
|
try:
|
|
|
from urllib3.util.retry import Retry # type: ignore
|
|
|
except Exception: # на всякий случай
|
|
|
Retry = None # type: ignore
|
|
|
|
|
|
from . import config, state
|
|
|
from .utils import clipf, quantf
|
|
|
from .geom import sector_contains, ang_diff_signed, normalize360
|
|
|
|
|
|
logger = logging.getLogger("PTZTracker.PTZ")
|
|
|
|
|
|
_sess = requests.Session()
|
|
|
_sess.trust_env = False
|
|
|
|
|
|
if Retry is not None:
|
|
|
_adapter = HTTPAdapter(
|
|
|
pool_connections=64,
|
|
|
pool_maxsize=64,
|
|
|
max_retries=Retry(
|
|
|
total=2,
|
|
|
backoff_factor=0.2,
|
|
|
status_forcelist=[500, 502, 503, 504],
|
|
|
raise_on_status=False,
|
|
|
),
|
|
|
)
|
|
|
else:
|
|
|
_adapter = HTTPAdapter(pool_connections=64, pool_maxsize=64, max_retries=0)
|
|
|
|
|
|
_sess.mount("http://", _adapter)
|
|
|
_sess.mount("https://", _adapter)
|
|
|
|
|
|
PTZ_HEADERS = {"Content-Type": "application/xml", "Connection": "keep-alive"}
|
|
|
|
|
|
AF_CAP_KEY = "af_caps" # ключ в state.ptz_states[cam_idx]
|
|
|
|
|
|
|
|
|
def _af_caps(st: dict) -> dict:
|
|
|
caps = st.get(AF_CAP_KEY)
|
|
|
if not isinstance(caps, dict):
|
|
|
caps = {}
|
|
|
st[AF_CAP_KEY] = caps
|
|
|
return caps
|
|
|
|
|
|
|
|
|
def _base(cam_idx: int):
|
|
|
"""
|
|
|
Возвращает:
|
|
|
base(str), ch(int), auth, verify_tls(bool), timeout_sec(float), cfg(dict)
|
|
|
где base = "{scheme}://{host}:{port}"
|
|
|
"""
|
|
|
cfg = config.CAMERA_CONFIG[cam_idx]
|
|
|
scheme = cfg.get("scheme") or ("https" if cfg.get("https") else "http")
|
|
|
host = cfg["ip"]
|
|
|
port = int(cfg.get("port") or (443 if scheme == "https" else 80))
|
|
|
ch = int(cfg.get("ptz_channel", 1))
|
|
|
|
|
|
user = cfg.get("username") or cfg.get("user") or cfg.get("login") or "admin"
|
|
|
pwd = cfg.get("password") or cfg.get("pass") or cfg.get("pwd") or ""
|
|
|
|
|
|
auth = HTTPDigestAuth(user, pwd)
|
|
|
base = f"{scheme}://{host}:{port}"
|
|
|
verify = bool(cfg.get("verify_tls", False))
|
|
|
timeout = float(cfg.get("ptz_timeout_sec", 3.0))
|
|
|
|
|
|
return base, ch, auth, verify, timeout, cfg
|
|
|
|
|
|
|
|
|
def _ptz_put(
|
|
|
url: str,
|
|
|
*,
|
|
|
data: bytes | str = b"",
|
|
|
headers=None,
|
|
|
auth=None,
|
|
|
timeout: float = 3.0,
|
|
|
verify: bool = False,
|
|
|
) -> Tuple[bool, int, str]:
|
|
|
"""
|
|
|
Унифицированный PUT с логом и отрезанным телом ответа.
|
|
|
Возвращает (ok, http_code, body[:300]).
|
|
|
"""
|
|
|
try:
|
|
|
r = _sess.put(url, data=data, headers=headers or {}, auth=auth, timeout=timeout, verify=verify)
|
|
|
body = (r.text or "")[:300]
|
|
|
ok = 200 <= r.status_code < 300
|
|
|
if not ok:
|
|
|
logger.warning("PTZ HTTP %s url=%s body=%r", r.status_code, url, body)
|
|
|
return ok, r.status_code, body
|
|
|
except Exception as e:
|
|
|
logger.warning("PTZ HTTP EXC url=%s timeout=%.1f verify=%s: %s", url, timeout, verify, e)
|
|
|
return False, 0, str(e)
|
|
|
|
|
|
|
|
|
def _ptz_get(url: str, *, auth=None, timeout: float = 3.0, verify: bool = False):
|
|
|
try:
|
|
|
r = _sess.get(url, auth=auth, timeout=timeout, verify=verify)
|
|
|
return r, None
|
|
|
except Exception as e:
|
|
|
return None, e
|
|
|
|
|
|
|
|
|
class PTZCmd(Enum):
|
|
|
MOVE = "move"
|
|
|
GOTO = "goto"
|
|
|
FOCUS = "focus"
|
|
|
|
|
|
|
|
|
class MoveTuple(NamedTuple):
|
|
|
typ: PTZCmd
|
|
|
cam_idx: int
|
|
|
pan_i: int
|
|
|
tilt_i: int
|
|
|
zoom_i: int
|
|
|
|
|
|
|
|
|
class GotoTuple(NamedTuple):
|
|
|
typ: PTZCmd
|
|
|
cam_idx: int
|
|
|
token: str
|
|
|
|
|
|
|
|
|
class FocusTuple(NamedTuple):
|
|
|
typ: PTZCmd
|
|
|
cam_idx: int
|
|
|
|
|
|
|
|
|
PTZCommand = Union[MoveTuple, GotoTuple, FocusTuple, None]
|
|
|
|
|
|
|
|
|
# ------------------------------
|
|
|
# Zoom reset helper (важно для патруля)
|
|
|
# ------------------------------
|
|
|
def request_zoom_reset(cam_idx: int, duration_sec: float = 2.0, strength: float | None = None) -> None:
|
|
|
"""
|
|
|
Просим "мягко" сбросить зум в течение duration_sec через continuous zoom-out.
|
|
|
strength: отрицательное значение (например -0.35). Если None — берём из config.
|
|
|
"""
|
|
|
st = state.ptz_states[cam_idx]
|
|
|
now = time.time()
|
|
|
st["zoom_reset_until"] = now + float(max(0.2, duration_sec))
|
|
|
if strength is None:
|
|
|
strength = float(getattr(config, "ZOOM_RESET_STRENGTH", -0.35))
|
|
|
st["zoom_reset_strength"] = float(strength)
|
|
|
|
|
|
|
|
|
def _apply_zoom_reset_overlay(cam_idx: int, zoom: float) -> float:
|
|
|
"""
|
|
|
Если активен zoom_reset — принудительно добавляем zoom-out (отрицательное).
|
|
|
"""
|
|
|
st = state.ptz_states[cam_idx]
|
|
|
until = float(st.get("zoom_reset_until", 0.0) or 0.0)
|
|
|
if time.time() < until:
|
|
|
strength = float(st.get("zoom_reset_strength", getattr(config, "ZOOM_RESET_STRENGTH", -0.35)) or -0.35)
|
|
|
# гарантируем, что команда будет "наружу" минимум strength
|
|
|
return min(float(zoom), float(strength))
|
|
|
return zoom
|
|
|
|
|
|
|
|
|
def move_ptz(cam_idx: int, pan: float, tilt: float, zoom: float = 0.0) -> None:
|
|
|
"""
|
|
|
Отправка относительного движения (continuous). Троттлинг + квантизация.
|
|
|
"""
|
|
|
st = state.ptz_states[cam_idx]
|
|
|
now = time.monotonic()
|
|
|
min_dt = getattr(config, "PTZ_MIN_INTERVAL_SEC", 0.12)
|
|
|
if now - st.get("last_ptz_send", 0.0) < min_dt:
|
|
|
return
|
|
|
|
|
|
q = getattr(config, "PTZ_QUANT", 0.01)
|
|
|
pan_f = clipf(quantf(pan, q), -1.0, 1.0)
|
|
|
tilt_f = clipf(quantf(tilt, q), -1.0, 1.0)
|
|
|
zoom_f = clipf(quantf(zoom, q), -1.0, 1.0)
|
|
|
|
|
|
lp, lt, lz = st.get("last_cmd", (0.0, 0.0, 0.0))
|
|
|
eps = getattr(config, "PTZ_EPS", 0.01)
|
|
|
if (abs(pan_f - lp) < eps) and (abs(tilt_f - lt) < eps) and (abs(zoom_f - lz) < eps):
|
|
|
return
|
|
|
|
|
|
st["last_ptz_send"] = now
|
|
|
st["last_cmd"] = (pan_f, tilt_f, zoom_f)
|
|
|
|
|
|
pan_i, tilt_i, zoom_i = int(pan_f * 100), int(tilt_f * 100), int(zoom_f * 100)
|
|
|
if state.ptz_cmd_q is not None:
|
|
|
try:
|
|
|
state.ptz_cmd_q.put_nowait(MoveTuple(PTZCmd.MOVE, cam_idx, pan_i, tilt_i, zoom_i))
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
|
|
|
def stop_ptz(cam_idx: int) -> None:
|
|
|
move_ptz(cam_idx, 0.0, 0.0, 0.0)
|
|
|
|
|
|
|
|
|
def goto_preset_token(cam_idx: int, token: str) -> None:
|
|
|
if state.ptz_cmd_q is not None:
|
|
|
try:
|
|
|
state.ptz_cmd_q.put_nowait(GotoTuple(PTZCmd.GOTO, cam_idx, token))
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
|
|
|
def call_autofocus(cam_idx: int) -> None:
|
|
|
"""
|
|
|
Троттлинг + запуск автофокуса, если камера это поддерживает и не в кулдауне.
|
|
|
Управляется флагом config.AUTOFOCUS_TRY_ISAPI.
|
|
|
"""
|
|
|
if not getattr(config, "AUTOFOCUS_TRY_ISAPI", False):
|
|
|
return
|
|
|
|
|
|
st = state.ptz_states[cam_idx]
|
|
|
now = time.monotonic()
|
|
|
|
|
|
# не чаще 1 раза в 2с
|
|
|
if now - st.get("last_focus_time", 0.0) < 2.0:
|
|
|
return
|
|
|
st["last_focus_time"] = now
|
|
|
|
|
|
caps = _af_caps(st)
|
|
|
cd = float(getattr(config, "AUTOFOCUS_COOLDOWN_SEC", 600.0))
|
|
|
|
|
|
# если знаем, что AF нет — уважаем кулдаун
|
|
|
if caps.get("has") is False and (now - caps.get("last_probe_fail", 0.0) < cd):
|
|
|
return
|
|
|
|
|
|
if state.ptz_cmd_q is not None:
|
|
|
try:
|
|
|
state.ptz_cmd_q.put_nowait(FocusTuple(PTZCmd.FOCUS, cam_idx))
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
|
|
|
def move_ptz_bounded(cam_idx: int, pan_speed: float, tilt_speed: float, zoom: float = 0.0) -> None:
|
|
|
"""
|
|
|
Обёртка над move_ptz.
|
|
|
|
|
|
ВАЖНО: у тебя ограничение сектора реализовано внутри _ptz_worker (по mode/az/sector).
|
|
|
Здесь добавляем только "overlay" сброса зума, чтобы патруль мог вернуть масштаб обратно.
|
|
|
"""
|
|
|
zoom = _apply_zoom_reset_overlay(cam_idx, zoom)
|
|
|
move_ptz(cam_idx, pan_speed, tilt_speed, zoom)
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
# Статус PTZ
|
|
|
# -----------------------------------------------------------------------------
|
|
|
def _parse_ptz_status(xml_text: str) -> Tuple[Optional[float], Optional[float]]:
|
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
|
def _norm_pan(v: float) -> float:
|
|
|
av = abs(v)
|
|
|
# сотые или десятые градуса (часто бывает)
|
|
|
if av > 3600 and av <= 36000:
|
|
|
return v / 100.0
|
|
|
if av > 360 and av <= 3600:
|
|
|
return v / 10.0
|
|
|
return v
|
|
|
|
|
|
def _norm_tilt(v: float) -> float:
|
|
|
av = abs(v)
|
|
|
if av > 900 and av <= 9000:
|
|
|
return v / 100.0
|
|
|
if av > 90 and av <= 900:
|
|
|
return v / 10.0
|
|
|
return v
|
|
|
|
|
|
try:
|
|
|
root = ET.fromstring(xml_text)
|
|
|
|
|
|
def local(tag: str) -> str:
|
|
|
return tag.split("}", 1)[-1] if "}" in tag else tag
|
|
|
|
|
|
# 1) СНАЧАЛА ищем “правильные” теги
|
|
|
pri_pan = ("azimuth", "absolutepan")
|
|
|
pri_tilt = ("elevation", "absolutetilt")
|
|
|
|
|
|
pan_val: Optional[float] = None
|
|
|
tilt_val: Optional[float] = None
|
|
|
|
|
|
for el in root.iter():
|
|
|
name = local(el.tag).lower()
|
|
|
if el.text is None:
|
|
|
continue
|
|
|
txt = el.text.strip()
|
|
|
if not txt:
|
|
|
continue
|
|
|
|
|
|
if pan_val is None and name in pri_pan:
|
|
|
try:
|
|
|
pan_val = _norm_pan(float(txt))
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
if tilt_val is None and name in pri_tilt:
|
|
|
try:
|
|
|
tilt_val = _norm_tilt(float(txt))
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
# 2) ТОЛЬКО если не нашли — пробуем pan/tilt как fallback
|
|
|
if pan_val is None or tilt_val is None:
|
|
|
for el in root.iter():
|
|
|
name = local(el.tag).lower()
|
|
|
if el.text is None:
|
|
|
continue
|
|
|
txt = el.text.strip()
|
|
|
if not txt:
|
|
|
continue
|
|
|
|
|
|
if pan_val is None and name == "pan":
|
|
|
try:
|
|
|
pan_val = _norm_pan(float(txt))
|
|
|
except Exception:
|
|
|
pass
|
|
|
if tilt_val is None and name == "tilt":
|
|
|
try:
|
|
|
tilt_val = _norm_tilt(float(txt))
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
return pan_val, tilt_val
|
|
|
except Exception:
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
def get_ptz_status(cam_idx: int) -> Tuple[Optional[float], Optional[float]]:
|
|
|
"""
|
|
|
Возвращает (azimuth_deg_normalized_0_360 | None, tilt | None).
|
|
|
Пытаемся по http, затем по https (если http дал 2xx, второй не трогаем).
|
|
|
"""
|
|
|
base, ch, auth, verify, timeout, _ = _base(cam_idx)
|
|
|
|
|
|
# Попытка HTTP
|
|
|
url_http = f"{base}/ISAPI/PTZCtrl/channels/{ch}/status"
|
|
|
r, _e = _ptz_get(url_http, auth=auth, timeout=timeout, verify=False if base.startswith("http://") else verify)
|
|
|
if r is not None and (200 <= r.status_code < 300):
|
|
|
pan, tilt = _parse_ptz_status(r.text)
|
|
|
if pan is not None or tilt is not None:
|
|
|
logger.info(
|
|
|
"[ISAPI] cam=%s OK status: pan=%s tilt=%s via %s",
|
|
|
cam_idx,
|
|
|
f"{normalize360(pan):.2f}" if pan is not None else "None",
|
|
|
f"{tilt:.2f}" if tilt is not None else "None",
|
|
|
url_http,
|
|
|
)
|
|
|
return normalize360(pan) if pan is not None else None, tilt
|
|
|
|
|
|
# Попытка HTTPS (если http не дал валидные числа)
|
|
|
host = config.CAMERA_CONFIG[cam_idx]["ip"]
|
|
|
url_https = f"https://{host}:443/ISAPI/PTZCtrl/channels/{ch}/status"
|
|
|
r2, _e2 = _ptz_get(url_https, auth=auth, timeout=timeout, verify=verify)
|
|
|
if r2 is not None and (200 <= r2.status_code < 300):
|
|
|
pan, tilt = _parse_ptz_status(r2.text)
|
|
|
if pan is not None or tilt is not None:
|
|
|
logger.info(
|
|
|
"[ISAPI] cam=%s OK status: pan=%s tilt=%s via %s",
|
|
|
cam_idx,
|
|
|
f"{normalize360(pan):.2f}" if pan is not None else "None",
|
|
|
f"{tilt:.2f}" if tilt is not None else "None",
|
|
|
url_https,
|
|
|
)
|
|
|
return normalize360(pan) if pan is not None else None, tilt
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
def read_ptz_azimuth_deg(cam_idx: int) -> Optional[float]:
|
|
|
pan_deg, _ = get_ptz_status(cam_idx)
|
|
|
return pan_deg
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
# Умный фолбэк автофокуса (с кэшем и кулдауном)
|
|
|
# -----------------------------------------------------------------------------
|
|
|
def _ptz_try_autofocus(base: str, auth, timeout: float, verify: bool, cam_idx: int) -> bool:
|
|
|
st = state.ptz_states[cam_idx]
|
|
|
caps = _af_caps(st)
|
|
|
|
|
|
if caps.get("has") and caps.get("url"):
|
|
|
ok, _code, _body_txt = _ptz_put(caps["url"], data="", headers=PTZ_HEADERS, auth=auth, timeout=timeout, verify=verify)
|
|
|
if ok:
|
|
|
logger.info("[ISAPI] cam=%s autofocus OK via cached %s", cam_idx, caps["url"])
|
|
|
return True
|
|
|
|
|
|
now = time.monotonic()
|
|
|
cd = float(getattr(config, "AUTOFOCUS_COOLDOWN_SEC", 600.0))
|
|
|
if caps.get("has") is False and (now - caps.get("last_probe_fail", 0.0) < cd):
|
|
|
return False
|
|
|
|
|
|
candidates = [
|
|
|
("PUT", f"{base}/ISAPI/PTZCtrl/channels/1/focus",
|
|
|
"<FocusData><focusMode>auto</focusMode><AutoFocusMode>trigger</AutoFocusMode></FocusData>"),
|
|
|
("PUT", f"{base}/ISAPI/PTZCtrl/channels/1/focus", ""),
|
|
|
("PUT", f"{base}/ISAPI/PTZCtrl/channels/1/triggerFocus", ""),
|
|
|
("PUT", f"{base}/ISAPI/Image/channels/1/focus",
|
|
|
"<FocusData><AutoFocusMode>trigger</AutoFocusMode></FocusData>"),
|
|
|
("PUT", f"{base}/ISAPI/Image/channels/1/focus/auto", ""),
|
|
|
("PUT", f"{base}/ISAPI/System/Video/inputs/channels/1/focus",
|
|
|
"<Focus><autoFocus>true</autoFocus></Focus>"),
|
|
|
("PUT", f"{base}/ISAPI/System/Video/inputs/channels/1/focus/auto", ""),
|
|
|
]
|
|
|
|
|
|
first_fail_logged = False
|
|
|
for _method, url, body in candidates:
|
|
|
ok, code, _body_txt = _ptz_put(url, data=body, headers=PTZ_HEADERS, auth=auth, timeout=timeout, verify=verify)
|
|
|
if ok:
|
|
|
caps["has"] = True
|
|
|
caps["url"] = url
|
|
|
logger.info("[ISAPI] cam=%s autofocus OK via %s", cam_idx, url)
|
|
|
return True
|
|
|
else:
|
|
|
if not first_fail_logged:
|
|
|
logger.warning("[ISAPI] cam=%s autofocus try fail code=%s url=%s", cam_idx, code, url)
|
|
|
first_fail_logged = True
|
|
|
else:
|
|
|
logger.debug("[ISAPI] cam=%s autofocus try fail code=%s url=%s", cam_idx, code, url)
|
|
|
|
|
|
caps["has"] = False
|
|
|
caps["url"] = None
|
|
|
caps["last_probe_fail"] = now
|
|
|
logger.info("[ISAPI] cam=%s autofocus unsupported — backoff %.0fs", cam_idx, cd)
|
|
|
return False
|
|
|
|
|
|
|
|
|
def _ptz_goto_preset_isapi(
|
|
|
cam_idx: int,
|
|
|
base: str,
|
|
|
ch: int,
|
|
|
auth,
|
|
|
verify: bool,
|
|
|
timeout: float,
|
|
|
token: str,
|
|
|
) -> bool:
|
|
|
tok = str(token).strip()
|
|
|
url = f"{base}/ISAPI/PTZCtrl/channels/{ch}/presets/{tok}/goto"
|
|
|
|
|
|
headers_xml = {"Content-Type": "application/xml; charset=UTF-8", "Connection": "keep-alive"}
|
|
|
|
|
|
ok, code, body = _ptz_put(url, data=b"", headers=headers_xml, auth=auth, timeout=timeout, verify=verify)
|
|
|
if ok:
|
|
|
logger.info("[ISAPI] cam=%s goto preset OK via PUT-empty url=%s", cam_idx, url)
|
|
|
return True
|
|
|
|
|
|
xml = f'<?xml version="1.0" encoding="UTF-8"?><PTZPreset><id>{tok}</id></PTZPreset>'
|
|
|
ok, code, body = _ptz_put(url, data=xml, headers=headers_xml, auth=auth, timeout=timeout, verify=verify)
|
|
|
if ok:
|
|
|
logger.info("[ISAPI] cam=%s goto preset OK via PUT-xml url=%s", cam_idx, url)
|
|
|
return True
|
|
|
|
|
|
headers_xml2 = {"Content-Type": "application/xml", "Connection": "keep-alive"}
|
|
|
ok, code, body = _ptz_put(url, data=xml, headers=headers_xml2, auth=auth, timeout=timeout, verify=verify)
|
|
|
if ok:
|
|
|
logger.info("[ISAPI] cam=%s goto preset OK via PUT-xml2 url=%s", cam_idx, url)
|
|
|
return True
|
|
|
|
|
|
try:
|
|
|
r = _sess.post(url, data=xml, headers=headers_xml, auth=auth, timeout=timeout, verify=verify)
|
|
|
body2 = (r.text or "")[:300]
|
|
|
ok2 = 200 <= r.status_code < 300
|
|
|
if ok2:
|
|
|
logger.info("[ISAPI] cam=%s goto preset OK via POST-xml url=%s", cam_idx, url)
|
|
|
return True
|
|
|
else:
|
|
|
logger.warning("PTZ HTTP %s url=%s body=%r", r.status_code, url, body2)
|
|
|
except Exception as e:
|
|
|
logger.warning("PTZ HTTP EXC url=%s timeout=%.1f verify=%s: %s", url, timeout, verify, e)
|
|
|
|
|
|
logger.error(
|
|
|
"[ISAPI] cam=%s goto preset FAILED token=%s last_http=%s last_body=%r",
|
|
|
cam_idx, tok, code, (body or "")[:200],
|
|
|
)
|
|
|
return False
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
# Воркер отправки PTZ-команд
|
|
|
# -----------------------------------------------------------------------------
|
|
|
def _ptz_worker() -> None:
|
|
|
"""
|
|
|
Читает команды из state.ptz_cmd_q и выполняет HTTP-вызовы.
|
|
|
"""
|
|
|
from collections import deque
|
|
|
pending = deque()
|
|
|
|
|
|
while True:
|
|
|
try:
|
|
|
if pending:
|
|
|
cmd: PTZCommand = pending.popleft()
|
|
|
else:
|
|
|
cmd: PTZCommand = state.ptz_cmd_q.get(timeout=0.5) # type: ignore
|
|
|
except Exception:
|
|
|
continue
|
|
|
|
|
|
if cmd is None:
|
|
|
break
|
|
|
|
|
|
# "latest wins": сжимаем поток MOVE-команд, чтобы не накапливать задержку
|
|
|
if isinstance(cmd, MoveTuple) and cmd.typ is PTZCmd.MOVE:
|
|
|
latest = cmd
|
|
|
while True:
|
|
|
try:
|
|
|
nxt: PTZCommand = state.ptz_cmd_q.get_nowait() # type: ignore
|
|
|
except Exception:
|
|
|
break
|
|
|
if isinstance(nxt, MoveTuple) and nxt.typ is PTZCmd.MOVE and nxt.cam_idx == latest.cam_idx:
|
|
|
latest = nxt
|
|
|
else:
|
|
|
pending.append(nxt)
|
|
|
cmd = latest
|
|
|
|
|
|
try:
|
|
|
# === CONTINUOUS MOVE ===
|
|
|
if isinstance(cmd, MoveTuple) and cmd.typ is PTZCmd.MOVE:
|
|
|
cam_idx, pan_i, tilt_i, zoom_i = cmd.cam_idx, cmd.pan_i, cmd.tilt_i, cmd.zoom_i
|
|
|
base, ch, auth, verify, timeout, cfg = _base(cam_idx)
|
|
|
st = state.ptz_states[cam_idx]
|
|
|
|
|
|
# не мешаем переезду на пресет
|
|
|
now_wall = time.time()
|
|
|
until = float(st.get("goto_in_progress_until", 0.0) or 0.0)
|
|
|
if now_wall < until:
|
|
|
continue
|
|
|
|
|
|
# overlay zoom reset (важно: работает даже если команда пришла не через move_ptz_bounded)
|
|
|
if now_wall < float(st.get("zoom_reset_until", 0.0) or 0.0):
|
|
|
strength = float(st.get("zoom_reset_strength", getattr(config, "ZOOM_RESET_STRENGTH", -0.35)) or -0.35)
|
|
|
zoom_i = min(int(strength * 100), zoom_i)
|
|
|
|
|
|
az = st.get("az_deg")
|
|
|
dmin = cfg.get("sector_min_deg")
|
|
|
dmax = cfg.get("sector_max_deg")
|
|
|
mode = st.get("mode", "IDLE")
|
|
|
|
|
|
# сектор-ограничение НЕ применяется в TRACK (там мы разрешаем выход "внутрь" и т.п.)
|
|
|
if dmin is not None and dmax is not None and mode != "TRACK":
|
|
|
if az is None:
|
|
|
# если нет азимута — не толкаем пан, кроме патруля в IDLE/SEARCH
|
|
|
if not (st.get("patrol_active") and mode in ("IDLE", "SEARCH")):
|
|
|
pan_i = 0
|
|
|
else:
|
|
|
pan_sign = cfg.get("pan_sign", 1) or 1
|
|
|
inside = sector_contains(float(az), float(dmin), float(dmax))
|
|
|
|
|
|
def _cmd_dir() -> int:
|
|
|
if pan_i == 0:
|
|
|
return 0
|
|
|
return 1 if (pan_i * pan_sign) > 0 else -1
|
|
|
|
|
|
cmd_dir = _cmd_dir()
|
|
|
|
|
|
if not inside:
|
|
|
# если вне сектора — запрещаем толкать ещё дальше наружу
|
|
|
if cmd_dir != 0:
|
|
|
to_min = ang_diff_signed(float(dmin), float(az))
|
|
|
to_max = ang_diff_signed(float(dmax), float(az))
|
|
|
if abs(to_min) <= abs(to_max):
|
|
|
need_dir = 1 if to_min > 0 else -1
|
|
|
else:
|
|
|
need_dir = 1 if to_max > 0 else -1
|
|
|
if cmd_dir != need_dir:
|
|
|
pan_i = 0
|
|
|
else:
|
|
|
# внутри: останов у границы + мягкое замедление
|
|
|
to_right = ang_diff_signed(float(dmax), float(az))
|
|
|
to_left = ang_diff_signed(float(dmin), float(az))
|
|
|
|
|
|
pushing_right = (pan_i * pan_sign) > 0 and to_right <= 0.0
|
|
|
pushing_left = (pan_i * pan_sign) < 0 and to_left >= 0.0
|
|
|
if pushing_right or pushing_left:
|
|
|
pan_i = 0
|
|
|
else:
|
|
|
if pan_i != 0:
|
|
|
dist_r = abs(to_right)
|
|
|
dist_l = abs(to_left)
|
|
|
dist_min = min(dist_r, dist_l)
|
|
|
edge_slow = getattr(config, "TRACK_EDGE_SLOWDOWN_DEG", 6.0)
|
|
|
edge_min_k = getattr(config, "TRACK_EDGE_MIN_SCALE", 0.25)
|
|
|
if dist_min < edge_slow:
|
|
|
k = max(edge_min_k, dist_min / max(1e-6, edge_slow))
|
|
|
pan_i = int(pan_i * k)
|
|
|
|
|
|
logger.info(
|
|
|
"[PTZ SEND] cam=%s mode=%s az=%s pan_i=%s tilt_i=%s zoom_i=%s",
|
|
|
cam_idx, st.get("mode", "IDLE"), st.get("az_deg"),
|
|
|
pan_i, tilt_i, zoom_i
|
|
|
)
|
|
|
|
|
|
url = f"{base}/ISAPI/PTZCtrl/channels/{ch}/continuous"
|
|
|
xml = f"<PTZData><pan>{pan_i}</pan><tilt>{tilt_i}</tilt><zoom>{zoom_i}</zoom></PTZData>"
|
|
|
ok, code, body = _ptz_put(
|
|
|
url, data=xml, headers=PTZ_HEADERS,
|
|
|
auth=auth, timeout=timeout, verify=verify
|
|
|
)
|
|
|
if not ok and code == 0:
|
|
|
logger.error("PTZ HTTP failed for camera %s: %s", cam_idx, body)
|
|
|
|
|
|
# === PRESET GOTO ===
|
|
|
elif isinstance(cmd, GotoTuple) and cmd.typ is PTZCmd.GOTO:
|
|
|
cam_idx, token = cmd.cam_idx, cmd.token
|
|
|
base, ch, auth, verify, timeout, _cfg = _base(cam_idx)
|
|
|
|
|
|
# ВАЖНО: используем надёжный goto (твоя функция), а не PUT-empty
|
|
|
ok = _ptz_goto_preset_isapi(cam_idx, base, ch, auth, verify, timeout, token)
|
|
|
if not ok:
|
|
|
continue
|
|
|
|
|
|
st = state.ptz_states[cam_idx]
|
|
|
now = time.time()
|
|
|
settle = float(getattr(config, "GOTO_SETTLE_SEC", 2.5))
|
|
|
st["goto_in_progress_until"] = now + settle
|
|
|
|
|
|
st["mode"] = "IDLE"
|
|
|
st["tracking_active"] = False
|
|
|
try:
|
|
|
from .notify import notify_detected
|
|
|
notify_detected(cam_idx, False, force=True)
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
# СБРОС ЗУМА после возврата на пресет (чтобы не патрулировать в зуме)
|
|
|
dur = float(getattr(config, "GOTO_ZOOM_RESET_SEC", 2.0))
|
|
|
strength = float(getattr(config, "ZOOM_RESET_STRENGTH", -0.35))
|
|
|
request_zoom_reset(cam_idx, duration_sec=dur, strength=strength)
|
|
|
|
|
|
# === AUTOFOCUS ===
|
|
|
elif isinstance(cmd, FocusTuple) and cmd.typ is PTZCmd.FOCUS:
|
|
|
cam_idx = cmd.cam_idx
|
|
|
if not getattr(config, "AUTOFOCUS_TRY_ISAPI", False):
|
|
|
continue
|
|
|
|
|
|
base, _ch, auth, verify, timeout, _ = _base(cam_idx)
|
|
|
|
|
|
try:
|
|
|
cfg = config.CAMERA_CONFIG.get(cam_idx, {})
|
|
|
override = cfg.get("focus_override")
|
|
|
except Exception:
|
|
|
override = None
|
|
|
|
|
|
if override:
|
|
|
url = f"{base}{override}"
|
|
|
ok, code, body = _ptz_put(
|
|
|
url, data="", headers=PTZ_HEADERS,
|
|
|
auth=auth, timeout=timeout, verify=verify
|
|
|
)
|
|
|
if not ok:
|
|
|
logger.warning(
|
|
|
"[ISAPI] cam=%s autofocus override failed code=%s body=%r",
|
|
|
cam_idx, code, (body or "")[:160]
|
|
|
)
|
|
|
_ptz_try_autofocus(base, auth, timeout, verify, cam_idx)
|
|
|
else:
|
|
|
logger.info("[ISAPI] cam=%s autofocus OK via override %s", cam_idx, url)
|
|
|
else:
|
|
|
_ptz_try_autofocus(base, auth, timeout, verify, cam_idx)
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error("PTZ worker error: %s", e)
|