From 636481509d2708bbd62142644a693bbd82df3393 Mon Sep 17 00:00:00 2001 From: Martin <32802427+mstu01@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:27:55 +0200 Subject: [PATCH 01/16] Fix swallowed exceptions in action handlers for ColorExtractor (#181537) --- .../components/color_extractor/services.py | 93 +++++----- .../components/color_extractor/strings.json | 15 ++ .../color_extractor/test_services.py | 169 ++++++++++++++---- 3 files changed, 202 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/color_extractor/services.py b/homeassistant/components/color_extractor/services.py index 273a54107f4d33..1ca65433668095 100644 --- a/homeassistant/components/color_extractor/services.py +++ b/homeassistant/components/color_extractor/services.py @@ -1,6 +1,7 @@ """Module for color_extractor (RGB extraction from images) component.""" import asyncio +from http import HTTPStatus import io import logging from typing import Any @@ -17,7 +18,7 @@ ) from homeassistant.const import SERVICE_TURN_ON from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_GET_COLOR @@ -67,17 +68,14 @@ def _get_color(file_handler: io.BytesIO | str) -> tuple[int, int, int]: async def _async_extract_color_from_url( hass: HomeAssistant, url: str -) -> tuple[int, int, int] | None: +) -> tuple[int, int, int]: """Handle call for URL based image.""" if not hass.config.is_allowed_external_url(url): - _LOGGER.error( - ( - "External URL '%s' is not allowed, please add to" - " 'allowlist_external_urls'" - ), - url, + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="url_not_allowed", + translation_placeholders={"url": url}, ) - return None _LOGGER.debug("Getting predominant RGB from image URL '%s'", url) @@ -85,14 +83,30 @@ async def _async_extract_color_from_url( try: session = aiohttp_client.async_get_clientsession(hass) - async with asyncio.timeout(10): - response = await session.get(url) - - except (TimeoutError, aiohttp.ClientError) as err: - _LOGGER.error("Failed to get ColorThief image due to HTTPError: %s", err) - return None - - content = await response.content.read() + async with asyncio.timeout(10), session.get(url) as response: + if response.status != HTTPStatus.OK: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="http_error", + translation_placeholders={ + "url": url, + "status": str(response.status), + }, + ) + content = await response.read() + + except TimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout", + translation_placeholders={"url": url}, + ) from err + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="fetch_failed", + translation_placeholders={"url": url, "error": str(err)}, + ) from err with io.BytesIO(content) as _file: _file.name = "color_extractor.jpg" @@ -103,14 +117,14 @@ async def _async_extract_color_from_url( def _extract_color_from_path( hass: HomeAssistant, file_path: str -) -> tuple[int, int, int] | None: +) -> tuple[int, int, int]: """Handle call for local file based image.""" if not hass.config.is_allowed_path(file_path): - _LOGGER.error( - "File path '%s' is not allowed, please add to 'allowlist_external_dirs'", - file_path, + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="path_not_allowed", + translation_placeholders={"file_path": file_path}, ) - return None _LOGGER.debug("Getting predominant RGB from file path '%s'", file_path) @@ -137,22 +151,21 @@ async def async_handle_service(service_call: ServiceCall) -> None: _extract_color_from_path, service_call.hass, image_reference ) - # pylint: disable-next=home-assistant-action-swallowed-exception except UnidentifiedImageError as ex: - _LOGGER.error( - "Bad image from %s '%s' provided, are you sure it's an image? %s", - image_type, - image_reference, - ex, - ) - return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_image", + translation_placeholders={ + "image_type": image_type, + "image_reference": image_reference, + }, + ) from ex - if color: - service_data[ATTR_RGB_COLOR] = color + service_data[ATTR_RGB_COLOR] = color - await service_call.hass.services.async_call( - LIGHT_DOMAIN, SERVICE_TURN_ON, service_data, blocking=True - ) + await service_call.hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, service_data, blocking=True + ) async def async_handle_get_color( @@ -186,16 +199,6 @@ async def async_handle_get_color( }, ) from ex - if color is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_image", - translation_placeholders={ - "image_type": image_type, - "image_reference": image_reference, - }, - ) - return {"color": color} diff --git a/homeassistant/components/color_extractor/strings.json b/homeassistant/components/color_extractor/strings.json index 80dbd07b77b005..8cb136cae1d288 100644 --- a/homeassistant/components/color_extractor/strings.json +++ b/homeassistant/components/color_extractor/strings.json @@ -7,8 +7,23 @@ } }, "exceptions": { + "fetch_failed": { + "message": "Failed to fetch the image from {url}: {error}" + }, + "http_error": { + "message": "Failed to fetch the image from {url}: the server responded with HTTP status {status}." + }, "invalid_image": { "message": "Bad image {image_reference} from {image_type} provided, are you sure it's an image?" + }, + "path_not_allowed": { + "message": "Path {file_path} is not allowed, add it to allowlist_external_dirs." + }, + "timeout": { + "message": "Timed out fetching the image from {url}." + }, + "url_not_allowed": { + "message": "URL {url} is not allowed, add it to allowlist_external_urls." } }, "services": { diff --git a/tests/components/color_extractor/test_services.py b/tests/components/color_extractor/test_services.py index eaf92b2bc7d9f8..2df6d71cab9013 100644 --- a/tests/components/color_extractor/test_services.py +++ b/tests/components/color_extractor/test_services.py @@ -26,7 +26,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component from homeassistant.util import color as color_util @@ -175,13 +175,22 @@ async def test_url_success( async def test_url_not_allowed( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_integration ) -> None: - """Test that a not allowed external URL fails to turn light on.""" + """Test that a not allowed external URL raises and doesn't turn light on.""" service_data = { ATTR_URL: "http://denied.com/images/logo.png", ATTR_ENTITY_ID: LIGHT_ENTITY, } - await _async_execute_service(hass, service_data) + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, service_data, blocking=True + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "url_not_allowed" + assert exc_info.value.translation_placeholders == { + "url": "http://denied.com/images/logo.png" + } # Light has not been modified due to failure state = hass.states.get(LIGHT_ENTITY) @@ -189,10 +198,24 @@ async def test_url_not_allowed( assert state.state == STATE_OFF +@pytest.mark.parametrize( + ("exc", "translation_key", "placeholder_keys"), + [ + pytest.param( + aiohttp.ClientError, "fetch_failed", {"url", "error"}, id="client_error" + ), + pytest.param(TimeoutError, "timeout", {"url"}, id="timeout"), + ], +) +@pytest.mark.usefixtures("setup_integration") async def test_url_exception( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_integration + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + exc: type[Exception], + translation_key: str, + placeholder_keys: set[str], ) -> None: - """Test that a HTTPError fails to turn light on.""" + """Test that a failed image download raises and doesn't turn light on.""" service_data = { ATTR_URL: "http://example.com/images/logo.png", ATTR_ENTITY_ID: LIGHT_ENTITY, @@ -201,10 +224,19 @@ async def test_url_exception( # Don't let the URL not being allowed sway our exception test hass.config.allowlist_external_urls.add("http://example.com/images/") - # Mock the HTTP Response with an HTTPError - aioclient_mock.get(url=service_data[ATTR_URL], exc=aiohttp.ClientError) + aioclient_mock.get(url=service_data[ATTR_URL], exc=exc) - await _async_execute_service(hass, service_data) + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, service_data, blocking=True + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + placeholders = exc_info.value.translation_placeholders + assert placeholders is not None + assert set(placeholders) == placeholder_keys + assert placeholders["url"] == service_data[ATTR_URL] # Light has not been modified due to failure state = hass.states.get(LIGHT_ENTITY) @@ -212,10 +244,18 @@ async def test_url_exception( assert state.state == STATE_OFF +@pytest.mark.parametrize( + "status", + [ + pytest.param(400, id="bad_request"), + pytest.param(304, id="not_modified"), + ], +) +@pytest.mark.usefixtures("setup_integration") async def test_url_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_integration + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, status: int ) -> None: - """Test that a HTTP Error (non 200) doesn't turn light on.""" + """Test that a non-OK HTTP status raises and doesn't turn light on.""" service_data = { ATTR_URL: "http://example.com/images/logo.png", ATTR_ENTITY_ID: LIGHT_ENTITY, @@ -224,10 +264,26 @@ async def test_url_error( # Don't let the URL not being allowed sway our exception test hass.config.allowlist_external_urls.add("http://example.com/images/") - # Mock the HTTP Response with a 400 Bad Request error - aioclient_mock.get(url=service_data[ATTR_URL], status=400) + aioclient_mock.get(url=service_data[ATTR_URL], status=status) - await _async_execute_service(hass, service_data) + # The body of a non-OK response must not be downloaded at all + with ( + patch( + "tests.test_util.aiohttp.AiohttpClientMockResponse.read", + side_effect=AssertionError("body read for a non-OK response"), + ), + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, service_data, blocking=True + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "http_error" + assert exc_info.value.translation_placeholders == { + "url": service_data[ATTR_URL], + "status": str(status), + } # Light has not been modified due to failure state = hass.states.get(LIGHT_ENTITY) @@ -298,7 +354,7 @@ async def test_file(hass: HomeAssistant, setup_integration) -> None: @patch("os.path.isfile", Mock(return_value=True)) @patch("os.access", Mock(return_value=True)) async def test_file_denied_dir(hass: HomeAssistant, setup_integration) -> None: - """Test file service fails for images in disallowed dirs.""" + """Test file service raises for images in disallowed dirs.""" service_data = { ATTR_PATH: "/path/to/a/dir/not/allowed/image.png", ATTR_ENTITY_ID: LIGHT_ENTITY, @@ -311,12 +367,16 @@ async def test_file_denied_dir(hass: HomeAssistant, setup_integration) -> None: assert state assert state.state == STATE_OFF - # Mock the file handler read with our 1x1 base64 encoded fixture image - with patch( - "homeassistant.components.color_extractor.services._get_file", _get_file_mock - ): - await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, service_data) - await hass.async_block_till_done() + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, service_data, blocking=True + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "path_not_allowed" + assert exc_info.value.translation_placeholders == { + "file_path": "/path/to/a/dir/not/allowed/image.png" + } state = hass.states.get(LIGHT_ENTITY) @@ -326,6 +386,61 @@ async def test_file_denied_dir(hass: HomeAssistant, setup_integration) -> None: assert state.state == STATE_OFF +@pytest.mark.parametrize( + ("image_attr", "image_reference", "image_type"), + [ + pytest.param(ATTR_PATH, "/opt/not_an_image.txt", "file path", id="file_path"), + pytest.param( + ATTR_URL, "http://example.com/images/not_an_image.txt", "URL", id="url" + ), + ], +) +@pytest.mark.usefixtures("setup_integration") +@patch("os.path.isfile", Mock(return_value=True)) +@patch("os.access", Mock(return_value=True)) +async def test_turn_on_invalid_image( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + image_attr: str, + image_reference: str, + image_type: str, +) -> None: + """Test that the turn_on service raises a ServiceValidationError when given an invalid image.""" + service_data = { + image_attr: image_reference, + ATTR_ENTITY_ID: LIGHT_ENTITY, + } + + hass.config.allowlist_external_dirs.add("/opt/") + hass.config.allowlist_external_urls.add("http://example.com/images/") + aioclient_mock.get( + url="http://example.com/images/not_an_image.txt", content=b"not an image" + ) + + with ( + patch( + "homeassistant.components.color_extractor.services._get_file", + Mock(return_value=io.BytesIO(b"not an image")), + ), + pytest.raises(ServiceValidationError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, service_data, blocking=True + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_image" + assert exc_info.value.translation_placeholders == { + "image_type": image_type, + "image_reference": image_reference, + } + + # The light must stay untouched when the image cannot be read + state = hass.states.get(LIGHT_ENTITY) + assert state + assert state.state == STATE_OFF + + @patch("os.path.isfile", Mock(return_value=True)) @patch("os.access", Mock(return_value=True)) async def test_get_color_service(hass: HomeAssistant, setup_integration) -> None: @@ -390,19 +505,13 @@ async def test_get_color_service_not_allowed_path( ATTR_PATH: "/opt/not_an_image.txt", } - with ( - patch( - "homeassistant.components.color_extractor.services._get_file", - Mock(side_effect=UnidentifiedImageError("Cannot identify image file")), - ), - pytest.raises(ServiceValidationError) as exc_info, - ): + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, SERVICE_GET_COLOR, service_data, blocking=True, return_response=True ) - assert exc_info.value.translation_key == "invalid_image" + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "path_not_allowed" assert exc_info.value.translation_placeholders == { - "image_type": "file path", - "image_reference": "/opt/not_an_image.txt", + "file_path": "/opt/not_an_image.txt", } From e02d091174940e1af3271ca01c10273b102d4136 Mon Sep 17 00:00:00 2001 From: Martin <32802427+mstu01@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:28:56 +0200 Subject: [PATCH 02/16] Fix swallowed exceptions in action handlers for Huawei LTE (#181538) --- homeassistant/components/huawei_lte/notify.py | 12 +- .../components/huawei_lte/strings.json | 5 + tests/components/huawei_lte/test_notify.py | 109 ++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 tests/components/huawei_lte/test_notify.py diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index b226bab4d5283b..d67110c87144dd 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -8,9 +8,11 @@ from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_RECIPIENT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import HuaweiLteConfigEntry, Router +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -61,6 +63,12 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: phone_numbers=targets, message=message ) _LOGGER.debug("Sent to %s: %s", targets, resp) - # pylint: disable-next=home-assistant-action-swallowed-exception except ResponseErrorException as ex: - _LOGGER.error("Could not send to %s: %s", targets, ex) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_failed", + translation_placeholders={ + "targets": ", ".join(targets), + "error": str(ex), + }, + ) from ex diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 396d03ca50756a..e8309ac302a8ad 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -379,6 +379,11 @@ } } }, + "exceptions": { + "send_message_failed": { + "message": "Failed to send SMS to {targets}: {error}" + } + }, "options": { "step": { "init": { diff --git a/tests/components/huawei_lte/test_notify.py b/tests/components/huawei_lte/test_notify.py new file mode 100644 index 00000000000000..1255f5381940c8 --- /dev/null +++ b/tests/components/huawei_lte/test_notify.py @@ -0,0 +1,109 @@ +"""Tests for the Huawei LTE notify platform.""" + +from unittest.mock import MagicMock, patch + +from huawei_lte_api.exceptions import ResponseErrorException +import pytest + +from homeassistant.components.huawei_lte.const import ( + DEFAULT_NOTIFY_SERVICE_NAME, + DOMAIN, +) +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TARGET, + DOMAIN as NOTIFY_DOMAIN, +) +from homeassistant.const import CONF_RECIPIENT, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import magic_client + +from tests.common import MockConfigEntry + +MOCK_CONF_URL = "http://huawei-lte.example.com" + +pytestmark = pytest.mark.parametrize( + ("options", "service_data", "expected_targets"), + [ + pytest.param( + {}, + {ATTR_TARGET: ["+1234567890"]}, + ["+1234567890"], + id="explicit_target", + ), + pytest.param( + {CONF_RECIPIENT: ["+1234567890", "+0987654321"]}, + {}, + ["+1234567890", "+0987654321"], + id="default_recipients", + ), + ], +) + + +async def setup_notify_service( + hass: HomeAssistant, options: dict[str, list[str]] +) -> MagicMock: + """Set up the integration and return the mocked router client.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_URL: MOCK_CONF_URL}, options=options + ) + entry.add_to_hass(hass) + client = magic_client() + with ( + patch("homeassistant.components.huawei_lte.Connection", MagicMock()), + patch("homeassistant.components.huawei_lte.Client", return_value=client), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.services.has_service(NOTIFY_DOMAIN, DEFAULT_NOTIFY_SERVICE_NAME) + return client + + +async def test_send_message( + hass: HomeAssistant, + options: dict[str, list[str]], + service_data: dict[str, list[str]], + expected_targets: list[str], +) -> None: + """Test that the message is sent to the given or the configured recipients.""" + client = await setup_notify_service(hass, options) + + await hass.services.async_call( + NOTIFY_DOMAIN, + DEFAULT_NOTIFY_SERVICE_NAME, + {ATTR_MESSAGE: "Hello", **service_data}, + blocking=True, + ) + + client.sms.send_sms.assert_called_once_with( + phone_numbers=expected_targets, message="Hello" + ) + + +async def test_send_message_error( + hass: HomeAssistant, + options: dict[str, list[str]], + service_data: dict[str, list[str]], + expected_targets: list[str], +) -> None: + """Test that a failing send raises an error with a translation key.""" + client = await setup_notify_service(hass, options) + client.sms.send_sms.side_effect = ResponseErrorException("Send failed", 100) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + NOTIFY_DOMAIN, + DEFAULT_NOTIFY_SERVICE_NAME, + {ATTR_MESSAGE: "Hello", **service_data}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "send_message_failed" + assert exc_info.value.translation_placeholders == { + "targets": ", ".join(expected_targets), + "error": "Send failed", + } From 1c063fcac669bea4af35704cab7dab41ff85959a Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Mon, 7 Sep 2026 17:31:34 +0200 Subject: [PATCH 03/16] Report the Weheat cooling start conditions (#181287) Co-authored-by: Claude Opus 5 Co-authored-by: Erwin Douna --- .../components/weheat/binary_sensor.py | 31 + homeassistant/components/weheat/icons.json | 33 ++ .../components/weheat/quality_scale.yaml | 2 +- homeassistant/components/weheat/strings.json | 33 ++ .../weheat/snapshots/test_binary_sensor.ambr | 550 ++++++++++++++++++ 5 files changed, 648 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py index 7d6d20c12f69ce..a5d477c94f0237 100644 --- a/homeassistant/components/weheat/binary_sensor.py +++ b/homeassistant/components/weheat/binary_sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from functools import partial from typing import override from weheat.abstractions.heat_pump import HeatPump @@ -11,6 +12,7 @@ BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -62,6 +64,25 @@ class WeHeatBinarySensorEntityDescription(BinarySensorEntityDescription): ] +COOLING_START_CONDITION_SENSORS = [ + WeHeatBinarySensorEntityDescription( + translation_key=f"cooling_start_condition_{condition}", + key=f"cooling_start_condition_{condition}", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=partial( + lambda condition, status: ( + status.cooling_start_conditions[condition] + if status.cooling_start_conditions is not None + else None + ), + condition, + ), + ) + for condition in HeatPump.COOLING_START_CONDITION_BITS +] + + async def async_setup_entry( hass: HomeAssistant, entry: WeheatConfigEntry, @@ -78,6 +99,16 @@ async def async_setup_entry( for entity_description in BINARY_SENSORS if entity_description.value_fn(weheatdata.data_coordinator.data) is not None ] + entities.extend( + WeheatHeatPumpBinarySensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for weheatdata in entry.runtime_data + if weheatdata.data_coordinator.data.cooling_start_conditions is not None + for entity_description in COOLING_START_CONDITION_SENSORS + ) async_add_entities(entities) diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json index c5fc792dbc72eb..9a1a81d92f8042 100644 --- a/homeassistant/components/weheat/icons.json +++ b/homeassistant/components/weheat/icons.json @@ -1,6 +1,39 @@ { "entity": { "binary_sensor": { + "cooling_start_condition_contact_not_blocked": { + "default": "mdi:snowflake-check" + }, + "cooling_start_condition_control_method": { + "default": "mdi:snowflake-check" + }, + "cooling_start_condition_demand": { + "default": "mdi:snowflake-check" + }, + "cooling_start_condition_dtc": { + "default": "mdi:snowflake-check" + }, + "cooling_start_condition_exponential_backoff": { + "default": "mdi:snowflake-check" + }, + "cooling_start_condition_heat_cool_delay": { + "default": "mdi:snowflake-check" + }, + "cooling_start_condition_indoor_unit_connected": { + "default": "mdi:snowflake-check" + }, + "cooling_start_condition_inside_temperature": { + "default": "mdi:snowflake-check" + }, + "cooling_start_condition_outside_air_temperature": { + "default": "mdi:snowflake-check" + }, + "cooling_start_condition_water_temperature": { + "default": "mdi:snowflake-check" + }, + "cooling_start_condition_water_to_air": { + "default": "mdi:snowflake-check" + }, "indoor_unit_auxiliary_pump_state": { "default": "mdi:pump" }, diff --git a/homeassistant/components/weheat/quality_scale.yaml b/homeassistant/components/weheat/quality_scale.yaml index 0f0710576533a5..22722cdb898373 100644 --- a/homeassistant/components/weheat/quality_scale.yaml +++ b/homeassistant/components/weheat/quality_scale.yaml @@ -76,7 +76,7 @@ rules: While unlikely to happen. Check if it is easily integrated. entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: todo icon-translations: done diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index 74b2da3ad197bb..d89d0b9aa4258b 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -30,6 +30,39 @@ }, "entity": { "binary_sensor": { + "cooling_start_condition_contact_not_blocked": { + "name": "Cooling contact not blocking" + }, + "cooling_start_condition_control_method": { + "name": "Cooling allowed by control method" + }, + "cooling_start_condition_demand": { + "name": "Demand for cooling from cooling curve" + }, + "cooling_start_condition_dtc": { + "name": "No cooling-related faults" + }, + "cooling_start_condition_exponential_backoff": { + "name": "No cooling back-off waiting time" + }, + "cooling_start_condition_heat_cool_delay": { + "name": "No heating in the last 24 hours" + }, + "cooling_start_condition_indoor_unit_connected": { + "name": "Cooling indoor unit connected" + }, + "cooling_start_condition_inside_temperature": { + "name": "Cooling room warmer than desired" + }, + "cooling_start_condition_outside_air_temperature": { + "name": "Cooling outside temperature high enough" + }, + "cooling_start_condition_water_temperature": { + "name": "Water warmer than cooling curve" + }, + "cooling_start_condition_water_to_air": { + "name": "Cooling air warmer than system water" + }, "indoor_unit_auxiliary_pump_state": { "name": "Indoor unit auxiliary water pump" }, diff --git a/tests/components/weheat/snapshots/test_binary_sensor.ambr b/tests/components/weheat/snapshots/test_binary_sensor.ambr index 75cff41a5698f9..4b72382298ad7e 100644 --- a/tests/components/weheat/snapshots/test_binary_sensor.ambr +++ b/tests/components/weheat/snapshots/test_binary_sensor.ambr @@ -1,4 +1,354 @@ # serializer version: 1 +# name: test_binary_entities[binary_sensor.test_model_cooling_air_warmer_than_system_water-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_model_cooling_air_warmer_than_system_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling air warmer than system water', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooling air warmer than system water', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_start_condition_water_to_air', + 'unique_id': '0000-1111-2222-3333_cooling_start_condition_water_to_air', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_cooling_air_warmer_than_system_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model Cooling air warmer than system water', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_cooling_air_warmer_than_system_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_cooling_allowed_by_control_method-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_model_cooling_allowed_by_control_method', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling allowed by control method', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooling allowed by control method', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_start_condition_control_method', + 'unique_id': '0000-1111-2222-3333_cooling_start_condition_control_method', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_cooling_allowed_by_control_method-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model Cooling allowed by control method', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_cooling_allowed_by_control_method', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_cooling_contact_not_blocking-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_model_cooling_contact_not_blocking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling contact not blocking', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooling contact not blocking', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_start_condition_contact_not_blocked', + 'unique_id': '0000-1111-2222-3333_cooling_start_condition_contact_not_blocked', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_cooling_contact_not_blocking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model Cooling contact not blocking', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_cooling_contact_not_blocking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_cooling_indoor_unit_connected-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_model_cooling_indoor_unit_connected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling indoor unit connected', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooling indoor unit connected', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_start_condition_indoor_unit_connected', + 'unique_id': '0000-1111-2222-3333_cooling_start_condition_indoor_unit_connected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_cooling_indoor_unit_connected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model Cooling indoor unit connected', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_cooling_indoor_unit_connected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_cooling_outside_temperature_high_enough-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_model_cooling_outside_temperature_high_enough', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling outside temperature high enough', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooling outside temperature high enough', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_start_condition_outside_air_temperature', + 'unique_id': '0000-1111-2222-3333_cooling_start_condition_outside_air_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_cooling_outside_temperature_high_enough-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model Cooling outside temperature high enough', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_cooling_outside_temperature_high_enough', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_cooling_room_warmer_than_desired-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_model_cooling_room_warmer_than_desired', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling room warmer than desired', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooling room warmer than desired', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_start_condition_inside_temperature', + 'unique_id': '0000-1111-2222-3333_cooling_start_condition_inside_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_cooling_room_warmer_than_desired-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model Cooling room warmer than desired', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_cooling_room_warmer_than_desired', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_demand_for_cooling_from_cooling_curve-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_model_demand_for_cooling_from_cooling_curve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Demand for cooling from cooling curve', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Demand for cooling from cooling curve', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_start_condition_demand', + 'unique_id': '0000-1111-2222-3333_cooling_start_condition_demand', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_demand_for_cooling_from_cooling_curve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model Demand for cooling from cooling curve', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_demand_for_cooling_from_cooling_curve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxiliary_water_pump-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -202,3 +552,203 @@ 'state': 'off', }) # --- +# name: test_binary_entities[binary_sensor.test_model_no_cooling_back_off_waiting_time-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_model_no_cooling_back_off_waiting_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'No cooling back-off waiting time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'No cooling back-off waiting time', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_start_condition_exponential_backoff', + 'unique_id': '0000-1111-2222-3333_cooling_start_condition_exponential_backoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_no_cooling_back_off_waiting_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model No cooling back-off waiting time', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_no_cooling_back_off_waiting_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_no_cooling_related_faults-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_model_no_cooling_related_faults', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'No cooling-related faults', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'No cooling-related faults', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_start_condition_dtc', + 'unique_id': '0000-1111-2222-3333_cooling_start_condition_dtc', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_no_cooling_related_faults-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model No cooling-related faults', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_no_cooling_related_faults', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_no_heating_in_the_last_24_hours-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_model_no_heating_in_the_last_24_hours', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'No heating in the last 24 hours', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'No heating in the last 24 hours', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_start_condition_heat_cool_delay', + 'unique_id': '0000-1111-2222-3333_cooling_start_condition_heat_cool_delay', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_no_heating_in_the_last_24_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model No heating in the last 24 hours', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_no_heating_in_the_last_24_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_water_warmer_than_cooling_curve-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_model_water_warmer_than_cooling_curve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water warmer than cooling curve', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water warmer than cooling curve', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_start_condition_water_temperature', + 'unique_id': '0000-1111-2222-3333_cooling_start_condition_water_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_water_warmer_than_cooling_curve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model Water warmer than cooling curve', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_water_warmer_than_cooling_curve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- From b6af61f7a4f980b31386b084eddb7b621200f0e5 Mon Sep 17 00:00:00 2001 From: rlrghb <254179942+rlrghb@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:36:32 -0700 Subject: [PATCH 04/16] Fix llama.cpp streaming capability reporting (#179886) --- .../components/llama_cpp/conversation.py | 3 ++- tests/components/llama_cpp/test_conversation.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/llama_cpp/conversation.py b/homeassistant/components/llama_cpp/conversation.py index 44ff4c07d7fead..3f413b32e83297 100644 --- a/homeassistant/components/llama_cpp/conversation.py +++ b/homeassistant/components/llama_cpp/conversation.py @@ -9,7 +9,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LlamaCppConfigEntry -from .const import DOMAIN +from .const import CONF_STREAMING, DOMAIN from .entity import LlamaCppBaseLLMEntity @@ -36,6 +36,7 @@ class LlamaCppConversationEntity( def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" super().__init__(entry, subentry) + self._attr_supports_streaming = bool(entry.data.get(CONF_STREAMING, False)) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL diff --git a/tests/components/llama_cpp/test_conversation.py b/tests/components/llama_cpp/test_conversation.py index b9e7c970aba9c6..3132ba38c9aa81 100644 --- a/tests/components/llama_cpp/test_conversation.py +++ b/tests/components/llama_cpp/test_conversation.py @@ -102,6 +102,22 @@ async def test_conversation_entity( assert mock_chat_log.content[1:] == snapshot +@pytest.mark.parametrize( + ("config_entry_data", "supports_streaming"), + [({CONF_STREAMING: True}, True), ({CONF_STREAMING: False}, False)], +) +async def test_conversation_entity_streaming_support( + hass: HomeAssistant, supports_streaming: bool +) -> None: + """Verify the conversation entity advertises streaming support.""" + agent_info = conversation.async_get_agent_info( + hass, "conversation.llama_cpp_conversation" + ) + + assert agent_info is not None + assert agent_info.supports_streaming is supports_streaming + + @pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) async def test_function_call( hass: HomeAssistant, From 3f4cebe868afa5b275a984d736684c1b4ab3eb27 Mon Sep 17 00:00:00 2001 From: Anthony <210036686+Herbertmt978@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:45:13 +0100 Subject: [PATCH 05/16] Add ScorpionTrack last reported sensor (#178755) Co-authored-by: Erwin Douna --- .../components/scorpiontrack/sensor.py | 94 ++++++++++++++----- .../components/scorpiontrack/strings.json | 5 + .../scorpiontrack/snapshots/test_sensor.ambr | 55 ++++++++++- tests/components/scorpiontrack/test_sensor.py | 52 +++++++++- 4 files changed, 174 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/scorpiontrack/sensor.py b/homeassistant/components/scorpiontrack/sensor.py index d31b9007f0ee57..360c7f4e96860f 100644 --- a/homeassistant/components/scorpiontrack/sensor.py +++ b/homeassistant/components/scorpiontrack/sensor.py @@ -1,15 +1,22 @@ """Sensor platform for ScorpionTrack.""" +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime from typing import override +from pyscorpiontrack import ScorpionTrackShare, ScorpionTrackVehicle + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfSpeed +from homeassistant.const import EntityCategory, UnitOfSpeed from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator from .entity import ScorpionTrackEntity @@ -17,49 +24,86 @@ PARALLEL_UPDATES = 0 +@dataclass(frozen=True, kw_only=True) +class ScorpionTrackSensorEntityDescription(SensorEntityDescription): + """Describe a ScorpionTrack sensor.""" + + value_fn: Callable[[ScorpionTrackVehicle], StateType | datetime] + available_fn: Callable[[ScorpionTrackVehicle], bool] = lambda _: True + suggested_unit_fn: Callable[[ScorpionTrackShare], str] | None = None + + +SENSORS: tuple[ScorpionTrackSensorEntityDescription, ...] = ( + ScorpionTrackSensorEntityDescription( + key="speed", + device_class=SensorDeviceClass.SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda vehicle: vehicle.position.speed_kmh, + available_fn=lambda vehicle: vehicle.position.speed_kmh is not None, + suggested_unit_fn=lambda share: ( + UnitOfSpeed.MILES_PER_HOUR + if share.uses_miles + else UnitOfSpeed.KILOMETERS_PER_HOUR + ), + ), + ScorpionTrackSensorEntityDescription( + key="last_reported", + translation_key="last_reported", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda vehicle: vehicle.position.timestamp, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ScorpionTrackConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up ScorpionTrack speed sensors.""" + """Set up ScorpionTrack sensors.""" coordinator = entry.runtime_data async_add_entities( - ScorpionTrackSpeedSensor(coordinator, vehicle.id) + ScorpionTrackSensor(coordinator, vehicle.id, entity_description) for vehicle in coordinator.data.vehicles + for entity_description in SENSORS ) -class ScorpionTrackSpeedSensor(ScorpionTrackEntity, SensorEntity): - """Represent the latest shared vehicle speed.""" +class ScorpionTrackSensor(ScorpionTrackEntity, SensorEntity): + """Represent a ScorpionTrack vehicle sensor.""" - _attr_device_class = SensorDeviceClass.SPEED - _attr_native_unit_of_measurement = UnitOfSpeed.KILOMETERS_PER_HOUR - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_suggested_display_precision = 1 + entity_description: ScorpionTrackSensorEntityDescription - def __init__(self, coordinator: ScorpionTrackCoordinator, vehicle_id: int) -> None: - """Initialize the speed sensor.""" + def __init__( + self, + coordinator: ScorpionTrackCoordinator, + vehicle_id: int, + entity_description: ScorpionTrackSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" super().__init__(coordinator, vehicle_id) - self._attr_unique_id = f"{coordinator.data.id}_{vehicle_id}_speed" - self._attr_suggested_unit_of_measurement = ( - UnitOfSpeed.MILES_PER_HOUR - if coordinator.data.uses_miles - else UnitOfSpeed.KILOMETERS_PER_HOUR + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.data.id}_{vehicle_id}_{entity_description.key}" ) - - def _available_speed(self) -> float | None: - """Return the speed if the sensor is available.""" - return self.get_vehicle().position.speed_kmh + if (suggested_unit_fn := entity_description.suggested_unit_fn) is not None: + self._attr_suggested_unit_of_measurement = suggested_unit_fn( + coordinator.data + ) @property @override def available(self) -> bool: - """Return if the speed sensor is available.""" - return super().available and self._available_speed() is not None + """Return if the sensor is available.""" + return super().available and self.entity_description.available_fn( + self.get_vehicle() + ) @property @override - def native_value(self) -> float | None: - """Return the speed in kilometres per hour.""" - return self._available_speed() + def native_value(self) -> StateType | datetime: + """Return the sensor value.""" + return self.entity_description.value_fn(self.get_vehicle()) diff --git a/homeassistant/components/scorpiontrack/strings.json b/homeassistant/components/scorpiontrack/strings.json index e2315e4b60f8da..99274846d33d54 100644 --- a/homeassistant/components/scorpiontrack/strings.json +++ b/homeassistant/components/scorpiontrack/strings.json @@ -27,6 +27,11 @@ "ignition": { "name": "Ignition" } + }, + "sensor": { + "last_reported": { + "name": "Last reported" + } } }, "exceptions": { diff --git a/tests/components/scorpiontrack/snapshots/test_sensor.ambr b/tests/components/scorpiontrack/snapshots/test_sensor.ambr index c1182dc6d53348..20f412e679db86 100644 --- a/tests/components/scorpiontrack/snapshots/test_sensor.ambr +++ b/tests/components/scorpiontrack/snapshots/test_sensor.ambr @@ -1,5 +1,56 @@ # serializer version: 1 -# name: test_speed_sensor_snapshot[sensor.ab12_cde_speed-entry] +# name: test_sensor_snapshot[sensor.ab12_cde_last_reported-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.ab12_cde_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last reported', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last reported', + 'platform': 'scorpiontrack', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '101_1_last_reported', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.ab12_cde_last_reported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'AB12 CDE Last reported', + }), + 'context': , + 'entity_id': 'sensor.ab12_cde_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-08-09T12:00:00+00:00', + }) +# --- +# name: test_sensor_snapshot[sensor.ab12_cde_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -44,7 +95,7 @@ 'unit_of_measurement': , }) # --- -# name: test_speed_sensor_snapshot[sensor.ab12_cde_speed-state] +# name: test_sensor_snapshot[sensor.ab12_cde_speed-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'speed', diff --git a/tests/components/scorpiontrack/test_sensor.py b/tests/components/scorpiontrack/test_sensor.py index bedb6b3301cc76..e2656664474149 100644 --- a/tests/components/scorpiontrack/test_sensor.py +++ b/tests/components/scorpiontrack/test_sensor.py @@ -14,6 +14,7 @@ ATTR_LONGITUDE, ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, + STATE_UNKNOWN, Platform, UnitOfSpeed, ) @@ -25,6 +26,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform ENTITY_ID = "sensor.ab12_cde_speed" +LAST_REPORTED_ENTITY_ID = "sensor.ab12_cde_last_reported" async def test_speed_sensor_state( @@ -44,13 +46,14 @@ async def test_speed_sensor_state( mock_scorpiontrack_client.async_get_share.assert_awaited_once_with() -async def test_speed_sensor_snapshot( +@pytest.mark.freeze_time("2026-08-11 12:00:00+00:00") +async def test_sensor_snapshot( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test the speed sensor entity and state attributes.""" + """Test the sensor entities and state attributes.""" with patch("homeassistant.components.scorpiontrack.PLATFORMS", (Platform.SENSOR,)): await setup_integration(hass, mock_config_entry) @@ -110,14 +113,22 @@ async def test_speed_sensor_availability( assert state.state == expected_state -async def test_removed_vehicle_makes_speed_sensor_unavailable( +@pytest.mark.parametrize( + "entity_id", + [ + pytest.param(ENTITY_ID, id="speed"), + pytest.param(LAST_REPORTED_ENTITY_ID, id="last-reported"), + ], +) +async def test_removed_vehicle_makes_sensor_unavailable( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, mock_share: ScorpionTrackShare, mock_scorpiontrack_client: AsyncMock, + entity_id: str, ) -> None: - """Test a speed sensor becomes unavailable if its vehicle leaves the share.""" + """Test a sensor becomes unavailable if its vehicle leaves the share.""" await setup_integration(hass, mock_config_entry) mock_scorpiontrack_client.async_get_share.return_value = replace( @@ -127,7 +138,7 @@ async def test_removed_vehicle_makes_speed_sensor_unavailable( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -151,3 +162,34 @@ async def test_speed_sensor_uses_existing_vehicle_device( device = device_registry.async_get(speed_entry.device_id) assert device is not None assert device.identifiers == {("scorpiontrack", "101_1")} + + +async def test_last_reported_sensor_without_timestamp( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """Test a missing timestamp is unknown without affecting the tracker.""" + vehicle = mock_share.vehicles[0] + mock_scorpiontrack_client.async_get_share.return_value = replace( + mock_share, + vehicles=( + replace( + vehicle, + position=replace(vehicle.position, timestamp=None), + ), + ), + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(LAST_REPORTED_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + tracker_state = hass.states.get("device_tracker.ab12_cde") + assert tracker_state is not None + assert tracker_state.state != STATE_UNAVAILABLE + assert tracker_state.attributes[ATTR_LATITUDE] == vehicle.position.latitude + assert tracker_state.attributes[ATTR_LONGITUDE] == vehicle.position.longitude From aa6fc90ba43b1faf49810423422835d2a3342940 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:49:39 -0400 Subject: [PATCH 06/16] Describe Z-Wave JS triggers for the automation editor (#181129) Co-authored-by: Claude Fable 5.1 --- homeassistant/components/zwave_js/icons.json | 8 + .../components/zwave_js/strings.json | 205 +++++++++++++++++ .../components/zwave_js/triggers.yaml | 209 ++++++++++++++++++ .../components/zwave_js/triggers/event.py | 27 ++- script/hassfest/triggers.py | 1 - tests/components/zwave_js/common.py | 4 + tests/components/zwave_js/test_trigger.py | 137 +++++++++++- 7 files changed, 578 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/zwave_js/triggers.yaml diff --git a/homeassistant/components/zwave_js/icons.json b/homeassistant/components/zwave_js/icons.json index 0a4b941aa25083..e03dac535341cb 100644 --- a/homeassistant/components/zwave_js/icons.json +++ b/homeassistant/components/zwave_js/icons.json @@ -120,5 +120,13 @@ "set_value": { "service": "mdi:form-textbox" } + }, + "triggers": { + "event": { + "trigger": "mdi:z-wave" + }, + "value_updated": { + "trigger": "mdi:update" + } } } diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 0f6c78f2ae49f8..19e1dd754a8896 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -426,6 +426,137 @@ } }, "selector": { + "command_class": { + "options": { + "0": "No Operation (0)", + "32": "Basic (32)", + "33": "Controller Replication (33)", + "34": "Application Status (34)", + "35": "Z/IP (35)", + "37": "Binary Switch (37)", + "38": "Multilevel Switch (38)", + "39": "All Switch (39)", + "40": "Binary Toggle Switch (40)", + "41": "Multilevel Toggle Switch (41)", + "43": "Scene Activation (43)", + "44": "Scene Actuator Configuration (44)", + "45": "Scene Controller Configuration (45)", + "48": "Binary Sensor (48)", + "49": "Multilevel Sensor (49)", + "50": "Meter (50)", + "51": "Color Switch (51)", + "52": "Network Management Inclusion (52)", + "53": "Pulse Meter (53)", + "54": "Basic Tariff Information (54)", + "55": "HRV Status (55)", + "57": "HRV Control (57)", + "58": "Demand Control Plan Configuration (58)", + "59": "Demand Control Plan Monitor (59)", + "60": "Meter Table Configuration (60)", + "61": "Meter Table Monitor (61)", + "62": "Meter Table Push Configuration (62)", + "63": "Prepayment (63)", + "64": "Thermostat Mode (64)", + "65": "Prepayment Encapsulation (65)", + "66": "Thermostat Operating State (66)", + "67": "Thermostat Setpoint (67)", + "68": "Thermostat Fan Mode (68)", + "69": "Thermostat Fan State (69)", + "70": "Climate Control Schedule (70)", + "71": "Thermostat Setback (71)", + "72": "Rate Table Configuration (72)", + "73": "Rate Table Monitor (73)", + "74": "Tariff Table Configuration (74)", + "75": "Tariff Table Monitor (75)", + "76": "Door Lock Logging (76)", + "77": "Network Management Basic Node (77)", + "78": "Schedule Entry Lock (78)", + "79": "Z/IP 6LoWPAN (79)", + "80": "Basic Window Covering (80)", + "81": "Move To Position Window Covering (81)", + "82": "Network Management Proxy (82)", + "83": "Schedule (83)", + "84": "Network Management Primary (84)", + "85": "Transport Service (85)", + "86": "CRC-16 Encapsulation (86)", + "87": "Application Capability (87)", + "88": "Z/IP ND (88)", + "89": "Association Group Information (89)", + "90": "Device Reset Locally (90)", + "91": "Central Scene (91)", + "92": "IP Association (92)", + "93": "Anti-Theft (93)", + "94": "Z-Wave Plus Info (94)", + "95": "Z/IP Gateway (95)", + "96": "Multi Channel (96)", + "97": "Z/IP Portal (97)", + "98": "Door Lock (98)", + "99": "User Code (99)", + "100": "Humidity Control Setpoint (100)", + "102": "Barrier Operator (102)", + "103": "Network Management Installation and Maintenance (103)", + "104": "Z/IP Naming and Location (104)", + "105": "Mailbox (105)", + "106": "Window Covering (106)", + "107": "Irrigation (107)", + "108": "Supervision (108)", + "109": "Humidity Control Mode (109)", + "110": "Humidity Control Operating State (110)", + "111": "Entry Control (111)", + "112": "Configuration (112)", + "113": "Notification (113)", + "114": "Manufacturer Specific (114)", + "115": "Powerlevel (115)", + "116": "Inclusion Controller (116)", + "117": "Protection (117)", + "118": "Lock (118)", + "119": "Node Naming and Location (119)", + "120": "Node Provisioning (120)", + "121": "Sound Switch (121)", + "122": "Firmware Update Meta Data (122)", + "123": "Grouping Name (123)", + "124": "Remote Association Activation (124)", + "125": "Remote Association Configuration (125)", + "126": "Anti-Theft Unlock (126)", + "128": "Battery (128)", + "129": "Clock (129)", + "130": "Hail (130)", + "132": "Wake Up (132)", + "133": "Association (133)", + "134": "Version (134)", + "135": "Indicator (135)", + "136": "Proprietary (136)", + "137": "Language (137)", + "138": "Time (138)", + "139": "Time Parameters (139)", + "140": "Geographic Location (140)", + "142": "Multi Channel Association (142)", + "143": "Multi Command (143)", + "144": "Energy Production (144)", + "145": "Manufacturer Proprietary (145)", + "146": "Screen Meta Data (146)", + "147": "Screen Attributes (147)", + "148": "Simple AV Control (148)", + "152": "Security (152)", + "154": "IP Configuration (154)", + "155": "Association Command Configuration (155)", + "156": "Alarm Sensor (156)", + "157": "Alarm Silence (157)", + "158": "Sensor Configuration (158)", + "159": "Security 2 (159)", + "160": "IR Repeater (160)", + "161": "Authentication (161)", + "162": "Authentication Media Write (162)", + "163": "Generic Schedule (163)" + } + }, + "event_source": { + "options": { + "controller": "Controller", + "driver": "Driver", + "node": "Node" + } + }, "network_type": { "options": { "existing": "It already exists", @@ -890,5 +1021,79 @@ }, "name": "Set a value" } + }, + "triggers": { + "event": { + "description": "Triggers when a Z-Wave JS controller, driver, or node emits an event.", + "fields": { + "config_entry_id": { + "description": "The Z-Wave JS config entry to listen to. Required for controller and driver events.", + "name": "Config entry" + }, + "device_id": { + "description": "Devices whose node events to listen to. Node events need at least one device or entity, and controller or driver events must not have any.", + "name": "Devices" + }, + "entity_id": { + "description": "Entities whose node events to listen to. Node events need at least one device or entity, and controller or driver events must not have any.", + "name": "Entities" + }, + "event": { + "description": "Name of the Z-Wave JS event, for example `value notification`.", + "name": "Event" + }, + "event_data": { + "description": "Key-value pairs the event data must contain for the trigger to fire.", + "name": "Event data" + }, + "event_source": { + "description": "Whether the event is emitted by the controller, the driver, or a node.", + "name": "Event source" + }, + "partial_dict_match": { + "description": "Match nested dictionaries in the event data on the given keys only, instead of requiring an exact match.", + "name": "Partial dictionary match" + } + }, + "name": "Z-Wave JS event received" + }, + "value_updated": { + "description": "Triggers when a Z-Wave value on one or more nodes changes.", + "fields": { + "command_class": { + "description": "Command class of the value.", + "name": "Command class" + }, + "device_id": { + "description": "Devices whose values to watch. At least one device or entity is required.", + "name": "Devices" + }, + "endpoint": { + "description": "Endpoint of the value.", + "name": "Endpoint" + }, + "entity_id": { + "description": "Entities whose values to watch. At least one device or entity is required.", + "name": "Entities" + }, + "from": { + "description": "Only trigger when the value changes from this value, or from any value in this list.", + "name": "From" + }, + "property": { + "description": "Property of the value.", + "name": "Property" + }, + "property_key": { + "description": "Property key of the value.", + "name": "Property key" + }, + "to": { + "description": "Only trigger when the value changes to this value, or to any value in this list.", + "name": "To" + } + }, + "name": "Z-Wave JS value updated" + } } } diff --git a/homeassistant/components/zwave_js/triggers.yaml b/homeassistant/components/zwave_js/triggers.yaml new file mode 100644 index 00000000000000..820e684e1eb079 --- /dev/null +++ b/homeassistant/components/zwave_js/triggers.yaml @@ -0,0 +1,209 @@ +# Describes the format for available Z-Wave JS triggers + +.device_id: &device_id + required: false + example: 8f4219cfa57e23f6f669c4616c2205e2 + selector: + device: + filter: + - integration: zwave_js + multiple: true + +.entity_id: &entity_id + required: false + example: sensor.living_room_temperature + selector: + entity: + filter: + - integration: zwave_js + multiple: true + +event: + fields: + event_source: + required: true + selector: + select: + translation_key: event_source + options: + - controller + - driver + - node + config_entry_id: + required: false + selector: + config_entry: + integration: zwave_js + device_id: *device_id + entity_id: *entity_id + event: + required: true + example: value notification + selector: + text: + event_data: + required: false + selector: + object: + partial_dict_match: + required: false + default: false + selector: + boolean: + +value_updated: + fields: + device_id: *device_id + entity_id: *entity_id + command_class: + required: true + selector: + select: + translation_key: command_class + sort: true + options: + - "0" + - "32" + - "33" + - "34" + - "35" + - "37" + - "38" + - "39" + - "40" + - "41" + - "43" + - "44" + - "45" + - "48" + - "49" + - "50" + - "51" + - "52" + - "53" + - "54" + - "55" + - "57" + - "58" + - "59" + - "60" + - "61" + - "62" + - "63" + - "64" + - "65" + - "66" + - "67" + - "68" + - "69" + - "70" + - "71" + - "72" + - "73" + - "74" + - "75" + - "76" + - "77" + - "78" + - "79" + - "80" + - "81" + - "82" + - "83" + - "84" + - "85" + - "86" + - "87" + - "88" + - "89" + - "90" + - "91" + - "92" + - "93" + - "94" + - "95" + - "96" + - "97" + - "98" + - "99" + - "100" + - "102" + - "103" + - "104" + - "105" + - "106" + - "107" + - "108" + - "109" + - "110" + - "111" + - "112" + - "113" + - "114" + - "115" + - "116" + - "117" + - "118" + - "119" + - "120" + - "121" + - "122" + - "123" + - "124" + - "125" + - "126" + - "128" + - "129" + - "130" + - "132" + - "133" + - "134" + - "135" + - "136" + - "137" + - "138" + - "139" + - "140" + - "142" + - "143" + - "144" + - "145" + - "146" + - "147" + - "148" + - "152" + - "154" + - "155" + - "156" + - "157" + - "158" + - "159" + - "160" + - "161" + - "162" + - "163" + property: + required: true + example: currentValue + selector: + text: + endpoint: + required: false + example: 1 + selector: + number: + min: 0 + mode: box + property_key: + required: false + example: 1 + selector: + text: + from: + required: false + selector: + object: + to: + required: false + selector: + object: diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 1e29e8dd338b88..8d67e29410ea18 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -51,11 +51,25 @@ PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}" -def validate_non_node_event_source(obj: dict) -> dict: - """Validate that a trigger for a non node event source has a config entry.""" - if obj[ATTR_EVENT_SOURCE] != "node" and ATTR_CONFIG_ENTRY_ID in obj: +def validate_event_source_targets(obj: dict) -> dict: + """Validate that the targets match the event source.""" + if obj[ATTR_EVENT_SOURCE] == "node": + if ATTR_DEVICE_ID not in obj and ATTR_ENTITY_ID not in obj: + raise vol.Invalid( + f"Node event triggers must contain {ATTR_DEVICE_ID} or " + f"{ATTR_ENTITY_ID}." + ) return obj - raise vol.Invalid(f"Non node event triggers must contain {ATTR_CONFIG_ENTRY_ID}.") + if ATTR_CONFIG_ENTRY_ID not in obj: + raise vol.Invalid( + f"Non node event triggers must contain {ATTR_CONFIG_ENTRY_ID}." + ) + if ATTR_DEVICE_ID in obj or ATTR_ENTITY_ID in obj: + raise vol.Invalid( + f"Non node event triggers must not contain {ATTR_DEVICE_ID} or " + f"{ATTR_ENTITY_ID}." + ) + return obj def validate_event_name(obj: dict) -> dict: @@ -112,10 +126,7 @@ def validate_event_data(obj: dict) -> dict: _OPTIONS_SCHEMA_DICT, validate_event_name, validate_event_data, - vol.Any( - validate_non_node_event_source, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), - ), + validate_event_source_targets, ) } ) diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py index 54de60f45d5961..9720a27c4c7d98 100644 --- a/script/hassfest/triggers.py +++ b/script/hassfest/triggers.py @@ -158,7 +158,6 @@ def validate_field_schema(trigger_schema: dict[str, Any]) -> dict[str, Any]: "webhook", "webostv", "zone", - "zwave_js", } diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 9d866a2267c032..fbe8cb7dbe50f9 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -3,6 +3,7 @@ from copy import deepcopy from typing import Any +from zwave_js_server.const import CommandClass from zwave_js_server.model.node.data_model import NodeDataType from homeassistant.components.zwave_js.helpers import ( @@ -10,6 +11,9 @@ value_matches_matcher, ) +# NIF markers listed in SDS13548 as "not an actual Command Class" +COMMAND_CLASS_MARKERS = {CommandClass.MARK, CommandClass.SECURITY_SCHEME0_MARK} + AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" BATTERY_SENSOR = "sensor.multisensor_6_battery_level" TAMPER_SENSOR = "binary_sensor.multisensor_6_tampering_product_cover_removed" diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index a4a2a3f9cd92b2..9f9aef4e00cea3 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -1,7 +1,8 @@ """The tests for Z-Wave JS automation triggers.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise import copy -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest import voluptuous as vol @@ -13,17 +14,24 @@ from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.components.zwave_js.trigger import TRIGGERS +from homeassistant.components.zwave_js.triggers.event import ( + _OPTIONS_SCHEMA_DICT as EVENT_OPTIONS_SCHEMA_DICT, +) from homeassistant.components.zwave_js.triggers.trigger_helpers import ( async_bypass_dynamic_config_validation, ) +from homeassistant.components.zwave_js.triggers.value_updated import ( + _OPTIONS_SCHEMA_DICT as VALUE_UPDATED_OPTIONS_SCHEMA_DICT, +) from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, trigger +from homeassistant.helpers.translation import async_get_translations from homeassistant.setup import async_setup_component -from .common import SCHLAGE_BE469_LOCK_ENTITY +from .common import COMMAND_CLASS_MARKERS, SCHLAGE_BE469_LOCK_ENTITY -from tests.common import async_capture_events +from tests.common import MockConfigEntry, async_capture_events async def test_zwave_js_value_updated( @@ -1042,6 +1050,84 @@ async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize( + ("event_source", "event", "option_keys", "expectation"), + [ + pytest.param( + "controller", + "inclusion started", + ["config_entry_id", "device_id"], + pytest.raises(vol.Invalid, match="must not contain"), + id="controller_with_device_id", + ), + pytest.param( + "driver", + "logging", + ["config_entry_id", "entity_id"], + pytest.raises(vol.Invalid, match="must not contain"), + id="driver_with_entity_id", + ), + pytest.param( + "node", + "interview stage completed", + [], + pytest.raises(vol.Invalid, match="must contain"), + id="node_without_targets", + ), + pytest.param( + "controller", + "inclusion started", + ["config_entry_id"], + does_not_raise(), + id="controller_without_targets", + ), + pytest.param( + "controller", + "inclusion started", + [], + pytest.raises(vol.Invalid, match="must contain config_entry_id"), + id="controller_without_config_entry", + ), + ], +) +async def test_zwave_js_event_source_target_validation( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + event_source: str, + event: str, + option_keys: list[str], + expectation: AbstractContextManager, +) -> None: + """Test that zwave_js.event targets are validated against the event source.""" + device = device_registry.async_get_device_by_identifier( + get_device_id(client.driver, lock_schlage_be469), integration.entry_id + ) + assert device + options = { + "config_entry_id": integration.entry_id, + "device_id": device.id, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + } + + with expectation: + await trigger.async_validate_trigger_config( + hass, + [ + { + "platform": f"{DOMAIN}.event", + "options": { + "event_source": event_source, + "event": event, + **{key: options[key] for key in option_keys}, + }, + } + ], + ) + + async def test_zwave_js_trigger_config_entry_unloaded( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -1495,3 +1581,46 @@ async def test_zwave_js_old_syntax( node.receive_event(event) await hass.async_block_till_done() assert len(zwavejs_value_updated) == 1 + + +@pytest.mark.usefixtures("integration") +async def test_value_updated_command_class_options(hass: HomeAssistant) -> None: + """Test the command class options and translations match the CommandClass enum.""" + expected = {str(cc.value) for cc in CommandClass if cc not in COMMAND_CLASS_MARKERS} + + descriptions = await trigger.async_get_all_descriptions(hass) + options = descriptions[f"{DOMAIN}.value_updated"]["fields"]["command_class"][ + "selector" + ]["select"]["options"] + assert len(options) == len(expected) + assert set(options) == expected + + translations = await async_get_translations(hass, "en", "selector", {DOMAIN}) + prefix = f"component.{DOMAIN}.selector.command_class.options." + assert { + key.removeprefix(prefix) for key in translations if key.startswith(prefix) + } == expected + + +@pytest.mark.parametrize( + ("trigger_type", "options_schema"), + [ + pytest.param(f"{DOMAIN}.event", EVENT_OPTIONS_SCHEMA_DICT, id="event"), + pytest.param( + f"{DOMAIN}.value_updated", + VALUE_UPDATED_OPTIONS_SCHEMA_DICT, + id="value_updated", + ), + ], +) +@pytest.mark.usefixtures("integration") +async def test_trigger_description_fields_match_schema( + hass: HomeAssistant, trigger_type: str, options_schema: dict[vol.Marker, object] +) -> None: + """Test the described fields match the trigger's options schema.""" + descriptions = await trigger.async_get_all_descriptions(hass) + fields = descriptions[trigger_type]["fields"] + assert set(fields) == {str(key) for key in options_schema} + assert {name for name, field in fields.items() if field["required"]} == { + str(key) for key in options_schema if isinstance(key, vol.Required) + } From 506402910096ac22b52f887f6b072c87c24475d4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:50:23 -0400 Subject: [PATCH 07/16] Bump vizaio to 0.7.0 (#181568) --- homeassistant/components/vizio/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index bff7a9e04976fa..ef908182efc03e 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["vizaio"], - "requirements": ["vizaio==0.6.2"], + "requirements": ["vizaio==0.7.0"], "zeroconf": ["_viziocast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 3003c8ea2900e7..3fe7c6f94cce3a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3394,7 +3394,7 @@ vilfo-api-client==0.5.0 visionpluspython==1.1.0 # homeassistant.components.vizio -vizaio==0.6.2 +vizaio==0.7.0 # homeassistant.components.caldav vobject==0.9.9 From ad36c66b048fdfebfd0a4b111ff5cdb0881d7187 Mon Sep 17 00:00:00 2001 From: koolsb <14332595+koolsb@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:56:20 -0500 Subject: [PATCH 08/16] Bump pyblackbird to 0.10 (#181565) --- homeassistant/components/blackbird/manifest.json | 2 +- homeassistant/components/blackbird/media_player.py | 2 +- pyproject.toml | 3 --- requirements_all.txt | 2 +- script/hassfest/requirements.py | 5 ----- 5 files changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/blackbird/manifest.json b/homeassistant/components/blackbird/manifest.json index a0f4b0c383cf3b..c7389baf5b4b5c 100644 --- a/homeassistant/components/blackbird/manifest.json +++ b/homeassistant/components/blackbird/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pyblackbird"], "quality_scale": "legacy", - "requirements": ["pyblackbird==0.6"] + "requirements": ["pyblackbird==0.10"] } diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py index 8382e463335cae..f5f0caf492dec0 100644 --- a/homeassistant/components/blackbird/media_player.py +++ b/homeassistant/components/blackbird/media_player.py @@ -4,7 +4,7 @@ from typing import override from pyblackbird import get_blackbird -from serial import SerialException +from serialx import SerialException import voluptuous as vol from homeassistant.components.media_player import ( diff --git a/pyproject.toml b/pyproject.toml index 3fa3a67ad6f973..69cf215201a050 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -544,9 +544,6 @@ filterwarnings = [ # https://pypi.org/project/panasonic-viera/ - v0.4.4 - 2025-11-25 # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.4/panasonic_viera/remote_control.py#L665 "ignore:.*invalid escape sequence:SyntaxWarning:.*panasonic_viera", - # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 - # https://github.com/koolsb/pyblackbird/pull/9 -> closed - "ignore:.*invalid escape sequence:SyntaxWarning:.*pyblackbird", # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 "ignore:.*invalid escape sequence:SyntaxWarning:.*pyws66i", # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 diff --git a/requirements_all.txt b/requirements_all.txt index 3fe7c6f94cce3a..87823356d69433 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2105,7 +2105,7 @@ pybalboa==1.1.3 pybbox==0.0.5-alpha # homeassistant.components.blackbird -pyblackbird==0.6 +pyblackbird==0.10 # homeassistant.components.bluesound pyblu==2.0.8 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index b395acea5e771b..cc02a32d7327a2 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -124,11 +124,6 @@ "airthings": {"airthings-cloud": {"async-timeout"}}, "apache_kafka": {"aiokafka": {"async-timeout"}}, "aseko_pool_live": {"gql": {"backoff"}}, - "blackbird": { - # https://github.com/koolsb/pyblackbird/issues/12 - # pyblackbird > pyserial-asyncio - "pyblackbird": {"pyserial-asyncio"} - }, "coinbase": {"coinbase-advanced-py": {"backoff"}}, "cmus": { # https://github.com/mtreinish/pycmus/issues/4 From 474dd34fbf2b2f14e0df7dbe584d96951ee21488 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 7 Sep 2026 20:11:06 +0200 Subject: [PATCH 09/16] Set easyEnergy quality scale to platinum (#181378) --- .../components/easyenergy/manifest.json | 1 + .../components/easyenergy/quality_scale.yaml | 100 ++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/easyenergy/quality_scale.yaml diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 2b2195c43bdb8a..c536f2a49aee8b 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "integration_type": "service", "iot_class": "cloud_polling", + "quality_scale": "platinum", "requirements": ["easyenergy==3.0.1"], "single_config_entry": true } diff --git a/homeassistant/components/easyenergy/quality_scale.yaml b/homeassistant/components/easyenergy/quality_scale.yaml new file mode 100644 index 00000000000000..5604338bfd1161 --- /dev/null +++ b/homeassistant/components/easyenergy/quality_scale.yaml @@ -0,0 +1,100 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-conditions: + status: exempt + comment: | + This integration does not provide custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: | + This integration does not provide custom triggers. + entity-event-setup: + status: exempt + comment: | + Entities in this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + The easyEnergy API does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration connects to a cloud service and does not support discovery. + discovery: + status: exempt + comment: | + This integration connects to a cloud service and does not support discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: | + This integration connects to a cloud service rather than physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + This integration exposes a fixed set of service devices and does not discover, + add, or remove devices dynamically. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + This integration has no user-configurable settings to reconfigure. + repair-issues: + status: exempt + comment: | + The integration has no user-actionable failure states that require a repair + issue. + stale-devices: + status: exempt + comment: | + This integration exposes a fixed set of service devices, so devices cannot + become stale after disappearing from the upstream service. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 3fb4753e427e37..68f8da759e7b2c 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -278,7 +278,6 @@ class Rule: "dweet", "dynalite", "eafm", - "easyenergy", "ebox", "ebusd", "ecoal_boiler", @@ -1205,7 +1204,6 @@ class Rule: "dweet", "dynalite", "eafm", - "easyenergy", "ebox", "ebusd", "ecoal_boiler", From 71e6810e7c73f4099fd40228eddd7259bacbcb36 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 7 Sep 2026 20:27:41 +0200 Subject: [PATCH 10/16] Protect mutable allowed_context_keys attribute in Selector classes (#181449) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/selector.py | 38 +++++++++++++++---------------- tests/helpers/test_selector.py | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 9a52e6e83d1873..88b690cb5bf189 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -64,11 +64,12 @@ class Selector[_T: Mapping[str, Any]]: # context for filtering for example. The selector defines # which context keys it supports and what selector types # are allowed for each key. - allowed_context_keys: dict[str, set[str]] = {} + allowed_context_keys: dict[str, set[str]] def __init__(self, config: Mapping[str, Any] | None = None) -> None: """Instantiate a selector.""" self.config = self.CONFIG_SCHEMA(config) + self.allowed_context_keys = {} @override def __eq__(self, other: object) -> bool: @@ -427,11 +428,6 @@ class AttributeSelector(Selector[AttributeSelectorConfig]): selector_type = "attribute" - allowed_context_keys = { - # Filters the available attributes based on the selected entity - "filter_entity": {"entity"} - } - CONFIG_SCHEMA = make_selector_config_schema( { vol.Required("entity_id"): cv.entity_id, @@ -444,6 +440,10 @@ class AttributeSelector(Selector[AttributeSelectorConfig]): def __init__(self, config: AttributeSelectorConfig) -> None: """Instantiate a selector.""" super().__init__(config) + self.allowed_context_keys = { + # Filters the available attributes based on the selected entity + "filter_entity": {"entity"} + } def __call__(self, data: Any) -> str: """Validate the passed selection.""" @@ -1354,11 +1354,6 @@ class MediaSelector(Selector[MediaSelectorConfig]): selector_type = "media" - allowed_context_keys = { - # Filters the available media based on the selected entity - "filter_entity": {EntitySelector.selector_type} - } - CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("accept"): [str], @@ -1381,6 +1376,10 @@ class MediaSelector(Selector[MediaSelectorConfig]): def __init__(self, config: MediaSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) + self.allowed_context_keys = { + # Filters the available media based on the selected entity + "filter_entity": {EntitySelector.selector_type} + } def __call__(self, data: Any) -> dict[str, Any] | list[dict[str, Any]]: """Validate the passed selection.""" @@ -2035,15 +2034,6 @@ class StateSelector(Selector[StateSelectorConfig]): selector_type = "state" - allowed_context_keys = { - # Filters the available states based on the selected entity - "filter_entity": {EntitySelector.selector_type}, - # Filters the available states based on the selected target - "filter_target": {"target"}, - # Only show the attribute values of a specific attribute - "filter_attribute": {AttributeSelector.selector_type}, - } - CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("entity_id"): cv.entity_id, @@ -2056,6 +2046,14 @@ class StateSelector(Selector[StateSelectorConfig]): def __init__(self, config: StateSelectorConfig) -> None: """Instantiate a selector.""" super().__init__(config) + self.allowed_context_keys = { + # Filters the available states based on the selected entity + "filter_entity": {EntitySelector.selector_type}, + # Filters the available states based on the selected target + "filter_target": {"target"}, + # Only show the attribute values of a specific attribute + "filter_attribute": {AttributeSelector.selector_type}, + } def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index cb49f9e1705769..de846a75496f65 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -44,6 +44,30 @@ def test_invalid_base_schema(schema) -> None: selector.validate_selector(schema) +def test_allowed_context_keys_not_shared_between_instances() -> None: + """Test allowed_context_keys is isolated between selector instances.""" + + class TestSelectorConfig(selector.BaseSelectorConfig, total=False): + """Test selector config class.""" + + class TestSelector(selector.Selector): + """Test selector used to verify instance isolation.""" + + CONFIG_SCHEMA = selector.make_selector_config_schema({}) + + selector_type = "test" + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + return data + + test_selector = TestSelector(TestSelectorConfig()) + other_selector = TestSelector(TestSelectorConfig()) + test_selector.allowed_context_keys["some_key"] = set() + assert test_selector.allowed_context_keys + assert not other_selector.allowed_context_keys + + def _test_selector( selector_type: str, schema: dict | None, From 33ca06362586db3b58a9258f55c5d29e72a3113b Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Mon, 7 Sep 2026 11:49:05 -0700 Subject: [PATCH 11/16] Bump aioskybell to 23.12.0 (#181587) --- homeassistant/components/skybell/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/skybell/conftest.py | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/skybell/manifest.json b/homeassistant/components/skybell/manifest.json index ad71e6804d9143..04a546310138e2 100644 --- a/homeassistant/components/skybell/manifest.json +++ b/homeassistant/components/skybell/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aioskybell"], - "requirements": ["aioskybell==22.7.0"] + "requirements": ["aioskybell==23.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 87823356d69433..adfce24a468b86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -432,7 +432,7 @@ aioruuvigateway==0.1.0 aioshelly==13.32.0 # homeassistant.components.skybell -aioskybell==22.7.0 +aioskybell==23.12.0 # homeassistant.components.slimproto aioslimproto==3.0.0 diff --git a/tests/components/skybell/conftest.py b/tests/components/skybell/conftest.py index bd553be908da0d..3cc14e8dd67426 100644 --- a/tests/components/skybell/conftest.py +++ b/tests/components/skybell/conftest.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.skybell.const import DOMAIN -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -60,34 +60,42 @@ async def set_aioclient_responses( aioclient_mock.get( f"{BASE_URL}devices/{DEVICE_ID}/info/", text=await async_load_fixture(hass, "device_info.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, ) aioclient_mock.get( f"{BASE_URL}devices/{DEVICE_ID}/settings/", text=await async_load_fixture(hass, "device_settings.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, ) aioclient_mock.get( f"{BASE_URL}devices/{DEVICE_ID}/activities/", text=await async_load_fixture(hass, "activities.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, ) aioclient_mock.get( f"{BASE_URL}devices/", text=await async_load_fixture(hass, "device.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, ) aioclient_mock.get( USERS_ME_URL, text=await async_load_fixture(hass, "me.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, ) aioclient_mock.post( f"{BASE_URL}login/", text=await async_load_fixture(hass, "login.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, ) aioclient_mock.get( f"{BASE_URL}devices/{DEVICE_ID}/activities/1234567890ab1234567890ac/video/", text=await async_load_fixture(hass, "video.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, ) aioclient_mock.get( f"{BASE_URL}devices/{DEVICE_ID}/avatar/", text=await async_load_fixture(hass, "avatar.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, ) aioclient_mock.get( f"https://v3-production-devices-avatar.s3.us-west-2.amazonaws.com/{DEVICE_ID}.jpg", From c9cdb6b969133f69ac77831401f6458d608a286b Mon Sep 17 00:00:00 2001 From: "Glenn Vandeuren (aka Iondependent)" Date: Mon, 7 Sep 2026 20:50:03 +0200 Subject: [PATCH 12/16] Bump nhc to 0.8.1 for Niko Home Control (#181584) --- homeassistant/components/niko_home_control/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index b86d83cb8d99a3..a4c4360ac95042 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.8.0"] + "requirements": ["nhc==0.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index adfce24a468b86..753e158d2af031 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1725,7 +1725,7 @@ nextcord==3.1.0 nextdns==5.0.1 # homeassistant.components.niko_home_control -nhc==0.8.0 +nhc==0.8.1 # homeassistant.components.nibe_heatpump nibe==2.24.0 From 0de08a70f156e5fa56a48ecce652d5a470cac29f Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 7 Sep 2026 21:13:31 +0200 Subject: [PATCH 13/16] Add configurable EnergyZero electricity price interval (#181530) --- .../components/energyzero/config_flow.py | 56 ++++++++- homeassistant/components/energyzero/const.py | 6 + .../components/energyzero/coordinator.py | 31 ++++- .../components/energyzero/diagnostics.py | 9 +- homeassistant/components/energyzero/sensor.py | 4 +- .../components/energyzero/strings.json | 17 +++ .../snapshots/test_diagnostics.ambr | 14 +-- .../components/energyzero/test_config_flow.py | 74 +++++++++++- tests/components/energyzero/test_init.py | 22 +++- tests/components/energyzero/test_interval.py | 109 ++++++++++++++++++ 10 files changed, 312 insertions(+), 30 deletions(-) create mode 100644 tests/components/energyzero/test_interval.py diff --git a/homeassistant/components/energyzero/config_flow.py b/homeassistant/components/energyzero/config_flow.py index 437ecf74ac3c30..0dd4368de9693e 100644 --- a/homeassistant/components/energyzero/config_flow.py +++ b/homeassistant/components/energyzero/config_flow.py @@ -2,9 +2,23 @@ from typing import Any, override -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +import voluptuous as vol -from .const import DOMAIN +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) +from homeassistant.core import callback +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig + +from .const import ( + CONF_ELECTRICITY_PRICE_INTERVAL, + DEFAULT_ELECTRICITY_PRICE_INTERVAL, + DOMAIN, + ELECTRICITY_INTERVALS, +) class EnergyZeroFlowHandler(ConfigFlow, domain=DOMAIN): @@ -12,6 +26,13 @@ class EnergyZeroFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + @staticmethod + @callback + @override + def async_get_options_flow(config_entry: ConfigEntry) -> EnergyZeroOptionsFlow: + """Return the options flow.""" + return EnergyZeroOptionsFlow() + @override async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -28,3 +49,34 @@ async def async_step_user( title="EnergyZero", data={}, ) + + +class EnergyZeroOptionsFlow(OptionsFlowWithReload): + """Manage EnergyZero options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the electricity price interval.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required( + CONF_ELECTRICITY_PRICE_INTERVAL, + default=DEFAULT_ELECTRICITY_PRICE_INTERVAL, + ): SelectSelector( + SelectSelectorConfig( + options=list(ELECTRICITY_INTERVALS), + translation_key=CONF_ELECTRICITY_PRICE_INTERVAL, + ) + ), + } + ), + self.config_entry.options, + ), + ) diff --git a/homeassistant/components/energyzero/const.py b/homeassistant/components/energyzero/const.py index 84c114d6779cc3..5d8018ad3707f1 100644 --- a/homeassistant/components/energyzero/const.py +++ b/homeassistant/components/energyzero/const.py @@ -4,6 +4,12 @@ import logging from typing import Final +from energyzero import Interval + +CONF_ELECTRICITY_PRICE_INTERVAL = "electricity_price_interval" +ELECTRICITY_INTERVALS = {"hourly": Interval.HOUR, "quarter_hourly": Interval.QUARTER} +DEFAULT_ELECTRICITY_PRICE_INTERVAL = "hourly" + DOMAIN: Final = "energyzero" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(minutes=10) diff --git a/homeassistant/components/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py index 783469c293fdb8..3110aa0c770d43 100644 --- a/homeassistant/components/energyzero/coordinator.py +++ b/homeassistant/components/energyzero/coordinator.py @@ -9,7 +9,6 @@ EnergyZero, EnergyZeroConnectionError, EnergyZeroNoDataError, - Interval, PriceType, ) @@ -19,7 +18,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR +from .const import ( + CONF_ELECTRICITY_PRICE_INTERVAL, + DEFAULT_ELECTRICITY_PRICE_INTERVAL, + DOMAIN, + ELECTRICITY_INTERVALS, + LOGGER, + SCAN_INTERVAL, + THRESHOLD_HOUR, +) type EnergyZeroConfigEntry = ConfigEntry[EnergyZeroDataUpdateCoordinator] @@ -30,6 +37,14 @@ class EnergyZeroData(NamedTuple): energy_today: EnergyPrices energy_tomorrow: EnergyPrices | None gas_today: EnergyPrices | None + electricity_price_step: timedelta + + @property + def next_energy_price(self) -> float | None: + """Return the electricity price one market period from now.""" + return self.energy_today.price_at_time( + self.energy_today.utcnow() + self.electricity_price_step + ) class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]): @@ -47,6 +62,13 @@ def __init__(self, hass: HomeAssistant, entry: EnergyZeroConfigEntry) -> None: config_entry=entry, ) + interval = entry.options.get( + CONF_ELECTRICITY_PRICE_INTERVAL, DEFAULT_ELECTRICITY_PRICE_INTERVAL + ) + self.electricity_interval = ELECTRICITY_INTERVALS[interval] + self.electricity_price_step = timedelta( + minutes=15 if interval == "quarter_hourly" else 60 + ) self.energyzero = EnergyZero(session=async_get_clientsession(hass)) @override @@ -61,7 +83,7 @@ async def _async_update_data(self) -> EnergyZeroData: energy_today = await self.energyzero.get_electricity_prices( start_date=today, end_date=today, - interval=Interval.HOUR, + interval=self.electricity_interval, price_type=PriceType.MARKET_WITH_VAT, local_tz=local_tz, ) @@ -81,7 +103,7 @@ async def _async_update_data(self) -> EnergyZeroData: energy_tomorrow = await self.energyzero.get_electricity_prices( start_date=tomorrow, end_date=tomorrow, - interval=Interval.HOUR, + interval=self.electricity_interval, price_type=PriceType.MARKET_WITH_VAT, local_tz=local_tz, ) @@ -95,4 +117,5 @@ async def _async_update_data(self) -> EnergyZeroData: energy_today=energy_today, energy_tomorrow=energy_tomorrow, gas_today=gas_today, + electricity_price_step=self.electricity_price_step, ) diff --git a/homeassistant/components/energyzero/diagnostics.py b/homeassistant/components/energyzero/diagnostics.py index f45092dea437b9..0d57b8c81adffd 100644 --- a/homeassistant/components/energyzero/diagnostics.py +++ b/homeassistant/components/energyzero/diagnostics.py @@ -34,14 +34,9 @@ async def async_get_config_entry_diagnostics( energy_today = coordinator_data.energy_today return { - "entry": { - "title": entry.title, - }, "energy": { - "current_hour_price": energy_today.current_price, - "next_hour_price": energy_today.price_at_time( - energy_today.utcnow() + timedelta(hours=1) - ), + "current_price": energy_today.current_price, + "next_price": coordinator_data.next_energy_price, "average_price": energy_today.average_price, "max_price": energy_today.extreme_prices[1], "min_price": energy_today.extreme_prices[0], diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 1d65a43406566d..a9068378d93b0b 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -67,9 +67,7 @@ class EnergyZeroSensorEntityDescription(SensorEntityDescription): service_type="today_energy", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", suggested_display_precision=3, - value_fn=lambda data: data.energy_today.price_at_time( - data.energy_today.utcnow() + timedelta(hours=1) - ), + value_fn=lambda data: data.next_energy_price, ), EnergyZeroSensorEntityDescription( key="average_price", diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index 8232cc62e01d4a..6ba6faf273f484 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -57,6 +57,23 @@ "message": "No price data available for {date}." } }, + "options": { + "step": { + "init": { + "data": { + "electricity_price_interval": "Electricity price interval" + } + } + } + }, + "selector": { + "electricity_price_interval": { + "options": { + "hourly": "Hourly", + "quarter_hourly": "Quarter-hourly" + } + } + }, "services": { "get_energy_prices": { "description": "Requests energy prices from EnergyZero.", diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr index 26d3533d84cac3..60f79ec56351ad 100644 --- a/tests/components/energyzero/snapshots/test_diagnostics.ambr +++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr @@ -3,18 +3,15 @@ dict({ 'energy': dict({ 'average_price': 0.14609224895833334, - 'current_hour_price': 0.17191075, + 'current_price': 0.17191075, 'highest_price_time': '2026-04-10T18:00:00+00:00', 'hours_priced_equal_or_lower': 20, 'lowest_price_time': '2026-04-11T06:00:00+00:00', 'max_price': 0.288152425, 'min_price': 0.077503525, - 'next_hour_price': 0.1521212, + 'next_price': 0.1521212, 'percentage_of_max': 59.66, }), - 'entry': dict({ - 'title': 'energy', - }), 'gas': dict({ 'current_hour_price': None, 'next_hour_price': None, @@ -25,18 +22,15 @@ dict({ 'energy': dict({ 'average_price': 0.14609224895833334, - 'current_hour_price': 0.17191075, + 'current_price': 0.17191075, 'highest_price_time': '2026-04-10T18:00:00+00:00', 'hours_priced_equal_or_lower': 20, 'lowest_price_time': '2026-04-11T06:00:00+00:00', 'max_price': 0.288152425, 'min_price': 0.077503525, - 'next_hour_price': 0.1521212, + 'next_price': 0.1521212, 'percentage_of_max': 59.66, }), - 'entry': dict({ - 'title': 'energy', - }), 'gas': dict({ 'current_hour_price': None, 'next_hour_price': None, diff --git a/tests/components/energyzero/test_config_flow.py b/tests/components/energyzero/test_config_flow.py index 09884ff4cf6505..c8ed98b5082b40 100644 --- a/tests/components/energyzero/test_config_flow.py +++ b/tests/components/energyzero/test_config_flow.py @@ -1,8 +1,14 @@ """Test the EnergyZero config flow.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch -from homeassistant.components.energyzero.const import DOMAIN +import pytest + +from homeassistant.components.energyzero.const import ( + CONF_ELECTRICITY_PRICE_INTERVAL, + DOMAIN, + ELECTRICITY_INTERVALS, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -48,3 +54,67 @@ async def test_single_instance( assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "single_instance_allowed" + + +@pytest.mark.freeze_time("2026-04-10 20:32:59") +@pytest.mark.parametrize("initial", [None, "hourly", "quarter_hourly"]) +@pytest.mark.parametrize("selected", ["hourly", "quarter_hourly"]) +@pytest.mark.usefixtures("mock_energyzero") +async def test_options_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + initial: str | None, + selected: str, +) -> None: + """Test defaults, saved options and automatic reload on changes.""" + options = {} if initial is None else {CONF_ELECTRICITY_PRICE_INTERVAL: initial} + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry(mock_config_entry, options=options) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + schema = result["data_schema"] + assert schema({}) == {CONF_ELECTRICITY_PRICE_INTERVAL: "hourly"} + key = next(iter(schema.schema)) + assert (key.description or {}).get("suggested_value", "hourly") == ( + initial or "hourly" + ) + + with patch.object(hass.config_entries, "async_reload", return_value=True) as reload: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_ELECTRICITY_PRICE_INTERVAL: selected}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config_entry.options == {CONF_ELECTRICITY_PRICE_INTERVAL: selected} + assert reload.call_count == (initial != selected) + + +@pytest.mark.freeze_time("2026-04-10 20:32:59") +@pytest.mark.parametrize("selected", ["hourly", "quarter_hourly"]) +async def test_options_reload( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_energyzero: MagicMock, + selected: str, +) -> None: + """Apply changed options to both requests without recreating entities.""" + original_coordinator = init_integration.runtime_data + original_entities = set(hass.states.async_entity_ids("sensor")) + mock_energyzero.get_electricity_prices.reset_mock() + result = await hass.config_entries.options.async_init(init_integration.entry_id) + await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_ELECTRICITY_PRICE_INTERVAL: selected} + ) + await hass.async_block_till_done() + assert init_integration.runtime_data is not original_coordinator + assert set(hass.states.async_entity_ids("sensor")) == original_entities + assert mock_energyzero.get_electricity_prices.await_count == 2 + assert all( + request.kwargs["interval"] == ELECTRICITY_INTERVALS[selected] + for request in mock_energyzero.get_electricity_prices.await_args_list + ) diff --git a/tests/components/energyzero/test_init.py b/tests/components/energyzero/test_init.py index 03b23c63470107..e97263cd4e6a36 100644 --- a/tests/components/energyzero/test_init.py +++ b/tests/components/energyzero/test_init.py @@ -7,20 +7,38 @@ from energyzero import EnergyZeroConnectionError, Interval, PriceType import pytest +from homeassistant.components.energyzero.const import CONF_ELECTRICITY_PRICE_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +@pytest.mark.parametrize( + ("options", "interval"), + [ + pytest.param({}, Interval.HOUR, id="existing"), + pytest.param( + {CONF_ELECTRICITY_PRICE_INTERVAL: "hourly"}, Interval.HOUR, id="hourly" + ), + pytest.param( + {CONF_ELECTRICITY_PRICE_INTERVAL: "quarter_hourly"}, + Interval.QUARTER, + id="quarter_hourly", + ), + ], +) @pytest.mark.freeze_time("2026-04-10 20:32:59") async def test_coordinator_requests_market_prices_with_vat( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_energyzero: MagicMock, + options: dict[str, str], + interval: Interval, ) -> None: """Test the coordinator requests the backwards-compatible price stream.""" mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry(mock_config_entry, options=options) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -32,14 +50,14 @@ async def test_coordinator_requests_market_prices_with_vat( call( start_date=today, end_date=today, - interval=Interval.HOUR, + interval=interval, price_type=PriceType.MARKET_WITH_VAT, local_tz=local_tz, ), call( start_date=tomorrow, end_date=tomorrow, - interval=Interval.HOUR, + interval=interval, price_type=PriceType.MARKET_WITH_VAT, local_tz=local_tz, ), diff --git a/tests/components/energyzero/test_interval.py b/tests/components/energyzero/test_interval.py new file mode 100644 index 00000000000000..a4f22e12948256 --- /dev/null +++ b/tests/components/energyzero/test_interval.py @@ -0,0 +1,109 @@ +"""Test electricity resolution with timezone-aware price data.""" + +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock +from zoneinfo import ZoneInfo + +from energyzero import EnergyPrices, EnergyZeroNoDataError, Interval +from energyzero.models import TimeRange +import pytest + +from homeassistant.components.energyzero.const import CONF_ELECTRICITY_PRICE_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("selected", "minutes", "interval"), + [("hourly", 60, Interval.HOUR), ("quarter_hourly", 15, Interval.QUARTER)], +) +@pytest.mark.parametrize("missing_tomorrow", [False, True]) +@pytest.mark.parametrize( + ("hours", "requests_tomorrow"), + [ + pytest.param( + 24, True, marks=pytest.mark.freeze_time("2026-04-10 20:32:59"), id="normal" + ), + pytest.param( + 23, False, marks=pytest.mark.freeze_time("2026-03-29 00:55:00"), id="spring" + ), + pytest.param( + 25, False, marks=pytest.mark.freeze_time("2026-10-25 00:55:00"), id="autumn" + ), + ], +) +async def test_electricity_interval( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_energyzero: MagicMock, + entity_registry: er.EntityRegistry, + selected: str, + minutes: int, + interval: Interval, + missing_tomorrow: bool, + hours: int, + requests_tomorrow: bool, +) -> None: + """Keep all periods on DST days and use the selected next-price step.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") + today = dt_util.now().date() + start = datetime.combine( + today, datetime.min.time(), ZoneInfo("Europe/Amsterdam") + ).astimezone(UTC) + step = timedelta(minutes=minutes) + prices = EnergyPrices( + prices={ + TimeRange(start + index * step, start + (index + 1) * step): float( + index + 1 + ) + for index in range(hours * 60 // minutes) + }, + average_price=(hours * 60 // minutes + 1) / 2, + ) + mock_energyzero.get_electricity_prices.side_effect = [ + prices, + EnergyZeroNoDataError() if missing_tomorrow else prices, + ] + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ELECTRICITY_PRICE_INTERVAL: selected} + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + expected = prices.price_at_time(dt_util.utcnow() + step) + assert expected != prices.current_price + assert (state := hass.states.get("sensor.energyzero_today_energy_next_hour_price")) + assert state.state == str(expected) + data = mock_config_entry.runtime_data.data + assert (data.energy_tomorrow is not None) == ( + requests_tomorrow and not missing_tomorrow + ) + assert len(data.energy_today.prices) == hours * 60 // minutes + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert diagnostics["energy"]["next_price"] == expected + assert diagnostics["energy"]["current_price"] == prices.current_price + assert diagnostics["energy"]["average_price"] == prices.average_price + assert ( + diagnostics["energy"]["hours_priced_equal_or_lower"] + == prices.time_ranges_priced_equal_or_lower + ) + assert ( + mock_energyzero.get_electricity_prices.call_args.kwargs["interval"] == interval + ) + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entries) == 11 + assert all( + entry.unique_id == f"12345_{entry.entity_id.removeprefix('sensor.energyzero_')}" + for entry in entries + ) From 574667e406dc4a04a44a62e49d049598810a4aef Mon Sep 17 00:00:00 2001 From: Matthew Hadley Date: Mon, 7 Sep 2026 14:17:02 -0500 Subject: [PATCH 14/16] Add missing LitterHopper statuses to Whisker hopper status sensor (#181342) --- .../components/litterrobot/icons.json | 6 ++++- .../components/litterrobot/sensor.py | 10 ++++--- .../components/litterrobot/strings.json | 6 ++++- tests/components/litterrobot/conftest.py | 17 ++++++++++++ tests/components/litterrobot/test_sensor.py | 26 ++++++++++++++++--- 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index 231daf2113b33b..484568be55ecbd 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -75,9 +75,13 @@ "disabled": "mdi:filter-remove", "empty": "mdi:filter-minus-outline", "enabled": "mdi:filter-check", + "jammed": "mdi:filter-off", + "litter_low": "mdi:filter-minus", "motor_disconnected": "mdi:engine-off", "motor_fault_short": "mdi:flash-off", - "motor_ot_amps": "mdi:flash-alert" + "motor_ot_amps": "mdi:flash-alert", + "offline": "mdi:cloud-off", + "ready": "mdi:filter-check-outline" } }, "next_filter_replacement": { diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index ead39b1fd622e8..a49fc65b1ab822 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -128,8 +128,8 @@ class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEnti value_fn=lambda robot: robot.cycle_count, ), ], - LitterRobot4: [ - RobotSensorEntityDescription[LitterRobot4]( + (LitterRobot4, LitterRobot5): [ + RobotSensorEntityDescription[LitterRobot4 | LitterRobot5]( key="hopper_status", translation_key="hopper_status", device_class=SensorDeviceClass.ENUM, @@ -140,6 +140,10 @@ class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEnti "motor_ot_amps", "motor_disconnected", "empty", + "litter_low", + "ready", + "jammed", + "offline", ], value_fn=( lambda robot: ( @@ -147,8 +151,6 @@ class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEnti ) ), ), - ], - (LitterRobot4, LitterRobot5): [ RobotSensorEntityDescription[LitterRobot4 | LitterRobot5]( key="litter_level", translation_key="litter_level", diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 0aaf1243504144..9b0774d0afc74e 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -127,9 +127,13 @@ "disabled": "[%key:common::state::disabled%]", "empty": "[%key:common::state::empty%]", "enabled": "[%key:common::state::enabled%]", + "jammed": "Jammed", + "litter_low": "Litter low", "motor_disconnected": "Motor disconnected", "motor_fault_short": "Motor shorted", - "motor_ot_amps": "Motor overtorqued" + "motor_ot_amps": "Motor overtorqued", + "offline": "[%key:component::litterrobot::entity::sensor::status_code::state::offline%]", + "ready": "[%key:component::litterrobot::entity::sensor::status_code::state::rdy%]" } }, "last_feeding": { diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index ffa80a5a3186b9..fc75ca37428733 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -154,6 +154,23 @@ def mock_account_with_litterhopper() -> MagicMock: ) +@pytest.fixture +def mock_account_with_litterhopper_5() -> MagicMock: + """Mock account with LitterHopper attached to Litter-Robot 5.""" + return create_mock_account( + robot_data={ + "state": { + **ROBOT_5_DATA["state"], + "hopperStatusIndicator": { + "title": "Litter low", + "value": HopperStatus.LITTER_LOW.value, + }, + } + }, + v5=True, + ) + + @pytest.fixture def mock_account_with_feederrobot() -> MagicMock: """Mock account with Feeder-Robot.""" diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index 354431531cccee..f6c7fb18dacaf5 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -2,10 +2,12 @@ from unittest.mock import MagicMock +from pylitterbot.robot.litterrobot4 import HopperStatus import pytest from homeassistant.components.litterrobot.sensor import icon_for_gauge_level from homeassistant.components.sensor import ( + ATTR_OPTIONS, DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, @@ -147,13 +149,31 @@ async def test_pet_visits_today_sensor( assert sensor.state == "2" +@pytest.mark.parametrize( + ("account_fixture", "expected_state"), + [ + pytest.param("mock_account_with_litterhopper", "enabled", id="litter_robot_4"), + pytest.param( + "mock_account_with_litterhopper_5", "litter_low", id="litter_robot_5" + ), + ], +) async def test_litterhopper_sensor( - hass: HomeAssistant, mock_account_with_litterhopper: MagicMock + hass: HomeAssistant, + request: pytest.FixtureRequest, + account_fixture: str, + expected_state: str, ) -> None: """Tests LitterHopper sensors.""" - await setup_integration(hass, mock_account_with_litterhopper, SENSOR_DOMAIN) + await setup_integration( + hass, request.getfixturevalue(account_fixture), SENSOR_DOMAIN + ) sensor = hass.states.get("sensor.test_hopper_status") - assert sensor.state == "enabled" + assert sensor.state == expected_state + # a status the library can report but the sensor does not declare is invalid + assert {status.name.lower() for status in HopperStatus} <= set( + sensor.attributes[ATTR_OPTIONS] + ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") From 7f8f9517b892a3bd94c9cbf17b390eda21dd8eed Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 7 Sep 2026 22:06:51 +0200 Subject: [PATCH 15/16] Bump energyzero to 5.1.0 (#181597) --- homeassistant/components/energyzero/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 7fd3d8f83870d3..60c553c1e927a9 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["energyzero==5.0.2"], + "requirements": ["energyzero==5.1.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 753e158d2af031..62195ebeb401a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -950,7 +950,7 @@ energyflip-client==0.2.2 energyid-webhooks==0.0.14 # homeassistant.components.energyzero -energyzero==5.0.2 +energyzero==5.1.0 # homeassistant.components.enocean enocean-async==0.4.2 From e85b8a256e3b8e402eb862333cdf5738477ea8c3 Mon Sep 17 00:00:00 2001 From: Johan Henkens Date: Mon, 7 Sep 2026 13:18:11 -0700 Subject: [PATCH 16/16] Add unit_of_measurement to climate and water_heater in ESPHome integration (#168747) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- homeassistant/components/esphome/climate.py | 4 +- homeassistant/components/esphome/const.py | 9 +- homeassistant/components/esphome/entity.py | 26 +++- .../components/esphome/water_heater.py | 5 +- tests/components/esphome/test_climate.py | 124 +++++++++++++++++ tests/components/esphome/test_entity.py | 7 + tests/components/esphome/test_water_heater.py | 126 ++++++++++++++++++ 7 files changed, 294 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 738ddad59eb6ba..74cea9151059a2 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -51,7 +51,6 @@ PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, - UnitOfTemperature, ) from homeassistant.core import callback from homeassistant.exceptions import ServiceValidationError @@ -62,6 +61,7 @@ convert_api_error_ha_error, esphome_float_state_property, esphome_state_property, + get_temperature_unit, platform_async_setup_entry, ) from .enum_mapper import EsphomeEnumMapper @@ -131,7 +131,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEntity): """A climate implementation for ESPHome.""" - _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "climate" _feature_flags = ClimateFeature(0) @@ -144,6 +143,7 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None: self._feature_flags = ClimateFeature( static_info.supported_feature_flags_compat(self._api_version) ) + self._attr_temperature_unit = get_temperature_unit(static_info) self._attr_precision = self._get_precision() self._attr_hvac_modes = [ _CLIMATE_MODES.from_esphome(mode) for mode in static_info.supported_modes diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 508065b091c810..c419ee9a9e620a 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -2,10 +2,11 @@ from typing import TYPE_CHECKING, Final +from aioesphomeapi import TemperatureUnit from awesomeversion import AwesomeVersion from homeassistant.components.bluetooth import BluetoothScanningMode -from homeassistant.const import __version__ as ha_version +from homeassistant.const import UnitOfTemperature, __version__ as ha_version from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: @@ -43,3 +44,9 @@ WAKE_WORDS_DIR_NAME = "custom_wake_words" WAKE_WORDS_API_PATH = "/api/esphome/wake_words" + +TEMPERATURE_UNIT_MAP: dict[TemperatureUnit, UnitOfTemperature] = { + TemperatureUnit.CELSIUS: UnitOfTemperature.CELSIUS, + TemperatureUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, + TemperatureUnit.KELVIN: UnitOfTemperature.KELVIN, +} diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index b1c0c92c6e9870..3392556d9b166e 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -8,15 +8,17 @@ from aioesphomeapi import ( APIConnectionError, + ClimateInfo, DeviceInfo as EsphomeDeviceInfo, EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, + WaterHeaterInfo, build_device_unique_id, ) import voluptuous as vol -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( @@ -29,7 +31,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, TEMPERATURE_UNIT_MAP # Import config flow so that it's added to the registry from .entry_data import ( @@ -42,6 +44,26 @@ _LOGGER = logging.getLogger(__name__) + +def get_temperature_unit( + static_info: ClimateInfo | WaterHeaterInfo, +) -> UnitOfTemperature: + """Return the HA temperature unit for the given ESPHome static info.""" + temperature_unit = static_info.temperature_unit + if ( + temperature_unit is not None + and (ha_unit := TEMPERATURE_UNIT_MAP.get(temperature_unit)) is not None + ): + return ha_unit + _LOGGER.warning( + "%s (device_id=%s): Unrecognized ESPHome temperature unit %r, defaulting to Celsius", + static_info.name, + static_info.device_id, + temperature_unit, + ) + return UnitOfTemperature.CELSIUS + + _InfoT = TypeVar("_InfoT", bound=EntityInfo) _EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") _StateT = TypeVar("_StateT", bound=EntityState) diff --git a/homeassistant/components/esphome/water_heater.py b/homeassistant/components/esphome/water_heater.py index 12a84140569154..d46c82244f922d 100644 --- a/homeassistant/components/esphome/water_heater.py +++ b/homeassistant/components/esphome/water_heater.py @@ -16,7 +16,7 @@ WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS from homeassistant.core import callback from .entity import ( @@ -24,6 +24,7 @@ convert_api_error_ha_error, esphome_float_state_property, esphome_state_property, + get_temperature_unit, platform_async_setup_entry, ) from .enum_mapper import EsphomeEnumMapper @@ -49,7 +50,6 @@ class EsphomeWaterHeater( ): """A water heater implementation for ESPHome.""" - _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_precision = PRECISION_TENTHS @callback @@ -58,6 +58,7 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None: """Set attrs from static info.""" super()._on_static_info_update(static_info) static_info = self._static_info + self._attr_temperature_unit = get_temperature_unit(static_info) self._attr_min_temp = static_info.min_temperature self._attr_max_temp = static_info.max_temperature self._attr_target_temperature_step = static_info.target_temperature_step diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 94b10a13377b0e..df938a04e5b17f 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -13,6 +13,7 @@ ClimatePreset, ClimateState, ClimateSwingMode, + TemperatureUnit, ) import pytest from syrupy.assertion import SnapshotAssertion @@ -24,7 +25,9 @@ ATTR_HUMIDITY, ATTR_HVAC_MODE, ATTR_MAX_HUMIDITY, + ATTR_MAX_TEMP, ATTR_MIN_HUMIDITY, + ATTR_MIN_TEMP, ATTR_PRESET_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, @@ -44,6 +47,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .conftest import MockGenericDeviceEntryType @@ -862,3 +866,123 @@ async def test_climate_entity_requires_two_point_keeps_range( assert ATTR_TEMPERATURE not in state.attributes assert state.attributes[ATTR_TARGET_TEMP_LOW] == 3.5 assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 4.0 + + +@pytest.mark.parametrize( + ("temperature_unit", "expected_temperature"), + [ + pytest.param(TemperatureUnit.CELSIUS, 22.0, id="celsius"), + pytest.param(TemperatureUnit.FAHRENHEIT, -5.6, id="fahrenheit"), + pytest.param(TemperatureUnit.KELVIN, -251.1, id="kelvin"), + pytest.param(None, 22.0, id="none_falls_back_to_celsius"), + ], +) +async def test_climate_entity_temperature_unit( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, + temperature_unit: TemperatureUnit | int | None, + expected_temperature: float, +) -> None: + """Test that the temperature unit is passed through correctly.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + temperature_unit=temperature_unit, + ) + ] + states = [ClimateState(key=1, mode=ClimateMode.COOL, target_temperature=22)] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + assert state.attributes[ATTR_TEMPERATURE] == expected_temperature + + +async def test_climate_entity_fahrenheit_unit( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test that a Fahrenheit climate entity converts temperatures correctly.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + temperature_unit=TemperatureUnit.FAHRENHEIT, + visual_min_temperature=32.0, + visual_max_temperature=212.0, + ) + ] + states = [ClimateState(key=1, mode=ClimateMode.COOL, target_temperature=32.0)] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + # 32 °F and 212 °F displayed in the HA system unit (°C) + assert state.attributes[ATTR_MIN_TEMP] == 0.0 + assert state.attributes[ATTR_MAX_TEMP] == 100.0 + # 32 °F target displayed in °C + assert state.attributes[ATTR_TEMPERATURE] == 0.0 + + # set_temperature is called in °C; ESPHome must receive the °F equivalent + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 10}, + blocking=True, + ) + mock_client.climate_command.assert_called_once_with( + key=1, target_temperature=50.0, device_id=0 + ) + + +async def test_climate_entity_fahrenheit_unit_fahrenheit_system( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a Fahrenheit climate entity under a Fahrenheit HA system passes through unchanged.""" + hass.config.units = US_CUSTOMARY_SYSTEM + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + temperature_unit=TemperatureUnit.FAHRENHEIT, + visual_min_temperature=32.0, + visual_max_temperature=212.0, + ) + ] + states = [ClimateState(key=1, mode=ClimateMode.COOL, target_temperature=72.0)] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + # No conversion — device and system are both °F + assert state.attributes[ATTR_MIN_TEMP] == 32.0 + assert state.attributes[ATTR_MAX_TEMP] == 212.0 + assert state.attributes[ATTR_TEMPERATURE] == 72.0 + + # set_temperature is called in °F; ESPHome must receive the same value + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 86}, + blocking=True, + ) + mock_client.climate_command.assert_called_once_with( + key=1, target_temperature=86.0, device_id=0 + ) diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index ef7ced51395442..ab605d339a906a 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -14,12 +14,14 @@ SensorInfo, SensorState, SubDeviceInfo, + TemperatureUnit, build_device_unique_id, build_unique_id, ) import pytest from homeassistant.components.esphome import DOMAIN +from homeassistant.components.esphome.const import TEMPERATURE_UNIT_MAP from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, @@ -3481,3 +3483,8 @@ async def test_mover_does_not_adopt_other_movers_state( assert hass.states.get("binary_sensor.test_sensor_one").state == STATE_UNKNOWN assert hass.states.get("binary_sensor.test_sensor_two").state == STATE_UNKNOWN + + +def test_temperature_unit_map_covers_all_units() -> None: + """Every aioesphomeapi TemperatureUnit must be mapped.""" + assert set(TEMPERATURE_UNIT_MAP) == set(TemperatureUnit) diff --git a/tests/components/esphome/test_water_heater.py b/tests/components/esphome/test_water_heater.py index 2263ce07074d94..0e304432912cd4 100644 --- a/tests/components/esphome/test_water_heater.py +++ b/tests/components/esphome/test_water_heater.py @@ -4,6 +4,7 @@ from aioesphomeapi import ( APIClient, + TemperatureUnit, WaterHeaterFeature, WaterHeaterInfo, WaterHeaterMode, @@ -14,6 +15,8 @@ from homeassistant.components.water_heater import ( ATTR_AWAY_MODE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, ATTR_OPERATION_LIST, DOMAIN as WATER_HEATER_DOMAIN, SERVICE_SET_AWAY_MODE, @@ -29,6 +32,7 @@ ATTR_TEMPERATURE, ) from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .conftest import MockGenericDeviceEntryType @@ -462,3 +466,125 @@ async def test_water_heater_set_away_mode( mock_client.water_heater_command.assert_has_calls( [call(key=1, away=away_mode, device_id=0)] ) + + +@pytest.mark.parametrize( + ("temperature_unit", "expected_temperature"), + [ + pytest.param(TemperatureUnit.CELSIUS, 50.0, id="celsius"), + pytest.param(TemperatureUnit.FAHRENHEIT, 10.0, id="fahrenheit"), + pytest.param(TemperatureUnit.KELVIN, -223.1, id="kelvin"), + pytest.param(None, 50.0, id="none_falls_back_to_celsius"), + ], +) +async def test_water_heater_temperature_unit( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, + temperature_unit: TemperatureUnit | int | None, + expected_temperature: float, +) -> None: + """Test that the temperature unit is passed through correctly.""" + entity_info = [ + WaterHeaterInfo( + object_id="my_boiler", + key=1, + name="My Boiler", + min_temperature=10.0, + max_temperature=85.0, + temperature_unit=temperature_unit, + ) + ] + states = [WaterHeaterState(key=1, target_temperature=50.0)] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("water_heater.test_my_boiler") + assert state is not None + assert state.attributes[ATTR_TEMPERATURE] == expected_temperature + + +async def test_water_heater_fahrenheit_unit( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test that a Fahrenheit water heater converts temperatures correctly.""" + entity_info = [ + WaterHeaterInfo( + object_id="my_boiler", + key=1, + name="My Boiler", + min_temperature=32.0, + max_temperature=212.0, + temperature_unit=TemperatureUnit.FAHRENHEIT, + ) + ] + states = [WaterHeaterState(key=1, target_temperature=32.0)] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("water_heater.test_my_boiler") + assert state is not None + # 32 °F and 212 °F displayed in the HA system unit (°C) + assert state.attributes[ATTR_MIN_TEMP] == 0.0 + assert state.attributes[ATTR_MAX_TEMP] == 100.0 + # 32 °F target displayed in °C + assert state.attributes[ATTR_TEMPERATURE] == 0.0 + + # set_temperature is called in °C; ESPHome must receive the °F equivalent + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "water_heater.test_my_boiler", ATTR_TEMPERATURE: 10}, + blocking=True, + ) + mock_client.water_heater_command.assert_called_once_with( + key=1, target_temperature=50.0, device_id=0 + ) + + +async def test_water_heater_fahrenheit_unit_fahrenheit_system( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a Fahrenheit water heater under a Fahrenheit HA system passes through unchanged.""" + hass.config.units = US_CUSTOMARY_SYSTEM + entity_info = [ + WaterHeaterInfo( + object_id="my_boiler", + key=1, + name="My Boiler", + min_temperature=32.0, + max_temperature=212.0, + temperature_unit=TemperatureUnit.FAHRENHEIT, + ) + ] + states = [WaterHeaterState(key=1, target_temperature=72.0)] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("water_heater.test_my_boiler") + assert state is not None + # No conversion — device and system are both °F + assert state.attributes[ATTR_MIN_TEMP] == 32.0 + assert state.attributes[ATTR_MAX_TEMP] == 212.0 + assert state.attributes[ATTR_TEMPERATURE] == 72.0 + + # set_temperature is called in °F; ESPHome must receive the same value + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "water_heater.test_my_boiler", ATTR_TEMPERATURE: 86}, + blocking=True, + ) + mock_client.water_heater_command.assert_called_once_with( + key=1, target_temperature=86.0, device_id=0 + )