mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Convert Hue to always use config entries (#13034)
This commit is contained in:
parent
1ae8b6ee08
commit
184f2be83e
@ -13,6 +13,7 @@ import os
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
@ -40,6 +41,10 @@ SERVICE_DECONZ = 'deconz'
|
||||
SERVICE_DAIKIN = 'daikin'
|
||||
SERVICE_SAMSUNG_PRINTER = 'samsung_printer'
|
||||
|
||||
CONFIG_ENTRY_HANDLERS = {
|
||||
SERVICE_HUE: 'hue',
|
||||
}
|
||||
|
||||
SERVICE_HANDLERS = {
|
||||
SERVICE_HASS_IOS_APP: ('ios', None),
|
||||
SERVICE_NETGEAR: ('device_tracker', None),
|
||||
@ -51,7 +56,6 @@ SERVICE_HANDLERS = {
|
||||
SERVICE_WINK: ('wink', None),
|
||||
SERVICE_XIAOMI_GW: ('xiaomi_aqara', None),
|
||||
SERVICE_TELLDUSLIVE: ('tellduslive', None),
|
||||
SERVICE_HUE: ('hue', None),
|
||||
SERVICE_DECONZ: ('deconz', None),
|
||||
SERVICE_DAIKIN: ('daikin', None),
|
||||
SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'),
|
||||
@ -105,6 +109,20 @@ async def async_setup(hass, config):
|
||||
logger.info("Ignoring service: %s %s", service, info)
|
||||
return
|
||||
|
||||
discovery_hash = json.dumps([service, info], sort_keys=True)
|
||||
if discovery_hash in already_discovered:
|
||||
return
|
||||
|
||||
already_discovered.add(discovery_hash)
|
||||
|
||||
if service in CONFIG_ENTRY_HANDLERS:
|
||||
await hass.config_entries.flow.async_init(
|
||||
CONFIG_ENTRY_HANDLERS[service],
|
||||
source=config_entries.SOURCE_DISCOVERY,
|
||||
data=info
|
||||
)
|
||||
return
|
||||
|
||||
comp_plat = SERVICE_HANDLERS.get(service)
|
||||
|
||||
# We do not know how to handle this service.
|
||||
@ -112,12 +130,6 @@ async def async_setup(hass, config):
|
||||
logger.info("Unknown service discovered: %s %s", service, info)
|
||||
return
|
||||
|
||||
discovery_hash = json.dumps([service, info], sort_keys=True)
|
||||
if discovery_hash in already_discovered:
|
||||
return
|
||||
|
||||
already_discovered.add(discovery_hash)
|
||||
|
||||
logger.info("Found new service: %s %s", service, info)
|
||||
|
||||
component, platform = comp_plat
|
||||
|
@ -4,31 +4,23 @@ This component provides basic support for the Philips Hue system.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/hue/
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.discovery import SERVICE_HUE
|
||||
from homeassistant.const import CONF_FILENAME, CONF_HOST
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers import discovery, aiohttp_client
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.util.json import save_json
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
|
||||
from .const import DOMAIN, API_NUPNP
|
||||
from .bridge import HueBridge
|
||||
# Loading the config flow file will register the flow
|
||||
from .config_flow import configured_hosts
|
||||
|
||||
REQUIREMENTS = ['aiohue==1.3.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "hue"
|
||||
SERVICE_HUE_SCENE = "hue_activate_scene"
|
||||
API_NUPNP = 'https://www.meethue.com/api/nupnp'
|
||||
|
||||
CONF_BRIDGES = "bridges"
|
||||
|
||||
CONF_ALLOW_UNREACHABLE = 'allow_unreachable'
|
||||
@ -42,6 +34,7 @@ DEFAULT_ALLOW_HUE_GROUPS = True
|
||||
BRIDGE_CONFIG_SCHEMA = vol.Schema({
|
||||
# Validate as IP address and then convert back to a string.
|
||||
vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string),
|
||||
# This is for legacy reasons and is only used for importing auth.
|
||||
vol.Optional(CONF_FILENAME, default=PHUE_CONFIG_FILE): cv.string,
|
||||
vol.Optional(CONF_ALLOW_UNREACHABLE,
|
||||
default=DEFAULT_ALLOW_UNREACHABLE): cv.boolean,
|
||||
@ -56,19 +49,6 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
ATTR_GROUP_NAME = "group_name"
|
||||
ATTR_SCENE_NAME = "scene_name"
|
||||
SCENE_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_GROUP_NAME): cv.string,
|
||||
vol.Required(ATTR_SCENE_NAME): cv.string,
|
||||
})
|
||||
|
||||
CONFIG_INSTRUCTIONS = """
|
||||
Press the button on the bridge to register Philips Hue with Home Assistant.
|
||||
|
||||

|
||||
"""
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the Hue platform."""
|
||||
@ -76,20 +56,8 @@ async def async_setup(hass, config):
|
||||
if conf is None:
|
||||
conf = {}
|
||||
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
async def async_bridge_discovered(service, discovery_info):
|
||||
"""Dispatcher for Hue discovery events."""
|
||||
# Ignore emulated hue
|
||||
if "HASS Bridge" in discovery_info.get('name', ''):
|
||||
return
|
||||
|
||||
await async_setup_bridge(
|
||||
hass, discovery_info['host'],
|
||||
'phue-{}.conf'.format(discovery_info['serial']))
|
||||
|
||||
discovery.async_listen(hass, SERVICE_HUE, async_bridge_discovered)
|
||||
hass.data[DOMAIN] = {}
|
||||
configured = configured_hosts(hass)
|
||||
|
||||
# User has configured bridges
|
||||
if CONF_BRIDGES in conf:
|
||||
@ -103,12 +71,19 @@ async def async_setup(hass, config):
|
||||
async with websession.get(API_NUPNP) as req:
|
||||
hosts = await req.json()
|
||||
|
||||
# Run through config schema to populate defaults
|
||||
bridges = [BRIDGE_CONFIG_SCHEMA({
|
||||
CONF_HOST: entry['internalipaddress'],
|
||||
CONF_FILENAME: '.hue_{}.conf'.format(entry['id']),
|
||||
}) for entry in hosts]
|
||||
bridges = []
|
||||
for entry in hosts:
|
||||
# Filter out already configured hosts
|
||||
if entry['internalipaddress'] in configured:
|
||||
continue
|
||||
|
||||
# Run through config schema to populate defaults
|
||||
bridges.append(BRIDGE_CONFIG_SCHEMA({
|
||||
CONF_HOST: entry['internalipaddress'],
|
||||
# Careful with using entry['id'] for other reasons. The
|
||||
# value is in lowercase but is returned uppercase from hub.
|
||||
CONF_FILENAME: '.hue_{}.conf'.format(entry['id']),
|
||||
}))
|
||||
else:
|
||||
# Component not specified in config, we're loaded via discovery
|
||||
bridges = []
|
||||
@ -116,277 +91,43 @@ async def async_setup(hass, config):
|
||||
if not bridges:
|
||||
return True
|
||||
|
||||
await asyncio.wait([
|
||||
async_setup_bridge(
|
||||
hass, bridge[CONF_HOST], bridge[CONF_FILENAME],
|
||||
bridge[CONF_ALLOW_UNREACHABLE], bridge[CONF_ALLOW_HUE_GROUPS]
|
||||
) for bridge in bridges
|
||||
])
|
||||
for bridge_conf in bridges:
|
||||
host = bridge_conf[CONF_HOST]
|
||||
|
||||
# Store config in hass.data so the config entry can find it
|
||||
hass.data[DOMAIN][host] = bridge_conf
|
||||
|
||||
# If configured, the bridge will be set up during config entry phase
|
||||
if host in configured:
|
||||
continue
|
||||
|
||||
# No existing config entry found, try importing it or trigger link
|
||||
# config flow if no existing auth. Because we're inside the setup of
|
||||
# this component we'll have to use hass.async_add_job to avoid a
|
||||
# deadlock: creating a config entry will set up the component but the
|
||||
# setup would block till the entry is created!
|
||||
hass.async_add_job(hass.config_entries.flow.async_init(
|
||||
DOMAIN, source='import', data={
|
||||
'host': bridge_conf[CONF_HOST],
|
||||
'path': bridge_conf[CONF_FILENAME],
|
||||
}
|
||||
))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_bridge(
|
||||
hass, host, filename=None,
|
||||
allow_unreachable=DEFAULT_ALLOW_UNREACHABLE,
|
||||
allow_hue_groups=DEFAULT_ALLOW_HUE_GROUPS,
|
||||
username=None):
|
||||
"""Set up a given Hue bridge."""
|
||||
assert filename or username, 'Need to pass at least a username or filename'
|
||||
|
||||
# Only register a device once
|
||||
if host in hass.data[DOMAIN]:
|
||||
return
|
||||
|
||||
if username is None:
|
||||
username = await hass.async_add_job(
|
||||
_find_username_from_config, hass, filename)
|
||||
|
||||
bridge = HueBridge(host, hass, filename, username, allow_unreachable,
|
||||
allow_hue_groups)
|
||||
await bridge.async_setup()
|
||||
|
||||
|
||||
def _find_username_from_config(hass, filename):
|
||||
"""Load username from config."""
|
||||
path = hass.config.path(filename)
|
||||
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
|
||||
with open(path) as inp:
|
||||
return list(json.load(inp).values())[0]['username']
|
||||
|
||||
|
||||
class HueBridge(object):
|
||||
"""Manages a single Hue bridge."""
|
||||
|
||||
def __init__(self, host, hass, filename, username,
|
||||
allow_unreachable=False, allow_groups=True):
|
||||
"""Initialize the system."""
|
||||
self.host = host
|
||||
self.hass = hass
|
||||
self.filename = filename
|
||||
self.username = username
|
||||
self.allow_unreachable = allow_unreachable
|
||||
self.allow_groups = allow_groups
|
||||
self.available = True
|
||||
self.config_request_id = None
|
||||
self.api = None
|
||||
|
||||
async def async_setup(self):
|
||||
"""Set up a phue bridge based on host parameter."""
|
||||
import aiohue
|
||||
|
||||
api = aiohue.Bridge(
|
||||
self.host,
|
||||
username=self.username,
|
||||
websession=aiohttp_client.async_get_clientsession(self.hass)
|
||||
)
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(5):
|
||||
# Initialize bridge and validate our username
|
||||
if not self.username:
|
||||
await api.create_user('home-assistant')
|
||||
await api.initialize()
|
||||
except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized):
|
||||
_LOGGER.warning("Connected to Hue at %s but not registered.",
|
||||
self.host)
|
||||
self.async_request_configuration()
|
||||
return
|
||||
except (asyncio.TimeoutError, aiohue.RequestError):
|
||||
_LOGGER.error("Error connecting to the Hue bridge at %s",
|
||||
self.host)
|
||||
return
|
||||
except aiohue.AiohueException:
|
||||
_LOGGER.exception('Unknown Hue linking error occurred')
|
||||
self.async_request_configuration()
|
||||
return
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unknown error connecting with Hue bridge at %s",
|
||||
self.host)
|
||||
return
|
||||
|
||||
self.hass.data[DOMAIN][self.host] = self
|
||||
|
||||
# If we came here and configuring this host, mark as done
|
||||
if self.config_request_id:
|
||||
request_id = self.config_request_id
|
||||
self.config_request_id = None
|
||||
self.hass.components.configurator.async_request_done(request_id)
|
||||
|
||||
self.username = api.username
|
||||
|
||||
# Save config file
|
||||
await self.hass.async_add_job(
|
||||
save_json, self.hass.config.path(self.filename),
|
||||
{self.host: {'username': api.username}})
|
||||
|
||||
self.api = api
|
||||
|
||||
self.hass.async_add_job(discovery.async_load_platform(
|
||||
self.hass, 'light', DOMAIN,
|
||||
{'host': self.host}))
|
||||
|
||||
self.hass.services.async_register(
|
||||
DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene,
|
||||
schema=SCENE_SCHEMA)
|
||||
|
||||
@callback
|
||||
def async_request_configuration(self):
|
||||
"""Request configuration steps from the user."""
|
||||
configurator = self.hass.components.configurator
|
||||
|
||||
# We got an error if this method is called while we are configuring
|
||||
if self.config_request_id:
|
||||
configurator.async_notify_errors(
|
||||
self.config_request_id,
|
||||
"Failed to register, please try again.")
|
||||
return
|
||||
|
||||
async def config_callback(data):
|
||||
"""Callback for configurator data."""
|
||||
await self.async_setup()
|
||||
|
||||
self.config_request_id = configurator.async_request_config(
|
||||
"Philips Hue", config_callback,
|
||||
description=CONFIG_INSTRUCTIONS,
|
||||
entity_picture="/static/images/logo_philips_hue.png",
|
||||
submit_caption="I have pressed the button"
|
||||
)
|
||||
|
||||
async def hue_activate_scene(self, call, updated=False):
|
||||
"""Service to call directly into bridge to set scenes."""
|
||||
group_name = call.data[ATTR_GROUP_NAME]
|
||||
scene_name = call.data[ATTR_SCENE_NAME]
|
||||
|
||||
group = next(
|
||||
(group for group in self.api.groups.values()
|
||||
if group.name == group_name), None)
|
||||
|
||||
scene_id = next(
|
||||
(scene.id for scene in self.api.scenes.values()
|
||||
if scene.name == scene_name), None)
|
||||
|
||||
# If we can't find it, fetch latest info.
|
||||
if not updated and (group is None or scene_id is None):
|
||||
await self.api.groups.update()
|
||||
await self.api.scenes.update()
|
||||
await self.hue_activate_scene(call, updated=True)
|
||||
return
|
||||
|
||||
if group is None:
|
||||
_LOGGER.warning('Unable to find group %s', group_name)
|
||||
return
|
||||
|
||||
if scene_id is None:
|
||||
_LOGGER.warning('Unable to find scene %s', scene_name)
|
||||
return
|
||||
|
||||
await group.set_action(scene=scene_id)
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register(DOMAIN)
|
||||
class HueFlowHandler(config_entries.ConfigFlowHandler):
|
||||
"""Handle a Hue config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Hue flow."""
|
||||
self.host = None
|
||||
|
||||
@property
|
||||
def _websession(self):
|
||||
"""Return a websession.
|
||||
|
||||
Cannot assign in init because hass variable is not set yet.
|
||||
"""
|
||||
return aiohttp_client.async_get_clientsession(self.hass)
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle a flow start."""
|
||||
from aiohue.discovery import discover_nupnp
|
||||
|
||||
if user_input is not None:
|
||||
self.host = user_input['host']
|
||||
return await self.async_step_link()
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(5):
|
||||
bridges = await discover_nupnp(websession=self._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_hosts = set(
|
||||
entry.data['host'] for entry
|
||||
in self.hass.config_entries.async_entries(DOMAIN))
|
||||
|
||||
hosts = [bridge.host for bridge in bridges
|
||||
if bridge.host not in configured_hosts]
|
||||
|
||||
if not hosts:
|
||||
return self.async_abort(
|
||||
reason='all_configured'
|
||||
)
|
||||
|
||||
elif 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."""
|
||||
import aiohue
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
bridge = aiohue.Bridge(self.host, websession=self._websession)
|
||||
try:
|
||||
with async_timeout.timeout(5):
|
||||
# Create auth token
|
||||
await bridge.create_user('home-assistant')
|
||||
# Fetches name and id
|
||||
await bridge.initialize()
|
||||
except (asyncio.TimeoutError, aiohue.RequestError,
|
||||
aiohue.LinkButtonNotPressed):
|
||||
errors['base'] = 'register_failed'
|
||||
except aiohue.AiohueException:
|
||||
errors['base'] = 'linking'
|
||||
_LOGGER.exception('Unknown Hue linking error occurred')
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=bridge.config.name,
|
||||
data={
|
||||
'host': bridge.host,
|
||||
'bridge_id': bridge.config.bridgeid,
|
||||
'username': bridge.username,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='link',
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Set up a bridge for a config entry."""
|
||||
await async_setup_bridge(hass, entry.data['host'],
|
||||
username=entry.data['username'])
|
||||
return True
|
||||
"""Set up a bridge from a config entry."""
|
||||
host = entry.data['host']
|
||||
config = hass.data[DOMAIN].get(host)
|
||||
|
||||
if config is None:
|
||||
allow_unreachable = DEFAULT_ALLOW_UNREACHABLE
|
||||
allow_groups = DEFAULT_ALLOW_HUE_GROUPS
|
||||
else:
|
||||
allow_unreachable = config[CONF_ALLOW_UNREACHABLE]
|
||||
allow_groups = config[CONF_ALLOW_HUE_GROUPS]
|
||||
|
||||
bridge = HueBridge(hass, entry, allow_unreachable, allow_groups)
|
||||
hass.data[DOMAIN][host] = bridge
|
||||
return await bridge.async_setup()
|
||||
|
143
homeassistant/components/hue/bridge.py
Normal file
143
homeassistant/components/hue/bridge.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""Code to handle a Hue bridge."""
|
||||
import asyncio
|
||||
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
|
||||
SERVICE_HUE_SCENE = "hue_activate_scene"
|
||||
ATTR_GROUP_NAME = "group_name"
|
||||
ATTR_SCENE_NAME = "scene_name"
|
||||
SCENE_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_GROUP_NAME): cv.string,
|
||||
vol.Required(ATTR_SCENE_NAME): cv.string,
|
||||
})
|
||||
|
||||
|
||||
class HueBridge(object):
|
||||
"""Manages a single Hue bridge."""
|
||||
|
||||
def __init__(self, hass, config_entry, allow_unreachable, allow_groups):
|
||||
"""Initialize the system."""
|
||||
self.config_entry = config_entry
|
||||
self.hass = hass
|
||||
self.allow_unreachable = allow_unreachable
|
||||
self.allow_groups = allow_groups
|
||||
self.available = True
|
||||
self.api = None
|
||||
|
||||
async def async_setup(self, tries=0):
|
||||
"""Set up a phue bridge based on host parameter."""
|
||||
host = self.config_entry.data['host']
|
||||
|
||||
try:
|
||||
self.api = await get_bridge(
|
||||
self.hass, host,
|
||||
self.config_entry.data['username']
|
||||
)
|
||||
except AuthenticationRequired:
|
||||
# usernames can become invalid if hub is reset or user removed.
|
||||
# We are going to fail the config entry setup and initiate a new
|
||||
# linking procedure. When linking succeeds, it will remove the
|
||||
# old config entry.
|
||||
self.hass.async_add_job(self.hass.config_entries.flow.async_init(
|
||||
DOMAIN, source='import', data={
|
||||
'host': host,
|
||||
}
|
||||
))
|
||||
return False
|
||||
|
||||
except CannotConnect:
|
||||
retry_delay = 2 ** (tries + 1)
|
||||
LOGGER.error("Error connecting to the Hue bridge at %s. Retrying "
|
||||
"in %d seconds", host, retry_delay)
|
||||
|
||||
async def retry_setup(_now):
|
||||
"""Retry setup."""
|
||||
if await self.async_setup(tries + 1):
|
||||
# This feels hacky, we should find a better way to do this
|
||||
self.config_entry.state = config_entries.ENTRY_STATE_LOADED
|
||||
|
||||
# Unhandled edge case: cancel this if we discover bridge on new IP
|
||||
self.hass.helpers.event.async_call_later(retry_delay, retry_setup)
|
||||
|
||||
return False
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
LOGGER.exception('Unknown error connecting with Hue bridge at %s',
|
||||
host)
|
||||
return False
|
||||
|
||||
self.hass.async_add_job(
|
||||
self.hass.helpers.discovery.async_load_platform(
|
||||
'light', DOMAIN, {'host': host}))
|
||||
|
||||
self.hass.services.async_register(
|
||||
DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene,
|
||||
schema=SCENE_SCHEMA)
|
||||
|
||||
return True
|
||||
|
||||
async def hue_activate_scene(self, call, updated=False):
|
||||
"""Service to call directly into bridge to set scenes."""
|
||||
group_name = call.data[ATTR_GROUP_NAME]
|
||||
scene_name = call.data[ATTR_SCENE_NAME]
|
||||
|
||||
group = next(
|
||||
(group for group in self.api.groups.values()
|
||||
if group.name == group_name), None)
|
||||
|
||||
scene_id = next(
|
||||
(scene.id for scene in self.api.scenes.values()
|
||||
if scene.name == scene_name), None)
|
||||
|
||||
# If we can't find it, fetch latest info.
|
||||
if not updated and (group is None or scene_id is None):
|
||||
await self.api.groups.update()
|
||||
await self.api.scenes.update()
|
||||
await self.hue_activate_scene(call, updated=True)
|
||||
return
|
||||
|
||||
if group is None:
|
||||
LOGGER.warning('Unable to find group %s', group_name)
|
||||
return
|
||||
|
||||
if scene_id is None:
|
||||
LOGGER.warning('Unable to find scene %s', scene_name)
|
||||
return
|
||||
|
||||
await group.set_action(scene=scene_id)
|
||||
|
||||
|
||||
async def get_bridge(hass, host, username=None):
|
||||
"""Create a bridge object and verify authentication."""
|
||||
import aiohue
|
||||
|
||||
bridge = aiohue.Bridge(
|
||||
host, username=username,
|
||||
websession=aiohttp_client.async_get_clientsession(hass)
|
||||
)
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(5):
|
||||
# Create username if we don't have one
|
||||
if not username:
|
||||
await bridge.create_user('home-assistant')
|
||||
# Initialize bridge (and validate our username)
|
||||
await bridge.initialize()
|
||||
|
||||
return bridge
|
||||
except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized):
|
||||
LOGGER.warning("Connected to Hue at %s but not registered.", host)
|
||||
raise AuthenticationRequired
|
||||
except (asyncio.TimeoutError, aiohue.RequestError):
|
||||
LOGGER.error("Error connecting to the Hue bridge at %s", host)
|
||||
raise CannotConnect
|
||||
except aiohue.AiohueException:
|
||||
LOGGER.exception('Unknown Hue linking error occurred')
|
||||
raise AuthenticationRequired
|
235
homeassistant/components/hue/config_flow.py
Normal file
235
homeassistant/components/hue/config_flow.py
Normal file
@ -0,0 +1,235 @@
|
||||
"""Config flow to configure Philips Hue."""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register(DOMAIN)
|
||||
class HueFlowHandler(config_entries.ConfigFlowHandler):
|
||||
"""Handle a Hue config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Hue flow."""
|
||||
self.host = None
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle a flow start."""
|
||||
from aiohue.discovery import discover_nupnp
|
||||
|
||||
if user_input is not None:
|
||||
self.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'
|
||||
)
|
||||
|
||||
elif 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_discovery(self, discovery_info):
|
||||
"""Handle a discovered Hue bridge.
|
||||
|
||||
This flow is triggered by the discovery component. It will check if the
|
||||
host is already configured and delegate to the import step if not.
|
||||
"""
|
||||
# Filter out emulated Hue
|
||||
if "HASS Bridge" in discovery_info.get('name', ''):
|
||||
return self.async_abort(reason='already_configured')
|
||||
|
||||
host = discovery_info.get('host')
|
||||
|
||||
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': 'phue-{}.conf'.format(serial)
|
||||
})
|
||||
|
||||
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 = 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,
|
||||
}
|
||||
)
|
6
homeassistant/components/hue/const.py
Normal file
6
homeassistant/components/hue/const.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Constants for the Hue component."""
|
||||
import logging
|
||||
|
||||
LOGGER = logging.getLogger('homeassistant.components.hue')
|
||||
DOMAIN = "hue"
|
||||
API_NUPNP = 'https://www.meethue.com/api/nupnp'
|
14
homeassistant/components/hue/errors.py
Normal file
14
homeassistant/components/hue/errors.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""Errors for the Hue component."""
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class HueException(HomeAssistantError):
|
||||
"""Base class for Hue exceptions."""
|
||||
|
||||
|
||||
class CannotConnect(HueException):
|
||||
"""Unable to connect to the bridge."""
|
||||
|
||||
|
||||
class AuthenticationRequired(HueException):
|
||||
"""Unknown error occurred."""
|
@ -20,7 +20,10 @@
|
||||
"abort": {
|
||||
"discover_timeout": "Unable to discover Hue bridges",
|
||||
"no_bridges": "No Philips Hue bridges discovered",
|
||||
"all_configured": "All Philips Hue bridges are already configured"
|
||||
"all_configured": "All Philips Hue bridges are already configured",
|
||||
"unknown": "Unknown error occurred",
|
||||
"cannot_connect": "Unable to connect to the bridge",
|
||||
"already_configured": "Bridge is already configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -384,7 +384,7 @@ class FlowManager:
|
||||
handler = HANDLERS.get(domain)
|
||||
|
||||
if handler is None:
|
||||
raise self.hass.helpers.UnknownHandler
|
||||
raise UnknownHandler
|
||||
|
||||
# Make sure requirements and dependencies of component are resolved
|
||||
await async_process_deps_reqs(
|
||||
|
@ -1,17 +0,0 @@
|
||||
"""Fixtures for Hue tests."""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.common import mock_coro_func
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bridge():
|
||||
"""Mock the HueBridge from initializing."""
|
||||
with patch('homeassistant.components.hue._find_username_from_config',
|
||||
return_value=None), \
|
||||
patch('homeassistant.components.hue.HueBridge') as mock_bridge:
|
||||
mock_bridge().async_setup = mock_coro_func()
|
||||
mock_bridge.reset_mock()
|
||||
yield mock_bridge
|
@ -1,99 +1,57 @@
|
||||
"""Test Hue bridge."""
|
||||
import asyncio
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import aiohue
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import hue
|
||||
from homeassistant.components.hue import bridge, errors
|
||||
|
||||
from tests.common import mock_coro
|
||||
|
||||
|
||||
class MockBridge(hue.HueBridge):
|
||||
"""Class that sets default for constructor."""
|
||||
async def test_bridge_setup():
|
||||
"""Test a successful setup."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
api = Mock()
|
||||
entry.data = {'host': '1.2.3.4', 'username': 'mock-username'}
|
||||
hue_bridge = bridge.HueBridge(hass, entry, False, False)
|
||||
|
||||
def __init__(self, hass, host='1.2.3.4', filename='mock-bridge.conf',
|
||||
username=None, **kwargs):
|
||||
"""Initialize a mock bridge."""
|
||||
super().__init__(host, hass, filename, username, **kwargs)
|
||||
with patch.object(bridge, 'get_bridge', return_value=mock_coro(api)):
|
||||
assert await hue_bridge.async_setup() is True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
"""Mock configurator.async_request_config."""
|
||||
with patch('homeassistant.components.configurator.'
|
||||
'async_request_config') as mock_request:
|
||||
yield mock_request
|
||||
|
||||
|
||||
async def test_setup_request_config_button_not_pressed(hass, mock_request):
|
||||
"""Test we request config if link button has not been pressed."""
|
||||
with patch('aiohue.Bridge.create_user',
|
||||
side_effect=aiohue.LinkButtonNotPressed):
|
||||
await MockBridge(hass).async_setup()
|
||||
|
||||
assert len(mock_request.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_setup_request_config_invalid_username(hass, mock_request):
|
||||
"""Test we request config if username is no longer whitelisted."""
|
||||
with patch('aiohue.Bridge.create_user',
|
||||
side_effect=aiohue.Unauthorized):
|
||||
await MockBridge(hass).async_setup()
|
||||
|
||||
assert len(mock_request.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_setup_timeout(hass, mock_request):
|
||||
"""Test we give up when there is a timeout."""
|
||||
with patch('aiohue.Bridge.create_user',
|
||||
side_effect=asyncio.TimeoutError):
|
||||
await MockBridge(hass).async_setup()
|
||||
|
||||
assert len(mock_request.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_only_create_no_username(hass):
|
||||
"""."""
|
||||
with patch('aiohue.Bridge.create_user') as mock_create, \
|
||||
patch('aiohue.Bridge.initialize') as mock_init:
|
||||
await MockBridge(hass, username='bla').async_setup()
|
||||
|
||||
assert len(mock_create.mock_calls) == 0
|
||||
assert len(mock_init.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_configurator_callback(hass, mock_request):
|
||||
"""."""
|
||||
hass.data[hue.DOMAIN] = {}
|
||||
with patch('aiohue.Bridge.create_user',
|
||||
side_effect=aiohue.LinkButtonNotPressed):
|
||||
await MockBridge(hass).async_setup()
|
||||
|
||||
assert len(mock_request.mock_calls) == 1
|
||||
|
||||
callback = mock_request.mock_calls[0][1][2]
|
||||
|
||||
mock_init = Mock(return_value=mock_coro())
|
||||
mock_create = Mock(return_value=mock_coro())
|
||||
|
||||
with patch('aiohue.Bridge') as mock_bridge, \
|
||||
patch('homeassistant.helpers.discovery.async_load_platform',
|
||||
return_value=mock_coro()) as mock_load_platform, \
|
||||
patch('homeassistant.components.hue.save_json') as mock_save:
|
||||
inst = mock_bridge()
|
||||
inst.username = 'mock-user'
|
||||
inst.create_user = mock_create
|
||||
inst.initialize = mock_init
|
||||
await callback(None)
|
||||
|
||||
assert len(mock_create.mock_calls) == 1
|
||||
assert len(mock_init.mock_calls) == 1
|
||||
assert len(mock_save.mock_calls) == 1
|
||||
assert mock_save.mock_calls[0][1][1] == {
|
||||
'1.2.3.4': {
|
||||
'username': 'mock-user'
|
||||
}
|
||||
assert hue_bridge.api is api
|
||||
assert len(hass.helpers.discovery.async_load_platform.mock_calls) == 1
|
||||
assert hass.helpers.discovery.async_load_platform.mock_calls[0][1][2] == {
|
||||
'host': '1.2.3.4'
|
||||
}
|
||||
assert len(mock_load_platform.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_bridge_setup_invalid_username():
|
||||
"""Test we start config flow if username is no longer whitelisted."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = {'host': '1.2.3.4', 'username': 'mock-username'}
|
||||
hue_bridge = bridge.HueBridge(hass, entry, False, False)
|
||||
|
||||
with patch.object(bridge, 'get_bridge',
|
||||
side_effect=errors.AuthenticationRequired):
|
||||
assert await hue_bridge.async_setup() is False
|
||||
|
||||
assert len(hass.async_add_job.mock_calls) == 1
|
||||
assert len(hass.config_entries.flow.async_init.mock_calls) == 1
|
||||
assert hass.config_entries.flow.async_init.mock_calls[0][2]['data'] == {
|
||||
'host': '1.2.3.4'
|
||||
}
|
||||
|
||||
|
||||
async def test_bridge_setup_timeout(hass):
|
||||
"""Test we retry to connect if we cannot connect."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = {'host': '1.2.3.4', 'username': 'mock-username'}
|
||||
hue_bridge = bridge.HueBridge(hass, entry, False, False)
|
||||
|
||||
with patch.object(bridge, 'get_bridge', side_effect=errors.CannotConnect):
|
||||
assert await hue_bridge.async_setup() is False
|
||||
|
||||
assert len(hass.helpers.event.async_call_later.mock_calls) == 1
|
||||
# Assert we are going to wait 2 seconds
|
||||
assert hass.helpers.event.async_call_later.mock_calls[0][1][0] == 2
|
||||
|
@ -1,28 +1,29 @@
|
||||
"""Tests for Philips Hue config flow."""
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import aiohue
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import hue
|
||||
from homeassistant.components.hue import config_flow, const, errors
|
||||
|
||||
from tests.common import MockConfigEntry, mock_coro
|
||||
|
||||
|
||||
async def test_flow_works(hass, aioclient_mock):
|
||||
"""Test config flow ."""
|
||||
aioclient_mock.get(hue.API_NUPNP, json=[
|
||||
aioclient_mock.get(const.API_NUPNP, json=[
|
||||
{'internalipaddress': '1.2.3.4', 'id': 'bla'}
|
||||
])
|
||||
|
||||
flow = hue.HueFlowHandler()
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
await flow.async_step_init()
|
||||
|
||||
with patch('aiohue.Bridge') as mock_bridge:
|
||||
def mock_constructor(host, websession):
|
||||
def mock_constructor(host, websession, username=None):
|
||||
"""Fake the bridge constructor."""
|
||||
mock_bridge.host = host
|
||||
return mock_bridge
|
||||
|
||||
@ -50,8 +51,8 @@ async def test_flow_works(hass, aioclient_mock):
|
||||
|
||||
async def test_flow_no_discovered_bridges(hass, aioclient_mock):
|
||||
"""Test config flow discovers no bridges."""
|
||||
aioclient_mock.get(hue.API_NUPNP, json=[])
|
||||
flow = hue.HueFlowHandler()
|
||||
aioclient_mock.get(const.API_NUPNP, json=[])
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
result = await flow.async_step_init()
|
||||
@ -60,13 +61,13 @@ async def test_flow_no_discovered_bridges(hass, aioclient_mock):
|
||||
|
||||
async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock):
|
||||
"""Test config flow discovers only already configured bridges."""
|
||||
aioclient_mock.get(hue.API_NUPNP, json=[
|
||||
aioclient_mock.get(const.API_NUPNP, json=[
|
||||
{'internalipaddress': '1.2.3.4', 'id': 'bla'}
|
||||
])
|
||||
MockConfigEntry(domain='hue', data={
|
||||
'host': '1.2.3.4'
|
||||
}).add_to_hass(hass)
|
||||
flow = hue.HueFlowHandler()
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
result = await flow.async_step_init()
|
||||
@ -75,10 +76,10 @@ async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock):
|
||||
|
||||
async def test_flow_one_bridge_discovered(hass, aioclient_mock):
|
||||
"""Test config flow discovers one bridge."""
|
||||
aioclient_mock.get(hue.API_NUPNP, json=[
|
||||
aioclient_mock.get(const.API_NUPNP, json=[
|
||||
{'internalipaddress': '1.2.3.4', 'id': 'bla'}
|
||||
])
|
||||
flow = hue.HueFlowHandler()
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
result = await flow.async_step_init()
|
||||
@ -88,11 +89,11 @@ async def test_flow_one_bridge_discovered(hass, aioclient_mock):
|
||||
|
||||
async def test_flow_two_bridges_discovered(hass, aioclient_mock):
|
||||
"""Test config flow discovers two bridges."""
|
||||
aioclient_mock.get(hue.API_NUPNP, json=[
|
||||
aioclient_mock.get(const.API_NUPNP, json=[
|
||||
{'internalipaddress': '1.2.3.4', 'id': 'bla'},
|
||||
{'internalipaddress': '5.6.7.8', 'id': 'beer'}
|
||||
])
|
||||
flow = hue.HueFlowHandler()
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
result = await flow.async_step_init()
|
||||
@ -108,14 +109,14 @@ async def test_flow_two_bridges_discovered(hass, aioclient_mock):
|
||||
|
||||
async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock):
|
||||
"""Test config flow discovers two bridges."""
|
||||
aioclient_mock.get(hue.API_NUPNP, json=[
|
||||
aioclient_mock.get(const.API_NUPNP, json=[
|
||||
{'internalipaddress': '1.2.3.4', 'id': 'bla'},
|
||||
{'internalipaddress': '5.6.7.8', 'id': 'beer'}
|
||||
])
|
||||
MockConfigEntry(domain='hue', data={
|
||||
'host': '1.2.3.4'
|
||||
}).add_to_hass(hass)
|
||||
flow = hue.HueFlowHandler()
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
result = await flow.async_step_init()
|
||||
@ -126,7 +127,7 @@ async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock):
|
||||
|
||||
async def test_flow_timeout_discovery(hass):
|
||||
"""Test config flow ."""
|
||||
flow = hue.HueFlowHandler()
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch('aiohue.discovery.discover_nupnp',
|
||||
@ -138,7 +139,7 @@ async def test_flow_timeout_discovery(hass):
|
||||
|
||||
async def test_flow_link_timeout(hass):
|
||||
"""Test config flow ."""
|
||||
flow = hue.HueFlowHandler()
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch('aiohue.Bridge.create_user',
|
||||
@ -148,13 +149,13 @@ async def test_flow_link_timeout(hass):
|
||||
assert result['type'] == 'form'
|
||||
assert result['step_id'] == 'link'
|
||||
assert result['errors'] == {
|
||||
'base': 'register_failed'
|
||||
'base': 'linking'
|
||||
}
|
||||
|
||||
|
||||
async def test_flow_link_button_not_pressed(hass):
|
||||
"""Test config flow ."""
|
||||
flow = hue.HueFlowHandler()
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch('aiohue.Bridge.create_user',
|
||||
@ -170,7 +171,7 @@ async def test_flow_link_button_not_pressed(hass):
|
||||
|
||||
async def test_flow_link_unknown_host(hass):
|
||||
"""Test config flow ."""
|
||||
flow = hue.HueFlowHandler()
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch('aiohue.Bridge.create_user',
|
||||
@ -180,5 +181,175 @@ async def test_flow_link_unknown_host(hass):
|
||||
assert result['type'] == 'form'
|
||||
assert result['step_id'] == 'link'
|
||||
assert result['errors'] == {
|
||||
'base': 'register_failed'
|
||||
'base': 'linking'
|
||||
}
|
||||
|
||||
|
||||
async def test_bridge_discovery(hass):
|
||||
"""Test a bridge being discovered."""
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch.object(config_flow, 'get_bridge',
|
||||
side_effect=errors.AuthenticationRequired):
|
||||
result = await flow.async_step_discovery({
|
||||
'host': '0.0.0.0',
|
||||
'serial': '1234'
|
||||
})
|
||||
|
||||
assert result['type'] == 'form'
|
||||
assert result['step_id'] == 'link'
|
||||
|
||||
|
||||
async def test_bridge_discovery_emulated_hue(hass):
|
||||
"""Test if discovery info is from an emulated hue instance."""
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
result = await flow.async_step_discovery({
|
||||
'name': 'HASS Bridge',
|
||||
'host': '0.0.0.0',
|
||||
'serial': '1234'
|
||||
})
|
||||
|
||||
assert result['type'] == 'abort'
|
||||
|
||||
|
||||
async def test_bridge_discovery_already_configured(hass):
|
||||
"""Test if a discovered bridge has already been configured."""
|
||||
MockConfigEntry(domain='hue', data={
|
||||
'host': '0.0.0.0'
|
||||
}).add_to_hass(hass)
|
||||
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
result = await flow.async_step_discovery({
|
||||
'host': '0.0.0.0',
|
||||
'serial': '1234'
|
||||
})
|
||||
|
||||
assert result['type'] == 'abort'
|
||||
|
||||
|
||||
async def test_import_with_existing_config(hass):
|
||||
"""Test importing a host with an existing config file."""
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
bridge = Mock()
|
||||
bridge.username = 'username-abc'
|
||||
bridge.config.bridgeid = 'bridge-id-1234'
|
||||
bridge.config.name = 'Mock Bridge'
|
||||
bridge.host = '0.0.0.0'
|
||||
|
||||
with patch.object(config_flow, '_find_username_from_config',
|
||||
return_value='mock-user'), \
|
||||
patch.object(config_flow, 'get_bridge',
|
||||
return_value=mock_coro(bridge)):
|
||||
result = await flow.async_step_import({
|
||||
'host': '0.0.0.0',
|
||||
'path': 'bla.conf'
|
||||
})
|
||||
|
||||
assert result['type'] == 'create_entry'
|
||||
assert result['title'] == 'Mock Bridge'
|
||||
assert result['data'] == {
|
||||
'host': '0.0.0.0',
|
||||
'bridge_id': 'bridge-id-1234',
|
||||
'username': 'username-abc'
|
||||
}
|
||||
|
||||
|
||||
async def test_import_with_no_config(hass):
|
||||
"""Test importing a host without an existing config file."""
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch.object(config_flow, 'get_bridge',
|
||||
side_effect=errors.AuthenticationRequired):
|
||||
result = await flow.async_step_import({
|
||||
'host': '0.0.0.0',
|
||||
})
|
||||
|
||||
assert result['type'] == 'form'
|
||||
assert result['step_id'] == 'link'
|
||||
|
||||
|
||||
async def test_import_with_existing_but_invalid_config(hass):
|
||||
"""Test importing a host with a config file with invalid username."""
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch.object(config_flow, '_find_username_from_config',
|
||||
return_value='mock-user'), \
|
||||
patch.object(config_flow, 'get_bridge',
|
||||
side_effect=errors.AuthenticationRequired):
|
||||
result = await flow.async_step_import({
|
||||
'host': '0.0.0.0',
|
||||
'path': 'bla.conf'
|
||||
})
|
||||
|
||||
assert result['type'] == 'form'
|
||||
assert result['step_id'] == 'link'
|
||||
|
||||
|
||||
async def test_import_cannot_connect(hass):
|
||||
"""Test importing a host that we cannot conncet to."""
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch.object(config_flow, 'get_bridge',
|
||||
side_effect=errors.CannotConnect):
|
||||
result = await flow.async_step_import({
|
||||
'host': '0.0.0.0',
|
||||
})
|
||||
|
||||
assert result['type'] == 'abort'
|
||||
assert result['reason'] == 'cannot_connect'
|
||||
|
||||
|
||||
async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass):
|
||||
"""Test that we clean up entries for same host and bridge.
|
||||
|
||||
An IP can only hold a single bridge and a single bridge can only be
|
||||
accessible via a single IP. So when we create a new entry, we'll remove
|
||||
all existing entries that either have same IP or same bridge_id.
|
||||
"""
|
||||
MockConfigEntry(domain='hue', data={
|
||||
'host': '0.0.0.0',
|
||||
'bridge_id': 'id-1234'
|
||||
}).add_to_hass(hass)
|
||||
|
||||
MockConfigEntry(domain='hue', data={
|
||||
'host': '1.2.3.4',
|
||||
'bridge_id': 'id-1234'
|
||||
}).add_to_hass(hass)
|
||||
|
||||
assert len(hass.config_entries.async_entries('hue')) == 2
|
||||
|
||||
flow = config_flow.HueFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
bridge = Mock()
|
||||
bridge.username = 'username-abc'
|
||||
bridge.config.bridgeid = 'id-1234'
|
||||
bridge.config.name = 'Mock Bridge'
|
||||
bridge.host = '0.0.0.0'
|
||||
|
||||
with patch.object(config_flow, 'get_bridge',
|
||||
return_value=mock_coro(bridge)):
|
||||
result = await flow.async_step_import({
|
||||
'host': '0.0.0.0',
|
||||
})
|
||||
|
||||
assert result['type'] == 'create_entry'
|
||||
assert result['title'] == 'Mock Bridge'
|
||||
assert result['data'] == {
|
||||
'host': '0.0.0.0',
|
||||
'bridge_id': 'id-1234',
|
||||
'username': 'username-abc'
|
||||
}
|
||||
# We did not process the result of this entry but already removed the old
|
||||
# ones. So we should have 0 entries.
|
||||
assert len(hass.config_entries.async_entries('hue')) == 0
|
||||
|
169
tests/components/hue/test_init.py
Normal file
169
tests/components/hue/test_init.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""Test Hue setup process."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components import hue
|
||||
|
||||
from tests.common import mock_coro, MockConfigEntry
|
||||
|
||||
|
||||
async def test_setup_with_no_config(hass):
|
||||
"""Test that we do not discover anything or try to setup a bridge."""
|
||||
with patch.object(hass, 'config_entries') as mock_config_entries, \
|
||||
patch.object(hue, 'configured_hosts', return_value=[]):
|
||||
assert await async_setup_component(hass, hue.DOMAIN, {}) is True
|
||||
|
||||
# No flows started
|
||||
assert len(mock_config_entries.flow.mock_calls) == 0
|
||||
|
||||
# No configs stored
|
||||
assert hass.data[hue.DOMAIN] == {}
|
||||
|
||||
|
||||
async def test_setup_with_discovery_no_known_auth(hass, aioclient_mock):
|
||||
"""Test discovering a bridge and not having known auth."""
|
||||
aioclient_mock.get(hue.API_NUPNP, json=[
|
||||
{
|
||||
'internalipaddress': '0.0.0.0',
|
||||
'id': 'abcd1234'
|
||||
}
|
||||
])
|
||||
|
||||
with patch.object(hass, 'config_entries') as mock_config_entries, \
|
||||
patch.object(hue, 'configured_hosts', return_value=[]):
|
||||
mock_config_entries.flow.async_init.return_value = mock_coro()
|
||||
assert await async_setup_component(hass, hue.DOMAIN, {
|
||||
hue.DOMAIN: {}
|
||||
}) is True
|
||||
|
||||
# Flow started for discovered bridge
|
||||
assert len(mock_config_entries.flow.mock_calls) == 1
|
||||
assert mock_config_entries.flow.mock_calls[0][2]['data'] == {
|
||||
'host': '0.0.0.0',
|
||||
'path': '.hue_abcd1234.conf',
|
||||
}
|
||||
|
||||
# Config stored for domain.
|
||||
assert hass.data[hue.DOMAIN] == {
|
||||
'0.0.0.0': {
|
||||
hue.CONF_HOST: '0.0.0.0',
|
||||
hue.CONF_FILENAME: '.hue_abcd1234.conf',
|
||||
hue.CONF_ALLOW_HUE_GROUPS: hue.DEFAULT_ALLOW_HUE_GROUPS,
|
||||
hue.CONF_ALLOW_UNREACHABLE: hue.DEFAULT_ALLOW_UNREACHABLE,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_setup_with_discovery_known_auth(hass, aioclient_mock):
|
||||
"""Test we don't do anything if we discover already configured hub."""
|
||||
aioclient_mock.get(hue.API_NUPNP, json=[
|
||||
{
|
||||
'internalipaddress': '0.0.0.0',
|
||||
'id': 'abcd1234'
|
||||
}
|
||||
])
|
||||
|
||||
with patch.object(hass, 'config_entries') as mock_config_entries, \
|
||||
patch.object(hue, 'configured_hosts', return_value=['0.0.0.0']):
|
||||
assert await async_setup_component(hass, hue.DOMAIN, {
|
||||
hue.DOMAIN: {}
|
||||
}) is True
|
||||
|
||||
# Flow started for discovered bridge
|
||||
assert len(mock_config_entries.flow.mock_calls) == 0
|
||||
|
||||
# Config stored for domain.
|
||||
assert hass.data[hue.DOMAIN] == {}
|
||||
|
||||
|
||||
async def test_setup_defined_hosts_known_auth(hass):
|
||||
"""Test we don't initiate a config entry if config bridge is known."""
|
||||
with patch.object(hass, 'config_entries') as mock_config_entries, \
|
||||
patch.object(hue, 'configured_hosts', return_value=['0.0.0.0']):
|
||||
assert await async_setup_component(hass, hue.DOMAIN, {
|
||||
hue.DOMAIN: {
|
||||
hue.CONF_BRIDGES: {
|
||||
hue.CONF_HOST: '0.0.0.0',
|
||||
hue.CONF_FILENAME: 'bla.conf',
|
||||
hue.CONF_ALLOW_HUE_GROUPS: False,
|
||||
hue.CONF_ALLOW_UNREACHABLE: True
|
||||
}
|
||||
}
|
||||
}) is True
|
||||
|
||||
# Flow started for discovered bridge
|
||||
assert len(mock_config_entries.flow.mock_calls) == 0
|
||||
|
||||
# Config stored for domain.
|
||||
assert hass.data[hue.DOMAIN] == {
|
||||
'0.0.0.0': {
|
||||
hue.CONF_HOST: '0.0.0.0',
|
||||
hue.CONF_FILENAME: 'bla.conf',
|
||||
hue.CONF_ALLOW_HUE_GROUPS: False,
|
||||
hue.CONF_ALLOW_UNREACHABLE: True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_setup_defined_hosts_no_known_auth(hass):
|
||||
"""Test we initiate config entry if config bridge is not known."""
|
||||
with patch.object(hass, 'config_entries') as mock_config_entries, \
|
||||
patch.object(hue, 'configured_hosts', return_value=[]):
|
||||
mock_config_entries.flow.async_init.return_value = mock_coro()
|
||||
assert await async_setup_component(hass, hue.DOMAIN, {
|
||||
hue.DOMAIN: {
|
||||
hue.CONF_BRIDGES: {
|
||||
hue.CONF_HOST: '0.0.0.0',
|
||||
hue.CONF_FILENAME: 'bla.conf',
|
||||
hue.CONF_ALLOW_HUE_GROUPS: False,
|
||||
hue.CONF_ALLOW_UNREACHABLE: True
|
||||
}
|
||||
}
|
||||
}) is True
|
||||
|
||||
# Flow started for discovered bridge
|
||||
assert len(mock_config_entries.flow.mock_calls) == 1
|
||||
assert mock_config_entries.flow.mock_calls[0][2]['data'] == {
|
||||
'host': '0.0.0.0',
|
||||
'path': 'bla.conf',
|
||||
}
|
||||
|
||||
# Config stored for domain.
|
||||
assert hass.data[hue.DOMAIN] == {
|
||||
'0.0.0.0': {
|
||||
hue.CONF_HOST: '0.0.0.0',
|
||||
hue.CONF_FILENAME: 'bla.conf',
|
||||
hue.CONF_ALLOW_HUE_GROUPS: False,
|
||||
hue.CONF_ALLOW_UNREACHABLE: True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_config_passed_to_config_entry(hass):
|
||||
"""Test that configured options for a host are loaded via config entry."""
|
||||
entry = MockConfigEntry(domain=hue.DOMAIN, data={
|
||||
'host': '0.0.0.0',
|
||||
})
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch.object(hue, 'HueBridge') as mock_bridge:
|
||||
mock_bridge.return_value.async_setup.return_value = mock_coro(True)
|
||||
assert await async_setup_component(hass, hue.DOMAIN, {
|
||||
hue.DOMAIN: {
|
||||
hue.CONF_BRIDGES: {
|
||||
hue.CONF_HOST: '0.0.0.0',
|
||||
hue.CONF_FILENAME: 'bla.conf',
|
||||
hue.CONF_ALLOW_HUE_GROUPS: False,
|
||||
hue.CONF_ALLOW_UNREACHABLE: True
|
||||
}
|
||||
}
|
||||
}) is True
|
||||
|
||||
assert len(mock_bridge.mock_calls) == 2
|
||||
p_hass, p_entry, p_allow_unreachable, p_allow_groups = \
|
||||
mock_bridge.mock_calls[0][1]
|
||||
|
||||
assert p_hass is hass
|
||||
assert p_entry is entry
|
||||
assert p_allow_unreachable is True
|
||||
assert p_allow_groups is False
|
@ -1,70 +0,0 @@
|
||||
"""Test Hue setup process."""
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components import hue
|
||||
from homeassistant.components.discovery import SERVICE_HUE
|
||||
|
||||
|
||||
async def test_setup_with_multiple_hosts(hass, mock_bridge):
|
||||
"""Multiple hosts specified in the config file."""
|
||||
assert await async_setup_component(hass, hue.DOMAIN, {
|
||||
hue.DOMAIN: {
|
||||
hue.CONF_BRIDGES: [
|
||||
{hue.CONF_HOST: '127.0.0.1'},
|
||||
{hue.CONF_HOST: '192.168.1.10'},
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
assert len(mock_bridge.mock_calls) == 2
|
||||
hosts = sorted(mock_call[1][0] for mock_call in mock_bridge.mock_calls)
|
||||
assert hosts == ['127.0.0.1', '192.168.1.10']
|
||||
|
||||
|
||||
async def test_bridge_discovered(hass, mock_bridge):
|
||||
"""Bridge discovery."""
|
||||
assert await async_setup_component(hass, hue.DOMAIN, {})
|
||||
|
||||
await hass.helpers.discovery.async_discover(SERVICE_HUE, {
|
||||
'host': '192.168.1.10',
|
||||
'serial': '1234567',
|
||||
})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_bridge.mock_calls) == 1
|
||||
assert mock_bridge.mock_calls[0][1][0] == '192.168.1.10'
|
||||
|
||||
|
||||
async def test_bridge_configure_and_discovered(hass, mock_bridge):
|
||||
"""Bridge is in the config file, then we discover it."""
|
||||
assert await async_setup_component(hass, hue.DOMAIN, {
|
||||
hue.DOMAIN: {
|
||||
hue.CONF_BRIDGES: {
|
||||
hue.CONF_HOST: '192.168.1.10'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert len(mock_bridge.mock_calls) == 1
|
||||
assert mock_bridge.mock_calls[0][1][0] == '192.168.1.10'
|
||||
hass.data[hue.DOMAIN] = {'192.168.1.10': {}}
|
||||
|
||||
mock_bridge.reset_mock()
|
||||
|
||||
await hass.helpers.discovery.async_discover(SERVICE_HUE, {
|
||||
'host': '192.168.1.10',
|
||||
'serial': '1234567',
|
||||
})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_bridge.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_setup_no_host(hass, aioclient_mock):
|
||||
"""Check we call discovery if domain specified but no bridges."""
|
||||
aioclient_mock.get(hue.API_NUPNP, json=[])
|
||||
|
||||
result = await async_setup_component(
|
||||
hass, hue.DOMAIN, {hue.DOMAIN: {}})
|
||||
assert result
|
||||
|
||||
assert len(aioclient_mock.mock_calls) == 1
|
@ -5,6 +5,7 @@ from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.bootstrap import async_setup_component
|
||||
from homeassistant.components import discovery
|
||||
from homeassistant.util.dt import utcnow
|
||||
@ -44,13 +45,12 @@ def netdisco_mock():
|
||||
yield
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def mock_discovery(hass, discoveries, config=BASE_CONFIG):
|
||||
async def mock_discovery(hass, discoveries, config=BASE_CONFIG):
|
||||
"""Helper to mock discoveries."""
|
||||
result = yield from async_setup_component(hass, 'discovery', config)
|
||||
result = await async_setup_component(hass, 'discovery', config)
|
||||
assert result
|
||||
|
||||
yield from hass.async_start()
|
||||
await hass.async_start()
|
||||
|
||||
with patch.object(discovery, '_discover', discoveries), \
|
||||
patch('homeassistant.components.discovery.async_discover',
|
||||
@ -59,8 +59,8 @@ def mock_discovery(hass, discoveries, config=BASE_CONFIG):
|
||||
return_value=mock_coro()) as mock_platform:
|
||||
async_fire_time_changed(hass, utcnow())
|
||||
# Work around an issue where our loop.call_soon not get caught
|
||||
yield from hass.async_block_till_done()
|
||||
yield from hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_discover, mock_platform
|
||||
|
||||
@ -154,3 +154,25 @@ def test_load_component_hassio(hass):
|
||||
yield from mock_discovery(hass, discover)
|
||||
|
||||
assert mock_hassio.called
|
||||
|
||||
|
||||
async def test_discover_config_flow(hass):
|
||||
"""Test discovery triggering a config flow."""
|
||||
discovery_info = {
|
||||
'hello': 'world'
|
||||
}
|
||||
|
||||
def discover(netdisco):
|
||||
"""Fake discovery."""
|
||||
return [('mock-service', discovery_info)]
|
||||
|
||||
with patch.dict(discovery.CONFIG_ENTRY_HANDLERS, {
|
||||
'mock-service': 'mock-component'}), patch(
|
||||
'homeassistant.config_entries.FlowManager.async_init') as m_init:
|
||||
await mock_discovery(hass, discover)
|
||||
|
||||
assert len(m_init.mock_calls) == 1
|
||||
args, kwargs = m_init.mock_calls[0][1:]
|
||||
assert args == ('mock-component',)
|
||||
assert kwargs['source'] == config_entries.SOURCE_DISCOVERY
|
||||
assert kwargs['data'] == discovery_info
|
||||
|
Loading…
x
Reference in New Issue
Block a user