279 lines
8.8 KiB
Python
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
|