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:
James Mackay committed 2026-10-11 06:47:20 +01:00
1 parent 2e1d162ef4
commit 89c05f1dcd
11 files changed
+627 -21

No files matched your search

+94 -13
View File
@@ -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)