diff --git a/Pipfile b/Pipfile index 79e1208f..fab00cfd 100644 --- a/Pipfile +++ b/Pipfile @@ -17,6 +17,7 @@ black = "*" [packages] aiohttp = "*" backoff = "*" +wrapt = "*" [pipenv] allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock index 28e37604..b11922a8 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "81182701cadd01dcf3eb5056bd5b37a42cdc2cfa46f7489e8df4d3650edca98c" + "sha256": "56fd0cfd6dd3899803ad48904a136010a6bdde1495c8b2cbee1e70f4d8ac06bc" }, "pipfile-spec": 6, "requires": {}, @@ -41,6 +41,13 @@ ], "version": "==19.3.0" }, + "backoff": { + "hashes": [ + "sha256:5e73e2cbe780e1915a204799dba0a01896f45f4385e636bcca7a0614d879d0cd", + "sha256:b8fba021fac74055ac05eb7c7bfce4723aedde6cd0a504e5326bcb0bdd6d19a4" + ], + "version": "==1.10.0" + }, "chardet": { "hashes": [ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", @@ -85,6 +92,12 @@ ], "version": "==3.7.4.1" }, + "wrapt": { + "hashes": [ + "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" + ], + "version": "==1.11.2" + }, "yarl": { "hashes": [ "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce", @@ -130,13 +143,6 @@ ], "version": "==19.3.0" }, - "backoff": { - "hashes": [ - "sha256:5e73e2cbe780e1915a204799dba0a01896f45f4385e636bcca7a0614d879d0cd", - "sha256:b8fba021fac74055ac05eb7c7bfce4723aedde6cd0a504e5326bcb0bdd6d19a4" - ], - "version": "==1.10.0" - }, "black": { "hashes": [ "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", @@ -514,10 +520,10 @@ }, "python-semantic-release": { "hashes": [ - "sha256:038f8961b617906db72634ef0746515b5de17e09cd3a69a6b0a1c020e6b5edb3", - "sha256:e00fae90765a380c2d619fdf0ad9c940a4b8b769bec964845c03edb9ca73af7f" + "sha256:71a0dfb59ab159f5a356cf06719be75c4457816ea2d090c9b3ffb2ce15f30618", + "sha256:98f8a4b21831c0aeae4331a4805b9c33d4945c6f2842bffac93b25972e40c303" ], - "version": "==4.4.0" + "version": "==4.5.0" }, "readme-renderer": { "hashes": [ diff --git a/setup.py b/setup.py index c292539e..72282316 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ VERSION = None # What packages are required for this module to be executed? -REQUIRED = ["aiohttp"] +REQUIRED = ["aiohttp", "backoff", "wrapt"] # What packages are optional? EXTRAS = { diff --git a/teslajsonpy/controller.py b/teslajsonpy/controller.py index 456dee25..1f5e5414 100644 --- a/teslajsonpy/controller.py +++ b/teslajsonpy/controller.py @@ -6,12 +6,12 @@ https://github.com/zabuldon/teslajsonpy """ import asyncio -from functools import wraps import logging import time -from typing import Optional, Text, Tuple +from typing import Callable, Optional, Text, Tuple import backoff +import wrapt from teslajsonpy.battery_sensor import Battery, Range from teslajsonpy.binary_sensor import ( @@ -58,6 +58,150 @@ def min_expo(base=2, factor=1, max_value=None, min_value=0): yield max_value +@wrapt.decorator +async def wake_up(wrapped, instance, args, kwargs) -> Callable: + # pylint: disable=protected-access + """Wrap a API func so it will attempt to wake the vehicle if asleep. + + The command wrapped is run once if the car_id was last reported + online. If wrapped detects the car_id is offline, five attempts + will be made to wake the vehicle to reissue the command. + + Raises + RetryLimitError: The wake_up has exceeded the 5 attempts. + TeslaException: Tesla connection errors + + Returns + Callable: Wrapped function that will wake_up + + """ + + def valid_result(result): + """Check if 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["response"].get("result") is True + or result["response"].get("reason") + != "could_not_wake_buses" + ) + ) + ) + ) + except TypeError as exception: + _LOGGER.error("Result: %s, %s", result, exception) + + retries = 0 + sleep_delay = 2 + instance = args[0] + car_id = args[1] + is_wake_command = len(args) >= 3 and args[2] == "wake_up" + result = None + if instance.car_online.get(instance._id_to_vin(car_id)) or is_wake_command: + try: + result = await wrapped(*args, **kwargs) + except TeslaException as ex: + _LOGGER.debug( + "Exception: %s\n%s(%s %s)", + ex.message, + wrapped.__name__, + args, + kwargs, + ) + raise + if valid_result(result) or is_wake_command: + return result + _LOGGER.debug( + "wake_up needed for %s -> %s \n" + "Info: args:%s, kwargs:%s, " + "VIN:%s, car_online:%s", + wrapped.__name__, + result, + args, + kwargs, + instance._id_to_vin(car_id)[-5:], + instance.car_online, + ) + instance.car_online[instance._id_to_vin(car_id)] = False + while ( + kwargs.get("wake_if_asleep") + and + # Check online state + ( + car_id is None + or ( + not instance._id_to_vin(car_id) + or not instance.car_online.get(instance._id_to_vin(car_id)) + ) + ) + ): + _LOGGER.debug("Attempting to wake up") + result = await instance._wake_up(car_id) + _LOGGER.debug( + "%s(%s): Wake Attempt(%s): %s", + wrapped.__name__, + instance._id_to_vin(car_id)[-5:], + retries, + result, + ) + if not result: + if retries < 5: + await asyncio.sleep(15 + sleep_delay ** (retries + 2)) + retries += 1 + continue + instance.car_online[instance._id_to_vin(car_id)] = False + raise RetryLimitError("Reached retry limit; aborting wake up") + break + instance.car_online[instance._id_to_vin(car_id)] = True + # retry function + _LOGGER.debug( + "Retrying %s(%s %s)", + wrapped.__name__, + args, + kwargs, + ) + try: + result = await wrapped(*args, **kwargs) + _LOGGER.debug( + "Retry after wake up succeeded: %s", + "True" if valid_result(result) else result, + ) + except TeslaException as ex: + _LOGGER.debug( + "Exception: %s\n%s(%s %s)", + ex.message, + wrapped.__name__, + args, + kwargs, + ) + raise + if valid_result(result): + return result + raise TeslaException("could_not_wake_buses") + + class Controller: # pylint: disable=too-many-public-methods """Controller for connections to Tesla Motors API.""" @@ -200,160 +344,6 @@ def register_websocket_callback(self, callback) -> int: self.__websocket_listeners.append(callback) return len(self.__websocket_listeners) - 1 - def wake_up(func): - # pylint: disable=no-self-argument - # issue is use of wraps on classmethods which should be replaced: - # https://hynek.me/articles/decorators/ - """Wrap a API func so it will attempt to wake the vehicle if asleep. - - The command func is run once if the car_id was last reported - online. Assuming func 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 - car_id (string): The vehicle to attempt to wake. - TODO: This currently requires a car_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 func - - Throws: - RetryLimitError - - """ - - @wraps(func) - async def wrapped(*args, **kwargs): - # pylint: disable=too-many-branches,protected-access, not-callable - def valid_result(result): - """Check if 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["response"].get("result") is True - or result["response"].get("reason") - != "could_not_wake_buses" - ) - ) - ) - ) - except TypeError as exception: - _LOGGER.error("Result: %s, %s", result, exception) - - retries = 0 - sleep_delay = 2 - inst = args[0] - car_id = args[1] - is_wake_command = len(args) >= 3 and args[2] == "wake_up" - result = None - if inst.car_online.get(inst._id_to_vin(car_id)) or is_wake_command: - try: - result = await func(*args, **kwargs) - except TeslaException as ex: - _LOGGER.debug( - "Exception: %s\n%s(%s %s)", - ex.message, - func.__name__, # pylint: disable=no-member, - args, - kwargs, - ) - raise - if valid_result(result) or is_wake_command: - return result - _LOGGER.debug( - "wake_up needed for %s -> %s \n" - "Info: args:%s, kwargs:%s, " - "VIN:%s, car_online:%s", - func.__name__, # pylint: disable=no-member - result, - args, - kwargs, - inst._id_to_vin(car_id)[-5:], - inst.car_online, - ) - inst.car_online[inst._id_to_vin(car_id)] = False - while ( - kwargs.get("wake_if_asleep") - and - # Check online state - ( - car_id is None - or ( - not inst._id_to_vin(car_id) - or not inst.car_online.get(inst._id_to_vin(car_id)) - ) - ) - ): - _LOGGER.debug("Attempting to wake up") - result = await inst._wake_up(car_id) - _LOGGER.debug( - "%s(%s): Wake Attempt(%s): %s", - func.__name__, # pylint: disable=no-member, - inst._id_to_vin(car_id)[-5:], - retries, - result, - ) - if not result: - if retries < 5: - await asyncio.sleep(15 + sleep_delay ** (retries + 2)) - retries += 1 - continue - inst.car_online[inst._id_to_vin(car_id)] = False - raise RetryLimitError("Reached retry limit; aborting wake up") - break - inst.car_online[inst._id_to_vin(car_id)] = True - # retry function - _LOGGER.debug( - "Retrying %s(%s %s)", - func.__name__, # pylint: disable=no-member, - args, - kwargs, - ) - try: - result = await func(*args, **kwargs) - _LOGGER.debug( - "Retry after wake up succeeded: %s", - "True" if valid_result(result) else result, - ) - except TeslaException as ex: - _LOGGER.debug( - "Exception: %s\n%s(%s %s)", - ex.message, - func.__name__, # pylint: disable=no-member, - args, - kwargs, - ) - raise - if valid_result(result): - return result - raise TeslaException("could_not_wake_buses") - - return wrapped - async def get_vehicles(self): """Get vehicles json from TeslaAPI.""" return (await self.__connection.get("vehicles"))["response"]