From 62b5f598a987fb175bdf75ad690d29678b4ab892 Mon Sep 17 00:00:00 2001 From: Martin Eberhardt Date: Fri, 13 Sep 2024 17:24:35 +0200 Subject: [PATCH 01/10] Add Send Destination function * Add support for sending a route or destination --- examples/destinations.py | 85 +++++++++++++++++++++++++ weconnect/elements/controls.py | 44 +++++++++++++ weconnect/elements/route.py | 113 +++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 examples/destinations.py create mode 100644 weconnect/elements/route.py diff --git a/examples/destinations.py b/examples/destinations.py new file mode 100644 index 0000000..24e8990 --- /dev/null +++ b/examples/destinations.py @@ -0,0 +1,85 @@ +import argparse + +from weconnect import weconnect +from weconnect.elements.route import Address, Destination, GeoCoordinate, Route + + +def main(): + """ + Simple example showing how to send destinations in a vehicle by providing the VIN as a parameter + Giving an address for the destination(s) is optional, and is only used for display purposes. + """ + parser = argparse.ArgumentParser( + prog="destinations", description="Example sending destinations" + ) + parser.add_argument( + "-u", "--username", help="Username of Volkswagen id", required=True + ) + parser.add_argument( + "-p", "--password", help="Password of Volkswagen id", required=True + ) + parser.add_argument( + "--vin", help="VIN of the vehicle to start destinations", required=True + ) + + args = parser.parse_args() + + route = Route( + [ + Destination( + name="VW Museum", + geoCoordinate=GeoCoordinate( + latitude=52.4278793, + longitude=10.8077433, + ), + ), + Destination( + name="Autostadt", + geoCoordinate=GeoCoordinate( + latitude=52.429380, + longitude=10.791520, + ), + address=Address( + country="Germany", + street="Stadtbrücke", + zipCode="38440", + city="Wolfsburg", + ), + ), + ] + ) + + print("# Initialize WeConnect") + weConnect = weconnect.WeConnect( + username=args.username, + password=args.password, + updateAfterLogin=False, + loginOnInit=False, + ) + print("# Login") + weConnect.login() + print("# update") + weConnect.update() + + for vin, vehicle in weConnect.vehicles.items(): + if vin == args.vin: + print("# send destinations") + + if ( + "destinations" not in vehicle.capabilities + or not vehicle.capabilities["destinations"] + ): + print("# destinations is not supported") + continue + + if not vehicle.controls.sendDestinations: + print("# sendDestinations is not available") + continue + + vehicle.controls.sendDestinations.value = route + + print("# destinations sent") + + +if __name__ == "__main__": + main() diff --git a/weconnect/elements/controls.py b/weconnect/elements/controls.py index a7fccdb..d8b3af4 100644 --- a/weconnect/elements/controls.py +++ b/weconnect/elements/controls.py @@ -1,5 +1,6 @@ import logging import json +from typing import Optional, Union import requests from weconnect.addressable import AddressableObject, ChangeableAttribute @@ -7,6 +8,7 @@ from weconnect.elements.charging_settings import ChargingSettings from weconnect.elements.climatization_settings import ClimatizationSettings from weconnect.elements.error import Error +from weconnect.elements.route import Route, Destination from weconnect.elements.window_heating_status import WindowHeatingStatus from weconnect.elements.access_status import AccessStatus from weconnect.elements.parking_position import ParkingPosition @@ -36,6 +38,7 @@ def __init__( self.honkAndFlashControl = None self.auxiliaryHeating = None self.activeVentilation = None + self.sendDestinations = None self.update() def update(self): # noqa: C901 @@ -79,6 +82,10 @@ def update(self): # noqa: C901 self.honkAndFlashControl = ChangeableAttribute( localAddress='honkAndFlash', parent=self, value=HonkAndFlashControlOperation.NONE, valueType=(HonkAndFlashControlOperation, int), valueSetter=self.__setHonkAndFlashControlChange) + if self.sendDestinations is None and 'destinations' in capabilities and not capabilities['destinations'].status.value: + self.sendDestinations = ChangeableAttribute( + localAddress='destinations', parent=self, value=None, valueType=Optional[Union[Route, Destination]], + valueSetter=self.__setDestinationsControlChange) if self.wakeupControl is None and 'vehicleWakeUpTrigger' in capabilities and not capabilities['vehicleWakeUpTrigger'].status.value: self.wakeupControl = ChangeableAttribute(localAddress='wakeup', parent=self, value=ControlOperation.NONE, valueType=ControlOperation, valueSetter=self.__setWakeupControlChange) @@ -388,3 +395,40 @@ def __setHonkAndFlashControlChange(self, value): # noqa: C901 else: raise ControlError(f'Could not control honkandflash ({controlResponse.status_code})') raise ControlError(f'Could not control honkandflash ({controlResponse.status_code})') + + def __setDestinationsControlChange(self, value:Optional[Union[Route, Destination]]): # noqa: C901 + if value is None or (not isinstance(value, Route) and not isinstance(value, Destination)): + raise ControlError('Could not control destination, value must be a Route or Destination object') + + if isinstance(value, Destination): + value = Route([value]) + + if not value.valid: + raise ControlError('Could not control destination, value must be a Route object with at least one valid Destination object') + + url = f'https://emea.bff.cariad.digital/vehicle/v1/vehicles/{self.vehicle.vin.value}/destinations' + + data = { + 'destinations': value.to_list() + } + + controlResponse = self.vehicle.weConnect.session.put(url, json=data, allow_redirects=True) + if controlResponse.status_code != requests.codes['accepted']: + errorDict = controlResponse.json() + if errorDict is not None and 'error' in errorDict: + error = Error(localAddress='error', parent=self, fromDict=errorDict['error']) + if error is not None: + message = '' + if error.message.enabled and error.message.value is not None: + message += error.message.value + if error.info.enabled and error.info.value is not None: + message += ' - ' + error.info.value + if error.retry.enabled and error.retry.value is not None: + if error.retry.value: + message += ' - Please retry in a moment' + else: + message += ' - No retry possible' + raise ControlError(f'Could not control destination ({message})') + else: + raise ControlError(f'Could not control destination ({controlResponse.status_code})') + raise ControlError(f'Could not control destination ({controlResponse.status_code})') diff --git a/weconnect/elements/route.py b/weconnect/elements/route.py new file mode 100644 index 0000000..f51e4bb --- /dev/null +++ b/weconnect/elements/route.py @@ -0,0 +1,113 @@ +from typing import Any, Optional +from dataclasses import dataclass + + +@dataclass +class Address: + country: str + street: str + zipCode: str + city: str + + def to_dict(self) -> dict[str, str]: + return { + "country": self.country, + "street": self.street, + "zipCode": self.zipCode, + "city": self.city, + } + + +@dataclass +class GeoCoordinate: + latitude: float + longitude: float + + def to_dict(self) -> dict[str, float]: + return { + "latitude": self.latitude, + "longitude": self.longitude, + } + + @property + def valid(self) -> bool: + return ( + isinstance(self.latitude, float) + and isinstance(self.longitude, float) + ) + + +class Destination: + + def __init__( + self, + geoCoordinate: Optional[GeoCoordinate], + name: str = "Destination", + address: Optional[Address] = None, + poiProvider: str = "unknown", + ): + """ + A single destination on a route. + + Args: + geoCoordinate (GeoCoordinate): A GeoCoordinate instance containing the coordinates of the destination (Required). + name (str): A name for the destination to be displayed in the car (Optional, defaults to "Destination"). + address (Address): The address of the destination, for display purposes only, not used for navigation (Optional). + poiProvider (str): The source of the location (Optional, defaults to "unknown"). + """ + if geoCoordinate is None or not isinstance(geoCoordinate, GeoCoordinate): + raise ValueError('geoCoordinate is required and must be a GeoCoordinate object') + + self.address = address + self.geoCoordinate = geoCoordinate + self.name = name + self.poiProvider = poiProvider + + @property + def valid(self) -> bool: + return ( + self.geoCoordinate is not None + and self.geoCoordinate.valid + ) + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "poiProvider": self.poiProvider, + "destinationName": self.name, + "destinationSource": "MobileApp", + } + + if self.address is not None: + data["address"] = self.address.to_dict() + elif self.geoCoordinate is not None: + data["geoCoordinate"] = self.geoCoordinate.to_dict() + + return data + + +class Route: + def __init__(self, destinations: list[Destination] = []): + if ( + destinations is None + or not isinstance(destinations, list) + or not all(isinstance(dest, Destination) for dest in destinations) + ): + raise ValueError("destinations must be a list of Destination objects.") + + self.destinations = destinations + + @property + def valid(self) -> bool: + return bool(self.destinations) and all( + isinstance(dest, Destination) and dest.valid for dest in self.destinations + ) + + def to_list(self) -> list[dict[str, Any]]: + route = [] + for i, destination in enumerate(self.destinations): + data = destination.to_dict() + if i < len(self.destinations) - 1: + data["destinationType"] = "stopover" + route.append(data) + + return route From 849f812150f584d4747c75ffaef8fac736604c1e Mon Sep 17 00:00:00 2001 From: Martin Eberhardt Date: Sat, 14 Sep 2024 14:23:11 +0200 Subject: [PATCH 02/10] Allow setting destination with a JSON string --- weconnect/elements/controls.py | 27 ++++--- weconnect/elements/route.py | 135 ++++++++++++++++++++++++--------- 2 files changed, 116 insertions(+), 46 deletions(-) diff --git a/weconnect/elements/controls.py b/weconnect/elements/controls.py index d8b3af4..a801724 100644 --- a/weconnect/elements/controls.py +++ b/weconnect/elements/controls.py @@ -396,18 +396,25 @@ def __setHonkAndFlashControlChange(self, value): # noqa: C901 raise ControlError(f'Could not control honkandflash ({controlResponse.status_code})') raise ControlError(f'Could not control honkandflash ({controlResponse.status_code})') - def __setDestinationsControlChange(self, value:Optional[Union[Route, Destination]]): # noqa: C901 - if value is None or (not isinstance(value, Route) and not isinstance(value, Destination)): - raise ControlError('Could not control destination, value must be a Route or Destination object') - - if isinstance(value, Destination): - value = Route([value]) - - if not value.valid: - raise ControlError('Could not control destination, value must be a Route object with at least one valid Destination object') + def __setDestinationsControlChange(self, value: Optional[Union[str, list, dict, Route, Destination]]): # noqa: C901 + if value is None: + raise ControlError("Could not control destination, value must not be None.") + if isinstance(value, Route): + # Value is already a Route, no further action needed + pass + elif isinstance(value, (str, list, dict, Destination)): + try: + value = Route.from_value(value) + except json.JSONDecodeError as err: + raise ControlError(f'Could not control destination, invalid JSON string: {str(err)}') + except Exception as err: + raise ControlError(f'Could not control destination, invalid data: {str(err)}') + else: + raise ControlError( + "Could not control destination, value must be a JSON string, list, dict, Route, or Destination." + ) url = f'https://emea.bff.cariad.digital/vehicle/v1/vehicles/{self.vehicle.vin.value}/destinations' - data = { 'destinations': value.to_list() } diff --git a/weconnect/elements/route.py b/weconnect/elements/route.py index f51e4bb..d26337d 100644 --- a/weconnect/elements/route.py +++ b/weconnect/elements/route.py @@ -1,5 +1,6 @@ -from typing import Any, Optional +from typing import Any, Optional, Union from dataclasses import dataclass +import json @dataclass @@ -23,28 +24,28 @@ class GeoCoordinate: latitude: float longitude: float + def __post_init__(self): + if not isinstance(self.latitude, float) or not isinstance(self.longitude, float): + raise TypeError("Latitude and longitude must be floats") + if not (-90.0 <= self.latitude <= 90.0 and -180.0 <= self.longitude <= 180.0): + raise ValueError( + "Latitude must be between -90 and 90 degrees, and longitude between -180 and 180 degrees." + ) + def to_dict(self) -> dict[str, float]: return { "latitude": self.latitude, "longitude": self.longitude, } - @property - def valid(self) -> bool: - return ( - isinstance(self.latitude, float) - and isinstance(self.longitude, float) - ) - class Destination: - def __init__( self, - geoCoordinate: Optional[GeoCoordinate], - name: str = "Destination", + geoCoordinate: GeoCoordinate, + name: Optional[str] = None, address: Optional[Address] = None, - poiProvider: str = "unknown", + poiProvider: Optional[str] = None, ): """ A single destination on a route. @@ -55,53 +56,58 @@ def __init__( address (Address): The address of the destination, for display purposes only, not used for navigation (Optional). poiProvider (str): The source of the location (Optional, defaults to "unknown"). """ - if geoCoordinate is None or not isinstance(geoCoordinate, GeoCoordinate): - raise ValueError('geoCoordinate is required and must be a GeoCoordinate object') + if not isinstance(geoCoordinate, GeoCoordinate): + raise ValueError('geoCoordinate is required') - self.address = address self.geoCoordinate = geoCoordinate - self.name = name - self.poiProvider = poiProvider - - @property - def valid(self) -> bool: - return ( - self.geoCoordinate is not None - and self.geoCoordinate.valid - ) + self.name = name or "Destination" + self.address = address + self.poiProvider = poiProvider or "unknown" def to_dict(self) -> dict[str, Any]: data: dict[str, Any] = { - "poiProvider": self.poiProvider, + "geoCoordinate": self.geoCoordinate.to_dict(), "destinationName": self.name, + "poiProvider": self.poiProvider, "destinationSource": "MobileApp", } if self.address is not None: data["address"] = self.address.to_dict() - elif self.geoCoordinate is not None: - data["geoCoordinate"] = self.geoCoordinate.to_dict() return data + @classmethod + def from_dict(cls, dest_dict): + if "geoCoordinate" in dest_dict: + dest_dict["geoCoordinate"] = GeoCoordinate(**dest_dict["geoCoordinate"]) + else: + raise ValueError("geoCoordinate is required in destination data") + + if "address" in dest_dict: + dest_dict["address"] = Address(**dest_dict["address"]) + + return cls( + geoCoordinate=dest_dict["geoCoordinate"], + name=dest_dict.get("name", "Destination"), + address=dest_dict.get("address"), + poiProvider=dest_dict.get("poiProvider", "unknown"), + ) + class Route: - def __init__(self, destinations: list[Destination] = []): - if ( + def __init__(self, destinations: Union[list[Destination], Destination] = []): + if isinstance(destinations, Destination): + destinations = [destinations] + elif ( destinations is None or not isinstance(destinations, list) or not all(isinstance(dest, Destination) for dest in destinations) ): - raise ValueError("destinations must be a list of Destination objects.") + raise TypeError("destinations must be a single Destination or a list of Destination objects.") self.destinations = destinations - @property - def valid(self) -> bool: - return bool(self.destinations) and all( - isinstance(dest, Destination) and dest.valid for dest in self.destinations - ) - def to_list(self) -> list[dict[str, Any]]: route = [] for i, destination in enumerate(self.destinations): @@ -111,3 +117,60 @@ def to_list(self) -> list[dict[str, Any]]: route.append(data) return route + + @classmethod + def from_collection(cls, route_list: Union[list, dict]): + """ + Create a route from a dict or list of dicts containing destinations. + + Args: + route_list (Union[list, dict]): A single destination dict or a list of destinations. + + Example: + Route.from_collection([ + { + "name": "VW Museum", + "geoCoordinate": { + "latitude": 52.4278793, + "longitude": 10.8077433, + }, + }, + { + "name": "Autostadt", + "geoCoordinate": { + "latitude": 52.429380, + "longitude": 10.791520, + }, + "address": { + "country": "Germany", + "street": "Stadtbrücke", + "zipCode": "38440", + "city": "Wolfsburg", + }, + }, + ]) + """ + if isinstance(route_list, dict): + route_list = [route_list] + + destinations = [] + + for dest in route_list: + if isinstance(dest, Destination): + destinations.append(dest) + else: + destinations.append(Destination.from_dict(dest)) + + return cls(destinations) + + @classmethod + def from_value(cls, value: Union[str, list, dict, Destination]) -> "Route": + if isinstance(value, Destination): + return cls([value]) + elif isinstance(value, (list, dict)): + return cls.from_collection(value) + elif isinstance(value, str): + data = json.loads(value) + return cls.from_collection(data) + else: + raise TypeError("Unsupported type for Route.from_value") From e11dfd94d7fc0f8d6e23b9a24cf43a6c1c3092d7 Mon Sep 17 00:00:00 2001 From: Martin Eberhardt Date: Sat, 14 Sep 2024 14:23:27 +0200 Subject: [PATCH 03/10] Add unit tests for route.py --- tests/test_route.py | 203 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 tests/test_route.py diff --git a/tests/test_route.py b/tests/test_route.py new file mode 100644 index 0000000..de08f07 --- /dev/null +++ b/tests/test_route.py @@ -0,0 +1,203 @@ +import pytest +import json +from weconnect.elements.route import Address, GeoCoordinate, Destination, Route + + +def test_valid_coordinates(): + geo = GeoCoordinate(latitude=52.52, longitude=13.405) + assert geo.to_dict() == {"latitude": 52.52, "longitude": 13.405} + + +def test_invalid_coordinate_types(): + with pytest.raises(TypeError): + GeoCoordinate(latitude="invalid", longitude="invalid") + + +def test_invalid_coordinates(): + with pytest.raises(ValueError): + GeoCoordinate(latitude=255.0, longitude=512.0) + + +def test_edge_case_coordinates(): + # Test with edge values like 0.0 + GeoCoordinate(latitude=0.0, longitude=0.0) + + +def test_address_to_dict(): + address = Address( + country="Germany", street="Unter den Linden", zipCode="10117", city="Berlin" + ) + expected_dict = { + "country": "Germany", + "street": "Unter den Linden", + "zipCode": "10117", + "city": "Berlin", + } + assert address.to_dict() == expected_dict + + +def test_valid_destination(): + geo = GeoCoordinate(latitude=52.52, longitude=13.405) + dest = Destination(geoCoordinate=geo, name="Brandenburg Gate") + expected_dict = { + "geoCoordinate": {"latitude": 52.52, "longitude": 13.405}, + "destinationName": "Brandenburg Gate", + "poiProvider": "unknown", + "destinationSource": "MobileApp", + } + assert dest.to_dict() == expected_dict + + +def test_destination_missing_geo(): + with pytest.raises(ValueError): + Destination(geoCoordinate=None) + + +def test_route_with_single_destination(): + geo = GeoCoordinate(latitude=52.52, longitude=13.405) + dest = Destination(geoCoordinate=geo) + route = Route(destinations=dest) + expected_list = [ + { + "geoCoordinate": {"latitude": 52.52, "longitude": 13.405}, + "destinationName": "Destination", + "poiProvider": "unknown", + "destinationSource": "MobileApp", + } + ] + assert route.to_list() == expected_list + + +def test_route_with_multiple_destinations(): + geo1 = GeoCoordinate(latitude=52.52, longitude=13.405) + dest1 = Destination(geoCoordinate=geo1) + geo2 = GeoCoordinate(latitude=48.8566, longitude=2.3522) + dest2 = Destination(geoCoordinate=geo2, name="Eiffel Tower") + route = Route(destinations=[dest1, dest2]) + expected_list = [ + { + "geoCoordinate": {"latitude": 52.52, "longitude": 13.405}, + "destinationName": "Destination", + "poiProvider": "unknown", + "destinationSource": "MobileApp", + "destinationType": "stopover", + }, + { + "geoCoordinate": {"latitude": 48.8566, "longitude": 2.3522}, + "destinationName": "Eiffel Tower", + "poiProvider": "unknown", + "destinationSource": "MobileApp", + }, + ] + assert route.to_list() == expected_list + + +def test_route_from_collection(): + data = [ + { + "geoCoordinate": {"latitude": 52.52, "longitude": 13.405}, + "name": "Brandenburg Gate", + }, + { + "geoCoordinate": {"latitude": 48.8566, "longitude": 2.3522}, + "name": "Eiffel Tower", + }, + ] + route = Route.from_collection(data) + assert len(route.destinations) == 2 + + +def test_invalid_destination_geo(): + with pytest.raises(ValueError): + Destination(geoCoordinate=None) + + +def test_route_with_invalid_destinations(): + with pytest.raises(TypeError): + Route(destinations="not a list") + + +def test_route_from_invalid_collection(): + with pytest.raises(ValueError): + Route.from_collection("invalid data") + + +def test_route_from_value_with_destination(): + geo = GeoCoordinate(latitude=52.52, longitude=13.405) + dest = Destination(geoCoordinate=geo, name="Brandenburg Gate") + route = Route.from_value(dest) + assert isinstance(route, Route) + assert len(route.destinations) == 1 + assert route.destinations[0].name == "Brandenburg Gate" + + +def test_route_from_value_with_list_of_destinations(): + geo1 = GeoCoordinate(latitude=52.52, longitude=13.405) + dest1 = Destination(geoCoordinate=geo1) + geo2 = GeoCoordinate(latitude=48.8566, longitude=2.3522) + dest2 = Destination(geoCoordinate=geo2, name="Eiffel Tower") + route = Route.from_value([dest1, dest2]) + assert isinstance(route, Route) + assert len(route.destinations) == 2 + + +def test_route_from_value_with_dict(): + data = { + "geoCoordinate": {"latitude": 52.52, "longitude": 13.405}, + "name": "Brandenburg Gate", + } + route = Route.from_value(data) + assert isinstance(route, Route) + assert len(route.destinations) == 1 + assert route.destinations[0].name == "Brandenburg Gate" + + +def test_route_from_value_with_list_of_dicts(): + data = [ + { + "geoCoordinate": {"latitude": 52.52, "longitude": 13.405}, + "name": "Brandenburg Gate", + }, + { + "geoCoordinate": {"latitude": 48.8566, "longitude": 2.3522}, + "name": "Eiffel Tower", + }, + ] + route = Route.from_value(data) + assert isinstance(route, Route) + assert len(route.destinations) == 2 + assert route.destinations[1].name == "Eiffel Tower" + + +def test_route_from_value_with_json_string(): + data = [ + { + "geoCoordinate": {"latitude": 52.52, "longitude": 13.405}, + "name": "Brandenburg Gate", + }, + { + "geoCoordinate": {"latitude": 48.8566, "longitude": 2.3522}, + "name": "Eiffel Tower", + }, + ] + json_data = json.dumps(data) + route = Route.from_value(json_data) + assert isinstance(route, Route) + assert len(route.destinations) == 2 + assert route.destinations[0].name == "Brandenburg Gate" + + +def test_route_from_value_with_invalid_json_string(): + invalid_json = '{"geoCoordinate": {"latitude": 52.52, "longitude": 13.405}, "name": "Brandenburg Gate"' # Missing closing brace + with pytest.raises(json.JSONDecodeError): + Route.from_value(invalid_json) + + +def test_route_from_value_with_invalid_type(): + with pytest.raises(TypeError): + Route.from_value(12345) + + +def test_route_from_value_with_none(): + with pytest.raises(TypeError): + Route.from_value(None) From b5ed8840befed1e5504762f300857aef51fb15c8 Mon Sep 17 00:00:00 2001 From: Martin Eberhardt Date: Sat, 14 Sep 2024 14:27:06 +0200 Subject: [PATCH 04/10] update valueType for sendDestinations --- weconnect/elements/controls.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/weconnect/elements/controls.py b/weconnect/elements/controls.py index a801724..29cb7b0 100644 --- a/weconnect/elements/controls.py +++ b/weconnect/elements/controls.py @@ -84,8 +84,12 @@ def update(self): # noqa: C901 valueSetter=self.__setHonkAndFlashControlChange) if self.sendDestinations is None and 'destinations' in capabilities and not capabilities['destinations'].status.value: self.sendDestinations = ChangeableAttribute( - localAddress='destinations', parent=self, value=None, valueType=Optional[Union[Route, Destination]], - valueSetter=self.__setDestinationsControlChange) + localAddress="destinations", + parent=self, + value=None, + valueType=Optional[Union[str, list, dict, Route, Destination]], + valueSetter=self.__setDestinationsControlChange, + ) if self.wakeupControl is None and 'vehicleWakeUpTrigger' in capabilities and not capabilities['vehicleWakeUpTrigger'].status.value: self.wakeupControl = ChangeableAttribute(localAddress='wakeup', parent=self, value=ControlOperation.NONE, valueType=ControlOperation, valueSetter=self.__setWakeupControlChange) From 75df1396fcd3ca19ce38a68f904ffaf46023f18d Mon Sep 17 00:00:00 2001 From: Martin Eberhardt Date: Sat, 14 Sep 2024 17:50:15 +0200 Subject: [PATCH 05/10] Fix linter errors --- tests/test_route.py | 2 +- weconnect/elements/controls.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_route.py b/tests/test_route.py index de08f07..5e71ceb 100644 --- a/tests/test_route.py +++ b/tests/test_route.py @@ -1,5 +1,5 @@ -import pytest import json +import pytest from weconnect.elements.route import Address, GeoCoordinate, Destination, Route diff --git a/weconnect/elements/controls.py b/weconnect/elements/controls.py index 29cb7b0..fed06cb 100644 --- a/weconnect/elements/controls.py +++ b/weconnect/elements/controls.py @@ -411,7 +411,7 @@ def __setDestinationsControlChange(self, value: Optional[Union[str, list, dict, value = Route.from_value(value) except json.JSONDecodeError as err: raise ControlError(f'Could not control destination, invalid JSON string: {str(err)}') - except Exception as err: + except (TypeError, ValueError) as err: raise ControlError(f'Could not control destination, invalid data: {str(err)}') else: raise ControlError( From 1537cf55af3c184aeb0bff8380683327b1b3da42 Mon Sep 17 00:00:00 2001 From: Martin Eberhardt Date: Sun, 15 Sep 2024 11:37:22 +0200 Subject: [PATCH 06/10] Fix Dict and List type hits for Python 3.8 --- weconnect/elements/route.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/weconnect/elements/route.py b/weconnect/elements/route.py index d26337d..a2ba58d 100644 --- a/weconnect/elements/route.py +++ b/weconnect/elements/route.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Union +from typing import Any, Optional, Union, Dict, List from dataclasses import dataclass import json @@ -10,7 +10,7 @@ class Address: zipCode: str city: str - def to_dict(self) -> dict[str, str]: + def to_dict(self) -> Dict[str, str]: return { "country": self.country, "street": self.street, @@ -32,7 +32,7 @@ def __post_init__(self): "Latitude must be between -90 and 90 degrees, and longitude between -180 and 180 degrees." ) - def to_dict(self) -> dict[str, float]: + def to_dict(self) -> Dict[str, float]: return { "latitude": self.latitude, "longitude": self.longitude, @@ -64,8 +64,8 @@ def __init__( self.address = address self.poiProvider = poiProvider or "unknown" - def to_dict(self) -> dict[str, Any]: - data: dict[str, Any] = { + def to_dict(self) -> Dict[str, Any]: + data: Dict[str, Any] = { "geoCoordinate": self.geoCoordinate.to_dict(), "destinationName": self.name, "poiProvider": self.poiProvider, @@ -96,7 +96,7 @@ def from_dict(cls, dest_dict): class Route: - def __init__(self, destinations: Union[list[Destination], Destination] = []): + def __init__(self, destinations: Union[List[Destination], Destination] = []): if isinstance(destinations, Destination): destinations = [destinations] elif ( @@ -108,7 +108,7 @@ def __init__(self, destinations: Union[list[Destination], Destination] = []): self.destinations = destinations - def to_list(self) -> list[dict[str, Any]]: + def to_list(self) -> List[Dict[str, Any]]: route = [] for i, destination in enumerate(self.destinations): data = destination.to_dict() From 6434394f3e8f7dab47e307b46a4b8bde200707e3 Mon Sep 17 00:00:00 2001 From: Martin Troels Eberhardt Date: Tue, 17 Sep 2024 00:10:59 +0200 Subject: [PATCH 07/10] Avoid modifying incoming dicts, add more type hints --- weconnect/elements/route.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/weconnect/elements/route.py b/weconnect/elements/route.py index a2ba58d..2db2a40 100644 --- a/weconnect/elements/route.py +++ b/weconnect/elements/route.py @@ -24,7 +24,7 @@ class GeoCoordinate: latitude: float longitude: float - def __post_init__(self): + def __post_init__(self) -> None: if not isinstance(self.latitude, float) or not isinstance(self.longitude, float): raise TypeError("Latitude and longitude must be floats") if not (-90.0 <= self.latitude <= 90.0 and -180.0 <= self.longitude <= 180.0): @@ -46,7 +46,7 @@ def __init__( name: Optional[str] = None, address: Optional[Address] = None, poiProvider: Optional[str] = None, - ): + ) -> None: """ A single destination on a route. @@ -78,25 +78,28 @@ def to_dict(self) -> Dict[str, Any]: return data @classmethod - def from_dict(cls, dest_dict): + def from_dict(cls, dest_dict: Dict[str, Any]) -> "Destination": + geoCoordinate: Optional[GeoCoordinate] = None + address: Optional[Address] = None + if "geoCoordinate" in dest_dict: - dest_dict["geoCoordinate"] = GeoCoordinate(**dest_dict["geoCoordinate"]) + geoCoordinate = GeoCoordinate(**dest_dict["geoCoordinate"]) else: raise ValueError("geoCoordinate is required in destination data") if "address" in dest_dict: - dest_dict["address"] = Address(**dest_dict["address"]) + address = Address(**dest_dict["address"]) return cls( - geoCoordinate=dest_dict["geoCoordinate"], + geoCoordinate=geoCoordinate, name=dest_dict.get("name", "Destination"), - address=dest_dict.get("address"), + address=address, poiProvider=dest_dict.get("poiProvider", "unknown"), ) class Route: - def __init__(self, destinations: Union[List[Destination], Destination] = []): + def __init__(self, destinations: Union[List[Destination], Destination] = []) -> None: if isinstance(destinations, Destination): destinations = [destinations] elif ( @@ -119,7 +122,7 @@ def to_list(self) -> List[Dict[str, Any]]: return route @classmethod - def from_collection(cls, route_list: Union[list, dict]): + def from_collection(cls, route_list: Union[list, dict]) -> "Route": """ Create a route from a dict or list of dicts containing destinations. @@ -153,7 +156,7 @@ def from_collection(cls, route_list: Union[list, dict]): if isinstance(route_list, dict): route_list = [route_list] - destinations = [] + destinations: List[Destination] = [] for dest in route_list: if isinstance(dest, Destination): From 375b9c7e304122f8e2c61fa6d321e25ddc1df87d Mon Sep 17 00:00:00 2001 From: Martin Troels Eberhardt Date: Tue, 17 Sep 2024 01:06:46 +0200 Subject: [PATCH 08/10] Be extra sure we're not modifying input variables --- weconnect/elements/controls.py | 7 ++++--- weconnect/elements/route.py | 14 ++++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/weconnect/elements/controls.py b/weconnect/elements/controls.py index fed06cb..a827dbf 100644 --- a/weconnect/elements/controls.py +++ b/weconnect/elements/controls.py @@ -401,14 +401,15 @@ def __setHonkAndFlashControlChange(self, value): # noqa: C901 raise ControlError(f'Could not control honkandflash ({controlResponse.status_code})') def __setDestinationsControlChange(self, value: Optional[Union[str, list, dict, Route, Destination]]): # noqa: C901 + route = None if value is None: raise ControlError("Could not control destination, value must not be None.") if isinstance(value, Route): # Value is already a Route, no further action needed - pass + route = value elif isinstance(value, (str, list, dict, Destination)): try: - value = Route.from_value(value) + route = Route.from_value(value) except json.JSONDecodeError as err: raise ControlError(f'Could not control destination, invalid JSON string: {str(err)}') except (TypeError, ValueError) as err: @@ -420,7 +421,7 @@ def __setDestinationsControlChange(self, value: Optional[Union[str, list, dict, url = f'https://emea.bff.cariad.digital/vehicle/v1/vehicles/{self.vehicle.vin.value}/destinations' data = { - 'destinations': value.to_list() + 'destinations': route.to_list() } controlResponse = self.vehicle.weConnect.session.put(url, json=data, allow_redirects=True) diff --git a/weconnect/elements/route.py b/weconnect/elements/route.py index 2db2a40..7ba76ce 100644 --- a/weconnect/elements/route.py +++ b/weconnect/elements/route.py @@ -25,7 +25,9 @@ class GeoCoordinate: longitude: float def __post_init__(self) -> None: - if not isinstance(self.latitude, float) or not isinstance(self.longitude, float): + if not isinstance(self.latitude, float) or not isinstance( + self.longitude, float + ): raise TypeError("Latitude and longitude must be floats") if not (-90.0 <= self.latitude <= 90.0 and -180.0 <= self.longitude <= 180.0): raise ValueError( @@ -57,7 +59,7 @@ def __init__( poiProvider (str): The source of the location (Optional, defaults to "unknown"). """ if not isinstance(geoCoordinate, GeoCoordinate): - raise ValueError('geoCoordinate is required') + raise ValueError("geoCoordinate is required") self.geoCoordinate = geoCoordinate self.name = name or "Destination" @@ -99,7 +101,9 @@ def from_dict(cls, dest_dict: Dict[str, Any]) -> "Destination": class Route: - def __init__(self, destinations: Union[List[Destination], Destination] = []) -> None: + def __init__( + self, destinations: Union[List[Destination], Destination] = [] + ) -> None: if isinstance(destinations, Destination): destinations = [destinations] elif ( @@ -107,7 +111,9 @@ def __init__(self, destinations: Union[List[Destination], Destination] = []) -> or not isinstance(destinations, list) or not all(isinstance(dest, Destination) for dest in destinations) ): - raise TypeError("destinations must be a single Destination or a list of Destination objects.") + raise TypeError( + "destinations must be a single Destination or a list of Destination objects." + ) self.destinations = destinations From 76d7154d669c8f3fcc88b1906a69d54e54f99627 Mon Sep 17 00:00:00 2001 From: Martin Troels Eberhardt Date: Wed, 18 Sep 2024 01:14:21 +0200 Subject: [PATCH 09/10] Don't modify input to Route.from_collection --- weconnect/elements/route.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/weconnect/elements/route.py b/weconnect/elements/route.py index 7ba76ce..e8d6b15 100644 --- a/weconnect/elements/route.py +++ b/weconnect/elements/route.py @@ -128,7 +128,7 @@ def to_list(self) -> List[Dict[str, Any]]: return route @classmethod - def from_collection(cls, route_list: Union[list, dict]) -> "Route": + def from_collection(cls, collection: Union[list, dict]) -> "Route": """ Create a route from a dict or list of dicts containing destinations. @@ -159,16 +159,16 @@ def from_collection(cls, route_list: Union[list, dict]) -> "Route": }, ]) """ - if isinstance(route_list, dict): - route_list = [route_list] - destinations: List[Destination] = [] - for dest in route_list: - if isinstance(dest, Destination): - destinations.append(dest) - else: - destinations.append(Destination.from_dict(dest)) + if isinstance(collection, dict): + destinations.append(Destination.from_dict(collection)) + else: + for dest in collection: + if isinstance(dest, Destination): + destinations.append(dest) + else: + destinations.append(Destination.from_dict(dest)) return cls(destinations) From d61952c06c792bc3b04cbacddf33b221e8e92fb3 Mon Sep 17 00:00:00 2001 From: Martin Troels Eberhardt Date: Wed, 9 Oct 2024 19:20:11 +0200 Subject: [PATCH 10/10] Fix sendDestinations valueType and default value --- weconnect/elements/controls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/weconnect/elements/controls.py b/weconnect/elements/controls.py index a827dbf..9afd541 100644 --- a/weconnect/elements/controls.py +++ b/weconnect/elements/controls.py @@ -86,8 +86,8 @@ def update(self): # noqa: C901 self.sendDestinations = ChangeableAttribute( localAddress="destinations", parent=self, - value=None, - valueType=Optional[Union[str, list, dict, Route, Destination]], + value='[]', + valueType=(str, list, dict, Route, Destination), valueSetter=self.__setDestinationsControlChange, ) if self.wakeupControl is None and 'vehicleWakeUpTrigger' in capabilities and not capabilities['vehicleWakeUpTrigger'].status.value: