Added: Ability to change store name and overwrite protection

This commit is contained in:
serversdwn
2026-01-08 19:16:59 +00:00
parent 1fb786c262
commit 6b363b0788
7 changed files with 445 additions and 14 deletions

View File

@@ -29,6 +29,8 @@ class NL43Status(Base):
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

View File

@@ -7,6 +7,7 @@ import logging
import ipaddress
import json
import os
import asyncio
from app.database import get_db
from app.models import NL43Config, NL43Status
@@ -195,6 +196,24 @@ async def start_measurement(unit_id: str, db: Session = Depends(get_db)):
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")
@@ -220,6 +239,12 @@ async def stop_measurement(unit_id: str, db: Session = Depends(get_db)):
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")
@@ -560,8 +585,18 @@ async def live_status(unit_id: str, db: Session = Depends(get_db)):
# 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": snap.__dict__}
return {"status": "ok", "data": response_data}
except ConnectionError as e:
logger.error(f"Failed to get live status for {unit_id}: {e}")
@@ -646,12 +681,23 @@ async def stream_live(websocket: WebSocket, unit_id: str):
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
@@ -749,6 +795,56 @@ async def get_ftp_status(unit_id: str, db: Session = Depends(get_db)):
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.
@@ -1016,6 +1112,38 @@ async def set_index_number(unit_id: str, payload: IndexPayload, db: Session = De
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."""

View File

@@ -25,6 +25,7 @@ logger = logging.getLogger(__name__)
class NL43Snapshot:
unit_id: str
measurement_state: str = "unknown"
counter: Optional[str] = None # d0: Measurement interval counter (1-600)
lp: Optional[str] = None # Instantaneous sound pressure level
leq: Optional[str] = None # Equivalent continuous sound level
lmax: Optional[str] = None # Maximum level
@@ -46,7 +47,29 @@ def persist_snapshot(s: NL43Snapshot, db: Session):
db.add(row)
row.last_seen = datetime.utcnow()
row.measurement_state = s.measurement_state
# Track measurement start time by detecting state transition
previous_state = row.measurement_state
new_state = s.measurement_state
logger.info(f"State transition check for {s.unit_id}: '{previous_state}' -> '{new_state}'")
# Device returns "Start" when measuring, "Stop" when stopped
# Normalize to previous behavior for backward compatibility
is_measuring = new_state == "Start"
was_measuring = previous_state == "Start"
if not was_measuring and is_measuring:
# Measurement just started - record the start time
row.measurement_start_time = datetime.utcnow()
logger.info(f"✓ Measurement started on {s.unit_id} at {row.measurement_start_time}")
elif was_measuring and not is_measuring:
# Measurement stopped - clear the start time
row.measurement_start_time = None
logger.info(f"✓ Measurement stopped on {s.unit_id}")
row.measurement_state = new_state
row.counter = s.counter
row.lp = s.lp
row.leq = s.leq
row.lmax = s.lmax
@@ -124,7 +147,7 @@ class NL43Client:
if result_code.startswith("$"):
result_code = result_code[1:].strip()
logger.debug(f"Result code from {self.device_key}: {result_code}")
logger.info(f"Result code from {self.device_key}: {result_code}")
# Check result code
if result_code == "R+0000":
@@ -186,12 +209,21 @@ class NL43Client:
logger.info(f"Parsed {len(parts)} data points from DOD response")
snap = NL43Snapshot(unit_id="", raw_payload=resp, measurement_state="Measure")
# Query actual measurement state (DOD doesn't include this information)
try:
measurement_state = await self.get_measurement_state()
except Exception as e:
logger.warning(f"Failed to get measurement state, defaulting to 'Measure': {e}")
measurement_state = "Measure"
snap = NL43Snapshot(unit_id="", raw_payload=resp, measurement_state=measurement_state)
# Parse known positions (based on NL43 communication guide - DRD format)
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ...
try:
# Skip d0 (counter) - start from d1
# Capture d0 (counter) for timer synchronization
if len(parts) >= 1:
snap.counter = parts[0] # d0: Measurement interval counter (1-600)
if len(parts) >= 2:
snap.lp = parts[1] # d1: Instantaneous sound pressure level
if len(parts) >= 3:
@@ -443,7 +475,9 @@ class NL43Client:
# Parse known positions (DRD format - same as DOD)
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ...
try:
# Skip d0 (counter) - start from d1
# Capture d0 (counter) for timer synchronization
if len(parts) >= 1:
snap.counter = parts[0] # d0: Measurement interval counter (1-600)
if len(parts) >= 2:
snap.lp = parts[1] # d1: Instantaneous sound pressure level
if len(parts) >= 3:
@@ -533,22 +567,36 @@ class NL43Client:
return resp.strip()
async def set_index_number(self, index: int):
"""Set index number for file numbering.
"""Set index number for file numbering (Store Name).
Args:
index: Index number (0000-9999)
"""
if not 0 <= index <= 9999:
raise ValueError("Index must be between 0000 and 9999")
await self._send_command(f"Index Number,{index:04d}\r\n")
logger.info(f"Set index number to {index:04d} on {self.device_key}")
await self._send_command(f"Store Name,{index:04d}\r\n")
logger.info(f"Set store name (index) to {index:04d} on {self.device_key}")
async def get_index_number(self) -> str:
"""Get current index number.
"""Get current index number (Store Name).
Returns: Current index number
"""
resp = await self._send_command("Index Number?\r\n")
resp = await self._send_command("Store Name?\r\n")
return resp.strip()
async def get_overwrite_status(self) -> str:
"""Check if saved data exists at current store target.
This command checks whether saved data exists in the set store target
(store mode / store name / store address). Use this before storing
to prevent accidentally overwriting data.
Returns:
"None" - No data exists (safe to store)
"Exist" - Data exists (would overwrite)
"""
resp = await self._send_command("Overwrite?\r\n")
return resp.strip()
async def get_all_settings(self) -> dict:
@@ -690,11 +738,36 @@ class NL43Client:
if name in ('.', '..'):
continue
# Parse modification time
# Format: "Jan 07 14:23" or "Dec 25 2025"
modified_str = f"{parts[5]} {parts[6]} {parts[7]}"
modified_timestamp = None
try:
from datetime import datetime
# Try parsing with time (recent files: "Jan 07 14:23")
try:
dt = datetime.strptime(modified_str, "%b %d %H:%M")
# Add current year since it's not in the format
dt = dt.replace(year=datetime.now().year)
# If the resulting date is in the future, it's actually from last year
if dt > datetime.now():
dt = dt.replace(year=dt.year - 1)
modified_timestamp = dt.isoformat()
except ValueError:
# Try parsing with year (older files: "Dec 25 2025")
dt = datetime.strptime(modified_str, "%b %d %Y")
modified_timestamp = dt.isoformat()
except Exception as e:
logger.warning(f"Failed to parse timestamp '{modified_str}': {e}")
file_info = {
"name": name,
"path": f"{remote_path.rstrip('/')}/{name}",
"size": size,
"modified": f"{parts[5]} {parts[6]} {parts[7]}",
"modified": modified_str, # Keep original string
"modified_timestamp": modified_timestamp, # Add parsed timestamp
"is_dir": is_dir,
}
files.append(file_info)