diff --git a/backend/services/scheduler.py b/backend/services/scheduler.py index a056cb4..7b9da92 100644 --- a/backend/services/scheduler.py +++ b/backend/services/scheduler.py @@ -295,9 +295,20 @@ class SchedulerService: stop_cycle handles: 1. Stop measurement 2. Enable FTP - 3. Download measurement folder - 4. Verify download + 3. Download measurement folder to SLMM local storage + + After stop_cycle, if download succeeded, this method fetches the ZIP + from SLMM and extracts it into Terra-View's project directory, creating + DataFile records for each file. """ + import hashlib + import io + import os + import zipfile + import httpx + from pathlib import Path + from backend.models import DataFile + # Parse notes for download preference include_download = True try: @@ -308,7 +319,7 @@ class SchedulerService: pass # Notes is plain text, not JSON # Execute the full stop cycle via device controller - # SLMM handles stop, FTP enable, and download + # SLMM handles stop, FTP enable, and download to SLMM-local storage cycle_response = await self.device_controller.stop_cycle( unit_id, action.device_type, @@ -340,10 +351,81 @@ class SchedulerService: except json.JSONDecodeError: pass + db.commit() + + # If SLMM downloaded the folder successfully, fetch the ZIP from SLMM + # and extract it into Terra-View's project directory, creating DataFile records + files_created = 0 + if include_download and cycle_response.get("download_success") and active_session: + folder_name = cycle_response.get("downloaded_folder") # e.g. "Auto_0058" + remote_path = f"/NL-43/{folder_name}" + + try: + SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") + async with httpx.AsyncClient(timeout=600.0) as client: + zip_response = await client.post( + f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download-folder", + json={"remote_path": remote_path} + ) + + if zip_response.is_success and len(zip_response.content) > 22: + base_dir = Path(f"data/Projects/{action.project_id}/{active_session.id}/{folder_name}") + base_dir.mkdir(parents=True, exist_ok=True) + + file_type_map = { + '.wav': 'audio', '.mp3': 'audio', + '.csv': 'data', '.txt': 'data', '.json': 'data', '.dat': 'data', + '.rnd': 'data', '.rnh': 'data', + '.log': 'log', + '.zip': 'archive', + '.jpg': 'image', '.jpeg': 'image', '.png': 'image', + '.pdf': 'document', + } + + with zipfile.ZipFile(io.BytesIO(zip_response.content)) as zf: + for zip_info in zf.filelist: + if zip_info.is_dir(): + continue + file_data = zf.read(zip_info.filename) + file_path = base_dir / zip_info.filename + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, 'wb') as f: + f.write(file_data) + checksum = hashlib.sha256(file_data).hexdigest() + ext = os.path.splitext(zip_info.filename)[1].lower() + data_file = DataFile( + id=str(uuid.uuid4()), + session_id=active_session.id, + file_path=str(file_path.relative_to("data")), + file_type=file_type_map.get(ext, 'data'), + file_size_bytes=len(file_data), + downloaded_at=datetime.utcnow(), + checksum=checksum, + file_metadata=json.dumps({ + "source": "stop_cycle", + "remote_path": remote_path, + "unit_id": unit_id, + "folder_name": folder_name, + "relative_path": zip_info.filename, + }), + ) + db.add(data_file) + files_created += 1 + + db.commit() + logger.info(f"Created {files_created} DataFile records for session {active_session.id} from {folder_name}") + else: + logger.warning(f"ZIP from SLMM for {folder_name} was empty or failed, skipping DataFile creation") + + except Exception as e: + logger.error(f"Failed to extract ZIP and create DataFile records for {folder_name}: {e}") + # Don't fail the stop action — the device was stopped successfully + return { "status": "stopped", "session_id": active_session.id if active_session else None, "cycle_response": cycle_response, + "files_created": files_created, } async def _execute_download(