""" Database module for storing sensor history. Uses SQLite with async access via aiosqlite. Handles automatic cleanup based on retention settings. """ import asyncio import time import logging from pathlib import Path from typing import Optional from datetime import datetime, timedelta import aiosqlite logger = logging.getLogger(__name__) class EnviroDatabase: """Async SQLite database for sensor history.""" def __init__(self, db_path: str = "enviro_history.db", retention_hours: int = 168): """ Initialize database. Args: db_path: Path to SQLite database file retention_hours: How long to keep data (default 7 days) """ self.db_path = Path(db_path) self.retention_hours = retention_hours self._db: Optional[aiosqlite.Connection] = None async def connect(self): """Connect to database and create tables if needed.""" self._db = await aiosqlite.connect(self.db_path) # Create readings table await self._db.execute(""" CREATE TABLE IF NOT EXISTS readings ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, temperature_c REAL, temperature_f REAL, humidity REAL, pressure REAL, light REAL, proximity INTEGER, noise REAL ) """) # Create index on timestamp for fast queries await self._db.execute(""" CREATE INDEX IF NOT EXISTS idx_readings_timestamp ON readings(timestamp) """) # Create alerts table await self._db.execute(""" CREATE TABLE IF NOT EXISTS alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, metric TEXT NOT NULL, threshold REAL NOT NULL, direction TEXT NOT NULL, enabled INTEGER DEFAULT 1, last_triggered REAL, UNIQUE(metric, direction) ) """) # Create triggered alerts log await self._db.execute(""" CREATE TABLE IF NOT EXISTS alert_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, metric TEXT NOT NULL, value REAL NOT NULL, threshold REAL NOT NULL, direction TEXT NOT NULL ) """) await self._db.commit() logger.info(f"Database connected: {self.db_path}") async def close(self): """Close database connection.""" if self._db: await self._db.close() self._db = None async def store_reading(self, reading: dict): """Store a sensor reading.""" await self._db.execute(""" INSERT INTO readings (timestamp, temperature_c, temperature_f, humidity, pressure, light, proximity, noise) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( reading["timestamp"], reading["temperature_c"], reading["temperature_f"], reading["humidity"], reading["pressure"], reading["light"], reading["proximity"], reading["noise"] )) await self._db.commit() async def get_history( self, metric: str, hours: float = 24, limit: int = 1000 ) -> list[dict]: """ Get historical readings for a metric. Args: metric: One of: temperature_c, temperature_f, humidity, pressure, light, proximity, noise hours: How many hours back to query limit: Maximum number of readings to return Returns: List of {timestamp, value} dicts """ valid_metrics = ["temperature_c", "temperature_f", "humidity", "pressure", "light", "proximity", "noise"] if metric not in valid_metrics: raise ValueError(f"Invalid metric: {metric}. Must be one of {valid_metrics}") since = time.time() - (hours * 3600) cursor = await self._db.execute(f""" SELECT timestamp, {metric} as value FROM readings WHERE timestamp > ? ORDER BY timestamp DESC LIMIT ? """, (since, limit)) rows = await cursor.fetchall() return [ {"timestamp": row[0], "value": row[1], "datetime": datetime.fromtimestamp(row[0]).isoformat()} for row in rows ] async def get_latest(self) -> Optional[dict]: """Get the most recent reading.""" cursor = await self._db.execute(""" SELECT timestamp, temperature_c, temperature_f, humidity, pressure, light, proximity, noise FROM readings ORDER BY timestamp DESC LIMIT 1 """) row = await cursor.fetchone() if not row: return None return { "timestamp": row[0], "datetime": datetime.fromtimestamp(row[0]).isoformat(), "temperature_c": row[1], "temperature_f": row[2], "humidity": row[3], "pressure": row[4], "light": row[5], "proximity": row[6], "noise": row[7] } async def cleanup_old_data(self): """Remove data older than retention period.""" cutoff = time.time() - (self.retention_hours * 3600) cursor = await self._db.execute( "DELETE FROM readings WHERE timestamp < ?", (cutoff,) ) await self._db.commit() deleted = cursor.rowcount if deleted > 0: logger.info(f"Cleaned up {deleted} old readings") return deleted # Alert management async def set_alert(self, metric: str, threshold: float, direction: str): """ Set an alert threshold. Args: metric: Which metric to monitor threshold: The threshold value direction: 'above' or 'below' """ if direction not in ('above', 'below'): raise ValueError("direction must be 'above' or 'below'") await self._db.execute(""" INSERT OR REPLACE INTO alerts (metric, threshold, direction, enabled) VALUES (?, ?, ?, 1) """, (metric, threshold, direction)) await self._db.commit() async def get_alerts(self) -> list[dict]: """Get all configured alerts.""" cursor = await self._db.execute(""" SELECT metric, threshold, direction, enabled, last_triggered FROM alerts """) rows = await cursor.fetchall() return [ { "metric": row[0], "threshold": row[1], "direction": row[2], "enabled": bool(row[3]), "last_triggered": row[4] } for row in rows ] async def log_alert_triggered(self, metric: str, value: float, threshold: float, direction: str): """Log when an alert is triggered.""" now = time.time() await self._db.execute(""" INSERT INTO alert_log (timestamp, metric, value, threshold, direction) VALUES (?, ?, ?, ?, ?) """, (now, metric, value, threshold, direction)) await self._db.execute(""" UPDATE alerts SET last_triggered = ? WHERE metric = ? AND direction = ? """, (now, metric, direction)) await self._db.commit() async def get_triggered_alerts(self, hours: float = 24) -> list[dict]: """Get recently triggered alerts.""" since = time.time() - (hours * 3600) cursor = await self._db.execute(""" SELECT timestamp, metric, value, threshold, direction FROM alert_log WHERE timestamp > ? ORDER BY timestamp DESC """, (since,)) rows = await cursor.fetchall() return [ { "timestamp": row[0], "datetime": datetime.fromtimestamp(row[0]).isoformat(), "metric": row[1], "value": row[2], "threshold": row[3], "direction": row[4] } for row in rows ] # Singleton instance _db: Optional[EnviroDatabase] = None async def get_database(db_path: str = "enviro_history.db", retention_hours: int = 168) -> EnviroDatabase: """Get or create the database instance.""" global _db if _db is None: _db = EnviroDatabase(db_path=db_path, retention_hours=retention_hours) await _db.connect() return _db