Add communication guide and project improvements documentation; enhance main app with logging, CORS configuration, and health check endpoints; implement input validation and error handling in routers; improve services with rate limiting and snapshot persistence; update models for SQLAlchemy best practices; create index.html for frontend interaction.

This commit is contained in:
serversdwn
2025-12-23 19:24:14 +00:00
parent 5c4722267f
commit dac731f912
15 changed files with 886 additions and 53 deletions

View File

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