Add communication guide and project improvements documentation; enhance main app with logging, CORS configuration, and health check endpoints; implement input validation and error handling in routers; improve services with rate limiting and snapshot persistence; update models for SQLAlchemy best practices; create index.html for frontend interaction.
This commit is contained in:
209
app/routers.py
209
app/routers.py
@@ -1,20 +1,52 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
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)):
|
||||
@@ -22,11 +54,15 @@ def get_config(unit_id: str, db: Session = Depends(get_db)):
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="NL43 config not found")
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"tcp_port": cfg.tcp_port,
|
||||
"tcp_enabled": cfg.tcp_enabled,
|
||||
"ftp_enabled": cfg.ftp_enabled,
|
||||
"web_enabled": cfg.web_enabled,
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +73,8 @@ def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depends(ge
|
||||
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:
|
||||
@@ -48,12 +86,17 @@ def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depends(ge
|
||||
|
||||
db.commit()
|
||||
db.refresh(cfg)
|
||||
logger.info(f"Updated config for unit {unit_id}")
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"tcp_port": cfg.tcp_port,
|
||||
"tcp_enabled": cfg.tcp_enabled,
|
||||
"ftp_enabled": cfg.ftp_enabled,
|
||||
"web_enabled": cfg.web_enabled,
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -63,19 +106,22 @@ def get_status(unit_id: str, db: Session = Depends(get_db)):
|
||||
if not status:
|
||||
raise HTTPException(status_code=404, detail="No NL43 status recorded")
|
||||
return {
|
||||
"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,
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -101,24 +147,111 @@ def upsert_status(unit_id: str, payload: StatusPayload, db: Session = Depends(ge
|
||||
db.add(status)
|
||||
|
||||
status.last_seen = datetime.utcnow()
|
||||
for field, value in payload.dict().items():
|
||||
for field, value in payload.model_dump().items():
|
||||
if value is not None:
|
||||
setattr(status, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(status)
|
||||
return {
|
||||
"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,
|
||||
"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")
|
||||
|
||||
Reference in New Issue
Block a user