diff --git a/Dockerfile.slm b/Dockerfile.slm new file mode 100644 index 0000000..0b6922d --- /dev/null +++ b/Dockerfile.slm @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl && \ + rm -rf /var/lib/apt/lists/* + +# Copy requirements and install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app /app/app + +# Expose port +EXPOSE 8100 + +# Run the SLM application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8100"] diff --git a/app/main.py b/app/main.py index fa18d6c..20daf35 100644 --- a/app/main.py +++ b/app/main.py @@ -38,11 +38,7 @@ from app.seismo.routers import ( from app.seismo import routes as seismo_legacy_routes # Import feature module routers (SLM) -from app.slm.routers import ( - nl43_proxy as slm_nl43_proxy, - dashboard as slm_dashboard, - ui as slm_ui, -) +from app.slm.routers import router as slm_router # Import API aggregation layer (placeholder for now) from app.api import dashboard as api_dashboard @@ -107,9 +103,7 @@ app.include_router(seismo_settings.router) app.include_router(seismo_legacy_routes.router) # SLM Feature Module APIs -app.include_router(slm_nl43_proxy.router) -app.include_router(slm_dashboard.router) -app.include_router(slm_ui.router) +app.include_router(slm_router) # API Aggregation Layer (future cross-feature endpoints) # app.include_router(api_dashboard.router) # TODO: Implement aggregation diff --git a/app/slm/__init__.py b/app/slm/__init__.py index e69de29..147ff99 100644 --- a/app/slm/__init__.py +++ b/app/slm/__init__.py @@ -0,0 +1 @@ +# SLMM addon package for NL43 integration. diff --git a/app/slm/database.py b/app/slm/database.py index 5627386..91c5630 100644 --- a/app/slm/database.py +++ b/app/slm/database.py @@ -1,29 +1,20 @@ -""" -Sound Level Meter feature module database connection -""" from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker import os -# Ensure data directory exists +# Ensure data directory exists for the SLMM addon os.makedirs("data", exist_ok=True) -# For now, we'll use the shared database (seismo_fleet.db) until we migrate -# TODO: Migrate to slm.db -SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db" - -engine = create_engine( - SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} -) +SQLALCHEMY_DATABASE_URL = "sqlite:///./data/slmm.db" +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - Base = declarative_base() def get_db(): - """Dependency for database sessions""" + """Dependency for database sessions.""" db = SessionLocal() try: yield db @@ -32,5 +23,5 @@ def get_db(): def get_db_session(): - """Get a database session directly (not as a dependency)""" + """Get a database session directly (not as a dependency).""" return SessionLocal() diff --git a/app/slm/main.py b/app/slm/main.py new file mode 100644 index 0000000..035136d --- /dev/null +++ b/app/slm/main.py @@ -0,0 +1,116 @@ +import os +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.slm.database import Base, engine +from app.slm 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", + description="Standalone module for NL43 configuration and status APIs", + 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=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") +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.slm.database import SessionLocal + from app.slm.services import NL43Client + from app.slm.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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + 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__": + import uvicorn + + uvicorn.run("app.main:app", host="0.0.0.0", port=int(os.getenv("PORT", "8100")), reload=True) diff --git a/app/slm/models.py b/app/slm/models.py index 4454c14..c84ac53 100644 --- a/app/slm/models.py +++ b/app/slm/models.py @@ -1,10 +1,43 @@ -""" -Sound Level Meter feature module models -""" -from sqlalchemy import Column, String, Integer, Boolean, DateTime, Float -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text, func +from app.slm.database import Base -Base = declarative_base() -# TODO: When we split databases, SLM-specific models will go here -# For now, SLM data is in the shared seismo_fleet.db database +class NL43Config(Base): + """ + NL43 connection/config metadata for the standalone SLMM addon. + """ + + __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) + ftp_username = Column(String, nullable=True) # FTP login username + ftp_password = Column(String, nullable=True) # FTP login password + web_enabled = Column(Boolean, default=False) + + +class NL43Status(Base): + """ + Latest NL43 status snapshot for quick dashboard/API access. + """ + + __tablename__ = "nl43_status" + + unit_id = Column(String, primary_key=True, index=True) + last_seen = Column(DateTime, default=func.now()) + measurement_state = Column(String, default="unknown") # Measure/Stop + measurement_start_time = Column(DateTime, nullable=True) # When measurement started (UTC) + counter = Column(String, nullable=True) # d0: Measurement interval counter (1-600) + lp = Column(String, nullable=True) # Instantaneous sound pressure level + leq = Column(String, nullable=True) # Equivalent continuous sound level + lmax = Column(String, nullable=True) # Maximum level + lmin = Column(String, nullable=True) # Minimum level + lpeak = Column(String, nullable=True) # Peak level + battery_level = Column(String, nullable=True) + power_source = Column(String, nullable=True) + sd_remaining_mb = Column(String, nullable=True) + sd_free_ratio = Column(String, nullable=True) + raw_payload = Column(Text, nullable=True) diff --git a/app/slm/routers.py b/app/slm/routers.py new file mode 100644 index 0000000..12d63d9 --- /dev/null +++ b/app/slm/routers.py @@ -0,0 +1,1333 @@ +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from datetime import datetime +from pydantic import BaseModel, field_validator +import logging +import ipaddress +import json +import os +import asyncio + +from app.slm.database import get_db +from app.slm.models import NL43Config, NL43Status +from app.slm.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 + ftp_username: str | None = None + ftp_password: str | 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.ftp_username is not None: + cfg.ftp_username = payload.ftp_username + if payload.ftp_password is not None: + cfg.ftp_password = payload.ftp_password + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.start() + logger.info(f"Started measurement on unit {unit_id}") + + # Query device status to trigger state transition detection + # Retry a few times since device may take a moment to change state + for attempt in range(3): + logger.info(f"Querying device status (attempt {attempt + 1}/3)") + await asyncio.sleep(0.5) # Wait 500ms between attempts + snap = await client.request_dod() + snap.unit_id = unit_id + persist_snapshot(snap, db) + + # Refresh the session to see committed changes + db.expire_all() + status = db.query(NL43Status).filter_by(unit_id=unit_id).first() + logger.info(f"State check: measurement_state={status.measurement_state if status else 'None'}, start_time={status.measurement_start_time if status else 'None'}") + if status and status.measurement_state == "Measure" and status.measurement_start_time: + logger.info(f"✓ Measurement state confirmed for {unit_id} with start time {status.measurement_start_time}") + break + + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.stop() + logger.info(f"Stopped measurement on unit {unit_id}") + + # Query device status to update database with "Stop" state + snap = await client.request_dod() + snap.unit_id = unit_id + persist_snapshot(snap, db) + + 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.post("/{unit_id}/store") +async def manual_store(unit_id: str, db: Session = Depends(get_db)): + """Manually store measurement data to SD card.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.manual_store() + logger.info(f"Manual store executed on unit {unit_id}") + return {"status": "ok", "message": "Data stored to SD card"} + except ConnectionError as e: + logger.error(f"Failed to store data on {unit_id}: {e}") + raise HTTPException(status_code=502, detail="Failed to communicate with device") + except TimeoutError: + logger.error(f"Timeout storing data on {unit_id}") + raise HTTPException(status_code=504, detail="Device communication timeout") + except Exception as e: + logger.error(f"Unexpected error storing data on {unit_id}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.post("/{unit_id}/pause") +async def pause_measurement(unit_id: str, db: Session = Depends(get_db)): + """Pause the current measurement.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.pause() + logger.info(f"Paused measurement on unit {unit_id}") + return {"status": "ok", "message": "Measurement paused"} + except Exception as e: + logger.error(f"Failed to pause measurement on {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/{unit_id}/resume") +async def resume_measurement(unit_id: str, db: Session = Depends(get_db)): + """Resume a paused measurement.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.resume() + logger.info(f"Resumed measurement on unit {unit_id}") + return {"status": "ok", "message": "Measurement resumed"} + except Exception as e: + logger.error(f"Failed to resume measurement on {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/{unit_id}/reset") +async def reset_measurement(unit_id: str, db: Session = Depends(get_db)): + """Reset the measurement data.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.reset() + logger.info(f"Reset measurement data on unit {unit_id}") + return {"status": "ok", "message": "Measurement data reset"} + except Exception as e: + logger.error(f"Failed to reset measurement on {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/measurement-state") +async def get_measurement_state(unit_id: str, db: Session = Depends(get_db)): + """Get current measurement state (Start/Stop).""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + state = await client.get_measurement_state() + is_measuring = state == "Start" + return { + "status": "ok", + "measurement_state": state, + "is_measuring": is_measuring + } + except Exception as e: + logger.error(f"Failed to get measurement state for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/{unit_id}/sleep") +async def sleep_device(unit_id: str, db: Session = Depends(get_db)): + """Put the device into sleep mode for battery conservation.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.sleep() + logger.info(f"Put device {unit_id} to sleep") + return {"status": "ok", "message": "Device entering sleep mode"} + except Exception as e: + logger.error(f"Failed to put {unit_id} to sleep: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/{unit_id}/wake") +async def wake_device(unit_id: str, db: Session = Depends(get_db)): + """Wake the device from sleep mode.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.wake() + logger.info(f"Woke device {unit_id} from sleep") + return {"status": "ok", "message": "Device waking from sleep mode"} + except Exception as e: + logger.error(f"Failed to wake {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/sleep/status") +async def get_sleep_status(unit_id: str, db: Session = Depends(get_db)): + """Get the sleep mode status.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + status = await client.get_sleep_status() + return {"status": "ok", "sleep_status": status} + except Exception as e: + logger.error(f"Failed to get sleep status for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/battery") +async def get_battery(unit_id: str, db: Session = Depends(get_db)): + """Get battery level.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + level = await client.get_battery_level() + return {"status": "ok", "battery_level": level} + except Exception as e: + logger.error(f"Failed to get battery level for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/clock") +async def get_clock(unit_id: str, db: Session = Depends(get_db)): + """Get device clock time.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + clock = await client.get_clock() + return {"status": "ok", "clock": clock} + except Exception as e: + logger.error(f"Failed to get clock for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +class ClockPayload(BaseModel): + datetime: str # Format: YYYY/MM/DD,HH:MM:SS or YYYY/MM/DD HH:MM:SS (both accepted) + + +@router.put("/{unit_id}/clock") +async def set_clock(unit_id: str, payload: ClockPayload, db: Session = Depends(get_db)): + """Set device clock time.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.set_clock(payload.datetime) + return {"status": "ok", "message": f"Clock set to {payload.datetime}"} + except Exception as e: + logger.error(f"Failed to set clock for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +class WeightingPayload(BaseModel): + weighting: str + channel: str = "Main" + + +@router.get("/{unit_id}/frequency-weighting") +async def get_frequency_weighting(unit_id: str, channel: str = "Main", db: Session = Depends(get_db)): + """Get frequency weighting (A, C, Z).""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + weighting = await client.get_frequency_weighting(channel) + return {"status": "ok", "frequency_weighting": weighting, "channel": channel} + except Exception as e: + logger.error(f"Failed to get frequency weighting for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.put("/{unit_id}/frequency-weighting") +async def set_frequency_weighting(unit_id: str, payload: WeightingPayload, db: Session = Depends(get_db)): + """Set frequency weighting (A, C, Z).""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.set_frequency_weighting(payload.weighting, payload.channel) + return {"status": "ok", "message": f"Frequency weighting set to {payload.weighting} on {payload.channel}"} + except Exception as e: + logger.error(f"Failed to set frequency weighting for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/time-weighting") +async def get_time_weighting(unit_id: str, channel: str = "Main", db: Session = Depends(get_db)): + """Get time weighting (F, S, I).""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + weighting = await client.get_time_weighting(channel) + return {"status": "ok", "time_weighting": weighting, "channel": channel} + except Exception as e: + logger.error(f"Failed to get time weighting for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.put("/{unit_id}/time-weighting") +async def set_time_weighting(unit_id: str, payload: WeightingPayload, db: Session = Depends(get_db)): + """Set time weighting (F, S, I).""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.set_time_weighting(payload.weighting, payload.channel) + return {"status": "ok", "message": f"Time weighting set to {payload.weighting} on {payload.channel}"} + except Exception as e: + logger.error(f"Failed to set time weighting for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + snap = await client.request_dod() + snap.unit_id = unit_id + + # Persist snapshot with database session + persist_snapshot(snap, db) + + # Get the persisted status to include measurement_start_time + status = db.query(NL43Status).filter_by(unit_id=unit_id).first() + + # Build response with snapshot data + measurement_start_time + response_data = snap.__dict__.copy() + if status and status.measurement_start_time: + response_data['measurement_start_time'] = status.measurement_start_time.isoformat() + else: + response_data['measurement_start_time'] = None + + logger.info(f"Retrieved live status for unit {unit_id}") + return {"status": "ok", "data": response_data} + + 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") + + +@router.get("/{unit_id}/results") +async def get_results(unit_id: str, db: Session = Depends(get_db)): + """Get final calculation results (DLC) from the last measurement.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + results = await client.request_dlc() + logger.info(f"Retrieved measurement results for unit {unit_id}") + return {"status": "ok", "data": results} + + except ConnectionError as e: + logger.error(f"Failed to get results for {unit_id}: {e}") + raise HTTPException(status_code=502, detail="Failed to communicate with device") + except TimeoutError: + logger.error(f"Timeout getting results for {unit_id}") + raise HTTPException(status_code=504, detail="Device communication timeout") + except Exception as e: + logger.error(f"Unexpected error getting results for {unit_id}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.websocket("/{unit_id}/stream") +async def stream_live(websocket: WebSocket, unit_id: str): + """WebSocket endpoint for real-time DRD streaming from NL43 device. + + Connects to the device, starts DRD streaming, and pushes updates to the WebSocket client. + The stream continues until the client disconnects or an error occurs. + """ + await websocket.accept() + logger.info(f"WebSocket connection accepted for unit {unit_id}") + + from app.slm.database import SessionLocal + + db: Session = SessionLocal() + + try: + # Get device configuration + cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() + if not cfg: + await websocket.send_json({"error": "NL43 config not found", "unit_id": unit_id}) + await websocket.close() + return + + if not cfg.tcp_enabled: + await websocket.send_json( + {"error": "TCP communication is disabled for this device", "unit_id": unit_id} + ) + await websocket.close() + return + + # Create client and define callback + client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + + async def send_snapshot(snap): + """Callback that sends each snapshot to the WebSocket client.""" + snap.unit_id = unit_id + + # Persist to database + try: + persist_snapshot(snap, db) + except Exception as e: + logger.error(f"Failed to persist snapshot during stream: {e}") + + # Get measurement_start_time from database + measurement_start_time = None + try: + status = db.query(NL43Status).filter_by(unit_id=unit_id).first() + if status and status.measurement_start_time: + measurement_start_time = status.measurement_start_time.isoformat() + except Exception as e: + logger.error(f"Failed to query measurement_start_time: {e}") + + # Send to WebSocket client + try: + await websocket.send_json({ + "unit_id": unit_id, + "timestamp": datetime.utcnow().isoformat(), + "measurement_state": snap.measurement_state, + "measurement_start_time": measurement_start_time, + "counter": snap.counter, # Measurement interval counter (1-600) + "lp": snap.lp, # Instantaneous sound pressure level + "leq": snap.leq, # Equivalent continuous sound level + "lmax": snap.lmax, # Maximum level + "lmin": snap.lmin, # Minimum level + "lpeak": snap.lpeak, # Peak level + "raw_payload": snap.raw_payload, + }) + except Exception as e: + logger.error(f"Failed to send snapshot via WebSocket: {e}") + raise + + # Start DRD streaming + logger.info(f"Starting DRD stream for unit {unit_id}") + await client.stream_drd(send_snapshot) + + except WebSocketDisconnect: + logger.info(f"WebSocket disconnected for unit {unit_id}") + except ConnectionError as e: + logger.error(f"Failed to connect to device {unit_id}: {e}") + try: + await websocket.send_json({"error": "Failed to communicate with device", "detail": str(e)}) + except Exception: + pass + except Exception as e: + logger.error(f"Unexpected error in WebSocket stream for {unit_id}: {e}") + try: + await websocket.send_json({"error": "Internal server error", "detail": str(e)}) + except Exception: + pass + finally: + db.close() + try: + await websocket.close() + except Exception: + pass + logger.info(f"WebSocket stream closed for unit {unit_id}") + + +@router.post("/{unit_id}/ftp/enable") +async def enable_ftp(unit_id: str, db: Session = Depends(get_db)): + """Enable FTP server on the device.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.enable_ftp() + logger.info(f"Enabled FTP on unit {unit_id}") + return {"status": "ok", "message": "FTP enabled"} + except Exception as e: + logger.error(f"Failed to enable FTP on {unit_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to enable FTP: {str(e)}") + + +@router.post("/{unit_id}/ftp/disable") +async def disable_ftp(unit_id: str, db: Session = Depends(get_db)): + """Disable FTP server on the device.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.disable_ftp() + logger.info(f"Disabled FTP on unit {unit_id}") + return {"status": "ok", "message": "FTP disabled"} + except Exception as e: + logger.error(f"Failed to disable FTP on {unit_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to disable FTP: {str(e)}") + + +@router.get("/{unit_id}/ftp/status") +async def get_ftp_status(unit_id: str, db: Session = Depends(get_db)): + """Get FTP server status from the device.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + status = await client.get_ftp_status() + return {"status": "ok", "ftp_enabled": status.lower() == "on", "ftp_status": status} + except Exception as e: + logger.error(f"Failed to get FTP status from {unit_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get FTP status: {str(e)}") + + +@router.get("/{unit_id}/ftp/latest-measurement-time") +async def get_latest_measurement_time(unit_id: str, db: Session = Depends(get_db)): + """Get the timestamp of the most recent measurement session from the NL-43 folder. + + The NL43 creates Auto_XXXX folders for each measurement session. This endpoint finds + the most recently modified Auto_XXXX folder and returns its timestamp, which indicates + when the measurement started. + """ + 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.ftp_enabled: + raise HTTPException(status_code=403, detail="FTP is disabled for this device") + + client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + # List directories in the NL-43 folder + items = await client.list_ftp_files("/NL-43") + + if not items: + return {"status": "ok", "latest_folder": None, "latest_timestamp": None} + + # Filter for Auto_XXXX directories with timestamps + auto_folders = [ + f for f in items + if f.get('is_dir', False) + and f.get('name', '').startswith('Auto_') + and f.get('modified_timestamp') + ] + + if not auto_folders: + return {"status": "ok", "latest_folder": None, "latest_timestamp": None} + + # Sort by modified_timestamp descending (most recent first) + auto_folders.sort(key=lambda x: x['modified_timestamp'], reverse=True) + latest = auto_folders[0] + + logger.info(f"Latest measurement folder for {unit_id}: {latest['name']} at {latest['modified_timestamp']}") + return { + "status": "ok", + "latest_folder": latest['name'], + "latest_timestamp": latest['modified_timestamp'] + } + + except Exception as e: + logger.error(f"Failed to get latest measurement time for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=f"FTP connection failed: {str(e)}") + + +@router.get("/{unit_id}/settings") +async def get_all_settings(unit_id: str, db: Session = Depends(get_db)): + """Get all current device settings for verification. + + Returns a comprehensive view of all device configuration including: + - Measurement state and weightings + - Timing and interval settings + - Battery level and clock + - Sleep and FTP status + + This is useful for verifying device configuration before starting measurements. + """ + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + settings = await client.get_all_settings() + logger.info(f"Retrieved all settings for unit {unit_id}") + return {"status": "ok", "unit_id": unit_id, "settings": settings} + + except ConnectionError as e: + logger.error(f"Failed to get settings for {unit_id}: {e}") + raise HTTPException(status_code=502, detail="Failed to communicate with device") + except TimeoutError: + logger.error(f"Timeout getting settings for {unit_id}") + raise HTTPException(status_code=504, detail="Device communication timeout") + except Exception as e: + logger.error(f"Unexpected error getting settings for {unit_id}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.get("/{unit_id}/ftp/files") +async def list_ftp_files(unit_id: str, path: str = "/", db: Session = Depends(get_db)): + """List files on the device via FTP. + + Query params: + path: Directory path on the device (default: root) + """ + cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() + if not cfg: + raise HTTPException(status_code=404, detail="NL43 config not found") + + client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + files = await client.list_ftp_files(path) + return {"status": "ok", "path": path, "files": files, "count": len(files)} + except ConnectionError as e: + logger.error(f"Failed to list FTP files on {unit_id}: {e}") + raise HTTPException(status_code=502, detail="Failed to communicate with device") + except Exception as e: + logger.error(f"Unexpected error listing FTP files on {unit_id}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +class TimingPayload(BaseModel): + preset: str + + +class IndexPayload(BaseModel): + index: int + + +class DownloadRequest(BaseModel): + remote_path: str + + +@router.post("/{unit_id}/ftp/download") +async def download_ftp_file(unit_id: str, payload: DownloadRequest, db: Session = Depends(get_db)): + """Download a file from the device via FTP. + + The file is saved to data/downloads/{unit_id}/ and can be retrieved via the response. + """ + cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() + if not cfg: + raise HTTPException(status_code=404, detail="NL43 config not found") + + # Create download directory + download_dir = f"data/downloads/{unit_id}" + os.makedirs(download_dir, exist_ok=True) + + # Extract filename from remote path + filename = os.path.basename(payload.remote_path) + if not filename: + raise HTTPException(status_code=400, detail="Invalid remote path") + + local_path = os.path.join(download_dir, filename) + + client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.download_ftp_file(payload.remote_path, local_path) + logger.info(f"Downloaded {payload.remote_path} from {unit_id} to {local_path}") + + # Return the file + return FileResponse( + path=local_path, + filename=filename, + media_type="application/octet-stream", + ) + except ConnectionError as e: + logger.error(f"Failed to download file from {unit_id}: {e}") + raise HTTPException(status_code=502, detail="Failed to communicate with device") + except Exception as e: + logger.error(f"Unexpected error downloading file from {unit_id}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +# Timing/Interval Configuration Endpoints + +@router.get("/{unit_id}/measurement-time") +async def get_measurement_time(unit_id: str, db: Session = Depends(get_db)): + """Get current measurement time preset.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + preset = await client.get_measurement_time() + return {"status": "ok", "measurement_time": preset} + except Exception as e: + logger.error(f"Failed to get measurement time for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.put("/{unit_id}/measurement-time") +async def set_measurement_time(unit_id: str, payload: TimingPayload, db: Session = Depends(get_db)): + """Set measurement time preset (10s, 1m, 5m, 10m, 15m, 30m, 1h, 8h, 24h, or custom like 00:05:30).""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.set_measurement_time(payload.preset) + return {"status": "ok", "message": f"Measurement time set to {payload.preset}"} + except Exception as e: + logger.error(f"Failed to set measurement time for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/leq-interval") +async def get_leq_interval(unit_id: str, db: Session = Depends(get_db)): + """Get current Leq calculation interval preset.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + preset = await client.get_leq_interval() + return {"status": "ok", "leq_interval": preset} + except Exception as e: + logger.error(f"Failed to get Leq interval for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.put("/{unit_id}/leq-interval") +async def set_leq_interval(unit_id: str, payload: TimingPayload, db: Session = Depends(get_db)): + """Set Leq calculation interval preset (Off, 10s, 1m, 5m, 10m, 15m, 30m, 1h, 8h, 24h, or custom like 00:05:30).""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.set_leq_interval(payload.preset) + return {"status": "ok", "message": f"Leq interval set to {payload.preset}"} + except Exception as e: + logger.error(f"Failed to set Leq interval for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/lp-interval") +async def get_lp_interval(unit_id: str, db: Session = Depends(get_db)): + """Get current Lp store interval.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + preset = await client.get_lp_interval() + return {"status": "ok", "lp_interval": preset} + except Exception as e: + logger.error(f"Failed to get Lp interval for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.put("/{unit_id}/lp-interval") +async def set_lp_interval(unit_id: str, payload: TimingPayload, db: Session = Depends(get_db)): + """Set Lp store interval (Off, 10ms, 25ms, 100ms, 200ms, 1s).""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.set_lp_interval(payload.preset) + return {"status": "ok", "message": f"Lp interval set to {payload.preset}"} + except Exception as e: + logger.error(f"Failed to set Lp interval for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/index-number") +async def get_index_number(unit_id: str, db: Session = Depends(get_db)): + """Get current index number for file numbering.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + index = await client.get_index_number() + return {"status": "ok", "index_number": index} + except Exception as e: + logger.error(f"Failed to get index number for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.put("/{unit_id}/index-number") +async def set_index_number(unit_id: str, payload: IndexPayload, db: Session = Depends(get_db)): + """Set index number for file numbering (0000-9999).""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + await client.set_index_number(payload.index) + return {"status": "ok", "message": f"Index number set to {payload.index:04d}"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to set index number for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/overwrite-check") +async def check_overwrite_status(unit_id: str, db: Session = Depends(get_db)): + """Check if data exists at current store target. + + Returns: + - "None": No data exists (safe to store) + - "Exist": Data exists (would overwrite existing data) + + Use this before starting a measurement to prevent accidentally overwriting data. + """ + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + overwrite_status = await client.get_overwrite_status() + will_overwrite = overwrite_status == "Exist" + return { + "status": "ok", + "overwrite_status": overwrite_status, + "will_overwrite": will_overwrite, + "safe_to_store": not will_overwrite + } + except Exception as e: + logger.error(f"Failed to check overwrite status for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/settings/all") +async def get_all_settings(unit_id: str, db: Session = Depends(get_db)): + """Get all device settings for verification.""" + 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, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + settings = await client.get_all_settings() + return {"status": "ok", "settings": settings} + except Exception as e: + logger.error(f"Failed to get all settings for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/diagnostics") +async def run_diagnostics(unit_id: str, db: Session = Depends(get_db)): + """Run comprehensive diagnostics on device connection and capabilities. + + Tests: + - Configuration exists + - TCP connection reachable + - Device responds to commands + - FTP status (if enabled) + """ + import asyncio + + diagnostics = { + "unit_id": unit_id, + "timestamp": datetime.now().isoformat(), + "tests": {} + } + + # Test 1: Configuration exists + cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() + if not cfg: + diagnostics["tests"]["config_exists"] = { + "status": "fail", + "message": "Unit configuration not found in database" + } + diagnostics["overall_status"] = "fail" + return diagnostics + + diagnostics["tests"]["config_exists"] = { + "status": "pass", + "message": f"Configuration found: {cfg.host}:{cfg.tcp_port}" + } + + # Test 2: TCP enabled + if not cfg.tcp_enabled: + diagnostics["tests"]["tcp_enabled"] = { + "status": "fail", + "message": "TCP communication is disabled in configuration" + } + diagnostics["overall_status"] = "fail" + return diagnostics + + diagnostics["tests"]["tcp_enabled"] = { + "status": "pass", + "message": "TCP communication enabled" + } + + # Test 3: Modem/Router reachable (check port 443 HTTPS) + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(cfg.host, 443), timeout=3.0 + ) + writer.close() + await writer.wait_closed() + diagnostics["tests"]["modem_reachable"] = { + "status": "pass", + "message": f"Modem/router reachable at {cfg.host}" + } + except asyncio.TimeoutError: + diagnostics["tests"]["modem_reachable"] = { + "status": "fail", + "message": f"Modem/router timeout at {cfg.host} (network issue)" + } + diagnostics["overall_status"] = "fail" + return diagnostics + except ConnectionRefusedError: + # Connection refused means host is up but port 443 closed - that's ok + diagnostics["tests"]["modem_reachable"] = { + "status": "pass", + "message": f"Modem/router reachable at {cfg.host} (HTTPS closed)" + } + except Exception as e: + diagnostics["tests"]["modem_reachable"] = { + "status": "fail", + "message": f"Cannot reach modem/router at {cfg.host}: {str(e)}" + } + diagnostics["overall_status"] = "fail" + return diagnostics + + # Test 4: TCP connection reachable (device port) + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(cfg.host, cfg.tcp_port), timeout=3.0 + ) + writer.close() + await writer.wait_closed() + diagnostics["tests"]["tcp_connection"] = { + "status": "pass", + "message": f"TCP connection successful to {cfg.host}:{cfg.tcp_port}" + } + except asyncio.TimeoutError: + diagnostics["tests"]["tcp_connection"] = { + "status": "fail", + "message": f"Connection timeout to {cfg.host}:{cfg.tcp_port}" + } + diagnostics["overall_status"] = "fail" + return diagnostics + except ConnectionRefusedError: + diagnostics["tests"]["tcp_connection"] = { + "status": "fail", + "message": f"Connection refused by {cfg.host}:{cfg.tcp_port}" + } + diagnostics["overall_status"] = "fail" + return diagnostics + except Exception as e: + diagnostics["tests"]["tcp_connection"] = { + "status": "fail", + "message": f"Connection error: {str(e)}" + } + diagnostics["overall_status"] = "fail" + return diagnostics + + # Wait a bit after connection test to let device settle + await asyncio.sleep(1.5) + + # Test 5: Device responds to commands + # Use longer timeout to account for rate limiting (device requires ≥1s between commands) + client = NL43Client(cfg.host, cfg.tcp_port, timeout=10.0, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + battery = await client.get_battery_level() + diagnostics["tests"]["command_response"] = { + "status": "pass", + "message": f"Device responds to commands (Battery: {battery})" + } + except ConnectionError as e: + diagnostics["tests"]["command_response"] = { + "status": "fail", + "message": f"Device not responding to commands: {str(e)}" + } + diagnostics["overall_status"] = "degraded" + return diagnostics + except ValueError as e: + diagnostics["tests"]["command_response"] = { + "status": "fail", + "message": f"Invalid response from device: {str(e)}" + } + diagnostics["overall_status"] = "degraded" + return diagnostics + except Exception as e: + diagnostics["tests"]["command_response"] = { + "status": "fail", + "message": f"Command error: {str(e)}" + } + diagnostics["overall_status"] = "degraded" + return diagnostics + + # Test 6: FTP status (if FTP is enabled in config) + if cfg.ftp_enabled: + try: + ftp_status = await client.get_ftp_status() + diagnostics["tests"]["ftp_status"] = { + "status": "pass" if ftp_status == "On" else "warning", + "message": f"FTP server status: {ftp_status}" + } + except Exception as e: + diagnostics["tests"]["ftp_status"] = { + "status": "warning", + "message": f"Could not query FTP status: {str(e)}" + } + else: + diagnostics["tests"]["ftp_status"] = { + "status": "skip", + "message": "FTP not enabled in configuration" + } + + # All tests passed + diagnostics["overall_status"] = "pass" + return diagnostics diff --git a/app/slm/routers/__init__.py b/app/slm/routers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/slm/routers/dashboard.py b/app/slm/routers/dashboard.py deleted file mode 100644 index fbcc233..0000000 --- a/app/slm/routers/dashboard.py +++ /dev/null @@ -1,328 +0,0 @@ -""" -SLM Dashboard Router - -Provides API endpoints for the Sound Level Meters dashboard page. -""" - -from fastapi import APIRouter, Request, Depends, Query -from fastapi.templating import Jinja2Templates -from fastapi.responses import HTMLResponse -from sqlalchemy.orm import Session -from sqlalchemy import func -from datetime import datetime, timedelta -import httpx -import logging -import os - -from app.seismo.database import get_db -from app.seismo.models import RosterUnit -from app.seismo.routers.roster_edit import sync_slm_to_slmm_cache - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"]) -templates = Jinja2Templates(directory="templates") - -# SLMM backend URL - configurable via environment variable -SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") - - -@router.get("/stats", response_class=HTMLResponse) -async def get_slm_stats(request: Request, db: Session = Depends(get_db)): - """ - Get summary statistics for SLM dashboard. - Returns HTML partial with stat cards. - """ - # Query all SLMs - all_slms = db.query(RosterUnit).filter_by(device_type="sound_level_meter").all() - - # Count deployed vs benched - deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired) - benched_count = sum(1 for slm in all_slms if not slm.deployed and not slm.retired) - retired_count = sum(1 for slm in all_slms if slm.retired) - - # Count recently active (checked in last hour) - one_hour_ago = datetime.utcnow() - timedelta(hours=1) - active_count = sum(1 for slm in all_slms - if slm.slm_last_check and slm.slm_last_check > one_hour_ago) - - return templates.TemplateResponse("partials/slm_stats.html", { - "request": request, - "total_count": len(all_slms), - "deployed_count": deployed_count, - "benched_count": benched_count, - "active_count": active_count, - "retired_count": retired_count - }) - - -@router.get("/units", response_class=HTMLResponse) -async def get_slm_units( - request: Request, - db: Session = Depends(get_db), - search: str = Query(None) -): - """ - Get list of SLM units for the sidebar. - Returns HTML partial with unit cards. - """ - query = db.query(RosterUnit).filter_by(device_type="sound_level_meter") - - # Filter by search term if provided - if search: - search_term = f"%{search}%" - query = query.filter( - (RosterUnit.id.like(search_term)) | - (RosterUnit.slm_model.like(search_term)) | - (RosterUnit.address.like(search_term)) - ) - - # Only show deployed units by default - units = query.filter_by(deployed=True, retired=False).order_by(RosterUnit.id).all() - - return templates.TemplateResponse("partials/slm_unit_list.html", { - "request": request, - "units": units - }) - - -@router.get("/live-view/{unit_id}", response_class=HTMLResponse) -async def get_live_view(request: Request, unit_id: str, db: Session = Depends(get_db)): - """ - Get live view panel for a specific SLM unit. - Returns HTML partial with live metrics and chart. - """ - # Get unit from database - unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() - - if not unit: - return templates.TemplateResponse("partials/slm_live_view_error.html", { - "request": request, - "error": f"Unit {unit_id} not found" - }) - - # Get modem information if assigned - modem = None - modem_ip = None - if unit.deployed_with_modem_id: - modem = db.query(RosterUnit).filter_by(id=unit.deployed_with_modem_id, device_type="modem").first() - if modem: - modem_ip = modem.ip_address - else: - logger.warning(f"SLM {unit_id} is assigned to modem {unit.deployed_with_modem_id} but modem not found") - - # Fallback to direct slm_host if no modem assigned (backward compatibility) - if not modem_ip and unit.slm_host: - modem_ip = unit.slm_host - logger.info(f"Using legacy slm_host for {unit_id}: {modem_ip}") - - # Try to get current status from SLMM - current_status = None - measurement_state = None - is_measuring = False - - try: - async with httpx.AsyncClient(timeout=5.0) as client: - # Get measurement state - state_response = await client.get( - f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state" - ) - if state_response.status_code == 200: - state_data = state_response.json() - measurement_state = state_data.get("measurement_state", "Unknown") - is_measuring = state_data.get("is_measuring", False) - - # Get live status - status_response = await client.get( - f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live" - ) - if status_response.status_code == 200: - status_data = status_response.json() - current_status = status_data.get("data", {}) - except Exception as e: - logger.error(f"Failed to get status for {unit_id}: {e}") - - return templates.TemplateResponse("partials/slm_live_view.html", { - "request": request, - "unit": unit, - "modem": modem, - "modem_ip": modem_ip, - "current_status": current_status, - "measurement_state": measurement_state, - "is_measuring": is_measuring - }) - - -@router.post("/control/{unit_id}/{action}") -async def control_slm(unit_id: str, action: str): - """ - Send control commands to SLM (start, stop, pause, resume, reset). - Proxies to SLMM backend. - """ - valid_actions = ["start", "stop", "pause", "resume", "reset"] - - if action not in valid_actions: - return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"} - - try: - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post( - f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}" - ) - - if response.status_code == 200: - return response.json() - else: - return { - "status": "error", - "detail": f"SLMM returned status {response.status_code}" - } - except Exception as e: - logger.error(f"Failed to control {unit_id}: {e}") - return { - "status": "error", - "detail": str(e) - } - -@router.get("/config/{unit_id}", response_class=HTMLResponse) -async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)): - """ - Get configuration form for a specific SLM unit. - Returns HTML partial with configuration form. - """ - unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() - - if not unit: - return HTMLResponse( - content=f'