/**
* 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' && (
)}
);
}
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])=>(
))}
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 && (
)}
{msg && (
{msg.text}
)}
Settings stored in localStorage key saltybot_settings_v1. Export to persist across browsers.
);
}
export function SettingsPanel({ subscribe, callService, connected = false, wsUrl = '' }) {
const [view, setView] = useState('PID');
const settings = useSettings({ callService, subscribe });
return (
{VIEWS.map(v => (
))}
{view==='PID' &&
}
{view==='Sensors' &&
}
{view==='Network' &&
}
{view==='Firmware' &&
}
{view==='Diagnostics' &&
}
{view==='Backup' &&
}
);
}