feat: enhance roster unit management with bidirectional pairing sync

fix: scheduler one off
This commit is contained in:
serversdwn
2026-02-11 20:13:27 +00:00
parent 842e9d6f61
commit ebe60d2b7d
3 changed files with 144 additions and 12 deletions

View File

@@ -237,7 +237,7 @@ async def add_roster_unit(
slm_measurement_range=slm_measurement_range if slm_measurement_range else None, 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: if deployed_with_modem_id:
modem = db.query(RosterUnit).filter( modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id, RosterUnit.id == deployed_with_modem_id,
@@ -252,6 +252,24 @@ async def add_roster_unit(
unit.coordinates = modem.coordinates unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id: if not unit.project_id and modem.project_id:
unit.project_id = 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.add(unit)
db.commit() db.commit()
@@ -564,7 +582,7 @@ async def edit_roster_unit(
unit.next_calibration_due = next_cal_date unit.next_calibration_due = next_cal_date
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None 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: if deployed_with_modem_id:
modem = db.query(RosterUnit).filter( modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id, RosterUnit.id == deployed_with_modem_id,
@@ -580,6 +598,8 @@ async def edit_roster_unit(
unit.coordinates = modem.coordinates unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id: if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id unit.project_id = modem.project_id
if not unit.note and modem.note:
unit.note = modem.note
# Modem-specific fields # Modem-specific fields
unit.ip_address = ip_address if ip_address else None 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_time_weighting = slm_time_weighting if slm_time_weighting else None
unit.slm_measurement_range = slm_measurement_range if slm_measurement_range 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 # Record history entries for changed fields
if old_note != note: if old_note != note:
record_history(db, unit_id, "note_change", "note", old_note, note, "manual") record_history(db, unit_id, "note_change", "note", old_note, note, "manual")

View File

@@ -112,6 +112,69 @@ class SLMMClient:
error_msg = str(e) if str(e) else type(e).__name__ error_msg = str(e) if str(e) else type(e).__name__
raise SLMMClientError(f"Unexpected error: {error_msg}") 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 # Unit Management
# ======================================================================== # ========================================================================
@@ -537,10 +600,13 @@ class SLMMClient:
remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav") remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav")
Returns: Returns:
Binary file content (as response) Dict with local_path, filename, size_bytes
""" """
data = {"remote_path": remote_path} return await self._download_request(
return await self._request("POST", f"/{unit_id}/ftp/download", data=data) f"/{unit_id}/ftp/download",
{"remote_path": remote_path},
unit_id,
)
async def download_folder( async def download_folder(
self, self,
@@ -557,10 +623,13 @@ class SLMMClient:
remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000") remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000")
Returns: 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._download_request(
return await self._request("POST", f"/{unit_id}/ftp/download-folder", data=data) f"/{unit_id}/ftp/download-folder",
{"remote_path": remote_path},
unit_id,
)
async def download_current_measurement( async def download_current_measurement(
self, self,

View File

@@ -36,8 +36,7 @@
<input type="datetime-local" <input type="datetime-local"
name="start_datetime" name="start_datetime"
id="oneoff_start_datetime" id="oneoff_start_datetime"
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" 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">
required>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2"> <p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
Must be in the future Must be in the future
</p> </p>
@@ -51,8 +50,7 @@
<input type="datetime-local" <input type="datetime-local"
name="end_datetime" name="end_datetime"
id="oneoff_end_datetime" id="oneoff_end_datetime"
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" 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">
required>
</div> </div>
<!-- Duration preview --> <!-- Duration preview -->