From 8229631d3f91e3ccafa916fbbfc444d2897e1d62 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 21 Sep 2026 17:22:56 +0100 Subject: [PATCH 01/33] Add field IDs to the bug report form (#182854) --- .github/ISSUE_TEMPLATE/bug_report.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 94e876aa3abf7..ae94c43069077 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,10 +6,14 @@ body: value: | This issue form is for reporting bugs only! + Please read our [issue reporting guide][reporting-issues] before submitting a report. + If you have a feature or enhancement request, please [request them here instead][fr]. [fr]: https://github.com/orgs/home-assistant/discussions + [reporting-issues]: https://www.home-assistant.io/help/reporting_issues/ - type: textarea + id: problem validations: required: true attributes: @@ -35,12 +39,14 @@ body: [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) - type: input + id: last_working_version attributes: label: What was the last working version of Home Assistant Core? placeholder: core- description: > If known, otherwise leave blank. - type: dropdown + id: installation_type validations: required: true attributes: @@ -76,6 +82,7 @@ body: value: | # Details - type: textarea + id: diagnostics attributes: label: Diagnostics information placeholder: "drag-and-drop the diagnostics data file here (do not copy-and-paste the content)" @@ -87,6 +94,7 @@ body: It generally allows pinpointing defects and thus resolving issues faster. - type: textarea + id: configuration attributes: label: Example YAML snippet description: | @@ -94,11 +102,13 @@ body: This can be from an automation, script, scene or configuration. render: yaml - type: textarea + id: logs attributes: label: Anything in the logs that might be useful for us? description: For example, error message, or stack traces. render: txt - type: textarea + id: additional_information attributes: label: Additional information description: > From 3c49717915cb1eceabbd95af3782413817ef8d48 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 21 Sep 2026 12:35:11 -0400 Subject: [PATCH 02/33] Set tool metadata in light (#182705) Co-authored-by: Claude --- homeassistant/components/light/llm.py | 24 +++++++++++++++++++++--- tests/components/light/test_llm.py | 9 ++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/light/llm.py b/homeassistant/components/light/llm.py index 17df38c98e962..4fce9f00e7c59 100644 --- a/homeassistant/components/light/llm.py +++ b/homeassistant/components/light/llm.py @@ -4,13 +4,25 @@ from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolAnnotations, +) from .const import DOMAIN from .intent import INTENT_SET +# Each intent sets a value on the user's own entities, so calling one again +# with the same arguments has no further effect. +LLM_ANNOTATIONS = ToolAnnotations(idempotent=True, open_world=False) + # Intents owned by this integration that are exposed as LLM tools. -LLM_INTENTS = (INTENT_SET,) +LLM_INTENTS = { + INTENT_SET: "Set light", +} @callback @@ -31,7 +43,13 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) + IntentTool( + f"{DOMAIN}__{handler.intent_type}", + handler, + title=LLM_INTENTS[handler.intent_type], + integration=DOMAIN, + annotations=LLM_ANNOTATIONS, + ) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/tests/components/light/test_llm.py b/tests/components/light/test_llm.py index 3352e278260e1..d6f282da78300 100644 --- a/tests/components/light/test_llm.py +++ b/tests/components/light/test_llm.py @@ -43,7 +43,14 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed light entity.""" - assert "light__HassLightSet" in await _tool_names(hass) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tools = {tool.name: tool for tool in result.tools} + assert "light__HassLightSet" in tools + + tool = tools["light__HassLightSet"] + assert tool.title == "Set light" + assert tool.integration == "light" + assert tool.annotations == llm.ToolAnnotations(idempotent=True, open_world=False) async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: From 2efaca27d1c19e1eb8de8361b24c107a3a450e3e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:38:00 +0200 Subject: [PATCH 03/33] Update awalsh128/cache-apt-pkgs-action action to v1.6.3 (#182812) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/actions/cache-apt-packages/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/cache-apt-packages/action.yml b/.github/actions/cache-apt-packages/action.yml index 515831299f936..5af058055f7c3 100644 --- a/.github/actions/cache-apt-packages/action.yml +++ b/.github/actions/cache-apt-packages/action.yml @@ -29,7 +29,7 @@ runs: shell: bash run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list - name: Install apt packages via cache - uses: awalsh128/cache-apt-pkgs-action@2c09a5e66da6c8016428a2172bd76e5e4f14bb17 # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: packages: ${{ inputs.packages }} version: ${{ inputs.version }} From 92c59bf6e66d30b3743918a939dd4aba55b29ec8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:41:14 +0200 Subject: [PATCH 04/33] Move update service registration to services module (#182826) --- homeassistant/components/update/__init__.py | 97 +--------------- homeassistant/components/update/const.py | 10 +- homeassistant/components/update/services.py | 119 ++++++++++++++++++++ 3 files changed, 132 insertions(+), 94 deletions(-) create mode 100644 homeassistant/components/update/services.py diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 554d6cb04c716..8052a00b03ba3 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -17,14 +17,13 @@ EntityCategory, EntityStateAttribute, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ABCCachedProperties, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 ATTR_AUTO_UPDATE, @@ -39,6 +38,7 @@ ATTR_TITLE, ATTR_UPDATE_PERCENTAGE, ATTR_VERSION, + DATA_COMPONENT, DEVICE_CLASSES_SCHEMA, DOMAIN, SERVICE_INSTALL, @@ -47,10 +47,10 @@ UpdateEntityFeature, UpdateEntityStateAttribute, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[UpdateEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -85,29 +85,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_INSTALL, - { - probatio.Optional(ATTR_VERSION): cv.string, - probatio.Optional(ATTR_BACKUP, default=False): cv.boolean, - }, - async_install, - [UpdateEntityFeature.INSTALL], - admin_only=True, - ) - - component.async_register_entity_service( - SERVICE_SKIP, - None, - async_skip, - admin_only=True, - ) - component.async_register_entity_service( - "clear_skipped", - None, - async_clear_skipped, - admin_only=True, - ) + async_setup_services(hass) websocket_api.async_register_command(hass, websocket_release_notes) @@ -124,73 +102,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None: - """Service call wrapper to validate the call.""" - # If version is not specified, but no update is available. - if (version := service_call.data.get(ATTR_VERSION)) is None and ( - entity.installed_version == entity.latest_version - or entity.latest_version is None - ): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="no_update_available", - translation_placeholders={"entity_id": entity.entity_id}, - ) - - # If version is specified, but not supported by the entity. - if ( - version is not None - and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features - ): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="specific_version_not_supported", - translation_placeholders={"entity_id": entity.entity_id}, - ) - - # If backup is requested, but not supported by the entity. - if ( - backup := service_call.data[ATTR_BACKUP] - ) and UpdateEntityFeature.BACKUP not in entity.supported_features: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="backup_not_supported", - translation_placeholders={"entity_id": entity.entity_id}, - ) - - # Update is already in progress. - if entity.in_progress is not False: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="update_in_progress", - translation_placeholders={"entity_id": entity.entity_id}, - ) - - await entity.async_install_with_progress(version, backup) - - -async def async_skip(entity: UpdateEntity, service_call: ServiceCall) -> None: - """Service call wrapper to validate the call.""" - if entity.auto_update: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="skip_not_supported", - translation_placeholders={"entity_id": entity.entity_id}, - ) - await entity.async_skip() - - -async def async_clear_skipped(entity: UpdateEntity, service_call: ServiceCall) -> None: - """Service call wrapper to validate the call.""" - if entity.auto_update: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="clear_skipped_not_supported", - translation_placeholders={"entity_id": entity.entity_id}, - ) - await entity.async_clear_skipped() - - class UpdateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes update entities.""" diff --git a/homeassistant/components/update/const.py b/homeassistant/components/update/const.py index 50c7bed340f2d..363ef27c31fd0 100644 --- a/homeassistant/components/update/const.py +++ b/homeassistant/components/update/const.py @@ -1,11 +1,19 @@ """Constants for the update component.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final import probatio +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import UpdateEntity + DOMAIN: Final = "update" +DATA_COMPONENT: HassKey[EntityComponent[UpdateEntity]] = HassKey(DOMAIN) class UpdateEntityStateAttribute(StrEnum): diff --git a/homeassistant/components/update/services.py b/homeassistant/components/update/services.py new file mode 100644 index 0000000000000..1e69f03840af3 --- /dev/null +++ b/homeassistant/components/update/services.py @@ -0,0 +1,119 @@ +"""Services for the Update integration.""" + +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_BACKUP, + ATTR_VERSION, + DATA_COMPONENT, + DOMAIN, + SERVICE_INSTALL, + SERVICE_SKIP, + UpdateEntityFeature, +) + +if TYPE_CHECKING: + from . import UpdateEntity + + +async def _async_install(entity: UpdateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to validate the call.""" + # If version is not specified, but no update is available. + if (version := service_call.data.get(ATTR_VERSION)) is None and ( + entity.installed_version == entity.latest_version + or entity.latest_version is None + ): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_update_available", + translation_placeholders={"entity_id": entity.entity_id}, + ) + + # If version is specified, but not supported by the entity. + if ( + version is not None + and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features + ): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="specific_version_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, + ) + + # If backup is requested, but not supported by the entity. + if ( + backup := service_call.data[ATTR_BACKUP] + ) and UpdateEntityFeature.BACKUP not in entity.supported_features: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="backup_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, + ) + + # Update is already in progress. + if entity.in_progress is not False: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_in_progress", + translation_placeholders={"entity_id": entity.entity_id}, + ) + + await entity.async_install_with_progress(version, backup) + + +async def _async_skip(entity: UpdateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to validate the call.""" + if entity.auto_update: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="skip_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, + ) + await entity.async_skip() + + +async def _async_clear_skipped(entity: UpdateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to validate the call.""" + if entity.auto_update: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="clear_skipped_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, + ) + await entity.async_clear_skipped() + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the update services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_INSTALL, + { + probatio.Optional(ATTR_VERSION): cv.string, + probatio.Optional(ATTR_BACKUP, default=False): cv.boolean, + }, + _async_install, + [UpdateEntityFeature.INSTALL], + admin_only=True, + ) + + component.async_register_entity_service( + SERVICE_SKIP, + None, + _async_skip, + admin_only=True, + ) + component.async_register_entity_service( + "clear_skipped", + None, + _async_clear_skipped, + admin_only=True, + ) From 256bb25591b6d7bc406d8ade1069f1be2f51f499 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:41:19 +0200 Subject: [PATCH 05/33] Move humidifier service registration to services module (#182825) --- .../components/humidifier/__init__.py | 56 ++----------- homeassistant/components/humidifier/const.py | 10 ++- .../components/humidifier/services.py | 82 +++++++++++++++++++ 3 files changed, 96 insertions(+), 52 deletions(-) create mode 100644 homeassistant/components/humidifier/services.py diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 10994b43399b1..c4d10f6c4eb2e 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -4,24 +4,21 @@ import logging from typing import Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_MODE, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ServiceValidationError +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 ATTR_ACTION, @@ -31,6 +28,7 @@ ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, ATTR_TARGET_HUMIDITY_STEP, + DATA_COMPONENT, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, DEVICE_CLASSES_SCHEMA, @@ -52,10 +50,10 @@ HumidifierEntityFeature, HumidifierEntityStateAttribute, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[HumidifierEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -84,24 +82,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on") - component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off") - component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle") - component.async_register_entity_service( - SERVICE_SET_MODE, - {probatio.Required(ATTR_MODE): cv.string}, - "async_set_mode", - [HumidifierEntityFeature.MODES], - ) - component.async_register_entity_service( - SERVICE_SET_HUMIDITY, - { - probatio.Required(ATTR_HUMIDITY): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ) - }, - async_service_humidity_set, - ) + async_setup_services(hass) return True @@ -282,30 +263,3 @@ def max_humidity(self) -> float: def supported_features(self) -> HumidifierEntityFeature: """Return the list of supported features.""" return self._attr_supported_features - - -async def async_service_humidity_set( - entity: HumidifierEntity, service_call: ServiceCall -) -> None: - """Handle set humidity service.""" - humidity = service_call.data[ATTR_HUMIDITY] - min_humidity = entity.min_humidity - max_humidity = entity.max_humidity - _LOGGER.debug( - "Check valid humidity %d in range %d - %d", - humidity, - min_humidity, - max_humidity, - ) - if humidity < min_humidity or humidity > max_humidity: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="humidity_out_of_range", - translation_placeholders={ - "humidity": str(humidity), - "min_humidity": str(min_humidity), - "max_humidity": str(max_humidity), - }, - ) - - await entity.async_set_humidity(humidity) diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index c0e5f77084cf1..105cca67076b9 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -1,11 +1,19 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final import probatio +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import HumidifierEntity + DOMAIN: Final = "humidifier" +DATA_COMPONENT: HassKey[EntityComponent[HumidifierEntity]] = HassKey(DOMAIN) MODE_NORMAL = "normal" MODE_ECO = "eco" diff --git a/homeassistant/components/humidifier/services.py b/homeassistant/components/humidifier/services.py new file mode 100644 index 0000000000000..9f6b30fd4b16e --- /dev/null +++ b/homeassistant/components/humidifier/services.py @@ -0,0 +1,82 @@ +"""Services for the Humidifier integration.""" + +import logging +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.const import ( + ATTR_MODE, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_HUMIDITY, + DATA_COMPONENT, + DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + HumidifierEntityFeature, +) + +if TYPE_CHECKING: + from . import HumidifierEntity + +_LOGGER = logging.getLogger(__name__) + + +async def _async_service_humidity_set( + entity: HumidifierEntity, service_call: ServiceCall +) -> None: + """Handle set humidity service.""" + humidity = service_call.data[ATTR_HUMIDITY] + min_humidity = entity.min_humidity + max_humidity = entity.max_humidity + _LOGGER.debug( + "Check valid humidity %d in range %d - %d", + humidity, + min_humidity, + max_humidity, + ) + if humidity < min_humidity or humidity > max_humidity: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="humidity_out_of_range", + translation_placeholders={ + "humidity": str(humidity), + "min_humidity": str(min_humidity), + "max_humidity": str(max_humidity), + }, + ) + + await entity.async_set_humidity(humidity) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the humidifier services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on") + component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off") + component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle") + component.async_register_entity_service( + SERVICE_SET_MODE, + {probatio.Required(ATTR_MODE): cv.string}, + "async_set_mode", + [HumidifierEntityFeature.MODES], + ) + component.async_register_entity_service( + SERVICE_SET_HUMIDITY, + { + probatio.Required(ATTR_HUMIDITY): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ) + }, + _async_service_humidity_set, + ) From c2bda8931bfd25e12e86712d54e27ce99d84dce0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:41:43 +0200 Subject: [PATCH 06/33] Move datetime service registration to services module (#182822) --- homeassistant/components/datetime/__init__.py | 30 +++++----------- homeassistant/components/datetime/const.py | 10 +++++- homeassistant/components/datetime/services.py | 35 +++++++++++++++++++ 3 files changed, 53 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/datetime/services.py diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index b5c4f7df94504..794bae68ddc53 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -4,23 +4,25 @@ import logging from typing import final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util -from homeassistant.util.hass_dict import HassKey -from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE +from .const import ( # noqa: F401 + ATTR_DATETIME, + DATA_COMPONENT, + DOMAIN, + SERVICE_SET_VALUE, +) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[DateTimeEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -30,14 +32,6 @@ __all__ = ["ATTR_DATETIME", "DOMAIN", "DateTimeEntity", "DateTimeEntityDescription"] -async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new date/time.""" - value: datetime = service_call.data[ATTR_DATETIME] - if value.tzinfo is None: - value = value.replace(tzinfo=dt_util.get_default_time_zone()) - return await entity.async_set_value(value) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Date/Time entities.""" component = hass.data[DATA_COMPONENT] = EntityComponent[DateTimeEntity]( @@ -45,13 +39,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_SET_VALUE, - { - probatio.Required(ATTR_DATETIME): cv.datetime, - }, - _async_set_value, - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/datetime/const.py b/homeassistant/components/datetime/const.py index 983bf75e2436e..aa532ce4ff799 100644 --- a/homeassistant/components/datetime/const.py +++ b/homeassistant/components/datetime/const.py @@ -1,8 +1,16 @@ """Provides the constants needed for the component.""" -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import DateTimeEntity DOMAIN: Final = "datetime" +DATA_COMPONENT: HassKey[EntityComponent[DateTimeEntity]] = HassKey(DOMAIN) ATTR_DATETIME = "datetime" diff --git a/homeassistant/components/datetime/services.py b/homeassistant/components/datetime/services.py new file mode 100644 index 0000000000000..9e47a75bc64e6 --- /dev/null +++ b/homeassistant/components/datetime/services.py @@ -0,0 +1,35 @@ +"""Services for the Date/Time integration.""" + +from datetime import datetime +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.util import dt as dt_util + +from .const import ATTR_DATETIME, DATA_COMPONENT, SERVICE_SET_VALUE + +if TYPE_CHECKING: + from . import DateTimeEntity + + +async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date/time.""" + value: datetime = service_call.data[ATTR_DATETIME] + if value.tzinfo is None: + value = value.replace(tzinfo=dt_util.get_default_time_zone()) + return await entity.async_set_value(value) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the date/time services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_SET_VALUE, + { + probatio.Required(ATTR_DATETIME): cv.datetime, + }, + _async_set_value, + ) From 28703698b2b6b9b86d4cf9622232b469b1a596d0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:42:04 +0200 Subject: [PATCH 07/33] Move remote service registration to services module (#182818) --- homeassistant/components/remote/__init__.py | 104 +++++--------------- homeassistant/components/remote/const.py | 30 +++++- homeassistant/components/remote/services.py | 88 +++++++++++++++++ 3 files changed, 143 insertions(+), 79 deletions(-) create mode 100644 homeassistant/components/remote/services.py diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index aab883a5961f6..85c81fc4921c3 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -6,11 +6,10 @@ import logging from typing import Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_COMMAND, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -22,45 +21,41 @@ from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, RemoteEntityFeature, RemoteEntityStateAttribute +from .const import ( # noqa: F401 + ATTR_ACTIVITY, + ATTR_ACTIVITY_LIST, + ATTR_ALTERNATIVE, + ATTR_COMMAND_TYPE, + ATTR_CURRENT_ACTIVITY, + ATTR_DELAY_SECS, + ATTR_DEVICE, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, + ATTR_TIMEOUT, + DATA_COMPONENT, + DEFAULT_DELAY_SECS, + DEFAULT_HOLD_SECS, + DEFAULT_NUM_REPEATS, + DOMAIN, + SERVICE_DELETE_COMMAND, + SERVICE_LEARN_COMMAND, + SERVICE_SEND_COMMAND, + SERVICE_SYNC, + RemoteEntityFeature, + RemoteEntityStateAttribute, +) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[RemoteEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=30) -ATTR_ACTIVITY = "activity" -ATTR_ACTIVITY_LIST = "activity_list" -ATTR_CURRENT_ACTIVITY = "current_activity" -ATTR_COMMAND_TYPE = "command_type" -ATTR_DEVICE = "device" -ATTR_NUM_REPEATS = "num_repeats" -ATTR_DELAY_SECS = "delay_secs" -ATTR_HOLD_SECS = "hold_secs" -ATTR_ALTERNATIVE = "alternative" -ATTR_TIMEOUT = "timeout" - MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -SERVICE_SEND_COMMAND = "send_command" -SERVICE_LEARN_COMMAND = "learn_command" -SERVICE_DELETE_COMMAND = "delete_command" -SERVICE_SYNC = "sync" - -DEFAULT_NUM_REPEATS = 1 -DEFAULT_DELAY_SECS = 0.4 -DEFAULT_HOLD_SECS = 0 - - -REMOTE_SERVICE_ACTIVITY_SCHEMA = cv.make_entity_service_schema( - {probatio.Optional(ATTR_ACTIVITY): cv.string} -) - def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the remote is on based on the statemachine.""" @@ -74,54 +69,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_TURN_OFF, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_turn_off" - ) - - component.async_register_entity_service( - SERVICE_TURN_ON, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_turn_on" - ) - - component.async_register_entity_service( - SERVICE_TOGGLE, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_toggle" - ) - - component.async_register_entity_service( - SERVICE_SEND_COMMAND, - { - probatio.Required(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), - probatio.Optional(ATTR_DEVICE): cv.string, - probatio.Optional( - ATTR_NUM_REPEATS, default=DEFAULT_NUM_REPEATS - ): cv.positive_int, - probatio.Optional(ATTR_DELAY_SECS): probatio.Coerce(float), - probatio.Optional( - ATTR_HOLD_SECS, default=DEFAULT_HOLD_SECS - ): probatio.Coerce(float), - }, - "async_send_command", - ) - - component.async_register_entity_service( - SERVICE_LEARN_COMMAND, - { - probatio.Optional(ATTR_DEVICE): cv.string, - probatio.Optional(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), - probatio.Optional(ATTR_COMMAND_TYPE): cv.string, - probatio.Optional(ATTR_ALTERNATIVE): cv.boolean, - probatio.Optional(ATTR_TIMEOUT): cv.positive_int, - }, - "async_learn_command", - ) - - component.async_register_entity_service( - SERVICE_DELETE_COMMAND, - { - probatio.Required(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), - probatio.Optional(ATTR_DEVICE): cv.string, - }, - "async_delete_command", - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/remote/const.py b/homeassistant/components/remote/const.py index 72aa671df9237..ac35c5d201128 100644 --- a/homeassistant/components/remote/const.py +++ b/homeassistant/components/remote/const.py @@ -1,9 +1,37 @@ """Constants for the remote component.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import RemoteEntity DOMAIN: Final = "remote" +DATA_COMPONENT: HassKey[EntityComponent[RemoteEntity]] = HassKey(DOMAIN) + +ATTR_ACTIVITY = "activity" +ATTR_ACTIVITY_LIST = "activity_list" +ATTR_CURRENT_ACTIVITY = "current_activity" +ATTR_COMMAND_TYPE = "command_type" +ATTR_DEVICE = "device" +ATTR_NUM_REPEATS = "num_repeats" +ATTR_DELAY_SECS = "delay_secs" +ATTR_HOLD_SECS = "hold_secs" +ATTR_ALTERNATIVE = "alternative" +ATTR_TIMEOUT = "timeout" + +SERVICE_SEND_COMMAND = "send_command" +SERVICE_LEARN_COMMAND = "learn_command" +SERVICE_DELETE_COMMAND = "delete_command" +SERVICE_SYNC = "sync" + +DEFAULT_NUM_REPEATS = 1 +DEFAULT_DELAY_SECS = 0.4 +DEFAULT_HOLD_SECS = 0 class RemoteEntityStateAttribute(StrEnum): diff --git a/homeassistant/components/remote/services.py b/homeassistant/components/remote/services.py new file mode 100644 index 0000000000000..c0b22553a1191 --- /dev/null +++ b/homeassistant/components/remote/services.py @@ -0,0 +1,88 @@ +"""Services for the Remote integration.""" + +import probatio + +from homeassistant.const import ( + ATTR_COMMAND, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_ACTIVITY, + ATTR_ALTERNATIVE, + ATTR_COMMAND_TYPE, + ATTR_DELAY_SECS, + ATTR_DEVICE, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, + ATTR_TIMEOUT, + DATA_COMPONENT, + DEFAULT_HOLD_SECS, + DEFAULT_NUM_REPEATS, + SERVICE_DELETE_COMMAND, + SERVICE_LEARN_COMMAND, + SERVICE_SEND_COMMAND, +) + +REMOTE_SERVICE_ACTIVITY_SCHEMA = cv.make_entity_service_schema( + {probatio.Optional(ATTR_ACTIVITY): cv.string} +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the remote services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_TURN_OFF, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_turn_off" + ) + + component.async_register_entity_service( + SERVICE_TURN_ON, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_turn_on" + ) + + component.async_register_entity_service( + SERVICE_TOGGLE, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_toggle" + ) + + component.async_register_entity_service( + SERVICE_SEND_COMMAND, + { + probatio.Required(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), + probatio.Optional(ATTR_DEVICE): cv.string, + probatio.Optional( + ATTR_NUM_REPEATS, default=DEFAULT_NUM_REPEATS + ): cv.positive_int, + probatio.Optional(ATTR_DELAY_SECS): probatio.Coerce(float), + probatio.Optional( + ATTR_HOLD_SECS, default=DEFAULT_HOLD_SECS + ): probatio.Coerce(float), + }, + "async_send_command", + ) + + component.async_register_entity_service( + SERVICE_LEARN_COMMAND, + { + probatio.Optional(ATTR_DEVICE): cv.string, + probatio.Optional(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), + probatio.Optional(ATTR_COMMAND_TYPE): cv.string, + probatio.Optional(ATTR_ALTERNATIVE): cv.boolean, + probatio.Optional(ATTR_TIMEOUT): cv.positive_int, + }, + "async_learn_command", + ) + + component.async_register_entity_service( + SERVICE_DELETE_COMMAND, + { + probatio.Required(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), + probatio.Optional(ATTR_DEVICE): cv.string, + }, + "async_delete_command", + ) From fd5526d06aeeaf7d54daa273e8d5da1aec543458 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:42:09 +0200 Subject: [PATCH 08/33] Move number service registration to services module (#182820) --- homeassistant/components/number/__init__.py | 45 ++----------------- homeassistant/components/number/const.py | 10 ++++- homeassistant/components/number/services.py | 49 +++++++++++++++++++++ 3 files changed, 62 insertions(+), 42 deletions(-) create mode 100644 homeassistant/components/number/services.py diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index a20acb1573254..4acf92e9e4df0 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -8,7 +8,6 @@ from math import ceil, floor from typing import TYPE_CHECKING, Any, Self, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -17,20 +16,13 @@ CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature, ) -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - async_get_hass_or_none, - callback, -) -from homeassistant.exceptions import ServiceValidationError +from homeassistant.core import HomeAssistant, async_get_hass_or_none, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_suggest_report_issue -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 AMBIGUOUS_UNITS, @@ -38,6 +30,7 @@ ATTR_MIN, ATTR_STEP, ATTR_VALUE, + DATA_COMPONENT, DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DEFAULT_STEP, @@ -50,11 +43,11 @@ NumberEntityCapabilityAttribute, NumberMode, ) +from .services import async_setup_services from .websocket_api import async_setup as async_setup_ws_api _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[NumberEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -94,41 +87,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_setup_ws_api(hass) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_SET_VALUE, - {probatio.Required(ATTR_VALUE): probatio.Coerce(float)}, - async_set_value, - ) + async_setup_services(hass) return True -async def async_set_value(entity: NumberEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new value.""" - value = service_call.data["value"] - if value < entity.min_value or value > entity.max_value: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="out_of_range", - translation_placeholders={ - "value": value, - "entity_id": entity.entity_id, - "min_value": str(entity.min_value), - "max_value": str(entity.max_value), - }, - ) - - try: - native_value = entity.convert_to_native_value(value) - # Clamp to the native range - native_value = min( - max(native_value, entity.native_min_value), entity.native_max_value - ) - await entity.async_set_native_value(native_value) - except NotImplementedError: - await entity.async_set_value(value) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" return await hass.data[DATA_COMPONENT].async_setup_entry(entry) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 5344b46ecaf18..b25135ae8f750 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -1,7 +1,7 @@ """Provides the constants needed for the component.""" from enum import StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final import probatio @@ -40,6 +40,7 @@ UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) +from homeassistant.util.hass_dict import HassKey from homeassistant.util.unit_conversion import ( ApparentPowerConverter, AreaConverter, @@ -77,6 +78,13 @@ DOMAIN: Final = "number" +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import NumberEntity + +DATA_COMPONENT: HassKey[EntityComponent[NumberEntity]] = HassKey(DOMAIN) + ATTR_VALUE = "value" ATTR_MIN = "min" ATTR_MAX = "max" diff --git a/homeassistant/components/number/services.py b/homeassistant/components/number/services.py new file mode 100644 index 0000000000000..cb395d95524eb --- /dev/null +++ b/homeassistant/components/number/services.py @@ -0,0 +1,49 @@ +"""Services for the Number integration.""" + +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError + +from .const import ATTR_VALUE, DATA_COMPONENT, DOMAIN, SERVICE_SET_VALUE + +if TYPE_CHECKING: + from . import NumberEntity + + +async def _async_set_value(entity: NumberEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new value.""" + value = service_call.data["value"] + if value < entity.min_value or value > entity.max_value: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="out_of_range", + translation_placeholders={ + "value": value, + "entity_id": entity.entity_id, + "min_value": str(entity.min_value), + "max_value": str(entity.max_value), + }, + ) + + try: + native_value = entity.convert_to_native_value(value) + # Clamp to the native range + native_value = min( + max(native_value, entity.native_min_value), entity.native_max_value + ) + await entity.async_set_native_value(native_value) + except NotImplementedError: + await entity.async_set_value(value) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the number services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_SET_VALUE, + {probatio.Required(ATTR_VALUE): probatio.Coerce(float)}, + _async_set_value, + ) From 96720c864717d822478ea736b7708fa2120a9d72 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:42:22 +0200 Subject: [PATCH 09/33] Move fan service registration to services module (#182817) --- homeassistant/components/fan/__init__.py | 112 ++++------------------- homeassistant/components/fan/const.py | 27 +++++- homeassistant/components/fan/services.py | 100 ++++++++++++++++++++ 3 files changed, 145 insertions(+), 94 deletions(-) create mode 100644 homeassistant/components/fan/services.py diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 921a6b9c3a1d3..189b05ef328cc 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -6,11 +6,10 @@ import math from typing import Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -22,45 +21,41 @@ from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, ) -from .const import ( +from .const import ( # noqa: F401 + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DATA_COMPONENT, + DIRECTION_FORWARD, + DIRECTION_REVERSE, DOMAIN, + SERVICE_DECREASE_SPEED, + SERVICE_INCREASE_SPEED, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, FanEntityCapabilityAttribute, FanEntityFeature, FanEntityStateAttribute, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[FanEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=30) - -SERVICE_INCREASE_SPEED = "increase_speed" -SERVICE_DECREASE_SPEED = "decrease_speed" -SERVICE_OSCILLATE = "oscillate" -SERVICE_SET_DIRECTION = "set_direction" -SERVICE_SET_PERCENTAGE = "set_percentage" -SERVICE_SET_PRESET_MODE = "set_preset_mode" - -DIRECTION_FORWARD = "forward" -DIRECTION_REVERSE = "reverse" - -ATTR_PERCENTAGE = "percentage" -ATTR_PERCENTAGE_STEP = "percentage_step" -ATTR_OSCILLATING = "oscillating" -ATTR_DIRECTION = "direction" -ATTR_PRESET_MODE = "preset_mode" -ATTR_PRESET_MODES = "preset_modes" - # mypy: disallow-any-generics @@ -94,76 +89,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) - # After the transition to percentage and preset_modes concludes, - # switch this back to async_turn_on and remove async_turn_on_compat - component.async_register_entity_service( - SERVICE_TURN_ON, - { - probatio.Optional(ATTR_PERCENTAGE): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ), - probatio.Optional(ATTR_PRESET_MODE): cv.string, - }, - "async_handle_turn_on_service", - [FanEntityFeature.TURN_ON], - ) - component.async_register_entity_service( - SERVICE_TURN_OFF, None, "async_turn_off", [FanEntityFeature.TURN_OFF] - ) - component.async_register_entity_service( - SERVICE_TOGGLE, - None, - "async_toggle", - [FanEntityFeature.TURN_OFF, FanEntityFeature.TURN_ON], - ) - component.async_register_entity_service( - SERVICE_INCREASE_SPEED, - { - probatio.Optional(ATTR_PERCENTAGE_STEP): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ) - }, - "async_increase_speed", - [FanEntityFeature.SET_SPEED], - ) - component.async_register_entity_service( - SERVICE_DECREASE_SPEED, - { - probatio.Optional(ATTR_PERCENTAGE_STEP): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ) - }, - "async_decrease_speed", - [FanEntityFeature.SET_SPEED], - ) - component.async_register_entity_service( - SERVICE_OSCILLATE, - {probatio.Required(ATTR_OSCILLATING): cv.boolean}, - "async_oscillate", - [FanEntityFeature.OSCILLATE], - ) - component.async_register_entity_service( - SERVICE_SET_DIRECTION, - {probatio.Optional(ATTR_DIRECTION): cv.string}, - "async_set_direction", - [FanEntityFeature.DIRECTION], - ) - component.async_register_entity_service( - SERVICE_SET_PERCENTAGE, - { - probatio.Required(ATTR_PERCENTAGE): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ) - }, - "async_set_percentage", - [FanEntityFeature.SET_SPEED], - ) - component.async_register_entity_service( - SERVICE_SET_PRESET_MODE, - {probatio.Required(ATTR_PRESET_MODE): cv.string}, - "async_handle_set_preset_mode_service", - [FanEntityFeature.SET_SPEED, FanEntityFeature.PRESET_MODE], - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/fan/const.py b/homeassistant/components/fan/const.py index 1d565787f2147..97893dba42105 100644 --- a/homeassistant/components/fan/const.py +++ b/homeassistant/components/fan/const.py @@ -1,9 +1,34 @@ """Constants for the fan component.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import FanEntity DOMAIN: Final = "fan" +DATA_COMPONENT: HassKey[EntityComponent[FanEntity]] = HassKey(DOMAIN) + +SERVICE_INCREASE_SPEED = "increase_speed" +SERVICE_DECREASE_SPEED = "decrease_speed" +SERVICE_OSCILLATE = "oscillate" +SERVICE_SET_DIRECTION = "set_direction" +SERVICE_SET_PERCENTAGE = "set_percentage" +SERVICE_SET_PRESET_MODE = "set_preset_mode" + +DIRECTION_FORWARD = "forward" +DIRECTION_REVERSE = "reverse" + +ATTR_PERCENTAGE = "percentage" +ATTR_PERCENTAGE_STEP = "percentage_step" +ATTR_OSCILLATING = "oscillating" +ATTR_DIRECTION = "direction" +ATTR_PRESET_MODE = "preset_mode" +ATTR_PRESET_MODES = "preset_modes" class FanEntityCapabilityAttribute(StrEnum): diff --git a/homeassistant/components/fan/services.py b/homeassistant/components/fan/services.py new file mode 100644 index 0000000000000..63f0c0e00fec8 --- /dev/null +++ b/homeassistant/components/fan/services.py @@ -0,0 +1,100 @@ +"""Services for the Fan integration.""" + +import probatio + +from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, + DATA_COMPONENT, + SERVICE_DECREASE_SPEED, + SERVICE_INCREASE_SPEED, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, + FanEntityFeature, +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the fan services.""" + component = hass.data[DATA_COMPONENT] + + # After the transition to percentage and preset_modes concludes, + # switch this back to async_turn_on and remove async_turn_on_compat + component.async_register_entity_service( + SERVICE_TURN_ON, + { + probatio.Optional(ATTR_PERCENTAGE): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ), + probatio.Optional(ATTR_PRESET_MODE): cv.string, + }, + "async_handle_turn_on_service", + [FanEntityFeature.TURN_ON], + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, None, "async_turn_off", [FanEntityFeature.TURN_OFF] + ) + component.async_register_entity_service( + SERVICE_TOGGLE, + None, + "async_toggle", + [FanEntityFeature.TURN_OFF, FanEntityFeature.TURN_ON], + ) + component.async_register_entity_service( + SERVICE_INCREASE_SPEED, + { + probatio.Optional(ATTR_PERCENTAGE_STEP): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ) + }, + "async_increase_speed", + [FanEntityFeature.SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_DECREASE_SPEED, + { + probatio.Optional(ATTR_PERCENTAGE_STEP): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ) + }, + "async_decrease_speed", + [FanEntityFeature.SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_OSCILLATE, + {probatio.Required(ATTR_OSCILLATING): cv.boolean}, + "async_oscillate", + [FanEntityFeature.OSCILLATE], + ) + component.async_register_entity_service( + SERVICE_SET_DIRECTION, + {probatio.Optional(ATTR_DIRECTION): cv.string}, + "async_set_direction", + [FanEntityFeature.DIRECTION], + ) + component.async_register_entity_service( + SERVICE_SET_PERCENTAGE, + { + probatio.Required(ATTR_PERCENTAGE): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ) + }, + "async_set_percentage", + [FanEntityFeature.SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_SET_PRESET_MODE, + {probatio.Required(ATTR_PRESET_MODE): cv.string}, + "async_handle_set_preset_mode_service", + [FanEntityFeature.SET_SPEED, FanEntityFeature.PRESET_MODE], + ) From 7fe792f1b90282123d2dcee9a0ebf052f8120f9c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:42:30 +0200 Subject: [PATCH 10/33] Move alarm_control_panel service registration to services module (#182816) --- .../alarm_control_panel/__init__.py | 53 +------------- .../components/alarm_control_panel/const.py | 10 ++- .../alarm_control_panel/services.py | 73 +++++++++++++++++++ 3 files changed, 85 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/alarm_control_panel/services.py diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index f20dfd2b83d89..7f297ff81fe90 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -4,7 +4,6 @@ import logging from typing import Any, Final, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -22,25 +21,24 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 ATTR_CHANGED_BY, ATTR_CODE_ARM_REQUIRED, + DATA_COMPONENT, DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelEntityStateAttribute, AlarmControlPanelState, CodeFormat, ) +from .services import async_setup_services _LOGGER: Final = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[AlarmControlPanelEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA: Final = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE: Final = cv.PLATFORM_SCHEMA_BASE @@ -48,11 +46,6 @@ CONF_DEFAULT_CODE = "default_code" -ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema( - {probatio.Optional(ATTR_CODE): cv.string} -) - - # mypy: disallow-any-generics @@ -64,47 +57,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) - component.async_register_entity_service( - SERVICE_ALARM_DISARM, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_disarm", - ) - component.async_register_entity_service( - SERVICE_ALARM_ARM_HOME, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_arm_home", - [AlarmControlPanelEntityFeature.ARM_HOME], - ) - component.async_register_entity_service( - SERVICE_ALARM_ARM_AWAY, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_arm_away", - [AlarmControlPanelEntityFeature.ARM_AWAY], - ) - component.async_register_entity_service( - SERVICE_ALARM_ARM_NIGHT, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_arm_night", - [AlarmControlPanelEntityFeature.ARM_NIGHT], - ) - component.async_register_entity_service( - SERVICE_ALARM_ARM_VACATION, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_arm_vacation", - [AlarmControlPanelEntityFeature.ARM_VACATION], - ) - component.async_register_entity_service( - SERVICE_ALARM_ARM_CUSTOM_BYPASS, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_arm_custom_bypass", - [AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS], - ) - component.async_register_entity_service( - SERVICE_ALARM_TRIGGER, - ALARM_SERVICE_SCHEMA, - "async_alarm_trigger", - [AlarmControlPanelEntityFeature.TRIGGER], - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py index e66f25c72f9d4..659bc158df87c 100644 --- a/homeassistant/components/alarm_control_panel/const.py +++ b/homeassistant/components/alarm_control_panel/const.py @@ -1,9 +1,17 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import AlarmControlPanelEntity DOMAIN: Final = "alarm_control_panel" +DATA_COMPONENT: HassKey[EntityComponent[AlarmControlPanelEntity]] = HassKey(DOMAIN) ATTR_CHANGED_BY: Final = "changed_by" ATTR_CODE_ARM_REQUIRED: Final = "code_arm_required" diff --git a/homeassistant/components/alarm_control_panel/services.py b/homeassistant/components/alarm_control_panel/services.py new file mode 100644 index 0000000000000..5a1c6696486cd --- /dev/null +++ b/homeassistant/components/alarm_control_panel/services.py @@ -0,0 +1,73 @@ +"""Services for the Alarm control panel integration.""" + +from typing import Final + +import probatio + +from homeassistant.const import ( + ATTR_CODE, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_validation import make_entity_service_schema + +from .const import DATA_COMPONENT, AlarmControlPanelEntityFeature + +ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema( + {probatio.Optional(ATTR_CODE): cv.string} +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the alarm control panel services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_ALARM_DISARM, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_disarm", + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_HOME, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_arm_home", + [AlarmControlPanelEntityFeature.ARM_HOME], + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_AWAY, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_arm_away", + [AlarmControlPanelEntityFeature.ARM_AWAY], + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_NIGHT, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_arm_night", + [AlarmControlPanelEntityFeature.ARM_NIGHT], + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_VACATION, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_arm_vacation", + [AlarmControlPanelEntityFeature.ARM_VACATION], + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_arm_custom_bypass", + [AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS], + ) + component.async_register_entity_service( + SERVICE_ALARM_TRIGGER, + ALARM_SERVICE_SCHEMA, + "async_alarm_trigger", + [AlarmControlPanelEntityFeature.TRIGGER], + ) From a0338b5a822d08ebb144406e4e6ce7b9151a1738 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:43:01 +0200 Subject: [PATCH 11/33] Move text service registration to services module (#182824) --- homeassistant/components/text/__init__.py | 34 +++--------------- homeassistant/components/text/const.py | 10 +++++- homeassistant/components/text/services.py | 44 +++++++++++++++++++++++ tests/components/text/test_init.py | 2 +- 4 files changed, 58 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/text/services.py diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index cee4d3c1e9077..fe654986e5530 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -7,32 +7,31 @@ import re from typing import Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, MAX_LENGTH_STATE_STATE # noqa: F401 -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 ATTR_MAX, ATTR_MIN, ATTR_PATTERN, ATTR_VALUE, + DATA_COMPONENT, DOMAIN, SERVICE_SET_VALUE, TextEntityCapabilityAttribute, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[TextEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -57,36 +56,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_SET_VALUE, - {probatio.Required(ATTR_VALUE): cv.string}, - _async_set_value, - ) + async_setup_services(hass) return True -async def _async_set_value(entity: TextEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new value.""" - value = service_call.data[ATTR_VALUE] - if len(value) < entity.min: - raise ValueError( - f"Value {value} for {entity.entity_id} is too short (minimum length" - f" {entity.min})" - ) - if len(value) > entity.max: - raise ValueError( - f"Value {value} for {entity.entity_id}" - f" is too long (maximum length {entity.max})" - ) - if entity.pattern_cmp and not entity.pattern_cmp.match(value): - raise ValueError( - f"Value {value} for {entity.entity_id}" - f" doesn't match pattern {entity.pattern}" - ) - await entity.async_set_value(value) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" return await hass.data[DATA_COMPONENT].async_setup_entry(entry) diff --git a/homeassistant/components/text/const.py b/homeassistant/components/text/const.py index b0f52a9b6983b..8056df4eb7b6d 100644 --- a/homeassistant/components/text/const.py +++ b/homeassistant/components/text/const.py @@ -1,9 +1,17 @@ """Provides the constants needed for the component.""" from enum import StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import TextEntity DOMAIN: Final = "text" +DATA_COMPONENT: HassKey[EntityComponent[TextEntity]] = HassKey(DOMAIN) class TextEntityCapabilityAttribute(StrEnum): diff --git a/homeassistant/components/text/services.py b/homeassistant/components/text/services.py new file mode 100644 index 0000000000000..ff0a62562fc80 --- /dev/null +++ b/homeassistant/components/text/services.py @@ -0,0 +1,44 @@ +"""Services for the Text integration.""" + +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .const import ATTR_VALUE, DATA_COMPONENT, SERVICE_SET_VALUE + +if TYPE_CHECKING: + from . import TextEntity + + +async def _async_set_value(entity: TextEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new value.""" + value = service_call.data[ATTR_VALUE] + if len(value) < entity.min: + raise ValueError( + f"Value {value} for {entity.entity_id} is too short (minimum length" + f" {entity.min})" + ) + if len(value) > entity.max: + raise ValueError( + f"Value {value} for {entity.entity_id}" + f" is too long (maximum length {entity.max})" + ) + if entity.pattern_cmp and not entity.pattern_cmp.match(value): + raise ValueError( + f"Value {value} for {entity.entity_id}" + f" doesn't match pattern {entity.pattern}" + ) + await entity.async_set_value(value) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the text services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_SET_VALUE, + {probatio.Required(ATTR_VALUE): cv.string}, + _async_set_value, + ) diff --git a/tests/components/text/test_init.py b/tests/components/text/test_init.py index 1dda5c3fb7715..f1e809b5e20be 100644 --- a/tests/components/text/test_init.py +++ b/tests/components/text/test_init.py @@ -13,8 +13,8 @@ DOMAIN, SERVICE_SET_VALUE, TextMode, - _async_set_value, ) +from homeassistant.components.text.services import _async_set_value from homeassistant.const import MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, ServiceCall, State from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY From ed63699a4b024c519dba23364c4b10cbe4654557 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:43:30 +0200 Subject: [PATCH 12/33] Move date service registration to services module (#182821) --- homeassistant/components/date/__init__.py | 19 +++++----------- homeassistant/components/date/const.py | 10 ++++++++- homeassistant/components/date/services.py | 27 +++++++++++++++++++++++ 3 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/date/services.py diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index a1a2e8cb0f487..ab675f2960bf6 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -4,23 +4,21 @@ import logging from typing import final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_DATE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import ATTR_DATE # noqa: F401 +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, SERVICE_SET_VALUE +from .const import DATA_COMPONENT, DOMAIN, SERVICE_SET_VALUE # noqa: F401 +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[DateEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -30,11 +28,6 @@ __all__ = ["DOMAIN", "DateEntity", "DateEntityDescription"] -async def _async_set_value(entity: DateEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new date.""" - return await entity.async_set_value(service_call.data[ATTR_DATE]) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Date entities.""" component = hass.data[DATA_COMPONENT] = EntityComponent[DateEntity]( @@ -42,9 +35,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_SET_VALUE, {probatio.Required(ATTR_DATE): cv.date}, _async_set_value - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/date/const.py b/homeassistant/components/date/const.py index 48eef31bed2d9..e229e980eef5a 100644 --- a/homeassistant/components/date/const.py +++ b/homeassistant/components/date/const.py @@ -1,7 +1,15 @@ """Provides the constants needed for the component.""" -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import DateEntity DOMAIN: Final = "date" +DATA_COMPONENT: HassKey[EntityComponent[DateEntity]] = HassKey(DOMAIN) SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/date/services.py b/homeassistant/components/date/services.py new file mode 100644 index 0000000000000..057bf1d545938 --- /dev/null +++ b/homeassistant/components/date/services.py @@ -0,0 +1,27 @@ +"""Services for the Date integration.""" + +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.const import ATTR_DATE +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .const import DATA_COMPONENT, SERVICE_SET_VALUE + +if TYPE_CHECKING: + from . import DateEntity + + +async def _async_set_value(entity: DateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date.""" + return await entity.async_set_value(service_call.data[ATTR_DATE]) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the date services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_SET_VALUE, {probatio.Required(ATTR_DATE): cv.date}, _async_set_value + ) From 602d564903ac3fee08dd2cd083c3c023a894a65d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:43:55 +0200 Subject: [PATCH 13/33] Move lock service registration to services module (#182815) --- homeassistant/components/lock/__init__.py | 29 +++++++------------- homeassistant/components/lock/const.py | 10 ++++++- homeassistant/components/lock/services.py | 32 +++++++++++++++++++++++ 3 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/lock/services.py diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 66c6566ff0d5a..0b07d6c978dd6 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -6,7 +6,6 @@ import re from typing import TYPE_CHECKING, Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -23,13 +22,18 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType -from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, LockEntityFeature, LockEntityStateAttribute, LockState +from .const import ( + DATA_COMPONENT, + DOMAIN, + LockEntityFeature, + LockEntityStateAttribute, + LockState, +) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[LockEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -40,10 +44,6 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -LOCK_SERVICE_SCHEMA = cv.make_entity_service_schema( - {probatio.Optional(ATTR_CODE): cv.string} -) - PROP_TO_ATTR = { "changed_by": LockEntityStateAttribute.CHANGED_BY, @@ -61,18 +61,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) - component.async_register_entity_service( - SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service" - ) - component.async_register_entity_service( - SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service" - ) - component.async_register_entity_service( - SERVICE_OPEN, - LOCK_SERVICE_SCHEMA, - "async_handle_open_service", - [LockEntityFeature.OPEN], - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/lock/const.py b/homeassistant/components/lock/const.py index 8e89832581160..fee089854add1 100644 --- a/homeassistant/components/lock/const.py +++ b/homeassistant/components/lock/const.py @@ -1,9 +1,17 @@ """Constants for the lock entity platform.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import LockEntity DOMAIN: Final = "lock" +DATA_COMPONENT: HassKey[EntityComponent[LockEntity]] = HassKey(DOMAIN) class LockEntityStateAttribute(StrEnum): diff --git a/homeassistant/components/lock/services.py b/homeassistant/components/lock/services.py new file mode 100644 index 0000000000000..5ebd0bed9578d --- /dev/null +++ b/homeassistant/components/lock/services.py @@ -0,0 +1,32 @@ +"""Services for the Lock integration.""" + +import probatio + +from homeassistant.const import ATTR_CODE, SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv + +from .const import DATA_COMPONENT, LockEntityFeature + +LOCK_SERVICE_SCHEMA = cv.make_entity_service_schema( + {probatio.Optional(ATTR_CODE): cv.string} +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the lock services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service" + ) + component.async_register_entity_service( + SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service" + ) + component.async_register_entity_service( + SERVICE_OPEN, + LOCK_SERVICE_SCHEMA, + "async_handle_open_service", + [LockEntityFeature.OPEN], + ) From 3eebe7ec5f544bbbfaa68f6779f0e6f11f8a989a Mon Sep 17 00:00:00 2001 From: Thomas <10558666+mosandlt@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:45:21 +0200 Subject: [PATCH 14/33] bosch_shc: rename Smart Plug routing switch to "Range extension" (#182836) Co-authored-by: Claude Sonnet 5 --- .../components/bosch_shc/strings.json | 2 +- tests/components/bosch_shc/conftest.py | 23 +++++++++++++++++++ tests/components/bosch_shc/test_switch.py | 22 ++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 1cc1069f2c24d..d2359625d613c 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -71,7 +71,7 @@ "name": "Child lock" }, "routing": { - "name": "Routing" + "name": "Range extension" } } } diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index c8cf981a38dd5..7b282a71a86b3 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -8,12 +8,14 @@ from boschshcpy import ( BatteryLevelService, PowerSwitchService, + RoutingService, SHCBatteryDevice, SHCLightSwitchBSM, SHCMicromoduleBlinds, SHCMicromoduleRelay, SHCPresenceSimulationSystem, SHCShutterControl, + SHCSmartPlug, SHCThermostat, ShutterControlService, ThermostatService, @@ -191,6 +193,27 @@ def micromodule_blinds_device( return device +def smart_plug_device( + device_id: str = "hdm:ZigBee:plug1", + name: str = "Smart Plug", + routing: RoutingService.State = RoutingService.State.DISABLED, +) -> SHCSmartPlug: + """Build a minimal device double for the smart_plugs bucket.""" + device = create_autospec(SHCSmartPlug, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = "PSM" + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.switchstate = PowerSwitchService.State.OFF + device.routing = routing + return device + + def thermostat_device( device_id: str = "hdm:ZigBee:thermostat1", name: str = "Thermostat", diff --git a/tests/components/bosch_shc/test_switch.py b/tests/components/bosch_shc/test_switch.py index 4955107cc7b4f..2a181a409acda 100644 --- a/tests/components/bosch_shc/test_switch.py +++ b/tests/components/bosch_shc/test_switch.py @@ -21,6 +21,7 @@ micromodule_relay_device, presence_simulation_system_device, setup_integration, + smart_plug_device, thermostat_device, ) @@ -193,3 +194,24 @@ async def test_no_presence_simulation_system( await setup_integration(hass, mock_config_entry) assert hass.states.get("switch.presence_simulation") is None + + +@pytest.mark.parametrize( + "device_buckets", + [{"smart_plugs": [smart_plug_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_smart_plug_routing_switch_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """The Smart Plug's routing switch is named "Range extension", not "Routing".""" + await setup_integration(hass, mock_config_entry) + + entry = entity_registry.async_get("switch.smart_plug_range_extension") + assert entry is not None + state = hass.states.get("switch.smart_plug_range_extension") + assert state is not None + assert state.attributes["friendly_name"] == "Smart Plug Range extension" From 082ba4b922c55eebaa3f34548be337344551a173 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:45:32 +0200 Subject: [PATCH 15/33] Move time service registration to services module (#182823) --- homeassistant/components/time/__init__.py | 19 +++++----------- homeassistant/components/time/const.py | 10 ++++++++- homeassistant/components/time/services.py | 27 +++++++++++++++++++++++ 3 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/time/services.py diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 56a9e9e81c789..fa7fb8c292dc7 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -4,23 +4,21 @@ import logging from typing import final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TIME -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import ATTR_TIME # noqa: F401 +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, SERVICE_SET_VALUE +from .const import DATA_COMPONENT, DOMAIN, SERVICE_SET_VALUE # noqa: F401 +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[TimeEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -30,11 +28,6 @@ __all__ = ["DOMAIN", "TimeEntity", "TimeEntityDescription"] -async def _async_set_value(entity: TimeEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new date.""" - return await entity.async_set_value(service_call.data[ATTR_TIME]) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Time entities.""" component = hass.data[DATA_COMPONENT] = EntityComponent[TimeEntity]( @@ -42,9 +35,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_SET_VALUE, {probatio.Required(ATTR_TIME): cv.time}, _async_set_value - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/time/const.py b/homeassistant/components/time/const.py index 441f87a914a99..d1fba65fea643 100644 --- a/homeassistant/components/time/const.py +++ b/homeassistant/components/time/const.py @@ -1,7 +1,15 @@ """Provides the constants needed for the component.""" -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import TimeEntity DOMAIN: Final = "time" +DATA_COMPONENT: HassKey[EntityComponent[TimeEntity]] = HassKey(DOMAIN) SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/time/services.py b/homeassistant/components/time/services.py new file mode 100644 index 0000000000000..bf9dc6b0b4fc0 --- /dev/null +++ b/homeassistant/components/time/services.py @@ -0,0 +1,27 @@ +"""Services for the Time integration.""" + +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.const import ATTR_TIME +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .const import DATA_COMPONENT, SERVICE_SET_VALUE + +if TYPE_CHECKING: + from . import TimeEntity + + +async def _async_set_value(entity: TimeEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date.""" + return await entity.async_set_value(service_call.data[ATTR_TIME]) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the time services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_SET_VALUE, {probatio.Required(ATTR_TIME): cv.time}, _async_set_value + ) From 5988177ac880d87239f3dd5730caade32cbe2b62 Mon Sep 17 00:00:00 2001 From: Tomasz Dylewski Date: Mon, 21 Sep 2026 18:46:52 +0200 Subject: [PATCH 16/33] Paj GPS dependecy upgrade to 0.4.1 (#182751) --- homeassistant/components/paj_gps/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/paj_gps/manifest.json b/homeassistant/components/paj_gps/manifest.json index 872572b8c0136..af25f23099e1b 100644 --- a/homeassistant/components/paj_gps/manifest.json +++ b/homeassistant/components/paj_gps/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["pajgps-api==0.4.0"] + "requirements": ["pajgps-api==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a0a82a1fb34f3..25faf12de96a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1887,7 +1887,7 @@ p1monitor==3.2.0 paho-mqtt==2.1.0 # homeassistant.components.paj_gps -pajgps-api==0.4.0 +pajgps-api==0.4.1 # homeassistant.components.panasonic_bluray panacotta==0.2 From fd3ecb1282d1f2ec17335e4841906bcd50aae157 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 21 Sep 2026 12:48:32 -0400 Subject: [PATCH 17/33] Set tool metadata in lawn_mower (#182704) Co-authored-by: Claude --- homeassistant/components/lawn_mower/llm.py | 25 +++++++++++++++++++--- tests/components/lawn_mower/test_llm.py | 18 +++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lawn_mower/llm.py b/homeassistant/components/lawn_mower/llm.py index 16b9b5f7b8ce9..6fe3b76067b3f 100644 --- a/homeassistant/components/lawn_mower/llm.py +++ b/homeassistant/components/lawn_mower/llm.py @@ -4,13 +4,26 @@ from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolAnnotations, +) from .const import DOMAIN from .intent import INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING +# Each intent sets a value on the user's own entities, so calling one again +# with the same arguments has no further effect. +LLM_ANNOTATIONS = ToolAnnotations(idempotent=True, open_world=False) + # Intents owned by this integration that are exposed as LLM tools. -LLM_INTENTS = (INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING) +LLM_INTENTS = { + INTENT_LANW_MOWER_DOCK: "Dock lawn mower", + INTENT_LANW_MOWER_START_MOWING: "Start mowing", +} @callback @@ -31,7 +44,13 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) + IntentTool( + f"{DOMAIN}__{handler.intent_type}", + handler, + title=LLM_INTENTS[handler.intent_type], + integration=DOMAIN, + annotations=LLM_ANNOTATIONS, + ) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/tests/components/lawn_mower/test_llm.py b/tests/components/lawn_mower/test_llm.py index a16946ca4c765..d50607bae5965 100644 --- a/tests/components/lawn_mower/test_llm.py +++ b/tests/components/lawn_mower/test_llm.py @@ -44,7 +44,23 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed lawn_mower entity.""" - assert await _tool_names(hass) >= TOOL_NAMES + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tools = {tool.name: tool for tool in result.tools} + assert tools.keys() >= TOOL_NAMES + + annotations = llm.ToolAnnotations(idempotent=True, open_world=False) + assert { + name: (tool.title, tool.integration, tool.annotations) + for name, tool in tools.items() + if name in TOOL_NAMES + } == { + "lawn_mower__HassLawnMowerDock": ("Dock lawn mower", "lawn_mower", annotations), + "lawn_mower__HassLawnMowerStartMowing": ( + "Start mowing", + "lawn_mower", + annotations, + ), + } async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: From 64976f170b09229205b5d525de2154e745909bdd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 21 Sep 2026 12:49:33 -0400 Subject: [PATCH 18/33] Set tool metadata in media_player (#182707) Co-authored-by: Claude --- homeassistant/components/media_player/llm.py | 57 +++++++++++++++----- tests/components/media_player/test_llm.py | 34 +++++++++++- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/media_player/llm.py b/homeassistant/components/media_player/llm.py index 11bc45a40936e..5c41144721194 100644 --- a/homeassistant/components/media_player/llm.py +++ b/homeassistant/components/media_player/llm.py @@ -4,7 +4,13 @@ from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolAnnotations, +) from .const import ( DOMAIN, @@ -20,17 +26,36 @@ ) # Intents owned by this integration that are exposed as LLM tools. -LLM_INTENTS = ( - INTENT_MEDIA_NEXT, - INTENT_MEDIA_PAUSE, - INTENT_PLAYER_MUTE, - INTENT_PLAYER_UNMUTE, - INTENT_MEDIA_PREVIOUS, - INTENT_MEDIA_SEARCH_AND_PLAY, - INTENT_MEDIA_UNPAUSE, - INTENT_SET_VOLUME, - INTENT_SET_VOLUME_RELATIVE, -) +LLM_INTENTS = { + INTENT_MEDIA_NEXT: "Next track", + INTENT_MEDIA_PAUSE: "Pause media", + INTENT_PLAYER_MUTE: "Mute player", + INTENT_PLAYER_UNMUTE: "Unmute player", + INTENT_MEDIA_PREVIOUS: "Previous track", + INTENT_MEDIA_SEARCH_AND_PLAY: "Search and play media", + INTENT_MEDIA_UNPAUSE: "Resume media", + INTENT_SET_VOLUME: "Set volume", + INTENT_SET_VOLUME_RELATIVE: "Change volume", +} + +# Setting a value on the user's own player has no further effect when it is +# repeated. Stepping through tracks or volume has an effect on every call, and +# a search reaches the media the player can read. +_CONTROL = ToolAnnotations(idempotent=True, open_world=False) +_CUMULATIVE = ToolAnnotations(open_world=False) + +INTENT_ANNOTATIONS = { + INTENT_MEDIA_PAUSE: _CONTROL, + # Unpausing clears the players it remembered, so a repeat can resume more. + INTENT_MEDIA_UNPAUSE: _CUMULATIVE, + INTENT_PLAYER_MUTE: _CONTROL, + INTENT_PLAYER_UNMUTE: _CONTROL, + INTENT_SET_VOLUME: _CONTROL, + INTENT_MEDIA_NEXT: _CUMULATIVE, + INTENT_MEDIA_PREVIOUS: _CUMULATIVE, + INTENT_SET_VOLUME_RELATIVE: _CUMULATIVE, + INTENT_MEDIA_SEARCH_AND_PLAY: ToolAnnotations(), +} @callback @@ -51,7 +76,13 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) + IntentTool( + f"{DOMAIN}__{handler.intent_type}", + handler, + title=LLM_INTENTS[handler.intent_type], + integration=DOMAIN, + annotations=INTENT_ANNOTATIONS[handler.intent_type], + ) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/tests/components/media_player/test_llm.py b/tests/components/media_player/test_llm.py index fcefaa14d9e36..559cd92b7f3e2 100644 --- a/tests/components/media_player/test_llm.py +++ b/tests/components/media_player/test_llm.py @@ -54,7 +54,39 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed media_player entity.""" - assert await _tool_names(hass) >= TOOL_NAMES + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tools = {tool.name: tool for tool in result.tools} + assert tools.keys() >= TOOL_NAMES + + control = llm.ToolAnnotations(idempotent=True, open_world=False) + repeats = llm.ToolAnnotations(open_world=False) + assert { + name: (tool.title, tool.integration, tool.annotations) + for name, tool in tools.items() + if name in TOOL_NAMES + } == { + "media_player__HassMediaNext": ("Next track", "media_player", repeats), + "media_player__HassMediaPause": ("Pause media", "media_player", control), + "media_player__HassMediaPlayerMute": ("Mute player", "media_player", control), + "media_player__HassMediaPlayerUnmute": ( + "Unmute player", + "media_player", + control, + ), + "media_player__HassMediaPrevious": ("Previous track", "media_player", repeats), + "media_player__HassMediaSearchAndPlay": ( + "Search and play media", + "media_player", + llm.ToolAnnotations(), + ), + "media_player__HassMediaUnpause": ("Resume media", "media_player", repeats), + "media_player__HassSetVolume": ("Set volume", "media_player", control), + "media_player__HassSetVolumeRelative": ( + "Change volume", + "media_player", + repeats, + ), + } async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: From 8358c9c1440d3c584f19f679bb773f8a8bf71d2f Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 22 Sep 2026 01:50:17 +0900 Subject: [PATCH 19/33] Adjust lg_thinq energy polling schedule to mitigate server load (#182810) Co-authored-by: YunseonPark-LGE --- homeassistant/components/lg_thinq/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 54cdfb5903e4c..d6fee8a44df91 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -829,9 +829,9 @@ async def async_added_to_hass(self) -> None: """Handle added to Hass.""" await super().async_added_to_hass() if self.coordinator.update_energy_at_time_of_day is None: - # random time 01:00:00 ~ 02:59:00 + # Random time between 01:00:00 and 05:59:00 self.coordinator.update_energy_at_time_of_day = time( - hour=random.randint(1, 2), minute=random.randint(0, 59) + hour=random.randint(1, 5), minute=random.randint(0, 59) ) _LOGGER.debug( "[%s] Set energy update time: %s", From d0cd63535fb1d3fb4c73cf8e3ba150ba5c3d69b4 Mon Sep 17 00:00:00 2001 From: YP30 <58120331+YP30@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:52:56 +0200 Subject: [PATCH 20/33] Fix irm_kmi serving stale data indefinitely after API errors (#182795) --- .../components/irm_kmi/coordinator.py | 46 +++++------ tests/components/irm_kmi/test_init.py | 81 ++++++++++++++++++- 2 files changed, 100 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/irm_kmi/coordinator.py b/homeassistant/components/irm_kmi/coordinator.py index feafb5b88b36c..cea0057d5899c 100644 --- a/homeassistant/components/irm_kmi/coordinator.py +++ b/homeassistant/components/irm_kmi/coordinator.py @@ -1,8 +1,8 @@ """DataUpdateCoordinator for the IRM KMI integration.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging -from typing import override +from typing import Final, override from irm_kmi_api import IrmKmiApiClientHa, IrmKmiApiError @@ -14,13 +14,15 @@ UpdateFailed, ) from homeassistant.util import dt as dt_util -from homeassistant.util.dt import utcnow from .data import ProcessedCoordinatorData from .utils import preferred_language _LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL: Final = timedelta(minutes=7) +GRACE_PERIOD: Final = 2.5 * UPDATE_INTERVAL + type IrmKmiConfigEntry = ConfigEntry[IrmKmiCoordinator] @@ -39,19 +41,22 @@ def __init__( _LOGGER, config_entry=entry, name="IRM KMI weather", - update_interval=timedelta(minutes=7), + update_interval=UPDATE_INTERVAL, ) self._api = api_client self._location = entry.data[CONF_LOCATION] + # last_update_success_time is also renewed while serving old data + self._last_api_success_time: datetime | None = None + + def _within_grace(self, last_success: datetime | None) -> bool: + """Return whether data from the last success may still be served.""" + return ( + last_success is not None and dt_util.utcnow() - last_success < GRACE_PERIOD + ) @override async def _async_update_data(self) -> ProcessedCoordinatorData: - """Fetch data from API endpoint. - - Pre-process the data to lookup tables so entities - can quickly look up their data. - """ - + """Fetch and process the IRM KMI data.""" self._api.expire_cache() try: @@ -63,26 +68,17 @@ async def _async_update_data(self) -> ProcessedCoordinatorData: ) except IrmKmiApiError as err: - if ( - self.last_update_success_time is not None - and self.update_interval is not None - and self.last_update_success_time - utcnow() - < timedelta(seconds=2.5 * self.update_interval.seconds) - ): + if self._within_grace(self._last_api_success_time): return self.data - - _LOGGER.warning( - "Could not connect to the API since %s", self.last_update_success_time - ) raise UpdateFailed( f"Error communicating with API for general forecast: {err}. " - f"Last success time is: {self.last_update_success_time}" + f"Last success time is: {self._last_api_success_time}" ) from err - if not self.last_update_success: - _LOGGER.warning("Successfully reconnected to the API") - - return await self.process_api_data() + data = await self.process_api_data() + # Only once processed, so the grace period never serves missing data + self._last_api_success_time = dt_util.utcnow() + return data async def process_api_data(self) -> ProcessedCoordinatorData: """From the API data, create the object that will be used in the entities.""" diff --git a/tests/components/irm_kmi/test_init.py b/tests/components/irm_kmi/test_init.py index 8adedfdda3895..82e628d055dd3 100644 --- a/tests/components/irm_kmi/test_init.py +++ b/tests/components/irm_kmi/test_init.py @@ -1,16 +1,20 @@ """Tests for the IRM KMI integration.""" -from unittest.mock import MagicMock +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock +from freezegun.api import FrozenDateTimeFactory from irm_kmi_api import IrmKmiApiError import pytest from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import setup_integration +from .const import WEATHER_ENTITY_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("mock_irm_kmi_api") @@ -33,6 +37,7 @@ async def test_config_entry_not_ready( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_irm_kmi_api: MagicMock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the IRM KMI configuration entry not ready.""" mock_irm_kmi_api.refresh_forecasts_coord.side_effect = IrmKmiApiError @@ -41,3 +46,75 @@ async def test_config_entry_not_ready( assert mock_irm_kmi_api.refresh_forecasts_coord.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + # The first refresh has no last success time to compare the grace period to + assert "Unexpected error fetching" not in caplog.text + + +@pytest.mark.freeze_time("2023-12-28T15:30:00+01:00") +async def test_entity_unavailable_after_grace_period( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_forecasts_coord: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the last known data is only served for the grace period.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE + + mock_get_forecasts_coord.side_effect = IrmKmiApiError + + freezer.tick(timedelta(minutes=8)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE + + freezer.tick(timedelta(minutes=7)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE + + freezer.tick(timedelta(minutes=7)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state == STATE_UNAVAILABLE + + mock_get_forecasts_coord.side_effect = None + + freezer.tick(timedelta(minutes=8)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE + + +@pytest.mark.freeze_time("2023-12-28T15:30:00+01:00") +async def test_grace_period_starts_at_the_last_usable_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_irm_kmi_api: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the grace period runs from the last refresh that produced data.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE + + mock_irm_kmi_api.get_daily_forecast.side_effect = TypeError + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state == STATE_UNAVAILABLE + + mock_irm_kmi_api.refresh_forecasts_coord.side_effect = IrmKmiApiError + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state == STATE_UNAVAILABLE From 5c99cd45d3d4c50aa517b71c9291d46837e8cada Mon Sep 17 00:00:00 2001 From: ssltg <45385287+ssltg@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:55:46 +0200 Subject: [PATCH 21/33] Bump async-upnp-client to 0.48.2 (#182573) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/wiim/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 8bb9bac83c438..d95876739ae15 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -9,7 +9,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.48.1", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.48.2", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index b38d044a66420..4ea22b2c38000 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.48.1"], + "requirements": ["async-upnp-client==0.48.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 3b113bb57a76a..ff0ade0945de4 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -40,7 +40,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==3.0.6", "wakeonlan==3.3.0", - "async-upnp-client==0.48.1" + "async-upnp-client==0.48.2" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index c85982a1e9164..dcde4bd6a42b7 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.48.1"] + "requirements": ["async-upnp-client==0.48.2"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index c88801ad2a940..2a03b771ea935 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.48.1", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.48.2", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json index 5079d1d8491e2..297c75c25ebe2 100644 --- a/homeassistant/components/wiim/manifest.json +++ b/homeassistant/components/wiim/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_push", "loggers": ["wiim.sdk", "async_upnp_client"], "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.48.1", "wiim==0.1.7"], + "requirements": ["async-upnp-client==0.48.2", "wiim==0.1.7"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 4faf9961d3c68..f64004c4b155a 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.48.1"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.48.2"], "zeroconf": [ { "name": "yeelink-*", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6b79833b1c4f0..980e5f4f7be7a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==1.0.2 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.48.1 +async-upnp-client==0.48.2 atomicwrites-homeassistant==1.4.1 attrs==26.1.0 audioop-lts==0.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 25faf12de96a9..04426585ac607 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -600,7 +600,7 @@ asusrouter==1.21.3 # homeassistant.components.upnp # homeassistant.components.wiim # homeassistant.components.yeelight -async-upnp-client==0.48.1 +async-upnp-client==0.48.2 # homeassistant.components.arve asyncarve==0.1.1 From 24fca373b9d729f773e3b6aae0960dd418cc0e43 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 21 Sep 2026 19:03:06 +0200 Subject: [PATCH 22/33] Type the common part of KNX UI entity configurations (#182728) Co-authored-by: Claude Opus 5 --- homeassistant/components/knx/binary_sensor.py | 11 +-- homeassistant/components/knx/button.py | 14 +-- homeassistant/components/knx/climate.py | 15 ++-- homeassistant/components/knx/cover.py | 9 +- homeassistant/components/knx/date.py | 11 +-- homeassistant/components/knx/datetime.py | 11 +-- homeassistant/components/knx/entity.py | 16 ++-- homeassistant/components/knx/fan.py | 9 +- homeassistant/components/knx/light.py | 14 ++- homeassistant/components/knx/notify.py | 12 ++- homeassistant/components/knx/number.py | 11 +-- homeassistant/components/knx/scene.py | 12 ++- homeassistant/components/knx/select.py | 10 +-- homeassistant/components/knx/sensor.py | 12 ++- .../components/knx/storage/config_store.py | 30 +++---- .../knx/storage/entity_store_schema.py | 87 ++++++++++++------- homeassistant/components/knx/switch.py | 18 ++-- homeassistant/components/knx/text.py | 11 +-- homeassistant/components/knx/time.py | 11 +-- homeassistant/components/knx/validation.py | 39 +++++---- homeassistant/components/knx/weather.py | 11 ++- tests/components/knx/test_config_store.py | 15 +++- 22 files changed, 193 insertions(+), 196 deletions(-) diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index f5dd68f218c93..6e2f67df99971 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -31,7 +31,6 @@ CONF_RESET_AFTER, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_MODULE_KEY, ) from .entity import ( @@ -41,9 +40,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import BinarySensorKnxConfig +from .storage.entity_store_schema import BinarySensorKnxConfig, KnxEntityData async def async_setup_entry( @@ -158,12 +155,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxBinarySensor( xknx=knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address_state=knx_conf.ga_sensor.state_and_passive(), sync_state=knx_conf.sync_state, invert=knx_conf.invert, diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 53575c669c67e..e8fa5c41e87dc 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -14,7 +14,7 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_PAYLOAD_LENGTH, CONF_VALUE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY +from .const import CONF_PAYLOAD_LENGTH, CONF_VALUE, KNX_ADDRESS, KNX_MODULE_KEY from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -22,8 +22,8 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_DATA, CONF_ENTITY, CONF_GA_SEND +from .storage.const import CONF_DATA, CONF_GA_SEND +from .storage.entity_store_schema import KnxEntityData from .storage.util import ConfigExtractor @@ -104,13 +104,13 @@ def __init__( self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize a KNX button.""" - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = ConfigExtractor(config.knx) button_data = knx_conf.get(CONF_DATA) if CONF_PAYLOAD in button_data and CONF_PAYLOAD_LENGTH in button_data: self._payload = int(button_data[CONF_PAYLOAD], 16) self._device = XknxRawValue( xknx=knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, payload_length=button_data[CONF_PAYLOAD_LENGTH], group_address=knx_conf.get_write(CONF_GA_SEND), ) @@ -119,7 +119,7 @@ def __init__( self._payload = button_data[CONF_VALUE] self._device = XknxExposeSensor( xknx=knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, value_type=dpt_string, group_address=knx_conf.get_write(CONF_GA_SEND), respond_to_read=False, @@ -128,5 +128,5 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 608ce1b1e6e7f..a88e9b57c578a 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -37,7 +37,6 @@ CONF_SYNC_STATE, CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, - DOMAIN, KNX_MODULE_KEY, ClimateConf, ) @@ -50,9 +49,7 @@ ) from .knx_module import KNXModule from .schema import ClimateSchema -from .storage.config_store import KnxEntityData from .storage.const import ( - CONF_ENTITY, CONF_GA_ACTIVE, CONF_GA_CONTROLLER_MODE, CONF_GA_CONTROLLER_STATUS, @@ -74,7 +71,11 @@ CONF_IGNORE_AUTO_MODE, CONF_TARGET_TEMPERATURE, ) -from .storage.entity_store_schema import ConfClimateFanSpeedMode, ConfSetpointShiftMode +from .storage.entity_store_schema import ( + ConfClimateFanSpeedMode, + ConfSetpointShiftMode, + KnxEntityData, +) from .storage.util import ConfigExtractor ATTR_COMMAND_VALUE = "command_value" @@ -705,11 +706,11 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = ConfigExtractor(config.knx) self._device = _create_climate_ui( - knx_module.xknx, knx_conf, config[CONF_ENTITY][CONF_NAME] + knx_module.xknx, knx_conf, config.entity.xknx_name ) default_hvac_mode = HVACMode(knx_conf.get(ClimateConf.DEFAULT_CONTROLLER_MODE)) diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 087129880d6fb..4cdc94285d645 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -30,7 +30,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from .const import CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY, CoverConf +from .const import CONF_SYNC_STATE, KNX_MODULE_KEY, CoverConf from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -39,9 +39,7 @@ ) from .knx_module import KNXModule from .schema import CoverSchema -from .storage.config_store import KnxEntityData from .storage.const import ( - CONF_ENTITY, CONF_GA_ANGLE, CONF_GA_POSITION_SET, CONF_GA_POSITION_STATE, @@ -49,6 +47,7 @@ CONF_GA_STOP, CONF_GA_UP_DOWN, ) +from .storage.entity_store_schema import KnxEntityData from .storage.util import ConfigExtractor @@ -313,9 +312,9 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) self._device = _create_ui_cover( - knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME] + knx_module.xknx, config.knx, config.entity.xknx_name ) self.init_base() diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index cfaa00025a0a2..0ac91cbccbaeb 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -21,7 +21,6 @@ CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ) @@ -32,9 +31,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import DateKnxConfig +from .storage.entity_store_schema import DateKnxConfig, KnxEntityData async def async_setup_entry( @@ -137,12 +134,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxDateDevice( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, localtime=False, group_address=knx_conf.ga_date.write, group_address_state=knx_conf.ga_date.state_and_passive(), diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index dcd0e9d8550c8..ee93adb7ea484 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -22,7 +22,6 @@ CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ) @@ -33,9 +32,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import DatetimeKnxConfig +from .storage.entity_store_schema import DatetimeKnxConfig, KnxEntityData async def async_setup_entry( @@ -142,12 +139,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxDateTimeDevice( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, localtime=False, group_address=knx_conf.ga_datetime.write, group_address_state=knx_conf.ga_datetime.state_and_passive(), diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index bc43dceaede2a..86d3df4d9ac4a 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -27,8 +27,8 @@ from homeassistant.helpers.entity_registry import RegistryEntry from .const import CONF_DEFAULT_ENTITY_ID, DOMAIN -from .storage.config_store import KnxEntityData, PlatformControllerBase -from .storage.const import CONF_DEVICE_INFO +from .storage.config_store import PlatformControllerBase +from .storage.entity_store_schema import BaseEntityConfig, KnxEntityData if TYPE_CHECKING: from .knx_module import KNXModule @@ -260,13 +260,15 @@ class KnxUiEntity(_KnxEntityBase): """Representation of a KNX UI entity.""" def __init__( - self, knx_module: KNXModule, unique_id: str, entity_config: dict[str, Any] + self, knx_module: KNXModule, unique_id: str, entity_config: BaseEntityConfig ) -> None: """Initialize the UI entity.""" self._knx_module = knx_module - self._attr_name = entity_config[CONF_NAME] + self._attr_name = entity_config.name self._attr_unique_id = unique_id - self._attr_entity_category = entity_config[CONF_ENTITY_CATEGORY] - if device_info := entity_config[CONF_DEVICE_INFO]: - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) + self._attr_entity_category = entity_config.entity_category + if entity_config.device_info: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entity_config.device_info)} + ) diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index a9235efb9f860..1fad769078db9 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -33,15 +33,14 @@ ) from .knx_module import KNXModule from .schema import FanSchema -from .storage.config_store import KnxEntityData from .storage.const import ( - CONF_ENTITY, CONF_GA_OSCILLATION, CONF_GA_SPEED, CONF_GA_STEP, CONF_GA_SWITCH, CONF_SPEED, ) +from .storage.entity_store_schema import KnxEntityData from .storage.util import ConfigExtractor _LOGGER = logging.getLogger(__name__) @@ -257,13 +256,13 @@ def __init__( self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize of KNX fan.""" - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = ConfigExtractor(config.knx) # max_step is required for step mode, thus can be used to differentiate modes max_step: int | None = knx_conf.get(CONF_SPEED, FanConf.MAX_STEP) super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) if max_step: # step control @@ -276,7 +275,7 @@ def __init__( self._device = XknxFan( xknx=knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address_speed=speed_write, group_address_speed_state=speed_state, group_address_oscillation=knx_conf.get_write(CONF_GA_OSCILLATION), diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index d16c9f038ad38..f6a8df0b2fa70 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -27,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import color as color_util -from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes +from .const import CONF_SYNC_STATE, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -36,12 +36,10 @@ ) from .knx_module import KNXModule from .schema import LightSchema -from .storage.config_store import KnxEntityData from .storage.const import ( CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, - CONF_ENTITY, CONF_GA_BLUE_BRIGHTNESS, CONF_GA_BLUE_SWITCH, CONF_GA_BRIGHTNESS, @@ -57,7 +55,7 @@ CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, ) -from .storage.entity_store_schema import LightColorMode +from .storage.entity_store_schema import KnxEntityData, LightColorMode from .storage.util import ConfigExtractor @@ -610,11 +608,11 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) self._device = _create_ui_light( - knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME] + knx_module.xknx, config.knx, config.entity.xknx_name ) self._attr_color_mode = next(iter(self.supported_color_modes)) - self._attr_max_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MAX] - self._attr_min_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MIN] + self._attr_max_color_temp_kelvin: int = config.knx[CONF_COLOR_TEMP_MAX] + self._attr_min_color_temp_kelvin: int = config.knx[CONF_COLOR_TEMP_MIN] diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index f427a6cb59c7d..5e9dbefe45acc 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -14,7 +14,7 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY +from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -22,9 +22,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import NotifyKnxConfig +from .storage.entity_store_schema import KnxEntityData, NotifyKnxConfig async def async_setup_entry( @@ -107,12 +105,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxNotification( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address=knx_conf.ga_send.write, value_type=knx_conf.ga_send.dpt, ) diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index c95e4ce1232e0..65465921d604c 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -28,7 +28,6 @@ CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, NumberConf, @@ -41,9 +40,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import NumberKnxConfig +from .storage.entity_store_schema import KnxEntityData, NumberKnxConfig async def async_setup_entry( @@ -178,16 +175,16 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx dpt_string = knx_conf.ga_sensor.dpt assert dpt_string is not None # required for number dpt_info = get_supported_dpts()[dpt_string] self._device = NumericValue( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address=knx_conf.ga_sensor.write, group_address_state=knx_conf.ga_sensor.state_and_passive(), respond_to_read=knx_conf.respond_to_read, diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index de525fea3ac03..2a9e61f314a6b 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -14,7 +14,7 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY +from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -24,9 +24,7 @@ ) from .knx_module import KNXModule from .schema import SceneSchema -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import SceneKnxConfig +from .storage.entity_store_schema import KnxEntityData, SceneKnxConfig async def async_setup_entry( @@ -117,12 +115,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxScene( xknx=knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address=knx_conf.ga_scene.write, scene_number=knx_conf.scene_number, ) diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 3e45e4e9911ed..205e5c54a12b0 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -29,7 +29,6 @@ CONF_STATE_ADDRESS, CONF_SYNC_STATE, CONF_VALUE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, SelectConf, @@ -42,8 +41,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import KnxEntityData from .storage.util import ConfigExtractor _LOGGER = logging.getLogger(__name__) @@ -206,7 +204,7 @@ def __init__( self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize a KNX select.""" - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = ConfigExtractor(config.knx) source = knx_conf.get(SelectConf.OPTIONS_SOURCE) # the group address key tells how options are defined if SelectConf.GA_ENUM in source: @@ -223,7 +221,7 @@ def __init__( self._device = RawValue( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, payload_length=payload_length, group_address=knx_conf.get_write(SelectConf.OPTIONS_SOURCE, ga_key), group_address_state=knx_conf.get_state_and_passive( @@ -235,6 +233,6 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) self._attr_options = list(self._option_payloads) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index e65603ded3794..18b0762aa5b62 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -36,7 +36,7 @@ from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.enum import try_parse_enum -from .const import ATTR_SOURCE, CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY +from .const import ATTR_SOURCE, CONF_SYNC_STATE, KNX_MODULE_KEY from .dpt import get_supported_dpts from .entity import ( KnxUiEntity, @@ -47,9 +47,7 @@ ) from .knx_module import KNXModule from .schema import SensorSchema -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import SensorKnxConfig +from .storage.entity_store_schema import KnxEntityData, SensorKnxConfig SCAN_INTERVAL = timedelta(seconds=10) @@ -256,16 +254,16 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx dpt_string = knx_conf.ga_sensor.dpt assert dpt_string is not None # required for sensor dpt_info = get_supported_dpts()[dpt_string] self._device = XknxSensor( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address_state=knx_conf.ga_sensor.state_and_passive(), sync_state=knx_conf.sync_state, always_callback=True, diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index cc3d7fb2a1bf3..ef18f5c8ead90 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -9,7 +9,6 @@ Any, Final, TypedDict, - cast, get_args, get_origin, get_type_hints, @@ -30,6 +29,7 @@ from ..repairs import async_create_entity_validation_issue from . import migration from .const import CONF_DATA, CONF_ENTITY +from .entity_store_schema import KnxEntityData from .entity_store_validation import ( EntityStoreValidationException, validate_entity_data, @@ -58,24 +58,20 @@ class KNXConfigStoreModel(TypedDict): time_server: KNXTimeServerStoreModel -class KnxEntityData[KnxT](TypedDict): - """Validated entity data: the common `entity` and the platform `knx` part.""" - - entity: dict[str, Any] - knx: KnxT - - def to_storage_dict(data: KnxEntityData[Any]) -> dict[str, Any]: """Render validated entity data to its JSON serializable storage form.""" - knx_config = data[DOMAIN] + return { + CONF_ENTITY: dataclasses.asdict(data.entity), + DOMAIN: _knx_to_storage(data.knx), + } + + +def _knx_to_storage(knx_config: Any) -> dict[str, Any]: if isinstance(knx_config, dict): - return cast(dict[str, Any], data) # platform not yet migrated to a typed config + return knx_config # platform not yet migrated to a typed config return { - CONF_ENTITY: data[CONF_ENTITY], - DOMAIN: { - name: encode(getattr(knx_config, name)) - for name, encode in _storage_encoders(type(knx_config)) - }, + name: encode(getattr(knx_config, name)) + for name, encode in _storage_encoders(type(knx_config)) } @@ -227,9 +223,9 @@ def get_entity_configs( invalid.append(unique_id) continue data: KnxEntityData[Any] = result[CONF_DATA] - if config_type is not None and not isinstance(data[DOMAIN], config_type): + if config_type is not None and not isinstance(data.knx, config_type): raise TypeError( - f"{platform} schema yields {type(data[DOMAIN]).__name__}," + f"{platform} schema yields {type(data.knx).__name__}," f" not {config_type.__name__}" ) validated[unique_id] = data diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 17ed93e2f951e..94c1b91b358b3 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -3,7 +3,7 @@ from collections.abc import Hashable from dataclasses import dataclass from enum import StrEnum, unique -from typing import Annotated +from typing import Annotated, Any import probatio from probatio import Key @@ -23,9 +23,9 @@ from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_ENTITY_ID, - CONF_NAME, CONF_PAYLOAD, CONF_PLATFORM, + EntityCategory, Platform, ) from homeassistant.helpers import selector @@ -47,7 +47,8 @@ ) from ..dpt import get_supported_dpts, raw_payload_length from ..validation import ( - entity_category_validator, + entity_category_supported, + parse_entity_category, validate_number_attributes, validate_sensor_attributes, ) @@ -56,7 +57,6 @@ CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, CONF_DATA, - CONF_DEVICE_INFO, CONF_DPT, CONF_ENTITY, CONF_GA_ACTIVE, @@ -135,34 +135,52 @@ SyncStateAllowFalse = Annotated[bool | str | int, SyncStateSelector(allow_false=True)] +@dataclass(kw_only=True, slots=True) +class BaseEntityConfig: + """Common UI configuration of a KNX entity.""" + + name: str | None = None + device_info: str | None = None + entity_category: Annotated[ + EntityCategory | None, probatio.Coerce(parse_entity_category) + ] = None + + @property + def xknx_name(self) -> str: + """Name of the xknx device, empty when HA names the entity after its device.""" + return self.name or "" + + +def _name_or_device_required(config: BaseEntityConfig) -> BaseEntityConfig: + """Require a name, unless the entity is named after its device.""" + if not config.name and config.device_info is None: + raise probatio.AnyInvalid("One of `Device` or `Name` is required") + return config + + def base_entity_schema(platform: Platform) -> probatio.All: """Return the base entity schema for a platform.""" return probatio.All( - { - probatio.Optional(CONF_NAME, default=None): probatio.Maybe(str), - probatio.Optional(CONF_DEVICE_INFO, default=None): probatio.Maybe(str), - probatio.Optional( - CONF_ENTITY_CATEGORY, default=None - ): entity_category_validator(platform), - }, - probatio.Any( - probatio.Schema( - { - probatio.Required(CONF_NAME): probatio.All(str, probatio.IsTrue()), - }, - extra=probatio.ALLOW_EXTRA, - ), - probatio.Schema( - { - probatio.Required(CONF_DEVICE_INFO): str, - }, - extra=probatio.ALLOW_EXTRA, - ), - msg="One of `Device` or `Name` is required", + probatio.DataclassSchema( + BaseEntityConfig, + {CONF_ENTITY_CATEGORY: entity_category_supported(platform)}, ), + _name_or_device_required, ) +@dataclass(kw_only=True, slots=True) +class KnxEntityData[KnxT]: + """Validated UI entity data: the common `entity` and the platform `knx` part.""" + + entity: BaseEntityConfig + knx: KnxT + + +def _to_entity_data(data: dict[str, Any]) -> KnxEntityData[Any]: + return KnxEntityData(entity=data[CONF_ENTITY], knx=data[DOMAIN]) + + @dataclass(kw_only=True, slots=True) class BinarySensorKnxConfig: """UI configuration of a KNX binary sensor.""" @@ -1118,14 +1136,17 @@ def _sensor_attribute_sub_validator(config: SensorKnxConfig) -> SensorKnxConfig: { platform: probatio.Schema( { - probatio.Required(CONF_DATA): probatio.Schema( - { - probatio.Required(CONF_ENTITY): base_entity_schema( - platform - ), - probatio.Required(DOMAIN): knx_schema, - }, - extra=probatio.PREVENT_EXTRA, # restrict in data key for yaml edit + probatio.Required(CONF_DATA): probatio.All( + probatio.Schema( + { + probatio.Required(CONF_ENTITY): base_entity_schema( + platform + ), + probatio.Required(DOMAIN): knx_schema, + }, + extra=probatio.PREVENT_EXTRA, # restrict in data key for yaml edit + ), + _to_entity_data, ), }, extra=probatio.ALLOW_EXTRA, # eg. "type" from WS-endpoint when validating directly diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 03f328a8caa79..090022ae20f0d 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -22,13 +22,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_RESPOND_TO_READ, - CONF_SYNC_STATE, - DOMAIN, - KNX_ADDRESS, - KNX_MODULE_KEY, -) +from .const import CONF_RESPOND_TO_READ, CONF_SYNC_STATE, KNX_ADDRESS, KNX_MODULE_KEY from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -37,9 +31,7 @@ ) from .knx_module import KNXModule from .schema import SwitchSchema -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import SwitchKnxConfig +from .storage.entity_store_schema import KnxEntityData, SwitchKnxConfig async def async_setup_entry( @@ -145,12 +137,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxSwitch( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address=knx_conf.ga_switch.write, group_address_state=knx_conf.ga_switch.state_and_passive(), respond_to_read=knx_conf.respond_to_read, diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index 6d3d7870940f4..3f0d885580516 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -28,7 +28,6 @@ CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ) @@ -39,9 +38,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import TextKnxConfig +from .storage.entity_store_schema import KnxEntityData, TextKnxConfig async def async_setup_entry( @@ -155,12 +152,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxNotification( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address=knx_conf.ga_text.write, group_address_state=knx_conf.ga_text.state_and_passive(), respond_to_read=knx_conf.respond_to_read, diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 1e73bc49c3ac2..469bb74976326 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -21,7 +21,6 @@ CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ) @@ -32,9 +31,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import TimeKnxConfig +from .storage.entity_store_schema import KnxEntityData, TimeKnxConfig async def async_setup_entry( @@ -137,12 +134,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxTimeDevice( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, localtime=False, group_address=knx_conf.ga_time.write, group_address_state=knx_conf.ga_time.state_and_passive(), diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index ff15edcf8df41..d58782655d02d 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -65,28 +65,27 @@ def dpt_value_validator(value: Any) -> str | int: sensor_type_validator = probatio.Any(numeric_type_validator, string_type_validator) -def entity_category_validator( - platform: Platform, -) -> Callable[[Any], EntityCategory | None]: - """Validate the entity category is supported by the platform. +def parse_entity_category(value: Any) -> EntityCategory | None: + """Parse an entity category; `None` and "" (the UI clears with it) mean none.""" + if value is None or value == "": + return None + try: + return EntityCategory(value) + except ValueError: + raise probatio.Invalid(f"'{value}' is not a valid entity category") from None - Works for both, UI and YAML configuration schema. - """ + +def entity_category_supported( + platform: Platform, +) -> Callable[[EntityCategory | None], EntityCategory | None]: + """Validate a parsed entity category is supported by the platform.""" valid_categories = set(EntityCategory) if platform in PLATFORMS_WITHOUT_CONFIG_CATEGORY: valid_categories -= {EntityCategory.CONFIG} - def validate(value: Any) -> EntityCategory | None: + def validate(entity_category: EntityCategory | None) -> EntityCategory | None: """Validate the entity category.""" - if value is None or value == "": # UI sends an empty value to clear it - return None - try: - entity_category = EntityCategory(value) - except ValueError: - raise probatio.Invalid( - f"'{value}' is not a valid entity category" - ) from None - if entity_category not in valid_categories: + if entity_category is not None and entity_category not in valid_categories: _options = ", ".join(sorted(valid_categories)) raise probatio.Invalid( f"Entity category '{entity_category}' is not supported by the" @@ -97,6 +96,14 @@ def validate(value: Any) -> EntityCategory | None: return validate +def entity_category_validator(platform: Platform) -> probatio.All: + """Validate the entity category is supported by the platform. + + Works for both, UI and YAML configuration schema. + """ + return probatio.All(parse_entity_category, entity_category_supported(platform)) + + def ga_validator(value: Any) -> str | int: """Validate that value is parsable as GroupAddress or InternalGroupAddress.""" if not isinstance(value, (str, int)): diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index db149debedfcf..afa784d09a864 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -20,7 +20,7 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY +from .const import CONF_SYNC_STATE, KNX_MODULE_KEY from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -29,9 +29,7 @@ ) from .knx_module import KNXModule from .schema import WeatherSchema -from .storage.config_store import KnxEntityData from .storage.const import ( - CONF_ENTITY, CONF_GA_AIR_PRESSURE, CONF_GA_BRIGHTNESS_EAST, CONF_GA_BRIGHTNESS_NORTH, @@ -47,6 +45,7 @@ CONF_GA_WIND_SPEED, CONF_INVERT_DAY_NIGHT, ) +from .storage.entity_store_schema import KnxEntityData from .storage.util import ConfigExtractor @@ -197,12 +196,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = ConfigExtractor(config.knx) self._device = XknxWeather( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, sync_state=knx_conf.get(CONF_SYNC_STATE), group_address_temperature=knx_conf.get_state_and_passive( CONF_GA_TEMPERATURE diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 5f09a62f413aa..c823f66f94b40 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -17,9 +17,11 @@ ) from homeassistant.components.knx.storage.const import CONF_DATA from homeassistant.components.knx.storage.entity_store_schema import ( + BaseEntityConfig, BinarySensorKnxConfig, DateKnxConfig, DatetimeKnxConfig, + KnxEntityData, NotifyKnxConfig, NumberKnxConfig, SceneKnxConfig, @@ -751,7 +753,7 @@ async def test_load_applies_schema_defaults_and_coercion( ) assert hass.states.get("light.missing_defaults") is not None config_store = hass.data[KNX_MODULE_KEY].config_store - light_config = config_store.get_entity_configs(Platform.LIGHT)[LIGHT_UID][DOMAIN] + light_config = config_store.get_entity_configs(Platform.LIGHT)[LIGHT_UID].knx assert light_config["color_temp_min"] == 2700 assert light_config["color_temp_max"] == 6000 @@ -980,16 +982,23 @@ def test_typed_config_storage_roundtrip( validated = validate_entity_data( {CONF_PLATFORM: platform, CONF_DATA: {"entity": entity_input, "knx": knx_input}} )[CONF_DATA] - assert isinstance(validated[DOMAIN], config_type) + assert isinstance(validated, KnxEntityData) + assert validated.entity == BaseEntityConfig(name="test") + assert isinstance(validated.knx, config_type) stored = to_storage_dict(validated) + assert stored["entity"] == { + "name": "test", + "device_info": None, + "entity_category": None, + } assert stored["knx"] == knx_stored assert json.loads(json.dumps(stored)) == stored # storage is JSON reloaded = validate_entity_data({CONF_PLATFORM: platform, CONF_DATA: stored})[ CONF_DATA ] - assert reloaded[DOMAIN] == validated[DOMAIN] + assert reloaded == validated assert to_storage_dict(reloaded) == stored From 7718b94a866f4ff7843d45cb1e9a44d7c6230c3e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 21 Sep 2026 13:04:15 -0400 Subject: [PATCH 23/33] Set tool metadata in fan (#182702) Co-authored-by: Claude --- homeassistant/components/fan/llm.py | 24 +++++++++++++++++++++--- tests/components/fan/test_llm.py | 9 ++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fan/llm.py b/homeassistant/components/fan/llm.py index 0e3c3262b8c6f..e28dfd5a7e432 100644 --- a/homeassistant/components/fan/llm.py +++ b/homeassistant/components/fan/llm.py @@ -4,13 +4,25 @@ from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolAnnotations, +) from .const import DOMAIN from .intent import INTENT_FAN_SET_SPEED +# Each intent sets a value on the user's own entities, so calling one again +# with the same arguments has no further effect. +LLM_ANNOTATIONS = ToolAnnotations(idempotent=True, open_world=False) + # Intents owned by this integration that are exposed as LLM tools. -LLM_INTENTS = (INTENT_FAN_SET_SPEED,) +LLM_INTENTS = { + INTENT_FAN_SET_SPEED: "Set fan speed", +} @callback @@ -31,7 +43,13 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) + IntentTool( + f"{DOMAIN}__{handler.intent_type}", + handler, + title=LLM_INTENTS[handler.intent_type], + integration=DOMAIN, + annotations=LLM_ANNOTATIONS, + ) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/tests/components/fan/test_llm.py b/tests/components/fan/test_llm.py index c27ac4ae652db..365e84fea2287 100644 --- a/tests/components/fan/test_llm.py +++ b/tests/components/fan/test_llm.py @@ -43,7 +43,14 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed fan entity.""" - assert "fan__HassFanSetSpeed" in await _tool_names(hass) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tools = {tool.name: tool for tool in result.tools} + assert "fan__HassFanSetSpeed" in tools + + tool = tools["fan__HassFanSetSpeed"] + assert tool.title == "Set fan speed" + assert tool.integration == "fan" + assert tool.annotations == llm.ToolAnnotations(idempotent=True, open_world=False) async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: From 6ca1b628c66c037090aa8e69ed320f0345ee948e Mon Sep 17 00:00:00 2001 From: Hamish Date: Tue, 22 Sep 2026 02:35:55 +0930 Subject: [PATCH 24/33] Gatus: Disable diagnostic sensors by default (#182476) --- .../components/gatus/quality_scale.yaml | 4 +-- homeassistant/components/gatus/sensor.py | 4 +++ tests/components/gatus/test_sensor.py | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml index a5dadd68ea642..deb415ac2bacb 100644 --- a/homeassistant/components/gatus/quality_scale.yaml +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -64,9 +64,7 @@ rules: dynamic-devices: done entity-category: done entity-device-class: done - entity-disabled-by-default: - status: exempt - comment: All entities represent monitored services and should be enabled by default. + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done diff --git a/homeassistant/components/gatus/sensor.py b/homeassistant/components/gatus/sensor.py index a04022ce8081c..21682fce57e53 100644 --- a/homeassistant/components/gatus/sensor.py +++ b/homeassistant/components/gatus/sensor.py @@ -60,6 +60,7 @@ class GatusSensorEntityDescription(SensorEntityDescription): key="status_code", translation_key="status_code", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=lambda coordinator, endpoint: ( endpoint.results[-1].status if endpoint.results else None ), @@ -70,6 +71,7 @@ class GatusSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENUM, options=["start", "healthy", "unhealthy", "resolved"], entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=lambda coordinator, endpoint: ( endpoint.events[-1].type.lower() if endpoint.events else None ), @@ -79,6 +81,7 @@ class GatusSensorEntityDescription(SensorEntityDescription): translation_key="certificate_expiration", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=lambda coordinator, endpoint: ( coordinator.last_update_time + timedelta( @@ -93,6 +96,7 @@ class GatusSensorEntityDescription(SensorEntityDescription): key="dns_rcode", translation_key="dns_rcode", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=lambda coordinator, endpoint: ( DNS_RCODE_MAP.get( endpoint.results[-1].dns_rcode, diff --git a/tests/components/gatus/test_sensor.py b/tests/components/gatus/test_sensor.py index 0facd887b4251..9793a57c02f8f 100644 --- a/tests/components/gatus/test_sensor.py +++ b/tests/components/gatus/test_sensor.py @@ -5,6 +5,7 @@ from freezegun.api import FrozenDateTimeFactory from gatus_api import EndpointStatus, Result +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform @@ -21,6 +22,7 @@ ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_setup_and_states( hass: HomeAssistant, mock_gatus_client: AsyncMock, @@ -153,6 +155,7 @@ async def test_sensor_missing_status_code( hass: HomeAssistant, mock_gatus_client: AsyncMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: """Test that a result missing status code evaluates to STATE_UNKNOWN for status code sensor.""" mock_gatus_client.get_endpoints_statuses.return_value = [ @@ -165,6 +168,11 @@ async def test_sensor_missing_status_code( ] await setup_integration(hass, mock_config_entry) + entity_registry.async_update_entity( + "sensor.backend_service_status_code", disabled_by=None + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get("sensor.backend_service_status_code") assert state is not None @@ -225,3 +233,25 @@ async def test_sensor_missing_dns_rcode( state = hass.states.get("sensor.backend_service_dns_response_code") assert state is None + + +async def test_diagnostic_sensors_disabled_by_default( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that diagnostic sensors are disabled by default.""" + await setup_integration(hass, mock_config_entry) + + for sensor_key in ( + "status_code", + "last_event", + "certificate_expiration", + "dns_response_code", + ): + entity_id = f"sensor.core_backend_service_{sensor_key}" + assert hass.states.get(entity_id) is None + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION From 1699ecac0a9f44455255431fce03a469a7630c7d Mon Sep 17 00:00:00 2001 From: Hamish Date: Tue, 22 Sep 2026 02:40:17 +0930 Subject: [PATCH 25/33] Add sensor platform to INDI Allsky integration (#182326) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Norbert Rittel --- .../components/indi_allsky/__init__.py | 8 +- .../components/indi_allsky/coordinator.py | 30 +- .../components/indi_allsky/manifest.json | 2 +- .../components/indi_allsky/sensor.py | 120 ++++++ .../components/indi_allsky/strings.json | 22 ++ homeassistant/generated/integrations.json | 2 +- tests/components/indi_allsky/conftest.py | 29 +- .../fixtures/exposure_complete.json | 13 + .../indi_allsky/snapshots/test_sensor.ambr | 373 ++++++++++++++++++ tests/components/indi_allsky/test_camera.py | 10 +- tests/components/indi_allsky/test_init.py | 18 +- tests/components/indi_allsky/test_sensor.py | 138 +++++++ 12 files changed, 748 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/indi_allsky/sensor.py create mode 100644 tests/components/indi_allsky/fixtures/exposure_complete.json create mode 100644 tests/components/indi_allsky/snapshots/test_sensor.ambr create mode 100644 tests/components/indi_allsky/test_sensor.py diff --git a/homeassistant/components/indi_allsky/__init__.py b/homeassistant/components/indi_allsky/__init__.py index 51eb4e00ba5b1..832240ccce7f7 100644 --- a/homeassistant/components/indi_allsky/__init__.py +++ b/homeassistant/components/indi_allsky/__init__.py @@ -5,7 +5,7 @@ from .coordinator import IndiAllSkyConfigEntry, IndiAllSkyDataUpdateCoordinator -_PLATFORMS: list[Platform] = [Platform.CAMERA] +_PLATFORMS: list[Platform] = [Platform.CAMERA, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> bool: @@ -15,6 +15,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: IndiAllSkyConfigEntry) - entry.runtime_data = coordinator + entry.async_create_background_task( + hass, + coordinator.client.listen(auto_reconnect=True), + "indi_allsky_ws_events", + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True diff --git a/homeassistant/components/indi_allsky/coordinator.py b/homeassistant/components/indi_allsky/coordinator.py index f458005ba44c6..9a6b1a88691c5 100644 --- a/homeassistant/components/indi_allsky/coordinator.py +++ b/homeassistant/components/indi_allsky/coordinator.py @@ -1,9 +1,10 @@ """DataUpdateCoordinator for INDI Allsky integration.""" +from dataclasses import dataclass import logging from typing import override -from aioindiallsky import IndiAllSkyClient, IndiAllSkyError +from aioindiallsky import ExposureData, IndiAllSkyClient, IndiAllSkyError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL @@ -19,7 +20,14 @@ type IndiAllSkyConfigEntry = ConfigEntry[IndiAllSkyDataUpdateCoordinator] -class IndiAllSkyDataUpdateCoordinator(DataUpdateCoordinator[None]): +@dataclass +class IndiAllSkyData: + """Data model for INDI Allsky coordinator data.""" + + exposure: ExposureData | None = None + + +class IndiAllSkyDataUpdateCoordinator(DataUpdateCoordinator[IndiAllSkyData]): """Class to manage fetching INDI Allsky data from the API.""" def __init__(self, hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> None: @@ -33,6 +41,13 @@ def __init__(self, hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> None: ), session=async_get_clientsession(hass), ) + self.latest_exposure: ExposureData | None = None + + unsub = self.client.register_callback( + "exposure_complete", self._handle_exposure_complete + ) + entry.async_on_unload(unsub) + entry.async_on_unload(self.client.disconnect) super().__init__( hass, @@ -42,13 +57,22 @@ def __init__(self, hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> None: update_interval=None, ) + def _handle_exposure_complete(self, exposure: ExposureData) -> None: + """Handle new exposure_complete event from WebSocket stream.""" + self.latest_exposure = exposure + self.async_set_updated_data(IndiAllSkyData(exposure=exposure)) + @override - async def _async_update_data(self) -> None: + async def _async_update_data(self) -> IndiAllSkyData: """Fetch INDI Allsky metadata and verify connection.""" try: await self.client.fetch_image("latestimage") + if not self.client.is_connected: + await self.client.connect() except IndiAllSkyError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", ) from err + + return IndiAllSkyData(exposure=self.latest_exposure) diff --git a/homeassistant/components/indi_allsky/manifest.json b/homeassistant/components/indi_allsky/manifest.json index fb63bfd8105df..74054ed8f762c 100644 --- a/homeassistant/components/indi_allsky/manifest.json +++ b/homeassistant/components/indi_allsky/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/indi_allsky", "integration_type": "service", - "iot_class": "local_polling", + "iot_class": "local_push", "loggers": ["aioindiallsky"], "quality_scale": "bronze", "requirements": ["aioindiallsky==0.1.2"] diff --git a/homeassistant/components/indi_allsky/sensor.py b/homeassistant/components/indi_allsky/sensor.py new file mode 100644 index 0000000000000..c90868471ea26 --- /dev/null +++ b/homeassistant/components/indi_allsky/sensor.py @@ -0,0 +1,120 @@ +"""Support for INDI Allsky sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import ( + IndiAllSkyConfigEntry, + IndiAllSkyData, + IndiAllSkyDataUpdateCoordinator, +) +from .entity import IndiAllSkyEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class IndiAllSkySensorEntityDescription(SensorEntityDescription): + """Class describing INDI Allsky sensor entities.""" + + value_fn: Callable[[IndiAllSkyData], StateType] + + +SENSOR_DESCRIPTIONS: tuple[IndiAllSkySensorEntityDescription, ...] = ( + IndiAllSkySensorEntityDescription( + key="binmode", + translation_key="binmode", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.exposure.binmode if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="exposure", + translation_key="exposure", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.exposure.exposure if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="filename", + translation_key="filename", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.exposure.filename if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="gain", + translation_key="gain", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.exposure.gain if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="sqm", + translation_key="sqm", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.exposure.sqm if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="stars", + translation_key="stars", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.exposure.stars if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.exposure.temp if data.exposure else None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IndiAllSkyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up INDI Allsky sensors based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + IndiAllSkySensor(coordinator, entry, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class IndiAllSkySensor(IndiAllSkyEntity, SensorEntity): + """Representation of an INDI Allsky sensor.""" + + entity_description: IndiAllSkySensorEntityDescription + + def __init__( + self, + coordinator: IndiAllSkyDataUpdateCoordinator, + entry: IndiAllSkyConfigEntry, + description: IndiAllSkySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, entry) + self.entity_description = description + self._attr_unique_id = f"{entry.entry_id}_{description.key}" + + @property + @override + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/indi_allsky/strings.json b/homeassistant/components/indi_allsky/strings.json index d4884470a3fdc..20318e771506c 100644 --- a/homeassistant/components/indi_allsky/strings.json +++ b/homeassistant/components/indi_allsky/strings.json @@ -26,6 +26,28 @@ } } }, + "entity": { + "sensor": { + "binmode": { + "name": "Binning mode" + }, + "exposure": { + "name": "Exposure time" + }, + "filename": { + "name": "Filename" + }, + "gain": { + "name": "Gain" + }, + "sqm": { + "name": "Sky quality" + }, + "stars": { + "name": "Stars" + } + } + }, "exceptions": { "update_failed": { "message": "Error communicating with INDI Allsky API" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e3b3f19c4e736..3d4f871b0019c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3377,7 +3377,7 @@ "name": "INDI Allsky", "integration_type": "service", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_push" }, "indianamichiganpower": { "name": "Indiana Michigan Power", diff --git a/tests/components/indi_allsky/conftest.py b/tests/components/indi_allsky/conftest.py index 4b3ebc9e8cdf8..31d762fffd27d 100644 --- a/tests/components/indi_allsky/conftest.py +++ b/tests/components/indi_allsky/conftest.py @@ -1,14 +1,16 @@ """Common fixtures for the INDI Allsky tests.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from collections.abc import Callable, Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +from aioindiallsky import ExposureData import pytest from homeassistant.components.indi_allsky.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture(autouse=True) @@ -30,6 +32,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture def mock_indi_allsky_client() -> Generator[AsyncMock]: """Mock the third-party aioindiallsky client globally across coordinator and config flow.""" + callbacks: dict[str, list[Callable[..., Any]]] = {} + + def register_callback( + event_type: str, callback: Callable[..., Any] + ) -> Callable[[], None]: + callbacks.setdefault(event_type, []).append(callback) + return lambda: callbacks[event_type].remove(callback) + with ( patch( "homeassistant.components.indi_allsky.coordinator.IndiAllSkyClient", @@ -44,9 +54,22 @@ def mock_indi_allsky_client() -> Generator[AsyncMock]: client_instance.fetch_image = AsyncMock( return_value=b"\xff\xd8\xff\xe0fake_jpeg_data" ) + client_instance.connect = AsyncMock() + client_instance.listen = AsyncMock() + client_instance.disconnect = AsyncMock() + client_instance.is_connected = False + client_instance.register_callback = MagicMock(side_effect=register_callback) + client_instance.callbacks = callbacks yield client_instance +@pytest.fixture +def mock_exposure_data() -> ExposureData: + """Fixture to provide sample ExposureData from fixture JSON.""" + raw_data = load_json_object_fixture("exposure_complete.json", DOMAIN) + return ExposureData.from_dict(raw_data) + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Fixture to cleanly create an INDI Allsky configuration entry.""" diff --git a/tests/components/indi_allsky/fixtures/exposure_complete.json b/tests/components/indi_allsky/fixtures/exposure_complete.json new file mode 100644 index 0000000000000..078f1adaf3bdd --- /dev/null +++ b/tests/components/indi_allsky/fixtures/exposure_complete.json @@ -0,0 +1,13 @@ +{ + "filename": "test.jpg", + "createDate": "2026-08-13 22:53:41", + "exposure": 0.185, + "gain": 0.0, + "binmode": 1, + "night": false, + "camera_id": 1, + "id": 4718, + "temp": -273.15, + "sqm": 32928.83, + "stars": 0 +} diff --git a/tests/components/indi_allsky/snapshots/test_sensor.ambr b/tests/components/indi_allsky/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..f2b8a8a503169 --- /dev/null +++ b/tests/components/indi_allsky/snapshots/test_sensor.ambr @@ -0,0 +1,373 @@ +# serializer version: 1 +# name: test_sensor_setup_and_states[sensor.indi_allsky_binning_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.indi_allsky_binning_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Binning mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Binning mode', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'binmode', + 'unique_id': '1234567890abcdef1234567890abcdef_binmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_binning_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Binning mode', + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_binning_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_exposure_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.indi_allsky_exposure_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Exposure time', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Exposure time', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'exposure', + 'unique_id': '1234567890abcdef1234567890abcdef_exposure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_exposure_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'INDI Allsky Exposure time', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_exposure_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_filename-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.indi_allsky_filename', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filename', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filename', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filename', + 'unique_id': '1234567890abcdef1234567890abcdef_filename', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_filename-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Filename', + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_filename', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_gain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.indi_allsky_gain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Gain', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gain', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gain', + 'unique_id': '1234567890abcdef1234567890abcdef_gain', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_gain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Gain', + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_gain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_sky_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.indi_allsky_sky_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sky quality', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sky quality', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sqm', + 'unique_id': '1234567890abcdef1234567890abcdef_sqm', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_sky_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Sky quality', + : , + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_sky_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_stars-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.indi_allsky_stars', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stars', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stars', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stars', + 'unique_id': '1234567890abcdef1234567890abcdef_stars', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_stars-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Stars', + : , + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_stars', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.indi_allsky_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890abcdef1234567890abcdef_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'INDI Allsky Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/indi_allsky/test_camera.py b/tests/components/indi_allsky/test_camera.py index 917f2615d3cda..eadb003eb6a09 100644 --- a/tests/components/indi_allsky/test_camera.py +++ b/tests/components/indi_allsky/test_camera.py @@ -1,12 +1,13 @@ """Tests for the INDI Allsky camera platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from aioindiallsky import IndiAllSkyError import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.camera import async_get_image +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -24,8 +25,11 @@ async def test_camera_setup_and_states( entity_registry: er.EntityRegistry, ) -> None: """Test standard successful setup and entity snapshots using snapshot_platform.""" - await setup_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.CAMERA]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) @pytest.mark.parametrize( diff --git a/tests/components/indi_allsky/test_init.py b/tests/components/indi_allsky/test_init.py index 32367e5eb6025..bc1ec68442643 100644 --- a/tests/components/indi_allsky/test_init.py +++ b/tests/components/indi_allsky/test_init.py @@ -1,8 +1,9 @@ -"""Test initialization of INDI Allsky integration.""" +"""Tests for the INDI Allsky integration.""" from unittest.mock import AsyncMock from aioindiallsky import IndiAllSkyConnectionError +import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -14,29 +15,36 @@ async def test_setup_and_unload_entry( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, mock_indi_allsky_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test successful setup and unload of entry.""" await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED + mock_indi_allsky_client.listen.assert_called_once_with(auto_reconnect=True) await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_indi_allsky_client.disconnect.assert_awaited_once() +@pytest.mark.parametrize( + "method_name", + ["fetch_image", "connect"], +) async def test_setup_failure_retry( hass: HomeAssistant, mock_indi_allsky_client: AsyncMock, mock_config_entry: MockConfigEntry, + method_name: str, ) -> None: """Test that an API connection failure during initial setup places entry in retry state.""" - mock_indi_allsky_client.fetch_image.side_effect = IndiAllSkyConnectionError( - "Cannot connect to INDI Allsky server" - ) + getattr( + mock_indi_allsky_client, method_name + ).side_effect = IndiAllSkyConnectionError("Cannot connect to INDI Allsky server") await setup_integration(hass, mock_config_entry) diff --git a/tests/components/indi_allsky/test_sensor.py b/tests/components/indi_allsky/test_sensor.py new file mode 100644 index 0000000000000..212b43f6e3c4f --- /dev/null +++ b/tests/components/indi_allsky/test_sensor.py @@ -0,0 +1,138 @@ +"""Tests for the INDI Allsky sensor platform.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, patch + +from aioindiallsky import ExposureData +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_indi_allsky_client" +) +async def test_sensor_setup_and_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test standard successful setup and entity snapshots using snapshot_platform.""" + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("mock_indi_allsky_client") +async def test_disabled_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that disabled-by-default sensors are registered as disabled.""" + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + for entity_id in ( + "sensor.indi_allsky_binning_mode", + "sensor.indi_allsky_filename", + "sensor.indi_allsky_gain", + ): + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + for entity_id in ( + "sensor.indi_allsky_exposure_time", + "sensor.indi_allsky_sky_quality", + "sensor.indi_allsky_stars", + "sensor.indi_allsky_temperature", + ): + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is None + + +async def test_sensor_updates( + hass: HomeAssistant, + mock_indi_allsky_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_exposure_data: ExposureData, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor state values update on exposure_complete event.""" + # Enable disabled sensors for testing + entity_registry.async_get_or_create( + domain="sensor", + platform="indi_allsky", + unique_id=f"{mock_config_entry.entry_id}_binmode", + suggested_object_id="indi_allsky_binning_mode", + disabled_by=None, + ) + entity_registry.async_get_or_create( + domain="sensor", + platform="indi_allsky", + unique_id=f"{mock_config_entry.entry_id}_filename", + suggested_object_id="indi_allsky_filename", + disabled_by=None, + ) + entity_registry.async_get_or_create( + domain="sensor", + platform="indi_allsky", + unique_id=f"{mock_config_entry.entry_id}_gain", + suggested_object_id="indi_allsky_gain", + disabled_by=None, + ) + + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + for callback in mock_indi_allsky_client.callbacks.get("exposure_complete", []): + callback(mock_exposure_data) + await hass.async_block_till_done() + + state = hass.states.get("sensor.indi_allsky_exposure_time") + assert state is not None + assert state.state == "0.185" + + state = hass.states.get("sensor.indi_allsky_temperature") + assert state is not None + assert state.state == STATE_UNKNOWN + + state = hass.states.get("sensor.indi_allsky_sky_quality") + assert state is not None + assert state.state == "32928.83" + + state = hass.states.get("sensor.indi_allsky_stars") + assert state is not None + assert state.state == "0" + + state = hass.states.get("sensor.indi_allsky_binning_mode") + assert state is not None + assert state.state == "1" + + state = hass.states.get("sensor.indi_allsky_filename") + assert state is not None + assert state.state == "test.jpg" + + state = hass.states.get("sensor.indi_allsky_gain") + assert state is not None + assert state.state == "0.0" + + for callback in mock_indi_allsky_client.callbacks.get("exposure_complete", []): + callback(replace(mock_exposure_data, temp=12.5)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.indi_allsky_temperature") + assert state is not None + assert state.state == "12.5" From 32b8e0517dd9e4e5747b1398242c1a50c9e907a4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:13:06 +0200 Subject: [PATCH 26/33] Prepare the breakup of the coordinator in LibreNMS (#182388) --- homeassistant/components/librenms/__init__.py | 41 ++++++- .../components/librenms/binary_sensor.py | 4 +- .../components/librenms/coordinator.py | 91 ++++++++------ homeassistant/components/librenms/entity.py | 29 ++--- homeassistant/components/librenms/sensor.py | 10 +- tests/components/librenms/conftest.py | 2 +- .../librenms/snapshots/test_sensor.ambr | 112 ++++++++++++++++++ 7 files changed, 219 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/librenms/__init__.py b/homeassistant/components/librenms/__init__.py index acd4e75d356bc..d78b20b95dd9c 100644 --- a/homeassistant/components/librenms/__init__.py +++ b/homeassistant/components/librenms/__init__.py @@ -1,9 +1,23 @@ """The LibreNMS integration.""" -from homeassistant.const import Platform +from aiolibrenms import Librenms +from aiolibrenms.const import CONNECT_ERRORS +from aiolibrenms.exceptions import LibrenmsUnauthenticatedError + +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import LibrenmsConfigEntry, LibrenmsDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import LibrenmsCentralDataUpdateCoordinator, LibrenmsConfigEntry PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -11,7 +25,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: LibrenmsConfigEntry) -> bool: """Set up LibreNMS from a config entry.""" - coordinator = LibrenmsDataUpdateCoordinator(hass, entry) + api = Librenms( + async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), + entry.data[CONF_API_KEY], + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_SSL], + ) + + try: + await api.system.async_get_system_info() + except LibrenmsUnauthenticatedError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from err + except CONNECT_ERRORS as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + coordinator = LibrenmsCentralDataUpdateCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/librenms/binary_sensor.py b/homeassistant/components/librenms/binary_sensor.py index 683ac0596a25d..ef7e2ac04d802 100644 --- a/homeassistant/components/librenms/binary_sensor.py +++ b/homeassistant/components/librenms/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import LibrenmsConfigEntry, LibrenmsDataUpdateCoordinator +from .coordinator import LibrenmsCentralDataUpdateCoordinator, LibrenmsConfigEntry from .entity import LibrenmsDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -64,7 +64,7 @@ class LibrenmsDeviceBinarySensorEntity(LibrenmsDeviceEntity, BinarySensorEntity) def __init__( self, - coordinator: LibrenmsDataUpdateCoordinator, + coordinator: LibrenmsCentralDataUpdateCoordinator, description: LibrenmsDeviceBinarySensorEntityDescription, device_id: int, ) -> None: diff --git a/homeassistant/components/librenms/coordinator.py b/homeassistant/components/librenms/coordinator.py index 09785ab71f190..8a3508fa217f7 100644 --- a/homeassistant/components/librenms/coordinator.py +++ b/homeassistant/components/librenms/coordinator.py @@ -13,16 +13,10 @@ from yarl import URL from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_PORT, - CONF_SSL, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -31,30 +25,30 @@ @dataclass -class LibrenmsData: +class LibrenmsCentralData: """Data class for storing data from the API.""" system: LibrenmsSystemInfo devices: dict[int, LibrenmsDeviceInfo] -type LibrenmsConfigEntry = ConfigEntry[LibrenmsDataUpdateCoordinator] +type LibrenmsConfigEntry = ConfigEntry[LibrenmsCentralDataUpdateCoordinator] -class LibrenmsDataUpdateCoordinator(DataUpdateCoordinator[LibrenmsData]): - """Class to manage fetching LibreNMS data.""" +class LibrenmsBaseDataUpdateCoordinator[T](DataUpdateCoordinator[T]): + """Base class to manage fetching LibreNMS data.""" config_entry: LibrenmsConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: LibrenmsConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: LibrenmsConfigEntry, + api: Librenms, + update_interval: timedelta, + ) -> None: """Initialize the data update coordinator.""" - self.api = Librenms( - async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]), - config_entry.data[CONF_API_KEY], - config_entry.data[CONF_HOST], - config_entry.data[CONF_PORT], - config_entry.data[CONF_SSL], - ) + self.api = api self.configuration_url = str( URL.build( scheme="https" if config_entry.data[CONF_SSL] else "http", @@ -67,27 +61,28 @@ def __init__(self, hass: HomeAssistant, config_entry: LibrenmsConfigEntry) -> No _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=60), + update_interval=update_interval, ) - @override - async def _async_setup(self) -> None: - """Handle setup of the coordinator.""" - try: - await self.api.system.async_get_system_info() - except LibrenmsUnauthenticatedError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="auth_error", - ) from err - except CONNECT_ERRORS as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err + +class LibrenmsCentralDataUpdateCoordinator( + LibrenmsBaseDataUpdateCoordinator[LibrenmsCentralData] +): + """Coordinator to fetch librenms system data and monitored devices meta data.""" + + def __init__( + self, hass: HomeAssistant, config_entry: LibrenmsConfigEntry, api: Librenms + ) -> None: + """Initialize the data update coordinator.""" + super().__init__( + hass, + config_entry=config_entry, + api=api, + update_interval=timedelta(seconds=60), + ) @override - async def _async_update_data(self) -> LibrenmsData: + async def _async_update_data(self) -> LibrenmsCentralData: """Update data via internal method.""" try: system = await self.api.system.async_get_system_info() @@ -104,4 +99,24 @@ async def _async_update_data(self) -> LibrenmsData: translation_placeholders={"error": str(err)}, ) from err - return LibrenmsData(system, {dev.device_id: dev for dev in devices}) + device_reg = dr.async_get(self.hass) + for device in devices: + identifier = f"{self.config_entry.entry_id}_{device.device_id}" + sw_version = device.version + model = None + if device.os != "ping": + if sw_version and (feature := device.features) is not None: + sw_version += f" ({feature})" + model = device.hardware + + device_reg.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, identifier)}, + sw_version=sw_version, + configuration_url=f"{self.configuration_url}/device/{device.device_id}", + name=device.display, + model=model, + serial_number=device.serial, + ) + + return LibrenmsCentralData(system, {dev.device_id: dev for dev in devices}) diff --git a/homeassistant/components/librenms/entity.py b/homeassistant/components/librenms/entity.py index 906e611bad601..b2d89ce267f56 100644 --- a/homeassistant/components/librenms/entity.py +++ b/homeassistant/components/librenms/entity.py @@ -8,39 +8,26 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import LibrenmsDataUpdateCoordinator +from .coordinator import LibrenmsCentralDataUpdateCoordinator -class LibrenmsDeviceEntity(CoordinatorEntity[LibrenmsDataUpdateCoordinator]): +class LibrenmsDeviceEntity(CoordinatorEntity[LibrenmsCentralDataUpdateCoordinator]): """Define LibreNMS device base entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: LibrenmsDataUpdateCoordinator, + coordinator: LibrenmsCentralDataUpdateCoordinator, device_id: int, ) -> None: """Initialize.""" super().__init__(coordinator) self.device_id = device_id - - identifier = f"{coordinator.config_entry.entry_id}_{self.device_id}" - sw_version = self._data.version - model = None - configuration_url = f"{coordinator.configuration_url}/device/{self.device_id}" - if self._data.os != "ping": - if sw_version and (feature := self._data.features) is not None: - sw_version += f" ({feature})" - model = self._data.hardware - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, identifier)}, - sw_version=sw_version, - configuration_url=configuration_url, - name=self._data.display, - model=model, - serial_number=self._data.serial, + identifiers={ + (DOMAIN, f"{coordinator.config_entry.entry_id}_{self.device_id}") + } ) @property @@ -55,14 +42,14 @@ def _data(self) -> LibrenmsDeviceInfo: return self.coordinator.data.devices[self.device_id] -class LibrenmsSystemEntity(CoordinatorEntity[LibrenmsDataUpdateCoordinator]): +class LibrenmsSystemEntity(CoordinatorEntity[LibrenmsCentralDataUpdateCoordinator]): """Define LibreNMS base entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: LibrenmsDataUpdateCoordinator, + coordinator: LibrenmsCentralDataUpdateCoordinator, ) -> None: """Initialize.""" super().__init__(coordinator) diff --git a/homeassistant/components/librenms/sensor.py b/homeassistant/components/librenms/sensor.py index 09e3dd237d963..84017422b66c8 100644 --- a/homeassistant/components/librenms/sensor.py +++ b/homeassistant/components/librenms/sensor.py @@ -15,9 +15,9 @@ from homeassistant.helpers.typing import StateType from .coordinator import ( + LibrenmsCentralData, + LibrenmsCentralDataUpdateCoordinator, LibrenmsConfigEntry, - LibrenmsData, - LibrenmsDataUpdateCoordinator, ) from .entity import LibrenmsSystemEntity @@ -29,8 +29,8 @@ class LibrenmsSystemSensorEntityDescription(SensorEntityDescription): """Librenms system sensor entity description.""" - value: Callable[[LibrenmsData], StateType] - is_suitable: Callable[[LibrenmsData], bool] = lambda _: True + value: Callable[[LibrenmsCentralData], StateType] + is_suitable: Callable[[LibrenmsCentralData], bool] = lambda _: True SYSTEM_SENSOR_TYPES: tuple[LibrenmsSystemSensorEntityDescription, ...] = ( @@ -99,7 +99,7 @@ class LibrenmsSystemSensorEntity(LibrenmsSystemEntity, SensorEntity): def __init__( self, - coordinator: LibrenmsDataUpdateCoordinator, + coordinator: LibrenmsCentralDataUpdateCoordinator, description: LibrenmsSystemSensorEntityDescription, ) -> None: """Initialize.""" diff --git a/tests/components/librenms/conftest.py b/tests/components/librenms/conftest.py index 1bbf4b39e0b7e..645bcc4453523 100644 --- a/tests/components/librenms/conftest.py +++ b/tests/components/librenms/conftest.py @@ -58,7 +58,7 @@ async def mock_librenms( """Mock the LibreNMS API.""" with ( patch( - "homeassistant.components.librenms.coordinator.Librenms", autospec=True + "homeassistant.components.librenms.Librenms", autospec=True ) as mock_librenms, patch( "homeassistant.components.librenms.config_flow.Librenms", new=mock_librenms diff --git a/tests/components/librenms/snapshots/test_sensor.ambr b/tests/components/librenms/snapshots/test_sensor.ambr index 95e64f0e5ef76..c20eeb065d283 100644 --- a/tests/components/librenms/snapshots/test_sensor.ambr +++ b/tests/components/librenms/snapshots/test_sensor.ambr @@ -1,6 +1,118 @@ # serializer version: 1 # name: test_sensors.12 list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_1', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'SFVH_KV01_SFOS', + 'model_id': None, + 'name': 'SophosXG', + 'name_by_user': None, + 'serial_number': 'C01yyyyyyxxxxxx', + 'sw_version': '22.0.1 MR-1-Build490', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/3', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_3', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'MC2425adw', + 'model_id': None, + 'name': 'Drucker', + 'name_by_user': None, + 'serial_number': '7529936145YFW', + 'sw_version': 'CXNZJ.250.038', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/13', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_13', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'QEMU Standard PC (i440FX + PIIX, 1996)', + 'model_id': None, + 'name': 'homeassistant', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': '6.18.37-haos (Home Assistant OS 18.1)', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/29', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_29', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'firmware.esphome.io (104.21.87.21)', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entry_id': , From 62c9711cdd1a8c81ff4b058a72103dc511e3233a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 21 Sep 2026 19:13:49 +0200 Subject: [PATCH 27/33] Handle service device in diagnostics for Alexa Devices (#182788) --- .../components/alexa_devices/diagnostics.py | 6 +++-- .../alexa_devices/test_diagnostics.py | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/diagnostics.py b/homeassistant/components/alexa_devices/diagnostics.py index 09a061737a2a2..58342307ac39d 100644 --- a/homeassistant/components/alexa_devices/diagnostics.py +++ b/homeassistant/components/alexa_devices/diagnostics.py @@ -55,9 +55,11 @@ async def async_get_device_diagnostics( coordinator = entry.runtime_data if TYPE_CHECKING: - # alexa_devices does not create child devices, and devices have a serial number assert isinstance(device_entry, DeviceEntry) - assert device_entry.serial_number + + if device_entry.serial_number is None: + # Service device has no serial number and no matching coordinator data + return {"service device": True} return build_device_data(coordinator.data[device_entry.serial_number]) diff --git a/tests/components/alexa_devices/test_diagnostics.py b/tests/components/alexa_devices/test_diagnostics.py index 00a63db8ca8f4..4c8388f6280f4 100644 --- a/tests/components/alexa_devices/test_diagnostics.py +++ b/tests/components/alexa_devices/test_diagnostics.py @@ -6,6 +6,7 @@ from syrupy.filters import props from homeassistant.components.alexa_devices.const import DOMAIN +from homeassistant.components.alexa_devices.entity import service_device_id from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -66,3 +67,25 @@ async def test_device_diagnostics( "modified_at", ) ) + + +async def test_service_device_diagnostics( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test Amazon service device diagnostics, which has no serial number.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, service_device_id(mock_config_entry.runtime_data)), + mock_config_entry.entry_id, + ) + assert device, repr(device_registry._devices) + assert device.serial_number is None + + assert await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) == {"service device": True} From 71e382e3a86e1e71254711d37e16594ecb2d26a0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 21 Sep 2026 19:14:34 +0200 Subject: [PATCH 28/33] Add cover speeds for SwitchBot curtains (#182416) Co-authored-by: Claude --- homeassistant/components/switchbot/const.py | 11 ++ homeassistant/components/switchbot/cover.py | 27 +++- .../components/switchbot/strings.json | 6 + tests/components/switchbot/test_cover.py | 116 +++++++++++++++++- 4 files changed, 153 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index e1cab0c89c311..e6e299d929632 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -261,6 +261,17 @@ class SupportedModels(StrEnum): ROLLER_SHADE_SPEED_QUIET: 1, } +# Curtain movement speeds, exposed as cover speeds. +# Only the Curtain 3 (identified by its advertised model friendly name) +# honours the speed byte; other curtain models always move at normal speed. +CURTAIN_3_MODEL_FRIENDLY_NAME = "Curtain 3" +CURTAIN_SPEED_NORMAL = "normal" +CURTAIN_SPEED_SILENT = "silent" +CURTAIN_SPEED_TO_VALUE = { + CURTAIN_SPEED_NORMAL: 255, + CURTAIN_SPEED_SILENT: 1, +} + AIRPURIFIER_BASIC_MODELS = { SwitchbotModel.AIR_PURIFIER_JP, SwitchbotModel.AIR_PURIFIER_US, diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 411e4f05dc26d..021fc9231853a 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -20,6 +20,8 @@ from .const import ( CONF_CURTAIN_SPEED, + CURTAIN_3_MODEL_FRIENDLY_NAME, + CURTAIN_SPEED_TO_VALUE, DEFAULT_CURTAIN_SPEED, ROLLER_SHADE_SPEED_PERFORMANCE, ROLLER_SHADE_SPEED_TO_MODE, @@ -67,10 +69,22 @@ def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: """Initialize the Switchbot.""" super().__init__(coordinator) self._attr_is_closed = None + # Only the Curtain 3 honours the speed byte; expose cover speeds + # (e.g. silent mode) for that model only. + if self._device.data.get("modelFriendlyName") == CURTAIN_3_MODEL_FRIENDLY_NAME: + self._attr_supported_features |= CoverEntityFeature.SPEED + self._attr_supported_speeds = list(CURTAIN_SPEED_TO_VALUE) @callback - def _get_curtain_speed(self) -> int: - """Return the configured curtain speed.""" + def _get_curtain_speed(self, kwargs: dict[str, Any]) -> int: + """Return the movement speed for the requested cover speed. + + A speed passed to the action takes precedence; otherwise the speed + configured in the options flow is used. + """ + if (speed := kwargs.get(ATTR_SPEED)) is not None: + # The cover entity validated the speed against _attr_supported_speeds. + return CURTAIN_SPEED_TO_VALUE[speed] return int( self.coordinator.config_entry.options.get( CONF_CURTAIN_SPEED, DEFAULT_CURTAIN_SPEED @@ -101,7 +115,7 @@ async def async_open_cover(self, **kwargs: Any) -> None: """Open the curtain.""" _LOGGER.debug("Switchbot to open curtain %s", self._address) - speed = self._get_curtain_speed() + speed = self._get_curtain_speed(kwargs) self._last_run_success = bool(await self._device.open(speed)) self._attr_is_opening = self._device.is_opening() self._attr_is_closing = self._device.is_closing() @@ -113,7 +127,7 @@ async def async_close_cover(self, **kwargs: Any) -> None: """Close the curtain.""" _LOGGER.debug("Switchbot to close the curtain %s", self._address) - speed = self._get_curtain_speed() + speed = self._get_curtain_speed(kwargs) self._last_run_success = bool(await self._device.close(speed)) self._attr_is_opening = self._device.is_opening() self._attr_is_closing = self._device.is_closing() @@ -134,10 +148,11 @@ async def async_stop_cover(self, **kwargs: Any) -> None: @override async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" - position = kwargs.get(ATTR_POSITION) + position = kwargs[ATTR_POSITION] _LOGGER.debug("Switchbot to move at %d %s", position, self._address) - self._last_run_success = bool(await self._device.set_position(position)) + speed = self._get_curtain_speed(kwargs) + self._last_run_success = bool(await self._device.set_position(position, speed)) self._attr_is_opening = self._device.is_opening() self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 87a6c93ebb768..9034516c04097 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -157,6 +157,12 @@ "false": "[%key:component::binary_sensor::entity_component::problem::state::on%]", "true": "[%key:component::binary_sensor::entity_component::problem::state::off%]" } + }, + "speed": { + "state": { + "normal": "Normal", + "silent": "Silent" + } } } }, diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index 6025464a1182e..76131bf38e8ac 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -15,17 +15,21 @@ ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, CoverEntityCapabilityAttribute, + CoverEntityFeature, CoverState, ) from homeassistant.components.switchbot.const import ( CONF_CURTAIN_SPEED, CONF_RETRY_COUNT, + CURTAIN_SPEED_NORMAL, + CURTAIN_SPEED_SILENT, DEFAULT_RETRY_COUNT, ROLLER_SHADE_SPEED_PERFORMANCE, ROLLER_SHADE_SPEED_QUIET, ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -176,7 +180,7 @@ async def test_curtain3_controlling( ) await hass.async_block_till_done() - mock_set_position.assert_awaited_once() + mock_set_position.assert_awaited_once_with(50, 255) # Default speed state = hass.states.get(entity_id) assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 60 @@ -231,6 +235,116 @@ async def test_curtain3_custom_speed_controlling( mock_close.assert_awaited_once_with(50) +@pytest.mark.parametrize( + ("speed", "expected_value"), + [ + pytest.param(CURTAIN_SPEED_NORMAL, 255, id="normal"), + pytest.param(CURTAIN_SPEED_SILENT, 1, id="silent"), + ], +) +async def test_curtain3_speed( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + speed: str, + expected_value: int, +) -> None: + """Test the curtain forwards the requested cover speed to the device.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="curtain") + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + + state = hass.states.get(entity_id) + assert state.attributes[CoverEntityCapabilityAttribute.SUPPORTED_SPEEDS] == [ + CURTAIN_SPEED_NORMAL, + CURTAIN_SPEED_SILENT, + ] + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: speed}, + blocking=True, + ) + mock_open.assert_awaited_once_with(expected_value) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: speed}, + blocking=True, + ) + mock_close.assert_awaited_once_with(expected_value) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50, ATTR_SPEED: speed}, + blocking=True, + ) + mock_set_position.assert_awaited_once_with(50, expected_value) + + +async def test_curtain_speed_not_supported( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test a non-Curtain-3 curtain does not expose the speed feature.""" + # A Curtain (not Curtain 3) advertises the "c" device type in its service data. + inject_bluetooth_service_info( + hass, + make_advertisement( + "AA:BB:CC:DD:EE:FF", + b"\xcf;Zwu\x0c\x19\x0b\x00\x11D\x006", + b"c\xc06\x00\x11D", + ), + ) + + entry = mock_entry_factory(sensor_type="curtain") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.open", + new=AsyncMock(return_value=True), + ) as mock_open: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + + state = hass.states.get(entity_id) + assert not state.attributes[ATTR_SUPPORTED_FEATURES] & CoverEntityFeature.SPEED + assert CoverEntityCapabilityAttribute.SUPPORTED_SPEEDS not in state.attributes + + # A speed passed to a model that does not support it is ignored and the + # curtain moves at the default speed. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: CURTAIN_SPEED_SILENT}, + blocking=True, + ) + mock_open.assert_awaited_once_with(255) + + async def test_blindtilt_setup( hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] ) -> None: From c945eab58a298db28c51a27f658b17efa0132c5e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 22 Sep 2026 03:40:52 +1000 Subject: [PATCH 29/33] Offer retry when Teslemetry Powerwall approval window expires (#181334) --- .../components/teslemetry/config_flow.py | 10 +++ .../components/teslemetry/strings.json | 1 + .../components/teslemetry/test_config_flow.py | 68 +++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index bd166b3e9f7a2..d7efe5aa34e99 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -451,6 +451,7 @@ def __init__(self) -> None: self._discovered_host: str = "" self._site_id: int | None = None self._site_name: str = "" + self._approval_expired: bool = False async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -603,6 +604,11 @@ async def async_step_pair( if user_input is None: return self.async_show_form(step_id="pair") + if self._approval_expired: + # The user saw the expired-window notice and submitted to try again. + self._approval_expired = False + return await self._async_begin_pairing() + try: client = await self._find_authorized_client() except PowerwallLookupError: @@ -618,6 +624,10 @@ async def async_step_pair( return await self.async_step_credentials() if client.state == AuthorizedClientState.PENDING_VERIFICATION: return self.async_show_form(step_id="pair", errors={"base": "key_pending"}) + if client.state == AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT: + # Surface the expiry; the user's next submit reopens the window. + self._approval_expired = True + return self.async_show_form(step_id="pair", errors={"base": "key_expired"}) # An unrecognized state reported as pending would trap the user forever. LOGGER.debug("Unrecognized authorized-client state: %s", client.state) return self.async_show_form(step_id="pair", errors={"base": "cannot_connect"}) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 1d69696b2b679..09e5e0a0544bb 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -58,6 +58,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_password": "[%key:common::config_flow::error::invalid_auth%]", + "key_expired": "The approval window on your Powerwall closed before the key was approved. Submit to open a new window, flick the On/Off switch on your primary Powerwall off and back on to approve it, then submit again.", "key_not_approved": "Your Powerwall system rejected Home Assistant's key because it has not been approved. Flick the On/Off switch on your primary Powerwall off and back on to approve it, then submit again.", "key_not_registered": "Home Assistant's key is no longer registered on your Powerwall system. Restart setup to register it again.", "key_pending": "Home Assistant's key has not been approved yet. Flick the On/Off switch on your primary Powerwall off and back on to approve it, then submit again." diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index 50db98dd7ed8b..f48de8cfb5d1a 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -2195,6 +2195,11 @@ async def test_add_authorized_client_failure_aborts(hass: HomeAssistant) -> None [ pytest.param(InvalidResponse(), "cannot_connect", id="lookup_failure"), pytest.param(_empty_clients(), "key_not_registered", id="key_not_registered"), + pytest.param( + _own_key_clients(AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT), + "key_expired", + id="key_expired", + ), pytest.param(_own_key_clients("gremlin"), "cannot_connect", id="unknown_state"), ], ) @@ -2228,6 +2233,69 @@ async def test_pair_step_second_lookup_errors( assert result["errors"] == {"base": expected_error} +@pytest.mark.usefixtures("mock_rsa_key") +async def test_pair_step_timeout_retry_reopens_window_and_succeeds( + hass: HomeAssistant, +) -> None: + """Submitting the expired form reopens the window and pairing can complete.""" + entry = await _setup_account_no_subentry(hass) + + client = _mock_powerwall_client() + with ( + patch( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_authorized_clients", + new=AsyncMock( + side_effect=[ + _empty_clients(), + _own_key_clients( + AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT + ), + _own_key_clients( + AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT + ), + _own_key_clients(AuthorizedClientState.VERIFIED), + ] + ), + ), + patch( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.add_authorized_client", + new=AsyncMock(), + ) as mock_add, + patch( + "homeassistant.components.teslemetry.config_flow.PowerwallClient", + return_value=client, + ), + patch.object(hass.config_entries, "async_schedule_reload"), + ): + result = await _start_add_flow_select_site(hass, entry) + assert result["step_id"] == "pair" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "pair" + assert result["errors"] == {"base": "key_expired"} + + # Submitting the expired form re-registers to open a fresh window. + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "pair" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "credentials" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_HOST: HOST, CONF_PASSWORD: PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_add.await_count == 2 + + @pytest.mark.usefixtures("mock_rsa_key") @pytest.mark.parametrize( ("patch_target", "error"), From 1ca85dc3bc2c7f3f0c00ddb63bf0a604b84da53e Mon Sep 17 00:00:00 2001 From: Thomas <10558666+mosandlt@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:41:09 +0200 Subject: [PATCH 30/33] bosch_shc: add whole-home open doors/windows summary sensor (#182869) Co-authored-by: Claude Sonnet 5 --- homeassistant/components/bosch_shc/sensor.py | 54 +++++++++++++++ .../components/bosch_shc/strings.json | 3 + tests/components/bosch_shc/test_sensor.py | 67 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 tests/components/bosch_shc/test_sensor.py diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index a5974a0b15278..c83863a1acd9a 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -7,6 +7,7 @@ from boschshcpy import ( SHCLightSwitchBSM, SHCMicromoduleShutterControl, + SHCSession, SHCSmartPlug, SHCSmartPlugCompact, SHCThermostat, @@ -28,10 +29,12 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import BoschConfigEntry +from .const import DOMAIN from .entity import SHCEntity PARALLEL_UPDATES = 0 @@ -297,6 +300,57 @@ async def async_setup_entry( async_add_entities(entities) + async_add_entities( + [SHCOpenWindowsSensor(session=session, parent_id=shc_info.unique_id)], + update_before_add=True, + ) + + +class SHCOpenWindowsSensor(SensorEntity): + """Whole-home summary of open doors/windows (official OpenAPI spec). + + Not tied to one SHC device, so this does not inherit SHCEntity — it's + scoped to the config entry and linked to the hub device directly. The + underlying doors-windows/openwindows endpoint is a plain GET, not + delivered by the long-poll stream, so this needs should_poll=True. + """ + + _attr_has_entity_name = True + _attr_translation_key = "open_windows_doors" + _attr_should_poll = True + + def __init__(self, session: SHCSession, parent_id: str) -> None: + """Initialize the open-windows/doors summary sensor.""" + self._session = session + self._attr_unique_id = f"{parent_id}_open_windows_doors" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, parent_id)}) + self._open_doors: list[dict[str, Any]] = [] + self._open_windows: list[dict[str, Any]] = [] + self._open_others: list[dict[str, Any]] = [] + + @property + @override + def native_value(self) -> int: + """Return the total count of open doors, windows, and other openings.""" + return len(self._open_doors) + len(self._open_windows) + len(self._open_others) + + @property + @override + def extra_state_attributes(self) -> dict[str, list[str]]: + """Return the names of each currently-open door/window/other opening.""" + return { + "open_doors": [d.get("name", "") for d in self._open_doors], + "open_windows": [w.get("name", "") for w in self._open_windows], + "open_others": [o.get("name", "") for o in self._open_others], + } + + def update(self) -> None: + """Poll the whole-home open-doors/open-windows summary.""" + data = self._session.api.get_open_windows() + self._open_doors = data.get("openDoors", []) + self._open_windows = data.get("openWindows", []) + self._open_others = data.get("openOthers", []) + class SHCSensor[_DeviceT: SHCDevice](SHCEntity, SensorEntity): """Representation of a SHC sensor.""" diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index d2359625d613c..1788a5194b74b 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -53,6 +53,9 @@ "humidity_rating": { "name": "Humidity rating" }, + "open_windows_doors": { + "name": "Open doors and windows" + }, "purity": { "name": "Purity" }, diff --git a/tests/components/bosch_shc/test_sensor.py b/tests/components/bosch_shc/test_sensor.py new file mode 100644 index 0000000000000..7827f27db15e4 --- /dev/null +++ b/tests/components/bosch_shc/test_sensor.py @@ -0,0 +1,67 @@ +"""Tests for the Bosch SHC sensor platform.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity + +from .conftest import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def platforms() -> Generator[None]: + """Restrict bosch_shc setup to the sensor platform.""" + with patch("homeassistant.components.bosch_shc.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("mock_session") +async def test_open_windows_doors_sensor( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """The whole-home open-doors/open-windows summary is exposed and polled.""" + mock_session.api.get_open_windows.return_value = { + "openDoors": [{"name": "Front Door"}], + "openWindows": [{"name": "Kitchen Window"}, {"name": "Bedroom Window"}], + "openOthers": [{"name": "Cat Flap"}], + } + await setup_integration(hass, mock_config_entry) + + entity_id = "sensor.mock_title_open_doors_and_windows" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "4" + assert state.attributes["open_doors"] == ["Front Door"] + assert state.attributes["open_windows"] == ["Kitchen Window", "Bedroom Window"] + assert state.attributes["open_others"] == ["Cat Flap"] + + hub_device = device_registry.async_get_device_by_identifier( + ("bosch_shc", "test-mac"), mock_config_entry.entry_id + ) + assert hub_device is not None + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.device_id == hub_device.id + + mock_session.api.get_open_windows.return_value = { + "openDoors": [], + "openWindows": [], + "openOthers": [], + } + await async_update_entity(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "0" From cf3580a8e472c4819997aea9191b48a4e22a1c9a Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 21 Sep 2026 18:58:06 +0100 Subject: [PATCH 31/33] Use the hassio-provided Supervisor user for Unix socket auth (#182302) Co-authored-by: Claude Fable 5.1 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/hassio/__init__.py | 6 +- homeassistant/components/hassio/auth.py | 9 ++- .../components/hassio/config_flow.py | 10 +--- homeassistant/components/hassio/const.py | 3 - homeassistant/components/http/__init__.py | 13 +---- homeassistant/components/http/auth.py | 48 +++++++--------- homeassistant/components/http/const.py | 11 +++- tests/components/hassio/test_auth.py | 4 +- tests/components/hassio/test_config_flow.py | 4 +- tests/components/hassio/test_init.py | 4 +- tests/components/http/test_auth.py | 55 +++++++++++++------ tests/components/http/test_init.py | 10 +++- tests/components/websocket_api/test_auth.py | 5 +- 13 files changed, 97 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 5285a5f2f4119..67abae3549478 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -18,6 +18,7 @@ from homeassistant.auth.models import User from homeassistant.components import frontend from homeassistant.components.homeassistant import async_set_stop_handler +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.onboarding import async_is_onboarded from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import EVENT_CORE_CONFIG_UPDATE, HASSIO_USER_NAME, Platform @@ -57,7 +58,6 @@ ADDONS_COORDINATOR, DATA_COMPONENT, DATA_HASSIO_HOST, - DATA_HASSIO_SUPERVISOR_USER, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, ENTRY_DATA_USER, @@ -340,7 +340,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if legacy_data is not None: legacy_user_id = legacy_data.get("hassio_user") - hass.data[DATA_HASSIO_SUPERVISOR_USER] = await _async_get_or_create_supervisor_user( + hass.data[DATA_SUPERVISOR_USER] = await _async_get_or_create_supervisor_user( hass, entry, legacy_user_id ) @@ -386,7 +386,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_legacy_store = True # Async setup runs first unconditionally and always populates this field - user = hass.data[DATA_HASSIO_SUPERVISOR_USER] + user = hass.data[DATA_SUPERVISOR_USER] if entry.data.get(ENTRY_DATA_USER) != user.id: hass.config_entries.async_update_entry( entry, diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 03065b0b4c852..4f728ec81f41c 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -15,12 +15,15 @@ from homeassistant.auth.providers import homeassistant as auth_ha from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView -from homeassistant.components.http.const import is_supervisor_unix_socket_request +from homeassistant.components.http.const import ( + DATA_SUPERVISOR_USER, + is_supervisor_unix_socket_request, +) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME, DATA_HASSIO_SUPERVISOR_USER +from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME _LOGGER = logging.getLogger(__name__) @@ -41,7 +44,7 @@ def __init__(self, hass: HomeAssistant) -> None: def _check_access(self, request: web.Request) -> None: """Check if this call is from Supervisor.""" - user = self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER) + user = self.hass.data.get(DATA_SUPERVISOR_USER) if user is None: raise HTTPServiceUnavailable diff --git a/homeassistant/components/hassio/config_flow.py b/homeassistant/components/hassio/config_flow.py index 95c3733df7733..54d9163fd7cab 100644 --- a/homeassistant/components/hassio/config_flow.py +++ b/homeassistant/components/hassio/config_flow.py @@ -2,15 +2,11 @@ from typing import Any +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import HASSIO_USER_NAME -from .const import ( - DATA_HASSIO_SUPERVISOR_USER, - DEFAULT_UPDATE_OPTIONS, - DOMAIN, - ENTRY_DATA_USER, -) +from .const import DEFAULT_UPDATE_OPTIONS, DOMAIN, ENTRY_DATA_USER class HassIoConfigFlow(ConfigFlow, domain=DOMAIN): @@ -23,7 +19,7 @@ async def async_step_system( ) -> ConfigFlowResult: """Handle the initial step.""" data: dict[str, Any] = {} - if (user := self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER)) is not None: + if (user := self.hass.data.get(DATA_SUPERVISOR_USER)) is not None: data[ENTRY_DATA_USER] = user.id return self.async_create_entry( diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index d05a16fa2a79e..8fd735023570f 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -20,8 +20,6 @@ SupervisorInfo, ) - from homeassistant.auth.models import User - from .coordinator import ( HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, @@ -153,7 +151,6 @@ ) DATA_KEY_MOUNTS = "mounts" DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host") -DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user") ENTRY_DATA_USER = "user" diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index cbc45ee5ad0a5..e7dcaf626d5b8 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -9,11 +9,7 @@ import probatio from homeassistant.components.network import async_get_source_ip -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - HASSIO_USER_NAME, -) +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir @@ -48,6 +44,7 @@ CONF_TRUSTED_PROXIES, CONF_USE_X_FORWARDED_FOR, CONF_USE_X_FRAME_OPTIONS, + DATA_SUPERVISOR_USER, DEFAULT_CORS, DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, @@ -215,11 +212,7 @@ async def start_server(*_: Any) -> None: async def start_supervisor_unix_socket(*_: Any) -> None: """Start the Unix socket after the Supervisor user is available.""" - if any( - user - for user in await hass.auth.async_get_users() - if user.system_generated and user.name == HASSIO_USER_NAME - ): + if hass.data.get(DATA_SUPERVISOR_USER) is not None: await server.async_start_supervisor_unix_socket() else: _LOGGER.error("Supervisor user not found; not starting Unix socket") diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index e2c6871939fcf..702eb7c8adde2 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -22,7 +22,6 @@ from homeassistant.auth import jwt_wrapper from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.components import websocket_api -from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.http import current_request from homeassistant.helpers.json import json_bytes @@ -30,6 +29,7 @@ from .auth_util import async_user_not_allowed_do_auth from .const import ( + DATA_SUPERVISOR_USER, KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER, @@ -94,7 +94,7 @@ def async_sign_path( return f"{url.path}?{url.query_string}" -async def async_setup_auth( # noqa: C901 +async def async_setup_auth( hass: HomeAssistant, app: Application, ) -> None: @@ -187,40 +187,30 @@ def async_validate_signed_request(request: Request) -> bool: request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True - supervisor_user_id: str | None = None - async def async_authenticate_supervisor_unix_socket(request: Request) -> bool: """Authenticate a request from a Unix socket as the Supervisor user. The Unix Socket is dedicated and only available to Supervisor. To avoid the extra overhead and round trips for the authentication and refresh tokens, we directly authenticate requests from the socket as - the Supervisor user. + the Supervisor user provided by the hassio integration. The user is + looked up in the auth store so a user removed at runtime is not + authenticated any longer. """ - nonlocal supervisor_user_id - - # Fast path: use cached user ID - if supervisor_user_id is not None: - if user := await hass.auth.async_get_user(supervisor_user_id): - request[KEY_HASS_USER] = user - return True - supervisor_user_id = None - - # Slow path: find the Supervisor user by name - for user in await hass.auth.async_get_users(): - if user.system_generated and user.name == HASSIO_USER_NAME: - supervisor_user_id = user.id - # Not setting KEY_HASS_REFRESH_TOKEN_ID since Supervisor user - # doesn't use refresh tokens. - request[KEY_HASS_USER] = user - return True - - # The Unix socket should not be serving before the hassio integration - # has created the Supervisor user. If we get here, something is wrong. - _LOGGER.error( - "Supervisor user not found; cannot authenticate Unix socket request" - ) - raise HTTPInternalServerError + if (supervisor_user := hass.data.get(DATA_SUPERVISOR_USER)) is None or ( + user := await hass.auth.async_get_user(supervisor_user.id) + ) is None: + # The Unix socket should not be serving before the hassio integration + # has provided the Supervisor user. If we get here, something is wrong. + _LOGGER.error( + "Supervisor user not found; cannot authenticate Unix socket request" + ) + raise HTTPInternalServerError + + # Not setting KEY_HASS_REFRESH_TOKEN_ID since Supervisor user + # doesn't use refresh tokens. + request[KEY_HASS_USER] = user + return True @middleware async def auth_middleware( diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index 24ada111c765c..a53b45aeb5c09 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,10 +1,14 @@ """HTTP specific constants.""" -from typing import Final +from typing import TYPE_CHECKING, Final from aiohttp.web import Request from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401 +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.auth.models import User DOMAIN: Final = "http" @@ -12,6 +16,11 @@ KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" KEY_SUPERVISOR_UNIX_SOCKET: Final = "ha_supervisor_unix_socket" +# System user used to authenticate requests over the Supervisor Unix socket. +# Set by the hassio integration during its setup; the Unix socket is only +# started once it is available. +DATA_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user") + CONF_SERVER_HOST: Final = "server_host" CONF_SERVER_PORT: Final = "server_port" CONF_BASE_URL: Final = "base_url" diff --git a/tests/components/hassio/test_auth.py b/tests/components/hassio/test_auth.py index 29f08d3ef66b9..86da1f215da27 100644 --- a/tests/components/hassio/test_auth.py +++ b/tests/components/hassio/test_auth.py @@ -10,7 +10,7 @@ from homeassistant.auth.providers.homeassistant import InvalidAuth from homeassistant.components.hassio.auth import HassIOBaseAuth -from homeassistant.components.hassio.const import DATA_HASSIO_SUPERVISOR_USER +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.core import HomeAssistant @@ -187,7 +187,7 @@ async def test_check_access_unix_socket_or_missing_peername( expectation: AbstractContextManager, ) -> None: """Test _check_access handles Unix socket requests and missing peername.""" - user = hass.data.get(DATA_HASSIO_SUPERVISOR_USER) + user = hass.data.get(DATA_SUPERVISOR_USER) assert user is not None auth_view = HassIOBaseAuth(hass) diff --git a/tests/components/hassio/test_config_flow.py b/tests/components/hassio/test_config_flow.py index 7905ab7c27cd6..f7336773cf22f 100644 --- a/tests/components/hassio/test_config_flow.py +++ b/tests/components/hassio/test_config_flow.py @@ -5,10 +5,10 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( - DATA_HASSIO_SUPERVISOR_USER, DEFAULT_UPDATE_OPTIONS, ENTRY_DATA_USER, ) +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -53,7 +53,7 @@ async def test_config_flow_uses_bootstrap_user(hass: HomeAssistant) -> None: user = await hass.auth.async_create_system_user( "Supervisor", group_ids=[GROUP_ID_ADMIN] ) - hass.data[DATA_HASSIO_SUPERVISOR_USER] = user + hass.data[DATA_SUPERVISOR_USER] = user with ( patch("homeassistant.components.hassio.async_setup", return_value=True), diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 7c57ef891cd85..c86a06e39a314 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -54,7 +54,6 @@ hostname_from_addon_slug, ) from homeassistant.components.hassio.const import ( - DATA_HASSIO_SUPERVISOR_USER, DATA_KEY_SUPERVISOR_ISSUES, DEFAULT_UPDATE_OPTIONS, ENTRY_DATA_USER, @@ -68,6 +67,7 @@ DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import HASSIO_USER_NAME @@ -368,7 +368,7 @@ async def test_setup_api_push_api_data_default( supervisor_client.homeassistant.set_options.assert_called_once_with( HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) - hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] + hassio_user = hass.data[DATA_SUPERVISOR_USER] assert hassio_user.system_generated assert len(hassio_user.groups) == 1 assert hassio_user.groups[0].id == GROUP_ID_ADMIN diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 7e29303b460eb..ff93c94cab6b8 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -28,6 +28,7 @@ async_sign_path, async_user_not_allowed_do_auth, ) +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.http.forwarded import async_setup_forwarded from homeassistant.components.http.request_context import ( current_request, @@ -766,7 +767,7 @@ async def test_unix_socket_auth_with_supervisor_user( supervisor_user = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] ) - await hass.auth.async_create_refresh_token(supervisor_user) + hass.data[DATA_SUPERVISOR_USER] = supervisor_user await async_setup_auth(hass, app) client = await aiohttp_client(app) @@ -798,16 +799,17 @@ async def test_unix_socket_auth_without_supervisor_user( assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR -async def test_unix_socket_auth_caches_user_id( +async def test_unix_socket_auth_removed_user( hass: HomeAssistant, app: web.Application, aiohttp_client: ClientSessionGenerator, ) -> None: - """Test that Unix socket auth caches the Supervisor user ID.""" + """Test that Unix socket requests fail once the Supervisor user was removed.""" supervisor_user = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] ) - await hass.auth.async_create_refresh_token(supervisor_user) + hass.data[DATA_SUPERVISOR_USER] = supervisor_user + await hass.auth.async_remove_user(supervisor_user) await async_setup_auth(hass, app) client = await aiohttp_client(app) @@ -816,20 +818,37 @@ async def test_unix_socket_auth_caches_user_id( "homeassistant.components.http.auth.is_supervisor_unix_socket_request", return_value=True, ): - # First request triggers user lookup req = await client.get("/") - assert req.status == HTTPStatus.OK - - # Second request should use cached user ID - with ( - patch( - "homeassistant.components.http.auth.is_supervisor_unix_socket_request", - return_value=True, - ), - patch.object( - hass.auth, "async_get_users", wraps=hass.auth.async_get_users - ) as mock_get_users, + assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_unix_socket_auth_uses_provided_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, +) -> None: + """Test that Unix socket auth uses the user provided by hassio, not a name match. + + A stale duplicate system user named Supervisor can be left behind in the + auth store; requests must be authenticated as the user hassio actually uses. + """ + stale_user = await hass.auth.async_create_system_user( + HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] + ) + supervisor_user = await hass.auth.async_create_system_user( + HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] + ) + hass.data[DATA_SUPERVISOR_USER] = supervisor_user + + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + with patch( + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, ): req = await client.get("/") - assert req.status == HTTPStatus.OK - mock_get_users.assert_not_called() + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == supervisor_user.id + assert data["user_id"] != stale_user.id diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 29e6e7bc0f1a5..5be711f3130b1 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -31,7 +31,11 @@ async_get_and_load_store, default_server_port, ) -from homeassistant.components.http.const import ENV_SETUP_PORT, ENV_SUPERVISOR +from homeassistant.components.http.const import ( + DATA_SUPERVISOR_USER, + ENV_SETUP_PORT, + ENV_SUPERVISOR, +) from homeassistant.const import EVENT_HOMEASSISTANT_STOP, HASSIO_USER_NAME, SERVER_PORT from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -958,7 +962,7 @@ async def test_unix_socket_started_with_supervisor( tmp_path: Path, ) -> None: """Test unix socket is started when running under Supervisor.""" - await hass.auth.async_create_system_user( + hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=["system-admin"] ) socket_path = tmp_path / "core.sock" @@ -1027,7 +1031,7 @@ async def test_supervisor_http_config_view( tmp_path: Path, ) -> None: """Test the HTTP config view is registered and served over the socket only.""" - await hass.auth.async_create_system_user( + hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=["system-admin"] ) socket_path = tmp_path / "core.sock" diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 1e9cd3b0fb800..a97e7d8c8535f 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -7,6 +7,7 @@ import pytest from homeassistant.auth.providers.homeassistant import HassAuthProvider +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.websocket_api import DOMAIN from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, @@ -436,8 +437,8 @@ async def test_unix_socket_auth_bypass( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: """Test that Unix socket connections skip websocket auth phase.""" - # Create the Supervisor system user - await hass.auth.async_create_system_user( + # Create the Supervisor system user, as the hassio integration would + hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=["system-admin"] ) From 1f949d9234a7b6d6c7acabf2e598d410feea20b3 Mon Sep 17 00:00:00 2001 From: Thomas <10558666+mosandlt@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:08:19 +0200 Subject: [PATCH 32/33] bosch_shc: add alarm bypass switch entities (#182246) Co-authored-by: Claude Sonnet 5 --- homeassistant/components/bosch_shc/icons.json | 6 + .../components/bosch_shc/strings.json | 6 + homeassistant/components/bosch_shc/switch.py | 63 +++++++++++ tests/components/bosch_shc/conftest.py | 24 ++++ tests/components/bosch_shc/test_switch.py | 104 +++++++++++++++++- 5 files changed, 202 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bosch_shc/icons.json b/homeassistant/components/bosch_shc/icons.json index 2ef170b1288a8..da813238f9776 100644 --- a/homeassistant/components/bosch_shc/icons.json +++ b/homeassistant/components/bosch_shc/icons.json @@ -12,6 +12,12 @@ } }, "switch": { + "bypass": { + "default": "mdi:shield-off-outline" + }, + "bypass_infinite": { + "default": "mdi:timer-off-outline" + }, "child_lock": { "default": "mdi:lock" }, diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 1788a5194b74b..63b169e0c343c 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -70,6 +70,12 @@ } }, "switch": { + "bypass": { + "name": "Break function" + }, + "bypass_infinite": { + "name": "Break function never expires" + }, "child_lock": { "name": "Child lock" }, diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index f1949dfd14807..34cc500cd6050 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -5,9 +5,11 @@ from typing import TYPE_CHECKING, Any, override from boschshcpy import ( + BypassService, CameraLightService, PowerSwitchService, PrivacyModeService, + SHCShutterContact2, SHCSmartPlug, ThermostatService, ) @@ -98,6 +100,14 @@ class SHCSwitchEntityDescription(SwitchEntityDescription): on_value=True, should_poll=False, ), + "bypass": SHCSwitchEntityDescription( + key="bypass", + translation_key="bypass", + device_class=SwitchDeviceClass.SWITCH, + on_key="bypass", + on_value=BypassService.State.BYPASS_ACTIVE, + should_poll=False, + ), } @@ -226,6 +236,27 @@ async def async_setup_entry( ) ) + entities.extend( + SHCSwitch( + hass=hass, + device=switch, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + description=SWITCH_TYPES["bypass"], + ) + for switch in session.device_helper.shutter_contacts2 + ) + + entities.extend( + SHCBypassInfiniteSwitch( + hass=hass, + device=switch, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + ) + for switch in session.device_helper.shutter_contacts2 + ) + async_add_entities(entities) @@ -308,3 +339,35 @@ def turn_on(self, **kwargs: Any) -> None: def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self._device.routing = False + + +class SHCBypassInfiniteSwitch(SHCEntity, SwitchEntity): + """Representation of a SHC alarm-bypass "never expires" switch.""" + + _attr_translation_key = "bypass_infinite" + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG + _device: SHCShutterContact2 + + def __init__( + self, hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str + ) -> None: + """Initialize an SHC bypass-never-expires switch.""" + super().__init__(hass, device, parent_id, entry_id) + self._attr_unique_id = f"{device.serial}_bypass_infinite" + + @property + @override + def is_on(self) -> bool: + """Return the state of the switch.""" + return self._device.bypass_infinite + + @override + def turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + self._device.set_bypass_configuration(infinite=True) + + @override + def turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + self._device.set_bypass_configuration(infinite=False) diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index 7b282a71a86b3..4bfc80eb3e845 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -7,6 +7,7 @@ from boschshcpy import ( BatteryLevelService, + BypassService, PowerSwitchService, RoutingService, SHCBatteryDevice, @@ -14,6 +15,7 @@ SHCMicromoduleBlinds, SHCMicromoduleRelay, SHCPresenceSimulationSystem, + SHCShutterContact2, SHCShutterControl, SHCSmartPlug, SHCThermostat, @@ -301,3 +303,25 @@ def presence_simulation_system_device( device.status = "AVAILABLE" device.enabled = enabled return device + + +def shutter_contact2_device( + device_id: str = "hdm:ZigBee:shuttercontact1", + name: str = "Shutter contact", + bypass: BypassService.State = BypassService.State.BYPASS_INACTIVE, + bypass_infinite: bool = False, +) -> SHCShutterContact2: + """Build a minimal device double for the shutter_contacts2 bucket.""" + device = create_autospec(SHCShutterContact2, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = "SWD2" + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.bypass = bypass + device.bypass_infinite = bypass_infinite + return device diff --git a/tests/components/bosch_shc/test_switch.py b/tests/components/bosch_shc/test_switch.py index 2a181a409acda..53ced8857858c 100644 --- a/tests/components/bosch_shc/test_switch.py +++ b/tests/components/bosch_shc/test_switch.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch -from boschshcpy import ThermostatService +from boschshcpy import BypassService, ThermostatService import pytest from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -21,6 +21,7 @@ micromodule_relay_device, presence_simulation_system_device, setup_integration, + shutter_contact2_device, smart_plug_device, thermostat_device, ) @@ -215,3 +216,104 @@ async def test_smart_plug_routing_switch_name( state = hass.states.get("switch.smart_plug_range_extension") assert state is not None assert state.attributes["friendly_name"] == "Smart Plug Range extension" + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "shutter_contacts2": [ + shutter_contact2_device(bypass=BypassService.State.BYPASS_INACTIVE) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_shutter_contact2_bypass( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A Door/Window Contact II's alarm bypass is exposed and controllable.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.shutter_contacts2[0] + + state = hass.states.get("switch.shutter_contact_break_function") + assert state is not None + assert state.state == "off" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function"}, + blocking=True, + ) + assert device.bypass is True + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function"}, + blocking=True, + ) + assert device.bypass is False + + +@pytest.mark.parametrize( + "device_buckets", + [{"shutter_contacts2": [shutter_contact2_device(bypass_infinite=False)]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_shutter_contact2_bypass_infinite( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A Door/Window Contact II's bypass-never-expires option is exposed and controllable.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.shutter_contacts2[0] + + state = hass.states.get("switch.shutter_contact_break_function_never_expires") + assert state is not None + assert state.state == "off" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function_never_expires"}, + blocking=True, + ) + device.set_bypass_configuration.assert_called_once_with(infinite=True) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function_never_expires"}, + blocking=True, + ) + device.set_bypass_configuration.assert_called_with(infinite=False) + + +@pytest.mark.parametrize( + "device_buckets", + [{"shutter_contacts2": [shutter_contact2_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_shutter_contact2_bypass_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A Door/Window Contact II's two bypass switches use distinct unique_ids.""" + await setup_integration(hass, mock_config_entry) + + bypass_entry = entity_registry.async_get("switch.shutter_contact_break_function") + bypass_infinite_entry = entity_registry.async_get( + "switch.shutter_contact_break_function_never_expires" + ) + assert bypass_entry is not None + assert bypass_infinite_entry is not None + assert bypass_entry.unique_id != bypass_infinite_entry.unique_id From 90233bf798181ca5dad08e638ba19feb2f3f38e7 Mon Sep 17 00:00:00 2001 From: Thomas <10558666+mosandlt@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:08:29 +0200 Subject: [PATCH 33/33] bosch_shc: add thermostat valve position entity (#182249) Co-authored-by: Claude Sonnet 5 --- .../components/bosch_shc/__init__.py | 1 + homeassistant/components/bosch_shc/sensor.py | 52 +++++++++++- .../components/bosch_shc/strings.json | 27 ++++++ homeassistant/components/bosch_shc/valve.py | 57 +++++++++++++ tests/components/bosch_shc/conftest.py | 9 +- tests/components/bosch_shc/test_sensor.py | 84 ++++++++++++++++++- tests/components/bosch_shc/test_valve.py | 38 +++++++++ 7 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/bosch_shc/valve.py create mode 100644 tests/components/bosch_shc/test_valve.py diff --git a/homeassistant/components/bosch_shc/__init__.py b/homeassistant/components/bosch_shc/__init__.py index 621942675f719..2c6c23c498962 100644 --- a/homeassistant/components/bosch_shc/__init__.py +++ b/homeassistant/components/bosch_shc/__init__.py @@ -20,6 +20,7 @@ Platform.COVER, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index c83863a1acd9a..6bc27ff9c05a0 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -23,6 +23,7 @@ SensorStateClass, ) from homeassistant.const import ( + EntityCategory, UnitOfEnergy, UnitOfPower, UnitOfRatio, @@ -56,6 +57,7 @@ class SHCSensorEntityDescription[_DeviceT: SHCDevice](SensorEntityDescription): TEMPERATURE_SENSOR = "temperature" HUMIDITY_SENSOR = "humidity" VALVE_TAPPET_SENSOR = "valvetappet" +VALVE_TAPPET_STATE_SENSOR = "valve_tappet_state" PURITY_SENSOR = "purity" AIR_QUALITY_SENSOR = "airquality" TEMPERATURE_RATING_SENSOR = "temperature_rating" @@ -65,6 +67,15 @@ class SHCSensorEntityDescription[_DeviceT: SHCDevice](SensorEntityDescription): ENERGY_SENSOR = "energy" COMMUNICATION_QUALITY_SENSOR = "communication_quality" + +def _valve_tappet_state_value(device: SHCThermostat) -> str | None: + """Return the valve motor status enum string, or None on unknown value.""" + try: + return str(device.valvestate.name.lower()) + except ValueError, AttributeError: + return None + + _THERMOSTAT_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[SHCThermostat] = ( SHCSensorEntityDescription( key=TEMPERATURE_SENSOR, @@ -80,10 +91,44 @@ class SHCSensorEntityDescription[_DeviceT: SHCDevice](SensorEntityDescription): translation_key=VALVE_TAPPET_SENSOR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + # Superseded by the "valve" platform's position entity; kept + # available (opt-in) for anyone already relying on the raw percentage. + entity_registry_enabled_default=False, + suggested_display_precision=0, value_fn=lambda device: device.position, - attributes_fn=lambda device: { - "valve_tappet_state": device.valvestate.name, - }, + # Kept for anyone already reading this attribute in a template or + # automation, even though the same value is now also a first-class + # sensor below (_VALVE_TAPPET_STATE_DESCRIPTION). + attributes_fn=lambda device: {"valve_tappet_state": device.valvestate.name}, + ) +) +_VALVE_TAPPET_STATE_DESCRIPTION: SHCSensorEntityDescription[SHCThermostat] = ( + SHCSensorEntityDescription( + key=VALVE_TAPPET_STATE_SENSOR, + translation_key=VALVE_TAPPET_STATE_SENSOR, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + "valve_adaption_successful", + "valve_adaption_in_progress", + "valve_adaption_requested", + "range_too_big", + "range_too_small", + "run_to_start_position", + "start_position_requested", + "in_start_position", + "not_available", + "no_valve_body_error", + "no_motor_error", + "valve_too_tight", + "fix_motor_logic_requested", + "fix_motor_logic_in_progress", + "fix_motor_logic_successful", + "error", + "unknown", + ], + value_fn=_valve_tappet_state_value, ) ) _WALLTHERMOSTAT_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[ @@ -226,6 +271,7 @@ async def async_setup_entry( for description in ( _THERMOSTAT_TEMPERATURE_DESCRIPTION, _VALVE_TAPPET_DESCRIPTION, + _VALVE_TAPPET_STATE_DESCRIPTION, ) ] diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 63b169e0c343c..7dac8015b5481 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -65,6 +65,28 @@ "temperature_rating": { "name": "Temperature rating" }, + "valve_tappet_state": { + "name": "Valve motor status", + "state": { + "error": "Error", + "fix_motor_logic_in_progress": "Motor logic fix in progress", + "fix_motor_logic_requested": "Motor logic fix requested", + "fix_motor_logic_successful": "Motor logic fix successful", + "in_start_position": "In start position", + "no_motor_error": "Error: no motor detected", + "no_valve_body_error": "Error: no valve body detected", + "not_available": "Not available", + "range_too_big": "Range too big", + "range_too_small": "Range too small", + "run_to_start_position": "Running to start position", + "start_position_requested": "Start position requested", + "unknown": "Unknown", + "valve_adaption_in_progress": "Adaptation in progress", + "valve_adaption_requested": "Adaptation requested", + "valve_adaption_successful": "Adaptation successful", + "valve_too_tight": "Valve too tight" + } + }, "valvetappet": { "name": "Valvetappet" } @@ -82,6 +104,11 @@ "routing": { "name": "Range extension" } + }, + "valve": { + "valve": { + "name": "Valve" + } } } } diff --git a/homeassistant/components/bosch_shc/valve.py b/homeassistant/components/bosch_shc/valve.py new file mode 100644 index 0000000000000..d969f8c0e147f --- /dev/null +++ b/homeassistant/components/bosch_shc/valve.py @@ -0,0 +1,57 @@ +"""Platform for valve integration.""" + +from typing import TYPE_CHECKING, override + +from boschshcpy import SHCThermostat + +from homeassistant.components.valve import ValveDeviceClass, ValveEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschConfigEntry +from .entity import SHCEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the SHC valve platform.""" + session = config_entry.runtime_data + + shc_info = session.information + if TYPE_CHECKING: + assert shc_info is not None and shc_info.unique_id is not None + + async_add_entities( + SHCValve( + hass=hass, + device=device, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + ) + for device in session.device_helper.thermostats + ) + + +class SHCValve(SHCEntity, ValveEntity): + """Representation of a SHC thermostat valve position.""" + + _attr_device_class = ValveDeviceClass.WATER + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_reports_position = True + _attr_translation_key = "valve" + _device: SHCThermostat + + @property + @override + def current_valve_position(self) -> int | None: + """Return current position of valve. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._device.position diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index 4bfc80eb3e845..c12836b1ebbd8 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -22,7 +22,10 @@ ShutterControlService, ThermostatService, ) -from boschshcpy.services_impl import PresenceSimulationConfigurationService +from boschshcpy.services_impl import ( + PresenceSimulationConfigurationService, + ValveTappetService, +) import pytest from homeassistant.components.bosch_shc.const import ( @@ -220,6 +223,8 @@ def thermostat_device( device_id: str = "hdm:ZigBee:thermostat1", name: str = "Thermostat", child_lock: ThermostatService.State = ThermostatService.State.OFF, + position: int = 50, + valvestate: ValveTappetService.State = ValveTappetService.State.VALVE_ADAPTION_SUCCESSFUL, ) -> SHCThermostat: """Build a minimal device double for the thermostats/roomthermostats/wallthermostats buckets.""" device = create_autospec(SHCThermostat, instance=True, spec_set=True) @@ -233,6 +238,8 @@ def thermostat_device( device.deleted = False device.status = "AVAILABLE" device.child_lock = child_lock + device.position = position + device.valvestate = valvestate return device diff --git a/tests/components/bosch_shc/test_sensor.py b/tests/components/bosch_shc/test_sensor.py index 7827f27db15e4..4b15942c45434 100644 --- a/tests/components/bosch_shc/test_sensor.py +++ b/tests/components/bosch_shc/test_sensor.py @@ -1,18 +1,21 @@ """Tests for the Bosch SHC sensor platform.""" from collections.abc import Generator +from datetime import timedelta from unittest.mock import MagicMock, patch +from boschshcpy.services_impl import ValveTappetService import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.util import dt as dt_util -from .conftest import setup_integration +from .conftest import setup_integration, thermostat_device -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture(autouse=True) @@ -65,3 +68,80 @@ async def test_open_windows_doors_sensor( state = hass.states.get(entity_id) assert state is not None assert state.state == "0" + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "thermostats": [ + thermostat_device(valvestate=ValveTappetService.State.VALVE_TOO_TIGHT) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valve_tappet_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A thermostat's valve motor status is exposed as an ENUM sensor.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.thermostat_valve_motor_status") + assert state is not None + assert state.state == "valve_too_tight" + + +@pytest.mark.parametrize( + "device_buckets", + [{"thermostats": [thermostat_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valvetappet_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """The raw valve tappet percentage sensor stays opt-in, superseded by the valve entity.""" + await setup_integration(hass, mock_config_entry) + + entry = entity_registry.async_get("sensor.thermostat_valvetappet") + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "thermostats": [ + thermostat_device(valvestate=ValveTappetService.State.VALVE_TOO_TIGHT) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valvetappet_state_attribute_preserved( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Enabling the legacy percentage sensor still surfaces its valve_tappet_state attribute.""" + await setup_integration(hass, mock_config_entry) + + with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1): + entity_registry.async_update_entity( + "sensor.thermostat_valvetappet", disabled_by=None + ) + await hass.async_block_till_done(wait_background_tasks=True) + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.thermostat_valvetappet") + assert state is not None + assert state.attributes["valve_tappet_state"] == "VALVE_TOO_TIGHT" diff --git a/tests/components/bosch_shc/test_valve.py b/tests/components/bosch_shc/test_valve.py new file mode 100644 index 0000000000000..0e56c40b3208a --- /dev/null +++ b/tests/components/bosch_shc/test_valve.py @@ -0,0 +1,38 @@ +"""Tests for the Bosch SHC valve platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import setup_integration, thermostat_device + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def platforms() -> Generator[None]: + """Restrict bosch_shc setup to the valve platform.""" + with patch("homeassistant.components.bosch_shc.PLATFORMS", [Platform.VALVE]): + yield + + +@pytest.mark.parametrize( + "device_buckets", + [{"thermostats": [thermostat_device(position=42)]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valve_position( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A thermostat's valve tappet position is exposed as a valve entity.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("valve.thermostat_valve") + assert state is not None + assert state.attributes["current_position"] == 42