Compare commits
10 Commits
4da53facb9
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a6eed21e | ||
|
|
392c2579ee | ||
|
|
0a08a8244d | ||
|
|
3e13edfa55 | ||
|
|
01aa89f94d | ||
|
|
e5d7d58c2f | ||
|
|
b527ad95f6 | ||
|
|
8951d212b2 | ||
|
|
a60a3072bd | ||
|
|
9a9e83c103 |
@@ -1,41 +1,19 @@
|
|||||||
# Python cache / compiled
|
|
||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
.Python
|
.Python
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
*.so
|
*.so
|
||||||
*.egg
|
*.egg
|
||||||
*.egg-info
|
*.egg-info
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
|
||||||
# VCS
|
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
||||||
# Databases (must live in volumes)
|
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|
||||||
# Environment / virtualenv
|
|
||||||
.env
|
.env
|
||||||
.venv
|
.venv
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
|
|
||||||
# Runtime data (mounted volumes)
|
|
||||||
data/
|
data/
|
||||||
|
|
||||||
# Editors / OS junk
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
.claude
|
|
||||||
sfm.code-workspace
|
|
||||||
|
|
||||||
# Tests (optional)
|
|
||||||
tests/
|
|
||||||
|
|||||||
@@ -5,14 +5,6 @@ All notable changes to Seismo Fleet Manager will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [0.4.1] - 2026 1-5
|
|
||||||
### Added
|
|
||||||
- **SLM integration**: Sound Level meters are now managable in SFM
|
|
||||||
|
|
||||||
## Fixed ##
|
|
||||||
- Fixed an issue where unit status wat loading from a saved cache and not based on when it was actually heard from last. Unit status is now accurate.
|
|
||||||
|
|
||||||
|
|
||||||
## [0.4.0] - 2025-12-16
|
## [0.4.0] - 2025-12-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Seismo Fleet Manager v0.4.1
|
# Seismo Fleet Manager v0.4.0
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import List, Dict
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.database import engine, Base, get_db
|
from backend.database import engine, Base, get_db
|
||||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity, slmm, slm_ui
|
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.models import IgnoredUnit
|
from backend.models import IgnoredUnit
|
||||||
|
|
||||||
@@ -68,8 +68,6 @@ app.include_router(roster_edit.router)
|
|||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(dashboard_tabs.router)
|
app.include_router(dashboard_tabs.router)
|
||||||
app.include_router(activity.router)
|
app.include_router(activity.router)
|
||||||
app.include_router(slmm.router)
|
|
||||||
app.include_router(slm_ui.router)
|
|
||||||
|
|
||||||
from backend.routers import settings
|
from backend.routers import settings
|
||||||
app.include_router(settings.router)
|
app.include_router(settings.router)
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
#!/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.
|
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 (seismograph, modem) with type-specific fields.
|
||||||
"""
|
"""
|
||||||
__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"
|
||||||
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)
|
||||||
@@ -36,26 +36,16 @@ class RosterUnit(Base):
|
|||||||
coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
|
coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
|
||||||
last_updated = Column(DateTime, default=datetime.utcnow)
|
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Seismograph-specific fields (nullable for modems and SLMs)
|
# Seismograph-specific fields (nullable for modems)
|
||||||
last_calibrated = Column(Date, nullable=True)
|
last_calibrated = Column(Date, nullable=True)
|
||||||
next_calibration_due = Column(Date, nullable=True)
|
next_calibration_due = Column(Date, nullable=True)
|
||||||
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit
|
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit
|
||||||
|
|
||||||
# Modem-specific fields (nullable for seismographs and SLMs)
|
# Modem-specific fields (nullable for seismographs)
|
||||||
ip_address = Column(String, nullable=True)
|
ip_address = Column(String, nullable=True)
|
||||||
phone_number = Column(String, nullable=True)
|
phone_number = Column(String, nullable=True)
|
||||||
hardware_model = 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):
|
class IgnoredUnit(Base):
|
||||||
"""
|
"""
|
||||||
@@ -104,4 +94,4 @@ class UserPreferences(Base):
|
|||||||
calibration_warning_days = Column(Integer, default=30)
|
calibration_warning_days = Column(Integer, default=30)
|
||||||
status_ok_threshold_hours = Column(Integer, default=12)
|
status_ok_threshold_hours = Column(Integer, default=12)
|
||||||
status_pending_threshold_hours = Column(Integer, default=24)
|
status_pending_threshold_hours = Column(Integer, default=24)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -100,14 +100,6 @@ def get_all_roster_units(db: Session = Depends(get_db)):
|
|||||||
"ip_address": unit.ip_address or "",
|
"ip_address": unit.ip_address or "",
|
||||||
"phone_number": unit.phone_number or "",
|
"phone_number": unit.phone_number or "",
|
||||||
"hardware_model": unit.hardware_model 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
|
"last_updated": unit.last_updated.isoformat() if unit.last_updated else None
|
||||||
} for unit in units]
|
} for unit in units]
|
||||||
|
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
"""
|
|
||||||
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"
|
|
||||||
})
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
"""
|
|
||||||
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)}"
|
|
||||||
)
|
|
||||||
@@ -24,47 +24,13 @@ def format_age(last_seen):
|
|||||||
return f"{int(hours)}h {int(mins)}m"
|
return f"{int(hours)}h {int(mins)}m"
|
||||||
|
|
||||||
|
|
||||||
def calculate_status(last_seen, status_ok_threshold=12, status_pending_threshold=24):
|
|
||||||
"""
|
|
||||||
Calculate status based on how long ago the unit was last seen.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
last_seen: datetime of last seen (UTC)
|
|
||||||
status_ok_threshold: hours before status becomes Pending (default 12)
|
|
||||||
status_pending_threshold: hours before status becomes Missing (default 24)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
"OK", "Pending", or "Missing"
|
|
||||||
"""
|
|
||||||
if not last_seen:
|
|
||||||
return "Missing"
|
|
||||||
|
|
||||||
last_seen = ensure_utc(last_seen)
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
hours_ago = (now - last_seen).total_seconds() / 3600
|
|
||||||
|
|
||||||
if hours_ago > status_pending_threshold:
|
|
||||||
return "Missing"
|
|
||||||
elif hours_ago > status_ok_threshold:
|
|
||||||
return "Pending"
|
|
||||||
else:
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
|
|
||||||
def emit_status_snapshot():
|
def emit_status_snapshot():
|
||||||
"""
|
"""
|
||||||
Merge roster (what we *intend*) with emitter data (what is *actually happening*).
|
Merge roster (what we *intend*) with emitter data (what is *actually happening*).
|
||||||
Status is recalculated based on current time to ensure accuracy.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
db = get_db_session()
|
db = get_db_session()
|
||||||
try:
|
try:
|
||||||
# Get user preferences for status thresholds
|
|
||||||
from backend.models import UserPreferences
|
|
||||||
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
|
||||||
status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12
|
|
||||||
status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24
|
|
||||||
|
|
||||||
roster = {r.id: r for r in db.query(RosterUnit).all()}
|
roster = {r.id: r for r in db.query(RosterUnit).all()}
|
||||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||||
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||||
@@ -74,6 +40,7 @@ def emit_status_snapshot():
|
|||||||
# --- Merge roster entries first ---
|
# --- Merge roster entries first ---
|
||||||
for unit_id, r in roster.items():
|
for unit_id, r in roster.items():
|
||||||
e = emitters.get(unit_id)
|
e = emitters.get(unit_id)
|
||||||
|
|
||||||
if r.retired:
|
if r.retired:
|
||||||
# Retired units get separated later
|
# Retired units get separated later
|
||||||
status = "Retired"
|
status = "Retired"
|
||||||
@@ -82,9 +49,8 @@ def emit_status_snapshot():
|
|||||||
fname = ""
|
fname = ""
|
||||||
else:
|
else:
|
||||||
if e:
|
if e:
|
||||||
|
status = e.status
|
||||||
last_seen = ensure_utc(e.last_seen)
|
last_seen = ensure_utc(e.last_seen)
|
||||||
# RECALCULATE status based on current time, not stored value
|
|
||||||
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
|
||||||
age = format_age(last_seen)
|
age = format_age(last_seen)
|
||||||
fname = e.last_file
|
fname = e.last_file
|
||||||
else:
|
else:
|
||||||
@@ -94,12 +60,12 @@ def emit_status_snapshot():
|
|||||||
age = "N/A"
|
age = "N/A"
|
||||||
fname = ""
|
fname = ""
|
||||||
|
|
||||||
units[unit_id] = {
|
units[unit_id] = {
|
||||||
"id": unit_id,
|
"id": unit_id,
|
||||||
"status": status,
|
"status": status,
|
||||||
"age": age,
|
"age": age,
|
||||||
"last": last_seen.isoformat() if last_seen else None,
|
"last": last_seen.isoformat() if last_seen else None,
|
||||||
"fname": fname,
|
"fname": fname,
|
||||||
"deployed": r.deployed,
|
"deployed": r.deployed,
|
||||||
"note": r.note or "",
|
"note": r.note or "",
|
||||||
"retired": r.retired,
|
"retired": r.retired,
|
||||||
@@ -110,22 +76,20 @@ def emit_status_snapshot():
|
|||||||
"deployed_with_modem_id": r.deployed_with_modem_id,
|
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||||
"ip_address": r.ip_address,
|
"ip_address": r.ip_address,
|
||||||
"phone_number": r.phone_number,
|
"phone_number": r.phone_number,
|
||||||
"hardware_model": r.hardware_model,
|
"hardware_model": r.hardware_model,
|
||||||
# Location for mapping
|
# Location for mapping
|
||||||
"location": r.location or "",
|
"location": r.location or "",
|
||||||
"address": r.address or "",
|
"address": r.address or "",
|
||||||
"coordinates": r.coordinates or "",
|
"coordinates": r.coordinates or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Add unexpected emitter-only units ---
|
# --- Add unexpected emitter-only units ---
|
||||||
for unit_id, e in emitters.items():
|
for unit_id, e in emitters.items():
|
||||||
if unit_id not in roster:
|
if unit_id not in roster:
|
||||||
last_seen = ensure_utc(e.last_seen)
|
last_seen = ensure_utc(e.last_seen)
|
||||||
# RECALCULATE status for unknown units too
|
|
||||||
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
|
||||||
units[unit_id] = {
|
units[unit_id] = {
|
||||||
"id": unit_id,
|
"id": unit_id,
|
||||||
"status": status,
|
"status": e.status,
|
||||||
"age": format_age(last_seen),
|
"age": format_age(last_seen),
|
||||||
"last": last_seen.isoformat(),
|
"last": last_seen.isoformat(),
|
||||||
"fname": e.last_file,
|
"fname": e.last_file,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
- SLMM_BASE_URL=http://172.19.0.1:8100
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
|
|||||||
@@ -6,4 +6,3 @@ python-multipart==0.0.6
|
|||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
Pillow==10.1.0
|
Pillow==10.1.0
|
||||||
httpx==0.25.2
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<!-- Status Bar -->
|
|
||||||
<div class="mb-6 p-4 rounded-lg {% if is_measuring %}bg-green-50 dark:bg-green-900/20{% else %}bg-gray-50 dark:bg-gray-900{% endif %}">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Measurement Status</div>
|
|
||||||
<div class="text-2xl font-bold {% if is_measuring %}text-green-600 dark:text-green-400{% else %}text-gray-600 dark:text-gray-400{% endif %}">
|
|
||||||
{% if measurement_state %}
|
|
||||||
{{ measurement_state }}
|
|
||||||
{% if is_measuring %}
|
|
||||||
<span class="inline-block w-3 h-3 bg-green-500 rounded-full ml-2 animate-pulse"></span>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
Unknown
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Battery</div>
|
|
||||||
<div class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{{ battery_level or '--' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Control Buttons -->
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
<button hx-post="/api/slmm/{{ unit_id }}/start"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
|
||||||
class="px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2
|
|
||||||
{% if is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
|
||||||
{% if is_measuring %}disabled{% endif %}>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
</svg>
|
|
||||||
Start
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button hx-post="/api/slmm/{{ unit_id }}/stop"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
|
||||||
class="px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2
|
|
||||||
{% if not is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
|
||||||
{% if not is_measuring %}disabled{% endif %}>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
|
||||||
</svg>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button hx-post="/api/slmm/{{ unit_id }}/pause"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
|
||||||
class="px-4 py-3 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors flex items-center justify-center gap-2">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
</svg>
|
|
||||||
Pause
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button hx-post="/api/slmm/{{ unit_id }}/reset"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-confirm="Are you sure you want to reset the measurement data?"
|
|
||||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
|
||||||
class="px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center justify-center gap-2">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
||||||
</svg>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
|
||||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<button hx-get="/api/slmm/{{ unit_id }}/live"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-indicator="#live-spinner"
|
|
||||||
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-2">
|
|
||||||
<svg id="live-spinner" class="htmx-indicator w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
||||||
</svg>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
|
||||||
</svg>
|
|
||||||
Get Live Data
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button hx-post="/api/slmm/{{ unit_id }}/store"
|
|
||||||
hx-swap="none"
|
|
||||||
class="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-2">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
|
|
||||||
</svg>
|
|
||||||
Store Data
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="slm-controls" hx-get="/slm/partials/{{ unit_id }}/controls" hx-trigger="refresh" hx-swap="outerHTML"></div>
|
|
||||||
@@ -124,7 +124,6 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
<option value="seismograph">Seismograph</option>
|
<option value="seismograph">Seismograph</option>
|
||||||
<option value="modem">Modem</option>
|
<option value="modem">Modem</option>
|
||||||
<option value="sound_level_meter">Sound Level Meter</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -187,49 +186,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sound Level Meter-specific fields -->
|
|
||||||
<div id="slmFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
||||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Sound Level Meter Information</p>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">SLM Model</label>
|
|
||||||
<input type="text" name="slm_model" placeholder="NL-43"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host/IP Address</label>
|
|
||||||
<input type="text" name="slm_host" placeholder="192.168.1.100"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
|
||||||
<input type="number" name="slm_tcp_port" placeholder="2255"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
|
||||||
<input type="text" name="slm_serial_number" placeholder="SN123456"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
|
||||||
<select name="slm_frequency_weighting"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
<option value="A">A-weighting</option>
|
|
||||||
<option value="C">C-weighting</option>
|
|
||||||
<option value="Z">Z-weighting (Flat)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
|
||||||
<select name="slm_time_weighting"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
<option value="F">F (Fast)</option>
|
|
||||||
<option value="S">S (Slow)</option>
|
|
||||||
<option value="I">I (Impulse)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" name="deployed" id="deployedCheckbox" value="true" checked onchange="toggleModemPairing()"
|
<input type="checkbox" name="deployed" id="deployedCheckbox" value="true" checked onchange="toggleModemPairing()"
|
||||||
@@ -432,21 +388,14 @@
|
|||||||
const deviceType = document.getElementById('deviceTypeSelect').value;
|
const deviceType = document.getElementById('deviceTypeSelect').value;
|
||||||
const seismoFields = document.getElementById('seismographFields');
|
const seismoFields = document.getElementById('seismographFields');
|
||||||
const modemFields = document.getElementById('modemFields');
|
const modemFields = document.getElementById('modemFields');
|
||||||
const slmFields = document.getElementById('slmFields');
|
|
||||||
|
|
||||||
if (deviceType === 'seismograph') {
|
if (deviceType === 'seismograph') {
|
||||||
seismoFields.classList.remove('hidden');
|
seismoFields.classList.remove('hidden');
|
||||||
modemFields.classList.add('hidden');
|
modemFields.classList.add('hidden');
|
||||||
slmFields.classList.add('hidden');
|
|
||||||
toggleModemPairing(); // Check if modem pairing should be shown
|
toggleModemPairing(); // Check if modem pairing should be shown
|
||||||
} else if (deviceType === 'modem') {
|
} else {
|
||||||
seismoFields.classList.add('hidden');
|
seismoFields.classList.add('hidden');
|
||||||
modemFields.classList.remove('hidden');
|
modemFields.classList.remove('hidden');
|
||||||
slmFields.classList.add('hidden');
|
|
||||||
} else if (deviceType === 'sound_level_meter') {
|
|
||||||
seismoFields.classList.add('hidden');
|
|
||||||
modemFields.classList.add('hidden');
|
|
||||||
slmFields.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ unit_id }} - Sound Level Meter{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-6">
|
|
||||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
|
||||||
</svg>
|
|
||||||
Back to Roster
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
{{ unit_id }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
{{ unit.slm_model or 'NL-43' }} Sound Level Meter
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<span class="px-3 py-1 rounded-full text-sm font-medium
|
|
||||||
{% if unit.deployed %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
|
||||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
|
||||||
{% if unit.deployed %}Deployed{% else %}Benched{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Control Panel -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Control Panel</h2>
|
|
||||||
<div hx-get="/slm/partials/{{ unit_id }}/controls"
|
|
||||||
hx-trigger="load, every 5s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="text-center py-8 text-gray-500">Loading controls...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Real-time Data Stream -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Real-time Measurements</h2>
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div id="slm-stream-container">
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<button onclick="startStream()"
|
|
||||||
id="stream-start-btn"
|
|
||||||
class="px-6 py-3 bg-seismo-orange text-white rounded-lg hover:bg-seismo-orange-dark transition-colors">
|
|
||||||
Start Real-time Stream
|
|
||||||
</button>
|
|
||||||
<p class="text-sm text-gray-500 mt-2">Click to begin streaming live measurement data</p>
|
|
||||||
</div>
|
|
||||||
<div id="stream-data" class="hidden">
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</div>
|
|
||||||
<div id="stream-lp" class="text-3xl font-bold text-gray-900 dark:text-white">--</div>
|
|
||||||
<div class="text-xs text-gray-500">dB</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</div>
|
|
||||||
<div id="stream-leq" class="text-3xl font-bold text-blue-600 dark:text-blue-400">--</div>
|
|
||||||
<div class="text-xs text-gray-500">dB</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmax</div>
|
|
||||||
<div id="stream-lmax" class="text-3xl font-bold text-red-600 dark:text-red-400">--</div>
|
|
||||||
<div class="text-xs text-gray-500">dB</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmin</div>
|
|
||||||
<div id="stream-lmin" class="text-3xl font-bold text-green-600 dark:text-green-400">--</div>
|
|
||||||
<div class="text-xs text-gray-500">dB</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div class="text-xs text-gray-500">
|
|
||||||
<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
|
||||||
Streaming
|
|
||||||
</div>
|
|
||||||
<button onclick="stopStream()"
|
|
||||||
class="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors">
|
|
||||||
Stop Stream
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Device Information -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Device Information</h2>
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_model or 'NL-43' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Serial Number</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_serial_number or 'N/A' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Host</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_host or 'Not configured' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">TCP Port</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_tcp_port or 'N/A' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Frequency Weighting</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_frequency_weighting or 'A' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Time Weighting</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_time_weighting or 'F (Fast)' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Location</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.address or unit.location or 'Not specified' }}</div>
|
|
||||||
</div>
|
|
||||||
{% if unit.note %}
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
|
||||||
<div class="text-gray-900 dark:text-white">{{ unit.note }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let ws = null;
|
|
||||||
|
|
||||||
function startStream() {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/api/slmm/{{ unit_id }}/stream`;
|
|
||||||
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
document.getElementById('stream-start-btn').classList.add('hidden');
|
|
||||||
document.getElementById('stream-data').classList.remove('hidden');
|
|
||||||
console.log('WebSocket connected');
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
console.error('Stream error:', data.error);
|
|
||||||
stopStream();
|
|
||||||
alert('Error: ' + data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update values
|
|
||||||
document.getElementById('stream-lp').textContent = data.lp || '--';
|
|
||||||
document.getElementById('stream-leq').textContent = data.leq || '--';
|
|
||||||
document.getElementById('stream-lmax').textContent = data.lmax || '--';
|
|
||||||
document.getElementById('stream-lmin').textContent = data.lmin || '--';
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
stopStream();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
console.log('WebSocket closed');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopStream() {
|
|
||||||
if (ws) {
|
|
||||||
ws.close();
|
|
||||||
ws = null;
|
|
||||||
}
|
|
||||||
document.getElementById('stream-start-btn').classList.remove('hidden');
|
|
||||||
document.getElementById('stream-data').classList.add('hidden');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
Reference in New Issue
Block a user