diff --git a/MyPSACC.py b/MyPSACC.py index 0673f9f..90f3553 100644 --- a/MyPSACC.py +++ b/MyPSACC.py @@ -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 diff --git a/Trip.py b/Trip.py index eb1dde1..4a7ada7 100644 --- a/Trip.py +++ b/Trip.py @@ -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 diff --git a/requirements.txt b/requirements.txt index 7d0b9f4..98a33b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,5 @@ pytz typing argparse flask -dash_bootstrap_components \ No newline at end of file +dash_bootstrap_components +geojson \ No newline at end of file diff --git a/server.py b/server.py index a7b5874..be85c5d 100755 --- a/server.py +++ b/server.py @@ -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: diff --git a/web/app.py b/web/app.py index 607feae..9adad93 100644 --- a/web/app.py +++ b/web/app.py @@ -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 ]) diff --git a/web/figures.py b/web/figures.py index 33d6c7a..2806018 100644 --- a/web/figures.py +++ b/web/figures.py @@ -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)))