diff --git a/ChargeControl.py b/ChargeControl.py index 08ae86c..9679867 100644 --- a/ChargeControl.py +++ b/ChargeControl.py @@ -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() diff --git a/MyPSACC.py b/MyPSACC.py index eda14d0..8b5c72b 100644 --- a/MyPSACC.py +++ b/MyPSACC.py @@ -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 = {} diff --git a/psa_connectedcar/models/position.py b/psa_connectedcar/models/position.py index 0c7ed7e..4e3e3a9 100644 --- a/psa_connectedcar/models/position.py +++ b/psa_connectedcar/models/position.py @@ -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 = {} diff --git a/psa_connectedcar/models/position_properties.py b/psa_connectedcar/models/position_properties.py index e5164ad..3412d52 100644 --- a/psa_connectedcar/models/position_properties.py +++ b/psa_connectedcar/models/position_properties.py @@ -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 = {} diff --git a/server.py b/server.py index fc12b64..bb1f1a7 100644 --- a/server.py +++ b/server.py @@ -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/') +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: