from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from datetime import datetime from pydantic import BaseModel, field_validator import logging import ipaddress from app.database import get_db from app.models import NL43Config, NL43Status from app.services import NL43Client, persist_snapshot logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/nl43", tags=["nl43"]) class ConfigPayload(BaseModel): host: str | None = None tcp_port: int | None = None tcp_enabled: bool | None = None ftp_enabled: bool | None = None web_enabled: bool | None = None @field_validator("host") @classmethod def validate_host(cls, v): if v is None: return v # Try to parse as IP address or hostname try: ipaddress.ip_address(v) except ValueError: # Not an IP, check if it's a valid hostname format if not v or len(v) > 253: raise ValueError("Invalid hostname length") # Allow hostnames (basic validation) if not all(c.isalnum() or c in ".-" for c in v): raise ValueError("Host must be a valid IP address or hostname") return v @field_validator("tcp_port") @classmethod def validate_port(cls, v): if v is None: return v if not (1 <= v <= 65535): raise ValueError("Port must be between 1 and 65535") return v @router.get("/{unit_id}/config") def get_config(unit_id: str, db: Session = Depends(get_db)): cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() if not cfg: raise HTTPException(status_code=404, detail="NL43 config not found") return { "status": "ok", "data": { "unit_id": unit_id, "host": cfg.host, "tcp_port": cfg.tcp_port, "tcp_enabled": cfg.tcp_enabled, "ftp_enabled": cfg.ftp_enabled, "web_enabled": cfg.web_enabled, }, } @router.put("/{unit_id}/config") def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depends(get_db)): cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() if not cfg: cfg = NL43Config(unit_id=unit_id) db.add(cfg) if payload.host is not None: cfg.host = payload.host if payload.tcp_port is not None: cfg.tcp_port = payload.tcp_port if payload.tcp_enabled is not None: cfg.tcp_enabled = payload.tcp_enabled if payload.ftp_enabled is not None: cfg.ftp_enabled = payload.ftp_enabled if payload.web_enabled is not None: cfg.web_enabled = payload.web_enabled db.commit() db.refresh(cfg) logger.info(f"Updated config for unit {unit_id}") return { "status": "ok", "data": { "unit_id": unit_id, "host": cfg.host, "tcp_port": cfg.tcp_port, "tcp_enabled": cfg.tcp_enabled, "ftp_enabled": cfg.ftp_enabled, "web_enabled": cfg.web_enabled, }, } @router.get("/{unit_id}/status") def get_status(unit_id: str, db: Session = Depends(get_db)): status = db.query(NL43Status).filter_by(unit_id=unit_id).first() if not status: raise HTTPException(status_code=404, detail="No NL43 status recorded") return { "status": "ok", "data": { "unit_id": unit_id, "last_seen": status.last_seen.isoformat() if status.last_seen else None, "measurement_state": status.measurement_state, "lp": status.lp, "leq": status.leq, "lmax": status.lmax, "lmin": status.lmin, "lpeak": status.lpeak, "battery_level": status.battery_level, "power_source": status.power_source, "sd_remaining_mb": status.sd_remaining_mb, "sd_free_ratio": status.sd_free_ratio, "raw_payload": status.raw_payload, }, } class StatusPayload(BaseModel): measurement_state: str | None = None lp: str | None = None leq: str | None = None lmax: str | None = None lmin: str | None = None lpeak: str | None = None battery_level: str | None = None power_source: str | None = None sd_remaining_mb: str | None = None sd_free_ratio: str | None = None raw_payload: str | None = None @router.post("/{unit_id}/status") def upsert_status(unit_id: str, payload: StatusPayload, db: Session = Depends(get_db)): status = db.query(NL43Status).filter_by(unit_id=unit_id).first() if not status: status = NL43Status(unit_id=unit_id) db.add(status) status.last_seen = datetime.utcnow() for field, value in payload.model_dump().items(): if value is not None: setattr(status, field, value) db.commit() db.refresh(status) return { "status": "ok", "data": { "unit_id": unit_id, "last_seen": status.last_seen.isoformat(), "measurement_state": status.measurement_state, "lp": status.lp, "leq": status.leq, "lmax": status.lmax, "lmin": status.lmin, "lpeak": status.lpeak, "battery_level": status.battery_level, "power_source": status.power_source, "sd_remaining_mb": status.sd_remaining_mb, "sd_free_ratio": status.sd_free_ratio, "raw_payload": status.raw_payload, }, } @router.post("/{unit_id}/start") async def start_measurement(unit_id: str, db: Session = Depends(get_db)): cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() if not cfg: raise HTTPException(status_code=404, detail="NL43 config not found") if not cfg.tcp_enabled: raise HTTPException(status_code=403, detail="TCP communication is disabled for this device") client = NL43Client(cfg.host, cfg.tcp_port) try: await client.start() logger.info(f"Started measurement on unit {unit_id}") except ConnectionError as e: logger.error(f"Failed to start measurement on {unit_id}: {e}") raise HTTPException(status_code=502, detail="Failed to communicate with device") except TimeoutError: logger.error(f"Timeout starting measurement on {unit_id}") raise HTTPException(status_code=504, detail="Device communication timeout") except Exception as e: logger.error(f"Unexpected error starting measurement on {unit_id}: {e}") raise HTTPException(status_code=500, detail="Internal server error") return {"status": "ok", "message": "Measurement started"} @router.post("/{unit_id}/stop") async def stop_measurement(unit_id: str, db: Session = Depends(get_db)): cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() if not cfg: raise HTTPException(status_code=404, detail="NL43 config not found") if not cfg.tcp_enabled: raise HTTPException(status_code=403, detail="TCP communication is disabled for this device") client = NL43Client(cfg.host, cfg.tcp_port) try: await client.stop() logger.info(f"Stopped measurement on unit {unit_id}") except ConnectionError as e: logger.error(f"Failed to stop measurement on {unit_id}: {e}") raise HTTPException(status_code=502, detail="Failed to communicate with device") except TimeoutError: logger.error(f"Timeout stopping measurement on {unit_id}") raise HTTPException(status_code=504, detail="Device communication timeout") except Exception as e: logger.error(f"Unexpected error stopping measurement on {unit_id}: {e}") raise HTTPException(status_code=500, detail="Internal server error") return {"status": "ok", "message": "Measurement stopped"} @router.get("/{unit_id}/live") async def live_status(unit_id: str, db: Session = Depends(get_db)): cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() if not cfg: raise HTTPException(status_code=404, detail="NL43 config not found") if not cfg.tcp_enabled: raise HTTPException(status_code=403, detail="TCP communication is disabled for this device") client = NL43Client(cfg.host, cfg.tcp_port) try: snap = await client.request_dod() snap.unit_id = unit_id # Persist snapshot with database session persist_snapshot(snap, db) logger.info(f"Retrieved live status for unit {unit_id}") return {"status": "ok", "data": snap.__dict__} except ConnectionError as e: logger.error(f"Failed to get live status for {unit_id}: {e}") raise HTTPException(status_code=502, detail="Failed to communicate with device") except TimeoutError: logger.error(f"Timeout getting live status for {unit_id}") raise HTTPException(status_code=504, detail="Device communication timeout") except ValueError as e: logger.error(f"Invalid response from device {unit_id}: {e}") raise HTTPException(status_code=502, detail="Device returned invalid data") except Exception as e: logger.error(f"Unexpected error getting live status for {unit_id}: {e}") raise HTTPException(status_code=500, detail="Internal server error")