219 lines
7.3 KiB
Python
219 lines
7.3 KiB
Python
"""
|
||
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()
|