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:
@@ -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 [])),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user