Second half of the intents feature (docs/INTENTS_PLAN.md §3-§5), engine first: the
options screen that WRITES a registration follows in this same ticket.
WHAT SHIPPED.
* THE MODEL (const.py + media_player.py). A registered intent is ordinary options
data — a list of dicts {id,name,package,activity,action,categories,data,fields}
— so it survives a reload and is otherwise inert. `normalise_intent` accepts a
row only if it names something to launch (a component with the slash Android
requires, OR an action), `registered_intents` reads the options defensively
(junk rows dropped, duplicate ids keep the first, the registration cap applied)
and never touches a shell.
* THE COMMAND. `intent_am_start_command` assembles
`am start -n <component> -a <action> -c <category>… -d <data> --es/--ei/--ez key value`.
`{placeholder}` substitution is plain regex replacement over the DATA template
only — no eval, no format(), no shell interpolation — and every value is
shlex.quote'd, so a variable whose value is `"; rm -rf /"` reaches the device as
literal data inside one quoted word. Address validation for adb_command is
untouched; `adb_command` stays the one raw-shell door.
* HONEST FAILURES, each with a plain message AND a translation key so the button
and the action both read well: an unknown intent name lists the registered ones,
a missing REQUIRED field names the field, a variable the intent did not declare
is REFUSED (nothing silently travels to the device and nothing is silently
dropped), a value that cannot become its declared type names the field and the
value, a data template with an unfilled placeholder names the placeholder, ADB
off/down says the protocol cannot carry intents, a transport that never answered
says so, and a device that refused the intent surfaces the DEVICE'S OWN message
("unable to resolve Intent", "does not exist", "Permission Denial") and raises.
* THE ACTION. `nvidia_shield.send_intent` takes `intent` (a select selector whose
options are the loaded intents, `custom_value` on) plus `variables`, resolves
exactly ONE Shield through t14's resolver (one participant, or no_target /
unsupported_target / ambiguous_target naming the service), and returns the
device's output under `intent_response`. Registered with
`SupportsResponse.OPTIONAL` FIRST, because `async_set_service_schema` reads
`hass.services.supports_response()` to publish the response flag — the t14
mechanism, re-verified against the installed HA for t17. The description is
republished on every entry setup, so an options change (which reloads) refreshes
the dropdown.
* THE BUTTONS. One button per registered intent (name from the registration, id
from the intent's stable id so a rename does not orphan the entity), pressing it
sends the intent with the fields' declared defaults and raises on every failure
instead of doing nothing. Its extra attributes expose what it will send.
TWO MEASURED REPAIRS IN THE SAME HOOK (media_player.py). Both were latent because
the test harness pre-sets `_manager`:
* the service registries were filled BEFORE the entry's manager was assigned, so a
real entry registered `None` and `adb_command` would crash with AttributeError
instead of answering. The manager is now consumed first, and both registries are
filled after it;
* `async_will_remove_from_hass` never dropped the entity's participation (the
comment said it did), so a removed or unloaded entry left a stale manager and the
action answered `adb_unavailable` for an entity that no longer exists instead of
reporting an unsupported target. Both registries are dropped now, keyed by the
key computed at add time.
Verify: 794 passed / 4 skipped and 795 passed / 3 skipped (androidtvremote2) —
unchanged counts, so nothing regressed; ruff clean.
The two address fields were the same Shield spelled twice, and every bug in the
zeroconf/IP-change path came from keeping them in sync. One field now:
* const.CONF_HOST replaces CONF_ADB_HOST / CONF_PROTOCOL_HOST in the setup form,
the options page, the pairing forms, the reconfigure/reauth seeding and the
description copy. ADB port 5555 stays the only port field;
* helpers.entry_host(entry) is the single resolver (options first, then data);
resolve_adb_config and the protocol settings both go through it, so both
transports read the same value at call time (the t32 F1 factory re-read keeps
working: the NEXT built poller sees the new address);
* helpers.seed_host / seed_adb_enabled own the YAML seed's shape rule. A block
may use `host` or the legacy protocol_host/adb_host keys — t41 keeps the old
spelling working — and t32 F4's outcome is unchanged: a protocol-only block
leaves ADB off, an address (or an explicit adb_enabled) turns it on;
* the zeroconf IP-change path collapses the t21-F2 per-host dance into one rule:
the discovery address IS the entry's address, so the entry is updated when it
differs and left alone when it matches. The blank-self-heal and
deliberately-different-ADB-host cases are gone with the duplication — a
second address field cannot express them anymore, and the tests now pin that
(previously "an explicit override is never clobbered");
* the options page exposes the protocol toggle it never had (the page could turn
ADB off but not the protocol);
* button.py (granted to this task) reads the entry's own address instead of
another integration's entity.
The v3 entry migration that maps existing adb_host/protocol_host onto CONF_HOST
and drops the androidtv key lands in the next commit; HA runs migrations before
setup, so an existing entry is never read with the old shape.
Verified: 311 passed / 2 skipped, 312 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD. The UI-copy contract tests (every rendered field
has a label in strings.json AND translations/en.json, key trees identical, host
errors name the field) drove the string edits.
The user ruling: this integration REPLACES both old integrations, so nothing may
depend on an androidtv/androidtv_remote entity anymore.
Removed:
* the setup-form field + its entity selector (CONF_ANDROIDTV_ENTITY is no longer
offered, only read by the migration that drops it);
* the entity-derived unique_id derivation (_unique_id_from_atv_device, t29 G2)
and, with it, every registry lookup for another integration's devices — the
user step now sets NO unique_id at all; the protocol MAC learned during
pairing is the identity;
* the Jellyfin auto-detect's second tier (the atv entity's friendly_name) — it
now uses the entry name the user typed or the device name from discovery;
* the app-name and app-id tiers (media_player.app_name/app_id + the shared
resolve_app_name chain, the Current App sensor, the sensor's state-change
listener and its `_androidtv_state` helper) — the manager's store is the only
source;
* the "transitional external entity" command tier in every path: `_cmd` and the
pan-media_player service call are gone, and so is the fallback in _media_cmd,
_remote_key_cmd, the §7 channel-digit sequence and the intent self-heal retry
(an intent no transport served now surrenders to the truth instead of
delegating);
* the entity-derived power signal (`_on_source_change`'s "device off clears the
intent" check no longer watches another integration's entity; the power
LADDER — protocol is_on → ADB wake → power switch — is unchanged).
turn_on/turn_off are native now: they send the protocol POWER key and, like the
remote entity, send nothing when the known state already matches (no superfluous
toggle). An ADB-only entry (protocol skipped) has no power write path — the
manager owns the socket and exposes no device-keyevent, which is what the old
delegation used to cover.
helpers.discover_adb_host — the one helper that derived a host from another
integration's config entry — is deleted; button.py (granted to this task) reads
the entry's own address fields instead of the entity, so the Pair ADB button
keeps working.
Present-day entries that still carry the key are unaffected (nothing reads it);
the v3 migration that drops it lands with the host unification.
Verified: 309 passed / 2 skipped, 310 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD (13 modules, 58 imports, 35 call arguments, 6
awaited HA calls) and 16 passed in the real-HA drift suite. The stub harness's
legacy service recorder became a watchdog: any HA service call from the media
player now fails the routing tests instead of being the expected outcome.
Home Assistant custom integration for NVIDIA Shield TV that composes
the Android TV Remote, Jellyfin, smart plug, and receiver entities
into a single unified media player with ADB-based now-playing from
any app. Part of the Media Zone ecosystem.
Entities:
- media_player: power (with smart plug fallback), transport, input,
volume routed to receiver, rich stream info overlay when Jellyfin
is active
- sensor: DAG attributes and stream info
- button: Pair ADB
Config:
- Two-step flow: basic setup + optional ADB pairing on the Shield
- Configurable ADB port (default 5555)
- Pick an existing Jellyfin media_player entity — stream info is read
from the HA Jellyfin integration's own DataUpdateCoordinator, so
no server URL / API key / extra config needed here
- Privacy mode (adb_store_app_info=False) suppresses non-Jellyfin
media details, showing only the app name
Ecosystem:
- Exposes media_dag_* attributes for ha-media-zone orchestration
- Exposes media_dag_running_apps and async_bring_to_foreground for
the Media Zone card's app switcher
Shared ADB code lives in ltomes/ha-adb-common and is vendored at
custom_components/nvidia_shield/ha_adb_common/ for HACS installs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>