diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 4168045..728918a 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -13,7 +13,6 @@ from .adb_session import ensure_adb_key from .connection import ShieldConnectionManager from .const import ( ATTR_MEDIA_DAG_INPUT_MAP, - CHANNEL_DIGIT_DELAY, CONF_ADB_COMMAND_ENABLED, CONF_ADB_ENABLED, 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_ADB_ENABLED, default=False)] = bool - schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool + schema_dict[vol.Optional(CONF_ADB_ENABLED)] = 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_PORT, default=DEFAULT_ADB_PORT)] = int - schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=False)] = bool + schema_dict[vol.Optional(CONF_ADB_PORT)] = int + schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str if "host" in defaults: - schema_dict[vol.Optional(CONF_ADB_HOST, default=defaults["host"])] = str - schema_dict[vol.Optional(CONF_PROTOCOL_HOST, default=defaults["host"])] = str + schema_dict[vol.Optional(CONF_ADB_HOST)] = str + schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str return schema_dict @@ -515,15 +514,30 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow(config_entry): - return NvidiaShieldOptionsFlow(config_entry) + return NvidiaShieldOptionsFlow() -class NvidiaShieldOptionsFlow(config_entries.OptionsFlow): - """Handle options for Nvidia Shield — single unified page.""" +class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): + """Handle options for Nvidia Shield — single unified page (t29 G3b). - def __init__(self, config_entry): - self._config_entry = config_entry - self._options = dict(config_entry.options) + 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. + """ + + 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): """Single-page options.""" @@ -542,7 +556,7 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow): 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.get(CONF_APP_NAME_MAP, {}) + app_map = self._options_dict.get(CONF_APP_NAME_MAP, {}) elif app_map_text.strip() == "": app_map = {} else: @@ -551,14 +565,14 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow): errors["base"] = "invalid_app_map" if not errors: if delay is not None: - self._options[CONF_CHANNEL_DIGIT_DELAY] = delay - self._options.update(user_input) - self._options[CONF_APP_NAME_MAP] = app_map + 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[CONF_APP_ICON_MAP] = icons - return self.async_create_entry(title="", data=self._options) + self._options_dict[CONF_APP_ICON_MAP] = icons + return self.async_create_entry(title="", data=self._options_dict) sel = _build_selectors() schema_dict = {} @@ -566,46 +580,36 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow): # 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). - 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 - jf = self._options.get(CONF_JELLYFIN_ENTITY) + jf = self._options_dict.get(CONF_JELLYFIN_ENTITY) jf_key = vol.Optional(CONF_JELLYFIN_ENTITY) 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"] # Receiver - recv = self._options.get(CONF_RECEIVER_ENTITY) + recv = self._options_dict.get(CONF_RECEIVER_ENTITY) recv_key = vol.Optional(CONF_RECEIVER_ENTITY) if recv: - recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv) + recv_key = vol.Optional(CONF_RECEIVER_ENTITY) schema_dict[recv_key] = sel["receiver"] # 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, default=current_delay)] = str - current_enter = self._options.get(CONF_CHANNEL_SEND_ENTER, False) - schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER, default=current_enter)] = bool + 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) - current_logcat = self._options.get(CONF_LOGCAT_EVENTS, False) - schema_dict[vol.Optional(CONF_LOGCAT_EVENTS, default=current_logcat)] = bool + schema_dict[vol.Optional(CONF_LOGCAT_EVENTS)] = bool # 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, default=current_flag)] = bool + schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED)] = bool # Receiver input — dropdown from DAG if receiver is set, text otherwise - current_input = self._options.get(CONF_RECEIVER_INPUT, "") - receiver = self._options.get(CONF_RECEIVER_ENTITY) + 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: @@ -618,43 +622,32 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow): ) input_key = vol.Optional(CONF_RECEIVER_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 else: - schema_dict[vol.Optional( - CONF_RECEIVER_INPUT, default=current_input - )] = str + schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str # Power - pwr = self._options.get(CONF_POWER_ENTITY) + pwr = self._options_dict.get(CONF_POWER_ENTITY) pwr_key = vol.Optional(CONF_POWER_ENTITY) if pwr: - pwr_key = vol.Optional(CONF_POWER_ENTITY, default=pwr) + pwr_key = vol.Optional(CONF_POWER_ENTITY) schema_dict[pwr_key] = sel["power"] # ADB options - schema_dict[vol.Optional( - CONF_ADB_ENABLED, - 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 + schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool + schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool # 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, - default=adb_host, - )] = str - schema_dict[vol.Optional( - CONF_ADB_PORT, - default=self._options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT), - )] = int + schema_dict[vol.Optional(CONF_ADB_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=vol.Schema(schema_dict), + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema_dict), self._options_dict + ), errors=errors, ) diff --git a/tests/entity_stublib.py b/tests/entity_stublib.py index 04885ef..8f99814 100644 --- a/tests/entity_stublib.py +++ b/tests/entity_stublib.py @@ -163,7 +163,32 @@ def install() -> None: }, ) 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 exceptions = types.ModuleType("homeassistant.exceptions") diff --git a/tests/test_config_flow_pairing.py b/tests/test_config_flow_pairing.py index 2849f6e..b17ede4 100644 --- a/tests/test_config_flow_pairing.py +++ b/tests/test_config_flow_pairing.py @@ -76,7 +76,30 @@ def _install_import_stubs() -> None: return {"type": "form", "step_id": step_id, "errors": dict(errors)} 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 core = types.ModuleType("homeassistant.core") diff --git a/tests/test_config_flow_protocol.py b/tests/test_config_flow_protocol.py index 1d441aa..a2623fc 100644 --- a/tests/test_config_flow_protocol.py +++ b/tests/test_config_flow_protocol.py @@ -132,6 +132,26 @@ def _install_stubs() -> None: class OptionsFlow: 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 ConfigFlow._get_reauth_entry = lambda self, entry=None: entry @@ -148,6 +168,7 @@ def _install_stubs() -> None: config_entries.ConfigFlow = ConfigFlow config_entries.OptionsFlow = OptionsFlow + config_entries.OptionsFlowWithReload = OptionsFlowWithReload config_entries.ConfigEntry = type("ConfigEntry", (), {}) sys.modules["homeassistant.config_entries"] = config_entries diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py index 370fd51..728b953 100644 --- a/tests/test_standalone_setup.py +++ b/tests/test_standalone_setup.py @@ -28,6 +28,7 @@ _STUB_NAMES = ( "homeassistant.const", "homeassistant.helpers", "homeassistant.helpers.entity_registry", + "homeassistant.helpers.device_registry", "homeassistant.helpers.selector", "nsh.helpers", "nsh.config_flow", @@ -94,6 +95,25 @@ def _install_stubs() -> None: def async_create_entry(self, *, title, 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 # module object (the stub closure reads this same object). _zc_holder = types.SimpleNamespace(entries=[]) @@ -104,6 +124,8 @@ def _install_stubs() -> None: config_entries.ConfigFlow = ConfigFlow config_entries.OptionsFlow = OptionsFlow + OptionsFlowWithReload.async_show_form = OptionsFlow.async_show_form # form plumbing + config_entries.OptionsFlowWithReload = OptionsFlowWithReload config_entries.ConfigEntry = type("ConfigEntry", (), {}) 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"] = 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.EntitySelector = lambda *a, **k: None selector.EntitySelectorConfig = lambda *a, **k: "" @@ -380,7 +406,8 @@ class TestAppIconColumn: async def test_two_column_line_stores_the_icon(self, tmp_path): module = _form_module() 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.async_show_form = lambda **kwargs: {"type": "form", **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): module = _form_module() 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.async_show_form = lambda **kwargs: {"type": "form", **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): module = _form_module() 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.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} @@ -441,7 +470,8 @@ class TestChannelEntryOptions: module = _form_module() 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)) flow.hass = hass flow._options = dict(options) if options else {} @@ -481,6 +511,71 @@ class TestChannelEntryOptions: 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: """t29 G3a: the deprecated listener+reload combo is gone (grep pins).""" 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): module = _form_module() 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)) return flow