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:
1 parent
74e0549196
commit
cd8f528a27
3 files changed
+808
-206
No files matched your search
@@ -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},
|
||||
}
|
||||
)
|
||||
Reference in new issue
Block a user