slm integration added
This commit is contained in:
@@ -9,7 +9,7 @@ from typing import List, Dict
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.database import engine, Base, get_db
|
||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity
|
||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity, slmm, slm_ui
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
from backend.models import IgnoredUnit
|
||||
|
||||
@@ -68,6 +68,8 @@ app.include_router(roster_edit.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(dashboard_tabs.router)
|
||||
app.include_router(activity.router)
|
||||
app.include_router(slmm.router)
|
||||
app.include_router(slm_ui.router)
|
||||
|
||||
from backend.routers import settings
|
||||
app.include_router(settings.router)
|
||||
|
||||
78
backend/migrate_add_slm_fields.py
Normal file
78
backend/migrate_add_slm_fields.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database migration: Add sound level meter fields to roster table.
|
||||
|
||||
Adds columns for sound_level_meter device type support.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
def migrate():
|
||||
"""Add SLM fields to roster table if they don't exist."""
|
||||
|
||||
# Try multiple possible database locations
|
||||
possible_paths = [
|
||||
Path("data/seismo_fleet.db"),
|
||||
Path("data/sfm.db"),
|
||||
Path("data/seismo.db"),
|
||||
]
|
||||
|
||||
db_path = None
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
db_path = path
|
||||
break
|
||||
|
||||
if db_path is None:
|
||||
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
|
||||
print("Creating database with models.py will include new fields automatically.")
|
||||
return
|
||||
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if columns already exist
|
||||
cursor.execute("PRAGMA table_info(roster)")
|
||||
existing_columns = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
new_columns = {
|
||||
"slm_host": "TEXT",
|
||||
"slm_tcp_port": "INTEGER",
|
||||
"slm_model": "TEXT",
|
||||
"slm_serial_number": "TEXT",
|
||||
"slm_frequency_weighting": "TEXT",
|
||||
"slm_time_weighting": "TEXT",
|
||||
"slm_measurement_range": "TEXT",
|
||||
"slm_last_check": "DATETIME",
|
||||
}
|
||||
|
||||
migrations_applied = []
|
||||
|
||||
for column_name, column_type in new_columns.items():
|
||||
if column_name not in existing_columns:
|
||||
try:
|
||||
cursor.execute(f"ALTER TABLE roster ADD COLUMN {column_name} {column_type}")
|
||||
migrations_applied.append(column_name)
|
||||
print(f"✓ Added column: {column_name} ({column_type})")
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"✗ Failed to add column {column_name}: {e}")
|
||||
else:
|
||||
print(f"○ Column already exists: {column_name}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if migrations_applied:
|
||||
print(f"\n✓ Migration complete! Added {len(migrations_applied)} new columns.")
|
||||
else:
|
||||
print("\n○ No migration needed - all columns already exist.")
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -19,14 +19,14 @@ class RosterUnit(Base):
|
||||
Roster table: represents our *intended assignment* of a unit.
|
||||
This is editable from the GUI.
|
||||
|
||||
Supports multiple device types (seismograph, modem) with type-specific fields.
|
||||
Supports multiple device types (seismograph, modem, sound_level_meter) with type-specific fields.
|
||||
"""
|
||||
__tablename__ = "roster"
|
||||
|
||||
# Core fields (all device types)
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
unit_type = Column(String, default="series3") # Backward compatibility
|
||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem"
|
||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "sound_level_meter"
|
||||
deployed = Column(Boolean, default=True)
|
||||
retired = Column(Boolean, default=False)
|
||||
note = Column(String, nullable=True)
|
||||
@@ -36,16 +36,26 @@ class RosterUnit(Base):
|
||||
coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
|
||||
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Seismograph-specific fields (nullable for modems)
|
||||
# Seismograph-specific fields (nullable for modems and SLMs)
|
||||
last_calibrated = Column(Date, nullable=True)
|
||||
next_calibration_due = Column(Date, nullable=True)
|
||||
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit
|
||||
|
||||
# Modem-specific fields (nullable for seismographs)
|
||||
# Modem-specific fields (nullable for seismographs and SLMs)
|
||||
ip_address = Column(String, nullable=True)
|
||||
phone_number = Column(String, nullable=True)
|
||||
hardware_model = Column(String, nullable=True)
|
||||
|
||||
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
||||
slm_host = Column(String, nullable=True) # Device IP or hostname
|
||||
slm_tcp_port = Column(Integer, nullable=True) # TCP control port (default 2255)
|
||||
slm_model = Column(String, nullable=True) # NL-43, NL-53, etc.
|
||||
slm_serial_number = Column(String, nullable=True) # Device serial number
|
||||
slm_frequency_weighting = Column(String, nullable=True) # A, C, Z
|
||||
slm_time_weighting = Column(String, nullable=True) # F (Fast), S (Slow), I (Impulse)
|
||||
slm_measurement_range = Column(String, nullable=True) # e.g., "30-130 dB"
|
||||
slm_last_check = Column(DateTime, nullable=True) # Last communication check
|
||||
|
||||
|
||||
class IgnoredUnit(Base):
|
||||
"""
|
||||
|
||||
@@ -100,6 +100,14 @@ def get_all_roster_units(db: Session = Depends(get_db)):
|
||||
"ip_address": unit.ip_address or "",
|
||||
"phone_number": unit.phone_number or "",
|
||||
"hardware_model": unit.hardware_model or "",
|
||||
"slm_host": unit.slm_host or "",
|
||||
"slm_tcp_port": unit.slm_tcp_port,
|
||||
"slm_model": unit.slm_model or "",
|
||||
"slm_serial_number": unit.slm_serial_number or "",
|
||||
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
||||
"slm_time_weighting": unit.slm_time_weighting or "",
|
||||
"slm_measurement_range": unit.slm_measurement_range or "",
|
||||
"slm_last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||
"last_updated": unit.last_updated.isoformat() if unit.last_updated else None
|
||||
} for unit in units]
|
||||
|
||||
|
||||
123
backend/routers/slm_ui.py
Normal file
123
backend/routers/slm_ui.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Sound Level Meter UI Router
|
||||
|
||||
Provides endpoints for SLM dashboard cards, detail pages, and real-time data.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/slm", tags=["slm-ui"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://172.19.0.1:8100")
|
||||
|
||||
|
||||
@router.get("/{unit_id}", response_class=HTMLResponse)
|
||||
async def slm_detail_page(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Sound level meter detail page with controls."""
|
||||
|
||||
# Get roster unit
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "sound_level_meter":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
return templates.TemplateResponse("slm_detail.html", {
|
||||
"request": request,
|
||||
"unit": unit,
|
||||
"unit_id": unit_id
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/{unit_id}/summary")
|
||||
async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Get SLM summary data for dashboard card."""
|
||||
|
||||
# Get roster unit
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "sound_level_meter":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
# Try to get live status from SLMM
|
||||
status_data = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
||||
if response.status_code == 200:
|
||||
status_data = response.json().get("data")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed": unit.deployed,
|
||||
"model": unit.slm_model or "NL-43",
|
||||
"location": unit.address or unit.location,
|
||||
"coordinates": unit.coordinates,
|
||||
"note": unit.note,
|
||||
"status": status_data,
|
||||
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/partials/{unit_id}/card", response_class=HTMLResponse)
|
||||
async def slm_dashboard_card(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Render SLM dashboard card partial."""
|
||||
|
||||
summary = await get_slm_summary(unit_id, db)
|
||||
|
||||
return templates.TemplateResponse("partials/slm_card.html", {
|
||||
"request": request,
|
||||
"slm": summary
|
||||
})
|
||||
|
||||
|
||||
@router.get("/partials/{unit_id}/controls", response_class=HTMLResponse)
|
||||
async def slm_controls_partial(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Render SLM control panel partial."""
|
||||
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "sound_level_meter":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
# Get current status from SLMM
|
||||
measurement_state = None
|
||||
battery_level = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
# Get measurement state
|
||||
state_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||
)
|
||||
if state_response.status_code == 200:
|
||||
measurement_state = state_response.json().get("measurement_state")
|
||||
|
||||
# Get battery level
|
||||
battery_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/battery"
|
||||
)
|
||||
if battery_response.status_code == 200:
|
||||
battery_level = battery_response.json().get("battery_level")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get SLM control data for {unit_id}: {e}")
|
||||
|
||||
return templates.TemplateResponse("partials/slm_controls.html", {
|
||||
"request": request,
|
||||
"unit_id": unit_id,
|
||||
"unit": unit,
|
||||
"measurement_state": measurement_state,
|
||||
"battery_level": battery_level,
|
||||
"is_measuring": measurement_state == "Start"
|
||||
})
|
||||
130
backend/routers/slmm.py
Normal file
130
backend/routers/slmm.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
SLMM (Sound Level Meter Manager) Proxy Router
|
||||
|
||||
Proxies requests from SFM to the standalone SLMM backend service.
|
||||
SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/slmm", tags=["slmm"])
|
||||
|
||||
# SLMM backend URL - configurable via environment variable
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def check_slmm_health():
|
||||
"""
|
||||
Check if the SLMM backend service is reachable and healthy.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{SLMM_BASE_URL}/health")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
"status": "ok",
|
||||
"slmm_status": "connected",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"slmm_version": data.get("version", "unknown"),
|
||||
"slmm_response": data
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "degraded",
|
||||
"slmm_status": "error",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": f"SLMM returned status {response.status_code}"
|
||||
}
|
||||
|
||||
except httpx.ConnectError:
|
||||
return {
|
||||
"status": "error",
|
||||
"slmm_status": "unreachable",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": "Cannot connect to SLMM backend. Is it running?"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"slmm_status": "error",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": str(e)
|
||||
}
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
async def proxy_to_slmm(path: str, request: Request):
|
||||
"""
|
||||
Proxy all requests to the SLMM backend service.
|
||||
|
||||
This allows SFM to act as a unified frontend for all device types,
|
||||
while SLMM remains a standalone backend service.
|
||||
"""
|
||||
# Build target URL
|
||||
target_url = f"{SLMM_BASE_URL}/api/nl43/{path}"
|
||||
|
||||
# Get query parameters
|
||||
query_params = dict(request.query_params)
|
||||
|
||||
# Get request body if present
|
||||
body = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
try:
|
||||
body = await request.body()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read request body: {e}")
|
||||
body = None
|
||||
|
||||
# Get headers (exclude host and other proxy-specific headers)
|
||||
headers = dict(request.headers)
|
||||
headers_to_exclude = ["host", "content-length", "transfer-encoding", "connection"]
|
||||
proxy_headers = {k: v for k, v in headers.items() if k.lower() not in headers_to_exclude}
|
||||
|
||||
logger.info(f"Proxying {request.method} request to SLMM: {target_url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Forward the request to SLMM
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
params=query_params,
|
||||
headers=proxy_headers,
|
||||
content=body
|
||||
)
|
||||
|
||||
# Return the response from SLMM
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.headers.get("content-type")
|
||||
)
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Failed to connect to SLMM backend at {SLMM_BASE_URL}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"SLMM backend service unavailable. Is SLMM running on {SLMM_BASE_URL}?"
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Timeout connecting to SLMM backend at {SLMM_BASE_URL}")
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="SLMM backend timeout"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error proxying to SLMM: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to proxy request to SLMM: {str(e)}"
|
||||
)
|
||||
Reference in New Issue
Block a user