You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

667 lines
25 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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)