feat: add all Argonaut 3 DIY widgets (W0-W5)

This commit is contained in:
Grant 2026-05-11 10:30:55 +02:00
parent 81fad536f9
commit 6a145f24d2
7 changed files with 1649 additions and 0 deletions

38
widgets/README.md Normal file
View 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`.

View 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>

View 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>

View 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>

View 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>

View 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>

View 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.0100.0 (%)
rov_return_budget (FLOAT) 0.0100.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 0100
const VAR_BUDGET = 'rov_return_budget'; // FLOAT 0100
// 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 0100
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>