1483 lines
61 KiB
Python
1483 lines
61 KiB
Python
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.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"])
|
|
|
|
|
|
async def ensure_sleep_mode_disabled(client: NL43Client, unit_id: str):
|
|
"""
|
|
Helper function to ensure sleep mode is disabled on the device.
|
|
Sleep/eco mode turns off TCP communications, preventing remote monitoring.
|
|
This should be called when configuring a device or starting measurements.
|
|
"""
|
|
try:
|
|
current_status = await client.get_sleep_status()
|
|
logger.info(f"Current sleep mode status for {unit_id}: {current_status}")
|
|
|
|
# If sleep mode is on, disable it
|
|
if "On" in current_status or "on" in current_status:
|
|
logger.info(f"Sleep mode is enabled on {unit_id}, disabling it to maintain TCP connectivity")
|
|
await client.wake()
|
|
logger.info(f"Successfully disabled sleep mode on {unit_id}")
|
|
else:
|
|
logger.info(f"Sleep mode already disabled on {unit_id}")
|
|
except Exception as e:
|
|
logger.warning(f"Could not verify/disable sleep mode on {unit_id}: {e}")
|
|
# Don't raise - we want configuration to succeed even if sleep mode check fails
|
|
|
|
|
|
class ConfigPayload(BaseModel):
|
|
host: str | None = None
|
|
tcp_port: int | None = None
|
|
ftp_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", "ftp_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,
|
|
"ftp_port": cfg.ftp_port,
|
|
"tcp_enabled": cfg.tcp_enabled,
|
|
"ftp_enabled": cfg.ftp_enabled,
|
|
"ftp_username": cfg.ftp_username,
|
|
"ftp_password": cfg.ftp_password,
|
|
"web_enabled": cfg.web_enabled,
|
|
},
|
|
}
|
|
|
|
|
|
@router.put("/{unit_id}/config")
|
|
async 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.ftp_port is not None:
|
|
cfg.ftp_port = payload.ftp_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}")
|
|
|
|
# If TCP is enabled and we have connection details, automatically disable sleep mode
|
|
# to ensure TCP communications remain available
|
|
if cfg.tcp_enabled and cfg.host and cfg.tcp_port:
|
|
logger.info(f"TCP enabled for {unit_id}, ensuring sleep mode is disabled")
|
|
client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password, ftp_port=cfg.ftp_port or 21)
|
|
await ensure_sleep_mode_disabled(client, 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, ftp_port=cfg.ftp_port or 21)
|
|
try:
|
|
# Ensure sleep mode is disabled before starting measurement
|
|
# Sleep mode would interrupt TCP communications
|
|
await ensure_sleep_mode_disabled(client, unit_id)
|
|
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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.post("/{unit_id}/sync-start-time")
|
|
async def sync_measurement_start_time(unit_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Sync measurement start time from FTP folder timestamp to database.
|
|
This fixes the issue where the database timestamp is wrong if measurement was already running.
|
|
"""
|
|
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
|
if not cfg:
|
|
raise HTTPException(status_code=404, detail="NL43 config not found")
|
|
|
|
# FTP credentials might not be configured, but we'll try anyway
|
|
client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password, ftp_port=cfg.ftp_port or 21)
|
|
|
|
try:
|
|
# Get list of measurement folders from FTP
|
|
files = await client.list_ftp_files("/NL-43")
|
|
|
|
# Filter for directories only
|
|
folders = [f for f in files if f.get('is_dir', False)]
|
|
|
|
if not folders:
|
|
return {"status": "error", "message": "No measurement folders found on FTP"}
|
|
|
|
# Sort by modified timestamp (newest first)
|
|
folders.sort(key=lambda f: f.get('modified_timestamp', ''), reverse=True)
|
|
|
|
# Get the latest folder
|
|
latest_folder = folders[0]
|
|
folder_name = latest_folder['name']
|
|
|
|
# Parse timestamp from modified_timestamp field
|
|
if 'modified_timestamp' in latest_folder and latest_folder['modified_timestamp']:
|
|
from datetime import datetime
|
|
timestamp_str = latest_folder['modified_timestamp']
|
|
|
|
# Parse ISO format timestamp (already in UTC from SLMM)
|
|
start_time = datetime.fromisoformat(timestamp_str.replace('Z', ''))
|
|
|
|
# Update database
|
|
status = db.query(NL43Status).filter_by(unit_id=unit_id).first()
|
|
if status:
|
|
old_time = status.measurement_start_time
|
|
status.measurement_start_time = start_time
|
|
db.commit()
|
|
|
|
logger.info(f"✓ Synced start time for {unit_id} from FTP folder {folder_name}")
|
|
logger.info(f" Old: {old_time}")
|
|
logger.info(f" New: {start_time}")
|
|
|
|
return {
|
|
"status": "ok",
|
|
"message": "Start time synced from FTP",
|
|
"folder": folder_name,
|
|
"start_time": start_time.isoformat(),
|
|
"old_start_time": old_time.isoformat() if old_time else None
|
|
}
|
|
else:
|
|
return {"status": "error", "message": "Device status not found in database"}
|
|
else:
|
|
return {"status": "error", "message": "Could not parse timestamp from FTP folder"}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to sync start time for {unit_id}: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Failed to sync start time: {str(e)}")
|
|
|
|
|
|
@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, ftp_port=cfg.ftp_port or 21)
|
|
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.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, ftp_port=cfg.ftp_port or 21)
|
|
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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")
|
|
|
|
|
|
@router.post("/{unit_id}/ftp/download-folder")
|
|
async def download_ftp_folder(unit_id: str, payload: DownloadRequest, db: Session = Depends(get_db)):
|
|
"""Download an entire folder from the device via FTP as a ZIP archive.
|
|
|
|
The folder is recursively downloaded and packaged into a ZIP file.
|
|
Useful for downloading complete measurement sessions (e.g., Auto_0000 folders).
|
|
"""
|
|
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 folder name from remote path
|
|
folder_name = os.path.basename(payload.remote_path.rstrip('/'))
|
|
if not folder_name:
|
|
raise HTTPException(status_code=400, detail="Invalid remote path")
|
|
|
|
# Generate ZIP filename
|
|
zip_filename = f"{folder_name}.zip"
|
|
zip_path = os.path.join(download_dir, zip_filename)
|
|
|
|
client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password, ftp_port=cfg.ftp_port or 21)
|
|
try:
|
|
await client.download_ftp_folder(payload.remote_path, zip_path)
|
|
logger.info(f"Downloaded folder {payload.remote_path} from {unit_id} to {zip_path}")
|
|
|
|
# Return the ZIP file
|
|
return FileResponse(
|
|
path=zip_path,
|
|
filename=zip_filename,
|
|
media_type="application/zip",
|
|
)
|
|
except ConnectionError as e:
|
|
logger.error(f"Failed to download folder 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 folder 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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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, ftp_port=cfg.ftp_port or 21)
|
|
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
|