webui to connect to PSA API

This commit is contained in:
Florian Bezannier
2021-05-30 17:42:55 +02:00
parent 1b96a0b5cc
commit 47b8af3ee0
12 changed files with 509 additions and 284 deletions
+6 -17
View File
@@ -1,4 +1,3 @@
import threading
import locale
import dash
@@ -13,18 +12,13 @@ try:
except ImportError:
from werkzeug import DispatcherMiddleware
from charge_control import ChargeControls
from mylogger import logger
from my_psacc import MyPSACC
import importlib
# pylint: disable=invalid-name
app = None
dash_app = None
dispatcher = None
# noinspection PyTypeChecker
myp: MyPSACC = None
# noinspection PyTypeChecker
chc: ChargeControls = None
def start_app(*args, **kwargs):
@@ -32,7 +26,7 @@ def start_app(*args, **kwargs):
def config_flask(title, base_path, debug: bool, host, port, reloader=False, # pylint: disable=too-many-arguments
unminified=False):
unminified=False, view="web.views"):
global app, dash_app, dispatcher
reload_view = app is not None
app = Flask(__name__)
@@ -54,20 +48,15 @@ def config_flask(title, base_path, debug: bool, host, port, reloader=False, # p
application = DispatcherMiddleware(Flask('dummy_app'), {base_path: app})
requests_pathname_prefix = base_path + "/"
dash_app = dash.Dash(external_stylesheets=[dbc.themes.BOOTSTRAP], external_scripts=locale_url, title=title,
server=app, requests_pathname_prefix=requests_pathname_prefix)
server=app, requests_pathname_prefix=requests_pathname_prefix,
suppress_callback_exceptions=True)
dash_app.enable_dev_tools(reloader)
# keep this line
import web.views # pylint: disable=import-outside-toplevel
importlib.import_module(view)
if reload_view:
import importlib # pylint: disable=import-outside-toplevel
importlib.reload(web.views)
importlib.reload(view)
return {"hostname": host, "port": port, "application": application, "use_reloader": reloader, "use_debugger": debug}
def run(config):
return run_simple(**config)
def save_config(my_peugeot: MyPSACC, name):
my_peugeot.save_config(name)
threading.Timer(30, save_config, args=[my_peugeot, name]).start()
+183
View File
@@ -0,0 +1,183 @@
from dash import callback_context
from dash.exceptions import PreventUpdate
from flask import request
from app_decoder import firstLaunchConfig
from libs.config import Config
from mylogger import LOG_FILE
from otp.otp import new_otp_session
from web.app import dash_app
import dash_bootstrap_components as dbc
from dash.dependencies import Output, Input, State
import dash_core_components as dcc
import dash_html_components as html
config = Config()
config_layout = dbc.Row(dbc.Col(className="col-md-12 col-lg-2 ml-2", children=[
html.H2('Config'),
dbc.Form([
dbc.FormGroup([
dbc.Label("Car Brand", html_for="psa-app"),
dcc.Dropdown(
id="psa-app",
options=[
{"label": "Peugeot", "value": "com.psa.mym.mypeugeot"},
{"label": "Opel", "value": "com.psa.mym.myopel"},
{"label": "Cirtroën", "value": "com.psa.mym.citroen"},
{"label": "DS", "value": "com.psa.mym.myds"},
{"label": "Vauxhall", "value": "com.psa.mym.myvauxhll"}
],
)]),
dbc.FormGroup(
[
dbc.Label("Email", html_for="psa-email"),
dbc.Input(type="email", id="psa-email", placeholder="Enter email"),
dbc.FormText(
"PSA account email",
color="secondary",
),
]
),
dbc.FormGroup(
[
dbc.Label("Password", html_for="psa-password"),
dbc.Input(
type="password",
id="psa-password",
placeholder="Enter password",
),
dbc.FormText(
"PSA account password",
color="secondary",
),
]
),
dbc.FormGroup(
[
dbc.Label("Country code", html_for="countrycode"),
dbc.Input(
type="text",
id="psa-countrycode",
placeholder="Enter your country code",
),
dbc.FormText(
"Example: FR for FRANCE or EN for England",
color="secondary",
)
]
),
dbc.FormGroup([
dbc.Button("Submit", color="primary", id="submit-form"),
dbc.FormText(
"After submit be patient it can take some time...",
color="secondary",
),
dcc.Loading(
id="loading-2",
children=[html.Div([html.Div(id="form_result")])],
type="circle",
)]
),
])]))
config_otp_layout = dbc.Row(dbc.Col(className="col-md-12 col-lg-2 ml-2", children=[
html.H2('Config OTP'),
dbc.Form([
dbc.FormGroup([
dbc.Label("Click to receive a code by SMS", html_for="ask-sms"),
dbc.Button("Send SMS", color="info", id="ask-sms"),
html.Div(id="sms-demand-result", className="mt-2")
]),
dbc.FormGroup(
[
dbc.Label("Write the code you just received by SMS", html_for="psa-email"),
dbc.Input(type="text", id="psa-code", placeholder="Enter code"),
]
),
dbc.FormGroup(
[
dbc.Label("Enter your code PIN", html_for="psa-pin"),
dbc.Input(
type="password",
id="psa-pin",
placeholder="Enter codepin",
),
dbc.FormText(
"It's a digit password",
color="secondary",
),
]
),
dbc.Button("Submit", color="primary", id="finish-otp"),
html.Div(id="opt-result")
])]))
def log_layout():
with open(LOG_FILE, "r") as f:
log_text = f.read()
return html.H3(children=["Log:", dbc.Textarea(
valid=True,
bs_size="sm",
className="mt-3",
style={"height": "80vh"},
placeholder="Log",
contentEditable=False,
value=log_text
)])
@dash_app.callback(
Output("form_result", "children"),
Input("submit-form", "n_clicks"),
State("psa-app", "value"),
State("psa-email", "value"),
State("psa-password", "value"),
State("psa-countrycode", "value"))
def connectPSA(n_clicks, app_name, email, password, countrycode): # pylint: disable=unused-argument
ctx = callback_context
if ctx.triggered:
try:
res = firstLaunchConfig(app_name, email, password, countrycode)
config.load_app()
return dbc.Alert([res, html.A(" Go to otp config", href=request.url_root + "config_otp")], color="success")
except Exception as e:
res = str(e)
return dbc.Alert(res, color="danger")
else:
return ""
raise PreventUpdate()
@dash_app.callback(
Output("sms-demand-result", "children"),
Input("ask-sms", "n_clicks"))
def askCode(n_clicks): # pylint: disable=unused-argument
ctx = callback_context
if ctx.triggered:
try:
config.myp.get_sms_otp_code()
return dbc.Alert("Sms sent", color="success")
except Exception as e:
res = str(e)
return dbc.Alert(res, color="danger")
raise PreventUpdate()
@dash_app.callback(
Output("opt-result", "children"),
Input("finish-otp", "n_clicks"),
State("psa-pin", "value"),
State("psa-code", "value"))
def finishOtp(n_clicks, code_pin, sms_code): # pylint: disable=unused-argument
ctx = callback_context
if ctx.triggered:
try:
otp_session = new_otp_session(smscode=sms_code, codepin=code_pin)
config.myp.otp = otp_session
Config().start_remote_control()
return dbc.Alert(["OTP config finish !!! ", html.A("Go to home", href=request.url_root)],
color="success")
except Exception as e:
res = str(e)
return dbc.Alert(res, color="danger")
raise PreventUpdate()
+80 -61
View File
@@ -18,13 +18,15 @@ from trip import Trips
from libs.charging import Charging
from web import figures
from web.app import app, dash_app, myp, chc
from web.app import app, dash_app
from web.db import Database
from web.config_views import config_layout, config_otp_layout, log_layout
from web.utils import diff_dashtable, dash_date_to_datetime
# pylint: disable=invalid-name
from web.figurefilter import FigureFilter
from web.utils import create_card
from libs.config import Config
RESPONSE = "-response"
EMPTY_DIV = "empty-div"
@@ -34,6 +36,21 @@ CALLBACK_CREATED = False
trips: Trips = Trips()
chargings: List[dict]
min_date = max_date = min_millis = max_millis = step = marks = cached_layout = None
CONFIG = Config()
@dash_app.callback(Output('page-content', 'children'),
[Input('url', 'pathname')])
def display_page(pathname):
if pathname == "/config":
return config_layout
if pathname == "/log":
return log_layout()
if not CONFIG.is_good:
return dcc.Location(pathname="/config", id="config_redirect")
if pathname == "/config_otp":
return config_otp_layout
return serve_layout()
def create_callback(): # noqa: MC0001
@@ -68,7 +85,7 @@ def create_callback(): # noqa: MC0001
is_open = False
if active_cell is not None and active_cell["column_id"] in ["start_level", "end_level"] and not is_open:
row = data[active_cell["row"]]
return figures.get_battery_curve_fig(row, myp.vehicles_list[0]), True
return figures.get_battery_curve_fig(row, CONFIG.myp.vehicles_list[0]), True
return "", False
@dash_app.callback([Output("tab_trips_popup_graph", "children"), Output("tab_trips_popup", "is_open"), ],
@@ -91,17 +108,17 @@ def create_callback(): # noqa: MC0001
def update_abrp(div_id, value):
vin = div_id["vin"]
if value:
myp.abrp.abrp_enable_vin.add(vin)
CONFIG.myp.abrp.abrp_enable_vin.add(vin)
else:
myp.abrp.abrp_enable_vin.discard(vin)
myp.save_config()
CONFIG.myp.abrp.abrp_enable_vin.discard(vin)
CONFIG.myp.save_config()
return " "
@app.route('/get_vehicles')
def get_vehicules():
response = app.response_class(
response=json.dumps(myp.get_vehicles(), default=lambda car: car.to_dict()),
response=json.dumps(CONFIG.myp.get_vehicles(), default=lambda car: car.to_dict()),
status=200,
mimetype='application/json'
)
@@ -112,7 +129,7 @@ def get_vehicules():
def get_vehicle_info(vin):
from_cache = int(request.args.get('from_cache', 0)) == 1
response = app.response_class(
response=json.dumps(myp.get_vehicle_info(vin, from_cache).to_dict(), default=str),
response=json.dumps(CONFIG.myp.get_vehicle_info(vin, from_cache).to_dict(), default=str),
status=200,
mimetype='application/json'
)
@@ -136,27 +153,27 @@ def get_style():
@app.route('/charge_now/<string:vin>/<int:charge>')
def charge_now(vin, charge):
return jsonify(myp.charge_now(vin, charge != 0))
return jsonify(CONFIG.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']))
return jsonify(CONFIG.myp.change_charge_hour(request.form['vin'], request.form['hour'], request.form['minute']))
@app.route('/wakeup/<string:vin>')
def wakeup(vin):
return jsonify(myp.wakeup(vin))
return jsonify(CONFIG.myp.wakeup(vin))
@app.route('/preconditioning/<string:vin>/<int:activate>')
def preconditioning(vin, activate):
return jsonify(myp.preconditioning(vin, activate))
return jsonify(CONFIG.myp.preconditioning(vin, activate))
@app.route('/position/<string:vin>')
def get_position(vin):
res = myp.get_vehicle_info(vin)
res = CONFIG.myp.get_vehicle_info(vin)
try:
coordinates = res.last_position.geometry.coordinates
except AttributeError:
@@ -176,14 +193,14 @@ def get_position(vin):
def get_charge_control():
logger.info(request)
vin = request.args['vin']
charge_control = chc.get(vin)
charge_control = CONFIG.chc.get(vin)
if charge_control is None:
return jsonify("error: VIN not in list")
if 'hour' in request.args and '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()
CONFIG.chc.save_config()
return jsonify(charge_control.get_dict())
@@ -199,12 +216,12 @@ def abrp():
token = request.args.get('token', None)
if vin is not None and enable is not None:
if enable == '1':
myp.abrp.abrp_enable_vin.add(vin)
CONFIG.myp.abrp.abrp_enable_vin.add(vin)
else:
myp.abrp.abrp_enable_vin.discard(vin)
CONFIG.myp.abrp.abrp_enable_vin.discard(vin)
if token is not None:
myp.abrp.token = token
return jsonify(dict(myp.abrp))
CONFIG.myp.abrp.token = token
return jsonify(dict(CONFIG.myp.abrp))
@app.after_request
@@ -222,49 +239,50 @@ def update_trips():
conn.close()
min_date = None
max_date = None
car = myp.vehicles_list[0] # todo handle multiple car
try:
trips_by_vin = Trips.get_trips(Cars([car]))
trips = trips_by_vin[car.vin]
assert len(trips) > 0
min_date = trips[0].start_at
max_date = trips[-1].start_at
figures.get_figures(trips[0].car)
except (AssertionError, KeyError):
logger.debug("No trips yet")
figures.get_figures(Car("vin","vid","brand"))
try:
chargings = Charging.get_chargings()
assert len(chargings) > 0
if min_date:
min_date = min(min_date, chargings[0]["start_at"])
max_date = max(max_date, chargings[-1]["start_at"])
else:
min_date = chargings[0]["start_at"]
max_date = chargings[-1]["start_at"]
except AssertionError:
logger.debug("No chargings yet")
if min_date is None:
return
# update for slider
try:
logger.debug("min_date:%s - max_date:%s", min_date, max_date)
min_millis = web.utils.unix_time_millis(min_date)
max_millis = web.utils.unix_time_millis(max_date)
step = (max_millis - min_millis) / 100
marks = web.utils.get_marks_from_start_end(min_date, max_date)
cached_layout = None # force regenerate layout
figures.get_figures(car)
except (ValueError, IndexError):
logger.error("update_trips (slider): %s", exc_info=True)
except AttributeError:
logger.debug("position table is probably empty :", exc_info=True)
if CONFIG.is_good:
car = CONFIG.myp.vehicles_list[0] # todo handle multiple car
try:
trips_by_vin = Trips.get_trips(Cars([car]))
trips = trips_by_vin[car.vin]
assert len(trips) > 0
min_date = trips[0].start_at
max_date = trips[-1].start_at
figures.get_figures(trips[0].car)
except (AssertionError, KeyError):
logger.debug("No trips yet")
figures.get_figures(Car("vin", "vid", "brand"))
try:
chargings = Charging.get_chargings()
assert len(chargings) > 0
if min_date:
min_date = min(min_date, chargings[0]["start_at"])
max_date = max(max_date, chargings[-1]["start_at"])
else:
min_date = chargings[0]["start_at"]
max_date = chargings[-1]["start_at"]
except AssertionError:
logger.debug("No chargings yet")
if min_date is None:
return
# update for slider
try:
logger.debug("min_date:%s - max_date:%s", min_date, max_date)
min_millis = web.utils.unix_time_millis(min_date)
max_millis = web.utils.unix_time_millis(max_date)
step = (max_millis - min_millis) / 100
marks = web.utils.get_marks_from_start_end(min_date, max_date)
cached_layout = None # force regenerate layout
figures.get_figures(car)
except (ValueError, IndexError):
logger.error("update_trips (slider): %s", exc_info=True)
except AttributeError:
logger.debug("position table is probably empty :", exc_info=True)
return
def __get_control_tabs():
tabs = []
for car in myp.vehicles_list:
for car in CONFIG.myp.vehicles_list:
if car.label is None:
label = car.vin
else:
@@ -273,7 +291,7 @@ def __get_control_tabs():
tabs.append(dbc.Tab(label=label, id="tab-" + car.vin, children=[
daq.ToggleSwitch(
id={'role': ABRP_SWITCH, 'vin': car.vin},
value=car.vin in myp.abrp.abrp_enable_vin,
value=car.vin in CONFIG.myp.abrp.abrp_enable_vin,
label="Send data to ABRP"
),
html.Div(id={'role': ABRP_SWITCH + RESPONSE, 'vin': car.vin})
@@ -311,7 +329,7 @@ def serve_layout():
fig_filter.src = {"trips": trips.get_trips_as_dict(), "chargings": chargings}
fig_filter.set_clientside_callback(dash_app)
create_callback()
except (IndexError, TypeError, NameError, AssertionError, NameError):
except (IndexError, TypeError, NameError, AssertionError, NameError, AttributeError):
summary_tab = figures.ERROR_DIV
maps = figures.ERROR_DIV
logger.warning("Failed to generate figure, there is probably not enough data yet", exc_info_debug=True)
@@ -320,8 +338,8 @@ def serve_layout():
data_div = html.Div([
*fig_filter.get_store(),
range_slider,
html.Div([
range_slider,
dbc.Tabs([
dbc.Tab(label="Summary", tab_id="summary", children=summary_tab),
dbc.Tab(label="Trips", tab_id="trips", id="tab_trips",
@@ -368,7 +386,7 @@ def serve_layout():
html.Div(id=EMPTY_DIV),
html.Div(id=EMPTY_DIV + "1")
])])
cached_layout = dbc.Container(fluid=True, children=[html.H1('My car info'), data_div])
cached_layout = data_div
return cached_layout
@@ -379,4 +397,5 @@ try:
except (IndexError, TypeError):
logger.debug("Failed to get trips, there is probably not enough data yet:", exc_info=True)
dash_app.layout = serve_layout
dash_app.layout = dbc.Container(fluid=True, children=[dcc.Location(id='url', refresh=False),
html.H1('My car info'), html.Div(id='page-content')])