mirror of
https://github.com/flobz/psa_car_controller.git
synced 2026-08-22 09:26:16 +00:00
@@ -140,6 +140,7 @@ cython_debug/
|
||||
.idea/
|
||||
backup.ab
|
||||
*.apk
|
||||
*.ini
|
||||
info.db
|
||||
otp.bin
|
||||
charge_config1.json
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
doc-warnings: false
|
||||
ignore-paths:
|
||||
- psa_connectedcar
|
||||
pep8:
|
||||
disable:
|
||||
- E722
|
||||
pyflakes:
|
||||
disable:
|
||||
- F401
|
||||
pylint:
|
||||
disable:
|
||||
- fixme
|
||||
- C0114
|
||||
- C0115
|
||||
- C0116
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
|
||||

|
||||
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
@@ -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,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():
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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')
|
||||
@@ -0,0 +1 @@
|
||||
from .otp import Otp
|
||||
+15
-14
@@ -4,25 +4,25 @@ from time import time
|
||||
|
||||
from Cryptodome.Cipher import AES
|
||||
|
||||
from otp.Tokenizer import Tokenizer
|
||||
from .tokenizer import Tokenizer
|
||||
|
||||
default_token = "0.2.11&&&&&&0&&0&&0&&9f13ba238fbabba08e85d93638e98ef5e48682a9d3e5bc325c3dd6fac8199a6ce09e9b4f373aa6a" \
|
||||
DEFAULT_TOKEN = "0.2.11&&&&&&0&&0&&0&&9f13ba238fbabba08e85d93638e98ef5e48682a9d3e5bc325c3dd6fac8199a6ce09e9b4f373aa6a" \
|
||||
"75a905c3d690f6e3335d1e8e5b748ecec3020a794149033f6ada6896db6d73b8d43b8365bbe15b9ac66f49d4e684a3628f1e" \
|
||||
"9f3deda0c4e24aba771946e6085b92c5ad312477152acf8db01e6aea4b409d5ac1a05c2fd4e95&&0&&&&&&&&&&&&0&&0&&0&" \
|
||||
"&0&&0&&0&&0&&&&&&&&0&&0&&0&&0&&0&&2.0.0&&http://m.inwebo.com/&&"
|
||||
default_version = "529"
|
||||
DEFAULT_VERSION = "529"
|
||||
|
||||
|
||||
def filterLoad(string: str):
|
||||
def filter_load(string: str):
|
||||
return string.replace("&", "&")
|
||||
|
||||
|
||||
# pylint: disable=invalid-name,too-many-instance-attributes,too-many-branches,too-many-statements
|
||||
class IWData:
|
||||
def __init__(self, IW):
|
||||
self.IW = IW
|
||||
self.tokenizer = Tokenizer(default_token)
|
||||
self.tokenizer = Tokenizer(DEFAULT_TOKEN)
|
||||
self.tokenizer.nextToken()
|
||||
self.load1xx(int(default_version), self.tokenizer)
|
||||
self.load1xx(int(DEFAULT_VERSION), self.tokenizer)
|
||||
|
||||
def load1xx(self, j, tokenizer):
|
||||
self.iwid = tokenizer.nextToken()
|
||||
@@ -64,8 +64,8 @@ class IWData:
|
||||
i = 0
|
||||
while i < self.iwsrvn:
|
||||
self.iwsrvid[i] = tokenizer.nextToken()
|
||||
self.iwsrvname[i] = filterLoad(tokenizer.nextToken())
|
||||
self.iwsrvlogo[i] = filterLoad(tokenizer.nextToken())
|
||||
self.iwsrvname[i] = filter_load(tokenizer.nextToken())
|
||||
self.iwsrvlogo[i] = filter_load(tokenizer.nextToken())
|
||||
if self.IW.isMac:
|
||||
self.iwsrvconnected[i] = tokenizer.nextTokenI()
|
||||
if j > 515:
|
||||
@@ -77,7 +77,7 @@ class IWData:
|
||||
if i2 < 0 or self.IW.isMac:
|
||||
self.iwsrvurl[i] = ""
|
||||
else:
|
||||
self.iwsrvurl[i] = filterLoad(tokenizer.nextToken())
|
||||
self.iwsrvurl[i] = filter_load(tokenizer.nextToken())
|
||||
if j < 520 or self.IW.isMac:
|
||||
self.iwsrvonlineotp[i] = 0
|
||||
else:
|
||||
@@ -106,16 +106,17 @@ class IWData:
|
||||
i4 = 0
|
||||
while i4 < self.iwmsgn:
|
||||
self.iwmsgid += tokenizer.nextToken()
|
||||
self.iwmsgtitle += filterLoad(tokenizer.nextToken())
|
||||
self.iwmsgcontent += filterLoad(tokenizer.nextToken())
|
||||
self.iwmsgtitle += filter_load(tokenizer.nextToken())
|
||||
self.iwmsgcontent += filter_load(tokenizer.nextToken())
|
||||
self.iwmsgack += tokenizer.nextTokenI()
|
||||
i4 += 1
|
||||
self.iwmajorversion = tokenizer.nextTokenI()
|
||||
self.iwnewversion = filterLoad(tokenizer.nextToken())
|
||||
self.iwnewversionurl = filterLoad(tokenizer.nextToken())
|
||||
self.iwnewversion = filter_load(tokenizer.nextToken())
|
||||
self.iwnewversionurl = filter_load(tokenizer.nextToken())
|
||||
self.mustupgrade = False
|
||||
self.datatouch = 0
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
def synchro(self, ixml: dict, key):
|
||||
aes_cipher = AES.new(bytes.fromhex(key), AES.MODE_ECB)
|
||||
value = ixml.get("id")
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user