From ddb93bec2047dd7689f510e805b05a61c8d1d9c6 Mon Sep 17 00:00:00 2001 From: sl-webui Date: Tue, 3 Mar 2026 13:49:51 -0500 Subject: [PATCH] Issue #354: Add ROS parameter editor to Settings Add Parameters tab for live ROS parameter editing with: - get_parameters service integration - set_parameter service support - Type-specific input controls - Node-based grouping - Search filtering Co-Authored-By: Claude Haiku 4.5 --- .../src/components/SettingsPanel.jsx | 201 +++++++++++++++++- 1 file changed, 200 insertions(+), 1 deletion(-) diff --git a/ui/social-bot/src/components/SettingsPanel.jsx b/ui/social-bot/src/components/SettingsPanel.jsx index 5da9a21..4668d25 100644 --- a/ui/social-bot/src/components/SettingsPanel.jsx +++ b/ui/social-bot/src/components/SettingsPanel.jsx @@ -15,7 +15,7 @@ import { simulateStepResponse, validatePID, } from '../hooks/useSettings.js'; -const VIEWS = ['PID', 'Sensors', 'Network', 'Firmware', 'Diagnostics', 'Backup']; +const VIEWS = ['Parameters', 'PID', 'Sensors', 'Network', 'Firmware', 'Diagnostics', 'Backup']; function ValidationBadges({ warnings }) { 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 ( + + ); + } + return ( + 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 ( +
+
+
ROS PARAMETERS
+ + {connected ? 'LIVE' : 'OFFLINE'} + +
+ +
+ 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" /> +
+ + {loading && ( +
+
Loading parameters…
+
+ )} + + {!loading && Object.keys(filteredGroups).length === 0 && ( +
+
+
No parameters found
+
{searchFilter ? 'Try a different search term' : 'Ensure robot is connected'}
+
+
+ )} + +
+ {Object.entries(filteredGroups).map(([node, names]) => ( +
+
{node}
+
+ {names.sort().map(name => { + const shortName = name.split('/').pop(); + const value = params[name]; + const type = detectParamType(value); + const isUpdating = updating === name; + + return ( +
+ {shortName} +
+ {renderParamInput(name, value)} + +
+ + {type === 5 ? 'bool' : type === 4 ? 'int' : type === 1 ? 'float' : 'str'} + +
+ ); + })} +
+
+ ))} +
+ + {result && ( +
{result.ok ? '✓ ' : '✕ '}{result.msg}
+ )} + +
+
+ Total Parameters: + {Object.keys(params).length} +
+
+ Grouped by Node: + {Object.keys(grouped).length} nodes +
+
+
+ ); +} + function BackupView({ exportSettingsJSON, importSettingsJSON }) { const [importText, setImportText] = useState(''); const [showImport, setShowImport] = useState(false); @@ -441,6 +639,7 @@ export function SettingsPanel({ subscribe, callService, connected = false, wsUrl }`}>{v.toUpperCase()} ))} + {view==='Parameters' && } {view==='PID' && } {view==='Sensors' && } {view==='Network' && }