The user asked for "an image entity of the device screen capture? Can that be added". It is added as `image.<entry>_screen_capture`, and the three boundaries are the feature: - it NEVER drives state — the state is the capture timestamp, and the capture path reads the ADB poller and nothing else (pinned by a manager that raises on any other attribute, and by a real-HA run whose recorded state is the timestamp with no bytes anywhere in the attributes); - it is OPT-IN and OFF by default (`screen_capture_enabled`, Main setup row) — with the option absent `async_setup_entry` adds no entity at all; - it NEVER polls — HA's own `should_poll = False` for image entities is kept, so only an image fetch or `homeassistant.update_entity` captures; concurrent fetches share one shell and a capture is reused for 5 s. BINARY-SAFE CAPTURE: `screencap -p | base64` over the existing shell, decoded locally with `base64.b64decode` and gated on the PNG signature AND the IHDR chunk. `AdbDevice.shell()` returns `str`, so raw PNG bytes cannot travel through it; the alternative (a device temp file + the library's file-read path) needs the raw AdbDevice and writes to the Shield's shared storage, where a crash between write and delete leaves a screenshot behind. The base64 route leaves no device-side artifact — nothing to clean up — at the cost of ~33% more bytes on the wire. HONEST FAILURES: ADB off / ADB down / a capture that returned no PNG / a transport that raised all make the entity UNAVAILABLE with `capture_status` (`adb_disabled` / `adb_disconnected` / `capture_failed`) — never a blank or zero-byte image. `image_last_updated` moves only on success. Tests: 25 new cases (a real 2x1 PNG fixture on the wire — bytes, never a str; every non-PNG answer rejected; disconnected/failed/recovery paths; capture reuse, coalescing and the expiry window; opt-in default OFF and on) plus 4 config-flow cases (schema default False, off stays off, on is stored, and it can be turned back off). The stub harness gains the image platform and `util.dt` faces, the real-HA entity matrix gains this class, and the drift sweep's coverage test demanded exactly that. README documents the cost, the opt-in and the boundary.
1942 lines
85 KiB
Python
1942 lines
85 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_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 the four curated entries ("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 device label (or its package id when the label
|
|
fetch did not answer) and its heuristic icon.
|
|
|
|
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 = app.get("name") or package
|
|
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)
|
|
),
|
|
}
|
|
|
|
|
|
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),
|
|
},
|
|
)
|
|
|
|
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
|
|
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
|
|
``(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 the 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.
|
|
"""
|
|
host = self._host()
|
|
if not host:
|
|
return None, "protocol_no_host"
|
|
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, "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,
|
|
)
|
|
with contextlib.suppress(Exception):
|
|
client.disconnect()
|
|
return None, "protocol_unreachable"
|
|
return client, None
|
|
|
|
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
|
|
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.
|
|
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,
|
|
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()
|
|
|
|
# ── 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
|
|
|
|
@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."
|
|
)
|
|
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):
|
|
return await self.async_step_menu_again()
|
|
return self._show_connection_form(errors)
|
|
|
|
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, and a package whose
|
|
``pm dump`` failed falls back to its id, which is NOT a name (see
|
|
``_editable_apps``).
|
|
"""
|
|
detected: dict = {}
|
|
for app in self._discovered_apps():
|
|
package = str(app.get("package") or "").strip()
|
|
if not package:
|
|
continue
|
|
detected.setdefault(
|
|
package,
|
|
{
|
|
"name": str(app.get("name") or "").strip(),
|
|
"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},
|
|
}
|
|
)
|