Files
hermes-bot/ros/hermes_mcp/hermes_mcp/mcp_node.py
T
kyleandClaude Sonnet 5.5 3b65697f1e Give Hermes recognitions, not just photos: faces(), tracking ids, discovery
Hermes asked for structured detections instead of a bare JPEG. faces() returns the
faces in view with no photo: a persistent track id, bbox, detection confidence, how
long the person has been in view, and identity {name, similarity} or null, never a
made-up name. look() returns that same JSON as text plus the photo (send_image can
skip it); list_people() gives names and counts; robot://status and two prompts make
the server discoverable, where list_resources and list_prompts were empty. The
detector now tracks faces across frames (FaceTracker) and publishes the track id and
the identity as a second hypothesis.

Deliberately not built: embeddings in responses, age/gender attributes, auto-updating
face data, and a remote enroll tool (a prompt could make the robot call anyone "Kyle").

Checked end to end: a real Hermes turn on the local ornith model called faces() (0.03 s,
no image) and answered that Kyle was there at 0.78 similarity. Also: the camera is
calibrated (18 of 24 views, 0.296 px, outlier rejection added) and the calibration is
kept in pi/calibration and restored by the installer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
2026-10-05 01:21:32 -07:00

121 lines
5.3 KiB
Python

"""ROS node: the bridge between the outside world and the ROS graph, and the only way in or out.
Serves MCP (streamable HTTP, bearer token) to Hermes on the USB link and talks to the graph locally.
Subscribes: image_topic (sensor_msgs/CompressedImage, default /camera/image_raw/compressed)
/vision/faces (vision_msgs/Detection2DArray) and /camera/camera_info, for faces() and look()
Publishes: /hermes/state (std_msgs/String):
`idle` once a second while an authenticated MCP client is connected: the agent heartbeat
the face uses to wake and, when it stops, to fall asleep ("no agent connected");
`looking` when look() is called, so the face squints and flashes.
The token comes from the ROBOT_MCP_TOKEN environment variable (an env file readable only by the
service user), never from a parameter.
"""
import os
import threading
import time
import rclpy
import uvicorn
from rclpy.node import Node
from sensor_msgs.msg import CameraInfo, CompressedImage
from std_msgs.msg import String
from vision_msgs.msg import Detection2DArray
from .bridge import FacesBuffer, LatestFrame, build_app, build_mcp
class McpNode(Node):
def __init__(self):
super().__init__('hermes_mcp')
param = lambda name, default: self.declare_parameter(name, default).value # noqa: E731
topic = param('image_topic', '/camera/image_raw/compressed')
self.port = int(param('port', 8765))
self.host = str(param('bind', '10.55.0.2')) # the USB link; the firewall allows only 10.55.0.1
self.grace_s = float(param('connected_grace_s', 30.0))
look_timeout = float(param('look_timeout_s', 3.0))
if bool(param('head', False)):
self.get_logger().error('head:=true but hermes_head is not built yet: turn_head stays off')
self.state_pub = self.create_publisher(String, '/hermes/state', 10)
self.frames = LatestFrame()
self.create_subscription(CompressedImage, topic, lambda m: self.frames.put(bytes(m.data)), 1)
self.faces = FacesBuffer()
self.create_subscription(Detection2DArray, '/vision/faces', self._on_faces, 5)
self.create_subscription(CameraInfo, '/camera/camera_info', self._on_camera_info, 1)
people_path = os.path.expanduser(str(param('faces_db', '~/.local/share/hermes/faces.json')))
hardware = {'speakers': bool(param('speakers', False))}
token = os.environ.get('ROBOT_MCP_TOKEN', '').strip()
self.activity = None
self._server = None
self._stopping = False
if len(token) < 16:
self.get_logger().error('ROBOT_MCP_TOKEN is missing or too short (openssl rand -hex 32): '
'not serving MCP, and no heartbeat')
return
mcp = build_mcp(self.frames, self._announce_look, head=None, look_timeout_s=look_timeout,
faces_buf=self.faces, people_path=people_path, hardware=hardware)
self.app, self.activity = build_app(mcp, token, self.host)
threading.Thread(target=self._serve, name='mcp-http', daemon=True).start()
self.create_timer(1.0, self._heartbeat)
def _on_camera_info(self, msg: CameraInfo) -> None:
if msg.width and msg.height:
self.faces.set_size(msg.width, msg.height)
def _on_faces(self, msg: Detection2DArray) -> None:
faces = []
for d in msg.detections:
if not d.results:
continue
c = d.bbox.center.position
faces.append({
'track_id': int(d.id) if d.id.isdigit() else hash(d.id) & 0xFFFF,
'bbox': {'x': c.x - d.bbox.size_x / 2, 'y': c.y - d.bbox.size_y / 2, 'w': d.bbox.size_x, 'h': d.bbox.size_y},
'confidence': d.results[0].hypothesis.score,
'name': d.results[1].hypothesis.class_id if len(d.results) > 1 else '',
'similarity': d.results[1].hypothesis.score if len(d.results) > 1 else 0.0,
})
self.faces.put(faces)
def _announce_look(self) -> None:
self.state_pub.publish(String(data='looking'))
def _heartbeat(self) -> None:
if self.activity.connected(time.monotonic(), self.grace_s):
self.state_pub.publish(String(data='idle'))
def _serve(self) -> None:
"""Serve until stopped. The USB address may not exist yet at boot, so a failed bind retries."""
while not self._stopping:
self._server = uvicorn.Server(uvicorn.Config(self.app, host=self.host, port=self.port,
log_level='warning'))
try:
self.get_logger().info(f'serving MCP on http://{self.host}:{self.port}/mcp')
self._server.run()
except (SystemExit, OSError) as exc:
self.get_logger().warn(f'MCP server stopped ({exc!r}); retrying in 3 s', throttle_duration_sec=30.0)
time.sleep(3.0)
def destroy_node(self):
self._stopping = True
if self._server is not None:
self._server.should_exit = True
super().destroy_node()
def main(args=None):
rclpy.init(args=args)
node = McpNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()