diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 45ee26d..0b89433 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -744,6 +744,132 @@ async def ftp_download_to_server( ) +@router.post("/{project_id}/ftp-download-folder-to-server") +async def ftp_download_folder_to_server( + project_id: str, + request: Request, + db: Session = Depends(get_db), +): + """ + Download an entire folder from an SLM to the server via FTP as a ZIP file. + Creates a DataFile record and stores the ZIP in data/Projects/{project_id}/ + """ + import httpx + import os + import hashlib + from pathlib import Path + from backend.models import DataFile + + data = await request.json() + unit_id = data.get("unit_id") + remote_path = data.get("remote_path") + location_id = data.get("location_id") + + if not unit_id or not remote_path: + raise HTTPException(status_code=400, detail="Missing unit_id or remote_path") + + # Get or create active session for this location/unit + session = db.query(RecordingSession).filter( + and_( + RecordingSession.project_id == project_id, + RecordingSession.location_id == location_id, + RecordingSession.unit_id == unit_id, + RecordingSession.status.in_(["recording", "paused"]) + ) + ).first() + + # If no active session, create one + if not session: + session = RecordingSession( + id=str(uuid.uuid4()), + project_id=project_id, + location_id=location_id, + unit_id=unit_id, + status="completed", + started_at=datetime.utcnow(), + stopped_at=datetime.utcnow(), + notes="Auto-created for FTP folder download" + ) + db.add(session) + db.commit() + db.refresh(session) + + # Download folder from SLMM + SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") + + try: + async with httpx.AsyncClient(timeout=600.0) as client: # Longer timeout for folders + response = await client.post( + f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download-folder", + json={"remote_path": remote_path} + ) + + if not response.is_success: + raise HTTPException( + status_code=response.status_code, + detail=f"Failed to download folder from SLMM: {response.text}" + ) + + # Extract folder name from remote_path + folder_name = os.path.basename(remote_path.rstrip('/')) + filename = f"{folder_name}.zip" + + # Create directory structure: data/Projects/{project_id}/{session_id}/ + project_dir = Path(f"data/Projects/{project_id}/{session.id}") + project_dir.mkdir(parents=True, exist_ok=True) + + # Save ZIP file to disk + file_path = project_dir / filename + file_content = response.content + + with open(file_path, 'wb') as f: + f.write(file_content) + + # Calculate checksum + checksum = hashlib.sha256(file_content).hexdigest() + + # Create DataFile record + data_file = DataFile( + id=str(uuid.uuid4()), + session_id=session.id, + file_path=str(file_path.relative_to("data")), # Store relative to data/ + file_type='archive', # ZIP archives + file_size_bytes=len(file_content), + downloaded_at=datetime.utcnow(), + checksum=checksum, + file_metadata=json.dumps({ + "source": "ftp_folder", + "remote_path": remote_path, + "unit_id": unit_id, + "location_id": location_id, + "folder_name": folder_name, + }) + ) + + db.add(data_file) + db.commit() + + return { + "success": True, + "message": f"Downloaded folder {folder_name} to server as ZIP", + "file_id": data_file.id, + "file_path": str(file_path), + "file_size": len(file_content), + } + + except httpx.TimeoutException: + raise HTTPException( + status_code=504, + detail="Timeout downloading folder from SLM (large folders may take a while)" + ) + except Exception as e: + logger.error(f"Error downloading folder to server: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to download folder to server: {str(e)}" + ) + + # ============================================================================ # Project Types # ============================================================================ diff --git a/backend/routers/slm_dashboard.py b/backend/routers/slm_dashboard.py index 9107b3c..9b20456 100644 --- a/backend/routers/slm_dashboard.py +++ b/backend/routers/slm_dashboard.py @@ -158,7 +158,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge is_measuring = False try: - async with httpx.AsyncClient(timeout=5.0) as client: + async with httpx.AsyncClient(timeout=10.0) as client: # Get measurement state state_response = await client.get( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state" @@ -168,7 +168,23 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge measurement_state = state_data.get("measurement_state", "Unknown") is_measuring = state_data.get("is_measuring", False) - # Get live status + # If measuring, sync start time from FTP to database (fixes wrong timestamps) + if is_measuring: + try: + sync_response = await client.post( + f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time", + timeout=10.0 + ) + if sync_response.status_code == 200: + sync_data = sync_response.json() + logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}") + else: + logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}") + except Exception as e: + # Don't fail the whole request if sync fails + logger.warning(f"Could not sync start time for {unit_id}: {e}") + + # Get live status (now with corrected start time) status_response = await client.get( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live" ) diff --git a/templates/partials/projects/ftp_browser.html b/templates/partials/projects/ftp_browser.html index 607af7a..568d7a0 100644 --- a/templates/partials/projects/ftp_browser.html +++ b/templates/partials/projects/ftp_browser.html @@ -1,4 +1,4 @@ - +

Download Files from SLMs

@@ -34,7 +34,7 @@ disabled> Disable FTP - + +
+ ` : `
- ` : ''} + `} `; } @@ -280,6 +296,90 @@ async function downloadFTPFile(unitId, remotePath, fileName) { } } +async function downloadFTPFolder(unitId, remotePath, folderName) { + const btn = event.target; + const originalHTML = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Downloading...'; + + try { + const response = await fetch(`/api/slmm/${unitId}/ftp/download-folder`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + remote_path: remotePath + }) + }); + + if (response.ok) { + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${folderName}.zip`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + // Show success message + alert(`✓ Folder "${folderName}" downloaded successfully as ZIP file!`); + } else { + const errorData = await response.json(); + alert('Folder download failed: ' + (errorData.detail || 'Unknown error')); + } + } catch (error) { + alert('Error downloading folder: ' + error); + } finally { + btn.disabled = false; + btn.innerHTML = originalHTML; + } +} + +async function downloadFolderToServer(unitId, remotePath, folderName) { + const btn = event.target; + const originalHTML = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Downloading...'; + + // Get location_id from the unit's data attribute + const unitContainer = btn.closest('[id^="ftp-files-"]'); + const locationId = unitContainer.dataset.locationId; + + try { + const response = await fetch(`/api/projects/{{ project_id }}/ftp-download-folder-to-server`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + unit_id: unitId, + remote_path: remotePath, + location_id: locationId + }) + }); + + const data = await response.json(); + + if (response.ok) { + // Show success message + alert(`✓ Folder "${folderName}" downloaded to server successfully as ZIP!\n\nFile ID: ${data.file_id}\nSize: ${formatFileSize(data.file_size)}`); + + // Refresh the downloaded files list + htmx.trigger('#project-files', 'refresh'); + } else { + alert('Folder download to server failed: ' + (data.detail || 'Unknown error')); + } + } catch (error) { + alert('Error downloading folder to server: ' + error); + } finally { + btn.disabled = false; + btn.innerHTML = originalHTML; + } +} + async function downloadToServer(unitId, remotePath, fileName) { const btn = event.target; const originalText = btn.innerHTML; diff --git a/templates/partials/slm_live_view.html b/templates/partials/slm_live_view.html index ae2ff03..c6a942d 100644 --- a/templates/partials/slm_live_view.html +++ b/templates/partials/slm_live_view.html @@ -1,5 +1,15 @@
+ + +
@@ -201,7 +211,7 @@
+ style="width: {% if current_status and current_status.battery_level %}{{ current_status.battery_level }}{% else %}0{% endif %}%">
@@ -934,9 +944,15 @@ function startMeasurementTimer(unitId) { // Stop any existing timer stopMeasurementTimer(); - // Record start time in localStorage - const startTime = Date.now(); - localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTime.toString()); + // Only set start time if not already set (preserve existing start time) + const existingStartTime = localStorage.getItem(TIMER_STORAGE_KEY + unitId); + if (!existingStartTime) { + const startTime = Date.now(); + localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTime.toString()); + console.log('No existing start time, setting to now:', new Date(startTime)); + } else { + console.log('Using existing start time from localStorage:', new Date(parseInt(existingStartTime))); + } // Show timer container const container = document.getElementById('elapsed-time-container'); @@ -1023,12 +1039,19 @@ async function resumeMeasurementTimerIfNeeded(unitId, isMeasuring) { // Fetch measurement start time from last folder on FTP async function fetchStartTimeFromFTP(unitId) { try { + console.log('Fetching FTP files from /NL-43...'); const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=/NL-43`); const result = await response.json(); + console.log('FTP response status:', response.status); + console.log('FTP response data:', result); + if (result.status === 'ok' && result.files && result.files.length > 0) { + console.log(`Found ${result.files.length} files/folders`); + // Filter for directories only const folders = result.files.filter(f => f.is_dir || f.type === 'directory'); + console.log(`Found ${folders.length} folders:`, folders.map(f => f.name)); if (folders.length > 0) { // Sort by modified timestamp (newest first) or by name @@ -1050,26 +1073,27 @@ async function fetchStartTimeFromFTP(unitId) { const startTime = parseFolderTimestamp(lastFolder); if (startTime) { - console.log('Parsed start time from folder:', new Date(startTime)); + console.log('✓ Parsed start time from folder:', new Date(startTime)); + console.log('✓ Storing start time in localStorage:', startTime); localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTime.toString()); startMeasurementTimer(unitId); } else { // Can't parse folder time - start from now - console.warn('Could not parse folder timestamp, starting timer from now'); + console.warn('✗ Could not parse folder timestamp, starting timer from now'); startMeasurementTimer(unitId); } } else { // No folders found - start from now - console.warn('No measurement folders found, starting timer from now'); + console.warn('✗ No measurement folders found in FTP response, starting timer from now'); startMeasurementTimer(unitId); } } else { // FTP failed or no files - start from now - console.warn('Could not access FTP, starting timer from now'); + console.warn('✗ FTP request failed or returned no files:', result); startMeasurementTimer(unitId); } } catch (error) { - console.error('Error fetching start time from FTP:', error); + console.error('✗ Error fetching start time from FTP:', error); // Fallback - start from now startMeasurementTimer(unitId); } @@ -1077,6 +1101,8 @@ async function fetchStartTimeFromFTP(unitId) { // Parse timestamp from folder name or modified time function parseFolderTimestamp(folder) { + console.log('Parsing timestamp from folder:', folder.name); + // Try parsing from folder name first // Expected formats: YYYYMMDD_HHMMSS or YYYY-MM-DD_HH-MM-SS const name = folder.name; @@ -1086,7 +1112,9 @@ function parseFolderTimestamp(folder) { const match1 = name.match(pattern1); if (match1) { const [_, year, month, day, hour, min, sec] = match1; - return new Date(year, month - 1, day, hour, min, sec).getTime(); + const timestamp = new Date(year, month - 1, day, hour, min, sec).getTime(); + console.log(` ✓ Matched pattern YYYYMMDD_HHMMSS: ${new Date(timestamp)}`); + return timestamp; } // Pattern: YYYY-MM-DD_HH-MM-SS (e.g., 2025-01-14_14-30-52) @@ -1094,27 +1122,91 @@ function parseFolderTimestamp(folder) { const match2 = name.match(pattern2); if (match2) { const [_, year, month, day, hour, min, sec] = match2; - return new Date(year, month - 1, day, hour, min, sec).getTime(); + const timestamp = new Date(year, month - 1, day, hour, min, sec).getTime(); + console.log(` ✓ Matched pattern YYYY-MM-DD_HH-MM-SS: ${new Date(timestamp)}`); + return timestamp; } - // Try using modified_timestamp (ISO format from SLMM) + console.log(' No pattern matched in folder name, trying modified_timestamp field'); + + // Try using modified_timestamp (ISO format from SLMM - already in UTC) if (folder.modified_timestamp) { - return new Date(folder.modified_timestamp).getTime(); + // SLMM returns timestamps in UTC format without 'Z' suffix + // Append 'Z' to ensure browser parses as UTC + let utcTimestamp = folder.modified_timestamp; + if (!utcTimestamp.endsWith('Z')) { + utcTimestamp = utcTimestamp + 'Z'; + } + const timestamp = new Date(utcTimestamp).getTime(); + console.log(` ✓ Using modified_timestamp (UTC): ${folder.modified_timestamp} → ${new Date(timestamp).toISOString()} → ${new Date(timestamp).toString()}`); + return timestamp; } // Fallback to modified (string format) if (folder.modified) { const parsedTime = new Date(folder.modified).getTime(); if (!isNaN(parsedTime)) { + console.log(` ✓ Using modified field: ${new Date(parsedTime)}`); return parsedTime; } + console.log(` ✗ Could not parse modified field: ${folder.modified}`); } // Could not parse + console.log(' ✗ Could not parse any timestamp from folder'); return null; } +// Apply status data to the DOM (used by bootstrap data and live refresh) +function applyDeviceStatusData(data) { + if (!data) { + return; + } + + const batteryLevelRaw = data.battery_level ?? '--'; + const batteryLevelElement = document.getElementById('battery-level'); + if (batteryLevelElement) { + const displayBattery = batteryLevelRaw === '' || batteryLevelRaw === '--' + ? '--' + : `${batteryLevelRaw}%`; + batteryLevelElement.textContent = displayBattery; + } + + const batteryBar = document.getElementById('battery-bar'); + if (batteryBar && batteryLevelRaw !== '' && batteryLevelRaw !== '--') { + const level = Number(batteryLevelRaw); + if (!Number.isNaN(level)) { + batteryBar.style.width = `${level}%`; + if (level > 50) { + batteryBar.className = 'bg-green-500 h-2 rounded-full transition-all'; + } else if (level > 20) { + batteryBar.className = 'bg-yellow-500 h-2 rounded-full transition-all'; + } else { + batteryBar.className = 'bg-red-500 h-2 rounded-full transition-all'; + } + } + } + + if (document.getElementById('power-source')) { + document.getElementById('power-source').textContent = data.power_source || '--'; + } + + if (document.getElementById('sd-remaining')) { + const sdRemainingRaw = data.sd_remaining_mb ?? '--'; + document.getElementById('sd-remaining').textContent = sdRemainingRaw === '--' + ? '--' + : `${sdRemainingRaw} MB`; + } + if (document.getElementById('sd-ratio')) { + const sdRatioRaw = data.sd_free_ratio ?? '--'; + document.getElementById('sd-ratio').textContent = sdRatioRaw === '--' + ? '--' + : `${sdRatioRaw}% free`; + } +} + + // Auto-refresh status every 30 seconds let refreshInterval; const REFRESH_INTERVAL_MS = 30000; // 30 seconds @@ -1125,46 +1217,8 @@ function updateDeviceStatus() { .then(response => response.json()) .then(result => { if (result.status === 'ok' && result.data) { - const data = result.data; + applyDeviceStatusData(result.data); - // Update battery - if (document.getElementById('battery-level')) { - const batteryLevel = data.battery_level || '--'; - document.getElementById('battery-level').textContent = batteryLevel === '--' ? '--' : `${batteryLevel}%`; - - // Update battery bar - const batteryBar = document.getElementById('battery-bar'); - if (batteryBar && batteryLevel !== '--') { - const level = parseInt(batteryLevel); - batteryBar.style.width = `${level}%`; - - // Color based on level - if (level > 50) { - batteryBar.className = 'bg-green-500 h-2 rounded-full transition-all'; - } else if (level > 20) { - batteryBar.className = 'bg-yellow-500 h-2 rounded-full transition-all'; - } else { - batteryBar.className = 'bg-red-500 h-2 rounded-full transition-all'; - } - } - } - - // Update power source - if (document.getElementById('power-source')) { - document.getElementById('power-source').textContent = data.power_source || '--'; - } - - // Update SD card info - if (document.getElementById('sd-remaining')) { - const sdRemaining = data.sd_remaining_mb || '--'; - document.getElementById('sd-remaining').textContent = sdRemaining === '--' ? '--' : `${sdRemaining} MB`; - } - if (document.getElementById('sd-ratio')) { - const sdRatio = data.sd_free_ratio || '--'; - document.getElementById('sd-ratio').textContent = sdRatio === '--' ? '--' : `${sdRatio}% free`; - } - - // Update last update timestamp if (document.getElementById('last-update')) { const now = new Date(); document.getElementById('last-update').textContent = now.toLocaleTimeString(); @@ -1199,21 +1253,51 @@ function stopAutoRefresh() { } } -// Start auto-refresh and load initial data when page loads -document.addEventListener('DOMContentLoaded', function() { +// Bootstrap data from server +const SLM_BOOTSTRAP_DATA = JSON.parse(document.getElementById('slm-bootstrap-data').textContent); + +// Initialize immediately when script loads (HTMX loads this partial after DOMContentLoaded) +(async function() { + console.log('Initializing SLM live view...'); + + const unitId = SLM_BOOTSTRAP_DATA.unit_id; + const isMeasuring = SLM_BOOTSTRAP_DATA.is_measuring; + const measurementStartTime = SLM_BOOTSTRAP_DATA.measurement_start_time; + console.log('Is measuring:', isMeasuring); + console.log('Measurement start time from backend:', measurementStartTime); + + // Start auto-refresh startAutoRefresh(); - // Load initial device settings - const unitId = '{{ unit.id }}'; - getDeviceClock(unitId); - getIndexNumber(unitId); - getFrequencyWeighting(unitId); - getTimeWeighting(unitId); + // Load initial device settings with delays (NL-43 requires 1 second between commands) + // These are background updates, so we can space them out + setTimeout(() => getIndexNumber(unitId), 1500); + setTimeout(() => getDeviceClock(unitId), 3000); + setTimeout(() => getFrequencyWeighting(unitId), 4500); + setTimeout(() => getTimeWeighting(unitId), 6000); - // Resume measurement timer if device is currently measuring - const isMeasuring = {{ 'true' if is_measuring else 'false' }}; - resumeMeasurementTimerIfNeeded(unitId, isMeasuring); -}); + // Initialize measurement timer if device is currently measuring + if (isMeasuring && measurementStartTime) { + // Backend has synced the start time from FTP, so database timestamp is now accurate + let utcTimestamp = measurementStartTime; + if (!utcTimestamp.endsWith('Z') && !utcTimestamp.includes('+') && !utcTimestamp.includes('-', 10)) { + utcTimestamp = utcTimestamp + 'Z'; + } + + const startTimeMs = new Date(utcTimestamp).getTime(); + localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTimeMs.toString()); + + console.log('✓ Timer initialized from synced database timestamp'); + console.log(' Start time (UTC):', new Date(startTimeMs).toISOString()); + console.log(' Start time (Local):', new Date(startTimeMs).toString()); + + startMeasurementTimer(unitId); + } else if (isMeasuring && !measurementStartTime) { + // Fallback: Measurement active but no start time - try FTP + console.warn('⚠ Measurement active but no start time, fetching from FTP...'); + setTimeout(() => resumeMeasurementTimerIfNeeded(unitId, isMeasuring), 500); + } +})(); // Cleanup on page unload window.addEventListener('beforeunload', function() { @@ -1485,12 +1569,22 @@ async function loadFTPFiles(unitId, path) { if (isDir) { html += ` -
-
+
+
${icon} ${escapeHtml(file.name)}
- ${dateText} +
+ + +
`; } else { @@ -1593,6 +1687,58 @@ async function downloadFTPFile(unitId, filePath, fileName) { } } +async function downloadFTPFolderModal(unitId, folderPath, folderName, btnElement) { + // Get the button element - either passed as argument or from event + const downloadBtn = btnElement || event.target; + const originalText = downloadBtn.innerHTML; + + try { + // Show download indicator + downloadBtn.innerHTML = ''; + downloadBtn.disabled = true; + + const response = await fetch(`/api/slmm/${unitId}/ftp/download-folder`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ remote_path: folderPath }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Folder download failed'); + } + + // The response is a ZIP file + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${folderName}.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + // Reset button + downloadBtn.innerHTML = originalText; + downloadBtn.disabled = false; + + // Show success message briefly + const originalBtnClass = downloadBtn.className; + downloadBtn.className = downloadBtn.className.replace('bg-blue-600', 'bg-green-600'); + setTimeout(() => { + downloadBtn.className = originalBtnClass; + }, 2000); + + } catch (error) { + console.error('Failed to download folder:', error); + alert('Failed to download folder: ' + error.message); + // Reset button on error + downloadBtn.innerHTML = originalText; + downloadBtn.disabled = false; + } +} + async function downloadToServer(unitId, filePath, fileName) { try { const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {