mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 22:07:24 +00:00
feat(picobrew): API de gestion déléguant au ctl; contrôleur capteurs préservé pour la phase 2
This commit is contained in:
parent
6f327afea1
commit
391612bb7d
File diff suppressed because it is too large
Load Diff
992
packages/secubox-picobrew/lib/stillwatch/legacy_controller.py
Normal file
992
packages/secubox-picobrew/lib/stillwatch/legacy_controller.py
Normal file
|
|
@ -0,0 +1,992 @@
|
|||
"""secubox-picobrew — FastAPI application for Homebrew/Fermentation Controller.
|
||||
|
||||
SecuBox-Deb :: PicoBrew Controller
|
||||
CyberMind — https://cybermind.fr
|
||||
Author: Gerald Kerma <devel@cybermind.fr>
|
||||
License: Proprietary / ANSSI CSPN candidate
|
||||
|
||||
Provides temperature monitoring, fermentation profiles, session logging,
|
||||
recipe management, and alerts for temperature deviations.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import glob
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
from fastapi import FastAPI, APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
import uuid
|
||||
|
||||
from secubox_core.auth import router as auth_router, require_jwt
|
||||
from secubox_core.logger import get_logger
|
||||
|
||||
app = FastAPI(title="secubox-picobrew", version="1.0.0", root_path="/api/v1/picobrew")
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# Health Check Endpoint (public, no auth)
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Public health check endpoint for sidebar status."""
|
||||
return {"status": "ok", "module": "deb"}
|
||||
|
||||
app.include_router(auth_router, prefix="/auth")
|
||||
router = APIRouter()
|
||||
log = get_logger("picobrew")
|
||||
|
||||
# Configuration paths
|
||||
CONFIG_FILE = Path("/etc/secubox/picobrew.toml")
|
||||
DATA_PATH = Path("/var/lib/secubox/picobrew")
|
||||
CACHE_FILE = Path("/var/cache/secubox/picobrew/sensors.json")
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"poll_interval": 30, # seconds
|
||||
"temp_unit": "C", # C or F
|
||||
"alert_threshold": 2.0, # degrees deviation before alert
|
||||
"sensor_types": ["ds18b20", "dht22", "bme280"],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Models
|
||||
# ============================================================================
|
||||
|
||||
class TempUnit(str, Enum):
|
||||
CELSIUS = "C"
|
||||
FAHRENHEIT = "F"
|
||||
|
||||
|
||||
class SensorType(str, Enum):
|
||||
DS18B20 = "ds18b20"
|
||||
DHT22 = "dht22"
|
||||
BME280 = "bme280"
|
||||
TILT = "tilt"
|
||||
ISPINDEL = "ispindel"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class SensorReading(BaseModel):
|
||||
sensor_id: str
|
||||
sensor_type: SensorType
|
||||
temperature: float
|
||||
humidity: Optional[float] = None
|
||||
gravity: Optional[float] = None
|
||||
battery: Optional[float] = None
|
||||
timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
|
||||
class Sensor(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
type: SensorType
|
||||
address: Optional[str] = None # Hardware address for 1-wire, BLE MAC for Tilt
|
||||
enabled: bool = True
|
||||
calibration_offset: float = 0.0
|
||||
last_reading: Optional[SensorReading] = None
|
||||
|
||||
|
||||
class ProfileStep(BaseModel):
|
||||
day: int
|
||||
target_temp: float
|
||||
ramp_time: int = 0 # hours to reach target
|
||||
|
||||
|
||||
class FermentationProfile(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = ""
|
||||
steps: List[ProfileStep]
|
||||
created: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
|
||||
class BrewSession(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
name: str
|
||||
recipe_name: Optional[str] = None
|
||||
profile_name: Optional[str] = None
|
||||
sensor_id: Optional[str] = None
|
||||
start_time: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
end_time: Optional[str] = None
|
||||
status: str = "active" # active, completed, aborted
|
||||
notes: Optional[str] = ""
|
||||
og: Optional[float] = None # Original gravity
|
||||
fg: Optional[float] = None # Final gravity
|
||||
readings: List[SensorReading] = []
|
||||
|
||||
|
||||
class Recipe(BaseModel):
|
||||
name: str
|
||||
style: Optional[str] = ""
|
||||
og_target: Optional[float] = None
|
||||
fg_target: Optional[float] = None
|
||||
fermentation_profile: Optional[str] = None
|
||||
ingredients: Optional[str] = ""
|
||||
instructions: Optional[str] = ""
|
||||
created: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
|
||||
class Alert(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
session_id: Optional[str] = None
|
||||
sensor_id: str
|
||||
alert_type: str # temp_high, temp_low, sensor_offline, battery_low
|
||||
message: str
|
||||
timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
acknowledged: bool = False
|
||||
|
||||
|
||||
class PicobrewConfig(BaseModel):
|
||||
poll_interval: int = 30
|
||||
temp_unit: TempUnit = TempUnit.CELSIUS
|
||||
alert_threshold: float = 2.0
|
||||
|
||||
|
||||
class AddProfileRequest(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = ""
|
||||
steps: List[ProfileStep]
|
||||
|
||||
|
||||
class AddRecipeRequest(BaseModel):
|
||||
name: str
|
||||
style: Optional[str] = ""
|
||||
og_target: Optional[float] = None
|
||||
fg_target: Optional[float] = None
|
||||
fermentation_profile: Optional[str] = None
|
||||
ingredients: Optional[str] = ""
|
||||
instructions: Optional[str] = ""
|
||||
|
||||
|
||||
class StartSessionRequest(BaseModel):
|
||||
name: str
|
||||
recipe_name: Optional[str] = None
|
||||
profile_name: Optional[str] = None
|
||||
sensor_id: Optional[str] = None
|
||||
og: Optional[float] = None
|
||||
notes: Optional[str] = ""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Storage Helpers
|
||||
# ============================================================================
|
||||
|
||||
def ensure_data_dirs():
|
||||
"""Ensure data directories exist."""
|
||||
for subdir in ["sensors", "profiles", "sessions", "recipes", "alerts"]:
|
||||
(DATA_PATH / subdir).mkdir(parents=True, exist_ok=True)
|
||||
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
"""Load picobrew configuration."""
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
import tomllib
|
||||
return tomllib.loads(CONFIG_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_CONFIG.copy()
|
||||
|
||||
|
||||
def load_json_file(path: Path) -> dict:
|
||||
"""Load a JSON file."""
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_json_file(path: Path, data: dict):
|
||||
"""Save data to a JSON file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def list_json_files(directory: Path) -> List[dict]:
|
||||
"""List all JSON files in a directory."""
|
||||
items = []
|
||||
if directory.exists():
|
||||
for f in directory.glob("*.json"):
|
||||
try:
|
||||
items.append(json.loads(f.read_text()))
|
||||
except Exception:
|
||||
pass
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Sensor Detection & Reading
|
||||
# ============================================================================
|
||||
|
||||
def detect_1wire_sensors() -> List[dict]:
|
||||
"""Detect DS18B20 1-wire temperature sensors."""
|
||||
sensors = []
|
||||
w1_path = Path("/sys/bus/w1/devices")
|
||||
if w1_path.exists():
|
||||
for device in w1_path.glob("28-*"):
|
||||
sensor_id = device.name
|
||||
sensors.append({
|
||||
"id": sensor_id,
|
||||
"type": "ds18b20",
|
||||
"address": sensor_id,
|
||||
"path": str(device / "w1_slave")
|
||||
})
|
||||
return sensors
|
||||
|
||||
|
||||
def read_ds18b20(device_path: str) -> Optional[float]:
|
||||
"""Read temperature from DS18B20 sensor."""
|
||||
try:
|
||||
path = Path(device_path)
|
||||
if path.exists():
|
||||
content = path.read_text()
|
||||
if "YES" in content:
|
||||
temp_str = content.split("t=")[-1].strip()
|
||||
return float(temp_str) / 1000.0
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def detect_i2c_sensors() -> List[dict]:
|
||||
"""Detect I2C sensors (BME280, etc.)."""
|
||||
sensors = []
|
||||
# Check for i2c devices
|
||||
i2c_path = Path("/sys/bus/i2c/devices")
|
||||
if i2c_path.exists():
|
||||
# BME280 typically at address 0x76 or 0x77
|
||||
for addr in ["0076", "0077"]:
|
||||
for bus in i2c_path.glob(f"*-{addr}"):
|
||||
sensors.append({
|
||||
"id": f"bme280-{addr}",
|
||||
"type": "bme280",
|
||||
"address": f"0x{addr}",
|
||||
"bus": bus.name.split("-")[0]
|
||||
})
|
||||
return sensors
|
||||
|
||||
|
||||
def detect_usb_sensors() -> List[dict]:
|
||||
"""Detect USB temperature sensors."""
|
||||
sensors = []
|
||||
# Look for common USB temperature loggers
|
||||
for pattern in ["/dev/ttyUSB*", "/dev/ttyACM*"]:
|
||||
for path in glob.glob(pattern):
|
||||
# Could be a USB temperature sensor
|
||||
sensors.append({
|
||||
"id": f"usb-{Path(path).name}",
|
||||
"type": "manual",
|
||||
"address": path
|
||||
})
|
||||
return sensors
|
||||
|
||||
|
||||
async def read_all_sensors() -> List[SensorReading]:
|
||||
"""Read all configured sensors."""
|
||||
readings = []
|
||||
sensors_dir = DATA_PATH / "sensors"
|
||||
|
||||
for sensor_file in sensors_dir.glob("*.json"):
|
||||
try:
|
||||
sensor = json.loads(sensor_file.read_text())
|
||||
if not sensor.get("enabled", True):
|
||||
continue
|
||||
|
||||
reading = None
|
||||
if sensor["type"] == "ds18b20":
|
||||
w1_path = f"/sys/bus/w1/devices/{sensor['address']}/w1_slave"
|
||||
temp = read_ds18b20(w1_path)
|
||||
if temp is not None:
|
||||
temp += sensor.get("calibration_offset", 0.0)
|
||||
reading = SensorReading(
|
||||
sensor_id=sensor["id"],
|
||||
sensor_type=SensorType.DS18B20,
|
||||
temperature=temp
|
||||
)
|
||||
elif sensor["type"] == "manual":
|
||||
# Manual sensors use last stored reading
|
||||
if sensor.get("last_reading"):
|
||||
reading = SensorReading(**sensor["last_reading"])
|
||||
|
||||
if reading:
|
||||
readings.append(reading)
|
||||
except Exception as e:
|
||||
log.error(f"Error reading sensor {sensor_file.name}: {e}")
|
||||
|
||||
return readings
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Background Cache Refresh
|
||||
# ============================================================================
|
||||
|
||||
_cache: dict = {"sensors": [], "last_update": None}
|
||||
|
||||
|
||||
async def refresh_cache():
|
||||
"""Background task to refresh sensor cache."""
|
||||
ensure_data_dirs()
|
||||
while True:
|
||||
try:
|
||||
readings = await read_all_sensors()
|
||||
_cache["sensors"] = [r.model_dump() for r in readings]
|
||||
_cache["last_update"] = datetime.utcnow().isoformat()
|
||||
|
||||
# Save to file cache
|
||||
save_json_file(CACHE_FILE, _cache)
|
||||
|
||||
# Check for alerts
|
||||
await check_alerts(readings)
|
||||
|
||||
# Log readings to active sessions
|
||||
await log_session_readings(readings)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Cache refresh failed: {e}")
|
||||
|
||||
config = get_config()
|
||||
await asyncio.sleep(config.get("poll_interval", 30))
|
||||
|
||||
|
||||
async def check_alerts(readings: List[SensorReading]):
|
||||
"""Check readings against active session targets and generate alerts."""
|
||||
config = get_config()
|
||||
threshold = config.get("alert_threshold", 2.0)
|
||||
|
||||
sessions = list_json_files(DATA_PATH / "sessions")
|
||||
active_sessions = [s for s in sessions if s.get("status") == "active"]
|
||||
|
||||
for session in active_sessions:
|
||||
if not session.get("profile_name") or not session.get("sensor_id"):
|
||||
continue
|
||||
|
||||
# Load profile
|
||||
profile_file = DATA_PATH / "profiles" / f"{session['profile_name']}.json"
|
||||
if not profile_file.exists():
|
||||
continue
|
||||
|
||||
profile = json.loads(profile_file.read_text())
|
||||
|
||||
# Calculate current target temp based on session start and profile
|
||||
start = datetime.fromisoformat(session["start_time"])
|
||||
days_elapsed = (datetime.utcnow() - start).days
|
||||
|
||||
target_temp = None
|
||||
for step in sorted(profile.get("steps", []), key=lambda s: s["day"]):
|
||||
if step["day"] <= days_elapsed:
|
||||
target_temp = step["target_temp"]
|
||||
|
||||
if target_temp is None:
|
||||
continue
|
||||
|
||||
# Find reading for this session's sensor
|
||||
for reading in readings:
|
||||
if reading.sensor_id == session["sensor_id"]:
|
||||
deviation = abs(reading.temperature - target_temp)
|
||||
if deviation > threshold:
|
||||
alert_type = "temp_high" if reading.temperature > target_temp else "temp_low"
|
||||
alert = Alert(
|
||||
session_id=session["id"],
|
||||
sensor_id=reading.sensor_id,
|
||||
alert_type=alert_type,
|
||||
message=f"Temperature {reading.temperature:.1f} is {deviation:.1f} degrees from target {target_temp:.1f}"
|
||||
)
|
||||
# Save alert
|
||||
alert_file = DATA_PATH / "alerts" / f"{alert.id}.json"
|
||||
save_json_file(alert_file, alert.model_dump())
|
||||
log.warning(f"Alert: {alert.message}")
|
||||
|
||||
|
||||
async def log_session_readings(readings: List[SensorReading]):
|
||||
"""Log readings to active sessions."""
|
||||
sessions_dir = DATA_PATH / "sessions"
|
||||
|
||||
for session_file in sessions_dir.glob("*.json"):
|
||||
try:
|
||||
session = json.loads(session_file.read_text())
|
||||
if session.get("status") != "active":
|
||||
continue
|
||||
|
||||
sensor_id = session.get("sensor_id")
|
||||
if not sensor_id:
|
||||
continue
|
||||
|
||||
for reading in readings:
|
||||
if reading.sensor_id == sensor_id:
|
||||
# Append reading to session
|
||||
if "readings" not in session:
|
||||
session["readings"] = []
|
||||
session["readings"].append(reading.model_dump())
|
||||
|
||||
# Keep only last 1000 readings to prevent file bloat
|
||||
if len(session["readings"]) > 1000:
|
||||
session["readings"] = session["readings"][-1000:]
|
||||
|
||||
save_json_file(session_file, session)
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Error logging to session {session_file.name}: {e}")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
"""Start background tasks."""
|
||||
ensure_data_dirs()
|
||||
asyncio.create_task(refresh_cache())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Public Endpoints (no auth required)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok", "module": "picobrew"}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def status():
|
||||
"""Get overall system status."""
|
||||
config = get_config()
|
||||
|
||||
# Count entities
|
||||
sensors = list_json_files(DATA_PATH / "sensors")
|
||||
sessions = list_json_files(DATA_PATH / "sessions")
|
||||
active_sessions = [s for s in sessions if s.get("status") == "active"]
|
||||
profiles = list_json_files(DATA_PATH / "profiles")
|
||||
recipes = list_json_files(DATA_PATH / "recipes")
|
||||
alerts = list_json_files(DATA_PATH / "alerts")
|
||||
unack_alerts = [a for a in alerts if not a.get("acknowledged", False)]
|
||||
|
||||
# Get latest readings from cache
|
||||
latest_readings = []
|
||||
if CACHE_FILE.exists():
|
||||
cache_data = load_json_file(CACHE_FILE)
|
||||
latest_readings = cache_data.get("sensors", [])
|
||||
|
||||
return {
|
||||
"running": True,
|
||||
"sensor_count": len(sensors),
|
||||
"active_sessions": len(active_sessions),
|
||||
"profile_count": len(profiles),
|
||||
"recipe_count": len(recipes),
|
||||
"unread_alerts": len(unack_alerts),
|
||||
"poll_interval": config.get("poll_interval", 30),
|
||||
"temp_unit": config.get("temp_unit", "C"),
|
||||
"last_reading": _cache.get("last_update"),
|
||||
"latest_readings": latest_readings[:5] # Last 5 readings
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Sensor Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/sensors")
|
||||
async def get_sensors(user=Depends(require_jwt)):
|
||||
"""Get list of all configured sensors."""
|
||||
sensors = list_json_files(DATA_PATH / "sensors")
|
||||
|
||||
# Add latest readings
|
||||
cache_data = load_json_file(CACHE_FILE) if CACHE_FILE.exists() else {}
|
||||
readings_map = {r["sensor_id"]: r for r in cache_data.get("sensors", [])}
|
||||
|
||||
for sensor in sensors:
|
||||
sensor["last_reading"] = readings_map.get(sensor["id"])
|
||||
|
||||
return {"sensors": sensors}
|
||||
|
||||
|
||||
@router.get("/sensors/detect")
|
||||
async def detect_sensors(user=Depends(require_jwt)):
|
||||
"""Detect available hardware sensors."""
|
||||
detected = []
|
||||
|
||||
# 1-wire (DS18B20)
|
||||
detected.extend(detect_1wire_sensors())
|
||||
|
||||
# I2C (BME280, etc.)
|
||||
detected.extend(detect_i2c_sensors())
|
||||
|
||||
# USB
|
||||
detected.extend(detect_usb_sensors())
|
||||
|
||||
return {"detected": detected}
|
||||
|
||||
|
||||
@router.post("/sensors")
|
||||
async def add_sensor(sensor: Sensor, user=Depends(require_jwt)):
|
||||
"""Add a new sensor."""
|
||||
ensure_data_dirs()
|
||||
sensor_file = DATA_PATH / "sensors" / f"{sensor.id}.json"
|
||||
|
||||
if sensor_file.exists():
|
||||
raise HTTPException(400, "Sensor with this ID already exists")
|
||||
|
||||
save_json_file(sensor_file, sensor.model_dump())
|
||||
log.info(f"Sensor {sensor.id} added by {user.get('sub', 'unknown')}")
|
||||
|
||||
return {"success": True, "sensor": sensor.model_dump()}
|
||||
|
||||
|
||||
@router.get("/sensors/{sensor_id}")
|
||||
async def get_sensor(sensor_id: str, user=Depends(require_jwt)):
|
||||
"""Get sensor details."""
|
||||
sensor_file = DATA_PATH / "sensors" / f"{sensor_id}.json"
|
||||
|
||||
if not sensor_file.exists():
|
||||
raise HTTPException(404, "Sensor not found")
|
||||
|
||||
sensor = load_json_file(sensor_file)
|
||||
|
||||
# Add latest reading
|
||||
cache_data = load_json_file(CACHE_FILE) if CACHE_FILE.exists() else {}
|
||||
for r in cache_data.get("sensors", []):
|
||||
if r["sensor_id"] == sensor_id:
|
||||
sensor["last_reading"] = r
|
||||
break
|
||||
|
||||
return sensor
|
||||
|
||||
|
||||
@router.delete("/sensors/{sensor_id}")
|
||||
async def delete_sensor(sensor_id: str, user=Depends(require_jwt)):
|
||||
"""Delete a sensor."""
|
||||
sensor_file = DATA_PATH / "sensors" / f"{sensor_id}.json"
|
||||
|
||||
if not sensor_file.exists():
|
||||
raise HTTPException(404, "Sensor not found")
|
||||
|
||||
sensor_file.unlink()
|
||||
log.info(f"Sensor {sensor_id} deleted by {user.get('sub', 'unknown')}")
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/sensors/{sensor_id}/reading")
|
||||
async def add_manual_reading(sensor_id: str, temperature: float, humidity: Optional[float] = None,
|
||||
gravity: Optional[float] = None, user=Depends(require_jwt)):
|
||||
"""Add a manual reading for a sensor."""
|
||||
sensor_file = DATA_PATH / "sensors" / f"{sensor_id}.json"
|
||||
|
||||
if not sensor_file.exists():
|
||||
raise HTTPException(404, "Sensor not found")
|
||||
|
||||
sensor = load_json_file(sensor_file)
|
||||
|
||||
reading = SensorReading(
|
||||
sensor_id=sensor_id,
|
||||
sensor_type=SensorType(sensor.get("type", "manual")),
|
||||
temperature=temperature,
|
||||
humidity=humidity,
|
||||
gravity=gravity
|
||||
)
|
||||
|
||||
sensor["last_reading"] = reading.model_dump()
|
||||
save_json_file(sensor_file, sensor)
|
||||
|
||||
return {"success": True, "reading": reading.model_dump()}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Profile Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/profiles")
|
||||
async def get_profiles(user=Depends(require_jwt)):
|
||||
"""Get all fermentation profiles."""
|
||||
profiles = list_json_files(DATA_PATH / "profiles")
|
||||
return {"profiles": profiles}
|
||||
|
||||
|
||||
@router.get("/profile/{name}")
|
||||
async def get_profile(name: str, user=Depends(require_jwt)):
|
||||
"""Get a specific fermentation profile."""
|
||||
profile_file = DATA_PATH / "profiles" / f"{name}.json"
|
||||
|
||||
if not profile_file.exists():
|
||||
raise HTTPException(404, "Profile not found")
|
||||
|
||||
return load_json_file(profile_file)
|
||||
|
||||
|
||||
@router.post("/profile/add")
|
||||
async def add_profile(req: AddProfileRequest, user=Depends(require_jwt)):
|
||||
"""Add a new fermentation profile."""
|
||||
ensure_data_dirs()
|
||||
|
||||
# Sanitize name for filename
|
||||
safe_name = "".join(c for c in req.name if c.isalnum() or c in "._- ")
|
||||
profile_file = DATA_PATH / "profiles" / f"{safe_name}.json"
|
||||
|
||||
profile = FermentationProfile(
|
||||
name=req.name,
|
||||
description=req.description or "",
|
||||
steps=req.steps
|
||||
)
|
||||
|
||||
save_json_file(profile_file, profile.model_dump())
|
||||
log.info(f"Profile {req.name} added by {user.get('sub', 'unknown')}")
|
||||
|
||||
return {"success": True, "profile": profile.model_dump()}
|
||||
|
||||
|
||||
@router.delete("/profile/{name}")
|
||||
async def delete_profile(name: str, user=Depends(require_jwt)):
|
||||
"""Delete a fermentation profile."""
|
||||
profile_file = DATA_PATH / "profiles" / f"{name}.json"
|
||||
|
||||
if not profile_file.exists():
|
||||
raise HTTPException(404, "Profile not found")
|
||||
|
||||
profile_file.unlink()
|
||||
log.info(f"Profile {name} deleted by {user.get('sub', 'unknown')}")
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Session Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/sessions")
|
||||
async def get_sessions(status: Optional[str] = None, user=Depends(require_jwt)):
|
||||
"""Get all brew sessions."""
|
||||
sessions = list_json_files(DATA_PATH / "sessions")
|
||||
|
||||
if status:
|
||||
sessions = [s for s in sessions if s.get("status") == status]
|
||||
|
||||
# Sort by start time, newest first
|
||||
sessions.sort(key=lambda s: s.get("start_time", ""), reverse=True)
|
||||
|
||||
return {"sessions": sessions}
|
||||
|
||||
|
||||
@router.get("/session/{session_id}")
|
||||
async def get_session(session_id: str, user=Depends(require_jwt)):
|
||||
"""Get a specific brew session with all readings."""
|
||||
session_file = DATA_PATH / "sessions" / f"{session_id}.json"
|
||||
|
||||
if not session_file.exists():
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
return load_json_file(session_file)
|
||||
|
||||
|
||||
@router.post("/session/start")
|
||||
async def start_session(req: StartSessionRequest, user=Depends(require_jwt)):
|
||||
"""Start a new brew session."""
|
||||
ensure_data_dirs()
|
||||
|
||||
session = BrewSession(
|
||||
name=req.name,
|
||||
recipe_name=req.recipe_name,
|
||||
profile_name=req.profile_name,
|
||||
sensor_id=req.sensor_id,
|
||||
og=req.og,
|
||||
notes=req.notes or ""
|
||||
)
|
||||
|
||||
session_file = DATA_PATH / "sessions" / f"{session.id}.json"
|
||||
save_json_file(session_file, session.model_dump())
|
||||
|
||||
log.info(f"Session {session.id} started by {user.get('sub', 'unknown')}")
|
||||
|
||||
return {"success": True, "session": session.model_dump()}
|
||||
|
||||
|
||||
@router.post("/session/{session_id}/end")
|
||||
async def end_session(session_id: str, fg: Optional[float] = None,
|
||||
notes: Optional[str] = None, user=Depends(require_jwt)):
|
||||
"""End a brew session."""
|
||||
session_file = DATA_PATH / "sessions" / f"{session_id}.json"
|
||||
|
||||
if not session_file.exists():
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
session = load_json_file(session_file)
|
||||
session["status"] = "completed"
|
||||
session["end_time"] = datetime.utcnow().isoformat()
|
||||
|
||||
if fg is not None:
|
||||
session["fg"] = fg
|
||||
if notes:
|
||||
session["notes"] = (session.get("notes", "") + "\n" + notes).strip()
|
||||
|
||||
save_json_file(session_file, session)
|
||||
log.info(f"Session {session_id} ended by {user.get('sub', 'unknown')}")
|
||||
|
||||
return {"success": True, "session": session}
|
||||
|
||||
|
||||
@router.delete("/session/{session_id}")
|
||||
async def delete_session(session_id: str, user=Depends(require_jwt)):
|
||||
"""Delete a brew session."""
|
||||
session_file = DATA_PATH / "sessions" / f"{session_id}.json"
|
||||
|
||||
if not session_file.exists():
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
session_file.unlink()
|
||||
log.info(f"Session {session_id} deleted by {user.get('sub', 'unknown')}")
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/session/{session_id}/readings")
|
||||
async def get_session_readings(session_id: str,
|
||||
limit: int = Query(100, le=1000),
|
||||
user=Depends(require_jwt)):
|
||||
"""Get readings for a session (for charting)."""
|
||||
session_file = DATA_PATH / "sessions" / f"{session_id}.json"
|
||||
|
||||
if not session_file.exists():
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
session = load_json_file(session_file)
|
||||
readings = session.get("readings", [])
|
||||
|
||||
# Return last N readings
|
||||
return {"readings": readings[-limit:]}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Recipe Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/recipes")
|
||||
async def get_recipes(user=Depends(require_jwt)):
|
||||
"""Get all recipes."""
|
||||
recipes = list_json_files(DATA_PATH / "recipes")
|
||||
return {"recipes": recipes}
|
||||
|
||||
|
||||
@router.get("/recipe/{name}")
|
||||
async def get_recipe(name: str, user=Depends(require_jwt)):
|
||||
"""Get a specific recipe."""
|
||||
# Try exact match first, then fuzzy
|
||||
for recipe_file in (DATA_PATH / "recipes").glob("*.json"):
|
||||
recipe = load_json_file(recipe_file)
|
||||
if recipe.get("name") == name:
|
||||
return recipe
|
||||
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
|
||||
@router.post("/recipe/add")
|
||||
async def add_recipe(req: AddRecipeRequest, user=Depends(require_jwt)):
|
||||
"""Add a new recipe."""
|
||||
ensure_data_dirs()
|
||||
|
||||
# Sanitize name for filename
|
||||
safe_name = "".join(c for c in req.name if c.isalnum() or c in "._- ")
|
||||
recipe_file = DATA_PATH / "recipes" / f"{safe_name}.json"
|
||||
|
||||
recipe = Recipe(
|
||||
name=req.name,
|
||||
style=req.style,
|
||||
og_target=req.og_target,
|
||||
fg_target=req.fg_target,
|
||||
fermentation_profile=req.fermentation_profile,
|
||||
ingredients=req.ingredients,
|
||||
instructions=req.instructions
|
||||
)
|
||||
|
||||
save_json_file(recipe_file, recipe.model_dump())
|
||||
log.info(f"Recipe {req.name} added by {user.get('sub', 'unknown')}")
|
||||
|
||||
return {"success": True, "recipe": recipe.model_dump()}
|
||||
|
||||
|
||||
@router.delete("/recipe/{name}")
|
||||
async def delete_recipe(name: str, user=Depends(require_jwt)):
|
||||
"""Delete a recipe."""
|
||||
for recipe_file in (DATA_PATH / "recipes").glob("*.json"):
|
||||
recipe = load_json_file(recipe_file)
|
||||
if recipe.get("name") == name:
|
||||
recipe_file.unlink()
|
||||
log.info(f"Recipe {name} deleted by {user.get('sub', 'unknown')}")
|
||||
return {"success": True}
|
||||
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Alert Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/alerts")
|
||||
async def get_alerts(acknowledged: Optional[bool] = None,
|
||||
limit: int = Query(50, le=200),
|
||||
user=Depends(require_jwt)):
|
||||
"""Get alerts."""
|
||||
alerts = list_json_files(DATA_PATH / "alerts")
|
||||
|
||||
if acknowledged is not None:
|
||||
alerts = [a for a in alerts if a.get("acknowledged", False) == acknowledged]
|
||||
|
||||
# Sort by timestamp, newest first
|
||||
alerts.sort(key=lambda a: a.get("timestamp", ""), reverse=True)
|
||||
|
||||
return {"alerts": alerts[:limit]}
|
||||
|
||||
|
||||
@router.post("/alerts/{alert_id}/acknowledge")
|
||||
async def acknowledge_alert(alert_id: str, user=Depends(require_jwt)):
|
||||
"""Acknowledge an alert."""
|
||||
alert_file = DATA_PATH / "alerts" / f"{alert_id}.json"
|
||||
|
||||
if not alert_file.exists():
|
||||
raise HTTPException(404, "Alert not found")
|
||||
|
||||
alert = load_json_file(alert_file)
|
||||
alert["acknowledged"] = True
|
||||
save_json_file(alert_file, alert)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/alerts/{alert_id}")
|
||||
async def delete_alert(alert_id: str, user=Depends(require_jwt)):
|
||||
"""Delete an alert."""
|
||||
alert_file = DATA_PATH / "alerts" / f"{alert_id}.json"
|
||||
|
||||
if not alert_file.exists():
|
||||
raise HTTPException(404, "Alert not found")
|
||||
|
||||
alert_file.unlink()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/alerts/clear")
|
||||
async def clear_alerts(acknowledged_only: bool = True, user=Depends(require_jwt)):
|
||||
"""Clear alerts."""
|
||||
alerts_dir = DATA_PATH / "alerts"
|
||||
count = 0
|
||||
|
||||
for alert_file in alerts_dir.glob("*.json"):
|
||||
try:
|
||||
alert = load_json_file(alert_file)
|
||||
if acknowledged_only and not alert.get("acknowledged", False):
|
||||
continue
|
||||
alert_file.unlink()
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log.info(f"Cleared {count} alerts by {user.get('sub', 'unknown')}")
|
||||
return {"success": True, "cleared": count}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Configuration Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/config")
|
||||
async def get_picobrew_config(user=Depends(require_jwt)):
|
||||
"""Get picobrew configuration."""
|
||||
return get_config()
|
||||
|
||||
|
||||
@router.post("/config")
|
||||
async def set_picobrew_config(config: PicobrewConfig, user=Depends(require_jwt)):
|
||||
"""Update picobrew configuration."""
|
||||
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
content = f"""# PicoBrew Fermentation Controller configuration
|
||||
poll_interval = {config.poll_interval}
|
||||
temp_unit = "{config.temp_unit.value}"
|
||||
alert_threshold = {config.alert_threshold}
|
||||
"""
|
||||
CONFIG_FILE.write_text(content)
|
||||
log.info(f"Config updated by {user.get('sub', 'unknown')}")
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Built-in Profiles (Common fermentation schedules)
|
||||
# ============================================================================
|
||||
|
||||
BUILTIN_PROFILES = [
|
||||
{
|
||||
"name": "Ale Standard",
|
||||
"description": "Standard ale fermentation at 18-20C",
|
||||
"steps": [
|
||||
{"day": 0, "target_temp": 18.0, "ramp_time": 0},
|
||||
{"day": 3, "target_temp": 19.0, "ramp_time": 12},
|
||||
{"day": 7, "target_temp": 20.0, "ramp_time": 24},
|
||||
{"day": 14, "target_temp": 4.0, "ramp_time": 48}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Lager Classic",
|
||||
"description": "Traditional lager with cold conditioning",
|
||||
"steps": [
|
||||
{"day": 0, "target_temp": 10.0, "ramp_time": 0},
|
||||
{"day": 7, "target_temp": 12.0, "ramp_time": 24},
|
||||
{"day": 14, "target_temp": 2.0, "ramp_time": 48},
|
||||
{"day": 28, "target_temp": 0.0, "ramp_time": 24}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Belgian Saison",
|
||||
"description": "Warm fermentation for Belgian Saison",
|
||||
"steps": [
|
||||
{"day": 0, "target_temp": 20.0, "ramp_time": 0},
|
||||
{"day": 2, "target_temp": 24.0, "ramp_time": 24},
|
||||
{"day": 5, "target_temp": 28.0, "ramp_time": 24},
|
||||
{"day": 10, "target_temp": 30.0, "ramp_time": 12}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Cold Crash",
|
||||
"description": "Quick cold crash for clearing",
|
||||
"steps": [
|
||||
{"day": 0, "target_temp": 18.0, "ramp_time": 0},
|
||||
{"day": 1, "target_temp": 4.0, "ramp_time": 12},
|
||||
{"day": 3, "target_temp": 0.0, "ramp_time": 6}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@router.get("/profiles/builtin")
|
||||
async def get_builtin_profiles(user=Depends(require_jwt)):
|
||||
"""Get list of built-in fermentation profiles."""
|
||||
return {"profiles": BUILTIN_PROFILES}
|
||||
|
||||
|
||||
@router.post("/profiles/import/{name}")
|
||||
async def import_builtin_profile(name: str, user=Depends(require_jwt)):
|
||||
"""Import a built-in profile to user profiles."""
|
||||
for profile in BUILTIN_PROFILES:
|
||||
if profile["name"] == name:
|
||||
ensure_data_dirs()
|
||||
safe_name = "".join(c for c in name if c.isalnum() or c in "._- ")
|
||||
profile_file = DATA_PATH / "profiles" / f"{safe_name}.json"
|
||||
|
||||
profile_data = FermentationProfile(
|
||||
name=profile["name"],
|
||||
description=profile["description"],
|
||||
steps=[ProfileStep(**s) for s in profile["steps"]]
|
||||
)
|
||||
|
||||
save_json_file(profile_file, profile_data.model_dump())
|
||||
log.info(f"Imported builtin profile {name} by {user.get('sub', 'unknown')}")
|
||||
|
||||
return {"success": True, "profile": profile_data.model_dump()}
|
||||
|
||||
raise HTTPException(404, "Built-in profile not found")
|
||||
|
||||
|
||||
app.include_router(router)
|
||||
32
packages/secubox-picobrew/tests/test_api_management.py
Normal file
32
packages/secubox-picobrew/tests/test_api_management.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
from fastapi.testclient import TestClient
|
||||
from api.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_status_reflects_ctl_output():
|
||||
"""L'API ne devine rien : elle relaie le verdict du ctl."""
|
||||
payload = json.dumps({"installed": True, "running": False, "ip": "10.100.0.140",
|
||||
"pinned_sha": "0123456789abcdef0123456789abcdef01234567",
|
||||
"session_active": False})
|
||||
with patch("api.main._ctl", return_value=(0, payload)):
|
||||
r = client.get("/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["installed"] is True and r.json()["running"] is False
|
||||
assert r.json()["ip"] == "10.100.0.140"
|
||||
|
||||
def test_status_degrades_cleanly_when_ctl_fails():
|
||||
"""Un ctl indisponible ne doit pas 500 le panel : état inconnu, pas de crash."""
|
||||
with patch("api.main._ctl", return_value=(1, "")):
|
||||
r = client.get("/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["installed"] is False
|
||||
assert r.json()["error"]
|
||||
|
||||
def test_start_delegates_to_ctl_and_never_runs_privileged_itself():
|
||||
with patch("api.main._ctl", return_value=(0, "")) as m:
|
||||
r = client.post("/start")
|
||||
assert r.status_code == 200
|
||||
assert m.call_args[0][0] == ["start"]
|
||||
Loading…
Reference in New Issue
Block a user