Add: pair_devices.html template for device pairing interface
Fix: - Polling intervals for SLMM. -modem view now list - device pairing much improved. -various other tweaks through out UI.
This commit is contained in:
@@ -21,6 +21,7 @@ from backend.database import engine, Base, get_db
|
||||
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
from backend.models import IgnoredUnit
|
||||
from backend.utils.timezone import get_user_timezone
|
||||
|
||||
# Create database tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -223,6 +224,67 @@ async def modems_page(request: Request):
|
||||
return templates.TemplateResponse("modems.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/pair-devices", response_class=HTMLResponse)
|
||||
async def pair_devices_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Device pairing page - two-column layout for pairing recorders with modems.
|
||||
"""
|
||||
from backend.models import RosterUnit
|
||||
|
||||
# Get all non-retired recorders (seismographs and SLMs)
|
||||
recorders = db.query(RosterUnit).filter(
|
||||
RosterUnit.retired == False,
|
||||
RosterUnit.device_type.in_(["seismograph", "slm", None]) # None defaults to seismograph
|
||||
).order_by(RosterUnit.id).all()
|
||||
|
||||
# Get all non-retired modems
|
||||
modems = db.query(RosterUnit).filter(
|
||||
RosterUnit.retired == False,
|
||||
RosterUnit.device_type == "modem"
|
||||
).order_by(RosterUnit.id).all()
|
||||
|
||||
# Build existing pairings list
|
||||
pairings = []
|
||||
for recorder in recorders:
|
||||
if recorder.deployed_with_modem_id:
|
||||
modem = next((m for m in modems if m.id == recorder.deployed_with_modem_id), None)
|
||||
pairings.append({
|
||||
"recorder_id": recorder.id,
|
||||
"recorder_type": (recorder.device_type or "seismograph").upper(),
|
||||
"modem_id": recorder.deployed_with_modem_id,
|
||||
"modem_ip": modem.ip_address if modem else None
|
||||
})
|
||||
|
||||
# Convert to dicts for template
|
||||
recorders_data = [
|
||||
{
|
||||
"id": r.id,
|
||||
"device_type": r.device_type or "seismograph",
|
||||
"deployed": r.deployed,
|
||||
"deployed_with_modem_id": r.deployed_with_modem_id
|
||||
}
|
||||
for r in recorders
|
||||
]
|
||||
|
||||
modems_data = [
|
||||
{
|
||||
"id": m.id,
|
||||
"deployed": m.deployed,
|
||||
"deployed_with_unit_id": m.deployed_with_unit_id,
|
||||
"ip_address": m.ip_address,
|
||||
"phone_number": m.phone_number
|
||||
}
|
||||
for m in modems
|
||||
]
|
||||
|
||||
return templates.TemplateResponse("pair_devices.html", {
|
||||
"request": request,
|
||||
"recorders": recorders_data,
|
||||
"modems": modems_data,
|
||||
"pairings": pairings
|
||||
})
|
||||
|
||||
|
||||
@app.get("/projects", response_class=HTMLResponse)
|
||||
async def projects_page(request: Request):
|
||||
"""Projects management and overview"""
|
||||
@@ -587,6 +649,7 @@ async def devices_all_partial(request: Request):
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
@@ -610,6 +673,7 @@ async def devices_all_partial(request: Request):
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
@@ -633,6 +697,7 @@ async def devices_all_partial(request: Request):
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
@@ -656,6 +721,7 @@ async def devices_all_partial(request: Request):
|
||||
"last_calibrated": None,
|
||||
"next_calibration_due": None,
|
||||
"deployed_with_modem_id": None,
|
||||
"deployed_with_unit_id": None,
|
||||
"ip_address": None,
|
||||
"phone_number": None,
|
||||
"hardware_model": None,
|
||||
@@ -678,7 +744,8 @@ async def devices_all_partial(request: Request):
|
||||
return templates.TemplateResponse("partials/devices_table.html", {
|
||||
"request": request,
|
||||
"units": units_list,
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S"),
|
||||
"user_timezone": get_user_timezone()
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||
|
||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -456,7 +457,7 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/edit/{unit_id}")
|
||||
def edit_roster_unit(
|
||||
async def edit_roster_unit(
|
||||
unit_id: str,
|
||||
device_type: str = Form("seismograph"),
|
||||
unit_type: str = Form("series3"),
|
||||
@@ -662,6 +663,16 @@ def edit_roster_unit(
|
||||
|
||||
db.commit()
|
||||
|
||||
# Sync SLM polling config to SLMM when deployed/retired status changes
|
||||
# This ensures benched units stop being polled
|
||||
if device_type == "slm" and (old_deployed != deployed_bool or old_retired != retired_bool):
|
||||
db.refresh(unit) # Refresh to get committed values
|
||||
try:
|
||||
await sync_slm_to_slmm(unit)
|
||||
logger.info(f"Synced SLM {unit_id} polling config to SLMM (deployed={deployed_bool}, retired={retired_bool})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
|
||||
|
||||
response = {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||
if cascaded_unit_id:
|
||||
response["cascaded_to"] = cascaded_unit_id
|
||||
@@ -669,7 +680,7 @@ def edit_roster_unit(
|
||||
|
||||
|
||||
@router.post("/set-deployed/{unit_id}")
|
||||
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||
async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
old_deployed = unit.deployed
|
||||
unit.deployed = deployed
|
||||
@@ -690,11 +701,21 @@ def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Sync SLM polling config to SLMM when deployed status changes
|
||||
if unit.device_type == "slm" and old_deployed != deployed:
|
||||
db.refresh(unit)
|
||||
try:
|
||||
await sync_slm_to_slmm(unit)
|
||||
logger.info(f"Synced SLM {unit_id} polling config to SLMM (deployed={deployed})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
|
||||
|
||||
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
||||
|
||||
|
||||
@router.post("/set-retired/{unit_id}")
|
||||
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||
async def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
old_retired = unit.retired
|
||||
unit.retired = retired
|
||||
@@ -715,6 +736,16 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Sync SLM polling config to SLMM when retired status changes
|
||||
if unit.device_type == "slm" and old_retired != retired:
|
||||
db.refresh(unit)
|
||||
try:
|
||||
await sync_slm_to_slmm(unit)
|
||||
logger.info(f"Synced SLM {unit_id} polling config to SLMM (retired={retired})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
|
||||
|
||||
return {"message": "Updated", "id": unit_id, "retired": retired}
|
||||
|
||||
|
||||
@@ -1156,3 +1187,145 @@ def delete_history_entry(history_id: int, db: Session = Depends(get_db)):
|
||||
db.delete(history_entry)
|
||||
db.commit()
|
||||
return {"message": "History entry deleted", "id": history_id}
|
||||
|
||||
|
||||
@router.post("/pair-devices")
|
||||
async def pair_devices(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a bidirectional pairing between a recorder (seismograph/SLM) and a modem.
|
||||
|
||||
Sets:
|
||||
- recorder.deployed_with_modem_id = modem_id
|
||||
- modem.deployed_with_unit_id = recorder_id
|
||||
|
||||
Also clears any previous pairings for both devices.
|
||||
"""
|
||||
data = await request.json()
|
||||
recorder_id = data.get("recorder_id")
|
||||
modem_id = data.get("modem_id")
|
||||
|
||||
if not recorder_id or not modem_id:
|
||||
raise HTTPException(status_code=400, detail="Both recorder_id and modem_id are required")
|
||||
|
||||
# Get or create the units
|
||||
recorder = db.query(RosterUnit).filter(RosterUnit.id == recorder_id).first()
|
||||
modem = db.query(RosterUnit).filter(RosterUnit.id == modem_id).first()
|
||||
|
||||
if not recorder:
|
||||
raise HTTPException(status_code=404, detail=f"Recorder {recorder_id} not found in roster")
|
||||
if not modem:
|
||||
raise HTTPException(status_code=404, detail=f"Modem {modem_id} not found in roster")
|
||||
|
||||
# Validate device types
|
||||
if recorder.device_type == "modem":
|
||||
raise HTTPException(status_code=400, detail=f"{recorder_id} is a modem, not a recorder")
|
||||
if modem.device_type != "modem":
|
||||
raise HTTPException(status_code=400, detail=f"{modem_id} is not a modem (type: {modem.device_type})")
|
||||
|
||||
# Clear any previous pairings
|
||||
# If recorder was paired with a different modem, clear that modem's link
|
||||
if recorder.deployed_with_modem_id and recorder.deployed_with_modem_id != modem_id:
|
||||
old_modem = db.query(RosterUnit).filter(RosterUnit.id == recorder.deployed_with_modem_id).first()
|
||||
if old_modem and old_modem.deployed_with_unit_id == recorder_id:
|
||||
record_history(db, old_modem.id, "update", "deployed_with_unit_id",
|
||||
old_modem.deployed_with_unit_id, None, "pair_devices", f"Cleared by new pairing")
|
||||
old_modem.deployed_with_unit_id = None
|
||||
|
||||
# If modem was paired with a different recorder, clear that recorder's link
|
||||
if modem.deployed_with_unit_id and modem.deployed_with_unit_id != recorder_id:
|
||||
old_recorder = db.query(RosterUnit).filter(RosterUnit.id == modem.deployed_with_unit_id).first()
|
||||
if old_recorder and old_recorder.deployed_with_modem_id == modem_id:
|
||||
record_history(db, old_recorder.id, "update", "deployed_with_modem_id",
|
||||
old_recorder.deployed_with_modem_id, None, "pair_devices", f"Cleared by new pairing")
|
||||
old_recorder.deployed_with_modem_id = None
|
||||
|
||||
# Record history for the pairing
|
||||
old_recorder_modem = recorder.deployed_with_modem_id
|
||||
old_modem_unit = modem.deployed_with_unit_id
|
||||
|
||||
# Set the new pairing
|
||||
recorder.deployed_with_modem_id = modem_id
|
||||
modem.deployed_with_unit_id = recorder_id
|
||||
|
||||
# Record history
|
||||
if old_recorder_modem != modem_id:
|
||||
record_history(db, recorder_id, "update", "deployed_with_modem_id",
|
||||
old_recorder_modem, modem_id, "pair_devices", f"Paired with modem")
|
||||
if old_modem_unit != recorder_id:
|
||||
record_history(db, modem_id, "update", "deployed_with_unit_id",
|
||||
old_modem_unit, recorder_id, "pair_devices", f"Paired with recorder")
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Paired {recorder_id} with modem {modem_id}")
|
||||
|
||||
# If SLM, sync to SLMM cache
|
||||
if recorder.device_type == "slm":
|
||||
await sync_slm_to_slmm_cache(
|
||||
unit_id=recorder_id,
|
||||
host=recorder.slm_host,
|
||||
tcp_port=recorder.slm_tcp_port,
|
||||
ftp_port=recorder.slm_ftp_port,
|
||||
deployed_with_modem_id=modem_id,
|
||||
db=db
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Paired {recorder_id} with {modem_id}",
|
||||
"recorder_id": recorder_id,
|
||||
"modem_id": modem_id
|
||||
}
|
||||
|
||||
|
||||
@router.post("/unpair-devices")
|
||||
async def unpair_devices(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Remove the bidirectional pairing between a recorder and modem.
|
||||
|
||||
Clears:
|
||||
- recorder.deployed_with_modem_id
|
||||
- modem.deployed_with_unit_id
|
||||
"""
|
||||
data = await request.json()
|
||||
recorder_id = data.get("recorder_id")
|
||||
modem_id = data.get("modem_id")
|
||||
|
||||
if not recorder_id or not modem_id:
|
||||
raise HTTPException(status_code=400, detail="Both recorder_id and modem_id are required")
|
||||
|
||||
recorder = db.query(RosterUnit).filter(RosterUnit.id == recorder_id).first()
|
||||
modem = db.query(RosterUnit).filter(RosterUnit.id == modem_id).first()
|
||||
|
||||
changes_made = False
|
||||
|
||||
if recorder and recorder.deployed_with_modem_id == modem_id:
|
||||
record_history(db, recorder_id, "update", "deployed_with_modem_id",
|
||||
recorder.deployed_with_modem_id, None, "unpair_devices", "Unpairing")
|
||||
recorder.deployed_with_modem_id = None
|
||||
changes_made = True
|
||||
|
||||
if modem and modem.deployed_with_unit_id == recorder_id:
|
||||
record_history(db, modem_id, "update", "deployed_with_unit_id",
|
||||
modem.deployed_with_unit_id, None, "unpair_devices", "Unpairing")
|
||||
modem.deployed_with_unit_id = None
|
||||
changes_made = True
|
||||
|
||||
if changes_made:
|
||||
db.commit()
|
||||
logger.info(f"Unpaired {recorder_id} from modem {modem_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Unpaired {recorder_id} from {modem_id}"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No pairing found between these devices"
|
||||
}
|
||||
|
||||
@@ -289,6 +289,74 @@ class DeviceController:
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
# ========================================================================
|
||||
# FTP Control
|
||||
# ========================================================================
|
||||
|
||||
async def enable_ftp(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Enable FTP server on device.
|
||||
|
||||
Must be called before downloading files.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
|
||||
Returns:
|
||||
Response dict with status
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.enable_ftp(unit_id)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph FTP not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
async def disable_ftp(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Disable FTP server on device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
|
||||
Returns:
|
||||
Response dict with status
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.disable_ftp(unit_id)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph FTP not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
# ========================================================================
|
||||
# Device Configuration
|
||||
# ========================================================================
|
||||
|
||||
@@ -350,7 +350,14 @@ class SchedulerService:
|
||||
unit_id: str,
|
||||
db: Session,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a 'download' action."""
|
||||
"""Execute a 'download' action.
|
||||
|
||||
This handles standalone download actions (not part of stop_cycle).
|
||||
The workflow is:
|
||||
1. Enable FTP on device
|
||||
2. Download current measurement folder
|
||||
3. (Optionally disable FTP - left enabled for now)
|
||||
"""
|
||||
# Get project and location info for file path
|
||||
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
|
||||
project = db.query(Project).filter_by(id=action.project_id).first()
|
||||
@@ -358,8 +365,8 @@ class SchedulerService:
|
||||
if not location or not project:
|
||||
raise Exception("Project or location not found")
|
||||
|
||||
# Build destination path
|
||||
# Example: data/Projects/{project-id}/sound/{location-name}/session-{timestamp}/
|
||||
# Build destination path (for logging/metadata reference)
|
||||
# Actual download location is managed by SLMM (data/downloads/{unit_id}/)
|
||||
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
|
||||
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
|
||||
|
||||
@@ -368,12 +375,18 @@ class SchedulerService:
|
||||
f"{location.name}/session-{session_timestamp}/"
|
||||
)
|
||||
|
||||
# Download files via device controller
|
||||
# Step 1: Enable FTP on device
|
||||
logger.info(f"Enabling FTP on {unit_id} for download")
|
||||
await self.device_controller.enable_ftp(unit_id, action.device_type)
|
||||
|
||||
# Step 2: Download current measurement folder
|
||||
# The slmm_client.download_files() now automatically determines the correct
|
||||
# folder based on the device's current index number
|
||||
response = await self.device_controller.download_files(
|
||||
unit_id,
|
||||
action.device_type,
|
||||
destination_path,
|
||||
files=None, # Download all files
|
||||
files=None, # Download all files in current measurement folder
|
||||
)
|
||||
|
||||
# TODO: Create DataFile records for downloaded files
|
||||
|
||||
@@ -478,9 +478,118 @@ class SLMMClient:
|
||||
return await self._request("GET", f"/{unit_id}/settings")
|
||||
|
||||
# ========================================================================
|
||||
# Data Download (Future)
|
||||
# FTP Control
|
||||
# ========================================================================
|
||||
|
||||
async def enable_ftp(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Enable FTP server on device.
|
||||
|
||||
Must be called before downloading files. FTP and TCP can work in tandem.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Dict with status message
|
||||
"""
|
||||
return await self._request("POST", f"/{unit_id}/ftp/enable")
|
||||
|
||||
async def disable_ftp(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Disable FTP server on device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Dict with status message
|
||||
"""
|
||||
return await self._request("POST", f"/{unit_id}/ftp/disable")
|
||||
|
||||
async def get_ftp_status(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get FTP server status on device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Dict with ftp_enabled status
|
||||
"""
|
||||
return await self._request("GET", f"/{unit_id}/ftp/status")
|
||||
|
||||
# ========================================================================
|
||||
# Data Download
|
||||
# ========================================================================
|
||||
|
||||
async def download_file(
|
||||
self,
|
||||
unit_id: str,
|
||||
remote_path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download a single file from unit via FTP.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav")
|
||||
|
||||
Returns:
|
||||
Binary file content (as response)
|
||||
"""
|
||||
data = {"remote_path": remote_path}
|
||||
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
|
||||
|
||||
async def download_folder(
|
||||
self,
|
||||
unit_id: str,
|
||||
remote_path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download an entire folder from unit via FTP as a ZIP archive.
|
||||
|
||||
Useful for downloading complete measurement sessions (e.g., Auto_0000 folders).
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
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
|
||||
"""
|
||||
data = {"remote_path": remote_path}
|
||||
return await self._request("POST", f"/{unit_id}/ftp/download-folder", data=data)
|
||||
|
||||
async def download_current_measurement(
|
||||
self,
|
||||
unit_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download the current measurement folder based on device's index number.
|
||||
|
||||
This is the recommended method for scheduled downloads - it automatically
|
||||
determines which folder to download based on the device's current store index.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Dict with local_path, folder_name, file_count, zip_size_bytes, index_number
|
||||
"""
|
||||
# Get current index number from device
|
||||
index_info = await self.get_index_number(unit_id)
|
||||
index_number = index_info.get("index_number", 0)
|
||||
|
||||
# Format as Auto_XXXX folder name
|
||||
folder_name = f"Auto_{index_number:04d}"
|
||||
remote_path = f"/NL43_DATA/{folder_name}"
|
||||
|
||||
# Download the folder
|
||||
result = await self.download_folder(unit_id, remote_path)
|
||||
result["index_number"] = index_number
|
||||
return result
|
||||
|
||||
async def download_files(
|
||||
self,
|
||||
unit_id: str,
|
||||
@@ -488,23 +597,24 @@ class SLMMClient:
|
||||
files: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download files from unit via FTP.
|
||||
Download measurement files from unit via FTP.
|
||||
|
||||
NOTE: This endpoint doesn't exist in SLMM yet. Will need to implement.
|
||||
This method automatically determines the current measurement folder and downloads it.
|
||||
The destination_path parameter is logged for reference but actual download location
|
||||
is managed by SLMM (data/downloads/{unit_id}/).
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
destination_path: Local path to save files
|
||||
files: List of filenames to download, or None for all
|
||||
destination_path: Reference path (for logging/metadata, not used by SLMM)
|
||||
files: Ignored - always downloads the current measurement folder
|
||||
|
||||
Returns:
|
||||
Dict with downloaded files list and metadata
|
||||
Dict with download result including local_path, folder_name, etc.
|
||||
"""
|
||||
data = {
|
||||
"destination_path": destination_path,
|
||||
"files": files or "all",
|
||||
}
|
||||
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
|
||||
# Use the new method that automatically determines what to download
|
||||
result = await self.download_current_measurement(unit_id)
|
||||
result["requested_destination"] = destination_path
|
||||
return result
|
||||
|
||||
# ========================================================================
|
||||
# Cycle Commands (for scheduled automation)
|
||||
|
||||
@@ -36,6 +36,10 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
|
||||
logger.warning(f"SLM {unit.id} has no host configured, skipping SLMM sync")
|
||||
return False
|
||||
|
||||
# Disable polling if unit is benched (deployed=False) or retired
|
||||
# Only actively deployed units should be polled
|
||||
should_poll = unit.deployed and not unit.retired
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.put(
|
||||
@@ -47,8 +51,8 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
|
||||
"ftp_enabled": True,
|
||||
"ftp_username": "USER", # Default NL43 credentials
|
||||
"ftp_password": "0000",
|
||||
"poll_enabled": not unit.retired, # Disable polling for retired units
|
||||
"poll_interval_seconds": 60, # Default interval
|
||||
"poll_enabled": should_poll, # Disable polling for benched or retired units
|
||||
"poll_interval_seconds": 3600, # Default to 1 hour polling
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ def emit_status_snapshot():
|
||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
|
||||
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||
"deployed_with_unit_id": r.deployed_with_unit_id,
|
||||
"ip_address": r.ip_address,
|
||||
"phone_number": r.phone_number,
|
||||
"hardware_model": r.hardware_model,
|
||||
@@ -137,6 +138,7 @@ def emit_status_snapshot():
|
||||
"last_calibrated": None,
|
||||
"next_calibration_due": None,
|
||||
"deployed_with_modem_id": None,
|
||||
"deployed_with_unit_id": None,
|
||||
"ip_address": None,
|
||||
"phone_number": None,
|
||||
"hardware_model": None,
|
||||
|
||||
Reference in New Issue
Block a user