# status.py from __future__ import annotations import time import asyncio import logging from typing import Optional, Tuple, List from . import config, state from .utils import clipf from .ptz_io import get_ptz_status # ожидает cam_idx: int, возвращает (pan_deg|None, tilt_deg|None) logger = logging.getLogger("PTZTracker") # ---------------- Публичные утилиты статуса ---------------- def get_cached_angles(cam_id: int) -> Tuple[Optional[float], Optional[float], Optional[float]]: """ Вернёт (pan_deg, tilt_deg, t_epoch) из кэша, если есть (иначе None). Кэш наполняется ptz_status_poller(). """ cache = getattr(state, "ptz_status", {}).get(cam_id) if not cache: return None, None, None return cache.get("pan"), cache.get("tilt"), cache.get("t") def format_angles_for_log(pan: Optional[float], tilt: Optional[float]) -> str: if pan is None and tilt is None: return "pan=NA tilt=NA" if pan is None: return f"pan=NA tilt={tilt:.2f}" if tilt is None: return f"pan={pan:.2f} tilt=NA" return f"pan={pan:.2f} tilt={tilt:.2f}" # --------------- Внутренний телеметрический логгер --------------- def _should_log_telemetry(cam_id: int, now: float, period: float) -> bool: """ Простая защита от спама: логируем статус камеры не чаще, чем раз в period секунд. """ key = f"_last_log_{cam_id}" last = getattr(state, key, 0.0) if now - last >= period: setattr(state, key, now) return True return False def _update_runtime_state(cam_id: int, pan: Optional[float], tilt: Optional[float], now: float) -> None: """ Проставляет дубли в state.ptz_states[cam_id] для использования остальным кодом, включая воркер из ptz_io.py (он читает az_deg!) и постпроцессинг. Ставим: - az_deg (совместимость с ptz_io._ptz_worker) - pan_deg (то же значение, чтобы везде было одинаково) - pan_deg_geo (если в конфиге есть north_offset_deg) - tilt_deg (клип -90..+90) - last_status_at """ st = state.ptz_states.setdefault(cam_id, {}) cfg = config.CAMERA_CONFIG.get(cam_id, {}) if pan is not None: pan_n = float(pan) % 360.0 st["az_deg"] = pan_n # <-- это читает ptz_io._ptz_worker при bounded-патруле st["pan_deg"] = pan_n # удобный синоним north_off = cfg.get("north_offset_deg") if north_off is not None: try: st["pan_deg_geo"] = (pan_n + float(north_off)) % 360.0 except Exception: st["pan_deg_geo"] = pan_n if tilt is not None: st["tilt_deg"] = clipf(float(tilt), -90.0, 90.0) st["last_status_at"] = now # -------------------- Основной поллер статуса --------------------- async def ptz_status_poller() -> None: """ Асинхронный опрос PTZ-камер из config.PTZ_CAM_IDS и публикация в state: - state.ptz_status[cid] = {"pan": deg|None, "tilt": deg|None, "t": now} - state.ptz_states[cid]["az_deg"/"pan_deg"/"pan_deg_geo"/"tilt_deg"/"last_status_at"] ВАЖНО: частота задаётся как PTZ_STATUS_HZ **на камеру**. То есть при PTZ_STATUS_HZ=4.0 и 3 камерах каждая опрашивается 4 Гц, а не 4/3. Редкое логирование: config.PTZ_STATUS_LOG_EVERY_SEC (>0 чтобы включить). """ hz = float(getattr(config, "PTZ_STATUS_HZ", 4.0)) cam_ids: List[int] = list(getattr(config, "PTZ_CAM_IDS", [])) n_cams = max(1, len(cam_ids)) if not cam_ids: logger.info("[STATUS] no PTZ cameras to poll (PTZ_CAM_IDS empty)") return # Интервал между подряд идущими запросами к ОДНОЙ камере: # хотим 'hz' опросов в секунду для каждой камеры. per_step_interval = 1.0 / max(0.1, hz * n_cams) log_period = float(getattr(config, "PTZ_STATUS_LOG_EVERY_SEC", 0.0)) if not hasattr(state, "ptz_status"): state.ptz_status = {} # type: ignore[attr-defined] logger.info( "[STATUS] PTZ status poller: %d cam(s), target %.2f Hz per cam (step_interval=%.3fs)", len(cam_ids), hz, per_step_interval, ) i = 0 try: while not getattr(state, "shutdown", False): cam_id = cam_ids[i % len(cam_ids)] i += 1 now = time.time() # Сетевой запрос делаем в thread pool, чтобы не блокировать event loop try: pan, tilt = await asyncio.to_thread(get_ptz_status, cam_id) except Exception as e: logger.warning("[STATUS] cam %d poll error: %s", cam_id, e) pan = tilt = None # Обновляем кэш cache = getattr(state, "ptz_status", {}).get(cam_id, {}) cache.update({"pan": pan, "tilt": tilt, "t": now}) state.ptz_status[cam_id] = cache # type: ignore[attr-defined] # Дублируем в ptz_states (для patrol/track/ptz_io и компаса) _update_runtime_state(cam_id, pan, tilt, now) # Редкое логирование (если включено) if log_period > 0 and _should_log_telemetry(cam_id, now, log_period): logger.info("[PTZ STATUS] cam %d %s", cam_id, format_angles_for_log(pan, tilt)) await asyncio.sleep(per_step_interval) except asyncio.CancelledError: logger.info("[STATUS] PTZ status poller cancelled") raise finally: logger.info("[STATUS] PTZ status poller stopped") # -------------------- Вспомогательные публичные -------------------- def last_attack_heading_deg(cam_id: int) -> Optional[float]: """ Возвращает последний закэшированный attack_heading_deg (если постпроцессинг его писал). Это используется для логов/отладки, не обязательно для патруля. """ st = state.ptz_states.get(cam_id) if hasattr(state, "ptz_states") else None if not st: return None val = st.get("attack_heading_deg") return float(val) if isinstance(val, (int, float)) else None def get_realtime_angles_for_log(cam_id: int) -> str: pan, tilt, t = get_cached_angles(cam_id) if t is None: return f"cam {cam_id} pan=NA tilt=NA" age = max(0.0, time.time() - t) return f"cam {cam_id} {format_angles_for_log(pan, tilt)} (t-{age:.2f}s)"