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

+381 -1
View File
@@ -16,7 +16,22 @@ import pytest
from conftest import _SHIELD_DIR
from entity_stublib import install, load_entity_modules
from nsh.connection import ShieldConnectionManager
from nsh.const import APP_LABEL_EXIT_MARKER, TIVIMATE_PACKAGE
from nsh.const import (
APP_LABEL_EXIT_MARKER,
CONF_APP_NAME_MAP,
CONF_COMMAND_SCRIPTS,
DEFAULT_APP_DIRECTORY,
TIVIMATE_PACKAGE,
)
def _service_validation_error():
"""HA's ServiceValidationError, as the stub harness installs it (t15).
Read at TEST time: the autouse fixture installs the faces per test, so the class
cannot be bound at import time.
"""
return sys.modules["homeassistant.exceptions"].ServiceValidationError
@pytest.fixture(autouse=True)
@@ -356,6 +371,241 @@ class TestRemoteEntity:
assert protocol.key_calls == []
class TestRemoteActivityContract:
"""t15: the app/activity picker HA renders off ``RemoteEntityFeature.ACTIVITY``.
The user compared our remote to the default Android TV app: "it doesn't display
the full set of options" and "the UI ... doesn't actually show any buttons". HA
only offers the picker when the feature flag is set, and the list has to come
from OUR directory — ``AndroidTVRemote`` exposes no app-list API (measured), the
same reason core keeps a user-maintained map.
"""
def test_the_activity_feature_is_declared(self, modules):
entity = _remote_entity(modules)
feature = modules["remote"].RemoteEntityFeature
assert entity.supported_features & feature.ACTIVITY
def test_activity_list_follows_the_directory_order(self, modules):
entity = _remote_entity(modules)
entity._entry.options[CONF_APP_NAME_MAP] = {
"com.a.one": "One",
"com.b.two": "Two",
"com.c.three": "Three",
}
assert modules["remote"].activity_names(
{CONF_APP_NAME_MAP: entity._entry.options[CONF_APP_NAME_MAP]}
) == ["One", "Two", "Three"]
def test_the_curated_table_is_the_floor_and_names_nothing_else(self, modules):
"""Nothing is fabricated: with no user map the curated table IS the list."""
names = modules["remote"].activity_names({})
assert names == list(DEFAULT_APP_DIRECTORY.values())
assert modules["remote"].activity_names({CONF_APP_NAME_MAP: {}}) == names
def test_nameless_discovered_apps_are_skipped_not_doubled(self, modules):
"""Never "pkg=pkg": a row nothing can name is not offered as a name."""
names = modules["remote"].activity_names(
{CONF_APP_NAME_MAP: {"com.a.one": "One"}},
[
{"package": "com.a.one", "name": "One"}, # already listed
{"package": "com.d.four", "name": "Four"}, # a real label
{"package": "com.e.five", "name": ""}, # nameless
{"package": "com.f.six", "name": "com.f.six"}, # id as its name
],
)
assert names == ["One", "Four"]
def test_a_duplicate_name_is_offered_once(self, modules):
names = modules["remote"].activity_names(
{CONF_APP_NAME_MAP: {"com.a.one": "One", "com.b.one": "one", "com.c.two": "Two"}}
)
assert names == ["One", "Two"]
def test_a_blank_row_cannot_be_offered_as_a_name(self, modules):
names = modules["remote"].activity_names(
{CONF_APP_NAME_MAP: {"com.a.one": "One"}},
[{"package": "com.g.seven", "name": " "}],
)
assert names == ["One"]
def test_current_activity_resolves_the_foreground_app(self, modules):
entity = _remote_entity(modules)
entity._entry.options[CONF_APP_NAME_MAP] = {"com.netflix.ninja": "Netflix"}
assert entity._resolve_activity_name() is None, "nothing known yet"
entity._manager.store.set("foreground", "com.netflix.ninja")
assert entity._resolve_activity_name() == "Netflix"
def test_current_activity_uses_the_protocol_push_first(self, modules):
entity = _remote_entity(modules)
entity._entry.options[CONF_APP_NAME_MAP] = {
"com.netflix.ninja": "Netflix",
"com.google.android.youtube.tv": "YouTube",
}
entity._manager.store.set("foreground", "com.google.android.youtube.tv")
entity._manager._on_current_app("com.netflix.ninja")
assert entity._resolve_activity_name() == "Netflix"
assert entity.activity == "com.netflix.ninja"
async def test_turn_on_with_an_activity_launches_it(self, modules):
entity = _remote_entity(modules)
entity._entry.options[CONF_APP_NAME_MAP] = {"com.netflix.ninja": "Netflix"}
entity._manager._on_is_on(False)
protocol = entity._manager._protocol
await entity.async_turn_on(activity="Netflix")
assert protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
assert protocol.launch_calls == ["com.netflix.ninja"]
async def test_turn_on_launches_an_unknown_package_verbatim(self, modules):
"""A package id (or deep link) the directory lacks is still launchable."""
entity = _remote_entity(modules)
entity._entry.options[CONF_APP_NAME_MAP] = {"com.netflix.ninja": "Netflix"}
entity._manager._on_is_on(True) # already on: no POWER
protocol = entity._manager._protocol
await entity.async_turn_on(activity="com.example.other")
assert protocol.key_calls == []
assert protocol.launch_calls == ["com.example.other"]
async def test_an_unknown_activity_name_is_a_clear_error(self, modules):
entity = _remote_entity(modules)
entity._entry.options[CONF_APP_NAME_MAP] = {"com.netflix.ninja": "Netflix"}
protocol = entity._manager._protocol
with pytest.raises(_service_validation_error()) as raised:
await entity.async_turn_on(activity="Netflx")
assert raised.value.translation_key == "unknown_activity"
assert protocol.launch_calls == [], "nothing was silently launched"
def test_an_empty_activity_keeps_the_old_behaviour(self, modules):
entity = _remote_entity(modules)
entity._entry.options[CONF_APP_NAME_MAP] = {"com.netflix.ninja": "Netflix"}
entity._manager._on_is_on(True)
protocol = entity._manager._protocol
asyncio.run(entity.async_turn_on(activity=""))
assert protocol.key_calls == []
assert protocol.launch_calls == []
async def test_a_command_script_still_owns_the_wake_sequence(self, modules):
"""The script masks the protocol: a press through it proves nothing (t15)."""
entity = _remote_entity(modules)
served = []
entity.hass = SimpleNamespace(services=_script_services(served))
entity._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": "script.wake"}
entity._manager._on_is_on(False) # the native path would fire POWER
await entity.async_turn_on(activity="com.netflix.ninja")
assert [call[2]["entity_id"] for call in served] == ["script.wake"]
assert entity._manager._protocol.key_calls == [], "the script owns POWER"
assert entity.extra_state_attributes["power_on_route"] == "script:script.wake"
def test_the_transport_attributes_distinguish_the_states(self, modules):
entity = _remote_entity(modules)
attrs = entity.extra_state_attributes
assert attrs["protocol_active"] is True
assert attrs["protocol_state"] == "never_connected", (
"the pre-t15 transport state: wired, never connected. It must never "
"look like a healthy one"
)
assert attrs["protocol_connected"] is False
assert attrs["protocol_ever_authenticated"] is False
assert attrs["protocol_current_app_seen"] is False
assert attrs["protocol_last_error"] is None
assert attrs["protocol_failures"] == 0
assert attrs["power_on_route"] == "protocol"
def test_no_protocol_reads_as_off_not_as_unpaired(self, modules):
entity = _remote_entity(modules)
entity._manager._protocol = None
attrs = entity.extra_state_attributes
assert attrs["protocol_active"] is False
assert attrs["protocol_state"] == "off"
assert attrs["protocol_connected"] is False
def test_a_push_is_remembered_even_after_the_store_expires(self, modules):
entity = _remote_entity(modules)
entity._manager._on_current_app("com.netflix.ninja")
entity._refresh_state()
assert entity.extra_state_attributes["protocol_current_app_seen"] is True
assert entity.extra_state_attributes["protocol_ever_authenticated"] is True
entity._manager.store.clear() # TTL expiry / a cleared store
entity._refresh_state()
assert entity.extra_state_attributes["protocol_current_app_seen"] is True, (
"\"has the current app push EVER arrived?\" cannot be answered from a "
"TTL-capped store"
)
def test_a_failed_launch_is_never_silent(self, modules):
entity = _remote_entity(modules)
protocol = entity._manager._protocol
protocol.fail_all = True
with pytest.raises(_service_validation_error()) as raised:
asyncio.run(entity._async_launch_activity("com.netflix.ninja"))
assert raised.value.translation_key == "activity_launch_failed"
assert entity._manager.protocol_failures == 1
assert "com.netflix.ninja" in (entity._manager.protocol_last_error or "")
async def test_discovery_grows_the_activity_list(self, modules):
entity = _remote_entity(modules)
entity._entry.options[CONF_APP_NAME_MAP] = {"com.a.one": "One"}
entity._attr_activity_list = modules["remote"].activity_names(
entity._entry.options
)
written = []
entity.async_write_ha_state = lambda: written.append(True)
async def _rows():
return [{"package": "com.d.four", "name": "Four"}]
entity._manager.async_refresh_installed_apps = _rows
await entity._async_refresh_activity_list(entity._manager)
assert entity.activity_list == ["One", "Four"]
assert written == [True]
async def test_a_failed_sweep_changes_nothing(self, modules):
"""A failed sweep must not be cached as an answer (t62) — here, not a list."""
entity = _remote_entity(modules)
entity._entry.options[CONF_APP_NAME_MAP] = {"com.a.one": "One"}
entity._attr_activity_list = ["One"]
written = []
entity.async_write_ha_state = lambda: written.append(True)
async def _boom():
raise RuntimeError("sweep failed")
entity._manager.async_refresh_installed_apps = _boom
await entity._async_refresh_activity_list(entity._manager)
assert entity.activity_list == ["One"]
assert written == []
class TestIntentRetryWithoutATransport:
"""t41: an intent no transport served surrenders instead of delegating."""
@@ -590,6 +840,7 @@ class TestStateModelT55:
no source reported anything at all.
"""
# ── the five states ──
async def test_no_data_at_all_is_the_only_on(self, modules):
@@ -833,6 +1084,117 @@ class TestStateModelT55:
assert np == {"state": "buffering", "title": "Sky News", "via": "app"}
class TestProtocolArmedKeepsTheAdbLadder:
"""t15: the pushes this unit turns ON must not change what ADB already answers.
The protocol had NEVER been connected here, so every media-session reading the user
relies on arrived while is_on / current_app / volume stayed permanently empty.
Arming the transport adds that traffic; these tests pin the boundary the user's
constraint names — ADB media-session tracking stays the state source, no ADB store
field is touched, and an entry with no protocol at all behaves as before with only
the protocol surface degraded.
"""
@staticmethod
def _adb_player(modules):
"""A playing Netflix session, an ADB foreground, and no media entity."""
from nsh.ha_adb_common import MediaSessionInfo
player, _served, _intents = make_player(modules)
manager = player._manager
manager.store.set("awake", True)
manager.store.set(
"sessions",
[MediaSessionInfo(package="com.netflix.ninja", playing=True, title="Movie")],
)
manager.store.set("foreground", "com.netflix.ninja")
player._read = lambda eid: None
return player, manager
def test_a_full_protocol_push_set_leaves_state_and_now_playing_alone(self, modules):
player, manager = self._adb_player(modules)
before_state = player.state.value
before_now = dict(player._get_adb_now_playing())
# the transport is armed: every push the library offers arrives at once
manager._on_is_available(True)
manager._on_is_on(True)
manager._on_current_app("com.google.android.youtube.tv")
manager._on_volume_info({"level": 5, "max": 15, "muted": False})
assert player.state.value == before_state == "playing"
assert dict(player._get_adb_now_playing()) == before_now
# the ONE field whose documented priority IS the push (P3-2) — and the ADB
# reading it shadows is still in the store, untouched (see the manager suite)
assert manager.store.get("foreground") == "com.netflix.ninja"
assert player.app_id == "com.google.android.youtube.tv"
# ...and the entity NAMES the tier that won, so the push is never a silent
# override of the ADB reading the user's setup is built on (t15)
assert player.extra_state_attributes["app_source"] == "protocol"
def test_a_protocol_reported_off_is_off_while_adb_keeps_its_truth(self, modules):
"""A device that says it is off outranks a stale session — newly reachable.
Before t15 this signal could not arrive at all, so an off Shield kept showing
its last session as playing. The ADB fields are still there (the push writes
only its own), which is why a later poll answers immediately again.
"""
player, manager = self._adb_player(modules)
manager._on_is_on(False)
assert player.state.value == "off"
assert manager.store.get("sessions"), "the ADB session reading was not cleared"
def test_an_adb_only_entry_still_answers_everything_it_did(self, modules):
"""Protocol disabled: only the protocol surface degrades (the t40 ladder)."""
player, manager = self._adb_player(modules)
manager._protocol = None # the entry never enabled the transport
remote = _remote_entity(modules)
remote._manager = manager
remote._entry.runtime_data = manager
remote._entry.options = dict(player._entry.options)
remote._refresh_state() # the manager ping that re-resolves the activity
assert player.state.value == "playing"
assert player.app_name == "Netflix" # curated table, no protocol involved
assert player.source_list # the app dropdown is independent of the transport
assert remote.available is True # a passive transport is not a broken one
assert remote.activity == "com.netflix.ninja" # the ADB parse names it
assert remote.current_activity == "Netflix"
assert remote.activity_list == player.source_list
assert remote.extra_state_attributes["protocol_state"] == "off"
assert remote.extra_state_attributes["protocol_connected"] is False
# an ADB-named activity says so: the tier is disclosed, not implied
assert remote.extra_state_attributes["activity_source"] == "adb"
def test_the_new_exception_keys_have_copy_in_both_files():
"""A raised translation_key with no copy shows the user a bare key (t15).
The three keys the remote raises: an activity name nothing can launch, a launch the
protocol could not run, and an entry with no protocol transport at all.
"""
import json
from pathlib import Path
root = (
Path(__file__).resolve().parent.parent
/ "custom_components"
/ "nvidia_shield"
)
for name in ("strings.json", "translations/en.json"):
exceptions = json.loads((root / name).read_text(encoding="utf-8"))["exceptions"]
for key in ("unknown_activity", "activity_launch_failed", "protocol_unavailable"):
assert exceptions[key]["title"], (name, key)
assert exceptions[key]["message"], (name, key)
# the placeholder the entity fills in is the one the copy names
assert "{activity}" in exceptions["unknown_activity"]["message"]
assert "{activity}" in exceptions["activity_launch_failed"]["message"]
# ...and the message tells the user where the answer is readable
assert "protocol_state" in exceptions["activity_launch_failed"]["message"]
class TestChannelLeakGuard:
"""F1: the channel number never leaks to a non-channel foreground app."""
@@ -959,6 +1321,14 @@ def _remote_entity(modules):
entity._manager = manager
entity._attr_unique_id = None
entity._manager_unregistered = None
# t15: mirrors __init__ — the activity contract and the transport evidence the
# attribute payload publishes (a __new__-built entity skips __init__).
entity._attr_supported_features = remote_module.RemoteEntityFeature.ACTIVITY
entity._attr_activity_list = remote_module.activity_names(entry.options)
entity._attr_current_activity = None
entity._protocol_pushes_seen = set()
entity._protocol_certificate = "unknown"
entity._activity_refresh_task = None
return entity
@@ -966,6 +1336,16 @@ async def _noop_async(*args, **kwargs):
return None
def _script_services(served):
"""A hass.services stand-in that records (domain, service, data) (t15)."""
class _RecordingServices:
async def async_call(self, domain, service, data=None, **kwargs):
served.append((domain, service, data))
return _RecordingServices()
class FakeServices:
"""hass.services stand-in recording registrations."""