Add a thermal guard that slows the robot before the Pi throttles
hermes_thermal reads the CPU temperature and throttle flags, publishes /health/*, and at 72 / 77 / 82 C scales the camera, face and detector rates down through their live parameters, restoring them as it cools one level at a time with a margin and a dwell so it does not flap. The face and detector gained live parameter callbacks for this, the bridge's robot://status carries a health block (temperature, level, throttle flags now and since boot), and a bringup test asserts the guard's normal rates equal the real ones. Live testing on the robot (forcing 73 / 78 / 85 C and back) caught a bug the unit tests missed: one logging line whose severity changed between warn and info raised in rclpy and killed the guard on its first cooling step, and the respawn hid it. Fixed with separate call sites; the tick now survives any error; a full heat-then-cool test drives the real node, and a mutation check confirmed it fails on the original bug. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
This commit is contained in:
@@ -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) == 5
|
||||
assert len(nodes) == 1 and 'face' in mod.NODES[1] and len(delayed) == 4 # the face starts at once
|
||||
assert len(nodes) + len(delayed) == len(mod.NODES) == 6
|
||||
assert len(nodes) == 1 and 'face' in mod.NODES[1] and len(delayed) == 5 # 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,7 @@ 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', 'hermes_mcp'):
|
||||
for name in ('camera', 'hermes_face', 'hermes_face_detector', 'hermes_audio_state', 'hermes_mcp', 'hermes_thermal'):
|
||||
assert 'ros__parameters' in cfg[name], name
|
||||
assert cfg['hermes_face']['ros__parameters']['rotation'] == 270
|
||||
assert cfg['camera']['ros__parameters']['FrameDurationLimits'] == [66666, 66666]
|
||||
@@ -38,3 +38,13 @@ 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
|
||||
|
||||
|
||||
def test_the_thermal_guards_normal_rates_equal_the_real_ones():
|
||||
cfg = yaml.safe_load(open(os.path.join(ROOT, 'config', 'robot.yaml')))
|
||||
t = cfg['hermes_thermal']['ros__parameters']
|
||||
d = cfg['camera']['ros__parameters']['FrameDurationLimits'][0]
|
||||
assert abs(t['camera_fps'] - 1e6 / d) < 0.01
|
||||
assert t['face_fps'] == cfg['hermes_face']['ros__parameters']['fps']
|
||||
assert t['detector_hz'] == cfg['hermes_face_detector']['ros__parameters']['rate_hz']
|
||||
assert t['warm_c'] < t['hot_c'] < t['critical_c'] < 85.0 # all below the Pi's hard limit
|
||||
|
||||
Reference in New Issue
Block a user