feat(config_flow): remote-protocol cert/PIN pairing step + reauth after device hard reset (shared .storage cert paths; ADB step body byte-intact, routes onward when protocol enabled)

This commit is contained in:
James Mackay committed 2026-10-08 09:32:27 +01:00
1 parent e37a3be75f
commit b5fc0dacf4
4 files changed
+193 -10

No files matched your search

+132 -2
View File
@@ -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/<domain>/`` (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"