From 82651f71b5273a8fed22a8e367264976532b8695 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Sat, 17 Jan 2026 08:00:05 +0000 Subject: [PATCH] Add roster management interface and related API endpoints - Implemented a new `/roster` endpoint to retrieve and manage device configurations. - Added HTML template for the roster page with a table to display device status and actions. - Introduced functionality to add, edit, and delete devices via the roster interface. - Enhanced `ConfigPayload` model to include polling options. - Updated the main application to serve the new roster page and link to it from the index. - Added validation for polling interval in the configuration payload. - Created detailed documentation for the roster management features and API endpoints. --- app/main.py | 5 + app/routers.py | 173 ++++++++++++ docs/ROSTER.md | 246 +++++++++++++++++ templates/index.html | 137 +++++++++- templates/roster.html | 624 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1175 insertions(+), 10 deletions(-) create mode 100644 docs/ROSTER.md create mode 100644 templates/roster.html diff --git a/app/main.py b/app/main.py index 65fbff0..406d7fc 100644 --- a/app/main.py +++ b/app/main.py @@ -72,6 +72,11 @@ def index(request: Request): return templates.TemplateResponse("index.html", {"request": request}) +@app.get("/roster", response_class=HTMLResponse) +def roster(request: Request): + return templates.TemplateResponse("roster.html", {"request": request}) + + @app.get("/health") async def health(): """Basic health check endpoint.""" diff --git a/app/routers.py b/app/routers.py index 2b5bfe4..9cf52ad 100644 --- a/app/routers.py +++ b/app/routers.py @@ -49,6 +49,8 @@ class ConfigPayload(BaseModel): ftp_username: str | None = None ftp_password: str | None = None web_enabled: bool | None = None + poll_enabled: bool | None = None + poll_interval_seconds: int | None = None @field_validator("host") @classmethod @@ -76,6 +78,13 @@ class ConfigPayload(BaseModel): raise ValueError("Port must be between 1 and 65535") return v + @field_validator("poll_interval_seconds") + @classmethod + def validate_poll_interval(cls, v): + if v is not None and not (10 <= v <= 3600): + raise ValueError("Poll interval must be between 10 and 3600 seconds") + return v + class PollingConfigPayload(BaseModel): """Payload for updating device polling configuration.""" @@ -131,6 +140,164 @@ def get_global_polling_status(db: Session = Depends(get_db)): } +@router.get("/roster") +def get_roster(db: Session = Depends(get_db)): + """ + Get list of all configured devices with their status. + + Returns all NL43Config entries along with their associated status information. + Used by the roster page to display all devices in a table. + + Note: Must be defined before /{unit_id} routes to avoid routing conflicts. + """ + configs = db.query(NL43Config).all() + + devices = [] + for cfg in configs: + status = db.query(NL43Status).filter_by(unit_id=cfg.unit_id).first() + + device_data = { + "unit_id": cfg.unit_id, + "host": cfg.host, + "tcp_port": cfg.tcp_port, + "ftp_port": cfg.ftp_port, + "tcp_enabled": cfg.tcp_enabled, + "ftp_enabled": cfg.ftp_enabled, + "ftp_username": cfg.ftp_username, + "ftp_password": cfg.ftp_password, + "web_enabled": cfg.web_enabled, + "poll_enabled": cfg.poll_enabled, + "poll_interval_seconds": cfg.poll_interval_seconds, + "status": None + } + + if status: + device_data["status"] = { + "last_seen": status.last_seen.isoformat() if status.last_seen else None, + "measurement_state": status.measurement_state, + "is_reachable": status.is_reachable, + "consecutive_failures": status.consecutive_failures, + "last_success": status.last_success.isoformat() if status.last_success else None, + "last_error": status.last_error + } + + devices.append(device_data) + + return { + "status": "ok", + "devices": devices, + "total": len(devices) + } + + +class RosterCreatePayload(BaseModel): + """Payload for creating a new device via roster.""" + unit_id: str + host: str + tcp_port: int = 2255 + ftp_port: int = 21 + tcp_enabled: bool = True + ftp_enabled: bool = False + ftp_username: str | None = None + ftp_password: str | None = None + web_enabled: bool = False + poll_enabled: bool = True + poll_interval_seconds: int = 60 + + @field_validator("host") + @classmethod + def validate_host(cls, v): + if v is None: + return v + # Try to parse as IP address or hostname + try: + ipaddress.ip_address(v) + except ValueError: + # Not an IP, check if it's a valid hostname format + if not v or len(v) > 253: + raise ValueError("Invalid hostname length") + # Allow hostnames (basic validation) + if not all(c.isalnum() or c in ".-" for c in v): + raise ValueError("Host must be a valid IP address or hostname") + return v + + @field_validator("tcp_port", "ftp_port") + @classmethod + def validate_port(cls, v): + if v is None: + return v + if not (1 <= v <= 65535): + raise ValueError("Port must be between 1 and 65535") + return v + + @field_validator("poll_interval_seconds") + @classmethod + def validate_poll_interval(cls, v): + if v is not None and not (10 <= v <= 3600): + raise ValueError("Poll interval must be between 10 and 3600 seconds") + return v + + +@router.post("/roster") +async def create_device(payload: RosterCreatePayload, db: Session = Depends(get_db)): + """ + Create a new device configuration via roster. + + This endpoint allows creating a new device with all configuration options. + If a device with the same unit_id already exists, returns a 409 conflict. + + Note: Must be defined before /{unit_id} routes to avoid routing conflicts. + """ + # Check if device already exists + existing = db.query(NL43Config).filter_by(unit_id=payload.unit_id).first() + if existing: + raise HTTPException( + status_code=409, + detail=f"Device with unit_id '{payload.unit_id}' already exists. Use PUT /{payload.unit_id}/config to update." + ) + + # Create new config + cfg = NL43Config( + unit_id=payload.unit_id, + host=payload.host, + tcp_port=payload.tcp_port, + ftp_port=payload.ftp_port, + tcp_enabled=payload.tcp_enabled, + ftp_enabled=payload.ftp_enabled, + ftp_username=payload.ftp_username, + ftp_password=payload.ftp_password, + web_enabled=payload.web_enabled, + poll_enabled=payload.poll_enabled, + poll_interval_seconds=payload.poll_interval_seconds + ) + + db.add(cfg) + db.commit() + db.refresh(cfg) + + logger.info(f"Created new device config for {payload.unit_id}") + + # If TCP is enabled, automatically disable sleep mode + if cfg.tcp_enabled and cfg.host and cfg.tcp_port: + logger.info(f"TCP enabled for {payload.unit_id}, ensuring sleep mode is disabled") + client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password, ftp_port=cfg.ftp_port) + await ensure_sleep_mode_disabled(client, payload.unit_id) + + return { + "status": "ok", + "message": f"Device {payload.unit_id} created successfully", + "data": { + "unit_id": cfg.unit_id, + "host": cfg.host, + "tcp_port": cfg.tcp_port, + "tcp_enabled": cfg.tcp_enabled, + "ftp_enabled": cfg.ftp_enabled, + "poll_enabled": cfg.poll_enabled, + "poll_interval_seconds": cfg.poll_interval_seconds + } + } + + # ============================================================================ # DEVICE-SPECIFIC ENDPOINTS # ============================================================================ @@ -207,6 +374,10 @@ async def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depe cfg.ftp_password = payload.ftp_password if payload.web_enabled is not None: cfg.web_enabled = payload.web_enabled + if payload.poll_enabled is not None: + cfg.poll_enabled = payload.poll_enabled + if payload.poll_interval_seconds is not None: + cfg.poll_interval_seconds = payload.poll_interval_seconds db.commit() db.refresh(cfg) @@ -228,6 +399,8 @@ async def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depe "tcp_enabled": cfg.tcp_enabled, "ftp_enabled": cfg.ftp_enabled, "web_enabled": cfg.web_enabled, + "poll_enabled": cfg.poll_enabled, + "poll_interval_seconds": cfg.poll_interval_seconds, }, } diff --git a/docs/ROSTER.md b/docs/ROSTER.md new file mode 100644 index 0000000..7ba8b62 --- /dev/null +++ b/docs/ROSTER.md @@ -0,0 +1,246 @@ +# SLMM Roster Management + +The SLMM standalone application now includes a roster management interface for viewing and configuring all Sound Level Meter devices. + +## Features + +### Web Interface + +Access the roster at: **http://localhost:8100/roster** + +The roster page provides: + +- **Device List Table**: View all configured SLMs with their connection details +- **Real-time Status**: See device connectivity status (Online/Offline/Stale) +- **Add Device**: Create new device configurations with a user-friendly modal form +- **Edit Device**: Modify existing device configurations +- **Delete Device**: Remove device configurations (does not affect physical devices) +- **Test Connection**: Run diagnostics on individual devices + +### Table Columns + +| Column | Description | +|--------|-------------| +| Unit ID | Unique identifier for the device | +| Host / IP | Device IP address or hostname | +| TCP Port | TCP control port (default: 2255) | +| FTP Port | FTP file transfer port (default: 21) | +| TCP | Whether TCP control is enabled | +| FTP | Whether FTP file transfer is enabled | +| Polling | Whether background polling is enabled | +| Status | Device connectivity status (Online/Offline/Stale) | +| Actions | Test, Edit, Delete buttons | + +### Status Indicators + +- **Online** (green): Device responded within the last 5 minutes +- **Stale** (yellow): Device hasn't responded recently but was seen before +- **Offline** (red): Device is unreachable or has consecutive failures +- **Unknown** (gray): No status data available yet + +## API Endpoints + +### List All Devices + +```bash +GET /api/nl43/roster +``` + +Returns all configured devices with their status information. + +**Response:** +```json +{ + "status": "ok", + "devices": [ + { + "unit_id": "SLM-43-01", + "host": "192.168.1.100", + "tcp_port": 2255, + "ftp_port": 21, + "tcp_enabled": true, + "ftp_enabled": true, + "ftp_username": "USER", + "ftp_password": "0000", + "web_enabled": false, + "poll_enabled": true, + "poll_interval_seconds": 60, + "status": { + "last_seen": "2026-01-16T20:00:00", + "measurement_state": "Start", + "is_reachable": true, + "consecutive_failures": 0, + "last_success": "2026-01-16T20:00:00", + "last_error": null + } + } + ], + "total": 1 +} +``` + +### Create New Device + +```bash +POST /api/nl43/roster +Content-Type: application/json + +{ + "unit_id": "SLM-43-01", + "host": "192.168.1.100", + "tcp_port": 2255, + "ftp_port": 21, + "tcp_enabled": true, + "ftp_enabled": false, + "poll_enabled": true, + "poll_interval_seconds": 60 +} +``` + +**Required Fields:** +- `unit_id`: Unique device identifier +- `host`: IP address or hostname + +**Optional Fields:** +- `tcp_port`: TCP control port (default: 2255) +- `ftp_port`: FTP port (default: 21) +- `tcp_enabled`: Enable TCP control (default: true) +- `ftp_enabled`: Enable FTP transfers (default: false) +- `ftp_username`: FTP username (only if ftp_enabled) +- `ftp_password`: FTP password (only if ftp_enabled) +- `poll_enabled`: Enable background polling (default: true) +- `poll_interval_seconds`: Polling interval 10-3600 seconds (default: 60) + +**Response:** +```json +{ + "status": "ok", + "message": "Device SLM-43-01 created successfully", + "data": { + "unit_id": "SLM-43-01", + "host": "192.168.1.100", + "tcp_port": 2255, + "tcp_enabled": true, + "ftp_enabled": false, + "poll_enabled": true, + "poll_interval_seconds": 60 + } +} +``` + +### Update Device + +```bash +PUT /api/nl43/{unit_id}/config +Content-Type: application/json + +{ + "host": "192.168.1.101", + "tcp_port": 2255, + "poll_interval_seconds": 120 +} +``` + +All fields are optional. Only include fields you want to update. + +### Delete Device + +```bash +DELETE /api/nl43/{unit_id}/config +``` + +Removes the device configuration and associated status data. Does not affect the physical device. + +**Response:** +```json +{ + "status": "ok", + "message": "Deleted device SLM-43-01" +} +``` + +## Usage Examples + +### Via Web Interface + +1. Navigate to http://localhost:8100/roster +2. Click "Add Device" to create a new configuration +3. Fill in the device details (unit ID, IP address, ports) +4. Configure TCP, FTP, and polling settings +5. Click "Save Device" +6. Use "Test" button to verify connectivity +7. Edit or delete devices as needed + +### Via API (curl) + +**Add a new device:** +```bash +curl -X POST http://localhost:8100/api/nl43/roster \ + -H "Content-Type: application/json" \ + -d '{ + "unit_id": "slm-site-a", + "host": "192.168.1.100", + "tcp_port": 2255, + "tcp_enabled": true, + "ftp_enabled": true, + "ftp_username": "USER", + "ftp_password": "0000", + "poll_enabled": true, + "poll_interval_seconds": 60 + }' +``` + +**Update device host:** +```bash +curl -X PUT http://localhost:8100/api/nl43/slm-site-a/config \ + -H "Content-Type: application/json" \ + -d '{"host": "192.168.1.101"}' +``` + +**Delete device:** +```bash +curl -X DELETE http://localhost:8100/api/nl43/slm-site-a/config +``` + +**List all devices:** +```bash +curl http://localhost:8100/api/nl43/roster | python3 -m json.tool +``` + +## Integration with Terra-View + +When SLMM is used as a module within Terra-View: + +1. Terra-View manages device configurations in its own database +2. Terra-View syncs configurations to SLMM via `PUT /api/nl43/{unit_id}/config` +3. Terra-View can query device status via `GET /api/nl43/{unit_id}/status` +4. SLMM's roster page can be used for standalone testing and diagnostics + +## Background Polling + +Devices with `poll_enabled: true` are automatically polled at their configured interval: + +- Polls device status every `poll_interval_seconds` (10-3600 seconds) +- Updates `NL43Status` table with latest measurements +- Tracks device reachability and failure counts +- Provides real-time status updates in the roster + +**Note**: Polling respects the NL43 protocol's 1-second rate limit between commands. + +## Validation + +The roster system validates: + +- **Unit ID**: Must be unique across all devices +- **Host**: Valid IP address or hostname format +- **Ports**: Must be between 1-65535 +- **Poll Interval**: Must be between 10-3600 seconds +- **Duplicate Check**: Returns 409 Conflict if unit_id already exists + +## Notes + +- Deleting a device from the roster does NOT affect the physical device +- Device configurations are stored in the SLMM database (`data/slmm.db`) +- Status information is updated by the background polling system +- The roster page auto-refreshes status indicators +- Test button runs full diagnostics (connectivity, TCP, FTP if enabled) diff --git a/templates/index.html b/templates/index.html index e5a8b86..7703189 100644 --- a/templates/index.html +++ b/templates/index.html @@ -31,6 +31,11 @@

SLMM NL43 Standalone

Configure a unit (host/port), then use controls to Start/Stop and fetch live status.

+

+ 📊 View Device Roster + | + API Documentation +

🔍 Connection Diagnostics @@ -40,13 +45,34 @@
- Unit Config - - - - - - + Unit Selection & Config + +
+
+ + +
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
- - +
+ + +
@@ -148,6 +176,7 @@ let ws = null; let streamUpdateCount = 0; + let availableDevices = []; function log(msg) { logEl.textContent += msg + "\n"; @@ -160,9 +189,97 @@ ftpCredentials.style.display = ftpEnabled ? 'block' : 'none'; } - // Add event listener for FTP checkbox + // Load device list from roster + async function refreshDeviceList() { + try { + const res = await fetch('/api/nl43/roster'); + const data = await res.json(); + + if (!res.ok) { + log('Failed to load device list'); + return; + } + + availableDevices = data.devices || []; + const selector = document.getElementById('deviceSelector'); + + // Save current selection + const currentSelection = selector.value; + + // Clear and rebuild options + selector.innerHTML = ''; + + availableDevices.forEach(device => { + const option = document.createElement('option'); + option.value = device.unit_id; + + // Add status indicator + let statusIcon = '⚪'; + if (device.status) { + if (device.status.is_reachable === false) { + statusIcon = '🔴'; + } else if (device.status.last_success) { + const lastSeen = new Date(device.status.last_success); + const ageMinutes = Math.floor((Date.now() - lastSeen) / 60000); + statusIcon = ageMinutes < 5 ? '🟢' : '🟡'; + } + } + + option.textContent = `${statusIcon} ${device.unit_id} (${device.host})`; + selector.appendChild(option); + }); + + // Restore selection if it still exists + if (currentSelection && availableDevices.find(d => d.unit_id === currentSelection)) { + selector.value = currentSelection; + } + + log(`Loaded ${availableDevices.length} device(s) from roster`); + } catch (err) { + log(`Error loading device list: ${err.message}`); + } + } + + // Load selected device configuration + function loadSelectedDevice() { + const selector = document.getElementById('deviceSelector'); + const unitId = selector.value; + + if (!unitId) { + return; + } + + const device = availableDevices.find(d => d.unit_id === unitId); + if (!device) { + log(`Device ${unitId} not found in list`); + return; + } + + // Populate form fields + document.getElementById('unitId').value = device.unit_id; + document.getElementById('host').value = device.host; + document.getElementById('port').value = device.tcp_port || 2255; + document.getElementById('tcpEnabled').checked = device.tcp_enabled || false; + document.getElementById('ftpEnabled').checked = device.ftp_enabled || false; + + if (device.ftp_username) { + document.getElementById('ftpUsername').value = device.ftp_username; + } + if (device.ftp_password) { + document.getElementById('ftpPassword').value = device.ftp_password; + } + + toggleFtpCredentials(); + + log(`Loaded configuration for ${device.unit_id}`); + } + + // Add event listeners document.addEventListener('DOMContentLoaded', function() { document.getElementById('ftpEnabled').addEventListener('change', toggleFtpCredentials); + + // Load device list on page load + refreshDeviceList(); }); async function runDiagnostics() { diff --git a/templates/roster.html b/templates/roster.html new file mode 100644 index 0000000..6c8d23d --- /dev/null +++ b/templates/roster.html @@ -0,0 +1,624 @@ + + + + + + SLMM Roster - Sound Level Meter Configuration + + + +
+
+

📊 Sound Level Meter Roster

+ +
+ +
+ + + + + + + + + + + + + + + + + + + +
Unit IDHost / IPTCP PortFTP PortTCPFTPPollingStatusActions
+ Loading... +
+
+
+ + + + + +
+ + + +