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

174 lines
5.8 KiB
Python

"""
camera.py — pytapo interaction: event polling + SD card clip download.
Tapo cameras use a LOCAL password (set in the camera's settings), NOT the
Tapo cloud account password. See README for how to find/set it.
"""
import asyncio
import logging
import os
import sqlite3
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from pytapo import Tapo
logger = logging.getLogger(__name__)
@dataclass
class TapoEvent:
camera_name: str
camera_ip: str
event_id: str
event_type: str # "motion", "person", "vehicle", etc.
start_time: datetime
end_time: datetime | None
raw: dict[str, Any]
class EventStore:
"""SQLite-backed deduplication store for processed events."""
def __init__(self, db_path: str):
self._db_path = db_path
self._init_db()
def _init_db(self):
with self._conn() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS processed_events (
event_id TEXT PRIMARY KEY,
camera_name TEXT,
processed_at TEXT
)
"""
)
def _conn(self) -> sqlite3.Connection:
return sqlite3.connect(self._db_path)
def is_seen(self, event_id: str) -> bool:
with self._conn() as conn:
row = conn.execute(
"SELECT 1 FROM processed_events WHERE event_id = ?", (event_id,)
).fetchone()
return row is not None
def mark_seen(self, event_id: str, camera_name: str):
with self._conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO processed_events (event_id, camera_name, processed_at) VALUES (?, ?, ?)",
(event_id, camera_name, datetime.utcnow().isoformat()),
)
def cleanup_old(self, keep_days: int = 7):
"""Remove events older than keep_days to avoid DB bloat."""
with self._conn() as conn:
conn.execute(
"DELETE FROM processed_events WHERE processed_at < datetime('now', ?)",
(f"-{keep_days} days",),
)
class CameraClient:
"""Wraps pytapo for a single camera."""
def __init__(self, name: str, ip: str, password: str, username: str = "admin"):
self.name = name
self.ip = ip
self._username = username
self._password = password
self._tapo: Tapo | None = None
def _connect(self) -> Tapo:
if self._tapo is None:
logger.debug("Connecting to camera %s (%s)", self.name, self.ip)
self._tapo = Tapo(self.ip, self._username, self._password, self._password)
return self._tapo
def _reconnect(self) -> Tapo:
self._tapo = None
return self._connect()
def get_events(self, start_index: int = 0, count: int = 20) -> list[dict]:
"""Fetch recent detection events from the camera."""
try:
tapo = self._connect()
result = tapo.getRecordings(start_index, count)
return result if isinstance(result, list) else []
except Exception as exc:
logger.warning("Failed to get events from %s: %s", self.name, exc)
self._tapo = None
return []
def download_clip(self, recording: dict, dest_path: str) -> bool:
"""
Download a recording file from the SD card to dest_path.
Returns True on success.
"""
try:
tapo = self._connect()
# pytapo's downloadRecording writes the file
# The recording dict contains 'startTime' and file info
file_url = recording.get("uri") or recording.get("url") or recording.get("key")
if not file_url:
logger.warning("No URI found in recording: %s", recording)
return False
tapo.downloadRecording(file_url, dest_path)
logger.info("Downloaded clip to %s", dest_path)
return True
except Exception as exc:
logger.error("Download failed for %s: %s", self.name, exc)
self._tapo = None
return False
def parse_events(camera_name: str, camera_ip: str, raw_list: list[dict]) -> list[TapoEvent]:
"""Convert raw pytapo recording dicts to TapoEvent objects."""
events = []
for item in raw_list:
try:
# pytapo returns timestamps as unix seconds (int or str)
start_ts = int(item.get("startTime") or item.get("start_time", 0))
end_ts = item.get("endTime") or item.get("end_time")
start_dt = datetime.utcfromtimestamp(start_ts) if start_ts else datetime.utcnow()
end_dt = datetime.utcfromtimestamp(int(end_ts)) if end_ts else None
# Event type: pytapo may use "eventType" or nested "detectionType"
evt_type = (
item.get("eventType")
or item.get("detectionType")
or item.get("event_type")
or "motion"
)
if isinstance(evt_type, list):
evt_type = evt_type[0] if evt_type else "motion"
# Unique ID: prefer explicit id, fallback to start timestamp + camera
event_id = (
item.get("id")
or item.get("eventId")
or f"{camera_ip}_{start_ts}"
)
events.append(
TapoEvent(
camera_name=camera_name,
camera_ip=camera_ip,
event_id=str(event_id),
event_type=str(evt_type),
start_time=start_dt,
end_time=end_dt,
raw=item,
)
)
except Exception as exc:
logger.warning("Failed to parse event item %s: %s", item, exc)
return events