Add the agent heartbeat; retune listening so it stops flickering

The face now treats /hermes/state as the agent's heartbeat: when nothing has been
heard for agent_timeout_s (10 s) it logs "agent gone" and sleeps, so "sleeping"
really means "no agent connected". It also logs every state change, which is how
a missed wake message was found: a one-off publish before discovery matches is
lost under volatile QoS.

The live log showed the face flickering between listening and idle about twice a
second, because the detector let go after 0.4 s. Hold is now 1.2 s so pauses
between words do not drop it, and the margin 7 dB over the adaptive floor (quiet
room noise peaks sit about 3 dB over it), with tests for both.

Confirmed by the owner on the real display: gaze directions, and a mouth that
follows their live voice proportionally.

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-04 23:43:14 -07:00
co-authored by Claude Sonnet 5.5
parent 79920ff6bc
commit 5d1ad1eff7
7 changed files with 95 additions and 15 deletions
+15 -1
View File
@@ -1,6 +1,6 @@
import pytest
from hermes_face.arbiter import resolve_state
from hermes_face.arbiter import Heartbeat, resolve_state
@pytest.mark.parametrize('agent,audio,expected', [
@@ -18,3 +18,17 @@ from hermes_face.arbiter import resolve_state
])
def test_priority(agent, audio, expected):
assert resolve_state(agent, audio) == expected
def test_heartbeat_expires_without_beats_and_recovers():
hb = Heartbeat(10.0)
assert not hb.alive(0.0) # never heard from: not alive
hb.beat(5.0)
assert hb.alive(5.0) and hb.alive(15.0)
assert not hb.alive(15.1)
hb.beat(20.0)
assert hb.alive(25.0)
def test_heartbeat_disabled_is_always_alive():
assert Heartbeat(0).alive(1e9) and Heartbeat(-1).alive(0.0)