diff --git a/.github/workflows/quality-check.yaml b/.github/workflows/quality-check.yaml index 4692c07..2b73413 100644 --- a/.github/workflows/quality-check.yaml +++ b/.github/workflows/quality-check.yaml @@ -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 }} \ No newline at end of file diff --git a/custom_components/dual_smart_thermostat/climate.py b/custom_components/dual_smart_thermostat/climate.py index b657f90..e8b0141 100644 --- a/custom_components/dual_smart_thermostat/climate.py +++ b/custom_components/dual_smart_thermostat/climate.py @@ -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 @@ -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, @@ -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, @@ -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()}) @@ -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) @@ -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, @@ -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, @@ -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 @@ -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, @@ -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): @@ -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.""" @@ -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): @@ -848,11 +896,19 @@ 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", @@ -860,10 +916,10 @@ async def _async_control_cooling(self, time=None, force=False): ) 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() @@ -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): @@ -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: diff --git a/custom_components/dual_smart_thermostat/const.py b/custom_components/dual_smart_thermostat/const.py index cbff696..2dad935 100644 --- a/custom_components/dual_smart_thermostat/const.py +++ b/custom_components/dual_smart_thermostat/const.py @@ -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" diff --git a/tests/test_thermostat.py b/tests/test_thermostat.py index 6e1cddb..2ce20b8 100644 --- a/tests/test_thermostat.py +++ b/tests/test_thermostat.py @@ -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 @@ -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" @@ -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" @@ -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.""" @@ -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,