Compare commits
3 Commits
1.0-experi
...
b73e4a8082
| Author | SHA1 | Date | |
|---|---|---|---|
| b73e4a8082 | |||
| 4da53facb9 | |||
| d83e459654 |
148
CHANGELOG.md
@@ -1,152 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Terra-View will be documented in this file.
|
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.5.0] - 2026-01-09
|
## [0.4.1] - 2026 1-5
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Unified Modular Monolith Architecture**: Complete architectural refactoring to modular monolith pattern
|
- **SLM integration**: Sound Level meters are now managable in SFM
|
||||||
- **Three Feature Modules**: Seismo (seismograph fleet), SLM (sound level meters), UI (shared templates/static)
|
|
||||||
- **Module Isolation**: Each module has its own database, models, services, and routers
|
|
||||||
- **Shared Infrastructure**: Common utilities and API aggregation layer
|
|
||||||
- **Multi-Container Deployment**: Three Docker containers (terra-view, sfm, slmm) built from single codebase
|
|
||||||
- **SLMM Integration**: Sound Level Meter Manager fully integrated as `app/slm/` module
|
|
||||||
- Migrated from separate repository to unified codebase
|
|
||||||
- Complete NL43 device management API (`/api/nl43/*`)
|
|
||||||
- Database models for NL43Config and NL43Status
|
|
||||||
- NL43Client service for device communication
|
|
||||||
- FTP, TCP, and web interface support for NL43 devices
|
|
||||||
- **SLM Dashboard API Layer**: New dashboard endpoints bridge UI and device APIs
|
|
||||||
- `GET /api/slm-dashboard/stats` - Aggregate statistics (total units, online/offline, measuring/idle)
|
|
||||||
- `GET /api/slm-dashboard/units` - List all units with latest status
|
|
||||||
- `GET /api/slm-dashboard/live-view/{unit_id}` - Real-time measurement data
|
|
||||||
- `GET /api/slm-dashboard/config/{unit_id}` - Retrieve unit configuration
|
|
||||||
- `POST /api/slm-dashboard/config/{unit_id}` - Update unit configuration
|
|
||||||
- `POST /api/slm-dashboard/control/{unit_id}/{action}` - Send control commands (start, stop, pause, resume, reset, sleep, wake)
|
|
||||||
- `GET /api/slm-dashboard/test-modem/{unit_id}` - Test device connectivity
|
|
||||||
- **Repository Rebranding**: Renamed from `seismo-fleet-manager` to `terra-view`
|
|
||||||
- Reflects unified platform nature (seismo + SLM + future modules)
|
|
||||||
- Git remote updated to `terra-view.git`
|
|
||||||
- All references updated throughout codebase
|
|
||||||
|
|
||||||
### Changed
|
## Fixed ##
|
||||||
- **Project Structure**: Complete reorganization following modular monolith pattern
|
- 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.
|
||||||
- `app/seismo/` - Seismograph fleet module (formerly `backend/`)
|
|
||||||
- `app/slm/` - Sound level meter module (integrated from SLMM)
|
|
||||||
- `app/ui/` - Shared templates and static assets
|
|
||||||
- `app/api/` - Cross-module API aggregation layer
|
|
||||||
- Removed `backend/` and `templates/` directories
|
|
||||||
- **Import Paths**: All imports updated from `backend.*` to `app.seismo.*` or `app.slm.*`
|
|
||||||
- **Database Initialization**: Each module initializes its own database tables
|
|
||||||
- Seismo database: `app/seismo/database.py`
|
|
||||||
- SLM database: `app/slm/database.py`
|
|
||||||
- **Docker Architecture**: Three-container deployment from single codebase
|
|
||||||
- `terra-view` (port 8001): Main UI/orchestrator with all modules
|
|
||||||
- `sfm` (port 8002): Seismograph Fleet Module API
|
|
||||||
- `slmm` (port 8100): Sound Level Meter Manager API
|
|
||||||
- All containers built from same unified codebase with different entry points
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Template Path Issues**: Fixed seismo dashboard template references
|
|
||||||
- Updated `app/seismo/routers/dashboard.py` to use `app/ui/templates` directory
|
|
||||||
- Resolved 404 errors for `partials/benched_table.html` and `partials/active_table.html`
|
|
||||||
- **Module Import Errors**: Corrected SLMM module structure
|
|
||||||
- Fixed `app/slm/main.py` to import from `app.slm.routers` instead of `app.routers`
|
|
||||||
- Updated all SLMM internal imports to use `app.slm.*` namespace
|
|
||||||
- **Docker Build Issues**: Resolved file permission problems
|
|
||||||
- Fixed dashboard.py permissions for Docker COPY operations
|
|
||||||
- Ensured all source files readable during container builds
|
|
||||||
|
|
||||||
### Technical Details
|
|
||||||
- **Modular Monolith Benefits**:
|
|
||||||
- Single repository for easier development and deployment
|
|
||||||
- Module boundaries enforced through folder structure
|
|
||||||
- Shared dependencies managed in single requirements.txt
|
|
||||||
- Independent database schemas per module
|
|
||||||
- Clean separation of concerns with explicit module APIs
|
|
||||||
- **Migration Path**: Existing installations automatically migrate
|
|
||||||
- Import path updates applied programmatically
|
|
||||||
- Database schemas remain compatible
|
|
||||||
- No data migration required
|
|
||||||
- **Module Structure**: Each module follows consistent pattern
|
|
||||||
- `database.py` - SQLAlchemy models and session management
|
|
||||||
- `models.py` - Pydantic schemas and database models
|
|
||||||
- `routers.py` - FastAPI route definitions
|
|
||||||
- `services.py` - Business logic and external integrations
|
|
||||||
- **Container Communication**: Containers use host networking
|
|
||||||
- terra-view proxies to sfm and slmm containers
|
|
||||||
- Environment variables configure API URLs
|
|
||||||
- Health checks ensure container availability
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
- **Breaking Changes**: Import paths changed for all modules
|
|
||||||
- Old: `from backend.models import RosterUnit`
|
|
||||||
- New: `from app.seismo.models import RosterUnit`
|
|
||||||
- **Configuration Updates**: Environment variables for multi-container setup
|
|
||||||
- `SFM_API_URL=http://localhost:8002` - SFM backend endpoint
|
|
||||||
- `SLMM_API_URL=http://localhost:8100` - SLMM backend endpoint
|
|
||||||
- `MODULE_MODE=sfm|slmm` - Future flag for API-only containers
|
|
||||||
- **Repository Migration**: Update git remotes for renamed repository
|
|
||||||
```bash
|
|
||||||
git remote set-url origin ssh://git@10.0.0.2:2222/serversdown/terra-view.git
|
|
||||||
```
|
|
||||||
|
|
||||||
## [0.4.2] - 2026-01-05
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **SLM Configuration Interface**: Sound Level Meters can now be configured directly from the SLM dashboard
|
|
||||||
- Configuration modal with comprehensive SLM parameter editing
|
|
||||||
- TCP port configuration for SLM control connections (default: 2255)
|
|
||||||
- FTP port configuration for SLM data retrieval (default: 21)
|
|
||||||
- Modem assignment for network access or direct IP connection support
|
|
||||||
- Test Modem button with ping-based connectivity verification (shows IP and response time)
|
|
||||||
- Test SLM Connection button for end-to-end connectivity validation
|
|
||||||
- Dynamic form fields that hide/show based on modem selection
|
|
||||||
- **SLM Dashboard Endpoints**: New API routes for SLM management
|
|
||||||
- `GET /api/slm-dashboard/config/{unit_id}` - Load SLM configuration form
|
|
||||||
- `POST /api/slm-dashboard/config/{unit_id}` - Save SLM configuration
|
|
||||||
- `GET /api/slm-dashboard/test-modem/{modem_id}` - Ping modem for connectivity test
|
|
||||||
- **Database Schema Updates**: Added `slm_ftp_port` column to roster table
|
|
||||||
- Migration script: `scripts/add_slm_ftp_port.py`
|
|
||||||
- Supports both TCP (control) and FTP (data) port configuration per SLM unit
|
|
||||||
- **Docker Environment Enhancements**:
|
|
||||||
- Added `iputils-ping` and `curl` packages to Docker image for network diagnostics
|
|
||||||
- Health check endpoint support via curl
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Form Validation**: Fixed 400 Bad Request error when adding modem units
|
|
||||||
- Form fields for device-specific parameters now properly disabled when hidden
|
|
||||||
- Empty string values for integer fields no longer cause validation failures
|
|
||||||
- JavaScript now disables hidden form sections to prevent unwanted data submission
|
|
||||||
- **Unit Status Accuracy**: Fixed issue where unit status was loading from a saved cache instead of actual last-heard time
|
|
||||||
- Unit status now accurately reflects real-time connectivity
|
|
||||||
- Status determination based on actual `slm_last_check` timestamp
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Roster Form Behavior**: Device-specific form fields are now disabled (not just hidden) when not applicable
|
|
||||||
- Prevents SLM fields from submitting when adding modems
|
|
||||||
- Prevents modem fields from submitting when adding SLMs
|
|
||||||
- Cleaner form submissions with only relevant data
|
|
||||||
- **Port Field Handling**: Backend now accepts port fields as strings and converts to integers
|
|
||||||
- Handles empty string values gracefully
|
|
||||||
- Proper type conversion with None fallback for empty values
|
|
||||||
|
|
||||||
### Technical Details
|
|
||||||
- Added `setFieldsDisabled()` helper function for managing form field state
|
|
||||||
- Updated `toggleDeviceFields()` and `toggleEditDeviceFields()` to disable/enable fields
|
|
||||||
- Backend type conversion: `slm_tcp_port` and `slm_ftp_port` accept strings, convert to int with empty string handling
|
|
||||||
- Modem ping uses subprocess with 1 packet, 2-second timeout, returns response time in milliseconds
|
|
||||||
- Configuration form uses 3-column grid layout for TCP Port, FTP Port, and Direct IP fields
|
|
||||||
|
|
||||||
## [0.4.1] - 2026-01-05
|
|
||||||
### Added
|
|
||||||
- **SLM Integration**: Sound Level Meters are now manageable in SFM
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Fixed an issue where unit status was 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
|
||||||
@@ -437,8 +301,6 @@ No database migration required for v0.4.0. All new features use existing databas
|
|||||||
- Photo management per unit
|
- Photo management per unit
|
||||||
- Automated status categorization (OK/Pending/Missing)
|
- Automated status categorization (OK/Pending/Missing)
|
||||||
|
|
||||||
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
|
||||||
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
|
||||||
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
|
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
|
||||||
[0.3.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.2...v0.3.3
|
[0.3.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.2...v0.3.3
|
||||||
[0.3.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.1...v0.3.2
|
[0.3.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.1...v0.3.2
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ FROM python:3.11-slim
|
|||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies (ping for network diagnostics)
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y --no-install-recommends iputils-ping curl && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
# Copy requirements first for better caching
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y --no-install-recommends iputils-ping curl && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Expose SFM port
|
|
||||||
EXPOSE 8002
|
|
||||||
|
|
||||||
# Run SFM backend (API only)
|
|
||||||
# For now: runs same app on different port
|
|
||||||
# Future: will run SFM-specific entry point
|
|
||||||
CMD ["python3", "-m", "app.main"]
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y --no-install-recommends curl && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy requirements and install dependencies
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY app /app/app
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8100
|
|
||||||
|
|
||||||
# Run the SLM application
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8100"]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y --no-install-recommends iputils-ping curl && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Expose Terra-View UI port
|
|
||||||
EXPOSE 8001
|
|
||||||
|
|
||||||
# Run Terra-View (UI + orchestration)
|
|
||||||
CMD ["python3", "-m", "app.main"]
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
# Terra-View Modular Monolith - Known-Good Baseline
|
|
||||||
|
|
||||||
**Date:** 2026-01-09
|
|
||||||
**Status:** ✅ IMPORT MIGRATION COMPLETE
|
|
||||||
|
|
||||||
## What We've Achieved
|
|
||||||
|
|
||||||
Successfully restructured the application into a modular monolith architecture with the new folder structure working end-to-end.
|
|
||||||
|
|
||||||
## New Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
/home/serversdown/sfm/seismo-fleet-manager/
|
|
||||||
├── app/
|
|
||||||
│ ├── main.py # NEW: Entry point with Terra-View branding
|
|
||||||
│ ├── core/ # Shared infrastructure
|
|
||||||
│ │ ├── config.py # NEW: Centralized configuration
|
|
||||||
│ │ └── database.py # Shared DB utilities
|
|
||||||
│ ├── ui/ # UI Layer (device-agnostic)
|
|
||||||
│ │ ├── routes.py # NEW: HTML page routes
|
|
||||||
│ │ ├── templates/ # All HTML templates (copied from old location)
|
|
||||||
│ │ └── static/ # All static files (copied from old location)
|
|
||||||
│ ├── seismo/ # Seismograph Feature Module
|
|
||||||
│ │ ├── models.py # ✅ Updated to use app.seismo.database
|
|
||||||
│ │ ├── database.py # NEW: Seismo-specific DB connection
|
|
||||||
│ │ ├── routers/ # API routers (copied from backend/routers/)
|
|
||||||
│ │ └── services/ # Business logic (copied from backend/services/)
|
|
||||||
│ ├── slm/ # Sound Level Meter Feature Module
|
|
||||||
│ │ ├── models.py # NEW: Placeholder for SLM models
|
|
||||||
│ │ ├── database.py # NEW: SLM-specific DB connection
|
|
||||||
│ │ └── routers/ # SLM routers (copied from backend/routers/)
|
|
||||||
│ └── api/ # API Aggregation Layer (placeholder)
|
|
||||||
│ ├── dashboard.py # NEW: Future aggregation endpoints
|
|
||||||
│ └── roster.py # NEW: Future aggregation endpoints
|
|
||||||
└── data/
|
|
||||||
└── seismo_fleet.db # Still using shared DB (migration pending)
|
|
||||||
```
|
|
||||||
|
|
||||||
## What's Working
|
|
||||||
|
|
||||||
✅ **Application starts successfully** on port 9999
|
|
||||||
✅ **Health endpoint works**: `/health` returns Terra-View v1.0.0
|
|
||||||
✅ **UI renders**: Main dashboard loads with proper templates
|
|
||||||
✅ **API endpoints work**: `/api/status-snapshot` returns seismograph data
|
|
||||||
✅ **Database access works**: Models properly connected
|
|
||||||
✅ **Static files serve**: CSS, JS, icons all accessible
|
|
||||||
|
|
||||||
## Critical Changes Made
|
|
||||||
|
|
||||||
### 1. Fixed Import in models.py
|
|
||||||
**File:** `app/seismo/models.py`
|
|
||||||
**Change:** `from backend.database import Base` → `from app.seismo.database import Base`
|
|
||||||
**Reason:** Avoid duplicate Base instances causing SQLAlchemy errors
|
|
||||||
|
|
||||||
### 2. Created New Entry Point
|
|
||||||
**File:** `app/main.py`
|
|
||||||
**Features:**
|
|
||||||
- Terra-View branding (title, version, health check)
|
|
||||||
- Imports from new `app.*` structure
|
|
||||||
- Registers all seismo and SLM routers
|
|
||||||
- Middleware for environment context
|
|
||||||
|
|
||||||
### 3. Created UI Routes Module
|
|
||||||
**File:** `app/ui/routes.py`
|
|
||||||
**Purpose:** Centralize all HTML page routes (device-agnostic)
|
|
||||||
|
|
||||||
### 4. Created Module-Specific Databases
|
|
||||||
**Files:** `app/seismo/database.py`, `app/slm/database.py`
|
|
||||||
**Status:** Both currently point to shared `seismo_fleet.db` (migration pending)
|
|
||||||
|
|
||||||
## Recent Updates (2026-01-09)
|
|
||||||
|
|
||||||
✅ **ALL imports updated** - Changed all `backend.*` imports to `app.seismo.*` or `app.slm.*`
|
|
||||||
✅ **Old structure deleted** - `backend/` and `templates/` directories removed
|
|
||||||
✅ **Containers rebuilt** - All three containers (Terra-View, SFM, SLMM) working with new imports
|
|
||||||
✅ **Verified working** - Tested health endpoints and UI after migration
|
|
||||||
|
|
||||||
## What's NOT Yet Done
|
|
||||||
|
|
||||||
❌ **Partial routes missing** - `/partials/*` endpoints not yet added
|
|
||||||
❌ **Database not split** - Still using shared `seismo_fleet.db`
|
|
||||||
|
|
||||||
## How to Run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start on custom port to avoid conflicts
|
|
||||||
PORT=9999 python3 -m app.main
|
|
||||||
|
|
||||||
# Test health endpoint
|
|
||||||
curl http://localhost:9999/health
|
|
||||||
|
|
||||||
# Test API endpoint
|
|
||||||
curl http://localhost:9999/api/status-snapshot
|
|
||||||
|
|
||||||
# Access UI
|
|
||||||
open http://localhost:9999/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps (Recommended Order)
|
|
||||||
|
|
||||||
1. **Add partial routes** to app/main.py or create separate router
|
|
||||||
2. **Test all endpoints thoroughly** - Verify roster CRUD, photos, settings
|
|
||||||
3. **Split databases** (Phase 2 of plan)
|
|
||||||
4. **Implement API aggregation layer** (Phase 3 of plan)
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
|
|
||||||
None currently - app starts and serves requests successfully!
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [x] App starts without errors
|
|
||||||
- [x] Health endpoint returns correct version
|
|
||||||
- [x] Main dashboard loads
|
|
||||||
- [x] Status snapshot API works
|
|
||||||
- [ ] All seismo endpoints work
|
|
||||||
- [ ] All SLM endpoints work
|
|
||||||
- [ ] Roster CRUD operations work
|
|
||||||
- [ ] Photos upload/download works
|
|
||||||
- [ ] Settings page works
|
|
||||||
|
|
||||||
## Rollback Instructions
|
|
||||||
|
|
||||||
~~The old structure has been deleted.~~ To rollback, restore from your backup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Restore from your backup
|
|
||||||
# The old backend/ and templates/ directories were removed on 2026-01-09
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
- **MIGRATION COMPLETE**: Old `backend/` and `templates/` directories removed
|
|
||||||
- **ALL IMPORTS UPDATED**: All Python files now use `app.*` imports
|
|
||||||
- **NO DATA LOSS**: Database untouched, only code structure changed
|
|
||||||
- **CONTAINERS WORKING**: All three containers (Terra-View, SFM, SLMM) healthy
|
|
||||||
- **FULLY SELF-CONTAINED**: Application runs entirely from `app/` directory
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Congratulations!** 🎉 Import migration complete! The modular monolith is now self-contained and production-ready.
|
|
||||||
30
README.md
@@ -1,31 +1,5 @@
|
|||||||
# Terra-View v0.5.0
|
# Seismo Fleet Manager v0.4.1
|
||||||
Unified platform for managing seismograph fleets and sound level meter deployments. Built as a modular monolith with independent feature modules (Seismo, SLM) sharing a common UI layer. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your entire 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.
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Terra-View follows a **modular monolith** architecture with independent feature modules in a single codebase:
|
|
||||||
|
|
||||||
- **app/seismo/** - Seismograph Fleet Module (SFM)
|
|
||||||
- Device roster and deployment tracking
|
|
||||||
- Series 3/4 telemetry ingestion
|
|
||||||
- Status monitoring (OK/Pending/Missing)
|
|
||||||
- Photo management and location tracking
|
|
||||||
- **app/slm/** - Sound Level Meter Manager (SLMM)
|
|
||||||
- NL43 device configuration and control
|
|
||||||
- Real-time measurement monitoring
|
|
||||||
- TCP/FTP/Web interface support
|
|
||||||
- Dashboard statistics and unit management
|
|
||||||
- **app/ui/** - Shared UI layer
|
|
||||||
- Templates, static assets, and common components
|
|
||||||
- Progressive Web App (PWA) support
|
|
||||||
- **app/api/** - API aggregation layer
|
|
||||||
- Cross-module endpoints
|
|
||||||
- Future unified dashboard APIs
|
|
||||||
|
|
||||||
**Multi-Container Deployment**: Three Docker containers built from the same codebase:
|
|
||||||
- `terra-view` (port 8001) - Main UI with all modules integrated
|
|
||||||
- `sfm` (port 8002) - Seismo API backend
|
|
||||||
- `slmm` (port 8100) - SLM API backend
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
"""
|
|
||||||
API Aggregation Layer - Dashboard endpoints
|
|
||||||
Composes data from multiple feature modules
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard-aggregation"])
|
|
||||||
|
|
||||||
# TODO: Implement aggregation endpoints that combine data from
|
|
||||||
# app.seismo and app.slm modules
|
|
||||||
|
|
||||||
# For now, individual feature modules expose their own APIs directly
|
|
||||||
# Future: Add cross-feature aggregation here
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"""
|
|
||||||
API Aggregation Layer - Roster endpoints
|
|
||||||
Aggregates roster data from all feature modules
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/roster-aggregation", tags=["roster-aggregation"])
|
|
||||||
|
|
||||||
# TODO: Implement unified roster endpoints that combine data from
|
|
||||||
# app.seismo and app.slm modules
|
|
||||||
|
|
||||||
# For now, individual feature modules expose their own roster APIs
|
|
||||||
# Future: Add cross-feature roster aggregation here
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"""
|
|
||||||
SLMM API Proxy
|
|
||||||
Forwards /api/slmm/* requests to the SLMM backend service
|
|
||||||
"""
|
|
||||||
import httpx
|
|
||||||
import logging
|
|
||||||
from fastapi import APIRouter, Request, Response, WebSocket
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from app.core.config import SLMM_API_URL
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/slmm", tags=["slmm-proxy"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
||||||
async def proxy_slmm_request(path: str, request: Request):
|
|
||||||
"""Proxy HTTP requests to SLMM backend"""
|
|
||||||
# Build target URL - rewrite /api/slmm/* to /api/nl43/*
|
|
||||||
target_url = f"{SLMM_API_URL}/api/nl43/{path}"
|
|
||||||
|
|
||||||
# Get query params
|
|
||||||
query_string = str(request.url.query)
|
|
||||||
if query_string:
|
|
||||||
target_url += f"?{query_string}"
|
|
||||||
|
|
||||||
logger.info(f"Proxying {request.method} {target_url}")
|
|
||||||
|
|
||||||
# Read request body
|
|
||||||
body = await request.body()
|
|
||||||
|
|
||||||
# Forward headers (exclude host)
|
|
||||||
headers = {
|
|
||||||
key: value
|
|
||||||
for key, value in request.headers.items()
|
|
||||||
if key.lower() not in ['host', 'content-length']
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
try:
|
|
||||||
# Make proxied request
|
|
||||||
response = await client.request(
|
|
||||||
method=request.method,
|
|
||||||
url=target_url,
|
|
||||||
content=body,
|
|
||||||
headers=headers
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return response
|
|
||||||
return Response(
|
|
||||||
content=response.content,
|
|
||||||
status_code=response.status_code,
|
|
||||||
headers=dict(response.headers)
|
|
||||||
)
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
logger.error(f"Proxy request failed: {e}")
|
|
||||||
return Response(
|
|
||||||
content=f'{{"detail": "SLMM backend unavailable: {str(e)}"}}',
|
|
||||||
status_code=502,
|
|
||||||
media_type="application/json"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/{unit_id}/live")
|
|
||||||
async def proxy_slmm_websocket(websocket: WebSocket, unit_id: str):
|
|
||||||
"""Proxy WebSocket connections to SLMM backend for live data streaming"""
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
# Build WebSocket URL
|
|
||||||
ws_protocol = "ws" if "localhost" in SLMM_API_URL or "127.0.0.1" in SLMM_API_URL else "wss"
|
|
||||||
ws_url = SLMM_API_URL.replace("http://", f"{ws_protocol}://").replace("https://", f"{ws_protocol}://")
|
|
||||||
ws_target = f"{ws_url}/api/slmm/{unit_id}/live"
|
|
||||||
|
|
||||||
logger.info(f"Proxying WebSocket to {ws_target}")
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
try:
|
|
||||||
async with client.stream("GET", ws_target) as response:
|
|
||||||
async for chunk in response.aiter_bytes():
|
|
||||||
await websocket.send_bytes(chunk)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"WebSocket proxy error: {e}")
|
|
||||||
await websocket.close(code=1011, reason=f"Backend error: {str(e)}")
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""
|
|
||||||
Core configuration for Terra-View application
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Application
|
|
||||||
APP_NAME = "Terra-View"
|
|
||||||
VERSION = "1.0.0"
|
|
||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
|
||||||
|
|
||||||
# Ports
|
|
||||||
PORT = int(os.getenv("PORT", 8001))
|
|
||||||
|
|
||||||
# External Services
|
|
||||||
# Terra-View is a unified application with seismograph logic built-in
|
|
||||||
# The only external HTTP dependency is SLMM for NL-43 device communication
|
|
||||||
SLMM_API_URL = os.getenv("SLMM_API_URL", "http://localhost:8100")
|
|
||||||
|
|
||||||
# Database URLs (feature-specific)
|
|
||||||
SEISMO_DATABASE_URL = "sqlite:///./data/seismo.db"
|
|
||||||
SLM_DATABASE_URL = "sqlite:///./data/slm.db"
|
|
||||||
MODEM_DATABASE_URL = "sqlite:///./data/modem.db"
|
|
||||||
216
app/main.py
@@ -1,216 +0,0 @@
|
|||||||
"""
|
|
||||||
Terra-View - Unified monitoring platform for device fleets
|
|
||||||
Modular monolith architecture with strict feature boundaries
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Import configuration
|
|
||||||
from app.core.config import APP_NAME, VERSION, ENVIRONMENT
|
|
||||||
|
|
||||||
# Import UI routes
|
|
||||||
from app.ui import routes as ui_routes
|
|
||||||
|
|
||||||
# Import feature module routers (seismo)
|
|
||||||
from app.seismo.routers import (
|
|
||||||
roster as seismo_roster,
|
|
||||||
units as seismo_units,
|
|
||||||
photos as seismo_photos,
|
|
||||||
roster_edit as seismo_roster_edit,
|
|
||||||
dashboard as seismo_dashboard,
|
|
||||||
dashboard_tabs as seismo_dashboard_tabs,
|
|
||||||
activity as seismo_activity,
|
|
||||||
seismo_dashboard as seismo_seismo_dashboard,
|
|
||||||
settings as seismo_settings,
|
|
||||||
partials as seismo_partials,
|
|
||||||
)
|
|
||||||
from app.seismo import routes as seismo_legacy_routes
|
|
||||||
|
|
||||||
# Import feature module routers (SLM)
|
|
||||||
from app.slm.routers import router as slm_router
|
|
||||||
from app.slm.dashboard import router as slm_dashboard_router
|
|
||||||
|
|
||||||
# Import API aggregation layer (placeholder for now)
|
|
||||||
from app.api import dashboard as api_dashboard
|
|
||||||
from app.api import roster as api_roster
|
|
||||||
|
|
||||||
# Initialize database tables
|
|
||||||
from app.seismo.database import engine as seismo_engine, Base as SeismoBase
|
|
||||||
SeismoBase.metadata.create_all(bind=seismo_engine)
|
|
||||||
|
|
||||||
from app.slm.database import engine as slm_engine, Base as SlmBase
|
|
||||||
SlmBase.metadata.create_all(bind=slm_engine)
|
|
||||||
|
|
||||||
# Initialize FastAPI app
|
|
||||||
app = FastAPI(
|
|
||||||
title=APP_NAME,
|
|
||||||
description="Unified monitoring platform for seismograph, modem, and sound level meter fleets",
|
|
||||||
version=VERSION
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add validation error handler to log details
|
|
||||||
@app.exception_handler(RequestValidationError)
|
|
||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
|
||||||
logger.error(f"Validation error on {request.url}: {exc.errors()}")
|
|
||||||
logger.error(f"Body: {await request.body()}")
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=400,
|
|
||||||
content={"detail": exc.errors()}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Configure CORS
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mount static files
|
|
||||||
app.mount("/static", StaticFiles(directory="app/ui/static"), name="static")
|
|
||||||
|
|
||||||
# Middleware to add environment to request state
|
|
||||||
@app.middleware("http")
|
|
||||||
async def add_environment_to_context(request: Request, call_next):
|
|
||||||
"""Middleware to add environment variable to request state"""
|
|
||||||
request.state.environment = ENVIRONMENT
|
|
||||||
response = await call_next(request)
|
|
||||||
return response
|
|
||||||
|
|
||||||
# ===== INCLUDE ROUTERS =====
|
|
||||||
|
|
||||||
# UI Layer (HTML pages)
|
|
||||||
app.include_router(ui_routes.router)
|
|
||||||
|
|
||||||
# Seismograph Feature Module APIs
|
|
||||||
app.include_router(seismo_roster.router)
|
|
||||||
app.include_router(seismo_units.router)
|
|
||||||
app.include_router(seismo_photos.router)
|
|
||||||
app.include_router(seismo_roster_edit.router)
|
|
||||||
app.include_router(seismo_dashboard.router)
|
|
||||||
app.include_router(seismo_dashboard_tabs.router)
|
|
||||||
app.include_router(seismo_activity.router)
|
|
||||||
app.include_router(seismo_seismo_dashboard.router)
|
|
||||||
app.include_router(seismo_settings.router)
|
|
||||||
app.include_router(seismo_partials.router, prefix="/partials")
|
|
||||||
app.include_router(seismo_legacy_routes.router)
|
|
||||||
|
|
||||||
# SLM Feature Module APIs
|
|
||||||
app.include_router(slm_router)
|
|
||||||
app.include_router(slm_dashboard_router)
|
|
||||||
|
|
||||||
# SLMM Backend Proxy (forward /api/slmm/* to SLMM service)
|
|
||||||
from app.api import slmm_proxy
|
|
||||||
app.include_router(slmm_proxy.router)
|
|
||||||
|
|
||||||
# API Aggregation Layer (future cross-feature endpoints)
|
|
||||||
# app.include_router(api_dashboard.router) # TODO: Implement aggregation
|
|
||||||
# app.include_router(api_roster.router) # TODO: Implement aggregation
|
|
||||||
|
|
||||||
# ===== ADDITIONAL ROUTES FROM OLD MAIN.PY =====
|
|
||||||
# These will need to be migrated to appropriate modules
|
|
||||||
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from typing import List, Dict
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from fastapi import Depends
|
|
||||||
|
|
||||||
from app.seismo.database import get_db
|
|
||||||
from app.seismo.services.snapshot import emit_status_snapshot
|
|
||||||
from app.seismo.models import IgnoredUnit
|
|
||||||
|
|
||||||
# TODO: Move these to appropriate feature modules or UI layer
|
|
||||||
|
|
||||||
@app.post("/api/sync-edits")
|
|
||||||
async def sync_edits(request: dict, db: Session = Depends(get_db)):
|
|
||||||
"""Process offline edit queue and sync to database"""
|
|
||||||
# TODO: Move to seismo module
|
|
||||||
from app.seismo.models import RosterUnit
|
|
||||||
|
|
||||||
class EditItem(BaseModel):
|
|
||||||
id: int
|
|
||||||
unitId: str
|
|
||||||
changes: Dict
|
|
||||||
timestamp: int
|
|
||||||
|
|
||||||
class SyncEditsRequest(BaseModel):
|
|
||||||
edits: List[EditItem]
|
|
||||||
|
|
||||||
sync_request = SyncEditsRequest(**request)
|
|
||||||
results = []
|
|
||||||
synced_ids = []
|
|
||||||
|
|
||||||
for edit in sync_request.edits:
|
|
||||||
try:
|
|
||||||
unit = db.query(RosterUnit).filter_by(id=edit.unitId).first()
|
|
||||||
|
|
||||||
if not unit:
|
|
||||||
results.append({
|
|
||||||
"id": edit.id,
|
|
||||||
"status": "error",
|
|
||||||
"reason": f"Unit {edit.unitId} not found"
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
for key, value in edit.changes.items():
|
|
||||||
if hasattr(unit, key):
|
|
||||||
if key in ['deployed', 'retired']:
|
|
||||||
setattr(unit, key, value in ['true', True, 'True', '1', 1])
|
|
||||||
else:
|
|
||||||
setattr(unit, key, value if value != '' else None)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
"id": edit.id,
|
|
||||||
"status": "success"
|
|
||||||
})
|
|
||||||
synced_ids.append(edit.id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
results.append({
|
|
||||||
"id": edit.id,
|
|
||||||
"status": "error",
|
|
||||||
"reason": str(e)
|
|
||||||
})
|
|
||||||
|
|
||||||
synced_count = len(synced_ids)
|
|
||||||
|
|
||||||
return JSONResponse({
|
|
||||||
"synced": synced_count,
|
|
||||||
"total": len(sync_request.edits),
|
|
||||||
"synced_ids": synced_ids,
|
|
||||||
"results": results
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health_check():
|
|
||||||
"""Health check endpoint"""
|
|
||||||
return {
|
|
||||||
"message": f"{APP_NAME} v{VERSION}",
|
|
||||||
"status": "running",
|
|
||||||
"version": VERSION,
|
|
||||||
"modules": ["seismo", "slm"]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
from app.core.config import PORT
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""
|
|
||||||
Seismograph feature module database connection
|
|
||||||
"""
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Ensure data directory exists
|
|
||||||
os.makedirs("data", exist_ok=True)
|
|
||||||
|
|
||||||
# For now, we'll use the old database (seismo_fleet.db) until we migrate
|
|
||||||
# TODO: Migrate to seismo.db
|
|
||||||
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)
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
"""Dependency for database sessions"""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_db_session():
|
|
||||||
"""Get a database session directly (not as a dependency)"""
|
|
||||||
return SessionLocal()
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"""
|
|
||||||
Partial routes for HTMX dynamic content loading.
|
|
||||||
These routes return HTML fragments that are loaded into the page via HTMX.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
from app.seismo.services.snapshot import emit_status_snapshot
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
templates = Jinja2Templates(directory="app/ui/templates")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/unknown-emitters", response_class=HTMLResponse)
|
|
||||||
async def get_unknown_emitters(request: Request):
|
|
||||||
"""
|
|
||||||
Returns HTML partial with unknown emitters (units reporting but not in roster).
|
|
||||||
Called periodically via HTMX (every 10s) from the roster page.
|
|
||||||
"""
|
|
||||||
snapshot = emit_status_snapshot()
|
|
||||||
|
|
||||||
# Convert unknown units dict to list and add required fields
|
|
||||||
unknown_list = []
|
|
||||||
for unit_id, unit_data in snapshot.get("unknown", {}).items():
|
|
||||||
unknown_list.append({
|
|
||||||
"id": unit_id,
|
|
||||||
"status": unit_data["status"],
|
|
||||||
"age": unit_data["age"],
|
|
||||||
"fname": unit_data.get("fname", ""),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by ID for consistent display
|
|
||||||
unknown_list.sort(key=lambda x: x["id"])
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"partials/unknown_emitters.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"unknown_units": unknown_list
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/devices-all", response_class=HTMLResponse)
|
|
||||||
async def get_all_devices(request: Request):
|
|
||||||
"""
|
|
||||||
Returns HTML partial with all devices (deployed, benched, retired, ignored).
|
|
||||||
Called on page load and when filters are applied.
|
|
||||||
"""
|
|
||||||
snapshot = emit_status_snapshot()
|
|
||||||
|
|
||||||
# Combine all units from different buckets
|
|
||||||
all_units = []
|
|
||||||
|
|
||||||
# Add active units (deployed)
|
|
||||||
for unit_id, unit_data in snapshot.get("active", {}).items():
|
|
||||||
unit_info = {
|
|
||||||
"id": unit_id,
|
|
||||||
"status": unit_data["status"],
|
|
||||||
"age": unit_data["age"],
|
|
||||||
"last_seen": unit_data.get("last", ""),
|
|
||||||
"fname": unit_data.get("fname", ""),
|
|
||||||
"deployed": True,
|
|
||||||
"retired": False,
|
|
||||||
"ignored": False,
|
|
||||||
"note": unit_data.get("note", ""),
|
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
|
||||||
"location": unit_data.get("location", ""),
|
|
||||||
"address": unit_data.get("address", ""),
|
|
||||||
"coordinates": unit_data.get("coordinates", ""),
|
|
||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
|
||||||
"ip_address": unit_data.get("ip_address"),
|
|
||||||
"phone_number": unit_data.get("phone_number"),
|
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
|
||||||
}
|
|
||||||
all_units.append(unit_info)
|
|
||||||
|
|
||||||
# Add benched units (not deployed, not retired)
|
|
||||||
for unit_id, unit_data in snapshot.get("benched", {}).items():
|
|
||||||
unit_info = {
|
|
||||||
"id": unit_id,
|
|
||||||
"status": unit_data["status"],
|
|
||||||
"age": unit_data["age"],
|
|
||||||
"last_seen": unit_data.get("last", ""),
|
|
||||||
"fname": unit_data.get("fname", ""),
|
|
||||||
"deployed": False,
|
|
||||||
"retired": False,
|
|
||||||
"ignored": False,
|
|
||||||
"note": unit_data.get("note", ""),
|
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
|
||||||
"location": unit_data.get("location", ""),
|
|
||||||
"address": unit_data.get("address", ""),
|
|
||||||
"coordinates": unit_data.get("coordinates", ""),
|
|
||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
|
||||||
"ip_address": unit_data.get("ip_address"),
|
|
||||||
"phone_number": unit_data.get("phone_number"),
|
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
|
||||||
}
|
|
||||||
all_units.append(unit_info)
|
|
||||||
|
|
||||||
# Add retired units
|
|
||||||
for unit_id, unit_data in snapshot.get("retired", {}).items():
|
|
||||||
unit_info = {
|
|
||||||
"id": unit_id,
|
|
||||||
"status": "Retired",
|
|
||||||
"age": unit_data["age"],
|
|
||||||
"last_seen": unit_data.get("last", ""),
|
|
||||||
"fname": unit_data.get("fname", ""),
|
|
||||||
"deployed": False,
|
|
||||||
"retired": True,
|
|
||||||
"ignored": False,
|
|
||||||
"note": unit_data.get("note", ""),
|
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
|
||||||
"location": unit_data.get("location", ""),
|
|
||||||
"address": unit_data.get("address", ""),
|
|
||||||
"coordinates": unit_data.get("coordinates", ""),
|
|
||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
|
||||||
"ip_address": unit_data.get("ip_address"),
|
|
||||||
"phone_number": unit_data.get("phone_number"),
|
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
|
||||||
}
|
|
||||||
all_units.append(unit_info)
|
|
||||||
|
|
||||||
# Sort by ID for consistent display
|
|
||||||
all_units.sort(key=lambda x: x["id"])
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"partials/devices_table.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"units": all_units
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"""
|
|
||||||
Seismograph Dashboard API Router
|
|
||||||
Provides endpoints for the seismograph-specific dashboard
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends, Query
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.seismo.database import get_db
|
|
||||||
from app.seismo.models import RosterUnit
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
|
||||||
templates = Jinja2Templates(directory="app/ui/templates")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_class=HTMLResponse)
|
|
||||||
async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
Returns HTML partial with seismograph statistics summary
|
|
||||||
"""
|
|
||||||
# Get all seismograph units
|
|
||||||
seismos = db.query(RosterUnit).filter_by(
|
|
||||||
device_type="seismograph",
|
|
||||||
retired=False
|
|
||||||
).all()
|
|
||||||
|
|
||||||
total = len(seismos)
|
|
||||||
deployed = sum(1 for s in seismos if s.deployed)
|
|
||||||
benched = sum(1 for s in seismos if not s.deployed)
|
|
||||||
|
|
||||||
# Count modems assigned to deployed seismographs
|
|
||||||
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
|
||||||
without_modem = deployed - with_modem
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"partials/seismo_stats.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"total": total,
|
|
||||||
"deployed": deployed,
|
|
||||||
"benched": benched,
|
|
||||||
"with_modem": with_modem,
|
|
||||||
"without_modem": without_modem
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/units", response_class=HTMLResponse)
|
|
||||||
async def get_seismo_units(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
search: str = Query(None)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Returns HTML partial with filterable seismograph unit list
|
|
||||||
"""
|
|
||||||
query = db.query(RosterUnit).filter_by(
|
|
||||||
device_type="seismograph",
|
|
||||||
retired=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply search filter
|
|
||||||
if search:
|
|
||||||
search_lower = search.lower()
|
|
||||||
query = query.filter(
|
|
||||||
(RosterUnit.id.ilike(f"%{search}%")) |
|
|
||||||
(RosterUnit.note.ilike(f"%{search}%")) |
|
|
||||||
(RosterUnit.address.ilike(f"%{search}%"))
|
|
||||||
)
|
|
||||||
|
|
||||||
seismos = query.order_by(RosterUnit.id).all()
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"partials/seismo_unit_list.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"units": seismos,
|
|
||||||
"search": search or ""
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# SLMM addon package for NL43 integration.
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
"""
|
|
||||||
Dashboard API endpoints for SLM/NL43 devices.
|
|
||||||
This layer aggregates and transforms data from the device API for UI consumption.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy import func
|
|
||||||
from typing import List, Dict, Any
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.slm.database import get_db as get_slm_db
|
|
||||||
from app.slm.models import NL43Config, NL43Status
|
|
||||||
from app.slm.services import NL43Client
|
|
||||||
# Import seismo database for roster data
|
|
||||||
from app.seismo.database import get_db as get_seismo_db
|
|
||||||
from app.seismo.models import RosterUnit
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
|
|
||||||
templates = Jinja2Templates(directory="app/ui/templates")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_class=HTMLResponse)
|
|
||||||
async def get_dashboard_stats(request: Request, db: Session = Depends(get_seismo_db)):
|
|
||||||
"""Get aggregate statistics for the SLM dashboard from roster (returns HTML)."""
|
|
||||||
# Query SLMs from the roster
|
|
||||||
slms = db.query(RosterUnit).filter_by(
|
|
||||||
device_type="sound_level_meter",
|
|
||||||
retired=False
|
|
||||||
).all()
|
|
||||||
|
|
||||||
total_units = len(slms)
|
|
||||||
deployed = sum(1 for s in slms if s.deployed)
|
|
||||||
benched = sum(1 for s in slms if not s.deployed)
|
|
||||||
|
|
||||||
# For "active", count SLMs with recent check-ins (within last hour)
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
||||||
active = sum(1 for s in slms if s.slm_last_check and s.slm_last_check >= one_hour_ago)
|
|
||||||
|
|
||||||
# Map to template variable names
|
|
||||||
# total_count, deployed_count, active_count, benched_count
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"partials/slm_stats.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"total_count": total_units,
|
|
||||||
"deployed_count": deployed,
|
|
||||||
"active_count": active,
|
|
||||||
"benched_count": benched
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/units", response_class=HTMLResponse)
|
|
||||||
async def get_units_list(request: Request, db: Session = Depends(get_seismo_db)):
|
|
||||||
"""Get list of all SLM units from roster (returns HTML)."""
|
|
||||||
# Query SLMs from the roster (not retired)
|
|
||||||
slms = db.query(RosterUnit).filter_by(
|
|
||||||
device_type="sound_level_meter",
|
|
||||||
retired=False
|
|
||||||
).order_by(RosterUnit.id).all()
|
|
||||||
|
|
||||||
units = []
|
|
||||||
for slm in slms:
|
|
||||||
# Map to template field names
|
|
||||||
unit_data = {
|
|
||||||
"id": slm.id,
|
|
||||||
"slm_host": slm.slm_host,
|
|
||||||
"slm_tcp_port": slm.slm_tcp_port,
|
|
||||||
"slm_last_check": slm.slm_last_check,
|
|
||||||
"slm_model": slm.slm_model or "NL-43",
|
|
||||||
"address": slm.address,
|
|
||||||
"deployed_with_modem_id": slm.deployed_with_modem_id,
|
|
||||||
}
|
|
||||||
units.append(unit_data)
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"partials/slm_unit_list.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"units": units
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/live-view/{unit_id}", response_class=HTMLResponse)
|
|
||||||
async def get_live_view(unit_id: str, request: Request, slm_db: Session = Depends(get_slm_db), roster_db: Session = Depends(get_seismo_db)):
|
|
||||||
"""Get live measurement data for a specific unit (returns HTML)."""
|
|
||||||
# Get unit from roster
|
|
||||||
unit = roster_db.query(RosterUnit).filter_by(
|
|
||||||
id=unit_id,
|
|
||||||
device_type="sound_level_meter"
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not unit:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"partials/slm_live_view_error.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"error": f"Unit {unit_id} not found in roster"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get status from monitoring database (may not exist yet)
|
|
||||||
status = slm_db.query(NL43Status).filter_by(unit_id=unit_id).first()
|
|
||||||
|
|
||||||
# Get modem info if available
|
|
||||||
modem = None
|
|
||||||
modem_ip = None
|
|
||||||
if unit.deployed_with_modem_id:
|
|
||||||
modem = roster_db.query(RosterUnit).filter_by(
|
|
||||||
id=unit.deployed_with_modem_id,
|
|
||||||
device_type="modem"
|
|
||||||
).first()
|
|
||||||
if modem:
|
|
||||||
modem_ip = modem.ip_address
|
|
||||||
elif unit.slm_host:
|
|
||||||
modem_ip = unit.slm_host
|
|
||||||
|
|
||||||
# Determine if measuring
|
|
||||||
is_measuring = False
|
|
||||||
if status and status.measurement_state:
|
|
||||||
is_measuring = status.measurement_state.lower() == 'start'
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"partials/slm_live_view.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"unit": unit,
|
|
||||||
"modem": modem,
|
|
||||||
"modem_ip": modem_ip,
|
|
||||||
"current_status": status,
|
|
||||||
"is_measuring": is_measuring
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config/{unit_id}", response_class=HTMLResponse)
|
|
||||||
async def get_unit_config(unit_id: str, request: Request, roster_db: Session = Depends(get_seismo_db)):
|
|
||||||
"""Return the HTML config form for a specific unit."""
|
|
||||||
unit = roster_db.query(RosterUnit).filter_by(
|
|
||||||
id=unit_id,
|
|
||||||
device_type="sound_level_meter"
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not unit:
|
|
||||||
raise HTTPException(status_code=404, detail="Unit configuration not found")
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"partials/slm_config_form.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"unit": unit
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config/{unit_id}")
|
|
||||||
async def update_unit_config(
|
|
||||||
unit_id: str,
|
|
||||||
request: Request,
|
|
||||||
roster_db: Session = Depends(get_seismo_db),
|
|
||||||
slm_db: Session = Depends(get_slm_db)
|
|
||||||
):
|
|
||||||
"""Update configuration for a specific unit from the form submission."""
|
|
||||||
unit = roster_db.query(RosterUnit).filter_by(
|
|
||||||
id=unit_id,
|
|
||||||
device_type="sound_level_meter"
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not unit:
|
|
||||||
raise HTTPException(status_code=404, detail="Unit configuration not found")
|
|
||||||
|
|
||||||
form = await request.form()
|
|
||||||
|
|
||||||
def get_int(value, default=None):
|
|
||||||
try:
|
|
||||||
return int(value) if value not in (None, "") else default
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
# Update roster fields
|
|
||||||
unit.slm_model = form.get("slm_model") or unit.slm_model
|
|
||||||
unit.slm_serial_number = form.get("slm_serial_number") or unit.slm_serial_number
|
|
||||||
unit.slm_frequency_weighting = form.get("slm_frequency_weighting") or unit.slm_frequency_weighting
|
|
||||||
unit.slm_time_weighting = form.get("slm_time_weighting") or unit.slm_time_weighting
|
|
||||||
unit.slm_measurement_range = form.get("slm_measurement_range") or unit.slm_measurement_range
|
|
||||||
|
|
||||||
unit.slm_host = form.get("slm_host") or None
|
|
||||||
unit.slm_tcp_port = get_int(form.get("slm_tcp_port"), unit.slm_tcp_port or 2255)
|
|
||||||
unit.slm_ftp_port = get_int(form.get("slm_ftp_port"), unit.slm_ftp_port or 21)
|
|
||||||
|
|
||||||
deployed_with_modem_id = form.get("deployed_with_modem_id") or None
|
|
||||||
unit.deployed_with_modem_id = deployed_with_modem_id
|
|
||||||
|
|
||||||
roster_db.commit()
|
|
||||||
roster_db.refresh(unit)
|
|
||||||
|
|
||||||
# Update or create NL43 config so SLMM can reach the device
|
|
||||||
config = slm_db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
|
||||||
if not config:
|
|
||||||
config = NL43Config(unit_id=unit_id)
|
|
||||||
slm_db.add(config)
|
|
||||||
|
|
||||||
# Resolve host from modem if present, otherwise fall back to direct IP or existing config
|
|
||||||
host_for_config = None
|
|
||||||
if deployed_with_modem_id:
|
|
||||||
modem = roster_db.query(RosterUnit).filter_by(
|
|
||||||
id=deployed_with_modem_id,
|
|
||||||
device_type="modem"
|
|
||||||
).first()
|
|
||||||
if modem and modem.ip_address:
|
|
||||||
host_for_config = modem.ip_address
|
|
||||||
if not host_for_config:
|
|
||||||
host_for_config = unit.slm_host or config.host or "127.0.0.1"
|
|
||||||
|
|
||||||
config.host = host_for_config
|
|
||||||
config.tcp_port = get_int(form.get("slm_tcp_port"), config.tcp_port or 2255)
|
|
||||||
config.tcp_enabled = True
|
|
||||||
config.ftp_enabled = bool(config.ftp_username and config.ftp_password)
|
|
||||||
|
|
||||||
slm_db.commit()
|
|
||||||
slm_db.refresh(config)
|
|
||||||
|
|
||||||
return {"success": True, "unit_id": unit_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/control/{unit_id}/{action}")
|
|
||||||
async def control_unit(unit_id: str, action: str, db: Session = Depends(get_slm_db)):
|
|
||||||
"""Send control command to a unit (start, stop, pause, resume, etc.)."""
|
|
||||||
config = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
|
||||||
if not config:
|
|
||||||
raise HTTPException(status_code=404, detail="Unit configuration not found")
|
|
||||||
|
|
||||||
if not config.tcp_enabled:
|
|
||||||
raise HTTPException(status_code=400, detail="TCP control not enabled for this unit")
|
|
||||||
|
|
||||||
# Create NL43Client
|
|
||||||
client = NL43Client(
|
|
||||||
host=config.host,
|
|
||||||
port=config.tcp_port,
|
|
||||||
timeout=5.0,
|
|
||||||
ftp_username=config.ftp_username,
|
|
||||||
ftp_password=config.ftp_password
|
|
||||||
)
|
|
||||||
|
|
||||||
# Map action to command
|
|
||||||
action_map = {
|
|
||||||
"start": "start_measurement",
|
|
||||||
"stop": "stop_measurement",
|
|
||||||
"pause": "pause_measurement",
|
|
||||||
"resume": "resume_measurement",
|
|
||||||
"reset": "reset_measurement",
|
|
||||||
"sleep": "sleep_mode",
|
|
||||||
"wake": "wake_from_sleep",
|
|
||||||
}
|
|
||||||
|
|
||||||
if action not in action_map:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
|
|
||||||
|
|
||||||
method_name = action_map[action]
|
|
||||||
method = getattr(client, method_name, None)
|
|
||||||
|
|
||||||
if not method:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Method {method_name} not implemented")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await method()
|
|
||||||
return {"success": True, "action": action, "result": result}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error executing {action} on {unit_id}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/test-modem/{unit_id}")
|
|
||||||
async def test_modem(unit_id: str, db: Session = Depends(get_slm_db)):
|
|
||||||
"""Test connectivity to a unit's modem/device."""
|
|
||||||
config = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
|
||||||
if not config:
|
|
||||||
raise HTTPException(status_code=404, detail="Unit configuration not found")
|
|
||||||
|
|
||||||
if not config.tcp_enabled:
|
|
||||||
raise HTTPException(status_code=400, detail="TCP control not enabled for this unit")
|
|
||||||
|
|
||||||
client = NL43Client(
|
|
||||||
host=config.host,
|
|
||||||
port=config.tcp_port,
|
|
||||||
timeout=5.0,
|
|
||||||
ftp_username=config.ftp_username,
|
|
||||||
ftp_password=config.ftp_password
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try to get measurement state as a connectivity test
|
|
||||||
state = await client.get_measurement_state()
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"unit_id": unit_id,
|
|
||||||
"host": config.host,
|
|
||||||
"port": config.tcp_port,
|
|
||||||
"reachable": True,
|
|
||||||
"measurement_state": state
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Modem test failed for {unit_id}: {e}")
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"unit_id": unit_id,
|
|
||||||
"host": config.host,
|
|
||||||
"port": config.tcp_port,
|
|
||||||
"reachable": False,
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Ensure data directory exists for the SLMM addon
|
|
||||||
os.makedirs("data", exist_ok=True)
|
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/slmm.db"
|
|
||||||
|
|
||||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
"""Dependency for database sessions."""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_db_session():
|
|
||||||
"""Get a database session directly (not as a dependency)."""
|
|
||||||
return SessionLocal()
|
|
||||||
116
app/slm/main.py
@@ -1,116 +0,0 @@
|
|||||||
import os
|
|
||||||
import logging
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
from app.slm.database import Base, engine
|
|
||||||
from app.slm import routers
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
||||||
handlers=[
|
|
||||||
logging.StreamHandler(),
|
|
||||||
logging.FileHandler("data/slmm.log"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Ensure database tables exist for the addon
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
logger.info("Database tables initialized")
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="SLMM NL43 Addon",
|
|
||||||
description="Standalone module for NL43 configuration and status APIs",
|
|
||||||
version="0.1.0",
|
|
||||||
)
|
|
||||||
|
|
||||||
# CORS configuration - use environment variable for allowed origins
|
|
||||||
# Default to "*" for development, but should be restricted in production
|
|
||||||
allowed_origins = os.getenv("CORS_ORIGINS", "*").split(",")
|
|
||||||
logger.info(f"CORS allowed origins: {allowed_origins}")
|
|
||||||
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=allowed_origins,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
|
||||||
|
|
||||||
app.include_router(routers.router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
|
||||||
def index(request: Request):
|
|
||||||
return templates.TemplateResponse("index.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health():
|
|
||||||
"""Basic health check endpoint."""
|
|
||||||
return {"status": "ok", "service": "slmm-nl43-addon"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health/devices")
|
|
||||||
async def health_devices():
|
|
||||||
"""Enhanced health check that tests device connectivity."""
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.slm.database import SessionLocal
|
|
||||||
from app.slm.services import NL43Client
|
|
||||||
from app.slm.models import NL43Config
|
|
||||||
|
|
||||||
db: Session = SessionLocal()
|
|
||||||
device_status = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
configs = db.query(NL43Config).filter_by(tcp_enabled=True).all()
|
|
||||||
|
|
||||||
for cfg in configs:
|
|
||||||
client = NL43Client(cfg.host, cfg.tcp_port, timeout=2.0, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password)
|
|
||||||
status = {
|
|
||||||
"unit_id": cfg.unit_id,
|
|
||||||
"host": cfg.host,
|
|
||||||
"port": cfg.tcp_port,
|
|
||||||
"reachable": False,
|
|
||||||
"error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try to connect (don't send command to avoid rate limiting issues)
|
|
||||||
import asyncio
|
|
||||||
reader, writer = await asyncio.wait_for(
|
|
||||||
asyncio.open_connection(cfg.host, cfg.tcp_port), timeout=2.0
|
|
||||||
)
|
|
||||||
writer.close()
|
|
||||||
await writer.wait_closed()
|
|
||||||
status["reachable"] = True
|
|
||||||
except Exception as e:
|
|
||||||
status["error"] = str(type(e).__name__)
|
|
||||||
logger.warning(f"Device {cfg.unit_id} health check failed: {e}")
|
|
||||||
|
|
||||||
device_status.append(status)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
all_reachable = all(d["reachable"] for d in device_status) if device_status else True
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok" if all_reachable else "degraded",
|
|
||||||
"devices": device_status,
|
|
||||||
"total_devices": len(device_status),
|
|
||||||
"reachable_devices": sum(1 for d in device_status if d["reachable"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
uvicorn.run("app.main:app", host="0.0.0.0", port=int(os.getenv("PORT", "8100")), reload=True)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text, func
|
|
||||||
from app.slm.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class NL43Config(Base):
|
|
||||||
"""
|
|
||||||
NL43 connection/config metadata for the standalone SLMM addon.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "nl43_config"
|
|
||||||
|
|
||||||
unit_id = Column(String, primary_key=True, index=True)
|
|
||||||
host = Column(String, default="127.0.0.1")
|
|
||||||
tcp_port = Column(Integer, default=80) # NL43 TCP control port (via RX55)
|
|
||||||
tcp_enabled = Column(Boolean, default=True)
|
|
||||||
ftp_enabled = Column(Boolean, default=False)
|
|
||||||
ftp_username = Column(String, nullable=True) # FTP login username
|
|
||||||
ftp_password = Column(String, nullable=True) # FTP login password
|
|
||||||
web_enabled = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
|
|
||||||
class NL43Status(Base):
|
|
||||||
"""
|
|
||||||
Latest NL43 status snapshot for quick dashboard/API access.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "nl43_status"
|
|
||||||
|
|
||||||
unit_id = Column(String, primary_key=True, index=True)
|
|
||||||
last_seen = Column(DateTime, default=func.now())
|
|
||||||
measurement_state = Column(String, default="unknown") # Measure/Stop
|
|
||||||
measurement_start_time = Column(DateTime, nullable=True) # When measurement started (UTC)
|
|
||||||
counter = Column(String, nullable=True) # d0: Measurement interval counter (1-600)
|
|
||||||
lp = Column(String, nullable=True) # Instantaneous sound pressure level
|
|
||||||
leq = Column(String, nullable=True) # Equivalent continuous sound level
|
|
||||||
lmax = Column(String, nullable=True) # Maximum level
|
|
||||||
lmin = Column(String, nullable=True) # Minimum level
|
|
||||||
lpeak = Column(String, nullable=True) # Peak level
|
|
||||||
battery_level = Column(String, nullable=True)
|
|
||||||
power_source = Column(String, nullable=True)
|
|
||||||
sd_remaining_mb = Column(String, nullable=True)
|
|
||||||
sd_free_ratio = Column(String, nullable=True)
|
|
||||||
raw_payload = Column(Text, nullable=True)
|
|
||||||
1333
app/slm/routers.py
@@ -1,828 +0,0 @@
|
|||||||
"""
|
|
||||||
NL43 TCP connector and snapshot persistence.
|
|
||||||
|
|
||||||
Implements simple per-request TCP calls to avoid long-lived socket complexity.
|
|
||||||
Extend to pooled connections/DRD streaming later.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, List
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from ftplib import FTP
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.slm.models import NL43Status
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class NL43Snapshot:
|
|
||||||
unit_id: str
|
|
||||||
measurement_state: str = "unknown"
|
|
||||||
counter: Optional[str] = None # d0: Measurement interval counter (1-600)
|
|
||||||
lp: Optional[str] = None # Instantaneous sound pressure level
|
|
||||||
leq: Optional[str] = None # Equivalent continuous sound level
|
|
||||||
lmax: Optional[str] = None # Maximum level
|
|
||||||
lmin: Optional[str] = None # Minimum level
|
|
||||||
lpeak: Optional[str] = None # Peak level
|
|
||||||
battery_level: Optional[str] = None
|
|
||||||
power_source: Optional[str] = None
|
|
||||||
sd_remaining_mb: Optional[str] = None
|
|
||||||
sd_free_ratio: Optional[str] = None
|
|
||||||
raw_payload: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
def persist_snapshot(s: NL43Snapshot, db: Session):
|
|
||||||
"""Persist the latest snapshot for API/dashboard use."""
|
|
||||||
try:
|
|
||||||
row = db.query(NL43Status).filter_by(unit_id=s.unit_id).first()
|
|
||||||
if not row:
|
|
||||||
row = NL43Status(unit_id=s.unit_id)
|
|
||||||
db.add(row)
|
|
||||||
|
|
||||||
row.last_seen = datetime.utcnow()
|
|
||||||
|
|
||||||
# Track measurement start time by detecting state transition
|
|
||||||
previous_state = row.measurement_state
|
|
||||||
new_state = s.measurement_state
|
|
||||||
|
|
||||||
logger.info(f"State transition check for {s.unit_id}: '{previous_state}' -> '{new_state}'")
|
|
||||||
|
|
||||||
# Device returns "Start" when measuring, "Stop" when stopped
|
|
||||||
# Normalize to previous behavior for backward compatibility
|
|
||||||
is_measuring = new_state == "Start"
|
|
||||||
was_measuring = previous_state == "Start"
|
|
||||||
|
|
||||||
if not was_measuring and is_measuring:
|
|
||||||
# Measurement just started - record the start time
|
|
||||||
row.measurement_start_time = datetime.utcnow()
|
|
||||||
logger.info(f"✓ Measurement started on {s.unit_id} at {row.measurement_start_time}")
|
|
||||||
elif was_measuring and not is_measuring:
|
|
||||||
# Measurement stopped - clear the start time
|
|
||||||
row.measurement_start_time = None
|
|
||||||
logger.info(f"✓ Measurement stopped on {s.unit_id}")
|
|
||||||
|
|
||||||
row.measurement_state = new_state
|
|
||||||
row.counter = s.counter
|
|
||||||
row.lp = s.lp
|
|
||||||
row.leq = s.leq
|
|
||||||
row.lmax = s.lmax
|
|
||||||
row.lmin = s.lmin
|
|
||||||
row.lpeak = s.lpeak
|
|
||||||
row.battery_level = s.battery_level
|
|
||||||
row.power_source = s.power_source
|
|
||||||
row.sd_remaining_mb = s.sd_remaining_mb
|
|
||||||
row.sd_free_ratio = s.sd_free_ratio
|
|
||||||
row.raw_payload = s.raw_payload
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.error(f"Failed to persist snapshot for unit {s.unit_id}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
# Rate limiting: NL43 requires ≥1 second between commands
|
|
||||||
_last_command_time = {}
|
|
||||||
_rate_limit_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
class NL43Client:
|
|
||||||
def __init__(self, host: str, port: int, timeout: float = 5.0, ftp_username: str = None, ftp_password: str = None):
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.timeout = timeout
|
|
||||||
self.ftp_username = ftp_username or "anonymous"
|
|
||||||
self.ftp_password = ftp_password or ""
|
|
||||||
self.device_key = f"{host}:{port}"
|
|
||||||
|
|
||||||
async def _enforce_rate_limit(self):
|
|
||||||
"""Ensure ≥1 second between commands to the same device."""
|
|
||||||
async with _rate_limit_lock:
|
|
||||||
last_time = _last_command_time.get(self.device_key, 0)
|
|
||||||
elapsed = time.time() - last_time
|
|
||||||
if elapsed < 1.0:
|
|
||||||
wait_time = 1.0 - elapsed
|
|
||||||
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s for {self.device_key}")
|
|
||||||
await asyncio.sleep(wait_time)
|
|
||||||
_last_command_time[self.device_key] = time.time()
|
|
||||||
|
|
||||||
async def _send_command(self, cmd: str) -> str:
|
|
||||||
"""Send ASCII command to NL43 device via TCP.
|
|
||||||
|
|
||||||
NL43 protocol returns two lines for query commands:
|
|
||||||
Line 1: Result code (R+0000 for success, error codes otherwise)
|
|
||||||
Line 2: Actual data (for query commands ending with '?')
|
|
||||||
"""
|
|
||||||
await self._enforce_rate_limit()
|
|
||||||
|
|
||||||
logger.info(f"Sending command to {self.device_key}: {cmd.strip()}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
reader, writer = await asyncio.wait_for(
|
|
||||||
asyncio.open_connection(self.host, self.port), timeout=self.timeout
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.error(f"Connection timeout to {self.device_key}")
|
|
||||||
raise ConnectionError(f"Failed to connect to device at {self.host}:{self.port}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Connection failed to {self.device_key}: {e}")
|
|
||||||
raise ConnectionError(f"Failed to connect to device: {str(e)}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
writer.write(cmd.encode("ascii"))
|
|
||||||
await writer.drain()
|
|
||||||
|
|
||||||
# Read first line (result code)
|
|
||||||
first_line_data = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=self.timeout)
|
|
||||||
result_code = first_line_data.decode(errors="ignore").strip()
|
|
||||||
|
|
||||||
# Remove leading $ prompt if present
|
|
||||||
if result_code.startswith("$"):
|
|
||||||
result_code = result_code[1:].strip()
|
|
||||||
|
|
||||||
logger.info(f"Result code from {self.device_key}: {result_code}")
|
|
||||||
|
|
||||||
# Check result code
|
|
||||||
if result_code == "R+0000":
|
|
||||||
# Success - for query commands, read the second line with actual data
|
|
||||||
is_query = cmd.strip().endswith("?")
|
|
||||||
if is_query:
|
|
||||||
data_line = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=self.timeout)
|
|
||||||
response = data_line.decode(errors="ignore").strip()
|
|
||||||
logger.debug(f"Data line from {self.device_key}: {response}")
|
|
||||||
return response
|
|
||||||
else:
|
|
||||||
# Setting command - return success code
|
|
||||||
return result_code
|
|
||||||
elif result_code == "R+0001":
|
|
||||||
raise ValueError("Command error - device did not recognize command")
|
|
||||||
elif result_code == "R+0002":
|
|
||||||
raise ValueError("Parameter error - invalid parameter value")
|
|
||||||
elif result_code == "R+0003":
|
|
||||||
raise ValueError("Spec/type error - command not supported by this device model")
|
|
||||||
elif result_code == "R+0004":
|
|
||||||
raise ValueError("Status error - device is in wrong state for this command")
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown result code: {result_code}")
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.error(f"Response timeout from {self.device_key}")
|
|
||||||
raise TimeoutError(f"Device did not respond within {self.timeout}s")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Communication error with {self.device_key}: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
writer.close()
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await writer.wait_closed()
|
|
||||||
|
|
||||||
async def request_dod(self) -> NL43Snapshot:
|
|
||||||
"""Request DOD (Data Output Display) snapshot from device.
|
|
||||||
|
|
||||||
Returns parsed measurement data from the device display.
|
|
||||||
"""
|
|
||||||
# _send_command now handles result code validation and returns the data line
|
|
||||||
resp = await self._send_command("DOD?\r\n")
|
|
||||||
|
|
||||||
# Validate response format
|
|
||||||
if not resp:
|
|
||||||
logger.warning(f"Empty data response from DOD command on {self.device_key}")
|
|
||||||
raise ValueError("Device returned empty data for DOD? command")
|
|
||||||
|
|
||||||
# Remove leading $ prompt if present (shouldn't be there after _send_command, but be safe)
|
|
||||||
if resp.startswith("$"):
|
|
||||||
resp = resp[1:].strip()
|
|
||||||
|
|
||||||
parts = [p.strip() for p in resp.split(",") if p.strip() != ""]
|
|
||||||
|
|
||||||
# DOD should return at least some data points
|
|
||||||
if len(parts) < 2:
|
|
||||||
logger.error(f"Malformed DOD data from {self.device_key}: {resp}")
|
|
||||||
raise ValueError(f"Malformed DOD data: expected comma-separated values, got: {resp}")
|
|
||||||
|
|
||||||
logger.info(f"Parsed {len(parts)} data points from DOD response")
|
|
||||||
|
|
||||||
# Query actual measurement state (DOD doesn't include this information)
|
|
||||||
try:
|
|
||||||
measurement_state = await self.get_measurement_state()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to get measurement state, defaulting to 'Measure': {e}")
|
|
||||||
measurement_state = "Measure"
|
|
||||||
|
|
||||||
snap = NL43Snapshot(unit_id="", raw_payload=resp, measurement_state=measurement_state)
|
|
||||||
|
|
||||||
# Parse known positions (based on NL43 communication guide - DRD format)
|
|
||||||
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ...
|
|
||||||
try:
|
|
||||||
# Capture d0 (counter) for timer synchronization
|
|
||||||
if len(parts) >= 1:
|
|
||||||
snap.counter = parts[0] # d0: Measurement interval counter (1-600)
|
|
||||||
if len(parts) >= 2:
|
|
||||||
snap.lp = parts[1] # d1: Instantaneous sound pressure level
|
|
||||||
if len(parts) >= 3:
|
|
||||||
snap.leq = parts[2] # d2: Equivalent continuous sound level
|
|
||||||
if len(parts) >= 4:
|
|
||||||
snap.lmax = parts[3] # d3: Maximum level
|
|
||||||
if len(parts) >= 5:
|
|
||||||
snap.lmin = parts[4] # d4: Minimum level
|
|
||||||
if len(parts) >= 6:
|
|
||||||
snap.lpeak = parts[5] # d5: Peak level
|
|
||||||
except (IndexError, ValueError) as e:
|
|
||||||
logger.warning(f"Error parsing DOD data points: {e}")
|
|
||||||
|
|
||||||
return snap
|
|
||||||
|
|
||||||
async def start(self):
|
|
||||||
"""Start measurement on the device.
|
|
||||||
|
|
||||||
According to NL43 protocol: Measure,Start (no $ prefix, capitalized param)
|
|
||||||
"""
|
|
||||||
await self._send_command("Measure,Start\r\n")
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
"""Stop measurement on the device.
|
|
||||||
|
|
||||||
According to NL43 protocol: Measure,Stop (no $ prefix, capitalized param)
|
|
||||||
"""
|
|
||||||
await self._send_command("Measure,Stop\r\n")
|
|
||||||
|
|
||||||
async def set_store_mode_manual(self):
|
|
||||||
"""Set the device to Manual Store mode.
|
|
||||||
|
|
||||||
According to NL43 protocol: Store Mode,Manual sets manual storage mode
|
|
||||||
"""
|
|
||||||
await self._send_command("Store Mode,Manual\r\n")
|
|
||||||
logger.info(f"Store mode set to Manual on {self.device_key}")
|
|
||||||
|
|
||||||
async def manual_store(self):
|
|
||||||
"""Manually store the current measurement data.
|
|
||||||
|
|
||||||
According to NL43 protocol: Manual Store,Start executes storing
|
|
||||||
Parameter p1="Start" executes the storage operation
|
|
||||||
Device must be in Manual Store mode first
|
|
||||||
"""
|
|
||||||
await self._send_command("Manual Store,Start\r\n")
|
|
||||||
logger.info(f"Manual store executed on {self.device_key}")
|
|
||||||
|
|
||||||
async def pause(self):
|
|
||||||
"""Pause the current measurement."""
|
|
||||||
await self._send_command("Pause,On\r\n")
|
|
||||||
logger.info(f"Measurement paused on {self.device_key}")
|
|
||||||
|
|
||||||
async def resume(self):
|
|
||||||
"""Resume a paused measurement."""
|
|
||||||
await self._send_command("Pause,Off\r\n")
|
|
||||||
logger.info(f"Measurement resumed on {self.device_key}")
|
|
||||||
|
|
||||||
async def reset(self):
|
|
||||||
"""Reset the measurement data."""
|
|
||||||
await self._send_command("Reset\r\n")
|
|
||||||
logger.info(f"Measurement data reset on {self.device_key}")
|
|
||||||
|
|
||||||
async def get_measurement_state(self) -> str:
|
|
||||||
"""Get the current measurement state.
|
|
||||||
|
|
||||||
Returns: "Start" if measuring, "Stop" if stopped
|
|
||||||
"""
|
|
||||||
resp = await self._send_command("Measure?\r\n")
|
|
||||||
state = resp.strip()
|
|
||||||
logger.info(f"Measurement state on {self.device_key}: {state}")
|
|
||||||
return state
|
|
||||||
|
|
||||||
async def get_battery_level(self) -> str:
|
|
||||||
"""Get the battery level."""
|
|
||||||
resp = await self._send_command("Battery Level?\r\n")
|
|
||||||
logger.info(f"Battery level on {self.device_key}: {resp}")
|
|
||||||
return resp.strip()
|
|
||||||
|
|
||||||
async def get_clock(self) -> str:
|
|
||||||
"""Get the device clock time."""
|
|
||||||
resp = await self._send_command("Clock?\r\n")
|
|
||||||
logger.info(f"Clock on {self.device_key}: {resp}")
|
|
||||||
return resp.strip()
|
|
||||||
|
|
||||||
async def set_clock(self, datetime_str: str):
|
|
||||||
"""Set the device clock time.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
datetime_str: Time in format YYYY/MM/DD,HH:MM:SS or YYYY/MM/DD HH:MM:SS
|
|
||||||
"""
|
|
||||||
# Device expects format: Clock,YYYY/MM/DD HH:MM:SS (space between date and time)
|
|
||||||
# Replace comma with space if present to normalize format
|
|
||||||
normalized = datetime_str.replace(',', ' ', 1)
|
|
||||||
await self._send_command(f"Clock,{normalized}\r\n")
|
|
||||||
logger.info(f"Clock set on {self.device_key} to {normalized}")
|
|
||||||
|
|
||||||
async def get_frequency_weighting(self, channel: str = "Main") -> str:
|
|
||||||
"""Get frequency weighting (A, C, Z, etc.).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
channel: Main, Sub1, Sub2, or Sub3
|
|
||||||
"""
|
|
||||||
resp = await self._send_command(f"Frequency Weighting ({channel})?\r\n")
|
|
||||||
logger.info(f"Frequency weighting ({channel}) on {self.device_key}: {resp}")
|
|
||||||
return resp.strip()
|
|
||||||
|
|
||||||
async def set_frequency_weighting(self, weighting: str, channel: str = "Main"):
|
|
||||||
"""Set frequency weighting.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
weighting: A, C, or Z
|
|
||||||
channel: Main, Sub1, Sub2, or Sub3
|
|
||||||
"""
|
|
||||||
await self._send_command(f"Frequency Weighting ({channel}),{weighting}\r\n")
|
|
||||||
logger.info(f"Frequency weighting ({channel}) set to {weighting} on {self.device_key}")
|
|
||||||
|
|
||||||
async def get_time_weighting(self, channel: str = "Main") -> str:
|
|
||||||
"""Get time weighting (F, S, I).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
channel: Main, Sub1, Sub2, or Sub3
|
|
||||||
"""
|
|
||||||
resp = await self._send_command(f"Time Weighting ({channel})?\r\n")
|
|
||||||
logger.info(f"Time weighting ({channel}) on {self.device_key}: {resp}")
|
|
||||||
return resp.strip()
|
|
||||||
|
|
||||||
async def set_time_weighting(self, weighting: str, channel: str = "Main"):
|
|
||||||
"""Set time weighting.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
weighting: F (Fast), S (Slow), or I (Impulse)
|
|
||||||
channel: Main, Sub1, Sub2, or Sub3
|
|
||||||
"""
|
|
||||||
await self._send_command(f"Time Weighting ({channel}),{weighting}\r\n")
|
|
||||||
logger.info(f"Time weighting ({channel}) set to {weighting} on {self.device_key}")
|
|
||||||
|
|
||||||
async def request_dlc(self) -> dict:
|
|
||||||
"""Request DLC (Data Last Calculation) - final stored measurement results.
|
|
||||||
|
|
||||||
This retrieves the complete calculation results from the last/current measurement,
|
|
||||||
including all statistical data. Similar to DOD but for final results.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with parsed DLC data
|
|
||||||
"""
|
|
||||||
resp = await self._send_command("DLC?\r\n")
|
|
||||||
logger.info(f"DLC data received from {self.device_key}: {resp[:100]}...")
|
|
||||||
|
|
||||||
# Parse DLC response - similar format to DOD
|
|
||||||
# The exact format depends on device configuration
|
|
||||||
# For now, return raw data - can be enhanced based on actual response format
|
|
||||||
return {
|
|
||||||
"raw_data": resp.strip(),
|
|
||||||
"device_key": self.device_key,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def sleep(self):
|
|
||||||
"""Put the device into sleep mode to conserve battery.
|
|
||||||
|
|
||||||
Sleep mode is useful for battery conservation between scheduled measurements.
|
|
||||||
Device can be woken up remotely via TCP command or by pressing a button.
|
|
||||||
"""
|
|
||||||
await self._send_command("Sleep Mode,On\r\n")
|
|
||||||
logger.info(f"Device {self.device_key} entering sleep mode")
|
|
||||||
|
|
||||||
async def wake(self):
|
|
||||||
"""Wake the device from sleep mode.
|
|
||||||
|
|
||||||
Note: This may not work if the device is in deep sleep.
|
|
||||||
Physical button press might be required in some cases.
|
|
||||||
"""
|
|
||||||
await self._send_command("Sleep Mode,Off\r\n")
|
|
||||||
logger.info(f"Device {self.device_key} waking from sleep mode")
|
|
||||||
|
|
||||||
async def get_sleep_status(self) -> str:
|
|
||||||
"""Get the current sleep mode status."""
|
|
||||||
resp = await self._send_command("Sleep Mode?\r\n")
|
|
||||||
logger.info(f"Sleep mode status on {self.device_key}: {resp}")
|
|
||||||
return resp.strip()
|
|
||||||
|
|
||||||
async def stream_drd(self, callback):
|
|
||||||
"""Stream continuous DRD output from the device.
|
|
||||||
|
|
||||||
Opens a persistent connection and streams DRD data lines.
|
|
||||||
Calls the provided callback function with each parsed snapshot.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Async function that receives NL43Snapshot objects
|
|
||||||
|
|
||||||
The stream continues until an exception occurs or the connection is closed.
|
|
||||||
Send SUB character (0x1A) to stop the stream.
|
|
||||||
"""
|
|
||||||
await self._enforce_rate_limit()
|
|
||||||
|
|
||||||
logger.info(f"Starting DRD stream for {self.device_key}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
reader, writer = await asyncio.wait_for(
|
|
||||||
asyncio.open_connection(self.host, self.port), timeout=self.timeout
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.error(f"DRD stream connection timeout to {self.device_key}")
|
|
||||||
raise ConnectionError(f"Failed to connect to device at {self.host}:{self.port}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"DRD stream connection failed to {self.device_key}: {e}")
|
|
||||||
raise ConnectionError(f"Failed to connect to device: {str(e)}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start DRD streaming
|
|
||||||
writer.write(b"DRD?\r\n")
|
|
||||||
await writer.drain()
|
|
||||||
|
|
||||||
# Read initial result code
|
|
||||||
first_line_data = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=self.timeout)
|
|
||||||
result_code = first_line_data.decode(errors="ignore").strip()
|
|
||||||
|
|
||||||
if result_code.startswith("$"):
|
|
||||||
result_code = result_code[1:].strip()
|
|
||||||
|
|
||||||
logger.debug(f"DRD stream result code from {self.device_key}: {result_code}")
|
|
||||||
|
|
||||||
if result_code != "R+0000":
|
|
||||||
raise ValueError(f"DRD stream failed to start: {result_code}")
|
|
||||||
|
|
||||||
logger.info(f"DRD stream started successfully for {self.device_key}")
|
|
||||||
|
|
||||||
# Continuously read data lines
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
line_data = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=30.0)
|
|
||||||
line = line_data.decode(errors="ignore").strip()
|
|
||||||
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Remove leading $ if present
|
|
||||||
if line.startswith("$"):
|
|
||||||
line = line[1:].strip()
|
|
||||||
|
|
||||||
# Parse the DRD data (same format as DOD)
|
|
||||||
parts = [p.strip() for p in line.split(",") if p.strip() != ""]
|
|
||||||
|
|
||||||
if len(parts) < 2:
|
|
||||||
logger.warning(f"Malformed DRD data from {self.device_key}: {line}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
snap = NL43Snapshot(unit_id="", raw_payload=line, measurement_state="Measure")
|
|
||||||
|
|
||||||
# Parse known positions (DRD format - same as DOD)
|
|
||||||
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ...
|
|
||||||
try:
|
|
||||||
# Capture d0 (counter) for timer synchronization
|
|
||||||
if len(parts) >= 1:
|
|
||||||
snap.counter = parts[0] # d0: Measurement interval counter (1-600)
|
|
||||||
if len(parts) >= 2:
|
|
||||||
snap.lp = parts[1] # d1: Instantaneous sound pressure level
|
|
||||||
if len(parts) >= 3:
|
|
||||||
snap.leq = parts[2] # d2: Equivalent continuous sound level
|
|
||||||
if len(parts) >= 4:
|
|
||||||
snap.lmax = parts[3] # d3: Maximum level
|
|
||||||
if len(parts) >= 5:
|
|
||||||
snap.lmin = parts[4] # d4: Minimum level
|
|
||||||
if len(parts) >= 6:
|
|
||||||
snap.lpeak = parts[5] # d5: Peak level
|
|
||||||
except (IndexError, ValueError) as e:
|
|
||||||
logger.warning(f"Error parsing DRD data points: {e}")
|
|
||||||
|
|
||||||
# Call the callback with the snapshot
|
|
||||||
await callback(snap)
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(f"DRD stream timeout (no data for 30s) from {self.device_key}")
|
|
||||||
break
|
|
||||||
except asyncio.IncompleteReadError:
|
|
||||||
logger.info(f"DRD stream closed by device {self.device_key}")
|
|
||||||
break
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Send SUB character to stop streaming
|
|
||||||
try:
|
|
||||||
writer.write(b"\x1A")
|
|
||||||
await writer.drain()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
writer.close()
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await writer.wait_closed()
|
|
||||||
|
|
||||||
logger.info(f"DRD stream ended for {self.device_key}")
|
|
||||||
|
|
||||||
async def set_measurement_time(self, preset: str):
|
|
||||||
"""Set measurement time preset.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
preset: Time preset (10s, 1m, 5m, 10m, 15m, 30m, 1h, 8h, 24h, or custom like "00:05:30")
|
|
||||||
"""
|
|
||||||
await self._send_command(f"Measurement Time Preset Manual,{preset}\r\n")
|
|
||||||
logger.info(f"Set measurement time to {preset} on {self.device_key}")
|
|
||||||
|
|
||||||
async def get_measurement_time(self) -> str:
|
|
||||||
"""Get current measurement time preset.
|
|
||||||
|
|
||||||
Returns: Current time preset setting
|
|
||||||
"""
|
|
||||||
resp = await self._send_command("Measurement Time Preset Manual?\r\n")
|
|
||||||
return resp.strip()
|
|
||||||
|
|
||||||
async def set_leq_interval(self, preset: str):
|
|
||||||
"""Set Leq calculation interval preset.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
preset: Interval preset (Off, 10s, 1m, 5m, 10m, 15m, 30m, 1h, 8h, 24h, or custom like "00:05:30")
|
|
||||||
"""
|
|
||||||
await self._send_command(f"Leq Calculation Interval Preset,{preset}\r\n")
|
|
||||||
logger.info(f"Set Leq interval to {preset} on {self.device_key}")
|
|
||||||
|
|
||||||
async def get_leq_interval(self) -> str:
|
|
||||||
"""Get current Leq calculation interval preset.
|
|
||||||
|
|
||||||
Returns: Current interval preset setting
|
|
||||||
"""
|
|
||||||
resp = await self._send_command("Leq Calculation Interval Preset?\r\n")
|
|
||||||
return resp.strip()
|
|
||||||
|
|
||||||
async def set_lp_interval(self, preset: str):
|
|
||||||
"""Set Lp store interval.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
preset: Store interval (Off, 10ms, 25ms, 100ms, 200ms, 1s)
|
|
||||||
"""
|
|
||||||
await self._send_command(f"Lp Store Interval,{preset}\r\n")
|
|
||||||
logger.info(f"Set Lp interval to {preset} on {self.device_key}")
|
|
||||||
|
|
||||||
async def get_lp_interval(self) -> str:
|
|
||||||
"""Get current Lp store interval.
|
|
||||||
|
|
||||||
Returns: Current store interval setting
|
|
||||||
"""
|
|
||||||
resp = await self._send_command("Lp Store Interval?\r\n")
|
|
||||||
return resp.strip()
|
|
||||||
|
|
||||||
async def set_index_number(self, index: int):
|
|
||||||
"""Set index number for file numbering (Store Name).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
index: Index number (0000-9999)
|
|
||||||
"""
|
|
||||||
if not 0 <= index <= 9999:
|
|
||||||
raise ValueError("Index must be between 0000 and 9999")
|
|
||||||
await self._send_command(f"Store Name,{index:04d}\r\n")
|
|
||||||
logger.info(f"Set store name (index) to {index:04d} on {self.device_key}")
|
|
||||||
|
|
||||||
async def get_index_number(self) -> str:
|
|
||||||
"""Get current index number (Store Name).
|
|
||||||
|
|
||||||
Returns: Current index number
|
|
||||||
"""
|
|
||||||
resp = await self._send_command("Store Name?\r\n")
|
|
||||||
return resp.strip()
|
|
||||||
|
|
||||||
async def get_overwrite_status(self) -> str:
|
|
||||||
"""Check if saved data exists at current store target.
|
|
||||||
|
|
||||||
This command checks whether saved data exists in the set store target
|
|
||||||
(store mode / store name / store address). Use this before storing
|
|
||||||
to prevent accidentally overwriting data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
"None" - No data exists (safe to store)
|
|
||||||
"Exist" - Data exists (would overwrite)
|
|
||||||
"""
|
|
||||||
resp = await self._send_command("Overwrite?\r\n")
|
|
||||||
return resp.strip()
|
|
||||||
|
|
||||||
async def get_all_settings(self) -> dict:
|
|
||||||
"""Query all device settings for verification.
|
|
||||||
|
|
||||||
Returns: Dictionary with all current device settings
|
|
||||||
"""
|
|
||||||
settings = {}
|
|
||||||
|
|
||||||
# Measurement settings
|
|
||||||
try:
|
|
||||||
settings["measurement_state"] = await self.get_measurement_state()
|
|
||||||
except Exception as e:
|
|
||||||
settings["measurement_state"] = f"Error: {e}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings["frequency_weighting"] = await self.get_frequency_weighting()
|
|
||||||
except Exception as e:
|
|
||||||
settings["frequency_weighting"] = f"Error: {e}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings["time_weighting"] = await self.get_time_weighting()
|
|
||||||
except Exception as e:
|
|
||||||
settings["time_weighting"] = f"Error: {e}"
|
|
||||||
|
|
||||||
# Timing/interval settings
|
|
||||||
try:
|
|
||||||
settings["measurement_time"] = await self.get_measurement_time()
|
|
||||||
except Exception as e:
|
|
||||||
settings["measurement_time"] = f"Error: {e}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings["leq_interval"] = await self.get_leq_interval()
|
|
||||||
except Exception as e:
|
|
||||||
settings["leq_interval"] = f"Error: {e}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings["lp_interval"] = await self.get_lp_interval()
|
|
||||||
except Exception as e:
|
|
||||||
settings["lp_interval"] = f"Error: {e}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings["index_number"] = await self.get_index_number()
|
|
||||||
except Exception as e:
|
|
||||||
settings["index_number"] = f"Error: {e}"
|
|
||||||
|
|
||||||
# Device info
|
|
||||||
try:
|
|
||||||
settings["battery_level"] = await self.get_battery_level()
|
|
||||||
except Exception as e:
|
|
||||||
settings["battery_level"] = f"Error: {e}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings["clock"] = await self.get_clock()
|
|
||||||
except Exception as e:
|
|
||||||
settings["clock"] = f"Error: {e}"
|
|
||||||
|
|
||||||
# Sleep mode
|
|
||||||
try:
|
|
||||||
settings["sleep_mode"] = await self.get_sleep_status()
|
|
||||||
except Exception as e:
|
|
||||||
settings["sleep_mode"] = f"Error: {e}"
|
|
||||||
|
|
||||||
# FTP status
|
|
||||||
try:
|
|
||||||
settings["ftp_status"] = await self.get_ftp_status()
|
|
||||||
except Exception as e:
|
|
||||||
settings["ftp_status"] = f"Error: {e}"
|
|
||||||
|
|
||||||
logger.info(f"Retrieved all settings for {self.device_key}")
|
|
||||||
return settings
|
|
||||||
|
|
||||||
async def enable_ftp(self):
|
|
||||||
"""Enable FTP server on the device.
|
|
||||||
|
|
||||||
According to NL43 protocol: FTP,On enables the FTP server
|
|
||||||
"""
|
|
||||||
await self._send_command("FTP,On\r\n")
|
|
||||||
logger.info(f"FTP enabled on {self.device_key}")
|
|
||||||
|
|
||||||
async def disable_ftp(self):
|
|
||||||
"""Disable FTP server on the device.
|
|
||||||
|
|
||||||
According to NL43 protocol: FTP,Off disables the FTP server
|
|
||||||
"""
|
|
||||||
await self._send_command("FTP,Off\r\n")
|
|
||||||
logger.info(f"FTP disabled on {self.device_key}")
|
|
||||||
|
|
||||||
async def get_ftp_status(self) -> str:
|
|
||||||
"""Query FTP server status on the device.
|
|
||||||
|
|
||||||
Returns: "On" or "Off"
|
|
||||||
"""
|
|
||||||
resp = await self._send_command("FTP?\r\n")
|
|
||||||
logger.info(f"FTP status on {self.device_key}: {resp}")
|
|
||||||
return resp.strip()
|
|
||||||
|
|
||||||
async def list_ftp_files(self, remote_path: str = "/") -> List[dict]:
|
|
||||||
"""List files on the device via FTP.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
remote_path: Directory path on the device (default: root)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of file info dicts with 'name', 'size', 'modified', 'is_dir'
|
|
||||||
"""
|
|
||||||
logger.info(f"Listing FTP files on {self.device_key} at {remote_path}")
|
|
||||||
|
|
||||||
def _list_ftp_sync():
|
|
||||||
"""Synchronous FTP listing using ftplib (supports active mode)."""
|
|
||||||
ftp = FTP()
|
|
||||||
ftp.set_debuglevel(0)
|
|
||||||
try:
|
|
||||||
# Connect and login
|
|
||||||
ftp.connect(self.host, 21, timeout=10)
|
|
||||||
ftp.login(self.ftp_username, self.ftp_password)
|
|
||||||
ftp.set_pasv(False) # Force active mode
|
|
||||||
|
|
||||||
# Change to target directory
|
|
||||||
if remote_path != "/":
|
|
||||||
ftp.cwd(remote_path)
|
|
||||||
|
|
||||||
# Get directory listing with details
|
|
||||||
files = []
|
|
||||||
lines = []
|
|
||||||
ftp.retrlines('LIST', lines.append)
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
# Parse Unix-style ls output
|
|
||||||
parts = line.split(None, 8)
|
|
||||||
if len(parts) < 9:
|
|
||||||
continue
|
|
||||||
|
|
||||||
is_dir = parts[0].startswith('d')
|
|
||||||
size = int(parts[4]) if not is_dir else 0
|
|
||||||
name = parts[8]
|
|
||||||
|
|
||||||
# Skip . and ..
|
|
||||||
if name in ('.', '..'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Parse modification time
|
|
||||||
# Format: "Jan 07 14:23" or "Dec 25 2025"
|
|
||||||
modified_str = f"{parts[5]} {parts[6]} {parts[7]}"
|
|
||||||
modified_timestamp = None
|
|
||||||
try:
|
|
||||||
from datetime import datetime
|
|
||||||
# Try parsing with time (recent files: "Jan 07 14:23")
|
|
||||||
try:
|
|
||||||
dt = datetime.strptime(modified_str, "%b %d %H:%M")
|
|
||||||
# Add current year since it's not in the format
|
|
||||||
dt = dt.replace(year=datetime.now().year)
|
|
||||||
|
|
||||||
# If the resulting date is in the future, it's actually from last year
|
|
||||||
if dt > datetime.now():
|
|
||||||
dt = dt.replace(year=dt.year - 1)
|
|
||||||
|
|
||||||
modified_timestamp = dt.isoformat()
|
|
||||||
except ValueError:
|
|
||||||
# Try parsing with year (older files: "Dec 25 2025")
|
|
||||||
dt = datetime.strptime(modified_str, "%b %d %Y")
|
|
||||||
modified_timestamp = dt.isoformat()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to parse timestamp '{modified_str}': {e}")
|
|
||||||
|
|
||||||
file_info = {
|
|
||||||
"name": name,
|
|
||||||
"path": f"{remote_path.rstrip('/')}/{name}",
|
|
||||||
"size": size,
|
|
||||||
"modified": modified_str, # Keep original string
|
|
||||||
"modified_timestamp": modified_timestamp, # Add parsed timestamp
|
|
||||||
"is_dir": is_dir,
|
|
||||||
}
|
|
||||||
files.append(file_info)
|
|
||||||
logger.debug(f"Found file: {file_info}")
|
|
||||||
|
|
||||||
logger.info(f"Found {len(files)} files/directories on {self.device_key}")
|
|
||||||
return files
|
|
||||||
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
ftp.quit()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Run synchronous FTP in thread pool
|
|
||||||
return await asyncio.to_thread(_list_ftp_sync)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list FTP files on {self.device_key}: {e}")
|
|
||||||
raise ConnectionError(f"FTP connection failed: {str(e)}")
|
|
||||||
|
|
||||||
async def download_ftp_file(self, remote_path: str, local_path: str):
|
|
||||||
"""Download a file from the device via FTP.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
remote_path: Full path to file on the device
|
|
||||||
local_path: Local path where file will be saved
|
|
||||||
"""
|
|
||||||
logger.info(f"Downloading {remote_path} from {self.device_key} to {local_path}")
|
|
||||||
|
|
||||||
def _download_ftp_sync():
|
|
||||||
"""Synchronous FTP download using ftplib (supports active mode)."""
|
|
||||||
ftp = FTP()
|
|
||||||
ftp.set_debuglevel(0)
|
|
||||||
try:
|
|
||||||
# Connect and login
|
|
||||||
ftp.connect(self.host, 21, timeout=10)
|
|
||||||
ftp.login(self.ftp_username, self.ftp_password)
|
|
||||||
ftp.set_pasv(False) # Force active mode
|
|
||||||
|
|
||||||
# Download file
|
|
||||||
with open(local_path, 'wb') as f:
|
|
||||||
ftp.retrbinary(f'RETR {remote_path}', f.write)
|
|
||||||
|
|
||||||
logger.info(f"Successfully downloaded {remote_path} to {local_path}")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
ftp.quit()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Run synchronous FTP in thread pool
|
|
||||||
await asyncio.to_thread(_download_ftp_sync)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to download {remote_path} from {self.device_key}: {e}")
|
|
||||||
raise ConnectionError(f"FTP download failed: {str(e)}")
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""
|
|
||||||
UI Layer Routes - HTML page routes only (no business logic)
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import HTMLResponse, FileResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
import os
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
# Setup Jinja2 templates
|
|
||||||
templates = Jinja2Templates(directory="app/ui/templates")
|
|
||||||
|
|
||||||
# Read environment (development or production)
|
|
||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
|
||||||
VERSION = "1.0.0" # Terra-View version
|
|
||||||
|
|
||||||
# Override TemplateResponse to include environment and version in context
|
|
||||||
original_template_response = templates.TemplateResponse
|
|
||||||
def custom_template_response(name, context=None, *args, **kwargs):
|
|
||||||
if context is None:
|
|
||||||
context = {}
|
|
||||||
context["environment"] = ENVIRONMENT
|
|
||||||
context["version"] = VERSION
|
|
||||||
return original_template_response(name, context, *args, **kwargs)
|
|
||||||
templates.TemplateResponse = custom_template_response
|
|
||||||
|
|
||||||
|
|
||||||
# ===== HTML PAGE ROUTES =====
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
|
||||||
async def dashboard(request: Request):
|
|
||||||
"""Dashboard home page"""
|
|
||||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/roster", response_class=HTMLResponse)
|
|
||||||
async def roster_page(request: Request):
|
|
||||||
"""Fleet roster page"""
|
|
||||||
return templates.TemplateResponse("roster.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/unit/{unit_id}", response_class=HTMLResponse)
|
|
||||||
async def unit_detail_page(request: Request, unit_id: str):
|
|
||||||
"""Unit detail page"""
|
|
||||||
return templates.TemplateResponse("unit_detail.html", {
|
|
||||||
"request": request,
|
|
||||||
"unit_id": unit_id
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings", response_class=HTMLResponse)
|
|
||||||
async def settings_page(request: Request):
|
|
||||||
"""Settings page for roster management"""
|
|
||||||
return templates.TemplateResponse("settings.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sound-level-meters", response_class=HTMLResponse)
|
|
||||||
async def sound_level_meters_page(request: Request):
|
|
||||||
"""Sound Level Meters management dashboard"""
|
|
||||||
return templates.TemplateResponse("sound_level_meters.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/seismographs", response_class=HTMLResponse)
|
|
||||||
async def seismographs_page(request: Request):
|
|
||||||
"""Seismographs management dashboard"""
|
|
||||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
# ===== PWA ROUTES =====
|
|
||||||
|
|
||||||
@router.get("/sw.js")
|
|
||||||
async def service_worker():
|
|
||||||
"""Serve service worker with proper headers for PWA"""
|
|
||||||
return FileResponse(
|
|
||||||
"app/ui/static/sw.js",
|
|
||||||
media_type="application/javascript",
|
|
||||||
headers={
|
|
||||||
"Service-Worker-Allowed": "/",
|
|
||||||
"Cache-Control": "no-cache"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/offline-db.js")
|
|
||||||
async def offline_db_script():
|
|
||||||
"""Serve offline database script"""
|
|
||||||
return FileResponse(
|
|
||||||
"app/ui/static/offline-db.js",
|
|
||||||
media_type="application/javascript",
|
|
||||||
headers={"Cache-Control": "no-cache"}
|
|
||||||
)
|
|
||||||
@@ -1,449 +0,0 @@
|
|||||||
<!-- Desktop Table View -->
|
|
||||||
<div class="hidden md:block rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
|
||||||
<table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
|
||||||
<tr>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('status')">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
Status
|
|
||||||
<span class="sort-indicator" data-column="status"></span>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('id')">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
Unit ID
|
|
||||||
<span class="sort-indicator" data-column="id"></span>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('type')">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
Type
|
|
||||||
<span class="sort-indicator" data-column="type"></span>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
Details
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('last_seen')">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
Last Seen
|
|
||||||
<span class="sort-indicator" data-column="last_seen"></span>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('age')">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
Age
|
|
||||||
<span class="sort-indicator" data-column="age"></span>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('note')">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
Note
|
|
||||||
<span class="sort-indicator" data-column="note"></span>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="roster-tbody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{% for unit in units %}
|
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
data-device-type="{{ unit.device_type }}"
|
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
|
||||||
data-health="{{ unit.status }}"
|
|
||||||
data-id="{{ unit.id }}"
|
|
||||||
data-type="{{ unit.device_type }}"
|
|
||||||
data-last-seen="{{ unit.last_seen }}"
|
|
||||||
data-age="{{ unit.age }}"
|
|
||||||
data-note="{{ unit.note if unit.note else '' }}">
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
{% if unit.status == 'OK' %}
|
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
|
||||||
{% elif unit.status == 'Pending' %}
|
|
||||||
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
|
||||||
{% else %}
|
|
||||||
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if unit.deployed %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
|
||||||
{% else %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
|
||||||
{{ unit.id }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
{% if unit.device_type == 'modem' %}
|
|
||||||
<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
|
|
||||||
</span>
|
|
||||||
{% 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">
|
|
||||||
Seismograph
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
|
||||||
{% if unit.device_type == 'modem' %}
|
|
||||||
{% if unit.ip_address %}
|
|
||||||
<div><span class="font-mono">{{ unit.ip_address }}</span></div>
|
|
||||||
{% endif %}
|
|
||||||
{% if unit.phone_number %}
|
|
||||||
<div>{{ unit.phone_number }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if unit.hardware_model %}
|
|
||||||
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
{% if unit.next_calibration_due %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
|
||||||
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if unit.deployed_with_modem_id %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-500">Modem:</span>
|
|
||||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-seismo-orange hover:underline font-medium">
|
|
||||||
{{ unit.deployed_with_modem_id }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm
|
|
||||||
{% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold
|
|
||||||
{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400
|
|
||||||
{% else %}text-gray-500 dark:text-gray-400
|
|
||||||
{% endif %}">
|
|
||||||
{{ unit.age }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs" title="{{ unit.note }}">
|
|
||||||
{{ unit.note if unit.note else '-' }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button onclick="editUnit('{{ unit.id }}')"
|
|
||||||
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
|
|
||||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{% if unit.deployed %}
|
|
||||||
<button onclick="toggleDeployed('{{ unit.id }}', false)"
|
|
||||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-1" title="Mark as Benched">
|
|
||||||
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button onclick="toggleDeployed('{{ unit.id }}', true)"
|
|
||||||
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Mark as Deployed">
|
|
||||||
<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="M5 13l4 4L19 7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
<button onclick="moveToIgnore('{{ unit.id }}')"
|
|
||||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-1" title="Move to Ignore List">
|
|
||||||
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onclick="deleteUnit('{{ unit.id }}')"
|
|
||||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
|
||||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Last updated indicator -->
|
|
||||||
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
|
||||||
Last updated: <span id="last-updated">{{ timestamp }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile Card View -->
|
|
||||||
<div class="md:hidden space-y-3">
|
|
||||||
{% for unit in units %}
|
|
||||||
<div class="unit-card device-card"
|
|
||||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
|
||||||
data-device-type="{{ unit.device_type }}"
|
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
|
||||||
data-health="{{ unit.status }}"
|
|
||||||
data-unit-id="{{ unit.id }}"
|
|
||||||
data-age="{{ unit.age }}">
|
|
||||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{% if unit.status == 'OK' %}
|
|
||||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
|
||||||
{% elif unit.status == 'Pending' %}
|
|
||||||
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
|
||||||
{% elif unit.status == 'Missing' %}
|
|
||||||
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
|
||||||
{% else %}
|
|
||||||
<span class="w-4 h-4 rounded-full bg-gray-400" title="No Data"></span>
|
|
||||||
{% endif %}
|
|
||||||
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="px-3 py-1 rounded-full text-xs font-medium
|
|
||||||
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
|
||||||
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
|
||||||
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
|
||||||
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
|
||||||
{% endif %}">
|
|
||||||
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Type Badge -->
|
|
||||||
<div class="mb-2">
|
|
||||||
{% if unit.device_type == 'modem' %}
|
|
||||||
<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
|
|
||||||
</span>
|
|
||||||
{% 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">
|
|
||||||
Seismograph
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Location -->
|
|
||||||
{% if unit.address %}
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
|
||||||
📍 {{ unit.address }}
|
|
||||||
</div>
|
|
||||||
{% elif unit.coordinates %}
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
|
||||||
📍 {{ unit.coordinates }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Project ID -->
|
|
||||||
{% if unit.project_id %}
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
|
||||||
🏗️ {{ unit.project_id }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Last Seen -->
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
|
||||||
🕐 {{ unit.age }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Deployed/Benched Indicator -->
|
|
||||||
<div class="mt-2">
|
|
||||||
{% if unit.deployed %}
|
|
||||||
<span class="text-xs text-blue-600 dark:text-blue-400">
|
|
||||||
⚡ Deployed
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
|
||||||
📦 Benched
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tap Hint -->
|
|
||||||
<div class="text-xs text-gray-400 mt-2 text-center border-t border-gray-200 dark:border-gray-700 pt-2">
|
|
||||||
Tap for details
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<!-- Mobile Last Updated -->
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
|
||||||
Last updated: <span id="last-updated-mobile">{{ timestamp }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Unit Detail Modal -->
|
|
||||||
<div id="unitModal" class="unit-modal">
|
|
||||||
<!-- Backdrop -->
|
|
||||||
<div class="unit-modal-backdrop" onclick="closeUnitModal(event)"></div>
|
|
||||||
|
|
||||||
<!-- Modal Content -->
|
|
||||||
<div class="unit-modal-content">
|
|
||||||
<!-- Handle Bar (Mobile Only) -->
|
|
||||||
<div class="modal-handle"></div>
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<h3 id="modalUnitId" class="text-xl font-bold text-gray-900 dark:text-white"></h3>
|
|
||||||
<button onclick="closeUnitModal(event)" data-close-modal class="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div id="modalContent" class="p-6">
|
|
||||||
<!-- Content will be populated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
|
||||||
<button id="modalEditBtn" class="w-full h-12 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
|
||||||
Edit Unit
|
|
||||||
</button>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<button id="modalDeployBtn" class="h-12 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
Deploy/Bench
|
|
||||||
</button>
|
|
||||||
<button id="modalDeleteBtn" class="h-12 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sort-indicator::after {
|
|
||||||
content: '⇅';
|
|
||||||
opacity: 0.3;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.sort-indicator.asc::after {
|
|
||||||
content: '↑';
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.sort-indicator.desc::after {
|
|
||||||
content: '↓';
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Update timestamp
|
|
||||||
const timestampElement = document.getElementById('last-updated');
|
|
||||||
if (timestampElement) {
|
|
||||||
timestampElement.textContent = new Date().toLocaleTimeString();
|
|
||||||
}
|
|
||||||
const timestampMobileElement = document.getElementById('last-updated-mobile');
|
|
||||||
if (timestampMobileElement) {
|
|
||||||
timestampMobileElement.textContent = new Date().toLocaleTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep a lightweight status map around for the mobile modal
|
|
||||||
const rosterUnits = {{ units | tojson }};
|
|
||||||
window.rosterStatusMap = rosterUnits.reduce((acc, unit) => {
|
|
||||||
acc[unit.id] = {
|
|
||||||
status: unit.status || 'Unknown',
|
|
||||||
age: unit.age || 'N/A',
|
|
||||||
last: unit.last_seen || 'Never'
|
|
||||||
};
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Sorting state
|
|
||||||
let currentSort = { column: null, direction: 'asc' };
|
|
||||||
|
|
||||||
function sortTable(column) {
|
|
||||||
const tbody = document.getElementById('roster-tbody');
|
|
||||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
|
||||||
|
|
||||||
// Determine sort direction
|
|
||||||
if (currentSort.column === column) {
|
|
||||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
|
||||||
} else {
|
|
||||||
currentSort.column = column;
|
|
||||||
currentSort.direction = 'asc';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort rows
|
|
||||||
rows.sort((a, b) => {
|
|
||||||
let aVal = a.getAttribute(`data-${column}`) || '';
|
|
||||||
let bVal = b.getAttribute(`data-${column}`) || '';
|
|
||||||
|
|
||||||
// Special handling for different column types
|
|
||||||
if (column === 'age') {
|
|
||||||
// Parse age strings like "2h 15m" or "45m" or "3d 5h"
|
|
||||||
aVal = parseAge(aVal);
|
|
||||||
bVal = parseAge(bVal);
|
|
||||||
} else if (column === 'status') {
|
|
||||||
// Sort by status priority: Missing > Pending > OK
|
|
||||||
const statusOrder = { 'Missing': 0, 'Pending': 1, 'OK': 2, '': 3 };
|
|
||||||
aVal = statusOrder[aVal] !== undefined ? statusOrder[aVal] : 999;
|
|
||||||
bVal = statusOrder[bVal] !== undefined ? statusOrder[bVal] : 999;
|
|
||||||
} else if (column === 'last_seen') {
|
|
||||||
// Sort by date
|
|
||||||
aVal = new Date(aVal).getTime() || 0;
|
|
||||||
bVal = new Date(bVal).getTime() || 0;
|
|
||||||
} else {
|
|
||||||
// String comparison (case-insensitive)
|
|
||||||
aVal = aVal.toLowerCase();
|
|
||||||
bVal = bVal.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
|
||||||
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-append rows in sorted order
|
|
||||||
rows.forEach(row => tbody.appendChild(row));
|
|
||||||
|
|
||||||
// Update sort indicators
|
|
||||||
updateSortIndicators();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAge(ageStr) {
|
|
||||||
// Parse age strings like "2h 15m", "45m", "3d 5h", "2w 3d"
|
|
||||||
if (!ageStr) return 0;
|
|
||||||
|
|
||||||
let totalMinutes = 0;
|
|
||||||
const weeks = ageStr.match(/(\d+)w/);
|
|
||||||
const days = ageStr.match(/(\d+)d/);
|
|
||||||
const hours = ageStr.match(/(\d+)h/);
|
|
||||||
const minutes = ageStr.match(/(\d+)m/);
|
|
||||||
|
|
||||||
if (weeks) totalMinutes += parseInt(weeks[1]) * 7 * 24 * 60;
|
|
||||||
if (days) totalMinutes += parseInt(days[1]) * 24 * 60;
|
|
||||||
if (hours) totalMinutes += parseInt(hours[1]) * 60;
|
|
||||||
if (minutes) totalMinutes += parseInt(minutes[1]);
|
|
||||||
|
|
||||||
return totalMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSortIndicators() {
|
|
||||||
// Clear all indicators
|
|
||||||
document.querySelectorAll('.sort-indicator').forEach(indicator => {
|
|
||||||
indicator.className = 'sort-indicator';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set current indicator
|
|
||||||
if (currentSort.column) {
|
|
||||||
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
|
||||||
if (indicator) {
|
|
||||||
indicator.className = `sort-indicator ${currentSort.direction}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<!-- Total Seismographs -->
|
|
||||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Total Seismographs</p>
|
|
||||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ total }}</p>
|
|
||||||
</div>
|
|
||||||
<svg class="w-12 h-12 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Deployed -->
|
|
||||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Deployed</p>
|
|
||||||
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">{{ deployed }}</p>
|
|
||||||
</div>
|
|
||||||
<svg class="w-12 h-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Benched -->
|
|
||||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
|
||||||
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
|
||||||
</div>
|
|
||||||
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- With Modem -->
|
|
||||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">With Modem</p>
|
|
||||||
<p class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-2">{{ with_modem }}<span class="text-base text-gray-500">/ {{ deployed }}</span></p>
|
|
||||||
{% if without_modem > 0 %}
|
|
||||||
<p class="text-xs text-orange-600 dark:text-orange-400 mt-1">{{ without_modem }} without modem</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<svg class="w-12 h-12 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
{% if units %}
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Modem</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Notes</th>
|
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{% for unit in units %}
|
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
|
||||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
|
||||||
{{ unit.id }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
|
||||||
{% if unit.deployed %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
|
||||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
Deployed
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
|
||||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
Benched
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
|
||||||
{% if unit.deployed_with_modem_id %}
|
|
||||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
|
||||||
{{ unit.deployed_with_modem_id }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
|
||||||
{% if unit.address %}
|
|
||||||
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
|
||||||
{% elif unit.coordinates %}
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
|
||||||
{% if unit.note %}
|
|
||||||
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
|
||||||
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
|
||||||
View Details →
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if search %}
|
|
||||||
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Found {{ units|length }} seismograph(s) matching "{{ search }}"
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
|
||||||
</svg>
|
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No seismographs found</h3>
|
|
||||||
{% if search %}
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No seismographs match "{{ search }}"</p>
|
|
||||||
<button onclick="document.getElementById('seismo-search').value = ''; htmx.trigger('#seismo-search', 'keyup');"
|
|
||||||
class="mt-3 text-blue-600 dark:text-blue-400 hover:underline">
|
|
||||||
Clear search
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a seismograph unit from the roster page.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
<form id="slm-config-form"
|
|
||||||
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="handleConfigSave(event)">
|
|
||||||
|
|
||||||
<div class="mb-6">
|
|
||||||
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Model & Serial -->
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
|
||||||
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select model...</option>
|
|
||||||
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
|
|
||||||
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>NL-53</option>
|
|
||||||
</select>
|
|
||||||
</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" value="{{ unit.slm_serial_number or '' }}"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
placeholder="e.g., SN123456">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Frequency & Time Weighting -->
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
<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-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select...</option>
|
|
||||||
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
|
|
||||||
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
|
|
||||||
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>Z-weighting (Linear)</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-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select...</option>
|
|
||||||
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
|
|
||||||
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
|
|
||||||
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Measurement Range -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
|
||||||
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select range...</option>
|
|
||||||
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
|
|
||||||
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
|
|
||||||
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Network Configuration -->
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
|
|
||||||
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<select name="deployed_with_modem_id" id="config-modem-select" class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">No modem (direct connection)</option>
|
|
||||||
<!-- Options loaded via JavaScript -->
|
|
||||||
</select>
|
|
||||||
<button type="button" id="test-modem-btn" onclick="testModemConnection()"
|
|
||||||
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors {% if not unit.deployed_with_modem_id %}opacity-50 cursor-not-allowed{% endif %}"
|
|
||||||
{% if not unit.deployed_with_modem_id %}disabled{% endif %}
|
|
||||||
title="Ping modem to test connectivity">
|
|
||||||
<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="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select a modem for network access, or leave blank for direct IP connection</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Port Configuration (always visible) -->
|
|
||||||
<div class="grid grid-cols-3 gap-4 mb-4">
|
|
||||||
<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" value="{{ unit.slm_tcp_port or '2255' }}"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
|
||||||
placeholder="2255">
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Control port</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
|
|
||||||
<input type="number" name="slm_ftp_port" value="{{ unit.slm_ftp_port or '21' }}"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
|
||||||
placeholder="21">
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Data transfer</p>
|
|
||||||
</div>
|
|
||||||
<div id="direct-ip-field" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direct IP</label>
|
|
||||||
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
|
||||||
placeholder="192.168.1.100">
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no modem</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="flex justify-end gap-3 mt-6">
|
|
||||||
<button type="button" onclick="closeConfigModal()"
|
|
||||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="testSLMConnection('{{ unit.id }}')"
|
|
||||||
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors">
|
|
||||||
Test SLM Connection
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
|
|
||||||
Save Configuration
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Load modems list for dropdown
|
|
||||||
async function loadModemsForConfig() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/roster/modems');
|
|
||||||
const modems = await response.json();
|
|
||||||
|
|
||||||
const select = document.getElementById('config-modem-select');
|
|
||||||
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
|
|
||||||
|
|
||||||
// Keep the "No modem" option
|
|
||||||
modems.forEach(modem => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = modem.id;
|
|
||||||
const ipText = modem.ip_address ? ' (' + modem.ip_address + ')' : '';
|
|
||||||
option.textContent = modem.id + ipText;
|
|
||||||
if (modem.id === currentValue) {
|
|
||||||
option.selected = true;
|
|
||||||
}
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load modems:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle direct IP field and test modem button based on modem selection
|
|
||||||
document.getElementById('config-modem-select')?.addEventListener('change', function() {
|
|
||||||
const directIpField = document.getElementById('direct-ip-field');
|
|
||||||
const testModemBtn = document.getElementById('test-modem-btn');
|
|
||||||
|
|
||||||
if (this.value === '') {
|
|
||||||
directIpField.classList.remove('hidden');
|
|
||||||
testModemBtn.disabled = true;
|
|
||||||
testModemBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
|
||||||
} else {
|
|
||||||
directIpField.classList.add('hidden');
|
|
||||||
testModemBtn.disabled = false;
|
|
||||||
testModemBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle save response
|
|
||||||
function handleConfigSave(event) {
|
|
||||||
if (event.detail.successful) {
|
|
||||||
// Show success message
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
toast.textContent = 'Configuration saved successfully!';
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.remove();
|
|
||||||
closeConfigModal();
|
|
||||||
// Refresh the unit list
|
|
||||||
htmx.trigger('#slm-list', 'load');
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
// Show error message
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
toast.textContent = 'Failed to save configuration';
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.remove();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test connection to modem (health ping)
|
|
||||||
async function testModemConnection() {
|
|
||||||
const modemSelect = document.getElementById('config-modem-select');
|
|
||||||
const modemId = modemSelect.value;
|
|
||||||
|
|
||||||
if (!modemId) {
|
|
||||||
alert('Please select a modem first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
toast.textContent = 'Pinging modem...';
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/slm-dashboard/test-modem/' + modemId);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
toast.remove();
|
|
||||||
|
|
||||||
const resultToast = document.createElement('div');
|
|
||||||
if (response.ok && data.status === 'success') {
|
|
||||||
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
const ipAddr = data.ip_address || modemId;
|
|
||||||
const respTime = data.response_time || 'N/A';
|
|
||||||
resultToast.innerHTML = '✓ Modem responding!<br><span class="text-xs">' + ipAddr + ' - ' + respTime + 'ms</span>';
|
|
||||||
} else {
|
|
||||||
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
resultToast.textContent = '⚠ Modem not responding: ' + (data.detail || 'Unknown error');
|
|
||||||
}
|
|
||||||
document.body.appendChild(resultToast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
resultToast.remove();
|
|
||||||
}, 4000);
|
|
||||||
} catch (error) {
|
|
||||||
toast.remove();
|
|
||||||
const errorToast = document.createElement('div');
|
|
||||||
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
errorToast.textContent = '✗ Failed to ping modem: ' + error.message;
|
|
||||||
document.body.appendChild(errorToast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
errorToast.remove();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test connection to SLM
|
|
||||||
async function testSLMConnection(unitId) {
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
toast.textContent = 'Testing SLM connection...';
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/slmm/' + unitId + '/status');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
toast.remove();
|
|
||||||
|
|
||||||
const resultToast = document.createElement('div');
|
|
||||||
if (response.ok && data.status === 'online') {
|
|
||||||
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
resultToast.textContent = '✓ SLM connection successful! ' + (data.model || 'SLM') + ' responding';
|
|
||||||
} else {
|
|
||||||
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
resultToast.textContent = '⚠ SLM connection failed or device offline';
|
|
||||||
}
|
|
||||||
document.body.appendChild(resultToast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
resultToast.remove();
|
|
||||||
}, 3000);
|
|
||||||
} catch (error) {
|
|
||||||
toast.remove();
|
|
||||||
const errorToast = document.createElement('div');
|
|
||||||
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
errorToast.textContent = '✗ SLM connection test failed: ' + error.message;
|
|
||||||
document.body.appendChild(errorToast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
errorToast.remove();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load modems on page load
|
|
||||||
loadModemsForConfig();
|
|
||||||
</script>
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
<form id="slm-config-form"
|
|
||||||
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="handleConfigSave(event)">
|
|
||||||
|
|
||||||
<div class="mb-6">
|
|
||||||
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Model & Serial -->
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
|
||||||
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select model...</option>
|
|
||||||
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
|
|
||||||
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>NL-53</option>
|
|
||||||
</select>
|
|
||||||
</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" value="{{ unit.slm_serial_number or '' }}"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
placeholder="e.g., SN123456">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Frequency & Time Weighting -->
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
<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-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select...</option>
|
|
||||||
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
|
|
||||||
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
|
|
||||||
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>Z-weighting (Linear)</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-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select...</option>
|
|
||||||
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
|
|
||||||
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
|
|
||||||
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Measurement Range -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
|
||||||
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select range...</option>
|
|
||||||
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
|
|
||||||
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
|
|
||||||
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Network Configuration -->
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
|
|
||||||
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
|
|
||||||
<select name="deployed_with_modem_id" id="config-modem-select" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">No modem (direct connection)</option>
|
|
||||||
<!-- Options loaded via JavaScript -->
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select a modem for network access, or leave blank for direct IP connection</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Legacy direct connection (shown only if no modem selected) -->
|
|
||||||
<div id="direct-connection-fields" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direct IP Address</label>
|
|
||||||
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
|
||||||
placeholder="192.168.1.100">
|
|
||||||
</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" value="{{ unit.slm_tcp_port or '' }}"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
|
||||||
placeholder="502">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="flex justify-end gap-3 mt-6">
|
|
||||||
<button type="button" onclick="closeConfigModal()"
|
|
||||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="testConnection('{{ unit.id }}')"
|
|
||||||
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors">
|
|
||||||
Test Connection
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
|
|
||||||
Save Configuration
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Load modems list for dropdown
|
|
||||||
async function loadModemsForConfig() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/roster/modems');
|
|
||||||
const modems = await response.json();
|
|
||||||
|
|
||||||
const select = document.getElementById('config-modem-select');
|
|
||||||
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
|
|
||||||
|
|
||||||
// Keep the "No modem" option
|
|
||||||
modems.forEach(modem => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = modem.id;
|
|
||||||
option.textContent = `${modem.id}${modem.ip_address ? ' (' + modem.ip_address + ')' : ''}`;
|
|
||||||
if (modem.id === currentValue) {
|
|
||||||
option.selected = true;
|
|
||||||
}
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load modems:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle direct connection fields based on modem selection
|
|
||||||
document.getElementById('config-modem-select')?.addEventListener('change', function() {
|
|
||||||
const directFields = document.getElementById('direct-connection-fields');
|
|
||||||
if (this.value === '') {
|
|
||||||
directFields.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
directFields.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle save response
|
|
||||||
function handleConfigSave(event) {
|
|
||||||
if (event.detail.successful) {
|
|
||||||
// Show success message
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
toast.textContent = 'Configuration saved successfully!';
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.remove();
|
|
||||||
closeConfigModal();
|
|
||||||
// Refresh the unit list
|
|
||||||
htmx.trigger('#slm-list', 'load');
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
// Show error message
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
toast.textContent = 'Failed to save configuration';
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.remove();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test connection to SLM
|
|
||||||
async function testConnection(unitId) {
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
toast.textContent = 'Testing connection...';
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/slmm/${unitId}/status`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
toast.remove();
|
|
||||||
|
|
||||||
const resultToast = document.createElement('div');
|
|
||||||
if (response.ok && data.status === 'online') {
|
|
||||||
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
resultToast.textContent = `✓ Connection successful! ${data.model || 'SLM'} responding`;
|
|
||||||
} else {
|
|
||||||
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
resultToast.textContent = `Connection failed or device offline`;
|
|
||||||
}
|
|
||||||
document.body.appendChild(resultToast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
resultToast.remove();
|
|
||||||
}, 3000);
|
|
||||||
} catch (error) {
|
|
||||||
toast.remove();
|
|
||||||
const errorToast = document.createElement('div');
|
|
||||||
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
|
||||||
errorToast.textContent = '✗ Connection test failed';
|
|
||||||
document.body.appendChild(errorToast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
errorToast.remove();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load modems on page load
|
|
||||||
loadModemsForConfig();
|
|
||||||
</script>
|
|
||||||
@@ -1,438 +0,0 @@
|
|||||||
<!-- Live View Panel for {{ unit.id }} -->
|
|
||||||
<div class="h-full flex flex-col">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ unit.id }}</h2>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{% if unit.slm_model %}{{ unit.slm_model }}{% endif %}
|
|
||||||
{% if unit.slm_serial_number %} • S/N: {{ unit.slm_serial_number }}{% endif %}
|
|
||||||
</p>
|
|
||||||
{% if modem %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
via Modem: {{ modem.id }}{% if modem_ip %} ({{ modem_ip }}){% endif %}
|
|
||||||
</p>
|
|
||||||
{% elif modem_ip %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
Direct: {{ modem_ip }}
|
|
||||||
</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-xs text-red-500 dark:text-red-400 mt-1">
|
|
||||||
⚠️ No modem assigned or IP configured
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Measurement Status Badge -->
|
|
||||||
<div>
|
|
||||||
{% if is_measuring %}
|
|
||||||
<span class="px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded-lg font-medium flex items-center">
|
|
||||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
|
||||||
Measuring
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
|
|
||||||
Stopped
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Control Buttons -->
|
|
||||||
<div class="flex gap-2 mb-6">
|
|
||||||
<button onclick="controlUnit('{{ unit.id }}', 'start')"
|
|
||||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg flex items-center">
|
|
||||||
<svg class="w-5 h-5 mr-2" 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 onclick="controlUnit('{{ unit.id }}', 'pause')"
|
|
||||||
class="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg flex items-center">
|
|
||||||
<svg class="w-5 h-5 mr-2" 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 onclick="controlUnit('{{ unit.id }}', 'stop')"
|
|
||||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center">
|
|
||||||
<svg class="w-5 h-5 mr-2" 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 onclick="controlUnit('{{ unit.id }}', 'reset')"
|
|
||||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg flex items-center">
|
|
||||||
<svg class="w-5 h-5 mr-2" 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>
|
|
||||||
|
|
||||||
<button id="start-stream-btn" onclick="initLiveDataStream('{{ unit.id }}')"
|
|
||||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center ml-auto">
|
|
||||||
<svg class="w-5 h-5 mr-2" 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>
|
|
||||||
Start Live Stream
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="stop-stream-btn" onclick="stopLiveDataStream()" style="display: none;"
|
|
||||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center ml-auto">
|
|
||||||
<svg class="w-5 h-5 mr-2" 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 Live Stream
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Current Metrics -->
|
|
||||||
<div class="grid grid-cols-5 gap-4 mb-6">
|
|
||||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</p>
|
|
||||||
<p id="live-lp" class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
|
||||||
{% if current_status and current_status.lp %}{{ current_status.lp }}{% else %}--{% endif %}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</p>
|
|
||||||
<p id="live-leq" class="text-2xl font-bold text-green-600 dark:text-green-400">
|
|
||||||
{% if current_status and current_status.leq %}{{ current_status.leq }}{% else %}--{% endif %}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmax (Max)</p>
|
|
||||||
<p id="live-lmax" class="text-2xl font-bold text-red-600 dark:text-red-400">
|
|
||||||
{% if current_status and current_status.lmax %}{{ current_status.lmax }}{% else %}--{% endif %}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
|
||||||
<p id="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
|
||||||
{% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
|
||||||
<p id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
|
||||||
{% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Live Chart -->
|
|
||||||
<div class="flex-1 bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4" style="min-height: 400px;">
|
|
||||||
<canvas id="liveChart"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Device Info -->
|
|
||||||
<div class="mt-4 grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">Battery:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
|
||||||
{% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">Power:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
|
||||||
{% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">Weighting:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
|
||||||
{% if unit.slm_frequency_weighting %}{{ unit.slm_frequency_weighting }}{% else %}--{% endif %} /
|
|
||||||
{% if unit.slm_time_weighting %}{{ unit.slm_time_weighting }}{% else %}--{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">SD Remaining:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
|
||||||
{% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
<script>
|
|
||||||
// Initialize Chart.js for live data visualization
|
|
||||||
function initializeChart() {
|
|
||||||
// Wait for Chart.js to load
|
|
||||||
if (typeof Chart === 'undefined') {
|
|
||||||
console.log('Waiting for Chart.js to load...');
|
|
||||||
setTimeout(initializeChart, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Chart.js loaded, version:', Chart.version);
|
|
||||||
|
|
||||||
const canvas = document.getElementById('liveChart');
|
|
||||||
if (!canvas) {
|
|
||||||
console.error('Chart canvas not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Canvas found:', canvas);
|
|
||||||
|
|
||||||
// Destroy existing chart if it exists
|
|
||||||
if (window.liveChart && typeof window.liveChart.destroy === 'function') {
|
|
||||||
console.log('Destroying existing chart');
|
|
||||||
window.liveChart.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
console.log('Creating new chart...');
|
|
||||||
|
|
||||||
// Dark mode detection
|
|
||||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
|
||||||
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
|
||||||
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
|
||||||
|
|
||||||
window.liveChart = new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: [],
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: 'Lp (Instantaneous)',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Leq (Equivalent)',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(34, 197, 94)',
|
|
||||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
animation: false,
|
|
||||||
interaction: {
|
|
||||||
intersect: false,
|
|
||||||
mode: 'index'
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
display: true,
|
|
||||||
grid: {
|
|
||||||
color: gridColor
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
color: textColor,
|
|
||||||
maxTicksLimit: 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
display: true,
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: 'Sound Level (dB)',
|
|
||||||
color: textColor
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
color: gridColor
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
color: textColor
|
|
||||||
},
|
|
||||||
min: 30,
|
|
||||||
max: 130
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
labels: {
|
|
||||||
color: textColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Chart created successfully:', window.liveChart);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize chart when DOM is ready
|
|
||||||
console.log('Executing initializeChart...');
|
|
||||||
initializeChart();
|
|
||||||
|
|
||||||
// WebSocket management (use global scope to avoid redeclaration)
|
|
||||||
if (typeof window.currentWebSocket === 'undefined') {
|
|
||||||
window.currentWebSocket = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initLiveDataStream(unitId) {
|
|
||||||
// Close existing connection if any
|
|
||||||
if (window.currentWebSocket) {
|
|
||||||
window.currentWebSocket.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset chart data
|
|
||||||
if (window.chartData) {
|
|
||||||
window.chartData.timestamps = [];
|
|
||||||
window.chartData.lp = [];
|
|
||||||
window.chartData.leq = [];
|
|
||||||
}
|
|
||||||
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
|
||||||
window.liveChart.data.labels = [];
|
|
||||||
window.liveChart.data.datasets[0].data = [];
|
|
||||||
window.liveChart.data.datasets[1].data = [];
|
|
||||||
window.liveChart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket URL for SLMM backend via proxy
|
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
|
||||||
|
|
||||||
window.currentWebSocket = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
window.currentWebSocket.onopen = function() {
|
|
||||||
console.log('WebSocket connected');
|
|
||||||
// Toggle button visibility
|
|
||||||
const startBtn = document.getElementById('start-stream-btn');
|
|
||||||
const stopBtn = document.getElementById('stop-stream-btn');
|
|
||||||
if (startBtn) startBtn.style.display = 'none';
|
|
||||||
if (stopBtn) stopBtn.style.display = 'flex';
|
|
||||||
};
|
|
||||||
|
|
||||||
window.currentWebSocket.onmessage = function(event) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
console.log('WebSocket data received:', data);
|
|
||||||
updateLiveMetrics(data);
|
|
||||||
updateLiveChart(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.currentWebSocket.onerror = function(error) {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.currentWebSocket.onclose = function() {
|
|
||||||
console.log('WebSocket closed');
|
|
||||||
// Toggle button visibility
|
|
||||||
const startBtn = document.getElementById('start-stream-btn');
|
|
||||||
const stopBtn = document.getElementById('stop-stream-btn');
|
|
||||||
if (startBtn) startBtn.style.display = 'flex';
|
|
||||||
if (stopBtn) stopBtn.style.display = 'none';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopLiveDataStream() {
|
|
||||||
if (window.currentWebSocket) {
|
|
||||||
window.currentWebSocket.close();
|
|
||||||
window.currentWebSocket = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update metrics display
|
|
||||||
function updateLiveMetrics(data) {
|
|
||||||
if (document.getElementById('live-lp')) {
|
|
||||||
document.getElementById('live-lp').textContent = data.lp || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-leq')) {
|
|
||||||
document.getElementById('live-leq').textContent = data.leq || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-lmax')) {
|
|
||||||
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-lmin')) {
|
|
||||||
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-lpeak')) {
|
|
||||||
document.getElementById('live-lpeak').textContent = data.lpeak || '--';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chart data storage (use global scope to avoid redeclaration)
|
|
||||||
if (typeof window.chartData === 'undefined') {
|
|
||||||
window.chartData = {
|
|
||||||
timestamps: [],
|
|
||||||
lp: [],
|
|
||||||
leq: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update live chart
|
|
||||||
function updateLiveChart(data) {
|
|
||||||
const now = new Date();
|
|
||||||
window.chartData.timestamps.push(now.toLocaleTimeString());
|
|
||||||
window.chartData.lp.push(parseFloat(data.lp || 0));
|
|
||||||
window.chartData.leq.push(parseFloat(data.leq || 0));
|
|
||||||
|
|
||||||
// Keep only last 60 data points
|
|
||||||
if (window.chartData.timestamps.length > 60) {
|
|
||||||
window.chartData.timestamps.shift();
|
|
||||||
window.chartData.lp.shift();
|
|
||||||
window.chartData.leq.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update chart if available
|
|
||||||
if (window.liveChart) {
|
|
||||||
window.liveChart.data.labels = window.chartData.timestamps;
|
|
||||||
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
|
||||||
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
|
||||||
window.liveChart.update('none');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Control function
|
|
||||||
async function controlUnit(unitId, action) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/slm-dashboard/control/${unitId}/${action}`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.status === 'ok') {
|
|
||||||
// Reload the live view to update status
|
|
||||||
setTimeout(() => {
|
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
|
||||||
target: '#live-view-panel',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
} else {
|
|
||||||
alert(`Error: ${result.detail || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert(`Failed to control unit: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup on page unload
|
|
||||||
window.addEventListener('beforeunload', function() {
|
|
||||||
if (window.currentWebSocket) {
|
|
||||||
window.currentWebSocket.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<!-- Error State for Live View -->
|
|
||||||
<div class="flex flex-col items-center justify-center h-[600px] text-red-500 dark:text-red-400">
|
|
||||||
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
</svg>
|
|
||||||
<p class="text-lg font-medium">Error Loading Unit</p>
|
|
||||||
<p class="text-sm mt-2 text-gray-600 dark:text-gray-400">{{ error }}</p>
|
|
||||||
</div>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<!-- Summary stat cards -->
|
|
||||||
<!-- Total Units Card -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Total Units</p>
|
|
||||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ total_count }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
|
|
||||||
<svg class="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Deployed Units Card -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Deployed</p>
|
|
||||||
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{{ deployed_count }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
|
|
||||||
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Active Now Card -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Active Now</p>
|
|
||||||
<p class="text-3xl font-bold text-seismo-orange mt-1">{{ active_count }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-orange-100 dark:bg-orange-900/30 p-3 rounded-lg">
|
|
||||||
<svg class="w-8 h-8 text-seismo-orange" 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Checked in last hour</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Benched Units Card -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Benched</p>
|
|
||||||
<p class="text-3xl font-bold text-gray-500 dark:text-gray-400 mt-1">{{ benched_count }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
|
|
||||||
<svg class="w-8 h-8 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<!-- SLM Unit List -->
|
|
||||||
{% if units %}
|
|
||||||
{% for unit in units %}
|
|
||||||
<div class="slm-unit-item bg-gray-100 dark:bg-gray-700 rounded-lg p-4 transition-colors relative group">
|
|
||||||
<!-- Configure button (appears on hover) -->
|
|
||||||
<button onclick="event.stopPropagation(); openConfigModal('{{ unit.id }}');"
|
|
||||||
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full p-1.5 hover:bg-gray-200 dark:hover:bg-gray-600 z-10"
|
|
||||||
title="Configure {{ unit.id }}">
|
|
||||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="cursor-pointer" onclick="selectUnit('{{ unit.id }}')">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span class="font-semibold">{{ unit.id }}</span>
|
|
||||||
<!-- Status indicator: green=active, yellow=recent, red=old, gray=never -->
|
|
||||||
{% if unit.slm_last_check %}
|
|
||||||
<span class="w-2 h-2 bg-green-500 rounded-full" title="Active"></span>
|
|
||||||
{% else %}
|
|
||||||
<span class="w-2 h-2 bg-gray-400 rounded-full" title="No check-in"></span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-sm space-y-1">
|
|
||||||
{% if unit.slm_model %}
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
|
||||||
</svg>
|
|
||||||
{{ unit.slm_model }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if unit.address %}
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="truncate">{{ unit.address }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if unit.deployed_with_modem_id %}
|
|
||||||
<div class="flex items-center font-mono text-xs">
|
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
|
||||||
</svg>
|
|
||||||
{{ unit.deployed_with_modem_id }}
|
|
||||||
</div>
|
|
||||||
{% elif unit.slm_host %}
|
|
||||||
<div class="flex items-center font-mono text-xs">
|
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
|
|
||||||
</svg>
|
|
||||||
{{ unit.slm_host }}{% if unit.slm_tcp_port %}:{{ unit.slm_tcp_port }}{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
|
||||||
</svg>
|
|
||||||
<p>No sound level meters found</p>
|
|
||||||
<p class="text-sm mt-1">Add units from the Fleet Roster</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Seismographs - Seismo Fleet Manager{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Seismographs</h1>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage and monitor seismograph units</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auto-refresh stats every 30 seconds -->
|
|
||||||
<div hx-get="/api/seismo-dashboard/stats"
|
|
||||||
hx-trigger="load, every 30s"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
class="mb-8">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Loading...</p>
|
|
||||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">--</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Seismograph List -->
|
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
|
||||||
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
|
|
||||||
|
|
||||||
<!-- Search Box -->
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="seismo-search"
|
|
||||||
placeholder="Search seismographs..."
|
|
||||||
class="w-full sm:w-64 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
hx-get="/api/seismo-dashboard/units"
|
|
||||||
hx-trigger="keyup changed delay:300ms"
|
|
||||||
hx-target="#seismo-units-list"
|
|
||||||
hx-include="[name='search']"
|
|
||||||
name="search"
|
|
||||||
/>
|
|
||||||
<svg class="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Units List (loaded via HTMX) -->
|
|
||||||
<div id="seismo-units-list"
|
|
||||||
hx-get="/api/seismo-dashboard/units"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<p class="text-gray-500 dark:text-gray-400">Loading seismographs...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Clear search input on escape key
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const searchInput = document.getElementById('seismo-search');
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
this.value = '';
|
|
||||||
htmx.trigger(this, 'keyup');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Sound Level Meters - Seismo Fleet Manager{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sound Level Meters</h1>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and manage sound level measurement devices</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
|
||||||
hx-get="/api/slm-dashboard/stats"
|
|
||||||
hx-trigger="load, every 10s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<!-- Stats will be loaded here -->
|
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content Grid -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<!-- SLM List -->
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Units</h2>
|
|
||||||
|
|
||||||
<!-- Search/Filter -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<input type="text"
|
|
||||||
placeholder="Search units..."
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
hx-get="/api/slm-dashboard/units"
|
|
||||||
hx-trigger="keyup changed delay:300ms"
|
|
||||||
hx-target="#slm-list"
|
|
||||||
hx-include="this"
|
|
||||||
name="search">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SLM List -->
|
|
||||||
<div id="slm-list"
|
|
||||||
class="space-y-2 max-h-[600px] overflow-y-auto"
|
|
||||||
hx-get="/api/slm-dashboard/units"
|
|
||||||
hx-trigger="load, every 10s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<!-- Loading skeleton -->
|
|
||||||
<div class="animate-pulse space-y-2">
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Live View Panel -->
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<div id="live-view-panel" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<!-- Initial state - no unit selected -->
|
|
||||||
<div class="flex flex-col items-center justify-center h-[600px] text-gray-400 dark:text-gray-500">
|
|
||||||
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
|
||||||
</svg>
|
|
||||||
<p class="text-lg font-medium">No unit selected</p>
|
|
||||||
<p class="text-sm mt-2">Select a sound level meter from the list to view live data</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Configuration Modal -->
|
|
||||||
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
|
|
||||||
<button onclick="closeConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="config-modal-content">
|
|
||||||
<!-- Content loaded via HTMX -->
|
|
||||||
<div class="animate-pulse space-y-4">
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Function to select a unit and load live view
|
|
||||||
function selectUnit(unitId) {
|
|
||||||
// Remove active state from all items
|
|
||||||
document.querySelectorAll('.slm-unit-item').forEach(item => {
|
|
||||||
item.classList.remove('bg-seismo-orange', 'text-white');
|
|
||||||
item.classList.add('bg-gray-100', 'dark:bg-gray-700');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add active state to clicked item
|
|
||||||
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
|
|
||||||
event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
|
|
||||||
|
|
||||||
// Load live view for this unit
|
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
|
||||||
target: '#live-view-panel',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration modal functions
|
|
||||||
function openConfigModal(unitId) {
|
|
||||||
const modal = document.getElementById('config-modal');
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Load configuration form via HTMX
|
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
|
||||||
target: '#config-modal-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeConfigModal() {
|
|
||||||
document.getElementById('config-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal on escape key
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal when clicking outside
|
|
||||||
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closeConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize WebSocket for selected unit
|
|
||||||
let currentWebSocket = null;
|
|
||||||
|
|
||||||
function initLiveDataStream(unitId) {
|
|
||||||
// Close existing connection if any
|
|
||||||
if (currentWebSocket) {
|
|
||||||
currentWebSocket.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket URL for SLMM backend via proxy
|
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
|
||||||
|
|
||||||
currentWebSocket = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
currentWebSocket.onopen = function() {
|
|
||||||
console.log('WebSocket connected');
|
|
||||||
// Toggle button visibility
|
|
||||||
const startBtn = document.getElementById('start-stream-btn');
|
|
||||||
const stopBtn = document.getElementById('stop-stream-btn');
|
|
||||||
if (startBtn) startBtn.style.display = 'none';
|
|
||||||
if (stopBtn) stopBtn.style.display = 'flex';
|
|
||||||
};
|
|
||||||
|
|
||||||
currentWebSocket.onmessage = async function(event) {
|
|
||||||
try {
|
|
||||||
let payload = event.data;
|
|
||||||
if (payload instanceof Blob) {
|
|
||||||
payload = await payload.text();
|
|
||||||
}
|
|
||||||
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
|
||||||
updateLiveChart(data);
|
|
||||||
updateLiveMetrics(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
currentWebSocket.onerror = function(error) {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
currentWebSocket.onclose = function() {
|
|
||||||
console.log('WebSocket closed');
|
|
||||||
// Toggle button visibility
|
|
||||||
const startBtn = document.getElementById('start-stream-btn');
|
|
||||||
const stopBtn = document.getElementById('stop-stream-btn');
|
|
||||||
if (startBtn) startBtn.style.display = 'flex';
|
|
||||||
if (stopBtn) stopBtn.style.display = 'none';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopLiveDataStream() {
|
|
||||||
if (currentWebSocket) {
|
|
||||||
currentWebSocket.close();
|
|
||||||
currentWebSocket = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update live chart with new data point
|
|
||||||
let chartData = {
|
|
||||||
timestamps: [],
|
|
||||||
lp: [],
|
|
||||||
leq: []
|
|
||||||
};
|
|
||||||
|
|
||||||
function updateLiveChart(data) {
|
|
||||||
const now = new Date();
|
|
||||||
chartData.timestamps.push(now.toLocaleTimeString());
|
|
||||||
chartData.lp.push(parseFloat(data.lp || 0));
|
|
||||||
chartData.leq.push(parseFloat(data.leq || 0));
|
|
||||||
|
|
||||||
// Keep only last 60 data points (1 minute at 1 sample/sec)
|
|
||||||
if (chartData.timestamps.length > 60) {
|
|
||||||
chartData.timestamps.shift();
|
|
||||||
chartData.lp.shift();
|
|
||||||
chartData.leq.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update chart (using Chart.js if available)
|
|
||||||
if (window.liveChart) {
|
|
||||||
window.liveChart.data.labels = chartData.timestamps;
|
|
||||||
window.liveChart.data.datasets[0].data = chartData.lp;
|
|
||||||
window.liveChart.data.datasets[1].data = chartData.leq;
|
|
||||||
window.liveChart.update('none'); // Update without animation for smooth real-time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLiveMetrics(data) {
|
|
||||||
// Update metric displays
|
|
||||||
if (document.getElementById('live-lp')) {
|
|
||||||
document.getElementById('live-lp').textContent = data.lp || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-leq')) {
|
|
||||||
document.getElementById('live-leq').textContent = data.leq || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-lmax')) {
|
|
||||||
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-lmin')) {
|
|
||||||
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup on page unload
|
|
||||||
window.addEventListener('beforeunload', function() {
|
|
||||||
if (currentWebSocket) {
|
|
||||||
currentWebSocket.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
373
backend/main.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import os
|
||||||
|
from fastapi import FastAPI, Request, Depends
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
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, slmm, slm_ui
|
||||||
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
from backend.models import IgnoredUnit
|
||||||
|
|
||||||
|
# Create database tables
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Read environment (development or production)
|
||||||
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
|
# Initialize FastAPI app
|
||||||
|
VERSION = "0.4.0"
|
||||||
|
app = FastAPI(
|
||||||
|
title="Seismo Fleet Manager",
|
||||||
|
description="Backend API for managing seismograph fleet status",
|
||||||
|
version=VERSION
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount static files
|
||||||
|
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
||||||
|
|
||||||
|
# Setup Jinja2 templates
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
# Add custom context processor to inject environment variable into all templates
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_environment_to_context(request: Request, call_next):
|
||||||
|
"""Middleware to add environment variable to request state"""
|
||||||
|
request.state.environment = ENVIRONMENT
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Override TemplateResponse to include environment and version in context
|
||||||
|
original_template_response = templates.TemplateResponse
|
||||||
|
def custom_template_response(name, context=None, *args, **kwargs):
|
||||||
|
if context is None:
|
||||||
|
context = {}
|
||||||
|
context["environment"] = ENVIRONMENT
|
||||||
|
context["version"] = VERSION
|
||||||
|
return original_template_response(name, context, *args, **kwargs)
|
||||||
|
templates.TemplateResponse = custom_template_response
|
||||||
|
|
||||||
|
# Include API routers
|
||||||
|
app.include_router(roster.router)
|
||||||
|
app.include_router(units.router)
|
||||||
|
app.include_router(photos.router)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy routes from the original backend
|
||||||
|
from backend import routes as legacy_routes
|
||||||
|
app.include_router(legacy_routes.router)
|
||||||
|
|
||||||
|
|
||||||
|
# HTML page routes
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
"""Dashboard home page"""
|
||||||
|
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/roster", response_class=HTMLResponse)
|
||||||
|
async def roster_page(request: Request):
|
||||||
|
"""Fleet roster page"""
|
||||||
|
return templates.TemplateResponse("roster.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/unit/{unit_id}", response_class=HTMLResponse)
|
||||||
|
async def unit_detail_page(request: Request, unit_id: str):
|
||||||
|
"""Unit detail page"""
|
||||||
|
return templates.TemplateResponse("unit_detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit_id": unit_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_page(request: Request):
|
||||||
|
"""Settings page for roster management"""
|
||||||
|
return templates.TemplateResponse("settings.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
# ===== PWA ROUTES =====
|
||||||
|
|
||||||
|
@app.get("/sw.js")
|
||||||
|
async def service_worker():
|
||||||
|
"""Serve service worker with proper headers for PWA"""
|
||||||
|
return FileResponse(
|
||||||
|
"backend/static/sw.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={
|
||||||
|
"Service-Worker-Allowed": "/",
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/offline-db.js")
|
||||||
|
async def offline_db_script():
|
||||||
|
"""Serve offline database script"""
|
||||||
|
return FileResponse(
|
||||||
|
"backend/static/offline-db.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic model for sync edits
|
||||||
|
class EditItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
unitId: str
|
||||||
|
changes: Dict
|
||||||
|
timestamp: int
|
||||||
|
|
||||||
|
|
||||||
|
class SyncEditsRequest(BaseModel):
|
||||||
|
edits: List[EditItem]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/sync-edits")
|
||||||
|
async def sync_edits(request: SyncEditsRequest, db: Session = Depends(get_db)):
|
||||||
|
"""Process offline edit queue and sync to database"""
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
|
results = []
|
||||||
|
synced_ids = []
|
||||||
|
|
||||||
|
for edit in request.edits:
|
||||||
|
try:
|
||||||
|
# Find the unit
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=edit.unitId).first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "error",
|
||||||
|
"reason": f"Unit {edit.unitId} not found"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply changes
|
||||||
|
for key, value in edit.changes.items():
|
||||||
|
if hasattr(unit, key):
|
||||||
|
# Handle boolean conversions
|
||||||
|
if key in ['deployed', 'retired']:
|
||||||
|
setattr(unit, key, value in ['true', True, 'True', '1', 1])
|
||||||
|
else:
|
||||||
|
setattr(unit, key, value if value != '' else None)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "success"
|
||||||
|
})
|
||||||
|
synced_ids.append(edit.id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "error",
|
||||||
|
"reason": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
synced_count = len(synced_ids)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"synced": synced_count,
|
||||||
|
"total": len(request.edits),
|
||||||
|
"synced_ids": synced_ids,
|
||||||
|
"results": results
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/partials/roster-deployed", response_class=HTMLResponse)
|
||||||
|
async def roster_deployed_partial(request: Request):
|
||||||
|
"""Partial template for deployed units tab"""
|
||||||
|
from datetime import datetime
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
|
units_list = []
|
||||||
|
for unit_id, unit_data in snapshot["active"].items():
|
||||||
|
units_list.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": unit_data.get("status", "Unknown"),
|
||||||
|
"age": unit_data.get("age", "N/A"),
|
||||||
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
|
"deployed": unit_data.get("deployed", False),
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"project_id": unit_data.get("project_id", ""),
|
||||||
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"ip_address": unit_data.get("ip_address"),
|
||||||
|
"phone_number": unit_data.get("phone_number"),
|
||||||
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by status priority (Missing > Pending > OK) then by ID
|
||||||
|
status_priority = {"Missing": 0, "Pending": 1, "OK": 2}
|
||||||
|
units_list.sort(key=lambda x: (status_priority.get(x["status"], 3), x["id"]))
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/roster_table.html", {
|
||||||
|
"request": request,
|
||||||
|
"units": units_list,
|
||||||
|
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/partials/roster-benched", response_class=HTMLResponse)
|
||||||
|
async def roster_benched_partial(request: Request):
|
||||||
|
"""Partial template for benched units tab"""
|
||||||
|
from datetime import datetime
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
|
units_list = []
|
||||||
|
for unit_id, unit_data in snapshot["benched"].items():
|
||||||
|
units_list.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": unit_data.get("status", "N/A"),
|
||||||
|
"age": unit_data.get("age", "N/A"),
|
||||||
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
|
"deployed": unit_data.get("deployed", False),
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"project_id": unit_data.get("project_id", ""),
|
||||||
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"ip_address": unit_data.get("ip_address"),
|
||||||
|
"phone_number": unit_data.get("phone_number"),
|
||||||
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by ID
|
||||||
|
units_list.sort(key=lambda x: x["id"])
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/roster_table.html", {
|
||||||
|
"request": request,
|
||||||
|
"units": units_list,
|
||||||
|
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/partials/roster-retired", response_class=HTMLResponse)
|
||||||
|
async def roster_retired_partial(request: Request):
|
||||||
|
"""Partial template for retired units tab"""
|
||||||
|
from datetime import datetime
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
|
units_list = []
|
||||||
|
for unit_id, unit_data in snapshot["retired"].items():
|
||||||
|
units_list.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": unit_data["status"],
|
||||||
|
"age": unit_data["age"],
|
||||||
|
"last_seen": unit_data["last"],
|
||||||
|
"deployed": unit_data["deployed"],
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"ip_address": unit_data.get("ip_address"),
|
||||||
|
"phone_number": unit_data.get("phone_number"),
|
||||||
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by ID
|
||||||
|
units_list.sort(key=lambda x: x["id"])
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/retired_table.html", {
|
||||||
|
"request": request,
|
||||||
|
"units": units_list,
|
||||||
|
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/partials/roster-ignored", response_class=HTMLResponse)
|
||||||
|
async def roster_ignored_partial(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Partial template for ignored units tab"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
ignored = db.query(IgnoredUnit).all()
|
||||||
|
ignored_list = []
|
||||||
|
for unit in ignored:
|
||||||
|
ignored_list.append({
|
||||||
|
"id": unit.id,
|
||||||
|
"reason": unit.reason or "",
|
||||||
|
"ignored_at": unit.ignored_at.strftime("%Y-%m-%d %H:%M:%S") if unit.ignored_at else "Unknown"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by ID
|
||||||
|
ignored_list.sort(key=lambda x: x["id"])
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/ignored_table.html", {
|
||||||
|
"request": request,
|
||||||
|
"ignored_units": ignored_list,
|
||||||
|
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/partials/unknown-emitters", response_class=HTMLResponse)
|
||||||
|
async def unknown_emitters_partial(request: Request):
|
||||||
|
"""Partial template for unknown emitters (HTMX)"""
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
|
unknown_list = []
|
||||||
|
for unit_id, unit_data in snapshot.get("unknown", {}).items():
|
||||||
|
unknown_list.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": unit_data["status"],
|
||||||
|
"age": unit_data["age"],
|
||||||
|
"fname": unit_data.get("fname", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by ID
|
||||||
|
unknown_list.sort(key=lambda x: x["id"])
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/unknown_emitters.html", {
|
||||||
|
"request": request,
|
||||||
|
"unknown_units": unknown_list
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {
|
||||||
|
"message": f"Seismo Fleet Manager v{VERSION}",
|
||||||
|
"status": "running",
|
||||||
|
"version": VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||||
84
backend/migrate_add_device_types.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
Migration script to add device type support to the roster table.
|
||||||
|
|
||||||
|
This adds columns for:
|
||||||
|
- device_type (seismograph/modem discriminator)
|
||||||
|
- Seismograph-specific fields (calibration dates, modem pairing)
|
||||||
|
- Modem-specific fields (IP address, phone number, hardware model)
|
||||||
|
|
||||||
|
Run this script once to migrate an existing database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
"""Add new columns to the roster table"""
|
||||||
|
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
print("The database will be created automatically when you run the application.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Migrating database: {DB_PATH}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if device_type column already exists
|
||||||
|
cursor.execute("PRAGMA table_info(roster)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if "device_type" in columns:
|
||||||
|
print("Migration already applied - device_type column exists")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Adding new columns to roster table...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Add device type discriminator
|
||||||
|
cursor.execute("ALTER TABLE roster ADD COLUMN device_type TEXT DEFAULT 'seismograph'")
|
||||||
|
print(" ✓ Added device_type column")
|
||||||
|
|
||||||
|
# Add seismograph-specific fields
|
||||||
|
cursor.execute("ALTER TABLE roster ADD COLUMN last_calibrated DATE")
|
||||||
|
print(" ✓ Added last_calibrated column")
|
||||||
|
|
||||||
|
cursor.execute("ALTER TABLE roster ADD COLUMN next_calibration_due DATE")
|
||||||
|
print(" ✓ Added next_calibration_due column")
|
||||||
|
|
||||||
|
cursor.execute("ALTER TABLE roster ADD COLUMN deployed_with_modem_id TEXT")
|
||||||
|
print(" ✓ Added deployed_with_modem_id column")
|
||||||
|
|
||||||
|
# Add modem-specific fields
|
||||||
|
cursor.execute("ALTER TABLE roster ADD COLUMN ip_address TEXT")
|
||||||
|
print(" ✓ Added ip_address column")
|
||||||
|
|
||||||
|
cursor.execute("ALTER TABLE roster ADD COLUMN phone_number TEXT")
|
||||||
|
print(" ✓ Added phone_number column")
|
||||||
|
|
||||||
|
cursor.execute("ALTER TABLE roster ADD COLUMN hardware_model TEXT")
|
||||||
|
print(" ✓ Added hardware_model column")
|
||||||
|
|
||||||
|
# Set all existing units to seismograph type
|
||||||
|
cursor.execute("UPDATE roster SET device_type = 'seismograph' WHERE device_type IS NULL")
|
||||||
|
print(" ✓ Set existing units to seismograph type")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"\nError during migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_database()
|
||||||
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()
|
||||||
78
backend/migrate_add_unit_history.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
Migration script to add unit history timeline support.
|
||||||
|
|
||||||
|
This creates the unit_history table to track all changes to units:
|
||||||
|
- Note changes (archived old notes, new notes)
|
||||||
|
- Deployment status changes (deployed/benched)
|
||||||
|
- Retired status changes
|
||||||
|
- Other field changes
|
||||||
|
|
||||||
|
Run this script once to migrate an existing database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
"""Create the unit_history table"""
|
||||||
|
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
print("The database will be created automatically when you run the application.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Migrating database: {DB_PATH}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if unit_history table already exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='unit_history'")
|
||||||
|
if cursor.fetchone():
|
||||||
|
print("Migration already applied - unit_history table exists")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Creating unit_history table...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE unit_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
unit_id TEXT NOT NULL,
|
||||||
|
change_type TEXT NOT NULL,
|
||||||
|
field_name TEXT,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_at TIMESTAMP NOT NULL,
|
||||||
|
source TEXT DEFAULT 'manual',
|
||||||
|
notes TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print(" ✓ Created unit_history table")
|
||||||
|
|
||||||
|
# Create indexes for better query performance
|
||||||
|
cursor.execute("CREATE INDEX idx_unit_history_unit_id ON unit_history(unit_id)")
|
||||||
|
print(" ✓ Created index on unit_id")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX idx_unit_history_changed_at ON unit_history(changed_at)")
|
||||||
|
print(" ✓ Created index on changed_at")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
|
print("Units will now track their complete history of changes.")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"\nError during migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_database()
|
||||||
80
backend/migrate_add_user_preferences.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Migration script to add user_preferences table.
|
||||||
|
|
||||||
|
This creates a new table for storing persistent user preferences:
|
||||||
|
- Display settings (timezone, theme, date format)
|
||||||
|
- Auto-refresh configuration
|
||||||
|
- Calibration defaults
|
||||||
|
- Status threshold customization
|
||||||
|
|
||||||
|
Run this script once to migrate an existing database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
"""Create user_preferences table"""
|
||||||
|
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
print("The database will be created automatically when you run the application.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Migrating database: {DB_PATH}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if user_preferences table already exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'")
|
||||||
|
table_exists = cursor.fetchone()
|
||||||
|
|
||||||
|
if table_exists:
|
||||||
|
print("Migration already applied - user_preferences table exists")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Creating user_preferences table...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE user_preferences (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
timezone TEXT DEFAULT 'America/New_York',
|
||||||
|
theme TEXT DEFAULT 'auto',
|
||||||
|
auto_refresh_interval INTEGER DEFAULT 10,
|
||||||
|
date_format TEXT DEFAULT 'MM/DD/YYYY',
|
||||||
|
table_rows_per_page INTEGER DEFAULT 25,
|
||||||
|
calibration_interval_days INTEGER DEFAULT 365,
|
||||||
|
calibration_warning_days INTEGER DEFAULT 30,
|
||||||
|
status_ok_threshold_hours INTEGER DEFAULT 12,
|
||||||
|
status_pending_threshold_hours INTEGER DEFAULT 24,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print(" ✓ Created user_preferences table")
|
||||||
|
|
||||||
|
# Insert default preferences
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO user_preferences (id) VALUES (1)
|
||||||
|
""")
|
||||||
|
print(" ✓ Inserted default preferences")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"\nError during migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_database()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
|
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.seismo.database import Base
|
from backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Emitter(Base):
|
class Emitter(Base):
|
||||||
@@ -39,9 +39,7 @@ class RosterUnit(Base):
|
|||||||
# Seismograph-specific fields (nullable for modems and SLMs)
|
# Seismograph-specific fields (nullable for modems and SLMs)
|
||||||
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
|
||||||
# Modem assignment (shared by seismographs and SLMs)
|
|
||||||
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit (device_type=modem)
|
|
||||||
|
|
||||||
# Modem-specific fields (nullable for seismographs and SLMs)
|
# Modem-specific fields (nullable for seismographs and SLMs)
|
||||||
ip_address = Column(String, nullable=True)
|
ip_address = Column(String, nullable=True)
|
||||||
@@ -51,7 +49,6 @@ class RosterUnit(Base):
|
|||||||
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
||||||
slm_host = Column(String, nullable=True) # Device IP or hostname
|
slm_host = Column(String, nullable=True) # Device IP or hostname
|
||||||
slm_tcp_port = Column(Integer, nullable=True) # TCP control port (default 2255)
|
slm_tcp_port = Column(Integer, nullable=True) # TCP control port (default 2255)
|
||||||
slm_ftp_port = Column(Integer, nullable=True) # FTP data retrieval port (default 21)
|
|
||||||
slm_model = Column(String, nullable=True) # NL-43, NL-53, etc.
|
slm_model = Column(String, nullable=True) # NL-43, NL-53, etc.
|
||||||
slm_serial_number = Column(String, nullable=True) # Device serial number
|
slm_serial_number = Column(String, nullable=True) # Device serial number
|
||||||
slm_frequency_weighting = Column(String, nullable=True) # A, C, Z
|
slm_frequency_weighting = Column(String, nullable=True) # A, C, Z
|
||||||
@@ -4,8 +4,8 @@ from sqlalchemy import desc
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from app.seismo.database import get_db
|
from backend.database import get_db
|
||||||
from app.seismo.models import UnitHistory, Emitter, RosterUnit
|
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["activity"])
|
router = APIRouter(prefix="/api", tags=["activity"])
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.seismo.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app/ui/templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/active")
|
@router.get("/dashboard/active")
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.seismo.database import get_db
|
from backend.database import get_db
|
||||||
from app.seismo.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard-tabs"])
|
router = APIRouter(prefix="/dashboard", tags=["dashboard-tabs"])
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ import shutil
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
from PIL.ExifTags import TAGS, GPSTAGS
|
from PIL.ExifTags import TAGS, GPSTAGS
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.seismo.database import get_db
|
from backend.database import get_db
|
||||||
from app.seismo.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["photos"])
|
router = APIRouter(prefix="/api", tags=["photos"])
|
||||||
|
|
||||||
@@ -4,8 +4,8 @@ from datetime import datetime, timedelta
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from app.seismo.database import get_db
|
from backend.database import get_db
|
||||||
from app.seismo.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["roster"])
|
router = APIRouter(prefix="/api", tags=["roster"])
|
||||||
|
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
|
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import logging
|
|
||||||
import httpx
|
|
||||||
import os
|
|
||||||
|
|
||||||
from app.seismo.database import get_db
|
from backend.database import get_db
|
||||||
from app.seismo.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# SLMM backend URL for syncing device configs to cache
|
|
||||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
|
||||||
|
|
||||||
|
|
||||||
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
||||||
@@ -45,98 +37,13 @@ def get_or_create_roster_unit(db: Session, unit_id: str):
|
|||||||
return unit
|
return unit
|
||||||
|
|
||||||
|
|
||||||
async def sync_slm_to_slmm_cache(
|
|
||||||
unit_id: str,
|
|
||||||
host: str = None,
|
|
||||||
tcp_port: int = None,
|
|
||||||
ftp_port: int = None,
|
|
||||||
ftp_username: str = None,
|
|
||||||
ftp_password: str = None,
|
|
||||||
deployed_with_modem_id: str = None,
|
|
||||||
db: Session = None
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Sync SLM device configuration to SLMM backend cache.
|
|
||||||
|
|
||||||
Terra-View is the source of truth for device configs. This function updates
|
|
||||||
SLMM's config cache (NL43Config table) so SLMM can look up device connection
|
|
||||||
info by unit_id without Terra-View passing host:port with every request.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
unit_id: Unique identifier for the SLM device
|
|
||||||
host: Direct IP address/hostname OR will be resolved from modem
|
|
||||||
tcp_port: TCP control port (default: 2255)
|
|
||||||
ftp_port: FTP port (default: 21)
|
|
||||||
ftp_username: FTP username (optional)
|
|
||||||
ftp_password: FTP password (optional)
|
|
||||||
deployed_with_modem_id: If set, resolve modem IP as host
|
|
||||||
db: Database session for modem lookup
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {"success": bool, "message": str}
|
|
||||||
"""
|
|
||||||
# Resolve host from modem if assigned
|
|
||||||
if deployed_with_modem_id and db:
|
|
||||||
modem = db.query(RosterUnit).filter_by(
|
|
||||||
id=deployed_with_modem_id,
|
|
||||||
device_type="modem"
|
|
||||||
).first()
|
|
||||||
if modem and modem.ip_address:
|
|
||||||
host = modem.ip_address
|
|
||||||
logger.info(f"Resolved host from modem {deployed_with_modem_id}: {host}")
|
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
if not host:
|
|
||||||
logger.warning(f"Cannot sync SLM {unit_id} to SLMM: no host/IP address provided")
|
|
||||||
return {"success": False, "message": "No host IP address available"}
|
|
||||||
|
|
||||||
# Set defaults
|
|
||||||
tcp_port = tcp_port or 2255
|
|
||||||
ftp_port = ftp_port or 21
|
|
||||||
|
|
||||||
# Build SLMM cache payload
|
|
||||||
config_payload = {
|
|
||||||
"host": host,
|
|
||||||
"tcp_port": tcp_port,
|
|
||||||
"tcp_enabled": True,
|
|
||||||
"ftp_enabled": bool(ftp_username and ftp_password),
|
|
||||||
"web_enabled": False
|
|
||||||
}
|
|
||||||
|
|
||||||
if ftp_username and ftp_password:
|
|
||||||
config_payload["ftp_username"] = ftp_username
|
|
||||||
config_payload["ftp_password"] = ftp_password
|
|
||||||
|
|
||||||
# Call SLMM cache update API
|
|
||||||
slmm_url = f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config"
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.put(slmm_url, json=config_payload)
|
|
||||||
|
|
||||||
if response.status_code in [200, 201]:
|
|
||||||
logger.info(f"Successfully synced SLM {unit_id} to SLMM cache")
|
|
||||||
return {"success": True, "message": "Device config cached in SLMM"}
|
|
||||||
else:
|
|
||||||
logger.error(f"SLMM cache sync failed for {unit_id}: HTTP {response.status_code}")
|
|
||||||
return {"success": False, "message": f"SLMM returned status {response.status_code}"}
|
|
||||||
|
|
||||||
except httpx.ConnectError:
|
|
||||||
logger.error(f"Cannot connect to SLMM service at {SLMM_BASE_URL}")
|
|
||||||
return {"success": False, "message": "SLMM service unavailable"}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error syncing SLM {unit_id} to SLMM: {e}")
|
|
||||||
return {"success": False, "message": str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/add")
|
@router.post("/add")
|
||||||
async def add_roster_unit(
|
def add_roster_unit(
|
||||||
id: str = Form(...),
|
id: str = Form(...),
|
||||||
device_type: str = Form("seismograph"),
|
device_type: str = Form("seismograph"),
|
||||||
unit_type: str = Form("series3"),
|
unit_type: str = Form("series3"),
|
||||||
deployed: str = Form(None),
|
deployed: bool = Form(False),
|
||||||
retired: str = Form(None),
|
retired: bool = Form(False),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
location: str = Form(None),
|
||||||
@@ -150,27 +57,8 @@ async def add_roster_unit(
|
|||||||
ip_address: str = Form(None),
|
ip_address: str = Form(None),
|
||||||
phone_number: str = Form(None),
|
phone_number: str = Form(None),
|
||||||
hardware_model: str = Form(None),
|
hardware_model: str = Form(None),
|
||||||
# Sound Level Meter-specific fields
|
|
||||||
slm_host: str = Form(None),
|
|
||||||
slm_tcp_port: str = Form(None),
|
|
||||||
slm_ftp_port: str = Form(None),
|
|
||||||
slm_model: str = Form(None),
|
|
||||||
slm_serial_number: str = Form(None),
|
|
||||||
slm_frequency_weighting: str = Form(None),
|
|
||||||
slm_time_weighting: str = Form(None),
|
|
||||||
slm_measurement_range: str = Form(None),
|
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
logger.info(f"Adding unit: id={id}, device_type={device_type}, deployed={deployed}, retired={retired}")
|
|
||||||
|
|
||||||
# Convert boolean strings to actual booleans
|
|
||||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
|
||||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
|
||||||
|
|
||||||
# Convert port strings to integers
|
|
||||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
|
||||||
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
|
||||||
|
|
||||||
if db.query(RosterUnit).filter(RosterUnit.id == id).first():
|
if db.query(RosterUnit).filter(RosterUnit.id == id).first():
|
||||||
raise HTTPException(status_code=400, detail="Unit already exists")
|
raise HTTPException(status_code=400, detail="Unit already exists")
|
||||||
|
|
||||||
@@ -193,8 +81,8 @@ async def add_roster_unit(
|
|||||||
id=id,
|
id=id,
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
unit_type=unit_type,
|
unit_type=unit_type,
|
||||||
deployed=deployed_bool,
|
deployed=deployed,
|
||||||
retired=retired_bool,
|
retired=retired,
|
||||||
note=note,
|
note=note,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location=location,
|
location=location,
|
||||||
@@ -209,56 +97,12 @@ async def add_roster_unit(
|
|||||||
ip_address=ip_address if ip_address else None,
|
ip_address=ip_address if ip_address else None,
|
||||||
phone_number=phone_number if phone_number else None,
|
phone_number=phone_number if phone_number else None,
|
||||||
hardware_model=hardware_model if hardware_model else None,
|
hardware_model=hardware_model if hardware_model else None,
|
||||||
# Sound Level Meter-specific fields
|
|
||||||
slm_host=slm_host if slm_host else None,
|
|
||||||
slm_tcp_port=slm_tcp_port_int,
|
|
||||||
slm_ftp_port=slm_ftp_port_int,
|
|
||||||
slm_model=slm_model if slm_model else None,
|
|
||||||
slm_serial_number=slm_serial_number if slm_serial_number else None,
|
|
||||||
slm_frequency_weighting=slm_frequency_weighting if slm_frequency_weighting else None,
|
|
||||||
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
|
||||||
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
|
||||||
)
|
)
|
||||||
db.add(unit)
|
db.add(unit)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# If sound level meter, sync config to SLMM cache
|
|
||||||
if device_type == "sound_level_meter":
|
|
||||||
logger.info(f"Syncing SLM {id} config to SLMM cache...")
|
|
||||||
result = await sync_slm_to_slmm_cache(
|
|
||||||
unit_id=id,
|
|
||||||
host=slm_host,
|
|
||||||
tcp_port=slm_tcp_port_int,
|
|
||||||
ftp_port=slm_ftp_port_int,
|
|
||||||
deployed_with_modem_id=deployed_with_modem_id,
|
|
||||||
db=db
|
|
||||||
)
|
|
||||||
|
|
||||||
if not result["success"]:
|
|
||||||
logger.warning(f"SLMM cache sync warning for {id}: {result['message']}")
|
|
||||||
# Don't fail the operation - device is still added to Terra-View roster
|
|
||||||
# User can manually sync later or SLMM will be synced on next config update
|
|
||||||
|
|
||||||
return {"message": "Unit added", "id": id, "device_type": device_type}
|
return {"message": "Unit added", "id": id, "device_type": device_type}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/modems")
|
|
||||||
def get_modems_list(db: Session = Depends(get_db)):
|
|
||||||
"""Get list of all modem units for dropdown selection"""
|
|
||||||
modems = db.query(RosterUnit).filter_by(device_type="modem", retired=False).order_by(RosterUnit.id).all()
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": modem.id,
|
|
||||||
"ip_address": modem.ip_address,
|
|
||||||
"phone_number": modem.phone_number,
|
|
||||||
"hardware_model": modem.hardware_model,
|
|
||||||
"deployed": modem.deployed
|
|
||||||
}
|
|
||||||
for modem in modems
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{unit_id}")
|
@router.get("/{unit_id}")
|
||||||
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""Get a single roster unit by ID"""
|
"""Get a single roster unit by ID"""
|
||||||
@@ -283,14 +127,6 @@ def get_roster_unit(unit_id: str, 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 or "",
|
|
||||||
"slm_ftp_port": unit.slm_ftp_port or "",
|
|
||||||
"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 "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -299,8 +135,8 @@ def edit_roster_unit(
|
|||||||
unit_id: str,
|
unit_id: str,
|
||||||
device_type: str = Form("seismograph"),
|
device_type: str = Form("seismograph"),
|
||||||
unit_type: str = Form("series3"),
|
unit_type: str = Form("series3"),
|
||||||
deployed: str = Form(None),
|
deployed: bool = Form(False),
|
||||||
retired: str = Form(None),
|
retired: bool = Form(False),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
location: str = Form(None),
|
||||||
@@ -314,29 +150,12 @@ def edit_roster_unit(
|
|||||||
ip_address: str = Form(None),
|
ip_address: str = Form(None),
|
||||||
phone_number: str = Form(None),
|
phone_number: str = Form(None),
|
||||||
hardware_model: str = Form(None),
|
hardware_model: str = Form(None),
|
||||||
# Sound Level Meter-specific fields
|
|
||||||
slm_host: str = Form(None),
|
|
||||||
slm_tcp_port: str = Form(None),
|
|
||||||
slm_ftp_port: str = Form(None),
|
|
||||||
slm_model: str = Form(None),
|
|
||||||
slm_serial_number: str = Form(None),
|
|
||||||
slm_frequency_weighting: str = Form(None),
|
|
||||||
slm_time_weighting: str = Form(None),
|
|
||||||
slm_measurement_range: str = Form(None),
|
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
if not unit:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail="Unit not found")
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
# Convert boolean strings to actual booleans
|
|
||||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
|
||||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
|
||||||
|
|
||||||
# Convert port strings to integers
|
|
||||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
|
||||||
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
|
||||||
|
|
||||||
# Parse date fields if provided
|
# Parse date fields if provided
|
||||||
last_cal_date = None
|
last_cal_date = None
|
||||||
if last_calibrated:
|
if last_calibrated:
|
||||||
@@ -360,8 +179,8 @@ def edit_roster_unit(
|
|||||||
# Update all fields
|
# Update all fields
|
||||||
unit.device_type = device_type
|
unit.device_type = device_type
|
||||||
unit.unit_type = unit_type
|
unit.unit_type = unit_type
|
||||||
unit.deployed = deployed_bool
|
unit.deployed = deployed
|
||||||
unit.retired = retired_bool
|
unit.retired = retired
|
||||||
unit.note = note
|
unit.note = note
|
||||||
unit.project_id = project_id
|
unit.project_id = project_id
|
||||||
unit.location = location
|
unit.location = location
|
||||||
@@ -379,16 +198,6 @@ def edit_roster_unit(
|
|||||||
unit.phone_number = phone_number if phone_number else None
|
unit.phone_number = phone_number if phone_number else None
|
||||||
unit.hardware_model = hardware_model if hardware_model else None
|
unit.hardware_model = hardware_model if hardware_model else None
|
||||||
|
|
||||||
# Sound Level Meter-specific fields
|
|
||||||
unit.slm_host = slm_host if slm_host else None
|
|
||||||
unit.slm_tcp_port = slm_tcp_port_int
|
|
||||||
unit.slm_ftp_port = slm_ftp_port_int
|
|
||||||
unit.slm_model = slm_model if slm_model else None
|
|
||||||
unit.slm_serial_number = slm_serial_number if slm_serial_number else None
|
|
||||||
unit.slm_frequency_weighting = slm_frequency_weighting if slm_frequency_weighting else None
|
|
||||||
unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None
|
|
||||||
unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None
|
|
||||||
|
|
||||||
# Record history entries for changed fields
|
# Record history entries for changed fields
|
||||||
if old_note != note:
|
if old_note != note:
|
||||||
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
|
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
|
||||||
@@ -9,9 +9,9 @@ import io
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.seismo.database import get_db
|
from backend.database import get_db
|
||||||
from app.seismo.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
from app.seismo.services.database_backup import DatabaseBackupService
|
from backend.services.database_backup import DatabaseBackupService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
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
@@ -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)}"
|
||||||
|
)
|
||||||
@@ -3,8 +3,8 @@ from sqlalchemy.orm import Session
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from app.seismo.database import get_db
|
from backend.database import get_db
|
||||||
from app.seismo.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["units"])
|
router = APIRouter(prefix="/api", tags=["units"])
|
||||||
|
|
||||||
@@ -4,8 +4,8 @@ from pydantic import BaseModel
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from app.seismo.database import get_db
|
from backend.database import get_db
|
||||||
from app.seismo.models import Emitter
|
from backend.models import Emitter
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.seismo.services.database_backup import DatabaseBackupService
|
from backend.services.database_backup import DatabaseBackupService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.seismo.database import get_db_session
|
from backend.database import get_db_session
|
||||||
from app.seismo.models import Emitter, RosterUnit, IgnoredUnit
|
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
||||||
|
|
||||||
|
|
||||||
def ensure_utc(dt):
|
def ensure_utc(dt):
|
||||||
@@ -60,7 +60,7 @@ def emit_status_snapshot():
|
|||||||
db = get_db_session()
|
db = get_db_session()
|
||||||
try:
|
try:
|
||||||
# Get user preferences for status thresholds
|
# Get user preferences for status thresholds
|
||||||
from app.seismo.models import UserPreferences
|
from backend.models import UserPreferences
|
||||||
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||||
status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12
|
status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12
|
||||||
status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24
|
status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 283 B After Width: | Height: | Size: 283 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 283 B After Width: | Height: | Size: 283 B |
@@ -347,3 +347,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
console.error('Failed to initialize offline database:', error);
|
console.error('Failed to initialize offline database:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Export for use in other scripts
|
||||||
|
export default OfflineDB;
|
||||||
@@ -1,25 +1,18 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
# --- TERRA-VIEW (PRODUCTION) ---
|
# --- PRODUCTION ---
|
||||||
# Unified application: UI + Seismograph logic + SLM dashboard/proxy
|
seismo-backend:
|
||||||
# Only external HTTP dependency: SLMM backend for NL-43 device communication
|
build: .
|
||||||
terra-view-prod:
|
container_name: seismo-fleet-manager
|
||||||
build:
|
ports:
|
||||||
context: .
|
- "8001:8001"
|
||||||
dockerfile: Dockerfile.terraview
|
|
||||||
container_name: terra-view
|
|
||||||
network_mode: host
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
- PORT=8001
|
- SLMM_BASE_URL=http://172.19.0.1:8100
|
||||||
- SLMM_API_URL=http://localhost:8100 # Points to SLMM container
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
|
||||||
- slmm
|
|
||||||
command: python3 -m app.main # Runs full Terra-View (UI + seismo + SLM)
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -27,55 +20,25 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# --- TERRA-VIEW (DEVELOPMENT) ---
|
# --- DEVELOPMENT ---
|
||||||
terra-view-dev:
|
sfm-dev:
|
||||||
build:
|
build: .
|
||||||
context: .
|
container_name: sfm-dev
|
||||||
dockerfile: Dockerfile.terraview
|
ports:
|
||||||
container_name: terra-view-dev
|
- "1001:8001"
|
||||||
network_mode: host
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data-dev:/app/data
|
- ./data-dev:/app/data
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- ENVIRONMENT=development
|
- ENVIRONMENT=development
|
||||||
- PORT=1001
|
|
||||||
- SLMM_API_URL=http://localhost:8100
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
|
||||||
- slmm
|
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
command: python3 -m app.main
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:1001/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# --- SLMM (Sound Level Meter Manager) ---
|
|
||||||
slmm:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.slm
|
|
||||||
container_name: slmm
|
|
||||||
network_mode: host
|
|
||||||
volumes:
|
|
||||||
- ./data-slm:/app/data
|
|
||||||
environment:
|
|
||||||
- PYTHONUNBUFFERED=1
|
|
||||||
- PORT=8100
|
|
||||||
- CORS_ORIGINS=*
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
data-dev:
|
data-dev:
|
||||||
data-slm:
|
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
# Device Type Dashboards
|
|
||||||
|
|
||||||
This document describes the separate dashboard system for different device types in SFM.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
SFM now has dedicated dashboards for each device type:
|
|
||||||
- **Main Dashboard** (`/`) - Combined overview of all devices
|
|
||||||
- **Seismographs Dashboard** (`/seismographs`) - Seismograph-specific view
|
|
||||||
- **Sound Level Meters Dashboard** (`/sound-level-meters`) - SLM-specific view
|
|
||||||
- **Fleet Roster** (`/roster`) - All devices with filtering and management
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### 1. Main Dashboard
|
|
||||||
|
|
||||||
**Route**: `/`
|
|
||||||
**Template**: [templates/dashboard.html](../templates/dashboard.html)
|
|
||||||
**Purpose**: Combined overview showing all device types
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Fleet summary card now includes device type breakdown
|
|
||||||
- Shows count of seismographs and SLMs separately
|
|
||||||
- Links to dedicated dashboards for each device type
|
|
||||||
- Shared components: map, alerts, recent photos, fleet status
|
|
||||||
|
|
||||||
**Device Type Counts**:
|
|
||||||
The dashboard calculates device type counts in JavaScript from the snapshot data:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Device type counts
|
|
||||||
let seismoCount = 0;
|
|
||||||
let slmCount = 0;
|
|
||||||
Object.values(data.units || {}).forEach(unit => {
|
|
||||||
if (unit.retired) return; // Don't count retired units
|
|
||||||
const deviceType = unit.device_type || 'seismograph';
|
|
||||||
if (deviceType === 'seismograph') {
|
|
||||||
seismoCount++;
|
|
||||||
} else if (deviceType === 'sound_level_meter') {
|
|
||||||
slmCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Seismographs Dashboard
|
|
||||||
|
|
||||||
**Route**: `/seismographs`
|
|
||||||
**Template**: [templates/seismographs.html](../templates/seismographs.html)
|
|
||||||
**Router**: [backend/routers/seismo_dashboard.py](../backend/routers/seismo_dashboard.py)
|
|
||||||
|
|
||||||
**API Endpoints**:
|
|
||||||
- `GET /api/seismo-dashboard/stats` - Statistics summary (HTML partial)
|
|
||||||
- `GET /api/seismo-dashboard/units?search=<query>` - Unit list with search (HTML partial)
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Statistics cards (total, deployed, benched, with/without modem)
|
|
||||||
- Searchable unit list with real-time filtering
|
|
||||||
- Shows modem assignments
|
|
||||||
- Links to individual unit detail pages
|
|
||||||
|
|
||||||
**Stats Calculation** ([backend/routers/seismo_dashboard.py:18](../backend/routers/seismo_dashboard.py#L18)):
|
|
||||||
|
|
||||||
```python
|
|
||||||
seismos = db.query(RosterUnit).filter_by(
|
|
||||||
device_type="seismograph",
|
|
||||||
retired=False
|
|
||||||
).all()
|
|
||||||
|
|
||||||
total = len(seismos)
|
|
||||||
deployed = sum(1 for s in seismos if s.deployed)
|
|
||||||
benched = sum(1 for s in seismos if not s.deployed)
|
|
||||||
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Sound Level Meters Dashboard
|
|
||||||
|
|
||||||
**Route**: `/sound-level-meters`
|
|
||||||
**Template**: [templates/sound_level_meters.html](../templates/sound_level_meters.html)
|
|
||||||
**Router**: [backend/routers/slm_dashboard.py](../backend/routers/slm_dashboard.py)
|
|
||||||
|
|
||||||
**API Endpoints**:
|
|
||||||
- `GET /api/slm-dashboard/stats` - Statistics summary (HTML partial)
|
|
||||||
- `GET /api/slm-dashboard/units?search=<query>` - Unit list with search (HTML partial)
|
|
||||||
- `GET /api/slm-dashboard/live-view/{unit_id}` - Live view panel (HTML partial)
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Statistics cards (total, deployed, benched, measuring)
|
|
||||||
- Searchable unit list
|
|
||||||
- Live view panel with real-time measurement data
|
|
||||||
- WebSocket integration for DRD streaming
|
|
||||||
- Shows modem assignments and IP resolution
|
|
||||||
|
|
||||||
See [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) for detailed SLM dashboard documentation.
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
The sidebar navigation ([templates/base.html:116-128](../templates/base.html#L116-L128)) includes links to both dashboards:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<a href="/seismographs">
|
|
||||||
<svg>...</svg>
|
|
||||||
Seismographs
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="/sound-level-meters">
|
|
||||||
<svg>...</svg>
|
|
||||||
Sound Level Meters
|
|
||||||
</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
Active page highlighting is automatic based on `request.url.path`.
|
|
||||||
|
|
||||||
## Database Queries
|
|
||||||
|
|
||||||
All dashboards filter by device type using SQLAlchemy:
|
|
||||||
|
|
||||||
### Seismographs Query
|
|
||||||
```python
|
|
||||||
seismos = db.query(RosterUnit).filter_by(
|
|
||||||
device_type="seismograph",
|
|
||||||
retired=False
|
|
||||||
).all()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sound Level Meters Query
|
|
||||||
```python
|
|
||||||
slms = db.query(RosterUnit).filter_by(
|
|
||||||
device_type="sound_level_meter",
|
|
||||||
retired=False
|
|
||||||
).all()
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Search Filter
|
|
||||||
```python
|
|
||||||
query = db.query(RosterUnit).filter_by(
|
|
||||||
device_type="seismograph",
|
|
||||||
retired=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if search:
|
|
||||||
query = query.filter(
|
|
||||||
(RosterUnit.id.ilike(f"%{search}%")) |
|
|
||||||
(RosterUnit.note.ilike(f"%{search}%")) |
|
|
||||||
(RosterUnit.address.ilike(f"%{search}%"))
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## UI Components
|
|
||||||
|
|
||||||
### Stats Cards (Partials)
|
|
||||||
|
|
||||||
**Seismograph Stats**: [templates/partials/seismo_stats.html](../templates/partials/seismo_stats.html)
|
|
||||||
- Total Seismographs
|
|
||||||
- Deployed
|
|
||||||
- Benched
|
|
||||||
- With Modem (showing X / Y deployed)
|
|
||||||
|
|
||||||
**SLM Stats**: [templates/partials/slm_stats.html](../templates/partials/slm_stats.html)
|
|
||||||
- Total SLMs
|
|
||||||
- Deployed
|
|
||||||
- Benched
|
|
||||||
- Currently Measuring (from live status)
|
|
||||||
|
|
||||||
### Unit Lists (Partials)
|
|
||||||
|
|
||||||
**Seismograph List**: [templates/partials/seismo_unit_list.html](../templates/partials/seismo_unit_list.html)
|
|
||||||
|
|
||||||
Table columns:
|
|
||||||
- Unit ID (link to detail page)
|
|
||||||
- Status (Deployed/Benched badge)
|
|
||||||
- Modem (link to modem unit)
|
|
||||||
- Location (address or coordinates)
|
|
||||||
- Notes
|
|
||||||
- Actions (View Details link)
|
|
||||||
|
|
||||||
**SLM List**: [templates/partials/slm_unit_list.html](../templates/partials/slm_unit_list.html)
|
|
||||||
|
|
||||||
Table columns:
|
|
||||||
- Unit ID (link to detail page)
|
|
||||||
- Model (NL-43, NL-53)
|
|
||||||
- Status (Deployed/Benched badge)
|
|
||||||
- Modem (link to modem unit)
|
|
||||||
- Location
|
|
||||||
- Actions (View Details, Live View)
|
|
||||||
|
|
||||||
## HTMX Integration
|
|
||||||
|
|
||||||
Both dashboards use HTMX for dynamic updates:
|
|
||||||
|
|
||||||
### Auto-refresh Stats
|
|
||||||
```html
|
|
||||||
<div hx-get="/api/seismo-dashboard/stats"
|
|
||||||
hx-trigger="load, every 30s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
```
|
|
||||||
|
|
||||||
### Search with Debouncing
|
|
||||||
```html
|
|
||||||
<input type="text"
|
|
||||||
hx-get="/api/seismo-dashboard/units"
|
|
||||||
hx-trigger="keyup changed delay:300ms"
|
|
||||||
hx-target="#seismo-units-list"
|
|
||||||
name="search" />
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding New Device Types
|
|
||||||
|
|
||||||
To add support for a new device type (e.g., "modem"):
|
|
||||||
|
|
||||||
1. **Create Router** (`backend/routers/modem_dashboard.py`):
|
|
||||||
```python
|
|
||||||
@router.get("/stats", response_class=HTMLResponse)
|
|
||||||
async def get_modem_stats(request: Request, db: Session = Depends(get_db)):
|
|
||||||
modems = db.query(RosterUnit).filter_by(
|
|
||||||
device_type="modem",
|
|
||||||
retired=False
|
|
||||||
).all()
|
|
||||||
# Calculate stats and return partial
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create Templates**:
|
|
||||||
- `templates/modems.html` - Main dashboard page
|
|
||||||
- `templates/partials/modem_stats.html` - Stats cards
|
|
||||||
- `templates/partials/modem_unit_list.html` - Unit list table
|
|
||||||
|
|
||||||
3. **Register in main.py**:
|
|
||||||
```python
|
|
||||||
from backend.routers import modem_dashboard
|
|
||||||
app.include_router(modem_dashboard.router)
|
|
||||||
|
|
||||||
@app.get("/modems", response_class=HTMLResponse)
|
|
||||||
async def modems_page(request: Request):
|
|
||||||
return templates.TemplateResponse("modems.html", {"request": request})
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Add to Navigation** (`templates/base.html`):
|
|
||||||
```html
|
|
||||||
<a href="/modems">
|
|
||||||
<svg>...</svg>
|
|
||||||
Modems
|
|
||||||
</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Update Main Dashboard** (`templates/dashboard.html`):
|
|
||||||
Add modem count to the device type breakdown:
|
|
||||||
```html
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<a href="/modems">Modems</a>
|
|
||||||
<span id="modem-count">--</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Update JavaScript to count modems:
|
|
||||||
```javascript
|
|
||||||
let modemCount = 0;
|
|
||||||
Object.values(data.units || {}).forEach(unit => {
|
|
||||||
if (unit.device_type === 'modem' && !unit.retired) {
|
|
||||||
modemCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.getElementById('modem-count').textContent = modemCount;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **Separation of Concerns**: Each device type has its own dedicated interface
|
|
||||||
2. **Scalability**: Easy to add new device types following the established pattern
|
|
||||||
3. **Performance**: Queries are filtered by device type, reducing data transfer
|
|
||||||
4. **User Experience**: Users can focus on specific device types without clutter
|
|
||||||
5. **Maintainability**: Each dashboard is self-contained and easy to modify
|
|
||||||
|
|
||||||
## Related Documentation
|
|
||||||
|
|
||||||
- [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) - SLM dashboard details
|
|
||||||
- [DEVICE_TYPE_SLM_SUPPORT.md](DEVICE_TYPE_SLM_SUPPORT.md) - Adding SLM device type support
|
|
||||||
- [MODEM_INTEGRATION.md](MODEM_INTEGRATION.md) - Modem assignment architecture
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# Sound Level Meter Device Type Support
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### 1. Frontend - Unit Detail Page
|
|
||||||
|
|
||||||
**File**: `templates/unit_detail.html`
|
|
||||||
|
|
||||||
#### Added Device Type Option
|
|
||||||
- Added "Sound Level Meter" option to device type dropdown (line 243)
|
|
||||||
- Value: `sound_level_meter`
|
|
||||||
|
|
||||||
#### Added SLM-Specific Fields Section (lines 320-370)
|
|
||||||
New form fields for Sound Level Meter configuration:
|
|
||||||
|
|
||||||
- **Host (IP Address)**: Device network address
|
|
||||||
- Field name: `slm_host`
|
|
||||||
- Example: `192.168.1.100`
|
|
||||||
|
|
||||||
- **TCP Port**: Control port (default 2255)
|
|
||||||
- Field name: `slm_tcp_port`
|
|
||||||
- Type: number
|
|
||||||
|
|
||||||
- **Model**: Device model designation
|
|
||||||
- Field name: `slm_model`
|
|
||||||
- Example: `NL-43`, `NL-53`
|
|
||||||
|
|
||||||
- **Serial Number**: Manufacturer serial number
|
|
||||||
- Field name: `slm_serial_number`
|
|
||||||
|
|
||||||
- **Frequency Weighting**: Sound measurement weighting curve
|
|
||||||
- Field name: `slm_frequency_weighting`
|
|
||||||
- Options: A-weighting, C-weighting, Z-weighting (Flat)
|
|
||||||
|
|
||||||
- **Time Weighting**: Temporal averaging method
|
|
||||||
- Field name: `slm_time_weighting`
|
|
||||||
- Options: Fast (125ms), Slow (1s), Impulse (35ms)
|
|
||||||
|
|
||||||
- **Measurement Range**: Device measurement capability
|
|
||||||
- Field name: `slm_measurement_range`
|
|
||||||
- Example: `30-130 dB`
|
|
||||||
|
|
||||||
#### Updated JavaScript Functions
|
|
||||||
|
|
||||||
**toggleDetailFields()** (lines 552-571)
|
|
||||||
- Now handles three device types: seismograph, modem, sound_level_meter
|
|
||||||
- Hides all device-specific sections, then shows only the relevant one
|
|
||||||
- Shows `slmFields` section when device type is `sound_level_meter`
|
|
||||||
|
|
||||||
**populateEditForm()** (lines 527-558)
|
|
||||||
- Populates all 7 SLM-specific fields from unit data
|
|
||||||
- Sets empty string as default if field is null
|
|
||||||
|
|
||||||
### 2. Backend - API Endpoints
|
|
||||||
|
|
||||||
**File**: `backend/routers/roster_edit.py`
|
|
||||||
|
|
||||||
#### Updated Add Unit Endpoint
|
|
||||||
`POST /api/roster/add`
|
|
||||||
|
|
||||||
**New Parameters** (lines 61-67):
|
|
||||||
- `slm_host`: str (optional)
|
|
||||||
- `slm_tcp_port`: int (optional)
|
|
||||||
- `slm_model`: str (optional)
|
|
||||||
- `slm_serial_number`: str (optional)
|
|
||||||
- `slm_frequency_weighting`: str (optional)
|
|
||||||
- `slm_time_weighting`: str (optional)
|
|
||||||
- `slm_measurement_range`: str (optional)
|
|
||||||
|
|
||||||
**Unit Creation** (lines 108-115):
|
|
||||||
All SLM fields are set when creating new unit.
|
|
||||||
|
|
||||||
#### Updated Get Unit Endpoint
|
|
||||||
`GET /api/roster/{unit_id}`
|
|
||||||
|
|
||||||
**New Response Fields** (lines 146-152):
|
|
||||||
Returns all 7 SLM fields in the response, with empty string as default if null.
|
|
||||||
|
|
||||||
#### Updated Edit Unit Endpoint
|
|
||||||
`POST /api/roster/edit/{unit_id}`
|
|
||||||
|
|
||||||
**New Parameters** (lines 177-183):
|
|
||||||
Same 7 SLM-specific parameters as add endpoint.
|
|
||||||
|
|
||||||
**Unit Update** (lines 232-239):
|
|
||||||
All SLM fields are updated when editing existing unit.
|
|
||||||
|
|
||||||
### 3. Database Schema
|
|
||||||
|
|
||||||
**File**: `backend/models.py`
|
|
||||||
|
|
||||||
The database schema already included SLM fields (no changes needed):
|
|
||||||
- All fields are nullable to support multiple device types
|
|
||||||
- Fields are only relevant when `device_type = "sound_level_meter"`
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Creating a New SLM Unit
|
|
||||||
|
|
||||||
1. Go to Fleet Roster page
|
|
||||||
2. Click "Add Unit" or edit an existing unit
|
|
||||||
3. Select "Sound Level Meter" from Device Type dropdown
|
|
||||||
4. Fill in SLM-specific fields (Host, Port, Model, etc.)
|
|
||||||
5. Save
|
|
||||||
|
|
||||||
### Converting Existing Unit to SLM
|
|
||||||
|
|
||||||
1. Open unit detail page
|
|
||||||
2. Click "Edit Unit"
|
|
||||||
3. Change Device Type to "Sound Level Meter"
|
|
||||||
4. SLM fields section will appear
|
|
||||||
5. Fill in required SLM configuration
|
|
||||||
6. Save changes
|
|
||||||
|
|
||||||
### Field Visibility
|
|
||||||
|
|
||||||
The form automatically shows/hides relevant fields based on device type:
|
|
||||||
- **Seismograph**: Shows calibration dates, modem deployment info
|
|
||||||
- **Modem**: Shows IP, phone number, hardware model
|
|
||||||
- **Sound Level Meter**: Shows host, port, model, serial, weightings, range
|
|
||||||
|
|
||||||
## Integration with SLMM Dashboard
|
|
||||||
|
|
||||||
Units with `device_type = "sound_level_meter"` will:
|
|
||||||
- Appear in the Sound Level Meters dashboard (`/sound-level-meters`)
|
|
||||||
- Be available for live monitoring and control
|
|
||||||
- Use the configured `slm_host` and `slm_tcp_port` for device communication
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Test SLM units have been added via `add_test_slms.py`:
|
|
||||||
- `nl43-001` - Deployed at construction site A
|
|
||||||
- `nl43-002` - Deployed at construction site B
|
|
||||||
- `nl53-001` - Deployed at residential area
|
|
||||||
- `nl43-003` - Benched for calibration
|
|
||||||
|
|
||||||
You can edit any of these units to verify the form works correctly.
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
1. `templates/unit_detail.html` - Added dropdown option, SLM fields section, updated JavaScript
|
|
||||||
2. `backend/routers/roster_edit.py` - Added SLM parameters to add/edit/get endpoints
|
|
||||||
3. `backend/models.py` - No changes (schema already supported SLM)
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
- Existing seismograph and modem units are unaffected
|
|
||||||
- All SLM fields are optional/nullable
|
|
||||||
- Forms gracefully handle units with missing device_type (defaults to seismograph)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version**: 1.0.0
|
|
||||||
**Date**: January 5, 2026
|
|
||||||
**Related**: SOUND_LEVEL_METERS_DASHBOARD.md
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# DEV Database Setup Instructions
|
|
||||||
|
|
||||||
## Current Situation
|
|
||||||
|
|
||||||
The test SLM and modem data was accidentally added to the **PRODUCTION** database (`data/seismo_fleet.db`).
|
|
||||||
|
|
||||||
**Good news**: I've already removed it! The production database is clean.
|
|
||||||
|
|
||||||
**Issue**: The DEV database (`data-dev/seismo_fleet.db`) is:
|
|
||||||
1. Owned by root (read-only for your user)
|
|
||||||
2. Missing the SLM-specific columns in its schema
|
|
||||||
|
|
||||||
## What You Need to Do
|
|
||||||
|
|
||||||
### Step 1: Fix DEV Database Permissions
|
|
||||||
|
|
||||||
Run this command to make the DEV database writable:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
|
||||||
sudo chown serversdown:serversdown data-dev/seismo_fleet.db
|
|
||||||
sudo chmod 664 data-dev/seismo_fleet.db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Migrate DEV Database Schema
|
|
||||||
|
|
||||||
Add the SLM columns to the DEV database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/migrate_dev_db.py
|
|
||||||
```
|
|
||||||
|
|
||||||
This will add these columns to the `roster` table:
|
|
||||||
- `slm_host`
|
|
||||||
- `slm_tcp_port`
|
|
||||||
- `slm_model`
|
|
||||||
- `slm_serial_number`
|
|
||||||
- `slm_frequency_weighting`
|
|
||||||
- `slm_time_weighting`
|
|
||||||
- `slm_measurement_range`
|
|
||||||
- `slm_last_check`
|
|
||||||
|
|
||||||
### Step 3: Add Test Data to DEV
|
|
||||||
|
|
||||||
Now you can safely add test data to the DEV database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add test SLMs
|
|
||||||
python3 scripts/add_test_slms.py
|
|
||||||
|
|
||||||
# Add test modems and assign to SLMs
|
|
||||||
python3 scripts/add_test_modems.py
|
|
||||||
```
|
|
||||||
|
|
||||||
This will create:
|
|
||||||
- 4 test SLM units (nl43-001, nl43-002, nl53-001, nl43-003)
|
|
||||||
- 4 test modem units (modem-001, modem-002, modem-003, modem-004)
|
|
||||||
- Assign modems to the SLMs
|
|
||||||
|
|
||||||
## Production Database Status
|
|
||||||
|
|
||||||
✅ **Production database is CLEAN** - all test data has been removed.
|
|
||||||
|
|
||||||
The production database (`data/seismo_fleet.db`) is ready for real production use.
|
|
||||||
|
|
||||||
## Test Scripts
|
|
||||||
|
|
||||||
All test scripts have been updated to use the DEV database:
|
|
||||||
|
|
||||||
### Scripts Updated:
|
|
||||||
1. `scripts/add_test_slms.py` - Now uses `data-dev/seismo_fleet.db`
|
|
||||||
2. `scripts/add_test_modems.py` - Now uses `data-dev/seismo_fleet.db`
|
|
||||||
|
|
||||||
### Scripts Created:
|
|
||||||
1. `scripts/remove_test_data_from_prod.py` - Removes test data from production (already run)
|
|
||||||
2. `scripts/update_dev_db_schema.py` - Updates schema (doesn't work for SQLite ALTER)
|
|
||||||
3. `scripts/migrate_dev_db.py` - Adds SLM columns to DEV database
|
|
||||||
|
|
||||||
All helper scripts are located in the `scripts/` directory. See [scripts/README.md](../scripts/README.md) for detailed usage instructions.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After running the steps above, verify everything worked:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check DEV database has test data
|
|
||||||
sqlite3 data-dev/seismo_fleet.db "SELECT id, device_type FROM roster WHERE device_type IN ('sound_level_meter', 'modem');"
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see:
|
|
||||||
```
|
|
||||||
nl43-001|sound_level_meter
|
|
||||||
nl43-002|sound_level_meter
|
|
||||||
nl53-001|sound_level_meter
|
|
||||||
nl43-003|sound_level_meter
|
|
||||||
modem-001|modem
|
|
||||||
modem-002|modem
|
|
||||||
modem-003|modem
|
|
||||||
modem-004|modem
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development vs Production
|
|
||||||
|
|
||||||
### When to Use DEV Database
|
|
||||||
|
|
||||||
To use the DEV database, set the environment variable:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Not implemented yet, but you could add this to database.py:
|
|
||||||
export DATABASE_ENV=dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Or modify `backend/database.py` to check for an environment variable.
|
|
||||||
|
|
||||||
### Current Setup
|
|
||||||
|
|
||||||
Right now, the application always uses `data/seismo_fleet.db` (production).
|
|
||||||
|
|
||||||
For development/testing, you could:
|
|
||||||
1. Point SFM to use the DEV database temporarily
|
|
||||||
2. Or keep test data in production (not recommended)
|
|
||||||
3. Or implement environment-based database selection
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- ✅ Production DB cleaned (no test data)
|
|
||||||
- ⚠️ DEV DB needs permission fix (run sudo commands above)
|
|
||||||
- ⚠️ DEV DB needs schema migration (run scripts/migrate_dev_db.py)
|
|
||||||
- ✅ Test scripts updated to use DEV DB
|
|
||||||
- ✅ All test data ready to be added to DEV DB
|
|
||||||
|
|
||||||
Run the commands above and you'll be all set!
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Fix DEV Database Permissions
|
|
||||||
|
|
||||||
## The Problem
|
|
||||||
|
|
||||||
SQLite needs write access to both the database file AND the directory it's in (to create temporary files like journals and WAL files).
|
|
||||||
|
|
||||||
Currently:
|
|
||||||
- ✅ Database file: `data-dev/seismo_fleet.db` - permissions fixed
|
|
||||||
- ❌ Directory: `data-dev/` - still owned by root
|
|
||||||
|
|
||||||
## The Fix
|
|
||||||
|
|
||||||
Run this command to fix the directory ownership:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo chown -R serversdown:serversdown /home/serversdown/sfm/seismo-fleet-manager/data-dev/
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the migration again:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/migrate_dev_db.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Full Setup Commands
|
|
||||||
|
|
||||||
Here's the complete sequence:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
|
||||||
|
|
||||||
# Fix directory ownership (includes all files inside)
|
|
||||||
sudo chown -R serversdown:serversdown data-dev/
|
|
||||||
|
|
||||||
# Migrate schema
|
|
||||||
python3 scripts/migrate_dev_db.py
|
|
||||||
|
|
||||||
# Add test data
|
|
||||||
python3 scripts/add_test_slms.py
|
|
||||||
python3 scripts/add_test_modems.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verify It Worked
|
|
||||||
|
|
||||||
After running the migration, you should see:
|
|
||||||
|
|
||||||
```
|
|
||||||
Migrating DEV database to add SLM columns...
|
|
||||||
============================================================
|
|
||||||
✓ Added column: slm_host
|
|
||||||
✓ Added column: slm_tcp_port
|
|
||||||
✓ Added column: slm_model
|
|
||||||
✓ Added column: slm_serial_number
|
|
||||||
✓ Added column: slm_frequency_weighting
|
|
||||||
✓ Added column: slm_time_weighting
|
|
||||||
✓ Added column: slm_measurement_range
|
|
||||||
✓ Added column: slm_last_check
|
|
||||||
============================================================
|
|
||||||
DEV database migration completed!
|
|
||||||
```
|
|
||||||
|
|
||||||
Then the test data scripts should work without errors!
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
# Modem Integration System
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The modem integration system allows Sound Level Meters (SLMs) and Seismographs to be deployed with network connectivity modems. Instead of storing IP addresses directly on each device, units are assigned to modems which provide the network connection. This enables:
|
|
||||||
|
|
||||||
- Centralized modem management and tracking
|
|
||||||
- IP address updates in one place
|
|
||||||
- Future modem API integration for diagnostics
|
|
||||||
- Proper asset tracking for network equipment
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Database Design
|
|
||||||
|
|
||||||
**Modem Units**: Stored as `RosterUnit` with `device_type = "modem"`
|
|
||||||
|
|
||||||
**Modem-Specific Fields**:
|
|
||||||
- `ip_address`: Network IP address or hostname
|
|
||||||
- `phone_number`: Cellular phone number (if applicable)
|
|
||||||
- `hardware_model`: Modem hardware (e.g., "Raven XTV", "Sierra Wireless AirLink")
|
|
||||||
|
|
||||||
**Device Assignment**:
|
|
||||||
- Both SLMs and Seismographs use `deployed_with_modem_id` to reference their modem
|
|
||||||
- This is a foreign key to another `RosterUnit.id` where `device_type = "modem"`
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
1. **Create Modem Units**
|
|
||||||
- Add modems as regular roster units with `device_type = "modem"`
|
|
||||||
- Set IP address, phone number, and hardware model
|
|
||||||
- Deploy or bench modems like any other asset
|
|
||||||
|
|
||||||
2. **Assign Devices to Modems**
|
|
||||||
- When editing an SLM or Seismograph, select modem from dropdown
|
|
||||||
- The `deployed_with_modem_id` field stores the modem ID
|
|
||||||
- IP address is fetched from the assigned modem at runtime
|
|
||||||
|
|
||||||
3. **Runtime Resolution**
|
|
||||||
- When SLM dashboard needs to connect to a device:
|
|
||||||
1. Load SLM unit data
|
|
||||||
2. Check `deployed_with_modem_id`
|
|
||||||
3. Fetch modem unit
|
|
||||||
4. Use modem's `ip_address` for connection
|
|
||||||
5. Fallback to legacy `slm_host` if no modem assigned
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### Frontend Changes
|
|
||||||
|
|
||||||
#### Unit Detail Page (`templates/unit_detail.html`)
|
|
||||||
|
|
||||||
**SLM Fields** (lines 320-375):
|
|
||||||
- Removed direct "Host (IP Address)" field
|
|
||||||
- Added "Deployed With Modem" dropdown selector
|
|
||||||
- Dropdown populated with all active modems via JavaScript
|
|
||||||
- Shows modem ID, IP address, and hardware model in dropdown
|
|
||||||
|
|
||||||
**JavaScript** (lines 456-485):
|
|
||||||
```javascript
|
|
||||||
async function loadModemsList() {
|
|
||||||
// Fetches all modems from /api/roster/modems
|
|
||||||
// Populates both seismograph and SLM modem dropdowns
|
|
||||||
// Shows format: "modem-001 (192.168.1.100) - Raven XTV"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### SLM Dashboard (`backend/routers/slm_dashboard.py`)
|
|
||||||
|
|
||||||
**Live View Endpoint** (lines 84-148):
|
|
||||||
```python
|
|
||||||
# Get modem information if assigned
|
|
||||||
modem = None
|
|
||||||
modem_ip = None
|
|
||||||
if unit.deployed_with_modem_id:
|
|
||||||
modem = db.query(RosterUnit).filter_by(
|
|
||||||
id=unit.deployed_with_modem_id,
|
|
||||||
device_type="modem"
|
|
||||||
).first()
|
|
||||||
if modem:
|
|
||||||
modem_ip = modem.ip_address
|
|
||||||
|
|
||||||
# Fallback to direct slm_host (backward compatibility)
|
|
||||||
if not modem_ip and unit.slm_host:
|
|
||||||
modem_ip = unit.slm_host
|
|
||||||
```
|
|
||||||
|
|
||||||
**Live View Template** (`templates/partials/slm_live_view.html`):
|
|
||||||
- Displays modem information in header
|
|
||||||
- Shows "via Modem: modem-001 (192.168.1.100)"
|
|
||||||
- Warning if no modem assigned
|
|
||||||
|
|
||||||
### Backend Changes
|
|
||||||
|
|
||||||
#### New API Endpoint (`backend/routers/roster_edit.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
GET /api/roster/modems
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns list of all non-retired modem units:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "modem-001",
|
|
||||||
"ip_address": "192.168.1.100",
|
|
||||||
"phone_number": "+1-555-0100",
|
|
||||||
"hardware_model": "Raven XTV",
|
|
||||||
"deployed": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Used by frontend to populate modem selection dropdowns.
|
|
||||||
|
|
||||||
#### Database Schema (`backend/models.py`)
|
|
||||||
|
|
||||||
**Modem Assignment Field** (line 44):
|
|
||||||
```python
|
|
||||||
# Shared by seismographs and SLMs
|
|
||||||
deployed_with_modem_id = Column(String, nullable=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Modem Fields** (lines 46-48):
|
|
||||||
```python
|
|
||||||
ip_address = Column(String, nullable=True)
|
|
||||||
phone_number = Column(String, nullable=True)
|
|
||||||
hardware_model = Column(String, nullable=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Legacy SLM Fields** (kept for backward compatibility):
|
|
||||||
```python
|
|
||||||
slm_host = Column(String, nullable=True) # Deprecated - use modem instead
|
|
||||||
slm_tcp_port = Column(Integer, nullable=True) # Still used
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Creating Modem Units
|
|
||||||
|
|
||||||
1. Go to Fleet Roster
|
|
||||||
2. Click "Add Unit"
|
|
||||||
3. Set Device Type to "Modem"
|
|
||||||
4. Fill in:
|
|
||||||
- Unit ID (e.g., "modem-001")
|
|
||||||
- IP Address (e.g., "192.168.1.100")
|
|
||||||
- Phone Number (if cellular)
|
|
||||||
- Hardware Model (e.g., "Raven XTV")
|
|
||||||
- Address/Coordinates (physical location)
|
|
||||||
5. Set Deployed status
|
|
||||||
6. Save
|
|
||||||
|
|
||||||
### Assigning Modem to SLM
|
|
||||||
|
|
||||||
1. Open SLM unit detail page
|
|
||||||
2. Click "Edit Unit"
|
|
||||||
3. Ensure Device Type is "Sound Level Meter"
|
|
||||||
4. In "Deployed With Modem" dropdown, select modem
|
|
||||||
5. Verify TCP Port (default 2255)
|
|
||||||
6. Save
|
|
||||||
|
|
||||||
### Assigning Modem to Seismograph
|
|
||||||
|
|
||||||
Same process - both device types use the same modem selection field.
|
|
||||||
|
|
||||||
## Test Data
|
|
||||||
|
|
||||||
Use the included script to create test modems:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 add_test_modems.py
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates:
|
|
||||||
- **modem-001**: 192.168.1.100, Raven XTV → assigned to nl43-001
|
|
||||||
- **modem-002**: 192.168.1.101, Raven XTV → assigned to nl43-002
|
|
||||||
- **modem-003**: 192.168.1.102, Sierra Wireless → assigned to nl53-001
|
|
||||||
- **modem-004**: Spare modem (not deployed)
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
### For Operations
|
|
||||||
|
|
||||||
1. **Centralized IP Management**
|
|
||||||
- Update modem IP once, affects all assigned devices
|
|
||||||
- Easy to track which modem serves which devices
|
|
||||||
- Inventory management for network equipment
|
|
||||||
|
|
||||||
2. **Asset Tracking**
|
|
||||||
- Modems are first-class assets in the roster
|
|
||||||
- Track deployment status, location, notes
|
|
||||||
- Can bench/retire modems independently
|
|
||||||
|
|
||||||
3. **Future Capabilities**
|
|
||||||
- Modem API integration (signal strength, data usage)
|
|
||||||
- Automatic IP updates from DHCP/cellular network
|
|
||||||
- Modem health monitoring
|
|
||||||
- Remote modem diagnostics
|
|
||||||
|
|
||||||
### For Maintenance
|
|
||||||
|
|
||||||
1. **Easier Troubleshooting**
|
|
||||||
- See which modem serves a device
|
|
||||||
- Check modem status separately
|
|
||||||
- Swap modems without reconfiguring devices
|
|
||||||
|
|
||||||
2. **Configuration Changes**
|
|
||||||
- Change IP addresses system-wide
|
|
||||||
- Move devices between modems
|
|
||||||
- Test with backup modems
|
|
||||||
|
|
||||||
## Migration from Legacy System
|
|
||||||
|
|
||||||
### For Existing SLMs with Direct IP
|
|
||||||
|
|
||||||
Legacy SLMs with `slm_host` set still work:
|
|
||||||
- System checks `deployed_with_modem_id` first
|
|
||||||
- Falls back to `slm_host` if no modem assigned
|
|
||||||
- Logs fallback usage for visibility
|
|
||||||
|
|
||||||
### Migration Steps
|
|
||||||
|
|
||||||
1. Create modem units for each IP address
|
|
||||||
2. Assign SLMs to their modems
|
|
||||||
3. System will use modem IP automatically
|
|
||||||
4. Legacy `slm_host` can be cleared (optional)
|
|
||||||
|
|
||||||
Script `add_test_modems.py` demonstrates this:
|
|
||||||
```python
|
|
||||||
# Clear legacy field after modem assignment
|
|
||||||
slm.slm_host = None
|
|
||||||
slm.deployed_with_modem_id = "modem-001"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
### Near-term
|
|
||||||
|
|
||||||
1. **Modem Status Dashboard**
|
|
||||||
- List all modems with connection status
|
|
||||||
- Show which devices use each modem
|
|
||||||
- Signal strength, data usage indicators
|
|
||||||
|
|
||||||
2. **Automatic IP Discovery**
|
|
||||||
- Query cellular provider API for modem IPs
|
|
||||||
- Auto-update IP addresses in database
|
|
||||||
- Alert on IP changes
|
|
||||||
|
|
||||||
3. **Modem Health Monitoring**
|
|
||||||
- Ping modems periodically
|
|
||||||
- Check cellular signal quality
|
|
||||||
- Data usage tracking
|
|
||||||
|
|
||||||
### Long-term
|
|
||||||
|
|
||||||
1. **Modem API Integration**
|
|
||||||
- Direct modem management (Raven, Sierra APIs)
|
|
||||||
- Remote reboot capability
|
|
||||||
- Configuration backup/restore
|
|
||||||
- Firmware updates
|
|
||||||
|
|
||||||
2. **Network Topology View**
|
|
||||||
- Graphical view of modem-device relationships
|
|
||||||
- Network health visualization
|
|
||||||
- Troubleshooting tools
|
|
||||||
|
|
||||||
3. **Multi-Modem Support**
|
|
||||||
- Failover between modems
|
|
||||||
- Load balancing
|
|
||||||
- Automatic fallback on modem failure
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### Get Modems List
|
|
||||||
|
|
||||||
**Endpoint**: `GET /api/roster/modems`
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "modem-001",
|
|
||||||
"ip_address": "192.168.1.100",
|
|
||||||
"phone_number": "+1-555-0100",
|
|
||||||
"hardware_model": "Raven XTV",
|
|
||||||
"deployed": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Used By**:
|
|
||||||
- Unit detail page (modem dropdown)
|
|
||||||
- Future modem management dashboard
|
|
||||||
|
|
||||||
### Get Unit with Modem Info
|
|
||||||
|
|
||||||
**Endpoint**: `GET /api/roster/{unit_id}`
|
|
||||||
|
|
||||||
**Response** (for SLM):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "nl43-001",
|
|
||||||
"device_type": "sound_level_meter",
|
|
||||||
"deployed_with_modem_id": "modem-001",
|
|
||||||
"slm_tcp_port": 2255,
|
|
||||||
"slm_model": "NL-43",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then fetch modem separately or use dashboard endpoint which resolves it automatically.
|
|
||||||
|
|
||||||
### SLM Live View (with modem resolution)
|
|
||||||
|
|
||||||
**Endpoint**: `GET /api/slm-dashboard/live-view/{unit_id}`
|
|
||||||
|
|
||||||
**Process**:
|
|
||||||
1. Loads SLM unit
|
|
||||||
2. Resolves `deployed_with_modem_id` to modem unit
|
|
||||||
3. Extracts `modem.ip_address`
|
|
||||||
4. Uses IP to connect to SLMM backend
|
|
||||||
5. Returns live view HTML with modem info
|
|
||||||
|
|
||||||
## Files Modified/Created
|
|
||||||
|
|
||||||
### Modified
|
|
||||||
1. `backend/models.py` - Clarified modem assignment field
|
|
||||||
2. `templates/unit_detail.html` - Added modem selector for SLMs
|
|
||||||
3. `backend/routers/roster_edit.py` - Added modems list endpoint
|
|
||||||
4. `backend/routers/slm_dashboard.py` - Modem resolution logic
|
|
||||||
5. `templates/partials/slm_live_view.html` - Display modem info
|
|
||||||
|
|
||||||
### Created
|
|
||||||
1. `add_test_modems.py` - Script to create test modems and assignments
|
|
||||||
2. `docs/MODEM_INTEGRATION.md` - This documentation
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### SLM shows "No modem assigned"
|
|
||||||
|
|
||||||
**Cause**: Unit has no `deployed_with_modem_id` set
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
1. Edit the SLM unit
|
|
||||||
2. Select a modem from dropdown
|
|
||||||
3. Save
|
|
||||||
|
|
||||||
### Modem dropdown is empty
|
|
||||||
|
|
||||||
**Cause**: No modem units in database
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
1. Create modem units first
|
|
||||||
2. Set `device_type = "modem"`
|
|
||||||
3. Ensure they're not retired
|
|
||||||
|
|
||||||
### Can't connect to SLM
|
|
||||||
|
|
||||||
**Possible Causes**:
|
|
||||||
1. Modem IP is incorrect
|
|
||||||
2. Modem is offline
|
|
||||||
3. SLM TCP port is wrong
|
|
||||||
4. Network routing issue
|
|
||||||
|
|
||||||
**Debug Steps**:
|
|
||||||
1. Check modem unit's IP address
|
|
||||||
2. Ping the modem IP
|
|
||||||
3. Check SLM's `slm_tcp_port` (default 2255)
|
|
||||||
4. Review logs for connection errors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version**: 1.0.0
|
|
||||||
**Date**: January 5, 2026
|
|
||||||
**Related**: SOUND_LEVEL_METERS_DASHBOARD.md, DEVICE_TYPE_SLM_SUPPORT.md
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
# SLM Configuration Interface
|
|
||||||
|
|
||||||
This document describes the SLM configuration interface added to the Sound Level Meters dashboard.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Sound Level Meters can now be configured directly from the dashboard without needing to navigate to the unit detail page. A configuration button appears on each SLM unit card on hover, opening a modal with all configurable parameters.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### 1. Quick Access Configuration Button
|
|
||||||
|
|
||||||
- **Location**: Appears on each SLM unit card in the unit list
|
|
||||||
- **Behavior**: Shows on hover (desktop) or always visible (mobile)
|
|
||||||
- **Icon**: Gear/settings icon in top-right corner of unit card
|
|
||||||
|
|
||||||
### 2. Configuration Modal
|
|
||||||
|
|
||||||
The configuration modal provides a comprehensive interface for all SLM parameters:
|
|
||||||
|
|
||||||
#### Device Information
|
|
||||||
- **Model**: Dropdown selection (NL-43, NL-53)
|
|
||||||
- **Serial Number**: Text input for device serial number
|
|
||||||
|
|
||||||
#### Measurement Parameters
|
|
||||||
- **Frequency Weighting**: A, C, or Z (Linear)
|
|
||||||
- **Time Weighting**: Fast (125ms), Slow (1s), or Impulse
|
|
||||||
- **Measurement Range**: 30-130 dB, 40-140 dB, or 50-140 dB
|
|
||||||
|
|
||||||
#### Network Configuration
|
|
||||||
- **Assigned Modem**: Dropdown list of available modems
|
|
||||||
- Shows modem ID and IP address
|
|
||||||
- Option for "No modem (direct connection)"
|
|
||||||
- **Direct IP Address**: Only shown when no modem assigned
|
|
||||||
- **TCP Port**: Only shown when no modem assigned (default: 502)
|
|
||||||
|
|
||||||
### 3. Actions
|
|
||||||
|
|
||||||
The modal provides three action buttons:
|
|
||||||
|
|
||||||
- **Test Connection**: Tests network connectivity to the SLM
|
|
||||||
- Uses current form values (not saved values)
|
|
||||||
- Shows toast notification with results
|
|
||||||
- Green: Connection successful
|
|
||||||
- Yellow: Connection failed or device offline
|
|
||||||
- Red: Test error
|
|
||||||
|
|
||||||
- **Cancel**: Closes modal without saving changes
|
|
||||||
|
|
||||||
- **Save Configuration**: Saves all changes to database
|
|
||||||
- Shows success/error toast
|
|
||||||
- Refreshes unit list on success
|
|
||||||
- Auto-closes modal after 2 seconds
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
### Frontend Components
|
|
||||||
|
|
||||||
#### Unit List Partial
|
|
||||||
**File**: [templates/partials/slm_unit_list.html](../templates/partials/slm_unit_list.html)
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Configure button (appears on hover) -->
|
|
||||||
<button onclick="event.stopPropagation(); openConfigModal('{{ unit.id }}');"
|
|
||||||
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100...">
|
|
||||||
<svg>...</svg>
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Configuration Modal
|
|
||||||
**File**: [templates/sound_level_meters.html](../templates/sound_level_meters.html#L73)
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Configuration Modal -->
|
|
||||||
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50...">
|
|
||||||
<div id="config-modal-content">
|
|
||||||
<!-- Content loaded via HTMX -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Configuration Form
|
|
||||||
**File**: [templates/partials/slm_config_form.html](../templates/partials/slm_config_form.html)
|
|
||||||
|
|
||||||
Form fields mapped to database columns:
|
|
||||||
- `slm_model` → `unit.slm_model`
|
|
||||||
- `slm_serial_number` → `unit.slm_serial_number`
|
|
||||||
- `slm_frequency_weighting` → `unit.slm_frequency_weighting`
|
|
||||||
- `slm_time_weighting` → `unit.slm_time_weighting`
|
|
||||||
- `slm_measurement_range` → `unit.slm_measurement_range`
|
|
||||||
- `deployed_with_modem_id` → `unit.deployed_with_modem_id`
|
|
||||||
- `slm_host` → `unit.slm_host` (legacy, only if no modem)
|
|
||||||
- `slm_tcp_port` → `unit.slm_tcp_port` (legacy, only if no modem)
|
|
||||||
|
|
||||||
### Backend Endpoints
|
|
||||||
|
|
||||||
#### GET /api/slm-dashboard/config/{unit_id}
|
|
||||||
**File**: [backend/routers/slm_dashboard.py:184](../backend/routers/slm_dashboard.py#L184)
|
|
||||||
|
|
||||||
Returns configuration form HTML partial with current unit values pre-populated.
|
|
||||||
|
|
||||||
**Response**: HTML partial (slm_config_form.html)
|
|
||||||
|
|
||||||
#### POST /api/slm-dashboard/config/{unit_id}
|
|
||||||
**File**: [backend/routers/slm_dashboard.py:203](../backend/routers/slm_dashboard.py#L203)
|
|
||||||
|
|
||||||
Saves configuration changes to database.
|
|
||||||
|
|
||||||
**Request**: Form data with configuration parameters
|
|
||||||
|
|
||||||
**Response**: JSON
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"unit_id": "nl43-001"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior**:
|
|
||||||
- Updates all SLM-specific fields from form data
|
|
||||||
- If modem is assigned: clears legacy `slm_host` and `slm_tcp_port`
|
|
||||||
- If no modem: uses direct IP fields from form
|
|
||||||
|
|
||||||
### JavaScript Functions
|
|
||||||
|
|
||||||
#### openConfigModal(unitId)
|
|
||||||
**File**: [templates/sound_level_meters.html:127](../templates/sound_level_meters.html#L127)
|
|
||||||
|
|
||||||
Opens configuration modal and loads form via HTMX.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function openConfigModal(unitId) {
|
|
||||||
const modal = document.getElementById('config-modal');
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
|
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
|
||||||
target: '#config-modal-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### closeConfigModal()
|
|
||||||
**File**: [templates/sound_level_meters.html:136](../templates/sound_level_meters.html#L136)
|
|
||||||
|
|
||||||
Closes configuration modal.
|
|
||||||
|
|
||||||
#### handleConfigSave(event)
|
|
||||||
**File**: [templates/partials/slm_config_form.html:109](../templates/partials/slm_config_form.html#L109)
|
|
||||||
|
|
||||||
Handles HTMX response after form submission:
|
|
||||||
- Shows success/error toast
|
|
||||||
- Refreshes unit list
|
|
||||||
- Auto-closes modal after 2 seconds
|
|
||||||
|
|
||||||
#### testConnection(unitId)
|
|
||||||
**File**: [templates/partials/slm_config_form.html:129](../templates/partials/slm_config_form.html#L129)
|
|
||||||
|
|
||||||
Tests connection to SLM unit:
|
|
||||||
```javascript
|
|
||||||
async function testConnection(unitId) {
|
|
||||||
const response = await fetch(`/api/slmm/${unitId}/status`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data.status === 'online') {
|
|
||||||
// Show success toast
|
|
||||||
} else {
|
|
||||||
// Show warning toast
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### loadModemsForConfig()
|
|
||||||
**File**: [templates/partials/slm_config_form.html:87](../templates/partials/slm_config_form.html#L87)
|
|
||||||
|
|
||||||
Loads available modems from `/api/roster/modems` and populates dropdown.
|
|
||||||
|
|
||||||
## User Workflow
|
|
||||||
|
|
||||||
### Configuring an SLM
|
|
||||||
|
|
||||||
1. Navigate to Sound Level Meters dashboard ([/sound-level-meters](../sound-level-meters))
|
|
||||||
2. Hover over desired SLM unit card in the list
|
|
||||||
3. Click the gear icon that appears in top-right corner
|
|
||||||
4. Configuration modal opens with current values pre-filled
|
|
||||||
5. Modify desired parameters:
|
|
||||||
- Update model/serial if needed
|
|
||||||
- Set measurement parameters (frequency/time weighting, range)
|
|
||||||
- Choose network configuration:
|
|
||||||
- **Option A**: Select a modem from dropdown (recommended)
|
|
||||||
- **Option B**: Enter direct IP address and port
|
|
||||||
6. (Optional) Click "Test Connection" to verify network settings
|
|
||||||
7. Click "Save Configuration"
|
|
||||||
8. Modal shows success message and auto-closes
|
|
||||||
9. Unit list refreshes to show updated information
|
|
||||||
|
|
||||||
### Network Configuration Options
|
|
||||||
|
|
||||||
**Modem Assignment (Recommended)**:
|
|
||||||
- Select modem from dropdown
|
|
||||||
- IP address automatically resolved from modem's `ip_address` field
|
|
||||||
- Direct IP/port fields hidden
|
|
||||||
- Enables modem tracking and management
|
|
||||||
|
|
||||||
**Direct Connection (Legacy)**:
|
|
||||||
- Select "No modem (direct connection)"
|
|
||||||
- Enter IP address and TCP port manually
|
|
||||||
- Direct IP/port fields become visible
|
|
||||||
- Useful for temporary setups or non-modem connections
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
The configuration interface updates these `roster` table columns:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- SLM-specific fields
|
|
||||||
slm_model VARCHAR -- Device model (NL-43, NL-53)
|
|
||||||
slm_serial_number VARCHAR -- Serial number
|
|
||||||
slm_frequency_weighting VARCHAR -- A, C, or Z weighting
|
|
||||||
slm_time_weighting VARCHAR -- Fast, Slow, or Impulse
|
|
||||||
slm_measurement_range VARCHAR -- Measurement range (30-130, 40-140, 50-140)
|
|
||||||
|
|
||||||
-- Network configuration
|
|
||||||
deployed_with_modem_id VARCHAR -- FK to modem unit (preferred method)
|
|
||||||
slm_host VARCHAR -- Legacy direct IP (only if no modem)
|
|
||||||
slm_tcp_port INTEGER -- Legacy TCP port (only if no modem)
|
|
||||||
```
|
|
||||||
|
|
||||||
## UI/UX Design
|
|
||||||
|
|
||||||
### Modal Behavior
|
|
||||||
- **Opens**: Via configure button on unit card
|
|
||||||
- **Closes**:
|
|
||||||
- Cancel button
|
|
||||||
- X button in header
|
|
||||||
- Escape key
|
|
||||||
- Clicking outside modal (on backdrop)
|
|
||||||
- **Auto-close**: After successful save (2 second delay)
|
|
||||||
|
|
||||||
### Responsive Design
|
|
||||||
- **Desktop**: Configuration button appears on hover
|
|
||||||
- **Mobile**: Configuration button always visible
|
|
||||||
- **Modal**: Responsive width, scrollable on small screens
|
|
||||||
- **Form**: Two-column layout on desktop, single column on mobile
|
|
||||||
|
|
||||||
### Visual Feedback
|
|
||||||
- **Loading**: Skeleton loader while form loads
|
|
||||||
- **Saving**: HTMX handles form submission
|
|
||||||
- **Success**: Green toast notification
|
|
||||||
- **Error**: Red toast notification
|
|
||||||
- **Testing**: Blue toast while testing, then green/yellow/red based on result
|
|
||||||
|
|
||||||
### Accessibility
|
|
||||||
- **Keyboard**: Modal can be closed with Escape key
|
|
||||||
- **Focus**: Modal traps focus when open
|
|
||||||
- **Labels**: All form fields have proper labels
|
|
||||||
- **Colors**: Sufficient contrast in dark/light modes
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
Potential improvements for future versions:
|
|
||||||
|
|
||||||
1. **Bulk Configuration**: Configure multiple SLMs at once
|
|
||||||
2. **Configuration Templates**: Save and apply configuration presets
|
|
||||||
3. **Configuration History**: Track configuration changes over time
|
|
||||||
4. **Remote Configuration**: Push configuration directly to device via SLMM
|
|
||||||
5. **Validation**: Real-time validation of IP addresses and ports
|
|
||||||
6. **Advanced Settings**: Additional NL-43/NL-53 specific parameters
|
|
||||||
7. **Configuration Import/Export**: JSON/CSV configuration files
|
|
||||||
|
|
||||||
## Related Documentation
|
|
||||||
|
|
||||||
- [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) - Main SLM dashboard
|
|
||||||
- [MODEM_INTEGRATION.md](MODEM_INTEGRATION.md) - Modem assignment architecture
|
|
||||||
- [DEVICE_TYPE_SLM_SUPPORT.md](DEVICE_TYPE_SLM_SUPPORT.md) - SLM device type implementation
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
# Sound Level Meters Dashboard
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Sound Level Meters dashboard is a new feature in SFM (soon to be rebranded as Terra-view) that provides real-time monitoring and control of Rion NL-43/NL-53 sound level meters through the SLMM backend integration.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### 1. Dashboard Summary Statistics
|
|
||||||
- **Total Units**: Count of all SLM devices in the system
|
|
||||||
- **Deployed Units**: Active devices currently in the field
|
|
||||||
- **Active Now**: Units that have checked in within the last hour
|
|
||||||
- **Benched Units**: Devices not currently deployed
|
|
||||||
|
|
||||||
### 2. Unit List (Sidebar)
|
|
||||||
- Searchable list of all deployed SLM units
|
|
||||||
- Real-time status indicators:
|
|
||||||
- 🟢 Green: Active (recently checked in)
|
|
||||||
- ⚪ Gray: No check-in data
|
|
||||||
- Quick unit information:
|
|
||||||
- Device model (NL-43, NL-53, etc.)
|
|
||||||
- Location/address
|
|
||||||
- Network address (IP:port)
|
|
||||||
- Click any unit to view its live data
|
|
||||||
|
|
||||||
### 3. Live View Panel
|
|
||||||
|
|
||||||
When a unit is selected, the live view panel displays:
|
|
||||||
|
|
||||||
#### Control Buttons
|
|
||||||
- **Start**: Begin measurement
|
|
||||||
- **Pause**: Pause current measurement
|
|
||||||
- **Stop**: Stop measurement
|
|
||||||
- **Reset**: Reset measurement data
|
|
||||||
- **Start Live Stream**: Open WebSocket connection for real-time DRD data
|
|
||||||
|
|
||||||
#### Real-time Metrics
|
|
||||||
- **Lp (Current)**: Instantaneous sound level in dB
|
|
||||||
- **Leq (Average)**: Equivalent continuous sound level
|
|
||||||
- **Lmax (Peak)**: Maximum sound level recorded
|
|
||||||
- **Lmin**: Minimum sound level recorded
|
|
||||||
|
|
||||||
#### Live Chart
|
|
||||||
- Real-time line chart showing Lp and Leq over time
|
|
||||||
- 60-second rolling window (adjustable)
|
|
||||||
- Chart.js-powered visualization with dark mode support
|
|
||||||
- No animation for smooth real-time updates
|
|
||||||
|
|
||||||
#### Device Information
|
|
||||||
- Battery level and power source
|
|
||||||
- Frequency weighting (A, C, Z)
|
|
||||||
- Time weighting (F, S, I)
|
|
||||||
- SD card remaining space
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Frontend Components
|
|
||||||
|
|
||||||
#### Main Template
|
|
||||||
**File**: `templates/sound_level_meters.html`
|
|
||||||
|
|
||||||
The main dashboard page that includes:
|
|
||||||
- Page header and navigation integration
|
|
||||||
- Stats summary section (auto-refreshes every 10s)
|
|
||||||
- Two-column layout: unit list (left) + live view (right)
|
|
||||||
- JavaScript functions for unit selection and WebSocket streaming
|
|
||||||
|
|
||||||
#### Partial Templates
|
|
||||||
|
|
||||||
1. **slm_stats.html** - Summary statistics cards
|
|
||||||
- Auto-loads on page load
|
|
||||||
- Refreshes every 10 seconds via HTMX
|
|
||||||
|
|
||||||
2. **slm_unit_list.html** - Searchable unit list
|
|
||||||
- Auto-loads on page load
|
|
||||||
- Refreshes every 10 seconds via HTMX
|
|
||||||
- Supports search filtering
|
|
||||||
|
|
||||||
3. **slm_live_view.html** - Live data panel for selected unit
|
|
||||||
- Loaded on-demand when unit is selected
|
|
||||||
- Includes Chart.js for visualization
|
|
||||||
- WebSocket connection for streaming data
|
|
||||||
|
|
||||||
4. **slm_live_view_error.html** - Error state display
|
|
||||||
|
|
||||||
### Backend Components
|
|
||||||
|
|
||||||
#### Router: `backend/routers/slm_dashboard.py`
|
|
||||||
|
|
||||||
**Endpoints:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
GET /api/slm-dashboard/stats
|
|
||||||
```
|
|
||||||
Returns HTML partial with summary statistics.
|
|
||||||
|
|
||||||
```python
|
|
||||||
GET /api/slm-dashboard/units?search={term}
|
|
||||||
```
|
|
||||||
Returns HTML partial with filtered unit list.
|
|
||||||
|
|
||||||
```python
|
|
||||||
GET /api/slm-dashboard/live-view/{unit_id}
|
|
||||||
```
|
|
||||||
Returns HTML partial with live view panel for specific unit.
|
|
||||||
- Fetches unit details from database
|
|
||||||
- Queries SLMM API for current measurement state
|
|
||||||
- Queries SLMM API for live status (DOD data)
|
|
||||||
|
|
||||||
```python
|
|
||||||
POST /api/slm-dashboard/control/{unit_id}/{action}
|
|
||||||
```
|
|
||||||
Sends control commands to SLMM backend.
|
|
||||||
- Valid actions: start, stop, pause, resume, reset
|
|
||||||
- Proxies to `http://localhost:8100/api/nl43/{unit_id}/{action}`
|
|
||||||
|
|
||||||
### Integration with SLMM
|
|
||||||
|
|
||||||
The dashboard communicates with the SLMM backend service running on port 8100:
|
|
||||||
|
|
||||||
**REST API Calls:**
|
|
||||||
- `GET /api/nl43/{unit_id}/measurement-state` - Check if measuring
|
|
||||||
- `GET /api/nl43/{unit_id}/live` - Get current DOD data
|
|
||||||
- `POST /api/nl43/{unit_id}/start|stop|pause|resume|reset` - Control commands
|
|
||||||
|
|
||||||
**WebSocket Streaming:**
|
|
||||||
- `WS /api/nl43/{unit_id}/live` - Real-time DRD data stream
|
|
||||||
- Proxied through SFM at `/api/slmm/{unit_id}/live`
|
|
||||||
- Streams continuous measurement data for live charting
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
**Table**: `roster`
|
|
||||||
|
|
||||||
SLM-specific fields in the RosterUnit model:
|
|
||||||
|
|
||||||
```python
|
|
||||||
device_type = "sound_level_meter" # Distinguishes SLMs from seismographs
|
|
||||||
slm_host = String # Device IP or hostname
|
|
||||||
slm_tcp_port = Integer # TCP control port (default 2255)
|
|
||||||
slm_model = String # NL-43, NL-53, etc.
|
|
||||||
slm_serial_number = String # Device serial number
|
|
||||||
slm_frequency_weighting = String # A, C, or Z weighting
|
|
||||||
slm_time_weighting = String # F (Fast), S (Slow), I (Impulse)
|
|
||||||
slm_measurement_range = String # e.g., "30-130 dB"
|
|
||||||
slm_last_check = DateTime # Last communication timestamp
|
|
||||||
```
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
The Sound Level Meters page is accessible from:
|
|
||||||
- **URL**: `/sound-level-meters`
|
|
||||||
- **Sidebar**: "Sound Level Meters" menu item (between Fleet Roster and Projects)
|
|
||||||
- **Icon**: Speaker/sound wave SVG icon
|
|
||||||
|
|
||||||
## Real-time Updates
|
|
||||||
|
|
||||||
The dashboard uses three mechanisms for real-time updates:
|
|
||||||
|
|
||||||
1. **HTMX Polling** (10-second intervals)
|
|
||||||
- Summary statistics
|
|
||||||
- Unit list
|
|
||||||
- Ensures data freshness even without user interaction
|
|
||||||
|
|
||||||
2. **On-Demand Loading** (HTMX)
|
|
||||||
- Live view panel loads when unit is selected
|
|
||||||
- Control button responses
|
|
||||||
|
|
||||||
3. **WebSocket Streaming** (continuous)
|
|
||||||
- Real-time DRD data for live charting
|
|
||||||
- User-initiated via "Start Live Stream" button
|
|
||||||
- Automatically closed on page unload or unit change
|
|
||||||
|
|
||||||
## Measurement Duration Tracking
|
|
||||||
|
|
||||||
**Important**: The NL-43/NL-53 devices do not expose measurement duration via their API. Elapsed time and interval counts are only visible on the device's on-screen display (OSD).
|
|
||||||
|
|
||||||
**Solution**: Track measurement start time in your application when calling the `/start` endpoint:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// When starting measurement
|
|
||||||
const startTime = new Date();
|
|
||||||
localStorage.setItem(`slm_${unitId}_start`, startTime.toISOString());
|
|
||||||
|
|
||||||
// Calculate elapsed time
|
|
||||||
const startTime = new Date(localStorage.getItem(`slm_${unitId}_start`));
|
|
||||||
const elapsed = (new Date() - startTime) / 1000; // seconds
|
|
||||||
```
|
|
||||||
|
|
||||||
**Future Enhancement**: SLMM backend could store measurement start times in a database table to track duration across sessions.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Add Test Data
|
|
||||||
|
|
||||||
Use the included script to add test SLM units:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 add_test_slms.py
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates:
|
|
||||||
- 3 deployed test units (nl43-001, nl43-002, nl53-001)
|
|
||||||
- 1 benched unit (nl43-003)
|
|
||||||
|
|
||||||
### Running the Dashboard
|
|
||||||
|
|
||||||
1. Start SLMM backend (port 8100):
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/slmm
|
|
||||||
uvicorn main:app --host 0.0.0.0 --port 8100
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start SFM (port 8000):
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
|
||||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Access dashboard:
|
|
||||||
```
|
|
||||||
http://localhost:8000/sound-level-meters
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Without Physical Devices
|
|
||||||
|
|
||||||
The dashboard will work without physical NL-43 devices connected:
|
|
||||||
- Unit list will display based on database records
|
|
||||||
- Live view will show connection errors (gracefully handled)
|
|
||||||
- Mock data can be added to SLMM for testing
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
### Near-term
|
|
||||||
1. **Measurement Duration Tracking**
|
|
||||||
- Add database table to track measurement sessions
|
|
||||||
- Display elapsed time in live view
|
|
||||||
- Store start/stop timestamps
|
|
||||||
|
|
||||||
2. **Historical Data View**
|
|
||||||
- Chart historical Leq intervals
|
|
||||||
- Export measurement data
|
|
||||||
- Comparison between units
|
|
||||||
|
|
||||||
3. **Alerts & Thresholds**
|
|
||||||
- Configurable sound level alerts
|
|
||||||
- Email/SMS notifications when thresholds exceeded
|
|
||||||
- Visual indicators on dashboard
|
|
||||||
|
|
||||||
### Long-term
|
|
||||||
1. **Map View**
|
|
||||||
- Display all SLMs on a map (like seismographs)
|
|
||||||
- Click map markers to view live data
|
|
||||||
- Color-coded by current sound level
|
|
||||||
|
|
||||||
2. **Batch Operations**
|
|
||||||
- Start/stop multiple units simultaneously
|
|
||||||
- Synchronized measurements
|
|
||||||
- Group configurations
|
|
||||||
|
|
||||||
3. **Advanced Analytics**
|
|
||||||
- Noise compliance reports
|
|
||||||
- Statistical summaries
|
|
||||||
- Trend analysis
|
|
||||||
|
|
||||||
## Integration with Terra-view
|
|
||||||
|
|
||||||
When SFM is rebranded to Terra-view:
|
|
||||||
|
|
||||||
1. **Multi-Module Dashboard**
|
|
||||||
- Sound Level Meters module (this dashboard)
|
|
||||||
- Seismograph Fleet Manager module (existing)
|
|
||||||
- Future monitoring modules
|
|
||||||
|
|
||||||
2. **Project Management**
|
|
||||||
- Link SLMs to projects
|
|
||||||
- Combined project view with seismographs + SLMs
|
|
||||||
- Project-level reporting
|
|
||||||
|
|
||||||
3. **Unified Navigation**
|
|
||||||
- Top-level module switcher
|
|
||||||
- Consistent UI/UX across modules
|
|
||||||
- Shared authentication and settings
|
|
||||||
|
|
||||||
## Technical Notes
|
|
||||||
|
|
||||||
### HTMX Integration
|
|
||||||
The dashboard extensively uses HTMX for dynamic updates without full page reloads:
|
|
||||||
- `hx-get`: Fetch and swap content
|
|
||||||
- `hx-trigger`: Auto-refresh intervals
|
|
||||||
- `hx-swap`: Content replacement strategy
|
|
||||||
- `hx-target`: Specify update target
|
|
||||||
|
|
||||||
### Dark Mode Support
|
|
||||||
All components support dark mode:
|
|
||||||
- Chart.js colors adapt to theme
|
|
||||||
- Tailwind dark: classes throughout
|
|
||||||
- Automatic theme detection
|
|
||||||
|
|
||||||
### Performance Considerations
|
|
||||||
- WebSocket connections are per-unit (only one active at a time)
|
|
||||||
- Chart data limited to 60 points (1 minute) to prevent memory bloat
|
|
||||||
- Polling intervals balanced for responsiveness vs server load
|
|
||||||
- Lazy loading of live view panel (only when unit selected)
|
|
||||||
|
|
||||||
## Files Modified/Created
|
|
||||||
|
|
||||||
### New Files
|
|
||||||
- `templates/sound_level_meters.html`
|
|
||||||
- `templates/partials/slm_stats.html`
|
|
||||||
- `templates/partials/slm_unit_list.html`
|
|
||||||
- `templates/partials/slm_live_view.html`
|
|
||||||
- `templates/partials/slm_live_view_error.html`
|
|
||||||
- `backend/routers/slm_dashboard.py`
|
|
||||||
- `add_test_slms.py`
|
|
||||||
- `docs/SOUND_LEVEL_METERS_DASHBOARD.md`
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
- `backend/main.py` - Added route and router import
|
|
||||||
- `templates/base.html` - Added navigation menu item
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
- Check SLMM API documentation: `/home/serversdown/slmm/docs/API.md`
|
|
||||||
- Review SFM changelog: `CHANGELOG.md`
|
|
||||||
- Submit issues to project repository
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version**: 1.0.0
|
|
||||||
**Created**: January 2026
|
|
||||||
**Last Updated**: January 5, 2026
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# Helper Scripts
|
|
||||||
|
|
||||||
This directory contains helper scripts for database management and testing.
|
|
||||||
|
|
||||||
## Database Migration Scripts
|
|
||||||
|
|
||||||
### migrate_dev_db.py
|
|
||||||
Migrates the DEV database schema to add SLM-specific columns to the `roster` table.
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
|
||||||
python3 scripts/migrate_dev_db.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
- Adds 8 SLM-specific columns to the DEV database (data-dev/seismo_fleet.db)
|
|
||||||
- Columns: slm_host, slm_tcp_port, slm_model, slm_serial_number, slm_frequency_weighting, slm_time_weighting, slm_measurement_range, slm_last_check
|
|
||||||
- Safe to run multiple times (skips existing columns)
|
|
||||||
|
|
||||||
### update_dev_db_schema.py
|
|
||||||
Inspects and displays the DEV database schema.
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
python3 scripts/update_dev_db_schema.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
- Shows all tables in the DEV database
|
|
||||||
- Lists all columns in the roster table
|
|
||||||
- Useful for verifying schema after migrations
|
|
||||||
|
|
||||||
## Test Data Scripts
|
|
||||||
|
|
||||||
### add_test_slms.py
|
|
||||||
Adds test Sound Level Meter units to the DEV database.
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
python3 scripts/add_test_slms.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it creates:**
|
|
||||||
- nl43-001: NL-43 SLM at Construction Site A
|
|
||||||
- nl43-002: NL-43 SLM at Construction Site B
|
|
||||||
- nl53-001: NL-53 SLM at Residential Area
|
|
||||||
- nl43-003: NL-43 SLM (not deployed, spare unit)
|
|
||||||
|
|
||||||
### add_test_modems.py
|
|
||||||
Adds test modem units to the DEV database and assigns them to SLMs.
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
python3 scripts/add_test_modems.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it creates:**
|
|
||||||
- modem-001, modem-002, modem-003: Deployed modems (Raven XTV and Sierra Wireless)
|
|
||||||
- modem-004: Spare modem (not deployed)
|
|
||||||
|
|
||||||
**Modem assignments:**
|
|
||||||
- nl43-001 → modem-001
|
|
||||||
- nl43-002 → modem-002
|
|
||||||
- nl53-001 → modem-003
|
|
||||||
|
|
||||||
## Cleanup Scripts
|
|
||||||
|
|
||||||
### remove_test_data_from_prod.py
|
|
||||||
**⚠️ PRODUCTION DATABASE CLEANUP**
|
|
||||||
|
|
||||||
Removes test data from the production database (data/seismo_fleet.db).
|
|
||||||
|
|
||||||
**Status:** Already executed successfully. Production database is clean.
|
|
||||||
|
|
||||||
**What it removed:**
|
|
||||||
- All test SLM units (nl43-001, nl43-002, nl53-001, nl43-003)
|
|
||||||
- All test modem units (modem-001, modem-002, modem-003, modem-004)
|
|
||||||
|
|
||||||
## Database Cloning
|
|
||||||
|
|
||||||
### clone_db_to_dev.py
|
|
||||||
Clones the production database to create/update the DEV database.
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
python3 scripts/clone_db_to_dev.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
- Copies data/seismo_fleet.db → data-dev/seismo_fleet.db
|
|
||||||
- Useful for syncing DEV database with production schema/data
|
|
||||||
|
|
||||||
## Setup Sequence
|
|
||||||
|
|
||||||
To set up a fresh DEV database with test data:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
|
||||||
|
|
||||||
# 1. Fix permissions (if needed)
|
|
||||||
sudo chown -R serversdown:serversdown data-dev/
|
|
||||||
|
|
||||||
# 2. Migrate schema
|
|
||||||
python3 scripts/migrate_dev_db.py
|
|
||||||
|
|
||||||
# 3. Add test data
|
|
||||||
python3 scripts/add_test_slms.py
|
|
||||||
python3 scripts/add_test_modems.py
|
|
||||||
|
|
||||||
# 4. Verify
|
|
||||||
sqlite3 data-dev/seismo_fleet.db "SELECT id, device_type FROM roster WHERE device_type IN ('sound_level_meter', 'modem');"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
- **DEV Database**: `data-dev/seismo_fleet.db` - Used for development and testing
|
|
||||||
- **Production Database**: `data/seismo_fleet.db` - Used by the running application
|
|
||||||
- All test scripts are configured to use the DEV database only
|
|
||||||
- Never run test data scripts against production
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Add slm_ftp_port column to roster table for FTP data retrieval port
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Determine database based on environment
|
|
||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
|
||||||
if ENVIRONMENT == "development":
|
|
||||||
DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
|
||||||
else:
|
|
||||||
DB_URL = "sqlite:///./data/seismo_fleet.db"
|
|
||||||
|
|
||||||
def add_ftp_port_column():
|
|
||||||
print(f"Adding slm_ftp_port column to {DB_URL}...")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
engine = create_engine(DB_URL, connect_args={"check_same_thread": False})
|
|
||||||
|
|
||||||
with engine.connect() as conn:
|
|
||||||
try:
|
|
||||||
# Try to add the column
|
|
||||||
conn.execute(text("ALTER TABLE roster ADD COLUMN slm_ftp_port INTEGER"))
|
|
||||||
conn.commit()
|
|
||||||
print("✓ Added column: slm_ftp_port (INTEGER)")
|
|
||||||
except Exception as e:
|
|
||||||
if "duplicate column name" in str(e).lower():
|
|
||||||
print(" Column slm_ftp_port already exists, skipping")
|
|
||||||
else:
|
|
||||||
print(f"✗ Error adding slm_ftp_port: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("Migration completed!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
add_ftp_port_column()
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Add test modem units and assign them to SLMs in DEV database
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from backend.models import RosterUnit
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# DEV database
|
|
||||||
DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
|
||||||
|
|
||||||
def add_test_modems():
|
|
||||||
engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False})
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
db = SessionLocal()
|
|
||||||
|
|
||||||
# Test modems
|
|
||||||
test_modems = [
|
|
||||||
{
|
|
||||||
"id": "modem-001",
|
|
||||||
"device_type": "modem",
|
|
||||||
"deployed": True,
|
|
||||||
"retired": False,
|
|
||||||
"note": "Raven XTV modem for Construction Site A",
|
|
||||||
"address": "123 Main St, Construction Site A",
|
|
||||||
"coordinates": "34.0522,-118.2437",
|
|
||||||
"ip_address": "192.168.1.100",
|
|
||||||
"phone_number": "+1-555-0100",
|
|
||||||
"hardware_model": "Raven XTV"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "modem-002",
|
|
||||||
"device_type": "modem",
|
|
||||||
"deployed": True,
|
|
||||||
"retired": False,
|
|
||||||
"note": "Raven XTV modem for Construction Site B",
|
|
||||||
"address": "456 Oak Ave, Construction Site B",
|
|
||||||
"coordinates": "34.0622,-118.2537",
|
|
||||||
"ip_address": "192.168.1.101",
|
|
||||||
"phone_number": "+1-555-0101",
|
|
||||||
"hardware_model": "Raven XTV"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "modem-003",
|
|
||||||
"device_type": "modem",
|
|
||||||
"deployed": True,
|
|
||||||
"retired": False,
|
|
||||||
"note": "Sierra Wireless modem for Residential Area",
|
|
||||||
"address": "789 Elm St, Residential Area",
|
|
||||||
"coordinates": "34.0722,-118.2637",
|
|
||||||
"ip_address": "192.168.1.102",
|
|
||||||
"phone_number": "+1-555-0102",
|
|
||||||
"hardware_model": "Sierra Wireless AirLink"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "modem-004",
|
|
||||||
"device_type": "modem",
|
|
||||||
"deployed": False,
|
|
||||||
"retired": False,
|
|
||||||
"note": "Spare modem - not deployed",
|
|
||||||
"ip_address": None,
|
|
||||||
"phone_number": "+1-555-0103",
|
|
||||||
"hardware_model": "Raven XTV"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
for modem_data in test_modems:
|
|
||||||
# Check if modem already exists
|
|
||||||
existing = db.query(RosterUnit).filter_by(id=modem_data["id"]).first()
|
|
||||||
if existing:
|
|
||||||
print(f"Modem {modem_data['id']} already exists, skipping...")
|
|
||||||
continue
|
|
||||||
|
|
||||||
modem = RosterUnit(**modem_data)
|
|
||||||
db.add(modem)
|
|
||||||
print(f"Added {modem_data['id']}")
|
|
||||||
|
|
||||||
# Assign modems to existing SLMs
|
|
||||||
slm_modem_assignments = {
|
|
||||||
"nl43-001": "modem-001",
|
|
||||||
"nl43-002": "modem-002",
|
|
||||||
"nl53-001": "modem-003"
|
|
||||||
}
|
|
||||||
|
|
||||||
for slm_id, modem_id in slm_modem_assignments.items():
|
|
||||||
slm = db.query(RosterUnit).filter_by(id=slm_id).first()
|
|
||||||
if slm:
|
|
||||||
slm.deployed_with_modem_id = modem_id
|
|
||||||
# Remove legacy slm_host since we're using modems now
|
|
||||||
slm.slm_host = None
|
|
||||||
print(f"Assigned {slm_id} to {modem_id}")
|
|
||||||
else:
|
|
||||||
print(f"SLM {slm_id} not found, skipping assignment...")
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.close()
|
|
||||||
print("\nTest modems added and assigned to SLMs successfully in DEV database (data-dev/seismo_fleet.db)!")
|
|
||||||
print("\nModem assignments:")
|
|
||||||
for slm_id, modem_id in slm_modem_assignments.items():
|
|
||||||
print(f" {slm_id} → {modem_id}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
add_test_modems()
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Add test Sound Level Meter units to the DEV database
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from backend.models import RosterUnit
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# DEV database
|
|
||||||
DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
|
||||||
|
|
||||||
def add_test_slms():
|
|
||||||
engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False})
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
db = SessionLocal()
|
|
||||||
|
|
||||||
test_slms = [
|
|
||||||
{
|
|
||||||
"id": "nl43-001",
|
|
||||||
"device_type": "sound_level_meter",
|
|
||||||
"deployed": True,
|
|
||||||
"retired": False,
|
|
||||||
"note": "Test unit at construction site A",
|
|
||||||
"address": "123 Main St, Construction Site A",
|
|
||||||
"coordinates": "34.0522,-118.2437",
|
|
||||||
"slm_host": "192.168.1.100",
|
|
||||||
"slm_tcp_port": 2255,
|
|
||||||
"slm_model": "NL-43",
|
|
||||||
"slm_serial_number": "123456",
|
|
||||||
"slm_frequency_weighting": "A",
|
|
||||||
"slm_time_weighting": "F",
|
|
||||||
"slm_measurement_range": "30-130 dB",
|
|
||||||
"slm_last_check": datetime.utcnow()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nl43-002",
|
|
||||||
"device_type": "sound_level_meter",
|
|
||||||
"deployed": True,
|
|
||||||
"retired": False,
|
|
||||||
"note": "Test unit at construction site B",
|
|
||||||
"address": "456 Oak Ave, Construction Site B",
|
|
||||||
"coordinates": "34.0622,-118.2537",
|
|
||||||
"slm_host": "192.168.1.101",
|
|
||||||
"slm_tcp_port": 2255,
|
|
||||||
"slm_model": "NL-43",
|
|
||||||
"slm_serial_number": "123457",
|
|
||||||
"slm_frequency_weighting": "A",
|
|
||||||
"slm_time_weighting": "S",
|
|
||||||
"slm_measurement_range": "30-130 dB",
|
|
||||||
"slm_last_check": datetime.utcnow()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nl53-001",
|
|
||||||
"device_type": "sound_level_meter",
|
|
||||||
"deployed": True,
|
|
||||||
"retired": False,
|
|
||||||
"note": "Test unit at residential monitoring",
|
|
||||||
"address": "789 Elm St, Residential Area",
|
|
||||||
"coordinates": "34.0722,-118.2637",
|
|
||||||
"slm_host": "192.168.1.102",
|
|
||||||
"slm_tcp_port": 2255,
|
|
||||||
"slm_model": "NL-53",
|
|
||||||
"slm_serial_number": "234567",
|
|
||||||
"slm_frequency_weighting": "C",
|
|
||||||
"slm_time_weighting": "F",
|
|
||||||
"slm_measurement_range": "25-140 dB",
|
|
||||||
"slm_last_check": datetime.utcnow()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nl43-003",
|
|
||||||
"device_type": "sound_level_meter",
|
|
||||||
"deployed": False,
|
|
||||||
"retired": False,
|
|
||||||
"note": "Benched for calibration",
|
|
||||||
"address": None,
|
|
||||||
"coordinates": None,
|
|
||||||
"slm_host": None,
|
|
||||||
"slm_tcp_port": None,
|
|
||||||
"slm_model": "NL-43",
|
|
||||||
"slm_serial_number": "123458",
|
|
||||||
"slm_frequency_weighting": "A",
|
|
||||||
"slm_time_weighting": "F",
|
|
||||||
"slm_measurement_range": "30-130 dB",
|
|
||||||
"slm_last_check": None
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
for slm_data in test_slms:
|
|
||||||
# Check if unit already exists
|
|
||||||
existing = db.query(RosterUnit).filter_by(id=slm_data["id"]).first()
|
|
||||||
if existing:
|
|
||||||
print(f"Unit {slm_data['id']} already exists, skipping...")
|
|
||||||
continue
|
|
||||||
|
|
||||||
unit = RosterUnit(**slm_data)
|
|
||||||
db.add(unit)
|
|
||||||
print(f"Added {slm_data['id']}")
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.close()
|
|
||||||
print("\nTest SLM units added successfully to DEV database (data-dev/seismo_fleet.db)!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
add_test_slms()
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Migrate DEV database to add SLM-specific columns
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
|
|
||||||
# DEV database
|
|
||||||
DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
|
||||||
|
|
||||||
def migrate_dev_database():
|
|
||||||
print("Migrating DEV database to add SLM columns...")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False})
|
|
||||||
|
|
||||||
# SLM columns to add
|
|
||||||
slm_columns = [
|
|
||||||
("slm_host", "VARCHAR"),
|
|
||||||
("slm_tcp_port", "INTEGER"),
|
|
||||||
("slm_model", "VARCHAR"),
|
|
||||||
("slm_serial_number", "VARCHAR"),
|
|
||||||
("slm_frequency_weighting", "VARCHAR"),
|
|
||||||
("slm_time_weighting", "VARCHAR"),
|
|
||||||
("slm_measurement_range", "VARCHAR"),
|
|
||||||
("slm_last_check", "DATETIME"),
|
|
||||||
]
|
|
||||||
|
|
||||||
with engine.connect() as conn:
|
|
||||||
for column_name, column_type in slm_columns:
|
|
||||||
try:
|
|
||||||
# Try to add the column
|
|
||||||
conn.execute(text(f"ALTER TABLE roster ADD COLUMN {column_name} {column_type}"))
|
|
||||||
conn.commit()
|
|
||||||
print(f"✓ Added column: {column_name}")
|
|
||||||
except Exception as e:
|
|
||||||
if "duplicate column name" in str(e).lower():
|
|
||||||
print(f" Column {column_name} already exists, skipping")
|
|
||||||
else:
|
|
||||||
print(f"✗ Error adding {column_name}: {e}")
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("DEV database migration completed!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
migrate_dev_database()
|
|
||||||