From 152377d60819be16680c0ba02a4cd5fd43ef69e8 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Thu, 22 Jan 2026 20:25:47 +0000 Subject: [PATCH] feat: terra-view scheduler implementation added. Start_cylce and stop_cycle functions added. --- app/routers.py | 98 +++++++++++++++++++++++++++++ app/services.py | 161 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) diff --git a/app/routers.py b/app/routers.py index 9cf52ad..b5c4e5d 100644 --- a/app/routers.py +++ b/app/routers.py @@ -562,6 +562,104 @@ async def stop_measurement(unit_id: str, db: Session = Depends(get_db)): return {"status": "ok", "message": "Measurement stopped"} +# ============================================================================ +# CYCLE COMMANDS (for scheduled automation) +# ============================================================================ + +class StartCyclePayload(BaseModel): + """Payload for start_cycle endpoint.""" + sync_clock: bool = Field(True, description="Whether to sync device clock to server time") + + +class StopCyclePayload(BaseModel): + """Payload for stop_cycle endpoint.""" + download: bool = Field(True, description="Whether to download measurement data") + download_path: str | None = Field(None, description="Custom path for ZIP file (optional)") + + +@router.post("/{unit_id}/start-cycle") +async def start_cycle(unit_id: str, payload: StartCyclePayload = None, db: Session = Depends(get_db)): + """ + Execute complete start cycle for scheduled automation: + 1. Sync device clock to server time (if sync_clock=True) + 2. Find next safe index (increment, check overwrite, repeat if needed) + 3. Start measurement + + Use this instead of /start when automating scheduled measurements. + This ensures the device is properly prepared before recording begins. + """ + cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() + if not cfg: + raise HTTPException(status_code=404, detail="NL43 config not found") + + if not cfg.tcp_enabled: + raise HTTPException(status_code=403, detail="TCP communication is disabled for this device") + + payload = payload or StartCyclePayload() + client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password, ftp_port=cfg.ftp_port or 21) + + try: + # Ensure sleep mode is disabled before starting + await ensure_sleep_mode_disabled(client, unit_id) + + # Execute the full start cycle + result = await client.start_cycle(sync_clock=payload.sync_clock) + + # Update status in database + snap = await client.request_dod() + snap.unit_id = unit_id + persist_snapshot(snap, db) + + logger.info(f"Start cycle completed for {unit_id}: index {result['old_index']} -> {result['new_index']}") + return {"status": "ok", "unit_id": unit_id, **result} + + except Exception as e: + logger.error(f"Start cycle failed for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/{unit_id}/stop-cycle") +async def stop_cycle(unit_id: str, payload: StopCyclePayload = None, db: Session = Depends(get_db)): + """ + Execute complete stop cycle for scheduled automation: + 1. Stop measurement + 2. Enable FTP + 3. Download measurement folder (matching current index) + 4. Verify download succeeded + + Use this instead of /stop when automating scheduled measurements. + This ensures data is properly saved and downloaded before the next session. + """ + cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() + if not cfg: + raise HTTPException(status_code=404, detail="NL43 config not found") + + if not cfg.tcp_enabled: + raise HTTPException(status_code=403, detail="TCP communication is disabled for this device") + + payload = payload or StopCyclePayload() + client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password, ftp_port=cfg.ftp_port or 21) + + try: + # Execute the full stop cycle + result = await client.stop_cycle( + download=payload.download, + download_path=payload.download_path, + ) + + # Update status in database + snap = await client.request_dod() + snap.unit_id = unit_id + persist_snapshot(snap, db) + + logger.info(f"Stop cycle completed for {unit_id}: folder={result.get('downloaded_folder')}, success={result.get('download_success')}") + return {"status": "ok", "unit_id": unit_id, **result} + + except Exception as e: + logger.error(f"Stop cycle failed for {unit_id}: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + @router.post("/{unit_id}/store") async def manual_store(unit_id: str, db: Session = Depends(get_db)): """Manually store measurement data to SD card.""" diff --git a/app/services.py b/app/services.py index 6d4ca30..c1cb6b4 100644 --- a/app/services.py +++ b/app/services.py @@ -1115,3 +1115,164 @@ class NL43Client: import traceback logger.error(f"[FTP-FOLDER] Full traceback:\n{traceback.format_exc()}") raise ConnectionError(f"FTP folder download failed: {str(e)}") + + # ======================================================================== + # Cycle Commands (for scheduled automation) + # ======================================================================== + + async def start_cycle(self, sync_clock: bool = True, max_index_attempts: int = 100) -> dict: + """ + Execute complete start cycle for scheduled automation: + 1. Sync device clock to server time + 2. Find next safe index (increment, check overwrite, repeat if needed) + 3. Start measurement + + Args: + sync_clock: Whether to sync device clock to server time (default: True) + max_index_attempts: Maximum attempts to find an unused index (default: 100) + + Returns: + dict with clock_synced, old_index, new_index, attempts_made, started + """ + logger.info(f"[START-CYCLE] === Starting measurement cycle on {self.device_key} ===") + + result = { + "clock_synced": False, + "server_time": None, + "old_index": None, + "new_index": None, + "attempts_made": 0, + "started": False, + } + + # Step 1: Sync clock to server time + if sync_clock: + # Use configured timezone + server_now = datetime.now(timezone.utc) + TIMEZONE_OFFSET + server_time = server_now.strftime("%Y/%m/%d %H:%M:%S") + logger.info(f"[START-CYCLE] Step 1: Syncing clock to {server_time} ({TIMEZONE_NAME})") + await self.set_clock(server_time) + result["clock_synced"] = True + result["server_time"] = server_time + logger.info(f"[START-CYCLE] Clock synced successfully") + else: + logger.info(f"[START-CYCLE] Step 1: Skipping clock sync (sync_clock=False)") + + # Step 2: Find next safe index with overwrite protection + logger.info(f"[START-CYCLE] Step 2: Finding safe index with overwrite protection") + current_index_str = await self.get_index_number() + current_index = int(current_index_str) + result["old_index"] = current_index + logger.info(f"[START-CYCLE] Current index: {current_index}") + + test_index = current_index + 1 + attempts = 0 + + while attempts < max_index_attempts: + test_index = test_index % 10000 # Wrap at 9999 + await self.set_index_number(test_index) + attempts += 1 + + # Check if this index is safe (no existing data) + overwrite_status = await self.get_overwrite_status() + logger.info(f"[START-CYCLE] Index {test_index:04d}: overwrite status = {overwrite_status}") + + if overwrite_status == "None": + # Safe to use this index + result["new_index"] = test_index + result["attempts_made"] = attempts + logger.info(f"[START-CYCLE] Found safe index {test_index:04d} after {attempts} attempt(s)") + break + + # Data exists, try next index + test_index += 1 + + if test_index == current_index: + # Wrapped around completely - all indices have data + logger.error(f"[START-CYCLE] All indices have data! Device storage is full.") + raise Exception("All indices have data. Download and clear device storage.") + + if result["new_index"] is None: + logger.error(f"[START-CYCLE] Could not find empty index after {max_index_attempts} attempts") + raise Exception(f"Could not find empty index after {max_index_attempts} attempts") + + # Step 3: Start measurement + logger.info(f"[START-CYCLE] Step 3: Starting measurement") + await self.start() + result["started"] = True + logger.info(f"[START-CYCLE] === Measurement started successfully ===") + + return result + + async def stop_cycle(self, download: bool = True, download_path: str = None) -> dict: + """ + Execute complete stop cycle for scheduled automation: + 1. Stop measurement + 2. Enable FTP + 3. Download measurement folder (matching current index) + 4. Verify download succeeded + + Args: + download: Whether to download measurement data (default: True) + download_path: Custom path for ZIP file (default: data/downloads/{device_key}/Auto_XXXX.zip) + + Returns: + dict with stopped, ftp_enabled, download_attempted, download_success, etc. + """ + logger.info(f"[STOP-CYCLE] === Stopping measurement cycle on {self.device_key} ===") + + result = { + "stopped": False, + "ftp_enabled": False, + "download_attempted": False, + "download_success": False, + "downloaded_folder": None, + "local_path": None, + } + + # Step 1: Stop measurement + logger.info(f"[STOP-CYCLE] Step 1: Stopping measurement") + await self.stop() + result["stopped"] = True + logger.info(f"[STOP-CYCLE] Measurement stopped") + + # Step 2: Enable FTP + logger.info(f"[STOP-CYCLE] Step 2: Enabling FTP") + await self.enable_ftp() + result["ftp_enabled"] = True + logger.info(f"[STOP-CYCLE] FTP enabled") + + if not download: + logger.info(f"[STOP-CYCLE] === Cycle complete (download=False) ===") + return result + + # Step 3: Get current index to know which folder to download + logger.info(f"[STOP-CYCLE] Step 3: Determining folder to download") + current_index_str = await self.get_index_number() + # Pad to 4 digits for folder name + folder_name = f"Auto_{current_index_str.zfill(4)}" + remote_path = f"/NL-43/{folder_name}" + result["downloaded_folder"] = folder_name + result["download_attempted"] = True + logger.info(f"[STOP-CYCLE] Will download folder: {remote_path}") + + # Step 4: Download the folder + if download_path is None: + # Default path: data/downloads/{device_key}/Auto_XXXX.zip + download_dir = f"data/downloads/{self.device_key}" + os.makedirs(download_dir, exist_ok=True) + download_path = os.path.join(download_dir, f"{folder_name}.zip") + + logger.info(f"[STOP-CYCLE] Step 4: Downloading to {download_path}") + try: + await self.download_ftp_folder(remote_path, download_path) + result["download_success"] = True + result["local_path"] = download_path + logger.info(f"[STOP-CYCLE] Download successful: {download_path}") + except Exception as e: + logger.error(f"[STOP-CYCLE] Download failed: {e}") + # Don't raise - the stop was successful, just the download failed + result["download_error"] = str(e) + + logger.info(f"[STOP-CYCLE] === Cycle complete ===") + return result