Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for Android TV Remote integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 import logging
7 from typing import Any
8 
9 from androidtvremote2 import (
10  AndroidTVRemote,
11  CannotConnect,
12  ConnectionClosed,
13  InvalidAuth,
14 )
15 import voluptuous as vol
16 
17 from homeassistant.components import zeroconf
18 from homeassistant.config_entries import (
19  SOURCE_REAUTH,
20  ConfigEntry,
21  ConfigFlow,
22  ConfigFlowResult,
23  OptionsFlow,
24 )
25 from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
26 from homeassistant.core import callback
27 from homeassistant.helpers.device_registry import format_mac
29  SelectOptionDict,
30  SelectSelector,
31  SelectSelectorConfig,
32  SelectSelectorMode,
33 )
34 
35 from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN
36 from .helpers import create_api, get_enable_ime
37 
38 _LOGGER = logging.getLogger(__name__)
39 
40 APPS_NEW_ID = "NewApp"
41 CONF_APP_DELETE = "app_delete"
42 CONF_APP_ID = "app_id"
43 
44 STEP_USER_DATA_SCHEMA = vol.Schema(
45  {
46  vol.Required("host"): str,
47  }
48 )
49 
50 STEP_PAIR_DATA_SCHEMA = vol.Schema(
51  {
52  vol.Required("pin"): str,
53  }
54 )
55 
56 
57 class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
58  """Handle a config flow for Android TV Remote."""
59 
60  VERSION = 1
61 
62  api: AndroidTVRemote
63  host: str
64  name: str
65  mac: str
66 
67  async def async_step_user(
68  self, user_input: dict[str, Any] | None = None
69  ) -> ConfigFlowResult:
70  """Handle the initial step."""
71  errors: dict[str, str] = {}
72  if user_input is not None:
73  self.hosthost = user_input[CONF_HOST]
74  api = create_api(self.hass, self.hosthost, enable_ime=False)
75  try:
76  await api.async_generate_cert_if_missing()
77  self.namename, self.macmac = await api.async_get_name_and_mac()
78  await self.async_set_unique_idasync_set_unique_id(format_mac(self.macmac))
79  self._abort_if_unique_id_configured_abort_if_unique_id_configured(updates={CONF_HOST: self.hosthost})
80  return await self._async_start_pair_async_start_pair()
81  except (CannotConnect, ConnectionClosed):
82  # Likely invalid IP address or device is network unreachable. Stay
83  # in the user step allowing the user to enter a different host.
84  errors["base"] = "cannot_connect"
85  return self.async_show_formasync_show_formasync_show_form(
86  step_id="user",
87  data_schema=STEP_USER_DATA_SCHEMA,
88  errors=errors,
89  )
90 
91  async def _async_start_pair(self) -> ConfigFlowResult:
92  """Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen."""
93  self.apiapi = create_api(self.hass, self.hosthost, enable_ime=False)
94  await self.apiapi.async_generate_cert_if_missing()
95  await self.apiapi.async_start_pairing()
96  return await self.async_step_pairasync_step_pair()
97 
98  async def async_step_pair(
99  self, user_input: dict[str, Any] | None = None
100  ) -> ConfigFlowResult:
101  """Handle the pair step."""
102  errors: dict[str, str] = {}
103  if user_input is not None:
104  try:
105  pin = user_input["pin"]
106  await self.apiapi.async_finish_pairing(pin)
107  if self.sourcesourcesourcesource == SOURCE_REAUTH:
108  await self.hass.config_entries.async_reload(
109  self._get_reauth_entry_get_reauth_entry().entry_id
110  )
111  return self.async_abortasync_abortasync_abort(reason="reauth_successful")
112  return self.async_create_entryasync_create_entryasync_create_entry(
113  title=self.namename,
114  data={
115  CONF_HOST: self.hosthost,
116  CONF_NAME: self.namename,
117  CONF_MAC: self.macmac,
118  },
119  )
120  except InvalidAuth:
121  # Invalid PIN. Stay in the pair step allowing the user to enter
122  # a different PIN.
123  errors["base"] = "invalid_auth"
124  except ConnectionClosed:
125  # Either user canceled pairing on the Android TV itself (most common)
126  # or device doesn't respond to the specified host (device was unplugged,
127  # network was unplugged, or device got a new IP address).
128  # Attempt to pair again.
129  try:
130  return await self._async_start_pair_async_start_pair()
131  except (CannotConnect, ConnectionClosed):
132  # Device doesn't respond to the specified host. Abort.
133  # If we are in the user flow we could go back to the user step to allow
134  # them to enter a new IP address but we cannot do that for the zeroconf
135  # flow. Simpler to abort for both flows.
136  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
137  return self.async_show_formasync_show_formasync_show_form(
138  step_id="pair",
139  data_schema=STEP_PAIR_DATA_SCHEMA,
140  description_placeholders={CONF_NAME: self.namename},
141  errors=errors,
142  )
143 
145  self, discovery_info: zeroconf.ZeroconfServiceInfo
146  ) -> ConfigFlowResult:
147  """Handle zeroconf discovery."""
148  _LOGGER.debug("Android TV device found via zeroconf: %s", discovery_info)
149  self.hosthost = discovery_info.host
150  self.namename = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.")
151  if not (mac := discovery_info.properties.get("bt")):
152  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
153  self.macmac = mac
154  existing_config_entry = await self.async_set_unique_idasync_set_unique_id(format_mac(mac))
155  # Sometimes, devices send an invalid zeroconf message with multiple addresses
156  # and one of them, which could end up being in discovery_info.host, is from a
157  # different device. If any of the discovery_info.ip_addresses matches the
158  # existing host, don't update the host.
159  if existing_config_entry and len(discovery_info.ip_addresses) > 1:
160  existing_host = existing_config_entry.data[CONF_HOST]
161  if existing_host != self.hosthost:
162  if existing_host in [
163  str(ip_address) for ip_address in discovery_info.ip_addresses
164  ]:
165  self.hosthost = existing_host
166  self._abort_if_unique_id_configured_abort_if_unique_id_configured(
167  updates={CONF_HOST: self.hosthost, CONF_NAME: self.namename}
168  )
169  _LOGGER.debug("New Android TV device found via zeroconf: %s", self.namename)
170  self.context.update({"title_placeholders": {CONF_NAME: self.namename}})
171  return await self.async_step_zeroconf_confirmasync_step_zeroconf_confirm()
172 
174  self, user_input: dict[str, Any] | None = None
175  ) -> ConfigFlowResult:
176  """Handle a flow initiated by zeroconf."""
177  if user_input is not None:
178  try:
179  return await self._async_start_pair_async_start_pair()
180  except (CannotConnect, ConnectionClosed):
181  # Device became network unreachable after discovery.
182  # Abort and let discovery find it again later.
183  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
184  return self.async_show_formasync_show_formasync_show_form(
185  step_id="zeroconf_confirm",
186  description_placeholders={CONF_NAME: self.namename},
187  )
188 
189  async def async_step_reauth(
190  self, entry_data: Mapping[str, Any]
191  ) -> ConfigFlowResult:
192  """Handle configuration by re-auth."""
193  self.hosthost = entry_data[CONF_HOST]
194  self.namename = entry_data[CONF_NAME]
195  self.macmac = entry_data[CONF_MAC]
196  return await self.async_step_reauth_confirmasync_step_reauth_confirm()
197 
199  self, user_input: dict[str, Any] | None = None
200  ) -> ConfigFlowResult:
201  """Dialog that informs the user that reauth is required."""
202  errors: dict[str, str] = {}
203  if user_input is not None:
204  try:
205  return await self._async_start_pair_async_start_pair()
206  except (CannotConnect, ConnectionClosed):
207  # Device is network unreachable. Abort.
208  errors["base"] = "cannot_connect"
209  return self.async_show_formasync_show_formasync_show_form(
210  step_id="reauth_confirm",
211  description_placeholders={CONF_NAME: self.namename},
212  errors=errors,
213  )
214 
215  @staticmethod
216  @callback
218  config_entry: ConfigEntry,
219  ) -> AndroidTVRemoteOptionsFlowHandler:
220  """Create the options flow."""
221  return AndroidTVRemoteOptionsFlowHandler(config_entry)
222 
223 
225  """Android TV Remote options flow."""
226 
227  def __init__(self, config_entry: ConfigEntry) -> None:
228  """Initialize options flow."""
229  self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {}))
230  self._conf_app_id_conf_app_id: str | None = None
231 
232  @callback
233  def _save_config(self, data: dict[str, Any]) -> ConfigFlowResult:
234  """Save the updated options."""
235  new_data = {k: v for k, v in data.items() if k not in [CONF_APPS]}
236  if self._apps:
237  new_data[CONF_APPS] = self._apps
238 
239  return self.async_create_entryasync_create_entry(title="", data=new_data)
240 
241  async def async_step_init(
242  self, user_input: dict[str, Any] | None = None
243  ) -> ConfigFlowResult:
244  """Manage the options."""
245  if user_input is not None:
246  if sel_app := user_input.get(CONF_APPS):
247  return await self.async_step_appsasync_step_apps(None, sel_app)
248  return self._save_config_save_config(user_input)
249 
250  apps_list = {
251  k: f"{v[CONF_APP_NAME]} ({k})" if CONF_APP_NAME in v else k
252  for k, v in self._apps.items()
253  }
254  apps = [SelectOptionDict(value=APPS_NEW_ID, label="Add new")] + [
255  SelectOptionDict(value=k, label=v) for k, v in apps_list.items()
256  ]
257  return self.async_show_formasync_show_form(
258  step_id="init",
259  data_schema=vol.Schema(
260  {
261  vol.Optional(CONF_APPS): SelectSelector(
263  options=apps, mode=SelectSelectorMode.DROPDOWN
264  )
265  ),
266  vol.Required(
267  CONF_ENABLE_IME,
268  default=get_enable_ime(self.config_entryconfig_entryconfig_entry),
269  ): bool,
270  }
271  ),
272  )
273 
274  async def async_step_apps(
275  self, user_input: dict[str, Any] | None = None, app_id: str | None = None
276  ) -> ConfigFlowResult:
277  """Handle options flow for apps list."""
278  if app_id is not None:
279  self._conf_app_id_conf_app_id = app_id if app_id != APPS_NEW_ID else None
280  return self._async_apps_form_async_apps_form(app_id)
281 
282  if user_input is not None:
283  app_id = user_input.get(CONF_APP_ID, self._conf_app_id_conf_app_id)
284  if app_id:
285  if user_input.get(CONF_APP_DELETE, False):
286  self._apps.pop(app_id)
287  else:
288  self._apps[app_id] = {
289  CONF_APP_NAME: user_input.get(CONF_APP_NAME, ""),
290  CONF_APP_ICON: user_input.get(CONF_APP_ICON, ""),
291  }
292 
293  return await self.async_step_initasync_step_init()
294 
295  @callback
296  def _async_apps_form(self, app_id: str) -> ConfigFlowResult:
297  """Return configuration form for apps."""
298 
299  app_schema = {
300  vol.Optional(
301  CONF_APP_NAME,
302  description={
303  "suggested_value": self._apps[app_id].get(CONF_APP_NAME, "")
304  if app_id in self._apps
305  else ""
306  },
307  ): str,
308  vol.Optional(
309  CONF_APP_ICON,
310  description={
311  "suggested_value": self._apps[app_id].get(CONF_APP_ICON, "")
312  if app_id in self._apps
313  else ""
314  },
315  ): str,
316  }
317  if app_id == APPS_NEW_ID:
318  data_schema = vol.Schema({**app_schema, vol.Optional(CONF_APP_ID): str})
319  else:
320  data_schema = vol.Schema(
321  {**app_schema, vol.Optional(CONF_APP_DELETE, default=False): bool}
322  )
323 
324  return self.async_show_formasync_show_form(
325  step_id="apps",
326  data_schema=data_schema,
327  description_placeholders={
328  "app_id": f"`{app_id}`" if app_id != APPS_NEW_ID else "",
329  },
330  )
ConfigFlowResult async_step_pair(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:100
ConfigFlowResult async_step_zeroconf(self, zeroconf.ZeroconfServiceInfo discovery_info)
Definition: config_flow.py:146
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
Definition: config_flow.py:191
AndroidTVRemoteOptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:219
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:69
ConfigFlowResult async_step_zeroconf_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:175
ConfigFlowResult async_step_reauth_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:200
ConfigFlowResult async_step_apps(self, dict[str, Any]|None user_input=None, str|None app_id=None)
Definition: config_flow.py:276
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:243
None _abort_if_unique_id_configured(self, dict[str, Any]|None updates=None, bool reload_on_update=True, *str error="already_configured")
ConfigEntry|None async_set_unique_id(self, str|None unique_id=None, *bool raise_on_progress=True)
ConfigFlowResult async_create_entry(self, *str title, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None, Mapping[str, Any]|None options=None)
ConfigFlowResult async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
ConfigFlowResult async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
None config_entry(self, ConfigEntry value)
str
_FlowResultT async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_create_entry(self, *str|None title=None, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None)
str|None source(self)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
AndroidTVRemote create_api(HomeAssistant hass, str host, bool enable_ime)
Definition: helpers.py:14
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
IssData update(pyiss.ISS iss)
Definition: __init__.py:33