Added: Ability to change store name and overwrite protection
This commit is contained in:
@@ -224,6 +224,7 @@ Caches latest measurement snapshot:
|
|||||||
- Uses active mode FTP (requires device to connect back)
|
- Uses active mode FTP (requires device to connect back)
|
||||||
- TCP and FTP are mutually exclusive on the device
|
- TCP and FTP are mutually exclusive on the device
|
||||||
- Credentials configurable per device
|
- Credentials configurable per device
|
||||||
|
- **Default NL43 FTP Credentials**: Username: `USER`, Password: `0000`
|
||||||
|
|
||||||
### Data Formats
|
### Data Formats
|
||||||
|
|
||||||
@@ -241,8 +242,9 @@ curl -X PUT http://localhost:8100/api/nl43/meter-001/config \
|
|||||||
"host": "192.168.1.100",
|
"host": "192.168.1.100",
|
||||||
"tcp_port": 2255,
|
"tcp_port": 2255,
|
||||||
"tcp_enabled": true,
|
"tcp_enabled": true,
|
||||||
"ftp_username": "admin",
|
"ftp_enabled": true,
|
||||||
"ftp_password": "password"
|
"ftp_username": "USER",
|
||||||
|
"ftp_password": "0000"
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class NL43Status(Base):
|
|||||||
unit_id = Column(String, primary_key=True, index=True)
|
unit_id = Column(String, primary_key=True, index=True)
|
||||||
last_seen = Column(DateTime, default=func.now())
|
last_seen = Column(DateTime, default=func.now())
|
||||||
measurement_state = Column(String, default="unknown") # Measure/Stop
|
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
|
lp = Column(String, nullable=True) # Instantaneous sound pressure level
|
||||||
leq = Column(String, nullable=True) # Equivalent continuous sound level
|
leq = Column(String, nullable=True) # Equivalent continuous sound level
|
||||||
lmax = Column(String, nullable=True) # Maximum level
|
lmax = Column(String, nullable=True) # Maximum level
|
||||||
|
|||||||
130
app/routers.py
130
app/routers.py
@@ -7,6 +7,7 @@ import logging
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import NL43Config, NL43Status
|
from app.models import NL43Config, NL43Status
|
||||||
@@ -195,6 +196,24 @@ async def start_measurement(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
try:
|
try:
|
||||||
await client.start()
|
await client.start()
|
||||||
logger.info(f"Started measurement on unit {unit_id}")
|
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:
|
except ConnectionError as e:
|
||||||
logger.error(f"Failed to start measurement on {unit_id}: {e}")
|
logger.error(f"Failed to start measurement on {unit_id}: {e}")
|
||||||
raise HTTPException(status_code=502, detail="Failed to communicate with device")
|
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:
|
try:
|
||||||
await client.stop()
|
await client.stop()
|
||||||
logger.info(f"Stopped measurement on unit {unit_id}")
|
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:
|
except ConnectionError as e:
|
||||||
logger.error(f"Failed to stop measurement on {unit_id}: {e}")
|
logger.error(f"Failed to stop measurement on {unit_id}: {e}")
|
||||||
raise HTTPException(status_code=502, detail="Failed to communicate with device")
|
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 with database session
|
||||||
persist_snapshot(snap, db)
|
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}")
|
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:
|
except ConnectionError as e:
|
||||||
logger.error(f"Failed to get live status for {unit_id}: {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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to persist snapshot during stream: {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
|
# Send to WebSocket client
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
"measurement_state": snap.measurement_state,
|
"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
|
"lp": snap.lp, # Instantaneous sound pressure level
|
||||||
"leq": snap.leq, # Equivalent continuous sound level
|
"leq": snap.leq, # Equivalent continuous sound level
|
||||||
"lmax": snap.lmax, # Maximum 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)}")
|
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")
|
@router.get("/{unit_id}/settings")
|
||||||
async def get_all_settings(unit_id: str, db: Session = Depends(get_db)):
|
async def get_all_settings(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""Get all current device settings for verification.
|
"""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))
|
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")
|
@router.get("/{unit_id}/settings/all")
|
||||||
async def get_all_settings(unit_id: str, db: Session = Depends(get_db)):
|
async def get_all_settings(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""Get all device settings for verification."""
|
"""Get all device settings for verification."""
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ logger = logging.getLogger(__name__)
|
|||||||
class NL43Snapshot:
|
class NL43Snapshot:
|
||||||
unit_id: str
|
unit_id: str
|
||||||
measurement_state: str = "unknown"
|
measurement_state: str = "unknown"
|
||||||
|
counter: Optional[str] = None # d0: Measurement interval counter (1-600)
|
||||||
lp: Optional[str] = None # Instantaneous sound pressure level
|
lp: Optional[str] = None # Instantaneous sound pressure level
|
||||||
leq: Optional[str] = None # Equivalent continuous sound level
|
leq: Optional[str] = None # Equivalent continuous sound level
|
||||||
lmax: Optional[str] = None # Maximum level
|
lmax: Optional[str] = None # Maximum level
|
||||||
@@ -46,7 +47,29 @@ def persist_snapshot(s: NL43Snapshot, db: Session):
|
|||||||
db.add(row)
|
db.add(row)
|
||||||
|
|
||||||
row.last_seen = datetime.utcnow()
|
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.lp = s.lp
|
||||||
row.leq = s.leq
|
row.leq = s.leq
|
||||||
row.lmax = s.lmax
|
row.lmax = s.lmax
|
||||||
@@ -124,7 +147,7 @@ class NL43Client:
|
|||||||
if result_code.startswith("$"):
|
if result_code.startswith("$"):
|
||||||
result_code = result_code[1:].strip()
|
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
|
# Check result code
|
||||||
if result_code == "R+0000":
|
if result_code == "R+0000":
|
||||||
@@ -186,12 +209,21 @@ class NL43Client:
|
|||||||
|
|
||||||
logger.info(f"Parsed {len(parts)} data points from DOD response")
|
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)
|
# 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, ...
|
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ...
|
||||||
try:
|
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:
|
if len(parts) >= 2:
|
||||||
snap.lp = parts[1] # d1: Instantaneous sound pressure level
|
snap.lp = parts[1] # d1: Instantaneous sound pressure level
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
@@ -443,7 +475,9 @@ class NL43Client:
|
|||||||
# Parse known positions (DRD format - same as DOD)
|
# Parse known positions (DRD format - same as DOD)
|
||||||
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ...
|
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ...
|
||||||
try:
|
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:
|
if len(parts) >= 2:
|
||||||
snap.lp = parts[1] # d1: Instantaneous sound pressure level
|
snap.lp = parts[1] # d1: Instantaneous sound pressure level
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
@@ -533,22 +567,36 @@ class NL43Client:
|
|||||||
return resp.strip()
|
return resp.strip()
|
||||||
|
|
||||||
async def set_index_number(self, index: int):
|
async def set_index_number(self, index: int):
|
||||||
"""Set index number for file numbering.
|
"""Set index number for file numbering (Store Name).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
index: Index number (0000-9999)
|
index: Index number (0000-9999)
|
||||||
"""
|
"""
|
||||||
if not 0 <= index <= 9999:
|
if not 0 <= index <= 9999:
|
||||||
raise ValueError("Index must be between 0000 and 9999")
|
raise ValueError("Index must be between 0000 and 9999")
|
||||||
await self._send_command(f"Index Number,{index:04d}\r\n")
|
await self._send_command(f"Store Name,{index:04d}\r\n")
|
||||||
logger.info(f"Set index number to {index:04d} on {self.device_key}")
|
logger.info(f"Set store name (index) to {index:04d} on {self.device_key}")
|
||||||
|
|
||||||
async def get_index_number(self) -> str:
|
async def get_index_number(self) -> str:
|
||||||
"""Get current index number.
|
"""Get current index number (Store Name).
|
||||||
|
|
||||||
Returns: Current index number
|
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()
|
return resp.strip()
|
||||||
|
|
||||||
async def get_all_settings(self) -> dict:
|
async def get_all_settings(self) -> dict:
|
||||||
@@ -690,11 +738,36 @@ class NL43Client:
|
|||||||
if name in ('.', '..'):
|
if name in ('.', '..'):
|
||||||
continue
|
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 = {
|
file_info = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"path": f"{remote_path.rstrip('/')}/{name}",
|
"path": f"{remote_path.rstrip('/')}/{name}",
|
||||||
"size": size,
|
"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,
|
"is_dir": is_dir,
|
||||||
}
|
}
|
||||||
files.append(file_info)
|
files.append(file_info)
|
||||||
|
|||||||
57
migrate_add_counter.py
Executable file
57
migrate_add_counter.py
Executable file
@@ -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()
|
||||||
58
migrate_add_measurement_start_time.py
Normal file
58
migrate_add_measurement_start_time.py
Normal file
@@ -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()
|
||||||
111
migrate_field_names.py
Normal file
111
migrate_field_names.py
Normal file
@@ -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))
|
||||||
Reference in New Issue
Block a user