# 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)