reachy_mini_hermes / tests /test_runtime_power.py
Timbo89's picture
Publish public companion experience from c47252b6
b35c494 verified
Raw
History Blame Contribute Delete
26.3 kB
from __future__ import annotations
import threading
import time
import pytest
from reachy_mini_hermes.config import AppConfig
from reachy_mini_hermes.hermes_client import SpeechAudio
from reachy_mini_hermes.runtime import (
HermesVoiceRuntime,
PowerModeToolCall,
RealtimePlayback,
completed_camera_call_id,
completed_power_mode_call,
doa_yaw_degrees,
realtime_audio_item_id,
)
class FakeMedia:
def __init__(self) -> None:
self.recording = False
self.starts = 0
self.stops = 0
self.playing_starts = 0
self.played: list[str] = []
self.doa: tuple[float, bool] = (0.0, False)
def start_playing(self) -> None:
self.playing_starts += 1
def play_sound(self, path: str) -> None:
self.played.append(path)
def get_DoA(self) -> tuple[float, bool]:
return self.doa
def start_recording(self) -> None:
self.recording = True
self.starts += 1
def stop_recording(self) -> None:
self.recording = False
self.stops += 1
def get_frame_jpeg(self) -> bytes:
return b"test-jpeg"
class FakeRobot:
def __init__(self) -> None:
self.media = FakeMedia()
self.tracking_started: list[float] = []
self.tracking_stops = 0
self.sleep_calls = 0
def goto_sleep(self) -> None:
self.sleep_calls += 1
def start_head_tracking(self, weight: float = 1.0) -> None:
self.tracking_started.append(weight)
def stop_head_tracking(self) -> None:
self.tracking_stops += 1
@pytest.fixture(autouse=True)
def fake_robot_fold_sensor(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep unit tests independent from a live daemon's `/api/state/full`."""
original = HermesVoiceRuntime._read_head_safely_folded
def read_folded(runtime: HermesVoiceRuntime) -> bool:
robot = runtime.robot
if isinstance(robot, FakeRobot):
return runtime._head_safely_folded or robot.sleep_calls > 0
return original(runtime)
monkeypatch.setattr(HermesVoiceRuntime, "_read_head_safely_folded", read_folded)
def test_sleep_stops_microphone_and_standby_restores_local_wake() -> None:
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
motor_modes: list[bool] = []
runtime._set_motor_mode = lambda enabled, wake=False: motor_modes.append(enabled) # type: ignore[method-assign]
runtime._recording = True
robot.media.recording = True
sleep = runtime.set_power_mode("sleep")
assert sleep["power_mode"] == "sleep"
assert robot.media.recording is False
assert robot.sleep_calls == 1
assert motor_modes[-2:] == [True, False]
standby = runtime.set_power_mode("standby")
assert standby["power_mode"] == "standby"
assert robot.media.recording is True
assert motor_modes[-1] is False
def test_standby_folds_head_before_releasing_motor_torque() -> None:
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
motor_modes: list[bool] = []
runtime._set_motor_mode = lambda enabled, wake=False: motor_modes.append(enabled) # type: ignore[method-assign]
status = runtime.set_power_mode("standby")
assert status["power_mode"] == "standby"
assert robot.sleep_calls == 1
assert motor_modes == [True, False]
assert runtime._head_safely_folded is True
def test_already_folded_standby_does_not_replay_sleep_motion() -> None:
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
runtime._head_safely_folded = True
motor_modes: list[bool] = []
runtime._set_motor_mode = lambda enabled, wake=False: motor_modes.append(enabled) # type: ignore[method-assign]
runtime.set_power_mode("standby")
assert robot.sleep_calls == 0
assert motor_modes == [False]
def test_unverified_sleep_pose_keeps_torque_enabled() -> None:
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
runtime._read_head_safely_folded = lambda: False # type: ignore[method-assign]
motor_modes: list[bool] = []
runtime._set_motor_mode = lambda enabled, wake=False: motor_modes.append(enabled) # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="without a verified folded pose"):
runtime.set_power_mode("sleep")
assert motor_modes == [True, True]
assert runtime._head_safely_folded is False
def test_fold_refuses_torque_release_until_action_worker_is_idle() -> None:
class BusyActions:
pending_count = 1
def cancel(self, *, stop_media: bool = True) -> bool:
return True
def wait_idle(self, timeout: float = 5.0) -> bool:
return False
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
runtime._actions = BusyActions() # type: ignore[assignment]
motor_modes: list[bool] = []
runtime._set_motor_mode = lambda enabled, wake=False: motor_modes.append(enabled) # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="movement did not stop"):
runtime.set_power_mode("sleep")
assert robot.sleep_calls == 0
assert motor_modes == [True]
assert runtime._head_safely_folded is False
def test_sleep_motion_failure_keeps_torque_enabled_instead_of_dropping_head() -> None:
class FailingSleepRobot(FakeRobot):
def goto_sleep(self) -> None:
raise RuntimeError("movement blocked")
runtime = HermesVoiceRuntime(FailingSleepRobot(), threading.Event())
motor_modes: list[bool] = []
runtime._set_motor_mode = lambda enabled, wake=False: motor_modes.append(enabled) # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="motors remain enabled"):
runtime.set_power_mode("sleep")
status = runtime.status()
assert motor_modes == [True, True]
assert "motors remain enabled" in str(status["last_error"])
assert status["power_mode"] == "sleep"
def test_robot_pose_fails_closed_on_missing_or_non_finite_daemon_fields(monkeypatch) -> None:
class Response:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, object]:
return {
"head_pose": {"x": 0, "y": 0, "z": 0, "roll": 0, "pitch": 0},
"body_yaw": float("nan"),
}
monkeypatch.setattr("reachy_mini_hermes.runtime.httpx.get", lambda *args, **kwargs: Response())
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
with pytest.raises(RuntimeError, match="head pose is incomplete"):
runtime.robot_pose()
def test_robot_pose_converts_complete_daemon_state_to_ui_units(monkeypatch) -> None:
class Response:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, object]:
return {
"head_pose": {
"x": 0.001,
"y": -0.002,
"z": 0.003,
"roll": 0.1,
"pitch": -0.2,
"yaw": 0.3,
},
"body_yaw": -0.4,
}
monkeypatch.setattr("reachy_mini_hermes.runtime.httpx.get", lambda *args, **kwargs: Response())
pose = HermesVoiceRuntime(FakeRobot(), threading.Event()).robot_pose()
assert pose == {
"x": 1.0,
"y": -2.0,
"z": 3.0,
"roll": 5.73,
"pitch": -11.46,
"yaw": 17.19,
"body_yaw": -22.92,
}
def test_meeting_mode_has_bounded_timer() -> None:
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
runtime._set_motor_mode = lambda enabled, wake=False: None # type: ignore[method-assign]
status = runtime.set_power_mode("meeting", duration_seconds=1)
assert status["power_mode"] == "meeting"
remaining = status["meeting_seconds_remaining"]
assert isinstance(remaining, int)
assert 59 <= remaining <= 60
@pytest.mark.parametrize("mode", ["standby", "meeting", "sleep"])
def test_privacy_power_modes_invalidate_agent_session(mode: str) -> None:
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
runtime._set_motor_mode = lambda enabled, wake=False: None # type: ignore[method-assign]
runtime.set_capability_profile("agent", adult_ui_unlocked=True)
agent = runtime.status()["agent"]
assert isinstance(agent, dict)
generation = int(agent["session_generation"])
runtime.set_power_mode(mode, duration_seconds=60)
assert runtime.agent_session_is_current(generation) is False
current = runtime.status()["agent"]
assert isinstance(current, dict)
assert current["current_task"] == ""
def test_awake_runs_physical_wake_motion() -> None:
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
calls: list[tuple[bool, bool]] = []
runtime._set_motor_mode = ( # type: ignore[method-assign]
lambda enabled, wake=False: calls.append((enabled, wake))
)
status = runtime.set_power_mode("awake")
assert status["power_mode"] == "awake"
assert calls[-1] == (True, True)
def test_motor_daemon_failure_is_reported_instead_of_false_standby(monkeypatch) -> None:
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
runtime._head_safely_folded = True
def fail_post(*args, **kwargs):
raise RuntimeError("daemon unavailable")
monkeypatch.setattr("reachy_mini_hermes.runtime.httpx.post", fail_post)
with pytest.raises(RuntimeError, match="disabling motor torque failed"):
runtime.set_power_mode("standby")
status = runtime.status()
assert status["motors_enabled"] is None
assert status["head_safely_folded"] is True
assert "daemon unavailable" in str(status["last_error"])
def test_wake_failure_recovers_to_confirmed_folded_standby(monkeypatch) -> None:
class FailingWakeRobot(FakeRobot):
def wake_up(self) -> None:
raise RuntimeError("wake blocked")
class Response:
@staticmethod
def raise_for_status() -> None:
return None
monkeypatch.setattr("reachy_mini_hermes.runtime.httpx.post", lambda *args, **kwargs: Response())
runtime = HermesVoiceRuntime(FailingWakeRobot(), threading.Event())
runtime._head_safely_folded = True
with pytest.raises(RuntimeError, match="wake motion failed"):
runtime.set_power_mode("awake")
status = runtime.status()
assert status["power_mode"] == "standby"
assert status["motors_enabled"] is False
assert status["head_safely_folded"] is True
assert "wake blocked" in str(status["last_error"])
def test_disable_failure_after_folding_keeps_enabled_torque_visible(monkeypatch) -> None:
class Response:
def __init__(self, fail: bool) -> None:
self.fail = fail
def raise_for_status(self) -> None:
if self.fail:
raise RuntimeError("disable rejected")
monkeypatch.setattr(
"reachy_mini_hermes.runtime.httpx.post",
lambda url, **kwargs: Response(url.endswith("/disabled")),
)
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
with pytest.raises(RuntimeError, match="disabling motor torque failed"):
runtime.set_power_mode("standby")
status = runtime.status()
assert status["power_mode"] == "standby"
assert status["motors_enabled"] is True
assert status["head_safely_folded"] is True
assert "disable rejected" in str(status["last_error"])
def test_power_transitions_are_serialized_across_clients() -> None:
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
state_lock = threading.Lock()
barrier = threading.Barrier(3)
active = 0
max_active = 0
errors: list[Exception] = []
def apply() -> None:
nonlocal active, max_active
with state_lock:
active += 1
max_active = max(max_active, active)
time.sleep(0.04)
mode = runtime._effective_power_mode()
runtime._set_status("test", mode, power_mode=mode, last_error="")
with state_lock:
active -= 1
runtime._apply_power_mode_unlocked = apply # type: ignore[method-assign]
def transition(mode: str) -> None:
barrier.wait()
try:
runtime.set_power_mode(mode)
except Exception as exc: # pragma: no cover - asserted below
errors.append(exc)
threads = [
threading.Thread(target=transition, args=("awake",)),
threading.Thread(target=transition, args=("standby",)),
]
for thread in threads:
thread.start()
barrier.wait()
for thread in threads:
thread.join(2)
assert not errors
assert max_active == 1
assert all(not thread.is_alive() for thread in threads)
def test_robot_action_execution_rechecks_confirmed_torque() -> None:
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
runtime._power_mode = "standby"
runtime._motors_enabled = False
with pytest.raises(RuntimeError, match="torque is not confirmed"):
runtime._before_robot_action()
def test_realtime_playback_remains_audible_after_generation_finishes() -> None:
playback = RealtimePlayback(item_id="item-123")
playback.add(now=10.0, duration_seconds=2.0)
playback.add(now=10.1, duration_seconds=2.0)
assert playback.audible(13.0)
assert playback.played_ms(11.5) == 1500
assert not playback.audible(14.1)
playback.reset()
assert playback.item_id == ""
assert playback.played_ms(15.0) == 0
def test_realtime_audio_item_tracking_ignores_function_calls() -> None:
function_call = {
"item": {
"id": "item-function",
"type": "function_call",
"name": "express_reachy_emotion",
}
}
assistant_message = {
"item": {
"id": "item-message",
"type": "message",
"role": "assistant",
}
}
assert realtime_audio_item_id("response.output_item.added", function_call) == ""
assert realtime_audio_item_id("response.output_item.added", assistant_message) == "item-message"
assert (
realtime_audio_item_id(
"response.output_audio.delta",
{"item_id": "item-audio", "delta": "ignored"},
)
== "item-audio"
)
def test_power_mode_tool_requires_completed_output_item() -> None:
completed: dict[str, object] = {
"item": {
"type": "function_call",
"name": "set_reachy_power_mode",
"status": "completed",
"call_id": "call-power",
"arguments": '{"mode":"meeting","duration_minutes":45}',
}
}
incomplete: dict[str, object] = {
"item": {**completed["item"], "status": "incomplete"}, # type: ignore[dict-item]
}
assert completed_power_mode_call("response.function_call_arguments.done", completed) is None
assert completed_power_mode_call("response.output_item.done", incomplete) is None
call = completed_power_mode_call("response.output_item.done", completed)
assert call is not None
assert call.call_id == "call-power"
assert call.mode == "meeting"
assert call.duration_minutes == 45
def test_standby_requests_current_conversation_to_stop_and_awake_clears_it() -> None:
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
runtime._set_motor_mode = lambda enabled, wake=False: None # type: ignore[method-assign]
runtime.set_power_mode("standby")
assert runtime._conversation_stop_requested.is_set()
runtime.set_power_mode("awake")
assert not runtime._conversation_stop_requested.is_set()
def test_power_mode_tool_applies_meeting_before_ending_realtime_session() -> None:
class Session:
def __init__(self) -> None:
self.results: list[tuple[str, dict[str, object], bool]] = []
def send_tool_result(
self,
call_id: str,
result: dict[str, object],
*,
continue_response: bool = True,
) -> None:
self.results.append((call_id, result, continue_response))
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
runtime._set_motor_mode = lambda enabled, wake=False: None # type: ignore[method-assign]
session = Session()
result = runtime._handle_power_mode_call(
session, # type: ignore[arg-type]
PowerModeToolCall("call-meeting", "meeting", 45),
)
assert result == {"ok": True, "mode": "meeting", "duration_minutes": 45}
assert runtime.status()["power_mode"] == "meeting"
assert runtime._conversation_stop_requested.is_set()
assert session.results == [("call-meeting", result, False)]
def test_manual_robot_action_auto_wakes_and_manual_stop_preserves_audio_pipeline() -> None:
class Actions:
def __init__(self) -> None:
self.queued: list[tuple[str, dict[str, object], bool]] = []
@property
def pending_count(self) -> int:
return 1
def enqueue(
self,
name: str,
arguments: dict[str, object],
*,
hold_pose: bool = False,
reject_if_busy: bool = False,
) -> dict[str, object]:
self.queued.append((name, arguments, hold_pose))
assert reject_if_busy is True
return {"accepted": True, "queued": name}
def cancel(self, *, stop_media: bool = True) -> bool:
assert stop_media is False
return True
def wait_idle(self, timeout: float = 5.0) -> bool:
return True
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
def set_motor_mode(enabled: bool, wake: bool = False) -> None:
runtime._motors_enabled = enabled
if enabled and wake:
runtime._head_safely_folded = False
runtime._set_motor_mode = set_motor_mode # type: ignore[method-assign]
actions = Actions()
runtime._actions = actions # type: ignore[assignment]
runtime.set_capability_profile("agent", adult_ui_unlocked=True)
agent = runtime.status()["agent"]
assert isinstance(agent, dict)
agent_generation = int(agent["session_generation"])
result = runtime.queue_manual_robot_action("look", "left")
assert result["ok"] is True
assert result["power_mode"] == "awake"
assert actions.queued == [("move_reachy_head", {"direction": "left"}, True)]
assert runtime.status()["power_mode"] == "awake"
stopped = runtime.stop_manual_robot_action()
assert stopped == {
"ok": True,
"robot_stopped": True,
"active_cancelled": True,
"queued_cancelled": 0,
}
assert runtime.agent_session_is_current(agent_generation) is False
assert robot.media.playing_starts == 0
def test_manual_stop_raises_when_action_worker_does_not_become_idle() -> None:
class Actions:
pending_count = 1
@staticmethod
def cancel(*, stop_media: bool = True) -> bool:
assert stop_media is False
return True
@staticmethod
def wait_idle(timeout: float = 5.0) -> bool:
assert timeout == 5.0
return False
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
runtime._actions = Actions() # type: ignore[assignment]
with pytest.raises(RuntimeError, match="did not become idle after Stop"):
runtime.stop_manual_robot_action()
def test_manual_robot_action_is_blocked_in_privacy_modes() -> None:
class Actions:
pending_count = 0
def cancel(self, *, stop_media: bool = True) -> bool:
return False
def wait_idle(self, timeout: float = 5.0) -> bool:
return True
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
runtime._set_motor_mode = lambda enabled, wake=False: None # type: ignore[method-assign]
runtime._actions = Actions() # type: ignore[assignment]
runtime.set_power_mode("sleep")
with pytest.raises(RuntimeError, match="blocked"):
runtime.queue_manual_robot_action("dance", "short")
def test_manual_stop_does_not_restore_playback_or_motion_in_privacy_mode() -> None:
class Actions:
pending_count = 1
def cancel(self, *, stop_media: bool = True) -> bool:
assert stop_media is False
return True
def wait_idle(self, timeout: float = 5.0) -> bool:
return True
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
runtime._actions = Actions() # type: ignore[assignment]
runtime._power_mode = "sleep"
runtime._privacy_requested.set()
stopped = runtime.stop_manual_robot_action()
assert stopped["active_cancelled"] is True
assert robot.media.playing_starts == 0
assert runtime._playback_stopped_for_privacy is False
with pytest.raises(RuntimeError, match="privacy"):
runtime._before_robot_action()
def test_camera_test_captures_locally_without_returning_image() -> None:
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
result = runtime.test_camera()
assert result == {"bytes": 9, "content_type": "image/jpeg"}
assert runtime.status()["camera_captures"] == 1
def test_runtime_status_reports_confirmed_motor_and_fold_state(monkeypatch) -> None:
class Response:
@staticmethod
def raise_for_status() -> None:
return None
monkeypatch.setattr("reachy_mini_hermes.runtime.httpx.post", lambda *args, **kwargs: Response())
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
runtime._head_safely_folded = False
runtime._set_motor_mode(True)
status = runtime.status()
assert status["motors_enabled"] is True
assert status["head_safely_folded"] is False
@pytest.mark.parametrize("mode", ["meeting", "sleep"])
def test_local_camera_capture_is_blocked_in_privacy_modes(mode: str) -> None:
runtime = HermesVoiceRuntime(FakeRobot(), threading.Event())
runtime._power_mode = mode
runtime._meeting_until = float("inf") if mode == "meeting" else 0.0
runtime._privacy_requested.set()
with pytest.raises(RuntimeError, match="privacy"):
runtime.camera_snapshot()
assert runtime.status()["camera_captures"] == 0
def test_camera_capture_rechecks_privacy_after_frame_arrives() -> None:
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
def enter_sleep_during_capture() -> bytes:
runtime._power_mode = "sleep"
runtime._privacy_requested.set()
return b"late-frame"
robot.media.get_frame_jpeg = enter_sleep_during_capture # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="privacy"):
runtime.camera_snapshot()
assert runtime.status()["camera_captures"] == 0
def test_camera_capture_requires_completed_output_item() -> None:
completed: dict[str, object] = {
"item": {
"type": "function_call",
"name": "capture_reachy_camera",
"status": "completed",
"call_id": "call-camera",
}
}
cancelled: dict[str, object] = {
"item": {
"type": "function_call",
"name": "capture_reachy_camera",
"status": "incomplete",
"call_id": "call-camera",
}
}
assert completed_camera_call_id("response.output_item.done", completed) == "call-camera"
assert completed_camera_call_id("response.function_call_arguments.done", completed) == ""
assert completed_camera_call_id("response.output_item.done", cancelled) == ""
def test_privacy_mode_stops_local_face_tracking() -> None:
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
runtime._set_motor_mode = lambda enabled, wake=False: None # type: ignore[method-assign]
runtime._set_face_tracking(True, weight=0.65)
assert runtime.status()["face_tracking_active"] is True
runtime.set_power_mode("meeting", duration_seconds=60)
assert robot.tracking_stops == 1
assert runtime.status()["face_tracking_active"] is False
def test_doa_angle_is_converted_to_reachy_yaw_with_deadband_and_clamp() -> None:
assert doa_yaw_degrees(1.5707963267948966) == 0.0
assert doa_yaw_degrees(0.7853981633974483) == 36.0
assert doa_yaw_degrees(0.0) == 60.0
def test_privacy_action_cancel_restarts_playback_on_standby() -> None:
class Actions:
pending_count = 0
def cancel(self, *, stop_media: bool = True) -> bool:
return True
def wait_idle(self, timeout: float = 5.0) -> bool:
return True
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
runtime._actions = Actions() # type: ignore[assignment]
runtime._set_motor_mode = lambda enabled, wake=False: None # type: ignore[method-assign]
runtime.set_power_mode("sleep")
runtime.set_power_mode("standby")
assert robot.media.playing_starts == 1
def test_doa_requires_current_speech_detection() -> None:
class Motion:
def __init__(self) -> None:
self.yaws: list[float] = []
def orient_to_sound(self, yaw: float) -> None:
self.yaws.append(yaw)
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
motion = Motion()
runtime._motion = motion # type: ignore[assignment]
config = AppConfig(doa_enabled=True)
robot.media.doa = (0.7853981633974483, False)
runtime._orient_to_voice(config)
assert motion.yaws == []
robot.media.doa = (0.7853981633974483, True)
runtime._orient_to_voice(config)
assert motion.yaws == [36.0]
def test_pipeline_playback_does_not_start_in_privacy_mode() -> None:
robot = FakeRobot()
runtime = HermesVoiceRuntime(robot, threading.Event())
runtime._set_motor_mode = lambda enabled, wake=False: None # type: ignore[method-assign]
runtime.set_power_mode("sleep")
played_before = list(robot.media.played)
result = runtime._play_response(SpeechAudio(b"audio", "audio/mpeg", ".mp3", "test"), "hello")
assert result is False
assert robot.media.played == played_before