# ptz_tracker_modular/wiper.py from __future__ import annotations import asyncio import logging from typing import Optional, Tuple import requests from requests.auth import HTTPDigestAuth log = logging.getLogger("PTZTracker.WIPER") _sess = requests.Session() _sess.trust_env = False try: import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) except Exception: pass # Базовое значение; можно переопределить в config.py как WIPER_MIN_INTERVAL_SEC _DEFAULT_MIN_INTERVAL_SEC = 1.5 _last_call: dict[int, float] = {} def _resolve_cam_id(cam_token: int) -> int: """cam_token = реальный ID из cameras.toml ИЛИ индекс в PTZ_CAM_IDS.""" from . import config if cam_token in config.CAMERA_CONFIG: return cam_token if 0 <= cam_token < len(config.PTZ_CAM_IDS): return config.PTZ_CAM_IDS[cam_token] raise KeyError(f"Unknown camera token: {cam_token}") def _base(cam_id: int): """Вернёт (base_url, auth, verify, timeout, meta, channel) для указанной камеры.""" from . import config as C cfg = C.CAMERA_CONFIG[cam_id] 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)) # ВАЖНО: сначала берём wiper_channel, потом ptz_channel, по умолчанию 1 ch = int(cfg.get("wiper_channel", 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 = int(cfg.get("ptz_timeout_sec", 6)) meta = {"ip": host, "port": port, "scheme": scheme, "channel": ch, "user": user} return base, auth, verify, timeout, meta, ch def _log_resp(tag: str, r: requests.Response | None, exc: Exception | None = None): """ Единая точка логирования ответов. Переведено в DEBUG, чтобы не шуметь в INFO. Никаких print(). Возвращает кортеж (ok, http_code|None, body_str). """ if r is not None: body = (r.text or "")[:300] # детальный лог — только в DEBUG if log.isEnabledFor(logging.DEBUG): log.debug("%s -> HTTP %s | %r", tag, r.status_code, body) return (200 <= r.status_code < 300), r.status_code, body else: log.warning("%s -> EXC %s", tag, exc) return False, None, str(exc) if exc else "" def _PUT(url, *, auth, verify, timeout, data=b"", headers=None): try: return _sess.put(url, data=data, headers=headers or {}, auth=auth, timeout=timeout, verify=verify), None except Exception as e: return None, e def _GET(url, *, auth, verify, timeout): try: return _sess.get(url, auth=auth, timeout=timeout, verify=verify), None except Exception as e: return None, e def _try_ptz(base, ch, auth, verify, timeout): """ Основная линейка эндпоинтов: 1) manualWiper (длинная протирка — предпочтительный путь) 2) Wiper(single) через XML 3) auxiliary?name=wiper:single 4) auxiliary?name=wiper:on """ # 1) manualWiper r, e = _PUT(f"{base}/ISAPI/PTZCtrl/channels/{ch}/manualWiper", auth=auth, verify=verify, timeout=timeout) ok, code, body = _log_resp("manualWiper", r, e) if ok: return True, "manualWiper", code, body # 2) Wiper(single) XML xml = b'single' r, e = _PUT(f"{base}/ISAPI/PTZCtrl/channels/{ch}/Wiper", auth=auth, verify=verify, timeout=timeout, data=xml, headers={"Content-Type": "application/xml"}) ok, code, body = _log_resp("Wiper(single)", r, e) if ok: return True, "Wiper(single)", code, body # 3) auxiliary wiper:single r, e = _PUT(f"{base}/ISAPI/PTZCtrl/channels/{ch}/auxiliary?name=wiper:single", auth=auth, verify=verify, timeout=timeout) ok, code, body = _log_resp("auxiliary?wiper:single", r, e) if ok: return True, "auxiliary(wiper:single)", code, body # 4) auxiliary wiper:on r, e = _PUT(f"{base}/ISAPI/PTZCtrl/channels/{ch}/auxiliary?name=wiper:on", auth=auth, verify=verify, timeout=timeout) ok, code, body = _log_resp("auxiliary?wiper:on", r, e) if ok: return True, "auxiliary(wiper:on)", code, body return False, "ptz_endpoints_failed", code, body def _probe_io(base, auth, verify, timeout): """Попытка найти дискретный выход, содержащий 'wiper' в имени.""" r, e = _GET(f"{base}/ISAPI/System/IO/outputs", auth=auth, verify=verify, timeout=timeout) ok, code, body = _log_resp("GET IO/outputs", r, e) if not ok or not body: return None import re ports = re.findall(r"(.*?)", body, flags=re.S) for chunk in ports: id_m = re.search(r"\s*(\d+)\s*", chunk) name_m = re.search(r"\s*([^<]+)\s*", chunk) if id_m and name_m and ("wiper" in name_m.group(1).strip().lower()): out_id = int(id_m.group(1)) log.debug("IO: found output id=%s", out_id) return out_id log.debug("IO: no output named 'wiper' found") return None def _try_io(base, out_id, auth, verify, timeout, pulse_ms: int): xmlA = f'high{pulse_ms}'.encode("utf-8") r, e = _PUT(f"{base}/ISAPI/System/IO/outputs/{out_id}/status", auth=auth, verify=verify, timeout=timeout, data=xmlA, headers={"Content-Type": "application/xml"}) ok, code, body = _log_resp(f"PUT IO/outputs/{out_id}/status", r, e) if ok: return True, "io_status", code, body xmlB = f'{pulse_ms}'.encode("utf-8") r, e = _PUT(f"{base}/ISAPI/System/IO/outputs/{out_id}/trigger", auth=auth, verify=verify, timeout=timeout, data=xmlB, headers={"Content-Type": "application/xml"}) ok, code, body = _log_resp(f"PUT IO/outputs/{out_id}/trigger", r, e) if ok: return True, "io_trigger", code, body return False, "io_failed", code, body def run(cam_token: int, sec: int = 3) -> Tuple[bool, str, Optional[int], str, int]: """ Синхронный запуск дворника. Возвращает: (ok, endpoint, http_status|None, detail, cam_id) """ cam_id = _resolve_cam_id(cam_token) base, auth, verify, timeout, meta, ch = _base(cam_id) log.info("WIPER start: cam_token=%s cam_id=%s meta=%s", cam_token, cam_id, meta) # 1) Пытаемся через PTZ эндпоинты ok, endpoint, code, body = _try_ptz(base, ch, auth, verify, timeout) if ok: return True, endpoint, code, body, cam_id # 2) Фоллбек через дискретный выход, если нашли out_id = _probe_io(base, auth, verify, timeout) if out_id is not None: pulse_ms = max(500, int(sec * 1000)) ok2, tag, code2, body2 = _try_io(base, out_id, auth, verify, timeout, pulse_ms) return (ok2, tag, code2, body2, cam_id) # 3) Совсем не получилось return False, endpoint, code, body, cam_id async def trigger_wiper_once(cam_token: int, sec: float = 3.0) -> Tuple[bool, str, Optional[int], str, int]: """ Асинхронный вызов с анти-дребезгом. Супрессия теперь возвращает ok=False (чтобы фронт видел «слишком часто»). Интервал берём из config.WIPER_MIN_INTERVAL_SEC при наличии. """ from . import config # локальный импорт, чтобы не тянуть конфиг на уровне модуля loop = asyncio.get_running_loop() cam_id = _resolve_cam_id(cam_token) min_interval = float(getattr(config, "WIPER_MIN_INTERVAL_SEC", _DEFAULT_MIN_INTERVAL_SEC)) now = loop.time() last = _last_call.get(cam_id, 0.0) if (now - last) < min_interval: delta = now - last log.debug("WIPER suppressed: cam_id=%s delta=%.2f < %.2f", cam_id, delta, min_interval) # ok=False, чтобы UI мог показать «слишком часто» return False, "suppressed", None, f"too-frequent ({delta:.2f}s < {min_interval:.2f}s)", cam_id _last_call[cam_id] = now # нормализация sec try: sec_i = int(round(float(sec))) except Exception: sec_i = 3 if sec_i <= 0: sec_i = 1 return await asyncio.to_thread(run, cam_token, sec_i)