diff --git a/README.md b/README.md index acc5c50..e9d4cdc 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ Caches latest measurement snapshot: - Uses active mode FTP (requires device to connect back) - TCP and FTP are mutually exclusive on the device - Credentials configurable per device +- **Default NL43 FTP Credentials**: Username: `USER`, Password: `0000` ### Data Formats @@ -241,8 +242,9 @@ curl -X PUT http://localhost:8100/api/nl43/meter-001/config \ "host": "192.168.1.100", "tcp_port": 2255, "tcp_enabled": true, - "ftp_username": "admin", - "ftp_password": "password" + "ftp_enabled": true, + "ftp_username": "USER", + "ftp_password": "0000" }' ``` diff --git a/app/models.py b/app/models.py index ce454f9..c86b4ca 100644 --- a/app/models.py +++ b/app/models.py @@ -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 diff --git a/app/routers.py b/app/routers.py index e9c5006..5d7cad4 100644 --- a/app/routers.py +++ b/app/routers.py @@ -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.""" diff --git a/app/services.py b/app/services.py index b582748..1137de2 100644 --- a/app/services.py +++ b/app/services.py @@ -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) diff --git a/migrate_add_counter.py b/migrate_add_counter.py new file mode 100755 index 0000000..cc5b905 --- /dev/null +++ b/migrate_add_counter.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Database migration: Add counter field to nl43_status table + +This adds the d0 (measurement interval counter) field to track the device's +actual measurement progress for accurate timer synchronization. +""" + +import sqlite3 +import sys + +DB_PATH = "data/slmm.db" + +def migrate(): + print(f"Adding counter field to: {DB_PATH}") + + try: + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Check if counter column already exists + cursor.execute("PRAGMA table_info(nl43_status)") + columns = [row[1] for row in cursor.fetchall()] + + if 'counter' in columns: + print("✓ Counter column already exists, no migration needed") + conn.close() + return + + print("Starting migration...") + + # Add counter column + cursor.execute(""" + ALTER TABLE nl43_status + ADD COLUMN counter TEXT + """) + + conn.commit() + print("✓ Added counter column") + + # Verify + cursor.execute("PRAGMA table_info(nl43_status)") + columns = [row[1] for row in cursor.fetchall()] + + if 'counter' not in columns: + raise Exception("Counter column was not added successfully") + + print("✓ Migration completed successfully") + + conn.close() + + except Exception as e: + print(f"✗ Migration failed: {e}") + sys.exit(1) + +if __name__ == "__main__": + migrate() diff --git a/migrate_add_measurement_start_time.py b/migrate_add_measurement_start_time.py new file mode 100644 index 0000000..0d8c45c --- /dev/null +++ b/migrate_add_measurement_start_time.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Database migration: Add measurement_start_time field to nl43_status table + +This tracks when a measurement session started by detecting the state transition +from "Stop" to "Measure", enabling accurate elapsed time display even for +manually-started measurements. +""" + +import sqlite3 +import sys + +DB_PATH = "data/slmm.db" + +def migrate(): + print(f"Adding measurement_start_time field to: {DB_PATH}") + + try: + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Check if measurement_start_time column already exists + cursor.execute("PRAGMA table_info(nl43_status)") + columns = [row[1] for row in cursor.fetchall()] + + if 'measurement_start_time' in columns: + print("✓ measurement_start_time column already exists, no migration needed") + conn.close() + return + + print("Starting migration...") + + # Add measurement_start_time column + cursor.execute(""" + ALTER TABLE nl43_status + ADD COLUMN measurement_start_time TEXT + """) + + conn.commit() + print("✓ Added measurement_start_time column") + + # Verify + cursor.execute("PRAGMA table_info(nl43_status)") + columns = [row[1] for row in cursor.fetchall()] + + if 'measurement_start_time' not in columns: + raise Exception("measurement_start_time column was not added successfully") + + print("✓ Migration completed successfully") + + conn.close() + + except Exception as e: + print(f"✗ Migration failed: {e}") + sys.exit(1) + +if __name__ == "__main__": + migrate() diff --git a/migrate_field_names.py b/migrate_field_names.py new file mode 100644 index 0000000..890dbb0 --- /dev/null +++ b/migrate_field_names.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Migration script to rename NL43 measurement field names to match actual device output. + +Changes: +- lp -> laeq (A-weighted equivalent continuous sound level) +- leq -> lae (A-weighted sound exposure level) +- lmax -> lasmax (A-weighted slow maximum) +- lmin -> lasmin (A-weighted slow minimum) +- lpeak -> lapeak (A-weighted peak) +""" + +import sqlite3 +import sys +from pathlib import Path + +def migrate_database(db_path: str): + """Migrate the database schema to use correct field names.""" + + print(f"Migrating database: {db_path}") + + # Connect to database + conn = sqlite3.connect(db_path) + cur = conn.cursor() + + try: + # Check if migration is needed + cur.execute("PRAGMA table_info(nl43_status)") + columns = [row[1] for row in cur.fetchall()] + + if 'laeq' in columns: + print("✓ Database already migrated") + return + + if 'lp' not in columns: + print("✗ Database schema does not match expected format") + sys.exit(1) + + print("Starting migration...") + + # SQLite doesn't support column renaming directly, so we need to: + # 1. Create new table with correct column names + # 2. Copy data from old table + # 3. Drop old table + # 4. Rename new table + + # Create new table with correct column names + cur.execute(""" + CREATE TABLE nl43_status_new ( + unit_id VARCHAR PRIMARY KEY, + last_seen DATETIME, + measurement_state VARCHAR, + laeq VARCHAR, + lae VARCHAR, + lasmax VARCHAR, + lasmin VARCHAR, + lapeak VARCHAR, + battery_level VARCHAR, + power_source VARCHAR, + sd_remaining_mb VARCHAR, + sd_free_ratio VARCHAR, + raw_payload TEXT + ) + """) + print("✓ Created new table with correct column names") + + # Copy data from old table to new table + cur.execute(""" + INSERT INTO nl43_status_new + (unit_id, last_seen, measurement_state, laeq, lae, lasmax, lasmin, lapeak, + battery_level, power_source, sd_remaining_mb, sd_free_ratio, raw_payload) + SELECT + unit_id, last_seen, measurement_state, lp, leq, lmax, lmin, lpeak, + battery_level, power_source, sd_remaining_mb, sd_free_ratio, raw_payload + FROM nl43_status + """) + rows_copied = cur.rowcount + print(f"✓ Copied {rows_copied} rows from old table") + + # Drop old table + cur.execute("DROP TABLE nl43_status") + print("✓ Dropped old table") + + # Rename new table + cur.execute("ALTER TABLE nl43_status_new RENAME TO nl43_status") + print("✓ Renamed new table to nl43_status") + + # Commit changes + conn.commit() + print("✓ Migration completed successfully") + + except Exception as e: + conn.rollback() + print(f"✗ Migration failed: {e}") + sys.exit(1) + finally: + conn.close() + +if __name__ == "__main__": + # Default database path + db_path = Path(__file__).parent / "data" / "slmm.db" + + # Allow custom path as command line argument + if len(sys.argv) > 1: + db_path = Path(sys.argv[1]) + + if not db_path.exists(): + print(f"✗ Database not found: {db_path}") + sys.exit(1) + + migrate_database(str(db_path))