diff --git a/.gitignore b/.gitignore index 2f69f23..7b2f69c 100644 --- a/.gitignore +++ b/.gitignore @@ -139,6 +139,11 @@ cython_debug/ .idea/ backup.ab +*.apk +info.db +otp.bin charge_config1.json config.json -test.json \ No newline at end of file +test.json +charge_config.json +cars.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7d457fb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,5 @@ +repos: +- repo: https://github.com/PyCQA/prospector + rev: 1.3.0 # The version of Prospector to use, at least 1.1.7 + hooks: + - id: prospector diff --git a/.prospector.yaml b/.prospector.yaml new file mode 100644 index 0000000..3a91988 --- /dev/null +++ b/.prospector.yaml @@ -0,0 +1,3 @@ +doc-warnings: false +ignore-paths: + - psa_connectedcar diff --git a/Car.py b/Car.py new file mode 100644 index 0000000..8788f82 --- /dev/null +++ b/Car.py @@ -0,0 +1,121 @@ +import json +from copy import copy + +from MyLogger import logger + +ENERGY_CAPACITY = {'SUV 3008': {'BATTERY_POWER': 10.8, 'FUEL_CAPACITY': 43}, + 'C5 Aircross': {'BATTERY_POWER': 10.8, 'FUEL_CAPACITY': 43}, + 'e-208': {'BATTERY_POWER': 46, 'FUEL_CAPACITY': 0}, + 'e-2008': {'BATTERY_POWER': 46, 'FUEL_CAPACITY': 0} + } +DEFAULT_BATTERY_POWER = 46 +DEFAULT_FUEL_CAPACITY = 0 +DEFAULT_MAX_ELEC_CONSUMPTION = 70 +DEFAULT_MAX_FUEL_CONSUMPTION = 30 +CARS_FILE = "cars.json" + +class Car: + def __init__(self, vin, vehicle_id, brand, label="unknown", battery_power=None, fuel_capacity=None, + max_elec_consumption=None, max_fuel_consumption=None): + self.vin = vin + self.vehicle_id = vehicle_id + self.label = label + self.brand = brand + self.battery_power = None + self.fuel_capacity = None + self.max_elec_consumption = 0 # kwh/100Km + self.max_fuel_consumption = 0 # L/100Km + self.set_energy_capacity(battery_power, fuel_capacity, max_elec_consumption, max_fuel_consumption) + self.status = None + + def set_energy_capacity(self, battery_power=None, fuel_capacity=None, max_elec_consumption=None, + max_fuel_consumption=None): + if battery_power is not None and fuel_capacity is not None: + self.battery_power = battery_power + self.fuel_capacity = fuel_capacity + elif self.label in ENERGY_CAPACITY: + self.battery_power = ENERGY_CAPACITY[self.label]["BATTERY_POWER"] + self.fuel_capacity = ENERGY_CAPACITY[self.label]["FUEL_CAPACITY"] + else: + logger.warning("Can't get car model please check %s", CARS_FILE) + self.battery_power = DEFAULT_BATTERY_POWER + self.fuel_capacity = DEFAULT_FUEL_CAPACITY + if self.is_electric(): + self.max_fuel_consumption = 0 + else: + self.max_fuel_consumption = max_fuel_consumption or DEFAULT_MAX_FUEL_CONSUMPTION + if self.is_thermal(): + self.max_elec_consumption = 0 + else: + self.max_elec_consumption = max_elec_consumption or DEFAULT_MAX_ELEC_CONSUMPTION + + def is_electric(self) -> bool: + return self.fuel_capacity == 0 and self.battery_power > 0 + + def is_thermal(self) -> bool: + return self.fuel_capacity > 0 and self.battery_power == 0 + + def is_hybrid(self) -> bool: + return self.fuel_capacity > 0 and self.battery_power > 0 + + def get_status(self): + if self.status is not None: + return self.status + logger.error("status of %s is None", self.vin) + raise ValueError("status of %s is None") + + @classmethod + def from_json(cls, data: dict): + return cls(**data) + + def to_dict(self): + car_dict = copy(self.__dict__) + car_dict.pop("status") + return car_dict + + def __str__(self): + return str(self.to_dict()) + + +class Cars(list): + def __init__(self, *args): + list.__init__(self, *args) + + def get_car_by_vin(self, vin) -> Car: + for car in self: + if car.vin == vin: + return car + return None + + def get_car_by_id(self, vehicle_id) -> Car: + for car in self: + if car.vehicle_id == vehicle_id: + return car + return None + + def add(self, car: Car): + if self.get_car_by_id(car.vehicle_id) is None: + self.append(car) + + @classmethod + def from_json(cls, data: list): + cars = list(map(Car.from_json, data)) + return cls(cars) + + def __str__(self): + return str(list(map(str, self))) + + def save_cars(self, name=CARS_FILE): + 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) + + @staticmethod + def load_cars(name=CARS_FILE): + try: + with open(name, "r") as f: + json_str = f.read() + return Cars.from_json(json.loads(json_str)) + except (FileNotFoundError, TypeError) as e: + logger.debug(e) + return Cars() diff --git a/ChargeControl.py b/ChargeControl.py index e8e8cf1..31e6e39 100644 --- a/ChargeControl.py +++ b/ChargeControl.py @@ -10,11 +10,15 @@ import pytz from MyPSACC import MyPSACC from MyLogger import logger -from psa_connectedcar.rest import ApiException + +DISCONNECTED = "Disconnected" +INPROGRESS = "InProgress" +FAILURE = "Failure" +STOPPED = "Stopped" +FINISHED = "Finished" class ChargeControl: - periodicity = 120 MQTT_TIMEOUT = 60 def __init__(self, psacc: MyPSACC, vin, percentage_threshold, stop_hour): @@ -23,8 +27,6 @@ class ChargeControl: self.set_stop_hour(stop_hour) self.psacc = psacc self.retry_count = 0 - self.thread: threading.Timer = None - self.always_check = True def set_stop_hour(self, stop_hour): if stop_hour is None or stop_hour == [0, 0]: @@ -36,95 +38,106 @@ class ChargeControl: if self._next_stop_hour < datetime.now(): self._next_stop_hour += timedelta(days=1) - def start(self): - periodicity = ChargeControl.periodicity + def get_stop_hour(self): + return self._stop_hour + + def control_charge_with_ack(self, charge: bool): + self.psacc.charge_now(self.vin, charge) + self.retry_count += 1 + sleep(ChargeControl.MQTT_TIMEOUT) + vehicle_status = self.psacc.get_vehicle_info(self.vin) + status = vehicle_status.get_energy('Electric').charging.status + if status in (FINISHED, DISCONNECTED): + logger.warning("Car state isn't compatible with charging %s", status) + if (status == INPROGRESS) != charge: + logger.warning("retry to control the charge of %s", self.vin) + self.psacc.charge_now(self.vin, charge) + self.retry_count += 1 + return False + self.retry_count = 0 + return True + + def force_update(self): + # force update if the car doesn't send info during 10 minutes + last_update = self.psacc.vehicles_list.get_car_by_vin(self.vin).get_status().get_energy('Electric').updated_at + if (datetime.utcnow().replace(tzinfo=pytz.UTC) - last_update).total_seconds() > 60 * 10: + self.psacc.wakeup(self.vin) + + def process(self): now = datetime.now() try: - if self._next_stop_hour is not None and self._next_stop_hour < now: - stop_charge = True - self._next_stop_hour += timedelta(days=1) - logger.info("it's time to stop the charge") - else: - stop_charge = False - - if self.percentage_threshold != 100 or stop_charge or self.always_check: - res = None - try: - res = self.psacc.get_vehicle_info(self.vin) - except ApiException: - logger.error(traceback.format_exc()) - if res is not None: - status = res.energy[0].charging.status - level = res.energy[0].level - logger.info(f"charging status of {self.vin} is {status}, battery level: {level}") - if status == "InProgress": - # force update if the car doesn't send info during 10 minutes - last_update = res.energy[0].updated_at - if (datetime.utcnow().replace(tzinfo=pytz.UTC) - last_update).total_seconds() > 60 * 10: - self.psacc.wakeup(self.vin) - if (level >= self.percentage_threshold and self.retry_count < 2) or stop_charge: - self.psacc.charge_now(self.vin, False) - self.retry_count += 1 - sleep(ChargeControl.MQTT_TIMEOUT) - res = self.psacc.get_vehicle_info(self.vin) - status = res.energy[0].charging.status - if status == "InProgress": - logger.warn(f"retry to stop the charge of {self.vin}") - self.psacc.charge_now(self.vin, False) - self.retry_count += 1 - if self._next_stop_hour is not None: - next_in_second = (self._next_stop_hour - now).total_seconds() - if next_in_second < periodicity: - periodicity = next_in_second + vehicle_status = self.psacc.vehicles_list.get_car_by_vin(self.vin).get_status() + status = vehicle_status.get_energy('Electric').charging.status + level = vehicle_status.get_energy('Electric').level + logger.info("charging status of %s is %s, battery level: %d", self.vin, status, level) + if status == "InProgress": + self.force_update() + if level >= self.percentage_threshold and self.retry_count < 2: + logger.info("Charge threshold is reached, stop the charge") + self.control_charge_with_ack(False) + elif self._next_stop_hour is not None: + if self._next_stop_hour < now: + self._next_stop_hour += timedelta(days=1) + logger.info("it's time to stop the charge") + self.control_charge_with_ack(False) else: - self.retry_count = 0 - else: - logger.error(f"error when get vehicle info of {self.vin}") + next_in_second = (self._next_stop_hour - now).total_seconds() + if next_in_second < self.psacc.info_refresh_rate: + periodicity = next_in_second + thread = threading.Timer(periodicity, self.process) + thread.setDaemon(True) + thread.start() + else: + if self._next_stop_hour < now: + self._next_stop_hour += timedelta(days=1) + self.retry_count = 0 + except AttributeError: + logger.error("Probably can't retrieve all information from API: %s", traceback.format_exc()) except: logger.error(traceback.format_exc()) - self.thread = threading.Timer(periodicity, self.start) - self.thread.start() def get_dict(self): chd = copy(self.__dict__) chd.pop("psacc") - chd.pop("thread") return chd -class ChargeControls: +class ChargeControls(dict): def __init__(self): - self.list: dict = {} - self._confighash = None + super().__init__() + self._config_hash = None def save_config(self, name="charge_config.json", force=False): chd = {} - for key, el in self.list.items(): - chd[el.vin] = {"percentage_threshold": el.percentage_threshold, "stop_hour": el._stop_hour} + charge_control: ChargeControl + for charge_control in self.values(): + chd[charge_control.vin] = {"percentage_threshold": charge_control.percentage_threshold, + "stop_hour": charge_control.get_stop_hour()} config_str = json.dumps(chd, sort_keys=True, indent=4).encode('utf-8') new_hash = md5(config_str).hexdigest() - if force or self._confighash != new_hash: + if force or self._config_hash != new_hash: with open(name, "wb") as f: f.write(config_str) - self._confighash = new_hash + self._config_hash = new_hash logger.info("save config change") + @staticmethod def load_config(psacc: MyPSACC, name="charge_config.json"): with open(name, "r") as f: - str = f.read() - chd = json.loads(str) + config_str = f.read() + chd = json.loads(config_str) charge_control_list = ChargeControls() for vin, el in chd.items(): - charge_control_list.list[vin] = ChargeControl(psacc, vin, **el) + charge_control_list[vin] = ChargeControl(psacc, vin, **el) return charge_control_list def get(self, vin) -> ChargeControl: try: - return self.list[vin] + return self[vin] except KeyError: - return None + pass - def start(self): - for vin, charge_control in self.list.items(): - charge_control.start() + def init(self): + for charge_control in self.values(): + charge_control.psacc.info_callback.append(charge_control.process) diff --git a/MyLogger.py b/MyLogger.py index 413f88f..a50f1a4 100644 --- a/MyLogger.py +++ b/MyLogger.py @@ -1,13 +1,23 @@ import logging 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 + logger = logging.getLogger("log") def my_logger(file='activity.log', handler_level=logging.INFO): global logger - logger.setLevel(logging.DEBUG) + #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') file_handler.setLevel(handler_level) diff --git a/MyPSACC.py b/MyPSACC.py index 53a6d4f..c317c32 100644 --- a/MyPSACC.py +++ b/MyPSACC.py @@ -10,26 +10,26 @@ from json import JSONEncoder from hashlib import md5 from time import sleep -import requests from oauth2_client.credentials_manager import CredentialManager, ServiceInformation import paho.mqtt.client as mqtt from requests import Response -from typing import List +from typing import Tuple import psa_connectedcar as psac -from Trip import Trip +from Car import Cars, Car from ecomix import Ecomix -from otp.Otp import load_otp, new_otp_session, save_otp +from otp.Otp import load_otp, new_otp_session, save_otp, ConfigException, Otp from psa_connectedcar import ApiClient from psa_connectedcar.rest import ApiException from MyLogger import logger -from threading import Semaphore, Timer -from functools import wraps -import sqlite3 -from web.db import get_db +from utils import get_temp, rate_limit +from web.db import get_db, clean_position +from geojson import Feature, Point, FeatureCollection +from geojson import dumps as geo_dumps -BATTERY_POWER = 46 +PSA_CORRELATION_DATE_FORMAT = "%Y%m%d%H%M%S%f" +PSA_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" oauhth_url = {"clientsB2CPeugeot": "https://idpcvs.peugeot.com/am/oauth2/access_token", "clientsB2CCitroen": "https://idpcvs.citroen.com/am/oauth2/access_token", @@ -45,25 +45,18 @@ MQTT_REQ_TOPIC = "psa/RemoteServices/from/cid/" MQTT_RESP_TOPIC = "psa/RemoteServices/to/cid/" MQTT_EVENT_TOPIC = "psa/RemoteServices/events/MPHRTServices/" MQTT_TOKEN_TTL = 890 +CARS_FILE = "cars.json" -def rate_limit(limit, every): - def limit_decorator(fn): - semaphore = Semaphore(limit) +# add method to class Energy +def get_energy(self, energy_type): + for energy in self._energy: + if energy.type == energy_type: + return energy + return psac.models.energy.Energy(charging=psac.models.energy_charging.EnergyCharging()) - @wraps(fn) - def wrapper(*args, **kwargs): - semaphore.acquire() - try: - return fn(*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 - timer.start() - return wrapper - - return limit_decorator +psac.models.status.Status.get_energy = get_energy class OpenIdCredentialManager(CredentialManager): @@ -101,7 +94,7 @@ class OauthAPIClient(ApiClient): response_type=None, auth_settings=None, async_req=None, _return_http_data_only=None, collection_formats=None, _preload_content=True, _request_timeout=None): - for x in range(0, 2): + for _ in range(0, 2): try: if not async_req: return self._ApiClient__call_api(resource_path, method, @@ -110,16 +103,14 @@ class OauthAPIClient(ApiClient): response_type, auth_settings, _return_http_data_only, collection_formats, _preload_content, _request_timeout) - else: - thread = 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)) - return thread + 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() @@ -127,8 +118,8 @@ class OauthAPIClient(ApiClient): raise e -def correlation_id(date): - date_str = date.strftime("%Y%m%d%H%M%S%f")[:-3] +def gen_correlation_id(date): + date_str = date.strftime(PSA_CORRELATION_DATE_FORMAT)[:-3] uuid_str = str(uuid.uuid4()).replace("-", "") correlation_id = uuid_str + date_str return correlation_id @@ -140,7 +131,8 @@ class MyPSACC: def connect(self, user, password): self.manager.init_with_user_credentials(user, password, self.realm) - def __init__(self, refresh_token, client_id, client_secret, remote_refresh_token, customer_id, realm, proxies=None, weather_api = None): + def __init__(self, refresh_token, client_id, client_secret, remote_refresh_token, customer_id, realm, country_code, + proxies=None, weather_api=None): self.realm = realm self.service_information = ServiceInformation(authorize_service, oauhth_url[self.realm], @@ -154,10 +146,10 @@ class MyPSACC: self.manager.refresh_token = refresh_token self.remote_refresh_token = remote_refresh_token self.remote_access_token = None - self.vehicles_list = None - self.setProxies(proxies) + self.vehicles_list = Cars.load_cars(CARS_FILE) + self.set_proxies(proxies) self.customer_id = customer_id - self._confighash = None + self._config_hash = None self.api_config.verify_ssl = False self.api_config.api_key['client_id'] = self.client_id self.api_config.api_key['x-introspect-realm'] = self.realm @@ -170,6 +162,11 @@ class MyPSACC: self._record_enabled = False self.otp = None self.weather_api = weather_api + self.country_code = country_code + self.mqtt_client = None + self.precond_programs = {} + self.info_callback = [] + self.info_refresh_rate = 120 def refresh_token(self): self.manager._refresh_token() @@ -179,7 +176,7 @@ class MyPSACC: api_instance = psac.VehiclesApi(OauthAPIClient(self.api_config)) return api_instance - def setProxies(self, proxies): + def set_proxies(self, proxies): if proxies is None: self._proxies = dict(http='', https='') self.api_config.proxy = None @@ -187,45 +184,57 @@ class MyPSACC: self._proxies = proxies self.api_config.proxy = proxies['http'] self.manager.proxies = self._proxies + Otp.set_proxies(proxies) def get_vehicle_info(self, vin): - res = self.api().get_vehicle_status(self.get_vehicle_id_with_vin(vin), extension=["odometer"]) - # retry - if res is None: - res = self.api().get_vehicle_status(self.get_vehicle_id_with_vin(vin), extension=["odometer"]) - if self._record_enabled: - self.record_info(vin, res) + res = None + car = self.vehicles_list.get_car_by_vin(vin) + for _ in range(0, 2): + try: + res = self.api().get_vehicle_status(car.vehicle_id, extension=["odometer"]) + if res is not None: + if self._record_enabled: + self.record_info(vin, res) + break + except ApiException: + logger.error(traceback.format_exc()) + car.status = res return res + def refresh_vehicle_info(self): + if self.info_refresh_rate is not None: + while True: + sleep(self.info_refresh_rate) + logger.info("refresh_vehicle_info") + for car in self.vehicles_list: + self.get_vehicle_info(car.vin) + for callback in self.info_callback: + callback() + # monitor doesn't seem to work - def newMonitor(self, vin, body): - res = self.manager.post("https://api.groupe-psa.com/connectedcar/v4/user/vehicles/" + self.vehicles_list[vin][ - "id"] + "/status?client_id=" + self.client_id, headers=self.headers, data=body) + def new_monitor(self, vin, body): + res = self.manager.post("https://api.groupe-psa.com/connectedcar/v4/user/vehicles/" + + self.vehicles_list.get_car_by_vin(vin).id + "/status?client_id=" + self.client_id, + headers=self.headers, data=body) data = res.json() return data def get_vehicles(self): - res = self.api().get_vehicles_by_device() - self.vehicles_list = {} - for vehicle in res.embedded.vehicles: - vin = vehicle.vin - self.vehicles_list[vin] = {"id": vehicle.id} + try: + res = self.api().get_vehicles_by_device() + for vehicle in res.embedded.vehicles: + self.vehicles_list.add(Car(vehicle.vin, vehicle.id, vehicle.brand, vehicle.label)) + self.vehicles_list.save_cars() + except ApiException: + logger.error(traceback.format_exc()) return self.vehicles_list - def get_vehicle_id_with_vin(self, vin): - return self.vehicles_list[vin]["id"] - - def getVIN(self): - if self.vehicles_list is None: - self.get_vehicles() - return list(self.vehicles_list.keys()) - - def load_otp(self): + def load_otp(self, force_new=False): otp_session = load_otp() - if otp_session is None: + if otp_session is None or force_new: self.get_sms_otp_code() - otp_session = new_otp_session() - return otp_session + otp_session = new_otp_session(otp_session) + self.otp = otp_session def get_sms_otp_code(self): res = self.manager.post( @@ -239,8 +248,12 @@ class MyPSACC: # 6 otp by day @rate_limit(6, 3600 * 24) - def getOtpCode(self): - otp_code = self.otp.getOtpCode() + def get_otp_code(self): + try: + otp_code = self.otp.get_otp_code() + except ConfigException: + self.load_otp(force_new=True) + otp_code = self.otp.get_otp_code() save_otp(self.otp) return otp_code @@ -257,81 +270,74 @@ class MyPSACC: if not force and self.remote_token_last_update is not None: last_update: datetime = self.remote_token_last_update if (datetime.now() - last_update).total_seconds() < MQTT_TOKEN_TTL: - return + return None self.manager._refresh_token() + 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, json={"grant_type": "refresh_token", "refresh_token": self.remote_refresh_token}, headers=self.headers) data = res.json() - logger.debug(f"refresh_remote_token: {data}") + logger.debug("refresh_remote_token: %s", data) if "access_token" in data: self.remote_access_token = data["access_token"] self.remote_refresh_token = data["refresh_token"] self.remote_token_last_update = datetime.now() else: - logger.error(f"can't refresh_remote_token: {data}\n Create a new one") + logger.error("can't refresh_remote_token: %s\n Create a new one", data) self.remote_token_last_update = datetime.now() - otp_code = self.getOtpCode() + otp_code = self.get_otp_code() res = self.get_remote_access_token(otp_code) self.mqtt_client.username_pw_set("IMA_OAUTH_ACCESS_TOKEN", self.remote_access_token) return res - def on_mqtt_connect(self, client, userdata, rc, a): - try: - logger.info("Connected with result code " + str(rc)) - topics = [MQTT_RESP_TOPIC + self.customer_id + "/#"] - for vin in self.getVIN(): - topics.append(MQTT_EVENT_TOPIC + vin) - for topic in topics: - client.subscribe(topic) - logger.info("subscribe to " + topic) - except: - logger.error(traceback.format_exc()) + logger.info("Connected with result code %s", rc) + topics = [MQTT_RESP_TOPIC + self.customer_id + "/#"] + for car in self.vehicles_list: + topics.append(MQTT_EVENT_TOPIC + car.vin) + for topic in topics: + client.subscribe(topic) + logger.info("subscribe to %s", topic) def on_mqtt_disconnect(self, client, userdata, rc): - try: - logger.warn("Disconnected with result code " + str(rc)) - if rc == 1: - self.refresh_remote_token(force=True) - else: - logger.warn(mqtt.error_string(rc)) - except: - logger.error(traceback.format_exc()) + logger.warning("Disconnected with result code %d", rc) + if rc == 1: + self.refresh_remote_token(force=True) + else: + logger.warning(mqtt.error_string(rc)) def on_mqtt_message(self, client, userdata, msg): - charge_not_detected = False try: - logger.info(f"mqtt msg {msg.topic} {str(msg.payload)}") + logger.info("mqtt msg %s %s", msg.topic, msg.payload) data = json.loads(msg.payload) + charge_info = None if msg.topic.startswith(MQTT_RESP_TOPIC): - if "return_code" in data: - if data["return_code"] == "0": - pass - elif data["return_code"] == "400": - self.refresh_remote_token(force=True) - logger.error("retry last request, token was expired") - else: - logger.error(f'{data["return_code"]} : {data["reason"]}') - else: + if "return_code" not in data: logger.debug("mqtt msg hasn't return code") - if msg.topic.startswith(MQTT_EVENT_TOPIC): - if data["charging_state"]['remaining_time'] != 0 and data["charging_state"]['rate'] == 0: - charge_not_detected = True - elif msg.topic.endswith("/VehicleState"): - if data["resp_data"]["charging_state"]['remaining_time'] != 0 \ - and data["resp_data"]["charging_state"]['rate'] == 0: - charge_not_detected = True - if charge_not_detected: + elif data["return_code"] == "400": + self.refresh_remote_token(force=True) + logger.error("retry last request, token was expired") + elif data["return_code"] == "300": + logger.error('%d', data["return_code"]) + elif data["return_code"] != "0": + logger.error('%s : %s', data["return_code"], data["reason"]) + if msg.topic.endswith("/VehicleState"): + charge_info = data["resp_data"]["charging_state"] + self.precond_programs[data["vin"]] = data["resp_data"]["precond_state"]["programs"] + elif msg.topic.startswith(MQTT_EVENT_TOPIC): + charge_info = data["charging_state"] + if charge_info is not None and charge_info['remaining_time'] != 0 and charge_info['rate'] == 0: # fix a psa server bug where charge beginning without status api being properly updated - logger.info("charge begin") + logger.warning("charge begin but API isn't updated") sleep(60) self.wakeup(data["vin"]) - except: + except KeyError: logger.error(traceback.format_exc()) def start_mqtt(self): - self.otp = self.load_otp() + self.load_otp() self.mqtt_client = mqtt.Client(clean_session=True, protocol=mqtt.MQTTv311) self.refresh_remote_token() self.mqtt_client.tls_set_context() @@ -345,19 +351,18 @@ class MyPSACC: def __keep_mqtt(self): # avoid token expiration timeout = 3600 * 24 # 1 day - try: - self.get_state(list(self.vehicles_list.keys())[0]) - except: - logger.warn("keep_mqtt error") - threading.Timer(timeout, self.__keep_mqtt).start() + if len(self.vehicles_list) > 0: + self.get_state(self.vehicles_list[0].vin) + t = threading.Timer(timeout, self.__keep_mqtt) + t.setDaemon(True) + t.start() def mqtt_request(self, vin, req_parameters): self.refresh_token() date = datetime.utcnow() - date_f = "%Y-%m-%dT%H:%M:%SZ" - date_str = date.strftime(date_f) + date_str = date.strftime(PSA_DATE_FORMAT) data = {"access_token": self.remote_access_token, "customer_id": self.customer_id, - "correlation_id": correlation_id(date), "req_date": date_str, "vin": vin, + "correlation_id": gen_correlation_id(date), "req_date": date_str, "vin": vin, "req_parameters": req_parameters} return json.dumps(data) @@ -365,7 +370,7 @@ class MyPSACC: 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.energy[0].charging.next_delayed_time + hour_str = data.get_energy('Electric').charging.next_delayed_time try: hour = re.findall(reg, hour_str)[0] h = int(hour[0]) @@ -376,11 +381,11 @@ class MyPSACC: return h, m except IndexError: logger.error(traceback.format_exc()) - logger.error(f"Can't get charge hour: {hour_str}") + logger.error("Can't get charge hour: %s", hour_str) def get_charge_status(self, vin): data = self.get_vehicle_info(vin) - status = data.energy[0].charging.status + status = data.get_energy('Electric').charging.status return status def veh_charge_request(self, vin, hour, miinute, charge_type): @@ -415,15 +420,15 @@ class MyPSACC: @rate_limit(3, 60 * 20) def wakeup(self, vin): - logger.info("ask wakeup to " + vin) + logger.info("ask wakeup to %s", vin) msg = self.mqtt_request(vin, {"action": "state"}) logger.info(msg) self.mqtt_client.publish(MQTT_REQ_TOPIC + self.customer_id + "/VehCharge/state", msg) return True - #get state from server by mqtt + # get state from server by mqtt def get_state(self, vin): - logger.info("ask state to " + vin) + logger.info("ask state to %s", vin) msg = self.mqtt_request(vin, {"action": "state"}) logger.info(msg) self.mqtt_client.publish(MQTT_REQ_TOPIC + self.customer_id + "/VehicleState", msg) @@ -445,11 +450,18 @@ class MyPSACC: value = "activate" else: value = "deactivate" - msg = self.mqtt_request(vin, {"asap": value, "programs": { - "program1": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0}, - "program2": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0}, - "program3": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0}, - "program4": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0}}}) + self.get_state(vin) + sleep(2) # wait for rep + if vin in self.precond_programs: + programs = self.precond_programs[vin] + else: + programs = { + "program1": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0}, + "program2": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0}, + "program3": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0}, + "program4": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0} + } + msg = self.mqtt_request(vin, {"asap": value, "programs": programs}) logger.info(msg) self.mqtt_client.publish(MQTT_REQ_TOPIC + self.customer_id + "/ThermalPrecond", msg) return True @@ -457,66 +469,84 @@ class MyPSACC: def save_config(self, name="config.json", force=False): config_str = json.dumps(self, cls=MyPeugeotEncoder, sort_keys=True, indent=4).encode("utf8") new_hash = md5(config_str).hexdigest() - if force or self._confighash != new_hash: + if force or self._config_hash != new_hash: with open(name, "wb") as f: f.write(config_str) - self._confighash = new_hash + self._config_hash = new_hash logger.info("save config change") @staticmethod def load_config(name="config.json"): with open(name, "r") as f: - str = f.read() - return MyPSACC(**json.loads(str)) + config_str = f.read() + 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") + return MyPSACC(**config) def set_record(self, value: bool): self._record_enabled = value def record_info(self, vin, status: psac.models.status.Status): - longitude = status.last_position.geometry.coordinates[0] - latitude = status.last_position.geometry.coordinates[1] - date = status.last_position.properties.updated_at mileage = status.timed_odometer.mileage - level = status.energy[0].level - charging_status = status.energy[0].charging.status - moving = status.kinetic.moving + level = status.get_energy('Electric').level + level_fuel = status.get_energy('Fuel').level + charge_date = status.get_energy('Electric').updated_at + try: + moving = status.kinetic.moving + logger.debug("") + except AttributeError: + logger.error("kinetic not available from api") + moving = None + try: + longitude = status.last_position.geometry.coordinates[0] + latitude = status.last_position.geometry.coordinates[1] + date = status.last_position.properties.updated_at + except AttributeError: + logger.error("last_position not available from api") + longitude = latitude = None + date = charge_date + logger.debug("vin:%s longitude:%s latitude:%s date:%s mileage:%s level:%s charge_date:%s level_fuel:" + "%s moving:%s", vin, longitude, latitude, date, mileage, level, charge_date, level_fuel, + moving) + self.record_position(vin, mileage, latitude, longitude, date, level, level_fuel, moving) + try: + charging_status = status.get_energy('Electric').charging.status + self.record_charging(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): conn = get_db() if mileage == 0: # fix a bug of the api - logger.error(f"The api return a wrong mileage for {vin} : {mileage}") + logger.error("The api return a wrong mileage for %s : %f", vin, mileage) else: if conn.execute("SELECT Timestamp from position where Timestamp=?", (date,)).fetchone() is None: - temp = None - if self.weather_api is not None: + temp = get_temp(latitude, longitude, self.weather_api) + if level_fuel == 0: # fix fuel level not provided when car is off try: - weather_rep = requests.get("https://api.openweathermap.org/data/2.5/onecall", - params={"lat": latitude, "lon": longitude, - "exclude": "minutely,hourly,daily,alerts", - "appid": "f8ee4124ea074950b696fd3e956a7069", "units": "metric"}) - temp = weather_rep.json()["current"]["temp"] - logger.debug(f"Temperature :{temp}c") - except Exception as e: - logger.error(f"Unable to get temperature from openweathermap :{e}") + level_fuel = conn.execute( + "SELECT level_fuel FROM position WHERE level_fuel>0 AND VIN=? ORDER BY Timestamp DESC " + "LIMIT 1", + (vin,)).fetchone()[0] + logger.info("level_fuel fixed with last real value %f for %s", level_fuel, vin) + except TypeError: + level_fuel = None + logger.info("level_fuel unfixed for %s", vin) - conn.execute( - "INSERT INTO position(Timestamp,VIN,longitude,latitude,mileage,level, moving, temperature) VALUES(?,?,?,?,?,?,?,?)", - (date, vin, longitude, latitude, mileage, level, moving, temp)) + conn.execute("INSERT INTO position(Timestamp,VIN,longitude,latitude,mileage,level,level_fuel,moving," + "temperature) VALUES(?,?,?,?,?,?,?,?,?)", + (date, vin, longitude, latitude, mileage, level, level_fuel, moving, temp)) conn.commit() - logger.info(f"new position recorded for {vin}") - res = conn.execute( - "SELECT Timestamp,mileage,level from position ORDER BY Timestamp DESC LIMIT 3;").fetchall() - # Clean DB - if len(res) == 3: - if res[0]["mileage"] == res[1]["mileage"] == res[2]["mileage"]: - if res[0]["level"] == res[1]["level"] == res[2]["level"]: - logger.debug("Delete duplicate line") - conn.execute("DELETE FROM position where Timestamp=?;", (res[1]["Timestamp"],)) - conn.commit() + logger.info("new position recorded for %s", vin) + clean_position(conn) else: logger.debug("position already saved") - # todo handle battery status - charge_date = status.energy[0].updated_at + def record_charging(self, vin, charging_status, charge_date, level, latitude, longitude): + conn = get_db() if charging_status == "InProgress": try: in_progress = conn.execute("SELECT stop_at FROM battery WHERE VIN=? ORDER BY start_at DESC limit 1", @@ -524,96 +554,52 @@ class MyPSACC: except TypeError: in_progress = False if not in_progress: - res = conn.execute("INSERT INTO battery(start_at,start_level,VIN) VALUES(?,?,?)", - (charge_date, level, vin)) + conn.execute("INSERT INTO battery(start_at,start_level,VIN) VALUES(?,?,?)", (charge_date, level, vin)) conn.commit() else: try: start_at, stop_at, start_level = conn.execute( - "SELECT start_at, stop_at, start_level from battery WHERE VIN=? ORDER BY start_at " - "DESC limit 1", (vin,)).fetchone() + "SELECT start_at, stop_at, start_level from battery WHERE VIN=? ORDER BY start_at " + "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 * BATTERY_POWER - res = conn.execute( + 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)) conn.commit() except TypeError: logger.debug("battery table is empty") - except: - logger.debug("Error when saving status " + traceback.format_exc()) conn.close() @staticmethod def get_recorded_position(): - from geojson import Feature, Point, FeatureCollection - from geojson import dumps as geo_dumps conn = get_db() res = conn.execute('SELECT * FROM position ORDER BY Timestamp') features_list = [] for row in res: + if row["longitude"] is None or row["latitude"] is None: + continue feature = Feature(geometry=Point((row["longitude"], row["latitude"])), - properties={"vin": row["vin"], "date": row["Timestamp"], "mileage": row["mileage"], - "level": row["level"]}) + properties={"vin": row["vin"], "date": row["Timestamp"].strftime("%x %X"), + "mileage": row["mileage"], + "level": row["level"], "level_fuel": row["level_fuel"]}) features_list.append(feature) feature_collection = FeatureCollection(features_list) conn.close() return geo_dumps(feature_collection, sort_keys=True) @staticmethod - def get_trips() -> List[Trip]: + def get_chargings(mini=None, maxi=None) -> Tuple[dict]: conn = get_db() - res = conn.execute('SELECT * FROM position ORDER BY Timestamp').fetchall() - trips = [] - if len(res) > 1: - start = res[0] - end = res[1] - tr = Trip() - #res = list(map(dict,res)) - for x in range(0, len(res) - 2): - next_el = res[x + 2] - if end["mileage"] - start["mileage"] == 0 or \ - (end["Timestamp"] - start["Timestamp"]).total_seconds() / 3600 > 3: - start = end - tr = Trip() - else: - distance = next_el["mileage"] - end["mileage"] # km - duration = (next_el["Timestamp"] - end["Timestamp"]).total_seconds() / 3600 - if (distance == 0 and duration > 0.08) or duration > 2: # check the speed to handle missing point - 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"]) - tr.duration = (end["Timestamp"] - start["Timestamp"]).total_seconds() / 3600 - tr.speed_average = tr.distance / tr.duration - diff_level = start["level"] - end["level"] - tr.consumption = diff_level / 100 * BATTERY_POWER # kw - tr.consumption_km = 100 * tr.consumption / tr.distance # kw/100 km - # logger.debug( - # f"Trip: {start['Timestamp']} {tr.distance:.1f}km {tr.duration:.2f}h {tr.speed_average:.2f} km/h {tr.consumption:.2f} kw {tr.consumption_km:.2f}kw/100km") - # filter bad value - if tr.consumption_km < 70: - trips.append(tr) - start = next_el - tr = Trip() - else: - tr.add_points(end["longitude"], end["latitude"]) - end = next_el - return trips - - @staticmethod - def get_chargings(min=None, max=None): - conn = get_db() - if min is not None: - if max is not None: - res = conn.execute("select * from battery WHERE start_at>=? and start_at<=?", (min, max)).fetchall() + 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>=?", (min,)).fetchall() - elif max is not None: - res = conn.execute("select * from battery WHERE start_at<=?", (max,)).fetchall() + 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)) @@ -622,10 +608,8 @@ class MyPSACC: class MyPeugeotEncoder(JSONEncoder): def default(self, mp: MyPSACC): data = copy(mp.__dict__) - mpd = {} - mpd["proxies"] = data["_proxies"] - mpd["refresh_token"] = mp.manager.refresh_token - mpd["client_secret"] = mp.service_information.client_secret - for el in ["client_id", "realm", "remote_refresh_token", "customer_id","weather_api"]: + mpd = {"proxies": data["_proxies"], "refresh_token": mp.manager.refresh_token, + "client_secret": mp.service_information.client_secret} + for el in ["client_id", "realm", "remote_refresh_token", "customer_id", "weather_api", "country_code"]: mpd[el] = data[el] return mpd diff --git a/Trip.py b/Trip.py index 4a7ada7..aaf1758 100644 --- a/Trip.py +++ b/Trip.py @@ -1,8 +1,15 @@ -from typing import List +from __future__ import annotations -from dateutil.tz import tzlocal -from geojson import Feature, Point, FeatureCollection, MultiLineString -from geojson import dumps as geo_dumps +from statistics import mean +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 trip_parser import TripParser +from web.db import get_db class Points(): @@ -14,6 +21,80 @@ class Points(): return self.latitude, self.longitude +class Trip: + def __init__(self): + self.start_at = None + self.end_at = None + self.positions: List[Points] = [] + self.speed_average = None + self.consumption = 0 + self.consumption_km = 0 + self.consumption_fuel = 0 + self.consumption_fuel_km = 0 + self.distance = None + self.duration = None + self.mileage = None + self.car: Car = None + self.temperatures = [] + + def add_points(self, latitude, longitude): + self.positions.append(Points(latitude, longitude)) + + def add_temperature(self, temp): + self.temperatures.append(temp) + + def get_temperature(self): + if len(self.temperatures) > 0: + return float(mean(self.temperatures)) + return None + + def set_consumption(self, diff_level: float) -> float: + if self.distance is None: + raise ValueError("Distance not set") + if diff_level < 0: + logger.debugv("trip has negative consumption") + diff_level = 0 + self.consumption = diff_level * self.car.battery_power / 100 + self.consumption_km = 100 * self.consumption / self.distance # kw/100 km + return self.consumption_km + + def set_fuel_consumption(self, consumption) -> float: + if self.distance is None: + raise ValueError("Distance not set") + if consumption < 0: + logger.debugv("trip has negative fuel consumption") + self.consumption_fuel = round(consumption, 2) # L + self.consumption_fuel_km = round(100 * self.consumption_fuel / self.distance, 2) # L/100 km + return self.consumption_fuel_km + + def get_consumption(self): + return { + 'date': self.start_at, + 'consumption': self.consumption_km, + } + + def get_consumption_fuel(self): + return { + 'date': self.start_at, + 'consumption': self.consumption_fuel_km, + } + + def to_geojson(self): + multi_line_string = MultiLineString(tuple(map(list, self.positions))) + return Feature(geometry=multi_line_string, properties={"start_at": self.start_at, "end_at": self.end_at, + "average speed": self.speed_average, + "average consumption": self.consumption_km, + "average consumption fuel": self.consumption_fuel_km}) + + def get_info(self): + res = {"start_at": self.start_at.astimezone(tz.tzlocal()).replace(tzinfo=None).strftime("%x %X"), + # convert to naive tz, + "duration": self.duration * 60, "speed_average": self.speed_average, + "consumption_km": self.consumption_km, "consumption_fuel_km": self.consumption_fuel_km, + "distance": self.distance, "mileage": self.mileage} + return res + + class Trips(list): def __init__(self, *args): list.__init__(self, *args) @@ -22,34 +103,113 @@ class Trips(list): feature_collection = FeatureCollection(self) return feature_collection - -class Trip: - def __init__(self): - self.start_at = None - self.end_at = None - self.positions: List[Points] = [] - self.speed_average = None - self.consumption = None - self.consumption_km = None - self.distance = None - self.duration = None - - def add_points(self, longitude, latitude): - self.positions.append(Points(longitude, latitude)) - - def get_consumption(self): - return { - 'date': self.start_at, - 'consumption': self.consumption_km, - } - - def to_geojson(self): - multi_line_string = MultiLineString(tuple(map(list, self.positions))) - return Feature(geometry=multi_line_string, properties={"start_at": self.start_at, "end_at": self.end_at, - "average speed": self.speed_average, - "average consumption": self.consumption_km}) - - def get_info(self): - res = {"start_at": self.start_at.astimezone(None).strftime("%x %X"), "end_at": self.end_at.astimezone(None).strftime("%x %X"), "duration": self.duration*60, - "speed_average": self.speed_average, "consumption_km": self.consumption_km, "distance": self.distance} + 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()}) 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) + return True + logger.debugv("trip discarded") + return False + + @staticmethod + def get_trips(vehicles_list: Cars) -> Dict[str, Trips]: + conn = get_db() + vehicles = conn.execute( + "SELECT DISTINCT vin FROM position;").fetchall() + trips_by_vin = {} + for vin in vehicles: + trips = Trips() + vin = vin[0] + res = conn.execute('SELECT * FROM position WHERE VIN=? ORDER BY Timestamp', (vin,)).fetchall() + if len(res) > 1: + car = vehicles_list.get_car_by_vin(vin) + assert car is not None + trip_parser = TripParser(car) + start = res[0] + end = res[1] + tr = 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", + res[x]['Timestamp'], res[x]['mileage'], res[x]['level'], res[x]['level_fuel']) + next_el = res[x + 2] + distance = end["mileage"] - start["mileage"] + duration = (end["Timestamp"] - start["Timestamp"]).total_seconds() / 3600 + try: + speed_average = distance / duration + except ZeroDivisionError: + speed_average = 0 + restart_trip = False + if trip_parser.is_refuel(start, end, distance): + restart_trip = True + elif speed_average < 0.2 and duration > 0.05: + restart_trip = True + logger.debugv("low speed detected") + if restart_trip: + start = end + tr = Trip() + logger.debugv("restart trip at %s mileage:%.1f level:%s level_fuel:%s", + start['Timestamp'], start['mileage'], start['level'], start['level_fuel']) + else: + distance = next_el["mileage"] - end["mileage"] # km + duration = (next_el["Timestamp"] - end["Timestamp"]).total_seconds() / 3600 + try: + speed_average = distance / duration + except ZeroDivisionError: + speed_average = 0 + end_trip = False + if trip_parser.is_refuel(end, next_el, distance): + end_trip = True + elif speed_average < 0.2 and duration > 0.05: + # (distance == 0 and duration > 0.08) or duration > 2 or + # check the speed to handle missing point + end_trip = True + logger.debugv("low speed detected") + elif duration > 2: + end_trip = True + logger.debugv("too much time detected") + elif x == len(res) - 3: # last record detected + # think if add point is needed + end = next_el + end_trip = True + logger.debugv("last position found") + 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"]) + 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 + diff_level, diff_level_fuel = trip_parser.get_level_consumption(start, end) + tr.car = car + if diff_level != 0: + tr.set_consumption(diff_level) # kw + if diff_level_fuel != 0: + tr.set_fuel_consumption(diff_level_fuel) + tr.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) + # filter bad value + trips.check_and_append(tr) + start = next_el + tr = Trip() + else: + tr.add_points(end["longitude"], end["latitude"]) + end = next_el + trips_by_vin[vin] = trips + return trips_by_vin diff --git a/app_decoder.py b/app_decoder.py index ae3e424..a8582c3 100755 --- a/app_decoder.py +++ b/app_decoder.py @@ -23,12 +23,10 @@ BRAND = {"com.psa.mym.myopel": {"realm": "clientsB2COpel", "brand_code": "com.psa.mym.myvauxhall": {"realm": "clientsB2CVauxhall", "brand_code": "0V", "app_name": "MyVauxall"} } - def getxmlvalue(root, name): for child in root.findall("*[@name='" + name + "']"): return child.text - def find_app_path(): base_dir = 'apps/' paths = os.listdir(base_dir) @@ -52,13 +50,16 @@ def find_preferences_xml(): def save_key_to_pem(pfx_data, pfx_password): - private_key, certificate, additional_certificates = pkcs12.load_key_and_certificates(pfx_data, - bytes.fromhex(pfx_password), - default_backend()) - with open("public.pem", "wb") as f: + private_key, certificate = pkcs12.load_key_and_certificates(pfx_data, + bytes.fromhex(pfx_password), default_backend())[:2] + try: + os.mkdir("certs") + except FileExistsError: + pass + with open("certs/public.pem", "wb") as f: f.write(certificate.public_bytes(encoding=serialization.Encoding.PEM)) - with open("private.pem", "wb") as f: + with open("certs/private.pem", "wb") as f: f.write(private_key.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption())) @@ -71,7 +72,7 @@ if sys.version_info < (3, 6): if not argv[1].endswith(".apk"): print("No apk given") - exit(1) + sys.exit(1) print("APK loading...") a = APK(argv[1]) package_name = a.get_package() @@ -109,7 +110,7 @@ except: traceback.print_exc() print(f"HOST_BRANDID : {HOST_BRANDID_PROD} sitecode: {site_code}") print(res.text) - exit(1) + sys.exit(1) save_key_to_pem(pfx_cert, "") @@ -125,7 +126,7 @@ try: "User-Agent": "okhttp/4.8.0", "Version": "1.27.0" }, - cert=("public.pem", "private.pem"), + cert=("certs/public.pem", "certs/private.pem"), ) res_dict = res2.json()["success"] @@ -134,29 +135,33 @@ try: except: traceback.print_exc() print(res2.text) - exit(1) + 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) os.chdir(current_dir) psacc.save_config(name="test.json") res = psacc.get_vehicles() + +for vehicle in res_dict["vehicles"]: + label = vehicle["short_label"].split(" ")[-1] + car = psacc.vehicles_list.get_car_by_vin(vehicle["vin"]) + if car.label == "unknown": + car.label = label + car.set_energy_capacity() +psacc.vehicles_list.save_cars() + print(f"\nYour vehicles: {res}") -## Charge control +# Charge control charge_controls = ChargeControls() -for vin, vehicle in res.items(): - chc = ChargeControl(None, vin, 100, [0, 0]) - charge_controls.list[vin] = chc +for vehicle in res: + chc = ChargeControl(psacc, vehicle.vin, 100, [0, 0]) + charge_controls[vehicle.vin] = chc charge_controls.save_config(name="charge_config1.json") -try: - os.remove("private.pem") - os.remove("public.pem") -except: - print("Error when deleting temp files") - print("Success !!!") diff --git a/ecomix.py b/ecomix.py index 38aa70a..94da2e0 100644 --- a/ecomix.py +++ b/ecomix.py @@ -1,9 +1,11 @@ -from datetime import datetime, timedelta +from datetime import datetime from statistics import mean, StatisticsError -import xml.etree.ElementTree as ET +import xml.etree.cElementTree as ElT import requests import reverse_geocode +from MyLogger import logger + class Ecomix: @staticmethod @@ -18,7 +20,7 @@ class Ecomix: } ) - etree = ET.fromstring(res.text) + etree = ElT.fromstring(res.text) period_start = (start.hour + int(start.minute / 30)) * 4 period_end = (end.hour + int(end.minute / 30)) * 4 @@ -38,12 +40,17 @@ class Ecomix: @staticmethod def get_co2_per_kw(start: datetime, end: datetime, latitude, longitude): - location = reverse_geocode.search([(latitude, longitude)])[0] - country_code = location["country_code"] + try: + location = reverse_geocode.search([(latitude, longitude)])[0] + country_code = location["country_code"] + except UnicodeDecodeError: + 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': co2_per_kw = Ecomix.get_data_france(start, end) else: co2_per_kw = None return co2_per_kw - diff --git a/otp/Otp.py b/otp/Otp.py index 8a81db4..c4f663a 100644 --- a/otp/Otp.py +++ b/otp/Otp.py @@ -8,16 +8,13 @@ from Cryptodome.PublicKey import RSA from Cryptodome import Hash from math import ceil - - from collections import defaultdict -from xml.etree import cElementTree as ET +from xml.etree import cElementTree as ElT from otp import oaep from otp.load import IWData import pickle from MyLogger import logger -proxies = None def etree_to_dict(t): @@ -45,7 +42,7 @@ base36 = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] -def numberToBase36(n): +def number_to_base36(n): b = 36 if n == 0: return [0] @@ -56,26 +53,32 @@ def numberToBase36(n): return digits +class ConfigException(Exception): + """Raise exception if otp config isn't correct""" + + class Otp: OTP_TWICE = 10 - OK=0 - kPub = "11" - exponent = int(kPub, 16) + OK = 0 + NOK = -1 + KPub = "11" + exponent = int(KPub, 16) ACTIVATE_MODE = "activate" OTP_MODE = "otp" MS_MODE = "ms" iw_host = "https://otp.mpsa.com" - def __init__(self, inweboAccessId): + proxies = None + + def __init__(self, inwebo_access_id, device_id=token_hex(8)): self.Kiw = None self.pinmode = None self.Kfact = None - self.Kiw = None self.pinmode = None self.needsync = None self.serviceid = None self.alias = None self.iwalea = token_hex(16) - self.device_id = token_hex(8) + self.device_id = device_id self.codepin = None self.challenge = "" self.action = "" @@ -84,7 +87,7 @@ class Otp: self.isMac = True self.data = IWData(self) self.cipher = None - self.macid = inweboAccessId + self.macid = inwebo_access_id self.smsCode = None self.mode = Otp.ACTIVATE_MODE self.defi = 0 @@ -93,20 +96,20 @@ class Otp: def init(self, Kfact=None, Kiw=None, pinmode=None): self.Kfact = Kfact self.pinmode = pinmode - self.Kiw = self.decode_oeap(Kiw, self.Kfact) + self.Kiw = self.decode_oaep(Kiw, self.Kfact) key = RSA.construct((int(self.Kiw, 16), Otp.exponent)) - self.cipher = oaep.new(key, hashAlgo=Hash.SHA256) + self.cipher = oaep.new(key, hash_algo=Hash.SHA256) - def getSerial(self): + def get_serial(self): return self.device_id + "/_/" + self.iwalea - def generateKMA(self, codepin): - serial = self.getSerial() + def generate_kma(self, codepin): + serial = self.get_serial() kma_str = codepin + ";" + serial kma = hashlib.sha256(kma_str.encode("utf-8")).hexdigest()[:32] return kma - def getR(self): + def get_r(self): if self.action == "upgrade": iw = self.data.iwK1 # not correctly implemented @@ -117,17 +120,18 @@ class Otp: else: R2 = self.challenge + ";" + iw + ";" - R0 = self.challenge + ";" + iw + ";" + self.getSerial() + R0 = self.challenge + ";" + iw + ";" + self.get_serial() R1 = self.challenge + ";" + iw + ";" + self.data.iwK1 - logger.debug(f"{R0}\n{R1}\n{R2}") + logger.debug("%s\n%s\n%s", R0, R1, R2) return {"R0": hashlib.sha256(R0.encode("utf-8")).hexdigest(), "R1": hashlib.sha256(R1.encode("utf-8")).hexdigest(), "R2": hashlib.sha256(R2.encode("utf-8")).hexdigest()} - def decode_oeap(self, enc, key): + @staticmethod + def decode_oaep(enc, key): modulus = int(key, 16) key = RSA.construct((modulus, Otp.exponent)) - cipher = oaep.new(key, hashAlgo=Hash.SHA256) + cipher = oaep.new(key, hash_algo=Hash.SHA256) block_size = 128 dec_string = "" enc_b = bytes.fromhex(enc) @@ -135,11 +139,11 @@ class Otp: for x in range(0, nb_block): if x == nb_block - 1: - max = len(enc_b) + maxi = len(enc_b) else: - max = (1 + x) * 128 - min = x * 128 - ciphertext = cipher.decrypt(enc_b[min:max]) + maxi = (1 + x) * 128 + mini = x * 128 + ciphertext = cipher.decrypt(enc_b[mini:maxi]) dec_string += ciphertext.hex() logger.debug(dec_string) return dec_string @@ -150,29 +154,28 @@ class Otp: headers={ "Connection": "Keep-Alive", "Host": "otp.mpsa.com", - "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 8.0.0; Android SDK built for x86_64 Build/OSR1.180418.004)" + "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 8.0.0; Android SDK built for x86_64 " + "Build/OSR1.180418.004) " }, params=param, - proxies=proxies, - verify=False + proxies=self.proxies, + verify=self.proxies is None ).text try: raw_xml = raw_xml[raw_xml.index("?>") + 2:] if setup: - return etree_to_dict(ET.XML(raw_xml))["ActionSetup"] - else: - return etree_to_dict(ET.XML(raw_xml))["ActionFinalize"] - - except: + return etree_to_dict(ElT.XML(raw_xml))["ActionSetup"] + return etree_to_dict(ElT.XML(raw_xml))["ActionFinalize"] + except KeyError: logger.debug(raw_xml) - raise Exception("Bad return from server") + raise ValueError("Bad response from server") def activation_start(self): param = {"action": "ActionSetup", "mode": self.mode, "id": self.data.iwid, "lastsync": self.data.iwTsync, "version": "Generator-1.0/0.2.11", "macid": self.macid} if self.mode == Otp.OTP_MODE: param.update({"sid": self.data.iwsecid}) - if self.mode == Otp.ACTIVATE_MODE: + elif self.mode == Otp.ACTIVATE_MODE: param.update({"code": self.smsCode}) xml = self.request(param, setup=True) @@ -183,12 +186,11 @@ class Otp: elif self.mode == Otp.OTP_MODE: self.challenge = xml["challenge"] return True - else: - return False + return False def activation_finalyze(self, random_bytes=None): - R = self.getR() + R = self.get_r() params = {"action": "ActionFinalize", "mode": self.mode, "id": self.data.iwid, "lastsync": self.data.iwTsync, "version": "Generator-1.0/0.2.11", "lang": "fr", "ack": "", "macid": self.macid} @@ -196,23 +198,26 @@ class Otp: params.update({"keytype": '0', "sid": self.data.iwsecid}) elif self.mode == Otp.ACTIVATE_MODE: - kma_crypt = self.cipher.encrypt(bytes.fromhex(self.generateKMA(self.codepin))).hex() + kma_crypt = self.cipher.encrypt(bytes.fromhex(self.generate_kma(self.codepin))).hex() pin_crypt = self.cipher.encrypt(self.codepin.encode("utf-8")).hex() - params.update({"serial": self.getSerial(), "code": self.smsCode, + params.update({"serial": self.get_serial(), "code": self.smsCode, "Kma": kma_crypt, "pin": pin_crypt, "name": "Android SDK built for x86_64 / UNKNOWN", }) params.update(R) xml = self.request(params) - - self.data.synchro(xml, self.generateKMA(self.codepin)) + if xml["err"] != "OK": + return Otp.NOK + self.data.synchro(xml, self.generate_kma(self.codepin)) if self.mode == Otp.OTP_MODE: - self.defi = str(xml["defi"]) + try: + self.defi = str(xml["defi"]) + except KeyError: + raise ConfigException if "J" in xml: logger.debug("Need another otp request") return Otp.OTP_TWICE - else: - return Otp.OK + return Otp.OK if "ms_n" not in xml or xml["ms_n"] == 0: logger.debug("no ms_n request needed") @@ -220,50 +225,53 @@ class Otp: if int(xml["ms_n"]) > 1: raise NotImplementedError - else: - ms_n = "0" + ms_n = "0" self.challenge = xml["challenge"] self.action = "synchro" - res = self.decode_oeap(xml["ms_key"], self.Kfact) + res = self.decode_oaep(xml["ms_key"], self.Kfact) temp_key = RSA.construct((int(res, 16), self.exponent)) - temp_cipher = oaep.new(temp_key, hashAlgo=Hash.SHA256) + temp_cipher = oaep.new(temp_key, hash_algo=Hash.SHA256) if random_bytes is None: random_bytes = token_bytes(16) - KpubEncode = temp_cipher.encrypt(random_bytes) + kpub_encode = temp_cipher.encrypt(random_bytes) - aes_cipher = AES.new(bytes.fromhex(self.generateKMA(self.codepin)), AES.MODE_ECB) - encodeAesFromHex = aes_cipher.encrypt(random_bytes).hex() - self.data.iwsecval = encodeAesFromHex + aes_cipher = AES.new(bytes.fromhex(self.generate_kma(self.codepin)), AES.MODE_ECB) + encode_aes_from_hex = aes_cipher.encrypt(random_bytes).hex() + self.data.iwsecval = encode_aes_from_hex self.data.iwsecid = xml["s_id"] self.data.iwsecn = 1 req_param = {"action": "ActionFinalize", "mode": Otp.MS_MODE, "ms_id" + ms_n: xml["ms_id"], - "ms_val" + ms_n: KpubEncode.hex(), "macid": self.macid} + "ms_val" + ms_n: kpub_encode.hex(), "macid": self.macid} req_param.update({"id": self.data.iwid, "lastsync": self.data.iwTsync, "ms_n": 1}) - req_param.update(self.getR()) + req_param.update(self.get_r()) xml = self.request(req_param) - self.data.synchro(xml, self.generateKMA(self.codepin)) + self.data.synchro(xml, self.generate_kma(self.codepin)) return Otp.OK - def _getOtpCode(self): - password = self.data.iwK1 + ":" + self.defi + ":" + self.data.iwsecval + def _get_otp_code(self): + 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) - otp = numberToBase36(nb) + otp = number_to_base36(nb) return otp - def getOtpCode(self): + def get_otp_code(self): self.mode = Otp.OTP_MODE - self.activation_start() - res = self.activation_finalyze() - if res == Otp.OTP_TWICE: - self.mode = Otp.OTP_MODE - self.activation_start() - self.activation_finalyze() - otp_code = self._getOtpCode() - logger.debug(f"otp code: {otp_code}") + otp_code = None + if self.activation_start(): + res = self.activation_finalyze() + if res != Otp.NOK: + if res == Otp.OTP_TWICE: + self.mode = Otp.OTP_MODE + self.activation_start() + self.activation_finalyze() + otp_code = self._get_otp_code() + logger.debug("otp code: %s", otp_code) + if otp_code is None: + raise ConfigException("Can't get otp code") return otp_code def __getstate__(self): @@ -271,34 +279,44 @@ class Otp: del odict['cipher'] # don't pickle this return odict - def __setstate__(self, dict): - self.__dict__.update(dict) + def __setstate__(self, dict_param): + self.__dict__.update(dict_param) if self.Kiw is not None: - key = RSA.construct((int(self.Kiw, 16), Otp.exponent)) - self.cipher = oaep.new(key, hashAlgo=Hash.SHA256) + key = RSA.construct((int(self.Kiw, 16), Otp.exponent)) + self.cipher = oaep.new(key, hash_algo=Hash.SHA256) + + @staticmethod + def set_proxies(proxies): + Otp.proxies = proxies def encode_oeap(text, key): - cipher = oaep.new(bytes.fromhex(key), hashAlgo=Hash.SHA256) + cipher = oaep.new(bytes.fromhex(key), hash_algo=Hash.SHA256) return cipher.encrypt(text) -def save_otp(obj): - with open("otp.bin", 'wb') as output: + +def save_otp(obj, filename="otp.bin"): + with open(filename, 'wb') as output: pickle.dump(obj, output) -def load_otp(): + +def load_otp(filename="otp.bin"): try: - with open("otp.bin", 'rb') as input: - return pickle.load(input) - except: + with open(filename, 'rb') as input_file: + return pickle.load(input_file) + except FileNotFoundError: logger.debug(traceback.format_exc()) return None -def new_otp_session(): - otp = Otp("bb8e981582b0f31353108fb020bead1c") + +def new_otp_session(old_otp_session: Otp = None): + if old_otp_session is None: + otp = Otp("bb8e981582b0f31353108fb020bead1c") + else: + otp = Otp("bb8e981582b0f31353108fb020bead1c", device_id=old_otp_session.device_id) otp.smsCode = input("What is the code you just received by SMS ?") otp.codepin = input("What is your app pin code ?") otp.activation_start() otp.activation_finalyze() save_otp(otp) - return otp \ No newline at end of file + return otp diff --git a/otp/Tokenizer.py b/otp/Tokenizer.py index 4ab35bf..ce5ac1f 100644 --- a/otp/Tokenizer.py +++ b/otp/Tokenizer.py @@ -1,41 +1,27 @@ -from locale import atoi - - -class Tokenizer(object): - def __init__(self, str, delimiter="&&"): - self.s:str = str +class Tokenizer: + def __init__(self, tokens, delimiter="&&"): + self.s: str = tokens self.delimiter = delimiter self.currentIndex = 0 def nextToken(self): if self.currentIndex >= len(self.s): return "" - indexOf = self.currentIndex+self.s[self.currentIndex:].index(self.delimiter) - if indexOf == -1: + 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() return substring - substring2 = self.s[self.currentIndex:indexOf] - self.currentIndex = indexOf + len(self.delimiter) - #print(f"{substring2} index:{self.currentIndex}") + substring2 = self.s[self.currentIndex:index_of] + self.currentIndex = index_of + len(self.delimiter) return substring2 def nextTokenI(self): token = self.nextToken() if token == "": return 0 - else: - return int(token, 16) + return int(token, 16) def hasMoreTokens(self): - return self.currentIndex < len(self.s) - -# a="0.2.11&&&&&&0&&0&&0&&9f13ba238fbabba08e85d93638e98ef5e48682a9d3e5bc325c3dd6fac8199a6ce09e9b4f373aa6a75a905c3d690f6e3335d1e8e5b748ecec3020a794149033f6ada6896db6d73b8d43b8365bbe15b9ac66f49d4e684a3628f1e9f3deda0c4e24aba771946e6085b92c5ad312477152acf8db01e6aea4b409d5ac1a05c2fd4e95&&0&&&&&&&&&&&&0&&0&&0&&0&&0&&0&&0&&&&&&&&0&&0&&0&&0&&0&&2.0.0&&http://m.inwebo.com/&&" -# t=Tokenizer(a) -# self = t -# t.nextToken() -# t.nextToken() -# t.currentIndex -# t.nextTokenI() -# atoi("") \ No newline at end of file + return self.currentIndex < len(self.s) \ No newline at end of file diff --git a/otp/load.py b/otp/load.py index 8aebeee..dd22711 100644 --- a/otp/load.py +++ b/otp/load.py @@ -6,15 +6,20 @@ from Cryptodome.Cipher import AES from otp.Tokenizer import Tokenizer -default_token="0.2.11&&&&&&0&&0&&0&&9f13ba238fbabba08e85d93638e98ef5e48682a9d3e5bc325c3dd6fac8199a6ce09e9b4f373aa6a75a905c3d690f6e3335d1e8e5b748ecec3020a794149033f6ada6896db6d73b8d43b8365bbe15b9ac66f49d4e684a3628f1e9f3deda0c4e24aba771946e6085b92c5ad312477152acf8db01e6aea4b409d5ac1a05c2fd4e95&&0&&&&&&&&&&&&0&&0&&0&&0&&0&&0&&0&&&&&&&&0&&0&&0&&0&&0&&2.0.0&&http://m.inwebo.com/&&" -default_version="529" +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" -def filterLoad(string:str): + +def filterLoad(string: str): return string.replace("&", "&") -class IWData(object): - def __init__(self,IW): - self.IW=IW + +class IWData: + def __init__(self, IW): + self.IW = IW self.tokenizer = Tokenizer(default_token) self.tokenizer.nextToken() self.load1xx(int(default_version), self.tokenizer) @@ -46,16 +51,16 @@ class IWData(object): self.iwH = tokenizer.nextToken() nextTokenI = tokenizer.nextTokenI() self.iwsrvn = nextTokenI - self.iwsrvid = [None]*(nextTokenI) - self.iwsrvname = [None]*(nextTokenI) - self.iwsrvlogo = [None]*(nextTokenI) - self.iwsrvurl = [None]*(nextTokenI) - self.iwsrvonlineotp = [None]*(nextTokenI) + self.iwsrvid = [None] * (nextTokenI) + self.iwsrvname = [None] * (nextTokenI) + self.iwsrvlogo = [None] * (nextTokenI) + self.iwsrvurl = [None] * (nextTokenI) + self.iwsrvonlineotp = [None] * (nextTokenI) if self.IW.isMac: - self.iwsrvconnected = [None]*(self.iwsrvn) + self.iwsrvconnected = [None] * (self.iwsrvn) j2 = self.iwsrvn - self.iwsrvsecure = [None]*(j2) - self.iwsrvksc = [None]*(j2) + self.iwsrvsecure = [None] * (j2) + self.iwsrvksc = [None] * (j2) i = 0 while i < self.iwsrvn: self.iwsrvid[i] = tokenizer.nextToken() @@ -63,12 +68,12 @@ class IWData(object): self.iwsrvlogo[i] = filterLoad(tokenizer.nextToken()) if self.IW.isMac: self.iwsrvconnected[i] = tokenizer.nextTokenI() - if j>515: + if j > 515: i2 = 1 - elif j ==515: - i2=0 + elif j == 515: + i2 = 0 else: - i2=-1 + i2 = -1 if i2 < 0 or self.IW.isMac: self.iwsrvurl[i] = "" else: @@ -85,8 +90,8 @@ class IWData(object): i += 1 nextTokenI2 = tokenizer.nextTokenI() self.iwsecn = nextTokenI2 - self.iwsecid = [None]*(nextTokenI2) - self.iwsecval = [None]*(nextTokenI2) + self.iwsecid = [None] * (nextTokenI2) + self.iwsecval = [None] * (nextTokenI2) i3 = 0 while ((i3)) < self.iwsecn: self.iwsecid[i3] = tokenizer.nextToken() @@ -94,15 +99,15 @@ class IWData(object): i3 += 1 self.iwmsgn = tokenizer.nextTokenI() self.iwmsgtime = tokenizer.nextTokenI() - self.iwmsgid ="" - self.iwmsgtitle ="" + self.iwmsgid = "" + self.iwmsgtitle = "" self.iwmsgcontent = "" self.iwmsgack = "" i4 = 0 while i4 < self.iwmsgn: - self.iwmsgid+= tokenizer.nextToken() - self.iwmsgtitle+= filterLoad(tokenizer.nextToken()) - self.iwmsgcontent+=filterLoad(tokenizer.nextToken()) + self.iwmsgid += tokenizer.nextToken() + self.iwmsgtitle += filterLoad(tokenizer.nextToken()) + self.iwmsgcontent += filterLoad(tokenizer.nextToken()) self.iwmsgack += tokenizer.nextTokenI() i4 += 1 self.iwmajorversion = tokenizer.nextTokenI() @@ -111,9 +116,8 @@ class IWData(object): self.mustupgrade = False self.datatouch = 0 - def synchro(self, ixml:dict, key): + def synchro(self, ixml: dict, key): aes_cipher = AES.new(bytes.fromhex(key), AES.MODE_ECB) - """ generated source for method synchro """ value = ixml.get("id") if value is not None and len(value) > 0: self.iwid = value @@ -162,14 +166,14 @@ class IWData(object): self.iwsrvksc = ixml.get("s_ksc") self.iwsrvsecure = ixml.get("s_secure") self.iwsrvurl = ixml.get("s_url") - self.iwsrvonlineotp = ixml.get("s_onlineotp") + self.iwsrvonlineotp = ixml.get("s_onlineotp") self.IW.synchroJustDone = 1 value = ixml.get("m_n") if value is not None and len(value) > 0: self.iwmsgtime = int(time()) - self.iwmsgn = ixml.get("m_n") - self.iwmsgid = ixml.get("m_id") - self.iwmsgtitle = ixml.get("m_title") - self.iwmsgcontent = ixml.get("m_content") - self.iwmsgack = ixml.get("m_ack") - self.datatouch = 1 \ No newline at end of file + self.iwmsgn = ixml.get("m_n") + self.iwmsgid = ixml.get("m_id") + self.iwmsgtitle = ixml.get("m_title") + self.iwmsgcontent = ixml.get("m_content") + self.iwmsgack = ixml.get("m_ack") + self.datatouch = 1 diff --git a/otp/oaep.py b/otp/oaep.py index 7229711..ff4f90a 100644 --- a/otp/oaep.py +++ b/otp/oaep.py @@ -26,9 +26,9 @@ class MyOAEP(PKCS1OAEP_Cipher): """ # See 7.1.2 in RFC3447 - modBits = Cryptodome.Util.number.size(self._key.n) - k = ceil_div(modBits, 8) # Convert from bits to bytes - hLen = self._hashObj.digest_size + mod_bits = Cryptodome.Util.number.size(self._key.n) + k = ceil_div(mod_bits, 8) # Convert from bits to bytes + h_len = self._hashObj.digest_size # Step 1b and 1c if len(ciphertext) != k: @@ -36,49 +36,50 @@ class MyOAEP(PKCS1OAEP_Cipher): # Step 2a (O2SIP) ct_int = bytes_to_long(ciphertext) # Step 2b (RSADP) - #m_int = self._key._decrypt(ct_int) - m_int = pow(ct_int,self._key.e,self._key.n) + # m_int = self._key._decrypt(ct_int) + m_int = pow(ct_int, self._key.e, self._key.n) # Complete step 2c (I2OSP) em = long_to_bytes(m_int, k) # Step 3a - lHash = self._hashObj.new(self._label).digest() + l_hash = self._hashObj.new(self._label).digest() # Step 3b y = em[0] # y must be 0, but we MUST NOT check it here in order not to # allow attacks like Manger's (http://dl.acm.org/citation.cfm?id=704143) - maskedSeed = em[1:hLen + 1] - maskedDB = em[hLen + 1:] + masked_seed = em[1:h_len + 1] + masked_db = em[h_len + 1:] # Step 3c - seedMask = self._mgf(maskedDB, hLen) + seed_mask = self._mgf(masked_db, h_len) # Step 3d - seed = strxor(maskedSeed, seedMask) + seed = strxor(masked_seed, seed_mask) # Step 3e - dbMask = self._mgf(seed, k - hLen - 1) + db_mask = self._mgf(seed, k - h_len - 1) # Step 3f - db = strxor(maskedDB, dbMask) + db = strxor(masked_db, db_mask) # Step 3g - one_pos = db[hLen:].find(b'\x01') - lHash1 = db[:hLen] + one_pos = db[h_len:].find(b'\x01') + l_hash1 = db[:h_len] invalid = bord(y) | int(one_pos < 0) - hash_compare = strxor(lHash1, lHash) + hash_compare = strxor(l_hash1, l_hash) for x in hash_compare: invalid |= bord(x) - for x in db[hLen:one_pos]: + for x in db[h_len:one_pos]: invalid |= bord(x) if invalid != 0: raise ValueError("Incorrect decryption.") # Step 4 - return db[hLen + one_pos + 1:] + return db[h_len + one_pos + 1:] -def new(key, hashAlgo=None, mgfunc=None, label=b'', randfunc=None): - if randfunc is None: - randfunc = Random.get_random_bytes - return MyOAEP(key, hashAlgo, mgfunc, label, randfunc) -#for testing +def new(key, hash_algo=None, mgfunc=None, label=b'', rand_func=None): + if rand_func is None: + rand_func = Random.get_random_bytes + return MyOAEP(key, hash_algo, mgfunc, label, rand_func) + + +# for testing def notrandom(x): if x == 32: return b'\xf56\xccL`\x8a\x97l\nX0\xf4\x11\x9a\x0e\xce\x99K^\xe6\xcbU\xf3W+It"\xf5\x84\x1d\xe6' - else: - return None + return None diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b3f631e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +prospector>=1.3.0 +pre-commit diff --git a/requirements.txt b/requirements.txt index 2e64fe0..15421cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ paho-mqtt>=1.5.0 dash>=1.18.0 plotly>=4 cryptography>=3.0 +Werkzeug>=1.0.0 pandas oauth2_client requests diff --git a/server.py b/server.py index b19aa10..9ad97cb 100755 --- a/server.py +++ b/server.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import atexit import sys +from os import environ from threading import Thread from oauth2_client.credentials_manager import OAuthError @@ -19,14 +20,17 @@ parser = argparse.ArgumentParser() def parse_args(): parser = argparse.ArgumentParser() parser.add_argument("-f", "--config", help="config file, default file: config.json", type=argparse.FileType('r')) - 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("-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') 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') - parser.add_argument("-m", "--mail", help="set the email address") - parser.add_argument("-P", "--password", help="set the password") - parser.add_argument("--remote-disable", help="disable remote control") + parser.add_argument("-R", "--refresh", help="refresh vehicles status every x min",type=int) + parser.add_argument("-m", "--mail", default=environ.get('USER_EMAIL', None), help="set the email address") + parser.add_argument("-P", "--password", default=environ.get('USER_PASSWORD', None), help="set the password") + 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 @@ -37,6 +41,10 @@ if __name__ == "__main__": raise RuntimeError("This application requires Python 3.6+") parser = parse_args() args = parser.parse_args() + try: + args.debug=int(args.debug) + except ValueError: + pass my_logger(handler_level=args.debug) logger.info("server start") if args.config: @@ -46,24 +54,35 @@ if __name__ == "__main__": atexit.register(web.app.myp.save_config) if args.record: web.app.myp.set_record(True) - try: - web.app.myp.manager._refresh_token() - except OAuthError: - if args.mail and args.password: - client_email = args.mail - client_password = args.password - else: - client_email = input("mypeugeot email: ") - client_password = input("mypeugeot password: ") - web.app.myp.connect(client_email, client_password) - logger.info(web.app.myp.get_vehicles()) - if args.remote_disable: - logger.info("mqtt disabled") + if args.offline: + logger.info("offline mode") else: - web.app.myp.start_mqtt() - if args.charge_control: - web.app.chc = ChargeControls.load_config(web.app.myp, name=args.charge_control) - web.app.chc.start() + try: + web.app.myp.manager._refresh_token() + except OAuthError: + if args.mail and args.password: + client_email = args.mail + client_password = args.password + else: + client_email = input("mypeugeot email: ") + client_password = input("mypeugeot password: ") + web.app.myp.connect(client_email, client_password) + logger.info(str(web.app.myp.get_vehicles())) + if args.remote_disable: + logger.info("mqtt disabled") + else: + web.app.myp.start_mqtt() + if args.refresh or args.charge_control: + if args.refresh: + web.app.myp.info_refresh_rate = args.refresh * 60 + if args.charge_control: + web.app.chc = ChargeControls.load_config(web.app.myp, name=args.charge_control) + web.app.chc.init() + t2 = Thread(target=web.app.myp.refresh_vehicle_info) + t2.setDaemon(True) + t2.start() + save_config(web.app.myp) - t1 = Thread(target=start_app, args=["My car info", args.base_path, args.debug < 20, args.listen, int(args.port)]) + 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_parser.py b/trip_parser.py new file mode 100644 index 0000000..4697c88 --- /dev/null +++ b/trip_parser.py @@ -0,0 +1,68 @@ +from collections.abc import Callable +from Car import Car +from MyLogger import logger + +LEVEL = "level" + +LEVEL_FUEL = "level_fuel" + + +class TripParser: + def __init__(self, car: Car): + self.car = car + self.get_level_consumption, self.is_refuel = self.__get_energy_method() + + def __get_energy_method(self) -> [Callable]: + if self.car.is_electric(): + return TripParser.get_elec_consumption, self.__is_recharging + if self.car.is_thermal(): + return TripParser.get_thermal_consumption, self.__is_refuel + if self.car.is_hybrid(): + return TripParser.get_hybrid_consumption, self.__is_refuel_or_recharging + raise ValueError("Unknown car type") + + @staticmethod + def get_thermal_consumption(start, end): + return [0, start[LEVEL_FUEL] - end[LEVEL_FUEL]] + + @staticmethod + def get_elec_consumption(start, end): + return [start[LEVEL] - end[LEVEL], 0] + + @staticmethod + def get_hybrid_consumption(start, end): + res = [] + for energy in [LEVEL, LEVEL_FUEL]: + if start[energy] is not None and end[energy] is not None: + res.append(start[energy] - end[energy]) + else: + res.append(0) + return res + + def __is_refuel_or_recharging(self, start, end, distance): + decharge, fuel_consumption = self.get_level_consumption(start, end) + if fuel_consumption < 0: + logger.debugv("refuel detected") + return True + if TripParser.is_recharging(decharge, distance): + logger.debugv("charge detected") + return True + return False + + def __is_refuel(self, start, end, distance): + fuel_consumption = self.get_level_consumption(start, end)[1] + if fuel_consumption < 0: + logger.debugv("refuel detected") + return True + return False + + def __is_recharging(self, start, end, distance): + decharge = self.get_level_consumption(start, end)[0] + return TripParser.is_recharging(decharge, distance) + + @staticmethod + def is_recharging(decharge, distance): + # 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 diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..9e080e1 --- /dev/null +++ b/utils.py @@ -0,0 +1,44 @@ +import traceback +from functools import wraps +from threading import Semaphore, Timer + +import requests + +from MyLogger import logger + + +def get_temp(latitude: str, longitude: str, api_key: str) -> float: + try: + if not (latitude is None or longitude is None or api_key is None): + weather_rep = requests.get("https://api.openweathermap.org/data/2.5/onecall", + params={"lat": latitude, "lon": longitude, + "exclude": "minutely,hourly,daily,alerts", + "appid": api_key, + "units": "metric"}) + temp = weather_rep.json()["current"]["temp"] + logger.debug("Temperature :%fc", temp) + return temp + except ConnectionError: + logger.error("Can't connect to openweathermap :%s", traceback.format_exc()) + except KeyError: + logger.error("Unable to get temperature from openweathermap :%s", traceback.format_exc()) + return None + + +def rate_limit(limit, every): + def limit_decorator(fn): + semaphore = Semaphore(limit) + + @wraps(fn) + def wrapper(*args, **kwargs): + semaphore.acquire() + try: + return fn(*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 + timer.start() + + return wrapper + + return limit_decorator diff --git a/web/app.py b/web/app.py index e857e71..553fa8b 100644 --- a/web/app.py +++ b/web/app.py @@ -8,7 +8,7 @@ import locale from werkzeug import run_simple try: from werkzeug.middleware.dispatcher import DispatcherMiddleware -except: +except ImportError: from werkzeug import DispatcherMiddleware from ChargeControl import ChargeControls @@ -19,15 +19,15 @@ app = None dash_app = None dispatcher = 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_url = [f"https://cdn.plot.ly/plotly-locale-{lang}-latest.js"] - except: + except (IndexError, locale.Error): locale_url = None - logger.warn("Can't get language") + logger.warning("Can't get language") app = Flask(__name__) app.config["DEBUG"] = debug if base_path == "/": @@ -39,11 +39,13 @@ 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.callback + import web.views return run_simple(host, port, application, use_reloader=False, use_debugger=debug) -myp = None +# noinspection PyTypeChecker +myp:MyPSACC = None +# noinspection PyTypeChecker chc: ChargeControls = None diff --git a/web/callback.py b/web/callback.py deleted file mode 100644 index 74ae744..0000000 --- a/web/callback.py +++ /dev/null @@ -1,178 +0,0 @@ -import json -import traceback -from datetime import datetime, timezone -import dash_bootstrap_components as dbc -from dash.dependencies import Output, Input -import dash_core_components as dcc -import dash_html_components as html - -from MyLogger import logger -from flask import jsonify, request, Response as FlaskResponse - -from MyPSACC import MyPSACC -from web import figures - -from web.app import app, dash_app, myp, chc -import web.db - -trips = None -chargings = None - - -@dash_app.callback(Output('trips_map', 'figure'), - Output('consumption_fig', 'figure'), - Output('consumption_fig_by_speed', 'figure'), - Output('consumption', 'children'), - Output('tab_trips', 'children'), - Output('tab_battery', 'children'), - Input('date-slider', 'value')) -def display_value(value): - min = datetime.fromtimestamp(value[0], tz=timezone.utc) - max = datetime.fromtimestamp(value[1], tz=timezone.utc) - filtered_trips = [] - for trip in trips: - if min <= trip.start_at <= max: - filtered_trips.append(trip) - filtered_chargings = MyPSACC.get_chargings(min,max) - figures.get_figures(filtered_trips,filtered_chargings) - consumption = "Average consumption: {:.1f} kW/100km".format(float(figures.consumption_df.mean(numeric_only=True))) - return figures.trips_map, figures.consumption_fig, figures.consumption_fig_by_speed, consumption, figures.table_fig, figures.battery_info - - -@app.route('/getvehicles') -def get_vehicules(): - return jsonify(myp.getVIN()) - - -@app.route('/get_vehicleinfo/') -def get_vehicle_Info(vin): - response = app.response_class( - response=json.dumps(myp.get_vehicle_info(vin).to_dict(), default=str), - status=200, - mimetype='application/json' - ) - return response - - -@app.route('/charge_now//') -def charge_now(vin, charge): - return jsonify(myp.charge_now(vin, charge != 0)) - - -@app.route('/charge_hour') -def change_charge_hour(): - return jsonify(myp.change_charge_hour(request.form['vin'], request.form['hour'], request.form['minute'])) - - -@app.route('/wakeup/') -def wakeup(vin): - return jsonify(myp.wakeup(vin)) - - -@app.route('/preconditioning//') -def preconditioning(vin, activate): - return jsonify(myp.preconditioning(vin, activate)) - - -@app.route('/position/') -def get_position(vin): - res = myp.get_vehicle_info(vin) - coordinates=res.last_position.geometry.coordinates - if len(coordinates)==3: # altitude is not always availlable - longitude, latitude, altitude = coordinates - return jsonify( - {"longitude": longitude, "latitude": latitude, "altitude": altitude, "url": f"http://maps.google.com/maps?q={latitude},{longitude}"}) - else: - longitude, latitude = coordinates - return jsonify( - {"longitude": longitude, "latitude": latitude, "url": f"http://maps.google.com/maps?q={latitude},{longitude}"}) - - - -# Set a battery threshold and schedule an hour to stop the charge -@app.route('/charge_control') -def charge_control(): - logger.info(request) - vin = request.args['vin'] - charge_control = chc.get(vin) - if charge_control is None: - return jsonify("error: VIN not in list") - if 'hour' in request.args and 'minute' in request.args: - charge_control.set_stop_hour([int(request.args["hour"]), int(request.args["minute"])]) - if 'percentage' in request.args: - charge_control.percentage_threshold = int(request.args['percentage']) - chc.save_config() - return jsonify(charge_control.get_dict()) - - -@app.route('/positions') -def get_recorded_position(): - return FlaskResponse(myp.get_recorded_position(), mimetype='application/json') - - -@app.after_request -def after_request(response): - header = response.headers - header['Access-Control-Allow-Origin'] = '*' - return response - - -def update_trips(): - global trips, chargings - logger.info("update_data") - try: - trips = MyPSACC.get_trips() - chargings = MyPSACC.get_chargings() - except: - logger.error("update_trips: " + traceback.format_exc()) - - -try: - web.db.callback_fct = update_trips - update_trips() - min_date = trips[0].start_at - max_date = trips[-1].start_at - min_millis = figures.unix_time_millis(min_date) - max_millis = figures.unix_time_millis(max_date) - step = (max_millis - min_millis) / 100 - figures.get_figures(trips, chargings) - data_div = html.Div([dcc.RangeSlider( - id='date-slider', - min=min_millis, - max=max_millis, - step=step, - marks=figures.get_marks_from_start_end(min_date, - max_date), - 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") - ]), - 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="Map", tab_id="map", children=[ - dcc.Graph(figure=figures.trips_map, id="trips_map", style={"height": '90vh'})]), - ], - id="tabs", - active_tab="summary", - ), - html.Div(id="tab-content", className="p-4"), - ])]) -except (IndexError, TypeError) as e: - logger.debug("Failed to generate figure, there is probably not enough data yet") - data_div = dbc.Alert("No data to show", color="danger") - -except: - logger.error("Failed to generate figure, there is probably not enough data yet") - logger.error(traceback.format_exc()) - data_div = dbc.Alert("No data to show", color="danger") - -dash_app.layout = dbc.Container(fluid=True, children=[ - html.H1('My car info'), - data_div -]) diff --git a/web/db.py b/web/db.py index 962bdac..d54bac8 100644 --- a/web/db.py +++ b/web/db.py @@ -1,24 +1,33 @@ import sqlite3 from datetime import datetime -import pytz -callback_fct = None +import pytz +from typing import Callable + +from MyLogger import logger + +callback_fct: Callable[[], None] = lambda: None default_db_file = 'info.db' + + def convert_datetime(st): return datetime.strptime(st.decode("utf-8"), "%Y-%m-%d %H:%M:%S+00:00").replace(tzinfo=pytz.UTC) def update_callback(): - if callback_fct is not None: - callback_fct() - return + 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 conn.execute("CREATE TABLE IF NOT EXISTS position (Timestamp DATETIME PRIMARY KEY, VIN TEXT, longitude REAL, " - "latitude REAL, mileage REAL, level INTEGER, moving BOOLEAN, temperature INTEGER);") + "latitude REAL, mileage REAL, level INTEGER, level_fuel INTEGER, moving BOOLEAN, temperature INTEGER);") + try: + conn.execute("ALTER TABLE position ADD level_fuel INTEGER;") + except sqlite3.OperationalError: + pass conn.execute("CREATE TABLE IF NOT EXISTS battery (start_at DATETIME PRIMARY KEY,stop_at DATETIME,VIN TEXT, " "start_level INTEGER, end_level INTEGER, co2 INTEGER, kw INTEGER);") conn.create_function("update_trips", 0, update_callback) @@ -26,3 +35,14 @@ def get_db(db_file=default_db_file): "SELECT update_trips(); END;") conn.commit() return conn + + +def clean_position(conn): + res = conn.execute( + "SELECT Timestamp,mileage,level from position ORDER BY Timestamp DESC LIMIT 3;").fetchall() + # Clean DB + if len(res) == 3 and res[0]["mileage"] == res[1]["mileage"] == res[2]["mileage"] and \ + res[0]["level"] == res[1]["level"] == res[2]["level"]: + logger.debug("Delete duplicate line") + conn.execute("DELETE FROM position where Timestamp=?;", (res[1]["Timestamp"],)) + conn.commit() diff --git a/web/figures.py b/web/figures.py index d14f9c9..128accc 100644 --- a/web/figures.py +++ b/web/figures.py @@ -1,18 +1,20 @@ from copy import deepcopy -from typing import List +from typing import List, Tuple import dash_bootstrap_components as dbc import dash_table 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 Trip +from Trip import Trips from pandas import options as pandas_options import dash_html_components as html + def unix_time_millis(dt): return int(dt.timestamp()) @@ -38,20 +40,24 @@ def get_marks_from_start_end(start, end): for date in result: marks[unix_time_millis(date)] = str(date.strftime(date_f)) return marks + return None consumption_fig = None consumption_df = None trips_map = None consumption_fig_by_speed = None +consumption_graph_by_temp = None table_fig = None pandas_options.display.float_format = '${:.2f}'.format info = "" battery_info = dbc.Alert("No data to show", color="danger") +battery_table = None -def get_figures(trips: List[Trip], charging: List[dict]): - global consumption_fig, consumption_df, trips_map, consumption_fig_by_speed, table_fig, info, battery_info +def get_figures(trips: Trips, charging: Tuple[dict]): + global consumption_fig, consumption_df, trips_map, consumption_fig_by_speed, table_fig, info, battery_info, \ + battery_table, consumption_graph_by_temp lats = [] lons = [] names = [] @@ -70,47 +76,108 @@ def get_figures(trips: List[Trip], charging: List[dict]): table_fig = dash_table.DataTable( id='trips-table', sort_action='native', - sort_by=[{'column_id': 'start_at', 'direction': 'desc'}], + # sort_by=[{'column_id': 'start_at', 'direction': 'desc'}], columns=[{'id': 'start_at', 'name': 'start at', 'type': 'datetime'}, {'id': 'duration', 'name': 'duration', 'type': 'numeric', 'format': deepcopy(nb_format).symbol_suffix(" min").precision(0)}, {'id': 'speed_average', 'name': 'average speed', 'type': 'numeric', - 'format': deepcopy(nb_format).symbol_suffix(" km/h")}, + 'format': deepcopy(nb_format).symbol_suffix(" km/h").precision(0)}, {'id': 'consumption_km', 'name': 'average consumption', 'type': 'numeric', - 'format': deepcopy(nb_format).symbol_suffix(" kw/100km")}, - {'id': 'distance', 'name': 'distance', 'type': 'numeric', 'format': nb_format.symbol_suffix(" km")}], - data=[tr.get_info() for tr in trips], + 'format': deepcopy(nb_format).symbol_suffix(" kWh/100km")}, + {'id': 'consumption_fuel_km', 'name': 'average consumption fuel', 'type': 'numeric', + 'format': deepcopy(nb_format).symbol_suffix(" L/100km")}, + {'id': 'distance', 'name': 'distance', 'type': 'numeric', + 'format': nb_format.symbol_suffix(" km").precision(1)}, + {'id': 'mileage', 'name': 'mileage', 'type': 'numeric', + 'format': nb_format.symbol_suffix(" km").precision(1)}], + data=[tr.get_info() for tr in trips[::-1]], + page_size=50 ) # consumption_fig - consumption_df = DataFrame.from_records([tr.get_consumption() for tr in trips]) + consumption_df = DataFrame.from_records(trips.get_long_trips()) consumption_fig = px.line(consumption_df, x="date", y="consumption", title='Consumption of the car') consumption_fig.update_layout(yaxis_title="Consumption kWh/100Km") - consum_df_by_speed = DataFrame.from_records( - [{"speed": tr.speed_average, "consumption": tr.consumption_km} for tr in trips]) - consumption_fig_by_speed = px.histogram(consum_df_by_speed, x="speed", y="consumption", histfunc="avg", + consumption_fig_by_speed = px.histogram(consumption_df, x="speed", y="consumption_km", histfunc="avg", title="Consumption by speed") consumption_fig_by_speed.update_traces(xbins_size=15) consumption_fig_by_speed.update_layout(bargap=0.05) consumption_fig_by_speed.add_trace( - go.Scatter(mode="markers", x=consum_df_by_speed["speed"], y=consum_df_by_speed["consumption"], + go.Scatter(mode="markers", x=consumption_df["speed"], y=consumption_df["consumption_km"], name="Trips")) consumption_fig_by_speed.update_layout(xaxis_title="average Speed km/h", yaxis_title="Consumption kWh/100Km") - kw_per_km = float(consumption_df.mean(numeric_only=True)) - info = "Average consumption: {:.1f} kW/100km".format(kw_per_km) + kw_per_km = float(consumption_df["consumption_km"].mean()) + info = "Average consumption: {:.1f} kWh/100km".format(kw_per_km) # charging charging_data = DataFrame.from_records(charging) try: - co2_per_kw = charging_data["co2"].sum() / charging_data["kw"].sum() - except ZeroDivisionError: + co2_data = charging_data[charging_data["co2"] > 0] + co2_per_kw = co2_data["co2"].sum() / co2_data["kw"].sum() + except (ZeroDivisionError, KeyError): co2_per_kw = 0 co2_per_km = co2_per_kw * kw_per_km / 100 try: charge_speed = 3600 * charging_data["kw"].mean() / \ (charging_data["stop_at"] - charging_data["start_at"]).mean().total_seconds() - except TypeError: # when there is no data yet: + except (TypeError, KeyError): # when there is no data yet: charge_speed = 0 - battery_info = html.Div(children=[html.P("Average gC02/kW: {:.1f}".format(co2_per_kw)), - html.P("Average gC02/km: {:1f}".format(co2_per_km)), - html.P("Average Charge SPEED {:1f} kW/h".format(charge_speed))]) + + 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)), + ] + ), + html.Tr( + [ + "{:.1f} g/kWh".format(co2_per_kw), + ] + ), + html.Tr( + [ + html.Td("Average charge speed:"), + html.Td("{:.3f} kW".format(charge_speed)) + ] + ) + ]) + + battery_table = dash_table.DataTable( + id='battery-table', + sort_action='native', + sort_by=[{'column_id': 'start_at', 'direction': 'desc'}], + columns=[{'id': 'start_at', 'name': 'start at', 'type': 'datetime'}, + {'id': 'stop_at', 'name': 'stop at', 'type': 'datetime'}, + {'id': 'start_level', 'name': 'start level', 'type': 'numeric'}, + {'id': 'end_level', 'name': 'end level', 'type': 'numeric'}, + {'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, + ) + consumption_by_temp_df = consumption_df[consumption_df["consumption_by_temp"].notnull()] + if len(consumption_by_temp_df) > 0: + consumption_fig_by_temp = px.histogram(consumption_by_temp_df, x="consumption_by_temp", y="consumption_km", + histfunc="avg", title="Consumption by temperature") + consumption_fig_by_temp.update_traces(xbins_size=2) + consumption_fig_by_temp.update_layout(bargap=0.05) + consumption_fig_by_temp.add_trace( + go.Scatter(mode="markers", x=consumption_by_temp_df["consumption_by_temp"], + y=consumption_by_temp_df["consumption_km"], name="Trips")) + consumption_fig_by_temp.update_layout(xaxis_title="average temperature in °C", + yaxis_title="Consumption kWh/100Km") + consumption_graph_by_temp = Graph(figure=consumption_fig_by_temp, id="consumption_fig_by_temp") + + else: + consumption_graph_by_temp = Graph(style={'display': 'none'}) diff --git a/web/views.py b/web/views.py new file mode 100644 index 0000000..6bdf0a7 --- /dev/null +++ b/web/views.py @@ -0,0 +1,208 @@ +import json +import traceback +from datetime import datetime, timezone +import dash_bootstrap_components as dbc +from dash.dependencies import Output, Input +import dash_core_components as dcc +import dash_html_components as html + +from MyLogger import logger +from flask import jsonify, request, Response as FlaskResponse + +from MyPSACC import MyPSACC +from Trip import Trips +from web import figures + +from web.app import app, dash_app, myp, chc +import web.db + +ERROR_DIV = dbc.Alert("No data to show, there is probably no trips recorded yet", color="danger") +trips: Trips +chargings: 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_fig_by_temp', 'graph'), + 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 + + +@app.route('/get_vehicles') +def get_vehicules(): + response = app.response_class( + response=json.dumps(myp.get_vehicles(), default=lambda car: car.to_dict()), + status=200, + mimetype='application/json' + ) + return response + + +@app.route('/get_vehicleinfo/') +def get_vehicle_info(vin): + response = app.response_class( + response=json.dumps(myp.get_vehicle_info(vin).to_dict(), default=str), + status=200, + mimetype='application/json' + ) + return response + + +@app.route('/charge_now//') +def charge_now(vin, charge): + return jsonify(myp.charge_now(vin, charge != 0)) + + +@app.route('/charge_hour') +def change_charge_hour(): + return jsonify(myp.change_charge_hour(request.form['vin'], request.form['hour'], request.form['minute'])) + + +@app.route('/wakeup/') +def wakeup(vin): + return jsonify(myp.wakeup(vin)) + + +@app.route('/preconditioning//') +def preconditioning(vin, activate): + return jsonify(myp.preconditioning(vin, activate)) + + +@app.route('/position/') +def get_position(vin): + res = myp.get_vehicle_info(vin) + try: + coordinates = res.last_position.geometry.coordinates + except AttributeError: + return jsonify({'error': 'last_position not available from api'}) + longitude, latitude, altitude = coordinates[:2] + if len(coordinates) == 3: # altitude is not always available + altitude = coordinates[2] + else: + altitude = None + return jsonify( + {"longitude": longitude, "latitude": latitude, "altitude": altitude, + "url": f"https://maps.google.com/maps?q={latitude},{longitude}"}) + + +# Set a battery threshold and schedule an hour to stop the charge +@app.route('/charge_control') +def charge_control(): + logger.info(request) + vin = request.args['vin'] + charge_control = chc.get(vin) + if charge_control is None: + return jsonify("error: VIN not in list") + if 'hour' in request.args and 'minute' in request.args: + charge_control.set_stop_hour([int(request.args["hour"]), int(request.args["minute"])]) + if 'percentage' in request.args: + charge_control.percentage_threshold = int(request.args['percentage']) + chc.save_config() + return jsonify(charge_control.get_dict()) + + +@app.route('/positions') +def get_recorded_position(): + return FlaskResponse(myp.get_recorded_position(), mimetype='application/json') + + +@app.after_request +def after_request(response): + header = response.headers + header['Access-Control-Allow-Origin'] = '*' + return response + + +def update_trips(): + global trips, chargings, cached_layout + logger.info("update_data") + try: + trips_by_vin = Trips.get_trips(myp.vehicles_list) + trips = next(iter(trips_by_vin.values())) # todo handle multiple car + chargings = MyPSACC.get_chargings() + except (StopIteration, AssertionError): + logger.error("update_trips: %s", traceback.format_exc()) + # update for slider + global min_date, max_date, min_millis, max_millis, step, marks + try: + min_date = trips[0].start_at + max_date = trips[-1].start_at + min_millis = figures.unix_time_millis(min_date) + max_millis = figures.unix_time_millis(max_date) + step = (max_millis - min_millis) / 100 + marks = figures.get_marks_from_start_end(min_date, max_date) + cached_layout = None # force regenerate layout + except (ValueError, IndexError): + logger.error("update_trips (slider): %s", traceback.format_exc()) + + +def serve_layout(): + global cached_layout + if cached_layout is None: + logger.debug("Create new layout") + try: + figures.get_figures(trips, chargings) + data_div = html.Div([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'})]), + ], + id="tabs", + active_tab="summary", + ), + html.Div(id="tab-content", className="p-4"), + ])]) + + except (IndexError, TypeError): + logger.debug("Failed to generate figure, there is probably not enough data yet %s", traceback.format_exc()) + data_div = ERROR_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 + update_trips() +except (IndexError, TypeError): + logger.debug("Failed to get trips, there is probably not enough data yet %s", traceback.format_exc()) + +dash_app.layout = serve_layout