diff --git a/custom_components/ev_smart_charging/const.py b/custom_components/ev_smart_charging/const.py index 7efdca1..e3a301d 100644 --- a/custom_components/ev_smart_charging/const.py +++ b/custom_components/ev_smart_charging/const.py @@ -99,6 +99,8 @@ CHARGING_STATUS_DISCONNECTED = "Disconnected" CHARGING_STATUS_NOT_ACTIVE = "Smart charging not active" +DEBOUNCE_TIME = 1.0 + # Defaults DEFAULT_NAME = DOMAIN DEFAULT_TARGET_SOC = 100 diff --git a/custom_components/ev_smart_charging/coordinator.py b/custom_components/ev_smart_charging/coordinator.py index 415cbbe..6ca38a7 100644 --- a/custom_components/ev_smart_charging/coordinator.py +++ b/custom_components/ev_smart_charging/coordinator.py @@ -35,6 +35,7 @@ CONF_EV_SOC_SENSOR, CONF_EV_TARGET_SOC_SENSOR, CONF_START_HOUR, + DEBOUNCE_TIME, DEFAULT_TARGET_SOC, READY_HOUR_NONE, START_HOUR_NONE, @@ -48,7 +49,7 @@ get_ready_hour_utc, get_start_hour_utc, ) -from .helpers.general import Validator, get_parameter +from .helpers.general import Validator, debounce_async, get_parameter from .sensor import EVSmartChargingSensor _LOGGER = logging.getLogger(__name__) @@ -254,31 +255,38 @@ async def update_state( self._charging_schedule = Scheduler.get_empty_schedule() self.sensor.charging_schedule = self._charging_schedule - async def turn_on_charging(self): + @debounce_async(DEBOUNCE_TIME) + async def turn_on_charging(self, state: bool = True): """Turn on charging""" - _LOGGER.debug("Turn on charging") - self.sensor.native_value = STATE_ON - if self.charger_switch is not None: - _LOGGER.debug("Before service call switch.turn_on: %s", self.charger_switch) - await self.hass.services.async_call( - domain=SWITCH, - service=SERVICE_TURN_ON, - target={"entity_id": self.charger_switch}, - ) + + if state is True: + _LOGGER.debug("Turn on charging") + self.sensor.native_value = STATE_ON + if self.charger_switch is not None: + _LOGGER.debug( + "Before service call switch.turn_on: %s", self.charger_switch + ) + await self.hass.services.async_call( + domain=SWITCH, + service=SERVICE_TURN_ON, + target={"entity_id": self.charger_switch}, + ) + else: + _LOGGER.debug("Turn off charging") + self.sensor.native_value = STATE_OFF + if self.charger_switch is not None: + _LOGGER.debug( + "Before service call switch.turn_off: %s", self.charger_switch + ) + await self.hass.services.async_call( + domain=SWITCH, + service=SERVICE_TURN_OFF, + target={"entity_id": self.charger_switch}, + ) async def turn_off_charging(self): """Turn off charging""" - _LOGGER.debug("Turn off charging") - self.sensor.native_value = STATE_OFF - if self.charger_switch is not None: - _LOGGER.debug( - "Before service call switch.turn_off: %s", self.charger_switch - ) - await self.hass.services.async_call( - domain=SWITCH, - service=SERVICE_TURN_OFF, - target={"entity_id": self.charger_switch}, - ) + await self.turn_on_charging(False) async def add_sensor(self, sensor: EVSmartChargingSensor): """Set up sensor""" diff --git a/custom_components/ev_smart_charging/helpers/general.py b/custom_components/ev_smart_charging/helpers/general.py index 13053bb..65f6870 100644 --- a/custom_components/ev_smart_charging/helpers/general.py +++ b/custom_components/ev_smart_charging/helpers/general.py @@ -1,7 +1,9 @@ """General helpers""" # pylint: disable=relative-beyond-top-level +import asyncio import logging +import threading from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import State @@ -73,3 +75,43 @@ def get_parameter(config_entry: ConfigEntry, parameter: str, default_val: Any = if parameter in config_entry.data.keys(): return config_entry.data.get(parameter) return default_val + + +def get_wait_time(time): + """Function to be patched during tests""" + return time + + +# pylint: disable=unused-argument +def debounce_async(wait_time): + """ + Decorator that will debounce an async function so that it is called + after wait_time seconds. If it is called multiple times, will wait + for the last call to be debounced and run only this one. + """ + + def decorator(function): + async def debounced(*args, **kwargs): + nonlocal wait_time + + def call_function(): + debounced.timer = None + return asyncio.run_coroutine_threadsafe( + function(*args, **kwargs), loop=debounced.loop + ) + + # Used for patching during testing to disable debounce + wait_time = get_wait_time(wait_time) + if wait_time == 0.0: + return await function(*args, **kwargs) + + debounced.loop = asyncio.get_running_loop() + if debounced.timer is not None: + debounced.timer.cancel() + debounced.timer = threading.Timer(wait_time, call_function) + debounced.timer.start() + + debounced.timer = None + return debounced + + return decorator diff --git a/tests/conftest.py b/tests/conftest.py index 9a326aa..bbeb540 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,9 +15,10 @@ # See here for more info: https://docs.pytest.org/en/latest/fixture.html (note that # pytest includes fixtures OOB which you can use as defined on this page) from unittest.mock import patch +import pytest from homeassistant.util import dt as dt_util +from custom_components.ev_smart_charging.const import DEBOUNCE_TIME -import pytest # pylint: disable=invalid-name pytest_plugins = "pytest_homeassistant_custom_component" @@ -88,3 +89,26 @@ def skip_service_calls_fixture(): """Skip service calls.""" with patch("homeassistant.core.ServiceRegistry.async_call"): yield + + +def pytest_configure(config): + """Register a new marker""" + config.addinivalue_line("markers", "ensure_debounce") + + +# This fixture is used to skip debounce. +# Decorate test case with @pytest.mark.ensure_debounce if debounce should be done. +@pytest.fixture(autouse=True) +def skip_debounce_fixture(request): + """Skip debounce""" + + debounce_time = 0.0 + + if "ensure_debounce" in request.keywords: + debounce_time = DEBOUNCE_TIME + + with patch( + "custom_components.ev_smart_charging.helpers.general.get_wait_time", + return_value=debounce_time, + ): + yield diff --git a/tests/coordinator/test_coordinator_debounce.py b/tests/coordinator/test_coordinator_debounce.py new file mode 100644 index 0000000..c9324c8 --- /dev/null +++ b/tests/coordinator/test_coordinator_debounce.py @@ -0,0 +1,85 @@ +"""Test ev_smart_charging coordinator.""" + +import asyncio +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from freezegun import freeze_time + +from homeassistant.core import HomeAssistant +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.helpers.entity_registry import async_get as async_entity_registry_get +from homeassistant.helpers.entity_registry import EntityRegistry + +from custom_components.ev_smart_charging.coordinator import ( + EVSmartChargingCoordinator, +) +from custom_components.ev_smart_charging.const import ( + DOMAIN, +) +from custom_components.ev_smart_charging.sensor import EVSmartChargingSensor +from tests.const import MOCK_CONFIG_ALL + +from tests.helpers.helpers import ( + MockChargerEntity, + MockPriceEntity, + MockSOCEntity, + MockTargetSOCEntity, +) +from tests.price import PRICE_20220930 + +# pylint: disable=unused-argument +@pytest.mark.ensure_debounce +@freeze_time("2022-09-30T02:00:00+02:00", tick=True) +async def test_coordinator_debounce( + hass: HomeAssistant, skip_service_calls, set_cet_timezone, freezer +): + """Test Coordinator debounce.""" + + entity_registry: EntityRegistry = async_entity_registry_get(hass) + MockSOCEntity.create(hass, entity_registry, "67") + MockTargetSOCEntity.create(hass, entity_registry, "80") + MockPriceEntity.create(hass, entity_registry, 123) + MockChargerEntity.create(hass, entity_registry, STATE_OFF) + MockPriceEntity.set_state(hass, PRICE_20220930, None) + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_ALL, entry_id="test") + coordinator = EVSmartChargingCoordinator(hass, config_entry) + assert coordinator is not None + sensor: EVSmartChargingSensor = EVSmartChargingSensor(config_entry) + assert sensor is not None + await coordinator.add_sensor(sensor) + await hass.async_block_till_done() + await coordinator.switch_active_update(False) + await coordinator.switch_apply_limit_update(False) + await coordinator.switch_continuous_update(True) + await coordinator.switch_ev_connected_update(False) + await coordinator.switch_keep_on_update(False) + await hass.async_block_till_done() + assert coordinator.sensor.state == STATE_OFF + + await coordinator.turn_on_charging() + await asyncio.sleep(2) + await hass.async_block_till_done() + assert coordinator.sensor.state == STATE_ON + + await coordinator.turn_off_charging() + await asyncio.sleep(0.1) + await hass.async_block_till_done() + assert coordinator.sensor.state == STATE_ON + await coordinator.turn_on_charging() + await asyncio.sleep(2) + await hass.async_block_till_done() + assert coordinator.sensor.state == STATE_ON + + await coordinator.turn_off_charging() + await asyncio.sleep(2) + await hass.async_block_till_done() + assert coordinator.sensor.state == STATE_OFF + + await coordinator.turn_on_charging() + await asyncio.sleep(0.1) + await hass.async_block_till_done() + assert coordinator.sensor.state == STATE_OFF + await coordinator.turn_off_charging() + await asyncio.sleep(2) + await hass.async_block_till_done() + assert coordinator.sensor.state == STATE_OFF diff --git a/tests/helpers/test_general.py b/tests/helpers/test_general.py index 02b7449..400abf9 100644 --- a/tests/helpers/test_general.py +++ b/tests/helpers/test_general.py @@ -1,7 +1,10 @@ """Test ev_smart_charging/helpers/general.py""" +import asyncio from datetime import datetime +from freezegun import freeze_time from homeassistant.core import State +import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -10,7 +13,12 @@ CONF_PCT_PER_HOUR, CONF_READY_HOUR, ) -from custom_components.ev_smart_charging.helpers.general import Validator, get_parameter +from custom_components.ev_smart_charging.helpers.general import ( + Validator, + debounce_async, + get_parameter, + get_wait_time, +) from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_OPTIONS @@ -195,3 +203,66 @@ async def test_get_parameter(hass): assert get_parameter(config_entry, CONF_MIN_SOC) == 30.0 assert get_parameter(config_entry, CONF_READY_HOUR) is None assert get_parameter(config_entry, CONF_READY_HOUR, 12) is 12 + + +async def test_get_wait_time(hass): + """Test get_wait_time""" + + assert get_wait_time(1.0) == 1.0 + assert get_wait_time(2.0) == 2.0 + + +TEST_DEBOUNCE_ASYNC_VALUE = 0 + + +# pylint: disable=global-statement +@debounce_async(1.0) +async def mock_function(new_value): + """Function to be used for testing""" + global TEST_DEBOUNCE_ASYNC_VALUE + TEST_DEBOUNCE_ASYNC_VALUE = new_value + + +@pytest.mark.ensure_debounce +@freeze_time("2022-09-30T02:00:00+02:00", tick=True) +async def test_debounce_async(hass): + """Test debounce_async""" + + global TEST_DEBOUNCE_ASYNC_VALUE + TEST_DEBOUNCE_ASYNC_VALUE = 0 + + await mock_function(1) + await asyncio.sleep(2) + await hass.async_block_till_done() + assert TEST_DEBOUNCE_ASYNC_VALUE == 1 + + await mock_function(2) + await asyncio.sleep(0.1) + await hass.async_block_till_done() + assert TEST_DEBOUNCE_ASYNC_VALUE == 1 + await mock_function(3) + await asyncio.sleep(2) + await hass.async_block_till_done() + assert TEST_DEBOUNCE_ASYNC_VALUE == 3 + + +@freeze_time("2022-09-30T02:00:00+02:00", tick=True) +async def test_debounce_async2(hass): + """Test debounce_async""" + + global TEST_DEBOUNCE_ASYNC_VALUE + TEST_DEBOUNCE_ASYNC_VALUE = 0 + + await mock_function(1) + await asyncio.sleep(2) + await hass.async_block_till_done() + assert TEST_DEBOUNCE_ASYNC_VALUE == 1 + + await mock_function(2) + await asyncio.sleep(0.1) + await hass.async_block_till_done() + assert TEST_DEBOUNCE_ASYNC_VALUE == 2 + await mock_function(3) + await asyncio.sleep(2) + await hass.async_block_till_done() + assert TEST_DEBOUNCE_ASYNC_VALUE == 3 diff --git a/tests/test_button.py b/tests/test_button.py index b36f9d3..3e4f416 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -18,7 +18,6 @@ from .const import MOCK_CONFIG_USER_NO_CHARGER - # We can pass fixtures as defined in conftest.py to tell pytest to use the fixture # for a given test. We can also leverage fixtures and mocks that are available in # Home Assistant using the pytest_homeassistant_custom_component plugin. @@ -27,7 +26,7 @@ # pylint: disable=unused-argument async def test_button(hass, bypass_validate_input_sensors): - """Test sensor properties.""" + """Test buttons.""" # Create a mock entry so we don't have to go through config flow config_entry = MockConfigEntry( domain=DOMAIN, data=MOCK_CONFIG_USER_NO_CHARGER, entry_id="test"