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.
382 lines
14 KiB
Python
382 lines
14 KiB
Python
"""The Nvidia Shield integration.
|
|
|
|
Phase 1 architecture (docs/MVP_PLAN.md §2): the entry owns a single
|
|
ShieldConnectionManager stored in ``entry.runtime_data`` — the persistent ADB
|
|
socket and all ADB-derived state live there, not on the entities. Entities are
|
|
thin callback-views over the manager's StateStore.
|
|
|
|
ADB is optional: a failed/absent ADB connection never blocks setup (no
|
|
ConfigEntryNotReady) — the manager retries in the background, or stays
|
|
passive until the user pairs via the Pair ADB button.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
|
|
from homeassistant import config_entries
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
from .adb_session import AdbMediaSessionPoller
|
|
from .connection import ShieldConnectionManager
|
|
from .const import (
|
|
ADB_COMMAND_SERVICE_DESCRIPTION,
|
|
CONF_ADB_ENABLED,
|
|
CONF_ADB_HOST,
|
|
CONF_ADB_PORT,
|
|
CONF_ANDROIDTV_ENTITY,
|
|
CONF_HOST,
|
|
CONF_PROTOCOL_ENABLED,
|
|
CONF_PROTOCOL_HOST,
|
|
CONF_REGISTERED_INTENTS,
|
|
DEFAULT_ADB_PORT,
|
|
DEFAULT_NAME,
|
|
DOMAIN,
|
|
ENTRY_VERSION,
|
|
SERVICE_ADB_COMMAND,
|
|
SERVICE_SEND_INTENT,
|
|
)
|
|
from .helpers import (
|
|
entry_host,
|
|
protocol_cert_paths,
|
|
resolve_adb_config,
|
|
seed_adb_enabled,
|
|
seed_host,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# t66: IMAGE is listed unconditionally — the platform's async_setup_entry adds
|
|
# nothing unless the user opted in, so an install that never turns screen capture
|
|
# on pays nothing for the platform existing (and toggling it is an options reload).
|
|
PLATFORMS = [
|
|
Platform.MEDIA_PLAYER,
|
|
Platform.SENSOR,
|
|
Platform.REMOTE,
|
|
Platform.BUTTON,
|
|
Platform.IMAGE,
|
|
]
|
|
|
|
|
|
async def async_setup(hass: HomeAssistant, config) -> bool:
|
|
"""Domain setup: actions (always-on) + optional YAML seeding (t30 U1).
|
|
|
|
YAML is SEED-ONLY (user ruling): every ``nvidia_shield`` block starts an
|
|
interactive SOURCE_IMPORT config flow — nothing is created without the
|
|
user walking the flow, and pairing stays interactive exactly as for UI
|
|
setup. Removing the YAML block creates OR deletes nothing. UI-configured
|
|
entries are never re-seeded (host/MAC duplicate guards).
|
|
"""
|
|
domain_config = (config or {}).get(DOMAIN)
|
|
for block in _yaml_seed_blocks(domain_config or []):
|
|
if not seed_host(block):
|
|
_LOGGER.warning(
|
|
"nvidia_shield YAML block without an address ignored: %s",
|
|
block.get("name", "<unnamed>"),
|
|
)
|
|
continue
|
|
hass.async_create_task(
|
|
hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": config_entries.SOURCE_IMPORT},
|
|
data=block,
|
|
)
|
|
)
|
|
# t29 G8 (kept here across the t30 splice): the action registers ALWAYS.
|
|
# The two APIs the target-bearing registration needs are imported WITH that
|
|
# registration, next to the handler they belong to (t14): importing the package —
|
|
# which every config-flow harness does via
|
|
# ``custom_components.nvidia_shield.config_flow`` — must not drag in the service
|
|
# registration surface for a domain that harness never sets up.
|
|
from homeassistant.core import SupportsResponse
|
|
from homeassistant.helpers.service import async_set_service_schema
|
|
|
|
from .media_player import (
|
|
_ADB_COMMAND_SCHEMA,
|
|
_INTENT_SCHEMA,
|
|
_async_adb_command_service,
|
|
_async_send_intent_service,
|
|
send_intent_service_description,
|
|
)
|
|
|
|
# t14: the action must be SELECTABLE for the user's Shield. Measured on HA
|
|
# 2026.10.1: `async_register` has no `target` parameter, so the UI's target
|
|
# picker comes from the service DESCRIPTION — the runtime
|
|
# `async_set_service_schema` (which wins over a services.yaml we do not ship)
|
|
# carries `target: {entity: [{domain: media_player}]}`, so the picker offers the
|
|
# Shield's media player entity. `supports_response=OPTIONAL` is registered FIRST
|
|
# because async_set_service_schema reads `hass.services.supports_response()` to
|
|
# publish the response flag, and the handler returns the command's output under
|
|
# ADB_COMMAND_RESPONSE_KEY.
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_ADB_COMMAND,
|
|
_async_adb_command_service,
|
|
schema=_ADB_COMMAND_SCHEMA,
|
|
supports_response=SupportsResponse.OPTIONAL,
|
|
)
|
|
async_set_service_schema(
|
|
hass, DOMAIN, SERVICE_ADB_COMMAND, ADB_COMMAND_SERVICE_DESCRIPTION
|
|
)
|
|
|
|
# t17: send_intent — the same measured mechanism as t14 (a runtime
|
|
# description carries the target picker, and the response flag is read from
|
|
# the registration, so OPTIONAL is registered FIRST). The description is
|
|
# republished by every async_setup_entry because the intent DROPDOWN is
|
|
# per-entry data while the service is domain-wide; at domain setup no entry
|
|
# has loaded yet, so the list is empty and every name is still typeable
|
|
# (`custom_value`), with the call-time lookup as the authority.
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_SEND_INTENT,
|
|
_async_send_intent_service,
|
|
schema=_INTENT_SCHEMA,
|
|
supports_response=SupportsResponse.OPTIONAL,
|
|
)
|
|
async_set_service_schema(
|
|
hass, DOMAIN, SERVICE_SEND_INTENT, send_intent_service_description(())
|
|
)
|
|
return True
|
|
|
|
|
|
def _yaml_seed_blocks(domain_config) -> list[dict]:
|
|
"""Normalize the optional YAML domain key into seed blocks (t30 U1)."""
|
|
blocks: list[dict] = []
|
|
raw_list = domain_config if isinstance(domain_config, list) else [domain_config]
|
|
for block in raw_list:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
name = block.get("name") or DEFAULT_NAME
|
|
# t32 F4 (review rider): the seed honors the block's shape — a
|
|
# protocol-only block does NOT force ADB on. t41: the address is ONE
|
|
# field (`host`, or a legacy protocol_host/adb_host).
|
|
seed = {
|
|
"name": name,
|
|
CONF_ADB_ENABLED: seed_adb_enabled(block),
|
|
CONF_HOST: seed_host(block),
|
|
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
|
|
}
|
|
if block.get("unique_id"):
|
|
seed["unique_id"] = str(block["unique_id"])
|
|
# t17: the intents block travels VERBATIM to the import flow, which owns
|
|
# the validation (and drops unusable rows with a log line) — the seed
|
|
# normaliser here only decides which keys reach the flow at all.
|
|
if isinstance(block.get(CONF_REGISTERED_INTENTS), list):
|
|
seed[CONF_REGISTERED_INTENTS] = block[CONF_REGISTERED_INTENTS]
|
|
blocks.append(seed)
|
|
return blocks
|
|
|
|
|
|
async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
|
|
"""Poller factory when ADB is configured AND usable today, else None.
|
|
|
|
Returning None leaves the manager passive: the entry still sets up, the
|
|
entities simply see no ADB state. The Pair ADB button / reconfigure
|
|
triggers a reload that re-evaluates this.
|
|
"""
|
|
if not entry.options.get(CONF_ADB_ENABLED, False):
|
|
return None
|
|
|
|
adb = resolve_adb_config(hass, entry)
|
|
if adb is None:
|
|
_LOGGER.warning(
|
|
"ADB enabled but no host resolvable for entry %s — continuing "
|
|
"without ADB state.",
|
|
entry.title,
|
|
)
|
|
return None
|
|
|
|
# Only auto-connect when a key already exists — an ADB handshake against
|
|
# an unpaired device is pointless noise (pairing happens via the Pair ADB
|
|
# button / config flow, untouched).
|
|
key_path_ready = await hass.async_add_executor_job(
|
|
os.path.exists, adb.key_path
|
|
)
|
|
if not key_path_ready:
|
|
_LOGGER.info(
|
|
"ADB key not found for entry %s — skipping auto-connect. "
|
|
"Use the Pair ADB button or reconfigure the integration.",
|
|
entry.title,
|
|
)
|
|
return None
|
|
|
|
# t32 F1 (review repair): the audit-sanctioned FACTORY RE-READ — the
|
|
# host/port resolve from entry.options AT CALL TIME, so the poller built
|
|
# after a zeroconf IP change targets the NEW address without a reload
|
|
# (the setup-time closure pinned the old address). A key-path change
|
|
# still needs the Pair ADB button / reconfigure reload, by design.
|
|
def _factory():
|
|
adb_now = resolve_adb_config(hass, entry)
|
|
if adb_now is None:
|
|
return None
|
|
return AdbMediaSessionPoller(adb_now.host, adb_now.port, adb_now.key_path)
|
|
|
|
return _factory
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Set up Nvidia Shield from a config entry."""
|
|
hass.data.setdefault(DOMAIN, {})
|
|
hass.data[DOMAIN][entry.entry_id] = entry
|
|
|
|
manager = ShieldConnectionManager(
|
|
hass,
|
|
entry,
|
|
poller_factory=await _build_poller_factory(hass, entry),
|
|
protocol_settings=await _build_protocol_settings(hass, entry),
|
|
invalid_auth_callback=lambda: entry.async_start_reauth(hass),
|
|
)
|
|
entry.runtime_data = manager
|
|
await manager.async_start()
|
|
|
|
# Close the persistent socket on Home Assistant stop as well as unload.
|
|
async def _close_on_hass_stop(_event) -> None:
|
|
await manager.async_stop()
|
|
|
|
entry.async_on_unload(
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close_on_hass_stop)
|
|
)
|
|
|
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
|
|
# t17: refresh the send_intent dropdown from what is ACTUALLY loaded. This
|
|
# runs on every reload, and an options change reloads the entry, so adding
|
|
# or removing an intent updates the picker. The union across entries is
|
|
# deliberate: the service is domain-wide and any of them may be the target.
|
|
_publish_send_intent_description(hass)
|
|
|
|
return True
|
|
|
|
|
|
def _publish_send_intent_description(hass: HomeAssistant) -> None:
|
|
"""Republish send_intent's description with the loaded intent names (t17).
|
|
|
|
Reads each loaded entry's OPTIONS through the runtime's own view of them
|
|
(``registered_intents``), so a hand-edited or malformed row cannot reach the
|
|
picker — the dropdown offers exactly what the action would accept.
|
|
"""
|
|
try:
|
|
from homeassistant.helpers.service import async_set_service_schema
|
|
|
|
from .media_player import registered_intents, send_intent_service_description
|
|
except ImportError: # pragma: no cover - a harness without HA's service module
|
|
# The dropdown is a RENDERING nicety: the action itself is registered by
|
|
# async_setup and works without it. Only a stub harness (which loads this
|
|
# module without the real homeassistant package) can get here.
|
|
return
|
|
|
|
names: list[str] = []
|
|
for loaded in (hass.data.get(DOMAIN) or {}).values():
|
|
options = getattr(loaded, "options", None)
|
|
if options is None:
|
|
options = getattr(getattr(loaded, "entry", None), "options", None)
|
|
names.extend(intent["name"] for intent in registered_intents(options or {}))
|
|
async_set_service_schema(
|
|
hass, DOMAIN, SERVICE_SEND_INTENT, send_intent_service_description(names)
|
|
)
|
|
|
|
|
|
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
|
"""Manager protocol_settings when the transport is enabled & resolvable.
|
|
|
|
The address is the entry's own single field (t41). Nothing is derived from
|
|
another integration.
|
|
"""
|
|
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
|
|
return None
|
|
|
|
host = entry_host(entry)
|
|
if not host:
|
|
_LOGGER.info(
|
|
"Protocol enabled for entry %s but no host resolvable — continuing "
|
|
"without the remote-protocol transport. Pair via the flow.",
|
|
entry.title,
|
|
)
|
|
return None
|
|
|
|
certfile, keyfile = await hass.async_add_executor_job(protocol_cert_paths, hass)
|
|
return {
|
|
"host": host,
|
|
"client_name": entry.title or "Home Assistant",
|
|
"certfile": certfile,
|
|
"keyfile": keyfile,
|
|
}
|
|
|
|
|
|
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
|
"""Migrate an older config entry to the current version.
|
|
|
|
v1 → v2 added the remote-protocol fields (ADB options carried over
|
|
untouched). v2 → v3 (t41) mirrors the two changes in the stored shape:
|
|
|
|
* the duplicated address fields collapse into the single CONF_HOST — the
|
|
protocol host wins, else the ADB host, since both name the same Shield;
|
|
* the androidtv-entity key goes. This integration owns the whole stack now:
|
|
the entity it used to configure is neither a state source nor an app-name
|
|
or power tier, so keeping the value would only mislead.
|
|
|
|
Downgrades (version > ENTRY_VERSION) are rejected so HA offers a rollback.
|
|
"""
|
|
if config_entry.version > ENTRY_VERSION:
|
|
return False
|
|
if config_entry.version >= ENTRY_VERSION:
|
|
return True
|
|
|
|
options = {**config_entry.options}
|
|
data = {**config_entry.data}
|
|
|
|
if config_entry.version < 2:
|
|
options.setdefault(CONF_PROTOCOL_ENABLED, False)
|
|
|
|
if not options.get(CONF_HOST):
|
|
options[CONF_HOST] = (
|
|
options.get(CONF_PROTOCOL_HOST)
|
|
or options.get(CONF_ADB_HOST)
|
|
or data.get(CONF_PROTOCOL_HOST)
|
|
or data.get(CONF_ADB_HOST)
|
|
or ""
|
|
)
|
|
|
|
dropped_entity = options.pop(CONF_ANDROIDTV_ENTITY, None)
|
|
dropped_entity = data.pop(CONF_ANDROIDTV_ENTITY, None) or dropped_entity
|
|
for legacy_key in (CONF_PROTOCOL_HOST, CONF_ADB_HOST):
|
|
options.pop(legacy_key, None)
|
|
data.pop(legacy_key, None)
|
|
|
|
hass.config_entries.async_update_entry(
|
|
config_entry, data=data, options=options, version=ENTRY_VERSION
|
|
)
|
|
if dropped_entity:
|
|
_LOGGER.info(
|
|
"%s: config entry migrated to version %d — dropped the androidtv "
|
|
"entity %s (this integration reads the Shield directly; the entry "
|
|
"needs no external media_player)",
|
|
config_entry.title,
|
|
ENTRY_VERSION,
|
|
dropped_entity,
|
|
)
|
|
else:
|
|
_LOGGER.info(
|
|
"Migrated %s config entry to version %d",
|
|
config_entry.title,
|
|
ENTRY_VERSION,
|
|
)
|
|
return True
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Unload a config entry."""
|
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
|
|
if unload_ok:
|
|
manager = getattr(entry, "runtime_data", None)
|
|
if manager is not None:
|
|
await manager.async_stop()
|
|
entry.runtime_data = None
|
|
hass.data[DOMAIN].pop(entry.entry_id)
|
|
|
|
return unload_ok
|
|
|
|
|