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

+201 -46
View File
@@ -28,6 +28,7 @@ from .const import (
CONF_PAIRING_CODE,
CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_PAIRED,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
CONF_REMOTE_COMMAND_SCRIPT,
@@ -338,6 +339,67 @@ def _build_selectors():
}
async def _async_start_protocol_pairing(
hass, host: str, client_name: str
) -> tuple[object | None, str | None]:
"""Build a throwaway pairing client and make the Shield show its PIN (t15).
Certificates live under ``.storage/<domain>/`` (core androidtv_remote helpers.py
pattern) and are SHARED between pairing and runtime — an entry paired later reuses
them, which is why enabling the transport on a paired entry asks for no new PIN.
Returns ``(client, None)`` or ``(None, error_key)``: no host, a missing
``androidtvremote2`` and an unreachable Shield are all keys the form can show,
never an exception out of a step.
t44 F1: androidtvremote2 raises ``CannotConnect`` from both calls below, and the
protocol transport is ON by default, so an asleep, rebooting or mis-addressed
Shield is the ordinary case rather than an edge one. Left uncaught it escaped the
config flow entirely, and the protocol_pair form — the address field AND the
"continue without pairing" escape — never rendered.
Module-level (t15) because BOTH flows drive a pairing session: the setup flow on
its way in, and the OPTIONS flow when the transport is switched on for an entry
that never paired.
"""
if not host:
return None, "protocol_no_host"
certfile, keyfile = await hass.async_add_executor_job(protocol_cert_paths, hass)
client = ShieldConnectionManager.build_protocol_client(
host,
client_name=client_name,
certfile=certfile,
keyfile=keyfile,
)
if client is None:
# build_protocol_client already logged the missing library; pairing cannot
# start, and the form still offers the skip path
return None, "protocol_unavailable"
try:
await client.async_generate_cert_if_missing()
await client.async_start_pairing()
except Exception as err: # noqa: BLE001 - the library's error IS the finding
_LOGGER.warning(
"protocol pairing could not start at %s (%s: %s) — showing the "
"form so the address can be corrected or pairing skipped",
host,
type(err).__name__,
err,
)
_disconnect_pairing_client(client)
return None, "protocol_unreachable"
return client, None
def _disconnect_pairing_client(client) -> None:
"""Drop a THROWAWAY pairing client (t15) — both flows hold exactly one.
A pairing client never becomes the entry's runtime transport, so a session whose
address was corrected, or that was skipped, must be closed here rather than left
dangling with a live TLS connection.
"""
if client is not None:
with contextlib.suppress(Exception):
client.disconnect()
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Nvidia Shield."""
@@ -542,6 +604,10 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_HOST: d.get(CONF_HOST, ""),
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, True),
# t15: the pairing marker travels with the entry: the pairing step set
# it (True) or the skip path cleared it (False), and the options flow
# reads it before it lets the transport run unpaired.
CONF_PROTOCOL_PAIRED: bool(d.get(CONF_PROTOCOL_PAIRED, False)),
},
)
@@ -563,57 +629,20 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Drop a pairing client whose session became void (host corrected)."""
client = self._protocol_client
self._protocol_client = None
if client is not None:
with contextlib.suppress(Exception):
client.disconnect()
_disconnect_pairing_client(client)
async def _start_protocol_pairing(self):
"""Build a throwaway protocol client and make the Shield show its PIN.
"""Build a throwaway protocol client and make the Shield show its PIN (t15).
Certificates live under ``.storage/<domain>/`` (core androidtv_remote
helpers.py pattern) and are shared between pairing and runtime. Returns
``(client, None)`` or ``(None, error_key)`` — no host, a missing
``androidtvremote2`` and an unreachable Shield are all keys the form can
show, never an exception out of the step.
t44 F1: androidtvremote2 raises ``CannotConnect`` from both calls below,
and the protocol transport is ON by default, so an asleep, rebooting or
mis-addressed Shield is the ordinary case rather than an edge one. Left
uncaught it escaped the config flow entirely, and the protocol_pair form
— the address field AND the "continue without pairing" escape — never
rendered.
Thin wrapper: the construction is module-level because the OPTIONS flow needs
exactly the same thing when the transport is switched on for an entry that
never paired (``_async_start_protocol_pairing``).
"""
host = self._host()
if not host:
return None, "protocol_no_host"
certfile, keyfile = await self.hass.async_add_executor_job(
protocol_cert_paths, self.hass
return await _async_start_protocol_pairing(
self.hass,
self._host(),
self._user_data.get("name", DEFAULT_NAME) or DEFAULT_NAME,
)
client = ShieldConnectionManager.build_protocol_client(
host,
client_name=self._user_data.get("name", DEFAULT_NAME) or DEFAULT_NAME,
certfile=certfile,
keyfile=keyfile,
)
if client is None:
# build_protocol_client already logged the missing library;
# pairing cannot start, and the form still offers the skip path
return None, "protocol_unavailable"
try:
await client.async_generate_cert_if_missing()
await client.async_start_pairing()
except Exception as err: # noqa: BLE001 - the library's error IS the finding
_LOGGER.warning(
"protocol pairing could not start at %s (%s: %s) — showing the "
"form so the address can be corrected or pairing skipped",
host,
type(err).__name__,
err,
)
with contextlib.suppress(Exception):
client.disconnect()
return None, "protocol_unreachable"
return client, None
async def _async_begin_protocol_pairing(self):
"""Start a PIN pairing session: ``(client, None)`` or ``(None, error)``."""
@@ -647,6 +676,9 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""
self._discard_protocol_client()
self._user_data[CONF_PROTOCOL_ENABLED] = False
# t15: skipped means NOT paired — the marker is what keeps an unpaired client
# off instead of letting it look like a dead remote later.
self._user_data[CONF_PROTOCOL_PAIRED] = False
if self._reauth_entry is not None:
# an existing entry cannot be re-created: leave it untouched and
# say so instead of pretending the re-pair worked
@@ -735,6 +767,12 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# t56: the protocol is paired now, so the setup menu must not offer to
# pair it again — it returns to the menu (Receiver / Connection /
# Finish) instead of creating the entry here.
#
# t15: and the pairing is PERSISTED. Nothing else can tell a paired entry
# from one that merely switched the transport on (the library has no
# is_paired, and certificate material is generated either way), so the
# options flow reads this marker before it lets the transport run unpaired.
self._user_data[CONF_PROTOCOL_PAIRED] = True
self._paired_protocol = True
return self._show_setup_menu()
@@ -851,6 +889,9 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"options": {
**entry.options,
CONF_PROTOCOL_ENABLED: True,
# t15: this reconfigure JUST paired — persist the marker the options
# flow reads before it lets an unpaired transport run.
CONF_PROTOCOL_PAIRED: True,
CONF_HOST: self._host(),
},
}
@@ -874,6 +915,8 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
options={
**entry.options,
CONF_PROTOCOL_ENABLED: True,
# t15: this reauth JUST paired — persist the marker (see above).
CONF_PROTOCOL_PAIRED: True,
CONF_HOST: self._host(),
},
)
@@ -1194,6 +1237,9 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
self._discover_note: str | None = None
self._editing_package: str | None = None
self._removing_package: str | None = None
# t15: the throwaway pairing client of a protocol_pair visit (see
# async_step_protocol_pair). Flow state, never an option.
self._protocol_client = None
@property
def _options_dict(self) -> dict:
@@ -1416,9 +1462,118 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
"""Main setup: the link, both transports and the linked entities."""
errors: dict = {}
if user_input is not None and self._apply_submission(dict(user_input), errors):
if self._protocol_enable_needs_pairing(user_input):
# t15: switching the transport ON for an entry that was never paired
# must PAIR now, not enable an unauthenticated client. The setup flow
# always paired (or turned the transport off); this is the way back in
# for an entry created in passive mode, and the only honest reading of
# "pair or stay off".
return await self.async_step_protocol_pair()
return await self.async_step_menu_again()
return self._show_connection_form(errors)
def _protocol_paired(self) -> bool:
"""True when this entry's protocol client was really paired (t15).
The persisted marker is the ONLY honest source: the library exposes no
``is_paired``, and certificate material proves only that something generated a
key — which an entry that merely enabled the transport has too (the runtime
calls ``async_generate_cert_if_missing``).
"""
data = getattr(self.config_entry, "data", None) or {}
return bool(self._options_dict.get(CONF_PROTOCOL_PAIRED) or data.get(CONF_PROTOCOL_PAIRED))
def _protocol_enable_needs_pairing(self, submission: dict) -> bool:
"""Does this submission turn the transport ON without a pairing to back it?"""
if CONF_PROTOCOL_ENABLED not in submission:
return False
if not self._options_dict.get(CONF_PROTOCOL_ENABLED):
return False # turned off (or left off): nothing runs, nothing to pair
return not self._protocol_paired()
async def async_step_protocol_pair(self, user_input=None):
"""Pair the remote protocol from the OPTIONS flow (t15).
Reached only by switching the transport ON for an entry that never paired. The
alternative is the state the user was stuck in: a client that connects nowhere,
no pushes, every send swallowed — a remote that looks unpaired whether it is or
not. ``skip_pairing`` leaves the transport OFF (persisted), so the entry cannot
end up enabled-but-unpaired either way.
"""
errors: dict = {}
if user_input is None:
return await self._async_restart_options_pairing(errors)
if user_input.get(CONF_SKIP_PAIRING):
return await self._async_skip_options_pairing()
typed_host = str(user_input.get(CONF_HOST) or "").strip()
if typed_host and typed_host != entry_host(self.config_entry):
# A host typed HERE is authoritative (same rule as the setup flow): the
# session started against the old address is dropped and the Shield is
# asked for a NEW code at the corrected address.
self._options_dict[CONF_HOST] = typed_host
self._discard_options_pairing_client()
code = str(user_input.get(CONF_PAIRING_CODE, "") or "").strip()
if self._protocol_client is None or not code:
return await self._async_restart_options_pairing(errors)
try:
await self._protocol_client.async_finish_pairing(code)
except Exception:
errors["base"] = "protocol_pair_failed"
return self._show_options_pair_form(errors)
self._discard_options_pairing_client()
# Paired: the transport may run, and the marker keeps every later visit from
# asking again (the certificate is reused by design — no new PIN).
self._options_dict[CONF_PROTOCOL_PAIRED] = True
return await self.async_step_menu_again()
def _show_options_pair_form(self, errors=None):
"""protocol_pair form for the OPTIONS flow: PIN, address, skip escape."""
host = entry_host(self.config_entry)
return self.async_show_form(
step_id="protocol_pair",
data_schema=vol.Schema(
{
vol.Required(CONF_PAIRING_CODE): str,
_host_field_key(CONF_HOST, host): str,
vol.Optional(CONF_SKIP_PAIRING, default=False): bool,
}
),
errors=errors or {},
)
async def _async_restart_options_pairing(self, errors: dict):
"""Start a fresh pairing session and render the form for its code (t15)."""
self._discard_options_pairing_client()
client, error = await _async_start_protocol_pairing(
self.hass,
entry_host(self.config_entry),
self.config_entry.title or DEFAULT_NAME,
)
if error:
errors["base"] = error
else:
self._protocol_client = client
return self._show_options_pair_form(errors)
async def _async_skip_options_pairing(self):
"""Continue with the transport OFF (t15) — never enabled-but-unpaired."""
self._discard_options_pairing_client()
self._options_dict[CONF_PROTOCOL_ENABLED] = False
self._options_dict[CONF_PROTOCOL_PAIRED] = False
return await self.async_step_menu_again()
def _discard_options_pairing_client(self) -> None:
"""Drop the options flow's throwaway pairing client (t15)."""
client = self._protocol_client
self._protocol_client = None
_disconnect_pairing_client(client)
def _show_connection_form(self, errors=None):
"""Main setup: the same settings the first setup form carries.
+237 -12
View File
@@ -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:
+25
View File
@@ -37,6 +37,15 @@ CONF_PAIRING_CODE = "pairing_code"
# steps — the entry still finishes (passive mode) instead of stranding the
# user on a form with no way forward.
CONF_SKIP_PAIRING = "skip_pairing"
#: t15: a PERSISTED marker that this entry's protocol client really paired (a
#: successful ``async_finish_pairing``). Pairing cannot be inferred from anything
#: else: the library exposes no ``is_paired``, and certificate material proves only
#: that SOMETHING generated a key — which an entry that merely enabled the transport
#: without pairing has too. Written by the setup flow's pairing step, the options
#: flow's, and the reauth/reconfigure finishers, so enabling the transport later can
#: tell "already paired" (reuse the certificate, no new PIN) from "never paired"
#: (must pair now, or stay off).
CONF_PROTOCOL_PAIRED = "protocol_paired"
DEFAULT_PROTOCOL_API_PORT = 6466
DEFAULT_PROTOCOL_PAIR_PORT = 6467
@@ -52,6 +61,22 @@ ADB_RECONNECT_BACKOFF_CAP = 300 # seconds; backoff never sleeps longer
ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms
STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
# t15: the remote-protocol transport's own state machine. The library offers NO
# ``is_paired``/``is_connected`` (measured on androidtvremote2), so these are the
# states we can actually prove — and the one the user was reading as "maybe
# unpaired" was ``never_connected``: keep_reconnecting() alone never connects
# (its loop is ``while self._remote_message_protocol:``, None until a first
# ``async_connect()``), so the transport had never been switched on at all.
PROTOCOL_OFF = "off" # no client wired (disabled/no library)
PROTOCOL_NEVER_CONNECTED = "never_connected" # wired, no session yet, no rejection
PROTOCOL_CONNECTED = "connected" # device reachable at the protocol
PROTOCOL_DISCONNECTED = "disconnected" # session was up, now reported down
PROTOCOL_AUTH_REJECTED = "auth_rejected" # cert refused: pair again (reauth)
#: Seconds allowed for the FIRST protocol connect before it counts as unreachable.
#: Core androidtv_remote uses the same 5 s before deciding ready/not-ready.
PROTOCOL_CONNECT_TIMEOUT = 5.0
DEFAULT_NAME = "Nvidia Shield"
# Config-entry schema version (t41): v1 → v2 added the remote-protocol fields,
+99 -45
View File
@@ -418,6 +418,91 @@ def resolve_app_name(
)
def normalize_app_directory(directory) -> dict[str, str]:
"""The t51 rule for an apps table: strip the edges, drop blanks, keep order.
ONE implementation for every consumer (the media player's source list, the
remote's activity list, the reverse lookup), so display and match key stay one
string: an entry like ``com.x= TiviMate `` shows as "TiviMate" AND resolves as
"TiviMate". An entry whose name — or whose package — is blank is dropped, so no
surface offers a child with no package behind it or a name no launch can reach.
Two packages normalizing to the same name are BOTH KEPT (each stays reachable by
its own id) and the FIRST in map order wins the name (t53).
"""
normalized: dict[str, str] = {}
for package, name in (directory or {}).items():
key = str(package).strip()
label = str(name).strip()
if not key or not label:
continue
normalized[key] = label
return normalized
def app_directory(options) -> dict[str, str]:
"""Launchable apps for an entry: the user's map, else the curated default (t61).
ONE source for the source list and the reverse lookup, so the dropdown can
never offer a name the launch path cannot resolve (``_app_directory``, and now
the remote's activity list, both read this).
"""
return normalize_app_directory(
(options or {}).get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
)
def distinct_app_names(names) -> list[str]:
"""Each name ONCE, compared case-insensitively, keeping the first spelling (t53).
``Dup``/``dup`` would otherwise show two rows while the case-insensitive
reverse lookup resolves both to the first entry — one row can only launch the
app it shows. The shadowed package is not lost: it stays reachable by id.
"""
seen: set[str] = set()
offered: list[str] = []
for name in names:
text = str(name)
folded = text.casefold()
if not text or folded in seen:
continue
seen.add(folded)
offered.append(text)
return offered
def activity_names(options, discovered=()) -> list[str]:
"""The remote entity's ACTIVITY list: friendly names, stable order (t15).
Core ``androidtv_remote`` builds its list from a user-maintained app map
because ``AndroidTVRemote`` exposes NO app-list API (measured on the installed
library, not assumed). Ours is the same shape of source plus what ADB discovery
actually found on THIS device:
1. the entry's app directory (the user's map, else the curated table) in map
order — the same names the media player's source list offers;
2. apps discovery found that no directory row covers, named by the one chain
(user map → device label → curated → id).
A discovered app NOTHING can name is skipped rather than offered as its own
package id (the ``pkg=pkg`` noise this contract forbids); it stays launchable by
id through ``remote.turn_on(activity=...)``. Nothing is invented here: an empty
directory with no discovery yields an empty list, and the protocol's pairing
state cannot add an entry.
"""
directory = app_directory(options)
names = list(directory.values())
covered = set(directory)
for row in discovered or ():
package = str(row.get("package") or "").strip()
if not package or package in covered:
continue
covered.add(package)
label = str(row.get("name") or "").strip()
if not label or label == package:
continue
names.append(label)
return distinct_app_names(names)
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
_attr_device_class = MediaPlayerDeviceClass.TV
@@ -948,44 +1033,14 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@property
def _app_directory(self) -> dict:
"""Launchable apps: the user's map, else the curated default (P4-1).
"""Launchable apps: the user's map, else the curated default (P4-1; t15).
ONE source for the source list and the reverse lookup (t61 removed the
browse tree that used to be the third consumer), so the dropdown can
never offer a name the launch path cannot resolve.
The map is hand-editable, so it is NORMALIZED here — once, so that every
consumer sees the same thing (t51):
* a name is stripped, so display and match key are one string: an entry
like ``com.x= TiviMate `` shows as "TiviMate" AND resolves as
"TiviMate", instead of being shown padded and missing every lookup;
* an entry whose name — or whose package — is blank/whitespace-only is
dropped, so no surface can offer a child with no package behind it or a
name no launch can reach (the t48 invariant, extended to the table);
* two packages normalizing to the same name are BOTH KEPT — each stays
reachable by its own package id, which ``select_source`` accepts (t61:
the browse tree that used to publish those ids is gone) — while
the FIRST in map order wins the name: that is the single entry
source_list offers and the package select_source resolves. Map order is
the user's order, so the winner does not move between rebuilds. "The same
name" is judged case-insensitively (t53), because the reverse lookup is:
names differing only by case would otherwise give the dropdown a second
row that launches the FIRST entry's app.
A well-formed map (what the editor and the curated default produce) passes
through byte-identical: stripping only touches the edges, so a name like
"My App" keeps its inner space.
The rule lives in the module-level :func:`app_directory` (t51: strip the
edges, drop blanks, keep map order, first name wins case-insensitively, the
shadowed package still reachable by id) so the source list, the reverse
lookup AND the remote's activity list cannot disagree about it.
"""
directory = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
normalized: dict[str, str] = {}
for package, name in directory.items():
key = str(package).strip()
label = str(name).strip()
if not key or not label:
continue
normalized[key] = label
return normalized
return app_directory(self._entry.options)
@property
def source_list(self) -> list[str]:
@@ -1004,15 +1059,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
row per name means a row can only launch the app it shows. The shadowed
package is not lost: ``select_source`` accepts its own package id.
"""
seen: set[str] = set()
offered: list[str] = []
for name in self._app_directory.values():
folded = name.casefold()
if folded in seen:
continue
seen.add(folded)
offered.append(name)
return offered
return distinct_app_names(self._app_directory.values())
@property
def source(self) -> str | None:
@@ -1300,6 +1347,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
attrs = {
"app_id": self.app_id,
"app_name": self.app_name,
# t15: which tier produced the app identity ("protocol" push / ADB parse),
# so the newly-reachable push cannot silently outrank the ADB reading the
# user's setup is built on — the same disclosure now_playing_via makes.
"app_source": (
self._manager.foreground_source if self._manager is not None else None
),
ATTR_MEDIA_DAG_ROLE: "source",
}
# t65: the reading is FLATTENED into separate top-level attributes.
+315 -16
View File
@@ -9,31 +9,58 @@ remote-protocol transport (androidtvremote2) owned by ShieldConnectionManager:
long presses are expressed as the START_LONG/END_LONG pair instead;
- ``num_repeats`` / ``delay_secs`` are honored; long-press duration uses the
``hold_secs`` parameter;
- ``activity`` = the protocol current-app push (package; name resolution is
deferred app-map work);
- turn on/off send the POWER key and honor the is_on push (no superfluous
toggling when the state already matches).
- ``RemoteEntityFeature.ACTIVITY`` with an ``activity_list`` built from OUR app
directory — the library exposes NO app-list API (measured), which is exactly why
core's list is user-maintained too — so the app/activity picker matches core;
- ``current_activity`` resolves the foreground push (protocol first, ADB parse as
the fallback) through the one ``resolve_app_name`` chain, and stays None while
nothing is known: no fabricated activity, ever;
- ``turn_on(activity=...)`` wakes when the device says it is off and then launches
through the same path ``select_source`` uses; an activity name the directory does
not carry is a ``ServiceValidationError``, never a silent no-op;
- the transport's REAL state is published in ``extra_state_attributes`` so a
never-connected transport cannot look like a paired one (no ``is_paired`` exists:
a session or a push is the only proof);
- a configured command script still owns turn_on/turn_off and never touches the
protocol, so a press that worked through it says NOTHING about pairing — the
attributes name which route each power command takes.
"""
import asyncio
import os
from homeassistant.components.remote import RemoteEntity
from homeassistant.components.remote import (
ATTR_ACTIVITY,
RemoteEntity,
RemoteEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .connection import ShieldConnectionManager
from .const import (
CONF_APP_NAME_MAP,
DOMAIN,
PROTOCOL_REMOTE_KEYS,
SCRIPT_VAR_COMMAND,
SCRIPT_VAR_DELAY_SECS,
SCRIPT_VAR_HOLD_SECS,
SCRIPT_VAR_NUM_REPEATS,
command_scripts,
remote_command_script,
)
from .media_player import async_call_script, async_run_command_script, entity_available
from .helpers import protocol_cert_paths
from .media_player import (
activity_names,
app_directory,
async_call_script,
async_run_command_script,
entity_available,
resolve_app_name,
)
_START_PREFIX = "START_LONG:"
_END_PREFIX = "END_LONG:"
@@ -64,6 +91,19 @@ def _key_code_name(command: str) -> str:
return upper # unknown names: the library reports/validates them
def _looks_launchable(value: str) -> bool:
"""True for a package id or a detail link — forms the launch path accepts (t15).
The rule is the repo's own definition of a package (``_valid_app_row``: dotted,
no spaces) plus URL-shaped deep links, which is exactly what
``send_launch_app_command`` handles. It is deliberately NOT a guess that an id
exists: the device answers that. It exists so "Netflx" (a typo of a listed name)
is refused while an app the directory does not carry can still be launched.
"""
if not value or " " in value:
return False
return "." in value or "://" in value
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
@@ -74,11 +114,21 @@ async def async_setup_entry(
class NvidiaShieldRemoteEntity(RemoteEntity):
"""Remote entity over the manager's androidtvremote2 transport."""
"""Remote entity over the manager's androidtvremote2 transport.
t15: this is the entity the app/activity picker hangs off. HA renders the picker
only when ``supported_features`` carries ``RemoteEntityFeature.ACTIVITY`` — the
user's "the UI doesn't actually show any buttons like the android tv remote
does" — and the list itself has to come from OUR directory, because
``AndroidTVRemote`` exposes no app-list API at all (measured on the installed
library; core androidtv_remote keeps a user-maintained map for the same reason).
"""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
#: The feature flag that makes HA offer the activity/app picker at all.
_attr_supported_features = RemoteEntityFeature.ACTIVITY
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
@@ -87,10 +137,58 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
entry, "runtime_data", None
)
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_remote"
# t15: the activity list is available from the FIRST render (a real-HA state
# write needs no entity platform), and is refreshed with discovered apps once
# ADB can answer (``async_added_to_hass``).
self._attr_activity_list = activity_names(self._entry.options)
self._attr_current_activity = self._resolve_activity_name()
#: Which protocol pushes have EVER arrived — the only pairing evidence the
#: library can offer (it has no is_paired). Sticky on purpose: the store
#: expires every field after STATE_STORE_TTL, so "has it ever arrived?"
#: cannot be read from the store.
self._protocol_pushes_seen: set[str] = set()
self._note_protocol_pushes()
#: "present"/"missing"/"unknown" — the shared setup certificate, which is
#: why enabling the transport on a paired entry asks for no new PIN.
self._protocol_certificate = "unknown"
self._activity_refresh_task = None
def _manager_of(self) -> ShieldConnectionManager | None:
return getattr(self._entry, "runtime_data", None)
def _resolve_activity_name(self) -> str | None:
"""The foreground app as a friendly name (t15), or None when nothing is known.
The same chain as the media player's ``app_name`` and the Current App sensor
(``resolve_app_name``): the user's map, then the cached device label, then the
curated table, then the raw id — so an activity and a source can never
disagree about what is on screen. The foreground identity prefers the protocol
push and falls back to the ADB parse, which keeps this entity useful on an
ADB-only setup (its protocol surface alone degrades).
"""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return None
return resolve_app_name(
manager.foreground_app,
self._entry.options.get(CONF_APP_NAME_MAP) or {},
manager.app_label,
)
def _note_protocol_pushes(self) -> None:
"""Remember which device pushes have arrived (t15) — sticky, see __init__."""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return
if manager.current_app is not None:
self._protocol_pushes_seen.add("current_app")
if manager.is_on is not None:
self._protocol_pushes_seen.add("is_on")
if manager.is_available is not None:
self._protocol_pushes_seen.add("is_available")
if manager.volume_info is not None:
self._protocol_pushes_seen.add("volume")
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
@@ -113,24 +211,158 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
@property
def activity(self) -> str | None:
"""Current foreground app reported by the protocol push."""
"""The raw foreground package (protocol push; ADB parse as the fallback).
Kept alongside HA's ``current_activity``: this is the id (what
``turn_on(activity=...)`` accepts and what the Current App sensor stamps),
while ``current_activity`` is the friendly name HA displays.
"""
manager = self._manager_of()
if isinstance(manager, ShieldConnectionManager):
return manager.current_app
return manager.foreground_app
return None
@property
def extra_state_attributes(self) -> dict:
"""The transport's real state — the pairing answer, readable from HA (t15).
There is no "is it paired?" API to call (the library exposes none either,
measured), so everything here is a fact that can be PROVEN:
* ``protocol_state`` — ``off`` / ``never_connected`` / ``connected`` /
``disconnected`` / ``auth_rejected``. ``never_connected`` is the state
that used to be permanent (``keep_reconnecting()`` alone never connects),
and it must never look like ``connected``.
* ``protocol_connected`` / ``protocol_ever_authenticated`` — a session or a
device push is the only proof of an authenticated (paired) client.
* ``protocol_current_app_seen`` / ``protocol_is_on_seen`` /
``protocol_volume_seen`` — whether each device push has EVER arrived.
* ``protocol_last_error`` / ``protocol_failures`` — a send that could not
run is recorded here and logged, never swallowed into a debug line.
* ``protocol_certificate`` — the setup pairing's shared ``client.pem`` is
still on disk, which is exactly why enabling the transport asks for no
new PIN (the other Android TV integration pairs its own client).
* ``power_on_route`` / ``power_off_route`` — ``script:<id>`` when a command
script owns that command. That path never touches the protocol, so a
success through it says NOTHING about pairing.
"""
return {
"activity": self.activity,
# t15: which tier produced it — a protocol push must never win SILENTLY
# over the ADB parse the user's setup is built on (see the manager's
# ``foreground_source``).
"activity_source": self._protocol_read("foreground_source"),
"power_on_route": self._power_route("turn_on"),
"power_off_route": self._power_route("turn_off"),
"protocol_active": self._protocol_active(),
"protocol_state": self._protocol_state(),
"protocol_connected": self._protocol_flag("protocol_connected"),
"protocol_ever_authenticated": self._protocol_flag(
"protocol_ever_authenticated"
),
"protocol_last_error": self._protocol_read("protocol_last_error"),
"protocol_failures": self._protocol_read("protocol_failures"),
"protocol_certificate": self._protocol_certificate,
"protocol_current_app_seen": "current_app" in self._protocol_pushes_seen,
"protocol_is_on_seen": "is_on" in self._protocol_pushes_seen,
"protocol_volume_seen": "volume" in self._protocol_pushes_seen,
}
def _protocol_read(self, name: str, default=None):
"""Read one transport fact off the manager, or the default (stub managers)."""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return default
return getattr(manager, name, default)
def _protocol_active(self) -> bool:
return bool(self._protocol_read("protocol_active", False))
def _protocol_state(self) -> str:
"""The transport state, with ``off`` for a manager that carries no client."""
if not self._protocol_active():
return "off"
return self._protocol_read("protocol_state", "never_connected")
def _protocol_flag(self, name: str) -> bool:
return bool(self._protocol_read(name, False))
def _power_route(self, command: str) -> str:
"""Which path ``turn_on``/``turn_off`` takes: a script, or the protocol (t15).
A configured command script owns the sequence and never touches the
protocol — so "the power press worked" through it is NOT evidence that the
transport is paired, or even connected. That is the conclusion the user drew
and then doubted, made readable instead of inferred.
"""
script = (command_scripts(self._entry.options) or {}).get(command)
return f"script:{script}" if script else "protocol"
@callback
def _refresh_state(self) -> None:
"""Manager store changed — re-evaluate state (activity/available)."""
"""Manager store changed — re-resolve activity, availability, transport."""
self._manager = self._manager_of()
self._note_protocol_pushes()
self._attr_current_activity = self._resolve_activity_name()
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
manager = self._manager_of()
if isinstance(manager, ShieldConnectionManager):
self._manager_unregistered = manager.register_listener(
self._refresh_state
)
self._manager_unregistered = manager.register_listener(self._refresh_state)
self._note_protocol_pushes()
await self._async_note_certificate()
self._schedule_activity_refresh()
async def _async_note_certificate(self) -> None:
"""Record whether the shared pairing certificate exists (t15).
This is the "no new PIN is expected" evidence, and it is a design fact, not a
guess: ``protocol_cert_paths`` is shared between the config flow's pairing step
and this runtime, so an entry paired at setup reuses that ``client.pem`` when
the transport is (re-)enabled. The OTHER Android TV integration pairs its own
client and shares nothing with us.
"""
add_executor_job = getattr(self.hass, "async_add_executor_job", None)
if add_executor_job is None:
return # a hass stand-in: leave it "unknown" rather than guess
try:
certfile, keyfile = await add_executor_job(protocol_cert_paths, self.hass)
except Exception: # pragma: no cover - a filesystem we cannot read
return
self._protocol_certificate = (
"present" if os.path.exists(certfile) and os.path.exists(keyfile) else "missing"
)
def _schedule_activity_refresh(self) -> None:
"""Add the apps ADB discovery actually found to the activity list (t15).
One bounded sweep, on demand — the t57 path the options editor uses — never a
per-state-write shell. The list is already complete for a configured
directory, and a sweep that cannot run changes nothing (no fabrication).
"""
manager = self._manager_of()
create_task = getattr(self.hass, "async_create_task", None)
if create_task is None or not isinstance(manager, ShieldConnectionManager):
return
if not callable(getattr(manager, "async_refresh_installed_apps", None)):
return # a manager stand-in: the directory alone is the honest list
self._activity_refresh_task = create_task(
self._async_refresh_activity_list(manager),
"nvidia_shield_remote_activities",
)
async def _async_refresh_activity_list(self, manager) -> None:
"""Fold discovered apps into the activity list (t15) — one write if it grew."""
try:
rows = await manager.async_refresh_installed_apps()
except Exception: # pragma: no cover - discovery never breaks the entity
return
names = activity_names(self._entry.options, rows)
if names != self._attr_activity_list:
self._attr_activity_list = names
self.async_write_ha_state()
async def async_will_remove_from_hass(self) -> None:
unregistered = getattr(self, "_manager_unregistered", None)
@@ -139,9 +371,31 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
self._manager_unregistered = None
async def async_turn_on(self, **kwargs) -> None:
"""Wake the device — via the configured script, else the POWER key."""
# t44: `turn_on` is one configuration, and the remote entity is the other
# route to it; a script that owns the power sequence owns it here too.
"""Wake the device, and launch a requested activity (t15).
With NO activity — or an empty one — this behaves exactly as before: the
configured script owns the wake sequence when there is one, otherwise POWER
goes out only when the device says it is off (unknown counts as "maybe on",
so nothing is toggled blindly).
With ``activity``, the same wake happens first (a script that owns turn_on
still owns it — it never touches the protocol), and then the app is launched
over the protocol through the same path ``select_source`` uses. A name the
activity list does not carry, and that is not a package/detail link either,
raises ``ServiceValidationError``: an activity request that cannot launch
must never look like it worked.
"""
activity = str(kwargs.get(ATTR_ACTIVITY) or "").strip()
await self._async_wake()
if activity:
await self._async_launch_activity(activity)
async def _async_wake(self) -> None:
"""Turn the device on: the configured script first, else the POWER key (t44).
t44: `turn_on` is one configuration, and the remote entity is the other route
to it; a script that owns the power sequence owns it here too.
"""
if await async_run_command_script(self.hass, self._entry.options, "turn_on"):
return
manager = self._manager_of()
@@ -152,6 +406,51 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
return
await manager.send_protocol_key(_POWER_KEY, "SHORT")
async def _async_launch_activity(self, activity: str) -> None:
"""Launch an activity through the shared launch path (t15).
The target comes from the same directory the activity list is built from, so
anything the picker offers resolves. A package id or a deep link passes
through: that is how an app the directory does not carry can still be
launched, and what core does with an activity it does not know. Anything else
is a request nothing can launch and raises instead of doing nothing.
"""
target = self._activity_target(activity)
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="protocol_unavailable",
translation_placeholders={"activity": activity},
)
if not await manager.launch_app(target):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="activity_launch_failed",
translation_placeholders={"activity": activity},
)
def _activity_target(self, activity: str) -> str:
"""Activity name (or id) → what the launch path takes (t15).
A listed NAME wins, compared case-insensitively — the same rule the
``source_list``/``select_source`` pair uses, so the picker can never offer a
name this does not resolve. A ``CONF_APP_NAME_MAP``-less entry still resolves
the curated names, because both read the one ``app_directory``.
"""
directory = app_directory(self._entry.options)
wanted = activity.casefold()
for package, name in directory.items():
if name.casefold() == wanted:
return package
if activity in directory or _looks_launchable(activity):
return activity
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unknown_activity",
translation_placeholders={"activity": activity},
)
async def async_turn_off(self, **kwargs) -> None:
"""Sleep the device — via the configured script, else the POWER key."""
if await async_run_command_script(self.hass, self._entry.options, "turn_off"):
@@ -377,6 +377,18 @@
"ambiguous_target": {
"title": "More than one Shield selected",
"message": "{entity_id} covers more than one adb_command target — select exactly one Shield (one media_player entity) — command not sent."
},
"unknown_activity": {
"title": "Unknown activity",
"message": "'{activity}' is not one of this Shield's activities. Use a name from the remote's activity list (the Apps map names, or the built-in list when no map is set), or pass an app's package id / a detail link."
},
"activity_launch_failed": {
"title": "Activity could not launch",
"message": "'{activity}' was not launched: the remote protocol could not run the command. Check the remote entity's protocol_* attributes (protocol_state says whether the transport is connected) — nothing was sent."
},
"protocol_unavailable": {
"title": "Remote-protocol transport unavailable",
"message": "'{activity}' was not launched: this entry has no remote-protocol transport (the option is off, or the androidtvremote2 library is missing). Turn the transport on and pair it, then try again."
}
},
"entity": {
@@ -377,6 +377,18 @@
"ambiguous_target": {
"title": "More than one Shield selected",
"message": "{entity_id} covers more than one adb_command target — select exactly one Shield (one media_player entity) — command not sent."
},
"unknown_activity": {
"title": "Unknown activity",
"message": "'{activity}' is not one of this Shield's activities. Use a name from the remote's activity list (the Apps map names, or the built-in list when no map is set), or pass an app's package id / a detail link."
},
"activity_launch_failed": {
"title": "Activity could not launch",
"message": "'{activity}' was not launched: the remote protocol could not run the command. Check the remote entity's protocol_* attributes (protocol_state says whether the transport is connected) — nothing was sent."
},
"protocol_unavailable": {
"title": "Remote-protocol transport unavailable",
"message": "'{activity}' was not launched: this entry has no remote-protocol transport (the option is off, or the androidtvremote2 library is missing). Turn the transport on and pair it, then try again."
}
},
"entity": {