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:
serversdwn
2025-12-23 19:24:14 +00:00
parent 5c4722267f
commit dac731f912
15 changed files with 886 additions and 53 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,12 +1,27 @@
import os
from fastapi import FastAPI
import logging
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.database import Base, engine
from app import routers
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler("data/slmm.log"),
],
)
logger = logging.getLogger(__name__)
# Ensure database tables exist for the addon
Base.metadata.create_all(bind=engine)
logger.info("Database tables initialized")
app = FastAPI(
title="SLMM NL43 Addon",
@@ -14,20 +29,85 @@ app = FastAPI(
version="0.1.0",
)
# CORS configuration - use environment variable for allowed origins
# Default to "*" for development, but should be restricted in production
allowed_origins = os.getenv("CORS_ORIGINS", "*").split(",")
logger.info(f"CORS allowed origins: {allowed_origins}")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
templates = Jinja2Templates(directory="templates")
app.include_router(routers.router)
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/health")
def health():
return {"status": "ok"}
async def health():
"""Basic health check endpoint."""
return {"status": "ok", "service": "slmm-nl43-addon"}
@app.get("/health/devices")
async def health_devices():
"""Enhanced health check that tests device connectivity."""
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.services import NL43Client
from app.models import NL43Config
db: Session = SessionLocal()
device_status = []
try:
configs = db.query(NL43Config).filter_by(tcp_enabled=True).all()
for cfg in configs:
client = NL43Client(cfg.host, cfg.tcp_port, timeout=2.0)
status = {
"unit_id": cfg.unit_id,
"host": cfg.host,
"port": cfg.tcp_port,
"reachable": False,
"error": None,
}
try:
# Try to connect (don't send command to avoid rate limiting issues)
import asyncio
reader, writer = await asyncio.wait_for(
asyncio.open_connection(cfg.host, cfg.tcp_port), timeout=2.0
)
writer.close()
await writer.wait_closed()
status["reachable"] = True
except Exception as e:
status["error"] = str(type(e).__name__)
logger.warning(f"Device {cfg.unit_id} health check failed: {e}")
device_status.append(status)
finally:
db.close()
all_reachable = all(d["reachable"] for d in device_status) if device_status else True
return {
"status": "ok" if all_reachable else "degraded",
"devices": device_status,
"total_devices": len(device_status),
"reachable_devices": sum(1 for d in device_status if d["reachable"]),
}
if __name__ == "__main__":

View File

@@ -1,5 +1,4 @@
from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text, func
from app.database import Base
@@ -11,6 +10,7 @@ class NL43Config(Base):
__tablename__ = "nl43_config"
unit_id = Column(String, primary_key=True, index=True)
host = Column(String, default="127.0.0.1")
tcp_port = Column(Integer, default=80) # NL43 TCP control port (via RX55)
tcp_enabled = Column(Boolean, default=True)
ftp_enabled = Column(Boolean, default=False)
@@ -25,7 +25,7 @@ class NL43Status(Base):
__tablename__ = "nl43_status"
unit_id = Column(String, primary_key=True, index=True)
last_seen = Column(DateTime, default=datetime.utcnow)
last_seen = Column(DateTime, default=func.now())
measurement_state = Column(String, default="unknown") # Measure/Stop
lp = Column(String, nullable=True)
leq = Column(String, nullable=True)

View File

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

View File

@@ -1,16 +1,23 @@
"""
Placeholder for NL43 TCP connector.
Implement TCP session management, command serialization, and DOD/DRD parsing here,
then call persist_snapshot to store the latest values.
NL43 TCP connector and snapshot persistence.
Implements simple per-request TCP calls to avoid long-lived socket complexity.
Extend to pooled connections/DRD streaming later.
"""
import asyncio
import contextlib
import logging
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from app.database import get_db_session
from app.models import NL43Status
logger = logging.getLogger(__name__)
@dataclass
class NL43Snapshot:
@@ -28,9 +35,8 @@ class NL43Snapshot:
raw_payload: Optional[str] = None
def persist_snapshot(s: NL43Snapshot):
def persist_snapshot(s: NL43Snapshot, db: Session):
"""Persist the latest snapshot for API/dashboard use."""
db = get_db_session()
try:
row = db.query(NL43Status).filter_by(unit_id=s.unit_id).first()
if not row:
@@ -51,5 +57,113 @@ def persist_snapshot(s: NL43Snapshot):
row.raw_payload = s.raw_payload
db.commit()
finally:
db.close()
except Exception as e:
db.rollback()
logger.error(f"Failed to persist snapshot for unit {s.unit_id}: {e}")
raise
# Rate limiting: NL43 requires ≥1 second between commands
_last_command_time = {}
_rate_limit_lock = asyncio.Lock()
class NL43Client:
def __init__(self, host: str, port: int, timeout: float = 5.0):
self.host = host
self.port = port
self.timeout = timeout
self.device_key = f"{host}:{port}"
async def _enforce_rate_limit(self):
"""Ensure ≥1 second between commands to the same device."""
async with _rate_limit_lock:
last_time = _last_command_time.get(self.device_key, 0)
elapsed = time.time() - last_time
if elapsed < 1.0:
wait_time = 1.0 - elapsed
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s for {self.device_key}")
await asyncio.sleep(wait_time)
_last_command_time[self.device_key] = time.time()
async def _send_command(self, cmd: str) -> str:
"""Send ASCII command to NL43 device via TCP."""
await self._enforce_rate_limit()
logger.info(f"Sending command to {self.device_key}: {cmd.strip()}")
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(self.host, self.port), timeout=self.timeout
)
except asyncio.TimeoutError:
logger.error(f"Connection timeout to {self.device_key}")
raise ConnectionError(f"Failed to connect to device at {self.host}:{self.port}")
except Exception as e:
logger.error(f"Connection failed to {self.device_key}: {e}")
raise ConnectionError(f"Failed to connect to device: {str(e)}")
try:
writer.write(cmd.encode("ascii"))
await writer.drain()
data = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=self.timeout)
response = data.decode(errors="ignore").strip()
logger.debug(f"Received response from {self.device_key}: {response}")
return response
except asyncio.TimeoutError:
logger.error(f"Response timeout from {self.device_key}")
raise TimeoutError(f"Device did not respond within {self.timeout}s")
except Exception as e:
logger.error(f"Communication error with {self.device_key}: {e}")
raise
finally:
writer.close()
with contextlib.suppress(Exception):
await writer.wait_closed()
async def request_dod(self) -> NL43Snapshot:
"""Request DOD (Data Output Display) snapshot from device."""
resp = await self._send_command("DOD?\r\n")
# Validate response format
if not resp:
logger.warning(f"Empty response from DOD command on {self.device_key}")
raise ValueError("Device returned empty response to DOD? command")
# Remove leading $ prompt if present
if resp.startswith("$"):
resp = resp[1:].strip()
parts = [p.strip() for p in resp.split(",") if p.strip() != ""]
# DOD should return at least some data points
if len(parts) < 2:
logger.error(f"Malformed DOD response from {self.device_key}: {resp}")
raise ValueError(f"Malformed DOD response: expected comma-separated values, got: {resp}")
logger.info(f"Parsed {len(parts)} data points from DOD response")
snap = NL43Snapshot(unit_id="", raw_payload=resp, measurement_state="Measure")
# Parse known positions (based on NL43 communication guide)
try:
if len(parts) >= 1:
snap.lp = parts[0]
if len(parts) >= 2:
snap.leq = parts[1]
if len(parts) >= 4:
snap.lmax = parts[3]
if len(parts) >= 5:
snap.lmin = parts[4]
if len(parts) >= 11:
snap.lpeak = parts[10]
except (IndexError, ValueError) as e:
logger.warning(f"Error parsing DOD data points: {e}")
return snap
async def start(self):
await self._send_command("$Measure, Start\r\n")
async def stop(self):
await self._send_command("$Measure, Stop\r\n")