mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
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:
parent
ed449a5abd
commit
01372024f5
178
homeassistant/components/binary_sensor/icons.json
Normal file
178
homeassistant/components/binary_sensor/icons.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
62
homeassistant/components/demo/icons.json
Normal file
62
homeassistant/components/demo/icons.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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(
|
||||
|
24
homeassistant/components/switch/icons.json
Normal file
24
homeassistant/components/switch/icons.json
Normal 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"
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
@ -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
112
script/hassfest/icons.py
Normal 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)
|
@ -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": {}}}
|
||||
|
@ -971,7 +971,6 @@ async def test_device_class_switch(
|
||||
None,
|
||||
"Demo Sensor",
|
||||
state=False,
|
||||
icon="mdi:switch",
|
||||
assumed=False,
|
||||
device_class=device_class,
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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"}
|
||||
|
12
tests/testing_config/custom_components/test/icons.json
Normal file
12
tests/testing_config/custom_components/test/icons.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"something": {
|
||||
"state": {
|
||||
"away": "mdi:home-outline",
|
||||
"home": "mdi:home"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"something": {
|
||||
"state": {
|
||||
"away": "mdi:home-outline",
|
||||
"home": "mdi:home"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"something": {
|
||||
"state": {
|
||||
"away": "mdi:home-outline",
|
||||
"home": "mdi:home"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_god_mode": "mdi:shield"
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user