Merge pull request 'feat(webui): ROS parameter editor in Settings panel (Issue #354)' (#360) from sl-webui/issue-354-settings into main

This commit is contained in:
sl-jetson 2026-03-03 15:20:48 -05:00
commit 94d12159b4

View File

@ -15,7 +15,7 @@ import {
simulateStepResponse, validatePID, simulateStepResponse, validatePID,
} from '../hooks/useSettings.js'; } from '../hooks/useSettings.js';
const VIEWS = ['PID', 'Sensors', 'Network', 'Firmware', 'Diagnostics', 'Backup']; const VIEWS = ['Parameters', 'PID', 'Sensors', 'Network', 'Firmware', 'Diagnostics', 'Backup'];
function ValidationBadges({ warnings }) { function ValidationBadges({ warnings }) {
if (!warnings?.length) return ( if (!warnings?.length) return (
@ -377,6 +377,204 @@ function DiagnosticsView({ exportDiagnosticsBundle, subscribe, connected }) {
); );
} }
function ParametersView({ callService, subscribe, connected }) {
const [params, setParams] = useState({});
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(null);
const [result, setResult] = useState(null);
const [searchFilter, setSearchFilter] = useState('');
// Fetch all ROS parameters on mount
useEffect(() => {
if (!connected || !callService) return;
setLoading(true);
// Call get_parameters service to list all params
callService('/rcl_interfaces/srv/GetParameters', {
names: [] // Empty list means get all parameters
}).then((resp) => {
if (resp.values) {
const newParams = {};
resp.names.forEach((name, i) => {
newParams[name] = resp.values[i];
});
setParams(newParams);
}
setLoading(false);
}).catch((err) => {
console.error('Failed to fetch parameters:', err);
setLoading(false);
});
}, [connected, callService]);
// Handle parameter edit
const handleParamChange = (paramName, newValue) => {
setParams(p => ({ ...p, [paramName]: newValue }));
};
// Apply parameter update
const applyParam = async (paramName, value) => {
if (!callService) return;
setUpdating(paramName);
try {
const resp = await callService('/rcl_interfaces/srv/SetParameters', {
parameters: [{
name: paramName,
value: {
type: detectParamType(value),
...(detectParamType(value) === 4 ? { integer_value: parseInt(value) } :
detectParamType(value) === 1 ? { double_value: parseFloat(value) } :
detectParamType(value) === 5 ? { bool_value: Boolean(value) } :
detectParamType(value) === 3 ? { string_value: String(value) } : {})
}
}]
});
if (resp.results?.[0]?.successful) {
setResult({ ok: true, msg: `${paramName} updated` });
} else {
setResult({ ok: false, msg: `Failed to update ${paramName}` });
}
setTimeout(() => setResult(null), 3000);
} catch (err) {
setResult({ ok: false, msg: 'Update failed: ' + err.message });
setTimeout(() => setResult(null), 3000);
} finally {
setUpdating(null);
}
};
// Group parameters by node name (part before /)
const grouped = {};
Object.keys(params).forEach(name => {
const parts = name.split('/');
const node = parts.length > 1 ? parts[1] : 'root';
if (!grouped[node]) grouped[node] = [];
grouped[node].push(name);
});
// Filter by search
const filteredGroups = {};
Object.entries(grouped).forEach(([node, names]) => {
const filtered = names.filter(n => n.toLowerCase().includes(searchFilter.toLowerCase()));
if (filtered.length > 0) {
filteredGroups[node] = filtered;
}
});
const detectParamType = (val) => {
if (typeof val === 'boolean') return 5; // bool
if (Number.isInteger(val)) return 4; // int64
if (typeof val === 'number') return 1; // double
return 3; // string
};
const renderParamInput = (name, value) => {
const type = detectParamType(value);
if (type === 5) { // bool
return (
<label className="flex items-center gap-2 text-xs cursor-pointer">
<div onClick={() => handleParamChange(name, !value)}
className={`w-6 h-3 rounded-full relative cursor-pointer transition-colors ${value ? 'bg-cyan-700' : 'bg-gray-700'}`}>
<span className={`absolute top-0.5 w-2 h-2 rounded-full bg-white transition-all ${value ? 'left-3' : 'left-0.5'}`}/>
</div>
<span className="text-gray-400">{String(value)}</span>
</label>
);
}
return (
<input type={type === 1 ? 'number' : 'text'} step={type === 1 ? '0.01' : undefined}
value={value} onChange={(e) => handleParamChange(name, e.target.value)}
className="flex-1 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-xs text-cyan-200 focus:outline-none focus:border-cyan-700" />
);
};
return (
<div className="space-y-4 flex flex-col h-full">
<div className="flex items-center gap-2 flex-wrap">
<div className="text-cyan-700 text-xs font-bold tracking-widest">ROS PARAMETERS</div>
<span className={`text-xs px-1.5 py-0.5 rounded border ml-auto ${connected ? 'text-green-400 border-green-800' : 'text-gray-600 border-gray-700'}`}>
{connected ? 'LIVE' : 'OFFLINE'}
</span>
</div>
<div>
<input type="text" placeholder="Search parameters..."
value={searchFilter} onChange={(e) => setSearchFilter(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-cyan-700" />
</div>
{loading && (
<div className="flex items-center justify-center py-8 text-gray-600">
<div>Loading parameters</div>
</div>
)}
{!loading && Object.keys(filteredGroups).length === 0 && (
<div className="flex items-center justify-center py-8 text-gray-600">
<div className="text-center">
<div className="text-sm mb-1">No parameters found</div>
<div className="text-xs text-gray-700">{searchFilter ? 'Try a different search term' : 'Ensure robot is connected'}</div>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-3">
{Object.entries(filteredGroups).map(([node, names]) => (
<div key={node} className="bg-gray-950 border border-gray-800 rounded-lg p-3 space-y-2">
<div className="text-gray-500 text-xs font-bold font-mono uppercase">{node}</div>
<div className="space-y-2">
{names.sort().map(name => {
const shortName = name.split('/').pop();
const value = params[name];
const type = detectParamType(value);
const isUpdating = updating === name;
return (
<div key={name} className="flex items-center gap-2 text-xs">
<span className="text-gray-600 w-32 truncate" title={shortName}>{shortName}</span>
<div className="flex-1 flex items-center gap-1">
{renderParamInput(name, value)}
<button onClick={() => applyParam(name, params[name])} disabled={isUpdating || !connected}
className="px-2 py-1 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs font-bold disabled:opacity-40 whitespace-nowrap">
{isUpdating ? '…' : 'SET'}
</button>
</div>
<span className={`text-xs px-1 py-0.5 rounded font-mono ${
type === 5 ? 'bg-blue-950 text-blue-400' :
type === 4 ? 'bg-yellow-950 text-yellow-400' :
type === 1 ? 'bg-green-950 text-green-400' :
'bg-gray-800 text-gray-400'
}`}>
{type === 5 ? 'bool' : type === 4 ? 'int' : type === 1 ? 'float' : 'str'}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
{result && (
<div className={`text-xs rounded px-2 py-1 border ${
result.ok ? 'bg-green-950 border-green-800 text-green-400' : 'bg-red-950 border-red-800 text-red-400'
}`}>{result.ok ? '✓ ' : '✕ '}{result.msg}</div>
)}
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
<div className="flex justify-between">
<span>Total Parameters:</span>
<span className="text-gray-500">{Object.keys(params).length}</span>
</div>
<div className="flex justify-between">
<span>Grouped by Node:</span>
<span className="text-gray-500">{Object.keys(grouped).length} nodes</span>
</div>
</div>
</div>
);
}
function BackupView({ exportSettingsJSON, importSettingsJSON }) { function BackupView({ exportSettingsJSON, importSettingsJSON }) {
const [importText, setImportText] = useState(''); const [importText, setImportText] = useState('');
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
@ -441,6 +639,7 @@ export function SettingsPanel({ subscribe, callService, connected = false, wsUrl
}`}>{v.toUpperCase()}</button> }`}>{v.toUpperCase()}</button>
))} ))}
</div> </div>
{view==='Parameters' && <ParametersView callService={callService} subscribe={subscribe} connected={connected} />}
{view==='PID' && <PIDView gains={settings.gains} setGains={settings.setGains} applyPIDGains={settings.applyPIDGains} applying={settings.applying} applyResult={settings.applyResult} connected={connected} />} {view==='PID' && <PIDView gains={settings.gains} setGains={settings.setGains} applyPIDGains={settings.applyPIDGains} applying={settings.applying} applyResult={settings.applyResult} connected={connected} />}
{view==='Sensors' && <SensorsView sensors={settings.sensors} setSensors={settings.setSensors} applySensorParams={settings.applySensorParams} applying={settings.applying} applyResult={settings.applyResult} connected={connected} />} {view==='Sensors' && <SensorsView sensors={settings.sensors} setSensors={settings.setSensors} applySensorParams={settings.applySensorParams} applying={settings.applying} applyResult={settings.applyResult} connected={connected} />}
{view==='Network' && <NetworkView wsUrl={wsUrl} connected={connected} />} {view==='Network' && <NetworkView wsUrl={wsUrl} connected={connected} />}