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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user