Files
enviro-service/database.py
2025-12-24 11:17:56 -06:00

279 lines
8.8 KiB
Python

"""
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