feat(media_player): 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: a configured command calls its script INSTEAD of the native protocol/ADB path, and with nothing configured every command behaves exactly as it did (the whole existing suite passes untouched, which is what "byte-identical" means here). * const.py owns the option: CONF_COMMAND_SCRIPTS (one dict, keyed by command), SCRIPT_COMMANDS (turn_on, turn_off, volume_up, volume_down, volume_set, volume_mute, select_source) and command_scripts(), the ONE normalizer of the stored shape — 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 instead of reaching the service layer. * media_player.py: async_run_command_script() calls script.turn_on with the script entity and its variables, blocking=True so a failure surfaces HERE — a mapped command that cannot run raises HomeAssistantError naming the command and script rather than falling back silently or doing nothing. Every command method checks its script first; volume_set passes `level` and select_source passes `app_name`. * volume_mute also passes `is_volume_muted`: without it a mute script cannot tell muting from unmuting, which is the case the user asked for. * volume_set and select_source have NO native path (C-VOL; app launching goes through play_media), so they are advertised only when configured — the feature flag is what makes HA offer the command at all. Unset leaves the feature set exactly as it was. * remote.py shares async_run_command_script for POWER: turn_on/turn_off is one configuration and the remote entity is the other route to it, so a script that owns the power sequence owns it on both. * config_flow.py: one entity selector per command (domain: script), stored as the single dict option, prefilled from the entry's options and cleared by saving without a value — the editor is the truth, as with the app map editor. Two things the harness caught, worth recording: `MediaPlayerEntityFeature` has no SET_VOLUME (the real member is VOLUME_SET — the stub enum and the drift engine disagree with a wrong name), and the stub `homeassistant.exceptions` had no HomeAssistantError, so the stub now mirrors the real inheritance (ServiceValidationError derives from it). tests/test_command_scripts.py covers each command mapped and unmapped, the exact variable payloads, the native path untouched when unmapped (protocol key, then the ADB fallthrough with the protocol down), the rejecting script's error, hand-edited option shapes, and the feature set. test_config_flow_ux.py covers the selectors, the stored shape, prefill and clearing. New tests leaked the harness into the next file at first (a missing per-file scope); that is fixed, and tests/test_harness_scoping.py now asserts that every file loading the harness scopes it, so the next such mistake fails where it is made. Verified: 401 passed / 2 skipped, 402 / 1 with androidtvremote2, reverse file order 401 / 2, ruff clean, and the drift engine exits 0 on a real-HA interpreter (23 modules, 59 imports, 157 relative imports, 36 call arguments, 6 awaited HA calls).
This commit is contained in:
1 parent
2e1d162ef4
commit
89c05f1dcd
11 files changed
+622
-16
No files matched your search
@@ -21,6 +21,7 @@ from .const import (
|
|||||||
CONF_APP_NAME_MAP,
|
CONF_APP_NAME_MAP,
|
||||||
CONF_CHANNEL_DIGIT_DELAY,
|
CONF_CHANNEL_DIGIT_DELAY,
|
||||||
CONF_CHANNEL_SEND_ENTER,
|
CONF_CHANNEL_SEND_ENTER,
|
||||||
|
CONF_COMMAND_SCRIPTS,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
CONF_LOGCAT_EVENTS,
|
CONF_LOGCAT_EVENTS,
|
||||||
@@ -34,6 +35,9 @@ from .const import (
|
|||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ENTRY_VERSION,
|
ENTRY_VERSION,
|
||||||
|
SCRIPT_COMMANDS,
|
||||||
|
SCRIPT_FIELD_PREFIX,
|
||||||
|
command_scripts,
|
||||||
)
|
)
|
||||||
from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host
|
from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host
|
||||||
|
|
||||||
@@ -187,6 +191,10 @@ def _build_selectors():
|
|||||||
"power": selector.EntitySelector(
|
"power": selector.EntitySelector(
|
||||||
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
|
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
|
delay = None
|
||||||
if delay is not None and not (0.05 <= delay <= 1.0):
|
if delay is not None and not (0.05 <= delay <= 1.0):
|
||||||
errors["base"] = "invalid_digit_delay"
|
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)
|
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
||||||
icons: dict[str, str] = {}
|
icons: dict[str, str] = {}
|
||||||
if app_map_text is None:
|
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
|
# truth, so a save without icons clears them (and a
|
||||||
# single-column map stays valid).
|
# single-column map stays valid).
|
||||||
self._options_dict[CONF_APP_ICON_MAP] = icons
|
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)
|
return self.async_create_entry(title="", data=self._options_dict)
|
||||||
|
|
||||||
sel = _build_selectors()
|
sel = _build_selectors()
|
||||||
@@ -891,6 +913,16 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
|||||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||||
schema_dict[recv_key] = sel["receiver"]
|
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)
|
# 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_DIGIT_DELAY)] = str
|
||||||
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
|
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
|
||||||
@@ -944,7 +976,8 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
|||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=self.add_suggested_values_to_schema(
|
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,
|
errors=errors,
|
||||||
)
|
)
|
||||||
@@ -160,3 +160,46 @@ SYSTEM_MEDIA_PACKAGES = [
|
|||||||
"com.android.tv.settings",
|
"com.android.tv.settings",
|
||||||
"com.nvidia.diagtools",
|
"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
|
||||||
@@ -9,9 +9,9 @@ State architecture:
|
|||||||
- Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off
|
- Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off
|
||||||
- Playback state: Jellyfin (polling, slow 10-30s) or ADB media session
|
- Playback state: Jellyfin (polling, slow 10-30s) or ADB media session
|
||||||
- Metadata: Jellyfin attributes
|
- Metadata: Jellyfin attributes
|
||||||
- Commands: protocol key push → ADB keyevent. The external androidtv_remote
|
- Commands: a configured per-command script (t44) → protocol key push → ADB
|
||||||
entity tier that used to back this is gone — t41 removed it with the entity,
|
keyevent. The external androidtv_remote entity tier that used to back this is
|
||||||
so there is no third command path to keep in step.
|
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:
|
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
|
||||||
1. User presses pause → intent recorded → UI shows paused immediately
|
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.config_entries import ConfigEntry
|
||||||
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
||||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
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 import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||||
@@ -66,7 +66,11 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
PROTOCOL_MEDIA_KEYS,
|
PROTOCOL_MEDIA_KEYS,
|
||||||
PROTOCOL_REMOTE_KEYS,
|
PROTOCOL_REMOTE_KEYS,
|
||||||
|
SCRIPT_VAR_APP_NAME,
|
||||||
|
SCRIPT_VAR_LEVEL,
|
||||||
|
SCRIPT_VAR_MUTED,
|
||||||
TIVIMATE_PACKAGE,
|
TIVIMATE_PACKAGE,
|
||||||
|
command_scripts,
|
||||||
)
|
)
|
||||||
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
||||||
from .jellyfin import fetch_session_media_info
|
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(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
@@ -633,12 +663,24 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||||
if self._jf_active():
|
if self._jf_active():
|
||||||
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
features = _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||||
|
else:
|
||||||
# ADB media session also supports playback controls
|
# ADB media session also supports playback controls
|
||||||
adb_info = self._manager.best_session(self.app_id) if self._manager else None
|
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):
|
if adb_info and (adb_info.playing or adb_info.paused):
|
||||||
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
features = _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||||
return _BASE_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 ──
|
# ── Metadata from Jellyfin ──
|
||||||
|
|
||||||
@@ -939,24 +981,63 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
return
|
return
|
||||||
await manager.send_protocol_key(PROTOCOL_REMOTE_KEYS["power"])
|
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):
|
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)
|
await self._power_key(send_when_on=False)
|
||||||
|
|
||||||
async def async_turn_off(self):
|
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)
|
await self._power_key(send_when_on=True)
|
||||||
|
|
||||||
async def async_volume_up(self):
|
async def async_volume_up(self):
|
||||||
|
if await self._script_served("volume_up"):
|
||||||
|
return
|
||||||
await self._remote_key_cmd("volume_up", "volume_up")
|
await self._remote_key_cmd("volume_up", "volume_up")
|
||||||
|
|
||||||
async def async_volume_down(self):
|
async def async_volume_down(self):
|
||||||
|
if await self._script_served("volume_down"):
|
||||||
|
return
|
||||||
await self._remote_key_cmd("volume_down", "volume_down")
|
await self._remote_key_cmd("volume_down", "volume_down")
|
||||||
|
|
||||||
async def async_mute_volume(self, mute):
|
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")
|
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:
|
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
|
||||||
"""Simple key command without intent bookkeeping: protocol → ADB keyevent."""
|
"""Simple key command without intent bookkeeping: protocol → ADB keyevent."""
|
||||||
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
|
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
|
|
||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS
|
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:"
|
_START_PREFIX = "START_LONG:"
|
||||||
_END_PREFIX = "END_LONG:"
|
_END_PREFIX = "END_LONG:"
|
||||||
@@ -131,7 +131,11 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
|||||||
self._manager_unregistered = None
|
self._manager_unregistered = None
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs) -> 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()
|
manager = self._manager_of()
|
||||||
if not isinstance(manager, ShieldConnectionManager):
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
return
|
return
|
||||||
@@ -141,7 +145,9 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
|||||||
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs) -> None:
|
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()
|
manager = self._manager_of()
|
||||||
if not isinstance(manager, ShieldConnectionManager):
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -106,6 +106,13 @@
|
|||||||
"adb_command_enabled": "Enable the adb_command service",
|
"adb_command_enabled": "Enable the adb_command service",
|
||||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||||
"channel_send_enter": "Send ENTER after channel digits",
|
"channel_send_enter": "Send ENTER after channel digits",
|
||||||
|
"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)"
|
"logcat_events": "Instant app detection (logcat stream)"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
|
|||||||
@@ -106,6 +106,13 @@
|
|||||||
"adb_command_enabled": "Enable the adb_command service",
|
"adb_command_enabled": "Enable the adb_command service",
|
||||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||||
"channel_send_enter": "Send ENTER after channel digits",
|
"channel_send_enter": "Send ENTER after channel digits",
|
||||||
|
"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)"
|
"logcat_events": "Instant app detection (logcat stream)"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class MediaPlayerEntityFeature(enum.IntFlag):
|
|||||||
PLAY_MEDIA = 256
|
PLAY_MEDIA = 256
|
||||||
NEXT_TRACK = 512
|
NEXT_TRACK = 512
|
||||||
PREVIOUS_TRACK = 1024
|
PREVIOUS_TRACK = 1024
|
||||||
|
SELECT_SOURCE = 2048
|
||||||
|
|
||||||
|
|
||||||
class MediaClass:
|
class MediaClass:
|
||||||
@@ -328,12 +329,18 @@ def install() -> None:
|
|||||||
|
|
||||||
exceptions = types.ModuleType("homeassistant.exceptions")
|
exceptions = types.ModuleType("homeassistant.exceptions")
|
||||||
|
|
||||||
class ServiceValidationErrorStubError(Exception):
|
class HomeAssistantErrorStubError(Exception):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args or (kwargs.get("translation_key"),))
|
super().__init__(*args or (kwargs.get("translation_key"),))
|
||||||
self.translation_domain = kwargs.get("translation_domain")
|
self.translation_domain = kwargs.get("translation_domain")
|
||||||
self.translation_key = kwargs.get("translation_key")
|
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
|
exceptions.ServiceValidationError = ServiceValidationErrorStubError
|
||||||
sys.modules["homeassistant.exceptions"] = exceptions
|
sys.modules["homeassistant.exceptions"] = exceptions
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,9 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
|||||||
|
|
||||||
class _RecordingServices:
|
class _RecordingServices:
|
||||||
async def async_call(self, domain, service, data=None, **kwargs):
|
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()
|
player.hass.services = _RecordingServices()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -33,12 +33,15 @@ from conftest import _SHIELD_DIR, _load_module
|
|||||||
from nsh.const import (
|
from nsh.const import (
|
||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
|
CONF_COMMAND_SCRIPTS,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
CONF_PAIRING_CODE,
|
CONF_PAIRING_CODE,
|
||||||
CONF_PROTOCOL_ENABLED,
|
CONF_PROTOCOL_ENABLED,
|
||||||
CONF_SKIP_PAIRING,
|
CONF_SKIP_PAIRING,
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
|
SCRIPT_COMMANDS,
|
||||||
|
SCRIPT_FIELD_PREFIX,
|
||||||
)
|
)
|
||||||
|
|
||||||
BASE = Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield"
|
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"
|
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:
|
class TestStringsTranslationsParity:
|
||||||
"""Both copies of the UI copy must expose the same key tree."""
|
"""Both copies of the UI copy must expose the same key tree."""
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,28 @@ class TestTeardownLeaks:
|
|||||||
entity_stublib.restore_modules(snapshot)
|
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(
|
@pytest.mark.parametrize(
|
||||||
"paths",
|
"paths",
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in new issue
Block a user