Files
wiki/tasks/tapo/tapo-monitor/main.py
2026-04-12 21:55:33 +03:00

219 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
main.py — Tapo Camera Monitor
Entry point: polls cameras, downloads clips, analyzes via Gemini,
sends Telegram notifications. Handles graceful shutdown on SIGTERM/SIGINT.
"""
import asyncio
import logging
import os
import signal
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from camera import CameraClient, EventStore, TapoEvent, parse_events
from config import load_config
from notifier import send_text, send_video
from video import get_duration, trim_clip
from vision import analyze_video
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stdout,
)
logger = logging.getLogger("tapo-monitor")
# ── Globals ──────────────────────────────────────────────────────────────────
_shutdown = asyncio.Event()
def _handle_signal(sig, frame):
logger.info("Signal %s received, shutting down…", sig)
_shutdown.set()
# ── Core pipeline ─────────────────────────────────────────────────────────────
def process_event(
event: TapoEvent,
client: CameraClient,
cfg: dict,
) -> None:
"""
Full pipeline for a single new event:
download → trim → analyze → notify → cleanup
"""
temp_dir = cfg.get("temp_dir", "/tmp/tapo-monitor")
clip_cfg = cfg.get("clip", {})
before_sec = float(clip_cfg.get("before_sec", 3))
after_sec = float(clip_cfg.get("after_sec", 7))
raw_path = os.path.join(temp_dir, f"{event.event_id}_raw.mp4")
trimmed_path = os.path.join(temp_dir, f"{event.event_id}_trimmed.mp4")
try:
# 1. Download
logger.info("[%s] Downloading clip for event %s", event.camera_name, event.event_id)
ok = client.download_clip(event.raw, raw_path)
if not ok or not Path(raw_path).exists():
logger.error("[%s] Download failed, skipping.", event.camera_name)
return
# 2. Determine event offset within clip
# The event start_time vs clip start_time (from raw dict)
clip_start_ts = int(event.raw.get("startTime") or event.raw.get("start_time", 0))
event_ts = int(event.start_time.timestamp())
offset = max(0.0, float(event_ts - clip_start_ts))
logger.debug("Event offset within clip: %.1f sec", offset)
# 3. Trim
ok = trim_clip(raw_path, trimmed_path, offset, before_sec, after_sec)
if not ok or not Path(trimmed_path).exists():
logger.error("[%s] Trim failed, skipping.", event.camera_name)
return
# 4. Analyze with Gemini
description = analyze_video(trimmed_path, cfg["gemini_api_key"])
# 5. Send Telegram notification
send_video(
bot_token=cfg["telegram"]["bot_token"],
chat_id=cfg["telegram"]["chat_id"],
video_path=trimmed_path,
camera_name=event.camera_name,
event_type=event.event_type,
event_time=event.start_time,
description=description,
)
finally:
# 6. Cleanup temp files
for path in (raw_path, trimmed_path):
try:
if Path(path).exists():
os.unlink(path)
logger.debug("Deleted temp file: %s", path)
except Exception as exc:
logger.warning("Could not delete %s: %s", path, exc)
# ── Polling loop ──────────────────────────────────────────────────────────────
async def poll_camera(
cam_cfg: dict,
event_store: EventStore,
cfg: dict,
) -> None:
"""Poll a single camera for new events."""
client = CameraClient(
name=cam_cfg["name"],
ip=cam_cfg["ip"],
password=cam_cfg["password"],
username=cam_cfg.get("username", "admin"),
)
name = cam_cfg["name"]
raw_events = await asyncio.get_event_loop().run_in_executor(
None, lambda: client.get_events(count=20)
)
if not raw_events:
logger.debug("[%s] No events returned.", name)
return
events = parse_events(name, cam_cfg["ip"], raw_events)
new_events = [e for e in events if not event_store.is_seen(e.event_id)]
if not new_events:
logger.debug("[%s] No new events.", name)
return
logger.info("[%s] %d new event(s) found.", name, len(new_events))
for event in new_events:
# Mark seen BEFORE processing to avoid reprocessing on crash mid-flight
event_store.mark_seen(event.event_id, event.camera_name)
try:
await asyncio.get_event_loop().run_in_executor(
None, lambda e=event: process_event(e, client, cfg)
)
except Exception as exc:
logger.exception("[%s] Unhandled error processing event %s: %s", name, event.event_id, exc)
async def main_loop(cfg: dict) -> None:
interval = int(cfg.get("polling_interval", 10))
event_store = EventStore(cfg.get("events_db", "/var/lib/tapo-monitor/events.db"))
cameras = [c for c in cfg.get("cameras", []) if c.get("enabled", True)]
if not cameras:
logger.error("No cameras configured or all disabled.")
return
logger.info("Starting Tapo Monitor | %d camera(s) | poll every %ds", len(cameras), interval)
# Startup notification
try:
names = ", ".join(c["name"] for c in cameras)
send_text(
cfg["telegram"]["bot_token"],
cfg["telegram"]["chat_id"],
f"🟢 Tapo Monitor запущен\nКамеры: {names}\nИнтервал: {interval}с",
)
except Exception:
pass
cleanup_counter = 0
while not _shutdown.is_set():
tasks = [
poll_camera(cam, event_store, cfg)
for cam in cameras
]
await asyncio.gather(*tasks, return_exceptions=True)
# Periodic DB cleanup (every ~100 cycles)
cleanup_counter += 1
if cleanup_counter >= 100:
event_store.cleanup_old(keep_days=7)
cleanup_counter = 0
try:
await asyncio.wait_for(_shutdown.wait(), timeout=interval)
except asyncio.TimeoutError:
pass # Normal: timeout means keep polling
logger.info("Shutdown complete.")
try:
send_text(
cfg["telegram"]["bot_token"],
cfg["telegram"]["chat_id"],
"🔴 Tapo Monitor остановлен.",
)
except Exception:
pass
# ── Entry point ───────────────────────────────────────────────────────────────
def main():
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
config_path = os.environ.get("TAPO_CONFIG", "config.yaml")
try:
cfg = load_config(config_path)
except Exception as exc:
logging.critical("Failed to load config: %s", exc)
sys.exit(1)
asyncio.run(main_loop(cfg))
if __name__ == "__main__":
main()