catch error on charge control

fix position api, improve handle of token expiration
add rate limit to wakeup fct
fix encoder name, wakeup device to follow charging status
wakeup device if it doesn't send info for 10 mins
This commit is contained in:
Florian Bezannier
2020-12-04 14:39:25 +01:00
parent 85be9de562
commit 541a42ac72
5 changed files with 218 additions and 117 deletions
+40 -33
View File
@@ -70,42 +70,49 @@ class ChargeControl:
def start(self):
periodicity = ChargeControl.periodicity
now = datetime.now()
if self._next_stop_hour is not None and self._next_stop_hour < now:
stop_charge = True
self._next_stop_hour += timedelta(days=1)
logger.info("it's time to stop the charge")
else :
stop_charge = False
try:
if self._next_stop_hour is not None and self._next_stop_hour < now:
stop_charge = True
self._next_stop_hour += timedelta(days=1)
logger.info("it's time to stop the charge")
else :
stop_charge = False
if self.percentage_threshold != 100 or stop_charge:
res = None
try:
res = self.psacc.get_vehicle_info(self.vin)
except ApiException:
logger.error(traceback.format_exc())
if res is not None:
status = res.energy[0]['charging']['status']
level = res.energy[0]["level"]
logger.info(f"charging status of {self.vin} is {status}, battery level: {level}")
if status == "InProgress":
if (level >= self.percentage_threshold and self.retry_count < 2) or stop_charge:
self.psacc.charge_now(self.vin,False)
self.retry_count += 1
sleep(ChargeControl.MQTT_TIMEOUT)
res = self.psacc.get_vehicle_info(self.vin)
status = res.energy[0]['charging']['status']
if status == "InProgress":
logger.warn(f"retry to stop the charge of {self.vin}")
self.psacc.charge_now(self.vin, False)
if self.percentage_threshold != 100 or stop_charge:
res = None
try:
res = self.psacc.get_vehicle_info(self.vin)
except ApiException:
logger.error(traceback.format_exc())
if res is not None:
status = res.energy[0]['charging']['status']
level = res.energy[0]["level"]
logger.info(f"charging status of {self.vin} is {status}, battery level: {level}")
if status == "InProgress":
# force update if the car doesn't send info during 10 minutes
last_update = datetime.strptime(res.energy[0]['updatedAt'], "%Y-%m-%dT%H:%M:%SZ")
if (datetime.utcnow() - last_update).total_seconds() > 60 * 10:
self.psacc.wakeup(self.vin)
if (level >= self.percentage_threshold and self.retry_count < 2) or stop_charge:
self.psacc.charge_now(self.vin,False)
self.retry_count += 1
if self._next_stop_hour is not None:
next_in_second = (self._next_stop_hour- now).total_seconds()
if next_in_second < periodicity:
periodicity = next_in_second
sleep(ChargeControl.MQTT_TIMEOUT)
res = self.psacc.get_vehicle_info(self.vin)
status = res.energy[0]['charging']['status']
if status == "InProgress":
logger.warn(f"retry to stop the charge of {self.vin}")
self.psacc.charge_now(self.vin, False)
self.retry_count += 1
if self._next_stop_hour is not None:
next_in_second = (self._next_stop_hour- now).total_seconds()
if next_in_second < periodicity:
periodicity = next_in_second
else:
self.retry_count = 0
else:
self.retry_count = 0
else:
logger.error(f"error when get vehicle info of {self.vin}")
logger.error(f"error when get vehicle info of {self.vin}")
except:
logger.error(traceback.format_exc())
self.thread = threading.Timer(periodicity, self.start)
self.thread.start()
+109 -24
View File
@@ -7,6 +7,8 @@ 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
import paho.mqtt.client as mqtt
from requests import Response
@@ -14,6 +16,9 @@ import psa_connectedcar as psac
from psa_connectedcar import ApiClient
from psa_connectedcar.rest import ApiException
from MyLogger import logger
from threading import Semaphore, Timer
from functools import wraps
oauhth_url = {"clientsB2CPeugeot":"https://idpcvs.peugeot.com/am/oauth2/access_token",
"clientsB2CCitroen":"https://idpcvs.citroen.com/am/oauth2/access_token",
@@ -25,6 +30,28 @@ 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']
MQTT_SERVER = "mwa.mpsa.com"
MQTT_REQ_TOPIC = "psa/RemoteServices/from/cid/"
MQTT_RESP_TOPIC = "psa/RemoteServices/to/cid/"
MQTT_EVENT_TOPIC = "psa/RemoteServices/events/MPHRTServices/"
MQTT_TOKEN_TTL = 890
def rate_limit(limit, every):
def limit_decorator(fn):
semaphore = Semaphore(limit)
@wraps(fn)
def wrapper(*args, **kwargs):
semaphore.acquire()
try:
return fn(*args, **kwargs)
finally: # don't catch but ensure semaphore release
timer = Timer(every, semaphore.release)
timer.setDaemon(True) # allows the timer to be canceled on exit
timer.start()
return wrapper
return limit_decorator
class OpenIdCredentialManager(CredentialManager):
def _grant_password_request(self, login: str, password: str, realm: str) -> dict:
@@ -126,6 +153,9 @@ class MyPSACC:
"x-introspect-realm": realm,
"accept": "application/hal+json",
}
self.remote_token_last_update = None
self._record_enabled = False
def refresh_token(self):
self.manager._refresh_token()
@@ -148,6 +178,8 @@ class MyPSACC:
# retry
if res is None:
res = self.api().get_vehicle_status(self.get_vehicle_id_with_vin(vin))
if self._record_enabled:
self.record_position(vin, res)
return res
# monitor doesn't seem to work
@@ -182,7 +214,12 @@ class MyPSACC:
self.remote_refresh_token = data["refresh_token"]
return res
def refresh_remote_token(self):
def refresh_remote_token(self, force=False):
self.manager._refresh_token()
if not force and self.remote_token_last_update is not None:
last_update: datetime = self.remote_token_last_update
if (datetime.now()-last_update).total_seconds() < MQTT_TOKEN_TTL:
return
res = self.manager.post(remote_url + self.client_id,
json={"grant_type": "refresh_token", "refresh_token": self.remote_refresh_token},
headers=self.headers)
@@ -190,14 +227,15 @@ class MyPSACC:
logger.debug(f"refresh_remote_token: {data}")
self.remote_access_token = data["access_token"]
self.remote_refresh_token = data["refresh_token"]
self.remote_token_last_update = datetime.now()
return data["access_token"], data["refresh_token"]
def on_mqtt_connect(self, client, userdata, rc, a):
try:
logger.info("Connected with result code " + str(rc))
topics = ["psa/RemoteServices/to/cid/" + self.customer_id + "/#"]
topics = [MQTT_RESP_TOPIC + self.customer_id + "/#"]
for vin in self.getVIN():
topics.append("psa/RemoteServices/events/MPHRTServices/" + vin + "/#")
topics.append(MQTT_EVENT_TOPIC+ vin + "/#")
for topic in topics:
client.subscribe(topic)
logger.info("subscribe to " + topic)
@@ -215,18 +253,24 @@ class MyPSACC:
def on_mqtt_message(self, client, userdata, msg):
logger.info(f"mqtt msg {msg.topic} {str(msg.payload)}")
try:
data = json.loads(msg.payload)
if data["return_code"] == "0":
return
elif data["return_code"] == "400":
self.manager._refresh_token()
self.refresh_remote_token()
logger.info("retry last request")
else:
logger.error(f'{data["return_code"]} : {data["reason"]}')
except:
logger.debug("mqtt msg hasn't return code")
data = json.loads(msg.payload)
if msg.topic.startswith(MQTT_RESP_TOPIC):
try:
if data["return_code"] == "0":
return
elif data["return_code"] == "400":
self.refresh_remote_token(force=True)
logger.error("retry last request, token was expired")
else:
logger.error(f'{data["return_code"]} : {data["reason"]}')
except:
logger.debug("mqtt msg hasn't return code")
elif msg.topic.startswith(MQTT_EVENT_TOPIC):
# fix charge beginning without status api being updated
if data["charging_state"]['remaining_time'] != 0 and data["charging_state"]['rate'] == 0:
logger.info("charge begin")
sleep(60)
self.wakeup(data["vin"])
def start_mqtt(self):
self.refresh_remote_token()
@@ -241,7 +285,8 @@ class MyPSACC:
return self.mqtt_client.is_connected()
def mqtt_request(self, vin, req_parameters):
date = datetime.now()
self.refresh_token()
date = datetime.utcnow()
date_f = "%Y-%m-%dT%H:%M:%SZ"
date_str = date.strftime(date_f)
data = {"access_token": self.remote_access_token, "customer_id": self.customer_id,
@@ -271,7 +316,7 @@ class MyPSACC:
# todo consider actual state before change the hour
msg = self.mqtt_request(vin, {"program": {"hour": hour, "minute": miinute}, "type": charge_type})
logger.info(msg)
self.mqtt_client.publish("psa/RemoteServices/from/cid/" + self.customer_id + "/VehCharge", 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
@@ -290,17 +335,19 @@ class MyPSACC:
def horn(self, vin, count):
msg = self.mqtt_request(vin, {"nb_horn": count, "action": "activate"})
logger.info(msg)
self.mqtt_client.publish("psa/RemoteServices/from/cid/" + self.customer_id + "/Horn", msg)
self.mqtt_client.publish(MQTT_REQ_TOPIC + self.customer_id + "/Horn", msg)
def lights(self, vin, duration: int):
msg = self.mqtt_request(vin, {"action": "activate", "duration": duration})
logger.info(msg)
self.mqtt_client.publish("psa/RemoteServices/from/cid/" + self.customer_id + "/Lights", msg)
self.mqtt_client.publish(MQTT_REQ_TOPIC + self.customer_id + "/Lights", msg)
@rate_limit(3, 60 * 20)
def wakeup(self, vin):
logger.info("ask wakeup to "+vin)
msg = self.mqtt_request(vin, {"action": "state"})
logger.info(msg)
self.mqtt_client.publish("psa/RemoteServices/from/cid/" + self.customer_id + "/VehCharge/state", msg)
self.mqtt_client.publish(MQTT_REQ_TOPIC + self.customer_id + "/VehCharge/state", msg)
return True
def lock_door(self, vin, lock: bool):
@@ -311,7 +358,7 @@ class MyPSACC:
msg = self.mqtt_request(vin, {"action": value})
logger.info(msg)
self.mqtt_client.publish("psa/RemoteServices/from/cid/" + self.customer_id + "/Doors", msg)
self.mqtt_client.publish(MQTT_REQ_TOPIC + self.customer_id + "/Doors", msg)
return True
def preconditioning(self, vin, activate: bool):
@@ -325,11 +372,11 @@ class MyPSACC:
"program3": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0},
"program4": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0}}})
logger.info(msg)
self.mqtt_client.publish("psa/RemoteServices/from/cid/" + self.customer_id + "/ThermalPrecond", msg)
self.mqtt_client.publish(MQTT_REQ_TOPIC + self.customer_id + "/ThermalPrecond", msg)
return True
def save_config(self, name="config.json", force=False):
config_str = json.dumps(self, cls=MyPuegeotEncoder, sort_keys=True, indent=4).encode("utf8")
config_str = json.dumps(self, cls=MyPeugeotEncoder, sort_keys=True, indent=4).encode("utf8")
new_hash = md5(config_str).hexdigest()
if force or self._confighash != new_hash:
with open(name, "wb") as f:
@@ -337,13 +384,51 @@ class MyPSACC:
self._confighash = new_hash
logger.info("save config change")
@staticmethod
def load_config(name="config.json"):
with open(name, "r") as f:
str = f.read()
return MyPSACC(**json.loads(str))
def set_record(self,value:bool):
self._record_enabled = value
class MyPuegeotEncoder(JSONEncoder):
def record_position(self,vin, res:psac.models.status.Status):
import sqlite3
conn = sqlite3.connect('info.db')
conn.execute(
"CREATE TABLE IF NOT EXISTS position (Timestamp DATETIME PRIMARY KEY, VIN TEXT, longitude REAL, latitude REAL);")
longitude = res.last_position.geometry.coordinates[0]
latitude = res.last_position.geometry.coordinates[1]
date = res.last_position.properties.updated_at
e = None
try:
conn.execute("INSERT INTO position(Timestamp,VIN,longitude,latitude) VALUES(?,?,?,?)",
(date, vin, longitude, latitude))
conn.commit()
except sqlite3.IntegrityError:
logger.debug("position already saved")
finally:
conn.close()
def get_recorded_position(self):
import sqlite3
from geojson import Feature, Point, FeatureCollection
from geojson import dumps as geo_dumps
conn = sqlite3.connect('info.db')
conn.row_factory = sqlite3.Row
res = conn.execute('SELECT * FROM position ORDER BY Timestamp');
features_list = []
for row in res:
print(row)
feature = Feature(geometry=Point((row["longitude"], row["latitude"])),
properties={"vin": row["vin"], "date": row["Timestamp"]})
features_list.append(feature)
feature_collection = FeatureCollection(features_list)
return geo_dumps(feature_collection, sort_keys=True)
class MyPeugeotEncoder(JSONEncoder):
def default(self, mp: MyPSACC):
data = copy(mp.__dict__)
mpd = {}
+27 -56
View File
@@ -31,58 +31,58 @@ class Position(object):
and the value is json key in definition.
"""
swagger_types = {
'created_at': 'datetime',
'type': 'str',
'geometry': 'Point',
'properties': 'PositionProperties',
'type': 'str'
'properties': 'PositionProperties'
}
attribute_map = {
'created_at': 'createdAt',
'type': 'type',
'geometry': 'geometry',
'properties': 'properties',
'type': 'type'
'properties': 'properties'
}
def __init__(self, created_at=None, geometry=None, properties=None, type='Feature'): # noqa: E501
def __init__(self, type='Feature', geometry=None, properties=None): # noqa: E501
"""Position - a model defined in Swagger""" # noqa: E501
self._created_at = None
self._type = None
self._geometry = None
self._properties = None
self._type = None
self.discriminator = None
if created_at is not None:
self.created_at = created_at
if geometry is not None:
self.geometry = geometry
self.properties = properties
if type is not None:
self.type = type
if geometry is not None:
self.geometry = geometry
if properties is not None:
self.properties = properties
@property
def created_at(self):
"""Gets the created_at of this Position. # noqa: E501
def type(self):
"""Gets the type of this Position. # noqa: E501
Date when the resource has been created. # noqa: E501
:return: The created_at of this Position. # noqa: E501
:rtype: datetime
:return: The type of this Position. # noqa: E501
:rtype: str
"""
return self._created_at
return self._type
@created_at.setter
def created_at(self, created_at):
"""Sets the created_at of this Position.
@type.setter
def type(self, type):
"""Sets the type of this Position.
Date when the resource has been created. # noqa: E501
:param created_at: The created_at of this Position. # noqa: E501
:type: datetime
:param type: The type of this Position. # noqa: E501
:type: str
"""
allowed_values = ["Feature"] # noqa: E501
if type not in allowed_values:
raise ValueError(
"Invalid value for `type` ({0}), must be one of {1}" # noqa: E501
.format(type, allowed_values)
)
self._created_at = created_at
self._type = type
@property
def geometry(self):
@@ -123,38 +123,9 @@ class Position(object):
:param properties: The properties of this Position. # noqa: E501
:type: PositionProperties
"""
if properties is None:
raise ValueError("Invalid value for `properties`, must not be `None`") # noqa: E501
self._properties = properties
@property
def type(self):
"""Gets the type of this Position. # noqa: E501
:return: The type of this Position. # noqa: E501
:rtype: str
"""
return self._type
@type.setter
def type(self, type):
"""Sets the type of this Position.
:param type: The type of this Position. # noqa: E501
:type: str
"""
allowed_values = ["Feature"] # noqa: E501
if type not in allowed_values:
raise ValueError(
"Invalid value for `type` ({0}), must be one of {1}" # noqa: E501
.format(type, allowed_values)
)
self._type = type
def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
+29 -3
View File
@@ -33,21 +33,24 @@ class PositionProperties(object):
swagger_types = {
'heading': 'float',
'signal_quality': 'float',
'type': 'str'
'type': 'str',
'updated_at': 'datetime'
}
attribute_map = {
'heading': 'heading',
'signal_quality': 'signalQuality',
'type': 'type'
'type': 'type',
'updated_at': 'updatedAt'
}
def __init__(self, heading=None, signal_quality=None, type=None): # noqa: E501
def __init__(self, heading=None, signal_quality=None, type=None, updated_at=None): # noqa: E501
"""PositionProperties - a model defined in Swagger""" # noqa: E501
self._heading = None
self._signal_quality = None
self._type = None
self._updated_at = None
self.discriminator = None
if heading is not None:
@@ -56,6 +59,8 @@ class PositionProperties(object):
self.signal_quality = signal_quality
if type is not None:
self.type = type
if updated_at is not None:
self.updated_at = updated_at
@property
def heading(self):
@@ -130,6 +135,27 @@ class PositionProperties(object):
self._type = type
@property
def updated_at(self):
"""Gets the updated_at of this PositionProperties. # noqa: E501
:return: The updated_at of this PositionProperties. # noqa: E501
:rtype: datetime
"""
return self._updated_at
@updated_at.setter
def updated_at(self, updated_at):
"""Sets the updated_at of this PositionProperties.
:param updated_at: The updated_at of this PositionProperties. # noqa: E501
:type: datetime
"""
self._updated_at = updated_at
def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
+13 -1
View File
@@ -8,6 +8,7 @@ from ChargeControl import ChargeControls
from MyLogger import my_logger
from MyPSACC import *
from flask import Flask, request, jsonify
from flask import Response as FlaskResponse
import argparse
from MyLogger import logger
@@ -49,6 +50,11 @@ def wakeup(vin):
def preconditioning(vin, activate):
return jsonify(myp.preconditioning(vin, activate))
@app.route('/position/<string:vin>')
def get_position(vin):
res = myp.get_vehicle_info(vin)
longitude, latitude = res.last_position.geometry.coordinates
return jsonify({"longitude":longitude,"latitude":latitude,"url":f"http://maps.google.com/maps?q={latitude},{longitude}"})
def save_config(mypeugeot: MyPSACC):
myp.save_config()
@@ -70,6 +76,9 @@ def charge_control():
chc.save_config()
return jsonify(charge_control.get_dict())
@app.route('/positions')
def get_recorded_position():
return FlaskResponse(myp.get_recorded_position(), mimetype='application/json')
def parse_args():
parser = argparse.ArgumentParser()
@@ -78,6 +87,7 @@ def parse_args():
parser.add_argument("-d", "--debug", help="enable debug", const=10, default=20, nargs='?')
parser.add_argument("-l", "--listen", help="change server listen address", default="127.0.0.1")
parser.add_argument("-p", "--port", help="change server listen address", default="5000")
parser.add_argument("-r", "--record-position", help="save vehicle position to db", action='store_true')
parser.add_argument("--remote-disable",help="disable remote control")
parser.parse_args()
return parser
@@ -85,7 +95,7 @@ def parse_args():
if __name__ == "__main__":
if sys.version_info < (3, 6):
raise RuntimeError("This application requres Python 3.6+")
raise RuntimeError("This application requires Python 3.6+")
parser = parse_args()
args = parser.parse_args()
my_logger(handler_level=args.debug)
@@ -94,6 +104,8 @@ if __name__ == "__main__":
myp = MyPSACC.load_config(name=args.config.name)
else:
myp = MyPSACC.load_config()
if args.record_position:
myp.set_record(True)
try:
myp.manager._refresh_token()
except OAuthError: