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
from random import random
import voluptuous as vol
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
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.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.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
@ -40,6 +42,15 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
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:
"""Set up the demo environment."""
@ -48,6 +59,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
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

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",
"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
import logging
import pathlib
from typing import Any
from typing import Any, cast
from homeassistant.core import HomeAssistant, callback
from homeassistant.loader import Integration, async_get_integrations
@ -21,12 +21,34 @@ ICON_CACHE: HassKey[_IconsCache] = HassKey("icon_cache")
_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(
icons_files: dict[str, pathlib.Path],
) -> dict[str, dict[str, Any]]:
"""Load and parse icons.json files."""
return {
component: load_json_object(icons_file)
component: _load_icons_file(icons_file)
for component, icons_file in icons_files.items()
}

View file

@ -9,6 +9,7 @@ import voluptuous as vol
from voluptuous.humanize import humanize_error
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.icon import convert_shorthand_service_icon
from .model import Config, Integration
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:
"""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}}
),
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
import pytest
import voluptuous as vol
from homeassistant.components.kitchen_sink import DOMAIN
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
icons = await icon.async_get_icons(hass, "services")
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
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")
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
hass.config.components.add("test_embedded")