470 lines
18 KiB
Python
470 lines
18 KiB
Python
"""Config flow for Nvidia Shield integration."""
|
|
|
|
import contextlib
|
|
import logging
|
|
|
|
import voluptuous as vol
|
|
from homeassistant import config_entries
|
|
from homeassistant.core import callback
|
|
from homeassistant.helpers import entity_registry as er
|
|
from homeassistant.helpers import selector
|
|
|
|
from .adb_session import ensure_adb_key
|
|
from .connection import ShieldConnectionManager
|
|
from .const import (
|
|
ATTR_MEDIA_DAG_INPUT_MAP,
|
|
CONF_ADB_ENABLED,
|
|
CONF_ADB_HOST,
|
|
CONF_ADB_PORT,
|
|
CONF_ADB_STORE_APP_INFO,
|
|
CONF_ANDROIDTV_ENTITY,
|
|
CONF_JELLYFIN_ENTITY,
|
|
CONF_PAIRING_CODE,
|
|
CONF_POWER_ENTITY,
|
|
CONF_PROTOCOL_ENABLED,
|
|
CONF_PROTOCOL_HOST,
|
|
CONF_RECEIVER_ENTITY,
|
|
CONF_RECEIVER_INPUT,
|
|
DEFAULT_ADB_PORT,
|
|
DEFAULT_NAME,
|
|
DOMAIN,
|
|
)
|
|
from .helpers import discover_adb_host, protocol_cert_paths
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
def _get_receiver_inputs(hass, receiver_entity_id: str | None) -> list[str]:
|
|
"""Read available inputs from a receiver's media_dag_input_map attribute."""
|
|
if not receiver_entity_id:
|
|
return []
|
|
state = hass.states.get(receiver_entity_id)
|
|
if not state:
|
|
return []
|
|
input_map = state.attributes.get(ATTR_MEDIA_DAG_INPUT_MAP, {})
|
|
return list(input_map.keys()) if isinstance(input_map, dict) else []
|
|
|
|
|
|
def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
|
"""Auto-detect the Jellyfin media_player entity for a device."""
|
|
registry = er.async_get(hass)
|
|
device_name_lower = device_name.lower()
|
|
candidates = []
|
|
|
|
for entry in registry.entities.values():
|
|
if entry.domain != "media_player" or entry.platform != "jellyfin":
|
|
continue
|
|
if entry.disabled_by is not None:
|
|
continue
|
|
|
|
eid_lower = entry.entity_id.lower()
|
|
name_lower = (entry.original_name or "").lower()
|
|
|
|
if device_name_lower in eid_lower or device_name_lower in name_lower:
|
|
state = hass.states.get(entry.entity_id)
|
|
if state and state.state != "unavailable":
|
|
candidates.append(entry.entity_id)
|
|
|
|
if candidates:
|
|
if len(candidates) > 1:
|
|
_LOGGER.warning(
|
|
"Multiple Jellyfin entities found for %s: %s. Using first.",
|
|
device_name, candidates,
|
|
)
|
|
return candidates[0]
|
|
return None
|
|
|
|
|
|
def _user_schema_dict(sel) -> dict:
|
|
"""User-step schema — the androidtv entity is optional (standalone)."""
|
|
schema_dict = {
|
|
vol.Required("name", default=DEFAULT_NAME): str,
|
|
vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
|
|
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
|
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
|
}
|
|
|
|
# If there are any receivers with DAG input maps, show a text field
|
|
# for receiver input (will be a dropdown in options once receiver is set)
|
|
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
|
|
|
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
|
|
|
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool
|
|
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
|
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
|
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int
|
|
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=False)] = bool
|
|
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
|
return schema_dict
|
|
|
|
|
|
def _build_selectors():
|
|
"""Build reusable selector instances."""
|
|
return {
|
|
"androidtv": selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain="media_player", device_class="tv")
|
|
),
|
|
"media_player": selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain="media_player")
|
|
),
|
|
"receiver": selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain="media_player", device_class="receiver")
|
|
),
|
|
"power": selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
|
|
),
|
|
}
|
|
|
|
|
|
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|
"""Handle a config flow for Nvidia Shield."""
|
|
|
|
VERSION = 2
|
|
|
|
def __init__(self):
|
|
self._user_data = {}
|
|
self._protocol_client = None
|
|
self._reauth_entry = None
|
|
|
|
async def async_step_user(self, user_input=None):
|
|
"""Step 1: Basic setup."""
|
|
errors = {}
|
|
|
|
if user_input is not None:
|
|
# Standalone setup (Phase 3): the external androidtv media_player
|
|
# is optional — the protocol push can be the only state source.
|
|
androidtv_entity = user_input.get(CONF_ANDROIDTV_ENTITY)
|
|
|
|
if not androidtv_entity and not user_input.get(CONF_PROTOCOL_ENABLED):
|
|
errors["base"] = "no_state_source"
|
|
sel = _build_selectors()
|
|
return self.async_show_form(
|
|
step_id="user",
|
|
data_schema=vol.Schema(_user_schema_dict(sel)),
|
|
errors=errors,
|
|
)
|
|
|
|
if androidtv_entity:
|
|
await self.async_set_unique_id(androidtv_entity)
|
|
self._abort_if_unique_id_configured()
|
|
|
|
# Auto-detect Jellyfin if not manually specified
|
|
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
|
|
if not jellyfin:
|
|
device_name = user_input.get("name", DEFAULT_NAME)
|
|
atv_state = (
|
|
self.hass.states.get(androidtv_entity)
|
|
if androidtv_entity
|
|
else None
|
|
)
|
|
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
|
|
|
|
for name in [device_name, atv_name]:
|
|
if not name:
|
|
continue
|
|
found = _find_jellyfin_entity(self.hass, name)
|
|
if found:
|
|
jellyfin = found
|
|
_LOGGER.info("Auto-detected Jellyfin entity: %s", found)
|
|
break
|
|
|
|
self._user_data = {
|
|
"name": user_input.get("name", DEFAULT_NAME),
|
|
CONF_ANDROIDTV_ENTITY: androidtv_entity or "",
|
|
CONF_JELLYFIN_ENTITY: jellyfin or "",
|
|
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
|
|
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
|
|
CONF_POWER_ENTITY: user_input.get(CONF_POWER_ENTITY, ""),
|
|
CONF_ADB_ENABLED: user_input.get(CONF_ADB_ENABLED, False),
|
|
CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True),
|
|
CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""),
|
|
CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
|
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, False),
|
|
CONF_PROTOCOL_HOST: user_input.get(CONF_PROTOCOL_HOST, ""),
|
|
}
|
|
|
|
# If ADB is enabled, go to pairing step
|
|
if self._user_data[CONF_ADB_ENABLED]:
|
|
return await self.async_step_adb_pair()
|
|
|
|
if self._user_data[CONF_PROTOCOL_ENABLED]:
|
|
return await self.async_step_protocol_pair()
|
|
|
|
return self._create_entry()
|
|
|
|
|
|
sel = _build_selectors()
|
|
|
|
schema_dict = _user_schema_dict(sel)
|
|
|
|
return self.async_show_form(
|
|
step_id="user",
|
|
data_schema=vol.Schema(schema_dict),
|
|
errors=errors,
|
|
)
|
|
|
|
def _create_entry(self):
|
|
"""Create the config entry from stored user data."""
|
|
d = self._user_data
|
|
entry_data = {}
|
|
if d.get(CONF_ANDROIDTV_ENTITY):
|
|
entry_data[CONF_ANDROIDTV_ENTITY] = d[CONF_ANDROIDTV_ENTITY]
|
|
return self.async_create_entry(
|
|
title=d["name"],
|
|
data=entry_data,
|
|
options={
|
|
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
|
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
|
CONF_RECEIVER_INPUT: d.get(CONF_RECEIVER_INPUT, ""),
|
|
CONF_POWER_ENTITY: d.get(CONF_POWER_ENTITY, ""),
|
|
CONF_ADB_ENABLED: d.get(CONF_ADB_ENABLED, False),
|
|
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
|
|
CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""),
|
|
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
|
CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, False),
|
|
CONF_PROTOCOL_HOST: d.get(CONF_PROTOCOL_HOST, ""),
|
|
},
|
|
)
|
|
|
|
async def _async_after_pairing(self):
|
|
"""Route after the pairing steps: protocol next, else create the entry."""
|
|
if self._user_data.get(CONF_PROTOCOL_ENABLED):
|
|
return await self.async_step_protocol_pair()
|
|
return self._create_entry()
|
|
|
|
def _protocol_host(self) -> str | None:
|
|
"""Protocol host: explicit override, else the ADB host fallback."""
|
|
return self._user_data.get(CONF_PROTOCOL_HOST) or self._resolve_adb_host()
|
|
|
|
async def _start_protocol_pairing(self):
|
|
"""Build a throwaway protocol client and make the Shield show its PIN.
|
|
|
|
Certificates live under ``.storage/<domain>/`` (core androidtv_remote
|
|
helpers.py pattern) and are shared between pairing and runtime.
|
|
"""
|
|
host = self._protocol_host()
|
|
if not host:
|
|
return None
|
|
certfile, keyfile = await self.hass.async_add_executor_job(
|
|
protocol_cert_paths, self.hass
|
|
)
|
|
client = ShieldConnectionManager.build_protocol_client(
|
|
host,
|
|
client_name=self._user_data.get("name", DEFAULT_NAME) or DEFAULT_NAME,
|
|
certfile=certfile,
|
|
keyfile=keyfile,
|
|
)
|
|
await client.async_generate_cert_if_missing()
|
|
await client.async_start_pairing()
|
|
return client
|
|
|
|
async def async_step_protocol_pair(self, user_input=None):
|
|
"""Pair with the remote protocol — the PIN is displayed on the Shield."""
|
|
errors = {}
|
|
if user_input is None:
|
|
# First visit: proactively start pairing so the PIN shows on the
|
|
# Shield while the form is open.
|
|
client = await self._start_protocol_pairing()
|
|
if client is None:
|
|
errors["base"] = "protocol_no_host"
|
|
else:
|
|
self._protocol_client = client
|
|
else:
|
|
client = self._protocol_client
|
|
code = user_input.get(CONF_PAIRING_CODE, "")
|
|
if client is None or not code:
|
|
errors["base"] = "protocol_invalid_code"
|
|
else:
|
|
try:
|
|
await client.async_finish_pairing(code)
|
|
except Exception:
|
|
errors["base"] = "protocol_pair_failed"
|
|
else:
|
|
try:
|
|
_name, mac = await client.async_get_name_and_mac()
|
|
except Exception:
|
|
mac = None
|
|
with contextlib.suppress(Exception):
|
|
client.disconnect()
|
|
self._protocol_client = None
|
|
if mac:
|
|
await self.async_set_unique_id(mac)
|
|
self._abort_if_unique_id_configured()
|
|
if self._reauth_entry is not None:
|
|
return self._finish_reauth()
|
|
return self._create_entry()
|
|
return self.async_show_form(
|
|
step_id="protocol_pair",
|
|
data_schema=vol.Schema({
|
|
vol.Required(CONF_PAIRING_CODE): str,
|
|
}),
|
|
errors=errors,
|
|
)
|
|
|
|
async def async_step_reauth(self, entry_data):
|
|
"""Device hard reset invalidated our certificates (review trigger)."""
|
|
self._reauth_entry = self.hass.config_entries.async_get_entry(
|
|
self.context.get("entry_id")
|
|
)
|
|
if self._reauth_entry is None:
|
|
return self.async_abort(reason="reauth_entry_missing")
|
|
return self.async_show_form(
|
|
step_id="reauth_confirm", data_schema=vol.Schema({})
|
|
)
|
|
|
|
async def async_step_reauth_confirm(self, user_input=None):
|
|
"""Re-pair after a device hard reset, then reload the entry."""
|
|
if user_input is not None:
|
|
entry = self._reauth_entry
|
|
self._user_data = {
|
|
"name": entry.title,
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""),
|
|
}
|
|
return await self.async_step_protocol_pair() # first visit re-pairs
|
|
return self.async_show_form(
|
|
step_id="reauth_confirm", data_schema=vol.Schema({})
|
|
)
|
|
|
|
def _finish_reauth(self):
|
|
"""Reauth pairing done: update options, schedule the reload, abort."""
|
|
entry = self._reauth_entry
|
|
self.hass.config_entries.async_update_entry(
|
|
entry,
|
|
options={
|
|
**entry.options,
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
|
|
},
|
|
)
|
|
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
|
self._reauth_entry = None
|
|
return self.async_abort(reason="reauth_successful")
|
|
|
|
def _resolve_adb_host(self) -> str | None:
|
|
"""Resolve ADB host from explicit config or the Android TV entity."""
|
|
return self._user_data.get(CONF_ADB_HOST) or discover_adb_host(
|
|
self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY)
|
|
)
|
|
|
|
async def async_step_adb_pair(self, user_input=None):
|
|
"""Step 2: ADB pairing. User clicks Submit after approving on the Shield."""
|
|
errors = {}
|
|
|
|
if user_input is not None:
|
|
host = self._resolve_adb_host()
|
|
port = self._user_data.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
|
|
|
if not host:
|
|
errors["base"] = "adb_no_host"
|
|
else:
|
|
key_path = await self.hass.async_add_executor_job(
|
|
ensure_adb_key, self.hass.config.config_dir
|
|
)
|
|
|
|
connected = await ShieldConnectionManager.test_connect(
|
|
host, port, key_path
|
|
)
|
|
if connected:
|
|
return await self._async_after_pairing()
|
|
else:
|
|
errors["base"] = "adb_failed"
|
|
|
|
return self.async_show_form(
|
|
step_id="adb_pair",
|
|
data_schema=vol.Schema({}),
|
|
errors=errors,
|
|
)
|
|
|
|
@staticmethod
|
|
@callback
|
|
def async_get_options_flow(config_entry):
|
|
return NvidiaShieldOptionsFlow(config_entry)
|
|
|
|
|
|
class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|
"""Handle options for Nvidia Shield — single unified page."""
|
|
|
|
def __init__(self, config_entry):
|
|
self._config_entry = config_entry
|
|
self._options = dict(config_entry.options)
|
|
|
|
async def async_step_init(self, user_input=None):
|
|
"""Single-page options."""
|
|
if user_input is not None:
|
|
self._options.update(user_input)
|
|
return self.async_create_entry(title="", data=self._options)
|
|
|
|
sel = _build_selectors()
|
|
schema_dict = {}
|
|
|
|
# Jellyfin
|
|
jf = self._options.get(CONF_JELLYFIN_ENTITY)
|
|
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
|
if jf:
|
|
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY, default=jf)
|
|
schema_dict[jf_key] = sel["media_player"]
|
|
|
|
# Receiver
|
|
recv = self._options.get(CONF_RECEIVER_ENTITY)
|
|
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
|
if recv:
|
|
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv)
|
|
schema_dict[recv_key] = sel["receiver"]
|
|
|
|
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
|
current_input = self._options.get(CONF_RECEIVER_INPUT, "")
|
|
receiver = self._options.get(CONF_RECEIVER_ENTITY)
|
|
inputs = _get_receiver_inputs(self.hass, receiver)
|
|
|
|
if inputs:
|
|
input_sel = selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=inputs,
|
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
|
custom_value=True,
|
|
)
|
|
)
|
|
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
|
if current_input:
|
|
input_key = vol.Optional(CONF_RECEIVER_INPUT, default=current_input)
|
|
schema_dict[input_key] = input_sel
|
|
else:
|
|
schema_dict[vol.Optional(
|
|
CONF_RECEIVER_INPUT, default=current_input
|
|
)] = str
|
|
|
|
# Power
|
|
pwr = self._options.get(CONF_POWER_ENTITY)
|
|
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
|
if pwr:
|
|
pwr_key = vol.Optional(CONF_POWER_ENTITY, default=pwr)
|
|
schema_dict[pwr_key] = sel["power"]
|
|
|
|
# ADB options
|
|
schema_dict[vol.Optional(
|
|
CONF_ADB_ENABLED,
|
|
default=self._options.get(CONF_ADB_ENABLED, False),
|
|
)] = bool
|
|
schema_dict[vol.Optional(
|
|
CONF_ADB_STORE_APP_INFO,
|
|
default=self._options.get(CONF_ADB_STORE_APP_INFO, True),
|
|
)] = bool
|
|
|
|
# ADB host/port (optional overrides, shown when ADB is enabled)
|
|
adb_host = self._options.get(CONF_ADB_HOST, "")
|
|
schema_dict[vol.Optional(
|
|
CONF_ADB_HOST,
|
|
default=adb_host,
|
|
)] = str
|
|
schema_dict[vol.Optional(
|
|
CONF_ADB_PORT,
|
|
default=self._options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
|
)] = int
|
|
|
|
return self.async_show_form(
|
|
step_id="init",
|
|
data_schema=vol.Schema(schema_dict),
|
|
)
|