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-вызовы.
"""
while True:
try:
cmd: PTZCommand = state.ptz_cmd_q.get(timeout=0.5) # type: ignore
except Exception:
continue
if cmd is None:
break
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)