Repository: bimmerconnected/bimmer_connected
Pull request #625 (2024-07-01 17:31:27)

Update docs
## 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.
-->

## Type of change
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] 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.
-->

- [ ] The code change is tested and works locally.
- [ ] Tests have been added to verify that the new code works.



Patch:
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/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/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