feat(remote): the app/activity picker, and a protocol transport that really connects (t15)

The user's remote displayed but offered no app picker, and the protocol
transport was never actually switched on. Both halves are fixed here.

REMOTE PARITY. The entity declares RemoteEntityFeature.ACTIVITY — exactly
what core's androidtv_remote declares — and publishes activity_list, which
is what makes the frontend render an activity picker at all. The list is
the app directory (the user's apps map, else the curated table as a floor)
in map order, then the apps ADB discovery found and something can name, in
the one name chain (user map > device label > curated > package id).
Nothing nameless is listed as a bare package id, and current_activity is
None until something is known: no activity is invented. remote.turn_on
honours activity= by waking the device when it reports itself off and then
launching through the existing launch path; a listed name resolves
case-insensitively, a package id or a detail link passes through, and
anything else raises unknown_activity naming the value instead of silently
doing nothing. An empty/absent activity keeps the previous behaviour
exactly, async_turn_off is untouched, and a configured command script still
owns the sequence.

WHY THE TRANSPORT WAS DEAD, MEASURED. androidtvremote2's keep_reconnecting
only starts _async_reconnect, whose whole body is
`while self._remote_message_protocol:` — false on the first check with
nothing connected, so the task returned immediately: no connect attempt, no
retry, and the auth callback was never reachable. HA core connects FIRST and
only then keeps it alive. Our _start_protocol called keep_reconnecting after
generating a certificate and never called async_connect anywhere, so every
send_key_command/send_launch_app_command raised ConnectionClosed internally,
the helpers swallowed it into `return False` plus a debug line, and no push
(is_available/is_on/current_app/volume_info) ever arrived.

The fix is a background connect loop: async_connect under a bounded timeout,
network failures retried with the ADB watchdog's jitter and backoff,
InvalidAuth routed into the EXISTING reauth callback (entry.async_start_reauth),
and keep_reconnecting only after a session is up. Setup never blocks on a
transport. A send that cannot run is no longer silent: it is counted, its
error recorded verbatim, and logged at WARNING with the transport state, and
the remote publishes protocol_state / protocol_connected /
protocol_ever_authenticated / protocol_last_error / protocol_failures /
protocol_certificate plus sticky per-push *_seen flags — so "sent" and
"never switched on" can never look alike again.

CASE (b) IS FIXED, NOT DOCUMENTED: switching the transport on in the options
flow for an entry that never paired now routes into the real
async_step_protocol_pair, and "continue without pairing" persists the
transport OFF. One marker carries the pairing (CONF_PROTOCOL_PAIRED),
written by the setup pairing step, the reauth finisher and the reconfigure
finisher, so no entry can sit enabled-but-unpaired. The certificate from the
first pairing is reused by design, so a paired entry is asked for no new PIN
— which is also why the marker, not certificate material, is the source of
truth.

ADB STAYS THE PRIMARY STATE SOURCE. Arming the protocol introduces pushes
that were never live before, so the reading now names its tier:
manager.foreground_source drives the media player's app_source and the
remote's activity_source, and the new tests prove a full protocol push set
leaves the media state, the now-playing reading and every ADB store field
untouched, while an ADB-only entry still serves media player, sensors, apps,
scripts and receiver with only the protocol surface degraded.

Copy: unknown_activity, activity_launch_failed and protocol_unavailable in
BOTH strings.json and translations/en.json, pinned by a test; the README
documents the picker, activity resolution, how to read the transport-state
attributes, the script bypass (a script-routed press is never evidence of
pairing), the connect-then-keep-alive lifecycle and cert reuse versus the
other integration's own pairing.

Evidence: 6 new real-HA FlowManager tests in tests/test_config_flow_real_ha.py
(unpaired -> pairs, PIN -> marker, skip -> off, paired -> not asked again, and
the setup flow's marker on success and on skip), a real-HA entity contract
test in tests/test_ha_api_drift.py (exact supported_features equality with
core, activity_list/current_activity reaching the state machine, live _attr_
updates) and new suites in tests/test_protocol_transport.py (connect-first,
keep_reconnecting alone is a no-op, retry, auth rejection, honest send
failures, ADB-primary precedence) and tests/test_command_routing.py (the
activity contract, turn_on(activity=), script ownership, the transport
attribute states). No device and no browser exist here: device behaviour is
inferred from the library and core source, and every UI-facing claim is
proven at the entity/registry/metadata level under real Home Assistant.

Verify: suites 719/4 and 720/3; real-HA glob (tests/test_adb_command_real_ha.py
+ tests/test_config_flow_real_ha.py) 56 passed, 0 skipped; ruff clean; drift
exit 0 (25 modules, 76 imports, 206 relative imports, 1322 statement blocks,
57 call arguments) with the floors raised 40->70, 100->195, 500->1300 and
52->55.
This commit is contained in:
James Mackay committed 2026-10-12 12:15:58 +01:00
1 parent 6aa3b6bfdd
commit 14c168c25e
14 files changed
+2101 -136

No files matched your search

+386 -8
View File
@@ -1,10 +1,12 @@
"""Unit tests for the protocol transport (androidtvremote2, Phase 2, t8).
Fake client mirrors AndroidTVRemote's surface (callback registration,
``keep_reconnecting`` that owns its own reconnect task, sync ``disconnect``);
Fake client mirrors AndroidTVRemote's surface (callback registration, an
``async_connect`` that must run BEFORE ``keep_reconnecting``, a self-owned
reconnect task that is a no-op until a session exists, sync ``disconnect``);
no real gRPC / no Home Assistant. Also covers `volume_tuple` normalization
against the real library's VolumeInfo TypedDict when the package is present
(`uvx --with androidtvremote2 pytest`).
"""
import asyncio
@@ -13,9 +15,12 @@ import time
import pytest
from nsh.connection import (
FIELD_AUDIO_PLAYBACK,
FIELD_AWAKE,
FIELD_FOREGROUND,
FIELD_IS_AVAILABLE,
FIELD_IS_ON,
FIELD_RUNNING_APPS,
FIELD_SESSIONS,
FIELD_VOLUME_INFO,
ShieldConnectionManager,
@@ -24,10 +29,38 @@ from nsh.connection import (
)
class FakeProtocolClient:
"""AndroidTVRemote-shaped fake: callbacks + self-owned reconnect task."""
class InvalidAuth(Exception): # noqa: N818 - the library's own class name
"""The library's InvalidAuth, matched by NAME (the manager never imports it)."""
def __init__(self):
class CannotConnect(Exception): # noqa: N818 - the library's own class name
"""The library's CannotConnect — a retryable transport failure."""
class ConnectionClosed(Exception): # noqa: N818 - the library's own class name
"""The library's ConnectionClosed — raised when nothing is connected."""
class FakeProtocolClient:
"""AndroidTVRemote-shaped fake: the library's ORDER, reproduced.
t15: the real ``AndroidTVRemote`` keeps ``_remote_message_protocol = None`` until
``async_connect()`` succeeds, and ``keep_reconnecting()`` starts a loop whose
condition is ``while self._remote_message_protocol:`` — so calling it alone
starts a task that returns immediately and NOTHING ever connects (no push, and
every send raises ConnectionClosed). The fake reproduces that, and records which
of the two calls the manager made, so a regression back to keep_reconnecting-only
fails a test instead of silently disabling the transport.
"""
def __init__(self, connect_error: Exception | None = None):
self._remote_message_protocol = None # library attribute: None until connected
self._connect_error = connect_error
self.connect_calls = 0
self.keep_reconnecting_calls = 0
self.keys_sent: list = []
self.apps_launched: list = []
self.texts_sent: list = []
self._is_on_callbacks: list = []
self._is_available_callbacks: list = []
self._current_app_callbacks: list = []
@@ -66,19 +99,51 @@ class FakeProtocolClient:
for cb in list(self._volume_callbacks):
cb(info)
# -- lifecycle --
def keep_reconnecting(self, invalid_auth_callback=None):
"""Own task like the real library (0.1 s → 30 s backoff internally)."""
"""Own task like the real library — and, like it, a NO-OP until connected.
The condition is the library's own: with no session the task runs once,
finds nothing to maintain and returns. That is exactly why
``async_connect()`` has to come first.
"""
self.keep_reconnecting_calls += 1
self._invalid_auth_callback = invalid_auth_callback
loop = asyncio.get_event_loop()
self._reconnect_task = loop.create_task(self._reconnect_loop())
async def _reconnect_loop(self):
while True: # the real task sleeps through its internal backoff
while self._remote_message_protocol: # the library's own guard
await asyncio.sleep(0.05)
# -- connect (t15: the call the manager used to be missing entirely) --
async def async_connect(self):
"""Connect; raise ``self._connect_error`` when the test wants a failure."""
self.connect_calls += 1
if self._connect_error is not None:
raise self._connect_error
self._remote_message_protocol = object() # library sets its RemoteProtocol
# -- sends (t15: the library raises when nothing is connected) --
def send_key_command(self, key, direction="SHORT"):
if not self._remote_message_protocol:
raise ConnectionClosed("Called send_key_command after disconnect")
self.keys_sent.append((key, direction))
def send_launch_app_command(self, app_link_or_app_id):
if not self._remote_message_protocol:
raise ConnectionClosed("Called send_launch_app_command after disconnect")
self.apps_launched.append(app_link_or_app_id)
def send_text(self, text):
if not self._remote_message_protocol:
raise ConnectionClosed("Called send_text after disconnect")
self.texts_sent.append(text)
def fire_invalid_auth(self):
self._invalid_auth_callback()
if self._invalid_auth_callback is not None:
self._invalid_auth_callback()
def disconnect(self):
self.disconnect_calls += 1
@@ -95,6 +160,16 @@ async def _drain_loop():
await asyncio.sleep(0.01)
async def _connect(manager, client, tries: int = 40):
"""Wait for the manager's background connect task to make its attempt (t15)."""
for _ in range(tries):
if client.connect_calls:
await _drain_loop()
return
await asyncio.sleep(0.005)
raise AssertionError("the manager never attempted to connect the protocol")
class TestPushCallbackWiring:
async def test_is_on_and_availability_push_into_store(self):
client = FakeProtocolClient()
@@ -142,6 +217,7 @@ class TestInvalidAuthHook:
client, invalid_auth_callback=lambda: fired.append(True)
)
await manager._start_protocol()
await _connect(manager, client) # t15: a session first, then pushes/auth
client.fire_invalid_auth()
@@ -152,10 +228,202 @@ class TestInvalidAuthHook:
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
client.fire_invalid_auth() # no callback registered — must not raise
await manager.async_stop()
def _fast_backoff(manager, delay: float = 0.01) -> None:
"""Make the connect-retry backoff fast for tests (t15), per manager."""
manager._jitter = lambda _delay: delay
class TestInitialConnect:
"""t15: the transport must CONNECT, not merely be told to keep reconnecting.
The shipped bug: `_start_protocol` called `keep_reconnecting()` and nothing in
the tree ever called `async_connect()`. The library's reconnect task is
`while self._remote_message_protocol:` — None until a first connect — so the
task returned immediately: no session, no push, every send raising
ConnectionClosed inside a swallowed handler, for every install. These tests pin
the order (connect first) and the state it reports.
"""
async def test_connects_before_handing_over_to_keep_reconnecting(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
assert client.connect_calls == 1
assert client.keep_reconnecting_calls == 1
assert client._remote_message_protocol is not None, "a session exists now"
assert client._reconnect_task is not None, (
"keep_reconnecting only maintains a session; a task with no session "
"would be the pre-t15 no-op"
)
assert manager.protocol_state == "connected"
assert manager.protocol_connected is True
assert manager.protocol_ever_authenticated is True
assert manager.protocol_last_error is None
await manager.async_stop()
async def test_keep_reconnecting_alone_would_stay_unconnected(self):
"""The fake reproduces the library: without async_connect nothing happens."""
client = FakeProtocolClient()
client.keep_reconnecting(lambda: None)
await _drain_loop()
assert client._reconnect_task.done(), (
"the library's loop returns at once with no session — which is why "
"keep_reconnecting() alone never connected anything"
)
assert client.connect_calls == 0
async def test_network_failure_keeps_retrying_and_stays_never_connected(self):
client = FakeProtocolClient(connect_error=CannotConnect("unreachable"))
manager = _protocol_manager(client)
_fast_backoff(manager)
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_state == "never_connected"
assert manager.protocol_connected is False
assert "CannotConnect" in (manager.protocol_last_error or "")
assert client.keep_reconnecting_calls == 0, (
"nothing to maintain until a session exists"
)
for _ in range(200): # the retry loop must keep trying
if client.connect_calls >= 2:
break
await asyncio.sleep(0.005)
assert client.connect_calls >= 2, "a network failure is retried with backoff"
assert manager.protocol_ever_authenticated is False
await manager.async_stop()
async def test_auth_rejection_stops_and_runs_the_reauth_hook(self):
client = FakeProtocolClient(connect_error=InvalidAuth("Need to pair"))
fired = []
manager = _protocol_manager(
client, invalid_auth_callback=lambda: fired.append(True)
)
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_state == "auth_rejected"
assert fired == [True], "the entry's reauth/pair flow is the answer"
assert "InvalidAuth" in (manager.protocol_last_error or "")
assert client.keep_reconnecting_calls == 0
connect_calls = client.connect_calls
await asyncio.sleep(0.05)
assert client.connect_calls == connect_calls, (
"retrying a rejected certificate can never succeed"
)
await manager.async_stop()
async def test_auth_rejection_after_a_session_marks_the_state(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_state == "connected"
client.fire_invalid_auth()
assert manager.protocol_state == "auth_rejected"
assert manager.protocol_last_error # readable, not only logged
await manager.async_stop()
class TestHonestSendFailures:
"""t15: a send that could not run must never look like one that did."""
async def test_send_without_a_session_warns_and_records(self, caplog):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
client._remote_message_protocol = None # session lost (library no-ops sends)
with caplog.at_level("WARNING"):
sent = await manager.send_protocol_key("POWER")
assert sent is False
assert manager.protocol_failures == 1
assert "POWER" in (manager.protocol_last_error or "")
assert "ConnectionClosed" in (manager.protocol_last_error or "")
assert any(
"did not run" in rec.message for rec in caplog.records
), "the failure is a WARNING carrying what happened"
assert client.keys_sent == []
await manager.async_stop()
async def test_launch_without_a_transport_warns_and_records(self, caplog):
manager = _protocol_manager(None) # protocol disabled for this entry
with caplog.at_level("WARNING"):
sent = await manager.launch_app("com.netflix.ninja")
assert sent is False
assert manager.protocol_failures == 1
assert "no protocol transport" in (manager.protocol_last_error or "")
assert any("did not run" in rec.message for rec in caplog.records)
await manager.async_stop()
async def test_a_working_send_is_not_counted_as_a_failure(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
assert await manager.send_protocol_key("POWER") is True
assert await manager.launch_app("com.netflix.ninja") is True
assert client.keys_sent == [("POWER", "SHORT")]
assert client.apps_launched == ["com.netflix.ninja"]
assert manager.protocol_failures == 0
assert manager.protocol_last_error is None
await manager.async_stop()
async def test_availability_push_drives_the_connected_state(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
client.push_is_available(False)
await _drain_loop()
assert manager.protocol_state == "disconnected"
assert manager.protocol_connected is False
client.push_is_available(True)
await _drain_loop()
assert manager.protocol_state == "connected"
await manager.async_stop()
async def test_a_push_proves_an_authenticated_session(self):
"""The library exposes no is_paired: a push is the only proof there is."""
client = FakeProtocolClient(connect_error=CannotConnect("down"))
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_ever_authenticated is False
client.push_current_app("com.netflix.ninja")
await _drain_loop()
assert manager.protocol_ever_authenticated is True
await manager.async_stop()
async def test_transport_state_is_off_without_a_client(self):
manager = _protocol_manager(None)
await manager._start_protocol()
assert manager.protocol_state == "off"
assert manager.protocol_connected is False
assert manager.protocol_ever_authenticated is False
await manager.async_stop()
class TestLifecycle:
async def test_stop_disconnects_lib_owned_reconnect_task(self):
"""The library owns keep_reconnecting's task; closing the client
@@ -163,6 +431,7 @@ class TestLifecycle:
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
task = client._reconnect_task
assert task is not None
@@ -315,6 +584,115 @@ class TestAdbOutageKeepsProtocolTruth:
await manager.async_stop()
class TestAdbStaysThePrimarySource:
"""t15: arming the protocol must not promote its pushes over ADB's readings.
The pushes this unit turns ON (is_on / is_available / current_app / volume) had
NEVER been live here — ``keep_reconnecting`` alone never connected — so the whole
protocol surface is new traffic next to an ADB media-session poller the user
relies on. The store's fields are disjoint by design; these tests pin that, and
that an ADB-only entry keeps working with only the protocol surface degraded.
"""
@staticmethod
def _adbreath(manager):
"""Every field the ADB poller owns, stamped, and read back."""
manager.store.set(FIELD_AWAKE, True)
manager.store.set(FIELD_SESSIONS, ["session-data"])
manager.store.set(FIELD_AUDIO_PLAYBACK, ["com.audio.last"])
manager.store.set(FIELD_RUNNING_APPS, [{"package": "com.run.now"}])
manager.store.set(FIELD_FOREGROUND, "com.adb.foreground")
return {
field: manager.store.get(field)
for field in (
FIELD_AWAKE,
FIELD_SESSIONS,
FIELD_AUDIO_PLAYBACK,
FIELD_RUNNING_APPS,
FIELD_FOREGROUND,
)
}
async def test_a_protocol_push_never_overwrites_an_adb_reading(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
before = self._adbreath(manager)
client.push_is_on(True)
client.push_is_available(True)
client.push_current_app("com.protocol.foreground")
client.push_volume_info({"level": 5, "max": 15, "muted": False})
await _drain_loop()
assert {
field: manager.store.get(field) for field in before
} == before, "ADB's own fields are untouched by every protocol push"
# ...and the protocol wrote exactly its own four
assert manager.is_on is True
assert manager.is_available is True
assert manager.current_app == "com.protocol.foreground"
assert manager.volume_info == (5.0, 15.0, False)
await manager.async_stop()
async def test_the_foreground_identity_falls_back_to_adb(self):
"""No push yet: the ADB parse answers, and nothing is invented."""
client = FakeProtocolClient(connect_error=CannotConnect("down"))
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
self._adbreath(manager)
assert manager.current_app is None, "no protocol push ever arrived"
assert manager.foreground_app == "com.adb.foreground"
assert manager.foreground_source == "adb", "the tier is named, not implied"
await manager.async_stop()
async def test_a_push_wins_while_fresh_and_the_adb_reading_survives_it(self):
"""The documented priority, and no loss: an expired push re-exposes ADB."""
now = [1000.0]
store = StateStore(ttl=60.0, clock=lambda: now[0])
client = FakeProtocolClient()
manager = _protocol_manager(client, store=store)
await manager._start_protocol()
await _connect(manager, client)
self._adbreath(manager)
client.push_current_app("com.protocol.foreground")
await _drain_loop()
assert manager.foreground_app == "com.protocol.foreground"
assert manager.foreground_source == "protocol"
assert manager.store.get(FIELD_FOREGROUND) == "com.adb.foreground", (
"the push wrote its OWN field — the ADB reading was not clobbered"
)
now[0] += 60.1 # both fields expire on the store's own TTL, not by a push
assert manager.current_app is None
assert manager.store.get(FIELD_FOREGROUND) is None
# re-stamped (the next ADB poll), the ADB reading answers again untouched
manager.store.set(FIELD_FOREGROUND, "com.adb.foreground")
assert manager.foreground_app == "com.adb.foreground"
assert manager.foreground_source == "adb"
await manager.async_stop()
async def test_an_adb_only_entry_keeps_working(self):
"""No protocol client at all: ADB truth refreshes, only the transport degrades."""
poller = FakeAdbForOutage()
poller.connected = True
poller.awake = True
manager = ShieldConnectionManager(hass=None, entry=None, poller=poller)
await manager._start_protocol()
assert manager._protocol is None
assert manager.protocol_state == "off"
assert manager.protocol_connected is False
assert manager.protocol_ever_authenticated is False
await manager.async_stop()
class TestImportGuard:
def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog):
"""F1: a protocol-enabled entry without androidtvremote2 keeps