diff --git a/libs/config.py b/libs/config.py index 8f0d141..a388033 100644 --- a/libs/config.py +++ b/libs/config.py @@ -51,6 +51,8 @@ class Config(metaclass=Singleton): self.chc: ChargeControls self.config_name = DEFAULT_NAME self.is_good: bool = False + self.offline = self.args.offline + self.remote_control = not (self.args.remote_disable or self.offline) def start_remote_control(self): if self.args.remote_disable: diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/abrp.py b/web/abrp.py index 61d926b..b169d26 100644 --- a/web/abrp.py +++ b/web/abrp.py @@ -19,6 +19,12 @@ class Abrp: self.abrp_enable_vin = set(abrp_enable_vin) self.proxies = None + def enable_abrp(self, vin, enable): + if enable: + self.abrp_enable_vin.add(vin) + else: + self.abrp_enable_vin.discard(vin) + def call(self, car: Car, ext_temp: float = None): try: if self.token is None or len(self.token) == 0: diff --git a/web/app.py b/web/app.py index fdb3216..fdc1f46 100644 --- a/web/app.py +++ b/web/app.py @@ -41,13 +41,12 @@ class MyProxyFix(ProxyFix): return super().__call__(environ, start_response) - def start_app(*args, **kwargs): run(config_flask(*args, **kwargs)) def config_flask(title, base_path, debug: bool, host, port, reloader=False, # pylint: disable=too-many-arguments - unminified=False, view="web.views"): + unminified=False, view="web.view.views"): global app, dash_app, dispatcher reload_view = app is not None app = Flask(__name__) @@ -68,9 +67,9 @@ 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 = DashCustom(external_stylesheets=[dbc.themes.BOOTSTRAP], external_scripts=locale_url, title=title, - server=app, requests_pathname_prefix=requests_pathname_prefix, - suppress_callback_exceptions=True, serve_locally=False) - dash_app.enable_dev_tools(reloader) + server=app, requests_pathname_prefix=requests_pathname_prefix, + suppress_callback_exceptions=True, serve_locally=False) + dash_app.enable_dev_tools(debug) app.wsgi_app = MyProxyFix(dash_app) # keep this line importlib.import_module(view) diff --git a/web/assets/images/battery-charge.svg b/web/assets/images/battery-charge.svg new file mode 100644 index 0000000..ca6b546 --- /dev/null +++ b/web/assets/images/battery-charge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/assets/images/mileage.svg b/web/assets/images/mileage.svg new file mode 100644 index 0000000..2548148 --- /dev/null +++ b/web/assets/images/mileage.svg @@ -0,0 +1,71 @@ + + + + + Artboard 84 + + + + + + + + Artboard 84 + + + + diff --git a/web/assets/images/sync.svg b/web/assets/images/sync.svg new file mode 100644 index 0000000..d0d7154 --- /dev/null +++ b/web/assets/images/sync.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/config_views.py b/web/config_views.py index d9ac622..a052280 100644 --- a/web/config_views.py +++ b/web/config_views.py @@ -106,9 +106,9 @@ config_otp_layout = dbc.Row(dbc.Col(className="col-md-12 col-lg-2 ml-2", childre "It's a digit password", color="secondary", ), + dbc.Button("Submit", color="primary", id="finish-otp") ] ), - dbc.Button("Submit", color="primary", id="finish-otp"), html.Div(id="opt-result") ])])) @@ -152,7 +152,7 @@ def connectPSA(n_clicks, app_name, email, password, countrycode): # pylint: dis @dash_app.callback( Output("sms-demand-result", "children"), Input("ask-sms", "n_clicks")) -def askCode(n_clicks): # pylint: disable=unused-argument +def askCode(n_clicks): # pylint: disable=unused-argument ctx = callback_context if ctx.triggered: try: @@ -163,6 +163,7 @@ def askCode(n_clicks): # pylint: disable=unused-argument return dbc.Alert(res, color="danger") raise PreventUpdate() + @dash_app.callback( Output("opt-result", "children"), Input("finish-otp", "n_clicks"), @@ -174,7 +175,9 @@ def finishOtp(n_clicks, code_pin, sms_code): # pylint: disable=unused-argument try: otp_session = new_otp_session(smscode=sms_code, codepin=code_pin) config.myp.otp = otp_session - Config().start_remote_control() + config.myp.refresh_remote_token(force=True) + config.save_config() + 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: diff --git a/web/tools/Button.py b/web/tools/Button.py new file mode 100644 index 0000000..56dfd09 --- /dev/null +++ b/web/tools/Button.py @@ -0,0 +1,30 @@ +import dash_bootstrap_components as dbc +from dash._utils import create_callback_id +from dash.dependencies import Output, MATCH, Input + +from web.app import dash_app + +RESPONSE = "-response" + + +class Button: + def __init__(self, role, button_id, label, fct, prevent_initial_call=True): # pylint: disable=too-many-arguments + self.role = role + self.button_id = button_id + self.label = label + self.html_el = self.get_html() + self._fct = fct + self._output = Output({'role': role + RESPONSE, 'id': MATCH}, 'children') + callback_id = create_callback_id(self._output) + if callback_id not in dash_app.callback_map: + dash_app.callback(self._output, [Input({'role': role, 'id': MATCH}, 'id'), + Input({'role': role, 'id': MATCH}, 'value')], + prevent_initial_call=prevent_initial_call)(self.call) + + def get_html(self): + return dbc.Button(self.label, id={'role': self.role, 'id': self.button_id}, n_clicks=0, color="light", + className="col") + + def call(self, div_id, value): # pylint: disable=unused-argument + self._fct(div_id["id"]) + return " " diff --git a/web/tools/Switch.py b/web/tools/Switch.py new file mode 100644 index 0000000..e45f7cc --- /dev/null +++ b/web/tools/Switch.py @@ -0,0 +1,22 @@ +import dash_bootstrap_components as dbc +import dash_daq as daq +import dash_html_components as html + +from web.tools.Button import Button, RESPONSE + + +class Switch(Button): + def __init__(self, role, button_id, label, fct, value): # pylint: disable=too-many-arguments + self.value = value + super().__init__(role, button_id, label, fct) + + def get_html(self): + return dbc.Col([daq.ToggleSwitch( # pylint: disable=not-callable + id={'role': self.role, 'id': self.button_id}, + value=self.value, + label=self.label + ), html.Div(id={'role': self.role + RESPONSE, 'id': self.button_id})]) + + def call(self, div_id, value): + self._fct(div_id["id"], value) + return " " \ No newline at end of file diff --git a/web/figurefilter.py b/web/tools/figurefilter.py similarity index 92% rename from web/figurefilter.py rename to web/tools/figurefilter.py index 319741c..722d9f6 100644 --- a/web/figurefilter.py +++ b/web/tools/figurefilter.py @@ -51,10 +51,13 @@ class FigureFilter: return dash_Graph def add_table(self, src, figure): - table = Table(figure.id, src, figure) - table.date_columns = [col["id"][:-4] for col in figure.columns if col["type"] == "datetime" and - col["id"].endswith("_str")] - self.tables.append(table) + try: + table = Table(figure.id, src, figure) + table.date_columns = [col["id"][:-4] for col in figure.columns if col["type"] == "datetime" and + col["id"].endswith("_str")] + self.tables.append(table) + except AttributeError: + logger.debug("figure isn't a table") def __get_table_date_column_id(self): res = {table.src: table.date_columns for table in self.tables} diff --git a/web/view/control.py b/web/view/control.py new file mode 100644 index 0000000..3e59568 --- /dev/null +++ b/web/view/control.py @@ -0,0 +1,48 @@ +import dash_bootstrap_components as dbc +import dash_html_components as html + +from mylogger import logger +from web.tools.Button import Button +from web.tools.Switch import Switch +from web.utils import card_value_div, create_card + +REFRESH_SWITCH = "refresh-switch" +ABRP_SWITCH = 'abrp-switch' +CHARGE_SWITCH = "charge-switch" +PRECONDITIONING_SWITCH = "preconditioning-switch" + + +def get_control_tabs(config): + tabs = [] + for car in config.myp.vehicles_list: + if car.label is None: + label = car.vin + else: + label = car.label + myp = config.myp + el = [] + buttons_row = [] + if config.remote_control: + try: + preconditionning_state = car.status.preconditionning.air_conditioning.status != "Disabled" + charging_state = car.status.get_energy('Electric').charging.status == "InProgress" + cards = {"Battery": {"text": [card_value_div("battery_value", "%", + value=str(int(car.status.get_energy('Electric').level)))], + "src": "assets/images/battery-charge.svg"}, + "Mileage": {"text": [card_value_div("mileage_value", "km", + value=str(int(car.status.timed_odometer.mileage)))], + "src": "assets/images/mileage.svg"} + } + el.append(dbc.Container(dbc.Row(children=create_card(cards)), fluid=True)) + buttons_row.extend([Button(REFRESH_SWITCH, car.vin, + html.Img(src="assets/images/sync.svg", width="50px"), myp.wakeup).get_html(), + Switch(CHARGE_SWITCH, car.vin, "Charge", myp.charge_now, charging_state).get_html(), + Switch(PRECONDITIONING_SWITCH, car.vin, "Preconditioning", + myp.preconditioning, preconditionning_state).get_html()]) + except (AttributeError, TypeError): + logger.exception("get_control_tabs:") + if not config.offline: + buttons_row.append(Switch(ABRP_SWITCH, car.vin, "Send data to ABRP", myp.abrp.enable_abrp, + car.vin in config.myp.abrp.abrp_enable_vin).get_html()) + tabs.append(dbc.Tab(label=label, id="tab-" + car.vin, children=[dbc.Row(buttons_row), *el])) + return dbc.Tabs(id="control-tabs", children=tabs) diff --git a/web/views.py b/web/view/views.py similarity index 88% rename from web/views.py rename to web/view/views.py index 27f3ef2..f9800e3 100644 --- a/web/views.py +++ b/web/view/views.py @@ -1,12 +1,12 @@ import json from typing import List +from urllib.parse import parse_qs, urlparse import dash_bootstrap_components as dbc -from dash.dependencies import Output, Input, MATCH, State +from dash.dependencies import Output, Input, State from dash.exceptions import PreventUpdate import dash_core_components as dcc import dash_html_components as html -import dash_daq as daq from flask import jsonify, request, Response as FlaskResponse import web.utils @@ -24,13 +24,12 @@ 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.tools.figurefilter import FigureFilter from web.utils import create_card from libs.config import Config +from web.view.control import get_control_tabs -RESPONSE = "-response" EMPTY_DIV = "empty-div" -ABRP_SWITCH = 'abrp-switch' CALLBACK_CREATED = False trips: Trips = Trips() @@ -39,19 +38,32 @@ min_date = max_date = min_millis = max_millis = step = marks = cached_layout = N CONFIG = Config() +def add_header(el): + return html.H1('My car info'), el + + @dash_app.callback(Output('page-content', 'children'), - [Input('url', 'pathname')]) -def display_page(pathname): - pathname = pathname[len(dash_app.requests_pathname_external_prefix)-1:] + [Input('url', 'pathname'), + Input('url', 'search')]) +def display_page(pathname, search): + pathname = pathname[len(dash_app.requests_pathname_external_prefix) - 1:] + query_params = parse_qs(urlparse(search).query) + no_header = query_params.get("header", None) == ["false"] if pathname == "/config": - return config_layout - if pathname == "/log": - return log_layout() - if not CONFIG.is_good: - return dcc.Location(pathname=dash_app.requests_pathname_external_prefix + "config", id="config_redirect") - if pathname == "/config_otp": - return config_otp_layout - return serve_layout() + page = config_layout + elif pathname == "/log": + page = log_layout() + elif not CONFIG.is_good: + page = dcc.Location(pathname=dash_app.requests_pathname_external_prefix + "config", id="config_redirect") + elif pathname == "/config_otp": + page = config_otp_layout + elif pathname == "/control": + page = get_control_tabs(CONFIG) + else: + page = serve_layout() + if no_header: + return page + return add_header(page) def create_callback(): # noqa: MC0001 @@ -103,19 +115,6 @@ def create_callback(): # noqa: MC0001 CALLBACK_CREATED = True -@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')) -def update_abrp(div_id, value): - vin = div_id["vin"] - if value: - CONFIG.myp.abrp.abrp_enable_vin.add(vin) - else: - CONFIG.myp.abrp.abrp_enable_vin.discard(vin) - CONFIG.myp.save_config() - return " " - - @app.route('/get_vehicles') def get_vehicules(): response = app.response_class( @@ -281,25 +280,6 @@ def update_trips(): return -def __get_control_tabs(): - tabs = [] - for car in CONFIG.myp.vehicles_list: - if car.label is None: - label = car.vin - else: - label = car.label - # pylint: disable=not-callable - tabs.append(dbc.Tab(label=label, id="tab-" + car.vin, children=[ - daq.ToggleSwitch( - id={'role': ABRP_SWITCH, 'vin': car.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}) - ])) - return tabs - - def serve_layout(): global cached_layout if cached_layout is None: @@ -379,13 +359,16 @@ def serve_layout(): ) ]), dbc.Tab(label="Map", tab_id="map", children=[maps]), - dbc.Tab(label="Control", tab_id="control", children=dbc.Tabs(id="control-tabs", - children=__get_control_tabs()))], + dbc.Tab(label="Control", tab_id="control", children=html.Iframe(src="/control?header=false", + style={"position": "absolute", + "height": "100%", + "width": "100%", + "border": "none"})) + ], id="tabs", active_tab="summary", persistence=True), - html.Div(id=EMPTY_DIV), - html.Div(id=EMPTY_DIV + "1") + html.Div(id=EMPTY_DIV) ])]) cached_layout = data_div return cached_layout @@ -399,4 +382,5 @@ except (IndexError, TypeError): logger.debug("Failed to get trips, there is probably not enough data yet:", exc_info=True) dash_app.layout = dbc.Container(fluid=True, children=[dcc.Location(id='url', refresh=False), - html.H1('My car info'), html.Div(id='page-content')]) + html.Div(id='page-content')], + style={"height": "100vh"})