Files
ha-nvidia-shield/custom_components/nvidia_shield/config_flow.py
T
James Mackay 75ebeb40b0 refactor(config): one Shield IP field for both transports (t41)
The two address fields were the same Shield spelled twice, and every bug in the
zeroconf/IP-change path came from keeping them in sync. One field now:

* const.CONF_HOST replaces CONF_ADB_HOST / CONF_PROTOCOL_HOST in the setup form,
  the options page, the pairing forms, the reconfigure/reauth seeding and the
  description copy. ADB port 5555 stays the only port field;
* helpers.entry_host(entry) is the single resolver (options first, then data);
  resolve_adb_config and the protocol settings both go through it, so both
  transports read the same value at call time (the t32 F1 factory re-read keeps
  working: the NEXT built poller sees the new address);
* helpers.seed_host / seed_adb_enabled own the YAML seed's shape rule. A block
  may use `host` or the legacy protocol_host/adb_host keys — t41 keeps the old
  spelling working — and t32 F4's outcome is unchanged: a protocol-only block
  leaves ADB off, an address (or an explicit adb_enabled) turns it on;
* the zeroconf IP-change path collapses the t21-F2 per-host dance into one rule:
  the discovery address IS the entry's address, so the entry is updated when it
  differs and left alone when it matches. The blank-self-heal and
  deliberately-different-ADB-host cases are gone with the duplication — a
  second address field cannot express them anymore, and the tests now pin that
  (previously "an explicit override is never clobbered");
* the options page exposes the protocol toggle it never had (the page could turn
  ADB off but not the protocol);
* button.py (granted to this task) reads the entry's own address instead of
  another integration's entity.

The v3 entry migration that maps existing adb_host/protocol_host onto CONF_HOST
and drops the androidtv key lands in the next commit; HA runs migrations before
setup, so an existing entry is never read with the old shape.

Verified: 311 passed / 2 skipped, 312 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD. The UI-copy contract tests (every rendered field
has a label in strings.json AND translations/en.json, key trees identical, host
errors name the field) drove the string edits.
2026-10-11 06:18:22 +01:00

933 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_APP_ICON_MAP,
CONF_APP_NAME_MAP,
CONF_CHANNEL_DIGIT_DELAY,
CONF_CHANNEL_SEND_ENTER,
CONF_HOST,
CONF_JELLYFIN_ENTITY,
CONF_LOGCAT_EVENTS,
CONF_PAIRING_CODE,
CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
CONF_SKIP_PAIRING,
DEFAULT_ADB_PORT,
DEFAULT_NAME,
DOMAIN,
)
from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host
_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, 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 — every field belongs to THIS integration (t41).
``defaults`` pre-fills the name and the ONE address (t41) 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_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_HOST, host)] = str
schema_dict[vol.Optional(CONF_ADB_PORT, default=port)] = int
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
return schema_dict
def _build_selectors():
"""Build reusable selector instances."""
return {
"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.
We own the whole stack (t41), so the two state sources are ours: the
Nvidia Remote Protocol (pairing) and ADB media-session mode
(CONF_ADB_ENABLED). The ``no_state_source`` error fires only when
neither is enabled.
"""
errors = {}
if user_input is not None:
has_source = 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)
# t41: nothing here derives a unique_id from another integration.
# The entry's identity is the protocol MAC learned during pairing
# (`async_set_unique_id(mac)` in the pairing step) or the config
# entry itself — never a renameable entity id (t29 G2's
# atv-device derivation went with the entity path).
# Auto-detect Jellyfin from the names setup already knows: the
# entry name the user typed, or the device name from discovery.
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
if not jellyfin:
for name in (user_input.get("name", DEFAULT_NAME), self._discovered_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_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_HOST: str(user_input.get(CONF_HOST) or "").strip(),
CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, False),
}
# 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 — IP change (t21 F2, collapsed by t41): there is
# ONE address field now, so the discovery address simply IS the
# entry's address. The per-host dance (blank self-heal / stale
# protocol host / deliberately different ADB host) only existed
# because the address was stored twice; it went with the
# duplication.
options = dict(entry.options)
if entry_host(entry) != host:
options[CONF_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.
Everything the integration needs lives in the OPTIONS (t41: one host
field among them); ``data`` stays empty because no value has to be
immutable.
"""
d = self._user_data
return self.async_create_entry(
title=d["name"],
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_HOST: d.get(CONF_HOST, ""),
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, False),
},
)
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 _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._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._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_HOST, self._host()): 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_HOST) or "").strip()
if typed_host and typed_host != self._host():
# 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_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
# the block's address comes from `host`, or from the legacy keys
block_host = seed_host(block)
seed_unique_id = block.get("unique_id")
if not seed_unique_id:
seed_unique_id = f"yaml:{name}:{block_host or 'nohost'}"
# duplicate guards
for existing in self._async_current_entries():
ehost = entry_host(existing)
if existing.unique_id == seed_unique_id or (
block_host and ehost == block_host
):
_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,
# t32 F4's shape rule on the t41 keys: an address names the Shield,
# and an explicit adb_enabled says otherwise
CONF_ADB_ENABLED: seed_adb_enabled(block),
CONF_HOST: block_host,
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
}
# 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 = block_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_HOST: entry_host(entry),
}
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_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False),
CONF_HOST: entry_host(entry),
CONF_ADB_PORT: entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
CONF_PROTOCOL_ENABLED: True,
}
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_HOST: self._host(),
},
}
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_HOST: self._host(),
},
)
self._reauth_entry = None
return self.async_abort(reason="reauth_successful")
def _host(self) -> str:
"""The Shield's address as this flow knows it (t41: ONE field).
What the user typed (or a seed carried) lives in ``_user_data``; the
discovery/seed hints stay as fallbacks for a form that was rendered with
a pre-filled field and submitted unchanged.
"""
return str(
self._user_data.get(CONF_HOST)
or self._discovered_host
or self._seed_host
or ""
).strip()
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_HOST, self._host()): 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_HOST) or "").strip()
if typed_host:
# a host typed HERE is authoritative (the user step is gone)
self._user_data[CONF_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._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"]
# Transport toggles (t41: both transports are ours; the skip paths and
# this page are the way to turn either one off)
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
# The ONE address field (t41), plus the ADB port
schema_dict[vol.Optional(CONF_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,
)