Files
ha-nvidia-shield/custom_components/nvidia_shield/config_flow.py
T

595 lines
23 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_COMMAND_ENABLED,
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY,
CONF_APP_NAME_MAP,
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 _app_map_to_text(app_map: dict) -> str:
"""Serialize the apps map to one ``package=name`` per line."""
return "\n".join(f"{pkg}={name}" for pkg, name in app_map.items())
def _app_map_from_text(text: str) -> dict[str, str] | None:
"""Parse one ``package=name`` per line; None on malformed input."""
app_map: dict[str, str] = {}
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if "=" not in line:
return None
package, name = line.split("=", 1)
package = package.strip()
name = name.strip()
if not package or not name or " " in package:
return None
app_map[package] = name
return app_map
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
"""User-step schema — the androidtv entity is optional (standalone).
``defaults`` pre-fills name/hosts from a zeroconf discovery event.
"""
defaults = defaults or {}
schema_dict = {
vol.Required("name", default=defaults.get("name", 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
if "host" in defaults:
schema_dict[vol.Optional(CONF_ADB_HOST, default=defaults["host"])] = str
schema_dict[vol.Optional(CONF_PROTOCOL_HOST, default=defaults["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
# Zeroconf discovery state (P4-1)
self._discovered_host: str | None = None
self._discovered_name: str | None = None
async def async_step_user(self, user_input=None):
"""Step 1: Basic setup.
Standalone setup (Phase 3): any one of the three state sources is
enough — the Android TV Remote entity, the Nvidia Remote Protocol
(pairing), or ADB media-session mode (CONF_ADB_ENABLED). The
``no_state_source`` error fires only when none of them is present.
"""
errors = {}
if user_input is not None:
androidtv_entity = user_input.get(CONF_ANDROIDTV_ENTITY)
has_source = (
bool(androidtv_entity)
or bool(user_input.get(CONF_PROTOCOL_ENABLED))
or bool(user_input.get(CONF_ADB_ENABLED))
)
if not has_source:
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()
defaults = {}
if self._discovered_host:
defaults = {
"name": self._discovered_name or DEFAULT_NAME,
"host": self._discovered_host,
}
schema_dict = _user_schema_dict(sel, defaults)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(schema_dict),
errors=errors,
)
async def async_step_zeroconf(self, discovery_info):
"""Zeroconf discovery of the Android TV remote service (P4-1).
Core androidtv_remote pattern, simplified: the discovery address
becomes the pre-filled protocol/ADB host on the user step; a known
MAC (from the service properties) aborts duplicate setup and
updates the existing entry's hosts on an IP change.
"""
host = str(
getattr(discovery_info, "ip_address", None)
or getattr(discovery_info, "host", None)
or ""
).strip()
if not host:
return self.async_abort(reason="zeroconf_no_host")
self._discovered_host = host
props = getattr(discovery_info, "properties", None) or {}
self._discovered_name = props.get("name")
mac = props.get("mac") or props.get("unique_id")
if mac:
await self.async_set_unique_id(mac)
for entry in self._async_current_entries():
if getattr(entry, "unique_id", None) != mac:
continue
# Known device — update hosts only on a real IP change
# (core androidtv_remote pattern, simplified): an explicitly
# configured host that moved to the discovery address.
protocol_host = entry.options.get(CONF_PROTOCOL_HOST, "")
adb_host = entry.options.get(CONF_ADB_HOST, "")
if (protocol_host and protocol_host != host) or (
adb_host and adb_host != host
):
self.hass.config_entries.async_update_entry(
entry,
options={
**entry.options,
CONF_PROTOCOL_HOST: host,
CONF_ADB_HOST: host,
},
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="already_configured")
self._abort_if_unique_id_configured()
# First-time setup: pre-filled user step
return await self.async_step_user()
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."""
errors = {}
if user_input is not None:
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
if app_map_text is None:
app_map = self._options.get(CONF_APP_NAME_MAP, {})
elif app_map_text.strip() == "":
app_map = {}
else:
app_map = _app_map_from_text(app_map_text)
if app_map is None:
errors["base"] = "invalid_app_map"
if not errors:
self._options.update(user_input)
self._options[CONF_APP_NAME_MAP] = app_map
return self.async_create_entry(title="", data=self._options)
sel = _build_selectors()
schema_dict = {}
# Apps name/icon map editor (P4-1, core androidtv_remote options
# pattern, simplified): one package=name per line.
current_map = self._options.get(CONF_APP_NAME_MAP, {})
if current_map:
app_map_default = _app_map_to_text(current_map)
schema_dict[vol.Optional(CONF_APP_NAME_MAP, default=app_map_default)] = str
else:
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
# 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"]
# P4-2a: the raw adb_command service is opt-in (default off)
current_flag = self._options.get(CONF_ADB_COMMAND_ENABLED, False)
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED, default=current_flag)] = bool
# 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),
errors=errors,
)