Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feeat: allows setting scope for openings #166

Merged
merged 2 commits into from
Apr 19, 2024
Merged
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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ The `openings` configuration variable accepts a list of opening entities and ope
An opening entity is a sensor that can be in two states: `on` or `off`. If the state is `on` the opening is considered open, if the state is `off` the opening is considered closed.
The opening object can contain a timeout property that defines the time in seconds after which the opening is considered open even if the state is still `on`. This is useful if you would want to ignor windows opened only for a short time.

### Openings Scope

The `openings_scope` configuration variable defines the scope of the openings. If set to `all` or not defined, any open openings will turn off the current hvac device and it will be in the idle state. If set, only devices that operating in the defined HVAC modes will be turned off. For example, if set to `heat` only the heater will be turned off if any of the openings are open.

### Openings Scope Configuration

```yaml
openings_scope: [heat, cool, heat_cool, fan_only]
```

```yaml
openings_scope:
- heat
- cool
```

## Openings Configuration

```yaml
Expand All @@ -143,6 +159,7 @@ climate:
- binary_sensor.window2
- entity_id: binary_sensor.window3
timeout: 00:00:30
openings_scope: [heat, cool]
target_sensor: sensor.study_temperature
```

Expand Down Expand Up @@ -322,6 +339,19 @@ The internal values can be set by the component only and the external values can

`timeout: <value>` The time after which the opening is considered open even if the state is still `on` (timedata)</br>

### openings_scope

_(optional) (array[string])_ "The scope of the openings. If set to [`all`] or not defined, any open openings will turn off the current hvac device and it will be in the idle state. If set, only devices that operating in the defined HVAC modes will be turned off. For example, if set to `heat` only the heater will be turned off if any of the openings are open."

_default: `all`_

options:
- `all`
- `heat`
- `cool`
- `heat_cool`
- `fan_only`

### min_temp

_(optional) (float)_
Expand Down
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 != [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
Loading