+ Set the device clock manually or sync to your computer's current time. Device must be stopped to change clock.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Store Name (Index)
+
+
+ ----
+
+
+
+
+
+ Set the file index number (0000-9999) for the next measurement storage. Requirements: Device must be stopped and FTP must be disabled to change this setting.
+
+
+
+
+
+
+
Data Already Exists
+
Starting a measurement will overwrite existing data at this index. Consider using auto-increment to find an empty slot.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Measurement Data Files
+
+
+
+
+
+
+
+
+
+
+
+ FTP server is disabled. Click "Enable FTP" to browse files.
+
+
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
Click "Refresh" to load files from the device
+
+
+
+
+
+
+
Loading files...
+
+
+
+
+
+
+ Failed to load files
+
+
+
+
@@ -236,9 +414,14 @@ function initializeChart() {
if (window.liveChart && typeof window.liveChart.destroy === 'function') {
console.log('Destroying existing chart');
window.liveChart.destroy();
+ window.liveChart = null;
}
const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ console.error('Failed to get 2D context from canvas');
+ return;
+ }
console.log('Creating new chart...');
// Dark mode detection
@@ -246,7 +429,8 @@ function initializeChart() {
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
- window.liveChart = new Chart(ctx, {
+ try {
+ window.liveChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
@@ -317,12 +501,33 @@ function initializeChart() {
}
});
- console.log('Chart created successfully:', window.liveChart);
+ console.log('Chart created successfully:', window.liveChart);
+ } catch (error) {
+ console.error('Failed to create chart:', error);
+ // Display error message in the chart container
+ const container = canvas.parentElement;
+ if (container) {
+ container.innerHTML = `
+
+
+
+
Chart failed to load
+
Try refreshing the page
+
+
+ `;
+ }
+ }
}
// Initialize chart when DOM is ready
console.log('Executing initializeChart...');
-initializeChart();
+// Use a small delay to ensure DOM is fully ready
+setTimeout(() => {
+ initializeChart();
+}, 50);
// WebSocket management (use global scope to avoid redeclaration)
if (typeof window.currentWebSocket === 'undefined') {
@@ -455,13 +660,19 @@ async function controlUnit(unitId, action) {
const result = await response.json();
if (result.status === 'ok') {
- // Reload the live view to update status
+ // Give device more time to change state before checking
+ // Start/Stop commands need time to propagate
+ setTimeout(() => {
+ checkMeasurementState();
+ }, 1500);
+
+ // Reload the live view to update all status displays
setTimeout(() => {
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
target: '#live-view-panel',
swap: 'innerHTML'
});
- }, 500);
+ }, 2000);
} else {
alert(`Error: ${result.detail || 'Unknown error'}`);
}
@@ -472,7 +683,9 @@ async function controlUnit(unitId, action) {
// Auto-refresh status every 30 seconds
-let refreshInterval;
+if (typeof window.refreshInterval === 'undefined') {
+ window.refreshInterval = null;
+}
const REFRESH_INTERVAL_MS = 30000; // 30 seconds
const unit_id = '{{ unit.id }}';
@@ -556,6 +769,182 @@ function stopAutoRefresh() {
}
// Start auto-refresh when page loads
+
+
+// Backend-Synced Measurement Timer
+// Uses measurement_start_time from backend (tracked via state transitions)
+// This works even for manually-started measurements!
+if (typeof window.timerInterval === 'undefined') {
+ window.timerInterval = null;
+ window.measurementStartTime = null; // ISO string from backend
+}
+
+// Format elapsed time as HH:MM:SS
+function formatElapsedTime(milliseconds) {
+ const totalSeconds = Math.floor(milliseconds / 1000);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ return hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0');
+}
+
+// Update timer display based on backend measurement_start_time
+function updateTimerDisplay() {
+ const timerElement = document.getElementById('timer-display');
+ if (!timerElement) {
+ console.error('Timer display element not found!');
+ return;
+ }
+
+ if (!window.measurementStartTime) {
+ timerElement.textContent = '00:00:00';
+ return;
+ }
+
+ // Calculate elapsed time from backend-provided start time (UTC)
+ // Ensure the timestamp is treated as UTC by adding 'Z' if not present
+ let timestamp = window.measurementStartTime;
+ if (!timestamp.endsWith('Z') && !timestamp.includes('+')) {
+ timestamp = timestamp + 'Z';
+ }
+ const startTime = new Date(timestamp);
+ const elapsed = Date.now() - startTime.getTime();
+ const formattedTime = formatElapsedTime(elapsed);
+ timerElement.textContent = formattedTime;
+}
+
+// Sync timer with backend data (with FTP fallback)
+async function syncTimerWithBackend(measurementState, measurementStartTime) {
+ // Device returns "Start" when measuring, "Stop" when stopped
+ const isMeasuring = measurementState === 'Start';
+
+ if (isMeasuring && measurementStartTime) {
+ // Measurement is running - check both backend and FTP timestamps
+ // Use whichever is earlier (older = actual measurement start)
+
+ // First check FTP for potentially older timestamp
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`);
+ const result = await response.json();
+
+ if (result.status === 'ok' && result.latest_timestamp) {
+ const backendTime = new Date(measurementStartTime + 'Z');
+ const ftpTime = new Date(result.latest_timestamp + 'Z');
+
+ // Use the earlier timestamp (represents actual measurement start)
+ if (ftpTime < backendTime) {
+ window.measurementStartTime = result.latest_timestamp;
+ window.timerSource = 'ftp';
+ console.log('Timer synced with FTP folder (earlier):', result.latest_folder, '@', result.latest_timestamp);
+ } else {
+ window.measurementStartTime = measurementStartTime;
+ window.timerSource = 'backend';
+ console.log('Timer synced with backend state (earlier):', measurementStartTime);
+ }
+ } else {
+ // No FTP timestamp, use backend
+ window.measurementStartTime = measurementStartTime;
+ window.timerSource = 'backend';
+ console.log('Timer synced with backend state:', measurementStartTime);
+ }
+ } catch (error) {
+ console.error('Failed to check FTP timestamp:', error);
+ // Fallback to backend on error
+ window.measurementStartTime = measurementStartTime;
+ window.timerSource = 'backend';
+ console.log('Timer synced with backend state (FTP check failed):', measurementStartTime);
+ }
+
+ // Start interval if not already running
+ if (!window.timerInterval) {
+ window.timerInterval = setInterval(updateTimerDisplay, 1000);
+ console.log('Timer display started');
+ }
+
+ // Update display immediately
+ updateTimerDisplay();
+ } else if (isMeasuring && !measurementStartTime) {
+ // Device is measuring but backend doesn't have start time yet
+ // Try FTP fallback to get measurement start from latest folder timestamp
+ if (!window.measurementStartTime || window.timerSource !== 'ftp') {
+ console.log('Device measuring but no backend start time - checking FTP fallback...');
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`);
+ const result = await response.json();
+
+ if (result.status === 'ok' && result.latest_timestamp) {
+ window.measurementStartTime = result.latest_timestamp;
+ window.timerSource = 'ftp';
+ console.log('Timer synced with FTP folder:', result.latest_folder, '@', result.latest_timestamp);
+
+ // Start timer interval if not already running
+ if (!window.timerInterval) {
+ window.timerInterval = setInterval(updateTimerDisplay, 1000);
+ console.log('Timer display started (FTP source)');
+ }
+
+ updateTimerDisplay();
+ } else {
+ console.log('No FTP timestamp available');
+ }
+ } catch (error) {
+ console.error('Failed to get FTP timestamp:', error);
+ }
+ }
+ } else {
+ // Not measuring - stop timer
+ if (window.timerInterval) {
+ clearInterval(window.timerInterval);
+ window.timerInterval = null;
+ console.log('Timer display stopped');
+ }
+ window.measurementStartTime = null;
+ window.timerSource = null;
+ document.getElementById('timer-display').textContent = '00:00:00';
+ }
+}
+
+// Check measurement state and sync timer
+async function checkMeasurementState() {
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/live`);
+ const result = await response.json();
+
+ if (result.status === 'ok' && result.data) {
+ await syncTimerWithBackend(
+ result.data.measurement_state,
+ result.data.measurement_start_time
+ );
+ }
+ } catch (error) {
+ console.error('Failed to check measurement state:', error);
+ }
+}
+
+// Initialize timer immediately (this partial is loaded via HTMX, so DOMContentLoaded may have already fired)
+if (typeof window.timerInitialized === 'undefined') {
+ window.timerInitialized = true;
+
+ // Execute initialization immediately
+ (async function() {
+ console.log('Timer initialization: Starting immediately');
+
+ // Check measurement state immediately
+ await checkMeasurementState();
+
+ // Load store name (index number)
+ await loadStoreName();
+
+ // Check FTP status and update buttons
+ await checkFTPStatus();
+
+ // Check measurement state periodically (every 5 seconds)
+ setInterval(checkMeasurementState, 5000);
+
+ console.log('Timer initialization: Complete, polling every 5 seconds');
+ })();
+}
document.addEventListener('DOMContentLoaded', startAutoRefresh);
// Cleanup on page unload
@@ -564,4 +953,510 @@ window.addEventListener('beforeunload', function() {
window.currentWebSocket.close();
}
});
+
+
+// File Browser Functions
+if (typeof window.currentPath === 'undefined') {
+ window.currentPath = '/';
+}
+
+// Format file size
+function formatFileSize(bytes) {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
+}
+
+// Format date
+function formatDate(dateStr) {
+ try {
+ const date = new Date(dateStr);
+ return date.toLocaleString();
+ } catch (e) {
+ return dateStr;
+ }
+}
+
+// Enable FTP on device
+async function enableFTP() {
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/ftp/enable`, {
+ method: 'POST'
+ });
+ const result = await response.json();
+
+ if (result.status === 'ok') {
+ document.getElementById('ftp-status').classList.add('hidden');
+ updateFTPButtons(true);
+ refreshFileList();
+ } else {
+ alert('Failed to enable FTP: ' + (result.detail || 'Unknown error'));
+ }
+ } catch (error) {
+ alert('Failed to enable FTP: ' + error.message);
+ }
+}
+
+async function disableFTP() {
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/ftp/disable`, {
+ method: 'POST'
+ });
+ const result = await response.json();
+
+ if (result.status === 'ok') {
+ updateFTPButtons(false);
+ alert('FTP disabled. TCP commands are now available.');
+ } else {
+ alert('Failed to disable FTP: ' + (result.detail || 'Unknown error'));
+ }
+ } catch (error) {
+ alert('Failed to disable FTP: ' + error.message);
+ }
+}
+
+function updateFTPButtons(ftpEnabled) {
+ const enableBtn = document.getElementById('enable-ftp-btn');
+ const disableBtn = document.getElementById('disable-ftp-btn');
+
+ console.log('updateFTPButtons called with ftpEnabled:', ftpEnabled);
+ console.log('Enable button element:', enableBtn);
+ console.log('Disable button element:', disableBtn);
+
+ if (enableBtn && disableBtn) {
+ if (ftpEnabled) {
+ enableBtn.classList.add('hidden');
+ disableBtn.classList.remove('hidden');
+ console.log('FTP is enabled - showing Disable FTP button');
+ } else {
+ enableBtn.classList.remove('hidden');
+ disableBtn.classList.add('hidden');
+ console.log('FTP is disabled - showing Enable FTP button');
+ }
+ } else {
+ console.error('FTP button elements not found in DOM');
+ }
+}
+
+async function checkFTPStatus() {
+ console.log('checkFTPStatus: Checking FTP status for unit_id:', unit_id);
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/ftp/status`);
+ const result = await response.json();
+
+ console.log('checkFTPStatus: Response:', result);
+
+ if (result.status === 'ok') {
+ console.log('checkFTPStatus: FTP enabled status:', result.ftp_enabled);
+ updateFTPButtons(result.ftp_enabled);
+ } else {
+ console.error('checkFTPStatus: Unexpected response status:', result.status);
+ }
+ } catch (error) {
+ console.error('checkFTPStatus: Failed to check FTP status:', error);
+ }
+}
+
+// Clock Management Functions
+async function syncClockToNow() {
+ const now = new Date();
+ // Format as YYYY-MM-DDTHH:mm for datetime-local input
+ const localDateTime = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
+
+ // Update the input field
+ document.getElementById('clock-input').value = localDateTime;
+
+ // Auto-submit
+ await setDeviceClock();
+}
+
+async function setDeviceClock() {
+ const clockInput = document.getElementById('clock-input');
+ const statusDiv = document.getElementById('clock-status');
+
+ if (!clockInput.value) {
+ showClockStatus('Please select a date and time', 'error');
+ return;
+ }
+
+ // Convert datetime-local to NL43 format: YYYY/MM/DD,HH:MM:SS
+ const dateTime = new Date(clockInput.value);
+ const nl43Format = dateTime.getFullYear() + '/' +
+ String(dateTime.getMonth() + 1).padStart(2, '0') + '/' +
+ String(dateTime.getDate()).padStart(2, '0') + ',' +
+ String(dateTime.getHours()).padStart(2, '0') + ':' +
+ String(dateTime.getMinutes()).padStart(2, '0') + ':' +
+ String(dateTime.getSeconds()).padStart(2, '0');
+
+ showClockStatus('Setting device clock...', 'info');
+
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/clock`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ datetime: nl43Format })
+ });
+
+ const result = await response.json();
+
+ if (response.ok && result.status === 'ok') {
+ showClockStatus('✓ Clock set successfully to ' + dateTime.toLocaleString(), 'success');
+ } else {
+ showClockStatus('✗ Failed: ' + (result.detail || 'Unknown error'), 'error');
+ }
+ } catch (error) {
+ showClockStatus('✗ Failed: ' + error.message, 'error');
+ }
+}
+
+function showClockStatus(message, type) {
+ const statusDiv = document.getElementById('clock-status');
+ statusDiv.textContent = message;
+ statusDiv.classList.remove('hidden', 'text-green-600', 'text-red-600', 'text-blue-600');
+
+ if (type === 'success') {
+ statusDiv.classList.add('text-green-600', 'dark:text-green-400');
+ } else if (type === 'error') {
+ statusDiv.classList.add('text-red-600', 'dark:text-red-400');
+ } else {
+ statusDiv.classList.add('text-blue-600', 'dark:text-blue-400');
+ }
+}
+
+// Store Name (Index) Management Functions
+async function loadStoreName() {
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/index-number`);
+ const result = await response.json();
+
+ if (response.ok && result.status === 'ok') {
+ const indexNum = result.index_number;
+ document.getElementById('store-name-display').textContent = indexNum;
+ document.getElementById('store-name-input').value = parseInt(indexNum);
+
+ // Check overwrite status after loading
+ await checkOverwriteStatus();
+ } else {
+ console.error('Failed to load store name:', result);
+ }
+ } catch (error) {
+ console.error('Error loading store name:', error);
+ }
+}
+
+async function setStoreName() {
+ const input = document.getElementById('store-name-input');
+ const statusDiv = document.getElementById('store-name-status');
+
+ const index = parseInt(input.value);
+
+ if (isNaN(index) || index < 0 || index > 9999) {
+ showStoreNameStatus('Please enter a number between 0 and 9999', 'error');
+ return;
+ }
+
+ showStoreNameStatus('Setting store name...', 'info');
+
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/index-number`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ index: index })
+ });
+
+ const result = await response.json();
+
+ if (response.ok && result.status === 'ok') {
+ showStoreNameStatus('✓ Store name set successfully to ' + index.toString().padStart(4, '0'), 'success');
+ // Update display
+ document.getElementById('store-name-display').textContent = index.toString().padStart(4, '0');
+ // Check overwrite status for new index
+ await checkOverwriteStatus();
+ } else {
+ showStoreNameStatus('✗ Failed: ' + (result.detail || 'Unknown error'), 'error');
+ }
+ } catch (error) {
+ showStoreNameStatus('✗ Failed: ' + error.message, 'error');
+ }
+}
+
+function showStoreNameStatus(message, type) {
+ const statusDiv = document.getElementById('store-name-status');
+ statusDiv.textContent = message;
+ statusDiv.classList.remove('hidden', 'text-green-600', 'text-red-600', 'text-blue-600');
+
+ if (type === 'success') {
+ statusDiv.classList.add('text-green-600', 'dark:text-green-400');
+ } else if (type === 'error') {
+ statusDiv.classList.add('text-red-600', 'dark:text-red-400');
+ } else {
+ statusDiv.classList.add('text-blue-600', 'dark:text-blue-400');
+ }
+}
+
+// Check Overwrite Status
+async function checkOverwriteStatus() {
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/overwrite-check`);
+ const result = await response.json();
+
+ const warningDiv = document.getElementById('overwrite-warning');
+ const indicator = document.getElementById('overwrite-indicator');
+
+ if (response.ok && result.status === 'ok') {
+ if (result.will_overwrite) {
+ // Show warning
+ warningDiv.classList.remove('hidden');
+ indicator.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200');
+ indicator.classList.add('bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200');
+ indicator.textContent = 'Data Exists';
+ } else {
+ // Hide warning
+ warningDiv.classList.add('hidden');
+ indicator.classList.remove('hidden', 'bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200');
+ indicator.classList.add('bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200');
+ indicator.textContent = 'Safe';
+ }
+ } else {
+ // Hide on error
+ warningDiv.classList.add('hidden');
+ indicator.classList.add('hidden');
+ }
+ } catch (error) {
+ console.error('Error checking overwrite status:', error);
+ }
+}
+
+// Auto-increment to find next available index
+async function autoIncrementStoreName() {
+ showStoreNameStatus('Finding next available index...', 'info');
+
+ try {
+ const currentIndexResponse = await fetch(`/api/slmm/${unit_id}/index-number`);
+ const currentIndexResult = await currentIndexResponse.json();
+
+ if (!currentIndexResponse.ok || currentIndexResult.status !== 'ok') {
+ showStoreNameStatus('✗ Failed to get current index', 'error');
+ return;
+ }
+
+ let currentIndex = parseInt(currentIndexResult.index_number);
+ let foundEmpty = false;
+ let checkedCount = 0;
+ const maxChecks = 100; // Safety limit
+
+ // Start from next index
+ currentIndex++;
+
+ while (!foundEmpty && checkedCount < maxChecks && currentIndex <= 9999) {
+ // Set the index
+ const setResponse = await fetch(`/api/slmm/${unit_id}/index-number`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ index: currentIndex })
+ });
+
+ if (!setResponse.ok) {
+ currentIndex++;
+ checkedCount++;
+ continue;
+ }
+
+ // Check if data exists at this index
+ const checkResponse = await fetch(`/api/slmm/${unit_id}/overwrite-check`);
+ const checkResult = await checkResponse.json();
+
+ if (checkResponse.ok && checkResult.status === 'ok') {
+ if (!checkResult.will_overwrite) {
+ // Found empty slot!
+ foundEmpty = true;
+ document.getElementById('store-name-display').textContent = currentIndex.toString().padStart(4, '0');
+ document.getElementById('store-name-input').value = currentIndex;
+ showStoreNameStatus(`✓ Found empty slot at index ${currentIndex.toString().padStart(4, '0')}`, 'success');
+ await checkOverwriteStatus();
+ break;
+ }
+ }
+
+ currentIndex++;
+ checkedCount++;
+ }
+
+ if (!foundEmpty) {
+ if (currentIndex > 9999) {
+ showStoreNameStatus('✗ No empty slots found (reached max 9999)', 'error');
+ } else {
+ showStoreNameStatus(`✗ No empty slot found in next ${maxChecks} indices`, 'error');
+ }
+ }
+ } catch (error) {
+ showStoreNameStatus('✗ Error: ' + error.message, 'error');
+ }
+}
+
+// Navigate to directory
+function navigateToDir(path) {
+ window.currentPath = path;
+ document.getElementById('current-path').textContent = path;
+ loadFiles(path);
+}
+
+// Go up one directory
+function navigateUp() {
+ const parts = window.currentPath.split('/').filter(p => p);
+ parts.pop();
+ const newPath = '/' + parts.join('/');
+ navigateToDir(newPath || '/');
+}
+
+// Download file
+async function downloadFile(path, filename) {
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/ftp/download`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ remote_path: path })
+ });
+
+ if (!response.ok) {
+ throw new Error('Download failed');
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ } catch (error) {
+ alert('Failed to download file: ' + error.message);
+ }
+}
+
+// Load files from path
+async function loadFiles(path) {
+ const fileList = document.getElementById('file-list');
+ const loading = document.getElementById('file-list-loading');
+ const error = document.getElementById('file-list-error');
+ const ftpStatus = document.getElementById('ftp-status');
+
+ // Show loading state
+ fileList.classList.add('hidden');
+ loading.classList.remove('hidden');
+ error.classList.add('hidden');
+
+ try {
+ const response = await fetch(`/api/slmm/${unit_id}/ftp/files?path=${encodeURIComponent(path)}`);
+ const result = await response.json();
+
+ if (result.status === 'ok') {
+ ftpStatus.classList.add('hidden');
+ displayFiles(result.files || []);
+ } else {
+ throw new Error(result.detail || 'Failed to load files');
+ }
+ } catch (err) {
+ loading.classList.add('hidden');
+
+ // Check if it's an FTP disabled error
+ if (err.message.includes('FTP') || err.message.includes('502')) {
+ ftpStatus.classList.remove('hidden');
+ fileList.classList.remove('hidden');
+ fileList.innerHTML = '