From 01200ef0a8d8d541a8da82df407bd7ff546ea038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tor=20Andr=C3=A9=20Roland?= Date: Mon, 9 Mar 2026 19:29:43 +0100 Subject: [PATCH] Optimizations to Adax local device control (#162109) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- homeassistant/components/adax/climate.py | 40 +++++- tests/components/adax/conftest.py | 11 +- tests/components/adax/test_climate.py | 176 +++++++++++++++++++++-- 3 files changed, 205 insertions(+), 22 deletions(-) mode change 100644 => 100755 homeassistant/components/adax/climate.py diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py old mode 100644 new mode 100755 index b41a4432437..62ddb213e2a --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -168,29 +168,57 @@ class LocalAdaxDevice(CoordinatorEntity[AdaxLocalCoordinator], ClimateEntity): if hvac_mode == HVACMode.HEAT: temperature = self._attr_target_temperature or self._attr_min_temp await self._adax_data_handler.set_target_temperature(temperature) + self._attr_target_temperature = temperature + self._attr_icon = "mdi:radiator" elif hvac_mode == HVACMode.OFF: await self._adax_data_handler.set_target_temperature(0) + self._attr_icon = "mdi:radiator-off" + else: + # Ignore unsupported HVAC modes to avoid desynchronizing entity state + # from the physical device. + return + + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return - await self._adax_data_handler.set_target_temperature(temperature) + if self._attr_hvac_mode == HVACMode.HEAT: + await self._adax_data_handler.set_target_temperature(temperature) - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + self._attr_target_temperature = temperature + self.async_write_ha_state() + + def _update_hvac_attributes(self) -> None: + """Update hvac mode and temperatures from coordinator data. + + The coordinator reports a target temperature of 0 when the heater is + turned off. In that case, only the hvac mode and icon are updated and + the previous non-zero target temperature is preserved. When the + reported target temperature is non-zero, the stored target temperature + is updated to match the coordinator value. + """ if data := self.coordinator.data: self._attr_current_temperature = data["current_temperature"] - self._attr_available = self._attr_current_temperature is not None if (target_temp := data["target_temperature"]) == 0: self._attr_hvac_mode = HVACMode.OFF self._attr_icon = "mdi:radiator-off" - if target_temp == 0: + if self._attr_target_temperature is None: self._attr_target_temperature = self._attr_min_temp else: self._attr_hvac_mode = HVACMode.HEAT self._attr_icon = "mdi:radiator" self._attr_target_temperature = target_temp + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_hvac_attributes() super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._update_hvac_attributes() diff --git a/tests/components/adax/conftest.py b/tests/components/adax/conftest.py index 026b9558a20..f2b110f5552 100644 --- a/tests/components/adax/conftest.py +++ b/tests/components/adax/conftest.py @@ -47,11 +47,6 @@ CLOUD_DEVICE_DATA: dict[str, Any] = [ } ] -LOCAL_DEVICE_DATA: dict[str, Any] = { - "current_temperature": 15, - "target_temperature": 20, -} - @pytest.fixture def mock_cloud_config_entry(request: pytest.FixtureRequest) -> MockConfigEntry: @@ -94,5 +89,9 @@ def mock_adax_local(): mock_adax_class = mock_adax.return_value mock_adax_class.get_status = AsyncMock() - mock_adax_class.get_status.return_value = LOCAL_DEVICE_DATA + mock_adax_class.get_status.return_value = { + "current_temperature": 15, + "target_temperature": 20, + } + mock_adax_class.set_target_temperature = AsyncMock() yield mock_adax_class diff --git a/tests/components/adax/test_climate.py b/tests/components/adax/test_climate.py index a5a93df74fa..a15c79a21ab 100644 --- a/tests/components/adax/test_climate.py +++ b/tests/components/adax/test_climate.py @@ -1,12 +1,24 @@ """Test Adax climate entity.""" from homeassistant.components.adax.const import SCAN_INTERVAL -from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode -from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, Platform +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from . import setup_integration -from .conftest import CLOUD_DEVICE_DATA, LOCAL_DEVICE_DATA +from .conftest import CLOUD_DEVICE_DATA from tests.common import AsyncMock, MockConfigEntry, async_fire_time_changed from tests.test_setup import FrozenDateTimeFactory @@ -67,13 +79,8 @@ async def test_climate_local( state = hass.states.get(entity_id) assert state assert state.state == HVACMode.HEAT - assert ( - state.attributes[ATTR_TEMPERATURE] == (LOCAL_DEVICE_DATA["target_temperature"]) - ) - assert ( - state.attributes[ATTR_CURRENT_TEMPERATURE] - == (LOCAL_DEVICE_DATA["current_temperature"]) - ) + assert state.attributes[ATTR_TEMPERATURE] == 20 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 15 mock_adax_local.get_status.side_effect = Exception() freezer.tick(SCAN_INTERVAL) @@ -83,3 +90,152 @@ async def test_climate_local( state = hass.states.get(entity_id) assert state assert state.state == STATE_UNAVAILABLE + + +async def test_climate_local_initial_state_from_first_refresh( + hass: HomeAssistant, + mock_local_config_entry: MockConfigEntry, + mock_adax_local: AsyncMock, +) -> None: + """Test that local climate state is initialized from first refresh data.""" + await setup_integration(hass, mock_local_config_entry) + + assert len(hass.states.async_entity_ids(Platform.CLIMATE)) == 1 + entity_id = hass.states.async_entity_ids(Platform.CLIMATE)[0] + + state = hass.states.get(entity_id) + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 20 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 15 + + +async def test_climate_local_initial_state_off_from_first_refresh( + hass: HomeAssistant, + mock_local_config_entry: MockConfigEntry, + mock_adax_local: AsyncMock, +) -> None: + """Test that local climate initializes correctly when first refresh reports off.""" + mock_adax_local.get_status.return_value["target_temperature"] = 0 + + await setup_integration(hass, mock_local_config_entry) + + assert len(hass.states.async_entity_ids(Platform.CLIMATE)) == 1 + entity_id = hass.states.async_entity_ids(Platform.CLIMATE)[0] + + state = hass.states.get(entity_id) + assert state + assert state.state == HVACMode.OFF + assert state.attributes[ATTR_TEMPERATURE] == 5 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 15 + + +async def test_climate_local_set_hvac_mode_updates_state_immediately( + hass: HomeAssistant, + mock_local_config_entry: MockConfigEntry, + mock_adax_local: AsyncMock, +) -> None: + """Test local hvac mode service updates both device and state immediately.""" + await setup_integration(hass, mock_local_config_entry) + + entity_id = hass.states.async_entity_ids(Platform.CLIMATE)[0] + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + mock_adax_local.set_target_temperature.assert_called_once_with(0) + state = hass.states.get(entity_id) + assert state + assert state.state == HVACMode.OFF + + mock_adax_local.set_target_temperature.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + mock_adax_local.set_target_temperature.assert_called_once_with(20) + state = hass.states.get(entity_id) + assert state + assert state.state == HVACMode.HEAT + + +async def test_climate_local_set_temperature_when_off_does_not_change_hvac_mode( + hass: HomeAssistant, + mock_local_config_entry: MockConfigEntry, + mock_adax_local: AsyncMock, +) -> None: + """Test setting target temperature while off does not send command or turn on.""" + await setup_integration(hass, mock_local_config_entry) + + entity_id = hass.states.async_entity_ids(Platform.CLIMATE)[0] + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + mock_adax_local.set_target_temperature.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 23, + }, + blocking=True, + ) + + mock_adax_local.set_target_temperature.assert_not_called() + state = hass.states.get(entity_id) + assert state + assert state.state == HVACMode.OFF + assert state.attributes[ATTR_TEMPERATURE] == 23 + + +async def test_climate_local_set_temperature_when_heat_calls_device( + hass: HomeAssistant, + mock_local_config_entry: MockConfigEntry, + mock_adax_local: AsyncMock, +) -> None: + """Test setting target temperature while heating calls local API.""" + await setup_integration(hass, mock_local_config_entry) + + entity_id = hass.states.async_entity_ids(Platform.CLIMATE)[0] + state = hass.states.get(entity_id) + assert state + assert state.state == HVACMode.HEAT + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 24, + }, + blocking=True, + ) + + mock_adax_local.set_target_temperature.assert_called_once_with(24) + state = hass.states.get(entity_id) + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 24