Hermes asked for structured detections instead of a bare JPEG. faces() returns the
faces in view with no photo: a persistent track id, bbox, detection confidence, how
long the person has been in view, and identity {name, similarity} or null, never a
made-up name. look() returns that same JSON as text plus the photo (send_image can
skip it); list_people() gives names and counts; robot://status and two prompts make
the server discoverable, where list_resources and list_prompts were empty. The
detector now tracks faces across frames (FaceTracker) and publishes the track id and
the identity as a second hypothesis.
Deliberately not built: embeddings in responses, age/gender attributes, auto-updating
face data, and a remote enroll tool (a prompt could make the robot call anyone "Kyle").
Checked end to end: a real Hermes turn on the local ornith model called faces() (0.03 s,
no image) and answered that Kyle was there at 0.78 similarity. Also: the camera is
calibrated (18 of 24 views, 0.296 px, outlier rejection added) and the calibration is
kept in pi/calibration and restored by the installer.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
175 lines
7.2 KiB
Python
175 lines
7.2 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
|
|
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']
|