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 %}
No device currently paired
This modem is available for assignment
Loading devices...
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 = 'Pairing device...
${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...
${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" %} + +Select a seismograph or SLM to pair
+