From ebe60d2b7d4e1f7d9205230ec4b57ec4c5d6380e Mon Sep 17 00:00:00 2001 From: serversdwn Date: Wed, 11 Feb 2026 20:13:27 +0000 Subject: [PATCH] feat: enhance roster unit management with bidirectional pairing sync fix: scheduler one off --- backend/routers/roster_edit.py | 69 +++++++++++++++- backend/services/slmm_client.py | 81 +++++++++++++++++-- .../partials/projects/schedule_oneoff.html | 6 +- 3 files changed, 144 insertions(+), 12 deletions(-) diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index 0c2d974..e4083f7 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -237,7 +237,7 @@ async def add_roster_unit( slm_measurement_range=slm_measurement_range if slm_measurement_range else None, ) - # Auto-fill location data from modem if pairing and fields are empty + # Auto-fill data from modem if pairing and fields are empty if deployed_with_modem_id: modem = db.query(RosterUnit).filter( RosterUnit.id == deployed_with_modem_id, @@ -252,6 +252,24 @@ async def add_roster_unit( unit.coordinates = modem.coordinates if not unit.project_id and modem.project_id: unit.project_id = modem.project_id + if not unit.note and modem.note: + unit.note = modem.note + + # Bidirectional pairing sync for new units + if device_type in ("seismograph", "slm") and deployed_with_modem_id: + modem_to_update = db.query(RosterUnit).filter( + RosterUnit.id == deployed_with_modem_id, + RosterUnit.device_type == "modem" + ).first() + if modem_to_update: + # Clear old device's reference if modem was paired elsewhere + if modem_to_update.deployed_with_unit_id and modem_to_update.deployed_with_unit_id != id: + old_device = db.query(RosterUnit).filter( + RosterUnit.id == modem_to_update.deployed_with_unit_id + ).first() + if old_device and old_device.deployed_with_modem_id == deployed_with_modem_id: + old_device.deployed_with_modem_id = None + modem_to_update.deployed_with_unit_id = id db.add(unit) db.commit() @@ -564,7 +582,7 @@ async def edit_roster_unit( unit.next_calibration_due = next_cal_date unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None - # Auto-fill location data from modem if pairing and fields are empty + # Auto-fill data from modem if pairing and fields are empty if deployed_with_modem_id: modem = db.query(RosterUnit).filter( RosterUnit.id == deployed_with_modem_id, @@ -580,6 +598,8 @@ async def edit_roster_unit( unit.coordinates = modem.coordinates if not unit.project_id and modem.project_id: unit.project_id = modem.project_id + if not unit.note and modem.note: + unit.note = modem.note # Modem-specific fields unit.ip_address = ip_address if ip_address else None @@ -598,6 +618,51 @@ async def edit_roster_unit( unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None + # Bidirectional pairing sync + new_modem_id = deployed_with_modem_id if deployed_with_modem_id else None + new_unit_pair_id = deployed_with_unit_id if deployed_with_unit_id else None + + # When a device (seismograph/SLM) sets deployed_with_modem_id, update modem's deployed_with_unit_id + if device_type in ("seismograph", "slm"): + # Clear old modem's reference if modem changed + old_modem_id = db.query(RosterUnit.deployed_with_modem_id).filter( + RosterUnit.id == unit_id + ).scalar() + # old_modem_id is already the new value at this point since we set it above, + # but we need to check the *previous* modem. We already set it, so check if + # there's a modem pointing to us that we're no longer paired with. + if new_modem_id: + modem_to_update = db.query(RosterUnit).filter( + RosterUnit.id == new_modem_id, + RosterUnit.device_type == "modem" + ).first() + if modem_to_update and modem_to_update.deployed_with_unit_id != unit_id: + # Clear old device's reference to this modem if modem was paired elsewhere + if modem_to_update.deployed_with_unit_id: + old_device = db.query(RosterUnit).filter( + RosterUnit.id == modem_to_update.deployed_with_unit_id + ).first() + if old_device and old_device.deployed_with_modem_id == new_modem_id: + old_device.deployed_with_modem_id = None + modem_to_update.deployed_with_unit_id = unit_id + + # When a modem sets deployed_with_unit_id, update device's deployed_with_modem_id + if device_type == "modem": + if new_unit_pair_id: + device_to_update = db.query(RosterUnit).filter( + RosterUnit.id == new_unit_pair_id, + RosterUnit.device_type.in_(["seismograph", "slm"]) + ).first() + if device_to_update and device_to_update.deployed_with_modem_id != unit_id: + # Clear old modem's reference to this device if device was paired elsewhere + if device_to_update.deployed_with_modem_id: + old_modem = db.query(RosterUnit).filter( + RosterUnit.id == device_to_update.deployed_with_modem_id + ).first() + if old_modem and old_modem.deployed_with_unit_id == new_unit_pair_id: + old_modem.deployed_with_unit_id = None + device_to_update.deployed_with_modem_id = unit_id + # Record history entries for changed fields if old_note != note: record_history(db, unit_id, "note_change", "note", old_note, note, "manual") diff --git a/backend/services/slmm_client.py b/backend/services/slmm_client.py index ec7ee57..b6b683e 100644 --- a/backend/services/slmm_client.py +++ b/backend/services/slmm_client.py @@ -112,6 +112,69 @@ class SLMMClient: error_msg = str(e) if str(e) else type(e).__name__ raise SLMMClientError(f"Unexpected error: {error_msg}") + async def _download_request( + self, + endpoint: str, + data: Dict[str, Any], + unit_id: str, + ) -> Dict[str, Any]: + """ + Make a download request to SLMM that returns binary file content (not JSON). + + Saves the file locally and returns metadata about the download. + """ + url = f"{self.api_base}{endpoint}" + + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client: + response = await client.post(url, json=data) + response.raise_for_status() + + # Determine filename from Content-Disposition header or generate one + content_disp = response.headers.get("content-disposition", "") + filename = None + if "filename=" in content_disp: + filename = content_disp.split("filename=")[-1].strip('" ') + + if not filename: + remote_path = data.get("remote_path", "download") + base = os.path.basename(remote_path.rstrip("/")) + filename = f"{base}.zip" if not base.endswith(".zip") else base + + # Save to local downloads directory + download_dir = os.path.join("data", "downloads", unit_id) + os.makedirs(download_dir, exist_ok=True) + local_path = os.path.join(download_dir, filename) + + with open(local_path, "wb") as f: + f.write(response.content) + + return { + "success": True, + "local_path": local_path, + "filename": filename, + "size_bytes": len(response.content), + } + + except httpx.ConnectError as e: + raise SLMMConnectionError( + f"Cannot connect to SLMM backend at {self.base_url}. " + f"Is SLMM running? Error: {str(e)}" + ) + except httpx.HTTPStatusError as e: + error_detail = "Unknown error" + try: + error_data = e.response.json() + error_detail = error_data.get("detail", str(error_data)) + except Exception: + error_detail = e.response.text or str(e) + raise SLMMDeviceError(f"SLMM download failed: {error_detail}") + except (SLMMConnectionError, SLMMDeviceError): + raise + except Exception as e: + error_msg = str(e) if str(e) else type(e).__name__ + raise SLMMClientError(f"Download error: {error_msg}") + # ======================================================================== # Unit Management # ======================================================================== @@ -537,10 +600,13 @@ class SLMMClient: remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav") Returns: - Binary file content (as response) + Dict with local_path, filename, size_bytes """ - data = {"remote_path": remote_path} - return await self._request("POST", f"/{unit_id}/ftp/download", data=data) + return await self._download_request( + f"/{unit_id}/ftp/download", + {"remote_path": remote_path}, + unit_id, + ) async def download_folder( self, @@ -557,10 +623,13 @@ class SLMMClient: remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000") Returns: - Dict with local_path, folder_name, file_count, zip_size_bytes + Dict with local_path, folder_name, size_bytes """ - data = {"remote_path": remote_path} - return await self._request("POST", f"/{unit_id}/ftp/download-folder", data=data) + return await self._download_request( + f"/{unit_id}/ftp/download-folder", + {"remote_path": remote_path}, + unit_id, + ) async def download_current_measurement( self, diff --git a/templates/partials/projects/schedule_oneoff.html b/templates/partials/projects/schedule_oneoff.html index 7453a1c..8b75a53 100644 --- a/templates/partials/projects/schedule_oneoff.html +++ b/templates/partials/projects/schedule_oneoff.html @@ -36,8 +36,7 @@ + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">

Must be in the future

@@ -51,8 +50,7 @@ + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">