diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 0215656..8b4c87d 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Nvidia Shield integration.""" +import contextlib import logging import voluptuous as vol @@ -18,14 +19,17 @@ from .const import ( CONF_ADB_STORE_APP_INFO, CONF_ANDROIDTV_ENTITY, CONF_JELLYFIN_ENTITY, + CONF_PAIRING_CODE, CONF_POWER_ENTITY, + CONF_PROTOCOL_ENABLED, + CONF_PROTOCOL_HOST, CONF_RECEIVER_ENTITY, CONF_RECEIVER_INPUT, DEFAULT_ADB_PORT, DEFAULT_NAME, DOMAIN, ) -from .helpers import discover_adb_host +from .helpers import discover_adb_host, protocol_cert_paths _LOGGER = logging.getLogger(__name__) @@ -96,6 +100,8 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): self._user_data = {} + self._protocol_client = None + self._reauth_entry = None async def async_step_user(self, user_input=None): """Step 1: Basic setup.""" @@ -134,12 +140,17 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True), CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""), CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT), + CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, False), + CONF_PROTOCOL_HOST: user_input.get(CONF_PROTOCOL_HOST, ""), } # 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() @@ -162,6 +173,8 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 self.async_show_form( step_id="user", @@ -184,9 +197,126 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True), CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""), CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT), + CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, False), + CONF_PROTOCOL_HOST: d.get(CONF_PROTOCOL_HOST, ""), }, ) + 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() + + def _protocol_host(self) -> str | None: + """Protocol host: explicit override, else the ADB host fallback.""" + return self._user_data.get(CONF_PROTOCOL_HOST) or self._resolve_adb_host() + + async def _start_protocol_pairing(self): + """Build a throwaway protocol client and make the Shield show its PIN. + + Certificates live under ``.storage//`` (core androidtv_remote + helpers.py pattern) and are shared between pairing and runtime. + """ + host = self._protocol_host() + if not host: + return None + certfile, keyfile = await self.hass.async_add_executor_job( + protocol_cert_paths, self.hass + ) + client = ShieldConnectionManager.build_protocol_client( + host, + client_name=self._user_data.get("name", DEFAULT_NAME) or DEFAULT_NAME, + certfile=certfile, + keyfile=keyfile, + ) + await client.async_generate_cert_if_missing() + await client.async_start_pairing() + return client + + async def async_step_protocol_pair(self, user_input=None): + """Pair with the remote protocol — the PIN is displayed on the Shield.""" + errors = {} + if user_input is None: + # First visit: proactively start pairing so the PIN shows on the + # Shield while the form is open. + client = await self._start_protocol_pairing() + if client is None: + errors["base"] = "protocol_no_host" + else: + self._protocol_client = client + else: + client = self._protocol_client + code = user_input.get(CONF_PAIRING_CODE, "") + if client is None or not code: + errors["base"] = "protocol_invalid_code" + else: + try: + await client.async_finish_pairing(code) + except Exception: + errors["base"] = "protocol_pair_failed" + else: + try: + _name, mac = await client.async_get_name_and_mac() + except Exception: + mac = None + with contextlib.suppress(Exception): + client.disconnect() + self._protocol_client = None + if mac: + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + if self._reauth_entry is not None: + return self._finish_reauth() + return self._create_entry() + return self.async_show_form( + step_id="protocol_pair", + data_schema=vol.Schema({ + vol.Required(CONF_PAIRING_CODE): str, + }), + errors=errors, + ) + + async def async_step_reauth(self, entry_data): + """Device hard reset invalidated our certificates (review trigger).""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context.get("entry_id") + ) + if self._reauth_entry is None: + return self.async_abort(reason="reauth_entry_missing") + return self.async_show_form( + step_id="reauth_confirm", data_schema=vol.Schema({}) + ) + + async def async_step_reauth_confirm(self, user_input=None): + """Re-pair after a device hard reset, then reload the entry.""" + if user_input is not None: + entry = self._reauth_entry + self._user_data = { + "name": entry.title, + CONF_PROTOCOL_ENABLED: True, + CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""), + } + return await self.async_step_protocol_pair() # first visit re-pairs + return self.async_show_form( + step_id="reauth_confirm", data_schema=vol.Schema({}) + ) + + def _finish_reauth(self): + """Reauth pairing done: update options, schedule the reload, abort.""" + entry = self._reauth_entry + self.hass.config_entries.async_update_entry( + entry, + options={ + **entry.options, + CONF_PROTOCOL_ENABLED: True, + CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""), + }, + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + self._reauth_entry = None + return self.async_abort(reason="reauth_successful") + def _resolve_adb_host(self) -> str | None: """Resolve ADB host from explicit config or the Android TV entity.""" return self._user_data.get(CONF_ADB_HOST) or discover_adb_host( @@ -212,7 +342,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host, port, key_path ) if connected: - return self._create_entry() + return await self._async_after_pairing() else: errors["base"] = "adb_failed" diff --git a/custom_components/nvidia_shield/helpers.py b/custom_components/nvidia_shield/helpers.py index b9c630c..3c52f7b 100644 --- a/custom_components/nvidia_shield/helpers.py +++ b/custom_components/nvidia_shield/helpers.py @@ -29,6 +29,19 @@ def adb_key_path(hass: HomeAssistant) -> str: return os.path.join(hass.config.config_dir, ".storage", DOMAIN, "adbkey") +def protocol_cert_paths(hass: HomeAssistant) -> tuple[str, str]: + """Local (certfile, keyfile) paths for the remote protocol client. + + Mirrors core androidtv_remote's storage pattern: private material under + ``.storage//``. Shared between config-flow pairing and runtime — + an entry aborts duplicate setup by unique_id (= device MAC), so one pair + of files suffices per install. + """ + base = os.path.join(hass.config.config_dir, ".storage", DOMAIN) + os.makedirs(base, exist_ok=True) + return os.path.join(base, "client.pem"), os.path.join(base, "client.key") + + def discover_adb_host( hass: HomeAssistant, androidtv_entity_id: str | None ) -> str | None: diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json index 64b1494..bf90389 100644 --- a/custom_components/nvidia_shield/strings.json +++ b/custom_components/nvidia_shield/strings.json @@ -14,7 +14,9 @@ "adb_enabled": "Enable ADB Media Session", "adb_store_app_info": "Show App Media Info", "adb_host": "ADB Host", - "adb_port": "ADB Port" + "adb_port": "ADB Port", + "protocol_enabled": "Pair with Nvidia Remote Protocol", + "protocol_host": "Protocol Host" }, "data_description": { "androidtv_entity": "The media_player entity from the Android TV Remote integration.", @@ -25,20 +27,38 @@ "adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.", "adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).", "adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.", - "adb_port": "ADB port number. Default is 5555." + "adb_port": "ADB port number. Default is 5555.", + "protocol_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.", + "protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host)." } }, "adb_pair": { "title": "Pair ADB", "description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again." + }, + "protocol_pair": { + "title": "Pair Remote Protocol", + "description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.", + "data": { + "pairing_code": "Pairing code shown on the Shield" + } + }, + "reauth_confirm": { + "title": "Re-pair Nvidia Shield", + "description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the Shield." } }, "error": { "adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.", - "adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step." + "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." }, "abort": { - "already_configured": "This Shield entity is already configured." + "already_configured": "This Shield entity is already configured.", + "reauth_successful": "Re-paired successfully.", + "reauth_entry_missing": "This entry no longer exists." } }, "options": { diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json index 64b1494..bf90389 100644 --- a/custom_components/nvidia_shield/translations/en.json +++ b/custom_components/nvidia_shield/translations/en.json @@ -14,7 +14,9 @@ "adb_enabled": "Enable ADB Media Session", "adb_store_app_info": "Show App Media Info", "adb_host": "ADB Host", - "adb_port": "ADB Port" + "adb_port": "ADB Port", + "protocol_enabled": "Pair with Nvidia Remote Protocol", + "protocol_host": "Protocol Host" }, "data_description": { "androidtv_entity": "The media_player entity from the Android TV Remote integration.", @@ -25,20 +27,38 @@ "adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.", "adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).", "adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.", - "adb_port": "ADB port number. Default is 5555." + "adb_port": "ADB port number. Default is 5555.", + "protocol_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.", + "protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host)." } }, "adb_pair": { "title": "Pair ADB", "description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again." + }, + "protocol_pair": { + "title": "Pair Remote Protocol", + "description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.", + "data": { + "pairing_code": "Pairing code shown on the Shield" + } + }, + "reauth_confirm": { + "title": "Re-pair Nvidia Shield", + "description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the Shield." } }, "error": { "adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.", - "adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step." + "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." }, "abort": { - "already_configured": "This Shield entity is already configured." + "already_configured": "This Shield entity is already configured.", + "reauth_successful": "Re-paired successfully.", + "reauth_entry_missing": "This entry no longer exists." } }, "options": {