From 37d2c946e8c4b445fc17ae3446a7fb9f7f635935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Fri, 27 Feb 2026 08:16:58 +0100 Subject: [PATCH 1/5] Add diagnostics platform to AWS S3 (#164118) Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com> Co-authored-by: Erwin Douna --- .../components/aws_s3/diagnostics.py | 55 ++++++++++++++ .../components/aws_s3/quality_scale.yaml | 2 +- .../aws_s3/snapshots/test_diagnostics.ambr | 73 +++++++++++++++++++ tests/components/aws_s3/test_diagnostics.py | 29 ++++++++ 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/aws_s3/diagnostics.py create mode 100644 tests/components/aws_s3/snapshots/test_diagnostics.ambr create mode 100644 tests/components/aws_s3/test_diagnostics.py diff --git a/homeassistant/components/aws_s3/diagnostics.py b/homeassistant/components/aws_s3/diagnostics.py new file mode 100644 index 00000000000000..85acf83816a9ed --- /dev/null +++ b/homeassistant/components/aws_s3/diagnostics.py @@ -0,0 +1,55 @@ +"""Diagnostics support for AWS S3.""" + +from __future__ import annotations + +import dataclasses +from typing import Any + +from homeassistant.components.backup import ( + DATA_MANAGER as BACKUP_DATA_MANAGER, + BackupManager, +) +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_ACCESS_KEY_ID, + CONF_BUCKET, + CONF_PREFIX, + CONF_SECRET_ACCESS_KEY, + DOMAIN, +) +from .coordinator import S3ConfigEntry +from .helpers import async_list_backups_from_s3 + +TO_REDACT = (CONF_ACCESS_KEY_ID, CONF_SECRET_ACCESS_KEY) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: S3ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + backup_manager: BackupManager = hass.data[BACKUP_DATA_MANAGER] + backups = await async_list_backups_from_s3( + coordinator.client, + bucket=entry.data[CONF_BUCKET], + prefix=entry.data.get(CONF_PREFIX, ""), + ) + + data = { + "coordinator_data": dataclasses.asdict(coordinator.data), + "config": { + **entry.data, + **entry.options, + }, + "backup_agents": [ + {"name": agent.name} + for agent in backup_manager.backup_agents.values() + if agent.domain == DOMAIN + ], + "backup": [backup.as_dict() for backup in backups], + } + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/aws_s3/quality_scale.yaml b/homeassistant/components/aws_s3/quality_scale.yaml index 0410a22c698911..49c3ea4e35c415 100644 --- a/homeassistant/components/aws_s3/quality_scale.yaml +++ b/homeassistant/components/aws_s3/quality_scale.yaml @@ -45,7 +45,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: S3 is a cloud service that is not discovered on the network. diff --git a/tests/components/aws_s3/snapshots/test_diagnostics.ambr b/tests/components/aws_s3/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..89bd2c04f59494 --- /dev/null +++ b/tests/components/aws_s3/snapshots/test_diagnostics.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_entry_diagnostics[large] + dict({ + 'backup': list([ + dict({ + 'addons': list([ + ]), + 'backup_id': '23e64aec', + 'database_included': True, + 'date': '2024-11-22T11:48:48.727189+01:00', + 'extra_metadata': dict({ + }), + 'folders': list([ + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0.dev0', + 'name': 'Core 2024.12.0.dev0', + 'protected': False, + 'size': 20971520, + }), + ]), + 'backup_agents': list([ + dict({ + 'name': 'test', + }), + ]), + 'config': dict({ + 'access_key_id': '**REDACTED**', + 'bucket': 'test', + 'endpoint_url': 'https://s3.eu-south-1.amazonaws.com', + 'secret_access_key': '**REDACTED**', + }), + 'coordinator_data': dict({ + 'all_backups_size': 20971520, + }), + }) +# --- +# name: test_entry_diagnostics[small] + dict({ + 'backup': list([ + dict({ + 'addons': list([ + ]), + 'backup_id': '23e64aec', + 'database_included': True, + 'date': '2024-11-22T11:48:48.727189+01:00', + 'extra_metadata': dict({ + }), + 'folders': list([ + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0.dev0', + 'name': 'Core 2024.12.0.dev0', + 'protected': False, + 'size': 1048576, + }), + ]), + 'backup_agents': list([ + dict({ + 'name': 'test', + }), + ]), + 'config': dict({ + 'access_key_id': '**REDACTED**', + 'bucket': 'test', + 'endpoint_url': 'https://s3.eu-south-1.amazonaws.com', + 'secret_access_key': '**REDACTED**', + }), + 'coordinator_data': dict({ + 'all_backups_size': 1048576, + }), + }) +# --- diff --git a/tests/components/aws_s3/test_diagnostics.py b/tests/components/aws_s3/test_diagnostics.py new file mode 100644 index 00000000000000..d10e511bcc97d5 --- /dev/null +++ b/tests/components/aws_s3/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for AWS S3 diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + mock_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From e63e54820c6f03a09e3d5927959ef7bdefc4c047 Mon Sep 17 00:00:00 2001 From: hanwg Date: Fri, 27 Feb 2026 15:19:10 +0800 Subject: [PATCH 2/5] Remove redundant exception messages from Telegram bot (#164289) --- homeassistant/components/telegram_bot/bot.py | 7 --- .../telegram_bot/test_telegram_bot.py | 47 ++++++++++++------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index deca8e25a65935..6781b6fff069bb 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -1080,7 +1080,6 @@ async def load_data( req = await client.get(url) except (httpx.HTTPError, httpx.InvalidURL) as err: raise HomeAssistantError( - f"Failed to load URL: {err!s}", translation_domain=DOMAIN, translation_key="failed_to_load_url", translation_placeholders={"error": str(err)}, @@ -1107,7 +1106,6 @@ async def load_data( 1 ) # Add a sleep to allow other async operations to proceed raise HomeAssistantError( - f"Failed to load URL: {req.status_code}", translation_domain=DOMAIN, translation_key="failed_to_load_url", translation_placeholders={"error": str(req.status_code)}, @@ -1117,13 +1115,11 @@ async def load_data( return await hass.async_add_executor_job(_read_file_as_bytesio, filepath) raise ServiceValidationError( - "File path has not been configured in allowlist_external_dirs.", translation_domain=DOMAIN, translation_key="allowlist_external_dirs_error", ) else: raise ServiceValidationError( - "URL or File is required.", translation_domain=DOMAIN, translation_key="missing_input", translation_placeholders={"field": "URL or File"}, @@ -1138,7 +1134,6 @@ def _validate_credentials_input( and not username ): raise ServiceValidationError( - "Username is required.", translation_domain=DOMAIN, translation_key="missing_input", translation_placeholders={"field": "Username"}, @@ -1154,7 +1149,6 @@ def _validate_credentials_input( and not password ): raise ServiceValidationError( - "Password is required.", translation_domain=DOMAIN, translation_key="missing_input", translation_placeholders={"field": "Password"}, @@ -1170,7 +1164,6 @@ def _read_file_as_bytesio(file_path: str) -> io.BytesIO: return data except OSError as err: raise HomeAssistantError( - f"Failed to load file: {err!s}", translation_domain=DOMAIN, translation_key="failed_to_load_file", translation_placeholders={"error": str(err)}, diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index ecdc7241d797f1..610a4a5ae36162 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -1443,10 +1443,9 @@ async def test_send_video( ) await hass.async_block_till_done() - assert ( - err.value.args[0] - == "File path has not been configured in allowlist_external_dirs." - ) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "allowlist_external_dirs_error" # test: missing username input @@ -1463,7 +1462,10 @@ async def test_send_video( ) await hass.async_block_till_done() - assert err.value.args[0] == "Username is required." + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "missing_input" + assert err.value.translation_placeholders == {"field": "Username"} # test: missing password input @@ -1479,7 +1481,10 @@ async def test_send_video( ) await hass.async_block_till_done() - assert err.value.args[0] == "Password is required." + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "missing_input" + assert err.value.translation_placeholders == {"field": "Password"} # test: 404 error @@ -1502,8 +1507,11 @@ async def test_send_video( ) await hass.async_block_till_done() + assert mock_get.call_count > 0 - assert err.value.args[0] == "Failed to load URL: 404" + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "failed_to_load_url" + assert err.value.translation_placeholders == {"error": "404"} # test: invalid url @@ -1521,11 +1529,13 @@ async def test_send_video( ) await hass.async_block_till_done() + assert mock_get.call_count > 0 - assert ( - err.value.args[0] - == "Failed to load URL: Request URL is missing an 'http://' or 'https://' protocol." - ) + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "failed_to_load_url" + assert err.value.translation_placeholders == { + "error": "Request URL is missing an 'http://' or 'https://' protocol." + } # test: no url/file input @@ -1538,7 +1548,10 @@ async def test_send_video( ) await hass.async_block_till_done() - assert err.value.args[0] == "URL or File is required." + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "missing_input" + assert err.value.translation_placeholders == {"field": "URL or File"} # test: load file error (e.g. not found, permissions error) @@ -1555,10 +1568,12 @@ async def test_send_video( ) await hass.async_block_till_done() - assert ( - err.value.args[0] - == "Failed to load file: [Errno 2] No such file or directory: '/tmp/not-exists'" - ) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "failed_to_load_file" + assert err.value.translation_placeholders == { + "error": "[Errno 2] No such file or directory: '/tmp/not-exists'" + } # test: success with file write_utf8_file("/tmp/mock", "mock file contents") # noqa: S108 From 75ed7b2fa2c55dce08c6e6457643f80582e09bfe Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 27 Feb 2026 08:46:08 +0100 Subject: [PATCH 3/5] Improve descriptions of `schlage` actions (#164299) --- homeassistant/components/schlage/strings.json | 12 ++++++------ tests/components/schlage/test_lock.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 3710fd7e3f781d..48f0232eb751af 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -74,31 +74,31 @@ }, "services": { "add_code": { - "description": "Add a PIN code to a lock.", + "description": "Adds a PIN code to a lock.", "fields": { "code": { - "description": "The PIN code to add. Must be unique to lock and be between 4 and 8 digits long.", + "description": "The PIN code to add. Must be unique to the lock and be between 4 and 8 digits long.", "name": "PIN code" }, "name": { - "description": "Name for PIN code. Must be case insensitively unique to lock.", + "description": "Name for PIN code. Must be case insensitively unique to the lock.", "name": "PIN name" } }, "name": "Add PIN code" }, "delete_code": { - "description": "Delete a PIN code from a lock.", + "description": "Deletes a PIN code from a lock.", "fields": { "name": { "description": "Name of PIN code to delete.", - "name": "PIN name" + "name": "[%key:component::schlage::services::add_code::fields::name::name%]" } }, "name": "Delete PIN code" }, "get_codes": { - "description": "Retrieve all PIN codes from the lock.", + "description": "Retrieves all PIN codes from a lock.", "name": "Get PIN codes" } } diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 1d801154f0a16f..378b49bfb6b26c 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -175,7 +175,7 @@ async def test_add_code_service_duplicate_name( with pytest.raises( ServiceValidationError, - match='A PIN code with the name "test_user" already exists on the lock.', + match='A PIN code with the name "test_user" already exists on the lock', ) as exc_info: await hass.services.async_call( DOMAIN, @@ -206,7 +206,7 @@ async def test_add_code_service_duplicate_code( with pytest.raises( ServiceValidationError, - match="A PIN code with this value already exists on the lock.", + match="A PIN code with this value already exists on the lock", ) as exc_info: await hass.services.async_call( DOMAIN, From f8a657cf01ce9f444c6d34cfcfa075b00b747019 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 27 Feb 2026 09:59:43 +0100 Subject: [PATCH 4/5] Proxmox expand data descriptions (#164304) --- homeassistant/components/proxmoxve/strings.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index f33f595e470d02..63c39da659858c 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -32,6 +32,14 @@ "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "host": "[%key:component::proxmoxve::config::step::user::data_description::host%]", + "password": "[%key:component::proxmoxve::config::step::user::data_description::password%]", + "port": "[%key:component::proxmoxve::config::step::user::data_description::port%]", + "realm": "[%key:component::proxmoxve::config::step::user::data_description::realm%]", + "username": "[%key:component::proxmoxve::config::step::user::data_description::username%]", + "verify_ssl": "[%key:component::proxmoxve::config::step::user::data_description::verify_ssl%]" + }, "description": "Use the following form to reconfigure your Proxmox VE server connection.", "title": "Reconfigure Proxmox VE integration" }, @@ -44,6 +52,14 @@ "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "host": "The hostname or IP address of your Proxmox VE server", + "password": "The password for the Proxmox VE server", + "port": "The port of your Proxmox VE server (default: 8006)", + "realm": "The authentication realm for the Proxmox VE server (default: 'pam')", + "username": "The username for the Proxmox VE server", + "verify_ssl": "Whether to verify SSL certificates. Disable only if you have a self-signed certificate" + }, "description": "Enter your Proxmox VE server details to set up the integration.", "title": "Connect to Proxmox VE" } From 46a87cd9dda9a2da2790c7653fd40bca072f9470 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 27 Feb 2026 09:16:35 +0000 Subject: [PATCH 5/5] Migrate evohome's zone services to entity-level services (#164105) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/evohome/climate.py | 42 ++++++--- homeassistant/components/evohome/entity.py | 12 +-- homeassistant/components/evohome/services.py | 92 ++++++++----------- .../components/evohome/services.yaml | 22 ++--- homeassistant/components/evohome/strings.json | 15 +-- tests/components/evohome/test_services.py | 49 ++++++++-- 6 files changed, 120 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 2e000546e08bfe..26a567dc486ce8 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -36,12 +36,12 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, EVOHOME_DATA, EvoService +from .const import ATTR_DURATION, ATTR_PERIOD, DOMAIN, EVOHOME_DATA, EvoService from .coordinator import EvoDataUpdateCoordinator from .entity import EvoChild, EvoEntity @@ -132,6 +132,24 @@ class EvoClimateEntity(EvoEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] _attr_temperature_unit = UnitOfTemperature.CELSIUS + async def async_clear_zone_override(self) -> None: + """Clear the zone override; only supported by zones.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="zone_only_service", + translation_placeholders={"service": EvoService.CLEAR_ZONE_OVERRIDE}, + ) + + async def async_set_zone_override( + self, setpoint: float, duration: timedelta | None = None + ) -> None: + """Set the zone override; only supported by zones.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="zone_only_service", + translation_placeholders={"service": EvoService.SET_ZONE_OVERRIDE}, + ) + class EvoZone(EvoChild, EvoClimateEntity): """Base for any evohome-compatible heating zone.""" @@ -170,22 +188,22 @@ def __init__( | ClimateEntityFeature.TURN_ON ) - async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: - """Process a service request (setpoint override) for a zone.""" - if service == EvoService.CLEAR_ZONE_OVERRIDE: - await self.coordinator.call_client_api(self._evo_device.reset()) - return + async def async_clear_zone_override(self) -> None: + """Clear the zone's override, if any.""" + await self.coordinator.call_client_api(self._evo_device.reset()) - # otherwise it is EvoService.SET_ZONE_OVERRIDE - temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp) + async def async_set_zone_override( + self, setpoint: float, duration: timedelta | None = None + ) -> None: + """Set the zone's override (mode/setpoint).""" + temperature = max(min(setpoint, self.max_temp), self.min_temp) - if ATTR_DURATION in data: - duration: timedelta = data[ATTR_DURATION] + if duration is not None: if duration.total_seconds() == 0: await self._update_schedule() until = self.setpoints.get("next_sp_from") else: - until = dt_util.now() + data[ATTR_DURATION] + until = dt_util.now() + duration else: until = None # indefinitely diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index 476482052958d3..0879fe739bc265 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -12,7 +12,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, EvoService +from .const import DOMAIN from .coordinator import EvoDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -47,22 +47,12 @@ async def process_signal(self, payload: dict | None = None) -> None: raise NotImplementedError if payload["unique_id"] != self._attr_unique_id: return - if payload["service"] in ( - EvoService.SET_ZONE_OVERRIDE, - EvoService.CLEAR_ZONE_OVERRIDE, - ): - await self.async_zone_svc_request(payload["service"], payload["data"]) - return await self.async_tcs_svc_request(payload["service"], payload["data"]) async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None: """Process a service request (system mode) for a controller.""" raise NotImplementedError - async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: - """Process a service request (setpoint override) for a zone.""" - raise NotImplementedError - @property def extra_state_attributes(self) -> Mapping[str, Any]: """Return the evohome-specific state attributes.""" diff --git a/homeassistant/components/evohome/services.py b/homeassistant/components/evohome/services.py index 40a4f60554170f..c6ce03a08f9a8b 100644 --- a/homeassistant/components/evohome/services.py +++ b/homeassistant/components/evohome/services.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from typing import Final +from typing import Any, Final from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE from evohomeasync2.schemas.const import ( @@ -13,9 +13,10 @@ ) import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control @@ -25,21 +26,38 @@ # system mode schemas are built dynamically when the services are registered # because supported modes can vary for edge-case systems -CLEAR_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( - {vol.Required(ATTR_ENTITY_ID): cv.entity_id} -) -SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_SETPOINT): vol.All( - vol.Coerce(float), vol.Range(min=4.0, max=35.0) - ), - vol.Optional(ATTR_DURATION): vol.All( - cv.time_period, - vol.Range(min=timedelta(days=0), max=timedelta(days=1)), - ), - } -) +# Zone service schemas (registered as entity services) +CLEAR_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {} +SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { + vol.Required(ATTR_SETPOINT): vol.All( + vol.Coerce(float), vol.Range(min=4.0, max=35.0) + ), + vol.Optional(ATTR_DURATION): vol.All( + cv.time_period, + vol.Range(min=timedelta(days=0), max=timedelta(days=1)), + ), +} + + +def _register_zone_entity_services(hass: HomeAssistant) -> None: + """Register entity-level services for zones.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + EvoService.CLEAR_ZONE_OVERRIDE, + entity_domain=CLIMATE_DOMAIN, + schema=CLEAR_ZONE_OVERRIDE_SCHEMA, + func="async_clear_zone_override", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + EvoService.SET_ZONE_OVERRIDE, + entity_domain=CLIMATE_DOMAIN, + schema=SET_ZONE_OVERRIDE_SCHEMA, + func="async_set_zone_override", + ) @callback @@ -51,8 +69,6 @@ def setup_service_functions( Not all Honeywell TCC-compatible systems support all operating modes. In addition, each mode will require any of four distinct service schemas. This has to be enumerated before registering the appropriate handlers. - - It appears that all TCC-compatible systems support the same three zones modes. """ @verify_domain_control(DOMAIN) @@ -72,28 +88,6 @@ async def set_system_mode(call: ServiceCall) -> None: } async_dispatcher_send(hass, DOMAIN, payload) - @verify_domain_control(DOMAIN) - async def set_zone_override(call: ServiceCall) -> None: - """Set the zone override (setpoint).""" - entity_id = call.data[ATTR_ENTITY_ID] - - registry = er.async_get(hass) - registry_entry = registry.async_get(entity_id) - - if registry_entry is None or registry_entry.platform != DOMAIN: - raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity") - - if registry_entry.domain != "climate": - raise ValueError(f"'{entity_id}' is not an {DOMAIN} controller/zone") - - payload = { - "unique_id": registry_entry.unique_id, - "service": call.service, - "data": call.data, - } - - async_dispatcher_send(hass, DOMAIN, payload) - assert coordinator.tcs is not None # mypy hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh) @@ -156,16 +150,4 @@ async def set_zone_override(call: ServiceCall) -> None: schema=vol.Schema(vol.Any(*system_mode_schemas)), ) - # The zone modes are consistent across all systems and use the same schema - hass.services.async_register( - DOMAIN, - EvoService.CLEAR_ZONE_OVERRIDE, - set_zone_override, - schema=CLEAR_ZONE_OVERRIDE_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - EvoService.SET_ZONE_OVERRIDE, - set_zone_override, - schema=SET_ZONE_OVERRIDE_SCHEMA, - ) + _register_zone_entity_services(hass) diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index 60dcf37ebb0ebb..cbf39f9c215707 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -28,14 +28,11 @@ reset_system: refresh_system: set_zone_override: + target: + entity: + integration: evohome + domain: climate fields: - entity_id: - required: true - example: climate.bathroom - selector: - entity: - integration: evohome - domain: climate setpoint: required: true selector: @@ -49,10 +46,7 @@ set_zone_override: object: clear_zone_override: - fields: - entity_id: - required: true - selector: - entity: - integration: evohome - domain: climate + target: + entity: + integration: evohome + domain: climate diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index 4f69eef4193baa..f66266f68544e4 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -1,13 +1,12 @@ { + "exceptions": { + "zone_only_service": { + "message": "Only zones support the `{service}` service" + } + }, "services": { "clear_zone_override": { "description": "Sets a zone to follow its schedule.", - "fields": { - "entity_id": { - "description": "[%key:component::evohome::services::set_zone_override::fields::entity_id::description%]", - "name": "[%key:component::evohome::services::set_zone_override::fields::entity_id::name%]" - } - }, "name": "Clear zone override" }, "refresh_system": { @@ -43,10 +42,6 @@ "description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.", "name": "Duration" }, - "entity_id": { - "description": "The entity ID of the Evohome zone.", - "name": "Entity" - }, "setpoint": { "description": "The temperature to be used instead of the scheduled setpoint.", "name": "Setpoint" diff --git a/tests/components/evohome/test_services.py b/tests/components/evohome/test_services.py index 2ec4d1158c99b2..7c1087ad7afe8f 100644 --- a/tests/components/evohome/test_services.py +++ b/tests/components/evohome/test_services.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import UTC, datetime +from typing import Any from unittest.mock import patch from evohomeasync2 import EvohomeClient @@ -18,10 +19,11 @@ ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError @pytest.mark.parametrize("install", ["default"]) -async def test_service_refresh_system( +async def test_refresh_system( hass: HomeAssistant, evohome: EvohomeClient, ) -> None: @@ -40,7 +42,7 @@ async def test_service_refresh_system( @pytest.mark.parametrize("install", ["default"]) -async def test_service_reset_system( +async def test_reset_system( hass: HomeAssistant, ctl_id: str, ) -> None: @@ -59,7 +61,7 @@ async def test_service_reset_system( @pytest.mark.parametrize("install", ["default"]) -async def test_ctl_set_system_mode( +async def test_set_system_mode( hass: HomeAssistant, ctl_id: str, freezer: FrozenDateTimeFactory, @@ -115,7 +117,7 @@ async def test_ctl_set_system_mode( @pytest.mark.parametrize("install", ["default"]) -async def test_zone_clear_zone_override( +async def test_clear_zone_override( hass: HomeAssistant, zone_id: str, ) -> None: @@ -126,9 +128,8 @@ async def test_zone_clear_zone_override( await hass.services.async_call( DOMAIN, EvoService.CLEAR_ZONE_OVERRIDE, - { - ATTR_ENTITY_ID: zone_id, - }, + {}, + target={ATTR_ENTITY_ID: zone_id}, blocking=True, ) @@ -136,7 +137,7 @@ async def test_zone_clear_zone_override( @pytest.mark.parametrize("install", ["default"]) -async def test_zone_set_zone_override( +async def test_set_zone_override( hass: HomeAssistant, zone_id: str, freezer: FrozenDateTimeFactory, @@ -151,9 +152,9 @@ async def test_zone_set_zone_override( DOMAIN, EvoService.SET_ZONE_OVERRIDE, { - ATTR_ENTITY_ID: zone_id, ATTR_SETPOINT: 19.5, }, + target={ATTR_ENTITY_ID: zone_id}, blocking=True, ) @@ -165,13 +166,41 @@ async def test_zone_set_zone_override( DOMAIN, EvoService.SET_ZONE_OVERRIDE, { - ATTR_ENTITY_ID: zone_id, ATTR_SETPOINT: 19.5, ATTR_DURATION: {"minutes": 135}, }, + target={ATTR_ENTITY_ID: zone_id}, blocking=True, ) mock_fcn.assert_awaited_once_with( 19.5, until=datetime(2024, 7, 10, 14, 15, tzinfo=UTC) ) + + +@pytest.mark.parametrize("install", ["default"]) +@pytest.mark.parametrize( + ("service", "service_data"), + [ + (EvoService.CLEAR_ZONE_OVERRIDE, {}), + (EvoService.SET_ZONE_OVERRIDE, {ATTR_SETPOINT: 19.5}), + ], +) +async def test_zone_services_with_ctl_id( + hass: HomeAssistant, + ctl_id: str, + service: EvoService, + service_data: dict[str, Any], +) -> None: + """Test calling zone-only services with a non-zone entity_id fail.""" + + with pytest.raises(ServiceValidationError) as excinfo: + await hass.services.async_call( + DOMAIN, + service, + service_data, + target={ATTR_ENTITY_ID: ctl_id}, + blocking=True, + ) + + assert excinfo.value.translation_key == "zone_only_service"