From 8373cff10daf4608af67a7cf067326fca8f8ebe2 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Thu, 29 Jan 2026 23:04:18 +0000 Subject: [PATCH] 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 %}