Run the whole robot from one launch file and one boot-time unit

hermes_bringup starts the face, camera, face detector and audio levels from one
launch file with their parameters in robot.yaml, each node its own respawned
process, the face first and the others after start_delay. robot.service runs it
at boot, so everything is ROS and the face is up whenever the robot is powered.
Reboot-tested: all services and nodes return by themselves, the face draws at
about 28 s on the boot clock, the mic gain survives, nothing throttles.

Cut the CPU: the camera runs at 15 fps (20% of a core, vs 40% at 30), and the
detector went from 131% to 40% by pinning OpenCV to one thread (its DNN burns 3-4x
its elapsed time across cores), decoding the JPEG at half size, and re-identifying
a tracked face at most once a second. The audio scope is published only while
someone is speaking or listening.

A standalone face unit that skipped `ros2 launch` saved only 3.7 s and was dropped:
the owner prefers everything in ROS and is fine with up to a minute. trim-boot.sh
disables the unused snapd and ModemManager.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
This commit is contained in:
2026-10-05 00:10:09 -07:00
co-authored by Claude Sonnet 5.5
parent 5d1ad1eff7
commit d8db0a48f5
18 changed files with 332 additions and 48 deletions
+32
View File
@@ -0,0 +1,32 @@
# Parameters for the robot's nodes, keyed by node name. The MCP token and other secrets do not
# belong here: they stay in an env file readable only by the service user.
camera:
ros__parameters:
width: 1296 # the OV5647's full-field binned mode: whole field of view
height: 972
format: RGB888
FrameDurationLimits: [66666, 66666] # 15 fps: ~20% of a core, vs ~40% at 30; plenty for 4 Hz faces
jpeg_quality: 80
hermes_face:
ros__parameters:
rotation: 270 # mounted pose, microSD end up (the panel is portrait)
x_offset: 0
y_offset: 80 # the 240x240 glass sits 80 rows into the controller's RAM
fps: 30.0
phosphor: P7
style: phosphor
initial_state: sleeping # no agent connected until hermes_mcp's heartbeat says so
agent_timeout_s: 10.0
gaze_follow: true
audio_drive: true
hermes_face_detector:
ros__parameters:
rate_hz: 4.0
recognize: true # only recognises people enrolled with hermes_vision.people
publish_debug: false
hermes_audio_state:
ros__parameters:
rate_hz: 30.0
+45
View File
@@ -0,0 +1,45 @@
"""Bring up the robot: camera, face, face detection, audio levels.
ros2 launch hermes_bringup robot.launch.py [camera:=false] [face:=false] [vision:=false] [audio:=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
Python processes do not fight over the CPU while the face imports and draws its first frame.
"""
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, TimerAction
from launch.conditions import IfCondition
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
# (launch argument, package, executable, node name in robot.yaml)
NODES = [
('camera', 'camera_ros', 'camera_node', 'camera'),
('face', 'hermes_face', 'face_node', 'hermes_face'),
('vision', 'hermes_vision', 'face_detector', 'hermes_face_detector'),
('audio', 'hermes_audio', 'audio_state', 'hermes_audio_state'),
]
DELAYED = {'camera', 'vision', 'audio'}
def generate_launch_description():
params = os.path.join(get_package_share_directory('hermes_bringup'), 'config', 'robot.yaml')
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'))
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,
condition=IfCondition(LaunchConfiguration(arg)))
actions.append(TimerAction(period=LaunchConfiguration('start_delay'), actions=[node])
if arg in DELAYED else node)
return LaunchDescription(actions)
+23
View File
@@ -0,0 +1,23 @@
<?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>hermes_bringup</name>
<version>0.1.0</version>
<description>Launch file and parameters that bring up the Hermes robot.</description>
<maintainer email="noreply@example.com">K. Isom</maintainer>
<license>UNLICENSED</license>
<exec_depend>launch</exec_depend>
<exec_depend>launch_ros</exec_depend>
<exec_depend>ament_index_python</exec_depend>
<exec_depend>camera_ros</exec_depend>
<exec_depend>hermes_face</exec_depend>
<exec_depend>hermes_vision</exec_depend>
<exec_depend>hermes_audio</exec_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
+4
View File
@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/hermes_bringup
[install]
install_scripts=$base/lib/hermes_bringup
+24
View File
@@ -0,0 +1,24 @@
import os
from glob import glob
from setuptools import find_packages, setup
package_name = 'hermes_bringup'
setup(
name=package_name,
version='0.1.0',
packages=find_packages(exclude=['test']),
data_files=[
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
(os.path.join('share', package_name, 'launch'), glob('launch/*.py')),
(os.path.join('share', package_name, 'config'), glob('config/*.yaml')),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='K. Isom',
maintainer_email='noreply@example.com',
description='Launch file and parameters for the Hermes robot.',
license='UNLICENSED',
)
+34
View File
@@ -0,0 +1,34 @@
import os
import pytest
import yaml
pytest.importorskip('launch_ros')
HERE = os.path.dirname(__file__)
ROOT = os.path.dirname(HERE)
def test_launch_description_has_one_respawning_process_per_node():
import importlib.util
spec = importlib.util.spec_from_file_location('robot_launch', os.path.join(ROOT, 'launch', 'robot.launch.py'))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
from launch.actions import TimerAction
from launch_ros.actions import 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
# 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
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'):
assert 'ros__parameters' in cfg[name], name
assert cfg['hermes_face']['ros__parameters']['rotation'] == 270
assert cfg['camera']['ros__parameters']['FrameDurationLimits'] == [66666, 66666]