saltylab-firmware/ui/settings_panel.js
sl-webui 921eaba8b3 feat: WebUI settings and configuration panel (Issue #614)
Standalone ui/settings_panel.{html,js,css} — no build step.

Sections / tabs:
- PID: balance_controller (Kp/Ki/Kd/i_clamp/rate),
  adaptive_pid (kp/ki/kd per load profile, output bounds)
- Speed: tank_driver (max_linear_vel, max_angular_vel, slip_factor),
  smooth_velocity_controller (accel/decel limits),
  battery_speed_limiter (speed factors)
- Safety: safety_zone (danger_range_m, warn_range_m, forward_arc_deg,
  debounce, min_valid_range, publish_rate),
  power_supervisor_node (battery % thresholds, speed factors),
  lidar_avoidance (e-stop distance, safety zone sizes)
- Sensors: boolean toggles (estop_all_arcs, lidar_enabled, uwb_enabled),
  uwb_imu_fusion weights and publish rate
- System: live /diagnostics subscriber (CPU/GPU/board/motor temps,
  RAM/GPU/disk usage, WiFi RSSI+latency, MQTT status, last-update),
  /rosapi/nodes node list

ROS2 parameter services (rcl_interfaces/srv/GetParameters +
SetParameters) via rosbridge WebSocket. Each section has independent
↓ LOAD (get_parameters) and ↑ APPLY (set_parameters) buttons with
success/error status feedback.

Presets: save/load/delete named snapshots of all values to
localStorage. Reset-to-defaults button restores built-in defaults.
Changed fields highlighted in amber (slider thumb + input border).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 10:08:47 -04:00

717 lines
29 KiB
JavaScript

/* settings_panel.js — Saltybot Settings Panel (Issue #614) */
'use strict';
// ── ROS2 parameter type constants ──────────────────────────────────────────
const P_BOOL = 1;
const P_INT = 2;
const P_DOUBLE = 3;
// ── Section / parameter definitions ───────────────────────────────────────
// Each section: { id, node, params: [{name, label, type, min, max, step, unit, def}] }
const SECTIONS = {
balance_pid: {
node: 'balance_controller',
params: [
{ name: 'pid_p', label: 'Proportional (Kp)', type: P_DOUBLE, min: 0, max: 5, step: 0.01, unit: '', def: 0.5 },
{ name: 'pid_i', label: 'Integral (Ki)', type: P_DOUBLE, min: 0, max: 2, step: 0.005, unit: '', def: 0.1 },
{ name: 'pid_d', label: 'Derivative (Kd)', type: P_DOUBLE, min: 0, max: 1, step: 0.005, unit: '', def: 0.05 },
{ name: 'i_clamp',label:'I clamp', type: P_DOUBLE, min: 0, max: 50, step: 0.5, unit: '', def: 10.0 },
{ name: 'frequency',label:'Control rate', type: P_INT, min: 10, max: 200, step: 10, unit: 'Hz', def: 50 },
],
},
adaptive_pid_empty: {
node: 'adaptive_pid',
params: [
{ name: 'kp_empty', label: 'Kp (empty load)', type: P_DOUBLE, min: 0, max: 50, step: 0.5, unit: '', def: 15.0 },
{ name: 'ki_empty', label: 'Ki (empty load)', type: P_DOUBLE, min: 0, max: 5, step: 0.05, unit: '', def: 0.5 },
{ name: 'kd_empty', label: 'Kd (empty load)', type: P_DOUBLE, min: 0, max: 10, step: 0.1, unit: '', def: 1.5 },
{ name: 'kp_light', label: 'Kp (light load)', type: P_DOUBLE, min: 0, max: 50, step: 0.5, unit: '', def: 18.0 },
{ name: 'ki_light', label: 'Ki (light load)', type: P_DOUBLE, min: 0, max: 5, step: 0.05, unit: '', def: 0.6 },
{ name: 'kd_light', label: 'Kd (light load)', type: P_DOUBLE, min: 0, max: 10, step: 0.1, unit: '', def: 2.0 },
{ name: 'kp_heavy', label: 'Kp (heavy load)', type: P_DOUBLE, min: 0, max: 50, step: 0.5, unit: '', def: 22.0 },
{ name: 'ki_heavy', label: 'Ki (heavy load)', type: P_DOUBLE, min: 0, max: 5, step: 0.05, unit: '', def: 0.8 },
{ name: 'kd_heavy', label: 'Kd (heavy load)', type: P_DOUBLE, min: 0, max: 10, step: 0.1, unit: '', def: 2.5 },
],
},
adaptive_pid_bounds: {
node: 'adaptive_pid',
params: [
{ name: 'kp_min', label: 'Kp min', type: P_DOUBLE, min: 0, max: 20, step: 0.5, unit: '', def: 5.0 },
{ name: 'kp_max', label: 'Kp max', type: P_DOUBLE, min: 0, max: 80, step: 1, unit: '', def: 40.0 },
{ name: 'ki_min', label: 'Ki min', type: P_DOUBLE, min: 0, max: 5, step: 0.05, unit: '', def: 0.0 },
{ name: 'ki_max', label: 'Ki max', type: P_DOUBLE, min: 0, max: 10, step: 0.1, unit: '', def: 5.0 },
{ name: 'kd_min', label: 'Kd min', type: P_DOUBLE, min: 0, max: 5, step: 0.05, unit: '', def: 0.0 },
{ name: 'kd_max', label: 'Kd max', type: P_DOUBLE, min: 0, max: 20, step: 0.2, unit: '', def: 10.0 },
{ name: 'output_min', label: 'Output min', type: P_DOUBLE, min: -100, max: 0, step: 1, unit: '', def: -50.0},
{ name: 'output_max', label: 'Output max', type: P_DOUBLE, min: 0, max: 100, step: 1, unit: '', def: 50.0 },
],
},
tank_limits: {
node: 'tank_driver',
params: [
{ name: 'max_linear_vel', label: 'Max linear vel', type: P_DOUBLE, min: 0.1, max: 3.0, step: 0.05, unit: 'm/s', def: 1.0 },
{ name: 'max_angular_vel', label: 'Max angular vel', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.1, unit: 'rad/s', def: 2.5 },
{ name: 'max_speed_ms', label: 'Max drive speed', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.1, unit: 'm/s', def: 1.5 },
{ name: 'slip_factor', label: 'Track slip factor',type: P_DOUBLE,min: 0, max: 0.5, step: 0.01, unit: '', def: 0.3 },
{ name: 'watchdog_timeout_s',label:'Watchdog timeout',type:P_DOUBLE, min: 0.1, max: 2.0, step: 0.05, unit: 's', def: 0.3 },
],
},
smooth_vel: {
node: 'smooth_velocity_controller',
params: [
{ name: 'max_linear_accel', label: 'Max linear accel', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.05, unit: 'm/s²', def: 0.5 },
{ name: 'max_linear_decel', label: 'Max linear decel', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.05, unit: 'm/s²', def: 0.8 },
{ name: 'max_angular_accel', label: 'Max angular accel', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.1, unit: 'rad/s²', def: 1.0 },
{ name: 'max_angular_decel', label: 'Max angular decel', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.1, unit: 'rad/s²', def: 1.0 },
],
},
batt_speed: {
node: 'battery_speed_limiter',
params: [
{ name: 'speed_factor_full', label: 'Speed factor (full)', type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 1.0 },
{ name: 'speed_factor_reduced', label: 'Speed factor (reduced)', type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.7 },
{ name: 'speed_factor_critical', label: 'Speed factor (critical)', type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.4 },
],
},
safety_zone: {
node: 'safety_zone',
params: [
{ name: 'danger_range_m', label: 'Danger range', type: P_DOUBLE, min: 0.05, max: 1.0, step: 0.01, unit: 'm', def: 0.30 },
{ name: 'warn_range_m', label: 'Warn range', type: P_DOUBLE, min: 0.2, max: 5.0, step: 0.05, unit: 'm', def: 1.00 },
{ name: 'forward_arc_deg', label: 'Forward arc (±)', type: P_DOUBLE, min: 10, max: 180, step: 5, unit: '°', def: 60.0 },
{ name: 'estop_debounce_frames',label: 'E-stop debounce', type: P_INT, min: 1, max: 20, step: 1, unit: 'frames', def: 2 },
{ name: 'min_valid_range_m', label: 'Min valid range', type: P_DOUBLE, min: 0.01, max: 0.5, step: 0.01, unit: 'm', def: 0.05 },
{ name: 'publish_rate', label: 'Publish rate', type: P_DOUBLE, min: 1, max: 50, step: 1, unit: 'Hz', def: 10.0 },
],
},
power_sup: {
node: 'power_supervisor_node',
params: [
{ name: 'warning_pct', label: 'Warning threshold', type: P_DOUBLE, min: 5, max: 60, step: 1, unit: '%', def: 30.0 },
{ name: 'dock_search_pct', label: 'Dock-search threshold',type: P_DOUBLE, min: 5, max: 50, step: 1, unit: '%', def: 20.0 },
{ name: 'critical_pct', label: 'Critical threshold', type: P_DOUBLE, min: 2, max: 30, step: 1, unit: '%', def: 10.0 },
{ name: 'emergency_pct', label: 'Emergency threshold', type: P_DOUBLE, min: 1, max: 15, step: 1, unit: '%', def: 5.0 },
{ name: 'warn_speed_factor', label: 'Speed factor (warn)',type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.6 },
{ name: 'critical_speed_factor',label:'Speed factor (crit)',type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.3 },
],
},
lidar_avoid: {
node: 'lidar_avoidance',
params: [
{ name: 'emergency_stop_distance', label: 'E-stop distance', type: P_DOUBLE, min: 0.1, max: 3.0, step: 0.05, unit: 'm', def: 0.5 },
{ name: 'min_safety_zone', label: 'Min safety zone', type: P_DOUBLE, min: 0.1, max: 2.0, step: 0.05, unit: 'm', def: 0.6 },
{ name: 'safety_zone_at_max_speed',label: 'Zone at max speed', type: P_DOUBLE, min: 0.5, max: 10, step: 0.1, unit: 'm', def: 3.0 },
{ name: 'max_speed_reference', label: 'Max speed reference',type: P_DOUBLE, min: 0.5, max: 20, step: 0.1, unit: 'm/s', def: 5.56 },
],
},
sensor_toggles: {
node: 'safety_zone', // placeholder — booleans often live on their own node
params: [
{ name: 'estop_all_arcs', label: 'E-stop all arcs', type: P_BOOL, unit: '', def: false, desc: 'Any sector triggers e-stop' },
{ name: 'lidar_enabled', label: 'LIDAR enabled', type: P_BOOL, unit: '', def: true, desc: '/scan input active' },
{ name: 'uwb_enabled', label: 'UWB positioning', type: P_BOOL, unit: '', def: true, desc: 'UWB anchors active' },
],
},
imu_fusion: {
node: 'uwb_imu_fusion',
params: [
{ name: 'uwb_weight', label: 'UWB weight', type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.7 },
{ name: 'imu_weight', label: 'IMU weight', type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.3 },
{ name: 'publish_rate', label: 'Publish rate', type: P_DOUBLE, min: 1, max: 200, step: 1, unit: 'Hz', def: 50.0 },
],
},
};
// ── Runtime state: current values per section ──────────────────────────────
const values = {}; // values[sectionId][paramName] = currentValue
const dirty = {}; // dirty[sectionId][paramName] = true if changed vs loaded
// Initialise values from defaults
Object.keys(SECTIONS).forEach(sid => {
values[sid] = {};
dirty[sid] = {};
SECTIONS[sid].params.forEach(p => {
values[sid][p.name] = p.def;
dirty[sid][p.name] = false;
});
});
// ── ROS ────────────────────────────────────────────────────────────────────
let ros = null;
function getService(nodeName, type) {
return new ROSLIB.Service({ ros, name: `/${nodeName}/${type}`, serviceType: `rcl_interfaces/srv/${type === 'get_parameters' ? 'GetParameters' : 'SetParameters'}` });
}
function extractValue(rosVal) {
switch (rosVal.type) {
case P_BOOL: return rosVal.bool_value;
case P_INT: return rosVal.integer_value;
case P_DOUBLE: return rosVal.double_value;
default: return undefined;
}
}
function makeRosValue(type, value) {
const v = { type };
if (type === P_BOOL) v.bool_value = !!value;
if (type === P_INT) v.integer_value = Math.round(value);
if (type === P_DOUBLE) v.double_value = parseFloat(value);
return v;
}
// ── Section load / apply ───────────────────────────────────────────────────
window.loadSection = function(sid) {
if (!ros) { setStatus(sid, 'err', 'Not connected'); return; }
const sec = SECTIONS[sid];
const svc = getService(sec.node, 'get_parameters');
const names = sec.params.map(p => p.name);
setStatus(sid, 'loading', `Loading from /${sec.node}`);
svc.callService({ names }, (resp) => {
if (!resp || !resp.values) {
setStatus(sid, 'err', 'No response'); return;
}
sec.params.forEach((p, i) => {
const v = extractValue(resp.values[i]);
if (v !== undefined) {
values[sid][p.name] = v;
dirty[sid][p.name] = false;
updateFieldUI(sid, p.name, v, false);
}
});
setStatus(sid, 'ok', `Loaded ${resp.values.length} params`);
}, (err) => {
setStatus(sid, 'err', `Error: ${err}`);
});
};
window.applySection = function(sid) {
if (!ros) { setStatus(sid, 'err', 'Not connected'); return; }
const sec = SECTIONS[sid];
const svc = getService(sec.node, 'set_parameters');
const parameters = sec.params.map(p => ({
name: p.name,
value: makeRosValue(p.type, values[sid][p.name]),
}));
setStatus(sid, 'loading', `Applying to /${sec.node}`);
svc.callService({ parameters }, (resp) => {
if (!resp || !resp.results) {
setStatus(sid, 'err', 'No response'); return;
}
const failures = resp.results.filter(r => !r.successful);
if (failures.length === 0) {
sec.params.forEach(p => { dirty[sid][p.name] = false; });
refreshFieldDirty(sid);
setStatus(sid, 'ok', `Applied ${parameters.length} params ✓`);
} else {
const reasons = failures.map(r => r.reason).join('; ');
setStatus(sid, 'err', `${failures.length} failed: ${reasons}`);
}
}, (err) => {
setStatus(sid, 'err', `Error: ${err}`);
});
};
// ── UI builder ─────────────────────────────────────────────────────────────
function buildFields() {
Object.keys(SECTIONS).forEach(sid => {
const sec = SECTIONS[sid];
const container = document.getElementById(`${sid}-fields`);
if (!container) return;
sec.params.forEach(p => {
if (p.type === P_BOOL) {
// Toggle row
const row = document.createElement('div');
row.className = 'toggle-row';
row.innerHTML = `
<span class="param-label">${p.label}</span>
<span class="toggle-desc">${p.desc || ''}</span>
<label class="toggle-switch">
<input type="checkbox" id="tgl-${sid}-${p.name}"
${p.def ? 'checked' : ''}>
<div class="toggle-track"></div>
</label>`;
container.appendChild(row);
const cb = row.querySelector(`#tgl-${sid}-${p.name}`);
cb.addEventListener('change', () => {
values[sid][p.name] = cb.checked;
dirty[sid][p.name] = true;
});
} else {
// Slider + number input row
const row = document.createElement('div');
row.className = 'param-row';
const sliderMin = p.min !== undefined ? p.min : 0;
const sliderMax = p.max !== undefined ? p.max : 100;
const sliderStep = p.step || 0.01;
const defVal = p.def !== undefined ? p.def : 0;
row.innerHTML = `
<span class="param-label">${p.label}</span>
<input type="range" class="param-slider"
id="sld-${sid}-${p.name}"
min="${sliderMin}" max="${sliderMax}" step="${sliderStep}"
value="${defVal}">
<input type="number" class="param-input"
id="inp-${sid}-${p.name}"
min="${sliderMin}" max="${sliderMax}" step="${sliderStep}"
value="${defVal}">
<span class="param-unit">${p.unit || ''}</span>`;
container.appendChild(row);
const slider = row.querySelector(`#sld-${sid}-${p.name}`);
const input = row.querySelector(`#inp-${sid}-${p.name}`);
slider.addEventListener('input', () => {
const v = parseFloat(slider.value);
input.value = v;
values[sid][p.name] = v;
dirty[sid][p.name] = true;
input.classList.add('changed');
slider.classList.add('changed');
});
input.addEventListener('change', () => {
let v = parseFloat(input.value);
v = Math.max(sliderMin, Math.min(sliderMax, v));
input.value = v;
slider.value = v;
values[sid][p.name] = v;
dirty[sid][p.name] = true;
input.classList.add('changed');
slider.classList.add('changed');
});
}
});
});
}
function updateFieldUI(sid, paramName, value, markDirty) {
const sec = SECTIONS[sid];
const p = sec.params.find(x => x.name === paramName);
if (!p) return;
if (p.type === P_BOOL) {
const cb = document.getElementById(`tgl-${sid}-${paramName}`);
if (cb) cb.checked = !!value;
} else {
const sld = document.getElementById(`sld-${sid}-${paramName}`);
const inp = document.getElementById(`inp-${sid}-${paramName}`);
if (sld) sld.value = value;
if (inp) inp.value = value;
if (!markDirty) {
if (sld) sld.classList.remove('changed');
if (inp) inp.classList.remove('changed');
}
}
}
function refreshFieldDirty(sid) {
const sec = SECTIONS[sid];
sec.params.forEach(p => {
if (p.type !== P_BOOL) {
const sld = document.getElementById(`sld-${sid}-${p.name}`);
const inp = document.getElementById(`inp-${sid}-${p.name}`);
if (!dirty[sid][p.name]) {
if (sld) sld.classList.remove('changed');
if (inp) inp.classList.remove('changed');
}
}
});
}
function setStatus(sid, cls, msg) {
const el = document.getElementById(`${sid}-status`);
if (!el) return;
el.className = `sec-status ${cls}`;
el.textContent = msg;
if (cls === 'ok') setTimeout(() => { el.textContent = ''; el.className = 'sec-status'; }, 4000);
}
// ── Tabs ───────────────────────────────────────────────────────────────────
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(`tab-${btn.dataset.tab}`).classList.add('active');
if (btn.dataset.tab === 'system') startDiagRefresh();
});
});
// ── Presets ────────────────────────────────────────────────────────────────
const PRESET_KEY = 'saltybot_settings_presets';
function getPresets() {
try { return JSON.parse(localStorage.getItem(PRESET_KEY) || '{}'); } catch(_) { return {}; }
}
function savePresetsToStorage(presets) {
localStorage.setItem(PRESET_KEY, JSON.stringify(presets));
}
function refreshPresetSelect() {
const sel = document.getElementById('preset-select');
const cur = sel.value;
sel.innerHTML = '<option value="">— select —</option>';
const presets = getPresets();
Object.keys(presets).sort().forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
sel.appendChild(opt);
});
if (cur) sel.value = cur;
}
function snapshotAllValues() {
const snap = {};
Object.keys(values).forEach(sid => {
snap[sid] = Object.assign({}, values[sid]);
});
return snap;
}
window.savePreset = function() {
const nameInput = document.getElementById('preset-name');
const name = nameInput.value.trim();
if (!name) { alert('Enter a preset name'); return; }
const presets = getPresets();
presets[name] = snapshotAllValues();
savePresetsToStorage(presets);
nameInput.value = '';
refreshPresetSelect();
document.getElementById('preset-select').value = name;
flashSaved();
};
window.loadPreset = function() {
const name = document.getElementById('preset-select').value;
if (!name) return;
const presets = getPresets();
const snap = presets[name];
if (!snap) return;
Object.keys(snap).forEach(sid => {
if (!values[sid]) return;
Object.keys(snap[sid]).forEach(paramName => {
values[sid][paramName] = snap[sid][paramName];
dirty[sid][paramName] = true;
updateFieldUI(sid, paramName, snap[sid][paramName], true);
});
});
flashSaved();
};
window.deletePreset = function() {
const name = document.getElementById('preset-select').value;
if (!name) return;
if (!confirm(`Delete preset "${name}"?`)) return;
const presets = getPresets();
delete presets[name];
savePresetsToStorage(presets);
refreshPresetSelect();
};
window.resetAllToDefaults = function() {
if (!confirm('Reset all fields to built-in defaults?')) return;
Object.keys(SECTIONS).forEach(sid => {
SECTIONS[sid].params.forEach(p => {
values[sid][p.name] = p.def;
dirty[sid][p.name] = false;
updateFieldUI(sid, p.name, p.def, false);
});
});
};
function flashSaved() {
const el = document.getElementById('save-indicator');
el.classList.remove('hidden');
el.style.animation = 'none';
void el.offsetHeight;
el.style.animation = 'fadeout 2s forwards';
setTimeout(() => el.classList.add('hidden'), 2100);
}
// ── System tab: diagnostics ────────────────────────────────────────────────
let diagTopic = null;
let diagRefreshTimer = null;
let diagState = {
cpuTemp: null, gpuTemp: null, boardTemp: null,
motorTempL: null, motorTempR: null,
ramUsed: null, ramTotal: null,
gpuUsed: null, gpuTotal: null,
diskUsed: null, diskTotal: null,
rssi: null, latency: null, mqttConnected: null,
nodes: [],
lastUpdate: null,
};
function startDiagRefresh() {
if (!ros || diagTopic) return;
diagTopic = new ROSLIB.Topic({
ros, name: '/diagnostics', messageType: 'diagnostic_msgs/DiagnosticArray',
throttle_rate: 2000,
});
diagTopic.subscribe(onDiagnostics);
}
function stopDiagRefresh() {
if (diagTopic) { diagTopic.unsubscribe(); diagTopic = null; }
}
function onDiagnostics(msg) {
const kv = {};
(msg.status || []).forEach(status => {
(status.values || []).forEach(pair => {
kv[pair.key] = pair.value;
});
diagState.nodes.push({ name: status.name, level: status.level, msg: status.message });
if (diagState.nodes.length > 40) diagState.nodes.splice(0, diagState.nodes.length - 40);
});
// Extract known keys
if (kv.cpu_temp_c) diagState.cpuTemp = parseFloat(kv.cpu_temp_c);
if (kv.gpu_temp_c) diagState.gpuTemp = parseFloat(kv.gpu_temp_c);
if (kv.board_temp_c) diagState.boardTemp = parseFloat(kv.board_temp_c);
if (kv.motor_temp_l_c) diagState.motorTempL = parseFloat(kv.motor_temp_l_c);
if (kv.motor_temp_r_c) diagState.motorTempR = parseFloat(kv.motor_temp_r_c);
if (kv.ram_used_mb) diagState.ramUsed = parseFloat(kv.ram_used_mb);
if (kv.ram_total_mb) diagState.ramTotal = parseFloat(kv.ram_total_mb);
if (kv.gpu_used_mb) diagState.gpuUsed = parseFloat(kv.gpu_used_mb);
if (kv.gpu_total_mb) diagState.gpuTotal = parseFloat(kv.gpu_total_mb);
if (kv.disk_used_gb) diagState.diskUsed = parseFloat(kv.disk_used_gb);
if (kv.disk_total_gb) diagState.diskTotal = parseFloat(kv.disk_total_gb);
if (kv.wifi_rssi_dbm) diagState.rssi = parseFloat(kv.wifi_rssi_dbm);
if (kv.wifi_latency_ms) diagState.latency = parseFloat(kv.wifi_latency_ms);
if (kv.mqtt_connected !== undefined) diagState.mqttConnected = kv.mqtt_connected === 'true';
diagState.lastUpdate = new Date();
renderDiag();
renderNet();
}
window.refreshDiag = function() {
startDiagRefresh();
renderDiag();
};
function tempColor(t) {
if (t === null) return '';
if (t > 80) return 'err';
if (t > 65) return 'warn';
return 'ok';
}
function pct(used, total) {
if (!total) return '—';
return ((used / total) * 100).toFixed(0) + '%';
}
function renderDiag() {
const g = document.getElementById('diag-grid');
if (!g) return;
const d = diagState;
if (d.lastUpdate === null) {
g.innerHTML = '<div class="diag-placeholder">Waiting for /diagnostics…</div>';
return;
}
const cards = [
{
title: 'Temperature',
rows: [
{ k: 'CPU', v: d.cpuTemp !== null ? d.cpuTemp.toFixed(1) + ' °C' : '—', cls: tempColor(d.cpuTemp) },
{ k: 'GPU', v: d.gpuTemp !== null ? d.gpuTemp.toFixed(1) + ' °C' : '—', cls: tempColor(d.gpuTemp) },
{ k: 'Board',v:d.boardTemp !== null ? d.boardTemp.toFixed(1)+' °C':'—', cls:tempColor(d.boardTemp)},
{ k: 'Motor L', v: d.motorTempL !== null ? d.motorTempL.toFixed(1)+' °C':'—', cls:tempColor(d.motorTempL)},
{ k: 'Motor R', v: d.motorTempR !== null ? d.motorTempR.toFixed(1)+' °C':'—', cls:tempColor(d.motorTempR)},
],
},
{
title: 'Memory',
rows: [
{ k: 'RAM used', v: d.ramUsed !== null ? d.ramUsed.toFixed(0)+' MB': '—', cls:'' },
{ k: 'RAM total', v: d.ramTotal !== null ? d.ramTotal.toFixed(0)+' MB': '—', cls:'' },
{ k: 'RAM %', v: pct(d.ramUsed, d.ramTotal), cls: d.ramUsed/d.ramTotal > 0.85 ? 'warn' : 'ok' },
{ k: 'GPU mem', v: d.gpuUsed !== null ? d.gpuUsed.toFixed(0)+' MB': '—', cls:'' },
{ k: 'GPU %', v: pct(d.gpuUsed, d.gpuTotal), cls:'' },
],
},
{
title: 'Storage',
rows: [
{ k: 'Disk used', v: d.diskUsed !== null ? d.diskUsed.toFixed(1)+' GB': '—', cls:'' },
{ k: 'Disk total', v: d.diskTotal !== null ? d.diskTotal.toFixed(1)+' GB': '—', cls:'' },
{ k: 'Disk %', v: pct(d.diskUsed, d.diskTotal), cls: d.diskUsed/d.diskTotal > 0.9 ? 'err' : d.diskUsed/d.diskTotal > 0.75 ? 'warn' : 'ok' },
],
},
{
title: 'Updated',
rows: [
{ k: 'Last msg', v: d.lastUpdate ? d.lastUpdate.toLocaleTimeString() : '—', cls:'' },
],
},
];
g.innerHTML = cards.map(c => `
<div class="diag-card">
<div class="diag-card-title">${c.title}</div>
${c.rows.map(r => `<div class="diag-kv"><span class="diag-k">${r.k}</span><span class="diag-v ${r.cls||''}">${r.v}</span></div>`).join('')}
</div>`).join('');
}
function rssiColor(rssi) {
if (rssi === null) return '';
if (rssi > -50) return 'ok';
if (rssi > -70) return 'warn';
return 'err';
}
function rssiBarCount(rssi) {
if (rssi === null) return 0;
if (rssi > -50) return 5;
if (rssi > -60) return 4;
if (rssi > -70) return 3;
if (rssi > -80) return 2;
return 1;
}
function latencyColor(ms) {
if (ms === null) return '';
if (ms < 50) return 'ok';
if (ms < 150) return 'warn';
return 'err';
}
function renderNet() {
const g = document.getElementById('net-grid');
if (!g) return;
const d = diagState;
const bars = rssiBarCount(d.rssi);
const heights = [4, 6, 9, 12, 15];
const barsHtml = heights.map((h, i) =>
`<div class="sig-bar ${i < bars ? 'lit' : ''}" style="height:${h}px"></div>`
).join('');
g.innerHTML = `
<div class="diag-card">
<div class="diag-card-title">WiFi</div>
<div class="diag-kv">
<span class="diag-k">RSSI</span>
<span class="diag-v ${rssiColor(d.rssi)}">${d.rssi !== null ? d.rssi + ' dBm' : '—'}</span>
</div>
<div class="diag-kv">
<span class="diag-k">Signal</span>
<span class="diag-v"><div class="sig-bars">${barsHtml}</div></span>
</div>
<div class="diag-kv">
<span class="diag-k">Latency</span>
<span class="diag-v ${latencyColor(d.latency)}">${d.latency !== null ? d.latency.toFixed(0) + ' ms' : '—'}</span>
</div>
</div>
<div class="diag-card">
<div class="diag-card-title">Services</div>
<div class="diag-kv">
<span class="diag-k">MQTT</span>
<span class="diag-v ${d.mqttConnected === null ? '' : d.mqttConnected ? 'ok' : 'err'}">
${d.mqttConnected === null ? '—' : d.mqttConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
<div class="diag-kv">
<span class="diag-k">rosbridge</span>
<span class="diag-v ${ros ? 'ok' : 'err'}">${ros ? 'Connected' : 'Disconnected'}</span>
</div>
</div>`;
}
// ── Node list ──────────────────────────────────────────────────────────────
window.loadNodeList = function() {
if (!ros) return;
const svc = new ROSLIB.Service({
ros, name: '/rosapi/nodes', serviceType: 'rosapi/Nodes',
});
svc.callService({}, (resp) => {
const wrap = document.getElementById('node-list-wrap');
if (!resp || !resp.nodes) { wrap.innerHTML = '<div class="diag-placeholder">No response</div>'; return; }
const sorted = [...resp.nodes].sort();
wrap.innerHTML = sorted.map(n =>
`<span class="node-chip active-node">${n}</span>`
).join('');
}, () => {
document.getElementById('node-list-wrap').innerHTML =
'<div class="diag-placeholder">Service unavailable — ensure rosapi is running</div>';
});
};
// ── Connection ─────────────────────────────────────────────────────────────
const $connDot = document.getElementById('conn-dot');
const $connLabel = document.getElementById('conn-label');
function connect() {
const url = document.getElementById('ws-input').value.trim() || 'ws://localhost:9090';
if (ros) { stopDiagRefresh(); try { ros.close(); } catch(_){} }
$connLabel.textContent = 'Connecting…';
$connLabel.style.color = '#d97706';
$connDot.className = '';
ros = new ROSLIB.Ros({ url });
ros.on('connection', () => {
$connDot.className = 'connected';
$connLabel.style.color = '#22c55e';
$connLabel.textContent = 'Connected';
localStorage.setItem('settings_ws_url', url);
// Auto-start diagnostics if system tab visible
const sysPanel = document.getElementById('tab-system');
if (sysPanel && sysPanel.classList.contains('active')) startDiagRefresh();
});
ros.on('error', () => {
$connDot.className = 'error';
$connLabel.style.color = '#ef4444';
$connLabel.textContent = 'Error';
});
ros.on('close', () => {
$connDot.className = '';
$connLabel.style.color = '#6b7280';
$connLabel.textContent = 'Disconnected';
stopDiagRefresh();
ros = null;
});
}
document.getElementById('btn-connect').addEventListener('click', connect);
// ── Init ───────────────────────────────────────────────────────────────────
buildFields();
refreshPresetSelect();
const savedUrl = localStorage.getItem('settings_ws_url');
if (savedUrl) {
document.getElementById('ws-input').value = savedUrl;
document.getElementById('footer-ws').textContent = savedUrl;
connect();
}