/** * SettingsPanel.jsx — System configuration dashboard. * * Sub-views: PID | Sensors | Network | Firmware | Diagnostics | Backup * * Props: * subscribe, callService — from useRosbridge * connected — rosbridge connection status * wsUrl — current rosbridge WebSocket URL */ import { useState, useEffect, useRef, useCallback } from 'react'; import { useSettings, PID_PARAMS, SENSOR_PARAMS, PID_NODE, simulateStepResponse, validatePID, } from '../hooks/useSettings.js'; const VIEWS = ['PID', 'Sensors', 'Network', 'Firmware', 'Diagnostics', 'Backup']; function ValidationBadges({ warnings }) { if (!warnings?.length) return (
All gains within safe bounds
); return (
{warnings.map((w, i) => (
{w.level === 'error' ? '✕' : '⚠'} {w.msg}
))}
); } function ApplyResult({ result }) { if (!result) return null; return (
{result.ok ? '✓ ' : '✕ '}{result.msg}
); } function StepResponseCanvas({ kp, ki, kd }) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; const PAD = { top: 12, right: 16, bottom: 24, left: 40 }; const CW = W - PAD.left - PAD.right; const CH = H - PAD.top - PAD.bottom; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#050510'; ctx.fillRect(0, 0, W, H); const data = simulateStepResponse(kp, ki, kd); if (!data.length) return; const tail = data.slice(-20); const maxTail = Math.max(...tail.map(d => Math.abs(d.theta))); const finalMax = Math.max(...data.map(d => Math.abs(d.theta))); const isUnstable = finalMax > 45; const isOscillating = !isUnstable && maxTail > 1.0; const lineColor = isUnstable ? '#ef4444' : isOscillating ? '#f59e0b' : '#22c55e'; const yMax = Math.min(90, Math.max(10, finalMax * 1.2)); const yMin = -Math.min(5, yMax * 0.1); const tx = (t) => PAD.left + (t / 2.4) * CW; const ty = (v) => PAD.top + CH - ((v - yMin) / (yMax - yMin)) * CH; ctx.strokeStyle = '#0d1b2a'; ctx.lineWidth = 1; for (let v = -10; v <= 90; v += 5) { if (v < yMin || v > yMax) continue; ctx.beginPath(); ctx.moveTo(PAD.left, ty(v)); ctx.lineTo(PAD.left + CW, ty(v)); ctx.stroke(); } for (let t = 0; t <= 2.4; t += 0.4) { ctx.beginPath(); ctx.moveTo(tx(t), PAD.top); ctx.lineTo(tx(t), PAD.top + CH); ctx.stroke(); } ctx.strokeStyle = lineColor; ctx.lineWidth = 2; ctx.shadowBlur = isUnstable ? 0 : 4; ctx.shadowColor = lineColor; ctx.beginPath(); data.forEach((d, i) => { const x = tx(d.t), y = ty(Math.max(yMin, Math.min(yMax, d.theta))); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke(); ctx.shadowBlur = 0; ctx.fillStyle = '#4b5563'; ctx.font = '8px monospace'; ctx.textAlign = 'right'; [0, 5, 10, 20, 45, 90].filter(v => v >= yMin && v <= yMax).forEach(v => { ctx.fillText(`${v}°`, PAD.left - 3, ty(v) + 3); }); ctx.textAlign = 'center'; [0, 0.5, 1.0, 1.5, 2.0, 2.4].forEach(t => { ctx.fillText(`${t.toFixed(1)}`, tx(t), PAD.top + CH + 14); }); const label = isUnstable ? 'UNSTABLE' : isOscillating ? 'OSCILLATING' : 'STABLE'; ctx.fillStyle = lineColor; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'right'; ctx.fillText(label, PAD.left + CW, PAD.top + 10); }, [kp, ki, kd]); return ( ); } function GainSlider({ param, value, onChange }) { const pct = ((value - param.min) / (param.max - param.min)) * 100; return (
{param.label} onChange(param.key, param.type === 'bool' ? e.target.checked : parseFloat(e.target.value))} />
{param.type !== 'bool' && (
onChange(param.key, parseFloat(e.target.value))} />
)}
); } function PIDView({ gains, setGains, applyPIDGains, applying, applyResult, connected }) { const [activeClass, setActiveClass] = useState('empty'); const warnings = validatePID(gains); const classKeys = { empty: ['kp_empty','ki_empty','kd_empty'], light: ['kp_light','ki_light','kd_light'], heavy: ['kp_heavy','ki_heavy','kd_heavy'], }; const overrideKeys = ['override_enabled','override_kp','override_ki','override_kd']; const clampKeys = ['kp_min','kp_max','ki_min','ki_max','kd_min','kd_max']; const miscKeys = ['control_rate','balance_setpoint_rad']; const handleChange = (key, val) => setGains(g => ({ ...g, [key]: val })); const previewKp = gains.override_enabled ? gains.override_kp : gains[`kp_${activeClass}`] ?? 15; const previewKi = gains.override_enabled ? gains.override_ki : gains[`ki_${activeClass}`] ?? 0.5; const previewKd = gains.override_enabled ? gains.override_kd : gains[`kd_${activeClass}`] ?? 1.5; const paramsByKey = Object.fromEntries(PID_PARAMS.map(p => [p.key, p])); return (
PID GAIN EDITOR
{connected ? 'LIVE' : 'OFFLINE'}
Simulated step response ({gains.override_enabled ? 'override' : activeClass} gains · 5° disturbance)
{Object.keys(classKeys).map(c => ( ))} {[['override','border-amber-500 text-amber-300'],['clamp','border-purple-500 text-purple-300'],['misc','border-gray-400 text-gray-200']].map(([id, activeStyle]) => ( ))}
{(activeClass==='override'?overrideKeys:activeClass==='clamp'?clampKeys:activeClass==='misc'?miscKeys:classKeys[activeClass]).map(key => { const p = paramsByKey[key]; if (!p) return null; return ; })}
{warnings.some(w=>w.level==='error')&&Fix errors before applying}
); } function SensorsView({ sensors, setSensors, applySensorParams, applying, applyResult, connected }) { const handleChange = (key, val) => setSensors(s => ({ ...s, [key]: val })); const grouped = {}; SENSOR_PARAMS.forEach(p => { if (!grouped[p.node]) grouped[p.node] = []; grouped[p.node].push(p); }); return (
SENSOR CONFIGURATION
{Object.entries(grouped).map(([node, params]) => (
{node}
{params.map(p => p.type === 'bool' ? ( ) : ( ))}
))}
); } function NetworkView({ wsUrl, connected }) { const [ddsDomain, setDdsDomain] = useState(() => parseInt(localStorage.getItem('saltybot_dds_domain')||'0',10)); const [saved, setSaved] = useState(false); const urlObj = (() => { try { return new URL(wsUrl); } catch { return null; } })(); const saveDDS = () => { localStorage.setItem('saltybot_dds_domain', String(ddsDomain)); setSaved(true); setTimeout(()=>setSaved(false),2000); }; return (
NETWORK SETTINGS
ROSBRIDGE WEBSOCKET
{[['URL', wsUrl, 'text-cyan-300 font-mono truncate'], ['Host', urlObj?.hostname??'—', 'text-gray-300 font-mono'], ['Port', urlObj?.port??'9090', 'text-gray-300 font-mono'], ['Status', connected?'CONNECTED':'DISCONNECTED', connected?'text-green-400':'text-red-400'] ].map(([k, v, cls]) => (<>
{k}
{v}
))}
ROS2 DDS DOMAIN
setDdsDomain(parseInt(e.target.value)||0)} /> {saved && Saved}
Set ROS_DOMAIN_ID on the robot to match. Range 0–232.
REFERENCE PORTS
Web dashboard: 8080
Rosbridge: ws://<host>:9090
RTSP: rtsp://<host>:8554/panoramic
MJPEG: http://<host>:8080/stream?topic=/camera/panoramic/compressed
); } function FirmwareView({ firmwareInfo, startFirmwareWatch, connected }) { useEffect(() => { if (!connected) return; const unsub = startFirmwareWatch(); return unsub; }, [connected, startFirmwareWatch]); const formatUptime = (s) => { if (!s) return '—'; return `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`; }; const rows = [ ['STM32 Firmware', firmwareInfo?.stm32Version ?? '—'], ['Jetson SW', firmwareInfo?.jetsonVersion ?? '—'], ['Last OTA Update',firmwareInfo?.lastOtaDate ?? '—'], ['Hostname', firmwareInfo?.hostname ?? '—'], ['ROS Distro', firmwareInfo?.rosDistro ?? '—'], ['Uptime', formatUptime(firmwareInfo?.uptimeS)], ]; return (
FIRMWARE INFO
{!connected && (connect to robot to fetch live info)}
{rows.map(([label, value], i) => (
{label} {value}
))}
{!firmwareInfo && connected && (
Waiting for /diagnostics… Ensure firmware diagnostics keys (stm32_fw_version etc.) are published.
)}
); } function DiagnosticsView({ exportDiagnosticsBundle, subscribe, connected }) { const [diagData, setDiagData] = useState(null); const [balanceData, setBalanceData] = useState(null); const [exporting, setExporting] = useState(false); useEffect(() => { if (!connected || !subscribe) return; const u1 = subscribe('/diagnostics', 'diagnostic_msgs/DiagnosticArray', msg => setDiagData(msg)); const u2 = subscribe('/saltybot/balance_state', 'std_msgs/String', msg => { try { setBalanceData(JSON.parse(msg.data)); } catch {} }); return () => { u1?.(); u2?.(); }; }, [connected, subscribe]); const errorCount = (diagData?.status??[]).filter(s=>s.level>=2).length; const warnCount = (diagData?.status??[]).filter(s=>s.level===1).length; const handleExport = () => { setExporting(true); exportDiagnosticsBundle(balanceData?{'live':{balanceState:balanceData}}:{}, {}); setTimeout(()=>setExporting(false), 1000); }; return (
DIAGNOSTICS EXPORT
{[['ERRORS',errorCount,errorCount?'text-red-400':'text-gray-600'],['WARNINGS',warnCount,warnCount?'text-amber-400':'text-gray-600'],['NODES',diagData?.status?.length??0,'text-gray-400']].map(([l,c,cl])=>(
{c}
{l}
))}
Download Diagnostics Bundle
Bundle: PID gains, sensor settings, firmware info, live /diagnostics, balance state, timestamp.
For rosbag: ros2 bag record -o saltybot_diag /diagnostics /saltybot/balance_state /odom
{diagData?.status?.length>0 && (
{[...diagData.status].sort((a,b)=>(b.level??0)-(a.level??0)).map((s,i)=>(
=2?'bg-red-950 border-red-800':s.level===1?'bg-amber-950 border-amber-800':'bg-gray-950 border-gray-800'}`}>
=2?'bg-red-400':s.level===1?'bg-amber-400':'bg-green-400'}`}/> {s.name} =2?'text-red-400':s.level===1?'text-amber-400':'text-gray-600'}>{s.message||(s.level===0?'OK':`L${s.level}`)}
))}
)}
); } function BackupView({ exportSettingsJSON, importSettingsJSON }) { const [importText, setImportText] = useState(''); const [showImport, setShowImport] = useState(false); const [msg, setMsg] = useState(null); const showMsg = (text, ok=true) => { setMsg({text,ok}); setTimeout(()=>setMsg(null),4000); }; const handleExport = () => { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([exportSettingsJSON()],{type:'application/json'})); a.download = `saltybot-settings-${new Date().toISOString().slice(0,10)}.json`; a.click(); showMsg('Settings exported'); }; const handleImport = () => { try { importSettingsJSON(importText); setImportText(''); setShowImport(false); showMsg('Settings imported'); } catch(e) { showMsg('Import failed: '+e.message, false); } }; return (
BACKUP & RESTORE
Export Settings
Saves PID gains, sensor config and UI preferences to JSON.
Restore Settings
Load a previously exported settings JSON. Click Apply after import to push to the robot.
{showImport && (