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", "autotrigger"), ("PUT", f"{base}/ISAPI/PTZCtrl/channels/1/focus", ""), ("PUT", f"{base}/ISAPI/PTZCtrl/channels/1/triggerFocus", ""), ("PUT", f"{base}/ISAPI/Image/channels/1/focus", "trigger"), ("PUT", f"{base}/ISAPI/Image/channels/1/focus/auto", ""), ("PUT", f"{base}/ISAPI/System/Video/inputs/channels/1/focus", "true"), ("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'{tok}' 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"{pan_i}{tilt_i}{zoom_i}" 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)