- Frontier exploration toward unexplored areas - Activates when idle >60s + no people detected - Turns toward detected sounds via audio_direction node - Approaches colorful/moving objects - Self-narrates findings via TTS - Respects geofence and obstacle boundaries - 10-minute max duration with auto-return - Configurable curiosity level (0-1.0) - Publishes /saltybot/curiosity_state Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
416 lines
15 KiB
JavaScript
416 lines
15 KiB
JavaScript
/**
|
||
* 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 (
|
||
<div className="flex items-center justify-center h-screen">
|
||
<div className="text-cyan-400">Loading parameters...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const groupParams = params[activeGroup] || {};
|
||
|
||
return (
|
||
<div className="flex flex-col h-full gap-4 p-4 bg-[#050510]">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-cyan-400">⚙️ Parameter Server</h1>
|
||
<p className="text-xs text-gray-500">Dynamic reconfiguration • {Object.keys(groupParams).length} parameters</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={saveOverrides}
|
||
disabled={pendingChanges.size === 0}
|
||
className="px-3 py-1 text-xs rounded border border-green-700 bg-green-950 text-green-400 hover:bg-green-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
💾 Save ({pendingChanges.size})
|
||
</button>
|
||
<button
|
||
onClick={resetAllParameters}
|
||
disabled={pendingChanges.size === 0}
|
||
className="px-3 py-1 text-xs rounded border border-gray-700 bg-gray-950 text-gray-400 hover:bg-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Reset All
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Presets */}
|
||
<div className="flex gap-2 pb-2 border-b border-gray-800">
|
||
<span className="text-xs text-gray-500 py-1">Presets:</span>
|
||
{presets.map(preset => (
|
||
<button
|
||
key={preset}
|
||
onClick={() => loadPreset(preset)}
|
||
className="px-2 py-1 text-xs rounded border border-cyan-700 bg-cyan-950 text-cyan-400 hover:bg-cyan-900 transition-colors"
|
||
>
|
||
{preset.charAt(0).toUpperCase() + preset.slice(1)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Group Tabs */}
|
||
<div className="flex gap-1 flex-wrap">
|
||
{PARAM_GROUPS.map(group => (
|
||
<button
|
||
key={group}
|
||
onClick={() => setActiveGroup(group)}
|
||
className={`px-3 py-1 text-xs font-bold rounded transition-colors ${
|
||
activeGroup === group
|
||
? `border-2 ${GROUP_COLORS[group]} ${GROUP_BG[group]} text-white`
|
||
: 'border border-gray-700 bg-gray-950 text-gray-400 hover:bg-gray-900'
|
||
}`}
|
||
>
|
||
{group.charAt(0).toUpperCase() + group.slice(1)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Error Display */}
|
||
{error && (
|
||
<div className="p-3 rounded bg-red-950 border border-red-700 text-red-400 text-sm">
|
||
⚠️ {error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Parameters */}
|
||
<div className="flex-1 overflow-y-auto space-y-2">
|
||
{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 (
|
||
<div
|
||
key={paramName}
|
||
className={`p-3 rounded border transition-all ${
|
||
paramInfo.is_safety
|
||
? 'border-red-700 bg-red-950 bg-opacity-30'
|
||
: 'border-gray-700 bg-gray-950 bg-opacity-30 hover:bg-opacity-50'
|
||
} ${isModified ? 'ring-2 ring-yellow-500' : ''}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => toggleParamExpanded(paramName)}
|
||
className="text-gray-500 hover:text-gray-300 px-1"
|
||
>
|
||
{isExpanded ? '▼' : '▶'}
|
||
</button>
|
||
<div className="flex-1">
|
||
<div className="font-mono text-sm text-gray-300 break-all">
|
||
{paramName}
|
||
{paramInfo.is_safety && <span className="ml-2 text-xs text-red-400">🔒 SAFETY</span>}
|
||
{isModified && <span className="ml-2 text-xs text-yellow-400">⚡ Modified</span>}
|
||
</div>
|
||
<div className="text-xs text-gray-500 mt-0.5">{paramInfo.description}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{isModified && (
|
||
<button
|
||
onClick={() => resetParameter(paramName)}
|
||
className="px-2 py-0.5 text-xs rounded border border-gray-600 bg-gray-900 text-gray-400 hover:bg-gray-800"
|
||
>
|
||
Reset
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{isExpanded && (
|
||
<div className="mt-3 ml-6 space-y-2">
|
||
{/* Type and Range Info */}
|
||
<div className="text-xs text-gray-500 grid grid-cols-3 gap-2">
|
||
<div>Type: <span className="text-cyan-400">{paramInfo.type}</span></div>
|
||
{paramInfo.min !== undefined && (
|
||
<div>Min: <span className="text-cyan-400">{paramInfo.min}</span></div>
|
||
)}
|
||
{paramInfo.max !== undefined && (
|
||
<div>Max: <span className="text-cyan-400">{paramInfo.max}</span></div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Input Field */}
|
||
<div className="flex items-center gap-2">
|
||
{paramInfo.type === 'bool' ? (
|
||
<select
|
||
value={currentValue ? 'true' : 'false'}
|
||
onChange={(e) => handleParamChange(paramName, e.target.value === 'true', paramInfo)}
|
||
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"
|
||
>
|
||
<option value="true">True</option>
|
||
<option value="false">False</option>
|
||
</select>
|
||
) : (
|
||
<input
|
||
type={paramInfo.type === 'int' ? 'number' : 'text'}
|
||
value={currentValue}
|
||
onChange={(e) => 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"
|
||
/>
|
||
)}
|
||
<span className="text-xs text-gray-500">
|
||
{isModified ? (
|
||
<>
|
||
<span className="text-gray-600">{paramInfo.value}</span>
|
||
<span className="mx-1">→</span>
|
||
<span className="text-yellow-400">{currentValue}</span>
|
||
</>
|
||
) : (
|
||
<span className="text-gray-400">{currentValue}</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Range Visualization */}
|
||
{paramInfo.type !== 'bool' && paramInfo.type !== 'string' && paramInfo.min !== undefined && paramInfo.max !== undefined && (
|
||
<div className="w-full bg-gray-900 rounded h-1 overflow-hidden">
|
||
<div
|
||
className="h-full bg-cyan-600"
|
||
style={{
|
||
width: `${((currentValue - paramInfo.min) / (paramInfo.max - paramInfo.min)) * 100}%`
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Safety Confirmation Dialog */}
|
||
{confirmDialog && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50">
|
||
<div className="bg-gray-900 border-2 border-red-600 rounded p-4 max-w-md">
|
||
<h3 className="text-lg font-bold text-red-400 mb-2">⚠️ Safety Parameter Confirmation</h3>
|
||
<p className="text-gray-300 mb-4">{confirmDialog.message}</p>
|
||
<div className="bg-gray-950 p-2 rounded mb-4 text-sm font-mono">
|
||
<div className="text-gray-500">{confirmDialog.paramName}</div>
|
||
<div className="text-gray-400">{confirmDialog.paramInfo.value} → <span className="text-yellow-400">{confirmDialog.newValue}</span></div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={confirmParameterChange}
|
||
className="flex-1 px-3 py-2 rounded bg-red-950 border border-red-700 text-red-400 hover:bg-red-900 font-bold"
|
||
>
|
||
✓ Confirm
|
||
</button>
|
||
<button
|
||
onClick={rejectParameterChange}
|
||
className="flex-1 px-3 py-2 rounded bg-gray-950 border border-gray-700 text-gray-400 hover:bg-gray-900"
|
||
>
|
||
✕ Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|