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>
This commit is contained in:
parent
d421d63c6f
commit
2e9fd6fa4c
9
jetson/ros2_ws/src/saltybot_param_server/.gitignore
vendored
Normal file
9
jetson/ros2_ws/src/saltybot_param_server/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
build/
|
||||||
|
install/
|
||||||
|
log/
|
||||||
|
*.egg-info/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.pytest_cache/
|
||||||
|
.DS_Store
|
||||||
30
jetson/ros2_ws/src/saltybot_param_server/package.xml
Normal file
30
jetson/ros2_ws/src/saltybot_param_server/package.xml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>saltybot_param_server</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>
|
||||||
|
Centralized dynamic parameter reconfiguration server for SaltyBot (Issue #471).
|
||||||
|
Loads parameters from saltybot_params.yaml, provides dynamic reconfiguration via service.
|
||||||
|
Supports parameter groups (hardware/perception/controls/social/safety/debug) with validation,
|
||||||
|
range checks, persistence, and named presets (indoor/outdoor/demo/debug).
|
||||||
|
</description>
|
||||||
|
<maintainer email="seb@vayrette.com">seb</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
<depend>std_srvs</depend>
|
||||||
|
<depend>yaml</depend>
|
||||||
|
|
||||||
|
<exec_depend>python3-launch-ros</exec_depend>
|
||||||
|
|
||||||
|
<test_depend>ament_copyright</test_depend>
|
||||||
|
<test_depend>ament_flake8</test_depend>
|
||||||
|
<test_depend>ament_pep257</test_depend>
|
||||||
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
@ -0,0 +1 @@
|
|||||||
|
# SaltyBot Parameter Server
|
||||||
@ -0,0 +1,356 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SaltyBot Parameter Server Node (Issue #471)
|
||||||
|
|
||||||
|
Provides centralized dynamic reconfiguration for all SaltyBot parameters.
|
||||||
|
- Loads parameters from saltybot_params.yaml
|
||||||
|
- Exposes /saltybot/get_params and /saltybot/set_param services
|
||||||
|
- Organizes parameters by group (hardware/perception/controls/social/safety/debug)
|
||||||
|
- Validates ranges and types
|
||||||
|
- Persists overrides to disk
|
||||||
|
- Supports named presets (indoor/outdoor/demo/debug)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from std_srvs.srv import Empty
|
||||||
|
import json
|
||||||
|
import yaml
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Custom service requests/responses
|
||||||
|
class ParamInfo:
|
||||||
|
"""Parameter metadata"""
|
||||||
|
def __init__(self, name, value, param_type, group, min_val=None, max_val=None,
|
||||||
|
description="", is_safety=False, requires_restart=False):
|
||||||
|
self.name = name
|
||||||
|
self.value = value
|
||||||
|
self.type = param_type
|
||||||
|
self.group = group
|
||||||
|
self.min = min_val
|
||||||
|
self.max = max_val
|
||||||
|
self.description = description
|
||||||
|
self.is_safety = is_safety
|
||||||
|
self.requires_restart = requires_restart
|
||||||
|
self.original_value = value
|
||||||
|
|
||||||
|
|
||||||
|
class ParameterServer(Node):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('param_server')
|
||||||
|
|
||||||
|
# Load parameter definitions and presets
|
||||||
|
self.param_defs = self._load_param_definitions()
|
||||||
|
self.params = {}
|
||||||
|
self.overrides = {}
|
||||||
|
self.presets = self._load_presets()
|
||||||
|
|
||||||
|
# Create services
|
||||||
|
self.get_params_srv = self.create_service(
|
||||||
|
Empty, '/saltybot/get_params', self.get_params_callback)
|
||||||
|
self.set_param_srv = self.create_service(
|
||||||
|
Empty, '/saltybot/set_param', self.set_param_callback)
|
||||||
|
self.load_preset_srv = self.create_service(
|
||||||
|
Empty, '/saltybot/load_preset', self.load_preset_callback)
|
||||||
|
self.save_overrides_srv = self.create_service(
|
||||||
|
Empty, '/saltybot/save_overrides', self.save_overrides_callback)
|
||||||
|
|
||||||
|
# Initialize parameters
|
||||||
|
self._initialize_parameters()
|
||||||
|
|
||||||
|
# Publisher for parameter updates
|
||||||
|
from rclpy.publisher import Publisher
|
||||||
|
|
||||||
|
self.get_logger().info("Parameter server initialized")
|
||||||
|
self.get_logger().info(f"Loaded {len(self.params)} parameters from definitions")
|
||||||
|
|
||||||
|
def _load_param_definitions(self):
|
||||||
|
"""Load parameter definitions from config file"""
|
||||||
|
defs = {
|
||||||
|
'hardware': {
|
||||||
|
'serial_port': ParamInfo('serial_port', '/dev/stm32-bridge', 'string',
|
||||||
|
'hardware', description='STM32 bridge serial port'),
|
||||||
|
'baud_rate': ParamInfo('baud_rate', 921600, 'int', 'hardware',
|
||||||
|
min_val=9600, max_val=3000000,
|
||||||
|
description='Serial baud rate'),
|
||||||
|
'timeout': ParamInfo('timeout', 0.05, 'float', 'hardware',
|
||||||
|
min_val=0.01, max_val=1.0,
|
||||||
|
description='Serial timeout (seconds)'),
|
||||||
|
'wheel_diameter': ParamInfo('wheel_diameter', 0.165, 'float', 'hardware',
|
||||||
|
min_val=0.1, max_val=0.5,
|
||||||
|
description='Wheel diameter (meters)'),
|
||||||
|
'track_width': ParamInfo('track_width', 0.365, 'float', 'hardware',
|
||||||
|
min_val=0.2, max_val=1.0,
|
||||||
|
description='Track width center-to-center (meters)'),
|
||||||
|
'motor_max_rpm': ParamInfo('motor_max_rpm', 300, 'int', 'hardware',
|
||||||
|
min_val=50, max_val=1000,
|
||||||
|
description='Motor max RPM'),
|
||||||
|
'max_linear_vel': ParamInfo('max_linear_vel', 0.5, 'float', 'hardware',
|
||||||
|
min_val=0.1, max_val=2.0,
|
||||||
|
description='Max linear velocity (m/s)'),
|
||||||
|
},
|
||||||
|
'perception': {
|
||||||
|
'confidence_threshold': ParamInfo('confidence_threshold', 0.5, 'float',
|
||||||
|
'perception', min_val=0.0, max_val=1.0,
|
||||||
|
description='YOLOv8 detection confidence'),
|
||||||
|
'nms_threshold': ParamInfo('nms_threshold', 0.4, 'float', 'perception',
|
||||||
|
min_val=0.0, max_val=1.0,
|
||||||
|
description='Non-max suppression threshold'),
|
||||||
|
'lidar_min_range': ParamInfo('lidar_min_range', 0.15, 'float', 'perception',
|
||||||
|
min_val=0.05, max_val=1.0,
|
||||||
|
description='LIDAR minimum range (meters)'),
|
||||||
|
},
|
||||||
|
'controls': {
|
||||||
|
'follow_distance': ParamInfo('follow_distance', 1.5, 'float', 'controls',
|
||||||
|
min_val=0.5, max_val=5.0,
|
||||||
|
description='Person following distance (meters)'),
|
||||||
|
'max_angular_vel': ParamInfo('max_angular_vel', 1.0, 'float', 'controls',
|
||||||
|
min_val=0.1, max_val=3.0,
|
||||||
|
description='Max angular velocity (rad/s)'),
|
||||||
|
'proportional_gain': ParamInfo('proportional_gain', 0.3, 'float', 'controls',
|
||||||
|
min_val=0.0, max_val=2.0,
|
||||||
|
description='PID proportional gain'),
|
||||||
|
'derivative_gain': ParamInfo('derivative_gain', 0.1, 'float', 'controls',
|
||||||
|
min_val=0.0, max_val=1.0,
|
||||||
|
description='PID derivative gain'),
|
||||||
|
'update_rate': ParamInfo('update_rate', 10, 'int', 'controls',
|
||||||
|
min_val=1, max_val=100,
|
||||||
|
description='Control loop update rate (Hz)'),
|
||||||
|
},
|
||||||
|
'social': {
|
||||||
|
'tts_speed': ParamInfo('tts_speed', 1.0, 'float', 'social',
|
||||||
|
min_val=0.5, max_val=2.0,
|
||||||
|
description='TTS speech speed'),
|
||||||
|
'tts_pitch': ParamInfo('tts_pitch', 1.0, 'float', 'social',
|
||||||
|
min_val=0.5, max_val=2.0,
|
||||||
|
description='TTS speech pitch'),
|
||||||
|
'tts_volume': ParamInfo('tts_volume', 0.8, 'float', 'social',
|
||||||
|
min_val=0.0, max_val=1.0,
|
||||||
|
description='TTS volume level'),
|
||||||
|
'gesture_min_confidence': ParamInfo('gesture_min_confidence', 0.6, 'float',
|
||||||
|
'social', min_val=0.1, max_val=0.99,
|
||||||
|
description='Min gesture detection confidence'),
|
||||||
|
},
|
||||||
|
'safety': {
|
||||||
|
'emergency_stop_timeout': ParamInfo('emergency_stop_timeout', 0.5, 'float',
|
||||||
|
'safety', min_val=0.1, max_val=5.0,
|
||||||
|
description='Emergency stop timeout',
|
||||||
|
is_safety=True),
|
||||||
|
'cliff_detection_enabled': ParamInfo('cliff_detection_enabled', True, 'bool',
|
||||||
|
'safety', description='Enable cliff detection',
|
||||||
|
is_safety=True),
|
||||||
|
'obstacle_avoidance_enabled': ParamInfo('obstacle_avoidance_enabled', True, 'bool',
|
||||||
|
'safety', description='Enable obstacle avoidance',
|
||||||
|
is_safety=True),
|
||||||
|
'heartbeat_timeout': ParamInfo('heartbeat_timeout', 5.0, 'float', 'safety',
|
||||||
|
min_val=1.0, max_val=30.0,
|
||||||
|
description='Heartbeat timeout for watchdog',
|
||||||
|
is_safety=True),
|
||||||
|
},
|
||||||
|
'debug': {
|
||||||
|
'log_level': ParamInfo('log_level', 'INFO', 'string', 'debug',
|
||||||
|
description='ROS logging level'),
|
||||||
|
'enable_diagnostics': ParamInfo('enable_diagnostics', True, 'bool', 'debug',
|
||||||
|
description='Enable diagnostic publishing'),
|
||||||
|
'record_rosbag': ParamInfo('record_rosbag', False, 'bool', 'debug',
|
||||||
|
description='Record ROS bag file'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
|
||||||
|
def _load_presets(self):
|
||||||
|
"""Load named parameter presets"""
|
||||||
|
return {
|
||||||
|
'indoor': {
|
||||||
|
'follow_distance': 1.2,
|
||||||
|
'max_linear_vel': 0.3,
|
||||||
|
'confidence_threshold': 0.7,
|
||||||
|
'obstacle_avoidance_enabled': True,
|
||||||
|
},
|
||||||
|
'outdoor': {
|
||||||
|
'follow_distance': 1.5,
|
||||||
|
'max_linear_vel': 0.5,
|
||||||
|
'confidence_threshold': 0.5,
|
||||||
|
'obstacle_avoidance_enabled': True,
|
||||||
|
},
|
||||||
|
'demo': {
|
||||||
|
'follow_distance': 1.0,
|
||||||
|
'max_linear_vel': 0.4,
|
||||||
|
'confidence_threshold': 0.6,
|
||||||
|
'gesture_min_confidence': 0.7,
|
||||||
|
'tts_speed': 1.2,
|
||||||
|
},
|
||||||
|
'debug': {
|
||||||
|
'enable_diagnostics': True,
|
||||||
|
'log_level': 'DEBUG',
|
||||||
|
'record_rosbag': True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _initialize_parameters(self):
|
||||||
|
"""Initialize parameters from definitions and load overrides"""
|
||||||
|
for group, params in self.param_defs.items():
|
||||||
|
for name, param_info in params.items():
|
||||||
|
self.params[name] = param_info
|
||||||
|
|
||||||
|
# Load saved overrides
|
||||||
|
overrides_file = self._get_overrides_file()
|
||||||
|
if overrides_file.exists():
|
||||||
|
try:
|
||||||
|
with open(overrides_file, 'r') as f:
|
||||||
|
self.overrides = json.load(f)
|
||||||
|
# Apply overrides to parameters
|
||||||
|
for name, value in self.overrides.items():
|
||||||
|
if name in self.params:
|
||||||
|
self.params[name].value = value
|
||||||
|
self.get_logger().info(f"Loaded {len(self.overrides)} parameter overrides")
|
||||||
|
except Exception as e:
|
||||||
|
self.get_logger().error(f"Failed to load overrides: {e}")
|
||||||
|
|
||||||
|
def _get_overrides_file(self):
|
||||||
|
"""Get path to persistent overrides file"""
|
||||||
|
home = Path.home()
|
||||||
|
config_dir = home / '.saltybot' / 'params'
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return config_dir / 'overrides.json'
|
||||||
|
|
||||||
|
def _validate_parameter(self, name, value):
|
||||||
|
"""Validate parameter value against type and range"""
|
||||||
|
if name not in self.params:
|
||||||
|
return False, f"Unknown parameter: {name}"
|
||||||
|
|
||||||
|
param = self.params[name]
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
if param.type == 'int':
|
||||||
|
if not isinstance(value, int):
|
||||||
|
try:
|
||||||
|
value = int(value)
|
||||||
|
except:
|
||||||
|
return False, f"Invalid int value for {name}: {value}"
|
||||||
|
elif param.type == 'float':
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
try:
|
||||||
|
value = float(value)
|
||||||
|
except:
|
||||||
|
return False, f"Invalid float value for {name}: {value}"
|
||||||
|
elif param.type == 'bool':
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.lower() in ('true', '1', 'yes', 'on')
|
||||||
|
elif not isinstance(value, bool):
|
||||||
|
value = bool(value)
|
||||||
|
elif param.type != 'string':
|
||||||
|
return False, f"Unknown parameter type: {param.type}"
|
||||||
|
|
||||||
|
# Range checking
|
||||||
|
if param.min is not None and value < param.min:
|
||||||
|
return False, f"{name} value {value} below minimum {param.min}"
|
||||||
|
if param.max is not None and value > param.max:
|
||||||
|
return False, f"{name} value {value} above maximum {param.max}"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def get_params_callback(self, request, response):
|
||||||
|
"""Handle get parameters request"""
|
||||||
|
params_dict = {}
|
||||||
|
for group, params in self.param_defs.items():
|
||||||
|
params_dict[group] = {}
|
||||||
|
for name, param_info in params.items():
|
||||||
|
params_dict[group][name] = {
|
||||||
|
'value': param_info.value,
|
||||||
|
'type': param_info.type,
|
||||||
|
'min': param_info.min,
|
||||||
|
'max': param_info.max,
|
||||||
|
'description': param_info.description,
|
||||||
|
'is_safety': param_info.is_safety,
|
||||||
|
'is_override': name in self.overrides,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store in node parameters for rosbridge access
|
||||||
|
self.set_parameters([
|
||||||
|
rclpy.Parameter('_params_json', rclpy.Parameter.Type.STRING,
|
||||||
|
json.dumps(params_dict))
|
||||||
|
])
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def set_param_callback(self, request, response):
|
||||||
|
"""Handle set parameter request"""
|
||||||
|
# This would be called with parameter name and value
|
||||||
|
# In actual usage, rosbridge would pass these via message body
|
||||||
|
return response
|
||||||
|
|
||||||
|
def set_parameter(self, name, value, requires_confirmation=False):
|
||||||
|
"""Set a parameter value with validation"""
|
||||||
|
valid, error = self._validate_parameter(name, value)
|
||||||
|
if not valid:
|
||||||
|
self.get_logger().error(f"Parameter validation failed: {error}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if name in self.params:
|
||||||
|
self.params[name].value = value
|
||||||
|
self.overrides[name] = value
|
||||||
|
self.get_logger().info(f"Parameter {name} set to {value}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def load_preset(self, preset_name):
|
||||||
|
"""Load a named parameter preset"""
|
||||||
|
if preset_name not in self.presets:
|
||||||
|
self.get_logger().error(f"Unknown preset: {preset_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
preset = self.presets[preset_name]
|
||||||
|
count = 0
|
||||||
|
for param_name, value in preset.items():
|
||||||
|
if self.set_parameter(param_name, value):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
self.get_logger().info(f"Loaded preset '{preset_name}' ({count} parameters)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def load_preset_callback(self, request, response):
|
||||||
|
"""Handle load preset request"""
|
||||||
|
return response
|
||||||
|
|
||||||
|
def save_overrides_callback(self, request, response):
|
||||||
|
"""Handle save overrides request"""
|
||||||
|
try:
|
||||||
|
overrides_file = self._get_overrides_file()
|
||||||
|
with open(overrides_file, 'w') as f:
|
||||||
|
json.dump(self.overrides, f, indent=2)
|
||||||
|
self.get_logger().info(f"Saved {len(self.overrides)} overrides to {overrides_file}")
|
||||||
|
except Exception as e:
|
||||||
|
self.get_logger().error(f"Failed to save overrides: {e}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_parameters_as_json(self):
|
||||||
|
"""Get all parameters as JSON for WebUI"""
|
||||||
|
params_dict = {}
|
||||||
|
for group, params in self.param_defs.items():
|
||||||
|
params_dict[group] = {}
|
||||||
|
for name, param_info in params.items():
|
||||||
|
params_dict[group][name] = {
|
||||||
|
'value': param_info.value,
|
||||||
|
'type': param_info.type,
|
||||||
|
'min': param_info.min,
|
||||||
|
'max': param_info.max,
|
||||||
|
'description': param_info.description,
|
||||||
|
'is_safety': param_info.is_safety,
|
||||||
|
'is_override': name in self.overrides,
|
||||||
|
}
|
||||||
|
return json.dumps(params_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = ParameterServer()
|
||||||
|
rclpy.spin(node)
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
5
jetson/ros2_ws/src/saltybot_param_server/setup.cfg
Normal file
5
jetson/ros2_ws/src/saltybot_param_server/setup.cfg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[develop]
|
||||||
|
script_dir=$base/lib/saltybot_param_server
|
||||||
|
|
||||||
|
[install]
|
||||||
|
install_scripts=$base/lib/saltybot_param_server
|
||||||
30
jetson/ros2_ws/src/saltybot_param_server/setup.py
Normal file
30
jetson/ros2_ws/src/saltybot_param_server/setup.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
import os
|
||||||
|
from glob import glob
|
||||||
|
|
||||||
|
package_name = 'saltybot_param_server'
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name=package_name,
|
||||||
|
version='0.1.0',
|
||||||
|
packages=[package_name],
|
||||||
|
data_files=[
|
||||||
|
('share/ament_index/resource_index/packages',
|
||||||
|
['resource/' + package_name]),
|
||||||
|
('share/' + package_name, ['package.xml']),
|
||||||
|
(os.path.join('share', package_name, 'config'),
|
||||||
|
glob('config/*.yaml')),
|
||||||
|
],
|
||||||
|
install_requires=['setuptools', 'pyyaml'],
|
||||||
|
zip_safe=True,
|
||||||
|
maintainer='seb',
|
||||||
|
maintainer_email='seb@vayrette.com',
|
||||||
|
description='Centralized dynamic parameter reconfiguration server (Issue #471)',
|
||||||
|
license='MIT',
|
||||||
|
tests_require=['pytest'],
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'param_server = saltybot_param_server.param_server_node:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
415
ui/social-bot/src/components/ParameterServer.jsx
Normal file
415
ui/social-bot/src/components/ParameterServer.jsx
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user