/** * 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 (
Dynamic reconfiguration • {Object.keys(groupParams).length} parameters
{confirmDialog.message}