Add icon translations support (#103294)

Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Franck Nijhof 2024-01-19 16:56:56 +01:00 committed by GitHub
parent ed449a5abd
commit 01372024f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 885 additions and 52 deletions

View File

@ -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"
}
}
}
}

View File

@ -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"
}
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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"
}
}

View File

@ -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

View File

@ -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
)

View File

@ -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,

112
script/hassfest/icons.py Normal file
View File

@ -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)

View File

@ -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": {}}}

View File

@ -971,7 +971,6 @@ async def test_device_class_switch(
None,
"Demo Sensor",
state=False,
icon="mdi:switch",
assumed=False,
device_class=device_class,
)

View File

@ -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

View File

@ -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"}

View File

@ -0,0 +1,12 @@
{
"entity": {
"switch": {
"something": {
"state": {
"away": "mdi:home-outline",
"home": "mdi:home"
}
}
}
}
}

View File

@ -0,0 +1,12 @@
{
"entity": {
"switch": {
"something": {
"state": {
"away": "mdi:home-outline",
"home": "mdi:home"
}
}
}
}
}

View File

@ -0,0 +1,15 @@
{
"entity": {
"switch": {
"something": {
"state": {
"away": "mdi:home-outline",
"home": "mdi:home"
}
}
}
},
"services": {
"enable_god_mode": "mdi:shield"
}
}