diff --git a/setup.py b/setup.py index 43d6b97f..a2db877b 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup setup( name='teslajsonpy', - version='0.0.22', + version='0.0.24', packages=['teslajsonpy'], include_package_data=True, python_requires='>=3', @@ -17,6 +17,8 @@ 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Topic :: Internet', ], ) diff --git a/teslajsonpy/BatterySensor.py b/teslajsonpy/BatterySensor.py index 29cfc112..6106e452 100644 --- a/teslajsonpy/BatterySensor.py +++ b/teslajsonpy/BatterySensor.py @@ -16,7 +16,7 @@ def __init__(self, data, controller): self.update() def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_charging_params(self._id) if data: self.__battery_level = data['battery_level'] @@ -46,7 +46,7 @@ def __init__(self, data, controller): self.update() def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_charging_params(self._id) if data: self.__battery_range = data['battery_range'] diff --git a/teslajsonpy/BinarySensor.py b/teslajsonpy/BinarySensor.py index 163ebf03..ab1bddf0 100644 --- a/teslajsonpy/BinarySensor.py +++ b/teslajsonpy/BinarySensor.py @@ -16,7 +16,7 @@ def __init__(self, data, controller): self.update() def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_drive_params(self._id) if data: if not data['shift_state'] or data['shift_state'] == 'P': @@ -45,7 +45,7 @@ def __init__(self, data, controller): self.bin_type = 0x2 def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_charging_params(self._id) if data: if data['charging_state'] in ["Disconnected", "Stopped", "NoPower"]: diff --git a/teslajsonpy/Charger.py b/teslajsonpy/Charger.py index 37d11d3c..e8eba65e 100644 --- a/teslajsonpy/Charger.py +++ b/teslajsonpy/Charger.py @@ -15,7 +15,7 @@ def __init__(self, data, controller): self.update() def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_charging_params(self._id) if data and (time.time() - self.__manual_update_time > 60): if data['charging_state'] != "Charging": @@ -25,14 +25,16 @@ def update(self): def start_charge(self): if not self.__charger_state: - data = self._controller.command(self._id, 'charge_start') + data = self._controller.command(self._id, 'charge_start', + wake_if_asleep=True) if data and data['response']['result']: self.__charger_state = True self.__manual_update_time = time.time() def stop_charge(self): if self.__charger_state: - data = self._controller.command(self._id, 'charge_stop') + data = self._controller.command(self._id, 'charge_stop', + wake_if_asleep=True) if data and data['response']['result']: self.__charger_state = False self.__manual_update_time = time.time() @@ -58,21 +60,23 @@ def __init__(self, data, controller): self.update() def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_charging_params(self._id) if data and (time.time() - self.__manual_update_time > 60): self.__maxrange_state = data['charge_to_max_range'] def set_max(self): if not self.__maxrange_state: - data = self._controller.command(self._id, 'charge_max_range') + data = self._controller.command(self._id, 'charge_max_range', + wake_if_asleep=True) if data['response']['result']: self.__maxrange_state = True self.__manual_update_time = time.time() def set_standard(self): if self.__maxrange_state: - data = self._controller.command(self._id, 'charge_standard') + data = self._controller.command(self._id, 'charge_standard', + wake_if_asleep=True) if data and data['response']['result']: self.__maxrange_state = False self.__manual_update_time = time.time() diff --git a/teslajsonpy/Climate.py b/teslajsonpy/Climate.py index 0b30b5c3..4757e1fa 100644 --- a/teslajsonpy/Climate.py +++ b/teslajsonpy/Climate.py @@ -38,7 +38,7 @@ def get_fan_status(self): return self.__fan_status def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_climate_params(self._id) if data: @@ -56,7 +56,10 @@ def update(self): def set_temperature(self, temp): temp = round(temp, 1) self.__manual_update_time = time.time() - data = self._controller.command(self._id, 'set_temps', {"driver_temp": temp, "passenger_temp": temp}) + data = self._controller.command(self._id, 'set_temps', + {"driver_temp": temp, + "passenger_temp": temp}, + wake_if_asleep=True) if data['response']['result']: self.__driver_temp_setting = temp self.__passenger_temp_setting = temp @@ -64,12 +67,16 @@ def set_temperature(self, temp): def set_status(self, enabled): self.__manual_update_time = time.time() if enabled: - data = self._controller.command(self._id, 'auto_conditioning_start') + data = self._controller.command(self._id, + 'auto_conditioning_start', + wake_if_asleep=True) if data['response']['result']: self.__is_auto_conditioning_on = True self.__is_climate_on = True else: - data = self._controller.command(self._id, 'auto_conditioning_stop') + data = self._controller.command(self._id, + 'auto_conditioning_stop', + wake_if_asleep=True) if data['response']['result']: self.__is_auto_conditioning_on = False self.__is_climate_on = False @@ -101,7 +108,7 @@ def get_outside_temp(self): return self.__outside_temp def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_climate_params(self._id) if data: self.__inside_temp = data['inside_temp'] if data['inside_temp'] else self.__inside_temp diff --git a/teslajsonpy/Exceptions.py b/teslajsonpy/Exceptions.py index 01966839..082fbe44 100644 --- a/teslajsonpy/Exceptions.py +++ b/teslajsonpy/Exceptions.py @@ -19,3 +19,7 @@ def __init__(self, code, *args, **kwargs): self.message = 'SERVICE_MAINTENANCE' elif self.code > 299: self.message = "UNKNOWN_ERROR" + +class RetryLimitError(TeslaException): + def __init__(self, *args, **kwargs): + pass diff --git a/teslajsonpy/GPS.py b/teslajsonpy/GPS.py index 1b7bbfb5..24c6b940 100644 --- a/teslajsonpy/GPS.py +++ b/teslajsonpy/GPS.py @@ -24,7 +24,7 @@ def get_location(self): return self.__location def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_drive_params(self._id) if data: self.__longitude = data['longitude'] @@ -54,7 +54,7 @@ def __init__(self, data, controller): self.__rated = True def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_state_params(self._id) if data: self.__odometer = data['odometer'] diff --git a/teslajsonpy/Lock.py b/teslajsonpy/Lock.py index 0de1e9db..cc27e26b 100644 --- a/teslajsonpy/Lock.py +++ b/teslajsonpy/Lock.py @@ -18,21 +18,23 @@ def __init__(self, data, controller): self.update() def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_state_params(self._id) if data and (time.time() - self.__manual_update_time > 60): self.__lock_state = data['locked'] def lock(self): if not self.__lock_state: - data = self._controller.command(self._id, 'door_lock') + data = self._controller.command(self._id, 'door_lock', + wake_if_asleep=True) if data['response']['result']: self.__lock_state = True self.__manual_update_time = time.time() def unlock(self): if self.__lock_state: - data = self._controller.command(self._id, 'door_unlock') + data = self._controller.command(self._id, 'door_unlock', + wake_if_asleep=True) if data['response']['result']: self.__lock_state = False self.__manual_update_time = time.time() @@ -61,21 +63,23 @@ def __init__(self, data, controller): self.update() def update(self): - self._controller.update(self._id) + self._controller.update(self._id, wake_if_asleep=False) data = self._controller.get_charging_params(self._id) if data and (time.time() - self.__manual_update_time > 60): self.__lock_state = not ((data['charge_port_door_open']) and (data['charge_port_door_open']) and (data['charge_port_latch'] != 'Engaged')) def lock(self): if not self.__lock_state: - data = self._controller.command(self._id, 'charge_port_door_close') + data = self._controller.command(self._id, 'charge_port_door_close', + wake_if_asleep=True) if data['response']['result']: self.__lock_state = True self.__manual_update_time = time.time() def unlock(self): if self.__lock_state: - data = self._controller.command(self._id, 'charge_port_door_open') + data = self._controller.command(self._id, 'charge_port_door_open', + wake_if_asleep=True) if data['response']['result']: self.__lock_state = False self.__manual_update_time = time.time() diff --git a/teslajsonpy/connection.py b/teslajsonpy/connection.py index b9e36458..e1b83508 100644 --- a/teslajsonpy/connection.py +++ b/teslajsonpy/connection.py @@ -4,11 +4,14 @@ from urllib.request import Request, build_opener from urllib.error import HTTPError import json +import logging from teslajsonpy.Exceptions import TeslaException +_LOGGER = logging.getLogger(__name__) class Connection(object): """Connection to Tesla Motors API""" + def __init__(self, email, password): """Initialize connection object""" self.user_agent = 'Model S 2.1.79 (SM-G900V; Android REL 4.4.4; en_US' @@ -50,6 +53,8 @@ def __open(self, url, headers={}, data=None, baseurl=""): if not baseurl: baseurl = self.baseurl req = Request("%s%s" % (baseurl, url), headers=headers) + _LOGGER.debug(url) + try: req.data = urlencode(data).encode('utf-8') except TypeError: @@ -61,9 +66,11 @@ def __open(self, url, headers={}, data=None, baseurl=""): charset = resp.info().get('charset', 'utf-8') data = json.loads(resp.read().decode(charset)) opener.close() + _LOGGER.debug(json.dumps(data)) return data except HTTPError as e: if e.code == 408: + _LOGGER.debug("%s", e) return False else: raise TeslaException(e.code) diff --git a/teslajsonpy/controller.py b/teslajsonpy/controller.py index 23edf0f0..a9bc722f 100644 --- a/teslajsonpy/controller.py +++ b/teslajsonpy/controller.py @@ -7,6 +7,11 @@ from teslajsonpy.BinarySensor import ParkingSensor, ChargerConnectionSensor from teslajsonpy.Charger import ChargerSwitch, RangeSwitch from teslajsonpy.GPS import GPS, Odometer +from teslajsonpy.Exceptions import TeslaException +from functools import wraps +import logging +from .Exceptions import RetryLimitError +_LOGGER = logging.getLogger(__name__) class Controller: @@ -20,13 +25,30 @@ def __init__(self, email, password, update_interval): self.__state = {} self.__driving = {} self.__gui = {} - self.__last_update_time = {} + self._last_update_time = {} # succesful attempts by car + self._last_wake_up_time = {} # succesful wake_ups by car + self._last_attempted_update_time = 0 # all attempts by controller self.__lock = RLock() - cars = self.__connection.get('vehicles')['response'] + self._car_online = {} + + cars = self.get_vehicles() + self._last_attempted_update_time = time.time() + for car in cars: - self.__last_update_time[car['id']] = 0 + self._last_update_time[car['id']] = 0 + self._last_wake_up_time[car['id']] = 0 self.__update[car['id']] = True - self.update(car['id']) + self._car_online[car['id']] = (car['state'] == 'online') + self.__climate[car['id']] = False + self.__charging[car['id']] = False + self.__state[car['id']] = False + self.__driving[car['id']] = False + self.__gui[car['id']] = False + + try: + self.update(car['id'], wake_if_asleep=False) + except (TeslaException, RetryLimitError): + pass self.__vehicles.append(Climate(car, self)) self.__vehicles.append(Battery(car, self)) self.__vehicles.append(Range(car, self)) @@ -40,44 +62,207 @@ def __init__(self, email, password, update_interval): self.__vehicles.append(GPS(car, self)) self.__vehicles.append(Odometer(car, self)) - def post(self, vehicle_id, command, data={}): - return self.__connection.post('vehicles/%i/%s' % (vehicle_id, command), data) + def wake_up(f): + """Wraps a API f so it will attempt to wake the vehicle if asleep. + + The command f is run once if the vehicle_id was last reported + online. Assuming f returns None and wake_if_asleep is True, 5 attempts + will be made to wake the vehicle to reissue the command. In addition, + if there is a `could_not_wake_buses` error, it will retry the command + Args: + inst (Controller): The instance of a controller + vehicle_id (string): The vehicle to attempt to wake. + TODO: This currently requires a vehicle_id, but update() does not; This + should also be updated to allow that case + wake_if_asleep (bool): Keyword arg to force a vehicle awake. Must be + set in the wrapped function f + Throws: + RetryLimitError + """ + @wraps(f) + def wrapped(*args, **kwargs): + def valid_result(result): + """Is TeslaAPI result succesful. + + Parameters + ---------- + result : tesla API result + This is the result of a Tesla Rest API call. + + Returns + ------- + bool + Tesla API failure can be checked in a dict with a bool in + ['response']['result'], a bool, or None or + ['response']['reason'] == 'could_not_wake_buses' + Returns true when a failure state not detected. + """ + try: + return (result is not None and result is not False and + (result is True or + (isinstance(result, dict) and + isinstance(result['response'], dict) and + ('result' in result['response'] and + result['response']['result'] is True) or + ('reason' in result['response'] and + result['response']['reason'] != + 'could_not_wake_buses') or + ('result' not in result['response'])))) + except TypeError as exception: + _LOGGER.error("Result: %s, %s", result, exception) + retries = 0 + sleep_delay = 2 + inst = args[0] + vehicle_id = args[1] + result = None + if (vehicle_id is not None and vehicle_id in inst._car_online and + inst._car_online[vehicle_id]): + try: + result = f(*args, **kwargs) + except TeslaException: + pass + if valid_result(result): + return result + _LOGGER.debug("Wrapped %s -> %s \n" + "Additional info: args:%s, kwargs:%s, " + "vehicle_id:%s, _car_online:%s", + f.__name__, result, args, kwargs, vehicle_id, + inst._car_online) + inst._car_online[vehicle_id] = False + while ('wake_if_asleep' in kwargs and kwargs['wake_if_asleep'] + and + # Check online state + (vehicle_id is None or + (vehicle_id is not None and + vehicle_id in inst._car_online and + not inst._car_online[vehicle_id]))): + result = inst._wake_up(vehicle_id, *args, **kwargs) + _LOGGER.debug("Wake Attempt(%s): %s", retries, result) + if not result: + if retries < 5: + time.sleep(sleep_delay**(retries+2)) + retries += 1 + continue + else: + inst._car_online[vehicle_id] = False + raise RetryLimitError + else: + break + # try function five more times + retries = 0 + while True: + try: + result = f(*args, **kwargs) + _LOGGER.debug("Retry Attempt(%s): %s", retries, result) + except TeslaException: + pass + finally: + retries += 1 + time.sleep(sleep_delay**(retries+1)) + if valid_result(result): + return result + elif retries >= 5: + raise RetryLimitError + return wrapped - def get(self, vehicle_id, command): + def get_vehicles(self): + return self.__connection.get('vehicles')['response'] + + @wake_up + def post(self, vehicle_id, command, data={}, wake_if_asleep=True): + return self.__connection.post('vehicles/%i/%s' % + (vehicle_id, command), data) + + @wake_up + def get(self, vehicle_id, command, wake_if_asleep=False): return self.__connection.get('vehicles/%i/%s' % (vehicle_id, command)) - def data_request(self, vehicle_id, name): - return self.get(vehicle_id, 'data_request/%s' % name)['response'] + def data_request(self, vehicle_id, name, wake_if_asleep=False): + return self.get(vehicle_id, 'data_request/%s' % name, + wake_if_asleep=False)['response'] - def command(self, vehicle_id, name, data={}): - return self.post(vehicle_id, 'command/%s' % name, data) + def command(self, vehicle_id, name, data={}, wake_if_asleep=True): + return self.post(vehicle_id, 'command/%s' % name, data, + wake_if_asleep=True) def list_vehicles(self): return self.__vehicles - def wake_up(self, vehicle_id): - self.post(vehicle_id, 'wake_up') + def _wake_up(self, vehicle_id, *args, **kwargs): + cur_time = int(time.time()) + if (not self._car_online[vehicle_id] or + (cur_time - self._last_wake_up_time[vehicle_id] > 300)): + result = self.post(vehicle_id, + 'wake_up', + wake_if_asleep=False) # avoid wrapper loop + self._car_online[vehicle_id] = (result['response']['state'] == + 'online') + self._last_wake_up_time[vehicle_id] = cur_time + _LOGGER.debug("Wakeup %s: %s", vehicle_id, + result['response']['state']) + return self._car_online[vehicle_id] + + def update(self, car_id=None, wake_if_asleep=False, force=False): + """Updates all vehicle attributes in the cache. - def update(self, car_id): + This command will connect to the Tesla API and first update the list of + online vehicles assuming no attempt for at least the [update_interval]. + It will then update all the cached values for cars that are awake + assuming no update has occurred for at least the [update_interval]. + + Args: + inst (Controller): The instance of a controller + car_id (string): The vehicle to update. If None, all cars are updated. + wake_if_asleep (bool): Keyword arg to force a vehicle awake. This is + processed by the wake_up decorator. + force (bool): Keyword arg to force a vehicle update regardless of the + update_interval + + Returns: + True if any update succeeded for any vehicle else false + Throws: + RetryLimitError + """ cur_time = time.time() with self.__lock: - if (self.__update[car_id] and - (cur_time - self.__last_update_time[car_id] > self.update_interval)): - self.wake_up(car_id) - data = self.get(car_id, 'data') - if data and data['response']: - self.__climate[car_id] = data['response']['climate_state'] - self.__charging[car_id] = data['response']['charge_state'] - self.__state[car_id] = data['response']['vehicle_state'] - self.__driving[car_id] = data['response']['drive_state'] - self.__gui[car_id] = data['response']['gui_settings'] - self.__last_update_time[car_id] = time.time() - else: - self.__climate[car_id] = False - self.__charging[car_id] = False - self.__state[car_id] = False - self.__driving[car_id] = False - self.__gui[car_id] = False + # Update the online cars using get_vehicles() + last_update = self._last_attempted_update_time + if (force or cur_time - last_update > self.update_interval): + cars = self.get_vehicles() + for car in cars: + self._car_online[car['id']] = (car['state'] == 'online') + self._last_attempted_update_time = cur_time + # Only update online vehicles that haven't been updated recently + # The throttling is per car's last succesful update + # Note: This separate check is because there may be individual cars + # to update. + update_succeeded = False + for id, v in self._car_online.items(): + # If specific car_id provided, only update match + if (car_id is not None and car_id != id): + continue + if (v and + (id in self.__update and self.__update[id]) and + (force or id not in self._last_update_time or + ((cur_time - self._last_update_time[id]) > + self.update_interval))): + # Only update cars with update flag on + try: + data = self.get(id, 'data', wake_if_asleep) + except TeslaException: + data = None + if data and data['response']: + response = data['response'] + self.__climate[car_id] = response['climate_state'] + self.__charging[car_id] = response['charge_state'] + self.__state[car_id] = response['vehicle_state'] + self.__driving[car_id] = response['drive_state'] + self.__gui[car_id] = response['gui_settings'] + self._car_online[car_id] = (response['state'] + == 'online') + self._last_update_time[car_id] = time.time() + update_succeeded = True + return update_succeeded def get_climate_params(self, car_id): return self.__climate[car_id] diff --git a/teslajsonpy/vehicle.py b/teslajsonpy/vehicle.py index 07a7f8ea..c8a10e34 100644 --- a/teslajsonpy/vehicle.py +++ b/teslajsonpy/vehicle.py @@ -16,7 +16,13 @@ def _uniq_name(self): str(self._vin[3]).upper(), self._vin, self.type) def id(self): - return self._id + return self._id + + def assumed_state(self): + return (not self._controller._car_online[self.id()] and + (self._controller._last_update_time[self.id()] - + self._controller._last_wake_up_time[self.id()] > + self._controller.update_interval)) @staticmethod def is_armable():