`ConfigFlow._async_handle_discovery_without_unique_id` is an `async def` in core (verified: `inspect.iscoroutinefunction` is True) and core awaits it. t32 called it without `await`, so the coroutine body never ran: no `already_configured` abort when the handler already had entries, no `DEFAULT_DISCOVERY_UNIQUE_ID` claim, no `already_in_progress` abort — HA's discovery dedup was dead, Python warned "coroutine ... was never awaited", and the host-equality loop that used to sit here was the only accidental dedup. The call is awaited and the now-unreachable host loop is gone (the real helper is stricter: any entry for the domain, or any in-flight flow). The harness mirrors the real contract instead of a sync no-op: * `entity_stublib`'s hub stub makes the helper a coroutine (a sync stub would turn the production `await` into a TypeError); * `test_standalone_setup`'s stub models the real semantics — a coroutine raising `DiscoveryDedupAbortError` (core raises AbortFlow), driven by the shared holder's entry list and in-progress list — and its tests now assert all three outcomes: awaited-and-proceeds (counter + claimed discovery unique id), `already_configured` when entries exist, `already_in_progress` when another flow runs. A fourth test collects the un-awaited-coroutine warning, and the prefill test in the UX suite asserts the awaited counter. Red proof: with the pre-fix config_flow.py the repo-owned sweep reports `config_flow.py:425: missing-await: self._async_handle_discovery_without_unique_id()`. Verified: 289 passed, 2 skipped (D3 stage); ruff clean.
994 lines
41 KiB
Python
994 lines
41 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 device_registry as dr
|
||
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_ICON_MAP,
|
||
CONF_APP_NAME_MAP,
|
||
CONF_CHANNEL_DIGIT_DELAY,
|
||
CONF_CHANNEL_SEND_ENTER,
|
||
CONF_JELLYFIN_ENTITY,
|
||
CONF_LOGCAT_EVENTS,
|
||
CONF_PAIRING_CODE,
|
||
CONF_POWER_ENTITY,
|
||
CONF_PROTOCOL_ENABLED,
|
||
CONF_PROTOCOL_HOST,
|
||
CONF_RECEIVER_ENTITY,
|
||
CONF_RECEIVER_INPUT,
|
||
CONF_SKIP_PAIRING,
|
||
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 _unique_id_from_atv_device(hass, androidtv_entity: str) -> str | None:
|
||
"""A stable unique_id from the atv entity's device MAC (t29 G2).
|
||
|
||
The device registry holds CONNECTION_NETWORK_MAC for Android TV
|
||
entities configured with a host — far stronger than the renameable
|
||
entity_id. None (→ no unique_id at all, not an entity-derived
|
||
placeholder) when the entity is unregistered or carries no MAC.
|
||
"""
|
||
if not androidtv_entity:
|
||
return None
|
||
registry = er.async_get(hass)
|
||
entity = registry.entities.get(androidtv_entity)
|
||
device_id = getattr(entity, "device_id", None) if entity else None
|
||
device = (
|
||
dr.async_get(hass).async_get(device_id) if device_id else None
|
||
)
|
||
if device is None:
|
||
return None
|
||
if isinstance(dr, dict): # stub shape guard
|
||
return None
|
||
for connection in device.connections:
|
||
# connections are {connection_type, value} pairs; only a REAL mac
|
||
# value is usable (an empty placeholder is skipped)
|
||
if connection[0] == "mac" and connection[1]:
|
||
return connection[1]
|
||
return None
|
||
|
||
|
||
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, icon_map: dict | None = None) -> str:
|
||
"""Serialize the apps map to one ``package=name[|icon]`` per line."""
|
||
icon_map = icon_map or {}
|
||
lines = []
|
||
for pkg, name in app_map.items():
|
||
icon = icon_map.get(pkg)
|
||
lines.append(f"{pkg}={name}|{icon}" if icon else f"{pkg}={name}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _app_map_from_text(
|
||
text: str, icons: dict[str, str] | None = None
|
||
) -> dict[str, str] | None:
|
||
"""Parse one ``package=name[|icon]`` per line; None on malformed input.
|
||
|
||
The optional second column (a picture URL or entity id) feeds
|
||
entity_picture through CONF_APP_ICON_MAP; single-column maps stay
|
||
valid. Malformed icon text (spaces) invalidates the row the same way
|
||
a malformed name does.
|
||
"""
|
||
icons = icons if icons is not None else {}
|
||
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, value = line.split("=", 1)
|
||
parts = value.strip().split("|", 1)
|
||
name = parts[0].strip()
|
||
icon = parts[1].strip() if len(parts) > 1 else ""
|
||
package = package.strip()
|
||
name = name.strip()
|
||
if not package or not name or " " in package:
|
||
return None
|
||
if icon:
|
||
if " " in icon:
|
||
return None
|
||
icons[package] = icon
|
||
app_map[package] = name
|
||
return app_map
|
||
|
||
|
||
def _host_field_key(field: str, host: str):
|
||
"""The host-field KEY, carrying the known address as its default.
|
||
|
||
t33 F-A: the default MUST ride on this key. Re-declaring a bare
|
||
``vol.Optional(field)`` afterwards is a no-op that silently drops it —
|
||
voluptuous keys compare by field name, so the first (defaultless) key
|
||
object survives and the form renders blank.
|
||
"""
|
||
if host:
|
||
return vol.Optional(field, default=host)
|
||
return vol.Optional(field)
|
||
|
||
|
||
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 (or a
|
||
YAML seed hint); the ADB port is pre-filled with the standard 5555 so it
|
||
reads as an advanced setting rather than required knowledge (t33 F-D).
|
||
"""
|
||
defaults = defaults or {}
|
||
host = str(defaults.get("host") or "")
|
||
port = defaults.get("port")
|
||
if not isinstance(port, int):
|
||
port = DEFAULT_ADB_PORT
|
||
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)] = bool
|
||
# the code's own fallback for this flag is True (async_step_user) — the
|
||
# rendered checkbox must agree with it
|
||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
||
schema_dict[_host_field_key(CONF_ADB_HOST, host)] = str
|
||
schema_dict[vol.Optional(CONF_ADB_PORT, default=port)] = int
|
||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
|
||
schema_dict[_host_field_key(CONF_PROTOCOL_HOST, 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
|
||
# audit G5 / t35 F1: the entry a "Reconfigure" visit targets
|
||
self._reconfigure_entry = None
|
||
# Zeroconf discovery state (P4-1)
|
||
self._discovered_host: str | None = None
|
||
self._discovered_name: str | None = None
|
||
# YAML-seed prefill hint (t33 F-A): feeds the same user-step prefill as
|
||
# a discovery, without claiming the address was discovered.
|
||
self._seed_host: 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"
|
||
return self._show_user_form(errors)
|
||
|
||
# t29 G2: the androidtv entity_id is a renameable reference —
|
||
# never a unique_id. A sqlite/registry MAC is preferred (the
|
||
# atv entity's device holds CONNECTION_NETWORK_MAC); without a
|
||
# resolvable MAC the entry gets NO unique_id (the same
|
||
# uniqueness-less semantics _async_handle_discovery_without_
|
||
# unique_id applies to MAC-less discovery) — renames can never
|
||
# break uniqueness.
|
||
fallback_unique_id = _unique_id_from_atv_device(
|
||
self.hass, androidtv_entity
|
||
)
|
||
if fallback_unique_id:
|
||
await self.async_set_unique_id(fallback_unique_id)
|
||
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()
|
||
|
||
return self._show_user_form(errors)
|
||
|
||
def _user_defaults(self) -> dict:
|
||
"""Defaults for the user step: whatever setup already knows.
|
||
|
||
A zeroconf discovery (or a YAML seed hint) supplies the address; the
|
||
port keeps a seed's hint instead of being flattened to the standard
|
||
value. This is the ONLY place the known address enters the form — the
|
||
schema keys carry these values as their defaults (t33 F-A).
|
||
"""
|
||
defaults = {
|
||
"name": self._discovered_name
|
||
or self._user_data.get("name")
|
||
or DEFAULT_NAME,
|
||
}
|
||
host = self._discovered_host or self._seed_host
|
||
if host:
|
||
defaults["host"] = host
|
||
port = self._user_data.get(CONF_ADB_PORT)
|
||
if isinstance(port, int):
|
||
defaults["port"] = port
|
||
return defaults
|
||
|
||
def _show_user_form(self, errors=None):
|
||
"""Render the user step with the known address actually applied.
|
||
|
||
t33 F-A2: a discovery-driven form says so in its description, so a
|
||
pre-filled host can never be misread as "unknown".
|
||
"""
|
||
note = ""
|
||
if self._discovered_host:
|
||
note = (
|
||
f"Discovered Shield at {self._discovered_host} — its address "
|
||
"is already filled in below. "
|
||
)
|
||
return self.async_show_form(
|
||
step_id="user",
|
||
data_schema=vol.Schema(
|
||
_user_schema_dict(_build_selectors(), self._user_defaults())
|
||
),
|
||
errors=errors or {},
|
||
description_placeholders={"discovery_note": note},
|
||
)
|
||
|
||
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 — per-host updates (t21 F2): each host is
|
||
# tracked independently against the discovery address.
|
||
# - a blank (auto-detect) host self-heals from the address;
|
||
# - a stale protocol host is the IP change and is updated;
|
||
# - a deliberately-different ADB host (an explicit override
|
||
# that differs from both the address and the stored
|
||
# protocol host) is never clobbered.
|
||
protocol_host = entry.options.get(CONF_PROTOCOL_HOST, "")
|
||
adb_host = entry.options.get(CONF_ADB_HOST, "")
|
||
options = dict(entry.options)
|
||
if protocol_host != host:
|
||
options[CONF_PROTOCOL_HOST] = host
|
||
if not adb_host or adb_host == protocol_host:
|
||
# blank (self-heal) or auto-synced alongside the protocol
|
||
# host — safe to follow the device address on discovery
|
||
options[CONF_ADB_HOST] = host
|
||
if options != entry.options:
|
||
# t29 G3a: the deprecated update+schedule-reload combo is
|
||
# gone (2026.12 error) — the entry updates ONLY here; the
|
||
# self-heal rides on the t32 F1 poller factory, which
|
||
# re-reads entry.options at call time (no reload).
|
||
self.hass.config_entries.async_update_entry(
|
||
entry, options=options
|
||
)
|
||
return self.async_abort(reason="already_configured")
|
||
self._abort_if_unique_id_configured()
|
||
else:
|
||
# t32 F2 / t37 D3: MAC-less discovery cannot carry a unique_id, so
|
||
# HA's own helper owns the dedup — and it MUST be awaited. It is a
|
||
# coroutine that aborts `already_configured` when the handler
|
||
# already has entries, claims DEFAULT_DISCOVERY_UNIQUE_ID, and
|
||
# aborts `already_in_progress` when another flow for this handler
|
||
# is running. t32 shipped the call without the await, so none of
|
||
# that ran ("coroutine was never awaited") and the host-equality
|
||
# guard that used to sit here was the accidental dedup; the real
|
||
# helper is stricter (any entry for the domain, any in-flight
|
||
# flow), so nothing is left to do here.
|
||
await self._async_handle_discovery_without_unique_id()
|
||
|
||
# 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()
|
||
|
||
def _discard_protocol_client(self) -> None:
|
||
"""Drop a pairing client whose session became void (host corrected)."""
|
||
client = self._protocol_client
|
||
self._protocol_client = None
|
||
if client is not None:
|
||
with contextlib.suppress(Exception):
|
||
client.disconnect()
|
||
|
||
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. Returns
|
||
None when no host is resolvable or ``androidtvremote2`` is missing —
|
||
the caller turns that into an error key, never a crash.
|
||
"""
|
||
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,
|
||
)
|
||
if client is None:
|
||
# build_protocol_client already logged the missing library;
|
||
# pairing cannot start, and the form still offers the skip path
|
||
return None
|
||
await client.async_generate_cert_if_missing()
|
||
await client.async_start_pairing()
|
||
return client
|
||
|
||
async def _async_begin_protocol_pairing(self):
|
||
"""Start a PIN pairing session: ``(client, None)`` or ``(None, error)``."""
|
||
if not self._protocol_host():
|
||
return None, "protocol_no_host"
|
||
client = await self._start_protocol_pairing()
|
||
if client is None:
|
||
return None, "protocol_unavailable"
|
||
return client, None
|
||
|
||
def _show_protocol_pair_form(self, errors=None):
|
||
"""protocol_pair form: the PIN, the address, and the skip escape.
|
||
|
||
t33 F-B/F-C: the host field lives on THIS form, so an unresolvable
|
||
address is fixable where the error is shown (the user step is gone),
|
||
and the skip option keeps the step from being a dead end.
|
||
"""
|
||
return self.async_show_form(
|
||
step_id="protocol_pair",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required(CONF_PAIRING_CODE): str,
|
||
_host_field_key(CONF_PROTOCOL_HOST, self._protocol_host() or ""): str,
|
||
vol.Optional(CONF_SKIP_PAIRING, default=False): bool,
|
||
}
|
||
),
|
||
errors=errors or {},
|
||
)
|
||
|
||
async def _async_skip_protocol_pairing(self):
|
||
"""Continue without the protocol transport (t33 F-B, passive mode).
|
||
|
||
The transport is switched OFF rather than left half-paired: an
|
||
unpaired protocol client fails auth on its first connect and would
|
||
only bounce the user into the reauth flow — a fresh dead end.
|
||
"""
|
||
self._discard_protocol_client()
|
||
self._user_data[CONF_PROTOCOL_ENABLED] = False
|
||
if self._reauth_entry is not None:
|
||
# an existing entry cannot be re-created: leave it untouched and
|
||
# say so instead of pretending the re-pair worked
|
||
self._reauth_entry = None
|
||
return self.async_abort(reason="reauth_skipped")
|
||
if self._reconfigure_entry is not None:
|
||
# same for a Reconfigure visit: nothing changed, the entry keeps
|
||
# its options and can be reconfigured again at any time
|
||
self._reconfigure_entry = None
|
||
return self.async_abort(reason="reconfigure_skipped")
|
||
return self._create_entry()
|
||
|
||
async def _async_restart_protocol_pairing(self, errors: dict):
|
||
"""Start a fresh pairing session and render the form for its code.
|
||
|
||
Called on the first visit, after a host correction, and when the user
|
||
submits without a code: each is a request for the Shield to show a new
|
||
code, and none of them may leave the step stuck.
|
||
"""
|
||
self._discard_protocol_client()
|
||
client, error = await self._async_begin_protocol_pairing()
|
||
if error:
|
||
errors["base"] = error
|
||
else:
|
||
self._protocol_client = client
|
||
return self._show_protocol_pair_form(errors)
|
||
|
||
async def async_step_protocol_pair(self, user_input=None):
|
||
"""Pair with the remote protocol — the PIN is displayed on the Shield.
|
||
|
||
Never a dead end (t33 F-B): a corrected address restarts pairing right
|
||
here, submitting without a code asks for a fresh one, and
|
||
``skip_pairing`` finishes the entry in passive mode.
|
||
"""
|
||
errors = {}
|
||
|
||
if user_input is None:
|
||
# First visit: proactively start pairing so the PIN shows on the
|
||
# Shield while the form is open.
|
||
return await self._async_restart_protocol_pairing(errors)
|
||
|
||
if user_input.get(CONF_SKIP_PAIRING):
|
||
return await self._async_skip_protocol_pairing()
|
||
|
||
typed_host = str(user_input.get(CONF_PROTOCOL_HOST) or "").strip()
|
||
if typed_host and typed_host != (self._protocol_host() or ""):
|
||
# A host typed HERE is authoritative — the user step is gone, so
|
||
# the session started against the old address is dropped and the
|
||
# Shield is asked for a NEW code at the corrected address (t35 F1).
|
||
self._user_data[CONF_PROTOCOL_HOST] = typed_host
|
||
self._discard_protocol_client()
|
||
|
||
code = str(user_input.get(CONF_PAIRING_CODE, "") or "").strip()
|
||
if self._protocol_client is None or not code:
|
||
# No live session, or no code typed: (re)start pairing so the
|
||
# Shield shows a code for the address currently on the form.
|
||
return await self._async_restart_protocol_pairing(errors)
|
||
|
||
client = self._protocol_client
|
||
try:
|
||
await client.async_finish_pairing(code)
|
||
except Exception:
|
||
errors["base"] = "protocol_pair_failed"
|
||
return self._show_protocol_pair_form(errors)
|
||
|
||
try:
|
||
_name, mac = await client.async_get_name_and_mac()
|
||
except Exception:
|
||
mac = None
|
||
with contextlib.suppress(Exception):
|
||
client.disconnect()
|
||
self._protocol_client = None
|
||
target = self._reauth_entry or self._reconfigure_entry
|
||
if mac and mac != getattr(target, "unique_id", None):
|
||
# A MAC that is already configured must never be adopted silently.
|
||
# A reauth/reconfigure flow already OWNS an entry, and
|
||
# _abort_if_unique_id_configured looks the domain up by unique_id —
|
||
# it would abort on that very entry when the MAC is unchanged
|
||
# (t35 F1: re-pairing an entry used to abort as "already_configured").
|
||
await self.async_set_unique_id(mac)
|
||
self._abort_if_unique_id_configured()
|
||
if self._reauth_entry is not None:
|
||
return self._finish_reauth()
|
||
if self._reconfigure_entry is not None:
|
||
return self._finish_reconfigure(mac)
|
||
return self._create_entry()
|
||
|
||
async def async_step_import(self, data):
|
||
"""Seed an interactive flow from YAML (t30 U1, seed-only ruling).
|
||
|
||
The import validates the block and routes into the standard user
|
||
flow — pairing stays interactive, exactly one visit. Duplicate
|
||
guards: the block's host/MAC-virtual unique_id aborts when an
|
||
entry already holds it, and a UI-configured entry with the same
|
||
protocol/adb host is never re-seeded.
|
||
"""
|
||
block = dict(data or {})
|
||
name = block.get("name") or DEFAULT_NAME
|
||
seed_unique_id = block.get("unique_id")
|
||
if not seed_unique_id:
|
||
host = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
|
||
seed_unique_id = f"yaml:{name}:{host or 'nohost'}"
|
||
|
||
# duplicate guards
|
||
yhost = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
|
||
for existing in self._async_current_entries():
|
||
ehost = existing.options.get(CONF_PROTOCOL_HOST) or existing.options.get(
|
||
CONF_ADB_HOST
|
||
)
|
||
if existing.unique_id == seed_unique_id or (
|
||
yhost and ehost == yhost
|
||
):
|
||
_LOGGER.info(
|
||
"nvidia_shield YAML seed for %r matches an existing "
|
||
"entry — not re-seeded",
|
||
name,
|
||
)
|
||
return self.async_abort(reason="already_configured")
|
||
await self.async_set_unique_id(seed_unique_id)
|
||
self._abort_if_unique_id_configured()
|
||
|
||
# route into the standard user flow (single interactive visit);
|
||
# t32 F4: the prefill honors the block shape — ADB only when the
|
||
# block actually carries an adb host.
|
||
self._user_data = {
|
||
"name": name,
|
||
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
|
||
CONF_ADB_HOST: block.get(CONF_ADB_HOST, ""),
|
||
CONF_ADB_PORT: block.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||
# protocol prefill hints ONLY (pairing stays interactive)
|
||
CONF_PROTOCOL_HOST: block.get(CONF_PROTOCOL_HOST, ""),
|
||
}
|
||
# t33 F-A: the seed host feeds the same user-step prefill as a
|
||
# zeroconf discovery, but WITHOUT the discovery announcement — the
|
||
# address came from configuration.yaml, not from the network.
|
||
self._seed_host = self._user_data[CONF_PROTOCOL_HOST] or self._user_data[
|
||
CONF_ADB_HOST
|
||
]
|
||
return await self.async_step_user(None)
|
||
|
||
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({})
|
||
)
|
||
|
||
async def async_step_reconfigure(self, user_input=None):
|
||
"""Re-run protocol pairing for an existing entry (audit G5, t35 F1).
|
||
|
||
Home Assistant offers this as the entry's "Reconfigure" action — the
|
||
discoverable way back for an entry whose protocol transport is off
|
||
(typically the "continue without protocol pairing" outcome): the
|
||
address is editable, the Shield shows a PIN, and the entry's options
|
||
are updated + reloaded.
|
||
"""
|
||
entry = self._reconfigure_entry or self._get_reconfigure_entry()
|
||
if entry is None:
|
||
# HA always links the entry; a missing one gets a plain abort
|
||
# instead of an exception
|
||
return self.async_abort(reason="reconfigure_entry_missing")
|
||
self._reconfigure_entry = entry
|
||
# Seed the flow from the entry so the pairing step offers the address
|
||
# the entry already knows (and _create_entry is never reached here).
|
||
self._user_data = {
|
||
"name": entry.title,
|
||
CONF_ANDROIDTV_ENTITY: entry.data.get(CONF_ANDROIDTV_ENTITY, ""),
|
||
CONF_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False),
|
||
CONF_ADB_HOST: entry.options.get(CONF_ADB_HOST, ""),
|
||
CONF_ADB_PORT: entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||
CONF_PROTOCOL_ENABLED: True,
|
||
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""),
|
||
}
|
||
return await self.async_step_protocol_pair()
|
||
|
||
def _finish_reconfigure(self, mac: str | None):
|
||
"""Reconfigure pairing done: update + reload + abort (G3a contract).
|
||
|
||
The protocol host persists as the address that just paired, so the
|
||
entry no longer depends on the ADB-host fallback for its transport.
|
||
HA derives the abort reason ("reconfigure_successful") from the flow
|
||
source, so this needs no integration string.
|
||
"""
|
||
entry = self._reconfigure_entry or self._get_reconfigure_entry()
|
||
updates: dict = {
|
||
"options": {
|
||
**entry.options,
|
||
CONF_PROTOCOL_ENABLED: True,
|
||
CONF_PROTOCOL_HOST: self._protocol_host() or "",
|
||
},
|
||
}
|
||
if mac:
|
||
# a swapped device keeps the entry's identity honest
|
||
updates["unique_id"] = mac
|
||
self._reconfigure_entry = None
|
||
return self.async_update_reload_and_abort(entry, **updates)
|
||
|
||
def _finish_reauth(self):
|
||
"""Reauth pairing done: update + reload + abort via the current API (G3a).
|
||
|
||
The deprecated update-entry + schedule-reload boomerang listener is
|
||
gone (2026.12 treats it as an error); the current config-entries
|
||
contract is async_update_reload_and_abort with _get_reauth_entry.
|
||
Core's kwarg is `options` (REPLACE) — there is no `options_updates`.
|
||
"""
|
||
entry = self._reauth_entry or self._get_reauth_entry()
|
||
self.async_update_reload_and_abort(
|
||
entry,
|
||
options={
|
||
**entry.options,
|
||
CONF_PROTOCOL_ENABLED: True,
|
||
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
|
||
},
|
||
)
|
||
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)
|
||
)
|
||
|
||
def _show_adb_pair_form(self, errors=None):
|
||
"""adb_pair form: the address/port (fixable here) and the skip escape.
|
||
|
||
t33 F-B/F-C/F-D: the fields live on THIS form — the user step is gone
|
||
— so a missing address is fixable where the error appears, the
|
||
standard 5555 is pre-filled, and the skip option ends the dead end.
|
||
"""
|
||
return self.async_show_form(
|
||
step_id="adb_pair",
|
||
data_schema=vol.Schema(
|
||
{
|
||
_host_field_key(CONF_ADB_HOST, self._resolve_adb_host() or ""): str,
|
||
vol.Optional(
|
||
CONF_ADB_PORT,
|
||
default=self._user_data.get(
|
||
CONF_ADB_PORT, DEFAULT_ADB_PORT
|
||
),
|
||
): int,
|
||
vol.Optional(CONF_SKIP_PAIRING, default=False): bool,
|
||
}
|
||
),
|
||
errors=errors or {},
|
||
)
|
||
|
||
async def async_step_adb_pair(self, user_input=None):
|
||
"""Step 2: ADB pairing. Approve the prompt on the Shield, then Submit.
|
||
|
||
Never a dead end (t33 F-B): the host/port are editable on the form, a
|
||
missing address is reported against the field above it, and the skip
|
||
option finishes the entry in passive mode.
|
||
"""
|
||
errors = {}
|
||
|
||
if user_input is not None:
|
||
if user_input.get(CONF_SKIP_PAIRING):
|
||
# Continue WITHOUT pairing. ADB stays enabled on purpose: the
|
||
# manager already degrades to its passive, background-retrying
|
||
# state when no key exists, and the Pair ADB button (only
|
||
# registered for adb-enabled entries) pairs later without
|
||
# re-adding the integration.
|
||
return await self._async_after_pairing()
|
||
|
||
typed_host = str(user_input.get(CONF_ADB_HOST) or "").strip()
|
||
if typed_host:
|
||
# a host typed HERE is authoritative (the user step is gone)
|
||
self._user_data[CONF_ADB_HOST] = typed_host
|
||
|
||
port = user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||
try:
|
||
port = int(port)
|
||
except (TypeError, ValueError):
|
||
port = DEFAULT_ADB_PORT
|
||
self._user_data[CONF_ADB_PORT] = port
|
||
|
||
host = self._resolve_adb_host()
|
||
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._show_adb_pair_form(errors)
|
||
|
||
@staticmethod
|
||
@callback
|
||
def async_get_options_flow(config_entry):
|
||
return NvidiaShieldOptionsFlow()
|
||
|
||
|
||
class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||
"""Handle options for Nvidia Shield — single unified page (t29 G3b).
|
||
|
||
The constructor takes NO arguments: HA assigns ``self.config_entry``
|
||
on instantiation (the pre-2026 "OptionsFlow(config_entry)" pattern is
|
||
deprecated). Suggested values come through
|
||
``add_suggested_values_to_schema``, and saving reloads via the
|
||
OptionsFlowWithReload base — no listener boomerangs.
|
||
"""
|
||
|
||
def __init__(self):
|
||
self._options: dict | None = None
|
||
|
||
@property
|
||
def _options_dict(self) -> dict:
|
||
if self._options is None:
|
||
if self.config_entry is not None:
|
||
self._options = dict(self.config_entry.options)
|
||
else:
|
||
self._options = {}
|
||
return self._options
|
||
|
||
async def async_step_init(self, user_input=None):
|
||
"""Single-page options."""
|
||
errors = {}
|
||
if user_input is not None:
|
||
# P5-2: validate the digit delay (0.05–1.0 s, §7 window)
|
||
delay = user_input.pop(CONF_CHANNEL_DIGIT_DELAY, None)
|
||
if delay is not None:
|
||
try:
|
||
delay = float(delay)
|
||
except (TypeError, ValueError):
|
||
errors["base"] = "invalid_digit_delay"
|
||
delay = None
|
||
if delay is not None and not (0.05 <= delay <= 1.0):
|
||
errors["base"] = "invalid_digit_delay"
|
||
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
||
icons: dict[str, str] = {}
|
||
if app_map_text is None:
|
||
app_map = self._options_dict.get(CONF_APP_NAME_MAP, {})
|
||
elif app_map_text.strip() == "":
|
||
app_map = {}
|
||
else:
|
||
app_map = _app_map_from_text(app_map_text, icons)
|
||
if app_map is None:
|
||
errors["base"] = "invalid_app_map"
|
||
if not errors:
|
||
if delay is not None:
|
||
self._options_dict[CONF_CHANNEL_DIGIT_DELAY] = delay
|
||
self._options_dict.update(user_input)
|
||
self._options_dict[CONF_APP_NAME_MAP] = app_map
|
||
# P5-2: the icon column — the editor is the source of
|
||
# truth, so a save without icons clears them (and a
|
||
# single-column map stays valid).
|
||
self._options_dict[CONF_APP_ICON_MAP] = icons
|
||
return self.async_create_entry(title="", data=self._options_dict)
|
||
|
||
sel = _build_selectors()
|
||
schema_dict = {}
|
||
|
||
# Apps name/icon map editor (P4-1, core androidtv_remote options
|
||
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
|
||
# the optional icon column).
|
||
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
|
||
|
||
# Jellyfin
|
||
jf = self._options_dict.get(CONF_JELLYFIN_ENTITY)
|
||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||
if jf:
|
||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||
schema_dict[jf_key] = sel["media_player"]
|
||
|
||
# Receiver
|
||
recv = self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||
if recv:
|
||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||
schema_dict[recv_key] = sel["receiver"]
|
||
|
||
# P5-2: channel digit behavior (the user-validated §7 flow)
|
||
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
|
||
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
|
||
|
||
# P5-3: the live events push is opt-in (default off; the user
|
||
# tests stability on their device and reports)
|
||
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS)] = bool
|
||
|
||
# P4-2a: the raw adb_command service is opt-in (default off)
|
||
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED)] = bool
|
||
|
||
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
||
current_input = self._options_dict.get(CONF_RECEIVER_INPUT, "")
|
||
receiver = self._options_dict.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)
|
||
schema_dict[input_key] = input_sel
|
||
else:
|
||
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
||
|
||
# Power
|
||
pwr = self._options_dict.get(CONF_POWER_ENTITY)
|
||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||
if pwr:
|
||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||
schema_dict[pwr_key] = sel["power"]
|
||
|
||
# ADB options
|
||
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
||
|
||
# ADB host/port (optional overrides, shown when ADB is enabled)
|
||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
||
|
||
# t29 G3b: suggested values come from the entry options via the
|
||
# documented helper — no per-key `default=` in the schema build.
|
||
return self.async_show_form(
|
||
step_id="init",
|
||
data_schema=self.add_suggested_values_to_schema(
|
||
vol.Schema(schema_dict), self._options_dict
|
||
),
|
||
errors=errors,
|
||
)
|