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
@@ -49,6 +49,12 @@ from .const import (
|
||||
CONF_LOGCAT_EVENTS,
|
||||
DEFAULT_APP_DIRECTORY,
|
||||
LOGCAT_SUPPRESS_WINDOW,
|
||||
PROTOCOL_AUTH_REJECTED,
|
||||
PROTOCOL_CONNECT_TIMEOUT,
|
||||
PROTOCOL_CONNECTED,
|
||||
PROTOCOL_DISCONNECTED,
|
||||
PROTOCOL_NEVER_CONNECTED,
|
||||
PROTOCOL_OFF,
|
||||
STATE_STORE_TTL,
|
||||
SYSTEM_MEDIA_PACKAGES,
|
||||
)
|
||||
@@ -121,6 +127,18 @@ def volume_tuple(info) -> tuple | None:
|
||||
return None
|
||||
|
||||
|
||||
def _protocol_error_kind(err: BaseException) -> str:
|
||||
"""Classify a protocol exception: "auth" or "network" (t15).
|
||||
|
||||
Classified by NAME so the manager keeps working around a test double and stays
|
||||
importable where the optional androidtvremote2 is not installed. Only the
|
||||
library's ``InvalidAuth`` is terminal — it means the device no longer accepts our
|
||||
certificate, so retrying can never succeed; everything else (CannotConnect,
|
||||
ConnectionClosed, TimeoutError, an OSError from the TLS handshake) is a transport
|
||||
failure worth another attempt.
|
||||
"""
|
||||
return "auth" if type(err).__name__ == "InvalidAuth" else "network"
|
||||
|
||||
class StateStore:
|
||||
"""Timed per-field state cache shared by all entities of one entry.
|
||||
|
||||
@@ -278,6 +296,18 @@ class ShieldConnectionManager:
|
||||
self._stopping = False
|
||||
self._failures = 0
|
||||
self._down_since = False # listeners already told the connection is down
|
||||
# t15: the protocol transport's REAL state. keep_reconnecting() only MAINTAINS
|
||||
# a session (the library's loop is `while self._remote_message_protocol:`,
|
||||
# None until something connects first), so before this unit the transport was
|
||||
# never connected: no push arrived and every send raised ConnectionClosed
|
||||
# internally. This state is what the entities publish so the difference
|
||||
# between "never switched on", "connected" and "cert refused" is readable.
|
||||
self._protocol_state: str = (
|
||||
PROTOCOL_NEVER_CONNECTED if self._protocol is not None else PROTOCOL_OFF
|
||||
)
|
||||
self._protocol_last_error: str | None = None
|
||||
self._protocol_ever_authenticated = False
|
||||
self._protocol_failures = 0
|
||||
|
||||
# ── One-shot connection test (pairing UX) ──
|
||||
|
||||
@@ -369,10 +399,84 @@ class ShieldConnectionManager:
|
||||
client.add_is_available_updated_callback(self._on_is_available)
|
||||
client.add_current_app_updated_callback(self._on_current_app)
|
||||
client.add_volume_info_updated_callback(self._on_volume_info)
|
||||
# One owner per transport: the library's keep_reconnecting() creates
|
||||
# and owns its reconnect task internally — we never wrap or duplicate
|
||||
# it (the stop path closes the client, which tears the task down).
|
||||
client.keep_reconnecting(self._on_invalid_auth)
|
||||
# t15: CONNECT FIRST, then hand maintenance to the library.
|
||||
#
|
||||
# Core androidtv_remote does `await api.async_connect()` (5 s timeout,
|
||||
# InvalidAuth -> ConfigEntryAuthFailed, CannotConnect -> ConfigEntryNotReady)
|
||||
# and ONLY THEN `api.keep_reconnecting(reauth_needed)`. The order is the
|
||||
# whole point: the library's reconnect loop is
|
||||
#
|
||||
# while self._remote_message_protocol:
|
||||
# exc = await self._remote_message_protocol.on_con_lost
|
||||
#
|
||||
# and `_remote_message_protocol` is None until a first async_connect()
|
||||
# succeeds — so calling keep_reconnecting() alone starts a task that checks
|
||||
# its condition once, sees None and RETURNS. That is what shipped (this
|
||||
# method called keep_reconnecting and nothing in the tree ever called
|
||||
# async_connect): no session, not one push, and every send raised
|
||||
# ConnectionClosed internally. The attempt below is a background task, never
|
||||
# setup work: an unreachable or unpaired Shield must not fail the entry —
|
||||
# ADB is the primary transport and stays the answer (t40 ladder).
|
||||
self._protocol_connect_task = self._create_task(
|
||||
self._protocol_connect_loop(client), "shield_protocol_connect"
|
||||
)
|
||||
|
||||
async def _protocol_connect_loop(self, client) -> None:
|
||||
"""Open the protocol session, then let the library keep it open (t15).
|
||||
|
||||
A NETWORK failure is retried with the ADB watchdog's backoff (nothing to
|
||||
lose: the next attempt costs one TCP/TLS handshake). An AUTH rejection stops
|
||||
the loop — retrying a certificate the device no longer accepts can never
|
||||
succeed — and asks the entry to run its reauth/pair flow instead.
|
||||
"""
|
||||
backoff = ADB_RECONNECT_BACKOFF_START
|
||||
try:
|
||||
while not self._stopping:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
client.async_connect(), timeout=PROTOCOL_CONNECT_TIMEOUT
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - classified right here
|
||||
if _protocol_error_kind(err) == "auth":
|
||||
_LOGGER.warning(
|
||||
"Shield remote-protocol auth was rejected (%s) — "
|
||||
"pushing the entry into its reauth/pair flow",
|
||||
err,
|
||||
)
|
||||
self._on_invalid_auth(
|
||||
reason=f"connect: {type(err).__name__}: {err}"
|
||||
)
|
||||
return
|
||||
self._note_protocol_state(
|
||||
PROTOCOL_NEVER_CONNECTED,
|
||||
f"connect: {type(err).__name__}: {err}",
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Shield remote-protocol connect failed (%s) — retrying in "
|
||||
"%.0f s",
|
||||
err,
|
||||
backoff,
|
||||
)
|
||||
await asyncio.sleep(self._jitter(backoff))
|
||||
backoff = min(backoff * 2, ADB_RECONNECT_BACKOFF_CAP)
|
||||
continue
|
||||
# Connected: remember it as an authenticated session and hand the
|
||||
# socket to the library's own reconnect task (one owner per
|
||||
# transport — the stop path closes the client, which tears it down).
|
||||
_LOGGER.info(
|
||||
"Shield remote-protocol transport connected to %s",
|
||||
getattr(client, "host", self.entry.title if self.entry else "?"),
|
||||
)
|
||||
self._protocol_ever_authenticated = True
|
||||
self._note_protocol_state(PROTOCOL_CONNECTED, None)
|
||||
client.keep_reconnecting(self._on_invalid_auth)
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # pragma: no cover - defensive: never kill the entry
|
||||
_LOGGER.exception("Shield remote-protocol connect loop crashed")
|
||||
|
||||
def _on_is_on(self, on) -> None:
|
||||
self._protocol_push(FIELD_IS_ON, bool(on))
|
||||
@@ -386,16 +490,27 @@ class ShieldConnectionManager:
|
||||
def _on_volume_info(self, info) -> None:
|
||||
self._protocol_push(FIELD_VOLUME_INFO, volume_tuple(info))
|
||||
|
||||
def _on_invalid_auth(self) -> None:
|
||||
def _on_invalid_auth(self, reason: str | None = None) -> None:
|
||||
"""Device hard reset — certificates rejected.
|
||||
|
||||
The library keeps its own backoff, but retrying can never succeed
|
||||
since certificated auth is gone — the entry-level callback (reauth
|
||||
since certificate auth is gone — the entry-level callback (reauth
|
||||
flow) decides the user-facing response (t9).
|
||||
|
||||
t15: the state and the error are recorded too — "the certificate was
|
||||
rejected" must be readable on the entity, not only in a log line. ``reason``
|
||||
carries the specific failure (the connect loop's exception) so the generic
|
||||
wording is only used when the library reports a rejection by itself.
|
||||
"""
|
||||
self._note_protocol_state(
|
||||
PROTOCOL_AUTH_REJECTED,
|
||||
reason or "auth rejected while reconnecting (device hard reset? pair again)",
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"Shield remote-protocol auth is invalid (device hard reset?) — "
|
||||
"notifying the entry's reauth hook"
|
||||
"notifying the entry's reauth hook (transport: %s, last error: %s)",
|
||||
self._protocol_state,
|
||||
self._protocol_last_error,
|
||||
)
|
||||
if self._invalid_auth_callback is not None:
|
||||
try:
|
||||
@@ -408,7 +523,17 @@ class ShieldConnectionManager:
|
||||
|
||||
Push callbacks may arrive off the event loop (grpc worker threads);
|
||||
route the listener notification back onto the captured loop.
|
||||
|
||||
t15: a push is the ONLY proof the library offers that a session is
|
||||
authenticated (it exposes no is_paired/is_connected), so receiving one marks
|
||||
the transport authenticated — and an availability push is the transport's own
|
||||
connected/disconnected report.
|
||||
"""
|
||||
self._protocol_ever_authenticated = True
|
||||
if field == FIELD_IS_AVAILABLE:
|
||||
self._protocol_state = (
|
||||
PROTOCOL_CONNECTED if value else PROTOCOL_DISCONNECTED
|
||||
)
|
||||
self.store.set(field, value)
|
||||
loop = self._protocol_loop
|
||||
if loop is not None and loop.is_running():
|
||||
@@ -459,6 +584,75 @@ class ShieldConnectionManager:
|
||||
"""
|
||||
return self._protocol is not None
|
||||
|
||||
@property
|
||||
def protocol_state(self) -> str:
|
||||
"""The transport's real state, in one word (t15).
|
||||
|
||||
``off`` (no client wired), ``never_connected`` (wired, no session yet),
|
||||
``connected``, ``disconnected`` or ``auth_rejected``. The library offers no
|
||||
is_paired/is_connected, so these are the states we can actually prove — and
|
||||
the pre-t15 code was permanently ``never_connected``, which is the state the
|
||||
user was reading as "maybe unpaired".
|
||||
"""
|
||||
return self._protocol_state
|
||||
|
||||
@property
|
||||
def protocol_connected(self) -> bool:
|
||||
"""True only while an authenticated session is up (never assumed)."""
|
||||
return self._protocol_state == PROTOCOL_CONNECTED
|
||||
|
||||
@property
|
||||
def protocol_ever_authenticated(self) -> bool:
|
||||
"""True once a session connected or ANY push arrived (the only proof)."""
|
||||
return self._protocol_ever_authenticated
|
||||
|
||||
@property
|
||||
def protocol_last_error(self) -> str | None:
|
||||
"""The last connect/auth/send failure, verbatim, or None (t15).
|
||||
|
||||
Readable on the entity so "the transport is fine" and "the transport could
|
||||
not run" are never the same picture — the masking this unit removed.
|
||||
"""
|
||||
return self._protocol_last_error
|
||||
|
||||
@property
|
||||
def protocol_failures(self) -> int:
|
||||
"""How many protocol sends could NOT run (never counted as successes)."""
|
||||
return self._protocol_failures
|
||||
|
||||
|
||||
def _note_protocol_state(self, state: str, error: str | None) -> None:
|
||||
"""Record the transport state (and an error when there is one) + notify."""
|
||||
changed = state != self._protocol_state
|
||||
self._protocol_state = state
|
||||
if error is not None:
|
||||
self._protocol_last_error = error
|
||||
if changed:
|
||||
self._notify()
|
||||
|
||||
def _note_protocol_send_failure(self, what: str, err) -> None:
|
||||
"""A protocol send that could NOT run must not look like one that did (t15).
|
||||
|
||||
Before this unit ``send_protocol_key``/``launch_app``/``send_text_ime``
|
||||
swallowed the failure into ``return False`` + a debug line, so a transport
|
||||
that had never connected was indistinguishable from a working one: the exact
|
||||
masking behind the user's "could be either" pairing question. Every failure
|
||||
is counted, recorded verbatim, and logged at WARNING carrying the transport
|
||||
state.
|
||||
"""
|
||||
self._protocol_failures += 1
|
||||
detail = f"{type(err).__name__}: {err}" if isinstance(err, BaseException) else str(err)
|
||||
self._protocol_last_error = f"{what}: {detail}"
|
||||
_LOGGER.warning(
|
||||
"Shield protocol %s did not run — nothing was sent (transport: %s, "
|
||||
"last error: %s, failures: %d)",
|
||||
what,
|
||||
self._protocol_state,
|
||||
self._protocol_last_error,
|
||||
self._protocol_failures,
|
||||
)
|
||||
self._notify()
|
||||
|
||||
async def async_protocol_device_info(self) -> tuple[str, str] | None:
|
||||
"""(device name, MAC) via the protocol client (P5-1 migration).
|
||||
|
||||
@@ -501,6 +695,22 @@ class ShieldConnectionManager:
|
||||
(P3-2 store priority: FIELD_CURRENT_APP > FIELD_FOREGROUND)."""
|
||||
return self.store.get(FIELD_CURRENT_APP) or self.store.get(FIELD_FOREGROUND)
|
||||
|
||||
@property
|
||||
def foreground_source(self) -> str | None:
|
||||
"""Which tier produced ``foreground_app``: ``"protocol"``, ``"adb"``, None (t15).
|
||||
|
||||
The two fields are DISJOINT, so naming the tier is exact. This is what keeps the
|
||||
newly-reachable protocol push from silently outranking the ADB parse that the
|
||||
user's setup is built on: the reading still prefers the push (P3-2), and the
|
||||
entity says where it came from — see the media player's ``app_source`` and the
|
||||
remote's ``activity_source``.
|
||||
"""
|
||||
if self.store.get(FIELD_CURRENT_APP):
|
||||
return "protocol"
|
||||
if self.store.get(FIELD_FOREGROUND):
|
||||
return "adb"
|
||||
return None
|
||||
|
||||
@property
|
||||
def volume_info(self) -> tuple | None:
|
||||
"""Protocol volume push as ``(level, max, muted)`` — readout only
|
||||
@@ -569,14 +779,19 @@ class ShieldConnectionManager:
|
||||
is up (RESEARCH_IDEAS §2 availability gate) — the call reports the
|
||||
send outcome; callers fall back to the ADB input-text path only
|
||||
when the protocol transport is down or the send failed.
|
||||
|
||||
t15: a send that could not run is logged at WARNING and recorded — the ADB
|
||||
fallback still runs, but silence here is what made a never-connected
|
||||
transport look like a working one.
|
||||
"""
|
||||
if self._protocol is None:
|
||||
self._note_protocol_send_failure("send_text", "no protocol transport")
|
||||
return False
|
||||
try:
|
||||
self._protocol.send_text(text)
|
||||
return True
|
||||
except Exception as err:
|
||||
_LOGGER.debug("protocol send_text failed: %s", err)
|
||||
self._note_protocol_send_failure("send_text", err)
|
||||
return False
|
||||
|
||||
async def send_adb_input_text(self, text: str) -> bool:
|
||||
@@ -1348,16 +1563,19 @@ class ShieldConnectionManager:
|
||||
async def send_protocol_key(self, key: str, direction: str = "SHORT") -> bool:
|
||||
"""Send a remote-protocol key push (fast, non-blocking).
|
||||
|
||||
Returns False when the transport is down or the send raises — entities
|
||||
then fall back to the ADB keyevent path.
|
||||
Returns False when the transport could not run the send (no client, or the
|
||||
library raised ConnectionClosed because nothing is connected) — entities then
|
||||
fall back to the ADB keyevent path. t15: that failure is a WARNING carrying the
|
||||
transport state, never a debug line (see ``_note_protocol_send_failure``).
|
||||
"""
|
||||
if self._protocol is None:
|
||||
self._note_protocol_send_failure(f"send_key {key}", "no protocol transport")
|
||||
return False
|
||||
try:
|
||||
self._protocol.send_key_command(key, direction)
|
||||
return True
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Protocol key %s failed: %s", key, err)
|
||||
self._note_protocol_send_failure(f"send_key {key}", err)
|
||||
return False
|
||||
|
||||
async def launch_app(self, app_link_or_app_id: str) -> bool:
|
||||
@@ -1365,14 +1583,21 @@ class ShieldConnectionManager:
|
||||
|
||||
``send_launch_app_command`` auto-prefixes ``market://launch?id=`` for
|
||||
bare package ids (C-LAUNCH); deep links pass through unchanged.
|
||||
|
||||
t15: False means the launch DID NOT RUN (no transport, or the library raised
|
||||
because nothing is connected) and says so at WARNING; callers that need to
|
||||
report the outcome to a user must check it, not assume it.
|
||||
"""
|
||||
if self._protocol is None:
|
||||
self._note_protocol_send_failure(
|
||||
f"launch {app_link_or_app_id}", "no protocol transport"
|
||||
)
|
||||
return False
|
||||
try:
|
||||
self._protocol.send_launch_app_command(app_link_or_app_id)
|
||||
return True
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Protocol launch %s failed: %s", app_link_or_app_id, err)
|
||||
self._note_protocol_send_failure(f"launch {app_link_or_app_id}", err)
|
||||
return False
|
||||
|
||||
async def send_digit_key(self, digit: str) -> bool:
|
||||
|
||||
Reference in new issue
Block a user