feat(intents): the registered-intent engine, the buttons and the send_intent action (t17, part 1)

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.
This commit is contained in:
James Mackay committed 2026-10-12 12:43:10 +01:00
1 parent 2819ee5a49
commit a8041e1428
4 files changed
+785 -32

No files matched your search

+88 -1
View File
@@ -16,6 +16,11 @@ from .const import (
DOMAIN,
)
from .helpers import entry_host
from .media_player import (
intent_default_variables,
registered_intents,
run_registered_intent,
)
_LOGGER = logging.getLogger(__name__)
@@ -25,8 +30,18 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
entities: list[ButtonEntity] = []
if entry.options.get(CONF_ADB_ENABLED, False):
async_add_entities([NvidiaShieldPairAdbButton(hass, entry)])
entities.append(NvidiaShieldPairAdbButton(hass, entry))
# t17: one button per REGISTERED intent. The registration IS the opt-in (the
# plan: nothing registered ever runs by itself), so a new intent adds a
# button and a removed one takes its button away — the options flow reloads
# the entry on every save, which re-runs this setup.
manager = getattr(entry, "runtime_data", None)
for intent in registered_intents(entry.options):
entities.append(NvidiaShieldIntentButton(hass, entry, manager, intent))
if entities:
async_add_entities(entities)
class NvidiaShieldPairAdbButton(ButtonEntity):
@@ -85,3 +100,75 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
host,
)
await poller.disconnect()
class NvidiaShieldIntentButton(ButtonEntity):
"""One button per REGISTERED intent (t17).
The whole point for an intent with no variables: pressing it sends the intent
with the fields' declared defaults. It deliberately does NOT cache a command
— :func:`run_registered_intent` builds it every press, so the button, the
action and the tests all exercise one code path.
Failures are LOUD. A missing required field, ADB being off/down, a transport
that never answered and a device that refused the intent all raise with the
reason (the device's own message included) — a button that silently did
nothing is exactly the "silent success" the plan forbids. An app that
rejected the intent is a CONFIGURATION problem, and the raised text says so.
"""
_attr_has_entity_name = True
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, manager, intent: dict
) -> None:
self.hass = hass
self._entry = entry
self._manager = manager
self._intent = intent
self._attr_name = intent["name"]
# the intent id is stable across renames, so a rename does not orphan the
# entity (and does not create a second one)
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_intent_{intent['id']}"
self._attr_icon = "mdi:gesture-tap-button"
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="NVIDIA",
model="Shield TV",
)
@property
def extra_state_attributes(self) -> dict:
"""What this button will send — the registration, readable (t17).
A button whose registration the user cannot see is a black box: these are
the SAME values the command is built from, and package/action/component
are what they typed on the Intents screen.
"""
return {
"intent_id": self._intent["id"],
"package": self._intent["package"],
"activity": self._intent.get("activity") or None,
"action": self._intent.get("action") or None,
"categories": list(self._intent.get("categories") or []),
"data": self._intent.get("data") or None,
"fields": [dict(field) for field in (self._intent.get("fields") or [])],
}
async def async_press(self) -> None:
"""Send the intent with its declared defaults, or raise with the reason."""
entity_id = getattr(self, "entity_id", None) or self._attr_unique_id
variables = intent_default_variables(self._intent)
output = await run_registered_intent(
self._manager, entity_id, self._intent, variables
)
_LOGGER.debug(
"intent button %s sent %s → %s",
entity_id,
self._intent["id"],
(output or "").strip().splitlines()[:1],
)