Add communication guide and project improvements documentation; enhance main app with logging, CORS configuration, and health check endpoints; implement input validation and error handling in routers; improve services with rate limiting and snapshot persistence; update models for SQLAlchemy best practices; create index.html for frontend interaction.
This commit is contained in:
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/database.cpython-310.pyc
Normal file
BIN
app/__pycache__/database.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-310.pyc
Normal file
BIN
app/__pycache__/main.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/models.cpython-310.pyc
Normal file
BIN
app/__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/routers.cpython-310.pyc
Normal file
BIN
app/__pycache__/routers.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/services.cpython-310.pyc
Normal file
BIN
app/__pycache__/services.cpython-310.pyc
Normal file
Binary file not shown.
88
app/main.py
88
app/main.py
@@ -1,12 +1,27 @@
|
||||
import os
|
||||
from fastapi import FastAPI
|
||||
import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.database import Base, engine
|
||||
from app import routers
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
logging.FileHandler("data/slmm.log"),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ensure database tables exist for the addon
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("Database tables initialized")
|
||||
|
||||
app = FastAPI(
|
||||
title="SLMM NL43 Addon",
|
||||
@@ -14,20 +29,85 @@ app = FastAPI(
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# CORS configuration - use environment variable for allowed origins
|
||||
# Default to "*" for development, but should be restricted in production
|
||||
allowed_origins = os.getenv("CORS_ORIGINS", "*").split(",")
|
||||
logger.info(f"CORS allowed origins: {allowed_origins}")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
app.include_router(routers.router)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
async def health():
|
||||
"""Basic health check endpoint."""
|
||||
return {"status": "ok", "service": "slmm-nl43-addon"}
|
||||
|
||||
|
||||
@app.get("/health/devices")
|
||||
async def health_devices():
|
||||
"""Enhanced health check that tests device connectivity."""
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import SessionLocal
|
||||
from app.services import NL43Client
|
||||
from app.models import NL43Config
|
||||
|
||||
db: Session = SessionLocal()
|
||||
device_status = []
|
||||
|
||||
try:
|
||||
configs = db.query(NL43Config).filter_by(tcp_enabled=True).all()
|
||||
|
||||
for cfg in configs:
|
||||
client = NL43Client(cfg.host, cfg.tcp_port, timeout=2.0)
|
||||
status = {
|
||||
"unit_id": cfg.unit_id,
|
||||
"host": cfg.host,
|
||||
"port": cfg.tcp_port,
|
||||
"reachable": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
# Try to connect (don't send command to avoid rate limiting issues)
|
||||
import asyncio
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(cfg.host, cfg.tcp_port), timeout=2.0
|
||||
)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
status["reachable"] = True
|
||||
except Exception as e:
|
||||
status["error"] = str(type(e).__name__)
|
||||
logger.warning(f"Device {cfg.unit_id} health check failed: {e}")
|
||||
|
||||
device_status.append(status)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
all_reachable = all(d["reachable"] for d in device_status) if device_status else True
|
||||
|
||||
return {
|
||||
"status": "ok" if all_reachable else "degraded",
|
||||
"devices": device_status,
|
||||
"total_devices": len(device_status),
|
||||
"reachable_devices": sum(1 for d in device_status if d["reachable"]),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
@@ -11,6 +10,7 @@ class NL43Config(Base):
|
||||
__tablename__ = "nl43_config"
|
||||
|
||||
unit_id = Column(String, primary_key=True, index=True)
|
||||
host = Column(String, default="127.0.0.1")
|
||||
tcp_port = Column(Integer, default=80) # NL43 TCP control port (via RX55)
|
||||
tcp_enabled = Column(Boolean, default=True)
|
||||
ftp_enabled = Column(Boolean, default=False)
|
||||
@@ -25,7 +25,7 @@ class NL43Status(Base):
|
||||
__tablename__ = "nl43_status"
|
||||
|
||||
unit_id = Column(String, primary_key=True, index=True)
|
||||
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||
last_seen = Column(DateTime, default=func.now())
|
||||
measurement_state = Column(String, default="unknown") # Measure/Stop
|
||||
lp = Column(String, nullable=True)
|
||||
leq = Column(String, nullable=True)
|
||||
|
||||
209
app/routers.py
209
app/routers.py
@@ -1,20 +1,52 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
import logging
|
||||
import ipaddress
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import NL43Config, NL43Status
|
||||
from app.services import NL43Client, persist_snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/nl43", tags=["nl43"])
|
||||
|
||||
|
||||
class ConfigPayload(BaseModel):
|
||||
host: str | None = None
|
||||
tcp_port: int | None = None
|
||||
tcp_enabled: bool | None = None
|
||||
ftp_enabled: bool | None = None
|
||||
web_enabled: bool | None = None
|
||||
|
||||
@field_validator("host")
|
||||
@classmethod
|
||||
def validate_host(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
# Try to parse as IP address or hostname
|
||||
try:
|
||||
ipaddress.ip_address(v)
|
||||
except ValueError:
|
||||
# Not an IP, check if it's a valid hostname format
|
||||
if not v or len(v) > 253:
|
||||
raise ValueError("Invalid hostname length")
|
||||
# Allow hostnames (basic validation)
|
||||
if not all(c.isalnum() or c in ".-" for c in v):
|
||||
raise ValueError("Host must be a valid IP address or hostname")
|
||||
return v
|
||||
|
||||
@field_validator("tcp_port")
|
||||
@classmethod
|
||||
def validate_port(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not (1 <= v <= 65535):
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
return v
|
||||
|
||||
|
||||
@router.get("/{unit_id}/config")
|
||||
def get_config(unit_id: str, db: Session = Depends(get_db)):
|
||||
@@ -22,11 +54,15 @@ def get_config(unit_id: str, db: Session = Depends(get_db)):
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="NL43 config not found")
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"tcp_port": cfg.tcp_port,
|
||||
"tcp_enabled": cfg.tcp_enabled,
|
||||
"ftp_enabled": cfg.ftp_enabled,
|
||||
"web_enabled": cfg.web_enabled,
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"unit_id": unit_id,
|
||||
"host": cfg.host,
|
||||
"tcp_port": cfg.tcp_port,
|
||||
"tcp_enabled": cfg.tcp_enabled,
|
||||
"ftp_enabled": cfg.ftp_enabled,
|
||||
"web_enabled": cfg.web_enabled,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +73,8 @@ def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depends(ge
|
||||
cfg = NL43Config(unit_id=unit_id)
|
||||
db.add(cfg)
|
||||
|
||||
if payload.host is not None:
|
||||
cfg.host = payload.host
|
||||
if payload.tcp_port is not None:
|
||||
cfg.tcp_port = payload.tcp_port
|
||||
if payload.tcp_enabled is not None:
|
||||
@@ -48,12 +86,17 @@ def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depends(ge
|
||||
|
||||
db.commit()
|
||||
db.refresh(cfg)
|
||||
logger.info(f"Updated config for unit {unit_id}")
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"tcp_port": cfg.tcp_port,
|
||||
"tcp_enabled": cfg.tcp_enabled,
|
||||
"ftp_enabled": cfg.ftp_enabled,
|
||||
"web_enabled": cfg.web_enabled,
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"unit_id": unit_id,
|
||||
"host": cfg.host,
|
||||
"tcp_port": cfg.tcp_port,
|
||||
"tcp_enabled": cfg.tcp_enabled,
|
||||
"ftp_enabled": cfg.ftp_enabled,
|
||||
"web_enabled": cfg.web_enabled,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -63,19 +106,22 @@ def get_status(unit_id: str, db: Session = Depends(get_db)):
|
||||
if not status:
|
||||
raise HTTPException(status_code=404, detail="No NL43 status recorded")
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"last_seen": status.last_seen.isoformat() if status.last_seen else None,
|
||||
"measurement_state": status.measurement_state,
|
||||
"lp": status.lp,
|
||||
"leq": status.leq,
|
||||
"lmax": status.lmax,
|
||||
"lmin": status.lmin,
|
||||
"lpeak": status.lpeak,
|
||||
"battery_level": status.battery_level,
|
||||
"power_source": status.power_source,
|
||||
"sd_remaining_mb": status.sd_remaining_mb,
|
||||
"sd_free_ratio": status.sd_free_ratio,
|
||||
"raw_payload": status.raw_payload,
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"unit_id": unit_id,
|
||||
"last_seen": status.last_seen.isoformat() if status.last_seen else None,
|
||||
"measurement_state": status.measurement_state,
|
||||
"lp": status.lp,
|
||||
"leq": status.leq,
|
||||
"lmax": status.lmax,
|
||||
"lmin": status.lmin,
|
||||
"lpeak": status.lpeak,
|
||||
"battery_level": status.battery_level,
|
||||
"power_source": status.power_source,
|
||||
"sd_remaining_mb": status.sd_remaining_mb,
|
||||
"sd_free_ratio": status.sd_free_ratio,
|
||||
"raw_payload": status.raw_payload,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -101,24 +147,111 @@ def upsert_status(unit_id: str, payload: StatusPayload, db: Session = Depends(ge
|
||||
db.add(status)
|
||||
|
||||
status.last_seen = datetime.utcnow()
|
||||
for field, value in payload.dict().items():
|
||||
for field, value in payload.model_dump().items():
|
||||
if value is not None:
|
||||
setattr(status, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(status)
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"last_seen": status.last_seen.isoformat(),
|
||||
"measurement_state": status.measurement_state,
|
||||
"lp": status.lp,
|
||||
"leq": status.leq,
|
||||
"lmax": status.lmax,
|
||||
"lmin": status.lmin,
|
||||
"lpeak": status.lpeak,
|
||||
"battery_level": status.battery_level,
|
||||
"power_source": status.power_source,
|
||||
"sd_remaining_mb": status.sd_remaining_mb,
|
||||
"sd_free_ratio": status.sd_free_ratio,
|
||||
"raw_payload": status.raw_payload,
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"unit_id": unit_id,
|
||||
"last_seen": status.last_seen.isoformat(),
|
||||
"measurement_state": status.measurement_state,
|
||||
"lp": status.lp,
|
||||
"leq": status.leq,
|
||||
"lmax": status.lmax,
|
||||
"lmin": status.lmin,
|
||||
"lpeak": status.lpeak,
|
||||
"battery_level": status.battery_level,
|
||||
"power_source": status.power_source,
|
||||
"sd_remaining_mb": status.sd_remaining_mb,
|
||||
"sd_free_ratio": status.sd_free_ratio,
|
||||
"raw_payload": status.raw_payload,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{unit_id}/start")
|
||||
async def start_measurement(unit_id: str, db: Session = Depends(get_db)):
|
||||
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="NL43 config not found")
|
||||
|
||||
if not cfg.tcp_enabled:
|
||||
raise HTTPException(status_code=403, detail="TCP communication is disabled for this device")
|
||||
|
||||
client = NL43Client(cfg.host, cfg.tcp_port)
|
||||
try:
|
||||
await client.start()
|
||||
logger.info(f"Started measurement on unit {unit_id}")
|
||||
except ConnectionError as e:
|
||||
logger.error(f"Failed to start measurement on {unit_id}: {e}")
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with device")
|
||||
except TimeoutError:
|
||||
logger.error(f"Timeout starting measurement on {unit_id}")
|
||||
raise HTTPException(status_code=504, detail="Device communication timeout")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error starting measurement on {unit_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
return {"status": "ok", "message": "Measurement started"}
|
||||
|
||||
|
||||
@router.post("/{unit_id}/stop")
|
||||
async def stop_measurement(unit_id: str, db: Session = Depends(get_db)):
|
||||
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="NL43 config not found")
|
||||
|
||||
if not cfg.tcp_enabled:
|
||||
raise HTTPException(status_code=403, detail="TCP communication is disabled for this device")
|
||||
|
||||
client = NL43Client(cfg.host, cfg.tcp_port)
|
||||
try:
|
||||
await client.stop()
|
||||
logger.info(f"Stopped measurement on unit {unit_id}")
|
||||
except ConnectionError as e:
|
||||
logger.error(f"Failed to stop measurement on {unit_id}: {e}")
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with device")
|
||||
except TimeoutError:
|
||||
logger.error(f"Timeout stopping measurement on {unit_id}")
|
||||
raise HTTPException(status_code=504, detail="Device communication timeout")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error stopping measurement on {unit_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
return {"status": "ok", "message": "Measurement stopped"}
|
||||
|
||||
|
||||
@router.get("/{unit_id}/live")
|
||||
async def live_status(unit_id: str, db: Session = Depends(get_db)):
|
||||
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="NL43 config not found")
|
||||
|
||||
if not cfg.tcp_enabled:
|
||||
raise HTTPException(status_code=403, detail="TCP communication is disabled for this device")
|
||||
|
||||
client = NL43Client(cfg.host, cfg.tcp_port)
|
||||
try:
|
||||
snap = await client.request_dod()
|
||||
snap.unit_id = unit_id
|
||||
|
||||
# Persist snapshot with database session
|
||||
persist_snapshot(snap, db)
|
||||
|
||||
logger.info(f"Retrieved live status for unit {unit_id}")
|
||||
return {"status": "ok", "data": snap.__dict__}
|
||||
|
||||
except ConnectionError as e:
|
||||
logger.error(f"Failed to get live status for {unit_id}: {e}")
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with device")
|
||||
except TimeoutError:
|
||||
logger.error(f"Timeout getting live status for {unit_id}")
|
||||
raise HTTPException(status_code=504, detail="Device communication timeout")
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid response from device {unit_id}: {e}")
|
||||
raise HTTPException(status_code=502, detail="Device returned invalid data")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error getting live status for {unit_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
130
app/services.py
130
app/services.py
@@ -1,16 +1,23 @@
|
||||
"""
|
||||
Placeholder for NL43 TCP connector.
|
||||
Implement TCP session management, command serialization, and DOD/DRD parsing here,
|
||||
then call persist_snapshot to store the latest values.
|
||||
NL43 TCP connector and snapshot persistence.
|
||||
|
||||
Implements simple per-request TCP calls to avoid long-lived socket complexity.
|
||||
Extend to pooled connections/DRD streaming later.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db_session
|
||||
from app.models import NL43Status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NL43Snapshot:
|
||||
@@ -28,9 +35,8 @@ class NL43Snapshot:
|
||||
raw_payload: Optional[str] = None
|
||||
|
||||
|
||||
def persist_snapshot(s: NL43Snapshot):
|
||||
def persist_snapshot(s: NL43Snapshot, db: Session):
|
||||
"""Persist the latest snapshot for API/dashboard use."""
|
||||
db = get_db_session()
|
||||
try:
|
||||
row = db.query(NL43Status).filter_by(unit_id=s.unit_id).first()
|
||||
if not row:
|
||||
@@ -51,5 +57,113 @@ def persist_snapshot(s: NL43Snapshot):
|
||||
row.raw_payload = s.raw_payload
|
||||
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to persist snapshot for unit {s.unit_id}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# Rate limiting: NL43 requires ≥1 second between commands
|
||||
_last_command_time = {}
|
||||
_rate_limit_lock = asyncio.Lock()
|
||||
|
||||
|
||||
class NL43Client:
|
||||
def __init__(self, host: str, port: int, timeout: float = 5.0):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self.device_key = f"{host}:{port}"
|
||||
|
||||
async def _enforce_rate_limit(self):
|
||||
"""Ensure ≥1 second between commands to the same device."""
|
||||
async with _rate_limit_lock:
|
||||
last_time = _last_command_time.get(self.device_key, 0)
|
||||
elapsed = time.time() - last_time
|
||||
if elapsed < 1.0:
|
||||
wait_time = 1.0 - elapsed
|
||||
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s for {self.device_key}")
|
||||
await asyncio.sleep(wait_time)
|
||||
_last_command_time[self.device_key] = time.time()
|
||||
|
||||
async def _send_command(self, cmd: str) -> str:
|
||||
"""Send ASCII command to NL43 device via TCP."""
|
||||
await self._enforce_rate_limit()
|
||||
|
||||
logger.info(f"Sending command to {self.device_key}: {cmd.strip()}")
|
||||
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self.host, self.port), timeout=self.timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Connection timeout to {self.device_key}")
|
||||
raise ConnectionError(f"Failed to connect to device at {self.host}:{self.port}")
|
||||
except Exception as e:
|
||||
logger.error(f"Connection failed to {self.device_key}: {e}")
|
||||
raise ConnectionError(f"Failed to connect to device: {str(e)}")
|
||||
|
||||
try:
|
||||
writer.write(cmd.encode("ascii"))
|
||||
await writer.drain()
|
||||
data = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=self.timeout)
|
||||
response = data.decode(errors="ignore").strip()
|
||||
logger.debug(f"Received response from {self.device_key}: {response}")
|
||||
return response
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Response timeout from {self.device_key}")
|
||||
raise TimeoutError(f"Device did not respond within {self.timeout}s")
|
||||
except Exception as e:
|
||||
logger.error(f"Communication error with {self.device_key}: {e}")
|
||||
raise
|
||||
finally:
|
||||
writer.close()
|
||||
with contextlib.suppress(Exception):
|
||||
await writer.wait_closed()
|
||||
|
||||
async def request_dod(self) -> NL43Snapshot:
|
||||
"""Request DOD (Data Output Display) snapshot from device."""
|
||||
resp = await self._send_command("DOD?\r\n")
|
||||
|
||||
# Validate response format
|
||||
if not resp:
|
||||
logger.warning(f"Empty response from DOD command on {self.device_key}")
|
||||
raise ValueError("Device returned empty response to DOD? command")
|
||||
|
||||
# Remove leading $ prompt if present
|
||||
if resp.startswith("$"):
|
||||
resp = resp[1:].strip()
|
||||
|
||||
parts = [p.strip() for p in resp.split(",") if p.strip() != ""]
|
||||
|
||||
# DOD should return at least some data points
|
||||
if len(parts) < 2:
|
||||
logger.error(f"Malformed DOD response from {self.device_key}: {resp}")
|
||||
raise ValueError(f"Malformed DOD response: expected comma-separated values, got: {resp}")
|
||||
|
||||
logger.info(f"Parsed {len(parts)} data points from DOD response")
|
||||
|
||||
snap = NL43Snapshot(unit_id="", raw_payload=resp, measurement_state="Measure")
|
||||
|
||||
# Parse known positions (based on NL43 communication guide)
|
||||
try:
|
||||
if len(parts) >= 1:
|
||||
snap.lp = parts[0]
|
||||
if len(parts) >= 2:
|
||||
snap.leq = parts[1]
|
||||
if len(parts) >= 4:
|
||||
snap.lmax = parts[3]
|
||||
if len(parts) >= 5:
|
||||
snap.lmin = parts[4]
|
||||
if len(parts) >= 11:
|
||||
snap.lpeak = parts[10]
|
||||
except (IndexError, ValueError) as e:
|
||||
logger.warning(f"Error parsing DOD data points: {e}")
|
||||
|
||||
return snap
|
||||
|
||||
async def start(self):
|
||||
await self._send_command("$Measure, Start\r\n")
|
||||
|
||||
async def stop(self):
|
||||
await self._send_command("$Measure, Stop\r\n")
|
||||
|
||||
Reference in New Issue
Block a user