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).
225 lines
8.5 KiB
Python
225 lines
8.5 KiB
Python
"""Remote entities for NVIDIA Shield — protocol-backed (Phase 2, t10).
|
|
|
|
Port of HA core androidtv_remote/remote.py semantics onto the absorbed
|
|
remote-protocol transport (androidtvremote2) owned by ShieldConnectionManager:
|
|
|
|
- ``send_command`` accepts SHORT/START_LONG/END_LONG direction prefixes
|
|
(``"START_LONG:<key>"``; no prefix = SHORT) and core-style service keys;
|
|
the core port's hold_secs+direction validation quirks are not carried over —
|
|
long presses are expressed as the START_LONG/END_LONG pair instead;
|
|
- ``num_repeats`` / ``delay_secs`` are honored; long-press duration uses the
|
|
``hold_secs`` parameter;
|
|
- ``activity`` = the protocol current-app push (package; name resolution is
|
|
deferred app-map work);
|
|
- turn on/off send the POWER key and honor the is_on push (no superfluous
|
|
toggling when the state already matches).
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
from homeassistant.components.remote import RemoteEntity
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers.entity import DeviceInfo
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
|
|
from .connection import ShieldConnectionManager
|
|
from .const import (
|
|
DOMAIN,
|
|
PROTOCOL_REMOTE_KEYS,
|
|
SCRIPT_VAR_COMMAND,
|
|
SCRIPT_VAR_DELAY_SECS,
|
|
SCRIPT_VAR_HOLD_SECS,
|
|
SCRIPT_VAR_NUM_REPEATS,
|
|
remote_command_script,
|
|
)
|
|
from .media_player import async_call_script, async_run_command_script, entity_available
|
|
|
|
_START_PREFIX = "START_LONG:"
|
|
_END_PREFIX = "END_LONG:"
|
|
_SHORT_PREFIX = "SHORT:"
|
|
_POWER_KEY = "KEYCODE_POWER"
|
|
|
|
|
|
def _split_command(command: str) -> tuple[str, str]:
|
|
"""Key + direction from a command string (default SHORT)."""
|
|
lowered = command.strip()
|
|
if lowered.startswith(_START_PREFIX):
|
|
return lowered[len(_START_PREFIX):], "START_LONG"
|
|
if lowered.startswith(_END_PREFIX):
|
|
return lowered[len(_END_PREFIX):], "END_LONG"
|
|
if lowered.startswith(_SHORT_PREFIX):
|
|
return lowered[len(_SHORT_PREFIX):], "SHORT"
|
|
return lowered, "SHORT"
|
|
|
|
|
|
def _key_code_name(command: str) -> str:
|
|
"""Command kind → protocol keycode (raw KEYCODE_ names pass through)."""
|
|
mapped = PROTOCOL_REMOTE_KEYS.get(command.lower())
|
|
if mapped is not None:
|
|
return mapped
|
|
upper = command.upper()
|
|
if upper.startswith("KEYCODE_"):
|
|
return upper
|
|
return upper # unknown names: the library reports/validates them
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Add the protocol-backed remote entity for this entry."""
|
|
async_add_entities([NvidiaShieldRemoteEntity(hass, entry)])
|
|
|
|
|
|
class NvidiaShieldRemoteEntity(RemoteEntity):
|
|
"""Remote entity over the manager's androidtvremote2 transport."""
|
|
|
|
_attr_has_entity_name = True
|
|
_attr_name = None
|
|
_attr_should_poll = False
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
self.hass = hass
|
|
self._entry = entry
|
|
self._manager: ShieldConnectionManager | None = getattr(
|
|
entry, "runtime_data", None
|
|
)
|
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_remote"
|
|
|
|
def _manager_of(self) -> ShieldConnectionManager | None:
|
|
return getattr(self._entry, "runtime_data", None)
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo:
|
|
return DeviceInfo(
|
|
identifiers={(DOMAIN, self._entry.entry_id)},
|
|
name=self._entry.title,
|
|
manufacturer="NVIDIA",
|
|
model="Shield TV",
|
|
)
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
"""Available while ANY usable transport can serve a command (t40).
|
|
|
|
The same ladder as the media player (``entity_available``): protocol
|
|
wired+available, ADB connected, fresh wake data or a passive manager all
|
|
count as usable, and a passive manager stays available — nothing to
|
|
transport is not a broken one.
|
|
"""
|
|
return entity_available(self._manager_of())
|
|
|
|
@property
|
|
def activity(self) -> str | None:
|
|
"""Current foreground app reported by the protocol push."""
|
|
manager = self._manager_of()
|
|
if isinstance(manager, ShieldConnectionManager):
|
|
return manager.current_app
|
|
return None
|
|
|
|
@callback
|
|
def _refresh_state(self) -> None:
|
|
"""Manager store changed — re-evaluate state (activity/available)."""
|
|
self._manager = self._manager_of()
|
|
self.async_write_ha_state()
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
manager = self._manager_of()
|
|
if isinstance(manager, ShieldConnectionManager):
|
|
self._manager_unregistered = manager.register_listener(
|
|
self._refresh_state
|
|
)
|
|
|
|
async def async_will_remove_from_hass(self) -> None:
|
|
unregistered = getattr(self, "_manager_unregistered", None)
|
|
if unregistered is not None:
|
|
unregistered()
|
|
self._manager_unregistered = None
|
|
|
|
async def async_turn_on(self, **kwargs) -> None:
|
|
"""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
|
|
if manager.is_on is not False:
|
|
# Unknown counts as "maybe on" — no superfluous toggle.
|
|
return
|
|
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
|
|
|
async def async_turn_off(self, **kwargs) -> None:
|
|
"""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
|
|
if manager.is_on is not True:
|
|
return
|
|
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
|
|
|
async def async_send_command(
|
|
self,
|
|
command: list[str],
|
|
num_repeats: int = 1,
|
|
delay_secs: float = 0.0,
|
|
hold_secs: float = 0.0,
|
|
) -> None:
|
|
"""Send protocol keys — or hand the whole command to the configured script.
|
|
|
|
t47: with a remote command script configured, it replaces the key path
|
|
entirely and receives the service call's own parameters, named as they are
|
|
on the service: ``command`` (a list of key commands), ``num_repeats``,
|
|
``delay_secs`` and ``hold_secs``. Unset, nothing changes: repeats, long
|
|
presses and delays go out as protocol keys exactly as before.
|
|
"""
|
|
script = remote_command_script(self._entry.options)
|
|
if script:
|
|
await async_call_script(
|
|
self.hass,
|
|
script,
|
|
"remote.send_command",
|
|
{
|
|
SCRIPT_VAR_COMMAND: command,
|
|
SCRIPT_VAR_NUM_REPEATS: num_repeats,
|
|
SCRIPT_VAR_DELAY_SECS: delay_secs,
|
|
SCRIPT_VAR_HOLD_SECS: hold_secs,
|
|
},
|
|
)
|
|
return
|
|
|
|
manager = self._manager_of()
|
|
if not isinstance(manager, ShieldConnectionManager):
|
|
return
|
|
for _ in range(max(1, num_repeats)):
|
|
for cmd in command:
|
|
key_part, direction = _split_command(cmd)
|
|
key_name = _key_code_name(key_part)
|
|
if hold_secs > 0 and direction == "SHORT":
|
|
if await manager.send_protocol_key(key_name, "START_LONG"):
|
|
await asyncio.sleep(max(0.0, hold_secs))
|
|
await manager.send_protocol_key(key_name, "END_LONG")
|
|
else:
|
|
await manager.send_protocol_key(key_name, direction)
|
|
if delay_secs > 0:
|
|
await asyncio.sleep(delay_secs)
|
|
|
|
async def async_send_text(self, text: str) -> bool:
|
|
"""Text entry (P5-2) via the protocol send_text (remote surface).
|
|
|
|
Falls back to ADB `input text` only when the protocol transport is
|
|
down or the send failed (the Shield may swallow IME text while its
|
|
on-screen keyboard is up, RESEARCH_IDEAS §2).
|
|
"""
|
|
manager = self._manager_of()
|
|
if manager is None or not text:
|
|
return False
|
|
if await manager.send_text_ime(text):
|
|
return True
|
|
return await manager.send_adb_input_text(text)
|