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:
1 parent
6aa3b6bfdd
commit
14c168c25e
14 files changed
+2101
-136
No files matched your search
@@ -243,7 +243,40 @@ class MediaPlayerEntity:
|
||||
return None
|
||||
|
||||
|
||||
class RemoteEntityFeature(enum.IntFlag):
|
||||
"""homeassistant.components.remote.RemoteEntityFeature (the real values)."""
|
||||
|
||||
LEARN_COMMAND = 1
|
||||
DELETE_COMMAND = 2
|
||||
ACTIVITY = 4
|
||||
|
||||
|
||||
class RemoteEntity:
|
||||
"""The RemoteEntity surface HA reads, backed by the ``_attr_*`` convention.
|
||||
|
||||
t15: HA renders the app/activity picker off ``supported_features`` and reads
|
||||
``activity_list``/``current_activity``; both are cached properties on the real
|
||||
base, reading ``_attr_*``. The stub carries that contract (the same shape as the
|
||||
ImageEntity face above) so a test can read what the entity publishes instead of
|
||||
asserting against our own attribute names.
|
||||
"""
|
||||
|
||||
_attr_activity_list: list | None = None
|
||||
_attr_current_activity: str | None = None
|
||||
_attr_supported_features = RemoteEntityFeature(0)
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
return self._attr_supported_features
|
||||
|
||||
@property
|
||||
def activity_list(self):
|
||||
return self._attr_activity_list
|
||||
|
||||
@property
|
||||
def current_activity(self):
|
||||
return self._attr_current_activity
|
||||
|
||||
def async_write_ha_state(self) -> None:
|
||||
return None
|
||||
|
||||
@@ -474,6 +507,14 @@ def install() -> None:
|
||||
|
||||
remote = types.ModuleType("homeassistant.components.remote")
|
||||
remote.RemoteEntity = RemoteEntity
|
||||
# t15: the feature flag that makes HA offer the activity/app picker, the service
|
||||
# field the picker submits, and the two attributes HA reads off the base class.
|
||||
remote.RemoteEntityFeature = RemoteEntityFeature
|
||||
remote.ATTR_ACTIVITY = "activity"
|
||||
remote.RemoteEntityStateAttribute = types.SimpleNamespace(
|
||||
ACTIVITY_LIST="activity_list",
|
||||
CURRENT_ACTIVITY="current_activity",
|
||||
)
|
||||
sys.modules["homeassistant.components.remote"] = remote
|
||||
|
||||
button = types.ModuleType("homeassistant.components.button")
|
||||
|
||||
+17
-7
@@ -142,15 +142,19 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
|
||||
|
||||
#: Non-vacuity floors — the ONE source of truth for the standalone entry point
|
||||
#: and the pytest wrapper (t39 F1). They sit below the current walk (25 modules,
|
||||
#: 73 imports, 193 relative imports, 54 checked arguments, 6 awaited HA calls,
|
||||
#: 1235 statement blocks) so a broken or mis-targeted walk fails loudly instead of
|
||||
#: 76 imports, 206 relative imports, 57 checked arguments, 6 awaited HA calls,
|
||||
#: 1322 statement blocks) so a broken or mis-targeted walk fails loudly instead of
|
||||
#: printing zeros and exiting 0.
|
||||
FLOORS: dict[str, int] = {
|
||||
# t41: the sweep is recursive, so the floor sits above the top-level-only
|
||||
# count (13) — a regression to the shallow glob trips it.
|
||||
"modules": 20,
|
||||
"imports": 40,
|
||||
"relative_imports": 100,
|
||||
# t15: raised from 40/100 to just under the current 76/206, because the remote
|
||||
# parity work is an IMPORTS-and-coupling feature: `RemoteEntityFeature`,
|
||||
# `ATTR_ACTIVITY`, the transport-state accessors and the shared app-directory
|
||||
# helpers all arrive as imports, and none of them may vanish unnoticed.
|
||||
"imports": 70,
|
||||
"relative_imports": 195,
|
||||
# t62 F2: raised from 20 to just under the current 46. A floor's job is to
|
||||
# notice an API OWNER going away, not only a broken walk: at 20, deleting
|
||||
# `async_show_menu` from HA_CALL_OWNERS left 40 checked arguments and still
|
||||
@@ -163,11 +167,17 @@ FLOORS: dict[str, int] = {
|
||||
# registration added two owners (`async_register`'s `schema`/`supports_response`
|
||||
# arguments and the module-level description writer). At 44 those owners could go
|
||||
# away without the gate noticing; at 52 they are load-bearing.
|
||||
"call_args": 52,
|
||||
#
|
||||
# t15: raised from 52 to just under the current 57 — the options-flow pairing
|
||||
# step is a real flow-display surface of its own (its own async_show_form with
|
||||
# step_id/data_schema/errors), so those call sites must not be able to vanish.
|
||||
"call_args": 55,
|
||||
"awaited_calls": 5,
|
||||
# t62 F2: the unreachable-statement pass walks ~1100 statement blocks. A
|
||||
# t62 F2: the unreachable-statement pass walks ~1300 statement blocks. A
|
||||
# floor here means a pass that silently stopped parsing cannot look green.
|
||||
"statement_blocks": 500,
|
||||
# t15: raised 500 -> 1200 -> 1300, just under the walk, for the same reason the
|
||||
# imports floor moved: the exhaustion pass covers the new surfaces too.
|
||||
"statement_blocks": 1300,
|
||||
}
|
||||
|
||||
#: The interpreter floor Home Assistant itself declares: PyPI metadata for
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -369,9 +369,69 @@ def test_the_media_player_extra_attributes_reach_the_state_machine():
|
||||
|
||||
_entity_id, _state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert {"app_id", "app_name"} <= set(attrs), sorted(attrs)
|
||||
# t15: ``app_source`` names the tier that produced the app identity, so the
|
||||
# protocol push can never look like the ADB reading it outranked.
|
||||
assert {"app_id", "app_name", "app_source"} <= set(attrs), sorted(attrs)
|
||||
|
||||
|
||||
def test_the_remote_entity_publishes_the_activity_contract_under_real_ha():
|
||||
"""t15: the app/activity picker contract, measured against real HA.
|
||||
|
||||
Core androidtv_remote's remote is the parity target. HA renders the picker from
|
||||
``RemoteEntityFeature.ACTIVITY`` plus ``activity_list``/``current_activity``,
|
||||
published through the base class's FINAL ``state_attributes`` — so this asserts
|
||||
what the STATE MACHINE receives, not what our own attributes happen to say. The
|
||||
transport evidence rides the same entity, which is what makes "paired?" and
|
||||
"switched on?" answerable without a device test.
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
from homeassistant.components.remote import RemoteEntityFeature
|
||||
|
||||
entity, states = _real_ha_entity(
|
||||
"remote",
|
||||
"NvidiaShieldRemoteEntity",
|
||||
options={
|
||||
"app_name_map": {
|
||||
"com.netflix.ninja": "Netflix",
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
}
|
||||
},
|
||||
entity_id="remote.shield",
|
||||
)
|
||||
|
||||
assert entity.supported_features == RemoteEntityFeature.ACTIVITY # exactly core's
|
||||
assert entity.activity_list == ["Netflix", "TiviMate"]
|
||||
assert entity.current_activity is None, "nothing is known yet — never fabricated"
|
||||
|
||||
_entity_id, _state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert attrs["activity_list"] == ["Netflix", "TiviMate"]
|
||||
assert attrs["current_activity"] is None
|
||||
# ...and the transport's real state, on the same entity (no runtime_data here,
|
||||
# so there is no transport at all — which must read as "off", not as unpaired)
|
||||
assert attrs["protocol_state"] == "off"
|
||||
assert attrs["protocol_connected"] is False
|
||||
assert attrs["protocol_ever_authenticated"] is False
|
||||
assert attrs["power_on_route"] == "protocol"
|
||||
assert attrs["protocol_current_app_seen"] is False
|
||||
|
||||
# The LIVE update paths our entity uses: HA's own metaclass turns the class-level
|
||||
# ``_attr_*`` declarations into properties backed by ``__attr_*`` plus cache
|
||||
# invalidation, so both assignments must take effect on the published state. A
|
||||
# private attribute name would leave ``activity_list``/``current_activity`` frozen
|
||||
# at their first value — silent in a unit test, wrong in a dashboard.
|
||||
entity._attr_current_activity = "Netflix"
|
||||
entity._attr_activity_list = ["Netflix", "TiviMate", "YouTube"]
|
||||
|
||||
assert entity.current_activity == "Netflix"
|
||||
assert entity.activity_list == ["Netflix", "TiviMate", "YouTube"]
|
||||
|
||||
_entity_id, _state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert attrs["current_activity"] == "Netflix"
|
||||
assert attrs["activity_list"] == ["Netflix", "TiviMate", "YouTube"]
|
||||
|
||||
#: The same real 2x1 PNG fixture tests/test_screen_capture_image.py uses (the
|
||||
#: wire form ``screencap -p | base64`` produces) — a real PNG, not a stand-in.
|
||||
_SCREEN_PNG_B64 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user