|
|
# -*- coding: utf-8 -*-
|
|
|
from __future__ import annotations
|
|
|
|
|
|
# --- bootstrap for direct script run ---
|
|
|
# Позволяет запускать файл как обычный скрипт: python ptz_tracker_modular/main.py
|
|
|
# и при этом импортировать пакет ptz_tracker_modular
|
|
|
if __package__ is None or __package__ == "":
|
|
|
import sys, pathlib
|
|
|
pkg_root = pathlib.Path(__file__).resolve().parent.parent # папка-родитель, содержащая пакет
|
|
|
if str(pkg_root) not in sys.path:
|
|
|
sys.path.insert(0, str(pkg_root))
|
|
|
__package__ = "ptz_tracker_modular"
|
|
|
# ---------------------------------------
|
|
|
|
|
|
import asyncio
|
|
|
import logging
|
|
|
import os
|
|
|
import threading
|
|
|
from queue import Queue
|
|
|
from typing import Iterable
|
|
|
|
|
|
|
|
|
def _parse_level(name: str, default=logging.ERROR) -> int:
|
|
|
name = (name or "").strip().upper()
|
|
|
return getattr(logging, name, default)
|
|
|
|
|
|
|
|
|
def setup_logging() -> logging.Logger:
|
|
|
"""
|
|
|
Лог только в консоль, по умолчанию уровень ERROR.
|
|
|
Файлы и папка logs не создаются.
|
|
|
"""
|
|
|
# По умолчанию только ошибки; можно переопределить LOG_LEVEL=WARNING/INFO/DEBUG
|
|
|
level = _parse_level(os.getenv("LOG_LEVEL", "ERROR"))
|
|
|
|
|
|
logging.basicConfig(
|
|
|
level=level,
|
|
|
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
|
|
handlers=[logging.StreamHandler()],
|
|
|
)
|
|
|
|
|
|
logger = logging.getLogger("PTZTracker")
|
|
|
# приглушим лишний шум от сторонних библиотек
|
|
|
logging.getLogger("websockets.client").setLevel(logging.WARNING)
|
|
|
logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
|
|
|
|
|
|
logger.error("Logging initialized, level=%s (console only, no file)", logging.getLevelName(level))
|
|
|
return logger
|
|
|
|
|
|
|
|
|
logger = setup_logging()
|
|
|
|
|
|
from ptz_tracker_modular import config, state
|
|
|
from ptz_tracker_modular.streaming import (
|
|
|
MicroBatcher,
|
|
|
video_broadcaster,
|
|
|
cpp_ws_loop,
|
|
|
cpp_detection_loop,
|
|
|
build_servers,
|
|
|
)
|
|
|
from ptz_tracker_modular.status import ptz_status_poller
|
|
|
from ptz_tracker_modular.heartbeat import heartbeat_pinger
|
|
|
from ptz_tracker_modular.sector import sector_autocal_from_presets, sector_init_on_startup
|
|
|
from ptz_tracker_modular.calibration import calibrate_presets, autocal_pan_sign, park_to_patrol_start
|
|
|
from ptz_tracker_modular.patrol import (
|
|
|
patrol_init_on_startup,
|
|
|
patrol_supervisor_tick_bounded,
|
|
|
patrol_supervisor_tick,
|
|
|
sync_patrol_coordinator_loop,
|
|
|
track_return_watchdog, # <-- импорт вотчдога
|
|
|
)
|
|
|
from ptz_tracker_modular import ptz_io # содержит _ptz_worker
|
|
|
|
|
|
# (опционально) uvloop
|
|
|
try:
|
|
|
import uvloop # type: ignore
|
|
|
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
|
|
|
def _dump_alarm_config():
|
|
|
"""Красиво логируем ключевые параметры тревог при старте (если уровень позволяет)."""
|
|
|
ws_url = getattr(config, "ALARM_WS_URL", "ws://127.0.0.1:3000/ws")
|
|
|
ws_fmt = getattr(config, "ALARM_WS_FORMAT", "swagger")
|
|
|
mac_only = bool(getattr(config, "ALARM_MAC_ONLY", False))
|
|
|
|
|
|
http_en = bool(getattr(config, "ALARM_HTTP_ENABLE", False))
|
|
|
http_mirror = bool(getattr(config, "ALARM_HTTP_MIRROR", False))
|
|
|
http_base = getattr(config, "ALARM_HTTP_BASE", "http://127.0.0.1:3000")
|
|
|
http_path = getattr(config, "ALARM_HTTP_PATH", "/videodata")
|
|
|
http_tmpl = getattr(config, "ALARM_HTTP_PATH_TMPL", "/videodata")
|
|
|
|
|
|
ws_bmax = getattr(config, "ALARM_WS_BATCH_MAX", getattr(config, "WS_BATCH_MAX", 16))
|
|
|
ws_bms = getattr(config, "ALARM_WS_BATCH_MS", getattr(config, "WS_BATCH_MS", 120))
|
|
|
http_bmax = getattr(config, "ALARM_HTTP_BATCH_MAX", getattr(config, "HTTP_BATCH_MAX", 16))
|
|
|
http_bms = getattr(config, "ALARM_HTTP_BATCH_MS", getattr(config, "HTTP_BATCH_MS", 120))
|
|
|
|
|
|
hb_sec = int(getattr(config, "ALARM_HEARTBEAT_SEC", 60))
|
|
|
logger.info("[ALARM] WS_URL=%s format=%s mac_only=%s", ws_url, ws_fmt, mac_only)
|
|
|
logger.info("[ALARM] HTTP enable=%s mirror=%s base=%s path=%s tmpl=%s",
|
|
|
http_en, http_mirror, http_base, http_path, http_tmpl)
|
|
|
logger.info("[ALARM] Batching: WS max=%s wait_ms=%s | HTTP max=%s wait_ms=%s",
|
|
|
ws_bmax, ws_bms, http_bmax, http_bms)
|
|
|
logger.info("[ALARM] Heartbeat: every %ss to %s", hb_sec, http_path)
|
|
|
|
|
|
|
|
|
def watch(coro: asyncio.Future | asyncio.Task | asyncio.coroutines, name: str) -> asyncio.Task:
|
|
|
"""
|
|
|
Обёртка для задач: если задача упала, логируем исключение.
|
|
|
Возвращает Task (подходит для gather).
|
|
|
"""
|
|
|
async def _guard():
|
|
|
try:
|
|
|
return await coro # type: ignore[arg-type]
|
|
|
except asyncio.CancelledError:
|
|
|
logger.info("[%s] cancelled", name)
|
|
|
raise
|
|
|
except Exception as e:
|
|
|
logger.exception("[%s] crashed: %s", name, e)
|
|
|
raise
|
|
|
|
|
|
if not isinstance(coro, (asyncio.Future, asyncio.Task)):
|
|
|
coro = asyncio.create_task(coro) # type: ignore[assignment]
|
|
|
task = asyncio.create_task(_guard(), name=name) # type: ignore[arg-type]
|
|
|
return task
|
|
|
|
|
|
|
|
|
async def main() -> None:
|
|
|
# --- Инициализация общего состояния ---
|
|
|
state.MAIN_LOOP = asyncio.get_running_loop()
|
|
|
state.alarm_queue = asyncio.Queue()
|
|
|
state.detection_queue = asyncio.Queue()
|
|
|
state.batcher = MicroBatcher(
|
|
|
max_batch=config.BATCH_MIN,
|
|
|
max_wait_ms=2.0,
|
|
|
adaptive=config.ADAPTIVE_BATCH_ENABLE,
|
|
|
bmin=config.BATCH_MIN,
|
|
|
bmax=config.BATCH_MAX,
|
|
|
target_ms=config.BATCH_TARGET_MS,
|
|
|
)
|
|
|
|
|
|
# Импорт alarms ПОСЛЕ инициализации state.*, чтобы внутри не было None
|
|
|
from ptz_tracker_modular import alarms # -> alarms.alarm_sender(), alarms.alarm_http_sender()
|
|
|
|
|
|
_dump_alarm_config()
|
|
|
|
|
|
# PTZ worker thread + очередь команд
|
|
|
state.ptz_cmd_q = Queue(maxsize=2048)
|
|
|
state._ptz_thread = threading.Thread(
|
|
|
target=ptz_io._ptz_worker, name="PTZWorker", daemon=True
|
|
|
)
|
|
|
state._ptz_thread.start()
|
|
|
logger.info("[PTZ] worker thread started")
|
|
|
|
|
|
# --- ВАЖНО: гарантируем базовые поля стейта для PTZ, иначе patrol может skip'ать камеры ---
|
|
|
# (частая причина "патруль не на всех PTZ": st.get("mode") == None)
|
|
|
for cam_id in config.PTZ_CAM_IDS:
|
|
|
st = state.ensure_cam_state(cam_id, is_ptz=True)
|
|
|
if st.get("mode") is None:
|
|
|
st["mode"] = "IDLE"
|
|
|
|
|
|
# ---- КАЛИБРОВКИ ----
|
|
|
for cam_id in config.PTZ_CAM_IDS:
|
|
|
cfg = config.CAMERA_CONFIG[cam_id]
|
|
|
need = (
|
|
|
cfg.get("preset1_deg") is None
|
|
|
or cfg.get("preset2_deg") is None
|
|
|
or cfg.get("sector_min_deg") is None
|
|
|
or cfg.get("sector_max_deg") is None
|
|
|
)
|
|
|
if need:
|
|
|
ok = await calibrate_presets(cam_id)
|
|
|
if not ok:
|
|
|
logger.warning(
|
|
|
"Failed to calibrate camera %s, using fallback for sector clamp.", cam_id
|
|
|
)
|
|
|
|
|
|
if config.USE_PRESET_EDGES_FOR_SECTOR:
|
|
|
need_sector = any(state.ptz_states[c].get("sector_left_deg") is None for c in config.PTZ_CAM_IDS)
|
|
|
if need_sector:
|
|
|
await sector_autocal_from_presets()
|
|
|
sector_init_on_startup()
|
|
|
|
|
|
for cam in config.PTZ_CAM_IDS:
|
|
|
try:
|
|
|
await autocal_pan_sign(cam)
|
|
|
except Exception as e:
|
|
|
logger.debug("autocal pan_sign cam %s: %s", cam, e)
|
|
|
|
|
|
await park_to_patrol_start()
|
|
|
|
|
|
# ---- Патруль ----
|
|
|
have_any_ptz = bool(config.PTZ_CAM_IDS)
|
|
|
if have_any_ptz and config.PATROL_ENABLE_ON_START:
|
|
|
if config.SYNC_PATROL_ENABLE and config.SYNC_COHORTS:
|
|
|
logger.info(
|
|
|
"SYNC PTZ patrol enabled (cohorts): %s",
|
|
|
", ".join(config.SYNC_COHORTS.keys()),
|
|
|
)
|
|
|
else:
|
|
|
patrol_init_on_startup()
|
|
|
logger.info("PTZ patrol started (standalone)")
|
|
|
elif not have_any_ptz:
|
|
|
logger.info("PTZ patrol skipped: no PTZ cameras configured")
|
|
|
else:
|
|
|
logger.info("PTZ patrol is DISABLED on start")
|
|
|
|
|
|
logger.info("PTZ tracking + HARD sector clamp + bounded patrol (resync enabled)")
|
|
|
|
|
|
# ---- WS servers ----
|
|
|
ctrl_server, video_server = await build_servers()
|
|
|
logger.info("[CTRL WS] listening on 0.0.0.0:%s", config.VUE_CONTROL_WS_PORT)
|
|
|
logger.info("[VIDEO WS] listening on 0.0.0.0:%s", config.VUE_VIDEO_WS_PORT)
|
|
|
|
|
|
async def patrol_supervisor_loop():
|
|
|
while True:
|
|
|
try:
|
|
|
patrol_supervisor_tick_bounded()
|
|
|
patrol_supervisor_tick()
|
|
|
except Exception as e:
|
|
|
logger.error("patrol_supervisor error: %s", e)
|
|
|
await asyncio.sleep(0.10)
|
|
|
|
|
|
async def track_watchdog_loop() -> None:
|
|
|
"""
|
|
|
Supervisor для per-camera watchdog'ов.
|
|
|
|
|
|
ВАЖНО: track_return_watchdog(cam_id) внутри себя бесконечный цикл.
|
|
|
Поэтому здесь мы НЕ await'им его по очереди, а запускаем отдельной Task
|
|
|
на каждую PTZ-камеру и следим, чтобы:
|
|
|
- вотчдог был запущен на всех cam_id из config.PTZ_CAM_IDS,
|
|
|
- если вотчдог упал — логируем причину и перезапускаем,
|
|
|
- если список PTZ камер изменился (reload) — отменяем лишние задачи.
|
|
|
"""
|
|
|
tasks_by_cam: dict[int, asyncio.Task] = {}
|
|
|
restart_after: dict[int, float] = {} # cam_id -> loop.time(), когда можно рестартить
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
tick_sec = 1.0
|
|
|
restart_cooldown_sec = 2.0
|
|
|
|
|
|
try:
|
|
|
while True:
|
|
|
now = loop.time()
|
|
|
|
|
|
# Снимок актуального списка PTZ камер (может меняться после reload)
|
|
|
ptz_ids = list(getattr(config, "PTZ_CAM_IDS", []) or [])
|
|
|
ptz_set = set(ptz_ids)
|
|
|
|
|
|
# 1) Запускаем/перезапускаем вотчдоги для актуальных камер
|
|
|
for cam_id in ptz_ids:
|
|
|
# гарантируем, что стейт камеры существует (чтобы внутри watchdog не было KeyError)
|
|
|
try:
|
|
|
state.ensure_cam_state(cam_id, is_ptz=True)
|
|
|
except Exception as e:
|
|
|
logger.error("[WATCHDOG] ensure_cam_state cam=%s failed: %s", cam_id, e)
|
|
|
continue
|
|
|
|
|
|
t = tasks_by_cam.get(cam_id)
|
|
|
|
|
|
if t is None:
|
|
|
# нет задачи — создаём, если прошёл cooldown
|
|
|
if now >= restart_after.get(cam_id, 0.0):
|
|
|
tasks_by_cam[cam_id] = asyncio.create_task(
|
|
|
track_return_watchdog(cam_id),
|
|
|
name=f"track_return_watchdog[{cam_id}]",
|
|
|
)
|
|
|
continue
|
|
|
|
|
|
if t.done():
|
|
|
# задача завершилась — логируем (если было исключение) и планируем рестарт
|
|
|
try:
|
|
|
exc = t.exception()
|
|
|
except asyncio.CancelledError:
|
|
|
exc = None
|
|
|
except Exception as e:
|
|
|
exc = e
|
|
|
|
|
|
if exc is not None:
|
|
|
logger.exception(
|
|
|
"[WATCHDOG] cam=%s track_return_watchdog crashed: %s",
|
|
|
cam_id,
|
|
|
exc,
|
|
|
)
|
|
|
|
|
|
tasks_by_cam.pop(cam_id, None)
|
|
|
restart_after[cam_id] = now + restart_cooldown_sec
|
|
|
|
|
|
# 2) Камеры, которые больше не PTZ — отменяем их watchdog'и
|
|
|
for cam_id in list(tasks_by_cam.keys()):
|
|
|
if cam_id not in ptz_set:
|
|
|
t = tasks_by_cam.pop(cam_id)
|
|
|
t.cancel()
|
|
|
restart_after.pop(cam_id, None)
|
|
|
# Можно подсказать, что камера теперь не PTZ (не обязательно)
|
|
|
try:
|
|
|
state.ensure_cam_state(cam_id, is_ptz=False)
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
await asyncio.sleep(tick_sec)
|
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
raise
|
|
|
|
|
|
finally:
|
|
|
# Корректно гасим все per-camera задачи
|
|
|
for t in tasks_by_cam.values():
|
|
|
t.cancel()
|
|
|
if tasks_by_cam:
|
|
|
await asyncio.gather(*tasks_by_cam.values(), return_exceptions=True)
|
|
|
|
|
|
# ---- Опциональный smoke-тест тревоги (по первому MAC) ----
|
|
|
if os.getenv("ALARM_SMOKE", "0") in ("1", "true", "TRUE", "yes", "YES"):
|
|
|
async def _smoke():
|
|
|
try:
|
|
|
# Берём первый доступный MAC из конфигурации
|
|
|
macs: Iterable[str] = (
|
|
|
cfg.get("mac") for _id, cfg in sorted(config.CAMERA_CONFIG.items())
|
|
|
if isinstance(cfg.get("mac"), str)
|
|
|
)
|
|
|
mac = next((m for m in macs if m), None)
|
|
|
if not mac:
|
|
|
logger.warning("[SMOKE] no MACs in CAMERA_CONFIG — skip")
|
|
|
return
|
|
|
logger.info("[SMOKE] sending test alarms for MAC=%s", mac)
|
|
|
alarms.queue_alarm_mac(mac, True)
|
|
|
await asyncio.sleep(0.2)
|
|
|
alarms.queue_alarm_mac(mac, False)
|
|
|
logger.info("[SMOKE] done")
|
|
|
except Exception as e:
|
|
|
logger.error("[SMOKE] failed: %s", e)
|
|
|
asyncio.create_task(_smoke())
|
|
|
|
|
|
# ---- Таски ----
|
|
|
tasks: list[asyncio.Task | asyncio.Future] = [
|
|
|
watch(video_broadcaster(), "video_broadcaster"),
|
|
|
watch(cpp_ws_loop(), "cpp_ws_loop"),
|
|
|
watch(cpp_detection_loop(), "cpp_detection_loop"),
|
|
|
watch(alarms.alarm_sender(), "alarm_sender"), # WS-alarms
|
|
|
watch(alarms.alarm_http_sender(), "alarm_http_sender"), # HTTP-alarms (если включено)
|
|
|
watch(alarms.periodic_status_sender(), "periodic_status_sender"), # heartbeat-массив камер
|
|
|
watch(ptz_status_poller(), "ptz_status_poller"),
|
|
|
watch(heartbeat_pinger(), "heartbeat_pinger"),
|
|
|
asyncio.create_task(ctrl_server.wait_closed(), name="ctrl_ws_wait_closed"),
|
|
|
asyncio.create_task(video_server.wait_closed(), name="video_ws_wait_closed"),
|
|
|
]
|
|
|
|
|
|
# Запускаем патрульный цикл, если он включён
|
|
|
if config.PATROL_ENABLE_ON_START and config.PTZ_CAM_IDS:
|
|
|
tasks.insert(8, watch(patrol_supervisor_loop(), "patrol_supervisor_loop"))
|
|
|
|
|
|
# Вотчдог по трекингу/IDLE — запускаем всегда, если есть PTZ-камеры
|
|
|
if config.PTZ_CAM_IDS:
|
|
|
tasks.insert(8, watch(track_watchdog_loop(), "track_watchdog_loop"))
|
|
|
|
|
|
# SYNC patrol при необходимости
|
|
|
if config.SYNC_PATROL_ENABLE and config.SYNC_COHORTS:
|
|
|
tasks.insert(8, watch(sync_patrol_coordinator_loop(), "sync_patrol_coordinator_loop"))
|
|
|
|
|
|
try:
|
|
|
await asyncio.gather(*tasks)
|
|
|
finally:
|
|
|
ctrl_server.close()
|
|
|
video_server.close()
|
|
|
await ctrl_server.wait_closed()
|
|
|
await video_server.wait_closed()
|
|
|
|
|
|
def run():
|
|
|
try:
|
|
|
asyncio.run(main())
|
|
|
except KeyboardInterrupt:
|
|
|
logger.info("Stopped by user")
|
|
|
finally:
|
|
|
try:
|
|
|
if state.ptz_cmd_q is not None:
|
|
|
state.ptz_cmd_q.put_nowait(None) # sentinel для PTZ-воркера
|
|
|
if state._ptz_thread is not None:
|
|
|
state._ptz_thread.join(timeout=0.5)
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
for _cid, _st in state.ptz_states.items():
|
|
|
if _st.get("zoom_reset_timer"):
|
|
|
try:
|
|
|
_st["zoom_reset_timer"].cancel()
|
|
|
except Exception:
|
|
|
pass
|
|
|
if _st.get("preset_timer"):
|
|
|
try:
|
|
|
_st["preset_timer"].cancel()
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
logger.info("Shutdown complete")
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
run()
|