Allow specifying icons for service sections (#124656)

* Allow specifying icons for service sections

* Improve kitchen_sink example
This commit is contained in:
Erik Montnemery 2024-08-28 11:15:26 +02:00 committed by GitHub
parent e9830f0835
commit c772c4a2d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 157 additions and 6 deletions

View File

@ -9,6 +9,8 @@ from __future__ import annotations
import datetime import datetime
from random import random from random import random
import voluptuous as vol
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
from homeassistant.components.recorder.statistics import ( from homeassistant.components.recorder.statistics import (
@ -18,7 +20,7 @@ from homeassistant.components.recorder.statistics import (
) )
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@ -40,6 +42,15 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
{
vol.Required("field_1"): vol.Coerce(int),
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
vol.Optional("field_3"): vol.Coerce(int),
vol.Optional("field_4"): vol.In(["forwards", "reverse"]),
}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the demo environment.""" """Set up the demo environment."""
@ -48,6 +59,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
DOMAIN, context={"source": SOURCE_IMPORT}, data={} DOMAIN, context={"source": SOURCE_IMPORT}, data={}
) )
) )
@callback
def service_handler(call: ServiceCall | None = None) -> None:
"""Do nothing."""
hass.services.async_register(
DOMAIN, "test_service_1", service_handler, SCHEMA_SERVICE_TEST_SERVICE_1
)
return True return True

View File

@ -7,5 +7,13 @@
} }
} }
} }
},
"services": {
"test_service_1": {
"service": "mdi:flask",
"sections": {
"advanced_fields": "mdi:test-tube"
}
}
} }
} }

View File

@ -0,0 +1,32 @@
test_service_1:
fields:
field_1:
required: true
selector:
number:
min: 0
max: 60
unit_of_measurement: seconds
field_2:
required: true
selector:
select:
options:
- "off"
- "auto"
- "cool"
advanced_fields:
collapsed: true
fields:
field_3:
selector:
number:
min: 0
max: 24
unit_of_measurement: hours
field_4:
selector:
select:
options:
- "forward"
- "reverse"

View File

@ -71,5 +71,35 @@
"title": "This is not a fixable problem", "title": "This is not a fixable problem",
"description": "This issue is never going to give up." "description": "This issue is never going to give up."
} }
},
"services": {
"test_service_1": {
"name": "Test service 1",
"description": "Fake service for testing",
"fields": {
"field_1": {
"name": "Field 1",
"description": "Number of seconds"
},
"field_2": {
"name": "Field 2",
"description": "Mode"
},
"field_3": {
"name": "Field 3",
"description": "Number of hours"
},
"field_4": {
"name": "Field 4",
"description": "Direction"
}
},
"sections": {
"advanced_fields": {
"name": "Advanced options",
"description": "Some very advanced things"
}
}
}
} }
} }

View File

@ -7,7 +7,7 @@ from collections.abc import Iterable
from functools import lru_cache from functools import lru_cache
import logging import logging
import pathlib import pathlib
from typing import Any from typing import Any, cast
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.loader import Integration, async_get_integrations from homeassistant.loader import Integration, async_get_integrations
@ -21,12 +21,34 @@ ICON_CACHE: HassKey[_IconsCache] = HassKey("icon_cache")
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def convert_shorthand_service_icon(
value: str | dict[str, str | dict[str, str]],
) -> dict[str, str | dict[str, str]]:
"""Convert shorthand service icon to dict."""
if isinstance(value, str):
return {"service": value}
return value
def _load_icons_file(
icons_file: pathlib.Path,
) -> dict[str, Any]:
"""Load and parse an icons.json file."""
icons = load_json_object(icons_file)
if "services" not in icons:
return icons
services = cast(dict[str, str | dict[str, str | dict[str, str]]], icons["services"])
for service, service_icons in services.items():
services[service] = convert_shorthand_service_icon(service_icons)
return icons
def _load_icons_files( def _load_icons_files(
icons_files: dict[str, pathlib.Path], icons_files: dict[str, pathlib.Path],
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
"""Load and parse icons.json files.""" """Load and parse icons.json files."""
return { return {
component: load_json_object(icons_file) component: _load_icons_file(icons_file)
for component, icons_file in icons_files.items() for component, icons_file in icons_files.items()
} }

View File

@ -9,6 +9,7 @@ import voluptuous as vol
from voluptuous.humanize import humanize_error from voluptuous.humanize import humanize_error
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.icon import convert_shorthand_service_icon
from .model import Config, Integration from .model import Config, Integration
from .translations import translation_key_validator from .translations import translation_key_validator
@ -60,6 +61,22 @@ DATA_ENTRY_ICONS_SCHEMA = vol.Schema(
) )
SERVICE_ICONS_SCHEMA = cv.schema_with_slug_keys(
vol.All(
convert_shorthand_service_icon,
vol.Schema(
{
vol.Optional("service"): icon_value_validator,
vol.Optional("sections"): cv.schema_with_slug_keys(
icon_value_validator, slug_validator=translation_key_validator
),
}
),
),
slug_validator=translation_key_validator,
)
def icon_schema(integration_type: str, no_entity_platform: bool) -> vol.Schema: def icon_schema(integration_type: str, no_entity_platform: bool) -> vol.Schema:
"""Create an icon schema.""" """Create an icon schema."""
@ -91,7 +108,7 @@ def icon_schema(integration_type: str, no_entity_platform: bool) -> vol.Schema:
{str: {"fix_flow": DATA_ENTRY_ICONS_SCHEMA}} {str: {"fix_flow": DATA_ENTRY_ICONS_SCHEMA}}
), ),
vol.Optional("options"): DATA_ENTRY_ICONS_SCHEMA, vol.Optional("options"): DATA_ENTRY_ICONS_SCHEMA,
vol.Optional("services"): state_validator, vol.Optional("services"): SERVICE_ICONS_SCHEMA,
} }
) )

View File

@ -5,6 +5,7 @@ from http import HTTPStatus
from unittest.mock import ANY from unittest.mock import ANY
import pytest import pytest
import voluptuous as vol
from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.components.kitchen_sink import DOMAIN
from homeassistant.components.recorder import get_instance from homeassistant.components.recorder import get_instance
@ -324,3 +325,24 @@ async def test_issues_created(
}, },
] ]
} }
async def test_service(
hass: HomeAssistant,
) -> None:
"""Test we can call the service."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
with pytest.raises(vol.error.MultipleInvalid):
await hass.services.async_call(DOMAIN, "test_service_1", blocking=True)
await hass.services.async_call(
DOMAIN, "test_service_1", {"field_1": 1, "field_2": "auto"}, blocking=True
)
await hass.services.async_call(
DOMAIN,
"test_service_1",
{"field_1": 1, "field_2": "auto", "field_3": 1, "field_4": "forwards"},
blocking=True,
)

View File

@ -101,7 +101,7 @@ async def test_get_icons(hass: HomeAssistant) -> None:
# Test services icons are available # Test services icons are available
icons = await icon.async_get_icons(hass, "services") icons = await icon.async_get_icons(hass, "services")
assert len(icons) == 1 assert len(icons) == 1
assert icons["switch"]["turn_off"] == "mdi:toggle-switch-variant-off" assert icons["switch"]["turn_off"] == {"service": "mdi:toggle-switch-variant-off"}
# Ensure icons file for platform isn't loaded, as that isn't supported # Ensure icons file for platform isn't loaded, as that isn't supported
icons = await icon.async_get_icons(hass, "entity") icons = await icon.async_get_icons(hass, "entity")
@ -126,7 +126,7 @@ async def test_get_icons(hass: HomeAssistant) -> None:
icons = await icon.async_get_icons(hass, "services") icons = await icon.async_get_icons(hass, "services")
assert len(icons) == 2 assert len(icons) == 2
assert icons["test_package"]["enable_god_mode"] == "mdi:shield" assert icons["test_package"]["enable_god_mode"] == {"service": "mdi:shield"}
# Load another one # Load another one
hass.config.components.add("test_embedded") hass.config.components.add("test_embedded")