feat: standardize device type for Sound Level Meters (SLM)

- Updated all instances of device_type from "sound_level_meter" to "slm" across the codebase.
- Enhanced documentation to reflect the new device type standardization.
- Added migration script to convert legacy device types in the database.
- Updated relevant API endpoints, models, and frontend templates to use the new device type.
- Ensured backward compatibility by deprecating the old device type without data loss.
This commit is contained in:
serversdwn
2026-01-16 18:31:27 +00:00
parent 6c7ce5aad0
commit 1ef0557ccb
22 changed files with 488 additions and 72 deletions

View File

@@ -308,7 +308,7 @@ print(response.json())
|-------|------|-------------| |-------|------|-------------|
| id | string | Unit identifier (primary key) | | id | string | Unit identifier (primary key) |
| unit_type | string | Hardware model name (default: `series3`) | | unit_type | string | Hardware model name (default: `series3`) |
| device_type | string | `seismograph` or `modem` discriminator | | device_type | string | Device type: `"seismograph"`, `"modem"`, or `"slm"` (sound level meter) |
| deployed | boolean | Whether the unit is in the field | | deployed | boolean | Whether the unit is in the field |
| retired | boolean | Removes the unit from deployments but preserves history | | retired | boolean | Removes the unit from deployments but preserves history |
| note | string | Notes about the unit | | note | string | Notes about the unit |
@@ -334,6 +334,39 @@ print(response.json())
| phone_number | string | Cellular number for the modem | | phone_number | string | Cellular number for the modem |
| hardware_model | string | Modem hardware reference | | hardware_model | string | Modem hardware reference |
**Sound Level Meter (SLM) fields**
| Field | Type | Description |
|-------|------|-------------|
| slm_host | string | Direct IP address for SLM (if not using modem) |
| slm_tcp_port | integer | TCP control port (default: 2255) |
| slm_ftp_port | integer | FTP file transfer port (default: 21) |
| slm_model | string | Device model (NL-43, NL-53) |
| slm_serial_number | string | Manufacturer serial number |
| slm_frequency_weighting | string | Frequency weighting setting (A, C, Z) |
| slm_time_weighting | string | Time weighting setting (F=Fast, S=Slow) |
| slm_measurement_range | string | Measurement range setting |
| slm_last_check | datetime | Last status check timestamp |
| deployed_with_modem_id | string | Modem pairing (shared with seismographs) |
### Device Type Schema
Terra-View supports three device types with the following standardized `device_type` values:
- **`"seismograph"`** (default) - Seismic monitoring devices (Series 3, Series 4, Micromate)
- Uses: calibration dates, modem pairing
- Examples: BE1234, UM12345 (Series 3/4 units)
- **`"modem"`** - Field modems and network equipment
- Uses: IP address, phone number, hardware model
- Examples: MDM001, MODEM-2025-01
- **`"slm"`** - Sound level meters (Rion NL-43/NL-53)
- Uses: TCP/FTP configuration, measurement settings, modem pairing
- Examples: SLM-43-01, NL43-001
**Important**: All `device_type` values must be lowercase. The legacy value `"sound_level_meter"` has been deprecated in favor of the shorter `"slm"`. Run `backend/migrate_standardize_device_types.py` to update existing databases.
### Emitter Table (Device Check-ins) ### Emitter Table (Device Check-ins)
| Field | Type | Description | | Field | Type | Description |

View File

@@ -111,26 +111,6 @@ async def startup_event():
await start_scheduler() await start_scheduler()
logger.info("Scheduler service started") logger.info("Scheduler service started")
# Sync all SLMs to SLMM on startup
logger.info("Syncing SLM devices to SLMM...")
try:
from backend.services.slmm_sync import sync_all_slms_to_slmm, cleanup_orphaned_slmm_devices
from backend.database import SessionLocal
db = SessionLocal()
try:
# Sync all SLMs from roster to SLMM
sync_results = await sync_all_slms_to_slmm(db)
logger.info(f"SLM sync complete: {sync_results}")
# Clean up orphaned devices in SLMM
cleanup_results = await cleanup_orphaned_slmm_devices(db)
logger.info(f"SLMM cleanup complete: {cleanup_results}")
finally:
db.close()
except Exception as e:
logger.error(f"Error syncing SLMs to SLMM on startup: {e}")
@app.on_event("shutdown") @app.on_event("shutdown")
def shutdown_event(): def shutdown_event():
"""Clean up services on app shutdown""" """Clean up services on app shutdown"""

View File

@@ -71,7 +71,7 @@ def migrate():
print("\n○ No migration needed - all columns already exist.") print("\n○ No migration needed - all columns already exist.")
print("\nSound level meter fields are now available in the roster table.") print("\nSound level meter fields are now available in the roster table.")
print("You can now set device_type='sound_level_meter' for SLM devices.") print("Note: Use device_type='slm' for Sound Level Meters. Legacy 'sound_level_meter' has been deprecated.")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,106 @@
"""
Database Migration: Standardize device_type values
This migration ensures all device_type values follow the official schema:
- "seismograph" - Seismic monitoring devices
- "modem" - Field modems and network equipment
- "slm" - Sound level meters (NL-43/NL-53)
Changes:
- Converts "sound_level_meter""slm"
- Safe to run multiple times (idempotent)
- No data loss
Usage:
python backend/migrate_standardize_device_types.py
"""
import sys
import os
# Add parent directory to path so we can import backend modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# Database configuration
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def migrate():
"""Standardize device_type values in the database"""
db = SessionLocal()
try:
print("=" * 70)
print("Database Migration: Standardize device_type values")
print("=" * 70)
print()
# Check for existing "sound_level_meter" values
result = db.execute(
text("SELECT COUNT(*) as count FROM roster WHERE device_type = 'sound_level_meter'")
).fetchone()
count_to_migrate = result[0] if result else 0
if count_to_migrate == 0:
print("✓ No records need migration - all device_type values are already standardized")
print()
print("Current device_type distribution:")
# Show distribution
distribution = db.execute(
text("SELECT device_type, COUNT(*) as count FROM roster GROUP BY device_type ORDER BY count DESC")
).fetchall()
for row in distribution:
device_type, count = row
print(f" - {device_type}: {count} units")
print()
print("Migration not needed.")
return
print(f"Found {count_to_migrate} record(s) with device_type='sound_level_meter'")
print()
print("Converting 'sound_level_meter''slm'...")
# Perform the migration
db.execute(
text("UPDATE roster SET device_type = 'slm' WHERE device_type = 'sound_level_meter'")
)
db.commit()
print(f"✓ Successfully migrated {count_to_migrate} record(s)")
print()
# Show final distribution
print("Updated device_type distribution:")
distribution = db.execute(
text("SELECT device_type, COUNT(*) as count FROM roster GROUP BY device_type ORDER BY count DESC")
).fetchall()
for row in distribution:
device_type, count = row
print(f" - {device_type}: {count} units")
print()
print("=" * 70)
print("Migration completed successfully!")
print("=" * 70)
except Exception as e:
db.rollback()
print(f"\n❌ Error during migration: {e}")
print("\nRolling back changes...")
raise
finally:
db.close()
if __name__ == "__main__":
migrate()

View File

@@ -19,14 +19,17 @@ class RosterUnit(Base):
Roster table: represents our *intended assignment* of a unit. Roster table: represents our *intended assignment* of a unit.
This is editable from the GUI. This is editable from the GUI.
Supports multiple device types (seismograph, modem, sound_level_meter) with type-specific fields. Supports multiple device types with type-specific fields:
- "seismograph" - Seismic monitoring devices (default)
- "modem" - Field modems and network equipment
- "slm" - Sound level meters (NL-43/NL-53)
""" """
__tablename__ = "roster" __tablename__ = "roster"
# Core fields (all device types) # Core fields (all device types)
id = Column(String, primary_key=True, index=True) id = Column(String, primary_key=True, index=True)
unit_type = Column(String, default="series3") # Backward compatibility unit_type = Column(String, default="series3") # Backward compatibility
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "sound_level_meter" device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
deployed = Column(Boolean, default=True) deployed = Column(Boolean, default=True)
retired = Column(Boolean, default=False) retired = Column(Boolean, default=False)
note = Column(String, nullable=True) note = Column(String, nullable=True)
@@ -197,7 +200,7 @@ class UnitAssignment(Base):
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
# Denormalized for efficient queries # Denormalized for efficient queries
device_type = Column(String, nullable=False) # sound_level_meter | seismograph device_type = Column(String, nullable=False) # "slm" | "seismograph"
project_id = Column(String, nullable=False, index=True) # FK to Project.id project_id = Column(String, nullable=False, index=True) # FK to Project.id
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
@@ -216,7 +219,7 @@ class ScheduledAction(Base):
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable if location-based) unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable if location-based)
action_type = Column(String, nullable=False) # start, stop, download, calibrate action_type = Column(String, nullable=False) # start, stop, download, calibrate
device_type = Column(String, nullable=False) # sound_level_meter | seismograph device_type = Column(String, nullable=False) # "slm" | "seismograph"
scheduled_time = Column(DateTime, nullable=False, index=True) scheduled_time = Column(DateTime, nullable=False, index=True)
executed_at = Column(DateTime, nullable=True) executed_at = Column(DateTime, nullable=True)

View File

@@ -273,7 +273,7 @@ async def assign_unit_to_location(
raise HTTPException(status_code=404, detail="Unit not found") raise HTTPException(status_code=404, detail="Unit not found")
# Check device type matches location type # Check device type matches location type
expected_device_type = "sound_level_meter" if location.location_type == "sound" else "seismograph" expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
if unit.device_type != expected_device_type: if unit.device_type != expected_device_type:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@@ -375,7 +375,7 @@ async def get_available_units(
Filters by device type matching the location type. Filters by device type matching the location type.
""" """
# Determine required device type # Determine required device type
required_device_type = "sound_level_meter" if location_type == "sound" else "seismograph" required_device_type = "slm" if location_type == "sound" else "seismograph"
# Get all units of the required type that are deployed and not retired # Get all units of the required type that are deployed and not retired
all_units = db.query(RosterUnit).filter( all_units = db.query(RosterUnit).filter(
@@ -397,7 +397,7 @@ async def get_available_units(
"id": unit.id, "id": unit.id,
"device_type": unit.device_type, "device_type": unit.device_type,
"location": unit.address or unit.location, "location": unit.address or unit.location,
"model": unit.slm_model if unit.device_type == "sound_level_meter" else unit.unit_type, "model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
} }
for unit in all_units for unit in all_units
if unit.id not in assigned_unit_ids if unit.id not in assigned_unit_ids

View File

@@ -549,7 +549,7 @@ async def get_ftp_browser(
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first() location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
# Only include SLM units # Only include SLM units
if unit and unit.device_type == "sound_level_meter": if unit and unit.device_type == "slm":
units_data.append({ units_data.append({
"assignment": assignment, "assignment": assignment,
"unit": unit, "unit": unit,

View File

@@ -223,7 +223,7 @@ async def add_roster_unit(
db.commit() db.commit()
# If sound level meter, sync config to SLMM cache # If sound level meter, sync config to SLMM cache
if device_type == "sound_level_meter": if device_type == "slm":
logger.info(f"Syncing SLM {id} config to SLMM cache...") logger.info(f"Syncing SLM {id} config to SLMM cache...")
result = await sync_slm_to_slmm_cache( result = await sync_slm_to_slmm_cache(
unit_id=id, unit_id=id,

View File

@@ -106,7 +106,7 @@ async def rename_unit(
db.commit() db.commit()
# If sound level meter, sync updated config to SLMM cache # If sound level meter, sync updated config to SLMM cache
if device_type == "sound_level_meter": if device_type == "slm":
logger.info(f"Syncing renamed SLM {new_id} (was {old_id}) config to SLMM cache...") logger.info(f"Syncing renamed SLM {new_id} (was {old_id}) config to SLMM cache...")
result = await sync_slm_to_slmm_cache( result = await sync_slm_to_slmm_cache(
unit_id=new_id, unit_id=new_id,

View File

@@ -131,7 +131,7 @@ async def create_scheduled_action(
raise HTTPException(status_code=404, detail="Location not found") raise HTTPException(status_code=404, detail="Location not found")
# Determine device type from location # Determine device type from location
device_type = "sound_level_meter" if location.location_type == "sound" else "seismograph" device_type = "slm" if location.location_type == "sound" else "seismograph"
# Get unit_id (optional - can be determined from assignment at execution time) # Get unit_id (optional - can be determined from assignment at execution time)
unit_id = form_data.get("unit_id") unit_id = form_data.get("unit_id")
@@ -188,7 +188,7 @@ async def schedule_recording_session(
if not location: if not location:
raise HTTPException(status_code=404, detail="Location not found") raise HTTPException(status_code=404, detail="Location not found")
device_type = "sound_level_meter" if location.location_type == "sound" else "seismograph" device_type = "slm" if location.location_type == "sound" else "seismograph"
unit_id = form_data.get("unit_id") unit_id = form_data.get("unit_id")
start_time = datetime.fromisoformat(form_data.get("start_time")) start_time = datetime.fromisoformat(form_data.get("start_time"))

View File

@@ -35,7 +35,7 @@ async def get_slm_stats(request: Request, db: Session = Depends(get_db)):
Returns HTML partial with stat cards. Returns HTML partial with stat cards.
""" """
# Query all SLMs # Query all SLMs
all_slms = db.query(RosterUnit).filter_by(device_type="sound_level_meter").all() all_slms = db.query(RosterUnit).filter_by(device_type="slm").all()
# Count deployed vs benched # Count deployed vs benched
deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired) deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired)
@@ -69,7 +69,7 @@ async def get_slm_units(
Get list of SLM units for the sidebar. Get list of SLM units for the sidebar.
Returns HTML partial with unit cards. Returns HTML partial with unit cards.
""" """
query = db.query(RosterUnit).filter_by(device_type="sound_level_meter") query = db.query(RosterUnit).filter_by(device_type="slm")
# Filter by project if provided # Filter by project if provided
if project: if project:
@@ -129,7 +129,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
Returns HTML partial with live metrics and chart. Returns HTML partial with live metrics and chart.
""" """
# Get unit from database # Get unit from database
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first()
if not unit: if not unit:
return templates.TemplateResponse("partials/slm_live_view_error.html", { return templates.TemplateResponse("partials/slm_live_view_error.html", {
@@ -242,7 +242,7 @@ async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(g
Get configuration form for a specific SLM unit. Get configuration form for a specific SLM unit.
Returns HTML partial with configuration form. Returns HTML partial with configuration form.
""" """
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first()
if not unit: if not unit:
return HTMLResponse( return HTMLResponse(
@@ -262,7 +262,7 @@ async def save_slm_config(request: Request, unit_id: str, db: Session = Depends(
Save SLM configuration. Save SLM configuration.
Updates unit parameters in the database. Updates unit parameters in the database.
""" """
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first()
if not unit: if not unit:
return {"status": "error", "detail": f"Unit {unit_id} not found"} return {"status": "error", "detail": f"Unit {unit_id} not found"}

View File

@@ -30,7 +30,7 @@ async def slm_detail_page(request: Request, unit_id: str, db: Session = Depends(
# Get roster unit # Get roster unit
unit = db.query(RosterUnit).filter_by(id=unit_id).first() unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit or unit.device_type != "sound_level_meter": if not unit or unit.device_type != "slm":
raise HTTPException(status_code=404, detail="Sound level meter not found") raise HTTPException(status_code=404, detail="Sound level meter not found")
return templates.TemplateResponse("slm_detail.html", { return templates.TemplateResponse("slm_detail.html", {
@@ -46,7 +46,7 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
# Get roster unit # Get roster unit
unit = db.query(RosterUnit).filter_by(id=unit_id).first() unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit or unit.device_type != "sound_level_meter": if not unit or unit.device_type != "slm":
raise HTTPException(status_code=404, detail="Sound level meter not found") raise HTTPException(status_code=404, detail="Sound level meter not found")
# Try to get live status from SLMM # Try to get live status from SLMM
@@ -61,7 +61,7 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
return { return {
"unit_id": unit_id, "unit_id": unit_id,
"device_type": "sound_level_meter", "device_type": "slm",
"deployed": unit.deployed, "deployed": unit.deployed,
"model": unit.slm_model or "NL-43", "model": unit.slm_model or "NL-43",
"location": unit.address or unit.location, "location": unit.address or unit.location,
@@ -89,7 +89,7 @@ async def slm_controls_partial(request: Request, unit_id: str, db: Session = Dep
"""Render SLM control panel partial.""" """Render SLM control panel partial."""
unit = db.query(RosterUnit).filter_by(id=unit_id).first() unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit or unit.device_type != "sound_level_meter": if not unit or unit.device_type != "slm":
raise HTTPException(status_code=404, detail="Sound level meter not found") raise HTTPException(status_code=404, detail="Sound level meter not found")
# Get current status from SLMM # Get current status from SLMM

View File

@@ -31,7 +31,7 @@ class DeviceController:
Usage: Usage:
controller = DeviceController() controller = DeviceController()
await controller.start_recording("nl43-001", "sound_level_meter", config={}) await controller.start_recording("nl43-001", "slm", config={})
await controller.stop_recording("seismo-042", "seismograph") await controller.stop_recording("seismo-042", "seismograph")
""" """
@@ -53,7 +53,7 @@ class DeviceController:
Args: Args:
unit_id: Unit identifier unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph" device_type: "slm" | "seismograph"
config: Device-specific recording configuration config: Device-specific recording configuration
Returns: Returns:
@@ -63,7 +63,7 @@ class DeviceController:
UnsupportedDeviceTypeError: Device type not supported UnsupportedDeviceTypeError: Device type not supported
DeviceControllerError: Operation failed DeviceControllerError: Operation failed
""" """
if device_type == "sound_level_meter": if device_type == "slm":
try: try:
return await self.slmm_client.start_recording(unit_id, config) return await self.slmm_client.start_recording(unit_id, config)
except SLMMClientError as e: except SLMMClientError as e:
@@ -81,7 +81,7 @@ class DeviceController:
else: else:
raise UnsupportedDeviceTypeError( raise UnsupportedDeviceTypeError(
f"Device type '{device_type}' is not supported. " f"Device type '{device_type}' is not supported. "
f"Supported types: sound_level_meter, seismograph" f"Supported types: slm, seismograph"
) )
async def stop_recording( async def stop_recording(
@@ -94,12 +94,12 @@ class DeviceController:
Args: Args:
unit_id: Unit identifier unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph" device_type: "slm" | "seismograph"
Returns: Returns:
Response dict from device module Response dict from device module
""" """
if device_type == "sound_level_meter": if device_type == "slm":
try: try:
return await self.slmm_client.stop_recording(unit_id) return await self.slmm_client.stop_recording(unit_id)
except SLMMClientError as e: except SLMMClientError as e:
@@ -126,12 +126,12 @@ class DeviceController:
Args: Args:
unit_id: Unit identifier unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph" device_type: "slm" | "seismograph"
Returns: Returns:
Response dict from device module Response dict from device module
""" """
if device_type == "sound_level_meter": if device_type == "slm":
try: try:
return await self.slmm_client.pause_recording(unit_id) return await self.slmm_client.pause_recording(unit_id)
except SLMMClientError as e: except SLMMClientError as e:
@@ -157,12 +157,12 @@ class DeviceController:
Args: Args:
unit_id: Unit identifier unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph" device_type: "slm" | "seismograph"
Returns: Returns:
Response dict from device module Response dict from device module
""" """
if device_type == "sound_level_meter": if device_type == "slm":
try: try:
return await self.slmm_client.resume_recording(unit_id) return await self.slmm_client.resume_recording(unit_id)
except SLMMClientError as e: except SLMMClientError as e:
@@ -192,12 +192,12 @@ class DeviceController:
Args: Args:
unit_id: Unit identifier unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph" device_type: "slm" | "seismograph"
Returns: Returns:
Status dict from device module Status dict from device module
""" """
if device_type == "sound_level_meter": if device_type == "slm":
try: try:
return await self.slmm_client.get_unit_status(unit_id) return await self.slmm_client.get_unit_status(unit_id)
except SLMMClientError as e: except SLMMClientError as e:
@@ -224,12 +224,12 @@ class DeviceController:
Args: Args:
unit_id: Unit identifier unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph" device_type: "slm" | "seismograph"
Returns: Returns:
Live data dict from device module Live data dict from device module
""" """
if device_type == "sound_level_meter": if device_type == "slm":
try: try:
return await self.slmm_client.get_live_data(unit_id) return await self.slmm_client.get_live_data(unit_id)
except SLMMClientError as e: except SLMMClientError as e:
@@ -261,14 +261,14 @@ class DeviceController:
Args: Args:
unit_id: Unit identifier unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph" device_type: "slm" | "seismograph"
destination_path: Local path to save files destination_path: Local path to save files
files: List of filenames, or None for all files: List of filenames, or None for all
Returns: Returns:
Download result with file list Download result with file list
""" """
if device_type == "sound_level_meter": if device_type == "slm":
try: try:
return await self.slmm_client.download_files( return await self.slmm_client.download_files(
unit_id, unit_id,
@@ -304,13 +304,13 @@ class DeviceController:
Args: Args:
unit_id: Unit identifier unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph" device_type: "slm" | "seismograph"
config: Configuration parameters config: Configuration parameters
Returns: Returns:
Updated config from device module Updated config from device module
""" """
if device_type == "sound_level_meter": if device_type == "slm":
try: try:
return await self.slmm_client.update_unit_config( return await self.slmm_client.update_unit_config(
unit_id, unit_id,
@@ -347,12 +347,12 @@ class DeviceController:
Args: Args:
unit_id: Unit identifier unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph" device_type: "slm" | "seismograph"
Returns: Returns:
True if device is reachable, False otherwise True if device is reachable, False otherwise
""" """
if device_type == "sound_level_meter": if device_type == "slm":
try: try:
status = await self.slmm_client.get_unit_status(unit_id) status = await self.slmm_client.get_unit_status(unit_id)
return status.get("last_seen") is not None return status.get("last_seen") is not None

View File

@@ -207,7 +207,7 @@ class SchedulerService:
project_id=action.project_id, project_id=action.project_id,
location_id=action.location_id, location_id=action.location_id,
unit_id=unit_id, unit_id=unit_id,
session_type="sound" if action.device_type == "sound_level_meter" else "vibration", session_type="sound" if action.device_type == "slm" else "vibration",
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
status="recording", status="recording",
session_metadata=json.dumps({"scheduled_action_id": action.id}), session_metadata=json.dumps({"scheduled_action_id": action.id}),
@@ -272,7 +272,7 @@ class SchedulerService:
# Build destination path # Build destination path
# Example: data/Projects/{project-id}/sound/{location-name}/session-{timestamp}/ # Example: data/Projects/{project-id}/sound/{location-name}/session-{timestamp}/
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M") session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
location_type_dir = "sound" if action.device_type == "sound_level_meter" else "vibration" location_type_dir = "sound" if action.device_type == "slm" else "vibration"
destination_path = ( destination_path = (
f"data/Projects/{project.id}/{location_type_dir}/" f"data/Projects/{project.id}/{location_type_dir}/"

View File

@@ -125,7 +125,7 @@ seismos = db.query(RosterUnit).filter_by(
### Sound Level Meters Query ### Sound Level Meters Query
```python ```python
slms = db.query(RosterUnit).filter_by( slms = db.query(RosterUnit).filter_by(
device_type="sound_level_meter", device_type="slm",
retired=False retired=False
).all() ).all()
``` ```

288
docs/DEVICE_TYPE_SCHEMA.md Normal file
View File

@@ -0,0 +1,288 @@
# Device Type Schema - Terra-View
## Overview
Terra-View uses a single roster table to manage three different device types. The `device_type` field is the primary discriminator that determines which fields are relevant for each unit.
## Official device_type Values
As of **Terra-View v0.4.3**, the following device_type values are standardized:
### 1. `"seismograph"` (Default)
**Purpose**: Seismic monitoring devices
**Applicable Fields**:
- Common: id, unit_type, deployed, retired, note, project_id, location, address, coordinates
- Specific: last_calibrated, next_calibration_due, deployed_with_modem_id
**Examples**:
- `BE1234` - Series 3 seismograph
- `UM12345` - Series 4 Micromate unit
- `SEISMO-001` - Custom seismograph
**Unit Type Values**:
- `series3` - Series 3 devices (default)
- `series4` - Series 4 devices
- `micromate` - Micromate devices
---
### 2. `"modem"`
**Purpose**: Field modems and network equipment
**Applicable Fields**:
- Common: id, unit_type, deployed, retired, note, project_id, location, address, coordinates
- Specific: ip_address, phone_number, hardware_model
**Examples**:
- `MDM001` - Field modem
- `MODEM-2025-01` - Network modem
- `RAVEN-XTV-01` - Specific modem model
**Unit Type Values**:
- `modem` - Generic modem
- `raven-xtv` - Raven XTV model
- Custom values for specific hardware
---
### 3. `"slm"` ⭐
**Purpose**: Sound level meters (Rion NL-43/NL-53)
**Applicable Fields**:
- Common: id, unit_type, deployed, retired, note, project_id, location, address, coordinates
- Specific: slm_host, slm_tcp_port, slm_ftp_port, slm_model, slm_serial_number, slm_frequency_weighting, slm_time_weighting, slm_measurement_range, slm_last_check, deployed_with_modem_id
**Examples**:
- `SLM-43-01` - NL-43 sound level meter
- `NL43-001` - NL-43 unit
- `NL53-002` - NL-53 unit
**Unit Type Values**:
- `nl43` - Rion NL-43 model
- `nl53` - Rion NL-53 model
---
## Migration from Legacy Values
### Deprecated Values
The following device_type values have been **deprecated** and should be migrated:
-`"sound_level_meter"` → ✅ `"slm"`
### How to Migrate
Run the standardization migration script to update existing databases:
```bash
cd /home/serversdown/tmi/terra-view
python3 backend/migrate_standardize_device_types.py
```
This script:
- Converts all `"sound_level_meter"` values to `"slm"`
- Is idempotent (safe to run multiple times)
- Shows before/after distribution of device types
- No data loss
---
## Database Schema
### RosterUnit Model (`backend/models.py`)
```python
class RosterUnit(Base):
"""
Supports multiple device types:
- "seismograph" - Seismic monitoring devices (default)
- "modem" - Field modems and network equipment
- "slm" - Sound level meters (NL-43/NL-53)
"""
__tablename__ = "roster"
# Core fields (all device types)
id = Column(String, primary_key=True)
unit_type = Column(String, default="series3")
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
deployed = Column(Boolean, default=True)
retired = Column(Boolean, default=False)
# ... other common fields
# Seismograph-specific
last_calibrated = Column(Date, nullable=True)
next_calibration_due = Column(Date, nullable=True)
# Modem-specific
ip_address = Column(String, nullable=True)
phone_number = Column(String, nullable=True)
hardware_model = Column(String, nullable=True)
# SLM-specific
slm_host = Column(String, nullable=True)
slm_tcp_port = Column(Integer, nullable=True)
slm_ftp_port = Column(Integer, nullable=True)
slm_model = Column(String, nullable=True)
slm_serial_number = Column(String, nullable=True)
slm_frequency_weighting = Column(String, nullable=True)
slm_time_weighting = Column(String, nullable=True)
slm_measurement_range = Column(String, nullable=True)
slm_last_check = Column(DateTime, nullable=True)
# Shared fields (seismograph + SLM)
deployed_with_modem_id = Column(String, nullable=True) # FK to modem
```
---
## API Usage
### Adding a New Unit
**Seismograph**:
```bash
curl -X POST http://localhost:8001/api/roster/add \
-F "id=BE1234" \
-F "device_type=seismograph" \
-F "unit_type=series3" \
-F "deployed=true"
```
**Modem**:
```bash
curl -X POST http://localhost:8001/api/roster/add \
-F "id=MDM001" \
-F "device_type=modem" \
-F "ip_address=192.0.2.10" \
-F "phone_number=+1-555-0100"
```
**Sound Level Meter**:
```bash
curl -X POST http://localhost:8001/api/roster/add \
-F "id=SLM-43-01" \
-F "device_type=slm" \
-F "slm_host=63.45.161.30" \
-F "slm_tcp_port=2255" \
-F "slm_model=NL-43"
```
### CSV Import Format
```csv
unit_id,unit_type,device_type,deployed,slm_host,slm_tcp_port,slm_model
SLM-43-01,nl43,slm,true,63.45.161.30,2255,NL-43
SLM-43-02,nl43,slm,true,63.45.161.31,2255,NL-43
BE1234,series3,seismograph,true,,,
MDM001,modem,modem,true,,,
```
---
## Frontend Behavior
### Device Type Selection
**Templates**: `unit_detail.html`, `roster.html`
```html
<select name="device_type">
<option value="seismograph">Seismograph</option>
<option value="modem">Modem</option>
<option value="slm">Sound Level Meter</option>
</select>
```
### Conditional Field Display
JavaScript functions check `device_type` to show/hide relevant fields:
```javascript
function toggleDetailFields() {
const deviceType = document.getElementById('device_type').value;
if (deviceType === 'seismograph') {
// Show calibration fields
} else if (deviceType === 'modem') {
// Show network fields
} else if (deviceType === 'slm') {
// Show SLM configuration fields
}
}
```
---
## Code Conventions
### Always Use Lowercase
**Correct**:
```python
if unit.device_type == "slm":
# Handle sound level meter
```
**Incorrect**:
```python
if unit.device_type == "SLM": # Wrong - case sensitive
if unit.device_type == "sound_level_meter": # Deprecated
```
### Query Patterns
**Filter by device type**:
```python
# Get all SLMs
slms = db.query(RosterUnit).filter_by(device_type="slm").all()
# Get deployed seismographs
seismos = db.query(RosterUnit).filter_by(
device_type="seismograph",
deployed=True
).all()
# Get all modems
modems = db.query(RosterUnit).filter_by(device_type="modem").all()
```
---
## Testing
### Verify Device Type Distribution
```bash
# Quick check
sqlite3 data/seismo_fleet.db "SELECT device_type, COUNT(*) FROM roster GROUP BY device_type;"
# Detailed view
sqlite3 data/seismo_fleet.db "SELECT id, device_type, unit_type, deployed FROM roster ORDER BY device_type, id;"
```
### Check for Legacy Values
```bash
# Should return 0 rows after migration
sqlite3 data/seismo_fleet.db "SELECT id FROM roster WHERE device_type = 'sound_level_meter';"
```
---
## Version History
- **v0.4.3** (2026-01-16) - Standardized device_type values, deprecated `"sound_level_meter"``"slm"`
- **v0.4.0** (2026-01-05) - Added SLM support with `"sound_level_meter"` value
- **v0.2.0** (2025-12-03) - Added modem device type
- **v0.1.0** (2024-11-20) - Initial release with seismograph-only support
---
## Related Documentation
- [README.md](../README.md) - Main project documentation with data model
- [DEVICE_TYPE_SLM_SUPPORT.md](DEVICE_TYPE_SLM_SUPPORT.md) - Legacy SLM implementation notes
- [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) - SLM dashboard features
- [SLM_CONFIGURATION.md](SLM_CONFIGURATION.md) - SLM device configuration guide

View File

@@ -1,5 +1,7 @@
# Sound Level Meter Device Type Support # Sound Level Meter Device Type Support
**⚠️ IMPORTANT**: This documentation uses the legacy `sound_level_meter` device type value. As of v0.4.3, the standardized value is `"slm"`. Run `backend/migrate_standardize_device_types.py` to update your database.
## Overview ## Overview
Added full support for "Sound Level Meter" as a device type in the roster management system. Users can now create, edit, and manage SLM units through the Fleet Roster interface. Added full support for "Sound Level Meter" as a device type in the roster management system. Users can now create, edit, and manage SLM units through the Fleet Roster interface.
@@ -95,7 +97,7 @@ All SLM fields are updated when editing existing unit.
The database schema already included SLM fields (no changes needed): The database schema already included SLM fields (no changes needed):
- All fields are nullable to support multiple device types - All fields are nullable to support multiple device types
- Fields are only relevant when `device_type = "sound_level_meter"` - Fields are only relevant when `device_type = "slm"`
## Usage ## Usage
@@ -125,7 +127,7 @@ The form automatically shows/hides relevant fields based on device type:
## Integration with SLMM Dashboard ## Integration with SLMM Dashboard
Units with `device_type = "sound_level_meter"` will: Units with `device_type = "slm"` will:
- Appear in the Sound Level Meters dashboard (`/sound-level-meters`) - Appear in the Sound Level Meters dashboard (`/sound-level-meters`)
- Be available for live monitoring and control - Be available for live monitoring and control
- Use the configured `slm_host` and `slm_tcp_port` for device communication - Use the configured `slm_host` and `slm_tcp_port` for device communication

View File

@@ -300,7 +300,7 @@ slm.deployed_with_modem_id = "modem-001"
```json ```json
{ {
"id": "nl43-001", "id": "nl43-001",
"device_type": "sound_level_meter", "device_type": "slm",
"deployed_with_modem_id": "modem-001", "deployed_with_modem_id": "modem-001",
"slm_tcp_port": 2255, "slm_tcp_port": 2255,
"slm_model": "NL-43", "slm_model": "NL-43",

View File

@@ -135,7 +135,7 @@ The dashboard communicates with the SLMM backend service running on port 8100:
SLM-specific fields in the RosterUnit model: SLM-specific fields in the RosterUnit model:
```python ```python
device_type = "sound_level_meter" # Distinguishes SLMs from seismographs device_type = "slm" # Distinguishes SLMs from seismographs
slm_host = String # Device IP or hostname slm_host = String # Device IP or hostname
slm_tcp_port = Integer # TCP control port (default 2255) slm_tcp_port = Integer # TCP control port (default 2255)
slm_model = String # NL-43, NL-53, etc. slm_model = String # NL-43, NL-53, etc.

View File

@@ -25,7 +25,7 @@ async def sync_all_slms():
try: try:
# Get all SLM devices from Terra-View (source of truth) # Get all SLM devices from Terra-View (source of truth)
slm_devices = db.query(RosterUnit).filter_by( slm_devices = db.query(RosterUnit).filter_by(
device_type="sound_level_meter" device_type="slm"
).all() ).all()
logger.info(f"Found {len(slm_devices)} SLM devices in Terra-View roster") logger.info(f"Found {len(slm_devices)} SLM devices in Terra-View roster")

View File

@@ -85,6 +85,10 @@
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium"> <span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
Modem Modem
</span> </span>
{% elif unit.device_type == 'slm' %}
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
SLM
</span>
{% else %} {% else %}
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium"> <span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
Seismograph Seismograph

View File

@@ -57,7 +57,7 @@
<button class="filter-btn filter-device-type active-filter" data-value="all">All</button> <button class="filter-btn filter-device-type active-filter" data-value="all">All</button>
<button class="filter-btn filter-device-type" data-value="seismograph">Seismographs</button> <button class="filter-btn filter-device-type" data-value="seismograph">Seismographs</button>
<button class="filter-btn filter-device-type" data-value="modem">Modems</button> <button class="filter-btn filter-device-type" data-value="modem">Modems</button>
<button class="filter-btn filter-device-type" data-value="sound_level_meter">SLMs</button> <button class="filter-btn filter-device-type" data-value="slm">SLMs</button>
</div> </div>
<!-- Status Filter --> <!-- Status Filter -->