2 Commits

Author SHA1 Message Date
serversdown 51dd6b682d feat: surface LN1/LN2 (L1/L10) percentiles through SLMM
Completes the SLMM side of the L1/L10 live-display contract. The NL-43's
DOD response carries percentile slots LN1-LN5 (channel 1, parts[5]/[6]);
parse the first two and expose them as ln1/ln2 end to end:

- NL43Snapshot dataclass: ln1/ln2 fields
- NL43Status model: ln1/ln2 columns (+ migrate_add_ln_percentiles.py)
- DOD parser: snap.ln1=parts[5], snap.ln2=parts[6]
- persist_snapshot writes them
- all /status data dicts, StatusPayload, and the DRD stream payload emit
  ln1/ln2 (null on the DRD stream itself, which doesn't carry percentiles)

Labels: device LN1 defaults to L5, not L1 — Terra-View defaults the label
to L1/L10, so the device's Ln1/Ln2 slots must be set to 1%/10% for the
labels to be accurate (dynamic label emission is a follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:01:31 +00:00
serversdown a7983d2958 fix: correct DOD field parsing and stop measurement-time resets
Two device-data bugs surfaced while scoping the live-feed work:

1. DOD parser misalignment. DOD's response has no leading counter and
   includes LE + LN1-LN5, but the parser reused the DRD field map
   (parts[0]=counter). That shifted everything: Lp was stored as the
   counter, Leq as Lp, LE as Leq, and LN1 as Lpeak (visible because
   "Lpeak" came out below Lmax, which is impossible). Parse DOD with its
   own map: Lp=0, Leq=1, Lmax=3, Lmin=4, Lpeak=10 (channel 1 = main).

2. measurement_start_time reset on every live-stream open/close. The DOD
   path tags state "Start"; the DRD stream path tags "Measure". The
   transition detector treated only "Start" as measuring, so opening the
   stream ("Start"->"Measure") read as a stop (cleared start time) and
   closing it ("Measure"->"Start") read as a start (reset to now). Every
   viewer reset the elapsed measurement time. Treat {"Start","Measure"}
   both as measuring.

LN1/LN2 (L1/L10) parsing + model/serialization is the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:53:00 +00:00
4 changed files with 101 additions and 14 deletions
+2
View File
@@ -41,6 +41,8 @@ class NL43Status(Base):
lmax = Column(String, nullable=True) # Maximum level lmax = Column(String, nullable=True) # Maximum level
lmin = Column(String, nullable=True) # Minimum level lmin = Column(String, nullable=True) # Minimum level
lpeak = Column(String, nullable=True) # Peak level lpeak = Column(String, nullable=True) # Peak level
ln1 = Column(String, nullable=True) # Percentile slot LN1 (configurable; device default L5, contract L1)
ln2 = Column(String, nullable=True) # Percentile slot LN2 (configurable; device default L10)
battery_level = Column(String, nullable=True) battery_level = Column(String, nullable=True)
power_source = Column(String, nullable=True) power_source = Column(String, nullable=True)
sd_remaining_mb = Column(String, nullable=True) sd_remaining_mb = Column(String, nullable=True)
+10
View File
@@ -450,6 +450,8 @@ def get_status(unit_id: str, db: Session = Depends(get_db)):
"lmax": status.lmax, "lmax": status.lmax,
"lmin": status.lmin, "lmin": status.lmin,
"lpeak": status.lpeak, "lpeak": status.lpeak,
"ln1": status.ln1,
"ln2": status.ln2,
"battery_level": status.battery_level, "battery_level": status.battery_level,
"power_source": status.power_source, "power_source": status.power_source,
"sd_remaining_mb": status.sd_remaining_mb, "sd_remaining_mb": status.sd_remaining_mb,
@@ -472,6 +474,8 @@ class StatusPayload(BaseModel):
lmax: str | None = None lmax: str | None = None
lmin: str | None = None lmin: str | None = None
lpeak: str | None = None lpeak: str | None = None
ln1: str | None = None
ln2: str | None = None
battery_level: str | None = None battery_level: str | None = None
power_source: str | None = None power_source: str | None = None
sd_remaining_mb: str | None = None sd_remaining_mb: str | None = None
@@ -504,6 +508,8 @@ def upsert_status(unit_id: str, payload: StatusPayload, db: Session = Depends(ge
"lmax": status.lmax, "lmax": status.lmax,
"lmin": status.lmin, "lmin": status.lmin,
"lpeak": status.lpeak, "lpeak": status.lpeak,
"ln1": status.ln1,
"ln2": status.ln2,
"battery_level": status.battery_level, "battery_level": status.battery_level,
"power_source": status.power_source, "power_source": status.power_source,
"sd_remaining_mb": status.sd_remaining_mb, "sd_remaining_mb": status.sd_remaining_mb,
@@ -1205,6 +1211,8 @@ async def stream_live(websocket: WebSocket, unit_id: str):
"lmax": snap.lmax, # Maximum level "lmax": snap.lmax, # Maximum level
"lmin": snap.lmin, # Minimum level "lmin": snap.lmin, # Minimum level
"lpeak": snap.lpeak, # Peak level "lpeak": snap.lpeak, # Peak level
"ln1": snap.ln1, # LN1 percentile (L1/L10 contract); null on DRD stream
"ln2": snap.ln2, # LN2 percentile; null on DRD stream
"raw_payload": snap.raw_payload, "raw_payload": snap.raw_payload,
}) })
except Exception as e: except Exception as e:
@@ -1876,6 +1884,8 @@ async def run_diagnostics(unit_id: str, db: Session = Depends(get_db)):
"lmax": status.lmax, "lmax": status.lmax,
"lmin": status.lmin, "lmin": status.lmin,
"lpeak": status.lpeak, "lpeak": status.lpeak,
"ln1": status.ln1,
"ln2": status.ln2,
"battery_level": status.battery_level, "battery_level": status.battery_level,
"power_source": status.power_source, "power_source": status.power_source,
"sd_remaining_mb": status.sd_remaining_mb, "sd_remaining_mb": status.sd_remaining_mb,
+31 -14
View File
@@ -46,6 +46,8 @@ class NL43Snapshot:
lmax: Optional[str] = None # Maximum level lmax: Optional[str] = None # Maximum level
lmin: Optional[str] = None # Minimum level lmin: Optional[str] = None # Minimum level
lpeak: Optional[str] = None # Peak level lpeak: Optional[str] = None # Peak level
ln1: Optional[str] = None # Percentile slot LN1 (configurable; device default L5, contract L1)
ln2: Optional[str] = None # Percentile slot LN2 (configurable; device default L10)
battery_level: Optional[str] = None battery_level: Optional[str] = None
power_source: Optional[str] = None power_source: Optional[str] = None
sd_remaining_mb: Optional[str] = None sd_remaining_mb: Optional[str] = None
@@ -69,10 +71,16 @@ def persist_snapshot(s: NL43Snapshot, db: Session):
logger.info(f"State transition check for {s.unit_id}: '{previous_state}' -> '{new_state}'") logger.info(f"State transition check for {s.unit_id}: '{previous_state}' -> '{new_state}'")
# Device returns "Start" when measuring, "Stop" when stopped # The device reports "Start" while measuring; the DOD path uses that string,
# Normalize to previous behavior for backward compatibility # but the DRD stream path tags snapshots "Measure" (and the DOD fallback also
is_measuring = new_state == "Start" # uses "Measure"). Treat ALL of these as "measuring" — otherwise opening and
was_measuring = previous_state == "Start" # closing the live stream flips state "Start"->"Measure"->"Start", which the
# old equality check misread as stop-then-start and RESET measurement_start_time
# every single time (the "elapsed time keeps resetting / shows wrong value on
# another computer" bug — and each extra viewer made it worse).
MEASURING_STATES = {"Start", "Measure"}
is_measuring = new_state in MEASURING_STATES
was_measuring = previous_state in MEASURING_STATES
if not was_measuring and is_measuring: if not was_measuring and is_measuring:
# Measurement just started - record the start time # Measurement just started - record the start time
@@ -102,6 +110,8 @@ def persist_snapshot(s: NL43Snapshot, db: Session):
row.lmax = s.lmax row.lmax = s.lmax
row.lmin = s.lmin row.lmin = s.lmin
row.lpeak = s.lpeak row.lpeak = s.lpeak
row.ln1 = s.ln1
row.ln2 = s.ln2
row.battery_level = s.battery_level row.battery_level = s.battery_level
row.power_source = s.power_source row.power_source = s.power_source
row.sd_remaining_mb = s.sd_remaining_mb row.sd_remaining_mb = s.sd_remaining_mb
@@ -691,22 +701,29 @@ class NL43Client:
snap = NL43Snapshot(unit_id="", raw_payload=resp, measurement_state=measurement_state) snap = NL43Snapshot(unit_id="", raw_payload=resp, measurement_state=measurement_state)
# Parse known positions (based on NL43 communication guide - DRD format) # Parse DOD positional fields. DOD's layout is DIFFERENT from DRD: it has NO
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ... # leading counter and it includes LE plus LN1LN5. The device returns 4 channels
# of 16 fields each — [Lp, Leq, LE, Lmax, Lmin, LN1, LN2, LN3, LN4, LN5, Lpeak,
# LIeq, Leq_mov, Ltm5, over, under] — and channel 1 (parts[0:16]) is the main
# display. The previous code reused the DRD map (treating parts[0] as a counter),
# which shifted everything: Lp was reported as the counter, Leq as Lp, LE as Leq,
# and LN1 as Lpeak (you could spot it because "Lpeak" came out < Lmax).
try: try:
# Capture d0 (counter) for timer synchronization
if len(parts) >= 1: if len(parts) >= 1:
snap.counter = parts[0] # d0: Measurement interval counter (1-600) snap.lp = parts[0] # Lp: instantaneous sound pressure level
if len(parts) >= 2: if len(parts) >= 2:
snap.lp = parts[1] # d1: Instantaneous sound pressure level snap.leq = parts[1] # Leq: equivalent continuous level
if len(parts) >= 3: # parts[2] = LE (sound exposure level) — not currently surfaced
snap.leq = parts[2] # d2: Equivalent continuous sound level
if len(parts) >= 4: if len(parts) >= 4:
snap.lmax = parts[3] # d3: Maximum level snap.lmax = parts[3] # Lmax
if len(parts) >= 5: if len(parts) >= 5:
snap.lmin = parts[4] # d4: Minimum level snap.lmin = parts[4] # Lmin
if len(parts) >= 11:
snap.lpeak = parts[10] # Lpeak (parts[5] is LN1, NOT Lpeak)
if len(parts) >= 6: if len(parts) >= 6:
snap.lpeak = parts[5] # d5: Peak level snap.ln1 = parts[5] # LN1 percentile slot (device default L5; contract L1)
if len(parts) >= 7:
snap.ln2 = parts[6] # LN2 percentile slot (device default L10)
except (IndexError, ValueError) as e: except (IndexError, ValueError) as e:
logger.warning(f"Error parsing DOD data points: {e}") logger.warning(f"Error parsing DOD data points: {e}")
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""
Migration script to add ln1 and ln2 percentile columns to the nl43_status table.
The NL-43 DOD response carries percentile slots LN1-LN5; the live SLM display
(Terra-View) shows two of them (default L1/L10). This adds storage for the two
surfaced slots. Run once per database to update existing schema.
"""
import sqlite3
import sys
from pathlib import Path
DB_PATH = Path(__file__).parent / "data" / "slmm.db"
def migrate():
"""Add ln1 and ln2 columns to the nl43_status table."""
if not DB_PATH.exists():
print(f"Database not found at {DB_PATH}")
print("No migration needed - database will be created with new schema")
return
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
cursor.execute("PRAGMA table_info(nl43_status)")
columns = [row[1] for row in cursor.fetchall()]
if "ln1" in columns and "ln2" in columns:
print("✓ ln1/ln2 columns already exist, no migration needed")
return
if "ln1" not in columns:
print("Adding ln1 column...")
cursor.execute("ALTER TABLE nl43_status ADD COLUMN ln1 TEXT")
print("✓ Added ln1 column")
if "ln2" not in columns:
print("Adding ln2 column...")
cursor.execute("ALTER TABLE nl43_status ADD COLUMN ln2 TEXT")
print("✓ Added ln2 column")
conn.commit()
print("\n✓ Migration completed successfully!")
except Exception as e:
conn.rollback()
print(f"✗ Migration failed: {e}", file=sys.stderr)
sys.exit(1)
finally:
conn.close()
if __name__ == "__main__":
migrate()