Files
kyleandClaude Sonnet 5.5 ce7063f647 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
2026-10-05 01:56:16 -07:00

50 lines
2.0 KiB
Python

import pytest
rclpy = pytest.importorskip('rclpy')
def test_the_node_constructs_and_has_the_three_targets():
"""Declaring a parameter twice raises at construction, and nothing else exercises the node."""
from hermes_thermal.thermal_node import ThermalNode
rclpy.init()
try:
node = ThermalNode()
assert [t.name for t in node.targets] == ['/camera', '/hermes_face', '/hermes_face_detector']
assert node.policy.level == 'ok'
node.destroy_node()
finally:
rclpy.shutdown()
def test_a_full_heat_then_cool_run_through_the_real_node_does_not_crash_and_steps_down_one_level_at_a_time():
"""The bug this guards against: a logging call site whose severity changed (warn heating, info cooling)
raised in the timer callback, so the node died on its first cooling step."""
from rclpy.parameter import Parameter
from hermes_thermal.thermal_node import ThermalNode
rclpy.init()
try:
node = ThermalNode()
node.act = False # no other nodes here; this is about the policy path
node.set_parameters([Parameter('dwell_s', value=0.0)])
levels = []
for temp in (60.0, 73.0, 78.0, 85.0, 60.0, 60.0, 60.0, 60.0):
node.set_parameters([Parameter('fake_temp_c', value=temp)])
node._poll() # _poll, not _tick: let any exception surface
levels.append(node.policy.level)
assert levels == ['ok', 'warm', 'hot', 'critical', 'hot', 'warm', 'ok', 'ok']
node.destroy_node()
finally:
rclpy.shutdown()
def test_the_tick_survives_an_unexpected_error():
from hermes_thermal.thermal_node import ThermalNode
rclpy.init()
try:
node = ThermalNode()
node._poll = lambda: (_ for _ in ()).throw(RuntimeError('boom'))
node._tick() # must not raise
node.destroy_node()
finally:
rclpy.shutdown()