diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 6c18cf2..5fc253a 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -49,7 +49,7 @@ jobs: run: | pytest \ -qq \ - --timeout=9 \ + --timeout=45 \ --durations=10 \ -n auto \ --cov-report=xml \ diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index ace8c6c..5fe38c7 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -53,7 +53,7 @@ jobs: run: | pytest \ -qq \ - --timeout=9 \ + --timeout=45 \ --durations=10 \ -n auto \ --cov-report=xml \ diff --git a/custom_components/ev_smart_charging/const.py b/custom_components/ev_smart_charging/const.py index e3a301d..7efdca1 100644 --- a/custom_components/ev_smart_charging/const.py +++ b/custom_components/ev_smart_charging/const.py @@ -99,8 +99,6 @@ 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 e7f86dc..7d5e72a 100644 --- a/custom_components/ev_smart_charging/coordinator.py +++ b/custom_components/ev_smart_charging/coordinator.py @@ -35,7 +35,6 @@ CONF_EV_SOC_SENSOR, CONF_EV_TARGET_SOC_SENSOR, CONF_START_HOUR, - DEBOUNCE_TIME, DEFAULT_TARGET_SOC, READY_HOUR_NONE, START_HOUR_NONE, @@ -49,7 +48,7 @@ get_ready_hour_utc, get_start_hour_utc, ) -from .helpers.general import Validator, debounce_async, get_parameter +from .helpers.general import Validator, get_parameter from .sensor import EVSmartChargingSensor _LOGGER = logging.getLogger(__name__) @@ -255,12 +254,11 @@ async def update_state( self._charging_schedule = Scheduler.get_empty_schedule() self.sensor.charging_schedule = self._charging_schedule - @debounce_async(DEBOUNCE_TIME) async def turn_on_charging(self, state: bool = True): """Turn on charging""" if state is True: - _LOGGER.info("Turn on charging") + _LOGGER.debug("Turn on charging") self.sensor.native_value = STATE_ON if self.charger_switch is not None: _LOGGER.debug( @@ -272,7 +270,7 @@ async def turn_on_charging(self, state: bool = True): target={"entity_id": self.charger_switch}, ) else: - _LOGGER.info("Turn off charging") + _LOGGER.debug("Turn off charging") self.sensor.native_value = STATE_OFF if self.charger_switch is not None: _LOGGER.debug( @@ -558,6 +556,7 @@ async def update_sensors( "max_price": max_price, } + time_now_local = dt.now() time_now_hour_local = dt.now().hour # To handle non-live SOC @@ -569,6 +568,33 @@ async def update_sensors( else: self.ready_hour_first = True + not_charging = True + if self._charging_schedule is not None: + not_charging = ( + get_charging_value(self._charging_schedule) is None + or get_charging_value(self._charging_schedule) == 0 + ) + # Handle self.switch_keep_on + if self.switch_keep_on: + # Only if price limit is not used and the EV is connected + if ( + self.switch_apply_limit is False or self.max_price == 0.0 + ) and self.switch_ev_connected: + # Only if SOC has reached Target SOC or there are no more scheduled charging + if ( + self.ev_soc is not None + and self.ev_target_soc is not None + and self.ev_soc >= self.ev_target_soc + ): + # Don't reschedule due to keep_on + not_charging = False + + if self.switch_keep_on_completion_time is not None and ( + time_now_local >= self.switch_keep_on_completion_time + ): + # Don't reschedule due to keep_on + not_charging = False + if ( (self.ev_soc is not None and self.ev_target_soc is not None) and (self.ev_soc > self.ev_soc_before_last_charging) @@ -576,7 +602,7 @@ async def update_sensors( (self.ev_soc >= self.ev_target_soc) or ( (self.tomorrow_valid or time_now_hour_local < self.ready_hour_local) - and self.auto_charging_state == STATE_OFF + and not_charging ) ) ): diff --git a/custom_components/ev_smart_charging/helpers/general.py b/custom_components/ev_smart_charging/helpers/general.py index 94c6c16..13053bb 100644 --- a/custom_components/ev_smart_charging/helpers/general.py +++ b/custom_components/ev_smart_charging/helpers/general.py @@ -1,9 +1,7 @@ """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 @@ -75,45 +73,3 @@ 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(): - _LOGGER.info("debounced.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) - _LOGGER.info("debounced.wait_time= %s", 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 5014738..f89baca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,6 @@ 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 # pylint: disable=invalid-name @@ -99,26 +98,3 @@ def skip_update_hourly_fixture(): "custom_components.ev_smart_charging.coordinator.EVSmartChargingCoordinator.update_hourly" ): 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_opportunistic2.py b/tests/coordinator/test_coordinator_opportunistic2.py index 73ef522..7b3b8bc 100644 --- a/tests/coordinator/test_coordinator_opportunistic2.py +++ b/tests/coordinator/test_coordinator_opportunistic2.py @@ -113,15 +113,16 @@ async def test_coordinator_opportunistic_1( assert coordinator.sensor.charging_number_of_hours == 8 # Move time to next day - freezer.move_to("2022-10-01T02:00:00+02:00") + freezer.move_to("2022-10-01T00:30:00+02:00") MockPriceEntity.set_state(hass, PRICE_20221001A, None) + MockSOCEntity.set_state(hass, "72") await coordinator.update_sensors() await hass.async_block_till_done() # Ready_hour = 08:00, Max price 200. Opportunistic level 50% # 3 hours => 02:00-05:00 - assert coordinator.auto_charging_state == STATE_ON - assert coordinator.sensor.state == STATE_ON + assert coordinator.auto_charging_state == STATE_OFF + assert coordinator.sensor.state == STATE_OFF assert coordinator.sensor.charging_is_planned is True assert coordinator.sensor.charging_start_time == datetime( 2022, 10, 1, 2, 0, tzinfo=dt_util.get_time_zone("Europe/Stockholm") @@ -130,3 +131,9 @@ async def test_coordinator_opportunistic_1( 2022, 10, 1, 5, 0, tzinfo=dt_util.get_time_zone("Europe/Stockholm") ) assert coordinator.sensor.charging_number_of_hours == 3 + + freezer.move_to("2022-10-01T02:00:00+02:00") + await coordinator.update_sensors() + await hass.async_block_till_done() + assert coordinator.auto_charging_state == STATE_ON + assert coordinator.sensor.state == STATE_ON diff --git a/tests/coordinator/test_coordinator_debounce.py b/tests/coordinator/test_coordinator_reschedule.py similarity index 53% rename from tests/coordinator/test_coordinator_debounce.py rename to tests/coordinator/test_coordinator_reschedule.py index ccc625a..181eda1 100644 --- a/tests/coordinator/test_coordinator_debounce.py +++ b/tests/coordinator/test_coordinator_reschedule.py @@ -1,7 +1,6 @@ """Test ev_smart_charging coordinator.""" - +from datetime import datetime import asyncio -import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry from freezegun import freeze_time @@ -9,6 +8,7 @@ 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 homeassistant.util import dt as dt_util from custom_components.ev_smart_charging.coordinator import ( EVSmartChargingCoordinator, @@ -25,65 +25,72 @@ MockSOCEntity, MockTargetSOCEntity, ) -from tests.price import PRICE_20220930 +from tests.price import PRICE_20220930, PRICE_20221001 # pylint: disable=unused-argument -@pytest.mark.ensure_debounce -@freeze_time("2022-09-30T02:00:00+02:00", tick=True) -async def test_coordinator_debounce( +@freeze_time("2022-09-30T05:59:50+02:00", tick=True) +async def test_coordinator_reschedule( hass: HomeAssistant, skip_service_calls, set_cet_timezone, freezer, skip_update_hourly, ): - """Test Coordinator debounce.""" + """Test Coordinator reschedule.""" entity_registry: EntityRegistry = async_entity_registry_get(hass) - MockSOCEntity.create(hass, entity_registry, "67") + MockSOCEntity.create(hass, entity_registry, "75") 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) + MockPriceEntity.set_state(hass, PRICE_20220930, PRICE_20221001) 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) + coordinator.ready_hour_local = 8 await hass.async_block_till_done() - await coordinator.switch_active_update(False) + await coordinator.switch_active_update(True) await coordinator.switch_apply_limit_update(False) await coordinator.switch_continuous_update(True) - await coordinator.switch_ev_connected_update(False) + await coordinator.switch_ev_connected_update(True) 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() + # Schedule 05-06 + await coordinator.update_configuration() + await asyncio.sleep(5) assert coordinator.sensor.state == STATE_ON + assert coordinator.sensor.charging_is_planned is True + assert coordinator.sensor.charging_start_time == datetime( + 2022, 9, 30, 5, 0, tzinfo=dt_util.get_time_zone("Europe/Stockholm") + ) + assert coordinator.sensor.charging_stop_time == datetime( + 2022, 9, 30, 6, 0, tzinfo=dt_util.get_time_zone("Europe/Stockholm") + ) + assert coordinator.sensor.charging_number_of_hours == 1 - 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() + # Schedule 06-07 + await asyncio.sleep(5) + await coordinator.update_sensors() + await asyncio.sleep(5) assert coordinator.sensor.state == STATE_ON + assert coordinator.sensor.charging_is_planned is True + assert coordinator.sensor.charging_start_time == datetime( + 2022, 9, 30, 6, 0, tzinfo=dt_util.get_time_zone("Europe/Stockholm") + ) + assert coordinator.sensor.charging_stop_time == datetime( + 2022, 9, 30, 7, 0, tzinfo=dt_util.get_time_zone("Europe/Stockholm") + ) + assert coordinator.sensor.charging_number_of_hours == 1 - await coordinator.turn_off_charging() - await asyncio.sleep(2) - await hass.async_block_till_done() + # No schedule + MockSOCEntity.set_state(hass, "80") + await coordinator.update_sensors() + await asyncio.sleep(5) assert coordinator.sensor.state == STATE_OFF + assert coordinator.sensor.charging_is_planned is False - 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 400abf9..d3929fa 100644 --- a/tests/helpers/test_general.py +++ b/tests/helpers/test_general.py @@ -1,10 +1,7 @@ """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 @@ -15,9 +12,7 @@ ) 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 @@ -202,67 +197,4 @@ async def test_get_parameter(hass): assert get_parameter(config_entry, CONF_PCT_PER_HOUR) == 8.0 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 + assert get_parameter(config_entry, CONF_READY_HOUR, 12) == 12