174 lines
5.8 KiB
Python
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
|