feat: enhance roster unit management with bidirectional pairing sync
fix: scheduler one off
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user