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

185 lines
7.7 KiB
Python

import asyncio
import base64
import json
import threading
import time
import pytest
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
HealthBuffer, people_summary)
TOKEN = 'x' * 32
class FakeHead:
def move(self, pan, tilt):
return max(-80, min(80, pan)), max(-45, min(45, tilt))
def position(self):
return 0.0, 0.0
def tool_names(mcp):
return {t.name for t in asyncio.run(mcp.list_tools())}
def test_tools_have_no_head_tools_without_a_head_and_keep_their_names_with_one():
base = {'look', 'faces', 'list_people'}
assert tool_names(build_mcp(LatestFrame())) == base
assert tool_names(build_mcp(LatestFrame(), head=FakeHead())) == base | {'turn_head', 'head_position'}
assert 'enroll' not in tool_names(build_mcp(LatestFrame(), head=FakeHead())) # enrolment is the owner's step
def test_look_returns_the_next_frame_not_a_stale_one():
frames = LatestFrame()
frames.put(b'stale', time.monotonic() - 5)
announced = []
mcp = build_mcp(frames, announce_look=lambda: announced.append(1), look_timeout_s=2.0)
threading.Timer(0.3, lambda: frames.put(b'\xff\xd8fresh')).start()
result = asyncio.run(mcp.call_tool('look', {}))
text, image = result.content
assert announced == [1]
assert text.type == 'text' and json.loads(text.text)['faces']['available'] is False # no detector running here
assert image.type == 'image' and image.mime_type == 'image/jpeg'
assert base64.b64decode(image.data) == b'\xff\xd8fresh' # the frame that arrived after the call
def test_look_raises_when_the_camera_is_silent():
mcp = build_mcp(LatestFrame(), look_timeout_s=0.3)
with pytest.raises(Exception) as err:
asyncio.run(mcp.call_tool('look', {}))
assert 'camera' in str(err.value.__cause__) # the SDK wraps it; the cause is ours
def test_wait_newer_semantics():
f = LatestFrame()
t0 = time.monotonic()
assert f.wait_newer(t0, 0.1) is None # nothing yet
f.put(b'a', t0 - 1)
assert f.wait_newer(t0, 0.1) is None # older than t0
threading.Timer(0.1, lambda: f.put(b'b')).start()
assert f.wait_newer(t0, 1.0) == b'b'
def test_auth_rejects_missing_and_wrong_tokens_and_counts_only_good_requests():
app, activity = build_app(build_mcp(LatestFrame()), TOKEN, '127.0.0.1')
with TestClient(app) as client:
assert client.get('/mcp').status_code == 401
assert client.get('/mcp', headers={'Authorization': 'Bearer wrong'}).status_code == 401
assert not activity.connected(time.monotonic(), 30.0) # rejected requests are not a client
r = client.get('/mcp', headers={'Authorization': f'Bearer {TOKEN}'})
assert r.status_code != 401
assert activity.connected(time.monotonic(), 30.0) # an authenticated one is
assert not activity.connected(time.monotonic() + 31.0, 30.0) # ...until the grace runs out
def test_activity_counts_inflight_requests_as_connected():
async def run():
release = asyncio.Event()
async def slow_app(scope, receive, send):
await release.wait()
act = Activity(slow_app)
task = asyncio.create_task(act({'type': 'http'}, None, None))
await asyncio.sleep(0.05)
assert act.inflight == 1 and act.connected(time.monotonic() + 1000, 1.0) # a held stream stays connected
release.set()
await task
assert act.inflight == 0
asyncio.run(run())
def test_bearer_auth_passes_lifespan_through():
seen = []
async def inner(scope, receive, send):
seen.append(scope['type'])
asyncio.run(BearerAuth(inner, TOKEN)({'type': 'lifespan'}, None, None))
assert seen == ['lifespan']
def face(track_id, x, name='', sim=0.0, conf=0.9, w=300, h=400):
return {'track_id': track_id, 'bbox': {'x': x, 'y': 100, 'w': w, 'h': h}, 'confidence': conf, 'name': name, 'similarity': sim}
def call(mcp, name, args=None):
return asyncio.run(mcp.call_tool(name, args or {}))
def test_faces_reports_identity_null_for_a_stranger_and_never_makes_up_a_name():
buf = FacesBuffer()
buf.put([face(1, 100, 'Kyle', 0.82), face(2, 600)])
out = call(build_mcp(LatestFrame(), faces_buf=buf), 'faces').structured_content
assert out['available'] and out['count'] == 2 and out['image_size'] == {'w': 1296, 'h': 972}
kyle, stranger = out['faces']
assert kyle['identity'] == {'name': 'Kyle', 'similarity': 0.82}
assert stranger['identity'] is None # unknown, not guessed
assert set(kyle) == {'track_id', 'bbox', 'bbox_norm', 'confidence', 'identity', 'seen_for_s'}
assert 'embedding' not in json.dumps(out)
def test_track_ids_persist_and_seen_for_grows():
buf = FacesBuffer()
buf.put([face(7, 100)], t=time.monotonic() - 3.0)
buf.put([face(7, 105)])
(f,) = buf.summary()['faces']
assert f['track_id'] == 7 and 2.5 < f['seen_for_s'] < 3.6
def test_faces_says_when_detection_is_not_running_instead_of_reporting_an_empty_room():
out = call(build_mcp(LatestFrame(), faces_buf=FacesBuffer()), 'faces').structured_content
assert out['available'] is False and 'reason' in out
buf = FacesBuffer()
buf.put([face(1, 100)], t=time.monotonic() - 30) # stale data is not "now"
assert buf.summary(max_age_s=1.5)['available'] is False
def test_an_empty_room_is_available_with_zero_faces():
buf = FacesBuffer()
buf.put([])
assert buf.summary()['available'] and buf.summary()['count'] == 0
def test_look_can_skip_the_photo():
frames, buf = LatestFrame(), FacesBuffer()
buf.put([face(1, 100, 'Kyle', 0.8)])
frames.put(b'\xff\xd8x')
result = call(build_mcp(frames, faces_buf=buf), 'look', {'send_image': 'never'})
assert len(result.content) == 1 and result.content[0].type == 'text'
assert json.loads(result.content[0].text)['faces']['faces'][0]['identity']['name'] == 'Kyle'
def test_list_people_gives_names_and_counts_never_face_data(tmp_path):
path = tmp_path / 'faces.json'
path.write_text(json.dumps({'Kyle': [[0.1] * 128] * 10, 'Alex': [[0.2] * 128] * 3}))
out = call(build_mcp(LatestFrame(), people_path=str(path)), 'list_people').structured_content
assert out == {'count': 2, 'people': [{'name': 'Alex', 'samples': 3}, {'name': 'Kyle', 'samples': 10}]}
assert people_summary(str(tmp_path / 'missing.json')) == {'count': 0, 'people': []}
def test_the_server_can_be_discovered_resources_and_prompts_are_not_empty():
mcp = build_mcp(LatestFrame(), hardware={'speakers': False})
assert [str(r.uri) for r in asyncio.run(mcp.list_resources())] == ['robot://status']
assert {p.name for p in asyncio.run(mcp.list_prompts())} == {'who_is_here', 'look_around'}
status = json.loads(asyncio.run(mcp.read_resource('robot://status'))[0].content)
assert status['hardware'] == {'camera': True, 'head': False, 'speakers': False}
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'