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

+557 -30
View File
@@ -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 '<empty>'}' is not a registered intent of {entity_id}. "
f"Known intents: {known}.",
translation_domain=DOMAIN,
translation_key="unknown_intent",
translation_placeholders={
"intent": wanted or "<empty>",
"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}