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
+23 -1
View File
@@ -109,6 +109,26 @@ class FacesBuffer:
return self._snapshot(time.monotonic())
class HealthBuffer:
"""The newest temperature, thermal level and throttle flags, for robot://status."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._d: dict = {}
self._t = -1.0
def update(self, **kw) -> None:
with self._lock:
self._d.update(kw)
self._t = time.monotonic()
def summary(self, max_age_s: float = 15.0) -> dict:
with self._lock:
if self._t < 0 or time.monotonic() - self._t > max_age_s:
return {'available': False}
return {'available': True, **self._d}
def people_summary(path: str) -> dict:
"""Who is enrolled on this robot: names and how many samples, read straight from the registry file
(JSON), never the face data itself."""
@@ -128,8 +148,9 @@ class Head(Protocol):
def build_mcp(frames: LatestFrame, announce_look: Callable[[], None] = lambda: None,
head: Optional[Head] = None, look_timeout_s: float = 3.0,
faces_buf: Optional[FacesBuffer] = None, people_path: str = '',
hardware: Optional[dict] = None) -> MCPServer:
hardware: Optional[dict] = None, health: Optional[HealthBuffer] = None) -> MCPServer:
faces_buf = faces_buf or FacesBuffer()
health = health or HealthBuffer()
hw = {'camera': True, 'head': head is not None, 'speakers': False, **(hardware or {})}
mcp = MCPServer(
"robot",
@@ -203,6 +224,7 @@ def build_mcp(frames: LatestFrame, announce_look: Callable[[], None] = lambda: N
'camera': {'image_size': live.get('image_size') if live['available'] else None},
'detects': ['faces'], 'not_detected': ['people', 'objects', 'motion'],
'face_detection_running': live['available'],
'health': health.summary(),
'recognition': {'enrolled': people_summary(people_path)['count'] if people_path else 0,
'registry': 'on the robot; enrolled by the owner, not through MCP; embeddings never leave it'},
'tools': sorted(['faces', 'look', 'list_people'] + (['turn_head', 'head_position'] if head else [])),
+12 -4
View File
@@ -17,11 +17,13 @@ import time
import rclpy
import uvicorn
from rclpy.node import Node
from sensor_msgs.msg import CameraInfo, CompressedImage
from std_msgs.msg import String
from sensor_msgs.msg import CameraInfo, CompressedImage, Temperature
from std_msgs.msg import String, UInt32
from vision_msgs.msg import Detection2DArray
from .bridge import FacesBuffer, LatestFrame, build_app, build_mcp
from hermes_thermal.sensors import decode_throttled
from .bridge import FacesBuffer, HealthBuffer, LatestFrame, build_app, build_mcp
class McpNode(Node):
@@ -41,6 +43,12 @@ class McpNode(Node):
self.faces = FacesBuffer()
self.create_subscription(Detection2DArray, '/vision/faces', self._on_faces, 5)
self.create_subscription(CameraInfo, '/camera/camera_info', self._on_camera_info, 1)
self.health = HealthBuffer()
self.create_subscription(Temperature, '/health/temperature',
lambda m: self.health.update(temperature_c=round(m.temperature, 1)), 2)
self.create_subscription(String, '/health/thermal_level', lambda m: self.health.update(thermal_level=m.data), 2)
self.create_subscription(UInt32, '/health/throttled',
lambda m: self.health.update(throttled=decode_throttled(m.data)), 2)
people_path = os.path.expanduser(str(param('faces_db', '~/.local/share/hermes/faces.json')))
hardware = {'speakers': bool(param('speakers', False))}
@@ -53,7 +61,7 @@ class McpNode(Node):
'not serving MCP, and no heartbeat')
return
mcp = build_mcp(self.frames, self._announce_look, head=None, look_timeout_s=look_timeout,
faces_buf=self.faces, people_path=people_path, hardware=hardware)
faces_buf=self.faces, people_path=people_path, hardware=hardware, health=self.health)
self.app, self.activity = build_app(mcp, token, self.host)
threading.Thread(target=self._serve, name='mcp-http', daemon=True).start()
self.create_timer(1.0, self._heartbeat)
+1
View File
@@ -11,6 +11,7 @@
<depend>sensor_msgs</depend>
<depend>std_msgs</depend>
<depend>vision_msgs</depend>
<exec_depend>hermes_thermal</exec_depend>
<test_depend>python3-pytest</test_depend>
+11 -1
View File
@@ -10,7 +10,7 @@ pytest.importorskip('mcp')
from starlette.testclient import TestClient # noqa: E402
from hermes_mcp.bridge import (Activity, BearerAuth, FacesBuffer, LatestFrame, build_app, build_mcp, # noqa: E402
people_summary)
HealthBuffer, people_summary)
TOKEN = 'x' * 32
@@ -172,3 +172,13 @@ def test_the_server_can_be_discovered_resources_and_prompts_are_not_empty():
assert status['detects'] == ['faces'] and 'people' in status['not_detected']
assert 'embeddings never leave it' in status['recognition']['registry']
assert 'turn_head' not in status['tools']
def test_status_reports_health_when_known_and_says_when_it_is_not():
h = HealthBuffer()
mcp = build_mcp(LatestFrame(), health=h)
unknown = json.loads(asyncio.run(mcp.read_resource('robot://status'))[0].content)
assert unknown['health'] == {'available': False}
h.update(temperature_c=63.4, thermal_level='ok', throttled={'available': True})
known = json.loads(asyncio.run(mcp.read_resource('robot://status'))[0].content)
assert known['health']['temperature_c'] == 63.4 and known['health']['thermal_level'] == 'ok'