mirror of
https://github.com/flobz/psa_car_controller.git
synced 2026-08-22 01:16:14 +00:00
update dashboard
This commit is contained in:
+40
-36
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import traceback
|
||||
import uuid
|
||||
from copy import copy
|
||||
@@ -25,11 +24,11 @@ from threading import Semaphore, Timer
|
||||
from functools import wraps
|
||||
import sqlite3
|
||||
|
||||
oauhth_url = {"clientsB2CPeugeot":"https://idpcvs.peugeot.com/am/oauth2/access_token",
|
||||
"clientsB2CCitroen":"https://idpcvs.citroen.com/am/oauth2/access_token",
|
||||
oauhth_url = {"clientsB2CPeugeot": "https://idpcvs.peugeot.com/am/oauth2/access_token",
|
||||
"clientsB2CCitroen": "https://idpcvs.citroen.com/am/oauth2/access_token",
|
||||
"clientsB2CDS": "https://idpcvs.driveds.com/am/oauth2/access_token",
|
||||
"clientsB2COpel":"https://idpcvs.opel.com/am/oauth2/access_token",
|
||||
"clientsB2CVauxhall":"https://idpcvs.vauxhall.co.uk/am/oauth2/access_token"}
|
||||
"clientsB2COpel": "https://idpcvs.opel.com/am/oauth2/access_token",
|
||||
"clientsB2CVauxhall": "https://idpcvs.vauxhall.co.uk/am/oauth2/access_token"}
|
||||
|
||||
authorize_service = "https://api.mpsa.com/api/connectedcar/v2/oauth/authorize"
|
||||
remote_url = "https://api.groupe-psa.com/connectedcar/v4/virtualkey/remoteaccess/token?client_id="
|
||||
@@ -40,6 +39,7 @@ MQTT_RESP_TOPIC = "psa/RemoteServices/to/cid/"
|
||||
MQTT_EVENT_TOPIC = "psa/RemoteServices/events/MPHRTServices/"
|
||||
MQTT_TOKEN_TTL = 890
|
||||
|
||||
|
||||
def rate_limit(limit, every):
|
||||
def limit_decorator(fn):
|
||||
semaphore = Semaphore(limit)
|
||||
@@ -58,6 +58,7 @@ def rate_limit(limit, every):
|
||||
|
||||
return limit_decorator
|
||||
|
||||
|
||||
class OpenIdCredentialManager(CredentialManager):
|
||||
def _grant_password_request(self, login: str, password: str, realm: str) -> dict:
|
||||
return dict(grant_type='password',
|
||||
@@ -129,7 +130,6 @@ def correlation_id(date):
|
||||
class MyPSACC:
|
||||
vehicles_url = "https://idpcvs.peugeot.com/api/connectedcar/v2/oauth/authorize"
|
||||
|
||||
|
||||
def connect(self, user, password):
|
||||
self.manager.init_with_user_credentials(user, password, self.realm)
|
||||
|
||||
@@ -155,9 +155,9 @@ class MyPSACC:
|
||||
self.api_config.api_key['client_id'] = self.client_id
|
||||
self.api_config.api_key['x-introspect-realm'] = self.realm
|
||||
self.headers = {
|
||||
"x-introspect-realm": realm,
|
||||
"accept": "application/hal+json",
|
||||
}
|
||||
"x-introspect-realm": realm,
|
||||
"accept": "application/hal+json",
|
||||
}
|
||||
self.remote_token_last_update = None
|
||||
self._record_enabled = False
|
||||
|
||||
@@ -222,7 +222,7 @@ class MyPSACC:
|
||||
def refresh_remote_token(self, force=False):
|
||||
if not force and self.remote_token_last_update is not None:
|
||||
last_update: datetime = self.remote_token_last_update
|
||||
if (datetime.now()-last_update).total_seconds() < MQTT_TOKEN_TTL:
|
||||
if (datetime.now() - last_update).total_seconds() < MQTT_TOKEN_TTL:
|
||||
return
|
||||
self.manager._refresh_token()
|
||||
res = self.manager.post(remote_url + self.client_id,
|
||||
@@ -243,7 +243,7 @@ class MyPSACC:
|
||||
logger.info("Connected with result code " + str(rc))
|
||||
topics = [MQTT_RESP_TOPIC + self.customer_id + "/#"]
|
||||
for vin in self.getVIN():
|
||||
topics.append(MQTT_EVENT_TOPIC+ vin + "/#")
|
||||
topics.append(MQTT_EVENT_TOPIC + vin + "/#")
|
||||
for topic in topics:
|
||||
client.subscribe(topic)
|
||||
logger.info("subscribe to " + topic)
|
||||
@@ -356,7 +356,7 @@ class MyPSACC:
|
||||
|
||||
@rate_limit(3, 60 * 20)
|
||||
def wakeup(self, vin):
|
||||
logger.info("ask wakeup to "+vin)
|
||||
logger.info("ask wakeup to " + vin)
|
||||
msg = self.mqtt_request(vin, {"action": "state"})
|
||||
logger.info(msg)
|
||||
self.mqtt_client.publish(MQTT_REQ_TOPIC + self.customer_id + "/VehCharge/state", msg)
|
||||
@@ -402,10 +402,10 @@ class MyPSACC:
|
||||
str = f.read()
|
||||
return MyPSACC(**json.loads(str))
|
||||
|
||||
def set_record(self,value:bool):
|
||||
def set_record(self, value: bool):
|
||||
self._record_enabled = value
|
||||
|
||||
def record_position(self,vin, res:psac.models.status.Status):
|
||||
def record_position(self, vin, res: psac.models.status.Status):
|
||||
conn = sqlite3.connect('info.db')
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS position (Timestamp DATETIME PRIMARY KEY, VIN TEXT, longitude REAL, latitude REAL, mileage REAL, level INTEGER);")
|
||||
@@ -433,9 +433,9 @@ class MyPSACC:
|
||||
res = conn.execute('SELECT * FROM position ORDER BY Timestamp');
|
||||
features_list = []
|
||||
for row in res:
|
||||
print(row)
|
||||
feature = Feature(geometry=Point((row["longitude"], row["latitude"])),
|
||||
properties={"vin": row["vin"], "date": row["Timestamp"], "mileage": row["mileage"], "level": row["level"]})
|
||||
properties={"vin": row["vin"], "date": row["Timestamp"], "mileage": row["mileage"],
|
||||
"level": row["level"]})
|
||||
features_list.append(feature)
|
||||
feature_collection = FeatureCollection(features_list)
|
||||
return geo_dumps(feature_collection, sort_keys=True)
|
||||
@@ -451,28 +451,32 @@ class MyPSACC:
|
||||
trips = []
|
||||
tr = Trip()
|
||||
battery_power = 46
|
||||
for next_el in res[2:]:
|
||||
distance = next_el["mileage"] - end["mileage"] # km
|
||||
duration = (next_el["Timestamp"] - end["Timestamp"]).total_seconds()/3600
|
||||
speed = distance/duration
|
||||
if distance == 0 or speed < 1: # check the speed to handle missing point
|
||||
tr.distance = end["mileage"] - start["mileage"] # km
|
||||
if tr.distance > 0:
|
||||
tr.start_at = start["Timestamp"]
|
||||
tr.end_at = end["Timestamp"]
|
||||
tr.add_points(end["longitude"], end["latitude"])
|
||||
tr.duration = (end["Timestamp"] - start["Timestamp"]).total_seconds() / 3600
|
||||
tr.speed_average = tr.distance / tr.duration
|
||||
print(start["level"] - end["level"])
|
||||
tr.consumption = (start["level"] - end["level"]) / 100 * battery_power # kw
|
||||
tr.consumption_km = 100 * tr.consumption / tr.distance # kw/100 km
|
||||
print(
|
||||
f"{start['Timestamp']} {tr.distance:.1f}km {tr.duration:.2f}h {tr.speed_average:.2f} km/h {tr.consumption:.2f} kw {tr.consumption_km:.2f}kw/100km")
|
||||
trips.append(tr)
|
||||
start = next_el
|
||||
for x in range(0,len(res)-2):
|
||||
next_el = res[x+2]
|
||||
if end["mileage"] - start["mileage"] == 0 or \
|
||||
(end["Timestamp"] - start["Timestamp"]).total_seconds() / 3600 > 3:
|
||||
start = end
|
||||
tr = Trip()
|
||||
else:
|
||||
tr.add_points(end["longitude"], end["latitude"])
|
||||
distance = next_el["mileage"] - end["mileage"] # km
|
||||
duration = (next_el["Timestamp"] - end["Timestamp"]).total_seconds() / 3600
|
||||
if (distance == 0 and duration > 0.08) or duration > 2: # check the speed to handle missing point
|
||||
tr.distance = end["mileage"] - start["mileage"] # km
|
||||
if tr.distance > 0:
|
||||
tr.start_at = start["Timestamp"]
|
||||
tr.end_at = end["Timestamp"]
|
||||
tr.add_points(end["longitude"], end["latitude"])
|
||||
tr.duration = (end["Timestamp"] - start["Timestamp"]).total_seconds() / 3600
|
||||
tr.speed_average = tr.distance / tr.duration
|
||||
tr.consumption = (start["level"] - end["level"]) / 100 * battery_power # kw
|
||||
tr.consumption_km = 100 * tr.consumption / tr.distance # kw/100 km
|
||||
logger.debug(
|
||||
f"Trip: {start['Timestamp']} {tr.distance:.1f}km {tr.duration:.2f}h {tr.speed_average:.2f} km/h {tr.consumption:.2f} kw {tr.consumption_km:.2f}kw/100km")
|
||||
trips.append(tr)
|
||||
start = next_el
|
||||
tr = Trip()
|
||||
else:
|
||||
tr.add_points(end["longitude"], end["latitude"])
|
||||
end = next_el
|
||||
return trips
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from typing import List
|
||||
|
||||
from dateutil.tz import tzlocal
|
||||
from geojson import Feature, Point, FeatureCollection, MultiLineString
|
||||
from geojson import dumps as geo_dumps
|
||||
|
||||
@@ -48,6 +50,6 @@ class Trip:
|
||||
"average consumption": self.consumption_km})
|
||||
|
||||
def get_info(self):
|
||||
res = {"start_at": self.start_at.strftime("%x %X"), "end_at": self.end_at.strftime("%x %X"), "duration": self.duration*60,
|
||||
res = {"start_at": self.start_at.astimezone(None).strftime("%x %X"), "end_at": self.end_at.astimezone(None).strftime("%x %X"), "duration": self.duration*60,
|
||||
"speed_average": self.speed_average, "consumption_km": self.consumption_km, "distance": self.distance}
|
||||
return res
|
||||
|
||||
+2
-1
@@ -13,4 +13,5 @@ pytz
|
||||
typing
|
||||
argparse
|
||||
flask
|
||||
dash_bootstrap_components
|
||||
dash_bootstrap_components
|
||||
geojson
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
import atexit
|
||||
import sys
|
||||
from threading import Thread
|
||||
from oauth2_client.credentials_manager import OAuthError
|
||||
@@ -11,6 +12,7 @@ from web.app import app, save_config
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("-f", "--config", help="config file", type=argparse.FileType('r'))
|
||||
@@ -21,7 +23,7 @@ def parse_args():
|
||||
parser.add_argument("-r", "--record-position", help="save vehicle position to db", action='store_true')
|
||||
parser.add_argument("-m", "--mail", help="change the email address")
|
||||
parser.add_argument("-P", "--password", help="change the password")
|
||||
parser.add_argument("--remote-disable",help="disable remote control")
|
||||
parser.add_argument("--remote-disable", help="disable remote control")
|
||||
parser.parse_args()
|
||||
return parser
|
||||
|
||||
@@ -37,6 +39,7 @@ if __name__ == "__main__":
|
||||
app.myp = MyPSACC.load_config(name=args.config.name)
|
||||
else:
|
||||
app.myp = MyPSACC.load_config()
|
||||
atexit.register(app.myp.save_config)
|
||||
if args.record_position:
|
||||
app.myp.set_record(True)
|
||||
try:
|
||||
@@ -50,10 +53,10 @@ if __name__ == "__main__":
|
||||
client_password = input("mypeugeot password: ")
|
||||
app.myp.connect(client_email, client_password)
|
||||
logger.info(app.myp.get_vehicles())
|
||||
t1 = Thread(target=app.run,kwargs={"host":args.listen,"port":int(args.port)})
|
||||
t1 = Thread(target=app.run, kwargs={"host": args.listen, "port": int(args.port)})
|
||||
t1.start()
|
||||
if args.remote_disable:
|
||||
logger.info("mqtt disabled")
|
||||
logger.info("mqtt disabled")
|
||||
else:
|
||||
app.myp.start_mqtt()
|
||||
if args.charge_control:
|
||||
|
||||
+45
-33
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
import dash
|
||||
import dash_bootstrap_components as dbc
|
||||
@@ -13,6 +14,7 @@ from flask import jsonify, request, Response as FlaskResponse
|
||||
from web import figures
|
||||
|
||||
from MyPSACC import MyPSACC
|
||||
|
||||
try:
|
||||
locale = locale.getlocale()[0].split("_")[0]
|
||||
locale_url = [f"https://cdn.plot.ly/plotly-locale-{locale}-latest.js"]
|
||||
@@ -30,6 +32,7 @@ chc = None
|
||||
Output('consumption_fig', 'figure'),
|
||||
Output('consumption_fig_by_speed', 'figure'),
|
||||
Output('consumption', 'children'),
|
||||
Output('tab_trips', 'children'),
|
||||
Input('date-slider', 'value'))
|
||||
def display_value(value):
|
||||
min = datetime.fromtimestamp(value[0], tz=timezone.utc)
|
||||
@@ -38,10 +41,9 @@ def display_value(value):
|
||||
for trip in trips:
|
||||
if min <= trip.start_at <= max:
|
||||
filtered_trips.append(trip)
|
||||
print(len(filtered_trips))
|
||||
figures.get_figures(filtered_trips)
|
||||
consumption = "Average consumption: {:.1f} kW/100km".format(float(figures.consumption_df.mean()))
|
||||
return figures.trips_map, figures.consumption_fig, figures.consumption_fig_by_speed, consumption
|
||||
consumption = "Average consumption: {:.1f} kW/100km".format(float(figures.consumption_df.mean(numeric_only=True)))
|
||||
return figures.trips_map, figures.consumption_fig, figures.consumption_fig_by_speed, consumption, figures.table_fig
|
||||
|
||||
|
||||
@app.route('/getvehicles')
|
||||
@@ -120,36 +122,46 @@ def save_config(my_peugeot: MyPSACC):
|
||||
threading.Timer(30, save_config, args=[my_peugeot]).start()
|
||||
|
||||
|
||||
trips = MyPSACC.get_trips()
|
||||
figures.get_figures(trips)
|
||||
try:
|
||||
trips = MyPSACC.get_trips()
|
||||
min_date = trips[0].start_at
|
||||
max_date = trips[-1].start_at
|
||||
min_millis = figures.unix_time_millis(min_date)
|
||||
max_millis = figures.unix_time_millis(max_date)
|
||||
step = (max_millis - min_millis) / 100
|
||||
figures.get_figures(trips)
|
||||
data_div = html.Div([dcc.RangeSlider(
|
||||
id='date-slider',
|
||||
min=min_millis,
|
||||
max=max_millis,
|
||||
step=step,
|
||||
marks=figures.get_marks_from_start_end(min_date,
|
||||
max_date),
|
||||
value=[min_millis, max_millis],
|
||||
),
|
||||
html.Div([
|
||||
dbc.Tabs([
|
||||
dbc.Tab(label="Summary", tab_id="summary", children=[
|
||||
html.H2(id="consumption",
|
||||
children=figures.info),
|
||||
dcc.Graph(figure=figures.consumption_fig, id="consumption_fig"),
|
||||
dcc.Graph(figure=figures.consumption_fig_by_speed, id="consumption_fig_by_speed")
|
||||
]),
|
||||
dbc.Tab(label="Trips", tab_id="trips", id="tab_trips", children=[figures.table_fig]),
|
||||
dbc.Tab(label="Map", tab_id="map", children=[
|
||||
dcc.Graph(figure=figures.trips_map, id="trips_map", style={"height": '90vh'})]),
|
||||
],
|
||||
id="tabs",
|
||||
active_tab="summary",
|
||||
),
|
||||
html.Div(id="tab-content", className="p-4"),
|
||||
])])
|
||||
except:
|
||||
logger.error("Failed to generate figure, there is probably not enough data yet")
|
||||
logger.error(traceback.format_exc())
|
||||
data_div = dbc.Alert("No data to show", color="danger")
|
||||
|
||||
dash_app.layout = dbc.Container(fluid=True, children=[
|
||||
html.H1('My car info'),
|
||||
dcc.RangeSlider(
|
||||
id='date-slider',
|
||||
min=figures.unix_time_millis(figures.consumption_df["date"].min()),
|
||||
max=figures.unix_time_millis(figures.consumption_df["date"].max()),
|
||||
step=None,
|
||||
marks=figures.get_marks_from_start_end(figures.consumption_df["date"].min(),
|
||||
figures.consumption_df["date"].max()),
|
||||
value=[figures.unix_time_millis(figures.consumption_df["date"].min()),
|
||||
figures.unix_time_millis(figures.consumption_df["date"].max())],
|
||||
),
|
||||
html.Div([
|
||||
dbc.Tabs([
|
||||
dbc.Tab(label="Summary", tab_id="summary", children=[
|
||||
html.H2(id="consumption",
|
||||
children="Average consumption: {:.1f} kW/100km".format(
|
||||
float(figures.consumption_df.mean()))),
|
||||
dcc.Graph(figure=figures.consumption_fig, id="consumption_fig"),
|
||||
dcc.Graph(figure=figures.consumption_fig_by_speed, id="consumption_fig_by_speed")
|
||||
]),
|
||||
dbc.Tab(label="Trips", tab_id="trips", children=[figures.table_fig]),
|
||||
dbc.Tab(label="Map", tab_id="map", children=[
|
||||
dcc.Graph(figure=figures.trips_map, id="trips_map", style={"height": '90vh'})]),
|
||||
],
|
||||
id="tabs",
|
||||
active_tab="summary",
|
||||
),
|
||||
html.Div(id="tab-content", className="p-4"),
|
||||
]),
|
||||
data_div
|
||||
])
|
||||
|
||||
+23
-17
@@ -17,7 +17,7 @@ def unix_time_millis(dt):
|
||||
|
||||
|
||||
def get_marks_from_start_end(start, end):
|
||||
nb_marks = 5
|
||||
nb_marks = 10
|
||||
result = []
|
||||
time_delta = int((end - start).total_seconds() / nb_marks)
|
||||
current = start
|
||||
@@ -28,11 +28,11 @@ def get_marks_from_start_end(start, end):
|
||||
result[-1] = end
|
||||
if time_delta < 3600 * 24:
|
||||
if time_delta > 3600:
|
||||
date_f = '%y-%m-%d %Hh'
|
||||
date_f = '%x %Hh'
|
||||
else:
|
||||
date_f = '%y-%m-%d %Hh%M'
|
||||
date_f = '%x %Hh%M'
|
||||
else:
|
||||
date_f = '%Y-%m'
|
||||
date_f = '%x'
|
||||
marks = {}
|
||||
for date in result:
|
||||
marks[unix_time_millis(date)] = str(date.strftime(date_f))
|
||||
@@ -45,10 +45,11 @@ trips_map = None
|
||||
consumption_fig_by_speed = None
|
||||
table_fig = None
|
||||
pandas_options.display.float_format = '${:.2f}'.format
|
||||
info = ""
|
||||
|
||||
|
||||
def get_figures(trips: List[Trip]):
|
||||
global consumption_fig, consumption_df, trips_map, consumption_fig_by_speed, table_fig
|
||||
global consumption_fig, consumption_df, trips_map, consumption_fig_by_speed, table_fig, info
|
||||
lats = []
|
||||
lons = []
|
||||
names = []
|
||||
@@ -60,32 +61,37 @@ def get_figures(trips: List[Trip]):
|
||||
lats = np.append(lats, None)
|
||||
lons = np.append(lons, None)
|
||||
names = np.append(names, None)
|
||||
|
||||
trips_map = px.line_mapbox(lat=lats, lon=lons, hover_name=names,
|
||||
mapbox_style="stamen-terrain", zoom=12)
|
||||
# table
|
||||
nb_format = Format(precision=2, scheme=Scheme.fixed, symbol=Symbol.yes)
|
||||
table_fig = dash_table.DataTable(
|
||||
id='trips-table',
|
||||
sort_action='native',
|
||||
columns=[{'id': 'start_at', 'name': 'start at', 'type': 'datetime'},
|
||||
{'id': 'duration', 'name': 'duration', 'type': 'numeric', 'format': deepcopy(nb_format).symbol_suffix(" min").precision(0)},
|
||||
{'id': 'speed_average', 'name': 'average speed', 'type': 'numeric', 'format': deepcopy(nb_format).symbol_suffix(" km/h")},
|
||||
{'id': 'consumption_km', 'name': 'average consumption', 'type': 'numeric', 'format': deepcopy(nb_format).symbol_suffix(" kw/100km")},
|
||||
{'id': 'duration', 'name': 'duration', 'type': 'numeric',
|
||||
'format': deepcopy(nb_format).symbol_suffix(" min").precision(0)},
|
||||
{'id': 'speed_average', 'name': 'average speed', 'type': 'numeric',
|
||||
'format': deepcopy(nb_format).symbol_suffix(" km/h")},
|
||||
{'id': 'consumption_km', 'name': 'average consumption', 'type': 'numeric',
|
||||
'format': deepcopy(nb_format).symbol_suffix(" kw/100km")},
|
||||
{'id': 'distance', 'name': 'distance', 'type': 'numeric', 'format': nb_format.symbol_suffix(" km")}],
|
||||
data=[tr.get_info() for tr in trips],
|
||||
)
|
||||
# map
|
||||
trips_map = px.line_mapbox(lat=lats, lon=lons, hover_name=names,
|
||||
mapbox_style="stamen-terrain", zoom=12)
|
||||
# consumption_fig
|
||||
consumption_df = DataFrame.from_records([tr.get_consumption() for tr in trips])
|
||||
consumption_fig = px.line(consumption_df, x="date", y="consumption", title='Consumption of the car')
|
||||
consumption_fig.update_layout(yaxis_title="Consumption kWh/100Km")
|
||||
|
||||
consum_df_by_speed = DataFrame.from_records(
|
||||
[{"speed": tr.speed_average, "value": tr.consumption_km} for tr in trips])
|
||||
consumption_fig_by_speed = px.histogram(consum_df_by_speed, x="speed", y="value", histfunc="avg",
|
||||
[{"speed": tr.speed_average, "consumption": tr.consumption_km} for tr in trips])
|
||||
consumption_fig_by_speed = px.histogram(consum_df_by_speed, x="speed", y="consumption", histfunc="avg",
|
||||
title="Consumption by speed")
|
||||
consumption_fig_by_speed.update_traces(xbins_size=15)
|
||||
consumption_fig_by_speed.update_layout(bargap=0.1)
|
||||
consumption_fig_by_speed.update_layout(bargap=0.05)
|
||||
consumption_fig_by_speed.add_trace(
|
||||
go.Scatter(mode="markers", x=consum_df_by_speed["speed"], y=consum_df_by_speed["value"],
|
||||
go.Scatter(mode="markers", x=consum_df_by_speed["speed"], y=consum_df_by_speed["consumption"],
|
||||
name="Trips"))
|
||||
consumption_fig_by_speed.update_layout(xaxis_title="average Speed km/h", yaxis_title="Consumption kW/100Km")
|
||||
consumption_fig_by_speed.update_layout(xaxis_title="average Speed km/h", yaxis_title="Consumption kWh/100Km")
|
||||
info = "Average consumption: {:.1f} kW/100km".format(
|
||||
float(consumption_df.mean(numeric_only=True)))
|
||||
|
||||
Reference in New Issue
Block a user