diff --git a/backend/main.py b/backend/main.py index c09adbd..c4f77e4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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() }) diff --git a/backend/models.py b/backend/models.py index bd22b0c..41a9c4c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -229,7 +229,7 @@ class ScheduledAction(Base): location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable if location-based) - action_type = Column(String, nullable=False) # start, stop, download, calibrate + action_type = Column(String, nullable=False) # start, stop, download, cycle, calibrate device_type = Column(String, nullable=False) # "slm" | "seismograph" scheduled_time = Column(DateTime, nullable=False, index=True) diff --git a/backend/routers/modem_dashboard.py b/backend/routers/modem_dashboard.py index a4d13c5..ed8b789 100644 --- a/backend/routers/modem_dashboard.py +++ b/backend/routers/modem_dashboard.py @@ -284,3 +284,146 @@ async def get_modem_diagnostics(modem_id: str, db: Session = Depends(get_db)): "carrier": None, "connection_type": None # LTE, 5G, etc. } + + +@router.get("/{modem_id}/pairable-devices") +async def get_pairable_devices( + modem_id: str, + db: Session = Depends(get_db), + search: str = Query(None), + hide_paired: bool = Query(True) +): + """ + Get list of devices (seismographs and SLMs) that can be paired with this modem. + Used by the device picker modal in unit_detail.html. + """ + # Check modem exists + modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first() + if not modem: + return {"status": "error", "detail": f"Modem {modem_id} not found"} + + # Query seismographs and SLMs + query = db.query(RosterUnit).filter( + RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]), + RosterUnit.retired == False + ) + + # Filter by search term if provided + if search: + search_term = f"%{search}%" + query = query.filter( + (RosterUnit.id.ilike(search_term)) | + (RosterUnit.project_id.ilike(search_term)) | + (RosterUnit.location.ilike(search_term)) | + (RosterUnit.address.ilike(search_term)) | + (RosterUnit.note.ilike(search_term)) + ) + + devices = query.order_by( + RosterUnit.deployed.desc(), + RosterUnit.device_type.asc(), + RosterUnit.id.asc() + ).all() + + # Build device list + device_list = [] + for device in devices: + # Skip already paired devices if hide_paired is True + is_paired_to_other = ( + device.deployed_with_modem_id is not None and + device.deployed_with_modem_id != modem_id + ) + is_paired_to_this = device.deployed_with_modem_id == modem_id + + if hide_paired and is_paired_to_other: + continue + + device_list.append({ + "id": device.id, + "device_type": device.device_type, + "deployed": device.deployed, + "project_id": device.project_id, + "location": device.location or device.address, + "note": device.note, + "paired_modem_id": device.deployed_with_modem_id, + "is_paired_to_this": is_paired_to_this, + "is_paired_to_other": is_paired_to_other + }) + + return {"devices": device_list, "modem_id": modem_id} + + +@router.post("/{modem_id}/pair") +async def pair_device_to_modem( + modem_id: str, + db: Session = Depends(get_db), + device_id: str = Query(..., description="ID of the device to pair") +): + """ + Pair a device (seismograph or SLM) to this modem. + Updates the device's deployed_with_modem_id field. + """ + # Check modem exists + modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first() + if not modem: + return {"status": "error", "detail": f"Modem {modem_id} not found"} + + # Find the device + device = db.query(RosterUnit).filter( + RosterUnit.id == device_id, + RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]), + RosterUnit.retired == False + ).first() + if not device: + return {"status": "error", "detail": f"Device {device_id} not found"} + + # Unpair any device currently paired to this modem + currently_paired = db.query(RosterUnit).filter( + RosterUnit.deployed_with_modem_id == modem_id + ).all() + for paired_device in currently_paired: + paired_device.deployed_with_modem_id = None + + # Pair the new device + device.deployed_with_modem_id = modem_id + db.commit() + + return { + "status": "success", + "modem_id": modem_id, + "device_id": device_id, + "message": f"Device {device_id} paired to modem {modem_id}" + } + + +@router.post("/{modem_id}/unpair") +async def unpair_device_from_modem(modem_id: str, db: Session = Depends(get_db)): + """ + Unpair any device currently paired to this modem. + """ + # Check modem exists + modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first() + if not modem: + return {"status": "error", "detail": f"Modem {modem_id} not found"} + + # Find and unpair device + device = db.query(RosterUnit).filter( + RosterUnit.deployed_with_modem_id == modem_id + ).first() + + if device: + old_device_id = device.id + device.deployed_with_modem_id = None + db.commit() + return { + "status": "success", + "modem_id": modem_id, + "unpaired_device_id": old_device_id, + "message": f"Device {old_device_id} unpaired from modem {modem_id}" + } + + return { + "status": "success", + "modem_id": modem_id, + "message": "No device was paired to this modem" + } diff --git a/backend/routers/recurring_schedules.py b/backend/routers/recurring_schedules.py index b784c5d..9a992c4 100644 --- a/backend/routers/recurring_schedules.py +++ b/backend/routers/recurring_schedules.py @@ -330,19 +330,35 @@ async def disable_schedule( db: Session = Depends(get_db), ): """ - Disable a schedule. + Disable a schedule and cancel all its pending actions. """ service = get_recurring_schedule_service(db) + + # Count pending actions before disabling (for response message) + from sqlalchemy import and_ + from backend.models import ScheduledAction + pending_count = db.query(ScheduledAction).filter( + and_( + ScheduledAction.execution_status == "pending", + ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'), + ) + ).count() + schedule = service.disable_schedule(schedule_id) if not schedule: raise HTTPException(status_code=404, detail="Schedule not found") + message = "Schedule disabled" + if pending_count > 0: + message += f" and {pending_count} pending action(s) cancelled" + return { "success": True, "schedule_id": schedule.id, "enabled": schedule.enabled, - "message": "Schedule disabled", + "cancelled_actions": pending_count, + "message": message, } diff --git a/backend/routers/roster.py b/backend/routers/roster.py index d2792e1..101fd02 100644 --- a/backend/routers/roster.py +++ b/backend/routers/roster.py @@ -2,20 +2,32 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from datetime import datetime, timedelta from typing import Dict, Any +import asyncio +import logging import random from backend.database import get_db from backend.services.snapshot import emit_status_snapshot +from backend.services.slm_status_sync import sync_slm_status_to_emitters router = APIRouter(prefix="/api", tags=["roster"]) +logger = logging.getLogger(__name__) @router.get("/status-snapshot") -def get_status_snapshot(db: Session = Depends(get_db)): +async def get_status_snapshot(db: Session = Depends(get_db)): """ Calls emit_status_snapshot() to get current fleet status. - This will be replaced with real Series3 emitter logic later. + Syncs SLM status from SLMM before generating snapshot. """ + # Sync SLM status from SLMM (with timeout to prevent blocking) + try: + await asyncio.wait_for(sync_slm_status_to_emitters(), timeout=2.0) + except asyncio.TimeoutError: + logger.warning("SLM status sync timed out, using cached data") + except Exception as e: + logger.warning(f"SLM status sync failed: {e}") + return emit_status_snapshot() diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index f8e6f7f..0f939b7 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -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" + } diff --git a/backend/routers/slm_dashboard.py b/backend/routers/slm_dashboard.py index be70cc2..3b93488 100644 --- a/backend/routers/slm_dashboard.py +++ b/backend/routers/slm_dashboard.py @@ -167,23 +167,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge measurement_state = state_data.get("measurement_state", "Unknown") is_measuring = state_data.get("is_measuring", False) - # If measuring, sync start time from FTP to database (fixes wrong timestamps) - if is_measuring: - try: - sync_response = await client.post( - f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time", - timeout=10.0 - ) - if sync_response.status_code == 200: - sync_data = sync_response.json() - logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}") - else: - logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}") - except Exception as e: - # Don't fail the whole request if sync fails - logger.warning(f"Could not sync start time for {unit_id}: {e}") - - # Get live status (now with corrected start time) + # Get live status (measurement_start_time is already stored in SLMM database) status_response = await client.get( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live" ) diff --git a/backend/services/alert_service.py b/backend/services/alert_service.py index f10ffd1..d85f6df 100644 --- a/backend/services/alert_service.py +++ b/backend/services/alert_service.py @@ -199,7 +199,7 @@ class AlertService: Args: schedule_id: The ScheduledAction or RecurringSchedule ID - action_type: start, stop, download + action_type: start, stop, download, cycle unit_id: Related unit error_message: Error from execution project_id: Related project @@ -235,7 +235,7 @@ class AlertService: Args: schedule_id: The ScheduledAction ID - action_type: start, stop, download + action_type: start, stop, download, cycle unit_id: Related unit project_id: Related project location_id: Related location diff --git a/backend/services/device_controller.py b/backend/services/device_controller.py index 82ae6fb..615cb32 100644 --- a/backend/services/device_controller.py +++ b/backend/services/device_controller.py @@ -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 # ======================================================================== diff --git a/backend/services/recurring_schedule_service.py b/backend/services/recurring_schedule_service.py index d4a8d83..3f53210 100644 --- a/backend/services/recurring_schedule_service.py +++ b/backend/services/recurring_schedule_service.py @@ -169,8 +169,25 @@ class RecurringScheduleService: return self.update_schedule(schedule_id, enabled=True) def disable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]: - """Disable a schedule.""" - return self.update_schedule(schedule_id, enabled=False) + """Disable a schedule and cancel its pending actions.""" + schedule = self.update_schedule(schedule_id, enabled=False) + if schedule: + # Cancel all pending actions generated by this schedule + pending_actions = self.db.query(ScheduledAction).filter( + and_( + ScheduledAction.execution_status == "pending", + ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'), + ) + ).all() + + for action in pending_actions: + action.execution_status = "cancelled" + + if pending_actions: + self.db.commit() + logger.info(f"Cancelled {len(pending_actions)} pending actions for disabled schedule {schedule.name}") + + return schedule def generate_actions_for_schedule( self, @@ -384,73 +401,33 @@ class RecurringScheduleService: if cycle_utc <= now_utc: continue - # Check if action already exists - if self._action_exists(schedule.project_id, schedule.location_id, "stop", cycle_utc): + # Check if cycle action already exists + if self._action_exists(schedule.project_id, schedule.location_id, "cycle", cycle_utc): continue - # Build notes with metadata - stop_notes = json.dumps({ + # Build notes with metadata for cycle action + cycle_notes = json.dumps({ "schedule_name": schedule.name, "schedule_id": schedule.id, "cycle_type": "daily", + "include_download": schedule.include_download, + "auto_increment_index": schedule.auto_increment_index, }) - # Create STOP action - stop_action = ScheduledAction( + # Create single CYCLE action that handles stop -> download -> start + # The scheduler's _execute_cycle method handles the full workflow with delays + cycle_action = ScheduledAction( id=str(uuid.uuid4()), project_id=schedule.project_id, location_id=schedule.location_id, unit_id=unit_id, - action_type="stop", + action_type="cycle", device_type=schedule.device_type, scheduled_time=cycle_utc, execution_status="pending", - notes=stop_notes, + notes=cycle_notes, ) - actions.append(stop_action) - - # Create DOWNLOAD action if enabled (1 minute after stop) - if schedule.include_download: - download_time = cycle_utc + timedelta(minutes=1) - download_notes = json.dumps({ - "schedule_name": schedule.name, - "schedule_id": schedule.id, - "cycle_type": "daily", - }) - download_action = ScheduledAction( - id=str(uuid.uuid4()), - project_id=schedule.project_id, - location_id=schedule.location_id, - unit_id=unit_id, - action_type="download", - device_type=schedule.device_type, - scheduled_time=download_time, - execution_status="pending", - notes=download_notes, - ) - actions.append(download_action) - - # Create START action (2 minutes after stop, or 1 minute after download) - start_offset = 2 if schedule.include_download else 1 - start_time = cycle_utc + timedelta(minutes=start_offset) - start_notes = json.dumps({ - "schedule_name": schedule.name, - "schedule_id": schedule.id, - "cycle_type": "daily", - "auto_increment_index": schedule.auto_increment_index, - }) - start_action = ScheduledAction( - id=str(uuid.uuid4()), - project_id=schedule.project_id, - location_id=schedule.location_id, - unit_id=unit_id, - action_type="start", - device_type=schedule.device_type, - scheduled_time=start_time, - execution_status="pending", - notes=start_notes, - ) - actions.append(start_action) + actions.append(cycle_action) return actions diff --git a/backend/services/scheduler.py b/backend/services/scheduler.py index 866ec64..f419a78 100644 --- a/backend/services/scheduler.py +++ b/backend/services/scheduler.py @@ -185,6 +185,8 @@ class SchedulerService: response = await self._execute_stop(action, unit_id, db) elif action.action_type == "download": response = await self._execute_download(action, unit_id, db) + elif action.action_type == "cycle": + response = await self._execute_cycle(action, unit_id, db) else: raise Exception(f"Unknown action type: {action.action_type}") @@ -350,7 +352,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 +367,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 +377,23 @@ class SchedulerService: f"{location.name}/session-{session_timestamp}/" ) - # Download files via device controller + # Step 1: Disable FTP first to reset any stale connection state + # Then enable FTP on device + logger.info(f"Resetting FTP on {unit_id} for download (disable then enable)") + try: + await self.device_controller.disable_ftp(unit_id, action.device_type) + except Exception as e: + logger.warning(f"FTP disable failed (may already be off): {e}") + 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 @@ -384,6 +404,200 @@ class SchedulerService: "device_response": response, } + async def _execute_cycle( + self, + action: ScheduledAction, + unit_id: str, + db: Session, + ) -> Dict[str, Any]: + """Execute a full 'cycle' action: stop -> download -> start. + + This combines stop, download, and start into a single action with + appropriate delays between steps to ensure device stability. + + Workflow: + 0. Pause background polling to prevent command conflicts + 1. Stop measurement (wait 10s) + 2. Disable FTP to reset state (wait 10s) + 3. Enable FTP (wait 10s) + 4. Download current measurement folder + 5. Wait 30s for device to settle + 6. Start new measurement cycle + 7. Re-enable background polling + + Total time: ~70-90 seconds depending on download size + """ + logger.info(f"[CYCLE] === Starting full cycle for {unit_id} ===") + + result = { + "status": "cycle_complete", + "steps": {}, + "old_session_id": None, + "new_session_id": None, + "polling_paused": False, + } + + # Step 0: Pause background polling for this device to prevent command conflicts + # NL-43 devices only support one TCP connection at a time + logger.info(f"[CYCLE] Step 0: Pausing background polling for {unit_id}") + polling_was_enabled = False + try: + if action.device_type == "slm": + # Get current polling state to restore later + from backend.services.slmm_client import get_slmm_client + slmm = get_slmm_client() + try: + polling_config = await slmm.get_device_polling_config(unit_id) + polling_was_enabled = polling_config.get("poll_enabled", False) + except Exception: + polling_was_enabled = True # Assume enabled if can't check + + # Disable polling during cycle + await slmm.update_device_polling_config(unit_id, poll_enabled=False) + result["polling_paused"] = True + logger.info(f"[CYCLE] Background polling paused for {unit_id}") + except Exception as e: + logger.warning(f"[CYCLE] Failed to pause polling (continuing anyway): {e}") + + try: + # Step 1: Stop measurement + logger.info(f"[CYCLE] Step 1/7: Stopping measurement on {unit_id}") + try: + stop_response = await self.device_controller.stop_recording(unit_id, action.device_type) + result["steps"]["stop"] = {"success": True, "response": stop_response} + logger.info(f"[CYCLE] Measurement stopped, waiting 10s...") + except Exception as e: + logger.warning(f"[CYCLE] Stop failed (may already be stopped): {e}") + result["steps"]["stop"] = {"success": False, "error": str(e)} + + await asyncio.sleep(10) + + # Step 2: Disable FTP to reset any stale state + logger.info(f"[CYCLE] Step 2/7: Disabling FTP on {unit_id}") + try: + await self.device_controller.disable_ftp(unit_id, action.device_type) + result["steps"]["ftp_disable"] = {"success": True} + logger.info(f"[CYCLE] FTP disabled, waiting 10s...") + except Exception as e: + logger.warning(f"[CYCLE] FTP disable failed (may already be off): {e}") + result["steps"]["ftp_disable"] = {"success": False, "error": str(e)} + + await asyncio.sleep(10) + + # Step 3: Enable FTP + logger.info(f"[CYCLE] Step 3/7: Enabling FTP on {unit_id}") + try: + await self.device_controller.enable_ftp(unit_id, action.device_type) + result["steps"]["ftp_enable"] = {"success": True} + logger.info(f"[CYCLE] FTP enabled, waiting 10s...") + except Exception as e: + logger.error(f"[CYCLE] FTP enable failed: {e}") + result["steps"]["ftp_enable"] = {"success": False, "error": str(e)} + # Continue anyway - download will fail but we can still try to start + + await asyncio.sleep(10) + + # Step 4: Download current measurement folder + logger.info(f"[CYCLE] Step 4/7: Downloading measurement data from {unit_id}") + location = db.query(MonitoringLocation).filter_by(id=action.location_id).first() + project = db.query(Project).filter_by(id=action.project_id).first() + + if location and project: + session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M") + location_type_dir = "sound" if action.device_type == "slm" else "vibration" + destination_path = ( + f"data/Projects/{project.id}/{location_type_dir}/" + f"{location.name}/session-{session_timestamp}/" + ) + + try: + download_response = await self.device_controller.download_files( + unit_id, + action.device_type, + destination_path, + files=None, + ) + result["steps"]["download"] = {"success": True, "response": download_response} + logger.info(f"[CYCLE] Download complete") + except Exception as e: + logger.error(f"[CYCLE] Download failed: {e}") + result["steps"]["download"] = {"success": False, "error": str(e)} + else: + result["steps"]["download"] = {"success": False, "error": "Project or location not found"} + + # Close out the old recording session + active_session = db.query(RecordingSession).filter( + and_( + RecordingSession.location_id == action.location_id, + RecordingSession.unit_id == unit_id, + RecordingSession.status == "recording", + ) + ).first() + + if active_session: + active_session.stopped_at = datetime.utcnow() + active_session.status = "completed" + active_session.duration_seconds = int( + (active_session.stopped_at - active_session.started_at).total_seconds() + ) + result["old_session_id"] = active_session.id + + # Step 5: Wait for device to settle before starting new measurement + logger.info(f"[CYCLE] Step 5/7: Waiting 30s for device to settle...") + await asyncio.sleep(30) + + # Step 6: Start new measurement cycle + logger.info(f"[CYCLE] Step 6/7: Starting new measurement on {unit_id}") + try: + cycle_response = await self.device_controller.start_cycle( + unit_id, + action.device_type, + sync_clock=True, + ) + result["steps"]["start"] = {"success": True, "response": cycle_response} + + # Create new recording session + new_session = RecordingSession( + id=str(uuid.uuid4()), + project_id=action.project_id, + location_id=action.location_id, + unit_id=unit_id, + session_type="sound" if action.device_type == "slm" else "vibration", + started_at=datetime.utcnow(), + status="recording", + session_metadata=json.dumps({ + "scheduled_action_id": action.id, + "cycle_response": cycle_response, + "action_type": "cycle", + }), + ) + db.add(new_session) + result["new_session_id"] = new_session.id + + logger.info(f"[CYCLE] New measurement started, session {new_session.id}") + + except Exception as e: + logger.error(f"[CYCLE] Start failed: {e}") + result["steps"]["start"] = {"success": False, "error": str(e)} + raise # Re-raise to mark the action as failed + + finally: + # Step 7: Re-enable background polling (always runs, even on failure) + if result.get("polling_paused") and polling_was_enabled: + logger.info(f"[CYCLE] Step 7/7: Re-enabling background polling for {unit_id}") + try: + if action.device_type == "slm": + from backend.services.slmm_client import get_slmm_client + slmm = get_slmm_client() + await slmm.update_device_polling_config(unit_id, poll_enabled=True) + logger.info(f"[CYCLE] Background polling re-enabled for {unit_id}") + except Exception as e: + logger.error(f"[CYCLE] Failed to re-enable polling: {e}") + # Don't raise - cycle completed, just log the error + + logger.info(f"[CYCLE] === Cycle complete for {unit_id} ===") + return result + # ======================================================================== # Recurring Schedule Generation # ======================================================================== diff --git a/backend/services/slm_status_sync.py b/backend/services/slm_status_sync.py new file mode 100644 index 0000000..4ec139b --- /dev/null +++ b/backend/services/slm_status_sync.py @@ -0,0 +1,125 @@ +""" +SLM Status Synchronization Service + +Syncs SLM device status from SLMM backend to Terra-View's Emitter table. +This bridges SLMM's polling data with Terra-View's status snapshot system. + +SLMM tracks device reachability via background polling. This service +fetches that data and creates/updates Emitter records so SLMs appear +correctly in the dashboard status snapshot. +""" + +import logging +from datetime import datetime, timezone +from typing import Dict, Any + +from backend.database import get_db_session +from backend.models import Emitter +from backend.services.slmm_client import get_slmm_client, SLMMClientError + +logger = logging.getLogger(__name__) + + +async def sync_slm_status_to_emitters() -> Dict[str, Any]: + """ + Fetch SLM status from SLMM and sync to Terra-View's Emitter table. + + For each device in SLMM's polling status: + - If last_success exists, create/update Emitter with that timestamp + - If not reachable, update Emitter with last known timestamp (or None) + + Returns: + Dict with synced_count, error_count, errors list + """ + client = get_slmm_client() + synced = 0 + errors = [] + + try: + # Get polling status from SLMM + status_response = await client.get_polling_status() + + # Handle nested response structure + data = status_response.get("data", status_response) + devices = data.get("devices", []) + + if not devices: + logger.debug("No SLM devices in SLMM polling status") + return {"synced_count": 0, "error_count": 0, "errors": []} + + db = get_db_session() + try: + for device in devices: + unit_id = device.get("unit_id") + if not unit_id: + continue + + try: + # Get or create Emitter record + emitter = db.query(Emitter).filter(Emitter.id == unit_id).first() + + # Determine last_seen from SLMM data + last_success_str = device.get("last_success") + is_reachable = device.get("is_reachable", False) + + if last_success_str: + # Parse ISO format timestamp + last_seen = datetime.fromisoformat( + last_success_str.replace("Z", "+00:00") + ) + # Convert to naive UTC for consistency with existing code + if last_seen.tzinfo: + last_seen = last_seen.astimezone(timezone.utc).replace(tzinfo=None) + else: + last_seen = None + + # Status will be recalculated by snapshot.py based on time thresholds + # Just store a provisional status here + status = "OK" if is_reachable else "Missing" + + # Store last error message if available + last_error = device.get("last_error") or "" + + if emitter: + # Update existing record + emitter.last_seen = last_seen + emitter.status = status + emitter.unit_type = "slm" + emitter.last_file = last_error + else: + # Create new record + emitter = Emitter( + id=unit_id, + unit_type="slm", + last_seen=last_seen, + last_file=last_error, + status=status + ) + db.add(emitter) + + synced += 1 + + except Exception as e: + errors.append(f"{unit_id}: {str(e)}") + logger.error(f"Error syncing SLM {unit_id}: {e}") + + db.commit() + + finally: + db.close() + + if synced > 0: + logger.info(f"Synced {synced} SLM device(s) to Emitter table") + + except SLMMClientError as e: + logger.warning(f"Could not reach SLMM for status sync: {e}") + errors.append(f"SLMM unreachable: {str(e)}") + except Exception as e: + logger.error(f"Error in SLM status sync: {e}", exc_info=True) + errors.append(str(e)) + + return { + "synced_count": synced, + "error_count": len(errors), + "errors": errors + } diff --git a/backend/services/slmm_client.py b/backend/services/slmm_client.py index a242c12..ec7ee57 100644 --- a/backend/services/slmm_client.py +++ b/backend/services/slmm_client.py @@ -109,7 +109,8 @@ class SLMMClient: f"SLMM operation failed: {error_detail}" ) except Exception as e: - raise SLMMClientError(f"Unexpected error: {str(e)}") + error_msg = str(e) if str(e) else type(e).__name__ + raise SLMMClientError(f"Unexpected error: {error_msg}") # ======================================================================== # Unit Management @@ -478,9 +479,124 @@ 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_raw = index_info.get("index_number", 0) + + # Convert to int - device returns string like "0000" or "0001" + try: + index_number = int(index_number_raw) + except (ValueError, TypeError): + 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 +604,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) diff --git a/backend/services/slmm_sync.py b/backend/services/slmm_sync.py index 78667f0..f90184d 100644 --- a/backend/services/slmm_sync.py +++ b/backend/services/slmm_sync.py @@ -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 } ) diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index 4a6cd38..ec0dc2c 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -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, @@ -146,6 +148,34 @@ def emit_status_snapshot(): "coordinates": "", } + # --- Derive modem status from paired devices --- + # Modems don't have their own check-in system, so we inherit status + # from whatever device they're paired with (seismograph or SLM) + # Check both directions: modem.deployed_with_unit_id OR device.deployed_with_modem_id + for unit_id, unit_data in units.items(): + if unit_data.get("device_type") == "modem" and not unit_data.get("retired"): + paired_unit_id = None + roster_unit = roster.get(unit_id) + + # First, check if modem has deployed_with_unit_id set + if roster_unit and roster_unit.deployed_with_unit_id: + paired_unit_id = roster_unit.deployed_with_unit_id + else: + # Fallback: check if any device has this modem in deployed_with_modem_id + for other_id, other_roster in roster.items(): + if other_roster.deployed_with_modem_id == unit_id: + paired_unit_id = other_id + break + + if paired_unit_id: + paired_unit = units.get(paired_unit_id) + if paired_unit: + # Inherit status from paired device + unit_data["status"] = paired_unit.get("status", "Missing") + unit_data["age"] = paired_unit.get("age", "N/A") + unit_data["last"] = paired_unit.get("last") + unit_data["derived_from"] = paired_unit_id + # Separate buckets for UI active_units = { uid: u for uid, u in units.items() diff --git a/templates/base.html b/templates/base.html index 1ddabe5..8d70e07 100644 --- a/templates/base.html +++ b/templates/base.html @@ -137,6 +137,13 @@ Modems + + + + + Pair Devices + + diff --git a/templates/dashboard.html b/templates/dashboard.html index 3a91dde..e153d5b 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -187,6 +187,68 @@ + +
+
+

Filter Dashboard

+ +
+ +
+ +
+ Device Type +
+ + + +
+
+ + +
+ Status +
+ + + +
+
+
+
+
@@ -302,6 +364,255 @@ + + +{% endblock %} diff --git a/templates/partials/devices_table.html b/templates/partials/devices_table.html index ed5aca6..ac48044 100644 --- a/templates/partials/devices_table.html +++ b/templates/partials/devices_table.html @@ -60,7 +60,9 @@ data-note="{{ unit.note if unit.note else '' }}"> -
{{ unit.last_seen }}
+
{{ unit.last_seen }}
- {% if unit.status == 'OK' %} + {% if not unit.deployed %} + + {% elif unit.status == 'OK' %} {% elif unit.status == 'Pending' %} @@ -230,6 +239,10 @@ Modem + {% elif unit.device_type == 'slm' %} + + SLM + {% else %} Seismograph @@ -345,6 +358,39 @@ diff --git a/templates/partials/projects/schedule_interval.html b/templates/partials/projects/schedule_interval.html index eea72fa..f7e1ab1 100644 --- a/templates/partials/projects/schedule_interval.html +++ b/templates/partials/projects/schedule_interval.html @@ -114,7 +114,7 @@

- At 00:00: Stop → Download (1 min) → Start (2 min) + At 00:00: Stop → Download → Start (~70 sec total)

@@ -132,12 +132,12 @@ document.getElementById('include_download').addEventListener('change', function( downloadStep.style.display = 'flex'; downloadArrow.style.display = 'block'; startStepNum.textContent = '3'; - cycleTiming.innerHTML = `At ${timeValue}: Stop → Download (1 min) → Start (2 min)`; + cycleTiming.innerHTML = `At ${timeValue}: Stop → Download → Start (~70 sec total)`; } else { downloadStep.style.display = 'none'; downloadArrow.style.display = 'none'; startStepNum.textContent = '2'; - cycleTiming.innerHTML = `At ${timeValue}: Stop → Start (1 min)`; + cycleTiming.innerHTML = `At ${timeValue}: Stop → Start (~40 sec total)`; } }); diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html index 720f158..31d1731 100644 --- a/templates/partials/roster_table.html +++ b/templates/partials/roster_table.html @@ -58,7 +58,9 @@ data-note="{{ unit.note if unit.note else '' }}">
- {% if unit.status == 'OK' %} + {% if not unit.deployed %} + + {% elif unit.status == 'OK' %} {% elif unit.status == 'Pending' %} @@ -83,6 +85,10 @@ Modem + {% elif unit.device_type == 'slm' %} + + SLM + {% else %} Seismograph @@ -195,7 +201,9 @@
- {% if unit.status == 'OK' %} + {% if not unit.deployed %} + + {% elif unit.status == 'OK' %} {% elif unit.status == 'Pending' %} @@ -222,6 +230,10 @@ Modem + {% elif unit.device_type == 'slm' %} + + SLM + {% else %} Seismograph @@ -337,6 +349,7 @@ {% endblock %} diff --git a/templates/roster.html b/templates/roster.html index 7e29582..df989b7 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -122,7 +122,7 @@ class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> - +
@@ -178,8 +178,13 @@
- + + + + +
@@ -206,21 +211,6 @@
-
- - -
-
- - -
-
- - -
I (Impulse)
+
@@ -301,7 +297,7 @@ class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> - +
@@ -360,8 +356,13 @@
- + + + + +
@@ -388,21 +389,6 @@
-
- - -
-
- - -
-
- - -
I (Impulse)
+
@@ -641,7 +633,7 @@ setFieldsDisabled(seismoFields, true); setFieldsDisabled(modemFields, false); setFieldsDisabled(slmFields, true); - } else if (deviceType === 'sound_level_meter') { + } else if (deviceType === 'slm') { seismoFields.classList.add('hidden'); modemFields.classList.add('hidden'); slmFields.classList.remove('hidden'); @@ -649,6 +641,7 @@ setFieldsDisabled(seismoFields, true); setFieldsDisabled(modemFields, true); setFieldsDisabled(slmFields, false); + toggleModemPairing(); // Check if modem pairing should be shown } } @@ -661,17 +654,26 @@ }); } - // Toggle modem pairing field visibility (only for deployed seismographs) + // Toggle modem pairing field visibility (only for deployed seismographs and SLMs) function toggleModemPairing() { const deviceType = document.getElementById('deviceTypeSelect').value; const deployedCheckbox = document.getElementById('deployedCheckbox'); const modemPairingField = document.getElementById('modemPairingField'); + const slmModemPairingField = document.getElementById('slmModemPairingField'); + // Seismograph modem pairing if (deviceType === 'seismograph' && deployedCheckbox.checked) { modemPairingField.classList.remove('hidden'); } else { modemPairingField.classList.add('hidden'); } + + // SLM modem pairing + if (deviceType === 'slm' && deployedCheckbox.checked) { + slmModemPairingField.classList.remove('hidden'); + } else { + slmModemPairingField.classList.add('hidden'); + } } // Add unknown unit to roster @@ -816,13 +818,14 @@ setFieldsDisabled(seismoFields, true); setFieldsDisabled(modemFields, false); setFieldsDisabled(slmFields, true); - } else if (deviceType === 'sound_level_meter') { + } else if (deviceType === 'slm') { seismoFields.classList.add('hidden'); modemFields.classList.add('hidden'); slmFields.classList.remove('hidden'); setFieldsDisabled(seismoFields, true); setFieldsDisabled(modemFields, true); setFieldsDisabled(slmFields, false); + toggleEditModemPairing(); // Check if modem pairing should be shown } } @@ -831,12 +834,21 @@ const deviceType = document.getElementById('editDeviceTypeSelect').value; const deployedCheckbox = document.getElementById('editDeployedCheckbox'); const modemPairingField = document.getElementById('editModemPairingField'); + const slmModemPairingField = document.getElementById('editSlmModemPairingField'); + // Seismograph modem pairing if (deviceType === 'seismograph' && deployedCheckbox.checked) { modemPairingField.classList.remove('hidden'); } else { modemPairingField.classList.add('hidden'); } + + // SLM modem pairing + if (deviceType === 'slm' && deployedCheckbox.checked) { + slmModemPairingField.classList.remove('hidden'); + } else { + slmModemPairingField.classList.add('hidden'); + } } // Edit Unit - Fetch data and populate form @@ -911,7 +923,7 @@ // Modem fields document.getElementById('editIpAddress').value = unit.ip_address; document.getElementById('editPhoneNumber').value = unit.phone_number; - document.getElementById('editHardwareModel').value = unit.hardware_model; + document.getElementById('editHardwareModel').value = unit.hardware_model || ''; document.getElementById('editDeploymentType').value = unit.deployment_type || ''; // Populate unit picker for modem (uses -edit-modem suffix) @@ -940,13 +952,36 @@ // SLM fields document.getElementById('editSlmModel').value = unit.slm_model || ''; - document.getElementById('editSlmHost').value = unit.slm_host || ''; - document.getElementById('editSlmTcpPort').value = unit.slm_tcp_port || ''; - document.getElementById('editSlmFtpPort').value = unit.slm_ftp_port || ''; document.getElementById('editSlmSerialNumber').value = unit.slm_serial_number || ''; document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || ''; document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || ''; + // Populate SLM modem picker (uses -edit-slm suffix) + const slmModemPickerValue = document.getElementById('modem-picker-value-edit-slm'); + const slmModemPickerSearch = document.getElementById('modem-picker-search-edit-slm'); + const slmModemPickerClear = document.getElementById('modem-picker-clear-edit-slm'); + if (slmModemPickerValue) slmModemPickerValue.value = unit.deployed_with_modem_id || ''; + if (unit.deployed_with_modem_id && unit.device_type === 'slm') { + // Fetch modem display (ID + IP + note) + fetch(`/api/roster/${unit.deployed_with_modem_id}`) + .then(r => r.ok ? r.json() : null) + .then(modem => { + if (modem && slmModemPickerSearch) { + let display = modem.id; + if (modem.ip_address) display += ` - ${modem.ip_address}`; + if (modem.note) display += ` - ${modem.note}`; + slmModemPickerSearch.value = display; + if (slmModemPickerClear) slmModemPickerClear.classList.remove('hidden'); + } + }) + .catch(() => { + if (slmModemPickerSearch) slmModemPickerSearch.value = unit.deployed_with_modem_id; + }); + } else { + if (slmModemPickerSearch) slmModemPickerSearch.value = ''; + if (slmModemPickerClear) slmModemPickerClear.classList.add('hidden'); + } + // Cascade section - show if there's a paired device const cascadeSection = document.getElementById('editCascadeSection'); const cascadeToUnitId = document.getElementById('editCascadeToUnitId'); @@ -1154,11 +1189,20 @@ }); } + // Check if any modal is currently open + function isAnyModalOpen() { + const modalIds = ['addUnitModal', 'editUnitModal', 'renameUnitModal', 'importModal', 'quickCreateProjectModal']; + return modalIds.some(id => { + const modal = document.getElementById(id); + return modal && !modal.classList.contains('hidden'); + }); + } + document.addEventListener('DOMContentLoaded', function() { // Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker) setInterval(() => { const deviceContent = document.getElementById('device-content'); - if (deviceContent && !document.querySelector('.modal:not(.hidden)')) { + if (deviceContent && !isAnyModalOpen()) { // Only auto-refresh if no modal is open refreshDeviceList(); } diff --git a/templates/unit_detail.html b/templates/unit_detail.html index 2f7ca90..92e4f80 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -43,7 +43,7 @@
-
-

--

+

+ + -- +

@@ -174,6 +179,54 @@

--

+ + + + + + @@ -287,7 +340,7 @@ class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> - + @@ -336,8 +389,20 @@
- +
+
+ {% set picker_id = "-detail-seismo" %} + {% set input_name = "deployed_with_modem_id" %} + {% include "partials/modem_picker.html" with context %} +
+ +
@@ -358,8 +423,13 @@
- + + + + +
@@ -411,11 +481,20 @@
- +
+
+ {% set picker_id = "-detail-slm" %} + {% set input_name = "deployed_with_modem_id" %} + {% include "partials/modem_picker.html" with context %} +
+ +

Select the modem that provides network connectivity for this SLM

@@ -442,6 +521,54 @@ class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> + + +
+
+ + +
+
+ +
+ + + + +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + + + {% include "partials/project_create_modal.html" %} + + + {% endblock %}