Files
ha-nvidia-shield/custom_components/nvidia_shield/config_flow.py
T
James Mackay 14c168c25e feat(remote): the app/activity picker, and a protocol transport that really connects (t15)
The user's remote displayed but offered no app picker, and the protocol
transport was never actually switched on. Both halves are fixed here.

REMOTE PARITY. The entity declares RemoteEntityFeature.ACTIVITY — exactly
what core's androidtv_remote declares — and publishes activity_list, which
is what makes the frontend render an activity picker at all. The list is
the app directory (the user's apps map, else the curated table as a floor)
in map order, then the apps ADB discovery found and something can name, in
the one name chain (user map > device label > curated > package id).
Nothing nameless is listed as a bare package id, and current_activity is
None until something is known: no activity is invented. remote.turn_on
honours activity= by waking the device when it reports itself off and then
launching through the existing launch path; a listed name resolves
case-insensitively, a package id or a detail link passes through, and
anything else raises unknown_activity naming the value instead of silently
doing nothing. An empty/absent activity keeps the previous behaviour
exactly, async_turn_off is untouched, and a configured command script still
owns the sequence.

WHY THE TRANSPORT WAS DEAD, MEASURED. androidtvremote2's keep_reconnecting
only starts _async_reconnect, whose whole body is
`while self._remote_message_protocol:` — false on the first check with
nothing connected, so the task returned immediately: no connect attempt, no
retry, and the auth callback was never reachable. HA core connects FIRST and
only then keeps it alive. Our _start_protocol called keep_reconnecting after
generating a certificate and never called async_connect anywhere, so every
send_key_command/send_launch_app_command raised ConnectionClosed internally,
the helpers swallowed it into `return False` plus a debug line, and no push
(is_available/is_on/current_app/volume_info) ever arrived.

The fix is a background connect loop: async_connect under a bounded timeout,
network failures retried with the ADB watchdog's jitter and backoff,
InvalidAuth routed into the EXISTING reauth callback (entry.async_start_reauth),
and keep_reconnecting only after a session is up. Setup never blocks on a
transport. A send that cannot run is no longer silent: it is counted, its
error recorded verbatim, and logged at WARNING with the transport state, and
the remote publishes protocol_state / protocol_connected /
protocol_ever_authenticated / protocol_last_error / protocol_failures /
protocol_certificate plus sticky per-push *_seen flags — so "sent" and
"never switched on" can never look alike again.

CASE (b) IS FIXED, NOT DOCUMENTED: switching the transport on in the options
flow for an entry that never paired now routes into the real
async_step_protocol_pair, and "continue without pairing" persists the
transport OFF. One marker carries the pairing (CONF_PROTOCOL_PAIRED),
written by the setup pairing step, the reauth finisher and the reconfigure
finisher, so no entry can sit enabled-but-unpaired. The certificate from the
first pairing is reused by design, so a paired entry is asked for no new PIN
— which is also why the marker, not certificate material, is the source of
truth.

ADB STAYS THE PRIMARY STATE SOURCE. Arming the protocol introduces pushes
that were never live before, so the reading now names its tier:
manager.foreground_source drives the media player's app_source and the
remote's activity_source, and the new tests prove a full protocol push set
leaves the media state, the now-playing reading and every ADB store field
untouched, while an ADB-only entry still serves media player, sensors, apps,
scripts and receiver with only the protocol surface degraded.

Copy: unknown_activity, activity_launch_failed and protocol_unavailable in
BOTH strings.json and translations/en.json, pinned by a test; the README
documents the picker, activity resolution, how to read the transport-state
attributes, the script bypass (a script-routed press is never evidence of
pairing), the connect-then-keep-alive lifecycle and cert reuse versus the
other integration's own pairing.

Evidence: 6 new real-HA FlowManager tests in tests/test_config_flow_real_ha.py
(unpaired -> pairs, PIN -> marker, skip -> off, paired -> not asked again, and
the setup flow's marker on success and on skip), a real-HA entity contract
test in tests/test_ha_api_drift.py (exact supported_features equality with
core, activity_list/current_activity reaching the state machine, live _attr_
updates) and new suites in tests/test_protocol_transport.py (connect-first,
keep_reconnecting alone is a no-op, retry, auth rejection, honest send
failures, ADB-primary precedence) and tests/test_command_routing.py (the
activity contract, turn_on(activity=), script ownership, the transport
attribute states). No device and no browser exist here: device behaviour is
inferred from the library and core source, and every UI-facing claim is
proven at the entity/registry/metadata level under real Home Assistant.

Verify: suites 719/4 and 720/3; real-HA glob (tests/test_adb_command_real_ha.py
+ tests/test_config_flow_real_ha.py) 56 passed, 0 skipped; ruff clean; drift
exit 0 (25 modules, 76 imports, 206 relative imports, 1322 statement blocks,
57 call arguments) with the floors raised 40->70, 100->195, 500->1300 and
52->55.
2026-10-12 12:15:58 +01:00

2117 lines
93 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_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_APP_ICON_MAP,
CONF_APP_NAME_MAP,
CONF_CHANNEL_DIGIT_DELAY,
CONF_CHANNEL_SEND_ENTER,
CONF_COMMAND_SCRIPTS,
CONF_HOST,
CONF_JELLYFIN_ENTITY,
CONF_LOGCAT_EVENTS,
CONF_PAIRING_CODE,
CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_PAIRED,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
CONF_REMOTE_COMMAND_SCRIPT,
CONF_SCREEN_CAPTURE_ENABLED,
CONF_SKIP_PAIRING,
DEFAULT_ADB_PORT,
DEFAULT_NAME,
DOMAIN,
ENTRY_VERSION,
SCRIPT_COMMANDS,
SCRIPT_FIELD_PREFIX,
command_scripts,
)
from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host
_LOGGER = logging.getLogger(__name__)
#: t64: the Apps ROW EDITOR's field names. They are UI only — a projection of one
#: ``package=name[|icon]`` row — so they never enter ``_ALL_OPTION_FIELDS`` and
#: ``_apply_submission`` drops them instead of storing them as options.
APP_FIELD_PACKAGE = "app_package"
APP_FIELD_NAME = "app_name"
APP_FIELD_ICON = "app_icon"
def _valid_app_row(package: str, name: str, icon: str = "") -> bool:
"""The ONE place a row's rules live: dotted package, no spaces, non-empty name.
The text field's parser and the row editor both call this, so a row typed into
the table and a row added through the form are accepted (or rejected)
identically — which is what lets editing one row leave every other row alone.
"""
if not package or not name or " " in package:
return False
return not (icon and " " in icon)
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 _valid_app_row(package, name, icon):
return None
if icon:
icons[package] = icon
app_map[package] = name
return app_map
def _apps_table_text(
stored_map: dict, stored_icons: dict, discovered: list[dict]
) -> str:
"""The Editable apps table: stored rows first, then discovered apps (t57).
Every app the Shield reports gets a row, so the page opens on the REAL app
list instead of only the built-in names ("it doesn't find all the apps").
The stored rows keep their position, name and icon — the user's edit is
never overwritten by a discovery — and a discovered app the map does not
mention is appended with the name the SAME chain resolves (user map > device
label > curated map) and its heuristic icon. An app whose name is unknown
anywhere is NOT written as ``pkg=pkg``: a row that saves the package id as if
it were a name is the defect the user reported from this very field, so such
an app is offered in the row editor (which says "no name yet" out loud)
instead of being pre-filled here.
The icon column is emitted only for a stored icon, because the discovered
icon is an MDI NAME (``mdi:netflix``) and the icon column's documented
values are a picture URL or a ``camera.*`` entity that feeds
``entity_picture``. Writing an ``mdi:`` string there would put a value the
media player cannot use into the user's own map — the icon is offered in the
table's description instead, and in the installed-apps sensor's attributes.
"""
lines: list[str] = []
seen: set[str] = set()
for package, name in (stored_map or {}).items():
seen.add(package)
icon = (stored_icons or {}).get(package)
lines.append(f"{package}={name}|{icon}" if icon else f"{package}={name}")
for app in discovered or []:
package = app.get("package")
if not package or package in seen:
continue
seen.add(package)
name = str(app.get("name") or "").strip()
if not name or name == package:
# nothing names this app: the id would be doubled into a "name" that
# is not one (t66). The row editor still offers it for naming.
continue
lines.append(f"{package}={name}")
return "\n".join(lines)
def _options_field_values(options: dict | None) -> dict:
"""Entry options as FRONTEND-SERIALIZABLE field values (t56).
The bug this exists to kill: the apps map is stored as a DICT, and the
options page handed that dict straight to ``add_suggested_values_to_schema``
as the suggested value of a TEXT field. Real Home Assistant serializes the
suggested value verbatim into the form the frontend receives (verified on a
real install: ``{"suggested_value": {"ar.tvplayer.tv": "TiviMate"}}`` for a
``type: string`` field), and the browser renders a non-string as
``[object Object]``.
So the suggestion layer gets only what a text field can display: the apps
map (plus its icon column) serialized to the documented
``package=name[|icon]`` lines, and every other option value unchanged.
"""
options = dict(options or {})
app_map = options.pop(CONF_APP_NAME_MAP, None)
icon_map = options.pop(CONF_APP_ICON_MAP, None)
if isinstance(app_map, dict):
options[CONF_APP_NAME_MAP] = _app_map_to_text(
app_map, icon_map if isinstance(icon_map, dict) else None
)
return options
def _app_map_submission(value) -> tuple[dict, dict] | None:
"""Parse the apps-table field into ``(app_map, icon_map)``; None if invalid.
A blank field is the documented way to clear the map; anything else must
parse as ``package=name[|icon]`` lines or the whole submission is rejected
(never half-stored).
"""
if value is None:
return {}, {}
if not str(value).strip():
return {}, {}
icons: dict[str, str] = {}
app_map = _app_map_from_text(str(value), icons)
if app_map is None:
return None
return app_map, icons
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"]
# t41: both transports are OURS and both are ON by default — the user opts
# out (or skips pairing) rather than opting in. The rendered checkboxes must
# agree with the code's fallbacks in async_step_user / _create_entry, so all
# three carry True (t33 F-A: a default the schema cannot see is a bug).
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=True)] = bool
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, default=True)] = bool
return schema_dict
class _ClearingEntitySelector(selector.EntitySelector):
"""An entity selector that accepts a CLEARED field (t47 F1).
Clearing an optional entity field used to depend on the frontend OMITTING the
key. It does not have to: a submission can carry ``''`` or ``None``, and real
Home Assistant's EntitySelector rejects both — the reviewer's real-HA run
produced "Schema validation failed at script_turn_on" from an explicit clear.
That made clearing look like a broken form rather than a choice, on every
entity field of this page (the script selectors are the newest, not the only
ones).
Returning the empty value unchanged lets the flow read it as "no selection",
while a real entity id still goes through the parent's validation. The class
stays an EntitySelector, so the frontend still renders an entity picker.
"""
def __call__(self, data):
if data is None or (isinstance(data, str) and not data.strip()):
return ""
return super().__call__(data)
def _build_selectors():
"""Build reusable selector instances."""
return {
"media_player": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain="media_player")
),
# t56: a receiver is a media_player (an AVR/soundbar) OR a plain switch
# (a soundbar/speaker that only knows on and off). The device_class
# constraint belongs to the media_player half only, so a switch is not
# filtered out by it — and the switch behaviour itself (turning it on
# during playback) lives in the media_player task that follows.
"receiver": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain=["media_player", "switch"])
),
"power": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
),
# t44: a script entity per command override
"script": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain="script")
),
# t64: the apps table is ONE ROW PER LINE — a bare ``str`` renders a
# SINGLE-LINE input (HA docs: "If no selector is defined, a text input
# for a single line will be shown"), so the stored multi-line map arrived
# flattened and new rows could not be typed at all. The multiline text
# selector is the same field with a real input for it.
"app_map": selector.TextSelector(
selector.TextSelectorConfig(multiline=True)
),
}
async def _async_start_protocol_pairing(
hass, host: str, client_name: str
) -> tuple[object | None, str | None]:
"""Build a throwaway pairing client and make the Shield show its PIN (t15).
Certificates live under ``.storage/<domain>/`` (core androidtv_remote helpers.py
pattern) and are SHARED between pairing and runtime — an entry paired later reuses
them, which is why enabling the transport on a paired entry asks for no new PIN.
Returns ``(client, None)`` or ``(None, error_key)``: no host, a missing
``androidtvremote2`` and an unreachable Shield are all keys the form can show,
never an exception out of a step.
t44 F1: androidtvremote2 raises ``CannotConnect`` from both calls below, and the
protocol transport is ON by default, so an asleep, rebooting or mis-addressed
Shield is the ordinary case rather than an edge one. Left uncaught it escaped the
config flow entirely, and the protocol_pair form — the address field AND the
"continue without pairing" escape — never rendered.
Module-level (t15) because BOTH flows drive a pairing session: the setup flow on
its way in, and the OPTIONS flow when the transport is switched on for an entry
that never paired.
"""
if not host:
return None, "protocol_no_host"
certfile, keyfile = await hass.async_add_executor_job(protocol_cert_paths, hass)
client = ShieldConnectionManager.build_protocol_client(
host,
client_name=client_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, "protocol_unavailable"
try:
await client.async_generate_cert_if_missing()
await client.async_start_pairing()
except Exception as err: # noqa: BLE001 - the library's error IS the finding
_LOGGER.warning(
"protocol pairing could not start at %s (%s: %s) — showing the "
"form so the address can be corrected or pairing skipped",
host,
type(err).__name__,
err,
)
_disconnect_pairing_client(client)
return None, "protocol_unreachable"
return client, None
def _disconnect_pairing_client(client) -> None:
"""Drop a THROWAWAY pairing client (t15) — both flows hold exactly one.
A pairing client never becomes the entry's runtime transport, so a session whose
address was corrected, or that was skipped, must be closed here rather than left
dangling with a live TLS connection.
"""
if client is not None:
with contextlib.suppress(Exception):
client.disconnect()
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Nvidia Shield."""
VERSION = ENTRY_VERSION
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, True),
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, True),
}
# t56: the first form stays exactly one screen (so nothing it used
# to accept moves or disappears), and the flow then becomes a MENU
# of sub-screens — Connection / Receiver / the pairing steps /
# Finish — instead of a single long page or a hidden sequence.
return await self.async_step_menu()
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 + setup sub-screens.
Everything the integration needs lives in the OPTIONS (t41: one host
field among them); ``data`` stays empty because no value has to be
immutable. The setup sub-screens (t56) contribute ``_setup_extras`` —
disjoint keys merged over the first form, so a value configured on a
sub-screen wins over the same key left blank on the first form.
"""
d = {**self._user_data, **self._setup_extras}
return self.async_create_entry(
# the name is the entry title; a flow driven programmatically (and
# the first form always carries it) may omit it, so fall back
title=d.get("name") or DEFAULT_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, True),
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, True),
# t15: the pairing marker travels with the entry: the pairing step set
# it (True) or the skip path cleared it (False), and the options flow
# reads it before it lets the transport run unpaired.
CONF_PROTOCOL_PAIRED: bool(d.get(CONF_PROTOCOL_PAIRED, False)),
},
)
async def _async_after_pairing(self):
"""Route after a pairing step: the protocol next, else the setup menu (t56).
The "ADB paired, now the protocol" sequence is deliberate and stays
automatic — a user who asked for both transports should not have to find
the second one in a menu — and the setup menu afterwards is where the
remaining sub-screens (Connection / Receiver) and Finish live.
"""
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
self, "_paired_protocol", False
):
return await self.async_step_protocol_pair()
return self._show_setup_menu()
def _discard_protocol_client(self) -> None:
"""Drop a pairing client whose session became void (host corrected)."""
client = self._protocol_client
self._protocol_client = None
_disconnect_pairing_client(client)
async def _start_protocol_pairing(self):
"""Build a throwaway protocol client and make the Shield show its PIN (t15).
Thin wrapper: the construction is module-level because the OPTIONS flow needs
exactly the same thing when the transport is switched on for an entry that
never paired (``_async_start_protocol_pairing``).
"""
return await _async_start_protocol_pairing(
self.hass,
self._host(),
self._user_data.get("name", DEFAULT_NAME) or DEFAULT_NAME,
)
async def _async_begin_protocol_pairing(self):
"""Start a PIN pairing session: ``(client, None)`` or ``(None, error)``."""
return await self._start_protocol_pairing()
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
# t15: skipped means NOT paired — the marker is what keeps an unpaired client
# off instead of letting it look like a dead remote later.
self._user_data[CONF_PROTOCOL_PAIRED] = 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)
# t56: the protocol is paired now, so the setup menu must not offer to
# pair it again — it returns to the menu (Receiver / Connection /
# Finish) instead of creating the entry here.
#
# t15: and the pairing is PERSISTED. Nothing else can tell a paired entry
# from one that merely switched the transport on (the library has no
# is_paired, and certificate material is generated either way), so the
# options flow reads this marker before it lets the transport run unpaired.
self._user_data[CONF_PROTOCOL_PAIRED] = True
self._paired_protocol = True
return self._show_setup_menu()
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,
# t15: this reconfigure JUST paired — persist the marker the options
# flow reads before it lets an unpaired transport run.
CONF_PROTOCOL_PAIRED: 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,
# t15: this reauth JUST paired — persist the marker (see above).
CONF_PROTOCOL_PAIRED: 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()
# ── t56: the setup menu and its sub-screens ──
@property
def _setup_extras(self) -> dict:
"""Options collected on the setup sub-screens (t56), merged, not last-writer-wins.
Lazily created so the flow's ``__init__`` stays untouched; the menu's
sub-screens each own a disjoint set of keys and merge into this dict, so
configuring the receiver and then the connection (in either order)
cannot drop the other's edits.
"""
if getattr(self, "_setup_extra_options", None) is None:
self._setup_extra_options = {}
return self._setup_extra_options
def _show_setup_menu(self):
"""The setup menu: receiver, connection, pairing, finish (t56).
Home Assistant's standard menu step: it renders every option as a row and
dispatches the choice to ``async_step_<row id>``.
TWO rules this menu has to obey, both learned the hard way:
* the row id must BE its handler name — ``receiver``,
``connection``, ``adb_pair``, ``protocol_pair``, ``finish`` — because
that is literally how HA dispatches (``_raise_if_step_does_not_exist``
on ``async_step_<row id>``). A friendlier alias like ``adb`` is an
UnknownStep.
* a sub-screen's rendered ``step_id`` must be the SAME string, because a
submitted form is dispatched by its own step_id. Rendering
``setup_receiver`` from the ``receiver`` row makes the form
unsubmittable.
``async_show_menu`` takes no ``errors`` (verified against HA 2026.10.1:
``step_id``/``menu_options``/``sort``/``description_placeholders``
only), which is why this method takes none either.
Both transports stay optional (the pairing steps keep their skip), so
the user can finish at any point. A transport that already paired is not
offered again — that row would re-open a pairing session the Shield no
longer needs (file an entry option to change it).
"""
# built in one explicit order rather than a chain of insert(0), which
# is how this read backwards until real HA showed the row order
options = ["receiver", "connection"]
if self._user_data.get(CONF_ADB_ENABLED) and not getattr(
self, "_paired_adb", False
):
options.append("adb_pair")
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
self, "_paired_protocol", False
):
options.append("protocol_pair")
options.append("finish")
return self.async_show_menu(
step_id="menu",
menu_options=options,
description_placeholders=self._setup_menu_placeholders(),
)
def _setup_menu_placeholders(self) -> dict:
"""What the setup menu says about the transports it still offers."""
pending = []
if self._user_data.get(CONF_ADB_ENABLED) and not getattr(
self, "_paired_adb", False
):
pending.append("ADB")
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
self, "_paired_protocol", False
):
pending.append("the remote protocol")
if not pending:
return {"pairing": "everything that needs pairing is paired"}
return {"pairing": "still to pair: " + " and ".join(pending)}
async def async_step_menu(self, user_input=None):
"""First visit: open the pending pairing; later visits: show the menu.
Deliberately NOT the menu's ``finish`` row (that dispatches to
``async_step_finish``, a different method). The menu's own option ids
are therefore ``['connection', 'receiver', 'adb'?, 'protocol'?,
'finish']`` and never include ``menu`` itself, so the flow manager can
never bounce back into this method — the one shape that would loop.
``user_input`` is never a menu choice here either: the flow manager
reads ``next_step_id`` off the MENU result and calls the chosen step
directly, then that step comes back here to re-render.
"""
if not getattr(self, "_pairing_underway", False):
self._pairing_underway = True
if self._user_data.get(CONF_ADB_ENABLED):
return await self.async_step_adb_pair()
if self._user_data.get(CONF_PROTOCOL_ENABLED):
return await self.async_step_protocol_pair()
return self._show_setup_menu()
def _finish_setup(self):
"""Create the entry — the ``finish`` row and the no-transport path."""
return self._create_entry()
def _apply_setup_submission(self, user_input: dict) -> None:
"""Merge a sub-screen submission into the flow's pending options (t56).
ONE rule for every field, so "cleared" and "untouched" can never be
confused: a key the submission CARRIES is authoritative — an empty
value clears the setting — and a key it does NOT carry is untouched and
keeps its stored value. The frontend submits every rendered field, so a
missing key only ever means "that field was not on this screen".
"""
for key, value in user_input.items():
if value is None:
# an explicit null means the same as an empty value: clear
self._setup_extras[key] = ""
else:
self._setup_extras[key] = value
async def async_step_receiver(self, user_input=None):
"""Setup sub-screen: the receiver/soundbar this Shield plays through.
Accepts a media_player OR a plain switch (t56 string/selector change) —
the switch behaviour itself lands with the media_player work that
follows.
"""
if user_input is not None:
self._apply_setup_submission(user_input)
return await self.async_step_menu()
sel = _build_selectors()
receiver = self._setup_extras.get(CONF_RECEIVER_ENTITY, "")
fields: dict = {}
if receiver:
fields[vol.Optional(CONF_RECEIVER_ENTITY, default=receiver)] = sel["receiver"]
else:
fields[vol.Optional(CONF_RECEIVER_ENTITY)] = sel["receiver"]
current_input = self._setup_extras.get(CONF_RECEIVER_INPUT, "")
if current_input:
fields[vol.Optional(CONF_RECEIVER_INPUT, default=current_input)] = str
else:
fields[vol.Optional(CONF_RECEIVER_INPUT)] = str
# the step_id IS the menu row id: HA dispatches a submitted form by its
# own step_id, so anything else is an UnknownStep on submit
return self.async_show_form(step_id="receiver", data_schema=vol.Schema(fields))
async def async_step_connection(self, user_input=None):
"""Setup sub-screen: the address, the ADB port and the two transports.
The same three settings the first form carries, re-editable together —
the first form is one screen by design (so nothing it used to accept can
disappear), and this menu entry is the way back to the address after a
pairing attempt failed. Merged like every other sub-screen.
"""
if user_input is not None:
self._apply_setup_submission(user_input)
if CONF_HOST in user_input:
self._user_data[CONF_HOST] = str(user_input.get(CONF_HOST) or "").strip()
if CONF_ADB_PORT in user_input:
try:
self._user_data[CONF_ADB_PORT] = int(user_input[CONF_ADB_PORT])
except (TypeError, ValueError):
self._user_data[CONF_ADB_PORT] = DEFAULT_ADB_PORT
for key in (CONF_ADB_ENABLED, CONF_PROTOCOL_ENABLED):
if key in user_input:
self._user_data[key] = bool(user_input[key])
return await self.async_step_menu()
host = self._host()
port = self._user_data.get(CONF_ADB_PORT)
if not isinstance(port, int):
port = DEFAULT_ADB_PORT
fields = {
_host_field_key(CONF_HOST, host): str,
vol.Optional(CONF_ADB_PORT, default=port): int,
vol.Optional(
CONF_ADB_ENABLED, default=bool(self._user_data.get(CONF_ADB_ENABLED, True))
): bool,
vol.Optional(
CONF_PROTOCOL_ENABLED,
default=bool(self._user_data.get(CONF_PROTOCOL_ENABLED, True)),
): bool,
}
return self.async_show_form(step_id="connection", data_schema=vol.Schema(fields))
async def async_step_finish(self, user_input=None):
"""The menu's finish row: create the entry from the first form + sub-screens."""
return self._finish_setup()
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.
#
# t56: a skip means the ADB attempt is DECIDED, so the setup
# menu takes over from here — the protocol is still offered on
# it (unpaired), and the user reaches Connection/Receiver/Finish
# without being marched through another form.
return self._show_setup_menu()
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:
# t57 F2: paired, so the menu must stop offering that row —
# the same rule the protocol row already follows
self._paired_adb = True
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 — a menu of sub-screens (t29 G3b, t56).
The constructor takes NO arguments: HA assigns ``self.config_entry`` on
instantiation (the pre-2026 ``OptionsFlow(config_entry)`` pattern is
deprecated) and resolves it through ``hass.config_entries`` at call time.
The page is a standard Home Assistant MENU step: Home Assistant renders the
rows, stores the choice as ``next_step_id``, and calls
``async_step_<choice>(None)``. Every sub-screen merges into ONE pending
options dict and returns to the menu, so an edit made on one sub-screen can
never be lost by visiting another — the merge is key-scoped and
deterministic, never last-writer-wins. "Save and close" commits it (and
reloads through the OptionsFlowWithReload base).
Suggested values come through ``add_suggested_values_to_schema`` and are
handed the SERIALIZED field values from ``_options_field_values``: the apps
map is a dict in storage but must be the documented ``package=name[|icon]``
text on the wire, or the browser renders ``[object Object]`` (t56).
"""
def __init__(self):
self._options: dict | None = None
# t64: the Discover row's OUTCOME, and the row the editor is working on.
# All three are UI state: they never become options.
self._discover_note: str | None = None
self._editing_package: str | None = None
self._removing_package: str | None = None
# t15: the throwaway pairing client of a protocol_pair visit (see
# async_step_protocol_pair). Flow state, never an option.
self._protocol_client = 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
# ── the menu (t56) ──
async def async_step_init(self, user_input=None):
"""The options menu: Main setup / Apps / Scripts / Receiver / Save.
A MENU result never reaches this method with form data: the flow
manager reads ``next_step_id`` off the menu result and calls the chosen
step directly. Returning the menu here is what every sub-screen comes
back to.
``discover`` is the explicit refresh (t57): it runs the ADB sweep and
reports what it found, so the app table is never built by accident and
nothing sweeps on a state poll.
"""
return self.async_show_menu(
step_id="init",
menu_options=[
"connection",
"receiver",
"apps",
"discover",
"scripts",
"finish",
],
description_placeholders=self._menu_placeholders(),
)
def _menu_placeholders(self) -> dict:
"""What the menu says about app discovery (t57/t64).
The user report (t64): "Discover installed apps seems to load then do
nothing which is confusing". So the outcome of the Discover ROW — how
many apps arrived, or why none did — outranks the state-based note once
the user has actually asked for a sweep; a failed or empty sweep is now
a sentence, not a silent no-op.
"""
if self._discover_note is not None:
return {"discovery": self._discover_note}
apps = self._discovered_apps()
if apps:
return {"discovery": f"{len(apps)} apps found on the Shield"}
manager = getattr(self.config_entry, "runtime_data", None) if (
self.config_entry is not None
) else None
if manager is not None and not getattr(manager, "connected", False):
return {"discovery": "no ADB connection — nothing discovered yet"}
return {"discovery": "not discovered yet — use Discover installed apps"}
async def async_step_discover(self, user_input=None):
"""Discover the apps installed on the Shield over ADB (t57).
A menu ROW, not a form: Home Assistant dispatches ``next_step_id`` to
this method with no user input, it awaits the manager's bounded sweep,
and the menu comes back with a line saying what was found. Running it
from a row keeps the sweep explicit (never a per-poll cost) and gives it
a place to report a failure without a new field.
"""
self._discover_note = await self._run_discovery()
return await self.async_step_init()
async def _run_discovery(self) -> str:
"""Sweep the device and report WHAT HAPPENED, in one line (t57/t64).
Discovery is a convenience: when ADB is off or down, or the sweep finds
nothing, the menu must still render and the table must still work on the
user's own rows. So nothing here raises — but the OUTCOME is returned as
a sentence the menu shows, because "it seemed to load then did nothing"
is the one thing a user cannot distinguish from a broken integration.
"""
entry = self.config_entry
manager = getattr(entry, "runtime_data", None) if entry is not None else None
if manager is None:
store = (getattr(self.hass, "data", None) or {}).get(DOMAIN) or {}
manager = store.get(getattr(entry, "entry_id", None))
sweep = getattr(manager, "async_refresh_installed_apps", None)
if sweep is None:
return "ADB is not set up for this entry — nothing could be discovered."
try:
await sweep(force=True)
except Exception as err: # noqa: BLE001 - a failed sweep is not an error
_LOGGER.debug("app discovery from the options page failed: %s", err)
return (
"the discovery sweep could not reach the Shield — check the ADB "
"connection; your saved rows are unchanged."
)
apps = self._discovered_apps()
if not apps:
return (
"the Shield reported no installed apps — the table keeps your "
"saved rows."
)
if getattr(manager, "app_labels_absent", False):
# Measured, not guessed: the sweep probed and this firmware named
# nothing, so say which names the user is looking at (t66).
return (
f"{len(apps)} apps found — this Shield exposes no app labels over "
"ADB, so names come from the built-in app names and your own rows."
)
return f"{len(apps)} apps found on the Shield — the table is updated."
async def async_step_menu_again(self, user_input=None):
"""Return to the options menu after a sub-screen saved (t56).
Deliberately NOT ``async_step_init``: HA dispatches a menu row to
``async_step_<row id>`` by NAME, so ``async_step_init`` is not what the
menu returns to — and calling it here would also bypass the ``step_id``
the browser expects on the next render.
``user_input`` is mandatory even though this step ignores it: the real
FlowManager calls ``async_step_<row>(user_input)`` for EVERY dispatched
menu row (t64 — this was only reachable through a stub before, and the
real manager raised TypeError on the row the Apps menu shares).
"""
return await self.async_step_init()
async def async_step_finish(self, user_input=None):
"""Save and close: commit the merged options (the base reloads)."""
return self.async_create_entry(title="", data=self._options_dict)
# ── the ONE merge rule ──
def _apply_submission(self, user_input: dict, errors: dict) -> bool:
"""Merge a sub-screen submission into the pending options.
The rule, so "cleared" and "untouched" can never be confused:
* a key the submission CARRIES is authoritative — an empty value (``''``
from a cleared selector, ``None`` from an explicit null, a whitespace
string) CLEARS the setting, a value sets it;
* a key the submission does NOT carry is untouched and keeps its stored
value.
Real Home Assistant submits every field the step rendered, so a missing
key only ever means "that field was not on this screen" (verified with a
real FlowManager: the submitted ``user_input`` is exactly the coerced
schema output for the rendered fields). The per-command scripts are the
one nested-shaped option, so when ANY script field is part of a
submission the whole dict is rebuilt from the submitted fields: that is
what makes unsetting a script persist instead of surviving as a stale
key, and it keeps ``command_scripts`` in the shape the service layer
reads (flat ``command -> script``).
Returns True when the submission is valid and merged.
"""
present = {
key: value
for key, value in user_input.items()
if key in _ALL_OPTION_FIELDS
}
# ── the apps table: parse or reject, never half-store ──
if CONF_APP_NAME_MAP in present:
parsed = _app_map_submission(present[CONF_APP_NAME_MAP])
if parsed is None:
errors["base"] = "invalid_app_map"
return False
app_map, icon_map = parsed
present[CONF_APP_NAME_MAP] = app_map
present[CONF_APP_ICON_MAP] = icon_map
# ── the channel digit delay (§7 window) ──
if CONF_CHANNEL_DIGIT_DELAY in present:
try:
delay = float(present[CONF_CHANNEL_DIGIT_DELAY])
except (TypeError, ValueError):
errors["base"] = "invalid_digit_delay"
return False
if not 0.05 <= delay <= 1.0:
errors["base"] = "invalid_digit_delay"
return False
present[CONF_CHANNEL_DIGIT_DELAY] = delay
# ── the per-command scripts: per FIELD, which is the unambiguous rule ──
#
# A cleared selector arrives as an empty value for THAT field (t57
# UNIT 0a verified the shape against real HA), so clearing is a field
# operation: a `script_<command>` key that is present and empty REMOVES
# its command, one that is present with a value sets it, and a key the
# submission does not carry is untouched — the same rule as every other
# option. Nothing depends on the page being complete, so a hand-made
# partial submission cannot silently wipe the commands it omits. The
# starting point is the normalized `command_scripts` view, so a command
# this version no longer knows about is dropped rather than carried.
script_fields = [f for f in present if f.startswith(SCRIPT_FIELD_PREFIX)]
if script_fields:
scripts = dict(command_scripts(self._options_dict))
for field in script_fields:
command = field[len(SCRIPT_FIELD_PREFIX):]
value = str(present[field] or "").strip()
if command in SCRIPT_COMMANDS and value:
scripts[command] = value
else:
# cleared, or a field this version has no command for
scripts.pop(command, None)
# UI only: the service layer reads the dict, never a flat key
present.pop(field)
present[CONF_COMMAND_SCRIPTS] = scripts
for key, value in present.items():
if value is None:
value = ""
if isinstance(value, str):
value = value.strip()
self._options_dict[key] = value
return True
# ── sub-screens ──
async def async_step_connection(self, user_input=None):
"""Main setup: the link, both transports and the linked entities."""
errors: dict = {}
if user_input is not None and self._apply_submission(dict(user_input), errors):
if self._protocol_enable_needs_pairing(user_input):
# t15: switching the transport ON for an entry that was never paired
# must PAIR now, not enable an unauthenticated client. The setup flow
# always paired (or turned the transport off); this is the way back in
# for an entry created in passive mode, and the only honest reading of
# "pair or stay off".
return await self.async_step_protocol_pair()
return await self.async_step_menu_again()
return self._show_connection_form(errors)
def _protocol_paired(self) -> bool:
"""True when this entry's protocol client was really paired (t15).
The persisted marker is the ONLY honest source: the library exposes no
``is_paired``, and certificate material proves only that something generated a
key — which an entry that merely enabled the transport has too (the runtime
calls ``async_generate_cert_if_missing``).
"""
data = getattr(self.config_entry, "data", None) or {}
return bool(self._options_dict.get(CONF_PROTOCOL_PAIRED) or data.get(CONF_PROTOCOL_PAIRED))
def _protocol_enable_needs_pairing(self, submission: dict) -> bool:
"""Does this submission turn the transport ON without a pairing to back it?"""
if CONF_PROTOCOL_ENABLED not in submission:
return False
if not self._options_dict.get(CONF_PROTOCOL_ENABLED):
return False # turned off (or left off): nothing runs, nothing to pair
return not self._protocol_paired()
async def async_step_protocol_pair(self, user_input=None):
"""Pair the remote protocol from the OPTIONS flow (t15).
Reached only by switching the transport ON for an entry that never paired. The
alternative is the state the user was stuck in: a client that connects nowhere,
no pushes, every send swallowed — a remote that looks unpaired whether it is or
not. ``skip_pairing`` leaves the transport OFF (persisted), so the entry cannot
end up enabled-but-unpaired either way.
"""
errors: dict = {}
if user_input is None:
return await self._async_restart_options_pairing(errors)
if user_input.get(CONF_SKIP_PAIRING):
return await self._async_skip_options_pairing()
typed_host = str(user_input.get(CONF_HOST) or "").strip()
if typed_host and typed_host != entry_host(self.config_entry):
# A host typed HERE is authoritative (same rule as the setup flow): the
# session started against the old address is dropped and the Shield is
# asked for a NEW code at the corrected address.
self._options_dict[CONF_HOST] = typed_host
self._discard_options_pairing_client()
code = str(user_input.get(CONF_PAIRING_CODE, "") or "").strip()
if self._protocol_client is None or not code:
return await self._async_restart_options_pairing(errors)
try:
await self._protocol_client.async_finish_pairing(code)
except Exception:
errors["base"] = "protocol_pair_failed"
return self._show_options_pair_form(errors)
self._discard_options_pairing_client()
# Paired: the transport may run, and the marker keeps every later visit from
# asking again (the certificate is reused by design — no new PIN).
self._options_dict[CONF_PROTOCOL_PAIRED] = True
return await self.async_step_menu_again()
def _show_options_pair_form(self, errors=None):
"""protocol_pair form for the OPTIONS flow: PIN, address, skip escape."""
host = entry_host(self.config_entry)
return self.async_show_form(
step_id="protocol_pair",
data_schema=vol.Schema(
{
vol.Required(CONF_PAIRING_CODE): str,
_host_field_key(CONF_HOST, host): str,
vol.Optional(CONF_SKIP_PAIRING, default=False): bool,
}
),
errors=errors or {},
)
async def _async_restart_options_pairing(self, errors: dict):
"""Start a fresh pairing session and render the form for its code (t15)."""
self._discard_options_pairing_client()
client, error = await _async_start_protocol_pairing(
self.hass,
entry_host(self.config_entry),
self.config_entry.title or DEFAULT_NAME,
)
if error:
errors["base"] = error
else:
self._protocol_client = client
return self._show_options_pair_form(errors)
async def _async_skip_options_pairing(self):
"""Continue with the transport OFF (t15) — never enabled-but-unpaired."""
self._discard_options_pairing_client()
self._options_dict[CONF_PROTOCOL_ENABLED] = False
self._options_dict[CONF_PROTOCOL_PAIRED] = False
return await self.async_step_menu_again()
def _discard_options_pairing_client(self) -> None:
"""Drop the options flow's throwaway pairing client (t15)."""
client = self._protocol_client
self._protocol_client = None
_disconnect_pairing_client(client)
def _show_connection_form(self, errors=None):
"""Main setup: the same settings the first setup form carries.
Deliberately its own field list rather than ``_user_schema_dict``: that
builder renders the entry's NAME (a config-entry title, not an option —
it would be a field whose edits are silently dropped) and it takes no
logcat/adb-command toggles. The field NAMES stay identical, which is
what a sub-screen has to preserve.
"""
sel = _build_selectors()
options = self._options_dict
port = options.get(CONF_ADB_PORT)
schema_dict = {
_host_field_key(CONF_HOST, str(options.get(CONF_HOST) or "")): str,
vol.Optional(
CONF_ADB_PORT, default=port if isinstance(port, int) else DEFAULT_ADB_PORT
): int,
vol.Optional(
CONF_ADB_ENABLED, default=bool(options.get(CONF_ADB_ENABLED, True))
): bool,
vol.Optional(
CONF_ADB_STORE_APP_INFO,
default=bool(options.get(CONF_ADB_STORE_APP_INFO, True)),
): bool,
vol.Optional(
CONF_PROTOCOL_ENABLED,
default=bool(options.get(CONF_PROTOCOL_ENABLED, True)),
): bool,
vol.Optional(
CONF_LOGCAT_EVENTS, default=bool(options.get(CONF_LOGCAT_EVENTS, False))
): bool,
vol.Optional(
CONF_ADB_COMMAND_ENABLED,
default=bool(options.get(CONF_ADB_COMMAND_ENABLED, False)),
): bool,
# t66: the screen-capture image entity. Default OFF — a capture is a
# full-resolution screen grab, and the standing constraint is that
# screen capture must never drive state.
vol.Optional(
CONF_SCREEN_CAPTURE_ENABLED,
default=bool(options.get(CONF_SCREEN_CAPTURE_ENABLED, False)),
): bool,
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
vol.Optional(CONF_POWER_ENTITY): sel["power"],
}
return self._show_sub_form("connection", schema_dict, errors)
async def async_step_receiver(self, user_input=None):
"""Receiver: the media_player OR switch this Shield plays through."""
errors: dict = {}
if user_input is not None and self._apply_submission(dict(user_input), errors):
return await self.async_step_menu_again()
return self._show_receiver_form(errors)
def _show_receiver_form(self, errors=None):
sel = _build_selectors()
# Receiver input: a dropdown from the DAG input map when the linked
# entity publishes one, a plain text field otherwise — a plain switch
# never publishes one, so it gets the text field.
inputs = _get_receiver_inputs(
self.hass, self._options_dict.get(CONF_RECEIVER_ENTITY)
)
input_field = (
selector.SelectSelector(
selector.SelectSelectorConfig(
options=inputs,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=True,
)
)
if inputs
else str
)
schema_dict = {
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
vol.Optional(CONF_RECEIVER_INPUT): input_field,
}
return self._show_sub_form("receiver", schema_dict, errors)
async def async_step_apps(self, user_input=None):
"""The Apps editor: a MENU of row actions (t64).
Home Assistant has no table selector (checked against the full selector
list — area/device/entity/label/number/object/select/template/text/… and
no table), so a table is edited the way HA edits one: small steps that
each do ONE row operation. This menu is the entry point; ``apps_text``
keeps the whole-table paste, and ``apps_add``/``apps_edit``/
``apps_remove`` work on single rows.
A menu row is dispatched to ``async_step_<row id>``, so one row PER APP
is impossible — the ids would have to be method names. The apps are
listed in this step's description instead, and the edit/remove steps ask
WHICH app through a selector built from the same stored map. Rows that
could not do anything (edit/remove with nothing stored) are not offered.
"""
return self.async_show_menu(
step_id="apps",
menu_options=self._apps_menu_options(),
description_placeholders=self._apps_placeholders(),
)
def _stored_apps(self) -> dict:
"""The rows the map already owns, ``{package: name}`` (t64).
The text field's suggestion additionally shows discovered apps (t57) —
those are an OFFER, not a stored row — so the row editor works on this
map only, and every mutation goes through the helpers below, which keeps
the name and icon columns from drifting apart.
"""
app_map = self._options_dict.get(CONF_APP_NAME_MAP)
if not isinstance(app_map, dict):
return {}
return {str(package): str(name) for package, name in app_map.items()}
def _apps_menu_options(self) -> list[str]:
"""The menu rows, minus the ones with nothing to act on (t64)."""
rows = ["apps_text", "apps_add"]
if self._editable_apps(stored_only=True):
rows += ["apps_edit", "apps_remove"]
rows.append("menu_again")
return rows
def _detected_apps(self) -> dict:
"""The apps the device reported, ``{package: {name, icon}}`` (t64).
Straight from the t57 cache — no sweep, no shell. The name may be empty:
the user's Shield exposes no device label at all (t66: the discovery table
no longer repeats the package id as a name), and an app nothing names is
left empty so ``_editable_apps`` can mark it as needing a name.
"""
detected: dict = {}
for app in self._discovered_apps():
package = str(app.get("package") or "").strip()
if not package:
continue
name = str(app.get("name") or "").strip()
if name == package:
# a name that IS the id is not a name (t66): the row editor says
# "(no name yet)" instead of offering the id twice.
name = ""
detected.setdefault(
package,
{
"name": name,
"icon": str(app.get("icon") or "").strip(),
},
)
return detected
def _editable_apps(self, *, stored_only: bool = False) -> dict:
"""Every app the user can act on, ``{package: {name, icon, stored}}``.
Saved rows first, then the detected ones the map does not mention — the
same union the text field's suggestion carries, so the dropdown lists
exactly what the table offers. A detected app whose name is empty (or is
just its own id) is NOT labelled twice: it is labelled as unnamed, which
is what makes it visibly editable instead of looking like a real name.
"""
icons = self._options_dict.get(CONF_APP_ICON_MAP) or {}
apps: dict = {}
for package, name in self._stored_apps().items():
apps[package] = {
"name": name,
"icon": str(icons.get(package) or ""),
"stored": True,
}
if stored_only:
return apps
for package, row in self._detected_apps().items():
if package in apps:
continue
apps[package] = {
"name": row["name"] if row["name"] != package else "",
# the device icon is an ``mdi:`` name, which the icon column does
# NOT accept (it feeds entity_picture), so it is never prefilled
"icon": "",
"stored": False,
}
return apps
def _app_label(self, package: str, row: dict) -> str:
"""How one app reads in a dropdown or in the menu's note (t64)."""
if row.get("name"):
return f"{row['name']} ({package})"
return f"{package} (no name yet)"
def _apps_placeholders(self) -> dict:
"""What the Apps menu says about the apps it offers to act on."""
apps = self._editable_apps()
if not apps:
return {"apps": "no apps known yet — use Discover installed apps, or add one"}
labels = []
for package, row in apps.items():
label = self._app_label(package, row)
# a detected-but-unsaved app is marked, so the user can see what the
# map does not own yet (and which name is worth fixing)
labels.append(label if row["stored"] else f"{label} [detected]")
shown = ", ".join(labels[:8])
if len(labels) > 8:
shown += f", … (+{len(labels) - 8} more)"
count = f"{len(labels)} app" if len(labels) == 1 else f"{len(labels)} apps"
return {"apps": f"{count} known — {shown}"}
def _app_selector(self, apps: dict):
"""The "which app" selector for the rows it is given (t64)."""
return selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value=package, label=self._app_label(package, row)
)
for package, row in apps.items()
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
def _show_app_choose_form(self, step_id: str, apps: dict, errors=None):
"""Ask WHICH app, one step before editing or removing it (t64)."""
return self._show_sub_form(
step_id,
{vol.Required(APP_FIELD_PACKAGE): self._app_selector(apps)},
errors,
)
def _show_app_row_form(
self,
step_id: str,
*,
package: str | None,
package_value: str = "",
name: str = "",
icon: str = "",
errors=None,
placeholders: dict | None = None,
):
"""The add/edit form for ONE row: package (add only), name, icon (t64).
The values are PREFILL VALUES, not voluptuous defaults: HA puts a
suggested value on the wire (``description.suggested_value``) so the
browser renders the field filled in, while a ``default=`` would only be
substituted during validation. The edit form is the one place the user has
to SEE the stored row, so the prefill has to travel that way.
"""
fields: dict = {}
prefill: dict = {APP_FIELD_NAME: name, APP_FIELD_ICON: icon}
if package is None:
fields[vol.Required(APP_FIELD_PACKAGE)] = str
prefill[APP_FIELD_PACKAGE] = package_value
fields[vol.Required(APP_FIELD_NAME)] = str
fields[vol.Optional(APP_FIELD_ICON)] = str
return self._show_sub_form(
step_id, fields, errors, override=prefill, placeholders=placeholders
)
def _write_app_row(self, package: str, name: str, icon: str) -> None:
"""Set ONE row; every other row (and its icon) stays untouched (t64)."""
options = self._options_dict
app_map = dict(options.get(CONF_APP_NAME_MAP) or {})
icon_map = dict(options.get(CONF_APP_ICON_MAP) or {})
app_map[package] = name
if icon:
icon_map[package] = icon
else:
icon_map.pop(package, None)
options[CONF_APP_NAME_MAP] = app_map
options[CONF_APP_ICON_MAP] = icon_map
def _drop_app_row(self, package: str) -> None:
"""Remove ONE row, keeping the icon column in step (t64)."""
options = self._options_dict
app_map = dict(options.get(CONF_APP_NAME_MAP) or {})
icon_map = dict(options.get(CONF_APP_ICON_MAP) or {})
app_map.pop(package, None)
icon_map.pop(package, None)
options[CONF_APP_NAME_MAP] = app_map
options[CONF_APP_ICON_MAP] = icon_map
async def async_step_apps_text(self, user_input=None):
"""The whole table as ONE multiline text field (t64)."""
errors: dict = {}
if user_input is not None and self._apply_submission(dict(user_input), errors):
return await self.async_step_apps()
return self._show_apps_text_form(errors)
async def async_step_apps_add(self, user_input=None):
"""ADD one row: package + name + optional icon (t64)."""
errors: dict = {}
submitted = dict(user_input or {})
package = str(submitted.get(APP_FIELD_PACKAGE) or "").strip()
name = str(submitted.get(APP_FIELD_NAME) or "").strip()
icon = str(submitted.get(APP_FIELD_ICON) or "").strip()
if user_input is not None:
if not _valid_app_row(package, name, icon):
errors["base"] = "invalid_app_row"
else:
self._write_app_row(package, name, icon)
return await self.async_step_apps()
return self._show_app_row_form(
"apps_add",
package=None,
package_value=package,
name=name,
icon=icon,
errors=errors,
)
async def async_step_apps_edit(self, user_input=None):
"""EDIT: pick an app (detected or saved), then set its name and icon (t64).
The dropdown lists EVERY app the user could act on — the saved rows plus
the ones discovery found — because that is what the request asked for
("selector to view the already available detected apps") and because a
detected app is exactly the one whose name needs fixing: picking it opens
the form prefilled with what is known (nothing, for a label-less Shield)
and saving stores the row.
"""
apps = self._editable_apps()
if user_input is not None:
package = str(user_input.get(APP_FIELD_PACKAGE) or "")
if package in apps:
self._editing_package = package
return await self.async_step_apps_edit_form()
if not apps:
return await self.async_step_apps() # nothing left to choose from
return self._show_app_choose_form(
"apps_edit", apps, {"base": "unknown_app_row"}
)
return self._show_app_choose_form("apps_edit", apps)
async def async_step_apps_edit_form(self, user_input=None):
"""The PREFILLED form for the app chosen in ``apps_edit`` (t64).
The package id IS the row's identity, so it is not editable here: the
form shows it (in the description) and edits the name and icon. Use
Add, Remove or the whole-list field to change the id itself.
"""
package = getattr(self, "_editing_package", None)
apps = self._editable_apps()
if not package or package not in apps:
return await self.async_step_apps() # the row is gone: no dead end
row = apps[package]
errors: dict = {}
name = row["name"]
icon = row["icon"]
if user_input is not None:
name = str(user_input.get(APP_FIELD_NAME) or "").strip()
icon = str(user_input.get(APP_FIELD_ICON) or "").strip()
if not _valid_app_row(package, name, icon):
errors["base"] = "invalid_app_row"
else:
self._write_app_row(package, name, icon)
self._editing_package = None
return await self.async_step_apps()
return self._show_app_row_form(
"apps_edit_form",
package=package,
name=name,
icon=icon,
errors=errors,
placeholders={"app": self._app_label(package, row)},
)
async def async_step_apps_remove(self, user_input=None):
"""REMOVE: choose which SAVED app, then confirm it explicitly (t64).
Only rows the map owns can be removed: a detected app that was never
saved has no line to delete, and it would come back with the next sweep.
"""
apps = self._editable_apps(stored_only=True)
if user_input is not None:
package = str(user_input.get(APP_FIELD_PACKAGE) or "")
if package in apps:
self._removing_package = package
return await self.async_step_apps_remove_confirm()
if not apps:
return await self.async_step_apps() # nothing left to choose from
return self._show_app_choose_form(
"apps_remove", apps, {"base": "unknown_app_row"}
)
return self._show_app_choose_form("apps_remove", apps)
async def async_step_apps_remove_confirm(self, user_input=None):
"""The confirmation MENU: dropping a row takes a second, explicit act (t64)."""
package = getattr(self, "_removing_package", None)
apps = self._editable_apps(stored_only=True)
if not package or package not in apps:
return await self.async_step_apps()
return self.async_show_menu(
step_id="apps_remove_confirm",
menu_options=["apps_remove_yes", "apps_remove_no"],
description_placeholders={
"app": self._app_label(package, apps[package])
},
)
async def async_step_apps_remove_yes(self, user_input=None):
"""Confirmed: drop the row and come back to the editor (t64)."""
if getattr(self, "_removing_package", None):
self._drop_app_row(self._removing_package)
self._removing_package = None
return await self.async_step_apps()
async def async_step_apps_remove_no(self, user_input=None):
"""Cancelled: nothing changes, and the editor comes back (t64)."""
self._removing_package = None
return await self.async_step_apps()
def _discovered_apps(self) -> list[dict]:
"""The apps the device reported, from the last background sweep (t57).
Sources, in order: the entry's ``runtime_data`` (where the integration
puts its connection manager) and then the domain store, both of which
the options flow can reach through ``hass``. Never triggers a sweep:
this is a pure read of the manager's TTL cache, so rendering a form
cannot issue shells. When nothing has been discovered (ADB off or down,
or the first sweep still running) the list is empty and the table shows
the user's own rows — the empty state, not a crash.
"""
entry = self.config_entry
manager = getattr(entry, "runtime_data", None) if entry is not None else None
if manager is None:
store = (getattr(self.hass, "data", None) or {}).get(DOMAIN) or {}
manager = store.get(getattr(entry, "entry_id", None))
if manager is None or not hasattr(manager, "installed_apps"):
return []
return manager.installed_apps()
def _show_apps_text_form(self, errors=None):
"""The whole table as TEXT in a MULTILINE field (t56/t64).
The field is a multiline ``TextSelector``, not a bare ``str``: HA renders
a selector-less string field as a SINGLE-LINE input (its docs: "If no
selector is defined, a text input for a single line will be shown"), which
is why the live page showed the stored map as one long flattened line with
no way to add a row. One row per line, exactly the stored format:
``package=name`` with an optional ``|icon``.
The suggestion carries TEXT in every case (never the stored dict — that
was the [object Object] bug) and is the user's own rows PLUS everything
discovery found on the device.
"""
stored = self._options_dict
discovered = self._discovered_apps()
suggested = _apps_table_text(
stored.get(CONF_APP_NAME_MAP) or {},
stored.get(CONF_APP_ICON_MAP) or {},
discovered,
)
icons = sorted({app.get("icon") for app in discovered if app.get("icon")})
return self._show_sub_form(
"apps_text",
{vol.Optional(CONF_APP_NAME_MAP): _build_selectors()["app_map"]},
errors,
override={CONF_APP_NAME_MAP: suggested},
placeholders={"icons": ", ".join(icons) or "none known"},
)
async def async_step_scripts(self, user_input=None):
"""Scripts: per-command overrides and the channel digit behaviour."""
errors: dict = {}
if user_input is not None and self._apply_submission(dict(user_input), errors):
return await self.async_step_menu_again()
return self._show_scripts_form(errors)
def _show_scripts_form(self, errors=None):
sel = _build_selectors()
schema_dict = {vol.Optional(CONF_REMOTE_COMMAND_SCRIPT): sel["script"]}
for command in SCRIPT_COMMANDS:
schema_dict[vol.Optional(f"{SCRIPT_FIELD_PREFIX}{command}")] = sel["script"]
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
return self._show_sub_form("scripts", schema_dict, errors)
# ── rendering ──
def _show_sub_form(
self,
step_id: str,
schema_dict: dict,
errors: dict | None,
override: dict | None = None,
placeholders: dict | None = None,
):
"""Render a sub-screen with only ITS suggested values — never a dict.
``_options_field_values`` is what serializes the apps map for the wire,
and the script selectors are suggested from the normalized
``command_scripts`` dict — the same accessor the service layer reads, so
the page cannot offer a script the runtime would ignore.
``override`` replaces individual suggested values for a screen whose
suggestion is not simply the stored option (the Apps table is the
stored map MERGED with discovery, t57). ``placeholders`` feeds the
step's ``description_placeholders``, which is how a screen reports
something long — the Discover row's outcome — without inventing a field
for it.
"""
suggested = _options_field_values(self._options_dict)
for command, script in command_scripts(self._options_dict).items():
suggested[f"{SCRIPT_FIELD_PREFIX}{command}"] = script
suggested.update(override or {})
kwargs: dict = {}
if placeholders:
kwargs["description_placeholders"] = dict(placeholders)
return self.async_show_form(
step_id=step_id,
data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema_dict), suggested
),
errors=errors or {},
**kwargs,
)
#: Every field the options page can render. A submission key outside this set is
#: menu/HA bookkeeping (`next_step_id`) and must never reach the stored options.
_ALL_OPTION_FIELDS = frozenset(
{
CONF_HOST,
CONF_ADB_PORT,
CONF_ADB_ENABLED,
CONF_PROTOCOL_ENABLED,
CONF_ADB_STORE_APP_INFO,
CONF_LOGCAT_EVENTS,
CONF_ADB_COMMAND_ENABLED,
CONF_SCREEN_CAPTURE_ENABLED,
CONF_JELLYFIN_ENTITY,
CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
CONF_APP_NAME_MAP,
CONF_APP_ICON_MAP,
CONF_REMOTE_COMMAND_SCRIPT,
CONF_CHANNEL_DIGIT_DELAY,
CONF_CHANNEL_SEND_ENTER,
CONF_COMMAND_SCRIPTS,
*{f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS},
}
)