Merge pull request #55 from flobz/feature-hybrid_compatibility

Feature hybrid compatibility
This commit is contained in:
Florian BEZANNIER
2021-03-29 17:34:11 +02:00
committed by GitHub
24 changed files with 1336 additions and 761 deletions
+6 -1
View File
@@ -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
+5
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
doc-warnings: false
ignore-paths:
- psa_connectedcar
+121
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+194 -34
View File
@@ -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
View File
@@ -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 !!!")
+13 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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("&amp;", "&")
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
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
prospector>=1.3.0
pre-commit
+1
View File
@@ -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
+41 -22
View File
@@ -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()
+68
View File
@@ -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)
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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
])
+26 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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