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>
717 lines
29 KiB
JavaScript
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();
|
|
}
|