mirror of
https://github.com/flobz/psa_car_controller.git
synced 2026-08-23 01:46:14 +00:00
Merge pull request #55 from flobz/feature-hybrid_compatibility
Feature hybrid compatibility
This commit is contained in:
+6
-1
@@ -139,6 +139,11 @@ cython_debug/
|
||||
|
||||
.idea/
|
||||
backup.ab
|
||||
*.apk
|
||||
info.db
|
||||
otp.bin
|
||||
charge_config1.json
|
||||
config.json
|
||||
test.json
|
||||
test.json
|
||||
charge_config.json
|
||||
cars.json
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
doc-warnings: false
|
||||
ignore-paths:
|
||||
- psa_connectedcar
|
||||
@@ -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()
|
||||
+76
-63
@@ -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)
|
||||
|
||||
+11
-1
@@ -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)
|
||||
|
||||
+220
-236
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+27
-22
@@ -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 !!!")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+101
-83
@@ -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
|
||||
return otp
|
||||
|
||||
+9
-23
@@ -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("")
|
||||
return self.currentIndex < len(self.s)
|
||||
+38
-34
@@ -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
|
||||
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
|
||||
|
||||
+25
-24
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
prospector>=1.3.0
|
||||
pre-commit
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
+8
-6
@@ -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
|
||||
|
||||
|
||||
|
||||
-178
@@ -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/<string:vin>')
|
||||
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/<string:vin>/<int:charge>')
|
||||
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/<string:vin>')
|
||||
def wakeup(vin):
|
||||
return jsonify(myp.wakeup(vin))
|
||||
|
||||
|
||||
@app.route('/preconditioning/<string:vin>/<int:activate>')
|
||||
def preconditioning(vin, activate):
|
||||
return jsonify(myp.preconditioning(vin, activate))
|
||||
|
||||
|
||||
@app.route('/position/<string:vin>')
|
||||
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
|
||||
])
|
||||
@@ -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()
|
||||
|
||||
+89
-22
@@ -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'})
|
||||
|
||||
+208
@@ -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/<string:vin>')
|
||||
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/<string:vin>/<int:charge>')
|
||||
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/<string:vin>')
|
||||
def wakeup(vin):
|
||||
return jsonify(myp.wakeup(vin))
|
||||
|
||||
|
||||
@app.route('/preconditioning/<string:vin>/<int:activate>')
|
||||
def preconditioning(vin, activate):
|
||||
return jsonify(myp.preconditioning(vin, activate))
|
||||
|
||||
|
||||
@app.route('/position/<string:vin>')
|
||||
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
|
||||
Reference in New Issue
Block a user