"""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}, )