From a8041e14280c3637ed009ef73d1f1b759c9e55d2 Mon Sep 17 00:00:00 2001 From: James Mackay Date: Mon, 12 Oct 2026 12:43:10 +0100 Subject: [PATCH] feat(intents): the registered-intent engine, the buttons and the send_intent action (t17, part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -a -c … -d --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. --- custom_components/nvidia_shield/__init__.py | 67 +- custom_components/nvidia_shield/button.py | 89 ++- custom_components/nvidia_shield/const.py | 74 +++ .../nvidia_shield/media_player.py | 587 +++++++++++++++++- 4 files changed, 785 insertions(+), 32 deletions(-) diff --git a/custom_components/nvidia_shield/__init__.py b/custom_components/nvidia_shield/__init__.py index 070d2c0..0ef9127 100644 --- a/custom_components/nvidia_shield/__init__.py +++ b/custom_components/nvidia_shield/__init__.py @@ -29,11 +29,13 @@ from .const import ( 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, @@ -90,7 +92,13 @@ async def async_setup(hass: HomeAssistant, config) -> bool: from homeassistant.core import SupportsResponse from homeassistant.helpers.service import async_set_service_schema - from .media_player import _ADB_COMMAND_SCHEMA, _async_adb_command_service + 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 @@ -111,6 +119,24 @@ async def async_setup(hass: HomeAssistant, config) -> bool: 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 @@ -133,6 +159,11 @@ def _yaml_seed_blocks(domain_config) -> list[dict]: } 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 @@ -209,9 +240,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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. diff --git a/custom_components/nvidia_shield/button.py b/custom_components/nvidia_shield/button.py index d0c053d..ae126b6 100644 --- a/custom_components/nvidia_shield/button.py +++ b/custom_components/nvidia_shield/button.py @@ -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], + ) diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py index c5b362f..c847b41 100644 --- a/custom_components/nvidia_shield/const.py +++ b/custom_components/nvidia_shield/const.py @@ -236,6 +236,80 @@ ADB_COMMAND_SERVICE_DESCRIPTION: dict = { }, } +# ── t17: registered intents (the second half of the intents feature) ── +# +# A registered intent is ordinary OPTIONS data: a list of dicts, one per intent, +# stored under CONF_REGISTERED_INTENTS in JSON-safe form (no sets, no objects). +# It is read by three consumers that must agree about the shape: the options +# screen (which writes it), the button platform (one entity per row) and the +# nvidia_shield.send_intent action (which builds the command). The shape is the +# one docs/INTENTS_PLAN.md §3 fixes: +# +# {id, name, package, activity, action, categories, data, fields} +# +# ``activity`` and ``action`` are alternatives (the plan: a component OR an +# action, optionally with categories), and ``fields`` declares the variables the +# caller may (or must) supply. Only the DATA template carries {placeholders}. +CONF_REGISTERED_INTENTS = "registered_intents" + +#: The action that fires a registered intent. Named by the feature, not by the +#: mechanism: the device's own vocabulary is "am start", which the user never +#: types. +SERVICE_SEND_INTENT = "send_intent" + +#: t17: the key the device's `am start` output is returned under. Deliberately +#: NOT ``adb_response``: that key belongs to the raw-command action, and a +#: template reading it should not silently start seeing intent output. +INTENT_RESPONSE_KEY = "intent_response" + +#: The four field types the plan fixes (v1): string/int/bool map to the device's +#: own flags (``--es``/``--ei``/``--ez``), and ``raw`` passes an extra through +#: verbatim — the documented escape hatch, NOT the default (the plan's §9 +#: non-goal is that nothing beyond these four is expressible). +INTENT_FIELD_TYPES = ("string", "int", "bool", "raw") + +#: The flag each declared type becomes on ``am start``. +INTENT_TYPE_FLAGS = { + "string": "--es", + "int": "--ei", + "bool": "--ez", + "raw": "--es", +} + +#: The explicit "Add new" sentinel of the intents selector. A registered intent +#: id can never collide with it: ids must be slug-shaped (letters, digits, +#: underscore), and this one is not. +INTENT_NEW_SENTINEL = "__new__" + +#: The options-flow fields of the per-intent form (one row at a time, the same +#: idiom as the apps editor). The declared fields are edited as TEXT, one +#: ``key:type[:required][=default]`` per line, because that is the shape the user +#: already knows from the apps table and it survives a paste. +INTENT_FIELD_CHOICE = "intent_choice" +INTENT_FIELD_NAME = "intent_name" +INTENT_FIELD_PACKAGE = "intent_package" +INTENT_FIELD_ACTIVITY = "intent_activity" +INTENT_FIELD_ACTION = "intent_action" +INTENT_FIELD_CATEGORIES = "intent_categories" +INTENT_FIELD_DATA = "intent_data" +INTENT_FIELD_DECLARED = "intent_fields" +INTENT_FIELD_CANDIDATE = "intent_candidate" +INTENT_FIELD_SCAN_PACKAGE = "intent_scan_package" + +#: How many declared fields one intent may carry, and how many intents an entry +#: may register. Bounded so a hand-edited options blob (or a runaway paste) +#: cannot turn one button press into a command with thousands of flags, and so +#: the options page stays readable. +INTENT_FIELD_LIMIT = 20 +INTENT_REGISTRATION_LIMIT = 50 + +#: The same target picker as adb_command (t14's measured mechanism), reused +#: verbatim: an intent is sent to exactly ONE Shield, selected as its media_player +#: entity, and the runtime description is what renders the picker. +INTENT_SERVICE_TARGET: dict = { + "entity": [{"domain": ADB_COMMAND_TARGET_DOMAIN}] +} + # P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer # (foreground AppState fast-path). Default OFF; the user flips it on and # reports stability on their device. diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 921d10e..3492122 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -68,6 +68,8 @@ Intent tracking bridges the gap between instant commands and slow Jellyfin polli import asyncio import logging +import re +import shlex import time import voluptuous as vol @@ -117,14 +119,22 @@ from .const import ( CONF_POWER_ENTITY, CONF_RECEIVER_ENTITY, CONF_RECEIVER_INPUT, + CONF_REGISTERED_INTENTS, DEFAULT_APP_DIRECTORY, DOMAIN, + INTENT_FIELD_LIMIT, + INTENT_FIELD_TYPES, + INTENT_REGISTRATION_LIMIT, + INTENT_RESPONSE_KEY, + INTENT_SERVICE_TARGET, + INTENT_TYPE_FLAGS, PROTOCOL_MEDIA_KEYS, PROTOCOL_REMOTE_KEYS, SCRIPT_VAR_APP_NAME, SCRIPT_VAR_LEVEL, SCRIPT_VAR_MUTED, SERVICE_ADB_COMMAND, + SERVICE_SEND_INTENT, TIVIMATE_PACKAGE, command_scripts, ) @@ -614,23 +624,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True) async def async_added_to_hass(self) -> None: - # P4-2a: opt-in as an adb_command target (option default off). - # Registered at setup; unregistered at unload when the last - # participating entity goes away. - if self._entry.options.get(CONF_ADB_COMMAND_ENABLED, False): - # F4: the registry key is computed once and stored on the - # entity, so add/remove always agree even when entity_id is - # assigned by HA core after the first read. - self._adb_command_key = ( - getattr(self, "entity_id", None) or self._attr_unique_id - ) - _ADB_COMMAND_TARGETS[self._adb_command_key] = self._manager - - # Discover power sensor siblings from the power switch - if self._power_entity: - self._power_sensors = discover_power_siblings(self.hass, self._power_entity) - - # Consume the entry's ShieldConnectionManager (thin-view pattern) + # Consume the entry's ShieldConnectionManager (thin-view pattern) FIRST: + # everything below that publishes this entity as a service target needs + # the manager, and ``__init__`` only knows the entry. manager = getattr(self._entry, "runtime_data", None) if manager is not None: self._manager = manager @@ -638,6 +634,29 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): self._on_manager_update ) + # F4: the registry key is computed once and stored on the entity, so + # add/remove always agree even when HA core assigns entity_id AFTER the + # first read. t17: the same key now serves both service registries + # (``adb_command`` and ``send_intent``), which is why it is not called + # ``_adb_command_key`` any more. + self._service_target_key = ( + getattr(self, "entity_id", None) or self._attr_unique_id + ) + # P4-2a: opt-in as an adb_command target (option default off). Registered + # at setup; unregistered at unload (see async_will_remove_from_hass). + if self._entry.options.get(CONF_ADB_COMMAND_ENABLED, False): + _ADB_COMMAND_TARGETS[self._service_target_key] = self._manager + # t17: registered intents are the user's OWN opt-in — nothing runs unless + # they registered one — so this entity participates in ``send_intent`` + # whenever it exists. An entry whose ADB is off still resolves as a + # target and then says so honestly (``intents_need_adb``), which is a + # better answer than "that entity is not a target". + _INTENT_TARGETS[self._service_target_key] = self._manager + + # Discover power sensor siblings from the power switch + if self._power_entity: + self._power_sensors = discover_power_siblings(self.hass, self._power_entity) + entities = [ entity_id for entity_id in (self._jellyfin_entity, self._power_entity) @@ -661,8 +680,19 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): for task in self._entity_tasks: task.cancel() self._entity_tasks.clear() - # P4-2a/t29 G8: drop this entity's service participation (same key - # as add). The service itself stays registered (always-on setup). + # P4-2a/t29 G8: drop this entity's service participation (same key as add, + # NEVER the current entity_id — HA may have changed it). The services + # themselves stay registered (always-on setup). + # + # t17 FIX: this drop was missing entirely, so a removed or unloaded entry + # kept a STALE manager in the registry and ``adb_command`` would answer + # ``adb_unavailable`` (or crash) for an entity that no longer exists + # instead of reporting an unsupported target. Both registries are + # dropped here because both are filled in async_added_to_hass. + key = getattr(self, "_service_target_key", None) + if key: + _ADB_COMMAND_TARGETS.pop(key, None) + _INTENT_TARGETS.pop(key, None) # ── Intent management ── @@ -1876,40 +1906,61 @@ def _requested_adb_command_targets(call) -> list[str]: return list(dict.fromkeys(requested)) -def _single_adb_command_target(call) -> str: - """Exactly ONE participating Shield, or a ServiceValidationError (t14). +def _single_participant_target(call, targets: dict, *, service: str) -> str: + """Exactly ONE participating Shield from ``targets``, or an error (t14/t17). The three failures are told apart because the fix differs: nothing selected (``no_target`` — pick the Shield), something selected that does not participate - (``unsupported_target`` — the entry's option is off, or it is another - integration's entity), and more than one selected (``ambiguous_target`` — a raw - shell command is never broadcast, and never aimed at the wrong Shield). + in THIS action (``unsupported_target`` — its entry does not offer the action, + or it is another integration's entity), and more than one selected + (``ambiguous_target`` — never broadcast, never aimed at the wrong Shield). + + t17: the same resolver serves ``adb_command`` and ``send_intent``; only the + registry of participants differs, and the messages name the SERVICE so the + error reads correctly for both (the placeholders are passed either way, so + neither call site can forget one). """ requested = _requested_adb_command_targets(call) if not requested: raise ServiceValidationError( + f"No Shield selected: pick one in the target picker, or pass entity_id " + f"(or its device_id). {service} sends nothing without a target.", translation_domain=DOMAIN, translation_key="no_target", - translation_placeholders={"service": f"{DOMAIN}.{SERVICE_ADB_COMMAND}"}, + translation_placeholders={"service": service}, ) - unknown = [ - entity_id for entity_id in requested if entity_id not in _ADB_COMMAND_TARGETS - ] + unknown = [entity_id for entity_id in requested if entity_id not in targets] if unknown: raise ServiceValidationError( + f"{', '.join(unknown)} is not a participant of {service} — nothing was sent.", translation_domain=DOMAIN, translation_key="unsupported_target", - translation_placeholders={"entity_id": ", ".join(unknown)}, + translation_placeholders={ + "entity_id": ", ".join(unknown), + "service": service, + }, ) if len(requested) > 1: raise ServiceValidationError( + f"{', '.join(requested)} covers more than one {service} target — select " + "exactly one Shield (one media_player entity). Nothing was sent.", translation_domain=DOMAIN, translation_key="ambiguous_target", - translation_placeholders={"entity_id": ", ".join(requested)}, + translation_placeholders={ + "entity_id": ", ".join(requested), + "service": service, + }, ) return requested[0] +def _single_adb_command_target(call) -> str: + """The ``adb_command`` target (t14): the generalised resolver, one registry.""" + return _single_participant_target( + call, _ADB_COMMAND_TARGETS, service=f"{DOMAIN}.{SERVICE_ADB_COMMAND}" + ) + + async def _async_adb_command_service(call): """Run a raw ADB shell command on the SELECTED Shield (t29 G8, t14). @@ -1950,3 +2001,479 @@ async def _async_adb_command_service(call): result = await poller.shell(command) _LOGGER.debug("adb_command %r → output=%s", command, result is not None) return {ADB_COMMAND_RESPONSE_KEY: result} + + +# ── t17: registered intents — the model, the command, and the action ── +# +# The second half of the intents feature (docs/INTENTS_PLAN.md §3-§5). The +# discovery half (t16) ANSWERS "what can this app be asked to do"; nothing it +# finds runs by itself. This half is the user's explicit "register it, then fire +# it": one button entity per registered intent, and the +# ``nvidia_shield.send_intent`` action for per-intent variables. +# +# WHAT A REGISTERED INTENT IS. Ordinary options data (CONF_REGISTERED_INTENTS: +# a list of dicts, see const.py), so it survives a reload and is otherwise +# inert. Two rules shape everything below: +# +# * NOTHING RUNS UNLESS THE USER ASKED. No auto-registration from discovery, and +# no intent is fired by a poll, a state change or a reload. The only triggers +# are a button press and the action. +# * THE COMMAND IS BUILT, NEVER PASSED THROUGH. ``am start`` is assembled from +# the stored fields with plain ``str.replace``-style placeholder substitution +# (NO eval, NO format(), NO shell interpolation) and every single value is +# ``shlex.quote``d, so a variable whose value is ``"; rm -rf /"`` reaches the +# device as literal data inside one quoted word. ``adb_command`` remains the +# one raw-shell door; this is not a second one. +# +# The failures are named because the fix differs: an unknown intent name lists +# the known ones, a missing REQUIRED field names the field, a variable the +# intent never declared 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 being off/down says so (the protocol cannot carry +# intents), and a device that answers with an error has its OWN message +# surfaced — never a silent success. + +#: ``{name}`` in a data template — the ONLY place a placeholder is honoured. +_INTENT_PLACEHOLDER_RE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}") + +#: A registered intent's id is a slug: it becomes the button's key and the +#: selector's value, so it must be safe in both and can never collide with the +#: "Add new" sentinel. +_INTENT_ID_RE = re.compile(r"^[a-z0-9_]+$") + +#: Declared field keys are shell-safe by construction: letters, digits and +#: underscores, never a leading dash (so a key can never be read as a flag). +_INTENT_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def intent_slug(text: str) -> str: + """A registered intent's id from its display name (t17). + + Deterministic and readable: lower-case, runs of anything else become ``_``. + The options screen uses this for new rows; an existing row keeps its id, so + renaming an intent never moves its button. + """ + slug = re.sub(r"[^a-z0-9]+", "_", str(text or "").lower()).strip("_") + return slug or "intent" + + +def _normalised_field(raw) -> dict | None: + """One declared field, defensively normalised; None when unusable (t17). + + The runtime reads options a user may have hand-edited, so this is the + tolerant door: a field without a usable key or type is DROPPED here and the + row keeps working with the fields that are sound (the options screen refuses + to write one in the first place). + """ + if not isinstance(raw, dict): + return None + key = str(raw.get("key") or "").strip() + type_ = str(raw.get("type") or "string").strip().lower() + if not _INTENT_KEY_RE.match(key) or type_ not in INTENT_FIELD_TYPES: + return None + field = {"key": key, "type": type_, "required": bool(raw.get("required"))} + default = raw.get("default") + if default is not None and str(default) != "": + field["default"] = str(default) + return field + + +def normalise_intent(raw) -> dict | None: + """One registered intent in the shape the runtime reads; None when unusable. + + The rule the whole feature turns on: an intent must name SOMETHING to launch + — a component (``pkg/.Activity``, with the slash Android requires) OR an + action (optionally with categories). A row with neither cannot produce a + command, so it is dropped rather than kept as a button that can only fail. + """ + if not isinstance(raw, dict): + return None + name = str(raw.get("name") or "").strip() + package = str(raw.get("package") or "").strip() + intent_id = str(raw.get("id") or "").strip() or intent_slug(name) + if not _INTENT_ID_RE.match(intent_id): + intent_id = intent_slug(intent_id or name) + activity = str(raw.get("activity") or "").strip() + action = str(raw.get("action") or "").strip() + if "/" not in activity: + # "-n pkg.Activity" without the "/" is never a component Android accepts + activity = "" + if not name or not package or not (activity or action): + return None + categories = [ + str(category).strip() + for category in (raw.get("categories") or []) + if str(category).strip() + ] + fields = [_normalised_field(field) for field in (raw.get("fields") or [])] + return { + "id": intent_id, + "name": name, + "package": package, + "activity": activity, + "action": action, + "categories": categories[:INTENT_FIELD_LIMIT], + "data": str(raw.get("data") or ""), + "fields": [field for field in fields if field][:INTENT_FIELD_LIMIT], + } + + +def registered_intents(options) -> list[dict]: + """Every registered intent of an entry, in order, deduplicated (t17). + + Reads OPTIONS and nothing else: no shell, no discovery, no I/O. Duplicate + ids keep their FIRST row (a hand-edited copy cannot shadow the real one), + and the registration cap is applied here so the runtime and the screen agree + about what exists. + """ + raw_rows = (options or {}).get(CONF_REGISTERED_INTENTS) or [] + if isinstance(raw_rows, dict): + # tolerate a mapping-shaped paste: the VALUES are the rows + raw_rows = list(raw_rows.values()) + intents: list[dict] = [] + seen: set[str] = set() + for raw in raw_rows: + intent = normalise_intent(raw) + if intent is None or intent["id"] in seen: + continue + seen.add(intent["id"]) + intents.append(intent) + if len(intents) >= INTENT_REGISTRATION_LIMIT: + break + return intents + + +def intent_by_id(options, intent_id) -> dict | None: + """The registered intent with this id, or None (t17).""" + wanted = str(intent_id or "").strip() + for intent in registered_intents(options): + if intent["id"] == wanted or intent["name"] == wanted: + return intent + return None + + +def intent_default_variables(intent) -> dict: + """The values a button press supplies: the declared DEFAULTS (t17). + + A required field with no default makes a button press an error naming the + field — which is honest: the button has no way to ask, and the action is + where such an intent is meant to be used. + """ + return { + field["key"]: field["default"] + for field in (intent.get("fields") or []) + if "default" in field + } + + +def _intent_value(key: str, type_: str, value, intent_name: str): + """Coerce one declared value to its type, or raise naming field+value (t17).""" + if type_ in ("string", "raw"): + return str(value) + if type_ == "int": + try: + return str(int(str(value).strip())) + except (TypeError, ValueError): + pass + elif type_ == "bool": + text = str(value).strip().lower() + if text in ("true", "1", "yes", "on"): + return "true" + if text in ("false", "0", "no", "off", "none"): + return "false" + raise ServiceValidationError( + f"'{key}' expects a {type_} value, but {value!r} is not one — " + f"'{intent_name}' was not sent.", + translation_domain=DOMAIN, + translation_key="invalid_intent_field", + translation_placeholders={ + "field": key, + "type": type_, + "value": str(value), + "intent": intent_name, + }, + ) + + +def intent_am_start_command(intent, variables=None) -> str: + """The ``am start`` command for ONE registered intent (t17). + + Assembled from the stored row plus the caller's variables; raises a + ServiceValidationError (with a plain message AND a translation key, so the + button and the action both read well) for every way it can be wrong. No + value is ever interpolated into a shell string unquoted, and no value is + ever evaluated: a data template's ``{placeholder}`` is replaced with text. + """ + intent_name = str(intent.get("name") or intent.get("id") or "intent") + fields = intent.get("fields") or [] + declared = {field["key"] for field in fields} + given = {str(key): value for key, value in (variables or {}).items()} + unknown = sorted(set(given) - declared) + if unknown: + raise ServiceValidationError( + f"'{intent_name}' declares no field(s) {', '.join(unknown)} — nothing was sent.", + translation_domain=DOMAIN, + translation_key="unknown_intent_variable", + translation_placeholders={ + "fields": ", ".join(unknown), + "intent": intent_name, + }, + ) + resolved: dict[str, tuple[str, str]] = {} + for field in fields: + key = field["key"] + if key in given: + value = given[key] + elif "default" in field: + value = field["default"] + elif field.get("required"): + raise ServiceValidationError( + f"'{intent_name}' needs a value for '{key}' — nothing was sent.", + translation_domain=DOMAIN, + translation_key="missing_intent_field", + translation_placeholders={"field": key, "intent": intent_name}, + ) + else: + continue # optional, no default, not supplied: simply not sent + resolved[key] = (_intent_value(key, field["type"], value, intent_name), field["type"]) + + data = str(intent.get("data") or "") + if data: + def _substitute(match: re.Match) -> str: + key = match.group(1) + if key not in resolved: + raise ServiceValidationError( + f"'{intent_name}' uses {{{key}}} in its data, but no value was " + "supplied for it — nothing was sent.", + translation_domain=DOMAIN, + translation_key="unresolved_intent_placeholder", + translation_placeholders={"placeholder": key, "intent": intent_name}, + ) + return str(resolved[key][0]) + + data = _INTENT_PLACEHOLDER_RE.sub(_substitute, data) + + parts = ["am start"] + activity = str(intent.get("activity") or "") + action = str(intent.get("action") or "") + if activity: + parts += ["-n", shlex.quote(activity)] + if action: + parts += ["-a", shlex.quote(action)] + for category in intent.get("categories") or []: + parts += ["-c", shlex.quote(str(category))] + if data: + parts += ["-d", shlex.quote(data)] + for key, (value, type_) in resolved.items(): + parts += [INTENT_TYPE_FLAGS[type_], shlex.quote(key), shlex.quote(value)] + return " ".join(parts) + + +#: The lines an ``am start`` failure prints. Checked as substrings because the +#: device's own wording varies by Android version; a match is surfaced VERBATIM +#: (the device's message is the useful one) and never guessed at. +_INTENT_FAILURE_MARKERS = ( + "error:", + "exception:", + "permission denial", + "does not exist", + "unable to resolve", + "not found", + "security exception", +) + + +def intent_failure_message(output) -> str | None: + """The device's own error text from an ``am start`` answer, or None (t17). + + ``am start`` prints "Starting: Intent {…}" when it accepted the intent. A + rejected one prints an error line instead, and THAT line is what the user + needs ("unable to resolve Intent", "does not exist", "Permission Denial"), + so it is returned as-is rather than replaced by our prose. + """ + if output is None: + return None + for line in str(output).splitlines(): + stripped = line.strip() + if not stripped: + continue + lowered = stripped.lower() + if any(marker in lowered for marker in _INTENT_FAILURE_MARKERS): + return stripped + return None + + +async def run_registered_intent(manager, entity_id: str, intent, variables=None) -> str: + """Send ONE registered intent over ADB; return the output, raise on failure (t17). + + The single place a registered intent becomes a shell command, shared by the + button platform and the action so the two cannot diverge. ADB is the only + transport that can carry an intent — when it is off or down the error says + exactly that instead of an obscure failure (the protocol transport has no + ``am start``). + """ + command = intent_am_start_command(intent, variables) + poller = getattr(manager, "adb_poller", None) if manager is not None else None + if poller is None or not getattr(manager, "connected", False): + raise ServiceValidationError( + f"'{intent.get('name') or intent.get('id')}' needs ADB: turn ADB on for " + f"{entity_id} and make sure the Shield is reachable — the remote protocol " + "cannot send an intent. Nothing was sent.", + translation_domain=DOMAIN, + translation_key="intents_need_adb", + translation_placeholders={"entity_id": entity_id}, + ) + _LOGGER.debug("send_intent %s → %s", intent.get("id"), command) + output = await poller.shell(command) + if output is None: + raise ServiceValidationError( + f"The Shield did not answer for '{intent.get('name') or intent.get('id')}' " + "— nothing was sent.", + translation_domain=DOMAIN, + translation_key="intent_no_answer", + translation_placeholders={"entity_id": entity_id}, + ) + message = intent_failure_message(output) + if message: + raise ServiceValidationError( + f"The Shield refused '{intent.get('name') or intent.get('id')}': {message}", + translation_domain=DOMAIN, + translation_key="intent_failed", + translation_placeholders={"message": message}, + ) + return output + + +# ── t17: the send_intent action ── +# +# WHICH SHIELDS PARTICIPATE. The same idea as adb_command, with a different +# registry on purpose: a raw shell command is opt-in behind +# ``adb_command_enabled`` (t29 G8), while an intent can only ever run because +# the USER REGISTERED IT, so the media player participates whenever it exists. +# Nothing runs by itself: an entry with no registered intents can produce no +# button and no successful call. +_INTENT_TARGETS: dict[str, ShieldConnectionManager] = {} + +_INTENT_SCHEMA = vol.Schema( + { + vol.Required("intent"): str, + vol.Optional("variables"): dict, + # exactly the t14 target handling: the picker merges the selection into + # the call as entity_id (string or list); a device target sends device_id + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_DEVICE_ID): cv.ensure_list_csv, + } +) + + +def send_intent_service_description(names) -> dict: + """The runtime DESCRIPTION of ``send_intent`` (t17). + + MEASURED on HA 2026.10.1 (t14, re-read for t17): ``async_set_service_schema`` + copies ``name``/``description``/``fields`` verbatim, validates only + ``target``, and adds the ``response`` flag from the service's registration — + so a field's ``selector`` is what the frontend renders, and the intent + dropdown is a plain select selector with ``custom_value`` on (a name + registered after this description was published must still be typeable, and + the action validates the name against the ENTRY's own list anyway). + + ``names`` is passed in because the list is per-ENTRY data while the service + description is domain-wide: the caller publishes what is loaded, and the + lookup at call time stays authoritative. + """ + options = sorted({str(name) for name in (names or []) if str(name).strip()}) + return { + "name": "Send intent", + "description": ( + "Send one of this Shield's REGISTERED intents over ADB (the Android " + "``am start`` command built from the registration). Register intents in " + "the integration's options under Intents; the action returns the " + "device's own output, and a rejected intent raises with the device's " + "message." + ), + "target": INTENT_SERVICE_TARGET, + "fields": { + "intent": { + "name": "Intent", + "description": ( + "Which registered intent to send, by its name (as the Intents " + "screen shows it). Registering an intent also creates a button " + "for it: the button is the no-variable way, this action is how " + "variables arrive from automations and scripts." + ), + "required": True, + "selector": { + "select": { + "options": options, + "custom_value": True, + "mode": "dropdown", + } + }, + }, + "variables": { + "name": "Variables", + "description": ( + "A value for each field the intent declares (keys as declared on " + "the Intents screen). A declared field with a default may be " + "omitted; a required one raises, naming the field. Fields the " + "intent does not declare are refused — nothing is guessed and " + "nothing extra is sent." + ), + "selector": {"object": {}}, + }, + "entity_id": { + "name": "Entity", + "description": ( + "The Shield to send it to: its media_player entity. Select it in " + "the target picker above, or pass entity_id (or the Shield's " + "device_id) in YAML." + ), + "selector": {"entity": {"domain": "media_player"}}, + }, + }, + } + + +async def _async_send_intent_service(call): + """Send ONE registered intent to the SELECTED Shield (t17). + + The target resolution is t14's, unchanged in spirit: exactly one participant, + or an error naming what to pass. Everything after that is the registration's + own business — the ENTRY's options decide which intents exist, so an unknown + name lists the ones that do, and the device's output is returned under + INTENT_RESPONSE_KEY so a template can see whether the Shield accepted it. + """ + entity_id = _single_participant_target( + call, _INTENT_TARGETS, service=f"{DOMAIN}.{SERVICE_SEND_INTENT}" + ) + manager = _INTENT_TARGETS[entity_id] + options = getattr(getattr(manager, "entry", None), "options", None) or {} + intents = registered_intents(options) + wanted = str(call.data.get("intent") or "").strip() + intent = intent_by_id(options, wanted) + if intent is None: + known = ", ".join(sorted(intent["name"] for intent in intents)) or "none yet" + raise ServiceValidationError( + f"'{wanted or ''}' is not a registered intent of {entity_id}. " + f"Known intents: {known}.", + translation_domain=DOMAIN, + translation_key="unknown_intent", + translation_placeholders={ + "intent": wanted or "", + "known": known, + "entity_id": entity_id, + }, + ) + variables = call.data.get("variables") or {} + if not isinstance(variables, dict): + raise ServiceValidationError( + f"'variables' must be a mapping of field name to value for " + f"'{intent['name']}' — nothing was sent.", + translation_domain=DOMAIN, + translation_key="invalid_intent_variables", + translation_placeholders={"intent": intent["name"]}, + ) + output = await run_registered_intent(manager, entity_id, intent, variables) + _LOGGER.debug("send_intent %s → output=%s", intent["id"], output is not None) + return {INTENT_RESPONSE_KEY: output}