From 9b881c79adc8179979b8d4aebe32fcea4691d781 Mon Sep 17 00:00:00 2001 From: Florian Bezannier Date: Wed, 1 Nov 2023 15:27:16 +0100 Subject: [PATCH] feat: add battery soh record --- .../psacc/application/psa_client.py | 33 ++++++---- psa_car_controller/psacc/model/battery_soh.py | 10 +++ psa_car_controller/psacc/repository/db.py | 31 +++++++++- .../web/assets/images/battery-soh.svg | 59 ++++++++++++++++++ psa_car_controller/web/view/control.py | 61 +++++++++++-------- tests/data/car_status.py | 18 ++++++ tests/test_db.py | 27 ++++++++ tests/test_unit.py | 54 +++++++++++++--- tests/utils.py | 8 +++ 9 files changed, 254 insertions(+), 47 deletions(-) create mode 100644 psa_car_controller/psacc/model/battery_soh.py create mode 100644 psa_car_controller/web/assets/images/battery-soh.svg create mode 100644 tests/test_db.py diff --git a/psa_car_controller/psacc/application/psa_client.py b/psa_car_controller/psacc/application/psa_client.py index 7356517f..44317b81 100644 --- a/psa_car_controller/psacc/application/psa_client.py +++ b/psa_car_controller/psacc/application/psa_client.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone from json import JSONEncoder from hashlib import md5 +from sqlite3.dbapi2 import IntegrityError from oauth2_client.credentials_manager import ServiceInformation from urllib3.exceptions import InvalidHeader @@ -194,17 +195,27 @@ def record_info(self, car: Car): # pylint: disable=too-many-locals Database.record_position(self.weather_api, car.vin, mileage, latitude, longitude, altitude, date, level, level_fuel, moving) self.abrp.call(car, Database.get_last_temp(car.vin)) - try: - charging_status = car.status.get_energy('Electric').charging.status - charging_mode = car.status.get_energy('Electric').charging.charging_mode - charging_rate = car.status.get_energy('Electric').charging.charging_rate - autonomy = car.status.get_energy('Electric').autonomy - Charging.record_charging(car, charging_status, charge_date, level, latitude, longitude, self.country_code, - charging_mode, charging_rate, autonomy, mileage) - logger.debug("charging_status:%s ", charging_status) - except AttributeError as ex: - logger.error("charging status not available from api") - logger.debug(ex) + if car.has_battery(): + electric_energy_status = car.status.get_energy('Electric') + try: + charging_status = electric_energy_status.charging.status + charging_mode = electric_energy_status.charging.charging_mode + charging_rate = electric_energy_status.charging.charging_rate + autonomy = electric_energy_status.autonomy + Charging.record_charging(car, charging_status, charge_date, level, latitude, longitude, + self.country_code, + charging_mode, charging_rate, autonomy, mileage) + logger.debug("charging_status:%s ", charging_status) + except AttributeError as ex: + logger.error("charging status not available from api") + logger.debug(ex) + try: + soh = electric_energy_status.battery.health.resistance + Database.record_battery_soh(car.vin, charge_date, soh) + except IntegrityError: + logger.info("SOH already recorded") + except AttributeError as ex: + logger.debug("Failed to record SOH: %s", ex) def __iter__(self): for key, value in self.__dict__.items(): diff --git a/psa_car_controller/psacc/model/battery_soh.py b/psa_car_controller/psacc/model/battery_soh.py new file mode 100644 index 00000000..ab9cc179 --- /dev/null +++ b/psa_car_controller/psacc/model/battery_soh.py @@ -0,0 +1,10 @@ +from datetime import datetime + +from typing import List + + +class BatterySoh: + def __init__(self, vin, dates, levels): + self.vin = vin + self.dates: List[datetime] = dates + self.levels: List[float] = levels diff --git a/psa_car_controller/psacc/repository/db.py b/psa_car_controller/psacc/repository/db.py index 2ad6397e..65b94b1b 100644 --- a/psa_car_controller/psacc/repository/db.py +++ b/psa_car_controller/psacc/repository/db.py @@ -13,6 +13,7 @@ from psa_car_controller.common import utils from psa_car_controller.psacc.model.battery_curve import BatteryCurveDto +from psa_car_controller.psacc.model.battery_soh import BatterySoh from psa_car_controller.psacc.model.charge import Charge from psa_car_controller.psacc.utils.utils import get_temp @@ -103,6 +104,8 @@ def init_db(conn): "start_level INTEGER, end_level INTEGER, co2 INTEGER, kw INTEGER);") conn.execute("""CREATE TABLE IF NOT EXISTS battery_curve (start_at DATETIME, VIN TEXT, date DATETIME, level INTEGER, UNIQUE(start_at, VIN, level));""") + conn.execute("""CREATE TABLE IF NOT EXISTS + battery_soh(date DATETIME, VIN TEXT, level FLOAT, UNIQUE(VIN, level));""") table_to_update = [["position", NEW_POSITION_COLUMNS], ["battery", NEW_BATTERY_COLUMNS], ["battery_curve", NEW_BATTERY_CURVE_COLUMNS]] @@ -182,7 +185,7 @@ def get_battery_curve(conn, start_at, stop_at, vin): battery_curves = [] res = conn.execute("""SELECT date, level, rate, autonomy FROM battery_curve - WHERE start_at=? and date<=? and VIN=? + WHERE start_at=? and date<=? and VIN=? ORDER BY date asc;""", (start_at, stop_at, vin)).fetchall() for row in res: @@ -267,6 +270,32 @@ def record_position(weather_api, vin, mileage, latitude, longitude, altitude, da logger.debug("position already saved") return False + @staticmethod + def record_battery_soh(vin: str, date: datetime, level: float): + conn = Database.get_db() + conn.execute("INSERT INTO battery_soh(date, VIN, level) VALUES(?,?,?)", (date, vin, level)) + conn.commit() + conn.close() + + @staticmethod + def get_soh_by_vin(vin) -> BatterySoh: + conn = Database.get_db() + res = conn.execute("SELECT date, level FROM battery_soh WHERE VIN=? ORDER BY date", (vin,)).fetchall() + dates = [] + levels = [] + for row in res: + dates.append(row[0]) + levels.append(row[1]) + return BatterySoh(vin, dates, levels) + + @staticmethod + def get_last_soh_by_vin(vin) -> float: + conn = Database.get_db() + res = conn.execute("SELECT level FROM battery_soh WHERE VIN=? ORDER BY date DESC LIMIT 1", (vin,)).fetchall() + if res: + return res[0][0] + return None + @staticmethod def get_last_charge(vin) -> Charge: conn = Database.get_db() diff --git a/psa_car_controller/web/assets/images/battery-soh.svg b/psa_car_controller/web/assets/images/battery-soh.svg new file mode 100644 index 00000000..a5efd1ae --- /dev/null +++ b/psa_car_controller/web/assets/images/battery-soh.svg @@ -0,0 +1,59 @@ + + + + + + + + + diff --git a/psa_car_controller/web/view/control.py b/psa_car_controller/web/view/control.py index 825e9e4e..df1ef1ed 100644 --- a/psa_car_controller/web/view/control.py +++ b/psa_car_controller/web/view/control.py @@ -1,9 +1,11 @@ import logging +from collections import OrderedDict import dash_bootstrap_components as dbc from dash import html from psa_car_controller.psacc.application.psa_client import PSAClient +from psa_car_controller.psacc.repository.db import Database from psa_car_controller.web.tools.Button import Button from psa_car_controller.web.tools.Switch import Switch from psa_car_controller.web.tools.utils import card_value_div, create_card @@ -33,31 +35,40 @@ def get_control_tabs(config): myp: PSAClient = config.myp el = [] buttons_row = [] - if config.remote_control and car.status is not None: - try: - preconditionning_state = car.status.preconditionning.air_conditioning.status != "Disabled" - charging_state = car.status.get_energy('Electric').charging.status == "InProgress" - cards = {"Battery": {"text": [card_value_div("battery_value", "%", - value=convert_value_to_str( - car.status.get_energy('Electric').level))], - "src": "assets/images/battery-charge.svg"}, - "Mileage": {"text": [card_value_div("mileage_value", "km", - value=convert_value_to_str( - car.status.timed_odometer.mileage))], - "src": "assets/images/mileage.svg"} - } - el.append(dbc.Container(dbc.Row(children=create_card(cards)), fluid=True)) - refresh_date = car.status.get_energy('Electric').updated_at.astimezone().strftime("%X %x") - buttons_row.extend([Button(REFRESH_SWITCH, car.vin, - html.Div([html.Img(src="assets/images/sync.svg", width="50px"), - refresh_date]), - myp.remote_client.wakeup).get_html(), - Switch(CHARGE_SWITCH, car.vin, "Charge", myp.remote_client.charge_now, - charging_state).get_html(), - Switch(PRECONDITIONING_SWITCH, car.vin, "Preconditioning", - myp.remote_client.preconditioning, preconditionning_state).get_html()]) - except (AttributeError, TypeError): - logger.exception("get_control_tabs:") + if car.status is not None: + cards = OrderedDict({"Battery SOC": {"text": [card_value_div("battery_value", "%", + value=convert_value_to_str( + car.status.get_energy('Electric').level))], + "src": "assets/images/battery-charge.svg"}, + "Mileage": {"text": [card_value_div("mileage_value", "km", + value=convert_value_to_str( + car.status.timed_odometer.mileage))], + "src": "assets/images/mileage.svg"} + }) + soh = Database.get_last_soh_by_vin(car.vin) + if soh: + cards["Battery SOH"] = {"text": [card_value_div("battery_soh_value", "%", + value=convert_value_to_str( + soh))], + "src": "assets/images/battery-soh.svg"} + cards.move_to_end("Mileage") + el.append(dbc.Container(dbc.Row(children=create_card(cards)), fluid=True)) + if config.remote_control: + try: + preconditionning_state = car.status.preconditionning.air_conditioning.status != "Disabled" + charging_state = car.status.get_energy('Electric').charging.status == "InProgress" + + refresh_date = car.status.get_energy('Electric').updated_at.astimezone().strftime("%X %x") + buttons_row.extend([Button(REFRESH_SWITCH, car.vin, + html.Div([html.Img(src="assets/images/sync.svg", width="50px"), + refresh_date]), + myp.remote_client.wakeup).get_html(), + Switch(CHARGE_SWITCH, car.vin, "Charge", myp.remote_client.charge_now, + charging_state).get_html(), + Switch(PRECONDITIONING_SWITCH, car.vin, "Preconditioning", + myp.remote_client.preconditioning, preconditionning_state).get_html()]) + except (AttributeError, TypeError): + logger.exception("get_control_tabs:") if not config.offline: buttons_row.append(Switch(ABRP_SWITCH, car.vin, "Send data to ABRP", myp.abrp.enable_abrp, car.vin in config.myp.abrp.abrp_enable_vin).get_html()) diff --git a/tests/data/car_status.py b/tests/data/car_status.py index aa6811f2..6c806a78 100644 --- a/tests/data/car_status.py +++ b/tests/data/car_status.py @@ -37,3 +37,21 @@ "vehicles": { "href": "https://api.groupe-psa.com/connectedcar/v4/user/vehicles/aa"}}, "odometer": {"createdAt": None, "mileage": 3196.5}, "updatedAt": "2022-03-26T11:02:54Z"} +ELECTRIC_CAR_STATUS_V2 = { + "lastPosition": {"type": "Feature", "geometry": {"type": "Point", "coordinates": [-1.59008, 47.274, 30]}, + "properties": {"updatedAt": "2021-03-29T06:22:51Z", "type": "Acquire", "signalQuality": 9}}, + "preconditionning": {"airConditioning": {"updatedAt": "2022-03-26T10:52:11Z", "status": "Disabled"}}, + "energy": [{"createdAt": "2021-09-14T20:39:06Z", "type": "Fuel", "level": 0}, + {"createdAt": "2022-03-26T11:02:54Z", "type": "Electric", "level": 59, "autonomy": 122, + "charging": {"plugged": False, "status": "Disconnected", "remainingTime": "PT0S", "chargingRate": 0, + "chargingMode": "No", "nextDelayedTime": "PT22H31M"}, + "battery": {"health": {"resistance": 90}}}], + "createdAt": "2022-03-26T11:02:54Z", + "battery": {"voltage": 83.5, "current": 0, "createdAt": "2022-03-26T10:52:11Z"}, + "kinetic": {"createdAt": "2021-03-29T06:22:51Z", "moving": True}, + "privacy": {"createdAt": "2022-03-26T11:02:53Z", "state": "None"}, + "service": {"type": "Electric", "createdAt": "2022-03-26T11:02:54Z"}, "_links": {"self": { + "href": "https://api.groupe-psa.com/connectedcar/v4/user/vehicles/aa/status"}, + "vehicles": { + "href": "https://api.groupe-psa.com/connectedcar/v4/user/vehicles/aa"}}, + "odometer": {"createdAt": None, "mileage": 3196.5}, "updatedAt": "2022-03-26T11:02:54Z"} diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 00000000..6388d560 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,27 @@ +import unittest +from sqlite3.dbapi2 import IntegrityError + +from psa_car_controller.psacc.model.battery_soh import BatterySoh +from psa_car_controller.psacc.repository.db import Database +from tests.utils import get_new_test_db, compare_dict, get_date, vehicule_list + + +class TestUnit(unittest.TestCase): + def test_record_soh(self): + get_new_test_db() + car = vehicule_list[0] + soh_list = [99.0, 96.0, 90.2] + for x in range(len(soh_list)): + Database.record_battery_soh(car.vin, get_date(x), soh_list[x]) + compare_dict(vars(BatterySoh(car.vin, + [get_date(0), get_date(1), get_date(2)], + soh_list)), + vars(Database.get_soh_by_vin(car.vin)) + ) + self.assertEqual(soh_list[-1], Database.get_last_soh_by_vin(car.vin)) + + def test_record_same_soh(self): + get_new_test_db() + car = vehicule_list[0] + Database.record_battery_soh(car.vin, get_date(0), 99.0) + self.assertRaises(IntegrityError, Database.record_battery_soh, car.vin, get_date(0), 99.0) diff --git a/tests/test_unit.py b/tests/test_unit.py index b8d31867..0ce1b0df 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -30,18 +30,10 @@ from psa_car_controller.psacc.repository.db import Database from psa_car_controller.psacc.repository.trips import Trips from psa_car_controller.psacc.utils.utils import get_temp -from tests.data.car_status import FUEL_CAR_STATUS, ELECTRIC_CAR_STATUS +from tests.data.car_status import FUEL_CAR_STATUS, ELECTRIC_CAR_STATUS, ELECTRIC_CAR_STATUS_V2, ELECTRIC_CAR_STATUS_V3 from tests.utils import DATA_DIR, record_position, latitude, longitude, date0, date1, date2, date3, record_charging, \ vehicule_list, get_new_test_db, get_date, date4 from psa_car_controller.web.figures import get_figures, get_battery_curve_fig, get_altitude_fig -from deepdiff import DeepDiff - - -def compare_dict(result, expected): - diff = DeepDiff(expected, result) - if diff != {}: - raise AssertionError(str(diff)) - return True dummy_value = 0 @@ -164,6 +156,37 @@ def test_electric_record_info(self, mock_db): True) self.assertEqual(db_record_position_arg, expected_result) + @patch("psa_car_controller.psacc.repository.db.Database.record_battery_soh") + @patch("psa_car_controller.psacc.repository.db.Database.record_position") + def test_electric_record_info_v2(self, mock_db, moock_soh): + api = ApiClient() + status: psa.connected_car_api.models.status.Status = api._ApiClient__deserialize( + ELECTRIC_CAR_STATUS_V2, "Status") + get_new_test_db() + car = self.vehicule_list[0] + car.status = status + myp = PSAClient.load_config(DATA_DIR + "config.json") + myp.record_info(car) + db_record_position_arg = mock_db.call_args_list[0][0] + expected_result = (None, 'VR3UHZKX', 3196.5, 47.274, -1.59008, 30, + datetime(2022, 3, 26, 11, 2, 54, tzinfo=tzutc()), + 59.0, + None, + True) + self.assertEqual(db_record_position_arg, expected_result) + self.assertEqual( + ('VR3UHZKX', + datetime( + 2022, + 3, + 26, + 11, + 2, + 54, + tzinfo=tzutc()), + 90), + moock_soh.call_args_list[0][0]) + def test_record_position_charging(self): get_new_test_db() config_repository.CONFIG_FILENAME = DATA_DIR + "config.ini" @@ -189,7 +212,18 @@ def test_record_position_charging(self): start_level = 40 end_level = 85 mileage = 123456789.1 - Charging.record_charging(car, "InProgress", date0, start_level, latitude, longitude, None, "slow", 20, 60, mileage) + Charging.record_charging( + car, + "InProgress", + date0, + start_level, + latitude, + longitude, + None, + "slow", + 20, + 60, + mileage) Charging.record_charging(car, "InProgress", date1, 70, latitude, longitude, "FR", "slow", 20, 60, mileage) Charging.record_charging(car, "InProgress", date1, 70, latitude, longitude, "FR", "slow", 20, 60, mileage) Charging.record_charging(car, "InProgress", date2, 80, latitude, longitude, "FR", "slow", 20, 60, mileage) diff --git a/tests/utils.py b/tests/utils.py index 0f497252..530e1090 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock import pytz +from deepdiff import DeepDiff from psa_car_controller.psa.RemoteClient import RemoteClient from psa_car_controller.psa.connected_car_api import Vehicles @@ -62,3 +63,10 @@ def get_rc() -> RemoteClient: account_info = MagicMock() account_info.realm = "" return RemoteClient(account_info, Vehicles, None, None) + + +def compare_dict(result, expected): + diff = DeepDiff(expected, result) + if diff != {}: + raise AssertionError(str(diff)) + return True