diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index c2cf67b43f4..1de6ec98520 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -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. diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 2a9022f909d..c5ff9654f90 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -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 diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 87893f66e1f..e36903983fe 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -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