mirror of
https://github.com/home-assistant/core
synced 2024-10-15 01:17:17 +00:00
Refactor of Hue integration with full V2 support (#58996)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
parent
4642a70651
commit
e1e6925097
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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},
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
174
homeassistant/components/hue/migration.py
Normal file
174
homeassistant/components/hue/migration.py
Normal 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")
|
117
homeassistant/components/hue/scene.py
Normal file
117
homeassistant/components/hue/scene.py
Normal 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,
|
||||
}
|
|
@ -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)
|
||||
|
|
158
homeassistant/components/hue/services.py
Normal file
158
homeassistant/components/hue/services.py
Normal 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
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
94
homeassistant/components/hue/switch.py
Normal file
94
homeassistant/components/hue/switch.py
Normal 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
|
||||
)
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
1
homeassistant/components/hue/v1/__init__.py
Normal file
1
homeassistant/components/hue/v1/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Hue V1 API specific platform implementation."""
|
56
homeassistant/components/hue/v1/binary_sensor.py
Normal file
56
homeassistant/components/hue/v1/binary_sensor.py
Normal 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,
|
||||
}
|
||||
}
|
||||
)
|
185
homeassistant/components/hue/v1/device_trigger.py
Normal file
185
homeassistant/components/hue/v1/device_trigger.py
Normal 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
|
|
@ -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},
|
||||
)
|
||||
)
|
|
@ -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,
|
557
homeassistant/components/hue/v1/light.py
Normal file
557
homeassistant/components/hue/v1/light.py
Normal 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}
|
135
homeassistant/components/hue/v1/sensor.py
Normal file
135
homeassistant/components/hue/v1/sensor.py
Normal 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,
|
||||
},
|
||||
}
|
||||
)
|
|
@ -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)
|
||||
)
|
|
@ -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()
|
1
homeassistant/components/hue/v2/__init__.py
Normal file
1
homeassistant/components/hue/v2/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Hue V2 API specific platform implementation."""
|
110
homeassistant/components/hue/v2/binary_sensor.py
Normal file
110
homeassistant/components/hue/v2/binary_sensor.py
Normal 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}"
|
86
homeassistant/components/hue/v2/device.py
Normal file
86
homeassistant/components/hue/v2/device.py
Normal 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))
|
115
homeassistant/components/hue/v2/device_trigger.py
Normal file
115
homeassistant/components/hue/v2/device_trigger.py
Normal 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,
|
||||
)
|
113
homeassistant/components/hue/v2/entity.py
Normal file
113
homeassistant/components/hue/v2/entity.py
Normal 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()
|
249
homeassistant/components/hue/v2/group.py
Normal file
249
homeassistant/components/hue/v2/group.py
Normal 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
|
57
homeassistant/components/hue/v2/hue_event.py
Normal file
57
homeassistant/components/hue/v2/hue_event.py
Normal 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
|
||||
)
|
||||
)
|
187
homeassistant/components/hue/v2/light.py
Normal file
187
homeassistant/components/hue/v2/light.py
Normal 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,
|
||||
)
|
174
homeassistant/components/hue/v2/sensor.py
Normal file
174
homeassistant/components/hue/v2/sensor.py
Normal 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}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
97
tests/components/hue/const.py
Normal file
97
tests/components/hue/const.py
Normal 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",
|
||||
}
|
2107
tests/components/hue/fixtures/v2_resources.json
Normal file
2107
tests/components/hue/fixtures/v2_resources.json
Normal file
File diff suppressed because it is too large
Load diff
61
tests/components/hue/test_binary_sensor.py
Normal file
61
tests/components/hue/test_binary_sensor.py
Normal 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"
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
82
tests/components/hue/test_device_trigger_v2.py
Normal file
82
tests/components/hue/test_device_trigger_v2.py
Normal 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)
|
|
@ -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,
|
||||
)
|
||||
),
|
||||
):
|
||||
|
|
|
@ -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)
|
|
@ -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"]
|
353
tests/components/hue/test_light_v2.py
Normal file
353
tests/components/hue/test_light_v2.py
Normal 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"
|
179
tests/components/hue/test_migration.py
Normal file
179
tests/components/hue/test_migration.py
Normal 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"
|
114
tests/components/hue/test_scene.py
Normal file
114
tests/components/hue/test_scene.py
Normal 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
|
|
@ -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 == {
|
123
tests/components/hue/test_sensor_v2.py
Normal file
123
tests/components/hue/test_sensor_v2.py
Normal 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"
|
265
tests/components/hue/test_services.py
Normal file
265
tests/components/hue/test_services.py
Normal 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
|
107
tests/components/hue/test_switch.py
Normal file
107
tests/components/hue/test_switch.py
Normal 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"
|
Loading…
Reference in a new issue