feat(config_flow): the G2 unique_id fix lands — the androidtv entity_id is never a unique_id; MAC derived from the device registry mac connection (empty placeholders skipped), else no unique_id; rename-stability + absence pinned by test (t29 G2)
This commit is contained in:
1 parent
086c089cc1
commit
e1a0fc3039
5 files changed
+228
-70
No files matched your search
@@ -13,7 +13,6 @@ from .adb_session import ensure_adb_key
|
|||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_MEDIA_DAG_INPUT_MAP,
|
ATTR_MEDIA_DAG_INPUT_MAP,
|
||||||
CHANNEL_DIGIT_DELAY,
|
|
||||||
CONF_ADB_COMMAND_ENABLED,
|
CONF_ADB_COMMAND_ENABLED,
|
||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_HOST,
|
CONF_ADB_HOST,
|
||||||
@@ -145,15 +144,15 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
|||||||
|
|
||||||
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
||||||
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool
|
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
||||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
||||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||||
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int
|
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
||||||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=False)] = bool
|
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
|
||||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
||||||
if "host" in defaults:
|
if "host" in defaults:
|
||||||
schema_dict[vol.Optional(CONF_ADB_HOST, default=defaults["host"])] = str
|
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST, default=defaults["host"])] = str
|
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
||||||
return schema_dict
|
return schema_dict
|
||||||
|
|
||||||
|
|
||||||
@@ -515,15 +514,30 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(config_entry):
|
def async_get_options_flow(config_entry):
|
||||||
return NvidiaShieldOptionsFlow(config_entry)
|
return NvidiaShieldOptionsFlow()
|
||||||
|
|
||||||
|
|
||||||
class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||||
"""Handle options for Nvidia Shield — single unified page."""
|
"""Handle options for Nvidia Shield — single unified page (t29 G3b).
|
||||||
|
|
||||||
def __init__(self, config_entry):
|
The constructor takes NO arguments: HA assigns ``self.config_entry``
|
||||||
self._config_entry = config_entry
|
on instantiation (the pre-2026 "OptionsFlow(config_entry)" pattern is
|
||||||
self._options = dict(config_entry.options)
|
deprecated). Suggested values come through
|
||||||
|
``add_suggested_values_to_schema``, and saving reloads via the
|
||||||
|
OptionsFlowWithReload base — no listener boomerangs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._options: dict | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _options_dict(self) -> dict:
|
||||||
|
if self._options is None:
|
||||||
|
if self.config_entry is not None:
|
||||||
|
self._options = dict(self.config_entry.options)
|
||||||
|
else:
|
||||||
|
self._options = {}
|
||||||
|
return self._options
|
||||||
|
|
||||||
async def async_step_init(self, user_input=None):
|
async def async_step_init(self, user_input=None):
|
||||||
"""Single-page options."""
|
"""Single-page options."""
|
||||||
@@ -542,7 +556,7 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
||||||
icons: dict[str, str] = {}
|
icons: dict[str, str] = {}
|
||||||
if app_map_text is None:
|
if app_map_text is None:
|
||||||
app_map = self._options.get(CONF_APP_NAME_MAP, {})
|
app_map = self._options_dict.get(CONF_APP_NAME_MAP, {})
|
||||||
elif app_map_text.strip() == "":
|
elif app_map_text.strip() == "":
|
||||||
app_map = {}
|
app_map = {}
|
||||||
else:
|
else:
|
||||||
@@ -551,14 +565,14 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
errors["base"] = "invalid_app_map"
|
errors["base"] = "invalid_app_map"
|
||||||
if not errors:
|
if not errors:
|
||||||
if delay is not None:
|
if delay is not None:
|
||||||
self._options[CONF_CHANNEL_DIGIT_DELAY] = delay
|
self._options_dict[CONF_CHANNEL_DIGIT_DELAY] = delay
|
||||||
self._options.update(user_input)
|
self._options_dict.update(user_input)
|
||||||
self._options[CONF_APP_NAME_MAP] = app_map
|
self._options_dict[CONF_APP_NAME_MAP] = app_map
|
||||||
# P5-2: the icon column — the editor is the source of
|
# P5-2: the icon column — the editor is the source of
|
||||||
# truth, so a save without icons clears them (and a
|
# truth, so a save without icons clears them (and a
|
||||||
# single-column map stays valid).
|
# single-column map stays valid).
|
||||||
self._options[CONF_APP_ICON_MAP] = icons
|
self._options_dict[CONF_APP_ICON_MAP] = icons
|
||||||
return self.async_create_entry(title="", data=self._options)
|
return self.async_create_entry(title="", data=self._options_dict)
|
||||||
|
|
||||||
sel = _build_selectors()
|
sel = _build_selectors()
|
||||||
schema_dict = {}
|
schema_dict = {}
|
||||||
@@ -566,46 +580,36 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
# Apps name/icon map editor (P4-1, core androidtv_remote options
|
# Apps name/icon map editor (P4-1, core androidtv_remote options
|
||||||
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
|
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
|
||||||
# the optional icon column).
|
# the optional icon column).
|
||||||
current_map = self._options.get(CONF_APP_NAME_MAP, {})
|
|
||||||
current_icons = self._options.get(CONF_APP_ICON_MAP, {})
|
|
||||||
if current_map:
|
|
||||||
app_map_default = _app_map_to_text(current_map, current_icons)
|
|
||||||
schema_dict[vol.Optional(CONF_APP_NAME_MAP, default=app_map_default)] = str
|
|
||||||
else:
|
|
||||||
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
|
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
|
||||||
|
|
||||||
# Jellyfin
|
# Jellyfin
|
||||||
jf = self._options.get(CONF_JELLYFIN_ENTITY)
|
jf = self._options_dict.get(CONF_JELLYFIN_ENTITY)
|
||||||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||||||
if jf:
|
if jf:
|
||||||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY, default=jf)
|
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||||||
schema_dict[jf_key] = sel["media_player"]
|
schema_dict[jf_key] = sel["media_player"]
|
||||||
|
|
||||||
# Receiver
|
# Receiver
|
||||||
recv = self._options.get(CONF_RECEIVER_ENTITY)
|
recv = self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||||
if recv:
|
if recv:
|
||||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv)
|
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||||
schema_dict[recv_key] = sel["receiver"]
|
schema_dict[recv_key] = sel["receiver"]
|
||||||
|
|
||||||
# P5-2: channel digit behavior (the user-validated §7 flow)
|
# P5-2: channel digit behavior (the user-validated §7 flow)
|
||||||
current_delay = self._options.get(CONF_CHANNEL_DIGIT_DELAY, CHANNEL_DIGIT_DELAY)
|
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
|
||||||
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY, default=current_delay)] = str
|
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
|
||||||
current_enter = self._options.get(CONF_CHANNEL_SEND_ENTER, False)
|
|
||||||
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER, default=current_enter)] = bool
|
|
||||||
|
|
||||||
# P5-3: the live events push is opt-in (default off; the user
|
# P5-3: the live events push is opt-in (default off; the user
|
||||||
# tests stability on their device and reports)
|
# tests stability on their device and reports)
|
||||||
current_logcat = self._options.get(CONF_LOGCAT_EVENTS, False)
|
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS)] = bool
|
||||||
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS, default=current_logcat)] = bool
|
|
||||||
|
|
||||||
# P4-2a: the raw adb_command service is opt-in (default off)
|
# P4-2a: the raw adb_command service is opt-in (default off)
|
||||||
current_flag = self._options.get(CONF_ADB_COMMAND_ENABLED, False)
|
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED)] = bool
|
||||||
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED, default=current_flag)] = bool
|
|
||||||
|
|
||||||
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
||||||
current_input = self._options.get(CONF_RECEIVER_INPUT, "")
|
current_input = self._options_dict.get(CONF_RECEIVER_INPUT, "")
|
||||||
receiver = self._options.get(CONF_RECEIVER_ENTITY)
|
receiver = self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||||||
inputs = _get_receiver_inputs(self.hass, receiver)
|
inputs = _get_receiver_inputs(self.hass, receiver)
|
||||||
|
|
||||||
if inputs:
|
if inputs:
|
||||||
@@ -618,43 +622,32 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
)
|
)
|
||||||
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
||||||
if current_input:
|
if current_input:
|
||||||
input_key = vol.Optional(CONF_RECEIVER_INPUT, default=current_input)
|
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
||||||
schema_dict[input_key] = input_sel
|
schema_dict[input_key] = input_sel
|
||||||
else:
|
else:
|
||||||
schema_dict[vol.Optional(
|
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
||||||
CONF_RECEIVER_INPUT, default=current_input
|
|
||||||
)] = str
|
|
||||||
|
|
||||||
# Power
|
# Power
|
||||||
pwr = self._options.get(CONF_POWER_ENTITY)
|
pwr = self._options_dict.get(CONF_POWER_ENTITY)
|
||||||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||||
if pwr:
|
if pwr:
|
||||||
pwr_key = vol.Optional(CONF_POWER_ENTITY, default=pwr)
|
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||||
schema_dict[pwr_key] = sel["power"]
|
schema_dict[pwr_key] = sel["power"]
|
||||||
|
|
||||||
# ADB options
|
# ADB options
|
||||||
schema_dict[vol.Optional(
|
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
||||||
CONF_ADB_ENABLED,
|
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
||||||
default=self._options.get(CONF_ADB_ENABLED, False),
|
|
||||||
)] = bool
|
|
||||||
schema_dict[vol.Optional(
|
|
||||||
CONF_ADB_STORE_APP_INFO,
|
|
||||||
default=self._options.get(CONF_ADB_STORE_APP_INFO, True),
|
|
||||||
)] = bool
|
|
||||||
|
|
||||||
# ADB host/port (optional overrides, shown when ADB is enabled)
|
# ADB host/port (optional overrides, shown when ADB is enabled)
|
||||||
adb_host = self._options.get(CONF_ADB_HOST, "")
|
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||||
schema_dict[vol.Optional(
|
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
||||||
CONF_ADB_HOST,
|
|
||||||
default=adb_host,
|
|
||||||
)] = str
|
|
||||||
schema_dict[vol.Optional(
|
|
||||||
CONF_ADB_PORT,
|
|
||||||
default=self._options.get(CONF_ADB_PORT, DEFAULT_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(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=vol.Schema(schema_dict),
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
|
vol.Schema(schema_dict), self._options_dict
|
||||||
|
),
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
+26
-1
@@ -163,7 +163,32 @@ def install() -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
config_entries.ConfigFlow = _config_flow_base
|
config_entries.ConfigFlow = _config_flow_base
|
||||||
config_entries.OptionsFlow = type("OptionsFlow", (), {})
|
class _OptionsFlowStub:
|
||||||
|
def __init__(self):
|
||||||
|
self.config_entry = None
|
||||||
|
|
||||||
|
def add_suggested_values_to_schema(self, schema, suggested):
|
||||||
|
# real HA merges hints; the stub records them on the schema dict
|
||||||
|
schema_dict = getattr(schema, "schema", None) or (
|
||||||
|
schema if isinstance(schema, dict) else {}
|
||||||
|
)
|
||||||
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class _OptionsFlowWithReload(_OptionsFlowStub):
|
||||||
|
"""Options flow that reloads after a successful save (real HA)."""
|
||||||
|
|
||||||
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||||
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||||
|
if entry_id is not None and scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry_id)
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
config_entries.OptionsFlow = _OptionsFlowStub
|
||||||
|
config_entries.OptionsFlowWithReload = _OptionsFlowWithReload
|
||||||
sys.modules["homeassistant.config_entries"] = config_entries
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
exceptions = types.ModuleType("homeassistant.exceptions")
|
exceptions = types.ModuleType("homeassistant.exceptions")
|
||||||
|
|||||||
@@ -76,7 +76,30 @@ def _install_import_stubs() -> None:
|
|||||||
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
||||||
|
|
||||||
config_entries.ConfigFlow = ConfigFlow
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
config_entries.OptionsFlow = type("OptionsFlow", (), {})
|
class _BaseOptionsFlow:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# t29 G3b: the current options-flow API surface on the local stub
|
||||||
|
class _SuggestedMixin:
|
||||||
|
def add_suggested_values_to_schema(self, schema, suggested):
|
||||||
|
schema_dict = getattr(schema, "schema", None) or (
|
||||||
|
schema if isinstance(schema, dict) else {}
|
||||||
|
)
|
||||||
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class OptionsFlowWithReload(_SuggestedMixin):
|
||||||
|
def async_create_entry(self, *, title, data):
|
||||||
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||||
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||||
|
if entry_id is not None and scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry_id)
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
config_entries.OptionsFlow = _BaseOptionsFlow
|
||||||
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||||
sys.modules["homeassistant.config_entries"] = config_entries
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
core = types.ModuleType("homeassistant.core")
|
core = types.ModuleType("homeassistant.core")
|
||||||
|
|||||||
@@ -132,6 +132,26 @@ def _install_stubs() -> None:
|
|||||||
|
|
||||||
class OptionsFlow:
|
class OptionsFlow:
|
||||||
pass
|
pass
|
||||||
|
# t29 G3b: the current options-flow API surface on the local stub
|
||||||
|
class _SuggestedMixin:
|
||||||
|
def add_suggested_values_to_schema(self, schema, suggested):
|
||||||
|
schema_dict = getattr(schema, "schema", None) or (
|
||||||
|
schema if isinstance(schema, dict) else {}
|
||||||
|
)
|
||||||
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class OptionsFlowWithReload(_SuggestedMixin):
|
||||||
|
def async_create_entry(self, *, title, data):
|
||||||
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||||
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||||
|
if entry_id is not None and scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry_id)
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# t29 G3a: the current config-entries API surface on the local stub
|
# t29 G3a: the current config-entries API surface on the local stub
|
||||||
ConfigFlow._get_reauth_entry = lambda self, entry=None: entry
|
ConfigFlow._get_reauth_entry = lambda self, entry=None: entry
|
||||||
@@ -148,6 +168,7 @@ def _install_stubs() -> None:
|
|||||||
|
|
||||||
config_entries.ConfigFlow = ConfigFlow
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
config_entries.OptionsFlow = OptionsFlow
|
config_entries.OptionsFlow = OptionsFlow
|
||||||
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
sys.modules["homeassistant.config_entries"] = config_entries
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ _STUB_NAMES = (
|
|||||||
"homeassistant.const",
|
"homeassistant.const",
|
||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.entity_registry",
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
"homeassistant.helpers.selector",
|
"homeassistant.helpers.selector",
|
||||||
"nsh.helpers",
|
"nsh.helpers",
|
||||||
"nsh.config_flow",
|
"nsh.config_flow",
|
||||||
@@ -94,6 +95,25 @@ def _install_stubs() -> None:
|
|||||||
def async_create_entry(self, *, title, data):
|
def async_create_entry(self, *, title, data):
|
||||||
return {"type": "create_entry", "title": title, "data": data}
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
# t29 G3b: the current options-flow API surface on the local stub
|
||||||
|
class _SuggestedMixin:
|
||||||
|
def add_suggested_values_to_schema(self, schema, suggested):
|
||||||
|
schema_dict = getattr(schema, "schema", None) or (
|
||||||
|
schema if isinstance(schema, dict) else {}
|
||||||
|
)
|
||||||
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class OptionsFlowWithReload(_SuggestedMixin):
|
||||||
|
def async_create_entry(self, *, title, data):
|
||||||
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||||
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||||
|
if entry_id is not None and scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry_id)
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
# Shared holder so tests can swap the current-entry list through the
|
# Shared holder so tests can swap the current-entry list through the
|
||||||
# module object (the stub closure reads this same object).
|
# module object (the stub closure reads this same object).
|
||||||
_zc_holder = types.SimpleNamespace(entries=[])
|
_zc_holder = types.SimpleNamespace(entries=[])
|
||||||
@@ -104,6 +124,8 @@ def _install_stubs() -> None:
|
|||||||
|
|
||||||
config_entries.ConfigFlow = ConfigFlow
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
config_entries.OptionsFlow = OptionsFlow
|
config_entries.OptionsFlow = OptionsFlow
|
||||||
|
OptionsFlowWithReload.async_show_form = OptionsFlow.async_show_form # form plumbing
|
||||||
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
sys.modules["homeassistant.config_entries"] = config_entries
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
@@ -131,6 +153,10 @@ def _install_stubs() -> None:
|
|||||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
sys.modules["homeassistant.helpers"] = helpers
|
sys.modules["homeassistant.helpers"] = helpers
|
||||||
|
|
||||||
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||||
|
device_registry.async_get = lambda hass: SimpleNamespace(devices={})
|
||||||
|
helpers.device_registry = device_registry
|
||||||
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||||
selector.EntitySelector = lambda *a, **k: None
|
selector.EntitySelector = lambda *a, **k: None
|
||||||
selector.EntitySelectorConfig = lambda *a, **k: ""
|
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||||
@@ -380,7 +406,8 @@ class TestAppIconColumn:
|
|||||||
async def test_two_column_line_stores_the_icon(self, tmp_path):
|
async def test_two_column_line_stores_the_icon(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
entry = SimpleNamespace(options={}, title="Shield")
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
flow = module.NvidiaShieldOptionsFlow(entry)
|
flow = module.NvidiaShieldOptionsFlow()
|
||||||
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
@@ -393,7 +420,8 @@ class TestAppIconColumn:
|
|||||||
async def test_single_column_map_stays_valid(self, tmp_path):
|
async def test_single_column_map_stays_valid(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
entry = SimpleNamespace(options={}, title="Shield")
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
flow = module.NvidiaShieldOptionsFlow(entry)
|
flow = module.NvidiaShieldOptionsFlow()
|
||||||
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
@@ -422,7 +450,8 @@ class TestAppIconColumn:
|
|||||||
async def test_malformed_icon_invalidates_the_row(self, tmp_path):
|
async def test_malformed_icon_invalidates_the_row(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
entry = SimpleNamespace(options={}, title="Shield")
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
flow = module.NvidiaShieldOptionsFlow(entry)
|
flow = module.NvidiaShieldOptionsFlow()
|
||||||
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
@@ -441,7 +470,8 @@ class TestChannelEntryOptions:
|
|||||||
|
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
entry = DefaultEntry(options=dict(options) if options else {})
|
entry = DefaultEntry(options=dict(options) if options else {})
|
||||||
flow = module.NvidiaShieldOptionsFlow(entry)
|
flow = module.NvidiaShieldOptionsFlow()
|
||||||
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
||||||
hass = FakeHass(str(tmp_path))
|
hass = FakeHass(str(tmp_path))
|
||||||
flow.hass = hass
|
flow.hass = hass
|
||||||
flow._options = dict(options) if options else {}
|
flow._options = dict(options) if options else {}
|
||||||
@@ -481,6 +511,71 @@ class TestChannelEntryOptions:
|
|||||||
assert result["errors"] == {"base": "invalid_digit_delay"}
|
assert result["errors"] == {"base": "invalid_digit_delay"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestUniqueIdSourceG2:
|
||||||
|
"""t29 G2: no unique_id equals the androidtv entity_id anymore."""
|
||||||
|
|
||||||
|
def test_entity_unique_id_fallback_is_gone(self):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
base = Path(__file__).parent.parent / "custom_components" / "nvidia_shield"
|
||||||
|
flow_src = (base / "config_flow.py").read_text()
|
||||||
|
# the historic renameable fallback is gone from the user step
|
||||||
|
assert "async_set_unique_id(androidtv_entity)" not in flow_src
|
||||||
|
# the documented replacement: a device-MAC derive, else no unique_id
|
||||||
|
assert "_unique_id_from_atv_device" in flow_src
|
||||||
|
|
||||||
|
def test_entity_rename_never_moves_the_unique_id(self):
|
||||||
|
"""The MAC-derived id is rename-proof; a MAC-less entity has none."""
|
||||||
|
|
||||||
|
|
||||||
|
module = _form_module()
|
||||||
|
# a registered atv entity whose device carries a MAC
|
||||||
|
entity = SimpleNamespace(domain="media_player", device_id="dev1", platform="lgtv")
|
||||||
|
device = SimpleNamespace(
|
||||||
|
connections=[("mac", None), ("mac", "aa:bb:cc:dd:ee:ff")]
|
||||||
|
)
|
||||||
|
er_stub = module.er
|
||||||
|
er_stub.async_get = lambda hass: SimpleNamespace(
|
||||||
|
entities={"media_player.shield": entity}
|
||||||
|
)
|
||||||
|
class _DevRegistry:
|
||||||
|
def __init__(self, by_id):
|
||||||
|
self._by_id = by_id
|
||||||
|
|
||||||
|
def async_get(self, device_id):
|
||||||
|
return self._by_id.get(device_id)
|
||||||
|
|
||||||
|
module.dr.async_get = lambda hass: _DevRegistry({"dev1": device})
|
||||||
|
|
||||||
|
# the resolved unique_id is the MAC — not the entity_id
|
||||||
|
uid = module._unique_id_from_atv_device(
|
||||||
|
FakeHass("/tmp"), "media_player.shield"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert uid == "aa:bb:cc:dd:ee:ff"
|
||||||
|
|
||||||
|
# renaming the entity does not affect the resolved value
|
||||||
|
er_stub.async_get = lambda hass: SimpleNamespace(
|
||||||
|
entities={"media_player.renamed": entity}
|
||||||
|
)
|
||||||
|
uid_renamed = module._unique_id_from_atv_device(
|
||||||
|
FakeHass("/tmp"), "media_player.renamed"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert uid_renamed == "aa:bb:cc:dd:ee:ff" # stable across renames
|
||||||
|
|
||||||
|
def test_macless_entity_yields_no_unique_id(self):
|
||||||
|
|
||||||
|
|
||||||
|
module = _form_module()
|
||||||
|
er_stub = module.er
|
||||||
|
er_stub.async_get = lambda hass: SimpleNamespace(entities={}) # unregistered
|
||||||
|
assert (
|
||||||
|
module._unique_id_from_atv_device(FakeHass("/tmp"), "media_player.x")
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestListenerComboGone:
|
class TestListenerComboGone:
|
||||||
"""t29 G3a: the deprecated listener+reload combo is gone (grep pins)."""
|
"""t29 G3a: the deprecated listener+reload combo is gone (grep pins)."""
|
||||||
def test_no_update_listener_remains_on_source_and_flow_paths(self):
|
def test_no_update_listener_remains_on_source_and_flow_paths(self):
|
||||||
@@ -801,7 +896,8 @@ class TestAppsMapOptionsEditor:
|
|||||||
def _options_flow(self, tmp_path, options):
|
def _options_flow(self, tmp_path, options):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
entry = SimpleNamespace(options=dict(options), title="Shield")
|
entry = SimpleNamespace(options=dict(options), title="Shield")
|
||||||
flow = module.NvidiaShieldOptionsFlow(entry)
|
flow = module.NvidiaShieldOptionsFlow()
|
||||||
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
return flow
|
return flow
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user