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}