Repository: bimmerconnected/bimmer_connected
Pull request #565 (2023-09-14 03:45:14)

Initial support for Toyota Supra
## Proposed change
This is a first attempt to add support for Toyota Supra, for which the Supra Connect service uses the same underlying APIs. 


## Type of change
- [x] New feature (which adds functionality to this library)

## Additional information
The API expects the value `toyota` in the request, however, it returns `DRITTKUNDE` as the response (German for "third party vendor"). To address this, the fallback parsing was updated to default to `CarBrand.Toyota` if `DRITTKUNDE` is returned. However, for all I know there may be other brands that also use `DRITTKUNDE`...(Rolls Royce infotainments are also iDrive based)

I have not yet had a chance to test this. Here is the fingerprint:
[toyota_supra_fingerprint.zip](https://github.com/bimmerconnected/bimmer_connected/files/12604523/toyota_supra_fingerprint.zip)


## Checklist
- [ ] The code change is tested and works locally.
- [ ] Tests have been added to verify that the new code works.


rikroe: Nice, thanks! Didn't know that there are even more cars and brands using NBT systems. Is it only the Supra? And only in the US? Or also globally/other cars?

Regarding the `CarBrands` issue: I would rather only have `TOYOTA` and `DRITTKUNDE` as valid options and no parsing from `DRITTKUNDE` to `TOYOTA` in the enum.
When initializing a vehicle, the `brand` could be added as additional parameter. Then we could check in `MyBMWVehicle.brand` property if the value in JSON is `DRITTKUNDE` and replace it with the init parameter?
vanshg: For Toyota, yeah only the Supra. For other brands it's only Rolls Royce as far as I can tell (see the last paragraph in the intro here: https://en.wikipedia.org/wiki/BMW_iDrive). The Supra is available in at least Europe but no clue about China.

> I would rather only have `TOYOTA` and `DRITTKUNDE` as valid options and no parsing from `DRITTKUNDE` to `TOYOTA` in the enum

The problem with this is that `drittkunde` is _not_ a valid value in API requests. So e.g. if we're iterating through all `CarBrands`, where `DRITTKUNDE` has been included as a value, the API will return a 400 Bad Request. 

One approach might still be to include `DRITTKUNDE` as an enum, but explicitly filter it out when iterating over all brands -- maybe a `CarBrands.validBrands()` helper?
rikroe: Interesting! 

I could imagine something like this:

```py
    @classmethod
    @property
    def api_brands(cls) -> list[str]:
        """Return members for API calls."""
        return (cls._member_map_[name] for name in cls._member_names_ if name != "DRITTKUNDE")
```
vanshg: IMO, I think that implementation would become a footgun, as it would break the normal/expected Enum iteration (i.e. you would _have_ to know to do `for brand in CarBrands.api_brands` when making requests as opposed to `for brand in CarBrands`)
vanshg: @rikroe Thoughts?
rikroe: I see your point. But to be honest, I also have no other idea.
Could you please rebase so that test are green? Then we should be able to merge

vanshg: Sorry for the delay @rikroe. Updated
rikroe: With #591 merged, could you please have another look? The URLs and return values do differ for the vehicle list, and I'm especially curious if there is any change regarding the returned brands.

Also, I have updated the tests so it is hopefully easier to understand where to adjust them.
sslobodyan98: @rikroe @vanshg Hey everyone, I was looking for an API to interact with my Supra and stumbled upon this over some Home Assistant forums. For my use case I just want a local project to mess around and don't need all the bulk of HA. I installed this package and tried to use the CLI tool to login, there are no obvious errors but it returns vehicles as 0 so assume it can't find any cars on my profile. Any ideas?
github-actions[bot]: There hasn't been any activity on this pull request recently. This pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days. Thank you for your contributions.
rikroe: The charging_profile has been fixed with #615. 
I'll see if I find some time to update this PR and get it merged (as it is mostly issues in the tests).
rikroe: I had a look into this and wanted to add some tests.

This lead to a question regarding your fingerprint @vanshg: Do you disable sending vehicle data automatically to you the API? 
Your fingerprint does not show any information (like open windows or lock state of the doors) which should be possible according to Toyota's website.

If yes, could you please run a new fingerprint with this enabled?
vanshg: I don't have anything disabled. When I check the Supra Connect app, I only have the ability to perform actions (lock, unlock, horn, lights, ventilation) -- Besides location, I don't see anything that indicates current vehicle state (door locks or windows)  
rikroe: Ah, so BMW gives you the same level of access as their pre-2015 cars... 

As I've adjusted some things (however mainly for the tests), could you please test the latest version of this branch with your car to verify everything still works?
vanshg: Yep, I can confirm this works. I am able to run various subcommands successfully (I tried `status`, `vehiclefinder`, and `horn`)

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..7c715b0 100644
--- a/bimmer_connected/account.py
+++ b/bimmer_connected/account.py
@@ -86,6 +86,11 @@ 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"]}
diff --git a/bimmer_connected/cli.py b/bimmer_connected/cli.py
index 9ad7a03..f4bda1f 100644
--- a/bimmer_connected/cli.py
+++ b/bimmer_connected/cli.py
@@ -138,7 +138,7 @@ async def get_status(args) -> None:
 
     if args.json:
         if args.vin:
-            print(json.dumps(account.get_vehicle(args.vin), cls=MyBMWJSONEncoder))
+            print(json.dumps(get_vehicle_or_return(account, args.vin), cls=MyBMWJSONEncoder))
         else:
             print(json.dumps(account.vehicles, cls=MyBMWJSONEncoder))
     else:
@@ -338,7 +338,11 @@ def main():
         logging.getLogger("asyncio").setLevel(logging.WARNING)
 
     loop = asyncio.get_event_loop()
-    loop.run_until_complete(args.func(args))
+    try:
+        loop.run_until_complete(args.func(args))
+    except Exception as ex:  # pylint: disable=broad-except
+        sys.stderr.write(f"{type(ex).__name__}: {ex}\n")
+        sys.exit(1)
 
 
 if __name__ == "__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):
diff --git a/docs/source/development/fingerprints_in_homeassistant.rst b/docs/source/development/fingerprints_in_homeassistant.rst
index bca9c89..b573396 100644
--- a/docs/source/development/fingerprints_in_homeassistant.rst
+++ b/docs/source/development/fingerprints_in_homeassistant.rst
@@ -20,8 +20,8 @@ Edit ``homeassistant/components/bmw_connected_drive/coordinator.py`` and locate
 
   ...
           try:
-              from bimmer_connected.tests.conftest import MyBMWMockRouter, ALL_STATES, ALL_CHARGING_SETTINGS
-              with MyBMWMockRouter(["WBY00000000REXI01"], ALL_STATES, ALL_CHARGING_SETTINGS):
+              from bimmer_connected.tests.conftest import MyBMWMockRouter, ALL_STATES, ALL_CHARGING_SETTINGS, ALL_PROFILES
+              with MyBMWMockRouter(["WBA00000000DEMO02","WBA00000000DEMO03"], ALL_PROFILES, ALL_STATES, ALL_CHARGING_SETTINGS):
                   await self.account.get_vehicles()
           except:
   ...
diff --git a/docs/source/development/reverse_engineering_mybmw.rst b/docs/source/development/reverse_engineering_mybmw.rst
deleted file mode 100644
index b9980d8..0000000
--- a/docs/source/development/reverse_engineering_mybmw.rst
+++ /dev/null
@@ -1,162 +0,0 @@
-Reverse engineering the MyBMW API
-=================================
-
-This document should be seen as a help in setting up a working environment to intercept traffic of the MyBMW app.
-Not every step will be described fully, this guide is rather a summary and list for further reading.
-It will most likely need adjustments to your specific setup.
-
-The MyBMW app is built with the Flutter framework and needs some additional persuasion to reveal the traffic.
-
-Disclaimer
-----------
-
-Note that we are actively disabling important security measures such as SSL/TLS encryption to understand which commands and messages are shared between the MyBMW app and the MyBMW servers.
-
-Also note that there could always be changes to the API or the app itself made by BMW to stop us from understanding what is going on.
-
-Acknowledgement
----------------
-Most of this document would not exist without the amazing work of Jeroen Becker:
-
-* `Intercepting traffic from Android Flutter applications (ARMv7) <https://blog.nviso.eu/2019/08/13/intercepting-traffic-from-android-flutter-applications/>`_
-* `Intercepting Flutter traffic on Android (ARMv8) <https://blog.nviso.eu/2020/05/20/intercepting-flutter-traffic-on-android-x64/>`_
-* `Intercepting Flutter traffic on iOS <https://blog.nviso.eu/2020/06/12/intercepting-flutter-traffic-on-ios/>`_
-
-Software & hardware requirements
---------------------------------
-
-.. note::
-   This document is based on the MyBMW **Android** app. It should work similarly using **iPhones**. If possible, please create a PR with more details.
-
-You will need:
-
-* A proxy with MITM capabilities such as `mitmproxy <https://mitmproxy.org/>`_
-* A **rooted** android phone with a version supported by MyBMW (currently Android 6.0 Marshmallow).
-  It could also work using an `Android emulator <https://developer.android.com/studio/run/emulator>`_.
-* Access to your phone using ADB (via USB)
-* `ProxyDroid <https://play.google.com/store/apps/details?id=org.proxydroid>`_ to forward all traffic to your proxy
-* `Ghidra <https://ghidra-sre.org/>`_ to find the location to patch out SSL verification
-* A python environment with `frida <https://frida.re/>`_
-* `frida-android-helper <https://github.com/Hamz-a/frida-android-helper>`_ to help installing ``frida`` on your phone
-
-Finding the location of SSL verification
-----------------------------------------
-
-The following steps are required if the location of the SSL verification function is not known.
-If it is, please continue with the `next section <#preparations-on-phone>`_.
-For more details, please refer to `Jeroen Becker's work <#acknowledgement>`_.
-
-Get an APK/XAPK of the MyBMW app (from your phone or one of the many download sites). APK names include:
-
-* ``de.bmw.connected.mobile20.cn`` (china)
-* ``de.bmw.connected.mobile20.na`` (north america)
-* ``de.bmw.connected.mobile20.row`` (rest of world)
-
-Now extract ``config.arm64-v8a.apk`` or ``config.armeabi-v7a.apk`` from the APK package (depending of your phone's target architecture).
-
-In Ghidra, load and analyze ``lib/ARCH/libflutter.so``.
-
-After analyze has finished, go to ``Search`` > ``For Scalar`` and search for value ``390``. Find ``mov r3, #0x186`` and jump to it.
-
-Double click on function name on right side to get the hex address and first bytes of the function
-
-.. code::
-
-  Example: 2d e9 f0 4f a3 b0 81 46 50 20 10 70
-
-Preparations on phone
----------------------
-
-On your phone, add your custom CA certificates to the system store (`instructions for emulator <https://docs.mitmproxy.org/stable/howto-install-system-trusted-ca-android/>`_,
-but works on **rooted** devies in similar fashion). This is required as the login screen is using the default Android WebView component,
-which again behaves differently from Flutter (or rather, behaves like expected).
-
-Add your local proxy server to your Android system using ProxyDroid.
-
-
-Disabling SSL verification with frida
--------------------------------------
-Install & upgrade ``frida-tools`` & ``frida-android-helper`` (see `requirements <#software-hardware-requirements>`_).
-Make sure that both are on the latest version.
-
-Create a frida hook named ``hook_flutter_disable_ssl.js`` with the following content. 
-If needed, **replace the search pattern** and **disable adding** ``0x01`` **on ARMv8**.
-
-.. code:: javascript
-
-  function hook_ssl_verify_result(address)
-  {
-    Interceptor.attach(address, {
-      onEnter: function(args) {
-        console.log("Disabling SSL validation")
-      },
-      onLeave: function(retval)
-      {
-        console.log("Retval: " + retval)
-        retval.replace(0x1);
-  
-      }
-    });
-  }
-  function disablePinning()
-  {
-  var m = Process.findModuleByName("libflutter.so"); 
-  var pattern = "2d e9 f0 4f a3 b0 81 46 50 20 10 70" // MyBMW 1.5.1 to 1.7.0 (all regions)
-  
-
-  var res = Memory.scan(m.base, m.size, pattern, {
-    onMatch: function(address, size){
-        console.log('[+] ssl_verify_result found at: ' + address.toString());
-  
-        // Add 0x01 because it's a THUMB function 
-        // Otherwise, we would get 'Error: unable to intercept function at 0x9906f8ac; please file a bug'
-        // REQUIRED ON ARMv7 ONLY!!
-        hook_ssl_verify_result(address.add(0x01));
-        
-      }, 
-    onError: function(reason){
-        console.log('[!] There was an error scanning memory');
-      },
-      onComplete: function()
-      {
-        console.log("All done")
-      }
-    });
-  }
-  setTimeout(disablePinning, 1000)
-
-Connect to your phone via ADB with root permissions.
-
-.. code:: bash
-
-  adb root && adb remount
-
-Update & start frida server on the phone with ``frida-android-helper``.
-
-.. code :: bash
-
-  fah server update && fah server start
-
-Start the MyBMW app from your computer via ``frida`` (adjust app identifier if needed).
-
-.. code:: bash
-
-  frida -Uf de.bmw.connected.mobile20.row -l .\hook_flutter_disable_ssl.js --no-pause
-
-Now you should be able to capture all traffic between your phone and the MyBMW API.
-
-Using the information in bimmer_connected
------------------------------------------
-
-If you learn anything by capturing the traffic, please create `Issues/Feature Requests <https://github.com/bimmerconnected/bimmer_connected/issues/new/choose>`_
-or `Pull Requests <https://github.com/bimmerconnected/bimmer_connected/pulls>`_ to our repository. Information that should be included contains:
-
-* The URL of the endpoint
-* HTTP headers of your request (**DO NOT** include **Cookie** or **Authentication** headers)
-* The request payload (if available)
-* The request response (if available)
-
-If the data contains personal information, please do not delete it but replace it with random data.
-
-.. warning::
-  Double check if all information is **sanitized** and no personal information or authentication data is included.


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/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..a040b97 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 (
@@ -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
new file mode 100644
index 0000000..6f5eec8
--- /dev/null
+++ b/bimmer_connected/tests/test_cli.py
@@ -0,0 +1,138 @@
+import json
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+import pytest
+
+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."
+
+
+def test_run_entrypoint():
+    """Test if the entrypoint is installed correctly."""
+    result = subprocess.run(["bimmerconnected", "--help"], capture_output=True, text=True)
+
+    assert FIXTURE_CLI_HELP in result.stdout
+    assert result.returncode == 0
+
+
+def test_run_module():
+    """Test if the module can be run as a python module."""
+    result = subprocess.run(["python", "-m", "bimmer_connected.cli", "--help"], capture_output=True, text=True)
+
+    assert FIXTURE_CLI_HELP in result.stdout
+    assert result.returncode == 0
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.parametrize(
+    ("vin", "expected_count"),
+    [
+        ("WBA00000000000F31", 1),
+        ("WBA00000000000F31,WBA00000000DEMO03", 0),
+        ("WBA00000000000Z99", 0),
+    ],
+)
+def test_status_json_filtered(capsys: pytest.CaptureFixture, vin, expected_count):
+    """Test the status command JSON output filtered by VIN."""
+
+    sys.argv = ["bimmerconnected", "status", "-j", "-v", vin, *ARGS_USER_PW_REGION]
+    try:
+        bimmer_connected.cli.main()
+    except SystemExit:
+        pass
+    result = capsys.readouterr()
+
+    if expected_count == 1:
+        result_json = json.loads(result.out)
+        assert isinstance(result_json, dict)
+        assert result_json["vin"] == vin
+    else:
+        assert "Error: Could not find vehicle" in result.err
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+def test_status_json_unfiltered(capsys: pytest.CaptureFixture):
+    """Test the status command JSON output filtered by VIN."""
+
+    sys.argv = ["bimmerconnected", "status", "-j", *ARGS_USER_PW_REGION]
+    bimmer_connected.cli.main()
+    result = capsys.readouterr()
+
+    result_json = json.loads(result.out)
+    assert isinstance(result_json, list)
+    assert len(result_json) == get_fingerprint_count("states")
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.parametrize(
+    ("vin", "expected_count"),
+    [
+        ("WBA00000000000F31", 1),
+        ("WBA00000000000F31,WBA00000000DEMO03", 0),
+        ("WBA00000000000Z99", 0),
+    ],
+)
+def test_status_filtered(capsys: pytest.CaptureFixture, vin, expected_count):
+    """Test the status command text output filtered by VIN."""
+
+    sys.argv = ["bimmerconnected", "status", "-v", vin, *ARGS_USER_PW_REGION]
+    try:
+        bimmer_connected.cli.main()
+    except SystemExit:
+        pass
+    result = capsys.readouterr()
+
+    assert f"Found {get_fingerprint_count('states')} vehicles" in result.out
+
+    if expected_count == 1:
+        assert f"VIN: {vin}" in result.out
+        assert result.out.count("VIN: ") == expected_count
+    else:
+        assert result.out.count("VIN: ") == expected_count
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+def test_status_unfiltered(capsys: pytest.CaptureFixture):
+    """Test the status command text output filtered by VIN."""
+
+    sys.argv = ["bimmerconnected", "status", *ARGS_USER_PW_REGION]
+    bimmer_connected.cli.main()
+    result = capsys.readouterr()
+
+    assert f"Found {get_fingerprint_count('states')} vehicles" in result.out
+    assert result.out.count("VIN: ") == get_fingerprint_count("states")
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.usefixtures("bmw_log_all_responses")
+def test_fingerprint(capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch):
+    """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(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 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
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)