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:
commit
94d12159b4
@ -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} />}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user