Add hermes_mcp, the bridge between Hermes and the ROS graph
The MCP server is now a ROS node and the only way in or out, per the owner's decision that ROS never leaves the robot. It serves MCP on the USB address only with a bearer token, keeps the stable tool name look() (which waits for the next camera frame, so it is never stale, and passes the camera's JPEG through), and registers turn_head/head_position only when a head is fitted, which today it is not. While an authenticated client is connected it republishes the agent heartbeat on /hermes/state, so the face wakes on connect and sleeps about 40 s after the client leaves; look() also makes the face squint. The token is generated on the Pi into /etc/robot-mcp.env (root:hermes, 640) and never printed. Verified end to end from talos with a real MCP client over the USB link: tools listed, a 157 KB JPEG back in 0.08 s, a wrong token refused, the Wi-Fi path dropped by the firewall. Host config snippets now point at 10.55.0.2. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
This commit is contained in:
@@ -30,3 +30,11 @@ hermes_face_detector:
|
||||
hermes_audio_state:
|
||||
ros__parameters:
|
||||
rate_hz: 30.0
|
||||
|
||||
hermes_mcp:
|
||||
ros__parameters:
|
||||
bind: 10.55.0.2 # the USB link; the firewall accepts 8765 only from 10.55.0.1 on usb0
|
||||
port: 8765
|
||||
head: false # no servos fitted yet: turn_head and head_position stay unregistered
|
||||
connected_grace_s: 30.0 # an authenticated client heard within this long keeps the heartbeat going
|
||||
look_timeout_s: 3.0
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Bring up the robot: camera, face, face detection, audio levels.
|
||||
"""Bring up the robot: camera, face, face detection, audio levels, and the MCP bridge.
|
||||
|
||||
ros2 launch hermes_bringup robot.launch.py [camera:=false] [face:=false] [vision:=false] [audio:=false]
|
||||
ros2 launch hermes_bringup robot.launch.py [camera:=false] [face:=false] [vision:=false] [audio:=false] [mcp:=false]
|
||||
[start_delay:=6.0]
|
||||
|
||||
Every node is its own process and is respawned if it exits, so one crash cannot take the others
|
||||
down: the face keeps running when the camera does not. Parameters are in config/robot.yaml.
|
||||
|
||||
The face starts at once; the camera, vision and audio nodes wait start_delay seconds, so the four
|
||||
The face starts at once; the camera, vision, audio and MCP nodes wait start_delay seconds, so the four
|
||||
Python processes do not fight over the CPU while the face imports and draws its first frame.
|
||||
"""
|
||||
import os
|
||||
@@ -24,10 +24,11 @@ NODES = [
|
||||
('face', 'hermes_face', 'face_node', 'hermes_face'),
|
||||
('vision', 'hermes_vision', 'face_detector', 'hermes_face_detector'),
|
||||
('audio', 'hermes_audio', 'audio_state', 'hermes_audio_state'),
|
||||
('mcp', 'hermes_mcp', 'mcp_server', 'hermes_mcp'),
|
||||
]
|
||||
|
||||
|
||||
DELAYED = {'camera', 'vision', 'audio'}
|
||||
DELAYED = {'camera', 'vision', 'audio', 'mcp'}
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
@@ -35,7 +36,7 @@ def generate_launch_description():
|
||||
actions = [DeclareLaunchArgument(arg, default_value='true', description=f'run the {name} node')
|
||||
for arg, _, _, name in NODES]
|
||||
actions.append(DeclareLaunchArgument('start_delay', default_value='6.0',
|
||||
description='seconds the camera, vision and audio wait for the face'))
|
||||
description='seconds the other nodes wait for the face'))
|
||||
for arg, package, executable, name in NODES:
|
||||
node = Node(package=package, executable=executable, name=name, output='screen',
|
||||
parameters=[params], respawn=True, respawn_delay=2.0,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<exec_depend>hermes_face</exec_depend>
|
||||
<exec_depend>hermes_vision</exec_depend>
|
||||
<exec_depend>hermes_audio</exec_depend>
|
||||
<exec_depend>hermes_mcp</exec_depend>
|
||||
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ def test_launch_description_has_one_respawning_process_per_node():
|
||||
ld = mod.generate_launch_description()
|
||||
nodes = [e for e in ld.entities if isinstance(e, Node)]
|
||||
delayed = [n for t in ld.entities if isinstance(t, TimerAction) for n in t.actions if isinstance(n, Node)]
|
||||
assert len(nodes) + len(delayed) == len(mod.NODES) == 4
|
||||
assert len(nodes) == 1 and 'face' in mod.NODES[1] and len(delayed) == 3 # the face starts at once
|
||||
assert len(nodes) + len(delayed) == len(mod.NODES) == 5
|
||||
assert len(nodes) == 1 and 'face' in mod.NODES[1] and len(delayed) == 4 # the face starts at once
|
||||
# respawn lives on a private attribute that differs between ROS releases, so check the source
|
||||
src = open(os.path.join(ROOT, 'launch', 'robot.launch.py')).read()
|
||||
assert 'respawn=True' in src and 'respawn_delay' in src
|
||||
@@ -28,7 +28,13 @@ def test_launch_description_has_one_respawning_process_per_node():
|
||||
|
||||
def test_every_node_in_the_launch_file_has_parameters_and_the_face_is_mounted_portrait():
|
||||
cfg = yaml.safe_load(open(os.path.join(ROOT, 'config', 'robot.yaml')))
|
||||
for name in ('camera', 'hermes_face', 'hermes_face_detector', 'hermes_audio_state'):
|
||||
for name in ('camera', 'hermes_face', 'hermes_face_detector', 'hermes_audio_state', 'hermes_mcp'):
|
||||
assert 'ros__parameters' in cfg[name], name
|
||||
assert cfg['hermes_face']['ros__parameters']['rotation'] == 270
|
||||
assert cfg['camera']['ros__parameters']['FrameDurationLimits'] == [66666, 66666]
|
||||
|
||||
|
||||
def test_the_mcp_bridge_binds_the_usb_link_and_has_no_head_yet():
|
||||
cfg = yaml.safe_load(open(os.path.join(ROOT, 'config', 'robot.yaml')))['hermes_mcp']['ros__parameters']
|
||||
assert cfg['bind'] == '10.55.0.2' and cfg['port'] == 8765 and cfg['head'] is False
|
||||
assert 'token' not in ' '.join(cfg) # the secret lives in an env file, not here
|
||||
|
||||
Reference in New Issue
Block a user