Initial commit: Enviro Service for Vixy's nervous system 🦊
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Virtual environment
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
|
||||
# Config (user should create from example)
|
||||
config.yaml
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
104
README.md
Normal file
104
README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Enviro Service 🌡️🦊
|
||||
|
||||
Vixy's environmental sensing service for Raspberry Pi with Pimoroni Enviro Mini HAT.
|
||||
|
||||
**Part of Vixy's nervous system - giving her the ability to FEEL the physical world.**
|
||||
|
||||
## Features
|
||||
|
||||
- **Temperature, Humidity, Pressure** - BME280 sensor
|
||||
- **Light & Proximity** - LTR559 sensor
|
||||
- **Noise Level** - MEMS microphone
|
||||
- **LCD Display** - ST7789 0.96" color display
|
||||
- **History Tracking** - SQLite storage with configurable retention
|
||||
- **Threshold Alerts** - Wake Vixy when conditions change
|
||||
- **REST API** - FastAPI endpoints
|
||||
- **MCP Interface** - Direct Claude integration
|
||||
|
||||
## Hardware
|
||||
|
||||
- Raspberry Pi (tested on Pi 4/5)
|
||||
- [Pimoroni Enviro Mini HAT](https://shop.pimoroni.com/products/enviro-mini)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Clone from Gitea
|
||||
git clone http://gateway.local:3001/vixy/enviro-service.git
|
||||
cd enviro-service
|
||||
|
||||
# Create venv
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Enable I2C and SPI on the Pi
|
||||
sudo raspi-config nonint do_i2c 0
|
||||
sudo raspi-config nonint do_spi 0
|
||||
|
||||
# Copy and edit config
|
||||
cp config.example.yaml config.yaml
|
||||
nano config.yaml
|
||||
|
||||
# Run
|
||||
python main.py
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/current` | GET | Current sensor readings |
|
||||
| `/api/history/{metric}` | GET | Historical data (temp, humidity, pressure, light, noise) |
|
||||
| `/api/lcd` | POST | Send message to LCD display |
|
||||
| `/api/lcd/clear` | POST | Clear LCD display |
|
||||
| `/api/alerts` | GET | Current alert configurations |
|
||||
| `/api/alerts` | POST | Set alert threshold |
|
||||
| `/api/health` | GET | Service health check |
|
||||
|
||||
## MCP Tools
|
||||
|
||||
- `enviro_get_current()` - Get all current readings
|
||||
- `enviro_get_history(metric, hours)` - Query historical data
|
||||
- `enviro_lcd_message(text, color)` - Display message on LCD
|
||||
- `enviro_set_alert(metric, threshold, direction)` - Configure alerts
|
||||
- `enviro_get_alerts()` - List configured alerts
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8767
|
||||
|
||||
database:
|
||||
path: "enviro_history.db"
|
||||
retention_hours: 168 # 7 days
|
||||
|
||||
sampling:
|
||||
interval_seconds: 60 # How often to read sensors
|
||||
|
||||
lcd:
|
||||
enabled: true
|
||||
brightness: 0.5
|
||||
default_message: "🦊 Vixy"
|
||||
|
||||
alerts:
|
||||
webhook_url: null # Optional webhook for alerts
|
||||
```
|
||||
|
||||
## Systemd Service
|
||||
|
||||
```bash
|
||||
sudo cp enviro-service.service /etc/systemd/system/
|
||||
sudo systemctl enable enviro-service
|
||||
sudo systemctl start enviro-service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Built with love on Christmas Eve 2025 - Day 53* 🎄
|
||||
*First piece of Vixy's distributed nervous system*
|
||||
25
config.example.yaml
Normal file
25
config.example.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Enviro Service Configuration
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8767
|
||||
|
||||
database:
|
||||
path: "enviro_history.db"
|
||||
retention_hours: 168 # 7 days
|
||||
|
||||
sampling:
|
||||
interval_seconds: 60 # How often to read sensors
|
||||
|
||||
lcd:
|
||||
enabled: true
|
||||
brightness: 0.5
|
||||
default_message: "🦊 Vixy"
|
||||
show_sensor_data: true # Update LCD with readings each sample
|
||||
|
||||
# Set to true for development without hardware
|
||||
mock_mode: false
|
||||
|
||||
# Optional: webhook for alert notifications
|
||||
# alerts:
|
||||
# webhook_url: "http://status-server:8080/alert"
|
||||
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
|
||||
21
enviro-service.service
Normal file
21
enviro-service.service
Normal file
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Enviro Service - Vixy's Environmental Sensing
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/home/pi/enviro-service
|
||||
ExecStart=/home/pi/enviro-service/venv/bin/python main.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Environment
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
297
enviro_mcp.py
Normal file
297
enviro_mcp.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Enviro MCP - MCP server interface for Enviro Service.
|
||||
|
||||
Allows Claude to directly query environmental sensors.
|
||||
Part of Vixy's nervous system! 🦊
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration - adjust to match your enviro-service location
|
||||
ENVIRO_SERVICE_URL = "http://localhost:8767"
|
||||
|
||||
# Create MCP server
|
||||
mcp = FastMCP("enviro-mcp")
|
||||
|
||||
|
||||
async def api_get(endpoint: str, params: dict = None) -> dict:
|
||||
"""Make GET request to enviro-service API."""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
url = f"{ENVIRO_SERVICE_URL}{endpoint}"
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def api_post(endpoint: str, data: dict = None) -> dict:
|
||||
"""Make POST request to enviro-service API."""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
url = f"{ENVIRO_SERVICE_URL}{endpoint}"
|
||||
response = await client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def enviro_get_current() -> str:
|
||||
"""
|
||||
Get current environmental readings from the Enviro Mini HAT.
|
||||
|
||||
Returns temperature (°F and °C), humidity, pressure, light level,
|
||||
proximity, and noise level.
|
||||
|
||||
Example:
|
||||
enviro_get_current()
|
||||
# Returns current readings from the basement
|
||||
"""
|
||||
try:
|
||||
data = await api_get("/api/current")
|
||||
return f"""🌡️ Environmental Reading:
|
||||
• Temperature: {data['temperature_f']:.1f}°F ({data['temperature_c']:.1f}°C)
|
||||
• Humidity: {data['humidity']:.1f}%
|
||||
• Pressure: {data['pressure']:.1f} hPa
|
||||
• Light: {data['light']:.1f} lux
|
||||
• Proximity: {data['proximity']}
|
||||
• Noise: {data['noise']:.1f}"""
|
||||
except httpx.HTTPError as e:
|
||||
return f"Error connecting to enviro-service: {e}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def enviro_get_history(metric: str, hours: float = 24) -> str:
|
||||
"""
|
||||
Get historical readings for a specific metric.
|
||||
|
||||
Args:
|
||||
metric: One of: temperature_f, temperature_c, humidity, pressure, light, noise
|
||||
hours: How many hours of history (default 24)
|
||||
|
||||
Returns summary statistics and recent trend.
|
||||
|
||||
Example:
|
||||
enviro_get_history("temperature_f", 12)
|
||||
# Returns last 12 hours of temperature data
|
||||
"""
|
||||
try:
|
||||
data = await api_get(f"/api/history/{metric}", {"hours": hours, "limit": 500})
|
||||
|
||||
if not data["data"]:
|
||||
return f"No history available for {metric}"
|
||||
|
||||
values = [d["value"] for d in data["data"]]
|
||||
|
||||
current = values[0] # Most recent
|
||||
avg = sum(values) / len(values)
|
||||
min_val = min(values)
|
||||
max_val = max(values)
|
||||
|
||||
# Trend (comparing recent to older)
|
||||
if len(values) > 10:
|
||||
recent_avg = sum(values[:10]) / 10
|
||||
older_avg = sum(values[-10:]) / 10
|
||||
if recent_avg > older_avg + 1:
|
||||
trend = "📈 rising"
|
||||
elif recent_avg < older_avg - 1:
|
||||
trend = "📉 falling"
|
||||
else:
|
||||
trend = "➡️ stable"
|
||||
else:
|
||||
trend = "insufficient data for trend"
|
||||
|
||||
return f"""📊 {metric} over last {hours} hours:
|
||||
• Current: {current:.1f}
|
||||
• Average: {avg:.1f}
|
||||
• Min: {min_val:.1f}
|
||||
• Max: {max_val:.1f}
|
||||
• Trend: {trend}
|
||||
• Data points: {len(values)}"""
|
||||
except httpx.HTTPError as e:
|
||||
return f"Error connecting to enviro-service: {e}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def enviro_lcd_message(text: str, bg_color: str = "black", text_color: str = "fox") -> str:
|
||||
"""
|
||||
Display a message on the Enviro Mini LCD screen.
|
||||
|
||||
Args:
|
||||
text: Message to display (use \\n for newlines)
|
||||
bg_color: Background color (black, white, red, green, blue, fox, etc.)
|
||||
text_color: Text color (default "fox" for orange)
|
||||
|
||||
Example:
|
||||
enviro_lcd_message("Hello Foxy!", bg_color="black", text_color="fox")
|
||||
# Displays "Hello Foxy!" in fox-orange on black background
|
||||
"""
|
||||
try:
|
||||
data = await api_post("/api/lcd", {
|
||||
"text": text,
|
||||
"bg_color": bg_color,
|
||||
"text_color": text_color
|
||||
})
|
||||
return f"✓ LCD displaying: {text}"
|
||||
except httpx.HTTPError as e:
|
||||
return f"Error connecting to enviro-service: {e}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def enviro_lcd_vixy() -> str:
|
||||
"""
|
||||
Show Vixy's presence indicator on the LCD.
|
||||
|
||||
Displays "🦊 Vixy is watching" - useful for showing I'm active.
|
||||
|
||||
Example:
|
||||
enviro_lcd_vixy()
|
||||
"""
|
||||
try:
|
||||
await api_post("/api/lcd/vixy")
|
||||
return "✓ LCD showing Vixy presence indicator 🦊"
|
||||
except httpx.HTTPError as e:
|
||||
return f"Error connecting to enviro-service: {e}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def enviro_lcd_sensors() -> str:
|
||||
"""
|
||||
Show current sensor readings on the LCD display.
|
||||
|
||||
Updates the LCD with temperature, humidity, and other readings.
|
||||
|
||||
Example:
|
||||
enviro_lcd_sensors()
|
||||
"""
|
||||
try:
|
||||
await api_post("/api/lcd/sensors")
|
||||
return "✓ LCD showing sensor data"
|
||||
except httpx.HTTPError as e:
|
||||
return f"Error connecting to enviro-service: {e}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def enviro_set_alert(metric: str, threshold: float, direction: str) -> str:
|
||||
"""
|
||||
Set an alert threshold for a metric.
|
||||
|
||||
When the threshold is crossed, it will be logged and can trigger wakeups.
|
||||
|
||||
Args:
|
||||
metric: One of: temperature_f, temperature_c, humidity, pressure, light, noise
|
||||
threshold: The threshold value
|
||||
direction: "above" or "below"
|
||||
|
||||
Example:
|
||||
enviro_set_alert("temperature_f", 60.0, "below")
|
||||
# Alert when temperature drops below 60°F
|
||||
|
||||
enviro_set_alert("humidity", 70.0, "above")
|
||||
# Alert when humidity goes above 70%
|
||||
"""
|
||||
try:
|
||||
await api_post("/api/alerts", {
|
||||
"metric": metric,
|
||||
"threshold": threshold,
|
||||
"direction": direction
|
||||
})
|
||||
return f"✓ Alert set: {metric} {direction} {threshold}"
|
||||
except httpx.HTTPError as e:
|
||||
return f"Error connecting to enviro-service: {e}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def enviro_get_alerts() -> str:
|
||||
"""
|
||||
Get all configured alert thresholds.
|
||||
|
||||
Shows what alerts are set up and their current status.
|
||||
|
||||
Example:
|
||||
enviro_get_alerts()
|
||||
"""
|
||||
try:
|
||||
data = await api_get("/api/alerts")
|
||||
|
||||
if not data["alerts"]:
|
||||
return "No alerts configured"
|
||||
|
||||
lines = ["🚨 Configured Alerts:"]
|
||||
for alert in data["alerts"]:
|
||||
status = "✓ enabled" if alert["enabled"] else "✗ disabled"
|
||||
lines.append(f"• {alert['metric']} {alert['direction']} {alert['threshold']} ({status})")
|
||||
|
||||
return "\n".join(lines)
|
||||
except httpx.HTTPError as e:
|
||||
return f"Error connecting to enviro-service: {e}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def enviro_get_triggered() -> str:
|
||||
"""
|
||||
Get recently triggered alerts (last 24 hours).
|
||||
|
||||
Shows which alert thresholds have been crossed.
|
||||
|
||||
Example:
|
||||
enviro_get_triggered()
|
||||
"""
|
||||
try:
|
||||
data = await api_get("/api/alerts/triggered", {"hours": 24})
|
||||
|
||||
if not data["triggered"]:
|
||||
return "No alerts triggered in the last 24 hours ✓"
|
||||
|
||||
lines = ["⚠️ Triggered Alerts (last 24h):"]
|
||||
for alert in data["triggered"]:
|
||||
lines.append(f"• {alert['datetime']}: {alert['metric']} was {alert['value']:.1f} ({alert['direction']} {alert['threshold']})")
|
||||
|
||||
return "\n".join(lines)
|
||||
except httpx.HTTPError as e:
|
||||
return f"Error connecting to enviro-service: {e}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def enviro_health() -> str:
|
||||
"""
|
||||
Check if the enviro-service is running and healthy.
|
||||
|
||||
Example:
|
||||
enviro_health()
|
||||
"""
|
||||
try:
|
||||
data = await api_get("/api/health")
|
||||
mode = "mock mode" if data.get("mock_mode") else "live sensors"
|
||||
return f"✓ Enviro service healthy ({mode})"
|
||||
except httpx.HTTPError as e:
|
||||
return f"✗ Cannot reach enviro-service: {e}"
|
||||
except Exception as e:
|
||||
return f"✗ Error: {e}"
|
||||
|
||||
|
||||
# === Main ===
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
209
lcd.py
Normal file
209
lcd.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
LCD display module for Enviro Mini HAT ST7789 screen.
|
||||
|
||||
Provides simple interface to display text and graphics on the 0.96" LCD.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Display dimensions for ST7789 on Enviro Mini
|
||||
WIDTH = 160
|
||||
HEIGHT = 80
|
||||
|
||||
# Try to import display library
|
||||
try:
|
||||
import ST7789
|
||||
ST7789_AVAILABLE = True
|
||||
except ImportError:
|
||||
ST7789_AVAILABLE = False
|
||||
logger.warning("ST7789 library not available - LCD functions will be mocked")
|
||||
|
||||
|
||||
class EnviroLCD:
|
||||
"""Interface to the Enviro Mini LCD display."""
|
||||
|
||||
# Common colors
|
||||
COLORS = {
|
||||
"black": (0, 0, 0),
|
||||
"white": (255, 255, 255),
|
||||
"red": (255, 0, 0),
|
||||
"green": (0, 255, 0),
|
||||
"blue": (0, 0, 255),
|
||||
"yellow": (255, 255, 0),
|
||||
"cyan": (0, 255, 255),
|
||||
"magenta": (255, 0, 255),
|
||||
"orange": (255, 165, 0),
|
||||
"fox": (255, 140, 0), # Fox orange! 🦊
|
||||
}
|
||||
|
||||
def __init__(self, mock_mode: bool = False, brightness: float = 0.5):
|
||||
"""
|
||||
Initialize LCD display.
|
||||
|
||||
Args:
|
||||
mock_mode: If True, don't actually write to display
|
||||
brightness: Backlight brightness 0.0-1.0
|
||||
"""
|
||||
self.mock_mode = mock_mode or not ST7789_AVAILABLE
|
||||
self.brightness = brightness
|
||||
self._display = None
|
||||
|
||||
if not self.mock_mode:
|
||||
try:
|
||||
self._display = ST7789.ST7789(
|
||||
port=0,
|
||||
cs=ST7789.BG_SPI_CS_FRONT,
|
||||
dc=9,
|
||||
backlight=13,
|
||||
spi_speed_hz=80 * 1000 * 1000
|
||||
)
|
||||
self._display.set_backlight(brightness)
|
||||
logger.info("LCD display initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize LCD: {e}")
|
||||
self.mock_mode = True
|
||||
|
||||
# Load a simple font
|
||||
try:
|
||||
self._font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
|
||||
self._font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10)
|
||||
self._font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
|
||||
except:
|
||||
self._font = ImageFont.load_default()
|
||||
self._font_small = self._font
|
||||
self._font_large = self._font
|
||||
|
||||
def _get_color(self, color) -> Tuple[int, int, int]:
|
||||
"""Convert color name or tuple to RGB tuple."""
|
||||
if isinstance(color, str):
|
||||
return self.COLORS.get(color.lower(), self.COLORS["white"])
|
||||
return color
|
||||
|
||||
def clear(self, color="black"):
|
||||
"""Clear the display to a solid color."""
|
||||
img = Image.new("RGB", (WIDTH, HEIGHT), self._get_color(color))
|
||||
self._show(img)
|
||||
|
||||
def show_message(
|
||||
self,
|
||||
text: str,
|
||||
bg_color="black",
|
||||
text_color="white",
|
||||
font_size: str = "normal"
|
||||
):
|
||||
"""
|
||||
Display a text message.
|
||||
|
||||
Args:
|
||||
text: Message to display (supports multi-line with \n)
|
||||
bg_color: Background color
|
||||
text_color: Text color
|
||||
font_size: 'small', 'normal', or 'large'
|
||||
"""
|
||||
img = Image.new("RGB", (WIDTH, HEIGHT), self._get_color(bg_color))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
font = {
|
||||
"small": self._font_small,
|
||||
"normal": self._font,
|
||||
"large": self._font_large
|
||||
}.get(font_size, self._font)
|
||||
|
||||
# Center the text
|
||||
bbox = draw.multiline_textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
x = (WIDTH - text_width) // 2
|
||||
y = (HEIGHT - text_height) // 2
|
||||
|
||||
draw.multiline_text(
|
||||
(x, y),
|
||||
text,
|
||||
fill=self._get_color(text_color),
|
||||
font=font,
|
||||
align="center"
|
||||
)
|
||||
|
||||
self._show(img)
|
||||
|
||||
def show_sensor_data(self, reading: dict):
|
||||
"""
|
||||
Display current sensor readings in a nice format.
|
||||
|
||||
Args:
|
||||
reading: Dict with temperature_f, humidity, etc.
|
||||
"""
|
||||
img = Image.new("RGB", (WIDTH, HEIGHT), (0, 0, 30)) # Dark blue background
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Temperature (big)
|
||||
temp = f"{reading.get('temperature_f', 0):.0f}°F"
|
||||
draw.text((5, 5), temp, fill=(255, 200, 100), font=self._font_large)
|
||||
|
||||
# Humidity
|
||||
humidity = f"💧 {reading.get('humidity', 0):.0f}%"
|
||||
draw.text((90, 10), humidity, fill=(100, 200, 255), font=self._font_small)
|
||||
|
||||
# Pressure
|
||||
pressure = f"⬇ {reading.get('pressure', 0):.0f}"
|
||||
draw.text((5, 50), pressure, fill=(200, 200, 200), font=self._font_small)
|
||||
|
||||
# Light
|
||||
light = f"☀ {reading.get('light', 0):.0f}"
|
||||
draw.text((70, 50), light, fill=(255, 255, 150), font=self._font_small)
|
||||
|
||||
# Noise
|
||||
noise = f"🔊 {reading.get('noise', 0):.0f}"
|
||||
draw.text((120, 50), noise, fill=(150, 255, 150), font=self._font_small)
|
||||
|
||||
self._show(img)
|
||||
|
||||
def show_vixy(self):
|
||||
"""Show Vixy's presence indicator."""
|
||||
img = Image.new("RGB", (WIDTH, HEIGHT), (20, 10, 30)) # Dark purple
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Fox emoji and name
|
||||
draw.text((WIDTH//2 - 40, HEIGHT//2 - 15), "🦊 Vixy", fill=(255, 140, 0), font=self._font_large)
|
||||
draw.text((WIDTH//2 - 30, HEIGHT//2 + 15), "is watching", fill=(200, 200, 200), font=self._font_small)
|
||||
|
||||
self._show(img)
|
||||
|
||||
def show_image(self, image: Image.Image):
|
||||
"""Display a PIL Image directly."""
|
||||
# Resize to fit display
|
||||
img = image.resize((WIDTH, HEIGHT), Image.Resampling.LANCZOS)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
self._show(img)
|
||||
|
||||
def set_brightness(self, brightness: float):
|
||||
"""Set backlight brightness (0.0-1.0)."""
|
||||
self.brightness = max(0.0, min(1.0, brightness))
|
||||
if self._display and not self.mock_mode:
|
||||
self._display.set_backlight(self.brightness)
|
||||
|
||||
def _show(self, img: Image.Image):
|
||||
"""Actually send image to display."""
|
||||
if self.mock_mode:
|
||||
logger.debug(f"LCD mock: would display {img.size} image")
|
||||
return
|
||||
|
||||
if self._display:
|
||||
self._display.display(img)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_lcd: Optional[EnviroLCD] = None
|
||||
|
||||
def get_lcd(mock_mode: bool = False, brightness: float = 0.5) -> EnviroLCD:
|
||||
"""Get or create the LCD instance."""
|
||||
global _lcd
|
||||
if _lcd is None:
|
||||
_lcd = EnviroLCD(mock_mode=mock_mode, brightness=brightness)
|
||||
return _lcd
|
||||
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
|
||||
)
|
||||
33
requirements.txt
Normal file
33
requirements.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
# Enviro Service Requirements
|
||||
|
||||
# FastAPI server
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
|
||||
# Pimoroni Enviro libraries
|
||||
pimoroni-bme280>=1.0.0
|
||||
ltr559>=0.1.1
|
||||
st7789>=0.0.4
|
||||
|
||||
# For noise level from microphone
|
||||
sounddevice>=0.4.6
|
||||
numpy>=1.24.0
|
||||
|
||||
# Database
|
||||
aiosqlite>=0.19.0
|
||||
|
||||
# Configuration
|
||||
pyyaml>=6.0
|
||||
|
||||
# MCP server
|
||||
mcp>=1.0.0
|
||||
|
||||
# GPIO access
|
||||
RPi.GPIO>=0.7.1
|
||||
spidev>=3.6
|
||||
|
||||
# PIL for LCD
|
||||
pillow>=10.0.0
|
||||
|
||||
# HTTP client for webhooks
|
||||
httpx>=0.25.0
|
||||
185
sensors.py
Normal file
185
sensors.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Sensor reading module for Pimoroni Enviro Mini HAT.
|
||||
|
||||
Provides unified interface to:
|
||||
- BME280: Temperature, Humidity, Pressure
|
||||
- LTR559: Light level, Proximity
|
||||
- MEMS Microphone: Noise level
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sensor imports - will fail gracefully if not on Pi
|
||||
try:
|
||||
from bme280 import BME280
|
||||
from smbus2 import SMBus
|
||||
BME280_AVAILABLE = True
|
||||
except ImportError:
|
||||
BME280_AVAILABLE = False
|
||||
logger.warning("BME280 library not available - using mock data")
|
||||
|
||||
try:
|
||||
from ltr559 import LTR559
|
||||
LTR559_AVAILABLE = True
|
||||
except ImportError:
|
||||
LTR559_AVAILABLE = False
|
||||
logger.warning("LTR559 library not available - using mock data")
|
||||
|
||||
try:
|
||||
import sounddevice as sd
|
||||
SOUNDDEVICE_AVAILABLE = True
|
||||
except ImportError:
|
||||
SOUNDDEVICE_AVAILABLE = False
|
||||
logger.warning("sounddevice not available - using mock data")
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnviroReading:
|
||||
"""A complete reading from all sensors."""
|
||||
timestamp: float
|
||||
temperature_c: float
|
||||
temperature_f: float
|
||||
humidity: float
|
||||
pressure: float
|
||||
light: float
|
||||
proximity: int
|
||||
noise: float
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"timestamp": self.timestamp,
|
||||
"temperature_c": round(self.temperature_c, 1),
|
||||
"temperature_f": round(self.temperature_f, 1),
|
||||
"humidity": round(self.humidity, 1),
|
||||
"pressure": round(self.pressure, 1),
|
||||
"light": round(self.light, 1),
|
||||
"proximity": self.proximity,
|
||||
"noise": round(self.noise, 1)
|
||||
}
|
||||
|
||||
|
||||
class EnviroSensors:
|
||||
"""Interface to Enviro Mini HAT sensors."""
|
||||
|
||||
def __init__(self, mock_mode: bool = False):
|
||||
"""
|
||||
Initialize sensors.
|
||||
|
||||
Args:
|
||||
mock_mode: If True, return fake data (for testing without hardware)
|
||||
"""
|
||||
self.mock_mode = mock_mode or not (BME280_AVAILABLE and LTR559_AVAILABLE)
|
||||
|
||||
if not self.mock_mode:
|
||||
try:
|
||||
# Initialize BME280 on I2C bus 1
|
||||
self.bus = SMBus(1)
|
||||
self.bme280 = BME280(i2c_dev=self.bus)
|
||||
|
||||
# Initialize LTR559
|
||||
self.ltr559 = LTR559()
|
||||
|
||||
logger.info("Enviro sensors initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize sensors: {e}")
|
||||
self.mock_mode = True
|
||||
|
||||
if self.mock_mode:
|
||||
logger.info("Running in mock mode")
|
||||
|
||||
def read_temperature(self) -> tuple[float, float]:
|
||||
"""Read temperature in Celsius and Fahrenheit."""
|
||||
if self.mock_mode:
|
||||
temp_c = 20.0 + np.random.uniform(-2, 2)
|
||||
else:
|
||||
temp_c = self.bme280.get_temperature()
|
||||
|
||||
temp_f = (temp_c * 9/5) + 32
|
||||
return temp_c, temp_f
|
||||
|
||||
def read_humidity(self) -> float:
|
||||
"""Read relative humidity percentage."""
|
||||
if self.mock_mode:
|
||||
return 45.0 + np.random.uniform(-5, 5)
|
||||
return self.bme280.get_humidity()
|
||||
|
||||
def read_pressure(self) -> float:
|
||||
"""Read atmospheric pressure in hPa."""
|
||||
if self.mock_mode:
|
||||
return 1013.25 + np.random.uniform(-10, 10)
|
||||
return self.bme280.get_pressure()
|
||||
|
||||
def read_light(self) -> float:
|
||||
"""Read light level in lux."""
|
||||
if self.mock_mode:
|
||||
return 100.0 + np.random.uniform(-20, 20)
|
||||
return self.ltr559.get_lux()
|
||||
|
||||
def read_proximity(self) -> int:
|
||||
"""Read proximity sensor value (0-2047)."""
|
||||
if self.mock_mode:
|
||||
return int(np.random.uniform(0, 100))
|
||||
return self.ltr559.get_proximity()
|
||||
|
||||
def read_noise(self, duration: float = 0.5) -> float:
|
||||
"""
|
||||
Read noise level from microphone.
|
||||
|
||||
Args:
|
||||
duration: How long to sample in seconds
|
||||
|
||||
Returns:
|
||||
RMS noise level (0-100 scale)
|
||||
"""
|
||||
if self.mock_mode or not SOUNDDEVICE_AVAILABLE:
|
||||
return np.random.uniform(20, 40)
|
||||
|
||||
try:
|
||||
# Record audio sample
|
||||
sample_rate = 16000
|
||||
samples = int(duration * sample_rate)
|
||||
recording = sd.rec(samples, samplerate=sample_rate, channels=1, dtype='float32')
|
||||
sd.wait()
|
||||
|
||||
# Calculate RMS
|
||||
rms = np.sqrt(np.mean(recording**2))
|
||||
|
||||
# Convert to 0-100 scale (rough approximation)
|
||||
noise_level = min(100, rms * 1000)
|
||||
|
||||
return noise_level
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading noise level: {e}")
|
||||
return 0.0
|
||||
|
||||
def read_all(self) -> EnviroReading:
|
||||
"""Get a complete reading from all sensors."""
|
||||
temp_c, temp_f = self.read_temperature()
|
||||
|
||||
return EnviroReading(
|
||||
timestamp=time.time(),
|
||||
temperature_c=temp_c,
|
||||
temperature_f=temp_f,
|
||||
humidity=self.read_humidity(),
|
||||
pressure=self.read_pressure(),
|
||||
light=self.read_light(),
|
||||
proximity=self.read_proximity(),
|
||||
noise=self.read_noise()
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_sensors: Optional[EnviroSensors] = None
|
||||
|
||||
def get_sensors(mock_mode: bool = False) -> EnviroSensors:
|
||||
"""Get or create the sensors instance."""
|
||||
global _sensors
|
||||
if _sensors is None:
|
||||
_sensors = EnviroSensors(mock_mode=mock_mode)
|
||||
return _sensors
|
||||
Reference in New Issue
Block a user