sl-android 2e9fd6fa4c feat: curiosity behavior — autonomous exploration when idle (Issue #470)
- 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>
2026-03-05 12:10:33 -05:00

416 lines
15 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}