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:
2026-10-05 01:56:16 -07:00
co-authored by Claude Sonnet 5.5
parent 9e730de70e
commit ce7063f647
25 changed files with 583 additions and 15 deletions
+16 -1
View File
@@ -20,6 +20,7 @@ import math
import time
import rclpy
from rcl_interfaces.msg import SetParametersResult
from rclpy.node import Node
from sensor_msgs.msg import CameraInfo, JointState
from std_msgs.msg import Float32MultiArray, String
@@ -73,7 +74,8 @@ class FaceNode(Node):
self.create_subscription(CameraInfo, '/camera/camera_info', self._on_camera_info, 1)
self._last = time.monotonic()
self._frames, self._stat_t = 0, self._last
self.create_timer(1.0 / self.fps, self._tick)
self._timer = self.create_timer(1.0 / self.fps, self._tick)
self.add_on_set_parameters_callback(self._on_params) # the thermal guard slows the face when hot
self.get_logger().info(f'face up: {output}, {self.fps:g} fps, state {self.renderer.state}')
self._resolve(time.monotonic())
@@ -109,6 +111,19 @@ class FaceNode(Node):
self.renderer.set_pose(math.degrees(pos.get(self.pan_joint, 0.0)),
math.degrees(pos.get(self.tilt_joint, 0.0)))
def _on_params(self, params) -> SetParametersResult:
for p in params:
if p.name == 'fps':
try:
fps = min(60.0, max(1.0, float(p.value)))
except (TypeError, ValueError):
return SetParametersResult(successful=False, reason='fps must be a number')
if fps != self.fps:
self.fps = fps
self.destroy_timer(self._timer)
self._timer = self.create_timer(1.0 / fps, self._tick)
return SetParametersResult(successful=True)
def _on_camera_info(self, msg: CameraInfo) -> None:
if msg.width and msg.height:
self.image_size = (float(msg.width), float(msg.height))
+18
View File
@@ -0,0 +1,18 @@
import pytest
rclpy = pytest.importorskip('rclpy')
def test_face_fps_can_be_changed_live_and_is_clamped():
from rclpy.parameter import Parameter
from hermes_face.face_node import FaceNode
rclpy.init(args=['--ros-args', '-p', 'output:=none'])
try:
node = FaceNode()
assert node.fps == 30.0
assert node.set_parameters([Parameter('fps', value=12.0)])[0].successful and node.fps == 12.0
assert node.set_parameters([Parameter('fps', value=500.0)])[0].successful and node.fps == 60.0 # clamped
assert not node.set_parameters([Parameter('fps', value='fast')])[0].successful
node.destroy_node()
finally:
rclpy.shutdown()