File: models.py

package info (click to toggle)
python-bimmer-connected 0.16.3-1.2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 8,304 kB
  • sloc: python: 4,469; makefile: 15
file content (209 lines) | stat: -rw-r--r-- 7,172 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
"""Generals models used for bimmer_connected."""

import logging
from dataclasses import InitVar, dataclass, field
from enum import Enum
from typing import Dict, List, NamedTuple, Optional, Tuple, Union

from bimmer_connected.const import DEFAULT_POI_NAME

_LOGGER = logging.getLogger(__name__)


class StrEnum(str, Enum):
    """A string enumeration of type `(str, Enum)`. All members are compared via `upper()`. Defaults to UNKNOWN."""

    @classmethod
    def _missing_(cls, value):
        has_unknown = False
        for member in cls:
            if member.value.upper() == "UNKNOWN":
                has_unknown = True
            if member.value.upper() == value.upper():
                return member
        if has_unknown:
            _LOGGER.warning("'%s' is not a valid '%s'", value, cls.__name__)
            return cls.UNKNOWN
        raise ValueError(f"'{value}' is not a valid {cls.__name__}")


@dataclass
class VehicleDataBase:
    """A base class for parsing and storing complex vehicle data."""

    @classmethod
    def from_vehicle_data(cls, vehicle_data: Dict):
        """Create the class based on vehicle data from API."""
        parsed = cls._parse_vehicle_data(vehicle_data) or {}
        if len(parsed) > 0:
            return cls(**parsed)
        return None

    def update_from_vehicle_data(self, vehicle_data: Dict):
        """Update the attributes based on vehicle data from API."""
        parsed = self._parse_vehicle_data(vehicle_data) or {}
        parsed.update(self._update_after_parse(parsed))
        if len(parsed) > 0:
            self.__dict__.update(parsed)

    @classmethod
    def _parse_vehicle_data(cls, vehicle_data: Dict) -> Optional[Dict]:
        """Parse desired attributes out of vehicle data from API."""
        raise NotImplementedError()

    def _update_after_parse(self, parsed: Dict) -> Dict:
        """Update parsed vehicle data with attributes stored in class if needed."""
        return parsed


@dataclass
class GPSPosition:
    """GPS coordinates."""

    latitude: Optional[float]
    longitude: Optional[float]

    def __post_init__(self):
        non_null_values = [k for k, v in self.__dict__.items() if v is None]
        if len(non_null_values) not in [0, len(self.__dataclass_fields__)]:
            raise TypeError(f"{type(self).__name__} requires either none or both arguments set")

        for field_name in self.__dataclass_fields__:
            value = getattr(self, field_name)
            if value is not None and not isinstance(value, (float, int)):
                raise TypeError(f"{type(self).__name__} '{field_name}' not of type '{Optional[Union[float, int]]}'")
            if field_name == "latitude" and not (-90 <= value <= 90):
                raise ValueError(f"{type(self).__name__}  'latitude' must be between -90 and 90, but got '{value}'")
            elif field_name == "longitude" and not (-180 <= value <= 180):
                raise ValueError(f"{type(self).__name__} 'longitude' must be between -180 and 180, but got '{value}'")
            # Force conversion to float
            setattr(self, field_name, float(value))

    @classmethod
    def init_nonempty(cls, latitude: Optional[float], longitude: Optional[float]):
        """Initialize GPSPosition but do not allow empty latitude/longitude."""
        if latitude is None or longitude is None:
            raise ValueError(f"{cls.__name__} requires both 'latitude' and 'longitude' set")
        return cls(latitude, longitude)

    def __iter__(self):
        yield from self.__dict__.values()

    def __getitem__(self, key):
        return tuple(self.__dict__.values())[key]

    def __eq__(self, other):
        if isinstance(other, Tuple):
            return tuple(self.__iter__()) == other
        if hasattr(self, "__dict__") and hasattr(other, "__dict__"):
            return self.__dict__ == other.__dict__
        if hasattr(self, "__dict__") and isinstance(other, Dict):
            return self.__dict__ == other
        return False


@dataclass
class PointOfInterestAddress:
    """Address data of a PointOfInterest."""

    street: Optional[str] = None
    postalCode: Optional[str] = None
    city: Optional[str] = None
    country: Optional[str] = None
    formatted: Optional[str] = None

    # The following attributes are not by us but available in the API
    banchi: Optional[str] = None
    chome: Optional[str] = None
    countryCode: Optional[str] = None
    district: Optional[str] = None
    go: Optional[str] = None
    houseNumber: Optional[str] = None
    region: Optional[str] = None
    regionCode: Optional[str] = None
    settlement: Optional[str] = None
    subblock: Optional[str] = None


@dataclass
class PointOfInterest:
    """A Point of Interest to be sent to the car."""

    lat: InitVar[float]
    lon: InitVar[float]
    name: InitVar[Optional[str]] = DEFAULT_POI_NAME
    street: InitVar[str] = None
    postal_code: InitVar[str] = None
    city: InitVar[str] = None
    country: InitVar[str] = None

    position: Dict[str, float] = field(init=False)
    address: Optional[PointOfInterestAddress] = field(init=False)
    # The following attributes are not by us but required in the API
    formattedAddress: Optional[str] = None
    entrances: Optional[List] = field(init=False)
    placeType: Optional[str] = "ADDRESS"
    category: Dict[str, Optional[str]] = field(init=False)
    title: Optional[str] = DEFAULT_POI_NAME

    # The following attributes are not by us but available in the API
    provider: Optional[str] = None
    providerId: Optional[str] = None
    providerPoiId: str = ""
    sourceType: Optional[str] = None
    vehicleCategoryId: Optional[str] = None

    def __post_init__(self, lat, lon, name, street, postal_code, city, country):
        position = GPSPosition(lat, lon)
        self.position = {
            "lat": position.latitude,
            "lon": position.longitude,
        }

        self.address = PointOfInterestAddress(str(street), str(postal_code), str(city), str(country))
        self.category = {"losCategory": "Address", "mguVehicleCategoryId": None, "name": "Address"}
        self.title = name

        if not self.formattedAddress:
            self.formattedAddress = ", ".join([str(i) for i in [street, postal_code, city] if i]) or "Coordinates only"


class ValueWithUnit(NamedTuple):
    """A value with a corresponding unit."""

    value: Optional[Union[int, float]]
    unit: Optional[str]


@dataclass
class AnonymizedResponse:
    """An anonymized response."""

    filename: str
    content: Optional[Union[List, Dict, str]] = None


@dataclass
class ChargingSettings:
    """Charging settings to control the vehicle."""

    chargingTarget: Optional[int]
    isUnlockCableActive = None
    acLimitValue: Optional[int] = None
    dcLoudness = None


class MyBMWAPIError(Exception):
    """General BMW API error."""


class MyBMWAuthError(MyBMWAPIError):
    """Auth-related error from BMW API (HTTP status codes 401 and 403)."""


class MyBMWQuotaError(MyBMWAPIError):
    """Quota exceeded on BMW API."""


class MyBMWRemoteServiceError(MyBMWAPIError):
    """Error when executing remote services."""