diff --git a/custom_components/sat/area.py b/custom_components/sat/area.py index c3bca8d5..03a81317 100644 --- a/custom_components/sat/area.py +++ b/custom_components/sat/area.py @@ -37,7 +37,7 @@ def state(self) -> State | None: if (self._hass is None) or (state := self._hass.states.get(self._entity_id)) is None: return None - return state if state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE] else None + return state if state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF] else None @property def target_temperature(self) -> float | None: diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 932f8d8f..27081a57 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -610,11 +610,13 @@ async def _async_thermostat_changed(self, event: Event[EventStateChangedData]) - if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return - if ( - old_state.state != new_state.state or - old_state.attributes.get("temperature") != new_state.attributes.get("temperature") - ): + if old_state.state != new_state.state: _LOGGER.debug("Thermostat State Changed.") + self._async_control_pid(True) + self.schedule_control_heating_loop() + + if old_state.attributes.get("temperature") != new_state.attributes.get("temperature"): + _LOGGER.debug("Thermostat Temperature Changed.") await self.async_set_target_temperature(new_state.attributes.get("temperature"), cascade=False) async def _async_inside_sensor_changed(self, event: Event[EventStateChangedData]) -> None: @@ -661,6 +663,7 @@ async def _async_main_climate_changed(self, event: Event[EventStateChangedData]) if old_state is None or new_state.state != old_state.state: _LOGGER.debug(f"Main Climate State Changed ({new_state.entity_id}).") + self._async_control_pid(True) self.schedule_control_heating_loop() async def _async_climate_changed(self, event: Event[EventStateChangedData]) -> None: diff --git a/tests/test_area.py b/tests/test_area.py new file mode 100644 index 00000000..daa22f58 --- /dev/null +++ b/tests/test_area.py @@ -0,0 +1,119 @@ +"""Tests for the Area class.""" + +import pytest +from unittest.mock import MagicMock +from homeassistant.components.climate import HVACMode +from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE +from homeassistant.core import State + +from custom_components.sat.area import Area +from custom_components.sat.const import * + + +def create_config_data(entity_id="climate.room1"): + return {CONF_ROOMS: [entity_id]} + + +def create_config_options(): + options = OPTIONS_DEFAULTS.copy() + return options + + +def mock_hass_with_climate_state(entity_id, hvac_state, target_temp=21.0, current_temp=20.0): + """Create a mock hass with a climate entity in the given state.""" + hass = MagicMock() + state = State(entity_id, hvac_state, { + "temperature": target_temp, + "current_temperature": current_temp, + }) + hass.states.get.return_value = state + return hass + + +class TestAreaState: + def test_state_returns_none_when_off(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", HVACMode.OFF) + + assert area.state is None + + def test_state_returns_none_when_unknown(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", STATE_UNKNOWN) + + assert area.state is None + + def test_state_returns_none_when_unavailable(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", STATE_UNAVAILABLE) + + assert area.state is None + + def test_state_returns_state_when_heating(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", HVACMode.HEAT) + + assert area.state is not None + assert area.state.state == HVACMode.HEAT + + +class TestAreaError: + def test_error_is_none_when_off(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", HVACMode.OFF, target_temp=21.0, current_temp=19.0) + + assert area.error is None + + def test_error_calculated_when_heating(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", HVACMode.HEAT, target_temp=21.0, current_temp=19.0) + + assert area.error is not None + assert area.error.value == 2.0 + + def test_error_is_none_when_unavailable(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", STATE_UNAVAILABLE, target_temp=21.0, current_temp=19.0) + + assert area.error is None + + +class TestAreaWeight: + def test_weight_is_none_when_off(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", HVACMode.OFF, target_temp=21.0, current_temp=19.0) + + assert area.weight is None + + def test_weight_calculated_when_heating(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", HVACMode.HEAT, target_temp=21.0, current_temp=19.0) + + assert area.weight is not None + assert area.weight > 0 + + +class TestAreaTemperatures: + def test_target_temperature_none_when_off(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", HVACMode.OFF, target_temp=21.0) + + assert area.target_temperature is None + + def test_current_temperature_none_when_off(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", HVACMode.OFF, current_temp=19.0) + + assert area.current_temperature is None + + def test_target_temperature_available_when_heating(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", HVACMode.HEAT, target_temp=21.0) + + assert area.target_temperature == 21.0 + + def test_current_temperature_available_when_heating(self): + area = Area(create_config_data(), create_config_options(), "climate.room1") + area._hass = mock_hass_with_climate_state("climate.room1", HVACMode.HEAT, current_temp=19.0) + + assert area.current_temperature == 19.0 diff --git a/tests/test_climate.py b/tests/test_climate.py index d74c1044..bbf55e7b 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -1,6 +1,7 @@ """The tests for the climate component.""" import pytest +from unittest.mock import patch from homeassistant.components.climate import HVACMode from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.template import DOMAIN as TEMPLATE_DOMAIN @@ -150,3 +151,120 @@ async def test_scenario_3(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.pulse_width_modulation_enabled assert climate.pwm.last_duty_cycle_percentage == 53.62 assert climate.pwm.duty_cycle == (643, 556) + + +@pytest.mark.parametrize(*[ + "domains, data, options, config", + [( + [(TEMPLATE_DOMAIN, 1)], + { + CONF_MODE: MODE_FAKE, + CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, + CONF_MINIMUM_SETPOINT: 57, + CONF_MAXIMUM_SETPOINT: 75, + CONF_THERMOSTAT: ["climate.better_thermostat"], + }, + { + CONF_HEATING_CURVE_COEFFICIENT: 1.8, + CONF_FORCE_PULSE_WIDTH_MODULATION: True, + }, + { + TEMPLATE_DOMAIN: [ + { + SENSOR_DOMAIN: [ + { + "name": "test_inside_sensor", + "state": "{{ 20.9 | float }}", + }, + { + "name": "test_outside_sensor", + "state": "{{ 9.9 | float }}", + } + ] + }, + ], + }, + )], +]) +async def test_thermostat_state_change_triggers_pid_recalculation( + hass: HomeAssistant, entry: MockConfigEntry, climate: SatClimate, coordinator: SatFakeCoordinator +) -> None: + """Test that PID recalculates when the connected thermostat changes state.""" + hass.states.async_set("climate.better_thermostat", HVACMode.HEAT, { + "temperature": 21.0, + "current_temperature": 20.9, + "hvac_modes": [HVACMode.HEAT, HVACMode.OFF], + }) + await hass.async_block_till_done() + + await coordinator.async_set_boiler_temperature(57) + await climate.async_set_target_temperature(21.0) + await climate.async_set_hvac_mode(HVACMode.HEAT) + + with patch.object(climate, "_async_control_pid", wraps=climate._async_control_pid) as spy: + hass.states.async_set("climate.better_thermostat", HVACMode.OFF, { + "temperature": 21.0, + "current_temperature": 20.9, + "hvac_modes": [HVACMode.HEAT, HVACMode.OFF], + }) + await hass.async_block_till_done() + spy.assert_any_call(True) + + +@pytest.mark.parametrize(*[ + "domains, data, options, config", + [( + [(TEMPLATE_DOMAIN, 1)], + { + CONF_MODE: MODE_FAKE, + CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, + CONF_MINIMUM_SETPOINT: 57, + CONF_MAXIMUM_SETPOINT: 75, + CONF_RADIATORS: ["climate.radiator1"], + }, + { + CONF_HEATING_CURVE_COEFFICIENT: 1.8, + CONF_FORCE_PULSE_WIDTH_MODULATION: True, + }, + { + TEMPLATE_DOMAIN: [ + { + SENSOR_DOMAIN: [ + { + "name": "test_inside_sensor", + "state": "{{ 20.9 | float }}", + }, + { + "name": "test_outside_sensor", + "state": "{{ 9.9 | float }}", + } + ] + }, + ], + }, + )], +]) +async def test_main_climate_state_change_triggers_pid_recalculation( + hass: HomeAssistant, entry: MockConfigEntry, climate: SatClimate, coordinator: SatFakeCoordinator +) -> None: + """Test that PID recalculates when a main climate/radiator changes state.""" + hass.states.async_set("climate.radiator1", HVACMode.HEAT, { + "temperature": 21.0, + "current_temperature": 20.9, + "hvac_modes": [HVACMode.HEAT, HVACMode.OFF], + "hvac_action": "heating", + }) + await hass.async_block_till_done() + + await coordinator.async_set_boiler_temperature(57) + await climate.async_set_target_temperature(21.0) + await climate.async_set_hvac_mode(HVACMode.HEAT) + + with patch.object(climate, "_async_control_pid", wraps=climate._async_control_pid) as spy: + hass.states.async_set("climate.radiator1", HVACMode.OFF, { + "temperature": 21.0, + "current_temperature": 20.9, + "hvac_modes": [HVACMode.HEAT, HVACMode.OFF], + }) + await hass.async_block_till_done() + spy.assert_any_call(True)