/* 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 = ` ${p.label} ${p.desc || ''} `; 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 = ` ${p.label} ${p.unit || ''}`; 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 = ''; 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 = '