feat(config-flow): menu of sub-screens on setup and options

Both configure pages were one long form: a field you wanted to change
meant re-submitting everything, and the options page rendered the apps
map as `[object Object]`.

Verified against a real Home Assistant install (not assumed): the HTTP
flow view serialises a field's suggested value verbatim, so a dict handed
to a `type: string` field reaches the browser as a non-string; and real
HA validates a submitted page against the schema the step returned, where
the built-in EntitySelector rejects `''` and `None`.

- The options flow is a MENU (Main setup / Receiver / Apps / Scripts /
  Save and close). Each sub-screen owns a disjoint set of keys and merges
  into one pending dict under a single rule: a key the submission CARRIES
  is authoritative (empty clears), a key it does not carry is untouched.
  The per-command script dict is rebuilt from the submitted script fields,
  so unsetting one persists instead of surviving as a stale key.
- The sub-screen step_id IS the menu row id. Real HA dispatches a menu
  choice and a submitted form by that same name, so a mismatch is an
  UnknownStep or an unsubmittable page (the setup menu first tried a
  "setup_menu" step id and dead-ended in real HA).
- The apps map reaches the suggestion layer serialized to the documented
  `package=name[|icon]` lines, and parses back to both maps.
- The setup flow keeps its first form and gains a menu after it; pairing
  completion returns to the menu, and the "ADB then protocol" sequence
  still happens automatically.
- Receiver accepts a `switch.*` as well as a media_player.

Tests: 522 pass on stubs, and tests/test_config_flow_real_ha.py drives
the real FlowManager + probatio + to_field_list; 53 + 11 of those fail on
the pre-fix tree.
This commit is contained in:
James Mackay committed 2026-10-12 07:17:21 +01:00
1 parent 74e0549196
commit cd8f528a27
3 files changed
+808 -206

No files matched your search

+510 -162
View File
@@ -130,6 +130,49 @@ def _app_map_from_text(
return app_map
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.
@@ -208,8 +251,13 @@ def _build_selectors():
"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", device_class="receiver")
selector.EntitySelectorConfig(domain=["media_player", "switch"])
),
"power": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
@@ -289,14 +337,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, True),
}
# If ADB is enabled, go to pairing step
if self._user_data[CONF_ADB_ENABLED]:
return await self.async_step_adb_pair()
if self._user_data[CONF_PROTOCOL_ENABLED]:
return await self.async_step_protocol_pair()
return self._create_entry()
# 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)
@@ -404,15 +449,19 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_user()
def _create_entry(self):
"""Create the config entry from stored user data.
"""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.
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
d = {**self._user_data, **self._setup_extras}
return self.async_create_entry(
title=d["name"],
# 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, ""),
@@ -428,11 +477,18 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
async def _async_after_pairing(self):
"""Route after the pairing steps: protocol next, else create the entry."""
if self._user_data.get(CONF_PROTOCOL_ENABLED):
return await self.async_step_protocol_pair()
return self._create_entry()
"""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)."""
@@ -607,7 +663,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self._finish_reauth()
if self._reconfigure_entry is not None:
return self._finish_reconfigure(mac)
return self._create_entry()
# 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).
@@ -765,6 +825,157 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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, errors=None):
"""The setup menu: connection, receiver, and finishing the entry (t56).
Home Assistant's standard menu step: it renders every option as a row
and dispatches the choice to ``async_step_<option>``. 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.
"""
options = ["finish"]
if self._user_data.get(CONF_ADB_ENABLED):
options.insert(0, "adb")
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
self, "_paired_protocol", False
):
options.insert(0, "protocol")
options.insert(0, "connection")
options.insert(0, "receiver")
return self.async_show_menu(
step_id="menu", menu_options=options, errors=errors or {}
)
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
return self.async_show_form(
step_id="setup_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="setup_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.
@@ -805,7 +1016,12 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# state when no key exists, and the Pair ADB button (only
# registered for adb-enabled entries) pairs later without
# re-adding the integration.
return await self._async_after_pairing()
#
# 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:
@@ -844,13 +1060,24 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
"""Handle options for Nvidia Shield — single unified page (t29 G3b).
"""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). Suggested values come through
``add_suggested_values_to_schema``, and saving reloads via the
OptionsFlowWithReload base — no listener boomerangs.
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):
@@ -865,154 +1092,275 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
self._options = {}
return self._options
# ── the menu (t56) ──
async def async_step_init(self, user_input=None):
"""Single-page options."""
errors = {}
if user_input is not None:
# t47 F1: a field submitted as None means "not supplied" — only an
# explicit empty value ('' or a cleared selector) clears a setting, so
# the two cannot be confused. Note the schema itself already tolerates
# the empty value (see _ClearingEntitySelector).
user_input = {key: value for key, value in user_input.items() if value is not None}
# P5-2: validate the digit delay (0.05–1.0 s, §7 window)
delay = user_input.pop(CONF_CHANNEL_DIGIT_DELAY, None)
if delay is not None:
try:
delay = float(delay)
except (TypeError, ValueError):
errors["base"] = "invalid_digit_delay"
delay = None
if delay is not None and not (0.05 <= delay <= 1.0):
errors["base"] = "invalid_digit_delay"
# t44: one selector per command, stored as a single dict option so
# the service layer reads one shape. Popped here because _options_dict
# is updated with the rest of user_input verbatim below.
scripts = {
command: script
"""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.
"""
return self.async_show_menu(
step_id="init",
menu_options=["connection", "receiver", "apps", "scripts", "finish"],
)
async def async_step_menu_again(self):
"""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.
"""
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: the submitted fields ARE the dict ──
#
# Rebuilding from the submitted fields is what makes removal persist: a
# script configured for `turn_on` and then cleared arrives as '' for
# that field, and a dict built only from the non-empty fields drops it
# instead of leaving a stale key behind. A command the submission does
# not carry is therefore cleared too — the Scripts screen renders every
# field, so the only way a key goes missing is a hand-made partial
# submission, and this keeps the old "a save without scripts clears
# them" contract unambiguous rather than shape-dependent.
if any(field.startswith(SCRIPT_FIELD_PREFIX) for field in present):
present[CONF_COMMAND_SCRIPTS] = {
command: str(present.get(f"{SCRIPT_FIELD_PREFIX}{command}") or "").strip()
for command in SCRIPT_COMMANDS
if (
script := str(
user_input.pop(f"{SCRIPT_FIELD_PREFIX}{command}", "") or ""
).strip()
)
if str(present.get(f"{SCRIPT_FIELD_PREFIX}{command}") or "").strip()
}
# t47: one optional script for the remote entity — a plain string
# option, unlike the per-command dict above
remote_script = user_input.get(CONF_REMOTE_COMMAND_SCRIPT)
if remote_script is not None:
user_input[CONF_REMOTE_COMMAND_SCRIPT] = str(remote_script).strip()
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
icons: dict[str, str] = {}
if app_map_text is None:
app_map = self._options_dict.get(CONF_APP_NAME_MAP, {})
elif app_map_text.strip() == "":
app_map = {}
else:
app_map = _app_map_from_text(app_map_text, icons)
if app_map is None:
errors["base"] = "invalid_app_map"
if not errors:
if delay is not None:
self._options_dict[CONF_CHANNEL_DIGIT_DELAY] = delay
self._options_dict.update(user_input)
self._options_dict[CONF_APP_NAME_MAP] = app_map
# P5-2: the icon column — the editor is the source of
# truth, so a save without icons clears them (and a
# single-column map stays valid).
self._options_dict[CONF_APP_ICON_MAP] = icons
# an empty dict clears every override (the editor is the truth)
self._options_dict[CONF_COMMAND_SCRIPTS] = scripts
return self.async_create_entry(title="", data=self._options_dict)
for field in [f for f in present if f.startswith(SCRIPT_FIELD_PREFIX)]:
# UI only: the service layer reads the dict, never a flat key.
# Dropped AFTER the rebuild — the rebuild reads them.
present.pop(field)
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()
schema_dict = {}
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,
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
vol.Optional(CONF_POWER_ENTITY): sel["power"],
}
return self._show_sub_form("connection", schema_dict, errors)
# Apps name/icon map editor (P4-1, core androidtv_remote options
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
# the optional icon column).
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
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)
# Jellyfin
jf = self._options_dict.get(CONF_JELLYFIN_ENTITY)
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
if jf:
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
schema_dict[jf_key] = sel["media_player"]
# Receiver
recv = self._options_dict.get(CONF_RECEIVER_ENTITY)
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
if recv:
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
schema_dict[recv_key] = sel["receiver"]
# t44: command script overrides — the escape hatch for hardware we do not
# drive (a speaker switch owning the volume). Unset = native path as before.
schema_dict[vol.Optional(CONF_REMOTE_COMMAND_SCRIPT)] = sel["script"]
configured_scripts = command_scripts(self._options_dict)
suggested_scripts = {}
for command in SCRIPT_COMMANDS:
field = f"{SCRIPT_FIELD_PREFIX}{command}"
schema_dict[vol.Optional(field)] = sel["script"]
if configured_scripts.get(command):
suggested_scripts[field] = configured_scripts[command]
# P5-2: channel digit behavior (the user-validated §7 flow)
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
# P5-3: the live events push is opt-in (default off; the user
# tests stability on their device and reports)
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS)] = bool
# P4-2a: the raw adb_command service is opt-in (default off)
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED)] = bool
# Receiver input — dropdown from DAG if receiver is set, text otherwise
current_input = self._options_dict.get(CONF_RECEIVER_INPUT, "")
receiver = self._options_dict.get(CONF_RECEIVER_ENTITY)
inputs = _get_receiver_inputs(self.hass, receiver)
if inputs:
input_sel = selector.SelectSelector(
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,
)
)
input_key = vol.Optional(CONF_RECEIVER_INPUT)
if current_input:
input_key = vol.Optional(CONF_RECEIVER_INPUT)
schema_dict[input_key] = input_sel
else:
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
# Power
pwr = self._options_dict.get(CONF_POWER_ENTITY)
pwr_key = vol.Optional(CONF_POWER_ENTITY)
if pwr:
pwr_key = vol.Optional(CONF_POWER_ENTITY)
schema_dict[pwr_key] = sel["power"]
# Transport toggles (t41: both transports are ours; the skip paths and
# this page are the way to turn either one off)
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
# The ONE address field (t41), plus the ADB port
schema_dict[vol.Optional(CONF_HOST)] = str
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
# t29 G3b: suggested values come from the entry options via the
# documented helper — no per-key `default=` in the schema build.
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema_dict),
{**self._options_dict, **suggested_scripts},
),
errors=errors,
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):
"""Apps: the editable apps table — one row per app, blank when empty."""
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_apps_form(errors)
def _show_apps_form(self, errors=None):
schema_dict = {
# One ROW per line, exactly the stored shape and the documented
# editor format: `package=name` with an optional `|icon`. The field
# is pre-filled with the serialized map (never the dict), and the
# empty state is simply blank — no discovery data exists yet in this
# task, so there are no rows to invent and nothing to crash on.
vol.Optional(CONF_APP_NAME_MAP): str,
}
return self._show_sub_form("apps", schema_dict, errors)
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):
"""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.
"""
suggested = _options_field_values(self._options_dict)
for command, script in command_scripts(self._options_dict).items():
suggested[f"{SCRIPT_FIELD_PREFIX}{command}"] = script
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 {},
)
#: 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_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},
}
)