From 842e9d6f613e8ec5f25d40d1fa3cf216706f06e7 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 10 Feb 2026 07:08:03 +0000 Subject: [PATCH 1/3] feat: add support for one-off recording schedules with start and end datetime --- backend/migrate_add_oneoff_schedule_fields.py | 73 +++++++ backend/models.py | 11 +- backend/routers/recurring_schedules.py | 37 ++++ .../services/recurring_schedule_service.py | 88 +++++++- backend/services/scheduler.py | 9 + .../projects/recurring_schedule_list.html | 18 ++ .../partials/projects/schedule_oneoff.html | 206 ++++++++++++++++++ templates/projects/detail.html | 73 ++++++- templates/roster.html | 2 + 9 files changed, 508 insertions(+), 9 deletions(-) create mode 100644 backend/migrate_add_oneoff_schedule_fields.py create mode 100644 templates/partials/projects/schedule_oneoff.html diff --git a/backend/migrate_add_oneoff_schedule_fields.py b/backend/migrate_add_oneoff_schedule_fields.py new file mode 100644 index 0000000..3785496 --- /dev/null +++ b/backend/migrate_add_oneoff_schedule_fields.py @@ -0,0 +1,73 @@ +""" +Migration: Add one-off schedule fields to recurring_schedules table + +Adds start_datetime and end_datetime columns for one-off recording schedules. + +Run this script once to update existing databases: + python -m backend.migrate_add_oneoff_schedule_fields +""" + +import sqlite3 +import os + +DB_PATH = "data/seismo_fleet.db" + + +def migrate(): + """Add one-off schedule columns to recurring_schedules table.""" + if not os.path.exists(DB_PATH): + print(f"Database not found at {DB_PATH}") + return False + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + try: + cursor.execute(""" + SELECT name FROM sqlite_master + WHERE type='table' AND name='recurring_schedules' + """) + if not cursor.fetchone(): + print("recurring_schedules table does not exist yet. Will be created on app startup.") + conn.close() + return True + + cursor.execute("PRAGMA table_info(recurring_schedules)") + columns = [row[1] for row in cursor.fetchall()] + + added = False + + if "start_datetime" not in columns: + print("Adding start_datetime column to recurring_schedules table...") + cursor.execute(""" + ALTER TABLE recurring_schedules + ADD COLUMN start_datetime DATETIME NULL + """) + added = True + + if "end_datetime" not in columns: + print("Adding end_datetime column to recurring_schedules table...") + cursor.execute(""" + ALTER TABLE recurring_schedules + ADD COLUMN end_datetime DATETIME NULL + """) + added = True + + if added: + conn.commit() + print("Successfully added one-off schedule columns.") + else: + print("One-off schedule columns already exist.") + + conn.close() + return True + + except Exception as e: + print(f"Migration failed: {e}") + conn.close() + return False + + +if __name__ == "__main__": + success = migrate() + exit(0 if success else 1) diff --git a/backend/models.py b/backend/models.py index b6d2c6f..49ec9af 100644 --- a/backend/models.py +++ b/backend/models.py @@ -321,9 +321,10 @@ class RecurringSchedule(Base): """ Recurring schedule definitions for automated sound monitoring. - Supports two schedule types: + Supports three schedule types: - "weekly_calendar": Select specific days with start/end times (e.g., Mon/Wed/Fri 7pm-7am) - "simple_interval": For 24/7 monitoring with daily stop/download/restart cycles + - "one_off": Single recording session with specific start and end date/time """ __tablename__ = "recurring_schedules" @@ -333,7 +334,7 @@ class RecurringSchedule(Base): unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (optional, can use assignment) name = Column(String, nullable=False) # "Weeknight Monitoring", "24/7 Continuous" - schedule_type = Column(String, nullable=False) # "weekly_calendar" | "simple_interval" + schedule_type = Column(String, nullable=False) # "weekly_calendar" | "simple_interval" | "one_off" device_type = Column(String, nullable=False) # "slm" | "seismograph" # Weekly Calendar fields (schedule_type = "weekly_calendar") @@ -349,7 +350,11 @@ class RecurringSchedule(Base): cycle_time = Column(String, nullable=True) # "00:00" - time to run stop/download/restart include_download = Column(Boolean, default=True) # Download data before restart - # Automation options (applies to both schedule types) + # One-Off fields (schedule_type = "one_off") + start_datetime = Column(DateTime, nullable=True) # Exact start date+time (stored as UTC) + end_datetime = Column(DateTime, nullable=True) # Exact end date+time (stored as UTC) + + # Automation options (applies to all schedule types) auto_increment_index = Column(Boolean, default=True) # Auto-increment store/index number before start # When True: prevents "overwrite data?" prompts by using a new index each time diff --git a/backend/routers/recurring_schedules.py b/backend/routers/recurring_schedules.py index 9a992c4..9a0289b 100644 --- a/backend/routers/recurring_schedules.py +++ b/backend/routers/recurring_schedules.py @@ -186,6 +186,41 @@ async def create_recurring_schedule( created_schedules = [] base_name = data.get("name", "Unnamed Schedule") + # Parse one-off datetime fields if applicable + one_off_start = None + one_off_end = None + if data.get("schedule_type") == "one_off": + from zoneinfo import ZoneInfo + + tz = ZoneInfo(data.get("timezone", "America/New_York")) + + start_dt_str = data.get("start_datetime") + end_dt_str = data.get("end_datetime") + + if not start_dt_str or not end_dt_str: + raise HTTPException(status_code=400, detail="One-off schedules require start and end date/time") + + try: + start_local = datetime.fromisoformat(start_dt_str).replace(tzinfo=tz) + end_local = datetime.fromisoformat(end_dt_str).replace(tzinfo=tz) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid datetime format") + + duration = end_local - start_local + if duration.total_seconds() < 900: + raise HTTPException(status_code=400, detail="Duration must be at least 15 minutes") + if duration.total_seconds() > 86400: + raise HTTPException(status_code=400, detail="Duration cannot exceed 24 hours") + + from datetime import timezone as dt_timezone + now_local = datetime.now(tz) + if start_local <= now_local: + raise HTTPException(status_code=400, detail="Start time must be in the future") + + # Convert to UTC for storage + one_off_start = start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) + one_off_end = end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) + # Create a schedule for each location for location in locations: # Determine device type from location @@ -207,6 +242,8 @@ async def create_recurring_schedule( include_download=data.get("include_download", True), auto_increment_index=data.get("auto_increment_index", True), timezone=data.get("timezone", "America/New_York"), + start_datetime=one_off_start, + end_datetime=one_off_end, ) # Generate actions immediately so they appear right away diff --git a/backend/services/recurring_schedule_service.py b/backend/services/recurring_schedule_service.py index 3f53210..d19ce41 100644 --- a/backend/services/recurring_schedule_service.py +++ b/backend/services/recurring_schedule_service.py @@ -49,6 +49,8 @@ class RecurringScheduleService: include_download: bool = True, auto_increment_index: bool = True, timezone: str = "America/New_York", + start_datetime: datetime = None, + end_datetime: datetime = None, ) -> RecurringSchedule: """ Create a new recurring schedule. @@ -57,7 +59,7 @@ class RecurringScheduleService: project_id: Project ID location_id: Monitoring location ID name: Schedule name - schedule_type: "weekly_calendar" or "simple_interval" + schedule_type: "weekly_calendar", "simple_interval", or "one_off" device_type: "slm" or "seismograph" unit_id: Specific unit (optional, can use assignment) weekly_pattern: Dict of day patterns for weekly_calendar @@ -66,6 +68,8 @@ class RecurringScheduleService: include_download: Whether to download data on cycle auto_increment_index: Whether to auto-increment store index before start timezone: Timezone for schedule times + start_datetime: Start date+time in UTC (one_off only) + end_datetime: End date+time in UTC (one_off only) Returns: Created RecurringSchedule @@ -85,6 +89,8 @@ class RecurringScheduleService: auto_increment_index=auto_increment_index, enabled=True, timezone=timezone, + start_datetime=start_datetime, + end_datetime=end_datetime, ) # Calculate next occurrence @@ -213,6 +219,8 @@ class RecurringScheduleService: actions = self._generate_weekly_calendar_actions(schedule, horizon_days) elif schedule.schedule_type == "simple_interval": actions = self._generate_interval_actions(schedule, horizon_days) + elif schedule.schedule_type == "one_off": + actions = self._generate_one_off_actions(schedule) else: logger.warning(f"Unknown schedule type: {schedule.schedule_type}") return [] @@ -431,6 +439,77 @@ class RecurringScheduleService: return actions + def _generate_one_off_actions( + self, + schedule: RecurringSchedule, + ) -> List[ScheduledAction]: + """ + Generate start and stop actions for a one-off recording. + + Unlike recurring types, this generates exactly one start and one stop action + using the schedule's start_datetime and end_datetime directly. + """ + if not schedule.start_datetime or not schedule.end_datetime: + logger.warning(f"One-off schedule {schedule.id} missing start/end datetime") + return [] + + actions = [] + now_utc = datetime.utcnow() + unit_id = self._resolve_unit_id(schedule) + + # Skip if end time has already passed + if schedule.end_datetime <= now_utc: + return [] + + # Check if actions already exist for this schedule + if self._action_exists(schedule.project_id, schedule.location_id, "start", schedule.start_datetime): + return [] + + # Create START action (only if start time hasn't passed) + if schedule.start_datetime > now_utc: + start_notes = json.dumps({ + "schedule_name": schedule.name, + "schedule_id": schedule.id, + "schedule_type": "one_off", + "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=schedule.start_datetime, + execution_status="pending", + notes=start_notes, + ) + actions.append(start_action) + + # Create STOP action + stop_notes = json.dumps({ + "schedule_name": schedule.name, + "schedule_id": schedule.id, + "schedule_type": "one_off", + "include_download": schedule.include_download, + }) + + stop_action = ScheduledAction( + id=str(uuid.uuid4()), + project_id=schedule.project_id, + location_id=schedule.location_id, + unit_id=unit_id, + action_type="stop", + device_type=schedule.device_type, + scheduled_time=schedule.end_datetime, + execution_status="pending", + notes=stop_notes, + ) + actions.append(stop_action) + + return actions + def _calculate_next_occurrence(self, schedule: RecurringSchedule) -> Optional[datetime]: """Calculate when the next action should occur.""" if not schedule.enabled: @@ -471,6 +550,13 @@ class RecurringScheduleService: if cycle_utc > now_utc: return cycle_utc + elif schedule.schedule_type == "one_off": + if schedule.start_datetime and schedule.start_datetime > now_utc: + return schedule.start_datetime + elif schedule.end_datetime and schedule.end_datetime > now_utc: + return schedule.end_datetime + return None + return None def _resolve_unit_id(self, schedule: RecurringSchedule) -> Optional[str]: diff --git a/backend/services/scheduler.py b/backend/services/scheduler.py index f419a78..a056cb4 100644 --- a/backend/services/scheduler.py +++ b/backend/services/scheduler.py @@ -628,6 +628,15 @@ class SchedulerService: for schedule in schedules: try: + # Auto-disable one-off schedules whose end time has passed + if schedule.schedule_type == "one_off" and schedule.end_datetime: + if schedule.end_datetime <= datetime.utcnow(): + schedule.enabled = False + schedule.next_occurrence = None + db.commit() + logger.info(f"Auto-disabled completed one-off schedule: {schedule.name}") + continue + actions = service.generate_actions_for_schedule(schedule, horizon_days=7) total_generated += len(actions) except Exception as e: diff --git a/templates/partials/projects/recurring_schedule_list.html b/templates/partials/projects/recurring_schedule_list.html index 12e7054..c5279d8 100644 --- a/templates/partials/projects/recurring_schedule_list.html +++ b/templates/partials/projects/recurring_schedule_list.html @@ -18,6 +18,10 @@ Weekly + {% elif item.schedule.schedule_type == 'one_off' %} + + One-Off + {% else %} 24/7 Cycle @@ -69,6 +73,20 @@ (with download) {% endif %} + {% elif item.schedule.schedule_type == 'one_off' %} +
+ + + + {% if item.schedule.start_datetime %} + {{ item.schedule.start_datetime|local_datetime }} {{ timezone_abbr() }} + → + {{ item.schedule.end_datetime|local_datetime }} {{ timezone_abbr() }} + {% endif %} + {% if item.schedule.include_download %} + (with download) + {% endif %} +
{% endif %} {% if item.schedule.next_occurrence %} diff --git a/templates/partials/projects/schedule_oneoff.html b/templates/partials/projects/schedule_oneoff.html new file mode 100644 index 0000000..7453a1c --- /dev/null +++ b/templates/partials/projects/schedule_oneoff.html @@ -0,0 +1,206 @@ + + + +
+
+

One-Off Recording

+

+ Schedule a single recording session with a specific start and end time. + Duration can be between 15 minutes and 24 hours. +

+
+ + +
+
+ + + +
+

How it works:

+
    +
  1. At the start time, the measurement will start
  2. +
  3. At the end time, the measurement will stop
  4. +
  5. If enabled, data will be downloaded via FTP after stop
  6. +
  7. The schedule will be auto-disabled after completion
  8. +
+
+
+
+ + +
+ + +

+ Must be in the future +

+
+ + +
+ + +
+ + + + + +
+ +
+ + +
+ +
+
+ + diff --git a/templates/projects/detail.html b/templates/projects/detail.html index fecf085..fa6c79d 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -456,7 +456,7 @@
-
+
+
@@ -498,6 +512,11 @@ {% include "partials/projects/schedule_interval.html" %}
+ + +
@@ -1108,13 +1127,18 @@ function getSelectedLocationIds() { function toggleScheduleType(type) { const weeklyEditor = document.getElementById('schedule-weekly-wrapper'); const intervalEditor = document.getElementById('schedule-interval-wrapper'); + const oneoffEditor = document.getElementById('schedule-oneoff-wrapper'); + + weeklyEditor.classList.add('hidden'); + intervalEditor.classList.add('hidden'); + oneoffEditor.classList.add('hidden'); if (type === 'weekly_calendar') { weeklyEditor.classList.remove('hidden'); - intervalEditor.classList.add('hidden'); - } else { - weeklyEditor.classList.add('hidden'); + } else if (type === 'simple_interval') { intervalEditor.classList.remove('hidden'); + } else if (type === 'one_off') { + oneoffEditor.classList.remove('hidden'); } } @@ -1178,7 +1202,7 @@ document.getElementById('schedule-form').addEventListener('submit', async functi } else { payload.include_download = true; } - } else { + } else if (scheduleType === 'simple_interval') { // Get interval data if (typeof getIntervalData === 'function') { const intervalData = getIntervalData(); @@ -1190,6 +1214,45 @@ document.getElementById('schedule-form').addEventListener('submit', async functi showScheduleError('Interval editor not loaded properly.'); return; } + } else if (scheduleType === 'one_off') { + // Get one-off data + if (typeof getOneOffData === 'function') { + const oneOffData = getOneOffData(); + + if (!oneOffData.start_datetime || !oneOffData.end_datetime) { + showScheduleError('Please select both start and end date/time.'); + return; + } + + const start = new Date(oneOffData.start_datetime); + const end = new Date(oneOffData.end_datetime); + const diffMinutes = (end - start) / (1000 * 60); + + if (diffMinutes <= 0) { + showScheduleError('End time must be after start time.'); + return; + } + if (diffMinutes < 15) { + showScheduleError('Duration must be at least 15 minutes.'); + return; + } + if (diffMinutes > 1440) { + showScheduleError('Duration cannot exceed 24 hours.'); + return; + } + if (start <= new Date()) { + showScheduleError('Start time must be in the future.'); + return; + } + + payload.start_datetime = oneOffData.start_datetime; + payload.end_datetime = oneOffData.end_datetime; + payload.include_download = oneOffData.include_download; + payload.auto_increment_index = oneOffData.auto_increment_index; + } else { + showScheduleError('One-off editor not loaded properly.'); + return; + } } try { diff --git a/templates/roster.html b/templates/roster.html index 5407c98..0ebc69d 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -772,6 +772,8 @@ // Handle Add Unit form submission document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) { + // Only handle the form's own POST request, not child HTMX requests (e.g. project picker search) + if (event.detail.elt !== this) return; if (event.detail.successful) { closeAddUnitModal(); refreshDeviceList(); From ebe60d2b7d4e1f7d9205230ec4b57ec4c5d6380e Mon Sep 17 00:00:00 2001 From: serversdwn Date: Wed, 11 Feb 2026 20:13:27 +0000 Subject: [PATCH 2/3] feat: enhance roster unit management with bidirectional pairing sync fix: scheduler one off --- backend/routers/roster_edit.py | 69 +++++++++++++++- backend/services/slmm_client.py | 81 +++++++++++++++++-- .../partials/projects/schedule_oneoff.html | 6 +- 3 files changed, 144 insertions(+), 12 deletions(-) diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index 0c2d974..e4083f7 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -237,7 +237,7 @@ async def add_roster_unit( slm_measurement_range=slm_measurement_range if slm_measurement_range else None, ) - # Auto-fill location data from modem if pairing and fields are empty + # Auto-fill data from modem if pairing and fields are empty if deployed_with_modem_id: modem = db.query(RosterUnit).filter( RosterUnit.id == deployed_with_modem_id, @@ -252,6 +252,24 @@ async def add_roster_unit( unit.coordinates = modem.coordinates if not unit.project_id and modem.project_id: unit.project_id = modem.project_id + if not unit.note and modem.note: + unit.note = modem.note + + # Bidirectional pairing sync for new units + if device_type in ("seismograph", "slm") and deployed_with_modem_id: + modem_to_update = db.query(RosterUnit).filter( + RosterUnit.id == deployed_with_modem_id, + RosterUnit.device_type == "modem" + ).first() + if modem_to_update: + # Clear old device's reference if modem was paired elsewhere + if modem_to_update.deployed_with_unit_id and modem_to_update.deployed_with_unit_id != id: + old_device = db.query(RosterUnit).filter( + RosterUnit.id == modem_to_update.deployed_with_unit_id + ).first() + if old_device and old_device.deployed_with_modem_id == deployed_with_modem_id: + old_device.deployed_with_modem_id = None + modem_to_update.deployed_with_unit_id = id db.add(unit) db.commit() @@ -564,7 +582,7 @@ async def edit_roster_unit( unit.next_calibration_due = next_cal_date unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None - # Auto-fill location data from modem if pairing and fields are empty + # Auto-fill data from modem if pairing and fields are empty if deployed_with_modem_id: modem = db.query(RosterUnit).filter( RosterUnit.id == deployed_with_modem_id, @@ -580,6 +598,8 @@ async def edit_roster_unit( unit.coordinates = modem.coordinates if not unit.project_id and modem.project_id: unit.project_id = modem.project_id + if not unit.note and modem.note: + unit.note = modem.note # Modem-specific fields unit.ip_address = ip_address if ip_address else None @@ -598,6 +618,51 @@ async def edit_roster_unit( unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None + # Bidirectional pairing sync + new_modem_id = deployed_with_modem_id if deployed_with_modem_id else None + new_unit_pair_id = deployed_with_unit_id if deployed_with_unit_id else None + + # When a device (seismograph/SLM) sets deployed_with_modem_id, update modem's deployed_with_unit_id + if device_type in ("seismograph", "slm"): + # Clear old modem's reference if modem changed + old_modem_id = db.query(RosterUnit.deployed_with_modem_id).filter( + RosterUnit.id == unit_id + ).scalar() + # old_modem_id is already the new value at this point since we set it above, + # but we need to check the *previous* modem. We already set it, so check if + # there's a modem pointing to us that we're no longer paired with. + if new_modem_id: + modem_to_update = db.query(RosterUnit).filter( + RosterUnit.id == new_modem_id, + RosterUnit.device_type == "modem" + ).first() + if modem_to_update and modem_to_update.deployed_with_unit_id != unit_id: + # Clear old device's reference to this modem if modem was paired elsewhere + if modem_to_update.deployed_with_unit_id: + old_device = db.query(RosterUnit).filter( + RosterUnit.id == modem_to_update.deployed_with_unit_id + ).first() + if old_device and old_device.deployed_with_modem_id == new_modem_id: + old_device.deployed_with_modem_id = None + modem_to_update.deployed_with_unit_id = unit_id + + # When a modem sets deployed_with_unit_id, update device's deployed_with_modem_id + if device_type == "modem": + if new_unit_pair_id: + device_to_update = db.query(RosterUnit).filter( + RosterUnit.id == new_unit_pair_id, + RosterUnit.device_type.in_(["seismograph", "slm"]) + ).first() + if device_to_update and device_to_update.deployed_with_modem_id != unit_id: + # Clear old modem's reference to this device if device was paired elsewhere + if device_to_update.deployed_with_modem_id: + old_modem = db.query(RosterUnit).filter( + RosterUnit.id == device_to_update.deployed_with_modem_id + ).first() + if old_modem and old_modem.deployed_with_unit_id == new_unit_pair_id: + old_modem.deployed_with_unit_id = None + device_to_update.deployed_with_modem_id = unit_id + # Record history entries for changed fields if old_note != note: record_history(db, unit_id, "note_change", "note", old_note, note, "manual") diff --git a/backend/services/slmm_client.py b/backend/services/slmm_client.py index ec7ee57..b6b683e 100644 --- a/backend/services/slmm_client.py +++ b/backend/services/slmm_client.py @@ -112,6 +112,69 @@ class SLMMClient: error_msg = str(e) if str(e) else type(e).__name__ raise SLMMClientError(f"Unexpected error: {error_msg}") + async def _download_request( + self, + endpoint: str, + data: Dict[str, Any], + unit_id: str, + ) -> Dict[str, Any]: + """ + Make a download request to SLMM that returns binary file content (not JSON). + + Saves the file locally and returns metadata about the download. + """ + url = f"{self.api_base}{endpoint}" + + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client: + response = await client.post(url, json=data) + response.raise_for_status() + + # Determine filename from Content-Disposition header or generate one + content_disp = response.headers.get("content-disposition", "") + filename = None + if "filename=" in content_disp: + filename = content_disp.split("filename=")[-1].strip('" ') + + if not filename: + remote_path = data.get("remote_path", "download") + base = os.path.basename(remote_path.rstrip("/")) + filename = f"{base}.zip" if not base.endswith(".zip") else base + + # Save to local downloads directory + download_dir = os.path.join("data", "downloads", unit_id) + os.makedirs(download_dir, exist_ok=True) + local_path = os.path.join(download_dir, filename) + + with open(local_path, "wb") as f: + f.write(response.content) + + return { + "success": True, + "local_path": local_path, + "filename": filename, + "size_bytes": len(response.content), + } + + except httpx.ConnectError as e: + raise SLMMConnectionError( + f"Cannot connect to SLMM backend at {self.base_url}. " + f"Is SLMM running? Error: {str(e)}" + ) + except httpx.HTTPStatusError as e: + error_detail = "Unknown error" + try: + error_data = e.response.json() + error_detail = error_data.get("detail", str(error_data)) + except Exception: + error_detail = e.response.text or str(e) + raise SLMMDeviceError(f"SLMM download failed: {error_detail}") + except (SLMMConnectionError, SLMMDeviceError): + raise + except Exception as e: + error_msg = str(e) if str(e) else type(e).__name__ + raise SLMMClientError(f"Download error: {error_msg}") + # ======================================================================== # Unit Management # ======================================================================== @@ -537,10 +600,13 @@ class SLMMClient: remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav") Returns: - Binary file content (as response) + Dict with local_path, filename, size_bytes """ - data = {"remote_path": remote_path} - return await self._request("POST", f"/{unit_id}/ftp/download", data=data) + return await self._download_request( + f"/{unit_id}/ftp/download", + {"remote_path": remote_path}, + unit_id, + ) async def download_folder( self, @@ -557,10 +623,13 @@ class SLMMClient: remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000") Returns: - Dict with local_path, folder_name, file_count, zip_size_bytes + Dict with local_path, folder_name, size_bytes """ - data = {"remote_path": remote_path} - return await self._request("POST", f"/{unit_id}/ftp/download-folder", data=data) + return await self._download_request( + f"/{unit_id}/ftp/download-folder", + {"remote_path": remote_path}, + unit_id, + ) async def download_current_measurement( self, diff --git a/templates/partials/projects/schedule_oneoff.html b/templates/partials/projects/schedule_oneoff.html index 7453a1c..8b75a53 100644 --- a/templates/partials/projects/schedule_oneoff.html +++ b/templates/partials/projects/schedule_oneoff.html @@ -36,8 +36,7 @@ + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">

Must be in the future

@@ -51,8 +50,7 @@ + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">
From caabfd0c42b2d627e7c644cf4744912ed0e5a47c Mon Sep 17 00:00:00 2001 From: serversdwn Date: Fri, 13 Feb 2026 17:02:00 +0000 Subject: [PATCH 3/3] fix: One off scheduler time now set in local time rather than UTC --- templates/partials/projects/schedule_oneoff.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/partials/projects/schedule_oneoff.html b/templates/partials/projects/schedule_oneoff.html index 8b75a53..c3e4e4d 100644 --- a/templates/partials/projects/schedule_oneoff.html +++ b/templates/partials/projects/schedule_oneoff.html @@ -114,7 +114,7 @@ function setMinDatetime() { now.setMinutes(Math.ceil(now.getMinutes() / 15) * 15); now.setSeconds(0); now.setMilliseconds(0); - const minStr = now.toISOString().slice(0, 16); + const minStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}T${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`; document.getElementById('oneoff_start_datetime').min = minStr; document.getElementById('oneoff_end_datetime').min = minStr; }