2 Commits

Author SHA1 Message Date
serversdwn
d78bafb76e fix: improved 24hr cycle via scheduler. Should help prevent issues with DLs. 2026-01-31 22:31:34 +00:00
serversdwn
8373cff10d added: Pairing options now available from the modem page. 2026-01-29 23:04:18 +00:00
9 changed files with 706 additions and 69 deletions

View File

@@ -229,7 +229,7 @@ class ScheduledAction(Base):
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id 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) 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" device_type = Column(String, nullable=False) # "slm" | "seismograph"
scheduled_time = Column(DateTime, nullable=False, index=True) scheduled_time = Column(DateTime, nullable=False, index=True)

View File

@@ -284,3 +284,146 @@ async def get_modem_diagnostics(modem_id: str, db: Session = Depends(get_db)):
"carrier": None, "carrier": None,
"connection_type": None # LTE, 5G, etc. "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"
}

View File

@@ -199,7 +199,7 @@ class AlertService:
Args: Args:
schedule_id: The ScheduledAction or RecurringSchedule ID schedule_id: The ScheduledAction or RecurringSchedule ID
action_type: start, stop, download action_type: start, stop, download, cycle
unit_id: Related unit unit_id: Related unit
error_message: Error from execution error_message: Error from execution
project_id: Related project project_id: Related project
@@ -235,7 +235,7 @@ class AlertService:
Args: Args:
schedule_id: The ScheduledAction ID schedule_id: The ScheduledAction ID
action_type: start, stop, download action_type: start, stop, download, cycle
unit_id: Related unit unit_id: Related unit
project_id: Related project project_id: Related project
location_id: Related location location_id: Related location

View File

@@ -384,73 +384,33 @@ class RecurringScheduleService:
if cycle_utc <= now_utc: if cycle_utc <= now_utc:
continue continue
# Check if action already exists # Check if cycle action already exists
if self._action_exists(schedule.project_id, schedule.location_id, "stop", cycle_utc): if self._action_exists(schedule.project_id, schedule.location_id, "cycle", cycle_utc):
continue continue
# Build notes with metadata # Build notes with metadata for cycle action
stop_notes = json.dumps({ cycle_notes = json.dumps({
"schedule_name": schedule.name, "schedule_name": schedule.name,
"schedule_id": schedule.id, "schedule_id": schedule.id,
"cycle_type": "daily", "cycle_type": "daily",
"include_download": schedule.include_download,
"auto_increment_index": schedule.auto_increment_index,
}) })
# Create STOP action # Create single CYCLE action that handles stop -> download -> start
stop_action = ScheduledAction( # The scheduler's _execute_cycle method handles the full workflow with delays
cycle_action = ScheduledAction(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
project_id=schedule.project_id, project_id=schedule.project_id,
location_id=schedule.location_id, location_id=schedule.location_id,
unit_id=unit_id, unit_id=unit_id,
action_type="stop", action_type="cycle",
device_type=schedule.device_type, device_type=schedule.device_type,
scheduled_time=cycle_utc, scheduled_time=cycle_utc,
execution_status="pending", execution_status="pending",
notes=stop_notes, notes=cycle_notes,
) )
actions.append(stop_action) actions.append(cycle_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)
return actions return actions

View File

@@ -185,6 +185,8 @@ class SchedulerService:
response = await self._execute_stop(action, unit_id, db) response = await self._execute_stop(action, unit_id, db)
elif action.action_type == "download": elif action.action_type == "download":
response = await self._execute_download(action, unit_id, db) response = await self._execute_download(action, unit_id, db)
elif action.action_type == "cycle":
response = await self._execute_cycle(action, unit_id, db)
else: else:
raise Exception(f"Unknown action type: {action.action_type}") raise Exception(f"Unknown action type: {action.action_type}")
@@ -375,8 +377,13 @@ class SchedulerService:
f"{location.name}/session-{session_timestamp}/" f"{location.name}/session-{session_timestamp}/"
) )
# Step 1: Enable FTP on device # Step 1: Disable FTP first to reset any stale connection state
logger.info(f"Enabling FTP on {unit_id} for download") # 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) await self.device_controller.enable_ftp(unit_id, action.device_type)
# Step 2: Download current measurement folder # Step 2: Download current measurement folder
@@ -397,6 +404,200 @@ class SchedulerService:
"device_response": response, "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 # Recurring Schedule Generation
# ======================================================================== # ========================================================================

View File

@@ -109,7 +109,8 @@ class SLMMClient:
f"SLMM operation failed: {error_detail}" f"SLMM operation failed: {error_detail}"
) )
except Exception as e: 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 # Unit Management
@@ -579,7 +580,13 @@ class SLMMClient:
""" """
# Get current index number from device # Get current index number from device
index_info = await self.get_index_number(unit_id) index_info = await self.get_index_number(unit_id)
index_number = index_info.get("index_number", 0) 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 # Format as Auto_XXXX folder name
folder_name = f"Auto_{index_number:04d}" folder_name = f"Auto_{index_number:04d}"

View File

@@ -2,7 +2,7 @@
{% if device %} {% if device %}
<div class="flex items-center gap-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg"> <div class="flex items-center gap-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg"> <div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
{% if device.device_type == "slm" %} {% if device.device_type == "slm" or device.device_type == "sound_level_meter" %}
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg> </svg>
@@ -18,7 +18,7 @@
{{ device.id }} {{ device.id }}
</a> </a>
<div class="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-400"> <div class="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-400">
<span class="capitalize">{{ device.device_type }}</span> <span class="capitalize">{{ device.device_type | replace("_", " ") }}</span>
{% if device.project_id %} {% if device.project_id %}
<span class="text-gray-400">|</span> <span class="text-gray-400">|</span>
<span>{{ device.project_id }}</span> <span>{{ device.project_id }}</span>
@@ -30,11 +30,17 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="flex items-center gap-2">
<button onclick="openModemPairDeviceModal()"
class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
Edit Pairing
</button>
<a href="/unit/{{ device.id }}" class="text-gray-400 hover:text-seismo-orange transition-colors"> <a href="/unit/{{ device.id }}" class="text-gray-400 hover:text-seismo-orange transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg> </svg>
</a> </a>
</div>
</div> </div>
{% else %} {% else %}
<div class="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg"> <div class="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
@@ -47,5 +53,12 @@
<p class="text-gray-600 dark:text-gray-400">No device currently paired</p> <p class="text-gray-600 dark:text-gray-400">No device currently paired</p>
<p class="text-sm text-gray-500 dark:text-gray-500">This modem is available for assignment</p> <p class="text-sm text-gray-500 dark:text-gray-500">This modem is available for assignment</p>
</div> </div>
<button onclick="openModemPairDeviceModal()"
class="px-4 py-2 text-sm bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
Pair Device
</button>
</div> </div>
{% endif %} {% endif %}

View File

@@ -114,7 +114,7 @@
</div> </div>
</div> </div>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3" id="cycle-timing"> <p class="text-xs text-gray-400 dark:text-gray-500 mt-3" id="cycle-timing">
At <span id="preview-time">00:00</span>: Stop → Download (1 min) → Start (2 min) At <span id="preview-time">00:00</span>: Stop → Download → Start (~70 sec total)
</p> </p>
</div> </div>
</div> </div>
@@ -132,12 +132,12 @@ document.getElementById('include_download').addEventListener('change', function(
downloadStep.style.display = 'flex'; downloadStep.style.display = 'flex';
downloadArrow.style.display = 'block'; downloadArrow.style.display = 'block';
startStepNum.textContent = '3'; startStepNum.textContent = '3';
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Download (1 min) → Start (2 min)`; cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Download → Start (~70 sec total)`;
} else { } else {
downloadStep.style.display = 'none'; downloadStep.style.display = 'none';
downloadArrow.style.display = 'none'; downloadArrow.style.display = 'none';
startStepNum.textContent = '2'; startStepNum.textContent = '2';
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Start (1 min)`; cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Start (~40 sec total)`;
} }
}); });

View File

@@ -1571,6 +1571,242 @@ function clearPairing(deviceType) {
closePairDeviceModal(); closePairDeviceModal();
} }
// ===== Modem Pair Device Modal Functions (for modems to pick a device) =====
let modemPairDevices = []; // Cache loaded devices
let modemHasCurrentPairing = false;
function openModemPairDeviceModal() {
const modal = document.getElementById('modemPairDeviceModal');
const searchInput = document.getElementById('modemPairDeviceSearch');
if (!modal) return;
modal.classList.remove('hidden');
if (searchInput) {
searchInput.value = '';
searchInput.focus();
}
// Reset checkboxes
document.getElementById('modemPairHidePaired').checked = true;
document.getElementById('modemPairShowSeismo').checked = true;
document.getElementById('modemPairShowSLM').checked = true;
// Load available devices
loadPairableDevices();
}
function closeModemPairDeviceModal() {
const modal = document.getElementById('modemPairDeviceModal');
if (modal) modal.classList.add('hidden');
modemPairDevices = [];
}
async function loadPairableDevices() {
const listContainer = document.getElementById('modemPairDeviceList');
listContainer.innerHTML = '<div class="text-center py-8"><div class="animate-spin inline-block w-6 h-6 border-2 border-seismo-orange border-t-transparent rounded-full"></div><p class="mt-2 text-sm text-gray-500">Loading devices...</p></div>';
try {
const response = await fetch(`/api/modem-dashboard/${unitId}/pairable-devices?hide_paired=false`);
if (!response.ok) throw new Error('Failed to load devices');
const data = await response.json();
modemPairDevices = data.devices || [];
// Check if modem has a current pairing
modemHasCurrentPairing = modemPairDevices.some(d => d.is_paired_to_this);
const unpairBtn = document.getElementById('modemUnpairBtn');
if (unpairBtn) {
unpairBtn.classList.toggle('hidden', !modemHasCurrentPairing);
}
if (modemPairDevices.length === 0) {
listContainer.innerHTML = '<p class="text-center py-8 text-gray-500">No devices found in roster</p>';
return;
}
renderModemPairDeviceList();
} catch (error) {
console.error('Failed to load pairable devices:', error);
listContainer.innerHTML = '<p class="text-center py-8 text-red-500">Failed to load devices</p>';
}
}
function filterModemPairDeviceList() {
renderModemPairDeviceList();
}
function renderModemPairDeviceList() {
const listContainer = document.getElementById('modemPairDeviceList');
const searchInput = document.getElementById('modemPairDeviceSearch');
const hidePairedCheckbox = document.getElementById('modemPairHidePaired');
const showSeismoCheckbox = document.getElementById('modemPairShowSeismo');
const showSLMCheckbox = document.getElementById('modemPairShowSLM');
const searchTerm = searchInput?.value?.toLowerCase() || '';
const hidePaired = hidePairedCheckbox?.checked ?? true;
const showSeismo = showSeismoCheckbox?.checked ?? true;
const showSLM = showSLMCheckbox?.checked ?? true;
// Filter devices
let filteredDevices = modemPairDevices.filter(device => {
// Filter by device type
if (device.device_type === 'seismograph' && !showSeismo) return false;
if (device.device_type === 'sound_level_meter' && !showSLM) return false;
// Hide devices paired to OTHER modems (but show unpaired and paired-to-this)
if (hidePaired && device.is_paired_to_other) return false;
// Search filter
if (searchTerm) {
const searchFields = [
device.id,
device.project_id || '',
device.location || '',
device.note || ''
].join(' ').toLowerCase();
if (!searchFields.includes(searchTerm)) return false;
}
return true;
});
if (filteredDevices.length === 0) {
listContainer.innerHTML = '<p class="text-center py-8 text-gray-500">No devices match your criteria</p>';
return;
}
// Build device list HTML
let html = '<div class="divide-y divide-gray-200 dark:divide-gray-700">';
for (const device of filteredDevices) {
const deviceTypeLabel = device.device_type === 'sound_level_meter' ? 'SLM' : 'Seismograph';
const deviceTypeClass = device.device_type === 'sound_level_meter'
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300'
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
const deployedBadge = device.deployed
? '<span class="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 text-xs rounded">Deployed</span>'
: '<span class="px-2 py-0.5 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 text-xs rounded">Benched</span>';
let pairingBadge = '';
if (device.is_paired_to_this) {
pairingBadge = '<span class="px-2 py-0.5 bg-seismo-orange/20 text-seismo-orange text-xs rounded font-medium">Current</span>';
} else if (device.is_paired_to_other) {
pairingBadge = `<span class="px-2 py-0.5 bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300 text-xs rounded">Paired: ${device.paired_modem_id}</span>`;
}
const isCurrentlyPaired = device.is_paired_to_this;
html += `
<div class="px-6 py-3 hover:bg-gray-50 dark:hover:bg-slate-700 cursor-pointer transition-colors ${isCurrentlyPaired ? 'bg-seismo-orange/5' : ''}"
onclick="selectDeviceForModem('${device.id}')">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-white">${device.id}</span>
<span class="px-2 py-0.5 ${deviceTypeClass} text-xs rounded">${deviceTypeLabel}</span>
</div>
${device.project_id ? `<div class="text-sm text-gray-500 dark:text-gray-400">${device.project_id}</div>` : ''}
${device.location ? `<div class="text-sm text-gray-500 dark:text-gray-400 truncate">${device.location}</div>` : ''}
</div>
<div class="flex items-center gap-2 ml-4">
${deployedBadge}
${pairingBadge}
</div>
</div>
</div>
`;
}
html += '</div>';
listContainer.innerHTML = html;
}
async function selectDeviceForModem(deviceId) {
const listContainer = document.getElementById('modemPairDeviceList');
// Show loading state
listContainer.innerHTML = '<div class="text-center py-8"><div class="animate-spin inline-block w-6 h-6 border-2 border-seismo-orange border-t-transparent rounded-full"></div><p class="mt-2 text-sm text-gray-500">Pairing device...</p></div>';
try {
const response = await fetch(`/api/modem-dashboard/${unitId}/pair?device_id=${encodeURIComponent(deviceId)}`, {
method: 'POST'
});
const result = await response.json();
if (result.status === 'success') {
closeModemPairDeviceModal();
// Reload the paired device section
loadPairedDevice();
// Show success message (optional toast)
showToast(`Paired with ${deviceId}`, 'success');
} else {
listContainer.innerHTML = `<p class="text-center py-8 text-red-500">${result.detail || 'Failed to pair device'}</p>`;
}
} catch (error) {
console.error('Failed to pair device:', error);
listContainer.innerHTML = '<p class="text-center py-8 text-red-500">Failed to pair device</p>';
}
}
async function unpairDeviceFromModem() {
const listContainer = document.getElementById('modemPairDeviceList');
// Show loading state
listContainer.innerHTML = '<div class="text-center py-8"><div class="animate-spin inline-block w-6 h-6 border-2 border-seismo-orange border-t-transparent rounded-full"></div><p class="mt-2 text-sm text-gray-500">Unpairing device...</p></div>';
try {
const response = await fetch(`/api/modem-dashboard/${unitId}/unpair`, {
method: 'POST'
});
const result = await response.json();
if (result.status === 'success') {
closeModemPairDeviceModal();
// Reload the paired device section
loadPairedDevice();
// Show success message
if (result.unpaired_device_id) {
showToast(`Unpaired ${result.unpaired_device_id}`, 'success');
} else {
showToast('No device was paired', 'info');
}
} else {
listContainer.innerHTML = `<p class="text-center py-8 text-red-500">${result.detail || 'Failed to unpair device'}</p>`;
}
} catch (error) {
console.error('Failed to unpair device:', error);
listContainer.innerHTML = '<p class="text-center py-8 text-red-500">Failed to unpair device</p>';
}
}
// Simple toast function (if not already defined)
function showToast(message, type = 'info') {
// Check if there's already a toast container
let toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.className = 'fixed bottom-4 right-4 z-50 space-y-2';
document.body.appendChild(toastContainer);
}
const toast = document.createElement('div');
const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-gray-700';
toast.className = `${bgColor} text-white px-4 py-2 rounded-lg shadow-lg transform transition-all duration-300 translate-x-0`;
toast.textContent = message;
toastContainer.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-x-full');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
</script> </script>
<!-- Pair Device Modal --> <!-- Pair Device Modal -->
@@ -1637,4 +1873,81 @@ function clearPairing(deviceType) {
<!-- Include Project Create Modal for inline project creation --> <!-- Include Project Create Modal for inline project creation -->
{% include "partials/project_create_modal.html" %} {% include "partials/project_create_modal.html" %}
<!-- Modem Pair Device Modal (for modems to pick a device) -->
<div id="modemPairDeviceModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen px-4">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black/50" onclick="closeModemPairDeviceModal()"></div>
<!-- Modal Content -->
<div class="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Pair Device to Modem</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Select a seismograph or SLM to pair</p>
</div>
<button onclick="closeModemPairDeviceModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Search and Filters -->
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 space-y-3">
<input type="text"
placeholder="Search by ID, project, location..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-transparent"
id="modemPairDeviceSearch"
autocomplete="off"
oninput="filterModemPairDeviceList()">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input type="checkbox"
id="modemPairHidePaired"
onchange="filterModemPairDeviceList()"
checked
class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
Hide paired devices
</label>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input type="checkbox"
id="modemPairShowSeismo"
onchange="filterModemPairDeviceList()"
checked
class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
Seismographs
</label>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input type="checkbox"
id="modemPairShowSLM"
onchange="filterModemPairDeviceList()"
checked
class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
SLMs
</label>
</div>
</div>
<!-- Device List -->
<div id="modemPairDeviceList" class="flex-1 overflow-y-auto max-h-80">
<!-- Populated by JavaScript -->
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900 flex gap-3">
<button onclick="unpairDeviceFromModem()"
id="modemUnpairBtn"
class="hidden px-4 py-2 bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg transition-colors">
Unpair Current
</button>
<button onclick="closeModemPairDeviceModal()" class="flex-1 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}