/
config_flow.py
252 lines (188 loc) · 8.37 KB
/
config_flow.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
"""Config flow to configure Philips Hue."""
import asyncio
import json
import os
from aiohue.discovery import discover_nupnp
import async_timeout
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
from .bridge import get_bridge
from .const import DOMAIN, LOGGER
from .errors import AuthenticationRequired, CannotConnect
HUE_MANUFACTURERURL = "http://www.philips.com"
@callback
def configured_hosts(hass):
"""Return a set of the configured hosts."""
return set(
entry.data["host"] for entry in hass.config_entries.async_entries(DOMAIN)
)
def _find_username_from_config(hass, filename):
"""Load username from config.
This was a legacy way of configuring Hue until Home Assistant 0.67.
"""
path = hass.config.path(filename)
if not os.path.isfile(path):
return None
with open(path) as inp:
try:
return list(json.load(inp).values())[0]["username"]
except ValueError:
# If we get invalid JSON
return None
class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Hue config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
def __init__(self):
"""Initialize the Hue flow."""
self.host = None
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
return await self.async_step_init(user_input)
async def async_step_init(self, user_input=None):
"""Handle a flow start."""
if user_input is not None:
self.host = self.context["host"] = user_input["host"]
return await self.async_step_link()
websession = aiohttp_client.async_get_clientsession(self.hass)
try:
with async_timeout.timeout(5):
bridges = await discover_nupnp(websession=websession)
except asyncio.TimeoutError:
return self.async_abort(reason="discover_timeout")
if not bridges:
return self.async_abort(reason="no_bridges")
# Find already configured hosts
configured = configured_hosts(self.hass)
hosts = [bridge.host for bridge in bridges if bridge.host not in configured]
if not hosts:
return self.async_abort(reason="all_configured")
if len(hosts) == 1:
self.host = hosts[0]
return await self.async_step_link()
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({vol.Required("host"): vol.In(hosts)}),
)
async def async_step_link(self, user_input=None):
"""Attempt to link with the Hue bridge.
Given a configured host, will ask the user to press the link button
to connect to the bridge.
"""
errors = {}
# We will always try linking in case the user has already pressed
# the link button.
try:
bridge = await get_bridge(self.hass, self.host, username=None)
return await self._entry_from_bridge(bridge)
except AuthenticationRequired:
errors["base"] = "register_failed"
except CannotConnect:
LOGGER.error("Error connecting to the Hue bridge at %s", self.host)
errors["base"] = "linking"
except Exception: # pylint: disable=broad-except
LOGGER.exception(
"Unknown error connecting with Hue bridge at %s", self.host
)
errors["base"] = "linking"
# If there was no user input, do not show the errors.
if user_input is None:
errors = {}
return self.async_show_form(step_id="link", errors=errors)
async def async_step_ssdp(self, discovery_info):
"""Handle a discovered Hue bridge.
This flow is triggered by the SSDP component. It will check if the
host is already configured and delegate to the import step if not.
"""
from homeassistant.components.ssdp import ATTR_MANUFACTURERURL
if discovery_info[ATTR_MANUFACTURERURL] != HUE_MANUFACTURERURL:
return self.async_abort(reason="not_hue_bridge")
# Filter out emulated Hue
if "HASS Bridge" in discovery_info.get("name", ""):
return self.async_abort(reason="already_configured")
host = self.context["host"] = discovery_info.get("host")
if any(
host == flow["context"].get("host") for flow in self._async_in_progress()
):
return self.async_abort(reason="already_in_progress")
if host in configured_hosts(self.hass):
return self.async_abort(reason="already_configured")
# This value is based off host/description.xml and is, weirdly, missing
# 4 characters in the middle of the serial compared to results returned
# from the NUPNP API or when querying the bridge API for bridgeid.
# (on first gen Hue hub)
serial = discovery_info.get("serial")
return await self.async_step_import(
{
"host": host,
# This format is the legacy format that Hue used for discovery
"path": f"phue-{serial}.conf",
}
)
async def async_step_homekit(self, homekit_info):
"""Handle HomeKit discovery."""
host = self.context["host"] = homekit_info.get("host")
if any(
host == flow["context"].get("host") for flow in self._async_in_progress()
):
return self.async_abort(reason="already_in_progress")
if host in configured_hosts(self.hass):
return self.async_abort(reason="already_configured")
return await self.async_step_import({"host": host})
async def async_step_import(self, import_info):
"""Import a new bridge as a config entry.
Will read authentication from Phue config file if available.
This flow is triggered by `async_setup` for both configured and
discovered bridges. Triggered for any bridge that does not have a
config entry yet (based on host).
This flow is also triggered by `async_step_discovery`.
If an existing config file is found, we will validate the credentials
and create an entry. Otherwise we will delegate to `link` step which
will ask user to link the bridge.
"""
host = self.context["host"] = import_info["host"]
path = import_info.get("path")
if path is not None:
username = await self.hass.async_add_job(
_find_username_from_config, self.hass, self.hass.config.path(path)
)
else:
username = None
try:
bridge = await get_bridge(self.hass, host, username)
LOGGER.info("Imported authentication for %s from %s", host, path)
return await self._entry_from_bridge(bridge)
except AuthenticationRequired:
self.host = host
LOGGER.info("Invalid authentication for %s, requesting link.", host)
return await self.async_step_link()
except CannotConnect:
LOGGER.error("Error connecting to the Hue bridge at %s", host)
return self.async_abort(reason="cannot_connect")
except Exception: # pylint: disable=broad-except
LOGGER.exception("Unknown error connecting with Hue bridge at %s", host)
return self.async_abort(reason="unknown")
async def _entry_from_bridge(self, bridge):
"""Return a config entry from an initialized bridge."""
# Remove all other entries of hubs with same ID or host
host = bridge.host
bridge_id = bridge.config.bridgeid
same_hub_entries = [
entry.entry_id
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.data["bridge_id"] == bridge_id or entry.data["host"] == host
]
if same_hub_entries:
await asyncio.wait(
[
self.hass.config_entries.async_remove(entry_id)
for entry_id in same_hub_entries
]
)
return self.async_create_entry(
title=bridge.config.name,
data={"host": host, "bridge_id": bridge_id, "username": bridge.username},
)