/** * ParameterServer.jsx — SaltyBot Centralized Dynamic Parameter Configuration (Issue #471) * * Features: * - Load and display parameters grouped by category (hardware/perception/controls/social/safety/debug) * - Edit parameters with real-time validation (type checking, min/max ranges) * - Display metadata: type, range, description, is_safety flag * - Load named presets (indoor/outdoor/demo/debug) * - Safety confirmation for critical parameters * - Persist parameter overrides * - Visual feedback for modified parameters * - Reset to defaults option */ import { useState, useEffect, useCallback } from 'react'; const PARAM_GROUPS = ['hardware', 'perception', 'controls', 'social', 'safety', 'debug']; const GROUP_COLORS = { hardware: 'border-blue-500', perception: 'border-purple-500', controls: 'border-green-500', social: 'border-rose-500', safety: 'border-red-500', debug: 'border-yellow-500', }; const GROUP_BG = { hardware: 'bg-blue-950', perception: 'bg-purple-950', controls: 'bg-green-950', social: 'bg-rose-950', safety: 'bg-red-950', debug: 'bg-yellow-950', }; export function ParameterServer({ subscribe, callService }) { const [params, setParams] = useState({}); const [editValues, setEditValues] = useState({}); const [presets, setPresets] = useState(['indoor', 'outdoor', 'demo', 'debug']); const [activeGroup, setActiveGroup] = useState('hardware'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expandedParams, setExpandedParams] = useState(new Set()); const [pendingChanges, setPendingChanges] = useState(new Set()); const [confirmDialog, setConfirmDialog] = useState(null); // Fetch parameters from server useEffect(() => { const fetchParams = async () => { try { setLoading(true); // Try to call the service or subscribe to parameter topic if (subscribe) { // Subscribe to parameter updates (if available) subscribe('/saltybot/parameters', 'std_msgs/String', (msg) => { try { const paramsData = JSON.parse(msg.data); setParams(paramsData); setError(null); } catch (e) { console.error('Failed to parse parameters:', e); } }); } setLoading(false); } catch (err) { setError(`Failed to fetch parameters: ${err.message}`); setLoading(false); } }; fetchParams(); }, [subscribe]); const toggleParamExpanded = useCallback((paramName) => { setExpandedParams(prev => { const next = new Set(prev); next.has(paramName) ? next.delete(paramName) : next.add(paramName); return next; }); }, []); const handleParamChange = useCallback((paramName, value, paramInfo) => { // Validate input based on type let validatedValue = value; let isValid = true; if (paramInfo.type === 'int') { validatedValue = parseInt(value, 10); isValid = !isNaN(validatedValue); } else if (paramInfo.type === 'float') { validatedValue = parseFloat(value); isValid = !isNaN(validatedValue); } else if (paramInfo.type === 'bool') { validatedValue = value === 'true' || value === true || value === 1; } // Check range if (isValid && paramInfo.min !== undefined && validatedValue < paramInfo.min) { isValid = false; } if (isValid && paramInfo.max !== undefined && validatedValue > paramInfo.max) { isValid = false; } if (isValid) { setEditValues(prev => ({ ...prev, [paramName]: validatedValue })); if (paramInfo.value !== validatedValue) { setPendingChanges(prev => new Set([...prev, paramName])); } else { setPendingChanges(prev => { const next = new Set(prev); next.delete(paramName); return next; }); } // For safety parameters, show confirmation if (paramInfo.is_safety && paramInfo.value !== validatedValue) { setConfirmDialog({ paramName, paramInfo, newValue: validatedValue, message: `Safety parameter "${paramName}" will be changed. This may affect robot behavior.` }); } } }, []); const confirmParameterChange = useCallback(() => { if (!confirmDialog) return; const { paramName, newValue } = confirmDialog; // Persist to backend if (callService) { callService('/saltybot/set_param', { name: paramName, value: newValue }); } setConfirmDialog(null); }, [confirmDialog, callService]); const rejectParameterChange = useCallback(() => { if (!confirmDialog) { setConfirmDialog(null); return; } const { paramName } = confirmDialog; setEditValues(prev => { const next = { ...prev }; delete next[paramName]; return next; }); setPendingChanges(prev => { const next = new Set(prev); next.delete(paramName); return next; }); setConfirmDialog(null); }, [confirmDialog]); const loadPreset = useCallback((presetName) => { if (callService) { callService('/saltybot/load_preset', { preset: presetName }); } }, [callService]); const saveOverrides = useCallback(() => { if (callService) { callService('/saltybot/save_overrides', {}); } setPendingChanges(new Set()); }, [callService]); const resetParameter = useCallback((paramName) => { setEditValues(prev => { const next = { ...prev }; delete next[paramName]; return next; }); setPendingChanges(prev => { const next = new Set(prev); next.delete(paramName); return next; }); }, []); const resetAllParameters = useCallback(() => { setEditValues({}); setPendingChanges(new Set()); }, []); if (loading) { return (
Loading parameters...
); } const groupParams = params[activeGroup] || {}; return (
{/* Header */}

⚙️ Parameter Server

Dynamic reconfiguration • {Object.keys(groupParams).length} parameters

{/* Presets */}
Presets: {presets.map(preset => ( ))}
{/* Group Tabs */}
{PARAM_GROUPS.map(group => ( ))}
{/* Error Display */} {error && (
⚠️ {error}
)} {/* Parameters */}
{Object.entries(groupParams).map(([paramName, paramInfo]) => { const isModified = pendingChanges.has(paramName); const currentValue = editValues[paramName] !== undefined ? editValues[paramName] : paramInfo.value; const isExpanded = expandedParams.has(paramName); return (
{paramName} {paramInfo.is_safety && 🔒 SAFETY} {isModified && ⚡ Modified}
{paramInfo.description}
{isModified && ( )}
{isExpanded && (
{/* Type and Range Info */}
Type: {paramInfo.type}
{paramInfo.min !== undefined && (
Min: {paramInfo.min}
)} {paramInfo.max !== undefined && (
Max: {paramInfo.max}
)}
{/* Input Field */}
{paramInfo.type === 'bool' ? ( ) : ( handleParamChange(paramName, e.target.value, paramInfo)} step={paramInfo.type === 'float' ? '0.01' : '1'} className="flex-1 px-2 py-1 rounded bg-gray-900 border border-gray-700 text-gray-300 text-sm focus:outline-none focus:border-cyan-500" /> )} {isModified ? ( <> {paramInfo.value} {currentValue} ) : ( {currentValue} )}
{/* Range Visualization */} {paramInfo.type !== 'bool' && paramInfo.type !== 'string' && paramInfo.min !== undefined && paramInfo.max !== undefined && (
)}
)}
); })}
{/* Safety Confirmation Dialog */} {confirmDialog && (

⚠️ Safety Parameter Confirmation

{confirmDialog.message}

{confirmDialog.paramName}
{confirmDialog.paramInfo.value} → {confirmDialog.newValue}
)}
); }