Initial commit: Enviro Service for Vixy's nervous system 🦊
This commit is contained in:
278
database.py
Normal file
278
database.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user