mirror of https://github.com/home-assistant/core
266 lines
8.8 KiB
Python
266 lines
8.8 KiB
Python
"""Support for local control of entities by emulating a Philips Hue bridge."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from functools import cache
|
|
import logging
|
|
|
|
from homeassistant.components import (
|
|
climate,
|
|
cover,
|
|
fan,
|
|
humidifier,
|
|
light,
|
|
media_player,
|
|
scene,
|
|
script,
|
|
)
|
|
from homeassistant.const import CONF_ENTITIES, CONF_TYPE
|
|
from homeassistant.core import (
|
|
Event,
|
|
EventStateChangedData,
|
|
HomeAssistant,
|
|
State,
|
|
callback,
|
|
split_entity_id,
|
|
)
|
|
from homeassistant.helpers import storage
|
|
from homeassistant.helpers.event import (
|
|
async_track_state_added_domain,
|
|
async_track_state_removed_domain,
|
|
)
|
|
from homeassistant.helpers.typing import ConfigType
|
|
|
|
SUPPORTED_DOMAINS = {
|
|
climate.DOMAIN,
|
|
cover.DOMAIN,
|
|
fan.DOMAIN,
|
|
humidifier.DOMAIN,
|
|
light.DOMAIN,
|
|
media_player.DOMAIN,
|
|
scene.DOMAIN,
|
|
script.DOMAIN,
|
|
}
|
|
|
|
|
|
TYPE_ALEXA = "alexa"
|
|
TYPE_GOOGLE = "google_home"
|
|
|
|
|
|
NUMBERS_FILE = "emulated_hue_ids.json"
|
|
DATA_KEY = "emulated_hue.ids"
|
|
DATA_VERSION = "1"
|
|
SAVE_DELAY = 60
|
|
|
|
CONF_ADVERTISE_IP = "advertise_ip"
|
|
CONF_ADVERTISE_PORT = "advertise_port"
|
|
CONF_ENTITY_HIDDEN = "hidden"
|
|
CONF_ENTITY_NAME = "name"
|
|
CONF_EXPOSE_BY_DEFAULT = "expose_by_default"
|
|
CONF_EXPOSED_DOMAINS = "exposed_domains"
|
|
CONF_HOST_IP = "host_ip"
|
|
CONF_LIGHTS_ALL_DIMMABLE = "lights_all_dimmable"
|
|
CONF_LISTEN_PORT = "listen_port"
|
|
CONF_OFF_MAPS_TO_ON_DOMAINS = "off_maps_to_on_domains"
|
|
CONF_UPNP_BIND_MULTICAST = "upnp_bind_multicast"
|
|
|
|
|
|
DEFAULT_LIGHTS_ALL_DIMMABLE = False
|
|
DEFAULT_LISTEN_PORT = 8300
|
|
DEFAULT_UPNP_BIND_MULTICAST = True
|
|
DEFAULT_OFF_MAPS_TO_ON_DOMAINS = {"script", "scene"}
|
|
DEFAULT_EXPOSE_BY_DEFAULT = True
|
|
DEFAULT_EXPOSED_DOMAINS = [
|
|
"switch",
|
|
"light",
|
|
"group",
|
|
"input_boolean",
|
|
"media_player",
|
|
"fan",
|
|
]
|
|
DEFAULT_TYPE = TYPE_GOOGLE
|
|
|
|
ATTR_EMULATED_HUE_NAME = "emulated_hue_name"
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class Config:
|
|
"""Hold configuration variables for the emulated hue bridge."""
|
|
|
|
def __init__(self, hass: HomeAssistant, conf: ConfigType, local_ip: str) -> None:
|
|
"""Initialize the instance."""
|
|
self.hass = hass
|
|
self.type = conf.get(CONF_TYPE)
|
|
self.numbers: dict[str, str] = {}
|
|
self.store: storage.Store | None = None
|
|
self.cached_states: dict[str, list] = {}
|
|
self._exposed_cache: dict[str, bool] = {}
|
|
|
|
if self.type == TYPE_ALEXA:
|
|
_LOGGER.warning(
|
|
"Emulated Hue running in legacy mode because type has been "
|
|
"specified. More info at https://goo.gl/M6tgz8"
|
|
)
|
|
|
|
# Get the IP address that will be passed to the Echo during discovery
|
|
self.host_ip_addr: str = conf.get(CONF_HOST_IP) or local_ip
|
|
|
|
# Get the port that the Hue bridge will listen on
|
|
self.listen_port: int = conf.get(CONF_LISTEN_PORT) or DEFAULT_LISTEN_PORT
|
|
|
|
# Get whether or not UPNP binds to multicast address (239.255.255.250)
|
|
# or to the unicast address (host_ip_addr)
|
|
self.upnp_bind_multicast: bool = conf.get(
|
|
CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST
|
|
)
|
|
|
|
# Get domains that cause both "on" and "off" commands to map to "on"
|
|
# This is primarily useful for things like scenes or scripts, which
|
|
# don't really have a concept of being off
|
|
off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS)
|
|
if isinstance(off_maps_to_on_domains, list):
|
|
self.off_maps_to_on_domains = set(off_maps_to_on_domains)
|
|
else:
|
|
self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS
|
|
|
|
# Get whether or not entities should be exposed by default, or if only
|
|
# explicitly marked ones will be exposed
|
|
self.expose_by_default: bool = conf.get(
|
|
CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT
|
|
)
|
|
|
|
# Get domains that are exposed by default when expose_by_default is
|
|
# True
|
|
self.exposed_domains = set(
|
|
conf.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS)
|
|
)
|
|
|
|
# Calculated effective advertised IP and port for network isolation
|
|
self.advertise_ip: str = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr
|
|
|
|
self.advertise_port: int = conf.get(CONF_ADVERTISE_PORT) or self.listen_port
|
|
|
|
self.entities: dict[str, dict[str, str]] = conf.get(CONF_ENTITIES, {})
|
|
|
|
self._entities_with_hidden_attr_in_config = {}
|
|
for entity_id in self.entities:
|
|
hidden_value = self.entities[entity_id].get(CONF_ENTITY_HIDDEN)
|
|
if hidden_value is not None:
|
|
self._entities_with_hidden_attr_in_config[entity_id] = hidden_value
|
|
|
|
# Get whether all non-dimmable lights should be reported as dimmable
|
|
# for compatibility with older installations.
|
|
self.lights_all_dimmable: bool = conf.get(CONF_LIGHTS_ALL_DIMMABLE) or False
|
|
|
|
if self.expose_by_default:
|
|
self.track_domains = set(self.exposed_domains) or SUPPORTED_DOMAINS
|
|
else:
|
|
self.track_domains = {
|
|
split_entity_id(entity_id)[0] for entity_id in self.entities
|
|
}
|
|
|
|
async def async_setup(self) -> None:
|
|
"""Set up tracking and migrate to storage."""
|
|
hass = self.hass
|
|
self.store = storage.Store(hass, DATA_VERSION, DATA_KEY) # type: ignore[arg-type]
|
|
numbers_path = hass.config.path(NUMBERS_FILE)
|
|
self.numbers = (
|
|
await storage.async_migrator(hass, numbers_path, self.store) or {}
|
|
)
|
|
async_track_state_added_domain(
|
|
hass, self.track_domains, self._clear_exposed_cache
|
|
)
|
|
async_track_state_removed_domain(
|
|
hass, self.track_domains, self._clear_exposed_cache
|
|
)
|
|
|
|
@cache # pylint: disable=method-cache-max-size-none
|
|
def entity_id_to_number(self, entity_id: str) -> str:
|
|
"""Get a unique number for the entity id."""
|
|
if self.type == TYPE_ALEXA:
|
|
return entity_id
|
|
|
|
# Google Home
|
|
for number, ent_id in self.numbers.items():
|
|
if entity_id == ent_id:
|
|
return number
|
|
|
|
number = "1"
|
|
if self.numbers:
|
|
number = str(max(int(k) for k in self.numbers) + 1)
|
|
self.numbers[number] = entity_id
|
|
assert self.store is not None
|
|
self.store.async_delay_save(lambda: self.numbers, SAVE_DELAY)
|
|
return number
|
|
|
|
def number_to_entity_id(self, number: str) -> str | None:
|
|
"""Convert unique number to entity id."""
|
|
if self.type == TYPE_ALEXA:
|
|
return number
|
|
|
|
# Google Home
|
|
return self.numbers.get(number)
|
|
|
|
def get_entity_name(self, state: State) -> str:
|
|
"""Get the name of an entity."""
|
|
if (
|
|
state.entity_id in self.entities
|
|
and CONF_ENTITY_NAME in self.entities[state.entity_id]
|
|
):
|
|
return self.entities[state.entity_id][CONF_ENTITY_NAME]
|
|
|
|
return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name) # type: ignore[no-any-return]
|
|
|
|
@cache # pylint: disable=method-cache-max-size-none
|
|
def get_exposed_entity_ids(self) -> list[str]:
|
|
"""Return a list of exposed states."""
|
|
state_machine = self.hass.states
|
|
if self.expose_by_default:
|
|
return [
|
|
state.entity_id
|
|
for state in state_machine.async_all()
|
|
if self.is_state_exposed(state)
|
|
]
|
|
return [
|
|
entity_id
|
|
for entity_id in self.entities
|
|
if (state := state_machine.get(entity_id)) and self.is_state_exposed(state)
|
|
]
|
|
|
|
@callback
|
|
def _clear_exposed_cache(self, event: Event[EventStateChangedData]) -> None:
|
|
"""Clear the cache of exposed entity ids."""
|
|
self.get_exposed_entity_ids.cache_clear()
|
|
|
|
def is_state_exposed(self, state: State) -> bool:
|
|
"""Cache determine if an entity should be exposed on the emulated bridge."""
|
|
if (exposed := self._exposed_cache.get(state.entity_id)) is not None:
|
|
return exposed
|
|
exposed = self._is_state_exposed(state)
|
|
self._exposed_cache[state.entity_id] = exposed
|
|
return exposed
|
|
|
|
def _is_state_exposed(self, state: State) -> bool:
|
|
"""Determine if an entity state should be exposed on the emulated bridge.
|
|
|
|
Async friendly.
|
|
"""
|
|
if state.attributes.get("view") is not None:
|
|
# Ignore entities that are views
|
|
return False
|
|
|
|
if state.entity_id in self._entities_with_hidden_attr_in_config:
|
|
return not self._entities_with_hidden_attr_in_config[state.entity_id]
|
|
|
|
if not self.expose_by_default:
|
|
return False
|
|
# Expose an entity if the entity's domain is exposed by default and
|
|
# the configuration doesn't explicitly exclude it from being
|
|
# exposed, or if the entity is explicitly exposed
|
|
if state.domain in self.exposed_domains:
|
|
return True
|
|
|
|
return False
|