From 5e474b4d8c10310da8c2e79d439654826c5bc9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Br=C3=BCggemann?= Date: Sat, 2 Apr 2022 17:06:54 +0200 Subject: [PATCH 1/6] Fix installation on ARM macOS --- poetry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 53c65fc..ab150e1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1059,7 +1059,7 @@ python-versions = ">=3.5" [[package]] name = "scipy" -version = "1.6.1" +version = "1.8.0" description = "SciPy: Scientific Library for Python" category = "main" optional = false From 038c0f9aa622a923bd3fdafa43b82a6e97fcd82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Br=C3=BCggemann?= Date: Sat, 2 Apr 2022 18:28:03 +0200 Subject: [PATCH 2/6] WIP: Multi Day Charge Control --- psa_car_controller/psa/RemoteClient.py | 4 ++-- .../psacc/application/charge_control.py | 24 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/psa_car_controller/psa/RemoteClient.py b/psa_car_controller/psa/RemoteClient.py index 2d17f78..cb68554 100644 --- a/psa_car_controller/psa/RemoteClient.py +++ b/psa_car_controller/psa/RemoteClient.py @@ -272,12 +272,12 @@ class RemoteClient: charge_type = IMMEDIATE_CHARGE else: charge_type = DELAYED_CHARGE - hour, minute = self.__get_charge_hour(vin) + hour, minute = self.get_charge_hour(vin) res = self.veh_charge_request(vin, hour, minute, charge_type) logger.info("charge_now: %s", res) return True - def __get_charge_hour(self, vin): + def get_charge_hour(self, vin): hour_str = self.vehicles_list.get_car_by_vin(vin).status.get_energy('Electric').charging.next_delayed_time try: return parse_hour(hour_str)[:2] diff --git a/psa_car_controller/psacc/application/charge_control.py b/psa_car_controller/psacc/application/charge_control.py index a2035e1..de648dc 100644 --- a/psa_car_controller/psacc/application/charge_control.py +++ b/psa_car_controller/psacc/application/charge_control.py @@ -8,7 +8,7 @@ from time import sleep import pytz -from psa_car_controller.psa.constants import DISCONNECTED, INPROGRESS, FINISHED +from psa_car_controller.psa.constants import DISCONNECTED, INPROGRESS, FINISHED, STOPPED from psa_car_controller.common.utils import RateLimitException from .psa_client import PSAClient @@ -55,7 +55,10 @@ class ChargeControl: self.retry_count = 0 return True - def force_update(self, quick_refresh): + def force_update(self, vehicle_status): + charging_mode = vehicle_status.get_energy('Electric').charging.charging_mode + quick_refresh = isinstance(charging_mode, str) and charging_mode == "Quick" + # force update if the car doesn't send info during 10 minutes last_update = self.psacc.vehicles_list.get_car_by_vin(self.vin).get_status().get_energy('Electric').updated_at if quick_refresh: @@ -68,17 +71,21 @@ class ChargeControl: except RateLimitException: logger.exception("force_update:") + def __is_approaching_scheduled_time(self, now: datetime): + scheduled_hour, scheduled_minute = self.psacc.remote_client.get_charge_hour(self.vin) + minutes_passed = now.hour * 60 + now.minute + scheduled_minute = scheduled_hour * 60 + scheduled_minute + return minutes_passed < scheduled_minute and scheduled_minute - minutes_passed < 30 + def process(self): now = datetime.now() try: vehicle_status = self.psacc.vehicles_list.get_car_by_vin(self.vin).get_status() status = vehicle_status.get_energy('Electric').charging.status level = vehicle_status.get_energy('Electric').level - if status == "InProgress" and self.percentage_threshold < 100: + if status == INPROGRESS and self.percentage_threshold < 100: logger.info("charging status of %s is %s, battery level: %d", self.vin, status, level) - charging_mode = vehicle_status.get_energy('Electric').charging.charging_mode - quick_refresh = isinstance(charging_mode, str) and charging_mode == "Quick" - self.force_update(quick_refresh) + self.force_update(vehicle_status) if level >= self.percentage_threshold and self.retry_count < 2: logger.info("Charge threshold is reached, stop the charge") self.control_charge_with_ack(False) @@ -94,6 +101,11 @@ class ChargeControl: thread = threading.Timer(periodicity, self.process) thread.setDaemon(True) thread.start() + elif status == STOPPED and self.percentage_threshold < 100 and level >= self.percentage_threshold and self.__is_approaching_scheduled_time(now): + logger.info("Approaching scheduled charging time, but should not charge. Postponing charge hour!") + self.force_update(vehicle_status) + hour = now.hour - 1 if now.hour > 0 else 23 + self.psacc.remote_client.change_charge_hour(self.vin, hour, 0) else: if self._next_stop_hour is not None and self._next_stop_hour < now: self._next_stop_hour += timedelta(days=1) From 0bd05d95f7b932eca3712c3b2fffd8cd7f6f202d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Br=C3=BCggemann?= Date: Sun, 10 Apr 2022 14:12:23 +0200 Subject: [PATCH 3/6] Renaming for clarity --- psa_car_controller/psacc/application/charge_control.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/psa_car_controller/psacc/application/charge_control.py b/psa_car_controller/psacc/application/charge_control.py index de648dc..1c9ceff 100644 --- a/psa_car_controller/psacc/application/charge_control.py +++ b/psa_car_controller/psacc/application/charge_control.py @@ -74,8 +74,8 @@ class ChargeControl: def __is_approaching_scheduled_time(self, now: datetime): scheduled_hour, scheduled_minute = self.psacc.remote_client.get_charge_hour(self.vin) minutes_passed = now.hour * 60 + now.minute - scheduled_minute = scheduled_hour * 60 + scheduled_minute - return minutes_passed < scheduled_minute and scheduled_minute - minutes_passed < 30 + scheduled_minute_of_day = scheduled_hour * 60 + scheduled_minute + return minutes_passed < scheduled_minute_of_day and scheduled_minute_of_day - minutes_passed < 30 def process(self): now = datetime.now() From 731cf808c9e7e44444ead2cf0461cb950b279475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Br=C3=BCggemann?= Date: Sun, 10 Apr 2022 15:14:12 +0200 Subject: [PATCH 4/6] Fix overly long line --- psa_car_controller/psacc/application/charge_control.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/psa_car_controller/psacc/application/charge_control.py b/psa_car_controller/psacc/application/charge_control.py index 1c9ceff..0ad8193 100644 --- a/psa_car_controller/psacc/application/charge_control.py +++ b/psa_car_controller/psacc/application/charge_control.py @@ -83,10 +83,12 @@ class ChargeControl: vehicle_status = self.psacc.vehicles_list.get_car_by_vin(self.vin).get_status() status = vehicle_status.get_energy('Electric').charging.status level = vehicle_status.get_energy('Electric').level - if status == INPROGRESS and self.percentage_threshold < 100: + has_threshold = self.percentage_threshold < 100 + hit_threshold = level >= self.percentage_threshold + if status == INPROGRESS and has_threshold: logger.info("charging status of %s is %s, battery level: %d", self.vin, status, level) self.force_update(vehicle_status) - if level >= self.percentage_threshold and self.retry_count < 2: + if hit_threshold and self.retry_count < 2: logger.info("Charge threshold is reached, stop the charge") self.control_charge_with_ack(False) elif self._next_stop_hour is not None: @@ -101,7 +103,7 @@ class ChargeControl: thread = threading.Timer(periodicity, self.process) thread.setDaemon(True) thread.start() - elif status == STOPPED and self.percentage_threshold < 100 and level >= self.percentage_threshold and self.__is_approaching_scheduled_time(now): + elif status == STOPPED and has_threshold and hit_threshold and self.__is_approaching_scheduled_time(now): logger.info("Approaching scheduled charging time, but should not charge. Postponing charge hour!") self.force_update(vehicle_status) hour = now.hour - 1 if now.hour > 0 else 23 From 232b559aa869d81feb779a5ac8fad558cc309907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Br=C3=BCggemann?= Date: Wed, 20 Apr 2022 12:33:27 +0200 Subject: [PATCH 5/6] Add mock test for charge control --- tests/test_chargecontrol.py | 117 ++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/test_chargecontrol.py diff --git a/tests/test_chargecontrol.py b/tests/test_chargecontrol.py new file mode 100644 index 0000000..1204ca7 --- /dev/null +++ b/tests/test_chargecontrol.py @@ -0,0 +1,117 @@ +import logging +import unittest +import datetime +from time import sleep + +from psa_car_controller.common.mylogger import my_logger +from psa_car_controller.psa.constants import INPROGRESS, STOPPED +from psa_car_controller.psacc.application.charge_control import ChargeControl + +# This simulates a 70 % charge limit with the scheduled charge time set to currentTime + 2 minutes. The initial fake +# status is "75 % and charging InProgress". First, ChargeControl should stop the charge. +# +# The fake status then changes to "75 % and charging Stopped, but still plugged". Since the scheduled charge time is +# only 2 minutes away, ChargeControl postpones the charge to the next day. It does so by setting the scheduled charge +# time to (currentHour - 1):00. For instance, when it's 14:21, it will change the scheduled charge time to 13:00, +# which has to be on the next day. +# +# For reference, here is a correct sample output: +# 2022-04-20 12:31:15,551 :: INFO :: charging status of 123 is InProgress, battery level: 75 +# 2022-04-20 12:31:15,552 :: INFO :: Charge threshold is reached, stop the charge +# 2022-04-20 12:31:15,552 :: INFO :: charge_now(123, False) +# 2022-04-20 12:31:15,552 :: INFO :: Approaching scheduled charging time, but should not charge. Postponing charge hour! +# 2022-04-20 12:31:15,552 :: INFO :: Changing stop_hour to 11:0 + +class MyTestCase(unittest.TestCase): + + def test_something(self): + my_logger() + ChargeControl.MQTT_TIMEOUT = 0 + + now = datetime.datetime.now() + + vin = "123" + percentage_threshold = 70 + + state = { + "charging": True, + "stop_hour": [now.hour, now.minute + 2] + } + + expectations = { + "stopped_charge": False, + "postponed_charge": False + } + + def met_expectations(): + return expectations["stopped_charge"] and expectations["postponed_charge"] + + class MockClient: + def charge_now(self, vin, now): + logging.getLogger().info(f"charge_now({vin}, {now})") + state["charging"] = now + if not now: + expectations["stopped_charge"] = True + + def get_charge_hour(self, vin): + return state["stop_hour"] + + def change_charge_hour(self, vin, hour, minute): + logging.getLogger().info(f"Changing stop_hour to {hour}:{minute}") + state["stop_hour"] = [hour, minute] + expectations["postponed_charge"] = True + + mock_client = MockClient() + + class MockCharging: + def __init__(self): + self.status = INPROGRESS if state["charging"] else STOPPED + self.charging_mode = "slow" + + class MockEnergy: + def __init__(self): + self.charging = MockCharging() + self.level = 75 + self.updated_at = datetime.datetime.now(datetime.timezone.utc) + + class MockStatus: + def get_energy(self, energy_type): + return MockEnergy() + + class MockCar: + def get_status(self): + return MockStatus() + + def get_energy(self): + return MockEnergy() + + class MockVehiclesList: + def get_car_by_vin(self, vin): + return MockCar() + + mock_vehicles_list = MockVehiclesList() + + class MockPsa: + def __init__(self): + self.vehicles_list = mock_vehicles_list + self.remote_client = mock_client + self.info_refresh_rate = 1 + + def get_vehicle_info(self, vin): + return MockStatus() + + mock_psac = MockPsa() + # noinspection PyTypeChecker + cc = ChargeControl(mock_psac, vin, percentage_threshold, state["stop_hour"]) + + rounds = 0 + while not met_expectations() and rounds < 10: + cc.process() + rounds += 1 + self.assertTrue(expectations["stopped_charge"], "charge_now(..., False) should be called to stop the charge") + self.assertTrue(expectations["postponed_charge"], "change_charge_hour(...) should be called to postpone " + "the charge until the next day") + + +if __name__ == '__main__': + unittest.main() From e02d211bcb8c95ae7198f77b7b8add76e13cf2fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Br=C3=BCggemann?= Date: Wed, 20 Apr 2022 12:33:47 +0200 Subject: [PATCH 6/6] Revert "Fix installation on ARM macOS" This reverts commit 5e474b4d8c10310da8c2e79d439654826c5bc9e8. --- poetry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index ab150e1..53c65fc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1059,7 +1059,7 @@ python-versions = ">=3.5" [[package]] name = "scipy" -version = "1.8.0" +version = "1.6.1" description = "SciPy: Scientific Library for Python" category = "main" optional = false