From 8bbc10b9f18d2af1a2e85718ecbbd580701c0ccb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:10:42 +0200 Subject: [PATCH] Move group service registration to services module (#184305) Co-authored-by: Claude Opus 5 --- homeassistant/components/group/__init__.py | 217 +------------------- homeassistant/components/group/const.py | 19 ++ homeassistant/components/group/services.py | 227 +++++++++++++++++++++ 3 files changed, 256 insertions(+), 207 deletions(-) create mode 100644 homeassistant/components/group/services.py diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index bb67a92cb336..1bde2e50543c 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -1,7 +1,5 @@ """Provide the functionality to group entities.""" -import asyncio -from collections.abc import Collection import logging from typing import Any @@ -10,22 +8,18 @@ import probatio from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, # noqa: F401 - ATTR_ICON, - ATTR_NAME, CONF_ENTITIES, CONF_ICON, CONF_NAME, - SERVICE_RELOAD, - Platform, + SERVICE_RELOAD, # noqa: F401 ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.group import ( expand_entity_ids as _expand_entity_ids, get_entity_ids as _get_entity_ids, get_group_entities, ) -from homeassistant.helpers.reload import async_reload_integration_platforms from homeassistant.helpers.typing import ConfigType # @@ -45,33 +39,19 @@ from .const import ( # noqa: F401 ATTR_OBJECT_ID, ATTR_ORDER, ATTR_REMOVE_ENTITIES, + CONF_ALL, CONF_HIDE_MEMBERS, DATA_COMPONENT, DOMAIN, GROUP_ORDER, + PLATFORMS, REG_KEY, + SERVICE_REMOVE, + SERVICE_SET, ) -from .entity import Group, async_get_component +from .entity import Group # noqa: F401 from .registry import async_setup as async_setup_registry - -CONF_ALL = "all" - - -SERVICE_SET = "set" -SERVICE_REMOVE = "remove" - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.COVER, - Platform.FAN, - Platform.LIGHT, - Platform.LOCK, - Platform.MEDIA_PLAYER, - Platform.NOTIFY, - Platform.SENSOR, - Platform.SWITCH, - Platform.VALVE, -] +from .services import async_process_config, async_setup_services _LOGGER = logging.getLogger(__name__) @@ -201,187 +181,10 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up all groups found defined in the configuration.""" - component = async_get_component(hass) - await async_setup_registry(hass) - await _async_process_config(hass, config) + await async_process_config(hass, config) - async def reload_service_handler(service: ServiceCall) -> None: - """Group reload handler. - - - Remove group.group entities not created by service calls and set them up again - - Reload xxx.group platforms - """ - conf = await component.async_prepare_reload(skip_reset=True) - - # Simplified + modified version of EntityPlatform.async_reset: - # - group.group never retries setup - # - group.group never polls - # - We don't need to reset EntityPlatform._setup_complete - # - Only remove entities which were not created by service calls - tasks = [ - entity.async_remove() - for entity in component.entities - if entity.entity_id.startswith("group.") and not entity.created_by_service - ] - - if tasks: - await asyncio.gather(*tasks) - - component.config = None - - await _async_process_config(hass, conf) - - await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) - - hass.services.async_register( - DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=probatio.Schema({}) - ) - - service_lock = asyncio.Lock() - - async def locked_service_handler(service: ServiceCall) -> None: - """Handle a service with an async lock.""" - async with service_lock: - await groups_service_handler(service) - - async def groups_service_handler(service: ServiceCall) -> None: - """Handle dynamic group service functions.""" - object_id = service.data[ATTR_OBJECT_ID] - entity_id = f"{DOMAIN}.{object_id}" - group = component.get_entity(entity_id) - - # new group - if service.service == SERVICE_SET and group is None: - entity_ids = ( - service.data.get(ATTR_ENTITIES) - or service.data.get(ATTR_ADD_ENTITIES) - or None - ) - - await Group.async_create_group( - hass, - service.data.get(ATTR_NAME, object_id), - created_by_service=True, - entity_ids=entity_ids, - icon=service.data.get(ATTR_ICON), - mode=service.data.get(ATTR_ALL), - object_id=object_id, - order=None, - context=service.context, - ) - return - - if group is None: - _LOGGER.warning("%s:Group '%s' doesn't exist!", service.service, object_id) - return - - group.async_set_context(service.context) - - # update group - if service.service == SERVICE_SET: - need_update = False - - if ATTR_ADD_ENTITIES in service.data: - delta = service.data[ATTR_ADD_ENTITIES] - entity_ids = set(group.tracking) | set(delta) - group.async_update_tracked_entity_ids(entity_ids) - - if ATTR_REMOVE_ENTITIES in service.data: - delta = service.data[ATTR_REMOVE_ENTITIES] - entity_ids = set(group.tracking) - set(delta) - group.async_update_tracked_entity_ids(entity_ids) - - if ATTR_ENTITIES in service.data: - entity_ids = service.data[ATTR_ENTITIES] - group.async_update_tracked_entity_ids(entity_ids) - - if ATTR_NAME in service.data: - group.set_name(service.data[ATTR_NAME]) - need_update = True - - if ATTR_ICON in service.data: - group.set_icon(service.data[ATTR_ICON]) - need_update = True - - if ATTR_ALL in service.data: - group.mode = all if service.data[ATTR_ALL] else any - need_update = True - - if need_update: - group.async_write_ha_state() - - return - - # remove group - if service.service == SERVICE_REMOVE: - await component.async_remove_entity(entity_id) - - hass.services.async_register( - DOMAIN, - SERVICE_SET, - locked_service_handler, - schema=probatio.All( - probatio.Schema( - { - probatio.Required(ATTR_OBJECT_ID): cv.slug, - probatio.Optional(ATTR_NAME): cv.string, - probatio.Optional(ATTR_ICON): cv.string, - probatio.Optional(ATTR_ALL): cv.boolean, - probatio.Exclusive(ATTR_ENTITIES, "entities"): cv.entity_ids, - probatio.Exclusive(ATTR_ADD_ENTITIES, "entities"): cv.entity_ids, - probatio.Exclusive(ATTR_REMOVE_ENTITIES, "entities"): cv.entity_ids, - } - ) - ), - ) - - hass.services.async_register( - DOMAIN, - SERVICE_REMOVE, - groups_service_handler, - schema=probatio.Schema({probatio.Required(ATTR_OBJECT_ID): cv.slug}), - ) + async_setup_services(hass) return True - - -async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> None: - """Process group configuration.""" - hass.data.setdefault(GROUP_ORDER, 0) - - entities = [] - domain_config: dict[str, dict[str, Any]] = config.get(DOMAIN, {}) - - for object_id, conf in domain_config.items(): - name: str = conf.get(CONF_NAME, object_id) - entity_ids: Collection[str] = conf.get(CONF_ENTITIES) or [] - icon: str | None = conf.get(CONF_ICON) - mode = bool(conf.get(CONF_ALL)) - order = hass.data[GROUP_ORDER] - - # We keep track of the order when we are creating the tasks - # in the same way that async_create_group does to make - # sure we use the same ordering system. This overcomes - # the problem with concurrently creating the groups - entities.append( - Group.async_create_group_entity( - hass, - name, - created_by_service=False, - entity_ids=entity_ids, - icon=icon, - object_id=object_id, - mode=mode, - order=order, - ) - ) - - # Keep track of the group order without iterating - # every state in the state machine every time - # we setup a new group - hass.data[GROUP_ORDER] += 1 - - # If called before the platform async_setup is called (test cases) - await async_get_component(hass).async_add_entities(entities) diff --git a/homeassistant/components/group/const.py b/homeassistant/components/group/const.py index c23e0c7277aa..067c2c15601f 100644 --- a/homeassistant/components/group/const.py +++ b/homeassistant/components/group/const.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from homeassistant.const import Platform from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: @@ -22,6 +23,24 @@ ATTR_ADD_ENTITIES = "add_entities" ATTR_REMOVE_ENTITIES = "remove_entities" ATTR_AUTO = "auto" ATTR_ENTITIES = "entities" + +SERVICE_SET = "set" +SERVICE_REMOVE = "remove" + +CONF_ALL = "all" + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.MEDIA_PLAYER, + Platform.NOTIFY, + Platform.SENSOR, + Platform.SWITCH, + Platform.VALVE, +] ATTR_OBJECT_ID = "object_id" ATTR_ORDER = "order" ATTR_ALL = "all" diff --git a/homeassistant/components/group/services.py b/homeassistant/components/group/services.py new file mode 100644 index 000000000000..0a3dab94dbe5 --- /dev/null +++ b/homeassistant/components/group/services.py @@ -0,0 +1,227 @@ +"""Services for the group integration.""" + +import asyncio +from collections.abc import Collection +from functools import partial +import logging +from typing import Any + +import probatio + +from homeassistant.const import ( + ATTR_ICON, + ATTR_NAME, + CONF_ENTITIES, + CONF_ICON, + CONF_NAME, + SERVICE_RELOAD, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.reload import async_reload_integration_platforms +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ATTR_ADD_ENTITIES, + ATTR_ALL, + ATTR_ENTITIES, + ATTR_OBJECT_ID, + ATTR_REMOVE_ENTITIES, + CONF_ALL, + DOMAIN, + GROUP_ORDER, + PLATFORMS, + SERVICE_REMOVE, + SERVICE_SET, +) +from .entity import Group, async_get_component + +_LOGGER = logging.getLogger(__name__) + + +async def async_process_config(hass: HomeAssistant, config: ConfigType) -> None: + """Process group configuration.""" + hass.data.setdefault(GROUP_ORDER, 0) + + entities = [] + domain_config: dict[str, dict[str, Any]] = config.get(DOMAIN, {}) + + for object_id, conf in domain_config.items(): + name: str = conf.get(CONF_NAME, object_id) + entity_ids: Collection[str] = conf.get(CONF_ENTITIES) or [] + icon: str | None = conf.get(CONF_ICON) + mode = bool(conf.get(CONF_ALL)) + order = hass.data[GROUP_ORDER] + + # We keep track of the order when we are creating the tasks + # in the same way that async_create_group does to make + # sure we use the same ordering system. This overcomes + # the problem with concurrently creating the groups + entities.append( + Group.async_create_group_entity( + hass, + name, + created_by_service=False, + entity_ids=entity_ids, + icon=icon, + object_id=object_id, + mode=mode, + order=order, + ) + ) + + # Keep track of the group order without iterating + # every state in the state machine every time + # we setup a new group + hass.data[GROUP_ORDER] += 1 + + # If called before the platform async_setup is called (test cases) + await async_get_component(hass).async_add_entities(entities) + + +async def _async_reload_service(service: ServiceCall) -> None: + """Group reload handler. + + - Remove group.group entities not created by service calls and set them up again + - Reload xxx.group platforms + """ + hass = service.hass + component = async_get_component(hass) + conf = await component.async_prepare_reload(skip_reset=True) + + # Simplified + modified version of EntityPlatform.async_reset: + # - group.group never retries setup + # - group.group never polls + # - We don't need to reset EntityPlatform._setup_complete + # - Only remove entities which were not created by service calls + tasks = [ + entity.async_remove() + for entity in component.entities + if entity.entity_id.startswith("group.") and not entity.created_by_service + ] + + if tasks: + await asyncio.gather(*tasks) + + component.config = None + + await async_process_config(hass, conf) + + await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) + + +async def _async_groups_service(service: ServiceCall) -> None: + """Handle dynamic group service functions.""" + hass = service.hass + component = async_get_component(hass) + object_id = service.data[ATTR_OBJECT_ID] + entity_id = f"{DOMAIN}.{object_id}" + group = component.get_entity(entity_id) + + # new group + if service.service == SERVICE_SET and group is None: + entity_ids = ( + service.data.get(ATTR_ENTITIES) + or service.data.get(ATTR_ADD_ENTITIES) + or None + ) + + await Group.async_create_group( + hass, + service.data.get(ATTR_NAME, object_id), + created_by_service=True, + entity_ids=entity_ids, + icon=service.data.get(ATTR_ICON), + mode=service.data.get(ATTR_ALL), + object_id=object_id, + order=None, + context=service.context, + ) + return + + if group is None: + _LOGGER.warning("%s:Group '%s' doesn't exist!", service.service, object_id) + return + + group.async_set_context(service.context) + + # update group + if service.service == SERVICE_SET: + need_update = False + + if ATTR_ADD_ENTITIES in service.data: + delta = service.data[ATTR_ADD_ENTITIES] + entity_ids = set(group.tracking) | set(delta) + group.async_update_tracked_entity_ids(entity_ids) + + if ATTR_REMOVE_ENTITIES in service.data: + delta = service.data[ATTR_REMOVE_ENTITIES] + entity_ids = set(group.tracking) - set(delta) + group.async_update_tracked_entity_ids(entity_ids) + + if ATTR_ENTITIES in service.data: + entity_ids = service.data[ATTR_ENTITIES] + group.async_update_tracked_entity_ids(entity_ids) + + if ATTR_NAME in service.data: + group.set_name(service.data[ATTR_NAME]) + need_update = True + + if ATTR_ICON in service.data: + group.set_icon(service.data[ATTR_ICON]) + need_update = True + + if ATTR_ALL in service.data: + group.mode = all if service.data[ATTR_ALL] else any + need_update = True + + if need_update: + group.async_write_ha_state() + + return + + # remove group + if service.service == SERVICE_REMOVE: + await component.async_remove_entity(entity_id) + + +async def _async_locked_groups_service( + service_lock: asyncio.Lock, service: ServiceCall +) -> None: + """Handle a service with an async lock.""" + async with service_lock: + await _async_groups_service(service) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the group services.""" + service_lock = asyncio.Lock() + + hass.services.async_register( + DOMAIN, SERVICE_RELOAD, _async_reload_service, schema=probatio.Schema({}) + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET, + partial(_async_locked_groups_service, service_lock), + schema=probatio.All( + probatio.Schema( + { + probatio.Required(ATTR_OBJECT_ID): cv.slug, + probatio.Optional(ATTR_NAME): cv.string, + probatio.Optional(ATTR_ICON): cv.string, + probatio.Optional(ATTR_ALL): cv.boolean, + probatio.Exclusive(ATTR_ENTITIES, "entities"): cv.entity_ids, + probatio.Exclusive(ATTR_ADD_ENTITIES, "entities"): cv.entity_ids, + probatio.Exclusive(ATTR_REMOVE_ENTITIES, "entities"): cv.entity_ids, + } + ) + ), + ) + hass.services.async_register( + DOMAIN, + SERVICE_REMOVE, + _async_groups_service, + schema=probatio.Schema({probatio.Required(ATTR_OBJECT_ID): cv.slug}), + )