Cache which entities are exposed in emulated_hue (#73093)

pull/73322/head
J. Nick Koston 2022-06-09 21:17:37 -10:00 committed by GitHub
parent 7a5fa8eb58
commit f4d339119f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 209 additions and 126 deletions

View File

@ -1,14 +1,40 @@
"""Support for local control of entities by emulating a Philips Hue bridge."""
from __future__ import annotations
from collections.abc import Iterable
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 HomeAssistant, State
from homeassistant.core import Event, 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"
@ -78,7 +104,7 @@ class Config:
# 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(
self.upnp_bind_multicast: bool = conf.get(
CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST
)
@ -93,7 +119,7 @@ class Config:
# 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(
self.expose_by_default: bool = conf.get(
CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT
)
@ -118,18 +144,31 @@ class Config:
# 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)
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 and migrate to storage."""
self.store = storage.Store(self.hass, DATA_VERSION, DATA_KEY) # type: ignore[arg-type]
"""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(
self.hass, self.hass.config.path(NUMBERS_FILE), self.store
)
or {}
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:
@ -166,6 +205,27 @@ class Config:
return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name)
@cache # pylint: disable=method-cache-max-size-none
def get_exposed_states(self) -> list[State]:
"""Return a list of exposed states."""
state_machine = self.hass.states
if self.expose_by_default:
return [
state
for state in state_machine.async_all()
if self.is_state_exposed(state)
]
states: list[State] = []
for entity_id in self.entities:
if (state := state_machine.get(entity_id)) and self.is_state_exposed(state):
states.append(state)
return states
@callback
def _clear_exposed_cache(self, event: Event) -> None:
"""Clear the cache of exposed states."""
self.get_exposed_states.cache_clear() # pylint: disable=no-member
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:
@ -174,13 +234,6 @@ class Config:
self._exposed_cache[state.entity_id] = exposed
return exposed
def filter_exposed_states(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_state_exposed(state)
]
return exposed
def _is_state_exposed(self, state: State) -> bool:
"""Determine if an entity state should be exposed on the emulated bridge.

View File

@ -844,10 +844,9 @@ def create_config_model(config: Config, request: web.Request) -> dict[str, Any]:
def create_list_of_entities(config: Config, request: web.Request) -> dict[str, Any]:
"""Create a list of all entities."""
hass: core.HomeAssistant = request.app["hass"]
json_response: dict[str, Any] = {
config.entity_id_to_number(entity.entity_id): state_to_json(config, entity)
for entity in config.filter_exposed_states(hass.states.async_all())
config.entity_id_to_number(state.entity_id): state_to_json(config, state)
for state in config.get_exposed_states()
}
return json_response

View File

@ -49,7 +49,8 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
)
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.typing import ConfigType
import homeassistant.util.dt as dt_util
from tests.common import (
@ -96,41 +97,58 @@ ENTITY_IDS_BY_NUMBER = {
ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()}
@pytest.fixture
def hass_hue(loop, hass):
"""Set up a Home Assistant instance for these tests."""
# We need to do this to get access to homeassistant/turn_(on,off)
loop.run_until_complete(setup.async_setup_component(hass, "homeassistant", {}))
def patch_upnp():
"""Patch async_create_upnp_datagram_endpoint."""
return patch(
"homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint"
)
loop.run_until_complete(
async def async_get_lights(client):
"""Get lights with the hue client."""
result = await client.get("/api/username/lights")
assert result.status == HTTPStatus.OK
assert CONTENT_TYPE_JSON in result.headers["content-type"]
return await result.json()
async def _async_setup_emulated_hue(hass: HomeAssistant, conf: ConfigType) -> None:
"""Set up emulated_hue with a specific config."""
with patch_upnp():
await setup.async_setup_component(
hass,
emulated_hue.DOMAIN,
{emulated_hue.DOMAIN: conf},
),
await hass.async_block_till_done()
@pytest.fixture
async def base_setup(hass):
"""Set up homeassistant and http."""
await asyncio.gather(
setup.async_setup_component(hass, "homeassistant", {}),
setup.async_setup_component(
hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}}
)
),
)
with patch(
"homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint"
):
loop.run_until_complete(
setup.async_setup_component(
hass,
emulated_hue.DOMAIN,
{
emulated_hue.DOMAIN: {
emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT,
emulated_hue.CONF_EXPOSE_BY_DEFAULT: True,
}
},
)
)
loop.run_until_complete(
@pytest.fixture
async def demo_setup(hass):
"""Fixture to setup demo platforms."""
# We need to do this to get access to homeassistant/turn_(on,off)
setups = [
setup.async_setup_component(hass, "homeassistant", {}),
setup.async_setup_component(
hass, light.DOMAIN, {"light": [{"platform": "demo"}]}
)
)
loop.run_until_complete(
hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}}
),
*[
setup.async_setup_component(
hass, comp.DOMAIN, {comp.DOMAIN: [{"platform": "demo"}]}
)
for comp in (light, climate, humidifier, media_player, fan, cover)
],
setup.async_setup_component(
hass,
script.DOMAIN,
@ -149,39 +167,7 @@ def hass_hue(loop, hass):
}
}
},
)
)
loop.run_until_complete(
setup.async_setup_component(
hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]}
)
)
loop.run_until_complete(
setup.async_setup_component(
hass, humidifier.DOMAIN, {"humidifier": [{"platform": "demo"}]}
)
)
loop.run_until_complete(
setup.async_setup_component(
hass, media_player.DOMAIN, {"media_player": [{"platform": "demo"}]}
)
)
loop.run_until_complete(
setup.async_setup_component(hass, fan.DOMAIN, {"fan": [{"platform": "demo"}]})
)
loop.run_until_complete(
setup.async_setup_component(
hass, cover.DOMAIN, {"cover": [{"platform": "demo"}]}
)
)
# setup a dummy scene
loop.run_until_complete(
),
setup.async_setup_component(
hass,
"scene",
@ -199,21 +185,49 @@ def hass_hue(loop, hass):
},
]
},
)
)
),
]
# create a lamp without brightness support
hass.states.async_set("light.no_brightness", "on", {})
return hass
await asyncio.gather(*setups)
@pytest.fixture
def hue_client(loop, hass_hue, hass_client_no_auth):
async def hass_hue(hass, base_setup, demo_setup):
"""Set up a Home Assistant instance for these tests."""
await _async_setup_emulated_hue(
hass,
{
emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT,
emulated_hue.CONF_EXPOSE_BY_DEFAULT: True,
},
)
# create a lamp without brightness support
hass.states.async_set("light.no_brightness", "on", {})
return hass
@callback
def _mock_hue_endpoints(
hass: HomeAssistant, conf: ConfigType, entity_numbers: dict[str, str]
) -> None:
"""Override the hue config with specific entity numbers."""
web_app = hass.http.app
config = Config(hass, conf, "127.0.0.1")
config.numbers = entity_numbers
HueUsernameView().register(web_app, web_app.router)
HueAllLightsStateView(config).register(web_app, web_app.router)
HueOneLightStateView(config).register(web_app, web_app.router)
HueOneLightChangeView(config).register(web_app, web_app.router)
HueAllGroupsStateView(config).register(web_app, web_app.router)
HueFullStateView(config).register(web_app, web_app.router)
HueConfigView(config).register(web_app, web_app.router)
@pytest.fixture
async def hue_client(hass_hue, hass_client_no_auth):
"""Create web client for emulated hue api."""
web_app = hass_hue.http.app
config = Config(
None,
_mock_hue_endpoints(
hass_hue,
{
emulated_hue.CONF_ENTITIES: {
"light.bed_light": {emulated_hue.CONF_ENTITY_HIDDEN: True},
@ -244,22 +258,12 @@ def hue_client(loop, hass_hue, hass_client_no_auth):
"scene.light_off": {emulated_hue.CONF_ENTITY_HIDDEN: False},
},
},
"127.0.0.1",
ENTITY_IDS_BY_NUMBER,
)
config.numbers = ENTITY_IDS_BY_NUMBER
HueUsernameView().register(web_app, web_app.router)
HueAllLightsStateView(config).register(web_app, web_app.router)
HueOneLightStateView(config).register(web_app, web_app.router)
HueOneLightChangeView(config).register(web_app, web_app.router)
HueAllGroupsStateView(config).register(web_app, web_app.router)
HueFullStateView(config).register(web_app, web_app.router)
HueConfigView(config).register(web_app, web_app.router)
return loop.run_until_complete(hass_client_no_auth())
return await hass_client_no_auth()
async def test_discover_lights(hue_client):
async def test_discover_lights(hass, hue_client):
"""Test the discovery of lights."""
result = await hue_client.get("/api/username/lights")
@ -292,6 +296,21 @@ async def test_discover_lights(hue_client):
assert "00:62:5c:3e:df:58:40:01-43" in devices # scene.light_on
assert "00:1c:72:08:ed:09:e7:89-77" in devices # scene.light_off
# Remove the state and ensure it disappears from devices
hass.states.async_remove("light.ceiling_lights")
await hass.async_block_till_done()
result_json = await async_get_lights(hue_client)
devices = {val["uniqueid"] for val in result_json.values()}
assert "00:2f:d2:31:ce:c5:55:cc-ee" not in devices # light.ceiling_lights
# Restore the state and ensure it reappears in devices
hass.states.async_set("light.ceiling_lights", STATE_ON)
await hass.async_block_till_done()
result_json = await async_get_lights(hue_client)
devices = {val["uniqueid"] for val in result_json.values()}
assert "00:2f:d2:31:ce:c5:55:cc-ee" in devices # light.ceiling_lights
async def test_light_without_brightness_supported(hass_hue, hue_client):
"""Test that light without brightness is supported."""
@ -316,19 +335,8 @@ async def test_lights_all_dimmable(hass, hass_client_no_auth):
emulated_hue.CONF_EXPOSE_BY_DEFAULT: True,
emulated_hue.CONF_LIGHTS_ALL_DIMMABLE: True,
}
with patch(
"homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint"
):
await setup.async_setup_component(
hass,
emulated_hue.DOMAIN,
{emulated_hue.DOMAIN: hue_config},
)
await hass.async_block_till_done()
config = Config(None, hue_config, "127.0.0.1")
config.numbers = ENTITY_IDS_BY_NUMBER
web_app = hass.http.app
HueOneLightStateView(config).register(web_app, web_app.router)
await _async_setup_emulated_hue(hass, hue_config)
_mock_hue_endpoints(hass, hue_config, ENTITY_IDS_BY_NUMBER)
client = await hass_client_no_auth()
light_without_brightness_json = await perform_get_light_state(
client, "light.no_brightness", HTTPStatus.OK
@ -568,13 +576,7 @@ async def test_get_light_state(hass_hue, hue_client):
assert office_json["state"][HUE_API_STATE_SAT] == 217
# Check all lights view
result = await hue_client.get("/api/username/lights")
assert result.status == HTTPStatus.OK
assert CONTENT_TYPE_JSON in result.headers["content-type"]
result_json = await result.json()
result_json = await async_get_lights(hue_client)
assert ENTITY_NUMBERS_BY_ID["light.ceiling_lights"] in result_json
assert (
result_json[ENTITY_NUMBERS_BY_ID["light.ceiling_lights"]]["state"][
@ -1616,3 +1618,32 @@ async def test_only_change_hue_or_saturation(hass, hass_hue, hue_client):
assert hass_hue.states.get("light.ceiling_lights").attributes[
light.ATTR_HS_COLOR
] == (0, 3)
async def test_specificly_exposed_entities(hass, base_setup, hass_client_no_auth):
"""Test specific entities with expose by default off."""
conf = {
emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT,
emulated_hue.CONF_EXPOSE_BY_DEFAULT: False,
emulated_hue.CONF_ENTITIES: {
"light.exposed": {emulated_hue.CONF_ENTITY_HIDDEN: False},
},
}
await _async_setup_emulated_hue(hass, conf)
_mock_hue_endpoints(hass, conf, {"1": "light.exposed"})
hass.states.async_set("light.exposed", STATE_ON)
await hass.async_block_till_done()
client = await hass_client_no_auth()
result_json = await async_get_lights(client)
assert "1" in result_json
hass.states.async_remove("light.exposed")
await hass.async_block_till_done()
result_json = await async_get_lights(client)
assert "1" not in result_json
hass.states.async_set("light.exposed", STATE_ON)
await hass.async_block_till_done()
result_json = await async_get_lights(client)
assert "1" in result_json