diff --git a/ui/dashboard.css b/ui/dashboard.css
new file mode 100644
index 0000000..1f7c806
--- /dev/null
+++ b/ui/dashboard.css
@@ -0,0 +1,429 @@
+/* dashboard.css — Saltybot Main Dashboard (Issue #630) */
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --bg0: #050510;
+ --bg1: #070712;
+ --bg2: #0a0a1a;
+ --bd: #0c2a3a;
+ --bd2: #1e3a5f;
+ --dim: #374151;
+ --mid: #6b7280;
+ --base: #9ca3af;
+ --hi: #d1d5db;
+ --cyan: #06b6d4;
+ --green: #22c55e;
+ --amber: #f59e0b;
+ --red: #ef4444;
+}
+
+html, body {
+ height: 100%;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 12px;
+ background: var(--bg0);
+ color: var(--base);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* ══════════════════ TOP BAR ══════════════════ */
+#topbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 14px;
+ background: var(--bg1);
+ border-bottom: 1px solid var(--bd);
+ flex-shrink: 0;
+ flex-wrap: wrap;
+}
+
+#topbar-left {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ flex-shrink: 0;
+}
+.logo {
+ color: #f97316;
+ font-weight: bold;
+ letter-spacing: .18em;
+ font-size: 14px;
+}
+#robot-name {
+ color: var(--mid);
+ font-size: 10px;
+ letter-spacing: .12em;
+}
+
+#topbar-status {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ flex: 1;
+ min-width: 0;
+ flex-wrap: wrap;
+ gap: 2px;
+}
+
+.stat-block {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 2px 10px;
+}
+.stat-icon { font-size: 14px; flex-shrink: 0; }
+.stat-body { display: flex; flex-direction: column; gap: 1px; }
+.stat-label {
+ font-size: 8px;
+ color: var(--dim);
+ letter-spacing: .1em;
+ text-transform: uppercase;
+}
+.stat-val {
+ font-size: 11px;
+ color: var(--hi);
+ font-family: monospace;
+ white-space: nowrap;
+}
+.stat-sep {
+ width: 1px;
+ height: 28px;
+ background: var(--bd);
+ flex-shrink: 0;
+}
+
+/* Battery bar */
+.batt-bar {
+ width: 28px;
+ height: 12px;
+ border: 1px solid var(--bd2);
+ border-radius: 2px;
+ overflow: hidden;
+ position: relative;
+ background: var(--bg0);
+}
+.batt-bar::after {
+ content: '';
+ position: absolute;
+ right: -3px; top: 3px;
+ width: 3px; height: 6px;
+ background: var(--bd2);
+ border-radius: 0 1px 1px 0;
+}
+#batt-fill {
+ height: 100%;
+ width: 0%;
+ background: var(--green);
+ transition: width .5s, background .5s;
+}
+
+#topbar-right {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+ margin-left: auto;
+}
+
+#conn-dot {
+ width: 8px; height: 8px; border-radius: 50%;
+ background: var(--dim); flex-shrink: 0;
+ transition: background .3s;
+}
+#conn-dot.connected { background: var(--green); }
+#conn-dot.error { background: var(--red); animation: blink 1s infinite; }
+
+#conn-label {
+ font-size: 10px;
+ color: var(--mid);
+ white-space: nowrap;
+}
+
+#ws-input {
+ background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px;
+ color: #67e8f9; padding: 2px 7px; font-family: monospace; font-size: 11px;
+ width: 175px;
+}
+#ws-input:focus { outline: none; border-color: var(--cyan); }
+
+.hbtn {
+ padding: 2px 8px; border-radius: 4px; border: 1px solid var(--bd2);
+ background: var(--bg2); color: #67e8f9; font-family: monospace;
+ font-size: 10px; font-weight: bold; cursor: pointer; transition: background .15s;
+ white-space: nowrap;
+}
+.hbtn:hover { background: #0e4f69; }
+
+@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
+@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
+
+/* ══════════════════ MAIN / GRID ══════════════════ */
+#main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ padding: 16px;
+ gap: 12px;
+ min-height: 0;
+}
+
+/* Auto-detect bar */
+#detect-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 12px;
+ background: var(--bg2);
+ border: 1px solid var(--bd2);
+ border-radius: 6px;
+ font-size: 10px;
+ color: var(--mid);
+ flex-shrink: 0;
+}
+#detect-bar.connected {
+ border-color: #14532d;
+ color: var(--green);
+ background: #020f06;
+}
+#detect-bar.error {
+ border-color: #7f1d1d;
+ color: var(--red);
+}
+#detect-status { flex: 1; }
+#detect-dots { display: flex; gap: 4px; }
+.detect-dot {
+ width: 6px; height: 6px; border-radius: 50%;
+ background: var(--dim);
+ transition: background .3s;
+}
+.detect-dot.trying { background: var(--amber); animation: pulse .7s infinite; }
+.detect-dot.ok { background: var(--green); }
+.detect-dot.fail { background: var(--red); }
+
+/* Panel grid */
+#grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 12px;
+}
+
+/* Panel card */
+.panel-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 14px;
+ background: var(--bg2);
+ border: 1px solid var(--bd2);
+ border-radius: 10px;
+ text-decoration: none;
+ color: inherit;
+ cursor: pointer;
+ transition: border-color .2s, background .2s, transform .1s;
+ position: relative;
+ overflow: hidden;
+}
+.panel-card::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(ellipse at top left, rgba(6,182,212,.04), transparent 60%);
+ pointer-events: none;
+}
+.panel-card:hover {
+ border-color: var(--cyan);
+ background: #0a0a1e;
+ transform: translateY(-1px);
+}
+.panel-card:active { transform: translateY(0); }
+
+.card-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.card-icon { font-size: 24px; flex-shrink: 0; }
+.card-title {
+ font-size: 12px;
+ font-weight: bold;
+ letter-spacing: .12em;
+ color: var(--hi);
+ text-transform: uppercase;
+}
+.card-sub { font-size: 9px; color: var(--dim); }
+.card-dot {
+ width: 9px; height: 9px; border-radius: 50%;
+ background: var(--dim);
+ margin-left: auto;
+ flex-shrink: 0;
+ transition: background .3s;
+}
+.card-dot.live { background: var(--green); }
+.card-dot.idle { background: var(--amber); }
+.card-dot.config { background: var(--mid); }
+
+.card-desc {
+ font-size: 10px;
+ color: var(--mid);
+ line-height: 1.5;
+}
+
+.card-topics {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+.card-topics code {
+ font-size: 8px;
+ color: #4b5563;
+ background: var(--bg0);
+ border: 1px solid var(--bd);
+ border-radius: 2px;
+ padding: 1px 4px;
+}
+
+.card-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-top: 1px solid var(--bd);
+ padding-top: 6px;
+ margin-top: auto;
+}
+.card-status {
+ font-size: 9px;
+ font-weight: bold;
+ letter-spacing: .1em;
+ color: var(--dim);
+}
+.card-status.live { color: var(--green); }
+.card-status.idle { color: var(--amber); }
+.card-msg { font-size: 9px; color: var(--dim); font-family: monospace; }
+
+/* Estop active state on card */
+.panel-card.estop-flash {
+ border-color: var(--red) !important;
+ animation: blink .6s infinite;
+}
+
+/* Info strip */
+#info-strip {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ padding: 6px 12px;
+ background: var(--bg2);
+ border: 1px solid var(--bd);
+ border-radius: 6px;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+}
+.info-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 14px;
+ border-right: 1px solid var(--bd);
+}
+.info-item:first-child { padding-left: 0; }
+.info-item:last-child { border-right: none; }
+.info-lbl { font-size: 8px; color: var(--dim); letter-spacing: .1em; }
+.info-val { font-size: 10px; color: var(--hi); font-family: monospace; }
+
+/* ══════════════════ BOTTOM BAR ══════════════════ */
+#bottombar {
+ display: flex;
+ align-items: center;
+ padding: 6px 14px;
+ background: var(--bg1);
+ border-top: 1px solid var(--bd);
+ flex-shrink: 0;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+#bottombar-left { display: flex; gap: 8px; align-items: center; }
+
+.estop-btn {
+ padding: 5px 18px;
+ border-radius: 5px;
+ border: 2px solid #7f1d1d;
+ background: #1c0505;
+ color: #fca5a5;
+ font-family: monospace;
+ font-size: 11px;
+ font-weight: bold;
+ cursor: pointer;
+ letter-spacing: .08em;
+ transition: background .15s, border-color .15s;
+}
+.estop-btn:hover { background: #3b0606; border-color: var(--red); }
+.estop-btn.active {
+ background: #7f1d1d;
+ border-color: var(--red);
+ color: #fff;
+ animation: blink .7s infinite;
+}
+
+.resume-btn {
+ padding: 5px 14px;
+ border-radius: 5px;
+ border: 1px solid #14532d;
+ background: #052010;
+ color: #86efac;
+ font-family: monospace;
+ font-size: 11px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: background .15s;
+}
+.resume-btn:hover { background: #0a3a1a; }
+
+#bottombar-center {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+}
+.bb-lbl { font-size: 9px; color: var(--dim); letter-spacing: .1em; flex-shrink: 0; }
+
+#mode-btns { display: flex; gap: 4px; }
+.mode-btn {
+ padding: 3px 10px;
+ border-radius: 4px;
+ border: 1px solid var(--bd2);
+ background: var(--bg2);
+ color: var(--mid);
+ font-family: monospace;
+ font-size: 10px;
+ font-weight: bold;
+ cursor: pointer;
+ letter-spacing: .08em;
+ transition: background .15s, color .15s, border-color .15s;
+}
+.mode-btn:hover { background: #0e4f69; color: var(--hi); }
+.mode-btn.active { background: #0e4f69; border-color: var(--cyan); color: #fff; }
+
+#bottombar-right {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-left: auto;
+}
+#uptime-display { display: flex; align-items: center; gap: 6px; }
+#session-time { font-size: 11px; color: var(--mid); font-family: monospace; }
+
+/* ══════════════════ RESPONSIVE ══════════════════ */
+@media (max-width: 700px) {
+ #topbar-status { display: none; }
+ #grid { grid-template-columns: 1fr 1fr; }
+}
+@media (max-width: 480px) {
+ #grid { grid-template-columns: 1fr; }
+ #mode-btns .mode-btn:nth-child(n+3) { display: none; }
+}
diff --git a/ui/dashboard.js b/ui/dashboard.js
new file mode 100644
index 0000000..d140d6e
--- /dev/null
+++ b/ui/dashboard.js
@@ -0,0 +1,480 @@
+/* dashboard.js — Saltybot Main Dashboard (Issue #630) */
+'use strict';
+
+// ── Auto-detect candidates ─────────────────────────────────────────────────
+// Try current page's hostname first (if served from robot), then localhost
+const AUTO_CANDIDATES = (() => {
+ const candidates = [];
+ if (location.hostname && location.hostname !== '' && location.hostname !== 'localhost') {
+ candidates.push(`ws://${location.hostname}:9090`);
+ }
+ candidates.push('ws://localhost:9090');
+ candidates.push('ws://saltybot.local:9090');
+ return candidates;
+})();
+
+// ── Panel definitions (status tracking) ───────────────────────────────────
+const PANELS = [
+ { id: 'map', watchTopic: '/saltybot/pose/fused', msgType: 'geometry_msgs/PoseStamped' },
+ { id: 'gamepad', watchTopic: null, msgType: null }, // output only
+ { id: 'diag', watchTopic: '/diagnostics', msgType: 'diagnostic_msgs/DiagnosticArray' },
+ { id: 'events', watchTopic: '/rosout', msgType: 'rcl_interfaces/msg/Log' },
+ { id: 'settings', watchTopic: null, msgType: null }, // service-based
+ { id: 'gimbal', watchTopic: '/gimbal/state', msgType: 'geometry_msgs/Vector3' },
+];
+
+// ── State ──────────────────────────────────────────────────────────────────
+const state = {
+ connected: false,
+ estop: false,
+ driveMode: 'MANUAL',
+ battery: null, // 0..100 %
+ voltage: null,
+ safetyState: null, // 'CLEAR' | 'WARN' | 'DANGER' | 'ESTOP'
+ closestM: null,
+ uptime: 0, // seconds since ROS connection
+ sessionStart: null, // Date for session timer
+ msgCount: 0,
+ lastMsgTs: 0,
+ msgRate: 0,
+ latency: null,
+ // Per-panel last-message timestamps
+ panelTs: {},
+};
+
+// ── ROS ────────────────────────────────────────────────────────────────────
+let ros = null;
+let cmdVelTopic = null;
+let modeTopic = null;
+let uptimeInterval = null;
+let panelWatches = {}; // id → ROSLIB.Topic
+let pingTs = 0;
+let pingTimeout = null;
+
+function connect(url) {
+ if (ros) {
+ Object.values(panelWatches).forEach(t => { try { t.unsubscribe(); } catch(_){} });
+ panelWatches = {};
+ try { ros.close(); } catch(_){}
+ }
+
+ ros = new ROSLIB.Ros({ url });
+
+ ros.on('connection', () => {
+ state.connected = true;
+ state.sessionStart = state.sessionStart || new Date();
+ localStorage.setItem('dashboard_ws_url', url);
+ $connDot.className = 'connected';
+ $connLabel.style.color = '#22c55e';
+ $connLabel.textContent = 'Connected';
+ $wsInput.value = url;
+ document.getElementById('info-ws').textContent = url;
+
+ setupTopics();
+ schedulePing();
+ updateDetectBar('connected', `Connected to ${url}`);
+ });
+
+ ros.on('error', () => {
+ state.connected = false;
+ $connDot.className = 'error';
+ $connLabel.style.color = '#ef4444';
+ $connLabel.textContent = 'Error';
+ updateDetectBar('error', `Error connecting to ${url}`);
+ });
+
+ ros.on('close', () => {
+ state.connected = false;
+ $connDot.className = '';
+ $connLabel.style.color = '#6b7280';
+ $connLabel.textContent = 'Disconnected';
+ });
+}
+
+function setupTopics() {
+ // ── Battery & temps from /diagnostics ──
+ const diagTopic = new ROSLIB.Topic({
+ ros, name: '/diagnostics',
+ messageType: 'diagnostic_msgs/DiagnosticArray',
+ throttle_rate: 2000,
+ });
+ diagTopic.subscribe(msg => {
+ state.msgCount++;
+ state.lastMsgTs = performance.now();
+ (msg.status || []).forEach(s => {
+ (s.values || []).forEach(kv => {
+ if (kv.key === 'battery_voltage_v') {
+ state.voltage = parseFloat(kv.value);
+ // 4S LiPo: 12.0V empty, 16.8V full
+ state.battery = Math.max(0, Math.min(100,
+ ((state.voltage - 12.0) / (16.8 - 12.0)) * 100));
+ }
+ if (kv.key === 'battery_soc_pct') {
+ state.battery = parseFloat(kv.value);
+ }
+ });
+ });
+ markPanelLive('diag');
+ renderBattery();
+ });
+
+ // ── Safety zone ──
+ const safetyTopic = new ROSLIB.Topic({
+ ros, name: '/saltybot/safety_zone/status',
+ messageType: 'std_msgs/String',
+ throttle_rate: 500,
+ });
+ safetyTopic.subscribe(msg => {
+ state.msgCount++;
+ try {
+ const d = JSON.parse(msg.data);
+ state.safetyState = d.state || d.fwd_zone || 'CLEAR';
+ state.closestM = d.closest_m != null ? d.closest_m : null;
+ if (d.estop !== undefined) {
+ // only auto-latch E-stop if ROS says it's active and we aren't tracking it locally
+ if (d.estop && !state.estop) activateEstop(false);
+ }
+ } catch(_) {
+ state.safetyState = msg.data;
+ }
+ renderSafety();
+ });
+
+ // ── Balance state / drive mode ──
+ const modeSub = new ROSLIB.Topic({
+ ros, name: '/saltybot/balance_state',
+ messageType: 'std_msgs/String',
+ throttle_rate: 1000,
+ });
+ modeSub.subscribe(msg => {
+ try {
+ const d = JSON.parse(msg.data);
+ if (d.mode) {
+ state.driveMode = d.mode.toUpperCase();
+ renderMode();
+ }
+ } catch(_) {}
+ });
+
+ // ── Pose (for map card liveness) ──
+ const poseTopic = new ROSLIB.Topic({
+ ros, name: '/saltybot/pose/fused',
+ messageType: 'geometry_msgs/PoseStamped',
+ throttle_rate: 1000,
+ });
+ poseTopic.subscribe(() => { markPanelLive('map'); });
+
+ // ── /rosout (for events card) ──
+ const rosoutTopic = new ROSLIB.Topic({
+ ros, name: '/rosout',
+ messageType: 'rcl_interfaces/msg/Log',
+ throttle_rate: 2000,
+ });
+ rosoutTopic.subscribe(() => { markPanelLive('events'); });
+
+ // ── Gimbal state ──
+ const gimbalTopic = new ROSLIB.Topic({
+ ros, name: '/gimbal/state',
+ messageType: 'geometry_msgs/Vector3',
+ throttle_rate: 1000,
+ });
+ gimbalTopic.subscribe(() => { markPanelLive('gimbal'); });
+
+ // ── cmd_vel monitor (for gamepad card liveness) ──
+ const cmdVelWatch = new ROSLIB.Topic({
+ ros, name: '/cmd_vel',
+ messageType: 'geometry_msgs/Twist',
+ throttle_rate: 500,
+ });
+ cmdVelWatch.subscribe(() => { markPanelLive('gamepad'); });
+
+ // ── Publisher topics ──
+ cmdVelTopic = new ROSLIB.Topic({
+ ros, name: '/cmd_vel',
+ messageType: 'geometry_msgs/Twist',
+ });
+
+ modeTopic = new ROSLIB.Topic({
+ ros, name: '/saltybot/drive_mode',
+ messageType: 'std_msgs/String',
+ });
+
+ // ── Robot uptime from /diagnostics or fallback to /rosapi ──
+ const upSvc = new ROSLIB.Service({
+ ros, name: '/rosapi/get_time',
+ serviceType: 'rosapi/GetTime',
+ });
+ upSvc.callService({}, () => {
+ state.uptime = 0;
+ if (uptimeInterval) clearInterval(uptimeInterval);
+ uptimeInterval = setInterval(() => { state.uptime++; renderUptime(); }, 1000);
+ });
+}
+
+// ── E-stop ─────────────────────────────────────────────────────────────────
+function activateEstop(sendCmd = true) {
+ state.estop = true;
+ if (sendCmd && cmdVelTopic) {
+ cmdVelTopic.publish(new ROSLIB.Message({
+ linear: { x: 0, y: 0, z: 0 }, angular: { x: 0, y: 0, z: 0 },
+ }));
+ }
+ document.getElementById('btn-estop').style.display = 'none';
+ document.getElementById('btn-resume').style.display = '';
+ document.getElementById('btn-estop').classList.add('active');
+ document.getElementById('val-estop').textContent = 'ACTIVE';
+ document.getElementById('val-estop').style.color = '#ef4444';
+ // Flash all cards
+ document.querySelectorAll('.panel-card').forEach(c => c.classList.add('estop-flash'));
+}
+
+function resumeFromEstop() {
+ state.estop = false;
+ document.getElementById('btn-estop').style.display = '';
+ document.getElementById('btn-resume').style.display = 'none';
+ document.getElementById('btn-estop').classList.remove('active');
+ document.getElementById('val-estop').textContent = 'OFF';
+ document.getElementById('val-estop').style.color = '#6b7280';
+ document.querySelectorAll('.panel-card').forEach(c => c.classList.remove('estop-flash'));
+}
+
+document.getElementById('btn-estop').addEventListener('click', () => activateEstop(true));
+document.getElementById('btn-resume').addEventListener('click', resumeFromEstop);
+
+// Space bar = emergency E-stop from dashboard
+document.addEventListener('keydown', e => {
+ if (e.code === 'Space' && e.target.tagName !== 'INPUT') {
+ e.preventDefault();
+ if (state.estop) resumeFromEstop(); else activateEstop(true);
+ }
+});
+
+// ── Drive mode buttons ─────────────────────────────────────────────────────
+document.querySelectorAll('.mode-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ state.driveMode = btn.dataset.mode;
+ if (modeTopic) {
+ modeTopic.publish(new ROSLIB.Message({ data: state.driveMode }));
+ }
+ renderMode();
+ });
+});
+
+// ── Panel liveness tracking ────────────────────────────────────────────────
+const LIVE_TIMEOUT_MS = 5000;
+
+function markPanelLive(id) {
+ state.panelTs[id] = Date.now();
+ renderPanelCard(id);
+}
+
+function renderPanelCard(id) {
+ const ts = state.panelTs[id];
+ const dot = document.getElementById(`dot-${id}`);
+ const statusEl = document.getElementById(`status-${id}`);
+ const msgEl = document.getElementById(`msg-${id}`);
+ if (!dot || !statusEl) return;
+
+ if (!state.connected) {
+ dot.className = 'card-dot';
+ statusEl.className = 'card-status';
+ statusEl.textContent = 'OFFLINE';
+ if (msgEl) msgEl.textContent = 'Not connected';
+ return;
+ }
+
+ // Settings card is always config
+ if (id === 'settings') return;
+ // Gamepad is output only — show "READY" when connected
+ if (id === 'gamepad') {
+ const age = ts ? Date.now() - ts : Infinity;
+ if (age < LIVE_TIMEOUT_MS) {
+ dot.className = 'card-dot live';
+ statusEl.className = 'card-status live';
+ statusEl.textContent = 'ACTIVE';
+ if (msgEl) msgEl.textContent = 'Receiving /cmd_vel';
+ } else {
+ dot.className = 'card-dot';
+ statusEl.className = 'card-status';
+ statusEl.textContent = 'READY';
+ if (msgEl) msgEl.textContent = 'Awaiting input';
+ }
+ return;
+ }
+
+ if (!ts) {
+ dot.className = 'card-dot idle';
+ statusEl.className = 'card-status idle';
+ statusEl.textContent = 'IDLE';
+ if (msgEl) msgEl.textContent = 'No messages yet';
+ return;
+ }
+
+ const age = Date.now() - ts;
+ if (age < LIVE_TIMEOUT_MS) {
+ dot.className = 'card-dot live';
+ statusEl.className = 'card-status live';
+ statusEl.textContent = 'LIVE';
+ if (msgEl) msgEl.textContent = `${(age / 1000).toFixed(1)}s ago`;
+ } else {
+ dot.className = 'card-dot idle';
+ statusEl.className = 'card-status idle';
+ statusEl.textContent = 'IDLE';
+ if (msgEl) msgEl.textContent = `${Math.floor(age / 1000)}s ago`;
+ }
+}
+
+// ── Render helpers ─────────────────────────────────────────────────────────
+function renderBattery() {
+ const el = document.getElementById('val-battery');
+ const fill = document.getElementById('batt-fill');
+ if (state.battery === null) { el.textContent = '—'; return; }
+ const pct = state.battery.toFixed(0);
+ el.textContent = `${pct}%${state.voltage ? ' · ' + state.voltage.toFixed(1) + 'V' : ''}`;
+ fill.style.width = pct + '%';
+ fill.style.background = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444';
+ el.style.color = pct > 50 ? '#d1d5db' : pct > 20 ? '#f59e0b' : '#ef4444';
+}
+
+function renderSafety() {
+ const el = document.getElementById('val-safety');
+ if (!state.safetyState) { el.textContent = '—'; return; }
+ const s = state.safetyState.toUpperCase();
+ el.textContent = s + (state.closestM != null ? ` · ${state.closestM.toFixed(2)}m` : '');
+ el.style.color = s === 'DANGER' || s === 'ESTOP' ? '#ef4444' :
+ s === 'WARN' ? '#f59e0b' : '#22c55e';
+}
+
+function renderMode() {
+ document.getElementById('val-mode').textContent = state.driveMode;
+ document.querySelectorAll('.mode-btn').forEach(b => {
+ b.classList.toggle('active', b.dataset.mode === state.driveMode);
+ });
+}
+
+function renderUptime() {
+ const el = document.getElementById('val-uptime');
+ if (!state.connected) { el.textContent = '—'; return; }
+ const s = state.uptime;
+ el.textContent = s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m ${s%60}s` : `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`;
+}
+
+function renderSession() {
+ if (!state.sessionStart) return;
+ const secs = Math.floor((Date.now() - state.sessionStart) / 1000);
+ const h = String(Math.floor(secs / 3600)).padStart(2, '0');
+ const m = String(Math.floor((secs % 3600) / 60)).padStart(2, '0');
+ const s = String(secs % 60).padStart(2, '0');
+ document.getElementById('session-time').textContent = `${h}:${m}:${s}`;
+}
+
+function renderMsgRate() {
+ const now = performance.now();
+ if (state.lastMsgTs) {
+ const dt = (now - state.lastMsgTs) / 1000;
+ document.getElementById('info-rate').textContent =
+ dt < 10 ? Math.round(1 / dt) + ' msg/s' : '—';
+ }
+}
+
+// ── Latency ping ───────────────────────────────────────────────────────────
+function schedulePing() {
+ if (pingTimeout) clearTimeout(pingTimeout);
+ pingTimeout = setTimeout(() => {
+ if (!ros || !state.connected) return;
+ const svc = new ROSLIB.Service({ ros, name: '/rosapi/get_time', serviceType: 'rosapi/GetTime' });
+ pingTs = performance.now();
+ svc.callService({}, () => {
+ state.latency = Math.round(performance.now() - pingTs);
+ document.getElementById('info-latency').textContent = state.latency + ' ms';
+ document.getElementById('info-ip').textContent = $wsInput.value.replace('ws://','').split(':')[0];
+ schedulePing();
+ }, () => { schedulePing(); });
+ }, 5000);
+}
+
+// ── Auto-detect ────────────────────────────────────────────────────────────
+const $detectStatus = document.getElementById('detect-status');
+const $detectDots = document.getElementById('detect-dots');
+const $detectBar = document.getElementById('detect-bar');
+
+function updateDetectBar(cls, msg) {
+ $detectBar.className = cls;
+ $detectStatus.textContent = msg;
+}
+
+function buildDetectDots() {
+ $detectDots.innerHTML = AUTO_CANDIDATES.map((_, i) =>
+ `
`
+ ).join('');
+}
+
+function tryAutoDetect(idx) {
+ if (idx >= AUTO_CANDIDATES.length) {
+ updateDetectBar('error', '✗ Auto-detect failed — enter URL manually');
+ return;
+ }
+ const url = AUTO_CANDIDATES[idx];
+ const dot = document.getElementById(`ddot-${idx}`);
+ if (dot) dot.className = 'detect-dot trying';
+ updateDetectBar('', `🔍 Trying ${url}…`);
+
+ const testRos = new ROSLIB.Ros({ url });
+ const timer = setTimeout(() => {
+ testRos.close();
+ if (dot) dot.className = 'detect-dot fail';
+ tryAutoDetect(idx + 1);
+ }, 2000);
+
+ testRos.on('connection', () => {
+ clearTimeout(timer);
+ if (dot) dot.className = 'detect-dot ok';
+ // Mark remaining as skipped
+ for (let j = idx + 1; j < AUTO_CANDIDATES.length; j++) {
+ const d = document.getElementById(`ddot-${j}`);
+ if (d) d.className = 'detect-dot';
+ }
+ testRos.close();
+ // Connect for real
+ connect(url);
+ });
+
+ testRos.on('error', () => {
+ clearTimeout(timer);
+ if (dot) dot.className = 'detect-dot fail';
+ tryAutoDetect(idx + 1);
+ });
+}
+
+// ── Connection bar ─────────────────────────────────────────────────────────
+const $connDot = document.getElementById('conn-dot');
+const $connLabel = document.getElementById('conn-label');
+const $wsInput = document.getElementById('ws-input');
+
+document.getElementById('btn-connect').addEventListener('click', () => {
+ connect($wsInput.value.trim() || 'ws://localhost:9090');
+});
+
+// ── Liveness refresh loop ──────────────────────────────────────────────────
+setInterval(() => {
+ PANELS.forEach(p => renderPanelCard(p.id));
+ renderMsgRate();
+ renderSession();
+}, 1000);
+
+// ── Session timer ──────────────────────────────────────────────────────────
+state.sessionStart = new Date();
+setInterval(renderSession, 1000);
+
+// ── Init ───────────────────────────────────────────────────────────────────
+buildDetectDots();
+
+// Restore saved URL or auto-detect
+const savedUrl = localStorage.getItem('dashboard_ws_url');
+if (savedUrl) {
+ $wsInput.value = savedUrl;
+ updateDetectBar('', `⟳ Reconnecting to ${savedUrl}…`);
+ connect(savedUrl);
+} else {
+ tryAutoDetect(0);
+}
diff --git a/ui/index.html b/ui/index.html
index a9c1998..67a30b1 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -2,1175 +2,269 @@
-
-SaltyLab HUD
-
-
+
+Saltybot — Dashboard
+
+
-
+
-
-
-
-
⚡ SALTYLAB
-
DISARMED
+
+
+
-
WebSerial ready
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Not connected
-
-
+
+
-
-
+
+
+
🔍 Auto-detecting rosbridge…
+
+
-
ATTITUDE
-
-
+
+
-
-
-
-
HEADING
-
-
-
- MAG
- --
- °
-
-
-
MOTOR
-
-
-
BATTERY
-
-
-
-
-
ENVIRONMENT
-
-
-
-
-
+
+
+
+
+ ROSBRIDGE
+ —
-
-
PKT/s: --
-
-
-
-
-
-
-
-
-
-
+
+ MSG RATE
+ —
-
-
-
-
-
-
-
-
-
- Fetching GPS data from /gps...
-
-
-
- --
- --
- --
-
+
+ LATENCY
+ —
-
-
-
-
-
- Ready
- Points: --
- Range: --m
-
-
-
-
-
-
-
- Ready
- FPS: --
- -- px
-
+
+ ROBOT IP
+ —
-
-
+
-
-
+
+
-
-
+
+
+
+
-
-
-
RC / CRSF-ELRS
-
-
-
-
RC STICKS (µs)
-
-
-
- CH1·CH2
-
-
-
- CH3·CH4
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- KP
-
- 0.0
-
-
- KI
-
- 0.000
-
-
- KD
-
- 0.000
-
-
- SP°
-
- 0.0°
-
-
- MAX
-
- 200
-
-
-
-
-
-
-
+
+
DRIVE MODE
+
+
+
+
+
-
-
-
+
+
+ SESSION
+ 00:00:00
+
+
issue #630
-
-
-
+