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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d457fb..1bb311c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,6 @@ repos: - repo: https://github.com/PyCQA/prospector - rev: 1.3.0 # The version of Prospector to use, at least 1.1.7 + rev: 1.3.1 # The version of Prospector to use, at least 1.1.7 hooks: - id: prospector + language: system diff --git a/.prospector.yaml b/.prospector.yaml index 3a91988..0af2a38 100644 --- a/.prospector.yaml +++ b/.prospector.yaml @@ -1,3 +1,15 @@ doc-warnings: false ignore-paths: - psa_connectedcar +pep8: + disable: + - E722 +pyflakes: + disable: + - F401 +pylint: + disable: + - fixme + - C0114 + - C0115 + - C0116 diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..a945bd1 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,18 @@ +[MASTER] +disable= + C0114,C0115,C0116,W0603,I0011,W0511 +[FORMAT] +max-line-length=120 + +[BASIC] +good-names=i, + j, + k, + ex, + Run, + e, + f, + t, + x, + ip, + s diff --git a/README.md b/README.md index 1294162..46d27b3 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ We will retrieve these informations: At the first launch you will receive a SMS and you will be asked to give it and also give your pin code (the four-digit code that your use on the android app). If it failed you can remove the file otp.bin and retry. - + You can see all options available with : ``python3 server.py -h`` @@ -91,18 +91,30 @@ We will retrieve these informations: http://localhost:5000/preconditioning/YOURVIN/1 or 0 - 3. Dashboard and stats (Beta) + ## III. Use the dashboard - You can add the -r argument to record the position of the vehicle and retrieve this information in a dashboard. +You can add the -r argument to record the position of the vehicle and retrieve this information in a dashboard. - ``python3 server.py -f test.json -c charge_config1.json -r`` +``python3 server.py -f test.json -c charge_config1.json -r`` - You will be able to visualize your trips, your consumption and some statistics: +You will be able to visualize your trips, your consumption and some statistics: ![Screenshot_20210128_104519](https://user-images.githubusercontent.com/48728684/106119895-01c98d80-6156-11eb-8969-9e8bc24f3677.png) - You have to add an api key from https://home.openweathermap.org/ in your config file, to be able to see your consumption vs exterior temperature. -## Connect your home automation system: +- You have to add an api key from https://home.openweathermap.org/ in your config file, to be able to see your consumption vs exterior temperature. +- You have to add an api key from https://co2signal.com/ to have your C02 emission by KM (in France the key isn't needed). +### Charge price calculation +The dashboard can give you the price by kilometer and price by kw that you pay. +You just have to set the price in the config file. + +After a successful launch of the app, a config.ini file will be created. +In this file you can set the price you pay for electricity in the following format "0.15". + +If you have a special price during the night you can set "night price", "night hour start" and "night hour end". +Hours need to be in the following format "23h12"? + +You can modify a price manually in the dashboard. It can be useful if you use public charge point. +## IV. Connect your home automation system: - [Domoticz](docs/domoticz/Domoticz.md) - [HomeAssistant](https://github.com/Flodu31/HomeAssistant-PeugeotIntegration) - Jeedom (Anyone can share the procedure ?) diff --git a/app_decoder.py b/app_decoder.py index 53d3bbc..ef8d05a 100755 --- a/app_decoder.py +++ b/app_decoder.py @@ -2,6 +2,10 @@ import json import os import traceback +from sys import argv +import sys +import re +from getpass import getpass from androguard.core.bytecodes.apk import APK @@ -10,13 +14,8 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.serialization import pkcs12 from cryptography.hazmat.backends import default_backend -from getpass import getpass - -from ChargeControl import ChargeControl, ChargeControls -from MyPSACC import MyPSACC -from sys import argv -import sys -import re +from charge_control import ChargeControl, ChargeControls +from my_psacc import MyPSACC BRAND = {"com.psa.mym.myopel": {"realm": "clientsB2COpel", "brand_code": "OP", "app_name": "MyOpel"}, "com.psa.mym.mypeugeot": {"realm": "clientsB2CPeugeot", "brand_code": "AP", "app_name": "MyPeugeot"}, @@ -85,7 +84,7 @@ client_id = resources.get_string(package_name, "PSA_API_CLIENT_ID_PROD")[1] client_secret = resources.get_string(package_name, "PSA_API_CLIENT_SECRET_PROD")[1] HOST_BRANDID_PROD = resources.get_string(package_name, "HOST_BRANDID_PROD")[1] pfx_cert = a.get_file("assets/MWPMYMA1.pfx") -remote_refresh_token = None +REMOTE_REFRESH_TOKEN = None print("APK loaded !") client_email = input(f"{BRAND[package_name]['app_name']} email: ") @@ -105,12 +104,13 @@ try: params={"jsonRequest": json.dumps( {"siteCode": site_code, "culture": "fr-FR", "action": "authenticate", "fields": {"USR_EMAIL": {"value": client_email}, - "USR_PASSWORD": {"value": client_password}}}) - } + "USR_PASSWORD": {"value": client_password}} + } + )} ) token = res.json()["accessToken"] -except: +except: # pylint: disable=bare-except traceback.print_exc() print(f"HOST_BRANDID : {HOST_BRANDID_PROD} sitecode: {site_code}") print(res.text) @@ -120,7 +120,8 @@ save_key_to_pem(pfx_cert, "") try: res2 = requests.post( - f"https://mw-{BRAND[package_name]['brand_code'].lower()}-m2c.mym.awsmpsa.com/api/v1/user?culture=fr_FR&width=1080&v=1.27.0", + f"https://mw-{BRAND[package_name]['brand_code'].lower()}-m2c.mym.awsmpsa.com/api/v1/" + f"user?culture=fr_FR&width=1080&v=1.27.0", data=json.dumps({"site_code": site_code, "ticket": token}), headers={ "Connection": "Keep-Alive", @@ -136,14 +137,14 @@ try: res_dict = res2.json()["success"] customer_id = BRAND[package_name]["brand_code"] + "-" + res_dict["id"] -except: +except: # pylint: disable=bare-except traceback.print_exc() print(res2.text) sys.exit(1) # Psacc -psacc = MyPSACC(None, client_id, client_secret, remote_refresh_token, customer_id, BRAND[package_name]["realm"], +psacc = MyPSACC(None, client_id, client_secret, REMOTE_REFRESH_TOKEN, customer_id, BRAND[package_name]["realm"], country_code) psacc.connect(client_email, client_password) diff --git a/ChargeControl.py b/charge_control.py similarity index 95% rename from ChargeControl.py rename to charge_control.py index e463e81..0a18e74 100644 --- a/ChargeControl.py +++ b/charge_control.py @@ -8,8 +8,8 @@ from time import sleep import pytz -from MyPSACC import MyPSACC -from MyLogger import logger +from my_psacc import MyPSACC +from mylogger import logger DISCONNECTED = "Disconnected" INPROGRESS = "InProgress" @@ -93,7 +93,7 @@ class ChargeControl: self.retry_count = 0 except AttributeError: logger.error("Probably can't retrieve all information from API: %s", traceback.format_exc()) - except: + except: # pylint: disable=bare-except logger.error(traceback.format_exc()) def get_dict(self): @@ -125,12 +125,12 @@ class ChargeControls(dict): @staticmethod def load_config(psacc: MyPSACC, name="charge_config.json"): - with open(name, "r") as f: - config_str = f.read() + with open(name, "r") as file: + config_str = file.read() chd = json.loads(config_str) charge_control_list = ChargeControls(name) - for vin, el in chd.items(): - charge_control_list[vin] = ChargeControl(psacc, vin, **el) + for vin, params in chd.items(): + charge_control_list[vin] = ChargeControl(psacc, vin, **params) return charge_control_list def get(self, vin) -> ChargeControl: @@ -138,6 +138,7 @@ class ChargeControls(dict): return self[vin] except KeyError: pass + return None def init(self): for charge_control in self.values(): diff --git a/ecomix.py b/ecomix.py index 94da2e0..02e2184 100644 --- a/ecomix.py +++ b/ecomix.py @@ -1,19 +1,27 @@ -from datetime import datetime +from datetime import datetime, timedelta from statistics import mean, StatisticsError import xml.etree.cElementTree as ElT +import numbers +import traceback + import requests import reverse_geocode -from MyLogger import logger +from mylogger import logger + +CO2_SIGNAL_REQ_INTERVAL = 600 class Ecomix: + _cache = {} + @staticmethod def get_data_france(start, end): start_str = start.strftime("%d/%m/%Y") end_str = end.strftime("%d/%m/%Y") res = requests.get( - f"https://eco2mix.rte-france.com/curves/eco2mixWeb?type=co2&&dateDeb={start_str}&dateFin={end_str}&mode=NORM", + f"https://eco2mix.rte-france.com/curves/eco2mixWeb?type=co2&&dateDeb={start_str}" + f"&dateFin={end_str}&mode=NORM", headers={ "Origin": "https://www.rte-france.com", "Referer": "https://www.rte-france.com/eco2mix/les-emissions-de-co2-par-kwh-produit-en-france", @@ -39,18 +47,65 @@ class Ecomix: return None @staticmethod - def get_co2_per_kw(start: datetime, end: datetime, latitude, longitude): + def get_data_from_co2_signal(latitude, longitude, co2_signal_key): + if co2_signal_key is not None: + try: + country_code = Ecomix.get_country(latitude, longitude) + assert country_code is not None + if country_code not in Ecomix._cache: + Ecomix._cache[country_code] = [] + elif len(Ecomix._cache[country_code]) > 0 and \ + (datetime.now()-Ecomix._cache[country_code][-1][0]).total_seconds() < CO2_SIGNAL_REQ_INTERVAL: + return False + res = requests.get("https://api.co2signal.com/v1/latest", + headers={"auth-token": co2_signal_key}, + params={"countryCode": country_code}) + data = res.json() + value = data["data"]["carbonIntensity"] + assert isinstance(value, numbers.Number) + Ecomix._cache[country_code].append([datetime.now(), value]) + return data["status"] == "ok" + except (AssertionError, NameError, KeyError): + logger.debug(traceback.format_exc()) + return False + else: + return False + + @staticmethod + def clean_cache(): + max_date = datetime.now() - timedelta(days=1) + for country in Ecomix._cache: + Ecomix._cache[country][:] = [x for x in Ecomix._cache[country] if max_date < x[0]] + + @staticmethod + def get_co2_from_signal_cache(start: datetime, end: datetime, country_code): + Ecomix.clean_cache() + co2_per_kw = [] + for row in Ecomix._cache.get(country_code, []): + if start < row[0] < end: + co2_per_kw.append(row[1]) + if len(co2_per_kw) == 0: + return None + return mean(co2_per_kw) + + @staticmethod + def get_country(latitude, longitude): try: location = reverse_geocode.search([(latitude, longitude)])[0] country_code = location["country_code"] - except UnicodeDecodeError: + return country_code + except (UnicodeDecodeError, IndexError): logger.error("Can't find country for %s %s", latitude, longitude) - country_code = None - except IndexError: - country_code = None - # todo implement other countries - if country_code == 'FR': + return None + + @staticmethod + def get_co2_per_kw(start: datetime, end: datetime, latitude, longitude, from_cache=False): + co2_per_kw = None + country_code = Ecomix.get_country(latitude, longitude) + if country_code is None: + return None + if from_cache: + co2_per_kw = Ecomix.get_co2_from_signal_cache(start, end, country_code) + elif country_code == 'FR': co2_per_kw = Ecomix.get_data_france(start, end) - else: - co2_per_kw = None return co2_per_kw diff --git a/Car.py b/libs/car.py similarity index 90% rename from Car.py rename to libs/car.py index 371d46b..d0a129a 100644 --- a/Car.py +++ b/libs/car.py @@ -1,11 +1,11 @@ import json from copy import copy -from MyLogger import logger +from mylogger import logger from libs.car_model import CarModel from libs.car_status import CarStatus - +# pylint: disable=too-many-instance-attributes,too-many-arguments class Car: def __init__(self, vin, vehicle_id, brand, label=None, battery_power=None, fuel_capacity=None, max_elec_consumption=None, max_fuel_consumption=None, abrp_name=None): @@ -76,7 +76,7 @@ class Car: class Cars(list): def __init__(self, *args): list.__init__(self, *args) - self.config_filename = "cars.json" + self.config_filename = "../cars.json" def get_car_by_vin(self, vin) -> Car: for car in self: @@ -106,20 +106,20 @@ class Cars(list): if name is None: name = self.config_filename config_str = json.dumps(self, default=lambda car: car.to_dict(), sort_keys=True, indent=4) - with open(name, "w") as f: - f.write(config_str) + with open(name, "w") as file: + file.write(config_str) @staticmethod def load_cars(name=None): if name is None: name = Cars().config_filename try: - with open(name, "r") as f: - json_str = f.read() + with open(name, "r") as file: + json_str = file.read() cars = Cars.from_json(json.loads(json_str)) cars.config_filename = name cars.save_cars() return cars - except (FileNotFoundError, TypeError) as e: - logger.debug(e) + except (FileNotFoundError, TypeError) as ex: + logger.debug(ex) return Cars() diff --git a/libs/car_model.py b/libs/car_model.py index 7f45fad..0ef25f8 100644 --- a/libs/car_model.py +++ b/libs/car_model.py @@ -1,6 +1,6 @@ import re -from MyLogger import logger +from mylogger import logger DEFAULT_BATTERY_POWER = 46 DEFAULT_FUEL_CAPACITY = 0 @@ -9,6 +9,7 @@ DEFAULT_MAX_FUEL_CONSUMPTION = 30 class CarModel: + # pylint: disable=too-many-arguments def __init__(self, name, battery_power, fuel_capacity, abrp_name=None, reg=None, max_elec_consumption=DEFAULT_MAX_ELEC_CONSUMPTION, max_fuel_consumption=DEFAULT_MAX_FUEL_CONSUMPTION): self.name = name @@ -42,6 +43,7 @@ class CarModel: class ElecModel(CarModel): + # pylint: disable=too-many-arguments def __init__(self, name, battery_power, abrp_name=None, reg=None, max_elec_consumption=DEFAULT_MAX_ELEC_CONSUMPTION): super().__init__(name, battery_power, 0, abrp_name, reg, max_elec_consumption, 0) diff --git a/libs/car_status.py b/libs/car_status.py index 82ebea4..392a587 100644 --- a/libs/car_status.py +++ b/libs/car_status.py @@ -1,7 +1,8 @@ -from MyLogger import logger +from mylogger import logger from psa_connectedcar import Position, Geometry, PositionProperties, Kinetic, Energy, EnergyCharging, Status +# pylint: disable=too-many-arguments class CarStatus(Status): def __init__(self, embedded=None, links=None, battery=None, doors_state=None, energy=None, environment=None, ignition=None, kinetic=None, last_position=None, preconditionning=None, privacy=None, safety=None, diff --git a/libs/charging.py b/libs/charging.py new file mode 100644 index 0000000..f700c33 --- /dev/null +++ b/libs/charging.py @@ -0,0 +1,42 @@ +from typing import List + +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 charge in charge_list: + charge["price"] = elec_price.get_price(charge["start_at"], charge["stop_at"], charge["kw"]) + set_chargings_price(conn, charge["start_at"], charge["price"]) + conn.close() + + # pylint: disable=too-many-arguments + @staticmethod + def update_chargings(conn, start_at, stop_at, level, co2_per_kw, consumption_kw, vin): + price = elec_price.get_price(start_at, stop_at, consumption_kw) + conn.execute( + "UPDATE battery set stop_at=?, end_level=?, co2=?, kw=?, price=? WHERE start_at=? and VIN=?", + (stop_at, level, co2_per_kw, consumption_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..edb17cf --- /dev/null +++ b/libs/elec_price.py @@ -0,0 +1,95 @@ +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, TypeError): + 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))) + + @staticmethod + def compare_hour(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/libs/oauth.py b/libs/oauth.py new file mode 100644 index 0000000..f2445a7 --- /dev/null +++ b/libs/oauth.py @@ -0,0 +1,78 @@ +from http import HTTPStatus + +from oauth2_client.credentials_manager import CredentialManager +from requests import Response + +import psa_connectedcar as psac +from mylogger import logger +from psa_connectedcar import ApiClient +from psa_connectedcar.rest import ApiException + + +class OpenIdCredentialManager(CredentialManager): + def _grant_password_request_realm(self, login: str, password: str, realm: str) -> dict: + return dict(grant_type='password', + username=login, + scope=' '.join(self.service_information.scopes), + password=password, realm=realm) + + def init_with_user_credentials_realm(self, login: str, password: str, realm: str): + self._token_request(self._grant_password_request_realm(login, password, realm), True) + + @staticmethod + def _is_token_expired(response: Response) -> bool: + if response.status_code == HTTPStatus.UNAUTHORIZED.value: + logger.info("token expired, renew") + try: + json_data = response.json() + return json_data.get('moreInformation') == 'Token is invalid' + except ValueError: + return False + else: + return False + + @property + def access_token(self): + return self._access_token + + +class Oauth2PSACCApiConfig(psac.Configuration): + def __init__(self): + super().__init__() + self.refresh_callback = None + + def set_refresh_callback(self, callback): + self.refresh_callback = callback + + +class OauthAPIClient(ApiClient): + # pylint: disable=no-member,too-many-arguments + def call_api(self, resource_path, method, + path_params=None, query_params=None, header_params=None, + body=None, post_params=None, files=None, + response_type=None, auth_settings=None, async_req=None, + _return_http_data_only=None, collection_formats=None, + _preload_content=True, _request_timeout=None): + for _ in range(0, 2): + try: + if not async_req: + return self._ApiClient__call_api(resource_path, method, + path_params, query_params, header_params, + body, post_params, files, + response_type, auth_settings, + _return_http_data_only, collection_formats, + _preload_content, _request_timeout) + return self.pool.apply_async(self.__call_api, (resource_path, + method, path_params, query_params, + header_params, body, + post_params, files, + response_type, auth_settings, + _return_http_data_only, + collection_formats, + _preload_content, _request_timeout)) + except ApiException as e: + if e.reason == 'Unauthorized': + self.configuration.refresh_callback() + else: + raise e + return None diff --git a/MyPSACC.py b/my_psacc.py similarity index 75% rename from MyPSACC.py rename to my_psacc.py index 6b7deae..6aa36ea 100644 --- a/MyPSACC.py +++ b/my_psacc.py @@ -4,29 +4,27 @@ import threading import traceback import uuid from datetime import datetime -from http import HTTPStatus from json import JSONEncoder from hashlib import md5 from time import sleep -from oauth2_client.credentials_manager import CredentialManager, ServiceInformation +from oauth2_client.credentials_manager import ServiceInformation import paho.mqtt.client as mqtt -from requests import Response -from typing import Tuple +from geojson import Feature, Point, FeatureCollection +from geojson import dumps as geo_dumps import psa_connectedcar as psac -from Car import Cars, Car +from libs.car import Cars, Car +from libs.charging import Charging +from libs.oauth import OpenIdCredentialManager, Oauth2PSACCApiConfig, OauthAPIClient from ecomix import Ecomix -from otp.Otp import load_otp, new_otp_session, save_otp, ConfigException, Otp -from psa_connectedcar import ApiClient +from otp.otp import load_otp, new_otp_session, save_otp, ConfigException, Otp from psa_connectedcar.rest import ApiException -from MyLogger import logger +from mylogger import logger from utils import get_temp, rate_limit from web.abrp import Abrp from web.db import get_db, clean_position, get_last_temp -from geojson import Feature, Point, FeatureCollection -from geojson import dumps as geo_dumps PSA_CORRELATION_DATE_FORMAT = "%Y%m%d%H%M%S%f" PSA_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" @@ -36,12 +34,13 @@ realm_info = { "clientsB2CCitroen": {"oauth_url": "https://idpcvs.citroen.com/am/oauth2/access_token", "app_name": "MyCitroen"}, "clientsB2CDS": {"oauth_url": "https://idpcvs.driveds.com/am/oauth2/access_token", "app_name": "MyDS"}, "clientsB2COpel": {"oauth_url": "https://idpcvs.opel.com/am/oauth2/access_token", "app_name": "MyOpel"}, - "clientsB2CVauxhall": {"oauth_url": "https://idpcvs.vauxhall.co.uk/am/oauth2/access_token", "app_name": "MyVauxhall"} + "clientsB2CVauxhall": {"oauth_url": "https://idpcvs.vauxhall.co.uk/am/oauth2/access_token", + "app_name": "MyVauxhall"} } -authorize_service = "https://api.mpsa.com/api/connectedcar/v2/oauth/authorize" -remote_url = "https://api.groupe-psa.com/connectedcar/v4/virtualkey/remoteaccess/token?client_id=" -scopes = ['openid profile'] +AUTHORIZE_SERVICE = "https://api.mpsa.com/api/connectedcar/v2/oauth/authorize" +REMOTE_URL = "https://api.groupe-psa.com/connectedcar/v4/virtualkey/remoteaccess/token?client_id=" +SCOPE = ['openid profile'] MQTT_SERVER = "mwa.mpsa.com" MQTT_REQ_TOPIC = "psa/RemoteServices/from/cid/" MQTT_RESP_TOPIC = "psa/RemoteServices/to/cid/" @@ -51,67 +50,6 @@ CARS_FILE = "cars.json" DEFAULT_CONFIG_FILENAME = "config.json" - - -class OpenIdCredentialManager(CredentialManager): - def _grant_password_request(self, login: str, password: str, realm: str) -> dict: - return dict(grant_type='password', - username=login, - scope=' '.join(self.service_information.scopes), - password=password, realm=realm) - - def init_with_user_credentials(self, login: str, password: str, realm: str): - self._token_request(self._grant_password_request(login, password, realm), True) - - @staticmethod - def _is_token_expired(response: Response) -> bool: - if response.status_code == HTTPStatus.UNAUTHORIZED.value: - logger.info("token expired, renew") - try: - json_data = response.json() - return json_data.get('moreInformation') == 'Token is invalid' - except ValueError: - return False - else: - return False - - -class Oauth2PSACCApiConfig(psac.Configuration): - def set_refresh_callback(self, callback): - self.refresh_callback = callback - - -class OauthAPIClient(ApiClient): - def call_api(self, resource_path, method, - path_params=None, query_params=None, header_params=None, - body=None, post_params=None, files=None, - response_type=None, auth_settings=None, async_req=None, - _return_http_data_only=None, collection_formats=None, - _preload_content=True, _request_timeout=None): - for _ in range(0, 2): - try: - if not async_req: - return self._ApiClient__call_api(resource_path, method, - path_params, query_params, header_params, - body, post_params, files, - response_type, auth_settings, - _return_http_data_only, collection_formats, - _preload_content, _request_timeout) - return self.pool.apply_async(self.__call_api, (resource_path, - method, path_params, query_params, - header_params, body, - post_params, files, - response_type, auth_settings, - _return_http_data_only, - collection_formats, - _preload_content, _request_timeout)) - except ApiException as e: - if e.reason == 'Unauthorized': - self.configuration.refresh_callback() - else: - raise e - - def gen_correlation_id(date): date_str = date.strftime(PSA_CORRELATION_DATE_FORMAT)[:-3] uuid_str = str(uuid.uuid4()).replace("-", "") @@ -119,22 +57,24 @@ def gen_correlation_id(date): return correlation_id +# pylint: disable=too-many-instance-attributes,too-many-public-methods class MyPSACC: def connect(self, user, password): - self.manager.init_with_user_credentials(user, password, self.realm) + self.manager.init_with_user_credentials_realm(user, password, self.realm) + # pylint: disable=too-many-arguments def __init__(self, refresh_token, client_id, client_secret, remote_refresh_token, customer_id, realm, country_code, - proxies=None, weather_api=None, abrp=None): + proxies=None, weather_api=None, abrp=None, co2_signal_api=None): self.realm = realm - self.service_information = ServiceInformation(authorize_service, + self.service_information = ServiceInformation(AUTHORIZE_SERVICE, realm_info[self.realm]['oauth_url'], client_id, client_secret, - scopes, False) + SCOPE, False) self.client_id = client_id self.manager = OpenIdCredentialManager(self.service_information) self.api_config = Oauth2PSACCApiConfig() - self.api_config.set_refresh_callback(self.manager._refresh_token) + self.api_config.set_refresh_callback(self.refresh_token) self.manager.refresh_token = refresh_token self.remote_refresh_token = remote_refresh_token self.remote_access_token = None @@ -164,16 +104,18 @@ class MyPSACC: self.abrp: Abrp = Abrp(**abrp) self.set_proxies(proxies) self.config_file = DEFAULT_CONFIG_FILENAME + self.co2_signal_api = co2_signal_api def get_app_name(self): return realm_info[self.realm]['app_name'] def refresh_token(self): + # pylint: disable=protected-access self.manager._refresh_token() self.save_config() def api(self) -> psac.VehiclesApi: - self.api_config.access_token = self.manager._access_token + self.api_config.access_token = self.manager.access_token api_instance = psac.VehiclesApi(OauthAPIClient(self.api_config)) return api_instance @@ -261,7 +203,7 @@ class MyPSACC: return otp_code def get_remote_access_token(self, password): - res = self.manager.post(remote_url + self.client_id, + res = self.manager.post(REMOTE_URL + self.client_id, json={"grant_type": "password", "password": password}, headers=self.headers) data = res.json() @@ -278,7 +220,7 @@ class MyPSACC: if self.remote_refresh_token is None: logger.error("remote_refresh_token isn't defined") self.load_otp(force_new=True) - res = self.manager.post(remote_url + self.client_id, + res = self.manager.post(REMOTE_URL + self.client_id, json={"grant_type": "refresh_token", "refresh_token": self.remote_refresh_token}, headers=self.headers) data = res.json() @@ -296,8 +238,9 @@ class MyPSACC: self.save_config() return res - def on_mqtt_connect(self, client, userdata, rc, a): - logger.info("Connected with result code %s", rc) + # pylint: disable=unused-argument + def __on_mqtt_connect(self, client, userdata, result_code, _): + logger.info("Connected with result code %s", result_code) topics = [MQTT_RESP_TOPIC + self.customer_id + "/#"] for car in self.vehicles_list: topics.append(MQTT_EVENT_TOPIC + car.vin) @@ -305,14 +248,16 @@ class MyPSACC: client.subscribe(topic) logger.info("subscribe to %s", topic) - def on_mqtt_disconnect(self, client, userdata, rc): - logger.warning("Disconnected with result code %d", rc) - if rc == 1: + # pylint: disable=unused-argument + def _on_mqtt_disconnect(self, client, userdata, result_code): + logger.warning("Disconnected with result code %d", result_code) + if result_code == 1: self.refresh_remote_token(force=True) else: - logger.warning(mqtt.error_string(rc)) + logger.warning(mqtt.error_string(result_code)) - def on_mqtt_message(self, client, userdata, msg): + # pylint: disable=unused-argument + def __on_mqtt_message(self, client, userdata, msg): try: logger.info("mqtt msg %s %s", msg.topic, msg.payload) data = json.loads(msg.payload) @@ -345,9 +290,9 @@ class MyPSACC: self.mqtt_client = mqtt.Client(clean_session=True, protocol=mqtt.MQTTv311) self.refresh_remote_token() self.mqtt_client.tls_set_context() - self.mqtt_client.on_connect = self.on_mqtt_connect - self.mqtt_client.on_message = self.on_mqtt_message - self.mqtt_client.on_disconnect = self.on_mqtt_disconnect + self.mqtt_client.on_connect = self.__on_mqtt_connect + self.mqtt_client.on_message = self.__on_mqtt_message + self.mqtt_client.on_disconnect = self._on_mqtt_disconnect self.mqtt_client.connect(MQTT_SERVER, 8885, 60) self.mqtt_client.loop_start() self.__keep_mqtt() @@ -371,36 +316,35 @@ class MyPSACC: return json.dumps(data) - def get_charge_hour(self, vin): + def __get_charge_hour(self, vin): reg = r"PT([0-9]{1,2})H([0-9]{1,2})?" data = self.get_vehicle_info(vin) hour_str = data.get_energy('Electric').charging.next_delayed_time try: - hour = re.findall(reg, hour_str)[0] - h = int(hour[0]) - if hour[1] == '': - m = 0 + hour_minute = re.findall(reg, hour_str)[0] + hour = int(hour_minute[0]) + if hour_minute[1] == '': + minute = 0 else: - m = hour[1] - return h, m + minute = hour_minute[1] + return hour, minute except IndexError: logger.error(traceback.format_exc()) logger.error("Can't get charge hour: %s", hour_str) + return None def get_charge_status(self, vin): data = self.get_vehicle_info(vin) status = data.get_energy('Electric').charging.status return status - def veh_charge_request(self, vin, hour, miinute, charge_type): - # todo consider actual state before change the hour + def __veh_charge_request(self, vin, hour, miinute, charge_type): msg = self.mqtt_request(vin, {"program": {"hour": hour, "minute": miinute}, "type": charge_type}) logger.info(msg) self.mqtt_client.publish(MQTT_REQ_TOPIC + self.customer_id + "/VehCharge", msg) def change_charge_hour(self, vin, hour, miinute): - # todo consider actual state before change the hour - self.veh_charge_request(vin, hour, miinute, "delayed") + self.__veh_charge_request(vin, hour, miinute, "delayed") return True def charge_now(self, vin, now): @@ -408,8 +352,8 @@ class MyPSACC: charge_type = "immediate" else: charge_type = "delayed" - hour, minute = self.get_charge_hour(vin) - self.veh_charge_request(vin, hour, minute, charge_type) + hour, minute = self.__get_charge_hour(vin) + self.__veh_charge_request(vin, hour, minute, charge_type) return True def horn(self, vin, count): @@ -488,8 +432,9 @@ class MyPSACC: config = dict(**json.loads(config_str)) if "country_code" not in config: config["country_code"] = input("What is your country code ? (ex: FR, GB, DE, ES...)\n") - if "abrp" not in config: - config["abrp"] = None + for new_el in ["abrp", "co2_signal_api"]: + if new_el not in config: + config[new_el] = None psacc = MyPSACC(**config) psacc.config_file = name return psacc @@ -512,16 +457,16 @@ class MyPSACC: logger.debug("vin:%s longitude:%s latitude:%s date:%s mileage:%s level:%s charge_date:%s level_fuel:" "%s moving:%s", car.vin, longitude, latitude, date, mileage, level, charge_date, level_fuel, moving) - self.record_position(car.vin, mileage, latitude, longitude, date, level, level_fuel, moving) + self.__record_position(car.vin, mileage, latitude, longitude, date, level, level_fuel, moving) self.abrp.call(car, get_last_temp(car.vin)) try: charging_status = car.status.get_energy('Electric').charging.status - self.record_charging(car.vin, charging_status, charge_date, level, latitude, longitude) + self.__record_charging(car.vin, charging_status, charge_date, level, latitude, longitude) logger.debug("charging_status:%s ", charging_status) except AttributeError: logger.error("charging status not available from api") - def record_position(self, vin, mileage, latitude, longitude, date, level, level_fuel, moving): + def __record_position(self, vin, mileage, latitude, longitude, date, level, level_fuel, moving): conn = get_db() if mileage == 0: # fix a bug of the api logger.error("The api return a wrong mileage for %s : %f", vin, mileage) @@ -550,7 +495,7 @@ class MyPSACC: logger.debug("position already saved") return False - def record_charging(self, vin, charging_status, charge_date, level, latitude, longitude): + def __record_charging(self, vin, charging_status, charge_date, level, latitude, longitude): conn = get_db() if charging_status == "InProgress": try: @@ -561,6 +506,7 @@ class MyPSACC: if not in_progress: conn.execute("INSERT INTO battery(start_at,start_level,VIN) VALUES(?,?,?)", (charge_date, level, vin)) conn.commit() + Ecomix.get_data_from_co2_signal(latitude, longitude, self.co2_signal_api) else: try: start_at, stop_at, start_level = conn.execute( @@ -568,11 +514,11 @@ class MyPSACC: "DESC limit 1", (vin,)).fetchone() in_progress = stop_at is None if in_progress: - co2_per_kw = Ecomix.get_co2_per_kw(start_at, charge_date, latitude, longitude) - 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)) + co2_per_kw = Ecomix.get_co2_per_kw(start_at, charge_date, latitude, longitude, + from_cache=self.co2_signal_api is not None) + consumption_kw = (level - start_level) / 100 * self.vehicles_list.get_car_by_vin(vin).battery_power + + Charging.update_chargings(conn, start_at, charge_date, level, co2_per_kw, consumption_kw, vin) conn.commit() except TypeError: logger.debug("battery table is empty") @@ -595,29 +541,19 @@ 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 + +# pylint: disable=arguments-differ class MyPeugeotEncoder(JSONEncoder): + def default(self, mp: MyPSACC): data = dict(mp) mpd = {"proxies": data["_proxies"], "refresh_token": mp.manager.refresh_token, - "client_secret": mp.service_information.client_secret, "abrp":dict(mp.abrp)} - for el in ["client_id", "realm", "remote_refresh_token", "customer_id", "weather_api", "country_code"]: - mpd[el] = data[el] + "client_secret": mp.service_information.client_secret, "abrp": dict(mp.abrp)} + for param in ["client_id", "realm", "remote_refresh_token", "customer_id", "weather_api", "country_code", + "co2_signal_api"]: + mpd[param] = data[param] return mpd diff --git a/MyLogger.py b/mylogger.py similarity index 96% rename from MyLogger.py rename to mylogger.py index a50f1a4..30c8291 100644 --- a/MyLogger.py +++ b/mylogger.py @@ -4,19 +4,19 @@ from logging.handlers import RotatingFileHandler DEBUG_LEVELV_NUM = 9 logging.addLevelName(DEBUG_LEVELV_NUM, "DEBUGV") + def debugv(self, message, *args, **kws): self.log(DEBUG_LEVELV_NUM, message, *args, **kws) logging.Logger.debugv = debugv - +# pylint: disable=invalid-name logger = logging.getLogger("log") def my_logger(file='activity.log', handler_level=logging.INFO): global logger - #logger.setLevel(logging.DEBUG) logger.setLevel(handler_level) formatter = logging.Formatter('%(asctime)s :: %(levelname)s :: %(message)s') file_handler = RotatingFileHandler(file, 'a', 1000000, 1, encoding='utf8') diff --git a/otp/__init__.py b/otp/__init__.py new file mode 100644 index 0000000..43972af --- /dev/null +++ b/otp/__init__.py @@ -0,0 +1 @@ +from .otp import Otp diff --git a/otp/load.py b/otp/load.py index dd22711..1ca1c85 100644 --- a/otp/load.py +++ b/otp/load.py @@ -4,25 +4,25 @@ from time import time from Cryptodome.Cipher import AES -from otp.Tokenizer import Tokenizer +from .tokenizer import Tokenizer -default_token = "0.2.11&&&&&&0&&0&&0&&9f13ba238fbabba08e85d93638e98ef5e48682a9d3e5bc325c3dd6fac8199a6ce09e9b4f373aa6a" \ +DEFAULT_TOKEN = "0.2.11&&&&&&0&&0&&0&&9f13ba238fbabba08e85d93638e98ef5e48682a9d3e5bc325c3dd6fac8199a6ce09e9b4f373aa6a" \ "75a905c3d690f6e3335d1e8e5b748ecec3020a794149033f6ada6896db6d73b8d43b8365bbe15b9ac66f49d4e684a3628f1e" \ "9f3deda0c4e24aba771946e6085b92c5ad312477152acf8db01e6aea4b409d5ac1a05c2fd4e95&&0&&&&&&&&&&&&0&&0&&0&" \ "&0&&0&&0&&0&&&&&&&&0&&0&&0&&0&&0&&2.0.0&&http://m.inwebo.com/&&" -default_version = "529" +DEFAULT_VERSION = "529" -def filterLoad(string: str): +def filter_load(string: str): return string.replace("&", "&") - +# pylint: disable=invalid-name,too-many-instance-attributes,too-many-branches,too-many-statements class IWData: def __init__(self, IW): self.IW = IW - self.tokenizer = Tokenizer(default_token) + self.tokenizer = Tokenizer(DEFAULT_TOKEN) self.tokenizer.nextToken() - self.load1xx(int(default_version), self.tokenizer) + self.load1xx(int(DEFAULT_VERSION), self.tokenizer) def load1xx(self, j, tokenizer): self.iwid = tokenizer.nextToken() @@ -64,8 +64,8 @@ class IWData: i = 0 while i < self.iwsrvn: self.iwsrvid[i] = tokenizer.nextToken() - self.iwsrvname[i] = filterLoad(tokenizer.nextToken()) - self.iwsrvlogo[i] = filterLoad(tokenizer.nextToken()) + self.iwsrvname[i] = filter_load(tokenizer.nextToken()) + self.iwsrvlogo[i] = filter_load(tokenizer.nextToken()) if self.IW.isMac: self.iwsrvconnected[i] = tokenizer.nextTokenI() if j > 515: @@ -77,7 +77,7 @@ class IWData: if i2 < 0 or self.IW.isMac: self.iwsrvurl[i] = "" else: - self.iwsrvurl[i] = filterLoad(tokenizer.nextToken()) + self.iwsrvurl[i] = filter_load(tokenizer.nextToken()) if j < 520 or self.IW.isMac: self.iwsrvonlineotp[i] = 0 else: @@ -106,16 +106,17 @@ class IWData: i4 = 0 while i4 < self.iwmsgn: self.iwmsgid += tokenizer.nextToken() - self.iwmsgtitle += filterLoad(tokenizer.nextToken()) - self.iwmsgcontent += filterLoad(tokenizer.nextToken()) + self.iwmsgtitle += filter_load(tokenizer.nextToken()) + self.iwmsgcontent += filter_load(tokenizer.nextToken()) self.iwmsgack += tokenizer.nextTokenI() i4 += 1 self.iwmajorversion = tokenizer.nextTokenI() - self.iwnewversion = filterLoad(tokenizer.nextToken()) - self.iwnewversionurl = filterLoad(tokenizer.nextToken()) + self.iwnewversion = filter_load(tokenizer.nextToken()) + self.iwnewversionurl = filter_load(tokenizer.nextToken()) self.mustupgrade = False self.datatouch = 0 + # pylint: disable=attribute-defined-outside-init def synchro(self, ixml: dict, key): aes_cipher = AES.new(bytes.fromhex(key), AES.MODE_ECB) value = ixml.get("id") diff --git a/otp/oaep.py b/otp/oaep.py index ff4f90a..23d88d0 100644 --- a/otp/oaep.py +++ b/otp/oaep.py @@ -7,6 +7,7 @@ from Cryptodome.Util.strxor import strxor class MyOAEP(PKCS1OAEP_Cipher): + # pylint: disable=too-many-locals,invalid-name def decrypt(self, ciphertext): """Decrypt a message with PKCS#1 OAEP. diff --git a/otp/Otp.py b/otp/otp.py similarity index 93% rename from otp/Otp.py rename to otp/otp.py index 77d0539..617d879 100644 --- a/otp/Otp.py +++ b/otp/otp.py @@ -1,22 +1,24 @@ import hashlib import traceback +import pickle from secrets import token_hex, token_bytes +from math import ceil +from collections import defaultdict +from xml.etree import cElementTree as ElT import requests from Cryptodome.Cipher import AES from Cryptodome.PublicKey import RSA from Cryptodome import Hash -from math import ceil -from collections import defaultdict -from xml.etree import cElementTree as ElT +from mylogger import logger -from otp import oaep -from otp.load import IWData -import pickle -from MyLogger import logger +from . import oaep +from .load import IWData +# pylint: disable=too-many-instance-attributes,invalid-name + def etree_to_dict(t): d = {t.tag: {} if t.attrib else None} children = list(t) @@ -168,7 +170,7 @@ class Otp: return etree_to_dict(ElT.XML(raw_xml))["ActionFinalize"] except KeyError: logger.debug(raw_xml) - raise ValueError("Bad response from server") + raise ValueError("Bad response from server") from KeyError def activation_start(self): param = {"action": "ActionSetup", "mode": self.mode, "id": self.data.iwid, "lastsync": self.data.iwTsync, @@ -213,7 +215,7 @@ class Otp: try: self.defi = str(xml["defi"]) except KeyError: - raise ConfigException + raise ConfigException from KeyError if "J" in xml: logger.debug("Need another otp request") return Otp.OTP_TWICE @@ -254,7 +256,7 @@ class Otp: password = self.data.iwK1 + ":" + str(self.defi) + ":" + self.data.iwsecval res = bytes(hashlib.sha256(password.encode("utf-8")).digest()) nb = ((int.from_bytes(res[:4], byteorder="big") & 0xfffffff) * 1024) + ( - int.from_bytes(res[4:8], byteorder="big") & 1023) + int.from_bytes(res[4:8], byteorder="big") & 1023) otp = number_to_base36(nb) return otp @@ -300,10 +302,24 @@ def save_otp(obj, filename="otp.bin"): pickle.dump(obj, output) +class RenameUnpickler(pickle.Unpickler): + def find_class(self, module, name): + renamed_module = module + if module == 'otp.Otp': + renamed_module = "otp.otp" + elif module == 'otp.Tokenizer': + renamed_module = "otp.tokenizer" + + return super().find_class(renamed_module, name) + + def load_otp(filename="otp.bin"): try: with open(filename, 'rb') as input_file: - return pickle.load(input_file) + try: + return pickle.load(input_file) + except ModuleNotFoundError: + return RenameUnpickler(input_file).load() except FileNotFoundError: logger.debug(traceback.format_exc()) return None diff --git a/otp/Tokenizer.py b/otp/tokenizer.py similarity index 86% rename from otp/Tokenizer.py rename to otp/tokenizer.py index ce5ac1f..08e4d8c 100644 --- a/otp/Tokenizer.py +++ b/otp/tokenizer.py @@ -1,3 +1,4 @@ +# pylint: disable=invalid-name class Tokenizer: def __init__(self, tokens, delimiter="&&"): self.s: str = tokens @@ -10,7 +11,7 @@ class Tokenizer: index_of = self.currentIndex + self.s[self.currentIndex:].index(self.delimiter) if index_of == -1: substring = self.s[self.currentIndex:] - self.currentIndex = self.s.length() + self.currentIndex = len(self.s) return substring substring2 = self.s[self.currentIndex:index_of] @@ -24,4 +25,4 @@ class Tokenizer: return int(token, 16) def hasMoreTokens(self): - return self.currentIndex < len(self.s) \ No newline at end of file + return self.currentIndex < len(self.s) diff --git a/psa-car-controller.service b/psa-car-controller.service new file mode 100644 index 0000000..f251332 --- /dev/null +++ b/psa-car-controller.service @@ -0,0 +1,14 @@ +# add user with this command: adduser --system --no-create-home psa +[Unit] +Description=PSA Controller +After=network-online.target + +[Service] +ExecStart=/opt/psa_car_controller/server.py -c -r -d +Restart=on-failure +RestartSec=1m +Environment=PYTHONUNBUFFERED=true +User=psa + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/server.py b/server.py index 28ffc6c..0782b03 100755 --- a/server.py +++ b/server.py @@ -3,21 +3,21 @@ import atexit import sys from os import environ from threading import Thread +from getpass import getpass +import argparse from oauth2_client.credentials_manager import OAuthError -from getpass import getpass import web.app -from ChargeControl import ChargeControls -from MyLogger import my_logger -import argparse -from MyLogger import logger -from MyPSACC import MyPSACC +from charge_control import ChargeControls +from mylogger import my_logger +from mylogger import logger +from my_psacc import MyPSACC from utils import is_port_in_use from web.app import start_app, save_config -parser = argparse.ArgumentParser() +CONFIG_NAME = "config.json" def parse_args(): @@ -27,7 +27,7 @@ def parse_args(): parser.add_argument("-c", "--charge-control", help="enable charge control, default charge_config.json", const="charge_config.json", nargs='?', metavar='charge config file') parser.add_argument("-d", "--debug", help="enable debug", const=10, default=20, nargs='?', - metavar='Debug level number') + metavar='Debug level number', type=int) parser.add_argument("-l", "--listen", help="change server listen address", default="127.0.0.1", metavar="IP") parser.add_argument("-p", "--port", help="change server listen port", default="5000") parser.add_argument("-r", "--record", help="save vehicle data to db", action='store_true') @@ -37,32 +37,24 @@ def parse_args(): parser.add_argument("--remote-disable", help="disable remote control", action='store_true') parser.add_argument("--offline", help="offline limited mode", action='store_true') parser.add_argument("-b", "--base-path", help="base path for web app", default="/") - parser.parse_args() - return parser + return parser.parse_args() +# flake8: noqa: C901 if __name__ == "__main__": if sys.version_info < (3, 6): raise RuntimeError("This application requires Python 3.6+") - parser = parse_args() - args = parser.parse_args() - try: - args.debug = int(args.debug) - except ValueError: - pass + args = parse_args() my_logger(handler_level=args.debug) if is_port_in_use(args.listen, int(args.port)): logger.error(" Address already in use") - exit(1) + sys.exit(1) logger.info("server start") if args.config: - config_name = args.config.name - else: - config_name = "config.json" - web.app.myp = MyPSACC.load_config(name=config_name) + CONFIG_NAME = args.config.name + web.app.myp = MyPSACC.load_config(name=CONFIG_NAME) atexit.register(web.app.myp.save_config) - if args.record: - web.app.myp.set_record(True) + web.app.myp.set_record(args.record) if args.offline: logger.info("offline mode") else: @@ -91,7 +83,7 @@ if __name__ == "__main__": t2.setDaemon(True) t2.start() - save_config(web.app.myp, config_name) + save_config(web.app.myp, CONFIG_NAME) t1 = Thread(target=start_app, args=["My car info", args.base_path, logger.level < 20, args.listen, int(args.port)]) t1.setDaemon(True) t1.start() diff --git a/Trip.py b/trip.py similarity index 77% rename from Trip.py rename to trip.py index aaf1758..e58a1a8 100644 --- a/Trip.py +++ b/trip.py @@ -6,13 +6,14 @@ from typing import List, Dict from dateutil import tz from geojson import Feature, FeatureCollection, MultiLineString -from Car import Cars, Car -from MyLogger import logger +from libs.car import Cars, Car +from mylogger import logger from trip_parser import TripParser from web.db import get_db -class Points(): +class Points: + # pylint: disable= too-few-public-methods def __init__(self, latitude, longitude): self.latitude = latitude self.longitude = longitude @@ -22,6 +23,7 @@ class Points(): class Trip: + # pylint: disable= too-many-instance-attributes def __init__(self): self.start_at = None self.end_at = None @@ -105,21 +107,27 @@ class Trips(list): def get_long_trips(self): res = [] - for tr in self: - if tr.consumption > 1.8: - res.append({"speed": tr.speed_average, "consumption_km": tr.consumption_km, "date": tr.start_at, - "consumption": tr.consumption, "consumption_by_temp": tr.get_temperature()}) + for trip in self: + if trip.consumption > 1.8: + res.append({"speed": trip.speed_average, "consumption_km": trip.consumption_km, "date": trip.start_at, + "consumption": trip.consumption, "consumption_by_temp": trip.get_temperature()}) return res - def check_and_append(self, tr: Trip): - if tr.consumption_km <= tr.car.max_elec_consumption and tr.consumption_fuel_km <= tr.car.max_fuel_consumption: - self.append(tr) + def get_distance(self): + return self[-1].mileage - self[0].mileage + + def check_and_append(self, trip: Trip): + if trip.consumption_km <= trip.car.max_elec_consumption and \ + trip.consumption_fuel_km <= trip.car.max_fuel_consumption: + self.append(trip) return True logger.debugv("trip discarded") return False + # flake8: noqa: C901 @staticmethod def get_trips(vehicles_list: Cars) -> Dict[str, Trips]: + # pylint: disable=too-many-locals,too-many-statements,too-many-nested-blocks conn = get_db() vehicles = conn.execute( "SELECT DISTINCT vin FROM position;").fetchall() @@ -134,7 +142,7 @@ class Trips(list): trip_parser = TripParser(car) start = res[0] end = res[1] - tr = Trip() + trip = Trip() # for debugging use this line res = list(map(dict,res)) for x in range(0, len(res) - 2): logger.debugv("%s mileage:%.1f level:%s level_fuel:%s", @@ -154,7 +162,7 @@ class Trips(list): logger.debugv("low speed detected") if restart_trip: start = end - tr = Trip() + trip = Trip() logger.debugv("restart trip at %s mileage:%.1f level:%s level_fuel:%s", start['Timestamp'], start['mileage'], start['level'], start['level_fuel']) else: @@ -183,33 +191,33 @@ class Trips(list): if end_trip: logger.debugv("stop trip at %s mileage:%.1f level:%s level_fuel:%s", end['Timestamp'], end['mileage'], end['level'], end['level_fuel']) - tr.distance = end["mileage"] - start["mileage"] # km - if tr.distance > 0: - tr.start_at = start["Timestamp"] - tr.end_at = end["Timestamp"] - tr.add_points(end["longitude"], end["latitude"]) + trip.distance = end["mileage"] - start["mileage"] # km + if trip.distance > 0: + trip.start_at = start["Timestamp"] + trip.end_at = end["Timestamp"] + trip.add_points(end["longitude"], end["latitude"]) if end["temperature"] is not None and start["temperature"] is not None: - tr.add_temperature(end["temperature"]) - tr.duration = (end["Timestamp"] - start["Timestamp"]).total_seconds() / 3600 - tr.speed_average = tr.distance / tr.duration + trip.add_temperature(end["temperature"]) + trip.duration = (end["Timestamp"] - start["Timestamp"]).total_seconds() / 3600 + trip.speed_average = trip.distance / trip.duration diff_level, diff_level_fuel = trip_parser.get_level_consumption(start, end) - tr.car = car + trip.car = car if diff_level != 0: - tr.set_consumption(diff_level) # kw + trip.set_consumption(diff_level) # kw if diff_level_fuel != 0: - tr.set_fuel_consumption(diff_level_fuel) - tr.mileage = end["mileage"] + trip.set_fuel_consumption(diff_level_fuel) + trip.mileage = end["mileage"] logger.debugv("Trip: %s -> %s %.1fkm %.2fh %.0fkm/h %.2fkWh %.2fkWh/100km %.2fL " "%.2fL/100km %.1fkm", - tr.start_at, tr.end_at, tr.distance, tr.duration, - tr.speed_average, tr.consumption, tr.consumption_km, - tr.consumption_fuel, tr.consumption_fuel_km, tr.mileage) + trip.start_at, trip.end_at, trip.distance, trip.duration, + trip.speed_average, trip.consumption, trip.consumption_km, + trip.consumption_fuel, trip.consumption_fuel_km, trip.mileage) # filter bad value - trips.check_and_append(tr) + trips.check_and_append(trip) start = next_el - tr = Trip() + trip = Trip() else: - tr.add_points(end["longitude"], end["latitude"]) + trip.add_points(end["longitude"], end["latitude"]) end = next_el trips_by_vin[vin] = trips return trips_by_vin diff --git a/trip_parser.py b/trip_parser.py index 4697c88..5917b13 100644 --- a/trip_parser.py +++ b/trip_parser.py @@ -1,6 +1,6 @@ from collections.abc import Callable -from Car import Car -from MyLogger import logger +from libs.car import Car +from mylogger import logger LEVEL = "level" @@ -49,6 +49,7 @@ class TripParser: return True return False + # pylint: disable=unused-argument def __is_refuel(self, start, end, distance): fuel_consumption = self.get_level_consumption(start, end)[1] if fuel_consumption < 0: @@ -65,4 +66,4 @@ class TripParser: # A margin of two is set because battery level can increase with regeneration system or temperature change. # If distance is bigger than 0 but charge bigger than five there is probably missing point and we assume that # regeneration/temperature can't increase by 5 percent the battery level - return decharge < -2 and (distance == 0 or decharge < -5) \ No newline at end of file + return decharge < -2 and (distance == 0 or decharge < -5) diff --git a/utils.py b/utils.py index 7659228..bd54e91 100644 --- a/utils.py +++ b/utils.py @@ -1,11 +1,11 @@ import traceback from functools import wraps from threading import Semaphore, Timer - -import requests import socket -from MyLogger import logger +import requests + +from mylogger import logger def get_temp(latitude: str, longitude: str, api_key: str) -> float: @@ -27,14 +27,14 @@ def get_temp(latitude: str, longitude: str, api_key: str) -> float: def rate_limit(limit, every): - def limit_decorator(fn): + def limit_decorator(func): semaphore = Semaphore(limit) - @wraps(fn) + @wraps(func) def wrapper(*args, **kwargs): semaphore.acquire() try: - return fn(*args, **kwargs) + return func(*args, **kwargs) finally: # don't catch but ensure semaphore release timer = Timer(every, semaphore.release) timer.setDaemon(True) # allows the timer to be canceled on exit diff --git a/web/abrp.py b/web/abrp.py index 88dbc93..23dba19 100644 --- a/web/abrp.py +++ b/web/abrp.py @@ -4,8 +4,8 @@ from datetime import datetime import requests -from Car import Car -from MyLogger import logger +from libs.car import Car +from mylogger import logger class Abrp: diff --git a/web/app.py b/web/app.py index 2a46939..ec428d1 100644 --- a/web/app.py +++ b/web/app.py @@ -1,29 +1,37 @@ import threading +import locale import dash import dash_bootstrap_components as dbc from flask import Flask -import locale from werkzeug import run_simple + try: from werkzeug.middleware.dispatcher import DispatcherMiddleware except ImportError: from werkzeug import DispatcherMiddleware -from ChargeControl import ChargeControls -from MyLogger import logger -from MyPSACC import MyPSACC +from charge_control import ChargeControls +from mylogger import logger +from my_psacc import MyPSACC + +# pylint: disable=invalid-name 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 try: lang = locale.getlocale()[0].split("_")[0] - locale.setlocale(locale.LC_TIME, ".".join(locale.getlocale())) #make sure LC_TIME is set + locale.setlocale(locale.LC_TIME, ".".join(locale.getlocale())) # make sure LC_TIME is set locale_url = [f"https://cdn.plot.ly/plotly-locale-{lang}-latest.js"] except (IndexError, locale.Error): locale_url = None @@ -39,16 +47,10 @@ def start_app(title, base_path, debug: bool, host, port): dash_app = dash.Dash(external_stylesheets=[dbc.themes.BOOTSTRAP], external_scripts=locale_url, title=title, server=app, requests_pathname_prefix=requests_pathname_prefix) # keep this line - import web.views + import web.views # pylint: disable=unused-import,import-outside-toplevel 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..63d6a16 100644 --- a/web/db.py +++ b/web/db.py @@ -1,31 +1,49 @@ import sqlite3 from datetime import datetime -import pytz from typing import Callable +import pytz -from MyLogger import logger +from mylogger import logger callback_fct: Callable[[], None] = lambda: None -default_db_file = 'info.db' +DEFAULT_DB_FILE = 'info.db' +# pylint: disable=invalid-name +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);") + "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 +51,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 +97,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 diff --git a/web/figures.py b/web/figures.py index 485b626..0f005a2 100644 --- a/web/figures.py +++ b/web/figures.py @@ -1,5 +1,5 @@ from copy import deepcopy -from typing import Tuple +from typing import List import dash_bootstrap_components as dbc import dash_table @@ -7,16 +7,18 @@ import numpy as np from dash_core_components import Graph from dash_table.Format import Format, Scheme, Symbol from dateutil.relativedelta import relativedelta -from pandas import DataFrame import plotly.express as px import plotly.graph_objects as go -from Trip import Trips +from pandas import DataFrame from pandas import options as pandas_options import dash_html_components as html +from trip import Trips +from libs.elec_price import ElecPrice -def unix_time_millis(dt): - return int(dt.timestamp()) + +def unix_time_millis(date): + return int(date.timestamp()) def get_marks_from_start_end(start, end): @@ -43,19 +45,23 @@ def get_marks_from_start_end(start, end): return None -consumption_fig = None -consumption_df = None -trips_map = None -consumption_fig_by_speed = None -consumption_graph_by_temp = None -table_fig = None +# pylint: disable=invalid-name +ERROR_DIV = dbc.Alert("No data to show, there is probably no trips recorded yet", color="danger") +PADDING_TOP = {"padding-top": "1em"} +consumption_fig = ERROR_DIV +consumption_df = ERROR_DIV +trips_map = ERROR_DIV +consumption_fig_by_speed = ERROR_DIV +consumption_graph_by_temp = ERROR_DIV +table_fig = ERROR_DIV pandas_options.display.float_format = '${:.2f}'.format info = "" -battery_info = dbc.Alert("No data to show", color="danger") +battery_info = ERROR_DIV battery_table = None -def get_figures(trips: Trips, charging: Tuple[dict]): +# pylint: disable=too-many-locals +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 = [] @@ -72,7 +78,7 @@ def get_figures(trips: Trips, charging: Tuple[dict]): trips_map = px.line_mapbox(lat=lats, lon=lons, hover_name=names, mapbox_style="stamen-terrain", zoom=12) # table - nb_format = Format(precision=2, scheme=Scheme.fixed, symbol=Symbol.yes) + nb_format = Format(precision=2, scheme=Scheme.fixed, symbol=Symbol.yes) # pylint: disable=no-member table_fig = dash_table.DataTable( id='trips-table', sort_action='native', @@ -116,36 +122,42 @@ 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() - except (TypeError, KeyError): # when there is no data yet: + price_kw = (charging_data["price"] / charging_data["kw"]).mean() + total_elec = kw_per_km * trips.get_distance() / 100 + except (TypeError, KeyError, ZeroDivisionError): # when there is no data yet: charge_speed = 0 - - battery_info = dash_table.DataTable( - id='battery_info', - sort_action='native', - columns=[{'id': 'name', 'name': ''}, - {'id': 'value', 'name': ''}], - style_header={'display': 'none'}, - style_data={'border': '0px'}, - data=[{"name": "Average emission:", "value": "{:.1f} g/km".format(co2_per_km)}, - {"name": " ", "value:": "{:.1f} g/kWh".format(co2_per_kw)}, - {"name": "Average charge speed:", "value": "{:.3f} kW".format(charge_speed)}]) - battery_info = html.Div(children=[html.Tr( - [ - html.Td('Average emission:', rowSpan=2), - html.Td("{:.1f} g/km".format(co2_per_km)), - ] - ), + price_kw = 0 + total_elec = 0 + battery_info = html.Div(children=[ + html.Tr( + [ + html.Td('Average emission:', rowSpan=2), + html.Td("{:.1f} g/km".format(co2_per_km))]), html.Tr( [ "{:.1f} g/kWh".format(co2_per_kw), ] ), - html.Tr( - [ - html.Td("Average charge speed:"), - html.Td("{:.3f} kW".format(charge_speed)) - ] - ) + html.Tr([ + html.Td("Average charge speed:", style=PADDING_TOP), + html.Td("{:.3f} kW".format(charge_speed)) + ]), + html.Tr(html.Td(" ", colSpan=2)), + html.Tr([ + html.Td('Average Price:', rowSpan=2, style=PADDING_TOP), + html.Td("{:.2f} {}/100km".format(price_kw * kw_per_km, ElecPrice.currency)), + ]), + html.Tr([ + "{:.2f} {}/kWh".format(price_kw, ElecPrice.currency), + ]), + html.Tr(html.Td(" ", colSpan=2)), + html.Tr([ + html.Td('Electricity consumption:', rowSpan=2, style=PADDING_TOP), + html.Td("{:.0f} kWh".format(total_elec)), + ]), + html.Tr([ + "{:.0f} {}".format(total_elec * price_kw, ElecPrice.currency), + ]), ]) battery_table = dash_table.DataTable( @@ -159,8 +171,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)}], - data=charging, + '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), 'editable': True} + ], + data=charging ) 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..39179f7 100644 --- a/web/views.py +++ b/web/views.py @@ -1,59 +1,106 @@ import json import traceback from datetime import datetime, timezone +from typing import List + import dash_bootstrap_components as dbc -from dash.dependencies import Output, Input, MATCH +from dash.dependencies import Output, Input, MATCH, State +from dash.exceptions import PreventUpdate import dash_core_components as dcc import dash_html_components as html import dash_daq as daq - -from MyLogger import logger +import pandas as pd from flask import jsonify, request, Response as FlaskResponse -from MyPSACC import MyPSACC -from Trip import Trips +from mylogger import logger + +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 + +# pylint: disable=invalid-name RESPONSE = "-response" - EMPTY_DIV = "empty-div" - ABRP_SWITCH = 'abrp-switch' +CALLBACK_CREATED = False -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 -@dash_app.callback(Output('trips_map', 'figure'), - Output('consumption_fig', 'figure'), - Output('consumption_fig_by_speed', 'figure'), - Output('consumption_graph_by_temp', 'children'), - Output('consumption', 'children'), - Output('tab_trips', 'children'), - Output('tab_battery', 'children'), - Output('tab_charge', 'children'), - Output('date-slider', 'max'), - Output('date-slider', 'step'), - Output('date-slider', 'marks'), - Input('date-slider', 'value')) -def display_value(value): - mini = datetime.fromtimestamp(value[0], tz=timezone.utc) - maxi = datetime.fromtimestamp(value[1], tz=timezone.utc) - filtered_trips = Trips() - for trip in trips: - if mini <= trip.start_at <= maxi: - filtered_trips.append(trip) - filtered_chargings = MyPSACC.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, \ - figures.consumption_graph_by_temp, consumption, figures.table_fig, figures.battery_info, \ - figures.battery_table, max_millis, step, marks +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.dropna(inplace=True) + for change in row.iteritems(): + changes.append( + { + row_id_name: data[idx][row_id_name], + "column_name": change[0], + "current_value": change[1], + "previous_value": df_previous.at[idx, change[0]], + } + ) + return changes + + +def create_callback(): + global CALLBACK_CREATED + if not CALLBACK_CREATED: + @dash_app.callback(Output('trips_map', 'figure'), + Output('consumption_fig', 'figure'), + Output('consumption_fig_by_speed', 'figure'), + Output('consumption_graph_by_temp', 'children'), + Output('consumption', 'children'), + Output('tab_trips', 'children'), + Output('tab_battery', 'children'), + Output('tab_charge', 'children'), + Output('date-slider', 'max'), + Output('date-slider', 'step'), + Output('date-slider', 'marks'), + Input('date-slider', 'value')) + def display_value(value): # pylint: disable=unused-variable + mini = datetime.fromtimestamp(value[0], tz=timezone.utc) + maxi = datetime.fromtimestamp(value[1], tz=timezone.utc) + filtered_trips = Trips() + for trip in trips: + if mini <= trip.start_at <= maxi: + filtered_trips.append(trip) + 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, \ + figures.consumption_graph_by_temp, consumption, figures.table_fig, figures.battery_info, \ + 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(timestamp, data, data_previous): # pylint: disable=unused-variable + if timestamp is None: + raise PreventUpdate + diff_data = diff_dashtable(data, data_previous, "start_at") + for changed_line in diff_data: + if changed_line['column_name'] == 'price': + if not set_chargings_price(get_db(), changed_line['start_at'], changed_line['current_value']): + logger.error("Can't find line to update in the database") + return "" + + CALLBACK_CREATED = True @dash_app.callback(Output({'role': ABRP_SWITCH + RESPONSE, 'vin': MATCH}, 'children'), @@ -128,7 +175,7 @@ def get_position(vin): # Set a battery threshold and schedule an hour to stop the charge @app.route('/charge_control') -def charge_control(): +def get_charge_control(): logger.info(request) vin = request.args['vin'] charge_control = chc.get(vin) @@ -176,7 +223,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 @@ -202,6 +249,7 @@ def __get_control_tabs(): 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}, @@ -219,46 +267,50 @@ def serve_layout(): logger.debug("Create new layout") try: figures.get_figures(trips, chargings) - data_div = html.Div([dcc.RangeSlider( + summary_tab = [html.H2(id="consumption", + children=figures.info), + dcc.Graph(figure=figures.consumption_fig, id="consumption_fig"), + dcc.Graph(figure=figures.consumption_fig_by_speed, id="consumption_fig_by_speed"), + figures.consumption_graph_by_temp] + maps = dcc.Graph(figure=figures.trips_map, id="trips_map", style={"height": '90vh'}) + create_callback() + range_slider = dcc.RangeSlider( id='date-slider', min=min_millis, max=max_millis, step=step, marks=marks, value=[min_millis, max_millis], - ), - html.Div([ - dbc.Tabs([ - dbc.Tab(label="Summary", tab_id="summary", children=[ - html.H2(id="consumption", - children=figures.info), - dcc.Graph(figure=figures.consumption_fig, id="consumption_fig"), - dcc.Graph(figure=figures.consumption_fig_by_speed, id="consumption_fig_by_speed"), - figures.consumption_graph_by_temp - ]), - dbc.Tab(label="Trips", tab_id="trips", id="tab_trips", children=[figures.table_fig]), - dbc.Tab(label="Battery", tab_id="battery", id="tab_battery", children=[figures.battery_info]), - dbc.Tab(label="Charge", tab_id="charge", id="tab_charge", children=[figures.battery_table]), - dbc.Tab(label="Map", tab_id="map", children=[ - dcc.Graph(figure=figures.trips_map, id="trips_map", style={"height": '90vh'})]), - dbc.Tab(label="Control", tab_id="control", children=dbc.Tabs(id="control-tabs", - children=__get_control_tabs())) - ], - id="tabs", - active_tab="summary", - ), - html.Div(id=EMPTY_DIV), - ])]) + ) except (IndexError, TypeError, NameError): + summary_tab = figures.ERROR_DIV + maps = figures.ERROR_DIV logger.warning("Failed to generate figure, there is probably not enough data yet") logger.debug(traceback.format_exc()) - data_div = ERROR_DIV + range_slider = html.Div() + data_div = html.Div([ + range_slider, + html.Div([ + dbc.Tabs([ + dbc.Tab(label="Summary", tab_id="summary", children=summary_tab), + dbc.Tab(label="Trips", tab_id="trips", id="tab_trips", children=[figures.table_fig]), + dbc.Tab(label="Battery", tab_id="battery", id="tab_battery", children=[figures.battery_info]), + dbc.Tab(label="Charge", tab_id="charge", id="tab_charge", children=[figures.battery_table]), + 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()))], + id="tabs", + active_tab="summary", + persistence=True), + html.Div(id=EMPTY_DIV), + ])]) cached_layout = dbc.Container(fluid=True, children=[html.H1('My car info'), data_div]) return cached_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())