From 5ee6f5eb289e707fba89b6026ae266614190d166 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Wed, 28 Jan 2026 20:02:10 +0000 Subject: [PATCH 01/10] feat: Enhance dashboard with filtering options and sync SLM status - Added a new filtering system to the dashboard for device types and statuses. - Implemented asynchronous SLM status synchronization to update the Emitter table. - Updated the status snapshot endpoint to sync SLM status before generating the snapshot. - Refactored the dashboard HTML to include filter controls and JavaScript for managing filter state. - Improved the unit detail page to handle modem associations and cascade updates to paired devices. - Removed redundant code related to syncing start time for measuring devices. --- backend/routers/roster.py | 16 +- backend/routers/slm_dashboard.py | 18 +- backend/services/slm_status_sync.py | 125 +++++++++ backend/services/snapshot.py | 16 ++ templates/dashboard.html | 418 ++++++++++++++++++++++------ templates/roster.html | 11 +- templates/unit_detail.html | 212 +++++++++++++- 7 files changed, 696 insertions(+), 120 deletions(-) create mode 100644 backend/services/slm_status_sync.py diff --git a/backend/routers/roster.py b/backend/routers/roster.py index d2792e1..101fd02 100644 --- a/backend/routers/roster.py +++ b/backend/routers/roster.py @@ -2,20 +2,32 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from datetime import datetime, timedelta from typing import Dict, Any +import asyncio +import logging import random from backend.database import get_db from backend.services.snapshot import emit_status_snapshot +from backend.services.slm_status_sync import sync_slm_status_to_emitters router = APIRouter(prefix="/api", tags=["roster"]) +logger = logging.getLogger(__name__) @router.get("/status-snapshot") -def get_status_snapshot(db: Session = Depends(get_db)): +async def get_status_snapshot(db: Session = Depends(get_db)): """ Calls emit_status_snapshot() to get current fleet status. - This will be replaced with real Series3 emitter logic later. + Syncs SLM status from SLMM before generating snapshot. """ + # Sync SLM status from SLMM (with timeout to prevent blocking) + try: + await asyncio.wait_for(sync_slm_status_to_emitters(), timeout=2.0) + except asyncio.TimeoutError: + logger.warning("SLM status sync timed out, using cached data") + except Exception as e: + logger.warning(f"SLM status sync failed: {e}") + return emit_status_snapshot() diff --git a/backend/routers/slm_dashboard.py b/backend/routers/slm_dashboard.py index be70cc2..3b93488 100644 --- a/backend/routers/slm_dashboard.py +++ b/backend/routers/slm_dashboard.py @@ -167,23 +167,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge measurement_state = state_data.get("measurement_state", "Unknown") is_measuring = state_data.get("is_measuring", False) - # If measuring, sync start time from FTP to database (fixes wrong timestamps) - if is_measuring: - try: - sync_response = await client.post( - f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time", - timeout=10.0 - ) - if sync_response.status_code == 200: - sync_data = sync_response.json() - logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}") - else: - logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}") - except Exception as e: - # Don't fail the whole request if sync fails - logger.warning(f"Could not sync start time for {unit_id}: {e}") - - # Get live status (now with corrected start time) + # Get live status (measurement_start_time is already stored in SLMM database) status_response = await client.get( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live" ) diff --git a/backend/services/slm_status_sync.py b/backend/services/slm_status_sync.py new file mode 100644 index 0000000..4ec139b --- /dev/null +++ b/backend/services/slm_status_sync.py @@ -0,0 +1,125 @@ +""" +SLM Status Synchronization Service + +Syncs SLM device status from SLMM backend to Terra-View's Emitter table. +This bridges SLMM's polling data with Terra-View's status snapshot system. + +SLMM tracks device reachability via background polling. This service +fetches that data and creates/updates Emitter records so SLMs appear +correctly in the dashboard status snapshot. +""" + +import logging +from datetime import datetime, timezone +from typing import Dict, Any + +from backend.database import get_db_session +from backend.models import Emitter +from backend.services.slmm_client import get_slmm_client, SLMMClientError + +logger = logging.getLogger(__name__) + + +async def sync_slm_status_to_emitters() -> Dict[str, Any]: + """ + Fetch SLM status from SLMM and sync to Terra-View's Emitter table. + + For each device in SLMM's polling status: + - If last_success exists, create/update Emitter with that timestamp + - If not reachable, update Emitter with last known timestamp (or None) + + Returns: + Dict with synced_count, error_count, errors list + """ + client = get_slmm_client() + synced = 0 + errors = [] + + try: + # Get polling status from SLMM + status_response = await client.get_polling_status() + + # Handle nested response structure + data = status_response.get("data", status_response) + devices = data.get("devices", []) + + if not devices: + logger.debug("No SLM devices in SLMM polling status") + return {"synced_count": 0, "error_count": 0, "errors": []} + + db = get_db_session() + try: + for device in devices: + unit_id = device.get("unit_id") + if not unit_id: + continue + + try: + # Get or create Emitter record + emitter = db.query(Emitter).filter(Emitter.id == unit_id).first() + + # Determine last_seen from SLMM data + last_success_str = device.get("last_success") + is_reachable = device.get("is_reachable", False) + + if last_success_str: + # Parse ISO format timestamp + last_seen = datetime.fromisoformat( + last_success_str.replace("Z", "+00:00") + ) + # Convert to naive UTC for consistency with existing code + if last_seen.tzinfo: + last_seen = last_seen.astimezone(timezone.utc).replace(tzinfo=None) + else: + last_seen = None + + # Status will be recalculated by snapshot.py based on time thresholds + # Just store a provisional status here + status = "OK" if is_reachable else "Missing" + + # Store last error message if available + last_error = device.get("last_error") or "" + + if emitter: + # Update existing record + emitter.last_seen = last_seen + emitter.status = status + emitter.unit_type = "slm" + emitter.last_file = last_error + else: + # Create new record + emitter = Emitter( + id=unit_id, + unit_type="slm", + last_seen=last_seen, + last_file=last_error, + status=status + ) + db.add(emitter) + + synced += 1 + + except Exception as e: + errors.append(f"{unit_id}: {str(e)}") + logger.error(f"Error syncing SLM {unit_id}: {e}") + + db.commit() + + finally: + db.close() + + if synced > 0: + logger.info(f"Synced {synced} SLM device(s) to Emitter table") + + except SLMMClientError as e: + logger.warning(f"Could not reach SLMM for status sync: {e}") + errors.append(f"SLMM unreachable: {str(e)}") + except Exception as e: + logger.error(f"Error in SLM status sync: {e}", exc_info=True) + errors.append(str(e)) + + return { + "synced_count": synced, + "error_count": len(errors), + "errors": errors + } diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index 4a6cd38..2fc23f8 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -146,6 +146,22 @@ def emit_status_snapshot(): "coordinates": "", } + # --- Derive modem status from paired devices --- + # Modems don't have their own check-in system, so we inherit status + # from whatever device they're paired with (seismograph or SLM) + for unit_id, unit_data in units.items(): + if unit_data.get("device_type") == "modem" and not unit_data.get("retired"): + roster_unit = roster.get(unit_id) + if roster_unit and roster_unit.deployed_with_unit_id: + paired_unit_id = roster_unit.deployed_with_unit_id + paired_unit = units.get(paired_unit_id) + if paired_unit: + # Inherit status from paired device + unit_data["status"] = paired_unit.get("status", "Missing") + unit_data["age"] = paired_unit.get("age", "N/A") + unit_data["last"] = paired_unit.get("last") + unit_data["derived_from"] = paired_unit_id + # Separate buckets for UI active_units = { uid: u for uid, u in units.items() diff --git a/templates/dashboard.html b/templates/dashboard.html index 3a91dde..08ba780 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -187,6 +187,68 @@ + +
+
+

Filter Dashboard

+ +
+ +
+ +
+ Device Type +
+ + + +
+
+ + +
+ Status +
+ + + +
+
+
+
+
@@ -302,6 +364,254 @@ + + +{% endblock %} diff --git a/templates/partials/devices_table.html b/templates/partials/devices_table.html index ed5aca6..798d5b7 100644 --- a/templates/partials/devices_table.html +++ b/templates/partials/devices_table.html @@ -104,8 +104,13 @@ {% if unit.phone_number %}
{{ unit.phone_number }}
{% endif %} - {% if unit.hardware_model %} -
{{ unit.hardware_model }}
+ {% if unit.deployed_with_unit_id %} +
+ Linked: + + {{ unit.deployed_with_unit_id }} + +
{% endif %} {% else %} {% if unit.next_calibration_due %} @@ -126,7 +131,7 @@
-
{{ unit.last_seen }}
+
{{ unit.last_seen }}
{ + const isoDate = cell.getAttribute('data-iso'); + cell.textContent = formatLastSeenLocal(isoDate); + }); + // Update timestamp const timestampElement = document.getElementById('last-updated'); if (timestampElement) { @@ -365,20 +403,23 @@ }; return acc; }, {}); +})(); - // Sorting state - let currentSort = { column: null, direction: 'asc' }; + // Sorting state (needs to persist across swaps) + if (typeof window.currentSort === 'undefined') { + window.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'; + if (window.currentSort.column === column) { + window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc'; } else { - currentSort.column = column; - currentSort.direction = 'asc'; + window.currentSort.column = column; + window.currentSort.direction = 'asc'; } // Sort rows @@ -406,8 +447,8 @@ bVal = bVal.toLowerCase(); } - if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1; - if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1; + if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1; + if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1; return 0; }); @@ -443,10 +484,10 @@ }); // Set current indicator - if (currentSort.column) { - const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`); + if (window.currentSort.column) { + const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`); if (indicator) { - indicator.className = `sort-indicator ${currentSort.direction}`; + indicator.className = `sort-indicator ${window.currentSort.direction}`; } } } diff --git a/templates/partials/modem_list.html b/templates/partials/modem_list.html index 72525d5..3826795 100644 --- a/templates/partials/modem_list.html +++ b/templates/partials/modem_list.html @@ -1,89 +1,127 @@ {% if modems %} -
- {% for modem in modems %} -
-
-
-
- - {{ modem.id }} - - {% if modem.hardware_model %} - {{ modem.hardware_model }} +
+ + + + + + + + + + + + + + {% for modem in modems %} + + + + + + + + + + {% endfor %} + +
Unit IDStatusIP AddressPhonePaired DeviceLocationActions
+
+ + {{ modem.id }} + + {% if modem.hardware_model %} + ({{ modem.hardware_model }}) + {% endif %} +
+
+ {% if modem.status == "retired" %} + + Retired + + {% elif modem.status == "benched" %} + + Benched + + {% elif modem.status == "in_use" %} + + In Use + + {% elif modem.status == "spare" %} + + Spare + + {% else %} + + — + {% endif %} - - - {% if modem.ip_address %} -

{{ modem.ip_address }}

- {% else %} -

No IP configured

- {% endif %} - - {% if modem.phone_number %} -

{{ modem.phone_number }}

- {% endif %} - - - - {% if modem.status == "retired" %} - Retired - {% elif modem.status == "benched" %} - Benched - {% elif modem.status == "in_use" %} - In Use - {% elif modem.status == "spare" %} - Spare - {% endif %} - - - - {% if modem.paired_device %} - - {% endif %} - - - {% if modem.location or modem.project_id %} -
- {% if modem.project_id %} - {{ modem.project_id }} - {% endif %} - {% if modem.location %} - {{ modem.location }} - {% endif %} -
- {% endif %} - - -
- - - Details - -
- - - - - {% endfor %} +
+ {% if modem.ip_address %} + {{ modem.ip_address }} + {% else %} + + {% endif %} + + {% if modem.phone_number %} + {{ modem.phone_number }} + {% else %} + + {% endif %} + + {% if modem.paired_device %} + + {{ modem.paired_device.id }} + ({{ modem.paired_device.device_type }}) + + {% else %} + None + {% endif %} + + {% if modem.project_id %} + {{ modem.project_id }} + {% endif %} + {% if modem.location %} + {{ modem.location }} + {% elif not modem.project_id %} + + {% endif %} + +
+ + + View → + +
+ + +
+ +{% if search %} +
+ Found {{ modems|length }} modem(s) matching "{{ search }}" +
+{% endif %} + {% else %}

No modems found

+ {% if search %} + + {% else %}

Add modems from the Fleet Roster

+ {% endif %}
{% endif %} diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html index 720f158..08e4292 100644 --- a/templates/partials/roster_table.html +++ b/templates/partials/roster_table.html @@ -337,6 +337,7 @@ + + + {% include "partials/project_create_modal.html" %} From 4957a08198ecd80a7f3fb1aa7bf84b0d1b15310a Mon Sep 17 00:00:00 2001 From: serversdwn Date: Thu, 29 Jan 2026 16:37:59 +0000 Subject: [PATCH 03/10] fix: improvedr pair status sharing. --- backend/services/snapshot.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index 856cdc5..ec0dc2c 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -151,11 +151,23 @@ def emit_status_snapshot(): # --- Derive modem status from paired devices --- # Modems don't have their own check-in system, so we inherit status # from whatever device they're paired with (seismograph or SLM) + # Check both directions: modem.deployed_with_unit_id OR device.deployed_with_modem_id for unit_id, unit_data in units.items(): if unit_data.get("device_type") == "modem" and not unit_data.get("retired"): + paired_unit_id = None roster_unit = roster.get(unit_id) + + # First, check if modem has deployed_with_unit_id set if roster_unit and roster_unit.deployed_with_unit_id: paired_unit_id = roster_unit.deployed_with_unit_id + else: + # Fallback: check if any device has this modem in deployed_with_modem_id + for other_id, other_roster in roster.items(): + if other_roster.deployed_with_modem_id == unit_id: + paired_unit_id = other_id + break + + if paired_unit_id: paired_unit = units.get(paired_unit_id) if paired_unit: # Inherit status from paired device From 8373cff10daf4608af67a7cf067326fca8f8ebe2 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Thu, 29 Jan 2026 23:04:18 +0000 Subject: [PATCH 04/10] added: Pairing options now available from the modem page. --- backend/routers/modem_dashboard.py | 143 +++++++++ templates/partials/modem_paired_device.html | 27 +- templates/unit_detail.html | 313 ++++++++++++++++++++ 3 files changed, 476 insertions(+), 7 deletions(-) diff --git a/backend/routers/modem_dashboard.py b/backend/routers/modem_dashboard.py index a4d13c5..ed8b789 100644 --- a/backend/routers/modem_dashboard.py +++ b/backend/routers/modem_dashboard.py @@ -284,3 +284,146 @@ async def get_modem_diagnostics(modem_id: str, db: Session = Depends(get_db)): "carrier": None, "connection_type": None # LTE, 5G, etc. } + + +@router.get("/{modem_id}/pairable-devices") +async def get_pairable_devices( + modem_id: str, + db: Session = Depends(get_db), + search: str = Query(None), + hide_paired: bool = Query(True) +): + """ + Get list of devices (seismographs and SLMs) that can be paired with this modem. + Used by the device picker modal in unit_detail.html. + """ + # Check modem exists + modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first() + if not modem: + return {"status": "error", "detail": f"Modem {modem_id} not found"} + + # Query seismographs and SLMs + query = db.query(RosterUnit).filter( + RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]), + RosterUnit.retired == False + ) + + # Filter by search term if provided + if search: + search_term = f"%{search}%" + query = query.filter( + (RosterUnit.id.ilike(search_term)) | + (RosterUnit.project_id.ilike(search_term)) | + (RosterUnit.location.ilike(search_term)) | + (RosterUnit.address.ilike(search_term)) | + (RosterUnit.note.ilike(search_term)) + ) + + devices = query.order_by( + RosterUnit.deployed.desc(), + RosterUnit.device_type.asc(), + RosterUnit.id.asc() + ).all() + + # Build device list + device_list = [] + for device in devices: + # Skip already paired devices if hide_paired is True + is_paired_to_other = ( + device.deployed_with_modem_id is not None and + device.deployed_with_modem_id != modem_id + ) + is_paired_to_this = device.deployed_with_modem_id == modem_id + + if hide_paired and is_paired_to_other: + continue + + device_list.append({ + "id": device.id, + "device_type": device.device_type, + "deployed": device.deployed, + "project_id": device.project_id, + "location": device.location or device.address, + "note": device.note, + "paired_modem_id": device.deployed_with_modem_id, + "is_paired_to_this": is_paired_to_this, + "is_paired_to_other": is_paired_to_other + }) + + return {"devices": device_list, "modem_id": modem_id} + + +@router.post("/{modem_id}/pair") +async def pair_device_to_modem( + modem_id: str, + db: Session = Depends(get_db), + device_id: str = Query(..., description="ID of the device to pair") +): + """ + Pair a device (seismograph or SLM) to this modem. + Updates the device's deployed_with_modem_id field. + """ + # Check modem exists + modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first() + if not modem: + return {"status": "error", "detail": f"Modem {modem_id} not found"} + + # Find the device + device = db.query(RosterUnit).filter( + RosterUnit.id == device_id, + RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]), + RosterUnit.retired == False + ).first() + if not device: + return {"status": "error", "detail": f"Device {device_id} not found"} + + # Unpair any device currently paired to this modem + currently_paired = db.query(RosterUnit).filter( + RosterUnit.deployed_with_modem_id == modem_id + ).all() + for paired_device in currently_paired: + paired_device.deployed_with_modem_id = None + + # Pair the new device + device.deployed_with_modem_id = modem_id + db.commit() + + return { + "status": "success", + "modem_id": modem_id, + "device_id": device_id, + "message": f"Device {device_id} paired to modem {modem_id}" + } + + +@router.post("/{modem_id}/unpair") +async def unpair_device_from_modem(modem_id: str, db: Session = Depends(get_db)): + """ + Unpair any device currently paired to this modem. + """ + # Check modem exists + modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first() + if not modem: + return {"status": "error", "detail": f"Modem {modem_id} not found"} + + # Find and unpair device + device = db.query(RosterUnit).filter( + RosterUnit.deployed_with_modem_id == modem_id + ).first() + + if device: + old_device_id = device.id + device.deployed_with_modem_id = None + db.commit() + return { + "status": "success", + "modem_id": modem_id, + "unpaired_device_id": old_device_id, + "message": f"Device {old_device_id} unpaired from modem {modem_id}" + } + + return { + "status": "success", + "modem_id": modem_id, + "message": "No device was paired to this modem" + } diff --git a/templates/partials/modem_paired_device.html b/templates/partials/modem_paired_device.html index 020492c..249f39e 100644 --- a/templates/partials/modem_paired_device.html +++ b/templates/partials/modem_paired_device.html @@ -2,7 +2,7 @@ {% if device %}
- {% if device.device_type == "slm" %} + {% if device.device_type == "slm" or device.device_type == "sound_level_meter" %} @@ -18,7 +18,7 @@ {{ device.id }}
- {{ device.device_type }} + {{ device.device_type | replace("_", " ") }} {% if device.project_id %} | {{ device.project_id }} @@ -30,11 +30,17 @@ {% endif %}
- - - - - +
+ + + + + + +
{% else %}
@@ -47,5 +53,12 @@

No device currently paired

This modem is available for assignment

+
{% endif %} diff --git a/templates/unit_detail.html b/templates/unit_detail.html index a5c1725..12431fc 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -1571,6 +1571,242 @@ function clearPairing(deviceType) { closePairDeviceModal(); } + +// ===== Modem Pair Device Modal Functions (for modems to pick a device) ===== +let modemPairDevices = []; // Cache loaded devices +let modemHasCurrentPairing = false; + +function openModemPairDeviceModal() { + const modal = document.getElementById('modemPairDeviceModal'); + const searchInput = document.getElementById('modemPairDeviceSearch'); + + if (!modal) return; + + modal.classList.remove('hidden'); + if (searchInput) { + searchInput.value = ''; + searchInput.focus(); + } + + // Reset checkboxes + document.getElementById('modemPairHidePaired').checked = true; + document.getElementById('modemPairShowSeismo').checked = true; + document.getElementById('modemPairShowSLM').checked = true; + + // Load available devices + loadPairableDevices(); +} + +function closeModemPairDeviceModal() { + const modal = document.getElementById('modemPairDeviceModal'); + if (modal) modal.classList.add('hidden'); + modemPairDevices = []; +} + +async function loadPairableDevices() { + const listContainer = document.getElementById('modemPairDeviceList'); + listContainer.innerHTML = '

Loading devices...

'; + + try { + const response = await fetch(`/api/modem-dashboard/${unitId}/pairable-devices?hide_paired=false`); + if (!response.ok) throw new Error('Failed to load devices'); + + const data = await response.json(); + modemPairDevices = data.devices || []; + + // Check if modem has a current pairing + modemHasCurrentPairing = modemPairDevices.some(d => d.is_paired_to_this); + const unpairBtn = document.getElementById('modemUnpairBtn'); + if (unpairBtn) { + unpairBtn.classList.toggle('hidden', !modemHasCurrentPairing); + } + + if (modemPairDevices.length === 0) { + listContainer.innerHTML = '

No devices found in roster

'; + return; + } + + renderModemPairDeviceList(); + } catch (error) { + console.error('Failed to load pairable devices:', error); + listContainer.innerHTML = '

Failed to load devices

'; + } +} + +function filterModemPairDeviceList() { + renderModemPairDeviceList(); +} + +function renderModemPairDeviceList() { + const listContainer = document.getElementById('modemPairDeviceList'); + const searchInput = document.getElementById('modemPairDeviceSearch'); + const hidePairedCheckbox = document.getElementById('modemPairHidePaired'); + const showSeismoCheckbox = document.getElementById('modemPairShowSeismo'); + const showSLMCheckbox = document.getElementById('modemPairShowSLM'); + + const searchTerm = searchInput?.value?.toLowerCase() || ''; + const hidePaired = hidePairedCheckbox?.checked ?? true; + const showSeismo = showSeismoCheckbox?.checked ?? true; + const showSLM = showSLMCheckbox?.checked ?? true; + + // Filter devices + let filteredDevices = modemPairDevices.filter(device => { + // Filter by device type + if (device.device_type === 'seismograph' && !showSeismo) return false; + if (device.device_type === 'sound_level_meter' && !showSLM) return false; + + // Hide devices paired to OTHER modems (but show unpaired and paired-to-this) + if (hidePaired && device.is_paired_to_other) return false; + + // Search filter + if (searchTerm) { + const searchFields = [ + device.id, + device.project_id || '', + device.location || '', + device.note || '' + ].join(' ').toLowerCase(); + if (!searchFields.includes(searchTerm)) return false; + } + + return true; + }); + + if (filteredDevices.length === 0) { + listContainer.innerHTML = '

No devices match your criteria

'; + return; + } + + // Build device list HTML + let html = '
'; + for (const device of filteredDevices) { + const deviceTypeLabel = device.device_type === 'sound_level_meter' ? 'SLM' : 'Seismograph'; + const deviceTypeClass = device.device_type === 'sound_level_meter' + ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300' + : 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'; + + const deployedBadge = device.deployed + ? 'Deployed' + : 'Benched'; + + let pairingBadge = ''; + if (device.is_paired_to_this) { + pairingBadge = 'Current'; + } else if (device.is_paired_to_other) { + pairingBadge = `Paired: ${device.paired_modem_id}`; + } + + const isCurrentlyPaired = device.is_paired_to_this; + + html += ` +
+
+
+
+ ${device.id} + ${deviceTypeLabel} +
+ ${device.project_id ? `
${device.project_id}
` : ''} + ${device.location ? `
${device.location}
` : ''} +
+
+ ${deployedBadge} + ${pairingBadge} +
+
+
+ `; + } + html += '
'; + + listContainer.innerHTML = html; +} + +async function selectDeviceForModem(deviceId) { + const listContainer = document.getElementById('modemPairDeviceList'); + + // Show loading state + listContainer.innerHTML = '

Pairing device...

'; + + try { + const response = await fetch(`/api/modem-dashboard/${unitId}/pair?device_id=${encodeURIComponent(deviceId)}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.status === 'success') { + closeModemPairDeviceModal(); + // Reload the paired device section + loadPairedDevice(); + // Show success message (optional toast) + showToast(`Paired with ${deviceId}`, 'success'); + } else { + listContainer.innerHTML = `

${result.detail || 'Failed to pair device'}

`; + } + } catch (error) { + console.error('Failed to pair device:', error); + listContainer.innerHTML = '

Failed to pair device

'; + } +} + +async function unpairDeviceFromModem() { + const listContainer = document.getElementById('modemPairDeviceList'); + + // Show loading state + listContainer.innerHTML = '

Unpairing device...

'; + + try { + const response = await fetch(`/api/modem-dashboard/${unitId}/unpair`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.status === 'success') { + closeModemPairDeviceModal(); + // Reload the paired device section + loadPairedDevice(); + // Show success message + if (result.unpaired_device_id) { + showToast(`Unpaired ${result.unpaired_device_id}`, 'success'); + } else { + showToast('No device was paired', 'info'); + } + } else { + listContainer.innerHTML = `

${result.detail || 'Failed to unpair device'}

`; + } + } catch (error) { + console.error('Failed to unpair device:', error); + listContainer.innerHTML = '

Failed to unpair device

'; + } +} + +// Simple toast function (if not already defined) +function showToast(message, type = 'info') { + // Check if there's already a toast container + let toastContainer = document.getElementById('toast-container'); + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.id = 'toast-container'; + toastContainer.className = 'fixed bottom-4 right-4 z-50 space-y-2'; + document.body.appendChild(toastContainer); + } + + const toast = document.createElement('div'); + const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-gray-700'; + toast.className = `${bgColor} text-white px-4 py-2 rounded-lg shadow-lg transform transition-all duration-300 translate-x-0`; + toast.textContent = message; + + toastContainer.appendChild(toast); + + // Auto-remove after 3 seconds + setTimeout(() => { + toast.classList.add('opacity-0', 'translate-x-full'); + setTimeout(() => toast.remove(), 300); + }, 3000); +} @@ -1637,4 +1873,81 @@ function clearPairing(deviceType) { {% include "partials/project_create_modal.html" %} + + + {% endblock %} From d78bafb76e9f31cee2b4b3be37de9c64fdc33600 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Sat, 31 Jan 2026 22:31:34 +0000 Subject: [PATCH 05/10] fix: improved 24hr cycle via scheduler. Should help prevent issues with DLs. --- backend/models.py | 2 +- backend/services/alert_service.py | 4 +- .../services/recurring_schedule_service.py | 64 +----- backend/services/scheduler.py | 205 +++++++++++++++++- backend/services/slmm_client.py | 11 +- .../partials/projects/schedule_interval.html | 6 +- 6 files changed, 230 insertions(+), 62 deletions(-) diff --git a/backend/models.py b/backend/models.py index bd22b0c..41a9c4c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -229,7 +229,7 @@ class ScheduledAction(Base): location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable if location-based) - action_type = Column(String, nullable=False) # start, stop, download, calibrate + action_type = Column(String, nullable=False) # start, stop, download, cycle, calibrate device_type = Column(String, nullable=False) # "slm" | "seismograph" scheduled_time = Column(DateTime, nullable=False, index=True) diff --git a/backend/services/alert_service.py b/backend/services/alert_service.py index f10ffd1..d85f6df 100644 --- a/backend/services/alert_service.py +++ b/backend/services/alert_service.py @@ -199,7 +199,7 @@ class AlertService: Args: schedule_id: The ScheduledAction or RecurringSchedule ID - action_type: start, stop, download + action_type: start, stop, download, cycle unit_id: Related unit error_message: Error from execution project_id: Related project @@ -235,7 +235,7 @@ class AlertService: Args: schedule_id: The ScheduledAction ID - action_type: start, stop, download + action_type: start, stop, download, cycle unit_id: Related unit project_id: Related project location_id: Related location diff --git a/backend/services/recurring_schedule_service.py b/backend/services/recurring_schedule_service.py index d4a8d83..f1da36a 100644 --- a/backend/services/recurring_schedule_service.py +++ b/backend/services/recurring_schedule_service.py @@ -384,73 +384,33 @@ class RecurringScheduleService: if cycle_utc <= now_utc: continue - # Check if action already exists - if self._action_exists(schedule.project_id, schedule.location_id, "stop", cycle_utc): + # Check if cycle action already exists + if self._action_exists(schedule.project_id, schedule.location_id, "cycle", cycle_utc): continue - # Build notes with metadata - stop_notes = json.dumps({ + # Build notes with metadata for cycle action + cycle_notes = json.dumps({ "schedule_name": schedule.name, "schedule_id": schedule.id, "cycle_type": "daily", + "include_download": schedule.include_download, + "auto_increment_index": schedule.auto_increment_index, }) - # Create STOP action - stop_action = ScheduledAction( + # Create single CYCLE action that handles stop -> download -> start + # The scheduler's _execute_cycle method handles the full workflow with delays + cycle_action = ScheduledAction( id=str(uuid.uuid4()), project_id=schedule.project_id, location_id=schedule.location_id, unit_id=unit_id, - action_type="stop", + action_type="cycle", device_type=schedule.device_type, scheduled_time=cycle_utc, execution_status="pending", - notes=stop_notes, + notes=cycle_notes, ) - actions.append(stop_action) - - # Create DOWNLOAD action if enabled (1 minute after stop) - if schedule.include_download: - download_time = cycle_utc + timedelta(minutes=1) - download_notes = json.dumps({ - "schedule_name": schedule.name, - "schedule_id": schedule.id, - "cycle_type": "daily", - }) - download_action = ScheduledAction( - id=str(uuid.uuid4()), - project_id=schedule.project_id, - location_id=schedule.location_id, - unit_id=unit_id, - action_type="download", - device_type=schedule.device_type, - scheduled_time=download_time, - execution_status="pending", - notes=download_notes, - ) - actions.append(download_action) - - # Create START action (2 minutes after stop, or 1 minute after download) - start_offset = 2 if schedule.include_download else 1 - start_time = cycle_utc + timedelta(minutes=start_offset) - start_notes = json.dumps({ - "schedule_name": schedule.name, - "schedule_id": schedule.id, - "cycle_type": "daily", - "auto_increment_index": schedule.auto_increment_index, - }) - start_action = ScheduledAction( - id=str(uuid.uuid4()), - project_id=schedule.project_id, - location_id=schedule.location_id, - unit_id=unit_id, - action_type="start", - device_type=schedule.device_type, - scheduled_time=start_time, - execution_status="pending", - notes=start_notes, - ) - actions.append(start_action) + actions.append(cycle_action) return actions diff --git a/backend/services/scheduler.py b/backend/services/scheduler.py index c05e467..f419a78 100644 --- a/backend/services/scheduler.py +++ b/backend/services/scheduler.py @@ -185,6 +185,8 @@ class SchedulerService: response = await self._execute_stop(action, unit_id, db) elif action.action_type == "download": response = await self._execute_download(action, unit_id, db) + elif action.action_type == "cycle": + response = await self._execute_cycle(action, unit_id, db) else: raise Exception(f"Unknown action type: {action.action_type}") @@ -375,8 +377,13 @@ class SchedulerService: f"{location.name}/session-{session_timestamp}/" ) - # Step 1: Enable FTP on device - logger.info(f"Enabling FTP on {unit_id} for download") + # Step 1: Disable FTP first to reset any stale connection state + # Then enable FTP on device + logger.info(f"Resetting FTP on {unit_id} for download (disable then enable)") + try: + await self.device_controller.disable_ftp(unit_id, action.device_type) + except Exception as e: + logger.warning(f"FTP disable failed (may already be off): {e}") await self.device_controller.enable_ftp(unit_id, action.device_type) # Step 2: Download current measurement folder @@ -397,6 +404,200 @@ class SchedulerService: "device_response": response, } + async def _execute_cycle( + self, + action: ScheduledAction, + unit_id: str, + db: Session, + ) -> Dict[str, Any]: + """Execute a full 'cycle' action: stop -> download -> start. + + This combines stop, download, and start into a single action with + appropriate delays between steps to ensure device stability. + + Workflow: + 0. Pause background polling to prevent command conflicts + 1. Stop measurement (wait 10s) + 2. Disable FTP to reset state (wait 10s) + 3. Enable FTP (wait 10s) + 4. Download current measurement folder + 5. Wait 30s for device to settle + 6. Start new measurement cycle + 7. Re-enable background polling + + Total time: ~70-90 seconds depending on download size + """ + logger.info(f"[CYCLE] === Starting full cycle for {unit_id} ===") + + result = { + "status": "cycle_complete", + "steps": {}, + "old_session_id": None, + "new_session_id": None, + "polling_paused": False, + } + + # Step 0: Pause background polling for this device to prevent command conflicts + # NL-43 devices only support one TCP connection at a time + logger.info(f"[CYCLE] Step 0: Pausing background polling for {unit_id}") + polling_was_enabled = False + try: + if action.device_type == "slm": + # Get current polling state to restore later + from backend.services.slmm_client import get_slmm_client + slmm = get_slmm_client() + try: + polling_config = await slmm.get_device_polling_config(unit_id) + polling_was_enabled = polling_config.get("poll_enabled", False) + except Exception: + polling_was_enabled = True # Assume enabled if can't check + + # Disable polling during cycle + await slmm.update_device_polling_config(unit_id, poll_enabled=False) + result["polling_paused"] = True + logger.info(f"[CYCLE] Background polling paused for {unit_id}") + except Exception as e: + logger.warning(f"[CYCLE] Failed to pause polling (continuing anyway): {e}") + + try: + # Step 1: Stop measurement + logger.info(f"[CYCLE] Step 1/7: Stopping measurement on {unit_id}") + try: + stop_response = await self.device_controller.stop_recording(unit_id, action.device_type) + result["steps"]["stop"] = {"success": True, "response": stop_response} + logger.info(f"[CYCLE] Measurement stopped, waiting 10s...") + except Exception as e: + logger.warning(f"[CYCLE] Stop failed (may already be stopped): {e}") + result["steps"]["stop"] = {"success": False, "error": str(e)} + + await asyncio.sleep(10) + + # Step 2: Disable FTP to reset any stale state + logger.info(f"[CYCLE] Step 2/7: Disabling FTP on {unit_id}") + try: + await self.device_controller.disable_ftp(unit_id, action.device_type) + result["steps"]["ftp_disable"] = {"success": True} + logger.info(f"[CYCLE] FTP disabled, waiting 10s...") + except Exception as e: + logger.warning(f"[CYCLE] FTP disable failed (may already be off): {e}") + result["steps"]["ftp_disable"] = {"success": False, "error": str(e)} + + await asyncio.sleep(10) + + # Step 3: Enable FTP + logger.info(f"[CYCLE] Step 3/7: Enabling FTP on {unit_id}") + try: + await self.device_controller.enable_ftp(unit_id, action.device_type) + result["steps"]["ftp_enable"] = {"success": True} + logger.info(f"[CYCLE] FTP enabled, waiting 10s...") + except Exception as e: + logger.error(f"[CYCLE] FTP enable failed: {e}") + result["steps"]["ftp_enable"] = {"success": False, "error": str(e)} + # Continue anyway - download will fail but we can still try to start + + await asyncio.sleep(10) + + # Step 4: Download current measurement folder + logger.info(f"[CYCLE] Step 4/7: Downloading measurement data from {unit_id}") + location = db.query(MonitoringLocation).filter_by(id=action.location_id).first() + project = db.query(Project).filter_by(id=action.project_id).first() + + if location and project: + session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M") + location_type_dir = "sound" if action.device_type == "slm" else "vibration" + destination_path = ( + f"data/Projects/{project.id}/{location_type_dir}/" + f"{location.name}/session-{session_timestamp}/" + ) + + try: + download_response = await self.device_controller.download_files( + unit_id, + action.device_type, + destination_path, + files=None, + ) + result["steps"]["download"] = {"success": True, "response": download_response} + logger.info(f"[CYCLE] Download complete") + except Exception as e: + logger.error(f"[CYCLE] Download failed: {e}") + result["steps"]["download"] = {"success": False, "error": str(e)} + else: + result["steps"]["download"] = {"success": False, "error": "Project or location not found"} + + # Close out the old recording session + active_session = db.query(RecordingSession).filter( + and_( + RecordingSession.location_id == action.location_id, + RecordingSession.unit_id == unit_id, + RecordingSession.status == "recording", + ) + ).first() + + if active_session: + active_session.stopped_at = datetime.utcnow() + active_session.status = "completed" + active_session.duration_seconds = int( + (active_session.stopped_at - active_session.started_at).total_seconds() + ) + result["old_session_id"] = active_session.id + + # Step 5: Wait for device to settle before starting new measurement + logger.info(f"[CYCLE] Step 5/7: Waiting 30s for device to settle...") + await asyncio.sleep(30) + + # Step 6: Start new measurement cycle + logger.info(f"[CYCLE] Step 6/7: Starting new measurement on {unit_id}") + try: + cycle_response = await self.device_controller.start_cycle( + unit_id, + action.device_type, + sync_clock=True, + ) + result["steps"]["start"] = {"success": True, "response": cycle_response} + + # Create new recording session + new_session = RecordingSession( + id=str(uuid.uuid4()), + project_id=action.project_id, + location_id=action.location_id, + unit_id=unit_id, + session_type="sound" if action.device_type == "slm" else "vibration", + started_at=datetime.utcnow(), + status="recording", + session_metadata=json.dumps({ + "scheduled_action_id": action.id, + "cycle_response": cycle_response, + "action_type": "cycle", + }), + ) + db.add(new_session) + result["new_session_id"] = new_session.id + + logger.info(f"[CYCLE] New measurement started, session {new_session.id}") + + except Exception as e: + logger.error(f"[CYCLE] Start failed: {e}") + result["steps"]["start"] = {"success": False, "error": str(e)} + raise # Re-raise to mark the action as failed + + finally: + # Step 7: Re-enable background polling (always runs, even on failure) + if result.get("polling_paused") and polling_was_enabled: + logger.info(f"[CYCLE] Step 7/7: Re-enabling background polling for {unit_id}") + try: + if action.device_type == "slm": + from backend.services.slmm_client import get_slmm_client + slmm = get_slmm_client() + await slmm.update_device_polling_config(unit_id, poll_enabled=True) + logger.info(f"[CYCLE] Background polling re-enabled for {unit_id}") + except Exception as e: + logger.error(f"[CYCLE] Failed to re-enable polling: {e}") + # Don't raise - cycle completed, just log the error + + logger.info(f"[CYCLE] === Cycle complete for {unit_id} ===") + return result + # ======================================================================== # Recurring Schedule Generation # ======================================================================== diff --git a/backend/services/slmm_client.py b/backend/services/slmm_client.py index ea30e77..ec7ee57 100644 --- a/backend/services/slmm_client.py +++ b/backend/services/slmm_client.py @@ -109,7 +109,8 @@ class SLMMClient: f"SLMM operation failed: {error_detail}" ) except Exception as e: - raise SLMMClientError(f"Unexpected error: {str(e)}") + error_msg = str(e) if str(e) else type(e).__name__ + raise SLMMClientError(f"Unexpected error: {error_msg}") # ======================================================================== # Unit Management @@ -579,7 +580,13 @@ class SLMMClient: """ # Get current index number from device index_info = await self.get_index_number(unit_id) - index_number = index_info.get("index_number", 0) + index_number_raw = index_info.get("index_number", 0) + + # Convert to int - device returns string like "0000" or "0001" + try: + index_number = int(index_number_raw) + except (ValueError, TypeError): + index_number = 0 # Format as Auto_XXXX folder name folder_name = f"Auto_{index_number:04d}" diff --git a/templates/partials/projects/schedule_interval.html b/templates/partials/projects/schedule_interval.html index eea72fa..f7e1ab1 100644 --- a/templates/partials/projects/schedule_interval.html +++ b/templates/partials/projects/schedule_interval.html @@ -114,7 +114,7 @@

- At 00:00: Stop → Download (1 min) → Start (2 min) + At 00:00: Stop → Download → Start (~70 sec total)

@@ -132,12 +132,12 @@ document.getElementById('include_download').addEventListener('change', function( downloadStep.style.display = 'flex'; downloadArrow.style.display = 'block'; startStepNum.textContent = '3'; - cycleTiming.innerHTML = `At ${timeValue}: Stop → Download (1 min) → Start (2 min)`; + cycleTiming.innerHTML = `At ${timeValue}: Stop → Download → Start (~70 sec total)`; } else { downloadStep.style.display = 'none'; downloadArrow.style.display = 'none'; startStepNum.textContent = '2'; - cycleTiming.innerHTML = `At ${timeValue}: Stop → Start (1 min)`; + cycleTiming.innerHTML = `At ${timeValue}: Stop → Start (~40 sec total)`; } }); From 639b485c28bd34a0ba9aef772a717c05fae65df5 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Sun, 1 Feb 2026 07:21:34 +0000 Subject: [PATCH 06/10] Fix: mobile type display in roster and device tables incorrect. --- templates/partials/devices_table.html | 4 ++++ templates/partials/roster_table.html | 8 ++++++++ templates/roster.html | 8 ++++---- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/templates/partials/devices_table.html b/templates/partials/devices_table.html index 798d5b7..b56b8c9 100644 --- a/templates/partials/devices_table.html +++ b/templates/partials/devices_table.html @@ -235,6 +235,10 @@ Modem + {% elif unit.device_type == 'slm' %} + + SLM + {% else %} Seismograph diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html index 08e4292..127493c 100644 --- a/templates/partials/roster_table.html +++ b/templates/partials/roster_table.html @@ -83,6 +83,10 @@ Modem + {% elif unit.device_type == 'slm' %} + + SLM + {% else %} Seismograph @@ -222,6 +226,10 @@ Modem + {% elif unit.device_type == 'slm' %} + + SLM + {% else %} Seismograph diff --git a/templates/roster.html b/templates/roster.html index db6974e..5aa8508 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -122,7 +122,7 @@ class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> - +
@@ -301,7 +301,7 @@ class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> - +
@@ -641,7 +641,7 @@ setFieldsDisabled(seismoFields, true); setFieldsDisabled(modemFields, false); setFieldsDisabled(slmFields, true); - } else if (deviceType === 'sound_level_meter') { + } else if (deviceType === 'slm') { seismoFields.classList.add('hidden'); modemFields.classList.add('hidden'); slmFields.classList.remove('hidden'); @@ -816,7 +816,7 @@ setFieldsDisabled(seismoFields, true); setFieldsDisabled(modemFields, false); setFieldsDisabled(slmFields, true); - } else if (deviceType === 'sound_level_meter') { + } else if (deviceType === 'slm') { seismoFields.classList.add('hidden'); modemFields.classList.add('hidden'); slmFields.classList.remove('hidden'); From 305540f56422de01b78e7a10386f6601dace4d42 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Sun, 1 Feb 2026 20:39:34 +0000 Subject: [PATCH 07/10] fix: SLM modal field now only contains correct fields. IP address is passed via modem pairing. Add: Fuzzy-search modem pairing for slms --- templates/partials/devices_table.html | 8 ++- templates/partials/roster_table.html | 8 ++- templates/roster.html | 93 +++++++++++++++++---------- templates/unit_detail.html | 16 +++-- 4 files changed, 83 insertions(+), 42 deletions(-) diff --git a/templates/partials/devices_table.html b/templates/partials/devices_table.html index b56b8c9..ac48044 100644 --- a/templates/partials/devices_table.html +++ b/templates/partials/devices_table.html @@ -60,7 +60,9 @@ data-note="{{ unit.note if unit.note else '' }}">
- {% if unit.status == 'OK' %} + {% if not unit.deployed %} + + {% elif unit.status == 'OK' %} {% elif unit.status == 'Pending' %} @@ -208,7 +210,9 @@
- {% if unit.status == 'OK' %} + {% if not unit.deployed %} + + {% elif unit.status == 'OK' %} {% elif unit.status == 'Pending' %} diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html index 127493c..31d1731 100644 --- a/templates/partials/roster_table.html +++ b/templates/partials/roster_table.html @@ -58,7 +58,9 @@ data-note="{{ unit.note if unit.note else '' }}">
- {% if unit.status == 'OK' %} + {% if not unit.deployed %} + + {% elif unit.status == 'OK' %} {% elif unit.status == 'Pending' %} @@ -199,7 +201,9 @@
- {% if unit.status == 'OK' %} + {% if not unit.deployed %} + + {% elif unit.status == 'OK' %} {% elif unit.status == 'Pending' %} diff --git a/templates/roster.html b/templates/roster.html index 5aa8508..157baf8 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -206,21 +206,6 @@
-
- - -
-
- - -
-
- - -
I (Impulse)
+
@@ -388,21 +379,6 @@
-
- - -
-
- - -
-
- - -
I (Impulse)
+
@@ -649,6 +631,7 @@ setFieldsDisabled(seismoFields, true); setFieldsDisabled(modemFields, true); setFieldsDisabled(slmFields, false); + toggleModemPairing(); // Check if modem pairing should be shown } } @@ -661,17 +644,26 @@ }); } - // Toggle modem pairing field visibility (only for deployed seismographs) + // Toggle modem pairing field visibility (only for deployed seismographs and SLMs) function toggleModemPairing() { const deviceType = document.getElementById('deviceTypeSelect').value; const deployedCheckbox = document.getElementById('deployedCheckbox'); const modemPairingField = document.getElementById('modemPairingField'); + const slmModemPairingField = document.getElementById('slmModemPairingField'); + // Seismograph modem pairing if (deviceType === 'seismograph' && deployedCheckbox.checked) { modemPairingField.classList.remove('hidden'); } else { modemPairingField.classList.add('hidden'); } + + // SLM modem pairing + if (deviceType === 'slm' && deployedCheckbox.checked) { + slmModemPairingField.classList.remove('hidden'); + } else { + slmModemPairingField.classList.add('hidden'); + } } // Add unknown unit to roster @@ -823,6 +815,7 @@ setFieldsDisabled(seismoFields, true); setFieldsDisabled(modemFields, true); setFieldsDisabled(slmFields, false); + toggleEditModemPairing(); // Check if modem pairing should be shown } } @@ -831,12 +824,21 @@ const deviceType = document.getElementById('editDeviceTypeSelect').value; const deployedCheckbox = document.getElementById('editDeployedCheckbox'); const modemPairingField = document.getElementById('editModemPairingField'); + const slmModemPairingField = document.getElementById('editSlmModemPairingField'); + // Seismograph modem pairing if (deviceType === 'seismograph' && deployedCheckbox.checked) { modemPairingField.classList.remove('hidden'); } else { modemPairingField.classList.add('hidden'); } + + // SLM modem pairing + if (deviceType === 'slm' && deployedCheckbox.checked) { + slmModemPairingField.classList.remove('hidden'); + } else { + slmModemPairingField.classList.add('hidden'); + } } // Edit Unit - Fetch data and populate form @@ -940,13 +942,36 @@ // SLM fields document.getElementById('editSlmModel').value = unit.slm_model || ''; - document.getElementById('editSlmHost').value = unit.slm_host || ''; - document.getElementById('editSlmTcpPort').value = unit.slm_tcp_port || ''; - document.getElementById('editSlmFtpPort').value = unit.slm_ftp_port || ''; document.getElementById('editSlmSerialNumber').value = unit.slm_serial_number || ''; document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || ''; document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || ''; + // Populate SLM modem picker (uses -edit-slm suffix) + const slmModemPickerValue = document.getElementById('modem-picker-value-edit-slm'); + const slmModemPickerSearch = document.getElementById('modem-picker-search-edit-slm'); + const slmModemPickerClear = document.getElementById('modem-picker-clear-edit-slm'); + if (slmModemPickerValue) slmModemPickerValue.value = unit.deployed_with_modem_id || ''; + if (unit.deployed_with_modem_id && unit.device_type === 'slm') { + // Fetch modem display (ID + IP + note) + fetch(`/api/roster/${unit.deployed_with_modem_id}`) + .then(r => r.ok ? r.json() : null) + .then(modem => { + if (modem && slmModemPickerSearch) { + let display = modem.id; + if (modem.ip_address) display += ` - ${modem.ip_address}`; + if (modem.note) display += ` - ${modem.note}`; + slmModemPickerSearch.value = display; + if (slmModemPickerClear) slmModemPickerClear.classList.remove('hidden'); + } + }) + .catch(() => { + if (slmModemPickerSearch) slmModemPickerSearch.value = unit.deployed_with_modem_id; + }); + } else { + if (slmModemPickerSearch) slmModemPickerSearch.value = ''; + if (slmModemPickerClear) slmModemPickerClear.classList.add('hidden'); + } + // Cascade section - show if there's a paired device const cascadeSection = document.getElementById('editCascadeSection'); const cascadeToUnitId = document.getElementById('editCascadeToUnitId'); diff --git a/templates/unit_detail.html b/templates/unit_detail.html index 12431fc..ae6dd78 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -689,9 +689,16 @@ function populateViewMode() { 'Missing': 'text-red-600 dark:text-red-400' }; - document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors[unitStatus.status] || 'bg-gray-400'}`; - document.getElementById('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`; - document.getElementById('statusText').textContent = unitStatus.status || 'Unknown'; + // If unit is not deployed (benched), show gray "Benched" status instead of health status + if (!currentUnit.deployed) { + document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500'; + document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400'; + document.getElementById('statusText').textContent = 'Benched'; + } else { + document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors[unitStatus.status] || 'bg-gray-400'}`; + document.getElementById('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`; + document.getElementById('statusText').textContent = unitStatus.status || 'Unknown'; + } // Format "Last Seen" with timezone-aware formatting if (unitStatus.last && typeof formatFullTimestamp === 'function') { @@ -704,7 +711,8 @@ function populateViewMode() { } else { document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400'; document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400'; - document.getElementById('statusText').textContent = 'No status data'; + // Show "Benched" if not deployed, otherwise "No status data" + document.getElementById('statusText').textContent = !currentUnit.deployed ? 'Benched' : 'No status data'; document.getElementById('lastSeen').textContent = '--'; document.getElementById('age').textContent = '--'; } From 24da5ab79f23ca8f1dea8b62ebab9c42f4f30ea9 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Mon, 2 Feb 2026 21:15:27 +0000 Subject: [PATCH 08/10] add: Modem model # now its own config. allowing for different options on different model #s --- templates/dashboard.html | 5 +- templates/partials/projects/file_list.html | 188 +++++++++++++++++++++ templates/roster.html | 16 +- templates/unit_detail.html | 118 +++++++++++-- 4 files changed, 307 insertions(+), 20 deletions(-) create mode 100644 templates/partials/projects/file_list.html diff --git a/templates/dashboard.html b/templates/dashboard.html index 08ba780..e153d5b 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -604,8 +604,9 @@ function updateFleetMapFiltered(allUnits) { } }); - // Fit bounds if we have markers - if (bounds.length > 0) { + // Only fit bounds on initial load, not on subsequent updates + // This preserves the user's current map view when auto-refreshing + if (bounds.length > 0 && !fleetMapInitialized) { const padding = window.innerWidth < 768 ? [20, 20] : [50, 50]; fleetMap.fitBounds(bounds, { padding: padding }); fleetMapInitialized = true; diff --git a/templates/partials/projects/file_list.html b/templates/partials/projects/file_list.html new file mode 100644 index 0000000..979a8ed --- /dev/null +++ b/templates/partials/projects/file_list.html @@ -0,0 +1,188 @@ + +{% if files %} +
+ {% for file_data in files %} + {% set file = file_data.file %} + {% set session = file_data.session %} + +
+ + {% if file.file_type == 'audio' %} + + + + {% elif file.file_type == 'archive' %} + + + + {% elif file.file_type == 'log' %} + + + + {% elif file.file_type == 'image' %} + + + + {% elif file.file_type == 'measurement' %} + + + + {% else %} + + + + {% endif %} + + +
+
+
+ {{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }} +
+
+
+ + + {{ file.file_type or 'unknown' }} + + + {# Leq vs Lp badge for RND files #} + {% if file.file_path and '_Leq_' in file.file_path %} + + Leq (15-min avg) + + {% elif file.file_path and '_Lp' in file.file_path and file.file_path.endswith('.rnd') %} + + Lp (instant) + + {% endif %} + + + + {% if file.file_size_bytes %} + {% if file.file_size_bytes < 1024 %} + {{ file.file_size_bytes }} B + {% elif file.file_size_bytes < 1048576 %} + {{ "%.1f"|format(file.file_size_bytes / 1024) }} KB + {% elif file.file_size_bytes < 1073741824 %} + {{ "%.1f"|format(file.file_size_bytes / 1048576) }} MB + {% else %} + {{ "%.2f"|format(file.file_size_bytes / 1073741824) }} GB + {% endif %} + {% else %} + Unknown size + {% endif %} + + + {% if session %} + + Session: {{ session.started_at|local_datetime if session.started_at else 'Unknown' }} + {% endif %} + + + {% if file.downloaded_at %} + + {{ file.downloaded_at|local_datetime }} + {% endif %} + + + {% if file.checksum %} + + + + + + {% endif %} +
+
+ + +
+ {% if file.file_type == 'measurement' or (file.file_path and file.file_path.endswith('.rnd')) %} + + + + + View + + {% endif %} + {# Only show Report button for Leq files #} + {% if file.file_path and '_Leq_' in file.file_path %} + + + + + Report + + {% endif %} + + +
+
+ {% endfor %} +
+{% else %} + +
+ + + +

No files downloaded yet

+

+ Files will appear here once they are downloaded from the sound level meter +

+
+{% endif %} + + diff --git a/templates/roster.html b/templates/roster.html index 157baf8..df989b7 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -178,8 +178,13 @@
- + + + + +
@@ -351,8 +356,13 @@
- + + + + +
@@ -913,7 +923,7 @@ // Modem fields document.getElementById('editIpAddress').value = unit.ip_address; document.getElementById('editPhoneNumber').value = unit.phone_number; - document.getElementById('editHardwareModel').value = unit.hardware_model; + document.getElementById('editHardwareModel').value = unit.hardware_model || ''; document.getElementById('editDeploymentType').value = unit.deployment_type || ''; // Populate unit picker for modem (uses -edit-modem suffix) diff --git a/templates/unit_detail.html b/templates/unit_detail.html index ae6dd78..2958b88 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -182,6 +182,42 @@
+ + + @@ -375,8 +411,13 @@
- + + + + +
@@ -434,7 +475,7 @@ {% set input_name = "deployed_with_modem_id" %} {% include "partials/modem_picker.html" with context %}
-