merge dev 0.6.1 to main. #27

Merged
serversdown merged 3 commits from dev into main 2026-02-15 23:44:19 -05:00
3 changed files with 144 additions and 12 deletions
Showing only changes of commit ebe60d2b7d - Show all commits

View File

@@ -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")

View File

@@ -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,

View File

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