diff --git a/homeassistant/components/binary_sensor/icons.json b/homeassistant/components/binary_sensor/icons.json new file mode 100644 index 00000000000..5bd1c338921 --- /dev/null +++ b/homeassistant/components/binary_sensor/icons.json @@ -0,0 +1,178 @@ +{ + "entity_component": { + "_": { + "default": "mdi:radiobox-blank", + "state": { + "on": "mdi:checkbox-marked-circle" + } + }, + "battery": { + "default": "mdi:battery", + "state": { + "on": "mdi:battery-outline" + } + }, + "battery_charging": { + "default": "mdi:battery", + "state": { + "on": "mdi:battery-charging" + } + }, + "carbon_monoxide": { + "default": "mdi:smoke-detector", + "state": { + "on": "mdi:smoke-detector-alert" + } + }, + "cold": { + "default": "mdi:thermometer", + "state": { + "on": "mdi:snowflake" + } + }, + "connectivity": { + "default": "mdi:close-network-outline", + "state": { + "on": "mdi:check-network-outline" + } + }, + "door": { + "default": "mdi:door-closed", + "state": { + "on": "mdi:door-open" + } + }, + "garage_door": { + "default": "mdi:garage", + "state": { + "on": "mdi:garage-open" + } + }, + "gas": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "heat": { + "default": "mdi:thermometer", + "state": { + "on": "mdi:fire" + } + }, + "light": { + "default": "mdi:brightness-5", + "state": { + "on": "mdi:brightness-7" + } + }, + "lock": { + "default": "mdi:lock", + "state": { + "on": "mdi:lock-open" + } + }, + "moisture": { + "default": "mdi:water-off", + "state": { + "on": "mdi:water" + } + }, + "motion": { + "default": "mdi:motion-sensor-off", + "state": { + "on": "mdi:motion-sensor" + } + }, + "moving": { + "default": "mdi:arrow-right", + "state": { + "on": "mdi:octagon" + } + }, + "occupancy": { + "default": "mdi:home-outline", + "state": { + "on": "mdi:home" + } + }, + "opening": { + "default": "mdi:square", + "state": { + "on": "mdi:square-outline" + } + }, + "plug": { + "default": "mdi:power-plug-off", + "state": { + "on": "mdi:power-plug" + } + }, + "power": { + "default": "mdi:power-plug-off", + "state": { + "on": "mdi:power-plug" + } + }, + "presence": { + "default": "mdi:home-outline", + "state": { + "on": "mdi:home" + } + }, + "problem": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "running": { + "default": "mdi:stop", + "state": { + "on": "mdi:play" + } + }, + "safety": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "smoke": { + "default": "mdi:smoke-detector-variant", + "state": { + "on": "mdi:smoke-detector-variant-alert" + } + }, + "sound": { + "default": "mdi:music-note-off", + "state": { + "on": "mdi:music-note" + } + }, + "tamper": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "update": { + "default": "mdi:package", + "state": { + "on": "mdi:package-up" + } + }, + "vibration": { + "default": "mdi:crop-portrait", + "state": { + "on": "mdi:vibrate" + } + }, + "window": { + "default": "mdi:window-closed", + "state": { + "on": "mdi:window-open" + } + } + } +} diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json new file mode 100644 index 00000000000..79c18bc0a2e --- /dev/null +++ b/homeassistant/components/demo/icons.json @@ -0,0 +1,62 @@ +{ + "entity": { + "climate": { + "ubercool": { + "state_attributes": { + "fan_mode": { + "state": { + "auto_high": "mdi:fan-auto", + "auto_low": "mdi:fan-auto", + "on_high": "mdi:fan-chevron-up", + "on_low": "mdi:fan-chevron-down" + } + }, + "swing_mode": { + "state": { + "1": "mdi:numeric-1", + "2": "mdi:numeric-2", + "3": "mdi:numeric-3", + "auto": "mdi:arrow-oscillating", + "off": "mdi:arrow-oscillating-off" + } + } + } + } + }, + "number": { + "volume": { + "default": "mdi:volume-high" + }, + "pwm": { + "default": "mdi:square-wave" + }, + "range": { + "default": "mdi:square-wave" + } + }, + "select": { + "speed": { + "state": { + "light_speed": "mdi:speedometer-slow", + "ludicrous_speed": "mdi:speedometer-medium", + "ridiculous_speed": "mdi:speedometer" + } + } + }, + "sensor": { + "thermostat_mode": { + "state": { + "away": "mdi:home-export-outline", + "comfort": "mdi:home-account", + "eco": "mdi:leaf", + "sleep": "mdi:weather-night" + } + } + }, + "switch": { + "air_conditioner": { + "default": "mdi:air-conditioner" + } + } + } +} diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 5bc0462769d..db065054804 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -23,7 +23,7 @@ async def async_setup_entry( "volume1", "volume", 42.0, - "mdi:volume-high", + "volume", False, mode=NumberMode.SLIDER, ), @@ -31,7 +31,7 @@ async def async_setup_entry( "pwm1", "PWM 1", 0.42, - "mdi:square-wave", + "pwm", False, native_min_value=0.0, native_max_value=1.0, @@ -42,7 +42,7 @@ async def async_setup_entry( "large_range", "Large Range", 500, - "mdi:square-wave", + "range", False, native_min_value=1, native_max_value=1000, @@ -52,7 +52,7 @@ async def async_setup_entry( "small_range", "Small Range", 128, - "mdi:square-wave", + "range", False, native_min_value=1, native_max_value=255, @@ -62,7 +62,7 @@ async def async_setup_entry( "temp1", "Temperature setting", 22, - "mdi:thermometer", + None, False, device_class=NumberDeviceClass.TEMPERATURE, native_min_value=15.0, @@ -87,7 +87,7 @@ class DemoNumber(NumberEntity): unique_id: str, device_name: str, state: float, - icon: str, + translation_key: str | None, assumed_state: bool, *, device_class: NumberDeviceClass | None = None, @@ -100,7 +100,7 @@ class DemoNumber(NumberEntity): """Initialize the Demo Number entity.""" self._attr_assumed_state = assumed_state self._attr_device_class = device_class - self._attr_icon = icon + self._attr_translation_key = translation_key self._attr_mode = mode self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py index 40df72b073b..f4f81a52052 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -19,8 +19,8 @@ async def async_setup_entry( """Set up the Demo config entry.""" async_add_entities( [ - DemoRemote("Remote One", False, None), - DemoRemote("Remote Two", True, "mdi:remote"), + DemoRemote("Remote One", False), + DemoRemote("Remote Two", True), ] ) @@ -30,11 +30,10 @@ class DemoRemote(RemoteEntity): _attr_should_poll = False - def __init__(self, name: str | None, state: bool, icon: str | None) -> None: + def __init__(self, name: str | None, state: bool) -> None: """Initialize the Demo Remote.""" self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_is_on = state - self._attr_icon = icon self._last_command_sent: str | None = None @property diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py index 2a50b0151b6..58244e063f5 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -21,7 +21,6 @@ async def async_setup_entry( DemoSelect( unique_id="speed", device_name="Speed", - icon="mdi:speedometer", current_option="ridiculous_speed", options=[ "light_speed", @@ -45,7 +44,6 @@ class DemoSelect(SelectEntity): self, unique_id: str, device_name: str, - icon: str, current_option: str | None, options: list[str], translation_key: str, @@ -53,7 +51,6 @@ class DemoSelect(SelectEntity): """Initialize the Demo select entity.""" self._attr_unique_id = unique_id self._attr_current_option = current_option - self._attr_icon = icon self._attr_options = options self._attr_translation_key = translation_key self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index eac267c7c15..ac91b069d8d 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -20,13 +20,13 @@ async def async_setup_entry( """Set up the demo switch platform.""" async_add_entities( [ - DemoSwitch("switch1", "Decorative Lights", True, None, True), + DemoSwitch("switch1", "Decorative Lights", True, True), DemoSwitch( "switch2", "AC", False, - "mdi:air-conditioner", False, + translation_key="air_conditioner", device_class=SwitchDeviceClass.OUTLET, ), ] @@ -45,14 +45,14 @@ class DemoSwitch(SwitchEntity): unique_id: str, device_name: str, state: bool, - icon: str | None, assumed: bool, + translation_key: str | None = None, device_class: SwitchDeviceClass | None = None, ) -> None: """Initialize the Demo switch.""" self._attr_assumed_state = assumed self._attr_device_class = device_class - self._attr_icon = icon + self._attr_translation_key = translation_key self._attr_is_on = state self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/demo/text.py b/homeassistant/components/demo/text.py index fecc1b95cf4..d7174002055 100644 --- a/homeassistant/components/demo/text.py +++ b/homeassistant/components/demo/text.py @@ -21,20 +21,17 @@ async def async_setup_entry( DemoText( unique_id="text", device_name="Text", - icon=None, native_value="Hello world", ), DemoText( unique_id="password", device_name="Password", - icon="mdi:text", native_value="Hello world", mode=TextMode.PASSWORD, ), DemoText( unique_id="text_1_to_5_char", device_name="Text with 1 to 5 characters", - icon="mdi:text", native_value="Hello", native_min=1, native_max=5, @@ -42,7 +39,6 @@ async def async_setup_entry( DemoText( unique_id="text_lowercase", device_name="Text with only lower case characters", - icon="mdi:text", native_value="world", pattern=r"[a-z]+", ), @@ -61,7 +57,6 @@ class DemoText(TextEntity): self, unique_id: str, device_name: str, - icon: str | None, native_value: str | None, mode: TextMode = TextMode.TEXT, native_max: int | None = None, @@ -71,7 +66,6 @@ class DemoText(TextEntity): """Initialize the Demo text entity.""" self._attr_unique_id = unique_id self._attr_native_value = native_value - self._attr_icon = icon self._attr_mode = mode if native_max is not None: self._attr_native_max = native_max diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py index 56ab715a7f7..d0ec87386ef 100644 --- a/homeassistant/components/demo/time.py +++ b/homeassistant/components/demo/time.py @@ -18,7 +18,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the demo time platform.""" - async_add_entities([DemoTime("time", "Time", time(12, 0, 0), "mdi:clock", False)]) + async_add_entities([DemoTime("time", "Time", time(12, 0, 0), False)]) class DemoTime(TimeEntity): @@ -33,12 +33,10 @@ class DemoTime(TimeEntity): unique_id: str, device_name: str, state: time, - icon: str, assumed_state: bool, ) -> None: """Initialize the Demo time entity.""" self._attr_assumed_state = assumed_state - self._attr_icon = icon self._attr_native_value = state self._attr_unique_id = unique_id diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index d168dc2a6aa..09419f2d3bd 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,6 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import service import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted from homeassistant.helpers.storage import Store from homeassistant.helpers.translation import async_get_translations @@ -344,6 +345,7 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the serving of the frontend.""" await async_setup_frontend_storage(hass) + websocket_api.async_register_command(hass, websocket_get_icons) websocket_api.async_register_command(hass, websocket_get_panels) websocket_api.async_register_command(hass, websocket_get_themes) websocket_api.async_register_command(hass, websocket_get_translations) @@ -647,6 +649,28 @@ class ManifestJSONView(HomeAssistantView): ) +@websocket_api.websocket_command( + { + "type": "frontend/get_icons", + vol.Required("category"): vol.In({"entity", "entity_component", "services"}), + vol.Optional("integration"): vol.All(cv.ensure_list, [str]), + } +) +@websocket_api.async_response +async def websocket_get_icons( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle get icons command.""" + resources = await async_get_icons( + hass, + msg["category"], + msg.get("integration"), + ) + connection.send_message( + websocket_api.result_message(msg["id"], {"resources": resources}) + ) + + @callback @websocket_api.websocket_command({"type": "get_panels"}) def websocket_get_panels( diff --git a/homeassistant/components/switch/icons.json b/homeassistant/components/switch/icons.json new file mode 100644 index 00000000000..00520914b9f --- /dev/null +++ b/homeassistant/components/switch/icons.json @@ -0,0 +1,24 @@ +{ + "entity_component": { + "_": { + "default": "mdi:toggle-switch-variant" + }, + "switch": { + "default": "mdi:toggle-switch-variant", + "state": { + "off": "mdi:toggle-switch-variant-off" + } + }, + "outlet": { + "default": "mdi:power-plug", + "state": { + "off": "mdi:power-plug-off" + } + } + }, + "services": { + "toggle": "mdi:toggle-switch-variant", + "turn_off": "mdi:toggle-switch-variant-off", + "turn_on": "mdi:toggle-switch-variant" + } +} diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index 97e0d20927c..3486925b095 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -1,7 +1,165 @@ """Icon helper methods.""" from __future__ import annotations +import asyncio +from collections.abc import Iterable from functools import lru_cache +import logging +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.loader import Integration, async_get_integrations +from homeassistant.util.json import load_json_object + +from .translation import build_resources + +ICON_LOAD_LOCK = "icon_load_lock" +ICON_CACHE = "icon_cache" + +_LOGGER = logging.getLogger(__name__) + + +@callback +def _component_icons_path(component: str, integration: Integration) -> str | None: + """Return the icons json file location for a component. + + Ex: components/hue/icons.json + If component is just a single file, will return None. + """ + domain = component.rpartition(".")[-1] + + # If it's a component that is just one file, we don't support icons + # Example custom_components/my_component.py + if integration.file_path.name != domain: + return None + + return str(integration.file_path / "icons.json") + + +def _load_icons_files(icons_files: dict[str, str]) -> dict[str, dict[str, Any]]: + """Load and parse icons.json files.""" + return { + component: load_json_object(icons_file) + for component, icons_file in icons_files.items() + } + + +async def _async_get_component_icons( + hass: HomeAssistant, + components: set[str], + integrations: dict[str, Integration], +) -> dict[str, Any]: + """Load icons.""" + icons: dict[str, Any] = {} + + # Determine files to load + files_to_load = {} + for loaded in components: + domain = loaded.rpartition(".")[-1] + if (path := _component_icons_path(loaded, integrations[domain])) is None: + icons[loaded] = {} + else: + files_to_load[loaded] = path + + # Load files + if files_to_load and ( + load_icons_job := hass.async_add_executor_job(_load_icons_files, files_to_load) + ): + icons |= await load_icons_job + + return icons + + +class _IconsCache: + """Cache for icons.""" + + __slots__ = ("_hass", "_loaded", "_cache") + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the cache.""" + self._hass = hass + self._loaded: set[str] = set() + self._cache: dict[str, dict[str, Any]] = {} + + async def async_fetch( + self, + category: str, + components: set[str], + ) -> dict[str, dict[str, Any]]: + """Load resources into the cache.""" + if components_to_load := components - self._loaded: + await self._async_load(components_to_load) + + return { + component: result + for component in components + if (result := self._cache.get(category, {}).get(component)) + } + + async def _async_load(self, components: set[str]) -> None: + """Populate the cache for a given set of components.""" + _LOGGER.debug( + "Cache miss for: %s", + ", ".join(components), + ) + + integrations: dict[str, Integration] = {} + domains = list({loaded.rpartition(".")[-1] for loaded in components}) + ints_or_excs = await async_get_integrations(self._hass, domains) + for domain, int_or_exc in ints_or_excs.items(): + if isinstance(int_or_exc, Exception): + raise int_or_exc + integrations[domain] = int_or_exc + + icons = await _async_get_component_icons(self._hass, components, integrations) + + self._build_category_cache(components, icons) + self._loaded.update(components) + + @callback + def _build_category_cache( + self, + components: set[str], + icons: dict[str, dict[str, Any]], + ) -> None: + """Extract resources into the cache.""" + resource: dict[str, Any] | str + categories: set[str] = set() + for resource in icons.values(): + categories.update(resource) + + for category in categories: + new_resources = build_resources(icons, components, category) + for component, resource in new_resources.items(): + category_cache: dict[str, Any] = self._cache.setdefault(category, {}) + category_cache[component] = resource + + +async def async_get_icons( + hass: HomeAssistant, + category: str, + integrations: Iterable[str] | None = None, +) -> dict[str, Any]: + """Return all icons of integrations. + + If integration specified, load it for that one; otherwise default to loaded + intgrations. + """ + lock = hass.data.setdefault(ICON_LOAD_LOCK, asyncio.Lock()) + + if integrations: + components = set(integrations) + else: + components = { + component for component in hass.config.components if "." not in component + } + async with lock: + if ICON_CACHE in hass.data: + cache: _IconsCache = hass.data[ICON_CACHE] + else: + cache = hass.data[ICON_CACHE] = _IconsCache(hass) + + return await cache.async_fetch(category, components) @lru_cache diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 873d54e7165..f0b20c945db 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -129,7 +129,7 @@ def _merge_resources( return resources -def _build_resources( +def build_resources( translation_strings: dict[str, dict[str, Any]], components: set[str], category: str, @@ -304,7 +304,7 @@ class _TranslationCache: translation_strings, components, category ) else: - new_resources = _build_resources( + new_resources = build_resources( translation_strings, components, category ) diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index c454c69d141..308c006defc 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -17,6 +17,7 @@ from . import ( dependencies, dhcp, docker, + icons, json, manifest, metadata, @@ -38,6 +39,7 @@ INTEGRATION_PLUGINS = [ config_schema, dependencies, dhcp, + icons, json, manifest, mqtt, diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py new file mode 100644 index 00000000000..e82aed855b2 --- /dev/null +++ b/script/hassfest/icons.py @@ -0,0 +1,112 @@ +"""Validate integration icon translation files.""" +from __future__ import annotations + +from typing import Any + +import orjson +import voluptuous as vol +from voluptuous.humanize import humanize_error + +import homeassistant.helpers.config_validation as cv + +from .model import Config, Integration +from .translations import translation_key_validator + + +def icon_value_validator(value: Any) -> str: + """Validate that the icon is a valid icon.""" + value = cv.string_with_no_html(value) + if not value.startswith("mdi:"): + raise vol.Invalid( + "The icon needs to be a valid icon from Material Design Icons and start with `mdi:`" + ) + return str(value) + + +def require_default_icon_validator(value: dict) -> dict: + """Validate that a default icon is set.""" + if "_" not in value: + raise vol.Invalid( + "An entity component needs to have a default icon defined with `_`" + ) + return value + + +def icon_schema(integration_type: str) -> vol.Schema: + """Create a icon schema.""" + + state_validator = cv.schema_with_slug_keys( + icon_value_validator, + slug_validator=translation_key_validator, + ) + + def icon_schema_slug(marker: type[vol.Marker]) -> dict[vol.Marker, Any]: + return { + marker("default"): icon_value_validator, + vol.Optional("state"): state_validator, + vol.Optional("state_attributes"): cv.schema_with_slug_keys( + { + marker("default"): icon_value_validator, + marker("state"): state_validator, + }, + slug_validator=translation_key_validator, + ), + } + + base_schema = vol.Schema( + { + vol.Optional("services"): state_validator, + } + ) + + if integration_type == "entity": + return base_schema.extend( + { + vol.Required("entity_component"): vol.All( + cv.schema_with_slug_keys( + icon_schema_slug(vol.Required), + slug_validator=vol.Any("_", cv.slug), + ), + require_default_icon_validator, + ) + } + ) + return base_schema.extend( + { + vol.Required("entity"): cv.schema_with_slug_keys( + cv.schema_with_slug_keys( + icon_schema_slug(vol.Optional), + slug_validator=translation_key_validator, + ), + slug_validator=cv.slug, + ), + } + ) + + +def validate_icon_file(config: Config, integration: Integration) -> None: # noqa: C901 + """Validate icon file for integration.""" + icons_file = integration.path / "icons.json" + if not icons_file.is_file(): + return + + name = str(icons_file.relative_to(integration.path)) + + try: + icons = orjson.loads(icons_file.read_text()) + except ValueError as err: + integration.add_error("icons", f"Invalid JSON in {name}: {err}") + return + + schema = icon_schema(integration.integration_type) + + try: + schema(icons) + except vol.Invalid as err: + integration.add_error("icons", f"Invalid {name}: {humanize_error(icons, err)}") + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Handle JSON files inside integrations.""" + for integration in integrations.values(): + validate_icon_file(config, integration) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index e3f0d7f35d5..274d916f10d 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -23,7 +23,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockUser, async_capture_events, async_fire_time_changed -from tests.typing import WebSocketGenerator +from tests.typing import MockHAClientWebSocket, WebSocketGenerator MOCK_THEMES = { "happy": {"primary-color": "red", "app-header-background-color": "blue"}, @@ -664,3 +664,76 @@ async def test_static_path_cache(hass: HomeAssistant, mock_http_client) -> None: # and again to make sure the cache works resp = await mock_http_client.get("/static/does-not-exist", allow_redirects=False) assert resp.status == 404 + + +async def test_get_icons(hass: HomeAssistant, ws_client: MockHAClientWebSocket) -> None: + """Test get_icons command.""" + with patch( + "homeassistant.components.frontend.async_get_icons", + side_effect=lambda hass, category, integrations: {}, + ): + await ws_client.send_json( + { + "id": 5, + "type": "frontend/get_icons", + "category": "entity_component", + } + ) + msg = await ws_client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == {"resources": {}} + + +async def test_get_icons_for_integrations( + hass: HomeAssistant, ws_client: MockHAClientWebSocket +) -> None: + """Test get_icons for integrations command.""" + with patch( + "homeassistant.components.frontend.async_get_icons", + side_effect=lambda hass, category, integrations: { + integration: {} for integration in integrations + }, + ): + await ws_client.send_json( + { + "id": 5, + "type": "frontend/get_icons", + "integration": ["frontend", "http"], + "category": "entity", + } + ) + msg = await ws_client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert set(msg["result"]["resources"]) == {"frontend", "http"} + + +async def test_get_icons_for_single_integration( + hass: HomeAssistant, ws_client: MockHAClientWebSocket +) -> None: + """Test get_icons for integration command.""" + with patch( + "homeassistant.components.frontend.async_get_icons", + side_effect=lambda hass, category, integrations: { + integration: {} for integration in integrations + }, + ): + await ws_client.send_json( + { + "id": 5, + "type": "frontend/get_icons", + "integration": "http", + "category": "entity", + } + ) + msg = await ws_client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == {"resources": {"http": {}}} diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index bf48564c251..9063a8977f6 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -971,7 +971,6 @@ async def test_device_class_switch( None, "Demo Sensor", state=False, - icon="mdi:switch", assumed=False, device_class=device_class, ) diff --git a/tests/helpers/test_icon.py b/tests/helpers/test_icon.py index a7fe623ea7e..cf329100d75 100644 --- a/tests/helpers/test_icon.py +++ b/tests/helpers/test_icon.py @@ -1,18 +1,26 @@ """Test Home Assistant icon util methods.""" +import pathlib +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import icon +from homeassistant.loader import IntegrationNotFound +from homeassistant.setup import async_setup_component + def test_battery_icon() -> None: """Test icon generator for battery sensor.""" - from homeassistant.helpers.icon import icon_for_battery_level + assert icon.icon_for_battery_level(None, True) == "mdi:battery-unknown" + assert icon.icon_for_battery_level(None, False) == "mdi:battery-unknown" - assert icon_for_battery_level(None, True) == "mdi:battery-unknown" - assert icon_for_battery_level(None, False) == "mdi:battery-unknown" + assert icon.icon_for_battery_level(5, True) == "mdi:battery-outline" + assert icon.icon_for_battery_level(5, False) == "mdi:battery-alert" - assert icon_for_battery_level(5, True) == "mdi:battery-outline" - assert icon_for_battery_level(5, False) == "mdi:battery-alert" - - assert icon_for_battery_level(100, True) == "mdi:battery-charging-100" - assert icon_for_battery_level(100, False) == "mdi:battery" + assert icon.icon_for_battery_level(100, True) == "mdi:battery-charging-100" + assert icon.icon_for_battery_level(100, False) == "mdi:battery" iconbase = "mdi:battery" for level in range(0, 100, 5): @@ -20,8 +28,8 @@ def test_battery_icon() -> None: "Level: %d. icon: %s, charging: %s" % ( level, - icon_for_battery_level(level, False), - icon_for_battery_level(level, True), + icon.icon_for_battery_level(level, False), + icon.icon_for_battery_level(level, True), ) ) if level <= 10: @@ -42,17 +50,183 @@ def test_battery_icon() -> None: postfix = "-alert" else: postfix = "" - assert iconbase + postfix == icon_for_battery_level(level, False) - assert iconbase + postfix_charging == icon_for_battery_level(level, True) + assert iconbase + postfix == icon.icon_for_battery_level(level, False) + assert iconbase + postfix_charging == icon.icon_for_battery_level(level, True) def test_signal_icon() -> None: """Test icon generator for signal sensor.""" - from homeassistant.helpers.icon import icon_for_signal_level + assert icon.icon_for_signal_level(None) == "mdi:signal-cellular-outline" + assert icon.icon_for_signal_level(0) == "mdi:signal-cellular-outline" + assert icon.icon_for_signal_level(5) == "mdi:signal-cellular-1" + assert icon.icon_for_signal_level(40) == "mdi:signal-cellular-2" + assert icon.icon_for_signal_level(80) == "mdi:signal-cellular-3" + assert icon.icon_for_signal_level(100) == "mdi:signal-cellular-3" - assert icon_for_signal_level(None) == "mdi:signal-cellular-outline" - assert icon_for_signal_level(0) == "mdi:signal-cellular-outline" - assert icon_for_signal_level(5) == "mdi:signal-cellular-1" - assert icon_for_signal_level(40) == "mdi:signal-cellular-2" - assert icon_for_signal_level(80) == "mdi:signal-cellular-3" - assert icon_for_signal_level(100) == "mdi:signal-cellular-3" + +def test_load_icons_files(hass: HomeAssistant) -> None: + """Test the load icons files function.""" + file1 = hass.config.path("custom_components", "test", "icons.json") + file2 = hass.config.path("custom_components", "test", "invalid.json") + assert icon._load_icons_files({"test": file1, "invalid": file2}) == { + "test": { + "entity": { + "switch": { + "something": { + "state": {"away": "mdi:home-outline", "home": "mdi:home"} + } + } + }, + }, + "invalid": {}, + } + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_get_icons(hass: HomeAssistant) -> None: + """Test the get icon helper.""" + icons = await icon.async_get_icons(hass, "entity") + assert icons == {} + + icons = await icon.async_get_icons(hass, "entity_component") + assert icons == {} + + # Set up test switch component + assert await async_setup_component(hass, "switch", {"switch": {"platform": "test"}}) + + # Test getting icons for the entity component + icons = await icon.async_get_icons(hass, "entity_component") + assert icons["switch"]["_"]["default"] == "mdi:toggle-switch-variant" + + # Test services icons are available + icons = await icon.async_get_icons(hass, "services") + assert len(icons) == 1 + assert icons["switch"]["turn_off"] == "mdi:toggle-switch-variant-off" + + # Ensure icons file for platform isn't loaded, as that isn't supported + icons = await icon.async_get_icons(hass, "entity") + assert icons == {} + icons = await icon.async_get_icons(hass, "entity", ["test.switch"]) + assert icons == {} + + # Load up an custom integration + hass.config.components.add("test_package") + await hass.async_block_till_done() + + icons = await icon.async_get_icons(hass, "entity") + assert len(icons) == 1 + + assert icons == { + "test_package": { + "switch": { + "something": {"state": {"away": "mdi:home-outline", "home": "mdi:home"}} + } + } + } + + icons = await icon.async_get_icons(hass, "services") + assert len(icons) == 2 + assert icons["test_package"]["enable_god_mode"] == "mdi:shield" + + # Load another one + hass.config.components.add("test_embedded") + await hass.async_block_till_done() + + icons = await icon.async_get_icons(hass, "entity") + assert len(icons) == 2 + + assert icons["test_package"] == { + "switch": { + "something": {"state": {"away": "mdi:home-outline", "home": "mdi:home"}} + } + } + + # Test getting non-existing integration + with pytest.raises( + IntegrationNotFound, match="Integration 'non_existing' not found" + ): + await icon.async_get_icons(hass, "entity", ["non_existing"]) + + +async def test_get_icons_while_loading_components(hass: HomeAssistant) -> None: + """Test the get icons helper loads icons.""" + integration = Mock(file_path=pathlib.Path(__file__)) + integration.name = "Component 1" + hass.config.components.add("component1") + load_count = 0 + + def mock_load_icons_files(files): + """Mock load icon files.""" + nonlocal load_count + load_count += 1 + return {"component1": {"entity": {"climate": {"test": {"icon": "mdi:home"}}}}} + + with patch( + "homeassistant.helpers.icon._component_icons_path", + return_value="choochoo.json", + ), patch( + "homeassistant.helpers.icon._load_icons_files", + mock_load_icons_files, + ), patch( + "homeassistant.helpers.icon.async_get_integrations", + return_value={"component1": integration}, + ): + times = 5 + all_icons = [await icon.async_get_icons(hass, "entity") for _ in range(times)] + + assert all_icons == [ + {"component1": {"climate": {"test": {"icon": "mdi:home"}}}} + for _ in range(times) + ] + assert load_count == 1 + + +async def test_caching(hass: HomeAssistant) -> None: + """Test we cache data.""" + hass.config.components.add("binary_sensor") + hass.config.components.add("switch") + + # Patch with same method so we can count invocations + with patch( + "homeassistant.helpers.icon.build_resources", + side_effect=icon.build_resources, + ) as mock_build: + load1 = await icon.async_get_icons(hass, "entity_component") + assert len(mock_build.mock_calls) == 2 + + load2 = await icon.async_get_icons(hass, "entity_component") + assert len(mock_build.mock_calls) == 2 + + assert load1 == load2 + + assert load1["binary_sensor"] + assert load1["switch"] + + load_switch_only = await icon.async_get_icons( + hass, "entity_component", integrations={"switch"} + ) + assert load_switch_only + assert list(load_switch_only) == ["switch"] + + load_binary_sensor_only = await icon.async_get_icons( + hass, "entity_component", integrations={"binary_sensor"} + ) + assert load_binary_sensor_only + assert list(load_binary_sensor_only) == ["binary_sensor"] + + # Check if new loaded component, trigger load + hass.config.components.add("media_player") + with patch( + "homeassistant.helpers.icon._load_icons_files", + side_effect=icon._load_icons_files, + ) as mock_load: + load_sensor_only = await icon.async_get_icons( + hass, "entity_component", integrations={"switch"} + ) + assert load_sensor_only + assert len(mock_load.mock_calls) == 0 + + await icon.async_get_icons( + hass, "entity_component", integrations={"media_player"} + ) + assert len(mock_load.mock_calls) == 1 diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 527e1a07c23..954c9ae7616 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -478,8 +478,8 @@ async def test_caching(hass: HomeAssistant) -> None: # Patch with same method so we can count invocations with patch( - "homeassistant.helpers.translation._build_resources", - side_effect=translation._build_resources, + "homeassistant.helpers.translation.build_resources", + side_effect=translation.build_resources, ) as mock_build: load_sensor_only = await translation.async_get_translations( hass, "en", "title", integrations={"sensor"} diff --git a/tests/testing_config/custom_components/test/icons.json b/tests/testing_config/custom_components/test/icons.json new file mode 100644 index 00000000000..45ac054199d --- /dev/null +++ b/tests/testing_config/custom_components/test/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "switch": { + "something": { + "state": { + "away": "mdi:home-outline", + "home": "mdi:home" + } + } + } + } +} diff --git a/tests/testing_config/custom_components/test_embedded/icons.json b/tests/testing_config/custom_components/test_embedded/icons.json new file mode 100644 index 00000000000..45ac054199d --- /dev/null +++ b/tests/testing_config/custom_components/test_embedded/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "switch": { + "something": { + "state": { + "away": "mdi:home-outline", + "home": "mdi:home" + } + } + } + } +} diff --git a/tests/testing_config/custom_components/test_package/icons.json b/tests/testing_config/custom_components/test_package/icons.json new file mode 100644 index 00000000000..e82168d7a1a --- /dev/null +++ b/tests/testing_config/custom_components/test_package/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "switch": { + "something": { + "state": { + "away": "mdi:home-outline", + "home": "mdi:home" + } + } + } + }, + "services": { + "enable_god_mode": "mdi:shield" + } +}