Repository: bimmerconnected/bimmer_connected
Pull request #626 (2024-07-01 21:57:23)

CLI: Store OAuth credentials in local file
<!--
  Thanks for your contribution to bimmer_connected!
-->
## Breaking change
<!--
  If your PR contains a breaking change, please explain that change here.
  Note: Remove this section if this PR is NOT a breaking change.
-->
Every CLI command now stores the API credentials in a file for reuse in later calls. By default, data is stored to `~/.bimmer_connected.json` (Mac and Linux) or `C:\Users\username\.bimmer_connected.json` (Windows). The file location can be changed using the `--oauth-store` parameter.
If you don't want the data stored, use the `--disable-oauth-store` parameter.

## Proposed change
<!--
  Describe the big picture of your changes here to communicate to the
  maintainers why we should accept this pull request. If it fixes a bug
  or resolves a feature request, be sure to link to that issue in the
  additional information section.
-->
Store OAuth2 tokens from API for commandline calls so everybody not using Home Assistant does not have to go through a full reauthentication flow on every CLI command.

## Type of change
- [ ] Bugfix (non-breaking change which fixes an issue)
- [x] New feature (which adds functionality to this library)
- [ ] Breaking change (fix/feature causing existing functionality to break)
- [ ] Code quality improvements to existing code or addition of tests

## Additional information
<!--
  Details are important, and help maintainers processing your PR.
  Please be sure to fill out additional details, if applicable.
-->

- This PR fixes or closes issue: fixes #
- This PR is related to issue: 

## Checklist
<!--
  Put an `x` in the boxes that apply. You can also fill these out after
  creating the PR. If you're unsure about any of them, don't hesitate to ask.
  We're here to help! This is simply a reminder of what we are going to look
  for before merging your code.
-->

- [x] The code change is tested and works locally.
- [x] Tests have been added to verify that the new code works.



Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index ac3120d..befbc1b 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -73,6 +73,7 @@ body:
       options:
         - label: I have 2 or more cars linked to the MyBMW account.
         - label: I have a Mini vehicle linked to my account.
+        - label: I have a Toyota Supra vehicle linked to my account.
   - type: textarea
     attributes:
       label: Output of bimmer_connected fingerprint
diff --git a/README.rst b/README.rst
index 2c8ff5d..6ee0858 100644
--- a/README.rst
+++ b/README.rst
@@ -14,19 +14,19 @@ bimmer_connected
 .. image:: https://static.pepy.tech/badge/bimmer_connected/month
     :target: https://pepy.tech/project/bimmer-connected/month
 .. image:: https://static.pepy.tech/badge/bimmer_connected
-    :target: https://pepy.tech/project/bimmer-connected    
-    
+    :target: https://pepy.tech/project/bimmer-connected
 
-This is a simple library to query and control the status of your BMW or Mini vehicle from
+
+This is a simple library to query and control the status of your BMW, Mini, or Toyota Supra vehicle from
 the MyBMW portal.
 
 
 Installation
 ============
-:code:`bimmer_connected` is tested against **Python 3.8 or above**. Just install the latest release from `PyPI <https://pypi.org/project/bimmer-connected/>`_ 
-using :code:`pip3 install --upgrade bimmer_connected`. 
+:code:`bimmer_connected` is tested against **Python 3.8 or above**. Just install the latest release from `PyPI <https://pypi.org/project/bimmer-connected/>`_
+using :code:`pip3 install --upgrade bimmer_connected`.
 
-Alternatively, clone the project and execute :code:`pip install -e .` to install the current 
+Alternatively, clone the project and execute :code:`pip install -e .` to install the current
 :code:`master` branch.
 
 .. note::
@@ -40,7 +40,7 @@ After installation, execute :code:`bimmerconnected` from command line for usage
 or see the full `CLI documentation <http://bimmer-connected.readthedocs.io/en/stable/#cli>`_.
 
 Please be aware that :code:`bimmer_connected` is an :code:`async` library when using it in Python code.
-The description of the :code:`modules` can be found in the `module documentation 
+The description of the :code:`modules` can be found in the `module documentation
 <http://bimmer-connected.readthedocs.io/en/stable/#module>`_.
 
 Example in an :code:`asyncio` event loop
@@ -56,13 +56,13 @@ Example in an :code:`asyncio` event loop
         await account.get_vehicles()
         vehicle = account.get_vehicle(VIN)
         print(vehicle.brand, vehicle.name, vehicle.vin)
-        
+
         result = await vehicle.remote_services.trigger_remote_light_flash()
         print(result.state)
 
     asyncio.run(main())
 
-    
+
 Example in non-async code
 ^^^^^^^^^^^^^^^^^^^^^^^^^^
 
@@ -77,25 +77,25 @@ Example in non-async code
     asyncio.run(account.get_vehicles())
     vehicle = account.get_vehicle(VIN)
     print(vehicle.brand, vehicle.name, vehicle.vin)
-        
+
     result = asyncio.run(vehicle.remote_services.trigger_remote_light_flash())
     print(result.state)
 
 
 Compatibility
 =============
-This works with BMW (and Mini) vehicles with a MyBMW account.
+This works with BMW/Mini/Toyota Supra vehicles with a MyBMW account.
 So far it is tested on vehicles with a 'MGU', 'NBTEvo', 'EntryEvo', 'NBT', or 'EntryNav'
-navigation system. If you have any trouble with other navigation systems, please create 
+navigation system. If you have any trouble with other navigation systems, please create
 an issue with your server responses (see next section).
 
-To use this library, your BMW (or Mini) must have the remote services enabled for your vehicle. 
-You might need to book this in the MyBMW/Mini Connected portal and this might cost 
-some money. In addition to that you need to enable the Remote Services in your infotainment 
+To use this library, your BMW/Mini/Toyota Supra must have the remote services enabled for your vehicle.
+You might need to book this in the MyBMW/Mini Connected/Supra Connect portal and this might cost
+some money. In addition to that you need to enable the Remote Services in your infotainment
 system in the vehicle.
 
 Different models of vehicles and infotainment systems result in different types of attributes
-provided by the server. So the experience with the library will certainly vary across the different 
+provided by the server. So the experience with the library will certainly vary across the different
 vehicle models.
 
 Data Contributions
@@ -116,7 +116,7 @@ If you want to contribute your data, perform the following steps:
     bimmerconnected fingerprint <username> <password> <region>
 
 This will create a set of log files in the "vehicle_fingerprint" folder.
-Before sending the data to anyone please **check for any personal data** such as **dealer name** or **country**. 
+Before sending the data to anyone please **check for any personal data** such as **dealer name** or **country**.
 
 The following attributes are by default replaced with anonymized values:
 
@@ -129,9 +129,9 @@ Create a new
 `fingerprint data contribution <https://github.com/bimmerconnected/bimmer_connected/discussions/new?category_id=32000818>`_
 and add the files as attachment to the discussion.
 
-Please add your model and year to the title of the issue, to make it easier to organize. 
-If you know the "chassis code" of your car, you can include that too. (For example, 
-googling "2017 BMW X5" will show a Wikipedia article entitled "BMW X5 (F15)". F15 is 
+Please add your model and year to the title of the issue, to make it easier to organize.
+If you know the "chassis code" of your car, you can include that too. (For example,
+googling "2017 BMW X5" will show a Wikipedia article entitled "BMW X5 (F15)". F15 is
 therefore the chassis code of the car.)
 
 
@@ -142,7 +142,7 @@ let us know in advance.
 
 Code Contributions
 ==================
-Contributions are welcome! Please make sure that your code passes the checks in :code:`.github/workflows/test.yml`. 
+Contributions are welcome! Please make sure that your code passes the checks in :code:`.github/workflows/test.yml`.
 We currently test against :code:`flake8`, :code:`pylint` and our own :code:`pytest` suite.
 And please add tests where it makes sense. The more the better.
 
diff --git a/bimmer_connected/account.py b/bimmer_connected/account.py
index 0280590..490c0e8 100644
--- a/bimmer_connected/account.py
+++ b/bimmer_connected/account.py
@@ -86,9 +86,14 @@ class MyBMWAccount:
                     )
                     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"]}
+                        **{"vin": vehicle_profile["vin"]},
                     )
 
                     await self.add_vehicle(vehicle_base, fetched_at)
@@ -151,10 +156,15 @@ class MyBMWAccount:
         """Set the position of the observer for all vehicles."""
         self.config.observer_position = GPSPosition(latitude=latitude, longitude=longitude)
 
-    def set_refresh_token(self, refresh_token: str, gcid: Optional[str] = None) -> None:
-        """Overwrite the current value of the MyBMW refresh token and GCID (if available)."""
+    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
-        self.config.authentication.gcid = gcid
+        if gcid:
+            self.config.authentication.gcid = gcid
+        if access_token:
+            self.config.authentication.access_token = access_token
 
     @staticmethod
     def get_stored_responses() -> List[AnonymizedResponse]:
diff --git a/bimmer_connected/api/authentication.py b/bimmer_connected/api/authentication.py
index 66c5469..c2203ce 100644
--- a/bimmer_connected/api/authentication.py
+++ b/bimmer_connected/api/authentication.py
@@ -137,11 +137,11 @@ class MyBMWAuthentication(httpx.Auth):
             if not token_data:
                 token_data = await self._login_china()
             token_data["expires_at"] = token_data["expires_at"] - EXPIRES_AT_OFFSET
-            self.gcid = token_data["gcid"]
 
         self.access_token = token_data["access_token"]
         self.expires_at = token_data["expires_at"]
         self.refresh_token = token_data["refresh_token"]
+        self.gcid = token_data["gcid"]
 
     async def _login_row_na(self):
         """Login to Rest of World and North America."""
@@ -227,6 +227,7 @@ class MyBMWAuthentication(httpx.Auth):
             "access_token": response_json["access_token"],
             "expires_at": expires_at,
             "refresh_token": response_json["refresh_token"],
+            "gcid": response_json["gcid"],
         }
 
     async def _refresh_token_row_na(self):
@@ -271,6 +272,7 @@ class MyBMWAuthentication(httpx.Auth):
             "access_token": response_json["access_token"],
             "expires_at": expires_at,
             "refresh_token": response_json["refresh_token"],
+            "gcid": response_json["gcid"],
         }
 
     async def _login_china(self):
diff --git a/bimmer_connected/cli.py b/bimmer_connected/cli.py
index f4bda1f..dda1a1c 100644
--- a/bimmer_connected/cli.py
+++ b/bimmer_connected/cli.py
@@ -23,8 +23,18 @@ TEXT_VIN = "Vehicle Identification Number"
 
 def main_parser() -> argparse.ArgumentParser:
     """Create the ArgumentParser with all relevant subparsers."""
-    parser = argparse.ArgumentParser(description="A simple executable to use and test the library.")
+    parser = argparse.ArgumentParser(description="Connect to MyBMW/MINI API and interact with your vehicle.")
     parser.add_argument("--debug", help="Print debug logs.", action="store_true")
+    parser.add_argument(
+        "--oauth-store",
+        help="Path to the OAuth2 storage file. Defaults to $HOME/.bimmer_connected.json.",
+        nargs="?",
+        metavar="FILE",
+        type=Path,
+        default=Path.home() / ".bimmer_connected.json",
+    )
+    parser.add_argument("--disable-oauth-store", help="Disable storing the OAuth2 tokens.", action="store_true")
+
     subparsers = parser.add_subparsers(dest="cmd")
     subparsers.required = True
 
@@ -125,15 +135,12 @@ def main_parser() -> argparse.ArgumentParser:
     return parser
 
 
-async def get_status(args) -> None:
+async def get_status(account: MyBMWAccount, args) -> None:
     """Get the vehicle status."""
     if args.json:
         for handler in logging.root.handlers[:]:
             logging.root.removeHandler(handler)
 
-    account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region))
-    if args.lat and args.lng:
-        account.set_observer_position(args.lat, args.lng)
     await account.get_vehicles()
 
     if args.json:
@@ -162,47 +169,36 @@ def get_vehicle_or_return(account: MyBMWAccount, vin: str) -> MyBMWVehicle:
     return vehicle
 
 
-async def fingerprint(args) -> None:
+async def fingerprint(account: MyBMWAccount, args) -> None:
     """Save the vehicle fingerprint."""
     time_dir = Path.home() / "vehicle_fingerprint" / time.strftime("%Y-%m-%d_%H-%M-%S")
     time_dir.mkdir(parents=True)
 
-    account = MyBMWAccount(
-        args.username,
-        args.password,
-        get_region_from_name(args.region),
-        log_responses=True,
-    )
-    if args.lat and args.lng:
-        account.set_observer_position(args.lat, args.lng)
+    account.config.log_responses = True
     await account.get_vehicles()
 
     log_response_store_to_file(account.get_stored_responses(), time_dir)
     print(f"fingerprint of the vehicles written to {time_dir}")
 
 
-async def light_flash(args) -> None:
+async def light_flash(account: MyBMWAccount, args) -> None:
     """Trigger the vehicle to flash its lights."""
-    account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region))
     await account.get_vehicles()
     vehicle = get_vehicle_or_return(account, args.vin)
     status = await vehicle.remote_services.trigger_remote_light_flash()
     print(status.state)
 
 
-async def horn(args) -> None:
+async def horn(account: MyBMWAccount, args) -> None:
     """Trigger the vehicle to horn."""
-    account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region))
     await account.get_vehicles()
     vehicle = get_vehicle_or_return(account, args.vin)
     status = await vehicle.remote_services.trigger_remote_horn()
     print(status.state)
 
 
-async def vehicle_finder(args) -> None:
+async def vehicle_finder(account: MyBMWAccount, args) -> None:
     """Trigger the vehicle finder to locate it."""
-    account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region))
-    account.set_observer_position(args.lat, args.lng)
     await account.get_vehicles()
     vehicle = get_vehicle_or_return(account, args.vin)
     status = await vehicle.remote_services.trigger_remote_vehicle_finder()
@@ -210,11 +206,10 @@ async def vehicle_finder(args) -> None:
     print({"gps_position": vehicle.vehicle_location.location, "heading": vehicle.vehicle_location.heading})
 
 
-async def chargingsettings(args) -> None:
+async def chargingsettings(account: MyBMWAccount, args) -> None:
     """Trigger a change to charging settings."""
     if not args.target_soc and not args.ac_limit:
         raise ValueError("At least one of 'charging-target' and 'ac-limit' has to be provided.")
-    account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region))
     await account.get_vehicles()
     vehicle = get_vehicle_or_return(account, args.vin)
     status = await vehicle.remote_services.trigger_charging_settings_update(
@@ -223,11 +218,10 @@ async def chargingsettings(args) -> None:
     print(status.state)
 
 
-async def chargingprofile(args) -> None:
+async def chargingprofile(account: MyBMWAccount, args) -> None:
     """Trigger a change to charging profile."""
     if not args.charging_mode and not args.precondition_climate:
         raise ValueError("At least one of 'charging-mode' and 'precondition-climate' has to be provided.")
-    account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region))
     await account.get_vehicles()
     vehicle = get_vehicle_or_return(account, args.vin)
     status = await vehicle.remote_services.trigger_charging_profile_update(
@@ -236,18 +230,16 @@ async def chargingprofile(args) -> None:
     print(status.state)
 
 
-async def charge(args) -> None:
+async def charge(account: MyBMWAccount, args) -> None:
     """Trigger a vehicle to start or stop charging."""
-    account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region))
     await account.get_vehicles()
     vehicle = get_vehicle_or_return(account, args.vin)
     status = await getattr(vehicle.remote_services, f"trigger_charge_{args.action.lower()}")()
     print(status.state)
 
 
-async def image(args) -> None:
+async def image(account: MyBMWAccount, args) -> None:
     """Download a rendered image of the vehicle."""
-    account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region))
     await account.get_vehicles()
     vehicle = get_vehicle_or_return(account, args.vin)
 
@@ -261,9 +253,8 @@ async def image(args) -> None:
         print(f"vehicle image saved to {filename}")
 
 
-async def send_poi(args) -> None:
+async def send_poi(account: MyBMWAccount, args) -> None:
     """Send Point Of Interest to car."""
-    account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region))
     await account.get_vehicles()
     vehicle = get_vehicle_or_return(account, args.vin)
 
@@ -279,9 +270,8 @@ async def send_poi(args) -> None:
     await vehicle.remote_services.trigger_send_poi(poi_data)
 
 
-async def send_poi_from_address(args) -> None:
+async def send_poi_from_address(account: MyBMWAccount, args) -> None:
     """Create Point of Interest from OSM Nominatim and send to car."""
-    account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region))
     await account.get_vehicles()
     vehicle = get_vehicle_or_return(account, args.vin)
 
@@ -337,13 +327,37 @@ def main():
         logging.basicConfig(level=logging.DEBUG)
         logging.getLogger("asyncio").setLevel(logging.WARNING)
 
+    account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region))
+    if args.lat and args.lng:
+        account.set_observer_position(args.lat, args.lng)
+
+    if args.oauth_store.exists():
+        try:
+            account.set_refresh_token(**json.load(args.oauth_store.open()))
+        except json.JSONDecodeError:
+            pass
+
     loop = asyncio.get_event_loop()
     try:
-        loop.run_until_complete(args.func(args))
+        loop.run_until_complete(args.func(account, args))
     except Exception as ex:  # pylint: disable=broad-except
         sys.stderr.write(f"{type(ex).__name__}: {ex}\n")
         sys.exit(1)
 
+    if args.disable_oauth_store:
+        return
+
+    args.oauth_store.parent.mkdir(parents=True, exist_ok=True)
+    args.oauth_store.write_text(
+        json.dumps(
+            {
+                "refresh_token": account.config.authentication.refresh_token,
+                "gcid": account.config.authentication.gcid,
+                "access_token": account.config.authentication.access_token,
+            }
+        ),
+    )
+
 
 if __name__ == "__main__":
     main()
diff --git a/bimmer_connected/const.py b/bimmer_connected/const.py
index c88d4d9..cfc2c5d 100644
--- a/bimmer_connected/const.py
+++ b/bimmer_connected/const.py
@@ -16,6 +16,7 @@ class CarBrands(str, Enum):
 
     BMW = "bmw"
     MINI = "mini"
+    TOYOTA = "toyota"
 
 
 class Regions(str, Enum):


Test patch:
diff --git a/bimmer_connected/tests/__init__.py b/bimmer_connected/tests/__init__.py
index d7fe4dd..b7b158f 100644
--- a/bimmer_connected/tests/__init__.py
+++ b/bimmer_connected/tests/__init__.py
@@ -22,6 +22,7 @@ VIN_G70 = "WBA00000000DEMO05"
 VIN_I01_NOREX = "WBY000000NOREXI01"
 VIN_I01_REX = "WBY00000000REXI01"
 VIN_I20 = "WBA00000000DEMO01"
+VIN_J29 = "WZ100000000000J29"
 
 ALL_VEHICLES: Dict[str, Dict] = {brand.value: {} for brand in CarBrands}
 ALL_PROFILES: Dict[str, Dict] = {}
diff --git a/bimmer_connected/tests/conftest.py b/bimmer_connected/tests/conftest.py
index f160469..db44e40 100644
--- a/bimmer_connected/tests/conftest.py
+++ b/bimmer_connected/tests/conftest.py
@@ -44,6 +44,15 @@ def bmw_log_all_responses(monkeypatch: pytest.MonkeyPatch):
     monkeypatch.setattr("bimmer_connected.account.RESPONSE_STORE", temp_store)
 
 
+@pytest.fixture
+def cli_home_dir(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch):
+    """Create a temporary home directory for the CLI tests."""
+    tmp_path = tmp_path_factory.mktemp("cli-home-")
+    monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
+
+    return tmp_path
+
+
 async def prepare_account_with_vehicles(region: Optional[Regions] = None):
     """Initialize account and get vehicles."""
     account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, region or TEST_REGION)
diff --git a/bimmer_connected/tests/responses/J29/toyota-eadrax-vcs_v4_vehicles_state_WZ100000000000J29.json b/bimmer_connected/tests/responses/J29/toyota-eadrax-vcs_v4_vehicles_state_WZ100000000000J29.json
new file mode 100644
index 0000000..2169f48
--- /dev/null
+++ b/bimmer_connected/tests/responses/J29/toyota-eadrax-vcs_v4_vehicles_state_WZ100000000000J29.json
@@ -0,0 +1,104 @@
+{
+    "capabilities": {
+        "a4aType": "BLUETOOTH",
+        "climateFunction": "VENTILATION",
+        "climateNow": true,
+        "climateTimerTrigger": "DEPARTURE_TIMER",
+        "digitalKey": {
+            "bookedServicePackage": "NONE",
+            "isDigitalKeyFirstSupported": false,
+            "state": "NOT_AVAILABLE",
+            "vehicleSoftwareUpgradeRequired": false
+        },
+        "horn": true,
+        "isBmwChargingSupported": false,
+        "isCarSharingSupported": false,
+        "isChargeNowForBusinessSupported": false,
+        "isChargingHistorySupported": false,
+        "isChargingHospitalityEnabled": false,
+        "isChargingLoudnessEnabled": false,
+        "isChargingPlanSupported": false,
+        "isChargingPowerLimitEnabled": false,
+        "isChargingSettingsEnabled": false,
+        "isChargingTargetSocEnabled": false,
+        "isClimateTimerSupported": true,
+        "isClimateTimerWeeklyActive": true,
+        "isCustomerEsimSupported": false,
+        "isDCSContractManagementSupported": false,
+        "isDataPrivacyEnabled": false,
+        "isEasyChargeEnabled": false,
+        "isEvGoChargingSupported": false,
+        "isMiniChargingSupported": false,
+        "isNonLscFeatureEnabled": false,
+        "isOptimizedChargingSupported": false,
+        "isPersonalPictureUploadSupported": false,
+        "isPlugAndChargeSupported": false,
+        "isRemoteEngineStartSupported": false,
+        "isRemoteHistoryDeletionSupported": false,
+        "isRemoteHistorySupported": true,
+        "isRemoteParkingSupported": false,
+        "isRemoteServicesActivationRequired": false,
+        "isRemoteServicesBookingRequired": false,
+        "isScanAndChargeSupported": false,
+        "isSustainabilityAccumulatedViewEnabled": false,
+        "isSustainabilitySupported": false,
+        "isWifiHotspotServiceSupported": false,
+        "lights": true,
+        "lock": true,
+        "remoteChargingCommands": {},
+        "sendPoi": true,
+        "specialThemeSupport": [],
+        "unlock": true,
+        "vehicleFinder": true,
+        "vehicleStateSource": "NOT_SUPPORTED"
+    },
+    "state": {
+        "checkControlMessages": [
+            {
+                "severity": "LOW",
+                "type": "ENGINE_OIL"
+            }
+        ],
+        "climateTimers": [
+            {
+                "departureTime": {
+                    "hour": 7,
+                    "minute": 0
+                },
+                "isWeeklyTimer": false,
+                "timerAction": "DEACTIVATE",
+                "timerWeekDays": []
+            },
+            {
+                "departureTime": {
+                    "hour": 7,
+                    "minute": 0
+                },
+                "isWeeklyTimer": true,
+                "timerAction": "DEACTIVATE",
+                "timerWeekDays": [
+                    "MONDAY"
+                ]
+            },
+            {
+                "departureTime": {
+                    "hour": 7,
+                    "minute": 0
+                },
+                "isWeeklyTimer": true,
+                "timerAction": "DEACTIVATE",
+                "timerWeekDays": [
+                    "MONDAY"
+                ]
+            }
+        ],
+        "driverPreferences": {
+            "lscPrivacyMode": "OFF"
+        },
+        "isLeftSteering": true,
+        "isLscSupported": false,
+        "lastFetched": "2023-09-14T03:27:47.176Z",
+        "lastUpdatedAt": "0001-01-01T00:00:00Z",
+        "requiredServices": []
+    }
+}
\ No newline at end of file
diff --git a/bimmer_connected/tests/responses/J29/toyota-eadrax-vcs_v5_vehicle-data_profile_WZ100000000000J29.json b/bimmer_connected/tests/responses/J29/toyota-eadrax-vcs_v5_vehicle-data_profile_WZ100000000000J29.json
new file mode 100644
index 0000000..d4f9fff
--- /dev/null
+++ b/bimmer_connected/tests/responses/J29/toyota-eadrax-vcs_v5_vehicle-data_profile_WZ100000000000J29.json
@@ -0,0 +1,36 @@
+{
+    "bodyType": "J29",
+    "brand": "DRITTKUNDE",
+    "color": 4281545523,
+    "countryOfOrigin": "US",
+    "driveTrain": "COMBUSTION",
+    "driverGuideInfo": {
+        "androidAppScheme": "com.bmwgroup.driversguide.usa",
+        "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.usa",
+        "iosAppScheme": "bmwdriversguide:///open",
+        "iosStoreUrl": "https://apps.apple.com/us/app/id714042570?mt=8"
+    },
+    "headUnitRaw": "NBTEVO",
+    "headUnitType": "NBT_EVO",
+    "hmiVersion": "ID5",
+    "model": "SPX 40i",
+    "softwareVersionCurrent": {
+        "iStep": 555,
+        "puStep": {
+            "month": 7,
+            "year": 19
+        },
+        "seriesCluster": "S18T"
+    },
+    "softwareVersionExFactory": {
+        "iStep": 560,
+        "puStep": {
+            "month": 3,
+            "year": 19
+        },
+        "seriesCluster": "S18T"
+    },
+    "telematicsUnit": "ATM1",
+    "vin": "WZ100000000000J29",
+    "year": 2019
+}
\ No newline at end of file
diff --git a/bimmer_connected/tests/responses/J29/toyota-eadrax-vcs_v5_vehicle-list.json b/bimmer_connected/tests/responses/J29/toyota-eadrax-vcs_v5_vehicle-list.json
new file mode 100644
index 0000000..227ddf1
--- /dev/null
+++ b/bimmer_connected/tests/responses/J29/toyota-eadrax-vcs_v5_vehicle-list.json
@@ -0,0 +1,13 @@
+{
+    "gcid": "ceb64158-d2ca-47e9-9ee6-cbffb881434e",
+    "mappingInfos": [
+        {
+            "isAssociated": false,
+            "isLmmEnabled": false,
+            "isPrimaryUser": true,
+            "mappingStatus": "CONFIRMED",
+            "vehicleMappingType": "CONNECTED",
+            "vin": "WZ100000000000J29"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/bimmer_connected/tests/responses/toyota-eadrax-vcs_v5_vehicle-list.json b/bimmer_connected/tests/responses/toyota-eadrax-vcs_v5_vehicle-list.json
new file mode 100644
index 0000000..0c54692
--- /dev/null
+++ b/bimmer_connected/tests/responses/toyota-eadrax-vcs_v5_vehicle-list.json
@@ -0,0 +1,4 @@
+{
+    "gcid": "ceb64158-d2ca-47e9-9ee6-cbffb881434e",
+    "mappingInfos": []
+}
\ No newline at end of file
diff --git a/bimmer_connected/tests/test_account.py b/bimmer_connected/tests/test_account.py
index c5067eb..3f888dd 100644
--- a/bimmer_connected/tests/test_account.py
+++ b/bimmer_connected/tests/test_account.py
@@ -13,7 +13,7 @@ from bimmer_connected.account import MyBMWAccount
 from bimmer_connected.api.authentication import MyBMWAuthentication, MyBMWLoginRetry
 from bimmer_connected.api.client import MyBMWClient
 from bimmer_connected.api.regions import get_region_from_name
-from bimmer_connected.const import ATTR_CAPABILITIES, VEHICLES_URL
+from bimmer_connected.const import ATTR_CAPABILITIES, VEHICLES_URL, CarBrands
 from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError, MyBMWQuotaError
 
 from . import (
@@ -373,11 +373,11 @@ async def test_refresh_token_getset(bmw_fixture: respx.Router):
     assert account.refresh_token is None
     await account.get_vehicles()
     assert account.refresh_token == "another_token_string"
-    assert account.gcid is None
+    assert account.gcid == "DUMMY"
 
     account.set_refresh_token("new_refresh_token")
     assert account.refresh_token == "new_refresh_token"
-    assert account.gcid is None
+    assert account.gcid == "DUMMY"
 
     account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, get_region_from_name("china"))
     account.set_refresh_token("new_refresh_token", "dummy_gcid")
@@ -448,8 +448,10 @@ async def test_429_retry_ok_vehicles(caplog, bmw_fixture: respx.Router):
         side_effect=[
             httpx.Response(429, json=json_429),
             httpx.Response(429, json=json_429),
-            httpx.Response(200, json=load_response(RESPONSE_DIR / "bmw-eadrax-vcs_v5_vehicle-list.json")),
-            httpx.Response(200, json=load_response(RESPONSE_DIR / "mini-eadrax-vcs_v5_vehicle-list.json")),
+            *[
+                httpx.Response(200, json=load_response(RESPONSE_DIR / f"{brand.value}-eadrax-vcs_v5_vehicle-list.json"))
+                for brand in CarBrands
+            ],
         ]
     )
     caplog.set_level(logging.DEBUG)
@@ -498,8 +500,10 @@ async def test_429_retry_with_login_ok_vehicles(bmw_fixture: respx.Router):
         side_effect=[
             httpx.Response(429, json=json_429),
             httpx.Response(429, json=json_429),
-            httpx.Response(200, json=load_response(RESPONSE_DIR / "bmw-eadrax-vcs_v5_vehicle-list.json")),
-            httpx.Response(200, json=load_response(RESPONSE_DIR / "mini-eadrax-vcs_v5_vehicle-list.json")),
+            *[
+                httpx.Response(200, json=load_response(RESPONSE_DIR / f"{brand.value}-eadrax-vcs_v5_vehicle-list.json"))
+                for brand in CarBrands
+            ],
         ]
     )
 
diff --git a/bimmer_connected/tests/test_cli.py b/bimmer_connected/tests/test_cli.py
index 6f5eec8..c17779a 100644
--- a/bimmer_connected/tests/test_cli.py
+++ b/bimmer_connected/tests/test_cli.py
@@ -1,8 +1,8 @@
 import json
 import subprocess
 import sys
-import tempfile
 from pathlib import Path
+from unittest import mock
 
 import pytest
 
@@ -11,7 +11,7 @@ import bimmer_connected.cli
 from . import get_fingerprint_count
 
 ARGS_USER_PW_REGION = ["myuser", "mypassword", "rest_of_world"]
-FIXTURE_CLI_HELP = "A simple executable to use and test the library."
+FIXTURE_CLI_HELP = "Connect to MyBMW/MINI API and interact with your vehicle."
 
 
 def test_run_entrypoint():
@@ -31,6 +31,7 @@ def test_run_module():
 
 
 @pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.usefixtures("cli_home_dir")
 @pytest.mark.parametrize(
     ("vin", "expected_count"),
     [
@@ -58,6 +59,7 @@ def test_status_json_filtered(capsys: pytest.CaptureFixture, vin, expected_count
 
 
 @pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.usefixtures("cli_home_dir")
 def test_status_json_unfiltered(capsys: pytest.CaptureFixture):
     """Test the status command JSON output filtered by VIN."""
 
@@ -71,6 +73,7 @@ def test_status_json_unfiltered(capsys: pytest.CaptureFixture):
 
 
 @pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.usefixtures("cli_home_dir")
 @pytest.mark.parametrize(
     ("vin", "expected_count"),
     [
@@ -99,6 +102,7 @@ def test_status_filtered(capsys: pytest.CaptureFixture, vin, expected_count):
 
 
 @pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.usefixtures("cli_home_dir")
 def test_status_unfiltered(capsys: pytest.CaptureFixture):
     """Test the status command text output filtered by VIN."""
 
@@ -112,27 +116,118 @@ def test_status_unfiltered(capsys: pytest.CaptureFixture):
 
 @pytest.mark.usefixtures("bmw_fixture")
 @pytest.mark.usefixtures("bmw_log_all_responses")
-def test_fingerprint(capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch):
+def test_fingerprint(capsys: pytest.CaptureFixture, cli_home_dir: Path):
     """Test the fingerprint command."""
 
-    with tempfile.TemporaryDirectory() as tmpdirname:
-        tmp_path = Path(tmpdirname)
-        monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
+    sys.argv = ["bimmerconnected", "fingerprint", *ARGS_USER_PW_REGION]
+    bimmer_connected.cli.main()
+    result = capsys.readouterr()
+
+    assert "fingerprint of the vehicles written to" in result.out
+
+    files = list((cli_home_dir / "vehicle_fingerprint").rglob("*"))
+    json_files = [f for f in files if f.suffix == ".json"]
+    txt_files = [f for f in files if f.suffix == ".txt"]
+
+    assert len(json_files) == (
+        get_fingerprint_count("vehicles")
+        + get_fingerprint_count("profiles")
+        + get_fingerprint_count("states")
+        + get_fingerprint_count("charging_settings")
+    )
+    assert len(txt_files) == 0
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.usefixtures("cli_home_dir")
+def test_oauth_store_credentials(cli_home_dir: Path):
+    """Test storing the oauth credentials."""
+
+    assert (cli_home_dir / ".bimmer_connected.json").exists() is False
+
+    sys.argv = ["bimmerconnected", "status", *ARGS_USER_PW_REGION]
+    bimmer_connected.cli.main()
+
+    assert (cli_home_dir / ".bimmer_connected.json").exists() is True
+    oauth_storage = json.loads((cli_home_dir / ".bimmer_connected.json").read_text())
+
+    assert set(oauth_storage.keys()) == {"access_token", "refresh_token", "gcid"}
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.usefixtures("cli_home_dir")
+def test_oauth_load_credentials(cli_home_dir: Path):
+    """Test loading and storing the oauth credentials."""
 
-        sys.argv = ["bimmerconnected", "fingerprint", *ARGS_USER_PW_REGION]
+    demo_oauth_data = {
+        "access_token": "demo_access_token",
+        "refresh_token": "demo_refresh_token",
+        "gcid": "demo_gcid",
+    }
+
+    (cli_home_dir / ".bimmer_connected.json").write_text(json.dumps(demo_oauth_data))
+    assert (cli_home_dir / ".bimmer_connected.json").exists() is True
+
+    sys.argv = ["bimmerconnected", "status", *ARGS_USER_PW_REGION]
+
+    with mock.patch("bimmer_connected.account.MyBMWAccount.set_refresh_token") as mock_listener:
         bimmer_connected.cli.main()
-        result = capsys.readouterr()
 
-        assert "fingerprint of the vehicles written to" in result.out
+        assert mock_listener.call_count == 1
+        mock_listener.assert_called_once_with(**demo_oauth_data)
+
+    assert (cli_home_dir / ".bimmer_connected.json").exists() is True
+    oauth_storage = json.loads((cli_home_dir / ".bimmer_connected.json").read_text())
 
-        files = list(tmp_path.rglob("*"))
-        json_files = [f for f in files if f.suffix == ".json"]
-        txt_files = [f for f in files if f.suffix == ".txt"]
+    assert set(oauth_storage.keys()) == {"access_token", "refresh_token", "gcid"}
+
+    assert oauth_storage["refresh_token"] != demo_oauth_data["refresh_token"]
+    assert oauth_storage["access_token"] != demo_oauth_data["access_token"]
+    assert oauth_storage["gcid"] != demo_oauth_data["gcid"]
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.usefixtures("cli_home_dir")
+@pytest.mark.parametrize(
+    ("filepath"),
+    [
+        (".bimmer_connected.json"),
+        ("other-dir/myfile.json"),
+    ],
+)
+def test_oauth_store_credentials_path(cli_home_dir: Path, tmp_path_factory: pytest.TempPathFactory, filepath: str):
+    """Test storing the oauth credentials to another file."""
+
+    new_folder = tmp_path_factory.mktemp("specific-path-")
+
+    assert (cli_home_dir / ".bimmer_connected.json").exists() is False
+    assert (new_folder / filepath).exists() is False
+
+    sys.argv = [
+        "bimmerconnected",
+        "--oauth-store",
+        str((new_folder / filepath).absolute()),
+        "status",
+        *ARGS_USER_PW_REGION,
+    ]
+    bimmer_connected.cli.main()
+
+    assert (cli_home_dir / ".bimmer_connected.json").exists() is False
+    assert (new_folder / filepath).exists() is True
+
+    oauth_storage = json.loads((new_folder / filepath).read_text())
+
+    assert set(oauth_storage.keys()) == {"access_token", "refresh_token", "gcid"}
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.usefixtures("cli_home_dir")
+def test_oauth_store_credentials_disabled(cli_home_dir: Path):
+    """Test NOT storing the oauth credentials."""
+
+    assert (cli_home_dir / ".bimmer_connected.json").exists() is False
+
+    sys.argv = ["bimmerconnected", "--disable-oauth-store", "status", *ARGS_USER_PW_REGION]
+    bimmer_connected.cli.main()
 
-        assert len(json_files) == (
-            get_fingerprint_count("vehicles")
-            + get_fingerprint_count("profiles")
-            + get_fingerprint_count("states")
-            + get_fingerprint_count("charging_settings")
-        )
-        assert len(txt_files) == 0
+    assert (cli_home_dir / ".bimmer_connected.json").exists() is False
diff --git a/bimmer_connected/tests/test_vehicle.py b/bimmer_connected/tests/test_vehicle.py
index 53af19b..2280b90 100644
--- a/bimmer_connected/tests/test_vehicle.py
+++ b/bimmer_connected/tests/test_vehicle.py
@@ -18,6 +18,7 @@ from . import (
     VIN_I01_NOREX,
     VIN_I01_REX,
     VIN_I20,
+    VIN_J29,
     get_deprecation_warning_count,
 )
 from .conftest import prepare_account_with_vehicles
@@ -94,6 +95,7 @@ async def test_drive_train_attributes(caplog, bmw_fixture: respx.Router):
         VIN_I01_NOREX: (False, True),
         VIN_I01_REX: (True, True),
         VIN_I20: (False, True),
+        VIN_J29: (True, False),
     }
 
     for vehicle in account.vehicles:
diff --git a/bimmer_connected/tests/test_vehicle_status.py b/bimmer_connected/tests/test_vehicle_status.py
index d600e5f..bc3619e 100644
--- a/bimmer_connected/tests/test_vehicle_status.py
+++ b/bimmer_connected/tests/test_vehicle_status.py
@@ -25,6 +25,7 @@ from . import (
     VIN_I01_NOREX,
     VIN_I01_REX,
     VIN_I20,
+    VIN_J29,
     get_deprecation_warning_count,
 )
 from .conftest import prepare_account_with_vehicles
@@ -69,9 +70,16 @@ async def test_generic_error_handling(caplog, bmw_fixture: respx.Router):
 
 
 @pytest.mark.asyncio
-async def test_range_combustion_no_info(caplog, bmw_fixture: respx.Router):
+@pytest.mark.parametrize(
+    ("vin"),
+    [
+        (VIN_F31),
+        (VIN_J29),
+    ],
+)
+async def test_range_combustion_no_info(caplog, bmw_fixture: respx.Router, vin: str):
     """Test if the parsing of very old vehicles."""
-    vehicle = (await prepare_account_with_vehicles()).get_vehicle(VIN_F31)
+    vehicle = (await prepare_account_with_vehicles()).get_vehicle(vin)
     status = vehicle.fuel_and_battery
 
     assert status.remaining_fuel == (None, None)