From cd58a9c55a349386acbb2313b0fa30dc1aba1ee5 Mon Sep 17 00:00:00 2001 From: Florian Bezannier Date: Fri, 18 Dec 2020 16:58:51 +0100 Subject: [PATCH] add dashboard --- ChargeControl.py | 5 +- MyPSACC.py | 47 ++++++++++++++- Trips.py | 25 ++++++++ requirements.txt | 21 +++++-- server.py | 105 +++++---------------------------- web/app.py | 150 +++++++++++++++++++++++++++++++++++++++++++++++ web/figures.py | 80 +++++++++++++++++++++++++ 7 files changed, 334 insertions(+), 99 deletions(-) create mode 100644 Trips.py create mode 100644 web/app.py create mode 100644 web/figures.py diff --git a/ChargeControl.py b/ChargeControl.py index 186a169..6686491 100644 --- a/ChargeControl.py +++ b/ChargeControl.py @@ -9,6 +9,7 @@ from MyPSACC import MyPSACC from MyLogger import logger from psa_connectedcar.rest import ApiException + class ChargeControls: def __init__(self): @@ -121,4 +122,6 @@ class ChargeControl: chd = copy(self.__dict__) chd.pop("psacc") chd.pop("thread") - return chd \ No newline at end of file + return chd + + diff --git a/MyPSACC.py b/MyPSACC.py index cabdd98..180f74f 100644 --- a/MyPSACC.py +++ b/MyPSACC.py @@ -1,5 +1,6 @@ import json import re +import sqlite3 import traceback import uuid from copy import copy @@ -12,13 +13,17 @@ from time import sleep from oauth2_client.credentials_manager import CredentialManager, ServiceInformation import paho.mqtt.client as mqtt from requests import Response +from typing import List + import psa_connectedcar as psac +from Trips import Trips from psa_connectedcar import ApiClient from psa_connectedcar.rest import ApiException from MyLogger import logger from threading import Semaphore, Timer from functools import wraps +from web.figures import convert_datetime oauhth_url = {"clientsB2CPeugeot":"https://idpcvs.peugeot.com/am/oauth2/access_token", "clientsB2CCitroen":"https://idpcvs.citroen.com/am/oauth2/access_token", @@ -419,7 +424,8 @@ class MyPSACC: finally: conn.close() - def get_recorded_position(self): + @staticmethod + def get_recorded_position(): import sqlite3 from geojson import Feature, Point, FeatureCollection from geojson import dumps as geo_dumps @@ -435,6 +441,41 @@ class MyPSACC: feature_collection = FeatureCollection(features_list) return geo_dumps(feature_collection, sort_keys=True) + @staticmethod + def get_trips() -> List[Trips]: + sqlite3.register_converter("DATETIME", convert_datetime) + conn = sqlite3.connect('info.db', detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES) + conn.row_factory = sqlite3.Row + res = conn.execute('SELECT * FROM position ORDER BY Timestamp').fetchall() + start = res[0] + end = res[1] + trips = [] + tr = Trips() + battery_power = 46 + for next_el in res[2:]: + distance = next_el["mileage"] - end["mileage"] # km + if distance == 0: + 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 + tr = Trips() + else: + tr.add_points(end["longitude"], end["latitude"]) + end = next_el + return trips + + class MyPeugeotEncoder(JSONEncoder): def default(self, mp: MyPSACC): data = copy(mp.__dict__) @@ -442,6 +483,8 @@ class MyPeugeotEncoder(JSONEncoder): mpd["proxies"] = data["_proxies"] mpd["refresh_token"] = mp.manager.refresh_token mpd["client_secret"] = mp.service_information.client_secret - for el in ["client_id", "realm", "remote_refresh_token","customer_id"]: + for el in ["client_id", "realm", "remote_refresh_token", "customer_id"]: mpd[el] = data[el] return mpd + + diff --git a/Trips.py b/Trips.py new file mode 100644 index 0000000..0672e00 --- /dev/null +++ b/Trips.py @@ -0,0 +1,25 @@ +from typing import List +class Points(): + def __init__(self, latitude, longitude): + self.latitude = latitude + self.longitude = longitude + +class Trips: + def __init__(self): + self.start_at = None + self.end_at = None + self.positions: List[Points] = [] + self.speed_average = None + self.consumption = None + self.consumption_km = None + + def add_points(self, longitude, latitude): + self.positions.append(Points(longitude, latitude)) + + def get_consumption(self): + return { + 'date': self.start_at, + 'consumption': self.consumption_km, + } + + diff --git a/requirements.txt b/requirements.txt index abfb865..63f8728 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,19 @@ oauth2_client -Flask -certifi -urllib3 -six -requests +Flask>=1.1.1 +certifi>=2018.4.16 +urllib3>=1.25.10 +six>=1.15.0 +requests>=2.24.0 paho_mqtt python_dateutil + +paho-mqtt>=1.5.0 +dash>=1.18.1 +numpy>=1.19.4 +pytz>=2019.3 +plotly>=4.5.0 +typing>=3.6.6 +python-dateutil>=2.8.0 +pandas>=1.1.3 +argparse>=1.4.0 +androguard>=3.3.5 \ No newline at end of file diff --git a/server.py b/server.py index 3e5d9a6..a7b5874 100755 --- a/server.py +++ b/server.py @@ -1,91 +1,15 @@ #!/usr/bin/env python3 import sys -import threading from threading import Thread - from oauth2_client.credentials_manager import OAuthError - from ChargeControl import ChargeControls from MyLogger import my_logger -from MyPSACC import * -from flask import Flask, request, jsonify -from flask import Response as FlaskResponse import argparse from MyLogger import logger +from MyPSACC import MyPSACC +from web.app import app, save_config parser = argparse.ArgumentParser() -app = Flask(__name__) - - -@app.route('/getvehicles') -def getvehicules(): - return jsonify(myp.getVIN()) - - -@app.route('/get_vehicleinfo/') -def get_vehicle_Info(vin): - response = app.response_class( - response=json.dumps(myp.get_vehicle_info(vin).to_dict(), default=str), - status=200, - mimetype='application/json' - ) - return response - - -@app.route('/charge_now//') -def charge_now(vin, charge): - return jsonify(myp.charge_now(vin, charge != 0)) - - -@app.route('/charge_hour') -def change_charge_hour(): - return jsonify(myp.change_charge_hour(request.form['vin'], request.form['hour'], request.form['minute'])) - - -@app.route('/wakeup/') -def wakeup(vin): - return jsonify(myp.wakeup(vin)) - - -@app.route('/preconditioning//') -def preconditioning(vin, activate): - return jsonify(myp.preconditioning(vin, activate)) - -@app.route('/position/') -def get_position(vin): - res = myp.get_vehicle_info(vin) - longitude, latitude = res.last_position.geometry.coordinates - return jsonify({"longitude":longitude,"latitude":latitude,"url":f"http://maps.google.com/maps?q={latitude},{longitude}"}) - -def save_config(mypeugeot: MyPSACC): - myp.save_config() - threading.Timer(30, save_config, args=[mypeugeot]).start() - - -# Set a battery threshold and schedule an hour to stop the charge -@app.route('/charge_control') -def charge_control(): - logger.info(request) - vin = request.args['vin'] - charge_control = chc.get(vin) - if charge_control is None: - return jsonify("error: VIN not in list") - if 'hour' in request.args or 'minute' in request.args: - charge_control.set_stop_hour([int(request.args["hour"]), int(request.args["minute"])]) - if 'percentage' in request.args: - charge_control.percentage_threshold = int(request.args['percentage']) - chc.save_config() - return jsonify(charge_control.get_dict()) - -@app.route('/positions') -def get_recorded_position(): - return FlaskResponse(myp.get_recorded_position(), mimetype='application/json') - -@app.after_request -def after_request(response): - header = response.headers - header['Access-Control-Allow-Origin'] = '*' - return response def parse_args(): parser = argparse.ArgumentParser() @@ -110,30 +34,29 @@ if __name__ == "__main__": my_logger(handler_level=args.debug) logger.info("server start") if args.config: - myp = MyPSACC.load_config(name=args.config.name) + app.myp = MyPSACC.load_config(name=args.config.name) else: - myp = MyPSACC.load_config() + app.myp = MyPSACC.load_config() if args.record_position: - myp.set_record(True) + app.myp.set_record(True) try: - myp.manager._refresh_token() + app.myp.manager._refresh_token() except OAuthError: if args.mail and args.password: client_email = args.mail - client_paswword = args.password + client_password = args.password else: client_email = input("mypeugeot email: ") - client_paswword = input("mypeugeot password: ") - myp.connect(client_email, client_paswword) - logger.info(myp.get_vehicles()) + 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.start() if args.remote_disable: logger.info("mqtt disabled") else: - myp.start_mqtt() + app.myp.start_mqtt() if args.charge_control: - chc = ChargeControls.load_config(myp, name=args.charge_control) - chc.start() - save_config(myp) - + app.chc = ChargeControls.load_config(app.myp, name=args.charge_control) + app.chc.start() + save_config(app.myp) \ No newline at end of file diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..2c2b5c2 --- /dev/null +++ b/web/app.py @@ -0,0 +1,150 @@ +import json +import threading +from datetime import datetime, timezone +import dash +import dash_bootstrap_components as dbc +from dash.dependencies import Output, Input +import dash_core_components as dcc +import dash_html_components as html + +from MyLogger import logger +from flask import jsonify, request, Response as FlaskResponse + +from web import figures + +from MyPSACC import MyPSACC + +dash_app = dash.Dash(external_stylesheets=[dbc.themes.BOOTSTRAP]) +app = dash_app.server +myp = None +chc = None + + +@dash_app.callback(Output('trips_map', 'figure'), + Output('consumption_fig', 'figure'), + Output('consumption_fig_by_speed', 'figure'), + Output('consumption', 'children'), + Input('date-slider', 'value')) +def display_value(value): + min = datetime.fromtimestamp(value[0], tz=timezone.utc) + max = datetime.fromtimestamp(value[1], tz=timezone.utc) + filtered_trips = [] + 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 + + +@app.route('/getvehicles') +def getvehicules(): + return jsonify(myp.getVIN()) + + +@app.route('/get_vehicleinfo/') +def get_vehicle_Info(vin): + response = app.response_class( + response=json.dumps(myp.get_vehicle_info(vin).to_dict(), default=str), + status=200, + mimetype='application/json' + ) + return response + + +@app.route('/charge_now//') +def charge_now(vin, charge): + return jsonify(myp.charge_now(vin, charge != 0)) + + +@app.route('/charge_hour') +def change_charge_hour(): + return jsonify(myp.change_charge_hour(request.form['vin'], request.form['hour'], request.form['minute'])) + + +@app.route('/wakeup/') +def wakeup(vin): + return jsonify(myp.wakeup(vin)) + + +@app.route('/preconditioning//') +def preconditioning(vin, activate): + return jsonify(myp.preconditioning(vin, activate)) + + +@app.route('/position/') +def get_position(vin): + res = myp.get_vehicle_info(vin) + longitude, latitude = res.last_position.geometry.coordinates + return jsonify( + {"longitude": longitude, "latitude": latitude, "url": f"http://maps.google.com/maps?q={latitude},{longitude}"}) + + +# Set a battery threshold and schedule an hour to stop the charge +@app.route('/charge_control') +def charge_control(): + logger.info(request) + vin = request.args['vin'] + charge_control = chc.get(vin) + if charge_control is None: + return jsonify("error: VIN not in list") + if 'hour' in request.args or 'minute' in request.args: + charge_control.set_stop_hour([int(request.args["hour"]), int(request.args["minute"])]) + if 'percentage' in request.args: + charge_control.percentage_threshold = int(request.args['percentage']) + save_config() + return jsonify(charge_control.get_dict()) + + +@app.route('/positions') +def get_recorded_position(): + return FlaskResponse(myp.get_recorded_position(), mimetype='application/json') + + +@app.after_request +def after_request(response): + header = response.headers + header['Access-Control-Allow-Origin'] = '*' + return response + + +def save_config(my_peugeot: MyPSACC): + my_peugeot.save_config() + threading.Timer(30, save_config, args=[my_peugeot]).start() + + +trips = MyPSACC.get_trips() +figures.get_figures(trips) +dash_app.layout = dbc.Container([ + html.H1('My car info'), + html.Hr(), + html.Div([ + 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(id='updatemode-output-container', style={'margin-top': 20}) + ], + style={'margin-left': 20} + ), + html.Div([ + html.Div([ + dcc.Graph(figure=figures.trips_map, id="trips_map") + ]), + html.Div([ + dcc.Graph(figure=figures.consumption_fig, id="consumption_fig") + ]), + html.H2(id="consumption", + children="Average consumption: {:.1f} kW/100km".format(float(figures.consumption_df.mean()))), + html.Div([ + dcc.Graph(figure=figures.consumption_fig_by_speed, id="consumption_fig_by_speed") + ]), + ], id="data-div"), +]) diff --git a/web/figures.py b/web/figures.py new file mode 100644 index 0000000..ae4045e --- /dev/null +++ b/web/figures.py @@ -0,0 +1,80 @@ +from datetime import datetime +from typing import List +import numpy as np +import pytz +from dateutil.relativedelta import relativedelta +from pandas import DataFrame +import plotly.express as px +import plotly.graph_objects as go +from Trips import Trips + + +def unix_time_millis(dt): + return int(dt.timestamp()) + + +def get_marks_from_start_end(start, end): + nb_marks = 5 + result = [] + time_delta = int((end - start).total_seconds() / nb_marks) + current = start + if time_delta > 0: + while current <= end: + result.append(current) + current += relativedelta(seconds=time_delta) + result[-1] = end + if time_delta < 3600 * 24: + if time_delta > 3600: + date_f = '%y-%m-%d %Hh' + else: + date_f = '%y-%m-%d %Hh%M' + else: + date_f = '%Y-%m' + marks = {} + for date in result: + marks[unix_time_millis(date)] = str(date.strftime(date_f)) + return marks + + +def convert_datetime(st): + return datetime.strptime(st.decode("utf-8"), "%Y-%m-%d %H:%M:%S+00:00").replace(tzinfo=pytz.UTC) + + +consumption_fig = None +consumption_df = None +trips_map = None +consumption_fig_by_speed = None + + +def get_figures(trips: List[Trips]): + global consumption_fig, consumption_df, trips_map, consumption_fig_by_speed + lats = [] + lons = [] + names = [] + for trip in trips: + for points in trip.positions: + lats = np.append(lats, points.longitude) + lons = np.append(lons, points.latitude) + names = np.append(names, [str(trip.start_at)]) + 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) + 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') + + 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", + 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.add_trace( + go.Scatter(mode="markers", x=consum_df_by_speed["speed"], y=consum_df_by_speed["value"], + name="Trips")) + consumption_fig_by_speed.update_layout(xaxis_title="average Speed km/h", yaxis_title="Consumption kW/100Km") + + +