From fb7cc9e905ae8dcb39aa27fe19a8e4aed184ddff Mon Sep 17 00:00:00 2001 From: Carlin Hefner Date: Sat, 3 Dec 2022 18:50:01 -0500 Subject: [PATCH] fix: represent car states properly --- teslajsonpy/car.py | 11 +- teslajsonpy/const.py | 6 + teslajsonpy/controller.py | 182 ++++++++++++++-------- tests/unit_tests/test_car.py | 57 ++++++- tests/unit_tests/test_polling_interval.py | 8 +- 5 files changed, 188 insertions(+), 76 deletions(-) diff --git a/teslajsonpy/car.py b/teslajsonpy/car.py index 22025c68..a657a9f1 100644 --- a/teslajsonpy/car.py +++ b/teslajsonpy/car.py @@ -378,9 +378,14 @@ def is_trunk_closed(self) -> bool: @property def is_on(self) -> bool: - """Return car is on.""" + """Return car is online (available, even if asleep).""" return self._controller.is_car_online(vin=self.vin) + @property + def is_asleep(self) -> bool: + """Return car is asleep.""" + return self._controller.is_car_asleep(vin=self.vin) + @property def longitude(self) -> float: """Return longitude.""" @@ -745,9 +750,10 @@ async def _send_command( name, path_vars=path_vars, wake_if_asleep=wake_if_asleep, **kwargs ) _LOGGER.debug("Response from command %s: %s", name, data) + self._controller.reset_tesla_exceptions(vin=self.vin) return data except TeslaException as ex: - if ex.code == 408 and not wake_if_asleep and not self.is_on: + if ex.code == 408 and not wake_if_asleep and self.is_asleep: # 408 due to being asleep and we didn't try to wake it _LOGGER.debug( "Vehicle unavailable for command: %s, car state: %s, wake_if_asleep: %s", @@ -756,6 +762,7 @@ async def _send_command( wake_if_asleep, ) return None + self._controller.count_tesla_exceptions(vin=self.vin) raise ex def _get_lat_long(self) -> Tuple[Optional[float], Optional[float]]: diff --git a/teslajsonpy/const.py b/teslajsonpy/const.py index ec5f3117..be96d380 100644 --- a/teslajsonpy/const.py +++ b/teslajsonpy/const.py @@ -30,3 +30,9 @@ RESOURCE_TYPE = "resource_type" RESOURCE_TYPE_SOLAR = "solar" RESOURCE_TYPE_BATTERY = "battery" + +STATUS_ONLINE = "online" # reported by Tesla, vehicle available and awake +STATUS_ASLEEP = "asleep" # reported by Tesla, vehicle available but asleep +STATUS_OFFLINE = "offline" # reported by Tesla, vehicle offline, wake/sleep unknown +STATUS_UNAVAILABLE = "unavailable" # set by Controller after successive api failures, overrides the above +STATUS_UNKNOWN = "unknown" # set by Controller during initialization, default value diff --git a/teslajsonpy/controller.py b/teslajsonpy/controller.py index 874e7d06..3ae778de 100644 --- a/teslajsonpy/controller.py +++ b/teslajsonpy/controller.py @@ -27,6 +27,10 @@ RESOURCE_TYPE, RESOURCE_TYPE_SOLAR, RESOURCE_TYPE_BATTERY, + STATUS_ONLINE, + STATUS_ASLEEP, + STATUS_UNKNOWN, + STATUS_UNAVAILABLE, WAKE_TIMEOUT, WAKE_CHECK_INTERVAL, ) @@ -162,7 +166,8 @@ def __init__( self.__lock = {} self.__update_lock = None # controls access to update function self.__wakeup_lock = {} - self.car_online = {} + self.__car_state: Dict[str, str] = {} + self.__car_sleeping: Dict[str, bool] = {} self.__id_vin_map = {} self.__vin_id_map = {} self.__vin_vehicle_id_map = {} @@ -178,6 +183,7 @@ def __init__( self._include_energysites: bool = True self._product_list: List[dict] = [] self._vehicle_list: List[dict] = [] + self._consecutive_exceptions: Dict[str, int] = {} self._vehicle_data: Dict[str, dict] = {} self._energysite_list: List[dict] = [] self._site_config: Dict[int, dict] = {} @@ -335,12 +341,14 @@ async def get_vehicle_data(self, vin: str, wake_if_asleep: bool = False) -> dict wake_if_asleep=wake_if_asleep, ) )["response"] + self.reset_tesla_exceptions(vin=vin) except TeslaException as ex: - if ex.message == "VEHICLE_UNAVAILABLE": - _LOGGER.debug("Vehicle offline - data unavailable.") - return {} - raise ex + self.count_tesla_exceptions(vin=vin) + if should_giveup(ex): + raise ex + _LOGGER.debug("Unable to get vehicle data: %s: %s", ex.code, ex.message) + return {} return response @@ -402,21 +410,23 @@ async def generate_car_objects( self._last_wake_up_time[vin] = 0 self.__update[vin] = True self.__update_state[vin] = "normal" - self.set_car_online(vin=vin, online_status=car["state"] == "online") + self._consecutive_exceptions[vin] = 0 + self._set_car_state(car.get("state", STATUS_UNKNOWN), vin=vin) self.set_last_park_time(vin=vin, timestamp=self._last_attempted_update_time) self.__driving[vin] = {} self._vehicle_data[vin] = {} - try: - self._vehicle_data[vin] = await self.get_vehicle_data( - vin, wake_if_asleep=wake_if_asleep - ) - except TeslaException as ex: - _LOGGER.warning( - "Unable to get vehicle data during setup, car will still be added. %s: %s", - ex.code, - ex.message, - ) + if not self.is_car_asleep(vin=vin) or wake_if_asleep: + try: + self._vehicle_data[vin] = await self.get_vehicle_data( + vin, wake_if_asleep=wake_if_asleep + ) + except TeslaException as ex: + _LOGGER.warning( + "Unable to get vehicle data during setup, car will still be added. %s: %s", + ex.code, + ex.message, + ) self.cars[vin] = TeslaCar(car, self, self._vehicle_data[vin]) return self.cars @@ -511,27 +521,35 @@ async def wake_up(self, car_id) -> bool: result = await self.api( "WAKE_UP", path_vars={"vehicle_id": car_id}, wake_if_asleep=False ) - state = result.get("response", {}).get("state") - self.set_car_online( - car_id=car_id, - online_status=state == "online", - ) - while not self.is_car_online(vin=car_vin) and time.time() < wake_deadline: + self._set_car_state(result.get("response", {}).get("state"), car_id=car_id) + while self.is_car_asleep(vin=car_vin) and time.time() < wake_deadline: await asyncio.sleep(WAKE_CHECK_INTERVAL) response = await self.get_vehicle_summary(vin=car_vin) - state = response.get("state") - self.set_car_online( - car_id=car_id, - online_status=state == "online", - ) + self._set_car_state(response.get("state"), car_id=car_id) _LOGGER.debug( "%s: Wakeup took %d seconds, state: %s", car_vin[-5:], time.time() - wake_start_time, - state, + self.get_car_state(vin=car_vin), ) - return self.is_car_online(vin=car_vin) + return not self.is_car_asleep(vin=car_vin) + + def count_tesla_exceptions(self, vin: str) -> None: + """Keep track of consecutive exceptions.""" + if vin not in self._consecutive_exceptions: + self._consecutive_exceptions[vin] = 0 + self._consecutive_exceptions[vin] += 1 + _LOGGER.debug( + "%s: exception counter increased to %d", + vin[-5:], + self._consecutive_exceptions[vin], + ) + + def reset_tesla_exceptions(self, vin: str) -> None: + """Reset consecutive exception counter.""" + self._consecutive_exceptions[vin] = 0 + _LOGGER.debug("%s: exception counter reset", vin[-5:]) def _calculate_next_interval(self, vin: Text) -> int: cur_time = round(time.time()) @@ -552,7 +570,7 @@ def _calculate_next_interval(self, vin: Text) -> int: if vin not in self.__update_state: self.__update_state[vin] = "normal" - if self.cars[vin].state == "asleep" or self.cars[vin].shift_state: + if self.is_car_asleep(vin=vin) or self.cars[vin].shift_state: self.set_last_park_time( vin=vin, timestamp=cur_time, shift_state=self.cars[vin].shift_state ) @@ -781,10 +799,8 @@ async def _get_and_process_battery_summary( self.set_vehicle_id_vin( vehicle_id=car["vehicle_id"], vin=car["vin"] ) - self.set_car_online( - vin=car["vin"], online_status=car["state"] == "online" - ) self.cars[car["vin"]].update_car_info(car) + self._set_car_state(car["state"], vin=car["vin"]) self._last_attempted_update_time = cur_time # Only update online vehicles that haven't been updated recently @@ -794,26 +810,20 @@ async def _get_and_process_battery_summary( car_id = self._update_id(car_id) car_vin = self._id_to_vin(car_id) - for vin, online in self.get_car_online().items(): + for vin, car in self.cars.items(): # If specific car_id provided, only update match if ( (car_vin and car_vin != vin) or vin not in self.__lock - or (vin and self.cars[vin].in_service) + or (vin and car.in_service) ): continue async with self.__lock[vin]: if ( - ( - online - or ( - wake_if_asleep - and self.cars[vin].state in ["asleep", "offline"] - ) - ) - and ( # pylint: disable=too-many-boolean-expressions - self.__update.get(vin) + (not self.is_car_asleep(vin=vin) or wake_if_asleep) + and self.__update.get( + vin ) # Only update cars with update flag on and ( force @@ -833,7 +843,7 @@ async def _get_and_process_battery_summary( "Last wake up %s ago. " ), vin[-5:], - self.cars[vin].state, + car.state, self.__update.get(vin), cur_time - self._last_update_time[vin], cur_time - self.get_last_park_time(vin=vin), @@ -1036,41 +1046,54 @@ def set_last_wake_up_time( _LOGGER.debug("%s: Resetting last_wake_up_time to: %s", vin[-5:], timestamp) self._last_wake_up_time[vin] = timestamp - def set_car_online( - self, car_id: Text = None, vin: Text = None, online_status: bool = True + def _set_car_state( + self, new_state: str, car_id: Text = None, vin: Text = None ) -> None: - """Set online status for car_id. + """Set vehicle state for car_id or vin. - Will also update "last_wake_up_time" if the car changes from offline + Will also update "last_wake_up_time" if the car changes from asleep to online Parameters ---------- + new_state: string + The state string from a vehicle api response car_id : string Identifier for the car on the owner-api endpoint. vin : string VIN number - online_status : boolean - True if the car is online (awake) - False if the car is offline (out of reach or sleeping) - - """ + if not (car_id or vin): + raise ValueError("Either car_id or vin must be provided to set car state") if car_id and not vin: vin = self._id_to_vin(car_id) - if vin and self.get_car_online(vin=vin) != online_status: + + if self._consecutive_exceptions.get(vin, 0) >= 5 and new_state == STATUS_ONLINE: + # Tesla is clearly lying that it's online, set it as unavailable instead + new_state = STATUS_UNAVAILABLE + + if self.get_car_state(vin=vin) != new_state: _LOGGER.debug( - "%s: Changing car_online from %s to %s", + "%s: Changing car state from %s to %s", vin[-5:], - self.get_car_online(vin=vin), - online_status, + self.get_car_state(vin=vin), + new_state, ) - self.car_online[vin] = online_status - if online_status: - self.set_last_wake_up_time(vin=vin, timestamp=round(time.time())) - - def get_car_online(self, car_id: Text = None, vin: Text = None): + self.__car_state[vin] = new_state + # Update the car object's state as well if it exists + if vin in self.cars: + self.cars[vin].update_car_info({"state": new_state, "vin": vin}) + + if self.is_car_asleep(vin=vin) and new_state == STATUS_ONLINE: + self.set_last_wake_up_time(vin=vin, timestamp=round(time.time())) + # Don't update __car_sleeping when state is offline or unavailable + if new_state == STATUS_ASLEEP: + self.__car_sleeping[vin] = True + if new_state == STATUS_ONLINE: + self.__car_sleeping[vin] = False + + def get_car_state(self, car_id: Text = None, vin: Text = None): """Get online status for car_id or all cars. Parameters @@ -1094,13 +1117,34 @@ def get_car_online(self, car_id: Text = None, vin: Text = None): """ if car_id and not vin: vin = self._id_to_vin(car_id) - if vin and vin in self.car_online: - return self.car_online[vin] - return self.car_online + if vin: + return self.__car_state.get(vin, STATUS_UNKNOWN) + return self.__car_state def is_car_online(self, car_id: Text = None, vin: Text = None) -> bool: - """Alias for get_car_online for better readability.""" - return self.get_car_online(car_id=car_id, vin=vin) + """Get online status for car_id or vin. + + Returns True if it is reachable, this includes asleep. + """ + if not (car_id or vin): + raise ValueError("Either car_id or vin must be provided") + + return self.get_car_state(car_id=car_id, vin=vin) in [ + STATUS_ONLINE, + STATUS_ASLEEP, + ] + + def is_car_asleep(self, car_id: Text = None, vin: Text = None) -> bool: + """Get asleep status for car_id or vin. + + Returns True if it is asleep, or was last seen as asleep. + """ + if not (car_id or vin): + raise ValueError("Either car_id or vin must be provided") + if car_id and not vin: + vin = self._id_to_vin(car_id) + + return self.__car_sleeping.get(vin) def set_id_vin(self, car_id: Text, vin: Text) -> None: """Update mappings of car_id <--> vin.""" @@ -1328,7 +1372,7 @@ async def api( "wake_if_asleep only supported on endpoints with 'vehicle_id' path variable" ) # If we already know the car is asleep, go ahead and wake it - if not self.is_car_online(car_id=car_id): + if self.is_car_asleep(car_id=car_id): await self.wake_up(car_id=car_id) return await self.__connection.post( "", method=method, data=kwargs, url=uri diff --git a/tests/unit_tests/test_car.py b/tests/unit_tests/test_car.py index b73acdbf..31402bf2 100644 --- a/tests/unit_tests/test_car.py +++ b/tests/unit_tests/test_car.py @@ -2,6 +2,13 @@ import pytest +from teslajsonpy.const import ( + STATUS_ASLEEP, + STATUS_OFFLINE, + STATUS_ONLINE, + STATUS_UNAVAILABLE, + STATUS_UNKNOWN, +) from teslajsonpy.controller import Controller from tests.tesla_mock import ( @@ -177,7 +184,9 @@ async def test_car_properties(monkeypatch): assert _car.is_trunk_closed - assert _car.is_on + assert _car.is_on is True + + assert _car.is_asleep is False assert _car.is_window_closed @@ -343,6 +352,52 @@ async def test_car_properties(monkeypatch): ) +@pytest.mark.asyncio +async def test_car_states(monkeypatch): + """Test TeslaCar class properties.""" + TeslaMock(monkeypatch) + _controller = Controller(None) + await _controller.connect() + await _controller.generate_car_objects() + + _car = _controller.cars[VIN] + + test_set = [ + # state, online, asleep + (STATUS_ONLINE, True, False), + (STATUS_OFFLINE, False, False), + (STATUS_UNKNOWN, False, False), + (STATUS_UNAVAILABLE, False, False), + (STATUS_ASLEEP, True, True), + # asleep keeps last known state when offline/unknown + (STATUS_OFFLINE, False, True), + (STATUS_UNKNOWN, False, True), + (STATUS_UNAVAILABLE, False, True), + # asleep flips back to False with online + (STATUS_ONLINE, True, False), + ] + for state, online, asleep in test_set: + _controller._set_car_state(state, vin=VIN) + assert _car.state == state + assert _car.is_on == online + assert _car.is_asleep == asleep + + test_set = [ + # state, expected, error_count + (STATUS_ONLINE, STATUS_ONLINE, 0), + (STATUS_ONLINE, STATUS_ONLINE, 4), + (STATUS_ONLINE, STATUS_UNAVAILABLE, 5), + (STATUS_ONLINE, STATUS_UNAVAILABLE, 6), + (STATUS_ASLEEP, STATUS_ASLEEP, 5), + ] + for state, expected, error_count in test_set: + _controller.reset_tesla_exceptions(VIN) + for _ in range(error_count): + _controller.count_tesla_exceptions(VIN) + _controller._set_car_state(state, vin=VIN) + assert _car.state == expected + + @pytest.mark.asyncio async def test_change_charge_limit(monkeypatch): """Test change charge limit.""" diff --git a/tests/unit_tests/test_polling_interval.py b/tests/unit_tests/test_polling_interval.py index 8779e23b..982e0cf5 100644 --- a/tests/unit_tests/test_polling_interval.py +++ b/tests/unit_tests/test_polling_interval.py @@ -1,7 +1,7 @@ """Test online sensor.""" +from teslajsonpy.const import STATUS_ONLINE, UPDATE_INTERVAL from teslajsonpy.controller import Controller -from teslajsonpy.const import UPDATE_INTERVAL from tests.tesla_mock import TeslaMock, VIN, CAR_ID VIN_INTERVAL = 5 @@ -16,7 +16,7 @@ def test_update_interval(monkeypatch): TeslaMock(monkeypatch) _controller = Controller(None) - monkeypatch.setitem(_controller.car_online, VIN, True) + _controller._set_car_state(STATUS_ONLINE, vin=VIN) _controller.set_id_vin(CAR_ID, VIN) @@ -35,7 +35,7 @@ def test_set_update_interval_vin(monkeypatch): TeslaMock(monkeypatch) _controller = Controller(None) - monkeypatch.setitem(_controller.car_online, VIN, True) + _controller._set_car_state(STATUS_ONLINE, vin=VIN) _controller.set_id_vin(CAR_ID, VIN) _controller.set_id_vin(CAR_ID2, VIN2) @@ -65,7 +65,7 @@ def test_get_update_interval_vin(monkeypatch): TeslaMock(monkeypatch) _controller = Controller(None) - monkeypatch.setitem(_controller.car_online, VIN, True) + _controller._set_car_state(STATUS_ONLINE, vin=VIN) _controller.set_id_vin(CAR_ID, VIN) _controller.update_interval = UPDATE_INTERVAL