Files
James Mackay a8041e1428 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.
2026-10-12 12:43:10 +01:00

485 lines
22 KiB
Python

"""Constants for the Nvidia Shield integration."""
# Re-export shared Media DAG constants
from .ha_adb_common.const import ( # noqa: F401
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
ATTR_MEDIA_DAG_RUNNING_APPS,
)
DOMAIN = "nvidia_shield"
# t41: the INTEGRATION's address field — one Shield IP for the whole stack
# (protocol + ADB). The two keys below it are LEGACY: a YAML seed may still
# carry them and the v3 entry migration maps them onto CONF_HOST, but no
# runtime code reads them.
CONF_HOST = "host"
CONF_ADB_HOST = "adb_host"
CONF_PROTOCOL_HOST = "protocol_host"
# t41 legacy: dropped from entries by the v3 migration (the androidtv entity
# path it configured is gone), never read at runtime.
CONF_ANDROIDTV_ENTITY = "androidtv_entity"
CONF_JELLYFIN_ENTITY = "jellyfin_entity"
CONF_RECEIVER_ENTITY = "receiver_entity"
CONF_RECEIVER_INPUT = "receiver_input"
CONF_POWER_ENTITY = "power_entity"
CONF_ADB_ENABLED = "adb_enabled"
CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
CONF_ADB_PORT = "adb_port"
# Remote-protocol transport (androidtvremote2) — Phase 2
CONF_PROTOCOL_ENABLED = "protocol_enabled"
CONF_PAIRING_CODE = "pairing_code"
# t33 F-B: the "continue without pairing" escape offered on BOTH pairing
# steps — the entry still finishes (passive mode) instead of stranding the
# user on a form with no way forward.
CONF_SKIP_PAIRING = "skip_pairing"
#: t15: a PERSISTED marker that this entry's protocol client really paired (a
#: successful ``async_finish_pairing``). Pairing cannot be inferred from anything
#: else: the library exposes no ``is_paired``, and certificate material proves only
#: that SOMETHING generated a key — which an entry that merely enabled the transport
#: without pairing has too. Written by the setup flow's pairing step, the options
#: flow's, and the reauth/reconfigure finishers, so enabling the transport later can
#: tell "already paired" (reuse the certificate, no new PIN) from "never paired"
#: (must pair now, or stay off).
CONF_PROTOCOL_PAIRED = "protocol_paired"
DEFAULT_PROTOCOL_API_PORT = 6466
DEFAULT_PROTOCOL_PAIR_PORT = 6467
ADB_POLL_INTERVAL = 10 # seconds
ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
DEFAULT_ADB_PORT = 5555
# Persistent-connection manager timing (Phase 1, docs/MVP_PLAN.md §2):
# watchdog reconnects with exponential backoff + jitter; ADB-derived store
# fields expire (B5) instead of clinging to stale values.
ADB_RECONNECT_BACKOFF_START = 5 # seconds before the first reconnect retry
ADB_RECONNECT_BACKOFF_CAP = 300 # seconds; backoff never sleeps longer
ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms
STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
# t15: the remote-protocol transport's own state machine. The library offers NO
# ``is_paired``/``is_connected`` (measured on androidtvremote2), so these are the
# states we can actually prove — and the one the user was reading as "maybe
# unpaired" was ``never_connected``: keep_reconnecting() alone never connects
# (its loop is ``while self._remote_message_protocol:``, None until a first
# ``async_connect()``), so the transport had never been switched on at all.
PROTOCOL_OFF = "off" # no client wired (disabled/no library)
PROTOCOL_NEVER_CONNECTED = "never_connected" # wired, no session yet, no rejection
PROTOCOL_CONNECTED = "connected" # device reachable at the protocol
PROTOCOL_DISCONNECTED = "disconnected" # session was up, now reported down
PROTOCOL_AUTH_REJECTED = "auth_rejected" # cert refused: pair again (reauth)
#: Seconds allowed for the FIRST protocol connect before it counts as unreachable.
#: Core androidtv_remote uses the same 5 s before deciding ready/not-ready.
PROTOCOL_CONNECT_TIMEOUT = 5.0
DEFAULT_NAME = "Nvidia Shield"
# Config-entry schema version (t41): v1 → v2 added the remote-protocol fields,
# v2 → v3 collapsed the two address fields into CONF_HOST and dropped the
# androidtv-entity key. One constant so the flow and the migration cannot drift.
ENTRY_VERSION = 3
# Emotiva DAG attribute we read to get available inputs
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
# Known Jellyfin app package IDs (used to route Jellyfin-family clients
# — including Wholphin, a Jellyfin client — to the rich-metadata path).
JELLYFIN_APP_IDS = [
"org.jellyfin.androidtv",
"org.jellyfin.mobile",
"com.github.damontecres.wholphin",
]
# Remote-protocol key names (RemoteKeyCode enum in remotemessage.proto).
# HA service kind → protocol keycode string.
PROTOCOL_MEDIA_KEYS = {
"media_play": "KEYCODE_MEDIA_PLAY",
"media_pause": "KEYCODE_MEDIA_PAUSE",
"media_stop": "KEYCODE_MEDIA_STOP",
"media_next_track": "KEYCODE_MEDIA_NEXT",
"media_previous_track": "KEYCODE_MEDIA_PREVIOUS",
}
PROTOCOL_VOLUME_KEYS = {
"volume_up": "KEYCODE_VOLUME_UP",
"volume_down": "KEYCODE_VOLUME_DOWN",
"volume_mute": "KEYCODE_VOLUME_MUTE",
}
PROTOCOL_REMOTE_KEYS = {
**PROTOCOL_VOLUME_KEYS,
"play": "KEYCODE_MEDIA_PLAY",
"pause": "KEYCODE_MEDIA_PAUSE",
"stop": "KEYCODE_MEDIA_STOP",
"up": "KEYCODE_DPAD_UP",
"down": "KEYCODE_DPAD_DOWN",
"left": "KEYCODE_DPAD_LEFT",
"right": "KEYCODE_DPAD_RIGHT",
"enter": "KEYCODE_DPAD_CENTER",
"home": "KEYCODE_HOME",
"back": "KEYCODE_BACK",
"menu": "KEYCODE_MENU",
"search": "KEYCODE_SEARCH",
"power": "KEYCODE_POWER",
"channel_up": "KEYCODE_CHANNEL_UP",
"channel_down": "KEYCODE_CHANNEL_DOWN",
}
# §7 channel selection: digits only, ~0.1 s apart, no ENTER by default
# (replicates HA core androidtv_remote's verified behavior).
# Zeroconf discovery (Phase 4): the Android TV remote service type.
ZEROCONF_SERVICE_TYPE = "_androidtvremote2._tcp.local."
# P4-1 apps name/icon map (options flow editor): package → friendly name,
# consumed by app_name after the androidtv-entity and protocol-push tiers.
CONF_APP_NAME_MAP = "app_name_map"
# P5-2: optional icon column (package|icon) — package → icon URL, feeding
# entity_picture when no Jellyfin session picture is live.
CONF_APP_ICON_MAP = "app_icon_map"
# P5-1: device manifest label cache TTL (long — labels change with app
# updates, not per poll).
APP_LABEL_TTL = 24 * 3600
# t57 UNIT 0: exit marker appended to the label dump so a FAILED fetch can be
# told from a package that genuinely has no label. ``adb shell`` output ending
# in this marker means the command ran; an empty answer still carries it, and
# a transport that never delivered output does not. The value is arbitrary but
# unlikely to collide with a manifest line.
APP_LABEL_EXIT_MARKER = "__NSH_EXIT:"
# t66: the label candidates searched FOR ON THE DEVICE. A real `pm dump` on the
# user's Shield is ~2241 lines of dumpsys (package + activity + meminfo +
# procstats + usagestats + batterystats), and the old fetch shipped
# ``| head -300`` — which cut before the package sections even appeared, so the
# negative was a truncation artefact rather than an answer. Searching on-device
# keeps the wire small AND the answer honest: these are the three shapes Android
# prints for an app's label (see ha_adb_common/app_label.py for what counts as a
# name in each).
APP_LABEL_GREP_SHAPES = "application-label|nonLocalizedLabel|labelRes"
# t66: how many label probes may come back "ran, no label" IN A ROW before the
# device is treated as one that exposes no labels at all (this user's Shield).
# One label-less package is ordinary (system/OEM packages often have none), so a
# single miss proves nothing — but a run of them means the whole firmware keeps
# its labels in resources we cannot read, and every further ``pm dump`` per
# package is a shell spent on a question already answered. The conclusion expires
# with APP_LABEL_TTL so a device change is noticed within a day, and any label
# found anywhere resets the run.
APP_LABEL_PROBE_LIMIT = 5
# P4-2a adb_command service: raw ADB shell on this entry — OFF by default
# (same security posture as the core androidtv integration).
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
SERVICE_ADB_COMMAND = "adb_command"
#: t14: the key the command's output is returned under, in the service RESPONSE.
#: It matches the deprecated ``androidtv.adb_command`` the user fell back to, so a
#: template written against that integration keeps reading the same field.
ADB_COMMAND_RESPONSE_KEY = "adb_response"
#: t14: the picker the UI offers for `nvidia_shield.adb_command`.
#:
#: MEASURED on HA 2026.10.1: ``ServiceRegistry.async_register`` has NO ``target``
#: parameter, so a target cannot be declared at registration. The frontend renders
#: the target picker from the service DESCRIPTION instead (``services.yaml``, or a
#: runtime ``async_set_service_schema`` call, which WINS over services.yaml) — this
#: is that description's ``target``. Only the ENTITY kind is declared: the Shield is
#: selected as its ``media_player`` entity (one per entry), exactly like the
#: deprecated ``androidtv.adb_command`` the user used to fall back to.
#:
#: The entity domain the action's target declares, and the only domain a
#: ``device_id`` expands to (a Shield device also carries sensors, a remote, an
#: image entity — none of those is something to run a raw shell command on).
ADB_COMMAND_TARGET_DOMAIN = "media_player"
ADB_COMMAND_SERVICE_TARGET: dict = {
"entity": [{"domain": ADB_COMMAND_TARGET_DOMAIN}]
}
#: t14: the service DESCRIPTION registered at setup (``async_set_service_schema``).
#:
#: Why a runtime description at all: the description is what the frontend receives
#: from ``get_services``, and a service with no description has no ``target``, which
#: is precisely why the user's UI offered no Shield to select and he had to run his
#: diagnostics through the deprecated integration. The prose here mirrors
#: ``strings.json``/``translations/en.json`` (where HA's translations live) so the
#: form renders even before a translation is loaded; ``tests/test_adb_command_target.py``
#: asserts the two cannot drift.
ADB_COMMAND_SERVICE_DESCRIPTION: dict = {
"name": "ADB command",
"description": (
"Send a raw ADB shell command to the persistent connection of an Nvidia "
"Shield entry (option-gated use)."
),
"target": ADB_COMMAND_SERVICE_TARGET,
"fields": {
"command": {
"name": "Command",
"description": "The shell command to run on the device.",
"required": True,
"selector": {"text": {}},
},
"entity_id": {
"name": "Entity",
"description": (
"The Shield to run it on: 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"}},
},
},
}
# ── 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.
CONF_LOGCAT_EVENTS = "logcat_events"
# P5-3: duplicate START for the same package inside this window is not
# re-stamped (repeat suppression).
LOGCAT_SUPPRESS_WINDOW = 15.0
# P4-2c/t66: the curated package -> name table. It is BOTH the BrowseMedia/source
# directory fallback and the third tier of the app-name chain (after the user's
# own map and a device label, before the raw package id) — on a Shield whose
# `pm dump` carries no label at all it is the answer that makes names work.
#
# It is a community-maintained convenience, NOT device truth: package ids are
# stable, display names change with vendors and app updates, and an app this
# table has never heard of falls back to its package id (shown once). Users
# override any entry in Configure > Apps, and that edit always wins. Adding a row
# is deliberately trivial — keep the ids sorted so the diff stays reviewable.
DEFAULT_APP_DIRECTORY = {
"ar.tvplayer.tv": "TiviMate",
"be.digitalia.mediasession2mqtt": "MediaSession2MQTT",
"com.amazon.amazonvideo.livingroom": "Prime Video",
"com.esaba.downloader": "Downloader",
"com.github.damontecres.wholphin": "Wholphin",
"com.google.android.sambadocumentsprovider": "Samba (SMB)",
"com.google.android.speech.pumpkin": "Google Speech Services",
"com.google.android.tts": "Speech Services by Google",
"com.google.android.tvlauncher": "Google TV launcher",
"com.google.android.youtube.tv": "YouTube",
"com.hyperion.grabber": "Hyperion Grabber",
"com.lonelycatgames.Xplore": "X-plore File Manager",
"com.nendo.argosy": "Argosy",
"com.netflix.ninja": "Netflix",
"com.plexapp.mediaserver.smb": "Plex Media Server",
"com.retroarch": "RetroArch",
"com.spocky.projengmenu": "Projectivy Launcher",
"dev.jdtech.jellyfin": "Findroid",
"dev.trooped.tvquickbars": "TV Quick Actions",
"flar2.homebutton": "Button Mapper",
"tv.pluto.android": "Pluto TV",
}
# t57: best-effort icon per discovered app. `pm dump` does NOT expose the
# app's icon resource (an extracted APK's icon is a bitmap only its own code
# can resolve, and `dumpsys package` prints no drawable reference), so a device
# icon is not obtainable over plain shell. These MDI names are therefore a
# HEURISTIC: a curated package -> MDI mapping with a generic fallback, honest
# about being a guess. The user's own `app_icon_map` (a picture URL or camera
# entity, see CONF_APP_ICON_MAP) always outranks it for entity_picture.
APP_ICON_FALLBACKS = {
"ar.tvplayer.tv": "mdi:television-classic",
"com.github.damontecres.wholphin": "mdi:jellyfish",
"com.netflix.ninja": "mdi:netflix",
"com.google.android.youtube.tv": "mdi:youtube-tv",
"com.google.android.tvlauncher": "mdi:view-dashboard",
"org.xbmc.kodi": "mdi:kodi",
"com.plexapp.android": "mdi:plex",
"com.amazon.amazonvideo.livingroom": "mdi:amazon",
"com.disney.disneyplus": "mdi:movie-open",
"com.spotify.tv.android": "mdi:spotify",
"com.google.android.apps.youtube.music": "mdi:music",
"com.valvesoftware.steamlink": "mdi:gamepad-variant",
"com.spotify.music": "mdi:spotify",
}
#: The fallback for an app this map has never heard of.
APP_ICON_GENERIC = "mdi:television"
# t57: installed-app discovery. The package list changes only when the user
# installs or uninstalls something, so a long TTL is right; refreshing is
# explicit (the options page and the app-list sensor ask for it) rather than a
# per-poll cost.
APP_LIST_TTL = 6 * 3600
#: `pm dump` sweeps for labels run in a small burst — enough to finish a
#: typical Shield quickly, few enough that the label fetches never queue
#: behind each other on one socket or stall the watchdog.
APP_SWEEP_CONCURRENCY = 4
#: Spacing between the fetches of one sweep, per worker. Keeps the burst a
#: trickle on the one socket the watchdog uses, while ~40 apps still finish in
#: a few seconds instead of a few minutes.
APP_SWEEP_SPACING = 0.05
CHANNEL_DIGIT_DELAY = 0.1
CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay"
CONF_CHANNEL_SEND_ENTER = "channel_send_enter"
# Verified on-device package names (docs/RESEARCH_SHIELD.md): TiviMate does
# not publish a MediaSession on Shield Android 11 — it is the headline
# Phase-3 fallback app.
TIVIMATE_PACKAGE = "ar.tvplayer.tv"
# Packages to ignore when parsing foreground/audio-playback identities
SYSTEM_MEDIA_PACKAGES = [
"com.google.android.apps.mediashell", # Chromecast receiver process
"com.android.systemui",
"com.android.tv.settings",
"com.nvidia.diagtools",
]
# t44: per-command script overrides — the escape hatch for hardware this
# integration deliberately does not drive itself (a speaker switch that owns the
# volume, an AVR input, a mute that has to hit two devices). One script per
# command: when one is configured, that command calls the script INSTEAD of the
# native protocol/ADB path, and with none configured nothing changes at all.
CONF_COMMAND_SCRIPTS = "command_scripts"
SCRIPT_COMMANDS = (
"turn_on",
"turn_off",
"volume_up",
"volume_down",
"volume_set",
"volume_mute",
"select_source",
)
#: Options-flow field name per command: ``script_turn_on``, ``script_volume_set``…
SCRIPT_FIELD_PREFIX = "script_"
#: Variables a command hands its script (HA script variables). volume_mute passes
#: whether it is muting or unmuting — a script cannot tell those apart otherwise.
SCRIPT_VAR_LEVEL = "level"
SCRIPT_VAR_APP_NAME = "app_name"
SCRIPT_VAR_MUTED = "is_volume_muted"
def command_scripts(options) -> dict[str, str]:
"""The script configured per command, normalized (t44).
The ONE place the stored option's shape is read: the options flow, the media
player and the remote entity all go through it. Options round-trip through HA
storage and can be hand-edited, so a value that is not a non-empty string for a
known command is dropped rather than handed to the service layer.
"""
raw = (options or {}).get(CONF_COMMAND_SCRIPTS)
if not isinstance(raw, dict):
return {}
scripts: dict[str, str] = {}
for command in SCRIPT_COMMANDS:
script = raw.get(command)
if isinstance(script, str) and script.strip():
scripts[command] = script.strip()
return scripts
# t47: ONE optional script for the remote entity's send_command. It replaces the
# whole key path (repeats, hold, delays included) rather than per-key overrides,
# because a remote command is a sequence: an IR bridge, a macro or a second device
# needs the call, not individual keys.
CONF_REMOTE_COMMAND_SCRIPT = "remote_command_script"
#: Variables remote.send_command hands its script — the names are the service's own
#: parameter names, and they are documented in the README and pinned in tests.
SCRIPT_VAR_COMMAND = "command"
SCRIPT_VAR_NUM_REPEATS = "num_repeats"
SCRIPT_VAR_DELAY_SECS = "delay_secs"
SCRIPT_VAR_HOLD_SECS = "hold_secs"
# t66: the screen-capture image entity — OFF by default. A capture is a
# full-resolution screen grab, and the user's standing constraint is that screen
# capture must never drive state; opt-in keeps a diagnostic surface from costing
# every install a grab per dashboard view.
CONF_SCREEN_CAPTURE_ENABLED = "screen_capture_enabled"
def remote_command_script(options) -> str | None:
"""The script configured for remote.send_command, or None (t47).
Same defensive read as ``command_scripts``: options round-trip through HA
storage and can be hand-edited, so anything that is not a non-empty string is
"not configured" rather than a service call to nowhere.
"""
script = (options or {}).get(CONF_REMOTE_COMMAND_SCRIPT)
if isinstance(script, str) and script.strip():
return script.strip()
return None