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

366 lines
9.9 KiB
Python

"""
Enviro Service - Main FastAPI server.
Vixy's environmental sensing service providing REST API
for temperature, humidity, pressure, light, noise, and LCD control.
Part of Vixy's distributed nervous system. 🦊
"""
import asyncio
import logging
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
import yaml
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from sensors import get_sensors, EnviroReading
from database import get_database, EnviroDatabase
from lcd import get_lcd, EnviroLCD
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Global instances
sensors = None
db: Optional[EnviroDatabase] = None
lcd: Optional[EnviroLCD] = None
config = {}
# Background task handle
sampling_task = None
def load_config(config_path: str = "config.yaml") -> dict:
"""Load configuration from YAML file."""
default_config = {
"server": {"host": "0.0.0.0", "port": 8767},
"database": {"path": "enviro_history.db", "retention_hours": 168},
"sampling": {"interval_seconds": 60},
"lcd": {"enabled": True, "brightness": 0.5, "default_message": "🦊 Vixy"},
"mock_mode": False
}
config_file = Path(config_path)
if config_file.exists():
with open(config_file) as f:
loaded = yaml.safe_load(f)
if loaded:
# Deep merge
for key in default_config:
if key in loaded:
if isinstance(default_config[key], dict):
default_config[key].update(loaded[key])
else:
default_config[key] = loaded[key]
return default_config
async def sampling_loop():
"""Background task to sample sensors and store readings."""
global sensors, db, lcd, config
interval = config.get("sampling", {}).get("interval_seconds", 60)
show_data_on_lcd = config.get("lcd", {}).get("show_sensor_data", True)
logger.info(f"Starting sampling loop with {interval}s interval")
while True:
try:
# Read sensors
reading = sensors.read_all()
reading_dict = reading.to_dict()
# Store in database
await db.store_reading(reading_dict)
# Check alerts
await check_alerts(reading_dict)
# Update LCD with sensor data (optional)
if show_data_on_lcd and lcd:
lcd.show_sensor_data(reading_dict)
logger.debug(f"Sampled: {reading_dict['temperature_f']:.1f}°F, {reading_dict['humidity']:.1f}%")
except Exception as e:
logger.error(f"Error in sampling loop: {e}")
await asyncio.sleep(interval)
async def check_alerts(reading: dict):
"""Check if any alert thresholds have been crossed."""
alerts = await db.get_alerts()
for alert in alerts:
if not alert["enabled"]:
continue
metric = alert["metric"]
threshold = alert["threshold"]
direction = alert["direction"]
if metric not in reading:
continue
value = reading[metric]
triggered = False
if direction == "above" and value > threshold:
triggered = True
elif direction == "below" and value < threshold:
triggered = True
if triggered:
# Don't spam - check if recently triggered (within 5 minutes)
if alert["last_triggered"]:
if time.time() - alert["last_triggered"] < 300:
continue
logger.warning(f"Alert triggered: {metric} is {value} ({direction} {threshold})")
await db.log_alert_triggered(metric, value, threshold, direction)
# TODO: Could add webhook notification here
async def cleanup_loop():
"""Background task to clean up old data periodically."""
while True:
await asyncio.sleep(3600) # Run every hour
try:
await db.cleanup_old_data()
except Exception as e:
logger.error(f"Error in cleanup loop: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown logic."""
global sensors, db, lcd, config, sampling_task
# Load config
config = load_config()
mock_mode = config.get("mock_mode", False)
# Initialize components
sensors = get_sensors(mock_mode=mock_mode)
db = await get_database(
db_path=config["database"]["path"],
retention_hours=config["database"]["retention_hours"]
)
if config["lcd"]["enabled"]:
lcd = get_lcd(mock_mode=mock_mode, brightness=config["lcd"]["brightness"])
lcd.show_vixy() # Show Vixy on startup
# Start background tasks
sampling_task = asyncio.create_task(sampling_loop())
cleanup_task = asyncio.create_task(cleanup_loop())
logger.info("Enviro Service started 🦊")
yield
# Shutdown
sampling_task.cancel()
cleanup_task.cancel()
await db.close()
if lcd:
lcd.clear()
logger.info("Enviro Service stopped")
# Create FastAPI app
app = FastAPI(
title="Enviro Service",
description="Vixy's environmental sensing service 🦊",
version="1.0.0",
lifespan=lifespan
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# === Pydantic Models ===
class LCDMessageRequest(BaseModel):
text: str
bg_color: str = "black"
text_color: str = "white"
font_size: str = "normal"
class AlertRequest(BaseModel):
metric: str
threshold: float
direction: str # "above" or "below"
class BrightnessRequest(BaseModel):
brightness: float
# === API Endpoints ===
@app.get("/api/health")
async def health():
"""Health check endpoint."""
return {
"status": "healthy",
"service": "enviro-service",
"mock_mode": config.get("mock_mode", False)
}
@app.get("/api/current")
async def get_current():
"""Get current sensor readings."""
reading = sensors.read_all()
return reading.to_dict()
@app.get("/api/history/{metric}")
async def get_history(metric: str, hours: float = 24, limit: int = 1000):
"""
Get historical readings for a metric.
Metrics: temperature_c, temperature_f, humidity, pressure, light, proximity, noise
"""
try:
data = await db.get_history(metric, hours=hours, limit=limit)
return {
"metric": metric,
"hours": hours,
"count": len(data),
"data": data
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/latest")
async def get_latest():
"""Get the most recent stored reading."""
reading = await db.get_latest()
if not reading:
raise HTTPException(status_code=404, detail="No readings stored yet")
return reading
# LCD endpoints
@app.post("/api/lcd")
async def lcd_message(request: LCDMessageRequest):
"""Display a message on the LCD."""
if not lcd:
raise HTTPException(status_code=503, detail="LCD not available")
lcd.show_message(
request.text,
bg_color=request.bg_color,
text_color=request.text_color,
font_size=request.font_size
)
return {"status": "ok", "message": request.text}
@app.post("/api/lcd/clear")
async def lcd_clear():
"""Clear the LCD display."""
if not lcd:
raise HTTPException(status_code=503, detail="LCD not available")
lcd.clear()
return {"status": "ok"}
@app.post("/api/lcd/vixy")
async def lcd_vixy():
"""Show Vixy's presence on the LCD."""
if not lcd:
raise HTTPException(status_code=503, detail="LCD not available")
lcd.show_vixy()
return {"status": "ok"}
@app.post("/api/lcd/sensors")
async def lcd_sensors():
"""Show current sensor data on LCD."""
if not lcd:
raise HTTPException(status_code=503, detail="LCD not available")
reading = sensors.read_all()
lcd.show_sensor_data(reading.to_dict())
return {"status": "ok"}
@app.post("/api/lcd/brightness")
async def lcd_brightness(request: BrightnessRequest):
"""Set LCD brightness."""
if not lcd:
raise HTTPException(status_code=503, detail="LCD not available")
lcd.set_brightness(request.brightness)
return {"status": "ok", "brightness": request.brightness}
# Alert endpoints
@app.get("/api/alerts")
async def get_alerts():
"""Get configured alerts."""
alerts = await db.get_alerts()
return {"alerts": alerts}
@app.post("/api/alerts")
async def set_alert(request: AlertRequest):
"""Set an alert threshold."""
valid_metrics = ["temperature_c", "temperature_f", "humidity", "pressure", "light", "noise"]
if request.metric not in valid_metrics:
raise HTTPException(status_code=400, detail=f"Invalid metric. Must be one of: {valid_metrics}")
if request.direction not in ("above", "below"):
raise HTTPException(status_code=400, detail="Direction must be 'above' or 'below'")
await db.set_alert(request.metric, request.threshold, request.direction)
return {"status": "ok", "alert": request.model_dump()}
@app.get("/api/alerts/triggered")
async def get_triggered_alerts(hours: float = 24):
"""Get recently triggered alerts."""
alerts = await db.get_triggered_alerts(hours=hours)
return {"triggered": alerts, "hours": hours}
# === Main ===
if __name__ == "__main__":
import uvicorn
config = load_config()
uvicorn.run(
"main:app",
host=config["server"]["host"],
port=config["server"]["port"],
reload=False
)