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
22 KiB
Current state
Last updated 2026-10-04. HANDOFF.md is the brief; this file tracks where the work stands.
Hardware on hand
Raspberry Pi 4 (8 GB), BrainCraft HAT, Arducam UC-346 camera, and nothing else: no servos, no fan, no speakers, no USB-C splitter, no printed enclosure (the camera hangs loose on its ribbon and is held by hand). The Pi runs at about 62 C open-air with everything on and no fan; an enclosure will be hotter, so fit the fan before enclosing it. Playback and the servos are untested. Work until those arrive is limited to what the Pi, HAT and camera can do.
The Pi (hermes-bot)
- Ubuntu 24.04.5 server, kernel
6.8.0-1065-raspi(fully upgraded), flashed fromtalos. Userhermes(key-only; passwordless sudo), SSH keys forkyle@vade(talos) andkyle@straylight. - On Wi-Fi at
192.168.88.152(MACd8:3a:dd:0d:66:f5);eth0is not cabled. The owner says it has a static IP on the rack switch; that address is not recorded here yet. USB link:10.55.0.2(see package 0b below);ssh hermes@10.55.0.2works from talos.avahi-daemonis installed, but mDNS is off insystemd-resolvedon talos, sohermes-bot.localdoesn't resolve there; use the IP. - cloud-init applies
network-configanduser-dataon the first boot only. To change Wi-Fi later, edit/etc/netplan/50-cloud-init.yamlon the Pi, or bumpinstance_idinmeta-dataonsystem-boot.pi/50-cloud-init.yamlis a template for the Wi-Fi block; it is gitignored because it holds credentials. - Installed: ROS 2 Jazzy (
ros-base,ros-dev-tools, colcon,robot_state_publisher,xacro,camera_ros);python3-numpy/pil/spidev/lgpio/rpi-lgpio; a venv~/venv(system site packages visible) withadafruit-circuitpython-rgb-displayand Blinka. Workspace:~/ros_ws, source synced fromros/withrsync -a --delete --exclude __pycache__ ros/ hermes@<ip>:~/ros_ws/src/, thencolcon build --symlink-install. - One unit runs the whole robot:
robot.servicerunsros2 launch hermes_bringup robot.launch.pyashermes(face, camera, face detection, audio levels), so everything is ROS and the face is up whenever the robot is powered; each node is its own process, respawned by the launch file. Install withpi/install-robot-service.sh.RuntimeDirectory=robotgives lgpio a writable working directory (it drops a pipe in its cwd; systemd's default/is not writable). The face starts first and the camera, vision and audio nodes followstart_delay(6 s) later, so the Python processes do not fight over the CPU. Parameters are inros/hermes_bringup/config/robot.yaml.robot-cpu-performance.serviceholds the governor atperformance(the face costs about half the CPU at full clock).pi/trim-boot.shdisables snapd and ModemManager (unused; ~9 s of boot CPU). - Boot timing (reboot-tested, everything returns by itself): the face draws at about 28 s on the
boot clock (kernel 6.9 s,
robot.serviceat ~10 s into userspace, thenros2 launchtakes ~10 s to start on a cold Pi, then the face ~3 s); camera, audio and vision are up by ~37 s. The owner is fine with up to a minute, and prefers everything in ROS to a faster non-ROS face; a splitrobot-face.servicethat skipped the launch framework only saved 3.7 s and was dropped. - Steady-state CPU with everything running (4 cores): face ~76%, camera ~57% (15 fps, from 30
at ~40% alone), detector ~40% (was 131%), audio ~12%; about half the machine idle, 62 C, no
throttling, no fan fitted. Camera
FrameDurationLimitsis the lever (15 fps ~20% alone, 10 fps ~14%); JPEG quality barely matters. OpenCV's DNN spins every core and burns 3-4x its elapsed time in CPU, so the detector node pins it to one thread (YuNet 86 ms, SFace 161 ms of CPU), decodes the JPEG at half size (IMREAD_REDUCED_COLOR_2, boxes scaled back to full-size pixels) and re-identifies a tracked face at most once a second (IdentityCache). ros2 node listwith its default short discovery window often shows only some of the nodes; useros2 node list --no-daemon --spin-time 6(all four appear), or check topics.- ROS never leaves the Pi (owner's decision):
ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST(set inpi/robot-env.sh, whichsources everything) and the firewall drops domain 42's DDS ports (17900-18149) on every interface but loopback.hermes_mcpis the only bridge to the outside; a host that wants data from the robot gets it through an MCP tool, not DDS. This supersedes HANDOFF.md'shost/ros-shelltalking to the robot. (ROS's setup script exportsSUBNET, so the variable must be assigned, not defaulted.) - PipeWire (1.0.5, WirePlumber 0.4.17) runs as
hermes's user services (loginctl enable-linger).~/.config/pipewire/pipewire-pulse.conf.d/50-network-server.conf(frompi/pipewire/) makes pipewire-pulse listen on TCP 4713 on all interfaces; the firewall is the protection. Defaults are the WM8960 source and sink (alsa_input/output.platform-soc_sound.stereo-fallback). Verified from talos:pactl -s tcp:10.55.0.2:4713 infoworks; the same on the Wi-Fi address times out.
Package 0a (platform proof on Ubuntu 24.04)
- Step 1 done:
host/check-pi-platform.shpasses. - Step 2 done: flashed, booted, upgraded; ROS installed;
ros2 topic listworks. - Step 5 (display) done. ST7789 works on
lgpiothrough Blinka (rpi-lgpio, noRPi.GPIO). Two gotchas: passcs=None(the kernel drives CE0; claiming GPIO8 raises "GPIO busy"), and the 240x240 glass sits aty_offset=80in the controller's RAM, for every rotation (the driver rotates in software and leaves the scan direction fixed). Mounted pose (microSD end up, USB-C edge on the left) needs content turned 90 degrees clockwise:rotation=270. Full-frame push is ~23 fps through the driver; the face pushes raw RGB565 with_blockand reaches ~27-28 fps. - Camera: works with the Raspberry Pi libcamera fork, not with the stock stacks. The OV5647 is
detected (
ov5647 10-0036) and Ubuntu's libcamera 0.2.0 lists it oncelibcamera-ipais installed, but capture dies in the Pi IPA (assertion "it != buffers_.end()" in prepareIsp()), and ROS Jazzy's libcamera 0.7.2 dies withFATAL … A list of V4L2 controls requires a ControlInfoMapright afterconfigure. Both are deterministic whatever the stream format or size. Fix: build the fork (github.com/raspberrypi/libcamera, commit6c1dd9d, 2026-08-17) with-Dpipelines=rpi/vc4 -Dipas=rpi/vc4 -Dcam=enabled -Dv4l2=false …into/opt/libcamera-rpi; itscamthen captures 1296x972 RGB888 at ~16 fps (dim room).pi/capture_still.pytakes a still with it.camera_rosstill has to be built against this fork (the apt one uses ROS's broken libcamera). Frames are padded (stride 3936) and B,G,R in memory. camera_ros0.7.0 is built from source against the fork in~/camera_ws(overlay on ROS; at runtime the fork must come first inLD_LIBRARY_PATH, whichsource ~/robot-env.shdoes; copy frompi/robot-env.sh). Verified:ros2 run camera_ros camera_node --ros-args -p width:=1296 -p height:=972 -p format:=RGB888publishes/camera/image_raw,/camera/image_raw/compressed(~26 Hz) and/camera/camera_info. It costs ~140% of a core with raw and compressed both on; lower the frame rate or size before running everything together. No calibration file yet.ros/hermes_vision:hermes_vision.detect(YuNet, Haar fallback; no ROS imports) and a nodeface_detectorthat subscribes to the compressed image and publishes/vision/faces(vision_msgs/Detection2DArray, bbox centre/size in pixels,class_id: face, score), atrate_hz(default 4, ~47% of a core on a Pi 4);publish_debug:=trueadds an annotated image. CLI:python -m hermes_vision.detect_cli IMAGE. YuNet 2023mar needs OpenCV 4.8+, so~/venvhasopencv-python-headless==4.10.0.84withnumpy<2(system NumPy stays 1.26.4 for ROS), androbot-env.shputs the venv first onPYTHONPATH; YuNet also needs input sides that are multiples of 32. Tested on a sample image and on a live frame (one face found, upright, no flip needed).- Audio capture works (verified 2026-10-04). The stock
wm8960-soundcardoverlay fails every stream withwm8960: No MCLK configuredon Ubuntu 24.04: it puts the 12.288 MHz fixed clock on the sound card's codec link, but the codec driver readsmclkfrom its own node.pi/overlays/hermes-wm8960.dtsis the stock overlay plusclocks/clock-nameson the codec node;pi/install-audio-overlay.shcompiles it to/boot/firmware/overlays/hermes-wm8960.dtbo, setsdtparam=audio=offanddtoverlay=hermes-wm8960. Capture then opens at 8-48 kHz. Address the card by name (hw:CARD=wm8960soundcard,DEV=0): its index changes (it was card 1, then 0). - The HAT's hardware Audio ON/OFF slide switch (right edge, by the "Audio" label) must be ON. With it off, the codec still answers on I2C and every software check looks right, but all recordings are exactly zero. That cost an hour of debugging; check it first.
- Mixer:
Left/Right Input Mixer Boost Switchdefault to off and must be on. Settings in use: Capture 47/47 (+18 dB),Left/Right Input Boost Mixer LINPUT1/RINPUT1volume 1, input mixer boost on. Singing at arm's length: RMS -34 to -49 dBFS, peaks -18 dBFS, no clipping, channels within 1-2 dB. Saved withalsactl storeand exported aspi/wm8960_asound.state; restoring at boot is not yet verified. No speakers on hand, so playback is untested. - Not done: PWM (waits for servos). Ask before rebooting the Pi.
Package 0b (USB link): Pi side and talos side working
- Pi:
dtoverlay=dwc2,dr_mode=peripheral;robot-usb-gadget.servicebuilds a configfs gadget (NCMusb0+ ACMttyGS0, host MAC02:68:65:72:6d:01, robot MAC02:68:65:72:6d:02) before networking;serial-getty@ttyGS0is enabled;/etc/netplan/60-usb0.yamlgivesusb010.55.0.2/30with no gateway, so the default route stays on Wi-Fi. Installed bypi/install-usb-gadget.sh(a backup ofconfig.txtisconfig.txt.bak-usbgadget). NCM works on the Pi 4 with kernel 1065; ECM was not needed. - netplan gotcha: match the gadget interface by
name: usb0. Amacaddress:match becomesPermanentMACAddress=in networkd, which a gadget interface lacks, so it stays unmanaged. - talos (Omarchy, NetworkManager):
host/omarchy/robot-usb-link.sh(needs sudo) adds profilehermes-robot-usb:10.55.0.1/30on the interface with MAC02:68:65:72:6d:01, priority 100, never-default. Verified from talos: ping ~2.4 ms, SSH over the link. - Firewall:
robot-firewall.serviceloadspi/nftables-robot.nftbefore networking at every boot (pi/install-firewall.sh; it must be loaded before the PipeWire network server is enabled). It accepts 4713/8765/8766 only onusb0from10.55.0.1, drops them elsewhere, and drops DDS on every interface but loopback. Verified: from talos, 4713 connects overusb0and times out over Wi-Fi, while an unused port on the Pi's Wi-Fi address refuses instantly (so only 4713 is blocked). SSH is deliberately left open on all interfaces (key-only), for V2; the owner's call. - Not yet verified: serial login prompt (talos's
/dev/ttyACM0isroot:uucp; usesudo screen /dev/ttyACM0 115200). Thehermesaccount has no password, so the console shows a login prompt but cannot log in until the owner sets one; no autologin by design. Also not done: the Pi booting with Wi-Fi off, replug and second-host recovery,get_throttledafter a servo sweep on the splitter, the NixOS snippet, and straylight/other-host testing. Currently the Pi is powered from talos's USB port (bench test, no servos).
Face (ros/hermes_face)
render.py: numpy port ofdocs/face-prototype.html, flat face only (head and lowpoly not ported). Phosphor and vector styles. States:sleeping idle listening thinking looking speaking.sleeping(new) is the "no agent connected" pose: eyes closed to thin lines, mouth short, dim and slowly breathing.lookingreturns to the state it interrupted. Tone mapping is a lookup table (RGB and RGB565 tables), glow is computed at half resolution, rotation is a freenp.rot90.display.py: ST7789 driver plus an async pusher (render overlaps the SPI transfer).face_node.py: nodehermes_face; subscribes/hermes/state(std_msgs/String) and/joint_states(pan_joint,tilt_joint, radians); starts ininitial_state(sleeping), so it runs with nothing else up. Parameters:rotation(270),x_offset(0),y_offset(80),baudrate,fps,output(st7789ornone),phosphor(P7),style,persistence_ms,focus,jitter,intensity,initial_state.demo.py: no ROS.python -m hermes_face.demo --sequencecycles the states on the panel;--png-dir DIRwrites a PNG per state without hardware.- Tests:
python3 -m pytest testinros/hermes_face(25 pass on the Pi). Renderer parameters (focus,persistence_ms,intensity,jitter) are clamped;fpsis clamped to 1-60. - Measured on the Pi 4: ~9 ms per frame in isolation. With the CPU at full clock: 24% of a core at
20 fps and 40% at 30 fps (target was under 40% at 20 fps or more). Under
ondemandthe same load reads 40% at 20 fps and ~67% at 30 fps. No C++ rewrite was needed. - Gaze: the node subscribes to
/vision/faces(and/camera/camera_infofor the image size) and the eyes glance toward the best face: a recognised one first, then the biggest. Image x flips on the display (a person on the image's right is on the robot's right, the viewer's left); the glance is smoothed, held 2.5 s, then drifts back to centre. Sleeping ignores it. The pan sign in the renderer was wrong (carried over from the prototype) and is fixed: positive pan = the robot's left = the viewer's right, positive tilt = looks up, as the MCPturn_headtool documents. Confirmed by the owner on the real display (2026-10-04): the glance directions look right. An earlier run showed nothing because the one-off wake message was lost (volatile QoS drops a sample published before discovery matches); test publishers must republish, ashermes_mcpwill. The node logs every state change (state: idle (agent=..., audio=...)), so behaviour can be checked fromjournalctl -u robot-facewithout watching the panel. - Agent heartbeat:
/hermes/statedoubles as the agent's heartbeat.hermes_mcpmust republish its state about once a second; afteragent_timeout_s(default 10, 0 disables) with none, the face logsagent gone, sleepingand sleeps, so "sleeping" means "no agent connected". Verified: woke on messages, slept 10 s after the last. - Not yet: audio levels for the mouth (
/audio/levels),hermes_mcppublishing states, launch file inhermes_bringup, head/lowpoly shapes, DotStar LEDs, button mute. - Open: IPS black level. The backlight is on at full brightness, so black reads dark gray on camera. Dimming it needs a software PWM on GPIO26 (not a hardware PWM pin).
Audio levels and the literal voice trace (ros/hermes_audio, face integration)
hermes_audionodeaudio_statetaps the default source and the default sink's monitor withparec(16 kHz mono) and publishes/audio/levels([mic_dbfs, speaker_dbfs]),/audio/activity(speaking|listening|quiet) and trigger-locked waveform windows/audio/scope/{mic,speaker}(256 samples). Audio itself never goes through ROS.hermes_audio.levels.Activitydebounces and keeps an adaptive noise floor: a 3 s start-up calibration, then it drops fast and rises slowly, and anything 'listening' without a gap for 8 s is absorbed as ambient noise. The microphone is ignored while speaking (it would hear the robot).- The face arbitrates (
hermes_face.arbiter): sleeping (agent side only) > speaking > listening > thinking > idle. Room sound never wakes a sleeping face: sleeping means "no agent connected".lookingstays a timed event owned by the renderer. While fresh (< 0.35 s) scope data exists, the mouth draws the real waveform (speaker when speaking, mic when listening), auto-scaled. - WirePlumber resets the codec's capture gain to 100% (+30 dB) when it takes the device,
overriding
alsactl's saved level. Fix:pactl set-source-volume @DEFAULT_SOURCE@ 63%(PipeWire's scale is cubic: 63% is -12 dB from the +30 dB maximum, so ALSACapturelands at 46, about +17 dB). WirePlumber remembers it; recheck after a reboot. The quiet-room floor at this gain is about -43 dBFS (preamp and mic noise). - Confirmed by the owner: the mouth draws their live voice and its size follows how close and loud they are (proportional). The activity detector was retuned after the log showed the face flickering between listening and idle about twice a second: margin 7 dB (quiet-room noise peaks sit at about +3 dB over the floor), hold 1.2 s so pauses between words do not drop it. The flicker fix is covered by unit tests but not yet re-confirmed live (the retest window was silent).
- Verified on the Pi: the
speakingpath (a tone played to the sink, with no speaker attached, shows on the monitor at -13.5 dBFS,speakingfor its length, mic ignored); levels and scope publish; 49 face tests, 10 audio tests. Not yet verified: the audio node as a service (it was run as a transient unit).
The MCP bridge (ros/hermes_mcp)
The only way in or out of the ROS graph. Serves MCP (SDK 2.3, streamable HTTP) on
http://10.55.0.2:8765/mcp, the USB address only (retrying the bind if the link is not up yet), with
a bearer token. Tool names are the stable contract with Hermes: look(), and turn_head /
head_position only when a head is fitted. With no servos only look is registered, by design
(head: false; the node refuses head: true until hermes_head exists). look() waits for the next
camera frame taken after the call (never a stale one) and passes the camera's JPEG through (1296x972,
~155 KB); it also publishes looking on /hermes/state so the face squints and flashes.
While an authenticated client is connected (in flight, or heard within connected_grace_s = 30) it
republishes idle on /hermes/state once a second: the agent heartbeat that wakes the face. Verified
end to end from talos over the USB link with a real MCP client: connects, lists look, look()
returns a JPEG in 0.08 s, a wrong token is refused, the Wi-Fi path is dropped by the firewall, and
the face went looking -> idle on connect and agent gone, sleeping about 40 s after the client
left. The token is in /etc/robot-mcp.env (root:hermes, mode 640), generated by
pi/install-robot-service.sh and never printed; read it with sudo cat. The MCP SDK is in ~/venv
(pip install "mcp>=2"). The old pi/robot_mcp_server.py, pi/robot-mcp.service and pi/install.sh
are superseded (package 5 rewrites the installer). Not yet verified: a real Hermes client, and
whether Hermes passes MCP image content to the model (HANDOFF package 6).
Face recognition (opt-in)
hermes_vision.recognize / people: SFace embeddings on YuNet's landmarks. Nothing is stored until
someone is enrolled (python -m hermes_vision.people enroll NAME, live from the camera topic, or
enroll-images); only 128-number embeddings are kept, in ~/.local/share/hermes/faces.json (mode
0600, directory 0700, written atomically), never images. list, forget NAME and check IMAGE
manage and test it. The detector node puts a recognised name in Detection2D.id and picks up
database changes without a restart; its log prints a count, not names. Checked with a throwaway
database: 8 of 9 unenrolled frames of the same person were recognised, down to side profiles
(similarity 0.52-0.66), the miss being a head tipped back (0.34 against the 0.363 threshold); an
unrelated photo scored 0.16. Nobody is enrolled yet; the owner enrols themselves live.
Next
- Finish package 0b: Wi-Fi-off boot test, replug recovery, serial login check, NixOS snippet in
host/nixos/. A new Omarchy host with a GPU (runs the local model "ornith") arrives this week: it needs onlyhost/omarchy/robot-usb-link.shand its SSH key added to the Pi. hermes_headand the servos when they arrive (thenhead: true), echo cancel and therobot_micnaming once speakers arrive, a fan before any enclosure, the Wi-Fi-off boot test, a real Hermes client againsthermes_mcp, and the rest ofHANDOFF.md.- Confirm the retuned listening detector live (the flicker fix is unit-tested only); a second person in frame for the recognition test (not possible on 2026-10-04).
Repository
Branch master. .gitignore covers .remember/, caches, .venv/, *.orig, *.rej and
pi/50-cloud-init.yaml. tools/check-pi-platform.sh duplicates host/check-pi-platform.sh and can
probably go.
Security review notes (parked until the USB link is up)
An automated review of the initial commit flagged three items. All stem from the code predating the USB-gadget design (HANDOFF.md package 0b); none is exposed until the Pi runs the services.
pi/robot_mcp_server.py: plain HTTP,ROBOT_MCP_BINDdefaults to0.0.0.0. Plan: default to10.55.0.2, start afterusb0.pi/pipewire/50-network-server.conf: TCP 4713 on all interfaces, unrestricted. Plan: keep, comment that the firewall is the protection.pi/nftables-robot.nft: still the Pi Zero version (single host IP, any interface). Package 0b rewrites it to accept only on the USB interface from10.55.0.1.pi/install.shpermissions looked fine (/etc/robot-mcp.envisroot:$USER, 640).
The owner wants to confirm the real IPs on the link before changing any of this. If the USB link doesn't work, revisit: TLS or a Tailscale-only bind for MCP and audio.