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