Merge pull request #87 from flobz/develop

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