Compare commits
2 Commits
4957a08198
...
d78bafb76e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d78bafb76e | ||
|
|
8373cff10d |
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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>
|
||||||
<a href="/unit/{{ device.id }}" class="text-gray-400 hover:text-seismo-orange transition-colors">
|
<div class="flex items-center gap-2">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button onclick="openModemPairDeviceModal()"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
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">
|
||||||
</svg>
|
Edit Pairing
|
||||||
</a>
|
</button>
|
||||||
|
<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">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</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 %}
|
||||||
|
|||||||
@@ -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)`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user