diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 3b4fab5..f151b05 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -24,6 +24,7 @@ import asyncio import logging import time +import voluptuous as vol from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntity, @@ -46,6 +47,7 @@ from .const import ( ATTR_MEDIA_DAG_ROLE, ATTR_MEDIA_DAG_RUNNING_APPS, CHANNEL_DIGIT_DELAY, + CONF_ADB_COMMAND_ENABLED, CONF_ADB_STORE_APP_INFO, CONF_ANDROIDTV_ENTITY, CONF_APP_NAME_MAP, @@ -58,6 +60,7 @@ from .const import ( DOMAIN, PROTOCOL_MEDIA_KEYS, PROTOCOL_REMOTE_KEYS, + SERVICE_ADB_COMMAND, TIVIMATE_PACKAGE, ) from .ha_adb_common import PlaybackIntent, running_apps_from_tasks @@ -183,6 +186,14 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True) async def async_added_to_hass(self) -> None: + # P4-2a: opt-in as an adb_command target (option default off). + # Registered at setup; unregistered at unload when the last + # participating entity goes away. + if self._entry.options.get(CONF_ADB_COMMAND_ENABLED, False): + entity_id = getattr(self, "entity_id", None) or self._attr_unique_id + _ADB_COMMAND_TARGETS[entity_id] = self._manager + _adb_command_service_ensure(self.hass) + # Discover power sensor siblings from the power switch if self._power_entity: self._power_sensors = discover_power_siblings(self.hass, self._power_entity) @@ -218,6 +229,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): for task in self._entity_tasks: task.cancel() self._entity_tasks.clear() + # P4-2a: drop this entity's service participation + entity_id = getattr(self, "entity_id", None) or self._attr_unique_id + if _ADB_COMMAND_TARGETS.pop(entity_id, None) is not None and self.hass: + _adb_command_service_maybe_remove(self.hass) # ── Intent management ── @@ -930,3 +945,121 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): except asyncio.CancelledError: raise + + +# ── P4-2a: the option-gated adb_command service ── +# Entities register their manager as a target when their entry enables the +# option; the domain service unregisters once the last participant unloads +# (core androidtv service posture: documented, opt-in, default off). +_ADB_COMMAND_TARGETS: dict[str, ShieldConnectionManager] = {} + +_ADB_COMMAND_SCHEMA = vol.Schema( + { + vol.Required("command"): str, + vol.Optional("entity_id"): str, + } +) + + +async def _async_adb_command_service(call) -> None: + """Run a raw ADB shell command on the targeted entry's connection.""" + command = str(call.data.get("command") or "").strip() + if not command: + _LOGGER.warning("adb_command: empty command ignored") + return + entity_id = call.data.get("entity_id") + pairs = [ + (eid, manager) + for eid, manager in _ADB_COMMAND_TARGETS.items() + if entity_id in (None, eid) + ] + if not pairs: + _LOGGER.warning( + "adb_command: no participating entry matches %s (is the option " + "enabled on the entry's options?)", + entity_id, + ) + return + for _eid, manager in pairs: + poller = manager._poller + if poller is None or not manager.connected: + _LOGGER.warning("adb_command: ADB unavailable — command not sent") + continue + result = await poller.shell(command) + _LOGGER.debug("adb_command %r → output=%s", command, result is not None) + + +def _adb_command_service_ensure(hass) -> None: + """Register the domain service once the first participant appears.""" + if not hass.services.has_service(DOMAIN, SERVICE_ADB_COMMAND): + hass.services.async_register( + DOMAIN, + SERVICE_ADB_COMMAND, + _async_adb_command_service, + schema=_ADB_COMMAND_SCHEMA, + ) + + +def _adb_command_service_maybe_remove(hass) -> None: + """Unregister when the last participant unloads.""" + if not _ADB_COMMAND_TARGETS and hass.services.has_service( + DOMAIN, SERVICE_ADB_COMMAND + ): + hass.services.async_remove(DOMAIN, SERVICE_ADB_COMMAND) + + +# ── P4-2b: diagnostics ── + +_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material") +_REDACTED = "**REDACTED**" + + +def _redact(obj): + """Redact sensitive keys from a JSON-ish diagnostics payload.""" + if isinstance(obj, dict): + return { + key: ( + _REDACTED + if any(m in str(key).lower() for m in _REDACT_KEY_MARKERS) + else _redact(value) + ) + for key, value in obj.items() + } + if isinstance(obj, list): + return [_redact(item) for item in obj] + return obj + + +async def async_get_config_entry_diagnostics(hass, entry): + """Entry diagnostics (P4-2b): redacted config + store + transport health. + + HA resolves diagnostics from a ``diagnostics`` module; the function is + import-ready there — kept in this module until a pure-diagnostics file + is added (Phase 4 trim). + """ + import dataclasses + + manager = getattr(entry, "runtime_data", None) + payload: dict = { + "entry": { + "data": dict(getattr(entry, "data", {}) or {}), + "options": dict(getattr(entry, "options", {}) or {}), + }, + "store": {}, + "transport": {}, + } + if manager is not None: + snapshot = getattr(manager.store, "_values", {}) + for name, (stamp, value) in snapshot.items(): + if dataclasses.is_dataclass(value) and not isinstance(value, type): + value = dataclasses.asdict(value) + payload["store"][name] = {"age": round(time.monotonic() - stamp, 3), "value": value} + payload["transport"] = { + "adb": {"connected": manager.connected, "awake": manager.awake}, + "protocol": { + "active": manager.protocol_active, + "available": manager.is_available, + "is_on": manager.is_on, + }, + } + return _redact(payload)