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