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).
444 lines
16 KiB
Python
444 lines
16 KiB
Python
"""tests/test_command_scripts.py — per-command script overrides (t44).
|
|
|
|
The user's remaining universal-media-player dependency: some commands need hardware
|
|
this integration deliberately does not drive — a speaker switch that owns the
|
|
volume, an AVR input that has to move with the Shield, a mute that has to hit two
|
|
devices. One script per command is the escape hatch, and the rule is absolute:
|
|
|
|
configured -> that command calls the script, with its variables, and never
|
|
touches the protocol or ADB path;
|
|
unset -> the command behaves exactly as it did before this existed.
|
|
|
|
The suite is expected to stay quiet about the first case elsewhere: nothing else may
|
|
start calling services, which is why tests/test_command_routing.py asserts that no
|
|
service call happens on the native paths.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import entity_stublib
|
|
import pytest
|
|
from nsh.const import (
|
|
CONF_COMMAND_SCRIPTS,
|
|
CONF_REMOTE_COMMAND_SCRIPT,
|
|
SCRIPT_VAR_APP_NAME,
|
|
SCRIPT_VAR_COMMAND,
|
|
SCRIPT_VAR_DELAY_SECS,
|
|
SCRIPT_VAR_HOLD_SECS,
|
|
SCRIPT_VAR_LEVEL,
|
|
SCRIPT_VAR_MUTED,
|
|
SCRIPT_VAR_NUM_REPEATS,
|
|
)
|
|
from test_command_routing import _remote_entity, make_manager, make_player
|
|
|
|
#: Commands with no data to pass — the script is called with the entity only.
|
|
PLAIN_COMMANDS = ("turn_on", "turn_off", "volume_up", "volume_down")
|
|
|
|
WATCH = "script.shield_watch"
|
|
|
|
|
|
@pytest.fixture(name="modules", autouse=True)
|
|
def scoped_modules():
|
|
"""Stub scope + loaded entity modules for one test, restored afterwards.
|
|
|
|
The t7 discipline: without the teardown this file's `nsh.*` modules and stub
|
|
faces survive into whichever file runs next, and the next file's flow/entity
|
|
classes bind against the wrong faces (the t44 file-order failure).
|
|
"""
|
|
snapshot = entity_stublib.snapshot_modules()
|
|
entity_stublib.install()
|
|
try:
|
|
yield entity_stublib.load_entity_modules()
|
|
finally:
|
|
entity_stublib.restore_modules(snapshot)
|
|
|
|
|
|
def _services(served):
|
|
"""A hass.services stand-in that records (domain, service, data)."""
|
|
|
|
class _RecordingServices:
|
|
async def async_call(self, domain, service, data=None, **kwargs):
|
|
served.append((domain, service, data))
|
|
|
|
return _RecordingServices()
|
|
|
|
|
|
def _player(modules, scripts=None, *, is_on=None):
|
|
player, served, _intents = make_player(modules)
|
|
if is_on is not None:
|
|
player._manager._on_is_on(is_on)
|
|
if scripts is not None:
|
|
player._entry.options[CONF_COMMAND_SCRIPTS] = scripts
|
|
player.hass.services = _services(served)
|
|
return player, served
|
|
|
|
|
|
def _script_call(script=WATCH, variables=None):
|
|
data = {"entity_id": script}
|
|
if variables:
|
|
data["variables"] = variables
|
|
return ("script", "turn_on", data)
|
|
|
|
|
|
class TestMappedCommands:
|
|
"""A configured script replaces the native path — for every command."""
|
|
|
|
@pytest.mark.parametrize("command", PLAIN_COMMANDS)
|
|
async def test_a_plain_command_calls_its_script_and_nothing_else(
|
|
self, modules, command
|
|
):
|
|
# every native path would have had work to do: ADB is up, and for the
|
|
# power commands the state gate is open
|
|
player, served = _player(modules, {command: WATCH}, is_on=command == "turn_off")
|
|
|
|
await getattr(player, f"async_{command}")()
|
|
|
|
assert served == [_script_call()]
|
|
assert player._manager._protocol.key_calls == []
|
|
assert player._manager._poller.media_calls == []
|
|
|
|
async def test_volume_set_passes_the_level(self, modules):
|
|
player, served = _player(modules, {"volume_set": WATCH})
|
|
|
|
await player.async_set_volume_level(0.4)
|
|
|
|
# exactly the variable name and the value as passed, nothing extra
|
|
assert served == [_script_call(variables={SCRIPT_VAR_LEVEL: 0.4})]
|
|
assert player._manager._protocol.key_calls == []
|
|
assert player._manager._poller.media_calls == []
|
|
|
|
@pytest.mark.parametrize("mute", [True, False])
|
|
async def test_volume_mute_passes_the_requested_state(self, modules, mute):
|
|
"""A script cannot tell mute from unmute without this."""
|
|
player, served = _player(modules, {"volume_mute": WATCH})
|
|
|
|
await player.async_mute_volume(mute)
|
|
|
|
assert served == [_script_call(variables={SCRIPT_VAR_MUTED: mute})]
|
|
assert player._manager._protocol.key_calls == []
|
|
assert player._manager._poller.media_calls == []
|
|
|
|
async def test_select_source_passes_the_app_name(self, modules):
|
|
player, served = _player(modules, {"select_source": WATCH})
|
|
|
|
await player.async_select_source("TiviMate")
|
|
|
|
assert served == [_script_call(variables={SCRIPT_VAR_APP_NAME: "TiviMate"})]
|
|
|
|
async def test_the_remote_entity_shares_the_same_turn_on_script(self, modules):
|
|
"""There are two routes to POWER; one configuration governs both."""
|
|
entity = _remote_entity(modules)
|
|
served = []
|
|
entity.hass = SimpleNamespace(services=_services(served))
|
|
entity._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
|
entity._manager._on_is_on(False) # the native path would fire
|
|
|
|
await entity.async_turn_on()
|
|
|
|
assert served == [_script_call()]
|
|
assert entity._manager._protocol.key_calls == []
|
|
|
|
|
|
class TestUnmappedCommands:
|
|
"""With nothing configured, every command keeps its native path."""
|
|
|
|
async def test_no_service_call_happens_at_all(self, modules):
|
|
player, served = _player(modules, {}, is_on=False)
|
|
|
|
await player.async_turn_on()
|
|
await player.async_volume_up()
|
|
|
|
assert served == [] # nothing left the integration
|
|
|
|
async def test_turn_on_still_sends_the_protocol_power_key(self, modules):
|
|
player, _served = _player(modules, {}, is_on=False)
|
|
|
|
await player.async_turn_on()
|
|
|
|
assert player._manager._protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
|
|
|
|
async def test_volume_up_still_uses_the_native_protocol_key(self, modules):
|
|
from nsh.const import PROTOCOL_REMOTE_KEYS
|
|
|
|
player, _served = _player(modules, {})
|
|
|
|
await player.async_volume_up()
|
|
|
|
assert player._manager._protocol.key_calls == [
|
|
(PROTOCOL_REMOTE_KEYS["volume_up"], "SHORT")
|
|
]
|
|
|
|
async def test_volume_up_still_falls_through_to_adb(self, modules):
|
|
"""The second native tier, reached exactly when the protocol is down."""
|
|
player, _served = _player(modules, {})
|
|
player._manager._protocol = None
|
|
|
|
await player.async_volume_up()
|
|
|
|
assert player._manager._poller.media_calls == ["volume_up"]
|
|
|
|
async def test_volume_set_without_a_script_stays_a_no_op(self, modules):
|
|
"""Absolute volume exists ONLY through a script (the protocol has no
|
|
VOLUME_SET key), so unconfigured it sends nothing — never a crash."""
|
|
player, served = _player(modules, {})
|
|
|
|
await player.async_set_volume_level(0.4)
|
|
|
|
assert served == []
|
|
assert player._manager._protocol.key_calls == []
|
|
assert player._manager._poller.media_calls == []
|
|
|
|
async def test_other_commands_are_unaffected_by_a_mapped_one(self, modules):
|
|
from nsh.const import PROTOCOL_REMOTE_KEYS
|
|
|
|
player, served = _player(modules, {"volume_up": WATCH})
|
|
|
|
await player.async_volume_down()
|
|
|
|
assert served == []
|
|
assert player._manager._protocol.key_calls == [
|
|
(PROTOCOL_REMOTE_KEYS["volume_down"], "SHORT")
|
|
]
|
|
|
|
|
|
class TestScriptFailures:
|
|
"""A mapped command that cannot run must be visible, never a silent no-op."""
|
|
|
|
async def test_a_raising_script_surfaces_a_clean_error(self, modules):
|
|
from homeassistant.exceptions import ServiceValidationError
|
|
|
|
player, _served = _player(modules, {"volume_up": WATCH}, is_on=False)
|
|
|
|
class _FailingServices:
|
|
async def async_call(self, domain, service, data=None, **kwargs):
|
|
raise RuntimeError("speaker switch is offline")
|
|
|
|
player.hass.services = _FailingServices()
|
|
|
|
with pytest.raises(ServiceValidationError) as raised:
|
|
await player.async_volume_up()
|
|
|
|
# translated (t47): the command, the script and the cause are placeholders
|
|
assert raised.value.translation_domain == "nvidia_shield"
|
|
assert raised.value.translation_key == "command_script_failed"
|
|
assert raised.value.translation_placeholders == {
|
|
"action": "volume_up",
|
|
"script": WATCH,
|
|
"error": "speaker switch is offline",
|
|
}
|
|
# ...and it did NOT quietly fall back to the native path
|
|
assert player._manager._protocol.key_calls == []
|
|
assert player._manager._poller.media_calls == []
|
|
|
|
|
|
class TestOptionShape:
|
|
"""The stored option is read through one normalizer, defensively."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"stored",
|
|
[
|
|
"script.watch", # not a dict at all
|
|
{"turn_on": 5}, # not an entity id
|
|
{"turn_on": " "}, # empty
|
|
{"bogus_command": WATCH}, # unknown command
|
|
None,
|
|
],
|
|
)
|
|
async def test_a_hand_edited_option_never_reaches_the_service_layer(
|
|
self, modules, stored
|
|
):
|
|
player, served = _player(modules, stored, is_on=False)
|
|
|
|
await player.async_turn_on()
|
|
|
|
assert served == []
|
|
assert player._manager._protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
|
|
|
|
async def test_a_padded_entity_id_is_trimmed(self, modules):
|
|
player, served = _player(modules, {"turn_on": f" {WATCH} "}, is_on=False)
|
|
|
|
await player.async_turn_on()
|
|
|
|
assert served == [_script_call()]
|
|
|
|
|
|
class TestFeatureSet:
|
|
"""A script-only command still has to be reachable, so it is advertised."""
|
|
|
|
def _features(self, player):
|
|
return int(player.supported_features)
|
|
|
|
def test_volume_set_appears_only_when_configured(self, modules):
|
|
"""volume_set has no native path at all, so the script is the only reach."""
|
|
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
|
|
|
player, _served = _player(modules, {})
|
|
baseline = self._features(player)
|
|
assert baseline & int(MediaPlayerEntityFeature.VOLUME_SET) == 0
|
|
|
|
player._entry.options[CONF_COMMAND_SCRIPTS] = {"volume_set": WATCH}
|
|
with_volume = self._features(player)
|
|
|
|
assert with_volume & int(MediaPlayerEntityFeature.VOLUME_SET)
|
|
# ...and nothing else moved
|
|
assert with_volume & ~int(MediaPlayerEntityFeature.VOLUME_SET) == baseline
|
|
|
|
def test_select_source_is_advertised_once_there_is_something_to_offer(
|
|
self, modules
|
|
):
|
|
"""t47: either a script OR a source list is enough — the native path exists
|
|
now, so an unconfigured integration still has sources to offer."""
|
|
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
|
|
|
player, _served = _player(modules, {})
|
|
flagged = self._features(player)
|
|
|
|
assert flagged & int(MediaPlayerEntityFeature.SELECT_SOURCE)
|
|
# a configured script changes nothing about the flag
|
|
player._entry.options[CONF_COMMAND_SCRIPTS] = {"select_source": WATCH}
|
|
assert self._features(player) & int(MediaPlayerEntityFeature.SELECT_SOURCE)
|
|
|
|
def test_a_script_for_an_already_advertised_command_changes_nothing(self, modules):
|
|
player, _served = _player(modules, {})
|
|
baseline = self._features(player)
|
|
|
|
player._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
|
|
|
assert self._features(player) == baseline
|
|
|
|
|
|
def test_the_remote_entry_options_are_the_media_player_options(modules):
|
|
"""Both entities read the same option key — pinned so they cannot drift."""
|
|
player, _served = _player(modules, {"turn_on": WATCH})
|
|
entity = _remote_entity(modules)
|
|
entity._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
|
|
|
assert (
|
|
player._entry.options[CONF_COMMAND_SCRIPTS]
|
|
== entity._entry.options[CONF_COMMAND_SCRIPTS]
|
|
)
|
|
assert make_manager().entry is None # the harness's manager starts unbound
|
|
|
|
|
|
WATCH_SCRIPT = "script.shield_remote"
|
|
|
|
|
|
class TestRemoteCommandScript:
|
|
"""t47 B: one optional script for remote.send_command, replacing the key path.
|
|
|
|
A remote command is a SEQUENCE (repeats, long presses, delays), so the escape
|
|
hatch is one script for the whole call rather than a per-key override: an IR
|
|
bridge, a macro or a second device needs the call, not the keys.
|
|
"""
|
|
|
|
def _entity(self, modules, script=None):
|
|
entity = _remote_entity(modules)
|
|
served = []
|
|
entity.hass = SimpleNamespace(services=_services(served))
|
|
if script is not None:
|
|
entity._entry.options[CONF_REMOTE_COMMAND_SCRIPT] = script
|
|
return entity, served
|
|
|
|
async def test_a_configured_script_receives_the_exact_parameters(self, modules):
|
|
entity, served = self._entity(modules, WATCH_SCRIPT)
|
|
|
|
await entity.async_send_command(
|
|
["HOME", "KEYCODE_DPAD_CENTER"],
|
|
num_repeats=3,
|
|
delay_secs=0.25,
|
|
hold_secs=1.5,
|
|
)
|
|
|
|
assert served == [
|
|
(
|
|
"script",
|
|
"turn_on",
|
|
{
|
|
"entity_id": WATCH_SCRIPT,
|
|
"variables": {
|
|
SCRIPT_VAR_COMMAND: ["HOME", "KEYCODE_DPAD_CENTER"],
|
|
SCRIPT_VAR_NUM_REPEATS: 3,
|
|
SCRIPT_VAR_DELAY_SECS: 0.25,
|
|
SCRIPT_VAR_HOLD_SECS: 1.5,
|
|
},
|
|
},
|
|
)
|
|
]
|
|
assert entity._manager._protocol.key_calls == [] # key path untouched
|
|
|
|
async def test_the_service_defaults_are_named_too(self, modules):
|
|
"""The variable set is fixed: a script can rely on all four being present."""
|
|
entity, served = self._entity(modules, WATCH_SCRIPT)
|
|
|
|
await entity.async_send_command(["home"])
|
|
|
|
variables = served[0][2]["variables"]
|
|
assert variables == {
|
|
SCRIPT_VAR_COMMAND: ["home"],
|
|
SCRIPT_VAR_NUM_REPEATS: 1,
|
|
SCRIPT_VAR_DELAY_SECS: 0.0,
|
|
SCRIPT_VAR_HOLD_SECS: 0.0,
|
|
}
|
|
|
|
async def test_unset_keeps_the_native_key_path(self, modules):
|
|
entity, served = self._entity(modules)
|
|
|
|
await entity.async_send_command(["HOME"], num_repeats=2, hold_secs=0.0)
|
|
|
|
assert served == []
|
|
assert entity._manager._protocol.key_calls == [
|
|
("KEYCODE_HOME", "SHORT"),
|
|
("KEYCODE_HOME", "SHORT"),
|
|
]
|
|
|
|
@pytest.mark.parametrize("stored", ["", " ", None, 7])
|
|
async def test_a_blank_or_hand_edited_option_means_unset(self, modules, stored):
|
|
entity, served = self._entity(modules, stored)
|
|
|
|
await entity.async_send_command(["HOME"])
|
|
|
|
assert served == []
|
|
assert entity._manager._protocol.key_calls == [("KEYCODE_HOME", "SHORT")]
|
|
|
|
async def test_a_raising_script_is_a_translated_error(self, modules):
|
|
from homeassistant.exceptions import ServiceValidationError
|
|
|
|
entity, _served = self._entity(modules, WATCH_SCRIPT)
|
|
|
|
class _FailingServices:
|
|
async def async_call(self, domain, service, data=None, **kwargs):
|
|
raise RuntimeError("IR bridge is offline")
|
|
|
|
entity.hass.services = _FailingServices()
|
|
|
|
with pytest.raises(ServiceValidationError) as raised:
|
|
await entity.async_send_command(["HOME"])
|
|
|
|
assert raised.value.translation_domain == "nvidia_shield"
|
|
assert raised.value.translation_key == "command_script_failed"
|
|
assert raised.value.translation_placeholders == {
|
|
"action": "remote.send_command",
|
|
"script": WATCH_SCRIPT,
|
|
"error": "IR bridge is offline",
|
|
}
|
|
assert entity._manager._protocol.key_calls == [] # never a silent no-op
|
|
|
|
|
|
def test_both_new_exception_keys_have_copy_in_both_files():
|
|
"""A raised translation_key with no copy shows the user a bare key."""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield"
|
|
for name in ("strings.json", "translations/en.json"):
|
|
exceptions = json.loads((root / name).read_text(encoding="utf-8"))["exceptions"]
|
|
for key in ("unknown_source", "command_script_failed"):
|
|
assert exceptions[key]["title"], (name, key)
|
|
assert exceptions[key]["message"], (name, key)
|
|
assert "{source}" in json.loads(
|
|
(root / "strings.json").read_text(encoding="utf-8")
|
|
)["exceptions"]["unknown_source"]["message"]
|
|
assert "remote_command_script" in json.loads(
|
|
(root / "translations/en.json").read_text(encoding="utf-8")
|
|
)["options"]["step"]["init"]["data"]
|