Refactor of Hue integration with full V2 support (#58996)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Marcel van der Veldt 2021-11-16 20:59:17 +01:00 committed by GitHub
parent 4642a70651
commit e1e6925097
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 7146 additions and 2255 deletions

View file

@ -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

View file

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

View file

@ -1,56 +1,24 @@
"""Hue binary sensor entities."""
from aiohue.sensors import TYPE_ZLL_PRESENCE
"""Support for Hue binary sensors."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_MOTION,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN as HUE_DOMAIN
from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor
PRESENCE_NAME_FORMAT = "{} motion"
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, 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,
}
}
)
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)

View file

@ -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"]
return self.config_entry.data[CONF_HOST]
@property
def allow_unreachable(self):
"""Allow unreachable light bulbs."""
return self.config_entry.options.get(
CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE
)
@property
def allow_groups(self):
"""Allow groups defined in the Hue bridge."""
return self.config_entry.options.get(
CONF_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS
)
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)
# 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
)
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
)
# 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 @@ class HueBridge:
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 @@ class HueBridge:
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 @@ class HueBridge:
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 @@ class HueBridge:
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},
)
)

View file

@ -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 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
)
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 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
)
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 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
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 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
)
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 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
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 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
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 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
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 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
# 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(

View file

@ -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

View file

@ -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 .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,
)
from . import DOMAIN
from .hue_event import CONF_HUE_EVENT
if TYPE_CHECKING:
from homeassistant.components.automation import (
AutomationActionType,
AutomationTriggerInfo,
)
from homeassistant.core import HomeAssistant
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},
}
from .bridge import HueBridge
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}
)
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(hass, config):
async def async_validate_trigger_config(hass: "HomeAssistant", config: ConfigType):
"""Validate config."""
config = TRIGGER_SCHEMA(config)
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")
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
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, config, action, automation_info):
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])
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")
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"
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.
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")
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)
# 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 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
if bridge.api_version == 1:
return await async_get_triggers_v1(bridge, device_entry)
return await async_get_triggers_v2(bridge, device_entry)

View file

@ -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 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
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)
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
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Old way of setting up Hue lights.
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]
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)

View file

@ -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"
}

View file

@ -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")

View file

@ -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,
}

View file

@ -1,135 +1,24 @@
"""Hue sensor entities."""
from aiohue.sensors import (
TYPE_ZLL_LIGHTLEVEL,
TYPE_ZLL_ROTARY,
TYPE_ZLL_SWITCH,
TYPE_ZLL_TEMPERATURE,
)
"""Support for Hue sensors."""
from __future__ import annotations
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 homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
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"
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, 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:
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)

View file

@ -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

View file

@ -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:

View file

@ -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"
}
}

View file

@ -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
)

View file

@ -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"
}
}

View file

@ -0,0 +1 @@
"""Hue V1 API specific platform implementation."""

View file

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

View file

@ -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

View file

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

View file

@ -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.const import CONF_DEVICE_ID, CONF_EVENT, CONF_ID, CONF_UNIQUE
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 @@ class HueEvent(GenericHueDevice):
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 @@ class HueEvent(GenericHueDevice):
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 @@ class HueEvent(GenericHueDevice):
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,

View file

@ -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}

View file

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

View file

@ -6,7 +6,7 @@ import logging
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.core import callback
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 @@ class SensorManager:
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 @@ class SensorManager:
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)
)

View file

@ -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 @@ class GenericHueDevice(entity.Entity):
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 @@ class GenericHueDevice(entity.Entity):
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()

View file

@ -0,0 +1 @@
"""Hue V2 API specific platform implementation."""

View file

@ -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}"

View file

@ -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))

View file

@ -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,
)

View file

@ -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()

View file

@ -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

View file

@ -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
)
)

View file

@ -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,
)

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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 @@ def create_mock_bridge(hass):
@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)
def create_mock_api(hass):
"""Create a mock API."""
api = Mock(initialize=AsyncMock())
@pytest.fixture
def mock_api_v2(hass):
"""Mock the Hue V2 api."""
return create_mock_api_v2(hass)
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 @@ def create_mock_api(hass):
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(hass):
"""Mock a Hue bridge."""
return create_mock_bridge(hass)
def mock_bridge_v1(hass):
"""Mock a Hue bridge with V1 api."""
return create_mock_bridge(hass, api_version=1)
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_bridge_v2(hass):
"""Mock a Hue bridge with V2 api."""
return create_mock_bridge(hass, api_version=2)
@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")

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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()
await hue_bridge.async_initialize_bridge()
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()
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)
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}
with patch.object(bridge, "authenticate_bridge"), patch("aiohue.Bridge"):
assert await hue_bridge.async_setup() is True
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

View file

@ -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
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)
if not mock_create_user:
async def create_user(username):
mock_bridge.username = username
mock_create_user = create_user
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,

View file

@ -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

View file

@ -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)

View file

@ -10,12 +10,19 @@ from homeassistant.setup import async_setup_component
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,
)
),
):

View file

@ -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)

View file

@ -4,12 +4,14 @@ from unittest.mock import Mock
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 = color.GamutType(
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"},
]
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
# 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"]

View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -3,29 +3,17 @@ import asyncio
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 @@ SENSOR_RESPONSE = {
}
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 == {

View file

@ -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"

View file

@ -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

View file

@ -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"