diff --git a/Dockerfile b/Dockerfile index 71bad7f..a158a02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -ARG PYTHON_DEP='python3 python3-wheel python3-typing-extensions python3-pandas python3-plotly python3-six python3-dateutil python3-brotli python3-pycryptodome libatlas3-base python3-cryptography python3-scipy androguard' +ARG PYTHON_DEP='python3 python3-wheel python3-typing-extensions python3-pandas python3-six python3-dateutil python3-brotli python3-pycryptodome libatlas3-base python3-cryptography python3-scipy androguard python3-flask' -FROM debian:buster-slim AS builder +FROM debian:bullseye-slim AS builder ARG PYTHON_DEP RUN BUILD_DEP='python3-pip python3-setuptools python3-dev libblas-dev liblapack-dev gfortran libatlas3-base' ; \ apt-get update && apt-get install -y --no-install-recommends $BUILD_DEP $PYTHON_DEP; @@ -8,7 +8,7 @@ COPY . /psa_car_controller/ RUN pip3 install --system --no-cache-dir -r /psa_car_controller/requirements.txt EXPOSE 5000 -FROM debian:buster-slim +FROM debian:bullseye-slim ARG PYTHON_DEP WORKDIR /config ENV PSACC_BASE_PATH=/ PSACC_PORT=5000 PSACC_OPTIONS="-c -r --web-conf" PSACC_CONFIG_DIR="/config" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f185c2b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: "2" + +services: + psacc: + image: flobz/psa_car_controller:latest + ports: + - "5000:5000" + volumes: + - ./config:/config diff --git a/docs/Docker.md b/docs/Docker.md index 4006fee..18af3ce 100644 --- a/docs/Docker.md +++ b/docs/Docker.md @@ -1,14 +1,19 @@ # Docker installation -A containerised version of the psa_car_controller Python scripts by Flobz. +A containerised version of the psa_car_controller. - Docker Hub: https://hub.docker.com/r/flobz/psa_car_controller ### Overview -Once the container is running, the configuration of the psa_car_controller app is near-identical to setup instructions detailed in the app's readme. +Once the container is running, the configuration of the psa_car_controller app is near-identical classic Linux/Windows installtion. ### Installation -Create the container, detached, exposing port 5000, and mapping the a new config folder on your host to /config inside the container: +Create the container, detached, exposing port 5000, and mapping config folder on your host to /config inside the container: +#### With docker-compose +``` +docker-compose up -d +``` +#### With Docker: ``` docker run -d -ti --name psa_car_controller1 \ --publish 5000:5000 \ diff --git a/libs/car_model.py b/libs/car_model.py index 5a29c1d..7474de0 100644 --- a/libs/car_model.py +++ b/libs/car_model.py @@ -64,6 +64,7 @@ car_models = [ CarModel("308", 0, 56, reg=r"VF3L35GG.*"), CarModel("208", 0, 44, reg=r"VR3UPHN[SE].*"), # VR3UPHNSSM VR3UPHNEKM CarModel("2008", 0, 44, reg=r"VR3USHNS.*"), # VR3USHNSKM + CarModel("2008 II", 0, 45, reg=r"VR3USHNK.*"), # VR3USHNKKL CarModel("SUV 5008 II", 0, 56, reg=r"VF3MRHNS.*"), # vf3mrhnsum CarModel("SUV 5008 II 2018", 0, 56, reg=r"VF3MRHNY.*"), # VF3MRHNYHH CarModel("C5 Aircross Hybrid", 13.2, 43, reg=r"VR7A4DGZ.*"), # VR7A4DGZSM diff --git a/libs/config.py b/libs/config.py index f39fd0f..30a8491 100644 --- a/libs/config.py +++ b/libs/config.py @@ -57,7 +57,7 @@ class Config(metaclass=Singleton): def start_remote_control(self): if self.args.remote_disable: logger.info("mqtt disabled") - elif not self.args.web_conf or path.exists(OTP_CONFIG_NAME): + elif not self.args.web_conf or path.isfile(OTP_CONFIG_NAME): if self.myp.mqtt_client is not None: self.myp.mqtt_client.disconnect() self.myp.start_mqtt() @@ -70,9 +70,10 @@ class Config(metaclass=Singleton): my_logger(handler_level=int(self.args.debug)) if self.args.config: self.config_name = self.args.config - if path.exists(self.config_name): + if path.isfile(self.config_name): self.myp = MyPSACC.load_config(name=self.config_name) elif self.args.web_conf: + self.is_good = False return False else: raise FileNotFoundError(self.config_name) @@ -94,6 +95,7 @@ class Config(metaclass=Singleton): logger.info(str(self.myp.get_vehicles())) self.is_good = True else: + self.is_good = False logger.error("Please reconnect by going to config web page") if self.args.refresh: self.myp.info_refresh_rate = self.args.refresh * 60 diff --git a/libs/requirements.py b/libs/requirements.py new file mode 100644 index 0000000..b7e64cf --- /dev/null +++ b/libs/requirements.py @@ -0,0 +1,27 @@ +import sys + +import pkg_resources +from pathlib import Path + +from mylogger import logger + + +class TestRequirements: + """Test availability of required packages.""" + + def __init__(self, requirement_path): + self.requirement_path = Path(requirement_path) + + def test_requirements(self): + """Test that each required package is available.""" + requirements = pkg_resources.parse_requirements(self.requirement_path.open()) + missing_requirement = False + for requirement in requirements: + requirement = str(requirement) + try: + pkg_resources.require(requirement) + except (pkg_resources.VersionConflict, pkg_resources.DistributionNotFound) as ex: + logger.error("%s\nYou need to install or update %s: pip3 install -U %s", ex, requirement, requirement) + missing_requirement = True + if missing_requirement: + sys.exit(10) diff --git a/requirements.txt b/requirements.txt index e34af87..17b3de9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,17 @@ paho-mqtt>=1.5.0 -dash>=1.18.0 +dash>=2 dash_daq -plotly>=4 +plotly>=5 cryptography>=2.6 Werkzeug>=1.0.0 +flask>=1.0.4 +dash_bootstrap_components>=1 pandas oauth2_client requests pytz typing argparse -flask -dash_bootstrap_components geojson reverse_geocode androguard @@ -21,4 +21,5 @@ pycryptodomex certifi >= 14.05.14 six >= 1.10 python_dateutil >= 2.5.3 -urllib3 >= 1.15.1 \ No newline at end of file +urllib3 >= 1.15.1 + diff --git a/server.py b/server.py index 63acaef..d7b9602 100755 --- a/server.py +++ b/server.py @@ -1,10 +1,17 @@ #!/usr/bin/env python3 # pylint: disable=wrong-import-position +import os import sys from threading import Thread + +from libs.requirements import TestRequirements + +DIR = os.path.dirname(os.path.realpath(__file__)) if sys.version_info < (3, 6): raise RuntimeError("This application requires Python 3.6+") +TestRequirements(DIR + "/requirements.txt").test_requirements() + import web.app from libs.config import Config from mylogger import logger diff --git a/web/figures.py b/web/figures.py index dd74270..ccf2f41 100644 --- a/web/figures.py +++ b/web/figures.py @@ -2,12 +2,12 @@ from copy import deepcopy from statistics import mean import dash_bootstrap_components as dbc -import dash_table -from dash_core_components import Graph -from dash_table.Format import Format, Scheme, Symbol +from dash import html +from dash.dash_table import DataTable import plotly.express as px import plotly.graph_objects as go -import dash_html_components as html +from dash.dash_table.Format import Scheme, Symbol, Format +from dash.dcc import Graph from libs.car import Car from libs.elec_price import ElecPrice @@ -69,7 +69,7 @@ def get_figures(car: Car): style_cell_conditional.append({'if': {'column_id': 'consumption_fuel_km', }, 'display': 'None', }) if car.is_thermal(): style_cell_conditional.append({'if': {'column_id': 'consumption_km', }, 'display': 'None', }) - table_fig = dash_table.DataTable( + table_fig = DataTable( id='trips-table', sort_action='custom', sort_by=[{'column_id': 'id', 'direction': 'desc'}], @@ -116,7 +116,7 @@ def get_figures(car: Car): consumption_fig_by_speed.update_layout(xaxis_title="average Speed km/h", yaxis_title="Consumption kWh/100Km") # battery_table - battery_table = dash_table.DataTable( + battery_table = DataTable( id='battery-table', sort_action='custom', sort_by=[{'column_id': 'start_at_str', 'direction': 'desc'}], diff --git a/web/tools/Button.py b/web/tools/Button.py index 582e73c..0dc0147 100644 --- a/web/tools/Button.py +++ b/web/tools/Button.py @@ -1,7 +1,7 @@ import dash_bootstrap_components as dbc +from dash import html from dash._utils import create_callback_id from dash.dependencies import Output, Input -import dash_html_components as html from web.app import dash_app diff --git a/web/tools/Switch.py b/web/tools/Switch.py index 0321688..ff28ce9 100644 --- a/web/tools/Switch.py +++ b/web/tools/Switch.py @@ -1,6 +1,6 @@ import dash_bootstrap_components as dbc import dash_daq as daq -import dash_html_components as html +from dash import html from dash.dependencies import Input from web.app import dash_app diff --git a/web/tools/figurefilter.py b/web/tools/figurefilter.py index 722d9f6..7031dd3 100644 --- a/web/tools/figurefilter.py +++ b/web/tools/figurefilter.py @@ -1,9 +1,10 @@ import json from logging import DEBUG +from dash import dcc from dash._utils import create_callback_id from dash.dependencies import Output, Input -from dash_core_components import Store + from mylogger import logger @@ -135,5 +136,5 @@ class FigureFilter: return False def get_store(self): - return [Store(id='clientside-figure-store', data=self.__get_figures()), - Store(id='clientside-data-store', data=self.src)] + return [dcc.Store(id='clientside-figure-store', data=self.__get_figures()), + dcc.Store(id='clientside-data-store', data=self.src)] diff --git a/web/utils.py b/web/utils.py index c584bd1..db3c09f 100644 --- a/web/utils.py +++ b/web/utils.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta import dash_bootstrap_components as dbc -import dash_html_components as html +from dash import html from dash.development.base_component import Component from pandas import DataFrame from pytz import UTC diff --git a/web/view/config_views.py b/web/view/config_views.py index c42ab6c..7d6872c 100644 --- a/web/view/config_views.py +++ b/web/view/config_views.py @@ -1,4 +1,4 @@ -from dash import callback_context +from dash import callback_context, html, dcc from dash.exceptions import PreventUpdate from flask import request @@ -9,37 +9,32 @@ 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() -login_config_layout = dbc.Row(dbc.Col(md=12, lg=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": "Citroën", "value": "com.psa.mym.mycitroen"}, - {"label": "DS", "value": "com.psa.mym.myds"}, - {"label": "Vauxhall", "value": "com.psa.mym.myvauxhall"} - ], - )]), - dbc.FormGroup( - [ +login_config_layout = dbc.Row(dbc.Col(md=12, lg=2, className="m-3", children=[ + dbc.Row(html.H2('Config')), + dbc.Row(className="ms-2", children=[ + dbc.Form([ + html.Div([ + 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": "Citroën", "value": "com.psa.mym.mycitroen"}, + {"label": "DS", "value": "com.psa.mym.myds"}, + {"label": "Vauxhall", "value": "com.psa.mym.myvauxhall"} + ], + )]), + html.Div([ 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( - [ + )]), + html.Div([ dbc.Label("Password", html_for="psa-password"), dbc.Input( type="password", @@ -49,11 +44,8 @@ login_config_layout = dbc.Row(dbc.Col(md=12, lg=2, children=[ dbc.FormText( "PSA account password", color="secondary", - ), - ] - ), - dbc.FormGroup( - [ + )]), + html.Div([ dbc.Label("Country code", html_for="countrycode"), dbc.Input( type="text", @@ -63,60 +55,48 @@ login_config_layout = dbc.Row(dbc.Col(md=12, lg=2, children=[ dbc.FormText( "Example: FR for FRANCE or GB for Great Britain...", 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", - ), + )]), + dbc.Row(dbc.Button("Submit", color="primary", id="submit-form")), + dbc.Row( + 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"), - ] +config_otp_layout = dbc.Row(dbc.Col(className="col-md-12 col-lg-2 m-3", children=[ + dbc.Row(html.H2('Config OTP')), + dbc.Form(className="ms-2", children=[ + 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.Label("Write the code you just received by SMS", html_for="psa-email"), + dbc.Input(type="text", id="psa-code", placeholder="Enter code"), + dbc.Label("Enter your PIN code", html_for="psa-pin"), + dbc.Input( + type="password", + id="psa-pin", + placeholder="Enter codepin", ), - 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") - ] + dbc.FormText( + "It's a digit password", + color="secondary", ), - html.Div(id="opt-result") + html.Div([ + 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.Container( + return html.H3(className="m-2", children=["Log:", dbc.Container( fluid=True, style={"height": "80vh", "overflow": "auto", @@ -125,13 +105,14 @@ def log_layout(): "white-space": "pre-line"}, children=log_text, className="m-3 bg-light h5"), - html.Div(id="empty-div")]) + html.Div(id="empty-div")]) -config_layout = dbc.Tabs([ - dbc.Tab([log_layout()], label="Log"), - dbc.Tab([login_config_layout], label="User config"), - dbc.Tab([config_otp_layout], label="OTP config")]) +def config_layout(activeTabs="log"): + return dbc.Tabs(active_tab=activeTabs, children=[ + dbc.Tab([log_layout()], label="Log", tab_id="log"), + dbc.Tab([login_config_layout], label="User config", tab_id="login"), + dbc.Tab([config_otp_layout], label="OTP config", tab_id="otp")]) @dash_app.callback( @@ -147,6 +128,7 @@ def connectPSA(n_clicks, app_name, email, password, countrycode): # pylint: dis try: res = firstLaunchConfig(app_name, email, password, countrycode) config.load_app() + config.start_remote_control() 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) diff --git a/web/view/control.py b/web/view/control.py index 3e59568..b90d69d 100644 --- a/web/view/control.py +++ b/web/view/control.py @@ -1,5 +1,5 @@ import dash_bootstrap_components as dbc -import dash_html_components as html +from dash import html from mylogger import logger from web.tools.Button import Button diff --git a/web/view/views.py b/web/view/views.py index 5c56cc5..1773738 100644 --- a/web/view/views.py +++ b/web/view/views.py @@ -3,10 +3,9 @@ from typing import List from urllib.parse import parse_qs, urlparse import dash_bootstrap_components as dbc +from dash import dcc, html from dash.dependencies import Output, Input, State from dash.exceptions import PreventUpdate -import dash_core_components as dcc -import dash_html_components as html from flask import jsonify, request, Response as FlaskResponse import web.utils @@ -20,7 +19,7 @@ from web import figures from web.app import app, dash_app from web.db import Database -from web.view.config_views import login_config_layout, config_otp_layout, log_layout, config_layout +from web.view.config_views import log_layout, config_layout from web.utils import diff_dashtable, dash_date_to_datetime # pylint: disable=invalid-name @@ -42,8 +41,7 @@ def add_header(el): return dbc.Row([dbc.Col(dcc.Link(html.H1('My car info'), href="/", style={"text-decoration": "none"})), dbc.Col(dcc.Link(html.Img(src="assets/images/settings.svg", width="30veh"), href="/config", - className="float-right"))], - className="justify-content-between"), el + className="float-end"))]), el @dash_app.callback(Output('page-content', 'children'), @@ -54,15 +52,15 @@ def display_page(pathname, search): query_params = parse_qs(urlparse(search).query) no_header = query_params.get("header", None) == ["false"] if pathname == "/config": - page = config_layout + page = config_layout() elif pathname == "/config_login": - page = login_config_layout + page = config_layout("login") elif pathname == "/log": page = log_layout() elif not CONFIG.is_good: page = dcc.Location(pathname=dash_app.requests_pathname_external_prefix + "config_login", id="config_redirect") elif pathname == "/config_otp": - page = config_otp_layout + page = config_layout("otp") elif pathname == "/control": page = get_control_tabs(CONFIG) else: