Skip to content

Commit

Permalink
Feature Request - open time for openings
Browse files Browse the repository at this point in the history
Fixes #56
  • Loading branch information
= authored and swingerman committed Jul 14, 2023
1 parent 03189e8 commit 07bd399
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 29 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/quality-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ jobs:
fetch-depth: 0
- name: SonarCloud Scan
uses: sonarsource/sonarcloud-github-action@master
with:
args: >
-Dsonar.python.coverage.reportPaths=coverage.xml
-Dsonar.tests=tests/
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
127 changes: 100 additions & 27 deletions custom_components/dual_smart_thermostat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import asyncio
from datetime import timedelta
import logging
from typing import List
from typing import Any, List

import voluptuous as vol

Expand Down Expand Up @@ -71,6 +71,7 @@
CONF_TARGET_TEMP_HIGH,
CONF_TARGET_TEMP_LOW,
CONF_TEMP_STEP,
ATTR_TIMEOUT,
DEFAULT_MAX_FLOOR_TEMP,
DEFAULT_NAME,
DEFAULT_TOLERANCE,
Expand Down Expand Up @@ -99,13 +100,19 @@

_LOGGER = logging.getLogger(__name__)


PRESET_SCHEMA = {
vol.Optional(ATTR_TEMPERATURE): vol.Coerce(float),
vol.Optional(ATTR_TARGET_TEMP_LOW): vol.Coerce(float),
vol.Optional(ATTR_TARGET_TEMP_HIGH): vol.Coerce(float),
}

TIMED_OPENING_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_TIMEOUT): vol.All(cv.time_period, cv.positive_timedelta),
}
)

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HEATER): cv.entity_id,
Expand Down Expand Up @@ -136,7 +143,7 @@
vol.Optional(CONF_TEMP_STEP): vol.In(
[PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]
),
vol.Optional(CONF_OPENINGS): [cv.entity_id],
vol.Optional(CONF_OPENINGS): [vol.Any(cv.entity_id, TIMED_OPENING_SCHEMA)],
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
).extend({vol.Optional(v): PRESET_SCHEMA for (k, v) in CONF_PRESETS.items()})
Expand Down Expand Up @@ -167,7 +174,7 @@ async def async_setup_platform(
)
cooler_entity_id = None
sensor_floor_entity_id = config.get(CONF_FLOOR_SENSOR)
opening_entities = config.get(CONF_OPENINGS)
openings = config.get(CONF_OPENINGS)
min_temp = config.get(CONF_MIN_TEMP)
max_temp = config.get(CONF_MAX_TEMP)
max_floor_temp = config.get(CONF_MAX_FLOOR_TEMP)
Expand Down Expand Up @@ -221,7 +228,7 @@ async def async_setup_platform(
cooler_entity_id,
sensor_entity_id,
sensor_floor_entity_id,
opening_entities,
openings,
min_temp,
max_temp,
max_floor_temp,
Expand Down Expand Up @@ -256,7 +263,7 @@ def __init__(
cooler_entity_id,
sensor_entity_id,
sensor_floor_entity_id,
opening_entities,
openings,
min_temp,
max_temp,
max_floor_temp,
Expand All @@ -283,7 +290,22 @@ def __init__(
self.cooler_entity_id = cooler_entity_id
self.sensor_entity_id = sensor_entity_id
self.sensor_floor_entity_id = sensor_floor_entity_id
self.opening_entities: List = opening_entities
if openings:
self.openings = list(
map(
lambda entry: entry
if isinstance(entry, dict)
else {ATTR_ENTITY_ID: entry, ATTR_TIMEOUT: None},
openings,
)
)
self.opening_entities: List[str] = list(
map(lambda entry: entry[ATTR_ENTITY_ID], self.openings)
)
else:
self.openings = []
self.opening_entities = []

self.ac_mode = ac_mode
self._heat_cool_mode = heat_cool_mode
self.min_cycle_duration: timedelta = min_cycle_duration
Expand Down Expand Up @@ -381,7 +403,7 @@ async def async_added_to_hass(self):
)
)

if self.opening_entities and len(self.opening_entities):
if self.opening_entities:
self.async_on_remove(
async_track_state_change_event(
self.hass,
Expand Down Expand Up @@ -730,7 +752,29 @@ async def _async_opening_changed(self, event):
if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return

await self._async_control_climate()
opening_entity = event.data.get("entity_id")
# get the opening timeout
opening_timeout = None
for opening in self.openings:
if opening_entity == opening[ATTR_ENTITY_ID]:
opening_timeout = opening[ATTR_TIMEOUT]
break

# schdule the closing of the opening
if opening_timeout is not None and new_state.state == STATE_OPEN:
_LOGGER.debug(
"Scheduling state open of opening %s in %s",
opening_entity,
opening_timeout,
)
self.async_on_remove(
async_track_time_interval(
self.hass, self._async_control_climate_forced, opening_timeout
)
)
else:
await self._async_control_climate(force=True)

self.async_write_ha_state()

async def _async_control_climate(self, time=None, force=False):
Expand All @@ -743,6 +787,10 @@ async def _async_control_climate(self, time=None, force=False):
else:
await self._async_control_heating(time, force)

async def _async_control_climate_forced(self, time=None):
_LOGGER.debug("_async_control_climate_forced, time %s", time)
await self._async_control_climate(force=True, time=time)

@callback
def _async_switch_changed(self, event):
"""Handle heater switch state changes."""
Expand Down Expand Up @@ -823,7 +871,7 @@ async def _async_control_heating(self, time=None, force=False):
async def _async_control_cooling(self, time=None, force=False):
"""Check if we need to turn heating on or off."""
async with self._temp_lock:
_LOGGER.debug("_async_control_cooling")
_LOGGER.debug("_async_control_cooling time: %s. force: %s", time, force)
self.set_self_active()

if not self._needs_control(time, force, cool=True):
Expand All @@ -848,22 +896,30 @@ async def _async_control_cooling(self, time=None, force=False):
self._async_cooler_turn_on if cooler_set else self._async_heater_turn_on
)

_LOGGER.info(
"is device active: %s, is opening open: %s",
is_device_active,
self._is_opening_open,
)

any_opening_open = self._is_opening_open

if is_device_active:
if too_cold or self._is_opening_open:
if too_cold or any_opening_open:
_LOGGER.info("Turning off cooler %s", control_entity)
await control_off()
elif time is not None and not self._is_opening_open:
elif time is not None and not any_opening_open:
# The time argument is passed only in keep-alive case
_LOGGER.info(
"Keep-alive - Turning on cooler (from active) %s",
control_entity,
)
await control_on()
else:
if too_hot and not self._is_opening_open:
if too_hot and not any_opening_open:
_LOGGER.info("Turning on cooler (from inactive) %s", control_entity)
await control_on()
elif time is not None or self._is_opening_open:
elif time is not None or any_opening_open:
# The time argument is passed only in keep-alive case
_LOGGER.info("Keep-alive - Turning off cooler %s", control_entity)
await control_off()
Expand Down Expand Up @@ -944,17 +1000,38 @@ async def _async_auto_toggle(self, too_cold, too_hot):
@property
def _is_opening_open(self):
"""If the binary opening is currently open."""
_is_open = False
if self.opening_entities:
for opening in self.opening_entities:
if self.hass.states.is_state(
opening, STATE_OPEN
) or self.hass.states.is_state(opening, STATE_ON):
_is_open = True
_LOGGER.debug("_is_opening_open")
if not self.opening_entities:
return False
else:
_is_open = False
for opening in self.openings:
opening_entity = opening[ATTR_ENTITY_ID]
if opening[ATTR_TIMEOUT] is not None:
if condition.state(
self.hass,
opening_entity,
STATE_OPEN,
opening[ATTR_TIMEOUT],
):
_is_open = True
_LOGGER.debug(
"Have timeout mode for opening %s, is open: %s",
opening,
_is_open,
)
else:
if self.hass.states.is_state(
opening_entity, STATE_OPEN
) or self.hass.states.is_state(opening_entity, STATE_ON):
_is_open = True
_LOGGER.debug(
"No timeout mode for opening %s, is open: %s.",
opening_entity,
_is_open,
)

return _is_open
else:
return False

@property
def _is_floor_hot(self):
Expand Down Expand Up @@ -1098,10 +1175,6 @@ def _needs_cycle(self, dual=False, cool=False):
def _is_too_cold(self, target_attr="_target_temp") -> bool:
"""checks if the current temperature is below target"""
target_temp = getattr(self, target_attr)
_LOGGER.debug(
"Debug is too cold?. %s",
target_temp >= self._cur_temp + self._cold_tolerance,
)
return target_temp >= self._cur_temp + self._cold_tolerance

def _is_too_hot(self, target_attr="_target_temp") -> bool:
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 @@ -26,6 +26,7 @@
CONF_TEMP_STEP = "target_temp_step"
CONF_OPENINGS = "openings"
CONF_HEAT_COOL_MODE = "heat_cool_mode"
ATTR_TIMEOUT = "timeout"
PRESET_ANTI_FREEZE = "Anti Freeze"


Expand Down
88 changes: 86 additions & 2 deletions tests/test_thermostat.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""The tests for the dual_smart_thermostat."""
import asyncio
from datetime import timedelta
import logging
import time
from typing import Final
from unittest.mock import patch

Expand Down Expand Up @@ -420,7 +422,7 @@ async def test_cooler_mode_cycle(hass, duration, result_state, setup_comp_1):
assert hass.states.get(cooler_switch).state == result_state


async def test_cooler_mode2(hass, setup_comp_1):
async def test_cooler_mode_dual(hass, setup_comp_1):
"""Test thermostat cooler switch in cooling mode."""
heater_switch = "input_boolean.heater"
cooler_switch = "input_boolean.cooler"
Expand Down Expand Up @@ -480,7 +482,7 @@ async def test_cooler_mode2(hass, setup_comp_1):
(timedelta(seconds=30), STATE_OFF),
],
)
async def test_cooler_mode2_cycle(hass, duration, result_state, setup_comp_1):
async def test_cooler_mode_dual_cycle(hass, duration, result_state, setup_comp_1):
"""Test thermostat cooler switch in cooling mode with cycle duration."""
heater_switch = "input_boolean.heater"
cooler_switch = "input_boolean.cooler"
Expand Down Expand Up @@ -538,6 +540,83 @@ async def test_cooler_mode2_cycle(hass, duration, result_state, setup_comp_1):
assert hass.states.get(cooler_switch).state == result_state


async def test_cooler_mode_opening(hass, setup_comp_1):
"""Test thermostat cooler switch in cooling mode."""
cooler_switch = "input_boolean.test"
opening_1 = "input_boolean.opening_1"
opening_2 = "input_boolean.opening_2"

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

temp_input = "input_number.temp"
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": DUAL_SMART_THERMOSTAT,
"name": "test",
"heater": cooler_switch,
"ac_mode": "true",
"target_sensor": temp_input,
"initial_hvac_mode": HVACMode.COOL,
"openings": [
opening_1,
{"entity_id": opening_2, "timeout": {"seconds": 10}},
],
}
},
)
await hass.async_block_till_done()

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

_setup_sensor(hass, temp_input, 23)
await hass.async_block_till_done()

await async_set_temperature(hass, 18)
assert hass.states.get(cooler_switch).state == STATE_ON

_setup_boolean(hass, opening_1, "open")
await hass.async_block_till_done()

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

_setup_boolean(hass, opening_1, "closed")
await hass.async_block_till_done()

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

_setup_boolean(hass, opening_2, "open")
await hass.async_block_till_done()

# wait 10 seconds, actually 133 due to the other tests run time seems to affect this
# needs to separate the tests
await asyncio.sleep(13)
await hass.async_block_till_done()

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

_setup_boolean(hass, opening_2, "closed")
await hass.async_block_till_done()

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


async def test_heater_cooler_mode(hass, setup_comp_1):
"""Test thermostat heater and cooler switch in heat/cool mode."""

Expand Down Expand Up @@ -912,6 +991,11 @@ def _setup_sensor(hass, sensor, temp):
hass.states.async_set(sensor, temp)


def _setup_boolean(hass, entity, state):
"""Set up the test sensor."""
hass.states.async_set(entity, state)


async def async_set_temperature(
hass,
temperature=None,
Expand Down

0 comments on commit 07bd399

Please sign in to comment.