289 lines
9.2 KiB
HTML
289 lines
9.2 KiB
HTML
<!DOCTYPE html>
|
|
<!--
|
|
============================================================
|
|
ARGONAUT 3 — SYSTEM HEALTH INDICATOR
|
|
Widget 1 — Primary safety status (always visible)
|
|
============================================================
|
|
Purpose : Display GREEN / AMBER / RED failsafe state as a
|
|
traffic-light row of three circles with a label.
|
|
Data : Reads NAMED_VALUE_INT "rov_failsafe" from the
|
|
Cockpit data lake. Values: 0=GREEN, 1=AMBER, 2=RED
|
|
(The ROS2 → MAVROS → NAMED_VALUE bridge must be
|
|
running for live data. Falls back to a "waiting"
|
|
state if the variable is absent.)
|
|
Import : Cockpit → Edit mode → Add DIY Widget →
|
|
gear icon → Import → select this file
|
|
Version : 1.0
|
|
============================================================
|
|
-->
|
|
<style>
|
|
/* ── Reset ───────────────────────────────────────────────── */
|
|
*, *::before, *::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
:root {
|
|
--bg: #09111a; /* widget background */
|
|
--text0: #d8eeff; /* primary label text */
|
|
--text1: #6a9bbf; /* secondary / dim text */
|
|
--green: #00e09a; /* GREEN state fill */
|
|
--amber: #ffb830; /* AMBER state fill */
|
|
--red: #ff3a5a; /* RED state fill */
|
|
--dim: #1a2d42; /* inactive circle fill */
|
|
--border: #1e3050; /* inactive circle border */
|
|
--mono: 'Courier New', monospace;
|
|
}
|
|
|
|
/* ── Full-height flex container ─────────────────────────── */
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text0);
|
|
font-family: var(--mono);
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
user-select: none;
|
|
}
|
|
|
|
/* ── Three-circle row ───────────────────────────────────── */
|
|
#circles {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
/* Each circle is a labelled flex column */
|
|
.circle-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
/* The circle itself */
|
|
.circle {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
border: 2px solid var(--border);
|
|
background: var(--dim);
|
|
transition: background 0.3s, border-color 0.3s, box-shadow 0.3s;
|
|
}
|
|
|
|
/* Colour states — applied to .circle when active */
|
|
.circle.green {
|
|
background: var(--green);
|
|
border-color: var(--green);
|
|
box-shadow: 0 0 12px var(--green);
|
|
}
|
|
|
|
.circle.amber {
|
|
background: var(--amber);
|
|
border-color: var(--amber);
|
|
box-shadow: 0 0 12px var(--amber);
|
|
}
|
|
|
|
.circle.red {
|
|
background: var(--red);
|
|
border-color: var(--red);
|
|
box-shadow: 0 0 16px var(--red);
|
|
}
|
|
|
|
/* RED state flashing border animation */
|
|
@keyframes flash-red {
|
|
0%, 100% { box-shadow: 0 0 16px var(--red); }
|
|
50% { box-shadow: 0 0 32px var(--red), 0 0 8px #ff000088; }
|
|
}
|
|
|
|
.circle.red { animation: flash-red 1s ease-in-out infinite; }
|
|
|
|
/* Label below each circle */
|
|
.circle-label {
|
|
font-size: 9px;
|
|
color: var(--text1);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
/* Active label is brighter */
|
|
.circle-label.active { color: var(--text0); font-weight: bold; }
|
|
|
|
/* ── Status message line ────────────────────────────────── */
|
|
#message {
|
|
font-size: 11px;
|
|
color: var(--text1);
|
|
text-align: center;
|
|
max-width: 180px;
|
|
line-height: 1.4;
|
|
min-height: 16px; /* prevent layout shift when empty */
|
|
}
|
|
|
|
/* Colour the message text to match current state */
|
|
#message.green { color: var(--green); }
|
|
#message.amber { color: var(--amber); }
|
|
#message.red { color: var(--red); }
|
|
|
|
/* ── Small "last updated" footer ─────────────────────────── */
|
|
#footer {
|
|
margin-top: 6px;
|
|
font-size: 9px;
|
|
color: #2a4060; /* very dim — not important */
|
|
}
|
|
</style>
|
|
|
|
<body>
|
|
|
|
<!-- Three traffic-light circles -->
|
|
<div id="circles">
|
|
|
|
<div class="circle-wrap">
|
|
<div id="c-green" class="circle"></div>
|
|
<span id="l-green" class="circle-label">Green</span>
|
|
</div>
|
|
|
|
<div class="circle-wrap">
|
|
<div id="c-amber" class="circle"></div>
|
|
<span id="l-amber" class="circle-label">Amber</span>
|
|
</div>
|
|
|
|
<div class="circle-wrap">
|
|
<div id="c-red" class="circle"></div>
|
|
<span id="l-red" class="circle-label">Red</span>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Human-readable status message -->
|
|
<div id="message">Connecting…</div>
|
|
|
|
<!-- Update timestamp (very dim) -->
|
|
<div id="footer">—</div>
|
|
|
|
</body>
|
|
|
|
<script>
|
|
// ── Config ────────────────────────────────────────────────
|
|
// Name of the data lake variable published by the ROS2 bridge
|
|
const VARIABLE = 'rov_failsafe';
|
|
|
|
// Poll interval in milliseconds
|
|
const POLL_MS = 500;
|
|
|
|
// State values
|
|
const STATE_GREEN = 0;
|
|
const STATE_AMBER = 1;
|
|
const STATE_RED = 2;
|
|
|
|
// Default messages for each state (used when no reason string present)
|
|
const MESSAGES = {
|
|
[STATE_GREEN]: 'Systems nominal',
|
|
[STATE_AMBER]: 'Parameter degraded — check system',
|
|
[STATE_RED]: 'CRITICAL — Safe action triggered',
|
|
};
|
|
|
|
// ── DOM references ────────────────────────────────────────
|
|
const cGreen = document.getElementById('c-green');
|
|
const cAmber = document.getElementById('c-amber');
|
|
const cRed = document.getElementById('c-red');
|
|
const lGreen = document.getElementById('l-green');
|
|
const lAmber = document.getElementById('l-amber');
|
|
const lRed = document.getElementById('l-red');
|
|
const msgEl = document.getElementById('message');
|
|
const footEl = document.getElementById('footer');
|
|
|
|
// ── Apply a state to the UI ───────────────────────────────
|
|
// state : 0 (GREEN) | 1 (AMBER) | 2 (RED) | null (waiting)
|
|
// reason: optional string to show in the message line
|
|
function applyState(state, reason) {
|
|
|
|
// Clear all active circle classes first
|
|
[cGreen, cAmber, cRed].forEach(c =>
|
|
c.classList.remove('green', 'amber', 'red')
|
|
);
|
|
|
|
// Clear all active label classes
|
|
[lGreen, lAmber, lRed].forEach(l =>
|
|
l.classList.remove('active')
|
|
);
|
|
|
|
// Remove all message colour classes
|
|
msgEl.classList.remove('green', 'amber', 'red');
|
|
|
|
if (state === STATE_GREEN) {
|
|
cGreen.classList.add('green');
|
|
lGreen.classList.add('active');
|
|
msgEl.classList.add('green');
|
|
msgEl.textContent = reason || MESSAGES[STATE_GREEN];
|
|
|
|
} else if (state === STATE_AMBER) {
|
|
cAmber.classList.add('amber');
|
|
lAmber.classList.add('active');
|
|
msgEl.classList.add('amber');
|
|
msgEl.textContent = reason || MESSAGES[STATE_AMBER];
|
|
|
|
} else if (state === STATE_RED) {
|
|
cRed.classList.add('red');
|
|
lRed.classList.add('active');
|
|
msgEl.classList.add('red');
|
|
msgEl.textContent = reason || MESSAGES[STATE_RED];
|
|
|
|
} else {
|
|
// No known state — waiting for data
|
|
msgEl.textContent = 'Waiting for data…';
|
|
}
|
|
}
|
|
|
|
// ── Poll the data lake ────────────────────────────────────
|
|
function poll() {
|
|
try {
|
|
// Guard: ensure Cockpit API is available
|
|
if (typeof window.cockpit === 'undefined' ||
|
|
typeof window.cockpit.getDataLakeValue !== 'function') {
|
|
applyState(null);
|
|
footEl.textContent = 'API not ready';
|
|
return;
|
|
}
|
|
|
|
// Read the primary state variable (integer 0/1/2)
|
|
const stateRaw = window.cockpit.getDataLakeValue(VARIABLE);
|
|
|
|
if (stateRaw === null || stateRaw === undefined) {
|
|
// Variable not yet in the data lake — bridge not running
|
|
applyState(null);
|
|
footEl.textContent = `Waiting for ${VARIABLE}`;
|
|
return;
|
|
}
|
|
|
|
const state = parseInt(stateRaw, 10);
|
|
|
|
// Optional: read a reason string if the bridge publishes one
|
|
// (published separately as NAMED_VALUE_FLOAT/rov_failsafe_reason)
|
|
// For now we use the default messages above.
|
|
const reason = null;
|
|
|
|
applyState(state, reason);
|
|
|
|
// Update timestamp footer
|
|
const now = new Date();
|
|
footEl.textContent = `Updated ${now.toLocaleTimeString()}`;
|
|
|
|
} catch (err) {
|
|
applyState(null);
|
|
footEl.textContent = 'Poll error — see console';
|
|
console.error('[Health Widget]', err);
|
|
}
|
|
}
|
|
|
|
// ── Start ─────────────────────────────────────────────────
|
|
// Initial delay gives the Cockpit API time to initialise
|
|
setTimeout(poll, 300);
|
|
setInterval(poll, POLL_MS);
|
|
</script>
|