feat: add all Argonaut 3 DIY widgets (W0-W5)
This commit is contained in:
parent
81fad536f9
commit
6a145f24d2
38
widgets/README.md
Normal file
38
widgets/README.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Argonaut 3 — Cockpit DIY Widgets
|
||||||
|
|
||||||
|
## How to install a widget
|
||||||
|
|
||||||
|
1. Open Cockpit in your browser
|
||||||
|
2. Enter edit mode (pencil icon, top right)
|
||||||
|
3. Click **Add Widget** at the bottom of the screen
|
||||||
|
4. Scroll right to find the `</>` DIY widget — drag it onto the view
|
||||||
|
5. Click the **gear icon** on the widget to open the editor
|
||||||
|
6. Click **Import** and select the `.html` file for the widget you want
|
||||||
|
7. The widget will load immediately
|
||||||
|
|
||||||
|
## Widget index
|
||||||
|
|
||||||
|
| File | Widget | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| w0_data_lake_inspector.html | Data Lake Inspector | Diagnostic — shows all data lake variables. Not production. |
|
||||||
|
| w1_system_health_indicator.html | System Health Indicator | Traffic light GREEN/AMBER/RED. Reads rov_failsafe from data lake. |
|
||||||
|
| w2_mission_status.html | Mission Status | Progress bar + state. Reads rov_mission_state and rov_mission_progress. |
|
||||||
|
| w3_abort_button.html | Abort Button | Confirm dialog → POST /abort to FastAPI. Edit FASTAPI_HOST before use. |
|
||||||
|
| w4_mission_setup_button.html | Mission Setup Button | Opens setup page in new tab. Edit SETUP_URL before use. |
|
||||||
|
| w5_battery_return_budget.html | Return Budget | Headroom warning. Reads SYS_STATUS/battery_remaining (native) + rov_return_budget. |
|
||||||
|
|
||||||
|
## Config values to edit before field use
|
||||||
|
|
||||||
|
In `w3_abort_button.html`:
|
||||||
|
|
||||||
|
```
|
||||||
|
const FASTAPI_HOST = 'http://blueos.local:8081';
|
||||||
|
```
|
||||||
|
|
||||||
|
In `w4_mission_setup_button.html`:
|
||||||
|
|
||||||
|
```
|
||||||
|
const SETUP_URL = 'http://blueos.local:8081/setup';
|
||||||
|
```
|
||||||
|
|
||||||
|
Both values are correct for real hardware. For dev VM, use the VM's Tailscale IP instead of `blueos.local`.
|
||||||
344
widgets/w0_data_lake_inspector.html
Normal file
344
widgets/w0_data_lake_inspector.html
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
============================================================
|
||||||
|
ARGONAUT 3 — DATA LAKE INSPECTOR
|
||||||
|
Widget 0 — Diagnostic tool (not a production widget)
|
||||||
|
============================================================
|
||||||
|
Purpose : Show all Cockpit data lake variables with live
|
||||||
|
updating values. Used during development to
|
||||||
|
discover what MAVLink data is available.
|
||||||
|
Import : Cockpit → Edit mode → Add DIY Widget →
|
||||||
|
gear icon → Import → select this file
|
||||||
|
Version : 1.1 (added scrolling, compact layout, fix)
|
||||||
|
============================================================
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
/* ── Reset and root variables ──────────────────────────── */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg0: #09111a; /* darkest background */
|
||||||
|
--bg1: #0e1d2e; /* table row background */
|
||||||
|
--bg2: #162336; /* header / input background */
|
||||||
|
--border: #1e3050; /* subtle border */
|
||||||
|
--text0: #d8eeff; /* primary text */
|
||||||
|
--text1: #6a9bbf; /* secondary / dim text */
|
||||||
|
--accent: #00c8f0; /* highlight colour */
|
||||||
|
--green: #00e09a; /* value flash on update */
|
||||||
|
--amber: #ffb830; /* status warning */
|
||||||
|
--red: #ff3a5a; /* status error / stale */
|
||||||
|
--mono: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Outer wrapper — fills the widget area ────────────── */
|
||||||
|
body {
|
||||||
|
background: var(--bg0);
|
||||||
|
color: var(--text0);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* prevent body scroll — table scrolls */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sticky header bar ─────────────────────────────────── */
|
||||||
|
#header {
|
||||||
|
background: var(--bg2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 5px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0; /* never shrink — always visible */
|
||||||
|
}
|
||||||
|
|
||||||
|
#header .title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search / filter input */
|
||||||
|
#search {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg0);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text0);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live variable count */
|
||||||
|
#count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text1);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection status dot */
|
||||||
|
#status {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status.ok { color: var(--green); }
|
||||||
|
#status.err { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Scrollable table container ────────────────────────── */
|
||||||
|
#table-wrap {
|
||||||
|
flex: 1; /* take remaining height */
|
||||||
|
overflow-y: auto; /* THIS is what enables scrolling */
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thin scrollbar styling */
|
||||||
|
#table-wrap::-webkit-scrollbar { width: 5px; }
|
||||||
|
#table-wrap::-webkit-scrollbar-track { background: var(--bg0); }
|
||||||
|
#table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
|
||||||
|
/* ── Table ─────────────────────────────────────────────── */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky column headers scroll with table — not the page */
|
||||||
|
thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text1);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: normal;
|
||||||
|
padding: 3px 6px;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column widths */
|
||||||
|
thead th:nth-child(1) { width: 55%; } /* Variable name */
|
||||||
|
thead th:nth-child(2) { width: 12%; } /* Type */
|
||||||
|
thead th:nth-child(3) { width: 33%; } /* Value */
|
||||||
|
|
||||||
|
/* Rows */
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover { background: var(--bg2); }
|
||||||
|
|
||||||
|
/* Flash green when a value changes */
|
||||||
|
tbody tr.flash td { color: var(--green); }
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 3px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variable name column */
|
||||||
|
td:nth-child(1) {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type column */
|
||||||
|
td:nth-child(2) {
|
||||||
|
color: var(--text1);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Value column */
|
||||||
|
td:nth-child(3) {
|
||||||
|
color: var(--text0);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state message */
|
||||||
|
#empty {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text1);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── Header bar ──────────────────────────────────────── -->
|
||||||
|
<div id="header">
|
||||||
|
<span class="title">⬡ Data Lake</span>
|
||||||
|
<input id="search" type="text" placeholder="filter variables…" />
|
||||||
|
<span id="count">0 vars</span>
|
||||||
|
<span id="status">connecting…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Scrollable table area ───────────────────────────── -->
|
||||||
|
<div id="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Variable</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody">
|
||||||
|
<!-- Rows injected by JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div id="empty" style="display:none">No variables match the filter.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── State ─────────────────────────────────────────────────
|
||||||
|
let vars = {}; // { variableName: { type, value } }
|
||||||
|
let filter = ''; // current search string (lowercased)
|
||||||
|
|
||||||
|
// ── DOM references ────────────────────────────────────────
|
||||||
|
const tbody = document.getElementById('tbody');
|
||||||
|
const countEl = document.getElementById('count');
|
||||||
|
const statusEl= document.getElementById('status');
|
||||||
|
const searchEl= document.getElementById('search');
|
||||||
|
const emptyEl = document.getElementById('empty');
|
||||||
|
|
||||||
|
// ── Format a raw value for display ───────────────────────
|
||||||
|
function fmt(v) {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
// Integers shown as-is; floats to 4 decimal places
|
||||||
|
return Number.isInteger(v) ? String(v) : v.toFixed(4);
|
||||||
|
}
|
||||||
|
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
||||||
|
if (typeof v === 'object') return JSON.stringify(v).slice(0, 60);
|
||||||
|
return String(v).slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render the table ─────────────────────────────────────
|
||||||
|
// Only re-renders rows that changed (avoids scroll reset)
|
||||||
|
function render() {
|
||||||
|
const keys = Object.keys(vars).sort();
|
||||||
|
const filtered = keys.filter(k =>
|
||||||
|
!filter || k.toLowerCase().includes(filter)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show/hide empty state
|
||||||
|
emptyEl.style.display = filtered.length === 0 ? '' : 'none';
|
||||||
|
|
||||||
|
// Update count
|
||||||
|
countEl.textContent = `${filtered.length} / ${keys.length} vars`;
|
||||||
|
|
||||||
|
// Build a map of existing rows by variable name
|
||||||
|
const existingRows = {};
|
||||||
|
tbody.querySelectorAll('tr[data-key]').forEach(row => {
|
||||||
|
existingRows[row.dataset.key] = row;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove rows no longer in filtered set
|
||||||
|
Object.keys(existingRows).forEach(k => {
|
||||||
|
if (!filtered.includes(k)) {
|
||||||
|
existingRows[k].remove();
|
||||||
|
delete existingRows[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add or update rows (in sorted order)
|
||||||
|
filtered.forEach((key, idx) => {
|
||||||
|
const { type, value } = vars[key];
|
||||||
|
let row = existingRows[key];
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
// Create new row
|
||||||
|
row = document.createElement('tr');
|
||||||
|
row.dataset.key = key;
|
||||||
|
row.innerHTML = `<td title="${key}">${key}</td><td></td><td></td>`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update type and value cells
|
||||||
|
const cells = row.cells;
|
||||||
|
const newType = type || '—';
|
||||||
|
const newValue = fmt(value);
|
||||||
|
|
||||||
|
// Flash green on value change
|
||||||
|
if (cells[2].textContent !== newValue) {
|
||||||
|
row.classList.add('flash');
|
||||||
|
setTimeout(() => row.classList.remove('flash'), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
cells[1].textContent = newType;
|
||||||
|
cells[2].textContent = newValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Poll the data lake every 500ms ───────────────────────
|
||||||
|
// getAllDataLakeVariablesInfo() returns {name: {type, value}}
|
||||||
|
function poll() {
|
||||||
|
try {
|
||||||
|
// Guard: API may not be ready immediately on load
|
||||||
|
if (typeof window.cockpit === 'undefined' ||
|
||||||
|
typeof window.cockpit.getAllDataLakeVariablesInfo !== 'function') {
|
||||||
|
statusEl.className = 'err';
|
||||||
|
statusEl.textContent = 'API not ready';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = window.cockpit.getAllDataLakeVariablesInfo();
|
||||||
|
|
||||||
|
if (!info || Object.keys(info).length === 0) {
|
||||||
|
statusEl.className = '';
|
||||||
|
statusEl.textContent = 'no data';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update our state from the fresh snapshot
|
||||||
|
Object.entries(info).forEach(([key, meta]) => {
|
||||||
|
vars[key] = {
|
||||||
|
type: meta?.type ?? typeof meta?.value ?? '?',
|
||||||
|
value: meta?.value ?? meta,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
statusEl.className = 'ok';
|
||||||
|
statusEl.textContent = 'live';
|
||||||
|
render();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.className = 'err';
|
||||||
|
statusEl.textContent = 'error';
|
||||||
|
console.error('[DataLake Inspector]', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search / filter handler ───────────────────────────────
|
||||||
|
searchEl.addEventListener('input', () => {
|
||||||
|
filter = searchEl.value.trim().toLowerCase();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Start polling ─────────────────────────────────────────
|
||||||
|
// Initial poll after 300ms (allows Cockpit API to initialise)
|
||||||
|
setTimeout(poll, 300);
|
||||||
|
setInterval(poll, 500);
|
||||||
|
</script>
|
||||||
288
widgets/w1_system_health_indicator.html
Normal file
288
widgets/w1_system_health_indicator.html
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
<!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>
|
||||||
230
widgets/w2_mission_status.html
Normal file
230
widgets/w2_mission_status.html
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
============================================================
|
||||||
|
ARGONAUT 3 — MISSION STATUS
|
||||||
|
Widget 2
|
||||||
|
============================================================
|
||||||
|
Purpose : Show the current mission state, progress bar, and
|
||||||
|
active waypoint during autonomous operation.
|
||||||
|
Data : Reads two NAMED_VALUE variables from the Cockpit
|
||||||
|
data lake (published by the ROS2 → MAVLink bridge):
|
||||||
|
rov_mission_state (INT) 0=IDLE, 1=RUNNING,
|
||||||
|
2=PAUSED, 3=COMPLETE,
|
||||||
|
4=ABORTED
|
||||||
|
rov_mission_progress (FLOAT) 0.0 to 1.0
|
||||||
|
Import : Cockpit → Edit mode → Add DIY Widget →
|
||||||
|
gear icon → Import → select this file
|
||||||
|
Note : Shows "--" placeholders when bridge is not running.
|
||||||
|
Version : 1.0
|
||||||
|
============================================================
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #09111a;
|
||||||
|
--bg2: #0e1d2e;
|
||||||
|
--border: #1e3050;
|
||||||
|
--text0: #d8eeff;
|
||||||
|
--text1: #6a9bbf;
|
||||||
|
--green: #00e09a;
|
||||||
|
--amber: #ffb830;
|
||||||
|
--red: #ff3a5a;
|
||||||
|
--blue: #00c8f0;
|
||||||
|
--dim: #1a2d42;
|
||||||
|
--mono: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
padding: 10px 14px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── State label (IDLE / RUNNING / etc.) ────────────────── */
|
||||||
|
#state-label {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text1); /* overridden per state */
|
||||||
|
}
|
||||||
|
|
||||||
|
#state-label.idle { color: var(--text1); }
|
||||||
|
#state-label.running { color: var(--green); }
|
||||||
|
#state-label.paused { color: var(--amber); }
|
||||||
|
#state-label.complete { color: var(--blue); }
|
||||||
|
#state-label.aborted { color: var(--red); }
|
||||||
|
#state-label.waiting { color: var(--dim); }
|
||||||
|
|
||||||
|
/* ── Progress bar ─────────────────────────────────────────── */
|
||||||
|
#bar-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--dim);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The filled portion */
|
||||||
|
#bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%; /* set dynamically via JS */
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--green); /* overridden per state */
|
||||||
|
transition: width 0.5s ease, background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Percentage label ────────────────────────────────────── */
|
||||||
|
#pct-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text1);
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Waypoint info line ──────────────────────────────────── */
|
||||||
|
#waypoint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Data source footer ──────────────────────────────────── */
|
||||||
|
#footer {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #1e3050;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="state-label" class="waiting">—</div>
|
||||||
|
|
||||||
|
<div id="bar-wrap">
|
||||||
|
<div id="bar-fill"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pct-label">—%</div>
|
||||||
|
|
||||||
|
<div id="waypoint">Waypoint: —</div>
|
||||||
|
|
||||||
|
<div id="footer">Waiting for rov_mission_state…</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Config ────────────────────────────────────────────────
|
||||||
|
const VAR_STATE = 'rov_mission_state'; // INT 0-4
|
||||||
|
const VAR_PROGRESS = 'rov_mission_progress'; // FLOAT 0.0-1.0
|
||||||
|
// Future: VAR_WAYPOINT could be a string variable once
|
||||||
|
// Cockpit supports NAMED_VALUE_STR. For now the waypoint
|
||||||
|
// label is derived from state + progress.
|
||||||
|
const POLL_MS = 500;
|
||||||
|
|
||||||
|
// State constants matching the MissionStatus ROS2 message
|
||||||
|
const STATE = {
|
||||||
|
0: 'IDLE',
|
||||||
|
1: 'RUNNING',
|
||||||
|
2: 'PAUSED',
|
||||||
|
3: 'COMPLETE',
|
||||||
|
4: 'ABORTED',
|
||||||
|
};
|
||||||
|
|
||||||
|
// CSS class and bar colour per state
|
||||||
|
const STATE_META = {
|
||||||
|
0: { cls: 'idle', barColor: '#1a2d42' }, /* dim — no mission */
|
||||||
|
1: { cls: 'running', barColor: '#00e09a' }, /* green — active */
|
||||||
|
2: { cls: 'paused', barColor: '#ffb830' }, /* amber — paused */
|
||||||
|
3: { cls: 'complete', barColor: '#00c8f0' }, /* blue — done */
|
||||||
|
4: { cls: 'aborted', barColor: '#ff3a5a' }, /* red — aborted */
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── DOM references ────────────────────────────────────────
|
||||||
|
const stateLabelEl = document.getElementById('state-label');
|
||||||
|
const barFillEl = document.getElementById('bar-fill');
|
||||||
|
const pctLabelEl = document.getElementById('pct-label');
|
||||||
|
const waypointEl = document.getElementById('waypoint');
|
||||||
|
const footerEl = document.getElementById('footer');
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────
|
||||||
|
function render(state, progress) {
|
||||||
|
const stateStr = STATE[state] ?? '?';
|
||||||
|
const meta = STATE_META[state] ?? { cls: 'waiting', barColor: '#1a2d42' };
|
||||||
|
const pct = Math.round((progress ?? 0) * 100);
|
||||||
|
|
||||||
|
// State label
|
||||||
|
stateLabelEl.className = meta.cls;
|
||||||
|
stateLabelEl.textContent = stateStr;
|
||||||
|
|
||||||
|
// Progress bar width + colour
|
||||||
|
barFillEl.style.width = `${pct}%`;
|
||||||
|
barFillEl.style.background = meta.barColor;
|
||||||
|
|
||||||
|
// Percentage text — suppress when idle or complete/aborted at 0
|
||||||
|
pctLabelEl.textContent = (state === 1 || state === 2)
|
||||||
|
? `${pct}%`
|
||||||
|
: (state === 3 ? '100%' : '—%');
|
||||||
|
|
||||||
|
// Waypoint info — simple derivation until a string variable is available
|
||||||
|
if (state === 1 || state === 2) {
|
||||||
|
waypointEl.textContent = `Progress ${pct}% complete`;
|
||||||
|
} else if (state === 3) {
|
||||||
|
waypointEl.textContent = 'Mission complete';
|
||||||
|
} else if (state === 4) {
|
||||||
|
waypointEl.textContent = 'Mission aborted — vehicle holding';
|
||||||
|
} else {
|
||||||
|
waypointEl.textContent = 'No mission active';
|
||||||
|
}
|
||||||
|
|
||||||
|
footerEl.textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Poll the data lake ────────────────────────────────────
|
||||||
|
function poll() {
|
||||||
|
try {
|
||||||
|
if (typeof window.cockpit === 'undefined' ||
|
||||||
|
typeof window.cockpit.getDataLakeValue !== 'function') {
|
||||||
|
stateLabelEl.className = 'waiting';
|
||||||
|
stateLabelEl.textContent = '—';
|
||||||
|
footerEl.textContent = 'Cockpit API not ready';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateRaw = window.cockpit.getDataLakeValue(VAR_STATE);
|
||||||
|
const progressRaw = window.cockpit.getDataLakeValue(VAR_PROGRESS);
|
||||||
|
|
||||||
|
if (stateRaw === null || stateRaw === undefined) {
|
||||||
|
stateLabelEl.className = 'waiting';
|
||||||
|
stateLabelEl.textContent = '—';
|
||||||
|
footerEl.textContent = `Waiting for ${VAR_STATE}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = parseInt(stateRaw, 10);
|
||||||
|
const progress = parseFloat(progressRaw ?? 0);
|
||||||
|
|
||||||
|
render(state, progress);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
stateLabelEl.className = 'waiting';
|
||||||
|
stateLabelEl.textContent = 'ERR';
|
||||||
|
footerEl.textContent = 'Poll error — see console';
|
||||||
|
console.error('[Mission Status Widget]', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────
|
||||||
|
setTimeout(poll, 300);
|
||||||
|
setInterval(poll, POLL_MS);
|
||||||
|
</script>
|
||||||
303
widgets/w3_abort_button.html
Normal file
303
widgets/w3_abort_button.html
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
============================================================
|
||||||
|
ARGONAUT 3 — ABORT BUTTON
|
||||||
|
Widget 3
|
||||||
|
============================================================
|
||||||
|
Purpose : Send an abort command to the mission executor via
|
||||||
|
a confirm dialog → POST to the FastAPI backend.
|
||||||
|
Safety : The confirm step is non-negotiable. A single press
|
||||||
|
shows a dialog. A second press (confirm) fires the
|
||||||
|
POST. The dialog auto-dismisses after 10 seconds
|
||||||
|
if not confirmed, to prevent accidental arming.
|
||||||
|
Endpoint: POST http://<FASTAPI_HOST>/abort
|
||||||
|
(FastAPI publishes true to /rov/mission/abort)
|
||||||
|
Config : Edit FASTAPI_HOST below to match your deployment.
|
||||||
|
During dev: the BlueOS VM IP + port 8081
|
||||||
|
In field: http://blueos.local:8081
|
||||||
|
Import : Cockpit → Edit mode → Add DIY Widget →
|
||||||
|
gear icon → Import → select this file
|
||||||
|
Version : 1.0
|
||||||
|
============================================================
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #09111a;
|
||||||
|
--bg2: #160a0f; /* red-tinted background */
|
||||||
|
--border: #3a1020;
|
||||||
|
--text0: #d8eeff;
|
||||||
|
--text1: #8fb3d4;
|
||||||
|
--red: #ff3a5a;
|
||||||
|
--red-dim: #7a1a28;
|
||||||
|
--green: #00e09a;
|
||||||
|
--amber: #ffb830;
|
||||||
|
--mono: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main abort button ───────────────────────────────────── */
|
||||||
|
#abort-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 0;
|
||||||
|
background: var(--red-dim);
|
||||||
|
border: 2px solid var(--red);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--red);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#abort-btn:hover {
|
||||||
|
background: #2a0c18;
|
||||||
|
box-shadow: 0 0 16px var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
#abort-btn:active {
|
||||||
|
background: var(--red);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button disabled state (after abort sent) */
|
||||||
|
#abort-btn:disabled {
|
||||||
|
background: #1a0a10;
|
||||||
|
border-color: #3a1020;
|
||||||
|
color: var(--red-dim);
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Confirm dialog overlay ──────────────────────────────── */
|
||||||
|
#confirm-overlay {
|
||||||
|
display: none; /* shown when confirm needed */
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show when active */
|
||||||
|
#confirm-overlay.active { display: flex; }
|
||||||
|
|
||||||
|
/* Dialog box */
|
||||||
|
#confirm-box {
|
||||||
|
background: #100510;
|
||||||
|
border: 2px solid var(--red);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
max-width: 220px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 30px var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-box p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text0);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-box .btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm (yes) button */
|
||||||
|
#confirm-yes {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--red);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-yes:hover { background: #ff6080; }
|
||||||
|
|
||||||
|
/* Cancel (no) button */
|
||||||
|
#confirm-no {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--text1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text1);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-no:hover { border-color: var(--text0); color: var(--text0); }
|
||||||
|
|
||||||
|
/* Auto-dismiss countdown */
|
||||||
|
#countdown {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--red-dim);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status message ──────────────────────────────────────── */
|
||||||
|
#status {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text1);
|
||||||
|
text-align: center;
|
||||||
|
min-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status.ok { color: var(--green); }
|
||||||
|
#status.err { color: var(--red); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Main button -->
|
||||||
|
<button id="abort-btn">⚠ ABORT MISSION</button>
|
||||||
|
|
||||||
|
<!-- Status message below the button -->
|
||||||
|
<div id="status">Standby</div>
|
||||||
|
|
||||||
|
<!-- Confirm dialog overlay (hidden until button pressed) -->
|
||||||
|
<div id="confirm-overlay">
|
||||||
|
<div id="confirm-box">
|
||||||
|
<p>Abort mission?<br>Vehicle will hold position<br>and await instruction.</p>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="confirm-yes">ABORT</button>
|
||||||
|
<button id="confirm-no">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div id="countdown"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Config ────────────────────────────────────────────────
|
||||||
|
// Change this to match your FastAPI deployment:
|
||||||
|
// Dev: http://192.168.122.89:8081 (BlueOS VM IP)
|
||||||
|
// Field: http://blueos.local:8081
|
||||||
|
const FASTAPI_HOST = 'http://blueos.local:8081';
|
||||||
|
|
||||||
|
// Seconds before the confirm dialog auto-dismisses
|
||||||
|
const AUTO_DISMISS_SEC = 10;
|
||||||
|
|
||||||
|
// ── DOM references ────────────────────────────────────────
|
||||||
|
const abortBtn = document.getElementById('abort-btn');
|
||||||
|
const overlay = document.getElementById('confirm-overlay');
|
||||||
|
const confirmYes = document.getElementById('confirm-yes');
|
||||||
|
const confirmNo = document.getElementById('confirm-no');
|
||||||
|
const countdownEl = document.getElementById('countdown');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
// ── Auto-dismiss timer handle ─────────────────────────────
|
||||||
|
let dismissTimer = null;
|
||||||
|
let countdownVal = AUTO_DISMISS_SEC;
|
||||||
|
|
||||||
|
// ── Show the confirm dialog ───────────────────────────────
|
||||||
|
function showConfirm() {
|
||||||
|
overlay.classList.add('active');
|
||||||
|
countdownVal = AUTO_DISMISS_SEC;
|
||||||
|
countdownEl.textContent = `Auto-cancel in ${countdownVal}s`;
|
||||||
|
|
||||||
|
// Countdown tick — dismiss automatically if ignored
|
||||||
|
dismissTimer = setInterval(() => {
|
||||||
|
countdownVal--;
|
||||||
|
countdownEl.textContent = `Auto-cancel in ${countdownVal}s`;
|
||||||
|
if (countdownVal <= 0) hideConfirm();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hide the confirm dialog ───────────────────────────────
|
||||||
|
function hideConfirm() {
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
clearInterval(dismissTimer);
|
||||||
|
dismissTimer = null;
|
||||||
|
countdownEl.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send the abort command ────────────────────────────────
|
||||||
|
async function sendAbort() {
|
||||||
|
hideConfirm();
|
||||||
|
abortBtn.disabled = true;
|
||||||
|
statusEl.className = '';
|
||||||
|
statusEl.textContent = 'Sending abort…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${FASTAPI_HOST}/abort`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ abort: true }),
|
||||||
|
// Short timeout — we don't want to hang
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
statusEl.className = 'ok';
|
||||||
|
statusEl.textContent = 'Abort sent — vehicle holding';
|
||||||
|
} else {
|
||||||
|
statusEl.className = 'err';
|
||||||
|
statusEl.textContent = `Server error: ${res.status}`;
|
||||||
|
abortBtn.disabled = false; // allow retry
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.className = 'err';
|
||||||
|
statusEl.textContent = 'Network error — check connection';
|
||||||
|
abortBtn.disabled = false; // allow retry
|
||||||
|
console.error('[Abort Widget]', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event listeners ───────────────────────────────────────
|
||||||
|
|
||||||
|
// First press — show dialog
|
||||||
|
abortBtn.addEventListener('click', () => {
|
||||||
|
if (!abortBtn.disabled) showConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm — fire abort
|
||||||
|
confirmYes.addEventListener('click', sendAbort);
|
||||||
|
|
||||||
|
// Cancel — dismiss dialog
|
||||||
|
confirmNo.addEventListener('click', hideConfirm);
|
||||||
|
|
||||||
|
// ── Reset button after 30s (allows re-abort if needed) ───
|
||||||
|
// In real operations the button stays disabled until the
|
||||||
|
// vehicle is verified safe and a manual reset is done.
|
||||||
|
// Uncomment the block below to allow automatic re-enable:
|
||||||
|
/*
|
||||||
|
function resetButton() {
|
||||||
|
setTimeout(() => {
|
||||||
|
abortBtn.disabled = false;
|
||||||
|
statusEl.className = '';
|
||||||
|
statusEl.textContent = 'Standby';
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
</script>
|
||||||
176
widgets/w4_mission_setup_button.html
Normal file
176
widgets/w4_mission_setup_button.html
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
============================================================
|
||||||
|
ARGONAUT 3 — MISSION SETUP BUTTON
|
||||||
|
Widget 4
|
||||||
|
============================================================
|
||||||
|
Purpose : Open the mission setup page in a new browser tab
|
||||||
|
without leaving the Cockpit dive view.
|
||||||
|
Visual : Subdued (dim) when a mission is active (running or
|
||||||
|
paused) to indicate it is available but not the
|
||||||
|
current priority. Full brightness when IDLE —
|
||||||
|
prompts the operator to configure before diving.
|
||||||
|
Data : Reads rov_mission_state to detect active mission.
|
||||||
|
Action : window.open() to setup page URL.
|
||||||
|
Config : Edit SETUP_URL below to match your deployment.
|
||||||
|
Import : Cockpit → Edit mode → Add DIY Widget →
|
||||||
|
gear icon → Import → select this file
|
||||||
|
Version : 1.0
|
||||||
|
============================================================
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #09111a;
|
||||||
|
--border: #1e3050;
|
||||||
|
--text0: #d8eeff;
|
||||||
|
--text1: #6a9bbf;
|
||||||
|
--accent: #00c8f0;
|
||||||
|
--dim: #1a2d42;
|
||||||
|
--amber: #ffb830;
|
||||||
|
--mono: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
padding: 10px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Setup button ────────────────────────────────────────── */
|
||||||
|
#setup-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s, color 0.2s, opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setup-btn:hover {
|
||||||
|
background: rgba(0,200,240,0.1);
|
||||||
|
box-shadow: 0 0 10px rgba(0,200,240,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subdued state when mission is active */
|
||||||
|
#setup-btn.subdued {
|
||||||
|
border-color: var(--dim);
|
||||||
|
color: var(--text1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setup-btn.subdued:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status line ─────────────────────────────────────────── */
|
||||||
|
#status {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status.active-warn { color: var(--amber); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<button id="setup-btn">⚙ Mission Setup</button>
|
||||||
|
<div id="status">—</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Config ────────────────────────────────────────────────
|
||||||
|
// URL for the mission setup page served by the extension backend
|
||||||
|
const SETUP_URL = 'http://blueos.local:8081/setup';
|
||||||
|
|
||||||
|
// Data lake variable for mission state (to detect active mission)
|
||||||
|
const VAR_STATE = 'rov_mission_state';
|
||||||
|
|
||||||
|
const POLL_MS = 1000; // less frequent poll — state changes slowly
|
||||||
|
|
||||||
|
// Mission state values (matches rov_interfaces/MissionStatus)
|
||||||
|
const STATE_IDLE = 0;
|
||||||
|
const STATE_RUNNING = 1;
|
||||||
|
const STATE_PAUSED = 2;
|
||||||
|
const STATE_COMPLETE = 3;
|
||||||
|
const STATE_ABORTED = 4;
|
||||||
|
|
||||||
|
// ── DOM references ────────────────────────────────────────
|
||||||
|
const setupBtn = document.getElementById('setup-btn');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
// ── Button click — open setup page ───────────────────────
|
||||||
|
setupBtn.addEventListener('click', () => {
|
||||||
|
window.open(SETUP_URL, '_blank', 'noopener,noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Poll mission state to control button appearance ───────
|
||||||
|
function poll() {
|
||||||
|
try {
|
||||||
|
if (typeof window.cockpit === 'undefined' ||
|
||||||
|
typeof window.cockpit.getDataLakeValue !== 'function') {
|
||||||
|
// API not ready — show neutral state
|
||||||
|
setupBtn.classList.remove('subdued');
|
||||||
|
statusEl.className = '';
|
||||||
|
statusEl.textContent = 'Connecting…';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateRaw = window.cockpit.getDataLakeValue(VAR_STATE);
|
||||||
|
|
||||||
|
if (stateRaw === null || stateRaw === undefined) {
|
||||||
|
// Bridge not running — show button normally (no mission context)
|
||||||
|
setupBtn.classList.remove('subdued');
|
||||||
|
statusEl.className = '';
|
||||||
|
statusEl.textContent = 'No mission loaded';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = parseInt(stateRaw, 10);
|
||||||
|
|
||||||
|
if (state === STATE_RUNNING || state === STATE_PAUSED) {
|
||||||
|
// Mission in progress — subdue the button (still accessible)
|
||||||
|
setupBtn.classList.add('subdued');
|
||||||
|
statusEl.className = 'active-warn';
|
||||||
|
statusEl.textContent = state === STATE_RUNNING
|
||||||
|
? 'Mission running — setup changes not recommended'
|
||||||
|
: 'Mission paused — setup changes not recommended';
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// IDLE / COMPLETE / ABORTED — full brightness
|
||||||
|
setupBtn.classList.remove('subdued');
|
||||||
|
statusEl.className = '';
|
||||||
|
statusEl.textContent = state === STATE_IDLE
|
||||||
|
? 'No mission loaded — configure before diving'
|
||||||
|
: 'Ready to configure next mission';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Setup Button Widget]', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────
|
||||||
|
setTimeout(poll, 300);
|
||||||
|
setInterval(poll, POLL_MS);
|
||||||
|
</script>
|
||||||
270
widgets/w5_battery_return_budget.html
Normal file
270
widgets/w5_battery_return_budget.html
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
============================================================
|
||||||
|
ARGONAUT 3 — BATTERY / RETURN BUDGET
|
||||||
|
Widget 5
|
||||||
|
============================================================
|
||||||
|
Purpose : Show battery percentage together with the dynamic
|
||||||
|
return budget. Highlights amber when headroom
|
||||||
|
(battery − return budget) drops below 15%.
|
||||||
|
Data : Reads two NAMED_VALUE variables from the data lake:
|
||||||
|
rov_battery_pct (FLOAT) 0.0–100.0 (%)
|
||||||
|
rov_return_budget (FLOAT) 0.0–100.0 (%)
|
||||||
|
(published by FastAPI backend based on depth,
|
||||||
|
distance, and thruster history)
|
||||||
|
Falls back to MAVROS native battery variable if
|
||||||
|
rov_battery_pct is not yet available.
|
||||||
|
Import : Cockpit → Edit mode → Add DIY Widget →
|
||||||
|
gear icon → Import → select this file
|
||||||
|
Version : 1.0
|
||||||
|
============================================================
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #09111a;
|
||||||
|
--bg2: #0e1d2e;
|
||||||
|
--border: #1e3050;
|
||||||
|
--text0: #d8eeff;
|
||||||
|
--text1: #6a9bbf;
|
||||||
|
--green: #00e09a;
|
||||||
|
--amber: #ffb830;
|
||||||
|
--red: #ff3a5a;
|
||||||
|
--dim: #1a2d42;
|
||||||
|
--mono: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
padding: 10px 14px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Battery icon + percentage ───────────────────────────── */
|
||||||
|
#batt-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG battery icon housing */
|
||||||
|
#batt-icon {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 22px;
|
||||||
|
border: 2px solid var(--text1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Battery terminal nub on right */
|
||||||
|
#batt-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -7px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 5px;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--text1);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fill bar inside the icon */
|
||||||
|
#batt-fill {
|
||||||
|
position: absolute;
|
||||||
|
left: 1px;
|
||||||
|
top: 1px;
|
||||||
|
bottom: 1px;
|
||||||
|
width: 80%; /* set by JS */
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--green);
|
||||||
|
transition: width 0.5s ease, background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Large percentage number */
|
||||||
|
#batt-pct {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--green);
|
||||||
|
transition: color 0.3s;
|
||||||
|
min-width: 56px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#batt-pct.amber { color: var(--amber); }
|
||||||
|
#batt-pct.red { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Return budget line ──────────────────────────────────── */
|
||||||
|
#budget-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#budget-val {
|
||||||
|
color: var(--text0);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#budget-val.warn { color: var(--amber); }
|
||||||
|
|
||||||
|
/* ── Headroom indicator ──────────────────────────────────── */
|
||||||
|
#headroom-row {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#headroom-row.warn { color: var(--amber); }
|
||||||
|
#headroom-row.crit { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Footer ──────────────────────────────────────────────── */
|
||||||
|
#footer {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #1e3050;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Battery visual + percentage -->
|
||||||
|
<div id="batt-row">
|
||||||
|
<div id="batt-icon">
|
||||||
|
<div id="batt-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div id="batt-pct">--%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Return budget required -->
|
||||||
|
<div id="budget-row">
|
||||||
|
Return budget: <span id="budget-val">--%</span> required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available headroom -->
|
||||||
|
<div id="headroom-row" id="headroom">Headroom: --</div>
|
||||||
|
|
||||||
|
<!-- Data source status -->
|
||||||
|
<div id="footer">Waiting for rov_battery_pct…</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Config ────────────────────────────────────────────────
|
||||||
|
const VAR_BATTERY = 'rov_battery_pct'; // FLOAT 0–100
|
||||||
|
const VAR_BUDGET = 'rov_return_budget'; // FLOAT 0–100
|
||||||
|
// Fallback MAVROS native battery variable (different name in data lake)
|
||||||
|
const VAR_MAVROS_BATT = 'BATTERY_STATUS/voltages/0'; // adjust if needed
|
||||||
|
|
||||||
|
// Thresholds (percentage points of headroom)
|
||||||
|
const WARN_HEADROOM = 15; // < 15% headroom → amber
|
||||||
|
const CRIT_HEADROOM = 5; // < 5% headroom → red
|
||||||
|
|
||||||
|
const POLL_MS = 1000;
|
||||||
|
|
||||||
|
// ── DOM references ────────────────────────────────────────
|
||||||
|
const battFill = document.getElementById('batt-fill');
|
||||||
|
const battPct = document.getElementById('batt-pct');
|
||||||
|
const budgetVal = document.getElementById('budget-val');
|
||||||
|
const headroomRow = document.getElementById('headroom-row');
|
||||||
|
const footerEl = document.getElementById('footer');
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────
|
||||||
|
function render(battPctVal, budgetPctVal) {
|
||||||
|
// Clamp to 0–100
|
||||||
|
const batt = Math.max(0, Math.min(100, battPctVal ?? 0));
|
||||||
|
const budget = Math.max(0, Math.min(100, budgetPctVal ?? 0));
|
||||||
|
const margin = batt - budget;
|
||||||
|
|
||||||
|
// Battery icon fill width (percentage of inner width)
|
||||||
|
battFill.style.width = `${batt}%`;
|
||||||
|
|
||||||
|
// Battery icon fill colour
|
||||||
|
let fillColor;
|
||||||
|
if (batt > 40) {
|
||||||
|
fillColor = '#00e09a'; /* green */
|
||||||
|
} else if (batt > 20) {
|
||||||
|
fillColor = '#ffb830'; /* amber */
|
||||||
|
} else {
|
||||||
|
fillColor = '#ff3a5a'; /* red */
|
||||||
|
}
|
||||||
|
battFill.style.background = fillColor;
|
||||||
|
|
||||||
|
// Percentage text + class
|
||||||
|
battPct.textContent = `${Math.round(batt)}%`;
|
||||||
|
battPct.className = batt > 40 ? '' : batt > 20 ? 'amber' : 'red';
|
||||||
|
|
||||||
|
// Return budget value
|
||||||
|
budgetVal.textContent = `${Math.round(budget)}%`;
|
||||||
|
|
||||||
|
// Headroom and warning state
|
||||||
|
headroomRow.textContent = `Headroom: ${Math.round(margin)}%`;
|
||||||
|
if (margin < CRIT_HEADROOM) {
|
||||||
|
headroomRow.className = 'crit';
|
||||||
|
budgetVal.className = 'warn';
|
||||||
|
} else if (margin < WARN_HEADROOM) {
|
||||||
|
headroomRow.className = 'warn';
|
||||||
|
budgetVal.className = 'warn';
|
||||||
|
} else {
|
||||||
|
headroomRow.className = '';
|
||||||
|
budgetVal.className = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
footerEl.textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Poll ──────────────────────────────────────────────────
|
||||||
|
function poll() {
|
||||||
|
try {
|
||||||
|
if (typeof window.cockpit === 'undefined' ||
|
||||||
|
typeof window.cockpit.getDataLakeValue !== 'function') {
|
||||||
|
footerEl.textContent = 'Cockpit API not ready';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try primary variable first; fall back to MAVROS native
|
||||||
|
let battRaw = window.cockpit.getDataLakeValue(VAR_BATTERY);
|
||||||
|
let source = VAR_BATTERY;
|
||||||
|
|
||||||
|
if (battRaw === null || battRaw === undefined) {
|
||||||
|
battRaw = window.cockpit.getDataLakeValue(VAR_MAVROS_BATT);
|
||||||
|
source = VAR_MAVROS_BATT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (battRaw === null || battRaw === undefined) {
|
||||||
|
footerEl.textContent = 'Waiting for battery data…';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetRaw = window.cockpit.getDataLakeValue(VAR_BUDGET);
|
||||||
|
const budget = (budgetRaw !== null && budgetRaw !== undefined)
|
||||||
|
? parseFloat(budgetRaw)
|
||||||
|
: 0; // 0 if budget not yet calculated
|
||||||
|
|
||||||
|
render(parseFloat(battRaw), budget);
|
||||||
|
|
||||||
|
// Note source in footer so we know which variable was used
|
||||||
|
if (source !== VAR_BATTERY) {
|
||||||
|
footerEl.textContent += ` (via ${source})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
footerEl.textContent = 'Poll error — see console';
|
||||||
|
console.error('[Battery Widget]', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────
|
||||||
|
setTimeout(poll, 300);
|
||||||
|
setInterval(poll, POLL_MS);
|
||||||
|
</script>
|
||||||
Loading…
Reference in New Issue
Block a user