From 1dbb695cec62c40a5aa02433ed1759c98661a6fb Mon Sep 17 00:00:00 2001 From: James Mackay Date: Thu, 8 Oct 2026 10:57:31 +0100 Subject: [PATCH] =?UTF-8?q?feat(config=5Fflow):=20zeroconf=20discovery=20s?= =?UTF-8?q?tep=20=E2=80=94=20pre-filled=20user=20step=20from=20the=20disco?= =?UTF-8?q?very=20address,=20duplicate-MAC=20abort,=20IP-change=20host=20u?= =?UTF-8?q?pdate=20+=20reload=20for=20known=20entries=20(P4-1a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nvidia_shield/config_flow.py | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 5ace53b..71125e1 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -75,10 +75,14 @@ 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).""" +def _user_schema_dict(sel, defaults: dict | None = None) -> dict: + """User-step schema — the androidtv entity is optional (standalone). + + ``defaults`` pre-fills name/hosts from a zeroconf discovery event. + """ + defaults = defaults or {} schema_dict = { - vol.Required("name", default=DEFAULT_NAME): str, + vol.Required("name", default=defaults.get("name", 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"], @@ -96,6 +100,9 @@ def _user_schema_dict(sel) -> dict: 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 + 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 return schema_dict @@ -126,6 +133,9 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._user_data = {} self._protocol_client = None self._reauth_entry = None + # Zeroconf discovery state (P4-1) + self._discovered_host: str | None = None + self._discovered_name: str | None = None async def async_step_user(self, user_input=None): """Step 1: Basic setup. @@ -205,7 +215,13 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): sel = _build_selectors() - schema_dict = _user_schema_dict(sel) + defaults = {} + if self._discovered_host: + defaults = { + "name": self._discovered_name or DEFAULT_NAME, + "host": self._discovered_host, + } + schema_dict = _user_schema_dict(sel, defaults) return self.async_show_form( step_id="user", @@ -213,6 +229,47 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_zeroconf(self, discovery_info): + """Zeroconf discovery of the Android TV remote service (P4-1). + + Core androidtv_remote pattern, simplified: the discovery address + becomes the pre-filled protocol/ADB host on the user step; a known + MAC (from the service properties) aborts duplicate setup and + updates the existing entry's hosts on an IP change. + """ + host = str( + getattr(discovery_info, "ip_address", None) + or getattr(discovery_info, "host", None) + or "" + ).strip() + if not host: + return self.async_abort(reason="zeroconf_no_host") + + self._discovered_host = host + props = getattr(discovery_info, "properties", None) or {} + self._discovered_name = props.get("name") + + mac = props.get("mac") or props.get("unique_id") + if mac: + await self.async_set_unique_id(mac) + for entry in self._async_current_entries(): + if getattr(entry, "unique_id", None) != mac: + continue + # Known device — update hosts on IP change (core pattern) + options = { + **entry.options, + CONF_PROTOCOL_HOST: host, + CONF_ADB_HOST: host, + } + if options != entry.options: + self.hass.config_entries.async_update_entry(entry, options=options) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + self._abort_if_unique_id_configured() + + # First-time setup: pre-filled user step + return await self.async_step_user() + def _create_entry(self): """Create the config entry from stored user data.""" d = self._user_data