From d35b61078d6712b1c0c0d9e2c3e0a6cedd035e28 Mon Sep 17 00:00:00 2001 From: James Mackay Date: Thu, 8 Oct 2026 10:28:56 +0100 Subject: [PATCH] =?UTF-8?q?feat(config=5Fflow):=20standalone=20setup=20?= =?UTF-8?q?=E2=80=94=20androidtv=20entity=20optional=20with=20no=5Fstate?= =?UTF-8?q?=5Fsource=20validation,=20entry=20data=20omits=20the=20field=20?= =?UTF-8?q?for=20standalone=20entries,=20sensor=20tolerates=20its=20absenc?= =?UTF-8?q?e;=20migration=20keeps=20v1=20field=20/=20v2=20absence=20valid?= =?UTF-8?q?=20(P3-1a,d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nvidia_shield/config_flow.py | 75 +++-- custom_components/nvidia_shield/sensor.py | 2 +- custom_components/nvidia_shield/strings.json | 7 +- .../nvidia_shield/translations/en.json | 7 +- tests/test_standalone_setup.py | 300 ++++++++++++++++++ 5 files changed, 359 insertions(+), 32 deletions(-) create mode 100644 tests/test_standalone_setup.py diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 0198be8..f94c6f0 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -75,6 +75,30 @@ def _find_jellyfin_entity(hass, device_name: str) -> str | None: return None +def _user_schema_dict(sel) -> dict: + """User-step schema — the androidtv entity is optional (standalone).""" + schema_dict = { + vol.Required("name", default=DEFAULT_NAME): str, + vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"], + vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"], + vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"], + } + + # If there are any receivers with DAG input maps, show a text field + # for receiver input (will be a dropdown in options once receiver is set) + schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str + + schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"] + + 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_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_PROTOCOL_HOST)] = str + return schema_dict + + def _build_selectors(): """Build reusable selector instances.""" return { @@ -108,16 +132,32 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - androidtv_entity = user_input[CONF_ANDROIDTV_ENTITY] + # Standalone setup (Phase 3): the external androidtv media_player + # is optional — the protocol push can be the only state source. + androidtv_entity = user_input.get(CONF_ANDROIDTV_ENTITY) - await self.async_set_unique_id(androidtv_entity) - self._abort_if_unique_id_configured() + if not androidtv_entity and not user_input.get(CONF_PROTOCOL_ENABLED): + errors["base"] = "no_state_source" + sel = _build_selectors() + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(_user_schema_dict(sel)), + errors=errors, + ) + + if androidtv_entity: + await self.async_set_unique_id(androidtv_entity) + self._abort_if_unique_id_configured() # Auto-detect Jellyfin if not manually specified jellyfin = user_input.get(CONF_JELLYFIN_ENTITY) if not jellyfin: device_name = user_input.get("name", DEFAULT_NAME) - atv_state = self.hass.states.get(androidtv_entity) + atv_state = ( + self.hass.states.get(androidtv_entity) + if androidtv_entity + else None + ) atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "") for name in [device_name, atv_name]: @@ -131,7 +171,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._user_data = { "name": user_input.get("name", DEFAULT_NAME), - CONF_ANDROIDTV_ENTITY: androidtv_entity, + CONF_ANDROIDTV_ENTITY: androidtv_entity or "", CONF_JELLYFIN_ENTITY: jellyfin or "", CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""), CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""), @@ -156,25 +196,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): sel = _build_selectors() - schema_dict = { - vol.Required("name", default=DEFAULT_NAME): str, - vol.Required(CONF_ANDROIDTV_ENTITY): sel["androidtv"], - vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"], - vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"], - } - - # If there are any receivers with DAG input maps, show a text field - # for receiver input (will be a dropdown in options once receiver is set) - schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str - - schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"] - - 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_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_PROTOCOL_HOST)] = str + schema_dict = _user_schema_dict(sel) return self.async_show_form( step_id="user", @@ -185,9 +207,12 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _create_entry(self): """Create the config entry from stored user data.""" d = self._user_data + entry_data = {} + if d.get(CONF_ANDROIDTV_ENTITY): + entry_data[CONF_ANDROIDTV_ENTITY] = d[CONF_ANDROIDTV_ENTITY] return self.async_create_entry( title=d["name"], - data={CONF_ANDROIDTV_ENTITY: d[CONF_ANDROIDTV_ENTITY]}, + data=entry_data, options={ CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""), CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""), diff --git a/custom_components/nvidia_shield/sensor.py b/custom_components/nvidia_shield/sensor.py index 2ee132e..f2751c6 100644 --- a/custom_components/nvidia_shield/sensor.py +++ b/custom_components/nvidia_shield/sensor.py @@ -73,7 +73,7 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: super().__init__(hass, entry) - self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY] + self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "") self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app" def _manager(self) -> ShieldConnectionManager | None: diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json index bf90389..d1cc3c6 100644 --- a/custom_components/nvidia_shield/strings.json +++ b/custom_components/nvidia_shield/strings.json @@ -6,7 +6,7 @@ "description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.", "data": { "name": "Device Name", - "androidtv_entity": "Android TV Remote", + "androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.", "jellyfin_entity": "Jellyfin Media Player", "receiver_entity": "Receiver", "receiver_input": "Receiver Input", @@ -53,7 +53,8 @@ "adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.", "protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.", "protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.", - "protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again." + "protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again.", + "no_state_source": "No state source selected. Link the Android TV Remote entity or enable the Nvidia Remote Protocol pairing \u2014 the integration needs at least one source of power/app state." }, "abort": { "already_configured": "This Shield entity is already configured.", @@ -77,7 +78,7 @@ "adb_port": "ADB Port" }, "data_description": { - "jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.", + "jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.", "receiver_entity": "The receiver this device is connected to.", "receiver_input": "Which input on the receiver.", "power_entity": "Smart plug switch for reliable on/off detection.", diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json index bf90389..d1cc3c6 100644 --- a/custom_components/nvidia_shield/translations/en.json +++ b/custom_components/nvidia_shield/translations/en.json @@ -6,7 +6,7 @@ "description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.", "data": { "name": "Device Name", - "androidtv_entity": "Android TV Remote", + "androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.", "jellyfin_entity": "Jellyfin Media Player", "receiver_entity": "Receiver", "receiver_input": "Receiver Input", @@ -53,7 +53,8 @@ "adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.", "protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.", "protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.", - "protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again." + "protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again.", + "no_state_source": "No state source selected. Link the Android TV Remote entity or enable the Nvidia Remote Protocol pairing \u2014 the integration needs at least one source of power/app state." }, "abort": { "already_configured": "This Shield entity is already configured.", @@ -77,7 +78,7 @@ "adb_port": "ADB Port" }, "data_description": { - "jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.", + "jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.", "receiver_entity": "The receiver this device is connected to.", "receiver_input": "Which input on the receiver.", "power_entity": "Smart plug switch for reliable on/off detection.", diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py new file mode 100644 index 0000000..de38599 --- /dev/null +++ b/tests/test_standalone_setup.py @@ -0,0 +1,300 @@ +"""P3-1 tests: standalone setup — optional androidtv entity + app fallback. + +The integration no longer hard-requires an external androidtv media_player: +the protocol current-app push can be the only state source. Covers the flow +validation (no state source → form error), the standalone pairing route, +entry data omitting the field, media_player app_id/app_name fallback, and +migration validity for v2 entries without the field. +""" + +import asyncio # noqa: F401 (async steps need the event loop) +import sys +import types +from types import SimpleNamespace + +import pytest +from conftest import _SHIELD_DIR, _load_module +from nsh.const import ( + CONF_ANDROIDTV_ENTITY, + CONF_PROTOCOL_ENABLED, + CONF_PROTOCOL_HOST, +) + +_STUB_NAMES = ( + "voluptuous", + "homeassistant", + "homeassistant.config_entries", + "homeassistant.core", + "homeassistant.const", + "homeassistant.helpers", + "homeassistant.helpers.entity_registry", + "homeassistant.helpers.selector", + "nsh.helpers", + "nsh.config_flow", + "nsh.entry", +) + + +def _install_stubs() -> None: + """Import-time stand-ins (idempotent; same shape as the pairing tests).""" + if sys.modules.get("voluptuous") is not None: + return + + vol = types.ModuleType("voluptuous") + vol.Required = lambda key, default=None: key + vol.Optional = lambda key, default=None: key + vol.Schema = lambda schema: schema + sys.modules["voluptuous"] = vol + + ha = types.ModuleType("homeassistant") + sys.modules["homeassistant"] = ha + + config_entries = types.ModuleType("homeassistant.config_entries") + + class ConfigFlow: + VERSION = 1 + + def __init_subclass__(cls, **kwargs): + kwargs.pop("domain", None) + super().__init_subclass__(**kwargs) + + def async_show_form(self, *, step_id, data_schema=None, errors=None): + return {"type": "form", "step_id": step_id, "errors": dict(errors or {})} + + def async_create_entry(self, *, title, data, options=None): + return {"type": "create_entry", "title": title, "data": data, + "options": options} + + def async_abort(self, *, reason): + return {"type": "abort", "reason": reason} + + async def async_set_unique_id(self, unique_id): + self._unique_id = unique_id + + def _abort_if_unique_id_configured(self, *, error=None, **kwargs): + return None + + class OptionsFlow: + pass + + config_entries.ConfigFlow = ConfigFlow + config_entries.OptionsFlow = OptionsFlow + config_entries.ConfigEntry = type("ConfigEntry", (), {}) + sys.modules["homeassistant.config_entries"] = config_entries + + core = types.ModuleType("homeassistant.core") + core.HomeAssistant = object + core.callback = lambda fn: fn + sys.modules["homeassistant.core"] = core + + ha_const = types.ModuleType("homeassistant.const") + ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop" + + class _Platform: + MEDIA_PLAYER = "media_player" + SENSOR = "sensor" + REMOTE = "remote" + BUTTON = "button" + + ha_const.Platform = _Platform + sys.modules["homeassistant.const"] = ha_const + + helpers = types.ModuleType("homeassistant.helpers") + entity_registry = types.ModuleType("homeassistant.helpers.entity_registry") + entity_registry.async_get = lambda hass: SimpleNamespace(entities={}) + helpers.entity_registry = entity_registry + sys.modules["homeassistant.helpers.entity_registry"] = entity_registry + sys.modules["homeassistant.helpers"] = helpers + + selector = types.ModuleType("homeassistant.helpers.selector") + selector.EntitySelector = lambda *a, **k: None + selector.EntitySelectorConfig = lambda *a, **k: "" + selector.SelectSelector = lambda *a, **k: None + selector.SelectSelectorConfig = lambda **k: "" + selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown") + sys.modules["homeassistant.helpers.selector"] = selector + + +class FakeConfigEntries: + def __init__(self): + self.updating_entry_calls: list = [] + + def async_get_entry(self, entry_id): + return None + + def async_update_entry(self, entry, **kwargs): + self.updating_entry_calls.append((entry, kwargs)) + + +class FakeHass: + def __init__(self, tmp_dir: str, states: dict | None = None): + self.config = SimpleNamespace(config_dir=tmp_dir) + self.config_entries = FakeConfigEntries() + self._states = states or {} + + class _States: + def get(self, entity_id): + return self._states.get(entity_id) + + self._states_ref = _States() + self._states_ref._states = self._states + self.states = self._states_ref + + @staticmethod + async def async_add_executor_job(fn, *args): + return fn(*args) + + +@pytest.fixture(autouse=True) +def scoped_stubs(): + """Keep the import stubs inside this module's tests (t7 discipline).""" + snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES} + _install_stubs() + yield + for name in _STUB_NAMES: + original = snapshot[name] + if original is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = original + + +def _form_module(): + _install_stubs() + if "nsh.helpers" not in sys.modules: + _load_module("nsh.helpers", _SHIELD_DIR / "helpers.py") + if "nsh.config_flow" not in sys.modules: + _load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py") + return sys.modules["nsh.config_flow"] + + +def _entry_module(): + _install_stubs() + if "nsh.entry" not in sys.modules: + _load_module("nsh.entry", _SHIELD_DIR / "__init__.py") + return sys.modules["nsh.entry"] + + +def _user_input(**overrides) -> dict: + base = { + "name": "mdarcy", + CONF_ANDROIDTV_ENTITY: "media_player.shield", + CONF_PROTOCOL_ENABLED: False, + CONF_PROTOCOL_HOST: "", + } + base.update(overrides) + return base + + +class TestStandaloneValidation: + async def test_neither_source_shows_form_error(self, tmp_path): + module = _form_module() + flow = module.NvidiaShieldConfigFlow() + flow.hass = FakeHass(str(tmp_path)) + + result = await flow.async_step_user(_user_input(**{CONF_ANDROIDTV_ENTITY: ""})) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_state_source"} + + async def test_androidtv_entity_alone_is_a_valid_source(self, tmp_path): + module = _form_module() + flow = module.NvidiaShieldConfigFlow() + flow.hass = FakeHass(str(tmp_path)) + + result = await flow.async_step_user(_user_input()) + + assert result["type"] == "create_entry" + assert result["data"] == {CONF_ANDROIDTV_ENTITY: "media_player.shield"} + + async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch): + module = _form_module() + flow = module.NvidiaShieldConfigFlow() + flow.hass = FakeHass(str(tmp_path)) + flow._user_data = {} + from nsh.connection import ShieldConnectionManager + + class _Client: + async def async_generate_cert_if_missing(self): + return True + + async def async_start_pairing(self): + return "8421" + + monkeypatch.setattr( + ShieldConnectionManager, "build_protocol_client", + lambda *a, **k: _Client(), + ) + monkeypatch.setattr( + module, "protocol_cert_paths", lambda hass: ("/tmp/a.pem", "/tmp/a.key") + ) + + result = await flow.async_step_user( + _user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True}) + ) + + assert result["type"] == "form" + assert result["step_id"] == "protocol_pair" # routed into pairing, no error + + async def test_standalone_entry_data_omits_the_field(self, tmp_path, monkeypatch): + """No androidtv entity → the key is absent from entry data (not "").""" + module = _form_module() + flow = module.NvidiaShieldConfigFlow() + flow.hass = FakeHass(str(tmp_path)) + flow._user_data = { + "name": "mdarcy", + CONF_ANDROIDTV_ENTITY: "", + CONF_PROTOCOL_ENABLED: True, + CONF_PROTOCOL_HOST: "shield.local", + } + + # skip the pairing route: drive _create_entry directly + result = flow._create_entry() + + assert result["type"] == "create_entry" + assert CONF_ANDROIDTV_ENTITY not in result["data"] + + +class TestMigrationKeepsOrAllowsAbsence: + async def test_v1_to_v2_keeps_the_androidtv_field(self, tmp_path): + module = _entry_module() + hass = FakeHass(str(tmp_path)) + entry = SimpleNamespace( + version=1, + data={CONF_ANDROIDTV_ENTITY: "media_player.shield"}, + options={}, + title="mdarcy", + ) + + ok = await module.async_migrate_entry(hass, entry) + + assert ok is True + assert entry.data[CONF_ANDROIDTV_ENTITY] == "media_player.shield" # kept + # options were rewritten with the protocol defaults; the data dict — + # including the androidtv field — was never touched + assert hass.config_entries.updating_entry_calls == [ + ( + entry, + { + "options": {CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_HOST: ""}, + "version": 2, + }, + ) + ] + + async def test_v2_without_the_field_is_valid(self, tmp_path): + """Standalone entries born v2 must pass through migration untouched.""" + module = _entry_module() + hass = FakeHass(str(tmp_path)) + entry = SimpleNamespace( + version=2, + data={}, + options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"}, + title="mdarcy", + ) + + ok = await module.async_migrate_entry(hass, entry) + + assert ok is True + assert hass.config_entries.updating_entry_calls == [] # no rewrite needed