Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
121 changes: 121 additions & 0 deletions tests/test_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,124 @@ 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)

initial_error = climate.pid.last_error

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()

assert climate.pid.last_error != initial_error or climate.pid.last_error == climate.error
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


@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)

initial_error = climate.pid.last_error

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()

assert climate.pid.last_error != initial_error or climate.pid.last_error == climate.error