File: account.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 (189 lines) | stat: -rw-r--r-- 7,353 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
"""Access to a MyBMW account and all vehicles therein."""

import datetime
import json
import logging
import ssl
from dataclasses import InitVar, dataclass, field
from typing import List, Optional, Union

from bimmer_connected.api.authentication import MyBMWAuthentication
from bimmer_connected.api.client import RESPONSE_STORE, MyBMWClient, MyBMWClientConfiguration
from bimmer_connected.api.regions import Regions
from bimmer_connected.const import (
    ATTR_ATTRIBUTES,
    VEHICLE_PROFILE_URL,
    VEHICLES_URL,
    CarBrands,
)
from bimmer_connected.models import AnonymizedResponse, GPSPosition, MyBMWAPIError, MyBMWAuthError, MyBMWQuotaError
from bimmer_connected.vehicle import MyBMWVehicle

VALID_UNTIL_OFFSET = datetime.timedelta(seconds=10)

_LOGGER = logging.getLogger(__name__)


@dataclass
class MyBMWAccount:
    """Create a new connection to the MyBMW web service."""

    username: str
    """MyBMW user name (email) or 86-prefixed phone number (China only)."""

    password: InitVar[str]
    """MyBMW password."""

    region: Regions
    """Region of the account. See `api.Regions`."""

    config: MyBMWClientConfiguration = None  # type: ignore[assignment]
    """Optional. If provided, username/password/region are ignored."""

    log_responses: InitVar[bool] = False
    """Optional. If set, all responses from the server will be logged to this directory."""

    observer_position: InitVar[GPSPosition] = None
    """Optional. Required for getting a position on older cars."""

    verify: InitVar[Union[ssl.SSLContext, str, bool]] = True
    """Optional. Specify SSL context (required for Home Assistant)."""

    use_metric_units: InitVar[Optional[bool]] = None
    """Deprecated. All returned values are metric units (km, l)."""

    vehicles: List[MyBMWVehicle] = field(default_factory=list, init=False)

    def __post_init__(self, password, log_responses, observer_position, verify, use_metric_units):
        """Initialize the account."""

        if use_metric_units is not None:
            _LOGGER.warning(
                "The use_metric_units parameter is deprecated and will be removed in a future release. "
                "All values will be returned in metric units, as the parameter has no effect on the API."
            )

        if self.config is None:
            self.config = MyBMWClientConfiguration(
                MyBMWAuthentication(self.username, password, self.region, verify=verify),
                log_responses=log_responses,
                observer_position=observer_position,
                verify=verify,
            )

    async def _init_vehicles(self) -> None:
        """Initialize vehicles from BMW servers."""
        _LOGGER.debug("Getting vehicle list")

        fetched_at = datetime.datetime.now(datetime.timezone.utc)

        async with MyBMWClient(self.config) as client:
            for brand in CarBrands:
                request_headers = client.generate_default_header(brand)
                vehicle_list_response = await client.post(
                    VEHICLES_URL,
                    headers=request_headers,
                )

                for vehicle in vehicle_list_response.json()["mappingInfos"]:
                    vehicle_profile_response = await client.get(
                        VEHICLE_PROFILE_URL, headers=dict(request_headers, **{"bmw-vin": vehicle["vin"]})
                    )
                    vehicle_profile = vehicle_profile_response.json()

                    # Special handling for DRITTKUNDE (third party customer) aka Toyota Supra.
                    # Requires TOYOTA in request, but returns DRITTKUNDE in response.
                    if brand == CarBrands.TOYOTA:
                        vehicle_profile["brand"] = CarBrands.TOYOTA.value.upper()

                    vehicle_base = dict(
                        {ATTR_ATTRIBUTES: {k: v for k, v in vehicle_profile.items() if k != "vin"}},
                        **{"vin": vehicle_profile["vin"]},
                    )

                    await self.add_vehicle(vehicle_base, fetched_at)

    async def get_vehicles(self, force_init: bool = False) -> None:
        """Retrieve vehicle data from BMW servers."""
        _LOGGER.debug("Getting vehicle list")

        if len(self.vehicles) == 0 or force_init:
            await self._init_vehicles()

        error_count = 0
        for vehicle in self.vehicles:
            # Get the detailed vehicle state
            try:
                await vehicle.get_vehicle_state()
            except (MyBMWAPIError, json.JSONDecodeError) as ex:
                # We don't want to fail completely if one vehicle fails, but we want to know about it
                error_count += 1

                # If it's a MyBMWQuotaError or MyBMWAuthError, we want to raise it
                if isinstance(ex, (MyBMWQuotaError, MyBMWAuthError)):
                    raise ex

                # Always log the error
                _LOGGER.error("Unable to get details for vehicle %s - (%s) %s", vehicle.vin, type(ex).__name__, ex)

                # If all vehicles fail, we want to raise an exception
                if error_count == len(self.vehicles):
                    raise ex

    async def add_vehicle(
        self,
        vehicle_base: dict,
        fetched_at: Optional[datetime.datetime] = None,
    ) -> None:
        """Add or update a vehicle from the API responses."""

        existing_vehicle = self.get_vehicle(vehicle_base["vin"])

        # If vehicle already exists, just update it's state
        if existing_vehicle:
            await existing_vehicle.get_vehicle_state()
        else:
            self.vehicles.append(MyBMWVehicle(self, vehicle_base, fetched_at))

    def get_vehicle(self, vin: str) -> Optional[MyBMWVehicle]:
        """Get vehicle with given VIN.

        The search is NOT case sensitive.
        :param vin: VIN of the vehicle you want to get.
        :return: Returns None if no vehicle is found.
        """
        for car in self.vehicles:
            if car.vin.upper() == vin.upper():
                return car
        return None

    def set_observer_position(self, latitude: float, longitude: float) -> None:
        """Set the position of the observer for all vehicles."""
        self.config.observer_position = GPSPosition.init_nonempty(latitude=latitude, longitude=longitude)

    def set_refresh_token(
        self, refresh_token: str, gcid: Optional[str] = None, access_token: Optional[str] = None
    ) -> None:
        """Overwrite the current value of the MyBMW tokens and GCID (if available)."""
        self.config.authentication.refresh_token = refresh_token
        if gcid:
            self.config.authentication.gcid = gcid
        if access_token:
            self.config.authentication.access_token = access_token

    @staticmethod
    def get_stored_responses() -> List[AnonymizedResponse]:
        """Return responses stored if log_responses was set to True."""
        responses = list(RESPONSE_STORE)
        RESPONSE_STORE.clear()
        return responses

    @property
    def refresh_token(self) -> Optional[str]:
        """Returns the current refresh_token."""
        return self.config.authentication.refresh_token

    @property
    def gcid(self) -> Optional[str]:
        """Returns the current GCID."""
        return self.config.authentication.gcid