445 lines
16 KiB
HTML
445 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Xterra Dashboard Simulator</title>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
</head>
|
|
<body>
|
|
<div id="alarmOverlay" class="alarm-overlay hidden">
|
|
<div class="alarm-box">
|
|
<div class="alarm-icon">⚠</div>
|
|
<h1 id="alarmTitle">ALARM</h1>
|
|
<p id="alarmMessage">--</p>
|
|
<button onclick="acknowledgeAlarm()">Acknowledge</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="sleepOverlay" class="sleep-overlay hidden" onclick="wakeScreen()">
|
|
<div>Screen Off</div>
|
|
<small>Tap to wake</small>
|
|
</div>
|
|
|
|
<div id="warningBanner" class="warning-banner hidden"></div>
|
|
|
|
<main>
|
|
<header>
|
|
<h1>Xterra Dashboard</h1>
|
|
<div id="statusLine">RS-485: -- | WiFi: --</div>
|
|
</header>
|
|
|
|
<section id="dashboard" class="screen active">
|
|
<div class="big-card">
|
|
<h2>Battery</h2>
|
|
<p><span id="dashSoc">--</span>%</p>
|
|
<small><span id="dashRuntime">--</span> hr runtime</small>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<div class="card" id="cardFridge1"><h2>Fridge A</h2><p><span id="dashFridge1">--</span></p><small id="healthFridge1">OK</small></div>
|
|
<div class="card" id="cardFridge2"><h2>Fridge B</h2><p><span id="dashFridge2">--</span></p><small id="healthFridge2">OK</small></div>
|
|
<div class="card kid-card" id="cardRear">
|
|
<h2>👧 Kid Area</h2>
|
|
<p><span id="dashRear">--</span></p>
|
|
<small id="kidStatus">Normal</small>
|
|
</div>
|
|
<div class="card" id="cardOutside"><h2>Outside</h2><p><span id="dashOutside">--</span></p><small id="healthOutside">OK</small></div>
|
|
</div>
|
|
|
|
<div class="relay-row">
|
|
<div id="dashStarlink" class="relay-pill">Starlink: --</div>
|
|
<div id="dashFridgeRelay" class="relay-pill">Fridge: --</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="battery" class="screen">
|
|
<h2>Battery Detail</h2>
|
|
<div class="detail-list">
|
|
<div>SOC <strong><span id="batSoc">--</span>%</strong></div>
|
|
<div>Voltage <strong><span id="batVoltage">--</span> V</strong></div>
|
|
<div>Current <strong><span id="batCurrent">--</span> A</strong></div>
|
|
<div>Remaining <strong><span id="batRemaining">--</span> Ah</strong></div>
|
|
<div>Runtime <strong><span id="batRuntime">--</span> hr</strong></div>
|
|
<div>Battery Temp <strong><span id="batTemp">--</span>°F</strong></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="temps" class="screen">
|
|
<h2>Temperatures</h2>
|
|
<div class="grid">
|
|
<div class="card"><h2>Fridge Zone 1</h2><p><span id="tempFridge1">--</span></p></div>
|
|
<div class="card"><h2>Fridge Zone 2</h2><p><span id="tempFridge2">--</span></p></div>
|
|
<div class="card"><h2>Rear Seat</h2><p><span id="tempRear">--</span></p></div>
|
|
<div class="card"><h2>Outside</h2><p><span id="tempOutside">--</span></p></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="power" class="screen">
|
|
<h2>Power Control</h2>
|
|
<button onclick="toggleRelay('starlink')" id="starlinkBtn">Starlink</button>
|
|
<button onclick="toggleRelay('fridge')" id="fridgeBtn">Fridge</button>
|
|
</section>
|
|
|
|
<section id="system" class="screen">
|
|
<h2>System</h2>
|
|
<div class="detail-list">
|
|
<div>RS-485 <strong id="sysRs485">--</strong></div>
|
|
<div>WiFi <strong id="sysWifi">--</strong></div>
|
|
<div>WiFi Override <strong id="sysWifiOverride">--</strong></div>
|
|
<div>Ignition <strong id="sysIgnition">--</strong></div>
|
|
</div>
|
|
<button onclick="enableWifi()">Enable WiFi 10 min</button>
|
|
<button onclick="toggleIgnition()">Toggle Ignition</button>
|
|
|
|
<h2>Communication</h2>
|
|
<div class="detail-list">
|
|
<div>Primary Link <strong id="commsPrimary">--</strong></div>
|
|
<div>Backup Available <strong id="commsBackup">--</strong></div>
|
|
<div>Messages Sent <strong id="commsSent">--</strong></div>
|
|
<div>Messages Received <strong id="commsReceived">--</strong></div>
|
|
<div>Last Message <strong id="commsLast">--</strong></div>
|
|
</div>
|
|
|
|
<button onclick="disconnectRs485()">Disconnect RS-485</button>
|
|
<button onclick="restoreRs485()">Restore RS-485</button>
|
|
|
|
<h2>Sensor Fault Simulation</h2>
|
|
<div class="settings-list">
|
|
<button onclick="toggleSensorFault('fridge_zone_1')">Toggle Fridge Zone 1 Fault</button>
|
|
<button onclick="toggleSensorFault('fridge_zone_2')">Toggle Fridge Zone 2 Fault</button>
|
|
<button onclick="toggleSensorFault('rear_seat')">Toggle Rear Seat Fault</button>
|
|
<button onclick="toggleSensorFault('outside')">Toggle Outside Fault</button>
|
|
</div>
|
|
|
|
</section>
|
|
</main>
|
|
|
|
<nav>
|
|
<button onclick="showScreen('dashboard')">Dashboard</button>
|
|
<button onclick="showScreen('battery')">Battery</button>
|
|
<button onclick="showScreen('temps')">Temps</button>
|
|
<button onclick="showScreen('power')">Power</button>
|
|
<button onclick="showScreen('system')">System</button>
|
|
<button onclick="showScreen('config')">Config</button>
|
|
</nav>
|
|
|
|
<script>
|
|
let relayState = {};
|
|
let activeAlarmKey = null;
|
|
let acknowledgedAlarms = new Set();
|
|
let screenWakeTimeout = null;
|
|
|
|
let alarmConfig = {
|
|
rear_seat_warning: 85,
|
|
rear_seat_critical: 95,
|
|
fridge_zone_1_warm: 45,
|
|
fridge_zone_2_warm: 15,
|
|
battery_low: 20,
|
|
audible_alarms: true
|
|
};
|
|
|
|
function showScreen(id) {
|
|
document.querySelectorAll('.screen').forEach(screen => {
|
|
screen.classList.remove('active');
|
|
});
|
|
document.getElementById(id).classList.add('active');
|
|
}
|
|
|
|
function onOff(value) {
|
|
return value ? 'ON' : 'OFF';
|
|
}
|
|
|
|
function tempText(value) {
|
|
return value === null || value === undefined ? 'FAULT' : `${value}°F`;
|
|
}
|
|
|
|
function setSensorCard(cardId, healthId, healthy) {
|
|
const card = document.getElementById(cardId);
|
|
const health = document.getElementById(healthId);
|
|
|
|
if (!card || !health) return;
|
|
|
|
if (healthy) {
|
|
card.classList.remove('sensor-fault');
|
|
health.textContent = 'OK';
|
|
} else {
|
|
card.classList.add('sensor-fault');
|
|
health.textContent = 'SENSOR FAULT';
|
|
}
|
|
}
|
|
|
|
function checkAlarms(data) {
|
|
const alarms = [];
|
|
const warnings = [];
|
|
|
|
if (data.network.communication_lost) {
|
|
warnings.push('Communication lost: RS-485 disconnected');
|
|
}
|
|
|
|
for (const [name, healthy] of Object.entries(data.sensor_health || {})) {
|
|
if (!healthy) {
|
|
warnings.push(`Sensor fault: ${name.replaceAll('_', ' ')}`);
|
|
}
|
|
}
|
|
|
|
if (data.sensor_health.rear_seat && data.temps.rear_seat >= alarmConfig.rear_seat_critical) {
|
|
alarms.push({
|
|
key: 'rear_seat_critical',
|
|
title: 'REAR SEAT TEMP CRITICAL',
|
|
message: `${data.temps.rear_seat}°F detected near car seat area`
|
|
});
|
|
} else if (data.sensor_health.rear_seat && data.temps.rear_seat >= alarmConfig.rear_seat_warning) {
|
|
warnings.push(`Rear seat temp high: ${data.temps.rear_seat}°F`);
|
|
}
|
|
|
|
if (data.sensor_health.fridge_zone_1 && data.temps.fridge_zone_1 >= alarmConfig.fridge_zone_1_warm) {
|
|
warnings.push(`Fridge Zone 1 warm: ${data.temps.fridge_zone_1}°F`);
|
|
}
|
|
|
|
if (data.sensor_health.fridge_zone_2 && data.temps.fridge_zone_2 >= alarmConfig.fridge_zone_2_warm) {
|
|
warnings.push(`Fridge Zone 2 warm: ${data.temps.fridge_zone_2}°F`);
|
|
}
|
|
|
|
if (data.battery.soc <= alarmConfig.battery_low) {
|
|
warnings.push(`Battery low: ${data.battery.soc}%`);
|
|
}
|
|
|
|
const banner = document.getElementById('warningBanner');
|
|
|
|
if (warnings.length > 0) {
|
|
banner.textContent = `⚠ ${warnings.join(' | ')}`;
|
|
banner.classList.remove('hidden');
|
|
} else {
|
|
banner.classList.add('hidden');
|
|
}
|
|
|
|
const unacked = alarms.find(alarm => !acknowledgedAlarms.has(alarm.key));
|
|
|
|
if (unacked) {
|
|
showAlarm(unacked);
|
|
}
|
|
}
|
|
|
|
function showAlarm(alarm) {
|
|
activeAlarmKey = alarm.key;
|
|
|
|
if (alarmConfig.audible_alarms) {
|
|
console.log('BEEP BEEP BEEP - alarm sound placeholder');
|
|
}
|
|
document.getElementById('alarmTitle').textContent = alarm.title;
|
|
document.getElementById('alarmMessage').textContent = alarm.message;
|
|
document.getElementById('alarmOverlay').classList.remove('hidden');
|
|
}
|
|
|
|
function acknowledgeAlarm() {
|
|
if (activeAlarmKey) {
|
|
acknowledgedAlarms.add(activeAlarmKey);
|
|
}
|
|
|
|
activeAlarmKey = null;
|
|
document.getElementById('alarmOverlay').classList.add('hidden');
|
|
}
|
|
|
|
function loadAlarmSettings() {
|
|
const saved = localStorage.getItem('alarmConfig');
|
|
|
|
if (saved) {
|
|
alarmConfig = JSON.parse(saved);
|
|
}
|
|
|
|
document.getElementById('rearWarningInput').value = alarmConfig.rear_seat_warning;
|
|
document.getElementById('rearCriticalInput').value = alarmConfig.rear_seat_critical;
|
|
if (alarmConfig.fridge_warm !== undefined) {
|
|
alarmConfig.fridge_zone_1_warm = alarmConfig.fridge_warm;
|
|
alarmConfig.fridge_zone_2_warm = 15;
|
|
delete alarmConfig.fridge_warm;
|
|
localStorage.setItem('alarmConfig', JSON.stringify(alarmConfig));
|
|
}
|
|
|
|
document.getElementById('fridgeZone1WarmInput').value = alarmConfig.fridge_zone_1_warm;
|
|
document.getElementById('fridgeZone2WarmInput').value = alarmConfig.fridge_zone_2_warm;
|
|
document.getElementById('batteryLowInput').value = alarmConfig.battery_low;
|
|
document.getElementById('audibleAlarmsInput').checked = alarmConfig.audible_alarms;
|
|
}
|
|
|
|
function saveAlarmSettings() {
|
|
alarmConfig = {
|
|
rear_seat_warning: Number(document.getElementById('rearWarningInput').value),
|
|
rear_seat_critical: Number(document.getElementById('rearCriticalInput').value),
|
|
fridge_zone_1_warm: Number(document.getElementById('fridgeZone1WarmInput').value),
|
|
fridge_zone_2_warm: Number(document.getElementById('fridgeZone2WarmInput').value),
|
|
battery_low: Number(document.getElementById('batteryLowInput').value),
|
|
audible_alarms: document.getElementById('audibleAlarmsInput').checked
|
|
};
|
|
|
|
localStorage.setItem('alarmConfig', JSON.stringify(alarmConfig));
|
|
|
|
acknowledgedAlarms.clear();
|
|
alert('Alarm settings saved');
|
|
}
|
|
|
|
async function loadStatus() {
|
|
const res = await fetch('/status');
|
|
const data = await res.json();
|
|
|
|
relayState = data.relays;
|
|
|
|
document.getElementById('statusLine').textContent =
|
|
`RS-485: ${data.network.rs485_connected ? 'Connected' : 'Lost'} | WiFi: ${data.network.wifi_enabled ? 'Enabled' : 'Disabled'}`;
|
|
|
|
if (data.network.communication_lost) {
|
|
document.body.classList.add('comms-lost');
|
|
} else {
|
|
document.body.classList.remove('comms-lost');
|
|
}
|
|
|
|
document.getElementById('dashSoc').textContent = data.battery.soc ?? '--';
|
|
document.getElementById('dashRuntime').textContent = data.battery.runtime_hours ?? '--';
|
|
document.getElementById('dashFridge1').textContent = tempText(data.temps.fridge_zone_1);
|
|
document.getElementById('dashFridge2').textContent = tempText(data.temps.fridge_zone_2);
|
|
document.getElementById('dashRear').textContent = tempText(data.temps.rear_seat);
|
|
|
|
setSensorCard('cardFridge1', 'healthFridge1', data.sensor_health?.fridge_zone_1 ?? true);
|
|
setSensorCard('cardFridge2', 'healthFridge2', data.sensor_health?.fridge_zone_2 ?? true);
|
|
setSensorCard('cardRear', 'kidStatus', data.sensor_health?.rear_seat ?? true);
|
|
setSensorCard('cardOutside', 'healthOutside', data.sensor_health?.outside ?? true);
|
|
|
|
const kidStatus = document.getElementById('kidStatus');
|
|
if (data.sensor_health.rear_seat && data.temps.rear_seat >= alarmConfig.rear_seat_critical) {
|
|
kidStatus.textContent = 'CRITICAL';
|
|
} else if (data.sensor_health.rear_seat && data.temps.rear_seat >= alarmConfig.rear_seat_warning) {
|
|
kidStatus.textContent = 'Warning';
|
|
} else {
|
|
kidStatus.textContent = 'Normal';
|
|
}
|
|
document.getElementById('dashOutside').textContent = tempText(data.temps.outside);
|
|
document.getElementById('dashStarlink').textContent = `Starlink: ${onOff(data.relays.starlink)}`;
|
|
document.getElementById('dashFridgeRelay').textContent = `Fridge: ${onOff(data.relays.fridge)}`;
|
|
|
|
document.getElementById('batSoc').textContent = data.battery.soc;
|
|
document.getElementById('batVoltage').textContent = data.battery.voltage ?? '--';
|
|
document.getElementById('batCurrent').textContent = data.battery.current ?? '--';
|
|
document.getElementById('batRemaining').textContent = data.battery.remaining_ah ?? '--';
|
|
document.getElementById('batRuntime').textContent = data.battery.runtime_hours ?? '--';
|
|
document.getElementById('batTemp').textContent = data.battery.temperature_f ?? '--';
|
|
|
|
document.getElementById('tempFridge1').textContent = tempText(data.temps.fridge_zone_1);
|
|
document.getElementById('tempFridge2').textContent = tempText(data.temps.fridge_zone_2);
|
|
document.getElementById('tempRear').textContent = tempText(data.temps.rear_seat);
|
|
document.getElementById('tempOutside').textContent = tempText(data.temps.outside);
|
|
|
|
document.getElementById('starlinkBtn').textContent = `Starlink: ${onOff(data.relays.starlink)}`;
|
|
document.getElementById('fridgeBtn').textContent = `Fridge: ${onOff(data.relays.fridge)}`;
|
|
|
|
document.getElementById('sysRs485').textContent = data.network.rs485_connected ? 'Connected' : 'Disconnected';
|
|
document.getElementById('sysWifi').textContent = data.network.wifi_enabled ? 'Enabled' : 'Disabled';
|
|
document.getElementById('sysWifiOverride').textContent = data.network.wifi_override_active ? 'Active' : 'Inactive';
|
|
document.getElementById('sysIgnition').textContent = data.vehicle?.ignition_on ? 'ON' : 'OFF';
|
|
|
|
document.getElementById('commsPrimary').textContent = data.network.rs485_connected ? 'RS-485' : 'RS-485 LOST';
|
|
document.getElementById('commsBackup').textContent = 'WiFi';
|
|
document.getElementById('commsSent').textContent = data.network.messages_sent ?? '--';
|
|
document.getElementById('commsReceived').textContent = data.network.messages_received ?? '--';
|
|
document.getElementById('commsLast').textContent = data.network.last_message_time
|
|
? new Date(data.network.last_message_time * 1000).toLocaleTimeString()
|
|
: '--';
|
|
|
|
updateSleepOverlay(data.vehicle?.ignition_on ?? true);
|
|
|
|
checkAlarms(data);
|
|
}
|
|
|
|
async function toggleRelay(name) {
|
|
await fetch(`/relay/${name}`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({state: !relayState[name]})
|
|
});
|
|
|
|
loadStatus();
|
|
}
|
|
|
|
function updateSleepOverlay(ignitionOn) {
|
|
const overlay = document.getElementById('sleepOverlay');
|
|
|
|
if (ignitionOn) {
|
|
overlay.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
if (!screenWakeTimeout) {
|
|
overlay.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
function wakeScreen() {
|
|
const overlay = document.getElementById('sleepOverlay');
|
|
overlay.classList.add('hidden');
|
|
|
|
if (screenWakeTimeout) {
|
|
clearTimeout(screenWakeTimeout);
|
|
}
|
|
|
|
screenWakeTimeout = setTimeout(() => {
|
|
screenWakeTimeout = null;
|
|
overlay.classList.remove('hidden');
|
|
}, 10000);
|
|
}
|
|
|
|
async function toggleIgnition() {
|
|
await fetch('/vehicle/ignition', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({})
|
|
});
|
|
|
|
loadStatus();
|
|
}
|
|
|
|
async function disconnectRs485() {
|
|
await fetch('/comms/rs485/disconnect', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({})
|
|
});
|
|
|
|
loadStatus();
|
|
}
|
|
|
|
async function restoreRs485() {
|
|
await fetch('/comms/rs485/restore', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({})
|
|
});
|
|
|
|
loadStatus();
|
|
}
|
|
|
|
async function toggleSensorFault(name) {
|
|
await fetch(`/sensor/${name}/fault`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({})
|
|
});
|
|
|
|
acknowledgedAlarms.clear();
|
|
loadStatus();
|
|
}
|
|
|
|
async function enableWifi() {
|
|
await fetch('/network/wifi', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({minutes: 10})
|
|
});
|
|
|
|
loadStatus();
|
|
}
|
|
|
|
loadAlarmSettings();
|
|
loadStatus();
|
|
setInterval(loadStatus, 2000);
|
|
</script>
|
|
</body>
|
|
</html>
|