mirror of
https://github.com/flobz/psa_car_controller.git
synced 2026-08-26 10:17:18 +00:00
feat: add battery soh record
This commit is contained in:
committed by
Florian BEZANNIER
parent
cddd0ad790
commit
38040130ea
@@ -3,6 +3,7 @@ import threading
|
||||
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 @@ class PSAClient:
|
||||
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.debug("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():
|
||||
|
||||
@@ -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
|
||||
@@ -13,6 +13,7 @@ from geojson import dumps as geo_dumps
|
||||
|
||||
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 @@ class Database:
|
||||
"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 @@ class Database:
|
||||
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 @@ class Database:
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
version="1.1"
|
||||
id="svg2"
|
||||
sodipodi:docname="battery-soh.svg"
|
||||
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="namedview2"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="50.333333"
|
||||
inkscape:cx="12"
|
||||
inkscape:cy="12"
|
||||
inkscape:window-width="2530"
|
||||
inkscape:window-height="1412"
|
||||
inkscape:window-x="30"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg2" />
|
||||
<path
|
||||
fill="none"
|
||||
d="M0 0h24v24H0z"
|
||||
id="path1" />
|
||||
<path
|
||||
d="M 11,6.0397351 H 7 V 20.039735 H 17 V 6.0397351 h -4 v -2 h -2 z m -2,-2 v -1 c 0,-0.5522847 0.4477153,-1 1,-1 h 4 c 0.552285,0 1,0.4477153 1,1 v 1 h 3 c 0.552285,0 1,0.4477153 1,1 V 21.039735 c 0,0.552285 -0.447715,1 -1,1 H 6 c -0.5522847,0 -1,-0.447715 -1,-1 V 5.0397351 c 0,-0.5522847 0.4477153,-1 1,-1 z"
|
||||
id="path2"
|
||||
sodipodi:nodetypes="ccccccccccsssscssssssssc" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.83317"
|
||||
id="rect4"
|
||||
width="1.9867547"
|
||||
height="8.543046"
|
||||
x="11.026489"
|
||||
y="8.9602642"
|
||||
ry="0.72471911" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.795508"
|
||||
id="rect4-3"
|
||||
width="1.9470197"
|
||||
height="7.947021"
|
||||
x="12.311257"
|
||||
y="-15.999998"
|
||||
ry="0.67415732"
|
||||
transform="rotate(90)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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())
|
||||
|
||||
@@ -37,3 +37,21 @@ ELECTRIC_CAR_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"}
|
||||
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"}
|
||||
|
||||
@@ -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)
|
||||
+44
-11
@@ -30,19 +30,10 @@ from psa_car_controller.psacc.repository.config_repository import ConfigReposito
|
||||
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
|
||||
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 +155,37 @@ class TestUnit(unittest.TestCase):
|
||||
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 +211,18 @@ class TestUnit(unittest.TestCase):
|
||||
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)
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import timedelta, datetime
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user