SwapStation_WebApp/frontend/js/dashboard.js

534 lines
23 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
// --- CONFIGURATION ---
const SOCKET_URL = "http://10.10.2.47:5000";
const API_BASE = "http://10.10.2.47:5000/api"; // Added for API calls
// --- DOM ELEMENT REFERENCES ---
const grid = document.getElementById('chambersGrid');
const chamberTmpl = document.getElementById('chamberTemplate');
const logTextArea = document.getElementById('instance-log');
const connChip = document.getElementById('connection-status-chip');
const stationNameEl = document.getElementById('station-name');
const stationLocationEl = document.getElementById('station-location');
const deviceIdEl = document.getElementById('device-id');
const productIdEl = document.getElementById('product-id');
const lastUpdateEl = document.getElementById('last-update-status');
const stationDiagCodeEl = document.getElementById('station-diag-code');
const backupPowerChip = document.getElementById('backup-power-chip');
const diagFlagsGrid = document.getElementById('diag-flags-grid');
const audioSelect = document.getElementById('audio-command-select');
// Header Buttons
const refreshBtn = document.getElementById('refreshBtn');
const downloadBtn = document.getElementById('downloadBtn');
const logoutBtn = document.getElementById('logout-btn');
// --- STATE ---
let selectedStation = null;
let socket;
let statusPollingInterval; // To hold our interval timer
let chamberData = Array(9).fill({ batteryPresent: false });
// The list of errors from your Python code
const DIAGNOSTIC_ERRORS = [
"Lock Power Cut", "Main Power Cut",
"Relayboard CAN", "DB CAN Recv",
"MB Can Recv", "Smoke Alarm",
"Water Alarm", "Phase Failure",
"Earth Leakage"
];
const BATTERY_FAULT_MAP = {
8: "UT", // Under Temperature
4: "OV", // Over Voltage
2: "OT", // Over Temperature
1: "OC" // Over Current
};
const CHARGER_FAULT_MAP = {
1: "OV", // Over Voltage
2: "UV", // Under Voltage
4: "OT", // Over Temperature
8: "CAN Failure"
// Add other charger fault codes here
};
// --- NEW: SWAP PROCESS ELEMENTS & LOGIC ---
const swapIdleText = document.getElementById('swap-idle-text');
const swapPairsList = document.getElementById('swap-pairs-list');
const startSwapBtn = document.getElementById('start-swap-btn');
const abortSwapBtn = document.getElementById('abort-swap-btn');
const clearSwapBtn = document.getElementById('clear-swap-btn');
const resetBtn = document.getElementById('station-reset-btn');
let currentPair = [], swapPairs = [];
// --- SWAP UI LOGIC ---
function updateSwapUI() {
const isBuilding = currentPair.length > 0 || swapPairs.length > 0;
if (swapIdleText) swapIdleText.style.display = isBuilding ? 'none' : 'block';
if (startSwapBtn) startSwapBtn.disabled = swapPairs.length === 0;
const pairedOut = swapPairs.map(p => p[0]), pairedIn = swapPairs.map(p => p[1]);
document.querySelectorAll('.chamber-card').forEach(card => {
const n = parseInt(card.dataset.chamberId, 10);
card.classList.remove('paired', 'pending');
if (pairedOut.includes(n) || pairedIn.includes(n)) card.classList.add('paired');
else if (currentPair.includes(n)) card.classList.add('pending');
});
if (swapPairsList) {
swapPairsList.innerHTML = '';
if (isBuilding) {
swapPairs.forEach(p => {
const e = document.createElement('div');
e.className = 'text-sm font-semibold flex items-center justify-center gap-2 bg-black/20 rounded p-1.5';
e.innerHTML = `<span class="text-emerald-300">${p[0]}</span><span>→</span><span class="text-sky-300">${p[1]}</span>`;
swapPairsList.appendChild(e);
});
if (currentPair.length > 0) {
const e = document.createElement('div');
e.className = 'text-sm font-semibold flex items-center justify-center gap-2 bg-black/20 rounded p-1.5 ring-2 ring-sky-500';
e.innerHTML = `<span class="text-emerald-300 font-bold">${currentPair[0]}</span><span>→</span><span class="text-gray-400">?</span>`;
swapPairsList.appendChild(e);
}
}
}
}
function handleChamberClick(num) {
// Deselection logic
if (currentPair.length === 1 && currentPair[0] === num) {
currentPair = [];
updateSwapUI();
return;
}
const isAlreadyPaired = swapPairs.flat().includes(num);
if (isAlreadyPaired) {
swapPairs = swapPairs.filter(pair => !pair.includes(num));
updateSwapUI();
return;
}
// Selection logic
if (swapPairs.length >= 4) return alert('Maximum of 4 swap pairs reached.');
// Note: Live validation would go here. Removed for testing.
currentPair.push(num);
if (currentPair.length === 2) {
swapPairs.push([...currentPair]);
currentPair = [];
}
updateSwapUI();
}
function clearSelection() {
currentPair = [];
swapPairs = [];
updateSwapUI();
}
// --- HELPER FUNCTIONS (Your original code is unchanged) ---
const applyButtonFeedback = (button) => {
if (!button) return;
button.classList.add('btn-feedback');
setTimeout(() => {
button.classList.remove('btn-feedback');
}, 150);
};
// And ensure your sendCommand function does NOT have the feedback logic
const sendCommand = (command, data = null) => {
if (!selectedStation || !socket || !socket.connected) {
logToInstance(`Cannot send command '${command}', not connected.`, "error");
return;
}
const payload = { station_id: selectedStation.id, command: command, data: data };
socket.emit('rpc_request', payload);
logToInstance(`Sent command: ${command}`, 'cmd');
};
const setChipStyle = (element, text, style) => {
if (!element) return;
const baseClass = element.className.split(' ')[0];
element.textContent = text;
element.className = `${baseClass} chip-${style}`;
};
const logToInstance = (message, type = 'info') => {
if (!logTextArea) return;
const timestamp = new Date().toLocaleTimeString();
const typeIndicator = type === 'error' ? '[ERROR]' : type === 'cmd' ? '[CMD]' : '[INFO]';
const newLog = `[${timestamp}] ${typeIndicator} ${message}\n`;
logTextArea.value = newLog + logTextArea.value;
};
/**
* Decodes a fault bitmask into a human-readable string using a given map.
* @param {number} faultCode The fault code number.
* @param {object} faultMap The map to use for decoding (e.g., BATTERY_FAULT_MAP).
* @returns {string} A comma-separated string of active faults, or "—" if none.
*/
const decodeFaults = (faultCode, faultMap) => {
if (!faultCode || faultCode === 0) {
return "—"; // No fault
}
const activeFaults = [];
for (const bit in faultMap) {
if ((faultCode & bit) !== 0) {
activeFaults.push(faultMap[bit]);
}
}
return activeFaults.length > 0 ? activeFaults.join(', ') : "—";
};
const updateChamberUI = (card, slot) => {
if (!card || !slot) return;
const filledState = card.querySelector('.filled-state');
const emptyState = card.querySelector('.empty-state');
const doorPill = card.querySelector('.door-pill');
// Always update door status
doorPill.textContent = slot.doorStatus ? 'OPEN' : 'CLOSED';
doorPill.className = slot.doorStatus ? 'door-pill door-open' : 'door-pill door-close';
const slotTempText = `${((slot.slotTemperature || 0) / 10).toFixed(1)} °C`;
if (slot.batteryPresent) {
filledState.style.display = 'flex';
emptyState.style.display = 'none';
// Update the detailed view
card.querySelector('.slot-temp').textContent = slotTempText;
card.querySelector('.bat-id-big').textContent = slot.batteryIdentification || '—';
card.querySelector('.soc').textContent = `${slot.soc || 0}%`;
// --- Populate the detailed view ---
card.querySelector('.bat-id-big').textContent = slot.batteryIdentification || '—';
card.querySelector('.soc').textContent = `${slot.soc || 0}%`;
card.querySelector('.voltage').textContent = `${((slot.voltage || 0) / 1000).toFixed(1)} V`;
card.querySelector('.bat-temp').textContent = `${((slot.batteryMaxTemp || 0) / 10).toFixed(1)} °C`;
// card.querySelector('.bat-fault').textContent = slot.batteryFaultCode || '—';
card.querySelector('.bat-fault').textContent = decodeFaults(slot.batteryFaultCode, BATTERY_FAULT_MAP);
// card.querySelector('.bat-fault').textContent = decodeBatteryFaults(slot.batteryFaultCode);
card.querySelector('.current').textContent = `${((slot.current || 0) / 1000).toFixed(1)} A`;
card.querySelector('.slot-temp').textContent = `${((slot.slotTemperature || 0) / 10).toFixed(1)} °C`;
card.querySelector('.chg-temp').textContent = `${((slot.chargerMaxTemp || 0) / 10).toFixed(1)} °C`;
// card.querySelector('.chg-fault').textContent = slot.chargerFaultCode || '—';
card.querySelector('.chg-fault').textContent = decodeFaults(slot.chargerFaultCode, CHARGER_FAULT_MAP);
const batPill = card.querySelector('.battery-status-pill');
batPill.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Present`;
batPill.className = 'battery-status-pill chip chip-emerald';
const chgPill = card.querySelector('.charger-status-pill');
if (slot.chargerPresent && slot.current > 0) {
chgPill.innerHTML = `<span class="h-2 w-2 rounded-full bg-sky-400 animate-pulseDot"></span> Charging`;
chgPill.className = 'charger-status-pill chip chip-sky';
} else {
chgPill.innerHTML = `<span class="h-2 w-2 rounded-full bg-slate-500"></span> Idle`;
chgPill.className = 'charger-status-pill chip chip-slate';
}
} else {
// Show the empty view
filledState.style.display = 'none';
emptyState.style.display = 'flex';
// --- DEBUGGING LOGIC ---
const tempElement = card.querySelector('.slot-temp-empty');
if (tempElement) {
tempElement.textContent = slotTempText;
// console.log(`Chamber ${slot.chamberNo}: Found .slot-temp-empty, setting text to: ${slotTempText}`);
} else {
// console.error(`Chamber ${slot.chamberNo}: Element .slot-temp-empty NOT FOUND! Check your HTML template.`);
}
}
// Check if the icon library is loaded and then render the icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
} else {
console.error('Lucide icon script is not loaded. Please check dashboard.html');
}
};
// --- NEW: Function to decode the SDC and update the UI ---
const updateDiagnosticsUI = (sdcCode) => {
if (!diagFlagsGrid) return;
diagFlagsGrid.innerHTML = ''; // Clear previous statuses
DIAGNOSTIC_ERRORS.forEach((errorText, index) => {
// Use bitwise AND to check if the bit at this index is set
const isActive = (sdcCode & (1 << index)) !== 0;
const div = document.createElement('div');
div.textContent = errorText;
// Apply different styles based on whether the alarm is active
if (isActive) {
div.className = 'text-rose-300 text-center font-semibold';
} else {
div.className = 'text-slate-500 text-center';
}
diagFlagsGrid.appendChild(div);
});
};
const checkStationStatus = async () => {
if (!selectedStation) return;
try {
const response = await fetch(`${API_BASE}/stations`);
if (!response.ok) return;
const stations = await response.json();
const thisStation = stations.find(s => s.id === selectedStation.id);
if (thisStation && connChip) {
if (thisStation.status === 'Online') {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
connChip.className = 'cham_chip cham_chip-emerald';
} else {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose';
if (lastUpdateEl) lastUpdateEl.textContent = "Waiting for data...";
// Optionally reset the dashboard if the station goes offline
// resetDashboardUI();
}
}
} catch (error) {
console.error("Failed to fetch station status:", error);
}
};
const resetDashboardUI = () => {
grid.querySelectorAll('.chamber-card').forEach(card => {
card.querySelector('.bat-id-big').textContent = 'Waiting...';
card.querySelector('.soc').textContent = '—';
card.querySelector('.voltage').textContent = '—';
card.querySelector('.bat-temp').textContent = '—';
card.querySelector('.bat-fault').textContent = '—';
card.querySelector('.current').textContent = '—';
card.querySelector('.slot-temp').textContent = '—';
card.querySelector('.chg-fault').textContent = '—';
// Show the "empty" view by default when resetting
card.querySelector('.filled-state').style.display = 'none';
card.querySelector('.empty-state').style.display = 'flex';
});
logToInstance("Station is offline. Clearing stale data.", "error");
};
// --- NEW: This function polls the API for the true station status ---
// const checkStationStatus = async () => {
// if (!selectedStation) return;
// try {
// const response = await fetch(`${API_BASE}/stations`);
// if (!response.ok) return;
// const stations = await response.json();
// const thisStation = stations.find(s => s.id === selectedStation.id);
// if (thisStation && connChip) {
// stationNameEl.textContent = thisStation.name;
// stationLocationEl.textContent = thisStation.location;
// if (thisStation.status === 'Online') {
// connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
// connChip.className = 'cham_chip cham_chip-emerald';
// } else {
// connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
// connChip.className = 'cham_chip cham_chip-rose';
// lastUpdateEl.textContent = "Waiting for data...";
// resetDashboardUI();
// }
// }
// } catch (error) {
// console.error("Failed to fetch station status:", error);
// }
// };
// --- MAIN LOGIC (Your original code is unchanged) ---
const initializeDashboard = () => {
try {
selectedStation = JSON.parse(localStorage.getItem('selected_station'));
if (!selectedStation || !selectedStation.id) {
window.location.href = './index.html';
}
stationNameEl.textContent = selectedStation.name || 'Unknown Station';
stationLocationEl.textContent = selectedStation.location || 'No location';
// This populates the span with id="device-id" with the Station's ID
deviceIdEl.textContent = selectedStation.id;
// This populates the span with id="product-id" with the Product ID
// productIdEl.textContent = selectedStation.product_id;
} catch (e) {
document.body.innerHTML = `<div class="text-center p-8 text-rose-400">${e.message} <a href="./station_selection.html" class="underline">Go Back</a></div>`;
return;
}
grid.innerHTML = '';
for (let i = 1; i <= 9; i++) {
const node = chamberTmpl.content.cloneNode(true);
const card = node.querySelector('.chamber-card');
card.dataset.chamberId = i;
card.querySelector('.slotNo').textContent = i;
grid.appendChild(node);
}
logToInstance(`Dashboard initialized for station: ${selectedStation.name} (${selectedStation.id})`);
// --- EVENT LISTENERS ---
// NEW: A single, global click listener for all button feedback
document.addEventListener('click', (event) => {
const button = event.target.closest('.btn');
if (button) {
applyButtonFeedback(button);
}
});
// Chamber Card and Swap Selection
document.querySelectorAll('.chamber-card').forEach(card => {
// Listener for swap selection
card.addEventListener('click', () => handleChamberClick(parseInt(card.dataset.chamberId, 10)));
// Listeners for command buttons inside the card
card.querySelectorAll('.btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const commandText = e.currentTarget.textContent.trim();
const command = commandText.replace(' ', '_');
const chamberNum = parseInt(e.currentTarget.closest('.chamber-card').dataset.chamberId, 10);
if (command === 'OPEN') {
if (confirm(`Are you sure you want to open door for Chamber ${chamberNum}?`)) {
sendCommand(command, chamberNum);
} else {
logToInstance(`Open Door command for Chamber ${chamberNum} cancelled.`);
}
} else {
sendCommand(command, chamberNum);
}
});
});
});
// Swap Panel Buttons
if (clearSwapBtn) clearSwapBtn.addEventListener('click', clearSelection);
if (startSwapBtn) {
startSwapBtn.addEventListener('click', () => {
if (swapPairs.length > 0) {
const formattedPairs = swapPairs.map(p => `[${p[0]},${p[1]}]`).join(',');
sendCommand('START_SWAP', formattedPairs);
clearSelection();
}
});
}
if (abortSwapBtn) abortSwapBtn.addEventListener('click', () => sendCommand('ABORT_SWAP'));
if (resetBtn) {
resetBtn.addEventListener('click', () => {
if (confirm('Are you sure you want to reset the station?')) {
sendCommand('STATION_RESET');
}
});
}
// Header Buttons
if (refreshBtn) refreshBtn.addEventListener('click', () => window.location.reload());
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
localStorage.clear();
window.location.href = './index.html';
});
}
// Audio Command Button (assuming it exists in your HTML)
const sendAudioBtn = document.getElementById('send-audio-btn');
if (sendAudioBtn) {
sendAudioBtn.addEventListener('click', () => {
const language = document.getElementById('audio-command-select').value;
sendCommand('LANGUAGE_UPDATE', language);
});
}
updateSwapUI();
connectSocket();
checkStationStatus();
statusPollingInterval = setInterval(checkStationStatus, 10000);
};
const connectSocket = () => {
socket = io(SOCKET_URL);
// --- CHANGED: No longer sets status to "Online" on its own ---
socket.on('connect', () => {
logToInstance("Successfully connected to the backend WebSocket.");
socket.emit('join_station_room', { station_id: selectedStation.id });
});
// --- CHANGED: Sets status to "Offline" correctly on disconnect ---
socket.on('disconnect', () => {
logToInstance("Disconnected from the backend WebSocket.", "error");
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose';
});
socket.on('dashboard_update', (message) => {
console.log("DEBUG: Received 'dashboard_update' message:", message);
const { stationId, data } = message;
console.log("Received data payload:", data);
if (stationId !== selectedStation.id) {
console.warn(`Ignoring message for wrong station. Expected ${selectedStation.id}, got ${stationId}`);
return;
}
lastUpdateEl.textContent = `Last Recv ${new Date().toLocaleTimeString()}`;
// 1. Check if the 'stationDiagnosticCode' key exists in the data.
if (data.hasOwnProperty('stationDiagnosticCode')) {
const sdc = data.stationDiagnosticCode;
stationDiagCodeEl.textContent = sdc;
updateDiagnosticsUI(sdc);
}
// 2. Check if the 'backupSupplyStatus' key exists.
if (data.hasOwnProperty('backupSupplyStatus')) {
if (data.backupSupplyStatus === 1) {
backupPowerChip.textContent = 'On Backup';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-amber';
} else {
backupPowerChip.textContent = 'On Mains Power';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-emerald';
}
}
// 3. Only process chamber-level data if it exists.
if (data.slotLevelPayload && Array.isArray(data.slotLevelPayload)) {
data.slotLevelPayload.forEach((slotData, index) => {
const slotId = index + 1;
const card = grid.querySelector(`.chamber-card[data-chamber-id="${slotId}"]`);
if (card) {
updateChamberUI(card, slotData);
}
});
}
});
};
// --- SCRIPT EXECUTION ---
initializeDashboard();
});