mirror of
https://github.com/flobz/psa_car_controller.git
synced 2026-08-22 01:16:14 +00:00
add charge price
This commit is contained in:
@@ -140,6 +140,7 @@ cython_debug/
|
||||
.idea/
|
||||
backup.ab
|
||||
*.apk
|
||||
*.ini
|
||||
info.db
|
||||
otp.bin
|
||||
charge_config1.json
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
doc-warnings: false
|
||||
pylint:
|
||||
disable:
|
||||
- C0114
|
||||
- C0111
|
||||
ignore-paths:
|
||||
- psa_connectedcar
|
||||
|
||||
+3
-18
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+5
-7
@@ -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()
|
||||
|
||||
@@ -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
|
||||
+21
-4
@@ -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:
|
||||
|
||||
+53
-9
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user