From 437e4e027c28d18102244140f62c949b21929edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sun, 5 Oct 2025 08:55:48 +0200 Subject: [PATCH 01/10] Bump Mill library (#153683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 4ae2ac8bbbfb62..40051aeb1e608f 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.13.1", "mill-local==0.3.0"] + "requirements": ["millheater==0.14.0", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index aaf824154230a7..45cd6c53d0b173 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1458,7 +1458,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.13.1 +millheater==0.14.0 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f87b7d6729742..f3565ef7da5846 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1253,7 +1253,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.13.1 +millheater==0.14.0 # homeassistant.components.minio minio==7.1.12 From 4a1d00e59a0147d6efbc214b0ad0c5dd6151aaab Mon Sep 17 00:00:00 2001 From: cdnninja Date: Sun, 5 Oct 2025 00:56:25 -0600 Subject: [PATCH 02/10] Bump pyvesync to 3.1.0 (#153693) --- homeassistant/components/vesync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vesync/conftest.py | 31 +++++++++++++++++-- .../vesync/snapshots/test_diagnostics.ambr | 2 -- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index 6ea7edd13d5163..8749dd956ff01b 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==3.0.0"] + "requirements": ["pyvesync==3.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 45cd6c53d0b173..b534561cf43bd5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2614,7 +2614,7 @@ pyvera==0.3.16 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==3.0.0 +pyvesync==3.1.0 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3565ef7da5846..a26b0ea13c06d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2175,7 +2175,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.16 # homeassistant.components.vesync -pyvesync==3.0.0 +pyvesync==3.1.0 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index faaefb2ed82c2d..8b15e7c76d3cd9 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -10,6 +10,7 @@ import pytest from pyvesync import VeSync +from pyvesync.auth import VeSyncAuth from pyvesync.base_devices.bulb_base import VeSyncBulb from pyvesync.base_devices.fan_base import VeSyncFanBase from pyvesync.base_devices.humidifier_base import HumidifierState @@ -51,15 +52,12 @@ def patch_vesync(): """Patch VeSync methods and several properties/attributes for all tests.""" props = { "enabled": True, - "token": "TEST_TOKEN", - "account_id": "TEST_ACCOUNT_ID", } with ( patch.multiple( "pyvesync.vesync.VeSync", check_firmware=AsyncMock(return_value=True), - login=AsyncMock(return_value=None), ), ExitStack() as stack, ): @@ -71,6 +69,33 @@ def patch_vesync(): yield +@pytest.fixture(autouse=True) +def patch_vesync_auth(): + """Patch VeSync Auth methods and several properties/attributes for all tests.""" + props = { + "_token": "TESTTOKEN", + "_account_id": "TESTACCOUNTID", + "_country_code": "US", + "_current_region": "US", + "_username": "TESTUSERNAME", + "_password": "TESTPASSWORD", + } + + with ( + patch.multiple( + "pyvesync.auth.VeSyncAuth", + login=AsyncMock(return_value=True), + ), + ExitStack() as stack, + ): + for name, value in props.items(): + mock = stack.enter_context( + patch.object(VeSyncAuth, name, new_callable=PropertyMock) + ) + mock.return_value = value + yield + + @pytest.fixture(name="config_entry") def config_entry_fixture(hass: HomeAssistant, config) -> ConfigEntry: """Create a mock VeSync config entry.""" diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 7b6c8a2899d847..3f01ce765b9334 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -53,7 +53,6 @@ 'device_status': 'on', 'device_type': 'Classic200S', 'display': 'Method', - 'displayJSON': 'Method', 'enabled': 'Method', 'features': list([ 'night_light', @@ -173,7 +172,6 @@ 'device_status': 'on', 'device_type': 'fan', 'display': 'Method', - 'displayJSON': 'Method', 'enabled': 'Method', 'fan_levels': 'Method', 'features': 'Method', From d9baad530ad232014e52a7e1564a06d9fe6728e1 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 5 Oct 2025 10:14:13 +0300 Subject: [PATCH 03/10] Shelly code quality and cleanup (#153692) --- homeassistant/components/shelly/climate.py | 22 ++++++---------------- homeassistant/components/shelly/entity.py | 14 +++----------- homeassistant/components/shelly/utils.py | 11 +---------- 3 files changed, 10 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 8918c5863cfef4..41a02741237ad9 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -333,8 +333,7 @@ async def set_state_full_path(self, **kwargs: Any) -> Any: async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if (current_temp := kwargs.get(ATTR_TEMPERATURE)) is None: - return + target_temp = kwargs[ATTR_TEMPERATURE] # Shelly TRV accepts target_t in Fahrenheit or Celsius, but you must # send the units that the device expects @@ -344,13 +343,13 @@ async def async_set_temperature(self, **kwargs: Any) -> None: ] LOGGER.debug("Themostat settings: %s", therm) if therm.get("target_t", {}).get("units", "C") == "F": - current_temp = TemperatureConverter.convert( - cast(float, current_temp), + target_temp = TemperatureConverter.convert( + cast(float, target_temp), UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT, ) - await self.set_state_full_path(target_t_enabled=1, target_t=f"{current_temp}") + await self.set_state_full_path(target_t_enabled=1, target_t=f"{target_temp}") async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" @@ -367,9 +366,6 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" - if not self._preset_modes: - return - preset_index = self._preset_modes.index(preset_mode) if preset_index == 0: @@ -523,12 +519,9 @@ def hvac_action(self) -> HVACAction: async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None: - return - await self.call_rpc( "Thermostat.SetConfig", - {"config": {"id": self._id, "target_C": target_temp}}, + {"config": {"id": self._id, "target_C": kwargs[ATTR_TEMPERATURE]}}, ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -589,9 +582,6 @@ def hvac_action(self) -> HVACAction: @rpc_call async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None: - return - await self.coordinator.device.blu_trv_set_target_temperature( - self._id, target_temp + self._id, kwargs[ATTR_TEMPERATURE] ) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index ebb2d8ca353abc..0e4a2b00742c9a 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -239,7 +239,7 @@ def async_restore_rpc_attribute_entities( sensors: Mapping[str, RpcEntityDescription], sensor_class: Callable, ) -> None: - """Restore block attributes entities.""" + """Restore RPC attributes entities.""" entities = [] ent_reg = er.async_get(hass) @@ -447,19 +447,11 @@ def _update_callback(self) -> None: self.async_write_ha_state() @rpc_call - async def call_rpc( - self, method: str, params: Any, timeout: float | None = None - ) -> Any: + async def call_rpc(self, method: str, params: Any) -> Any: """Call RPC method.""" LOGGER.debug( - "Call RPC for entity %s, method: %s, params: %s, timeout: %s", - self.name, - method, - params, - timeout, + "Call RPC for entity %s, method: %s, params: %s", self.name, method, params ) - if timeout: - return await self.coordinator.device.call_rpc(method, params, timeout) return await self.coordinator.device.call_rpc(method, params) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 0fcec2942614ec..a3ff2b0643b9cc 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -637,11 +637,6 @@ def async_remove_shelly_rpc_entities( entity_reg.async_remove(entity_id) -def is_rpc_thermostat_mode(ident: int, status: dict[str, Any]) -> bool: - """Return True if 'thermostat:' is present in the status.""" - return f"thermostat:{ident}" in status - - def get_virtual_component_ids(config: dict[str, Any], platform: str) -> list[str]: """Return a list of virtual component IDs for a platform.""" component = VIRTUAL_COMPONENTS_MAP.get(platform) @@ -694,11 +689,7 @@ def async_remove_orphaned_entities( entity_reg = er.async_get(hass) device_reg = dr.async_get(hass) - if not ( - devices := device_reg.devices.get_devices_for_config_entry_id(config_entry_id) - ): - return - + devices = device_reg.devices.get_devices_for_config_entry_id(config_entry_id) for device in devices: entities = er.async_entries_for_device(entity_reg, device.id, True) for entity in entities: From 1629dad1a89568dca10796803c7e5336a12bfeac Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 5 Oct 2025 00:25:45 -0700 Subject: [PATCH 04/10] Bump opower to 0.15.6 (#153714) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index cd24da920870fe..5d95acaa7792e5 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "bronze", - "requirements": ["opower==0.15.5"] + "requirements": ["opower==0.15.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index b534561cf43bd5..0c19fd859be2ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1652,7 +1652,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.15.5 +opower==0.15.6 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a26b0ea13c06d2..4ae5ce859a4019 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1411,7 +1411,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.15.5 +opower==0.15.6 # homeassistant.components.oralb oralb-ble==0.17.6 From e8e0eabb9931a82482abdcff3098c91da2e56ca9 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 5 Oct 2025 00:35:50 -0700 Subject: [PATCH 05/10] Double max retries in Google Drive (#153717) --- homeassistant/components/google_drive/api.py | 2 ++ tests/components/google_drive/snapshots/test_backup.ambr | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py index c21d42e0f3a314..2a96b5e09a07ff 100644 --- a/homeassistant/components/google_drive/api.py +++ b/homeassistant/components/google_drive/api.py @@ -22,6 +22,7 @@ from homeassistant.helpers import config_entry_oauth2_flow _UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600 +_UPLOAD_MAX_RETRIES = 20 _LOGGER = logging.getLogger(__name__) @@ -150,6 +151,7 @@ async def async_upload_backup( backup_metadata, open_stream, backup.size, + max_retries=_UPLOAD_MAX_RETRIES, timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT), ) _LOGGER.debug( diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr index 891eb0e1cbe923..55791e385f8b78 100644 --- a/tests/components/google_drive/snapshots/test_backup.ambr +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -154,6 +154,7 @@ 987, ), dict({ + 'max_retries': 20, 'timeout': dict({ 'ceil_threshold': 5, 'connect': None, @@ -226,6 +227,7 @@ 987, ), dict({ + 'max_retries': 20, 'timeout': dict({ 'ceil_threshold': 5, 'connect': None, From 31fe0322aba3949c5e77029f92288e5bc3bd21cf Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 5 Oct 2025 00:47:46 -0700 Subject: [PATCH 06/10] Clarify description for media player entity in Google Assistant SDK (#153715) --- homeassistant/components/google_assistant_sdk/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 2ebd04db4b6118..5831db9a0e3159 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -59,7 +59,7 @@ }, "media_player": { "name": "Media player entity", - "description": "Name(s) of media player entities to play response on." + "description": "Name(s) of media player entities to play the Google Assistant's audio response on. This does not target the device for the command itself." } } } From ea5a52cdc818df3a59bf13cd4fe60c09be4a03cc Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Sun, 5 Oct 2025 09:49:14 +0200 Subject: [PATCH 07/10] Version bump pydaikin to 2.17.0 (#153718) --- homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 799ff378a358ee..82fd91d96f0a7e 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.16.0"], + "requirements": ["pydaikin==2.17.0"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0c19fd859be2ee..3c5e0ca7a03482 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1936,7 +1936,7 @@ pycsspeechtts==1.0.8 pycync==0.4.1 # homeassistant.components.daikin -pydaikin==2.16.0 +pydaikin==2.17.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ae5ce859a4019..8bff5ed1d28b09 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1629,7 +1629,7 @@ pycsspeechtts==1.0.8 pycync==0.4.1 # homeassistant.components.daikin -pydaikin==2.16.0 +pydaikin==2.17.0 # homeassistant.components.deako pydeako==0.6.0 From ee7262efb4950f8fd4257035d71350b8964a4891 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sun, 5 Oct 2025 10:36:52 +0200 Subject: [PATCH 08/10] Portainer add button platform (#153063) --- .../components/portainer/__init__.py | 2 +- homeassistant/components/portainer/button.py | 128 +++++++++ .../components/portainer/quality_scale.yaml | 5 +- tests/components/portainer/conftest.py | 3 +- .../portainer/snapshots/test_button.ambr | 246 ++++++++++++++++++ tests/components/portainer/test_button.py | 114 ++++++++ 6 files changed, 491 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/portainer/button.py create mode 100644 tests/components/portainer/snapshots/test_button.ambr create mode 100644 tests/components/portainer/test_button.py diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index 732831b27c5e09..ba78ee324091f0 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -18,7 +18,7 @@ from .coordinator import PortainerCoordinator -_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SWITCH] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SWITCH] type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py new file mode 100644 index 00000000000000..917bc9f4676559 --- /dev/null +++ b/homeassistant/components/portainer/button.py @@ -0,0 +1,128 @@ +"""Support for Portainer buttons.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from pyportainer import Portainer +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +from pyportainer.models.docker import DockerContainer + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PortainerConfigEntry +from .const import DOMAIN +from .coordinator import PortainerCoordinator, PortainerCoordinatorData +from .entity import PortainerContainerEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class PortainerButtonDescription(ButtonEntityDescription): + """Class to describe a Portainer button entity.""" + + press_action: Callable[ + [Portainer, int, str], + Coroutine[Any, Any, None], + ] + + +BUTTONS: tuple[PortainerButtonDescription, ...] = ( + PortainerButtonDescription( + key="restart", + name="Restart Container", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=( + lambda portainer, endpoint_id, container_id: portainer.restart_container( + endpoint_id, container_id + ) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PortainerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Portainer buttons.""" + coordinator: PortainerCoordinator = entry.runtime_data + + async_add_entities( + PortainerButton( + coordinator=coordinator, + entity_description=entity_description, + device_info=container, + via_device=endpoint, + ) + for endpoint in coordinator.data.values() + for container in endpoint.containers.values() + for entity_description in BUTTONS + ) + + +class PortainerButton(PortainerContainerEntity, ButtonEntity): + """Defines a Portainer button.""" + + entity_description: PortainerButtonDescription + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: PortainerButtonDescription, + device_info: DockerContainer, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize the Portainer button entity.""" + self.entity_description = entity_description + super().__init__(device_info, coordinator, via_device) + + device_identifier = ( + self._device_info.names[0].replace("/", " ").strip() + if self._device_info.names + else None + ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}" + + async def async_press(self) -> None: + """Trigger the Portainer button press service.""" + try: + await self.entity_description.press_action( + self.coordinator.portainer, self.endpoint_id, self.device_id + ) + except PortainerConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerTimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err diff --git a/homeassistant/components/portainer/quality_scale.yaml b/homeassistant/components/portainer/quality_scale.yaml index fd13fd350658b4..d26f0087d87cd7 100644 --- a/homeassistant/components/portainer/quality_scale.yaml +++ b/homeassistant/components/portainer/quality_scale.yaml @@ -26,10 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: | - No custom actions are defined. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/tests/components/portainer/conftest.py b/tests/components/portainer/conftest.py index 90a3fe65b15847..446572083fa338 100644 --- a/tests/components/portainer/conftest.py +++ b/tests/components/portainer/conftest.py @@ -49,8 +49,7 @@ def mock_portainer_client() -> Generator[AsyncMock]: DockerContainer.from_dict(container) for container in load_json_array_fixture("containers.json", DOMAIN) ] - client.start_container = AsyncMock(return_value=None) - client.stop_container = AsyncMock(return_value=None) + client.restart_container = AsyncMock(return_value=None) yield client diff --git a/tests/components/portainer/snapshots/test_button.ambr b/tests/components/portainer/snapshots/test_button.ambr new file mode 100644 index 00000000000000..83d4f65aaf24fa --- /dev/null +++ b/tests/components/portainer/snapshots/test_button.ambr @@ -0,0 +1,246 @@ +# serializer version: 1 +# name: test_all_button_entities_snapshot[button.focused_einstein_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.focused_einstein_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_focused_einstein_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.focused_einstein_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'focused_einstein Restart Container', + }), + 'context': , + 'entity_id': 'button.focused_einstein_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.funny_chatelet_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.funny_chatelet_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_funny_chatelet_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.funny_chatelet_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'funny_chatelet Restart Container', + }), + 'context': , + 'entity_id': 'button.funny_chatelet_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.practical_morse_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.practical_morse_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_practical_morse_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.practical_morse_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'practical_morse Restart Container', + }), + 'context': , + 'entity_id': 'button.practical_morse_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.serene_banach_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.serene_banach_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_serene_banach_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.serene_banach_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'serene_banach Restart Container', + }), + 'context': , + 'entity_id': 'button.serene_banach_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.stoic_turing_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.stoic_turing_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_stoic_turing_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.stoic_turing_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'stoic_turing Restart Container', + }), + 'context': , + 'entity_id': 'button.stoic_turing_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/portainer/test_button.py b/tests/components/portainer/test_button.py new file mode 100644 index 00000000000000..8f99e2faa2089d --- /dev/null +++ b/tests/components/portainer/test_button.py @@ -0,0 +1,114 @@ +"""Tests for the Portainer button platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +BUTTON_DOMAIN = "button" + + +async def test_all_button_entities_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test for all Portainer button entities.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("action", "client_method"), + [ + ("restart", "restart_container"), + ], +) +async def test_buttons( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + client_method: str, +) -> None: + """Test pressing a Portainer container action button triggers client call. Click, click!""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, mock_config_entry) + + entity_id = f"button.practical_morse_{action}_container" + method_mock = getattr(mock_portainer_client, client_method) + pre_calls = len(method_mock.mock_calls) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(method_mock.mock_calls) == pre_calls + 1 + + +@pytest.mark.parametrize( + ("exception", "client_method"), + [ + (PortainerAuthenticationError("auth"), "restart_container"), + (PortainerConnectionError("conn"), "restart_container"), + (PortainerTimeoutError("timeout"), "restart_container"), + ], +) +async def test_buttons_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + client_method: str, +) -> None: + """Test that Portainer buttons, but this time when they will do boom for sure.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, mock_config_entry) + + action = client_method.split("_")[0] + entity_id = f"button.practical_morse_{action}_container" + + method_mock = getattr(mock_portainer_client, client_method) + method_mock.side_effect = exception + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) From ab80991eac7679373f9a46dafce7e43b3dab7781 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 5 Oct 2025 11:53:52 +0300 Subject: [PATCH 09/10] Add Shelly support for climate entities (#153450) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/shelly/climate.py | 225 ++++++++++- homeassistant/components/shelly/const.py | 2 + homeassistant/components/shelly/strings.json | 11 + homeassistant/components/shelly/utils.py | 13 + .../shelly/fixtures/st1820_gen3.json | 321 +++++++++++++++ .../shelly/fixtures/st802_gen3.json | 373 ++++++++++++++++++ .../shelly/snapshots/test_climate.ambr | 176 +++++++++ tests/components/shelly/test_climate.py | 199 +++++++++- 8 files changed, 1316 insertions(+), 4 deletions(-) create mode 100644 tests/components/shelly/fixtures/st1820_gen3.json create mode 100644 tests/components/shelly/fixtures/st802_gen3.json diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 41a02741237ad9..e0cffbc7f179d6 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from dataclasses import asdict, dataclass -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from aioshelly.block_device import Block from aioshelly.const import BLU_TRV_IDENTIFIER, RPC_GENERATIONS @@ -14,6 +14,7 @@ DOMAIN as CLIMATE_DOMAIN, PRESET_NONE, ClimateEntity, + ClimateEntityDescription, ClimateEntityFeature, HVACAction, HVACMode, @@ -33,23 +34,235 @@ BLU_TRV_TEMPERATURE_SETTINGS, DOMAIN, LOGGER, + MODEL_LINKEDGO_ST802_THERMOSTAT, + MODEL_LINKEDGO_ST1820_THERMOSTAT, NOT_CALIBRATED_ISSUE_ID, RPC_THERMOSTAT_SETTINGS, SHTRV_01_TEMPERATURE_SETTINGS, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import ShellyRpcEntity, get_entity_block_device_info, rpc_call +from .entity import ( + RpcEntityDescription, + ShellyRpcAttributeEntity, + ShellyRpcEntity, + async_setup_entry_rpc, + get_entity_block_device_info, + rpc_call, +) from .utils import ( async_remove_shelly_entity, get_block_entity_name, get_blu_trv_device_info, get_device_entry_gen, + get_rpc_key_by_role, get_rpc_key_ids, + id_from_key, is_rpc_thermostat_internal_actuator, ) PARALLEL_UPDATES = 0 +THERMOSTAT_TO_HA_MODE = { + "cool": HVACMode.COOL, + "dry": HVACMode.DRY, + "heat": HVACMode.HEAT, + "ventilation": HVACMode.FAN_ONLY, +} + +HA_TO_THERMOSTAT_MODE = {value: key for key, value in THERMOSTAT_TO_HA_MODE.items()} + +PRESET_FROST_PROTECTION = "frost_protection" + + +@dataclass(kw_only=True, frozen=True) +class RpcClimateDescription(RpcEntityDescription, ClimateEntityDescription): + """Class to describe a RPC climate.""" + + +class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity): + """Entity that controls a LINKEDGO Thermostat on RPC based Shelly devices.""" + + entity_description: RpcClimateDescription + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = "thermostat" + _id: int + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcEntityDescription, + ) -> None: + """Initialize RPC LINKEDGO Thermostat.""" + super().__init__(coordinator, key, attribute, description) + self._attr_name = None # Main device entity + + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + + config = coordinator.device.config + self._status = coordinator.device.status + + self._attr_min_temp = config[key]["min"] + self._attr_max_temp = config[key]["max"] + self._attr_target_temperature_step = config[key]["meta"]["ui"]["step"] + + self._current_humidity_key = get_rpc_key_by_role(config, "current_humidity") + self._current_temperature_key = get_rpc_key_by_role( + config, "current_temperature" + ) + self._thermostat_enable_key = get_rpc_key_by_role(config, "enable") + + self._target_humidity_key = get_rpc_key_by_role(config, "target_humidity") + if self._target_humidity_key: + self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY + self._attr_min_humidity = config[self._target_humidity_key]["min"] + self._attr_max_humidity = config[self._target_humidity_key]["max"] + + self._anti_freeze_key = get_rpc_key_by_role(config, "anti_freeze") + if self._anti_freeze_key: + self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + self._attr_preset_modes = [PRESET_NONE, PRESET_FROST_PROTECTION] + + self._fan_speed_key = get_rpc_key_by_role(config, "fan_speed") + if self._fan_speed_key: + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE + self._attr_fan_modes = config[self._fan_speed_key]["options"] + + # ST1820 only supports HEAT and OFF + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + # ST802 supports multiple working modes + self._working_mode_key = get_rpc_key_by_role(config, "working_mode") + if self._working_mode_key: + modes = config[self._working_mode_key]["options"] + self._attr_hvac_modes = [HVACMode.OFF] + [ + THERMOSTAT_TO_HA_MODE[mode] for mode in modes + ] + + @property + def current_humidity(self) -> float | None: + """Return the current humidity.""" + if TYPE_CHECKING: + assert self._current_humidity_key is not None + + return cast(float, self._status[self._current_humidity_key]["value"]) + + @property + def target_humidity(self) -> float | None: + """Return the humidity we try to reach.""" + if TYPE_CHECKING: + assert self._target_humidity_key is not None + + return cast(float, self._status[self._target_humidity_key]["value"]) + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac operation ie. heat, cool mode.""" + if TYPE_CHECKING: + assert self._thermostat_enable_key is not None + + if not self._status[self._thermostat_enable_key]["value"]: + return HVACMode.OFF + + if self._working_mode_key is not None: + working_mode = self._status[self._working_mode_key]["value"] + return THERMOSTAT_TO_HA_MODE[working_mode] + + return HVACMode.HEAT # ST1820 + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if TYPE_CHECKING: + assert self._current_temperature_key is not None + + return cast(float, self._status[self._current_temperature_key]["value"]) + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return cast(float, self.attribute_value) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + if TYPE_CHECKING: + assert self._anti_freeze_key is not None + + if self._status[self._anti_freeze_key]["value"]: + return PRESET_FROST_PROTECTION + + return PRESET_NONE + + @property + def fan_mode(self) -> str | None: + """Return the fan setting.""" + if TYPE_CHECKING: + assert self._fan_speed_key is not None + + return cast(str, self._status[self._fan_speed_key]["value"]) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + await self.coordinator.device.number_set(self._id, kwargs[ATTR_TEMPERATURE]) + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + assert self._target_humidity_key is not None + + await self.coordinator.device.number_set( + id_from_key(self._target_humidity_key), humidity + ) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + if TYPE_CHECKING: + assert self._fan_speed_key is not None + + await self.coordinator.device.enum_set( + id_from_key(self._fan_speed_key), fan_mode + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if TYPE_CHECKING: + assert self._thermostat_enable_key is not None + + await self.coordinator.device.boolean_set( + id_from_key(self._thermostat_enable_key), hvac_mode != HVACMode.OFF + ) + + if self._working_mode_key is None or hvac_mode == HVACMode.OFF: + return + + await self.coordinator.device.enum_set( + id_from_key(self._working_mode_key), + HA_TO_THERMOSTAT_MODE[hvac_mode], + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if TYPE_CHECKING: + assert self._anti_freeze_key is not None + + await self.coordinator.device.boolean_set( + id_from_key(self._anti_freeze_key), preset_mode == PRESET_FROST_PROTECTION + ) + + +RPC_LINKEDGO_THERMOSTAT: dict[str, RpcClimateDescription] = { + "linkedgo_thermostat_climate": RpcClimateDescription( + key="number", + sub_key="value", + role="target_temperature", + models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT}, + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -150,6 +363,14 @@ def async_setup_rpc_entry( if blutrv_key_ids: async_add_entities(RpcBluTrvClimate(coordinator, id_) for id_ in blutrv_key_ids) + async_setup_entry_rpc( + hass, + config_entry, + async_add_entities, + RPC_LINKEDGO_THERMOSTAT, + RpcLinkedgoThermostatClimate, + ) + @dataclass class ShellyClimateExtraStoredData(ExtraStoredData): diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 5378177bb3c634..47a31163fe5f4f 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -312,3 +312,5 @@ class BLEScannerMode(StrEnum): # Shelly-X specific models MODEL_NEO_WATER_VALVE = "NeoWaterValve" MODEL_FRANKEVER_WATER_VALVE = "WaterValve" +MODEL_LINKEDGO_ST802_THERMOSTAT = "ST-802" +MODEL_LINKEDGO_ST1820_THERMOSTAT = "ST1820" diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 294c5937ab0937..443abc119e56ed 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -118,6 +118,17 @@ } }, "entity": { + "climate": { + "thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "frost_protection": "Frost protection" + } + } + } + } + }, "event": { "input": { "state_attributes": { diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index a3ff2b0643b9cc..bfdbcee74a1cb9 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -476,6 +476,19 @@ def get_rpc_key_ids(keys_dict: dict[str, Any], key: str) -> list[int]: return [int(k.split(":")[1]) for k in keys_dict if k.startswith(f"{key}:")] +def get_rpc_key_by_role(keys_dict: dict[str, Any], role: str) -> str | None: + """Return key by role for RPC device from a dict.""" + for key, value in keys_dict.items(): + if value.get("role") == role: + return key + return None + + +def id_from_key(key: str) -> int: + """Return id from key.""" + return int(key.split(":")[-1]) + + def is_rpc_momentary_input( config: dict[str, Any], status: dict[str, Any], key: str ) -> bool: diff --git a/tests/components/shelly/fixtures/st1820_gen3.json b/tests/components/shelly/fixtures/st1820_gen3.json new file mode 100644 index 00000000000000..b2795161dc3c60 --- /dev/null +++ b/tests/components/shelly/fixtures/st1820_gen3.json @@ -0,0 +1,321 @@ +{ + "config": { + "ble": { + "enable": true, + "rpc": { + "enable": true + } + }, + "boolean:200": { + "access": "crw", + "default_value": false, + "id": 200, + "meta": { + "cloud": ["log"], + "ui": { + "titles": ["Disabled", "Enabled"], + "view": "toggle" + } + }, + "name": "Anti-freeze", + "owner": "service:0", + "persisted": false, + "role": "anti_freeze" + }, + "boolean:201": { + "access": "crw", + "default_value": false, + "id": 201, + "meta": { + "cloud": ["log"], + "ui": { + "titles": ["Disabled", "Enabled"], + "view": "toggle" + } + }, + "name": "Child lock", + "owner": "service:0", + "persisted": false, + "role": "child_lock" + }, + "boolean:202": { + "access": "crw", + "default_value": false, + "id": 202, + "meta": { + "cloud": ["log"], + "ui": { + "titles": ["Disabled", "Enable"], + "view": "toggle" + } + }, + "name": "Enable thermostat", + "owner": "service:0", + "persisted": false, + "role": "enable" + }, + "bthome": {}, + "cloud": { + "enable": false, + "server": "wss://repo.shelly.cloud:6022/jrpc" + }, + "mqtt": { + "client_id": "st1820-aabbccddeeff", + "enable": false, + "enable_control": true, + "enable_rpc": true, + "rpc_ntf": true, + "server": "mqtt.test.server", + "ssl_ca": null, + "status_ntf": false, + "topic_prefix": "st1820-aabbccddeeff", + "use_client_cert": false, + "user": null + }, + "number:200": { + "access": "cr", + "default_value": 0, + "id": 200, + "max": 100, + "meta": { + "cloud": ["measurement", "log"], + "ui": { + "unit": "%", + "view": "label" + } + }, + "min": 0, + "name": "Current humidity", + "owner": "service:0", + "persisted": false, + "role": "current_humidity" + }, + "number:201": { + "access": "cr", + "default_value": 25, + "id": 201, + "max": 35, + "meta": { + "cloud": ["measurement", "log"], + "ui": { + "unit": "°C", + "view": "label" + } + }, + "min": 15, + "name": "Current temperature", + "owner": "service:0", + "persisted": false, + "role": "current_temperature" + }, + "number:202": { + "access": "crw", + "default_value": 25, + "id": 202, + "max": 35, + "meta": { + "cloud": ["log"], + "ui": { + "step": 0.5, + "unit": "°C", + "view": "slider" + } + }, + "min": 15, + "name": "Target temperature", + "owner": "service:0", + "persisted": false, + "role": "target_temperature" + }, + "service:0": { + "humidity_offset": 0, + "id": 0, + "power_down_memory": true, + "temp_anti_freeze": 5, + "temp_hysteresis": 1, + "temp_offset": 0, + "temp_range": [15, 35] + }, + "sys": { + "cfg_rev": 34, + "debug": { + "file_level": null, + "level": 2, + "mqtt": { + "enable": false + }, + "udp": { + "addr": null + }, + "websocket": { + "enable": false + } + }, + "device": { + "discoverable": true, + "eco_mode": false, + "fw_id": "20241121-103618/1.4.99-xt-prod1-gc6448fb", + "mac": "AABBCCDDEEFF", + "name": "Test Name" + }, + "location": { + "lat": 32.1033, + "lon": 34.8879, + "tz": "Asia/Jerusalem" + }, + "rpc_udp": { + "dst_addr": null, + "listen_port": null + }, + "sntp": { + "server": "sntp.test.server" + }, + "ui_data": {} + }, + "wifi": { + "ap": { + "enable": true, + "is_open": true, + "range_extender": { + "enable": false + }, + "ssid": "ST1820-AABBCCDDEEFF" + }, + "roam": { + "interval": 60, + "rssi_thr": -80 + }, + "sta": { + "enable": true, + "gw": null, + "ip": null, + "ipv4mode": "dhcp", + "is_open": false, + "nameserver": null, + "netmask": null, + "ssid": "Wifi-Network-Name" + }, + "sta1": { + "enable": false, + "gw": null, + "ip": null, + "ipv4mode": "dhcp", + "is_open": true, + "nameserver": null, + "netmask": null, + "ssid": null + } + }, + "ws": { + "enable": false, + "server": null, + "ssl_ca": "ca.pem" + } + }, + "shelly": { + "app": "XT1", + "auth_domain": null, + "auth_en": false, + "fw_id": "20241121-103618/1.4.99-xt-prod1-gc6448fb", + "gen": 3, + "id": "st1820-aabbccddeeff", + "jti": "00023E000007", + "jwt": { + "aud": "XT1", + "f": 1, + "iat": 1733130983, + "jti": "00023E000007", + "n": "Starlight Thermostat ST1820", + "p": "ST1820", + "url": "https://www.linkedgo-e.com/Floor_Heating_Thermostat.html", + "v": 1, + "xt1": { + "svc0": { + "type": "linkedgo-st1820-floor-thermostat" + } + } + }, + "mac": "AABBCCDDEEFF", + "model": "S3XT-0S", + "name": "Test Name", + "slot": 0, + "svc0": { + "build_id": "20241206-114057/aafe9c3", + "type": "linkedgo-st1820-floor-thermostat", + "ver": "0.5.2-st1820-prod0" + }, + "ver": "1.4.99-xt-prod1" + }, + "status": { + "ble": {}, + "boolean:200": { + "value": false + }, + "boolean:201": { + "value": true + }, + "boolean:202": { + "value": true + }, + "bthome": {}, + "cloud": { + "connected": false + }, + "mqtt": { + "connected": false + }, + "number:200": { + "value": 59 + }, + "number:201": { + "value": 25.5 + }, + "number:202": { + "value": 27 + }, + "service:0": { + "etag": "c592bdbf305187d367eb573b3576b074", + "state": "running", + "stats": { + "mem": 800, + "mem_peak": 935 + } + }, + "sys": { + "available_updates": { + "stable": { + "svc0": { + "ver": "0.7.0" + }, + "version": "1.5.1" + } + }, + "btrelay_rev": 0, + "cfg_rev": 34, + "fs_free": 585728, + "fs_size": 1048576, + "kvs_rev": 0, + "last_sync_ts": 1759408493, + "mac": "AABBCCDDEEFF", + "ram_free": 47748, + "ram_min_free": 34092, + "ram_size": 252564, + "reset_reason": 1, + "restart_required": false, + "schedule_rev": 0, + "time": "15:36", + "unixtime": 1759408575, + "uptime": 18088, + "webhook_rev": 1 + }, + "wifi": { + "rssi": -41, + "ssid": "Wifi-Network-Name", + "sta_ip": "192.168.2.24", + "status": "got ip" + }, + "ws": { + "connected": false + } + } +} diff --git a/tests/components/shelly/fixtures/st802_gen3.json b/tests/components/shelly/fixtures/st802_gen3.json new file mode 100644 index 00000000000000..afc9f0950043bb --- /dev/null +++ b/tests/components/shelly/fixtures/st802_gen3.json @@ -0,0 +1,373 @@ +{ + "config": { + "ble": { + "enable": true, + "rpc": { + "enable": true + } + }, + "boolean:200": { + "access": "crw", + "default_value": false, + "id": 200, + "meta": { + "cloud": ["log"], + "ui": { + "titles": ["Off", "On"], + "view": "toggle" + } + }, + "name": "Anti-Freeze", + "owner": "service:0", + "persisted": false, + "role": "anti_freeze" + }, + "boolean:201": { + "access": "crw", + "default_value": false, + "id": 201, + "meta": { + "cloud": ["log"], + "ui": { + "titles": ["Disabled", "Enabled"], + "view": "toggle" + } + }, + "name": "Enable thermostat", + "owner": "service:0", + "persisted": false, + "role": "enable" + }, + "bthome": {}, + "cloud": { + "enable": false, + "server": "wss://repo.shelly.cloud:6022/jrpc" + }, + "enum:200": { + "access": "crw", + "default_value": "auto", + "id": 200, + "meta": { + "cloud": ["log"], + "ui": { + "titles": { + "auto": "Auto", + "high": "High", + "low": "Low", + "medium": "Medium", + "strong": "Strong", + "whisper": "Whisper" + }, + "view": "select" + } + }, + "name": "Fan speed", + "options": ["auto", "low", "medium", "high"], + "owner": "service:0", + "persisted": false, + "role": "fan_speed" + }, + "enum:201": { + "access": "crw", + "default_value": "cool", + "id": 201, + "meta": { + "ui": { + "titles": { + "boost": "Boost", + "cool": "Cool", + "dry": "Dry", + "floor_heating": "Floor heating", + "heat": "Heat", + "ventilation": "Ventilation" + }, + "view": "select" + } + }, + "name": "Working mode", + "options": ["cool", "dry", "heat", "ventilation"], + "owner": "service:0", + "persisted": false, + "role": "working_mode" + }, + "mqtt": { + "client_id": "st-802-aabbccddeeff", + "enable": false, + "enable_control": true, + "enable_rpc": true, + "rpc_ntf": true, + "server": "mqtt.test.server", + "ssl_ca": null, + "status_ntf": false, + "topic_prefix": "st-802-aabbccddeeff", + "use_client_cert": false, + "user": null + }, + "number:200": { + "access": "crw", + "default_value": 0, + "id": 200, + "max": 100, + "meta": { + "cloud": ["measurement", "log"], + "ui": { + "unit": "%", + "view": "label" + } + }, + "min": 0, + "name": "Current humidity", + "owner": "service:0", + "persisted": false, + "role": "current_humidity" + }, + "number:201": { + "access": "crw", + "default_value": 20, + "id": 201, + "max": 35, + "meta": { + "cloud": ["measurement", "log"], + "ui": { + "step": 0.5, + "unit": "°C", + "view": "label" + } + }, + "min": 5, + "name": "Current temperature", + "owner": "service:0", + "persisted": false, + "role": "current_temperature" + }, + "number:202": { + "access": "crw", + "default_value": 45, + "id": 202, + "max": 75, + "meta": { + "cloud": ["log"], + "ui": { + "unit": "%", + "view": "slider" + } + }, + "min": 40, + "name": "Target humidity", + "owner": "service:0", + "persisted": false, + "role": "target_humidity" + }, + "number:203": { + "access": "crw", + "default_value": 20, + "id": 203, + "max": 35, + "meta": { + "cloud": ["log"], + "ui": { + "step": 0.5, + "unit": "°C", + "view": "slider" + } + }, + "min": 5, + "name": "Target temperature", + "owner": "service:0", + "persisted": false, + "role": "target_temperature" + }, + "service:0": { + "id": 0, + "temp_unit": "C", + "thermostat_mode": "auto" + }, + "sys": { + "cfg_rev": 70, + "debug": { + "file_level": null, + "level": 2, + "mqtt": { + "enable": false + }, + "udp": { + "addr": null + }, + "websocket": { + "enable": false + } + }, + "device": { + "discoverable": true, + "eco_mode": false, + "fw_id": "20241121-103618/1.4.99-xt-prod1-gc6448fb", + "mac": "AABBCCDDEEFF", + "name": "Test Name" + }, + "location": { + "lat": 32.1033, + "lon": 34.8879, + "tz": "Asia/Jerusalem" + }, + "rpc_udp": { + "dst_addr": null, + "listen_port": null + }, + "sntp": { + "server": "sntp.test.server" + }, + "ui_data": {} + }, + "wifi": { + "ap": { + "enable": true, + "is_open": true, + "range_extender": { + "enable": false + }, + "ssid": "ST-802-AABBCCDDEEFF" + }, + "roam": { + "interval": 60, + "rssi_thr": -80 + }, + "sta": { + "enable": true, + "gw": null, + "ip": null, + "ipv4mode": "dhcp", + "is_open": false, + "nameserver": null, + "netmask": null, + "ssid": "Wifi-Network-Name" + }, + "sta1": { + "enable": false, + "gw": null, + "ip": null, + "ipv4mode": "dhcp", + "is_open": true, + "nameserver": null, + "netmask": null, + "ssid": null + } + }, + "ws": { + "enable": false, + "server": null, + "ssl_ca": "ca.pem" + } + }, + "shelly": { + "app": "XT1", + "auth_domain": null, + "auth_en": false, + "fw_id": "20241121-103618/1.4.99-xt-prod1-gc6448fb", + "gen": 3, + "id": "st-802-aabbccddeeff", + "jti": "00023E000006", + "jwt": { + "aud": "XT1", + "f": 1, + "iat": 1732626411, + "jti": "00023E000006", + "n": "Youth Smart Thermostat ST802", + "p": "ST-802", + "url": "https://www.linkedgo-e.com/", + "v": 1, + "xt1": { + "svc0": { + "type": "linkedgo-st-802-hvac" + } + } + }, + "mac": "AABBCCDDEEFF", + "model": "S3XT-0S", + "name": "Test Name", + "slot": 0, + "svc0": { + "build_id": "20241126-134710/490e7db", + "type": "linkedgo-st-802-hvac", + "ver": "0.5.2-st802-prod0" + }, + "ver": "1.4.99-xt-prod1" + }, + "status": { + "ble": {}, + "boolean:200": { + "value": false + }, + "boolean:201": { + "value": true + }, + "bthome": {}, + "cloud": { + "connected": false + }, + "enum:200": { + "value": "auto" + }, + "enum:201": { + "value": "heat" + }, + "mqtt": { + "connected": false + }, + "number:200": { + "value": 58 + }, + "number:201": { + "value": 25.1 + }, + "number:202": { + "value": 60 + }, + "number:203": { + "value": 20.5 + }, + "service:0": { + "etag": "49da4f1517e4f8a548cb3b1491d14597", + "state": "running", + "stats": { + "mem": 655, + "mem_peak": 786 + } + }, + "sys": { + "available_updates": { + "stable": { + "svc0": { + "ver": "0.7.0" + }, + "version": "1.5.1" + } + }, + "btrelay_rev": 0, + "cfg_rev": 70, + "fs_free": 589824, + "fs_size": 1048576, + "kvs_rev": 0, + "last_sync_ts": 1759408492, + "mac": "AABBCCDDEEFF", + "ram_free": 46756, + "ram_min_free": 24900, + "ram_size": 252388, + "reset_reason": 1, + "restart_required": false, + "schedule_rev": 0, + "time": "15:51", + "unixtime": 1759409476, + "uptime": 18989, + "webhook_rev": 1 + }, + "wifi": { + "rssi": -46, + "ssid": "Wifi-Network-Name", + "sta_ip": "192.168.2.24", + "status": "got ip" + }, + "ws": { + "connected": false + } + } +} diff --git a/tests/components/shelly/snapshots/test_climate.ambr b/tests/components/shelly/snapshots/test_climate.ambr index 35746dd5c0883a..ebc03966a0b853 100644 --- a/tests/components/shelly/snapshots/test_climate.ambr +++ b/tests/components/shelly/snapshots/test_climate.ambr @@ -210,6 +210,182 @@ 'state': 'heat', }) # --- +# name: test_rpc_linkedgo_st1820_thermostat[climate.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 15, + 'preset_modes': list([ + 'none', + 'frost_protection', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'thermostat', + 'unique_id': '123456789ABC-number:202-linkedgo_thermostat_climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_linkedgo_st1820_thermostat[climate.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 59, + 'current_temperature': 25.5, + 'friendly_name': 'Test name', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 15, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'frost_protection', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 27, + }), + 'context': , + 'entity_id': 'climate.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_rpc_linkedgo_st802_thermostat[climate.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_humidity': 75, + 'max_temp': 35, + 'min_humidity': 40, + 'min_temp': 5, + 'preset_modes': list([ + 'none', + 'frost_protection', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'thermostat', + 'unique_id': '123456789ABC-number:203-linkedgo_thermostat_climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_linkedgo_st802_thermostat[climate.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 58, + 'current_temperature': 25.1, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Test name', + 'humidity': 60, + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_humidity': 75, + 'max_temp': 35, + 'min_humidity': 40, + 'min_temp': 5, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'frost_protection', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 20.5, + }), + 'context': , + 'entity_id': 'climate.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_wall_display_thermostat_mode[climate.test_name-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 61946298f79d8d..54cf44c6155ecd 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -16,18 +16,28 @@ from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_PRESET_MODE, DOMAIN as CLIMATE_DOMAIN, + FAN_LOW, PRESET_NONE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.humidifier import ATTR_HUMIDITY +from homeassistant.components.shelly.climate import PRESET_FROST_PROTECTION +from homeassistant.components.shelly.const import ( + DOMAIN, + MODEL_LINKEDGO_ST802_THERMOSTAT, + MODEL_LINKEDGO_ST1820_THERMOSTAT, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -53,7 +63,11 @@ ) from .conftest import MOCK_STATUS_COAP -from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data +from tests.common import ( + async_load_json_object_fixture, + mock_restore_cache, + mock_restore_cache_with_extra_data, +) SENSOR_BLOCK_ID = 3 DEVICE_BLOCK_ID = 4 @@ -925,3 +939,184 @@ async def test_blu_trv_set_target_temp_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_rpc_linkedgo_st802_thermostat( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test LINKEDGO ST802 thermostat climate.""" + entity_id = "climate.test_name" + + device_fixture = await async_load_json_object_fixture( + hass, "st802_gen3.json", DOMAIN + ) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, 3, model=MODEL_LINKEDGO_ST802_THERMOSTAT) + + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") + + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") + + # Test HVAC mode cool + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["enum:201"], "value", "cool") + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(201, True) + mock_rpc_device.enum_set.assert_called_once_with(201, "cool") + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.COOL + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 25}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 25) + mock_rpc_device.mock_update() + + mock_rpc_device.number_set.assert_called_once_with(203, 25.0) + assert (state := hass.states.get(entity_id)) + assert state.attributes.get(ATTR_TEMPERATURE) == 25 + + # Test set humidity + mock_rpc_device.number_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: entity_id, ATTR_HUMIDITY: 66}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["number:202"], "value", 66) + mock_rpc_device.mock_update() + + mock_rpc_device.number_set.assert_called_once_with(202, 66.0) + assert (state := hass.states.get(entity_id)) + assert state.attributes.get(ATTR_HUMIDITY) == 66 + + # Anti-Freeze preset mode + mock_rpc_device.boolean_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_FROST_PROTECTION}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", True) + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(200, True) + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_FROST_PROTECTION + + # Test set fan mode + mock_rpc_device.enum_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["enum:200"], "value", "low") + mock_rpc_device.mock_update() + + mock_rpc_device.enum_set.assert_called_once_with(200, "low") + assert (state := hass.states.get(entity_id)) + assert state.attributes.get(ATTR_FAN_MODE) == FAN_LOW + + # Test HVAC mode off + mock_rpc_device.boolean_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["boolean:201"], "value", False) + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(201, False) + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + + +async def test_rpc_linkedgo_st1820_thermostat( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test LINKEDGO ST1820 thermostat climate.""" + entity_id = "climate.test_name" + + device_fixture = await async_load_json_object_fixture( + hass, "st1820_gen3.json", DOMAIN + ) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, 3, model=MODEL_LINKEDGO_ST1820_THERMOSTAT) + + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") + + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 25}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["number:202"], "value", 25) + mock_rpc_device.mock_update() + + mock_rpc_device.number_set.assert_called_once_with(202, 25.0) + assert (state := hass.states.get(entity_id)) + assert state.attributes.get(ATTR_TEMPERATURE) == 25 + + # Anti-Freeze preset mode + mock_rpc_device.boolean_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_FROST_PROTECTION}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", True) + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(200, True) + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_FROST_PROTECTION + + # Test HVAC mode off + mock_rpc_device.boolean_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["boolean:202"], "value", False) + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(202, False) + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF From 0a071a13e26e41e6be0b0f06acaeb520a455a2c6 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Sun, 5 Oct 2025 11:12:10 +0200 Subject: [PATCH 10/10] Version bump pydaikin to 2.17.1 (#153726) --- homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 82fd91d96f0a7e..54f974e60a5324 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.17.0"], + "requirements": ["pydaikin==2.17.1"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 3c5e0ca7a03482..7d0fd6fb2f2c79 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1936,7 +1936,7 @@ pycsspeechtts==1.0.8 pycync==0.4.1 # homeassistant.components.daikin -pydaikin==2.17.0 +pydaikin==2.17.1 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8bff5ed1d28b09..9ef242b1b3d3de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1629,7 +1629,7 @@ pycsspeechtts==1.0.8 pycync==0.4.1 # homeassistant.components.daikin -pydaikin==2.17.0 +pydaikin==2.17.1 # homeassistant.components.deako pydeako==0.6.0