core/homeassistant/components/emulated_hue/hue_api.py

927 lines
33 KiB
Python

"""Support for a Hue API to control Home Assistant."""
from __future__ import annotations
import asyncio
from collections.abc import Iterable
from functools import lru_cache
import hashlib
from http import HTTPStatus
from ipaddress import ip_address
import logging
import time
from typing import Any
from aiohttp import web
from homeassistant import core
from homeassistant.components import (
climate,
cover,
fan,
humidifier,
light,
media_player,
scene,
script,
)
from homeassistant.components.climate import (
SERVICE_SET_TEMPERATURE,
ClimateEntityFeature,
)
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
ATTR_POSITION,
CoverEntityFeature,
)
from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntityFeature
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.humidifier import ATTR_HUMIDITY, SERVICE_SET_HUMIDITY
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP,
ATTR_HS_COLOR,
ATTR_TRANSITION,
ATTR_XY_COLOR,
ColorMode,
LightEntityFeature,
)
from homeassistant.components.media_player import (
ATTR_MEDIA_VOLUME_LEVEL,
MediaPlayerEntityFeature,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
ATTR_TEMPERATURE,
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_VOLUME_SET,
STATE_CLOSED,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import Event, EventStateChangedData, State
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.util.json import json_loads
from homeassistant.util.network import is_local
from .config import Config
_LOGGER = logging.getLogger(__name__)
_OFF_STATES: dict[str, str] = {cover.DOMAIN: STATE_CLOSED}
# How long to wait for a state change to happen
STATE_CHANGE_WAIT_TIMEOUT = 5.0
# How long an entry state's cache will be valid for in seconds.
STATE_CACHED_TIMEOUT = 2.0
STATE_BRIGHTNESS = "bri"
STATE_COLORMODE = "colormode"
STATE_HUE = "hue"
STATE_SATURATION = "sat"
STATE_COLOR_TEMP = "ct"
STATE_TRANSITION = "tt"
STATE_XY = "xy"
# Hue API states, defined separately in case they change
HUE_API_STATE_ON = "on"
HUE_API_STATE_BRI = "bri"
HUE_API_STATE_COLORMODE = "colormode"
HUE_API_STATE_HUE = "hue"
HUE_API_STATE_SAT = "sat"
HUE_API_STATE_CT = "ct"
HUE_API_STATE_XY = "xy"
HUE_API_STATE_EFFECT = "effect"
HUE_API_STATE_TRANSITION = "transitiontime"
# Hue API min/max values - https://developers.meethue.com/develop/hue-api/lights-api/
HUE_API_STATE_BRI_MIN = 1 # Brightness
HUE_API_STATE_BRI_MAX = 254
HUE_API_STATE_HUE_MIN = 0 # Hue
HUE_API_STATE_HUE_MAX = 65535
HUE_API_STATE_SAT_MIN = 0 # Saturation
HUE_API_STATE_SAT_MAX = 254
HUE_API_STATE_CT_MIN = 153 # Color temp
HUE_API_STATE_CT_MAX = 500
HUE_API_USERNAME = "nouser"
UNAUTHORIZED_USER = [
{"error": {"address": "/", "description": "unauthorized user", "type": "1"}}
]
DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN = {
cover.DOMAIN: CoverEntityFeature.SET_POSITION,
fan.DOMAIN: FanEntityFeature.SET_SPEED,
media_player.DOMAIN: MediaPlayerEntityFeature.VOLUME_SET,
climate.DOMAIN: ClimateEntityFeature.TARGET_TEMPERATURE,
}
ENTITY_FEATURES_BY_DOMAIN = {
cover.DOMAIN: CoverEntityFeature,
fan.DOMAIN: FanEntityFeature,
media_player.DOMAIN: MediaPlayerEntityFeature,
climate.DOMAIN: ClimateEntityFeature,
}
@lru_cache(maxsize=32)
def _remote_is_allowed(address: str) -> bool:
"""Check if remote address is allowed."""
return is_local(ip_address(address))
class HueUnauthorizedUser(HomeAssistantView):
"""Handle requests to find the emulated hue bridge."""
url = "/api"
name = "emulated_hue:api:unauthorized_user"
extra_urls = ["/api/"]
requires_auth = False
async def get(self, request: web.Request) -> web.Response:
"""Handle a GET request."""
return self.json(UNAUTHORIZED_USER)
class HueUsernameView(HomeAssistantView):
"""Handle requests to create a username for the emulated hue bridge."""
url = "/api"
name = "emulated_hue:api:create_username"
extra_urls = ["/api/"]
requires_auth = False
async def post(self, request: web.Request) -> web.Response:
"""Handle a POST request."""
assert request.remote is not None
if not _remote_is_allowed(request.remote):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
try:
data = await request.json(loads=json_loads)
except ValueError:
return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST)
if "devicetype" not in data:
return self.json_message("devicetype not specified", HTTPStatus.BAD_REQUEST)
return self.json([{"success": {"username": HUE_API_USERNAME}}])
class HueAllGroupsStateView(HomeAssistantView):
"""Handle requests for getting info about entity groups."""
url = "/api/{username}/groups"
name = "emulated_hue:all_groups:state"
requires_auth = False
def __init__(self, config: Config) -> None:
"""Initialize the instance of the view."""
self.config = config
@core.callback
def get(self, request: web.Request, username: str) -> web.Response:
"""Process a request to make the Brilliant Lightpad work."""
assert request.remote is not None
if not _remote_is_allowed(request.remote):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
return self.json({})
class HueGroupView(HomeAssistantView):
"""Group handler to get Logitech Pop working."""
url = "/api/{username}/groups/0/action"
name = "emulated_hue:groups:state"
requires_auth = False
def __init__(self, config: Config) -> None:
"""Initialize the instance of the view."""
self.config = config
@core.callback
def put(self, request: web.Request, username: str) -> web.Response:
"""Process a request to make the Logitech Pop working."""
assert request.remote is not None
if not _remote_is_allowed(request.remote):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
return self.json(
[
{
"error": {
"address": "/groups/0/action/scene",
"type": 7,
"description": "invalid value, dummy for parameter, scene",
}
}
]
)
class HueAllLightsStateView(HomeAssistantView):
"""Handle requests for getting info about all entities."""
url = "/api/{username}/lights"
name = "emulated_hue:lights:state"
requires_auth = False
def __init__(self, config: Config) -> None:
"""Initialize the instance of the view."""
self.config = config
@core.callback
def get(self, request: web.Request, username: str) -> web.Response:
"""Process a request to get the list of available lights."""
assert request.remote is not None
if not _remote_is_allowed(request.remote):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
return self.json(create_list_of_entities(self.config, request))
class HueFullStateView(HomeAssistantView):
"""Return full state view of emulated hue."""
url = "/api/{username}"
name = "emulated_hue:username:state"
requires_auth = False
def __init__(self, config: Config) -> None:
"""Initialize the instance of the view."""
self.config = config
@core.callback
def get(self, request: web.Request, username: str) -> web.Response:
"""Process a request to get the list of available lights."""
assert request.remote is not None
if not _remote_is_allowed(request.remote):
return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED)
if username != HUE_API_USERNAME:
return self.json(UNAUTHORIZED_USER)
json_response = {
"lights": create_list_of_entities(self.config, request),
"config": create_config_model(self.config, request),
}
return self.json(json_response)
class HueConfigView(HomeAssistantView):
"""Return config view of emulated hue."""
url = "/api/{username}/config"
extra_urls = ["/api/config"]
name = "emulated_hue:username:config"
requires_auth = False
def __init__(self, config: Config) -> None:
"""Initialize the instance of the view."""
self.config = config
@core.callback
def get(self, request: web.Request, username: str = "") -> web.Response:
"""Process a request to get the configuration."""
assert request.remote is not None
if not _remote_is_allowed(request.remote):
return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED)
json_response = create_config_model(self.config, request)
return self.json(json_response)
class HueOneLightStateView(HomeAssistantView):
"""Handle requests for getting info about a single entity."""
url = "/api/{username}/lights/{entity_id}"
name = "emulated_hue:light:state"
requires_auth = False
def __init__(self, config: Config) -> None:
"""Initialize the instance of the view."""
self.config = config
@core.callback
def get(self, request: web.Request, username: str, entity_id: str) -> web.Response:
"""Process a request to get the state of an individual light."""
assert request.remote is not None
if not _remote_is_allowed(request.remote):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
hass = request.app[KEY_HASS]
hass_entity_id = self.config.number_to_entity_id(entity_id)
if hass_entity_id is None:
_LOGGER.error(
"Unknown entity number: %s not found in emulated_hue_ids.json",
entity_id,
)
return self.json_message("Entity not found", HTTPStatus.NOT_FOUND)
if (state := hass.states.get(hass_entity_id)) is None:
_LOGGER.error("Entity not found: %s", hass_entity_id)
return self.json_message("Entity not found", HTTPStatus.NOT_FOUND)
if not self.config.is_state_exposed(state):
_LOGGER.error("Entity not exposed: %s", entity_id)
return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED)
json_response = state_to_json(self.config, state)
return self.json(json_response)
class HueOneLightChangeView(HomeAssistantView):
"""Handle requests for setting info about entities."""
url = "/api/{username}/lights/{entity_number}/state"
name = "emulated_hue:light:state"
requires_auth = False
def __init__(self, config: Config) -> None:
"""Initialize the instance of the view."""
self.config = config
async def put( # noqa: C901
self, request: web.Request, username: str, entity_number: str
) -> web.Response:
"""Process a request to set the state of an individual light."""
assert request.remote is not None
if not _remote_is_allowed(request.remote):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
config = self.config
hass = request.app[KEY_HASS]
entity_id = config.number_to_entity_id(entity_number)
if entity_id is None:
_LOGGER.error("Unknown entity number: %s", entity_number)
return self.json_message("Entity not found", HTTPStatus.NOT_FOUND)
if (entity := hass.states.get(entity_id)) is None:
_LOGGER.error("Entity not found: %s", entity_id)
return self.json_message("Entity not found", HTTPStatus.NOT_FOUND)
if not config.is_state_exposed(entity):
_LOGGER.error("Entity not exposed: %s", entity_id)
return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED)
try:
request_json = await request.json()
except ValueError:
_LOGGER.error("Received invalid json")
return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST)
# Get the entity's supported features
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if entity.domain == light.DOMAIN:
color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) or []
# Parse the request
parsed: dict[str, Any] = {
STATE_ON: False,
STATE_BRIGHTNESS: None,
STATE_HUE: None,
STATE_SATURATION: None,
STATE_COLOR_TEMP: None,
STATE_XY: None,
STATE_TRANSITION: None,
}
if HUE_API_STATE_ON in request_json:
if not isinstance(request_json[HUE_API_STATE_ON], bool):
_LOGGER.error("Unable to parse data: %s", request_json)
return self.json_message("Bad request", HTTPStatus.BAD_REQUEST)
parsed[STATE_ON] = request_json[HUE_API_STATE_ON]
else:
parsed[STATE_ON] = _hass_to_hue_state(entity)
for key, attr in (
(HUE_API_STATE_BRI, STATE_BRIGHTNESS),
(HUE_API_STATE_HUE, STATE_HUE),
(HUE_API_STATE_SAT, STATE_SATURATION),
(HUE_API_STATE_CT, STATE_COLOR_TEMP),
(HUE_API_STATE_TRANSITION, STATE_TRANSITION),
):
if key in request_json:
try:
parsed[attr] = int(request_json[key])
except ValueError:
_LOGGER.error("Unable to parse data (2): %s", request_json)
return self.json_message("Bad request", HTTPStatus.BAD_REQUEST)
if HUE_API_STATE_XY in request_json:
try:
parsed[STATE_XY] = (
float(request_json[HUE_API_STATE_XY][0]),
float(request_json[HUE_API_STATE_XY][1]),
)
except ValueError:
_LOGGER.error("Unable to parse data (2): %s", request_json)
return self.json_message("Bad request", HTTPStatus.BAD_REQUEST)
if HUE_API_STATE_BRI in request_json:
if entity.domain == light.DOMAIN:
if light.brightness_supported(color_modes):
parsed[STATE_ON] = parsed[STATE_BRIGHTNESS] > 0
else:
parsed[STATE_BRIGHTNESS] = None
elif entity.domain == scene.DOMAIN:
parsed[STATE_BRIGHTNESS] = None
parsed[STATE_ON] = True
elif entity.domain in [
script.DOMAIN,
media_player.DOMAIN,
fan.DOMAIN,
cover.DOMAIN,
climate.DOMAIN,
humidifier.DOMAIN,
]:
# Convert 0-254 to 0-100
level = (parsed[STATE_BRIGHTNESS] / HUE_API_STATE_BRI_MAX) * 100
parsed[STATE_BRIGHTNESS] = round(level)
parsed[STATE_ON] = True
# Choose general HA domain
domain = core.DOMAIN
# Entity needs separate call to turn on
turn_on_needed = False
# Convert the resulting "on" status into the service we need to call
service: str | None = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF
# Construct what we need to send to the service
data: dict[str, Any] = {ATTR_ENTITY_ID: entity_id}
# If the requested entity is a light, set the brightness, hue,
# saturation and color temp
if entity.domain == light.DOMAIN:
if parsed[STATE_ON]:
if (
light.brightness_supported(color_modes)
and parsed[STATE_BRIGHTNESS] is not None
):
data[ATTR_BRIGHTNESS] = hue_brightness_to_hass(
parsed[STATE_BRIGHTNESS]
)
if light.color_supported(color_modes):
if any((parsed[STATE_HUE], parsed[STATE_SATURATION])):
if parsed[STATE_HUE] is not None:
hue = parsed[STATE_HUE]
else:
hue = 0
if parsed[STATE_SATURATION] is not None:
sat = parsed[STATE_SATURATION]
else:
sat = 0
# Convert hs values to hass hs values
hue = int((hue / HUE_API_STATE_HUE_MAX) * 360)
sat = int((sat / HUE_API_STATE_SAT_MAX) * 100)
data[ATTR_HS_COLOR] = (hue, sat)
if parsed[STATE_XY] is not None:
data[ATTR_XY_COLOR] = parsed[STATE_XY]
if (
light.color_temp_supported(color_modes)
and parsed[STATE_COLOR_TEMP] is not None
):
data[ATTR_COLOR_TEMP] = parsed[STATE_COLOR_TEMP]
if (
entity_features & LightEntityFeature.TRANSITION
and parsed[STATE_TRANSITION] is not None
):
data[ATTR_TRANSITION] = parsed[STATE_TRANSITION] / 10
# If the requested entity is a script, add some variables
elif entity.domain == script.DOMAIN:
data["variables"] = {
"requested_state": STATE_ON if parsed[STATE_ON] else STATE_OFF
}
if parsed[STATE_BRIGHTNESS] is not None:
data["variables"]["requested_level"] = parsed[STATE_BRIGHTNESS]
# If the requested entity is a climate, set the temperature
elif entity.domain == climate.DOMAIN:
# We don't support turning climate devices on or off,
# only setting the temperature
service = None
if (
entity_features & ClimateEntityFeature.TARGET_TEMPERATURE
and parsed[STATE_BRIGHTNESS] is not None
):
domain = entity.domain
service = SERVICE_SET_TEMPERATURE
data[ATTR_TEMPERATURE] = parsed[STATE_BRIGHTNESS]
# If the requested entity is a humidifier, set the humidity
elif entity.domain == humidifier.DOMAIN:
if parsed[STATE_BRIGHTNESS] is not None:
turn_on_needed = True
domain = entity.domain
service = SERVICE_SET_HUMIDITY
data[ATTR_HUMIDITY] = parsed[STATE_BRIGHTNESS]
# If the requested entity is a media player, convert to volume
elif entity.domain == media_player.DOMAIN:
if (
entity_features & MediaPlayerEntityFeature.VOLUME_SET
and parsed[STATE_BRIGHTNESS] is not None
):
turn_on_needed = True
domain = entity.domain
service = SERVICE_VOLUME_SET
# Convert 0-100 to 0.0-1.0
data[ATTR_MEDIA_VOLUME_LEVEL] = parsed[STATE_BRIGHTNESS] / 100.0
# If the requested entity is a cover, convert to open_cover/close_cover
elif entity.domain == cover.DOMAIN:
domain = entity.domain
if service == SERVICE_TURN_ON:
service = SERVICE_OPEN_COVER
else:
service = SERVICE_CLOSE_COVER
if (
entity_features & CoverEntityFeature.SET_POSITION
and parsed[STATE_BRIGHTNESS] is not None
):
domain = entity.domain
service = SERVICE_SET_COVER_POSITION
data[ATTR_POSITION] = parsed[STATE_BRIGHTNESS]
# If the requested entity is a fan, convert to speed
elif (
entity.domain == fan.DOMAIN
and entity_features & FanEntityFeature.SET_SPEED
and parsed[STATE_BRIGHTNESS] is not None
):
domain = entity.domain
# Convert 0-100 to a fan speed
data[ATTR_PERCENTAGE] = parsed[STATE_BRIGHTNESS]
# Map the off command to on
if entity.domain in config.off_maps_to_on_domains:
service = SERVICE_TURN_ON
# Separate call to turn on needed
if turn_on_needed:
await hass.services.async_call(
core.DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=False,
)
if service is not None:
state_will_change = parsed[STATE_ON] != _hass_to_hue_state(entity)
await hass.services.async_call(domain, service, data, blocking=False)
if state_will_change:
# Wait for the state to change.
await wait_for_state_change_or_timeout(
hass, entity_id, STATE_CACHED_TIMEOUT
)
# Create success responses for all received keys
json_response = [
create_hue_success_response(
entity_number, HUE_API_STATE_ON, parsed[STATE_ON]
)
]
for key, val in (
(STATE_BRIGHTNESS, HUE_API_STATE_BRI),
(STATE_HUE, HUE_API_STATE_HUE),
(STATE_SATURATION, HUE_API_STATE_SAT),
(STATE_COLOR_TEMP, HUE_API_STATE_CT),
(STATE_XY, HUE_API_STATE_XY),
(STATE_TRANSITION, HUE_API_STATE_TRANSITION),
):
if parsed[key] is not None:
json_response.append(
create_hue_success_response(entity_number, val, parsed[key])
)
if entity.domain in config.off_maps_to_on_domains:
# Caching is required because things like scripts and scenes won't
# report as "off" to Alexa if an "off" command is received, because
# they'll map to "on". Thus, instead of reporting its actual
# status, we report what Alexa will want to see, which is the same
# as the actual requested command.
config.cached_states[entity_id] = [parsed, None]
else:
config.cached_states[entity_id] = [parsed, time.time()]
return self.json(json_response)
def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]:
"""Retrieve and convert state and brightness values for an entity."""
cached_state_entry = config.cached_states.get(entity.entity_id, None)
cached_state = None
# Check if we have a cached entry, and if so if it hasn't expired.
if cached_state_entry is not None:
entry_state, entry_time = cached_state_entry
if entry_time is None:
# Handle the case where the entity is listed in config.off_maps_to_on_domains.
cached_state = entry_state
elif time.time() - entry_time < STATE_CACHED_TIMEOUT and entry_state[
STATE_ON
] == _hass_to_hue_state(entity):
# We only want to use the cache if the actual state of the entity
# is in sync so that it can be detected as an error by Alexa.
cached_state = entry_state
else:
# Remove the now stale cached entry.
config.cached_states.pop(entity.entity_id)
if cached_state is None:
return _build_entity_state_dict(entity)
data: dict[str, Any] = cached_state
# Make sure brightness is valid
if data[STATE_BRIGHTNESS] is None:
data[STATE_BRIGHTNESS] = HUE_API_STATE_BRI_MAX if data[STATE_ON] else 0
# Make sure hue/saturation are valid
if (data[STATE_HUE] is None) or (data[STATE_SATURATION] is None):
data[STATE_HUE] = 0
data[STATE_SATURATION] = 0
# If the light is off, set the color to off
if data[STATE_BRIGHTNESS] == 0:
data[STATE_HUE] = 0
data[STATE_SATURATION] = 0
_clamp_values(data)
return data
@lru_cache(maxsize=512)
def _build_entity_state_dict(entity: State) -> dict[str, Any]:
"""Build a state dict for an entity."""
is_on = _hass_to_hue_state(entity)
data: dict[str, Any] = {
STATE_ON: is_on,
STATE_BRIGHTNESS: None,
STATE_HUE: None,
STATE_SATURATION: None,
STATE_COLOR_TEMP: None,
}
attributes = entity.attributes
if is_on:
data[STATE_BRIGHTNESS] = hass_to_hue_brightness(
attributes.get(ATTR_BRIGHTNESS) or 0
)
if (hue_sat := attributes.get(ATTR_HS_COLOR)) is not None:
hue = hue_sat[0]
sat = hue_sat[1]
# Convert hass hs values back to hue hs values
data[STATE_HUE] = int((hue / 360.0) * HUE_API_STATE_HUE_MAX)
data[STATE_SATURATION] = int((sat / 100.0) * HUE_API_STATE_SAT_MAX)
else:
data[STATE_HUE] = HUE_API_STATE_HUE_MIN
data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN
data[STATE_COLOR_TEMP] = attributes.get(ATTR_COLOR_TEMP) or 0
else:
data[STATE_BRIGHTNESS] = 0
data[STATE_HUE] = 0
data[STATE_SATURATION] = 0
data[STATE_COLOR_TEMP] = 0
if entity.domain == climate.DOMAIN:
temperature = attributes.get(ATTR_TEMPERATURE, 0)
# Convert 0-100 to 0-254
data[STATE_BRIGHTNESS] = round(temperature * HUE_API_STATE_BRI_MAX / 100)
elif entity.domain == humidifier.DOMAIN:
humidity = attributes.get(ATTR_HUMIDITY, 0)
# Convert 0-100 to 0-254
data[STATE_BRIGHTNESS] = round(humidity * HUE_API_STATE_BRI_MAX / 100)
elif entity.domain == media_player.DOMAIN:
level = attributes.get(ATTR_MEDIA_VOLUME_LEVEL, 1.0 if is_on else 0.0)
# Convert 0.0-1.0 to 0-254
data[STATE_BRIGHTNESS] = round(min(1.0, level) * HUE_API_STATE_BRI_MAX)
elif entity.domain == fan.DOMAIN:
percentage = attributes.get(ATTR_PERCENTAGE) or 0
# Convert 0-100 to 0-254
data[STATE_BRIGHTNESS] = round(percentage * HUE_API_STATE_BRI_MAX / 100)
elif entity.domain == cover.DOMAIN:
level = attributes.get(ATTR_CURRENT_POSITION, 0)
data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX)
_clamp_values(data)
return data
def _clamp_values(data: dict[str, Any]) -> None:
"""Clamp brightness, hue, saturation, and color temp to valid values."""
for key, v_min, v_max in (
(STATE_BRIGHTNESS, HUE_API_STATE_BRI_MIN, HUE_API_STATE_BRI_MAX),
(STATE_HUE, HUE_API_STATE_HUE_MIN, HUE_API_STATE_HUE_MAX),
(STATE_SATURATION, HUE_API_STATE_SAT_MIN, HUE_API_STATE_SAT_MAX),
(STATE_COLOR_TEMP, HUE_API_STATE_CT_MIN, HUE_API_STATE_CT_MAX),
):
if data[key] is not None:
data[key] = max(v_min, min(data[key], v_max))
@lru_cache(maxsize=1024)
def _entity_unique_id(entity_id: str) -> str:
"""Return the emulated_hue unique id for the entity_id."""
unique_id = hashlib.md5(entity_id.encode()).hexdigest()
return (
f"00:{unique_id[0:2]}:{unique_id[2:4]}:"
f"{unique_id[4:6]}:{unique_id[6:8]}:{unique_id[8:10]}:"
f"{unique_id[10:12]}:{unique_id[12:14]}-{unique_id[14:16]}"
)
def state_to_json(config: Config, state: State) -> dict[str, Any]:
"""Convert an entity to its Hue bridge JSON representation."""
color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) or []
unique_id = _entity_unique_id(state.entity_id)
state_dict = get_entity_state_dict(config, state)
json_state: dict[str, str | bool | int] = {
HUE_API_STATE_ON: state_dict[STATE_ON],
"reachable": state.state != STATE_UNAVAILABLE,
"mode": "homeautomation",
}
retval: dict[str, str | dict[str, str | bool | int]] = {
"state": json_state,
"name": config.get_entity_name(state),
"uniqueid": unique_id,
"manufacturername": "Home Assistant",
"swversion": "123",
}
is_light = state.domain == light.DOMAIN
color_supported = is_light and light.color_supported(color_modes)
color_temp_supported = is_light and light.color_temp_supported(color_modes)
if color_supported and color_temp_supported:
# Extended Color light (Zigbee Device ID: 0x0210)
# Same as Color light, but which supports additional setting of color temperature
retval["type"] = "Extended color light"
retval["modelid"] = "HASS231"
json_state.update(
{
HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS],
HUE_API_STATE_HUE: state_dict[STATE_HUE],
HUE_API_STATE_SAT: state_dict[STATE_SATURATION],
HUE_API_STATE_CT: state_dict[STATE_COLOR_TEMP],
HUE_API_STATE_EFFECT: "none",
}
)
if state_dict[STATE_HUE] > 0 or state_dict[STATE_SATURATION] > 0:
json_state[HUE_API_STATE_COLORMODE] = "hs"
else:
json_state[HUE_API_STATE_COLORMODE] = "ct"
elif color_supported:
# Color light (Zigbee Device ID: 0x0200)
# Supports on/off, dimming and color control (hue/saturation, enhanced hue, color loop and XY)
retval["type"] = "Color light"
retval["modelid"] = "HASS213"
json_state.update(
{
HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS],
HUE_API_STATE_COLORMODE: "hs",
HUE_API_STATE_HUE: state_dict[STATE_HUE],
HUE_API_STATE_SAT: state_dict[STATE_SATURATION],
HUE_API_STATE_EFFECT: "none",
}
)
elif color_temp_supported:
# Color temperature light (Zigbee Device ID: 0x0220)
# Supports groups, scenes, on/off, dimming, and setting of a color temperature
retval["type"] = "Color temperature light"
retval["modelid"] = "HASS312"
json_state.update(
{
HUE_API_STATE_COLORMODE: "ct",
HUE_API_STATE_CT: state_dict[STATE_COLOR_TEMP],
HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS],
}
)
elif state_supports_hue_brightness(state, color_modes):
# Dimmable light (Zigbee Device ID: 0x0100)
# Supports groups, scenes, on/off and dimming
retval["type"] = "Dimmable light"
retval["modelid"] = "HASS123"
json_state.update({HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS]})
elif not config.lights_all_dimmable:
# On/Off light (ZigBee Device ID: 0x0000)
# Supports groups, scenes and on/off control
retval["type"] = "On/Off light"
retval["productname"] = "On/Off light"
retval["modelid"] = "HASS321"
else:
# Dimmable light (Zigbee Device ID: 0x0100)
# Supports groups, scenes, on/off and dimming
# Reports fixed brightness for compatibility with Alexa.
retval["type"] = "Dimmable light"
retval["modelid"] = "HASS123"
json_state.update({HUE_API_STATE_BRI: HUE_API_STATE_BRI_MAX})
return retval
def state_supports_hue_brightness(
state: State, color_modes: Iterable[ColorMode]
) -> bool:
"""Return True if the state supports brightness."""
domain = state.domain
if domain == light.DOMAIN:
return light.brightness_supported(color_modes)
if not (required_feature := DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN.get(domain)):
return False
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
enum = ENTITY_FEATURES_BY_DOMAIN[domain]
features = enum(features) if type(features) is int else features # noqa: E721
return required_feature in features
def create_hue_success_response(
entity_number: str, attr: str, value: str
) -> dict[str, Any]:
"""Create a success response for an attribute set on a light."""
success_key = f"/lights/{entity_number}/state/{attr}"
return {"success": {success_key: value}}
def create_config_model(config: Config, request: web.Request) -> dict[str, Any]:
"""Create a config resource."""
return {
"name": "HASS BRIDGE",
"mac": "00:00:00:00:00:00",
"swversion": "01003542",
"apiversion": "1.17.0",
"whitelist": {HUE_API_USERNAME: {"name": "HASS BRIDGE"}},
"ipaddress": f"{config.advertise_ip}:{config.advertise_port}",
"linkbutton": True,
}
def create_list_of_entities(config: Config, request: web.Request) -> dict[str, Any]:
"""Create a list of all entities."""
hass = request.app[KEY_HASS]
return {
config.entity_id_to_number(entity_id): state_to_json(config, state)
for entity_id in config.get_exposed_entity_ids()
if (state := hass.states.get(entity_id))
}
def hue_brightness_to_hass(value: int) -> int:
"""Convert hue brightness 1..254 to hass format 0..255."""
return min(255, round((value / HUE_API_STATE_BRI_MAX) * 255))
def hass_to_hue_brightness(value: int) -> int:
"""Convert hass brightness 0..255 to hue 1..254 scale."""
return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX))
def _hass_to_hue_state(entity: State) -> bool:
"""Convert hass entity states to simple True/False on/off state for Hue."""
return entity.state != _OFF_STATES.get(entity.domain, STATE_OFF)
async def wait_for_state_change_or_timeout(
hass: core.HomeAssistant, entity_id: str, timeout: float
) -> None:
"""Wait for an entity to change state."""
ev = asyncio.Event()
@core.callback
def _async_event_changed(event: Event[EventStateChangedData]) -> None:
ev.set()
unsub = async_track_state_change_event(hass, [entity_id], _async_event_changed)
try:
async with asyncio.timeout(STATE_CHANGE_WAIT_TIMEOUT):
await ev.wait()
except TimeoutError:
pass
finally:
unsub()