diff --git a/.gitignore b/.gitignore index 7b2f69c..58906f4 100644 --- a/.gitignore +++ b/.gitignore @@ -140,6 +140,7 @@ cython_debug/ .idea/ backup.ab *.apk +*.ini info.db otp.bin charge_config1.json diff --git a/.prospector.yaml b/.prospector.yaml index 3a91988..434c109 100644 --- a/.prospector.yaml +++ b/.prospector.yaml @@ -1,3 +1,7 @@ doc-warnings: false +pylint: + disable: + - C0114 + - C0111 ignore-paths: - psa_connectedcar diff --git a/MyPSACC.py b/MyPSACC.py index f429672..d5debad 100644 --- a/MyPSACC.py +++ b/MyPSACC.py @@ -12,11 +12,11 @@ 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 Tuple import psa_connectedcar as psac from Car import Cars, Car from ecomix import Ecomix +from libs.charging import Charging from otp.Otp import load_otp, new_otp_session, save_otp, ConfigException, Otp from psa_connectedcar import ApiClient from psa_connectedcar.rest import ApiException @@ -573,9 +573,8 @@ class MyPSACC: co2_per_kw = Ecomix.get_co2_per_kw(start_at, charge_date, latitude, longitude, from_cache=self.co2_signal_api is not None) kw = (level - start_level) / 100 * self.vehicles_list.get_car_by_vin(vin).battery_power - conn.execute( - "UPDATE battery set stop_at=?, end_level=?, co2=?, kw=? WHERE start_at=? and VIN=?", - (charge_date, level, co2_per_kw, kw, start_at, vin)) + + Charging.update_chargings(conn, start_at, charge_date, level, co2_per_kw, kw, vin) conn.commit() except TypeError: logger.debug("battery table is empty") @@ -598,20 +597,6 @@ class MyPSACC: conn.close() return geo_dumps(feature_collection, sort_keys=True) - @staticmethod - def get_chargings(mini=None, maxi=None) -> Tuple[dict]: - conn = get_db() - if mini is not None: - if maxi is not None: - res = conn.execute("select * from battery WHERE start_at>=? and start_at<=?", (mini, maxi)).fetchall() - else: - res = conn.execute("select * from battery WHERE start_at>=?", (mini,)).fetchall() - elif maxi is not None: - res = conn.execute("select * from battery WHERE start_at<=?", (maxi,)).fetchall() - else: - res = conn.execute("select * from battery").fetchall() - return tuple(map(dict, res)) - def __iter__(self): for key, value in self.__dict__.items(): yield key, value diff --git a/libs/charging.py b/libs/charging.py new file mode 100644 index 0000000..86a01c6 --- /dev/null +++ b/libs/charging.py @@ -0,0 +1,39 @@ +from libs.elec_price import ElecPrice +from web.db import get_db, set_chargings_price, clean_battery + +elec_price = ElecPrice.read_config() + + +class Charging: + @staticmethod + def get_chargings(mini=None, maxi=None) -> list[dict]: + conn = get_db() + if mini is not None: + if maxi is not None: + res = conn.execute("select * from battery WHERE start_at>=? and start_at<=?", (mini, maxi)).fetchall() + else: + res = conn.execute("select * from battery WHERE start_at>=?", (mini,)).fetchall() + elif maxi is not None: + res = conn.execute("select * from battery WHERE start_at<=?", (maxi,)).fetchall() + else: + res = conn.execute("select * from battery").fetchall() + conn.close() + return list(map(dict, res)) + + @staticmethod + def set_default_price(): + if elec_price.is_enable(): + conn = get_db() + charge_list = list(map(dict, conn.execute("SELECT * FROM battery WHERE price IS NULL").fetchall())) + for el in charge_list: + el["price"] = elec_price.get_price(el["start_at"], el["stop_at"], el["kw"]) + set_chargings_price(conn, el["start_at"], el["price"]) + conn.close() + + @staticmethod + def update_chargings(conn, start_at, stop_at, level, co2_per_kw, kw, vin): + price = elec_price.get_price(start_at, stop_at, kw) + conn.execute( + "UPDATE battery set stop_at=?, end_level=?, co2=?, kw=?, price=? WHERE start_at=? and VIN=?", + (stop_at, level, co2_per_kw, kw, price, start_at, vin)) + clean_battery(conn) diff --git a/libs/elec_price.py b/libs/elec_price.py new file mode 100644 index 0000000..8ae7635 --- /dev/null +++ b/libs/elec_price.py @@ -0,0 +1,94 @@ +from datetime import datetime, timezone, timedelta +import configparser +from statistics import mean + +CONFIG_FILENAME = "config.ini" + + +def set_number(value): + try: + return float(value) + except ValueError: + return None + + +def utc_to_local(utc_dt): + return utc_dt.replace(tzinfo=timezone.utc).astimezone(tz=None) + + +class ElecPrice: + currency = "" + + def __init__(self, day_price, night_price=None, nights_hours=None): + self.day_price = set_number(day_price) + self.night_price = set_number(night_price) + self.nights_hour = None + self.set_night_hour(nights_hours) + self.config_filename = CONFIG_FILENAME + + def set_night_hour(self, value): + if value is not None and isinstance(value, list): + self.nights_hour = [] + for hours in value: + self.nights_hour.append(list(map(int, hours))) + + def compare_hour(self, date: datetime, hour, minute): + if date.hour < hour: + return False + if date.hour == hour and date.minute < minute: + return False + return True + + def get_instant_price(self, date): + local_date = utc_to_local(date) + if self.night_price is None: + return self.day_price + if self.compare_hour(local_date, self.nights_hour[0][0], self.nights_hour[0][1]) or \ + not self.compare_hour(local_date, self.nights_hour[1][0], self.nights_hour[1][1]): + return self.night_price + return self.day_price + + def get_price(self, start, end, consumption): + prices = [] + date = start + while date < end: + prices.append(self.get_instant_price(date)) + date = date + timedelta(minutes=30) + return round(consumption * mean(prices), 2) + + def is_enable(self): + return self.day_price is not None + + @staticmethod + def read_config(name=CONFIG_FILENAME): + config = configparser.ConfigParser() + if len(config.read(name)) == 0: + ElecPrice.write_default_config(name) + config.read(name) + elec_config = config["Electricity config"] + if len(elec_config["night price"]) > 0: + night_hours = [] + night_price = elec_config["night price"] + for hour in [elec_config["night hour start"], elec_config["night hour end"]]: + night_hours.append(hour.split("h")) + else: + night_hours = None + night_price = None + ElecPrice.currency = config["General"]["currency"] + return ElecPrice(elec_config["day price"], night_price, night_hours) + + @staticmethod + def write_default_config(name=CONFIG_FILENAME): + config = configparser.ConfigParser() + config["General"] = { + "currency": "€" + } + config["Electricity config"] = { + "day price": "", + "night price": "", + "night hour start": "", + "night hour end": "" + } + + with open(name, "w") as f: + config.write(f) diff --git a/web/app.py b/web/app.py index 2a46939..d92d69f 100644 --- a/web/app.py +++ b/web/app.py @@ -6,6 +6,7 @@ from flask import Flask import locale from werkzeug import run_simple + try: from werkzeug.middleware.dispatcher import DispatcherMiddleware except ImportError: @@ -18,6 +19,10 @@ from MyPSACC import MyPSACC app = None dash_app = None dispatcher = None +# noinspection PyTypeChecker +myp:MyPSACC = None +# noinspection PyTypeChecker +chc: ChargeControls = None def start_app(title, base_path, debug: bool, host, port): global app, dash_app, dispatcher @@ -42,13 +47,6 @@ def start_app(title, base_path, debug: bool, host, port): import web.views return run_simple(host, port, application, use_reloader=False, use_debugger=debug) - -# noinspection PyTypeChecker -myp:MyPSACC = None -# noinspection PyTypeChecker -chc: ChargeControls = None - - def save_config(my_peugeot: MyPSACC, name): my_peugeot.save_config(name) threading.Timer(30, save_config, args=[my_peugeot, name]).start() diff --git a/web/db.py b/web/db.py index a8cc21d..bd09474 100644 --- a/web/db.py +++ b/web/db.py @@ -8,24 +8,40 @@ from MyLogger import logger callback_fct: Callable[[], None] = lambda: None default_db_file = 'info.db' +db_initialized = False -def convert_datetime(st): - return datetime.strptime(st.decode("utf-8"), "%Y-%m-%d %H:%M:%S+00:00").replace(tzinfo=pytz.UTC) +def convert_datetime_from_bytes(bytes_string): + return datetime.strptime(bytes_string.decode("utf-8"), "%Y-%m-%d %H:%M:%S+00:00").replace(tzinfo=pytz.UTC) + + +def convert_datetime_from_string(st): + return datetime.strptime(st, "%Y-%m-%dT%H:%M:%S+00:00").replace(tzinfo=pytz.UTC) def update_callback(): callback_fct() -def get_db(db_file=default_db_file): - sqlite3.register_converter("DATETIME", convert_datetime) - conn = sqlite3.connect(db_file, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES) - conn.row_factory = sqlite3.Row +def set_db_callback(callbackfct): + global callback_fct + callback_fct = callbackfct + + +def backup(conn): + back_conn = sqlite3.connect("info_backup.db") + conn.backup(back_conn) + back_conn.close() + + +def init_db(conn): + global db_initialized conn.execute("CREATE TABLE IF NOT EXISTS position (Timestamp DATETIME PRIMARY KEY, VIN TEXT, longitude REAL, " "latitude REAL, mileage REAL, level INTEGER, level_fuel INTEGER, moving BOOLEAN, temperature INTEGER);") + make_backup = False try: conn.execute("ALTER TABLE position ADD level_fuel INTEGER;") + make_backup = True except sqlite3.OperationalError: pass conn.execute("CREATE TABLE IF NOT EXISTS battery (start_at DATETIME PRIMARY KEY,stop_at DATETIME,VIN TEXT, " @@ -33,10 +49,34 @@ def get_db(db_file=default_db_file): conn.create_function("update_trips", 0, update_callback) conn.execute("CREATE TEMP TRIGGER IF NOT EXISTS update_trigger AFTER INSERT ON position BEGIN " "SELECT update_trips(); END;") + try: + conn.execute("ALTER TABLE battery ADD price INTEGER;") + make_backup = True + except sqlite3.OperationalError: + pass + if make_backup: + backup(conn) + clean_battery(conn) conn.commit() + db_initialized = True + + +def get_db(db_file=default_db_file): + sqlite3.register_converter("DATETIME", convert_datetime_from_bytes) + conn = sqlite3.connect(db_file, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES) + conn.row_factory = sqlite3.Row + if not db_initialized: + init_db(conn) return conn +def clean_battery(conn): + # delete charging longer than 17h + conn.execute("DElETE FROM battery WHERE JULIANDAY(stop_at)-JULIANDAY(start_at)>0.7;") + conn.execute("DELETE FROM battery WHERE start_level==end_level;") + conn.commit() + + def clean_position(conn): res = conn.execute( "SELECT Timestamp,mileage,level from position ORDER BY Timestamp DESC LIMIT 3;").fetchall() @@ -55,3 +95,13 @@ def get_last_temp(vin): if res is None: return None return res[0] + + +def set_chargings_price(conn, start_at, price): + if isinstance(start_at, str): + start_at = convert_datetime_from_string(start_at) + update = conn.execute("UPDATE battery SET price=? WHERE start_at=?", (price, start_at)).rowcount == 1 + conn.commit() + if not update: + logger.error("Can't find line to update in the database") + return update \ No newline at end of file diff --git a/web/figures.py b/web/figures.py index 485b626..1315404 100644 --- a/web/figures.py +++ b/web/figures.py @@ -1,5 +1,4 @@ from copy import deepcopy -from typing import Tuple import dash_bootstrap_components as dbc import dash_table @@ -14,6 +13,8 @@ from Trip import Trips from pandas import options as pandas_options import dash_html_components as html +from libs.elec_price import ElecPrice + def unix_time_millis(dt): return int(dt.timestamp()) @@ -55,7 +56,7 @@ battery_info = dbc.Alert("No data to show", color="danger") battery_table = None -def get_figures(trips: Trips, charging: Tuple[dict]): +def get_figures(trips: Trips, charging: list[dict]): global consumption_fig, consumption_df, trips_map, consumption_fig_by_speed, table_fig, info, battery_info, \ battery_table, consumption_graph_by_temp lats = [] @@ -116,8 +117,10 @@ def get_figures(trips: Trips, charging: Tuple[dict]): try: charge_speed = 3600 * charging_data["kw"].mean() / \ (charging_data["stop_at"] - charging_data["start_at"]).mean().total_seconds() + price_kw = (charging_data["price"] / charging_data["kw"]).mean() except (TypeError, KeyError): # when there is no data yet: charge_speed = 0 + price_kw = 0 battery_info = dash_table.DataTable( id='battery_info', @@ -145,7 +148,18 @@ def get_figures(trips: Trips, charging: Tuple[dict]): html.Td("Average charge speed:"), html.Td("{:.3f} kW".format(charge_speed)) ] - ) + ), + html.Tr( + [ + html.Td('Average Price:', rowSpan=2), + html.Td("{:.2f} {}/100km".format(price_kw*kw_per_km, ElecPrice.currency)), + ] + ), + html.Tr( + [ + "{:.2f} {}/kW".format(price_kw, ElecPrice.currency), + ] + ), ]) battery_table = dash_table.DataTable( @@ -159,8 +173,11 @@ def get_figures(trips: Trips, charging: Tuple[dict]): {'id': 'co2', 'name': 'CO2', 'type': 'numeric', 'format': deepcopy(nb_format).symbol_suffix(" g/kWh").precision(1)}, {'id': 'kw', 'name': 'consumption', 'type': 'numeric', - 'format': deepcopy(nb_format).symbol_suffix(" kWh").precision(3)}], + 'format': deepcopy(nb_format).symbol_suffix(" kWh").precision(2)}, + {'id': 'price', 'name': 'price', 'type': 'numeric', + 'format': deepcopy(nb_format).symbol_suffix(" "+ElecPrice.currency).precision(2)}], data=charging, + editable=True ) consumption_by_temp_df = consumption_df[consumption_df["consumption_by_temp"].notnull()] if len(consumption_by_temp_df) > 0: diff --git a/web/views.py b/web/views.py index 7d45ed7..3447090 100644 --- a/web/views.py +++ b/web/views.py @@ -2,33 +2,57 @@ import json import traceback from datetime import datetime, timezone import dash_bootstrap_components as dbc -from dash.dependencies import Output, Input, MATCH +from dash.dependencies import Output, Input, MATCH, State import dash_core_components as dcc import dash_html_components as html import dash_daq as daq +import pandas as pd +from dash.exceptions import PreventUpdate from MyLogger import logger from flask import jsonify, request, Response as FlaskResponse -from MyPSACC import MyPSACC from Trip import Trips + +from libs.charging import Charging from web import figures from web.app import app, dash_app, myp, chc -import web.db +from web.db import set_chargings_price, get_db, set_db_callback RESPONSE = "-response" - EMPTY_DIV = "empty-div" - ABRP_SWITCH = 'abrp-switch' ERROR_DIV = dbc.Alert("No data to show, there is probably no trips recorded yet", color="danger") trips: Trips -chargings: dict +chargings: list[dict] min_date = max_date = min_millis = max_millis = step = marks = cached_layout = None +def diff_dashtable(data, data_previous, row_id_name="row_id"): + df, df_previous = pd.DataFrame(data=data), pd.DataFrame(data_previous) + for _df in [df, df_previous]: + assert row_id_name in _df.columns + _df = _df.set_index(row_id_name) + mask = df.ne(df_previous) + df_diff = df[mask].dropna(how="all", axis="columns").dropna(how="all", axis="rows") + changes = [] + for idx, row in df_diff.iterrows(): + row_id = row.name + row.dropna(inplace=True) + for change in row.iteritems(): + changes.append( + { + row_id_name: data[row.name][row_id_name], + "column_name": change[0], + "current_value": change[1], + "previous_value": df_previous.at[row_id, change[0]], + } + ) + return changes + + @dash_app.callback(Output('trips_map', 'figure'), Output('consumption_fig', 'figure'), Output('consumption_fig_by_speed', 'figure'), @@ -48,7 +72,7 @@ def display_value(value): for trip in trips: if mini <= trip.start_at <= maxi: filtered_trips.append(trip) - filtered_chargings = MyPSACC.get_chargings(mini, maxi) + filtered_chargings = Charging.get_chargings(mini, maxi) figures.get_figures(filtered_trips, filtered_chargings) consumption = "Average consumption: {:.1f} kWh/100km".format(float(figures.consumption_df["consumption_km"].mean())) return figures.trips_map, figures.consumption_fig, figures.consumption_fig_by_speed, \ @@ -56,6 +80,25 @@ def display_value(value): figures.battery_table, max_millis, step, marks +@dash_app.callback( + Output(EMPTY_DIV, "children"), + [Input("battery-table", "data_timestamp")], + [ + State("battery-table", "data"), + State("battery-table", "data_previous"), + ], +) +def capture_diffs(ts, data, data_previous): + if ts is None: + raise PreventUpdate + diff_data = diff_dashtable(data, data_previous, "start_at") + for el in diff_data: + if el['column_name'] == 'price': + if not set_chargings_price(get_db(), el['start_at'], el['current_value']): + logger.error("Can't find line to update in the database") + return "" + + @dash_app.callback(Output({'role': ABRP_SWITCH + RESPONSE, 'vin': MATCH}, 'children'), Input({'role': ABRP_SWITCH, 'vin': MATCH}, 'id'), Input({'role': ABRP_SWITCH, 'vin': MATCH}, 'value')) @@ -176,7 +219,7 @@ def update_trips(): trips_by_vin = Trips.get_trips(myp.vehicles_list) trips = next(iter(trips_by_vin.values())) # todo handle multiple car assert len(trips) > 0 - chargings = MyPSACC.get_chargings() + chargings = Charging.get_chargings() except (StopIteration, AssertionError): logger.debug("No trips yet") return @@ -258,7 +301,8 @@ def serve_layout(): try: - web.db.callback_fct = update_trips + set_db_callback(update_trips) + Charging.set_default_price() update_trips() except (IndexError, TypeError): logger.debug("Failed to get trips, there is probably not enough data yet %s", traceback.format_exc())