From 66909fc9cad17be80cbbb38f7106119829c01a82 Mon Sep 17 00:00:00 2001 From: Jonathan de Jong Date: Mon, 12 Jan 2026 21:46:20 +0100 Subject: [PATCH] Support HVAC mode in set temperature calls in Mill (#155416) Co-authored-by: Martin Hjelmare --- homeassistant/components/mill/climate.py | 22 +- tests/components/mill/test_climate.py | 572 +++++++++++++++++++++++ 2 files changed, 585 insertions(+), 9 deletions(-) create mode 100644 tests/components/mill/test_climate.py diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index a9a920e3f52..3a8535b811b 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -7,6 +7,7 @@ from mill_local import OperationMode import voluptuous as vol from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -111,13 +112,16 @@ class MillHeater(MillBaseEntity, ClimateEntity): super().__init__(coordinator, device) async def async_set_temperature(self, **kwargs: Any) -> None: - """Set new target temperature.""" + """Set new target temperature and optionally HVAC mode.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return await self.coordinator.mill_data_connection.set_heater_temp( self._id, float(temperature) ) - await self.coordinator.async_request_refresh() + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + await self.async_handle_set_hvac_mode_service(hvac_mode) + else: + await self.coordinator.async_request_refresh() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -125,12 +129,11 @@ class MillHeater(MillBaseEntity, ClimateEntity): await self.coordinator.mill_data_connection.heater_control( self._id, power_status=True ) - await self.coordinator.async_request_refresh() elif hvac_mode == HVACMode.OFF: await self.coordinator.mill_data_connection.heater_control( self._id, power_status=False ) - await self.coordinator.async_request_refresh() + await self.coordinator.async_request_refresh() @callback def _update_attr(self, device: mill.Heater) -> None: @@ -189,25 +192,26 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit self._update_attr() async def async_set_temperature(self, **kwargs: Any) -> None: - """Set new target temperature.""" + """Set new target temperature and optionally HVAC mode.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return await self.coordinator.mill_data_connection.set_target_temperature( float(temperature) ) - await self.coordinator.async_request_refresh() + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + await self.async_handle_set_hvac_mode_service(hvac_mode) + else: + await self.coordinator.async_request_refresh() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" if hvac_mode == HVACMode.HEAT: await self.coordinator.mill_data_connection.set_operation_mode_control_individually() - await self.coordinator.async_request_refresh() elif hvac_mode == HVACMode.OFF: await self.coordinator.mill_data_connection.set_operation_mode_off() - await self.coordinator.async_request_refresh() elif hvac_mode == HVACMode.AUTO: await self.coordinator.mill_data_connection.set_operation_mode_weekly_program() - await self.coordinator.async_request_refresh() + await self.coordinator.async_request_refresh() @callback def _handle_coordinator_update(self) -> None: diff --git a/tests/components/mill/test_climate.py b/tests/components/mill/test_climate.py new file mode 100644 index 00000000000..0e72511bc11 --- /dev/null +++ b/tests/components/mill/test_climate.py @@ -0,0 +1,572 @@ +"""Tests for Mill climate.""" + +import contextlib +from contextlib import nullcontext +from unittest.mock import MagicMock, call, patch + +from mill import Heater +from mill_local import OperationMode +import pytest + +from homeassistant.components import mill +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.components.mill.const import DOMAIN +from homeassistant.components.recorder import Recorder +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + +HEATER_ID = "dev_id" +HEATER_NAME = "heater_name" +ENTITY_CLIMATE = f"climate.{HEATER_NAME}" + +TEST_SET_TEMPERATURE = 25 +TEST_AMBIENT_TEMPERATURE = 20 + +NULL_EFFECT = nullcontext() + +## MILL AND LOCAL MILL FIXTURES + + +@pytest.fixture +async def mock_mill(): + """Mock the mill.Mill object. + + It is imported and initialized only in /homeassistant/components/mill/__init__.py + """ + + with ( + patch( + "homeassistant.components.mill.Mill", + autospec=True, + ) as mock_mill_class, + ): + mill = mock_mill_class.return_value + mill.connect.return_value = True + mill.fetch_heater_and_sensor_data.return_value = {} + mill.fetch_historic_energy_usage.return_value = {} + yield mill + + +@pytest.fixture +async def mock_mill_local(): + """Mock the mill_local.Mill object.""" + + with ( + patch( + "homeassistant.components.mill.MillLocal", + autospec=True, + ) as mock_mill_local_class, + ): + milllocal = mock_mill_local_class.return_value + milllocal.url = "http://dummy.url" + milllocal.name = HEATER_NAME + milllocal.mac_address = "dead:beef" + milllocal.version = "0x210927" + milllocal.connect.return_value = { + "name": milllocal.name, + "mac_address": milllocal.mac_address, + "version": milllocal.version, + "operation_key": "", + "status": "ok", + } + status = { + "ambient_temperature": TEST_AMBIENT_TEMPERATURE, + "set_temperature": TEST_AMBIENT_TEMPERATURE, + "current_power": 0, + "control_signal": 0, + "raw_ambient_temperature": TEST_AMBIENT_TEMPERATURE, + "operation_mode": OperationMode.OFF.value, + } + milllocal.fetch_heater_and_sensor_data.return_value = status + milllocal._status = status + yield milllocal + + +## CLOUD HEATER INTEGRATION + + +@pytest.fixture +async def cloud_heater(hass: HomeAssistant, mock_mill: MagicMock) -> Heater: + """Load Mill integration and creates one cloud heater.""" + + heater = Heater( + name=HEATER_NAME, + device_id=HEATER_ID, + available=True, + is_heating=False, + power_status=False, + current_temp=float(TEST_AMBIENT_TEMPERATURE), + set_temp=float(TEST_AMBIENT_TEMPERATURE), + ) + + devices = {HEATER_ID: heater} + + mock_mill.fetch_heater_and_sensor_data.return_value = devices + mock_mill.devices = devices + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + mill.CONF_USERNAME: "user", + mill.CONF_PASSWORD: "pswd", + mill.CONNECTION_TYPE: mill.CLOUD, + }, + ) + config_entry.add_to_hass(hass) + + # We just need to load the climate component. + with patch("homeassistant.components.mill.PLATFORMS", [Platform.CLIMATE]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + return heater + + +@pytest.fixture +async def cloud_heater_set_temp(mock_mill: MagicMock, cloud_heater: MagicMock): + """Gets mock for the cloud heater `set_heater_temp` method.""" + return mock_mill.set_heater_temp + + +@pytest.fixture +async def cloud_heater_control(mock_mill: MagicMock, cloud_heater: MagicMock): + """Gets mock for the cloud heater `heater_control` method.""" + return mock_mill.heater_control + + +@pytest.fixture +async def functional_cloud_heater( + cloud_heater: MagicMock, + cloud_heater_set_temp: MagicMock, + cloud_heater_control: MagicMock, +) -> Heater: + """Make sure the cloud heater is "functional". + + This will create a pseudo-functional cloud heater, + meaning that function calls will edit the original cloud heater + in a similar way that the API would. + """ + + def calculate_heating(): + if ( + cloud_heater.power_status + and cloud_heater.set_temp > cloud_heater.current_temp + ): + cloud_heater.is_heating = True + + def set_temperature(device_id: str, set_temp: float): + assert device_id == HEATER_ID, "set_temperature called with wrong device_id" + + cloud_heater.set_temp = set_temp + + calculate_heating() + + def heater_control(device_id: str, power_status: bool): + assert device_id == HEATER_ID, "set_temperature called with wrong device_id" + + # power_status gives the "do we want to heat, Y/N", while is_heating is based on temperature and internal state and whatnot. + cloud_heater.power_status = power_status + + calculate_heating() + + cloud_heater_set_temp.side_effect = set_temperature + cloud_heater_control.side_effect = heater_control + + return cloud_heater + + +## LOCAL HEATER INTEGRATION + + +@pytest.fixture +async def local_heater(hass: HomeAssistant, mock_mill_local: MagicMock) -> dict: + """Local Mill Heater. + + This returns a by-reference status dict + with which this heater's information is organised and updated. + """ + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + mill.CONF_IP_ADDRESS: "192.168.1.59", + mill.CONNECTION_TYPE: mill.LOCAL, + }, + ) + config_entry.add_to_hass(hass) + + # We just need to load the climate component. + with patch("homeassistant.components.mill.PLATFORMS", [Platform.CLIMATE]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + return mock_mill_local._status + + +@pytest.fixture +async def local_heater_set_target_temperature( + mock_mill_local: MagicMock, local_heater: MagicMock +): + """Gets mock for the local heater `set_target_temperature` method.""" + return mock_mill_local.set_target_temperature + + +@pytest.fixture +async def local_heater_set_mode_control_individually( + mock_mill_local: MagicMock, local_heater: MagicMock +): + """Gets mock for the local heater `set_operation_mode_control_individually` method.""" + return mock_mill_local.set_operation_mode_control_individually + + +@pytest.fixture +async def local_heater_set_mode_off( + mock_mill_local: MagicMock, local_heater: MagicMock +): + """Gets mock for the local heater `set_operation_mode_off` method.""" + return mock_mill_local.set_operation_mode_off + + +@pytest.fixture +async def functional_local_heater( + mock_mill_local: MagicMock, + local_heater_set_target_temperature: MagicMock, + local_heater_set_mode_control_individually: MagicMock, + local_heater_set_mode_off: MagicMock, + local_heater: MagicMock, +) -> None: + """Make sure the local heater is "functional". + + This will create a pseudo-functional local heater, + meaning that function calls will edit the original local heater + in a similar way that the API would. + """ + + def set_temperature(target_temperature: float): + local_heater["set_temperature"] = target_temperature + + def set_operation_mode(operation_mode: OperationMode): + local_heater["operation_mode"] = operation_mode.value + + def mode_control_individually(): + set_operation_mode(OperationMode.CONTROL_INDIVIDUALLY) + + def mode_off(): + set_operation_mode(OperationMode.OFF) + + local_heater_set_target_temperature.side_effect = set_temperature + local_heater_set_mode_control_individually.side_effect = mode_control_individually + local_heater_set_mode_off.side_effect = mode_off + + +### CLOUD + + +@pytest.mark.parametrize( + ( + "before_state", + "before_attrs", + "service_name", + "service_params", + "effect", + "heater_control_calls", + "heater_set_temp_calls", + "after_state", + "after_attrs", + ), + [ + # set_hvac_mode + ( + HVACMode.OFF, + {}, + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + NULL_EFFECT, + [call(HEATER_ID, power_status=True)], + [], + HVACMode.HEAT, + {}, + ), + ( + HVACMode.OFF, + {}, + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + NULL_EFFECT, + [call(HEATER_ID, power_status=False)], + [], + HVACMode.OFF, + {}, + ), + ( + HVACMode.OFF, + {}, + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.COOL}, + pytest.raises(HomeAssistantError), + [], + [], + HVACMode.OFF, + {}, + ), + # set_temperature (with hvac mode) + ( + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE}, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.HEAT}, + NULL_EFFECT, + [call(HEATER_ID, power_status=True)], + [call(HEATER_ID, float(TEST_SET_TEMPERATURE))], + HVACMode.HEAT, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE}, + ), + ( + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE}, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.OFF}, + NULL_EFFECT, + [call(HEATER_ID, power_status=False)], + [call(HEATER_ID, float(TEST_SET_TEMPERATURE))], + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE}, + ), + ( + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE}, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE}, + NULL_EFFECT, + [], + [call(HEATER_ID, float(TEST_SET_TEMPERATURE))], + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE}, + ), + ( + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE}, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.COOL}, + pytest.raises(HomeAssistantError), + # MillHeater will set the temperature before calling async_handle_set_hvac_mode, + # meaning an invalid HVAC mode will raise only after the temperature is set. + [], + [call(HEATER_ID, float(TEST_SET_TEMPERATURE))], + HVACMode.OFF, + # likewise, in this test, it hasn't had the chance to update its ambient temperature, + # because the exception is raised before a refresh can be requested from the coordinator + {ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE}, + ), + ], +) +async def test_cloud_heater( + recorder_mock: Recorder, + hass: HomeAssistant, + functional_cloud_heater: MagicMock, + cloud_heater_control: MagicMock, + cloud_heater_set_temp: MagicMock, + before_state: HVACMode, + before_attrs: dict, + service_name: str, + service_params: dict, + effect: "contextlib.AbstractContextManager", + heater_control_calls: list, + heater_set_temp_calls: list, + after_state: HVACMode, + after_attrs: dict, +) -> None: + """Tests setting HVAC mode (directly or through set_temperature) for a cloud heater.""" + + state = hass.states.get(ENTITY_CLIMATE) + assert state is not None + assert state.state == before_state + for attr, value in before_attrs.items(): + assert state.attributes.get(attr) == value + + with effect: + await hass.services.async_call( + CLIMATE_DOMAIN, + service_name, + service_params | {ATTR_ENTITY_ID: ENTITY_CLIMATE}, + blocking=True, + ) + + await hass.async_block_till_done() + + cloud_heater_control.assert_has_calls(heater_control_calls) + cloud_heater_set_temp.assert_has_calls(heater_set_temp_calls) + + state = hass.states.get(ENTITY_CLIMATE) + assert state is not None + assert state.state == after_state + for attr, value in after_attrs.items(): + assert state.attributes.get(attr) == value + + +### LOCAL + + +@pytest.mark.parametrize( + ( + "before_state", + "before_attrs", + "service_name", + "service_params", + "effect", + "heater_mode_set_individually_calls", + "heater_mode_set_off_calls", + "heater_set_target_temperature_calls", + "after_state", + "after_attrs", + ), + [ + # set_hvac_mode + ( + HVACMode.OFF, + {}, + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + NULL_EFFECT, + [call()], + [], + [], + HVACMode.HEAT, + {}, + ), + ( + HVACMode.OFF, + {}, + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + NULL_EFFECT, + [], + [call()], + [], + HVACMode.OFF, + {}, + ), + ( + HVACMode.OFF, + {}, + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.COOL}, + pytest.raises(HomeAssistantError), + [], + [], + [], + HVACMode.OFF, + {}, + ), + # set_temperature (with hvac mode) + ( + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE}, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.HEAT}, + NULL_EFFECT, + [call()], + [], + [call(float(TEST_SET_TEMPERATURE))], + HVACMode.HEAT, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE}, + ), + ( + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE}, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.OFF}, + NULL_EFFECT, + [], + [call()], + [call(float(TEST_SET_TEMPERATURE))], + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE}, + ), + ( + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE}, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE}, + NULL_EFFECT, + [], + [], + [call(float(TEST_SET_TEMPERATURE))], + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE}, + ), + ( + HVACMode.OFF, + {ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE}, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.COOL}, + pytest.raises(HomeAssistantError), + # LocalMillHeater will set the temperature before calling async_handle_set_hvac_mode, + # meaning an invalid HVAC mode will raise only after the temperature is set. + [], + [], + [call(float(TEST_SET_TEMPERATURE))], + HVACMode.OFF, + # likewise, in this test, it hasn't had the chance to update its ambient temperature, + # because the exception is raised before a refresh can be requested from the coordinator + {ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE}, + ), + ], +) +async def test_local_heater( + hass: HomeAssistant, + functional_local_heater: MagicMock, + local_heater_set_mode_control_individually: MagicMock, + local_heater_set_mode_off: MagicMock, + local_heater_set_target_temperature: MagicMock, + before_state: HVACMode, + before_attrs: dict, + service_name: str, + service_params: dict, + effect: "contextlib.AbstractContextManager", + heater_mode_set_individually_calls: list, + heater_mode_set_off_calls: list, + heater_set_target_temperature_calls: list, + after_state: HVACMode, + after_attrs: dict, +) -> None: + """Tests setting HVAC mode (directly or through set_temperature) for a local heater.""" + + state = hass.states.get(ENTITY_CLIMATE) + assert state is not None + assert state.state == before_state + for attr, value in before_attrs.items(): + assert state.attributes.get(attr) == value + + with effect: + await hass.services.async_call( + CLIMATE_DOMAIN, + service_name, + service_params | {ATTR_ENTITY_ID: ENTITY_CLIMATE}, + blocking=True, + ) + await hass.async_block_till_done() + + local_heater_set_mode_control_individually.assert_has_calls( + heater_mode_set_individually_calls + ) + local_heater_set_mode_off.assert_has_calls(heater_mode_set_off_calls) + local_heater_set_target_temperature.assert_has_calls( + heater_set_target_temperature_calls + ) + + state = hass.states.get(ENTITY_CLIMATE) + assert state is not None + assert state.state == after_state + for attr, value in after_attrs.items(): + assert state.attributes.get(attr) == value