Skip to content

Commit

Permalink
feeat: allows setting scope for openings
Browse files Browse the repository at this point in the history
Fixes #155
  • Loading branch information
= authored and swingerman committed Apr 19, 2024
1 parent b448cfa commit dca8cda
Show file tree
Hide file tree
Showing 13 changed files with 497 additions and 27 deletions.
10 changes: 7 additions & 3 deletions custom_components/dual_smart_thermostat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
FeatureManager,
)
from custom_components.dual_smart_thermostat.managers.opening_manager import (
OpeningHvacModeScope,
OpeningManager,
)
from custom_components.dual_smart_thermostat.managers.preset_manager import (
Expand Down Expand Up @@ -100,6 +101,7 @@
CONF_MIN_FLOOR_TEMP,
CONF_MIN_TEMP,
CONF_OPENINGS,
CONF_OPENINGS_SCOPE,
CONF_PRECISION,
CONF_PRESETS,
CONF_PRESETS_OLD,
Expand Down Expand Up @@ -149,7 +151,10 @@
}

OPENINGS_SCHEMA = {
vol.Optional(CONF_OPENINGS): [vol.Any(cv.entity_id, TIMED_OPENING_SCHEMA)]
vol.Optional(CONF_OPENINGS): [vol.Any(cv.entity_id, TIMED_OPENING_SCHEMA)],
vol.Optional(CONF_OPENINGS_SCOPE): vol.Any(
OpeningHvacModeScope, [scope.value for scope in OpeningHvacModeScope]
),
}

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
Expand Down Expand Up @@ -215,7 +220,6 @@ async def async_setup_platform(
name = config[CONF_NAME]
sensor_entity_id = config[CONF_SENSOR]
sensor_floor_entity_id = config.get(CONF_FLOOR_SENSOR)
openings = config.get(CONF_OPENINGS)
keep_alive = config.get(CONF_KEEP_ALIVE)
presets_dict = {
key: config[value] for key, value in CONF_PRESETS.items() if value in config
Expand Down Expand Up @@ -243,7 +247,7 @@ async def async_setup_platform(
unit = hass.config.units.temperature_unit
unique_id = config.get(CONF_UNIQUE_ID)

opening_manager = OpeningManager(hass, openings)
opening_manager = OpeningManager(hass, config)

temperature_manager = TemperatureManager(
hass,
Expand Down
1 change: 1 addition & 0 deletions custom_components/dual_smart_thermostat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
CONF_PRECISION = "precision"
CONF_TEMP_STEP = "target_temp_step"
CONF_OPENINGS = "openings"
CONF_OPENINGS_SCOPE = "openings_scope"
CONF_HEAT_COOL_MODE = "heat_cool_mode"

ATTR_PREV_TARGET = "prev_target_temp"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ def hvac_mode(self, hvac_mode: HVACMode):
async def async_set_hvac_mode(self, hvac_mode: HVACMode):
_LOGGER.info("Setting hvac mode to %s of %s", hvac_mode, self.hvac_modes)
if hvac_mode in self.hvac_modes:
self._hvac_mode = hvac_mode
self.hvac_mode = hvac_mode
else:
self._hvac_mode = HVACMode.OFF
self.hvac_mode = HVACMode.OFF

if self._hvac_mode == HVACMode.OFF:
if self.hvac_mode == HVACMode.OFF:
await self.async_turn_off()
self._hvac_action_reason = HVACActionReason.NONE
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def __init__(

if initial_hvac_mode in self.hvac_modes:
self._hvac_mode = initial_hvac_mode
self._set_sub_device_hvac_mode(initial_hvac_mode)
else:
self._hvac_mode = None

Expand Down Expand Up @@ -89,6 +90,13 @@ def hvac_mode(self) -> HVACMode:
@hvac_mode.setter
def hvac_mode(self, hvac_mode: HVACMode):
self._hvac_mode = hvac_mode
self._set_sub_device_hvac_mode(hvac_mode)

def _set_sub_device_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode in self.cooler_device.hvac_modes:
self.cooler_device.hvac_mode = hvac_mode
if hvac_mode in self.fan_device.hvac_modes and hvac_mode is not HVACMode.OFF:
self.fan_device.hvac_mode = hvac_mode

async def async_on_startup(self):

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ async def _async_control_devices_when_off(self, time=None) -> None:
too_cold = self.temperatures.is_too_cold(self._target_temp_attr)
is_floor_hot = self.temperatures.is_floor_hot
is_floor_cold = self.temperatures.is_floor_cold
any_opening_open = self.openings.any_opening_open
any_opening_open = self.openings.any_opening_open(self.hvac_mode)

if (too_cold and not any_opening_open and not is_floor_hot) or is_floor_cold:

Expand Down Expand Up @@ -202,7 +202,7 @@ async def _async_control_devices_when_on(self, time=None) -> None:
too_hot = self.temperatures.is_too_hot(self._target_temp_attr)
is_floor_hot = self.temperatures.is_floor_hot
is_floor_cold = self.temperatures.is_floor_cold
any_opening_open = self.openings.any_opening_open
any_opening_open = self.openings.any_opening_open(self.hvac_mode)
first_stage_timed_out = self._first_stage_heating_timed_out()

_LOGGER.info(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode):
_LOGGER.info("Setting hvac mode to %s of %s", hvac_mode, self.hvac_modes)
if hvac_mode in self.hvac_modes:
_LOGGER.debug("hvac mode found")
self._hvac_mode = hvac_mode
self.hvac_mode = hvac_mode

if hvac_mode is not HVACMode.OFF:
# handles HVACmode.HEAT
Expand Down Expand Up @@ -179,7 +179,7 @@ async def _async_control_heat_cool(self, time=None, force=False) -> None:
if not self._active and self.temperatures.cur_temp is not None:
self._active = True

if self.openings.any_opening_open:
if self.openings.any_opening_open(self.hvac_mode):
await self.async_turn_off()
self._hvac_action_reason = HVACActionReason.OPENING
elif self.temperatures.is_floor_hot:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ async def _async_control_device_when_on(self, time=None) -> None:
too_hot = self.temperatures.is_too_hot(self._target_temp_attr)
is_floor_hot = self.temperatures.is_floor_hot
is_floor_cold = self.temperatures.is_floor_cold
any_opening_open = self.openings.any_opening_open
any_opening_open = self.openings.any_opening_open(self.hvac_mode)

_LOGGER.debug("_async_control_device_when_on, floor cold: %s", is_floor_cold)

Expand All @@ -88,7 +88,7 @@ async def _async_control_device_when_on(self, time=None) -> None:
self._hvac_action_reason = HVACActionReason.TARGET_TEMP_REACHED
if is_floor_hot:
self._hvac_action_reason = HVACActionReason.OVERHEAT
if self.openings.any_opening_open:
if any_opening_open:
self._hvac_action_reason = HVACActionReason.OPENING

elif time is not None and not any_opening_open and not is_floor_hot:
Expand All @@ -107,7 +107,7 @@ async def _async_control_device_when_off(self, time=None) -> None:
too_cold = self.temperatures.is_too_cold(self._target_temp_attr)
is_floor_hot = self.temperatures.is_floor_hot
is_floor_cold = self.temperatures.is_floor_cold
any_opening_open = self.openings.any_opening_open
any_opening_open = self.openings.any_opening_open(self.hvac_mode)

if (too_cold and not any_opening_open and not is_floor_hot) or is_floor_cold:
_LOGGER.debug("Turning on heater (from inactive) %s", self.entity_id)
Expand All @@ -126,5 +126,5 @@ async def _async_control_device_when_off(self, time=None) -> None:

if is_floor_hot:
self._hvac_action_reason = HVACActionReason.OVERHEAT
if self.openings.any_opening_open:
if any_opening_open:
self._hvac_action_reason = HVACActionReason.OPENING
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ async def async_control_hvac(self, time=None, force=False):
if not self._needs_control(time, force):
return

any_opening_open = self.openings.any_opening_open
any_opening_open = self.openings.any_opening_open(self.hvac_mode)

_LOGGER.info(
"%s - async_control_hvac - is device active: %s, %s, is opening open: %s",
Expand All @@ -194,7 +194,7 @@ async def async_control_hvac(self, time=None, force=False):
async def _async_control_when_active(self, time=None) -> None:
_LOGGER.debug("%s _async_control_when_active", self.__class__.__name__)
too_cold = self.temperatures.is_too_cold(self._target_temp_attr)
any_opening_open = self.openings.any_opening_open
any_opening_open = self.openings.any_opening_open(self.hvac_mode)

if too_cold or any_opening_open:
_LOGGER.debug("Turning off entity %s", self.entity_id)
Expand All @@ -215,7 +215,7 @@ async def _async_control_when_active(self, time=None) -> None:

async def _async_control_when_inactive(self, time=None) -> None:
too_hot = self.temperatures.is_too_hot(self._target_temp_attr)
any_opening_open = self.openings.any_opening_open
any_opening_open = self.openings.any_opening_open(self.hvac_mode)

_LOGGER.debug("too_hot: %s", too_hot)
_LOGGER.debug("any_opening_open: %s", any_opening_open)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
"""Opening Manager for Dual Smart Thermostat."""

from enum import StrEnum
from itertools import chain
import logging
from typing import List

from homeassistant.components.climate import HVACMode
from homeassistant.const import (
ATTR_ENTITY_ID,
STATE_ON,
Expand All @@ -11,20 +15,40 @@
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import condition
from homeassistant.helpers.typing import ConfigType

from custom_components.dual_smart_thermostat.const import (
ATTR_TIMEOUT,
CONF_OPENINGS,
CONF_OPENINGS_SCOPE,
TIMED_OPENING_SCHEMA,
)

_LOGGER = logging.getLogger(__name__)


class OpeningHvacModeScope(StrEnum):
"""Opening Scope Options"""

_ignore_ = "member cls"
cls = vars()
for member in chain(list(HVACMode)):
cls[member.name] = member.value

ALL = "all"


class OpeningManager:
"""Opening Manager for Dual Smart Thermostat."""

def __init__(self, hass: HomeAssistant, openings) -> None:
def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
self.hass = hass

openings = config.get(CONF_OPENINGS)
self.openings_scope: List[OpeningHvacModeScope] = config.get(
CONF_OPENINGS_SCOPE
) or [OpeningHvacModeScope.ALL]

self.openings = self.conform_openings_list(openings) if openings else []
self.opening_entities = (
self.conform_opnening_entities(self.openings) if openings else []
Expand All @@ -47,18 +71,35 @@ def conform_opnening_entities(openings: [TIMED_OPENING_SCHEMA]) -> list: # type
"""Return a list of entities from a list of openings."""
return [entry[ATTR_ENTITY_ID] for entry in openings]

@property
def any_opening_open(self) -> bool:
def any_opening_open(
self, hvac_mode_scope: OpeningHvacModeScope = OpeningHvacModeScope.ALL
) -> bool:
"""If any opening is currently open."""
_LOGGER.debug("_any_opening_open")
if not self.opening_entities:
return False

_is_open = False
for opening in self.openings:
if self._is_opening_open(opening):
_is_open = True
break

_LOGGER.debug("Checking openings: %s", self.opening_entities)
_LOGGER.debug("hvac_mode_scope: %s", hvac_mode_scope)

if (
# the requester doesn't care about the scope or defaultt
hvac_mode_scope == OpeningHvacModeScope.ALL
# the requester sets it's scope and it's in the scope
# in case of ALL, it's always in the scope
or (
self.openings_scope is not [OpeningHvacModeScope.ALL]
and hvac_mode_scope in self.openings_scope
)
# the scope is not restricted at all
or OpeningHvacModeScope.ALL in self.openings_scope
):
for opening in self.openings:
if self._is_opening_open(opening):
_is_open = True
break

return _is_open

Expand Down
100 changes: 99 additions & 1 deletion tests/test_cooler_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@
HVACMode,
)
from homeassistant.components.climate.const import DOMAIN as CLIMATE
from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON
from homeassistant.const import (
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_CLOSED,
STATE_OFF,
STATE_ON,
STATE_OPEN,
)
from homeassistant.core import DOMAIN as HASS_DOMAIN, HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
Expand Down Expand Up @@ -1173,3 +1180,94 @@ async def test_cooler_mode_opening(
await hass.async_block_till_done()

assert hass.states.get(cooler_switch).state == STATE_ON


@pytest.mark.parametrize(
["hvac_mode", "oepning_scope", "switch_state"],
[
([HVACMode.COOL, ["all"], STATE_OFF]),
([HVACMode.COOL, [HVACMode.COOL], STATE_OFF]),
([HVACMode.COOL, [HVACMode.FAN_ONLY], STATE_ON]),
],
)
async def test_cooler_mode_opening_scope(
hass: HomeAssistant,
hvac_mode,
oepning_scope,
switch_state,
setup_comp_1, # noqa: F811
) -> None:
"""Test thermostat cooler switch in cooling mode."""
cooler_switch = "input_boolean.test"

opening_1 = "input_boolean.opening_1"

assert await async_setup_component(
hass,
input_boolean.DOMAIN,
{
"input_boolean": {
"test": None,
"test_fan": None,
"opening_1": None,
"opening_2": None,
}
},
)

assert await async_setup_component(
hass,
input_number.DOMAIN,
{
"input_number": {
"temp": {"name": "test", "initial": 10, "min": 0, "max": 40, "step": 1}
}
},
)

assert await async_setup_component(
hass,
CLIMATE,
{
"climate": {
"platform": DOMAIN,
"name": "test",
"heater": cooler_switch,
"ac_mode": "true",
"target_sensor": common.ENT_SENSOR,
"initial_hvac_mode": hvac_mode,
"openings": [
opening_1,
],
"openings_scope": oepning_scope,
}
},
)
await hass.async_block_till_done()

assert hass.states.get(cooler_switch).state == STATE_OFF

setup_sensor(hass, 23)
await hass.async_block_till_done()

await common.async_set_temperature(hass, 18)
await hass.async_block_till_done()
assert (
hass.states.get(cooler_switch).state == STATE_ON
if hvac_mode == HVACMode.COOL
else STATE_OFF
)

setup_boolean(hass, opening_1, STATE_OPEN)
await hass.async_block_till_done()

assert hass.states.get(cooler_switch).state == switch_state

setup_boolean(hass, opening_1, STATE_CLOSED)
await hass.async_block_till_done()

assert (
hass.states.get(cooler_switch).state == STATE_ON
if hvac_mode == HVACMode.COOL
else STATE_OFF
)
Loading

0 comments on commit dca8cda

Please sign in to comment.