diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 798c58f..acf90cd 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -21,6 +21,7 @@ from .const import ( CONF_APP_NAME_MAP, CONF_CHANNEL_DIGIT_DELAY, CONF_CHANNEL_SEND_ENTER, + CONF_COMMAND_SCRIPTS, CONF_HOST, CONF_JELLYFIN_ENTITY, CONF_LOGCAT_EVENTS, @@ -34,6 +35,9 @@ from .const import ( DEFAULT_NAME, DOMAIN, ENTRY_VERSION, + SCRIPT_COMMANDS, + SCRIPT_FIELD_PREFIX, + command_scripts, ) from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host @@ -187,6 +191,10 @@ def _build_selectors(): "power": selector.EntitySelector( selector.EntitySelectorConfig(domain=["switch", "binary_sensor"]) ), + # t44: a script entity per command override + "script": selector.EntitySelector( + selector.EntitySelectorConfig(domain="script") + ), } @@ -848,6 +856,18 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): delay = None if delay is not None and not (0.05 <= delay <= 1.0): errors["base"] = "invalid_digit_delay" + # t44: one selector per command, stored as a single dict option so + # the service layer reads one shape. Popped here because _options_dict + # is updated with the rest of user_input verbatim below. + scripts = { + command: script + for command in SCRIPT_COMMANDS + if ( + script := str( + user_input.pop(f"{SCRIPT_FIELD_PREFIX}{command}", "") or "" + ).strip() + ) + } app_map_text = user_input.pop(CONF_APP_NAME_MAP, None) icons: dict[str, str] = {} if app_map_text is None: @@ -867,6 +887,8 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): # truth, so a save without icons clears them (and a # single-column map stays valid). self._options_dict[CONF_APP_ICON_MAP] = icons + # an empty dict clears every override (the editor is the truth) + self._options_dict[CONF_COMMAND_SCRIPTS] = scripts return self.async_create_entry(title="", data=self._options_dict) sel = _build_selectors() @@ -891,6 +913,16 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): recv_key = vol.Optional(CONF_RECEIVER_ENTITY) schema_dict[recv_key] = sel["receiver"] + # t44: command script overrides — the escape hatch for hardware we do not + # drive (a speaker switch owning the volume). Unset = native path as before. + configured_scripts = command_scripts(self._options_dict) + suggested_scripts = {} + for command in SCRIPT_COMMANDS: + field = f"{SCRIPT_FIELD_PREFIX}{command}" + schema_dict[vol.Optional(field)] = sel["script"] + if configured_scripts.get(command): + suggested_scripts[field] = configured_scripts[command] + # P5-2: channel digit behavior (the user-validated §7 flow) schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool @@ -944,7 +976,8 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( - vol.Schema(schema_dict), self._options_dict + vol.Schema(schema_dict), + {**self._options_dict, **suggested_scripts}, ), errors=errors, ) diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py index 42c1075..38b3341 100644 --- a/custom_components/nvidia_shield/const.py +++ b/custom_components/nvidia_shield/const.py @@ -160,3 +160,46 @@ SYSTEM_MEDIA_PACKAGES = [ "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 diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 7f372ab..9699433 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -9,9 +9,9 @@ State architecture: - Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off - Playback state: Jellyfin (polling, slow 10-30s) or ADB media session - Metadata: Jellyfin attributes - - Commands: protocol key push → ADB keyevent. The external androidtv_remote - entity tier that used to back this is gone — t41 removed it with the entity, - so there is no third command path to keep in step. + - Commands: a configured per-command script (t44) → protocol key push → ADB + keyevent. The external androidtv_remote entity tier that used to back this is + gone — t41 removed it with the entity — so a command has exactly one owner. Intent tracking bridges the gap between instant commands and slow Jellyfin polling: 1. User presses pause → intent recorded → UI shows paused immediately @@ -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 ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, 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 @@ -66,7 +66,11 @@ from .const import ( DOMAIN, PROTOCOL_MEDIA_KEYS, PROTOCOL_REMOTE_KEYS, + SCRIPT_VAR_APP_NAME, + SCRIPT_VAR_LEVEL, + SCRIPT_VAR_MUTED, TIVIMATE_PACKAGE, + command_scripts, ) from .ha_adb_common import PlaybackIntent, running_apps_from_tasks from .jellyfin import fetch_session_media_info @@ -102,6 +106,32 @@ _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). + + ``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. + """ + 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}" + ) from err + return True + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -633,12 +663,24 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): @property def supported_features(self) -> MediaPlayerEntityFeature: if self._jf_active(): - return _BASE_FEATURES | _PLAYBACK_FEATURES - # ADB media session also supports playback controls - adb_info = self._manager.best_session(self.app_id) if self._manager else None - if adb_info and (adb_info.playing or adb_info.paused): - return _BASE_FEATURES | _PLAYBACK_FEATURES - return _BASE_FEATURES + features = _BASE_FEATURES | _PLAYBACK_FEATURES + else: + # ADB media session also supports playback controls + adb_info = self._manager.best_session(self.app_id) if self._manager else None + if adb_info and (adb_info.playing or adb_info.paused): + features = _BASE_FEATURES | _PLAYBACK_FEATURES + else: + features = _BASE_FEATURES + + # t44: a command that exists ONLY as a script still has to be reachable, and + # the feature flag is what makes HA offer it. volume_set and select_source + # have no native path at all, so they appear exactly when configured — + # without a script the feature set is what it always was. + if self._script_for("volume_set"): + features |= MediaPlayerEntityFeature.VOLUME_SET + if self._script_for("select_source"): + features |= MediaPlayerEntityFeature.SELECT_SOURCE + return features # ── Metadata from Jellyfin ── @@ -939,24 +981,63 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): return await manager.send_protocol_key(PROTOCOL_REMOTE_KEYS["power"]) + def _script_for(self, command: str) -> str | None: + """The script configured to own ``command``, if any (t44).""" + return command_scripts(self._entry.options).get(command) + + async def _script_served(self, command: str, variables: dict | None = None) -> bool: + """True when a configured script handled ``command`` instead of the transports.""" + return await async_run_command_script( + self.hass, self._entry.options, command, variables + ) + async def async_turn_on(self): - """Wake the Shield (only when it reports OFF).""" + """Wake the Shield (only when it reports OFF) — or run the turn_on script.""" + if await self._script_served("turn_on"): + return await self._power_key(send_when_on=False) async def async_turn_off(self): - """Sleep the Shield (only when it reports ON).""" + """Sleep the Shield (only when it reports ON) — or run the turn_off script.""" + if await self._script_served("turn_off"): + return await self._power_key(send_when_on=True) async def async_volume_up(self): + if await self._script_served("volume_up"): + return await self._remote_key_cmd("volume_up", "volume_up") async def async_volume_down(self): + if await self._script_served("volume_down"): + return await self._remote_key_cmd("volume_down", "volume_down") async def async_mute_volume(self, mute): - """Mute via the protocol/ADB key — no protocol VOLUME_SET exists (C-VOL).""" + """Mute — via the configured script, else the protocol/ADB key (C-VOL).""" + if await self._script_served("volume_mute", {SCRIPT_VAR_MUTED: mute}): + return await self._remote_key_cmd("volume_mute", "mute") + async def async_set_volume_level(self, volume): + """Set absolute volume — reachable only with a script (t44). + + There is no native path to replace: the protocol carries UP/DOWN/MUTE keys + only (C-VOL) and ADB has no absolute volume either. Configured, this is the + hook for a device whose volume is owned elsewhere (a speaker switch); + unconfigured it stays the no-op it has always been. + """ + await self._script_served("volume_set", {SCRIPT_VAR_LEVEL: volume}) + + async def async_select_source(self, source): + """Select a source — reachable only with a script (t44). + + The integration has no native source selection (app launching goes through + play_media with the app map). A script for this command is the hook for an + AVR input or anything else that has to move with the Shield. + """ + await self._script_served("select_source", {SCRIPT_VAR_APP_NAME: source}) + async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None: """Simple key command without intent bookkeeping: protocol → ADB keyevent.""" protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key) diff --git a/custom_components/nvidia_shield/remote.py b/custom_components/nvidia_shield/remote.py index 503bec3..259a7e7 100644 --- a/custom_components/nvidia_shield/remote.py +++ b/custom_components/nvidia_shield/remote.py @@ -25,7 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .connection import ShieldConnectionManager from .const import DOMAIN, PROTOCOL_REMOTE_KEYS -from .media_player import entity_available +from .media_player import async_run_command_script, entity_available _START_PREFIX = "START_LONG:" _END_PREFIX = "END_LONG:" @@ -131,7 +131,11 @@ class NvidiaShieldRemoteEntity(RemoteEntity): self._manager_unregistered = None async def async_turn_on(self, **kwargs) -> None: - """Wake the device with the POWER key when it reports off.""" + """Wake the device — via the configured script, else the POWER key.""" + # t44: `turn_on` is one configuration, and the remote entity is the other + # route to it; a script that owns the power sequence owns it here too. + if await async_run_command_script(self.hass, self._entry.options, "turn_on"): + return manager = self._manager_of() if not isinstance(manager, ShieldConnectionManager): return @@ -141,7 +145,9 @@ class NvidiaShieldRemoteEntity(RemoteEntity): await manager.send_protocol_key(_POWER_KEY, "SHORT") async def async_turn_off(self, **kwargs) -> None: - """Sleep the device with the POWER key when it reports on.""" + """Sleep the device — via the configured script, else the POWER key.""" + if await async_run_command_script(self.hass, self._entry.options, "turn_off"): + return manager = self._manager_of() if not isinstance(manager, ShieldConnectionManager): return diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json index 29e9a23..260bbed 100644 --- a/custom_components/nvidia_shield/strings.json +++ b/custom_components/nvidia_shield/strings.json @@ -106,7 +106,14 @@ "adb_command_enabled": "Enable the adb_command service", "channel_digit_delay": "Channel digit delay (seconds)", "channel_send_enter": "Send ENTER after channel digits", - "logcat_events": "Instant app detection (logcat stream)" + "script_turn_on": "Turn on script", + "script_turn_off": "Turn off script", + "script_volume_up": "Volume up script", + "script_volume_down": "Volume down script", + "script_volume_set": "Volume set script", + "script_volume_mute": "Mute script", + "script_select_source": "Select source script", + "logcat_events": "Instant app detection (logcat stream)" }, "data_description": { "jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.", diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json index 29e9a23..260bbed 100644 --- a/custom_components/nvidia_shield/translations/en.json +++ b/custom_components/nvidia_shield/translations/en.json @@ -106,7 +106,14 @@ "adb_command_enabled": "Enable the adb_command service", "channel_digit_delay": "Channel digit delay (seconds)", "channel_send_enter": "Send ENTER after channel digits", - "logcat_events": "Instant app detection (logcat stream)" + "script_turn_on": "Turn on script", + "script_turn_off": "Turn off script", + "script_volume_up": "Volume up script", + "script_volume_down": "Volume down script", + "script_volume_set": "Volume set script", + "script_volume_mute": "Mute script", + "script_select_source": "Select source script", + "logcat_events": "Instant app detection (logcat stream)" }, "data_description": { "jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.", diff --git a/tests/entity_stublib.py b/tests/entity_stublib.py index f96a80e..f3198f0 100644 --- a/tests/entity_stublib.py +++ b/tests/entity_stublib.py @@ -63,6 +63,7 @@ class MediaPlayerEntityFeature(enum.IntFlag): PLAY_MEDIA = 256 NEXT_TRACK = 512 PREVIOUS_TRACK = 1024 + SELECT_SOURCE = 2048 class MediaClass: @@ -328,12 +329,18 @@ def install() -> None: exceptions = types.ModuleType("homeassistant.exceptions") - class ServiceValidationErrorStubError(Exception): + class HomeAssistantErrorStubError(Exception): def __init__(self, *args, **kwargs): super().__init__(*args or (kwargs.get("translation_key"),)) self.translation_domain = kwargs.get("translation_domain") self.translation_key = kwargs.get("translation_key") + class ServiceValidationErrorStubError(HomeAssistantErrorStubError): + """Real HA: ServiceValidationError derives from HomeAssistantError (t44).""" + + # t44: media_player raises HomeAssistantError when a configured command script + # fails, so both names have to exist — and with the real inheritance. + exceptions.HomeAssistantError = HomeAssistantErrorStubError exceptions.ServiceValidationError = ServiceValidationErrorStubError sys.modules["homeassistant.exceptions"] = exceptions diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index b350557..b383e67 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -126,7 +126,9 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None): class _RecordingServices: async def async_call(self, domain, service, data=None, **kwargs): - served.append((domain, service)) + # t44: the payload matters now (command scripts pass HA script + # variables), so record it too — every existing assertion is `== []`. + served.append((domain, service, data)) player.hass.services = _RecordingServices() diff --git a/tests/test_command_scripts.py b/tests/test_command_scripts.py new file mode 100644 index 0000000..329ac49 --- /dev/null +++ b/tests/test_command_scripts.py @@ -0,0 +1,304 @@ +"""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, + SCRIPT_VAR_APP_NAME, + SCRIPT_VAR_LEVEL, + SCRIPT_VAR_MUTED, +) +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_a_command_with_no_native_path_stays_a_no_op(self, modules): + """volume_set/select_source exist ONLY through a script — never a crash.""" + player, served = _player(modules, {}) + + await player.async_set_volume_level(0.4) + await player.async_select_source("TiviMate") + + assert served == [] + + 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 HomeAssistantError + + 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(HomeAssistantError) as raised: + await player.async_volume_up() + + message = str(raised.value) + assert "volume_up" in message + assert WATCH in message + assert "speaker switch is offline" in message + # ...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_and_select_source_appear_only_when_configured(self, modules): + from homeassistant.components.media_player import MediaPlayerEntityFeature + + player, _served = _player(modules, {}) + baseline = self._features(player) + + player._entry.options[CONF_COMMAND_SCRIPTS] = {"volume_set": WATCH} + with_volume = self._features(player) + + player._entry.options[CONF_COMMAND_SCRIPTS] = { + "volume_set": WATCH, + "select_source": WATCH, + } + with_both = self._features(player) + + extra = ( + MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.SELECT_SOURCE + ) + assert with_volume & int(MediaPlayerEntityFeature.VOLUME_SET) + assert not with_volume & int(MediaPlayerEntityFeature.SELECT_SOURCE) + assert with_both & int(extra) == int(extra) + # nothing else moved, and unset means the feature set it always had + assert with_both & ~int(extra) == baseline + assert baseline & int(extra) == 0 + + 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 diff --git a/tests/test_config_flow_ux.py b/tests/test_config_flow_ux.py index efe737f..fcb6af8 100644 --- a/tests/test_config_flow_ux.py +++ b/tests/test_config_flow_ux.py @@ -33,12 +33,15 @@ from conftest import _SHIELD_DIR, _load_module from nsh.const import ( CONF_ADB_ENABLED, CONF_ADB_PORT, + CONF_COMMAND_SCRIPTS, CONF_HOST, CONF_JELLYFIN_ENTITY, CONF_PAIRING_CODE, CONF_PROTOCOL_ENABLED, CONF_SKIP_PAIRING, DEFAULT_ADB_PORT, + SCRIPT_COMMANDS, + SCRIPT_FIELD_PREFIX, ) BASE = Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield" @@ -1448,6 +1451,97 @@ class TestCopyContract: assert field in step["data"], f"options.{field} has no label" +class TestCommandScriptOptions: + """t44: one script selector per command, stored as one dict option. + + The escape hatch for hardware this integration does not drive — a speaker switch + that owns the volume. Unset means the native protocol/ADB path, exactly as + before, so the page has to make unsetting as easy as setting. + """ + + def _flow(self, tmp_path, options=None): + module = _form_module() + flow = module.NvidiaShieldOptionsFlow() + flow.config_entry = SimpleNamespace( + options=dict(options or {}), entry_id="e1", title="Shield" + ) + flow.hass = FakeHass(str(tmp_path)) + return flow + + @staticmethod + def _submitted(schema): + return getattr(schema, "schema", schema) or {} + + async def test_a_script_selector_exists_for_every_command(self, tmp_path): + flow = self._flow(tmp_path) + + form = await flow.async_step_init(None) + + fields = set(vol_mirror.declared_fields(form["data_schema"])) + assert {f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS} <= fields + + async def test_saving_stores_one_dict_option_and_no_flat_keys(self, tmp_path): + flow = self._flow(tmp_path) + + result = await flow.async_step_init( + { + f"{SCRIPT_FIELD_PREFIX}volume_set": "script.shield_volume", + f"{SCRIPT_FIELD_PREFIX}turn_on": "script.shield_wake", + } + ) + + assert result["type"] == "create_entry" + assert result["data"][CONF_COMMAND_SCRIPTS] == { + "volume_set": "script.shield_volume", + "turn_on": "script.shield_wake", + } + # the per-command field is UI only: the service layer reads the dict + assert not [key for key in result["data"] if key.startswith(SCRIPT_FIELD_PREFIX)] + + async def test_configured_scripts_prefill_their_selectors(self, tmp_path): + flow = self._flow( + tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}} + ) + + form = await flow.async_step_init(None) + + suggested = self._submitted(form["data_schema"]).get("__suggested__", {}) + assert suggested[f"{SCRIPT_FIELD_PREFIX}turn_on"] == "script.shield_wake" + # an unconfigured command is not suggested anything + assert f"{SCRIPT_FIELD_PREFIX}volume_up" not in suggested + + async def test_clearing_every_script_removes_the_option(self, tmp_path): + """The editor is the truth: a save without scripts clears them.""" + flow = self._flow( + tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}} + ) + + result = await flow.async_step_init({}) + + assert result["data"][CONF_COMMAND_SCRIPTS] == {} + + async def test_a_stale_unknown_command_is_not_carried_forward(self, tmp_path): + flow = self._flow( + tmp_path, + {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake", "gone": "script.x"}}, + ) + + result = await flow.async_step_init({f"{SCRIPT_FIELD_PREFIX}turn_on": "script.a"}) + + assert result["data"][CONF_COMMAND_SCRIPTS] == {"turn_on": "script.a"} + + async def test_the_page_still_renders_with_a_hand_edited_option(self, tmp_path): + """A string where the dict belongs must not break the form.""" + flow = self._flow(tmp_path, {CONF_COMMAND_SCRIPTS: "script.shield_wake"}) + + form = await flow.async_step_init(None) + + assert form["type"] == "form" + assert f"{SCRIPT_FIELD_PREFIX}turn_on" in vol_mirror.declared_fields( + form["data_schema"] + ) + + class TestStringsTranslationsParity: """Both copies of the UI copy must expose the same key tree.""" diff --git a/tests/test_harness_scoping.py b/tests/test_harness_scoping.py index bd8263f..4e5925c 100644 --- a/tests/test_harness_scoping.py +++ b/tests/test_harness_scoping.py @@ -157,6 +157,28 @@ class TestTeardownLeaks: entity_stublib.restore_modules(snapshot) +def test_every_file_that_loads_the_harness_scopes_it(): + """A missing teardown is invisible until the NEXT file runs — so check for it. + + This is the mistake the fix itself invites: a new test file calls + load_entity_modules() for convenience and forgets the per-file scope, and the + failure surfaces somewhere else entirely (t44 added exactly that file). + """ + #: Files that exercise the harness in a CHILD process: they leave nothing in + #: this process's sys.modules, so they need no teardown. + child_process_only = {"test_module_coverage.py"} + offenders = [] + for path in sorted((REPO_ROOT / "tests").glob("test_*.py")): + if path.name in child_process_only: + continue + text = path.read_text(encoding="utf-8") + loads = "load_entity_modules(" in text or "entity_stublib.install()" in text + if loads and "snapshot_modules()" not in text: + offenders.append(path.name) + + assert offenders == [] + + @pytest.mark.parametrize( "paths", [