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

+223
View File
@@ -862,6 +862,229 @@ class TestRealSetupMenu:
# (``selector.text.multiline``) — the value the frontend actually reads.
class _FakePairingClient:
"""The async pairing surface the flows drive (t15) — a double, no socket.
Only what the flows call: generate/start/finish plus ``disconnect`` for the
throwaway session. ``finish_error`` reproduces a wrong PIN.
"""
def __init__(self, finish_error: Exception | None = None):
self.finish_calls: list[str] = []
self.disconnect_calls = 0
self._finish_error = finish_error
async def async_generate_cert_if_missing(self) -> None:
return None
async def async_start_pairing(self) -> None:
return None
async def async_finish_pairing(self, code: str) -> None:
self.finish_calls.append(code)
if self._finish_error is not None:
raise self._finish_error
def disconnect(self) -> None:
self.disconnect_calls += 1
async def async_get_name_and_mac(self) -> tuple[str, str]:
"""The setup flow asks for the device identity right after pairing (t15)."""
return "Shield", "AA:BB:CC:DD:EE:FF"
class TestRealOptionsProtocolPairing:
"""t15: switching the transport ON on a never-paired entry must PAIR.
The live report: the transport was switched on, NO PIN prompt appeared, and the
remote then looked unpaired whether it was or not. Measured cause: the certificate
from setup is reused by design (so a PAIRED entry correctly asks for nothing), and
the library has no ``is_paired`` — which is why the marker is persisted and why an
unpaired entry is routed into the pairing step instead of being let loose.
"""
@staticmethod
def _connection_page(entry, **overrides):
"""The options Connection page as the real frontend submits it (t60)."""
page = {
"host": entry.options.get("host", "10.0.0.9"),
"adb_port": 5555,
"adb_enabled": True,
"adb_store_app_info": True,
"protocol_enabled": True,
"logcat_events": False,
"adb_command_enabled": False,
"screen_capture_enabled": False,
"jellyfin_entity": "",
"power_entity": "",
}
page.update(overrides)
return page
@staticmethod
def _fake_pairing(monkeypatch, client):
"""Answer the module-level pairing builder with a client double (t15)."""
async def _start(hass, host, client_name):
return client, None
monkeypatch.setattr(cf, "_async_start_protocol_pairing", _start)
async def _open_connection(self, ha, entry):
manager, menu = await open_options(ha, entry)
page = await manager.async_configure(
menu["flow_id"], {"next_step_id": "connection"}
)
assert page["type"] == "form" and page["step_id"] == "connection"
return manager, page
async def test_enabling_the_transport_on_an_unpaired_entry_pairs(self, ha, monkeypatch):
entry = make_entry(ha, options={"host": "10.0.0.9", "protocol_enabled": False})
client = _FakePairingClient()
self._fake_pairing(monkeypatch, client)
manager, page = await self._open_connection(ha, entry)
result = await manager.async_configure(
page["flow_id"], self._connection_page(entry, protocol_enabled=True)
)
assert result["type"] == "form", result
assert result["step_id"] == "protocol_pair", (
"an unpaired entry must not end up running an unauthenticated client"
)
assert result["data_schema"] is not None
async def test_the_pin_then_marks_the_entry_paired_and_keeps_it_enabled(
self, ha, monkeypatch
):
entry = make_entry(ha, options={"host": "10.0.0.9", "protocol_enabled": False})
client = _FakePairingClient()
self._fake_pairing(monkeypatch, client)
manager, page = await self._open_connection(ha, entry)
pair_form = await manager.async_configure(
page["flow_id"], self._connection_page(entry, protocol_enabled=True)
)
after_code = await manager.async_configure(
pair_form["flow_id"], {"pairing_code": "A1B2C3", "host": "10.0.0.9"}
)
assert after_code["type"] == "menu", after_code
assert after_code["step_id"] == "init"
assert client.finish_calls == ["A1B2C3"]
assert client.disconnect_calls == 1, "the throwaway session is closed"
saved = await manager.async_configure(
after_code["flow_id"], {"next_step_id": "finish"}
)
assert saved["type"] == "create_entry"
assert saved["data"]["protocol_paired"] is True
assert saved["data"]["protocol_enabled"] is True
async def test_skipping_the_pairing_leaves_the_transport_off(self, ha, monkeypatch):
entry = make_entry(ha, options={"host": "10.0.0.9", "protocol_enabled": False})
self._fake_pairing(monkeypatch, _FakePairingClient())
manager, page = await self._open_connection(ha, entry)
pair_form = await manager.async_configure(
page["flow_id"], self._connection_page(entry, protocol_enabled=True)
)
after_skip = await manager.async_configure(
pair_form["flow_id"],
{"pairing_code": "", "host": "10.0.0.9", "skip_pairing": True},
)
assert after_skip["type"] == "menu", after_skip
saved = await manager.async_configure(
after_skip["flow_id"], {"next_step_id": "finish"}
)
assert saved["data"]["protocol_enabled"] is False, "stay off, never half-paired"
assert saved["data"]["protocol_paired"] is False
async def test_a_paired_entry_is_not_asked_for_a_pin_again(self, ha, monkeypatch):
"""(a) documented: the setup certificate is REUSED, so no new PIN."""
entry = make_entry(
ha,
options={
"host": "10.0.0.9",
"protocol_enabled": False,
"protocol_paired": True,
},
)
async def _must_not_run(hass, host, client_name): # pragma: no cover
raise AssertionError("a paired entry must not start a pairing session")
monkeypatch.setattr(cf, "_async_start_protocol_pairing", _must_not_run)
manager, page = await self._open_connection(ha, entry)
result = await manager.async_configure(
page["flow_id"], self._connection_page(entry, protocol_enabled=True)
)
assert result["type"] == "menu", result
assert result["step_id"] == "init"
class TestRealSetupProtocolPairing:
"""t15: the SETUP pairing writes the marker the options flow reads.
Both halves of the pairing contract in one place: the flow that pairs records it,
and the skip path records the opposite, so no entry can be enabled-but-unpaired
after a reload — the state the old code could not tell apart from a working one.
"""
async def _pair_through_setup(self, ha, monkeypatch, client, **form_overrides):
"""Drive setup → protocol_pair → the PIN → the setup menu → create_entry."""
async def _begin(self):
return client, None
monkeypatch.setattr(
cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _begin
)
manager, form = await open_setup(ha)
pairing = await manager.async_configure(
form["flow_id"],
first_form(adb_enabled=False, protocol_enabled=True, **form_overrides),
)
assert pairing["type"] == "form" and pairing["step_id"] == "protocol_pair"
return manager, pairing
async def test_a_successful_setup_pairing_sets_the_marker(self, ha, monkeypatch):
client = _FakePairingClient()
manager, pairing = await self._pair_through_setup(ha, monkeypatch, client)
after_code = await manager.async_configure(
pairing["flow_id"], {"pairing_code": "1234", "host": "10.0.0.9"}
)
assert after_code["type"] == "menu", after_code
assert client.finish_calls == ["1234"]
created = await manager.async_configure(
after_code["flow_id"], {"next_step_id": "finish"}
)
assert created["type"] == "create_entry", created
# t15: the marker travels with the new entry — the options flow reads it
# before it lets the transport run.
assert created["options"]["protocol_paired"] is True
assert created["options"]["protocol_enabled"] is True
async def test_skipping_the_setup_pairing_records_not_paired(self, ha, monkeypatch):
client = _FakePairingClient()
manager, pairing = await self._pair_through_setup(ha, monkeypatch, client)
after_skip = await manager.async_configure(
pairing["flow_id"],
{"pairing_code": "", "host": "10.0.0.9", "skip_pairing": True},
)
assert after_skip["type"] == "create_entry", after_skip
assert after_skip["options"]["protocol_enabled"] is False
assert after_skip["options"]["protocol_paired"] is False
assert client.finish_calls == []
class TestRealAppsMultilineField:
"""t64: the apps field is a MULTILINE text field — the live bug's cause."""