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

+91
View File
@@ -419,6 +419,14 @@ already submitted. The two pairing rows appear only while that transport still n
pairing (a skip leaves its row there to retry; a successful pairing removes it), and
the menu's description names what is still to pair.
Turning **Add the Nvidia Remote Protocol** on in the **Main setup** row of the
*options* page does not silently enable a client that cannot connect: an entry that has
never completed a pairing is taken to the same **Pair the remote protocol** step, and
its **Continue without pairing** escape turns the option back off. Pairing is remembered
per entry, so switching the transport off and on again on a paired entry asks for no new
PIN (the certificate from the first pairing is reused — see
[is my transport paired?](#is-my-protocol-transport-paired-connected-or-not-running-at-all)).
### The Apps table
The **Apps** row opens a small editor rather than one long form. Home Assistant has no
@@ -767,6 +775,88 @@ All four variables are always present, including their defaults, so a script can
on the shape. Unset — the default — nothing changes: keys go out over the protocol
exactly as before.
### The remote entity: the app / activity picker
The `remote` entity speaks Home Assistant's own remote contract, matching the
`androidtv_remote` entity it replaces — so a replaced setup keeps its automations
**and** gets the app picker the frontend only renders for a remote that declares it:
| Piece | Value |
| --- | --- |
| `supported_features` | includes `RemoteEntityFeature.ACTIVITY` — without that flag Home Assistant offers **no activity picker at all**, which is why the remote card can look empty |
| `activity_list` | the friendly app names, in your order: your **Apps table** (or the built-in table when you have none), plus the apps **ADB discovery** actually found on the device |
| `current_activity` | the app on screen as a friendly name: the protocol's current-app push when it arrives, the ADB foreground reading otherwise. `None` while nothing is known — no activity is ever invented |
| `turn_on` with `activity:` | wakes the Shield when it reports itself off, then launches that app over the protocol |
```yaml
# launch Netflix by the name from the activity list / Apps table
action: remote.turn_on
target:
entity_id: remote.living_room
data:
activity: Netflix
```
An `activity` resolves in this order: a **name** from the activity list
(case-insensitive), then a **package id** or a **detail link** (`https://…`,
`market://…`) passed through unchanged — that is how an app your table does not carry
is still launchable, and what the other integration does with a name it does not know.
Anything else raises a clear error naming the value instead of silently doing nothing.
Leaving `activity` out (or empty) keeps the old behaviour exactly: wake through the
configured script, else `POWER` when the device reports it is off.
**Where the list comes from, and why.** `androidtvremote2` exposes **no app-list API
at all** (measured against the installed library) — which is exactly why
`androidtv_remote` keeps a user-maintained app map. This integration has the same kind
of source *plus* something the other one lacks: ADB discovery, so the picker can offer
what is actually installed and name it through the one chain (your Apps table → the
device's own label → the built-in table → the package id). Apps nothing can name are
not listed as bare package ids; pass their id to `activity` if you need them.
#### Is my protocol transport paired, connected, or not running at all?
The library offers no `is_paired` / `is_connected`, so the remote entity publishes what
can be **proven** (Developer Tools > States, or
`state_attr('remote.living_room', 'protocol_state')`):
| Attribute | Meaning |
| --- | --- |
| `protocol_state` | `off` (no transport configured), `never_connected`, `connected`, `disconnected`, or `auth_rejected` |
| `protocol_connected` | `true` only while the device reports the protocol session up |
| `protocol_ever_authenticated` | `true` once a session connected **or any push arrived** — the only proof of a paired client the library gives |
| `protocol_current_app_seen` / `protocol_is_on_seen` / `protocol_volume_seen` | whether each device push has **ever** arrived (sticky: the state cache expires, these do not) |
| `protocol_last_error` / `protocol_failures` | the last protocol connect/send failure verbatim, and how many sends could **not** run |
| `protocol_certificate` | `present` / `missing` — the certificate from your setup pairing, which is why enabling the transport on a paired entry asks for **no new PIN** |
| `power_on_route` / `power_off_route` | `protocol`, or `script:<script>` when a command script owns that command |
| `activity_source` (and the media player's `app_source`) | which tier produced the app identity: `protocol` (the device's own push) or `adb` (the dumpsys parse) — the push is preferred, and it is never a silent override |
Reading it:
* **`auth_rejected`** — the device refused our certificate (a factory reset, or a
deleted `.storage/nvidia_shield/client.pem`). Home Assistant also flags the entry
for reauthentication; that flow re-pairs.
* **`never_connected`** while the transport is enabled — switched on but with no
session. Pairing again is the answer (the options flow routes an unpaired entry
straight into the pairing step rather than enabling a client that cannot connect).
* **`off`** — the transport is not configured. Everything else here still works: ADB
is the state source, so an ADB-only setup degrades **only** the protocol surface.
* A send that could not run is **never silent**: it is counted, recorded verbatim in
`protocol_last_error`, and logged at WARNING together with `protocol_state`.
**The other Android TV integration.** `androidtv_remote` pairs **its own client** and
shares nothing with us: its certificate lives in its own storage and its entities are
its own. Ours (`client.pem` under `.storage/nvidia_shield/`) is reused between pairing
and runtime by design, so *not* being asked for a PIN on an already-paired entry is
expected — and it says nothing about whether the session is up. `protocol_state`
answers that.
**A configured script bypasses the protocol.** When a command script owns `turn_on`
(or `turn_off`), that script runs and the protocol is **never touched** — so a power
press that worked through it is *not* evidence that the transport is paired, or even
connected (`power_on_route` reads `script:…`). A configured **Remote command script**
is the same story for `remote.send_command`. Only a command that went out over the
protocol, with `protocol_state` = `connected`, exercises the transport.
## Entities
| Entity | Type | Description |
@@ -774,6 +864,7 @@ exactly as before.
| Media Player | `media_player` | Main entity with playback controls, metadata and the flat `now_playing_*` attributes (below) |
| Current app (`sensor.current_app`) | `sensor` | Currently running app |
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string, with its own flat attributes (below) |
| Remote | `remote` | Key commands, the activity/app picker, and the transport-state attributes (see [the activity picker](#the-remote-entity-the-app--activity-picker)) |
| Pair ADB | `button` | Initiate or re-pair ADB connection |
| Screen capture (`image.screen_capture`) | `image` | The Shield's current screen — **opt-in, off by default**, manual/diagnostic only (see [Screen capture](#screen-capture-opt-in-manual)) |