"""Button entities for NVIDIA Shield.""" import logging from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .adb_session import AdbMediaSessionPoller, ensure_adb_key from .const import ( CONF_ADB_ENABLED, CONF_ADB_PORT, DEFAULT_ADB_PORT, DOMAIN, ) from .helpers import entry_host from .media_player import ( intent_default_variables, registered_intents, run_registered_intent, ) _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: entities: list[ButtonEntity] = [] if entry.options.get(CONF_ADB_ENABLED, False): entities.append(NvidiaShieldPairAdbButton(hass, entry)) # t17: one button per REGISTERED intent. The registration IS the opt-in (the # plan: nothing registered ever runs by itself), so a new intent adds a # button and a removed one takes its button away — the options flow reloads # the entry on every save, which re-runs this setup. manager = getattr(entry, "runtime_data", None) for intent in registered_intents(entry.options): entities.append(NvidiaShieldIntentButton(hass, entry, manager, intent)) if entities: async_add_entities(entities) class NvidiaShieldPairAdbButton(ButtonEntity): """Button to initiate or re-pair the ADB connection.""" _attr_has_entity_name = True _attr_name = "Pair ADB" _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:link-variant" def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.hass = hass self._entry = entry self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_pair_adb" @property def device_info(self) -> DeviceInfo: return DeviceInfo( identifiers={(DOMAIN, self._entry.entry_id)}, name=self._entry.title, manufacturer="NVIDIA", model="Shield TV", ) async def async_press(self) -> None: """Attempt ADB connection. The Shield will show a pairing prompt.""" # t41: the entry's own single address — never derived from another # integration's entity. host = entry_host(self._entry) port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT) if not host: _LOGGER.error("Could not determine ADB host. Set it in integration options.") return key_path = await self.hass.async_add_executor_job( ensure_adb_key, self.hass.config.config_dir ) _LOGGER.info( "Attempting ADB pairing with %s:%d — approve the prompt on the Shield", host, port, ) poller = AdbMediaSessionPoller(host, port, key_path) if await poller.connect(): _LOGGER.info("ADB pairing successful with %s", host) await poller.disconnect() # Reload to start polling await self.hass.config_entries.async_reload(self._entry.entry_id) else: _LOGGER.error( "ADB pairing failed with %s. Make sure Network debugging is enabled " "and you approved the prompt on the Shield.", host, ) await poller.disconnect() class NvidiaShieldIntentButton(ButtonEntity): """One button per REGISTERED intent (t17). The whole point for an intent with no variables: pressing it sends the intent with the fields' declared defaults. It deliberately does NOT cache a command — :func:`run_registered_intent` builds it every press, so the button, the action and the tests all exercise one code path. Failures are LOUD. A missing required field, ADB being off/down, a transport that never answered and a device that refused the intent all raise with the reason (the device's own message included) — a button that silently did nothing is exactly the "silent success" the plan forbids. An app that rejected the intent is a CONFIGURATION problem, and the raised text says so. """ _attr_has_entity_name = True def __init__( self, hass: HomeAssistant, entry: ConfigEntry, manager, intent: dict ) -> None: self.hass = hass self._entry = entry self._manager = manager self._intent = intent self._attr_name = intent["name"] # the intent id is stable across renames, so a rename does not orphan the # entity (and does not create a second one) self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_intent_{intent['id']}" self._attr_icon = "mdi:gesture-tap-button" @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 extra_state_attributes(self) -> dict: """What this button will send — the registration, readable (t17). A button whose registration the user cannot see is a black box: these are the SAME values the command is built from, and package/action/component are what they typed on the Intents screen. """ return { "intent_id": self._intent["id"], "package": self._intent["package"], "activity": self._intent.get("activity") or None, "action": self._intent.get("action") or None, "categories": list(self._intent.get("categories") or []), "data": self._intent.get("data") or None, "fields": [dict(field) for field in (self._intent.get("fields") or [])], } async def async_press(self) -> None: """Send the intent with its declared defaults, or raise with the reason.""" entity_id = getattr(self, "entity_id", None) or self._attr_unique_id variables = intent_default_variables(self._intent) output = await run_registered_intent( self._manager, entity_id, self._intent, variables ) _LOGGER.debug( "intent button %s sent %s → %s", entity_id, self._intent["id"], (output or "").strip().splitlines()[:1], )