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

feat: open time for openings #60

Merged
merged 1 commit into from
Jul 14, 2023
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
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
Loading