"""The Nvidia Shield integration. Phase 1 architecture (docs/MVP_PLAN.md §2): the entry owns a single ShieldConnectionManager stored in ``entry.runtime_data`` — the persistent ADB socket and all ADB-derived state live there, not on the entities. Entities are thin callback-views over the manager's StateStore. ADB is optional: a failed/absent ADB connection never blocks setup (no ConfigEntryNotReady) — the manager retries in the background, or stays passive until the user pairs via the Pair ADB button. """ import logging import os from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from .adb_session import AdbMediaSessionPoller from .connection import ShieldConnectionManager from .const import ( ADB_COMMAND_SERVICE_DESCRIPTION, CONF_ADB_ENABLED, CONF_ADB_HOST, CONF_ADB_PORT, CONF_ANDROIDTV_ENTITY, CONF_HOST, CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_HOST, CONF_REGISTERED_INTENTS, DEFAULT_ADB_PORT, DEFAULT_NAME, DOMAIN, ENTRY_VERSION, SERVICE_ADB_COMMAND, SERVICE_SEND_INTENT, ) from .helpers import ( entry_host, protocol_cert_paths, resolve_adb_config, seed_adb_enabled, seed_host, ) _LOGGER = logging.getLogger(__name__) # t66: IMAGE is listed unconditionally — the platform's async_setup_entry adds # nothing unless the user opted in, so an install that never turns screen capture # on pays nothing for the platform existing (and toggling it is an options reload). PLATFORMS = [ Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON, Platform.IMAGE, ] async def async_setup(hass: HomeAssistant, config) -> bool: """Domain setup: actions (always-on) + optional YAML seeding (t30 U1). YAML is SEED-ONLY (user ruling): every ``nvidia_shield`` block starts an interactive SOURCE_IMPORT config flow — nothing is created without the user walking the flow, and pairing stays interactive exactly as for UI setup. Removing the YAML block creates OR deletes nothing. UI-configured entries are never re-seeded (host/MAC duplicate guards). """ domain_config = (config or {}).get(DOMAIN) for block in _yaml_seed_blocks(domain_config or []): if not seed_host(block): _LOGGER.warning( "nvidia_shield YAML block without an address ignored: %s", block.get("name", ""), ) continue hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=block, ) ) # t29 G8 (kept here across the t30 splice): the action registers ALWAYS. # The two APIs the target-bearing registration needs are imported WITH that # registration, next to the handler they belong to (t14): importing the package — # which every config-flow harness does via # ``custom_components.nvidia_shield.config_flow`` — must not drag in the service # registration surface for a domain that harness never sets up. from homeassistant.core import SupportsResponse from homeassistant.helpers.service import async_set_service_schema from .media_player import ( _ADB_COMMAND_SCHEMA, _INTENT_SCHEMA, _async_adb_command_service, _async_send_intent_service, send_intent_service_description, ) # t14: the action must be SELECTABLE for the user's Shield. Measured on HA # 2026.10.1: `async_register` has no `target` parameter, so the UI's target # picker comes from the service DESCRIPTION — the runtime # `async_set_service_schema` (which wins over a services.yaml we do not ship) # carries `target: {entity: [{domain: media_player}]}`, so the picker offers the # Shield's media player entity. `supports_response=OPTIONAL` is registered FIRST # because async_set_service_schema reads `hass.services.supports_response()` to # publish the response flag, and the handler returns the command's output under # ADB_COMMAND_RESPONSE_KEY. hass.services.async_register( DOMAIN, SERVICE_ADB_COMMAND, _async_adb_command_service, schema=_ADB_COMMAND_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) async_set_service_schema( hass, DOMAIN, SERVICE_ADB_COMMAND, ADB_COMMAND_SERVICE_DESCRIPTION ) # t17: send_intent — the same measured mechanism as t14 (a runtime # description carries the target picker, and the response flag is read from # the registration, so OPTIONAL is registered FIRST). The description is # republished by every async_setup_entry because the intent DROPDOWN is # per-entry data while the service is domain-wide; at domain setup no entry # has loaded yet, so the list is empty and every name is still typeable # (`custom_value`), with the call-time lookup as the authority. hass.services.async_register( DOMAIN, SERVICE_SEND_INTENT, _async_send_intent_service, schema=_INTENT_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) async_set_service_schema( hass, DOMAIN, SERVICE_SEND_INTENT, send_intent_service_description(()) ) return True def _yaml_seed_blocks(domain_config) -> list[dict]: """Normalize the optional YAML domain key into seed blocks (t30 U1).""" blocks: list[dict] = [] raw_list = domain_config if isinstance(domain_config, list) else [domain_config] for block in raw_list: if not isinstance(block, dict): continue name = block.get("name") or DEFAULT_NAME # t32 F4 (review rider): the seed honors the block's shape — a # protocol-only block does NOT force ADB on. t41: the address is ONE # field (`host`, or a legacy protocol_host/adb_host). seed = { "name": name, CONF_ADB_ENABLED: seed_adb_enabled(block), CONF_HOST: seed_host(block), CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT), } if block.get("unique_id"): seed["unique_id"] = str(block["unique_id"]) # t17: the intents block travels VERBATIM to the import flow, which owns # the validation (and drops unusable rows with a log line) — the seed # normaliser here only decides which keys reach the flow at all. if isinstance(block.get(CONF_REGISTERED_INTENTS), list): seed[CONF_REGISTERED_INTENTS] = block[CONF_REGISTERED_INTENTS] blocks.append(seed) return blocks async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry): """Poller factory when ADB is configured AND usable today, else None. Returning None leaves the manager passive: the entry still sets up, the entities simply see no ADB state. The Pair ADB button / reconfigure triggers a reload that re-evaluates this. """ if not entry.options.get(CONF_ADB_ENABLED, False): return None adb = resolve_adb_config(hass, entry) if adb is None: _LOGGER.warning( "ADB enabled but no host resolvable for entry %s — continuing " "without ADB state.", entry.title, ) return None # Only auto-connect when a key already exists — an ADB handshake against # an unpaired device is pointless noise (pairing happens via the Pair ADB # button / config flow, untouched). key_path_ready = await hass.async_add_executor_job( os.path.exists, adb.key_path ) if not key_path_ready: _LOGGER.info( "ADB key not found for entry %s — skipping auto-connect. " "Use the Pair ADB button or reconfigure the integration.", entry.title, ) return None # t32 F1 (review repair): the audit-sanctioned FACTORY RE-READ — the # host/port resolve from entry.options AT CALL TIME, so the poller built # after a zeroconf IP change targets the NEW address without a reload # (the setup-time closure pinned the old address). A key-path change # still needs the Pair ADB button / reconfigure reload, by design. def _factory(): adb_now = resolve_adb_config(hass, entry) if adb_now is None: return None return AdbMediaSessionPoller(adb_now.host, adb_now.port, adb_now.key_path) return _factory async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nvidia Shield from a config entry.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = entry manager = ShieldConnectionManager( hass, entry, poller_factory=await _build_poller_factory(hass, entry), protocol_settings=await _build_protocol_settings(hass, entry), invalid_auth_callback=lambda: entry.async_start_reauth(hass), ) entry.runtime_data = manager await manager.async_start() # Close the persistent socket on Home Assistant stop as well as unload. async def _close_on_hass_stop(_event) -> None: await manager.async_stop() entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close_on_hass_stop) ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # t17: refresh the send_intent dropdown from what is ACTUALLY loaded. This # runs on every reload, and an options change reloads the entry, so adding # or removing an intent updates the picker. The union across entries is # deliberate: the service is domain-wide and any of them may be the target. _publish_send_intent_description(hass) return True def _publish_send_intent_description(hass: HomeAssistant) -> None: """Republish send_intent's description with the loaded intent names (t17). Reads each loaded entry's OPTIONS through the runtime's own view of them (``registered_intents``), so a hand-edited or malformed row cannot reach the picker — the dropdown offers exactly what the action would accept. """ try: from homeassistant.helpers.service import async_set_service_schema from .media_player import registered_intents, send_intent_service_description except ImportError: # pragma: no cover - a harness without HA's service module # The dropdown is a RENDERING nicety: the action itself is registered by # async_setup and works without it. Only a stub harness (which loads this # module without the real homeassistant package) can get here. return names: list[str] = [] for loaded in (hass.data.get(DOMAIN) or {}).values(): options = getattr(loaded, "options", None) if options is None: options = getattr(getattr(loaded, "entry", None), "options", None) names.extend(intent["name"] for intent in registered_intents(options or {})) async_set_service_schema( hass, DOMAIN, SERVICE_SEND_INTENT, send_intent_service_description(names) ) async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry): """Manager protocol_settings when the transport is enabled & resolvable. The address is the entry's own single field (t41). Nothing is derived from another integration. """ if not entry.options.get(CONF_PROTOCOL_ENABLED, False): return None host = entry_host(entry) if not host: _LOGGER.info( "Protocol enabled for entry %s but no host resolvable — continuing " "without the remote-protocol transport. Pair via the flow.", entry.title, ) return None certfile, keyfile = await hass.async_add_executor_job(protocol_cert_paths, hass) return { "host": host, "client_name": entry.title or "Home Assistant", "certfile": certfile, "keyfile": keyfile, } async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate an older config entry to the current version. v1 → v2 added the remote-protocol fields (ADB options carried over untouched). v2 → v3 (t41) mirrors the two changes in the stored shape: * the duplicated address fields collapse into the single CONF_HOST — the protocol host wins, else the ADB host, since both name the same Shield; * the androidtv-entity key goes. This integration owns the whole stack now: the entity it used to configure is neither a state source nor an app-name or power tier, so keeping the value would only mislead. Downgrades (version > ENTRY_VERSION) are rejected so HA offers a rollback. """ if config_entry.version > ENTRY_VERSION: return False if config_entry.version >= ENTRY_VERSION: return True options = {**config_entry.options} data = {**config_entry.data} if config_entry.version < 2: options.setdefault(CONF_PROTOCOL_ENABLED, False) if not options.get(CONF_HOST): options[CONF_HOST] = ( options.get(CONF_PROTOCOL_HOST) or options.get(CONF_ADB_HOST) or data.get(CONF_PROTOCOL_HOST) or data.get(CONF_ADB_HOST) or "" ) dropped_entity = options.pop(CONF_ANDROIDTV_ENTITY, None) dropped_entity = data.pop(CONF_ANDROIDTV_ENTITY, None) or dropped_entity for legacy_key in (CONF_PROTOCOL_HOST, CONF_ADB_HOST): options.pop(legacy_key, None) data.pop(legacy_key, None) hass.config_entries.async_update_entry( config_entry, data=data, options=options, version=ENTRY_VERSION ) if dropped_entity: _LOGGER.info( "%s: config entry migrated to version %d — dropped the androidtv " "entity %s (this integration reads the Shield directly; the entry " "needs no external media_player)", config_entry.title, ENTRY_VERSION, dropped_entity, ) else: _LOGGER.info( "Migrated %s config entry to version %d", config_entry.title, ENTRY_VERSION, ) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: manager = getattr(entry, "runtime_data", None) if manager is not None: await manager.async_stop() entry.runtime_data = None hass.data[DOMAIN].pop(entry.entry_id) return unload_ok