mirror of
https://github.com/home-assistant/core.git
synced 2025-07-17 18:27:09 +00:00
Add basic typing to emulated_hue (#72663)
* Add basic typing to emulated_hue * type a few more places * fixes * numbers are always stringified * numbers are always stringified * coverage * drop assert
This commit is contained in:
parent
d603952872
commit
237ef6419b
@ -1,4 +1,6 @@
|
|||||||
"""Support for local control of entities by emulating a Philips Hue bridge."""
|
"""Support for local control of entities by emulating a Philips Hue bridge."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@ -12,10 +14,29 @@ from homeassistant.const import (
|
|||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import storage
|
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
CONF_ADVERTISE_IP,
|
||||||
|
CONF_ADVERTISE_PORT,
|
||||||
|
CONF_ENTITY_HIDDEN,
|
||||||
|
CONF_ENTITY_NAME,
|
||||||
|
CONF_EXPOSE_BY_DEFAULT,
|
||||||
|
CONF_EXPOSED_DOMAINS,
|
||||||
|
CONF_HOST_IP,
|
||||||
|
CONF_LIGHTS_ALL_DIMMABLE,
|
||||||
|
CONF_LISTEN_PORT,
|
||||||
|
CONF_OFF_MAPS_TO_ON_DOMAINS,
|
||||||
|
CONF_UPNP_BIND_MULTICAST,
|
||||||
|
DEFAULT_LIGHTS_ALL_DIMMABLE,
|
||||||
|
DEFAULT_LISTEN_PORT,
|
||||||
|
DEFAULT_TYPE,
|
||||||
|
TYPE_ALEXA,
|
||||||
|
TYPE_GOOGLE,
|
||||||
|
Config,
|
||||||
|
)
|
||||||
|
from .const import DOMAIN
|
||||||
from .hue_api import (
|
from .hue_api import (
|
||||||
HueAllGroupsStateView,
|
HueAllGroupsStateView,
|
||||||
HueAllLightsStateView,
|
HueAllLightsStateView,
|
||||||
@ -27,46 +48,14 @@ from .hue_api import (
|
|||||||
HueUnauthorizedUser,
|
HueUnauthorizedUser,
|
||||||
HueUsernameView,
|
HueUsernameView,
|
||||||
)
|
)
|
||||||
from .upnp import DescriptionXmlView, create_upnp_datagram_endpoint
|
from .upnp import (
|
||||||
|
DescriptionXmlView,
|
||||||
DOMAIN = "emulated_hue"
|
UPNPResponderProtocol,
|
||||||
|
create_upnp_datagram_endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
TYPE_ALEXA = "alexa"
|
|
||||||
TYPE_GOOGLE = "google_home"
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
CONFIG_ENTITY_SCHEMA = vol.Schema(
|
CONFIG_ENTITY_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@ -75,6 +64,7 @@ CONFIG_ENTITY_SCHEMA = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
DOMAIN: vol.Schema(
|
DOMAIN: vol.Schema(
|
||||||
@ -102,8 +92,6 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
ATTR_EMULATED_HUE_NAME = "emulated_hue_name"
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
|
||||||
"""Activate the emulated_hue component."""
|
"""Activate the emulated_hue component."""
|
||||||
@ -140,7 +128,7 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
|
|||||||
config.advertise_ip,
|
config.advertise_ip,
|
||||||
config.advertise_port or config.listen_port,
|
config.advertise_port or config.listen_port,
|
||||||
)
|
)
|
||||||
protocol = None
|
protocol: UPNPResponderProtocol | None = None
|
||||||
|
|
||||||
async def stop_emulated_hue_bridge(event):
|
async def stop_emulated_hue_bridge(event):
|
||||||
"""Stop the emulated hue bridge."""
|
"""Stop the emulated hue bridge."""
|
||||||
@ -161,7 +149,8 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
|
|||||||
nonlocal site
|
nonlocal site
|
||||||
nonlocal runner
|
nonlocal runner
|
||||||
|
|
||||||
_, protocol = await listen
|
transport_protocol = await listen
|
||||||
|
protocol = transport_protocol[1]
|
||||||
|
|
||||||
runner = web.AppRunner(app)
|
runner = web.AppRunner(app)
|
||||||
await runner.setup()
|
await runner.setup()
|
||||||
@ -184,163 +173,3 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
|
|||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
"""Hold configuration variables for the emulated hue bridge."""
|
|
||||||
|
|
||||||
def __init__(self, hass, conf, local_ip):
|
|
||||||
"""Initialize the instance."""
|
|
||||||
self.hass = hass
|
|
||||||
self.type = conf.get(CONF_TYPE)
|
|
||||||
self.numbers = None
|
|
||||||
self.store = None
|
|
||||||
self.cached_states = {}
|
|
||||||
self._exposed_cache = {}
|
|
||||||
|
|
||||||
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 = conf.get(CONF_HOST_IP)
|
|
||||||
if self.host_ip_addr is None:
|
|
||||||
self.host_ip_addr = local_ip
|
|
||||||
|
|
||||||
# Get the port that the Hue bridge will listen on
|
|
||||||
self.listen_port = conf.get(CONF_LISTEN_PORT)
|
|
||||||
if not isinstance(self.listen_port, int):
|
|
||||||
self.listen_port = DEFAULT_LISTEN_PORT
|
|
||||||
_LOGGER.info(
|
|
||||||
"Listen port not specified, defaulting to %s", self.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 = 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
|
|
||||||
self.off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS)
|
|
||||||
if not isinstance(self.off_maps_to_on_domains, list):
|
|
||||||
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 = 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 = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr
|
|
||||||
|
|
||||||
self.advertise_port = conf.get(CONF_ADVERTISE_PORT) or self.listen_port
|
|
||||||
|
|
||||||
self.entities = 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 = conf.get(CONF_LIGHTS_ALL_DIMMABLE)
|
|
||||||
|
|
||||||
async def async_setup(self):
|
|
||||||
"""Set up and migrate to storage."""
|
|
||||||
self.store = storage.Store(self.hass, DATA_VERSION, DATA_KEY)
|
|
||||||
self.numbers = (
|
|
||||||
await storage.async_migrator(
|
|
||||||
self.hass, self.hass.config.path(NUMBERS_FILE), self.store
|
|
||||||
)
|
|
||||||
or {}
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_id_to_number(self, entity_id):
|
|
||||||
"""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
|
|
||||||
self.store.async_delay_save(lambda: self.numbers, SAVE_DELAY)
|
|
||||||
return number
|
|
||||||
|
|
||||||
def number_to_entity_id(self, number):
|
|
||||||
"""Convert unique number to entity id."""
|
|
||||||
if self.type == TYPE_ALEXA:
|
|
||||||
return number
|
|
||||||
|
|
||||||
# Google Home
|
|
||||||
assert isinstance(number, str)
|
|
||||||
return self.numbers.get(number)
|
|
||||||
|
|
||||||
def get_entity_name(self, entity):
|
|
||||||
"""Get the name of an entity."""
|
|
||||||
if (
|
|
||||||
entity.entity_id in self.entities
|
|
||||||
and CONF_ENTITY_NAME in self.entities[entity.entity_id]
|
|
||||||
):
|
|
||||||
return self.entities[entity.entity_id][CONF_ENTITY_NAME]
|
|
||||||
|
|
||||||
return entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name)
|
|
||||||
|
|
||||||
def is_entity_exposed(self, entity):
|
|
||||||
"""Cache determine if an entity should be exposed on the emulated bridge."""
|
|
||||||
entity_id = entity.entity_id
|
|
||||||
if entity_id not in self._exposed_cache:
|
|
||||||
self._exposed_cache[entity_id] = self._is_entity_exposed(entity)
|
|
||||||
return self._exposed_cache[entity_id]
|
|
||||||
|
|
||||||
def filter_exposed_entities(self, states):
|
|
||||||
"""Filter a list of all states down to exposed entities."""
|
|
||||||
exposed = []
|
|
||||||
for entity in states:
|
|
||||||
entity_id = entity.entity_id
|
|
||||||
if entity_id not in self._exposed_cache:
|
|
||||||
self._exposed_cache[entity_id] = self._is_entity_exposed(entity)
|
|
||||||
if self._exposed_cache[entity_id]:
|
|
||||||
exposed.append(entity)
|
|
||||||
return exposed
|
|
||||||
|
|
||||||
def _is_entity_exposed(self, entity):
|
|
||||||
"""Determine if an entity should be exposed on the emulated bridge.
|
|
||||||
|
|
||||||
Async friendly.
|
|
||||||
"""
|
|
||||||
if entity.attributes.get("view") is not None:
|
|
||||||
# Ignore entities that are views
|
|
||||||
return False
|
|
||||||
|
|
||||||
if entity.entity_id in self._entities_with_hidden_attr_in_config:
|
|
||||||
return not self._entities_with_hidden_attr_in_config[entity.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 entity.domain in self.exposed_domains:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
213
homeassistant/components/emulated_hue/config.py
Normal file
213
homeassistant/components/emulated_hue/config.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
"""Support for local control of entities by emulating a Philips Hue bridge."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_ENTITIES, CONF_TYPE
|
||||||
|
from homeassistant.core import HomeAssistant, State
|
||||||
|
from homeassistant.helpers import storage
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
|
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
|
||||||
|
) -> 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 = conf.get(CONF_HOST_IP)
|
||||||
|
if self.host_ip_addr is None:
|
||||||
|
self.host_ip_addr = local_ip
|
||||||
|
|
||||||
|
# Get the port that the Hue bridge will listen on
|
||||||
|
self.listen_port = conf.get(CONF_LISTEN_PORT)
|
||||||
|
if not isinstance(self.listen_port, int):
|
||||||
|
self.listen_port = DEFAULT_LISTEN_PORT
|
||||||
|
_LOGGER.info(
|
||||||
|
"Listen port not specified, defaulting to %s", self.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 = 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 = 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 = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr
|
||||||
|
|
||||||
|
self.advertise_port = conf.get(CONF_ADVERTISE_PORT) or self.listen_port
|
||||||
|
|
||||||
|
self.entities = 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 = conf.get(CONF_LIGHTS_ALL_DIMMABLE)
|
||||||
|
|
||||||
|
async def async_setup(self) -> None:
|
||||||
|
"""Set up and migrate to storage."""
|
||||||
|
self.store = storage.Store(self.hass, DATA_VERSION, DATA_KEY) # type: ignore[arg-type]
|
||||||
|
self.numbers = (
|
||||||
|
await storage.async_migrator(
|
||||||
|
self.hass, self.hass.config.path(NUMBERS_FILE), self.store
|
||||||
|
)
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
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, entity: State) -> str:
|
||||||
|
"""Get the name of an entity."""
|
||||||
|
if (
|
||||||
|
entity.entity_id in self.entities
|
||||||
|
and CONF_ENTITY_NAME in self.entities[entity.entity_id]
|
||||||
|
):
|
||||||
|
return self.entities[entity.entity_id][CONF_ENTITY_NAME]
|
||||||
|
|
||||||
|
return entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name)
|
||||||
|
|
||||||
|
def is_entity_exposed(self, entity: State) -> bool:
|
||||||
|
"""Cache determine if an entity should be exposed on the emulated bridge."""
|
||||||
|
if (exposed := self._exposed_cache.get(entity.entity_id)) is not None:
|
||||||
|
return exposed
|
||||||
|
exposed = self._is_entity_exposed(entity)
|
||||||
|
self._exposed_cache[entity.entity_id] = exposed
|
||||||
|
return exposed
|
||||||
|
|
||||||
|
def filter_exposed_entities(self, states: Iterable[State]) -> list[State]:
|
||||||
|
"""Filter a list of all states down to exposed entities."""
|
||||||
|
exposed: list[State] = [
|
||||||
|
state for state in states if self.is_entity_exposed(state)
|
||||||
|
]
|
||||||
|
return exposed
|
||||||
|
|
||||||
|
def _is_entity_exposed(self, entity: State) -> bool:
|
||||||
|
"""Determine if an entity should be exposed on the emulated bridge.
|
||||||
|
|
||||||
|
Async friendly.
|
||||||
|
"""
|
||||||
|
if entity.attributes.get("view") is not None:
|
||||||
|
# Ignore entities that are views
|
||||||
|
return False
|
||||||
|
|
||||||
|
if entity.entity_id in self._entities_with_hidden_attr_in_config:
|
||||||
|
return not self._entities_with_hidden_attr_in_config[entity.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 entity.domain in self.exposed_domains:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
@ -2,3 +2,5 @@
|
|||||||
|
|
||||||
HUE_SERIAL_NUMBER = "001788FFFE23BFC2"
|
HUE_SERIAL_NUMBER = "001788FFFE23BFC2"
|
||||||
HUE_UUID = "2f402f80-da50-11e1-9b23-001788255acc"
|
HUE_UUID = "2f402f80-da50-11e1-9b23-001788255acc"
|
||||||
|
|
||||||
|
DOMAIN = "emulated_hue"
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
"""Support for a Hue API to control Home Assistant."""
|
"""Support for a Hue API to control Home Assistant."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
from homeassistant import core
|
from homeassistant import core
|
||||||
from homeassistant.components import (
|
from homeassistant.components import (
|
||||||
@ -58,9 +63,12 @@ from homeassistant.const import (
|
|||||||
STATE_ON,
|
STATE_ON,
|
||||||
STATE_UNAVAILABLE,
|
STATE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
|
from homeassistant.core import State
|
||||||
from homeassistant.helpers.event import async_track_state_change_event
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
from homeassistant.util.network import is_local
|
from homeassistant.util.network import is_local
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# How long to wait for a state change to happen
|
# How long to wait for a state change to happen
|
||||||
@ -111,7 +119,7 @@ class HueUnauthorizedUser(HomeAssistantView):
|
|||||||
extra_urls = ["/api/"]
|
extra_urls = ["/api/"]
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
async def get(self, request):
|
async def get(self, request: web.Request) -> web.Response:
|
||||||
"""Handle a GET request."""
|
"""Handle a GET request."""
|
||||||
return self.json(UNAUTHORIZED_USER)
|
return self.json(UNAUTHORIZED_USER)
|
||||||
|
|
||||||
@ -124,8 +132,9 @@ class HueUsernameView(HomeAssistantView):
|
|||||||
extra_urls = ["/api/"]
|
extra_urls = ["/api/"]
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
async def post(self, request):
|
async def post(self, request: web.Request) -> web.Response:
|
||||||
"""Handle a POST request."""
|
"""Handle a POST request."""
|
||||||
|
assert request.remote is not None
|
||||||
if not is_local(ip_address(request.remote)):
|
if not is_local(ip_address(request.remote)):
|
||||||
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
@ -147,13 +156,14 @@ class HueAllGroupsStateView(HomeAssistantView):
|
|||||||
name = "emulated_hue:all_groups:state"
|
name = "emulated_hue:all_groups:state"
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config: Config) -> None:
|
||||||
"""Initialize the instance of the view."""
|
"""Initialize the instance of the view."""
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
@core.callback
|
@core.callback
|
||||||
def get(self, request, username):
|
def get(self, request: web.Request, username: str) -> web.Response:
|
||||||
"""Process a request to make the Brilliant Lightpad work."""
|
"""Process a request to make the Brilliant Lightpad work."""
|
||||||
|
assert request.remote is not None
|
||||||
if not is_local(ip_address(request.remote)):
|
if not is_local(ip_address(request.remote)):
|
||||||
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
@ -167,13 +177,14 @@ class HueGroupView(HomeAssistantView):
|
|||||||
name = "emulated_hue:groups:state"
|
name = "emulated_hue:groups:state"
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config: Config) -> None:
|
||||||
"""Initialize the instance of the view."""
|
"""Initialize the instance of the view."""
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
@core.callback
|
@core.callback
|
||||||
def put(self, request, username):
|
def put(self, request: web.Request, username: str) -> web.Response:
|
||||||
"""Process a request to make the Logitech Pop working."""
|
"""Process a request to make the Logitech Pop working."""
|
||||||
|
assert request.remote is not None
|
||||||
if not is_local(ip_address(request.remote)):
|
if not is_local(ip_address(request.remote)):
|
||||||
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
@ -197,13 +208,14 @@ class HueAllLightsStateView(HomeAssistantView):
|
|||||||
name = "emulated_hue:lights:state"
|
name = "emulated_hue:lights:state"
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config: Config) -> None:
|
||||||
"""Initialize the instance of the view."""
|
"""Initialize the instance of the view."""
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
@core.callback
|
@core.callback
|
||||||
def get(self, request, username):
|
def get(self, request: web.Request, username: str) -> web.Response:
|
||||||
"""Process a request to get the list of available lights."""
|
"""Process a request to get the list of available lights."""
|
||||||
|
assert request.remote is not None
|
||||||
if not is_local(ip_address(request.remote)):
|
if not is_local(ip_address(request.remote)):
|
||||||
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
@ -217,13 +229,14 @@ class HueFullStateView(HomeAssistantView):
|
|||||||
name = "emulated_hue:username:state"
|
name = "emulated_hue:username:state"
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config: Config) -> None:
|
||||||
"""Initialize the instance of the view."""
|
"""Initialize the instance of the view."""
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
@core.callback
|
@core.callback
|
||||||
def get(self, request, username):
|
def get(self, request: web.Request, username: str) -> web.Response:
|
||||||
"""Process a request to get the list of available lights."""
|
"""Process a request to get the list of available lights."""
|
||||||
|
assert request.remote is not None
|
||||||
if not is_local(ip_address(request.remote)):
|
if not is_local(ip_address(request.remote)):
|
||||||
return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
||||||
if username != HUE_API_USERNAME:
|
if username != HUE_API_USERNAME:
|
||||||
@ -245,13 +258,14 @@ class HueConfigView(HomeAssistantView):
|
|||||||
name = "emulated_hue:username:config"
|
name = "emulated_hue:username:config"
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config: Config) -> None:
|
||||||
"""Initialize the instance of the view."""
|
"""Initialize the instance of the view."""
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
@core.callback
|
@core.callback
|
||||||
def get(self, request, username=""):
|
def get(self, request: web.Request, username: str = "") -> web.Response:
|
||||||
"""Process a request to get the configuration."""
|
"""Process a request to get the configuration."""
|
||||||
|
assert request.remote is not None
|
||||||
if not is_local(ip_address(request.remote)):
|
if not is_local(ip_address(request.remote)):
|
||||||
return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
@ -267,17 +281,18 @@ class HueOneLightStateView(HomeAssistantView):
|
|||||||
name = "emulated_hue:light:state"
|
name = "emulated_hue:light:state"
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config: Config) -> None:
|
||||||
"""Initialize the instance of the view."""
|
"""Initialize the instance of the view."""
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
@core.callback
|
@core.callback
|
||||||
def get(self, request, username, entity_id):
|
def get(self, request: web.Request, username: str, entity_id: str) -> web.Response:
|
||||||
"""Process a request to get the state of an individual light."""
|
"""Process a request to get the state of an individual light."""
|
||||||
|
assert request.remote is not None
|
||||||
if not is_local(ip_address(request.remote)):
|
if not is_local(ip_address(request.remote)):
|
||||||
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
hass = request.app["hass"]
|
hass: core.HomeAssistant = request.app["hass"]
|
||||||
hass_entity_id = self.config.number_to_entity_id(entity_id)
|
hass_entity_id = self.config.number_to_entity_id(entity_id)
|
||||||
|
|
||||||
if hass_entity_id is None:
|
if hass_entity_id is None:
|
||||||
@ -307,17 +322,20 @@ class HueOneLightChangeView(HomeAssistantView):
|
|||||||
name = "emulated_hue:light:state"
|
name = "emulated_hue:light:state"
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config: Config) -> None:
|
||||||
"""Initialize the instance of the view."""
|
"""Initialize the instance of the view."""
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
async def put(self, request, username, entity_number): # noqa: C901
|
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."""
|
"""Process a request to set the state of an individual light."""
|
||||||
|
assert request.remote is not None
|
||||||
if not is_local(ip_address(request.remote)):
|
if not is_local(ip_address(request.remote)):
|
||||||
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
config = self.config
|
config = self.config
|
||||||
hass = request.app["hass"]
|
hass: core.HomeAssistant = request.app["hass"]
|
||||||
entity_id = config.number_to_entity_id(entity_number)
|
entity_id = config.number_to_entity_id(entity_number)
|
||||||
|
|
||||||
if entity_id is None:
|
if entity_id is None:
|
||||||
@ -344,7 +362,7 @@ class HueOneLightChangeView(HomeAssistantView):
|
|||||||
color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])
|
color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])
|
||||||
|
|
||||||
# Parse the request
|
# Parse the request
|
||||||
parsed = {
|
parsed: dict[str, Any] = {
|
||||||
STATE_ON: False,
|
STATE_ON: False,
|
||||||
STATE_BRIGHTNESS: None,
|
STATE_BRIGHTNESS: None,
|
||||||
STATE_HUE: None,
|
STATE_HUE: None,
|
||||||
@ -416,10 +434,10 @@ class HueOneLightChangeView(HomeAssistantView):
|
|||||||
turn_on_needed = False
|
turn_on_needed = False
|
||||||
|
|
||||||
# Convert the resulting "on" status into the service we need to call
|
# Convert the resulting "on" status into the service we need to call
|
||||||
service = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF
|
service: str | None = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF
|
||||||
|
|
||||||
# Construct what we need to send to the service
|
# Construct what we need to send to the service
|
||||||
data = {ATTR_ENTITY_ID: entity_id}
|
data: dict[str, Any] = {ATTR_ENTITY_ID: entity_id}
|
||||||
|
|
||||||
# If the requested entity is a light, set the brightness, hue,
|
# If the requested entity is a light, set the brightness, hue,
|
||||||
# saturation and color temp
|
# saturation and color temp
|
||||||
@ -596,7 +614,7 @@ class HueOneLightChangeView(HomeAssistantView):
|
|||||||
return self.json(json_response)
|
return self.json(json_response)
|
||||||
|
|
||||||
|
|
||||||
def get_entity_state(config, entity):
|
def get_entity_state(config: Config, entity: State) -> dict[str, Any]:
|
||||||
"""Retrieve and convert state and brightness values for an entity."""
|
"""Retrieve and convert state and brightness values for an entity."""
|
||||||
cached_state_entry = config.cached_states.get(entity.entity_id, None)
|
cached_state_entry = config.cached_states.get(entity.entity_id, None)
|
||||||
cached_state = None
|
cached_state = None
|
||||||
@ -617,7 +635,7 @@ def get_entity_state(config, entity):
|
|||||||
# Remove the now stale cached entry.
|
# Remove the now stale cached entry.
|
||||||
config.cached_states.pop(entity.entity_id)
|
config.cached_states.pop(entity.entity_id)
|
||||||
|
|
||||||
data = {
|
data: dict[str, Any] = {
|
||||||
STATE_ON: False,
|
STATE_ON: False,
|
||||||
STATE_BRIGHTNESS: None,
|
STATE_BRIGHTNESS: None,
|
||||||
STATE_HUE: None,
|
STATE_HUE: None,
|
||||||
@ -700,7 +718,7 @@ def get_entity_state(config, entity):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def entity_to_json(config, entity):
|
def entity_to_json(config: Config, entity: State) -> dict[str, Any]:
|
||||||
"""Convert an entity to its Hue bridge JSON representation."""
|
"""Convert an entity to its Hue bridge JSON representation."""
|
||||||
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||||
color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])
|
color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])
|
||||||
@ -709,7 +727,7 @@ def entity_to_json(config, entity):
|
|||||||
|
|
||||||
state = get_entity_state(config, entity)
|
state = get_entity_state(config, entity)
|
||||||
|
|
||||||
retval = {
|
retval: dict[str, Any] = {
|
||||||
"state": {
|
"state": {
|
||||||
HUE_API_STATE_ON: state[STATE_ON],
|
HUE_API_STATE_ON: state[STATE_ON],
|
||||||
"reachable": entity.state != STATE_UNAVAILABLE,
|
"reachable": entity.state != STATE_UNAVAILABLE,
|
||||||
@ -793,13 +811,15 @@ def entity_to_json(config, entity):
|
|||||||
return retval
|
return retval
|
||||||
|
|
||||||
|
|
||||||
def create_hue_success_response(entity_number, attr, value):
|
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."""
|
"""Create a success response for an attribute set on a light."""
|
||||||
success_key = f"/lights/{entity_number}/state/{attr}"
|
success_key = f"/lights/{entity_number}/state/{attr}"
|
||||||
return {"success": {success_key: value}}
|
return {"success": {success_key: value}}
|
||||||
|
|
||||||
|
|
||||||
def create_config_model(config, request):
|
def create_config_model(config: Config, request: web.Request) -> dict[str, Any]:
|
||||||
"""Create a config resource."""
|
"""Create a config resource."""
|
||||||
return {
|
return {
|
||||||
"mac": "00:00:00:00:00:00",
|
"mac": "00:00:00:00:00:00",
|
||||||
@ -811,29 +831,29 @@ def create_config_model(config, request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_list_of_entities(config, request):
|
def create_list_of_entities(config: Config, request: web.Request) -> dict[str, Any]:
|
||||||
"""Create a list of all entities."""
|
"""Create a list of all entities."""
|
||||||
hass = request.app["hass"]
|
hass: core.HomeAssistant = request.app["hass"]
|
||||||
json_response = {}
|
json_response: dict[str, Any] = {
|
||||||
|
config.entity_id_to_number(entity.entity_id): entity_to_json(config, entity)
|
||||||
for entity in config.filter_exposed_entities(hass.states.async_all()):
|
for entity in config.filter_exposed_entities(hass.states.async_all())
|
||||||
number = config.entity_id_to_number(entity.entity_id)
|
}
|
||||||
json_response[number] = entity_to_json(config, entity)
|
|
||||||
|
|
||||||
return json_response
|
return json_response
|
||||||
|
|
||||||
|
|
||||||
def hue_brightness_to_hass(value):
|
def hue_brightness_to_hass(value: int) -> int:
|
||||||
"""Convert hue brightness 1..254 to hass format 0..255."""
|
"""Convert hue brightness 1..254 to hass format 0..255."""
|
||||||
return min(255, round((value / HUE_API_STATE_BRI_MAX) * 255))
|
return min(255, round((value / HUE_API_STATE_BRI_MAX) * 255))
|
||||||
|
|
||||||
|
|
||||||
def hass_to_hue_brightness(value):
|
def hass_to_hue_brightness(value: int) -> int:
|
||||||
"""Convert hass brightness 0..255 to hue 1..254 scale."""
|
"""Convert hass brightness 0..255 to hue 1..254 scale."""
|
||||||
return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX))
|
return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX))
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_state_change_or_timeout(hass, entity_id, timeout):
|
async def wait_for_state_change_or_timeout(
|
||||||
|
hass: core.HomeAssistant, entity_id: str, timeout: float
|
||||||
|
) -> None:
|
||||||
"""Wait for an entity to change state."""
|
"""Wait for an entity to change state."""
|
||||||
ev = asyncio.Event()
|
ev = asyncio.Event()
|
||||||
|
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
"""Test the Emulated Hue component."""
|
"""Test the Emulated Hue component."""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from homeassistant.components.emulated_hue import (
|
from homeassistant.components.emulated_hue.config import (
|
||||||
DATA_KEY,
|
DATA_KEY,
|
||||||
DATA_VERSION,
|
DATA_VERSION,
|
||||||
SAVE_DELAY,
|
SAVE_DELAY,
|
||||||
Config,
|
Config,
|
||||||
)
|
)
|
||||||
|
from homeassistant.const import EVENT_HOMEASSISTANT_START
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
from homeassistant.util import utcnow
|
from homeassistant.util import utcnow
|
||||||
|
|
||||||
@ -121,6 +122,13 @@ async def test_setup_works(hass):
|
|||||||
"""Test setup works."""
|
"""Test setup works."""
|
||||||
hass.config.components.add("network")
|
hass.config.components.add("network")
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.emulated_hue.create_upnp_datagram_endpoint"
|
"homeassistant.components.emulated_hue.create_upnp_datagram_endpoint",
|
||||||
), patch("homeassistant.components.emulated_hue.async_get_source_ip"):
|
AsyncMock(),
|
||||||
|
) as mock_create_upnp_datagram_endpoint, patch(
|
||||||
|
"homeassistant.components.emulated_hue.async_get_source_ip"
|
||||||
|
):
|
||||||
assert await async_setup_component(hass, "emulated_hue", {})
|
assert await async_setup_component(hass, "emulated_hue", {})
|
||||||
|
|
||||||
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mock_create_upnp_datagram_endpoint.mock_calls) == 2
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import defusedxml.ElementTree as ET
|
import defusedxml.ElementTree as ET
|
||||||
@ -52,11 +53,13 @@ def hue_client(aiohttp_client):
|
|||||||
|
|
||||||
async def setup_hue(hass):
|
async def setup_hue(hass):
|
||||||
"""Set up the emulated_hue integration."""
|
"""Set up the emulated_hue integration."""
|
||||||
assert await setup.async_setup_component(
|
with patch("homeassistant.components.emulated_hue.create_upnp_datagram_endpoint"):
|
||||||
hass,
|
assert await setup.async_setup_component(
|
||||||
emulated_hue.DOMAIN,
|
hass,
|
||||||
{emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}},
|
emulated_hue.DOMAIN,
|
||||||
)
|
{emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
|
||||||
def test_upnp_discovery_basic():
|
def test_upnp_discovery_basic():
|
||||||
|
Loading…
x
Reference in New Issue
Block a user