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

+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.