feat(remote): optional command script for remote.send_command (t47 B)

One optional 'Remote command script' on the Configure page. Configured, it replaces
the whole key path for remote.send_command; unset, repeats, long presses and delays go
out as protocol keys exactly as before (the existing send_command tests pass
untouched).

A remote command is a SEQUENCE — repeats, a START_LONG/END_LONG pair, an inter-repeat
delay — so the escape hatch is one script for the call rather than per-key overrides:
an IR bridge, a macro, or a second device needs the call, not individual keys. The
script receives the service's own parameters as variables, named as they are on the
service and documented in the README and pinned in tests:

  command       the list of key commands as submitted (e.g. ["HOME", "KEYCODE_DPAD_CENTER"])
  num_repeats   the repeat count (default 1)
  delay_secs    the inter-repeat delay (default 0.0)
  hold_secs     the long-press duration, START_LONG/END_LONG handled by the script
                (default 0.0) — the script owns the key semantics, so it decides what
                "hold" means

All four are always present, including defaults, so a script can rely on the shape.
A raising script surfaces a translated error rather than a silent no-op. That made the
t44 script-failure mechanism consistent in the process: async_call_script() is now the
one caller for both the media player's per-command scripts and the remote's, raising
ServiceValidationError(translation_domain="nvidia_shield",
translation_key="command_script_failed") with {action, script, error} placeholders
instead of a bare HomeAssistantError — the t44 test was updated to assert the
translation contract (domain, key, placeholders) rather than a message string, which is
stronger evidence and gives the user real copy. The new copy lives in BOTH strings
files, as does the unknown_source key from UNIT A.

Option handling reuses the t47 C work: the field is an entity selector (so a cleared
value is tolerated by the schema), the stored value is trimmed, and a blank,
hand-edited or non-string value means "not configured" rather than a call to nowhere.

Verified: 454 passed / 2 skipped, ruff clean, drift engine exit 0 under real HA
(23 modules, 58 imports, 164 relative imports, 36 call arguments, 7 awaited HA calls).
This commit is contained in:
James Mackay committed 2026-10-11 07:07:06 +01:00
1 parent b2aab15ebf
commit c3e4c7855c
8 files changed
+299 -28

No files matched your search

+27 -15
View File
@@ -38,7 +38,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
@@ -106,29 +106,41 @@ _PLAYBACK_FEATURES = (
)
async def async_run_command_script(
hass: HomeAssistant, options: dict, command: str, variables: dict | None = None
) -> bool:
"""Run the script configured for ``command``; True when one handled it (t44).
async def async_call_script(
hass: HomeAssistant, script: str, action: str, variables: dict | None = None
) -> None:
"""Call a configured script with variables, surfacing failures (t44/t47).
``blocking=True`` so a script that fails surfaces HERE: a mapped command that
cannot run must be a visible error, never a silent no-op. Module-level so the
remote entity shares it — one configuration governs both routes to POWER.
``blocking=True`` so a script that fails surfaces HERE: a command that cannot
run must be a visible, translated error rather than a silent no-op. Module-level
so the media player and the remote entity share one implementation.
"""
script = command_scripts(options).get(command)
if not script:
return False
data: dict = {"entity_id": script}
if variables:
data["variables"] = variables
try:
await hass.services.async_call("script", "turn_on", data, blocking=True)
except Exception as err: # noqa: BLE001 - re-raised as a user-facing error
_LOGGER.error("the script for %s (%s) failed: %s", command, script, err)
raise HomeAssistantError(
f"the script configured for {command} ({script}) failed: {err}"
_LOGGER.error("the script for %s (%s) failed: %s", action, script, err)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="command_script_failed",
translation_placeholders={
"action": action,
"script": script,
"error": str(err),
},
) from err
async def async_run_command_script(
hass: HomeAssistant, options: dict, command: str, variables: dict | None = None
) -> bool:
"""Run the script configured for ``command``; True when one handled it (t44)."""
script = command_scripts(options).get(command)
if not script:
return False
await async_call_script(hass, script, command, variables)
return True