Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion custom_components/sat/area.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 7 additions & 4 deletions custom_components/sat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
DeRRudi77 marked this conversation as resolved.

async def _async_inside_sensor_changed(self, event: Event[EventStateChangedData]) -> None:
Expand Down Expand Up @@ -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:
Expand Down
119 changes: 119 additions & 0 deletions tests/test_area.py
Original file line number Diff line number Diff line change
@@ -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
118 changes: 118 additions & 0 deletions tests/test_climate.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)