2018-03-30 03:15:40 +00:00
|
|
|
"""Code to handle a Hue bridge."""
|
|
|
|
import asyncio
|
2020-02-08 21:20:37 +00:00
|
|
|
from functools import partial
|
2020-02-27 20:53:36 +00:00
|
|
|
import logging
|
2018-03-30 03:15:40 +00:00
|
|
|
|
2020-02-08 21:20:37 +00:00
|
|
|
from aiohttp import client_exceptions
|
2019-04-15 23:45:46 +00:00
|
|
|
import aiohue
|
2018-03-30 03:15:40 +00:00
|
|
|
import async_timeout
|
2019-12-01 05:33:11 +00:00
|
|
|
import slugify as unicode_slug
|
2018-03-30 03:15:40 +00:00
|
|
|
import voluptuous as vol
|
|
|
|
|
2019-12-16 18:45:09 +00:00
|
|
|
from homeassistant import core
|
2020-04-08 21:20:03 +00:00
|
|
|
from homeassistant.const import HTTP_INTERNAL_SERVER_ERROR
|
2019-02-14 04:36:06 +00:00
|
|
|
from homeassistant.exceptions import ConfigEntryNotReady
|
2018-03-30 03:15:40 +00:00
|
|
|
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
|
|
|
|
2020-07-02 12:12:24 +00:00
|
|
|
from .const import (
|
|
|
|
CONF_ALLOW_HUE_GROUPS,
|
|
|
|
CONF_ALLOW_UNREACHABLE,
|
|
|
|
DEFAULT_ALLOW_HUE_GROUPS,
|
|
|
|
DEFAULT_ALLOW_UNREACHABLE,
|
|
|
|
LOGGER,
|
|
|
|
)
|
2018-03-30 03:15:40 +00:00
|
|
|
from .errors import AuthenticationRequired, CannotConnect
|
2019-10-28 15:45:08 +00:00
|
|
|
from .helpers import create_config_flow
|
2020-01-31 22:47:40 +00:00
|
|
|
from .sensor_base import SensorManager
|
2018-03-30 03:15:40 +00:00
|
|
|
|
|
|
|
SERVICE_HUE_SCENE = "hue_activate_scene"
|
|
|
|
ATTR_GROUP_NAME = "group_name"
|
|
|
|
ATTR_SCENE_NAME = "scene_name"
|
2021-02-03 14:42:52 +00:00
|
|
|
ATTR_TRANSITION = "transition"
|
2019-07-31 19:25:30 +00:00
|
|
|
SCENE_SCHEMA = vol.Schema(
|
2021-02-03 14:42:52 +00:00
|
|
|
{
|
|
|
|
vol.Required(ATTR_GROUP_NAME): cv.string,
|
|
|
|
vol.Required(ATTR_SCENE_NAME): cv.string,
|
2021-03-05 17:43:26 +00:00
|
|
|
vol.Optional(ATTR_TRANSITION): cv.positive_int,
|
2021-02-03 14:42:52 +00:00
|
|
|
}
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2020-02-08 21:20:37 +00:00
|
|
|
# How long should we sleep if the hub is busy
|
2020-02-27 20:53:36 +00:00
|
|
|
HUB_BUSY_SLEEP = 0.5
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
2018-03-30 03:15:40 +00:00
|
|
|
|
|
|
|
|
2018-07-20 08:45:20 +00:00
|
|
|
class HueBridge:
|
2018-03-30 03:15:40 +00:00
|
|
|
"""Manages a single Hue bridge."""
|
|
|
|
|
2020-07-02 12:12:24 +00:00
|
|
|
def __init__(self, hass, config_entry):
|
2018-03-30 03:15:40 +00:00
|
|
|
"""Initialize the system."""
|
|
|
|
self.config_entry = config_entry
|
|
|
|
self.hass = hass
|
|
|
|
self.available = True
|
2019-10-28 15:45:08 +00:00
|
|
|
self.authorized = False
|
2018-03-30 03:15:40 +00:00
|
|
|
self.api = None
|
2019-12-01 21:24:16 +00:00
|
|
|
self.parallel_updates_semaphore = None
|
2020-01-31 22:47:40 +00:00
|
|
|
# Jobs to be executed when API is reset.
|
|
|
|
self.reset_jobs = []
|
|
|
|
self.sensor_manager = None
|
2020-07-07 14:33:37 +00:00
|
|
|
self.unsub_config_entry_listener = None
|
2018-03-30 03:15:40 +00:00
|
|
|
|
2018-04-01 16:03:01 +00:00
|
|
|
@property
|
|
|
|
def host(self):
|
|
|
|
"""Return the host of this bridge."""
|
2019-07-31 19:25:30 +00:00
|
|
|
return self.config_entry.data["host"]
|
2018-04-01 16:03:01 +00:00
|
|
|
|
2020-07-02 12:12:24 +00:00
|
|
|
@property
|
|
|
|
def allow_unreachable(self):
|
|
|
|
"""Allow unreachable light bulbs."""
|
|
|
|
return self.config_entry.options.get(
|
|
|
|
CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE
|
|
|
|
)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def allow_groups(self):
|
|
|
|
"""Allow groups defined in the Hue bridge."""
|
|
|
|
return self.config_entry.options.get(
|
|
|
|
CONF_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS
|
|
|
|
)
|
|
|
|
|
2018-03-30 03:15:40 +00:00
|
|
|
async def async_setup(self, tries=0):
|
|
|
|
"""Set up a phue bridge based on host parameter."""
|
2018-04-01 16:03:01 +00:00
|
|
|
host = self.host
|
2018-04-09 14:09:08 +00:00
|
|
|
hass = self.hass
|
2018-03-30 03:15:40 +00:00
|
|
|
|
2019-12-16 18:45:09 +00:00
|
|
|
bridge = aiohue.Bridge(
|
|
|
|
host,
|
|
|
|
username=self.config_entry.data["username"],
|
|
|
|
websession=aiohttp_client.async_get_clientsession(hass),
|
|
|
|
)
|
|
|
|
|
2018-03-30 03:15:40 +00:00
|
|
|
try:
|
2019-12-16 18:45:09 +00:00
|
|
|
await authenticate_bridge(hass, bridge)
|
|
|
|
|
2018-03-30 03:15:40 +00:00
|
|
|
except AuthenticationRequired:
|
2019-02-14 15:01:46 +00:00
|
|
|
# Usernames can become invalid if hub is reset or user removed.
|
2018-03-30 03:15:40 +00:00
|
|
|
# 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.
|
2019-10-28 15:45:08 +00:00
|
|
|
create_config_flow(hass, host)
|
2018-03-30 03:15:40 +00:00
|
|
|
return False
|
|
|
|
|
2020-08-28 11:50:32 +00:00
|
|
|
except CannotConnect as err:
|
2019-05-22 12:59:16 +00:00
|
|
|
LOGGER.error("Error connecting to the Hue bridge at %s", host)
|
2020-08-28 11:50:32 +00:00
|
|
|
raise ConfigEntryNotReady from err
|
2018-03-30 03:15:40 +00:00
|
|
|
|
|
|
|
except Exception: # pylint: disable=broad-except
|
2019-07-31 19:25:30 +00:00
|
|
|
LOGGER.exception("Unknown error connecting with Hue bridge at %s", host)
|
2018-03-30 03:15:40 +00:00
|
|
|
return False
|
|
|
|
|
2019-12-16 18:45:09 +00:00
|
|
|
self.api = bridge
|
2020-01-31 22:47:40 +00:00
|
|
|
self.sensor_manager = SensorManager(self)
|
2019-12-16 18:45:09 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
hass.async_create_task(
|
|
|
|
hass.config_entries.async_forward_entry_setup(self.config_entry, "light")
|
|
|
|
)
|
|
|
|
hass.async_create_task(
|
|
|
|
hass.config_entries.async_forward_entry_setup(
|
|
|
|
self.config_entry, "binary_sensor"
|
|
|
|
)
|
|
|
|
)
|
|
|
|
hass.async_create_task(
|
|
|
|
hass.config_entries.async_forward_entry_setup(self.config_entry, "sensor")
|
|
|
|
)
|
2018-03-30 03:15:40 +00:00
|
|
|
|
2019-12-01 21:24:16 +00:00
|
|
|
self.parallel_updates_semaphore = asyncio.Semaphore(
|
|
|
|
3 if self.api.config.modelid == "BSB001" else 10
|
|
|
|
)
|
|
|
|
|
2020-07-07 14:33:37 +00:00
|
|
|
self.unsub_config_entry_listener = self.config_entry.add_update_listener(
|
2020-07-02 12:12:24 +00:00
|
|
|
_update_listener
|
|
|
|
)
|
|
|
|
|
2019-10-28 15:45:08 +00:00
|
|
|
self.authorized = True
|
2018-03-30 03:15:40 +00:00
|
|
|
return True
|
|
|
|
|
2020-02-08 21:20:37 +00:00
|
|
|
async def async_request_call(self, task):
|
|
|
|
"""Limit parallel requests to Hue hub.
|
2019-12-01 21:24:16 +00:00
|
|
|
|
2020-02-08 21:20:37 +00:00
|
|
|
The Hue hub can only handle a certain amount of parallel requests, total.
|
|
|
|
Although we limit our parallel requests, we still will run into issues because
|
|
|
|
other products are hitting up Hue.
|
|
|
|
|
|
|
|
ClientOSError means hub closed the socket on us.
|
|
|
|
ContentResponseError means hub raised an error.
|
|
|
|
Since we don't make bad requests, this is on them.
|
|
|
|
"""
|
2019-12-01 21:24:16 +00:00
|
|
|
async with self.parallel_updates_semaphore:
|
2020-02-08 21:20:37 +00:00
|
|
|
for tries in range(4):
|
|
|
|
try:
|
|
|
|
return await task()
|
|
|
|
except (
|
|
|
|
client_exceptions.ClientOSError,
|
|
|
|
client_exceptions.ClientResponseError,
|
2020-02-27 20:53:36 +00:00
|
|
|
client_exceptions.ServerDisconnectedError,
|
2020-02-08 21:20:37 +00:00
|
|
|
) as err:
|
2020-02-27 20:53:36 +00:00
|
|
|
if tries == 3:
|
2020-07-05 21:04:19 +00:00
|
|
|
_LOGGER.error("Request failed %s times, giving up", tries)
|
2020-02-27 20:53:36 +00:00
|
|
|
raise
|
|
|
|
|
|
|
|
# We only retry if it's a server error. So raise on all 4XX errors.
|
|
|
|
if (
|
2020-02-08 21:20:37 +00:00
|
|
|
isinstance(err, client_exceptions.ClientResponseError)
|
2020-04-08 21:20:03 +00:00
|
|
|
and err.status < HTTP_INTERNAL_SERVER_ERROR
|
2020-02-08 21:20:37 +00:00
|
|
|
):
|
|
|
|
raise
|
|
|
|
|
|
|
|
await asyncio.sleep(HUB_BUSY_SLEEP * tries)
|
2019-12-01 21:24:16 +00:00
|
|
|
|
2018-04-12 12:28:54 +00:00
|
|
|
async def async_reset(self):
|
|
|
|
"""Reset this bridge to default state.
|
|
|
|
|
|
|
|
Will cancel any scheduled setup retry and will unload
|
|
|
|
the config entry.
|
|
|
|
"""
|
|
|
|
# The bridge can be in 3 states:
|
|
|
|
# - Setup was successful, self.api is not None
|
|
|
|
# - Authentication was wrong, self.api is None, not retrying setup.
|
|
|
|
|
|
|
|
# If the authentication was wrong.
|
|
|
|
if self.api is None:
|
|
|
|
return True
|
|
|
|
|
2020-01-31 22:47:40 +00:00
|
|
|
while self.reset_jobs:
|
|
|
|
self.reset_jobs.pop()()
|
|
|
|
|
2020-07-07 14:33:37 +00:00
|
|
|
if self.unsub_config_entry_listener is not None:
|
|
|
|
self.unsub_config_entry_listener()
|
2020-07-02 12:12:24 +00:00
|
|
|
|
2018-04-12 12:28:54 +00:00
|
|
|
# If setup was successful, we set api variable, forwarded entry and
|
|
|
|
# register service
|
2019-04-18 05:13:03 +00:00
|
|
|
results = await asyncio.gather(
|
|
|
|
self.hass.config_entries.async_forward_entry_unload(
|
2019-07-31 19:25:30 +00:00
|
|
|
self.config_entry, "light"
|
|
|
|
),
|
2019-04-18 05:13:03 +00:00
|
|
|
self.hass.config_entries.async_forward_entry_unload(
|
2019-07-31 19:25:30 +00:00
|
|
|
self.config_entry, "binary_sensor"
|
|
|
|
),
|
2019-04-18 05:13:03 +00:00
|
|
|
self.hass.config_entries.async_forward_entry_unload(
|
2019-07-31 19:25:30 +00:00
|
|
|
self.config_entry, "sensor"
|
|
|
|
),
|
|
|
|
)
|
2020-01-31 22:47:40 +00:00
|
|
|
|
2019-04-18 05:13:03 +00:00
|
|
|
# None and True are OK
|
|
|
|
return False not in results
|
2018-04-12 12:28:54 +00:00
|
|
|
|
2020-10-11 19:01:49 +00:00
|
|
|
async def hue_activate_scene(self, call, updated=False, hide_warnings=False):
|
2018-03-30 03:15:40 +00:00
|
|
|
"""Service to call directly into bridge to set scenes."""
|
|
|
|
group_name = call.data[ATTR_GROUP_NAME]
|
|
|
|
scene_name = call.data[ATTR_SCENE_NAME]
|
2021-03-05 17:43:26 +00:00
|
|
|
transition = call.data.get(ATTR_TRANSITION)
|
2018-03-30 03:15:40 +00:00
|
|
|
|
|
|
|
group = next(
|
2019-07-31 19:25:30 +00:00
|
|
|
(group for group in self.api.groups.values() if group.name == group_name),
|
|
|
|
None,
|
|
|
|
)
|
2018-03-30 03:15:40 +00:00
|
|
|
|
2018-06-27 19:22:29 +00:00
|
|
|
# Additional scene logic to handle duplicate scene names across groups
|
|
|
|
scene = next(
|
2019-07-31 19:25:30 +00:00
|
|
|
(
|
|
|
|
scene
|
|
|
|
for scene in self.api.scenes.values()
|
|
|
|
if scene.name == scene_name
|
|
|
|
and group is not None
|
|
|
|
and sorted(scene.lights) == sorted(group.lights)
|
|
|
|
),
|
|
|
|
None,
|
|
|
|
)
|
2018-03-30 03:15:40 +00:00
|
|
|
|
|
|
|
# If we can't find it, fetch latest info.
|
2018-06-27 19:22:29 +00:00
|
|
|
if not updated and (group is None or scene is None):
|
2020-02-08 21:20:37 +00:00
|
|
|
await self.async_request_call(self.api.groups.update)
|
|
|
|
await self.async_request_call(self.api.scenes.update)
|
2020-10-11 19:01:49 +00:00
|
|
|
return await self.hue_activate_scene(call, updated=True)
|
2018-03-30 03:15:40 +00:00
|
|
|
|
|
|
|
if group is None:
|
2020-10-11 19:01:49 +00:00
|
|
|
if not hide_warnings:
|
|
|
|
LOGGER.warning(
|
|
|
|
"Unable to find group %s" " on bridge %s", group_name, self.host
|
|
|
|
)
|
|
|
|
return False
|
2018-03-30 03:15:40 +00:00
|
|
|
|
2018-06-27 19:22:29 +00:00
|
|
|
if scene is None:
|
2019-07-31 19:25:30 +00:00
|
|
|
LOGGER.warning("Unable to find scene %s", scene_name)
|
2020-10-11 19:01:49 +00:00
|
|
|
return False
|
2018-03-30 03:15:40 +00:00
|
|
|
|
2021-02-03 14:42:52 +00:00
|
|
|
return await self.async_request_call(
|
|
|
|
partial(group.set_action, scene=scene.id, transitiontime=transition)
|
|
|
|
)
|
2018-03-30 03:15:40 +00:00
|
|
|
|
2019-10-28 15:45:08 +00:00
|
|
|
async def handle_unauthorized_error(self):
|
|
|
|
"""Create a new config flow when the authorization is no longer valid."""
|
|
|
|
if not self.authorized:
|
|
|
|
# we already created a new config flow, no need to do it again
|
|
|
|
return
|
|
|
|
LOGGER.error(
|
2021-03-19 14:26:36 +00:00
|
|
|
"Unable to authorize to bridge %s, setup the linking again", self.host
|
2019-10-28 15:45:08 +00:00
|
|
|
)
|
|
|
|
self.authorized = False
|
|
|
|
create_config_flow(self.hass, self.host)
|
|
|
|
|
2018-03-30 03:15:40 +00:00
|
|
|
|
2019-12-16 18:45:09 +00:00
|
|
|
async def authenticate_bridge(hass: core.HomeAssistant, bridge: aiohue.Bridge):
|
2018-03-30 03:15:40 +00:00
|
|
|
"""Create a bridge object and verify authentication."""
|
|
|
|
try:
|
2019-05-24 22:55:13 +00:00
|
|
|
with async_timeout.timeout(10):
|
2018-03-30 03:15:40 +00:00
|
|
|
# Create username if we don't have one
|
2019-12-16 18:45:09 +00:00
|
|
|
if not bridge.username:
|
2019-12-01 05:33:11 +00:00
|
|
|
device_name = unicode_slug.slugify(
|
|
|
|
hass.config.location_name, max_length=19
|
|
|
|
)
|
|
|
|
await bridge.create_user(f"home-assistant#{device_name}")
|
|
|
|
|
2018-03-30 03:15:40 +00:00
|
|
|
# Initialize bridge (and validate our username)
|
|
|
|
await bridge.initialize()
|
|
|
|
|
2020-08-28 11:50:32 +00:00
|
|
|
except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized) as err:
|
|
|
|
raise AuthenticationRequired from err
|
2020-07-02 12:12:24 +00:00
|
|
|
except (
|
|
|
|
asyncio.TimeoutError,
|
|
|
|
client_exceptions.ClientOSError,
|
|
|
|
client_exceptions.ServerDisconnectedError,
|
|
|
|
client_exceptions.ContentTypeError,
|
2020-08-28 11:50:32 +00:00
|
|
|
) as err:
|
|
|
|
raise CannotConnect from err
|
|
|
|
except aiohue.AiohueException as err:
|
2019-07-31 19:25:30 +00:00
|
|
|
LOGGER.exception("Unknown Hue linking error occurred")
|
2020-08-28 11:50:32 +00:00
|
|
|
raise AuthenticationRequired from err
|
2020-07-02 12:12:24 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def _update_listener(hass, entry):
|
|
|
|
"""Handle options update."""
|
|
|
|
await hass.config_entries.async_reload(entry.entry_id)
|