From e1e69250978de5f8e64c0a7228319746e31b09f8 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 16 Nov 2021 20:59:17 +0100 Subject: [PATCH] Refactor of Hue integration with full V2 support (#58996) Co-authored-by: Paulus Schoutsen --- CODEOWNERS | 2 +- homeassistant/components/hue/__init__.py | 179 +- homeassistant/components/hue/binary_sensor.py | 80 +- homeassistant/components/hue/bridge.py | 301 +-- homeassistant/components/hue/config_flow.py | 107 +- homeassistant/components/hue/const.py | 34 +- .../components/hue/device_trigger.py | 262 +- homeassistant/components/hue/light.py | 577 +---- homeassistant/components/hue/manifest.json | 4 +- homeassistant/components/hue/migration.py | 174 ++ homeassistant/components/hue/scene.py | 117 + homeassistant/components/hue/sensor.py | 157 +- homeassistant/components/hue/services.py | 158 ++ homeassistant/components/hue/services.yaml | 5 + homeassistant/components/hue/strings.json | 15 +- homeassistant/components/hue/switch.py | 94 + .../components/hue/translations/en.json | 41 +- homeassistant/components/hue/v1/__init__.py | 1 + .../components/hue/v1/binary_sensor.py | 56 + .../components/hue/v1/device_trigger.py | 185 ++ .../components/hue/{ => v1}/helpers.py | 15 +- .../components/hue/{ => v1}/hue_event.py | 15 +- homeassistant/components/hue/v1/light.py | 557 +++++ homeassistant/components/hue/v1/sensor.py | 135 ++ .../components/hue/{ => v1}/sensor_base.py | 14 +- .../components/hue/{ => v1}/sensor_device.py | 18 +- homeassistant/components/hue/v2/__init__.py | 1 + .../components/hue/v2/binary_sensor.py | 110 + homeassistant/components/hue/v2/device.py | 86 + .../components/hue/v2/device_trigger.py | 115 + homeassistant/components/hue/v2/entity.py | 113 + homeassistant/components/hue/v2/group.py | 249 ++ homeassistant/components/hue/v2/hue_event.py | 57 + homeassistant/components/hue/v2/light.py | 187 ++ homeassistant/components/hue/v2/sensor.py | 174 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hue/conftest.py | 270 ++- tests/components/hue/const.py | 97 + .../components/hue/fixtures/v2_resources.json | 2107 +++++++++++++++++ tests/components/hue/test_binary_sensor.py | 61 + tests/components/hue/test_bridge.py | 357 +-- tests/components/hue/test_config_flow.py | 294 ++- ...e_trigger.py => test_device_trigger_v1.py} | 60 +- .../components/hue/test_device_trigger_v2.py | 82 + tests/components/hue/test_init.py | 28 +- .../hue/test_init_multiple_bridges.py | 143 -- .../hue/{test_light.py => test_light_v1.py} | 255 +- tests/components/hue/test_light_v2.py | 353 +++ tests/components/hue/test_migration.py | 179 ++ tests/components/hue/test_scene.py | 114 + ...{test_sensor_base.py => test_sensor_v1.py} | 141 +- tests/components/hue/test_sensor_v2.py | 123 + tests/components/hue/test_services.py | 265 +++ tests/components/hue/test_switch.py | 107 + 55 files changed, 7163 insertions(+), 2272 deletions(-) create mode 100644 homeassistant/components/hue/migration.py create mode 100644 homeassistant/components/hue/scene.py create mode 100644 homeassistant/components/hue/services.py create mode 100644 homeassistant/components/hue/switch.py create mode 100644 homeassistant/components/hue/v1/__init__.py create mode 100644 homeassistant/components/hue/v1/binary_sensor.py create mode 100644 homeassistant/components/hue/v1/device_trigger.py rename homeassistant/components/hue/{ => v1}/helpers.py (77%) rename homeassistant/components/hue/{ => v1}/hue_event.py (90%) create mode 100644 homeassistant/components/hue/v1/light.py create mode 100644 homeassistant/components/hue/v1/sensor.py rename homeassistant/components/hue/{ => v1}/sensor_base.py (95%) rename homeassistant/components/hue/{ => v1}/sensor_device.py (83%) create mode 100644 homeassistant/components/hue/v2/__init__.py create mode 100644 homeassistant/components/hue/v2/binary_sensor.py create mode 100644 homeassistant/components/hue/v2/device.py create mode 100644 homeassistant/components/hue/v2/device_trigger.py create mode 100644 homeassistant/components/hue/v2/entity.py create mode 100644 homeassistant/components/hue/v2/group.py create mode 100644 homeassistant/components/hue/v2/hue_event.py create mode 100644 homeassistant/components/hue/v2/light.py create mode 100644 homeassistant/components/hue/v2/sensor.py create mode 100644 tests/components/hue/const.py create mode 100644 tests/components/hue/fixtures/v2_resources.json create mode 100644 tests/components/hue/test_binary_sensor.py rename tests/components/hue/{test_device_trigger.py => test_device_trigger_v1.py} (71%) create mode 100644 tests/components/hue/test_device_trigger_v2.py delete mode 100644 tests/components/hue/test_init_multiple_bridges.py rename tests/components/hue/{test_light.py => test_light_v1.py} (77%) create mode 100644 tests/components/hue/test_light_v2.py create mode 100644 tests/components/hue/test_migration.py create mode 100644 tests/components/hue/test_scene.py rename tests/components/hue/{test_sensor_base.py => test_sensor_v1.py} (81%) create mode 100644 tests/components/hue/test_sensor_v2.py create mode 100644 tests/components/hue/test_services.py create mode 100644 tests/components/hue/test_switch.py diff --git a/CODEOWNERS b/CODEOWNERS index a43b51d8b5b40..c143826fe1495 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -231,7 +231,7 @@ homeassistant/components/homematic/* @pvizeli @danielperna84 homeassistant/components/honeywell/* @rdfurman homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop @fphammerle -homeassistant/components/hue/* @balloob @frenck +homeassistant/components/hue/* @balloob @frenck @marcelveldt homeassistant/components/huisbaasje/* @dennisschroer homeassistant/components/humidifier/* @home-assistant/core @Shulyaka homeassistant/components/hunterdouglas_powerview/* @bdraco diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 8e0f194e90487..794283e09f590 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -1,79 +1,41 @@ """Support for the Philips Hue system.""" -import asyncio -import logging from aiohue.util import normalize_bridge_id -import voluptuous as vol from homeassistant import config_entries, core from homeassistant.components import persistent_notification -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.service import verify_domain_control +from homeassistant.helpers import device_registry as dr from .bridge import HueBridge -from .const import ( - ATTR_GROUP_NAME, - ATTR_SCENE_NAME, - ATTR_TRANSITION, - CONF_ALLOW_HUE_GROUPS, - CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_HUE_GROUPS, - DEFAULT_ALLOW_UNREACHABLE, - DOMAIN, -) - -_LOGGER = logging.getLogger(__name__) -SERVICE_HUE_SCENE = "hue_activate_scene" +from .const import DOMAIN, SERVICE_HUE_ACTIVATE_SCENE +from .migration import check_migration +from .services import async_register_services async def async_setup_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry -): +) -> bool: """Set up a bridge from a config entry.""" + # check (and run) migrations if needed + await check_migration(hass, entry) - # Migrate allow_unreachable from config entry data to config entry options - if ( - CONF_ALLOW_UNREACHABLE not in entry.options - and CONF_ALLOW_UNREACHABLE in entry.data - and entry.data[CONF_ALLOW_UNREACHABLE] != DEFAULT_ALLOW_UNREACHABLE - ): - options = { - **entry.options, - CONF_ALLOW_UNREACHABLE: entry.data[CONF_ALLOW_UNREACHABLE], - } - data = entry.data.copy() - data.pop(CONF_ALLOW_UNREACHABLE) - hass.config_entries.async_update_entry(entry, data=data, options=options) - - # Migrate allow_hue_groups from config entry data to config entry options - if ( - CONF_ALLOW_HUE_GROUPS not in entry.options - and CONF_ALLOW_HUE_GROUPS in entry.data - and entry.data[CONF_ALLOW_HUE_GROUPS] != DEFAULT_ALLOW_HUE_GROUPS - ): - options = { - **entry.options, - CONF_ALLOW_HUE_GROUPS: entry.data[CONF_ALLOW_HUE_GROUPS], - } - data = entry.data.copy() - data.pop(CONF_ALLOW_HUE_GROUPS) - hass.config_entries.async_update_entry(entry, data=data, options=options) - + # setup the bridge instance bridge = HueBridge(hass, entry) - - if not await bridge.async_setup(): + if not await bridge.async_initialize_bridge(): return False - _register_services(hass) + # register Hue domain services + async_register_services(hass) - config = bridge.api.config + api = bridge.api # For backwards compat - unique_id = normalize_bridge_id(config.bridgeid) + unique_id = normalize_bridge_id(api.config.bridge_id) if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=unique_id) # For recovering from bug where we incorrectly assumed homekit ID = bridge ID + # Remove this logic after Home Assistant 2022.4 elif entry.unique_id != unique_id: # Find entries with this unique ID other_entry = next( @@ -84,7 +46,6 @@ async def async_setup_entry( ), None, ) - if other_entry is None: # If no other entry, update unique ID of this entry ID. hass.config_entries.async_update_entry(entry, unique_id=unique_id) @@ -100,88 +61,54 @@ async def async_setup_entry( hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False + # add bridge device to device registry device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, config.mac)}, - identifiers={(DOMAIN, config.bridgeid)}, - manufacturer="Signify", - name=config.name, - model=config.modelid, - sw_version=config.swversion, - ) - - if config.modelid == "BSB002" and config.swversion < "1935144040": - persistent_notification.async_create( - hass, - "Your Hue hub has a known security vulnerability ([CVE-2020-6007](https://cve.circl.lu/cve/CVE-2020-6007)). Go to the Hue app and check for software updates.", - "Signify Hue", - "hue_hub_firmware", + if bridge.api_version == 1: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)}, + identifiers={(DOMAIN, api.config.bridge_id)}, + manufacturer="Signify", + name=api.config.name, + model=api.config.model_id, + sw_version=api.config.software_version, ) - - elif config.swupdate2_bridge_state == "readytoinstall": - err = ( - "Please check for software updates of the bridge in the Philips Hue App.", - "Signify Hue", - "hue_hub_firmware", + # create persistent notification if we found a bridge version with security vulnerability + if ( + api.config.model_id == "BSB002" + and api.config.software_version < "1935144040" + ): + persistent_notification.async_create( + hass, + "Your Hue hub has a known security vulnerability ([CVE-2020-6007] " + "(https://cve.circl.lu/cve/CVE-2020-6007)). " + "Go to the Hue app and check for software updates.", + "Signify Hue", + "hue_hub_firmware", + ) + else: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)}, + identifiers={ + (DOMAIN, api.config.bridge_id), + (DOMAIN, api.config.bridge_device.id), + }, + manufacturer=api.config.bridge_device.product_data.manufacturer_name, + name=api.config.name, + model=api.config.model_id, + sw_version=api.config.software_version, ) - _LOGGER.warning(err) return True -async def async_unload_entry(hass, entry): +async def async_unload_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): """Unload a config entry.""" unload_success = await hass.data[DOMAIN][entry.entry_id].async_reset() if len(hass.data[DOMAIN]) == 0: hass.data.pop(DOMAIN) - hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE) + hass.services.async_remove(DOMAIN, SERVICE_HUE_ACTIVATE_SCENE) return unload_success - - -@core.callback -def _register_services(hass): - """Register Hue services.""" - - async def hue_activate_scene(call, skip_reload=True): - """Handle activation of Hue scene.""" - # Get parameters - group_name = call.data[ATTR_GROUP_NAME] - scene_name = call.data[ATTR_SCENE_NAME] - - # Call the set scene function on each bridge - tasks = [ - bridge.hue_activate_scene( - call.data, skip_reload=skip_reload, hide_warnings=skip_reload - ) - for bridge in hass.data[DOMAIN].values() - if isinstance(bridge, HueBridge) - ] - results = await asyncio.gather(*tasks) - - # Did *any* bridge succeed? If not, refresh / retry - # Note that we'll get a "None" value for a successful call - if None not in results: - if skip_reload: - await hue_activate_scene(call, skip_reload=False) - return - _LOGGER.warning( - "No bridge was able to activate " "scene %s in group %s", - scene_name, - group_name, - ) - - if not hass.services.has_service(DOMAIN, SERVICE_HUE_SCENE): - # Register a local handler for scene activation - hass.services.async_register( - DOMAIN, - SERVICE_HUE_SCENE, - verify_domain_control(hass, DOMAIN)(hue_activate_scene), - schema=vol.Schema( - { - vol.Required(ATTR_GROUP_NAME): cv.string, - vol.Required(ATTR_SCENE_NAME): cv.string, - vol.Optional(ATTR_TRANSITION): cv.positive_int, - } - ), - ) diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index c675544503c57..b66b85a48445c 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -1,56 +1,24 @@ -"""Hue binary sensor entities.""" -from aiohue.sensors import TYPE_ZLL_PRESENCE - -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOTION, - BinarySensorEntity, -) - -from .const import DOMAIN as HUE_DOMAIN -from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor - -PRESENCE_NAME_FORMAT = "{} motion" - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Defer binary sensor setup to the shared sensor module.""" - bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] - - if not bridge.sensor_manager: - return - - await bridge.sensor_manager.async_register_component( - "binary_sensor", async_add_entities - ) - - -class HuePresence(GenericZLLSensor, BinarySensorEntity): - """The presence sensor entity for a Hue motion sensor device.""" - - _attr_device_class = DEVICE_CLASS_MOTION - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self.sensor.presence - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - attributes = super().extra_state_attributes - if "sensitivity" in self.sensor.config: - attributes["sensitivity"] = self.sensor.config["sensitivity"] - if "sensitivitymax" in self.sensor.config: - attributes["sensitivity_max"] = self.sensor.config["sensitivitymax"] - return attributes - - -SENSOR_CONFIG_MAP.update( - { - TYPE_ZLL_PRESENCE: { - "platform": "binary_sensor", - "name_format": PRESENCE_NAME_FORMAT, - "class": HuePresence, - } - } -) +"""Support for Hue binary sensors.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .bridge import HueBridge +from .const import DOMAIN +from .v1.binary_sensor import async_setup_entry as setup_entry_v1 +from .v2.binary_sensor import async_setup_entry as setup_entry_v2 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + if bridge.api_version == 1: + await setup_entry_v1(hass, config_entry, async_add_entities) + else: + await setup_entry_v2(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index e669cf7b031f2..5005f858a5800 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -2,126 +2,119 @@ from __future__ import annotations import asyncio -from functools import partial +from collections.abc import Callable from http import HTTPStatus import logging +from typing import Any from aiohttp import client_exceptions -import aiohue +from aiohue import HueBridgeV1, HueBridgeV2, LinkButtonNotPressed, Unauthorized +from aiohue.errors import AiohueException import async_timeout -import slugify as unicode_slug from homeassistant import core +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import ( - ATTR_GROUP_NAME, - ATTR_SCENE_NAME, - ATTR_TRANSITION, - CONF_ALLOW_HUE_GROUPS, - CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_HUE_GROUPS, - DEFAULT_ALLOW_UNREACHABLE, - DOMAIN, - LOGGER, -) -from .errors import AuthenticationRequired, CannotConnect -from .helpers import create_config_flow -from .sensor_base import SensorManager +from .const import CONF_API_VERSION, DOMAIN +from .v1.sensor_base import SensorManager +from .v2.device import async_setup_devices +from .v2.hue_event import async_setup_hue_events # How long should we sleep if the hub is busy HUB_BUSY_SLEEP = 0.5 -PLATFORMS = ["light", "binary_sensor", "sensor"] - -_LOGGER = logging.getLogger(__name__) +PLATFORMS_v1 = ["light", "binary_sensor", "sensor"] +PLATFORMS_v2 = ["light", "binary_sensor", "sensor", "scene", "switch"] class HueBridge: """Manages a single Hue bridge.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: core.HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the system.""" self.config_entry = config_entry self.hass = hass - self.available = True self.authorized = False - self.api = None - self.parallel_updates_semaphore = None + self.parallel_updates_semaphore = asyncio.Semaphore( + 3 if self.api_version == 1 else 10 + ) # Jobs to be executed when API is reset. - self.reset_jobs = [] - self.sensor_manager = None - self._update_callbacks = {} + self.reset_jobs: list[core.CALLBACK_TYPE] = [] + self.sensor_manager: SensorManager | None = None + self.logger = logging.getLogger(__name__) + # store actual api connection to bridge as api + app_key: str = self.config_entry.data[CONF_API_KEY] + websession = aiohttp_client.async_get_clientsession(hass) + if self.api_version == 1: + self.api = HueBridgeV1(self.host, app_key, websession) + else: + self.api = HueBridgeV2(self.host, app_key, websession) + # store (this) bridge object in hass data + hass.data.setdefault(DOMAIN, {})[self.config_entry.entry_id] = self @property - def host(self): + def host(self) -> str: """Return the host of this bridge.""" - return self.config_entry.data["host"] - - @property - def allow_unreachable(self): - """Allow unreachable light bulbs.""" - return self.config_entry.options.get( - CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE - ) + return self.config_entry.data[CONF_HOST] @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 - ) - - async def async_setup(self, tries=0): - """Set up a phue bridge based on host parameter.""" - host = self.host - hass = self.hass - - bridge = aiohue.Bridge( - host, - username=self.config_entry.data["username"], - websession=aiohttp_client.async_get_clientsession(hass), - ) + def api_version(self) -> int: + """Return api version we're set-up for.""" + return self.config_entry.data[CONF_API_VERSION] + async def async_initialize_bridge(self) -> bool: + """Initialize Connection with the Hue API.""" try: - await authenticate_bridge(hass, bridge) + with async_timeout.timeout(10): + await self.api.initialize() - except AuthenticationRequired: + except (LinkButtonNotPressed, Unauthorized): # 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. - create_config_flow(hass, host) + create_config_flow(self.hass, self.host) return False - - except CannotConnect as err: + except ( + asyncio.TimeoutError, + client_exceptions.ClientOSError, + client_exceptions.ServerDisconnectedError, + client_exceptions.ContentTypeError, + ) as err: raise ConfigEntryNotReady( - f"Error connecting to the Hue bridge at {host}" + f"Error connecting to the Hue bridge at {self.host}" ) from err - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unknown error connecting with Hue bridge at %s", host) + self.logger.exception("Unknown error connecting to Hue bridge") return False - self.api = bridge - if bridge.sensors is not None: - self.sensor_manager = SensorManager(self) - - hass.data.setdefault(DOMAIN, {})[self.config_entry.entry_id] = self - hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS) - - self.parallel_updates_semaphore = asyncio.Semaphore( - 3 if self.api.config.modelid == "BSB001" else 10 - ) - + # v1 specific initialization/setup code here + if self.api_version == 1: + if self.api.sensors is not None: + self.sensor_manager = SensorManager(self) + self.hass.config_entries.async_setup_platforms( + self.config_entry, PLATFORMS_v1 + ) + + # v2 specific initialization/setup code here + else: + await async_setup_devices(self) + await async_setup_hue_events(self) + self.hass.config_entries.async_setup_platforms( + self.config_entry, PLATFORMS_v2 + ) + + # add listener for config entry updates. self.reset_jobs.append(self.config_entry.add_update_listener(_update_listener)) - self.reset_jobs.append(asyncio.create_task(self._subscribe_events()).cancel) - self.authorized = True return True - async def async_request_call(self, task): + async def async_request_call( + self, task: Callable, *args, allowed_errors: list[str] | None = None, **kwargs + ) -> Any: """Limit parallel requests to Hue hub. The Hue hub can only handle a certain amount of parallel requests, total. @@ -132,17 +125,30 @@ async def async_request_call(self, task): ContentResponseError means hub raised an error. Since we don't make bad requests, this is on them. """ + max_tries = 5 async with self.parallel_updates_semaphore: - for tries in range(4): + for tries in range(max_tries): try: - return await task() + return await task(*args, **kwargs) + except AiohueException as err: + # The new V2 api is a bit more fanatic with throwing errors + # some of which we accept in certain conditions + # handle that here. Note that these errors are strings and do not have + # an identifier or something. + if allowed_errors is not None and str(err) in allowed_errors: + # log only + self.logger.debug( + "Ignored error/warning from Hue API: %s", str(err) + ) + return None + raise err except ( client_exceptions.ClientOSError, client_exceptions.ClientResponseError, client_exceptions.ServerDisconnectedError, ) as err: - if tries == 3: - _LOGGER.error("Request failed %s times, giving up", tries) + if tries == max_tries: + self.logger.error("Request failed %s times, giving up", tries) raise # We only retry if it's a server error. So raise on all 4XX errors. @@ -154,7 +160,7 @@ async def async_request_call(self, task): await asyncio.sleep(HUB_BUSY_SLEEP * tries) - async def async_reset(self): + async def async_reset(self) -> bool: """Reset this bridge to default state. Will cancel any scheduled setup retry and will unload @@ -171,12 +177,9 @@ async def async_reset(self): while self.reset_jobs: self.reset_jobs.pop()() - self._update_callbacks = {} - - # If setup was successful, we set api variable, forwarded entry and - # register service + # Unload platforms unload_success = await self.hass.config_entries.async_unload_platforms( - self.config_entry, PLATFORMS + self.config_entry, PLATFORMS_v1 if self.api_version == 1 else PLATFORMS_v2 ) if unload_success: @@ -184,127 +187,29 @@ async def async_reset(self): return unload_success - async def hue_activate_scene(self, data, skip_reload=False, hide_warnings=False): - """Service to call directly into bridge to set scenes.""" - if self.api.scenes is None: - _LOGGER.warning("Hub %s does not support scenes", self.api.host) - return - - group_name = data[ATTR_GROUP_NAME] - scene_name = data[ATTR_SCENE_NAME] - transition = data.get(ATTR_TRANSITION) - - group = next( - (group for group in self.api.groups.values() if group.name == group_name), - None, - ) - - # Additional scene logic to handle duplicate scene names across groups - scene = next( - ( - 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, - ) - - # If we can't find it, fetch latest info. - if not skip_reload and (group is None or scene is None): - await self.async_request_call(self.api.groups.update) - await self.async_request_call(self.api.scenes.update) - return await self.hue_activate_scene(data, skip_reload=True) - - if group is None: - if not hide_warnings: - LOGGER.warning( - "Unable to find group %s" " on bridge %s", group_name, self.host - ) - return False - - if scene is None: - LOGGER.warning("Unable to find scene %s", scene_name) - return False - - return await self.async_request_call( - partial(group.set_action, scene=scene.id, transitiontime=transition) - ) - - async def handle_unauthorized_error(self): + async def handle_unauthorized_error(self) -> None: """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( + self.logger.error( "Unable to authorize to bridge %s, setup the linking again", self.host ) self.authorized = False create_config_flow(self.hass, self.host) - async def _subscribe_events(self): - """Subscribe to Hue events.""" - try: - async for updated_object in self.api.listen_events(): - key = (updated_object.ITEM_TYPE, updated_object.id) - - if key in self._update_callbacks: - for callback in self._update_callbacks[key]: - callback() - - except GeneratorExit: - pass - - @core.callback - def listen_updates(self, item_type, item_id, update_callback): - """Listen to updates.""" - key = (item_type, item_id) - callbacks: list[core.CALLBACK_TYPE] | None = self._update_callbacks.get(key) - - if callbacks is None: - callbacks = self._update_callbacks[key] = [] - - callbacks.append(update_callback) - - @core.callback - def unsub(): - try: - callbacks.remove(update_callback) - except ValueError: - pass - - return unsub - - -async def authenticate_bridge(hass: core.HomeAssistant, bridge: aiohue.Bridge): - """Create a bridge object and verify authentication.""" - try: - async with async_timeout.timeout(10): - # Create username if we don't have one - if not bridge.username: - device_name = unicode_slug.slugify( - hass.config.location_name, max_length=19 - ) - await bridge.create_user(f"home-assistant#{device_name}") - - # Initialize bridge (and validate our username) - await bridge.initialize() - - except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized) as err: - raise AuthenticationRequired from err - except ( - asyncio.TimeoutError, - client_exceptions.ClientOSError, - client_exceptions.ServerDisconnectedError, - client_exceptions.ContentTypeError, - ) as err: - raise CannotConnect from err - except aiohue.AiohueException as err: - LOGGER.exception("Unknown Hue linking error occurred") - raise AuthenticationRequired from err - - -async def _update_listener(hass, entry): - """Handle options update.""" + +async def _update_listener(hass: core.HomeAssistant, entry: ConfigEntry) -> None: + """Handle ConfigEntry options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +def create_config_flow(hass: core.HomeAssistant, host: str) -> None: + """Start a config flow.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"host": host}, + ) + ) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 0ffa7e358f07e..0499031c4f2e2 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -2,31 +2,35 @@ from __future__ import annotations import asyncio -from typing import Any +import logging from urllib.parse import urlparse -import aiohue -from aiohue.discovery import discover_nupnp, normalize_bridge_id +from aiohue import LinkButtonNotPressed, create_app_key +from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp +from aiohue.util import normalize_bridge_id import async_timeout +import slugify as unicode_slug import voluptuous as vol -from homeassistant import config_entries, core +from homeassistant import config_entries from homeassistant.components import ssdp, zeroconf -from homeassistant.const import CONF_HOST, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .bridge import authenticate_bridge from .const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, + CONF_API_VERSION, DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_UNREACHABLE, DOMAIN, - LOGGER, ) -from .errors import AuthenticationRequired, CannotConnect +from .errors import CannotConnect + +LOGGER = logging.getLogger(__name__) HUE_MANUFACTURERURL = ("http://www.philips.com", "http://www.philips-hue.com") HUE_IGNORED_BRIDGE_NAMES = ["Home Assistant Bridge", "Espalexa"] @@ -40,33 +44,35 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> HueOptionsFlowHandler: """Get the options flow for this handler.""" return HueOptionsFlowHandler(config_entry) - def __init__(self): + def __init__(self) -> None: """Initialize the Hue flow.""" - self.bridge: aiohue.Bridge | None = None - self.discovered_bridges: dict[str, aiohue.Bridge] | None = None + self.bridge: DiscoveredHueBridge | None = None + self.discovered_bridges: dict[str, DiscoveredHueBridge] | None = None - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: """Handle a flow initialized by the user.""" # This is for backwards compatibility. return await self.async_step_init(user_input) - @core.callback - def _async_get_bridge(self, host: str, bridge_id: str | None = None): - """Return a bridge object.""" + async def _get_bridge( + self, host: str, bridge_id: str | None = None + ) -> DiscoveredHueBridge: + """Return a DiscoveredHueBridge object.""" + bridge = await discover_bridge( + host, websession=aiohttp_client.async_get_clientsession(self.hass) + ) if bridge_id is not None: bridge_id = normalize_bridge_id(bridge_id) + assert bridge_id == bridge.id + return bridge - return aiohue.Bridge( - host, - websession=aiohttp_client.async_get_clientsession(self.hass), - bridge_id=bridge_id, - ) - - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input: ConfigType | None = None) -> FlowResult: """Handle a flow start.""" # Check if user chooses manual entry if user_input is not None and user_input["id"] == HUE_MANUAL_BRIDGE_ID: @@ -116,7 +122,7 @@ async def async_step_init(self, user_input=None): ) async def async_step_manual( - self, user_input: dict[str, Any] | None = None + self, user_input: ConfigType | None = None ) -> FlowResult: """Handle manual bridge setup.""" if user_input is None: @@ -126,10 +132,10 @@ async def async_step_manual( ) self._async_abort_entries_match({"host": user_input["host"]}) - self.bridge = self._async_get_bridge(user_input[CONF_HOST]) + self.bridge = await self._get_bridge(user_input[CONF_HOST]) return await self.async_step_link() - async def async_step_link(self, user_input=None): + async def async_step_link(self, user_input: ConfigType | None = None) -> FlowResult: """Attempt to link with the Hue bridge. Given a configured host, will ask the user to press the link button @@ -141,10 +147,17 @@ async def async_step_link(self, user_input=None): bridge = self.bridge assert bridge is not None errors = {} + device_name = unicode_slug.slugify( + self.hass.config.location_name, max_length=19 + ) try: - await authenticate_bridge(self.hass, bridge) - except AuthenticationRequired: + app_key = await create_app_key( + bridge.host, + f"home-assistant#{device_name}", + websession=aiohttp_client.async_get_clientsession(self.hass), + ) + except LinkButtonNotPressed: errors["base"] = "register_failed" except CannotConnect: LOGGER.error("Error connecting to the Hue bridge at %s", bridge.host) @@ -165,11 +178,15 @@ async def async_step_link(self, user_input=None): ) return self.async_create_entry( - title=bridge.config.name, - data={CONF_HOST: bridge.host, CONF_USERNAME: bridge.username}, + title=f"Hue Bridge {bridge.id}", + data={ + CONF_HOST: bridge.host, + CONF_API_KEY: app_key, + CONF_API_VERSION: 2 if bridge.supports_v2 else 1, + }, ) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle a discovered Hue bridge. This flow is triggered by the SSDP component. It will check if the @@ -196,8 +213,7 @@ async def async_step_ssdp(self, discovery_info): return self.async_abort(reason="not_hue_bridge") host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname - - bridge = self._async_get_bridge(host, discovery_info[ssdp.ATTR_UPNP_SERIAL]) + bridge = await self._get_bridge(host, discovery_info[ssdp.ATTR_UPNP_SERIAL]) # type: ignore[arg-type] await self.async_set_unique_id(bridge.id) self._abort_if_unique_id_configured( @@ -215,9 +231,8 @@ async def async_step_zeroconf( This flow is triggered by the Zeroconf component. It will check if the host is already configured and delegate to the import step if not. """ - bridge = self._async_get_bridge( - discovery_info[zeroconf.ATTR_HOST], - discovery_info[zeroconf.ATTR_PROPERTIES]["bridgeid"], + bridge = await self._get_bridge( + discovery_info["host"], discovery_info["properties"]["bridgeid"] ) await self.async_set_unique_id(bridge.id) @@ -228,18 +243,20 @@ async def async_step_zeroconf( self.bridge = bridge return await self.async_step_link() - async def async_step_homekit(self, discovery_info): + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a discovered Hue bridge on HomeKit. The bridge ID communicated over HomeKit differs, so we cannot use that as the unique identifier. Therefore, this method uses discovery without a unique ID. """ - self.bridge = self._async_get_bridge(discovery_info[CONF_HOST]) + self.bridge = await self._get_bridge(discovery_info[CONF_HOST]) await self._async_handle_discovery_without_unique_id() return await self.async_step_link() - async def async_step_import(self, import_info): + async def async_step_import(self, import_info: ConfigType) -> FlowResult: """Import a new bridge as a config entry. This flow is triggered by `async_setup` for both configured and @@ -251,24 +268,26 @@ async def async_step_import(self, import_info): # Check if host exists, abort if so. self._async_abort_entries_match({"host": import_info["host"]}) - self.bridge = self._async_get_bridge(import_info["host"]) + self.bridge = await self._get_bridge(import_info["host"]) return await self.async_step_link() class HueOptionsFlowHandler(config_entries.OptionsFlow): """Handle Hue options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Hue options flow.""" self.config_entry = config_entry - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_init(self, user_input: ConfigType | None = None) -> FlowResult: """Manage Hue options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) + if self.config_entry.data.get(CONF_API_VERSION, 1) > 1: + # Options for Hue are only applicable to V1 bridges. + return self.async_show_form(step_id="init") + return self.async_show_form( step_id="init", data_schema=vol.Schema( diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index 5313584659dc2..eef453fb83d61 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -1,24 +1,34 @@ """Constants for the Hue component.""" -import logging -LOGGER = logging.getLogger(__package__) DOMAIN = "hue" -# How long to wait to actually do the refresh after requesting it. -# We wait some time so if we control multiple lights, we batch requests. -REQUEST_REFRESH_DELAY = 0.3 +CONF_API_VERSION = "api_version" -CONF_ALLOW_UNREACHABLE = "allow_unreachable" -DEFAULT_ALLOW_UNREACHABLE = False +CONF_SUBTYPE = "subtype" -CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" -DEFAULT_ALLOW_HUE_GROUPS = False +ATTR_HUE_EVENT = "hue_event" +SERVICE_HUE_ACTIVATE_SCENE = "hue_activate_scene" +ATTR_GROUP_NAME = "group_name" +ATTR_SCENE_NAME = "scene_name" +ATTR_TRANSITION = "transition" +ATTR_DYNAMIC = "dynamic" + + +# V1 API SPECIFIC CONSTANTS ################## GROUP_TYPE_LIGHT_GROUP = "LightGroup" GROUP_TYPE_ROOM = "Room" GROUP_TYPE_LUMINAIRE = "Luminaire" GROUP_TYPE_LIGHT_SOURCE = "LightSource" +GROUP_TYPE_ZONE = "Zone" +GROUP_TYPE_ENTERTAINMENT = "Entertainment" -ATTR_GROUP_NAME = "group_name" -ATTR_SCENE_NAME = "scene_name" -ATTR_TRANSITION = "transition" +CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +DEFAULT_ALLOW_HUE_GROUPS = False + +CONF_ALLOW_UNREACHABLE = "allow_unreachable" +DEFAULT_ALLOW_UNREACHABLE = False + +# How long to wait to actually do the refresh after requesting it. +# We wait some time so if we control multiple lights, we batch requests. +REQUEST_REFRESH_DELAY = 0.3 diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index 5af68b9d7697f..76fb8cd6c96bf 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -1,189 +1,105 @@ """Provides device automations for Philips Hue events.""" -import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from typing import TYPE_CHECKING + from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) -from homeassistant.components.homeassistant.triggers import event as event_trigger -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_DOMAIN, - CONF_EVENT, - CONF_PLATFORM, - CONF_TYPE, - CONF_UNIQUE_ID, +from homeassistant.const import CONF_DEVICE_ID +from homeassistant.core import CALLBACK_TYPE +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN +from .v1.device_trigger import ( + async_attach_trigger as async_attach_trigger_v1, + async_get_triggers as async_get_triggers_v1, + async_validate_trigger_config as async_validate_trigger_config_v1, ) - -from . import DOMAIN -from .hue_event import CONF_HUE_EVENT - -CONF_SUBTYPE = "subtype" - -CONF_SHORT_PRESS = "remote_button_short_press" -CONF_SHORT_RELEASE = "remote_button_short_release" -CONF_LONG_RELEASE = "remote_button_long_release" -CONF_DOUBLE_SHORT_RELEASE = "remote_double_button_short_press" -CONF_DOUBLE_LONG_RELEASE = "remote_double_button_long_press" - -CONF_TURN_ON = "turn_on" -CONF_TURN_OFF = "turn_off" -CONF_DIM_UP = "dim_up" -CONF_DIM_DOWN = "dim_down" -CONF_BUTTON_1 = "button_1" -CONF_BUTTON_2 = "button_2" -CONF_BUTTON_3 = "button_3" -CONF_BUTTON_4 = "button_4" -CONF_DOUBLE_BUTTON_1 = "double_buttons_1_3" -CONF_DOUBLE_BUTTON_2 = "double_buttons_2_4" - -HUE_DIMMER_REMOTE_MODEL = "Hue dimmer switch" # RWL020/021 -HUE_DIMMER_REMOTE = { - (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, - (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, - (CONF_SHORT_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2002}, - (CONF_LONG_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2003}, - (CONF_SHORT_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3002}, - (CONF_LONG_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3003}, - (CONF_SHORT_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4002}, - (CONF_LONG_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4003}, -} - -HUE_BUTTON_REMOTE_MODEL = "Hue Smart button" # ZLLSWITCH/ROM001 -HUE_BUTTON_REMOTE = { - (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, - (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, -} - -HUE_WALL_REMOTE_MODEL = "Hue wall switch module" # ZLLSWITCH/RDM001 -HUE_WALL_REMOTE = { - (CONF_SHORT_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1002}, - (CONF_SHORT_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 2002}, -} - -HUE_TAP_REMOTE_MODEL = "Hue tap switch" # ZGPSWITCH -HUE_TAP_REMOTE = { - (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 34}, - (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 16}, - (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 17}, - (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 18}, -} - -HUE_FOHSWITCH_REMOTE_MODEL = "Friends of Hue Switch" # ZGPSWITCH -HUE_FOHSWITCH_REMOTE = { - (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 20}, - (CONF_LONG_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 16}, - (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 21}, - (CONF_LONG_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 17}, - (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 23}, - (CONF_LONG_RELEASE, CONF_BUTTON_3): {CONF_EVENT: 19}, - (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 22}, - (CONF_LONG_RELEASE, CONF_BUTTON_4): {CONF_EVENT: 18}, - (CONF_DOUBLE_SHORT_RELEASE, CONF_DOUBLE_BUTTON_1): {CONF_EVENT: 101}, - (CONF_DOUBLE_LONG_RELEASE, CONF_DOUBLE_BUTTON_1): {CONF_EVENT: 100}, - (CONF_DOUBLE_SHORT_RELEASE, CONF_DOUBLE_BUTTON_2): {CONF_EVENT: 99}, - (CONF_DOUBLE_LONG_RELEASE, CONF_DOUBLE_BUTTON_2): {CONF_EVENT: 98}, -} - - -REMOTES = { - HUE_DIMMER_REMOTE_MODEL: HUE_DIMMER_REMOTE, - HUE_TAP_REMOTE_MODEL: HUE_TAP_REMOTE, - HUE_BUTTON_REMOTE_MODEL: HUE_BUTTON_REMOTE, - HUE_WALL_REMOTE_MODEL: HUE_WALL_REMOTE, - HUE_FOHSWITCH_REMOTE_MODEL: HUE_FOHSWITCH_REMOTE, -} - -TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( - {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} +from .v2.device_trigger import ( + async_attach_trigger as async_attach_trigger_v2, + async_get_triggers as async_get_triggers_v2, + async_validate_trigger_config as async_validate_trigger_config_v2, ) +if TYPE_CHECKING: + from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, + ) + from homeassistant.core import HomeAssistant -def _get_hue_event_from_device_id(hass, device_id): - """Resolve hue event from device id.""" - for bridge in hass.data.get(DOMAIN, {}).values(): - for hue_event in bridge.sensor_manager.current_events.values(): - if device_id == hue_event.device_registry_id: - return hue_event - - return None + from .bridge import HueBridge -async def async_validate_trigger_config(hass, config): +async def async_validate_trigger_config(hass: "HomeAssistant", config: ConfigType): """Validate config.""" - config = TRIGGER_SCHEMA(config) - - device_registry = await hass.helpers.device_registry.async_get_registry() - device = device_registry.async_get(config[CONF_DEVICE_ID]) - - trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - - if not device: - raise InvalidDeviceAutomationConfig( - f"Device {config[CONF_DEVICE_ID]} not found" - ) - - if device.model not in REMOTES: - raise InvalidDeviceAutomationConfig( - f"Device model {device.model} is not a remote" - ) - - if trigger not in REMOTES[device.model]: - raise InvalidDeviceAutomationConfig( - f"Device does not support trigger {trigger}" - ) - - return config - - -async def async_attach_trigger(hass, config, action, automation_info): + if DOMAIN not in hass.data: + # happens at startup + return config + device_id = config[CONF_DEVICE_ID] + # lookup device in HASS DeviceRegistry + dev_reg: dr.DeviceRegistry = dr.async_get(hass) + device_entry = dev_reg.async_get(device_id) + if device_entry is None: + raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid") + + for conf_entry_id in device_entry.config_entries: + if conf_entry_id not in hass.data[DOMAIN]: + continue + bridge: "HueBridge" = hass.data[DOMAIN][conf_entry_id] + if bridge.api_version == 1: + return await async_validate_trigger_config_v1(bridge, device_entry, config) + return await async_validate_trigger_config_v2(bridge, device_entry, config) + + +async def async_attach_trigger( + hass: "HomeAssistant", + config: ConfigType, + action: "AutomationActionType", + automation_info: "AutomationTriggerInfo", +) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - device_registry = await hass.helpers.device_registry.async_get_registry() - device = device_registry.async_get(config[CONF_DEVICE_ID]) - - hue_event = _get_hue_event_from_device_id(hass, device.id) - if hue_event is None: - raise InvalidDeviceAutomationConfig - - trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - - trigger = REMOTES[device.model][trigger] - - event_config = { - event_trigger.CONF_PLATFORM: "event", - event_trigger.CONF_EVENT_TYPE: CONF_HUE_EVENT, - event_trigger.CONF_EVENT_DATA: {CONF_UNIQUE_ID: hue_event.unique_id, **trigger}, - } - - event_config = event_trigger.TRIGGER_SCHEMA(event_config) - return await event_trigger.async_attach_trigger( - hass, event_config, action, automation_info, platform_type="device" + device_id = config[CONF_DEVICE_ID] + # lookup device in HASS DeviceRegistry + dev_reg: dr.DeviceRegistry = dr.async_get(hass) + device_entry = dev_reg.async_get(device_id) + if device_entry is None: + raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid") + + for conf_entry_id in device_entry.config_entries: + if conf_entry_id not in hass.data[DOMAIN]: + continue + bridge: "HueBridge" = hass.data[DOMAIN][conf_entry_id] + if bridge.api_version == 1: + return await async_attach_trigger_v1( + bridge, device_entry, config, action, automation_info + ) + return await async_attach_trigger_v2( + bridge, device_entry, config, action, automation_info + ) + raise InvalidDeviceAutomationConfig( + f"Device ID {device_id} is not found on any Hue bridge" ) -async def async_get_triggers(hass, device_id): - """List device triggers. - - Make sure device is a supported remote model. - Retrieve the hue event object matching device entry. - Generate device trigger list. - """ - device_registry = await hass.helpers.device_registry.async_get_registry() - device = device_registry.async_get(device_id) - - if device.model not in REMOTES: - return - - triggers = [] - for trigger, subtype in REMOTES[device.model]: - triggers.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_PLATFORM: "device", - CONF_TYPE: trigger, - CONF_SUBTYPE: subtype, - } - ) - - return triggers +async def async_get_triggers(hass: "HomeAssistant", device_id: str): + """Get device triggers for given (hass) device id.""" + if DOMAIN not in hass.data: + return [] + # lookup device in HASS DeviceRegistry + dev_reg: dr.DeviceRegistry = dr.async_get(hass) + device_entry = dev_reg.async_get(device_id) + if device_entry is None: + raise ValueError(f"Device ID {device_id} is not valid") + + # Iterate all config entries for this device + # and work out the bridge version + for conf_entry_id in device_entry.config_entries: + if conf_entry_id not in hass.data[DOMAIN]: + continue + bridge: "HueBridge" = hass.data[DOMAIN][conf_entry_id] + + if bridge.api_version == 1: + return await async_get_triggers_v1(bridge, device_entry) + return await async_get_triggers_v2(bridge, device_entry) diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 12fbf77aa8bb3..2bd9652f9b0fc 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -1,563 +1,28 @@ -"""Support for the Philips Hue lights.""" +"""Support for Hue lights.""" from __future__ import annotations -from datetime import timedelta -from functools import partial -import logging -import random +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback -import aiohue -import async_timeout +from .bridge import HueBridge +from .const import DOMAIN +from .v1.light import async_setup_entry as setup_entry_v1 +from .v2.group import async_setup_entry as setup_groups_entry_v2 +from .v2.light import async_setup_entry as setup_entry_v2 -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, - ATTR_EFFECT, - ATTR_FLASH, - ATTR_HS_COLOR, - ATTR_TRANSITION, - EFFECT_COLORLOOP, - EFFECT_RANDOM, - FLASH_LONG, - FLASH_SHORT, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, - SUPPORT_EFFECT, - SUPPORT_FLASH, - SUPPORT_TRANSITION, - LightEntity, -) -from homeassistant.core import callback -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from homeassistant.util import color -from .const import ( - DOMAIN as HUE_DOMAIN, - GROUP_TYPE_LIGHT_GROUP, - GROUP_TYPE_LIGHT_SOURCE, - GROUP_TYPE_LUMINAIRE, - GROUP_TYPE_ROOM, - REQUEST_REFRESH_DELAY, -) -from .helpers import remove_devices +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up light entities.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] -SCAN_INTERVAL = timedelta(seconds=5) - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_HUE_ON_OFF = SUPPORT_FLASH | SUPPORT_TRANSITION -SUPPORT_HUE_DIMMABLE = SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS -SUPPORT_HUE_COLOR_TEMP = SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP -SUPPORT_HUE_COLOR = SUPPORT_HUE_DIMMABLE | SUPPORT_EFFECT | SUPPORT_COLOR -SUPPORT_HUE_EXTENDED = SUPPORT_HUE_COLOR_TEMP | SUPPORT_HUE_COLOR - -SUPPORT_HUE = { - "Extended color light": SUPPORT_HUE_EXTENDED, - "Color light": SUPPORT_HUE_COLOR, - "Dimmable light": SUPPORT_HUE_DIMMABLE, - "On/Off plug-in unit": SUPPORT_HUE_ON_OFF, - "Color temperature light": SUPPORT_HUE_COLOR_TEMP, -} - -ATTR_IS_HUE_GROUP = "is_hue_group" -GAMUT_TYPE_UNAVAILABLE = "None" -# Minimum Hue Bridge API version to support groups -# 1.4.0 introduced extended group info -# 1.12 introduced the state object for groups -# 1.13 introduced "any_on" to group state objects -GROUP_MIN_API_VERSION = (1, 13, 0) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Old way of setting up Hue lights. - - Can only be called when a user accidentally mentions hue platform in their - config. But even in that case it would have been ignored. - """ - - -def create_light(item_class, coordinator, bridge, is_group, rooms, api, item_id): - """Create the light.""" - api_item = api[item_id] - - if is_group: - supported_features = 0 - for light_id in api_item.lights: - if light_id not in bridge.api.lights: - continue - light = bridge.api.lights[light_id] - supported_features |= SUPPORT_HUE.get(light.type, SUPPORT_HUE_EXTENDED) - supported_features = supported_features or SUPPORT_HUE_EXTENDED - else: - supported_features = SUPPORT_HUE.get(api_item.type, SUPPORT_HUE_EXTENDED) - return item_class( - coordinator, bridge, is_group, api_item, supported_features, rooms - ) - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Hue lights from a config entry.""" - bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] - api_version = tuple(int(v) for v in bridge.api.config.apiversion.split(".")) - rooms = {} - - allow_groups = bridge.allow_groups - supports_groups = api_version >= GROUP_MIN_API_VERSION - if allow_groups and not supports_groups: - _LOGGER.warning("Please update your Hue bridge to support groups") - - light_coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="light", - update_method=partial(async_safe_fetch, bridge, bridge.api.lights.update), - update_interval=SCAN_INTERVAL, - request_refresh_debouncer=Debouncer( - bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True - ), - ) - - # First do a refresh to see if we can reach the hub. - # Otherwise we will declare not ready. - await light_coordinator.async_refresh() - - if not light_coordinator.last_update_success: - raise PlatformNotReady - - if not supports_groups: - update_lights_without_group_support = partial( - async_update_items, - bridge, - bridge.api.lights, - {}, - async_add_entities, - partial(create_light, HueLight, light_coordinator, bridge, False, rooms), - None, - ) - # We add a listener after fetching the data, so manually trigger listener - bridge.reset_jobs.append( - light_coordinator.async_add_listener(update_lights_without_group_support) - ) + if bridge.api_version == 1: + await setup_entry_v1(hass, config_entry, async_add_entities) return - - group_coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="group", - update_method=partial(async_safe_fetch, bridge, bridge.api.groups.update), - update_interval=SCAN_INTERVAL, - request_refresh_debouncer=Debouncer( - bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True - ), - ) - - if allow_groups: - update_groups = partial( - async_update_items, - bridge, - bridge.api.groups, - {}, - async_add_entities, - partial(create_light, HueLight, group_coordinator, bridge, True, None), - None, - ) - - bridge.reset_jobs.append(group_coordinator.async_add_listener(update_groups)) - - cancel_update_rooms_listener = None - - @callback - def _async_update_rooms(): - """Update rooms.""" - nonlocal cancel_update_rooms_listener - rooms.clear() - for item_id in bridge.api.groups: - group = bridge.api.groups[item_id] - if group.type != GROUP_TYPE_ROOM: - continue - for light_id in group.lights: - rooms[light_id] = group.name - - # Once we do a rooms update, we cancel the listener - # until the next time lights are added - bridge.reset_jobs.remove(cancel_update_rooms_listener) - cancel_update_rooms_listener() # pylint: disable=not-callable - cancel_update_rooms_listener = None - - @callback - def _setup_rooms_listener(): - nonlocal cancel_update_rooms_listener - if cancel_update_rooms_listener is not None: - # If there are new lights added before _async_update_rooms - # is called we should not add another listener - return - - cancel_update_rooms_listener = group_coordinator.async_add_listener( - _async_update_rooms - ) - bridge.reset_jobs.append(cancel_update_rooms_listener) - - _setup_rooms_listener() - await group_coordinator.async_refresh() - - update_lights_with_group_support = partial( - async_update_items, - bridge, - bridge.api.lights, - {}, - async_add_entities, - partial(create_light, HueLight, light_coordinator, bridge, False, rooms), - _setup_rooms_listener, - ) - # We add a listener after fetching the data, so manually trigger listener - bridge.reset_jobs.append( - light_coordinator.async_add_listener(update_lights_with_group_support) - ) - update_lights_with_group_support() - - -async def async_safe_fetch(bridge, fetch_method): - """Safely fetch data.""" - try: - async with async_timeout.timeout(4): - return await bridge.async_request_call(fetch_method) - except aiohue.Unauthorized as err: - await bridge.handle_unauthorized_error() - raise UpdateFailed("Unauthorized") from err - except aiohue.AiohueException as err: - raise UpdateFailed(f"Hue error: {err}") from err - - -@callback -def async_update_items( - bridge, api, current, async_add_entities, create_item, new_items_callback -): - """Update items.""" - new_items = [] - - for item_id in api: - if item_id in current: - continue - - current[item_id] = create_item(api, item_id) - new_items.append(current[item_id]) - - bridge.hass.async_create_task(remove_devices(bridge, api, current)) - - if new_items: - # This is currently used to setup the listener to update rooms - if new_items_callback: - new_items_callback() - async_add_entities(new_items) - - -def hue_brightness_to_hass(value): - """Convert hue brightness 1..254 to hass format 0..255.""" - return min(255, round((value / 254) * 255)) - - -def hass_to_hue_brightness(value): - """Convert hass brightness 0..255 to hue 1..254 scale.""" - return max(1, round((value / 255) * 254)) - - -class HueLight(CoordinatorEntity, LightEntity): - """Representation of a Hue light.""" - - def __init__(self, coordinator, bridge, is_group, light, supported_features, rooms): - """Initialize the light.""" - super().__init__(coordinator) - self.light = light - self.bridge = bridge - self.is_group = is_group - self._supported_features = supported_features - self._rooms = rooms - - if is_group: - self.is_osram = False - self.is_philips = False - self.is_innr = False - self.is_ewelink = False - self.is_livarno = False - self.gamut_typ = GAMUT_TYPE_UNAVAILABLE - self.gamut = None - else: - self.is_osram = light.manufacturername == "OSRAM" - self.is_philips = light.manufacturername == "Philips" - self.is_innr = light.manufacturername == "innr" - self.is_ewelink = light.manufacturername == "eWeLink" - self.is_livarno = light.manufacturername.startswith("_TZ3000_") - self.gamut_typ = self.light.colorgamuttype - self.gamut = self.light.colorgamut - _LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut)) - if self.light.swupdatestate == "readytoinstall": - err = ( - "Please check for software updates of the %s " - "bulb in the Philips Hue App." - ) - _LOGGER.warning(err, self.name) - if self.gamut and not color.check_valid_gamut(self.gamut): - err = "Color gamut of %s: %s, not valid, setting gamut to None." - _LOGGER.debug(err, self.name, str(self.gamut)) - self.gamut_typ = GAMUT_TYPE_UNAVAILABLE - self.gamut = None - - @property - def unique_id(self): - """Return the unique ID of this Hue light.""" - unique_id = self.light.uniqueid - if not unique_id and self.is_group and self.light.room: - unique_id = self.light.room["id"] - - return unique_id - - @property - def device_id(self): - """Return the ID of this Hue light.""" - return self.unique_id - - @property - def name(self): - """Return the name of the Hue light.""" - return self.light.name - - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - if self.is_group: - bri = self.light.action.get("bri") - else: - bri = self.light.state.get("bri") - - if bri is None: - return bri - - return hue_brightness_to_hass(bri) - - @property - def _color_mode(self): - """Return the hue color mode.""" - if self.is_group: - return self.light.action.get("colormode") - return self.light.state.get("colormode") - - @property - def hs_color(self): - """Return the hs color value.""" - mode = self._color_mode - source = self.light.action if self.is_group else self.light.state - - if mode in ("xy", "hs") and "xy" in source: - return color.color_xy_to_hs(*source["xy"], self.gamut) - - return None - - @property - def color_temp(self): - """Return the CT color value.""" - # Don't return color temperature unless in color temperature mode - if self._color_mode != "ct": - return None - - if self.is_group: - return self.light.action.get("ct") - return self.light.state.get("ct") - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - if self.is_group: - return super().min_mireds - - min_mireds = self.light.controlcapabilities.get("ct", {}).get("min") - - # We filter out '0' too, which can be incorrectly reported by 3rd party buls - if not min_mireds: - return super().min_mireds - - return min_mireds - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - if self.is_group: - return super().max_mireds - if self.is_livarno: - return 500 - - max_mireds = self.light.controlcapabilities.get("ct", {}).get("max") - - if not max_mireds: - return super().max_mireds - - return max_mireds - - @property - def is_on(self): - """Return true if device is on.""" - if self.is_group: - return self.light.state["any_on"] - return self.light.state["on"] - - @property - def available(self): - """Return if light is available.""" - return self.coordinator.last_update_success and ( - self.is_group - or self.bridge.allow_unreachable - or self.light.state["reachable"] - ) - - @property - def supported_features(self): - """Flag supported features.""" - return self._supported_features - - @property - def effect(self): - """Return the current effect.""" - return self.light.state.get("effect", None) - - @property - def effect_list(self): - """Return the list of supported effects.""" - if self.is_osram: - return [EFFECT_RANDOM] - return [EFFECT_COLORLOOP, EFFECT_RANDOM] - - @property - def device_info(self) -> DeviceInfo | None: - """Return the device info.""" - if self.light.type in ( - GROUP_TYPE_LIGHT_GROUP, - GROUP_TYPE_ROOM, - GROUP_TYPE_LUMINAIRE, - GROUP_TYPE_LIGHT_SOURCE, - ): - return None - - suggested_area = None - if self.light.id in self._rooms: - suggested_area = self._rooms[self.light.id] - - return DeviceInfo( - identifiers={(HUE_DOMAIN, self.device_id)}, - manufacturer=self.light.manufacturername, - # productname added in Hue Bridge API 1.24 - # (published 03/05/2018) - model=self.light.productname or self.light.modelid, - name=self.name, - # Not yet exposed as properties in aiohue - suggested_area=suggested_area, - sw_version=self.light.raw["swversion"], - via_device=(HUE_DOMAIN, self.bridge.api.config.bridgeid), - ) - - async def async_added_to_hass(self) -> None: - """Handle entity being added to Home Assistant.""" - self.async_on_remove( - self.bridge.listen_updates( - self.light.ITEM_TYPE, self.light.id, self.async_write_ha_state - ) - ) - await super().async_added_to_hass() - - async def async_turn_on(self, **kwargs): - """Turn the specified or all lights on.""" - command = {"on": True} - - if ATTR_TRANSITION in kwargs: - command["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) - - if ATTR_HS_COLOR in kwargs: - if self.is_osram: - command["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535) - command["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) - else: - # Philips hue bulb models respond differently to hue/sat - # requests, so we convert to XY first to ensure a consistent - # color. - xy_color = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], self.gamut) - command["xy"] = xy_color - elif ATTR_COLOR_TEMP in kwargs: - temp = kwargs[ATTR_COLOR_TEMP] - command["ct"] = max(self.min_mireds, min(temp, self.max_mireds)) - - if ATTR_BRIGHTNESS in kwargs: - command["bri"] = hass_to_hue_brightness(kwargs[ATTR_BRIGHTNESS]) - - flash = kwargs.get(ATTR_FLASH) - - if flash == FLASH_LONG: - command["alert"] = "lselect" - del command["on"] - elif flash == FLASH_SHORT: - command["alert"] = "select" - del command["on"] - elif not self.is_innr and not self.is_ewelink and not self.is_livarno: - command["alert"] = "none" - - if ATTR_EFFECT in kwargs: - effect = kwargs[ATTR_EFFECT] - if effect == EFFECT_COLORLOOP: - command["effect"] = "colorloop" - elif effect == EFFECT_RANDOM: - command["hue"] = random.randrange(0, 65535) - command["sat"] = random.randrange(150, 254) - else: - command["effect"] = "none" - - if self.is_group: - await self.bridge.async_request_call( - partial(self.light.set_action, **command) - ) - else: - await self.bridge.async_request_call( - partial(self.light.set_state, **command) - ) - - await self.coordinator.async_request_refresh() - - async def async_turn_off(self, **kwargs): - """Turn the specified or all lights off.""" - command = {"on": False} - - if ATTR_TRANSITION in kwargs: - command["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) - - flash = kwargs.get(ATTR_FLASH) - - if flash == FLASH_LONG: - command["alert"] = "lselect" - del command["on"] - elif flash == FLASH_SHORT: - command["alert"] = "select" - del command["on"] - elif not self.is_innr and not self.is_livarno: - command["alert"] = "none" - - if self.is_group: - await self.bridge.async_request_call( - partial(self.light.set_action, **command) - ) - else: - await self.bridge.async_request_call( - partial(self.light.set_state, **command) - ) - - await self.coordinator.async_request_refresh() - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - if not self.is_group: - return {} - return {ATTR_IS_HUE_GROUP: self.is_group} + # v2 setup logic here + await setup_entry_v2(hass, config_entry, async_add_entities) + await setup_groups_entry_v2(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 6640ffc9fae48..75cdea853e703 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==2.6.3"], + "requirements": ["aiohue==3.0.1"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", @@ -22,7 +22,7 @@ "models": ["BSB002"] }, "zeroconf": ["_hue._tcp.local."], - "codeowners": ["@balloob", "@frenck"], + "codeowners": ["@balloob", "@frenck", "@marcelveldt"], "quality_scale": "platinum", "iot_class": "local_push" } diff --git a/homeassistant/components/hue/migration.py b/homeassistant/components/hue/migration.py new file mode 100644 index 0000000000000..fb584a19100e3 --- /dev/null +++ b/homeassistant/components/hue/migration.py @@ -0,0 +1,174 @@ +"""Various helpers to handle config entry and api schema migrations.""" + +import logging + +from aiohue import HueBridgeV2 +from aiohue.discovery import is_v2_bridge +from aiohue.v2.models.resource import ResourceTypes + +from homeassistant import core +from homeassistant.components.binary_sensor import DEVICE_CLASS_MOTION +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_USERNAME, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, +) +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import async_get as async_get_device_registry +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry as entities_for_config_entry, + async_entries_for_device, + async_get as async_get_entity_registry, +) + +from .const import CONF_API_VERSION, DOMAIN + +LOGGER = logging.getLogger(__name__) + + +async def check_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> None: + """Check if config entry needs any migration actions.""" + host = entry.data[CONF_HOST] + + # migrate CONF_USERNAME --> CONF_API_KEY + if CONF_USERNAME in entry.data: + LOGGER.info("Migrate %s to %s in schema", CONF_USERNAME, CONF_API_KEY) + data = dict(entry.data) + data[CONF_API_KEY] = data.pop(CONF_USERNAME) + hass.config_entries.async_update_entry(entry, data=data) + + conf_api_version = entry.data.get(CONF_API_VERSION, 1) + if conf_api_version == 1: + # a bridge might have upgraded firmware since last run so + # we discover its capabilities at every startup + websession = aiohttp_client.async_get_clientsession(hass) + if await is_v2_bridge(host, websession): + supported_api_version = 2 + else: + supported_api_version = 1 + LOGGER.debug( + "Configured api version is %s and supported api version %s for bridge %s", + conf_api_version, + supported_api_version, + host, + ) + + # the call to `is_v2_bridge` returns (silently) False even on connection error + # so if a migration is needed it will be done on next startup + + if conf_api_version == 1 and supported_api_version == 2: + # run entity/device schema migration for v2 + await handle_v2_migration(hass, entry) + + # store api version in entry data + if ( + CONF_API_VERSION not in entry.data + or conf_api_version != supported_api_version + ): + data = dict(entry.data) + data[CONF_API_VERSION] = supported_api_version + hass.config_entries.async_update_entry(entry, data=data) + + +async def handle_v2_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> None: + """Perform migration of devices and entities to V2 Id's.""" + host = entry.data[CONF_HOST] + api_key = entry.data[CONF_API_KEY] + websession = aiohttp_client.async_get_clientsession(hass) + dev_reg = async_get_device_registry(hass) + ent_reg = async_get_entity_registry(hass) + LOGGER.info("Start of migration of devices and entities to support API schema 2") + # initialize bridge connection just for the migration + async with HueBridgeV2(host, api_key, websession) as api: + + sensor_class_mapping = { + DEVICE_CLASS_BATTERY: ResourceTypes.DEVICE_POWER, + DEVICE_CLASS_MOTION: ResourceTypes.MOTION, + DEVICE_CLASS_ILLUMINANCE: ResourceTypes.LIGHT_LEVEL, + DEVICE_CLASS_TEMPERATURE: ResourceTypes.TEMPERATURE, + } + + # handle entities attached to device + for hue_dev in api.devices: + zigbee = api.devices.get_zigbee_connectivity(hue_dev.id) + if not zigbee: + # not a zigbee device + continue + mac = zigbee.mac_address + # get/update existing device by V1 identifier (mac address) + # the device will now have both the old and the new identifier + identifiers = {(DOMAIN, hue_dev.id), (DOMAIN, mac)} + hass_dev = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, identifiers=identifiers + ) + LOGGER.info("Migrated device %s (%s)", hass_dev.name, hass_dev.id) + # loop through al entities for device and find match + for ent in async_entries_for_device(ent_reg, hass_dev.id, True): + # migrate light + if ent.entity_id.startswith("light"): + # should always return one lightid here + new_unique_id = next(iter(hue_dev.lights)) + if ent.unique_id == new_unique_id: + continue # just in case + LOGGER.info( + "Migrating %s from unique id %s to %s", + ent.entity_id, + ent.unique_id, + new_unique_id, + ) + ent_reg.async_update_entity( + ent.entity_id, new_unique_id=new_unique_id + ) + continue + # migrate sensors + matched_dev_class = sensor_class_mapping.get( + ent.device_class or "unknown" + ) + if matched_dev_class is None: + # this may happen if we're looking at orphaned or unsupported entity + LOGGER.warning( + "Skip migration of %s because it no longer exists on the bridge", + ent.entity_id, + ) + continue + for sensor in api.devices.get_sensors(hue_dev.id): + if sensor.type != matched_dev_class: + continue + new_unique_id = sensor.id + if ent.unique_id == new_unique_id: + break # just in case + LOGGER.info( + "Migrating %s from unique id %s to %s", + ent.entity_id, + ent.unique_id, + new_unique_id, + ) + ent_reg.async_update_entity(ent.entity_id, new_unique_id=sensor.id) + break + + # migrate entities that are not connected to a device (groups) + for ent in entities_for_config_entry(ent_reg, entry.entry_id): + if ent.device_id is not None: + continue + v1_id = f"/groups/{ent.unique_id}" + hue_group = api.groups.room.get_by_v1_id(v1_id) + if hue_group is None or hue_group.grouped_light is None: + # this may happen if we're looking at some orphaned entity + LOGGER.warning( + "Skip migration of %s because it no longer exist on the bridge", + ent.entity_id, + ) + continue + new_unique_id = hue_group.grouped_light + LOGGER.info( + "Migrating %s from unique id %s to %s ", + ent.entity_id, + ent.unique_id, + new_unique_id, + ) + ent_reg.async_update_entity(ent.entity_id, new_unique_id=new_unique_id) + LOGGER.info("Migration of devices and entities to support API schema 2 finished") diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py new file mode 100644 index 0000000000000..55807ad0f2f2a --- /dev/null +++ b/homeassistant/components/hue/scene.py @@ -0,0 +1,117 @@ +"""Support for scene platform for Hue scenes (V2 only).""" +from __future__ import annotations + +from typing import Any + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.scenes import ScenesController +from aiohue.v2.models.scene import Scene as HueScene + +from homeassistant.components.scene import Scene as SceneEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .bridge import HueBridge +from .const import DOMAIN +from .v2.entity import HueBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up scene platform from Hue group scenes.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + + if bridge.api_version == 1: + # should not happen, but just in case + raise NotImplementedError("Scene support is only available for V2 bridges") + + # add entities for all scenes + @callback + def async_add_entity(event_type: EventType, resource: HueScene) -> None: + """Add entity from Hue resource.""" + async_add_entities([HueSceneEntity(bridge, api.scenes, resource)]) + + # add all current items in controller + for item in api.scenes: + async_add_entity(EventType.RESOURCE_ADDED, item) + + # register listener for new items only + config_entry.async_on_unload( + api.scenes.subscribe(async_add_entity, event_filter=EventType.RESOURCE_ADDED) + ) + + +class HueSceneEntity(HueBaseEntity, SceneEntity): + """Representation of a Scene entity from Hue Scenes.""" + + def __init__( + self, + bridge: HueBridge, + controller: ScenesController, + resource: HueScene, + ) -> None: + """Initialize the entity.""" + super().__init__(bridge, controller, resource) + self.resource = resource + self.controller = controller + + @property + def name(self) -> str: + """Return default entity name.""" + group = self.controller.get_group(self.resource.id) + return f"{group.metadata.name} - {self.resource.metadata.name}" + + @property + def is_dynamic(self) -> bool: + """Return if this scene has a dynamic color palette.""" + if self.resource.palette.color and len(self.resource.palette.color) > 1: + return True + if ( + self.resource.palette.color_temperature + and len(self.resource.palette.color_temperature) > 1 + ): + return True + return False + + async def async_activate(self, **kwargs: Any) -> None: + """Activate Hue scene.""" + transition = kwargs.get("transition") + if transition is not None: + # hue transition duration is in steps of 100 ms + transition = int(transition * 100) + dynamic = kwargs.get("dynamic", self.is_dynamic) + await self.bridge.async_request_call( + self.controller.recall, + self.resource.id, + dynamic=dynamic, + duration=transition, + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the optional state attributes.""" + group = self.controller.get_group(self.resource.id) + brightness = None + if palette := self.resource.palette: + if palette.dimming: + brightness = palette.dimming[0].brightness + if brightness is None: + # get brightness from actions + for action in self.resource.actions: + if action.action.dimming: + brightness = action.action.dimming.brightness + break + return { + "group_name": group.metadata.name, + "group_type": group.type.value, + "name": self.resource.metadata.name, + "speed": self.resource.speed, + "brightness": brightness, + "is_dynamic": self.is_dynamic, + } diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 9bd701fe5261d..7218831abe23c 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -1,135 +1,24 @@ -"""Hue sensor entities.""" -from aiohue.sensors import ( - TYPE_ZLL_LIGHTLEVEL, - TYPE_ZLL_ROTARY, - TYPE_ZLL_SWITCH, - TYPE_ZLL_TEMPERATURE, -) - -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, - ENTITY_CATEGORY_DIAGNOSTIC, - LIGHT_LUX, - PERCENTAGE, - TEMP_CELSIUS, -) - -from .const import DOMAIN as HUE_DOMAIN -from .sensor_base import SENSOR_CONFIG_MAP, GenericHueSensor, GenericZLLSensor - -LIGHT_LEVEL_NAME_FORMAT = "{} light level" -REMOTE_NAME_FORMAT = "{} battery level" -TEMPERATURE_NAME_FORMAT = "{} temperature" - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Defer sensor setup to the shared sensor module.""" - bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] - - if not bridge.sensor_manager: +"""Support for Hue sensors.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .bridge import HueBridge +from .const import DOMAIN +from .v1.sensor import async_setup_entry as setup_entry_v1 +from .v2.sensor import async_setup_entry as setup_entry_v2 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensor entities.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + if bridge.api_version == 1: + await setup_entry_v1(hass, config_entry, async_add_entities) return - - await bridge.sensor_manager.async_register_component("sensor", async_add_entities) - - -class GenericHueGaugeSensorEntity(GenericZLLSensor, SensorEntity): - """Parent class for all 'gauge' Hue device sensors.""" - - -class HueLightLevel(GenericHueGaugeSensorEntity): - """The light level sensor entity for a Hue motion sensor device.""" - - _attr_device_class = DEVICE_CLASS_ILLUMINANCE - _attr_native_unit_of_measurement = LIGHT_LUX - - @property - def native_value(self): - """Return the state of the device.""" - if self.sensor.lightlevel is None: - return None - - # https://developers.meethue.com/develop/hue-api/supported-devices/#clip_zll_lightlevel - # Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm - # scale used because the human eye adjusts to light levels and small - # changes at low lux levels are more noticeable than at high lux - # levels. - return round(float(10 ** ((self.sensor.lightlevel - 1) / 10000)), 2) - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - attributes = super().extra_state_attributes - attributes.update( - { - "lightlevel": self.sensor.lightlevel, - "daylight": self.sensor.daylight, - "dark": self.sensor.dark, - "threshold_dark": self.sensor.tholddark, - "threshold_offset": self.sensor.tholdoffset, - } - ) - return attributes - - -class HueTemperature(GenericHueGaugeSensorEntity): - """The temperature sensor entity for a Hue motion sensor device.""" - - _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_native_unit_of_measurement = TEMP_CELSIUS - - @property - def native_value(self): - """Return the state of the device.""" - if self.sensor.temperature is None: - return None - - return self.sensor.temperature / 100 - - -class HueBattery(GenericHueSensor, SensorEntity): - """Battery class for when a batt-powered device is only represented as an event.""" - - _attr_device_class = DEVICE_CLASS_BATTERY - _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_native_unit_of_measurement = PERCENTAGE - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC - - @property - def unique_id(self): - """Return a unique identifier for this device.""" - return f"{self.sensor.uniqueid}-battery" - - @property - def native_value(self): - """Return the state of the battery.""" - return self.sensor.battery - - -SENSOR_CONFIG_MAP.update( - { - TYPE_ZLL_LIGHTLEVEL: { - "platform": "sensor", - "name_format": LIGHT_LEVEL_NAME_FORMAT, - "class": HueLightLevel, - }, - TYPE_ZLL_TEMPERATURE: { - "platform": "sensor", - "name_format": TEMPERATURE_NAME_FORMAT, - "class": HueTemperature, - }, - TYPE_ZLL_SWITCH: { - "platform": "sensor", - "name_format": REMOTE_NAME_FORMAT, - "class": HueBattery, - }, - TYPE_ZLL_ROTARY: { - "platform": "sensor", - "name_format": REMOTE_NAME_FORMAT, - "class": HueBattery, - }, - } -) + await setup_entry_v2(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py new file mode 100644 index 0000000000000..72e88f0d956ab --- /dev/null +++ b/homeassistant/components/hue/services.py @@ -0,0 +1,158 @@ +"""Handle Hue Service calls.""" +from __future__ import annotations + +import asyncio +import logging + +from aiohue import HueBridgeV1, HueBridgeV2 +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service import verify_domain_control + +from .bridge import HueBridge +from .const import ( + ATTR_DYNAMIC, + ATTR_GROUP_NAME, + ATTR_SCENE_NAME, + ATTR_TRANSITION, + DOMAIN, + SERVICE_HUE_ACTIVATE_SCENE, +) + +LOGGER = logging.getLogger(__name__) + + +def async_register_services(hass: HomeAssistant) -> None: + """Register services for Hue integration.""" + + async def hue_activate_scene(call: ServiceCall, skip_reload=True): + """Handle activation of Hue scene.""" + # Get parameters + group_name = call.data[ATTR_GROUP_NAME] + scene_name = call.data[ATTR_SCENE_NAME] + transition = call.data.get(ATTR_TRANSITION) + dynamic = call.data.get(ATTR_DYNAMIC, False) + + # Call the set scene function on each bridge + tasks = [ + hue_activate_scene_v1(bridge, group_name, scene_name, transition) + if bridge.api_version == 1 + else hue_activate_scene_v2( + bridge, group_name, scene_name, transition, dynamic + ) + for bridge in hass.data[DOMAIN].values() + if isinstance(bridge, HueBridge) + ] + results = await asyncio.gather(*tasks) + + # Did *any* bridge succeed? + # Note that we'll get a "True" value for a successful call + if True not in results: + LOGGER.warning( + "No bridge was able to activate scene %s in group %s", + scene_name, + group_name, + ) + + if not hass.services.has_service(DOMAIN, SERVICE_HUE_ACTIVATE_SCENE): + # Register a local handler for scene activation + hass.services.async_register( + DOMAIN, + SERVICE_HUE_ACTIVATE_SCENE, + verify_domain_control(hass, DOMAIN)(hue_activate_scene), + schema=vol.Schema( + { + vol.Required(ATTR_GROUP_NAME): cv.string, + vol.Required(ATTR_SCENE_NAME): cv.string, + vol.Optional(ATTR_TRANSITION): cv.positive_int, + vol.Optional(ATTR_DYNAMIC): cv.boolean, + } + ), + ) + + +async def hue_activate_scene_v1( + bridge: HueBridge, + group_name: str, + scene_name: str, + transition: int | None = None, + is_retry: bool = False, +) -> bool: + """Service for V1 bridge to call directly into bridge to set scenes.""" + api: HueBridgeV1 = bridge.api + if api.scenes is None: + LOGGER.warning("Hub %s does not support scenes", api.host) + return False + + group = next( + (group for group in api.groups.values() if group.name == group_name), + None, + ) + # Additional scene logic to handle duplicate scene names across groups + scene = next( + ( + scene + for scene in api.scenes.values() + if scene.name == scene_name + and group is not None + and sorted(scene.lights) == sorted(group.lights) + ), + None, + ) + # If we can't find it, fetch latest info and try again + if not is_retry and (group is None or scene is None): + await bridge.async_request_call(api.groups.update) + await bridge.async_request_call(api.scenes.update) + return await hue_activate_scene_v1( + bridge, group_name, scene_name, transition, is_retry=True + ) + + if group is None or scene is None: + LOGGER.debug( + "Unable to find scene %s for group %s on bridge %s", + scene_name, + group_name, + bridge.host, + ) + return False + + await bridge.async_request_call( + group.set_action, scene=scene.id, transitiontime=transition + ) + return True + + +async def hue_activate_scene_v2( + bridge: HueBridge, + group_name: str, + scene_name: str, + transition: int | None = None, + dynamic: bool = True, +) -> bool: + """Service for V2 bridge to call scene by name.""" + LOGGER.warning( + "Use of service_call '%s' is deprecated and will be removed " + "in a future release. Please use scene entities instead", + SERVICE_HUE_ACTIVATE_SCENE, + ) + api: HueBridgeV2 = bridge.api + for scene in api.scenes: + if scene.metadata.name.lower() != scene_name.lower(): + continue + group = api.scenes.get_group(scene.id) + if group.metadata.name.lower() != group_name.lower(): + continue + # found match! + if transition: + transition = transition * 100 # in steps of 100ms + await api.scenes.recall(scene.id, dynamic=dynamic, duration=transition) + return True + LOGGER.debug( + "Unable to find scene %s for group %s on bridge %s", + scene_name, + group_name, + bridge.host, + ) + return False diff --git a/homeassistant/components/hue/services.yaml b/homeassistant/components/hue/services.yaml index 07eeca6fa0fb5..4e6d1ad69983c 100644 --- a/homeassistant/components/hue/services.yaml +++ b/homeassistant/components/hue/services.yaml @@ -16,3 +16,8 @@ hue_activate_scene: example: "Energize" selector: text: + dynamic: + name: Dynamic + description: Enable dynamic mode of the scene (V2 bridges and supported scenes only). + selector: + boolean: diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 678b7c2cad24a..458e21419abe8 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -44,14 +44,24 @@ "dim_down": "Dim down", "dim_up": "Dim up", "turn_off": "Turn off", - "turn_on": "Turn on" + "turn_on": "Turn on", + "1": "First button", + "2": "Second button", + "3": "Third button", + "4": "Fourth button" }, "trigger_type": { "remote_button_long_release": "\"{subtype}\" button released after long press", "remote_button_short_press": "\"{subtype}\" button pressed", "remote_button_short_release": "\"{subtype}\" button released", "remote_double_button_long_press": "Both \"{subtype}\" released after long press", - "remote_double_button_short_press": "Both \"{subtype}\" released" + "remote_double_button_short_press": "Both \"{subtype}\" released", + + "initial_press": "Button \"{subtype}\" pressed initially", + "repeat": "Button \"{subtype}\" held down", + "short_release": "Button \"{subtype}\" released after short press", + "long_release": "Button \"{subtype}\" released after long press", + "double_short_release": "Both \"{subtype}\" released" } }, "options": { @@ -59,6 +69,7 @@ "init": { "data": { "allow_hue_groups": "Allow Hue groups", + "allow_hue_scenes": "Allow Hue scenes", "allow_unreachable": "Allow unreachable bulbs to report their state correctly" } } diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py new file mode 100644 index 0000000000000..3de96b45842f0 --- /dev/null +++ b/homeassistant/components/hue/switch.py @@ -0,0 +1,94 @@ +"""Support for switch platform for Hue resources (V2 only).""" +from __future__ import annotations + +from typing import Any, Union + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.sensors import LightLevelController, MotionController +from aiohue.v2.models.resource import SensingService + +from homeassistant.components.switch import DEVICE_CLASS_SWITCH, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .bridge import HueBridge +from .const import DOMAIN +from .v2.entity import HueBaseEntity + +ControllerType = Union[LightLevelController, MotionController] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Hue switch platform from Hue resources.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + + if bridge.api_version == 1: + # should not happen, but just in case + raise NotImplementedError("Switch support is only available for V2 bridges") + + @callback + def register_items(controller: ControllerType): + @callback + def async_add_entity(event_type: EventType, resource: SensingService) -> None: + """Add entity from Hue resource.""" + async_add_entities( + [HueSensingServiceEnabledEntity(bridge, controller, resource)] + ) + + # add all current items in controller + for item in controller: + async_add_entity(EventType.RESOURCE_ADDED, item) + + # register listener for new items only + config_entry.async_on_unload( + controller.subscribe( + async_add_entity, event_filter=EventType.RESOURCE_ADDED + ) + ) + + # setup for each switch-type hue resource + register_items(api.sensors.motion) + register_items(api.sensors.light_level) + + +class HueSensingServiceEnabledEntity(HueBaseEntity, SwitchEntity): + """Representation of a Switch entity from Hue SensingService.""" + + _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_device_class = DEVICE_CLASS_SWITCH + + def __init__( + self, + bridge: HueBridge, + controller: LightLevelController | MotionController, + resource: SensingService, + ) -> None: + """Initialize the entity.""" + super().__init__(bridge, controller, resource) + self.resource = resource + self.controller = controller + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + return self.resource.enabled + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.bridge.async_request_call( + self.controller.set_enabled, self.resource.id, enabled=True + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.bridge.async_request_call( + self.controller.set_enabled, self.resource.id, enabled=False + ) diff --git a/homeassistant/components/hue/translations/en.json b/homeassistant/components/hue/translations/en.json index e03eabd3d2361..2f1010c88b761 100644 --- a/homeassistant/components/hue/translations/en.json +++ b/homeassistant/components/hue/translations/en.json @@ -35,23 +35,33 @@ }, "device_automation": { "trigger_subtype": { - "button_1": "First button", - "button_2": "Second button", - "button_3": "Third button", - "button_4": "Fourth button", - "dim_down": "Dim down", - "dim_up": "Dim up", - "double_buttons_1_3": "First and Third buttons", - "double_buttons_2_4": "Second and Fourth buttons", - "turn_off": "Turn off", - "turn_on": "Turn on" + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "double_buttons_1_3": "First and Third buttons", + "double_buttons_2_4": "Second and Fourth buttons", + "dim_down": "Dim down", + "dim_up": "Dim up", + "turn_off": "Turn off", + "turn_on": "Turn on", + "1": "First button", + "2": "Second button", + "3": "Third button", + "4": "Fourth button" }, "trigger_type": { - "remote_button_long_release": "\"{subtype}\" button released after long press", - "remote_button_short_press": "\"{subtype}\" button pressed", - "remote_button_short_release": "\"{subtype}\" button released", - "remote_double_button_long_press": "Both \"{subtype}\" released after long press", - "remote_double_button_short_press": "Both \"{subtype}\" released" + "remote_button_long_release": "\"{subtype}\" button released after long press", + "remote_button_short_press": "\"{subtype}\" button pressed", + "remote_button_short_release": "\"{subtype}\" button released", + "remote_double_button_long_press": "Both \"{subtype}\" released after long press", + "remote_double_button_short_press": "Both \"{subtype}\" released", + + "initial_press": "Button \"{subtype}\" pressed initially", + "repeat": "Button \"{subtype}\" held down", + "short_release": "Button \"{subtype}\" released after short press", + "long_release": "Button \"{subtype}\" released after long press", + "double_short_release": "Both \"{subtype}\" released" } }, "options": { @@ -59,6 +69,7 @@ "init": { "data": { "allow_hue_groups": "Allow Hue groups", + "allow_hue_scenes": "Allow Hue scenes", "allow_unreachable": "Allow unreachable bulbs to report their state correctly" } } diff --git a/homeassistant/components/hue/v1/__init__.py b/homeassistant/components/hue/v1/__init__.py new file mode 100644 index 0000000000000..fdca29a0d94aa --- /dev/null +++ b/homeassistant/components/hue/v1/__init__.py @@ -0,0 +1 @@ +"""Hue V1 API specific platform implementation.""" diff --git a/homeassistant/components/hue/v1/binary_sensor.py b/homeassistant/components/hue/v1/binary_sensor.py new file mode 100644 index 0000000000000..21650e52b9c4e --- /dev/null +++ b/homeassistant/components/hue/v1/binary_sensor.py @@ -0,0 +1,56 @@ +"""Hue binary sensor entities.""" +from aiohue.v1.sensors import TYPE_ZLL_PRESENCE + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOTION, + BinarySensorEntity, +) + +from ..const import DOMAIN as HUE_DOMAIN +from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor + +PRESENCE_NAME_FORMAT = "{} motion" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer binary sensor setup to the shared sensor module.""" + bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + + if not bridge.sensor_manager: + return + + await bridge.sensor_manager.async_register_component( + "binary_sensor", async_add_entities + ) + + +class HuePresence(GenericZLLSensor, BinarySensorEntity): + """The presence sensor entity for a Hue motion sensor device.""" + + _attr_device_class = DEVICE_CLASS_MOTION + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.sensor.presence + + @property + def extra_state_attributes(self): + """Return the device state attributes.""" + attributes = super().extra_state_attributes + if "sensitivity" in self.sensor.config: + attributes["sensitivity"] = self.sensor.config["sensitivity"] + if "sensitivitymax" in self.sensor.config: + attributes["sensitivity_max"] = self.sensor.config["sensitivitymax"] + return attributes + + +SENSOR_CONFIG_MAP.update( + { + TYPE_ZLL_PRESENCE: { + "platform": "binary_sensor", + "name_format": PRESENCE_NAME_FORMAT, + "class": HuePresence, + } + } +) diff --git a/homeassistant/components/hue/v1/device_trigger.py b/homeassistant/components/hue/v1/device_trigger.py new file mode 100644 index 0000000000000..d6b471b7257a4 --- /dev/null +++ b/homeassistant/components/hue/v1/device_trigger.py @@ -0,0 +1,185 @@ +"""Provides device automations for Philips Hue events in V1 bridge/api.""" +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_EVENT, + CONF_PLATFORM, + CONF_TYPE, + CONF_UNIQUE_ID, +) +from homeassistant.helpers.device_registry import DeviceEntry + +from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN + +if TYPE_CHECKING: + from ..bridge import HueBridge + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} +) + + +CONF_SHORT_PRESS = "remote_button_short_press" +CONF_SHORT_RELEASE = "remote_button_short_release" +CONF_LONG_RELEASE = "remote_button_long_release" +CONF_DOUBLE_SHORT_RELEASE = "remote_double_button_short_press" +CONF_DOUBLE_LONG_RELEASE = "remote_double_button_long_press" + +CONF_TURN_ON = "turn_on" +CONF_TURN_OFF = "turn_off" +CONF_DIM_UP = "dim_up" +CONF_DIM_DOWN = "dim_down" +CONF_BUTTON_1 = "button_1" +CONF_BUTTON_2 = "button_2" +CONF_BUTTON_3 = "button_3" +CONF_BUTTON_4 = "button_4" +CONF_DOUBLE_BUTTON_1 = "double_buttons_1_3" +CONF_DOUBLE_BUTTON_2 = "double_buttons_2_4" + +HUE_DIMMER_REMOTE_MODEL = "Hue dimmer switch" # RWL020/021 +HUE_DIMMER_REMOTE = { + (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, + (CONF_SHORT_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2002}, + (CONF_LONG_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2003}, + (CONF_SHORT_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3002}, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3003}, + (CONF_SHORT_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4002}, + (CONF_LONG_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4003}, +} + +HUE_BUTTON_REMOTE_MODEL = "Hue Smart button" # ZLLSWITCH/ROM001 +HUE_BUTTON_REMOTE = { + (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, +} + +HUE_WALL_REMOTE_MODEL = "Hue wall switch module" # ZLLSWITCH/RDM001 +HUE_WALL_REMOTE = { + (CONF_SHORT_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1002}, + (CONF_SHORT_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 2002}, +} + +HUE_TAP_REMOTE_MODEL = "Hue tap switch" # ZGPSWITCH +HUE_TAP_REMOTE = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 34}, + (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 16}, + (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 17}, + (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 18}, +} + +HUE_FOHSWITCH_REMOTE_MODEL = "Friends of Hue Switch" # ZGPSWITCH +HUE_FOHSWITCH_REMOTE = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 20}, + (CONF_LONG_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 16}, + (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 21}, + (CONF_LONG_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 17}, + (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 23}, + (CONF_LONG_RELEASE, CONF_BUTTON_3): {CONF_EVENT: 19}, + (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 22}, + (CONF_LONG_RELEASE, CONF_BUTTON_4): {CONF_EVENT: 18}, + (CONF_DOUBLE_SHORT_RELEASE, CONF_DOUBLE_BUTTON_1): {CONF_EVENT: 101}, + (CONF_DOUBLE_LONG_RELEASE, CONF_DOUBLE_BUTTON_1): {CONF_EVENT: 100}, + (CONF_DOUBLE_SHORT_RELEASE, CONF_DOUBLE_BUTTON_2): {CONF_EVENT: 99}, + (CONF_DOUBLE_LONG_RELEASE, CONF_DOUBLE_BUTTON_2): {CONF_EVENT: 98}, +} + + +REMOTES = { + HUE_DIMMER_REMOTE_MODEL: HUE_DIMMER_REMOTE, + HUE_TAP_REMOTE_MODEL: HUE_TAP_REMOTE, + HUE_BUTTON_REMOTE_MODEL: HUE_BUTTON_REMOTE, + HUE_WALL_REMOTE_MODEL: HUE_WALL_REMOTE, + HUE_FOHSWITCH_REMOTE_MODEL: HUE_FOHSWITCH_REMOTE, +} + + +def _get_hue_event_from_device_id(hass, device_id): + """Resolve hue event from device id.""" + for bridge in hass.data.get(DOMAIN, {}).values(): + for hue_event in bridge.sensor_manager.current_events.values(): + if device_id == hue_event.device_registry_id: + return hue_event + + return None + + +async def async_validate_trigger_config(bridge, device_entry, config): + """Validate config.""" + config = TRIGGER_SCHEMA(config) + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + if not device_entry: + raise InvalidDeviceAutomationConfig( + f"Device {config[CONF_DEVICE_ID]} not found" + ) + + if device_entry.model not in REMOTES: + raise InvalidDeviceAutomationConfig( + f"Device model {device_entry.model} is not a remote" + ) + + if trigger not in REMOTES[device_entry.model]: + raise InvalidDeviceAutomationConfig( + f"Device does not support trigger {trigger}" + ) + + return config + + +async def async_attach_trigger(bridge, device_entry, config, action, automation_info): + """Listen for state changes based on configuration.""" + hass = bridge.hass + + hue_event = _get_hue_event_from_device_id(hass, device_entry.id) + if hue_event is None: + raise InvalidDeviceAutomationConfig + + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + trigger = REMOTES[device_entry.model][trigger] + + event_config = { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: ATTR_HUE_EVENT, + event_trigger.CONF_EVENT_DATA: {CONF_UNIQUE_ID: hue_event.unique_id, **trigger}, + } + + event_config = event_trigger.TRIGGER_SCHEMA(event_config) + return await event_trigger.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) + + +async def async_get_triggers(bridge: "HueBridge", device: DeviceEntry): + """Return device triggers for device on `v1` bridge. + + Make sure device is a supported remote model. + Retrieve the hue event object matching device entry. + Generate device trigger list. + """ + if device.model not in REMOTES: + return + + triggers = [] + for trigger, subtype in REMOTES[device.model]: + triggers.append( + { + CONF_DEVICE_ID: device.id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + ) + + return triggers diff --git a/homeassistant/components/hue/helpers.py b/homeassistant/components/hue/v1/helpers.py similarity index 77% rename from homeassistant/components/hue/helpers.py rename to homeassistant/components/hue/v1/helpers.py index 739e27d3360d0..d4582f0bb52b4 100644 --- a/homeassistant/components/hue/helpers.py +++ b/homeassistant/components/hue/v1/helpers.py @@ -1,9 +1,9 @@ """Helper functions for Philips Hue.""" -from homeassistant import config_entries + from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg from homeassistant.helpers.entity_registry import async_get_registry as get_ent_reg -from .const import DOMAIN +from ..const import DOMAIN async def remove_devices(bridge, api_ids, current): @@ -30,14 +30,3 @@ async def remove_devices(bridge, api_ids, current): for item_id in removed_items: del current[item_id] - - -def create_config_flow(hass, host): - """Start a config flow.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"host": host}, - ) - ) diff --git a/homeassistant/components/hue/hue_event.py b/homeassistant/components/hue/v1/hue_event.py similarity index 90% rename from homeassistant/components/hue/hue_event.py rename to homeassistant/components/hue/v1/hue_event.py index 00b6c3e44f257..9074baaaa885e 100644 --- a/homeassistant/components/hue/hue_event.py +++ b/homeassistant/components/hue/v1/hue_event.py @@ -1,7 +1,7 @@ """Representation of a Hue remote firing events for button presses.""" import logging -from aiohue.sensors import ( +from aiohue.v1.sensors import ( EVENT_BUTTON, TYPE_ZGP_SWITCH, TYPE_ZLL_ROTARY, @@ -12,11 +12,11 @@ from homeassistant.core import callback from homeassistant.util import dt as dt_util, slugify +from ..const import ATTR_HUE_EVENT from .sensor_device import GenericHueDevice -_LOGGER = logging.getLogger(__name__) +LOGGER = logging.getLogger(__name__) -CONF_HUE_EVENT = "hue_event" CONF_LAST_UPDATED = "last_updated" EVENT_NAME_FORMAT = "{}" @@ -44,11 +44,6 @@ def __init__(self, sensor, name, bridge, primary_sensor=None): self.async_update_callback ) ) - self.bridge.reset_jobs.append( - self.bridge.listen_updates( - self.sensor.ITEM_TYPE, self.sensor.id, self.async_update_callback - ) - ) @callback def async_update_callback(self): @@ -90,7 +85,7 @@ def async_update_callback(self): CONF_EVENT: state, CONF_LAST_UPDATED: self.sensor.lastupdated, } - self.bridge.hass.bus.async_fire(CONF_HUE_EVENT, data) + self.bridge.hass.bus.async_fire(ATTR_HUE_EVENT, data) async def async_update_device_registry(self): """Update device registry.""" @@ -102,7 +97,7 @@ async def async_update_device_registry(self): config_entry_id=self.bridge.config_entry.entry_id, **self.device_info ) self.device_registry_id = entry.id - _LOGGER.debug( + LOGGER.debug( "Event registry with entry_id: %s and device_id: %s", self.device_registry_id, self.device_id, diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py new file mode 100644 index 0000000000000..9cddb665006e3 --- /dev/null +++ b/homeassistant/components/hue/v1/light.py @@ -0,0 +1,557 @@ +"""Support for the Philips Hue lights.""" +from __future__ import annotations + +from datetime import timedelta +from functools import partial +import logging +import random + +import aiohue +import async_timeout + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_TRANSITION, + EFFECT_COLORLOOP, + EFFECT_RANDOM, + FLASH_LONG, + FLASH_SHORT, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, + SUPPORT_FLASH, + SUPPORT_TRANSITION, + LightEntity, +) +from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) +from homeassistant.util import color + +from ..bridge import HueBridge +from ..const import ( + CONF_ALLOW_HUE_GROUPS, + CONF_ALLOW_UNREACHABLE, + DEFAULT_ALLOW_HUE_GROUPS, + DEFAULT_ALLOW_UNREACHABLE, + DOMAIN as HUE_DOMAIN, + GROUP_TYPE_ENTERTAINMENT, + GROUP_TYPE_LIGHT_GROUP, + GROUP_TYPE_LIGHT_SOURCE, + GROUP_TYPE_LUMINAIRE, + GROUP_TYPE_ROOM, + GROUP_TYPE_ZONE, + REQUEST_REFRESH_DELAY, +) +from .helpers import remove_devices + +SCAN_INTERVAL = timedelta(seconds=5) + +LOGGER = logging.getLogger(__name__) + +SUPPORT_HUE_ON_OFF = SUPPORT_FLASH | SUPPORT_TRANSITION +SUPPORT_HUE_DIMMABLE = SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS +SUPPORT_HUE_COLOR_TEMP = SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP +SUPPORT_HUE_COLOR = SUPPORT_HUE_DIMMABLE | SUPPORT_EFFECT | SUPPORT_COLOR +SUPPORT_HUE_EXTENDED = SUPPORT_HUE_COLOR_TEMP | SUPPORT_HUE_COLOR + +SUPPORT_HUE = { + "Extended color light": SUPPORT_HUE_EXTENDED, + "Color light": SUPPORT_HUE_COLOR, + "Dimmable light": SUPPORT_HUE_DIMMABLE, + "On/Off plug-in unit": SUPPORT_HUE_ON_OFF, + "Color temperature light": SUPPORT_HUE_COLOR_TEMP, +} + +ATTR_IS_HUE_GROUP = "is_hue_group" +GAMUT_TYPE_UNAVAILABLE = "None" +# Minimum Hue Bridge API version to support groups +# 1.4.0 introduced extended group info +# 1.12 introduced the state object for groups +# 1.13 introduced "any_on" to group state objects +GROUP_MIN_API_VERSION = (1, 13, 0) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up Hue lights. + + Can only be called when a user accidentally mentions hue platform in their + config. But even in that case it would have been ignored. + """ + + +def create_light(item_class, coordinator, bridge, is_group, rooms, api, item_id): + """Create the light.""" + api_item = api[item_id] + + if is_group: + supported_features = 0 + for light_id in api_item.lights: + if light_id not in bridge.api.lights: + continue + light = bridge.api.lights[light_id] + supported_features |= SUPPORT_HUE.get(light.type, SUPPORT_HUE_EXTENDED) + supported_features = supported_features or SUPPORT_HUE_EXTENDED + else: + supported_features = SUPPORT_HUE.get(api_item.type, SUPPORT_HUE_EXTENDED) + return item_class( + coordinator, bridge, is_group, api_item, supported_features, rooms + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Hue lights from a config entry.""" + bridge: HueBridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + api_version = tuple(int(v) for v in bridge.api.config.apiversion.split(".")) + rooms = {} + + allow_groups = config_entry.options.get( + CONF_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS + ) + supports_groups = api_version >= GROUP_MIN_API_VERSION + if allow_groups and not supports_groups: + LOGGER.warning("Please update your Hue bridge to support groups") + + light_coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name="light", + update_method=partial(async_safe_fetch, bridge, bridge.api.lights.update), + update_interval=SCAN_INTERVAL, + request_refresh_debouncer=Debouncer( + bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True + ), + ) + + # First do a refresh to see if we can reach the hub. + # Otherwise we will declare not ready. + await light_coordinator.async_refresh() + + if not light_coordinator.last_update_success: + raise PlatformNotReady + + if not supports_groups: + update_lights_without_group_support = partial( + async_update_items, + bridge, + bridge.api.lights, + {}, + async_add_entities, + partial(create_light, HueLight, light_coordinator, bridge, False, rooms), + None, + ) + # We add a listener after fetching the data, so manually trigger listener + bridge.reset_jobs.append( + light_coordinator.async_add_listener(update_lights_without_group_support) + ) + return + + group_coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name="group", + update_method=partial(async_safe_fetch, bridge, bridge.api.groups.update), + update_interval=SCAN_INTERVAL, + request_refresh_debouncer=Debouncer( + bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True + ), + ) + + if allow_groups: + update_groups = partial( + async_update_items, + bridge, + bridge.api.groups, + {}, + async_add_entities, + partial(create_light, HueLight, group_coordinator, bridge, True, None), + None, + ) + + bridge.reset_jobs.append(group_coordinator.async_add_listener(update_groups)) + + cancel_update_rooms_listener = None + + @callback + def _async_update_rooms(): + """Update rooms.""" + nonlocal cancel_update_rooms_listener + rooms.clear() + for item_id in bridge.api.groups: + group = bridge.api.groups[item_id] + if group.type not in [GROUP_TYPE_ROOM, GROUP_TYPE_ZONE]: + continue + for light_id in group.lights: + rooms[light_id] = group.name + + # Once we do a rooms update, we cancel the listener + # until the next time lights are added + bridge.reset_jobs.remove(cancel_update_rooms_listener) + cancel_update_rooms_listener() # pylint: disable=not-callable + cancel_update_rooms_listener = None + + @callback + def _setup_rooms_listener(): + nonlocal cancel_update_rooms_listener + if cancel_update_rooms_listener is not None: + # If there are new lights added before _async_update_rooms + # is called we should not add another listener + return + + cancel_update_rooms_listener = group_coordinator.async_add_listener( + _async_update_rooms + ) + bridge.reset_jobs.append(cancel_update_rooms_listener) + + _setup_rooms_listener() + await group_coordinator.async_refresh() + + update_lights_with_group_support = partial( + async_update_items, + bridge, + bridge.api.lights, + {}, + async_add_entities, + partial(create_light, HueLight, light_coordinator, bridge, False, rooms), + _setup_rooms_listener, + ) + # We add a listener after fetching the data, so manually trigger listener + bridge.reset_jobs.append( + light_coordinator.async_add_listener(update_lights_with_group_support) + ) + update_lights_with_group_support() + + +async def async_safe_fetch(bridge, fetch_method): + """Safely fetch data.""" + try: + with async_timeout.timeout(4): + return await bridge.async_request_call(fetch_method) + except aiohue.Unauthorized as err: + await bridge.handle_unauthorized_error() + raise UpdateFailed("Unauthorized") from err + except aiohue.AiohueException as err: + raise UpdateFailed(f"Hue error: {err}") from err + + +@callback +def async_update_items( + bridge, api, current, async_add_entities, create_item, new_items_callback +): + """Update items.""" + new_items = [] + + for item_id in api: + if item_id in current: + continue + + current[item_id] = create_item(api, item_id) + new_items.append(current[item_id]) + + bridge.hass.async_create_task(remove_devices(bridge, api, current)) + + if new_items: + # This is currently used to setup the listener to update rooms + if new_items_callback: + new_items_callback() + async_add_entities(new_items) + + +def hue_brightness_to_hass(value): + """Convert hue brightness 1..254 to hass format 0..255.""" + return min(255, round((value / 254) * 255)) + + +def hass_to_hue_brightness(value): + """Convert hass brightness 0..255 to hue 1..254 scale.""" + return max(1, round((value / 255) * 254)) + + +class HueLight(CoordinatorEntity, LightEntity): + """Representation of a Hue light.""" + + def __init__(self, coordinator, bridge, is_group, light, supported_features, rooms): + """Initialize the light.""" + super().__init__(coordinator) + self.light = light + self.bridge = bridge + self.is_group = is_group + self._supported_features = supported_features + self._rooms = rooms + self.allow_unreachable = self.bridge.config_entry.options.get( + CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE + ) + + if is_group: + self.is_osram = False + self.is_philips = False + self.is_innr = False + self.is_ewelink = False + self.is_livarno = False + self.gamut_typ = GAMUT_TYPE_UNAVAILABLE + self.gamut = None + else: + self.is_osram = light.manufacturername == "OSRAM" + self.is_philips = light.manufacturername == "Philips" + self.is_innr = light.manufacturername == "innr" + self.is_ewelink = light.manufacturername == "eWeLink" + self.is_livarno = light.manufacturername.startswith("_TZ3000_") + self.gamut_typ = self.light.colorgamuttype + self.gamut = self.light.colorgamut + LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut)) + if self.light.swupdatestate == "readytoinstall": + err = ( + "Please check for software updates of the %s " + "bulb in the Philips Hue App." + ) + LOGGER.warning(err, self.name) + if self.gamut and not color.check_valid_gamut(self.gamut): + err = "Color gamut of %s: %s, not valid, setting gamut to None." + LOGGER.debug(err, self.name, str(self.gamut)) + self.gamut_typ = GAMUT_TYPE_UNAVAILABLE + self.gamut = None + + @property + def unique_id(self): + """Return the unique ID of this Hue light.""" + unique_id = self.light.uniqueid + if not unique_id and self.is_group: + unique_id = self.light.id + + return unique_id + + @property + def device_id(self): + """Return the ID of this Hue light.""" + return self.unique_id + + @property + def name(self): + """Return the name of the Hue light.""" + return self.light.name + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + if self.is_group: + bri = self.light.action.get("bri") + else: + bri = self.light.state.get("bri") + + if bri is None: + return bri + + return hue_brightness_to_hass(bri) + + @property + def _color_mode(self): + """Return the hue color mode.""" + if self.is_group: + return self.light.action.get("colormode") + return self.light.state.get("colormode") + + @property + def hs_color(self): + """Return the hs color value.""" + mode = self._color_mode + source = self.light.action if self.is_group else self.light.state + + if mode in ("xy", "hs") and "xy" in source: + return color.color_xy_to_hs(*source["xy"], self.gamut) + + return None + + @property + def color_temp(self): + """Return the CT color value.""" + # Don't return color temperature unless in color temperature mode + if self._color_mode != "ct": + return None + + if self.is_group: + return self.light.action.get("ct") + return self.light.state.get("ct") + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + if self.is_group: + return super().min_mireds + + min_mireds = self.light.controlcapabilities.get("ct", {}).get("min") + + # We filter out '0' too, which can be incorrectly reported by 3rd party buls + if not min_mireds: + return super().min_mireds + + return min_mireds + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + if self.is_group: + return super().max_mireds + if self.is_livarno: + return 500 + + max_mireds = self.light.controlcapabilities.get("ct", {}).get("max") + + if not max_mireds: + return super().max_mireds + + return max_mireds + + @property + def is_on(self): + """Return true if device is on.""" + if self.is_group: + return self.light.state["any_on"] + return self.light.state["on"] + + @property + def available(self): + """Return if light is available.""" + return self.coordinator.last_update_success and ( + self.is_group or self.allow_unreachable or self.light.state["reachable"] + ) + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features + + @property + def effect(self): + """Return the current effect.""" + return self.light.state.get("effect", None) + + @property + def effect_list(self): + """Return the list of supported effects.""" + if self.is_osram: + return [EFFECT_RANDOM] + return [EFFECT_COLORLOOP, EFFECT_RANDOM] + + @property + def device_info(self) -> DeviceInfo | None: + """Return the device info.""" + if self.light.type in ( + GROUP_TYPE_ENTERTAINMENT, + GROUP_TYPE_LIGHT_GROUP, + GROUP_TYPE_ROOM, + GROUP_TYPE_LUMINAIRE, + GROUP_TYPE_LIGHT_SOURCE, + GROUP_TYPE_ZONE, + ): + return None + + suggested_area = None + if self._rooms and self.light.id in self._rooms: + suggested_area = self._rooms[self.light.id] + + return DeviceInfo( + identifiers={(HUE_DOMAIN, self.device_id)}, + manufacturer=self.light.manufacturername, + # productname added in Hue Bridge API 1.24 + # (published 03/05/2018) + model=self.light.productname or self.light.modelid, + name=self.name, + sw_version=self.light.swversion, + suggested_area=suggested_area, + via_device=(HUE_DOMAIN, self.bridge.api.config.bridgeid), + ) + + async def async_turn_on(self, **kwargs): + """Turn the specified or all lights on.""" + command = {"on": True} + + if ATTR_TRANSITION in kwargs: + command["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) + + if ATTR_HS_COLOR in kwargs: + if self.is_osram: + command["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535) + command["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) + else: + # Philips hue bulb models respond differently to hue/sat + # requests, so we convert to XY first to ensure a consistent + # color. + xy_color = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], self.gamut) + command["xy"] = xy_color + elif ATTR_COLOR_TEMP in kwargs: + temp = kwargs[ATTR_COLOR_TEMP] + command["ct"] = max(self.min_mireds, min(temp, self.max_mireds)) + + if ATTR_BRIGHTNESS in kwargs: + command["bri"] = hass_to_hue_brightness(kwargs[ATTR_BRIGHTNESS]) + + flash = kwargs.get(ATTR_FLASH) + + if flash == FLASH_LONG: + command["alert"] = "lselect" + del command["on"] + elif flash == FLASH_SHORT: + command["alert"] = "select" + del command["on"] + elif not self.is_innr and not self.is_ewelink and not self.is_livarno: + command["alert"] = "none" + + if ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + if effect == EFFECT_COLORLOOP: + command["effect"] = "colorloop" + elif effect == EFFECT_RANDOM: + command["hue"] = random.randrange(0, 65535) + command["sat"] = random.randrange(150, 254) + else: + command["effect"] = "none" + + if self.is_group: + await self.bridge.async_request_call(self.light.set_action, **command) + else: + await self.bridge.async_request_call(self.light.set_state, **command) + + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): + """Turn the specified or all lights off.""" + command = {"on": False} + + if ATTR_TRANSITION in kwargs: + command["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) + + flash = kwargs.get(ATTR_FLASH) + + if flash == FLASH_LONG: + command["alert"] = "lselect" + del command["on"] + elif flash == FLASH_SHORT: + command["alert"] = "select" + del command["on"] + elif not self.is_innr and not self.is_livarno: + command["alert"] = "none" + + if self.is_group: + await self.bridge.async_request_call(self.light.set_action, **command) + else: + await self.bridge.async_request_call(self.light.set_state, **command) + + await self.coordinator.async_request_refresh() + + @property + def extra_state_attributes(self): + """Return the device state attributes.""" + if not self.is_group: + return {} + return {ATTR_IS_HUE_GROUP: self.is_group} diff --git a/homeassistant/components/hue/v1/sensor.py b/homeassistant/components/hue/v1/sensor.py new file mode 100644 index 0000000000000..df12fe84274a1 --- /dev/null +++ b/homeassistant/components/hue/v1/sensor.py @@ -0,0 +1,135 @@ +"""Hue sensor entities.""" +from aiohue.v1.sensors import ( + TYPE_ZLL_LIGHTLEVEL, + TYPE_ZLL_ROTARY, + TYPE_ZLL_SWITCH, + TYPE_ZLL_TEMPERATURE, +) + +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + LIGHT_LUX, + PERCENTAGE, + TEMP_CELSIUS, +) + +from ..const import DOMAIN as HUE_DOMAIN +from .sensor_base import SENSOR_CONFIG_MAP, GenericHueSensor, GenericZLLSensor + +LIGHT_LEVEL_NAME_FORMAT = "{} light level" +REMOTE_NAME_FORMAT = "{} battery level" +TEMPERATURE_NAME_FORMAT = "{} temperature" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + + if not bridge.sensor_manager: + return + + await bridge.sensor_manager.async_register_component("sensor", async_add_entities) + + +class GenericHueGaugeSensorEntity(GenericZLLSensor, SensorEntity): + """Parent class for all 'gauge' Hue device sensors.""" + + +class HueLightLevel(GenericHueGaugeSensorEntity): + """The light level sensor entity for a Hue motion sensor device.""" + + _attr_device_class = DEVICE_CLASS_ILLUMINANCE + _attr_native_unit_of_measurement = LIGHT_LUX + + @property + def native_value(self): + """Return the state of the device.""" + if self.sensor.lightlevel is None: + return None + + # https://developers.meethue.com/develop/hue-api/supported-devices/#clip_zll_lightlevel + # Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm + # scale used because the human eye adjusts to light levels and small + # changes at low lux levels are more noticeable than at high lux + # levels. + return round(float(10 ** ((self.sensor.lightlevel - 1) / 10000)), 2) + + @property + def extra_state_attributes(self): + """Return the device state attributes.""" + attributes = super().extra_state_attributes + attributes.update( + { + "lightlevel": self.sensor.lightlevel, + "daylight": self.sensor.daylight, + "dark": self.sensor.dark, + "threshold_dark": self.sensor.tholddark, + "threshold_offset": self.sensor.tholdoffset, + } + ) + return attributes + + +class HueTemperature(GenericHueGaugeSensorEntity): + """The temperature sensor entity for a Hue motion sensor device.""" + + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_native_unit_of_measurement = TEMP_CELSIUS + + @property + def native_value(self): + """Return the state of the device.""" + if self.sensor.temperature is None: + return None + + return self.sensor.temperature / 100 + + +class HueBattery(GenericHueSensor, SensorEntity): + """Battery class for when a batt-powered device is only represented as an event.""" + + _attr_device_class = DEVICE_CLASS_BATTERY + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_native_unit_of_measurement = PERCENTAGE + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return f"{self.sensor.uniqueid}-battery" + + @property + def native_value(self): + """Return the state of the battery.""" + return self.sensor.battery + + +SENSOR_CONFIG_MAP.update( + { + TYPE_ZLL_LIGHTLEVEL: { + "platform": "sensor", + "name_format": LIGHT_LEVEL_NAME_FORMAT, + "class": HueLightLevel, + }, + TYPE_ZLL_TEMPERATURE: { + "platform": "sensor", + "name_format": TEMPERATURE_NAME_FORMAT, + "class": HueTemperature, + }, + TYPE_ZLL_SWITCH: { + "platform": "sensor", + "name_format": REMOTE_NAME_FORMAT, + "class": HueBattery, + }, + TYPE_ZLL_ROTARY: { + "platform": "sensor", + "name_format": REMOTE_NAME_FORMAT, + "class": HueBattery, + }, + } +) diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/v1/sensor_base.py similarity index 95% rename from homeassistant/components/hue/sensor_base.py rename to homeassistant/components/hue/v1/sensor_base.py index a99d92c03939d..142941a1859f9 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/v1/sensor_base.py @@ -6,7 +6,7 @@ from typing import Any from aiohue import AiohueException, Unauthorized -from aiohue.sensors import TYPE_ZLL_PRESENCE +from aiohue.v1.sensors import TYPE_ZLL_PRESENCE import async_timeout from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT @@ -14,13 +14,13 @@ from homeassistant.helpers import debounce, entity from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import REQUEST_REFRESH_DELAY +from ..const import REQUEST_REFRESH_DELAY from .helpers import remove_devices from .hue_event import EVENT_CONFIG_MAP from .sensor_device import GenericHueDevice SENSOR_CONFIG_MAP: dict[str, Any] = {} -_LOGGER = logging.getLogger(__name__) +LOGGER = logging.getLogger(__name__) def _device_id(aiohue_sensor): @@ -49,12 +49,12 @@ def __init__(self, bridge): self._enabled_platforms = ("binary_sensor", "sensor") self.coordinator = DataUpdateCoordinator( bridge.hass, - _LOGGER, + LOGGER, name="sensor", update_method=self.async_update_data, update_interval=self.SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( - bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True + bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True ), ) @@ -76,7 +76,7 @@ async def async_register_component(self, platform, async_add_entities): self._component_add_entities[platform] = async_add_entities if len(self._component_add_entities) < len(self._enabled_platforms): - _LOGGER.debug("Aborting start with %s, waiting for the rest", platform) + LOGGER.debug("Aborting start with %s, waiting for the rest", platform) return # We have all components available, start the updating. @@ -173,7 +173,7 @@ class GenericHueSensor(GenericHueDevice, entity.Entity): def available(self): """Return if sensor is available.""" return self.bridge.sensor_manager.coordinator.last_update_success and ( - self.bridge.allow_unreachable + self.allow_unreachable # remotes like Hue Tap (ZGPSwitchSensor) have no _reachability_ or self.sensor.config.get("reachable", True) ) diff --git a/homeassistant/components/hue/sensor_device.py b/homeassistant/components/hue/v1/sensor_device.py similarity index 83% rename from homeassistant/components/hue/sensor_device.py rename to homeassistant/components/hue/v1/sensor_device.py index 92c586ff8e0c7..176b5f118b236 100644 --- a/homeassistant/components/hue/sensor_device.py +++ b/homeassistant/components/hue/v1/sensor_device.py @@ -1,7 +1,11 @@ """Support for the Philips Hue sensor devices.""" from homeassistant.helpers import entity -from .const import DOMAIN as HUE_DOMAIN +from ..const import ( + CONF_ALLOW_UNREACHABLE, + DEFAULT_ALLOW_UNREACHABLE, + DOMAIN as HUE_DOMAIN, +) class GenericHueDevice(entity.Entity): @@ -13,6 +17,9 @@ def __init__(self, sensor, name, bridge, primary_sensor=None): self._name = name self._primary_sensor = primary_sensor self.bridge = bridge + self.allow_unreachable = bridge.config_entry.options.get( + CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE + ) @property def primary_sensor(self): @@ -53,12 +60,3 @@ def device_info(self) -> entity.DeviceInfo: sw_version=self.primary_sensor.swversion, via_device=(HUE_DOMAIN, self.bridge.api.config.bridgeid), ) - - async def async_added_to_hass(self) -> None: - """Handle entity being added to Home Assistant.""" - self.async_on_remove( - self.bridge.listen_updates( - self.sensor.ITEM_TYPE, self.sensor.id, self.async_write_ha_state - ) - ) - await super().async_added_to_hass() diff --git a/homeassistant/components/hue/v2/__init__.py b/homeassistant/components/hue/v2/__init__.py new file mode 100644 index 0000000000000..ebcf4873dc70e --- /dev/null +++ b/homeassistant/components/hue/v2/__init__.py @@ -0,0 +1 @@ +"""Hue V2 API specific platform implementation.""" diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py new file mode 100644 index 0000000000000..f655e20375505 --- /dev/null +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -0,0 +1,110 @@ +"""Support for Hue binary sensors.""" +from __future__ import annotations + +from typing import Any, Union + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.config import EntertainmentConfigurationController +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.sensors import MotionController +from aiohue.v2.models.entertainment import ( + EntertainmentConfiguration, + EntertainmentStatus, +) +from aiohue.v2.models.motion import Motion + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOTION, + DEVICE_CLASS_RUNNING, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from ..bridge import HueBridge +from ..const import DOMAIN +from .entity import HueBaseEntity + +SensorType = Union[Motion, EntertainmentConfiguration] +ControllerType = Union[MotionController, EntertainmentConfigurationController] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Hue Sensors from Config Entry.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + + @callback + def register_items(controller: ControllerType, sensor_class: SensorType): + @callback + def async_add_sensor(event_type: EventType, resource: SensorType) -> None: + """Add Hue Binary Sensor.""" + async_add_entities([sensor_class(bridge, controller, resource)]) + + # add all current items in controller + for sensor in controller: + async_add_sensor(EventType.RESOURCE_ADDED, sensor) + + # register listener for new sensors + config_entry.async_on_unload( + controller.subscribe( + async_add_sensor, event_filter=EventType.RESOURCE_ADDED + ) + ) + + # setup for each binary-sensor-type hue resource + register_items(api.sensors.motion, HueMotionSensor) + register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor) + + +class HueBinarySensorBase(HueBaseEntity, BinarySensorEntity): + """Representation of a Hue binary_sensor.""" + + def __init__( + self, + bridge: HueBridge, + controller: ControllerType, + resource: SensorType, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(bridge, controller, resource) + self.resource = resource + self.controller = controller + + +class HueMotionSensor(HueBinarySensorBase): + """Representation of a Hue Motion sensor.""" + + _attr_device_class = DEVICE_CLASS_MOTION + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.resource.motion.motion + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + return {"motion_valid": self.resource.motion.motion_valid} + + +class HueEntertainmentActiveSensor(HueBinarySensorBase): + """Representation of a Hue Entertainment Configuration as binary sensor.""" + + _attr_device_class = DEVICE_CLASS_RUNNING + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.resource.status == EntertainmentStatus.ACTIVE + + @property + def name(self) -> str: + """Return sensor name.""" + type_title = self.resource.type.value.replace("_", " ").title() + return f"{self.resource.name}: {type_title}" diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py new file mode 100644 index 0000000000000..1608743cc48cd --- /dev/null +++ b/homeassistant/components/hue/v2/device.py @@ -0,0 +1,86 @@ +"""Handles Hue resource of type `device` mapping to Home Assistant device.""" +from typing import TYPE_CHECKING + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.device import Device, DeviceArchetypes + +from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SUGGESTED_AREA, + ATTR_SW_VERSION, + ATTR_VIA_DEVICE, +) +from homeassistant.core import callback +from homeassistant.helpers import device_registry + +from ..const import DOMAIN + +if TYPE_CHECKING: + from ..bridge import HueBridge + + +async def async_setup_devices(bridge: "HueBridge"): + """Manage setup of devices from Hue devices.""" + entry = bridge.config_entry + hass = bridge.hass + api: HueBridgeV2 = bridge.api # to satisfy typing + dev_reg = device_registry.async_get(hass) + dev_controller = api.devices + + @callback + def add_device(hue_device: Device) -> device_registry.DeviceEntry: + """Register a Hue device in device registry.""" + model = f"{hue_device.product_data.product_name} ({hue_device.product_data.model_id})" + params = { + ATTR_IDENTIFIERS: {(DOMAIN, hue_device.id)}, + ATTR_SW_VERSION: hue_device.product_data.software_version, + ATTR_NAME: hue_device.metadata.name, + ATTR_MODEL: model, + ATTR_MANUFACTURER: hue_device.product_data.manufacturer_name, + } + if room := dev_controller.get_room(hue_device.id): + params[ATTR_SUGGESTED_AREA] = room.metadata.name + if hue_device.metadata.archetype == DeviceArchetypes.BRIDGE_V2: + params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id)) + else: + params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id) + if zigbee := dev_controller.get_zigbee_connectivity(hue_device.id): + params[ATTR_CONNECTIONS] = { + (device_registry.CONNECTION_NETWORK_MAC, zigbee.mac_address) + } + + return dev_reg.async_get_or_create(config_entry_id=entry.entry_id, **params) + + @callback + def remove_device(hue_device_id: str) -> None: + """Remove device from registry.""" + if device := dev_reg.async_get_device({(DOMAIN, hue_device_id)}): + # note: removal of any underlying entities is handled by core + dev_reg.async_remove_device(device.id) + + @callback + def handle_device_event(evt_type: EventType, hue_device: Device) -> None: + """Handle event from Hue devices controller.""" + if evt_type == EventType.RESOURCE_DELETED: + remove_device(hue_device.id) + else: + # updates to existing device will also be handled by this call + add_device(hue_device) + + # create/update all current devices found in controller + known_devices = [add_device(hue_device) for hue_device in dev_controller] + + # Check for nodes that no longer exist and remove them + for device in device_registry.async_entries_for_config_entry( + dev_reg, entry.entry_id + ): + if device not in known_devices: + dev_reg.async_remove_device(device.id) + + # add listener for updates on Hue devices controller + entry.async_on_unload(dev_controller.subscribe(handle_device_event)) diff --git a/homeassistant/components/hue/v2/device_trigger.py b/homeassistant/components/hue/v2/device_trigger.py new file mode 100644 index 0000000000000..b33b7540cb8a9 --- /dev/null +++ b/homeassistant/components/hue/v2/device_trigger.py @@ -0,0 +1,115 @@ +"""Provides device automations for Philips Hue events.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from aiohue.v2.models.button import ButtonEvent +from aiohue.v2.models.resource import ResourceTypes +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, + CONF_UNIQUE_ID, +) +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.typing import ConfigType + +from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN + +if TYPE_CHECKING: + from aiohue.v2 import HueBridgeV2 + + from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, + ) + + from ..bridge import HueBridge + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): str, + vol.Required(CONF_SUBTYPE): int, + vol.Optional(CONF_UNIQUE_ID): str, + } +) + + +async def async_validate_trigger_config( + bridge: "HueBridge", + device_entry: DeviceEntry, + config: ConfigType, +): + """Validate config.""" + config = TRIGGER_SCHEMA(config) + return config + + +async def async_attach_trigger( + bridge: "HueBridge", + device_entry: DeviceEntry, + config: ConfigType, + action: "AutomationActionType", + automation_info: "AutomationTriggerInfo", +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + hass = bridge.hass + event_config = event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: ATTR_HUE_EVENT, + event_trigger.CONF_EVENT_DATA: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + CONF_TYPE: config[CONF_TYPE], + CONF_SUBTYPE: config[CONF_SUBTYPE], + }, + } + ) + return await event_trigger.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) + + +async def async_get_triggers(bridge: "HueBridge", device_entry: DeviceEntry): + """Return device triggers for device on `v2` bridge.""" + api: HueBridgeV2 = bridge.api + + # Get Hue device id from device identifier + hue_dev_id = get_hue_device_id(device_entry) + # extract triggers from all button resources of this Hue device + triggers = [] + for resource in api.devices.get_sensors(hue_dev_id): + if resource.type != ResourceTypes.BUTTON: + continue + for event_type in (x.value for x in ButtonEvent if x != ButtonEvent.UNKNOWN): + triggers.append( + { + CONF_DEVICE_ID: device_entry.id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: event_type, + CONF_SUBTYPE: resource.metadata.control_id, + CONF_UNIQUE_ID: device_entry.id, + } + ) + return triggers + + +@callback +def get_hue_device_id(device_entry: DeviceEntry) -> str | None: + """Get Hue device id from device entry.""" + return next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and ":" not in identifier[1] # filter out v1 mac id + ), + None, + ) diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py new file mode 100644 index 0000000000000..1b646857e4402 --- /dev/null +++ b/homeassistant/components/hue/v2/entity.py @@ -0,0 +1,113 @@ +"""Generic Hue Entity Model.""" +from __future__ import annotations + +from aiohue.v2.controllers.base import BaseResourcesController +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.clip import CLIPResource +from aiohue.v2.models.connectivity import ConnectivityServiceStatus +from aiohue.v2.models.resource import ResourceTypes + +from homeassistant.core import callback +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry + +from ..bridge import HueBridge +from ..const import DOMAIN + +RESOURCE_TYPE_NAMES = { + # a simple mapping of hue resource type to Hass name + ResourceTypes.LIGHT_LEVEL: "Illuminance", + ResourceTypes.DEVICE_POWER: "Battery", +} + + +class HueBaseEntity(Entity): + """Generic Entity Class for a Hue resource.""" + + _attr_should_poll = False + + def __init__( + self, + bridge: HueBridge, + controller: BaseResourcesController, + resource: CLIPResource, + ) -> None: + """Initialize a generic Hue resource entity.""" + self.bridge = bridge + self.controller = controller + self.resource = resource + self.device = ( + controller.get_device(resource.id) or bridge.api.config.bridge_device + ) + self.logger = bridge.logger.getChild(resource.type.value) + + # Entity class attributes + self._attr_unique_id = resource.id + # device is precreated in main handler + # this attaches the entity to the precreated device + if self.device is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device.id)}, + ) + + @property + def name(self) -> str: + """Return name for the entity.""" + if self.device is None: + # this is just a guard + # creating a pretty name for device-less entities (e.g. groups/scenes) + # should be handled in the platform instead + return self.resource.type.value + dev_name = self.device.metadata.name + # if resource is a light, use the device name + if self.resource.type == ResourceTypes.LIGHT: + return dev_name + # for sensors etc, use devicename + pretty name of type + type_title = RESOURCE_TYPE_NAMES.get( + self.resource.type, self.resource.type.value.replace("_", " ").title() + ) + return f"{dev_name}: {type_title}" + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + # Add value_changed callbacks. + self.async_on_remove( + self.controller.subscribe( + self._handle_event, + self.resource.id, + (EventType.RESOURCE_UPDATED, EventType.RESOURCE_DELETED), + ) + ) + + @property + def available(self) -> bool: + """Return entity availability.""" + if self.device is None: + # devices without a device attached should be always available + return True + if self.resource.type == ResourceTypes.ZIGBEE_CONNECTIVITY: + # the zigbee connectivity sensor itself should be always available + return True + if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id): + # all device-attached entities get availability from the zigbee connectivity + return zigbee.status == ConnectivityServiceStatus.CONNECTED + return True + + @callback + def on_update(self) -> None: + """Call on update event.""" + # used in subclasses + + @callback + def _handle_event(self, event_type: EventType, resource: CLIPResource) -> None: + """Handle status event for this resource.""" + if event_type == EventType.RESOURCE_DELETED and resource.id == self.resource.id: + self.logger.debug("Received delete for %s", self.entity_id) + # non-device bound entities like groups and scenes need to be removed here + # all others will be be removed by device setup in case of device removal + ent_reg = async_get_entity_registry(self.hass) + ent_reg.async_remove(self.entity_id) + else: + self.logger.debug("Received status update for %s", self.entity_id) + self.on_update() + self.async_write_ha_state() diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py new file mode 100644 index 0000000000000..ae77ab3853965 --- /dev/null +++ b/homeassistant/components/hue/v2/group.py @@ -0,0 +1,249 @@ +"""Support for Hue groups (room/zone).""" +from __future__ import annotations + +from typing import Any + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.groups import GroupedLight, Room, Zone + +from homeassistant.components.group.light import LightGroup +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_TRANSITION, + ATTR_XY_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_ONOFF, + COLOR_MODE_XY, + SUPPORT_TRANSITION, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from ..bridge import HueBridge +from ..const import DOMAIN +from .entity import HueBaseEntity + +ALLOWED_ERRORS = [ + "device (groupedLight) has communication issues, command (on) may not have effect", + 'device (groupedLight) is "soft off", command (on) may not have effect', + "device (light) has communication issues, command (on) may not have effect", + 'device (light) is "soft off", command (on) may not have effect', +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Hue groups on light platform.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + + # to prevent race conditions (groupedlight is created before zone/room) + # we create groupedlights from the room/zone and actually use the + # underlying grouped_light resource for control + + @callback + def async_add_light(event_type: EventType, resource: Room | Zone) -> None: + """Add Grouped Light for Hue Room/Zone.""" + if grouped_light_id := resource.grouped_light: + grouped_light = api.groups.grouped_light[grouped_light_id] + light = GroupedHueLight(bridge, grouped_light, resource) + async_add_entities([light]) + + # add current items + for item in api.groups.room.items + api.groups.zone.items: + async_add_light(EventType.RESOURCE_ADDED, item) + + # register listener for new zones/rooms + config_entry.async_on_unload( + api.groups.room.subscribe( + async_add_light, event_filter=EventType.RESOURCE_ADDED + ) + ) + config_entry.async_on_unload( + api.groups.zone.subscribe( + async_add_light, event_filter=EventType.RESOURCE_ADDED + ) + ) + + +class GroupedHueLight(HueBaseEntity, LightGroup): + """Representation of a Grouped Hue light.""" + + # Entities for Hue groups are disabled by default + _attr_entity_registry_enabled_default = False + + def __init__( + self, bridge: HueBridge, resource: GroupedLight, group: Room | Zone + ) -> None: + """Initialize the light.""" + controller = bridge.api.groups.grouped_light + super().__init__(bridge, controller, resource) + self.resource = resource + self.group = group + self.controller = controller + self.api: HueBridgeV2 = bridge.api + self._attr_supported_features |= SUPPORT_TRANSITION + + self._update_values() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + + # subscribe to group updates + self.async_on_remove( + self.api.groups.subscribe(self._handle_event, self.group.id) + ) + # We need to watch the underlying lights too + # if we want feedback about color/brightness changes + if self._attr_supported_color_modes: + light_ids = tuple( + x.id for x in self.controller.get_lights(self.resource.id) + ) + self.async_on_remove( + self.api.lights.subscribe(self._handle_event, light_ids) + ) + + @property + def name(self) -> str: + """Return name of room/zone for this grouped light.""" + return self.group.metadata.name + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self.resource.on.on + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the optional state attributes.""" + scenes = { + x.metadata.name for x in self.api.scenes if x.group.rid == self.group.id + } + lights = {x.metadata.name for x in self.controller.get_lights(self.resource.id)} + return { + "is_hue_group": True, + "hue_scenes": scenes, + "hue_type": self.group.type.value, + "lights": lights, + } + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + transition = kwargs.get(ATTR_TRANSITION) + xy_color = kwargs.get(ATTR_XY_COLOR) + color_temp = kwargs.get(ATTR_COLOR_TEMP) + brightness = kwargs.get(ATTR_BRIGHTNESS) + if brightness is not None: + # Hue uses a range of [0, 100] to control brightness. + brightness = float((brightness / 255) * 100) + if transition is not None: + # hue transition duration is in steps of 100 ms + transition = int(transition * 100) + + # NOTE: a grouped_light can only handle turn on/off + # To set other features, you'll have to control the attached lights + if ( + brightness is None + and xy_color is None + and color_temp is None + and transition is None + ): + await self.bridge.async_request_call( + self.controller.set_state, + id=self.resource.id, + on=True, + allowed_errors=ALLOWED_ERRORS, + ) + return + + # redirect all other feature commands to underlying lights + # note that this silently ignores params sent to light that are not supported + for light in self.controller.get_lights(self.resource.id): + await self.bridge.async_request_call( + self.api.lights.set_state, + light.id, + on=True, + brightness=brightness if light.supports_dimming else None, + color_xy=xy_color if light.supports_color else None, + color_temp=color_temp if light.supports_color_temperature else None, + transition_time=transition, + allowed_errors=ALLOWED_ERRORS, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.bridge.async_request_call( + self.controller.set_state, + id=self.resource.id, + on=False, + allowed_errors=ALLOWED_ERRORS, + ) + + @callback + def on_update(self) -> None: + """Call on update event.""" + self._update_values() + + @callback + def _update_values(self) -> None: + """Set base values from underlying lights of a group.""" + supported_color_modes = set() + lights_with_color_support = 0 + lights_with_color_temp_support = 0 + lights_with_dimming_support = 0 + total_brightness = 0 + all_lights = self.controller.get_lights(self.resource.id) + lights_in_colortemp_mode = 0 + # loop through all lights to find capabilities + for light in all_lights: + if color_temp := light.color_temperature: + lights_with_color_temp_support += 1 + # we assume mired values from the first capable light + self._attr_color_temp = color_temp.mirek + self._attr_max_mireds = color_temp.mirek_schema.mirek_maximum + self._attr_min_mireds = color_temp.mirek_schema.mirek_minimum + if color_temp.mirek is not None and color_temp.mirek_valid: + lights_in_colortemp_mode += 1 + if color := light.color: + lights_with_color_support += 1 + # we assume xy values from the first capable light + self._attr_xy_color = (color.xy.x, color.xy.y) + if dimming := light.dimming: + lights_with_dimming_support += 1 + total_brightness += dimming.brightness + # this is a bit hacky because light groups may contain lights + # of different capabilities. We set a colormode as supported + # if any of the lights support it + # this means that the state is derived from only some of the lights + # and will never be 100% accurate but it will be close + if lights_with_color_support > 0: + supported_color_modes.add(COLOR_MODE_XY) + if lights_with_color_temp_support > 0: + supported_color_modes.add(COLOR_MODE_COLOR_TEMP) + if lights_with_dimming_support > 0: + if len(supported_color_modes) == 0: + # only add color mode brightness if no color variants + supported_color_modes.add(COLOR_MODE_BRIGHTNESS) + self._attr_brightness = round( + ((total_brightness / lights_with_dimming_support) / 100) * 255 + ) + else: + supported_color_modes.add(COLOR_MODE_ONOFF) + self._attr_supported_color_modes = supported_color_modes + # pick a winner for the current colormode + if lights_in_colortemp_mode == lights_with_color_temp_support: + self._attr_color_mode = COLOR_MODE_COLOR_TEMP + elif lights_with_color_support > 0: + self._attr_color_mode = COLOR_MODE_XY + elif lights_with_dimming_support > 0: + self._attr_color_mode = COLOR_MODE_BRIGHTNESS + else: + self._attr_color_mode = COLOR_MODE_ONOFF diff --git a/homeassistant/components/hue/v2/hue_event.py b/homeassistant/components/hue/v2/hue_event.py new file mode 100644 index 0000000000000..86dabc26660cf --- /dev/null +++ b/homeassistant/components/hue/v2/hue_event.py @@ -0,0 +1,57 @@ +"""Handle forward of events transmitted by Hue devices to HASS.""" +import logging +from typing import TYPE_CHECKING + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.button import Button + +from homeassistant.const import CONF_DEVICE_ID, CONF_ID, CONF_TYPE, CONF_UNIQUE_ID +from homeassistant.core import callback +from homeassistant.helpers import device_registry +from homeassistant.util import slugify + +from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN as DOMAIN + +CONF_CONTROL_ID = "control_id" + +if TYPE_CHECKING: + from ..bridge import HueBridge + +LOGGER = logging.getLogger(__name__) + + +async def async_setup_hue_events(bridge: "HueBridge"): + """Manage listeners for stateless Hue sensors that emit events.""" + hass = bridge.hass + api: HueBridgeV2 = bridge.api # to satisfy typing + conf_entry = bridge.config_entry + dev_reg = device_registry.async_get(hass) + + # at this time the `button` resource is the only source of hue events + btn_controller = api.sensors.button + + @callback + def handle_button_event(evt_type: EventType, hue_resource: Button) -> None: + """Handle event from Hue devices controller.""" + LOGGER.debug("Received button event: %s", hue_resource) + hue_device = btn_controller.get_device(hue_resource.id) + device = dev_reg.async_get_device({(DOMAIN, hue_device.id)}) + + # Fire event + data = { + # send slugified entity name as id = backwards compatibility with previous version + CONF_ID: slugify(f"{hue_device.metadata.name}: Button"), + CONF_DEVICE_ID: device.id, # type: ignore + CONF_UNIQUE_ID: hue_resource.id, + CONF_TYPE: hue_resource.button.last_event.value, + CONF_SUBTYPE: hue_resource.metadata.control_id, + } + hass.bus.async_fire(ATTR_HUE_EVENT, data) + + # add listener for updates from `button` resource + conf_entry.async_on_unload( + btn_controller.subscribe( + handle_button_event, event_filter=EventType.RESOURCE_UPDATED + ) + ) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py new file mode 100644 index 0000000000000..42972f2242cf1 --- /dev/null +++ b/homeassistant/components/hue/v2/light.py @@ -0,0 +1,187 @@ +"""Support for Hue lights.""" +from __future__ import annotations + +from typing import Any + +from aiohue import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.lights import LightsController +from aiohue.v2.models.light import Light + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_TRANSITION, + ATTR_XY_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_ONOFF, + COLOR_MODE_XY, + SUPPORT_TRANSITION, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from ..bridge import HueBridge +from ..const import DOMAIN +from .entity import HueBaseEntity + +ALLOWED_ERRORS = [ + "device (light) has communication issues, command (on) may not have effect", + 'device (light) is "soft off", command (on) may not have effect', +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Hue Light from Config Entry.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + controller: LightsController = api.lights + + @callback + def async_add_light(event_type: EventType, resource: Light) -> None: + """Add Hue Light.""" + light = HueLight(bridge, controller, resource) + async_add_entities([light]) + + # add all current items in controller + for light in controller: + async_add_light(EventType.RESOURCE_ADDED, resource=light) + + # register listener for new lights + config_entry.async_on_unload( + controller.subscribe(async_add_light, event_filter=EventType.RESOURCE_ADDED) + ) + + +class HueLight(HueBaseEntity, LightEntity): + """Representation of a Hue light.""" + + def __init__( + self, bridge: HueBridge, controller: LightsController, resource: Light + ) -> None: + """Initialize the light.""" + super().__init__(bridge, controller, resource) + self.resource = resource + self.controller = controller + self._supported_color_modes = set() + if self.resource.supports_color: + self._supported_color_modes.add(COLOR_MODE_XY) + if self.resource.supports_color_temperature: + self._supported_color_modes.add(COLOR_MODE_COLOR_TEMP) + if self.resource.supports_dimming: + if len(self._supported_color_modes) == 0: + # only add color mode brightness if no color variants + self._supported_color_modes.add(COLOR_MODE_BRIGHTNESS) + # support transition if brightness control + self._attr_supported_features |= SUPPORT_TRANSITION + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0..255.""" + if dimming := self.resource.dimming: + # Hue uses a range of [0, 100] to control brightness. + return round((dimming.brightness / 100) * 255) + return None + + @property + def color_mode(self) -> str: + """Return the current color mode of the light.""" + if color_temp := self.resource.color_temperature: + if color_temp.mirek_valid and color_temp.mirek is not None: + return COLOR_MODE_COLOR_TEMP + if self.resource.supports_color: + return COLOR_MODE_XY + if self.resource.supports_dimming: + return COLOR_MODE_BRIGHTNESS + return COLOR_MODE_ONOFF + + @property + def is_on(self) -> bool: + """Return true if device is on (brightness above 0).""" + return self.resource.on.on + + @property + def xy_color(self) -> tuple[float, float] | None: + """Return the xy color.""" + if color := self.resource.color: + return (color.xy.x, color.xy.y) + return None + + @property + def color_temp(self) -> int: + """Return the color temperature.""" + if color_temp := self.resource.color_temperature: + return color_temp.mirek + return 0 + + @property + def min_mireds(self) -> int: + """Return the coldest color_temp that this light supports.""" + if color_temp := self.resource.color_temperature: + return color_temp.mirek_schema.mirek_minimum + return 0 + + @property + def max_mireds(self) -> int: + """Return the warmest color_temp that this light supports.""" + if color_temp := self.resource.color_temperature: + return color_temp.mirek_schema.mirek_maximum + return 0 + + @property + def supported_color_modes(self) -> set | None: + """Flag supported features.""" + return self._supported_color_modes + + @property + def extra_state_attributes(self) -> dict[str, str] | None: + """Return the optional state attributes.""" + return { + "mode": self.resource.mode.value, + "dynamics": self.resource.dynamics.status.value, + } + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + transition = kwargs.get(ATTR_TRANSITION) + xy_color = kwargs.get(ATTR_XY_COLOR) + color_temp = kwargs.get(ATTR_COLOR_TEMP) + brightness = kwargs.get(ATTR_BRIGHTNESS) + if brightness is not None: + # Hue uses a range of [0, 100] to control brightness. + brightness = float((brightness / 255) * 100) + if transition is not None: + # hue transition duration is in steps of 100 ms + transition = int(transition * 100) + + await self.bridge.async_request_call( + self.controller.set_state, + id=self.resource.id, + on=True, + brightness=brightness, + color_xy=xy_color, + color_temp=color_temp, + transition_time=transition, + allowed_errors=ALLOWED_ERRORS, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + transition = kwargs.get(ATTR_TRANSITION) + if transition is not None: + # hue transition duration is in steps of 100 ms + transition = int(transition * 100) + await self.bridge.async_request_call( + self.controller.set_state, + id=self.resource.id, + on=False, + transition_time=transition, + allowed_errors=ALLOWED_ERRORS, + ) diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py new file mode 100644 index 0000000000000..bb801b7817daf --- /dev/null +++ b/homeassistant/components/hue/v2/sensor.py @@ -0,0 +1,174 @@ +"""Support for Hue sensors.""" +from __future__ import annotations + +from typing import Any, Union + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.sensors import ( + DevicePowerController, + LightLevelController, + SensorsController, + TemperatureController, + ZigbeeConnectivityController, +) +from aiohue.v2.models.connectivity import ZigbeeConnectivity +from aiohue.v2.models.device_power import DevicePower +from aiohue.v2.models.light_level import LightLevel +from aiohue.v2.models.temperature import Temperature + +from homeassistant.components.binary_sensor import DEVICE_CLASS_CONNECTIVITY +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + LIGHT_LUX, + PERCENTAGE, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from ..bridge import HueBridge +from ..const import DOMAIN +from .entity import HueBaseEntity + +SensorType = Union[DevicePower, LightLevel, Temperature, ZigbeeConnectivity] +ControllerType = Union[ + DevicePowerController, + LightLevelController, + TemperatureController, + ZigbeeConnectivityController, +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Hue Sensors from Config Entry.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + ctrl_base: SensorsController = api.sensors + + @callback + def register_items(controller: ControllerType, sensor_class: SensorType): + @callback + def async_add_sensor(event_type: EventType, resource: SensorType) -> None: + """Add Hue Sensor.""" + async_add_entities([sensor_class(bridge, controller, resource)]) + + # add all current items in controller + for sensor in controller: + async_add_sensor(EventType.RESOURCE_ADDED, sensor) + + # register listener for new sensors + config_entry.async_on_unload( + controller.subscribe( + async_add_sensor, event_filter=EventType.RESOURCE_ADDED + ) + ) + + # setup for each sensor-type hue resource + register_items(ctrl_base.temperature, HueTemperatureSensor) + register_items(ctrl_base.light_level, HueLightLevelSensor) + register_items(ctrl_base.device_power, HueBatterySensor) + register_items(ctrl_base.zigbee_connectivity, HueZigbeeConnectivitySensor) + + +class HueSensorBase(HueBaseEntity, SensorEntity): + """Representation of a Hue sensor.""" + + _attr_state_class = STATE_CLASS_MEASUREMENT + + def __init__( + self, + bridge: HueBridge, + controller: ControllerType, + resource: SensorType, + ) -> None: + """Initialize the light.""" + super().__init__(bridge, controller, resource) + self.resource = resource + self.controller = controller + + +class HueTemperatureSensor(HueSensorBase): + """Representation of a Hue Temperature sensor.""" + + _attr_native_unit_of_measurement = TEMP_CELSIUS + _attr_device_class = DEVICE_CLASS_TEMPERATURE + + @property + def native_value(self) -> float: + """Return the value reported by the sensor.""" + return round(self.resource.temperature.temperature, 1) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + return {"temperature_valid": self.resource.temperature.temperature_valid} + + +class HueLightLevelSensor(HueSensorBase): + """Representation of a Hue LightLevel (illuminance) sensor.""" + + _attr_native_unit_of_measurement = LIGHT_LUX + _attr_device_class = DEVICE_CLASS_ILLUMINANCE + + @property + def native_value(self) -> int: + """Return the value reported by the sensor.""" + # Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm + # scale used because the human eye adjusts to light levels and small + # changes at low lux levels are more noticeable than at high lux + # levels. + return int(10 ** ((self.resource.light.light_level - 1) / 10000)) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + return { + "light_level": self.resource.light.light_level, + "light_level_valid": self.resource.light.light_level_valid, + } + + +class HueBatterySensor(HueSensorBase): + """Representation of a Hue Battery sensor.""" + + _attr_native_unit_of_measurement = PERCENTAGE + _attr_device_class = DEVICE_CLASS_BATTERY + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + + @property + def native_value(self) -> int: + """Return the value reported by the sensor.""" + return self.resource.power_state.battery_level + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + return {"battery_state": self.resource.power_state.battery_state.value} + + +class HueZigbeeConnectivitySensor(HueSensorBase): + """Representation of a Hue ZigbeeConnectivity sensor.""" + + _attr_device_class = DEVICE_CLASS_CONNECTIVITY + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + @property + def native_value(self) -> str: + """Return the value reported by the sensor.""" + return self.resource.status.value + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + return {"mac_address": self.resource.mac_address} diff --git a/requirements_all.txt b/requirements_all.txt index 7b80cc3150e6e..123a9c618c987 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -186,7 +186,7 @@ aiohomekit==0.6.3 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.3 +aiohue==3.0.1 # homeassistant.components.imap aioimaplib==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4336c51cc2f6b..768c3c7854f8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ aiohomekit==0.6.3 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.3 +aiohue==3.0.1 # homeassistant.components.apache_kafka aiokafka==0.6.0 diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 648337d75392b..0ebeaca9b8846 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -1,56 +1,70 @@ """Test helpers for Hue.""" +import asyncio from collections import deque +import json import logging from unittest.mock import AsyncMock, Mock, patch -from aiohue.groups import Groups -from aiohue.lights import Lights -from aiohue.scenes import Scenes -from aiohue.sensors import Sensors +import aiohue.v1 as aiohue_v1 +import aiohue.v2 as aiohue_v2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.clip import parse_clip_resource import pytest from homeassistant.components import hue -from homeassistant.components.hue import sensor_base as hue_sensor_base +from homeassistant.components.hue.v1 import sensor_base as hue_sensor_base +from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry -from tests.components.light.conftest import mock_light_profiles # noqa: F401 +from tests.common import ( + MockConfigEntry, + async_mock_service, + load_fixture, + mock_device_registry, +) + +# from tests.components.light.conftest import mock_light_profiles # noqa: F401 @pytest.fixture(autouse=True) def no_request_delay(): """Make the request refresh delay 0 for instant tests.""" - with patch("homeassistant.components.hue.light.REQUEST_REFRESH_DELAY", 0): + with patch("homeassistant.components.hue.const.REQUEST_REFRESH_DELAY", 0): yield -def create_mock_bridge(hass): - """Create a mock Hue bridge.""" +def create_mock_bridge(hass, api_version=1): + """Create a mocked HueBridge instance.""" bridge = Mock( hass=hass, - available=True, authorized=True, - allow_unreachable=False, - allow_groups=False, - api=create_mock_api(hass), config_entry=None, reset_jobs=[], + api_version=api_version, spec=hue.HueBridge, ) - bridge.sensor_manager = hue_sensor_base.SensorManager(bridge) - bridge.mock_requests = bridge.api.mock_requests - bridge.mock_light_responses = bridge.api.mock_light_responses - bridge.mock_group_responses = bridge.api.mock_group_responses - bridge.mock_sensor_responses = bridge.api.mock_sensor_responses - async def async_setup(): + bridge.logger = logging.getLogger(__name__) + + if bridge.api_version == 2: + bridge.api = create_mock_api_v2(hass) + bridge.mock_requests = bridge.api.mock_requests + else: + bridge.api = create_mock_api_v1(hass) + bridge.sensor_manager = hue_sensor_base.SensorManager(bridge) + bridge.mock_requests = bridge.api.mock_requests + bridge.mock_light_responses = bridge.api.mock_light_responses + bridge.mock_group_responses = bridge.api.mock_group_responses + bridge.mock_sensor_responses = bridge.api.mock_sensor_responses + + async def async_initialize_bridge(): if bridge.config_entry: hass.data.setdefault(hue.DOMAIN, {})[bridge.config_entry.entry_id] = bridge return True - bridge.async_setup = async_setup + bridge.async_initialize_bridge = async_initialize_bridge - async def async_request_call(task): - await task() + async def async_request_call(task, *args, allowed_errors=None, **kwargs): + await task(*args, **kwargs) bridge.async_request_call = async_request_call @@ -65,14 +79,21 @@ async def async_reset(): @pytest.fixture -def mock_api(hass): - """Mock the Hue api.""" - return create_mock_api(hass) +def mock_api_v1(hass): + """Mock the Hue V1 api.""" + return create_mock_api_v1(hass) + + +@pytest.fixture +def mock_api_v2(hass): + """Mock the Hue V2 api.""" + return create_mock_api_v2(hass) -def create_mock_api(hass): - """Create a mock API.""" - api = Mock(initialize=AsyncMock()) +def create_mock_api_v1(hass): + """Create a mock V1 API.""" + api = Mock(spec=aiohue_v1.HueBridgeV1) + api.initialize = AsyncMock() api.mock_requests = [] api.mock_light_responses = deque() api.mock_group_responses = deque() @@ -97,43 +118,194 @@ async def mock_request(method, path, **kwargs): logger = logging.getLogger(__name__) api.config = Mock( - bridgeid="ff:ff:ff:ff:ff:ff", - mac="aa:bb:cc:dd:ee:ff", - modelid="BSB002", + bridge_id="ff:ff:ff:ff:ff:ff", + mac_address="aa:bb:cc:dd:ee:ff", + model_id="BSB002", apiversion="9.9.9", - swversion="1935144040", + software_version="1935144040", ) api.config.name = "Home" - api.lights = Lights(logger, {}, [], mock_request) - api.groups = Groups(logger, {}, [], mock_request) - api.sensors = Sensors(logger, {}, [], mock_request) - api.scenes = Scenes(logger, {}, [], mock_request) + api.lights = aiohue_v1.Lights(logger, {}, mock_request) + api.groups = aiohue_v1.Groups(logger, {}, mock_request) + api.sensors = aiohue_v1.Sensors(logger, {}, mock_request) + api.scenes = aiohue_v1.Scenes(logger, {}, mock_request) return api +@pytest.fixture(scope="session") +def v2_resources_test_data(): + """Load V2 resources mock data.""" + return json.loads(load_fixture("hue/v2_resources.json")) + + +def create_mock_api_v2(hass): + """Create a mock V2 API.""" + api = Mock(spec=aiohue_v2.HueBridgeV2) + api.initialize = AsyncMock() + api.config = Mock( + bridge_id="aabbccddeeffggh", + mac_address="00:17:88:01:aa:bb:fd:c7", + model_id="BSB002", + api_version="9.9.9", + software_version="1935144040", + bridge_device=Mock( + id="4a507550-8742-4087-8bf5-c2334f29891c", + product_data=Mock(manufacturer_name="Mock"), + ), + spec=aiohue_v2.ConfigController, + ) + api.config.name = "Home" + api.mock_requests = [] + + api.logger = logging.getLogger(__name__) + api.events = aiohue_v2.EventStream(api) + api.devices = aiohue_v2.DevicesController(api) + api.lights = aiohue_v2.LightsController(api) + api.sensors = aiohue_v2.SensorsController(api) + api.groups = aiohue_v2.GroupsController(api) + api.scenes = aiohue_v2.ScenesController(api) + + async def mock_request(method, path, **kwargs): + kwargs["method"] = method + kwargs["path"] = path + api.mock_requests.append(kwargs) + return kwargs.get("json") + + api.request = mock_request + + async def load_test_data(data): + """Load test data into controllers.""" + api.config = aiohue_v2.ConfigController(api) + + await asyncio.gather( + api.config.initialize(data), + api.devices.initialize(data), + api.lights.initialize(data), + api.scenes.initialize(data), + api.sensors.initialize(data), + api.groups.initialize(data), + ) + + def emit_event(event_type, data): + """Emit an event from a (hue resource) dict.""" + api.events.emit(EventType(event_type), parse_clip_resource(data)) + + api.load_test_data = load_test_data + api.emit_event = emit_event + # mock context manager too + api.__aenter__ = AsyncMock(return_value=api) + api.__aexit__ = AsyncMock() + return api + + +@pytest.fixture +def mock_bridge_v1(hass): + """Mock a Hue bridge with V1 api.""" + return create_mock_bridge(hass, api_version=1) + + @pytest.fixture -def mock_bridge(hass): - """Mock a Hue bridge.""" - return create_mock_bridge(hass) +def mock_bridge_v2(hass): + """Mock a Hue bridge with V2 api.""" + return create_mock_bridge(hass, api_version=2) -async def setup_bridge_for_sensors(hass, mock_bridge, hostname=None): - """Load the Hue platform with the provided bridge for sensor-related platforms.""" +@pytest.fixture +def mock_config_entry_v1(hass): + """Mock a config entry for a Hue V1 bridge.""" + return create_config_entry(api_version=1) + + +@pytest.fixture +def mock_config_entry_v2(hass): + """Mock a config entry.""" + return create_config_entry(api_version=2) + + +def create_config_entry(api_version=1, host="mock-host"): + """Mock a config entry for a Hue bridge.""" + return MockConfigEntry( + domain=hue.DOMAIN, + title=f"Mock bridge {api_version}", + data={"host": host, "api_version": api_version, "api_key": ""}, + ) + + +async def setup_component(hass): + """Mock setup Hue component.""" + with patch.object(hue, "async_setup_entry", return_value=True): + assert ( + await async_setup_component( + hass, + hue.DOMAIN, + {}, + ) + is True + ) + + +async def setup_bridge(hass, mock_bridge, config_entry): + """Load the Hue integration with the provided bridge.""" + mock_bridge.config_entry = config_entry + config_entry.add_to_hass(hass) + with patch("homeassistant.components.hue.HueBridge", return_value=mock_bridge): + await hass.config_entries.async_setup(config_entry.entry_id) + + +async def setup_platform( + hass, + mock_bridge, + platforms, + hostname=None, +): + """Load the Hue integration with the provided bridge for given platform(s).""" + if not isinstance(platforms, (list, tuple)): + platforms = [platforms] if hostname is None: hostname = "mock-host" hass.config.components.add(hue.DOMAIN) - config_entry = MockConfigEntry( - domain=hue.DOMAIN, - title="Mock Title", - data={"host": hostname}, + config_entry = create_config_entry( + api_version=mock_bridge.api_version, host=hostname ) mock_bridge.config_entry = config_entry hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge} - await hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor") - await hass.config_entries.async_forward_entry_setup(config_entry, "sensor") + # simulate a full setup by manually adding the bridge config entry - config_entry.add_to_hass(hass) + await setup_bridge(hass, mock_bridge, config_entry) + assert await async_setup_component(hass, hue.DOMAIN, {}) is True + await hass.async_block_till_done() + + for platform in platforms: + await hass.config_entries.async_forward_entry_setup(config_entry, platform) # and make sure it completes before going further await hass.async_block_till_done() + + +@pytest.fixture +def mock_bridge_setup(): + """Mock bridge setup.""" + with patch.object(hue, "HueBridge") as mock_bridge: + mock_bridge.return_value.async_initialize_bridge = AsyncMock(return_value=True) + mock_bridge.return_value.api_version = 1 + mock_bridge.return_value.api.config = Mock( + bridge_id="mock-id", + mac_address="00:00:00:00:00:00", + software_version="1.0.0", + model_id="BSB002", + ) + mock_bridge.return_value.api.config.name = "Mock Hue bridge" + yield mock_bridge.return_value + + +@pytest.fixture(name="device_reg") +def get_device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture(name="calls") +def track_calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") diff --git a/tests/components/hue/const.py b/tests/components/hue/const.py new file mode 100644 index 0000000000000..84b342c73be32 --- /dev/null +++ b/tests/components/hue/const.py @@ -0,0 +1,97 @@ +"""Constants for Hue tests.""" + + +FAKE_DEVICE = { + "id": "fake_device_id_1", + "id_v1": "/lights/1", + "metadata": {"archetype": "unknown_archetype", "name": "Hue mocked device"}, + "product_data": { + "certified": True, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "abcdefg", + "product_archetype": "unknown_archetype", + "product_name": "Hue Mocked on/off light with a sensor", + "software_version": "1.88.1", + }, + "services": [ + {"rid": "fake_light_id_1", "rtype": "light"}, + {"rid": "fake_zigbee_connectivity_id_1", "rtype": "zigbee_connectivity"}, + {"rid": "fake_temperature_sensor_id_1", "rtype": "temperature"}, + {"rid": "fake_motion_sensor_id_1", "rtype": "motion"}, + ], + "type": "device", +} + +FAKE_LIGHT = { + "alert": {"action_values": ["breathe"]}, + "dynamics": { + "speed": 0.0, + "speed_valid": False, + "status": "none", + "status_values": ["none"], + }, + "id": "fake_light_id_1", + "id_v1": "/lights/1", + "metadata": {"archetype": "unknown", "name": "Hue fake light 1"}, + "mode": "normal", + "on": {"on": False}, + "owner": {"rid": "fake_device_id_1", "rtype": "device"}, + "type": "light", +} + +FAKE_ZIGBEE_CONNECTIVITY = { + "id": "fake_zigbee_connectivity_id_1", + "id_v1": "/lights/29", + "mac_address": "00:01:02:03:04:05:06:07", + "owner": {"rid": "fake_device_id_1", "rtype": "device"}, + "status": "connected", + "type": "zigbee_connectivity", +} + +FAKE_SENSOR = { + "enabled": True, + "id": "fake_temperature_sensor_id_1", + "id_v1": "/sensors/1", + "owner": {"rid": "fake_device_id_1", "rtype": "device"}, + "temperature": {"temperature": 18.0, "temperature_valid": True}, + "type": "temperature", +} + +FAKE_BINARY_SENSOR = { + "enabled": True, + "id": "fake_motion_sensor_id_1", + "id_v1": "/sensors/2", + "motion": {"motion": False, "motion_valid": True}, + "owner": {"rid": "fake_device_id_1", "rtype": "device"}, + "type": "motion", +} + +FAKE_SCENE = { + "actions": [ + { + "action": { + "color_temperature": {"mirek": 156}, + "dimming": {"brightness": 65.0}, + "on": {"on": True}, + }, + "target": {"rid": "3a6710fa-4474-4eba-b533-5e6e72968feb", "rtype": "light"}, + }, + { + "action": {"on": {"on": True}}, + "target": {"rid": "7697ac8a-25aa-4576-bb40-0036c0db15b9", "rtype": "light"}, + }, + ], + "group": {"rid": "6ddc9066-7e7d-4a03-a773-c73937968296", "rtype": "room"}, + "id": "fake_scene_id_1", + "id_v1": "/scenes/test", + "metadata": { + "image": { + "rid": "7fd2ccc5-5749-4142-b7a5-66405a676f03", + "rtype": "public_image", + }, + "name": "Mocked Scene", + }, + "palette": {"color": [], "color_temperature": [], "dimming": []}, + "speed": 0.5, + "type": "scene", +} diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json new file mode 100644 index 0000000000000..806dcecfacfc2 --- /dev/null +++ b/tests/components/hue/fixtures/v2_resources.json @@ -0,0 +1,2107 @@ +[ + { + "id": "9c489c26-9e34-4fcd-8324-a57e3a664cc0", + "status": "unpaired", + "status_values": ["pairing", "paired", "unpaired"], + "type": "homekit" + }, + { + "actions": [ + { + "action": { + "color": { + "xy": { + "x": 0.5058, + "y": 0.4477 + } + }, + "dimming": { + "brightness": 46.85 + }, + "on": { + "on": true + } + }, + "target": { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + } + }, + { + "action": { + "dimming": { + "brightness": 46.85 + }, + "gradient": { + "points": [ + { + "color": { + "xy": { + "x": 0.4808, + "y": 0.4485 + } + } + }, + { + "color": { + "xy": { + "x": 0.4958, + "y": 0.443 + } + } + }, + { + "color": { + "xy": { + "x": 0.5058, + "y": 0.4477 + } + } + }, + { + "color": { + "xy": { + "x": 0.5586, + "y": 0.4081 + } + } + }, + { + "color": { + "xy": { + "x": 0.569, + "y": 0.4003 + } + } + } + ] + }, + "on": { + "on": true + } + }, + "target": { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + } + }, + { + "action": { + "color": { + "xy": { + "x": 0.5586, + "y": 0.4081 + } + }, + "dimming": { + "brightness": 46.85 + }, + "on": { + "on": true + } + }, + "target": { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + } + } + ], + "group": { + "rid": "7cee478d-6455-483a-9e32-9f9fdcbcc4f6", + "rtype": "zone" + }, + "id": "fce5eabb-2f51-461b-b112-5362da301236", + "id_v1": "/scenes/qYDehk7EfGoRvkj", + "metadata": { + "image": { + "rid": "93984a4f-2d1b-4554-b972-b60fa8e476c5", + "rtype": "public_image" + }, + "name": "Dynamic Test Scene" + }, + "palette": { + "color": [ + { + "color": { + "xy": { + "x": 0.4808, + "y": 0.4485 + } + }, + "dimming": { + "brightness": 74.02 + } + }, + { + "color": { + "xy": { + "x": 0.5023, + "y": 0.4467 + } + }, + "dimming": { + "brightness": 100.0 + } + }, + { + "color": { + "xy": { + "x": 0.5615, + "y": 0.4059 + } + }, + "dimming": { + "brightness": 100.0 + } + } + ], + "color_temperature": [ + { + "color_temperature": { + "mirek": 451 + }, + "dimming": { + "brightness": 31.1 + } + } + ], + "dimming": [] + }, + "speed": 0.6269841194152832, + "type": "scene" + }, + { + "actions": [ + { + "action": { + "color_temperature": { + "mirek": 156 + }, + "dimming": { + "brightness": 100.0 + }, + "on": { + "on": true + } + }, + "target": { + "rid": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "rtype": "light" + } + }, + { + "action": { + "on": { + "on": true + } + }, + "target": { + "rid": "7697ac8a-25aa-4576-bb40-0036c0db15b9", + "rtype": "light" + } + } + ], + "group": { + "rid": "6ddc9066-7e7d-4a03-a773-c73937968296", + "rtype": "room" + }, + "id": "cdbf3740-7977-4a11-8275-8c78636ad4bd", + "id_v1": "/scenes/LwgmWgRnaRUxg6K", + "metadata": { + "image": { + "rid": "7fd2ccc5-5749-4142-b7a5-66405a676f03", + "rtype": "public_image" + }, + "name": "Regular Test Scene" + }, + "palette": { + "color": [], + "color_temperature": [], + "dimming": [] + }, + "speed": 0.5, + "type": "scene" + }, + { + "id": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "id_v1": "/sensors/50", + "metadata": { + "archetype": "unknown_archetype", + "name": "Wall switch with 2 controls" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "RDM001", + "product_archetype": "unknown_archetype", + "product_name": "Hue wall switch module", + "software_version": "1.0.3" + }, + "services": [ + { + "rid": "c658d3d8-a013-4b81-8ac6-78b248537e70", + "rtype": "button" + }, + { + "rid": "be1eb834-bdf5-4d26-8fba-7b1feaa83a9d", + "rtype": "button" + }, + { + "rid": "c1cd98a6-6c23-43bb-b6e1-08dda9e168a4", + "rtype": "device_power" + }, + { + "rid": "af520f40-e080-43b0-9bb5-41a4d5251b2b", + "rtype": "zigbee_connectivity" + } + ], + "type": "device" + }, + { + "id": "0b216218-d811-4c95-8c55-bbcda50f9d50", + "id_v1": "/lights/29", + "metadata": { + "archetype": "floor_shade", + "name": "Hue light with color and color temperature 1" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "4080248P9", + "product_archetype": "floor_shade", + "product_name": "Hue color floor", + "software_version": "1.88.1" + }, + "services": [ + { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + }, + { + "rid": "1987ba66-c21d-48d0-98fb-121d939a71f3", + "rtype": "zigbee_connectivity" + }, + { + "rid": "5d7b3979-b936-47ff-8458-554f8a2921db", + "rtype": "entertainment" + } + ], + "type": "device" + }, + { + "id": "60b849cc-a8b5-4034-8881-ed1cd560fd13", + "id_v1": "/lights/4", + "metadata": { + "archetype": "ceiling_round", + "name": "Hue light with color temperature only" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "LTC001", + "product_archetype": "ceiling_round", + "product_name": "Hue ambiance ceiling", + "software_version": "1.88.1" + }, + "services": [ + { + "rid": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "rtype": "light" + }, + { + "rid": "bd878f44-feb7-406e-8af9-6a1796d1ddc9", + "rtype": "zigbee_connectivity" + } + ], + "type": "device" + }, + { + "id": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "id_v1": "/sensors/10", + "metadata": { + "archetype": "unknown_archetype", + "name": "Hue Dimmer switch with 4 controls" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "RWL021", + "product_archetype": "unknown_archetype", + "product_name": "Hue dimmer switch", + "software_version": "1.1.28573" + }, + "services": [ + { + "rid": "f92aa267-1387-4f02-9950-210fb7ca1f5a", + "rtype": "button" + }, + { + "rid": "7f1ab9f6-cc2b-4b40-9011-65e2af153f75", + "rtype": "button" + }, + { + "rid": "b4edb2d6-55d0-47f4-bd43-7ae215ef1062", + "rtype": "button" + }, + { + "rid": "40a810bf-3d22-4c56-9334-4a59a00768ab", + "rtype": "button" + }, + { + "rid": "0bb058bc-2139-43d9-8c9b-edfb4570953b", + "rtype": "device_power" + }, + { + "rid": "db50a5d9-8cc7-486f-be06-c0b8f0d26c69", + "rtype": "zigbee_connectivity" + } + ], + "type": "device" + }, + { + "id": "b9e76da7-ac22-476a-986d-e466e62e962f", + "id_v1": "/lights/16", + "metadata": { + "archetype": "hue_lightstrip", + "name": "Hue light with color and color temperature 2" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "LST002", + "product_archetype": "hue_lightstrip", + "product_name": "Hue lightstrip plus", + "software_version": "67.88.1" + }, + "services": [ + { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + }, + { + "rid": "717afeb6-b1ce-426e-96de-48e8fe037fb0", + "rtype": "zigbee_connectivity" + }, + { + "rid": "d88acc42-259c-43b5-bf5d-90c16cdb8f2f", + "rtype": "entertainment" + } + ], + "type": "device" + }, + { + "id": "fcdfab5d-8e04-4e9c-a999-7f92cb38c4fc", + "id_v1": "/lights/23", + "metadata": { + "archetype": "classic_bulb", + "name": "Hue on/off light" + }, + "product_data": { + "certified": false, + "manufacturer_name": "eWeLink", + "model_id": "SA-003-Zigbee", + "product_archetype": "classic_bulb", + "product_name": "On/Off light", + "software_version": "1.0.2" + }, + "services": [ + { + "rid": "7697ac8a-25aa-4576-bb40-0036c0db15b9", + "rtype": "light" + }, + { + "rid": "6b00ce2b-a8a5-4bab-bc5e-757a0b0338ff", + "rtype": "zigbee_connectivity" + } + ], + "type": "device" + }, + { + "id": "7745ebea-dd33-429c-a900-bae4e7ae1107", + "id_v1": "/sensors/5", + "metadata": { + "archetype": "unknown_archetype", + "name": "Hue Smart button 1 control" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "ROM001", + "product_archetype": "unknown_archetype", + "product_name": "Hue Smart button", + "software_version": "2.47.8" + }, + "services": [ + { + "rid": "31cffcda-efc2-401f-a152-e10db3eed232", + "rtype": "button" + }, + { + "rid": "3f219f5a-ad6c-484f-b976-769a9c267a72", + "rtype": "device_power" + }, + { + "rid": "bba44861-8222-45c9-9e6b-d7f3a6543829", + "rtype": "zigbee_connectivity" + } + ], + "type": "device" + }, + { + "id": "4a507550-8742-4087-8bf5-c2334f29891c", + "id_v1": "", + "metadata": { + "archetype": "bridge_v2", + "name": "Philips hue" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "BSB002", + "product_archetype": "bridge_v2", + "product_name": "Philips hue", + "software_version": "1.48.1948086000" + }, + "services": [ + { + "rid": "07dd5849-abcd-efgh-b9b9-eb540408ce00", + "rtype": "bridge" + }, + { + "rid": "6c898412-ed25-4402-9807-a0c326616b0f", + "rtype": "zigbee_connectivity" + }, + { + "rid": "b8ab0c30-b227-4d35-9c96-7cd16131fcc5", + "rtype": "entertainment" + } + ], + "type": "device" + }, + { + "id": "8d07d39c-3c19-47ce-ac7a-8bf3d8e849b9", + "id_v1": "/lights/11", + "metadata": { + "archetype": "hue_bloom", + "name": "Hue light with color only" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "LLC011", + "product_archetype": "hue_bloom", + "product_name": "Hue bloom", + "software_version": "67.91.1" + }, + "services": [ + { + "rid": "74a45fee-1b3d-4553-b5ab-040da8a10cfd", + "rtype": "light" + }, + { + "rid": "98baae94-76d9-4bc4-a1d1-d53f1d7b1286", + "rtype": "zigbee_connectivity" + }, + { + "rid": "8e6a4ff3-14ca-42f9-8358-9d691b9a4524", + "rtype": "entertainment" + } + ], + "type": "device" + }, + { + "id": "1c8be0d5-a68b-45c2-8f56-530d13b0c128", + "id_v1": "/lights/24", + "metadata": { + "archetype": "hue_lightstrip_tv", + "name": "Hue light with color and color temperature gradient" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "LCX003", + "product_archetype": "hue_lightstrip_tv", + "product_name": "Hue play gradient lightstrip", + "software_version": "1.86.7" + }, + "services": [ + { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + }, + { + "rid": "ff4e6545-341f-4b0d-9869-b6feb6e6fe87", + "rtype": "zigbee_connectivity" + }, + { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + ], + "type": "device" + }, + { + "id": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "id_v1": "/sensors/66", + "metadata": { + "archetype": "unknown_archetype", + "name": "Hue motion sensor" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "SML001", + "product_archetype": "unknown_archetype", + "product_name": "Hue motion sensor", + "software_version": "1.1.27575" + }, + "services": [ + { + "rid": "b6896534-016d-4052-8cb4-ef04454df62c", + "rtype": "motion" + }, + { + "rid": "669f609d-4860-4f1c-bc25-7a9cec1c3b6c", + "rtype": "device_power" + }, + { + "rid": "ec9b5ad7-2471-4356-b757-d00537828963", + "rtype": "zigbee_connectivity" + }, + { + "rid": "d504e7a4-9a18-4854-90fd-c5b6ac102c40", + "rtype": "light_level" + }, + { + "rid": "66466e14-d2fa-4b96-b2a0-e10de9cd8b8b", + "rtype": "temperature" + } + ], + "type": "device" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "color": { + "gamut": { + "blue": { + "x": 0.1532, + "y": 0.0475 + }, + "green": { + "x": 0.17, + "y": 0.7 + }, + "red": { + "x": 0.6915, + "y": 0.3083 + } + }, + "gamut_type": "C", + "xy": { + "x": 0.5614, + "y": 0.4058 + } + }, + "color_temperature": { + "mirek": null, + "mirek_schema": { + "mirek_maximum": 500, + "mirek_minimum": 153 + }, + "mirek_valid": false + }, + "dimming": { + "brightness": 46.85, + "min_dim_level": 0.10000000149011612 + }, + "dynamics": { + "speed": 0.627, + "speed_valid": true, + "status": "dynamic_palette", + "status_values": ["none", "dynamic_palette"] + }, + "id": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "id_v1": "/lights/29", + "metadata": { + "archetype": "floor_shade", + "name": "Hue light with color and color temperature 1" + }, + "mode": "normal", + "on": { + "on": true + }, + "owner": { + "rid": "0b216218-d811-4c95-8c55-bbcda50f9d50", + "rtype": "device" + }, + "type": "light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "color_temperature": { + "mirek": 369, + "mirek_schema": { + "mirek_maximum": 454, + "mirek_minimum": 153 + }, + "mirek_valid": true + }, + "dimming": { + "brightness": 59.45, + "min_dim_level": 0.10000000149011612 + }, + "dynamics": { + "speed": 0.0, + "speed_valid": false, + "status": "none", + "status_values": ["none"] + }, + "id": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "id_v1": "/lights/4", + "metadata": { + "archetype": "ceiling_round", + "name": "Hue light with color temperature only" + }, + "mode": "normal", + "on": { + "on": false + }, + "owner": { + "rid": "60b849cc-a8b5-4034-8881-ed1cd560fd13", + "rtype": "device" + }, + "type": "light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "color": { + "gamut": { + "blue": { + "x": 0.1532, + "y": 0.0475 + }, + "green": { + "x": 0.17, + "y": 0.7 + }, + "red": { + "x": 0.6915, + "y": 0.3083 + } + }, + "gamut_type": "C", + "xy": { + "x": 0.5022, + "y": 0.4466 + } + }, + "color_temperature": { + "mirek": null, + "mirek_schema": { + "mirek_maximum": 500, + "mirek_minimum": 153 + }, + "mirek_valid": false + }, + "dimming": { + "brightness": 46.85, + "min_dim_level": 0.02500000037252903 + }, + "dynamics": { + "speed": 0.627, + "speed_valid": true, + "status": "dynamic_palette", + "status_values": ["none", "dynamic_palette"] + }, + "id": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "id_v1": "/lights/16", + "metadata": { + "archetype": "hue_lightstrip", + "name": "Hue light with color and color temperature 2" + }, + "mode": "normal", + "on": { + "on": true + }, + "owner": { + "rid": "b9e76da7-ac22-476a-986d-e466e62e962f", + "rtype": "device" + }, + "type": "light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "dynamics": { + "speed": 0.0, + "speed_valid": false, + "status": "none", + "status_values": ["none"] + }, + "id": "7697ac8a-25aa-4576-bb40-0036c0db15b9", + "id_v1": "/lights/23", + "metadata": { + "archetype": "classic_bulb", + "name": "Hue on/off light" + }, + "mode": "normal", + "on": { + "on": false + }, + "owner": { + "rid": "fcdfab5d-8e04-4e9c-a999-7f92cb38c4fc", + "rtype": "device" + }, + "type": "light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "color": { + "gamut": { + "blue": { + "x": 0.138, + "y": 0.08 + }, + "green": { + "x": 0.2151, + "y": 0.7106 + }, + "red": { + "x": 0.704, + "y": 0.296 + } + }, + "gamut_type": "A", + "xy": { + "x": 0.4849, + "y": 0.3895 + } + }, + "dimming": { + "brightness": 50.0, + "min_dim_level": 10.0 + }, + "dynamics": { + "speed": 0.6389, + "speed_valid": true, + "status": "dynamic_palette", + "status_values": ["none", "dynamic_palette"] + }, + "id": "74a45fee-1b3d-4553-b5ab-040da8a10cfd", + "id_v1": "/lights/11", + "metadata": { + "archetype": "hue_bloom", + "name": "Hue light with color only" + }, + "mode": "normal", + "on": { + "on": true + }, + "owner": { + "rid": "8d07d39c-3c19-47ce-ac7a-8bf3d8e849b9", + "rtype": "device" + }, + "type": "light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "color": { + "gamut": { + "blue": { + "x": 0.1532, + "y": 0.0475 + }, + "green": { + "x": 0.17, + "y": 0.7 + }, + "red": { + "x": 0.6915, + "y": 0.3083 + } + }, + "gamut_type": "C", + "xy": { + "x": 0.5022, + "y": 0.4466 + } + }, + "color_temperature": { + "mirek": null, + "mirek_schema": { + "mirek_maximum": 500, + "mirek_minimum": 153 + }, + "mirek_valid": false + }, + "dimming": { + "brightness": 46.85, + "min_dim_level": 0.10000000149011612 + }, + "dynamics": { + "speed": 0.627, + "speed_valid": true, + "status": "dynamic_palette", + "status_values": ["none", "dynamic_palette"] + }, + "gradient": { + "points": [ + { + "color": { + "xy": { + "x": 0.5022, + "y": 0.4466 + } + } + }, + { + "color": { + "xy": { + "x": 0.4806, + "y": 0.4484 + } + } + }, + { + "color": { + "xy": { + "x": 0.5022, + "y": 0.4466 + } + } + }, + { + "color": { + "xy": { + "x": 0.5614, + "y": 0.4058 + } + } + }, + { + "color": { + "xy": { + "x": 0.5022, + "y": 0.4466 + } + } + } + ], + "points_capable": 5 + }, + "id": "8015b17f-8336-415b-966a-b364bd082397", + "id_v1": "/lights/24", + "metadata": { + "archetype": "hue_lightstrip_tv", + "name": "Hue light with color and color temperature gradient" + }, + "mode": "normal", + "on": { + "on": true + }, + "owner": { + "rid": "1c8be0d5-a68b-45c2-8f56-530d13b0c128", + "rtype": "device" + }, + "type": "light" + }, + { + "id": "af520f40-e080-43b0-9bb5-41a4d5251b2b", + "id_v1": "/sensors/50", + "mac_address": "00:17:88:01:0b:aa:bb:99", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "1987ba66-c21d-48d0-98fb-121d939a71f3", + "id_v1": "/lights/29", + "mac_address": "00:17:88:01:09:aa:bb:65", + "owner": { + "rid": "0b216218-d811-4c95-8c55-bbcda50f9d50", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "bd878f44-feb7-406e-8af9-6a1796d1ddc9", + "id_v1": "/lights/4", + "mac_address": "00:17:88:01:06:aa:bb:58", + "owner": { + "rid": "60b849cc-a8b5-4034-8881-ed1cd560fd13", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "db50a5d9-8cc7-486f-be06-c0b8f0d26c69", + "id_v1": "/sensors/10", + "mac_address": "00:17:88:01:08:aa:cc:60", + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "717afeb6-b1ce-426e-96de-48e8fe037fb0", + "id_v1": "/lights/16", + "mac_address": "00:17:88:aa:aa:bb:0d:ab", + "owner": { + "rid": "b9e76da7-ac22-476a-986d-e466e62e962f", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "6b00ce2b-a8a5-4bab-bc5e-757a0b0338ff", + "id_v1": "/lights/23", + "mac_address": "00:12:4b:00:1f:aa:bb:f3", + "owner": { + "rid": "fcdfab5d-8e04-4e9c-a999-7f92cb38c4fc", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "bba44861-8222-45c9-9e6b-d7f3a6543829", + "id_v1": "/sensors/5", + "mac_address": "00:17:88:01:aa:cc:87:b6", + "owner": { + "rid": "7745ebea-dd33-429c-a900-bae4e7ae1107", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "6c898412-ed25-4402-9807-a0c326616b0f", + "id_v1": "", + "mac_address": "00:17:88:01:aa:bb:fd:c7", + "owner": { + "rid": "4a507550-8742-4087-8bf5-c2334f29891c", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "d2ae969a-add5-41b1-afbd-f2837b2eb551", + "id_v1": "/lights/34", + "mac_address": "00:17:88:01:aa:bb:cc:ed", + "owner": { + "rid": "5ad8326c-e51a-4594-8738-fc700b53fcc4", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "98baae94-76d9-4bc4-a1d1-d53f1d7b1286", + "id_v1": "/lights/11", + "mac_address": "00:17:88:aa:bb:1e:cc:b2", + "owner": { + "rid": "8d07d39c-3c19-47ce-ac7a-8bf3d8e849b9", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "ff4e6545-341f-4b0d-9869-b6feb6e6fe87", + "id_v1": "/lights/24", + "mac_address": "00:17:88:01:aa:bb:cc:3d", + "owner": { + "rid": "1c8be0d5-a68b-45c2-8f56-530d13b0c128", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "ec9b5ad7-2471-4356-b757-d00537828963", + "id_v1": "/sensors/66", + "mac_address": "00:17:aa:bb:cc:09:ac:c3", + "owner": { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "5d7b3979-b936-47ff-8458-554f8a2921db", + "id_v1": "/lights/29", + "owner": { + "rid": "0b216218-d811-4c95-8c55-bbcda50f9d50", + "rtype": "device" + }, + "proxy": true, + "renderer": true, + "segments": { + "configurable": false, + "max_segments": 1, + "segments": [ + { + "length": 1, + "start": 0 + } + ] + }, + "type": "entertainment" + }, + { + "id": "d88acc42-259c-43b5-bf5d-90c16cdb8f2f", + "id_v1": "/lights/16", + "owner": { + "rid": "b9e76da7-ac22-476a-986d-e466e62e962f", + "rtype": "device" + }, + "proxy": true, + "renderer": true, + "segments": { + "configurable": false, + "max_segments": 1, + "segments": [ + { + "length": 1, + "start": 0 + } + ] + }, + "type": "entertainment" + }, + { + "id": "b8ab0c30-b227-4d35-9c96-7cd16131fcc5", + "id_v1": "", + "owner": { + "rid": "4a507550-8742-4087-8bf5-c2334f29891c", + "rtype": "device" + }, + "proxy": true, + "renderer": false, + "type": "entertainment" + }, + { + "id": "8e6a4ff3-14ca-42f9-8358-9d691b9a4524", + "id_v1": "/lights/11", + "owner": { + "rid": "8d07d39c-3c19-47ce-ac7a-8bf3d8e849b9", + "rtype": "device" + }, + "proxy": false, + "renderer": true, + "segments": { + "configurable": false, + "max_segments": 1, + "segments": [ + { + "length": 1, + "start": 0 + } + ] + }, + "type": "entertainment" + }, + { + "id": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "id_v1": "/lights/24", + "owner": { + "rid": "1c8be0d5-a68b-45c2-8f56-530d13b0c128", + "rtype": "device" + }, + "proxy": true, + "renderer": true, + "segments": { + "configurable": false, + "max_segments": 10, + "segments": [ + { + "length": 2, + "start": 0 + }, + { + "length": 3, + "start": 2 + }, + { + "length": 5, + "start": 5 + }, + { + "length": 4, + "start": 10 + }, + { + "length": 5, + "start": 14 + }, + { + "length": 3, + "start": 19 + }, + { + "length": 2, + "start": 22 + } + ] + }, + "type": "entertainment" + }, + { + "button": { + "last_event": "short_release" + }, + "id": "c658d3d8-a013-4b81-8ac6-78b248537e70", + "id_v1": "/sensors/50", + "metadata": { + "control_id": 1 + }, + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "type": "button" + }, + { + "id": "be1eb834-bdf5-4d26-8fba-7b1feaa83a9d", + "id_v1": "/sensors/50", + "metadata": { + "control_id": 2 + }, + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "type": "button" + }, + { + "id": "f92aa267-1387-4f02-9950-210fb7ca1f5a", + "id_v1": "/sensors/10", + "metadata": { + "control_id": 1 + }, + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "type": "button" + }, + { + "button": { + "last_event": "short_release" + }, + "id": "7f1ab9f6-cc2b-4b40-9011-65e2af153f75", + "id_v1": "/sensors/10", + "metadata": { + "control_id": 2 + }, + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "type": "button" + }, + { + "id": "b4edb2d6-55d0-47f4-bd43-7ae215ef1062", + "id_v1": "/sensors/10", + "metadata": { + "control_id": 3 + }, + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "type": "button" + }, + { + "id": "40a810bf-3d22-4c56-9334-4a59a00768ab", + "id_v1": "/sensors/10", + "metadata": { + "control_id": 4 + }, + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "type": "button" + }, + { + "button": { + "last_event": "short_release" + }, + "id": "31cffcda-efc2-401f-a152-e10db3eed232", + "id_v1": "/sensors/5", + "metadata": { + "control_id": 1 + }, + "owner": { + "rid": "7745ebea-dd33-429c-a900-bae4e7ae1107", + "rtype": "device" + }, + "type": "button" + }, + { + "id": "c1cd98a6-6c23-43bb-b6e1-08dda9e168a4", + "id_v1": "/sensors/50", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "power_state": { + "battery_level": 100, + "battery_state": "normal" + }, + "type": "device_power" + }, + { + "id": "0bb058bc-2139-43d9-8c9b-edfb4570953b", + "id_v1": "/sensors/10", + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "power_state": { + "battery_level": 83, + "battery_state": "normal" + }, + "type": "device_power" + }, + { + "id": "3f219f5a-ad6c-484f-b976-769a9c267a72", + "id_v1": "/sensors/5", + "owner": { + "rid": "7745ebea-dd33-429c-a900-bae4e7ae1107", + "rtype": "device" + }, + "power_state": { + "battery_level": 91, + "battery_state": "normal" + }, + "type": "device_power" + }, + { + "id": "669f609d-4860-4f1c-bc25-7a9cec1c3b6c", + "id_v1": "/sensors/66", + "owner": { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + "power_state": { + "battery_level": 100, + "battery_state": "normal" + }, + "type": "device_power" + }, + { + "children": [ + { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + }, + { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + }, + { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + } + ], + "grouped_services": [ + { + "rid": "f2416154-9607-43ab-a684-4453108a200e", + "rtype": "grouped_light" + } + ], + "id": "7cee478d-6455-483a-9e32-9f9fdcbcc4f6", + "id_v1": "/groups/5", + "metadata": { + "archetype": "downstairs", + "name": "Test Zone" + }, + "services": [ + { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + }, + { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + }, + { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + }, + { + "rid": "f2416154-9607-43ab-a684-4453108a200e", + "rtype": "grouped_light" + } + ], + "type": "zone" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "id": "f2416154-9607-43ab-a684-4453108a200e", + "id_v1": "/groups/5", + "on": { + "on": true + }, + "type": "grouped_light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "id": "0a74457c-cb8d-44c2-a5a5-dcb7b3675550", + "id_v1": "/groups/0", + "on": { + "on": true + }, + "type": "grouped_light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "id": "e937f8db-2f0e-49a0-936e-027e60e15b34", + "id_v1": "/groups/3", + "on": { + "on": false + }, + "type": "grouped_light" + }, + { + "children": [ + { + "rid": "6ddc9066-7e7d-4a03-a773-c73937968296", + "rtype": "room" + }, + { + "rid": "0b216218-d811-4c95-8c55-bbcda50f9d50", + "rtype": "device" + }, + { + "rid": "b9e76da7-ac22-476a-986d-e466e62e962f", + "rtype": "device" + }, + { + "rid": "5ad8326c-e51a-4594-8738-fc700b53fcc4", + "rtype": "device" + }, + { + "rid": "8d07d39c-3c19-47ce-ac7a-8bf3d8e849b9", + "rtype": "device" + }, + { + "rid": "1c8be0d5-a68b-45c2-8f56-530d13b0c128", + "rtype": "device" + }, + { + "rid": "7745ebea-dd33-429c-a900-bae4e7ae1107", + "rtype": "device" + }, + { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + } + ], + "grouped_services": [ + { + "rid": "0a74457c-cb8d-44c2-a5a5-dcb7b3675550", + "rtype": "grouped_light" + } + ], + "id": "a3fbc86a-bf4c-4c69-899d-d6eafc37e288", + "id_v1": "/groups/0", + "services": [ + { + "rid": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "rtype": "light" + }, + { + "rid": "d0df7249-02c1-4480-ba2c-d61b1e648a58", + "rtype": "light" + }, + { + "rid": "74a45fee-1b3d-4553-b5ab-040da8a10cfd", + "rtype": "light" + }, + { + "rid": "d2d48fac-df99-4f8d-8bdc-bac82d2cfb24", + "rtype": "light" + }, + { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + }, + { + "rid": "1d1ac857-9b89-48aa-a4f3-68302e7d0998", + "rtype": "light" + }, + { + "rid": "7697ac8a-25aa-4576-bb40-0036c0db15b9", + "rtype": "light" + }, + { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + }, + { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + }, + { + "rid": "6a5d8ce8-c0a0-43bb-870e-d7e641cdb063", + "rtype": "button" + }, + { + "rid": "85fa4928-b061-4d19-8458-c5e30d375e39", + "rtype": "button" + }, + { + "rid": "a0640313-0a01-42b9-b236-c5e0a1568ef5", + "rtype": "button" + }, + { + "rid": "50fe978e-117c-4fc5-bb17-f707c1614a11", + "rtype": "button" + }, + { + "rid": "31cffcda-efc2-401f-a152-e10db3eed232", + "rtype": "button" + }, + { + "rid": "f92aa267-1387-4f02-9950-210fb7ca1f5a", + "rtype": "button" + }, + { + "rid": "7f1ab9f6-cc2b-4b40-9011-65e2af153f75", + "rtype": "button" + }, + { + "rid": "b4edb2d6-55d0-47f4-bd43-7ae215ef1062", + "rtype": "button" + }, + { + "rid": "40a810bf-3d22-4c56-9334-4a59a00768ab", + "rtype": "button" + }, + { + "rid": "487aa265-8ea1-4280-a663-cbf93a79ccd7", + "rtype": "button" + }, + { + "rid": "f6e137cf-8e93-4f6a-be9c-2f820bf6d893", + "rtype": "button" + }, + { + "rid": "c658d3d8-a013-4b81-8ac6-78b248537e70", + "rtype": "button" + }, + { + "rid": "be1eb834-bdf5-4d26-8fba-7b1feaa83a9d", + "rtype": "button" + }, + { + "rid": "b6896534-016d-4052-8cb4-ef04454df62c", + "rtype": "motion" + }, + { + "rid": "d504e7a4-9a18-4854-90fd-c5b6ac102c40", + "rtype": "light_level" + }, + { + "rid": "66466e14-d2fa-4b96-b2a0-e10de9cd8b8b", + "rtype": "temperature" + }, + { + "rid": "0a74457c-cb8d-44c2-a5a5-dcb7b3675550", + "rtype": "grouped_light" + } + ], + "type": "bridge_home" + }, + { + "children": [ + { + "rid": "60b849cc-a8b5-4034-8881-ed1cd560fd13", + "rtype": "device" + }, + { + "rid": "fcdfab5d-8e04-4e9c-a999-7f92cb38c4fc", + "rtype": "device" + } + ], + "grouped_services": [ + { + "rid": "e937f8db-2f0e-49a0-936e-027e60e15b34", + "rtype": "grouped_light" + } + ], + "id": "6ddc9066-7e7d-4a03-a773-c73937968296", + "id_v1": "/groups/3", + "metadata": { + "archetype": "bathroom", + "name": "Test Room" + }, + "services": [ + { + "rid": "7697ac8a-25aa-4576-bb40-0036c0db15b9", + "rtype": "light" + }, + { + "rid": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "rtype": "light" + }, + { + "rid": "e937f8db-2f0e-49a0-936e-027e60e15b34", + "rtype": "grouped_light" + } + ], + "type": "room" + }, + { + "channels": [ + { + "channel_id": 0, + "members": [ + { + "index": 0, + "service": { + "rid": "5d7b3979-b936-47ff-8458-554f8a2921db", + "rtype": "entertainment" + } + } + ], + "position": { + "x": -0.8399999737739563, + "y": 0.8999999761581421, + "z": -0.5 + } + }, + { + "channel_id": 1, + "members": [ + { + "index": 0, + "service": { + "rid": "d88acc42-259c-43b5-bf5d-90c16cdb8f2f", + "rtype": "entertainment" + } + } + ], + "position": { + "x": -0.9399999976158142, + "y": -0.20999999344348907, + "z": -1.0 + } + }, + { + "channel_id": 2, + "members": [ + { + "index": 0, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": -0.4000000059604645, + "y": 0.800000011920929, + "z": -0.4000000059604645 + } + }, + { + "channel_id": 3, + "members": [ + { + "index": 1, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": -0.4000000059604645, + "y": 0.800000011920929, + "z": 0.0 + } + }, + { + "channel_id": 4, + "members": [ + { + "index": 2, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": -0.4000000059604645, + "y": 0.800000011920929, + "z": 0.4000000059604645 + } + }, + { + "channel_id": 5, + "members": [ + { + "index": 3, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": 0.0, + "y": 0.800000011920929, + "z": 0.4000000059604645 + } + }, + { + "channel_id": 6, + "members": [ + { + "index": 4, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": 0.4000000059604645, + "y": 0.800000011920929, + "z": 0.4000000059604645 + } + }, + { + "channel_id": 7, + "members": [ + { + "index": 5, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": 0.4000000059604645, + "y": 0.800000011920929, + "z": 0.0 + } + }, + { + "channel_id": 8, + "members": [ + { + "index": 6, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": 0.4000000059604645, + "y": 0.800000011920929, + "z": -0.4000000059604645 + } + }, + { + "channel_id": 9, + "members": [ + { + "index": 0, + "service": { + "rid": "be321947-0a48-4742-913d-073b3b540c97", + "rtype": "entertainment" + } + } + ], + "position": { + "x": 0.9100000262260437, + "y": 0.8100000023841858, + "z": -0.3799999952316284 + } + } + ], + "configuration_type": "screen", + "id": "c14cf1cf-6c7a-4984-b8bb-c5b71aeb70fc", + "id_v1": "/groups/2", + "light_services": [ + { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + }, + { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + }, + { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + } + ], + "locations": { + "service_locations": [ + { + "position": { + "x": -0.8399999737739563, + "y": 0.8999999761581421, + "z": -0.5 + }, + "positions": [ + { + "x": -0.8399999737739563, + "y": 0.8999999761581421, + "z": -0.5 + } + ], + "service": { + "rid": "5d7b3979-b936-47ff-8458-554f8a2921db", + "rtype": "entertainment" + } + }, + { + "position": { + "x": -0.9399999976158142, + "y": -0.20999999344348907, + "z": -1.0 + }, + "positions": [ + { + "x": -0.9399999976158142, + "y": -0.20999999344348907, + "z": -1.0 + } + ], + "service": { + "rid": "d88acc42-259c-43b5-bf5d-90c16cdb8f2f", + "rtype": "entertainment" + } + }, + { + "position": { + "x": -0.4000000059604645, + "y": 0.800000011920929, + "z": -0.4000000059604645 + }, + "positions": [ + { + "x": -0.4000000059604645, + "y": 0.800000011920929, + "z": -0.4000000059604645 + }, + { + "x": 0.4000000059604645, + "y": 0.800000011920929, + "z": -0.4000000059604645 + } + ], + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + }, + { + "position": { + "x": 0.9100000262260437, + "y": 0.8100000023841858, + "z": -0.3799999952316284 + }, + "positions": [ + { + "x": 0.9100000262260437, + "y": 0.8100000023841858, + "z": -0.3799999952316284 + } + ], + "service": { + "rid": "be321947-0a48-4742-913d-073b3b540c97", + "rtype": "entertainment" + } + } + ] + }, + "metadata": { + "name": "Entertainmentroom 1" + }, + "name": "Entertainmentroom 1", + "status": "inactive", + "stream_proxy": { + "mode": "auto", + "node": { + "rid": "b8ab0c30-b227-4d35-9c96-7cd16131fcc5", + "rtype": "entertainment" + } + }, + "type": "entertainment_configuration" + }, + { + "bridge_id": "aabbccddeeffggh", + "id": "07dd5849-abcd-efgh-b9b9-eb540408ce00", + "id_v1": "", + "owner": { + "rid": "4a507550-8742-4087-8bf5-c2334f29891c", + "rtype": "device" + }, + "time_zone": { + "time_zone": "Europe/Amsterdam" + }, + "type": "bridge" + }, + { + "enabled": true, + "id": "b6896534-016d-4052-8cb4-ef04454df62c", + "id_v1": "/sensors/66", + "motion": { + "motion": false, + "motion_valid": true + }, + "owner": { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + "type": "motion" + }, + { + "enabled": true, + "id": "d504e7a4-9a18-4854-90fd-c5b6ac102c40", + "id_v1": "/sensors/67", + "light": { + "light_level": 18027, + "light_level_valid": true + }, + "owner": { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + "type": "light_level" + }, + { + "enabled": true, + "id": "66466e14-d2fa-4b96-b2a0-e10de9cd8b8b", + "id_v1": "/sensors/68", + "owner": { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + "temperature": { + "temperature": 18.139999389648438, + "temperature_valid": true + }, + "type": "temperature" + }, + { + "configuration": { + "end_state": "last_state", + "where": [ + { + "group": { + "rid": "c14cf1cf-6c7a-4984-b8bb-c5b71aeb70fc", + "rtype": "entertainment_configuration" + } + } + ] + }, + "dependees": [ + { + "level": "critical", + "target": { + "rid": "c14cf1cf-6c7a-4984-b8bb-c5b71aeb70fc", + "rtype": "entertainment_configuration" + }, + "type": "ResourceDependee" + } + ], + "enabled": true, + "id": "0670cfb1-2bd7-4237-a0e3-1827a44d7231", + "last_error": "", + "metadata": { + "name": "state_after_streaming" + }, + "migrated_from": "/resourcelinks/47450", + "script_id": "7719b841-6b3d-448d-a0e7-601ae9edb6a2", + "status": "running", + "type": "behavior_instance" + }, + { + "configuration_schema": { + "$ref": "leaving_home_config.json#" + }, + "description": "Automatically turn off your lights when you leave", + "id": "0194752a-2d53-4f92-8209-dfdc52745af3", + "metadata": { + "name": "Leaving home" + }, + "state_schema": {}, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "schedule_config.json#" + }, + "description": "Schedule turning on and off lights", + "id": "7238c707-8693-4f19-9095-ccdc1444d228", + "metadata": { + "name": "Schedule" + }, + "state_schema": {}, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "lights_state_after_streaming_config.json#" + }, + "description": "State of lights in the entertainment group after streaming ends", + "id": "7719b841-6b3d-448d-a0e7-601ae9edb6a2", + "metadata": { + "name": "Light state after streaming" + }, + "state_schema": {}, + "trigger_schema": {}, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "basic_goto_sleep_config.json#" + }, + "description": "Get ready for nice sleep.", + "id": "7e571ac6-f363-42e1-809a-4cbf6523ed72", + "metadata": { + "name": "Basic go to sleep routine" + }, + "state_schema": {}, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "coming_home_config.json#" + }, + "description": "Automatically turn your lights to choosen light states, when you arrive at home.", + "id": "fd60fcd1-4809-4813-b510-4a18856a595c", + "metadata": { + "name": "Coming home" + }, + "state_schema": {}, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "basic_wake_up_config.json#" + }, + "description": "Get your body in the mood to wake up by fading on the lights in the morning.", + "id": "ff8957e3-2eb9-4699-a0c8-ad2cb3ede704", + "metadata": { + "name": "Basic wake up routine" + }, + "state_schema": {}, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "natural_light_config.json#" + }, + "description": "Natural light during the day", + "id": "a4260b49-0c69-4926-a29c-417f4a38a352", + "metadata": { + "name": "Natural Light" + }, + "state_schema": { + "$ref": "natural_light_state.json#" + }, + "trigger_schema": { + "$ref": "smart_scene_trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "timer_config.json#" + }, + "description": "Countdown Timer", + "id": "e73bc72d-96b1-46f8-aa57-729861f80c78", + "metadata": { + "name": "Timers" + }, + "state_schema": { + "$ref": "timer_state.json#" + }, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "id": "c6e03a31-4c30-4cef-834f-26ffbb06a593", + "name": "Test geofence client", + "type": "geofence_client" + }, + { + "id": "52612630-841e-4d39-9763-60346a0da759", + "is_configured": true, + "type": "geolocation" + } + ] + \ No newline at end of file diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py new file mode 100644 index 0000000000000..f1d6a1a8087c9 --- /dev/null +++ b/tests/components/hue/test_binary_sensor.py @@ -0,0 +1,61 @@ +"""Philips Hue binary_sensor platform tests for V2 bridge/api.""" + + +from .conftest import setup_platform +from .const import FAKE_BINARY_SENSOR, FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY + + +async def test_binary_sensors(hass, mock_bridge_v2, v2_resources_test_data): + """Test if all v2 binary_sensors get created with correct features.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "binary_sensor") + # there shouldn't have been any requests at this point + assert len(mock_bridge_v2.mock_requests) == 0 + # 2 binary_sensors should be created from test data + assert len(hass.states.async_all()) == 2 + + # test motion sensor + sensor = hass.states.get("binary_sensor.hue_motion_sensor_motion") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Hue motion sensor: Motion" + assert sensor.attributes["device_class"] == "motion" + assert sensor.attributes["motion_valid"] is True + + # test entertainment room active sensor + sensor = hass.states.get( + "binary_sensor.entertainmentroom_1_entertainment_configuration" + ) + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Entertainmentroom 1: Entertainment Configuration" + assert sensor.attributes["device_class"] == "running" + + +async def test_binary_sensor_add_update(hass, mock_bridge_v2): + """Test if binary_sensor get added/updated from events.""" + await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) + await setup_platform(hass, mock_bridge_v2, "binary_sensor") + + test_entity_id = "binary_sensor.hue_mocked_device_motion" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake sensor by emitting event + mock_bridge_v2.api.emit_event("add", FAKE_BINARY_SENSOR) + await hass.async_block_till_done() + + # the entity should now be available + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "off" + + # test update of entity works on incoming event + updated_sensor = {**FAKE_BINARY_SENSOR, "motion": {"motion": True}} + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "on" diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index 034acf88efa60..bede2f7578958 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -2,61 +2,70 @@ import asyncio from unittest.mock import AsyncMock, Mock, patch +from aiohttp import client_exceptions +from aiohue.errors import Unauthorized +from aiohue.v1 import HueBridgeV1 +from aiohue.v2 import HueBridgeV2 import pytest -from homeassistant import config_entries -from homeassistant.components import hue -from homeassistant.components.hue import bridge, errors +from homeassistant.components.hue import bridge from homeassistant.components.hue.const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, ) from homeassistant.exceptions import ConfigEntryNotReady -ORIG_SUBSCRIBE_EVENTS = bridge.HueBridge._subscribe_events +async def test_bridge_setup_v1(hass, mock_api_v1): + """Test a successful setup for V1 bridge.""" + config_entry = Mock() + config_entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1} + config_entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} -@pytest.fixture(autouse=True) -def mock_subscribe_events(): - """Mock subscribe events method.""" - with patch( - "homeassistant.components.hue.bridge.HueBridge._subscribe_events" - ) as mock: - yield mock - - -async def test_bridge_setup(hass, mock_subscribe_events): - """Test a successful setup.""" - entry = Mock() - api = Mock(initialize=AsyncMock()) - entry.data = {"host": "1.2.3.4", "username": "mock-username"} - entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - hue_bridge = bridge.HueBridge(hass, entry) - - with patch("aiohue.Bridge", return_value=api), patch.object( + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( hass.config_entries, "async_forward_entry_setup" ) as mock_forward: - assert await hue_bridge.async_setup() is True + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True - assert hue_bridge.api is api + assert hue_bridge.api is mock_api_v1 + assert isinstance(hue_bridge.api, HueBridgeV1) + assert hue_bridge.api_version == 1 assert len(mock_forward.mock_calls) == 3 forward_entries = {c[1][1] for c in mock_forward.mock_calls} assert forward_entries == {"light", "binary_sensor", "sensor"} - assert len(mock_subscribe_events.mock_calls) == 1 + +async def test_bridge_setup_v2(hass, mock_api_v2): + """Test a successful setup for V2 bridge.""" + config_entry = Mock() + config_entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 2} + + with patch.object(bridge, "HueBridgeV2", return_value=mock_api_v2), patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as mock_forward: + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True + + assert hue_bridge.api is mock_api_v2 + assert isinstance(hue_bridge.api, HueBridgeV2) + assert hue_bridge.api_version == 2 + assert len(mock_forward.mock_calls) == 5 + forward_entries = {c[1][1] for c in mock_forward.mock_calls} + assert forward_entries == {"light", "binary_sensor", "sensor", "switch", "scene"} -async def test_bridge_setup_invalid_username(hass): +async def test_bridge_setup_invalid_api_key(hass): """Test we start config flow if username is no longer whitelisted.""" entry = Mock() - entry.data = {"host": "1.2.3.4", "username": "mock-username"} + entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1} entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} hue_bridge = bridge.HueBridge(hass, entry) with patch.object( - bridge, "authenticate_bridge", side_effect=errors.AuthenticationRequired + hue_bridge.api, "initialize", side_effect=Unauthorized ), patch.object(hass.config_entries.flow, "async_init") as mock_init: - assert await hue_bridge.async_setup() is False + assert await hue_bridge.async_initialize_bridge() is False assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][2]["data"] == {"host": "1.2.3.4"} @@ -65,50 +74,34 @@ async def test_bridge_setup_invalid_username(hass): async def test_bridge_setup_timeout(hass): """Test we retry to connect if we cannot connect.""" entry = Mock() - entry.data = {"host": "1.2.3.4", "username": "mock-username"} + entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1} entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} hue_bridge = bridge.HueBridge(hass, entry) with patch.object( - bridge, "authenticate_bridge", side_effect=errors.CannotConnect + hue_bridge.api, + "initialize", + side_effect=client_exceptions.ServerDisconnectedError, ), pytest.raises(ConfigEntryNotReady): - await hue_bridge.async_setup() - - -async def test_reset_if_entry_had_wrong_auth(hass): - """Test calling reset when the entry contained wrong auth.""" - entry = Mock() - entry.data = {"host": "1.2.3.4", "username": "mock-username"} - entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - hue_bridge = bridge.HueBridge(hass, entry) - - with patch.object( - bridge, "authenticate_bridge", side_effect=errors.AuthenticationRequired - ), patch.object(bridge, "create_config_flow") as mock_create: - assert await hue_bridge.async_setup() is False - - assert len(mock_create.mock_calls) == 1 - - assert await hue_bridge.async_reset() + await hue_bridge.async_initialize_bridge() -async def test_reset_unloads_entry_if_setup(hass, mock_subscribe_events): +async def test_reset_unloads_entry_if_setup(hass, mock_api_v1): """Test calling reset while the entry has been setup.""" - entry = Mock() - entry.data = {"host": "1.2.3.4", "username": "mock-username"} - entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - hue_bridge = bridge.HueBridge(hass, entry) + config_entry = Mock() + config_entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1} + config_entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - with patch.object(bridge, "authenticate_bridge"), patch( - "aiohue.Bridge" - ), patch.object(hass.config_entries, "async_forward_entry_setup") as mock_forward: - assert await hue_bridge.async_setup() is True + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as mock_forward: + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True await asyncio.sleep(0) assert len(hass.services.async_services()) == 0 assert len(mock_forward.mock_calls) == 3 - assert len(mock_subscribe_events.mock_calls) == 1 with patch.object( hass.config_entries, "async_forward_entry_unload", return_value=True @@ -119,17 +112,15 @@ async def test_reset_unloads_entry_if_setup(hass, mock_subscribe_events): assert len(hass.services.async_services()) == 0 -async def test_handle_unauthorized(hass): +async def test_handle_unauthorized(hass, mock_api_v1): """Test handling an unauthorized error on update.""" - entry = Mock(async_setup=AsyncMock()) - entry.data = {"host": "1.2.3.4", "username": "mock-username"} - entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - hue_bridge = bridge.HueBridge(hass, entry) - - with patch.object(bridge, "authenticate_bridge"), patch("aiohue.Bridge"): - assert await hue_bridge.async_setup() is True + config_entry = Mock(async_setup=AsyncMock()) + config_entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1} + config_entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - assert hue_bridge.authorized is True + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1): + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True with patch.object(bridge, "create_config_flow") as mock_create: await hue_bridge.handle_unauthorized_error() @@ -137,233 +128,3 @@ async def test_handle_unauthorized(hass): assert hue_bridge.authorized is False assert len(mock_create.mock_calls) == 1 assert mock_create.mock_calls[0][1][1] == "1.2.3.4" - - -GROUP_RESPONSE = { - "group_1": { - "name": "Group 1", - "lights": ["1", "2"], - "type": "LightGroup", - "action": { - "on": True, - "bri": 254, - "hue": 10000, - "sat": 254, - "effect": "none", - "xy": [0.5, 0.5], - "ct": 250, - "alert": "select", - "colormode": "ct", - }, - "state": {"any_on": True, "all_on": False}, - } -} -SCENE_RESPONSE = { - "scene_1": { - "name": "Cozy dinner", - "lights": ["1", "2"], - "owner": "ffffffffe0341b1b376a2389376a2389", - "recycle": True, - "locked": False, - "appdata": {"version": 1, "data": "myAppData"}, - "picture": "", - "lastupdated": "2015-12-03T10:09:22", - "version": 2, - } -} - - -async def test_hue_activate_scene(hass, mock_api): - """Test successful hue_activate_scene.""" - config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "mock-host", "username": "mock-username"}, - "test", - options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, - ) - hue_bridge = bridge.HueBridge(hass, config_entry) - - mock_api.mock_group_responses.append(GROUP_RESPONSE) - mock_api.mock_scene_responses.append(SCENE_RESPONSE) - - with patch("aiohue.Bridge", return_value=mock_api), patch.object( - hass.config_entries, "async_forward_entry_setup" - ): - assert await hue_bridge.async_setup() is True - - assert hue_bridge.api is mock_api - - call = Mock() - call.data = {"group_name": "Group 1", "scene_name": "Cozy dinner"} - with patch("aiohue.Bridge", return_value=mock_api): - assert await hue_bridge.hue_activate_scene(call.data) is None - - assert len(mock_api.mock_requests) == 3 - assert mock_api.mock_requests[2]["json"]["scene"] == "scene_1" - assert "transitiontime" not in mock_api.mock_requests[2]["json"] - assert mock_api.mock_requests[2]["path"] == "groups/group_1/action" - - -async def test_hue_activate_scene_transition(hass, mock_api): - """Test successful hue_activate_scene with transition.""" - config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "mock-host", "username": "mock-username"}, - "test", - options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, - ) - hue_bridge = bridge.HueBridge(hass, config_entry) - - mock_api.mock_group_responses.append(GROUP_RESPONSE) - mock_api.mock_scene_responses.append(SCENE_RESPONSE) - - with patch("aiohue.Bridge", return_value=mock_api), patch.object( - hass.config_entries, "async_forward_entry_setup" - ): - assert await hue_bridge.async_setup() is True - - assert hue_bridge.api is mock_api - - call = Mock() - call.data = {"group_name": "Group 1", "scene_name": "Cozy dinner", "transition": 30} - with patch("aiohue.Bridge", return_value=mock_api): - assert await hue_bridge.hue_activate_scene(call.data) is None - - assert len(mock_api.mock_requests) == 3 - assert mock_api.mock_requests[2]["json"]["scene"] == "scene_1" - assert mock_api.mock_requests[2]["json"]["transitiontime"] == 30 - assert mock_api.mock_requests[2]["path"] == "groups/group_1/action" - - -async def test_hue_activate_scene_group_not_found(hass, mock_api): - """Test failed hue_activate_scene due to missing group.""" - config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "mock-host", "username": "mock-username"}, - "test", - options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, - ) - hue_bridge = bridge.HueBridge(hass, config_entry) - - mock_api.mock_group_responses.append({}) - mock_api.mock_scene_responses.append(SCENE_RESPONSE) - - with patch("aiohue.Bridge", return_value=mock_api), patch.object( - hass.config_entries, "async_forward_entry_setup" - ): - assert await hue_bridge.async_setup() is True - - assert hue_bridge.api is mock_api - - call = Mock() - call.data = {"group_name": "Group 1", "scene_name": "Cozy dinner"} - with patch("aiohue.Bridge", return_value=mock_api): - assert await hue_bridge.hue_activate_scene(call.data) is False - - -async def test_hue_activate_scene_scene_not_found(hass, mock_api): - """Test failed hue_activate_scene due to missing scene.""" - config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "mock-host", "username": "mock-username"}, - "test", - options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, - ) - hue_bridge = bridge.HueBridge(hass, config_entry) - - mock_api.mock_group_responses.append(GROUP_RESPONSE) - mock_api.mock_scene_responses.append({}) - - with patch("aiohue.Bridge", return_value=mock_api), patch.object( - hass.config_entries, "async_forward_entry_setup" - ): - assert await hue_bridge.async_setup() is True - - assert hue_bridge.api is mock_api - - call = Mock() - call.data = {"group_name": "Group 1", "scene_name": "Cozy dinner"} - with patch("aiohue.Bridge", return_value=mock_api): - assert await hue_bridge.hue_activate_scene(call.data) is False - - -async def test_event_updates(hass, caplog): - """Test calling reset while the entry has been setup.""" - events = asyncio.Queue() - - async def iterate_queue(): - while True: - event = await events.get() - if event is None: - return - yield event - - async def wait_empty_queue(): - count = 0 - while not events.empty() and count < 50: - await asyncio.sleep(0) - count += 1 - - hue_bridge = bridge.HueBridge(None, None) - hue_bridge.api = Mock(listen_events=iterate_queue) - subscription_task = asyncio.create_task(ORIG_SUBSCRIBE_EVENTS(hue_bridge)) - - calls = [] - - def obj_updated(): - calls.append(True) - - unsub = hue_bridge.listen_updates("lights", "2", obj_updated) - - events.put_nowait(Mock(ITEM_TYPE="lights", id="1")) - - await wait_empty_queue() - assert len(calls) == 0 - - events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) - - await wait_empty_queue() - assert len(calls) == 1 - - unsub() - - events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) - - await wait_empty_queue() - assert len(calls) == 1 - - # Test we can override update listener. - def obj_updated_false(): - calls.append(False) - - unsub = hue_bridge.listen_updates("lights", "2", obj_updated) - unsub_false = hue_bridge.listen_updates("lights", "2", obj_updated_false) - - events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) - - await wait_empty_queue() - assert len(calls) == 3 - assert calls[-2] is True - assert calls[-1] is False - - # Also call multiple times to make sure that works. - unsub() - unsub() - unsub_false() - unsub_false() - - events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) - - await wait_empty_queue() - assert len(calls) == 3 - - events.put_nowait(None) - await subscription_task diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 2c79795d48b31..31468e198da0f 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -1,16 +1,16 @@ """Tests for Philips Hue config flow.""" import asyncio -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch -from aiohttp import client_exceptions -import aiohue from aiohue.discovery import URL_NUPNP +from aiohue.errors import LinkButtonNotPressed import pytest import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.hue import config_flow, const +from homeassistant.components.hue.errors import CannotConnect from tests.common import MockConfigEntry @@ -22,36 +22,36 @@ def hue_setup_fixture(): yield -def get_mock_bridge( - bridge_id="aabbccddeeff", host="1.2.3.4", mock_create_user=None, username=None -): - """Return a mock bridge.""" - mock_bridge = Mock() - mock_bridge.host = host - mock_bridge.username = username - mock_bridge.config.name = "Mock Bridge" - mock_bridge.id = bridge_id - - if not mock_create_user: - - async def create_user(username): - mock_bridge.username = username - - mock_create_user = create_user +def get_discovered_bridge(bridge_id="aabbccddeeff", host="1.2.3.4", supports_v2=False): + """Return a mocked Discovered Bridge.""" + return Mock(host=host, id=bridge_id, supports_v2=supports_v2) - mock_bridge.create_user = mock_create_user - mock_bridge.initialize = AsyncMock() - return mock_bridge +def create_mock_api_discovery(aioclient_mock, bridges): + """Patch aiohttp responses with fake data for bridge discovery.""" + aioclient_mock.get( + URL_NUPNP, + json=[{"internalipaddress": host, "id": id} for (host, id) in bridges], + ) + for (host, bridge_id) in bridges: + aioclient_mock.get( + f"http://{host}/api/config", + json={"bridgeid": bridge_id}, + ) + # mock v2 support if v2 found in id + aioclient_mock.get( + f"https://{host}/clip/v2/resources", + status=403 if "v2" in bridge_id else 404, + ) async def test_flow_works(hass): """Test config flow .""" - mock_bridge = get_mock_bridge() + disc_bridge = get_discovered_bridge(supports_v2=True) with patch( "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], + return_value=[disc_bridge], ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -61,7 +61,7 @@ async def test_flow_works(hass): assert result["step_id"] == "init" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"id": mock_bridge.id} + result["flow_id"], user_input={"id": disc_bridge.id} ) assert result["type"] == "form" @@ -74,23 +74,23 @@ async def test_flow_works(hass): ) assert flow["context"]["unique_id"] == "aabbccddeeff" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + with patch.object(config_flow, "create_app_key", return_value="123456789"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == "create_entry" - assert result["title"] == "Mock Bridge" + assert result["title"] == "Hue Bridge aabbccddeeff" assert result["data"] == { "host": "1.2.3.4", - "username": "home-assistant#test-home", + "api_key": "123456789", + "api_version": 2, } - assert len(mock_bridge.initialize.mock_calls) == 1 - -async def test_manual_flow_works(hass, aioclient_mock): +async def test_manual_flow_works(hass): """Test config flow discovers only already configured bridges.""" - mock_bridge = get_mock_bridge() + disc_bridge = get_discovered_bridge(bridge_id="id-1234", host="2.2.2.2") MockConfigEntry( domain="hue", source=config_entries.SOURCE_IGNORE, unique_id="bla" @@ -98,7 +98,7 @@ async def test_manual_flow_works(hass, aioclient_mock): with patch( "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], + return_value=[disc_bridge], ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -114,14 +114,7 @@ async def test_manual_flow_works(hass, aioclient_mock): assert result["type"] == "form" assert result["step_id"] == "manual" - bridge = get_mock_bridge( - bridge_id="id-1234", host="2.2.2.2", username="username-abc" - ) - - with patch( - "aiohue.Bridge", - return_value=bridge, - ): + with patch.object(config_flow, "discover_bridge", return_value=disc_bridge): result = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "2.2.2.2"} ) @@ -129,16 +122,17 @@ async def test_manual_flow_works(hass, aioclient_mock): assert result["type"] == "form" assert result["step_id"] == "link" - with patch("homeassistant.components.hue.config_flow.authenticate_bridge"), patch( + with patch.object(config_flow, "create_app_key", return_value="123456789"), patch( "homeassistant.components.hue.async_unload_entry", return_value=True ): result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "create_entry" - assert result["title"] == "Mock Bridge" + assert result["title"] == f"Hue Bridge {disc_bridge.id}" assert result["data"] == { "host": "2.2.2.2", - "username": "username-abc", + "api_key": "123456789", + "api_version": 1, } entries = hass.config_entries.async_entries("hue") assert len(entries) == 2 @@ -146,8 +140,8 @@ async def test_manual_flow_works(hass, aioclient_mock): assert entry.unique_id == "id-1234" -async def test_manual_flow_bridge_exist(hass, aioclient_mock): - """Test config flow discovers only already configured bridges.""" +async def test_manual_flow_bridge_exist(hass): + """Test config flow aborts on already configured bridges.""" MockConfigEntry( domain="hue", unique_id="id-1234", data={"host": "2.2.2.2"} ).add_to_hass(hass) @@ -163,25 +157,17 @@ async def test_manual_flow_bridge_exist(hass, aioclient_mock): assert result["type"] == "form" assert result["step_id"] == "manual" - bridge = get_mock_bridge( - bridge_id="id-1234", host="2.2.2.2", username="username-abc" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "2.2.2.2"} ) - with patch( - "aiohue.Bridge", - return_value=bridge, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"host": "2.2.2.2"} - ) - assert result["type"] == "abort" assert result["reason"] == "already_configured" async def test_manual_flow_no_discovered_bridges(hass, aioclient_mock): """Test config flow discovers no bridges.""" - aioclient_mock.get(URL_NUPNP, json=[]) + create_mock_api_discovery(aioclient_mock, []) result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -192,9 +178,12 @@ async def test_manual_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(URL_NUPNP, json=[{"internalipaddress": "1.2.3.4", "id": "bla"}]) + mock_host = "1.2.3.4" + mock_id = "bla" + create_mock_api_discovery(aioclient_mock, [(mock_host, mock_id)]) + MockConfigEntry( - domain="hue", unique_id="bla", data={"host": "1.2.3.4"} + domain="hue", unique_id=mock_id, data={"host": mock_host} ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -212,12 +201,8 @@ async def test_flow_bridges_discovered(hass, aioclient_mock): domain="hue", source=config_entries.SOURCE_IGNORE, unique_id="bla" ).add_to_hass(hass) - aioclient_mock.get( - URL_NUPNP, - json=[ - {"internalipaddress": "1.2.3.4", "id": "bla"}, - {"internalipaddress": "5.6.7.8", "id": "beer"}, - ], + create_mock_api_discovery( + aioclient_mock, [("1.2.3.4", "bla"), ("5.6.7.8", "beer_v2")] ) result = await hass.config_entries.flow.async_init( @@ -230,19 +215,13 @@ async def test_flow_bridges_discovered(hass, aioclient_mock): assert result["data_schema"]({"id": "not-discovered"}) result["data_schema"]({"id": "bla"}) - result["data_schema"]({"id": "beer"}) + result["data_schema"]({"id": "beer_v2"}) result["data_schema"]({"id": "manual"}) async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock): """Test config flow discovers two bridges.""" - aioclient_mock.get( - URL_NUPNP, - json=[ - {"internalipaddress": "1.2.3.4", "id": "bla"}, - {"internalipaddress": "5.6.7.8", "id": "beer"}, - ], - ) + create_mock_api_discovery(aioclient_mock, [("1.2.3.4", "bla"), ("5.6.7.8", "beer")]) MockConfigEntry( domain="hue", unique_id="bla", data={"host": "1.2.3.4"} ).add_to_hass(hass) @@ -273,51 +252,25 @@ async def test_flow_timeout_discovery(hass): assert result["reason"] == "discover_timeout" -async def test_flow_link_timeout(hass): - """Test config flow.""" - mock_bridge = get_mock_bridge( - mock_create_user=AsyncMock(side_effect=asyncio.TimeoutError), - ) - with patch( - "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], - ): - result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"id": mock_bridge.id} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - - assert result["type"] == "abort" - assert result["reason"] == "cannot_connect" - - async def test_flow_link_unknown_error(hass): """Test if a unknown error happened during the linking processes.""" - mock_bridge = get_mock_bridge( - mock_create_user=AsyncMock(side_effect=OSError), - ) + disc_bridge = get_discovered_bridge() with patch( "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], + return_value=[disc_bridge], ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"id": mock_bridge.id} - ) + with patch.object(config_flow, "create_app_key", side_effect=Exception): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"id": disc_bridge.id} + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == "form" assert result["step_id"] == "link" @@ -326,58 +279,57 @@ async def test_flow_link_unknown_error(hass): async def test_flow_link_button_not_pressed(hass): """Test config flow .""" - mock_bridge = get_mock_bridge( - mock_create_user=AsyncMock(side_effect=aiohue.LinkButtonNotPressed), - ) + disc_bridge = get_discovered_bridge() with patch( "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], + return_value=[disc_bridge], ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"id": mock_bridge.id} - ) + with patch.object(config_flow, "create_app_key", side_effect=LinkButtonNotPressed): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"id": disc_bridge.id} + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == "form" assert result["step_id"] == "link" assert result["errors"] == {"base": "register_failed"} -async def test_flow_link_unknown_host(hass): +async def test_flow_link_cannot_connect(hass): """Test config flow .""" - mock_bridge = get_mock_bridge( - mock_create_user=AsyncMock(side_effect=client_exceptions.ClientOSError), - ) + disc_bridge = get_discovered_bridge() with patch( "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], + return_value=[disc_bridge], ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"id": mock_bridge.id} - ) + with patch.object(config_flow, "create_app_key", side_effect=CannotConnect): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"id": disc_bridge.id} + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == "abort" assert result["reason"] == "cannot_connect" @pytest.mark.parametrize("mf_url", config_flow.HUE_MANUFACTURERURL) -async def test_bridge_ssdp(hass, mf_url): +async def test_bridge_ssdp(hass, mf_url, aioclient_mock): """Test a bridge being discovered.""" + create_mock_api_discovery(aioclient_mock, [("0.0.0.0", "1234")]) result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -468,8 +420,9 @@ async def test_bridge_ssdp_espalexa(hass): assert result["reason"] == "not_hue_bridge" -async def test_bridge_ssdp_already_configured(hass): +async def test_bridge_ssdp_already_configured(hass, aioclient_mock): """Test if a discovered bridge has already been configured.""" + create_mock_api_discovery(aioclient_mock, [("0.0.0.0", "1234")]) MockConfigEntry( domain="hue", unique_id="1234", data={"host": "0.0.0.0"} ).add_to_hass(hass) @@ -488,8 +441,9 @@ async def test_bridge_ssdp_already_configured(hass): assert result["reason"] == "already_configured" -async def test_import_with_no_config(hass): +async def test_import_with_no_config(hass, aioclient_mock): """Test importing a host without an existing config file.""" + create_mock_api_discovery(aioclient_mock, [("0.0.0.0", "1234")]) result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -500,55 +454,52 @@ async def test_import_with_no_config(hass): assert result["step_id"] == "link" -async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass): +async def test_creating_entry_removes_entries_for_same_host_or_bridge( + hass, aioclient_mock +): """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. """ + create_mock_api_discovery(aioclient_mock, [("2.2.2.2", "id-1234")]) orig_entry = MockConfigEntry( domain="hue", - data={"host": "0.0.0.0", "username": "aaaa"}, + data={"host": "0.0.0.0", "api_key": "123456789"}, unique_id="id-1234", ) orig_entry.add_to_hass(hass) MockConfigEntry( domain="hue", - data={"host": "1.2.3.4", "username": "bbbb"}, + data={"host": "1.2.3.4", "api_key": "123456789"}, unique_id="id-5678", ).add_to_hass(hass) assert len(hass.config_entries.async_entries("hue")) == 2 - bridge = get_mock_bridge( - bridge_id="id-1234", host="2.2.2.2", username="username-abc" + result = await hass.config_entries.flow.async_init( + "hue", + data={"host": "2.2.2.2"}, + context={"source": config_entries.SOURCE_IMPORT}, ) - with patch( - "aiohue.Bridge", - return_value=bridge, - ): - result = await hass.config_entries.flow.async_init( - "hue", - data={"host": "2.2.2.2"}, - context={"source": config_entries.SOURCE_IMPORT}, - ) - assert result["type"] == "form" assert result["step_id"] == "link" - with patch("homeassistant.components.hue.config_flow.authenticate_bridge"), patch( - "homeassistant.components.hue.async_unload_entry", return_value=True - ): + with patch( + "homeassistant.components.hue.config_flow.create_app_key", + return_value="123456789", + ), patch("homeassistant.components.hue.async_unload_entry", return_value=True): result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "create_entry" - assert result["title"] == "Mock Bridge" + assert result["title"] == "Hue Bridge id-1234" assert result["data"] == { "host": "2.2.2.2", - "username": "username-abc", + "api_key": "123456789", + "api_version": 1, } entries = hass.config_entries.async_entries("hue") assert len(entries) == 2 @@ -559,7 +510,7 @@ async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass): async def test_bridge_homekit(hass, aioclient_mock): """Test a bridge being discovered via HomeKit.""" - aioclient_mock.get(URL_NUPNP, json=[{"internalipaddress": "1.2.3.4", "id": "bla"}]) + create_mock_api_discovery(aioclient_mock, [("0.0.0.0", "bla")]) result = await hass.config_entries.flow.async_init( const.DOMAIN, @@ -599,8 +550,9 @@ async def test_bridge_import_already_configured(hass): assert result["reason"] == "already_configured" -async def test_bridge_homekit_already_configured(hass): +async def test_bridge_homekit_already_configured(hass, aioclient_mock): """Test if a HomeKit discovered bridge has already been configured.""" + create_mock_api_discovery(aioclient_mock, [("0.0.0.0", "aabbccddeeff")]) MockConfigEntry( domain="hue", unique_id="aabbccddeeff", data={"host": "0.0.0.0"} ).add_to_hass(hass) @@ -615,8 +567,9 @@ async def test_bridge_homekit_already_configured(hass): assert result["reason"] == "already_configured" -async def test_ssdp_discovery_update_configuration(hass): +async def test_ssdp_discovery_update_configuration(hass, aioclient_mock): """Test if a discovered bridge is configured and updated with new host.""" + create_mock_api_discovery(aioclient_mock, [("1.1.1.1", "aabbccddeeff")]) entry = MockConfigEntry( domain="hue", unique_id="aabbccddeeff", data={"host": "0.0.0.0"} ) @@ -637,8 +590,8 @@ async def test_ssdp_discovery_update_configuration(hass): assert entry.data["host"] == "1.1.1.1" -async def test_options_flow(hass): - """Test options config flow.""" +async def test_options_flow_v1(hass): + """Test options config flow for a V1 bridge.""" entry = MockConfigEntry( domain="hue", unique_id="aabbccddeeff", @@ -683,8 +636,26 @@ def _get_schema_default(schema, key_name): raise KeyError(f"{key_name} not found in schema") -async def test_bridge_zeroconf(hass): +async def test_options_flow_v2(hass): + """Test options config flow for a V2 bridge.""" + entry = MockConfigEntry( + domain="hue", + unique_id="v2bridge", + data={"host": "0.0.0.0", "api_version": 2}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + # V2 bridge does not have config options + assert result["data_schema"] is None + + +async def test_bridge_zeroconf(hass, aioclient_mock): """Test a bridge being discovered.""" + create_mock_api_discovery(aioclient_mock, [("192.168.1.217", "ecb5fafffeabcabc")]) result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -706,8 +677,9 @@ async def test_bridge_zeroconf(hass): assert result["step_id"] == "link" -async def test_bridge_zeroconf_already_exists(hass): +async def test_bridge_zeroconf_already_exists(hass, aioclient_mock): """Test a bridge being discovered by zeroconf already exists.""" + create_mock_api_discovery(aioclient_mock, [("192.168.1.217", "ecb5faabcabc")]) entry = MockConfigEntry( domain="hue", source=config_entries.SOURCE_SSDP, diff --git a/tests/components/hue/test_device_trigger.py b/tests/components/hue/test_device_trigger_v1.py similarity index 71% rename from tests/components/hue/test_device_trigger.py rename to tests/components/hue/test_device_trigger_v1.py index d0c20018c308f..fcb6ca5668ed3 100644 --- a/tests/components/hue/test_device_trigger.py +++ b/tests/components/hue/test_device_trigger_v1.py @@ -1,43 +1,23 @@ -"""The tests for Philips Hue device triggers.""" -import pytest +"""The tests for Philips Hue device triggers for V1 bridge.""" -from homeassistant.components import hue -import homeassistant.components.automation as automation -from homeassistant.components.hue import device_trigger +from homeassistant.components import automation, hue +from homeassistant.components.hue.v1 import device_trigger from homeassistant.setup import async_setup_component -from .conftest import setup_bridge_for_sensors as setup_bridge -from .test_sensor_base import HUE_DIMMER_REMOTE_1, HUE_TAP_REMOTE_1 +from .conftest import setup_platform +from .test_sensor_v1 import HUE_DIMMER_REMOTE_1, HUE_TAP_REMOTE_1 -from tests.common import ( - assert_lists_same, - async_get_device_automations, - async_mock_service, - mock_device_registry, -) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +from tests.common import assert_lists_same, async_get_device_automations REMOTES_RESPONSE = {"7": HUE_TAP_REMOTE_1, "8": HUE_DIMMER_REMOTE_1} -@pytest.fixture -def device_reg(hass): - """Return an empty, loaded, registry.""" - return mock_device_registry(hass) - - -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -async def test_get_triggers(hass, mock_bridge, device_reg): +async def test_get_triggers(hass, mock_bridge_v1, device_reg): """Test we get the expected triggers from a hue remote.""" - mock_bridge.mock_sensor_responses.append(REMOTES_RESPONSE) - await setup_bridge(hass, mock_bridge) + mock_bridge_v1.mock_sensor_responses.append(REMOTES_RESPONSE) + await setup_platform(hass, mock_bridge_v1, ["sensor", "binary_sensor"]) - assert len(mock_bridge.mock_requests) == 1 + assert len(mock_bridge_v1.mock_requests) == 1 # 2 remotes, just 1 battery sensor assert len(hass.states.async_all()) == 1 @@ -88,11 +68,11 @@ async def test_get_triggers(hass, mock_bridge, device_reg): assert_lists_same(triggers, expected_triggers) -async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls): +async def test_if_fires_on_state_change(hass, mock_bridge_v1, device_reg, calls): """Test for button press trigger firing.""" - mock_bridge.mock_sensor_responses.append(REMOTES_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + mock_bridge_v1.mock_sensor_responses.append(REMOTES_RESPONSE) + await setup_platform(hass, mock_bridge_v1, ["sensor", "binary_sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 1 # Set an automation with a specific tap switch trigger @@ -145,13 +125,13 @@ async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls): "buttonevent": 18, "lastupdated": "2019-12-28T22:58:02", } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - await mock_bridge.sensor_manager.coordinator.async_refresh() + await mock_bridge_v1.sensor_manager.coordinator.async_refresh() await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 2 + assert len(mock_bridge_v1.mock_requests) == 2 assert len(calls) == 1 assert calls[0].data["some"] == "B4 - 18" @@ -162,10 +142,10 @@ async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls): "buttonevent": 34, "lastupdated": "2019-12-28T22:58:05", } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - await mock_bridge.sensor_manager.coordinator.async_refresh() + await mock_bridge_v1.sensor_manager.coordinator.async_refresh() await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 3 + assert len(mock_bridge_v1.mock_requests) == 3 assert len(calls) == 1 diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py new file mode 100644 index 0000000000000..bda963552c796 --- /dev/null +++ b/tests/components/hue/test_device_trigger_v2.py @@ -0,0 +1,82 @@ +"""The tests for Philips Hue device triggers for V2 bridge.""" +from aiohue.v2.models.button import ButtonEvent + +from homeassistant.components import hue +from homeassistant.components.hue.v2.device import async_setup_devices +from homeassistant.components.hue.v2.hue_event import async_setup_hue_events + +from .conftest import setup_platform + +from tests.common import ( + assert_lists_same, + async_capture_events, + async_get_device_automations, +) + + +async def test_hue_event(hass, mock_bridge_v2, v2_resources_test_data): + """Test hue button events.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, ["binary_sensor", "sensor"]) + await async_setup_devices(mock_bridge_v2) + await async_setup_hue_events(mock_bridge_v2) + + events = async_capture_events(hass, "hue_event") + + # Emit button update event + btn_event = { + "button": {"last_event": "short_release"}, + "id": "c658d3d8-a013-4b81-8ac6-78b248537e70", + "metadata": {"control_id": 1}, + "type": "button", + } + mock_bridge_v2.api.emit_event("update", btn_event) + + # wait for the event + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data["id"] == "wall_switch_with_2_controls_button" + assert events[0].data["unique_id"] == btn_event["id"] + assert events[0].data["type"] == btn_event["button"]["last_event"] + assert events[0].data["subtype"] == btn_event["metadata"]["control_id"] + + +async def test_get_triggers(hass, mock_bridge_v2, v2_resources_test_data, device_reg): + """Test we get the expected triggers from a hue remote.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, ["binary_sensor", "sensor"]) + + # Get triggers for `Wall switch with 2 controls` + hue_wall_switch_device = device_reg.async_get_device( + {(hue.DOMAIN, "3ff06175-29e8-44a8-8fe7-af591b0025da")} + ) + triggers = await async_get_device_automations( + hass, "trigger", hue_wall_switch_device.id + ) + + trigger_batt = { + "platform": "device", + "domain": "sensor", + "device_id": hue_wall_switch_device.id, + "type": "battery_level", + "entity_id": "sensor.wall_switch_with_2_controls_battery", + } + + expected_triggers = [ + trigger_batt, + *( + { + "platform": "device", + "domain": hue.DOMAIN, + "device_id": hue_wall_switch_device.id, + "unique_id": hue_wall_switch_device.id, + "type": event_type, + "subtype": control_id, + } + for event_type in (x.value for x in ButtonEvent if x != ButtonEvent.UNKNOWN) + for control_id in range(1, 3) + ), + ] + + assert_lists_same(triggers, expected_triggers) diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 05a7ade69486d..b44a638669eb1 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -10,12 +10,19 @@ from tests.common import MockConfigEntry -@pytest.fixture -def mock_bridge_setup(): +@pytest.fixture(name="mock_bridge_setup") +def get_mock_bridge_setup(): """Mock bridge setup.""" with patch.object(hue, "HueBridge") as mock_bridge: - mock_bridge.return_value.async_setup = AsyncMock(return_value=True) - mock_bridge.return_value.api.config = Mock(bridgeid="mock-id") + mock_bridge.return_value.async_initialize_bridge = AsyncMock(return_value=True) + mock_bridge.return_value.api_version = 1 + mock_bridge.return_value.api.config = Mock( + bridge_id="mock-id", + mac_address="00:00:00:00:00:00", + software_version="1.0.0", + model_id="BSB002", + ) + mock_bridge.return_value.api.config.name = "Mock Hue bridge" yield mock_bridge.return_value @@ -108,11 +115,14 @@ async def test_fixing_unique_id_other_correct(hass, mock_bridge_setup): async def test_security_vuln_check(hass): """Test that we report security vulnerabilities.""" - - entry = MockConfigEntry(domain=hue.DOMAIN, data={"host": "0.0.0.0"}) + entry = MockConfigEntry( + domain=hue.DOMAIN, data={"host": "0.0.0.0", "api_version": 1} + ) entry.add_to_hass(hass) - config = Mock(bridgeid="", mac="", modelid="BSB002", swversion="1935144020") + config = Mock( + bridge_id="", mac_address="", model_id="BSB002", software_version="1935144020" + ) config.name = "Hue" with patch.object( @@ -120,7 +130,9 @@ async def test_security_vuln_check(hass): "HueBridge", Mock( return_value=Mock( - async_setup=AsyncMock(return_value=True), api=Mock(config=config) + async_initialize_bridge=AsyncMock(return_value=True), + api=Mock(config=config), + api_version=1, ) ), ): diff --git a/tests/components/hue/test_init_multiple_bridges.py b/tests/components/hue/test_init_multiple_bridges.py deleted file mode 100644 index 1e3df824a3891..0000000000000 --- a/tests/components/hue/test_init_multiple_bridges.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Test Hue init with multiple bridges.""" -from unittest.mock import patch - -import pytest - -from homeassistant.components import hue -from homeassistant.setup import async_setup_component - -from .conftest import create_mock_bridge - -from tests.common import MockConfigEntry - - -async def setup_component(hass): - """Hue component.""" - with patch.object(hue, "async_setup_entry", return_value=True): - assert ( - await async_setup_component( - hass, - hue.DOMAIN, - {}, - ) - is True - ) - - -async def test_hue_activate_scene_both_responds( - hass, mock_bridge1, mock_bridge2, mock_config_entry1, mock_config_entry2 -): - """Test that makes both bridges successfully activate a scene.""" - - await setup_component(hass) - - await setup_bridge(hass, mock_bridge1, mock_config_entry1) - await setup_bridge(hass, mock_bridge2, mock_config_entry2) - - with patch.object( - mock_bridge1, "hue_activate_scene", return_value=None - ) as mock_hue_activate_scene1, patch.object( - mock_bridge2, "hue_activate_scene", return_value=None - ) as mock_hue_activate_scene2: - await hass.services.async_call( - "hue", - "hue_activate_scene", - {"group_name": "group_2", "scene_name": "my_scene"}, - blocking=True, - ) - - mock_hue_activate_scene1.assert_called_once() - mock_hue_activate_scene2.assert_called_once() - - -async def test_hue_activate_scene_one_responds( - hass, mock_bridge1, mock_bridge2, mock_config_entry1, mock_config_entry2 -): - """Test that makes only one bridge successfully activate a scene.""" - - await setup_component(hass) - - await setup_bridge(hass, mock_bridge1, mock_config_entry1) - await setup_bridge(hass, mock_bridge2, mock_config_entry2) - - with patch.object( - mock_bridge1, "hue_activate_scene", return_value=None - ) as mock_hue_activate_scene1, patch.object( - mock_bridge2, "hue_activate_scene", return_value=False - ) as mock_hue_activate_scene2: - await hass.services.async_call( - "hue", - "hue_activate_scene", - {"group_name": "group_2", "scene_name": "my_scene"}, - blocking=True, - ) - - mock_hue_activate_scene1.assert_called_once() - mock_hue_activate_scene2.assert_called_once() - - -async def test_hue_activate_scene_zero_responds( - hass, mock_bridge1, mock_bridge2, mock_config_entry1, mock_config_entry2 -): - """Test that makes no bridge successfully activate a scene.""" - - await setup_component(hass) - - await setup_bridge(hass, mock_bridge1, mock_config_entry1) - await setup_bridge(hass, mock_bridge2, mock_config_entry2) - - with patch.object( - mock_bridge1, "hue_activate_scene", return_value=False - ) as mock_hue_activate_scene1, patch.object( - mock_bridge2, "hue_activate_scene", return_value=False - ) as mock_hue_activate_scene2: - await hass.services.async_call( - "hue", - "hue_activate_scene", - {"group_name": "group_2", "scene_name": "my_scene"}, - blocking=True, - ) - - # both were retried - assert mock_hue_activate_scene1.call_count == 2 - assert mock_hue_activate_scene2.call_count == 2 - - -async def setup_bridge(hass, mock_bridge, config_entry): - """Load the Hue light platform with the provided bridge.""" - mock_bridge.config_entry = config_entry - config_entry.add_to_hass(hass) - with patch("homeassistant.components.hue.HueBridge", return_value=mock_bridge): - await hass.config_entries.async_setup(config_entry.entry_id) - - -@pytest.fixture -def mock_config_entry1(hass): - """Mock a config entry.""" - return create_config_entry() - - -@pytest.fixture -def mock_config_entry2(hass): - """Mock a config entry.""" - return create_config_entry() - - -def create_config_entry(): - """Mock a config entry.""" - return MockConfigEntry( - domain=hue.DOMAIN, - data={"host": "mock-host"}, - ) - - -@pytest.fixture -def mock_bridge1(hass): - """Mock a Hue bridge.""" - return create_mock_bridge(hass) - - -@pytest.fixture -def mock_bridge2(hass): - """Mock a Hue bridge.""" - return create_mock_bridge(hass) diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light_v1.py similarity index 77% rename from tests/components/hue/test_light.py rename to tests/components/hue/test_light_v1.py index 6025b725c60bc..8c82a544edef7 100644 --- a/tests/components/hue/test_light.py +++ b/tests/components/hue/test_light_v1.py @@ -4,12 +4,14 @@ import aiohue -from homeassistant import config_entries from homeassistant.components import hue -from homeassistant.components.hue import light as hue_light +from homeassistant.components.hue.const import CONF_ALLOW_HUE_GROUPS +from homeassistant.components.hue.v1 import light as hue_light from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import color +from .conftest import create_config_entry + HUE_LIGHT_NS = "homeassistant.components.light.hue." GROUP_RESPONSE = { "1": { @@ -170,50 +172,43 @@ LIGHT_GAMUT_TYPE = "A" -async def setup_bridge(hass, mock_bridge): +async def setup_bridge(hass, mock_bridge_v1): """Load the Hue light platform with the provided bridge.""" hass.config.components.add(hue.DOMAIN) - config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "mock-host"}, - "test", - ) - mock_bridge.config_entry = config_entry - hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge} + config_entry = create_config_entry() + config_entry.options = {CONF_ALLOW_HUE_GROUPS: True} + mock_bridge_v1.config_entry = config_entry + hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge_v1} await hass.config_entries.async_forward_entry_setup(config_entry, "light") # To flush out the service call to update the group await hass.async_block_till_done() -async def test_not_load_groups_if_old_bridge(hass, mock_bridge): - """Test that we don't try to load gorups if bridge runs old software.""" - mock_bridge.api.config.apiversion = "1.12.0" - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 +async def test_not_load_groups_if_old_bridge(hass, mock_bridge_v1): + """Test that we don't try to load groups if bridge runs old software.""" + mock_bridge_v1.api.config.apiversion = "1.12.0" + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 0 -async def test_no_lights_or_groups(hass, mock_bridge): +async def test_no_lights_or_groups(hass, mock_bridge_v1): """Test the update_lights function when no lights are found.""" - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append({}) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append({}) + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 0 -async def test_lights(hass, mock_bridge): +async def test_lights(hass, mock_bridge_v1): """Test the update_lights function with some lights.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 # 2 lights assert len(hass.states.async_all()) == 2 @@ -228,12 +223,12 @@ async def test_lights(hass, mock_bridge): assert lamp_2.state == "off" -async def test_lights_color_mode(hass, mock_bridge): +async def test_lights_color_mode(hass, mock_bridge_v1): """Test that lights only report appropriate color mode.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) - await setup_bridge(hass, mock_bridge) + await setup_bridge(hass, mock_bridge_v1) lamp_1 = hass.states.get("light.hue_lamp_1") assert lamp_1 is not None @@ -245,15 +240,15 @@ async def test_lights_color_mode(hass, mock_bridge): new_light1_on = LIGHT_1_ON.copy() new_light1_on["state"] = new_light1_on["state"].copy() new_light1_on["state"]["colormode"] = "ct" - mock_bridge.mock_light_responses.append({"1": new_light1_on}) - mock_bridge.mock_group_responses.append({}) + mock_bridge_v1.mock_light_responses.append({"1": new_light1_on}) + mock_bridge_v1.mock_group_responses.append({}) # Calling a service will trigger the updates to run await hass.services.async_call( "light", "turn_on", {"entity_id": "light.hue_lamp_2"}, blocking=True ) # 2x light update, 1 group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 lamp_1 = hass.states.get("light.hue_lamp_1") assert lamp_1 is not None @@ -263,18 +258,13 @@ async def test_lights_color_mode(hass, mock_bridge): assert "hs_color" in lamp_1.attributes -async def test_groups(hass, mock_bridge): +async def test_groups(hass, mock_bridge_v1): """Test the update_lights function with some lights.""" - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) - mock_bridge.api.groups._v2_resources = [ - {"id_v1": "/groups/1", "id": "group-1-mock-id", "type": "room"}, - {"id_v1": "/groups/2", "id": "group-2-mock-id", "type": "room"}, - ] - - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) + + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 # 2 hue group lights assert len(hass.states.async_all()) == 2 @@ -289,18 +279,18 @@ async def test_groups(hass, mock_bridge): assert lamp_2.state == "on" ent_reg = er.async_get(hass) - assert ent_reg.async_get("light.group_1").unique_id == "group-1-mock-id" - assert ent_reg.async_get("light.group_2").unique_id == "group-2-mock-id" + assert ent_reg.async_get("light.group_1").unique_id == "1" + assert ent_reg.async_get("light.group_2").unique_id == "2" -async def test_new_group_discovered(hass, mock_bridge): +async def test_new_group_discovered(hass, mock_bridge_v1): """Test if 2nd update has a new group.""" - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.allow_groups = True + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 new_group_response = dict(GROUP_RESPONSE) @@ -322,15 +312,15 @@ async def test_new_group_discovered(hass, mock_bridge): "state": {"any_on": True, "all_on": False}, } - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(new_group_response) + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(new_group_response) # Calling a service will trigger the updates to run await hass.services.async_call( "light", "turn_on", {"entity_id": "light.group_1"}, blocking=True ) # 2x group update, 1x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 3 new_group = hass.states.get("light.group_3") @@ -340,13 +330,12 @@ async def test_new_group_discovered(hass, mock_bridge): assert new_group.attributes["color_temp"] == 250 -async def test_new_light_discovered(hass, mock_bridge): +async def test_new_light_discovered(hass, mock_bridge_v1): """Test if 2nd update has a new light.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 new_light_response = dict(LIGHT_RESPONSE) @@ -372,14 +361,14 @@ async def test_new_light_discovered(hass, mock_bridge): "uniqueid": "789", } - mock_bridge.mock_light_responses.append(new_light_response) + mock_bridge_v1.mock_light_responses.append(new_light_response) # Calling a service will trigger the updates to run await hass.services.async_call( "light", "turn_on", {"entity_id": "light.hue_lamp_1"}, blocking=True ) # 2x light update, 1 group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 3 light = hass.states.get("light.hue_lamp_3") @@ -387,18 +376,18 @@ async def test_new_light_discovered(hass, mock_bridge): assert light.state == "off" -async def test_group_removed(hass, mock_bridge): +async def test_group_removed(hass, mock_bridge_v1): """Test if 2nd update has removed group.""" - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.allow_groups = True + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append({"1": GROUP_RESPONSE["1"]}) + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append({"1": GROUP_RESPONSE["1"]}) # Calling a service will trigger the updates to run await hass.services.async_call( @@ -406,7 +395,7 @@ async def test_group_removed(hass, mock_bridge): ) # 2x group update, 1x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 1 group = hass.states.get("light.group_1") @@ -416,17 +405,16 @@ async def test_group_removed(hass, mock_bridge): assert removed_group is None -async def test_light_removed(hass, mock_bridge): +async def test_light_removed(hass, mock_bridge_v1): """Test if 2nd update has removed light.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 - mock_bridge.mock_light_responses.clear() - mock_bridge.mock_light_responses.append({"1": LIGHT_RESPONSE.get("1")}) + mock_bridge_v1.mock_light_responses.clear() + mock_bridge_v1.mock_light_responses.append({"1": LIGHT_RESPONSE.get("1")}) # Calling a service will trigger the updates to run await hass.services.async_call( @@ -434,7 +422,7 @@ async def test_light_removed(hass, mock_bridge): ) # 2x light update, 1 group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 1 light = hass.states.get("light.hue_lamp_1") @@ -444,14 +432,14 @@ async def test_light_removed(hass, mock_bridge): assert removed_light is None -async def test_other_group_update(hass, mock_bridge): +async def test_other_group_update(hass, mock_bridge_v1): """Test changing one group that will impact the state of other light.""" - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.allow_groups = True + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 group_2 = hass.states.get("light.group_2") @@ -480,15 +468,15 @@ async def test_other_group_update(hass, mock_bridge): "state": {"any_on": False, "all_on": False}, } - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(updated_group_response) + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(updated_group_response) # Calling a service will trigger the updates to run await hass.services.async_call( "light", "turn_on", {"entity_id": "light.group_1"}, blocking=True ) # 2x group update, 1x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 2 group_2 = hass.states.get("light.group_2") @@ -497,13 +485,12 @@ async def test_other_group_update(hass, mock_bridge): assert group_2.state == "off" -async def test_other_light_update(hass, mock_bridge): +async def test_other_light_update(hass, mock_bridge_v1): """Test changing one light that will impact state of other light.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 lamp_2 = hass.states.get("light.hue_lamp_2") @@ -534,14 +521,14 @@ async def test_other_light_update(hass, mock_bridge): "uniqueid": "123", } - mock_bridge.mock_light_responses.append(updated_light_response) + mock_bridge_v1.mock_light_responses.append(updated_light_response) # Calling a service will trigger the updates to run await hass.services.async_call( "light", "turn_on", {"entity_id": "light.hue_lamp_1"}, blocking=True ) # 2x light update, 1 group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 2 lamp_2 = hass.states.get("light.hue_lamp_2") @@ -551,30 +538,29 @@ async def test_other_light_update(hass, mock_bridge): assert lamp_2.attributes["brightness"] == 100 -async def test_update_timeout(hass, mock_bridge): +async def test_update_timeout(hass, mock_bridge_v1): """Test bridge marked as not available if timeout error during update.""" - mock_bridge.api.lights.update = Mock(side_effect=asyncio.TimeoutError) - mock_bridge.api.groups.update = Mock(side_effect=asyncio.TimeoutError) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 0 + mock_bridge_v1.api.lights.update = Mock(side_effect=asyncio.TimeoutError) + mock_bridge_v1.api.groups.update = Mock(side_effect=asyncio.TimeoutError) + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 0 assert len(hass.states.async_all()) == 0 -async def test_update_unauthorized(hass, mock_bridge): +async def test_update_unauthorized(hass, mock_bridge_v1): """Test bridge marked as not authorized if unauthorized during update.""" - mock_bridge.api.lights.update = Mock(side_effect=aiohue.Unauthorized) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 0 + mock_bridge_v1.api.lights.update = Mock(side_effect=aiohue.Unauthorized) + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 0 assert len(hass.states.async_all()) == 0 - assert len(mock_bridge.handle_unauthorized_error.mock_calls) == 1 + assert len(mock_bridge_v1.handle_unauthorized_error.mock_calls) == 1 -async def test_light_turn_on_service(hass, mock_bridge): +async def test_light_turn_on_service(hass, mock_bridge_v1): """Test calling the turn on service on a light.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) + await setup_bridge(hass, mock_bridge_v1) light = hass.states.get("light.hue_lamp_2") assert light is not None assert light.state == "off" @@ -582,7 +568,7 @@ async def test_light_turn_on_service(hass, mock_bridge): updated_light_response = dict(LIGHT_RESPONSE) updated_light_response["2"] = LIGHT_2_ON - mock_bridge.mock_light_responses.append(updated_light_response) + mock_bridge_v1.mock_light_responses.append(updated_light_response) await hass.services.async_call( "light", @@ -590,10 +576,10 @@ async def test_light_turn_on_service(hass, mock_bridge): {"entity_id": "light.hue_lamp_2", "brightness": 100, "color_temp": 300}, blocking=True, ) - # 2x light update, 1 group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + # 2x light update, 1x group update, 1 turn on request + assert len(mock_bridge_v1.mock_requests) == 4 - assert mock_bridge.mock_requests[2]["json"] == { + assert mock_bridge_v1.mock_requests[2]["json"] == { "bri": 100, "on": True, "ct": 300, @@ -614,21 +600,20 @@ async def test_light_turn_on_service(hass, mock_bridge): blocking=True, ) - assert len(mock_bridge.mock_requests) == 6 + assert len(mock_bridge_v1.mock_requests) == 5 - assert mock_bridge.mock_requests[4]["json"] == { + assert mock_bridge_v1.mock_requests[4]["json"] == { "on": True, "xy": (0.138, 0.08), "alert": "none", } -async def test_light_turn_off_service(hass, mock_bridge): +async def test_light_turn_off_service(hass, mock_bridge_v1): """Test calling the turn on service on a light.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) + await setup_bridge(hass, mock_bridge_v1) light = hass.states.get("light.hue_lamp_1") assert light is not None assert light.state == "on" @@ -636,16 +621,16 @@ async def test_light_turn_off_service(hass, mock_bridge): updated_light_response = dict(LIGHT_RESPONSE) updated_light_response["1"] = LIGHT_1_OFF - mock_bridge.mock_light_responses.append(updated_light_response) + mock_bridge_v1.mock_light_responses.append(updated_light_response) await hass.services.async_call( "light", "turn_off", {"entity_id": "light.hue_lamp_1"}, blocking=True ) # 2x light update, 1 for group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 - assert mock_bridge.mock_requests[2]["json"] == {"on": False, "alert": "none"} + assert mock_bridge_v1.mock_requests[2]["json"] == {"on": False, "alert": "none"} assert len(hass.states.async_all()) == 2 @@ -663,8 +648,8 @@ def test_available(): colorgamuttype=LIGHT_GAMUT_TYPE, colorgamut=LIGHT_GAMUT, ), + bridge=Mock(config_entry=Mock(options={"allow_unreachable": False})), coordinator=Mock(last_update_success=True), - bridge=Mock(allow_unreachable=False), is_group=False, supported_features=hue_light.SUPPORT_HUE_EXTENDED, rooms={}, @@ -680,10 +665,10 @@ def test_available(): colorgamut=LIGHT_GAMUT, ), coordinator=Mock(last_update_success=True), - bridge=Mock(allow_unreachable=True), is_group=False, supported_features=hue_light.SUPPORT_HUE_EXTENDED, rooms={}, + bridge=Mock(config_entry=Mock(options={"allow_unreachable": True})), ) assert light.available is True @@ -696,10 +681,10 @@ def test_available(): colorgamut=LIGHT_GAMUT, ), coordinator=Mock(last_update_success=True), - bridge=Mock(allow_unreachable=False), is_group=True, supported_features=hue_light.SUPPORT_HUE_EXTENDED, rooms={}, + bridge=Mock(config_entry=Mock(options={"allow_unreachable": False})), ) assert light.available is True @@ -756,9 +741,8 @@ def test_hs_color(): assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) -async def test_group_features(hass, mock_bridge): +async def test_group_features(hass, mock_bridge_v1): """Test group features.""" - color_temp_type = "Color temperature light" extended_color_type = "Extended color light" @@ -920,11 +904,10 @@ async def test_group_features(hass, mock_bridge): "4": light_4, } - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append(light_response) - mock_bridge.mock_group_responses.append(group_response) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + mock_bridge_v1.mock_light_responses.append(light_response) + mock_bridge_v1.mock_group_responses.append(group_response) + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 color_temp_feature = hue_light.SUPPORT_HUE["Color temperature light"] extended_color_feature = hue_light.SUPPORT_HUE["Extended color light"] diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py new file mode 100644 index 0000000000000..0a06a87f7f25f --- /dev/null +++ b/tests/components/hue/test_light_v2.py @@ -0,0 +1,353 @@ +"""Philips Hue lights platform tests for V2 bridge/api.""" + +from homeassistant.components.light import COLOR_MODE_COLOR_TEMP, COLOR_MODE_XY +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_platform +from .const import FAKE_DEVICE, FAKE_LIGHT, FAKE_ZIGBEE_CONNECTIVITY + + +async def test_lights(hass, mock_bridge_v2, v2_resources_test_data): + """Test if all v2 lights get created with correct features.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "light") + # there shouldn't have been any requests at this point + assert len(mock_bridge_v2.mock_requests) == 0 + # 6 entities should be created from test data (grouped_lights are disabled by default) + assert len(hass.states.async_all()) == 6 + + # test light which supports color and color temperature + light_1 = hass.states.get("light.hue_light_with_color_and_color_temperature_1") + assert light_1 is not None + assert ( + light_1.attributes["friendly_name"] + == "Hue light with color and color temperature 1" + ) + assert light_1.state == "on" + assert light_1.attributes["brightness"] == int(46.85 / 100 * 255) + assert light_1.attributes["mode"] == "normal" + assert light_1.attributes["color_mode"] == COLOR_MODE_XY + assert set(light_1.attributes["supported_color_modes"]) == { + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_XY, + } + assert light_1.attributes["xy_color"] == (0.5614, 0.4058) + assert light_1.attributes["min_mireds"] == 153 + assert light_1.attributes["max_mireds"] == 500 + assert light_1.attributes["dynamics"] == "dynamic_palette" + + # test light which supports color temperature only + light_2 = hass.states.get("light.hue_light_with_color_temperature_only") + assert light_2 is not None + assert ( + light_2.attributes["friendly_name"] == "Hue light with color temperature only" + ) + assert light_2.state == "off" + assert light_2.attributes["mode"] == "normal" + assert light_2.attributes["supported_color_modes"] == [COLOR_MODE_COLOR_TEMP] + assert light_2.attributes["min_mireds"] == 153 + assert light_2.attributes["max_mireds"] == 454 + assert light_2.attributes["dynamics"] == "none" + + # test light which supports color only + light_3 = hass.states.get("light.hue_light_with_color_only") + assert light_3 is not None + assert light_3.attributes["friendly_name"] == "Hue light with color only" + assert light_3.state == "on" + assert light_3.attributes["brightness"] == 128 + assert light_3.attributes["mode"] == "normal" + assert light_3.attributes["supported_color_modes"] == [COLOR_MODE_XY] + assert light_3.attributes["color_mode"] == COLOR_MODE_XY + assert light_3.attributes["dynamics"] == "dynamic_palette" + + # test light which supports on/off only + light_4 = hass.states.get("light.hue_on_off_light") + assert light_4 is not None + assert light_4.attributes["friendly_name"] == "Hue on/off light" + assert light_4.state == "off" + assert light_4.attributes["mode"] == "normal" + assert light_4.attributes["supported_color_modes"] == [] + + +async def test_light_turn_on_service(hass, mock_bridge_v2, v2_resources_test_data): + """Test calling the turn on service on a light.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "light") + + test_light_id = "light.hue_light_with_color_temperature_only" + + # verify the light is off before we start + assert hass.states.get(test_light_id).state == "off" + + # now call the HA turn_on service + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "brightness_pct": 100, "color_temp": 300}, + blocking=True, + ) + + # PUT request should have been sent to device with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["on"]["on"] is True + assert mock_bridge_v2.mock_requests[0]["json"]["dimming"]["brightness"] == 100 + assert mock_bridge_v2.mock_requests[0]["json"]["color_temperature"]["mirek"] == 300 + + # Now generate update event by emitting the json we've sent as incoming event + mock_bridge_v2.mock_requests[0]["json"]["color_temperature"].pop("mirek_valid") + mock_bridge_v2.api.emit_event("update", mock_bridge_v2.mock_requests[0]["json"]) + await hass.async_block_till_done() + + # the light should now be on + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["mode"] == "normal" + assert test_light.attributes["supported_color_modes"] == [COLOR_MODE_COLOR_TEMP] + assert test_light.attributes["color_mode"] == COLOR_MODE_COLOR_TEMP + assert test_light.attributes["brightness"] == 255 + + # test again with sending transition + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "brightness_pct": 50, "transition": 6}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 2 + assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is True + assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 600 + + +async def test_light_turn_off_service(hass, mock_bridge_v2, v2_resources_test_data): + """Test calling the turn off service on a light.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "light") + + test_light_id = "light.hue_light_with_color_and_color_temperature_1" + + # verify the light is on before we start + assert hass.states.get(test_light_id).state == "on" + + # now call the HA turn_off service + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": test_light_id}, + blocking=True, + ) + + # PUT request should have been sent to device with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["on"]["on"] is False + + # Now generate update event by emitting the json we've sent as incoming event + mock_bridge_v2.api.emit_event("update", mock_bridge_v2.mock_requests[0]["json"]) + await hass.async_block_till_done() + + # the light should now be off + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "off" + + # test again with sending transition + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": test_light_id, "transition": 6}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 2 + assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is False + assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 600 + + +async def test_light_added(hass, mock_bridge_v2): + """Test new light added to bridge.""" + await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) + + await setup_platform(hass, mock_bridge_v2, "light") + + test_entity_id = "light.hue_mocked_device" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake entity (and attached device and zigbee_connectivity) by emitting events + mock_bridge_v2.api.emit_event("add", FAKE_LIGHT) + await hass.async_block_till_done() + + # the entity should now be available + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "off" + assert test_entity.attributes["friendly_name"] == FAKE_DEVICE["metadata"]["name"] + + +async def test_light_availability(hass, mock_bridge_v2, v2_resources_test_data): + """Test light availability property.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "light") + + test_light_id = "light.hue_light_with_color_and_color_temperature_1" + + # verify entity does exist and is available before we start + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + + # Change availability by modififying the zigbee_connectivity status + for status in ("connectivity_issue", "disconnected", "connected"): + mock_bridge_v2.api.emit_event( + "update", + { + "id": "1987ba66-c21d-48d0-98fb-121d939a71f3", + "status": status, + "type": "zigbee_connectivity", + }, + ) + mock_bridge_v2.api.emit_event( + "update", + { + "id": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "type": "light", + }, + ) + await hass.async_block_till_done() + + # the entity should now be available only when zigbee is connected + test_light = hass.states.get(test_light_id) + assert test_light.state == "on" if status == "connected" else "unavailable" + + +async def test_grouped_lights(hass, mock_bridge_v2, v2_resources_test_data): + """Test if all v2 grouped lights get created with correct features.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "light") + + # test if entities for hue groups are created and disabled by default + for entity_id in ("light.test_zone", "light.test_room"): + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + + # enable the entity + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload platform and check if entities are correctly there + await hass.config_entries.async_forward_entry_unload( + mock_bridge_v2.config_entry, "light" + ) + await hass.config_entries.async_forward_entry_setup( + mock_bridge_v2.config_entry, "light" + ) + await hass.async_block_till_done() + + # test light created for hue zone + test_entity = hass.states.get("light.test_zone") + assert test_entity is not None + assert test_entity.attributes["friendly_name"] == "Test Zone" + assert test_entity.state == "on" + assert test_entity.attributes["brightness"] == 119 + assert test_entity.attributes["color_mode"] == COLOR_MODE_XY + assert set(test_entity.attributes["supported_color_modes"]) == { + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_XY, + } + assert test_entity.attributes["min_mireds"] == 153 + assert test_entity.attributes["max_mireds"] == 500 + assert test_entity.attributes["is_hue_group"] is True + assert test_entity.attributes["hue_scenes"] == {"Dynamic Test Scene"} + assert test_entity.attributes["hue_type"] == "zone" + assert test_entity.attributes["lights"] == { + "Hue light with color and color temperature 1", + "Hue light with color and color temperature gradient", + "Hue light with color and color temperature 2", + } + + # test light created for hue room + test_entity = hass.states.get("light.test_room") + assert test_entity is not None + assert test_entity.attributes["friendly_name"] == "Test Room" + assert test_entity.state == "off" + assert test_entity.attributes["supported_color_modes"] == [COLOR_MODE_COLOR_TEMP] + assert test_entity.attributes["min_mireds"] == 153 + assert test_entity.attributes["max_mireds"] == 454 + assert test_entity.attributes["is_hue_group"] is True + assert test_entity.attributes["hue_scenes"] == {"Regular Test Scene"} + assert test_entity.attributes["hue_type"] == "room" + assert test_entity.attributes["lights"] == { + "Hue on/off light", + "Hue light with color temperature only", + } + + # Test calling the turn on service on a grouped light + test_light_id = "light.test_zone" + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "brightness_pct": 100, "xy_color": (0.123, 0.123)}, + blocking=True, + ) + + # PUT request should have been sent to ALL group lights with correct params + assert len(mock_bridge_v2.mock_requests) == 3 + for index in range(0, 3): + assert mock_bridge_v2.mock_requests[index]["json"]["on"]["on"] is True + assert ( + mock_bridge_v2.mock_requests[index]["json"]["dimming"]["brightness"] == 100 + ) + assert mock_bridge_v2.mock_requests[index]["json"]["color"]["xy"]["x"] == 0.123 + assert mock_bridge_v2.mock_requests[index]["json"]["color"]["xy"]["y"] == 0.123 + + # Now generate update events by emitting the json we've sent as incoming events + for index in range(0, 3): + mock_bridge_v2.api.emit_event( + "update", mock_bridge_v2.mock_requests[index]["json"] + ) + await hass.async_block_till_done() + + # the light should now be on and have the properties we've set + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["color_mode"] == COLOR_MODE_XY + assert test_light.attributes["brightness"] == 255 + assert test_light.attributes["xy_color"] == (0.123, 0.123) + + # Test calling the turn off service on a grouped light. + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": test_light_id}, + blocking=True, + ) + + # PUT request should have been sent to ONLY the grouped_light resource with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["on"]["on"] is False + + # Now generate update event by emitting the json we've sent as incoming event + mock_bridge_v2.api.emit_event("update", mock_bridge_v2.mock_requests[0]["json"]) + await hass.async_block_till_done() + + # the light should now be off + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "off" diff --git a/tests/components/hue/test_migration.py b/tests/components/hue/test_migration.py new file mode 100644 index 0000000000000..382a075bbbd63 --- /dev/null +++ b/tests/components/hue/test_migration.py @@ -0,0 +1,179 @@ +"""Test Hue migration logic.""" +from unittest.mock import patch + +from homeassistant.components import hue +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_migrate_api_key(hass): + """Test if username gets migrated to api_key.""" + config_entry = MockConfigEntry( + domain=hue.DOMAIN, + data={"host": "0.0.0.0", "api_version": 2, "username": "abcdefgh"}, + ) + await hue.migration.check_migration(hass, config_entry) + # the username property should have been migrated to api_key + assert config_entry.data == { + "host": "0.0.0.0", + "api_version": 2, + "api_key": "abcdefgh", + } + + +async def test_auto_switchover(hass): + """Test if config entry from v1 automatically switches to v2.""" + config_entry = MockConfigEntry( + domain=hue.DOMAIN, + data={"host": "0.0.0.0", "api_version": 1, "username": "abcdefgh"}, + ) + + with patch.object(hue.migration, "is_v2_bridge", retun_value=True), patch.object( + hue.migration, "handle_v2_migration" + ) as mock_mig: + await hue.migration.check_migration(hass, config_entry) + assert len(mock_mig.mock_calls) == 1 + # the api version should now be version 2 + assert config_entry.data == { + "host": "0.0.0.0", + "api_version": 2, + "api_key": "abcdefgh", + } + + +async def test_light_entity_migration( + hass, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data +): + """Test if entity schema for lights migrates from v1 to v2.""" + config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + + # create device/entity with V1 schema in registry + device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(hue.DOMAIN, "00:17:88:01:09:aa:bb:65")}, + ) + ent_reg.async_get_or_create( + "light", + hue.DOMAIN, + "00:17:88:01:09:aa:bb:65", + suggested_object_id="migrated_light_1", + device_id=device.id, + ) + + # now run the migration and check results + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.hue.migration.HueBridgeV2", + return_value=mock_bridge_v2.api, + ): + await hue.migration.handle_v2_migration(hass, config_entry) + + # migrated device should have new identifier (guid) and old style (mac) + migrated_device = dev_reg.async_get(device.id) + assert migrated_device is not None + assert migrated_device.identifiers == { + (hue.DOMAIN, "0b216218-d811-4c95-8c55-bbcda50f9d50"), + (hue.DOMAIN, "00:17:88:01:09:aa:bb:65"), + } + # the entity should have the new identifier (guid) + migrated_entity = ent_reg.async_get("light.migrated_light_1") + assert migrated_entity is not None + assert migrated_entity.unique_id == "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1" + + +async def test_sensor_entity_migration( + hass, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data +): + """Test if entity schema for sensors migrates from v1 to v2.""" + config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + + # create device with V1 schema in registry for Hue motion sensor + device_mac = "00:17:aa:bb:cc:09:ac:c3" + device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(hue.DOMAIN, device_mac)} + ) + + # mapping of device_class to new id + sensor_mappings = { + ("temperature", "sensor", "66466e14-d2fa-4b96-b2a0-e10de9cd8b8b"), + ("illuminance", "sensor", "d504e7a4-9a18-4854-90fd-c5b6ac102c40"), + ("battery", "sensor", "669f609d-4860-4f1c-bc25-7a9cec1c3b6c"), + ("motion", "binary_sensor", "b6896534-016d-4052-8cb4-ef04454df62c"), + } + + # create entities with V1 schema in registry for Hue motion sensor + for dev_class, platform, new_id in sensor_mappings: + ent_reg.async_get_or_create( + platform, + hue.DOMAIN, + f"{device_mac}-{dev_class}", + suggested_object_id=f"hue_migrated_{dev_class}_sensor", + device_id=device.id, + device_class=dev_class, + ) + + # now run the migration and check results + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.hue.migration.HueBridgeV2", + return_value=mock_bridge_v2.api, + ): + await hue.migration.handle_v2_migration(hass, config_entry) + + # migrated device should have new identifier (guid) and old style (mac) + migrated_device = dev_reg.async_get(device.id) + assert migrated_device is not None + assert migrated_device.identifiers == { + (hue.DOMAIN, "2330b45d-6079-4c6e-bba6-1b68afb1a0d6"), + (hue.DOMAIN, device_mac), + } + # the entities should have the correct V2 identifier (guid) + for dev_class, platform, new_id in sensor_mappings: + migrated_entity = ent_reg.async_get( + f"{platform}.hue_migrated_{dev_class}_sensor" + ) + assert migrated_entity is not None + assert migrated_entity.unique_id == new_id + + +async def test_group_entity_migration( + hass, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data +): + """Test if entity schema for grouped_lights migrates from v1 to v2.""" + config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + + ent_reg = er.async_get(hass) + + # create (deviceless) entity with V1 schema in registry + ent_reg.async_get_or_create( + "light", + hue.DOMAIN, + "3", + suggested_object_id="hue_migrated_grouped_light", + config_entry=config_entry, + ) + + # now run the migration and check results + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await hass.async_block_till_done() + with patch( + "homeassistant.components.hue.migration.HueBridgeV2", + return_value=mock_bridge_v2.api, + ): + await hue.migration.handle_v2_migration(hass, config_entry) + + # the entity should have the new identifier (guid) + migrated_entity = ent_reg.async_get("light.hue_migrated_grouped_light") + assert migrated_entity is not None + assert migrated_entity.unique_id == "e937f8db-2f0e-49a0-936e-027e60e15b34" diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py new file mode 100644 index 0000000000000..82595d3845d3c --- /dev/null +++ b/tests/components/hue/test_scene.py @@ -0,0 +1,114 @@ +"""Philips Hue scene platform tests for V2 bridge/api.""" + + +from .conftest import setup_platform +from .const import FAKE_SCENE + + +async def test_scene(hass, mock_bridge_v2, v2_resources_test_data): + """Test if (config) scenes get created.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "scene") + # there shouldn't have been any requests at this point + assert len(mock_bridge_v2.mock_requests) == 0 + # 2 entities should be created from test data + assert len(hass.states.async_all()) == 2 + + # test (dynamic) scene for a hue zone + test_entity = hass.states.get("scene.test_zone_dynamic_test_scene") + assert test_entity is not None + assert test_entity.name == "Test Zone - Dynamic Test Scene" + assert test_entity.state == "scening" + assert test_entity.attributes["group_name"] == "Test Zone" + assert test_entity.attributes["group_type"] == "zone" + assert test_entity.attributes["name"] == "Dynamic Test Scene" + assert test_entity.attributes["speed"] == 0.6269841194152832 + assert test_entity.attributes["brightness"] == 46.85 + assert test_entity.attributes["is_dynamic"] is True + + # test (regular) scene for a hue room + test_entity = hass.states.get("scene.test_room_regular_test_scene") + assert test_entity is not None + assert test_entity.name == "Test Room - Regular Test Scene" + assert test_entity.state == "scening" + assert test_entity.attributes["group_name"] == "Test Room" + assert test_entity.attributes["group_type"] == "room" + assert test_entity.attributes["name"] == "Regular Test Scene" + assert test_entity.attributes["speed"] == 0.5 + assert test_entity.attributes["brightness"] == 100.0 + assert test_entity.attributes["is_dynamic"] is False + + +async def test_scene_turn_on_service(hass, mock_bridge_v2, v2_resources_test_data): + """Test calling the turn on service on a scene.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "scene") + + test_entity_id = "scene.test_room_regular_test_scene" + + # call the HA turn_on service + await hass.services.async_call( + "scene", + "turn_on", + {"entity_id": test_entity_id}, + blocking=True, + ) + + # PUT request should have been sent to device with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["recall"] == {"action": "active"} + + # test again with sending transition + await hass.services.async_call( + "scene", + "turn_on", + {"entity_id": test_entity_id, "transition": 6}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 2 + assert mock_bridge_v2.mock_requests[1]["json"]["recall"] == { + "action": "active", + "duration": 600, + } + + +async def test_scene_updates(hass, mock_bridge_v2, v2_resources_test_data): + """Test scene events from bridge.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "scene") + + test_entity_id = "scene.test_room_mocked_scene" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake scene + mock_bridge_v2.api.emit_event("add", FAKE_SCENE) + await hass.async_block_till_done() + + # the entity should now be available + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "scening" + assert test_entity.name == "Test Room - Mocked Scene" + assert test_entity.attributes["brightness"] == 65.0 + + # test update + updated_resource = {**FAKE_SCENE} + updated_resource["actions"][0]["action"]["dimming"]["brightness"] = 35.0 + mock_bridge_v2.api.emit_event("update", updated_resource) + await hass.async_block_till_done() + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.attributes["brightness"] == 35.0 + + # test delete + mock_bridge_v2.api.emit_event("delete", updated_resource) + await hass.async_block_till_done() + await hass.async_block_till_done() + test_entity = hass.states.get(test_entity_id) + assert test_entity is None diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_v1.py similarity index 81% rename from tests/components/hue/test_sensor_base.py rename to tests/components/hue/test_sensor_v1.py index 8b8eb45a22244..c35403eaac12e 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_v1.py @@ -3,29 +3,17 @@ from unittest.mock import Mock import aiohue -import pytest from homeassistant.components import hue -from homeassistant.components.hue import sensor_base -from homeassistant.components.hue.hue_event import CONF_HUE_EVENT +from homeassistant.components.hue.const import ATTR_HUE_EVENT +from homeassistant.components.hue.v1 import sensor_base from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util -from .conftest import create_mock_bridge, setup_bridge_for_sensors as setup_bridge - -from tests.common import ( - async_capture_events, - async_fire_time_changed, - mock_device_registry, -) - - -@pytest.fixture -def device_reg(hass): - """Return an empty, loaded, registry.""" - return mock_device_registry(hass) +from .conftest import create_mock_bridge, setup_platform +from tests.common import async_capture_events, async_fire_time_changed PRESENCE_SENSOR_1_PRESENT = { "state": {"presence": True, "lastupdated": "2019-01-01T01:00:00"}, @@ -293,18 +281,17 @@ def device_reg(hass): } -async def test_no_sensors(hass, mock_bridge): +async def test_no_sensors(hass, mock_bridge_v1): """Test the update_items function when no sensors are found.""" - mock_bridge.allow_groups = True - mock_bridge.mock_sensor_responses.append({}) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + mock_bridge_v1.mock_sensor_responses.append({}) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 0 -async def test_sensors_with_multiple_bridges(hass, mock_bridge): +async def test_sensors_with_multiple_bridges(hass, mock_bridge_v1): """Test the update_items function with some sensors.""" - mock_bridge_2 = create_mock_bridge(hass) + mock_bridge_2 = create_mock_bridge(hass, api_version=1) mock_bridge_2.mock_sensor_responses.append( { "1": PRESENCE_SENSOR_3_PRESENT, @@ -312,21 +299,23 @@ async def test_sensors_with_multiple_bridges(hass, mock_bridge): "3": TEMPERATURE_SENSOR_3, } ) - mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) - await setup_bridge(hass, mock_bridge) - await setup_bridge(hass, mock_bridge_2, hostname="mock-bridge-2") + mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + await setup_platform( + hass, mock_bridge_2, ["binary_sensor", "sensor"], "mock-bridge-2" + ) - assert len(mock_bridge.mock_requests) == 1 + assert len(mock_bridge_v1.mock_requests) == 1 assert len(mock_bridge_2.mock_requests) == 1 # 3 "physical" sensors with 3 virtual sensors each + 1 battery sensor assert len(hass.states.async_all()) == 10 -async def test_sensors(hass, mock_bridge): +async def test_sensors(hass, mock_bridge_v1): """Test the update_items function with some sensors.""" - mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 # 2 "physical" sensors with 3 virtual sensors each assert len(hass.states.async_all()) == 7 @@ -366,23 +355,23 @@ async def test_sensors(hass, mock_bridge): ) -async def test_unsupported_sensors(hass, mock_bridge): +async def test_unsupported_sensors(hass, mock_bridge_v1): """Test that unsupported sensors don't get added and don't fail.""" response_with_unsupported = dict(SENSOR_RESPONSE) response_with_unsupported["7"] = UNSUPPORTED_SENSOR - mock_bridge.mock_sensor_responses.append(response_with_unsupported) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + mock_bridge_v1.mock_sensor_responses.append(response_with_unsupported) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 # 2 "physical" sensors with 3 virtual sensors each + 1 battery sensor assert len(hass.states.async_all()) == 7 -async def test_new_sensor_discovered(hass, mock_bridge): +async def test_new_sensor_discovered(hass, mock_bridge_v1): """Test if 2nd update has a new sensor.""" - mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 7 new_sensor_response = dict(SENSOR_RESPONSE) @@ -394,13 +383,13 @@ async def test_new_sensor_discovered(hass, mock_bridge): } ) - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - await mock_bridge.sensor_manager.coordinator.async_refresh() + await mock_bridge_v1.sensor_manager.coordinator.async_refresh() await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 2 + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 10 presence = hass.states.get("binary_sensor.bedroom_sensor_motion") @@ -411,25 +400,25 @@ async def test_new_sensor_discovered(hass, mock_bridge): assert temperature.state == "17.75" -async def test_sensor_removed(hass, mock_bridge): +async def test_sensor_removed(hass, mock_bridge_v1): """Test if 2nd update has removed sensor.""" - mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 7 - mock_bridge.mock_sensor_responses.clear() + mock_bridge_v1.mock_sensor_responses.clear() keys = ("1", "2", "3") - mock_bridge.mock_sensor_responses.append({k: SENSOR_RESPONSE[k] for k in keys}) + mock_bridge_v1.mock_sensor_responses.append({k: SENSOR_RESPONSE[k] for k in keys}) # Force updates to run again - await mock_bridge.sensor_manager.coordinator.async_refresh() + await mock_bridge_v1.sensor_manager.coordinator.async_refresh() # To flush out the service call to update the group await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 2 + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 3 sensor = hass.states.get("binary_sensor.living_room_sensor_motion") @@ -439,31 +428,31 @@ async def test_sensor_removed(hass, mock_bridge): assert removed_sensor is None -async def test_update_timeout(hass, mock_bridge): +async def test_update_timeout(hass, mock_bridge_v1): """Test bridge marked as not available if timeout error during update.""" - mock_bridge.api.sensors.update = Mock(side_effect=asyncio.TimeoutError) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 0 + mock_bridge_v1.api.sensors.update = Mock(side_effect=asyncio.TimeoutError) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 0 assert len(hass.states.async_all()) == 0 -async def test_update_unauthorized(hass, mock_bridge): +async def test_update_unauthorized(hass, mock_bridge_v1): """Test bridge marked as not authorized if unauthorized during update.""" - mock_bridge.api.sensors.update = Mock(side_effect=aiohue.Unauthorized) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 0 + mock_bridge_v1.api.sensors.update = Mock(side_effect=aiohue.Unauthorized) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 0 assert len(hass.states.async_all()) == 0 - assert len(mock_bridge.handle_unauthorized_error.mock_calls) == 1 + assert len(mock_bridge_v1.handle_unauthorized_error.mock_calls) == 1 -async def test_hue_events(hass, mock_bridge, device_reg): +async def test_hue_events(hass, mock_bridge_v1, device_reg): """Test that hue remotes fire events when pressed.""" - mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) - events = async_capture_events(hass, CONF_HUE_EVENT) + events = async_capture_events(hass, ATTR_HUE_EVENT) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 7 assert len(events) == 0 @@ -471,8 +460,8 @@ async def test_hue_events(hass, mock_bridge, device_reg): {(hue.DOMAIN, "00:00:00:00:00:44:23:08")} ) - mock_bridge.api.sensors["7"].last_event = {"type": "button"} - mock_bridge.api.sensors["8"].last_event = {"type": "button"} + mock_bridge_v1.api.sensors["7"].last_event = {"type": "button"} + mock_bridge_v1.api.sensors["8"].last_event = {"type": "button"} new_sensor_response = dict(SENSOR_RESPONSE) new_sensor_response["7"] = dict(new_sensor_response["7"]) @@ -480,7 +469,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): "buttonevent": 18, "lastupdated": "2019-12-28T22:58:03", } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again async_fire_time_changed( @@ -488,7 +477,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): ) await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 2 + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 7 assert len(events) == 1 assert events[-1].data == { @@ -509,7 +498,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): "buttonevent": 3002, "lastupdated": "2019-12-28T22:58:03", } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again async_fire_time_changed( @@ -517,7 +506,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): ) await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 3 + assert len(mock_bridge_v1.mock_requests) == 3 assert len(hass.states.async_all()) == 7 assert len(events) == 2 assert events[-1].data == { @@ -535,7 +524,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): "buttonevent": 18, "lastupdated": "2019-12-28T22:58:02", } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again async_fire_time_changed( @@ -543,7 +532,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): ) await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 7 assert len(events) == 2 @@ -580,7 +569,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): ], }, } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again async_fire_time_changed( @@ -588,13 +577,13 @@ async def test_hue_events(hass, mock_bridge, device_reg): ) await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 5 + assert len(mock_bridge_v1.mock_requests) == 5 assert len(hass.states.async_all()) == 8 assert len(events) == 2 # A new press fires the event new_sensor_response["21"]["state"]["lastupdated"] = "2020-01-31T15:57:19" - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again async_fire_time_changed( @@ -606,7 +595,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): {(hue.DOMAIN, "ff:ff:00:0f:e7:fd:bc:b7")} ) - assert len(mock_bridge.mock_requests) == 6 + assert len(mock_bridge_v1.mock_requests) == 6 assert len(hass.states.async_all()) == 8 assert len(events) == 3 assert events[-1].data == { diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py new file mode 100644 index 0000000000000..2668922590f33 --- /dev/null +++ b/tests/components/hue/test_sensor_v2.py @@ -0,0 +1,123 @@ +"""Philips Hue sensor platform tests for V2 bridge/api.""" + +from homeassistant.components import hue +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import setup_bridge, setup_platform +from .const import FAKE_DEVICE, FAKE_SENSOR, FAKE_ZIGBEE_CONNECTIVITY + + +async def test_sensors(hass, mock_bridge_v2, v2_resources_test_data): + """Test if all v2 sensors get created with correct features.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "sensor") + # there shouldn't have been any requests at this point + assert len(mock_bridge_v2.mock_requests) == 0 + # 6 entities should be created from test data + assert len(hass.states.async_all()) == 6 + + # test temperature sensor + sensor = hass.states.get("sensor.hue_motion_sensor_temperature") + assert sensor is not None + assert sensor.state == "18.1" + assert sensor.attributes["friendly_name"] == "Hue motion sensor: Temperature" + assert sensor.attributes["device_class"] == "temperature" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "°C" + assert sensor.attributes["temperature_valid"] is True + + # test illuminance sensor + sensor = hass.states.get("sensor.hue_motion_sensor_illuminance") + assert sensor is not None + assert sensor.state == "63" + assert sensor.attributes["friendly_name"] == "Hue motion sensor: Illuminance" + assert sensor.attributes["device_class"] == "illuminance" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "lx" + assert sensor.attributes["light_level"] == 18027 + assert sensor.attributes["light_level_valid"] is True + + # test battery sensor + sensor = hass.states.get("sensor.wall_switch_with_2_controls_battery") + assert sensor is not None + assert sensor.state == "100" + assert sensor.attributes["friendly_name"] == "Wall switch with 2 controls: Battery" + assert sensor.attributes["device_class"] == "battery" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "%" + assert sensor.attributes["battery_state"] == "normal" + + # test disabled zigbee_connectivity sensor + entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + + +async def test_enable_sensor( + hass, mock_bridge_v2, v2_resources_test_data, mock_config_entry_v2 +): + """Test enabling of the by default disabled zigbee_connectivity sensor.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) + + assert await async_setup_component(hass, hue.DOMAIN, {}) is True + await hass.async_block_till_done() + await hass.config_entries.async_forward_entry_setup(mock_config_entry_v2, "sensor") + + entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + + # enable the entity + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload platform and check if entity is correctly there + await hass.config_entries.async_forward_entry_unload(mock_config_entry_v2, "sensor") + await hass.config_entries.async_forward_entry_setup(mock_config_entry_v2, "sensor") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "connected" + assert state.attributes["mac_address"] == "00:17:88:01:0b:aa:bb:99" + + +async def test_sensor_add_update(hass, mock_bridge_v2): + """Test if sensors get added/updated from events.""" + await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) + await setup_platform(hass, mock_bridge_v2, "sensor") + + test_entity_id = "sensor.hue_mocked_device_temperature" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake sensor by emitting event + mock_bridge_v2.api.emit_event("add", FAKE_SENSOR) + await hass.async_block_till_done() + + # the entity should now be available + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "18.0" + + # test update of entity works on incoming event + updated_sensor = {**FAKE_SENSOR, "temperature": {"temperature": 22.5}} + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "22.5" diff --git a/tests/components/hue/test_services.py b/tests/components/hue/test_services.py new file mode 100644 index 0000000000000..86557b8574858 --- /dev/null +++ b/tests/components/hue/test_services.py @@ -0,0 +1,265 @@ +"""Test Hue services.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components import hue +from homeassistant.components.hue import bridge +from homeassistant.components.hue.const import ( + CONF_ALLOW_HUE_GROUPS, + CONF_ALLOW_UNREACHABLE, +) + +from .conftest import setup_bridge, setup_component + +GROUP_RESPONSE = { + "group_1": { + "name": "Group 1", + "lights": ["1", "2"], + "type": "LightGroup", + "action": { + "on": True, + "bri": 254, + "hue": 10000, + "sat": 254, + "effect": "none", + "xy": [0.5, 0.5], + "ct": 250, + "alert": "select", + "colormode": "ct", + }, + "state": {"any_on": True, "all_on": False}, + } +} +SCENE_RESPONSE = { + "scene_1": { + "name": "Cozy dinner", + "lights": ["1", "2"], + "owner": "ffffffffe0341b1b376a2389376a2389", + "recycle": True, + "locked": False, + "appdata": {"version": 1, "data": "myAppData"}, + "picture": "", + "lastupdated": "2015-12-03T10:09:22", + "version": 2, + } +} + + +async def test_hue_activate_scene(hass, mock_api_v1): + """Test successful hue_activate_scene.""" + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + "test", + options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, + ) + + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( + hass.config_entries, "async_forward_entry_setup" + ): + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True + + assert hue_bridge.api is mock_api_v1 + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1): + assert ( + await hue.services.hue_activate_scene_v1( + hue_bridge, "Group 1", "Cozy dinner" + ) + is True + ) + + assert len(mock_api_v1.mock_requests) == 3 + assert mock_api_v1.mock_requests[2]["json"]["scene"] == "scene_1" + assert "transitiontime" not in mock_api_v1.mock_requests[2]["json"] + assert mock_api_v1.mock_requests[2]["path"] == "groups/group_1/action" + + +async def test_hue_activate_scene_transition(hass, mock_api_v1): + """Test successful hue_activate_scene with transition.""" + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + "test", + options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, + ) + + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( + hass.config_entries, "async_forward_entry_setup" + ): + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True + + assert hue_bridge.api is mock_api_v1 + + with patch("aiohue.HueBridgeV1", return_value=mock_api_v1): + assert ( + await hue.services.hue_activate_scene_v1( + hue_bridge, "Group 1", "Cozy dinner", 30 + ) + is True + ) + + assert len(mock_api_v1.mock_requests) == 3 + assert mock_api_v1.mock_requests[2]["json"]["scene"] == "scene_1" + assert mock_api_v1.mock_requests[2]["json"]["transitiontime"] == 30 + assert mock_api_v1.mock_requests[2]["path"] == "groups/group_1/action" + + +async def test_hue_activate_scene_group_not_found(hass, mock_api_v1): + """Test failed hue_activate_scene due to missing group.""" + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + "test", + options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, + ) + + mock_api_v1.mock_group_responses.append({}) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( + hass.config_entries, "async_forward_entry_setup" + ): + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True + + assert hue_bridge.api is mock_api_v1 + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1): + assert ( + await hue.services.hue_activate_scene_v1( + hue_bridge, "Group 1", "Cozy dinner" + ) + is False + ) + + +async def test_hue_activate_scene_scene_not_found(hass, mock_api_v1): + """Test failed hue_activate_scene due to missing scene.""" + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + "test", + options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, + ) + + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append({}) + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( + hass.config_entries, "async_forward_entry_setup" + ): + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True + + assert hue_bridge.api is mock_api_v1 + + with patch("aiohue.HueBridgeV1", return_value=mock_api_v1): + assert ( + await hue.services.hue_activate_scene_v1( + hue_bridge, "Group 1", "Cozy dinner" + ) + is False + ) + + +async def test_hue_multi_bridge_activate_scene_all_respond( + hass, mock_bridge_v1, mock_bridge_v2, mock_config_entry_v1, mock_config_entry_v2 +): + """Test that makes multiple bridges successfully activate a scene.""" + await setup_component(hass) + + mock_api_v1 = mock_bridge_v1.api + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) + await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) + + with patch.object( + hue.services, "hue_activate_scene_v2", return_value=True + ) as mock_hue_activate_scene2: + await hass.services.async_call( + "hue", + "hue_activate_scene", + {"group_name": "Group 1", "scene_name": "Cozy dinner"}, + blocking=True, + ) + + assert len(mock_api_v1.mock_requests) == 3 + assert mock_api_v1.mock_requests[2]["json"]["scene"] == "scene_1" + assert mock_api_v1.mock_requests[2]["path"] == "groups/group_1/action" + + mock_hue_activate_scene2.assert_called_once() + + +async def test_hue_multi_bridge_activate_scene_one_responds( + hass, mock_bridge_v1, mock_bridge_v2, mock_config_entry_v1, mock_config_entry_v2 +): + """Test that makes only one bridge successfully activate a scene.""" + await setup_component(hass) + + mock_api_v1 = mock_bridge_v1.api + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) + await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) + + with patch.object( + hue.services, "hue_activate_scene_v2", return_value=False + ) as mock_hue_activate_scene2: + await hass.services.async_call( + "hue", + "hue_activate_scene", + {"group_name": "Group 1", "scene_name": "Cozy dinner"}, + blocking=True, + ) + + assert len(mock_api_v1.mock_requests) == 3 + assert mock_api_v1.mock_requests[2]["json"]["scene"] == "scene_1" + assert mock_api_v1.mock_requests[2]["path"] == "groups/group_1/action" + mock_hue_activate_scene2.assert_called_once() + + +async def test_hue_multi_bridge_activate_scene_zero_responds( + hass, mock_bridge_v1, mock_bridge_v2, mock_config_entry_v1, mock_config_entry_v2 +): + """Test that makes no bridge successfully activate a scene.""" + await setup_component(hass) + mock_api_v1 = mock_bridge_v1.api + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) + await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) + + with patch.object( + hue.services, "hue_activate_scene_v2", return_value=False + ) as mock_hue_activate_scene2: + await hass.services.async_call( + "hue", + "hue_activate_scene", + {"group_name": "Non existing group", "scene_name": "Non existing Scene"}, + blocking=True, + ) + + # the V1 implementation should have retried (2 calls) + assert len(mock_api_v1.mock_requests) == 2 + assert mock_hue_activate_scene2.call_count == 1 diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py new file mode 100644 index 0000000000000..30f4d3634b468 --- /dev/null +++ b/tests/components/hue/test_switch.py @@ -0,0 +1,107 @@ +"""Philips Hue switch platform tests for V2 bridge/api.""" + +from .conftest import setup_platform +from .const import FAKE_BINARY_SENSOR, FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY + + +async def test_switch(hass, mock_bridge_v2, v2_resources_test_data): + """Test if (config) switches get created.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "switch") + # there shouldn't have been any requests at this point + assert len(mock_bridge_v2.mock_requests) == 0 + # 2 entities should be created from test data + assert len(hass.states.async_all()) == 2 + + # test config switch to enable/disable motion sensor + test_entity = hass.states.get("switch.hue_motion_sensor_motion") + assert test_entity is not None + assert test_entity.name == "Hue motion sensor: Motion" + assert test_entity.state == "on" + assert test_entity.attributes["device_class"] == "switch" + + +async def test_switch_turn_on_service(hass, mock_bridge_v2, v2_resources_test_data): + """Test calling the turn on service on a switch.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "switch") + + test_entity_id = "switch.hue_motion_sensor_motion" + + # call the HA turn_on service + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": test_entity_id}, + blocking=True, + ) + + # PUT request should have been sent to device with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["enabled"] is True + + +async def test_switch_turn_off_service(hass, mock_bridge_v2, v2_resources_test_data): + """Test calling the turn off service on a switch.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "switch") + + test_entity_id = "switch.hue_motion_sensor_motion" + + # verify the switch is on before we start + assert hass.states.get(test_entity_id).state == "on" + + # now call the HA turn_off service + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": test_entity_id}, + blocking=True, + ) + + # PUT request should have been sent to device with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["enabled"] is False + + # Now generate update event by emitting the json we've sent as incoming event + mock_bridge_v2.api.emit_event("update", mock_bridge_v2.mock_requests[0]["json"]) + await hass.async_block_till_done() + + # the switch should now be off + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "off" + + +async def test_switch_added(hass, mock_bridge_v2): + """Test new switch added to bridge.""" + await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) + + await setup_platform(hass, mock_bridge_v2, "switch") + + test_entity_id = "switch.hue_mocked_device_motion" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake entity (and attached device and zigbee_connectivity) by emitting events + mock_bridge_v2.api.emit_event("add", FAKE_BINARY_SENSOR) + await hass.async_block_till_done() + + # the entity should now be available + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "on" + + # test update + updated_resource = {**FAKE_BINARY_SENSOR, "enabled": False} + mock_bridge_v2.api.emit_event("update", updated_resource) + await hass.async_block_till_done() + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "off"